この記事はCyberAgentエンジニアAdvent Calendar 2015の20日目の記事です。
はじめに
こんにちは。サイバーエジェントの新卒のギア@nghialvと申します。現在,AmebaFRESH!という新規生放送サービスのチームにiOSエンジニアやっています。
私は入社した時に,多くのユーザーに使われているAmebaアプリチームに入りました。Amebaアプリはなん年前から作成されましたので,今までの他のiOSアプリと同じでMVCモデルに基づいて設計されています。しかし,アプリがどんどん大きくしてMVCモデルの問題点を感じるようになりました。
4ヶ月前にAmebaFRESH!の新規サービスに移動して,MVVMモデルとReactiveCocoaを検討して,導入しました。
今日MVVMモデルとReactiveCocoaについて説明させていただきます。
目次
- MVCからMVVMに移動
- Water pipesからReactiveCocoaを理解
- ReactiveCocoaとMVVMを組み合わせるサンプル
MVCからMVVMに移動
上の図は一般的なMVCモデルの仕組みです。MVCモデルはModel, View, ViewControllerの3つの部分に分割しています。
このモデルは以下の2つ問題点が存在していることがわかりました。
まずは,ViewControllerはModelとViewを持っていて,ロジックの処理は基本的にViewControllerに配置されています。アプリの成長と共に大規模なViewControllerがどんどん増えていきます。(この理由で皆んなよくMVCはMassive ViewControllerと呼んでいますね)iOSエンジニアの皆さんはこの問題をあったことがありましたね。
次は,ViewControllerはViewを持っていますので,Viewに依存していて,ViewControllerのテスト書くのはなかなか難しいと思います。
そこで,テスト書ける部分は主にModelとUtilityの周りだけになります。
それらの問題点でMVVMモデルの導入を検討しました。
上の図により,ViewModelの部分が追加されました。
ViewModelはModelオブジェクトのデータを基にViewに食わせる値を生成するロジック(プレゼンテーションロジック)やViewの状態を持ちます。
iOSのViewControllerはViewと同じ役割になっています。
さらに,MVCと異なって,data binding概念を入っています。
ViewModelはViewのインスタンスを持ちではなく,data bindingでViewModel側はデータの変更がありましたら,Viewに通知を送ります。
じゃ、MVVMモデルの良い点?
- ViewModelはよく変われるViewを持っていないので,モデルみたいにテスト書くことは容易になります。
- 大規模なViewControllerがなくなります。
- ViewはViewModelにバインディングする仕組みなので,Viewのリセットなどを行いたい場合は単純新しいViewModelインスタンスを生成して,再バインディングすることで,Viewがリセットされます。ステートの管理が楽になります。
(これは自分が感じできた良い点ですが,もし何かわかりましたら,追加お願い致します。)
上はバインディングについて何回も言いましたが,具体的はどんな感じって疑問している人もいるでしょう。
それはReactiveCocoaの役割ですね。
Water pipesからReactiveCocoa理解
ReactiveCocoaの定義は以下のとおりですね。
ReactiveCocoa (RAC) is a Cocoa framework inspired by Functional Reactive Programming. It provides APIs for composing and transforming streams of values over time.
実は最初私はこの定義を呼んだ時に,ReactiveCocoaはなんのものか想像できなかったです。
しかし,使ってみて,この定義の文は完璧だとわかるようになりました。
また,ReactiveCocoaの仕組みはWater pipesの仕組みと全く同じかなと考えていますよ。
じゃ次はReactiveCocoaの定義の文とWater pipesを用いて,ReactiveCocoaを説明させていただきます。これを読んだ後に絶対ReactiveCocoaはなんのものか想像できるようになると思います。
定義の文に注目したいのは「Stream」「Transforming」「Composing」の3つの言葉です。
Stream
let (signal, sink) = Signal<String, ErrorType>.pipe()
signal
.observe { event in
// hi
}
sink.sendNext("hi")
上の図をみると説明がなくてもなんとなくイメージできましたね。
eventをsinkに入れて,observerがそのイベントを受け取ることができます。
全くWater pipesの仕組みと似ているでしょう。
もちろん,水は上に流すことができないですが,イベントはすべての方向に行けます。(笑)
let (signal, sink) = Signal<String, ErrorType>.pipe()
signal
.observe { event in
// hi
}
signal
.observe { event in
// hi
}
sink.sendNext("hi")
もう一点注意が必要なのは,observeする前のイベントを受け取ることができないです。
Transforming
次はtransform
ですね。また,以下の図をみると説明は何もなくてもすぐわかります。
let (signal, sink) = Signal<String, ErrorType>.pipe()
signal
.map { str in str.uppercaseString }
.observe { event in
// “HI"
}
sink.sendNext("hi")
Transforming
はsignal
から別のsignal
に変換することです。
全くWater pipesの仕組みと似ているでしょう。
ReactiveCocoaはいろいろなtransformを提供しています。私は以下のようなものをよく使っています。
map
, flatMap
, filter
, reduce
, collect
, takeUntil
, mapError
, skipRepeats
, throttle
, skip
, observeOn
...
もちろん,自分は新しいtransform追加することは簡単です。
Composing
数学にcompose
は以下のとおり定義されていますね。
• compose <A, B, C> (A -> B, B -> C) -> A -> C
f1: A -> B
f2: B -> C
f3: C -> D
f = f1・f2・f3
=> f: A -> D
let searchResults = searchStrings
.flatMap(.Latest) { (query: String) -> SignalProducer<(NSData, NSURLResponse), NSError> in
let URLRequest = self.searchRequestWithEscapedQuery(query)
return NSURLSession.sharedSession().rac_dataWithRequest(URLRequest)
}
.map { (data, URLResponse) -> String in
let string = String(data: data, encoding: NSUTF8StringEncoding)!
return self.parseJSONResultsFromString(string)
}
.observeOn(UIScheduler())
.startWithNext { results in
print("Search results: \(results)")
}
同じ意味で,ReactiveCocoaはsignal
とtransform
を何回も組み合わせることが可能であり,新しいsignal
を生成することができます。
全くWater pipesの仕組みと似ているでしょう。
ReactiveCocoaを使うとある時にアプリは以下のようなかんじになりますね。(笑)
はい,ここまで,Water pipesを使ってReactiveCocoaを説明しました。
もちろん,ReactiveCocoaは他の概念もいっぱいありますが,基盤の概念は上のとおりです。
ReactiveCocoaとMVVMを組み合わせるサンプル
モバイルアプリではよくあるリスト画面をReactiveCocoaとMVVMモデルでの実装を紹介させていただきます。
実は今のプロジェクトでCellに対するCellModelという概念も導入していて,TableViewやCollectionViewのCellとCellModelを管理するため,自分が開発しているHakuba、Sapporoを使っています。
class ProgramsViewModel {
private var offset = 0
private (set) lazy var refreshAction: Action<Int, [ProgramCellModel], ApiError> = { [unowned self] in
return Action { count in
Api.Programs
.getPrograms(count: count)
.map(ProgramCellModel.init)
}
}()
private (set) lazy var loadmoreAction: Action<(Int, Int), [ProgramCellModel], ApiError> = { [unowned self] in
return Action { offset, count in
Api.Programs
.getPrograms(offset: offset, count: count)
.map(ProgramCellModel.init)
}
}()
func refresh() {
refreshAction
.apply(25)
.takeUntil(willDeinitProducer)
.on { [unowned self] in
self.offset = $0.count
}
.start()
}
func loadmore() {
loadmoreAction
.apply(offset, 25)
.takeUntil(willDeinitProducer)
.on { [unowned self] in
self.offset += $0.count
}
.start()
}
}
class ProgramsViewController: UIViewController {
@IBOutlet private weak var collectionView: UICollectionView!
private lazy var sapporo: Sapporo = Sapporo(collectionView: self.collectionView)
let viewmodel = ProgramViewModel()
let viewmodelDisposable = ScopedCompositeDisposable()
override func viewDidLoad() {
super.viewDidLoad()
bindViewModelSignals()
sapporo.loadmoreHandler = { [weak self] in
self?.viewmodel.loadmore()
}
viewmodel.refresh()
}
}
// MARK: - Private methods
private extension ProgramsViewController {
func bindViewModelSignals() {
viewmodelDisposable += viewmodel.refreshAction.values
.observeOn(uiScheduler)
.observeNext { [unowned self] cellmodels in
self.sapporo[ProgramsSection]
.reset(cellmodels)
.bump()
}
viewmodelDisposable += viewmodel.loadmoreAction.values
.observeOn(uiScheduler)
.observeNext { [unowned self] cellmodels in
self.sapporo[ProgramsSection]
.append(cellmodels)
.bump()
}
viewmodelDisposable += viewmodel.refreshAction.errors
.observeOn(uiScheduler)
.observeNext { [unowned self] error in
// show a reloadable error view
}
viewmodelDisposable += viewmodel.loadmoreAction.errors
.observeOn(uiScheduler)
.observerNext { [unowned self] error in
// show an error message
}
viewmodelDisposable += combineLatest(viewmodel.refreshAction.executing.producer, viewmodel.loadmoreAction.executing.producer)
.observeOn(uiScheduler)
.map { $0 || $1 }
.observerNext { [unowned self] loading in
// show or hide loading indicator
}
}
}
最後に
今日、自分の理解でReactiveCocoaとMVVMモデルを軽く説明しました。
MVVMモデルのサンプルプロジェクトやブログはまだまだ多くないので,時々困りました点もありましたが,
プロジェクトに導入してみて,プロジェクトの設計が綺麗になっていて,毎日楽しく開発できています。
明日はk66dangoさんです。