用 ReSwift 實現 Redux 架構

image.png

隨著 app 的發展, MVC 漸漸的滿足不了業務的需求。大家都在探索各種各樣的架構模式來適應這種情況,像是MVVM、VIPER、Riblets 等等。 他們都有各自的特點,但是都有同一個核心: 通過多向數據流將代碼按照單一職責原則來劃分代碼。在多向數據流中,數據在各個模塊中傳遞。

多向數據流并不一定是你想要的,反而,單向數據流才是我們更喜歡的數據傳遞方式。在這個 ReSwift 的教程中,你會學到如何使用 ReSwift 來實現單向數據流,并完成一個狀態驅動的游戲——MemoryTunes

什么是 ReSwift

ReSwift 是一個輕量級的框架,能夠幫助你很輕松的去構建一個 Redux 架構的app。當然它是用Swift 實現的。

RxSwift 有以下四個模塊

  • Views: 響應 Store 的改變,并且把他們展示在頁面上。views 發出 Actions
  • Actions:發起app 種狀態的改變。Action 是有 Reducer 操作的。
  • Reducers: 直接改變程序的狀態,這些狀態由 Store 來保存。
  • Store:保存當前的程序的狀態。其他模塊,比如說 Views 可以訂閱這個狀態,并且響應狀態的改變。

ReSwift 至少有以下這些優勢:

  • 很強的約束力:把一些代碼放在不合適的地方往往具有很強的誘惑性,雖然這樣寫很方便。ReSwift 通過很強的約束力來避免這種情況。
  • 單向數據流:多向數據流的代碼在閱讀和debug上都可能變成一場災難。一個改變可能會帶來一系列的連鎖反應。而單向數據流就能讓程序的運行更加具有可預測性,也能夠減少閱讀這些代碼的痛苦。
  • 容易測試:大多數的業務邏輯都在Reducer 中,這些都是純的功能。
  • 復用性:ReSwift 中的每個組件—Store、Reducer、Action ,都是能在各個平臺獨立運行的,可以很輕松的在iOS、macOS、或者tvOS 中復用這些模塊。

多向數據流 vs. 單向數據流

通過以下的幾個例子,我們來理解一下什么是數據流。一個基于 VIPER 架構實現的程序就允許數據在其組件中多向傳遞。

VIPER 中的多向數據流

VIPER 中的多向數據流

跟 ReSwift 中的數據傳遞方向比較一下:

ReSwift 中的單向數據流

可以看出來,數據是單向傳遞的,這么做,可以讓程序中的數據傳遞更加清晰,也能夠很輕松的定位到問題的所在。

開始

這是一個已經把整個框架差不多搭建起來的模版項目,包含了一些骨架代碼,和庫。GitHub

首先需要做一些準備工作,首先就是要設置這個app最重要的部分:state

打開AppState.swift 文件,創建一個 AppState 的結構體:

import ReSwift

struct AppState : StateType{
  
}

這個結構體定義了整個app的狀態。

在創建包含所有的 AppState 的 Store 之前,還要創建一個主 Reducer

Reducer 是唯一可以直接改變 StoreAppState 的值的地方。只有 Action 可以驅動 Reducer 來改變當前程序的狀態。而 Reducer 改變當前 AppState 的值,又取決于他接受到的 Action 類型。

注意,在程序中只有一個 Store, 他也只有一個主 Reducer

接下來在AppReducer.swift 中創建主 reducer:

import ReSwift

func appReducer(action: Action, state: AppState?) -> AppState {
  return AppState()
}

appReducer 是一個接收 Action 并且返回改變之后的 AppState 的函數。參數 state 是程序當前的 state。 這個函數可以根據他接收的 Action 直接改變這個 狀態。現在就可以很容易的創建一個 AppState 值了。

現在應該創建 Store 來保存 state 了。

Store 包含了整個程序當前的狀態:這是 AppState 的一個實例。打開AppDelegate.swift ,在 impore UIkit 下面添加如下代碼:

import ReSwift

var store = Store<AppState>(reducer: appReducer, state: nil)

這段代碼通過 appReducer 創建了一個全局的變量store,appReducer 是這個 Store 的主 Reducer,他包含了接收到action的時候,store 應該怎么改變的規則。因為這是一些準備工作,所以只是傳遞了一個 nil state 進去。

