KINTO Tech Blog
Development

Combineを使ってMVVMを実現した話

Cover Image for Combineを使ってMVVMを実現した話

こんにちは

KINTOテクノロジーズ モバイルアプリ開発Gの小山です。
モバイルアプリの開発・運用に携わっています。担当はiOSです。

今回はGlobal KINTOのモバイルアプリ開発の中でMVVMを採用した内容を紹介したいと思います。

MVVM と Combine

話をする前にまずこれらについて軽く説明をしておきたいと思います。

MVVM

ソフトウェア開発におけるアーキテクチャのひとつです。
MVCモデルの派生系で Model-View-ViewModel という構成を取ります。

細かいことを言うとややこしいので簡潔に示すと、

  • Model
    データをひとまとめにした存在であり、データのまとまりと簡単な処理を担います。複雑な処理は行いません。
  • View
    画面描写を担う存在です。画面を生成するのに必要なデータを受け取り、ただ描写します。
  • ViewModel
    ModelとViewの中間地点の存在で、複数のModelのデータをまとめたり、Viewの状態を保持するための値を持つ役割を担ったりします。

といった感じです。個人の見解です。

ただこれでもiOSやり始め当初の私には理解が難しかったです。
そこでiOSの開発に当てはめるとこんな感じになります。

  • Model
    扱うデータをただひとまとめにします。基準として辞書型を採用するくらいならModelを一つ作ります。Model内のデータに関わるシンプルなメソッドのみを持ちます。
  • View
    ViewControllerや個別のViewがこれに当たります。描写に関わるコーディングのみ行い、データの操作は行わず、受け取ったデータをただ描写します。基準としてif文が全くない状態を目指します。
  • ViewModel
    ViewControllerからトリガーを受け取ってデータを返却します。ViewControllerとは1対1で、個別のViewに関しては場合によって上位のViewControllerのViewModelが役目を担っても良いです。

これを実現していきたいと思います。本記事の中では特にViewとViewModelの分割をご紹介したいと思います。

Combine

2019年の6月に登場した、Apple公式のリアクティブアーキテクチャを実現するフレームワークです。
それまではリアクティブアーキテクチャを実現するにはサードパーティ製のRxSwiftを使うことがほとんどでしたが、公式でサポートの厚いライブラリがリリースされました。

ちょ、ちょっと待ってリアクティブアーキテクチャってなんだっけ

と、思った方もいるかと思います。
私がそうでした。Wikipediaによると以下の通りです。

reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change.
-- Wikipedia | Reactive programming

これを和訳すると以下の通りです。

データストリームと変更の伝播に関係する宣言型プログラミングパラダイムです。

いまいち分からないですね。
こちらもややこしくせず理解するには、表計算ソフトをイメージすると良いと聞いたことがあります。

以下のような簡単な計算式を入力したとします。

A B C
10 20 =A1+B1

これを入力完了するともちろんC1セルに計算結果が入ります。

A B C
10 20 30

この状態でA1セルを20に書き換えた場合はどうなるでしょうか。

A B C
20 20 40

もちろんC1セルが40に書き変わります。

このようにA1の変更に対応してC1に直接手を加えずに変更を伝播させていくことを、リアクティブアーキテクチャと呼びます。
初めにデータ処理の宣言(=A1+B1)のみを行い、最後に実データを流す(A1に20を代入)、といったイメージが私はしっくりきました。

背景

前置きが少々長くなりました。我々がなぜCombine、MVVMを採用するに至ったかという話です。

Global KINTOの開発案件を担当した当初、外注していたソースコードを内製に切り替えて間もない状況だったこともあり、既に出来上がっている部分はいわゆるスパゲッティコードの状態でメンテナンス性が非常に悪い状態でした。
あらゆるところでグローバル変数が呼ばれ、どのクラスがどのクラスと依存関係にあるか全く分からない状態でした。

しかし幸いなことに、コードと画面数が比較的少なかったため全体的にアーキテクチャを見直すことで解決をしようと試みました。
この際に、モバイルアプリ開発GにてCombineを使ったMVVMの知見があったため、MVVMのアーキテクチャに則り進めることとなりました。

アーキテクチャに則った設計をきちんとすることで、新規参入したメンバーでも理解しやすく、安全に改修できるようなプロジェクトにすることが目標です。

いざ、実装!

今回はMVVMに則ると決めたので、ViewとViewModelは責務を分離します。

なお、今回の実装ではUIKitを使用します。

View - Part1

Viewでは一切の演算処理を行わない代わりに、イベント発火のタイミングだけをViewModelに伝えたいです。
これを実装するために、CombineのPublisherを利用していきます。
例えば、viewDidLoadのタイミングを送信する必要がある場合、PassthroughSubjectにVoid型を持たせて定義しておきます。

