20 min read

iOS 中小型App 通用项目架构方案

1. 背景

本文探讨一种 iOS App 通用项目的架构设计方案,旨在针对中小型的 App 的需求。

2. 前言

App 架构是软件设计的一个分支,它关心如何设计一个 App 的结构。具体来说,它关注于两个 方面:如何将 App 分解为不同的接口和概念层次部件,以及这些部件之间和自身的不同操作中 所使用的控制流和数据流路径。

我们通常使用简单的框图来解释 App 的架构。比如,Apple 的 MVC 模式可以通过 model、 view 和 controller 三层结构来描述。

上面框图中的模块展示了这个模式中不同名字的三个层次。在一个 MVC 项目中,绝大部分的代 码都会落到其中某个层上。箭头表示了这些层进行连接的方式。

但是,这种简单的框图几乎无法解释在实践中模式的操作方式。这是因为在实际的 App 架构中, 部件的构建有非常多的可能性。事件流在层中穿梭的方式是什么?部件之间是否应该在编译期间或者运行时持有对方? 要怎么读取和修改不同部件中的数据?以及状态的变更应该以哪条路径在 App 中穿行?

2.1 传统思路

在最高的层级上,App 架构其实就是一套分类,App 中不同的部件会被归纳到某个类型中去。 在本文中,我们将这些不同的种类叫做层次:一个层次指的是,遵循一些基本规则并负责特定功能的接口和其他代码的集合。

基于设计模式六大原则之一的单一职责原则,可以简单的将系统划分为业务层,基础层,底层容器层,OS 层等等,以便于维护和扩展。

不管什么 App,我们都可以按照分层思路来设计架构,如下图所示:

image-20210923110651968

2.2 组件化思路

但随着业务的发展,系统变得越来越复杂,只做分层就不够了。

此时,一般会将各个子系统划分为相对独立的模块,通过中介者模式收敛交互代码,把模块间交互部分进行集中封装, 所有模块间调用均通过中介者来做。

进一步的,通过技术手段,消除中介者对业务模块依赖,即形成了业务模块化架构设计,在业界也叫“组件化架构”。

image-20210923141858644

通常讲到组件化,很多人会认为我们去把一些可以抽象出来的通用的功能模块比如网络库,本地数据存储等等封装出来,以三方库的形式提供给 App 的开发者,这样就算是组件化的开发。

严格上来讲,这只能算功能模块的组件化,而我们这里要探讨的是移动端整体架构的组件化,除了提供功能模块的组件化,更多的是强调将 App 的总体业务拆分成不同的业务模块,去实现各个业务模块间的解耦合,甚至包括业务模块和主工程文件之间的解耦合,最终实现业务模块的分布式开发,以及业务模块级别的代码共享。

3. 业界主流 App 架构方案

由于 App 在移动端设备的显示特点,基本上都是独占一屏内容,每一屏的内容相对独立,一屏或者说一个界面就是一个模块。

下面我们分为两部分来讲解,单屏架构设计方案和跨屏通信方案。

3.1 单屏架构方案

3.1.1 MV*模式

对于移动端的开发模式,首先想到的就是经典模式 MVC,以及后来的MVPMVVM等变种。我们这几种模式合并简称为**"MV*模式"**。

MV*模式

image-20210923091244226

纯粹的MVC容易出现Massive Controller的问题,开发人员将所有业务逻辑堆砌在Controller,造成单元测试、修改维护等操作异常困难,所以衍生出了MVPMVVM架构设计模式。

  • MVP : Model 不仅仅是模型,还包括数据的存取操作,比如 sqlite 读写,http 请求等。

  • MVVM :ViewModel 本质上和 Controller 没有太大区别,使用上会结合Reactive模式,做到双向数据绑定。

Android Architecture ComponentMVVM的典型代表

Android 前几年用MVP比较多,也是谷歌官方推荐过的一个开发模式。现如今MVP已经被抛弃,谷歌已经全面转向 AndroidX 的 Jetpack,一个基于MVVM的新架构模式。

