本文当时为了专利过审下架了,现在放回来 ~
本文写作于兔年之前,心境上却是有诸多感叹,大名鼎鼎的网络库 AFNetworking 停止维护了,iOS 开发早已是昨日黄花,移动端也从夕阳走向落幕了 ~

认清现实,摆正心态,拥抱变化,跨端开发确实比原生开发更值得卷 (o_ _)ノ 。作为一个码了10年的老派程序员,卷也要选择优雅的卷。Flutter \ Web \ Rust and so on ... 各种花里胡哨的跨端语言,怎么有机的结合在一起,这确实是一门学问。
起因
言归正传 ~
从小故事开始
我有个薛定谔状态的朋友,他入职了一家公司里做 iOS 开发,一天他接到一个业务需求,需要配合 Web 开发修改一下 windTrack
埋点桥接实现,通过对代码进行字符串搜索,他发现在不同模块里有4个可能是 windTrack
桥接的插件,但好在是3个已经被标记成过时,看起来他只需要处理未过时的那个即可。
故事嘛都有戏剧性,在修改打包后提供给 Web 开发同学测试时发现,修改并没有生效 ... 多方查找问题后发现,windTrack
桥接最终会调用到一个标记过时的方法里 ...

问题的本质
后续这个朋友如何处理暂且不表,我们来聊聊为什么会出现这样的问题?
本质上是可维护性的缺失,为什么项目里会存留4个具有相同能力的方法呢?因为它经手了 N(N > 4)位开发同学,这在业务快速增长时期,确实是存在的现象,有人在维护、有人在新增、有人在重构...懂得都懂。
思维升华
靠人约束规范是不可控的,加再多的 Code Review 流程也是不可控的,因人而异就会因人而劣化,直至规范形同虚设。这也包括维护文档这件事,理论上每个跨端桥接都要求有完善的使用文档,但现实上,文档的约束比代码更难,毕竟也没有 Docs Review 流程。
分析
我们虽然明了问题的本质,但还是需要具体的分析问题、解决问题。
现有开发方式
以 iOS - Web 通信桥接为例。
例:iOS 实现
底层调用上,基本都是通过 WKWebview
的 WKScriptMessageHandler
代理来监听 web -> iOS 的调用,通过主动调用 JS callbackId 的方式来回调消息。
部分代码截图:


从收到的 message.body
解析出调用的方法名、方法参数以及回调的 callbackId 等字符串。
然后我们都会或多或少的封装下,用各种方式找到具体的实现方法。这里就不展开讲了,基本就是用配置的方式、用代码反射的方式这样来做解耦。
像笔者公司上,就是用的代码反射,如下图:

实现方法上就可以转变为:
- (void)windTrack:(NSDictionary *)params completeBlock:(void (^)(id _Nullable result))completeBlock {
...
}
同理,Flutter 也有一套相同的实现方式,只不过 Flutter Channel 比 CallbackId 更优雅些。
例:Web 调用
在 Web 端使用层面,需要一些代码来隐藏 callbackId 以及抹平 iOS 双端调用差别,这里不做过多说明,还是以 windTrack
为例:
Bridge.call('report/windTrack', { ...params }).then(result => {})
一般来说,Web 开发同学也会把上述调用方法再二次封装一下来使用。
存在问题
可能多数同学或者以前的笔者也会觉得这样实现并没有什么问题,实现方式上大同小异,基本也都是这样来开发的。
但结合我们要求的可维护性来看,是有着以下弊端的:
- 用字符串类型的方法名字段做桥的连接标识位,这个属于人为强行定义,并不是可靠的。虽然可以增加一些运行时校验来检查,但也是环节滞后的测试手段。
- 参数并没有明确定义,统一都是 Map 对象,虽然也可以增加明确方法注释来缓解这个问题,但需要多端对齐参数及其类型,意外时常发生,最多也是增加运行时校验来断言。
- Flutter、Web 已定义了大量的桥接方法,也说不上来哪个到底有用,哪个已经没用了,对于原生桥接实现方法来说,就是一个也不敢删,生怕线上出现问题。何况还有命中到作废方法这种奇葩。
- 最为重要的是,从开发到维护,沟通上的成本居高不下,还会因人而废,一旦出现问题,排查起来十分头疼。
思考
我们总结下上述的问题,来想一下对开发最友好的是什么样的?
不需要写方法匹配
不需要写使用文档
不需要手动解析 Map
入参
不需要手动拼装 Map
回调
这在解决方案上,当然会想到用跨端工具链(说到跨端工具链,我们是在说什么?)的方式来实现。
简单来说:一次定义,多端实现,任意调用。
这也类似笔者前几篇文章中写到的 Flutter 多引擎渲染组件 中跨端处理方式。
举例
我们还是以故事里的windTrack
为例:
方法提供者只需要实现 interface windTrack(int eventId, String eventName, Map attributes)
。
方法使用者直接调用 windTrack(eventId, eventName, attributes)
。
剩下的过程开发都不需要接入,都有工具链进行生成。
解决方案
实现效果
先看下最终实现的效果。