編譯運行,當然,什么都看不見。因為還沒寫啊!

App Routing

現在可以創建第一個實質的 state了,可是使用 IB 的導航,或者是 routing。

App 路由在所有的架構模式中都是一個挑戰,在 ReSwift 中也是。在 MemoryTunes 中將使用很簡單的方法來做這件事情,首先需要通過 enum 定義所有的終點,然后讓 AppState 持有當前的終點。AppRouter 就會響應這個值的改變,達到路由的目的。

AppRouter.swift 中添加下面的代碼:

import ReSwift

enum RoutingDestination: String {
  case menu = "MenuTableViewController"
  case categories = "CategoriesTableViewController"
  case game = "GameViewController"
}

這個枚舉代表了app 中的所有 ViewController。

到現在,終于有能夠放在你程序狀態中的數據了。在這個例子里面,只有一個 state 結構體(AppState), 你也可以在這個 state 里面通過子狀態的方法,將狀態進行分類,這是一個很好的實踐。

打開 RoutingState.swift 添加如下的子狀態結構體:

import ReSwift

struct RoutingState: StateType {
  var navigationState: RoutingDestination
  
  init(navigationState: RoutingDestination = .menu) {
    self.navigationState = navigationState
  }
}

RoutingState 包含了 navigationState, 這個東西,就是當前屏幕展示的界面。

menu 是 navigationState 的默認值。如果沒有制定的話,將它設置成這個app的最初狀態。

AppState.swift 中,添加如下代碼:

let routingState: RoutingState

現在 AppState 就有了 RoutingState 這個子狀態。編譯一下,會發現一個錯誤。

appReducer 編譯不過了!因為我們給 AppState 添加了 routingState,但是在初始化的時候并沒有把這個東西傳進去。現在還需要一個 reducer 來創建 routingState

現在我們只有一個主 Reducer, 跟 state 類型,我們也可以通過 子Reducer 來將 Reducer 劃分開來。

RoutingReducer.swift 中添加下面的 Reducer

import ReSwift

func routingReducer(action: Action, state: RoutingState?) -> RoutingState {
  let state = state ?? RoutingState()
  return state
}

跟 主 Reducer 差不多, routionReducer 根據接收到的 Action 改變狀態,然后將這個狀態返回。到現在,還沒有創建 action 所以如果沒有接收到 state 的話,就 new 一個 RoutingState,然后返回。

子 reducer 負責創造他們對應的 子狀態。

現在回到 AppReducer.swift 去改變這個編譯錯誤:

return AppState(routingState: routingReducer(action: action, 
                                         state: state?.routingState))

給 AppState 的初始化方法中添加了對應的參數。其中的 action 和 state 都是由main reducer 傳遞進去的。

訂閱 subscribing

還記得 RoutingState 里面那個默認的 state .menu 嗎?他就是 app 默認的狀態。只是你還沒有訂閱它。

任何的類都可以定于這個 store, 不僅僅是 View。當一個類訂閱了這個 Store 之后,每次 state 的改變他都會得到通知。我們在 AppRouter 中訂閱這個 Store, 然后收到通知之后,push 一個 Controller

打開 AppRouter.swift 然后重新寫 AppRouter

final class AppRouter {
  
  let navigationController: UINavigationController
  
  init(window: UIWindow) {
    navigationController = UINavigationController()
    window.rootViewController = navigationController
    
    // 1
    store.subscribe(self) {
      $0.select {
        $0.routingState
      }
    }
  }
  
  // 2
  fileprivate func  pushViewController(identifier: String, animated: Bool) {
    let viewController = instantiateViewController(identifier: identifier)
    navigationController.pushViewController(viewController, animated: animated)
  }
  
  fileprivate func instantiateViewController(identifier: String) -> UIViewController {
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    return storyboard.instantiateViewController(withIdentifier: identifier)
  }
  
}

// MARK: - StoreSubscriber
// 3
extension AppRouter :StoreSubscriber {
  func newState(state: RoutingState) {
    // 4
    let shouldsAnimate = navigationController.topViewController != nil
    pushViewController(identifier: state.navigationState.rawValue, animated: shouldsAnimate)
  }
}

