ReSwift Tutorial: Memory Game App

本文由 Michael Ciurus 发表于 raywenderlich ,原文地址

ReSwift 是用 Swift 实现的类似 Redux 的库。翻译这篇文章是在自己完全按照文章学习了一遍后,在文章的各个部分可能加入我自己的对于这个设计模式的一些见解,如有不对的地方,请各位大神指点。蟹蟹

Build a Memory Game App with ReSwift!.png

Note: 这个教程使用 Xcode 8 和 Swift 3。

随着 iOS 应用的大小不断增长, MVC 逐渐的不再成为设计模式的首选。

现在iOS开发者有很多可扩展的设计模式可供选择,例如 MVVM, VIPER 和 Riblets(小弟第一次听到这个模式,哎,还是太菜了)。他们看起来差别很大,但是他们都有一个共同的目标:在多向数据流的模式下将代码分割到单一职责的地方。在多向流中,数据在许多模块中以各种方向流动。

有些时候,你并不想(或需要)多向的数据流—你想要数据按照一个方向流动:那就是单向数据流。在这个 ReSwift 教程中,你想要摆脱常规路线并且学习如何使用 ReSwift 框架在一个状态复杂的 Memory Game app - MemoryTunes 中实现单向数据流。

首先- ReSwift 是什么?

介绍 ReSwift

ReSwift 是一个小型的框架,帮助你在 Swift 中创建类似 Redux 的架构。
ReSwift 有四个主要的组件:

  • Views: 相应 store 的改变并且将他们展示在屏幕上。Views 发送 Actions
  • Actions: 在应用中 初始化一个 state 的改变。 一个 Action 被一个 Reducer 持有。
  • Reducers: 直接改变应用的 state,并且被存储在 Store 中。
  • Store: 存储当前应用状态的值。其他组件像 Views 可以订阅和相应它的改变。

ReSwift 有了许多优点:

  • 严格的限制: 我们很容易贪图方便将部分代码放在了不属于它的地方。ReSwift 对于将要发生什么和在哪里产生有严格的限制。
  • 单向数据流: 那些实现了多向数据流的应用的代码易读性不高并且难以调试。一个改变可以导致一些列的事件将数据发送到整个程序。单向流动更加容易预测并且大大降低了读取代码所需要的知识。
  • 容易测试: 大部分逻辑都包含在 Reducer 中,并且这是一个纯函数(纯函数的特点:必须要有一个输入值,并且一定会有返回值,只要输入值相同,它的返回值也一定是相同的,它的值不会受外界环境的影响)。
  • 平台无关: 所有 ReSwift 的元素- Stores, Reducers, 和 Actions --都是平台无关的。你可以很容易的在iOS, macOS, 或者 tvOS中复用。

多向数据流 vs. 单向数据流

为了展示我对数据流的理解,查看下面的栗子。一个应用在 VIPER 支持下在它的组件间实现了多向数据流:


VIPER – Multidirectional data flow.png

与之相比,在 ReSwift 上构建应用的单向数据流:


ReSwift – Unidirectional data flow.png

因为数据只能按照一个方向流动,这更加直观地跟随代码并且追踪你应用中的问题。

让我们开始吧

一开始我们要下载一个最初的项目,这里面包含了一些代码骨架和框架,包括 ReSwift,并且随着你的不断深入将会学到很多。

首先,你需要设置一下 ReSwift的纽带(wiring)。以设置应用的核心:state 为开始。

打开 AppState.swift 并且创建一个 AppState 结构体遵循 StateType 协议:

import ReSwift
struct AppState: StateType {
  
}

这个结构体将定义了应用的整个 state。

在你创建一个包含 AppStore 值的 Store 之前,你不得不创建主要的 Reducer

reducer.png

Reducers 是唯一的块可以直接改变当前 AppState 的值,并且被 Store 持有。只有 Actions可以初始化一个 Reducer来改变当前应用的状态。Reducer 根据它收到的 Action 的状态来改变当前的 AppStore 的值。

Note: 在应用中只有唯一一个 Store,而且它只有唯一一个主要的 Reducer

AppReducer.swift 中创建应用的主要的 reducer

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