如上图,只要定义 YAML,就会根据上述定义,来生成多端的使用代码。
再来看看 iOS 和 Web 端生成的效果
iOS 支撑服务效果
生成的 iOS 组件库

这里只需开发接口实现层
@interface GNBReportServiceImplement () <GNBReportServiceObserver>
@end
@implementation GNBReportServiceImplement
- (instancetype)init {
self = [super init];
if (self) {
[GNBManager.sharedInstance.reportService addObserver:self];
}
return self;
}
// MARK: - GNBReportServiceObserver
- (void)reportProviderDidWindTrack:(nullable NSString *)eventId event:(nullable NSString *)event detailInfo:(nullable NSDictionary *)detailInfo completedBlock:(nonnull void (^)(NSError *))completedBlock {
...
}
@end
Web 组件调用效果
生成的 Web 组件

注册后即可使用
GNB.init(new GNBAppRegister());
...
GNB.report.windTrack(...);
可以看到,流程上就转变为:全局定义 + 各端具体实现 API + 各端调用 API。大大的增强可维护性,间接的降低了人力成本,又能为老板省钱了[手动狗头]。
总体架构

专有名词解释
[GNB] Gaoding Native Bridge,稿定本地通信方案
上图是从能力视角描述了我们实现了什么,下图是从流程视角,来表明整个过程是如何运转起来的。

详细设计
定义规范
组件定义采用 YAML 标准化语言定义。
文件命名以用插件模块名称,比如 user.yaml 、 report.yaml 。
APIs 定义
定义 |
说明 |
name |
名称 |
note |
说明 |
params |
参数 List[name: 名称note: 说明type: 类型default: 默认值required: 是否必填,默认非必填,有默认值也为非必填] |
callback |
回调 List[name: 名称note: 说明type: 类型required: 是否必填,默认非必填] |
Classes 定义
定义 |
说明 |
name |
名称 |
note |
说明 |
properties |
属性列表[name: 名称note: 说明type: 类型] |
TYPE 支持
YAML 定义 |
Flutter(dart) |
iOS(objectivec) |
Android(kt) |
Web(ts) |
String |
String |
NSString * |
String |
string |
int/long/double/bool |
number |
NSNumber * |
Number |
number |
Map |
Map |
NSDictionary * |
Map |
any |
List<String> |
List<String> |
NSArray<NSString *> |
List<String> |
array<string> |
List<int/long/double/bool> |
List<number> |
NSArray<NSNumber *> |
List<Number/Boolean> |
array<number> |
List<Class> |
List<Class> |
NSArray<Class >
|
List<Class> |
array<Interface> |
Class |
Class |
Class * |
Class |
Interface |
Any |
dynamic |
id |
Any |
any |
number 类型说明 定义上还是用 int/long/double/bool 来定义,但为了数据传输安全,所以各端用 number 类型来承接,且会在注释上会带上当前的精度说明。至于为什么定义上不直接使用 number,这是为 Rust 扩展考虑,Rust 上数值类型是明确精度(比如 f64),并没有提供 number 泛型。
Class 说明为了不增加拆箱复杂度, List<Class> 只能在 Class 中定义,不能直接在 params / callback 中使用。
Any 说明 Any 类型尽量不要使用,前期为了过渡,后续会禁用掉。
示例
####
classes:
- name: UserInfo
note: 用户信息定义
properties:
- { name: userId, note: 用户 ID, type: String }
####
apis:
- name: fetchUserInfo
- note: 获取当前用户信息
- callback:
- name: userInfo
note: 用户信息
type: UserInfo
抽象服务
对 iOS / Android 来说,是能力的实现方。
当前并不会改变以前的 channel 或者 bridge 底层实现形式,只会在这个基础上另外封装。
封装上,因为可以自动生成了,所以不再需要主动注册插件,也不需要写动态调用的代码,直接构建 map 对象注册各个方法转发,且去生成相应的 Service。
Service 提供 Observer 作为需实现的 API。
好处是可以在任意模块、代码监听来提供服务实现。
当然,前期我们还是会把实现层都写在一个模块里统一管理。

