​ 《企鹅电竞weex实践—— iOS SDK的小九九》

时间:2022-05-05
本文章向大家介绍​ 《企鹅电竞weex实践—— iOS SDK的小九九》,主要内容包括关于Weex、Weex框架、桥接端(Bridge)、纯Native端、Module、Component、WXSDKInstance、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

| 导语 企鹅电竞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:));
}

这样已经比较清晰了,每一行宏其实就是生成一个新的方法,方法以wxexport_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源码以及在这个过程中踩过的坑。


如果您觉得我们的内容还不错,就请转发到朋友圈,和小伙伴一起分享吧~