Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(27)-搭配ViewModel使用LiveData

ViewModel 中的應用程式資料轉換為 LiveData,並觀察自動更新 UI 所出現的變化。

在先前的程式碼研究室中,您已瞭解如何使用 ViewModel 儲存應用程式資料。ViewModel 可在設定變更時保留應用程式資料。在本程式碼研究室中,您將瞭解如何整合 LiveDataViewModel 中的資料。

LiveData 類別也屬於 Android 架構元件的一部分,為可觀察的資料容器類別。

學習目標

  • 如何在應用程式中使用 LiveDataMutableLiveData
  • 如何使用 LiveData 來封裝儲存在 ViewModel 中的資料。
  • 如何新增觀察器(observer)方法以觀察 LiveData 中的變化。
  • 如何在版面配置檔案中編寫 binding 表達式。

建構項目

  • 針對 Unscramble 應用程式中的應用程式資料 (字詞、字詞計數和分數) 使用 LiveData
  • 新增觀察器(observer)方法,在資料變更時接收通知,並自動更新打散字詞的 text view。
  • 在 layout 檔案中編寫 binding 表達式,在基礎 LiveData 變更時觸發。分數、字詞計數和打散字詞 text view 會自動更新。

範例應用程式總覽

本程式碼研究室會使用您在先前程式碼研究室中熟悉的 Unscramble 解決方案程式碼。應用程式會顯示打散的字詞,讓玩家進行重組。玩家可以嘗試任意次數來猜測正確字詞。目前字詞、玩家分數和字詞計數等應用程式資料會儲存在 ViewModel 中。不過,該應用程式的 UI 不會反映新的分數和字詞計數值。在本程式碼研究室中,您將使用 LiveData 實作缺少的功能。


什麼是 Livedata

LiveData 是可觀察的資料容器類別,可感知生命週期。

注意:LiveDatavalue 更新時,UI 會自動更新。

LiveData 特性如下:

  • LiveData 可保存資料;LiveData 包裝函式可與任何類型的資料搭配使用。
  • LiveData 可觀察,這表示當 LiveData 物件保存的資料變更時,observer 會接收通知。
  • LiveData 可感知生命週期。將 observer 附加至 LiveData 時,observer 會與 LifecycleOwner 建立關聯 (通常是 activity 或 fragment )。LiveData 只會更新處於有效生命週期狀態 (例如 STARTEDRESUMED) 的 observer。如要進一步瞭解 LiveDataobserver,請參閱這篇文章

範例程式碼中的 UI 更新

在範例程式碼中,每當您想在 UI 中顯示新的打散字詞時,系統都會明確呼叫 updateNextWordOnScreen() 方法。您可在遊戲初始化期間,以及玩家按下「提交」或「略過」按鈕時呼叫此方法。將從 onViewCreated()restartGame()onSkipWord()onSubmitWord() 方法呼叫此方法。使用 Livedata 時,您不必從多個位置呼叫此方法來更新 UI。您只會在 observer 中執行一次。


將 LiveData 新增至 currentScrambledWord

在這項工作中,您會學習如何將 GameViewModel 中的目前字詞轉換為 LiveData,以使用 LiveData, 包裝任何資料。在後續工作中,您會將 observer 新增至此類 LiveData 物件,並瞭解如何觀察 LiveData

MutableLiveData

MutableLiveDataLiveData 的可變動版本,也就是可以變更儲存在其中的資料值。

  1. GameViewModel 中,將變數 _currentScrambledWord 的類型變更為 MutableLiveData<String>LiveDataMutableLiveData 屬於一般類別,因此您需要指定其保存的資料類型。

  2. _currentScrambledWord 的變數類型變更為 val,因為 LiveData/MutableLiveData 物件的值會保持不變,只有儲存在物件中的資料會改變。

1
2
// String改成 LiveData
private val _currentScrambledWord = MutableLiveData<String>()
  1. 將支援欄位 currentScrambledWord 類型變更為 LiveData<String>,因為此欄位不可變動。Android Studio 會顯示某些錯誤,您將在後續步驟中進行修正。
