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