网易新闻iOS工程组件化实践
背景
近年来,移动应用的组件化是一个非常热门的话题,也有很多互联网大厂产出了关于组件化的设计和实践。组件化是一种架构思想,将整个 app 这个复杂的系统细分为许多的小单元,这些单元称为组件。组件化的好处是可以减小系统的规模,更有效的去进行团队分工合作,从而提高整体开发效率。组件之间有所属的架构层级以及明确的依赖关系,通常来说对于底层组件,更多的要求是抽象以及复用,对于上层业务组件,更多的是为了分治以及隔离,最大程度的降低它们之间的耦合。所以组件化的目的就是高内聚低耦合可复用,提高整个工程的可维护性。
随着项目的不断迭代发展,网易新闻也需要组件化的思想来优化整个架构,规范开发流程,本文从以网易新闻 iOS 项目为背景,来讨论我们是如何完成组件化的实践。
架构分层
首先任何一个移动应用整体的架构从产品和业务的角度自底向上可以分为三层,分别为与产品无关的底层,与产品相关却与具体业务无关的中间层以及与具体业务相关的上层。首先我们先了解下网易新闻最终的架构图
网易新闻的组件化过程可以按照这个分层方式分为三个阶段:基础设施建设,通用服务处理,业务组件化。下面会就各个不同阶段一些重要的点进行讨论
基础设施建设
团队规范
组件化最终目的是提高团队合作开发效率,而团队合作的基础就是沟通交流,我们在组件化的初期阶段首先确定了团队规范,其中主要包括代码规范,Git 操作规范以及 Review 机制。
- 代码规范主要包括命名,代码格式以及代码组织等方面,其中重点是如何有效的执行起来,一是每个人改动的旧代码需要按新的代码规范来,二是我们利用一个迭代的时间,每个人提交的功能代码需要所有同学一起来 Review,其中最重要的就是 Review 代码规范部分。一个迭代之后,所有人都统一的代码规范
- Git 操作包括 CommitMessage 的统一风格,分支管理以及 Rebase 合并,这部分主要是利用 CI 和脚本等强制的措施保证执行
- Review 机制,首先确定组件责任人以及 Partner 机制,每次提交的代码需要相应的责任人负责或自己的 Partner 进行 Review,再由 CI 负责合并
基础库搭建
这个阶段的现状,部分三方库通过 CocoaPods 引入,其他所有代码都在主工程中。存在的主要问题:
- 基础功能不完善
- 一些基础功能存在业务入侵,甚至是三方库
- 核心的通用功能没有封装,直接引用三方库
- 基础功能与业务没有隔离机制
优化
- 完善基础库,去除它们对业务依赖
- 核心的通用库,如网络,图片,持久化等做一次封装,避免直接依赖三方库,可以定制我们自己的功能,或当替换三方库时保证面向业务层的接口稳定,从而减少对业务层的影响
- 将基础功能封装为独立的库,利用 CocoaPods 做成私有远端 Pod 或本地 Pod,也就是将库 Pod 化,客观上形成基础功能和业务的隔离机制
- 架构层次上,我们将基础功能归类俩层,基础层和通用层
- 基础层,包括三方库和一些基础工具类的东西,这些工具类大多数是对 NSFoundation 和 UIKit 的扩展
- 通用层,包括一个 app 最基础的组件,有网络,图片,数据库,文件存储,日志等模块,向上层提供通用功能的同时,实现调用收敛从而可以完成一些全局配置
这个阶段代码方面核心的任务就是封装,无论是什么样的项目以及项目是否需要组件化,个人认为这个阶段的都是必须的。
通用服务处理
这部分代码是和我们产品相关,但又和具体业务无关的。总体上分为俩大部分,UI 相关和数据相关,分别划分为框架层和服务层。
- 框架层,主要包括视图框架和消息框架
- 视图框架包括视图基类,视图容器类(容器类包括导航,Tabbar,横向滑动的 ScrollPage 以及列表)以及一些主题字体等视图相关的类
- 消息框架包括路由以及 JSBridge 等消息处理相关的类
- 服务层,主要包括一些通用业务,比如登录,用户,发布,广告,支付,统计等,因为上层的任何具体业务都可能会调用到它们相当于为上层提供服务,而它们本身可能也存在单独的视图,所以又需要依赖下层框架层,所以单独将它们划分到服务层中
视图框架能力
视图框架包括视图基类以及一些视图容器类,它们除了提供本身的功能之外,最重要的是需要提供足够的 context,使得上层业务组件可以不借助其他组件独立的完成功能,达到高内聚低耦合的目的。我们拿 iOS 的视图控制基类 UIViewController 来举例,它提供了 navigationController 属性,这样我们可以在控制器内部完成页面的跳转,另外也提供了 viewDidAppear 等生命周期方法,我们也可以利用这些方法完成相应的业务逻辑。如果 UIViewController 没有提供这些 Context,我们除了需要额外的开发来完成这些功能,还会引出另外的俩个问题,一是每个开发同学的经验和想法都不同,所以完成的逻辑也不一样,造成代码不统一收敛,这样的又导致功能扩展时会很被动。二是在开发这些功能的同时,需要依赖其他类或组件才能完成,提高了类或组件之间的耦合性。
拿网易新闻的场景举例,在我们的列表框架中,每个 SectionController(可以看作是列表中 cell 的控制器)都可以拿到上层列表控制器的基类以及的其个性化的生命周期事件,同时也有自己的生命周期,列表滚动以及数据加载时机等接口。这样每个 SectionController 可以相对容易且独立的完成相应的业务需求。下图是 SectionController 提供的部分 Context
路由
页面之间的调用是必然存在相互依赖的,所以必须要通过解耦框架去解决,而现在有很多成熟的路由方案,也有很多文章讨论了路由的各种实现方案,这里简单介绍下我们方案的特点:
- 采用的 URL 字符串硬编码方案,因为简单直观,针对硬编码问题,我们建立了封装类组件,将所有硬编码代码都放到该组件内,业务代码依赖该组件完成跳转逻辑,既把硬编码控制到一定范围内,也达到了业务调用编译检查的目的
- 去中心化策略,路由组件注册以及调用在自己页面内部完成,整体逻辑类似于 BeeHive 框架
- 区分了外部路由(通过 URL Scheme 传入的)和内部路由(工程内模块间调用的),因为光靠字符串没法完成复杂对象的传递,所以需要内部路由,而对外部调用又需要有控制逻辑,所以区分出外部路由
- 对于很多页面需要登录的特点,我们抽象出 preConditon 接口,调用方不需要处理是否登录等这些前置条件逻辑,统一由页面提供方处理
下图展示了业务组件如何注册并实现内外部路由
依赖梳理
对于服务层来说,它更接近上层业务,所以业务入侵更严重,同时还存在更多的相互依赖问题。这一阶段的重点工作就是梳理依赖关系,解除不合理的依赖,保证组件之间不能存在循环依赖。我们主要有三个方式来处理依赖,分别是依赖解除,依赖注入和中间人组件处理。
这里举一个例子,我们现在有登录和任务俩个组件,当登录完成时,在登录组件内直接调用登录任务完成,导致登录依赖任务。在某一任务完成时,任务组件内判断需要判断当前是否是登录状态,导致任务依赖登录,最终产生了循环依赖。
首先从组件的逻辑上来看,任务依赖用户,而用户又和登录密切相关,所以任务单项依赖登录比较合适。
- 依赖解除,登录成功事件是一个典型的一对多场景,很多业务在登录成功后会触发一些逻辑,所以利用通知机制,登录成功发出通知,只需要任务组件监听登录完成通知即可
- 依赖注入,在登录和任务组建创建后,将任务组件注入到登录组件中,当然对于登录组件来说,它只是拿到了一个包含任务完成方法的协议对象,当任务完成后主动调用该对象的协议方法,从而去除了登录对任务的依赖
- 上层组件处理,相当于一个简易版的中介者模式,上层组件依赖登录获取到登录成功消息,直接调用任务组件的登录任务已完成接口
上面的案例只是为了举例说明,实际过程中根据不同的业务场景使用不同的手段,需要从易实现,易维护,易扩展等方面去考虑。对于一些核心复杂模块,可能无法像上面描述的那样简单的去拆分,而是需要整体梳理重构去解耦,再比如某些模块已经确定后续不再维护,那我们甚至可以利用 Runtime 等手段,直接反射调用去解除依赖。
业务组件化
业务组件划分
由于新闻项目的特点,在我们业务组件化开始的过程中,遇到了一个棘手问题,某些业务之间是高度耦合在一起的,没法划分出明确的边界,比如视频列表,它既存在于首页信息流 Tab 下,又存在于视频 tab 下,那么这个模块是属于信息流还是视频。再比如跟贴列表存在于跟贴页,又存在于视频详情页,那它又将如何划分。
计算机的世界里,很多问题都可以通过增加一层来解决,无论软件还是硬件
首先在视图层级上有一个明确的边界,就是页面,而页面之间我们可以通过 Router 去解耦,在网易新闻的业务中,页面可分为俩大类,一级页和二级页,分别是在容器 Tabbar 和 Navigation 下面,所以我们在此基础上单独划分出一层展现层。最终我们将业务组件分为俩层,展现层和业务层。再回到上面的例子,跟贴列表可以划分到跟贴业务中,跟贴二级页和视频沉浸二级页依赖跟贴业务中的跟贴列表。
可以看到我们既完成了跟贴列表的复用,又对于不同的业务场景完成了跟贴列表的扩展。如果没有划分我们很容易在跟贴列表写出类似下面的代码。
if (场景 A) { //do something } else if (场景 B) { //do something }
现在我们通过新建类的方式完成了业务功能的扩展,也就是我们一直强调的分治效果。
业务模型处理
在组件之间的调用过程中,很容易因为模型的传递造成组件之间的相互引用,如果我们直接使用 JSON 或者是字典来传递又会影响开发效率。我们采用的方法也很简单,就是直接将这些模型打包成一个组件下沉,我们把它命名为 BaseBusiness,因为模型类有一个最大的特点就是它本身不需要依赖任何业务,所以对它的依赖不会造成组件之间的循环依赖。而在实际实现过程中 BaseBusiness 组件除了业务模型,还包括协议,枚举以及上面路由提到的路由的硬编码封装类。
而该组件也需要严格控制,不能依赖任何其他业务。
核心业务重构
对于网易新闻 App 来说有三大核心组件,分别是列表,WebView 和播放器。几乎每一个页面都有它们的存在,而它们又是我们核心业务(信息流,文章详情页和视频)基础组件,由于这些核心业务的长期迭代,其代码结构以及可维护性已经非常差了,耦合依赖更为严重,不像通用组件那样可以通过一些解耦手段可以解决,而我们也针对 APP 的三个核心业务场景,列表,播放器和 H5 进行了重构,将依赖它们的业务架构标准化,一方面为业务组件化的拆分提供条件,另一方面让业务同学对这些核心模块达成统一认知,从代码结构上形成约束,提高代码复用率,为这些模块后续的迭代提供更好的基础。
实施工具
架构理念的落地,也需要高效工具的加持。我们在实践过程中也逐步完善着自己的效率工具,以下挑选几项进行简单介绍。
组件配置信息
我们编写了全新的 Pod 配置文件,来代替 Podfile。无论本地还是远端 Pod,工程里所有的模块都使用新的配置文件进行管理和维护,以得到更好的扩展性来适应我们的定制化需求。
在我们自己的 Pod 配置中,除了大家熟悉的 Pod 名称、版本号、路径、tag、分支这些常规属性,还增加了负责人、所在架构层级、是否需要二进制化等参数。举个例子:
"NTESNetworking" => { :path => "./DevelopmentPods/NTESNetworking/NTESNetworking.podspec", :owner => "xf", :level => 2, :binary => true }
这条配置中,除了能看到本地 Pod 的名称、路径,还可以看到:
- owner 是 xf 同学。任何 Merge request 出现此 Pod 中的修改,都会自动通知 xf 同学来进行 code review
- level 为 2,处在通用层。在进行 pod install 时,如果此 Pod 发生违背层级规则的依赖,将会出现 warning
- binary 为 true,使用二进制方式进行构建
这三个主要的定制化参数,分别关联了另外三个重要的机制和工具。下面继续说:
代码责任制及 Merge Request 规范化
项目的代码是通过公司内部 Gitlab 托管的,我们在 Gitlab 提供基础功能的基础上进行了一些改良,使得我们可以利用代码责任人制来 Review 代码。
- 代码提交阶段
- 提交代码,触发 Gitlab CI,通过分支的命名规则确定这次提交是否需要代码合入以及 Review
- 通过 Gitlab API 获取本次提交所有修改代码的文件路径
- 根据路径确定组件名称,再通过组件配置信息获取组件负责人
- 通过公司内部 IM 通知所有被修改的组件负责人来 Review
- Review 过后将会打上相应负责人的 Label
- 代码合入阶段
- Gitlab CI 轮询所有的 Merge Request 信息
- 当 Merge Request 已经被所有的责任人 Review,也就是打上 Label,进入合并阶段
- 通过分支命名规则确定合入的目标分支,检查冲突,进行 Fast-forward Merge
规范组件依赖
我们在组件化过程中,防止组件之间依赖关系的劣化,确定了俩条最基础的依赖规则
- 上层组件不能反向依赖下层组件
- 第六层展现层组件之间不能存在依赖关系
为了保证规则的不被破坏,我们在每次`Pod install`之后都会利用 level 信息检查,如果打破了规则会给出相应的警告。
组件二进制化
在二进制化方面,我们没有采用业界主流的的远端 CI 生成二进制,通过版本号控制的远端二进制方案。而是采用了通过 Xcode 缓存,MD5 区分版本的本地二进制方案。
- 生产端
- 每次 Xcode 编译完成后确定组件是否已经是二进制
- 如果不是,则获取组件的 MD5,判断是否已经存在缓存
- 通过环境变量"CONFIGURATION_BUILD_DIR"找到相应组件的 Xcode 缓存并缓存到本地
- 消费端
- 根据配置确定要使用的二进制组件
- 获取组件的 MD5,判断二进制是否存在
- 将 BuildPhase 中的 Compile Sources 清空,并将组件标记为已使用二进制
- 分别处理所有 Pod 和主工程的 XCConfig 文件,将其中的"FRAMEWORK_SEARCH_PATHS" "HEADER_SEARCH_PATHS" "OTHER_CFLAGS"变量修改指向本地的二进制缓存文件
与此同时,我们也调研了二进制 Debug 方案,发现只要知道源文件的编译路径,就可以通过 lldb 的 settings set -- target.source-map 命令,将源编译路径 map 到当前工程,从而完成二进制化下的 debug。这样我们只需要在生成二进制阶段额外保存源组件的编译路径,在消费阶段,将 lldb map 命令通过脚本放到 LLDB Init File 中即可。
另外由于我们只是清空了编译阶段的 Compile Sources,所有源码仍然还存在于整个工程中,可以随时查看,同时还可以支持 Debug, 这也大大方便了我们的日常开发维护。
这是一个完全是基于本地的方案,不需要服务器资源,也不需要额外的人力开发维护。此外由于我们的业务组件变更频繁,如果采用版本号管理将会非常繁琐,所以采用 MD5 对代码签名将会非常的方便。
进程和效果
上图主要统计了我们在通用服务处理以及业务组件化这俩个阶段的组件化进程,包括了 2 个指标,主工程源文件数(蓝色折线)和组件数量(绿色柱状)的变化。可以发现在前期的几个版本主工程文件数量基本没有变化,因为这个时候的很多代码模块根本没有办法去进行组件化,需要大量的梳理以及重构,另一方面业务代码还在不断的迭代更新,导致了初期的进度十分缓慢,但是随着组件化的进行,后面整体工程架构变得越来越合理,整体效率被大大提高了,最终在 78 版本将主工程的源文件数变为 1,只保留了 main 文件,使整个工程变为一个壳工程,而组件数量最终达到了 268 个。
后记
组件化原则
开篇我们有提到「底层抽象复用、上层分治隔离」,这一原则贯穿了整个组件化项目。通过工程组件化,我们明确了代码边界,使整体架构更清晰,提高团队对于项目结构的认知。同时对于各个层级的模块,使用适合的策略进行梳理和重构。
没有银弹,寻找适合自己的方案
我们的组件化方案与业界的方案最大的不同,是没有利用解耦框架将业务完全隔离。而是将业务分为两层,顶部的展现层利用路由隔离,下方的业务层可以单项依赖。
这主要源于两个方面的考虑:一是项目特点,我们的业务之间耦合本身比较高,如果完全解耦,业务之间的接口会非常多,而解耦框架在业务之间相互调用也是有代价的,从而导致开发效率的降低;二是团队的组织架构,我们团队没有按业务拆分成单独的团队,业务之间也不需要完全隔离。
关于重构
在庞大的组件化项目中,核心模块的重构占据了大量时间。
在业务狂奔的时代,工程中难免会出现妥协式代码。一旦这些“脏代码”没有被及时优化和清理,就容易在后续迭代中对其他代码进行污染和扩散。其实技术债并不可怕,处理这些债务的关键不一定是多么高深的技术点,而是决心。在我们的组件化进程中,对大量技术模块、业务组件进行梳理和重构,从基础层到展现层,各个环节都付出了大量时间和耐心。这个煎熬的过程在最终换来的,是卸下沉重包袱的舒爽。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论