1
2
3
// String改成 LiveData
val currentScrambledWord: LiveData<String>
get() = _currentScrambledWord
  1. 如要存取 LiveData 物件中的資料,請使用 value 屬性。在 getNextWord() 方法的 GameViewModel 中,於 else 區塊內,將 _currentScrambledWord 引用變更為 _currentScrambledWord.value
1
2
3
4
5
6
7
8
private fun getNextWord() {
...
} else {
// 因 _currentScrambledWord 為 LiveData,需使用 value 屬性來存取 LiveData 物件中的資料
_currentScrambledWord.value = String(tempWord)
...
}
}

將 Observer 附加至 LiveData 物件

在這項工作中,您將會在應用程式元件 GameFragment 中設定觀察器(observer)。您新增的 observer 會觀察應用程式資料 currentScrambledWord 的變更。LiveData 具備生命週期感知功能,代表其只會更新處於有效生命週期狀態的 observer。因此,GameFragment 中的 observer 只會在 GameFragment 處於 STARTEDRESUMED 狀態時收到通知。

  1. GameFragment 中,刪除 updateNextWordOnScreen() 方法及其所有呼叫。您將會在 LiveData 中附加 observer,因此不需要使用此方法。

  2. onSubmitWord() 中,按照下列步驟修改空的 if-else 區塊。完整方法應如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
private fun onSubmitWord() {
val playerWord = binding.textInputEditText.text.toString()

if (viewModel.isUserWordCorrect(playerWord)) {
setErrorTextField(false)
if (!viewModel.nextWord()) {
showFinalScoreDialog()
}
} else {
setErrorTextField(true)
}
}
  1. currentScrambledWord LiveData 附加 observer。在 GameFragmentonViewCreated() callback 結尾中,於 currentScrambledWord 上呼叫 observe() 方法。
1
2
// 觀察 currentScrambledWord 的 LiveData
viewModel.currentScrambledWord.observe()
  • Android Studio 會顯示缺少參數的相關錯誤。您將在下一個步驟中修正錯誤。
  1. viewLifecycleOwner 做為第一個參數傳遞至 observe() 方法。viewLifecycleOwner 代表fragment 的 view 生命週期。此參數可協助 LiveData 留意 GameFragment 生命週期,且只有在 GameFragment 處於有效狀態 (STARTEDRESUMED) 時,才通知 observer。

  2. 使用 newWord 函式參數將 lambda 新增為第二個參數。newWord 將包含新的打散字詞值。

1
2
3
4
// 觀察 scrambledCharArray LiveData,傳入 LifecycleOwner 和 observer
viewModel.currentScrambledWord.observe(viewLifecycleOwner,
{ newWord ->
})
  • lambda 運算式是未宣告的匿名函式,但會立即以運算式的形式傳遞。lambda 運算式一律會加上大括號 { }
  1. lambda 運算式的函式主體中,將 newWord 指派至打散字詞 text view。
1
2
3
4
5
6
// Observe the scrambledCharArray LiveData, passing in the LifecycleOwner and the observer.
viewModel.currentScrambledWord.observe(viewLifecycleOwner,
// 使用 newWord 函式參數將 lambda 新增為第二個參數,將 newWord 設為打散字詞 text view 的文字
{ newWord ->
binding.textViewUnscrambledWord.text = newWord
})
  1. 編譯並執行應用程式。您的遊戲應用程式應可照常運作,但現在會在 LiveData observer 中 (而非在 updateNextWordOnScreen() 方法中) 自動更新打散字詞 text view。

將 Observer 附加至分數和字詞計數

如同前一個工作,在這項工作中,您會將 LiveData 新增至應用程式的其他資料、分數和字詞計數中,使 UI 在遊戲期間能夠更新分數和字詞計數的正確值。

步驟 1:使用 LiveData 包裝分數和字詞計數

  1. GameViewModel 中,將 _score_currentWordCount 類別變數的類型變更為 val

  2. 將變數 _score_currentWordCount 的資料類型變更為 MutableLiveData,並將其初始化為 0

  3. 將支援欄位類型變更為 LiveData<Int>

