2018,我们的组件化实施之路 | 掘金年度征文

7,078 阅读22分钟

前言:本篇文章是诸葛找房 iOS 技术团队近半年组件化实施之路的经验积累与沉淀,近半年来我们的组件化经历了从0到1的质变,开发方式由原有的多人混合开发逐渐变为了相对独立的分业务线开发,并且已经初步实现了同一组件在不同项目下的快速集成,组件化带来的便捷与迅速正在慢慢地向我们铺展开来,后续充满想象空间。然而对一个已经在线上运营多年的系统进行组件化,并不是一条容易的路,这一路我们升级打怪,汇集全组人的智慧,及时的调整组件化结构,让它不至于走弯路。今天我们将主要从工程实施的方方面面与大家分享一点我们的见解,文章较长,建议大家先收藏哈。

首先简单看下关于组件化选用哪种方案和组件化分层的问题

  • 目前比较流行的大致有3种,RouterProtocolTarget-Action.我们采用了第三种,在此要感谢casa前辈的智慧与无私贡献。至于选用哪一种,不在今天的讨论范围内,因为无论你打算或者正在使用哪一种,与今天我们要讲的都没有冲突。
  • 组件化一般分3层,从下至上依次是基础组件、基础业务组件和业务组件。其中下层不依赖于上层,下层的实现对上层是透明的,上层使用下层提供的服务和接口而不必关心其实现细节,下层不可随意更改对外的接口,位于同一层的各个实体之间通过协议进行通讯。读者可以通过诸葛 iOS 技术演进组件化来了解我们的组件化整体架构设计。

一、从工程的角度,如何看待组件化?

将一个工程组件化,就像是重新建造一个结构设计不合理的大楼一样,这个大楼的各种线路、各种管道都杂糅在一起,承重墙和家具东倒西歪,虽然能提供正常的居住服务,但是后续对大楼的的改造与装修却很费事。为了以后能容纳更多人居住、提供更好的居住体验,有必要在现在对大楼进行重建。在改造的过程中,仍然需要提供正常的居住服务,因此大楼不能采用爆破的方式完全推到重建,因为那样的成本过高,只能在每一次版本迭代中进行组件化,组件化对用户和市场来说是无感知的才对。改造的时候是一个房间一个房间的改造,需要进行房屋的物品归类、线路拆分、垃圾倾倒等各种准备工作,然后就是把所有相关东西都挪出去进行单独改造,挪出去的时候要保证整栋大楼不会倒塌,其他的功能不受影响才可以,挪出去的东西要组装成一间功能独立的屋子,这个屋子就是一个小的生态系统,后续的维修与改造都只需要care这个屋子就可以;建好这个屋子后,需要再把它放回原处,这间屋子就可以正常住人了。

二、业务组件拆分的几个步骤

1、组件预处理

预处理需要在主工程中进行,预处理的主要目的是为了给第二步组件从主工程抽离达到单独运行铺平道路,由于预处理发生在主工程中,因此预处理阶段不需要考虑代码同步的问题,预处理主要包括以下几个方面:

  • 在主工程中对该组件的所有相关控制器跳转和服务调用进行引用解耦,如果采用的target-action方案,就都通过mediator进行页面跳转和服务调用,但组件内部可以不必采用这种方式。类似于拆承重墙之前,先从别的地方运一些足够结实的柱子过来,支撑在原来的地方,保证屋子拆出去后,大楼不会发生倒塌。
  • 在主工程中将该组件涉及到的文件引用关系梳理清楚,去掉没有用的引用,同时对该组件按照统一模板创建对应文件夹。这里的统一模板基本上跟我们主工程的文件结构一致,都有自己独立的网络层、存储层等。总之需要把要拆出来的组件当做一个独立的项目来看待,一个项目需要有什么,这个组件就需要有什么。
  • 按照模板将该组件的所有文件进行重新归类,在这过程中需要区分哪些文件属于公共的需要下沉的,哪些是这个组件独有的。因此这个过程会不断丰富完善公共的基础业务组件。

2、组件抽离、编译、运行

  • 首先可以通过pod lib create XXX命令创建一个pod工程, 然后配置该组件的podspec文件,指明该组件都需要依赖哪些库。
  • 将经过预处理的的组件抽出来放到Development Pods中的class中,将图片资源放到Asset中。
  • 给该业务组件设置开发环境变量开关,更改组件中本地资源如图片、plist等的加载方式,设置组件的程序入口等
  • 如果该组件跟别的业务组件有通讯,那么还需要提供别的业务组件的接口。如果此时别的业务还没有拆成组件,那么建议建立一个接口组件,专门存放那些还没有抽成组件的业务接口和服务。

3、组件引入

  • 第二步结束,且组件通过初步测试后,就可以将组件以开发库的方式引入主工程了,此时该业务组件不需要进行发版。关于开发库的引入方式,下面会再进行介绍。

以上大致的介绍了业务组件拆分的三个大阶段,接下来我们再看下组件拆分过程中遇到的一些具体问题,这些问题相信大家在实践中基本都会遇到。

一、主工程中业务组件的引用方式问题

注:业务组件可以不必进行pod发版,一是因为业务组件开发中频繁的发版很耗时间,二是业务组件拥有自己的tag,不必通过pod版本号进行控制。可以直接在主工程的podfile中以开发库的方式引入业务组件,开发库一般有两种方式,分别是pathcommitId方式。

  • 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'
      • 不指定commitIdpod '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该组件即可。
    • 其他方式:对应的可以通过脚本方式进行组件化工程的installcommitupdate等一系列操作,或者通过脚本进行两个不同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 多分支管理
综合分析后,我们整体采用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复用、数据与服务复用
    • 原则上不提倡通过接口方式进行业务UIModel的复用,如果就是想复用,可以把对应的UIModel下沉到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 与我的技术之路 征文活动正在进行中......