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