企业微信 iOS 工程演进之路(一)- 组件化

2022-12-15 17:15:14 浏览数 (2)

本文转载自内部同事分享linkzhong(钟亮)

发表时间 2022年10月24日


导语:本文介绍了企业微信 iOS 端工程近一年的演进方向,随着业务快速迭代企业微信 iOS 端已经成长为一个大型项目,去年我们还接入了腾讯会议、腾讯文档、企业邮箱等插件,为了适应业务的高速发展,我们进行了组件化、插件集成能力建设、Bazel 迁移等工作。

一、背景介绍

随着企业微信业务的快速迭代,企业微信 iOS 客户端工程成长为一个超过 800 万行代码的大型项目。由于 B 端需求多样化,企业微信不可能实现全部 SaaS 功能,多强联合是未来竞争方向上的必选项,我们需要的是一个航母级可以搭载其它业务的平台型 APP。同时企业微信客户端内融合了腾讯会议、腾讯文档、企业邮箱等功能,要融合多个异构系统、支撑多个团队同时协作开发一个 APP 是极大的挑战。

迅速膨胀的代码量和功能模块数量带来了一些新的问题:

  1. 开发编译速度慢,全量编译耗时约 80 分钟,更新代码编译耗时通常超过 20 分钟
  2. Xcode 工程文件体积迅速膨胀,出现工程加载耗时长,修改工程文件卡顿,编写代码时代码提示、断点调试响应慢等问题;
  3. 模块之间耦合严重,相互依赖关系复杂,没有明确的架构分层,导致修改组件内部功能影响其它组件功能的问题,增加了代码的维护难度和测试的工作难度。

面对业务发展带来的问题和挑战,原工程已经不能满足当前需求。在这个背景之下,去年我们启动了企微 iOS 工程专项改造工作,经过一年的努力,完成了企微部分模块组件化、会议/文档/邮箱插件接入、Bazel 工程迁移等工作。

二、组件化

2.1 架构介绍

针对历史架构的缺陷,我们梳理了内部业务模块、基础模块、公共模块之间的关系,还考虑了会议、文档、邮箱插件和企微平台之间的联系,引入了组件管理中心来做组件解耦,提出了企业微信 iOS 架构框架,如下图所示:

架构分为四层,通用层、通用底层、UI框架层、功能模块,其中通用层、通用底层用 C 编写,主要实现网络、db、日志、线程模型等通用能力,以及通用的业务能力接口,可以做到跨 iOS、Android、Mac、Win、linux 5 平台代码复用。 各个平台在通用底层的基础上实现各自的 UI,iOS UI 层用 OC 编写业务组件,组件管理中心 为组件提供生命周期管理、组件间通信、通知管理等能力,插件可以复用各个组件提供的接口,集成到企微的业务中来。

2.2 组件化工作拆解

通过架构梳理,一共梳理出 70 多个组件,其中包含约 1.7 万个源码文件800 万行代码,面对如此庞大的工程,重构工作将会带来不少的开发、测试工作量。我们不可能一蹴而就,一次性完成整个工程的重构和解耦,需要有一套可行的方案来逐步完成。

我们将组件化工作拆解为 4 个阶段:

  1. 基础能力建设:实现组件管理容器,为组件、插件提供生命周期管理、组件间通信、通知监听等基础能力;
  2. 物理目录拆分:根据前期规划的组件,为每个组件新建一个独立文件夹,将属于组件的代码归拢到一处,从物理上实现隔离;
  3. 分析组件之间的依赖关系:依赖关系主要分为两类,组件外部依赖接口和对外暴露的接口。通过梳理依赖关系,我们可以清楚的看到每个组件的耦合程度以及改造的难度和工作量,耦合越严重的组件改造工作量和影响面越大,同时通过依赖关系还能准确的定位到需要改动的代码位置;
  4. 组件拆分:根据依赖分析的结果实施组件化,封装组件对外暴露接口,将组件间调用从直接引用方式改为接口调用方式。

2.3 组件化基础能力建设

如下图所示,组件管理中心 ModuleManager 具备以下能力:

  1. 组件生命周期管理:组件需要在 ModuleManager 注册,并实现相应接口,实现组件初始化逻辑、组件生命周期管理逻辑;
  2. 组件间通信:组件提供对外能力接口,并实现这些接口,组件间可以用通道相互调用;
  3. 系统事件/应用事件通知:系统事件(应用启动、前后台切换、后台应用刷新、收到 APNS等),应用事件(账号切换等)通知机制。组件可以监听相应事件,在事件发生时执行自己的逻辑;
  4. 隐私权限管理:例如手机系统相册权限、定位权限、通讯录权限申请及使用,组件如果需要使用设备隐私相关的权限,需要向组件管理中心申请,统一管理敏感操作;
  5. 多账号数据隔离:多个账号切换时要保证不同账号的数据隔离,由组件管理中心保证不同账号不会串数据。

关于组件间通信方案的选择,已经有不少成熟的组件间通信方案,我们选择了基于协议的服务注册方案。组件间通信模型如下图所示,每个组件对外暴露一组Protocol,然后在组件内部实现对应接口。如果组件A需要调用组件B的接口,首先通过ModuleManager拿到组件B的接口实现对象,然后就可以调用组件B的接口。

以下代码示例展示了一个接口的定义、实现、调用的完整流程。

代码语言:javascript复制
// 文件:WWKUtilityServiceProtocol.h
@protocol WWKUtilityServiceProtocol <NSObject, WWKServiceProtocol>
/// 获取 string 类型的 systemconfig
- (std::string)stringSystemConfigForKey:(NSString *)key;
@end

// 文件:WWKUtilityService.mm
@implementation WWKUtilityService
- (std::string)stringSystemConfigForKey:(NSString *)key {
    return "config";
}
@end

// 调用方
[WWKFindService(WWKUtilityServiceProtocol) stringSystemConfigForKey:@"key"];

2.4 组件目录拆分

完成组件管理中心后,为实施组件解耦,首先要将组件代码从物理路径上分隔开。根据之前架构的梳理,我们将代码分为若干个组件,每个组件为一个独立文件夹,将代码移动到对应目录。挪动文件的物理路径会遇到头文件找不到的编译报错,我们编写了一个工具自动修正头文件路径来辅助完成拆分工作。

2.5 组件依赖关系分析

组件物理目录拆分之后,就要进行代码逻辑的解耦合,如果是一个新项目或小型项目可以直接封装接口。但是企微有大量历史代码要处理,需要一套可行的方案来获取修改列表、评估解耦每个组件的工作量、确定改造工作需要投入多少人力和时间完成,并辅助开发进行修改工作,通过分析组件的依赖关系可以获取到组件代码逻辑解耦列表。

分析组件的依赖关系,我们可以从组件内文件的依赖关系入手,依赖关系分为两种,第一种是组件暴露给其它组件依赖的符号,第二种是组件依赖其它组件的符号,在探索分析依赖关系方案时,我们共想到三种方案,分别是:分析头文件依赖、分析链接日志、解析 AST,前两种方案简单易实现,但是得到的结果精度不够,不能满足我们的需求,最终我们选择了解析AST方案,使用 Clang LibTooling 编写工具,通过解析 AST 来分析依赖关系。下面展开讲讲三个方案的流程及优缺点。

方案一、分析头文件依赖

我们首先想到的方案是解析源码依赖的头文件,解析流程如下图所示。

  1. 执行一次完整的编译,得到编译中间产物“.d文件”,它包含了编译一个文件所需的所有头文件;
  2. 解析“.d 文件”,得到源码文件直接依赖、间接依赖的所有头文件,这里的解析比较简单,用脚本逐行读取就可以完成;
  3. 过滤组件内部头文件、系统 SDK 头文件,得到组件外部依赖的头文件列表,通过分析头文件所属组件得到组件间的依赖关系。

该方案的优点是原理和实现方式比较简单,只需对编译产物进行简单的解析即可得到结果;缺点是得到的数据粒度太粗,依赖关系只能精确到文件,不能精确到具体符号。对于改造工作有一定指导意义,可以得到一个模糊的关系图,细节还得人工筛选一次,不能满足我们的需求。

方案二、分析链接日志

我们在开发过程中经常遇到“Undefined symbols”类型的链接报错:

代码语言:javascript复制
Undefined symbols for architecture arm64:
&nbsp; "_OBJC_CLASS_$_XXX", referenced from:
&nbsp; &nbsp; &nbsp; objc-class-ref in XXX.o
ld: symbol(s) not found for architecture arm64

这个报错原因是链接过程中符号缺失,报错日志会把所有缺失的符号列出来,我们可以利用这个报错信息获得组件链接过程中依赖的符号,间接分析出依赖信息。

举个例子,要分析“组件A”对外依赖、被外部依赖的符号信息,可以按照以下步骤完成:

  1. 构造一个子工程,子工程仅包含“组件A”的代码,工程的产物是一个动态库,由于“组件A”依赖了其它组件的符号,但是其它组件没有参与编译链接,所以在链接时会报错,错误类型是 “Undefined symbols”,用脚本解析日志可以得到“组件A”对外依赖的所有符号;
  2. 同理,将“组件A”源码从主工程中去掉,形成一个子工程,然后编译工程,链接时同样会报错 “Undefined symbols”,用脚本解析报错日志可以得到“组件A”被外部依赖的所有符号;

该方案优点是粒度能精确到具体符号,实现也比较简单,通过构造特殊的工程,解析链接报错日志就能得到结果。缺点是方案不够通用,如果要解析整个工程组件间依赖关系,需要构造大量的子工程,且结论要编译、链接完成后才能得到,效率很低;同时该方案得到的结论粒度不够细,只能精确到符号,没有符号所属源码文件、行号列号等信息,不能满足需求。

最终方案、解析 AST

LibToolingLLVM 工具链里的接口,它提供了强大的 AST 解析和控制能力,用于编写基于 Clang 能力的独立工具。我们可以基于它的 ASTMatcher 编写工具解析源码,得到函数定义、函数调用等信息,从中可以分析出组件的依赖关系。

举个例子演示它的能力,假如我们有下面一段代码,想要提取出其中的函数调用 ModelA *model = [[ModelA alloc] initWithStr:@"AAAAA"];

代码语言:javascript复制
// 示例源码
@implementation Demo
- (void)viewDidLoad {
    [super viewDidLoad];
    ModelA *model = [[ModelA alloc] initWithStr:@"AAAAA"];
}
@end

用下面的 Matcher 语句就可以达到我们的目的

代码语言:javascript复制
// Matcher
objcMethodDecl(
    hasAncestor(
        objcImplementationDecl().bind("myClass")
    ),
    forEachDescendant(
        objcMessageExpr().bind("funcCaller")
    )
).bind("mySelector")

使用工具 clang-query 可以快速验证 matcher 是否符合预期,解析结果如下图所示:

代码语言:javascript复制
clang-query -p /xxx/xxx/compile_commands.json /xxx/xxx/Demo.mm
> set bind-root false
> set print-matcher true
> enable output dump
> set traversal IgnoreUnlessSpelledInSource
> m objcMethodDecl(hasAncestor(objcImplementationDecl().bind("myClass")),forEachDescendant(objcMessageExpr().bind("funcCaller"))).bind("mySelector")

理解了 ASTMatcher 的使用方法,接下来就是编写工具完成解析工作,工具解析流程如下:

  1. 使用 ASTMatcher 编写 Matchers 从 AST 中匹配我们需要的节点,提取出每个文件的函数定义/调用、变量定义/调用、类定义/引用列表,列表中还包含每个符号的代码文本,及所属文件路径,文件行列号等信息;
  2. 比对符号使用文件与符号定义文件所属组件,可以区分是外部依赖符号还是内部符号,从而分析出文件之间的依赖关系,最终汇总成组件间的依赖信息。

最终每个组件会生成两个表格,对外暴露符号和外部依赖符号,如下图所示,表格中包含符号定义的文件路径、行号、列号,使用符号的文件路径、行号、列号,以及符号的定义代码、使用符号的代码等信息。

2.6 组件拆分

完成了组件依赖关系分析之后就可以启动组件拆分工作了,组件拆分工作需要投入大量人力完成,开发同事根据依赖关系输出的表格找到需要改造的代码位置,然后动手封装接口,修改接口调用方式,完成代码逻辑的解耦。

我们选择了依赖相对简单的组件作为试点验证方案的可行性,在实施过程中不断完善方案,逐步完成整个工程的组件化。 在实施过程中我们发现有很大一部分接口属于胶水代码,封装工作简单重复,这类简单的接口可以用工具来生成代码,从而进一步减少人工工作量,这是后续的一个优化方向。

三、插件集成

3.1 背景及方案

企微作为一个平台型 APP ,要具备集成会议、文档、邮箱等多团队协作开发插件的能力,由于这些业务前期不是基于企微架构进行开发,有独立的架构和技术栈。

在组件化的基础上,我们为外部插件提供了集成的能力,将新插件看做一个组件集成到企微 APP 中,插件通过 ModuleManager 调用组件暴露出一系列能力接口,插件也可以在 ModuleManager 注册接口,供其它组件调用。

插件开发涉及到多团队协作,不同开发团队有各自的代码仓库、开发工程、规范流程等,如何融合多个插件、让开发流程更顺畅、高效的运转是一个不小的挑战。

传统的 SDK 开发模式如下图所示,SDK 开发同事一般会写一个 Demo 工程来调试 SDK 功能,开发完成后由集成方接入 SDK,调用 SDK 提供的接口,在集成方工程联调接口。SDK 开发环境对于集成方是无感知的,不会依赖集成方的环境和数据。

这种方式在标准化 SDK 场景下是没有问题的。但是企微在集成会议、邮箱、文档插件时,插件侧要进行深度的业务融合和定制化开发,插件开发同事需要使用企微的账号体系、数据进行调试,很难构造一个 Demo 工程模拟联调环境。

针对这种特殊的合作背景,我们提出了一种新的开发模式,如下图所示,先将企微的核心能力打包为一个 SDK,集成到插件开发壳工程中,插件开发完成后打包成 SDK 集成到企微工程中。通过双向接入对方 SDK 的方式,实现了开发、联调环境的统一。

3.2 插件开发壳工程

为了解决外部插件开发、联调效率问题,我们搭建了一个专门用于插件开发的壳工程,可以做到无企微代码启动企微 APP,具备大部分企微能力,使用真实的环境、数据进行联调。在这个壳工程的基础上就可以开发新的插件。它具备以下特点:

  1. 不依赖企微代码;
  2. 开发联调环境对齐企微主工程;
  3. 工程轻量,编译速度快;
  4. 跨团队协作开发效率高;

工程由插件源码、图片/文案等资源文件、WeComKit、动态库组成。

3.3 WeComKit 介绍

WeComKit 是企微基础能力 SDK,它是插件开发壳工程的核心。它将企微主要能力打包成一个动态库,以 API 的方式暴露接口供外部插件调用,插件通过 ModuleManager 可以调用企微组件的接口。

打包 WeComKit 动态库时遇到一个问题,主工程依赖了部分插件的符号,打包 WeComKit 时不会链接插件的符号,因此会报错 Undefined symbols,需要在链接时使用参数 -undefined dynamic_lookup 开启符号动态查找,可以解决这个问题。

3.4 插件开发流程

插件开发流程如下图所示:

  1. 将主工程组件、组件管理中心、插件、对外能力接口、资源文件等打包为WeComKit
  2. WeComKit、主工程资源文件、主工程依赖的三方动态库接入到壳工程中,在壳工程里开发插件功能;
  3. 插件开发完成后,将代码、头文件、资源文件打包为 PluginFramework,集成到主工程中。

最终为了让流程自动跑起来,我们搭建了两条流水线,分别用于打包 WeComKit 和 PluginFramework,流水线定期执行更新主工程、壳工程里使用的 Framework。

四、小结

在组件化的过程中,我们发现了面对企微这种体量大、需求复杂的工程,传统的 Xcode 工程显得力不从心,有工程卡顿、配置难以维护、工程不够灵活、编译慢等问题。业界常用方案是使用 CocoaPods 来管理组件化工程,但它是针对 Swift 和 Objective-C 设计的,不支持跨平台,无法满足需求,最终我们选择了一条不同的路。

0 人点赞