Использование ReSwift: написание приложения Memory Game

https://www.raywenderlich.com/155815/reswift-tutorial-memory-game-app
  • Перевод
  • Tutorial
ReSwift

Примечание. В этой статье используются Xcode 8 и Swift 3.

По мере того, как размеры iOS приложений продолжают увеличиваться, паттерн MVC постепенно теряет свою роль как «подходящего» архитектурного решения.

Для iOS разработчиков доступны более эффективные архитектурные паттерны, такие как MVVM, VIPER и Riblets. Они сильно отличаются, но у них есть общая цель: разбить код на блоки по принципу единой ответственности с многонаправленным потоком данных. В многонаправленном потоке, данные перемещаются в разных направлениях между различными модулями.

Иногда вы не хотите (или вам не нужно) использовать многонаправленный поток данных — вместо этого вы хотите, чтобы данные передавались в одном направлении: это однонаправленный поток данных. В данной статье про ReSwift вы свернёте с проторенного пути и узнаете, как использовать фреймворк ReSwift для реализации однонаправленного потока данных при создании Memory Game приложения, под названием MemoryTunes.

Но сначала — что такое ReSwift?

Введение в ReSwift


ReSwift — небольшой фреймворк, который поможет вам реализовать архитектуру Redux с помощью Swift.

ReSwift имеет четыре основных компонента:

  • Views: Реагирует на изменения в Store и отображает их на экране. Views отправляет Actions.
  • Actions: Инициирует изменение состояния в приложении. Actions обрабатывается Reducer.
  • Reducers: Непосредственно изменяет состояние приложения, которое хранится в Store.
  • Store: Хранит текущее состояние приложения. Другие модули, подобные Views, могут подключаться и реагировать на изменения.

ReSwift имеет ряд интересных преимуществ:

  • Очень сильные ограничения: заманчиво размещать небольшие фрагменты кода в удобном месте, где в действительности его не должно быть. ReSwift предотвращает это, устанавливая очень сильные ограничения на то, что происходит и где это происходит.
  • Однонаправленный поток данных: приложения, реализующие многонаправленный поток данных, могут быть очень трудными для чтения и отладки. Одно изменение может привести к цепочке событий, которые отправляют данные по всему приложению. Однонаправленный поток более предсказуем и значительно снижает когнитивную нагрузку, необходимую для чтения кода.
  • Простота тестирования: большая часть бизнес логики содержится в Reducers, которые являются чистыми функциями.
  • Платформонезависимость: все элементы ReSwift — Stores, Reducers и Actions — независимы от платформы. Их можно легко использовать повторно для iOS, macOS или tvOS.

Многонаправленный или однонаправленный поток


Чтобы пояснить, что я имею в виду говоря о потоке данных, приведу следующий пример. Приложение, созданное с помощью VIPER, поддерживает многонаправленный поток данных между модулями:

VIPER

VIPER — многонаправленный поток данных

Сравним его с однонаправленным потоком данных в приложении, построенном на базе ReSwift:
ReSwift

ReSwift — однонаправленный поток данных

Поскольку данные могут передаваться только в одном направлении, гораздо проще визуально следовать за кодом и выявлять любые проблемы в приложении.

Начинаем


Начните с загрузки проекта, который в данный момент содержит некоторый исходный код и набор фреймворков, включая ReSwift, о котором вы узнаете больше по ходу прочтения данной статьи.

Во-первых, Вы должны будете настроить работу с ReSwift. Начните с создания ядра приложения: его состояния.

Откройте AppState.swift и создайте структуру AppState, которая соответствует StateType:

import ReSwift

struct AppState: StateType {

}

Эта структура определяет состояние приложения.

Прежде чем создать Store, который будет содержать значение AppState, вам нужно создать главный Reducer.

Reducer

Reducer напрямую изменяет текущее значение AppState, хранящееся в Store. Только Action может запустить Reducer для изменения текущего состояния приложения. Reducer формирует текущее значение AppState в зависимости от Action, который он получает.

Примечание. В приложении есть только один Store и у него есть только один главный Reducer.

Создайте главную reducer функцию приложения в AppReducer.swift

import ReSwift

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

appReducer – это функция, которая принимает Action и возвращает измененный AppState. Параметр state — текущее состояние приложения. Эта функция должна соответствующим образом изменять state в зависимости от полученного action. Сейчас просто создается новое значение AppState — вы вернетесь к нему, как только сконфигурируете Store.

