Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

アジェンダ ● この発表について ● MVVM概要 ● 関心の分離 ● RecyclerView ● データフロー ● まとめ

Slide 4

Slide 4 text

この発表について

Slide 5

Slide 5 text

MVVM View Model ViewModel

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

複雑なMVVM ViewModel ViewModel ViewModel ViewModel

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

MVVM概要

Slide 12

Slide 12 text

MVVM概要 ● アプリケーションをModel / View / ViewModel に分割するアーキテクチャパターン ● MicrosoftのKen CooperとTed Petersが開発 ● WPF/Silverlightで使われていたが、最近は他 のプラットフォームでも採用例が多い

Slide 13

Slide 13 text

関心の分離

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

ViewとViewModelのつながり View ViewModel Viewでイベント発生 -> ViewModelに通知 ● ViewとViewModelのつながりはデータバインディングで実現 ● ViewはViewModelを監視し、状態の変更に応じてViewが変更され る ● ViewModelはViewへの参照を持たない 変更通知 イベント通知

Slide 25

Slide 25 text

ViewModelとModelのつながり ViewModel ViewModelはModelの状態変更を要求 ● ViewModelをModelの状態を監視し、状態の変更に応じて ViewModelが変更される ● ModelはViewModelへの参照を持たない Model 変更通知 状態変更を要求

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

関心の分離 ● Android開発における実践的な方法 ○ テスタビリティを基準として使う ○ ViewとViewModel ○ ViewModelとModel ○ ダイアログ表示・画面遷移 ○ Context

Slide 28

Slide 28 text

テスタビリティを基準として使う ● 関心の分離ができているかの基準の1つとし て、ViewModelのテスタビリティを意識する ● ユニットテストを書く・書かないに関わらず、良い 設計の指針となる ● 依存するオブジェクトについてはMockへの差し 替えを可能にする ○ Dagger2などのDIコンテナの利用を推奨

Slide 29

Slide 29 text

テスタビリティのポイント ● テストできないものが混入していないか ○ Viewへの参照 ○ データベース、ネットワーク、ファイルなどを直接操作し ていない ○ スレッドの制御を行っていない ○ …etc

Slide 30

Slide 30 text

関心の分離 ● Android開発における実践的な方法 ○ テスタビリティを基準として使う ○ ViewとViewModel ○ ViewModelとModel ○ ダイアログ表示・画面遷移 ○ Context

Slide 31

Slide 31 text

ViewとViewModel ● Data binding expression ● View -> ViewModel ● ViewModel -> View ● ViewModelとModel

Slide 32

Slide 32 text

Data binding expression ● レイアウトXMLの中に式を記述し、Viewと ViewModelの接続を行う class ViewModel{ val name : ObservableField }

Slide 33

Slide 33 text

ViewとViewModel ● Data binding expression ● View -> ViewModel ● ViewModel -> View

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

View -> ViewModel

Slide 36

Slide 36 text

View -> ViewModel

Slide 37

Slide 37 text

ViewとViewModel ● Data binding expression ● View -> ViewModel ● ViewModel -> View

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

カスタムセッター(画像) ...   @BindingAdapter("imageUrl") fun ImageView.setImageUrl(imageUrl:String){ Picasso.with(this.context).load(imageUrl).into(this) } Kotlinの場合拡張メソッドを使って BindingAdapterを記述可能

Slide 40

Slide 40 text

関心の分離 ● Android開発における実践的な方法 ○ テスタビリティを基準として使う ○ ViewとViewModel ○ ViewModelとModel ○ ダイアログ表示・画面遷移 ○ Context

Slide 41

Slide 41 text

ViewModelとModel ● ViewModel -> Model ○ Modelの状態を変更する ● Model -> ViewModel ○ ViewModelがModelの状態を監視する ○ 監視は、RxJava等のインターフェースを使う ○ ※本発表ではRxJavaを使います。説明を単純にするた め、エラー処理やライフサイクルの管理は省略します。

Slide 42

Slide 42 text

例 UserSettingViewModel SharedPreference load バインド SettingA:ON

Slide 43

Slide 43 text

悪い例(UserSettingViewModel) class UserSettingViewModel(private val context: Context) { val settingA = ObservableField() fun load(){ val pref = context.getSharedPreferences("UserSetting", Context.MODE_PRIVATE) val value = pref.getString("SettingA", "") this.settingA.set(value) } } ViewModelがSharedPreferenceを直接参照

Slide 44

Slide 44 text

改善 UserSettingViewModel SharedPreference UserSettingRepository 監視 load バインド SettingA:ON

Slide 45

Slide 45 text

UserSettingRepository class UserSettingRepository(private val context:Context) { private val settingASubject = PublishSubject.create() fun getSettingA() : Observable{ 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

Slide 46

Slide 46 text

UserSettingViewModel class UserSettingViewModel(private val repository: UserSettingRepository) { val settingA = ObservableField() init { repository.getSettingA().subscribe { value -> settingA.set(value) } } fun load(){ repository.loadSettingA() } } 状態を監視し続け、変更があっ たらsettingAを更新 ※将来的に設定の変更機能をつ けたときも、変更はここに流れて くる repositoryにデータを読み込ませる

Slide 47

Slide 47 text

関心の分離 ● Android開発における実践的な方法 ○ テスタビリティを基準として使う ○ ViewとViewModel ○ ViewModelとModel ○ ダイアログ表示・画面遷移 ○ Context

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

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

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

関心の分離 ● Android開発における実践的な方法 ○ テスタビリティを基準として使う ○ ViewとViewModel ○ ViewModelとModel ○ ダイアログ表示・画面遷移 ○ Context

Slide 52

Slide 52 text

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(); ・・・・・・・ 多い!!!

Slide 53

Slide 53 text

Context ● View, ViewModel, Modelのそれぞれで必要と する場所がある ● ViewModelがContextの機能を使う場合 ○ 使うとしたらgetStringあたりか ○ ラッパーを作るなどしてViewModelでのみ使って良いメ ソッドを制限した方が良い

Slide 54

Slide 54 text

RecyclerView

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

ViewModelの定義 class TasksViewModel(...){ val items : ObservableField> } sealed class TaskItemViewModel{ class Task(...) : TaskItemViewModel(){ val name : String } class Ad(...) : TaskItemViewModel(){ ... } }

Slide 60

Slide 60 text

Adapterの生成 Adapter RecyclerView

Slide 61

Slide 61 text

Activity class TasksActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { ... val binding = DataBindingUtil.setContentView(this, R.layout.activity_tasks) binding.recyclerView.adapter = TasksAdapter(repository) Adapterを生成

Slide 62

Slide 62 text

ModelをAdapterに渡す Adapter データバインディングで渡す Model Model Model

Slide 63

Slide 63 text

BindingAdapter + バインド @BindingAdapter("taskItems") fun RecyclerView.setTaskItems(items:List>?){ (this.adapter as TasksAdapter).setData(items ?: listOf()) } activity_tasks.xml RecyclerViewExtension.kt

Slide 64

Slide 64 text

ViewModel AdapterでView/ViewModelの生成と接続 ViewModel ViewModel View View View データバインディングで渡す 生成 生成 生成 Adapter Model Model Model

Slide 65

Slide 65 text

TasksAdapter class TasksAdapter(private val repository: TaskRepository) : RecyclerView.Adapter() { private var items = listOf() fun setData(items:List){ 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) } 長い!!のでポイントだけ・・・

Slide 66

Slide 66 text

ViewHolder class TaskViewHolder(val binding:ItemTaskBinding) : RecyclerView.ViewHolder(binding.root) class TaskAdViewHolder(val binding:ItemTaskAdBinding) : RecyclerView.ViewHolder(binding.root) BindingをViewHolderで持つ

Slide 67

Slide 67 text

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の作成

Slide 68

Slide 68 text

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の接続

Slide 69

Slide 69 text

完了

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

ObservableList ● ObservableList(android.databinding.Observa bleList)インターフェースの利用 ○ ObservableArrayListという実装クラスを使うのが楽 ○ コレクションに変更が起きると、コールバックに変更内容 が伝わる ○ Adapterで変更コールバックを登録し、変更が起きたら 更新

Slide 74

Slide 74 text

Model ViewModel ObservableList Adapter ViewModel ViewModel View View View 変更通知(onItemRangeInserted) 生成 Model Model Model 追加 ObservableList

Slide 75

Slide 75 text

ObservableListの定義 public interface ObservableList extends List { void addOnListChangedCallback(ObservableList.OnListChangedCallback extends ObservableList> listener); void removeOnListChangedCallback(ObservableList.OnListChangedCallback extends ObservableList> listener); }

Slide 76

Slide 76 text

OnListChangedCallback public abstract static class OnListChangedCallback { 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); } 新規追加はこれが呼ばれる

Slide 77

Slide 77 text

TasksAdapter class TasksAdapter : RecyclerView.Adapter() { fun setData(items:ObservableList){ items.addOnListChangedCallback([長いので省略]...{ override fun onItemRangeInserted(list: ObservableList?, positionStart: Int, itemCount: Int) { notifyItemRangeInserted(positionStart, itemCount) } ……. 変更分だけAdapterを更新

Slide 78

Slide 78 text

差分検知 ● ViewModelから新しいコレクションが渡されたと きに、古いコレクションとの差分を計算して、差 分の分だけViewを更新する

Slide 79

Slide 79 text

差分検知 ViewModel ViewModel View View Model Model 差分を計算 差分検知機能 Model Model Model 新しいコレクションが流れてくる NEW

Slide 80

Slide 80 text

差分検知 ViewModel ViewModel View View Adapter 差分検知機能 ViewModel View 生成 差分を伝える Model Model Model NEW

Slide 81

Slide 81 text

参考:ライブラリ ● 自前で実装するのは辛いので、ライブラリを使 いたい ● DiffUtil ○ 紹介だけ ○ https://developer.android.com/reference/android/sup port/v7/util/DiffUtil.html ● Epoxy ○ 後ほど紹介だけ行います

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

参考:Epoxy ● https://github.com/airbnb/epoxy ● Airbnbが開発している、RecyclerViewの実装 支援ライブラリ ● 非常に簡潔に実装することができる ● データバインディングにも対応 ● 差分更新機能がある ● ※ここでは紹介だけとさせて下さい

Slide 86

Slide 86 text

データフロー

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

フロー TaskRepository WebAPI TaskItemViewModel .Task TasksViewModel

Slide 92

Slide 92 text

TaskRepositoryの定義 class TaskRepository { fun getTasks() : Single fun complete(id:Long) : Single } RxJavaのSingle リポジトリが内部的にWebAPIにアクセスし、 返ってきた結果を1回だけ流す

Slide 93

Slide 93 text

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を更新

Slide 94

Slide 94 text

class TasksViewModel(private val repository: TaskRepository){ val items = ObservableField>() val count = ObservableInt() fun onComplete(models:List){ items.set(models) count.set(models.count { it is TaskItem.Task }) } } TasksViewModel Item, タスク数の更新

Slide 95

Slide 95 text

複雑化 ● このサンプルのレベルだとまだ追えるが、このよ うなViewModel間のやり取りが増えるとどんど ん複雑化 ● なぜか? ○ ViewModel間でのやり取りにより必要以上にデータフ ローが複雑化

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

改善 TaskRepository TasksViewModel WebAPI TaskItemViewModel .Task

Slide 98

Slide 98 text

class TaskRepository { fun getTask() : Observable fun complete(id:Long) } TaskRepository Modelの状態変更を通知 何回も流れ続けるため、 Single -> Observableに変更 Modelの状態変更を要求 返り値がない

Slide 99

Slide 99 text

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

Slide 100

Slide 100 text

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

Slide 101

Slide 101 text

完了 TaskRepository TasksViewModel WebAPI TaskItemViewModel .Task

Slide 102

Slide 102 text

まとめ

Slide 103

Slide 103 text

まとめ ● 関心の分離を意識してView - ViewModel - Model を適切に分離し、ViewModelの肥大化 を防ぐ ● RecyclerViewは基本的な実装パターンをベー スに変更検知や効率化を考慮 ● データフローを単純化して複雑化しないように

Slide 104

Slide 104 text

Fin