AndroidアプリにViewModelを導入しました

はじめまして。Misocaモバイルチームのtijinsです。

この記事は、弥生アドベントカレンダー14日目の記事です。

MVPからMVVMへ

Android版MisocaはModel-View-Presenter構造で作られていたのですが、Model-View-ViewModel構造へのリファクタリングが完了しました。

コードがスッキリしたので、リファクタリングの内容を紹介します。

MVPパターンとMVVMパターン

Model-View-Presenter(MVPパターン)

MVPパターンでは、PresenterがViewの参照(intrefaceで)を保持し、結果をViewに表示します。

Viewを直接参照せずにInterfaceにしておくことで、PresenterからViewの依存を排除しています。

@startuml
  interface PresenterResult
  class View implements PresenterResult
  {
    表示用データ
  }
  class Presenter
  class Model

  View --> Presenter: 読み込み・新規作成等の操作
  Presenter --> PresenterResult : 結果の表示
  Presenter --> Model
@enduml

Model-View-ViewModel(MVVMパターン)

MVVMパターンでは、ViewModelからViewの参照は不要です。

表示用のデータはViewModelが保持しており、ViewがViewModel上のデータを監視(Observe)して表示します。

@startuml
  class View
  note right of ViewModel : ViewModelは、操作結果により、\n保持する表示用データを更新する
  class ViewModel{
    表示用データ
  }
  class Model

  View --> ViewModel: 読み込み・新規作成等の操作\n変更の監視(変更があれば表示を更新する)
  ViewModel --> Model
@enduml

ViewModel化のメリット

ViewModelにも色々あるみたいなのですが、ここでのViewModelはデータを保持できるPresenterといった感じです。

PresenterからViewの参照を排除できる

ViewModel化前は、Presenterに非同期処理完了のコールバックとして、Viewの参照を(interfaceとしてですが)渡していました。

ViewModelを利用すると、ViewModelからViewへの参照は不要になります。

AndroidアプリでのViewModelのメリット

  • FragmentでもActivityライフサイクルでのデータ保持が可能

Fragmentにデータを保持していると、画面の回転等でFragmentが破棄された際に、データの読み込みが必要でした。

ActivityライフサイクルのViewModelを利用する事で、Fragmentが破棄されても、データの引き継ぎが可能になります。

  • ライフサイクル管理の単純化

Presenterが行う非同期処理の結果をFragmentで表示する場合、先にFragmentが破棄されていると、クラッシュしてしまう問題がありました。

ViewModelのライフサイクルはFragmentから分離されている為、Fragmentの状態を気にせずに、非同期処理が可能です。

リファクタリング前のコード

最初にリファクタリング前のコードです。

Viewを直接参照しないのは、PresenterからViewの依存を排除する為です。

こうしておくと、テスト用のダミークラスに差し替えたりできます。

@startuml

  interface ItemsResult
  {
    onItemLoaded(items)
    onFailed(message)
  }
  
  class ItemsFragment implements ItemsResult
  {
    items:List<Item>
  }
  class ItemsPresenter
  {
    loadItems(page)
  }
  class UseCase

  ItemsFragment --> ItemsPresenter: itemsの読み込み操作
  ItemsPresenter --> ItemsResult : 結果(items)
  ItemsPresenter --> UseCase


@enduml

ItemsViewInterface.kt

interface ItemsResult
{
    fun onItemsLoaded(items:List<Item>)
    fun onFailed(message:String)
}

ItemsPresenter.kt

class ItemsPresenter(
  private val context:Context,
  private val coroutineScope: CoroutineScope,
  private val view: ItemsResult) {

    private val useCase = ItemsUseCase()

    fun loadItems(page:Int) {
        coroutineScope.launch{
            try {
                val result = useCase.loadItems(context, page)
                view.onLoadItems(result)
            } catch (ex: Exception) {
                view.onFailed(ex.message)
            }
        }
    }
}

ItemsFragment.kt

class ItemsFragment:Fragment,ItemsResult
{
    // 請求書一覧のアダプターです
    private var adapter: ListAdapter<Item, RecyclerView.ViewHolder>?
    private var presenter: ItemsPresenter?
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        presenter = ItemsPresenter(requireContext(), viewLifecycleOwner.lifecycleScope, this)
        presenter?.loadItems(1)
    }
    
    override fun onItemsLoaded(items:List<Item>){
        adapter?.submitList(items)
    }
    
    override fun onFailed(message: String){
        // エラー表示する
    }
}

