iOSエンジニアリング

iOSでRxSwift・Combineを使う理由とは? リアクティブプログラミングが解決する問題

iOS開発を学んでいると、ある時点で必ずRxSwiftやCombineという壁に出会います。求人票にはほぼ必ず登場しますが、実際にコードを開くと、map、flatMap、sinkが延々と続く見慣れない構文に戸惑いがちです。「クロージャーやデリゲートで普通に動くのに、わざわざこれを学ぶ必要があるの?」…

読了 7 分
iOSでRxSwift・Combineを使う理由とは? リアクティブプログラミングが解決する問題のカバー画像

iOS開発を学んでいると、ある時点で必ずRxSwiftやCombineという壁に出会います。求人票にはほぼ必ず登場しますが、実際にコードを開くと、mapflatMapsinkが延々と続く見慣れない構文に戸惑いがちです。「クロージャーやデリゲートで普通に動くのに、わざわざこれを学ぶ必要があるの?」と疑問に思うのは自然です。

今日は構文より前に、なぜこのようなツールが必要になったのかを整理します。理由がわかれば、構文は自然についてきます。


iOSアプリの正体は「イベント処理マシン」です

アプリの仕事を分解すると、その大半はイベントへの反応です。

  • ユーザーがボタンをタップする → 画面を切り替える
  • ネットワーク応答が届く → リストを更新する
  • キーボードが表示される → 入力欄を上に移動する
  • テキストフィールドの値が変わる → 検索結果を再取得する

問題は、UIKitがこれらのイベントをそれぞれ異なる方法で渡すことです。

イベントソース 通知方法
ボタンタップ target-action
テーブルビューのスクロール delegate
ネットワーク応答 completion handler(クロージャー)
キーボードの表示 NotificationCenter
オブジェクトのプロパティ変更 KVO(Key-Value Observing)

iOSでは、1つの画面でこの5種類が同時に使われるのはごく普通です。「イベントに反応する」という同じ処理なのに、コードの形は5つに分散しています。そのため、1画面の動作を把握するには、ビューコントローラーのあちこちを探し回らなければなりません。

RxSwiftとCombineが解決しようとする最初の問題は、まさにこれです。すべてのイベントを「時間とともに流れてくる値のストリーム」という1つの形に統一します。ボタンタップも、ネットワーク応答も、キーボード通知も、すべて同じインターフェースで扱えます。


コールバックで耐えているとぶつかる2つの壁

壁1:非同期処理の組み合わせ

「プロフィール画面用にユーザー情報と最近の投稿一覧を同時に取得し、両方が届いたら画面を描画する」というよくある要件をcompletion handlerで書くと、次のようになります。

var user: User?
var posts: [Post]?

func loadProfile() {
    let group = DispatchGroup()
    group.enter()
    api.fetchUser { result in
        user = try? result.get()
        group.leave()
    }
    group.enter()
    api.fetchPosts { result in
        posts = try? result.get()
        group.leave()
    }
    group.notify(queue: .main) {
        guard let user, let posts else { /* エラー処理はどこに書けばいいのでしょう? */ return }
        render(user, posts)
    }
}

DispatchGroupや中間保存用の変数、分散したエラー処理まで必要になります。リクエストが3つ、4つと増え、「Aが終わったら、その結果でBをリクエストする」という依存関係まで混ざると、ネストは収拾がつかないほど深くなります。いわゆるコールバック地獄です。

Combineでは、同じ要件を次のように表現できます。

api.fetchUser()
    .zip(api.fetchPosts())
    .receive(on: DispatchQueue.main)
    .sink(receiveCompletion: { completion in
        if case .failure(let error) = completion { showError(error) }
    }, receiveValue: { user, posts in
        render(user, posts)
    })
    .store(in: &cancellables)

「2つをzipでまとめ、メインスレッドで受け取り、成功時と失敗時に通知する」。コードが要件をほぼそのまま表しています。エラー処理も1か所に集約できます。

壁2:連続するイベントの制御

検索欄を考えてみましょう。文字を入力するたびにAPIを呼ぶと、「スイフト」という6文字で6回リクエストが送られます。そのため、通常は次のような条件を加えます。

  • 入力が止まって0.3秒経過してからのみリクエストする(debounce)
  • 直前の検索語と同じならリクエストしない(重複排除)
  • 新しいリクエストが始まったら、まだ終わっていない前のリクエストをキャンセルする

これをTimerとフラグ変数で直接実装すると、タイマーの無効化とリクエストのキャンセルのタイミングが絡み合い、バグが入りやすいコードになります。Combineなら、検証済みのオペレーターをつなぐだけで済みます。

