DMM.comの、一番深くておもしろいトコロ。

DMMポイントクラブのiOSのUI更新を支える"StatefulViewController"

DMMポイントクラブのiOSのUI更新を支える"StatefulViewController"

  • このエントリーをはてなブックマークに追加

本記事は DMMグループ Advent Calandar 2021、20日目の投稿になります。

DMMポイントクラブグループ モバイルアプリチーム iOSエンジニアの中尾がお送りします。
DMMへは今年4月に新卒入社したばかりの若輩者ですが、宜しくお願いします。

まず始めにDMMポイントクラブというサービスについてご紹介です。

2021年4月にiOS/Androidで本格リリース、Web版を2021年9月にリリースした、出来立てほやほやのDMMの新サービスです。
DMMポイントを貯めて、使って、管理できる、DMMのポイントサービスとなってます。 興味がある方は是非チェックしてみてください。

lp.pointclub.dmm.com

DMMポイントクラブアプリの2021年は、とにかく機能拡充に取り組んだ年でした。 プロダクトのフェーズとしてはMVPを経てPMFを目指す段階でしたので、この8ヶ月間で大きめの新機能を4~5個ほど、細かな修正も多々リリースしました。

機能追加の際には画面も追加することがほとんどだと思います。ですが、DMMポイントクラブのiOS開発ではViewControllerのローディング周りのUI更新ロジックを、UIViewControllerのサブクラスとして切り出し共通化しており、この共通クラスを各画面で継承し利用しています。

この共通クラスをStatefulViewControllerと呼んでいますが、このクラスのおかげで実装時にViewControllerのUI更新を複雑に考えることなく、シンプルに可読性高く実装できているなと感じているので、今回の記事でご紹介します。

目次

DMMポイントクラブ iOSのアーキテクチャ

前提としてDMMポイントクラブのiOS版の設計について説明しておくと、Combineを使ったVIPERアーキテクチャにFlowControllerパターンを加えた設計を採用しています。

※この記事では本筋と逸れるため、VIPERアーキテクチャ、FlowControllerパターンについての詳細な説明はしません

今回はUI更新周りの話なので、ViewとPresenterに絞ってお話しします。

VIPERアーキテクチャでのViewとPresenterの定義は、

  • View ... UI更新、Presenterへのユーザーアクションの通知を担う
  • Presenter ... Viewへ更新の通知、Interactorへのデータ処理、Routerへの画面遷移を依頼するといったプレゼンテーションロジックを担う

という感じかと思います。

これらViewとPresenterのDMMポイントクラブ iOSでの設計を紹介すると、以下の図のような形で運用しています。

f:id:dmmadcale2021:20211130160537p:plain

Viewからはユーザからのアクションを定義したMessageをPresenterへ通知し、PresenterはそのMessageに応じて特定の処理(データの取得や画面遷移など)を行います。

PresenterはViewの状態を定義した「ViewState」をCurrentValueSubjectとして持っており、Viewがそれを購読しています。 PresenterがViewStateを更新し次第、CurrentValueSubjectがViewStateの新しい値をViewへ伝え、Viewが値を更新する、というような流れです。

StatefulViewControllerとは

DMMポイントクラブ iOSで使っているStatefulViewControllerの設計にあたって一部参考とさせていただいた、aschuch/StatefulViewControllerというOSSがあります。

github.com

aschuch/StatefulViewControllerでは、アプリが通信する際にViewControllerやViewでユーザーに通知する必要がある「状態」があるとし、以下の4つとして定義しています。

  • Loading: The content is currently loaded over the network.
  • Content: The content is available and presented to the user.
  • Empty: There is currently no content available to display.
  • Error: An error occurred whilst downloading content.

これらの4つの状態は通信の際に以下チャート図のフローを経て決定されるとしています。

https://github.com/aschuch/StatefulViewController/raw/master/Resources/decision_tree.png

通信開始時

  • 表示するデータがある ... コンテンツを表示
  • 表示するデータがない ... ローディングを表示

通信終了:エラー

  • 表示するデータがある ... コンテンツを表示した上で、エラーを表示する
  • 表示するデータがない ... エラーを表示する

通信終了:正常

  • 表示するデータがある ... コンテンツ表示
  • 表示するデータがない ... 空を示すViewを表示する

というような感じです。aschuch/StatefulViewControllerは、これらの通信時におけるViewControllerのUI更新をProtocolとして提供しています。

DMMポイントクラブ iOSでのStatefulViewController

画面実装において多くの場合、前述の4つの状態は考慮する必要があると思います。

