Dec 21

JSI的桥注入方案(JavaScriptCore C API)

Lrdcq , 2021/12/21 21:18 , 程序 , 閱讀(2913) , Via 本站原創
在讨论js容器桥注入方案时,不可避免的会聊到react-native所搭建的JavascriptInterface(这是RN自己造的词,不过本文用JSI指代相同类型的方案)。JSI类方案一般确实不会有人提,核心原因是——除了RN,几乎没有什么方案是直接使用JSCore进行js执行的,而是通过Webview进行的h5页面执行,而对于webview来说,是无法直接拿到jscore的(严格来说只有iOS的UIWebview能拿到jscore,但是已经被淘汰了),

之所以native开发者无法直接拿到JSCore对象,很大一部分原因也是现在移动端系统中的内嵌浏览器控件,基本上都从程序进程搬到独立进程中执行了(主要是chrome模式的成功带来起的风潮)。既然这么麻烦,我们需要确定的是,JSI这种方案真的快么?因此本文讨论的是JSI方案最低限度的桥注入方案(注入方法,注入对象)与在还可用的UIWebview中的实际调用性能,和传统方案比到底有多大的性能提升,还是说方便了什么。本文用苹果的JavaScriptCore作为测试JSCore,安卓上J2V8操作方式类似因此就不展示了。

JSI的实现

JSI的实现本质是调用JSCore的JSI的C函数,RN之所以封装为c++的API主要还是考虑到js对象还是主要是面向对象的,并且也需要进行内存管理,同时异常处理也更方便一些。JSC在oc上完成了oc对象通过反射(runtime)直接注入到JSCore中,而JSI方案则是仿照这个过程,通过函数与注册信息,完成方法注入,并且同时附加bridge本身的管理过程。通过JSI注册的所有函数理所当然都是同步函数,数据到底是同步return回去还是通过callback传输回去完全由实现控制,当然也可以同时拥有,比如建设request桥,同步return实际的requestobject用于cancel,而异步callback回response。一个简单的同步桥看起来是这样的:
JSValueRef syncBridgeImp(JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
    if (argumentCount >= 3) {
        //读取参数1,字符串
        JSStringRef moduleName = JSValueToStringCopy(ctx, arguments[0], nullptr);
        NSLogJSStringRef(@"[syncBridge] moduleName = %@", moduleName);
        
        //读取参数2,字符串
        JSStringRef method = JSValueToStringCopy(ctx, arguments[1], nullptr);
        NSLogJSStringRef(@"[syncBridge] method = %@", method);
        
        //读取参数3,数组,遍历其内容
        JSObjectRef array = JSValueToObject(ctx, arguments[2], nullptr);
        NSUInteger length = JSValueToNumber(ctx, JSObjectGetProperty(ctx, array, JSStringCreateWithUTF8CString("length"), nullptr), nullptr);
        
        for (NSUInteger i = 0; i < length; i++) {
            NSLogJSStringRef([NSString stringWithFormat:@"[syncBridge] array[%ld] %%@", i], JSValueToStringCopy(ctx, JSObjectGetPropertyAtIndex(ctx, array, (unsigned int)i, nullptr), nullptr));
        }
        
        //构成返回对象
        /*
         {
            "moduleName":moduleName,
            "method":method,
            "data":
         }
         */
        JSObjectRef result = JSObjectMake(ctx, nullptr, nullptr);
        JSObjectSetProperty(ctx, result, JSStringCreateWithUTF8CString("moduleName"), JSValueMakeString(ctx, moduleName),kJSPropertyAttributeNone, nullptr);
        JSObjectSetProperty(ctx, result, JSStringCreateWithUTF8CString("method"), JSValueMakeString(ctx, method), kJSPropertyAttributeNone, nullptr);
        JSObjectRef data = JSObjectMake(ctx, nullptr, nullptr);
        for (NSUInteger i = 0; i < length; i++) {
            JSValueRef oldValue = JSObjectGetPropertyAtIndex(ctx, array, (unsigned int)i, nullptr);
            JSObjectSetPropertyForKey(ctx, data, oldValue, oldValue, kJSPropertyAttributeNone, nullptr);
        }
        JSObjectSetProperty(ctx, result, JSStringCreateWithUTF8CString("data"), data, kJSPropertyAttributeNone, nullptr);

        return result;
    }
    return JSValueMakeUndefined(ctx);
}

    //注入同步桥
    {
        //创建了一个名字叫syncBridge的函数
        JSStringRef fooName = JSStringCreateWithUTF8CString("syncBridge");//函数名
        JSObjectRef function = JSObjectMakeFunctionWithCallback(jscoreContext, fooName, &syncBridgeImp);//函数指针指向的函数的实现
        JSObjectSetProperty(jscoreContext, globalObject, fooName, function, kJSPropertyAttributeNone, nullptr);
        JSStringRelease(fooName);
    }

