2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ViewModelの実装方法まとめ

Posted at

前回はNavigationに関しての記事を書きましたが、今回はkotlinにおけるViewModelの実装方法、LiveDataの使い方、DataBindingの適用方法についてまとめていきます。

この記事の内容

公式のドキュメントやトレーニング「Android Kotlin の基礎」のレッスン5「アーキテクチャ コンポーネント」を参考に実装のポイントとエッセンスをまとめていきます。
ViewModelとは何か、またMVVMデザインパターン等については触れません。

前提知識

  • kotlinの基礎的な文法
  • AndroidStudioの使い方/アプリの作り方
  • 画面や画面部品の配置方法
  • Navigationの実装方法(前回記事参照)

開発環境

  • Windows 10 Home
  • Android Studio 4.2.1

作成するサンプル

StartFragmentSecondFragmentFinalFragment の3つの画面を持ち、遷移ボタンでそれぞれの画面に遷移します。
SecondFragment には AddButtonSubtractButton の2種類のボタンがあり、これをクリックすることで画面上のscoreが加算/減算されます。
このクリック時の処理(scoreの更新、画面遷移)をViewModelにより置き換えていきます。
リファクタリング前後で画面の動きは変わりません。

app.gif

GetStarted

それではさっそく作業を始めましょう。

build.gradle(app)の更新

build.gradle(app) ファイルを開き、dependenciesブロックに以下の通りimplementationを追記してください。

implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'

ViewModelの作成

Fragmentに対しViewModelを作成します。 java フォルダに Kotlin Class を追加してください。
クラス名は StartViewModel 等が分かりやすいでしょう。
作成したクラスはViewModel()を継承させ、initブロックを追加します。

class SecondViewModel : ViewModel() {
    init {
    }
}

SecondFragment のメンバ変数としてViewModelを定義します。
ViewModelonCreateView()メソッドで生成します。

class SecondFragment : Fragment() {

    private var _score = 1

    private lateinit var viewModel: SecondViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // (略)
        viewModel = ViewModelProvider(this).get(SecondViewModel::class.java)
        // (略)
    }
}

ViewModelの実装

SecondFragment では画面に描画するスコアの変数をFragment内部で保持していますが、これを SecondViewModel に移し替えます。
同時AddButtonSubtractButtonがクリックされた際の処理をメソッドとして定義しておきます。

class SecondViewModel : ViewModel() {

    var score = 0
    var displayScore = ""

    init {
    }

    fun addScore() {
        score++
        displayScore = score.toString()
    }

    fun subtractScore() {
        score++
        displayScore = score.toString()
    }
}

あわせて SecondFragment 側もリファクタリングします。
Bindingオブジェクトの変数にViewModelの値を設定している点に注目してください。

class SecondFragment : Fragment() {

    private var _score = 1

    private lateinit var binding: FragmentSecondBinding
    private lateinit var viewModel: SecondViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_second,
            container,
            false
        )

        // viewModelの生成
        viewModel = ViewModelProvider(this).get(SecondViewModel::class.java)

        // クリックリスナを設定
        binding.toFinalFragmentButton.setOnClickListener { view: View ->
            view.findNavController().navigate(
                SecondFragmentDirections.actionSecondFragmentToFinalFragment(_score)
            )
        }
        binding.addButton.setOnClickListener {
            viewModel.addScore()
            _score = viewModel.score
            binding.scoreText.text = viewModel.displayScore
        }
        binding.subtractButton.setOnClickListener {
            viewModel.subtractScore()
            _score = viewModel.score
            binding.scoreText.text = viewModel.displayScore
        }

        return binding.root
    }
}

ViewModelFactoryの作成

これは必ずしもマストな作業ではないのですが、ViewModelを生成するViewModelFactoryを作成しておきます。
factory methodCreational Patternの一種だそうです(スミマセンこの辺りはおれも勉強不足)。
ここでは FinalFragmentViewModelViewModelFactoryを新たに作成します。
FinalViewModel はこんな感じ。

class FinalViewModel(finalScore: Int) : ViewModel() {
    var score = finalScore
    init {
    }
}

FinalViewModelFactory はこう。Factoryクラスはcreate()メソッドを定義する必要があります。

class FinalViewModelFactory(private val finalScore: Int) : ViewModelProvider.Factory {
    override fun <T : ViewModel?> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(FinalViewModel::class.java)) {
            return FinalViewModel(finalScore) as T
        }
        throw IllegalArgumentException("不正なViewModelクラスが引数に設定されました。")
    }
}

FinalFragment も下記の通りリファクタリングします。
ViewModelの生成方法が前節で紹介したものと異なる点に注意してください。