DMMポイントクラブ iOSでも通信は行いますので、通信を行うViewController共通の概念とし、ローディングの表示/非表示のロジックも含めた上で、Protocolではなくクラスとして、UIViewControllerを拡張する形で取り入れています。

DMMポイントクラブにおけるStatefulViewControllerは、以下のようにPresenterから受け取ったViewStateの変更を元に、必要に応じて呼び出すUI更新のロジックを切り替えます (ViewStateについては後述)。

詳しくは最後に載せているサンプルを見ていただければと思いますが、onEmpty()などは、StatefulViewControllerを継承するViewControllerがoverrideし利用します。

open class StatefulViewController<T, E>: UIViewController where T: Equatable, E: Error {
    public var viewState: ViewState<T, E> = .empty {
        didSet {
            switch viewState {
            case .empty:
                onEmpty()
            case .loading(let value):
                onLoading(value)
            case .loaded(let value):
                onLoaded(value)
            case .failed(let value, let error):
                onFailed(value, error)
            }
        }
    }
 
    open func onLoading(_ value: T?) {
        ...
    }

    open func onLoaded(_ value: T) {
        ...
    }

    open func onFailed(_ value: T?, _ error: E) {
        ...
    }

    open func onEmpty() {
        ...
    }

    open func onReload() {
        ...
    }

    ...
}

ViewStateについて

ViewStateは「Viewの状態」を示すモデルです。 これを状態の更新がかかる度にスナップショットとしてViewに適用することで、Viewの状態をトレーサブル且つ一貫性を持たせています。

ローディングのフラグやエラー、データなどViewの状態をPresenterやViewModelの単純なプロパティとして持たせ、それをViewが参照する場合、UI更新時に各状態において意識する必要がない値を意識しなければならなくなります。ですが、このViewStateはEnumのAssociatedValueを用いて各状態において必要な値(更新したデータ、あるいはエラー)を渡しているので、その状態に必要ない値へのアクセスを禁止でき、意識する必要がなくなります (.loaded状態において、Errorを意識する必要がないなど)。

public enum ViewState<T, E>: Equatable where T: Equatable, E: Error {
    case empty
    case loading(T?)
    case loaded(T)
    case failed(T?, E)

    public var value: T? {
    ...
    }

    public var error: E? {
    ...
    }

    ...
}

ViewStateの設計はStatefulViewControllerと併せ、こちらの記事を設計の参考とさせていただきました。

jobandtalent.engineering

詳細な実装は省かせていただきますが、先ほどDMMポイントクラブ iOSでのViewとPresenterの関係についてお話しした通り、Presenterが通信前にViewStateを通じてViewに.loadingを通知したり、Interactorから受け取った結果に応じて、.loaded, .failedを通知します。

こうした排他的な状態の定義と切り替えによって、状態における不確実性を少なくしています。

StatefulViewController: サンプル

実際にStatefulViewControllerを継承して実装を行う場合は以下のような実装になります。

ViewController

final class SampleViewController: StatefulViewController<SampleStateValue, Error> {
    override func viewDidLoad() {
        super.viewDidLoad()  
   // Presenterが保持するViewStateの変更を購読
        dependency.presenter.state
            .receive(on: dependency.mainQueue)
            .assign(to: \.viewState, on: self)
            .store(in: &cancellables)
    }

    override func onLoaded(_ value: SampleStateValue) {
        super.onLoaded(value)
   // 取得したデータを利用してUIを更新する処理
    }

    override func onEmpty() {
        super.onEmpty()
   // データが空であった場合のUI表示処理
    }

    override func onFailed(_ value: SampleStateValue?, _ error: Error) {
        super.onFailed(value, error)
   // エラーが返ってきた場合のUI表示処理(アラート等)
    }
    ...
}

ViewState

typealias SampleState = ViewState<SampleStateValue, Error>

struct SampleStateValue: Equatable {
    // 画面固有の状態
    // - 表示するデータなどを持つ
}

Presenter

final class SamplePresenter: SamplePresenterProtocol {
    let state = CurrentValueSubject<SampleState, Never>(.empty)
    ...
}

終わりに

本記事ではDMMポイントクラブのiOS開発で運用している、通信時におけるViewControllerの状態管理を担う「StatefulViewController」についてご紹介しました。

iOS開発におけるUI更新周りの設計の一助となれば幸いです。

現在弊社では、いくつかのポジションにてiOSエンジニアの募集をかけております。ご興味がある方はぜひ採用情報をチェックしてみてください。

dmm-corp.com