MVVMベストプラクティス

0f85c4d301c85e27886ad1050e42bcaa?s=47 cheesesand101
February 08, 2018
6.6k

 MVVMベストプラクティス

0f85c4d301c85e27886ad1050e42bcaa?s=128

cheesesand101

February 08, 2018
Tweet

Transcript

  1. MVVMベストプラクティス DroidKaigi2018 Yasuhiko Sakamoto

  2. 自己紹介 • Yasuhiko Sakamoto • カカクコムで新規事業を担当しています • アプリエンジニア、チームビルディング、データ 分析等いろいろやってます

  3. アジェンダ • この発表について • MVVM概要 • 関心の分離 • RecyclerView •

    データフロー • まとめ
  4. この発表について

  5. MVVM View Model ViewModel

  6. MVVM 実際に作ってみると・・・

  7. やけに太った人がいるMVVM ViewModel View Model

  8. とてもよく使うんだけど・・・ MVVMでどう作るんだっけ? View Model ViewModel

  9. 複雑なMVVM ViewModel ViewModel ViewModel ViewModel

  10. この発表について • 今までの経験(主に反省)を踏まえ、MVVMで 開発をする上で直面する様々な問題に対して、 ベストと思われる実践的なプラクティスを紹介し ていきます

  11. MVVM概要

  12. MVVM概要 • アプリケーションをModel / View / ViewModel に分割するアーキテクチャパターン • MicrosoftのKen

    CooperとTed Petersが開発 • WPF/Silverlightで使われていたが、最近は他 のプラットフォームでも採用例が多い
  13. 関心の分離

  14. 問題:やけに太った人がいるMVVM ViewModel View Model ViewModelがViewと Modelを侵食し肥大化 「関心の分離」を考える

  15. 関心の分離 • 関心の分離とは • MVVMにおける関心の分離 • Android開発における実践的な方法

  16. 関心の分離とは • アプリケーションを適切な単位に分解・構成する ことにより複雑化を防ぎ、再利用性・保守性を高 めることができる

  17. Presentation Domain Separation(PDS) by マーティン・ファウラー  「最も有用な設計原則に、 プログラム(ユーザーイ ンターフェイス)のプレゼンテーション層とその他の 機能をうまく分ける、というのがあります。」 http://bliki-ja.github.io/PresentationDomainSeparation/

  18. 関心の分離 • 関心の分離とは • MVVMにおける関心の分離 • Android開発における実践的な方法

  19. MVVMにおける関心の分離 • MVVMは Presentation Domain Separationを 実現するパターンの一つである

  20. MVVMにおけるPDS View Model ViewModel Domain その他の機能 Presentation プレゼンテーション層

  21. Viewの関心 • データの見せ方 ◦ 形状、レイアウト、アニメーション・・・ • ユーザーイベントView View

  22. Modelの関心 • ViewとViewModel以外の、プレゼンテーション 層に依存しない情報 • アプリの各種状態、データモデル、ビジネスロ ジック、ネットワーク、データベース・・・etc Model

  23. ViewModelの関心 • Viewの状態を抽象化して持っている • ModelとViewの接続 ◦ ModelをViewが扱いやすい形にする ViewModel

  24. ViewとViewModelのつながり View ViewModel Viewでイベント発生 -> ViewModelに通知 • ViewとViewModelのつながりはデータバインディングで実現 • ViewはViewModelを監視し、状態の変更に応じてViewが変更され

    る • ViewModelはViewへの参照を持たない 変更通知 イベント通知
  25. ViewModelとModelのつながり ViewModel ViewModelはModelの状態変更を要求 • ViewModelをModelの状態を監視し、状態の変更に応じて ViewModelが変更される • ModelはViewModelへの参照を持たない Model 変更通知

    状態変更を要求
  26. 関心の分離 • 関心の分離とは • MVVMにおける関心の分離 • Android開発における実践的な方法

  27. 関心の分離 • Android開発における実践的な方法 ◦ テスタビリティを基準として使う ◦ ViewとViewModel ◦ ViewModelとModel ◦

    ダイアログ表示・画面遷移 ◦ Context
  28. テスタビリティを基準として使う • 関心の分離ができているかの基準の1つとし て、ViewModelのテスタビリティを意識する • ユニットテストを書く・書かないに関わらず、良い 設計の指針となる • 依存するオブジェクトについてはMockへの差し 替えを可能にする

    ◦ Dagger2などのDIコンテナの利用を推奨
  29. テスタビリティのポイント • テストできないものが混入していないか ◦ Viewへの参照 ◦ データベース、ネットワーク、ファイルなどを直接操作し ていない ◦ スレッドの制御を行っていない

    ◦ …etc
  30. 関心の分離 • Android開発における実践的な方法 ◦ テスタビリティを基準として使う ◦ ViewとViewModel ◦ ViewModelとModel ◦

    ダイアログ表示・画面遷移 ◦ Context
  31. ViewとViewModel • Data binding expression • View -> ViewModel •

    ViewModel -> View • ViewModelとModel
  32. Data binding expression • レイアウトXMLの中に式を記述し、Viewと ViewModelの接続を行う <TextView ... android:text=”@{viewModel.name}” />

    class ViewModel{ val name : ObservableField<String> }
  33. ViewとViewModel • Data binding expression • View -> ViewModel •

    ViewModel -> View
  34. View -> ViewModel • Viewで発生したイベントを通知 • ViewModelにViewが混入しないようにする

  35. View -> ViewModel <Button ... android:onClick="@{viewModel::onClick}" class XXViewModel{ fun onClick(view:View){

    ViewModelにViewが混入してしまう
  36. View -> ViewModel <Button ... android:onClick="@{() -> viewModel.onClick()}" class XXViewModel{

    fun onClick(){ ラムダを使った記法が可能
  37. ViewとViewModel • Data binding expression • View -> ViewModel •

    ViewModel -> View
  38. ViewModel -> View • ViewModelはViewに状態を公開 • ViewModelの状態をそのままViewで利用でき ないとき、バインド時に変換を行う ◦ カスタムセッターを利用

  39. カスタムセッター(画像) <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" > ...  <ImageView ... app:imageUrl="@{viewModel.imageUrl}" />

    @BindingAdapter("imageUrl") fun ImageView.setImageUrl(imageUrl:String){ Picasso.with(this.context).load(imageUrl).into(this) } Kotlinの場合拡張メソッドを使って BindingAdapterを記述可能
  40. 関心の分離 • Android開発における実践的な方法 ◦ テスタビリティを基準として使う ◦ ViewとViewModel ◦ ViewModelとModel ◦

    ダイアログ表示・画面遷移 ◦ Context
  41. ViewModelとModel • ViewModel -> Model ◦ Modelの状態を変更する • Model ->

    ViewModel ◦ ViewModelがModelの状態を監視する ◦ 監視は、RxJava等のインターフェースを使う ◦ ※本発表ではRxJavaを使います。説明を単純にするた め、エラー処理やライフサイクルの管理は省略します。
  42. 例 UserSettingViewModel SharedPreference load バインド SettingA:ON

  43. 悪い例(UserSettingViewModel) class UserSettingViewModel(private val context: Context) { val settingA =

    ObservableField<String>() fun load(){ val pref = context.getSharedPreferences("UserSetting", Context.MODE_PRIVATE) val value = pref.getString("SettingA", "") this.settingA.set(value) } } ViewModelがSharedPreferenceを直接参照
  44. 改善 UserSettingViewModel SharedPreference UserSettingRepository 監視 load バインド SettingA:ON

  45. UserSettingRepository class UserSettingRepository(private val context:Context) { private val settingASubject =

    PublishSubject.create<String>() fun getSettingA() : Observable<String>{ return this.settingASubject } fun loadSettingA(){ val pref = context.getSharedPreferences("UserSetting", Context.MODE_PRIVATE) val value = pref.getString("SettingA", "") this.settingASubject.onNext(value) } } PublishSubjectをObservableとして返すだけ データを取得してPublisSubjectにonNext
  46. UserSettingViewModel class UserSettingViewModel(private val repository: UserSettingRepository) { val settingA =

    ObservableField<String>() init { repository.getSettingA().subscribe { value -> settingA.set(value) } } fun load(){ repository.loadSettingA() } } 状態を監視し続け、変更があっ たらsettingAを更新 ※将来的に設定の変更機能をつ けたときも、変更はここに流れて くる repositoryにデータを読み込ませる
  47. 関心の分離 • Android開発における実践的な方法 ◦ テスタビリティを基準として使う ◦ ViewとViewModel ◦ ViewModelとModel ◦

    ダイアログ表示・画面遷移 ◦ Context
  48. ダイアログ表示・画面遷移 • ダイアログ表示や画面遷移はViewModelの関 心事ではないので、ViewModelからはトリガー を引くだけにする

  49. 代表的な実装パターン • Navigator ◦ AndroidのMVVM実装でよく使われる ◦ Activityをラップするオブジェクト(Navigator)を作り、 ViewModelはNavigator経由でViewの操作を行う

  50. Navigatorの実装例 (DroidKaigi/conference-app-2017) @ActivityScope public class Navigator { private final Activity

    activity; @Inject public Navigator(AppCompatActivity activity) { this.activity = activity; } public void navigateToSessionDetail(@NonNull Session session, @Nullable Class<? extends Activity> parentClass) { activity.startActivity(SessionDetailActivity.createIntent(activity, session.id, parentClass)); } https://github.com/DroidKaigi/conference-app-2017/blob/master/app/src/main/java/io/github/droidkaigi/confsched2017/view/helper/Navi gator.java
  51. 関心の分離 • Android開発における実践的な方法 ◦ テスタビリティを基準として使う ◦ ViewとViewModel ◦ ViewModelとModel ◦

    ダイアログ表示・画面遷移 ◦ Context
  52. Context (android.content.Context) public abstract class Context { public final String

    getString(int resId); public final String getString(int resId, Object... formatArgs) ; public final int getColor(int id); public final Drawable getDrawable(int id); public final TypedArray obtainStyledAttributes(AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes); public abstract ClassLoader getClassLoader(); public abstract String getPackageName(); public abstract ApplicationInfo getApplicationInfo(); public abstract String getPackageResourcePath(); public abstract String getPackageCodePath(); public abstract SharedPreferences getSharedPreferences(String var1, int var2); public abstract boolean moveSharedPreferencesFrom(Context var1, String var2); public abstract boolean deleteSharedPreferences(String var1); public abstract FileInputStream openFileInput(String var1) throws FileNotFoundException; public abstract FileOutputStream openFileOutput(String var1, int var2) throws FileNotFoundException; public abstract boolean deleteFile(String var1); public abstract File getFileStreamPath(String var1); public abstract File getDataDir(); ・・・・・・・ 多い!!!
  53. Context • View, ViewModel, Modelのそれぞれで必要と する場所がある • ViewModelがContextの機能を使う場合 ◦ 使うとしたらgetStringあたりか

    ◦ ラッパーを作るなどしてViewModelでのみ使って良いメ ソッドを制限した方が良い
  54. RecyclerView

  55. とてもよく使うんだけど・・・ MVVMでどう作るんだっけ? View Model ViewModel

  56. RecyclerView • 基本的な実装パターン • 変更検知 • 実装の効率化

  57. 基本的な実装パターン TODO 広告 広告付きTODOアプリ

  58. Modelの定義 sealed class TaskItem{ class Task(val name:String, ...) : TaskItem()

    class Ad(...) : TaskItem() }
  59. ViewModelの定義 class TasksViewModel(...){ val items : ObservableField<List<TaskItem>> } sealed class

    TaskItemViewModel{ class Task(...) : TaskItemViewModel(){ val name : String } class Ad(...) : TaskItemViewModel(){ ... } }
  60. Adapterの生成 Adapter RecyclerView

  61. Activity class TasksActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?)

    { ... val binding = DataBindingUtil.setContentView<ActivityTasksBinding>(this, R.layout.activity_tasks) binding.recyclerView.adapter = TasksAdapter(repository) Adapterを生成
  62. ModelをAdapterに渡す Adapter データバインディングで渡す Model Model Model

  63. BindingAdapter + バインド @BindingAdapter("taskItems") fun RecyclerView.setTaskItems(items:List<TaskItem>>?){ (this.adapter as TasksAdapter).setData(items ?:

    listOf()) } <android.support.v7.widget.RecyclerView android:id="@+id/recyclerView" ... app:taskItems="@{viewModel.items}" /> activity_tasks.xml RecyclerViewExtension.kt
  64. ViewModel AdapterでView/ViewModelの生成と接続 ViewModel ViewModel View View View データバインディングで渡す 生成 生成

    生成 Adapter Model Model Model
  65. TasksAdapter class TasksAdapter(private val repository: TaskRepository) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { private

    var items = listOf<TaskItem>() fun setData(items:List<TaskItem>){ this.items = items this.notifyDataSetChanged() } override fun getItemCount(): Int { return this.items.size } override fun getItemViewType(position: Int): Int { val item = this.items.get(position) return when(item){ is TaskItem.Ad-> ViewType.Ad.type is TaskItem.Task -> ViewType.Task.type else -> TODO() } } override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder { val viewTypeEnum = ViewType.from(viewType) val layoutInflater = LayoutInflater.from(parent!!.context) return when(viewTypeEnum){ ViewType.Ad -> TaskAdViewHolder(DataBindingUtil.inflate(layoutInflater, R.layout.item_task_ad, parent, false)) ViewType.Task -> TaskViewHolder(DataBindingUtil.inflate(layoutInflater, R.layout.item_task, parent, false)) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) { if(holder != null){ val item = this.items.get(position) when (holder) { is TaskViewHolder -> { val viewModel = TaskItemViewModel.Task(item as TaskItem.Task, repository) holder.binding.viewModel = viewModel holder.binding.executePendingBindings() } is TaskAdViewHolder -> { val viewModel = TaskItemViewModel.Ad(item as TaskItem.Ad) holder.binding.viewModel = viewModel holder.binding.executePendingBindings() } } } } enum class ViewType(val type : Int){ Ad(0), Task(1); companion object { fun from(value:Int) : ViewType{ return ViewType.values().first { it.type == value } } } } class TaskAdViewHolder(val binding: ItemTaskAdBinding) : RecyclerView.ViewHolder(binding.root) class TaskViewHolder(val binding: ItemTaskBinding) : RecyclerView.ViewHolder(binding.root) } 長い!!のでポイントだけ・・・
  66. ViewHolder class TaskViewHolder(val binding:ItemTaskBinding) : RecyclerView.ViewHolder(binding.root) class TaskAdViewHolder(val binding:ItemTaskAdBinding) :

    RecyclerView.ViewHolder(binding.root) BindingをViewHolderで持つ
  67. TasksAdapter.onCreateViewHolder override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): RecyclerView.ViewHolder { val

    viewTypeEnum = ViewType.from(viewType) val layoutInflater = LayoutInflater.from(parent!!.context) return when(viewTypeEnum){ ViewType.Ad -> TaskAdViewHolder(DataBindingUtil.inflate(layoutInflater, R.layout.item_task_ad, parent, false)) ViewType.Task -> TaskViewHolder(DataBindingUtil.inflate(layoutInflater, R.layout.item_task, parent, false)) } } ViewHolderの作成
  68. TasksAdapter.onBindViewHolder override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) { if(holder !=

    null){ val item = this.items.get(position) when (holder) { is TaskViewHolder -> { val viewModel = TaskItemViewModel.Task(item as TaskItem.Task, repository) holder.binding.viewModel = viewModel holder.binding.executePendingBindings() } is TaskAdViewHolder -> { val viewModel = TaskItemViewModel.Ad(item as TaskItem.Ad) holder.binding.viewModel = viewModel holder.binding.executePendingBindings() } } } } ViewModelの生成 & ViewとViewModelの接続
  69. 完了

  70. RecyclerView • 基本的な実装パターン • 変更検知 • 実装の効率化

  71. 変更検知 タップしてTODOを追加

  72. 変更検知 • コレクションの子要素が変化したときにそれをど うViewに反映するか? ◦ まるごと更新(notifyDataSetChanged) ▪ 変更のアニメーションができない・・・ • 差分更新をする方法として以下の2方法がある

    ◦ ObservableList ◦ 差分検知機能を利用
  73. ObservableList • ObservableList(android.databinding.Observa bleList<T>)インターフェースの利用 ◦ ObservableArrayListという実装クラスを使うのが楽 ◦ コレクションに変更が起きると、コールバックに変更内容 が伝わる ◦

    Adapterで変更コールバックを登録し、変更が起きたら 更新
  74. Model ViewModel ObservableList Adapter ViewModel ViewModel View View View 変更通知(onItemRangeInserted)

    生成 Model Model Model 追加 ObservableList
  75. ObservableListの定義 public interface ObservableList<T> extends List<T> { void addOnListChangedCallback(ObservableList.OnListChangedCallback<? extends

    ObservableList<T>> listener); void removeOnListChangedCallback(ObservableList.OnListChangedCallback<? extends ObservableList<T>> listener); }
  76. OnListChangedCallback public abstract static class OnListChangedCallback<T extends ObservableList> { public

    OnListChangedCallback() { } public abstract void onChanged(T var1); public abstract void onItemRangeChanged(T var1, int var2, int var3); public abstract void onItemRangeInserted(T var1, int var2, int var3); public abstract void onItemRangeMoved(T var1, int var2, int var3, int var4); public abstract void onItemRangeRemoved(T var1, int var2, int var3); } 新規追加はこれが呼ばれる
  77. TasksAdapter class TasksAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { fun setData(items:ObservableList<TaskItemViewModel>){ items.addOnListChangedCallback([長いので省略]...{ override

    fun onItemRangeInserted(list: ObservableList<TaskItemViewModel>?, positionStart: Int, itemCount: Int) { notifyItemRangeInserted(positionStart, itemCount) } ……. 変更分だけAdapterを更新
  78. 差分検知 • ViewModelから新しいコレクションが渡されたと きに、古いコレクションとの差分を計算して、差 分の分だけViewを更新する

  79. 差分検知 ViewModel ViewModel View View Model Model 差分を計算 差分検知機能 Model

    Model Model 新しいコレクションが流れてくる NEW
  80. 差分検知 ViewModel ViewModel View View Adapter 差分検知機能 ViewModel View 生成

    差分を伝える Model Model Model NEW
  81. 参考:ライブラリ • 自前で実装するのは辛いので、ライブラリを使 いたい • DiffUtil ◦ 紹介だけ ◦ https://developer.android.com/reference/android/sup

    port/v7/util/DiffUtil.html • Epoxy ◦ 後ほど紹介だけ行います
  82. RecyclerView • 基本的な実装パターン • 変更検知 • 実装の効率化

  83. 実装の効率化 • Adapterは定型的な実装が多い ◦ ViewTypeの決定 ◦ ViewHolderの生成 ◦ 変更検知 ◦

    ….
  84. ライブラリの利用 • 独自に実装するよりも、ライブラリを利用する方 が良い • Adapterの実装を支援するライブラリはいくつか 存在するので、プロジェクトの要件に合ったもの を採用して下さい

  85. 参考:Epoxy • https://github.com/airbnb/epoxy • Airbnbが開発している、RecyclerViewの実装 支援ライブラリ • 非常に簡潔に実装することができる • データバインディングにも対応

    • 差分更新機能がある • ※ここでは紹介だけとさせて下さい
  86. データフロー

  87. 問題:複雑なMVVM ViewModel ViewModel ViewModel ViewModel 特にViewModel間が複 雑化 データフローの単純化を 考える

  88. なぜ複雑化するのか? • TODOアプリのサンプルを引き続き利用して説 明します

  89. サンプルにタスク数を追加

  90. タスクを完了 チェックすると完了 数が減る

  91. フロー TaskRepository WebAPI TaskItemViewModel .Task TasksViewModel

  92. TaskRepositoryの定義 class TaskRepository { fun getTasks() : Single<TaskItem> fun complete(id:Long)

    : Single<TaskItem> } RxJavaのSingle リポジトリが内部的にWebAPIにアクセスし、 返ってきた結果を1回だけ流す
  93. sealed class TaskItemViewModel{ class Task(private val parent:TasksViewModel, private val repository:

    TaskRepository, private val model:TaskItem.Task) : TaskItemViewModel(){ fun complete(){ repository.complete(model.id).subscribe ({ models -> parent.onComplete(models) }, {error -> //(省略) }) } } } TaskItemViewModel.Task Modelの更新 TasksViewModelを更新
  94. class TasksViewModel(private val repository: TaskRepository){ val items = ObservableField<List<TaskItem>>() val

    count = ObservableInt() fun onComplete(models:List<TaskItem>){ items.set(models) count.set(models.count { it is TaskItem.Task }) } } TasksViewModel Item, タスク数の更新
  95. 複雑化 • このサンプルのレベルだとまだ追えるが、このよ うなViewModel間のやり取りが増えるとどんど ん複雑化 • なぜか? ◦ ViewModel間でのやり取りにより必要以上にデータフ ローが複雑化

  96. ViewModelとModel Model ViewModel Modelの状態変更を要求 Modelの状態変更を受け取る Modelの状態を変更 -> Modelの状態変更に応じてViewModelが自動で 更新という形になっていればViewModel間のやり取りがなくなり、データフ ローが単純になる

  97. 改善 TaskRepository TasksViewModel WebAPI TaskItemViewModel .Task

  98. class TaskRepository { fun getTask() : Observable<TaskItem> fun complete(id:Long) }

    TaskRepository Modelの状態変更を通知 何回も流れ続けるため、 Single -> Observableに変更 Modelの状態変更を要求 返り値がない
  99. sealed class TaskItemViewModel{ class Task(private val repository: TaskRepository, private val

    model:TaskItem.Task) : TaskItemViewModel(){ fun delete(){ repository.complete(model.id) } } } TaskItemViewModel.Task Modelの状態変更を要求
  100. TasksViewModel class TasksViewModel(private val repository: TaskRepository) { val items =

    ObservableField<List<TaskItem>>() val count = ObservableInt() init { repository.getTask().subscribe { models -> items.set(models) count.set(models.count { it is TaskItem.Task }) } } } Modelの状態変更を監視し続ける Modelの状態変更が起きるたびに、常に最新の状態に書き換わる
  101. 完了 TaskRepository TasksViewModel WebAPI TaskItemViewModel .Task

  102. まとめ

  103. まとめ • 関心の分離を意識してView - ViewModel - Model を適切に分離し、ViewModelの肥大化 を防ぐ •

    RecyclerViewは基本的な実装パターンをベー スに変更検知や効率化を考慮 • データフローを単純化して複雑化しないように
  104. Fin