「Androidアプリ設計パターン入門」を読んで(その2)

今回の記事では、MVVM(Model-View-ViewModel)と呼ばれるアーキテクチャについて説明していきます。

サンプルアプリ

今後しばらくの記事では、Google社によって提供されているAndroid Architecture BlueprintsのTODOアプリをベースに話を進めていきます。
TODOアプリは以下の5画面を持っています。

f:id:tkhs0604:20190706002533p:plain


ソースコードはこちらにあります。

github.com


なお、このアプリはMVVMの実装の正解というわけではありません。また、重要なことはコードを構造化する方法を理解することなので、ここでは実装すべてには触れません。
ここで触れなかった部分につきましては、上記のリンクをご参照ください。

MVVM(Model-View-ViewModel)とは

MVVMは、UIの分離を目的としたアーキテクチャです。
後述するデータバインディングという仕組みによってViewを抽象化し、ビジネスロジックから分離します。

MVVMの各要素の主な役割は以下の通りです。

  • Model
  • View
    • UIを定義する
  • ViewModel
    • ModelとViewの仲介役
    • Modelを操作し、Viewが使いやすい形でデータを提供する


データバインディングとは

データバインディングは、データとUIを紐付ける仕組みです。
データバインディングには以下の2種類があります。

  • 単方向データバインディング
    • データに変更があった場合にUIが更新される
  • 双方向データバインディング
    • データに変更があった場合にUIが更新され、UIに操作があった場合にデータが更新される


Androidでは、Android Jetpackの一部として提供されているデータバインディングライブラリによって後者の双方向データバインディングを実現できます。

developer.android.com


TODOアプリの実装

アーキテクチャの概要を説明したところで、ここからはTODOアプリが具体的にどのように実装されているかを見ていきます。

まず、TODOアプリのプロジェクト構成は以下の通りです。このプロジェクトは基本的に画面単位でパッケージ管理されています。
dataパッケージにはタスクを保存するためのリポジトリとインタフェースが格納されています。utilパッケージにはいわゆるユーティリティクラスが格納されています。

com.example.android.architecture.blueprints.todoapp
 ├ addedittask (追加画面、編集画面)
 ├ data
 ├ statistics  (統計画面)
 ├ taskdetail  (詳細画面)
 ├ tasks       (トップ画面)
 └ util


ここからは、例として詳細画面のソースコードを見ていきたいと思います。
taskdetailパッケージの中身は以下の通りです。

taskdetail
 ├ TaskDetailActivity
 ├ TaskDetailFragment
 ├ TaskDetailNavigator
 └ TaskDetailViewModel


この4つのクラスの役割と実装について、以下にまとめていきます。

ViewModelの役割と実装

まず、ViewModelの役割と実装を見ていきます。

TaskDetailViewModelの実装の大部分は継承元のTaskViewModelで行われているため、内容が分かりづらいかもしれません。
TaskDetailViewModelの役割は、Navigatorを経由してFragmentとActivityを橋渡しすることです。
TaskViewModelはレポジトリへアクセスし、データを取得したり更新する役割を担っています。

/**
  * ViewModel
  */
class TaskDetailViewModel(
        context: Context, 
        tasksRepository: TasksRepository
) : TaskViewModel(context, tasksRepository) {

    private var mTaskDetailNavigator: TaskDetailNavigator? = null

    // Navigator(Activity)の参照を受け取る
    fun setNavigator(taskDetailNavigator: TaskDetailNavigator) {
        mTaskDetailNavigator = taskDetailNavigator
    }

    // メモリリークを防ぐため、Activity(Navigator)が破棄されるときに
    // 参照を破棄する
    fun onActivityDestroyed() {
        mTaskDetailNavigator = null
    }

    // タスクを削除し、トップ画面へ遷移する
    override fun deleteTask() {
        super.deleteTask()
        mTaskDetailNavigator?.onTaskDeleted()
    }

    // 編集画面へ遷移する
    fun startEditTask() {
        mTaskDetailNavigator?.onStartEditTask()
    }

}


Fragmentの役割と実装

次に、Fragmentの役割と実装を見ていきます。

TaskDetailFragmentの役割は、UIの初期設定とユーザ操作をViewModelへ伝えることです。
タスク一覧以外のFloatingActionButtonSnackbarに関する処理はFragmentに書かなくても問題はまったくないのですが、ユーザ操作をViewModelへ伝える役割をFragmentに集約するためにActivityではなくFragmentで宣言しています。

なお、データバインディングに利用されているTaskdetailFragBindingtaskdetail_frag.xmlの内容を基にコンパイル時に自動生成されるクラスです。

/**
  * Fragment
  */
class TaskDetailFragment : Fragment() {

    private var mViewModel: TaskDetailViewModel? = null
    private lateinit var mSnackbarCallback: Observable.OnPropertyChangedCallback

