阅读视图

发现新文章,点击刷新页面。

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

本文当时为了专利过审下架了,现在放回来 ~

本文写作于兔年之前,心境上却是有诸多感叹,大名鼎鼎的网络库 AFNetworking 停止维护了,iOS 开发早已是昨日黄花,移动端也从夕阳走向落幕了 ~

lQLPJxf-k1hlNyzNAaPNA7Gw8JmC9IJ2K5gDwlduWYCXAA_945_419.png

认清现实,摆正心态,拥抱变化,跨端开发确实比原生开发更值得卷 (o_ _)ノ 。作为一个码了10年的老派程序员,卷也要选择优雅的卷。Flutter \ Web \ Rust and so on ... 各种花里胡哨的跨端语言,怎么有机的结合在一起,这确实是一门学问。

起因

言归正传 ~

从小故事开始

我有个薛定谔状态的朋友,他入职了一家公司里做 iOS 开发,一天他接到一个业务需求,需要配合 Web 开发修改一下 windTrack 埋点桥接实现,通过对代码进行字符串搜索,他发现在不同模块里有4个可能是 windTrack 桥接的插件,但好在是3个已经被标记成过时,看起来他只需要处理未过时的那个即可。

故事嘛都有戏剧性,在修改打包后提供给 Web 开发同学测试时发现,修改并没有生效 ... 多方查找问题后发现,windTrack桥接最终会调用到一个标记过时的方法里 ...

绝望难过伤心动态表情包.gif

问题的本质

后续这个朋友如何处理暂且不表,我们来聊聊为什么会出现这样的问题?

本质上是可维护性的缺失,为什么项目里会存留4个具有相同能力的方法呢?因为它经手了 N(N > 4)位开发同学,这在业务快速增长时期,确实是存在的现象,有人在维护、有人在新增、有人在重构...懂得都懂。

思维升华

靠人约束规范是不可控的,加再多的 Code Review 流程也是不可控的,因人而异就会因人而劣化,直至规范形同虚设。这也包括维护文档这件事,理论上每个跨端桥接都要求有完善的使用文档,但现实上,文档的约束比代码更难,毕竟也没有 Docs Review 流程。

分析

我们虽然明了问题的本质,但还是需要具体的分析问题、解决问题。

现有开发方式

以 iOS - Web 通信桥接为例。

例:iOS 实现

底层调用上,基本都是通过 WKWebviewWKScriptMessageHandler 代理来监听 web -> iOS 的调用,通过主动调用 JS callbackId 的方式来回调消息。

部分代码截图:

image.png

image.png

从收到的 message.body 解析出调用的方法名、方法参数以及回调的 callbackId 等字符串。

然后我们都会或多或少的封装下,用各种方式找到具体的实现方法。这里就不展开讲了,基本就是用配置的方式、用代码反射的方式这样来做解耦。

像笔者公司上,就是用的代码反射,如下图:

image.png

实现方法上就可以转变为:

- (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)

剩下的过程开发都不需要接入,都有工具链进行生成。

解决方案

实现效果

先看下最终实现的效果。

image.png

如上图,只要定义 YAML,就会根据上述定义,来生成多端的使用代码。

再来看看 iOS 和 Web 端生成的效果

iOS 支撑服务效果

生成的 iOS 组件库

image.png

这里只需开发接口实现层

@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 组件

image.png

注册后即可使用

GNB.init(new GNBAppRegister());
...
GNB.report.windTrack(...);

可以看到,流程上就转变为:全局定义 + 各端具体实现 API + 各端调用 API。大大的增强可维护性,间接的降低了人力成本,又能为老板省钱了[手动狗头]。

总体架构

1.png

专有名词解释

[GNB] Gaoding Native Bridge,稿定本地通信方案

上图是从能力视角描述了我们实现了什么,下图是从流程视角,来表明整个过程是如何运转起来的。

image.png

详细设计

定义规范

组件定义采用 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。

好处是可以在任意模块、代码监听来提供服务实现。

当然,前期我们还是会把实现层都写在一个模块里统一管理。

image.png

调用入口

以 iOS Web 容器为例

image.png

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

image.png

因为 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 为例。

调用入口
image.png

入口可以根据环境注册不同的 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 文件夹中

image.png

代码生成

选择 python 作为开发语言,更为通用。

image.png

生成流程上:

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

image.png

具体代码实现不在文章中表述了(掘金不爱大段代码 ~),这里着重讲一下实现难点和思考。

DSL Model

YAML 定义是一种标准的 DSL 语言,但在使用上,用 Map[XXX] 对脚本来言并不好维护,也不够优雅,所以在生成前,我们会做一个 DSL 模型,来承载数据结构。

model.py

class PropertyModel:
    ...
class GNBAPIModel:
    ...
class GNBClassModel:
    ...
class ModuleInfo:
    ...
image.png

简单示意,我们把 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 类型转换工具方法:

image.png

其中较为麻烦的是对自定义模型的处理,在装拆箱中需要有相应的 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 中的定义头:

image.png

结合编码转换提供的工具类,这样写即可。

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')
产物包

image.png

对于不同的环境,使用不同的产物包模版。

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 中,把整个流程自动化起来。

image.png

当然,现在更多的是 monorepo 大仓的形式,所以不会打成远程包,而是采用application-services的方式本地依赖。

后续上也完全可以很简单的指定远端仓库,增加下各个语言的仓库推送命令,生成二方库来使用。

Schema 校验

有心的看官们可能有注意到,如何限定 YAML 的编写呢,这个如果不符合标准,生成出来的东西完全是不可用的。

这里就要介绍下大名鼎鼎的 jsonschema,我们常用的 package.json 也好,pubspec.yaml 也好,都是根据这个规范来检查我们在里面的配置项。

当然,这个不发布也是可以直接使用的。

我们先构建一个 gnb.schema.json 文件

image.png

其中比较有意思的就是自定义类型的判断:

"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"
  }
}

image.png

题外话:jsonshema 也可以用于后端接口参数校验。

生成在线文档

还有架构图上提到的生成文档能力,这个笔者在 Flutter 多引擎渲染组件 已经用 Ruby 实现过一次,这次是用 python 重写(不为别的,就是折腾)。

套路上也差不多,先看下效果:

image.png

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

image.png

生成上比生成代码简单的多,这里不做过多阐述。

总结

这篇文章笔者个人觉得对比前些篇文章会更抽象一些,用的也是 Web 和 iOS 双端举例,限于篇幅,没有 Flutter 和 Android 的代码展示,但原理都是相同的,希望大家能了解到其中的思想 ~

整体方案来说并不只是在通信上的抽象,优势还在于可以很方便的替换底层通信实现。无论是 bridge 还是 channel,甚至可以换成 ffi 或者 protobuf 这样的通信形式,都不会影响上层的服务调用及支撑实现。

后续生成上也会对更多的平台进行支持,比如增加 Rust 的支撑服务,让 Rust 直接与 Web / Flutter 通信,毕竟终端工程师 ~= 全干工程师[手动狗头]。

可能会有同学疑问,这些生成的组件包是怎么通过 monorepo 结合到大仓里的,这里是用了application-services的建设方案,这个后续会另起一篇文章阐述 ~

本方案还在落地过程中,当落地后会把生成代码工具开源共享 ~


感想

本来笔者想靠本文升到创作 Lv4,达成年前定的小目标。但硬靠着前些篇文章的积累就已经达到了 🎉 。

后续写作上,就不不不不参加日更活动了 (o_ _)ノ ,文章上更加精益求精(长篇大论)~ 给自己定的 2023 年目标是 20 篇文章即安好 ~


感谢阅读,如果对你有用请点个赞 ❤️

中秋节GIF动图引导在看提示.gif
❌