1
2
3
4
5
6
7
private val _score = MutableLiveData(0)
val score: LiveData<Int>
get() = _score

private val _currentWordCount = MutableLiveData(0)
val currentWordCount: LiveData<Int>
get() = _currentWordCount
  1. reinitializeData() 方法開頭的 GameViewModel 中,將 _score_currentWordCount 的引用變更為 _score.value_currentWordCount.value
1
2
3
4
5
6
fun reinitializeData() {
_score.value = 0
_currentWordCount.value = 0
wordsList.clear()
getNextWord()
}
  1. nextWord() 方法內的 GameViewModel 中,將 _currentWordCount 的參照變更為 _currentWordCount.value!!
1
2
3
4
5
6
fun nextWord(): Boolean {
return if (_currentWordCount.value!! < MAX_NO_OF_WORDS) {
getNextWord()
true
} else false
}
  1. GameViewModelincreaseScore()getNextWord() 方法中,分別將 _score_currentWordCount 的參照變更為 _score.value_currentWordCount.value。Android Studio 會顯示錯誤,因為 _score 已不再是整數,而是 LiveData,您將在後續步驟中修正此錯誤。

  2. 使用 plus() Kotlin 函式增加 _score 值,如此即可使用空值安全性執行加法。

1
2
3
4
private fun increaseScore() {
// 因 _score 改為 LiveData,使用 plus方法 取代「+」
_score.value = (_score.value)?.plus(SCORE_INCREASE)
}
  1. 同樣地,您也可以使用 inc() Kotlin 函式,使用空值安全性將值增加一。
1
2
3
4
5
6
7
8
9
private fun getNextWord() {
...
} else {
_currentScrambledWord.value = String(tempWord)
// 因 _currentWordCount 改為 LiveData,使用 .inc()方法 取代「++」
_currentWordCount.value = (_currentWordCount.value)?.inc()
wordsList.add(currentWord)
}
}
  1. GameFragment 中,使用 value 屬性存取 score 的值。在 showFinalScoreDialog() 方法中,將 viewModel.score 變更為 viewModel.score.value
1
2
3
4
5
6
7
private fun showFinalScoreDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.congratulations))
.setMessage(getString(R.string.you_scored, viewModel.score.value))
...
.show()
}

步驟 2:將 Observer 附加至分數和字詞計數

應用程式中的分數和字詞計數不會更新。在這項工作中,您將使用 LiveData observer 進行更新。

  1. GameFragmentonViewCreated() 方法中 ,刪除更新分數和字詞計數 text view 的程式碼。

移除:

1
2
binding.score.text = getString(R.string.score, 0)
binding.wordCount.text = getString(R.string.word_count, 0, MAX_NO_OF_WORDS)
  1. GameFragmentonViewCreated() 方法的結尾,為 score 附加 observer。將 viewLifecycleOwner 做為第一個參數傳遞至 observer,並使用 lambda 運算式做為第二個參數。在 lambda 運算式中,將新的分數做為參數傳遞,並在函式主體中,將新分數設為 text view 的文字。
1
2
3
4
viewModel.score.observe(viewLifecycleOwner,
{ newScore ->
binding.score.text = getString(R.string.score, newScore)
})
  1. onViewCreated() 方法結尾,為 currentWordCount LiveData 附加 observer。將 viewLifecycleOwner 做為第一個參數傳遞至 observer,並使用 lambda 運算式做為第二個參數。在 lambda 運算式中,將新字詞計數做為參數傳遞,並在函式主體中,將新字詞計數與 MAX_NO_OF_WORDS 設為 text view 的文字。
1
2
3
4
5
viewModel.currentWordCount.observe(viewLifecycleOwner,
{ newWordCount ->
binding.wordCount.text =
getString(R.string.word_count, newWordCount, MAX_NO_OF_WORDS)
})
  • 在生命週期擁有者的生命週期期間 (即 GameFragment),當 ViewModel 內的分數和字詞計數值發生變化時,就會觸發新的 observer。
  1. 執行應用程式即可見證其奧妙之處。使用一些字詞進行遊戲。畫面上的分數和字詞計數也會正確更新。請注意,這些 text view 並不會根據程式碼的部分條件進行更新。scorecurrentWordCountLiveData,且基礎值變更時,系統會自動呼叫對應的 observer。

將 LiveData 搭配 Data Binding 使用

在先前的工作中,您的應用程式會監聽程式碼中的 data 變更。同樣地,應用程式也可以監聽 layout 中的 data 變更。透過 Data Binding當可觀察的 LiveData 值變更時,系統也會通知其綁定(bind)layout 中的 UI 元素,且可從 layout 中更新 UI

概念:Data Binding

在先前的程式碼研究室中,您已瞭解單向的 view binding。您可以將 view 綁定(bind)至程式碼,但無法將程式碼綁定(bind)至 view。

複習 View Binding:

view binding 是一項可讓您更輕鬆地在程式碼中訪問 view 的功能。它會為各 XML layout 檔案產生 binding class。凡是在對應 layout 中具有 ID 的 view,binding class 的例項都會包含指向這些 view 的直接引用。舉例來說,Unscramble 應用程式目前使用 view binding,因此使用產生的 binding class 可在程式碼中引用 view

範例:

1
2
3
4
binding.textViewUnscrambledWord.text = newWord
binding.score.text = getString(R.string.score, newScore)
binding.wordCount.text =
getString(R.string.word_count, newWordCount, MAX_NO_OF_WORDS)
  • 如果使用 view binding,就無法引用 view 中的應用程式資料 (layout檔案)。您可以使用 data binding 完成這項操作。
Data Binding

Data Binding 程式庫也屬於 Android Jetpack 程式庫的一部分。Data Binding 使用宣告式格式將 layout 中的 UI 元件 bind 至應用程式中的資料(data)來源,稍後將在程式碼研究室中說明。

簡單來說,Data Binding 會將 data (從程式碼) bindView + View Binding (將 view bind 至程式碼)︰

在 UI controller 中使用 view binding 的範例

1
binding.textViewUnscrambledWord.text = viewModel.currentScrambledWord

在 layout 檔案中使用 data binding 的範例

1
android:text="@{gameViewModel.currentScrambledWord}"
  • 以上範例說明如何使用 data binding 程式庫,直接將應用程式 data 指派至 layout 檔案中的 view/widget(小工具)。請注意,指派運算式中使用 @{} 語法。

使用 data binding 的主要優點在於,您可以移除 activity 中的許多 UI 架構呼叫,使其更加簡單且易於維護。這還可改善應用程式效能,避免發生記憶體流失及空值(null)指標例外狀況。

步驟 1:變更資料繫結的檢視繫結

  1. build.gradle(Module) 檔案中,啟用 buildFeatures 區段下的 dataBinding 屬性。

1
2
3
buildFeatures {
viewBinding true
}

取代為

1
2
3
buildFeatures {
dataBinding true
}
  1. 如要在任何 Kotlin 專案中使用 data binding,請套用 kotlin-kapt 外掛程式。此步驟已在 build.gradle(Module) 檔案中完成。
1
2
3
4
5
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt'
}
  • 上述步驟會自動為應用程式中的每個 layout XML 檔案產生 binding class,如果 layout 檔案名稱為 activity_main.xml,則自動產生的 class 將會稱為 ActivityMainBinding

步驟 2:將 layout 檔案轉換為 Data Binding layout

