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 内路由:
- 启动进入 App 的 Home 页面(图示 2)
- 从 Home 页面到进 Feature Flow(图示 3)
- Feature 内按流程的页面的路由(图示 4)
- 各 Feature 之间的页面跳转(图示 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 开发上的应用。其核心理念如下:
- 抽象出一个 Coordinator 对象概念
- 由该 Coordinator 对象负责 ViewController 的创建和配置。
- 由该 Coordinator 对象来管理所有的 ViewController 跳转
- Coordinator 可以派生子 Coordinator 来管理不同的 Feature Flow
经过这层抽象之后,一个复杂 App 的路由对应关系就会如下:
从图中可以看出,应用的 UI 和业务逻辑被清晰的拆分开,各自有了自己清晰的职责。ViewController 的初始化,ViewController 之间的链接逻辑全部都转移到 App Coordinator 的体系中去了,ViewController 则彻底变成了一个个独立的个体,其只负责:
- 自己界面内的子 UIView 组织,
- 接收数据并把数据绑定到对应的子 UIView 展示
- 把界面上的 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 可以得到如下好处:
- ViewController 变得非常简单,成为了一个概念清晰的,独立的 UI 组件。这极大的增加了其可复用性。
- UI 和业务逻辑的抽离也增加了业务代码的可复用性,在多屏时代,当你需要为当前应用增加一个 iPad 版本时,只需要重新做一套 iPad UI 对接到当前 iPhone 版的 App Coordinator 中就完成了。
- 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 开发可以得到如下好处:
- 统一管理应用状态,包括统一的机制和唯一的状态容器,这让应用状态的改变更容易预测,也更容易调试。
- 清晰的逻辑拆分,清晰的代码组织方式,让团队的协作更加容易。
- 函数式的编程方式,每个组件都只做一件小事并且是独立的小函数,这增加了应用的可测试性。
- 单向数据流,数据驱动 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 页面,在做页面时,需要考虑:
- 该 ViewController 包含多少子 UIView?子 UIView 是如何组织在一起的?
- 该 ViewController 需要的数据及该数据的格式?
- 该 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 )关注我们。
评论