private let didLoad: PassthroughSubject<Void, Never> = .init()

これをviewDidLoad時に送信するようにします。

override func viewDidLoad() {
    super.viewDidLoad()
    didLoad.send()
}

また、ボタンのタップ時にはtapPublisher[1]を利用するのがスマートです。
まず、対象のボタンをOutlet接続し、送信用のPublisherを準備します。
もしボタンを押したタイミングで表示されている画面のString値を渡したい場合は、VoidではなくStringでPublisherを定義します。

@IBOutlet weak var button: UIButton!
@IBOutlet weak var textArea: UITextField!
private let tapButton: PassthroughSubject<String, Never> = .init()

その後、tapPublisherを利用してイベントを検知してViewModelに送信します。

button
    .tapPublisher
    .sink { [weak self] in
        self?.tapButton.send(textArea.text ?? "")
    }
    .store(in: &cancellables)

続いて、ViewModelを見てみましょう。

ViewModel

ViewModelではViewから受け取ったイベントトリガーを元に、情報を取得したり生成したりすることで、Viewに必要な情報を集めて返してあげます。
こちらではCombineのOperator、Subscriberを使って実現していきます。

初めに、View側との接続準備のため、ViewModelとは別にInput,Outputの構造体を定義します。

InputはViewから渡されるデータ、Outputはその結果ViewModelから渡したいデータです。
データのやり取りの際に、渡されるデータとエラーの型がきちんと決まっていれば処理は可能であるため、型は一律AnyPublisherを使います。

struct ViewModelInput {
    let viewDidLoad: AnyPublisher<Void, Never> // 画面が最初に表示されたときにトリガーが欲しい!
    let tapButton: AnyPublisher<String, Never> // ボタンがタップされたときにテキストが欲しい!
}

struct ViewModelOutput {
    let onShowWelcome: AnyPublisher<String, Never> // Welcomeテキストを画面に表示してほしい
    let onShowText: AnyPublisher<String, Never> // 任意のテキストを画面に表示してほしい
    let onShowOnlyFirst: AnyPublisher<Void, Never> // 最初にボタンを押された時のみ固定値を画面に出してほしい
}

続いて、データの処理部分を作成します。Inputを受け取ってOutputに変換する部分の実装になります。
例えば、viewDidLoad時に特定のAPIを実行するような動作であれば、viewDidLoadのPublisherからOperatorを繋げてAPIリクエストを実行します。

func transform(input: ViewModelInput) -> ViewModelOutput {
    let apiResponse = input
        .viewDidLoad
        .flatMap { api.requestData() }

同様にボタンタップ時、画面上のテキストを基にデータを生成するような動作をしたい場合は、mapを使います。

    let text = input
        .tapButton
        .map { createText(text: $0) }

また他にも、viewDidLoadが呼ばれ、且つボタンのタップもされたときに処理させたいことがあるとします。
そういった場合はPublisher同士を接続することができます。いくつか接続方法がありますが、今回はzipを使います。

    let didLoad = input
        .viewDidLoad
    
    let buttonTap = input
        .tapButton
    
    let didLoadAndButtonTap = didLoad
        .zip(buttonTap)
        .map { _ in }

このように様々なOperatorがCombineやその拡張ライブラリにはすでに準備されているため、コードをシンプルに保ちながら複雑な処理を実現することができます。
Operatorはいくつもありますが、個人的に便利でこれさえ覚えておけばOKというものを以下に挙げておきます。

  • map
  • flatMap
  • compactMap
  • filter
  • share
  • materialize [2]
  • merge
  • zip
  • combineLatest
  • withLatestFrom [2:1]

そして最後にOutputの型に合わせてreturnしてあげます。
Combineを利用する場合、Operatorを接続するたびに型がどんどん拡張されてしまうため、最後にAnyPublisherにまとめてから生成したデータをViewに返します。

    return .init(
        onShowWelcome: apiResponse.eraseToAnyPublisher(),
        onShowText: text.eraseToAnyPublisher(),
        onShowOnlyFirst: didLoadAndButtonTap.eraseToAnyPublisher()
    )
}

View - Part2

ViewModel側が完成したので、Viewと接続して画面描写を行います。
先ほど用意したtransformメソッドを呼び出して、ViewModelでの処理結果を受け取りましょう。

let output = viewModel.transform(
    input: .init(
        viewDidLoad: didLoad.eraseToAnyPublisher(),
        tapButton: tapButton.eraseToAnyPublisher()
    )
)

処理結果を受け取ったら、各結果の際にどんな画面描写をするか実装して完成になります。

output
    .onShowWelcome
    .receive(on: DispatchQueue.main)
    .sink { [weak self] in
        self?.textArea.text = $0
    }
    .store(in: &cancellables)

