1. はじめに
8月から9月にかけて技術書典に向けての準備やiOS関連イベントと忙しい感じでしたが、それまでに色々と自分なりにReduxを用いたアーキテクチャーとUIとの組み合わせに関して調査していたことやReactNativeも少々扱っていた経緯もあったので、今回は1つの画面において多くの画面状態が存在するような形のUIサンプルにおいてRedux(ReSwift)を部分的に適用したものを例にとって解説していこうと思います。
サンプルの全体的な動きの動画:
Githubでのサンプルコード:
※こちらのサンプルはPullRequestやIssue等はお気軽にどうぞ!
本記事でご紹介している内容に関しましては、株式会社Visits Technology様にて開催された__「ROPPONGI.swift 第5回」__の中でも登壇時にサンプルを交えてご紹介しましたので、その際に使用したスライド等も併せて共有致します。
ROPPONGI.swift 第5回に関する記事:
発表資料:
2. 今回の参考資料とサンプル概要について
今回紹介するサンプルについては、 「チュートリアル → ユーザーの情報入力 → 1画面に複数の画面要素が混在する」 という形のUI実装を伴ったものになります。
画面遷移以外の画面の要素表示に関する部分をReduxで管理することでそれぞれの状態に合わせた状態を実現するような構成を取っています。またContainerViewを利用した親子関係を持ったViewControllerがある場合でも対処できるような形にしています。Viewの細かな表現部分に関しては若干作り込みが甘い点等はあるかと思いますが、お気づきの点等があればご指摘頂けますと幸いに思います。
★2-1. このサンプルを実装するにあたっての参考資料:
今回のサンプル実装とRedux関連処理部分の構成については、下記の記事等で紹介されていたコードを参考にしました。
またReSwiftのリポジトリの他には下記の様なサンプルコードもありますので、実際に組み込む前の予行演習としてReSwiftやReduxの処理を知る上での手助けになるかと思います。
ReSwiftのサンプルコード:
参考資料:
- Unindirectional Data Flow in Swift
- Architecture Thoughts: Migrating Marvel's iOS App to ReSwift
- ReduxのSwift実装「ReSwift」を触ってみる
- RxSwiftを投げ出したあなたに贈る、ReduxライクなReSwiftのご提案
この記事では触れていませんが、ReSwiftと併せてRxSwiftも一緒を利用した事例については下記の記事も参考になるかと思います。紹介しているサンプルについてはまだ規模が小さいですが、より規模が大きく複雑度が増すようなケースにおいては有効なのではないかと思います(ReSwift+RxSwiftの構成については私もチャレンジしてみたい!)。
★2-2. 今回のサンプルについて:
サンプルのデザインや画面構成:
こちらは初回に起動した際に表示されるチュートリアル画面とユーザーのアンケート情報入力画面になります。例えばチュートリアル画面が終わって再起動した場合にはアンケート情報入力画面から開始できるような考慮をInitialSettingViewController.swift
の読み込み時に現在のユーザー状態に応じた画面の振り分けをすることで実現しています。
この画面に関しては、1つの画面をまとめるViewControllerの中にUIScrollViewを配置し、さらにその中にコンテンツ要素に該当する複数のContainerViewを配置しています。ContainerViewの親子間の処理の概要としては、それぞれのContainerViewにおいて表示するためのデータをReduxで管理し、親子間のView表示に関わる処理はProtocolを活用するようにしています。
遷移先の画面についてはここではダミーのものになりますが、カスタムトランジションを利用してサムネイル画像が浮き上がるような表現や遷移先の画面がバウンドするような表現を加えています。
環境やバージョンについて:
- Xcode10.1
- Swift4.2
- MacOS Mojave (Ver10.14)
使用ライブラリ:
Reduxを実現するためのライブラリやその他アプリ開発でよく使う定番ライブラリに加えてUI構築のためのライブラリも加えています。API通信のレスポンスや遅延を伴う処理のハンドリングを行う部分に関しては、実務ではBolts-Swiftを利用することが多いのですが、個人的にはよりシンプルで書きやすく感じたPromiseKitを使用しています。
| ライブラリ名 | ライブラリの機能概要 |
|:-----------|:------------|:------------|
|ReSwift |ReduxのSwift実装用 |
|PromiseKit |非同期通信のハンドリング |
|SwiftyJSON |JSONデータの解析をしやすくする |
|Alamofire |HTTPないしはHTTPSのネットワーク通信用 |
|AlamofireImage |画像URLからの非同期での画像表示とキャッシュサポート |
|RealmSwift |アプリ内のデータベース |
|CalculateCalendarLogic |日本の祝祭日の判定 |
|KYNavigationProgress |UINavigationBar直下へのProgressBar表示 |
3. ReSwiftを用いたSwiftとReduxの処理関係のまとめ
ここからはReSwiftを用いてReduxの処理をアプリ内で実現するために、押さえておきたい事項やそれぞれの部分が担っている役割についてまとめていきます。Reduxは下記に示す3つの原則に則った上で状態管理のフローを縛ることによって状態管理を行う形になっています。
- Single source of truth.
- State is read-only.
- Mutations are written as pure function.
下記に示しているのはReduxのデータフローの図解(※ベースはReSwiftのリポジトリに掲載されていたもの)になりますが、状態(State)の更新はAction経由でしか許可していない点とReducer内の処理によって状態の更新を実行する点、そしてView(ViewController)側では状態の更新通知を受け取る形になっている点がポイントになるかと思います。
また、今回のサンプルアプリにおいてはActionを発行するためのメソッド群を別途ActionCreatorsというグループに分けています。メソッド内で必要なデータの取得や登録等の処理が実行された際には、その結果をActionを経由してReducerに引き渡し、更新されたStateを元にしてView要素の構築を行なっていくという一連の流れとなっています。
このように取得データと画面におけるUIの状態との歩調を合わせるためにReduxでの状態管理を行い、 画面とStateの紐付けを行うためにViewControllerでStateの変更を関しするような形にしています。またReduxの処理フローをiOSアプリ内に組み込んでいく場合に注意しておくと良いポイントとしては、StateとViewControllerとの関わり方をどのように設計していくかという点にあると個人的に感じております。
★3-1. Redux処理における登場人物と基本事項の確認:
ここではReSwiftを利用してReduxの実装を行う際において必要なファイルと役割に関して解説します。今回のサンプルでは、下記のような形でReduxの処理を実現するために必要な要素を役割ごとのファイルに分割した上でまとめています。さらに命名によって画面ごとにそれぞれのStateが対応するようにしています。
上記の役割を担うためのそれぞれのファイルで定義するべきものの具体例をここから示して行くことにします。以降のコードはPickupMessage(画像付きメッセージを表示する部分の状態や表示データの取得を行う)
におけるReduxの処理部分のものになります。
① Stateに関する部分の実装例:
まずは対応する画面の状態(このサンプルにおいては画面表示に必要なデータ要素
)の定義をReSwift.StateType
プロトコルを適用したStructで下記のような形で記載します。
import Foundation
import ReSwift
// ピックアップメッセージの状態に関するstateの定義
struct PickupMessageState: ReSwift.StateType {
// ピックアップメッセージの一覧を格納する配列(初期値: [])
var pickupMessageStateList: [PickupMessageEntity] = []
// ピックアップメッセージの一覧エリアの表示フラグ(初期値: false)
var isPickupMessageAreaHidden: Bool = false
}
このStateに設定されているプロパティの値を元に画面表示を組み立てますが、Stateを更新するための処理はActionを発行する処理を経由して行う形となる点がポイントになります。
② Actionに関する部分の実装例:
続いてそれぞれの画面に対応するStateを更新するための唯一の手段となるActionに関して見ていきます。ReSwift.Action
プロトコルを適用したenumを該当するStateのextensionとして下記のような形で記載します。
import Foundation
import ReSwift
extension PickupMessageState {
// ピックアップメッセージのstateを変更させるアクションをEnumで定義する
enum pickupMessageAction: ReSwift.Action {
// ピックアップメッセージの読み込み成功時にAPIより取得した値をセットするアクション
case setPickupMessage(pickupMessage: [PickupMessageEntity])
// ピックアップメッセージエリアの表示状態の値をセットするアクション
case setIsPickupMessageAreaHidden(result: Bool)
}
}
Stateの更新をするために必要となる値については、Actionで定義した引数にへ格納することで以降の処理に引き渡す形にしています。Stateを更新をして表示内容の変更を実行したい場合には、ViewController等でappStore.dispatch(action)
のような形でStoreのdispatchメソッドを実行することで実現します。
③ ActionCreatorに関する部分の実装例:
ActionCreatorでは、Actionの引数をして渡す内容(主に外部APIから取得した表示データや実行結果・アプリ内に格納しているデータ)
を作成する処理とActionの発行をする処理をセットにしてまとめた部分になります。下記の例は、該当部分の表示状態の設定や表示したいデータを取得する処理をまとめたものになります。
import Foundation
import ReSwift
struct PickupMessageActionCreator {}
extension PickupMessageActionCreator {
// ピックアップメッセージ表示エリアの表示状態を反映する
static func shouldHidePickupMessageArea(result: Bool) {
// Storeにあるdispatchメソッドを実行してStateの更新要求を送る
appStore.dispatch(
// 該当するStateのextentionとして定義したActionを実行する
PickupMessageState.pickupMessageAction.setIsPickupMessageAreaHidden(result: result)
)
}
// ピックアップメッセージを取得する
static func fetchGourmetShopList() {
// ピックアップメッセージのAPIから全件情報を取得する
// MEMO:
// ① 各種APIのエンドポイントへのアクセスをする処理はAPIManagerクラスを別途用意している。
// ② APIアクセスに関する処理ではSwiftyJSONとPromiseKitを併用している。
// ③ Actionを発行する前にデータを取得や登録等の処理を実行して、その結果を一緒に引き渡す。
APIManagerForPickupMessage.shared.getPickupMessageList()
.done { messageJSON in
// 成功時: 取得したJSONを解析したものを配列にしたものを引数に渡してアクションの実行
let pickupMessageList = PickupMessage.getPickupMessagesBy(json: messageJSON)
appStore.dispatch(PickupMessageState.pickupMessageAction.setPickupMessage(pickupMessage: pickupMessageList))
}.catch { error in
// 失敗時: 空配列を引数に渡してアクションの実行
appStore.dispatch(PickupMessageState.pickupMessageAction.setPickupMessage(pickupMessage: []))
}
}
}
場合によってはViewController内で直接Actionを発行するケースもあると思いますが、このようにAlamofire等を利用した非同期通信を伴う処理やRealm等を利用したアプリ内データを利用する処理がAction発行前に必要な場合には一連の処理をひとまとめにしておくと良いかと思います。
④ Reducerに関する部分の実装例:
Action発行時の内容(引数として送られたデータ等も含む)と現在のStateの値を元に更新されたStateを作成する処理はReducerで実行します。Reducerについては該当するStateごとに設定されており、この部分の処理でしかStateの更新は許可されていない点に注意してください。Actionに定義した引数で受け取ったものをStateに反映する場合の処理の場合は下記のような形となります。
import Foundation
import ReSwift
struct PickupMessageReducer {}
extension PickupMessageReducer {
static func reducer(action: ReSwift.Action, state: PickupMessageState?) -> PickupMessageState {
// ピックアップメッセージのstateを取得する(ない場合は初期状態とする)
var state = state ?? PickupMessageState()
// ピックアップメッセージのstateを変更させるアクションでない場合はstateの変更は許容しない
guard let action = action as? PickupMessageState.pickupMessageAction else { return state }
// ピックアップメッセージのstateを変更させるアクションに合わせてStateの更新を実行する
switch action {
case let .setPickupMessage(pickupMessage):
state.pickupMessageStateList = pickupMessage
case let .setIsPickupMessageAreaHidden(result):
state.isPickupMessageAreaHidden = result
}
// Debug.
AppLogger.printMessageForDebug("PickupMessageStateが更新されました。")
return state
}
}
ここで紹介しているReducerの処理はシンプルなものになりますが、画面の表示やコントロールに必要な要素(State)に関わる部分ですので「Actionを発行するとState内の該当の値が正しく反映されていること」をUnitTest等を利用して動作を担保するような形にすると更に良いかと思います。
⑤ AppDelegateでのStoreの設定等の部分:
①〜④では「とあるStateが更新されて新しいStateになるまでの概要」に関して説明しました。このアプリで管理しているすべての状態については1つのStore内で保持・管理されており、それぞれの画面やロジック部分で必要となるActionの発行やState値の利用の際には、AppDelegate.swift内でグローバルに定義したStoreのインスタンス
から利用する形となります。
// Storeの定義をあらかじめAppDelegate.swiftのクラス外に下記のように追記しておく
let appStore = Store(reducer: appReduce, state: AppState(), middleware: [ActionLoggingMiddleware])
// PickupMessageReducerにてPickupMessageStateの更新が終わった後の処理の概要:
// ① PickupMessageStateの更新がStoreに反映された後は該当のViewController内でAppStateの更新を検知できるようする。
// ② AppStateの更新を検知した際に必要なメソッドの処理内にPickupMessageStateの値に合わせた実装を行なっていく。
// ※1 AppState()で行なっていること → PickupMessageStateをはじめ、このアプリで利用するStateを集約する
// ※2 AppReduceで行なっていること → PickupMessageReducerをはじめ、このアプリで利用するReducerを集約する
// ※3 MiddlewareではActionの実行前後で割り込ませて実行したい処理を記載する(今回は発行されたアクションのログ出力のみ)
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
・・・(省略)・・・
}
⑥ 該当するViewControllerでのAppStateの更新を検知する部分:
最後にStoreで管理されているStateが変更されたタイミングで、State値が更新された通知を画面に対応するViewControllerにて受け取れるようにするための処理は下記のような形になります。更新されたStateに合わせたView要素の表示に関する処理については、func newState(state: AppState)
内に記載して「状態を元に表示内容を決める」形にしている点がポイントになるかと思います。
class PickupMessageViewController: UIViewController {
・・・(省略)・・・
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Stateが更新された際に通知を検知できるようにするリスナーを登録する
appStore.subscribe(self)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stateが更新された際に通知を検知できるようにするリスナーを解除する
appStore.unsubscribe(self)
}
・・・(省略)・・・
}
extension PickupMessageViewController: StoreSubscriber {
// Stateの更新が検知された際に実行される処理
func newState(state: AppState) {
// TODO: PickupMessageStateの状態に合わせて配置したView要素を変更する処理を記載する
・・・(省略)・・・
// Debug.
AppLogger.printStateForDebug(state.pickupMessageState, viewController: self)
}
}
本サンプルで利用しているReduxに関連した処理に関する構成ファイルの概要とStateの具体例に関しては下記のような形になっています。
画面ごとにReduxのデータフローを実現するための処理を分割しているためにファイル数は多くなりますが、実際のUIの動きを検証する際にStateの現在状態をデバッグログに表示しながらの確認もできたりするので、クラッシュするケースの再現時やイレギュラーな画面表示になる場合の検知等に上手に活用できるのではないかと思います。
★3-2. ReSwiftで実現するRedux処理の概要図と処理フロー:
上記で解説したReduxの処理に必要な各々の処理で行われている内容と、処理フローをまとめると下記のような形の流れとなります。
一見するとiOSアプリ開発でよくお目にかかるアーキテクチャとは相入れない印象を持つかもしれませんが、状態を格納しているオブジェクト(Stateが該当)の現在状態を元に各々の画面状態を構築するという形になる点に注目すると理解がしやすくなると思います。
★3-3. StoreとState/Reducerの関連図と集約部分に関して:
今回のサンプルではそれぞれの画面に対応するStateを用意する形にしているので、それぞれのStateやReducer自体はなるべく複雑な形にはせずに集約するためのファイルやメソッドを下記のような形で定義しています。
複数のContainerViewを利用して、それぞれの画面要素ごとにViewControllerを分割して表示している部分が多い場合や項目が多い入力フォーム等では1つのViewControllerで状態管理をすると処理が煩雑になりやすいので、画面全体における状態の管理とUI処理を切り分けて考えることができる構成にするためにReduxの概念や考え方を取り入れるのも有効な選択肢の1つであるように思います。
4. このサンプルにおけるその他Reduxと関連する処理に関して
ここでは、本サンプルにおけるReduxのデータフローを実現するために必要なファイル構成部分以外の部分の処理に関する部分の補足説明になります。
★4-1. Redux処理とプロトコルの使い分けについての解説:
基本的にはStateが更新されたタイミングを検知し、該当するStateの値を元に部品となるViewをはじめとする画面要素を構築していく形の構成になっていますが、ContainerViewを利用してそれぞれのViewControllerに対して親子関係があるような構成の部分では、「子のViewControllerに配置されたボタンを押下した時に、親のViewControllerから次の画面を表示する画面遷移処理を実行する」 という動きをしなければいけない場合があるかと思います。
このような処理をする必要が出てくる場合については、Reduxで部分で処理するのではなく下記のような意図でプロトルを用いて関連する画面に対するViewの処理を実行できるようにしています。
MainViewController.swift
で表示している月ごとのカレンダー表示部分において、日付のボタンを押下した場合に画面遷移をする場合を考えてみることにします。月ごとのカレンダー表示部分は親のViewControllerに配置したContainerViewから表示しており、ViewControllerを分割しています。
また今回は画面遷移処理のようにReduxで管理していない部分については、子のViewControllerから親のViewControllerに定義した画面遷移を実行できるようにプロトコルを活用して親子間の処理を連携できるようにしています。
① ContainerViewとEmbedSegueで繋がっている子のViewControllerでの処理:
// MARK: - Protocol
// MEMO: ContainerViewを介したViewに関する処理はプロトコル経由で接続する
protocol MonthlyCalendarViewDelegate: NSObjectProtocol {
// 月別カレンダーのボタンタップ時にプロトコルを適用したViewController側で行うためのメソッド
func selectMonthlyCalendar(selectedDate: (selectedYear: Int, selectedMonth: Int, selectedDay: Int))
}
class MonthlyCalendarViewController: UIViewController {
・・・(省略)・・・
@objc private func calendarButtonTapped(button: UIButton) {
// Debug.
print("選択された日付:", "\(selectedYear!)年\(selectedMonth!)月\(button.tag)日")
// カレンダーで選択された日付を取得して、プロトコルを適用しているViewControllerに受け渡す
let targetSelectedDate: (selectedYear: Int, selectedMonth: Int, selectedDay: Int) = (
selectedYear: selectedYear!,
selectedMonth: selectedMonth!,
selectedDay: button.tag
)
self.delegate?.selectMonthlyCalendar(selectedDate: targetSelectedDate)
}
・・・(省略)・・・
}
② ContainerViewを配置している親のViewControllerでの処理:
// MARK: - MonthlyCalendarViewDelegate
extension MainViewController: MonthlyCalendarViewDelegate {
// 選択されたカレンダーの内容を反映させつつ、画面遷移を行う
func selectMonthlyCalendar(selectedDate: (selectedYear: Int, selectedMonth: Int, selectedDay: Int)) {
// 記事表示用の画面へ遷移する
let storyboard = UIStoryboard(name: "DailyMemo", bundle: nil)
let dailyMemoViewController = storyboard.instantiateViewController(withIdentifier: "DailyMemoViewController") as! DailyMemoViewController
dailyMemoViewController.setSelectedDate(selectedDate)
// カスタムトランジションのプロトコルを適用させて遷移する
let navigationController = UINavigationController(rootViewController: dailyMemoViewController)
navigationController.transitioningDelegate = self
self.present(navigationController, animated: true, completion: nil)
}
}
// MARK: - UIViewControllerTransitioningDelegate
extension MainViewController: UIViewControllerTransitioningDelegate {
// MEMO: 適用しているカスタムトランジションの詳細については、DailyMemoTransition.swiftを参照
// 進む場合のアニメーションの設定を行う
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// 現在の画面サイズを引き渡して画面が縮むトランジションにする
dailyMemoTransition.originalFrame = self.view.frame
dailyMemoTransition.presenting = true
return dailyMemoTransition
}
// 戻る場合のアニメーションの設定を行う
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
// 縮んだ状態から画面が戻るトランジションにする
dailyMemoTransition.presenting = false
return dailyMemoTransition
}
}
★4-2. 非同期処理やデータの取得処理とActionの発行を組み合わせる部分の解説:
続いてAPI通信の様な非同期処理を伴う部分のAction発行時の処理を考えてみます。API通信実行する部分についてはAPIのエンドポイントへアクセスした際の処理結果に応じた処理を実行するためのクラス(Singleton Instance)を定義しています。このクラス内の処理の概要としては、ActionCreatorで定義したStateを更新するためのActionを発行する処理と連動して扱えるようにするため、下記のような形にします。
PickupMessage(画像付きメッセージを表示する部分の状態や表示データの取得を行う)部分におけるAPI通信処理:
import Foundation
import Alamofire
import PromiseKit
import SwiftyJSON
class APIManagerForPickupMessage {
private let baseUrl = "APIのエンドポイントとなるURLを指定する"
// MARK: - Singleton Instance
static let shared = APIManagerForPickupMessage()
private init() {}
// MARK: - Functions
// PickupMessage一覧を取得する
func getPickupMessageList() -> Promise<JSON> {
// Alamofireの非同期通信をPromiseKitの処理でラッピングする
// 例.
// -----------
// getNewsList(page: 0)
// .done { json in
// // 受け取ったJSONに関する処理をする
// print(json)
// }
// .catch { error in
// // エラーを受け取った際の処理をする
// print(error.localizedDescription)
// }
// -----------
// 参考URL:
// https://medium.com/@guerrix/101-alamofire-promisekit-671436726ff6
// ※ Swift4系では書き方が変わっているのでご注意を!
// https://stackoverflow.com/questions/48932536/swift4-error-cannot-convert-value-of-type-void-to-expected-argument-typ
return Promise { seal in
Alamofire.request(baseUrl, method: .get).validate().responseJSON { response in
switch response.result {
// 成功時の処理(表示に必要な部分だけを抜き出して返す)
case .success(let response):
let res = JSON(response)
let json = res["article"]["contents"]
seal.fulfill(json)
// 失敗時の処理(エラーの結果をそのまま返す)
case .failure(let error):
seal.reject(error)
}
}
}
}
}
※ 本サンプルでは「API通信結果のハンドリング・任意の処理を実行したn秒後に別の処理を続けて実行する」部分を実現するためにライブラリ:PromiseKitを利用しています。
5. UI表現に関する処理とReduxに関する処理の組み合わせ
ここからは実際に本サンプル内で実装している、Reduxで実現する状態管理と画面表示を連結した処理部分の事例においてポイントとなりそうな部分を紹介していきたいと思います。
その中でも特にこの記事では、素直に実装した場合にViewControllerの状態やUIの管理が煩雑になりやすい3つのケースに関して図解を交えて簡単に説明できればと思います。
(詳細なレイアウト定義や実装に関しては、冒頭に掲載したリポジトリを参考にして頂けましたら幸いです)
★5-1. 項目が多めな入力フォームを構築する場合の例:
まずは、初回に表示されるユーザーの情報を入力するフォーム部分のUIをReduxでの状態管理を組み合わせた例について見ていきます。
この画面におけるReduxを用いて管理したいものとしては、
- この画面からユーザーが現在入力または選択している値の状態
- 次の画面に進むための条件が満たされているかの判定
- キーボードが表示されているかの判定
の3つとなり、上記の情報を踏まえてこの画面におけるStateを定義すると下記のような形になるかと思います。
Redux部分の実装としては、「ユーザーの入力や選択する行動によってUIの状態が変化する = ユーザーのユーザーの入力や選択した値が見える状態となる」 となる必要があります。
画面内に配置されているUIパーツからの入力または選択された値をStateへ反映する際は、UITextFieldDelegate
やUITextViewDelegate
をはじめとする各種Delegateに関する処理やaddTarget
メソッドで実行される処理内にActionCreatorで定義したStateを更新するためのActionを発行する処理を実行させることで実現します。
その後はReducerを経由して入力された値をStateに反映することで、Stateを更新してViewControllerで仕込んであるfunc newState(state: AppState)
から更新されたStateを受け取って、ユーザーが入力した値を反映します。この画面におけるReduxの処理は言い換えると 「入力されたデータをStateに反映させて、ViewControllerでStateの値をそのままUI要素へ反映する」 形になります。
★5-2. 次の10件を取得して現在の表示中の一覧に追加する場合の例:
次に、一番最初に10件の表示データが表示されている初期状態の画面において一番下にある「次の10件を表示する」ボタンを押下した際に現在表示されているデータの一番後ろから新たに取得した10件のデータを表示するようなUIを考えて見ていきます。
表示データの一覧を表示しているViewControllerに関しては下記のような形で、ContainerViewを利用してそれぞれの画面を分離しています。このViewControllerで実行する処理はAPIから次の10件の表示データを取得してこのViewControllerに対応するStateを更新して画面に反映する処理をしていきます。
しかしながら、これだけでは親のViewControllerに配置しているContainerViewの高さは変化しないので、このタイミングでStateで格納されている表示データの個数から高さを計算して反映させる処理を下記のような形でプロトコルを利用して実行させることで実現しています。
この様な動きをする処理については、普通のUITableViewの処理だけで実現しようとする場合には、画面の状態まで詳細に考慮しようとするとなかなか面倒な実装になりますが、Reduxを用いた状態管理を利用することで変化をさせるべき部分の処理に関する見通しをよくすることができる様に思います。
★5-3. 複数個のContainerViewへ親側の処理結果を伝える場合の例:
最後に、それぞれのコンテンツ要素を表示しているContainerViewを配置している親の画面をRefreshした場合の処理について見ていきます。(iOS10以降からUIScrollViewについても引っ張って更新する動きができるようになっています)
この場合に関しても、下記の様に 「それぞれの画面ごとに初期の状態に戻る処理をするのではなく、それぞれの画面に対応しているStateを初期の状態に戻すためのActionを発行する」 ことで実現できます。
UI上で実現したい動き次第ではViewController内だけで処理すると骨が折れてしまいそうな実装でも、Reduxの処理と組み合わせることで、データの更新フローの整理と表示データに関する状態とUI表示に関する状態の関係性を整理できる様な例はまだまだあるかと思いますので、この部分に関しては引き続き模索して行こうと思います。
6. XCTestを活用してReduxのデータフローを担保するためのヒント
今回のサンプルではActionCreator内に定義したメソッドにおいてViewの表示に必要なデータを取得する処理を実行し、Action発行時にReducerにデータを送ることでView表示に必要なStateへ反映する形を取っています。
その中でもReduxのデータフローにおける 「Store(変更後のState) → Action → Reducer → Store(変更後のState)」 の一連の流れを行う処理を担保したかったので、下記のような形でテストコードを実装しました。
★6-1. Actionに定義された引数の値をStateに反映する部分のテスト:
それぞれのActionを発行する前後におけるStateの変化を反映する処理が担保されている例を見てみようと思います。Stateを更新するために取得したデータはActionを発行する際に引数として渡され、その後Reducerでの処理を経て新しいStateとなります。この処理のテストコードは下記のような形となります。
任意のStoreをコード内に準備して、検証したいStateに関するAction発行前後のStateの変化が正しく実行されていることを確認することがポイントになるかと思います。
// 初回設定に関するStateにおいてActionの発行から値反映までが実行されているかを確認するテストコード
func testInitialSettingState() {
let appStore = Store(reducer: appReduce, state: AppState(), middleware: [ActionLoggingMiddleware])
// Action発行前の値に関するテスト
let beforeInstallAppDate = appStore.state.tutorialState.installAppDate
let beforeIsFinishedUserSetting = appStore.state.tutorialState.isFinishedUserSetting
let beforeIsFinishedTutorial = appStore.state.tutorialState.isFinishedTutorial
XCTAssertEqual(nil, beforeInstallAppDate, "最初はinstallAppDateがnilである")
XCTAssertEqual(false, beforeIsFinishedUserSetting, "最初はisFinishedUserSettingがfalseである")
XCTAssertEqual(false, beforeIsFinishedTutorial, "最初はisFinishedTutorialがfalseである")
// アクションの実行 ※InitialSettingActionCreator.setCurrentUserStatus()
let currentDate = Date()
appStore.dispatch(
TutorialState.tutorialAction.setInstallAppDate(date: currentDate)
)
appStore.dispatch(
TutorialState.tutorialAction.setIsFinishedTutorial(result: true)
)
appStore.dispatch(
TutorialState.tutorialAction.setIsFinishedUserSetting(result: true)
)
// Action発行後の値に関するテスト
let afterInstallAppDate = appStore.state.tutorialState.installAppDate
let afterIsFinishedUserSetting = appStore.state.tutorialState.isFinishedUserSetting
let afterIsFinishedTutorial = appStore.state.tutorialState.isFinishedTutorial
XCTAssertEqual(currentDate, afterInstallAppDate, "installAppDateにAction経由で現在時刻が反映される")
XCTAssertEqual(true, afterIsFinishedUserSetting, "isFinishedUserSettingにAction経由でtrueが反映される")
XCTAssertEqual(true, afterIsFinishedTutorial, "isFinishedTutorialにAction経由でtrueが反映される")
}
コード例は、アプリの状態に関する値の反映に関するRedux処理部分のものになりますが、ユーザーが入力した値をStateに反映する処理についても冗長な感じではありますがほぼ同様のテストコードを加えています。(基本的にはStateの値を元にUIやViewの状態が作成される形です)
★6-2. API通信時に取得した値をStateに反映する部分のテスト:
本サンプル内で使用しているActionCreator内に定義されたメソッドにおいては、APIの非同期通信を利用するものがあります。このような部分の処理に関しては、APIから取得したJSONデータを加工したものをAction発行時に引き渡す必要があるので、別途レスポンスで取得した値と同じ形式のJSONデータをスタブとして持ち、そのデータを利用すると良いかと思います。この処理のテストコードは下記のような形となります。
// ピックアップメッセージ画面に関するStateにおいてActionの発行から値反映までが実行されているかを確認するテスト
func testPickupMessageState() {
let appStore = Store(reducer: appReduce, state: AppState(), middleware: [ActionLoggingMiddleware])
// Action発行前の値に関するテスト
let beforeIsPickupMessageAreaHidden = appStore.state.pickupMessageState.isPickupMessageAreaHidden
let beforePickupMessageStateList = appStore.state.pickupMessageState.pickupMessageStateList
XCTAssertEqual(false, beforeIsPickupMessageAreaHidden, "初期値はfalseである")
XCTAssertEqual(0, beforePickupMessageStateList.count, "初期値は空配列である")
// PickupMessageActionCreator内に定義した値を反映するアクションの実行
appStore.dispatch(
PickupMessageState.pickupMessageAction.setIsPickupMessageAreaHidden(result: true)
)
// MEMO: スタブで用意した形式のJSONファイルを読み込んでAction実行時にJSONの値を反映させる
// ※ エンドポイントとほぼ同じレスポンスのJSONファイルを準備する
let filePath = Bundle.main.url(forResource: "PickupMessageStub", withExtension: "json")!
let messageJSON = JSON(try! Data(contentsOf: filePath))
let pickupMessageList = PickupMessage.getPickupMessagesBy(json: messageJSON["article"]["contents"])
appStore.dispatch(
PickupMessageState.pickupMessageAction.setPickupMessage(pickupMessage: pickupMessageList)
)
// Action発行後の値に関するテスト
let afterIsPickupMessageAreaHidden = appStore.state.pickupMessageState.isPickupMessageAreaHidden
let afterPickupMessageStateList = appStore.state.pickupMessageState.pickupMessageStateList
// (1) 表示フラグの変更
XCTAssertEqual(true, afterIsPickupMessageAreaHidden, "setIsPickupMessageAreaHiddenの引数と同じ値となる")
// (2) JSONからの表示用データ生成
XCTAssertEqual(18, afterPickupMessageStateList.count, "pickupMessageStub.json内の表示用データの個数と同じ値となる")
// (3) 表示用データの構成要素
let firstData: PickupMessageEntity = afterPickupMessageStateList.first!
XCTAssertEqual("1", firstData.id, "pickupMessageStub.jsonのデータ部分の1番目に相当するID文字列と同じ値となる")
XCTAssertEqual("旬の魚が安く手に入る「いきいき魚市」", firstData.title, "pickupMessageStub.jsonのデータ部分の1番目に相当するタイトル名と同じ値となる")
XCTAssertEqual("ショッピング・お買い物", firstData.category, "pickupMessageStub.jsonのデータ部分の1番目に相当するカテゴリ名と同じ値となる")
XCTAssertEqual("https://kanazawa-photos.s3-ap-northeast-1.amazonaws.com/articles/images/1/large.jpg", firstData.imageUrl, "pickupMessageStub.jsonのデータ部分の1番目に相当する画像ファイルURLの文字列と同じ値となる")
}
特にReSwiftで実現するReduxのデータフローを担保するためのテストコードを用意しておくことで、画面状態を構成するために必要な値をAction経由で渡した際に然るべき形でStateに反映されていることを担保しておくと実装がより捗るかと思います。(機能を担保するためのテストコードの形は様々な形があると思いますが、状態の更新を伴う処理に関しては重要な部分なのでこのサンプルではこのような形にしました)
7. あとがき
この記事の中ではReduxの活用とUI実装の組み合わせに関するトピックに関して、自分なりに簡単なサンプル実装を通じての解説をしましたが、今後はさらにより複雑な画面構成のパターンやRxSwift等とも組み合わせたアーキテクチャの事例に関しても深掘りができるよう突き詰めていければと感じました。
Reduxのルールや原則はiOSが本来持っている仕組みとは異なる部分もあるので、その部分に関しては理解するまでは若干のしんどさはありましたが、Stateの値の状態に応じてアプリのUI要素がStateに対応した形になる、すなわち 「Stateの値 = アプリのUI要素の状態」 として結びついていることを念頭に置きながら進めていくとより理解が深められるのではないかと思います。
サンプルを作成する過程の中ではReSwiftで実現できるReduxの構成はもちろんですが、Stateの変化とUIの変化を結びつける上でのStateの構造設計やUI間での関連する処理の設計に関しても、「各状態におけるデータとUIのあるべき姿を整理する」 ことが大切になってきます。その中でも特に、
- ReSwift(Redux)のStateで管理したい部分はどこか?
- データに関わる部分とUIの変更処理の連携を実現するにあたりReduxやProtocolをいかに利用するか?
の2点については、設計時ないしは実装時に方針を予め立ててから進めていくと良いかもしれません。
UI実装をする際においては、アプリ内の機能が増えていくことに伴ってUI実装においても状態管理がどんどん煩雑化しやすく、特に状態と紐づくUIの考慮漏れについては複雑になるほどに検知がしにくくなる部分になるかと思います。このような問題に対応する場合の解決策の選択肢の1つとして、状態とUI要素の関連性を整理するためにReduxを利用する方法は有効なのではないかと個人的に感じた次第です(今関わっているプロジェクトやこれから作る個人アプリでも導入してみたいなぁ...という願望もあります)。