基于ReSwift和App Coordinator的iOS架构

2017 年 4 月 18 日

iOS 架构漫谈

当我们在谈 iOS 应用架构时,我们听到最多的是 MVC,MVVM,VIPER 这三个 Buzz Word,他们的逻辑一脉相承,不断的从 ViewController 中把逻辑拆分出去。从苹果官方推荐的 MVC:

图片来源

随着系统的复杂,把功能进行细化,把整合View 展示数据的逻辑的独立出来形成ViewModel 模块,架构风格就变成了MVVM:

图片来源

随着系统的更加复杂,把路由的职责,获取数据的职责也独立出去,架构风格就变成了VIPER:

图片来源

本文则想从另一个角度和大家探讨一个新的iOS 应用架构方案,架构的本质是管理复杂性,在讨论具体的架构方案前,我们首先应该明确一个iOS 应用的开发,其复杂性在哪里?

iOS 应用的开发复杂度

对于一个 iOS 应用来说,其开发的复杂性主要体现在三个方面:

复杂界面设计的实现和样式管理

iOS App 最终呈现给用户的是一组组的 UI 界面,而对于一个特定的 App 来说,其 UI 的设计元素(如配色,字体大小,间距等)基本上是固定的,另外,组成该 App 的基础组件(如 Button 种类,输入框种类等)也是有限的。但是如何管理,组合,重用组件则是架构师需要考虑的问题,尤其是一些 App 在开发过程中可能出现大量的 UI 样式重构,更需要清晰的控制住重构的影响范围。这儿的复杂性本质上是 UI 组件自身设计实现的复杂性,多 UI 组件之间的组合方式和 UI 组件的重用机制。

路由设计

对于一个大型的 iOS 应用,通常会把其功能按 Feature 拆分,经过这样的拆分之后,其可能出现的路由有以下几种:

  • APP 间路由: 从其它 App 调起当前 App,并进入一个很深层次的页面(图示 1)。

  • APP 内路由:

  1. 启动进入 App 的 Home 页面(图示 2)
  2. 从 Home 页面到进 Feature Flow(图示 3)
  3. Feature 内按流程的页面的路由(图示 4)
  4. 各 Feature 之间的页面跳转(图示 5)
  5. 各 Feature 共享的单点信息页的跳转(图示 6)

根据 Apple 官方的 MVC 架构,这些复杂的各种跳转逻辑,以及跳转前的 ViewController 的准备工作等逻辑缠绕在 AppDelegate 的初始化,ViewController 的 UI 逻辑中。这儿的复杂性主要是 UI 和业务之间缠绕不清的相互耦合。

应用状态管理

一个 iOS 应用本质上就是一个状态机,从一个状态的 UI 由 User Action 或者 API 调用返回的 Data Action 触发达到下一个状态的 UI。为了准确的控制应用功能,开发者需要能够清楚的知道:

  • 应用的当前 UI 是由哪些状态决定的?
  • User Action 会影响哪些应用状态?如何影响的?
  • Data Action 会影响哪些应用状态?如何影响的?

在 MVC,MVVM,VIPER 的架构中,应用的状态分散在 Model 或者 Entity 中,甚至有些状态直接保存在 View Controller 中,在跟踪状态时经常需要跨越多个 Model,很难获取到一个全貌的应用状态。另外,对于 Action 会如何影响应用的状态跟踪起来也比较困难,尤其是当一个 Action 产生的影响路径不同,或最终可能导致多个 Model 的状态发生改变时。这儿的复杂性主要体现在治理分散的状态,以及管理不统一的状态改变机制带来的复杂性。

如何管理这些复杂度

前面明确了 iOS 应用开发的复杂性所在,那么从架构层面上应该如何去管理这些复杂性呢?

使用 Atomic Design 和 Component Driven Development 管理界面开发的复杂度

UI 界面的复杂度本质上是一个点上的复杂度,其复杂性集中在系统的某些小细节处,不会增加系统整体规划的复杂度,所以控制其复杂度的主要方式是隔离,避免一个 UI 组件之间的相互交织,变成一个面上的复杂度,导致复杂度不可控。在 UI 层,最流行的隔离方式就是组件化,在笔者之前的一篇文章《前端组件化方案》中详细解释了前端组件化方案的实施细节,这儿就不再赘述。

