基于ReSwift和App Coordinator的iOS架構(gòu)
一、iOS架構(gòu)漫談
當(dāng)我們在談iOS應(yīng)用架構(gòu)時,我們聽到最多的是MVC,MVVM,VIPER這三個Buzz Word,他們的邏輯一脈相承,不斷的從ViewController中把邏輯拆分出去。從蘋果官方推薦的MVC:
(圖片來自:http://t.cn/R4vP8Ko)
隨著系統(tǒng)的復(fù)雜,把功能進(jìn)行細(xì)化,把整合View展示數(shù)據(jù)的邏輯獨(dú)立出來形成ViewModel模塊,架構(gòu)風(fēng)格就變成了MVVM:
(圖片來自:http://t.cn/R4vP8Ko)
隨著系統(tǒng)的更加復(fù)雜,把路由的職責(zé),獲取數(shù)據(jù)的職責(zé)也獨(dú)立出去,架構(gòu)風(fēng)格就變成了VIPER:
(圖片來自:http://t.cn/R4vP8Ko)
本文則想從另一個角度和大家探討一個新的iOS應(yīng)用架構(gòu)方案,架構(gòu)的本質(zhì)是管理復(fù)雜性,在討論具體的架構(gòu)方案前,我們首先應(yīng)該明確一個iOS應(yīng)用的開發(fā),其復(fù)雜性在哪里?
二、iOS應(yīng)用的開發(fā)復(fù)雜度
對于一個iOS應(yīng)用來說,其開發(fā)的復(fù)雜性主要體現(xiàn)在三個方面:
1. 復(fù)雜界面設(shè)計的實現(xiàn)和樣式管理
iOS App最終呈現(xiàn)給用戶的是一組組的UI界面,而對于一個特定的App來說,其UI的設(shè)計元素(如配色,字體大小,間距等)基本上是固定的,另外,組成該App的基礎(chǔ)組件(如Button種類,輸入框種類等)也是有限的。但是如何管理、組合、重用組件則是架構(gòu)師需要考慮的問題,尤其是一些App在開發(fā)過程中可能出現(xiàn)大量的UI樣式重構(gòu),更需要清晰的控制住重構(gòu)的影響范圍。這兒的復(fù)雜性本質(zhì)上是UI組件自身設(shè)計實現(xiàn)的復(fù)雜性,多UI組件之間的組合方式和UI組件的重用機(jī)制。
2. 路由設(shè)計
對于一個大型的iOS應(yīng)用,通常會把其功能按Feature拆分,經(jīng)過這樣的拆分之后,其可能出現(xiàn)的路由有以下幾種:
APP間路由: 從其它App調(diào)起當(dāng)前App,并進(jìn)入一個很深層次的頁面(圖示1)。
APP內(nèi)路由:
- 啟動進(jìn)入App的Home頁面(圖示2)
- 從Home頁面到進(jìn)Feature Flow(圖示3)
- Feature內(nèi)按流程的頁面的路由(圖示4)
- 各Feature之間的頁面跳轉(zhuǎn)(圖示5)
- 各Feature共享的單點(diǎn)信息頁的跳轉(zhuǎn)(圖示6)
根據(jù)Apple官方的MVC架構(gòu),這些復(fù)雜的各種跳轉(zhuǎn)邏輯,以及跳轉(zhuǎn)前的ViewController的準(zhǔn)備工作等邏輯纏繞在AppDelegate的初始化,ViewController的UI邏輯中。這兒的復(fù)雜性主要是UI和業(yè)務(wù)之間纏繞不清的相互耦合。
3. 應(yīng)用狀態(tài)管理
一個iOS應(yīng)用本質(zhì)上就是一個狀態(tài)機(jī),從一個狀態(tài)的UI由User Action或者API調(diào)用返回的Data Action觸發(fā)達(dá)到下一個狀態(tài)的UI。為了準(zhǔn)確的控制應(yīng)用功能,開發(fā)者需要能夠清楚的知道:
- 應(yīng)用的當(dāng)前UI是由哪些狀態(tài)決定的?
- User Action會影響哪些應(yīng)用狀態(tài)?如何影響的?
- Data Action會影響哪些應(yīng)用狀態(tài)?如何影響的?
在MVC,MVVM,VIPER的架構(gòu)中,應(yīng)用的狀態(tài)分散在Model或者Entity中,甚至有些狀態(tài)直接保存在View Controller中,在跟蹤狀態(tài)時經(jīng)常需要跨越多個Model,很難獲取到一個全貌的應(yīng)用狀態(tài)。另外,對于Action會如何影響應(yīng)用的狀態(tài)跟蹤起來也比較困難,尤其是當(dāng)一個Action產(chǎn)生的影響路徑不同,或最終可能導(dǎo)致多個Model的狀態(tài)發(fā)生改變時。這兒的復(fù)雜性主要體現(xiàn)在治理分散的狀態(tài),以及管理不統(tǒng)一的狀態(tài)改變機(jī)制帶來的復(fù)雜性。
三、如何管理這些復(fù)雜度
前面明確了iOS應(yīng)用開發(fā)的復(fù)雜性所在,那么從架構(gòu)層面上應(yīng)該如何去管理這些復(fù)雜性呢?
1. 使用Atomic Design和Component Driven Development管理界面開發(fā)的復(fù)雜度
UI界面的復(fù)雜度本質(zhì)上是一個點(diǎn)上的復(fù)雜度,其復(fù)雜性集中在系統(tǒng)的某些小細(xì)節(jié)處,不會增加系統(tǒng)整體規(guī)劃的復(fù)雜度,所以控制其復(fù)雜度的主要方式是隔離,避免一個UI組件之間的相互交織,變成一個面上的復(fù)雜度,導(dǎo)致復(fù)雜度不可控。在UI層,***的隔離方式就是組件化,在筆者之前的一篇文章《前端組件化方案》中詳細(xì)解釋了前端組件化方案的實施細(xì)節(jié),這兒就不再贅述。
2. 使用App Coordinator統(tǒng)一管理應(yīng)用路由
應(yīng)用的路由主要分為App間路由和App內(nèi)路由,對它們需要分別處理
(1) App間路由
對于APP之間的路由,主要通過兩種方式實現(xiàn):
一種是URL Scheme 通過在當(dāng)前App中配置進(jìn)行相應(yīng)的設(shè)置,即可從別的APP跳轉(zhuǎn)到當(dāng)前APP。進(jìn)入當(dāng)前App之后,直接在AppDelegate中的方法:
- func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool
轉(zhuǎn)換進(jìn)App內(nèi)的路由。
另一種是Universal Links,同樣的通過在當(dāng)前App中進(jìn)行配置,當(dāng)用戶點(diǎn)擊URL就會跳轉(zhuǎn)到當(dāng)前的App里。進(jìn)入當(dāng)前APP之后,直接在AppDelegate中的方法:
- func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]?) -> Void) -> Bool
中轉(zhuǎn)進(jìn)App內(nèi)路由。
所以App間的路由邏輯相對簡單,就是一個把外部URL映射到內(nèi)部路由中。這部分只需要增加一個URL Scheme或Universal Link對應(yīng)到App內(nèi)路由的處理邏輯即可。
(2) App內(nèi)路由
對于內(nèi)部路由,我們可以引入App Coordinator來管理所有路由。App Coordinator是Soroush Khanlou在2015年的NSSpain演講上提出的一個模式,其本質(zhì)上是Martin Fowler在《Patterns of Enterprise Application Architecture》中描述的Application Controller模式在iOS開發(fā)上的應(yīng)用。其核心理念如下:
- 抽象出一個Coordinator對象概念。
- 由該Coordinator對象負(fù)責(zé)ViewController的創(chuàng)建和配置。
- 由該Coordinator對象來管理所有的ViewController跳轉(zhuǎn)。
- Coordinator可以派生子Coordinator來管理不同的Feature Flow。
經(jīng)過這層抽象之后,一個復(fù)雜App的路由對應(yīng)關(guān)系就會如下:
從圖中可以看出,應(yīng)用的UI和業(yè)務(wù)邏輯被清晰的拆分開,各自有了自己清晰的職責(zé)。ViewController的初始化,ViewController之間的鏈接邏輯全部都轉(zhuǎn)移到App Coordinator的體系中去了,ViewController則徹底變成了一個個獨(dú)立的個體,其只負(fù)責(zé):
- 自己界面內(nèi)的子UIView組織;
- 接收數(shù)據(jù)并把數(shù)據(jù)綁定到對應(yīng)的子UIView展示;
- 把界面上的user action轉(zhuǎn)換為業(yè)務(wù)上的user intents,然后轉(zhuǎn)入App Coordinator中進(jìn)行業(yè)務(wù)處理。
通過引入AppCoordinator之后,UI和業(yè)務(wù)邏輯被拆分開,各自處理自己負(fù)責(zé)的邏輯。在iOS應(yīng)用中,路由的底層實現(xiàn)還是UINavigationController提供的present,push,pop等函數(shù),在其之上,iOS社區(qū)出了各種封裝庫來更好的封裝ViewController之間的跳轉(zhuǎn)接口,如JLRoutes,routable-ios,MGJRouter等,在這個基礎(chǔ)上我們來進(jìn)一步思考App Coordinator,其概念核心是把ViewController跳轉(zhuǎn)和業(yè)務(wù)邏輯一起抽象為user intents(用戶意圖),對于開發(fā)者具體使用什么樣的方式實現(xiàn)的跳轉(zhuǎn)邏輯并沒有限制,而路由的實現(xiàn)方式在一個應(yīng)用中的影響范圍非常廣,切換路由的實現(xiàn)方式基本上就是一次全App的重構(gòu)(做過React應(yīng)用的react-router0.13升級的朋友應(yīng)該深有體會)。所以在App Coordinator的基礎(chǔ)之上,還可以引入Protocol-Oriented Programming的概念,在App Coordinator的具體實現(xiàn)和ViewController之間抽象一層Protocols,把UI和業(yè)務(wù)邏輯的實現(xiàn)徹底抽離開。經(jīng)過這層抽象之后,路由關(guān)系變化如下:
經(jīng)過App Coordinator統(tǒng)一處理路由之后,App可以得到如下好處:
- ViewController變得非常簡單,成為了一個概念清晰的,獨(dú)立的UI組件。這極大的增加了其可復(fù)用性。
- UI和業(yè)務(wù)邏輯的抽離也增加了業(yè)務(wù)代碼的可復(fù)用性,在多屏?xí)r代,當(dāng)你需要為當(dāng)前應(yīng)用增加一個iPad版本時,只需要重新做一套iPad UI對接到當(dāng)前iPhone版的App Coordinator中就完成了。
- App Coordinator定義與實現(xiàn)的分離,UI和業(yè)務(wù)的分離讓應(yīng)用在做A/B Testing時變得更加容易,可以簡單的使用不同實現(xiàn)的Coordinator,或者不同版本的ViewController即可。
3. 使用ReSwift管理應(yīng)用狀態(tài)
前面提到引入App Coordinator之后,ViewController剩下的職責(zé)之一就是“接收數(shù)據(jù)并把數(shù)據(jù)綁定到對應(yīng)的子UIView展示”,這兒的數(shù)據(jù)來源就是應(yīng)用的狀態(tài)。它山之石,可以攻玉,不只是iOS應(yīng)用有復(fù)雜狀態(tài)管理的問題,在越來越多的邏輯往前端遷移的時代,所有的前端都面臨著類似的問題,而目前Web前端最火的Redux就是為了解決這個問題誕生的狀態(tài)管理機(jī)制,而ReSwift則把這套機(jī)制帶入了iOS的世界。這套機(jī)制中主要有一下幾個概念:
- App State: 在一個時間點(diǎn)上,應(yīng)用的所有狀態(tài). 只要App State一樣,應(yīng)用的展現(xiàn)就是一樣的。
- Store: 保存App State的對象,其還負(fù)責(zé)發(fā)送Action更新App State。
- Action: 表示一次改變應(yīng)用狀態(tài)的行為,其本身可以攜帶用以改變App State的數(shù)據(jù)。
- Reducer: 一個接收當(dāng)前App State和Action,返回新的App State的小函數(shù)。
在這個機(jī)制下, 一個App的狀態(tài)轉(zhuǎn)換如下:
- 啟動初始化App State -> 初始化UI,并把它綁定到對應(yīng)的App State的屬性上
- 業(yè)務(wù)操作 -> 產(chǎn)生Action -> Reducer接收Action和當(dāng)前App State產(chǎn)生新的AppState -> 更新當(dāng)前State -> 通知UI AppState有更新 -> UI顯示新的狀態(tài) -> 下一個業(yè)務(wù)操作……
在這個狀態(tài)轉(zhuǎn)換的過程中,需要注意,業(yè)務(wù)操作會有兩類:
- 無異步調(diào)用的操作,如點(diǎn)擊界面把界面數(shù)據(jù)存儲到App State上;這類操作處理起來非常簡單,按照上面提到的狀態(tài)轉(zhuǎn)換流程走一圈即可。
- 有異步調(diào)用的操作。如點(diǎn)擊查詢,調(diào)用API,數(shù)據(jù)返回之后再存儲到App State上。這類操作就需要引入一個新的邏輯概念(Action Creators)來處理,通過Action Creators來處理異步調(diào)用并分發(fā)新的Action。
整個App的狀態(tài)變換過程如下:
無異步調(diào)用操作的狀態(tài)流轉(zhuǎn)
有異步調(diào)用操作的狀態(tài)流轉(zhuǎn)
經(jīng)過ReSwift統(tǒng)一管理應(yīng)用狀態(tài)之后,App開發(fā)可以得到如下好處:
- 統(tǒng)一管理應(yīng)用狀態(tài),包括統(tǒng)一的機(jī)制和唯一的狀態(tài)容器,這讓應(yīng)用狀態(tài)的改變更容易預(yù)測,也更容易調(diào)試。
- 清晰的邏輯拆分,清晰的代碼組織方式,讓團(tuán)隊的協(xié)作更加容易。
- 函數(shù)式的編程方式,每個組件都只做一件小事并且是獨(dú)立的小函數(shù),這增加了應(yīng)用的可測試性。
- 單向數(shù)據(jù)流,數(shù)據(jù)驅(qū)動UI的編程方式。
四、整理后的iOS架構(gòu)
經(jīng)過上面的大篇幅介紹,我們來歸納下結(jié)合了App Coordinator和ReSwift的一個iOS App的整體架構(gòu)圖:
五、架構(gòu)實戰(zhàn)
上面已經(jīng)講解了整體的架構(gòu)原理,”Talk is cheap”, 接下來就以Raywendlich上面的這個App為例來看看如何實踐這個架構(gòu)。
(圖片來自:http://t.cn/RCO2Sa0)
1. ***步:構(gòu)建UI組件
在構(gòu)建UI組件時,因為每個組件都是獨(dú)立的,所以團(tuán)隊可以并發(fā)的做多個UI頁面,在做頁面時,需要考慮:
- 該ViewController包含多少子UIView?子UIView是如何組織在一起的?
- 該ViewController需要的數(shù)據(jù)及該數(shù)據(jù)的格式?
- 該ViewController需要支持哪些業(yè)務(wù)操作?
以***個頁面為例:
- class SearchSceneViewController: BaseViewController {
- //定義業(yè)務(wù)操作的接口
- var searchSceneCoordinator:SearchSceneCoordinatorProtocol?
- //子組件
- var searchView:SearchView?
- //該UI接收的數(shù)據(jù)結(jié)構(gòu)
- private func update(state: AppState) {
- if let searchCriteria = state.property.searchCriter {
- searchView?.update(searchCriteria: searchCriteria) } }?
- //支持的業(yè)務(wù)操作
- 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的形式從外部注入,組件內(nèi)命名更組件化,不應(yīng)包含業(yè)務(wù)含義。
其它的幾個ViewController也依法炮制,完成所有UI組件,這步完成之后,我們就有了App的所有UI組件,以及UI支持的所有操作接口。下一步就是把他們串聯(lián)起來,根據(jù)業(yè)務(wù)邏輯完成User Journey。
2. 第二步:構(gòu)建App Coordinators串聯(lián)所有的ViewController
首先,在AppDelegate中加入AppCoordinator,把路由跳轉(zhuǎn)的邏輯轉(zhuǎn)移到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中實現(xiàn)首頁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)
- }
- }
在上一步中我們已經(jīng)為每個ViewController定義好對應(yīng)的CoordinatorProtocol,也會在這一步中實現(xiàn)
- 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. 從上面的的代碼中可以看出,我們跳轉(zhuǎn)邏輯中只做了兩件事:初始化ViewController和裝配該ViewController對應(yīng)的Coordinator。這步完成之后,所有UI之間就已經(jīng)按照業(yè)務(wù)邏輯串聯(lián)起來了。下一步就是根據(jù)業(yè)務(wù)邏輯,讓用App State在UI之間流轉(zhuǎn)起來。
3. 第三步:引入ReSwift架構(gòu)構(gòu)建Redux風(fēng)格的應(yīng)用狀態(tài)管理機(jī)制
首先,跟著ReSwift官方指導(dǎo)選取你喜歡的方式引入ReSwift框架,筆者使用的是Carthage。
(1) 定義App State
然后,需要根據(jù)業(yè)務(wù)定義出整個App的State,定義State的方式可以從業(yè)務(wù)上建模,也可以根據(jù)UI需求來建模,筆者偏向于從UI需求建模,這樣的State更容易和UI進(jìn)行綁定。在本例中主要的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
- )
(2) 把App State綁定到對應(yīng)的UI上
注入之后,就可以把AppState中的屬性綁定到對應(yīng)的UI上了,注意,接收數(shù)據(jù)綁定應(yīng)該是每個頁面的頂層ViewController,其它的子View都應(yīng)該只是以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)
- }
和實現(xiàn)StoreSubscriber的newState方法
- class SearchSceneViewController: StoreSubscriber {
- ......
- override func newState(state: AppState) {
- self.update(state: state)
- super.newState(state: state)
- }
- ......
- }
經(jīng)過綁定之后,每一次的AppState修改都會通知到ViewController,ViewController就可以根據(jù)AppState中的內(nèi)容更新自己的UI了。
(3) 定義Actions和Reducers實現(xiàn)App State更新機(jī)制
綁定好UI和AppState之后,接下來就應(yīng)該實現(xiàn)改變AppState的機(jī)制了,首先需要定義會改變AppState的Action們
- struct UpdateSearchCriteria: Action {
- let searchCriteria:SearchCriteria
- }
- ......
然后,在AppCoordinator中根據(jù)業(yè)務(wù)邏輯把對應(yīng)的Action分發(fā)出去, 如果有異步請求,還需要使用ActionCreator來請求數(shù)據(jù),然后再生成Action發(fā)送出去
- 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分發(fā)出去之后,初始化Store時注入的Reducer就會接收到相應(yīng)的Action,并根據(jù)自己的業(yè)務(wù)邏輯和當(dāng)前App State的狀態(tài)生成一個新的App State
- func propertyReducer(_ state: PropertyState?, action: Action) -> PropertyState {
- var statestate = state ?? PropertyState()
- switch action {
- case let action as UpdateSearchCriteria:
- state.searchCriteria = action.searchCriteria
- ...
- default:
- break
- }
- return state
- }
最終Store以Reducer生成的新App State替換掉老的App State完成了應(yīng)用狀態(tài)的更新。
以上三步就是一個完整的架構(gòu)實踐步驟,該示例的所有源代碼可以在筆者的Github上找到。
六、總結(jié)
以解決掉Massive ViewController的iOS應(yīng)用架構(gòu)之爭持續(xù)多年,筆者也參與了公司內(nèi)外的多場討論,架構(gòu)本無好壞,只是各自適應(yīng)不同的上下文而已。本文中提到的架構(gòu)方式使用了多種模式,它們各自解決了架構(gòu)上的一些問題,但并不是一定要捆綁在一起使用,大家完全可以根據(jù)需要裁剪出自己需要的模式,希望本文中提到的架構(gòu)模式能夠給你帶來一些啟迪。
【本文是51CTO專欄作者“ThoughtWorks”的原創(chuàng)稿件,微信公眾號:思特沃克,轉(zhuǎn)載請聯(lián)系原作者】