6
3

More than 5 years have passed since last update.

ReactiveSwiftを克服する: Action (Part 6)

Posted at

この記事について

この記事は、Conquering ReactiveSwift: Action (Part 6)
の翻訳です。

以下、本文です。

ReactiveSwiftを克服する: Action (Part 6)

ReactiveSwiftを克服するシリーズのパート6へようこそ。前回の記事では、PropertyとMutablePropertyについて学びました。今回は、Sourceカテゴリに属する基本要素のActionについて議論していきます。

定義

Actionは、繰り返し可能で、遅延実行することのできるタスクを表します。SignalProducerと似ていますが、Actionには次のようなより高次な特徴があります。

  1. シリアル実行を強制することができる
  2. 異なる入力を与えることができる
  3. 条件付きで実行することができる
  4. タスクが進行中かチェックすることができる

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つの操作を互いに排他的にしましょう。つまり、signalProducer2signalProducer1が完了していない限り開始しないようにしましょう。このふるまいを実装するには、Actionを使う必要があります。

Actionは、さまざまな入力で実行される繰り返し可能なタスクとして定義されます。

Actionのコアはexecuteというクロージャにあります。このクロージャは、繰り返し可能なタスクをSignalProducerの形でカプセル化します。Actionがapply()から呼び出されると、executeというクロージャが実行されて、SignalProducerが生成されます。そして、生成されたSignalProducerのstartを呼びます。いったん実行されると、SignalProducerは0個かそれ以上の値を送り、そのうち終了します。処理が進行中の間はActionは動作しないようになっています(つまり、進行中の処理が一旦終了しない限り次の処理は開始できません)。下の図を参照してください:

howActionWorks.png
訳注: 画像は翻訳元からの引用です

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()
}
  1. 最初のapplyが実行されると、1秒後にログの出力が始まります。
  2. 次のapplyは、最初のapplyがまだ実行中なので無視されます。
  3. 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()
}

ここまでをまとめると:

  1. SignalProducerを返すクロージャを定義する
  2. クロージャでActionを生成する
  3. Actionを監視する
  4. applystartで開始!

ここまでの例として使ってきた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でラップすることで、それぞれのネットワーク呼び出しを排他的にしたり、呼び出し可/不可に条件を加えることも可能です。さらに、isExecutingisEnabledといったプロパティを監視することで、ロード中の表示を管理することができます。

これでActionについては以上です。サンプルコードはこちらにあります。

シリーズ内リンク集

  1. ReactiveSwiftを克服する: 導入 (Part 1)
  2. ReactiveSwiftを克服する: 基本要素 (Part 2)
  3. ReactiveSwiftを克服する: SignalとObserver (Part 3)
  4. ReactiveSwiftを克服する: SignalProducer (Part 4)
  5. ReactiveSwiftを克服する: Property (Part 5)
  6. この記事です
6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3