はじめに
こんにちは、アイカワと申します。
この記事は iOS Advent Calendar 2020 の 23 日目の記事です。
昨日は堤さんの「coremltools 4.0でPyTorchモデルを変換する」でした。
最近は SwiftUI や The Composable Architecture(以後 TCA)の勉強をしていて、TCA についての解説記事もあまりないことを踏まえて、この記事を書こうと思います(ボリュームが想像以上に膨れあがってしまいすみません🙇♂️ )
iOS のバージョンの兼ね合いもあって SwiftUI をプロダクトに導入する事はまだ難しいかもしれませんが、TCA は UIKit でも利用できます。(Combine に依存しているため、iOS13 からしか利用できませんが😢 )
また、利用方法は SwiftUI と UIKit でそこまで大きくは変わらないです。
そのため UIKit から少し触ってみるでもいいので、SwiftUI をまだ触っていなくても一度勉強してみるのは良さそうと思っています。
それと、記事の最後でも少し説明しますが、「UIKit に TCA を組み込む -> View を SwiftUI に切り替える」という手順で徐々に SwiftUI に移行していくのも個人的には良さそうだと思っています。
今回は TCA の作者である Point-Free さんが公開されている Example > Case Studies > 02-Effects-Cancellation を題材に TCA について解説してみようと思います。
「02-Effects-Cancellation」は実際に API 通信も行うことになるため、ある程度実践的な例になっていると思います。
しかし、そのまま解説しようとすると TCA の若干発展的な内容も含んでしまうため、**「少し簡単な構造に作り替えた + UIKit に書き換えたものを追加」**したものをベースに説明していこうと思います。(コードはこちら)
TCA とは?
The Composable Architecture はざっくり説明すると(語弊はありますが) Redux ライクな状態管理手法を提供するライブラリです。(詳しい説明については README を参照していただけるとわかりやすいと思います。FAQ には Elm, Redux と比較した違いのようなことも書かれています)
今回 TCA が何かという厳密な説明は省略しますが、(正確ではない部分はありますが)イメージは ↓ の図のように
-
View
- View そのもの。後述の ViewStore を保持する。ViewStore を通じ、「Action を Reducer に送って State(状態)を変更」・「State の変更を UI に反映」などを行う
-
Action
- (主に)View から発生しうる Action を定義
-
Reducer
- ViewStore から受け取った Action に応じて主に State を変更する
-
Effect
- Reducer 内の処理で副作用が存在する場合には、Effect を利用して Action を送り、再び Reducer に State の変更を委ねる(今回の例で出てくるので、今何を言っているのかわからなくても大丈夫です)
- Effect で明確に副作用を管理しているというところが TCA のイチオシポイントです
-
Environment
- Reducer で使用する依存関係(例えば API Client や スケジューラなど)の整理場所。Environment を Reducer で使用することによって、テストが容易になるなどの恩恵がある
-
State
- アプリが管理する状態。Action によって動作する Reducer のみが State を変更できる
-
Store
- State, Reducer, Environment を用いてイニシャライズされる。実装する上では、Store を直接利用するのではなく、Store を利用した ViewStore を主に利用することになる
という主に 8 つの役割(太字の部分を数えています)が登場します。
今回紹介する例では全てのものを利用するため、この記事を読み終える頃にはある程度、それぞれの役割と使い方を理解できるようになって頂けていれば嬉しいです。
自分は TCA の存在自体は今城さんの「Swiftによるアプリ開発のためのThe Composable Architectureがすごく良いので紹介したい」という記事で知りました。
その時、TCA のことを良さそうだと思って何から勉強すれば理解できるんだろう、という時に役立ったのは Point-Free さんが公開されている A Tour of the Composable Architecture でした。
Part1 - 4 まで無料で公開されており、TCA の網羅的な解説や TCA の便利なテストサポート機能、Reducer の少し発展的な使用方法(Reducer を分割してみる)まで記載されているので、興味を持たれた方はぜひ目を通してみて頂けると良いと思います。
(余談ですが、Point-Free さんの他の動画は有料のものが多いですが、どれも非常に勉強になるものばかりなのでおすすめです)
ちなみに、力尽きて続きを書くことができていないのですが、Qiita に「A Tour of the Composable Architecture」の Part1 - Part3 の前半までについては解説記事(ほぼ翻訳みたいになってしまっている)のようなものも書いているので、英語きつい...みたいな方はぜひ参考にしていただけると嬉しいです。(もちろん一番は元記事を見ていただくことが網羅的に理解できることに繋がるかとは思います🙏 )
今回解説するアプリ概要について紹介
さて、前置きが長くなってしまいましたが、コードの紹介の前に簡単にアプリの概要を紹介します。
今回紹介するアプリは、好きな数字を選択して、その数字に関係するトリビアを表示するだけという UI 的には簡単なものになります。
数字にまつわるトリビアについては、Numbers API を使用して取得しています。
イメージのために、gif も貼っておきます ↓
数字を Stepper で選択し、Button を押せば選択した数字に関するトリビアが出てくるシンプルなアプリです。通信中にボタンを再度押せば、通信をキャンセルすることができるようにもなっています。(おまけ程度に記事の後半で紹介する UIKit バージョンもチラッと gif に載せています)
Numbers API の仕様
Numbers API の Reference は ↓ になります。
非常にシンプルな API で、今回は以下のように利用しています。
-
type
はtrivia
で固定 -
number
はユーザーに Stepper で入力させる -
http://numbersapi.com/{number}/trivia
を叩いて、その結果(String)を取得し、それを View 上に表示するという形で利用する
コードを辿りながらアプリの実装方法について解説
一応、参考のために最初にファイルツリーを示します。
TCASampleCancellation
|__ SwiftUI
| |__ EffectsCancellationView.swift # SwiftUI 製の View と TCA の各要素
|__ UIKit
| |__ EffectsCancellationViewController.swift # UIKit 製の View
| |__ EffectsCancellationViewController.xib # xib
|__ Internal
| |__ ActivityIndicator.swift # ローディング中であることを示す Indicator(重要ではないので説明しない)
| |__ UIViewRepresented.swift # UIKit から ActivityIndicator を利用できるようにしている(重要ではないので説明しない)
|__ TCASampleCancellationApp.swift # Root View(各 View のイニシャライズを行う)
|__ NumbersAPIClient.swift # NumbersAPI と通信するための APIClient
TCA の要素それぞれについて説明する前に、まず Numbers API と通信するための APIClient の実装について説明します。
APIClient
APIClient Interface
struct NumbersAPIClient {
var trivia: (Int) -> Effect<String, TriviaApiError>
struct TriviaApiError: Error, Equatable {}
}
APIClient のインタフェースは上記のように定義されています。
Numbers API は任意の数字を含んだ URL でリクエストを行い、それによって返却される String のレスポンスを受け取る仕様になっています。
API 通信なのでエラーが発生する可能性も考慮し、独自の TriviaApiError
という struct を定義し、利用しています。
それらを考慮し、 trivia
は (Int) -> Effect<String, TriviaApiError>
というクロージャで定義されています。
プログラム上の副作用は TCA では Effect で扱います。例えば今回の API Client などが副作用にあたるため、 trivia
では Effect を返却するようにしています。
Effect は Reducer 内で利用するため、そちらでもう少し詳しく解説します。
今は**「 Effect は Combine の Publisher のラッパーで、副作用を扱うためのもの」**程度に理解しておいて頂ければ問題ないと思います。
APIClient Implementation
上で紹介した Interface を実際に実装して、簡単に利用できるようにしたものが以下になります。
extension NumbersAPIClient {
static let live = NumbersAPIClient(
trivia: { number in
URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(number)/trivia")!)
.map { data, _ in String.init(decoding: data, as: UTF8.self) }
.catch { _ in
Just("\(number) is a good number Brent")
.delay(for: 1, scheduler: DispatchQueue.main)
}
.mapError { _ in TriviaApiError() }
.eraseToEffect()
})
}
extension 内で、static な live
という変数で定義することによって、他の場所からは NumbersAPIClient.live
という形で APIClient を利用できるようになります。
trivia
は number
という Int 型の変数を受け取って、その変数を利用して URLSession
で通信を行っています。
dataTaskPublisher
の内部では主に以下のようなことが行われています。
-
"http://numbersapi.com/\(number)/trivia"
でリクエスト -
map
内で、受け取ったdata
を元に String として decode -
catch
によって、エラーがあれば単純な"\(number) is a good number Brent"
という文字列を返すようにしている -
mapError
によって、エラーがあれば、TriviaApiError()
を返すようにしている -
eraseToEffect
は Publisher のOutput
とFailure
を Effect 型として扱えるようにする。上流の operator によってOutput == String
,Failure == TriviaApiError
という結果になっているため、Effect<String, TriviaApiError>
として扱えるようになる(Publisher の Output, Failure などがピンとこない方は WWDC の Combine の動画を見ていただけると理解できると思います)
上記は Combine の話がほとんどで、唯一 eraseToEffect
のみは TCA の概念となっています。
eraseToEffect
は上記で説明したことを行うだけですが、もし詳しい実装を見たい方はこちらを参照して頂けると良いかと思います。
この一連の処理によって、NumbersAPIClient.trivia
は (Int) -> Effect<String, TriviaApiError>
という定義を満たせるようになります。
State
次に State についてです。
State という名前の通り、State ではアプリで管理したい状態を定義します。
TCA では State は基本的に struct で定義することになります。
State のコードは以下のようになります。
struct EffectsCancellationState: Equatable {
var count = 0
var currentTrivia: String?
var isTriviaRequestInFlight = false
}
-
count
:ユーザーが現在選択している数字 -
currentTrivia
:数字についてのトリビアを入れるための変数。View 上にトリビアを表示しない場合もあるため optional で定義している -
isTriviaRequestInFlight
:API リクエスト中かどうかを判断するための変数
State を Equatable
に適合させているのには主に以下のような理由があります。
- テストで State が扱いやすくなる
- State を assert するようなことを考えるとこれは想像しやすいかもしれないです。今回はテストまで踏み込んで説明はしないのですが、TCA のテストヘルパーは非常に強力なので一度利用してみるのがおすすめです。(一応、こちらの記事でもテストについて紹介しています)
- View で State を扱う際に自動的に State の重複を排除してくれる
- TCA は State を
Equatable
に適合させることによって、自動的に State の重複を排除してくれる仕組みを備えています。Combine のremoveDupulicates
を利用して重複を排除するような実装になっている(コードはこちら)のですが、少し横道に逸れるため詳しくは踏み込まないでおこうと思います。
- TCA は State を
State を Equatable
に適合させずに実装することもできるようですが、↑のような恩恵があるため基本的に State は Equatable
に適合させるのが良いと思っています。
Action
次に Action についてです。
Action はユーザーが起こす UI 操作イベントなど発生しうる全ての Action を定義する部分になります。
列挙する形になるため、enum で定義することになります。
これについても先にコードを示します。
enum EffectsCancellationAction: Equatable {
case cancelButtonTapped // API リクエスト中にキャンセルボタンをタップした時
case stepperChanged(Int) // Stepper の値が変更された時(+ or - ボタンを押した時)
case triviaButtonTapped // API リクエストボタンをタップした時
// ↑ ユーザーの操作によって発火
// ---------------------------------------------------------------------
// ↓ Effect によって発火
// triviaButtonTapped の処理中で返却される Effect によって発火する
case triviaResponse(Result<String, TriviaApiError>)
}
View の説明はまだしていないため、少しイメージが湧きにくいかもしれないのですが、先ほどの gif をイメージしながら考えていただけると良いと思います。
後で説明することになりますが、View 内で Action を送るためには、 viewStore.send(.アクション名)
という記述で Action を発火させることができます。
TCA では Action を通じてのみアプリの状態である State を変更することができるため、何か状態を変更したい場合は必ず Action を発火させることになります。
上から三つの Action はユーザーの操作によって発火するため、比較的わかりやすいと思うのですが、 triviaResponse(Result<String, TriviaApiError>)
は少し特殊です。
具体的には triviaButtonTapped
Action が発火することによって Reducer で State を変更するための処理が行われるのですが、その処理の中で triviaResponse
は発火します。
文章だけだとイメージも湧きにくく、次の Reducer 内で説明した方がわかりやすいかと思うので、ここではこのくらいの説明に留めます。
ちなみに Action が Equatable
に適合しているのもテストで扱いやすくするためです。
Reducer
さて、いよいよ TCA の中でも一番複雑な Reducer について説明します。
複雑とは言いましたが、処理の流れを少しずつ追えば理解できるものではあるので、少しずつ処理を追っていこうと思います。
まず前提として Reducer は以下のように動作することを頭に入れていただけると良いかと思います。
- (主に)View から Action が
viewStore.send(アクション名)
という形で送られる - Action に応じて Reducer 内で State を変更するための処理を行う
- その際、依存関係を利用するために後述の Environment を利用したり
- 副作用を扱うために前述の Effect を利用したりする
- 最終的に Reducer 内の各処理は Effect を返す
もし処理の流れがわからなくなった時は ↑ のどこにあたるかを意識しながら読んでいただけると良いと思います。
処理の流れを追う前に少しだけ Environment について説明します。
Environment
Environment はアプリにおいて依存関係を整理するための場所です。
イメージは外部から値を注入(DI)した方が、テストなどが書きやすくなるような値をここに置きます。
今回は以下のようになっています。
struct EffectsCancellationEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
var numbersClient: NumbersAPIClient
}
今回はテストについて解説しないため、利点をうまく伝えることができず苦しいですが、こちらのコードを参照していただくのがわかりやすいと思います。
チラッとだけ説明すると、Test では Test 用の TestStore
というものを利用することになるのですが、これのイニシャライズ時に Environment は以下のように注入されます。(コードの処理について解説はしないので、深く追わなくても大丈夫です)
let store = TestStore(
initialState: .init(),
reducer: effectsCancellationReducer,
environment: .init(
mainQueue: self.scheduler.eraseToAnyScheduler(),
trivia: { n in Effect(value: "\(n) is a good number Brent") } // mock の client(数を受け取って Effect を返すだけ)
)
)
このように Environment を利用することによって、簡単に依存関係を外部から注入できる仕組みが TCA には備わっています。
Reducer の処理を追ってみる
それでは、少しずつ処理を追っていきます。
まず、Reducer の全体像は以下のようになっています。
let effectsCancellationReducer = Reducer<
EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment
> { state, action, environment in
// 1. リクエストをキャンセルする時に、リクエストを一意に識別するための ID
struct TriviaRequestId: Hashable {}
switch action {
case .cancelButtonTapped:
// 1. キャンセルボタンを押した時の処理
case let .stepperChanged(value):
// 2. Stepper で数字が変更された時の処理(value は 数字)
case .triviaButtonTapped:
// 3. triviaButton(API リクエストを行うボタン)を押した時の処理
case let .triviaResponse(.success(response)):
// 3. 後で説明します
case .triviaResponse(.failure):
// 3. 後で説明します
}
}
まず、Reducer は以下のように State, Action, Environment を定義して利用する形になります。
let effectsCancellationReducer = Reducer<
EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment
> { state, action, environment in
// ...
}
このように定義することによって、今まで定義してきた State, Action, Environment を Reducer 内で扱うことができるようになります。
実際にどのように扱うかについては、コメントアウトの部分に数字を振ったので、数字ごとに追っていきます。
1. キャンセルボタンを押した時の処理
まず、View のキャンセルボタンを押した時に関わる処理から説明します。
Reducer は**「Action を受け取る -> State を変更する -> Effect を返却する(後ほど説明)」**という処理をほぼ一貫して行うため、一つの処理を理解できれば、次以降はサクサク理解できるかなと思います。
処理は多くないため、「キャンセルボタンを押した時の処理」について先にコードを示します。
struct TriviaRequestId: Hashable {}
switch action {
case .cancelButtonTapped:
state.isTriviaRequestInFlight = false
return .cancel(id: TriviaRequestId())
case ...
TriviaRequestId
という struct はリクエストを一意に識別するために利用する Hashable
なもので、今回のように .cancel(id: )
の引数として利用することができます。
.cancel
などの内部的な処理については詳しく解説はしませんが、コードを見ていただけるとなんとなく処理は掴めるかなと思います。
概要だけ説明すると TriviaRequestId
は以下のように扱っています。
- リクエストを投げる時には
.cancellable(id: TriviaRequestId())
という形で一意にリクエストを識別できる値を保持する(後の 3 番で登場します) - リクエストをキャンセルしたい時には今回のように
.cancel(id: TriviaRequestId())
という形で一意に識別したリクエストをキャンセルできる
以上を踏まえて、.cancelButtonTapped
Action では以下のような処理が行われています。
- State の
isTriviaRequestInFlight
をfalse
にして、リクエスト中ではないという状態を保持する -
.cancel(id: TriviaRequestId())
によってリクエストをキャンセルしつつ、.cancel(id: )
の戻り値である Effect を 返却 する
Reducer の処理の流れについては説明しましたが、Effect を 返却 するという部分については説明できていないため、軽く説明します。
TCA における Reducer は Effect を返却する必要があるため、各 Action を受け取って処理を終える時には必ず何らかの Effect を 返却しなければなりません。
.cancel(id: )
はリクエストをキャンセルしつつ、Effect を戻り値として持っています。
参考のために、.cancel(id: )
の実装は以下のようになっています。
public static func cancel(id: AnyHashable) -> Effect {
return .fireAndForget {
cancellablesLock.sync {
cancellationCancellables[id]?.forEach { $0.cancel() }
}
}
}
このため return .cancel(id: )
という実装によって、「Reducer が Effect を返却しなければならない」という要件を満たすことができていることになります。
2. Stepper で数字が変更された時の処理
次に View の Stepper で数字が変更された時に関わる処理を説明します。
こちらもコードを先に示します。
switch action {
case ...
case let .stepperChanged(value):
state.count = value
state.currentTrivia = nil
state.isTriviaRequestInFlight = false
return .cancel(id: TriviaRequestId())
case ...
先ほどの 1 番の処理が理解できていれば、こちらの処理は容易く理解できると思います。
一応説明しておくと以下のような処理が行われています。
- State の
count
に Stepper で選択された数字を保持する - 数字が変更されたということなので、State の
currentTrivia
をnil
にし、画面に表示する用のトリビアテキストが表示されないようにする - State の
isTriviaRequestInFlight
をfalse
にして、リクエスト中ではないという状態を保持する -
.cancel(id: TriviaRequestId())
によってリクエストをキャンセルしつつ、.cancel(id: )
の戻り値である Effect を 返却する(1 番と同じ処理)
3. triviaButton を押した時の処理
さて、Reducer の最後の処理です。
switch action {
case ...
// 3-1: triviaButton が押された時に発火
case .triviaButtonTapped:
state.currentTrivia = nil
state.isTriviaRequestInFlight = true
return environment.numbersClient.trivia(state.count)
.receive(on: environment.mainQueue)
.catchToEffect()
.map(EffectsCancellationAction.triviaResponse)
.cancellable(id: TriviaRequestId())
// 3-2: map(EffectsCancellationAction.triviaResponse) が success だった時に発火
case let .triviaResponse(.success(response)):
state.isTriviaRequestInFlight = false
state.currentTrivia = response
return .none
// 3-3 map(EffectsCancellationAction.triviaResponse) が failure だった時に発火
case .triviaResponse(.failure):
state.isTriviaRequestInFlight = false
return .none
さらに番号を振ったので、番号ごとに説明します。
######3-1 case .triviaButtonTapped
(複雑なので State の変化と Effect に関わる処理に分けて説明します)
[State の変化]
- State の
currentTrivia
をnil
にして、一旦 View に表示されるトリビアを非表示にする - State の
isTriviaRequestInFlight
をtrue
にして、リクエスト中であるという状態を保持する
Effect に関わる処理は結構複雑であるため、↑ の図と照らし合わせながら説明します。
-
count
の値を使って、Environment のnumbersClient
で API リクエストを行っている(①)- この時点では
Effect<String, TriviaApiError>
という型になっている(忘れてしまった方は APIClient の実装部分を参照)
- この時点では
-
environment.mainQueue
で指定したスレッドで処理を実行するようにしている(②) -
Effect<String, TriviaApiError>
という型で流れてきた値をcatchToEffect
によって<Result<String, TriviaApiError>, Never>
という型で扱えるようにする(TCA のcatchToEffect
についての実装についてはこちら) -
map(EffectsCancellationAction.triviaResponse)
で、流れてきた Result の結果によってtriviaResponse
Action を発火させる -
cancellable(id: TriviaRequestId())
という形で一意にリクエストを識別できるように保持しておきつつ、Effect を返却している
複雑には見えますが、一つ一つの operator を追っていけば何とか処理の流れは理解できるかなと思います。
重要なのは catchToEffect
によって <Result<String, TriviaApiError>, Never>
という型で扱えるようにしている部分だと思います。
この Result で EffectsCancellationAction.triviaResponse
を発火させることによって、Result が success の場合であれば、.triviaResponse(.success(response))
が発火し、Result が failure であれば、.triviaResponse(.failure)
が発火するという流れになっています。
それぞれの triviaResponse
Action が発火した後の動作は簡単ですが、一応説明します。
3-2 case let .triviaResponse(.success(response))
- リクエストが終了した状態なので、State の
isTriviaRequestInFlight
をfalse
にする - State の
currentTrivia
に返却されたresponse
を代入する -
none
Effect(何も Effect を返却する必要がない場合はこれを使う) を返却する
3-3 .triviaResponse(.failure)
- リクエストが終了した状態なので、State の
isTriviaRequestInFlight
をfalse
にする -
none
Effect を返却する
View
最後に View について説明します。ここまで理解できていればそれほど難しくないと思います。
まず全体像を示します。
struct EffectsCancellationView: View {
// ① Store を View 内で定義
let store: Store<EffectsCancellationState, EffectsCancellationAction>
var body: some View {
// ② WithViewStore を使って ViewStore を利用できるようにする
WithViewStore(self.store) { viewStore in
Form {
Section(
footer: Button("Number facts provided by numbersapi.com") {
UIApplication.shared.open(URL(string: "http://numbersapi.com")!)
}
) {
// ④ Stepper の表示
Stepper(
value: viewStore.binding(
get: { $0.count }, send: EffectsCancellationAction.stepperChanged)
) {
Text("\(viewStore.count)")
}
// ③ トリビアボタン(リクエスト・リクエストキャンセル)の表示
if viewStore.isTriviaRequestInFlight {
HStack {
Button("Cancel") { viewStore.send(.cancelButtonTapped) }
Spacer()
ActivityIndicator()
}
} else {
Button("Number fact") { viewStore.send(.triviaButtonTapped) }
.disabled(viewStore.isTriviaRequestInFlight)
}
// ⑤ トリビアテキストの表示
viewStore.currentTrivia.map {
Text($0).padding([.top, .bottom], 8)
}
}
}
}
.navigationBarTitle("Effect cancellation")
}
}
イメージとしては以下をおさえておけばコードを理解できると思います。
- Store を View 内で定義
- WithViewStore を用いて、ViewStoreを View 内で利用できるようにする
- ViewStore を用いて Action を送ったり、State を利用したりする
- Action を送った結果 State が変更されれば View は自動的に更新されます
こちらも少しずつ見ていきます。
① Store を View 内で定義
let store: Store<EffectsCancellationState, EffectsCancellationAction>
まず、View 内で Store を定義します。
Store ではこれまでに作成した EffectsCancellationState
と EffectsCancellationAction
を利用するようにします。
View のイニシャライズ時にこの store に値を設定するため、View のイニシャライズは以下のように行います。
EffectsCancellationView(
store: Store(
initialState: EffectsCancellationState(), // State のイニシャライズ
reducer: effectsCancellationReducer, // Reducer は EffectsCancellationView.swift で定義したものを利用
environment: EffectsCancellationEnvironment( // Environment はイニシャライズ時に注入
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
numbersClient: NumbersAPIClient.live
)
)
)
このようにすれば、View 内で Store を利用することが可能になります。
② WithViewStore を使って ViewStore を利用できるようにする
var body: some View {
WithViewStore(self.store) { viewStore in
// ...
}
}
先ほど Store を定義しましたが、View 内部では Store を直接扱うのではなく、ViewStore というものを用いて Action の送信や State へのアクセスを行います。
↑のように WithViewStore
に定義していた Store を入れてあげると ViewStore が View から扱えるようになります。
(ちなみに Store を与えるだけで利用できるようになっているのは、State を Equatable
に適合させているからになります。)
③ トリビアボタン(リクエスト・リクエストキャンセル)の表示
次はトリビアボタンの表示に関わる部分について説明します。
画面では以下がそれぞれ対応します。
リクエストボタン | リクエストキャンセルボタン |
---|---|
コードは以下になります。
if viewStore.isTriviaRequestInFlight {
HStack {
Button("Cancel") { viewStore.send(.cancelButtonTapped) }
Spacer()
ActivityIndicator()
}
} else {
Button("Number fact") { viewStore.send(.triviaButtonTapped) }
.disabled(viewStore.isTriviaRequestInFlight)
}
難しいことはしていませんが、以下のような処理を行っています。
-
isTriviaRequestInFlight
がtrue
なら- 通信中ということなので「Cancel ボタン」を表示する
-
isTriviaRequestInFlight
がfalse
なら- 通信中ではないということなので「Number Fact」ボタンを表示する
処理は簡単ですが、重要なことを行っているので軽く以下で説明します。
ViewStore を通した State の利用
コードでは viewStore.isTriviaRequestInFlight
がこれに当たります。
細かい理解をせずとも ViewStore を通じて State 内の変数を利用できるということさえ理解できれていれば TCA を用いた実装を行うことは可能ですが、仕組みについても少しだけ説明します。
TCA の ViewStore 自体の State に関わる実装は以下のようになっています(コード全体はこちら)。
@dynamicMemberLookup
public final class ViewStore<State, Action>: ObservableObject {
...
public private(set) var state: State {
willSet {
self.objectWillChange.send()
}
}
public subscript<LocalState>(dynamicMember keyPath: KeyPath<State, LocalState>) -> LocalState {
self.state[keyPath: keyPath]
}
...
}
上記のように、dynamicMemberLookup を用いることによって、 viewStore.state.isTriviaRequestInFlight
ではなく viewStore.isTriviaRequestInFlight
という形で利用できるようになっています。
SwiftUI で ViewStore を利用する時には、state
という記述を省略できて良さそう?くらいの印象ですが、後述する「UIKit から ViewStore を利用する場合」との対称性的なことを考えると、ここで dynamicMemberLookup が使われているメリットももう少し見えてくると今城さんの発表を聞いて感じることができました。(余談ですが、今城さんが開催されている iOSアプリ開発のためのFunctional Architecture情報共有会はクローズドな共有会ですが、非常に勉強になって楽しいので興味がある方は参加してみると良いかもしれないです)
ViewStore を通した Action の送信
前述したように TCA では Action を送ることにより Reducer に Action ごとの処理を委ね、State の変更を行います。
つまり、Action を送ることでしか State を変更することはできません。
Action の送信方法は簡単で、以下のようにすれば Action を送信することが可能です。
Button("Cancel") { viewStore.send(.cancelButtonTapped) }
Button("Number fact") { viewStore.send(.triviaButtonTapped) }
このように send
に Action 名を指定すれば、View 内では状態を気にせず実装を行うことができます。
④ Stepper の表示
Stepper で数字を切り替える部分に関するコードは以下になります。
Stepper(
value: viewStore.binding(
get: { $0.count }, send: EffectsCancellationAction.stepperChanged)
) {
Text("\(viewStore.count)")
}
③と利用方法が若干異なる部分は viewStore.binding
を使っている部分だけなので、そこだけ説明します。
例えば、今回が良い例だと思うのですが Stepper のように数字が変更された場合、その数字自体も取得して Stepper の value にしたいし、Stepper によって数字が変更された場合、Action を送りたいという場合に viewStore.binding
を利用します。
viewStore.binding
は上記のコードのように、get
に取得したい State を記述し、send
に送信したい Action を記述するだけになります。
⑤ トリビアテキストの表示
最後にトリビアテキストを表示する部分になります。
以下のように map
を利用して、currentTrivia
State に入っている値を Text として表示しているだけになります。
viewStore.currentTrivia.map {
Text($0).padding([.top, .bottom], 8)
}
以上が SwiftUI + TCA の解説でした。(長くなってしまったのに加え、わかりにくい所も結構ありそうです... 🙇♂️ )
次は、このアプリを UIKit+TCA で作ってみたので、その説明もおまけ程度にしてみようかなと思います。(コードは冒頭で説明したリポジトリの UIKit ディレクトリに置いています)
おまけ(UIKit で TCA を使う)
ここまでで、SwiftUI と TCA の相性の良さについては何となく理解してもらえていると嬉しいです。
もちろん、この位の複雑度の低いアプリであれば、TCA を利用せずに作ることもできますが、状態が増えれば増えるほど TCA のありがたみは大きくなりそうです。
状態が増えてきて、Reducer ごちゃごちゃになってしまいそう...みたいな時には、今回は紹介できていませんが、Reducer を分割することもできます。
さて、TCA についてある程度解説を終えた上で、おまけ程度に今回のアプリを UIKit + TCA で作ってみたので、その紹介もします。
SwiftUI と UIKit における TCA の利用方法の違い
SwiftUI と UIKit における TCA の利用方法は大きくは異なりません。
State, Action, Reducer, Environment などについては使い回すことができるため、UIKit で View を作り替えて、少しだけ調整してあげれば終わりです。
State・Action・Reducer・Environment をそのまま利用するという前提だと、主に以下の対応を行えば UIKit に置き換えることができます。
- UIKit で View を作る
- 今回は個人的に楽な方法で実装したいので Storyboard と ViewController で作ります
- ViewController で
viewStore
を定義する - ViewController の
viewDidLoad
内でviewStore.publisher
から値を取得して UI に反映する-
viewStore.publisher
は後で詳しく説明しますが、Combine の Publisher とほぼ同じくらいのイメージで OK です - RxSwift であれば ViewModel から View にデータバインディングする感覚と似ていると思います
-
- UI イベントが起こった時に
viewStore.send
で Action を発火させる - (おまけ)今回は SwiftUI 製の画面からから、この View に遷移させたいため、
UIViewControllerRepresentable
に適合させた WrapperView を作ります
それでは、一つずつ見ていきます。
UIKit で View を作る
楽に作ることを目的としたため、SwiftUI と同じ見た目の View ではないですが、機能を最低限満たす以下のようなものを UIKit で作りました。
単純な View だけに関わるコードは以下になります。(ほとんど Storyboard で作っています)
final class EffectsCancellationViewController: UIViewController {
@IBOutlet private weak var numberLabel: UILabel! // ユーザーが選択した数字を表示するラベル
@IBOutlet private weak var triviaLabel: UILabel! // API 実行が成功した時に表示するトリビアラベル
@IBOutlet private weak var apiButton: UIButton! // NumberFact, Cancel 用のボタン
@IBOutlet private weak var activityIndicator: UIActivityIndicatorView!
...
}
ViewController で viewStore
を定義する
SwiftUI の場合は View 内で Store を定義していましたが、UIKit の場合は ViewStore を定義します。
MVVM を触ったことがある方であれば、ViewModel のように扱うものだとイメージして頂くのがわかりやすそうな気がします。
ViewStore に関わるコードは以下のようになります。
final class EffectsCancellationViewController: UIViewController {
...
private let viewStore: ViewStore<EffectsCancellationState, EffectsCancellationAction>
private var cancellables: Set<AnyCancellable> = []
init(store: Store<EffectsCancellationState, EffectsCancellationAction>) {
self.viewStore = ViewStore(store)
...
}
SwiftUI とそこまで変わらないのですが、SwiftUI では Store を定義していたものの代わりに ViewStore を EffectsCancellationState
と EffectsCancellationAction
を利用する形で定義しています。
イニシャライズ時に viewStore
をセットしてあげる必要があるため、ViewController のイニシャライザで注入するようにしています。
cancellables
は Combine における Cancellable です。
viewDidLoad
内で viewStore.publisher
から値を取得して UI に反映する
SwiftUI と主に異なる部分は State を UI へ反映する方法だと思います。
とは言え、Combine さえ理解できていれば特に難しいことはないと思います。
先ほど、viewStore
を定義しましたが、viewStore
には StorePublisher<State>
型の publisher
があります。
StorePublisher<State>
は Combine における Publisher のラッパーであるため、viewStore.publisher
は基本的に Publisher と同じように扱うことができます。
そのため、UIKit の場合は以下のコードのように viewStore.publisher
を通じて State を取得し、State に変更があれば UI にも反映されるようにします。
override func viewDidLoad() {
super.viewDidLoad()
// activityIndicator はずっとアニメーションさせておく
activityIndicator.startAnimating()
viewStore.publisher
.map { "\($0.count)" }
.assign(to: \.text, on: numberLabel)
.store(in: &cancellables)
viewStore.publisher.currentTrivia // 加工する必要がなければ直接 assign できる
.assign(to: \.text, on: triviaLabel)
.store(in: &cancellables)
// (Combine 勉強中なので、UIButton に assign する方法がわかりませんでした 😇 )
viewStore.publisher.sink { [weak self] state in
let buttonTitle = state.isTriviaRequestInFlight ? "Cancel" : "NumberFact"
self?.apiButton.setTitle(buttonTitle, for: .normal)
}.store(in: &cancellables)
viewStore.publisher
.map { $0.isTriviaRequestInFlight ? false : true }
.assign(to: \.isHidden, on: activityIndicator)
.store(in: &cancellables)
}
UI イベントが起こった時に viewStore.send
で Action を発火させる
UI イベントが起こった時に viewStore.send
で Action を発火させるのは SwiftUI の場合とほとんど変わりません。
具体的に発火させているのは以下のコードになります。
@IBAction private func tapStepper(stepper: UIStepper) {
viewStore.send(.stepperChanged(Int(stepper.value)))
}
@IBAction private func tapAPIButton(_ sender: Any) {
if viewStore.isTriviaRequestInFlight {
viewStore.send(.cancelButtonTapped)
} else {
viewStore.send(.triviaButtonTapped)
}
}
今回の場合、Stepper で値が変更された時と、API ボタンがタップされた時に Action を発火させたいため、上記のようなコードになります。
(おまけ) UIViewControllerRepresentable
に適合させた WrapperView を作る
最後におまけですが、今回は SwiftUI 製の ListView からこの UIKit 製の View に遷移させたいため、UIViewControllerRepresentable
に適合させた WrapperView を作っておきます。
struct EffectsCancellationViewControllerWrapper: UIViewControllerRepresentable {
let store: Store<EffectsCancellationState, EffectsCancellationAction>
init(store: Store<EffectsCancellationState, EffectsCancellationAction>) {
self.store = store
}
typealias UIViewControllerType = EffectsCancellationViewController
func makeUIViewController(context: Context) -> UIViewControllerType {
return EffectsCancellationViewController(store: store)
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}
上記のように定義しておけば、SwiftUI 製の ListView からは以下のように UIKit 製の View に遷移させることができます。
NavigationLink(
"UIKitView",
destination: EffectsCancellationViewControllerWrapper(
store: Store(
initialState: EffectsCancellationState(),
reducer: effectsCancellationReducer,
environment: EffectsCancellationEnvironment(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
numbersClient: NumbersAPIClient.live
)
)
)
)
以上で UIKit + TCA についての説明は終了になります🙏
おわりに
非常に長くなってしまいましたが、今回は実際に API と通信するアプリを題材に 「SwiftUI + TCA」と「UIKit + TCA」について説明してみました。
TCA について興味がある方にとってこの記事が参考になれば嬉しいです。
また、今回は「SwiftUI + TCA」でできたものを「UIKit + TCA」に移行するという流れでの紹介になってしまいましたが、この逆も簡単にできそうであることは想像しやすいと思います。
まだ iOS12 以下を切ることができないプロダクトが多いとは思いますが、徐々に移行していく際は
- UIKit 製の View の状態管理を TCA で行うようにする
- その状態の View を UIKit から SwiftUI に置き換える
という流れも個人的にはありかもと感じているため、選択肢を増やす意味でも TCA を理解しておくメリットはあると思います。
自分が今関わっているプロダクトではアーキテクチャとして VIPER を採用しているため、VIPER の状態管理を SwiftUI に移行していく方法も TCA を頭に入れながら探っていきたいと個人的には思っています。
改めて長い文章となってしまいましたが、読んでいただきありがとうございました!(間違っているところがあればぜひ教えていただけますと幸いです🙏 )