output
    .onShowText
    .receive(on: DispatchQueue.main)
    .sink { [weak self] in
        self?.textArea.text = $0
    }
    .store(in: &cancellables)

output
    .onShowOnlyFirst
    .receive(on: DispatchQueue.main)
    .sink { [weak self] in
        self?.showCustomMessage("First!")
    }
    .store(in: &cancellables)

完成

こうして完成したコードがこちらになります。

ViewController.swift
class ViewController: UIViewController {

    @IBOutlet weak var button: UIButton!
    @IBOutlet weak var textArea: UITextField!
    
    private var viewModel: ViewModelProtocol!
    
    private let didLoad: PassthroughSubject<Void, Never> = .init()
    private let tapButton: PassthroughSubject<String, Never> = .init()
    private var cancellables: Set<AnyCancellable> = .init()
    
    func configure(viewModel: ViewModelProtocol) {
        self.viewModel = viewModel
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
        didLoad.send()
    }

    func bind() {
        let output = viewModel.transform(
            input: .init(
                viewDidLoad: didLoad.eraseToAnyPublisher(),
                tapButton: tapButton.eraseToAnyPublisher()
            )
        )
        
        button
            .tapPublisher
            .sink { [weak self] in
                self?.tapButton.send(textArea.text ?? "")
            }
            .store(in: &cancellables)
        
        output
            .onShowWelcome
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in
                self?.textArea.text = $0
            }
            .store(in: &cancellables)
        
        output
            .onShowText
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in
                self?.textArea.text = $0
            }
            .store(in: &cancellables)
        
        output
            .onShowOnlyFirst
            .receive(on: DispatchQueue.main)
            .sink { [weak self] in
                self?.showCustomMessage("First!") // あらかじめメソッドが準備されている想定
            }
            .store(in: &cancellables)
    }
}
ViewModel.swift
protocol ViewModelProtocol {
    func transform(input: ViewModelInput) -> ViewModelOutput
}

struct ViewModelInput {
    let viewDidLoad: AnyPublisher<Void, Never>
    let tapButton: AnyPublisher<String, Never>
}

struct ViewModelOutput {
    let onShowWelcome: AnyPublisher<String, Never>
    let onShowText: AnyPublisher<String, Never>
    let onShowOnlyFirst: AnyPublisher<Void, Never>
}

struct ViewModel: ViewModelProtocol {
    let api: SomeApiProtocol
    
    init(api: SomeApiProtocol) {
        self.api = api
    }
    
    func transform(input: ViewModelInput) -> ViewModelOutput {
        let didLoad = input
            .viewDidLoad
            .share()
        
        let apiResponse = didLoad
            .flatMap { api.requestData() } // Future<String, Never> が返される想定
        
        let buttonTap = input
            .tapButton
            .share()
        
        let text = buttonTap
            .map { createText(text: $0) }
        
        let didLoadAndButtonTap = didLoad
            .zip(buttonTap)
            .map { _ in }
        
        return .init(
            onShowWelcome: apiResponse.eraseToAnyPublisher(),
            onShowText: text.eraseToAnyPublisher(),
            onShowOnlyFirst: didLoadAndButtonTap.eraseToAnyPublisher()
        )
    }
    
    func createText(text: String) -> String {
        "\(text) show!!"
    }
}

このようにViewとViewModelで明確に役割分担を行うことでコード内に秩序が生まれます。
どのViewでも同じ構成をとることができるため、コードの可読性が向上しメンテナンスもしやすく、役割が決まっているので無理矢理なコーディングが生まれにくいです。
ただしデメリットとしてはCombineやRxSwiftなどのリアクティブプログラミングを経験していない場合、それまでのSwiftとは言語が変わったかと思うほど理解が難しい点です。

Global KINTOのiOSのプロジェクトでは、全てのViewをこの構成に則って作成しています。
可読性が向上したことでメンテナンス性も上がり、新規のViewを作成する場合でも作成の仕方にばらつきが出づらくなりました。

最後に

モバイルアプリ開発Gでは、本記事のような知見をGlobal KINTO以外のプロダクトに共有したり、これとは全く別の新しいアーキテクチャに挑戦するといったようなことも実践しています。
引き続き、モバイルアプリ開発Gの責務として、iOSの様々な開発手法に挑戦していきたいと思っています!どうぞよろしくお願いします。

脚注
  1. 拡張ライブラリのCombineCocoaを使用しています ↩︎

  2. 拡張ライブラリのCombineExtを使用しています ↩︎ ↩︎

Facebook

関連記事 | Related Posts

We are hiring!

【iOSエンジニア】モバイルアプリ開発G/大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【iOS/Androidエンジニア】モバイルアプリ開発G/東京

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。