Tina Tang's Blog

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

0%

Android筆記(43)-Preferences DataStore

學習如何使用 Jetpack DataStore 在 app 中儲存 key-value 組合。

在先前的程式碼研究室中,我們已說明如何使用 Room (database 抽象層) 將資料儲存在 SQLite database 中。本程式碼研究室會介紹 Jetpack DataStoreDataStore 以 Kotlin coroutineFlow 為基礎而設計,共提供兩種不同的實作方式,一種是專門儲存 typed objects 的 Proto DataStore,另一種則是專門儲存 key-value 組合的 Preferences DataStore

本程式碼研究室會說明如何使用 Preferences DataStore,Proto DataStore 則不在本程式碼研究室的說明範圍內。

學習目標

  • DataStore 是什麼?您應使用 DataStore 的原因及時機為何?
  • 如何將 Preference DataStore 新增至 app。

範例程式碼

下載範例程式碼

在本程式碼研究室中,您將會從先前的解決方案程式碼擴充 Word app 的功能。範例程式碼可能包含您在先前程式碼研究室中也熟悉的程式碼。

範例 app 總覽

Words app 包含兩個畫面:第一個畫面會顯示使用者可選取的字母,第二個畫面則會顯示開頭為所選字母的字詞 list

這個 app 可讓使用者透過 menu option,切換為以 list 或 grid layout 來顯示字母。

  1. 下載範例程式碼,然後在 Android Studio 中開啟並執行 app。系統會以 liner layout 顯示字母。
  2. 輕觸右上角的 menu option。layout 會切換為 grid layout
  3. 結束 app 並重新啟動。您可以在 Android Studio 中使用「Stop ‘app’」和「Run ‘app’」選項。請注意,重新啟動 app 後,字母會以 liner layout 顯示,而不是 grid layout

請注意,系統不會保留使用者的選擇。本程式碼研究室會說明如何修正此問題。
在本程式碼研究室中,您會瞭解如何使用 Preferences DataStore保留 DataStore 中的 layout 設定


Preferences DataStore 簡介

Preferences DataStore 適合用於簡單的小型 dataset,例如儲存登入詳細資料深色模式設定字型大小等等。DataStore 不適用於複雜的 dataset,例如線上雜貨店的商品目錄清單學生資料庫。如果需要儲存大型或複雜的 dataset,建議您使用 Room 而非 DataStore。

  • 儲存簡單的小型 dataset:DataStore
  • 儲存大型或複雜的 dataset:Room

使用 Jetpack DataStore 程式庫就能建立簡單、安全且非同步的 API,可用來儲存資料。其提供兩種不同的導入方式:Preferences DataStoreProto DataStore。雖然 Preferences DataStore 和 Proto DataStore 都能儲存資料,但卻使用不同的做法:

  • Preferences DataStore 可依據 keys 來存取和儲存資料,而不必事先界定結構定義(schema) (database model)。
  • Proto DataStore 使用通訊協定緩衝區(Protocol buffers)來界定結構定義(schema)。使用通訊協定緩衝區(Protocol buffers) (或 Protobufs) 可讓您保留強類型資料(persist strongly typed data)。與 XML 和其他類似的資料格式相比,Protobufs 更快更簡單,而且更清晰明確。

Room 與 Datastore 的比較:適用時機
如果您的 app 需要以 SQL 等結構化格式儲存大型/複雜的資料,建議您使用 Room。不過,如果您只想儲存簡單或少許資料,且這些資料可以儲存在 key/value 組合中,建議您使用 DataStore

Proto DataStore 與 Preferences DataStore 的比較:使用時機
Proto DataStore type safeefficient,但需要 configurationsetup。如果您的 app 資料夠簡單,可以儲存在 key/value 組合中,那麼易於設定的 Preferences DataStore 則較為適合。

將 Preferences DataStore 新增為 dependency

要將 DataStore 整合至 app,第一步是將其新增為 dependency。

  1. build.gradle(Module: Words.app) 中新增下列 dependency:
1
2
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1"

建立 Preferences DataStore

  1. 新增名為 data 的 package,並在其中建立名為 SettingsDataStore 的 Kotlin class。

  2. SettingsDataStore class 中加入 type 為 Context 的 constructor 參數。

1
class SettingsDataStore(context: Context) {}
  1. SettingsDataStore class 之外,宣告名為 LAYOUT_PREFERENCES_NAMEprivate const val,並為其指派 string 值 layout_preferences。這是你在下一步要執行實例化(instantiate)Preferences Datastore name。
1
private const val LAYOUT_PREFERENCES_NAME = "layout_preferences"
  1. 請在 class 外使用 preferencesDataStore 委托(delegate)來建立 DataStore instance。由於您使用 Preferences Datastore,因此需要Preferences 傳遞做為 datastore type。此外,請LAYOUT_PREFERENCES_NAME 設為 datastore name

完成的程式碼如下:

1
2
3
4
5
6
7
private const val LAYOUT_PREFERENCES_NAME = "layout_preferences"

// Create a DataStore instance using the preferencesDataStore delegate, with the Context as
// receiver.
private val Context.dataStore : DataStore<Preferences> by preferencesDataStore(
name = LAYOUT_PREFERENCES_NAME
)

