💻

2025年にRxSwiftと向き合った設計

に公開

はじめに

かつてiOSアプリ開発ではUIKitとRxSwift/RxCocoaを利用したリアクティブプログラミングとそれを元にした設計手法であるMVVMが一世を風靡しました。
時代は流れ、SwiftUIが主流となり、さらにSwift言語そのものにリアクティブプログラミングな機能も追加されていき、RxSwiftはその活躍の場が限られるようになりました。

しかし、現場ではRxSwiftを使った既存のプロジェクトが多く存在します。
そのようなプロジェクトに若手のメンバーが入ると学習障壁の高さと情報の少なさと古さに手を焼いてしまうという話を聞くようになりました。

すでに大多数のベテランエンジニアの興味はRxSwiftから失せており、RxSwiftを使った既存のプロジェクトはどうしていくべきなのか議論されているものは見かけません。
そのため、本記事では2025年におけるRxSwiftを使った設計の向き合い方について解説します。

※ 以降はRxSwift/RxCocoaを区別せずにRxSwiftと表記します。

BehaviorRelayとPublishRelayの基本

RxSwiftが提供するBehaviorRelayPublishRelayは、UIイベントを扱う上で基本となる要素です。どちらも.accept()で新しい値を受け取り、.subscribe()で値を次に流すことができます。

両者の最大の違いは 「値を保持するか、しないか」 です。

両者共、BehaviorSubjectPublishSubjectと違いonErroronCompletedで終了しないという共通点がありますが今回は省略します。

値の保持と読み込み

BehaviorRelayは値を保持し続けるため、初期値を持たせる必要があります。
PublishRelayは値を保持しないため初期値は不要です。

初期化
let behaviorRelay = BehaviorRelay<String>(value: "初期値")
let publishRelay = PublishRelay<String>()

BehaviorRelayは値を保持しているためvalueでいつでも読み込めます。

値の読み込み
viewModel.process(behaviorRelay.value)

// !! publishRelayはvalueを持たないのでエラーとなる !!
viewModel.process(publishRelay.value)

リアクティブな入力と出力

BehaviorRelayPublishRelayは共に値を受け取り、次の処理に値を渡すことができます。
これらの処理は一度明記すると新しい値がきたら自動(リアクティブ)で反映されていきます。

入力はこちらです。

入力
// 明示的に値を受け取る
behaviorRelay.accept(1)
publishRelay.accept(1)

// 他のRelayやObservableなどから流れてきた値を受け取る
viewModel.userNameRelay.bind(to: behaviorRelay).disposed(by: disposeBag)
viewModel.userNameRelay.bind(to: publishRelay).disposed(by: disposeBag)

出力はこちらです。

出力
// behaviorRelayに値が流れてきたらonNextのクロージャが受け取る
behaviorRelay.subscribe(onNext: { 
  print("流れてきた値: \($0)") 
})
.disposed(by: disposeBag)

// publishRelayに値が流れてきたらonNextのクロージャが受け取る
publishRelay.subscribe(onNext: { 
  print("流れてきた値: \($0)") 
})
.disposed(by: disposeBag)

最もシンプルなViewModel

このBehaviorRelayPublishRelayを使って最もシンプルなViewModelを書くとこのようになります。

MVVMのViewModel
final class RxSwiftViewModel {

    private let disposeBag: DisposeBag = .init()

    // --- Input ---
    let fetchRelay = PublishRelay<Int>()

    // --- Output ---
    let userRelay: BehaviorRelay<User?> = .init(value: nil)

    init() {
        fetchRelay
            // 1. イベントが来たら、まず1秒待つ(通信時間を表現)
            .delay(.seconds(1), scheduler: MainScheduler.instance)
            // 2. 待った後、IDをUserオブジェクトに変換する
            .map { userId -> User? in
                return User(id: userId)
            }
            .bind(to: self.userRelay)
            .disposed(by: disposeBag)
    }
}

それを利用するViewController側はこのようになります。

MVVMのViewController(View)
class UserViewController: UIViewController {
    private let viewModel = RxSwiftViewModel()
    private let disposeBag = DisposeBag()

