有一定的程式設計經驗之後,會愈來愈感受到程式架構的重要性,在 iOS app 開發的世界裡,最常見的莫過於 MVC 架構,因為它夠簡單而且是蘋果推薦的架構。但當你的程式越來越龐大,流程越來越複雜的時候,就會發現 MVC 架構已經無法滿足需求了。這幾年最為人所知的就是 MVP / MVVM / VIPER / Coordinator 這幾個模式。
我認為這些模式的著眼點都在於「UI」:它們假設你有一套辦法去存取或修改資料,然後它們提出的方案是關於如何處理「界面顯示 / 使用者互動 / 資料存取」之間的關係。
當程式越長越大,要儲存的狀態越來越多,不同畫面之間需要同步的資料也越來越多,我們該如何管理資料的存取、確保其一致性與正確性呢?Facebook 之前提出了 Flux 架構,後來有人提出改良版的 Redux 架構,不管是 Flux 還是 Redux,其重點都是在於「資料的流動是單向的,資料只有一份,並且只有一個角色可以修改資料」。
Flux / Redux 一開始提出是給網站使用的架構,後來有人把它套用到 iOS 開發,不過我查到的資料都是使用 Swift 實作。無可否認使用 Swift 來實作這套架構的確比較方便,只是我很好奇用 Objective-C 的話會有多困難,以下就是我的一些開發過程。
Redux 的四個角色
從上圖可以看到,Redux 架構很簡單,只有四個角色:
Action
- 單純的資料結構。
- 表示它所代表的動作類型,以及附帶的資料。
Store
- 負責收到 Action。
- 負責把 Action 跟最新的 State 傳給 Reducer。
- 負責修改 State,並讓外界可以取得最新的 State。
- 負責送出「State 已經更新」的通知給感興趣的人。
State
- 單純的資料結構。
- 代表整個 app 需要的所有資料。
Reducer
- 單純的函式。
- 輸入是「Action」跟「State」,輸出是「修改過的 State」。
例子:文章列表
現在我們要來寫一個很簡單的 app,它唯一的功能就是跟伺服器要求最新的文章列表,然後一筆筆顯示處理。假設我們的網路功能跟 UI 都設計好了,那該怎麼套用 Redux 架構來處理資料的部分呢?
Action
我會建議一開始由 Action 先規劃。這個例子裡的 Action 很單純,就是用一個 property 來記錄 action type,再用一個 property 來記錄 payload。因為有些 type 不需要附帶資料,所以 payload 是 nullable。這裡我規劃了兩個 type,第一個是取得文章列表之後我需要 SetPosts
來更新 State 裡頭的文章列表,第二個是 AppendPosts
,當我取得下一頁的文章列表之後我要把它附加到 State 原有的列表裡。
/// TLBAction.h
typedef NS_ENUM (NSInteger, TLBActionType) {
TLBActionTypeSetPosts,
TLBActionTypeAppendPosts,
};
@interface TLBAction : NSObject
@property (nonatomic, assign, readonly) TLBActionType type;
@property (nonatomic, strong, readonly, nullable) id payload;
- (instancetype)initWithActionType:(TLBActionType)type payload:(nullable id)payload;
@end
/// TLBAction.m
@interface TLBAction ()
@property (nonatomic, assign, readwrite) TLBActionType type;
@property (nonatomic, strong, readwrite, nullable) id payload;
@end
@implementation TLBAction
- (instancetype)initWithActionType:(TLBActionType)type payload:(id)payload {
if (self = [super init]) {
_type = type;
_payload = payload;
}
return self;
}
@end
State
State 沒什麼好說的,就是一個單純的資料結構,用來儲存會用到的資料。值得一提的是,只要存原始資料就好,可以藉由原始資料推算出的資料不需要存起來。
/// TLBState.h
@interface TLBState : NSObject <NSCopying>
@property (nonatomic, strong) NSOrderedSet <NSString *> *posts;
@end
Reducer
Reducer 是唯一知道該怎麼修改 State 的地方,一個 Reducer 可能只會修改 State 的某一部分。當 Action 越來越多、State 越來越大的時候,也可以將多個 Reducer 合成一個更大的 Reducer。
在原始的 Redux 定義裡頭,Reducer 的格式是 func(state, action) -> state
,傳舊的 state 進去會先產生一個新的 state 再來修改這個新 state,而不是直接修改舊的 state。但在 Objective-C 的世界,這代表在每個 Reducer 裡頭都得產生一個新的 state instance,Reducer 一多的情況就可能對效能造成影響。所以我在這裡把它定義成 typedef void (^TLBReduceBlock)(TLBState **, TLBAction *)
,傳入的是 state 的記憶體位址,在 Reducer 裡頭就可以直接去修改 state,避免一直產生新的 instance 的問題。
要注意的是,你不應該預期 Reducer 會以怎樣的順序被呼叫,它應該是一個 pure function。
/// TLBReducer.h
typedef void (^TLBReduceBlock)(TLBState **, TLBAction *);
@interface TLBReducer : NSObject
+ (NSArray *)availableReduceBlocks;
@end
/// TLBReducer.m
@implementation TLBReducer
+ (NSArray *)availableReduceBlocks {
return @[
[self postActionsReducer]
];
}
+ (TLBReduceBlock)postActionsReducer {
TLBReduceBlock block = ^(TLBState **state, TLBAction *action) {
if (state == NULL) {
return;
}
TLBState *newState = *state;
switch (action.type) {
case TLBActionTypeSetPosts: {
newState.posts = [NSOrderedSet orderedSetWithArray:action.payload];
break;
}
case TLBActionTypeAppendPosts: {
NSMutableOrderedSet *set = [newState.posts mutableCopy];
[set addObjectsFromArray:action.payload];
newState.posts = [set copy];
break;
}
default: {
break;
}
}
};
return block;
}
@end
Store
一個 app 只會有一個 Store,所以它會是一個 singleton。外界會要求它去 dispatch 一個 action,它就會讓全部的 Reducer 依序處理這個 action,並且為了確保一次只有一個 Action 被執行,所以我建立了一個 serial queue 來處理。最後把處理過的結果寫回 State,並通知感興趣的人 State 已更新。通知有很多種實作方式,在這裡我是用 ReactiveCocoa
的 RACSignal
讓別人來訂閱。
/// TLBStore.h
@interface TLBStore : NSObject
@property (nonatomic, strong, readonly) RACSignal *stateObserver;
+ (instancetype)shardInstance;
- (void)dispatchAction:(TLBAction *)action;
- (TLBState *)currentState;
@end
/// TLBStore.m
@interface TLBStore ()
@property (nonatomic, strong, readwrite) RACSignal *stateObserver;
@property (nonatomic, strong) TLBState *state;
@property (nonatomic, strong) NSArray <TLBReduceBlock> *reducers;
@property (nonatomic, strong) dispatch_queue_t serialQueue;
@end
@implementation TLBStore
+ (instancetype)shardInstance {
static TLBStore *_sharedInstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_sharedInstance = [[TLBStore alloc] init];
});
return _sharedInstance;
}
- (instancetype)init {
if (self = [super init]) {
_serialQueue = dispatch_queue_create("Redux Store Action Queue", DISPATCH_QUEUE_SERIAL);
}
return self;
}
- (void)dispatchAction:(TLBAction *)action {
dispatch_async(self.serialQueue, ^{
TLBState *newState = [self.state copy];
for (TLBReduceBlock block in self.reducers) {
block(&newState, action);
}
self.state = newState;
});
}
- (TLBState *)currentState {
return [self.state copy];
}
- (RACSignal *)stateObserver {
if (!_stateObserver) {
_stateObserver = [RACObserve(self, state) replayLast];
}
return _stateObserver;
}
- (TLBState *)state {
if (!_state) {
_state = [[TLBState alloc] init];
}
return _state;
}
- (NSArray <TLBReduceBlock> *)reducers {
if (!_reducers) {
_reducers = [TLBReducer availableReduceBlocks];
}
return _reducers;
}
@end
整個串起來
假如現在我有一個 UIViewController
,我要跟伺服器請求文章列表,取得列表之後就更新我的 tableView
,那使用 ReactiveCocoa
程式碼長得大概像這樣。
/// TLBPostListViewController.m
@interface TLBPostListViewController () <UITableViewDataSource, UITableViewDelegate>
@property (weak, nonatomic) IBOutlet UITableView *tableView;
@property (nonatomic, strong) NSOrderedSet <NSString *> *posts;
@property (nonatomic, strong) RACDisposable *stateObserver;
@end
@implementation TLBPostListViewController
- (void)dealloc {
[_stateObserver dispose];
_stateObserver = nil;
}
- (void)viewDidLoad {
[super viewDidLoad];
[[[TLBNetworkManager shardManager] fetchPost] subscribeNext:^(NSArray *posts) {
// 送出 action 之後就不理會它了,因為我們會監聽 state 的變化
TLBAction *action = [[TLBAction alloc] initWithActionType:TLBActionTypeSetPosts payload:posts];
[[TLBStore shardInstance] dispatchAction:action];
}];
@weakify(self);
// 監聽 state 的變化
self.stateObserver = [[TLBStore shardInstance].stateObserver subscribeNext:^(TLBState *state) {
@strongify(self);
if (![self.posts isEqualToOrderedSet:state.posts]) {
self.posts = [state.posts copy];
[self.tableView reloadData];
}
}];
}
@end
結論
Redux 只是一個處理資料的方案,它可以跟 MVC / MVVM / VIPER / Coordinator 等架構相互配合,因為它們要處理的是不同問題。我覺得使用 Redux 有以下這些優點:
- 架構清晰,每個角色該做什麼事都有明確規定。
- 資料有統一的處理方式,而且資料來源只有一個,確保資料的一致性。
- 團隊可以寫出統一風格的程式碼。
- 可與其他 UI 相關的架構一同使用。
當然它也有缺點:
- 多出不少程式碼。
- 架構變得比較複雜,簡單的小專案不適合用它。
- 會多吃一些記憶體。
- 速度會稍微慢一點(但對大多數人來說應該感覺不出來)。
總結來說,每個架構有其適合的場景,你要先瞭解要解決的問題再來選擇要使用的架構,不要太早優化也不要過度設計了。
Q&A
Q: 我覺得這個例子很單純,根本不需要用到 Redux?
A: 沒錯!我只是為了舉例,現實情況下如果是像這麼簡單的專案,千萬不要搞得如此複雜!
Q: 現實情況下,State 會變得很大一包,可以切小一點嗎?
A: 我覺得可能有兩種解法:
- 針對每個 feature 或頁面,建立
sub-store
,這個 sub-store 提供每個 feature 或頁面需要的sub-state
。 - 針對每個 feature 或頁面,建立
State category
,這個 category 提供每個 feature 或頁面需要的sub-state
。
不管是哪個方法,原始的資料依然全部都存在 State 裡頭,sub-state 的資料都是從原始 State 推導而來。
Q: 如果我的資料是用資料庫(或其他方式)儲存的,該怎麼辦?
A: 你應該在資料持久層上面再加一層存取層,由 Store 去跟存取層溝通,由存取層決定該怎麼把資料實際存到資料庫(或其他地方)。
參考資料
- iOS Architecture Patterns
- Architecting iOS Apps with VIPER
- Brigade’s Experience Using an MVC Alternative
- Khanlou | Coordinators Redux
- Khanlou | The Coordinator
- Improve your iOS Architecture with FlowControllers
- Flow Controllers on iOS for a Better Navigation Control
- Redux
- ReSwift
- Real World Flux Architecture on iOS
- Watch Managing Consistency of Immutable Models
- Immutable models and data consistency in our iOS App