基于ReSwift和App Coordinator的iOS架构

1,516 阅读18分钟
原文链接: www.infoq.com

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之间的路由,主要通过两种方式实现:

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

转换进App内的路由。

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

中转进App内路由。

所以App间的路由逻辑相对简单,就是一个把外部URL映射到内部路由中。这部分只需要增加一个URL Scheme或Universal Link对应到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之间的跳转接口,如JLRoutesroutable-iosMGJRouter等,在这个基础上我们来进一步思考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即可。

使用ReSwift管理应用状态

前面提到引入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为例来看看如何实践这个架构。

(图片来源:koenig-media.raywenderlich.com/uploads/201…

第一步:构建UI组件

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

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

以第一个页面为例:

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__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!)    
  }
}__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

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

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

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__    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
    }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__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)
    }
}__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__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)    
  }
}__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

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

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

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__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
    ...
}__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__let mainStore = Store<AppState>(    
  reducer: AppReducer(),    
  state: nil
  )__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

把App State绑定到对应的UI上

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__  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)
    }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

和实现StoreSubscriber的newState方法

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__class SearchSceneViewController: StoreSubscriber {    
  ......    
  override func newState(state: AppState) {        
    self.update(state: state)        
    super.newState(state: state)    
  }    
  ......
} __Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__struct UpdateSearchCriteria: Action {    
  let searchCriteria:SearchCriteria
}
......__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__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()
        }
    }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__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
    }__Tue Sep 19 2017 17:55:59 GMT+0800 (CST)____Tue Sep 19 2017 17:55:59 GMT+0800 (CST)__

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

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

总结

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


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

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