    private let fetchButton = UIButton()
    private let userLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        disposeBag.insert {
            // Input: ボタンタップをViewModelのfetchRelayに接続
            fetchButton.rx.tap
                .map { 1 } // タップイベントを、サンプル用のユーザーID `1` に変換
                .bind(to: viewModel.fetchRelay)

            // Output: ViewModelのuserRelayをラベルのテキストに接続
            viewModel.userRelay
                .map { user -> String? in
                    guard let user = user else { return "ユーザー情報なし" }
                    return "ユーザーID: \(user.id)"
                }
                .bind(to: userLabel.rx.text)
        }
    }
}

これがRxSwiftを使ったViewModelの最もシンプルな実装です。
ですが、これにはいくつかの問題があるため、実務ではこの形をしていないことが多いです。

BehaviorRelayをpublicにしてはいけない理由とその対策

理由

この最もシンプルなViewModelの問題点は、入力用のfetchRelayと出力用のuserRelayがpublicになっており、どちらも値を受け取れるし次に値を流せることにあります。

どういうことかfetchRelayで解説します。
fetchRelayはViewController(外)から値を受け取り、ViewModel(中)に値を流して通信などの次の処理をさせる目的で用意されています。

ですが、やろうと思えば、以下のようにViewController(外)で値を流す処理も書けてしまいます。
これはfetchRelayの目的とは違う使い方です。

ダメなことが出来てしまう例1
class UserViewController: UIViewController {
    // 省略

    override func viewDidLoad() {
        super.viewDidLoad()

        self.disposeBag.insert {
            fetchButton.rx.tap
                .map { 1 }
                .bind(to: viewModel.fetchRelay)

            viewModel.fetchRelay.subscribe(onNext: {
                // !! fetchRelayに値が流れたら何かできてしまう !!
                // 本来こんな使い方をされたくない
            })
        }
    }
}

さらに出力用のuserRelayも同様の問題も秘めています。
userRelayは本来ViewModel(中)で値を受け取り、それをViewController(外)に値を流す目的で用意されています。

ダメなことが出来てしまう例2
UserViewController.swift
class UserViewController: UIViewController {

    // 省略

    override func viewDidLoad() {
        super.viewDidLoad()

        self.disposeBag.insert {
            // Output: ViewModelのuserRelayをラベルのテキストに接続
            viewModel.userRelay
                .map { user -> String? in
                    guard let user = user else { return "ユーザー情報なし" }
                    return "ユーザーID: \(user.id)"
                }
                .bind(to: userLabel.rx.text)
        }
    }

    private func process() {
        // !! 全く無関係なタイミングでuserRelayに値が流せてしまう !!
        // 本来こんな使い方をされたくない
        viewModel.userRelay.accept(User(id: 20))
    }
}

このように、ViewModelが想定していない場所から入出力が行われると、誰がロジックを起動したのか追跡が困難になり、デバッグが非常に難しくなります。
この問題の解決策について次で解説します。

対策

ここからだいぶ長く解説が続きます。
この複雑さがRxSwiftが衰退していった理由の一端と思ってます。
最後に最終的な問題点を記載していますので、今は読み飛ばして次の「根本的な課題は何だったのか」から読んでも構いません。

実際、私はよく分からないまま見様見真似で使わざるを得ない状況になり、たくさんの無意味なコードを生み出すことになりました。

この問題を解決し、ViewModelの責務を明確にするための代表的な設計パターンが、このセクションで示す3つのサンプルコードです。

  1. ObserverとObservableを公開する
    • 状態を保持するBehaviorRelayprivateで隠蔽します。
    • 外部からの入口としてAnyObserverを、出口としてObservablepublicで公開します。
ダメなことをさせないようにするViewModelの設計1
RxSwiftViewModel.swift
final class RxSwiftViewModel {

    private let disposeBag: DisposeBag = .init()

    // --- Input ---
    let fetchObserver: AnyObserver<Int>
    private let _fetchTrigger = PublishRelay<Int>()

    // --- Output ---
    let userObservable: Observable<User?>
    private let _userRelay = BehaviorRelay<User?>(value: nil)

