前回はNavigation
に関しての記事を書きましたが、今回はkotlin
におけるViewModel
の実装方法、LiveData
の使い方、DataBinding
の適用方法についてまとめていきます。
この記事の内容
公式のドキュメントやトレーニング「Android Kotlin の基礎」のレッスン5「アーキテクチャ コンポーネント」を参考に実装のポイントとエッセンスをまとめていきます。
ViewModel
とは何か、またMVVM
デザインパターン等については触れません。
前提知識
- kotlinの基礎的な文法
- AndroidStudioの使い方/アプリの作り方
- 画面や画面部品の配置方法
-
Navigation
の実装方法(前回記事参照)
開発環境
- Windows 10 Home
- Android Studio 4.2.1
作成するサンプル
StartFragment 、 SecondFragment 、 FinalFragment の3つの画面を持ち、遷移ボタンでそれぞれの画面に遷移します。
SecondFragment には AddButton 、 SubtractButton の2種類のボタンがあり、これをクリックすることで画面上のscoreが加算/減算されます。
このクリック時の処理(scoreの更新、画面遷移)をViewModel
により置き換えていきます。
リファクタリング前後で画面の動きは変わりません。
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
を定義します。
ViewModel
はonCreateView()
メソッドで生成します。
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 に移し替えます。
同時AddButton、SubtractButtonがクリックされた際の処理をメソッドとして定義しておきます。
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 method
はCreational Patternの一種だそうです(スミマセンこの辺りはおれも勉強不足)。
ここでは FinalFragment のViewModel
とViewModelFactory
を新たに作成します。
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
を実装していきます。
SecondFragment のViewModel
生成処理の直下に、以下のコードを追記してください。
// 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_で保持するsocre、displayScoreを下記の通りprivateなMutableLiveData
、publicなLiveData
に分離します。
あわせて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 は画面遷移をメソッド化しつつ、eventToFinalのObserverを追加しました。
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 クラスを経由してView
とViewModel
を結び付けていましたが、両者を直接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
の利用を前提としているためレイアウトファイルはDataBindin
g用のものに更新済みです。
これが未処理の場合は前回の記事を参照し、レイアウトを変更してください(gradle
の更新も必要です)。
続いてレイアウトファイルのAddButton、SubtractButton、toFinalFragmentButtonのリスナを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}"
あわせて SecondFragment でbinding.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
についてです。
ではでは。