背景
在使用JavascriptCore或者V8引擎构建自定义渲染框架的时候,经常会遇到以下情况:
场景1
从中可以看到:基础库代码和启动代码在各个业务模块中都重复了一次,这会导致很多不必要的内存和加载时间的开销,有没有什么方式可以达成如下架构呢?
即多个业务模块共享相同的基础库代码,从而实现内存开销大幅减少,并加速加载的时间。
场景2
用js写的业务1想通过native的bridge发一大片数据给业务2(例如图片)
我们想避免这样一次copy的bridge操作,而直接把这份数据的“指针”发生给业务2
调研
查看Apple的文档
developer.apple.com/documentati…
从官方文档可以看到,Native的虚拟机确实提供了这样的能力,那么关键就是怎么使用呢?网上StackOverflow搜了半天也没找到答案,最后经过自己尝试,发现其实原理其实就是把JSValue直接设置给其它Context就行,代码片段如下:
JSContextGroupRef contextGroup = JSContextGroupCreate();
JSGlobalContextRef context1Ref = JSGlobalContextCreateInGroup(contextGroup, nil);
JSGlobalContextRef context2Ref = JSGlobalContextCreateInGroup(contextGroup, nil);
JSContext *context1 = [JSContext contextWithJSGlobalContextRef:context1Ref];
JSContext *context2 = [JSContext contextWithJSGlobalContextRef:context2Ref];
[context1 evaluateScript:@"this.sharedData = 1"];
context2[@"sharedData"] = context1[@"sharedData"];
{
JSValue *data1 = [context1 evaluateScript:@"sharedData"];
JSValue *data2 = [context2 evaluateScript:@"sharedData"];
NSLog(@"data1 = %@, data2 = %@", data1, data2);
}
可以看到,通过native的“搭桥”,context2里面也能访问到被共享的sharedData了。
看到这里,一般都会疑惑,这个context2里面的sharedData到底是js虚拟机帮我们创建了一份拷贝呢,还是真的实现了指针共享,即context2就是真的访问了context1里面的那份数据呢?
git:* af369b1 - (HEAD -> main) 共享数据 (4 seconds ago)
证明context2访问的是context1里面变量的指针,而非拷贝:
其实很好证明,直接上TypedArray和对象即可,代码片段如下:
JSContextGroupRef contextGroup = JSContextGroupCreate();
JSGlobalContextRef context1Ref = JSGlobalContextCreateInGroup(contextGroup, nil);
JSGlobalContextRef context2Ref = JSGlobalContextCreateInGroup(contextGroup, nil);
JSContext *context1 = [JSContext contextWithJSGlobalContextRef:context1Ref];
JSContext *context2 = [JSContext contextWithJSGlobalContextRef:context2Ref];
[context1 evaluateScript:@"this.sharedData = new Uint8Array(1024 * 1024 * 1024).fill(1);"];
context2[@"sharedData"] = context1[@"sharedData"];
{
JSValue *data1 = [context1 evaluateScript:@"sharedData"];
JSValue *data2 = [context2 evaluateScript:@"sharedData"];
NSLog(@"data1 = %@, data2 = %@", data1, data2);
}
调试结果如下:
我们创建一个大小为1G的TypeArray,可以看到内存精确的就是1G,而不是2G,再仔细观察两个JSValue的指针,发现内部的OpaqueJSValue的指针都是一模一样的值,由此我们可以知道,这种共享方式确实是有效的。
全局作用域绑定问题
到这里,基本的调研就是结束了,于是可以开始着手共享的事情了,数据的共享基本没有什么问题,按这个方案使用Native中转一次即可,效果也非常好。
但是代码共享的坑才刚刚开始,需要解决非常多的问题:
问题:
看下面的代码,你所认为的纯函数,在共享context的情况下,其实并非纯函数,来看一个共享之后访问全局数据的例子:
JSContextGroupRef contextGroup = JSContextGroupCreate();
JSGlobalContextRef context1Ref = JSGlobalContextCreateInGroup(contextGroup, nil);
JSGlobalContextRef context2Ref = JSGlobalContextCreateInGroup(contextGroup, nil);
JSContext *context1 = [JSContext contextWithJSGlobalContextRef:context1Ref];
JSContext *context2 = [JSContext contextWithJSGlobalContextRef:context2Ref];
[context1 evaluateScript:@"this.data = 1"];
[context2 evaluateScript:@"this.data = 2"];
[context1 evaluateScript:@"g = function() { return this.data;}"];
[context2 evaluateScript:@"g = function() { return this.data;}"];
[context1 evaluateScript:@"function f() { return g();}"];
context2[@"f"] = context1[@"f"];
{
JSValue *f1 = [context1 evaluateScript:@"f();"];
JSValue *f2 = [context2 evaluateScript:@"f();"];
NSLog(@"");
}
首先,我们期望的调用关系应该是这样的:
但是,实际的情况确实下面这样:
查看结果可以知道:
可以看到共享之后两个f调用出去之后全都访问了context1里面的这份data数据,这显然是有问题的,这样会使得两个context之间的全局数据相互干扰。
原因:
这个主要是js的标准所导致的,对js来说所有函数都会自动闭包全局作用域对象,就算从context1复制到context2中了 ,其scope依然不会变。
解决方案
此处的关键问题是在选定函数g的时候,如何确定其所处的global环境,方法其实有很多,例如,
- 直接改造js代码,传入所在的namespace是一种纯js的方案
- 这里介绍一种对js代码编写没有影响的方案,即Native直接找出调用者,并实现谁调用就访问谁的全局对象。
通过如上图所示的方案,其实就相当于解除了函数所绑定的那个全局作用域,因为我们始终坚持使用宏任务中调用方的作用域即可。
准备知识1:全局Callback对象
JSCore中,在创建GlobalContext的时候,我们其实可以指定一个Native的CallbackObject作为整个JS运行环境的根对象,之后对全局任何变量的引用,都会回调给Native来决定返回哪个对象。代码片段如下:
static JSValueRef getter(JSContextRef context, JSObjectRef object, JSStringRef propertyName, JSValueRef* exception)
{
NSString *name = (__bridge NSString *)JSStringCopyCFString(kCFAllocatorDefault, propertyName);
NSLog(@"name = %@",name);
return NULL;
/*
TODO:查找entry,然后return entry.propertyName
*/
}
JSContextGroupRef contextGroup = JSContextGroupCreate();
JSClassDefinition g = kJSClassDefinitionEmpty;
g.getProperty = getter;
JSClassRef jsClass = JSClassCreate(&g);
JSGlobalContextRef context1Ref = JSGlobalContextCreateInGroup(contextGroup, jsClass);
JSGlobalContextRef context2Ref = JSGlobalContextCreateInGroup(contextGroup, jsClass);
JSContext *context1 = [JSContext contextWithJSGlobalContextRef:context1Ref];
JSContext *context2 = [JSContext contextWithJSGlobalContextRef:context2Ref];
[context1 evaluateScript:@"this.data = 1"];
[context2 evaluateScript:@"this.data = 2"];
[context1 evaluateScript:@"g = function() { return this.data;}"];
[context2 evaluateScript:@"g = function() { return this.data;}"];
[context1 evaluateScript:@"function f() { return g();}"];
context2[@"f"] = context1[@"f"];
{
JSValue *f1 = [context1 evaluateScript:@"f();"];
JSValue *f2 = [context2 evaluateScript:@"f();"];
NSLog(@"");
}
可以看到,任何对g的访问都会经过我们的getter函数,当我们返回NULL的时候,系统就会回到js环境走默认的查找流程,即返回当前函数绑定的全局作用域里面的那个g
那么,这里其实我们有一个介入这个流程的机会:
准备知识2: WebKit源码定位功能
经过大量时间查找,发现这样一个属性:
查看其使用的地方,发现其在任何执行本地js脚本的时候都会创建一个
这个正是我们需要的东西。
可是问题来了,VM特么的是个什么鬼呢?我怎么拿到呢?
然后又经过大量时间研究,发现VM特么的就是这个鬼:
是的,就是我们之前看到的Apple文档里面所说的那个JSContextGroup
到这里,其实已经可以使用私有API拿到这个属性了,我们先用私有API验证一下这个结论是否正确,请注意⚠️,别在线上代码使用这个私有API。(该私有API在iOS13以上有,13以下叫另外一个名字),强转成接收一个VM对象的函数,然后传入group即可得到JSGlobalContextRef
dlsym(RTLD_DEFAULT, "_ZNK3JSC2VM29deprecatedVMEntryGlobalObjectEPNS_14JSGlobalObjectE")
该函数的返回值就是entry对象 。
又经过大量时间研究,发现需要特别⚠️一点,这个entry对象只在js代码执行的时候才能取到(即getter),执行完之后,其析构函数会把VM里面的entry设置为null
如何不使用私有API获取:
我们自然想到了遍历的方法,扫描一下JSContextGroup的内存,看看哪个地址偏移是VMEntryScope,然后读取出里面的global entry。
这里还需要考虑一点,就是内存扫描,绝对不能使用C语言里面的星号*,因为有些内存根本就不是可以读取的,这里必须使用系统调用VM_Read。
代码如下:
ptrdiff_t JSCopyOnWriteGlobal::entryOffset() {
static std::once_flag flag;
static ptrdiff_t result;
std::call_once(flag, [=]() {
auto vm = (vm_address_t)JSContextGetGroup(context_);
for (ptrdiff_t offset = 0x0; offset < 0xfffff; offset += 0x10) {
auto maybeScope = ReadPointer(vm + offset);
if (!maybeScope) {
continue;
}
auto maybeGroup = ReadPointer(maybeScope);
if (maybeGroup != vm) {
continue;
}
auto maybeGlobal = ReadPointer(maybeScope + sizeof(vm_address_t));
if ((vm_address_t)context_ - maybeGlobal > 0x100) {
continue;
}
apiCastGlobalOffset = (vm_address_t)context_ - maybeGlobal;
result = offset;
return;
}
assert(false);
error = HippyCopyOnWriteErrorType::kNoScope;
});
return result;
}
JSGlobalContextRef JSCopyOnWriteGlobal::entry() {
if (error != HippyCopyOnWriteErrorType::kNoError) {
return context_;
}
auto vm = (vm_address_t)JSContextGetGroup(context_);
auto scope = ReadPointer(vm + entryOffset());
if (!scope) {
return context_;
}
auto maybeGlobal = ReadPointer(scope + sizeof(vm_address_t)) + apiCastGlobalOffset;
auto founded = std::find(contexts_.begin(), contexts_.end(), maybeGlobal);
if (founded == contexts_.end()) {
assert(false);
JSCopyOnWriteGlobal::error = HippyCopyOnWriteErrorType::kNoGlobalInScope;
return context_;
}
return (JSGlobalContextRef)maybeGlobal;
}
注意,里面的context_ - maybeGlobal > 0x100 看起来比较奇怪,在ios13以上系统,其实它们是相等的,在ios13以下,两者其实相差了0x48,这个就自行看webkit源码理解吧,不细说了。
Webpack打包后,被共享的代码里面的数据如何互斥问题:
如上所示,webpack打包后的vendor代码,只产生了一个mapBase变量,当我们共享这个mapBase的时候,其内部modules里面的数据又会成为一个新的问题,即可能js里面的单例会出问题
解决方案:
将Webpack打包后的IIFE包一层,在全部js代码加载完成之后,统一将这些没有执行的IIFE全部执行一次即可。
假设业务1和业务2,共享了两个js代码文件即A.js和B.js(其实业务1和业务2可能是两个相同业务的不同实例也是可以的,此时可以做到完全共享全部代码)
当加载业务2的时候,会走如下流程:
如何包装一个IIFE呢?
代码如下:
- (void)loadIIFE:(NSData *)code sourceURL:(NSURL *)sourceURL context:(JSGlobalContextRef)context {
auto name = [self getIIFEExportNameFromCode:(const char *)code.bytes sourceURL:sourceURL.path];
NSString *iifeName = [self iifeNameFromExportVariable:name];
auto wrapper = R"(
function %@() {
%s;
return %@;
}
)";
auto lazyCode = [NSString stringWithFormat:@(wrapper),iifeName, (const char *)code.bytes, name];
[[JSContext contextWithJSGlobalContextRef:context] evaluateScript:lazyCode withSourceURL:sourceURL];
}
假设原来的Webpack打包出来的IIFE叫mapBase,那么我们封装的函数就叫mapBase_,在需要真正运行的时候再调用var mapBase = mapBase_(),对于没有名字的IIFE,特殊处理一下,使用文件path或者业务名称就行。
最终效果:
可以看到,ios13以上系统可以获得大幅内存减少