Galapagos Tech Blog

株式会社ガラパゴスのメンバーによる技術ブログです。

RxSwiftでMVVMの簡単なサンプルコード

こんにちは、iOS/Androidエンジニアのイバンです。

今回はアプリのアーキテクチャについての記事になります。

ソフトウエアのアーキテクチャというと様々な提案があります。iOSアプリ開発になると、アップルが推奨するMVCが一番よく使われているでしょう。

MVCと課題

MVCとはモデル(データ)・ビュー(ユーザインターフェース)・コントローラー(ロジック)という3つの責任にコンポーネントに分別するソフトウエアデザインパターンの一つです。

ビューはモデルのデータを表示して、コントローラーはビューとモデルを結びつくロジックを持っているコンポーネントです。

コントローラーは多くのUIのロジックとデータの処理のロジック両方を持ってコードの行数が簡単に何千行に上ります。iOSMVCは実際にM + VC、つまり、ビューとコントローラーは一つになっているわけですね。

ビジネスロジックとUIロジックが混合しているとユニットテストも書きにくくなります。どちらかというと書かないようになってしまうことが多いですね。

そうなるとコードのメンテナンス性とテスト性が低下してしまい技術的負債が増え続けるパターンになりがちです。

太くなってしまうコントローラーが業界中にファットコントローラー、MVCはMassive View Controllerと呼ばれるようになってますね。

MVVMでコントローラーをダイエットさせましょう

ファットコントローラーを痩せたコントローラーにするには様々な提案がありますが、今回はMVVMを紹介したいと思います。

MVVMのパターンは下記のようにアプリのコンポーネントを分別します。

  • M(モデル):情報を持つエンティティ。
  • V(ビュー):ビューとビューコントローラを含めます。ビューを管理する以外のロジックは持ってない。
  • VM(ビューモデル):モデルを取得して表示用にデータを変換する

f:id:glpgsinc:20170406175204p:plain

ビュー(コントローラー)は情報の表示とインプットの受け付けしか行いません。表示のフォーマットとインプットのアクションとその他の情報処理はビューモデルに任せます。ビューはモデルについて何も知りません。つまり、モデルのインスタンスは持ちません。

ビューモデルでは表示のロジックを行いますがUIについて何も知りません。UIKitインポートしないし、ビューのインスタンスも持ちません。 ビューモデルがモデルから取得した情報を加工してビューに提供します。

モデルは情報しか持ってないです。Swiftだとstructで実装するのは一般的です。

ここは疑問一つ挙げられることがあると思います。もしビューモデルは表示用にデータを取得して処理するなら、ビューを参照しないでどうやって画面を更新するのか?

答えはデータバインディングが仕込まれているのです。

データバインディング

データバインディングの仕組みを使うと、データがビューと連動させられてデータが変わったら自動的にビューに反映されます。

iOSはデータバインディングを対応してないので外部ライブラリを導入する必要があります。ReactKit/Bond、ReactiveCocoa、RxSwiftなどありますが、今回はRxSwiftを使います。

RxSwiftはバインディングだけではなく非同期処理やイベント処理を宣言的に書けるライブラリです。サンプルコードで使う分の解説はしますが、RxSwiftについて詳しく知りたい方は初めてのRxSwiftの発表資料や参考文献の節にあるリンクをご確認いただければ幸いです。

実際にMVVMどうなる?

サンプルアプリは単純にAPIサービスを利用して画像とテキストを取得して表示するだけですが、簡単なソースコードになりますのでMVVMはどうなっているのかわかりやすいと思います。実際のアプリには足りないところが多いですが、MVVMの基本的な仕組みの理解には十分かと思います。要求あれば今後の記事で拡張していきたいと思います。

今回はXKCDというギークなコミックサイトのAPIを使ってコミックを表示します。サンプルコードはこちらからダウンロードできます: xkcd観覧アプリ

さて、コードを追って解説しましょう。

ビュー