    init() {
        self.fetchObserver = AnyObserver { event in
            guard let userId = event.element else { return }
            self._fetchTrigger.accept(userId)
        }
        self.userObservable = self._userRelay.asObservable()

        _fetchTrigger
            .delay(.seconds(1), scheduler: MainScheduler.instance)
            .map { userId -> User? in
                return User(id: userId)
            }
            .bind(to: self._userRelay)
            .disposed(by: disposeBag)
    }
}

ViewModelの呼び出し側のViewControllerはこのようになります。
さきほどの意図していないような使い方をするとビルドエラーとなります。

ダメなことをさせないようにするViewModelをダメな使い方をしようとしてもビルドエラーになる例
UserViewController.swift
class UserViewController: UIViewController {

    // 省略

    override func viewDidLoad() {
        super.viewDidLoad()

        self.disposeBag.insert {
            // 省略

            // !! fetchObserverから値を受け取って何かするということはできない !!
            // これはビルドエラーになる
            viewModel.fetchObserver.subscribe(onNext: {})
            
            // 省略
        }
    }

    private func process() {
        // !! userObservableに任意のタイミングで値を流すことはできない !!
        // これはビルドエラーになる
        viewModel.userObservable.accept(User(id: 20))
    }
}
  1. Protocolで責務を定義する
    • ViewModelがどのようなInputOutputを持つべきか、protocolで明確に契約を定義します。
    • ViewModel自身がそのprotocolに準拠することで、インターフェースが強制され、より安全でテストしやすい設計になります。
ダメなことをさせないようにするViewModelの設計2
RxSwiftViewModel.swift
// ViewModelのInputとOutputを定義するための汎用的なProtocol
protocol ViewModelType {
    associatedtype Input
    associatedtype Output

    var inputs: Input { get }
    var outputs: Output { get }
}

// このViewModelが準拠するInputのProtocol
protocol RxSwiftViewModelInput {
    var fetchObserver: AnyObserver<Int> { get }
}

// このViewModelが準拠するOutputのProtocol
protocol RxSwiftViewModelOutput {
    var userObservable: Observable<User?> { get }
}

// ViewModel本体
final class RxSwiftViewModel: ViewModelType, RxSwiftViewModelInput, RxSwiftViewModelOutput {

    // MARK: - ViewModelType Conformance
    var inputs: Input { self }
    var outputs: Output { self }

    // MARK: - Input
    let fetchObserver: AnyObserver<Int>

    // MARK: - Output
    let userObservable: Observable<User?>

    // MARK: - Private Properties
    private let disposeBag: DisposeBag = .init()
    private let _fetchTrigger = PublishRelay<Int>()
    private let _userRelay = BehaviorRelay<User?>(value: nil)

    init() {
        self.fetchObserver = AnyObserver { event in
            guard let userId = event.element else { return }
            self._fetchTrigger.accept(userId)
        }
        self.userObservable = self._userRelay.asObservable()

        self.disposeBag.insert {
            _fetchTrigger
                .delay(.seconds(1), scheduler: MainScheduler.instance)
                .map { userId -> User? in
                    return User(id: userId)
                }
                .bind(to: _userRelay)
        }
    }
}

これを利用する側のViewControllerはこのようになります。
viewModel.inputsviewModel.outputsとなることで入出力専用であることがより明確になりました。

inputsとoutputsを呼び出すViewController
class UserViewController: UIViewController {

    // 省略

    override func viewDidLoad() {
        super.viewDidLoad()

        self.disposeBag.insert {
            
            // viewModel.inputsとなることで入力であることが明確になる
            fetchButton.rx.tap
                .map { 1 }
                .bind(to: viewModel.inputs.fetchObserver)
            
            // viewModel.outputsとなることで出力であることが明確になる
            viewModel.outputs.userObservable
                .map { user -> String? in
                    guard let user = user else { return "ユーザー情報なし" }
                    return "ユーザーID: \(user.id)"
                }
                .bind(to: userLabel.rx.text)
        }
    }
}
  1. initで入力ストリームを受け取る
    • ViewModelが入力用のプロパティを持つのではなく、初期化時にView側からUIイベントのストリーム(Observable)そのものを注入します。
    • ViewModelは受け取ったストリームを変換して出力するだけの純粋な変換器となり、Viewから完全に独立します。