Пришло время создать Store, который хранит состояние приложения, и reducer, в свою очередь, мог его изменять.

Store

Store хранит текущее состояние всего приложения: это значение вашей AppState структуры. Откройте AppDelegate.swift и замените import UIKit следующим:

import ReSwift

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

Это создает глобальную переменную store, инициализированную appReducer. appReducer — главный Reducer блока Store, в котором содержатся инструкции о том, как store должно измениться при получении Action. Поскольку это первоначальное создание, а не итеративное изменение, вы передаете пустой state.

Скомпилируйте и запустите приложение, чтобы убедиться, что мы все сделали правильно:

image

Это не очень интересно… Но по крайней мере это работает :]

Навигация по интерфейсу


Пришло время создать первое реальное состояние приложения. Вы начнете с навигации по интерфейсу (routing).

Навигация в приложение (или routing) — это сложная задача для каждой архитектуры, а не только для ReSwift. Вам предстоит использовать простой подход в MemoryTunes, где вы определите весь список экранов в enum и AppState будет содержать текущее значение. AppRouter отреагирует на изменения этого значения и покажет текущее состояние на экране.

Откройте AppRouter.swift и замените import UIKit следующим:

import ReSwift

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

Этот enum определяет все контроллеры, представленные в приложении.

Теперь у вас есть что хранить в State приложения. В этом случае существует только одна основная структура состояния (AppState), но вы можете разделить состояние приложения на под-состояния, указанные в основном состоянии.

Поскольку это хорошая практика, вы будете группировать переменные состояния в структуры под-состояния. Откройте RoutingState.swift и добавьте следующую структуру под-состояния для навигаций:

import ReSwift

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

RoutingState содержит navigationState, который представляет текущий пункт назначения на экране.

Примечание: menu является значением по умолчанию для navigationState. Это значение косвенно устанавливается по умолчанию при запуске приложение, если вы не укажите другое при инициализации RoutingState

В AppState.swift добавьте следующее внутрь структуры:

let routingState: RoutingState

AppState теперь содержит под-состояние RoutingState.

Запустите приложение, и вы увидите проблему:

Oops…

Функция appReducer больше не компилируется! Это связано с тем, что вы добавили routingState в AppState, но ничего не передавали в вызов инициализатора по умолчанию. Чтобы создать RoutingState вам нужен reducer.

Существует только одна основная функция в Reducer, но, как и состояние, reducers должны быть разделенный на sub-reducers.

Sub-State and Sub-Reducers

Добавьте следующий Reducer для навигации в RoutingReducer.swift:

import ReSwift

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

Подобно главному Reducer, routingReducer изменяет состояние в зависимости от действия, которое он получает, а затем возвращает его. У вас еще нет действий, поэтому создается новый RoutingState если state равен nil и возвращается данное значение.

Sub-reducers отвечают за инициализацию начальных значений соответствующих под-состояний.
Вернитесь в AppReducer.swift, чтобы исправить предупреждение компилятора. Измените функцию appReducer, чтобы она соответствовала этому:

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

Мы добавили аргумент routingState в инициализатор AppState. action и state от основного reduser передаются в routingReducer для определения нового состояния. Привыкайте к этой рутине, потому что вам придется повторять это для каждого созданного вами sub-state и sub-reducer.

Subscribing/Подписка


Помните, что значение menu по умолчанию установленно для RoutingState? На самом деле это текущее состояние приложения! Вы просто нигде на него не подписались.

Любой класс может подписаться на Store, а не только Views. Когда класс подписывается на Store, он получает информацию обо всех изменениях, которые происходят в текущем состоянии или под-состоянии. Вам необходимо это сделать в 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(identifier: String, animated: Bool) {
    let viewController = instantiateViewController(identifier: identifier)
    navigationController.pushViewController(viewController, animated: animated)
  }
    
  private 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 shouldAnimate = navigationController.topViewController != nil
    // 5
    pushViewController(identifier: state.navigationState.rawValue, animated: shouldAnimate)
  }
}

