本文写作于兔年之前,心境上却是有许多感叹,大名鼎鼎的网络库 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 完成

底层调用上,根本都是经过 WKWebviewWKScriptMessageHandler 署理来监听 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 作为开发言语,更为通用。

跨端通信终结者|看我是如何保证多端消息一致性的

生成流程上:

  1. 解析 YAML 生成 DSL Model
  2. 拷贝资源文件
  3. 生成 iOS / Android / Web / Flutter 代码
  4. 构建产品

跨端通信终结者|看我是如何保证多端消息一致性的

详细代码完成不在文章中表述了(不爱大段代码 ~),这儿着重讲一下完成难点和考虑。

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 篇文章即安好 ~


感谢阅览,假如对你有用请点个赞 ❤️

跨端通信终结者|看我是如何保证多端消息一致性的