これらのパターンを適用することで、ViewModelのカプセル化が保たれ、堅牢な設計を実現できます。

ダメなことをさせないようにするViewModelの設計3
RxSwiftViewModel.swift
// ViewModelのInputをinitで受け取るパターン
final class RxSwiftViewModel {

    // --- Output ---
    let userObservable: Observable<User?>
    private let _userRelay = BehaviorRelay<User?>(value: nil)

    // --- Private Properties ---
    private let disposeBag: DisposeBag = .init()

    // initでInputとなるObservableを外部から受け取る
    init(fetchTrigger: Observable<Int>) {
        self.userObservable = _userRelay.asObservable()

        disposeBag.insert {
            fetchTrigger
                .delay(.seconds(1), scheduler: MainScheduler.instance)
                .map { userId -> User? in
                    return User(id: userId)
                }
                .bind(to: _userRelay)
        }
    }
}

このViewModelを利用するViewControllerは以下のようになります。
ViewModelを初期化する際に、トリガーとなるObservableを外部から注入しているのがポイントです。

入力用のObservableを注入しているViewController
UserViewController.swift
class UserViewController: UIViewController {

    private var viewModel: RxSwiftViewModel!
    private let disposeBag = DisposeBag()

    private let fetchButton = UIButton()
    private let userLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViewModel()
        bindViewModel()
    }

    private func setupViewModel() {
        // 1. View側のUIイベント(ボタンタップ)をObservableとして定義
        let fetchTrigger = fetchButton.rx.tap
            .map { 1 }
            .asObservable()

        // 2. Observableを渡してViewModelを初期化
        self.viewModel = RxSwiftViewModel(fetchTrigger: fetchTrigger)
    }

    private func bindViewModel() {
        // Output: ViewModelのuserObservableをラベルのテキストに接続
        viewModel.userObservable
            .map { user -> String? in
                guard let user = user else { return "ユーザー情報なし" }
                return "ユーザーID: \(user.id)"
            }
            .bind(to: userLabel.rx.text)
            .disposed(by: disposeBag)
    }
}

根本的な課題は何だったのか

先のセクションで見た3つの複雑な設計パターンは、すべて「ViewModelの入力と出力を明確に分離する」という共通の目的を持っていました。
リアクティブプログラミングの特性上、値がどこから来てどこへ行くのかを厳密に管理する必要があったためです。

「1. ObserverとObservableを公開する」ではAnyObserver(入力)とObservable(出力)という専用の型を利用することで入力と出力を区別していました。

「2. Protocolで責務を定義する」では、さらに一歩進んで、入力用のInputと出力用のOutputというprotocolでグループ化しました。
これによりviewModel.inputsviewModel.outputsという明確な形で入力と出力を区別していました。

「3. initで入力ストリームを受け取る」では、ViewModelから入力用のパラメータをなくし、初期化時に外部から入力用のObservableを注入する形を取りました。
これにより、ViewModelは「入力は初期化時に設定」して、ViewModelの出力はパラメータで受け取るという方法で入力と出力を区別していました。

しかし、本当にこんな複雑な記述をしないと「入力と出力を明確にする」ことはできないのでしょうか?

次のセクションでトレンドのSwiftUIとObservationフレームワークではどうやっているのか見ていきます。

Observationを使ったViewModelの入出力

SwiftUIは、AppleがWWDC 2019で発表した、宣言的UI(Declarative UI)を前提としたフレームワークです。
コードでUIの「状態」を記述すると、フレームワークが自動的に実際のUI描画や更新を行ってくれます。

このSwiftUIにおける状態管理を、よりシンプルで高パフォーマンスにするために、WWDC 2023 (iOS 17)で導入されたのがObservationフレームワークです。
従来のObservableObject@Publishedを使った方法に比べ、より少ないコードで、かつ効率的にViewの更新をハンドリングできるようになりました。

この新しい仕組みを使ったViewModelが、以下のSwiftUIViewModelです。

2025年の仕様のViewModel
SwiftUIViewModel.swift
@Observable
@MainActor
final class SwiftUIViewModel {

    // --- Output ---
    private(set) var user: User?