在這段代碼中,我們改了 AppRouter 這個類,然后添加了一個 extension。我們看看具體每一步都做了什么吧!

  1. AppState 現在訂閱了全局的 store, 在閉包里面, selct 表明正在訂閱 routingState 的改變。
  2. pushViewController 用來初始化,并且 push 這個控制器。通過 identifier 加載的 StoryBoard 中的控制器。
  3. AppRouter 響應 StoreSubscriber, 當 routingState 改變的時候,將新的值返回回來。
  4. 根控制器是不需要動畫的,所以在這個地方判斷一下根控制器。
  5. 當 state 發生改變,就可以去出 state.navigationState, push 出對應的 controller

AppRouter 現在就就初始化 menu 然后將 MenuTableViewController push 出來

編譯運行:

現在 app 中就是 MenuTableViewController 了, 現在當然還是空的。畢竟我們還沒有開始學 view。

View

任何東西都可能是一個 StoreSubscriber, 但是大多數情況下都是 view 層在響應狀態的變化。現在是讓 MenuTableViewController 來展示兩個不同的 menu 了。

MenuState.swift, 創建對應的 Reducer

import ReSwift

struct MenuState: StateType {
  var menuTitles: [String]
  
  init() {
    menuTitles = ["NewGame", "Choose Category"]
  }
}

MenuState 有一個 *menuTitles, 這個屬性就是 tableView 的 title

MenuReducer.swift 中,創建這個 state 對應的 Reducer:

import ReSwift

func menuReducer(action: Action, state: MenuState?) -> MenuState {
  return MenuState()
}

因為 MenuState 是靜態的,所以不需要去處理狀態的變化。所以這里只需要簡單的返回一個新的 MenuState

回到 AppState.swift 中, 添加

let meunState: MenuState

編譯又失敗了,然后需要到 AppReducer.swift 中去修改這個編譯錯誤。

return AppState(routingState: routingReducer(action: action,
                                  state: state?.routingState),
      meunState: menuReducer(action: action, state: state?.meunState))

現在有了 MenuState, 接下來就是要訂閱它了。

先在打開 MenuTableViewController.swift, 然后將代碼改成這樣:

import ReSwift

final class MenuTableViewController: UITableViewController {
  // 1
  var tableDataSource: TableDataSource<UITableViewCell, String>?
  
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    // 2
    store.subscribe(self) {
      $0.select {
        $0.menuState
      }
    }
  }
  
  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    // 3
    store.unsubscribe(self)
  }
  
}

// MARK: - StoreSubscriber
extension MenuTableViewController: StoreSubscriber {
  func newState(state: MenuState) {
    // 4
    tableDataSource = TableDataSource(cellIdentifier: "TitleCell", models: state.menuTitles) {
      $0.textLabel?.text = $1
      $0.textLabel?.textAlignment = .center
      return $0
    }
    
    tableView.dataSource = tableDataSource
    tableView.reloadData()
  }
}

現在我們來看看這段代碼做了什么?

  1. TableDataSource 包含了UITableView data source 相關的東西。
  2. 訂閱了 menuState
  3. 取消訂閱
  4. 這段代碼就是實現 UITableView 的代碼,在這兒可以很明確的看到 state 是怎么變成 view 的。

可能已經發現了,ReSwift 使用了很多值類型變量,而不是對象類型。并且推薦使用聲明式的 UI 代碼。為什么呢?

StoreSubscriber 中定義的 newState 回調了狀態的改變。你可能會通過這樣的方法去接貨這個值

final class MenuTableViewController: UITableViewController {
  var currentMenuTitlesState: [String]
  ...

但是寫聲明式的 UI 代碼,可以很明確的知道 state 是怎么轉換成 View 的。在這個例子中的問題的 UITableView 并沒有這樣的API。這就是我寫 TableDataSource 來橋接的原因。如果你感興趣的話可以去看看這個 TableDataSource.swift

編譯運行,就能夠看到了:

Action

做好了 View 接下來就來寫 Action 了。

Action 是 Store 中數據改變的原因。一個 Action 就是一個有很多變量結構體,這寫變量也是這個 Action 的參數。 Reducer 處理一系列的 action, 然后改變 app 的狀態。

我們現在先創建一個 Action, 打開 RoutingAction.swift

import ReSwift

struct RoutingAction: Action {
  let destination: RoutingDestination
}

RoutingAction 改變當前的 routing 終點

現在,當 menu 的 cell 被點擊的時候,派發一個 action。

MenuTableViewController.swift 中添加下面的代碼:

override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    var routeDestination: RoutingDestination = .categories
    switch indexPath.row {
    case 0: routeDestination = .game
    case 1: routeDestination = .categories
    default:break
    }
    store.dispatch(RoutingAction(destination: routeDestination))
  }

