七猫 iOS 组件化实践
背景
传统 App 架构设计更多强调的是分层(例如网络层、UI 层等),但是随着业务的发展,系统越发复杂,App 内各业务耦合严重,边界越来越模糊,代码质量、开发效率都会受到影响。
我司研发中心开始推行敏捷迭代的开发模式,为了适应以及解决已有的问题,客户端架构上也必须做出调整。通过组件化架构,可以进一步明确业务职责及边界、减少依赖、优化编译速度(二进制)、独立测试等,以支持多条业务线独立、并行推进,提升研发效率。
组件一般指将功能抽离成一个个独立组件,强调的是代码的复用,一般在架构底层;模块一般指的是功能/业务抽离成独立模块,可以独立运行、调试,强调的是业务的隔离,一般在架构业务层;在 iOS 中一般统一称之为组件,所以下文中都统一称之为组件。
业界方案
自蘑菇街之后,iOS 组件化百花齐放、百家争鸣,各厂都出了自己的组件化方案,但是整体上都是大同小异。
组件化的基本概念大多数文章都已经介绍了,主要涉及组件架构、组件通信、组件代码管理、组件工具四个方面,就不赘述了。接下来主要从组件化架构、组建通信两个角度来进行概述。
组件化架构
借用蘑菇街的图:
借用有赞最终架构的图:
总体而言,分为三层:
- 业务组件:各个业务组件,业务的具体实现。
- 路由服务:定义各个业务组件的对外的路由、业务接口,不包含具体实现(蘑菇街未画出)。
- 基础组件:包含了网络、模型解析、存储等基础功能组件,可以跨 App 复用。
还有一个宿主 app 层,作为壳工程,管理App配置、组装业务组件,上面蘑菇街和有赞未画出。
路由方案
个人认为有赞路由的介绍、霜神的路由设计分析已经比较完整了,就不累述了,以下做个简单的总结:
- URL-Scheme 方案(参考蘑菇街的 MGJRouter):和前端的页面路由类似,一个 URL 绑定一个 Handler,主要用于页面跳转、业务调用。
- Protocol 方案(参考蘑菇街的 MGJRouter):是对 URL-Scheme 的一种补充,更方便传递复杂参数,减少硬编码,更适用于业务调用,而且IDE能提供代码补全和编译检查,更不容易犯错。
- Target-Action 方案(参考 casa 的 CTMediator):利用 Runtime 解耦,页面跳转和跨组件业务都统一调用。
- Notification 方案:比较适合1对多的解耦,例如全局的登录、登出。
组件化实践
七猫免费小说项目是 Swift 与 Objective-C 混编的,所有的代码、资源文件、项目配置、部分源码引入的三方库都是放在一个大仓库内,依赖 Cocoapods 管理三方库。
根据我们当前的情况,制定了组件化的架构、路由方案以及迁移计划。
组件化架构
- 考虑到 App 信息、用户信息在整个应用内都是通用的,我们抽离出了核心业务组件,作为整个 App 的核心信息、供业务组件直接调用;同时规定该组件只包含 App信息、用户信息的增删改查,不包含任何业务逻辑,严格划分界限、防止劣化。
- 公共服务不仅包含了对外的路由、业务接口,还包含了跨业务组件通用的实体类、协议。
- 基础层我们并没有拆分成独立的功能组件,只拆分出基础、通用UI、源码管理的三方库组件等。
整体的架构遵循的基本原则:
- 越底层越稳定。
- 上层依赖下层,下层不能反向依赖上层。
- 同层组件尽量不要相互依赖,除了业务组件通过路由进行通信。
- 业务组件尽量功能内聚,减少跨组件的交互,可以适当容忍代码上的重复以避免不必要的耦合。
在拆分当中发现,业务之间还是存在关联性,无法做到完全的解耦,单个业务组件也不具备独立调试的能力;综合考虑多仓带来的维护成本,代码隔离、独立版本管理带来的收益等因素,还是决定单组件以独立 project 的模式存在,编译成 library 引入到主项目中,整体项目结构如下:
单个组件我们是以 Module 的格式来编译的(想要了解更多的 Module 相关的,可以参考美团的文章),并且单组件的配置通过 xcconfig 文件来管理的(和 Cocoapods 相同),相比于 Editor area -> Target -> Build Settings 更加直观,常用配置如下:
// xcconfig 配置
// 1. 设置 module
DEFINES_MODULE = YES
MODULEMAP_FILE = "QMReader/Support Files/QMReader.modulemap"
OTHER_CFLAGS = $(inherited) -fmodule-map-file="$(SRCROOT)/QMReader/Support Files/QMReader.modulemap"
OTHER_SWIFT_FLAGS = $(inherited) -import-underlying-module -Xcc -fmodule-map-file="$(SRCROOT)/QMReader/Support Files/QMReader.modulemap"
// 2. 头文件搜索路径
HEADER_SEARCH_PATHS = ......
// 3. framework 搜索路径
FRAMEWORK_SEARCH_PATHS = ......
// 4. Library 搜索路径
LIBRARY_SEARCH_PATHS = ......
//
更多的 build setting 配置可以参考xcodebuildsettings。
我们是混编项目,Library 内不能通过桥接文件来解决 Swift 调用 OC 的问题,需要在umbrella header引用 OC 文件:
#ifndef QMReader_umbrella_h
#define QMReader_umbrella_h
#import "SwiftInvokeOC.h"
#endif /* QMReader_umbrella_h */
路由方案
路由我们选用了URL-Scheme+Protocol+Notifications的方案:
- URL-Scheme: 用于跨组件的页面跳转,例如跳转书籍详情页;
- Protocol:用于跨组件的业务调用,例如登录操作、下载书籍操作、观看广告操作等;
- Notification:用来全局的广播,例如登录登出、用户信息更新等;
路由的定义:
// RouterHandlerProtocol 定义(用于定义 URL-Scheme,用于页面跳转,放在 SDK 组件)
public typealias RouteHandler = ([String: Any?]) -> Void
public typealias RouteCompletion = (Any) -> Void
public protocol RouterHandlerProtocol {
/// 给 url 绑定 handler
func bind(_ url: String, to handler: @escaping QMRouteHandler)
/// 处理 url
func handle(_ url: String, complexParams: [String: Any?]? , completion: QMRouteCompletion?) -> Any?
......
}
// RouterModuleProtocol 定义(用于定义跨业务调用,放在 SDK 组件)
public protocol RouterModuleService {
/// 注册 module
func register<Module>(_ protocolType: Module.Type, module: Module)
/// 获取 module
func module<Module>(for protocolType: Module.Type) -> Module?
......
}
// ApplicationLifeCycle 定义(用于全局的 App 生命周期分发,放在 SDK 组件)
public protocol ApplicationLifeCycle: UIApplicationDelegate {
/// app 初始化信息已配置
func applicationDidInitialize()
/// app 数据库已连接
func applicationDidConnectDataBase()
/// app 进入主界面
func applicationDidEnterHome()
}
// 路由的实现类(实现 URL-Sheme,跨业务调用,放在 SDK 组件)
final public class Router: NSObject, RouterModuleService, RouterHandlerProtocol {
/// 初始化所有的 modules
public func setupAllModules()
}
// 路由的生命周期的分发
extension Router: ApplicationLifeCycle { ...... }
抽象业务组件的基础协议为ModuleService,各个业务再继承自ModuleService扩展其对外的方法:
// module 基础抽象协议(放在 SDK 组件)
public protocol ModuleService: AnyObject {
/// 初始化设置
func setup()
....
}
// 业务 Module 协议(放在 Service 组件内)
public protocol Business1Service: ModuleService {
/// 登录
func login()
}
// 业务 Module 实现类(放在具体业务组件内)
final class Business1Module: Business1Service {
func login() {
/// 登录
}
}
在 OC 里面,业务Module一般使用重写 +load 完成自注册,但是在 Swift 内需要显示的在主项目内进行注册:
class AppDelegate: UIResponder, UIApplicationDelegate {
// 注册协议
func registerModules() {
Router.shared.register(Business1Service.self, module: Business1Module.sharedInstance)
......
Router.shared.setupAllModules()
}
}
业务组件调用其他组件的功能:
Router.shared.module(for: Business1Service.self)?.login()
需要额外说明的是,我们在跨组件业务调用中并没有去 Model 化,而是将通用 Model 放在了 Service 组件内,复杂参数还是使用 Model 来传递,减少硬编码以及出错的概率。
制定计划
经过多次讨论,制定了组件化迁移原则以及计划。
迁移原则:
- 保证组件化和业务开发并行,合理安排组件化时间,和业务小组保持一致,减少沟通成本。
- 保证组件拆分出来,初期先以粗颗粒度进行拆分,适当容忍代码上的重复;初期不考虑代码隔离,代码还是放在一个大仓库内,以不同的 project 进行隔离,先不考虑 Cocoapods 私有仓库。
- 尽量保证不影响原来的业务逻辑,保证稳定性,做好技术调研、技术方案评审,业务重构由业务负责人完成,重构代码 review 必须经过两个人,做好文档记录,因为项目没有单元测试,更加依赖 QA 回归测试、针对性地重点测试。
- 尽量一次性完成单个组件地迁移,降低对业务的影响,减少 QA 的回归测试压力。
迁移计划:
- 先理清项目架构,整体功能进行分层。
- 部分功能重新整理逻辑,适当重构、进行解耦,不做大规划的重写,以免影响业务逻辑。
- 先拆分基础层,再核心业务,然后业务层、壳 App,从下往上,每个迭代完成单层或者单个组件的解耦拆分。
未来的思考与规划
完成项目组件化之后,还有一些未完成的、以及使用过中发现不足的,还需要进一步地思考与优化:
- 资源文件拆分:因为单个组件没有独立调试的能力,并且原来我们的设计是一次性给到所有的资源文件(包含旧的和新增的)、开发一次性替换,所以初期就没考虑资源文件拆分到业务组件内,后续要考虑下是否必要。
- 组件整理与文档补充:初期组件拆分粒度较大,需要再重新审视下合理性、明确边界、防劣化;整理开发文档,方便业务同学使用。
- 基础组件升级:以独立于小说项目的视角,整理基础组件的不足、继续完善,沉淀出能跨 App 复用的基础组件。
- 分仓(代码隔离、版本号管理):组件化之后,碰到小渠道阶段部分功能验收不通过,代码回退影响其他功能,要重新思考下分仓的必要性。
- 编译优化:现在是全量编译,编译时间过长,影响开发、测试打包效率等;考虑分仓后组件二进制化(业界有单源多版本、单源双版本、双源单版本方案,可参考美柚方案),以及运行时二进制源码切换、方便调试(可参考美团、美柚方案)、不影响开发效率。
- 开发质量:组件化后、同学之间工作更加独立,如何保证在组件集成到 App 内,质量的稳定性?CI 代码检测、App 的性能监控等更加迫切。
- 开发效率:组件化后,工程的复杂度变高,需要配套的工具替代重复的工作、降低操作难度,例如分仓后组件版本管理、打包管理,CI/CD 等。