AppReducer 是一个接受一个 Action 参数和返回改变了的 AppState 的函数。state 是当前应用的状态;这个方法根据它收到的 action 的类型来改变 state。现在,这很容易就创建了一个新的 AppState 值---你将会将其返回一旦配置好了 Store
因为 reducer 有了一个 state 所以是时候创建了个 Store 了,该做些行动了。

store.png

Store 包含了你的整个应用的状态:这就是你的 AppState 结构体的值。打开 AppDelegate.swift 并且用一下的代码替代 import UIKit:

import ReSwift
//TODO
var store = Store<AppState>(reducer: appReducer, state: nil)

这里通过传入 appReducer 来初始化一个全局的 Store 变量。appReducer 是 Stores 的主要的 Reducer,这里包含的说明表明了 store 是如何改变的当它收到一个 Action。因为这是最初的创建,而不是迭代的改变,所以你只需传入一个空的 state。
构建并且运行来保证应用得要编译:

It’s not very exciting… But at least it works.png

路由

是时候在应用中创建第一个实际的 state 了。你将从导航界面或者路由开始。
应用中的路由对于每个架构都是一个挑战,不只是 ReSwift。你将要在 MemoryTune中使用一种非常简单的方法,你要将所有的目的地(页面)定义在一个枚举类型中,并且你的 AppState 将会持有当前的目的地(页面)的值。AppRouter 将会相应它(enum)的值的变化并且将正确的页面显示在屏幕上。
打开 AppRouter.swift 并且用一下代码代替 import UIKit

import ReSwift

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

这个枚举类型代表了你的应用中的所有页面。

你终于有一些东西可以存储在应用的 State中。整个应用中只有唯一一个主state结构(AppState 在这个例子中),但是你可以将应用的状态分成一些子状态并且被这个主state所引用。

因为这是一个很好的实践,所以你将要组织这些state变量放到子状态的结构总。打开RoutingState.swift 并且为路由添加一下子状态结构:

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

RoutingState 包含了 navigationState---这代表了屏幕上的页面。

Note: navigationState 的默认值是 menu。如果你没有在路由初始化的时候指定它的状态,那么隐式地使它成为应用的默认值。

AppState.swift 中,将一下代码添加到结构体中:

let routingState: RoutingState

编译并运行,然而你会发现一个问题:

missingInit.png

appReducer 不再被编译!这是因为你将 routingState 添加到 AppState ,但是并没有在默认的初始化中传递任何值。为了创建 routingState, 你需要一个 reducer。
只有一个主 Reducer 方法,但是就像 state 一样, reducers 应该被分成多个子 reducers。

Sub-State and Sub-Reducers.png

为了导航添加一下 ReducerroutingReducer.swift :

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

和主 Reducer 一样,routingReducer 改变 state 依据它收到的 action,然后就返回它。现在你还没有任何 actions, 所以现在创建一个新的 RoutingState 如果 state 是 nil 并且返回它。
子reducers 负责初始化与它们相关的子 states 的值。
回到 AppReducer.swift 来处理编译警告。修改 appReducer 的内容来与下面的相符合:

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

AppState 初始化的时候添加 routingState 参数。从主 reducer 传入 actionstateroutingReducer 来创建新的 state。熟悉这一套操作,因为你将会在你创建的子 state 和子 reducer 中重复这一动作。

订阅

还记得 RoutingState 中的默认值 menu 吗?这个确实是你的应用的当前状态。你不会在任何地方订阅它。
任何类都可以订阅 Store,不只是视图。当一个类订阅了 Store, 每当当前的 state 或者是 子 state 发生改变这个类都会收到通知。你将要在 AppRouter 做这些以便它在 UINavigationController 中改变当前的屏幕当 routingState 改变的时候。

打开 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(inentifier: String, animated: Bool) {
    let viewController = instantiateViewController(indetifier: indetifier)
    navigationController.pushViewController(viewController, animated: animated)
  }
  
  private func instantiateViewController(identifier: String) -> UIViewController {
   let storyboard = UIStoryboard(name: "Main", bundle: nil)
   return storybard.instantiateViewController(withIdentifier: indentifier)
  }
}

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

