MVVMはフロントエンドエンジニアにとっては重要なデザインパターンです。iOSアプリではオブジェクトがやりとりする方法がたくさんあります。デリゲートやコールバック、Notificationです。このSwift Language User Groupの講演では、 Max Alexander がRxSwiftを使った3つの簡単なパターンで開発のプロセスをストリームとして扱う方法をお話します。MVVMの基本からカスタムObserverの作り方、API論争、並列処理やDispatch Queueを使った手動呼出まで扱っています。あなたのコードはあまりにすっきりしてしまって、仕事を辞める際の引き継ぎが簡単になってしまうでしょうね。
Introduction (00:00)
Maxです。RxSwiftを使ったMVVMパターンについてお話します。最初はMVVMについてお話します。コードをいくつか見てみましょう。
Massive View Controller (00:51)
MVVMはMVCパターンの問題(みんな”Massive View Controller”とジョークを言いますが)を解決しています。多くのエンジニアがView Controllerから始めるケースを良く見かけます(これはいいことです。新しいプロジェクトを始めるとすでにView Controllerは含まれているので)。どんどん進みます。止まりません。数年は止まりません。するとレガシーなアプリが出来上がります。
こうなると新しいエンジニアが入ってくるのが大変です。みんなどの関数を呼んでいて、データはどこにあって、サービスクラスはどこにあって、UIすらどこにあるのかわからなくなっているからです。
View Controllerをゴリゴリ進めるのではなく、Model、View、ViewModelを使いましょう。
View (01:37)
- UIButton
- UITextField
- UITableView
- UICollectionView
- NSLayoutConstraints
これはView Controllerを2つの違うクラスに分割する方法です。Viewは普通のもので、Interface Builderで作るでしょう。UITextField
やUITableView
には制約をつけるでしょう。iOSやMacアプリの開発をしたことがある人なら、これはよく行っていることですね。UIViewController
だと以下のようになりますね。ここから始めましょう。
class LoginViewController : UIViewController {
@IBOutlet weak var usernameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var confirmButton: UIButton!
}
それからモデルがあります。
Model (02:10)
「モデル」は抽象的な言葉です。違うクラスからやってくるデータのようなものです。
- CLLocationManager
- Alamofire
- Facebook SDK
- Database like Realm
- Service Classes
Facebook APIやTwitter APIのように、違う形、異なる形式になります。Realmのようなデータベース、ジェネリクスのあるサービスクラスなどです。多くの人はこれをPOJO(”Plain Old Java Object)だったりPOCO(”Plain Old Class Object”)と言ったりします。しかし、それは問題ではありません。つまり、どこかから来るデータがあるということです。そして、モデルクラスには、import UIKit
は見つからないでしょう。
ViewModel (02:55)
ViewModelの役割は、
- データの準備
- データの操作
を行うことです。ViewとModelの間に入ります。Login View ControllerにはusernameTextFieldとpasswordTextFieldというメンバ変数があります。それからAlmofireやFacebookAPIClientのようなものを呼び出す関数があります。データを準備して、呼び出しているのがわかると思います。
すべての実装はお見せしていませんが、何が行われているのかはおわかりいただけると思います。これでLoginViewController
があって、メンバ変数であるデータを準備すると、LoginViewModel
の準備が整います。
struct LoginViewModel {
var username: String = ""
var password: String = ""
func attemptToLogin() {
let params = [
"username": username,
"password": password
]
ApiClient.shared.login(email: email, password: password) { (response, error) in
}
}
}
UIViewController
とUIView
(見た目によりますが)があります。Subviewのメンバ変数となります。structをつくります(ある時点のデータなので、structです。classとして使用しないでください)。
class LoginViewController : UIViewController {
@IBOutlet weak var usernameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var confirmButton: UIButton!
//viewModel is just a member variable here.
var viewModel = LoginViewModel()
override func viewDidLoad(){
super.viewDidLoad()
}
}
ViewModel
はメンバ変数を持った構造体です。サービスクラスとやり取りします。ModelはCLLocationManager
のラッパーやストアとやりとりするAPIを持っています。このUIとのやり取りにはViewModelにあるデータを取得する関数を使うことができます。コードの最後では、ViewModelの生成の仕方と関数の呼び出し方をお見せしました。
UIとViewModelをつなぐのは、みなさんそれぞれ違った方法を取るので、単純ではありません。
class LoginViewController : UIViewController {
@IBOutlet weak var usernameTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var confirmButton: UIButton!
//viewModel is just a member variable here.
var viewModel = LoginViewModel()
override func viewDidLoad(){
super.viewDidLoad()
usernameTextField.addTarget(self, action:
#selector(LoginViewController.usernameTextFieldDidChange:_),
forControlEvents: UIControlEvents.EditingChanged)
passwordTextField.addTarget(self,
action: #selector(LoginViewController.passworldTextFieldDidChange:_),
forControlEvents: UIControlEvents.EditingChanged)
}
func usernameTextFieldDidChange(textField: UITextField){
viewModel.username = textField.text ?? ""
}
func passworldTextFieldDidChange(textField: UITextField){
viewModel.password = textField.text ?? ""
}
func confirmButtonTapped(sender: UIButton){
viewModel.attemptLogin()
}
}
IBOutlet
になっているUIKitクラスの変数とViewModelがあります。viewDidLoad
では、Interface Builderで即席のイベントハンドラを使うことができます。しかしusernameTextFieldに変更があったときには、viewModel.username
にテキストを入れて変更したいです。passwordも同じです。確認ボタンをタップすると、それを接続して、ViewModelでログインを試みます。
これは大きな部分が欠けているのであまり楽しくありませんでした。 変化するイベントに反応するUIです。 ViewとViewModelのつなぎ方はお話しました。ですが、View自体を更新する必要があります。インジケータを出したり、確認ボタンの使用可否を確認したりです。
例えば、フォームのバリデーションはデータに行き、その後に戻ります。この部分は難しいです。 ViewModelがUIに戻ってくるこの部分は、誰もがやり方を変えることです。これを行うための方法がひとつあります。
struct LoginViewModel {
var username: String = "" {
didSet {
evaluateValidity()
}
}
var password: String = "" {
didSet {
evaluateValidity()
}
}
var isValid : Bool = ""
func attemptToLogin() {
let params = [
"username": username,
"password": password
]
ApiClient.shared.login(email: email, password: password)
{ (response, error) in
}
}
private func evaluateValidity(){
isValid = username.characters.count > 0
&& password.characters.count > 0
}
}
リアクションするハンドラとしてdidSet
を使います。usernameやpasswordを更新すると、フォームの有効性を評価して、username
やpassword
に値があることを確認します。これを厳密に行うことができます。
isValid
フォームの変更を行えます。ですが、フォームのisDisabled
やisEnabled
のような状態をどうやって戻せばよいでしょうか。
ViewModelはUIKitをインポートしていないことに気をつけてください。UIViewController
への参照はありません。このクラスの役割はデータを準備することです。データを変更することではありません。ViewControllerはViewModelを持っています。長い目でみると、時間とリソースがあればViewModelがtestableにもなります。
ありえないことですが(コードはスライドを御覧ください)、確認ボタンの状態であるisValid
をdidSet
があるにも関わらずisDisabled
に変更したいとします。弱参照のLoginViewControllerを作って、viewDidLoad
でそれを即席でおこない、セットします。それからこの2つのものがevaluateValidityを実行すると、これが変更されます。
良くないシナリオです。MVVMの良いところを活かせていません。「前後の参照をどんどん押していきます」と言うからです。加えて、あるオブジェクトが他のオブジェクトを所有しているので循環参照に陥るという残念な状況になります。
注釈を加えておきたいのですが、username
とpassword
のテキストフィールドの更新があるので、同じ関数を呼び出します。特にクレジットカードのフォームのようにとても大きなものでは、同じ評価サイクルを呼び出すことで、何百ものフォームに入るフォームの数を確認することになります。
didSet
でevaluateValidityを呼ぶデータの流れが2つあります。それが他の変更を行うisValid
を呼び出します。2つの違うイベントをとり、ひとつにまとめ、データに何かを行います。将来的にこれは大いに単純化されます。これはすべて同期のコードです。非同期になるとどうなるのでしょうか?
View ModelのWorst Practice (09:06)
- View Controllerの参照は持たないこと
- UIKitをインポートしないこと。違うファイルで行う。
- UIKitから参照しないこと。(「ボタンの参照が必要だ、ViewModelで参照しよう」と思うかもしれませんが、しないでください)
- (StringやStruct、JSONなどの)データだけにしてください。classは関数型ではありません。
以前みたパターンです。isValid
がUIButtonの状態を更新する必要があります。
struct LoginViewModel {
var username: String = "" {
didSet {
evaluateValidity()
}
}
var password: String = "" {
didSet {
evaluateValidity()
}
}
var isValid : Bool = "" {
didSet {
isValidCallback?(isValid: isValid)
}
}
var isValidCallback : ((isValid: Bool) -> Void)?
func attemptToLogin() {
//truncated for space
}
private func evaluateValidity(){
isValid = username.characters.count > 0
&& password.characters.count > 0
}
}
このクロージャで小さなコールバックを作りました。
class LoginViewController {
@IBOutlet var confirmButton: UIButton!
var loginViewModel = LoginViewModel()
override func viewDidLoad(){
super.viewDidLoad()
loginViewModel.isValidCallback = { [weak self] (isValid) in
self?.confirmButton.isEnabled = isValid
}
}
}
ViewModelが検知できます。コールバックを呼び出して、リスナーに値を返します。このリスナーはView Controllerにあります。LoginViewModelはisValid
というコールバックを持つことになり、値をセットします。ランタイムでこれができてViewController全体の状態をテストする代わりにテストできるのでいいものです。
UIがViewModelとやりとりし、ViewModelがModelとやりとりするような単一方向のデータフローでの問題は、難しい問題です。UIKitからViewModel、Modelへ、そしてViewModelに戻って、どれくらい簡単か分かりました。
ViewModelからUIKitに戻るこの部分は、誰もが別のやり方をするでしょう。これはRxSwiftが解決してくれます。
RxSwift (11:38)
RxSwiftがどのように助けてくれるのでしょうか? JavaScriptの世界から来た人なら、lodash.jsやunderscore.jsのイベントのことです。イベントやデータをまるで配列やコレクションを扱っているかのように演算できます。時間の経過と共に変化するというのは、配列が変化することに似ています。
RxSwiftのplaygroundを用意しました(動画を御覧ください)。Observabeleはコレクション型と言えます。イベントやイベントを放出するソースを放出し、ほぼすべてのものを表すObservableを作成します。簡単です。WebSocketから何か購入するかどうかを教えてくれる、株券のヘルパーのようなものを作成します。それを模倣した何か作ります。
配列のFloatをObservableにし、株価が上昇します。1, 2, 35, 90はFloatです。Playgroundでは、すでに動いています。イベントをサブスクライブでき、イベントを放出し続け、値を得ます。例としてはいいところです。これが入ってくるのを覚えておいてください。文字通りには振る舞いません。例えば、株価は数分おきに更新されません。様々な時間で更新されます。
イベントをサブスクライブして、UIに変更を伝えたいと思います。値段が30以上のときに買いたいとします。来た通りの値段を表示します。これから別の文字列を生成したいです。
新しいObservableのデータにある2番目の文字列がフィルターしたいものです。フィルターが上手く行ったときだけ、このサブスクライブブロックが動作します。RxSwiftではフィルターや、マップなどが可能です。例えば、何かをマップしようとします。為替レートをかけられます。例えば、違う国で何か買おうとして、為替レートがある場合です。この新しいレートを表示します。フィルターを取り除き、すべての単一で異なるイベントに行います。
Observableの簡単なサブクラス(substructのようなもの)があります。Observableはイベントを放出するもので、うれしいことにジェネリクスによって、常に型があります。Variableとよばれるものです。常にstaticに生成できます。プリミティブ型周辺の表現です。ユーザー名とパスワードはObservableです。サブスクライブすることなく、username.value
という形で値を取得するからです。
UIから変更されますが、isValid
の状態も監視したいです。self.username
とself.password
の2つのストリームを作り、変更されたときに評価します。これは combineLatest
と呼ばれるOperatorです。LodashのgroupBy
に近いものです。
何か変更があれば、このReducerブロックが実行され、Booleanが返ってきます。username.character.count
が0より大きくて、password.character.count
も0より大きければ、今べてのdidSet
が取り外され、privateメソッドが取り払われ、データストリームが生成されたことになります。UIから移動し、isValid
がUIに返ります。
よくやるのは、fromUI
やtoUI
というコードを書くことです。もしくはfromViewModel
やtoViewModel
ですね。コードに2つのセクションがあるのはおわかりだと思います。つまり一つはこの方向で、もう一つは逆の方向を示しています。
View ControllerはいつものViewModelを持ちますが、RxCocoaのextensionをいくつか持っています。UIKitの最上層にあるextensionのメソッドで、デリゲートを作ることなく値の変更が行えます。
私はデリゲートが嫌いで、この世で一番悪いものだと思っています。なぜならスクロールさせるからです。 特に UITableView
やUICollectionView
です。単純に2つのText Viewを持っており、それぞれの参照を確認する必要があります。むしろ迷惑ですよね。ここではviewDidLoad内の usernameTextField.rx.text
で直接参照を取得でき、viewModelとbindTo
で接続します。bindTo
はすべてのインテントと目的のためにサブスクライブするのと同じことです。
不確定なイベントを取得するために、いろんなAPIを使ってきたと思いますが、次にトークンハンドラを停止する必要があります。”DisposeBag”はこれらのコレクションです。DisposeBagがdeinitされると、ストリームも処分されます。キレイになるのでいいことです。disposeBag.dispose
を呼ぶことで手動で処分することもできます。ViewModelはrx.isValid
を持っています。これをisEnabled
にmapします。
簡単な例を持ってきました。UITextField
のrx.text
がOptional型のStringを返すことに気づくと思います。簡単にするために、空のStringをデフォルト値としています。
import RxSwift
struct LoginViewModel {
var username = Variable<String>("")
var password = Variable<String>("")
var isValid : Observable<Bool>{
return Observable.combineLatest( self.username, self.password)
{ (username, password) in
return username.characters.count > 0
&& password.characters.count > 0
}
}
}
簡単なログインフォームをつくります。
import RxSwift
import RxCocoa
class LoginViewController {
var usernameTextField: UITextField!
var passwordTextField: UITextField!
var confirmButton: UIBUtton!
var viewModel = LoginViewModel()
var disposeBag = DisposeBag()
override func viewDidLoad(){
super.viewDidLoad()
usernameTextField.rx.text.bindTo(viewModel.username).addTo(disposeBag)
passwordTextField.rx.text.bindTo(viewModel.password).addTo(disposeBag)
//from the viewModel
viewModel.rx.isValid.map{ $0 }
.bindTo(confirmButton.rx.isEnabled)
}
}
ご覧の通り、ログインボタンがありますが、無効になっています。パスワードを入れると、突然ログインボタンが有効になります。コードが大量にあります。取り除けば元に戻ります。
LoginViewModel
にブレークポイントを張ってみると、何か変更がある度にブレークポイントで止まります。combineLatest
が可能にしていることです。値が返ってきた時のそれぞれの状態が得られます。ユーザー名を調べると、その値が得られ、パスワードを調べると、その値が得られます。それからBooleanが返されます。これにより、値を常に変更したり組み合わせたりして新しい値を取得することができます。
** UIBindingsがお互いにやりとりしていないことを確認してください**。 RxCocoaは多くのデリゲートでのやり取りを取り除いたので、多くの人々がRxCocoaにワクワクしていたことは分かっています。しかし、あなたのUIストリームがお互いにやり取りをしないようにしてください。すぐ面倒になります。
たとえば、「このViewModelのことをしない」と決めたとします。また、ユーザ名のテキストフィールドとパスワードのテキストフィールドにbindして combineLatest
を作成します。結果セレクタがこれを返します。そして、この isValid.bindTo
を生成します。
class ViewController : UIViewController {
override func viewDidLoad(){
super.viewDidLoad()
let isValid Observable.combineLatest(
username.rx.text,
password.rx.text,
resultSelector: { (username, password) -> Bool in
return username.characters.count > 0
&& password.characters.count > 0
})
isValid.bindTo(confirmButton.rx.isEnabled)
.addTo(disposeBag)
}
}
これは全くテストできないコードです。なぜならisValid
はView Controlerを取り巻き続けているからです。参照は持っていませんが、そこで生存し続けています。MVVMの利益を何も享受できていません。これではまだMassive View Controllerです。
class MyCustomView : UIView {
var sink : AnyObserver<SomeComplexStructure> {
return AnyObserver { [weak self] event in
switch event {
case .next(let data):
self?.something.text = data.property.text
break
case .error(let error):
self?.backgroundColor = .red
case .completed:
self.alpha = 0
}
}
}
}
UITableViewやUICollectionViewはこれらをすべて行うので、コストパフォーマンスがよくありません。rowを取り除くのはStack Overflowで見たものです。自分でUIViewのサブクラスを作るかもしれませんし、MapboxのようなViewがあるかもしれません。もしくはCocoaPodsでインストールしたライブラリを使っているかもしれません。サブクラスにする必要はなく、extensionのメソッドを作ればいいのです。
これを行えるアイディアとしては、このRxの使えるカスタムビューです。
class ViewController {
let myCustomView : MyCustomView
override func viewDidLoad(){
super.viewDidLoad()
viewModel.dataStream
.bindTo(myCustomView.sink)
.addTo(disposeBag)
}
}
Observerを作ります。Observerはイベントの中にあるコードブロックです。イベントはその型やエラー、completionを返すenumです。sinkにたくさん詰め込み、それからプロパティであるtextを保持させて何かを行い、エラーの場合はredを返します。完了したら(alphaを0にして)消えていきます。
カスタムした銘柄ラベルや株価がある場合は、Rxの使える要素を作ることができます。dataのことや非同期のことを気にしなくていいので、良いものだと思っています。このフォーマットでデータを受け取るだけです。それからナイスなジェネリクスもついてますね。ブロックの中ではweakにしてあることに気をつけてください。自分自身を参照していますからね。
これでいくつかのカスタムビューを持っていることになります。ViewModelでは、入力されるデータストリームがあります。これをsinkにbindしています。これによって、リアクティブなストリームを受けいれるUIViewを手に入れることができます。
そして、これを手に入れるには、CocoaPodsまたはCarthage、RxSwiftやRxCocoaを使います。RxCocoaはUIKitのextensionで、Macデベロッパーでも使えます。これはUITableViewやUICollectionViewがどれだけ巨大なものなのかを伝えるのに重要なものです。
RxSwiftの上に構築された独立したオープンソースライブラリである__RxDataSource__ の力をお見せするサンプルアプリ (動画をご覧ください)を用意しました。ここでは、様々なセクションでUITableViewを使ってカスタマイズを行っています。
このコードがいかに短いかをお見せします。UITableViewのData Sourceすら実装していません。セクションがあります。セクションは面白いです。ただのプロトコルです。Data Sourceはプロトコルを適用しています。その上にIDがあります。RxDataSourceにそのIDが何であり、どう変化するか、いつ変わるのかを伝え、手動でデータソースを常に変更する必要はなく、それぞれのUITableView.animate rowまたはdelete rowを呼び出します。異なる2つのセクションがあり、Data Sourceが一つあります。
Data Sourceはセクションのジェネリックに型付けされます。これはカスタムしたTable Viewの実装です。dequeue
reusable identifierを呼び、使いたいTable View Cellを伝えます。DataSourceに対するViewModelは、DataSourceを持ちますが、どのセクションに対応するのかを伝えてください。staticなセクションについては、bindTo(tableView.rx.items(...))
を行い、DataSourceとbindします。これでデリゲートをセットし、複数のセクションを持ったことになります。
異なる値に対するメンバ変数を作っておらず、インデックスやセクションの順番、パスの管理をしていません。RxSwiftやRxDataSourcesを通して行っています。これが非同期で行えるのです。
ランダム化された例はとてもパワフルです。UICollectionView
にすべてのセクションがあります。そしてニセの非同期ストリームがコンスタントに動作しています。 自分の手で書くよりもとても小さいです。 それが行っているのは、異なる数値を作成し、値を別々に取り除くことだけです。決してinsertRow
、 indexPath
などの突貫工事をナンセンスと呼ぶ必要はありません。そして今、あなたはかなり印象的なものを持っています。これは、株券のアプリに最適です。
Operatorについてはあまりお話できませんでした。みなさんにドヤ顔したかったわけではありません。次回は、複雑なものをsortしたり、groupByしたり、combine, merge, zipしたりするOperatorを使ったり、それがどういう挙動をしているのか、よりRxSwiftのアルゴリズムやデータ構造に着目したいと思っています。しかしこれは、データ構造とUIがとても良く合致する方法です。
Q & A (27:55)
Q: 会社でこれをするのは難しいですか?大企業や中小企業ではより難しくなりますか?どのやって人々を納得させるのですか? Max: これはRxSwiftの大きな部分であり、突然導入されたLodashとは異っていて、あまり強くはありません。必要に応じて独自のネイティブイベントを使用することができ、独立した方法で使用することができます。たとえば、「新しいView Controllerを作成する」というチケットがたくさんある場合は、それを使うことができます。そして古いコードは知る必要はありません。 2つのことを並行して維持する必要がありますが、これはユーティリティライブラリです。フレームワークとは異なります。
Q: RxSwiftにはパフォーマンスの問題はありますか?アプリが大きくなってネットワークのコードがある場合、速さは非常に重要だと思います。 Max:はい、それは重要な話題です。メインスレッドでは問題ありません。私はこの問題を見ていませんが、RxSwiftのリポジトリのページにベンチマークがあるので、チェックアウトして見ることができます。
iOS開発の本当のトリックは、非同期と並行処理が可能なことで、ここがRxSwiftが輝く場所です。たぶん別の話がありますが、実際にどのようなメリットがあるのかをすぐに調べることができます。
たとえば(動画参照)、あなたは並列処理を行うようなスケジューラーを作成することができます。どちらが欲しいのですか?バックグラウンドスケジューラを作成することができます。株価。そしてスケジューラーでObserveしてください。そして、nextをしてください。たぶんmapでしょう。これは、バックグラウンドQoSになるでしょう。それをメインスレッドに戻したいとします。これはバックグラウンドで実行されます。つまり、パフォーマンスのメリットが見られるようになります。
他にも、throttleを使って何かをしたいと思うかもしれません。あなたが検索のようなことをするなら、あなたはYelpから来たと言ったのですか?多くの人々は、アプリ内の検索ボタンをクリックしたくありません。彼らは入力したいし、これらのものをバックエンドに送信したいと思います。あなたもおそらくストリームのすべての文字を送信するつもりはないでしょう。
これが入ってくるテキストのストリームだとしましょう。throttleを行うことができて、0.25秒かかります。メインスケジューラ。インスタンス。登録する。そしてここであなたの非同期コードを実行してください。 MVVMのやり方をするつもりなら、あなたはbindToに行きます。誰かが速いタイパーである場合、検索したくない、たとえば、ピザなどを検索する場合は、Pを検索したくないなど、それは無関係です。スロットルだけでなく、スロットルでもよいが、実際には3ビットのデータを入力した場合はこのブロックを実際に実行することができます。これは、メインスレッドをできるだけ早く維持するという点で、あなたを助けます。
しかし、あなたはiPhone 7sのメインスレッドでそれをやってはいけません。深刻な問題を抱えています。これは高速のマシンです。他のスレッドですべてを行い、それらを適切なスレッドに渡します。そして、いつもこれをテストすることができます。テスト中のリポジトリでチェックすることができます。スレッドでスレッドをthrowし、別のスレッドでスレッドを実行した後、メインスレッドに返します。しかし、あなたのViewModelでは、通常、ほとんどの場合、メインスレッドに戻す必要があります。
About the content
This content has been published here with the express permission of the author.