下面是 Google 官方推荐的 Android 应用架构示意图:

img

3.1.2 VIPER

前面讲到的几个架构大多脱胎于 MVC,但是VIPER 和 MVC 没有啥关系,是一个全新的架构。从一点就可以看出来:前面几个 MVX 框架在 iOS 下是无法摆脱 Apple 的 viewcontroller 影响的,但是 VIPER 彻底弱化了 VC 的概念,让 VC 变成了真正意义上的 View。把 VC 的职责进行了彻底的拆分,分散到各个子层里面了。

下图就是 VIPER 的架构图:

See the source image

VIPER 的核心在于它是建立在单一责任原则上的架构,是非常干净的架构。它将每个模块与其他模块隔离开来。因此,更改或修复错误非常简单,因为您只需要更新特定的模块。此外,VIPER 还为单元测试创建了一个非常好的环境。由于每个模块独立于其他模块,因此保持了低耦合。在开发人员之间划分工作也很简单。

VIPER 的缺点也很明显,每一个模块都必须包含这 5 个部分,虽然可以借助 template 来简化操作,但对小功能的模块也要这么多胶水代码,有点过度解耦的嫌疑。

总结:VIPER 适合大型 App,不适合小型 App。

3.1.3 Flux

Flux 是什么?
简单说,Flux 是一种架构思想,专门解决软件的结构问题。它跟 MVC 架构是同一类东西,但是更加简单和清晰。

Flux 概念分成四个部分:

  • View: 视图层
  • Action(动作):视图层发出的消息(比如点击按钮,手势操作)
  • Dispatcher(派发器):用来接收 Actions、执行异步回调函数
  • Store(数据层):用来存放应用的状态,一旦发生变动,就提醒 Views 要更新页面

image-20210922170057673

Flux 的最大特点,就是数据的"单向流动"。

  1. 用户访问 View
  2. View 发出用户的 Action
  3. Dispatcher 收到 Action,要求 Store 进行相应的更新
  4. Store 更新后,发出一个"change"事件
  5. View 收到"change"事件后,更新页面

上面过程中,数据总是"单向流动",任何相邻的部分都不会发生数据的"双向流动",这保证了流程的清晰。

Flux 适用比较复杂的场景,尤其是在状态比较多、且各有关联的时候。使用Store 统一管理一个State,UI 监听State的变化即可,保证 UI 只有一个状态来源。

3.1.4 RIBs

RIBs 是 Uber 开源的一个跨平台架构模式,架构图如下:

早在 2016 年,Uber 就在Engineering the Architecture Behind Uber’s New Rider App一文中介绍了他们重构 Uber App 所采用的架构和技术,从源码我们能看出,RIBs 就是 VIPER 模式的一个实现,并在 VIPER 的基础上做了不少改进。

具体介绍请看官方文档,优缺点跟 VIPER 一致。

3.2 路由

路由的左右是拉起另外一屏,并解决参数传递和回调的问题,目前业界常见的跨屏方案大致如下几种:

  • 基于路由 URL 的 UI 页面统跳管理
  • 基于反射的远程接口调用封装
  • 基于面向协议思想的服务注册方案
  • 基于通知的广播方案

3.2.1 路由 URL 统跳方案

统跳路由是页面解耦的最常见方式,大量应用于前端页面。通过把一个 URL 与一个页面绑定,需要时通过 URL 可以方便的打开相应页面。其中最著名的开源方案就是蘑菇街的MGJRouter

当然有些场景会比这个复杂,比如有些页面需要更多参数。

基本类型的参数,URL 协议天然支持:

MGJRouter.registerWithHandler("mgj://foo/bar") { (routerParameters) in
   print("routerParameters:\(routerParameters ?? [:])")
}

MGJRouter.open("mgj://foo/bar")

复杂类型的参数,可以提供一个额外的字典参数 params, 将复杂参数放到 Map 中即可。需要回调的时候可以再传一个 completion 参数:

func openURL(_ urlStr: String, params: [AnyHashable : Any]?, completion: RouteCompletion?)

URL 本身是一种跨多端的通用协议,使用路由 URL 统跳方案的优势是动态性及多端统一 (H5, iOS,Android,Weex/RN),缺点是能处理的交互场景偏简单,所以一般更适用于简单 UI 页面跳转。一些复杂操作和数据传输,虽然也可以通过此方式实现,但都不是很高的效率。

目前天猫和蘑菇街等都有使用路由 URL 作为自己的页面统跳方案,达到解耦的目的。

3.2.2 基于反射的远程调用封装

当无法 import 某个类的头文件但仍需调用其方法时,最常想到的就是基于反射来实现了。例:

Class manager = NSClassFromString(@"Manager");
NSArray *list = [manager performSelector:@selector(getGoodsList)];
//code to handle the list

但这种方式存在大量的 hardcode 字符串,无法触发代码自动补全,容易出现拼写错误,而且这类错误只能在运行时触发相关方法后才能发现,无论是开发效率还是开发质量都有较大的影响。

这其实是各端远程调用都需要解决的问题。移动端最常见的远程调用就是向后端接口发网络请求。针对这类问题,我们很容易想到创建一个网络层,将这类“危险代码”封装到里面。上层业务调用时网络层接口时,不需要 hardcode 字符串,也不需要理解内部麻烦的逻辑。

类似的,我可以将模块间通讯也封装到一个“网络层”中(或者叫消息转发层)。这样危险代码只存在某几个文件里,可以特别地进行 code review 和联调测试。后期还可以通过单元测试来保障质量。模块化方案中,我们可以称这类“转发层”为 Mediator (当然你也可以起个别的名字)。同时因为 performSelector 方法附带参数数量有限,也没有返回值,所以更适合使用 NSInvocation 来实现。

@interface Mediator
//Mediator提供基于NSInvocation的远程接口调用方法的统一封装
- (id)performTarget:(NSString *)targetName
             action:(NSString *)actionName
             params:(NSDictionary *)params;

@end

//Goods模块所有对外提供的方法封装在一个Category中
@interface Mediator(Goods)
- (NSArray*)goods_getGoodsList;
- (NSInteger)goods_getGoodsCount;
...
@end
@impletation Mediator(Goods)
- (NSArray*)goods_getGoodsList {
    return [self performTarget:@“GoodsModule” action:@"getGoodsList" params:nil];
}
- (NSInteger)goods_getGoodsCount {
    return [self performTarget:@“GoodsModule” action:@"getGoodsCount" params:nil];
}
...
@end

然后各个业务模块依赖 Mediator, 就可以直接调用这些方法了。

//业务方依赖Mediator模块,可以直接调用相关方法
NSArray *list = [[Mediator sharedInstance] goods_getGoodsList];

这种方案的优势是调用简单方便,代码自动补全和编译时检查都仍然有效。

劣势是 category 或者 extension 存在重名覆盖的风险,需要通过开发规范以及一些检查机制来规避。同时 Mediator 只是收敛了 hardcode, 并未消除 hardcode, 仍然对开发效率有一定影响。

业界的 CTMediator 开源库,以及美团都是采用类似方案。

3.2.3 服务注册方案

有没有办法绝对的避免 hardcode 呢?

如果接触过后端的服务化改造,会发现和移动端的业务模块化很相似。

Dubbo 就是服务化的经典框架之一,它是通过服务注册的方式来实现远程接口调用的。即每个模块提供自己对外服务的协议声明,然后将此声明注册到中间层。调用方能从中间层看到存在哪些服务接口,然后直接调用即可。

例:

//Goods模块提供的所有对外服务都放在GoodsModuleService中
@protocol GoodsModuleService
- (NSArray*)getGoodsList;
- (NSInteger)getGoodsCount;
...
@end
//Goods模块提供实现GoodsModuleService的对象,
//并在+load方法中注册
@interface GoodsModule : NSObject<GoodsModuleService>
@end
@implementation GoodsModule
+ (void)load {
    //注册服务
    [ServiceManager registerService:@protocol(service_protocol)
                  withModule:self.class]
}
//提供具体实现
- (NSArray*)getGoodsList {...}
- (NSInteger)getGoodsCount {...}
@end

//将GoodsModuleService放在某个公共模块中,对所有业务模块可见
//业务模块可以直接调用相关接口
...
id<GoodsModuleService> module = [ServiceManager objByService:@protocol(GoodsModuleService)];
NSArray *list = [module getGoodsList];
...

这种方式的优势也包括调用简单方便,代码自动补全和编译时检查都有效。实现起来也简单,协议的所有实现仍然在模块内部,所以不需要写反射代码了。同时对外暴露的只有协议,符合团队协作的“面向协议编程”的思想。

劣势是如果服务提供方和使用方依赖的是公共模块中的同一份协议(protocol), 当协议内容改变时,会存在所有服务依赖模块编译失败的风险。同时需要一个注册过程,将 Protocol 协议与具体实现绑定起来。

业界里,蘑菇街的 ServiceManager 和阿里的 BeeHive 都是采用的这个方案。

3.2.4 通知广播方案

基于通知的模块间通讯方案,实现思路非常简单, 直接基于系统的 NSNotificationCenter 即可。

优势是实现简单,非常适合处理一对多的通讯场景;劣势是仅适用于简单通讯场景,复杂数据传输,同步调用等方式都不太方便。

模块化通讯方案中,更多的是把通知方案作为以上几种方案的补充。

4. 本项目的设计方案

经过上面的大篇幅介绍,下面我们就来归纳下,本项目整体架构没有单独采用任何以上一种架构,而是考虑了 Swift 语言特性,然后选取其中一些方案的优点,总结出一套结合了 MVVM-Protocol + Reactive + Coodinator + Flux的设计方案。

下面具体说一下方案选型。

4.1 响应式框架 RxSwift

在 iOS 项目中,实际负责View的部分其实是UIViewController,对应 Android 的Activity。我们引入RxSwift 框架,在 View 和 ViewModel 之间做一个双向绑定。

RxSwift 是 Rx 模式的 Swift 语言实现,还有其他的语言版本,比如 RxJava, RxC#, RxJs 等。

Rx 是什么?其中有一个定义是这样的:

ReactiveX 是一个通过使用可观察的序列来组合异步事件编码的类库。

如果你对函数式编程不熟悉,你可以把 Rx 想象成一种极端的观察者模式。关于更多的信息,你可以参考 官方文档

这种写法到底给我们带来了什么好处呢?

  1. 所有逻辑都是被声明式地写到了同一个地方。
  2. 我们通过观察和响应的方式来处理状态的变化。
  3. 我们使用 RxCocoa(UIKit 的 Rx 绑定) 的语法糖来简短明了地设置列表视图的数据源和代理。

4.2 Coordinator 路由方案

本项目采用的方案有别于上述 4 种,采用的是一种叫Coordinator的方案。

Coordinator 是 Soroush Khanlou 在 2015 年的 NSSpain 演讲上提出的一个模式,其本质上是 Martin Fowler 在《 Patterns of Enterprise Application Architecture 》中描述的 Application Controller 模式在 iOS 开发上的应用。其核心理念如下:

  1. 抽象出一个 Coordinator 对象概念

  2. 由该 Coordinator 对象负责 ViewController 的创建和配置。

  3. 由该 Coordinator 对象来管理所有的 ViewController 跳转

  4. Coordinator 可以派生子 Coordinator 来管理不同的 Feature Flow

经过这层抽象之后,一个复杂 App 的路由对应关系就会如下:

从图中可以看出,应用的 UI 和业务逻辑被清晰的拆分开,各自有了自己清晰的职责。ViewController 的初始化,ViewController 之间的链接逻辑全部都转移到 App Coordinator 的体系中去了,ViewController 则彻底变成了一个个独立的个体,其只负责:

  1. 自己界面内的子 UIView 组织
  2. 接收数据并把数据绑定到对应的子 UIView 展示
  3. 把界面上的 user action 转换为业务上的 user intents,然后转入 App Coordinator 中进行业务处理。

通过引入 AppCoordinator 之后,UI 和业务逻辑被拆分开,各自处理自己负责的逻辑。

在 iOS 应用中,路由的底层实现还是 UINavigationController 提供的 presentpushpop 等函数,在其之上,iOS 社区出了各种封装库来更好的封装 ViewController 之间的跳转接口,如上面的统跳路由方案 MGJRouter 等。

在这个基础上我们来进一步思考 Coordinator,其概念核心是把 ViewController 跳转和业务逻辑一起抽象为 user intents(用户意图),对于开发者具体使用什么样的方式实现的跳转逻辑并没有限制,而路由的实现方式在一个应用中的影响范围非常广,切换路由的实现方式基本上就是一次全 App 的重构。

所以在 Coordinator 的基础之上,还可以引入 Protocol-Oriented Programming 的概念,在 Coordinator 的具体实现和 ViewController 之间抽象一层 Protocols,把 UI 和业务逻辑的实现彻底抽离开。

经过这层抽象之后,路由关系变化如下:

经过 Coordinator 统一处理路由之后,App 可以得到如下好处:

  1. ViewController 变得非常简单,成为了一个概念清晰的,独立的 UI 组件。这极大的增加了其可复用性。
  2. UI 和业务逻辑的抽离也增加了业务代码的可复用性,在多屏时代,当你需要为当前应用增加一个 iPad 版本时,只需要重新做一套 iPad UI 对接到当前 iPhone 版的 Coordinator 中就完成了。
  3. Coordinator 定义与实现的分离,UI 和业务的分离让应用在做 A/B Testing 时变得更加容易,可以简单的使用不同实现的 Coordinator,或者不同版本的 ViewController 即可。

4.3 类 Flux 框架 ReactorKit

Swift 中的 Flux 实现有很多个开源库:ReSwiftReactorKitFluxor等,本项目中选择 ReactorKit。

ReactorKit

ReactorKit 剔除了全局 State的概念,由用户自己去决定是否需要全局 State,可以看作是 Flux 和 Reactive 编程的结合体。

设计流程如下图:

4.4 其他基础功能的技术选型

列一下本项目主要功能对应的库,基本上都是纯 Swift 编写的:

  • Resolver

    非常轻量级的依赖注入库,结合了 Swift 的 Property WrApper 特性

  • Moya

    Moya 的基本思想是,提供一些网络抽象层,它们经过充分地封装,并直接调用 Alamofire。它们应该足够简单,可以很轻松地应对常见任务,也应该足够全面,应对复杂任务也同样容易。

  • MMKV

    MMKV 是基于 mmap 内存映射的 key-value 组件,底层序列化/反序列化使用 protobuf 实现,性能高,稳定性强。从 2015 年中至今在微信上使用,其性能和稳定性经过了时间的验证。近期也已移植到 Android / macOS / Win32 / POSIX 平台,一并开源。

  • Kingfisher

    非常轻量级的图片自动缓存库,LRU 算法实现

5. 总结

移动应用的业务模块化架构设计,其真正的目标是提升开发质量和效率。单从实现角度来看并没有什么黑魔法或技术难点,更多的是结合团队实际开发协作方式和业务场景的具体考量——“适合自己的才是最好的”。

通过过往多年的实践,发现一味的追求性能,绝对的追求模块间编译隔离,过早的追求模块代码管理隔离等方式都偏离了模块化设计的真正目的,是得不偿失的。更合适的方式是在可控的改造代价下,一定程度考虑未来的优化方式,更多的考虑当前的实际场景,来设计适合自己的模块化方式。