其中的核心是实现函数JSObjectCallAsFunctionCallback,它的定义是JSValueRef (JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception),实现函数从js给到的参数除了js上下文context,主要是functionobject即函数本身,thisobject即caller,参数长度和参数数组,最后是异常指针用于抛出js异常;同时函数需要同步return一个jsvalue即函数的返回值,这里直接构建了一个相当复杂的对象。同时还有一些注意点包括:

a. 部分js对象需要自己手动release比如出现了多次的JSStringRef(上文中也有遗漏,注意到了么)。JSString是一个用于jscore内部的对象而不是js引擎中的等效对象,因此不会被js垃圾回收直接管理。而创建出的js对象jsvalue与jsobject,其实会被js垃圾回收控制,因此不能在native去release。

b. 一般来说JSValue值得js中的所有value包括number,string和object,而JSObject就是一个object(其他function,array和其他native对象),在jscore上所有JSObject都可以直接视为JSValue。

c. 异常指针指向的一个JSValue也就是我们所说的js中可以throw任意元素,但是按标准做法如果真要抛出内建error对象,需要通过JSObjectMakeError构建一个JSObject传出去。

d. 所有的js操作都依赖JSContext,JSContext即一次单次js执行的执行上下文。它的来源包括
JSGlobalContextRef jscoreContext = JSGlobalContextCreateInGroup(contextGroup, nullptr);
//与
JSContext *jscoreContext = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

其中通过contextGroup即通过JSVirtualMachine创建出的全局上下文是一次性的,即当同步代码运行完成后,该上下文就会被销毁。而从webview中获取到的全局上下文是和当前document的生命周期绑定的。这个区别意味着通过JSVirtualMachine使用JSContext时,每一次都需要通过JSGlobalContextCreateInGroup重新生成上下文;而操作webview的上下文时,只要当前文档还在,就可以使用(当然也可以反向获取到webview的JSVirtualMachine并重新获取)。JSContext是否失效目前并没有有效的判断方法,一旦失效就会变成野指针直接爆炸,因此需要注意手动和实际生命周期配对。

e. 一定程度和c++混写确实会更方便。

说了同步桥,异步桥其实就是需要针对性的对callback进行处理,diff的代码看起来如下:
JSValueRef asyncBridgeImp(JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
    if (argumentCount >= 4) {
        //...
        //读取参数4,回调
        JSObjectRef callback = JSValueToObject(ctx, arguments[3], nullptr);
        if (!JSObjectIsFunction(ctx, callback)) {
            return JSValueMakeUndefined(ctx);
        }
        
        //构成返回对象
        JSObjectRef result = JSObjectMake(ctx, nullptr, nullptr);
        //...
        
        JSValueProtect(ctx, callback);
        JSValueProtect(ctx, result);
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.0 * NSEC_PER_SEC)), ..., ^{
            //调用callback,如果是自己创建的contextGroup,需要重新获取ctx
            //JSGlobalContextRef ctx = JSGlobalContextCreateInGroup(contextGroup, nullptr);
            JSValueRef args[1];
            args[0] = result;
            JSObjectCallAsFunction(ctx, callback, nullptr, 1, args, nullptr);
            
            JSValueUnprotect(ctx, callback);
            JSValueUnprotect(ctx, result);
        });
    }
    return JSValueMakeUndefined(ctx);
}

相对于同步桥,需要注意的几点刚才提到过,需要补充的包括:

a. 因为异步,js对象需要在native主动持有,因此对callback和需要使用的其他对象使用JSValueProtect做native强应用,callback调用完成后在JSValueUnprotect。JSValueProtect是基于引用计数的因此需要配对的调用否则内存泄漏。

b. JSObjectCallAsFunction即等效于js中的apply或者call,需要手动指定this,如果为空就是全局obj。

c. 如果函数没有什么特别的行为,同步返回JSValueMakeUndefined一个undefined即可。