リファクタリング後のコード

ViewModelの実装

ViewModelからFragmentへの参照が無くなり、すっきりしていると思います。

@startuml

  class ItemsViewModel
  {
    - itemsBuffer:List<Item>
    + items:MutableLiveData<List<Item>>
    + networkState:MutableLiveData<NetworkState>
    laodItems(page)
  }

  note right of Fragment: Items, networkStateの変更を監視している
  class Fragment
  class UseCase

  Fragment --> ItemsViewModel : itemsの読み込み
  ItemsViewModel --> UseCase

@enduml

ItemsViewModel.kt

MisocaのAndroid版では、APIとの通信にContextが必要な為、AndroidViewModelを継承しています。

AndroidViewModelを継承することで、ApplicationContextをgetApplication()で参照可能になります。

ただし、ApplicationContextには言語設定や画面向きの変更が反映されない為、ViewModel内でUIに関する処理を行わないよう注意が必要です。

class ItemsViewModel(context: Context) : AndroidViewModel(context.applicationContext as Application){
    // 通信中状態、エラーの通知用のobserve可能なプロパティ
    val networkState = MutableLiveData<NetworkState>()    
    // 表示するデータの実体(LiveData経由でFragmentに渡す)
    private val itemsBuffer = ArrayList<Item>()
    // observe可能なプロパティ
    val items = MutableLiveData<List<Item>>()
    
    // データの一覧を読み込みます
    fun loadItems(page: Int) {
        viewModelScope.launch {
            try {
                networkState.postValue(NetworkState.LOADING)
                
                // loadItemsはDispatchers.IOで動作するsuspend functionです。
                val result = useCase.loadItems(getApplication(), page)
                itemsBuffer.addAll(result)
                
                // observeしているViewに更新を通知する
                items.postValue(itemsBuffer)
                networkState.postValue(NetworkState.LOADED)
            } catch (ex: Exception) {
                // エラーメッセージを表示
                networkState.postValue(NetworkState.error(ex.message))
            }
        }
    }
}

NetworkStateの実装は、下記のコードを参考にしました。

https://github.com/android/architecture-components-samples/tree/master/PagingWithNetworkSample

ViewModel上のデータ変更を監視する

ViewModelをFragmentから使う場合は、fragment-ktxにあるExtension(viewModels,activityViewModels)を使用すると便利です。

build.gradle

dependencies {
    implementation "androidx.fragment:fragment-ktx:$fragmentKtxVersion"
}

ItemsFragment.kt

class ItemsFragment:Fragment
{
    // fragment-ktxのactivityViewModelsでviewModelを取得します
    private val viewModel: ItemsViewModel by activityViewModels()
    // 請求書一覧のアダプターです
    private var adapter: ListAdapter<Item, RecyclerView.ViewHolder>?

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)

     observeViewModel()
     // itemsの読み込みを開始します
     viewModel.loadItems(1)
   }

    private fun observeViewModel() {
        // itemsの更新を監視します
        viewModel.items.observe(viewLifecycleOwner) { items ->
            // List<Item>をそのまま通知しても、DiffUtilが効率的に再描画してくれるので気にしない
            adapter?.submitList(items)
        }
        
        // 通信中状態の更新を監視します
        viewModel.networkState.observe(viewLifecycleOwner) {
            when (it.status) {
                Status.RUNNING ->{
                    // プログレスバーを表示
                }
                Status.FAILED -> {
                    // エラー表示
                }
                Status.Success -> {
                    // プログレスバーを消す
                }
            }
        }
    }
}

おまけ

この画面は2つのタブがそれぞれFragmentになっているのですが、ViewModelを通じて2つのタブがデータを共有しています。

タブ間(Fragment間)でのデータの移動も、ViewModelを使用する事で、簡単に実現できています。

スクリーンショット 2020-11-13 10.37.55.png (47.6 kB)

まとめ

Viewの操作が不要になって、スッキリしました

宣伝

Misoca 開発チームでは、モバイルアプリエンジニア(iOS, Android)を募集しています。

www.wantedly.com