使用App Coordinator 统一管理应用路由

应用的路由主要分为App 间路由和App 内路由,对它们需要分别处理

App 间路由

对于 APP 之间的路由,主要通过两种方式实现:

一种是 URL Scheme 通过在当前 App 中配置进行相应的设置,即可从别的 APP 跳转到当前 APP。进入当前 App 之后,直接在 AppDelegate 中的方法:

func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool转换进 App 内的路由。

另一种是 Universal Links ,同样的通过在当前 App 中进行配置,当用户点击 URL 就会跳转到当前的 App 里。进入当前 APP 之后,直接在 AppDelegate 中的方法:

func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool中转进 App 内路由。

所以 App 间的路由逻辑相对简单,就是一个把外部 URL 映射到内部路由中。这部分只需要增加一个 URL Scheme 或 Universal Link 对应到 App 内路由的处理逻辑即可。

App 内路由

对于内部路由,我们可以引入 App Coordinator 来管理所有路由。 App 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 提供的 present,push,pop 等函数,在其之上,iOS 社区出了各种封装库来更好的封装 ViewController 之间的跳转接口,如 JLRoutes routable-ios MGJRouter 等,在这个基础上我们来进一步思考 App Coordinator,其概念核心是把 ViewController 跳转和业务逻辑一起抽象为 user intents(用户意图),对于开发者具体使用什么样的方式实现的跳转逻辑并没有限制,而路由的实现方式在一个应用中的影响范围非常广,切换路由的实现方式基本上就是一次全 App 的重构(做过 React 应用的 react-router0.13 升级的朋友应该深有体会)。所以在 App Coordinator 的基础之上,还可以引入 Protocol-Oriented Programming 的概念,在 App Coordinator 的具体实现和 ViewController 之间抽象一层 Protocols,把 UI 和业务逻辑的实现彻底抽离开。经过这层抽象之后,路由关系变化如下:

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

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

使用 Re S wift 管理应用状态

前面提到引入 App Coordinator 之后,ViewController 的剩下的职责之一就是“接收数据并把数据绑定到对应的子 UIView 展示”,这儿的数据来源就是应用的状态。它山之石,可以攻玉,不只是 iOS 应用有复杂状态管理的问题,在越来越多的逻辑往前端迁移的时代,所有的前端都面临着类似的问题,而目前 Web 前端最火的 Redux 就是为了解决这个问题诞生的状态管理机制,而 ReSwift 则把这套机制带入了 iOS 的世界。这套机制中主要有一下几个概念:

  • App State: 在一个时间点上,应用的所有状态. 只要 App State 一样,应用的展现就是一样的。
  • Store: 保存 App State 的对象,其还负责发送 Action 更新 App State.
  • Action: 表示一次改变应用状态的行为,其本身可以携带用以改变 App State 的数据。
  • Reducer: 一个接收当前 App State 和 Action,返回新的 App State 的小函数。

在这个机制下, 一个 App 的状态转换如下:

  • 启动初始化 App State -> 初始化 UI,并把它绑定到对应的 App State 的属性上
  • 业务操作 -> 产生 Action -> Reducer 接收 Action 和当前 App State 产生新的 AppState -> 更新当前 State -> 通知 UI AppState 有更新 -> UI 显示新的状态 -> 下一个业务操作…

在这个状态转换的过程中,需要注意,业务操作会有两类:

  • 无异步调用的操作,如点击界面把界面数据存储到 App State 上;这类操作处理起来非常简单,按照上面提到的状态转换流程走一圈即可。
  • 有异步调用的操作。如点击查询,调用 API,数据返回之后再存储到 App State 上。这类操作就需要引入一个新的逻辑概念( Action Creators ) 来处理,通过 Action Creators 来处理异步调用并分发新的 Action。

整个 App 的状态变换过程如下:

无异步调用操作的状态流转(图片来源

有异步调用操作的状态流转(图片来源

经过 ReSwift 统一管理应用状态之后,App 开发可以得到如下好处:

  1. 统一管理应用状态,包括统一的机制和唯一的状态容器,这让应用状态的改变更容易预测,也更容易调试。
  2. 清晰的逻辑拆分,清晰的代码组织方式,让团队的协作更加容易。
  3. 函数式的编程方式,每个组件都只做一件小事并且是独立的小函数,这增加了应用的可测试性。
  4. 单向数据流,数据驱动 UI 的编程方式。

整理后的 iOS 架构

经过上面的大篇幅介绍,下面我们就来归纳下结合了 App Coordinator 和 ReSwift 的一个 iOS App 的整体架构图:

架构实战

上面已经讲解了整体的架构原理,“Talk is cheap”, 接下来就以 Raywendlich 上面的这个 App 为例来看看如何实践这个架构。

(图片来源: https://koenig-media.raywenderlich.com/uploads/2015/03/PropertyFinder.png

第一步:构建 UI 组件

在构建 UI 组件时,因为每个组件都是独立的,所以团队可以并发的做多个 UI 页面,在做页面时,需要考虑:

  1. 该 ViewController 包含多少子 UIView?子 UIView 是如何组织在一起的?
  2. 该 ViewController 需要的数据及该数据的格式?
  3. 该 ViewController 需要支持哪些业务操作?

以第一个页面为例:

复制代码
class SearchSceneViewController: BaseViewController {
// 定义业务操作的接口
var searchSceneCoordinator:SearchSceneCoordinatorProtocol?
// 子组件
var searchView:SearchView?
// 该 UI 接收的数据结构
private func update(state: AppState) {
if let searchCriteria = state.property.searchCriter {
searchView?.update(searchCriteria: searchCriteria) } }?
// 支持的业务操作
func searchByCity(searchCriteria:SearchCriteria) {
searchSceneCoordinator?.searchByCity(searchCriteria: searchCriteria)
}?
func searchByCurrentLocation() {
searchSceneCoordinator?.searchByCurrentLocation()
}
// 子组件的组织
override func viewDidLoad() {
super.viewDidLoad()
searchView = SearchView(frame: self.view.bounds)
searchView?.goButtonOnClick = self.searchByCity
searchView?.locationButtonOnClick = self.searchByCurrentLocation
self.view.addSubview(searchView!)
}
}

注:子组件支持的操作都以 property 的形式从外部注入,组件内命名更组件化,不应包含业务含义。

其它的几个 ViewController 也依法炮制,完成所有 UI 组件,这步完成之后,我们就有了 App 的所有 UI 组件,以及 UI 支持的所有操作接口。下一步就是把他们串联起来,根据业务逻辑完成 User Journey。

第二步:构建 App Coordinators 串联所有的 ViewController

首先,在 AppDelegate 中加入 AppCoordinator,把路由跳转的逻辑转移到 AppCoordinator 中。

复制代码
var appCoordinator: AppCoordinator!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
window = UIWindow()
let rootVC = UINavigationController()
window?.rootViewController = rootVC
appCoordinator = AppCoordinator(rootVC)
appCoordinator.start()
window?.makeKeyAndVisible()
return true
}

然后,在 AppCoordinator 中实现首页 SeachSceneViewController 的加载

复制代码
class AppCoordinator {
var rootVC: UINavigationController
init(_ rootVC: UINavigationController){
self.rootVC = rootVC
}
func start() {
let searchVC = SearchSceneViewController();
let searchSceneCoordinator = SearchSceneCoordinator(self.rootVC)
searchVC.searchSceneCoordinator = searchSceneCoordinator
self.rootVC.pushViewController(searchVC, animated: true)
}
}

在上一步中我们已经为每个 ViewController 定义好对应的 CoordinatorProtocol,也会在这一步中实现

复制代码
protocol SearchSceneCoordinatorProtocol {
func searchByCity(searchCriteria:SearchCriteria)
func searchByCurrentLocation()
}
class SearchSceneCoordinator: AppCoordinator, SearchSceneCoordinatorProtocol {
func searchByCity(searchCriteria:SearchCriteria) {
self.pushSearchResultViewController()
}
func searchByCurrentLocation() {
self.pushSearchResultViewController()
}
private func pushSearchResultViewController() {
let searchResultVC = SearchResultSceneViewController();
let searchResultCoordinator = SearchResultsSceneCoordinator(self.rootVC)
searchResultVC.searchResultCoordinator = searchResultCoordinator
self.rootVC.pushViewController(searchResultVC, animated: true)
}
}

以同样的方式完成 SearchResultSceneCoordinator. 从上面的的代码中可以看出,我们跳转逻辑中只做了两件事:初始化 ViewController 和装配该 ViewController 对应的 Coordinator。这步完成之后,所有 UI 之间就已经按照业务逻辑串联起来了。下一步就是根据业务逻辑,让用 App State 在 UI 之间流转起来。

第三步:引入 ReSwift 架构构建 Redux 风格的应用状态管理机制

首先,跟着 Re S wift 官方指导选取你喜欢的方式引入 ReSwift 框架,笔者使用的是 Carthage。

定义 App State

然后,需要根据业务定义出整个 App 的 State,定义 State 的方式可以从业务上建模,也可以根据 UI 需求来建模,笔者偏向于从 UI 需求建模,这样的 State 更容易和 UI 进行绑定。在本例中主要的 State 有:

复制代码
struct AppState: StateType {
var property:PropertyState
...
}
struct PropertyState {
var searchCriteria:SearchCriteria?
var properties:[PropertyDetail]?
var selectedProperty:Int = -1
}
struct SearchCriteria {
let placeName:String?
let centerPoint:String?
}
struct PropertyDetail {
var title:String
...
}

定义好 State 的模型之后,接着就需要把 AppState 绑定到 Store 上,然后直接把 Store 以全局变量的形式添加到 AppDelegate 中。

复制代码
let mainStore = Store<AppState>(
reducer: AppReducer(),
state: nil
)

把 App State 绑定到对应的 UI 上

注入之后,就可以把 AppState 中的属性绑定到对应的 UI 上了,注意,接收数据绑定应该是每个页面的顶层 ViewController,其它的子 View 都应该只是以 property 的形式接收 ViewController 传递的值。绑定 AppState 需要做两件事:订阅 AppState

复制代码
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
mainStore.subscribe(self) { state in state }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
mainStore.unsubscribe(self)
}

和实现 StoreSubscriber 的 newState 方法

复制代码
class SearchSceneViewController: StoreSubscriber {
......
override func newState(state: AppState) {
self.update(state: state)
super.newState(state: state)
}
......
}

经过绑定之后,每一次的 AppState 修改都会通知到 ViewController,ViewController 就可以根据 AppState 中的内容更新自己的 UI 了。

定义 Actions 和 Reducers 实现 App State 更新机制

绑定好 UI 和 AppState 之后,接下来就应该实现改变 AppState 的机制了,首先需要定义会改变 AppState 的 Action 们

复制代码
struct UpdateSearchCriteria: Action {
let searchCriteria:SearchCriteria
}
......

然后,在 AppCoordinator 中根据业务逻辑把对应的 Action 分发出去, 如果有异步请求,还需要使用 ActionCreator 来请求数据,然后再生成 Action 发送出去

复制代码
func searchProperties(searchCriteria: SearchCriteria, _ callback:(() -> Void)?) -> ActionCreator {
return { state, store in
store.dispatch(UpdateSearchCriteria(searchCriteria: searchCriteria))
self.propertyApi.findProperties(
searchCriteria: searchCriteria,
success: { (response) in
store.dispatch(UpdateProperties(response: response))
store.dispatch(EndLoading())
callback?()
},
failure: { (error) in
store.dispatch(EndLoading())
store.dispatch(SaveErrorMessage(errorMessage: (error?.localizedDescription)!))
}
)
return StartLoading()
}
}

Action 分发出去之后,初始化 Store 时注入的 Reducer 就会接收到相应的 Action,并根据自己的业务逻辑和当前 App State 的状态生成一个新的 App State

复制代码
func propertyReducer(_ state: PropertyState?, action: Action) -> PropertyState {
var state = state ?? PropertyState()
switch action {
case let action as UpdateSearchCriteria:
state.searchCriteria = action.searchCriteria
...
default:
break
}
return state
}

最终 Store 以 Reducer 生成的新 App State 替换掉老的 App State 完成了应用状态的更新。

以上三步就是一个完整的架构实践步骤,该示例的所有源代码可以在笔者的Github 上找到。

总结

以解决掉Massive ViewController 的iOS 应用架构之争持续多年,笔者也参与了公司内外的多场讨论,架构本无好坏,只是各自适应不同的上下文而已。本文中提到的架构方式使用了多种模式,它们各自解决了架构上的一些问题,但并不是一定要捆绑在一起使用,大家完全可以根据需要裁剪出自己需要的模式,希望本文中提到的架构模式能够给你带来一些启迪。


感谢张凯峰对本文的策划,徐川对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2017 年 4 月 18 日 17:59 6290

评论

发布
暂无评论
发现更多内容

Netty系列之源码解析(一)

猿灯塔

Netty

废掉一个人最好的办法是让他忙到没有时间思考

熊斌

程序员人生 职场 思考

忆秦娥·记游(三)

轩辕御龙

没有永恒的技术,只有适合的技术

码闻强

技术 个人成长 职业规划

音视频已强势崛起,我们该如何快速入门音视频技术?

音视频专家-李超

音视频 WebRTC ffmpeg 在线教育 音视频会议

Istio 1.5:对开发人员有什么帮助?

麦叔

云原生 istio servicemesh

如何学习区块链技术

Kaichao

比特币 区块链 以太坊

开发机直连Docker中的redis容器小案例

麦叔

redis Docker

二、基于 Dockerfile 构建并运行镜像

悟尘

Docker Kubernetes 容器 k8s Compose

四、Docker 网络原理、分类及容器互联配置

悟尘

Docker Kubernetes 容器 k8s Compose

回"疫"录(6):致敬最美逆行者

小天同学

疫情 回忆录 现实纪录 纪实 创新突破

B站、Quora、InfoQ,哪个的阅读/播放量会先到10W+?

赵新龙

写作平台 B站 Quora

web集群架构

桥哥技术之路

Make Tmux Great Again

xinchen

tmux

最通俗易懂的H264基本原理

音视频专家-李超

音视频 WebRTC ffmpeg 音视频会议 H264

redis数据结构介绍三-第三部分 整数集合

Nick

redis 源码 数据结构 源码分析 算法

记游(四)

轩辕御龙

一、Docker基础入门及架构介绍

悟尘

Docker Kubernetes 容器 k8s Compose

多人实时互动之各WebRTC流媒体服务器比较

音视频专家-李超

WebRTC 在线教育 音视频会议 mediasoup janus

Flink Weekly | 每周社区动态更新

Apache Flink

大数据 flink 流计算 实时计算 大数据处理

初入响应式编程(下)

CD826

spring 微服务 响应式编程 reactor

redis数据结构介绍二-第二部分 跳表

Nick

redis 源码 数据结构 源码分析 算法

从翻译到本地化:我在Airbnb做本地化经理的经历

葛仲君

产品 翻译 Airbnb 本地化 全球化

工作时间都去哪儿了?

伯薇

效率 时间管理 个人提升 团队

三、基于 Docker-registry/Nexus3 搭建本地仓库

悟尘

Docker Kubernetes 容器 k8s Compose

西江月·记游(一)

轩辕御龙

菩萨蛮·记游(二)

轩辕御龙

程序员陪娃漫画系列——排队问题

孙苏勇

程序员 生活 程序员人生 陪伴 漫画

我认为“写作平台”还缺少读者

小天同学

产品 反馈 写作平台 建议

广告与数据算法系列1.1.1: 什么是广告

黄崇远@数据虫巢

互联网 算法 广告

Java并发编程系列——Fork-Join

孙苏勇

Java Java并发 并发编程 线程

基于ReSwift和App Coordinator的iOS架构-InfoQ