以上的代码,你更新了 AppRouter 并且添加了一个扩展。让我们看看这些是做什么的:

  1. AppState 现在订阅了全局的 store。在闭包中, select 代表了你特意订阅了 routingState 的改变。
  2. pushViewController 将被用来初始化和将给予的视图推到导航栈中。这里使用了instantiateViewController方法,这个方法根据传入的identifier来加载视图。
  3. 使 AppRouter 遵循 StoreSubscriber 协议来获得newState回到当 routingState 发生改变。
  4. 如果当前的是根视图控制器那么你将不需要动画,所以所以检查当前的目的地是否是推向根视图。
  5. 当状态改变的时候,你将根据 state.navigationStaterawValue---这是视图控制器的名字,将新的目的地视图推向 UINavigationController
  6. AppRouter 现在将要相应初始值 menu并且将 MenuTableViewController 推到导航控制器中。

编译运行并查看:

Menu.png

你的应用展示了空的 MenuTableViewController 。你将使用按钮来填充它,它将会导航到其他视图在下一章会讲述。

视图

View.png

任何东西都可以作为 StoreSubscriber ,但通常来说是视图来相应状态的变化。你的目的是使 MenuTableViewController 展示两种不同的菜单按钮。是时候开始你的State/Reducer 操作了。
打开 MenuState.swift 并且创建一个状态和下面的一样:

import ReSwift
struct MenuState: StateType {
  var menuTitles: [String]
  
  init() {
    menuTitles = ["New Game", "Choose Category"]
  }
}

MenuState 包含了 menuTitles,你将使用这个来初始化显示在 table view 上的标题。
打开 MenuReducer.swift , 用以下的代码为这个状态创建一个 Reducer:

import ReSwift

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

因为 MenuState 是静态的,你不必担心状态的改变。所以就简单的返回了一个新的状态就好了。
回到 AppState.swift,在 AppState 的底部添加 MenuState

let menuState: MenuState

编译器将会报错因为你又一次修改了默认的初始化操作。在AppReducer.swift 中,像下面一样修改 AppState 初始化:

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

现在你有了 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) {
     tableDataSource = TableDataSource(cellIdentifier: "TitleCell", models: state.menuTitles) { cell, model in
     cell.textLable?.text = model
     cell.textLable?.textAlignment = .center
     return cell
     }
     tableView.dataSource = tableDataSource
     tableView.reloadData()
   }
 }

这个控制器现在订阅了 MenuState的变化并且声明式的渲染了状态。

  1. TableDataSource 是被包含在了启动器中(starter)并且作为UITableView的一个声明式的数据源。
  2. viewWillAppear 中订阅 menuState 。现在每当menuState 改变的时候 , 你将接收到 newState 的回调。
  3. 退订,如果需要的话。
  4. 这是声明的部分。这是你填充UITableView 的地方。你可以很清楚地看到代码中状态是如何转化到视图中的。

Note: 你可能已经注意到了,ReSwift 喜欢不可变性-重度使用结构体(值引用)而不是对象。同样鼓励你来写声明式的UI代码。为什么呢?

newState 回调--定义在StoreSubscriber 传递了状态的改变。你可能会在属性中捕获状态的值,就像这样:

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

但是编写声明式的UI代码清楚地显示了状态是如何转化成视图,这种方式更加干净。

净并且更加容易效仿。这个例子中的问题是 UITableView并没有声明式的API。这就是为什么我创建了 TableDataSource.swift

编译并运行,你将看到菜单选项:

MenuView.png

事件

Action.png

现在你有菜单选项,如果它们打开新的页面那么就会很棒。是时候写你的第一个事件了。
事件改变了Store。一个事件是一个简单的结构用来存储变量:事件的参数。一个 Reducer 持有一个被分发的时间并且根据事件的类型和它的参数来改变应用的状态。
RoutingAction.swift 中创建一个事件:

import ReSwift

struct RoutingAction: Action {
  let destination: RoutingDestination
}

RoutingAction 改变当前的路由destination

现在你将要分发 RoutingAction 当一个菜单按钮被点按。

打开 MenuTableViewController.swift 并且添加一下代码到 MenuTableViewController:

override func tableView(_ tableView: UITableView, disSelectRowAt indexPath: IndexPath) {
  var routeDestination: RoutingDestinationi = .categories
  switch(indexPath.row) {
    case 0: routeDestination = .game
    case 1: routeDestination = .categories
    default: break
  }
  //这里通过 store 分发了一个 actioin,这个 action 带有上面生成的 routeDestination
  store.dispatch(RoutingAction(destination: routeDestination))
}

