ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

Combineフレームワークを使ったViewModelの形について

はじめに

こんにちは、ローソンデジタルイノベーション(LDI)でiOSエンジニアを担当している山形です。
今LDIでは開発しているiOSアプリで使用しているRxSwiftをCombineやSwift Concurrencyへ置き換えを進めています。
開発しているiOSアプリはMVVMを採用しておりCombineを扱いやすい形でViewModelを実装するにはどうすれば良いか考えたので記事にしたいと思います。
1つの例としてみなさんのお役に立てれば幸いです。

開発環境

macOS:Ventura 13.6
Xcode:15.0
サポートOS:iOS14〜

ViewModelの実装方法

MVVMアーキテクチャでよく使われるViewとViewModel間のデータバインディングを行うためにViewModelはどのような形が実装しやすいかという点で考え実装しています。 実装者によってばらつきが生まれないようにProtocolで実装をある程度強制できるようにしており、以下でソースコードの記載と共に説明します。

ViewModel実装のためのProtocol

以下はViewModelを実装するためのProtocolです。

protocol ViewModelType: AnyObject {
    var cancellables: Set<AnyCancellable> { get }

    associatedtype Input
    associatedtype Output

    func bind(input: Input) -> Output
}

登場した各項目について以下の表で説明します。

項目 内容
cancellables ViewModelが破棄された時に購読処理を一括で破棄するためのインスタンス
combineの購読処理それぞれに指定します。
Input ViewController→ViewModelに通知されるイベント定義
Output ViewModel→ViewControllerに通知するデータ返却のための定義
bind(input: Input) -> Output データバインディングを実装しOutputをViewControllerに返却するための関数

ViewController実装のためのProtocol

以下はViewControllerを実装するためのProtocolです。

protocol ViewModelBindable {
    associatedtype ViewModel: ViewModelType

    var cancellables: Set<AnyCancellable> { get }
    var viewModel: ViewModel { get }
    var viewModelInput: ViewModel.Input { get }

    func bind(to viewModel: ViewModel)
}

登場した各項目について以下の表で説明します。

項目 内容
cancellables ViewControllerが破棄された時に購読処理を一括で破棄するためのインスタンス
combineの購読処理それぞれに指定します。
viewModel ViewModelのインスタンス
viewModelInput ViewModelで定義したInput情報のインスタンス
iOSアプリのようなGUIを持つアプリはユーザー操作によるイベントを契機に始まります。
そのためInput情報を知っているViewController, Viewはこの情報を使ってViewModelにイベント通知を行います。
bind(to viewModel: ViewModel) ViewModelにInput情報を渡しOutputを受け取りデータバインディングを実装するための関数
viewDidLoadでコールすることを想定しています。

この実装ではViewModelに対する入力(Input)とViewContorollerに対する出力(Output)を分けて実装するようにしています。
InputとOutputを分けることによって役割が明確になりますが副次的な効果として実装の一貫性を維持することもできました。

使い方について

では用意したProtocolを使って実際に実装してみます。
以下はviewWillAppearでイベント通知を受け取った際にLDITechBlogInfoという情報を画面側に通知するViewModelのサンプルです。

final class LDITechBlogViewModel: ViewModelType {
    private(set) var cancellables = Set<AnyCancellable>()

    struct Input {
        let viewWillAppear = PassthroughSubject<Void, Never>()
    }

    struct Output {
        let loadedData: AnyPublisher<LDITechBlogInfo, Never>
    }

    func bind(input: Input) -> Output {
        let loadedDataSubject = PassthroughSubject<LDITechBlogInfo, Never>()
        let output = Output(loadedData: loadedDataSubject.eraseToAnyPublisher())
        input.viewWillAppear
            .sink {
                // 何かしらの情報取得処理や加工処理をここで行います。今回は単純に初期化したものを通知しています。
                loadedDataSubject.send(LDITechBlogInfo())
            }
            .store(in: &cancellables)

        return output
    }
}

以下はViewControllerのサンプルです。
画面側ではViewModelとViewModelへ通知するためのInput情報を保持します。

final class LDITechBlogViewController: UIViewController, ViewModelBindable {
    private(set) var cancellables = Set<AnyCancellable>()
    private(set) var viewModel: LDITechBlogViewModel
    private(set) var viewModelInput = LDITechBlogViewModel.Input()

    init(viewModel: LDITechBlogViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        bind(to: viewModel)
    }

    func bind(to viewModel: LDITechBlogViewModel) {
        let output = viewModel.bind(input: viewModelInput)
        ...
    }

    ...
}

ViewとViewModelのデータバインディングは以下の関数内で実装します。

    func bind(to viewModel: LDITechBlogViewModel) {
        let output = viewModel.bind(input: viewModelInput)
        output.loadedData
            // UIへ反映させるためメインスレッドで処理を実行します。
            .receive(on: DispatchQueue.main)
            .sink { [weak self] loadedData in
                // Viewへの反映処理などを記述します。
                titleLabel.text = loadedData.title
            }
            .store(in: &cancellables)
    }

画面側での入力(Input)をViewModelに通知する実装は以下のように行います。

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModelInput.viewWillAppear.send()
    }

ここまでの処理をシーケンス図として起こしてみます。 LDITechBlogViewModelLDITechBlogViewControllerLDITechBlogViewModelLDITechBlogViewControllerInputとOutputのデータバインディングを行います。イベント通知を行います。データバインディングしているオブジェクトへ通知します。データバインディング実装時に購読した処理が実行され画面への反映を行います。viewDidLoadbind(to: viewModel)bind(input: Input) -> OutputOutputviewWillAppearviewModelInput.viewWillAppear.send()loadedData

いかがでしょうか、サンプルはシンプルなコードとなっていますがViewControllerからのイベントを起点にViewModelへ通知、データバインディングしたオブジェクトへ反映させる実装を行うことができたかと思います。

最後に

ここまで記事を読んでいただきありがとうございます!
冒頭でお伝えした通り現在RxSwiftをCombineやSwift Concurrencyへ置き換えを進めています。
置き換えていく中で新たに課題が生まれたり、より良い実装方法が見えてきた時など、良いネタがあればまた記事にしたいと考えています。
今後もローソンデジタルイノベーションでは技術ブログを更新していきますので、是非「読者になる」で応援していただけますと幸いです。