class FinalFragment : Fragment() {

    private lateinit var binding: FragmentFinalBinding
    private lateinit var viewModel: FinalViewModel
    private lateinit var viewModelFactory: FinalViewModelFactory

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_final,
            container,
            false
        )

        // 引数
        val args = FinalFragmentArgs.fromBundle(requireArguments())

        // viewModelFactoryの生成
        viewModelFactory = FinalViewModelFactory(args.argsScore)

        // viewModelの生成
        viewModel = ViewModelProvider(this, viewModelFactory)
            .get(FinalViewModel::class.java)

        // 設定
        binding.finalScoreText.text = viewModel.score.toString()

        return binding.root
    }
}

LiveData

LiveDataの導入

ViewModelのメンバ変数をLiveDataに置き換えます。
LiveDataを活用することでこれまで明示的に設定していた画面上のscoreが、値の更新と同時に自動的に置き換わります。
SecondViewModel を下記の通りリファクタリングします。

class SecondViewModel : ViewModel() {

    var score = MutableLiveData<Int>()
    var displayScore = MutableLiveData<String>()

    init {
        score.value = 0
        displayScore.value = ""
    }

    fun addScore() {
        score.value = (score.value)?.plus(1)
        displayScore.value = score.value.toString()
    }

    fun subtractScore() {
        score.value = (score.value)?.minus(1)
        displayScore.value = score.value.toString()
    }
}

SecondFragment のリスナはこんな感じ。

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // (略)
        binding.addButton.setOnClickListener {
            viewModel.addScore()
            _score = viewModel.score.value?:0
            binding.scoreText.text = viewModel.displayScore.value
        }
        binding.subtractButton.setOnClickListener {
            viewModel.subtractScore()
            _score = viewModel.score.value?:0
            binding.scoreText.text = viewModel.displayScore.value
        }
        // (略)
    }
}

Observerの登録

LiveDataの状態を監視するObserverを実装していきます。
SecondFragmentViewModel生成処理の直下に、以下のコードを追記してください。

        // Observerの登録
        viewModel.displayScore.observe(viewLifecycleOwner, Observer { newDisplayScore ->
            binding.scoreText.text = newDisplayScore
        })

これによりクリックリスナのbinding.scoreText.text = viewModel.displayScore.valueという記述は不要となるため、削除します。

アプリを起動してみてください。
Fragment側で明示的に設定していたsocreが、自動で更新されるようになりました。

Observerのカプセル化

デザインパターンとして、ViewModelで保持する変数は基本的にViewModel内部でしか必要ありません。
つまりViewModelではread onlyの変数のみをpublicとして定義すればより安全な設計となるため、カプセル化の検討を行います。
_SecondViewModel_で保持するsocredisplayScoreを下記の通りprivateMutableLiveDatapublicLiveDataに分離します。
あわせてViewModel内で扱う変数は、すべてアンダースコアつきのものに修正しておきます。

    private val _score = MutableLiveData<Int>()
    val score: LiveData<Int>
        get() = _score

    private val _displayScore = MutableLiveData<String>()
    val displayScore:LiveData<String>
        get() = _displayScore

scoreの監視

仮に SecondFragment 上のscoreが10に達したら強制的に FinalFragment に遷移する、という要件があったとします。
これを実現するためには SecondViewModel を下記の通り改修します。
なおonToFinalComplete()は画面再描画がされた場合に正常にBoolの値を保持するために必要な処理です。

class SecondViewModel : ViewModel() {

    private val _score = MutableLiveData<Int>()
    val score: LiveData<Int>
        get() = _score

    private val _displayScore = MutableLiveData<String>()
    val displayScore:LiveData<String>
        get() = _displayScore

    // 追記
    private val _eventToFinal = MutableLiveData<Boolean>()
    val eventToFinal:LiveData<Boolean>
        get() = _eventToFinal

    init {
        _score.value = 0
        _displayScore.value = ""
    }

    fun addScore() {
        _score.value = (score.value)?.plus(1)
        _displayScore.value = score.value.toString()

        // 修了判定
        if (_score.value == 10) {
            onToFinal()
        }
    }

    fun subtractScore() {
        _score.value = (score.value)?.minus(1)
        _displayScore.value = score.value.toString()
    }

    // 追記
    fun onToFinal() {
        _eventToFinal.value = true
    }

    // 追記
    fun onToFinalComplete() {
        _eventToFinal.value = false
    }
}

SecondFragment は画面遷移をメソッド化しつつ、eventToFinalObserverを追加しました。

class SecondFragment : Fragment() {

    private var _score = 1

