Hummer 的通信机制

—— 一个跨端框架应该要怎么通信?

世界上有很多跨端框架,有自己把底层渲染的工作都包了的,那个叫 Flutter,有那种把小程序装进 App,用 Webview 搞定页面的,用小程序框架来实现跨端的,这个滴滴内部自研有一个,叫星河

还有一种更为常见,就是在 Android 和 iOS 都实现一套 UI和基础类,然后开发者只需要编写一套业务代码就能给两端用,这种有ReactNative,Weex,还有今天我要说的 Hummer。


在开发需求的过程中,有一些问题常常萦绕在我的头脑中:

在 Native 侧需要做什么工作,才能把TypeScript 代码运行起来?

或者问得更具体点:

一个 Hello world 的文字怎么才能通过几行 TypeScript 代码,就能显示到手机屏幕上?


初始化Hummer:安装引擎

像汽车一样,想让Hummer运行,车子先要有引擎

[Hummer startEngine:nil];

这行代码做了什么?

  1. 加载所有已经导出的类

    什么叫「导出的类 Exported Class」?指的是那些在 Native 侧已经写好了,TypeScript 代码可以使用的类。导出的方法,导出的属性同理。

    那什么叫加载呢?就是把这些Native 类起一个objc 的类名,再起一个 js 类名,然后放入一个 dictionary 中,这样既可以通过 objc 类名获取到这个类,也能通过 js 类名获取到这个类。一类,两名。就像给我家的仆人起一个中文名玛丽,起一个英文名 Mary,那既可以通过「玛丽」呼唤她,也可以通过「Mary」呼唤她。

  2. 加载所有导出的方法和属性

  3. 确定导出类的父类关系

    比如我们已经加载了 ABCD 四个导出类,那其中的 A 类可能是继承B 类的,那么就要把这个父类关系给记录下来,记录在 A 类的superClassReference属性中即可(注意这里确定的 是js组件的继承关系,比如 Text 继承自 View)

点火的结果是我们得到了一个 HMExportManager ,它里边有许许多多加载好的类、方法和属性。这时Hummer 已经有了动力来源了。

好像忘了点东西?

在开始的开始,导出类是哪里来的? 我没有写导出的代码,那一定是Hummer 自己写的,神奇的事情发生在这里(我们随便找个类)

点进去看实现:

用到了__attribute__((section(“name”))) ,这个编译属性,改变了数据的存储特性。也就是说通过魔法一般的代码,我们得以在编译链接的时候就把对应的结构体(也就是 HMExportStruct)写到可执行文件 Mach-O 中,在后续的初始化代码中能读出来。

ARM 文档在这里,有兴趣就点去看看吧。
used 参数是告诉编译器:不管我用不用都不需要优化掉这个函数。这里有一篇文章讲了怎么使用这个编译属性

于是我们的疑惑得以解开了:

举个栗子🌰给观众朋友们整明白点:
我们在 .m 文件中写下 HM_EXPORT_CLASS(Loading, HMActivityIndicatorView),
就足以让编译器把对应的 __hm_export_class_Loding__ 结构体写到 Mach-O 文件中,最后在startEngine 的时候就能获取到对应的导出类。

准备控制器:执行 JS 代码的必要条件

通过 startEngine,我们的 Hummer 项目在客观上有了上路奔驰的能力,但是,还没有一个操作系统,没有油门、方向盘、换挡杆等一系列跟车子交流的能力,我们无法(从 JS 侧)控制Hummer框架。

所以这时我们还需要初始化这个「中间者」,它就是:

HMJSContext跨端层,负责 Native 和 JS 之间的通信。

// HMViewController.m
- (void)renderWithScript:(NSString *)script {
   ...
    //渲染脚本之前 需要注册bridge
    HMJSContext *context = [HMJSContext contextInRootView:self.hmRootView];
    self.context = context;
   ...
    
    //执行脚本
    [context evaluateScript:script fileName:self.URL];
   ...
}

初始化 HMJSContext ,有五件事情要做

  1. 读取 Hummer bundle 文件中的 builtin.js 文件

  2. 初始化 HMJSCExecutor(这个是 context 的核心,作用后面讲)

  3. 执行 builtin.js

  4. 把 Native 端的类信息(startEngine 的时候生成的),包装成 JS 兼容的格式

  5. 把上面包装的信息注册到 JS 侧

 先做个结论:HMJSContext 初始化之后,车子就有了方向盘,就有了点火器,就有了油门,有了一切该有的,只要来个人就能把这辆车开起来了。


