本文写作于兔年之前,心境上却是有许多感叹,大名鼎鼎的网络库 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 篇文章即安好 ~
感谢阅览,假如对你有用请点个赞 ❤️