调用入口
以 iOS Web 容器为例

在 WKWebview 的 script 消息接收代理中调用我们生成的 GNB
模块的入口 GNBManager.sharedInstance execute:params:completedBlock:
方法即可。

因为 GNB
模块的代码是自动生成的,所以可以无视一些复杂度规范,直接用 if else
来进行判断后直接命中方法,不再需要动态反射等不好维护的解耦手段。
接口代理
上图中,执行入口会通过方法名称命中到 XXXService,这里我们来了解下,service 是如何做的,这也是抽象服务的关键设计。
还是以 windTrack
为例:
@implementation GNBReportService
- (void)windTrack:(NSDictionary *)params completedBlock:(void (^)(NSDictionary *result))completedBlock {
[self notifyObserversWithSelector:@selector(reportProviderDidWindTrack:event:detailInfo:completedBlock:), params[@"eventId"], params[@"event"], params[@"detailInfo"], ^(NSError *error) {
NSDictionary *_result = [GNBUtils resultWithData:@{
} error:error]; // 自动装箱
GDBlockCall(completedBlock, _result);
}];
}
@end
入口命中后会触发一个观察者通知方法,通知给监听者,这里除了模块解耦外,最主要的是做了装箱拆箱,把入参拆箱,把出参装箱,当然这也是自动生成的,所以可以保证它是可靠的。
// MARK: - Observer
@protocol GNBReportServiceObserver <NSObject>
/// 埋点上报
///
/// - Parameter eventId: <String> 事件 ID
/// - Parameter event: <String> 事件定义
/// - Parameter detailInfo: <Map> 详细内容
/// - Parameter completedBlock: 回调
- (void)reportProviderDidWindTrack:(nullable NSString *)eventId event:(nullable NSString *)event detailInfo:(nullable NSDictionary *)detailInfo completedBlock:(void (^)(NSError *error))completedBlock;
@end
使用上,接口实现者实现上述代理即可。
调用组件
调用组件 Web 的比较简单,因为只需要构造 TS interface 即可。相对的 Flutter 较为麻烦,因为 Class <-> Map 是比较重的。
还是以 Web 为例。
调用入口

入口可以根据环境注册不同的 Register,以适应不同的宿主环境(Wap / 小程序 / APP),其中 GNBAppRegister
也是自动生成的,Wap / 小程序的实现代码需要手动补充。
export interface GNBRegister {
/**
* 报告相关
*/
report: GNBReport
...
}
/**
* Gaoding Native Bridge
*/
export class GNB {
private static _register?: GNBRegister
static get report(): GNBReport {
assert(GNB._register, 'GNB 必须注册使用')
assert(GNB._register!.report, 'report 未实现')
return GNB._register!.report
}
...
/**
* 初始化 GNB
* @param register 注册者
*/
static init(register: GNBRegister): void {
GNB._register = register
}
}
调用服务
再顺着调用入口看下来,会生成如下的GNBAppRegister
export class GNBAppRegister implements GNBRegister {
report = {
windTrack(eventId?: string, event?: string, detailInfo?: any): Promise<GNBReportWindTrackResponse> {
return bridge.call('GNB_report/windTrack', {
'eventId': eventId,
'event': event,
'detailInfo': detailInfo,
})
},
}
...
}
服务定义生成在 bridges 文件夹中