    private lateinit var binding: FragmentSecondBinding
    private lateinit var viewModel: SecondViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_second,
            container,
            false
        )

        // viewModelの生成
        viewModel = ViewModelProvider(this).get(SecondViewModel::class.java)

        // Observerの登録
        viewModel.displayScore.observe(viewLifecycleOwner, Observer { newDisplayScore ->
            binding.scoreText.text = newDisplayScore
        })
        // 修了判定
        viewModel.eventToFinal.observe(viewLifecycleOwner, Observer { toFinal ->
            if (toFinal) {
                toFinalFragment()
            }
        })

        // クリックリスナを設定
        binding.toFinalFragmentButton.setOnClickListener { view: View ->
            toFinalFragment()
        }
        binding.addButton.setOnClickListener {
            viewModel.addScore()
            _score = viewModel.score.value?:0
        }
        binding.subtractButton.setOnClickListener {
            viewModel.subtractScore()
            _score = viewModel.score.value?:0
        }

        return binding.root
    }

    private fun toFinalFragment() {
        val action = SecondFragmentDirections.actionSecondFragmentToFinalFragment(_score)
        NavHostFragment.findNavController(this).navigate(action)
        viewModel.onToFinalComplete()
    }
}

DataBindingの導入

これまでは Fragment.kt クラスを経由してViewViewModelを結び付けていましたが、両者を直接DataBindingで接合します。
これでイベントハンドラも不要となります。

Data Bindingの設定

レイアウトファイルを開き、親ビューの直下に<data>ブロック、<variable>ブロックを作成します。
Bindingとして対応する FragmentのViewModel を設定します。

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>
        <variable
            name="secondViewModel"
            type="com.warpstudio.android.viewmodelsample.SecondFragment" />
    </data>

なお今回はNavigationの利用を前提としているためレイアウトファイルはDataBinding用のものに更新済みです。
これが未処理の場合は前回の記事を参照し、レイアウトを変更してください(gradleの更新も必要です)。

続いてレイアウトファイルのAddButtonSubtractButtontoFinalFragmentButtonのリスナをViewModelのメソッドに直接指定します。

        <Button
            android:id="@+id/addButton"
            // (略)
            android:onClick="@{() -> secondViewModel.addScore()"/>

        <Button
            android:id="@+id/subtractButton"
            // (略)
            android:onClick="@{() -> secondViewModel.subtractScore()}"/>

これにより、 SecondFragment のイベントハンドラが不要となります。

画面部品の値をBinding先のViewModelから直接設定するよう変更します。scoreを表示するTextViewが該当します。

        <TextView
            android:id="@+id/scoreText"
            // (略)
            android:text="@{secondViewModel.displayScore}"

あわせて SecondFragmentbinding.lifecycleOwner = viewLifecycleOwnerという記述を追加してください。当該画面部品に値を設定するObserverさえ不要となります。

最終的な SecondFragment のコードは以下の通りとなります。

class SecondFragment : Fragment() {

    private var _score = 1

    private lateinit var binding: FragmentSecondBinding
    private lateinit var viewModel: SecondViewModel

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = DataBindingUtil.inflate(
            inflater,
            R.layout.fragment_second,
            container,
            false
        )

        // viewModelの生成
        viewModel = ViewModelProvider(this).get(SecondViewModel::class.java)

        // viewModelをbinding
        binding.secondViewModel = viewModel
        // LiveDataのためにviewLifecycleを設定
        binding.lifecycleOwner = viewLifecycleOwner

        // Observerの登録
        viewModel.displayScore.observe(viewLifecycleOwner, Observer { newDisplayScore ->
            binding.scoreText.text = newDisplayScore
        })
        viewModel.score.observe(viewLifecycleOwner, Observer { newScore ->
            _score = newScore
        })
        // 修了判定
        viewModel.eventToFinal.observe(viewLifecycleOwner, Observer { toFinal ->
            if (toFinal) {
                toFinalFragment()
            }
        })

        return binding.root
    }

    private fun toFinalFragment() {
        val action = SecondFragmentDirections.actionSecondFragmentToFinalFragment(_score)
        NavHostFragment.findNavController(this).navigate(action)
        viewModel.onToFinalComplete()
    }
}

Recap

以上っ!
ViewModelの辺りからよく分からなくなってきた、なんて話をたまに周りから聞きます。
この記事では前回同様やはり概念的な内容は触れていないのですが、この辺はまぁ筋トレ的にあれこれ実装をして感覚をつかんでいくのも大事だと思うので、良かったらその際の参考にしてみてください。

次回はRoomについてです。
ではでは。

2
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?