Help us understand the problem. What is going on with this article?

MVVMをベースに複雑な振る舞いをしっかり把握できるアプリ開発

More than 3 years have passed since last update.

TL;DR

  • 複雑になりがちな構造やコードをシンプルで把握しやすいコードで記述したい
  • MVVMを用いて責務を明確にし関心事を分離した構造にする
  • ViewDataBindingとFRPを用いて時間とともに変化するデータやステートに伴う処理を宣言的に記述し、Viewとデータの動的な変化を相互的に連動させる
  • 上記をSwiftとそのパラダイムを活かしたライブラリ(SwiftBond)を中心に実現する

はじめに

Swiftで新規のアプリを開発することになり、MVVM、FRP、ViewDataBindingの要素技術を活用して開発を行いました。設計やライブラリ選定は2015年5月に行っており実装環境はXcode6.4,Swift1.2になります。Swift2.0以上になるとSwift系ライブラリも大きくインタフェースを変更しているため、ここで紹介しているサンプルコードもそのままでは動作しないことをご留意ください。

解決したい問題とゴール

昨今のアプリ開発は複雑化の一途を辿っており、画面1つをとっても多様なステートを取りつつ様々なユーザのアクションをこなしています。その中で浮き彫りになる以下の課題を解決できないかという試行錯誤からスタートしました。その上でプログラマーがより品質よくメンテナンス性の高いコードを記述できるにようになること、つまりは複雑な要件でも"振る舞いをしっかり把握できる"ことをゴールに捉えています。

■ 課題点

  • ViewControllerに記述するコードが集中してファットコードに陥ると、どのような振る舞いを行うのか把握しにくい
  • 命令的な手続きによるロジックの記述は複雑になるとある時間軸における各ステートにおいて、どのような振る舞いを行うのか把握しにくい
  • AbstractなClassに共通処理を設け継承すると、どのような振る舞いを行うのか把握しにくい
  • ロジックの分岐が網羅されて記述されていないと、どのような振る舞いを行うのか把握しにくい
  • 多様なアプリのステートを複数のフラグ(Boolean)を組み合わせて管理したくない
  • クラッシュしないアプリを作りたい

MVVM風アーキテクチャ

201500825_Sync_iOSの開発舞台裏_key.jpg

MVCもそうですがMVVMは様々なコンテキスト(Web、App、SPA)で多様に解釈されている部分もあると思うので、ここではMVVMを参考にオレオレMVVM風アーキテクチャとして設計しました。各レイヤーの責務(responsibility)の分け方がMVVMと少し異なります。ビジネスロジックは"Modelに記述する"が一般的かもしれませんがViewModelに記述しています。これは画面の表示に紐付いたステートをViewModelで管理しているのですが、ステートによってロジックも影響を受けるのでViewModelにある方が振る舞いを把握しやすかったためです。ViewControllerはViewの保持とViewとViewModelのデータとステートのBindingを主な責務とし、Modelはデータの一時的な保持と永続化を責務としています。

ここで言う画面のステートとは例えばサーバと通信して一覧を表示する画面において以下のようなステートです。SwiftのEnumで実装しています。

  • 初期表示状態
  • 通信中表示状態
  • エラー表示状態
  • データ0件表示状態
  • データn件表示状態

図に記載している主なライブラリです。

  • SwiftBond - データやステートの動的な変化とViewをBindingします。変化をFRPで評価できます。PureSwiftです。
  • Alamofire - HTTPネットワークライブラリです。PureSwiftです。
  • SwiftTask - Promiseライクなフロー制御ライブラリです。PureSwiftです。
  • ReactKit - SwiftTaskを活用して作成されたReactive Programmingライブラリ。PureSwiftです。
  • SwiftyJSON - JSONオブジェクトを扱いやすくするライブラリです。PureSwiftです。
  • SDWebImage - 画像のダウンロードとキャシュを行うライブラリです。ObjCです。
  • Realm - オブジェクトを永続化するライブラリです。ObjCとSwiftのハイブリット実装です。

上記以外にもアプリでは40個ほどのライブラリを利用しています。

@cor0suke_k さんがライセンス表記からなんと手打ちで作成くださいました。
Syncで使われているライブラリ(ライセンス表記から抽出した)

責務を明確にし関心事を分離した構造

201500825_Sync_iOSの開発舞台裏_key.jpg

ポイントは、ViewとLogicはまったく依存しあっておらず、データやステートを介してBindingにより連携するという点です。

View/ViewControllerはデータやステートがどのように値が生成・更新されるかは関知しません。
ViewModelのロジックはViewについては関知しません。Viewの参照は持ちません。

