前言: 本篇文章是诸葛找房 iOS 技术团队近半年组件化实施之路的经验积累与沉淀, 近半年来我们的组件化经历了从 0 到 1 的质变, 开发方式由原有的多人混合开发逐渐变为了相对独立的分业务线开发, 并且已经初步实现了同一组件在不同项目下的快速集成, 组件化带来的便捷与迅速正在慢慢地向我们铺展开来, 后续充满想象空间. 然而对一个已经在线上运营多年的系统进行组件化, 并不是一条容易的路, 这一路我们升级打怪, 汇集全组人的智慧, 及时的调整组件化结构, 让它不至于走弯路. 今天我们将主要从工程实施的方方面面与大家分享一点我们的见解, 文章较长, 建议大家先收藏哈.
首先简单看下关于组件化选用哪种方案和组件化分层的问题
目前比较流行的大致有 3 种, Router ,Protocol,Target-Action. 我们采用了第三种, 在此要感谢 casa 前辈的智慧与无私贡献. 至于选用哪一种, 不在今天的讨论范围内, 因为无论你打算或者正在使用哪一种, 与今天我们要讲的都没有冲突.
组件化一般分 3 层, 从下至上依次是基础组件, 基础业务组件和业务组件. 其中下层不依赖于上层, 下层的实现对上层是透明的, 上层使用下层提供的服务和接口而不必关心其实现细节, 下层不可随意更改对外的接口, 位于同一层的各个实体之间通过协议进行通讯. 读者可以通过诸葛 iOS 技术演进组件化 https://mp.weixin.qq.com/s/Wmwv4dTVJjWlLYhmCxCUhw 来了解我们的组件化整体架构设计.
一, 从工程的角度, 如何看待组件化?
将一个工程组件化, 就像是重新建造一个结构设计不合理的大楼一样, 这个大楼的各种线路, 各种管道都杂糅在一起, 承重墙和家具东倒西歪, 虽然能提供正常的居住服务, 但是后续对大楼的的改造与装修却很费事. 为了以后能容纳更多人居住, 提供更好的居住体验, 有必要在现在对大楼进行重建. 在改造的过程中, 仍然需要提供正常的居住服务, 因此大楼不能采用爆破的方式完全推到重建, 因为那样的成本过高, 只能在每一次版本迭代中进行组件化, 组件化对用户和市场来说是无感知的才对. 改造的时候是一个房间一个房间的改造, 需要进行房屋的物品归类, 线路拆分, 垃圾倾倒等各种准备工作, 然后就是把所有相关东西都挪出去进行单独改造, 挪出去的时候要保证整栋大楼不会倒塌, 其他的功能不受影响才可以, 挪出去的东西要组装成一间功能独立的屋子, 这个屋子就是一个小的生态系统, 后续的维修与改造都只需要 care 这个屋子就可以; 建好这个屋子后, 需要再把它放回原处, 这间屋子就可以正常住人了.
二, 业务组件拆分的几个步骤
1, 组件预处理
预处理需要在主工程中进行, 预处理的主要目的是为了给第二步组件从主工程抽离达到单独运行铺平道路, 由于预处理发生在主工程中, 因此预处理阶段不需要考虑代码同步的问题, 预处理主要包括以下几个方面:
在主工程中对该组件的所有相关控制器跳转和服务调用进行引用解耦, 如果采用的 target-action 方案, 就都通过 mediator 进行页面跳转和服务调用, 但组件内部可以不必采用这种方式. 类似于拆承重墙之前, 先从别的地方运一些足够结实的柱子过来, 支撑在原来的地方, 保证屋子拆出去后, 大楼不会发生倒塌.
在主工程中将该组件涉及到的文件引用关系梳理清楚, 去掉没有用的引用, 同时对该组件按照统一模板创建对应文件夹. 这里的统一模板基本上跟我们主工程的文件结构一致, 都有自己独立的网络层, 存储层等. 总之需要把要拆出来的组件当做一个独立的项目来看待, 一个项目需要有什么, 这个组件就需要有什么.
按照模板将该组件的所有文件进行重新归类, 在这过程中需要区分哪些文件属于公共的需要下沉的, 哪些是这个组件独有的. 因此这个过程会不断丰富完善公共的基础业务组件.
2, 组件抽离, 编译, 运行
首先可以通过 pod lib create XXX 命令创建一个 pod 工程, 然后配置该组件的 podspec 文件, 指明该组件都需要依赖哪些库.
将经过预处理的的组件抽出来放到 Development Pods 中的 class 中, 将图片资源放到 Asset 中.
给该业务组件设置开发环境变量开关, 更改组件中本地资源如图片, plist 等的加载方式, 设置组件的程序入口等
如果该组件跟别的业务组件有通讯, 那么还需要提供别的业务组件的接口. 如果此时别的业务还没有拆成组件, 那么建议建立一个接口组件, 专门存放那些还没有抽成组件的业务接口和服务.
3, 组件引入
第二步结束, 且组件通过初步测试后, 就可以将组件以开发库的方式引入主工程了, 此时该业务组件不需要进行发版. 关于开发库的引入方式, 下面会再进行介绍.
以上大致的介绍了业务组件拆分的三个大阶段, 接下来我们再看下组件拆分过程中遇到的一些具体问题, 这些问题相信大家在实践中基本都会遇到.
一, 主工程中业务组件的引用方式问题
注: 业务组件可以不必进行 pod 发版, 一是因为业务组件开发中频繁的发版很耗时间, 二是业务组件拥有自己的 tag, 不必通过 pod 版本号进行控制. 可以直接在主工程的 podfile 中以开发库的方式引入业务组件, 开发库一般有两种方式, 分别是 path 和 commitId 方式.
path 方式
简介: path 方式是指业务组件在主工程中以路径的方式引入, 这个路径可以是相对的也可以是绝对的, 一般选用相对路径, 即将所有的组件与主工程都放在一个文件夹下, 这样在主工程中就可以用下面的方式引入业务组件.
如
pod 'ZGAModule', :path=>'../ZGAModule/'
优点: 开发库与远端是同步的, 组件可以直接在主工程里改, 改动后分别在组件里提交即可.
缺点: 所有的开发人员电脑上必须用一套路径一致的文件夹, 想要把主工程运行起来的话, 需要将所有的开发库都下载下来, 否则当别人引用了一个我电脑上没有的组件的时候, 我这里就会报错; update 主工程的时候, 必须依次更新所有的开发库, 可能会有遗漏; 如果使用了 Jenkins 打包, 会污染 Jenkins 环境.
commitId 方式
简介: commitId 方式要求开发库在主工程中以该组件远端仓库地址的方式引入, 可以指定 commitId, 也可以不指定. 不指定 commitId 的话, 就默认始终指向最新的业务代码.
指定 commitId:
pod 'ZGAModule', :Git => 'git@git.zhuge.com:iOS/ZGAModule.git', :commit => '1234567'
不指定 commitId:
pod 'ZGAModule',:Git => 'git@git.zhuge.com:iOS/ZGAModule.git'
或者
pod 'ZGAModule',:Git => 'git@git.zhuge.com:iOS/ZGAModule.git', :branch => 'dev'
优点: 开发人员本地不需要维护一套路径一致的工程文件, 想要运行主工程, 直接 pod install 安装各个组件即可; 不侵入 Jenkins 的环境;
缺点: 开发库与远端不能同步, 组件有改动的话, 只能在组件里改, 不能直接在主工程里改; 如果没有指定 commitId, 那么组件更新后, 主工程只能用 pod update 的方式更新该组件, 速度较慢; 如果指定了 commitId, 那么每次改动组件后都得到主工程的 podfile 中, 修改该组件的 commitId;
我们采用的方案
以上两种方案各有优缺点, 我们综合了这两种方式的优点, 我们整体上采用了第二种方案, 但没有指定具体的 commitId, 这样就会始终指向最新的业务组件; 但 podfile 中所有的业务组件都有一份以 path 方式引入的备份, 只不过在远端这些 path 引用是被注释掉的. 之所以有这些 path 备份, 是为了方便开发人员在本地自由切换, 最大限度的提升开发效率.
针对小的改动, 开发人员可以在主工程中手动将 commitId 方式改成 path 方式, 调试通过后, 再将 podfile 改回去, 然后直接提交对应组件的改动.
针对大的改动, 建议直接在组件中进行开发与提交, 最后在主工程中 update 该组件即可.
其他方式: 对应的可以通过脚本方式进行组件化工程的 install,commit,update 等一系列操作, 或者通过脚本进行两个不同 repo 之间的 merge 操作, 但这样做都需要再开发维护一套脚本, 导致系统复杂性变高.
附: 主工程 podfile 的大致写法
- #******************** 诸葛私有库 ********************
- #远端库
- #A 业务
- pod 'ZGAModule',:Git => 'git@git.zhuge.com:iOS/ZGAModule.git'
- pod 'ZGBModule',:Git => 'git@git.zhuge.com:iOS/ZGBModule.git'
- pod 'ZGCModule',:Git => 'git@git.zhuge.com:iOS/ZGCModule.git'
- # 本地库(远端这些本地库方式一定是被注释掉的, review 的时候如果发现被打开了, 那么可以将本次提交打回去, 或者开发相关脚本进行监测)
- #A 业务
- # pod 'ZGAModule', :path=>'../ZGAModule/'
- # pod 'ZGBModule', :path=>'../ZGBModule/'
- # pod 'ZGCModule', :path=>'../ZGCModule/'
二, 业务组件的开发时机问题
这里我们主要分享下如何在人员有限, 需求不断地情况下处理好组件化与版本迭代之间的时间冲突.
1, 需求开发时间占比和两个生命周期
首先我们提出一个简单的概念, 这个概念大家一听便知: 需求开发时间占比, 也就是版本需求开发时间占当天有效开发总时间的比例, 并给出高 (80%), 中(50%-80%), 低(50% 以下) 三个时间档. 假设我们可以在版本迭代过程中需求开发时间占比为中和低的所有时间段进行组件化开发.
下面我们再看下版本迭代的大致生命周期, 不同公司可能稍微有些差异, 但大致是相似的.
一个业务组件的一般生命周期如下 :
2, 组件的开发时机
组件的开发时机要避开需求开发时间占比较高的时间段, 从上图中可以看出, 组件的开发时间主要是以下两个时间阶段:
版本上线之后至版本新需求开始研发之前
版本整体提测后至版本灰度测试或者临近上线前
3, 组件生命周期不同阶段的时间原则
组件的预处理阶段, 需要在主工程中进行, 不用考虑代码不同步问题, 并且预处理工作 bug 率最低, 在版本生命周期的任何阶段都可以进行.
组件预处理阶段结束后, 需要看版本整体时间是否充分, 不充分的话不要把组件从主工程中抽离出来, 因为组件相关的代码一旦从主工程中抽离出来, 组件化的生命周期就一定要在当前版本的生命周期内, 而不能拖延到下一个, 否则的话需要处理很多代码不同步的问题. 因此预处理工作结束后, 如果时间不够充分, 那就在下个版本, 把该组件从主工程中抽离出来.
新的业务组件不需要考虑预处理和原有代码的抽离工作, 直接在当前版本迭代中开发即可
三, 业务组件测试
为了保证线上服务的稳定性, 需要对新抽离的组件进行多轮测试, 尤其是组件代码跟主工程代码不同步的情况下, 更要加强测试. 可以采用三级测试的方式进行测试:
三级测试
组件单元提测(组件开发好后需要该组件的开发人员对组件进行自测)
组件主工程提测 (自测没有大问题后, 可以通知开发过该业务的人需要进行一轮测试)
版本提测(依赖测试人员进行回归测试)
测试原则:
功能一致: 与线上版本的功能完全一致
代码一致性: 分模块分页面一行一行的捋代码
四, 业务组件化分支合并到 matser
一般的, 组件化分支是单独的一个分支, 由于组件化工程与 master 主工程差距较大, 很多文件被移动, 进行代码合并时必然会产生很多不必要的冲突, 因此组件化工程测试完毕后, 如果不想解决那些冲突, 可以不采取合并到 master 的方式, 而是直接替换 master 上的工程
业务开发直接在当前的组件化分支中开发, 不再从 master 迁出
等组件化基本抽离完成后, 再回到正常的开发方式中
五, 组件后续的开发与维护和组件回滚
关于组件后续的开发与维护
原则上只需要在各自组件工程中进行开发, 不需要依赖主工程.
要保证组件的基础生态环境与主工程的基础生态环境一致;
组件对外暴露的接口一旦成型后, 后续不可以轻易改动, 但是可以增加新接口.
组件改动后, 需要通知主工程的负责人, 更新该组件, 目前没有主动通知的机制, 后续需要进行完善.
组件后续的开发遵循正常的 Git 开发准则和 gerrit 代码 review 准则
关于组件回滚
由于各个组件都是一个个独立的 Git 项目, 目前可以做到针对某一个业务进行代码回滚, 而不必主工程整体回滚, 回滚时直接在主工程中重新指定 commitId 即可
六, 业务组件的粒度(由大变小)
工具性以外的业务组件, 可以不遵循子库的设计方式, 一是它本身已经很大, 都在一个工程里开发容易产生工程文件冲突; 二是业务之间的引用关系由外界决定, 无法肯定两个业务子库之间不会产生引用, 即便通过 mediator 引用, 也不太好, 不如拆成一个一个的小组件
七, 关于业务组件中文件的命名规则
不同项目中引入同一业务组件时, 为了降低组件对主工程的侵入性. 最好对组件中的类进行重命名, 可以采用组件名 + 类名的方式命名. 如新房业务组件下的新房列表, 可以叫做
- ZGNewHouseModuleNewHouselistVC
- .
八, 关于不同项目下同一业务组件的个性化差异解决方案
关于组件的个性化解决方案, 目前可供我们选择的主要有两种, 一个是在组件内部通过环境变量来区分不同端, 另一个是通过 Git 的分支进行管理.
两种方案各有优缺点, 至于选用哪一种方案必须从业务当前的相似性和业务之后的发展趋势(只是同步现有的代码还是同步以后所有的代码, 产品之间的差异), 代码基础环境相似性和代码复杂度, 开发人力等几个方面综合考虑, 具体分析, 不能盲目的选择. 简单看下这两种方案:
关于第一种方案:
组件需要对外暴露一个设置当前 App 类型的接口, 组件保存该类型后, 开发者需要在组件内部有区别的地方通过该 App 类型进行区分, 来展示不同的视图, 提供不同的功能与服务.
优点:
组件只需要有一个分支, 各个项目可以用一个地址引用该组件, 便于管理.
组件修改提交后, 各个业务线都会有更新, 只需要改一次即可.
缺点:
每次有新的 App, 都需要重新定义一个新的 App 类型, 会让组件内部的代码复杂度变高, 后续维护成本变高, 对新人不友好.
修改一个应用的个性化需求后, 存在污染其他应用的可能性.
组件的基础环境可能跟不同项目的基础环境不一致. 如 A 项目要求组件使用 1.1 版本的三方库, B 项目要求组件使用最新的三方库, 由于无法简单的强制不同端的基础环境一致, 这种方式会引起库冲突.
人力分配比较模糊.
关于第二种方案:
业务组件为每一个应用都建立对应的分支, 以 C 端的新房组件为例, master 分支为 C 端的新房, 经纪人端的新房从 master 分出, 可以叫做 newhouse_agent 分支, 开发人员在各个分支中进行组件化的差异性开发. 共性的东西由 master 的维护者开发. 从现有的 C 端实际情况来看的话, 基本不存在个性化分支合并到 master 的情况, 因为经纪人业务不太可能面向 C 端用户, 只存在 master 往别的分支合并的情况, 通过 Git 的代码合并来同步共性业务.
优点:
不同端的个性化需求通过 Git 多分支进行管理, 组件内部, 不需要定义 App 类型, 复杂度低, 维护成本低.
修改一个应用的个性化需求后, 不存在污染其他应用的情况, 因为彼此之间保持独立.
组件在不同应用下的的基础环境可以不一致, 针对不同项目可以有不同的三方库版本, 甚至不同的三方库依赖, 灵活性较高.
共性业务由 master 的开发者, 一般是组件的创建者维护. 个性化需求由对应端的开发者维护, 分工明确. 原则上允许 master 合并到其他分支, 不允许其他分支合并到 master, 其他分支和分支之间可以根据业务需求有选择性的合并. 共性业务同样只需要修改一次即可, 原则上 master 上是个性化最少的组件.
缺点:
一个组件可能会有多个分支, 如果用的不是 master 分支, 那么就必须指定版本号才可以.
组建依赖的相关基础组件可能都需要有调整.
合并过程中可能出现冲突以及一些不需要的功能, 因此对于小的同步, 建议直接手动复制, 粘贴修改, 大的同步可以用 merge 操作, 然后进行微调, 该操作需要进行估时, 纳入排期.
流程如下图所示:
综合分析后, 我们整体采用 Git 分支, 局部采用环境变量的方式进行管理.
九, 其他细节问题
如何使用脚本提升效率, 我们开发或规划了哪些脚本?
zg_pod_upload: 用于简化 pod 库的发版流程, 同时支持组件的本地校验.
zg_pod_initialize: 用于快速创建一个组件化工程, 合并了 pod lib create 的几个命令, 并在对应的 class 文件下, 创建对应的组件模板.
zg_file_filter: 用于组件引入时的文件去重.
zg_file_replace: 文件重命名脚本, 用于批量的对业务组件的相关代码重新命名.
如何处理每个业务组件的三方库初始化?
每个组件负责自己三方库的初始化, 所有的三方库都在各自的业务线中进行初始化, 在主工程壳子中调用各个业务组件对外暴露的 SDK 初始化方法即可.
如果三方库依赖于 bundleID, 那么需要为对应的 bundleID 申请对应的三方库配置 ID 和 Key.
如何处理组件之间的相互跳转?
现有的 Mediator 方案本身就支持带参数, 不带参数, 带返回值, 无返回值, 带 block 回调, 不带 block 回调的调用. 具体可阅读 casa 的系列文章.
业务组件之间如何进行复用?
复用一般分为 UI 复用, Model 复用,
数据与服务复用
.
原则上不提倡通过接口方式进行业务 UI 和 Model 的复用, 如果就是想复用, 可以把对应的 UI 和 Model 下沉到 Base 层之后再复用.
数据与服务可以通过业务接口的形式复用.
业务组件的接口是否需要与业务代码拆开?
业务组件接口与业务代码放到一起太容易发生跨域访问, 后续维护问题多, 因为开发人员可能为了图省事, 不通过业务接口进行通讯, 而直接引入了具体的类文件.
可以把业务组件的接口做成业务组件的子库, 并且约定规范, 业务调用时只允许使用该业务组件的接口组件, 不允许直接使用业务代码组件.
或者单独建立一个仓库, 里面存放所有的业务接口.
如何处理业务组件的图片资源归属问题 ?
业务组件自己维护自己的所有图片, 不需要把所有的图片资源都放到 Base 层, 以免打散后续开发的连贯性.
允许不同业务线之间有重复的图片
不需要将重复的图片单独搞成 pod 库
组件之间出现双向引用或者主库下的子库之间出现横向依赖怎么办?
同一层的组件实体之间通过协议进行解耦, 需要避免出现这种情况
如何让分离出来的代码与主工程保持同步?
总原则是推迟业务组件从主工程中分离出来的时间点
尽可能的在预处理阶段做更多的事情
分离出来后, 就需要做好代码修改记录, 之后手工进行同步了, 这时候应该快速的把它做成 pod 库, 之后就以组件化方式开发
分模块, 分功能进行全链路的回归测试
如何处理域名与多 target 问题?
第一种是在各自的组件中自己维护自己的域名, 分散管理, 缺点是会写一些逻辑相似的代码, 每个组件都需要进行环境配置, 可能会拖慢启动速度
第二种是将域名写在 baseModule 里, 统一管理, 将 target 的逻辑也写在 baseModule 里
如果组件 A 有一部分逻辑没有完全从主工程中抽离出来, 或者组件 A 引用了还没有拆出来的组件 B, 这时候该怎么办?(半组件化)
针对第一种情况以将未完全抽离出来的逻辑写在主工程中 A 对应的 target 里, 在 A 中通过 Mediator 进行调用, 后续再进行不断地拆分
针对第二种情况可以将组件 B 的接口写到
CommonModuleExports
中, 组件 B 的 target 依然放到主工程中, 在 A 中以 Mediator 的方式引用 B 组件, 后续再对 B 组件进行拆分
如何管理通知?
组件内部可以正常使用通知
跨组件的通知需要慎重, 尽量不要习惯性的采用通知.
十, 关于业务组件的评价标准
标准总是要有的, 有了标准, 才会有前进的方向. 然而目前关于如何评价一个业务组件的好坏, 还没有统一的标准. 我们在实践中试着总结了几条, 供大家交流参考.
组件可单独编译与运行, 不需要依赖主工程.
组件横向之间没有侵入性, 组件修改后不会影响跟它位于同一层次的组件, PM 不用担心改了 A, 坏了 B 的问题.
组件在不同项目中具有较高的可移植性. 可以快速的移植到新的项目或者现有的别的项目中, 而不用对别的项目进行较大的改动.
结语
组件化是一个漫长, 繁琐, 复杂但有意义的过程, 是一项团队性的工作, 建议大家在过程当中加强团队成员之间的沟通, 遇到问题及时解决, 及时调整, 定好方向后就只管大胆地往前走. 同时也欢迎大家与我们沟通交流, 希望我们的分享能够在实践中帮到大家! 预祝大家新年快乐~
掘金年度征文 | 2018 与我的技术之路 征文活动正在进行中......
来源: https://juejin.im/post/5c39b9a8e51d457cb97b944a