这根据选中的 row 设置 routeDestination。然后使用 dispatch 来向 Store 传递 routingAction

这个事件正在分发,但是它还没有被任何 reducer 支持。打开RoutingReducer.swift 并且将 routingReducer 中的内容替换成一下的代码-更新状态:

var state = state ?? RoutingState()

switch action {
  case let routingAction as RoutingAction:
    state.navigationState = routingAction.destination
  default: break
}

return state

这个 switch 检查传递来的 action 是否是一个 RoutingAction。如果是,它就用这个 destination 来改变 RoutingState,然后返回它。

编译和运行。现在当你点击菜单按钮,正确的视图控制器就会被推到导航控制器的顶端。

navigation.gif

更新状态

你可能发现了当前导航器实现中的循环。当你点击 New Game 按钮选项,RoutingStatanavigationStatemenu变成了game。但是当你使用导航控制器的返回箭头来返回到菜单界面的时候, navigationState 没有发生任何改变。

在 ReSwift中,保持状态与当前的UI状态的同步是很重要的。我们很容易将这点忘记当UIKit帮我们完全搞定这些事,就像导航器返回按钮或者用户在 UITextField中打字。

每当 MenuTableViewController 出现就更新 navigationState,通过这种方法来修复这个bug。

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

如果导航器的返回箭头被使用那么就手动更新store。

运行应用并且再次测试导航。。。啊啊。。。。现在导航已经完全坏了。没有页面能够被推入,而且你可能会看到应用奔溃。

stickers.gif

打开 AppRouter.swift ;你应该会想到,每当收到一个新的 navigatioinStatepushViewController都会被调用一次。

你不得不动态检查 MenuViewController在推送之前是否已经可见。用一下代码替代 pushViewController的内容:

let viewController = instactiateViewController(indentifier: 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:) 并且和将要被推入的新的视图相比较。如果它们符合,就直接 return并不需要将重复的视图控制器推入。
编译和运行程序,导航器将和往常一样工作,当你退出当前栈时 menu 的状态也会随着改变。

navigationnew.gif

UI事件来更新状态和动态检查当前状态是十分复杂的。这是一个你必须克服的挑战之一当你使用ReSwift。幸运的是,这不会经常发生。

目录

现在你将更进一步来实现一个更加复杂的页面:CategoriesTableViewController。你需要允许用户选择音乐的类别,所以当他们在玩游戏的时候能够欣赏他们最喜欢的乐队了。从添加状态到 CategoriesState.swift开始:

import ReSwift

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

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

这个 enum 定义了几种音乐类别。CategoriesState 包含了一些列可用的categories并且还有保存状态的currentCategoriesSelected

ChangeCategoriesAction.swift中添加一下代码:

import ReSwift

struce ChangeCategoryAction: Action {
  let categoryIndex: Int
}

这个创建了一个可以改变CategoriesState的事件,使用 categoryIndex来联系音乐类别。
现在你需要实现一个 Reduce来接受 ChageCategoryAction并且存储更新后的状态。打开 CategoriesReducer.swift 并且添加一下代码:

import ReSwift

private struce CategoriesReducerConstants {
  static let userDefaultsCategoryKey = "currentCategoryKey"
}

private typelias 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
   saveCurrentCategoryStateToUerDefaults(category: newCategory)
   default: break
 }
 return state
}

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

//4
private func saveCurrentCategoryStateToUserDefault(category: Category) {
  let userDefaults = UserDefaults.standard
  userDefaults.set(category.rawValue, forKey: C.userDefaultsCategoryKey)
  userDefaults.sychronize()
}

就想其他的reducers一样,这实现了一个方法来完成状态的更新。在这个例子中,你也将已选中的类别存入 UserDefaults。简要的看一下这都做了些什么:

  1. 如果可能的话从 UserDefaults 中加载当前的lei'bie类别,并且用它来实例化一个 CategoriesState 如果不存在的话。
  2. 通过更新state 和保存新的类别到 UserDefault 来响应 ChangeCategoryAction
  3. getCurrentCategoryStateFromUserDefaults 是一个辅助方法从 UserDefaults中加载类别。
  4. saveCurrentCategoryStatetoUserDefaults 是一个辅助方法来将类别保存到 UserDefaults

这些辅助方法也同样是全局的纯函数。打开 AppState.swift 并且将一下的添加到 struct的末尾:

