对于中大型
移动端APP开发来讲,组件化
是一种常用的项目架构方式。个人最近几年在工作项目中也一直使用组件化
的方式来开发,在这过程中也积累了一些经验和思考。主要是来自在日常开发中使用组件化开发遇到的问题以及和其他开发同学的交流探讨。
本文通过以下问题来介绍组件化这种开发架构的思想和常见的一些问题:
提示:本文说的组件化工程是指
Multirepo
使用独立的git仓库来管理组件。
在组件化架构之前,传统使用的工程架构主要是以Monolithic
方式的单一工程架构,也就是将所有代码放在单个代码仓库
里管理。单一工程架构使用了这么多年为什么突然遇到了问题,这也引入了APP项目开发的一个大背景,现有中大型APP
项目变的越来越复杂:
多APP项目并存
- 集团内部存在多个APP
项目,不同APP
希望可以复用
现有组件能力快速搭建出新的APP
。功能增多
- 随着项目功能越来越多,代码量增多。同时需要更多的开发人员参与到项目中,这会增加开发团队之间协作的成本。多语言/多技术栈
- 引入了更多的新技术,例如使用一种以上的跨平台UI
技术用于快速交付业务,不同的编程语言、音视频、跨平台框架,增加了整个工程的复杂度。以上这些业务发展的诉求就给传统单一工程架构
方式带来了很多新的技术要求:
编译速度
缓慢。git
工程提交同时可能带来更多的git提交冲突
和编译错误
。git
提交关联到对应的功能模块需求。发版时进行合规检查避免带入不规范的代码,对整个功能模块回滚
的诉求。单个
APP复用到支持多个
APP复用。基础
能力组件,对于业务
能力组件也需要支持复用。(例如一个页面组件同时在多个APP使用)跨平台容器
需要复用底层组件能力避免重复开发,同时不同跨平台容器API需要尽量保持统一,底层基础设施向容器化
发展支持业务跨APP
复用。跨组件通信
需要支持跨语言/跨技术栈
通信。完全解耦
隔离技术栈的具体实现。添加/移除
,基于现有组件能力快速搭建出不同的APP。A/B
能力。包体积
限制和不同组件包含相同符号导致的符号冲突
问题,在复用组件的时候需要尽可能引入最小依赖原则
降低接入成本。基于以上这些问题,现在的组件化架构希望可以解决这些问题提升整个交付效率
和交付质量
。
组件化架构通常具备以下优点:
代码复用
- 功能封装成组件更容易复用
到不同的项目中,直接复用可以提高开发效率
。并且每个组件职责单一使用时会带入最小的依赖。降低理解复杂度
- 工程拆分为小组件以后,对于组件使用方我们只需要通过组件对外暴露的公开API
去使用组件的功能,不需要理解它内部的具体实现。这样可以帮助我们更容易理解整个大的项目工程。更好的解耦
- 在传统单一工程项目中,虽然我们可以使用设计模式或者编码规范来约束模块间的依赖关系
,但是由于都存放在单一工程目录中缺少清晰的模块边界
依然无法避免不健康的依赖关系。组件化以后可以明确定义需要对外暴露的能力,对于模块间的依赖关系我们可以进行强约束限制依赖,更好的做到解耦。对一个模块的添加和移除都会更容易,并且模块间的依赖关系更加清晰。隔离技术栈
- 不同的组件可以使用不同的编程语言/技术栈
,并且不用担心会影响到其他组件或主工程。例如在不同的组件内可以自由选择使用Kotlin
或Swift
,可以使用不同的跨平台框架,只需要通过规范的方式暴露出页面路由
或者服务方法
即可。独立开发/维护/发布
- 大型项目通常有很多团队。在传统单一项目集成打包时可能会遇到代码提交/分支合并的冲突问题。组件化以后每个团队负责自己的组件,组件可以独立开发/维护/发布提升开发效率。提高编译/构建速度
- 由于组件会提前编译发布成二进制库进行依赖使用,相比编译全部源代码可以节省大量的编译耗时。同时在日常组件开发时只需要编译少量依赖组件,相比单一工程可以减少大量的编译耗时和编译错误。管控代码权限
- 通过组件化将代码拆分到不同组件git
仓库中,我们可以更好的管控代码权限和限制代码变更范围。管理版本变更
- 我们通常会使用CocoaPods/Gradle
这类依赖管理工具来管理项目中所有的组件依赖。因为每一个组件都有一个明确的版本,这样我们可以通过对比
APP不同版本打包时的组件依赖表很清晰的识别组件版本特性的变更,避免带入不合规的组件版本特性。并且在出现问题时也很方便通过配置表进行回滚撤回。提示:组件化架构是为了解决
单一工程架构
开发中的问题。如果你的项目中也会遇到这些痛点,那可能就需要做组件化
。
虽然组件化
架构可以带来这么多收益,但不是只要使用组件化架构就可以解决所有问题。通常来讲当我们使用一种新的技术方案解决现有问题的时候也会带来一些新的问题,组件化架构
能带来多少收益主要取决于整个工程组件化的质量。那在组件化架构
中我们如何去评估项目工程的组件化架构质量,我们需要关注哪些问题。对于软件架构来讲,最重要的就是管理组件实体以及组件间的关系。所以对于组件化架构来讲主要是关注以下三个问题:
某种程度上组件拆分粒度
也是一种平衡的艺术,我们需要在效率
和质量
之间找到一种相对的平衡。组件拆分粒度太粗
:导致组件间耦合紧密,并不能利用更好的复用/解耦/提高编译速度这些优势。组件拆分粒度太细
:导致需要维护更多的组件代码仓库、功能变更可能涉及多个组件代码的修改/发布,这些都会带来额外的成本,同时组件过多也会导致组件依赖查找过程变的更复杂更慢。
组件的职责
也会影响我们对于组件的拆分方式:每个组件的定位是什么,应该包含什么样的功能,是否可以被复用,添加某个功能的时候应该创建新组件还是添加到现有组件,当组件复杂到一定程度时是否需要拆分出新个组件。
在拆分组件前需要提前去思考这些问题。
组件间的依赖方式主要分为直接强耦合依赖
和间接松耦合依赖
。强耦合依赖
是对依赖的组件直接使用对应的API
进行调用,这种调用方式优点是简单直接性能更好,缺点是一种完全耦合的调用方式。(基础组件
通常使用这种方式)。松耦合依赖
主要是通过通知
、URL Scheme
、ObjC Runtime
、服务接口
、事件队列
等通信方式进行间接依赖调用。虽然性能相对差一点,但这是一种相对耦合程度比较低并且灵活的依赖方式。(业务组件
通常使用这种方式)
组件间的依赖关系很重要是因为在长期的项目开发演化过程中很容易形成一种复杂的网状依赖关系
。虽然看似使用组件化的方式将模块拆分成不同的组件,但是组件间可能存在很多相互交叉的依赖耦合关系,很多组件都被其他组件直接依赖
或隐式间接依赖
。这样我们就背离了组件化
架构更好的解耦
、更好的复用
、更快速
的开发/编译/发布的初衷。
所以我们需要制定一套规范去约束和规范组件间的依赖关系:两个组件之间是否可以依赖,组件间依赖方向,选择强耦合依赖还是松耦合依赖。
松耦合依赖
通常可以使用通知
、URL Scheme
、ObjC Runtime
、服务接口
、事件队列
等方式通信进行间接调用,但是使用哪种方式更好业界也有很多争论,并且每种方式都有一些优缺点。通常在项目中会根据不同的使用场景至少
会选择2种通信方式。
耦合程度低的方式例如URL Scheme
,可以做到完全解耦相对比较灵活。但是无法利用编译时检查
、无法传递复杂对象
、调用方/被调用方都需要对参数做大量的正确性检查和对齐
。同时可能无法检测对应的调用方法是否存在。
耦合程度高的方式例如服务接口
,需要对服务接口方法进行强依赖,但是可以利用编译时检查
、传递复杂对象、并且可以更好的支持Swift
特性。
我们需要在解耦程度
、容易使用
、安全
上找到一种合适的方式。
提示:这里的耦合程度高是相对于耦合程度低的方式进行比较,相比
直接依赖
对应组件依然是一种耦合程度低的依赖关系。
基于以上这些组件化架构的问题,需要一些组件化架构
相关的规范和原则帮助我们做好组件化架构,后面主要会围绕以下三点进行介绍:
组件拆分原则
- 拆分思想和最佳实践指导组件拆分组件间依赖
- 优化组件间依赖关系跨组件调用/通信方式的选择质量保障
- 避免在持续的工程演化过程中工程质量逐渐劣化。主要包含安全卡口和CI
检查接下来以一个典型的电商APP
架构案例来介绍一个组件化工程。这个案例架构具备之前所说现有中大型APP架构的一些特点,多组件、多技术栈、业务间需要解耦、复用底层基础组件。基于这个案例来介绍上面的三点原则。
组件拆分最重要是帮我们梳理出组件职责以及组件职责的边界。组件划分也会使用很多通用的设计原则和架构思想。
通常我们可以首先使用分层架构的思想将所有组件
纵向拆分为多层组件
,上面层级的组件只能依赖下面层级的组件。一般至少可以划分为四层组件
:
基础层
- 提供核心的与上层业务无关的基础能力。可以被上层组件直接依赖
使用。业务公共层
- 主要包含页面路由、公共UI组件、跨组件通信以及服务接口,可被上层组件直接依赖
使用。业务实现层
- 业务核心实现层,包含原生页面、跨平台容器、业务服务实现。组件间不能直接依赖
,只能通过调用页面路由
或跨组件通信组件
进行使用。APP宿主层
- 主要包含APP主工程
、启动流程、页面路由注册、服务注册、SDK参数初始化等组件,用于构建打包生成相应的APP
。划分层级可以很好的指导我们进行组件拆分。在拆分组件时我们需要先识别它应该在哪一层,它应该以哪种调用方式被其他组件使用,新添加的功能是否会产生反向依赖
,帮助我们规范组件间的依赖关系。同时按层级拆分组件也有利于底层基础组件的复用
。
以下场景使用分层思想就很容易识别:
例子:APP内业务发起网络请求通常需要携带公共参数/Cookie
。
没有组件分层约束
- 网络库可能会依赖登录服务
获取用户信息、依赖定位服务
获取经纬度,引入大量的依赖变成业务组件。有组件分层约束
- 网络库作为一个基础组件
,它不需要关注上层业务需要携带哪些公共业务参数,同时登录/定位服务组件在网络库上层不能被反向依赖
。这时候会考虑单独创建一个公共参数管理类,在APP运行时监听各种状态的变更并调用网络库更新公共参数/Cookie
。登录状态切换
经常会涉及到很多业务逻辑的触发,例如清空本地用户缓存、地址缓存、清空购物车数据、UI状态变更。
没有组件分层约束
- 可能会在登录服务内当登录状态切换时调用多个业务逻辑的触发,导致登录服务引入多个业务组件依赖。有组件分层约束
- 登录组件只需要在登录状态切换时发出通知,无需知道登录状态切换会影响哪些业务。业务逻辑应该监听登录状态的变更。虽然很多场景下我们很容易能识别处理出来一个功能应该归属于基础
组件还是业务
组件,例如一个UI控件是基础组件还是业务组件。但是很多时候边界又非常的模糊,例如一个添加购物车按键应该是一个基础
组件还是业务
组件呢。
基础组件
- 如果不需要依赖业务公共层那应当划分为一个基础组件。业务组件
- 依赖了业务公共层或者网络库,那就应该划分为一个业务组件。分层思想可以很好的帮助我们管理组件间的依赖关系,并且明确每个组件的职责边界。
划分基础/业务组件主要是为了强制约束组件间的依赖关系。以上面的组件分层架构为例:
基础组件
- 基础组件可
被直接依赖使用,使用方调用基础组件对外暴露API
直接使用。基础层
、业务公共层
都为基础组件。业务组件
- 业务组件不可
被直接依赖使用,只能通过间接通信方式
进行使用。APP宿主层
和业务实现层
都为业务组件。提示:这里的业务组件并不包含
业务UI组件
。
基础组件通常根据职责单一原则进行拆分比较容易拆分,但是会有一些拆分场景需要考虑:
将核心基础能力和扩展能力拆分到不同的组件。以网络库为例,除了提供最核心的接口请求
能力,同时可能还包含一些扩展能力例如HTTPDNS
、网络性能检测
、弱网优化
等能力。但这些扩展能力放在网络库组件内部可能会导致以下问题:
最小依赖原则
。带来更多的包体积,引入更多的组件依赖,增加模块间的耦合度。所以这种场景我们可以考虑根据实际情况将扩展能力拆分到相应的插件组件,使用方需要时再依赖引入对应插件组件。
针对业务页面可以使用技术栈
、业务域
、页面粒度
三种方式进行更细粒度的划分,通常至少要拆分到技术栈
、业务域
这一层级,页面粒度
拆分根据具体页面复杂度和复用诉求。
基于技术栈进行拆分
- 不同的技术栈需要拆分到不同的组件进行管理。基于业务域进行拆分
- 将同一个业务域的所有页面拆分一个组件,避免不同业务域之间形成强耦合依赖关系,同一个业务域通常会有更多复用和通信的场景也方便开发。例如订单详情和订单列表可放置在一起管理。基于页面粒度进行拆分
- 单个页面复杂度过高
或需要被单独复用
时需要拆分到一个单个组件管理。提示:放置在单一组件内的多个页面之间也应适当降低耦合程度。
第三方库应使用独立的组件进行管理,一方面有利于组件复用同时避免多个重复第三方库导致符号冲突,另一方面有利于后续升级维护。
为了避免拆分过多的组件,我们通常会创建聚合组件将一些代码量不多/功能相似
的类放到同一个组件内,例如Foundation组件
、UI组件
。但是很多时候会存在滥用的场景,应当警惕这类公共聚合组件。下面是一些公共聚合组件容易滥用的场景:
但是也不能完全避免使用聚合公共组件,不然会导致产生更多的小组件增加维护成本。但是我们将一个能力添加到公共聚合组件时可以根据以下几个条件来权衡:
当存在以下情况时可考虑对第三方库进行适当的封装避免直接暴露第三方库:
Break Change
变更时升级更容易以网络库为例:1.通常需要对接公司内部的API网关能力所以需要适当做一些封装,例如签名或者加密策略。2.使用方通常只需要用到一个通用的请求方法无需对外暴露太多API。3.为了安全通常需要对业务方隐藏一些方法避免错误调用,例如全局Cookie修改等能力。4.对外隐藏具体第三方库可以方便变更。
第三方库组件尽可能不要直接修改源码,除修复Bug/Crash
之外尽可能避免带入其他功能代码导致后面更新困难。需要添加功能时可以通过在其他组件内使用第三方库对外暴露的API进行能力扩展。
基于以上表格中各种方案的优缺点,个人推荐使用URL Scheme
协议作为页面路由
通信方式,使用服务接口
提供业务功能服务。通知订阅场景可使用通知
或RxSwift
方式提供一对多的订阅能力。
以购物车服务为例,购物车接口服务提供了添加购物车的能力。加车服务具体的实现应该放在购物车页面组件内还是独立出来放置在单独的组件。将购物车服务实现和购物车页面拆分的优点
是购物车服务和购物车页面更好的解耦,都能单独支持复用。缺点
是开发效率降低,修改购物车功能时可能会涉及到同时修改购物车服务
组件和购物车页面
组件。
所以在需要单独复用服务
或页面
的场景时可考虑分别拆分出单个组件(例如购物车服务作为一种通用能力提供给上层跨平台容器能力)。但即使在同一个组件内也建议对服务和页面使用分层设计的方式进行解耦。
一般项目可能至少会有10+个服务接口,这些服务接口应该统一存放在单个组件还是每个接口对应一个组件。
跨APP
复用的项目。并且使用方可能会引入大量无用的接口依赖。Swift
的服务接口实现推荐使用Swift
实现传统的服务接口模式通常会遇到以下两个问题:
Objective-C
和Swift
调用,同时希望使用Swift
特性设计API。如何实现Objective-C
和Swift
协议可以复用
一个实例Swift
对于动态性支持比较弱,纯Swift
类无法支持运行时动态创建只能在注册时创建实例基于以上问题,个人推荐使用下面的方式实现接口服务模式:
Objective-C
协议提供最基础的服务能力,之后创建Swift
协议扩展提供部分Swift
特性的APINSObject
支持运行时动态初始化// @objc协议
@objc public protocol JDCartService {
func addCart(request: JDAddCartRequest, onSuccess: () -> Void, onFail: () ->)
}
// swift协议
public protocol CartService: JDCartService {
func addCart() async
func addCart(onCompletion: Result)
}
// 实现类
class CartServiceImp: NSObject, CartService {
// 同时实现Objc和Swift协议
}
中心化注册是在宿主APP
启动时统一注册服务接口的对应实现实例,分布式注册是在组件内组件自身进行注册。个人推荐中心化注册的方式在宿主APP
启动时统一进行注册管理,明确服务的实现方更清晰,同时避免不同组件包含同一个服务接口的不同实例导致的冲突。
因为组件编译发布的时候会生成二进制库,编译器会将依赖的常量、枚举、宏
替换成对应的值或代码
,所以当后续这些常量、枚举、宏
发生变更的时候,已生成的二进制库并不会改变导致打包的时候依然使用的旧值,必须重新发布使用这些值的组件才行。所以应当尽量避免修改常量、枚举、宏
值,如果已知后续可能会变更的情况下应避免使用常量、枚举、宏
。
添加API
的方式扩展现有能力,避免对原有API进行break change
改动或移除原有API
进行修改提示:特别是对于
Objective-C
这类动态调用的语言来讲,打包构建时并不能发现调用的方法不存在、参数错误这些问题。所以我们应当尽可能避免现有方法的变更。同时也推荐更多使用Swift
编译器可以发现这些问题提示编译错误。
以Cocoapods
为例,组件发布大版本会导致依赖此组件的所有组件都必须同时升级到大的版本重新发布,这样会给组件使用放带来极大的更新成本。所以组件应该减少发布大版本,除非必须强制所有组件一定要升级。
接口服务
减少暴露View类
当只关注API
提供的能力并不关注API
提供的形态时尽可能通过API
的方式来暴露能力。因为暴露接口方法相比视图View,调用方只需要依赖接口方法相比依赖View类可以更小化的依赖,同时接口对于实现方未来扩展能力更灵活。以选择用户地址API
为例,通常调用方并不关注实现方以弹窗
的方式还是页面
的方式提供交互能力让用户选择,只关注用户最终选择的地址数据。并且调用方不需要处理弹窗和页面的展示逻辑使用起来更方便,也便于实现方之后修改交互方式。
addressService.chooseAddress { address in
}
let addressView = AddressView()
addressView.callback = { address in
///
}
addressView.show()
Runtime
反射动态调用类应当尽量避免使用反射
机制在运行时使用其他类,因为类的实现方不能保证这个类一直存在,编译器也无法检测出错误。某些基于AOP
的功能可能会使用到这种动态反射能力,但是大部分场景应该尽量避免。
第三方库组件不允许依赖其他组件。
虽然前面讲到了很多规范和原则,但是并不能保证我们的这些规范和原则可以强制执行。所以我们需要在组件发布和应用打包阶段添加一些卡口安全检测,及时发现组件化依赖问题避免带入线上。
在组件发布时添加一个安全检查,避免不符合依赖规范的组件发布成功。通常我们可以添加以下依赖检查规则:
集成系统需要将特定需求和组件版本关联到一起,打包时会根据版本需求自动加入对应的组件版本。避免开发同学直接修改组件版本引入不应该加入到版本的特性。
在宿主APP打包时,提前检测出接口服务存在的问题,避免带入到线上。通常可以检测以下问题:
ObjC Runtime
动态调用类和方法线上检查可以帮助我们在灰度发布的及时发现问题及时修复,通常可以发现以下问题:
我们可以通过一些指标来量化整个工程组件化的健康程度,以下列出常见的一些指标:
组件依赖的所有基础组件总数,当依赖的基础组件总数过高时应该及时进行重构。如果大量的业务组件都需要依赖非常多的基础组件,那可能说明基础组件的依赖关系出现了很大的问题,这时候需要对基础组件进行优化重构:
业务组件对其他业务服务组件的依赖数量。当业务组件依赖了其他业务服务调用时也会造成隐式的耦合关系,依赖过多时应当考虑是否应该对外暴露可监听变化的通知订阅以订阅观察
的方式替代主动调用
错误的依赖关系应该及时优化改造。
基础组件应该直接使用头文件API
暴露还是使用接口
间接暴露有时候很难权衡,但是可以根据一些特性来权衡选择:
功能单一/依赖少
- 一些工具类,例如Foundation
API复杂
- API非常多如果使用接口需要抽象太多接口,例如网络库
、日志
UI组件
- 需要直接暴露UIView
的UI组件
,例如UIKit
可扩展性
- 基于接口可以灵活替换不同的实现,例如定位能力可以使用系统自带的API,也可以使用不同地图厂商的API减少依赖引入
- 降低使用方的接入成本,提高日常开发/组件发布效率可插拔能力
- 对应的能力可移除,同时也不影响核心业务个人认为小项目也可以做组件化,需要关注的是需要做到什么程度的组件化。通常来讲越大型越复杂
的项目组件化拆分的粒度更细组件数越多
。对于小项目来讲虽然早期做组件化的收益并不大,也需要适当考虑未来的发展趋势预留一定的空间,同时也需要适当考虑模块间的依赖关系避免后期拆分模块时很困难。刚开始做粒度比较粗的组件化,之后在项目发展中不断的调整组件化的粒度。也可以考虑使用类似Monorepo
的方式来管理项目,代码都在一个仓库中管理,通过文件夹隔离规范模块间的依赖。
一般来讲我们需要使用循序渐进逐步重构的策略对原有项目进行改造,但是有一些模块可以优先被组件化拆分降低整个组件化的难度:
Foundation
、UI组件
、网络库
、图片库
、埋点日志
等最基础的组件。不被其他组件依赖
或被其他组件依赖较少
的模块组件,这些模块相对比较独立拆分起来比较高效并且对现有工程改造较小。例如性能监控
、微信SDK
这类相对独立的能力。组件化架构可能会带来以下这些额外的成本:
git
仓库Example
工程进行日常开发调试Code Review
、版本合入检查
我个人并没有在实际的项目中使用过Monorepo
方式管理项目。Monorepo
是将所有组件代码放在单个git
仓库内管理,然后使用文件夹拆分为不同的组件。不同文件夹中的代码不能直接依赖使用,需要配置本地文件夹的组件依赖关系,在实现组件化的同时避免拆分太多的git
仓库。不过个人认为Monorepo
同时也需要解决以下几个问题:
编译耗时优化
- 将所有源码放在单个工程中会导致编译变慢,所以必须优化现有工程编译流程,降低非必要的重复编译耗时。组件版本管理
- 在组件化工程中我们可以通过配置组件的特定版本来管理功能是否合入到版本中,但在Monorepo
中只能通过分支Merge Request
来管理特性是否合入,回滚也会更加繁琐。高质量CI流程
- 在单个仓库中,当一个开发者有仓库权限时他就可以修改该仓库的任意代码。所以必须完善代码合入规范,更高标准的Code Review
、集成测试检查
、自动化检查
避免问题代码带到线上。个人认为并不存在一个完美的架构,我们自身的组织架构、业务、人员都在变动,架构也需要随着这个过程进行适当的调整和重构,最重要的是我们能及时发现架构中存在的问题并且有意愿/能力去调整避免一直堆积变成更大的技术债务。
同时工程架构的改变也会一定程度的改变开发人员的分工,对于大型工程来讲组件化的程度更高,每个开发人员的工作分工会更细。对于底层基础组件的开发,需要提供更多高性能/高质量的基础组件让上层业务开发人员更加效率的支撑业务,技术深度也会更加深入。对于上层业务开发,更多是使用这些底层基础组件,同时可能也需要掌握多种跨端UI技术栈快速支撑业务,技术栈会更广但是不会太深入。
作者:京东零售 何骁
来源:京东云开发者社区