    // --- Input ---
    func fetch(userId: Int) async {
        // 本当はAPI通信をして取得したUser情報を代入する
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        self.user = User(id: userId)
    }
}
ViewModelを呼び出すView(SwiftUI)
ContentView.swift
struct ContentView: View {
    private var viewModel = SwiftUIViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("get user: \(viewModel.user?.id ?? 0)")

            Button("fetch user") {
                Task {
                    await viewModel.fetch(userId: 1)
                }
            }
        }
    }
}

UIのフレームワーク自体がリアクティブ前提なこともあり、かなり簡潔に実装できていることが分かります。
また入力と出力も単純明快です。
入力はメソッドであり、出力はパラメータです。

これであれば、先のセクションで書いたややこしいテクニックも必要なく、入力と出力が明確になります。
では、なぜRxSwiftを使ったViewModelでは入力にメソッドを使わなかったのでしょう?
それには次で話す非同期処理が大きく関わっています。

非同期処理の進化

RxSwiftが広く使われるようになった背景には、コールバックによる非同期処理の記述の複雑さ、いわゆる「コールバック地獄」がありました。
ここでは、ViewModelから複数の非同期処理を連続して呼び出す例を3つ比較していきます。

1. async/await登場以前:コールバック地獄

例えば、複数の非同期処理を順番に実行する場合、以下のようにfetchUserのコールバックの中でprocessUserのコールバックがネストしていました。

RxSwiftがなかった頃のViewModelとその呼び出し
class ViewModel {
    // 擬似的な非同期API
    private func fetchUser(completion: @escaping (Result<User, Error>) -> Void) { /* ... */ }
    private func processUser(user: User, completion: @escaping (Result<Bool, Error>) -> Void) { /* ... */ }

    func fetch(completion: @escaping (Result<Bool, Error>) -> Void) { 
        viewModel.fetchUser { result in
            // 1段階目のネスト
            switch result {
            case .success(let user):
                viewModel.processUser(user: user) { result in
                    // 2段階目のネスト
                    completion(result)
                }
            case .failure(let error):
                completion(.error(error))
            }
        }
    }
}
class ViewController: UIViewController {
    let viewModel = ViewModel()

    func buttonTapped() {
      viewModel.fetch { result in 
          switch result {
          case .success(let status):
              completion(status)
              print("最終結果: \(status)")
          case .failure(let error):
              print("エラー: \(error)")
          }
      }
    }
}

もちろん、さらに処理が続くようであればこのネストがどんどん深くなっていきswitchによる分岐もどんどん増えていきます。
そうしてViewModelの中の可読性が著しく低くなることが問題でした。

2. RxSwiftによる解決

このコールバック地獄を解決したのが、RxSwiftのflatMapに代表されるオペレータでした。

RxSwiftの導入によるコールバック地獄からの開放
class ViewModel {
    private let disposeBag = DisposeBag()
    let fetchTrigger = PublishRelay<Void>()
    let finalStatus = PublishRelay<Bool>()

    // 擬似的な非同期API
    private func fetchUser() -> Observable<User> { /* ... */ }
    private func processUser(user: User) -> Observable<Bool> { /* ... */ }

    init() {
        fetchTrigger
            .flatMap { self.fetchUser() }
            .flatMap { user in self.processUser(user: user) }
            .bind(to: finalStatus)
            .disposed(by: disposeBag)
    }
}
// ViewController
class ViewController: UIViewController {
    let viewModel = ViewModel()
    let disposeBag = DisposeBag()
    let button = UIButton()

    func viewDidLoad() {
        super.viewDidLoad()

        disposeBag.insert {

            // ボタンタップをViewModelの入力に接続
            button.rx.tap.bind(to: viewModel.fetchTrigger)

            // ViewModelの出力
            viewModel.finalStatus.subscribe(onNext: { 
              print("最終結果: \($0)") 
            })
        }
    }
}

ネストが解消され、フラットに記述できるのが大きな魅力となり、入力もRxSwiftで記述することが主流となりました。
しかし、そうなったために、先の章で解説したように、入力出力を分けるための色々な書き方が考案されていきました。

3. async/awaitによる現代的な解決

しかしSwift 5.5で登場したasync/awaitは、この問題を言語レベルで、よりシンプルに解決しました。

