七猫 iOS 组件化实践

背景

传统 App 架构设计更多强调的是分层(例如网络层、UI 层等),但是随着业务的发展,系统越发复杂,App 内各业务耦合严重,边界越来越模糊,代码质量、开发效率都会受到影响。

我司研发中心开始推行敏捷迭代的开发模式,为了适应以及解决已有的问题,客户端架构上也必须做出调整。通过组件化架构,可以进一步明确业务职责及边界、减少依赖、优化编译速度(二进制)、独立测试等,以支持多条业务线独立、并行推进,提升研发效率。

组件一般指将功能抽离成一个个独立组件,强调的是代码的复用,一般在架构底层;模块一般指的是功能/业务抽离成独立模块,可以独立运行、调试,强调的是业务的隔离,一般在架构业务层;在 iOS 中一般统一称之为组件,所以下文中都统一称之为组件。

业界方案

自蘑菇街之后,iOS 组件化百花齐放、百家争鸣,各厂都出了自己的组件化方案,但是整体上都是大同小异。

组件化的基本概念大多数文章都已经介绍了,主要涉及组件架构、组件通信、组件代码管理、组件工具四个方面,就不赘述了。接下来主要从组件化架构、组建通信两个角度来进行概述。

组件化架构

借用蘑菇街的图:

借用有赞最终架构的图:

总体而言,分为三层:

  1. 业务组件:各个业务组件,业务的具体实现。
  2. 路由服务:定义各个业务组件的对外的路由、业务接口,不包含具体实现(蘑菇街未画出)。
  3. 基础组件:包含了网络、模型解析、存储等基础功能组件,可以跨 App 复用。

还有一个宿主 app 层,作为壳工程,管理App配置、组装业务组件,上面蘑菇街和有赞未画出。

路由方案

个人认为有赞路由的介绍霜神的路由设计分析已经比较完整了,就不累述了,以下做个简单的总结:

  1. URL-Scheme 方案(参考蘑菇街的 MGJRouter):和前端的页面路由类似,一个 URL 绑定一个 Handler,主要用于页面跳转、业务调用。
  2. Protocol 方案(参考蘑菇街的 MGJRouter):是对 URL-Scheme 的一种补充,更方便传递复杂参数,减少硬编码,更适用于业务调用,而且IDE能提供代码补全和编译检查,更不容易犯错。
  3. Target-Action 方案(参考 casa 的 CTMediator):利用 Runtime 解耦,页面跳转和跨组件业务都统一调用。
  4. Notification 方案:比较适合1对多的解耦,例如全局的登录、登出。

组件化实践

七猫免费小说项目是 Swift 与 Objective-C 混编的,所有的代码、资源文件、项目配置、部分源码引入的三方库都是放在一个大仓库内,依赖 Cocoapods 管理三方库。

根据我们当前的情况,制定了组件化的架构、路由方案以及迁移计划。

组件化架构

  1. 考虑到 App 信息、用户信息在整个应用内都是通用的,我们抽离出了核心业务组件,作为整个 App 的核心信息、供业务组件直接调用;同时规定该组件只包含 App信息、用户信息的增删改查,不包含任何业务逻辑,严格划分界限、防止劣化。
  2. 公共服务不仅包含了对外的路由、业务接口,还包含了跨业务组件通用的实体类、协议。
  3. 基础层我们并没有拆分成独立的功能组件,只拆分出基础、通用UI、源码管理的三方库组件等。

整体的架构遵循的基本原则

  1. 越底层越稳定。
  2. 上层依赖下层,下层不能反向依赖上层
  3. 同层组件尽量不要相互依赖,除了业务组件通过路由进行通信
  4. 业务组件尽量功能内聚,减少跨组件的交互,可以适当容忍代码上的重复以避免不必要的耦合。

在拆分当中发现,业务之间还是存在关联性,无法做到完全的解耦,单个业务组件也不具备独立调试的能力;综合考虑多仓带来的维护成本,代码隔离、独立版本管理带来的收益等因素,还是决定单组件以独立 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的方案:

  1. URL-Scheme: 用于跨组件的页面跳转,例如跳转书籍详情页;
  2. Protocol:用于跨组件的业务调用,例如登录操作、下载书籍操作、观看广告操作等;
  3. 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 来传递,减少硬编码以及出错的概率。

制定计划

经过多次讨论,制定了组件化迁移原则以及计划。

迁移原则:

  1. 保证组件化和业务开发并行,合理安排组件化时间,和业务小组保持一致,减少沟通成本。
  2. 保证组件拆分出来,初期先以粗颗粒度进行拆分,适当容忍代码上的重复;初期不考虑代码隔离,代码还是放在一个大仓库内,以不同的 project 进行隔离,先不考虑 Cocoapods 私有仓库。
  3. 尽量保证不影响原来的业务逻辑,保证稳定性,做好技术调研、技术方案评审,业务重构由业务负责人完成,重构代码 review 必须经过两个人,做好文档记录,因为项目没有单元测试,更加依赖 QA 回归测试、针对性地重点测试。
  4. 尽量一次性完成单个组件地迁移,降低对业务的影响,减少 QA 的回归测试压力。

迁移计划:

  1. 先理清项目架构,整体功能进行分层。
  2. 部分功能重新整理逻辑,适当重构、进行解耦,不做大规划的重写,以免影响业务逻辑。
  3. 先拆分基础层,再核心业务,然后业务层、壳 App,从下往上,每个迭代完成单层或者单个组件的解耦拆分。

未来的思考与规划

完成项目组件化之后,还有一些未完成的、以及使用过中发现不足的,还需要进一步地思考与优化:

  1. 资源文件拆分:因为单个组件没有独立调试的能力,并且原来我们的设计是一次性给到所有的资源文件(包含旧的和新增的)、开发一次性替换,所以初期就没考虑资源文件拆分到业务组件内,后续要考虑下是否必要。
  2. 组件整理与文档补充:初期组件拆分粒度较大,需要再重新审视下合理性、明确边界、防劣化;整理开发文档,方便业务同学使用。
  3. 基础组件升级:以独立于小说项目的视角,整理基础组件的不足、继续完善,沉淀出能跨 App 复用的基础组件。
  4. 分仓(代码隔离、版本号管理):组件化之后,碰到小渠道阶段部分功能验收不通过,代码回退影响其他功能,要重新思考下分仓的必要性。
  5. 编译优化:现在是全量编译,编译时间过长,影响开发、测试打包效率等;考虑分仓后组件二进制化(业界有单源多版本、单源双版本、双源单版本方案,可参考美柚方案),以及运行时二进制源码切换、方便调试(可参考美团、美柚方案)、不影响开发效率。
  6. 开发质量:组件化后、同学之间工作更加独立,如何保证在组件集成到 App 内,质量的稳定性?CI 代码检测、App 的性能监控等更加迫切。
  7. 开发效率:组件化后,工程的复杂度变高,需要配套的工具替代重复的工作、降低操作难度,例如分仓后组件版本管理、打包管理,CI/CD 等。

引用

  1. 蘑菇街 - 蘑菇街 App 的组件化之路
  2. 有赞 - 有赞移动 iOS 组件化(模块化)架构设计实践
  3. 霜神 - iOS 组件化 —— 路由设计思路分析
  4. xcodebuildsettings