searchTextSubject
    .debounce(for: .seconds(0.3), scheduler: DispatchQueue.main)
    .removeDuplicates()
    .map { api.search(query: $0) }
    .switchToLatest()   // 新しいリクエストが来たら前のリクエストを自動的にキャンセル
    .sink { results in render(results) }
    .store(in: &cancellables)

要点はここです。時間が関わるイベント制御ロジックを自分で実装せず、検証済みの部品を組み合わせて解決する。リアクティブプログラミングの価値が最もはっきり現れる部分です。

時間制御ロジックを自作せず、検証済みのオペレーターを組み合わせる
時間制御ロジックを自作せず、検証済みのオペレーターを組み合わせる

MVVMバインディングの標準部品

前回の記事で扱ったように、MVVM(Model-View-ViewModel)は「ViewModelが変わるとViewも自動的に追従して変わる」というバインディングがあって初めて完成します。しかしUIKitには組み込みのバインディングがありません。この空白を埋めてきたのがRxSwift(RxCocoa)で、iOS 13以降はCombineがその役割を引き継ぎました。

viewModel.$isLoading
    .sink { [weak self] in self?.spinner.isAnimating = $0 }
    .store(in: &cancellables)

実務でRxSwiftやCombineを求める求人が多い実質的な理由はここにあります。MVVMベースのコードベースでは、バインディング層が実質的にこの2つのフレームワークのどちらかで作られているからです。


RxSwiftとCombineは何が違うのでしょうか?

概念的には、どちらも同じリアクティブプログラミングのツールです。ObservableがPublisherに、subscribesinkに名前を変えただけと言っても差し支えありません。片方を学べば、用語対応表だけで他方へ移れます。それでも選択基準は明確です。

  • RxSwift:サードパーティーライブラリ。iOSバージョンの制約がなく、RxCocoaによるUIKitバインディングが豊富で、蓄積された資料と強いコミュニティが強みです。一方で外部依存が生じ、ビルド時間が長くなります。
  • Combine:Appleのファーストパーティーフレームワーク。iOS 13以降なら依存関係を追加せずに使え、SwiftUIの@PublishedObservableObjectに自然に連携します。ただしUIKitのバインディング対応はRxCocoaより薄めです。

新規プロジェクトならCombineが無難なデフォルトで、RxSwiftは既存のコードベースで使われているチームに加わるときに学ぶケースが多いでしょう。

RxSwiftとCombineは同じ仕事をする2つのツールボックス。選択基準はチームの状況です
RxSwiftとCombineは同じ仕事をする2つのツールボックス。選択基準はチームの状況です

async/awaitが登場した今でも必要なのでしょうか?

Swift 5.5のasync/await以降、「もうCombineは学ばなくてもいいのでは?」という質問をよく聞きます。半分はその通りです。「リクエスト1回、レスポンス1回」で終わる単発の非同期処理なら、async/awaitのほうがはるかに読みやすくなります。上のプロフィール例もasync let2行で済みます。

しかし、先ほどの検索欄のように値が流れ続けるストリームは別の話です。テキスト入力、位置情報の更新、WebSocketメッセージ、ViewModelの状態変化のように終わらず続くイベントへ、debounceやcombineLatestなどの時間ベースの制御をかける必要があるなら、今もリアクティブツールの領域です。AppleもAsyncSequenceでこの領域を広げていますが、オペレーターのエコシステムはまだCombineのほうが充実しています。

まとめると、次のようになります。

  • 単発の非同期処理(ネットワークリクエストなど) → async/await
  • 継続するイベントストリーム+時間ベースの制御、UIバインディング → Combine(またはRxSwift)

両者は競合関係というより、担当領域が異なるツールです。


まとめ

RxSwiftとCombineを使う理由を一文でまとめると、こうなります。ばらばらな非同期イベントを1つのストリームインターフェースに統一し、その上で組み合わせ・時間制御・バインディングを宣言的に処理するためです。

  • デリゲート・クロージャー・通知に分散したイベント処理を1つの方式に統一します
  • 複数の非同期処理の組み合わせを、ネストしたコールバックなしで表現します
  • debounce、重複排除、リクエストのキャンセルといった時間制御を、検証済みのオペレーターで解決します
  • MVVMバインディングの事実上の標準部品です

構文が見慣れないため敷居が高く見えますが、「イベントを値のストリームとして捉える」という視点をつかめば、オペレーターは配列のmapfilterを時間軸へ拡張したものにすぎません。次回は、CombineのPublisherとSubscriberが実際にどのように連携して動くのか、内部動作を詳しく見ていきます。

あわせて読みたい