В приведенном выше коде вы обновили класс AppRouter и добавили extension. Давайте более подробно рассмотрим что мы сделали:

  1. AppState сейчас подписан на глобальный store. В выражении замыкания, select указывает, что вы подписались на изменения в routingState.
  2. pushViewController будет использоваться для создания экземпляра и добавление его в стек навигаций. Здесь используется метод instantiateViewController, который загружает контроллер, основанный на переданном identifier.
  3. Создаем AppRouter который соответствует StoreSubscriber, чтобы newState получал обратные вызовы, как только routingState изменится.
  4. Вы не хотите приводить в действие корневой контроллер представления, поэтому проверьте, является ли текущий назначенный пункт назначения корневым.
  5. Когда состояние изменяется, вы добавляете новый пункт назначения в UINavigationController, используя rawValue для state.navigationState, который является именем контроллера представления.

AppRouter теперь теперь будет реагировать на начальное значение menu и отобразит MenuTableViewController.

Скомпилируйте и запустите приложение, чтобы убедиться, что ошибка исчезла:

image


На данный момент отображается MenuTableViewController, который пуст. Вы отобразите в нем меню, которое будет перенаправлять пользователя на другие экраны.

View


View

Все, что угодно может быть StoreSubscriber, но большую часть времени это будет view, реагирующий на изменения состояния. Ваша задача – сделать так, чтобы MenuTableViewControleller отображал две опции подменю (или меню). Пришло время для процедуры State/Reducer!

Перейдите в MenuState.swift и создайте состояние для меню следующим образом:

import ReSwift

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

MenuState структура состоит из массива menuTitles, который вы инициализируете заголовками, которые будут отображаться в виде таблицы.

В MenuReducer.swift создайте Reducer для этого состояния со следующим кодом:

import ReSwift

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

Поскольку MenuState является статическим, вам не нужно беспокоиться об обработке изменений состояния. Таким образом, просто возвращается новый MenuState.

Вернитесь в AppState.swift. Добавьте MenuState в конец AppState.

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) {
    // 4
    tableDataSource = TableDataSource(cellIdentifier:"TitleCell", models: state.menuTitles) {cell, model in
      cell.textLabel?.text = model
      cell.textLabel?.textAlignment = .center
      return cell
    }
    
    tableView.dataSource = tableDataSource
    tableView.reloadData()
  }
}

Теперь контроллер подписан на изменения MenuState и декларативно отображает состояние на пользовательском интерфейсе.

  1. TableDataSource включен в систему запуска и действует как декларативный источник данных для UITableView.
  2. Подпишитесь на menuState в viewWillAppear. Теперь вы будете получать обратные вызовы в newState каждый раз, когда menuState изменится.
  3. При необходимости отпишитесь.
  4. Это декларативная часть. Здесь вы заполняете UITableView. Вы можете четко видеть в коде, как состояние преобразуется в view.

Примечание. Как вы могли заметить, ReSwift поддерживает неизменяемость – активно использует структуры (значения), а не объекты. Он также призывает вас создать декларативный код пользовательского интерфейса. Зачем?

Обратный вызов newState, определенный в StoreSubscriber, передает изменения состояния. У вас может возникнуть соблазн зафиксировать значение состояния в параметре, например,

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

Но писать декларативный код пользовательского интерфейса, который четко показывает, как состояние преобразуется в представление, является более понятным и гораздо более простым в использовании. Проблема в этом примере заключается в том, что UITableView не имеет декларативного API. Вот почему я создал TableDataSource для устранения различия. Если вас интересуют детали, взгляните на TableDataSource.swift.


Скомпилируйте и запустите приложение и вы увидите меню:

image

Actions/Действия


Actions


Теперь, когда у вас есть готовое меню, было бы здорово, если бы мы могли с помощью него переходить/открывать новые экраны. Пришло время написать свой первый Action.

Действия инициируют изменение в Store. Действие — это простая структура, которая может содержать переменные: параметры Action. Reducer обрабатывает сформированное Action и изменяет состояние приложения в зависимости от типа действия и его параметров.

Создайте действие в RoutingAction.swift:

import ReSwift

struct RoutingAction: Action {
  let destination: RoutingDestination
}

RoutingAction изменяет текущий destination.

Теперь вы собираетесь исполнить RoutingAction при выборе пункта меню.

Откройте MenuTableViewController.swift и добавьте следующее в MenuTableViewController:

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))
}

Это установит значение routeDestination на основе выбранного вами row. Затем применяется dispatch для передачи RoutingAction в Store.

Action готов к исполнению, но не поддерживается никаким 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, которое затем возвращается обратно.