let categoriesState: CategoriesState

categoriesState 现在是 AppState 的一部分了。你将要管理这部分!

打开 AppReducer.swift 并且修改返回值来匹配一下的代码:

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

你已经将 categoriesState 添加到 appReducer 传递了 actioncategoriesState

现在你需要创建类别页面,就想 MenuTableViewController一样。你将要使它订阅Store并且使用 TableDataSource

打开 CategoriesTableViewController.swift 并且用一下的代码替代:

import ReSwift

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

//MARK: - StoreSubscriber
extension CategoriesTableViewController: StoreSubscriber {
  func newState(state: CategoriesState) {
    tableDataSource = TableDataSource(cellIdentifier: "CategoryCell", models: state.categories) { cell, model in
    cell.textLable?.text = model.rawValue
    //3
    cell.accessoryType = (state.currentCategorySelected == model) ? .checkmark : .none
    return cell
    }
    self.tableView.dataSource = tableDataSource
    self.tableView.reloadData()
  }
}

这里的代码和 MenuTalbeViewController 十分相似。看一下亮点的部分:

  1. viewWillAppear 中订阅 categoriesState 的变化并且在 viewWillDisappear 中取消订阅。
  2. 当用户选择一个cell的时候分发 ChangeCategoryAction
  3. newState中,当前被选中的cell添加标记。

所有的事情都做好了。现在你可以选择一个类别了。编译和运行程序,为你自己选择一个 Choose Category

ChooseCategory.png

异步任务

异步操作很难? 嗯?对于ReSwift来说并不是。

你将要从 iTunes API 下载图片给 Memory Cards。首先,你要创建一个游戏state, reducer和相关的 action。

打开 GameState.swift 并且你将会看到一个 MemoryCard 结构体代表了一个游戏卡。他包含了显示在卡上的图片的 imageUrlisFliped 代表了前一张牌是否可见并且 isAlreadyGuessed dai'bi代表了卡片是否匹配。

你将要在文件中添加游戏状态。从导入 ReSwift 开始:

import ReSwift

现在添加一下代码到文件的底部:

struce GameState: StateType {
  var memoryCards: [MemoryCard]
  //1
  var showLoading: Bool
  //2
  var gameFinished: Bool
}

这些定义了游戏的状态。除了包含可用的卡片的数组外,这里的属性代表了:

  1. 加载指示器是否可见
  2. 游戏是否结束

GameReducer.swift 中添加一个game Reducer:

import ReSwift

func gameReducer(action: Action, state: GameState?) -> GameState {
  let state = state ?? GameState(memoryCard: [], showLoding: false, gameFinished: false)
  return state
}

目前仅仅是创建了一个新的 GameState。你等会儿还会回来。

AppReducer.swift 中,最后一次更新初始化操作:

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

Note: 在做了几次 Action/Reducer/State 常规操作后你会注意到所有的事情都是可预测的、简单和熟悉。这编程友好的常规操作得益于 ReSwift 的单向的本质并且在每个组件中严格的限制了它。可能你已经学到了,只有 Reducer 可以改变 app Store并且只有 Action 可以初始化那个变化。你也立刻就知道到哪里去看,和到哪里去添加新的代码。

现在定义一个事件来更新卡片通过添加一下的代码到 SetCardsAction.swift :

import ReSwift

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

GameState 中这操作为卡片设置了图片地址。

现在你将要创建你的第一个异步事件。在 FetchTunesAction.swift 中,添加一下代码:

import ReSwift

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

struct FetchTunes: Action {
  
}

fetchTunes 通过使用 iTunesAPI (包括启动装置(starter)) 来获取图片。在闭包中,你分发了一个 带有结果SetCardAction 事件。在 ReSwift中操作异步任务就是那么简单:当它完成时,及时分发一个事件。就是这样。

fetchTunes 返回了 FetchTunesAction ,被用来表示获取已经开始了。

打开 GameReducer.swift 并且为两个新的事件添加支持。用一下的代码提到 gameRedcer 中的内容:

var state = state ?? GameState(memoryCards: [], showLoading: false, gameFinished: false)

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

return state