    // ViewModelの参照を受け取る
    fun setViewModel(taskViewModel: TaskDetailViewModel) {
        mViewModel = taskViewModel
    }

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)
        setupFab()
        setupSnackbar()
    }

    override fun onCreateView(
            inflater: LayoutInflater, 
            container: ViewGroup?, 
            savedInstanceState: Bundle?
    ): View? {
        // タスク詳細部分のUIを作成する
        val view = inflater.inflate(R.layout.taskdetail_frag, container, false)

        // ViewとViewModelをバインドする
        TaskdetailFragBinding.bind(view).apply {
            viewmodel = mViewModel
        }

        setHasOptionsMenu(true)
        return view
    }

    override fun onDestroy() {
        mViewModel?.snackbarText?.removeOnPropertyChangedCallback(mSnackbarCallback)
        super.onDestroy()
    }

    ...

    // FloatingActionButtonの初期設定を行う
    private fun setupFab() {
        activity?.findViewById<FloatingActionButton>(R.id.fab_edit_task)?.setOnClickListener {
            mViewModel?.startEditTask()
        }
    }

    // Snackbarの初期設定を行う
    private fun setupSnackbar() {
        mSnackbarCallback = object : Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(observable: Observable, i: Int) {
                SnackbarUtils.showSnackbar(view, mViewModel?.getSnackbarText())
            }
        }
        mViewModel?.snackbarText?.addOnPropertyChangedCallback(mSnackbarCallback)
    }

    ...

}


Navigatorの役割と実装

TaskDetailNavigatorの役割は、ユーザ操作をFragmentからActivityへと橋渡しすることです。
TODOアプリではActivityがNavigatorの処理を実装しているので、詳細は後述します。

/**
  * Navigator
  */
interface TaslDetailNavigator {
    fun onTaskDeleted()
    fun onStartEditTask()
}


Activityの役割と実装

最後に、Activityの役割と実装を見ていきます。

TaskDetailActivityの役割は、インスタンスのライフサイクルを管理することです。具体的には、ViewModelのライフサイクルを管理します。
そのため、TaskDetailViewModelインスタンスはFragmentではなくActivityで生成しています。
詳細は割愛しますが、このこととfindOrCreateViewFragment()によってViewModelのライフサイクルをActivityと一致させています。

/**
  * Activity
  */
class TaskDetailActivity : AppCompatActivity(), TaskDetailNavigator {

    private lateinit var mTaskViewModel: TaskDetailViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.taskdetail_act)
        setupToolbar()

        val taskDetailFragment = findOrCreateViewFragment()
        mTaskViewModel = findOrCreateViewModel()

        // ViewModelにNavigator(Activity)の参照を渡す
        mTaskViewModel.setNavigator(this)

        // FragmentにViewModelの参照を渡す
        taskDetailFragment.setViewModel(mTaskViewModel)
    }

    override fun onDestroy() {
        mTaskViewModel.onActivityDestroyed()
        super.onDestroy()
    }

    ...

    // TaskDetailNavigator#onTaskDeleted()の実装
    override fun onTaskDeleted() {
        setResult(DELETE_RESULT_OK)
        finish()
    }

    // TaskDetailNavigator#onStartEditTask()の実装
    override fun onStartEditTask() {
        val taskId = intent.getStringExtra(EXTRA_TASK_ID)

        val intent = Intent(this, AddEditTaskActivity::class.java).apply {
            putExtra(AddEditTaskFragment.ARGUMENT_EDIT_TASK_ID, taskId)
        }
        startActivityForResult(intent, REQUEST_EDIT_TASK)
    }

    ...

}


ここまでの話をまとめると以下のようになります。

Android Architecture Blueprintsより引用


この図からもViewModelがViewを参照していない、すなわちUIが分離されていることが分かります。
このようにMVVMでは、原則としてViewとViewModelの依存方向を単方向に制限することで、それぞれのクラスの役割を明確にしています。

依存方向の原則に反する処理とその対応

上記の通り、MVVMではViewとViewModelの依存方向を単方向に制限することを原則としていますが、Androidではその原則に反する処理があります。
TODOアプリにおいては、Snackbarによるメッセージ表示処理がそれに当たります。

この理由として、AndroidフレームワークではToastSnackbarを表示する際にContextViewへの参照が必要になるためです。
このContextViewといったUIに関する情報をViewModelが持つことは原則できません。そのため、TODOアプリでSnackbarを表示するためには何かしらの形でViewModelからActivityやFragmentへ通知する方法を採らなければなりません。
TODOアプリでは、この課題をデータバインディングライブラリのObservableFieldという仕組みを利用して解決しようとしています。

TaskDetailFragmentソースコードを再掲します。ここでの説明に不要な処理は省略しています。

/**
  * Fragment
  */
class TaskDetailFragment : Fragment() {

    private var mViewModel: TaskDetailViewModel? = null
    private lateinit var mSnackbarCallback: Observable.OnPropertyChangedCallback

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        ...
        setupSnackbar()
    }

    ...

    override fun onDestroy() {
        mViewModel?.snackbarText?.removeOnPropertyChangedCallback(mSnackbarCallback)
        super.onDestroy()
    }

    ...

    // Snackbarの初期設定を行う
    private fun setupSnackbar() {
        mSnackbarCallback = object : Observable.OnPropertyChangedCallback() {
            override fun onPropertyChanged(observable: Observable, i: Int) {
                SnackbarUtils.showSnackbar(view, mViewModel?.getSnackbarText())
            }
        }
        mViewModel?.snackbarText?.addOnPropertyChangedCallback(mSnackbarCallback)
    }

    ...

}


snackbarTextというプロパティに対してコールバックを設定しています。
このsnackbarTextプロパティはTaskViewModelが持っているObsevableFieldです。このsnackbarTextの値が更新されるとonPropertyChanged(observable, i)が呼び出されます。
ViewModelで処理が完結せずFragmentでViewを参照してUIを更新することにはなってしまいますが、このような対応が現実的であると考えられます。


MVVMアーキテクチャに関する説明は以上です。
次回の記事ではMVPアーキテクチャを取り扱います。