Скомпилируйте и запустите приложение. Теперь, когда вы выбираете пункт меню, соответствующий контроллер представления отобразится поверх контроллера меню.

image

Обновление состояния


Возможно, вы заметили ошибку в текущей реализации навигации. Когда вы нажимаете на пункт меню New Game то navigationState в RoutingState изменяется в menu на game. Но когда вы нажимаете на кнопку возврата, чтобы вернуться в меню, navigationState ничего не обновляет!

В ReSwift важно сохранить состояние, синхронизированное с текущим состоянием пользовательского интерфейса. Об этом легко забыть, когда что-либо полностью управляется UIKit, например навигация возврата или заполнения текстового поля пользователем в UITextField.

Мы можем это исправить, если будем обновлять navigationState при появлении MenuTableViewController.

В MenuTableViewController.swift добавьте эту строку в нижней части viewWillAppear:

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

Если пользователь нажал на кнопку позрата, то этот код обновит store.

Запустите приложение и снова проверьте навигацию. Иииии… теперь навигация полностью неисправна. Ничто не отображается.

image

Откройте AppRouter.swift. Запомните, что pushViewController вызывается каждый раз, когда получен новый navigationState. Это означает, что вы обновляете меню RoutingDestination путем нажатия на него снова!

Вы должны выполнить дополнительную проверку если MenuViewController не отображается. Замените содержимое pushViewController на:

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:) для последнего контроллера представлений и сравниваете его с новым, появляющимся при нажатии. Если они совпадают, то возвращаете два значения.

Скомпилируйте и запустите приложение и навигация должна работать нормально при правильной настройке меню, когда вы измените стек.

navigation

Обновление состояния с помощью пользовательского интерфейса и динамическая проверка текущего состояния как правило сложны. Это одна из проблем, которую вам предстоит преодолеть при работе с ReSwift. К счастью, это не должно происходить очень часто.

Категории


Сейчас вы сделаете шаг вперед и реализуете более сложный экран: CategoriesTableViewController. Вы должны разрешить пользователям выбирать категорию музыки, чтобы они могли наслаждаться игрой в Memory, слушая своих любимых исполнителей. Начните с добавления состояний в 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: [Category]
  var currentCategorySelected: Category
  
  init(currentCategory: Category) {
    categories = [ .pop, .electronic, .rock, .metal, .rap]
    currentCategorySelected = currentCategory
  }
}

enum определяет несколько категорий музыки. CategoriesState содержит массив доступных категорий, а также currentCategorySelected для отслеживания состояния.

В ChangeCategoryAction.swift добавьте следующее:

import ReSwift

struct ChangeCategoryAction: Action {
  let categoryIndex: Int
}

Это вызывает действие, которое может изменять CategoryState с помощью categoryIndex для ссылки на категории музыки.

Теперь вам нужно реализовать Reducer, который принимает ChangeCategoryAction и сохраняет обновленное состояние. Откройте CategoriesReducer.swift и добавьте следующее:

import ReSwift

private struct CategoriesReducerConstants {
  static let userDefaultsCategoryKey = "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.userDefaultsCategoryKey)
  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.userDefaultsCategoryKey)
  userDefaults.synchronize()
}

Как и в случае с другими reducers, формируется метод полного обновления состояния посредством действий. В этом случае вы также сохраняете выбранную категорию в UserDefaults. Более подробно о том, как это происходит:

  1. Загружается текущая категория из UserDefaults, если она доступна и используется для создания образа CategoriesState, если он еще не создан.
  2. Реагирование на ChangeCategoryAction при помощи обновления состояния и сохранения новой категории в UserDefaults.
  3. getCurrentCategoryStateFromUserDefaults — вспомогательная функция, которая загружает категорию из UserDefaults.
  4. saveCurrentCategoryStateToUserDefaults — вспомогательная функция, которая сохраняет категорию в UserDefaults.

Вспомогательные функции также являются чистыми глобальными функциями. Вы можете поместить их в класс или структуру, но они всегда должны оставаться чистыми.

Естественно, вам нужно обновить AppState с новым состоянием. Откройте AppState.swift и добавьте следующее в конец структуры:

let categoriesState: CategoriesState

categoryState теперь является частью 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))

Здесь вы добавили categoryState в appReducer, передав action и categoriesState.

Теперь вам нужно создать экран категорий, аналогично MenuTableViewController. Вы подпишете его к Store и используете TableDataSource.

