| 导语 企鹅电竞iOS端在3.2版本接入了Weex,感受了一把前端的代码原生的体验。本文从WeexSDK源码出发,主要介绍了Weex在iOS侧的框架结构以及大致的工作流程。
关于Weex
Weex是一套跨平台的动态页面解决方案,让开发者可以用前端的语法写出Native级别的体验,这一点核心功能与React Native是相同的,但RN并不是今天的主角, 这里也不多花笔墨介绍。WEEX宣称「Write Once, Run Everywhere」,同一份代码可以在不同端上运行。Weex是如何做到的呢?
话不多说,先上一张上镜率特别高的流程图,了解一下Weex的工作流程:
在服务端,开发者将写好的Weex文件转换成JS bundle并部署到服务器上供终端下载;终端会在合适的时机拉取JS Bundle,同时利用WeexSDK 中预先准备好的 JavaScript 引擎解析执行JS bundle,在执行过程中通过JS-Native Bridge产生各种终端能够识别的命令进行界面渲染或数据存储、网络通信、调用设备功能、用户交互响应等移动应用的场景实践。
Weex框架
Weex源码可以在Github(https://github.com/apache/incubator-weex)上下载到,先看下0.16.1版本下的文件目录结构:
目录的划分比较清楚,一个目录基本就是对应一个功能模块,我们可以对其做一个归类,把它分为三端:JS端、桥接端和纯Native端。见下图:(灰色的方块代表一个文件目录)
JS端
JS端主要内容是Weex源码中的native-bundle-main.js文件,它提供了一系列Weex的基础JS方法,作用相当于一个库,因此我们又称之为JS Framework。Weex把JS Bundle拆分为基础JS库和业务JS代码, 并把JS库带到安装包中,这样一来,页面请求的JS Bundle就只需要包含业务代码,体积会变得很小,对于加载速度提升大有裨益。JS Framework会在WeexSDK初始化时被加载到内存中。
桥接端(Bridge)
桥接层负责JS和Native的通信,主要依靠一个全局的JSContext作为媒介。WeexSDK初始化时会往这个全局的JSContext中注入一些方法,举个栗子:
- (void)registerCallNative:(WXJSCallNative)callNative
{
JSValue* (^callNativeBlock)(JSValue *, JSValue *, JSValue *) = ^JSValue*(JSValue *instance, JSValue *tasks, JSValue *callback){
NSString *instanceId = [instance toString];
NSArray *tasksArray = [tasks toArray];
NSString *callbackId = [callback toString];
return [JSValue valueWithInt32:(int32_t)callNative(instanceId, tasksArray, callbackId) inContext:[JSContext currentContext]];
};
_jsContext[@"callNative"] = callNativeBlock
}
Bridge中调用registerCallNative:方法往JSContext里面写入一个名为callNative的方法。在JS端就可以通过callNative的方法调用终端的callNativeBlock块,而在callNativeBlock中会把JS端传过来的JSValue值转成终端可理解的类型,再分发出去。类似callNative这样注入到JSContext方法还有不少,如callNativeModule、callNativeComponent等,原理大同小异,不作赘述。
而终端调用JS也是通过取JSContext对象,调用invokeMethod:withArguments:方法实现。
- (JSValue *)callJSMethod:(NSString *)method args:(NSArray *)args
{
return [[_jsContext globalObject] invokeMethod:method withArguments:args];
}
或者也可以在JS调用Native时传入一个callback,终端暂存这个callback,在有需要的时候调回给JS。这一方式在Component和Module中比较常见。
纯Native端
主要的业务都代码都是这一端,包括JS Bundle的请求、UI渲染、性能统计等等。功能层面对其自上而下划分,又可拆分为接口层(Interface)、功能层(Function)、基础层(Basic)。
接口层顾名思义,就是对外暴露API的模块,是最贴近开发者的一层。通过Engine可以对SDK进行初始化,同时注册一些通用的Component和Module,JS Framework会在此时被加载。Controller不必多说,可以使用这个现成的Controller创建一个weex页面,我们需要做的仅仅是传递一个url给它。但如果想要自已实现一个Weex Controller而不是继承它,这个时候就需要用到Model中的WXSDKInstance,Weex渲染过程的各个阶段都会在WXSDKInstance中有回调,JS Bundle的请求也是在这个类中发出。
先绕过功能层,看基础层。基础层提供了一些基础的、与业务无强相关的功能。如Network就是对NSURLSession进行一次再封装,提供基础的下载功能;Event用来定义一些标准手势事件;Layout则是页面布局相关实现,布局引擎采用C语言,可以跨平台使用。Event和Layout最终都服务于Component。
最后是代码最重的功能层,其中Monitor和DevTool是相对比较独立的,Monitor是测速模块,DevTool用来支持远程调试的,可以不集成到代码中,不影响编译,此处不谈。
Module、Component和Handler是Weex三贱客,它们都是采用插件的形式集合到SDK中,很方便扩展。注册的时机也相同,SDK会在初始化时调用registerDefaultModules/Compoents/Handlers加载一些标准的插件。
+ (void)registerDefaults
{
[self _registerDefaultComponents];
[self _registerDefaultModules];
[self _registerDefaultHandlers];
}
这么说也许有点抽象,还是上代码吧,看看registerDefaultCompoents都register什么了:
// register some default components when the engine initializes.+ (void)_registerDefaultComponents
{
[self registerComponent:@"container" withClass:NSClassFromString(@"WXDivComponent") withProperties:nil];
[self registerComponent:@"div" withClass:NSClassFromString(@"WXComponent") withProperties:nil];
[self registerComponent:@"text" withClass:NSClassFromString(@"WXTextComponent") withProperties:nil];
[self registerComponent:@"image" withClass:NSClassFromString(@"WXImageComponent") withProperties:nil];
……
}
相信看完代码心里就多少有点底了,宏观来说,Component的注册就是通过registerComponent:withClass:withProperties:方法把一个终端的组件映射成了JS端的一个标签。
从注册时的命名上不难看出,Component实现的是UIKit的功能。那还有两贱客是干吗的?我们可以认为Module是终端提供给JS的功能模块,为了让JS获得终端的能力,例如网络请求的能力(WXStreamModule)、定时器的能力(WXTimerModule)等。三贱客里,Component和Module都是可以直接和JS通信的,而Handler不行,Handler仅仅作为面向协议编程的一种手段,在纯Native端使用。
Loader就是采用了Handler的形式对Network进行了进一步的再封装,用这种方式会让Loader模块更灵活一些,开发者完全可以通过重新注册Handler来挂载新的网络请求方法,实现一些自定义的功能。
除了Component和Module,还有一个模块可以直接和JS通信,就是结构图中与桥接端相邻的Manager(与桥接端相邻代表二者可以直接通信),Manager模块主要包括WXComponentManager和Factory,WXComponentManager用来做Component的调度,而Factory用来保存Component和Module的配置。
讲完了WeexSDK源码框架,或许读者还是会有不少疑问:说了那么一堆高大上的名词,然并卵,我还是不知道Weex是怎么跑起来的。那我们就更具体一点,去看看Module和Component的世界吧。
Module
前面有一个高频词汇:注册。所谓注册,其实在实现上就是往全局字典里面以Key-Value的形式保存一些模块的信息。Weex三贱客都需要注册,Handler的注册上文提到过,其实就是以Protocol Name为Key,往全局字典里面写入一个实现了该Protocol的对象。Module和Component的注册则更接近一些,都是以注册时的标签为Key,而保存的value是一个WXInvocationConfig派生类对象,可以瞄一眼WXInvocationConfig携带的信息,包括:标签名、类名、同步方法和异步方法。
@interface WXInvocationConfig : NSObject
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *clazz;
@property (nonatomic, strong) NSMutableDictionary *asyncMethods;
@property (nonatomic, strong) NSMutableDictionary *syncMethods;
- (instancetype)initWithName:(NSString *)name class:(NSString *)clazz;
- (void)registerMethods;
@end
标签名和类名是注册时作为初始化参数传入的,我们主要看下同步方法和异步方法是怎么来的,它包含了哪些信息。
以WXDomModule为例,在WXDomModule的类实现文件中有一坨被WX_EXPORT_METHOD宏定义包裹的selector:
WX_EXPORT_METHOD(@selector(createBody:))
WX_EXPORT_METHOD(@selector(addElement:element:atIndex:))
WX_EXPORT_METHOD(@selector(removeElement:))
WX_EXPORT_METHOD(@selector(moveElement:parentRef:index:))
WX_EXPORT_METHOD(@selector(addEvent:event:))
WX_EXPORT_METHOD(@selector(removeEvent:event:))
……
查看宏定义:
#define WX_EXPORT_METHOD(method) WX_EXPORT_METHOD_INTERNAL(method,wx_export_method_)
#define WX_EXPORT_METHOD_INTERNAL(method, token) \
+ (NSString *)WX_CONCAT_WRAPPER(token, __LINE__) {
\ return NSStringFromSelector(method); \
}
#define WX_CONCAT_WRAPPER(a, b) WX_CONCAT(a, b)
将WX_EXPORT_METHOD(@selector(createBody:))展开
+ (NSString *)wx_export_method_40 {
return NSStringFromSelector(@selector(createBody:));
}
这样已经比较清晰了,每一行宏其实就是生成一个新的方法,方法以wx_export_method_为前缀,后面携带上当前的行数来保证方法名的唯一性。方法的返回值是包裹选择子的方法名,换句话说,这个宏实际上就是做了一个映射,把终端想要暴露给JS的方法名映射成具有固定格式的方法名。有了这么一个奇葩的前缀后,妈妈再也不用担心我们找不到这些方法了,Weex会在运行时取到对应Module的方法列表,然后遍历其中的方法,判断方法是否包含该前缀,如果包含,那么则保存到asyncMethods中,保存时以OC方法名的第一段作为Key。这就是WXInvocationConfig中registerMethods方法的实现。废话不多说,还是上代码:
- (void)registerMethods
{
Class currentClass = NSClassFromString(_clazz);
while (currentClass != [NSObject class]) {
unsigned int methodCount = 0;
Method *methodList = class_copyMethodList(object_getClass(currentClass), &methodCount);
for (unsigned int i = 0; i < methodCount; i++) {
NSString *selStr = [NSString stringWithCString:sel_getName(method_getName(methodList[i])) encoding:NSUTF8StringEncoding];
BOOL isSyncMethod = NO;
if ([selStr hasPrefix:@"wx_export_method_sync_"]) {
isSyncMethod = YES;
} else if ([selStr hasPrefix:@"wx_export_method_"]) {
isSyncMethod = NO;
} else {
continue;
}
NSString *name = nil, *method = nil;
SEL selector = NSSelectorFromString(selStr);
if ([currentClass respondsToSelector:selector]) {
method = ((NSString* (*)(id, SEL))[currentClass methodForSelector:selector])(currentClass, selector);
}
NSRange range = [method rangeOfString:@":"];
if (range.location != NSNotFound) {
name = [method substringToIndex:range.location];
} else {
name = method;
}
NSMutableDictionary *methods = isSyncMethod ? _syncMethods : _asyncMethods;
[methods setObject:method forKey:name];
}
free(methodList);
currentClass = class_getSuperclass(currentClass);
}
}
同步方法的注册过程相同,只不过使用的宏不同,携带的前缀信息也不同而已。
调用完registerMethods方法后,WXDomModule的Config包含的信息如下:
同步方法和异步方法存储的Key列表就是JS端可调用的函数名列表。趁热打铁,继续看下JS端是具体是怎么调用这些暴露出去的方法。
在前文第二节Weex框架中有提到,WeexSDK会在桥接层往JSContext注入一些方法作为JS调用Native的通道,其中callNativeModule方法就是用来调用Native Module的(JS调用Module不仅限于callNativeModule方法)。
[_jsBridge registerCallNativeModule:^NSInvocation*(NSString *instanceId, NSString *moduleName, NSString *methodName, NSArray *arguments, NSDictionary *options) {
WXSDKInstance *instance = [WXSDKManager instanceForID:instanceId];
WXModuleMethod *method = [[WXModuleMethod alloc] initWithModuleName:moduleName methodName:methodName arguments:arguments options:options instance:instance];
return [method invoke];
}];
JS端调用callNativeModule传参,终端将JSValue转成moduleName、methodName、arguments等参数,并通过这些参数生成了一个WXModuleMethod对象,WXModuleMethod会用这些参数去之前注册的Config里找到Module实例(被WXSDKInstance持有)和对应的selector,然后New出一个NSInvocation对象进行invoke,此时JS就可以无障碍地使用Module的方法了。剩下的就只是把不同的功能封装到不同的Module中并暴露JS端所需的接口而已,这里简单罗列了0.16.1版本上WeexSDK所自带的Module:
Moudule | 能力 |
---|---|
WXDomModule | 提供Demo解析能力 |
WXNavigatorModule | 提供控制UI能力 |
WXStreamModule | 提供网络请求能力 |
WXAnimationModule | 提供动画能力 |
WXModalUIModule | 提供alert、toast等模态UI展示能力 |
WXWebViewModule | 提供webview基础能力 |
WXInstanceWrap | 提供访问终端instance实例能力 |
WXTimerModule | 提供定时器能力 |
WXStorageModule | 提供持久化能力 |
WXClipboardModule | 提供剪切板能力 |
WXGlobalEventModule | 提供全局事件(监听通知)能力 |
WXCanvasModule | 提供绘图能力 |
WXPickerModule | 提供DatePicker和TimePicker能力 |
WXMetaModule | 提供设置视口(viewport)能力 |
WXWebSocketModule | 提供WebSocket能力 |
WXVoiceOverModule | 提供VoiceOver能力 |
Component
理解了Module后再来看一下Component。前面提到过Component的主要作用对应UIKit,每一个Component类就与一种UI类型强相关,如tableView、imageView。Component维护了一个生命周期,这一点跟UIViewController有点相似:
Component的init方法有许多参数要传,包括样式、属性、事件等都可以在初始化时传入;loadView时,WXComponent的派生类需要返回一个UI类型实例,它会被赋值给Component的view属性,跟Component关联起来;loadView之后会走到addEvent,这里允许我们添加一些自定义的事件(常用的单击、长按等事件已经实现,在初始化时传入即可,不需要操作addEvent方法);在viewDidLoad中可以对view做个性化的配置,然后启动布局。
Weex允许在view加载出来了以后再去updateStyles/Attributes,JS可以直接访问到这个Component对象。
JS调用Component的原理和Module基本一样,通过注入的callNativeComponent、callUpdateAttrs等一系列方法,在调用过程中生成一个WXComponentMethod对象,然后再利用NSInvocation invoke触达Native。除此之外,JS还可以通过WXComponentManager间接调用Component。
WXComponentManager是Component的调度器,可以直接和JS通信。注入JSContext的方法中与其相关的有callAddElement、callRemoveElement、callAddEvent等,通过这些方法直接调用WXComponentManager即图示中的链路①。而在首屏渲染时通常走的是链路②,即JS Framework在解析JS Bundle时会先访问WXDomModule,然后再由WXDomModule间接地调用WXComponentManager,两种方式其实没有太大差别,在首屏全部使用WXDomModule会更容易监控Dom解析过程而已。
addComponent的作用类似于addSubview,WXComponentManager会先用JS传递过来的componentData创建Component对象,然后再把生成的Component添加到它supercomponent的树结构中,同时把Component关联的view加到视图层级上去,之后再对它的children结点递归调用addComponent。
- (void)_recursivelyAddComponent:(NSDictionary *)componentData toSupercomponent:(WXComponent *)supercomponent atIndex:(NSInteger)index appendingInTree:(BOOL)appendingInTree
{
WXComponent *component = [self _buildComponentForData:componentData supercomponent:supercomponent];
[supercomponent _insertSubcomponent:component atIndex:index];
if (!component->_isTemplate) {
[supercomponent insertSubview:component atIndex:index];
}
NSArray *subcomponentsData = [componentData valueForKey:@"children"];
BOOL appendTree = !appendingInTree && [component.attributes[@"append"] isEqualToString:@"tree"];
// if ancestor is appending tree, child should not be laid out again even it is appending tree.
for(NSDictionary *subcomponentData in subcomponentsData){
[self _recursivelyAddComponent:subcomponentData toSupercomponent:component atIndex:-1 appendingInTree:appendTree || appendingInTree];
}
[component _didInserted];
if (appendTree) {
// If appending tree,force layout in case of too much tasks piling up in syncQueue
[self _layoutAndSyncUI];
}
}
Component的布局引擎是来自Facebook Yoga,采用的是盒子模型,每个Component都可以当作一个盒子,通过定义它的外边距边界 margin edge、边框边界 border edge、内边距边界 padding edge 和内容边界 content edge来确定Component的Frame。
WXComponentManager中会起一个DisplayLink,在每个定时周期循环遍历各个元素,检查是否需要更新布局,需要布局的cssNode会在is_dirty字段标识。如果超过1s没有布局任务,DisplayLink会进入休眠状态直至下一次唤醒。
WXSDKInstance
在了解过Module和Component的大致原理后,对Weex已经有一个基本认知,但距离整个流程跑通还欠缺一点。分散的Module和Component本身是不会工作的,还需要一个动力,这时我们的WXSDKInstance就要粉墨登场了。
还记得最初的那张Weex框架图吗? WXSDKInstance(在Model模块里)是整个Weex页面加载的起点,它会去服务端请求JS Bundle,没有JS Bundle我们什么事都干不了!
WXSDKInstance下载JS Bundle后,会把它传给JS Framework,JS Framework解析JS Bundle并通过WXDomModule往RootView上渲染视图。每个WXSDKInstance都会绑定一个独立的WXComponentManager。
用图表示的话,流程大概是下面这样子,WXSDKInstace负责串联各部分模块并带动整个流程:
在一些关键的流程上,WXSDKInstance都会有回调,如图上标注出的onCreate()是在下载完JS Bundle,RootView被创建出时回调;renderFinish()是在首屏Dom解析完成后回调;除此之外,还有onFailed()会在加载失败时回调,onJSRuntimeException()在JS执行异常时回调。这些回调都是对外暴露的,我们可以这些回调上做一些定制化的内容。
怎么样,是不是迫不及待想实践一番了?下一篇文章中我将会介绍企鹅电竞是如何接入Weex源码以及在这个过程中踩过的坑。
如果您觉得我们的内容还不错,就请转发到朋友圈,和小伙伴一起分享吧~