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