代码生成
选择 python 作为开发语言,更为通用。

生成流程上:
- 解析 YAML 生成 DSL Model
- 拷贝资源文件
- 生成 iOS / Android / Web / Flutter 代码
- 构建产物包

具体代码实现不在文章中表述了(掘金不爱大段代码 ~),这里着重讲一下实现难点和思考。
DSL Model
YAML 定义是一种标准的 DSL 语言,但在使用上,用 Map[XXX] 对脚本来言并不好维护,也不够优雅,所以在生成前,我们会做一个 DSL 模型,来承载数据结构。
model.py
class PropertyModel:
...
class GNBAPIModel:
...
class GNBClassModel:
...
class ModuleInfo:
...

简单示意,我们把 YAML 映射到 Model 的过程。
api.py
class API:
...
@staticmethod
def get_modules() -> list[ModuleInfo]:
"""
获取模块信息
"""
modules = []
for file_name in os.listdir(Define.yaml_dir):
with open(Define.yaml_dir + '/' + file_name) as f:
json = yaml.load(f.read(), Loader=yaml.FullLoader)
info = ModuleInfo(file_name, json.get('note'),
json.get('apis'), json.get('classes'))
modules.append(info)
return modules
编码转换
整个生成上,重头戏就是处理各种编码的转换。
首先是类型转换,对基础类型、引用类型、自定义类型进行转换。
这里不同的生成器根据上述 YAML 类型定义,使用不同的类型转换工具方法。
例如 iOS 类型转换工具方法:

其中较为麻烦的是对自定义模型的处理,在装拆箱中需要有相应的 toJSON
/ toModel
方法。
在类型处理之外,还提供了如下的工具方法:
def oc_array_class_type(type: str) -> str:
"""
返回 NSArray<Class> 中的 Class
"""
def oc_to_json(type: str, name: str) -> str:
"""
获取 oc 的序列化
"""
def oc_assign(type: str) -> str:
"""
获取 OC 修饰符
"""
def oc_import(name: str, prefix: str = '\n') -> str:
"""
获取 OC 引用
"""
def oc_property(name: str, type: str, note: str = '', **optional) -> str:
"""
获取 OC 属性行
"""
def oc_protocol(name: str, note: str = '') -> GenContainer:
"""
获取 OC 代理块
"""
def oc_interface(
name: str,
note: str = '',
extends: str = 'NSObject',
) -> GenContainer:
"""
获取 OC interface
"""
def oc_implementation(name: str) -> GenContainer:
"""
获取 OC implementation
"""
def oc_method(name: str,
note: str = 'no message',
params: list[PropertyModel] = [],
callback: list[PropertyModel] = []) -> GenContainer:
"""
获取 OC 方法
"""
def oc_notification_method(name: str,
params: list[PropertyModel] = [],
callback: list[PropertyModel] = []) -> GenContainer:
"""
获取 OC 响应 Observer 方法
"""
def oc_block(callback: list[PropertyModel] = []) -> list[str]:
"""
获取 OC 响应的 Block 值
"""
def oc_assert_required(params: list[PropertyModel] = []) -> list[str]:
"""
获取 OC 必填 Assert
"""
def oc_lazy_getter(name: str, type: str) -> str:
"""
获取 OC 懒加载的 Getter
Args:
name (str): 名称
type (str): 类型
"""
代码格式化
其实有想到用第三方格式化工具,比如 Web 使用 prettier
来格式化生成代码,但现有的就有4种语言,找齐可用的格式化插件有些不现实,特别是 iOS 的格式化。
好在这个项目格式化还不算复杂,提供一些格式化工具方法即可优雅的封装起来。
utils/ios.py
def format_line(line: list[str], prefix='') -> str:
"""
格式化文本行
Args:
line (list[str]): 文本行
prefix (str, optional): 每行前缀. Defaults to ''.
"""
text = f'\n{prefix}'.join(line)
text = text.replace(f'\t', ' ')
return text
在生成上就优雅的多,比如生成 GNBReportService.h 中的定义头:

结合编码转换提供的工具类,这样写即可。
def get_header_methods(self, module: ModuleInfo) -> str:
"""
返回 Service 的方法定义
"""
line = []
for api in module.apis:
line.append(self.get_method_define(api.name) + ';')
return format_line(line, '\n')
产物包

对于不同的环境,使用不同的产物包模版。
iOS:cocoapods
Android:gradle
Flutter:FlutterPlugin
Web:npm
其中 Web 比较特别,我们希望直接依赖产物,所以在生成脚本的最后一句构建产物包中,还会执行响应的 Web 构建命令。
main.sh
# Web build
printf "[gnb-codegen]: web building ...\n"
cd ../../components/gaoding_native_bridge/web/gaoding-native-bridge
yarn
yarn build
直接生成产物到 /lib 中,把整个流程自动化起来。

当然,现在更多的是 monorepo 大仓的形式,所以不会打成远程包,而是采用application-services的方式本地依赖。
后续上也完全可以很简单的指定远端仓库,增加下各个语言的仓库推送命令,生成二方库来使用。
Schema 校验
有心的看官们可能有注意到,如何限定 YAML 的编写呢,这个如果不符合标准,生成出来的东西完全是不可用的。
这里就要介绍下大名鼎鼎的 jsonschema,我们常用的 package.json
也好,pubspec.yaml
也好,都是根据这个规范来检查我们在里面的配置项。
当然,这个不发布也是可以直接使用的。
我们先构建一个 gnb.schema.json 文件

其中比较有意思的就是自定义类型的判断:
"pattern": "^(String|int|long|double|bool|Map|List|List<(String|int|long|double|bool)>|Any|GNB(?:[A-Z][a-z]+)+)$",
可以看到,是通过正则匹配类型是否正确的,而自定义类型就是GNB开头作为类型名称的才可以,也是一种取巧设计。
然后我们在工程中的 .vscode/settings.json 文件进行配置即可生效:
{
...
"yaml.schemas": {
"gnb.schema.json": "*.yaml"
}
}

题外话:jsonshema 也可以用于后端接口参数校验。
生成在线文档
还有架构图上提到的生成文档能力,这个笔者在 Flutter 多引擎渲染组件 已经用 Ruby 实现过一次,这次是用 python 重写(不为别的,就是折腾)。
套路上也差不多,先看下效果:

Docs 在线文档用的 VuePress2 编辑,生成相应的 markdown 文件即可。

生成上比生成代码简单的多,这里不做过多阐述。
总结
这篇文章笔者个人觉得对比前些篇文章会更抽象一些,用的也是 Web 和 iOS 双端举例,限于篇幅,没有 Flutter 和 Android 的代码展示,但原理都是相同的,希望大家能了解到其中的思想 ~
整体方案来说并不只是在通信上的抽象,优势还在于可以很方便的替换底层通信实现。无论是 bridge 还是 channel,甚至可以换成 ffi 或者 protobuf 这样的通信形式,都不会影响上层的服务调用及支撑实现。
后续生成上也会对更多的平台进行支持,比如增加 Rust 的支撑服务,让 Rust 直接与 Web / Flutter 通信,毕竟终端工程师 ~= 全干工程师[手动狗头]。
可能会有同学疑问,这些生成的组件包是怎么通过 monorepo 结合到大仓里的,这里是用了application-services的建设方案,这个后续会另起一篇文章阐述 ~
本方案还在落地过程中,当落地后会把生成代码工具开源共享 ~
感想
本来笔者想靠本文升到创作 Lv4,达成年前定的小目标。但硬靠着前些篇文章的积累就已经达到了 🎉 。
后续写作上,就不不不不参加日更活动了 (o_ _)ノ ,文章上更加精益求精(长篇大论)~ 给自己定的 2023 年目标是 20 篇文章即安好 ~
感谢阅读,如果对你有用请点个赞 ❤️