各レイヤーおけるインスタンスの関係性

201500825_Sync_iOSの開発舞台裏_key.jpg

参照関係を明確にすることでプログラマーが全体の挙動を把握しやすくなります。一番複雑なコードになりがちなViewModelから他のレイヤーに対する参照を待たないことで複雑さの軽減になり、ViewModel単体のテスト容易性にも繋がります。

■ View / ViewController レイヤー

  • ViewはUIを提供する
  • ViewControllerはViewとViewModelの間に介在しBindingを行う
  • Viewの参照はViewControllerが保持する
  • ViewModelの参照はViewControllerが保持する

■ ViewModel レイヤー

  • ViewModelは特定範囲の関心事におけるロジック、データ、ステートのまとまり
  • 1つのViewModelを複数のViewControllerから参照することもある (画面横断的な関心事の場合のViewModel)
  • 複数のViewModelを1つのViewControllerから参照することもある (細かく責務でViewModelを分割した場合)
  • ViewModelの一部のロジックを共通的なクラスとして切り出してViewModelから共通的なViewModelを参照することある
  • ViewModelはViewやViewControllerの参照は保持しない
  • データをプレゼンテーション用に変換して保持する
  • Modelの参照を保持しない

■ Model レイヤー

  • 永続化またはキャシュ可能なデータを保持する
  • 永続化またはキャシュのためのメカニズムを保有する

通信で一覧のデータ取得し表示する例

リクエスト処理フロー

201500825_Sync_iOSの開発舞台裏_key.jpg

iOSアプリの中でよくある、通信でデータを取得して結果を画面に表示するまでの一連の処理フローです。

  1. サーバのAPIをコール(Alamofire)
  2. JSONを取得してModelのインスタンスにマッピング(SwiftyJSON)
  3. Modelのインスタンスを永続化(Realm)
  4. 1.2.3の一覧の処理をプロミスで(SwiftTask)
  5. プロミスのリザルトからViewDataBindingされた変数のデータやステートを更新、この時点でModelのインスタンスを破棄
  6. 5.の更新により動的にステートに応じたデータを画面上に表示(SwiftBond)

ViewBinding (SwiftBond)

上記のフローの中の、データを画面に表示する部分のViewBindingについて解説します。

通信によりデータであるitems変数とステートであるrequestListState変数を扱います。items変数は配列、requestListState変数はEnumになります。EnumであるrequestListStateの値を変更するとitemsの件数を鑑みて画面の表示内容が自動的に切り替えます。

■ itemsが0件の場合

itemsが0件なのでrequestListStateに応じて以下のViewを表示するようにしています。

  • requestListState変数に.Noneを代入すると、自動的に画面はNoDataViewを表示します。
  • requestListState変数に.Requestingを代入すると、自動的に画面はIndicatorViewを表示します。
  • requestListState変数に.Errorを代入すると、自動的に画面はRetryViewを表示します。

201500825_Sync_iOSの開発舞台裏_key.jpg

■ itemsが1件以上の場合

itemsが1件以上なのでitemsの内容をTableViewで表示しつつ、各requestListStateに応じた表示を行っています。

  • requestListState変数に.Noneを代入すると、TableViewを表示したままです。
  • requestListState変数に.Requestingを代入すると、TableViewを表示しつつ、ステータスバーのインディケーターを表示します。
  • requestListState変数に.Errorを代入すると、TableViewを表示しつつ、ステータスバーにエラーメッセージを表示します。

201500825_Sync_iOSの開発舞台裏_key.jpg

実装コード

■ ViewModel

items変数とrequestListState変数をSwiftBondのDynamic型で定義します。Dynamic型で変数を定義するとSwiftBondの機能を介してBindingすることが可能となります。Dynamic型の値が更新されると動的にBindingした先に変更が通知されView変更するしたり任意の処理などが行えます。Dynamic型は複数のDynamic型の値を組み合わせてFRPのように記述して評価することができます。

// 通信の各状態をEnumで表現
enum RequestListState {
    case None
    case Requesting
    case Error
}

// ここではRequestListViewModelという名前のViewModelとしてクラスを定義
// Generics<T>を取ることで汎用的に配列のデータのクラスを扱えるようにしている
final class RequestListViewModel<T> {
    // T型のDynamicArrayとして定義し、初期値にDynamicArrayのインスタンスを代入
    // T型はData用のViewModelを想定
    let items: DynamicArray<T> = DynamicArray([])

    // RequestListState型のDynamicとして定義し、初期値に.Noneを代入
    let requestListState = Dynamic<RequestListState>(.None)