ComicViewControllerviewDidLoadviewModelの初期化とデータバインディングを行います。ViewModelからViewModelへバインディングは2パターンがあります。ViewModelからはビューモデル側でデータが更新になったらビューを更新するようにバインディングします。

例えば

comicViewModel.title.asDriver().drive(titleLabel.rx.text).disposed(by: disposeBag)

ビューモデルがコミックのタイトルを取得できたらRxの力でタイトルラベルに自動的に反映されるように設定します。 DriverはRxSwiftの世界で監視可能なオブジェクトのことです。最後にあるディスポーザはメモリ管理の為に追加します。

ViewModelへのところではボタンの処理(前後のコミックを依頼する)の設定と最新のコミックを取得する依頼を行いす。

ボタンのハンドリングはIBActionでも良いですが、Rxを使うとコンパクトにviewDidLoad()の中に書けます。

nextButton.rx.tap.asDriver().drive(onNext: {
    self.comicViewModel.getNextComic()
}).disposed(by: disposeBag)

実際の処理はcomicViewModelに任せます。

ビューコントローラーは以上でビューのデータバインディングとボタンのタップイベントを受けるだけのコードになります。

ビューモデル

ビューモデルはデータを取得して表示用に処理します。まずはデータバインディングできるようにRxの監視型プロパティーを用意します。例えばタイトルは下記のように宣言します。

var title: Variable<String>

ビューコントローラ側にデータバインディングdrive(..)のところ)を設定してますのでtitle.value = "タイトルです"だけで自動的にtitleLabelに反映されます。

文字列はそんな感じですが、次へ、前へのボタンは無効になったり有効になったりしますね。それはisNextEnabled/isPreviousEnabledのドライバーで実現します。監視型のVariableで最新号と観覧中のコミックを持っていて、isNextEnabledcombineLatestでその両方が同じの場合(見ているコミックは最新)はfalseを返します。isNextEnablednextButtonenabledプロパティーにバインドされてますので自動的に反映されます。

isNextEnabled = Driver.combineLatest(self.latestComicNum.asDriver(), self.currentComic.asDriver(), resultSelector: { (latestNum, current) -> Bool in
    guard let latestNum = latestNum, let currentNum = current?.num else { return false }
    return  latestNum != currentNum
}).distinctUntilChanged()

isPreviousEnabledはコミック番号が1かどうかを確認するだけですのでmapで適切なブールを返します。

isPreviousEnabled = currentComic.asDriver().map({ (comic) -> Bool in
    guard let num = comic?.num else {
        return false
    }
    return num > 1
}).distinctUntilChanged()

distinctUntilChangedは回覧中が変わる度にブールの値が変わらなければ無駄にenabledバインディング処理が起こさないようにあります。

ビューモデルの初期化は以上で終わります。他の関数はapiサービスの呼び出しとビューモデルの情報の更新です。

ApiサービスもRx化になっているのでsubscribe(onNext:)でコミックの情報を取得します。情報はComicモデルクラスの形で来ますが、モデルから表示用に変えるのがupdateViewModelというヘルパー関数で行います。日付のフォーマットを変えたり画像のurlのstringをURLオブジェクトに変換します。

func getLatestComic() {
    service.getLatestComic().subscribe(onNext: { (comic) in
        guard let comic = comic else {
            return
        }
        self.latestComicNum.value = comic.num
        self.updateViewModel(comic: comic)
    }).disposed(by: disposeBag)
}

Variablevalueをアサインすると、Rxのバインディングで画面が更新されます。

private func updateViewModel(comic: Comic) {
    self.currentComic.value = comic
    self.title.value = comic.title ?? ""

    if let urlString = comic.img, let url = URL(string: urlString) {
        self.imageUrl.value = url
    }

    if let date = comic.date {
        self.date.value = formatter.string(from: date)
    } else {
        self.date.value = ""
    }
}

モデル

モデルは生のデータしか持ってないstructです。引数としてDictionaryを渡せるコンストラクタもありますが、基本的にはロジックが持ちません。ビジネスロジックはビューモデルか別のクラスに委任すると良いです。例えばコミックを取得するのはComicServiceの役割で、それを利用するのはモデルではなくビューモデルですね。

何かのロジックを入れるとしたら、内部のデータの形式の変更やデータのバリデーションぐらいなら良いかと思います。

TODO

エラーハンドリング

このサンプルコードは未完成なプロジェクトになっています。エラーハンドリングは行っていません。実装のアプローチとしてビューモデルからアラートを出すのは一番簡単ですが、エラーの場合にビューを更新したい場合はエラーの状態を持つ監視型のプロパティーをビューモデルに追加してビューコントローラーでバインディングしてビューを更新されるのが考えられますね。

テスト

テストコードも一切書いてないですね。書くとしたら主にビューモデルのテストを書いたらカバレッジの高いテストになります。ビューコントローラーにはロジックがなくて、ビューモデルにはテストの難しいビューもありませんのでテストが書きやすくなっています。

テストに関するもう一つ改善できるところはビューモデルが持つComicServiceインスタンスです。現状のコードだとビューモデルでインスタンスを生成してますが、DI(依存性注入)を使ってインスタンスをビューモデルに渡すとテストのターゲットの際、テスト用のモックサービスに入れ替えて通信なし気楽にテストできるようになります。

それらの課題は今後、の記事の続きにしたいと思います。

参考

新卒2017の入社式をやりました

こんにちは、社長の中平です。

4月3日よりガラパゴスでは新たな仲間を4名を迎い入れました。

f:id:glpgsinc:20170403135423j:plain

11時より社内会議室(ダーウィン)で執り行われた入社式では、 涙あり笑いありの非常に思い出に残る式となりました。

f:id:glpgsinc:20170403111648j:plain

実は一ヶ月前の3月からインターンとして入ってもらっていたので、 入社式をしても新鮮味は無いかなと懸念していましたが、そんなことはありませんでした。

ワクワクキラキラとした彼らの目を見ていると、こっちもワクワクしてくるし、 彼らが輝ける場所をこれからも作り続けないとなと身が締まる思いです。

そして今日から2ヶ月間、地獄の(?)の研修が始まります。

優秀なエース級の先輩社員達が創意工夫して作ったデジタルモノづくりを骨の髄まで学べるスペシャル研修。 正直、羨ましいなと思いつつも、彼らがどのように成長していくのか、今から本当に楽しみです。

最後に、ガラパゴスは常に最先端の技術に寄り添う会社です。 その思いを知って欲しくて入社式で彼らに伝えた事。

  •  学び続けよう(好奇心を絶やさず、独自に学び、謙虚に)

  •  同期は宝

  •  視野を高く、志高く

2ヶ月後にまたブログ書こうかな! 成長が本当に楽しみ!!!

Phoenix Framework v1.3のおはなし

ご機嫌よう、ガラパゴスのおとめです。

今日は、先日RC版がリリースされたPhoenix Framework v1.3を見ていこうと思います。

大きく変わったところ

v1.3の変更点を眺めていて、次の点が興味深いと思いました。

  • web/ディレクトリが引っ越しました。
  • umbrellaがサポートされました。
  • contextがサポートされました。
  • action_fallbackが追加されました。

さて、これらの変更は、個々に見ていくよりも、まずは全体を見て概念をつかんだほうがいいのかな、と思いますので、背景から見てみましょう。

境界について考える

これまでのPhoenix Frameworkには、おおよそ以下のような問題があった、と考えられていたようです。

  • web/の下に何でもかんでも詰め込まれていて全体で一つのアプリケーションになっていて、ぜんぜん関係なくても全てに依存している。
  • modelに色々なロジックが詰め込まれていて見通しが悪いし、それでもロジックはmodelに集中して、分かりにくくなりがち。

でも、誰もが必要なことだけに集中したいですね。境界を、ここでの境界というのは境界線上のことではなく責任を明確にするとうことですが、はっきりさせたい。そのためにはどうしたらいいのかしらん?

ここに二つの鍵があります。コンテキスト境界と、アンブレラプロジェクトです。

コンテキスト境界

コンテキスト境界はドメイン駆動開発の概念ですが、軽くおさらいしておきましょう。

あるmodelがあったとして、そのmodelはあちこちで使われていて、それぞれに必要な色々なメソッドがあるとします。ことによると、あちらとこちらでは同じ言葉で違う意味、みたいな場合まであったりして、仕方がないので修飾語をくっつけてsome_method_of_a_contextとかsome_method_of_other_contextとか、みたいな感じになったりします。もちろんこういうのは、くんずほぐれつ、コード上のあちこちに散らばっていて、なんかもう全体として巨大な一つのmodelになっていて、この世に、少なくともオフィスには、呪いを生み出しますね。

無理。

ですので、共通な部分と、ある特定の部分と、別の特定の部分と、という風に分けましょう。

でも、どうやって? 概念としては分かりますが、例えばある特定の言語で書かれたコードでは?

Phoenix Framework v1.3では、複雑になる一方のmodelに対してコンテキストが導入されました。例えばこんな風になります。

$ mix phx.gen.json Followers User users username:string

ディレクトリ構造はこのようになります。

+- lib
   +- my_app
      +- followers
      +- users
      +- web

この例はマイクロブログ風にユーザーにはフォロワーがいて、という世界観を表現しています。フォローを承認してみましょう。モデルの.コンテキストの.メソッド、という呼び出しになります。

user
|> Followers.unaccepted
|> Followers.accept

もちろんこの単純な例では、そんなのUsersにあっても全然構わないように見えますが、それでも、ある種の責任を綺麗に分離できましたね。

アンブレラプロジェクト

Elixirにはアンブレラプロジェクトという、プロジェクトが大きくなった時に、複数の子プロジェクトに分割して管理するための機能があります。

そして、Phoenix Framework v1.3は、このアンブレラプロジェクトに対応しました。

まず、mix newで親プロジェクトを作ります。この時に--umbrellaオプションを指定します。

$ mix new my_app --umbrella

するとappsconfigだけのプロジェクトができます。

my_app
+- apps
+- config
|  +- config.exs
+- mix.exs

例えばガラパゴスは主にスマートフォンアプリを開発していますが、スマートフォンアプリはAPI(アプリ向け)とWEB(管理画面)がセットになっている場合がほとんどです。

それでは、子プロジェクトを作ってみましょう。

$ cd my_app/apps
$ mix phx.new.ecto my_app_core # 共通のコア機能
$ mix phx.new.web my_app_web # 管理画面
$ mix phx.new.web my_app_api # API

するとこのように、appsの下にそれぞれ独立したプロジェクトができました。御機嫌よう世界。

my_app
+- apps
|  +- my_app_api
|  |  +- assets
|  |  +- config
|  |  +- lib
|  |  +- priv
|  |  +- test
|  |  +- mix.exs
|  +- my_app_core
|  |  +- config
|  |  +- lib
|  |  +- priv
|  |  +- test
|  |  +- mix.exs
|  +- my_app_web
|     +- (ry
+- config
+- mix.exs

例えばAPIに管理画面関連の依存関係があっても全く無駄ですし、逆もそうですし、アプリケーションがロードされた時に必要ない依存関係が色々なオーバーヘッドになっているのとかも。

でも、こうしてアンブレラプロジェクトにすることで、スッキリしました。子プロジェクトという形で、責任の範囲も明確になりました。

さいごに

コードを書いていると、これはここにあっていいのかしら? みたいなことを感じることも多々ありますが、境界を綺麗に分割できると嬉しいですね。

さて、ガラパゴスでは人類の進化に貢献したいエンジニアを大絶賛超募集しています。皆様の応募をお待ちしています。

www.glpgs.com

では、御機嫌よう。

この記事は業務の一環として業務時間を使って書きました