Откройте CategoriesTableViewController.swift и замените содержимое следующим:

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.viewWillDisappear(animated)
    store.unsubscribe(self)
  }
  
  override func tableView(_ 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.textLabel?.text = model.rawValue
      // 3
      cell.accessoryType = (state.currentCategorySelected == model) ? .checkmark : .none
      return cell
    }
    
    self.tableView.dataSource = tableDataSource
    self.tableView.reloadData()
  }
}

Это довольно похоже на MenuTableViewController. Вот некоторые основные моменты:

  1. В viewWillAppear подпишитесь на изменения categoriesState и отпишитесь в viewWillDisappear.
  2. Вызывается ChangeCategoryAction, когда пользователь выбирает ячейку.
  3. В newState отметьте с помощью галочки ячейку для выбранной в данный момент категории

Все настроено. Теперь вы можете выбрать категорию. Скомпилируйте и запустите приложение и выберите Choose Category чтобы самим убедиться в правильной работе.

image

Асинхронные задачи


Асинхронное программирование — трудная задача? Да! Но не для ReSwift.

Вы получаете изображения для Memory card из iTunes API. Но для начала нужно создать игровое состояние, reducer и связанное с ним действие.

Откройте GameState.swift, и вы увидите структуру MemoryCard, представляющую игровую карту. Она включает imageUrl, которая будет отображаться на карте. isFlipped указывает, видима ли лицевая сторона карты, а isAlreadyGuessed указывает, была ли карта угадана.

Вы добавите состояние игры в этот файл. Начните с импорта ReSwift в верхней части файла:

import ReSwift

Теперь добавьте следующий код в конец файла:

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

Это определяет состояние игры. В дополнение к содержимому массива доступных memoryCards указать параметры:

  1. Индикатор загрузки, виден или нет.
  2. Игра закончена.

Добавьте игровой Reducer в GameReducer.swift:

import ReSwift

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

    return state
}

В данный момент просто создается новый GameState. Вы вернетесь к этому позже.

В файле AppState.swift добавьте gameState в конце AppState:

let gameState: 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))

Примечание. Обратите внимание насколько предсказуемо, понятно и просто работать после выполнения процедуры Action / Reducer / State. Эта процедура удобна для программистов, благодаря однонаправленному характеру ReSwift и строгим ограничениям, которые он устанавливает для каждого модуля. Как вам известно только Reducers могут изменить Store в приложении и только Actions могут инициировать это изменение. Вы сразу узнаете, где искать проблемы и где добавить новый код.

Теперь определите действие для обновления карт, добавив в SetCardsAction.swift следующий код:

import ReSwift

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

Action задает URL изображения для карт в GameState.

Теперь вы готовы создать первое асинхронное действие. В FetchTunesAction.swift добавьте следующее:

import ReSwift

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

struct FetchTunesAction: Action {
}

Метод fetchTunes извлекает изображения с помощью iTunesAPI (включено в стартовый проект). С помощью замыкания вы отправляете SetCardsAction с результатом. Асинхронные задачи в ReSwift очень просты: всего лишь отправьте действие позже, когда завершите. Вот и все.

Метод fetchTunes возвращает FetchTunesAction, который будет использоваться для обозначения начала выборки.

Откройте GameReducer.swift и добавьте поддержку двух новых действий. Замените содержимое gameReducer на следующее:

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

Вы изменили state на константу, а затем запустили переключатель действий, который выполняет следующие действия:

  1. В FetchTunesAction устанавливает showLoading в состояние true.
  2. В SetCardsAction выбираются в случайном порядке карты и showLoading устанавливается в состояние false. generateNewCards можно найти в файле MemoryGameLogic.swift — файл, который включен в стартовый проект.

Пришло время раскладывать карты в GameViewController. Начните с создания ячейки.

Откройте файл CardCollectionViewCell.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
}

Метод configureCell выполняет следующие действия:
  1. Использует прекрасную библиотеку Kingfisher для кэширования изображений.
  2. Показывает карту если она угадана или перевернута.

Затем выполните представление коллекции для отображения карт. Так же, как и для табличных представлений, существует декларативная оболочка для UICollectionView с именем CollectionDataSource, включенной в используемую вами программу запуска.

Откройте GameViewController.swift и сначала замените UIKit на:

import ReSwift

