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風アーキテクチャ
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で使われているライブラリ(ライセンス表記から抽出した)
責務を明確にし関心事を分離した構造
ポイントは、ViewとLogicはまったく依存しあっておらず、データやステートを介してBindingにより連携するという点です。
View/ViewControllerはデータやステートがどのように値が生成・更新されるかは関知しません。
ViewModelのロジックはViewについては関知しません。Viewの参照は持ちません。
各レイヤーおけるインスタンスの関係性
参照関係を明確にすることでプログラマーが全体の挙動を把握しやすくなります。一番複雑なコードになりがちな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 レイヤー
- 永続化またはキャシュ可能なデータを保持する
- 永続化またはキャシュのためのメカニズムを保有する
通信で一覧のデータ取得し表示する例
リクエスト処理フロー
iOSアプリの中でよくある、通信でデータを取得して結果を画面に表示するまでの一連の処理フローです。
- サーバのAPIをコール(Alamofire)
- JSONを取得してModelのインスタンスにマッピング(SwiftyJSON)
- Modelのインスタンスを永続化(Realm)
- 1.2.3の一覧の処理をプロミスで(SwiftTask)
- プロミスのリザルトからViewDataBindingされた変数のデータやステートを更新、この時点でModelのインスタンスを破棄
- 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を表示します。
■ itemsが1件以上の場合
items
が1件以上なのでitems
の内容をTableViewで表示しつつ、各requestListState
に応じた表示を行っています。
-
requestListState
変数に.None
を代入すると、TableViewを表示したままです。 -
requestListState
変数に.Requesting
を代入すると、TableViewを表示しつつ、ステータスバーのインディケーターを表示します。 -
requestListState
変数に.Error
を代入すると、TableViewを表示しつつ、ステータスバーにエラーメッセージを表示します。
実装コード
■ 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を制御する例
ユーザによる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のisSignInInput
をsignUpButton
のEnabled
に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です。アプリはこちらからダウンロード頂けます。開発のスケジュール、リソース、アプリの規模や進め方などにいてiOSを中心に紹介した記事を別途Qiitaに書いてます。是非合わせて参照ください。
Qiitaの記事 メッセージングアプリSync開発の舞台裏(iOS)
参考
SwiftBond
MVVM
- Introduction to MVVM
- MVVM入門(objc.io #13 Architecture 日本語訳)
- MVVM in iOS
- リアクティブプログラミングとMVVMパターンについて