這段代碼,根據選擇的 cell 設置不同的 routeDestination 然后用dispatch 方法派發出去。

這個 action 被派發出去了,但是,還沒有被任何的 reducer 給支持。現在去 RoutingReducer.swift 然后做一下對應的修改。

  var state = state ?? RoutingState()
  
  switch action {
  case let routingAction as RoutingAction:
    state.navigationState = routingAction.destination
  default: break
  }
  return state

switch 語句用來判斷是否傳入的 action 是 RoutingAction。如果是,就修改 state 為這個 action 的 destination

編譯運行,現在點擊 item , 就會對應的 push 出 controller。

Updating the State

這樣去實現導航可能是由瑕疵的。當你點擊 "New Game" 的時候,RoutingStatenavigationState 就會從menu 變成 game。 但是當你點擊 controller 的返回按鈕的時候,navigationState 卻沒有改變。

在 ReSwift 中,讓狀態跟 UI 同步是很重要的,但是這又是最容易搞忘的東西。特別是向上面那樣,由 UIKit 自動控制的東西。

我們可以在 MenutableViewController 出現的時候更新一下這個狀態。

MenuTableViewController.swiftviewWillAppear: 方法中,添加:

store.dispatch(RoutingAction(destination: .menu))

這樣就能夠在上面的問題出現的時候解決這個問題。

運行一下呢?呃... 完全亂了。也可能會看到一個崩潰。

打開 AppRouter.swift, 你會看到每次接收到一個新的 navigationState 的時候,都會調用 pushViewController 方法。也就是說,每次響應就會 push 一個 menu 出來!

所以我們還必須在 push 之前確定這個 controller 是不是正在屏幕中。所以我們修改一下 pushViewController 這個方法:

fileprivate func  pushViewController(identifier: String, animated: Bool) {
    let viewController = instantiateViewController(identifier: identifier)
    let newViewControllerType = type(of: viewController)
    if let currentVc = navigationController.topViewController {
      let currentViewControllerType = type(of: currentVc)
      if currentViewControllerType == newViewControllerType {
        return
      }
    }
    navigationController.pushViewController(viewController, animated: animated)
}

上面的方法中,通過 type(of:) 方法來避免當前的 topViewController 跟 要推出來的 Controller 進行對比。如果相等,就直接 return

編譯運行,這時候,又一切正常了。

當 UI 發生變化的時候更新當前的狀態是比較復雜的事情。這是寫 ReSwift 的時候必須要解決的一件事情。還好他不是那么常見。

Category

現在,我們繼續來實現 CategoriesTableViewController 這一部分跟之前的部分比起來更復雜一些。這個界面需要允許用戶來選擇音樂的類型,首先,我們在CategoriesState.swift 中添加響應的狀態。

import ReSwift

enum Category: String {
  case pop = "Pop"
  case electrinic = "Electronic"
  case rock = "Rock"
  case metal = "Metal"
  case rap = "Rap"
}

struct CategoriesState: StateType {
  let categories: [Category]
  var currentCategorySelected: Category
  
  init(currentCategory: Category) {
    categories = [.pop, .electrinic, .rock, .metal, .rap]
    currentCategorySelected = currentCategory
  }
}

這個枚舉定義了一些音樂的類型。CategoriesState 包含了一個數組的種類,以及當前選擇的種類。

ChangeCategoryAction.swift 中添加這些代碼:

import ReSwift

struct ChangeCategoryAction: Action {
  let categoryIndex: Int
}

這里定義了對應的 action, 使用 categoryIndex 來尋找對應的音樂類型。

現在來實現 Reducer了。 這個 reducer 需要接受 ChangeCategoryAction 然后將新的 state 保存起來。打開 CategoryReducer.swift

import ReSwift

private struct CategoriesReducerConstants {
  static let userDefaultCategoryKey = "currentCategoryKey"
}

private typealias C = CategoriesReducerConstants

func categoriesReducer(action: Action, state: CategoriesState?) -> CategoriesState {
  var currentCategory: Category = .pop
  // 1
  if let loadedCategory = getCurrentCategoryStateFromUserDefaults() {
    currentCategory = loadedCategory
  }
  var state = state ?? CategoriesState(currentCategory: currentCategory)
  
  switch action {
  case let changeCategoryAction as ChangeCategoryAction:
    // 2
    let newCategory = state.categories[changeCategoryAction.categoryIndex]
    state.currentCategorySelected = newCategory
    saveCurrentCategoryStateToUserdefaults(category: newCategory)
  default: break
  }
  return state
}

// 3
private func getCurrentCategoryStateFromUserDefaults() -> Category?
{
  let userDefaults = UserDefaults.standard
  let rawValue = userDefaults.string(forKey: C.userDefaultCategoryKey)
  if let rawValue = rawValue {
    return Category(rawValue: rawValue)
  } else {
    return nil
  }
}

// 4
private func saveCurrentCategoryStateToUserdefaults(category: Category) {
  let userDefaults = UserDefaults.standard
  userDefaults.set(category.rawValue, forKey: C.userDefaultCategoryKey)
  userDefaults.synchronize()
}

跟其他的 Reducer 一樣,這些方法實現了一下比較復雜的狀態的改變,并且將選擇之后的狀態通過 Userdefault 持久化。

  1. 從 UserDefault 中獲取 category, 然后賦值給 CategoriesState
  2. 在接收到 ChangeCategoryAction 的時候更新狀態,然后保存下來
  3. 從 Userdefault 中獲取state
  4. 將 state 保存在 UserDefault 中

3、4 中的兩個方法都是功能很單一的方法,而且是全局的。你也可以把他們放在一個類或者結構體中。

接下來很自然的,就會需要在 AppState 中添加新的狀態。打開 AppState.swift 然后添加對應的狀態:

let categoriesState: CategoriesState

然后去 AppReducer.swift 中去修改對應的錯誤

  return AppState(routingState: routingReducer(action: action,
                                               state: state?.routingState),
                  meunState: menuReducer(action: action, state: state?.meunState),
        categoriesState: categoriesReducer(action: action, state: state?.categoriesState))

現在還需要 View 了。現在需要在 CategoriesViewController 中去寫這部分的 View

import ReSwift

final class CategoriesTableViewController: UITableViewController {
  var tableDataSource: TableDataSource<UITableViewCell, Category>?
  
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    //1
    store.subscribe(self) {
      $0.select {
        $0.categoriesState
      }
    }
  }
  
  override func viewWillDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    store.unsubscribe(self)
  }
  
  override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // 2
    store.dispatch(ChangeCategoryAction(categoryIndex: indexPath.row))
  }
}

extension CategoriesTableViewController: StoreSubscriber {
  func newState(state: CategoriesState) {
    tableDataSource = TableDataSource(cellIdentifier: "CategoryCell", models: state.categories) {
      $0.textLabel?.text = $1.rawValue
      // 3
      $0.accessoryType = (state.currentCategorySelected == $1) ? .checkmark : .none
      return $0
    }
    tableView.dataSource = tableDataSource
    tableView.reloadData()
  }
}

這部分的代碼跟 MenuTableViewController 差不多。注釋中標記的內容分別是:

  1. viewWillAppear 中訂閱 categoriesState 的改變,然后在 viewillDisappear 中取消訂閱。
  2. 將事件派發出去
  3. 標記選擇的狀態

所有的東西都寫好了,現在試一下!

異步任務

怎么都跑不了這個話題,這在 ReSwift 也很方便。

場景:從 iTunes的 API 中去獲取照片。首先需要創建對應的 state, reducer 以及相關的 action.

打開 GameState.swift 添加

import ReSwift

struct GameState: StateType {
  var memoryCards: [MemoryCard]
  // 1
  var showLoading: Bool
  // 2
  var gameFinishied: Bool
}

這段代碼定義了 Game 的狀態。

  1. loading 的 菊花,是否存在
  2. 游戲是否結束

接下來是Reducer GameReducer.swift:

import ReSwift

func gameReducer(action: Action, state: GameState?) -> GameState {
  let state = state ?? GameState(memoryCards: [],
                                 showLoading: false,
                                 gameFinishied: false)
  return state
}

這段代碼就是簡單的創建了一個 GameState, 稍后會再回到這個地方的。

AppState.swift 中,添加對應的狀態

let gameState: GameState

修改 AppReducer.swift 中出現的編譯錯誤

return AppState(routingState: routingReducer(action: action,
                                               state: state?.routingState),
                  meunState: menuReducer(action: action, state: state?.meunState),
                  categoriesState: categoriesReducer(action: action, state: state?.categoriesState),
                  gameState: gameReducer(action: action, state: state?.gameState))

發現了規律了吧,在每次寫完 Action/Reducer/State之后應該做什么都是可見并且很簡單的。這種情況,得益于ReSwift 的單向數據特效和嚴格的代碼約束。只有 Reducer 能夠改變 app 的 Store,只有 Action 能夠觸發這種響應。這樣做能夠讓你知道在上面地方找代碼,在什么地方做新功能。

現在開始定義 Action, 這個 action 用來更新卡片。在 SetCardsAction.swift

import ReSwift

struct SetCardsAction: Action {
  let cardImageUrls: [String]
}

這個 action 用來設置 GameState 中圖片的URL

現在開始準備程序中第一個異步行為吧!在 FetchTumesAction.swift 中,添加下面的代碼:

import ReSwift

func fetchTunes(state: AppState, store: Store<AppState>) -> FetchTunesAction {
  iTunesAPI.searchFor(category: state.categoriesState.currentCategorySelected.rawValue) {
    store.dispatch(SetCardsAction(cardImageUrls: $0))
  }
  return FetchTunesAction()
}


struct FetchTunesAction: Action{}

fetchTunes 通過 itunesAPI 獲取了圖片。然后在閉包中將結果派發出來。 ReSwift 中的異步任務就是這么簡單。

fetchTunes 返回一個 FetchTunesAction 這個 action 是用來驗證請求的。

打開 OpenReducer.swift 然后添加對這兩個 action 的支持。把 gameReducer 中的代碼改成下面這樣:

 var state = state ?? GameState(memoryCards: [],
                                 showLoading: false,
                                 gameFinishied: false)
  switch action {
  // 1
  case _ as FetchTunesAction:
    state = GameState(memoryCards: [],
                      showLoading: true,
                      gameFinishied: false)
  // 2
  case let setCardsAction as SetCardsAction:
    state.memoryCards = generateNewCards(with: setCardsAction.cardImageUrls)
    state.showLoading = false
  default:break
  }
  return state

這段代碼,就是根據具體的 action 做不同的事情。

  1. FetchTunesAction, 設置 showLoading 為 true
  2. SetCardsAction, 打亂卡片,然后將 showLoading 設置為 false。 generateNewCards 方法可以在 MemoryGameLogic.swift 中找到

現在開始寫 View

CardCollectionViewCell.swift 中添加下面的方法:

  func configCell(with cardState: MemoryCard) {
    let url = URL(string: cardState.imageUrl)
    // 1
    cardImageView.kf.setImage(with: url)
    // 2
    cardImageView.alpha = cardState.isAlreadyGuessed || cardState.isFlipped ? 1 : 0
  }

configCell 這個方法做了下面兩件事情:

  1. 使用 Kingfisher 來緩存圖片
  2. 判斷是否展示圖片

下一步,實現 CollectionView。在 gameViewCotroller.swift 倒入 import ReSwift 然后在 showGameFinishedAlert 上面添加下面的代碼:

 var collectionDataSource: CollectionDataSource<CardCollectionViewCell, MemoryCard>?
  override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    store.subscribe(self) {
      $0.select {
        $0.gameState
      }
    }
  }
  
  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    store.unsubscribe(self)
  }
  
  override func viewDidLoad() {
    // 1
    store.dispatch(fetchTunes)
    collectionView.delegate = self
    loadingIndicator.hidesWhenStopped = true
    
    // 2
    collectionDataSource = CollectionDataSource.init(cellIdentifier: "CardCell", models: []) {
      $0.configCell(with: $1)
      return $0
    }
    collectionView.dataSource = collectionDataSource
  }