Data Binding layout 檔案略有不同,以 根(root) 標記 <layout> 為開頭,後面接著可選的 <data> 元素和 view 根(root) 元素。此 view 元素就是非綁定(bind) layout 檔案中的根(root)。

  1. 開啟 game_fragment.xml,選取「Code」分頁標籤。

  2. 如要將 layout 轉換成 data binding layout,請將根(root)元素納入 <layout> 標記中。您也必須將命名空間定義 (開頭為 xmlns: 的屬性) 移至新的根(root)元素。在根(root)元素上方的 <layout> 標記中加入 <data></data> 標記。

  • Android Studio 提供可自動執行此作業的便利方法:在根(root)元素 <ScrollView> 上按一下滑鼠右鍵,然後依序選取「Show Context Actions」>「Convert to data binding layout」。
  1. layout 應如下所示:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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>

</data>

<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">

<androidx.constraintlayout.widget.ConstraintLayout
...
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</layout>
  1. GameFragment 中,在 onCreateView() 方法的一開始,變更 binding 變數的 instance,以使用 data binding。

1
binding = GameFragmentBinding.inflate(inflater, container, false)

取代為

1
binding = DataBindingUtil.inflate(inflater, R.layout.game_fragment, container, false)
  1. 編譯程式碼;您應能夠順利編譯。您的應用程式現在會使用 data binding,layout 中的 view 也可存取應用程式的 data。

新增 Data Binding 變數

在這項工作中,您必須在 layout 檔案中加入屬性,以便存取 viewModel 中的應用程式 data。您將初始化程式碼中的 layout 變數。

  1. game_fragment.xml<data> 標記中,新增名為 <variable> 的子標記,宣告名為 gameViewModel 且類型(type)為 GameViewModel 的屬性。您會使用此方法ViewModel 中的 data 綁定(bind)至 layout
1
2
3
4
5
6
<data>
<!-- 將 ViewModel 中的 data 綁定(bind)至 layout -->
<variable
name="gameViewModel"
type="com.example.android.unscramble.ui.game.GameViewModel" />
</data>
  • 請注意,gameViewModel 的類型包含套件名稱(package name)。請確認此套件名稱與應用程式中的套件名稱相符。
  1. gameViewModel 宣告下方,在 <data> 標記中加入另一個變數,並將其命名為 maxNoOfWords,類型(type)為 Integer。您將使用此方法綁定(bind)至 ViewModel 中的變數,以儲存每場遊戲的字詞數。
1
2
3
4
5
6
7
<data>
...
<!-- 每場遊戲的字詞數 -->
<variable
name="maxNoOfWords"
type="int" />
</data>
  1. onViewCreated() 方法的開頭的 GameFragment 中,初始化 layout 變數 gameViewModelmaxNoOfWords
1
2
3
4
5
6
7
8
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

// 初始化 layout 變數 gameViewModel 和 maxNoOfWords
binding.gameViewModel = viewModel
binding.maxNoOfWords = MAX_NO_OF_WORDS
...
}
  1. LiveData 可觀察生命週期,因此您必須將生命週期擁有者傳遞給 layout。在 onViewCreated() 方法內的 GameFragment 中,在綁定(bind)變數的初始化下方新增下列程式碼。
1
2
// 將 fragment view 指定為 binding 的 lifecycle owner,以觀察 LiveData 更新
binding.lifecycleOwner = viewLifecycleOwner
  • 提醒您,您在實作 LiveData observer 時,也實作類似功能。您已將 viewLifecycleOwner 做為其中一個參數傳遞給 LiveData observer。

使用 binding 運算式

binding 運算式會寫入屬性 (例如 android:text) layout 內,並參照 layout 屬性。layout 屬性會透過 <variable> 標記在 data binding layout 檔案的頂部進行宣告。當任何相依變數有所變更時,「DB Library」將執行 binding 運算式 (進而更新 view)。使用 data binding library時,此變更偵測是無須付費的最佳化功能。

binding 運算式的語法

binding 運算式以 @ 符號開頭,並加上大括號 {}。在以下範例中,將TextView 文字設為 user 變數的 firstName 屬性:

範例:

1
2
3
<TextView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{user.firstName}" />

步驟 1:將 binding 運算式新增至目前字詞

