Nelson 寫些 iOS 開發的東東

用 Objective-C 實作 Redux 架構

| Comments

前言

有一定的程式設計經驗之後,會愈來愈感受到程式架構的重要性,在 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 已更新。通知有很多種實作方式,在這裡我是用 ReactiveCocoaRACSignal 讓別人來訂閱。

/// 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 去跟存取層溝通,由存取層決定該怎麼把資料實際存到資料庫(或其他地方)。

參考資料

Comments

comments powered by Disqus