SwiftのCombineを、RxSwiftとの違いを理解しながら導入する

こんにちは、株式会社スタメンでTUNAGのiOSアプリエンジニアをしている青木 (@38Punkd)です。

何気に今回の記事がこの Tech Blog への初投稿で、ワクワクしています。

TUNAGのiOSアプリは、これまでリアクティブプログラミングの手法として、RxSwiftを導入してきました。 そして今年度から、アプリがサポートするOSバージョンの下限を13.0に引き上げたため、Apple公式の非同期フレームワークCombineが使えるようになりました。

アプリに対してサードパーティ製のライブラリであるRxSwiftへの依存度を下げたかったことと、純粋に新しい技術を試してみたいという好奇心も相まって、アプリにCombineを導入することを試みました。

実際にCombineを導入してみた感想と、導入する際の注意点をお伝えできれば思います。

SingleからFutureへの移行

通信は大別すると、結果の受け取りを継続して監視する必要のあるものと、結果の受け取りが一回きりで良いものの2種類がありますが、 今回は、Combineの中でも一回きりの非同期処理を監視するFuture型を使って、一回きりのAPI通信の結果を取得する実装をしました。

これまでは、一回きりのAPI通信部分は、RxSwiftのSingleを用いて以下のようなコードを書いていました。

結果を発行する側

Singleを用いて、非同期処理の結果を通知できるオブジェクトを返すメソッドを用意します。

// データレイヤー(APIと通信をする)
class Repository {
    func fetchTaskList() -> Single<[TaskEntity]> {
        Single<[TaskEntity]>.create(subscribe: { observer in
            APIClient.request { response in
                observer(.success(`TaskEntity型`)))
            }
            return Disposables.create()
        })
    }
}

結果を受け取る側

上記のSingleを、subscribeメソッドを用いて購読し、結果を非同期に受け取れるようにします。

// ドメインレイヤー(ビジネスロジックを扱う)
class Interactor {
    func requestTaskList() {
        Repository().fetchTaskList()
            .subscribe(
              onSuccess: { taskList in
                // API通信の結果取得した taskList を出力します
            } onFailure: { error in
                // errorを出力します
            })
            .disposed(by: disposeBag)
    }

    let disposeBag = DisposeBag()
}

これを、CombineのFuture型を用いて以下のように書きました。

結果を発行する側

Futureを用いて、非同期処理の結果を通知できるオブジェクトを返すメソッドを用意します。

// データレイヤー(APIと通信をする)
class Repository {
    func fetchTaskList() -> Future<[TaskEntity], Error> {
                Future<[TaskEntity], Error> { [unowned self] promise in
                    APIClient.request
                    .sink { completion in
                        switch completion in
                        case .failure(let error):
                            promise(.failure(error))
                        case .finished:
                            ()
                    } receiveValue: { response in
                    promise(.success(`TaskEntity型`))
                    }
                  .store(in: &cancellables)
         }
    }
    
    var cancellables = Set<AnyCancellables>()
}

結果を受け取る側

上記のFutureを、sinkメソッドを用いて購読し、結果を非同期に受け取れるようにします。

// ドメインレイヤー(ビジネスロジックを扱う)
class Interactor {
    func requestTaskList() {
        Repository().fetchTaskList()
            .sink { completion in
                switch completion in
                case .failure(let error):
                    // errorを出力します
                case .finished:
                    ()
            } receiveValue: { taskList in
                // API通信の結果取得した taskList を出力します
            }
            .store(in: &cancellables)
    }

    var cancellables = Set<AnyCancellables>()
}

Futureは、非同期の結果や値を表すための型であり、RxSwiftでいうSingleと同じ役割を担います。

sinkメソッドは、監視可能なイベントを実際に監視するためのメソッドです。 イベントの監視状況は、sinkメソッドのトレイリングクロージャに入ってくる値(上記のコードではcompletionという変数名で表しています)から確認できます。 この変数は、failurefinishedの二つのケースを持つenum型です。

監視中にイベントが発生(=この場合、タスク一覧の取得が完了する)すると、クロージャ型の引数receiveValueに値が入ってきます。

最後にstoreメソッドによって、sinkメソッドのAnyCancellable型の戻り値をcancellablesプロパティに保存します。 こうする事で、Future型をインスタンス生成した後も、イベントを監視できる仕組みが出来上がりました。

Combineを使う上で気を付けるべきポイントは2つありました。

  • sinkメソッドの戻り値AnyCancellableは保持する必要がある
  • Futureはインスタンス生成直後に監視を開始する

これらのポイントについて、詳細に解説していきます。