提示:只要在 Kotlin 檔案的頂層建立 DataStore instance,便可在 app 的其他部分透過此屬性存取 instance。這樣就能更輕鬆地將 DataStore 保持為單例模式(singleton)


實作 SettingsDataStore class

如前所述,Preferences DataStore 會以 key/value 組合的形式儲存資料。在這個步驟中,您會定義儲存 layout 設定所需的 key,也會定義要寫入和讀取 Preferences DataStore 的函式

Key type functions

有別於 Room,Preferences DataStore 並不會使用預先定義的結構定義(schema),而是使用對應的 key type functions,來定義您儲存在 DataStore<Preferences> instance 中每個 value 的 key。舉例來說,如要定義 int 值的 key,請使用 intPreferencesKey();要定義 string 值的鍵,則使用 stringPreferencesKey()。整體來說,這些 function names 的前置 string會與所儲存 key 的 data type 相同。

data\SettingsDataStore class 中實作以下內容:

  1. 如要導入 SettingsDataStore class,首先請建立用於儲存 Boolean 值的 key,該 Boolean 值會指定使用者設定是否屬於 liner layout。建立名為 IS_LINEAR_LAYOUT_MANAGERprivate class type,並使用 booleanPreferencesKey() (傳入 is_linear_layout_manager key name 做為函式參數) 來初始化。
1
private val IS_LINEAR_LAYOUT_MANAGER = booleanPreferencesKey("is_linear_layout_manager")

寫入 Preferences DataStore

現在,請開始使用 key,並將 Boolean 值 layout 設定儲存在 DataStore 中。Preferences DataStore 提供 edit() suspend function,可以交易形式(transactionally)更新 DataStore 中的資料。函式的轉換(transform)參數接受程式碼區塊,您可以視需要更新 value。轉換區塊(transform block)的所有程式碼皆視為單一交易(single transaction)。原理上,交易作業會移至 Dispacter.IO 底下,因此在呼叫 edit() 函式時,別忘了將函式設為 suspend

  1. 建立一個名為 saveLayoutToPreferencesStore()suspend 函式,該函式採用以下兩個參數:layout 設定 Boolean 值和 Context
1
2
3
suspend fun saveLayoutToPreferencesStore(isLinearLayoutManager: Boolean, context: Context) {

}
  1. 實作上述函式,呼叫 dataStore.edit(),並傳遞程式碼區塊以儲存新的 value。
1
2
3
4
5
suspend fun saveLayoutToPreferencesStore(isLinearLayoutManager: Boolean, context: Context) {
context.dataStore.edit { preferences ->
preferences[IS_LINEAR_LAYOUT_MANAGER] = isLinearLayoutManager
}
}

從 Preferences DataStore 讀取

Preferences DataStore 會公開儲存在 Flow<Preferences> 的資料,只要偏好設定有所變更,該程式碼就會發出資料。您不想公開整個 Preferences object,只需公開 Boolean 值即可。因此,我們會對應 Flow<Preferences>,並取得您所需的 Boolean 值。

  1. 公開根據 dataStore.data: Flow<Preferences> 建構的 preferenceFlow: Flow<UserPreferences>,進行對應以擷取 Boolean 偏好設定。由於 Datastore 在首次執行時沒有任何內容,因此系統會預設傳回 true
1
2
3
4
5
val preferenceFlow: Flow<Boolean> = context.dataStore.data
.map { preferences ->
// On the first run of the app, we will use LinearLayoutManager by default
preferences[IS_LINEAR_LAYOUT_MANAGER] ?: true
}
  1. 如果下列項目未自動 import,請新增以下資訊:
1
2
3
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map

例外狀況處理

DataStore 從檔案讀取(reads)寫入(writes)資料,系統可能會在存取資料時出現 IOExceptions。您可以使用 catch() 運算子來擷取例外狀況(exceptions),以處理這些問題。

  1. 若在讀取資料時發生錯誤,SharedPreference DataStore 會 throws IOException。在 preferenceFlow 宣告中,請在 map() 之前使用 catch() 運算子擷取 IOException,並發出 emptyPreferences()。為求簡單,我們預計此處不會出現其他類型的 exceptions;如果出現其他類型的 exception,請重新 thrown 該 exception。
1
2
3
4
5
6
7
8
9
10
11
12
13
val preferenceFlow: Flow<Boolean> = context.dataStore.data
.catch {
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}
.map { preferences ->
// On the first run of the app, we will use LinearLayoutManager by default
preferences[IS_LINEAR_LAYOUT_MANAGER] ?: true
}

使用 SettingsDataStore class

在下一個工作中,您將會在 LetterListFragment class 中使用 SettingsDataStore。您要將 observer 附加到 layout 設定,並據此更新 UI

請在 LetterListFragment 中採取下列步驟:

  1. 宣告稱為 SettingsDataStore 且 type 為 SettingsDataStoreprivate class 變數。由於您後將會初始化這個變數,因此請將其設為 lateinit
1
private lateinit var SettingsDataStore: SettingsDataStore
  1. onViewCreated() 函式的末尾,請初始化新變數,然後將 requireContext() 傳遞至 SettingsDataStore constructor。