然后我们再细看这五件事情。虽然有五件,但是只有两件事需要说

  • 初始化 HMJSCExecutor

  • 执行 builtin.js

可以把HMJSCExecutor 看做是HMJSContext的打手,嫡系,核心下属,因为Executor,听名字就知道,就是负责 execute的,「执行 JS 代码,以及监听 JS 调用 Native 的回调。」

来看看它的初始化函数

- (instancetype)init {
...
    // 共用一个 JS 环境
    if (!virtualMachineRef) {
        virtualMachineRef = JSContextGroupCreate();
    }
...
    [HMExecutorMap setObject:self forKey:[NSValue valueWithPointer:_contextRef]];

    // 注入对象
    
    // 生成 JS 字符串
    JSStringRef hummerCallString = JSStringCreateWithUTF8CString("hummerCall");
    JSStringRef hummerCreateString = JSStringCreateWithUTF8CString("hummerCreate");
    ...
    JSObjectRef globalThis = JSContextGetGlobalObject(_contextRef);

    // 创建 C 函数的 bridge
    JSObjectRef nativeLoggingHookFunction = JSObjectMakeFunctionWithCallback(_contextRef, NULL, &nativeLoggingHook);
    JSObjectRef inlineHummerCallFunction = JSObjectMakeFunctionWithCallback(_contextRef, NULL, &hummerCall);
    ...
    JSValueRef exception = NULL;
    
    // 把 bridge 绑定到 JS 环境的 Global 对象上, 后面可以通过 globalThis.method() 来调用这里绑定好的函数
    JSObjectSetProperty(_contextRef, globalThis, nativeLoggingHookStringRef, nativeLoggingHookFunction, kJSPropertyAttributeReadOnly | kJSPropertyAttributeDontDelete, &exception);
    JSObjectSetProperty(_contextRef, globalThis, hummerCallString, inlineHummerCallFunction, kJSPropertyAttributeReadOnly | kJSPropertyAttributeDontDelete, &exception);
    ...
}

JSObjectMakeFunctionWithCallback:把 C 函数的 callback 注册到 JS 侧,比如JS 调用hummerCall时,就会直接调用 C 函数「hummerCall」

一句话概括就是:注册了一些 C 函数给 JS。


等等,写到这儿我已经有点迷糊了,简单概括一下

Hummer 有了引擎之后,我们需要有一个HMJSContext来控制它,于是就得去初始化这玩意儿:创建一个HMJSCExecutor,注册了一些 C 函数给 JS。

OK,我们继续。

那接下来就是说说注册的都是些什么函数?

  1. hummerCall:js 侧调用 Native方法(类方法和成员方法)

  2. hummerCreate:创建Native对象,并返回 js 指针来持有这个对象

  3. hummerGetProperty:(获取对象的属性值)

  4. hummerSetProperty:(设置对象的属性值)

  5. hummerCallFunction:(调用闭包函数 closure)

这五个函数,都会在执行 builtin.js 文件的时候使用到。所以注意看index.ts,HummerBase.ts,injectClassModel.ts这几个文件

不管是hummerCall 还是hummerGetProperty、hummerSetProperty,他们都调用了下面这个函数:

- (JSValueRef)hummerGetSetPropertyWithArgumentCount:(size_t)argumentCount 
                                          arguments:(const JSValueRef _Nonnull[])arguments 
                                           isSetter:(BOOL)isSetter {
                      
    1. 判断是类方法还是成员方法
    2. 根据 arguments 获取到 target, method 和 methodSignature
    3. 组装成 NSInvocation 并转发消息
    4. 返回结果              
  }

这就是 HMJSCExecutor 的初始化工作。


初始化 HMJSContext 的第二件值得一说的事

执行 builtin.js。

找一下,发现在这儿

但是这个打开一看,您猜怎么着?混淆过的,看不了。那怎么办呢?把 Hummer 工程一整个 clone 下来,整这个路径:Hummer/iOS/builtin/src/。

底下有若干个文件,据可靠消息,这几个文件就是混淆后的 builtin.js 文件

index.ts

