查看原文
其他

企业微信零耦合集成腾讯会议和腾讯文档插件化架构实践

钟亮 腾讯云开发者 2023-05-15

导语 | 企业微信 iOS 端作为代码超过800万行的大型项目,接入了腾讯会议、腾讯文档、企业邮箱等功能插件。要融合多个异构系统、支撑多个团队同时协作开发一个 APP 是极大的挑战。同时,迅速膨胀的代码量和功能模块数量给企微团队带来了编译耗时大增、模块耦合严重等负担。为了适应业务的高速发展,企微团队进行了组件化、插件集成能力建设工作。本文将进行详细介绍。

目录

1 问题与挑战

2 组件化探索与实践

  2.1 架构介绍

  2.2 组件化工作拆解

  2.3 组件化基础能力建设

  2.4 组件目录拆分

  2.5 组件依赖关系分析

3 插件集成

     3.1  背景及方案

     3.2  插件开发壳工程

     3.3  WeComKit 介绍

     3.4  插件开发流程

4 总结思考


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

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

面对业务发展带来的问题和挑战,原工程已经不能满足当前需求。在这个背景之下,去年企微团队启动了企微 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 个阶段:
第一阶段,基础能力建设:实现组件管理容器,为组件、插件提供生命周期管理、组件间通信、通知监听等基础能力;第二阶段,物理目录拆分:根据前期规划的组件,为每个组件新建一个独立文件夹,将属于组件的代码归拢到一处,从物理上实现隔离;第三阶段,分析组件之间的依赖关系:依赖关系主要分为两类,组件外部依赖接口和对外暴露的接口。通过梳理依赖关系,企微团队可以清楚看到每个组件的耦合程度以及改造的难度和工作量,耦合越严重的组件改造工作量和影响面越大,同时通过依赖关系还能准确定位到需要改动的代码位置;第四阶段,组件拆分:根据依赖分析的结果实施组件化,封装组件对外暴露接口,将组件间调用从直接引用方式改为接口调用方式。

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

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

以下代码示例展示了一个接口的定义、实现、调用的完整流程。
// 文件: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 来分析依赖关系。下面展开讲讲三个方案的流程及优缺点。

方案一:分析头文件依赖
企微团队首先想到的方案是解析源码依赖的头文件,解析流程如下图所示。

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

编译中间产物示意图:

.d 文件内容示意图:

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

方案二:分析链接日志
企微团队在开发过程中经常遇到“Undefined symbols”类型的链接报错:
Undefined symbols for architecture arm64:&nbsp; "_OBJC_CLASS_$_XXX", referenced from:&nbsp; &nbsp; &nbsp; objc-class-ref in XXX.old: symbol(s) not found for architecture arm64

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

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

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

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

最终方案:解析 AST。LibTooling 是 LLVM 工具链里的接口,它提供了强大的 AST 解析和控制能力,用于编写基于 Clang 能力的独立工具。企微团队可以基于它的 ASTMatcher 编写工具解析源码,得到函数定义、函数调用等信息,从中可以分析出组件的依赖关系。

举个例子演示它的能力,假如企微团队有下面一段代码,想要提取出其中的函数调用 ModelA *model = [[ModelA alloc] initWithStr:@"AAAAA"];
// 示例源码@implementation Demo- (void)viewDidLoad { [super viewDidLoad]; ModelA *model = [[ModelA alloc] initWithStr:@"AAAAA"];}@end

用下面的 Matcher 语句就可以达到企微团队的目的。
// MatcherobjcMethodDecl( hasAncestor( objcImplementationDecl().bind("myClass") ), forEachDescendant( objcMessageExpr().bind("funcCaller") )).bind("mySelector")

使用工具 clang-query 可以快速验证 matcher 是否符合预期,解析结果如下图所示:
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 的使用方法,接下来就是编写工具完成解析工作。工具解析流程如下:首先,使用 ASTMatcher 编写 Matchers 从 AST 中匹配企微团队需要的节点,提取出每个文件的函数定义/调用、变量定义/调用、类定义/引用列表,列表中还包含每个符号的代码文本,及所属文件路径,文件行列号等信息;然后,比对符号使用文件与符号定义文件所属组件,可以区分是外部依赖符号还是内部符号,从而分析出文件之间的依赖关系,最终汇总成组件间的依赖信息。

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

6)组件拆分

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

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

插件集成

3.1 背景及方案

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

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

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

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

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

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

3.2 插件开发壳工程

为了解决外部插件开发、联调效率问题,企微团队搭建了一个专门用于插件开发的壳工程,可以做到无企微代码启动企微 APP,具备大部分企微能力,使用真实的环境、数据进行联调。在这个壳工程的基础上就可以开发新的插件。它具备以下特点:不依赖企微代码;开发联调环境对齐企微主工程;工程轻量,编译速度快;跨团队协作开发效率高。

壳工程如下图所示,工程由插件源码、图片/文案等资源文件、WeComKit、动态库组成。

3.3 WeComKit 介绍

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

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

3.4 插件开发流程

插件开发流程如下图所示:首先,将主工程组件、组件管理中心、插件、对外能力接口、资源文件等打包为 WeComKit;其次,将 WeComKit、主工程资源文件、主工程依赖的三方动态库接入到壳工程中,在壳工程里开发插件功能;最后,插件开发完成后,将代码、头文件、资源文件打包为 PluginFramework,集成到主工程中。


最终为了让流程自动跑起来,企微团队搭建了两条蓝盾流水线。它们分别用于打包 WeComKit 和 PluginFramework。值得一提的是,流水线定期执行更新主工程、壳工程里使用的 Framework。

总结思考
在组件化的过程中,企微团队发现了面对企微这种体量大、需求复杂的工程,传统的 Xcode 工程显得有些力不从心。它有程卡顿、配置难以维护、工程不够灵活、编译慢等问题。业界常用方案是使用 CocoaPods 来管理组件化工程,但它是针对 Swift 和 Objective-C 设计的,不支持跨平台,无法满足需求,最终企微团队选择了一条不同的路。如果您感兴趣,欢迎留言并持续关注本公众号,我们将持续输出系列内容。


兔年在即
腾讯云开发者福利加码
独家限量红包封面来啦
关注腾讯云开发者公众号
后台回复【888】
参与福兔红包封面抽奖



你可能感兴趣的腾讯工程师作品
| 你的2022年度开发者关键词,请查收>>
React语境下前端DDD的长年探索经验| 国民级应用:微信是如何防止崩溃的?| 从Linux零拷贝深入了解Linux-I/O技术盲盒:前端后端AI与算法运维工程师文化

🔹关注我并点亮星标🔹

工作日晚8点 看腾讯技术、学专家经验
点赞|分享|在看 传递好技术


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存