由于沒有寫 StoreSubscriber ,所以這里會有一點點的編譯錯誤。我們先假設已經寫了。這段代碼,首先是訂閱了取消訂閱 gameState 然后:

  1. 派發 fetchTunes 來獲取圖片
  2. 使用 CollectiondataSource 來配置 cell 相關信息。

現在我們來添加 StoreSubscriber :

extension GameViewController: StoreSubscriber {

  func newState(state: GameState) {
    collectionDataSource?.models = state.memoryCards
    collectionView.reloadData()
    // 1
    state.showLoading ? loadingIndicator.startAnimating() : loadingIndicator.stopAnimating()
       // 2
    if state.gameFinishied {
      showGameFinishedAlert()
      store.dispatch(fetchTunes)
    }
  }
}

這段代碼實現了 state 改變的時候對應的變化。他會更新 dataSource

  1. 更新 loading indicator 的狀態。
  2. 當游戲結束時,彈窗

現在,運行一下吧!

Play

游戲的邏輯是: 讓用戶翻轉兩張卡片的時候,如果它們是一眼的,就讓他們保持,如果不一樣就翻回去。用戶的任務是在盡可能少的嘗試之后翻轉所有的卡片。

現在需要一個翻轉的事件。在 OpenCardAction.swift 中添加代碼:

import ReSwift

struct FlipCardAction: Action{
  let cardIndexToFlip: Int
}

當卡片翻轉的時候: FlipCardAction 使用 cardIndexToFlip 來更新 gameState 中的狀態。

下一步修改 gamereducer 來支持這個 action。打開 GameReducer.swift 添加下面對應的case

  case let flipCardAction as FlipCardAction:
    state.memoryCards = flipCard(index: flipCardAction.cardIndexToFlip,
                                 memoryCards: state.memoryCards)
    state.gameFinishied = hasFinishedGame(cards: state.memoryCards)

對 FlipCardAction 來說, flipCard 改變卡片的狀態。hasFinishedGame 會在游戲結束的時候調用。兩個方法都可以在 MemoryGameLogic.swift 中找到。

最后一個問題是在點擊的時候,把翻轉的 action 派發出去。

GameViewController.swift 中,找到 UICollectionViewDelegate 這個 extension。在 collectionView(_:didSelectItemAt:) 中添加:

store.dispatch(FlipCardAction(cardIndexToFlip: indexPath.row))

當卡片被選擇的時候,關聯的indexPath.row 就會跟著 FlipcardAction 被派發出去.

再運行一下,就會發現!

結束語

模版項目已經完整項目都在 GitHub

ReSwift 不僅僅是我們今天提到的內容。他還以很多:

  • Middleware: 中間件。swift目前還沒有很好的辦法來做切面。但是 ReSwift 解決了這個問題。可以使用ReSwift 的 [Middleware] 特性來解決這個問題。他能夠讓你輕松的切面(logging, 統計, 緩存)。
  • Routing: 在這個 app 中已經實現了自己的 Routing, 還有個更通用的解決方案ReSwift-Routing 單這在社區還是一個還沒有完全解決的問題。說不定解決它的人就是你!
  • Testing: ReSwift 或許是最方便測試的框架了。 Reducer 包含了你需要測試的所有代碼。他們都是純的功能函數。這種函數在接受了同一個input 總是返回同一個值,他們不回依賴于程序當前的狀態。
  • Debugging: ReSwift 的所有狀態都在一個結構體中定義,并且是單向數據流的,debug 會非常的簡單,甚至你還可以用 ReSwift-Recorder 來記錄下導致 crash 的狀態
  • Persistence: 因為所有的狀態都在一個地方,拓展和堅持都是很容易的事情。緩存離線的數據也是一個比較麻煩的架構問題,但是 ReSwift 解決了這個問題。
  • others: Redux 架構并不是一個庫,它是一種編程范式,你也可以自己實現一套,還有 Katana 或者 ReduxKit 也可以做這件事

如果你想學習更多關于 ReSwift 的東西,可以看 ReSwift 作者 Benjamin Encz 的演講視頻

Christian Tietze’s blog 的博客上有很多有趣的例子。

這篇文章翻譯自Ray wenderlich ReSwift Tutorial: Memory Game App]

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容