async/awaitの導入によるコールバック地獄からの開放
ViewModel.swift
// ViewModel
@MainActor
class ViewModel {
    // 擬似的な非同期API
    private func fetchUser() async throws -> User { /* ... */ }
    private func processUser(user: User) async throws -> Bool { /* ... */ }

    func fetch() async -> Bool? {
        do {
            let user = try await fetchUser()
            let status = try await processUser(user: user)
            return status
        } catch {
            print("エラー: \(error)")
            return nil
        }
    }
}
ViewController.swift
// ViewController
class ViewController: UIViewController {
    let viewModel = AsyncAwaitViewModel()

    func buttonTapped() {
        Task {
            if let status = await viewModel.fetch() {
                print("最終結果: \(status)")
            }
        }
    }
}

ViewModelの内部実装が、まるで同期処理のように直線的に書けています。
このasync/awaitの登場が、現代において「入力メソッドで十分」と言えるようになった大きな理由と言えます。

RxSwiftのメリットとデメリットの振り返り

かつてRxSwiftのメリットであった「コールバック地獄からの解放」や「リアクティブなUI更新」は、現代ではSwift標準のasync/awaitやObservationフレームワークによって、よりシンプルに実現できます。

RxSwiftを使うことのデメリットは入力出力を明確に区別する書き方が複雑かつ複数のパターンがあることです。
さらに解説しきれませんでしたがRxSwiftでは以下のようなデメリットもあります。

  • デバッグがしづらい
    RxSwiftを使ったプロジェクトでXcodeでブレークポイントを貼って処理を止めると、スタックトレースが非常に読みづらくなりデバッグがしづらくなる欠点があります。参考

  • オペレータや拡張されたライブラリが多過ぎる
    RxSwiftの標準機能自体にもたくさんのオペレータがあり、同じ要件でも組み合わせるオペレータが違ったりします。
    そのため実装者以外の人が見ても何をしているのか追いづらいという問題がありました。
    さらに、RxSwiftを拡張したライブラリも多く開発され、さらなる学習量の増加と実装のパターンの多さが増すことになりました。

RxSwiftには他にも多くのメリットがあるため、一概に不要になったとは言えません。
しかし、ここまで説明した内容のメリットだけに関して言えばその役割を終えたと言えます。

もし読んでくださる方々のプロジェクトで他にメリットがないようであれば、デメリットを解消するために書き換えを検討してみてはいかがでしょうか。
ですが、巨大なプロジェクトで根本となるフレームワークを外すのは工数が膨大です。

また、Observationが使えるようになるのはiOS17以降であり、UIKitで使うにはコツが必要です。
iOS18からUIKitでもObservationが簡単に書けるよう標準でサポートされますが、実用段階になるのはまだもう少し先と言えます。

そのため、次のセクションでは現段階ではRxSwiftとどう向き合って設計をしていけばいいか解説していきます。

最終的に現実的な落とし所

すごく長くなりましたが、ここからが本題と言えます。

これまでのセクションで、RxSwiftでViewModelの入力と出力を安全に分離するためには、AnyObserverprotocolを使うなど、いくつかの複雑な設計パターンが必要になることを見てきました。
またasync/awaitObservationの登場によりそのような複雑な設計パターンは不要になってきたと記載しました。

この章では、全面的な書き換えを目指すのではなく、既存のRxSwiftのコードベースに、モダンな書き方を少しずつ取り入れていくための、現実的な「落とし所」となる流れを紹介します。

まずRxSwiftで、がっつり色々書かれたViewModelがあるとします。

InputとOutputで書かれたViewModel
protocol ViewModelType {
    associatedtype Input
    associatedtype Output

    var inputs: Input { get }
    var outputs: Output { get }
}

protocol ViewModelInput {
    var fetchObserver: AnyObserver<Int> { get }
}

protocol ViewModelOutput {
    var userObservable: Observable<User?> { get }
}

final class ViewModel: ViewModelType, ViewModelInput, ViewModelOutput {

    var inputs: Input { self }
    var outputs: Output { self }

    let fetchObserver: AnyObserver<Int>

    let userObservable: Observable<User?>

    private let disposeBag: DisposeBag = .init()
    private let _fetchTrigger = PublishRelay<Int>()
    private let _userRelay = BehaviorRelay<User?>(value: nil)