    // 上記2つの変数を元にComputed PropertyでDynamicを返すロジックでFRPとして評価される
    // itemsとrequestListStateの状況により、各Viewを表示するか判断している
    var indicatorViewHidden: Dynamic<Bool> {
        let a = requestListFirstState.map { $0 != RequestListState.Requesting }
        let b = items.map { count($0) > 0 }
        return reduce(a, b) { $0 || $1 == true }
    }
    var retryViewHidden: Dynamic<Bool> {
        let a = requestListFirstState.map { $0 != RequestListState.Error }
        let b = items.map { count($0) > 0 }
        return reduce(a, b) { $0 || $1 == true }
    }
    var noDataFirstViewHidden: Dynamic<Bool> {
        let a = indicatorViewHidden.map { $0 == false }
        let b = requestListFirstState.map { $0 == .Error }
        return reduce(a, b, c) { $0 || $1 == true }
    }

    // 通信の開始、正常完了、異常完了でrequestListStateを更新
    // 正常完了時にitemsを更新
    typealias RequestTask = Task<Progress, [T], NSError>
    func request(URL: NSURL) -> RequestTask {
        self.requestListState.value = .Requesting
        let task = RequestManager.request(URL).success { [weak self] (items: [T]) -> Void in
            self?.items.setArray(items)
            self?.requestListState.value = .None

        }.failure { [weak self] (errorInfo: RequestTask.ErrorInfo) -> Void in
            self?.requestListState.value = .Error
        }
        return task
    } 
}    

補足

Dynamic型は保持している値を変更できるので、一見varで定義する必要があるように思えますが、Dynamic型の変数自体を変更しているわけではなく、内部で保持している値を変更しているのでletで記述できます。

■ ViewController

ViewControllerでViewModelのDynamic型の変数とViewをBindingします。Bindingを記述が宣言的なので、何が変化すると何がどうなるのか把握しやすくなります。またViewControllerの記述量を抑えることにも貢献できます。

final class ContactsViewController: UIViewController {
    let tableViewDataSourceBond: UITableViewDataSourceBond<ContactCell>!
    let viewModel = RequestListViewModel<ContactViewModel>()
    let indicatorView = InstantiateFromNib(IndicatorView)
    let retryView = InstantiateFromNib(RetryView)
    let noDataView = InstantiateFromNib(NoDataView)

    override func viewDidLoad() {
        super.viewDidLoad()

        // Dynamic型のBoolean変数とViewのHiddenをBinding
        viewModel.indicatorViewHidden ->> indicatorView.dynHidden
        viewModel.retryViewHidden ->> retryView.dynHidden
        viewModel.noDataFirstViewHidden ->> noDataView.dynHidden
        // Dynamic型のRequestListState変数とBond型をBinding
        // 任意の処理を行いた時に便利
        viewModel.requestListState ->| stateChangedObserver

        // DynamicArray型とTablewViewのDatasourceをBinding
        // 各要素(ContactViewModel)とTablewViewをCellをBinding
        tableViewDataSourceBond = UITableViewDataSourceBond(tableView: tableView)
        viewModel.items.map { (vm: ContactViewModel, index: Int) -> ContactCell in
            let cell = tableView.dequeueReusableCellWithIdentifier(ContactCell.identifier, 
                         forIndexPath: NSIndexPath(forRow: index, inSection: 0)) as! ContactCell
            vm.name ->> cell.nameLabel
            vm.desc ->> cell.descLabel
            vm.avatarImage ->> cell.avatarView.avatarImageView
            vm.fetchAvatarImageIfNeeded()
            return cell        
        } ->> tableViewDataSourceBond        
    }