В 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. Представление подписывается на gameState в viewWillAppear и отписывается в viewWillDisappear. В viewDidLoad выполняются следующие действия:

  1. Исполняется fetchTunes чтобы начать получать изображения из iTunes API.
  2. Настраиваются ячейки с помощью CollectionDataSource, который получает соответствующую model для configureCell.

Теперь необходимо добавить расширение, чтобы выполнить 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 и теперь вы сможете увидеть карты.

Играть


Логика игры позволяет игроку перевернуть две карты. Если они одинаковы, то остаются открытыми, если нет, то снова зарываются. Цель игрока — открыть все карты используя минимальное количество попыток.

Для этого вам понадобится действие переворачивания. Откройте 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 изменение состояние карт Memory осуществляется на основе cardIndexToFlip и другой игровой логики. hasFinishedGame вызывается, чтобы определить, закончилась ли игра и соответственно обновить состояние. Обе функции можно найти в MemoryGameLogic.swift.

Заключительная часть головоломки — послать действие переворачивания при выборе карты. Это запустит логику игры и внесет соответствующие изменения состояния.

В файле GameViewController.swift найдите расширение UICollectionViewDelegate. Добавьте следующее в collectionView (_:didSelectItemAt:):

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

Когда в представлении коллекции карта выбрана то связанный с ней indexPath.row отправляется с помощью FlipCardAction.

Запустите игру. Теперь вы можете играть. Возвращайтесь и получайте удовольствие! :]

Теперь вы можете играть

Что дальше?


Вы можете скачать финальную версию приложения MemoryTunes здесь.
Еще многое предстоит узнать о ReSwift.

  • Программное обеспечение: В настоящее время нет хорошего способа обработки Cross CuttingConcern в Swift. В ReSwift, вы получите его бесплатно! Вы можете решать различные задачи, используя функцию Middleware, имеющуюся в ReSwift. Это позволяет легко справиться с ведением журнала, статистикой, кэшированием.
  • Навигация по интерфейсу. Вы реализовали собственную навигацию для приложения MemoryTunes. Можно использовать более общее решение, такое как ReSwift-Router. Это по-прежнему открытая проблема и может быть вы будете одним из тех, кто решит ее?:]
  • Тестирование: ReSwift, вероятно, самая простая архитектура для создания тестов. Reducers содержат код, необходимый для тестирования и они являются чистыми функциями. Чистые функции всегда дают один и тот же результат для того же входа, не полагаются на состояние приложения и не имеют побочных эффектов.
  • Отладка: при условии, что состояние ReSwift определено в одной структуре и однонаправленном потоке, отладка намного проще. Вы даже можете записывать этапы состояния, приводящие к сбою.
  • Persistence. Поскольку все состояние вашего приложения находится в одном месте, вы можете легко его сериализовать и сохранять. Кэширование контента для автономного режима — сложная архитектурная проблема. С ReSwift вы получаете его почти бесплатно.
  • Другие реализации: Архитектура подобная Redux не является библиотекой: это парадигма. Вы можете реализовать ее самостоятельно используя ReSwift или другие библиотеки, такие как Katana или ReduxKit.

Если вы хотите расширить свои знания по этой теме, прослушайте ReSwift talk — беседы Бенджамина Энца (Benjamin Encz), создателя ReSwift

В ReSwift’s также есть много интересных примеров проектов. И, наконец, в блоге Christian Tietze’s blog можно найти новые темы о ReSwift.

Если у вас есть какие-либо вопросы, комментарии или идеи, присоединяйтесь к обсуждению на форумах ниже!
Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 3
  • 0
    Я правильно понимаю, что при любом изменении состояния, оно каждый раз полностью перестраивается?
    И еще я вижу это рекомендуемая практика хранить в состоянии коллекции данных. Это нормально смотрится для сингл пэйдж приложения. Но если у меня много экранов, получается состояние хранит наборы неиспользуемых данных. Или они как-то подчищаются?
    • 0
      На сколько я знаю, то да. Оно каждый раз перестраивается. Так как это пришло с Web и они там по этом поводу не сильно заморачиваются.
      Получается состояние хранит наборы неиспользуемых данных. как я понимаю так и есть…
      • 0
        Вот поэтому в подобных статьях всегда смущает вводная «когда размер приложения увеличивается...». Применяя сложную универсальную архитектуру всегда теряешь контроль над производительностью и возможность оптимизации, а критичнее всего оно как раз же в больших проектах.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое