前段時間在RxSwift上做了一些實踐,Rx確實是一個強大的工具,但同時也是一把雙刃劍,如果濫用的話反而會帶來副作用,本文就引入Rx模式之後如何更好的管理應用的狀態和邏輯做了一些粗淺的總結。
本文篇幅較長,主要圍繞著狀態管理這一話題進行介紹,前兩個部分介紹了前端領域中React和Vue所採用的狀態管理模式及其在Swift中的實現,最後介紹了另一種簡化的狀態管理方案。不會涉及複雜的Rx特性,閱讀前對Rx有一些基本的瞭解即可。
為什麼狀態管理這麼重要
一個複雜的頁面通常需要維護大量的變數來表示其執行期間的各種狀態,在MVVM中頁面大部分的狀態和邏輯都通過ViewModel來維護,在常見的寫法中ViewModel和檢視之間通常用Delegate
來通訊,比如說在資料改變的時候通知檢視層更新UI等等:
在這種模式中,ViewModel的狀態更新之後需要我們呼叫Delegate手動通知檢視層。而在Rx中這一層關係被淡化了,由於Rx是響應式的,設定好繫結關係後ViewModel只需要改變資料的值,Rx會自動的通知每一個觀察者:
Rx為我們隱藏了通知檢視的過程,首先這樣的好處是明顯的:ViewModel可以更加專注於資料本身,不用再去管UI層的邏輯;但是濫用這個特性也會帶來麻煩,大量的可觀察變數和繫結操作會讓邏輯變得含糊不清,修改一個變數的時候可能會導致一系列難以預料的連鎖反應,這樣程式碼反而會變得更加難以維護。
想要更好的過渡到響應式程式設計,一個統一的狀態管理方案是不可或缺的。在這一塊前端領域有不少成熟的實踐方案,Swift中也有一些開源庫對其進行了實現,其中的思想我們可以先來參考一下。
下面的介紹中所涉及的示例程式碼在:github.com/L-Zephyr/My…。
Redux - ReSwift
Redux
是Facebook所提出的基於Flux改良的一種狀態管理模式,在Swift中有一個名為ReSwift的開源專案實現了這個模式。
雙向繫結和單向繫結
要理解Redux首先要明白Redux是為了解決什麼問題而生的,Redux為應用提供統一的狀態管理,並實現了單向的資料流。所謂的單向繫結
和雙向繫結
所描述的都是檢視(View)和資料(Model)之間的關係:
比方說有一個展示訊息的頁面,首先需要從網路載入最新的訊息,在MVC中我們可以這樣寫:
class NormalMessageViewController: UIViewController {
var msgList: [MsgItem] = [] // 資料來源
// 網路請求
func request() {
// 1. 開始請求前播放loading動畫
self.startLoading()
MessageProvider.request(.news) { (result) in
switch result {
case .success(let response):
if let list = try? response.map([MsgItem].self) {
// 2. 請求結束後更新model
self.msgList = list
}
case .failure(_):
break
}
// 3. model更新後同步更新UI
self.stopLoading()
self.tableView.reloadData()
}
}
// ...
}
複製程式碼
還可以將不需要的訊息從列表中刪除:
extension NormalMessageViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
// 1. 更新model
self.msgList.remove(at: indexPath.row)
// 2. 重新整理UI
self.tableView.reloadData()
}
}
// ...
}
複製程式碼
在request
方法中我們通過網路請求修改了資料msgList
,一旦msgList
發生改變必須重新整理UI,顯然檢視的狀態跟資料是同步的;在tableView上刪除訊息時,檢視層直接對資料進行操作然後重新整理UI。檢視層即會響應資料改變的事件,又會直接訪問和修改資料,這就是一個雙向繫結的關係:
雖然在這個例子中看起來非常簡單,但是當頁面比較複雜的時候UI操作和資料操作混雜在一起會讓邏輯變得混亂。看到這裡單向繫結
的含義就很明顯了,它去掉了View -> Model
的這一層關係,檢視層不能直接對資料進行修改,它只能通過某種機制向資料層傳遞事件,並在資料改變的時候重新整理UI。
實現
為了構造單向資料流,Redux引入了一系列概念,這是Redux中所描述的資料流:
其中的State
就是應用的狀態,也就是我們的Model部分,先不管這裡的Action
、Reducer
等概念,從圖中可以看到State和View是有著直接的繫結關係的,而View的事件則會通過Action
、Store
等一系列操作間接的改變State
,下面來詳細的介紹一下Redux的資料流的實現以及所涉及到的概念:
-
View 顧名思義,View就是檢視,使用者在檢視上的操作事件不會直接修改模型,而是會被對映成一個個
Action
。 -
Action
Action
表示一個對資料操作的請求,Action會被髮送到Store
中,這是對模型資料進行修改的唯一辦法。在ReSwift中有一個名為Action的協議(僅作標記用的空協議),對於Model中資料的每個操作,比如說設定一個值,都需要有一個對應的Action:
/// 設定資料的Action struct ActionSetMessage: Action { var news: [MsgItem] = [] } /// 移除某項資料的Action struct ActionRemoveMessage: Action { var index: Int } 複製程式碼
用
struct
型別來表示一個Action,Action所攜帶的資料儲存在其成員變數中。 -
Store和State 就像上面所提到的,
State
表示了應用中的Model資料,而Store
則是存放State的地方;在Redux中Store是一個全域性的容器,所有元件的狀態都被儲存在裡面;Store接受一個Action,然後修改資料並通知檢視層更新UI。如下所示,每一個頁面和元件都有各自的狀態以及用來儲存狀態的Store:
// State struct ReduxMessageState: StateType { var newsList: [MsgItem] = [] } // Store,直接使用ReSwift的Store型別來初始化即可,初始化時要指定reducer和狀態的初始值 let newsStore = Store<ReduxMessageState>(reducer: reduxMessageReducer, state: nil) 複製程式碼
Store
通過一個dispatch
方法來接收Action
,檢視呼叫這個方法來向Store傳遞Action:messageStore.dispatch(ActionRemoveMessage(index: 0)) 複製程式碼
-
Reducer
Reducer
是一個比較特殊的函式,這裡其實是借鑑了函式式的一些思想,首先Redux強調了資料的不可變性(Immutable),簡單來說就是一個資料模型在建立之後就不可被修改,那當我們要修改Model某個屬性時要怎麼辦呢?答案就是建立一個新的Model,Reducer
的作用就體現在這裡:Reducer
是一個函式,它的簽名如下:(_ action: Action, _ state: StateType?) -> StateType 複製程式碼
接受一個表示動作的action和一個表示當前狀態的state,然後計算並返回一個新的State,隨後這個新的State會被更新到Store中:
// Store.swift中的實現 open func _defaultDispatch(action: Action) { guard !isDispatching else { raiseFatalError("...") } isDispatching = true let newState = reducer(action, state) // 1. 通過reducer計算出新的state isDispatching = false state = newState // 2. 直接將新的state賦值到當前的state上 } 複製程式碼
應用中所有資料模型的更新操作最終都通過
Reducer
來完成,為了保證這一套流程可以正常的完成,Reducer
必須是一個純函式:它的輸出只取決於輸入的引數,不依賴任何外部變數,同樣也不能包含任何非同步的操作。在這個例子中的
Reducer
是這樣寫的:func reduxMessageReducer(action: Action, state: ReduxMessageState?) -> ReduxMessageState { var state = state ?? ReduxMessageState() // 根據不同的Action對資料進行相應的修改 switch action { case let setMessage as ActionSetMessage: // 設定列表資料 state.newsList = setMessage.news case let remove as ActionRemoveMessage: // 移除某一項 state.newsList.remove(at: remove.index) default: break } // 最後直接返回修改後的整個State結構體 return state } 複製程式碼
最後在檢視中實現StoreSubscriber
協議接收State改變的通知並更新UI即可。詳細的程式碼請看Demo中的Redux
資料夾。
分析
Redux將View -> Model
這一層關係分解成了View -> Action -> Store -> Model
,每一個模組只負責一件事情,資料始終沿著這條鏈路單向傳遞。
-
優點:
-
在處理大量狀態的時候單向資料流更加容易維護,所有事件都通過唯一的入口
dispatch
手動觸發,資料的每一個處理過程都是透明的,這樣就可以追蹤到每一次的狀態變更操作。在前端中Redux的配套工具redux-devtools就提供了一個名為Time Travel
的功能,能夠回溯應用的任意歷史狀態。 -
全域性Store有利於在多個元件之間共享狀態。
-
-
缺點:
-
首先Redux為它的資料流指定了大量的規則,無疑會帶來更高的學習成本。
-
在Redux的核心模型中並沒有考慮非同步(Reducer是純函式),所以如網路請求這樣的非同步任務還需要通過
ActionCreator
之類的機制間接處理,進一步提升了複雜度。 -
另一個被廣為詬病的缺點是,Redux會引入大量樣板程式碼,在上面這個簡單的例子中我們需要為頁面建立Store、State、Reducer、Action等不同的結構:
即便是修改一個狀態變數這樣簡單的操作都需要經過這一套流程,這無疑會大大增加程式碼量。
-
綜上所述,Redux模式雖然有許多優點,但它帶來的成本也無法忽視。如果你的頁面和互動極其複雜或是多個頁面之間有大量的共享狀態的話可以考慮Redux,但是對於大部分應用來說,Redux模式並不太適用。
Vuex - ReactorKit
Vue
也是近年來十分熱門的前端框架之一,Vuex
則是其專門為Vue
提出的狀態管理模式,在Redux之上進行了一些優化;而ReactorKit
是一個Swift的開源庫,它的一些設計理念與Vuex十分相似,所以這裡我將它們放在一起來講。
實現
與ReSwift
不同的是ReactorKit
的實現本身便於基於RxSwift
,所以不必再考慮如何與Rx結合,下面是ReactorKit
中資料的流程圖:
大體流程與Redux類似,不同的是Store
變成了Reactor
,這是ReactorKit
引入的一個新概念,它不要求在全域性範圍統一管理狀態,而是每個元件管理各自的狀態,所以每個檢視元件都有各自所對應的Reactor
。
具體的程式碼請看Demo中的ReactorKit
資料夾,各個部分的含義如下:
-
Reactor:
現在用
ReactorKit
來重寫上面的那個例子,首先需要為這個頁面建立一個實現了Reactor
協議的型別MessageReactor
:class MessageReactor: Reactor { // 與Redux中的Action作用相同,可以是非同步 enum Action { case request case removeItem(Int) } // 表示修改狀態的動作(同步) enum Mutation { case setMessageList([MsgItem]) case removeItem(Int) } // 狀態 struct State { var newsList: [MsgItem] = [] } ... } 複製程式碼
一個Reactor需要定義
State
、Action
、Mutation
這三個部分,後面會一一介紹。首先比起Redux這裡多了一個
Mutation
的概念,在Redux中由於Action直接與Reducer中的操作對應,所以Action只能用來表示同步的操作。ReactorKit
將這個概念更加細化,拆分成了兩個部分:Action
和Mutation
:Action
:檢視層觸發的動作,可以表示同步和非同步(比如網路請求),它最終會被轉換成Mutation再被傳遞到Reducer中;Mutation
:只能表示同步操作,相當於Redux模式中的Action,最終被傳入Reducer中參與新狀態的計算;
-
mutate():
mutate()
是Reactor中的一個方法,用來將使用者觸發的Action
轉換成Mutation
,mutate()
的存在使得Action可以表示非同步操作,因為無論是非同步還是同步的Action最後都會被轉換成同步的Mutation:func mutate(action: MessageReactor.Action) -> Observable<MessageReactor.Mutation> { switch action { case .request: // 1. 非同步:網路請求結束後將得到的資料轉換成Mutation return service.request().map { Mutation.setMessageList($0) } case .removeItem(let index): // 2. 同步:直接用just包裝一個Mutation return .just(Mutation.removeItem(index)) } } 複製程式碼
值得一提的是,這裡的
mutate()
方法返回的是一個Observable<Mutation>
型別的例項,得益於Rx強大的描述能力,我們可以用一致的方式來處理同步和非同步程式碼。 -
reduce():
reduce()
方法這裡就沒太多可說的了,它扮演的角色與Redux中的Reducer一樣,唯一不同的是這裡接受的是一個Mutation
型別,但本質是一樣的:func reduce(state: MessageReactor.State, mutation: MessageReactor.Mutation) -> MessageReactor.State { var state = state switch mutation { case .setMessageList(let news): state.newsList = news case .removeItem(let index): state.newsList.remove(at: index) } return state } 複製程式碼
-
Service
圖中還有一個與
mutate()
產生互動的Service
物件,Service指的是實現具體業務邏輯的地方,Reactor
會通過各個Service
物件來執行具體的業務邏輯,比如說網路請求:protocol MessageServiceType { /// 網路請求 func request() -> Observable<[MsgItem]> } final class MessageService: MessageServiceType { func request() -> Observable<[MsgItem]> { return MessageProvider .rx .request(.news) .mapModel([MsgItem].self) .asObservable() } } 複製程式碼
看到這裡
Reactor
的本質基本上已經明瞭:Reactor
實際上是一箇中間層,它負責管理檢視的狀態,並作為檢視和具體業務邏輯之間通訊的橋樑。
此外ReactorKit
希望我們的所有程式碼都通過函式響應式(FRP)的風格來編寫,這從它的API設計上可以看出:Reactor
型別中沒有提供如dispatch
這樣的方法,而是隻提供了一個Subject
型別的變數action
:
var action: ActionSubject<Action> { get }
複製程式碼
在Rx中Subject
既是觀察者又是可觀察物件,常常扮演一箇中間橋樑的角色。檢視上所有的Action
都通過Rx繫結到action
變數上,而不是通過手動觸發的方式:比方說我們想在viewDidLoad
的時候發起一個網路請求,常規的寫法是這樣的:
override func viewDidLoad() {
super.viewDidLoad()
service.request() // 手動觸發一個網路請求動作
}
複製程式碼
而ReactorKit
所推崇的函式式風格是這樣的:
// bind是統一進行事件繫結的地方
func bind(reactor: MessageReactor) {
self.rx.viewDidLoad // 1. 將viewDidLoad作為一個可觀察的事件
.map { Reactor.Action.request } // 2. 將viewDidLoad事件轉成Action
.bind(to: reactor.action) // 3. 繫結到action變數上
.disposed(by: self.disposeBag)
// ...
}
複製程式碼
bind
方法是檢視層進行事件繫結的地方,我們將VC的viewDidLoad
作為一個事件源,將其轉換成網路請求的Action之後繫結到reactor.action
上,這樣當VC的viewDidLoad被呼叫時該事件源就會發出一個事件並觸發Reactor
中網路請求的操作。
這樣的寫法是更加FRP,一切都是事件流,但是實際用起來並不是那麼完美。首先我們需要為用到的所有UI元件提供Rx擴充套件(上面的例子使用了RxViewController這個庫);其次這對reactor例項初始化的時機有更加嚴格的要求,因為bind
方法是在reactor例項初始化的時候自動呼叫的,所以不能在viewDidLoad
中初始化,否則會錯過viewDidLoad
事件。
分析
- 優點:
- 相比ReSwift簡化了一些流程,並且以元件為單位來管理各自的狀態,相比起來更容易在現有工程中引入;
- 與
RxSwfit
很好的結合在了一起,能提供較為完善的函式響應式(FRP)開發體驗;
- 缺點:
- 因為核心思想還是Redux模式,所以模板程式碼過多的問題還是無法避免;
另一種簡化方案
Redux模式對於大部分應用來說還是過於沉重了,而且Swift的語言特性也不像JavaScript那樣靈活,很多樣板程式碼無法避免。所以這裡總結了另一套簡化的方案,希望能在享受單向資料流優勢的同時減輕使用者的負擔。
詳細的程式碼請看Demo中的Custom
資料夾:
實現非常簡單,核心是一個Store
型別:
public protocol StateType { }
public class Store<ConcreteState>: StoreType where ConcreteState: StateType {
public typealias State = ConcreteState
/// 狀態變數,一個只讀型別的變數
public private(set) var state: State
/// 狀態變數對應的可觀察物件,當狀態發生改變時`rxState`會傳送相應的事件
public var rxState: Observable<State> {
return _state.asObservable()
}
/// 強制更新狀態,所有的觀察者都會收到next事件
public func forceUpdateState() {
_state.onNext(state)
}
/// 在一個閉包中更新狀態變數,當閉包返回後一次性應用所有的更新,用於更新狀態變數
public func performStateUpdate(_ updater: (inout State) -> Void) {
updater(&self.state)
forceUpdateState()
}
...
}
複製程式碼
其中StateType
是一個空協議,僅作為型別約束用;Store
作為一個基類,負責儲存元件的狀態,以及管理狀態更新的資料來源,核心程式碼非常簡單,下面來看一下實際應用。
ViewModel
在實際開發中我讓ViewModel
來處理狀態管理和變更的邏輯,再來實現一次上面的那個例子,將一個業務方的ViewModel
分成三個部分:
// <1>
struct MessageState: StateType {
...
}
// <2>
extension Reactive where Base: MessageViewModel {
...
}
// <3>
class MessageViewModel: Store<MessageState> {
required public init(state: MessageState) {
super.init(state: state)
}
...
}
複製程式碼
各個部分的含義如下:
-
定義頁面的狀態變數
描述一個頁面所需的所有狀態變數都需要定義在一個單獨的實現了
StateType
協議的struct
中:struct MessageState: StateType { var msgList: [MsgItem] = [] // 原始資料 } 複製程式碼
從前面的程式碼中可以看到
Store
中有一個只讀的state
屬性:public private(set) var state: State 複製程式碼
業務方的ViewModel直接通過
self.state
來訪問當前的狀態變數。而修改狀態變數則通過一個performStateUpdate
方法來完成,方法簽名如下:public func performStateUpdate(_ updater: (inout State) -> Void) 複製程式碼
ViewModel在修改狀態變數的時候通過
updater
閉包中的引數直接進行修改:performStateUpdate { $0.msgList = [...] } // 修改狀態變數 複製程式碼
執行完畢後頁面的狀態會被更新,所繫結的UI元件也會接受到狀態更新的事件。這樣一來能避免為每一個狀態變數建立一個Action,簡化了流程,同時所有更新狀態的操作都由經過同一個入口,有利於之後的分析。
統一管理狀態變數有以下幾個優點:
- *邏輯清晰:*在瀏覽頁面的程式碼時只要檢視這個型別就能知道哪些變數是需要特別關注的;
- *頁面持久化:*只需序列化這個結構體就能夠儲存這個頁面的全部資訊,在恢復時只需要將反序列化出來的State賦值給
ViewModel
的state
變數即可:self.state = localState
;
- *便於測試:*單元測試時可以通過檢查State型別的變數來進行測試;
-
定義對外暴露的可觀察變數(Getter)
ViewModel需要暴露一些能讓檢視進行繫結的可觀察物件(Observable),
Store
中提供了一個名為rxState
的Observable<State>
型別物件作為狀態更新的統一事件源,但是為了更加便於檢視層使用,我們需要將其進一步細化。這部分邏輯定義在ViewModel的
Rx擴充套件
中,對外提供可觀察的屬性,這裡定義了檢視層需要繫結的所有狀態。這部分的作用相當於Getter
,是檢視層從ViewModel中獲取資料來源的介面:extension Reactive where Base: MessageViewModel { var sections: Observable<[MessageTableSectionModel]> { return base .rxState // 從統一的事件源rxState中分流 .map({ (state) -> [MessageTableSectionModel] in // 將VM中的後端原始模型型別轉換成UI層可以直接使用的檢視模型 return [ MessageTableSectionModel(items: state.msgList.map { MessageTableCellModel.news($0) }) ] }) } } 複製程式碼
這樣一來檢視層不需要關心
State
中的資料型別,直接通過rx
屬性來獲取自己需要觀察的屬性即可:// 檢視層直接觀察sections,不需要關心內部的轉換邏輯 vm.rx.sections.subscribe(...) 複製程式碼
為什麼要將檢視層使用的介面定義在擴充套件中,而不是直接觀察基類中的
rxState
:- 定義在Rx擴充套件中的變數可以直接通過ViewModel的rx屬性訪問到,便於檢視層使用;
- State中的原始資料可能需要一定轉換才能讓檢視層使用(比如上面將原始的
MsgItem
型別轉換成TableView可以直接使用的SectionModel模型),這部分的邏輯適合放在擴充套件的計算屬性中,讓檢視層更加純粹;
-
對外提供的方法(Action)
ViewModel還需要接收檢視層的事件以觸發具體的業務邏輯,如果這一步通過Rx繫結的方式來完成的話,會對業務層程式碼的編寫方式帶來很多限制(參考上面的ReactorKit)。所以這部分不做過多的封裝,還是通過方法的形式來對外暴露介面,這部分就相當於Action,不過這樣的代價是Action無法再通過統一的介面來派發:
class MessageViewModel: Store<MessageState> { // 請求 func request() { state.loadingState = .loading MessageProvider.rx .request(.news) .map([MsgItem].self) .subscribe(onSuccess: { (items) in // 請求完成後改變state中響應的變數,UI層會自動響應 self.performStateUpdate { $0.msgList = items $0.loadingState = .normal } }, onError: { error in self.performStateUpdate { $0.loadingState = .normal } }) .disposed(by: self.disposeBag) } } 複製程式碼
我們之前已經將狀態和UI完全分離開來了,所以在ViewModel的邏輯中只需要關心
state
中的狀態即可,不需要關心與檢視層的互動,所以以這種方式編寫的程式碼同樣也是十分清晰的。
View
檢視層需要實現一個名為View
的協議,這裡主要參考了ReactorKit
中的設計:
/// 檢視層協議
public protocol View: class {
/// 用於宣告該檢視對應的ViewModel的型別
associatedtype ViewModel: StoreType
/// ViewModel的例項,有預設實現,檢視層需要在合適的時機初始化
var viewModel: ViewModel? { set get }
/// 檢視層實現這個方法,並在其中進行繫結
func doBinding(_ vm: ViewModel)
}
複製程式碼
對於檢視層來說,它需要做兩件事:
-
實現一個
doBinding
方法,所有的Rx事件繫結都放在這個方法中完成:func doBinding(_ vm: MessageViewModel) { vm.rx.sections .drive(self.tableView.rx.items(dataSource: dataSource)) .disposed(by: self.disposeBag) } 複製程式碼
-
在合適的時機初始化
viewModel
屬性:override func viewDidLoad() { super.viewDidLoad() // 初始化ViewModel self.viewModel = MessageViewModel(state: MessageState()) } 複製程式碼
當
viewModel
初始化完成後會自動呼叫doBinding
方法進行繫結,並且在例項的生命週期中只會被執行一次。
在檢視層中對於各種狀態的繫結是很重要的一個環節,View
協議存在的意義在於將檢視層的事件繫結規範化,防止繫結操作的程式碼散落在各處降低可讀性。
資料流
按照以上流程實現的頁面資料流如下:
- 檢視(View)中的事件觸發時,直接呼叫相應的方法觸發
ViewModel
中的邏輯; ViewModel
中執行具體的業務邏輯,並通過performStateUpdate
修改儲存在State中的狀態變數;- 狀態變數發生改變之後,通過Rx的繫結自動通知檢視層更新UI;
這樣能保證一個頁面的資料始終按照預期的方式來變化,而且單向資料流的特點使得我們可以像Redux這樣追蹤所有狀態的變更,比如說我們可以簡單的利用Swift的反射(Mirror
)來將所有狀態的變更列印到控制檯中:
public func performStateUpdate(_ updater: (inout State) -> Void) {
updater(&self.state)
#if DEBUG
StateChangeRecorder.shared.record(state, on: self) // 記錄狀態的變更
#endif
forceUpdateState()
}
複製程式碼
實現的程式碼在StateChangeRecorder.swift
檔案中,非常簡單隻有不到100行。每當有狀態發生改變的時候就會在控制檯中列印一條Log:
如果你為所有StateType
型別實現序列化和反序列化的操作,甚至可以實現類似redux-devtools這樣的Time Travel
功能,這裡就不再繼續引申了。
總結
引入Rx模式需要多方面的考慮,本文僅針對狀態管理這一點作了介紹,上面介紹的三種方案各有特點,最終的選擇還是要結合專案的實際情況來判斷。