你改变状态使其成为常量,然后再实现一个 action,switch做了一下事情:

  1. 当action为 FetchTunesAction ,设置了 showLoadingtrue
  2. 当action为 setCardsAction, 随机化卡组并且设置 showLoadingfalsegenerateNewCards 可以在 MemoryGameLogic.swift 中被找到,这包含了启动装置。

是时候在 GameViewController 中画出卡片了。以这是cell开始。

打开 CardCollecionViewCell.swift 并且添加一下方法到 CardCollectionViewCell 的底部:

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

configure 做了一下事情:

  1. 使用很棒的 Kingfisher 库(喵神写的,膜拜~)来存储图片。
  2. 当卡片已经被猜出或者卡片被翻转时显示图片。

接下来你将要实现 collection view 来展示卡片。就像table views那样,对UICOllectionView有个声明式的包装叫CollectionDataSource.swift ,这些都被包含在启动器中并且你将会在之后所利用。

打开GameViewController,将一下代码添加到 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(cellIdentifier: "CardCell", models: [], configureCell: { (cell, model) -> CardCollectionViewCell in
    cell.configureCell(with: model)
    return cell
  })
  collectionView.dataSource = collectionDataSource
}

注意这里可能会导致编译警告直到你实现了 StoreSubscriber 协议。视图在 viewWillAppear 中订阅了 gameState 并且在 viewWillDisappear 中取消了订阅。在 viewDidLoad 中做了一下事件:

  1. 当开始从 iTunes API 获取图片时分发 fetchTunes
  2. 通过使用 CollectionDataSource - 获取合适的 modelconfigureCell 来设置cells。

现在你需要添加一个扩展来遵循StoreSubscriber。在文件的底部添加一下代码:

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

这实现了 newState 来持有状态的改变。这改变了数据源同时:

  1. 根据状态来改变加载指示器的状态。
  2. 重新一场游戏和显示提示当游戏结束的时候。

编译和运行游戏,选择 New Game ,并且你现在可以看到 mrmory 卡片。

GameAlmost.png

玩游戏

游戏的逻辑是用户点击两张卡片,如果它们相同,它们保持正面;如果它们不相同,它们将再次隐藏。玩家的目标是用最少的步数使所有卡片都为正面。
为了做到,你需要一个点击的事件。打开FlipCardAction.swift 并且添加一下代码:

import ReSwift

struct FlipCardAction: Action {
  let cardIndexToFlip: Int
}

FlipCardAction 将会使用 cardIndexToFlip 来更新 GameState 当一张卡片被点击了。
更改gameReducer 来支持 FlipCardAction和游戏的算法。打开 GameReducer.swift并且在default 之前添加一下的例子:

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

对于一个 FlipCardAction来说, flipCard 根据 cardIndexTiFlip和其他游戏逻辑来改变 memory cards 的状态。 hasFinishedGame 被调用来确定游戏是否结束和相应的更新状态。两个方法都可以在MemoryGameLogic.swift 中找到。

最后一块拼图是发送一个点击事件当卡片被选中了。这会完成游戏的逻辑并且使得相关的状态变化。

GameViewController.swift 中,找到 UICollectionViewDelegate 扩展。添加一下代码到collectionView(_: didSelectItemAt:) :

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

当collection view 中的卡片被选中了,相关连的indexPath.row 将被发送到 FlipCardAction

运行游戏,现在你可以玩游戏了。当你玩的开心的时候记得回来。

GameFinished.png

接下来干什么呢?

你可以在这里 下载最终的 MemoryTunes
接下来任然有许多 ReSwift相关的需要学习。

相关资料:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 随着 app 的发展, MVC 渐渐的满足不了业务的需求。大家都在探索各种各样的架构模式来适应这种情况,像是MVV...
    CepheusSun阅读 5,483评论 9 28
  • 几乎每个人都听过“不忘初心,方得始终”,却少有人知道下一句“初心易得,始终难守”。 ​​​ 所以,从这四句话中,我...
    A分享阅读 636评论 2 2
  • 在巷子里不经意的小酒馆, 在初春燕子北归的季节里, 兄弟们家庭的聚会, 谈天说地,相叙情谊。 让我感慨万分的, 不...
    蒹葭essay阅读 503评论 0 1
  • 沫沫盯着雪白的天花板,眼神空洞内里空虚!不知是从什么时候开始,清晨盯着天花发呆的行为已然养成了一种习惯,什么都不做...
    沫沫苏阅读 595评论 2 3