    // NetworkActivityIndicatorVisibleをrequestListStateの状態により表示切り替え
    lazy var stateChangedObserver = Bond<RequestListState> { [weak self] state in
        switch state {
        case .Requesting:
            UIApplication.sharedApplication().networkActivityIndicatorVisible = true
        default:
            UIApplication.sharedApplication().networkActivityIndicatorVisible = false
        }
    }    

ユーザの入力に応じてValidationとボタンのEnableを制御する例

201500825_Sync_iOSの開発舞台裏_key.jpg

ユーザによるEmailとPasswordの入力をValidationチェックの上、Sign UpボタンのEnableを動的に変更する例を解説します。

実装コード

■ ViewModel

email変数とemail変数をSwiftBondのDynamic型で定義します。この変数の値が変更されると動に的連動してValidatorの評価を行うComputed PropertyなNSErrorのDynamic型を定義します。最後にValidatorの評価が変更されるとSign Upボタンが押下可能かBooleanのDynamic型で定義しています。

final class SignUpViewModel {
    let email = Dynamic<String>("")
    let password = Dynamic<String>("")
    var emailError: Dynamic<NSError?> { return email.map {Validator.Email($0).validate()} }
    var passwordError: Dynamic<NSError?> { return password.map {Validator.Password($0).validate()} }  
    var isSignInInput: Dynamic<Bool> {
        let isEmailValid = emailError.map { nil == $0 }
        let isPasswordValid = passwordError.map { nil == $0 }
        return reduce(isEmailValid, isPasswordValid) { $0 && $1 == true }
    }

■ ViewController

TextFieldに対するユーザの入力をViewModelのemail, password変数にBindingしています。ユーザが入力を行う度に動的に値が更新されます。ViewModelのisSignInInputsignUpButtonEnabledにBindingしています。isSignInInputが変更されると動的に連動してボタンのEnabledも更新されます。

final class SignUpViewController {
   override func viewDidLoad() {
        super.viewDidLoad()       
        emailTextField ->> viewModel.email
        passwordTextField ->> viewModel.password
        viewModel.isSignInInput ->> signUpButton.dynEnabled

■ Validator

NGRValidatorというライブラリでValidationを行っています。Email形式のチェックや最小文字数のチェックを記述しやすいです。エラーに対するエラーメッセージも定義できます。

enum Validator {
    case Email(String?)
    case Password(String?)

    func validate() -> NSError? {
        switch self {
        case .Email(var value):
            return NGRValidator.validateValue(value, named: "Email") { 
                (validator: NGRPropertyValidator!) in
                    validator.required().msgNil(LocalizedString("is required."))
                    validator.syntax(NGRSyntax.Email).msgWrongSyntax(NGRSyntax.Email, "is not valid syntax.")
            }
        case .Password(var value):
            return NGRValidator.validateValue(value, named: "Password") { 
                (validator: NGRPropertyValidator!) in
                    validator.required().msgNil("is required.")
                    validator.minLength(6).msgTooShort("is too short.")
            }

共通処理のために継承をしない

ViewController, View, ViewModel, ModelにおいてAbstractレイヤーを設けない(継承しない)方針です。

共通処理を継承すると以下の問題があります。

  • どのような振る舞いを行うのか把握しにくくなる
  • 共通化したコードを改修するときに影響範囲がわかりにくく変更しにくい

とくにViewControllerはAbstractなViewControllerでロジックの共通化を行わずに、常にUIViewControllerやUITableViewControllerから継承します。

yimajo先生も語っておられます。iOSアプリの設計でBaseViewControllerのようなのは作りたくない

共通化したいコードは以下の活用を検討します。

  • Delegate
  • Protcol
  • Generics
    • Model を任意のProtocolを準拠させてGenericsでロジックを共通化
  • Aspect
    • ロギングやAnalytics送信
  • Class
    • 共通処理をClassとして切り出し

分岐網羅する

Swiftで安全のためにOptional Bindingでif分を記述すると、到達不可能だと思われる分岐が発生したりします。これはプログラマーが到達不可能と判断し担保したに過ぎず、もしかしたら到達するときが来るかもしれません。そのため実行時に到達不可能な分岐に達した場合は、念のためサーバにイベント情報として送信してプログラマーが検知できるようにして分岐網羅するようにしています。

以下の例では、Realmデータベースの任意のデータを更新する処理ですがデータが存在することが前提で記述しています。基本的にこのタイミングでnilになることはまずないはずなのですが、万が一nilになった場合はサーバーにその旨を送っています。NSErrorUnreachableCodeは独自にNSErrorクラスを拡張して定義したErrorで、エラーが発生したクラス名、メソッド名、行番号、アプリのバージョンなどの情報を保持しています。

    func updateRead() {
        Realm().write {
            if let channel = Channel.find(channelId) {
                channel.isRead = true
                Realm().add(channel, update: true)
            } else {
                let error = NSErrorUnreachableCode(self.dynamicType)
                TrackingManager.sendEventTrackingError(error: error)
            }
        }
    }

作ったアプリについて

Sync___成果を生み出すグループメッセージアプリ.jpg

今回の試みは以下のアプリ開発で行いました。

ビジネスシーンで使えるメッセージングサービスSyncです。アプリはこちらからダウンロード頂けます。開発のスケジュール、リソース、アプリの規模や進め方などにいてiOSを中心に紹介した記事を別途Qiitaに書いてます。是非合わせて参照ください。

Qiitaの記事 メッセージングアプリSync開発の舞台裏(iOS)

参考

SwiftBond

MVVM

FRP

susieyy
フリーランス - スタートアップエンジニアリングアドバイザー - iOS技術顧問 - プロトタイプ開発
https://susieyy.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした