ABCBinding--简化Cocos和Native交互利器(iOS篇)

ABCBinding--简化Cocos和Native交互利器里主要介绍了ABCBindingJavaScript侧和Android侧的设计和实现,本篇是其姊妹篇,主要介绍iOS侧是如何设计和实现的。

1. 背景介绍

1.1 Cocos 调用 iOS

在官方文档中,说明了如何在 iOS 平台上使用 Javascript 直接调用 Objective-C 方法:

jsException: function (scence, msg, stack) {
    if (cc.sys.isNative && cc.sys.os === cc.sys.OS_ANDROID) {
        jsb.reflection.callStaticMethod(
            "xxx/xxx/xxx/xxx/xxx",
            "xxx",
            "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V",
            scence, msg, stack
        );
    } else if (cc.sys.isNative && cc.sys.os === cc.sys.OS_IOS) {
        jsb.reflection.callStaticMethod(
            "xxx",
            "xxx:xxx:xxx:",
            scence, msg, stack
        );
    }
}

从代码当中我们可以看出有以下问题:

  1. 方法调用比较麻烦,需要明确的指出方法的Class,方法名,参数类型,特别容易出错;
  2. 一旦 Native 端的方法发生变化,Cocos层必须要同步修改,否则就会出现异常,甚至有可能 crash
  3. 不方便回调,这一点非常痛苦,Cocos作为UI层,native大部分是基于响应式给予Cocos响应,无法直接回调真是是噩梦;

我们尝试着用注解的思路来解决这些问题,从而设计并实现了 ABCBindingABCBinding 能够极大的简化 CocosNative 的交互,方便维护。

1.2 iOS 调用 Cocos

通过 evalStringC++ / Objective-C 中执行 JavaScript 代码。

Application::getInstance()->getScheduler()->performFunctionInCocosThread([=](){
    se::ScriptEngine::getInstance()->evalString(script.c_str());
});

这里需要注意:除非明确当前线程是 主线程,否则都需要将函数分发到主线程执行。

Cocos调用不支持回调的情况下,我们只能通过evalString的方式,再调用Cocos,当然这也是唯一的途径。

2. 业内其他方案回顾

有这么一句话:如果说我看得远,那是因为我站在巨人的肩上。

这句话是牛顿的经典名言,意思是说他这一生的成就是因为有之前的巨人做基础。

2.1 React Native

我们知道,React Native可以调用native侧的方法。并且RN框架也给我们提供了这一能力,只要我们按照某些约定在native侧实现一个方法,那么就可以在JS侧顺利调用。如下实现了一个简单的native模块:

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface NativeLogModule : NSObject<RCTBridgeModule>

@end

#import "NativeLogBridge.h"

@implementation NativeLogModule
RCT_EXPORT_MODULE()
RCT_EXPORT_METHOD(nativeLog:(id)obj) {
  NSLog(@"%@",obj);
}
@end

以上是一段OC写的native代码,NativeLogModule遵守了RN提供的协议RCTBridgeModule。该协议中规定了一些宏和方法。遵守了这个协议,NativeLogModule就可以使用RCT_EXPORT_MODULE()宏将该类以module的方式暴露给JS,然后使用RCT_EXPORT_METHODnative方法暴露给JS

接下来看下JS侧是怎么调用NativeLogModule的nativeLog方法。

import { NativeModules } from 'react-native';

NativeModules.NativeLogModule.nativeLog('巴拉巴拉叭叭叭');

以上两行JS即可实现native方法的调用,第一行是导入NativeModules模块,第二行通过NativeModule调用NativeLogModule

nativeLog方法。以上即可实现JS调用Native方法。但在学习RN之初,想必大家都有一个疑问,Native方法是怎么暴露给JS的呢?JS又是怎么调用这些Native方法的呢?

这里就不得不说RN中的两个宏了,RCT_EXPORT_MODULE RCT_EXPORT_METHOD

2.1.1 RCT_EXPORT_MODULE 模块导出

#define RCT_EXPORT_MODULE(js_name) \
RCT_EXTERN void RCTRegisterModule(Class); \
+ (NSString *)moduleName { return @#js_name; } \
+ (void)load { RCTRegisterModule(self); }

如上代码所示,RCT_EXPORT_MODULE宏背后是两个静态方法+(NSString *)moduleName和+(NSString *)loadmoduleName方法简单的返回了native模块的类名,如果RCT_EXPORT_MODULE宏的参数是空,就默认导出类名作为模块名,如果参数不是空,就以参数名为模块名。load方法是大家耳熟能详的的,load方法调用RCTRegisterModule函数注册了模块。

2.1.2 RCTRegisterModule 模块注册

我们再来看看RCTRegisterModule函数的实现(该函数定义在RCTBridge.m中):

static NSMutableArray<Class> *RCTModuleClasses;

void RCTRegisterModule(Class moduleClass)
{
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    RCTModuleClasses = [NSMutableArray new];
    RCTModuleClassesSyncQueue = dispatch_queue_create("com.facebook.react.ModuleClassesSyncQueue", DISPATCH_QUEUE_CONCURRENT);
  });

  RCTAssert([moduleClass conformsToProtocol:@protocol(RCTBridgeModule)],
            @"%@ does not conform to the RCTBridgeModule protocol",
            moduleClass);

  // Register module
  dispatch_barrier_async(RCTModuleClassesSyncQueue, ^{
    [RCTModuleClasses addObject:moduleClass];
  });
}

很简单,RCTRegisterModule函数只做了3件事:

  1. 创建一个全局的可变数组和一个队列(懒加载);

  2. 检查导出给JS模块是否遵守了RCTBridgeModule协议;

  3. 把要导出的类添加到全局的可变数组中进行记录。

可见,在app启动后调用load方法时,所有需要暴露给JS的方法都已经被注册到一个数组中。到此为止,只是把需要导出给JS的类记录下来了,那这些类又是在什么阶段提供给JS的呢?Native侧全局搜索RCTGetModuleClasses函数,可以看到该函数在RCTCxxBridgestart中被调用了:

- (void)start
{
  ...
  [self registerExtraModules];
  // Initialize all native modules that cannot be loaded lazily
  (void)[self _initializeModules:RCTGetModuleClasses() withDispatchGroup:prepareBridge lazilyDiscovered:NO];
  [self registerExtraLazyModules];
  ...
}

2.1.3 RCT_EXPORT_METHOD 方法导出

#define RCT_EXPORT_METHOD(method) \
  RCT_REMAP_METHOD(, method)
  
#define RCT_REMAP_METHOD(js_name, method) \
 _RCT_EXTERN_REMAP_METHOD(js_name, method, NO) \
 - (void)method RCT_DYNAMIC;

#define _RCT_EXTERN_REMAP_METHOD(js_name, method, is_blocking_synchronous_method) \
  + (const RCTMethodInfo *)RCT_CONCAT(__rct_export__, RCT_CONCAT(js_name, RCT_CONCAT(__LINE__, __COUNTER__))) { \
    static RCTMethodInfo config = {#js_name, #method, is_blocking_synchronous_method}; \
    return &config; \
  }

#define RCT_CONCAT2(A, B) A ## B
#define RCT_CONCAT(A, B) RCT_CONCAT2(A, B)

通过上面一系列的宏调用看出,RCT_EXPORT_METHOD最终做了2件事:

  1. 定义一个实例方法;

  2. 定义一个静态方法,该方法名的格式是+(const RCTMethodInfo *)__rct_export__+js_name+___LINE__+__COUNTER__

如果没有js_name参数,那么最终的格式就是+(const RCTMethodInfo *)__rct_export__+___LINE__+__COUNTER__

比如:+(const RCTMethodInfo *)__rct_export__131 其中13是__LINE__,1是__COUNTER__

而这个方法的实现是这样的:

+(const RCTMethodInfo *)__rct_export__+js_name+__LINE__+__COUNTER__ {
    static RCTMethodInfo config = {
        js_name,
        method,
        is_blocking_synchronous_method
    }
    return &config;
}

可以看出,最终把这个方法包装成了一个RCTMethodInfo对象,在运行时RN会扫描所有导出的native module中以__rct_export__开头的方法。

2.2 Flutter

Flutter 提供了 Platform Channel 机制,让消息能够在 nativeFlutter 之间进行传递。

每个 Channel 都有一个独一无二的名字,Channel 之间通过 name 区分彼此。

Channel 使用 codec 消息编解码器,支持从基础数据到二进制格式数据的转换、解析。

Channel 有三种类型,分别是:

  1. BasicMessageChannel:用于传递基本数据 ;

  2. MethodChannel: 用于传递方法调用,Flutter 侧调用 native 侧的功能,并获取处理结果;

  3. EventChannel:用于向 Flutter 侧传递事件,native 侧主动发消息给 Flutter

这三种类型比较相似,因为它们都是传递数据,实现方式也比较类似;

我们从调用处开始进入 Flutter engine,一步步跟踪代码运行过程。

2.2.1 函数注册

在 iOS 项目中,注册获取电量的 channel

FlutterMethodChannel* batteryChannel = [FlutterMethodChannel
      methodChannelWithName:@"samples.flutter.io/battery"
            binaryMessenger:controller];
            
[batteryChannel setMethodCallHandler:^(FlutterMethodCall* call,
                                         FlutterResult result) {
    if ([@"getBatteryLevel" isEqualToString:call.method]) {
      int batteryLevel = [weakSelf getBatteryLevel];
      if (batteryLevel == -1) {
        result([FlutterError errorWithCode:@"UNAVAILABLE"
                                   message:@"Battery info unavailable"
                                   details:nil]);
      } else {
        result(@(batteryLevel));
      }
      ...
  }];

中间的跟踪代码咱就省去了,直奔核心代码。

FlutterEngine 将桥接事件转发给 iosPlatformViewPlatformMessageRouter

// PlatformMessageRouter
void PlatformMessageRouter::SetMessageHandler(const std::string& channel,
                                              FlutterBinaryMessageHandler handler) {
  message_handlers_.erase(channel);
  if (handler) {
    message_handlers_[channel] =
        fml::ScopedBlock<FlutterBinaryMessageHandler>{handler, fml::OwnershipPolicy::Retain};
  }
}

PlatformMessageRouter 的属性 message_handlers_ 是个哈希表,key 是桥接名,valuehandle。原生注册桥接方法,其实就是维护一个 map 字典对象。这样,注册原生方法完成了。

2.2.2 函数调用

先来看下 dart 侧如何调用获取电量的桥接方法

static const MethodChannel methodChannel =
      MethodChannel('samples.flutter.io/battery');
      
final int result = await methodChannel.invokeMethod('getBatteryLevel');

invokeMethod通过一系列调用,最终把消息转发给PlatformMessageRouter

void PlatformMessageRouter::HandlePlatformMessage(
    fml::RefPtr<blink::PlatformMessage> message) const {
  fml::RefPtr<blink::PlatformMessageResponse> completer = message->response();
  auto it = message_handlers_.find(message->channel());
  if (it != message_handlers_.end()) {
    FlutterBinaryMessageHandler handler = it->second;
    NSData* data = nil;
    if (message->hasData()) {
      data = GetNSDataFromVector(message->data());
    }
    handler(data, ^(NSData* reply) {
      if (completer) {
        if (reply) {
          completer->Complete(GetMappingFromNSData(reply));
        } else {
          completer->CompleteEmpty();
        }
      }
    });
  } else {
    if (completer) {
      completer->CompleteEmpty();
    }
  }
}

message_handlers_中取出channelName对应的 handle 并执行,handle 完成后,将结果回调给 Flutter

2.3 总结套路

这里我们就梳理出了一般的业内套路,大伙儿其实也看出来了,无非就两步:

  1. 函数注册,在启动的时候注册,数据结构一般使用字典;
  2. 函数调用,通过消息转发或实现类似的调用协议。

3. ABCBinding 的结构设计

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决。
Any problem in computer science can be solved by another layer of indirection.

ABCBinding 的结构如下图所示:(可以看出,ABCBinding其实就是个中间层或中间件)

ABCBinding

4. ABCBinding iOS 侧如何使用

  1. 添加到工程;

    pod 'ABCBinding'
    
  2. 实现代理协议ABCBindingCocosEvalStringProtocol,具体实现参加ABCBindingCocosEvalStringProtocol.h

  3. 引入JSCocos工程

    import { Binding } from '../ABCKit-lib/abc';
    
  4. 编写一个ABCBinding接口,例如,我们定义一个获取手机信息接口 我们约定名称 getHardwareInfo 无传入参数,返回手机型号,OS版本。在TS中调用:

    import { Binding } from '../ABCKit-lib/abc';
    Binding.callNativeMethod('getHardwareInfo').then((hardwareInfo) => {
      //get hardwareInfo 
         console.log(hardwareInfo.brand);
         console.log(hardwareInfo.model);
         console.log(hardwareInfo.OsVersion);
    })
    
  5. iOS 任意.m文件中编写实现,只需要实现@ABCBinding(FUNCTION)和@end即可,函数名称编译器会帮助实现;

    ABCBinding_iOS_Code
    @ABCBinding(getHardwareInfo)
    + (void)getHardwareInfo:(NSDictionary *)JSParam Callback:(ABCBindingCallBack *)callback {
        callback.onSuccess({
          @"brand":@"brand",
          @"model":@"model",
          @"OsVersion":@"OsVersion"
        });
    }
    @end
    

5. ABCBinding iOS 侧的一些实现细节

了解了业内的套路,我们来尝试实现ABCBinding的函数注册和函数调用;

5.1 使用 __attribute__实现编译期自动注册

首先是函数注册,我们同样使用一个map数据结构来存储函数的注册的classmethod;

我们这里的方案是编译期写入注册信息,在代码上,一个启动注册项最终都会对应到一个函数的执行,所以在运行时只要能获取到函数的指针,就可以触发启动注册项。方案核心思想就是在编译时把数据(如函数指针)写入到可执行文件的 __DATA 段中,运行时再从 __DATA 段取出数据进行相应的操作(调用函数)。

程序源代码被编译之后主要分为两个段:程序指令和程序数据。代码段属于程序指令,data.bss 节属于数据段。

Mach-O 的组成结构如上图所示,包含了 HeaderLoad commandsData(包含 Segment 的具体数据),我们平时了解到的可执行文件、库文件、DSYM 文件、动态库、动态链接器都是这种格式的。

相关技术实现:

  1. __attribute__

    Clang 提供了很多的编译函数,它们可以完成不同的功能,其中一项就是 section() 函数,section() 函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段。在具体的实现中,主要分为编译期和运行时两部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key可以代表不同的启动阶段), *pointer} 对到数据段。到运行时,在合适的时间节点,在根据 key 读取出函数指针,完成函数的调用。

    Clang AttributesClang 提供的一种源码注解,方便开发者向编译器表达某种要求,参与控制如 Static AnalyzerName ManglingCode Generation 等过程,一般以__attribute__(xxx)的形式出现在代码中;为方便使用,一些常用属性也被 Cocoa 定义成宏,比如在系统头文件中经常出现的 NS_CLASS_AVAILABLE_IOS(9_0) 就是 __attribute__(availability(...)) 这个属性的简单写法。编译器提供了我们一种 __attribute__((section("xxx段,xxx节")的方式让我们将一个指定的数据储存到我们需要的节当中。

    used的作用是告诉编译器,我声明的这个符号是需要保留的。被used修饰以后,意味着即使函数没有被引用,在Release下也不会被优化。如果不加这个修饰,那么Release环境链接器会去掉没有被引用的段。

    通常情况下,编译器会将对象放置于DATA段的data或者bss节中。但是,有时我们需要将数据放置于特殊的节中,此时section可以达到目的。

    constructor:见名知意,加上这个属性的函数会在可执行文件(或 shared libraryload时被调用,可以理解为在 main() 函数调用前执行。

  2. 编译期写入数据

    首先我们定义函数存储的结构体,如下,function 是函数指针,指向我们要写入的函数。

    struct ABCBinding_Function {
        void (* _Nonnull function)(void);
    };
    

    将包含函数指针的结构体写入到我们指定的数据区指定的段 __DATA, 指定的节 __ABCBinding,方法如下(这里我们定义了个宏):

    #define ABCBinding(JSFunc)\
    ...\
    static void _ABCBinding##JSFunc##load(void); \
    __attribute__((used, section("__DATA,__ABCBinding"))) \
    static const struct ABCBinding_Function __FABCBinding##JSFunc = (struct ABCBinding_Function){(void *)(&_ABCBinding##JSFunc##load)}; \
    ...\
    

    将工程打包,然后用 MachOView 打开 Mach-O 文件,可以看出数据写入到相关数据区了,如下

  3. 运行时读出数据。

    如果要到 main 之前的阶段,之前我们是使用 load 方法,现在使用 __attribute__constructor 属性也可以实现这个效果,而且更方便,并且此时所有 Class 都已经加载完成。相关代码如下:

    __attribute__((constructor))
    void premain() {
      	...
        NSMutableArray<ABCBindingLaunchModel *> *arrayModule = modulesInDyld();
        for (ABCBindinLaunchModel *item in arrayModule) {
            IMP imp = item.imp;
            void (*func)(void) = (void *)imp;
            func();
        }
        ...
    }
    

    这里我们调用了func()func()里实现了向map中注册classmethod,至此,我们就完成了函数的注册。

5.2 反射 + Runtime 函数调用

接下来就是函数调用,在iOS首先想到的就是Runtime函数调用,部分代码如下:

+ (NSNumber*)executeWithMethodName:(NSString*)methodName
                            args:(NSString*)args
                        callback:(NSString *)callback {
    ...
    // 反射得到运行时class和方法选择子
    SEL sel = NSSelectorFromString(method);
    Class cls = NSClassFromString(className);
    ...
    // 执行函数
    SuppressPerformSelectorLeakWarning(
        [cls performSelector:sel withObject:dicArgs withObject:bindingCallback];
    );
    ...
}

5.3 宏定义实现类注解,约束代码规范

众所周知,在Objective-C中是没有注解的,但是看到Java里注解用的那个爽,咱们iOSer也是真馋了。

这里我们使用iOS宏的相关点,在预编译阶段解析宏替换生成自定义的class和自定义的protocol

为了能实现类似注解的功能,ABCBinding使用了@protocol xxx @end的关键字。这样就实现了三个功能:

  1. 可以在任意.m文件中使用,独立于文件的其他实现;
  2. 使用@required关键字,实现了编译器代码提示和代码生成;
  3. 由于每个JSFunc被定义成了一个类,那么如果JSFunc相同的话,编译器也会报类重复的错,实现了函数的唯一性。

6. 关于App Store 提审风险

Cocos官方文档中有一段这个描述:

警告:苹果 App Store 在 2017 年 3 月对部分应用发出了警告,原因是使用了一些有风险的方法,其中 respondsToSelector:performSelector: 是反射机制使用的核心 API,在使用时请谨慎关注苹果官方对此的态度发展,相关讨论:JSPatchReact-NativeWeex

小弟个人是经历了JSPatch的从兴起到衰落的完整过程,关于这个警告,苹果重点是避免某些不法分子通过反射调用私有API,从而侵害用户的隐私数据。(JSPatch这里是完全可以做得到绕过苹果的审核机制,所以被封杀了)。

React-NativeWeex的相关应用目前是可以在App Store上架的。其原因也是这些框架中,并没有提供没有任何校验的反射调用。

ABCBinding这里每一个brigde都是一个参与编译的class类,苹果静态代码扫描是完全可以扫得到的。而这里使用的反射和和Runtime调用也都是系统公开的API,所以这里整体就是使用系统公开的API调用可以编译的类,并不会调用私有API

7. 致谢

一起开发Cocos端和Android端的小伙伴:@hughxnwang @bearhuang