sinkメソッドの戻り値AnyCancellableは保持する必要がある

sinkメソッドはAnyCancellableを返却値として返します。

func sink(receiveValue: @escaping ((Self.Output) -> [Void](https://developer.apple.com/documentation/Swift/Void))) -> [AnyCancellable](https://developer.apple.com/documentation/combine/anycancellable)

AnyCancellableは、非同期処理の中断を表すために使用される型です。

非同期処理が完了した後も監視を続ける場合、AnyCancellableのインスタンスを保持し、必要なときに中断することができます。 しかし、AnyCancellable型の戻り値をプロパティとして保存せずに捨ててしまうと、そのインスタンスは参照されず、すぐに解放されてしまいます。 その結果、監視が中断されます。

AnyCancellableの非同期を中断する方法は以下の2パターンがあります。

  • AnyCancellableのインスタンスが破棄される。
  • AnyCancellableに対して、cancelメソッドを呼び出す。

AnyCancellableのインスタンスが破棄された場合に、AnyCancellableの非同期が中断されるのは、破棄されたタイミングでAnyCancellable自身がcancelメソッドを呼び出す仕様になっているからです。

なので、AnyCancellableインスタンスに対してstoreメソッドを実行して、プロパティとして保存しない場合、AnyCancellableインスタンスは解放され、監視が終了してしまいます。

class Interactor {
    // ×
    // requestTaskListメソッドのスコープを抜けると、
    // sinkメソッドの戻り値はすぐに解放されてしまう、良くない例です。
    func requestTaskList() {
        _ = Repository().fetchTaskList().sink { ... }
    }
}

(おまけ)

storeメソッドを呼ばない場合は、以下のようにも書けます。

class Interactor {
    func requestTaskList() {
        cancellable = Repository().fetchTaskList().sink { ... }
    }
    var cancellable: AnyCancellables?
}

なおこのAnyCancellables型のプロパティは、保持しているクラスが破棄されると、もちろん同時に破棄されますが、同時に監視も終了します。 なので、明示的に監視の中断処理を、デイニシャライザの中に書く必要はありません。

class Repository {
    // Repositoryのインスタンスが破棄されたら、cancellableも破棄されるので、
    // このdeinitメソッドは不要です。
    deinit {
        cancellable.cancel()
    }
    ...
    var cancellable: AnyCancellables?
}

Futureはインスタンス生成直後に監視を開始する

RxSwiftのSingleは、subscribeメソッドを実行するまで、監視を開始しません。この性質は Cold Observable と呼ばれます。 しかし、Futureはインスタンス生成された直後に監視を開始します。この性質は Hot Observable と呼ばれます。

Singleと違い、FutureはHotであることを意識しないと、以下のように意図しないタイミングで処理が走ってしまいます。

class Repository {
    let taskList: Future<[TaskEntity], Error> = Future<[TaskEntity], Error> { in
        cancellable = APIClient.request.sink { ... }
    }
    var cancellable: AnyCancellables?
}

class Interactor { ... }

上記のコードでは、Repositoryがインスタンス生成された瞬間に、taskListを取得するAPI通信が走ってしまいます。 Future型を使う際は、意図しないメモリ消費を避けるために、ストアドプロパティとして定義するのではなく、メソッドの戻り値や計算プロパティとしてあげるのが良さそうです。

// ⚪︎
// fetchTaskListメソッドを呼び出して、初めてFuture内部が実行されます。
func fetchTaskList() -> Future<[TaskEntity], Error> { ... }

// ⚪︎
// fetchTaskListプロパティを参照して初めて、初めてFuture内部が実行されます。
var fetchTaskList: Future<[TaskEntity], Error> { ... }

// △
// fetchTaskListを保持するクラスがインスタンス生成された瞬間に、Future内部が実行されてしまいます。
// 意図しない挙動につながる怖い実装です。
var fetchTaskList = Future<[TaskEntity], Error> { ... }

Combineに触れてみて

実際にCombineの機能の一部を導入してみた結果、書き方はRxSwiftと非常に似ていて、かつ処理の流れがより一層直感的なコードになったと感じました。 プロダクト保守性の観点からも、アプリの採用技術をApple公式のフレームワークに徐々に寄せていきたいと思っていますし、これを機に、Combineの導入をより推進できればと思いました。

弊社では、このようにして新技術を積極的に導入検討し、より良いプロダクトを追い求めて開発をしています。 人と組織を強くする HR Tech SaaSプロダクトを作りながら、技術でワクワクしたいソフトウェアエンジニアを、全技術領域で募集しています。

お得意の技術領域を問わず、ぜひカジュアルにお話ししましょう!

herp.careers