    init() {
        self.fetchObserver = AnyObserver { event in
            guard let userId = event.element else { return }
            self._fetchTrigger.accept(userId)
        }
        self.userObservable = self._userRelay.asObservable()

        self.disposeBag.insert {
            //!! debounce, throttleなどのRxSwiftの便利なオペレータを使った複雑なロジックとする !!
            _fetchTrigger
                .delay(.seconds(1), scheduler: MainScheduler.instance)
                .map { userId -> User? in
                    return getUser(id: userId)
                }
                .bind(to: _userRelay)
        }
    }
}

段階的に移行していきますが最終的な目標は以下のようなViewModelです

目指すべきViewModel
@Observable
final class ViewModel {

    // --- Output ---
    private(set) var user: User?

    // --- Input ---
    func fetch(userId: Int) async {
      Task {
          //!! RxSwiftでやっていた処理の代わりのロジックがあればここに記載していく!!

          try? await hogehoge()
          let user = try? await getUser(id: userId)
          
          await MainActor.run {
              self. user = user
          }
        }
    }
}

まずは目標に近付くように、入力をメソッドにして、そのために必要となったViewModelInputViewModelOutputを消してみます。

入力をメソッドにしたViewModel
final class ViewModel {

    let userObservable: Observable<User?>

    private let disposeBag: DisposeBag = .init()
    private let _fetchTrigger = PublishRelay<Int>()
    private let _userRelay = BehaviorRelay<User?>(value: nil)

    init() {
        self.userObservable = self._userRelay.asObservable()

        self.disposeBag.insert {
            //!! debounce, throttleなどのRxSwiftの便利なオペレータを使った複雑なロジックとする !!
            _fetchTrigger
                .delay(.seconds(1), scheduler: MainScheduler.instance)
                .map { userId -> User? in
                    return getUser(id: userId)
                }
                .bind(to: _userRelay)
        }
    }

    func fetch(userId: Int) {
        _fetchTrigger.accept(userId)
    }
}

これだけでだいぶボイラープレートな書き方が消えて見やすくなりました。
もし、_fetchTriggerの後にシンプルなオペレータしか使っていない場合は、さらにPublishRelayを消して簡単にできます。

PublishRelayを消したViewModel
final class ViewModel {

    let userObservable: Observable<User?>

    private let _userRelay = BehaviorRelay<User?>(value: nil)

    func fetch(userId: Int) {
        Task { @MainActor in
          //!! RxSwiftでやっていた処理の代わりの実装をここに記載していく!!

          // 非同期な処理
          try? await hogehoge()
          let user = try? await getUser(id: userId)

          // UI更新(およびRxSwiftへの値の流し込み)のためにMainActorに切り替える
          await MainActor.run {
              self. _userRelay.accept(user)
          }
        }
    }
}

ここからObservationが使えるようであれば最終型へと書き換えができます。

理想となるViewModel
@Observable
final class ViewModel {

    private(set) var user: User?

    func fetch(userId: Int) {
        Task { @MainActor in
          
          //!! RxSwiftでやっていた処理の代わりの実装をここに記載していく!!
          // 非同期な処理
          try? await hogehoge()
          let user = try? await getUser(id: userId)
          
          // UI更新(およびRxSwiftへの値の流し込み)のためにMainActorに切り替える
          await MainActor.run {
              self.user = user
          }
        }
    }
}

終わりに

  • 入力について
    基本はメソッドでやる
    RxSwiftのオペレータを使う場合はprivateなPublishRelayを用意してメソッドから呼び出す

  • 出力について
    基本はprivateなBehaviorRelayを用意してpublicなObservableで値を出力する
    Observationが使えるプロジェクトになれば普通のパラメータにする

本記事で詳細に解説した通り、現代のRxSwiftプロジェクトにおける現実的な改善策は、まずViewModelへの入力をメソッドに置き換えることです。
なぜこのシンプルな手法が最適解なのか、その背景にある歴史や思想を理解することで、自信を持ってチーム内で意見を出し合い、コードを改善できるはずです。
この記事が、現場でRxSwiftに悩む開発者の一助となれば幸いです。

Discussion