在此步驟中,您可以將目前的字詞 text view 綁定(bind)至 ViewModel 中的 LiveData 物件

  1. game_fragment.xml 中,請將 text 屬性新增至 textView_unscrambled_word text view。使用新的 layout 變數 gameViewModel,並將 @{gameViewModel.currentScrambledWord} 指派給 text 屬性。
1
2
3
4
5
<TextView
android:id="@+id/textView_unscrambled_word"
...
android:text="@{gameViewModel.currentScrambledWord}"
.../>
  1. GameFragment 中,移除 currentScrambledWordLiveData observer 程式碼: fragment 中不再需要使用 observer 程式碼。layout 會直接收到 LiveData 的變更更新。

移除:

1
2
3
4
viewModel.currentScrambledWord.observe(viewLifecycleOwner,
{ newWord ->
binding.textViewUnscrambledWord.text = newWord
})
  1. 執行您的應用程式,應用程式應可照常運作。不過,打散字詞 text view 目前使用 binding 運算式更新 UI,而非 LiveData observer。

步驟 2:將 binding 運算式新增至分數和字詞計數

data binding 運算式的資源

data binding 運算式可透過下列語法引用應用程式資源。

範例:

1
android:padding="@{@dimen/largePadding}"
  • 在上述範例中,系統會為 padding 屬性指派 dimen.xml 資源檔案的 largePadding 值。

您也可以傳遞 layout 屬性做為資源參數。

範例:

1
android:text="@{@string/example_resource(user.lastName)}"

strings.xml

1
<string name="example_resource">Last Name: %s</string>
  • 在上述範例中,example_resource 是具有 %s 預留位置的字串資源。您會將 user.lastName 做為資源參數傳入 binding 運算式,其中 user 是 layout 變數。

在此步驟中,您會將 binding 運算式新增至分數和字詞計數 text view,並傳入資源參數。這個步驟與您為上述 textView_unscrambled_word 所做操作類似。

  1. game_fragment.xml 中,使用以下 binidng 運算式更新 word_count text view 的 text 屬性。使用 word_count 字串資源,並將 gameViewModel.currentWordCountmaxNoOfWords 做為資源參數傳入。
1
2
3
4
5
<TextView
android:id="@+id/word_count"
...
android:text="@{@string/word_count(gameViewModel.currentWordCount, maxNoOfWords)}"
.../>
  1. 使用以下 binding 運算式更新 score text view 的 text 屬性。使用 score 字串資源,並傳入 gameViewModel.score 做為資源參數。
1
2
3
4
5
<TextView
android:id="@+id/score"
...
android:text="@{@string/score(gameViewModel.score)}"
... />
  1. GameFragment 中移除 LiveData observer。您已無需使用這些 observer,binding 運算式會在對應 LiveData 變更時更新 UI。

移除:

1
2
3
4
5
6
7
8
9
10
viewModel.score.observe(viewLifecycleOwner,
{ newScore ->
binding.score.text = getString(R.string.score, newScore)
})

viewModel.currentWordCount.observe(viewLifecycleOwner,
{ newWordCount ->
binding.wordCount.text =
getString(R.string.word_count, newWordCount, MAX_NO_OF_WORDS)
})
  1. 執行應用程式,使用一些字詞進行遊戲。現在,您的程式碼會使用 LiveData 和 binding 運算式更新 UI。

恭喜!您已瞭解如何將 LiveData observer 搭配 LiveData 使用,以及將 LiveData 搭配 binding 運算式使用。


總結

  • LiveData 可保存資料;LiveData 包裝函式可與任何資料搭配使用
  • LiveData 可觀察,這表示當 LiveData 物件保存的資料變更時,observer 會接收通知。
  • LiveData 可感知生命週期。將 observer 附加至 LiveData 時,observer 會與 LifecycleOwner 建立關聯 (通常是 activity 或 fragment)。LiveData 只會更新處於有效生命週期狀態 (例如 STARTEDRESUMED) 的 observer。
  • 應用程式可以透過 data binding 和 binding 運算式監聽 layout 中的 LiveData 變更。
  • binding 運算式會寫入屬性 (例如 android:text) layout 內,並引用 layout 屬性。