另外,我们说JSI方案另一个优点是不用序列化和反序列化,因此处理js对象也是关键。理论上native可以直接处理的js对象包括一类对象中除了kJSTypeSymbol以外的其他对象,kJSTypeObject中的二类对象的话,object和array肯定是能处理的,同时function也可以视为block/runable,constructor同理,date可以视情况转换为native的date对象,error可以转换为native的error对象或者array(error本质确实是数组),RegExp也许可以转换,Promise如果能自定义一个native承接对象或者rx也行,当然TypedArray如果要处理也可以处理为二进制字符串即databuffer。列举起来如下,并且进行了基础代码的实现:
點擊在新視窗中瀏覽此圖片
id JSValue2OCObject(JSContextRef ctx, JSValueRef value) {
    JSType type = JSValueGetType(ctx, value);
    switch (type) {
        case kJSTypeNumber:
            return @(JSValueToNumber(ctx, value, nullptr));
        case kJSTypeBoolean:
            return @(JSValueToBoolean(ctx, value));
        case kJSTypeString: {
            JSStringRef jsString = JSValueToStringCopy(ctx, value, nullptr);
            size_t maxBufferSize = JSStringGetMaximumUTF8CStringSize(jsString);
            char *utf8Buffer = new char[maxBufferSize];
            JSStringGetUTF8CString(jsString, utf8Buffer, maxBufferSize);
            NSString *str = [NSString stringWithUTF8String:utf8Buffer];
            delete [] utf8Buffer;
            JSStringRelease(jsString);
            return str;
        }
        case kJSTypeObject: {
            JSObjectRef obj = JSValueToObject(ctx, value, nullptr);
            if (JSValueIsArray(ctx, value)) {
                //array
                NSUInteger length = JSValueToNumber(ctx, JSObjectGetProperty(ctx, obj, JSStringCreateWithUTF8CString("length"), nullptr), nullptr);
                NSMutableArray *mArr = [NSMutableArray array];
                for (NSUInteger i = 0; i < length; i++) {
                    JSValueRef anyValue = JSObjectGetPropertyAtIndex(ctx, obj, (unsigned int)i, nullptr);
                    [mArr addObject:JSValue2OCObject(ctx, anyValue)];
                }
                return [mArr copy];
            } else if(JSValueIsObject(ctx, value)) {
                //object
                JSPropertyNameArrayRef keys = JSObjectCopyPropertyNames(ctx, obj);
                size_t count = JSPropertyNameArrayGetCount(keys);
                NSMutableDictionary *mDic = [NSMutableDictionary dictionary];
                for (size_t i = 0; i < count; i++) {
                    JSStringRef key = JSPropertyNameArrayGetNameAtIndex(keys, i);
                    JSValueRef anyValue = JSObjectGetProperty(ctx, obj, key, nullptr);
                    size_t maxBufferSize = JSStringGetMaximumUTF8CStringSize(key);
                    char *utf8Buffer = new char[maxBufferSize];
                    JSStringGetUTF8CString(key, utf8Buffer, maxBufferSize);
                    NSString *keyStr = [NSString stringWithUTF8String:utf8Buffer];
                    delete [] utf8Buffer;
                    [mDic setValue:JSValue2OCObject(ctx, anyValue) forKey:keyStr];
                }
                JSPropertyNameArrayRelease(keys);
                return [mDic copy];
            }
            //其他不能处理
            return [NSNull null];
        }
        case kJSTypeUndefined:
        case kJSTypeNull:
        case kJSTypeSymbol:
        default:
            return [NSNull null];
    }
}

JSValueRef data2nativeImp(JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
    if (argumentCount >= 1) {
        JSValueRef value = arguments[0];
        id obj = JSValue2OCObject(ctx, value);
        NSLog(@"[data2native] %@", obj);
    }
    return JSValueMakeUndefined(ctx);
}

目前显然未实现的主要是function,麻烦点在于入参返参数的抽象和反射。将data2native方法暴露到js中即可输入js方法进行测试。

方案对比

将如上方案落地到UIWebview中执行,即可对桥与速度解析性能进行测试,需要判断是以下两点:1. JSI的桥调用同步性能优于传统的拦截方案。2. JSI桥用对象解析而不是序列化反序列化性能更优秀。这样在无论webview场景还是直接使用jscore的场景均能面面俱到了。

1. 桥调用性能对比

这里是对比的方案是如上UIWebview中同步桥调用与通过同步xhr+序列化构成的同步桥进行对比,分别进行1000次调用计算耗时ms,其中同步XMLHttpRequest硬编码上传输json并且native侧不识别参数以避免json序列化反序列化导致的性能影响,response部分也是直接返回硬编码的数据。具体来说,同步xhr会发起一个形如/mybridgehostkey/syncBridge?moduleName=moduleName&method=method&data=%5B1%2C2%2C%7B%7D%5D的请求,moduleName和method和实际传输数据json序列化后base64编码为data直接传输。native部分通过NSURLProtocol并且识别path部分的/mybridgehostkey进行拦截,并且直接返回结果,代码如下:
- (void)startLoading {
    NSDictionary * const responseHeaderFields = @{
                                                  @"Content-Type": @"text/plain"
                                                  };
    NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:self.request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:responseHeaderFields];
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
    [self.client URLProtocol:self didLoadData:[@"[1,2,3]" dataUsingEncoding:NSUTF8StringEncoding]];//直接mock结果
    [self.client URLProtocolDidFinishLoading:self];
}

结果得到的1000次执行耗时(ms)数据如下:

- JSI同步桥:Min: 271ms / Max: 332ms / Avg: 305.8ms
- XHR同步桥:Min: 894ms / Max: 1068ms / Avg: 981.4ms

结论:显然JSI比传统拦截通信方案性能高60%以上。分析具体原因的话,很大一方面是传统拦截通信方式都是基于特殊domAPI暴露的接口完成中,其中内部逻辑的消耗不容小觑。同时网络请求相关的流程会有跨线程行为,而JSI是直接在JS线程中调用到native,如果保持同步桥执行的话可以继续在该线程中完成后续工作。同时JSI传输到native的数据也直接是JS对象的引用(而不存在拷贝行为)。因此JSI的桥接部分性能高是理所当然的。

2. 序列化与JS对象解析性能对比

对比的是如上data2native的JSI函数与另外单独实现的json2native函数,json2native的实现是js侧通过JSON.stringify序列化,而native侧则转换为OC字符串后通过NSJSONSerialization反序列化数据,看起来如下:
JSValueRef json2nativeImp(JSContextRef ctx, JSObjectRef function, JSObjectRef thisObject, size_t argumentCount, const JSValueRef arguments[], JSValueRef* exception) {
    if (argumentCount >= 1) {
        NSString *string = JSStringRef2NSString(JSValueToStringCopy(ctx, arguments[0], nullptr));
        id obj = [NSJSONSerialization JSONObjectWithData:[string dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
        NSLog(@"[json2native] %@", obj);
    }
    return JSValueMakeUndefined(ctx);
}

结果得到的1000次执行耗时(ms)数据如下:

- JSI传输:Min: 100ms / Max: 136ms / Avg: 122.2ms
- Json传输:Min: 72ms / Max: 123ms / Avg: 94.6ms

结论:通过JSI传输大json相较于序列化方案还有一定劣势。同时考虑到测试时采用了一个中等大小的json(5kb左右),同时如果传输数据为空,结论应该会趋近于传输性能对比即上一个测试的结果,因此可以推测,JSI通信复杂对象效率随着数据复杂度增加相对于json传输劣势更明显,3KB左右的数据量应该是平衡点。出现这个现象的可能的原因是:在测试中转为OC对象,本质上涉及到从js中拷贝为c字符串再拷贝到OC中的双重转换,如果不是转换到oc而是c/c++的map对象性能应该会高一截。同时也很清楚的是各门语言的序列化反序列化逻辑已经极限优化过了,在大string的情况下完全不会有性能恶劣影响,然而普通的递归的对jsvalue的遍历就不好说了,相关逻辑如果对字符串/内存空间等项目进行更多的优化才可能获得更好的性能。

————————

总的来说,目前如果不是亲自使用webkit或者v5这样的浏览器引擎,或者直接使用jscore/v8这样的场景,以上优化优化方向确实作用不大/并不能影响大局。但是当深入到react-native或者自行通过jscore开发独立runtime方案时,根据以上理论实践与rn这样的前人的步伐,确实才是最优之选。

本文涉及的代码较多,因此把核心代码也一起打包上传在了下方附件处,仅供参考(实际业务使用还需要继续优化与边界处理)。
关键词:jsi , javascriptcore , c , api , jscore , ios , react-native
logo