1
2
3
4
5
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
// Initialize SettingsDataStore
SettingsDataStore = SettingsDataStore(requireContext())
}

Read 和 observe 資料

  1. LetterListFragmentonViewCreated() 方法中,於 SettingsDataStore 初始化底下,使用 asLiveData()preferenceFlow 轉換為 Livedata。請附加一個 observer,並傳遞到 viewLifecycleOwner 做為 owner
1
SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { })
  1. observer 內,將新的 layout 設定指派給 isLinearLayoutManager 變數。呼叫 chooseLayout() 函式以更新 RecyclerView layout
1
2
3
4
SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { value ->
isLinearLayoutManager = value
chooseLayout()
})

完成的 onViewCreated() 函式應如下所示:

1
2
3
4
5
6
7
8
9
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
recyclerView = binding.recyclerView
// Initialize SettingsDataStore
SettingsDataStore = SettingsDataStore(requireContext())
SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { value ->
isLinearLayoutManager = value
chooseLayout()
})
}

將 layout 設定寫入 DataStore

最後一個步驟則是在使用者輕觸 menu option 時,,將 layout 設定寫入 Preferences DataStore。您應以同步(asynchronously)方式在 coroutine 中將資料寫入 Preferences DataStore。如要在 fragment 中執行此操作,請使用名為 LifecycleScopeCoroutineScope

LifecycleScope

Lifecycle-aware components (如 fragment) 為 app 中的邏輯範圍(logical scopes)以及LiveData 的互通層提供一流支援。系統會為每個 Lifecycle object 定義 LifecycleScopeLifecycle owner 遭到刪除(destroyed),系統就會取消此範圍(scope)內啟動的所有 coroutine

  1. LetterListFragmentonOptionsItemSelected() 函式內,於 R.id.action_switch_layout case 的結尾,使用 lifecycleScope 來啟動 coroutine。在 launch 區塊內,呼叫 saveLayoutToPreferencesStore() 以傳遞 isLinearLayoutManagercontext
1
2
3
4
5
6
7
8
9
10
11
12
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_switch_layout -> {
...
// Launch a coroutine and write the layout setting in the preference Datastore
lifecycleScope.launch {
SettingsDataStore.saveLayoutToPreferencesStore(isLinearLayoutManager, requireContext())
}
...

return true
}
  1. 現在,請測試 Preferences DataStore 的持續性(persistence)。將 app layout 變更為 grid layout。結束 app 並重新啟動。

重新啟動 app 後,字母現在會以 grid layout 顯示,而不是 liner layout。您的 app 已成功儲存使用者選取的 layout!

請注意,雖然字母現在會以 grid layout 顯示,但 menu icon 未正確更新。我們接下來會說明如何解決這個問題。


修正 menu icon bug

Menu icon bug,原因在於在 onViewCreated() 中,RecyclerView layout 的更新依據是 layout 設定而而不是 menu icon。只要在更新 RecyclerView layout 時同時重畫(redrawing) menu,即可解決這個問題。

重畫 options menu

建立 menu 後,系統就不會多此一舉地為每個 frame 重畫(redrawn)相同的 menu。invalidateOptionsMenu() 函式會指示 Android 重畫(redraw) options menu

變更 option menu 的內容 (例如新增 menu item刪除 item 或是變更 menu text 或 icon) 時,您可以呼叫這個函式。在本例中,menu icon 已變更。呼叫此方法就會宣告 option menu 已變更,並應重新建立 menu。下次需要顯示時 option menu 時,就會呼叫 onCreateOptionsMenu(android.view.Menu) 方法。

  1. LetterListFragment 中的 onViewCreated() 內,於 preferenceFlow observer 結尾,呼叫 chooseLayout() 的底下,透過對 activity 呼叫 invalidateOptionsMenu() 來重畫(redraw) menu。
1
2
3
4
5
6
7
8
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
SettingsDataStore.preferenceFlow.asLiveData().observe(viewLifecycleOwner, { value ->
...
// Redraw the menu
activity?.invalidateOptionsMenu()
})
}
  1. 再次執行 app,並變更 layout。

  2. 結束 app 並重新啟動。請注意,menu icon 現在已正確更新。

恭喜!您已成功將 Preferences DataStore 新增至 app,以便儲存使用者的選擇。


總結

  • DataStore 提供採用 Kotlin coroutines 和 Flow 的完全非同步(asynchronous) API,可確保資料的一致性(consistency)
  • Jetpack DataStore 是一項資料儲存(data storage) solution,可讓您使用protocol buffers來儲存 key-value 組合或 typed objects
  • DataStore 提供兩種實作方式:Preferences DataStoreProto DataStore
  • Preferences DataStore 不會使用預先定義(predefined)結構定義(schema)
  • Preferences DataStore 使用對應的 key type function,定義每個需要儲存在 DataStore<Preferences> instance 中的 value。舉例來說,想定義 int value 的 key,請使用 intPreferencesKey()
  • Preferences DataStore 提供 edit() 函式,可以交易形式(transactionally)更新 DataStore 中的資料。