这个文件是给 globalThis 注册很多方法,但是没有调用,比如hummerLoadClass (这个是个非常重要的方法,执行完了 builtin.js 后,Native 侧又特地调用了这个 hummerLoadClass,在HMJSContext#init )

hummerLoadClass 的作用是:将Native导出类的信息全部注册到 JS 侧。入参就是一个 JSON 字符串,在HMJSContext#init() 的倒数第二行可以看到具体的执行代码

HummerBase.ts

据说是所有注册到 js 侧的类的基类

injectClassModel.ts

上面的hummerLoadClass 注册信息时,循环调用injectClassModel 方法来完成注册的。调用一次,注册一个导出类的信息。

export default function injectClassModel(jsClassName: string, classModel: ClassModel, classModelMap: Record<string, ClassModel | undefined | null>): typeof HummerBase | undefined {

    let jsClass = HummerBase
    if (isNotEmptyString(classModel.superClassName) && classModelMap[classModel.superClassName]) {
        if (typeof globalThis[classModel.superClassName] !== 'function') {
            //  0. 有父类并且父类没有注入则先注入父类
            const innerLoaderClassModel = classModelMap[classModel.superClassName]
            if (innerLoaderClassModel) {
                const superClass = injectClassModel(classModel.superClassName, innerLoaderClassModel, classModelMap)
                if (superClass) {
                    jsClass = superClass
                }
            }
        } else {
            jsClass = globalThis[classModel.superClassName]
        }
    }
    jsClass = class extends jsClass { }
    classModel.methodPropertyList?.forEach(methodPropertyModel => {
        // 这里是重点,但是没看懂
        const prototypeOrClsss = methodPropertyModel.isClass ? jsClass : jsClass.prototype
        // 注入方法
        if (methodPropertyModel.isMethod) {
            prototypeOrClsss[methodPropertyModel.nameString] = function (...args: unknown[]) {
                if (isOfType<HummerBase>(this, '_private')) {
                    // 之前在 Native 给 globalThis 注册的 hummerCall 在这时候派上用场了
                    return globalThis.hummerCall(this._private, jsClassName, methodPropertyModel.nameString, ...args)
                } else {
                    // 类方法调用
                    return globalThis.hummerCall(jsClassName, methodPropertyModel.nameString, ...args)
                }
            }
        } else {
            Object.defineProperty(prototypeOrClsss, methodPropertyModel.nameString, {
                // getter setter 注册
                get: function (this: HummerBase | unknown) {
                    if (isOfType<HummerBase>(this, '_private')) {
                        return globalThis.hummerGetProperty(this._private, jsClassName, methodPropertyModel.nameString)
                    } else {
                        return globalThis.hummerGetProperty(jsClassName, methodPropertyModel.nameString)
                    }
                },
                set: function (this: HummerBase | unknown, newValue: unknown) {
                    if (isOfType<HummerBase>(this, '_private')) {
                        return globalThis.hummerSetProperty(this._private, jsClassName, methodPropertyModel.nameString, newValue)
                    } else {
                        return globalThis.hummerSetProperty(jsClassName, methodPropertyModel.nameString, newValue)
                    }
                }
            })
        }
    })
    // 给 jsClass 添加 name 属性,值是 jsClassName(比如 'Text')       
    Object.defineProperty(jsClass, 'name', { value: jsClassName })
    // 将 jsClass 挂载到 globalThis 上
    globalThis[jsClassName] = jsClass

    return jsClass // 不返也行,反正也没用
}

终于:执行 JS 代码

等执行了 builtin.js, 再执行 JS 函数 hummerLoadClass,Hummer 这车引擎有了,控制器方向盘油门什么的也齐了,可以开车上路,执行业务开发者写的 ts 代码了,比如:

import {Hummer,View, Text} from '@hummer/hummer-front'
class RootView extends View {
  constructor() {
    super();
    this.style = {
      width: '100%',
      height: '100%',
      alignItems: 'flex-start',
      justifyContent: 'center'
    }
    this.initElement()
  }

  initElement(){
    this.appendText('~ Hello Hummer ~')
  }

  appendText(message:string){
    var text = new Text()
    text.style = {
      fontSize: 20,
    }

    text.text = message
    this.appendChild(text);
  }
}

// 根页面渲染
Hummer.render(new RootView());

总结

我们收拾一下思路,问出一个可以概括本篇文章的问题:

如何实现一个跨端框架?

首先,你要在 Native 侧要实现很多很多个类,这样js 侧才有东西可以调用。不仅要实现,还得把这些类信息记录下来放到一个对象中,在运行的时候 js 侧通过这个对象能找到这些类。

其次,你要创建 js 侧调用 Native的口子,要把某些必不可少的 C 函数注册到 js 侧,执行 Hummer 内置的builtin.js ,把 js 环境倒腾好。js 侧要只能有哪些类,并且能调用这些类,创建 Native 对象,跟 Native 对象交互。

此时,跨端框架已经准备好运行业务代码了。


参考文档


Hummer 的通信机制
http://example.com/2022/07/25/Hummer的通信机制/
作者
发布于
2022年7月25日
许可协议