この記事について
この記事は、Conquering ReactiveSwift: Action (Part 6)
の翻訳です。
以下、本文です。
ReactiveSwiftを克服する: Action (Part 6)
ReactiveSwiftを克服するシリーズのパート6へようこそ。前回の記事では、PropertyとMutablePropertyについて学びました。今回は、Sourceカテゴリに属する基本要素のActionについて議論していきます。
定義
Actionは、繰り返し可能で、遅延実行することのできるタスクを表します。SignalProducerと似ていますが、Actionには次のようなより高次な特徴があります。
- シリアル実行を強制することができる
- 異なる入力を与えることができる
- 条件付きで実行することができる
- タスクが進行中かチェックすることができる
Actionの性質と使い方をより理解するために、次の課題を考えてみましょう:
(N * 10) 秒の間、N秒ごとに経過時間を出力しなさい。
SignalProducerについて学んだこととして、Signalの開始を遅らせるにはそのタスクをSignalProducerでラップする必要がありました。そのときは、50秒の間、5行ごとにIntの値を排出するSignalProducerを定義しました。ですが、SignalProducerのstart時に間隔を指定したければどうしたらよいでしょうか?
ひとつ思いつく方法として、時間間隔を入力するとSignalProducerを返すクロージャを定義することです:
// 与えられた時間ごとにIntを排出するSignalProducerを返す
let signalProducerGenerator: (Int) -> SignalProducer<Int, NoError> = { timeInterval in
return SignalProducer<Int, NoError> { (observer, lifetime) in
let now = DispatchTime.now()
for index in 0..<10 {
let timeElapsed = index * timeInterval
DispatchQueue.main.asyncAfter(deadline: now + Double(timeElapsed)) {
guard !lifetime.hasEnded else {
observer.sendInterrupted()
return
}
observer.send(value: timeElapsed)
if index == 9 {
observer.sendCompleted()
}
}
}
}
}
クロージャはこんな感じで使います:
let signalProducer1 = signalProducerGenerator(1)
let signalProducer2 = signalProducerGenerator(2)
signalProducer1.startWithValues { value in
print("value from signalProducer1 = \(value)")
}
signalProducer2.startWithValues { value in
print("value from signalProducer2 = \(value)")
}
結構簡単でしょう?
では、これらの2つの操作を互いに排他的にしましょう。つまり、signalProducer2
はsignalProducer1
が完了していない限り開始しないようにしましょう。このふるまいを実装するには、Actionを使う必要があります。
Actionは、さまざまな入力で実行される繰り返し可能なタスクとして定義されます。
Actionのコアはexecute
というクロージャにあります。このクロージャは、繰り返し可能なタスクをSignalProducerの形でカプセル化します。Actionがapply()
から呼び出されると、execute
というクロージャが実行されて、SignalProducerが生成されます。そして、生成されたSignalProducerのstart
を呼びます。いったん実行されると、SignalProducerは0個かそれ以上の値を送り、そのうち終了します。処理が進行中の間はActionは動作しないようになっています(つまり、進行中の処理が一旦終了しない限り次の処理は開始できません)。下の図を参照してください:
Actionには次のような便利なプロパティがあります:
- values: Actionの実行により生成された値を送るSignalです
- error: Actionの実行により生成されたエラーを送るSignalです
- isExecuting:
Property<Bool>
型のプロパティで、Actionが現在実行中か否かを表します - isEnabled:
Property<Bool>
型のプロパティで、Actionが現在実行可能か否かを表します
Actionはジェネリックなクラスで、型パラメータを3つとります。クラス定義はこうなっています:
public final class Action<Input, Output, Error: Swift.Error>
- Input:
apply()
で外部から与えられるインプットの型を表します。 - Output: ActionのSignalが排出する値を表します。
- Error: Signalが送るエラーの型を表します。
この記事で使う例では、InputはInt
, OutputもInt
, ErrorはNoError
になります。
これでActionを定義する準備ができました。Actionは典型的には以下のようなクロージャで初期化します:
(Input) -> SignalProducer<Output, Error>
したがって、私たちが必要としているのは、(Input) -> SignalProducer<Int, NoError>
という型のクロージャです。ここでは、先ほど定義したクロージャを再利用しましょう。Actionの定義方法はこうなります:
let action = Action<Int, Int, NoError>(execute: signalProducerGenerator)
次に、このActionからのSignalを監視しましょう。value
というプロパティからアクセスできます:
// 受け取る値を監視する
action.values.observeValues { value in
print("Time elapsed = \(value)")
}
// 完了を監視する
action.values.observeCompleted {
print("Action completed")
}
次はActionに対して様々なインプットでapply
を呼びます:
// 1. インプットをapplyしてstart
action.apply(1).start()
// 2. Actionが処理実行中なので無視される
action.apply(2).start()
DispatchQueue.main.asyncAfter(deadline: .now() + 12.0) {
// 3. `action.apply(1)`が完了した後なので実行される
action.apply(3).start()
}
- 最初の
apply
が実行されると、1秒後にログの出力が始まります。 - 次の
apply
は、最初のapply
がまだ実行中なので無視されます。 - 3番目の
apply
は、12秒後に実行されます。なぜなら、12秒後には最初のapply
の実行が完了しているからです。ここでは、3秒ごとにログが出力されます。
コード全体はこうなっています:
// クロージャでActionを定義する
let action = Action<Int, Int, NoError>(execute: signalProducerGenerator)
// 受け取った値を監視する
action.values.observeValues { value in
print("Time elapsed = \(value)")
}
// Actionの完了を監視する
action.completed.observeValues {
print("Action completed")
}
// インプットをapplyしてstart
action.apply(1).start()
action.apply(2).start() // Actionが実行中なので無視される
DispatchQueue.main.asyncAfter(deadline: .now() + 12.0) {
// `action.apply(1)`が完了したので実行される
action.apply(3).start()
}
ここまでをまとめると:
- SignalProducerを返すクロージャを定義する
- クロージャでActionを生成する
- Actionを監視する
-
apply
とstart
で開始!
ここまでの例として使ってきたActionは、apply
メソッドより与えられる外部からのインプットにのみ依存しています。ですが、Actionは外部からの入力だけでなく、Propertyによる内部状態にも依存するように設計することも可能です。この場合、クロージャのexecute
は、Actionの内部状態(Propertyで表現されます)と外部からの入力との両方にアクセスすることができます。
このことを理解するために、次の例を考えてみます:
ブログアプリを開発しているとしましょう。このアプリでは、title
の文字数が10文字以上ないといけません。私たちは、最小文字列長さを設定して、現在のテキストの長さをチェックしてくれるvalidator
を実装する必要があります。
ステップごとに進んでいきましょう:
1. クロージャを定義する
この例では次のような型のクロージャを書きます:
(State.Value, Input) -> SignalProducer<Output, Error>
一つ目の引数は現在の状態を、二つ目の引数はapply
メソッドから与えられる外部からの入力を表します。この例でいうと、一つ目の引数はユーザーが入力するテキストで、二つ目の引数は最小の文字列長さにあたります。
このクロージャを実際に定義しましょう。返り値は、Bool型の値を排出するSignalProducerで、文字列長さが十分か否かを表します。
func lengthCheckerSignalProducer(text: String, minimumLength: Int) -> SignalProducer<Bool, NoError> {
return SignalProducer<Bool, NoError> { (observer, _) in
observer.send(value: (text.count > minimumLength))
observer.sendCompleted()
}
}
2. Propertyを定義する
今回モデル化するべきなのはユーザーの入力なので、String型のPropertyを定義します。Propertyは監視可能なボックスで、保持している値が変わるたびに値を排出します。値がSignalやSignalProducerによって決まるようなPropertyも定義可能です。もしReactiveCocoaを使っていれば、ユーザーからの入力を表すSignalのtextField.reactive.continuousTextValues
を使えます。ただ、今回はReactiveCocoaは使っていないので、1秒ごとに1文字を排出するSignalをテキスト入力と見立てることにしましょう。下記のtextSignalGenerator
は、Stringを受け取り、1秒ごとに1文字を排出するSignalを返します。
func textSignalGenerator(text: String) -> Signal<String, NoError> {
return Signal<String, NoError> { (observer, _) in
let now = DispatchTime.now()
for index in 0..<text.count {
DispatchQueue.main.asyncAfter(deadline: now + 1.0 * Double(index)) {
let indexStartOfText = text.startIndex
let indexEndOfText = text.index(startIndex, offsetBy: index)
let substring = text[indexStartOfText...indexEndOfText]
let value = String(substring)
observer.send(value: value)
}
}
}
}
このSignalからPropertyを定義します:
let title = "ReactiveSwift"
let titleSignal = textSignalGenerator(text: title)
let titleProperty = Property(initial: "", then: titleSignal)
3. Actionを定義する
Propertyとクロージャの用意ができました。バリデーションを行ってくれるActionを定義しましょう:
let titleLengthChecker = Action<Int, Bool, NoError>(
state: titleProperty,
execute: lengthCheckerSignalProducer
)
4. Observerを定義する
先ほど議論した通り、Actionのvalues
というプロパティから内部にあるSignalがアクセスできます。このvalues
を通してActionの処理結果を監視することができます。titleLengthChecker
から返ってくる値を監視してログ出力しましょう:
titleLengthChecker.values.observeValues { isValid in
print("is title valid: \(isValid)")
}
5. Actionにapply
する
Actionを開始させます。下のコードでは、title
の長さの分だけ1秒ごとにstart
を呼んでいます。それぞれ、titleLengthChecker
に対して10をapplyしてから、start
を呼びます。
for i in 0..<title.count {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double(i)) {
titleLengthChecker.apply(10).start()
}
}
たとえば、subtitle
のような最小文字列長さがことなるようなものがあれば、lengthCheckerSignalProducer
を再利用してことなるActionを定義することもできます。
もう一つ、Actionの重要な特徴は条件付きで動作可能にすることができることです。これを使って、先ほどの例を少し拡張してみましょう。たとえば、文字列の長さが5以上の場合のみバリデーション処理を走らせたければこう書けます:
let titleLengthChecker = Action<Int, Bool, NoError>(
state: titleProperty,
enabledIf: { $0.count > 5 },
execute: lengthCheckerSignalProducer
)
まとめ
実際にActionを使うときに便利なのが、ネットワーク通信処理の実装です。通信処理をActionでラップすることで、それぞれのネットワーク呼び出しを排他的にしたり、呼び出し可/不可に条件を加えることも可能です。さらに、isExecuting
やisEnabled
といったプロパティを監視することで、ロード中の表示を管理することができます。
これでActionについては以上です。サンプルコードはこちらにあります。