移动端APP组件化架构实践
前言
对于中大型移动端APP开发来讲,组件化是一种常用的项目架构方式。个人最近几年在工作项目中也一直使用组件化的方式来开发,在这过程中也积累了一些经验和思考。主要是来自在日常开发中使用组件化开发遇到的问题以及和其他开发同学的交流探讨。
本文通过以下问题来介绍组件化这种开发架构的思想和常见的一些问题:
为什么需要组件化
组件化过程中会遇到的挑战和选择
如何维护一个高质量的组件化项目
提示:本文说组件化工程是指Multirepo使用独立的git仓库来管理组件。
1. 单一工程架构遇到的问题
多APP项目并存 - 集团内部存在多个APP项目,不同APP希望可以复用现有组件能力快速搭建出新的APP。 功能增多 - 随着项目功能越来越多,代码量增多。同时需要更多的开发人员参与到项目中,这会增加开发团队之间协作的成本。 多语言/多技术栈 - 引入了更多的新技术,例如使用一种以上的跨平台UI技术用于快速交付业务,不同的编程语言、音视频、跨平台框架,增加了整个工程的复杂度。
1.1 工程效率
工程代码量过大会导致编译速度缓慢。 单git工程提交同时可能带来更多的git提交冲突和编译错误。
1.2 质量问题
如何将git提交关联到对应的功能模块需求。发版时进行合规检查避免带入不规范的代码,对整个功能模块回滚的诉求。 如何在单仓库中管控这么多开发人员的代码权限,尽可能避免不安全的提交并且限制改动范围。
1.3 更大范围的组件复用
基础组件从支持单个APP复用到支持多个APP复用。 不只是基础能力组件,对于业务能力组件也需要支持复用。(例如一个页面组件同时在多个APP使用) 跨平台容器需要复用底层组件能力避免重复开发,同时不同跨平台容器API需要尽量保持统一,底层基础设施向容器化发展支持业务跨APP复用。
1.4 跨技术栈通信
由于页面导航多技术栈混合共存,页面路由需要支持跨技术栈。 跨组件通信需要支持跨语言/跨技术栈通信。
1.5 更好的解耦
页面解耦。由于页面导航栈混合共存,页面自身不再清晰的知道上游和下游页面由什么技术栈搭建,所以页面路由需要做到完全解耦隔离技术栈的具体实现。 业务组件间维持松耦合关系,可以灵活添加/移除,基于现有组件能力快速搭建出不同的APP。 对于同一个服务或页面可以插件化方式灵活提供多种不同的实现,不同的APP宿主也可以提供不同的实现并且提供A/B能力。 由于包体积限制和不同组件包含相同符号导致的符号冲突问题,在复用组件的时候需要尽可能引入最小依赖原则降低接入成本。
2. 组件化架构的优势
基于以上这些问题,现在的组件化架构希望可以解决这些问题提升整个交付效率和交付质量。
代码复用 - 功能封装成组件更容易复用到不同的项目中,直接复用可以提高开发效率。并且每个组件职责单一使用时会带入最小的依赖。 降低理解复杂度 - 工程拆分为小组件以后,对于组件使用方我们只需要通过组件对外暴露的公开API去使用组件的功能,不需要理解它内部的具体实现。这样可以帮助我们更容易理解整个大的项目工程。 更好的解耦 - 在传统单一工程项目中,虽然我们可以使用设计模式或者编码规范来约束模块间的依赖关系,但是由于都存放在单一工程目录中缺少清晰的模块边界依然无法避免不健康的依赖关系。组件化以后可以明确定义需要对外暴露的能力,对于模块间的依赖关系我们可以进行强约束限制依赖,更好的做到解耦。对一个模块的添加和移除都会更容易,并且模块间的依赖关系更加清晰。 隔离技术栈 - 不同的组件可以使用不同的编程语言/技术栈,并且不用担心会影响到其他组件或主工程。例如在不同的组件内可以自由选择使用Kotlin或Swift,可以使用不同的跨平台框架,只需要通过规范的方式暴露出页面路由或者服务方法即可。 独立开发/维护/发布 - 大型项目通常有很多团队。在传统单一项目集成打包时可能会遇到代码提交/分支合并的冲突问题。组件化以后每个团队负责自己的组件,组件可以独立开发/维护/发布提升开发效率。 提高编译/构建速度 - 由于组件会提前编译发布成二进制库进行依赖使用,相比编译全部源代码可以节省大量的编译耗时。同时在日常组件开发时只需要编译少量依赖组件,相比单一工程可以减少大量的编译耗时和编译错误。 管控代码权限 - 通过组件化将代码拆分到不同组件git仓库中,我们可以更好地管控代码权限和限制代码变更范围。 管理版本变更 - 我们通常会使用CocoaPods/Gradle这类依赖管理工具来管理项目中所有的组件依赖。因为每一个组件都有一个明确的版本,这样我们可以通过对比APP不同版本打包时的组件依赖表很清晰的识别组件版本特性的变更,避免带入不合规的组件版本特性。并且在出现问题时也很方便通过配置表进行回滚撤回。
提示:组件化架构是为了解决单一工程架构开发中的问题。如果你的项目中也会遇到这些痛点,那可能就需要做组件化。
虽然组件化架构可以带来这么多收益,但不是只要使用组件化架构就可以解决所有问题。通常来讲当我们使用一种新的技术方案解决现有问题的时候也会带来一些新的问题,组件化架构能带来多少收益主要取决于整个工程组件化的质量。那在组件化架构中我们如何去评估项目工程的组件化架构质量,我们需要关注哪些问题。对于软件架构来讲,最重要的就是管理组件实体以及组件间的关系。所以对于组件化架构来讲主要是关注以下三个问题:
如何划分组件的粒度、组件职责边界在哪里? 组件间的依赖关系应该如何管理? 组件间应该使用哪种方式调用和通信?
1. 组件组件拆分的粒度、组件职责边界在哪里?
2. 组件间的依赖关系应该如何管理?
组件间的依赖方式主要分为直接强耦合依赖和间接松耦合依赖。强耦合依赖是对依赖的组件直接使用对应的API进行调用,这种调用方式优点是简单直接性能更好,缺点是一种完全耦合的调用方式。(基础组件通常使用这种方式)。松耦合依赖主要是通过通知、URL Scheme、ObjC Runtime、服务接口、事件队列等通信方式进行间接依赖调用。虽然性能相对差一点,但这是一种相对耦合程度比较低并且灵活的依赖方式。(业务组件通常使用这种方式)
3. 组件间松耦合依赖关系应该使用哪种方式调用和通信?
提示:这里的耦合程度高是相对于耦合程度低的方式进行比较,相比直接依赖对应组件依然是一种耦合程度低的依赖关系。
组件拆分原则 - 拆分思想和最佳实践指导组件拆分 组件间依赖 - 优化组件间依赖关系跨组件调用/通信方式的选择 质量保障 - 避免在持续的工程演化过程中工程质量逐渐劣化。主要包含安全卡口和CI检查
1. 工程实例
2. 组建拆分原则
基础层 - 提供核心的与上层业务无关的基础能力。可以被上层组件直接依赖使用。 业务公共层 - 主要包含页面路由、公共UI组件、跨组件通信以及服务接口,可被上层组件直接依赖使用。 业务实现层 - 业务核心实现层,包含原生页面、跨平台容器、业务服务实现。组件间不能直接依赖,只能通过调用页面路由或跨组件通信组件进行使用。 APP宿主层 - 主要包含APP主工程、启动流程、页面路由注册、服务注册、SDK参数初始化等组件,用于构建打包生成相应的APP。
基础组件依赖业务组件
没有组件分层约束 - 网络库可能会依赖登录服务获取用户信息、依赖定位服务获取经纬度,引入大量的依赖变成业务组件。 有组件分层约束 - 网络库作为一个基础组件,它不需要关注上层业务需要携带哪些公共业务参数,同时登录/定位服务组件在网络库上层不能被反向依赖。这时候会考虑单独创建一个公共参数管理类,在APP运行时监听各种状态的变更并调用网络库更新公共参数/Cookie。
业务组件间依赖方向是否正确
没有组件分层约束 - 可能会在登录服务内当登录状态切换时调用多个业务逻辑的触发,导致登录服务引入多个业务组件依赖。 有组件分层约束 - 登录组件只需要在登录状态切换时发出通知,无需知道登录状态切换会影响哪些业务。业务逻辑应该监听登录状态的变更。
识别基础组件还是业务组件
基础组件 - 如果不需要依赖业务公共层那应当划分为一个基础组件。 业务组件 - 依赖了业务公共层或者网络库,那就应该划分为一个业务组件。
基础组件 - 基础组件可被直接依赖使用,使用方调用基础组件对外暴露API直接使用。基础层、业务公共层都为基础组件。 业务组件 - 业务组件不可被直接依赖使用,只能通过间接通信方式进行使用。APP宿主层和业务实现层都为业务组件。
提示:这里的业务组件并不包含业务UI组件。
2.3 基础组件拆分
使用插件组件拆分基础组件扩展能力
扩展能力会使组件自身代码变得更加复杂。 使用方不一定会使用所有这些扩展能力违反了最小依赖原则。带来更多的包体积,引入更多的组件依赖,增加模块间的耦合度。 相关的扩展能力不支持灵活的替换/插拔。
业务页面拆分方式
基于技术栈进行拆分 - 不同的技术栈需要拆分到不同的组件进行管理。 基于业务域进行拆分 - 将同一个业务域的所有页面拆分一个组件,避免不同业务域之间形成强耦合依赖关系,同一个业务域通常会有更多复用和通信的场景也方便开发。例如订单详情和订单列表可放置在一起管理。 基于页面粒度进行拆分 - 单个页面复杂度过高或需要被单独复用时需要拆分到一个单个组件管理。
提示:放置在单一组件内的多个页面之间也应适当降低耦合程度。
2.5 第三方库
第三方库应拆分单独组件管理
减少使用通用聚合公共组件
添加一个新功能不知道应当加在哪里时,就加到公共聚合组件内,时间久了以后公共组件依赖特别多。 公共组件添加了一个非常复杂的能力,导致复杂度变高或者引入大量依赖 太多能力聚合到一起。例如将网络库、图片库这些能力放在同一个组件内 基础/业务UI组件没有拆分。基础UI组件通常只提供最基础的UI和非常轻量的逻辑,业务组件通常会充当基础UI组件的数据源以及业务逻辑。
是否会引入大量新的依赖 功能复杂度、代码数量,太复杂的不应该添加到公共组件 能力是否需要被单独复用,需要单独复用就不应该添加到公共组件
第三方库考虑不直接对外暴露使用
使用方通常只需要使用少量API,第三方库会对外暴露大量API增加使用难度,同时可能导致一些安全问题 对外隐藏具体实现,方便后续更换其他第三方库、自实现、第三方库发生Break Change变更时升级更容易 需要封装扩展一些能力让使用方使用起来更容易
第三方库尽可能避免直接修改源码
3. 组件间依赖关系
3.1 业务组件间通信方式选择
松耦合通信方式对比
服务接口对应的实现和页面是否需要拆分
统一存放:优点是一起管理更快捷方便。缺点是所有接口对应一个组件版本,不能支持单一接口使用不同版本,不利于需要跨APP复用的项目。并且使用方可能会引入大量无用的接口依赖。 分开存放:优点是每个接口可使用不同的版本并且使用方只需要依赖特定的接口。缺点是会产生更多的组件仓库,组件数量也会增加依赖查找的耗时。
所以大型项目选择分开存放的方式管理接口相对更合适一点。也可以考虑将大部分最核心的服务接口放置到一起管理。
接口需要同时支持Objective-C和Swift调用,同时希望使用Swift特性设计API。如何实现Objective-C和Swift协议可以复用一个实例 Swift对于动态性支持比较弱,纯Swift类无法支持运行时动态创建只能在注册时创建实例
使用Objective-C协议提供最基础的服务能力,之后创建Swift协议扩展提供部分Swift特性的API 接口实现类继承NSObject支持运行时动态初始化
// @objc协议@objc public protocol JDCartService {func addCart(request: JDAddCartRequest, onSuccess: () -> Void, onFail: () ->)}// swift协议public protocol CartService: JDCartService {func addCart() asyncfunc addCart(onCompletion: Result)}// 实现类class CartServiceImp: NSObject, CartService {// 同时实现Objc和Swift协议}服务应该中心化注册还是分布式注册
3.3 组件版本兼容
谨慎使用常量、枚举、宏
基础组件API向后兼容
对外API需保证向后兼容,使用添加API的方式扩展现有能力,避免对原有API进行break change改动或移除 使用对象封装传递参数和回调参数,避免对原有API进行修改
提示:特别是对于Objective-C这类动态调用的语言来讲,打包构建时并不能发现调用的方法不存在、参数错误这些问题。所以我们应当尽可能避免现有方法的变更。同时也推荐更多使用Swift编译器可以发现这些问题提示编译错误。
减少发布大版本
优先选择接口服务减少暴露View类
--使用接口的方法
addressService.chooseAddress { address in}
--使用View的方式
let addressView = AddressView()addressView.callback = { address in///}addressView.show()
避免使用Runtime反射动态调用类
3.4 第三方库
4. 质量保障
4.1 CI检查
组件发布
第三方库不可依赖其他组件 基础组件不可依赖业务组件 业务组件不可直接依赖业务组件 组件间通常不可相互依赖 不允许组件层级间反向依赖
版本集成规范
打包构建
服务接口对应的实现类不存在 服务接口对应的实现类没有实现所有方法 使用ObjC Runtime动态调用类和方法 组件被依赖但是并没有被使用到(基于代码依赖查找)
4.2 线上异常上报
路由跳转对应的页面不存在 接口服务对应的实现类不存在 接口服务对应的方法不存在
4.3 可量化指标
基础组件依赖数量
考虑使用接口服务对外暴露能力,组件层级需要提升 考虑将部分能力拆分出为独立的新组件
业务服务依赖数量
错误依赖关系数量
错误的依赖关系应该及时优化改造。
基础组件应该直接暴露还是使用接口对外暴露
--API直接暴露
功能单一/依赖少 - 一些工具类,例如Foundation API复杂 - API非常多如果使用接口需要抽象太多接口,例如网络库、日志 UI组件 - 需要直接暴露UIView的UI组件,例如UIKit
--接口对外暴露
可扩展性 - 基于接口可以灵活替换不同的实现,例如定位能力可以使用系统自带的API,也可以使用不同地图厂商的API 减少依赖引入 - 降低使用方的接入成本,提高日常开发/组件发布效率 可插拔能力 - 对应的能力可移除,同时也不影响核心业务
4.1 小项目是否应该做组件化
4.2 单一工程如何改造为组件化工程
优先拆分出最核心的所有业务模块可能都需要使用的组件,这些组件拆分完成以后才能为之后业务模块拆分提供基础。例如Foundation、UI组件、网络库、图片库、埋点日志等最基础的组件。 优先拆分不被其他组件依赖或被其他组件依赖较少的模块组件,这些模块相对比较独立拆分起来比较高效并且对现有工程改造较小。例如性能监控、微信SDK这类相对独立的能力。
4.3 组件化带来的额外成本
管理更多的组件git仓库 每次组件发布都需要重新编译/发布 由于组件使用方都是使用相应的组件二进制库,所以调试源码会变得更困难 开发组件管理平台,管理组件版本、版本配置表等能力 每个组件需要有自己的Example工程进行日常开发调试 处理可能存在的组件版本不一致导致的依赖冲突、编译错误等问题 需求可能会涉及到多组件改动,如何进行Code Review、版本合入检查
4.4 Monorepo
编译耗时优化 - 将所有源码放在单个工程中会导致编译变慢,所以必须优化现有工程编译流程,降低非必要的重复编译耗时。 组件版本管理 - 在组件化工程中我们可以通过配置组件的特定版本来管理功能是否合入到版本中,但在Monorepo中只能通过分支Merge Request来管理特性是否合入,回滚也会更加繁琐。 高质量CI流程 - 在单个仓库中,当一个开发者有仓库权限时他就可以修改该仓库的任意代码。所以必须完善代码合入规范,更高标准的Code Review、集成测试检查、自动化检查避免问题代码带到线上。
个人认为并不存在一个完美的架构,我们自身的组织架构、业务、人员都在变动,架构也需要随着这个过程进行适当的调整和重构,最重要的是我们能及时发现架构中存在的问题并且有意愿/能力去调整避免一直堆积变成更大的技术债务。