再見 SharedPreferences,你好Jetpack DataStore!
Proto DataStore:存儲類的對象(typed objects ),通過 protocol buffers 將對象序列化存儲在本地,protocol buffers 現在已經應用的非常廣泛,無論是微信還是阿里等等大廠都在使用,我們在部分業(yè)務場景中也用到了 protocol buffers,會在后續(xù)的文章詳細分析
Preferences DataStore:以鍵值對的形式存儲在本地和 SharedPreferences 類似,但是 DataStore 是基于 Flow 實現的,不會阻塞主線程,并且保證類型安全
Jetpack DataStore 將會分為至少 2 篇文章來分析,今天這篇文章主要來介紹 Jetpack DataStore 其中一種實現方式 Preferences DataStore。
文章中的示例代碼,已經上傳到 GitHub 歡迎前去查看 AndroidX-Jetpack-Practice/DataStoreSimple
GitHub 地址:
https://github.com/hi-dhl/AndroidX-Jetpack-Practice
這篇文章會涉及到 Koltin flow 相關內容,如果不了解可以先去看另外一篇文章 Kotlin Flow 是什么?Channel 是什么?
通過這篇文章你將學習到以下內容:
那些年我們所經歷的 SharedPreferences 坑? 為什么需要 DataStore?它為我們解決了什么問題? 如何在項目中使用 DataStore? 如何遷移 SharedPreferences 到 DataStore? MMKV、DataStore、SharedPreferences 的不同之處?
一個新庫的出現必定為我們解決了一些問題,那么 Jetpack DataStore 為我們解決什么問題呢,在分析之前,我們需要先來了解 SharedPreferences 都有那些坑。
1.那些年我們所經歷的 SharedPreferences 坑
SharedPreference 是一個輕量級的數據存儲方式,使用起來也非常方便,以鍵值對的形式存儲在本地,初始化 SharedPreference 的時候,會將整個文件內容加載內存中,因此會帶來以下問題:
通過 getXXX() 方法獲取數據,可能會導致主線程阻塞 SharedPreference 不能保證類型安全 SharedPreference 加載的數據會一直留在內存中,浪費內存 apply() 方法雖然是異步的,可能會發(fā)生 ANR,在 8.0 之前和 8.0 之后實現各不相同 apply() 方法無法獲取到操作成功或者失敗的結果
接下來我們逐個來分析一下 SharedPreferences 帶來的這些問題,在文章中 SharedPreference 簡稱 SP。
getXXX() 方法可能會導致主線程阻塞
所有 getXXX() 方法都是同步的,在主線程調用 get 方法,必須等待 SP 加載完畢,會導致主線程阻塞,下面的代碼,我相信小伙伴們并不陌生。
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 異步加載 SP 文件內容
sp.getString("jetpack", ""); // 等待 SP 加載完畢
調用 getSharedPreferences() 方法,最終會調用 SharedPreferencesImpl#startLoadFromDisk() 方法開啟一個線程異步讀取數據。
frameworks/base/core/java/android/app/SharedPreferencesImpl.java
private final Object mLock = new Object();
private boolean mLoaded = false;
private void startLoadFromDisk() {
synchronized (mLock) {
mLoaded = false;
}
new Thread("SharedPreferencesImpl-load") {
public void run() {
loadFromDisk();
}
}.start();
}
正如你所看到的,開啟一個線程異步讀取數據,當我們正在讀取一個比較大的數據,還沒讀取完,接著調用 getXXX() 方法。
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
awaitLoadedLocked();
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
private void awaitLoadedLocked() {
......
while (!mLoaded) {
try {
mLock.wait();
} catch (InterruptedException unused) {
}
}
......
}
在同步方法內調用了 wait() 方法,會一直等待 getSharedPreferences() 方法開啟的線程讀取完數據才能繼續(xù)往下執(zhí)行,如果讀取幾 KB 的數據還好,假設讀取一個大的文件,勢必會造成主線程阻塞。
SP 不能保證類型安全
調用 getXXX() 方法的時候,可能會出現 ClassCastException 異常,因為使用相同的 key 進行操作的時候,putXXX 方法可以使用不同類型的數據覆蓋掉相同的 key。
val key = "jetpack"
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE) // 異步加載 SP 文件內容
sp.edit { putInt(key, 0) } // 使用 Int 類型的數據覆蓋相同的 key
sp.getString(key, ""); // 使用相同的 key 讀取 Sting 類型的數據
使用 Int 類型的數據覆蓋掉相同的 key,然后使用相同的 key 讀取 Sting 類型的數據,編譯正常,但是運行會出現以下異常。
java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
SP 加載的數據會一直留在內存中
通過 getSharedPreferences() 方法加載的數據,最后會將數據存儲在靜態(tài)的成員變量中。
// 調用 getSharedPreferences 方法,最后會調用 getSharedPreferencesCacheLocked 方法
public SharedPreferences getSharedPreferences(File file, int mode) {
......
final ArrayMap<File, SharedPreferencesImpl> cache = getSharedPreferencesCacheLocked();
return sp;
}
// 通過靜態(tài)的 ArrayMap 緩存 SP 加載的數據
private static ArrayMap<String, ArrayMap<File, SharedPreferencesImpl>> sSharedPrefsCache;
// 將數據保存在 sSharedPrefsCache 中
private ArrayMap<File, SharedPreferencesImpl> getSharedPreferencesCacheLocked() {
......
ArrayMap<File, SharedPreferencesImpl> packagePrefs = sSharedPrefsCache.get(packageName);
if (packagePrefs == null) {
packagePrefs = new ArrayMap<>();
sSharedPrefsCache.put(packageName, packagePrefs);
}
return packagePrefs;
}
通過靜態(tài)的 ArrayMap 緩存每一個 SP 文件,而每個 SP 文件內容通過 Map 緩存鍵值對數據,這樣數據會一直留在內存中,浪費內存。
apply() 方法是異步的,可能會發(fā)生 ANR
apply() 方法是異步的,為什么還會造成 ANR 呢?曾今的字節(jié)跳動就出現過這個問題,具體詳情可以點擊這里前去查看 剖析 SharedPreference apply 引起的 ANR 問題 而且 Google 也明確指出了 apply() 的問題。

簡單總結一下:apply() 方法是異步的,本身是不會有任何問題,但是當生命周期處于 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 的時候會一直等待 apply() 方法將數據保存成功,否則會一直等待,從而阻塞主線程造成 ANR,一起來分析一下為什么異步方法還會阻塞主線程,先來看看 apply() 方法的實現。
frameworks/base/core/java/android/app/SharedPreferencesImpl.java
public void apply() {
final long startTime = System.currentTimeMillis();
final MemoryCommitResult mcr = commitToMemory();
final Runnable awaitCommit = new Runnable() {
@Override
public void run() {
mcr.writtenToDiskLatch.await(); // 等待
......
}
};
// 將 awaitCommit 添加到隊列 QueuedWork 中
QueuedWork.addFinisher(awaitCommit);
Runnable postWriteRunnable = new Runnable() {
@Override
public void run() {
awaitCommit.run();
QueuedWork.removeFinisher(awaitCommit);
}
};
// 8.0 之前加入到一個單線程的線程池中執(zhí)行
// 8.0 之后加入 HandlerThread 中執(zhí)行寫入任務
SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable);
}
將一個 awaitCommit 的 Runnable 任務,添加到隊列 QueuedWork 中,在 awaitCommit 中會調用 await() 方法等待,在 handleStopService 、 handleStopActivity 等等生命周期會以這個作為判斷條件,等待任務執(zhí)行完畢 將一個 postWriteRunnable 的 Runnable 寫任務,通過 enqueueDiskWrite 方法,將寫入任務加入到隊列中,而寫入任務在一個線程中執(zhí)行
注意:在 8.0 之前和 8.0 之后 enqueueDiskWrite() 方法實現邏輯各不相同。
在 8.0 之前調用 enqueueDiskWrite() 方法,將寫入任務加入到 單個線程的線程池 中執(zhí)行,如果 apply() 多次的話,任務將會依次執(zhí)行,效率很低,android-7.0.0_r34 源碼如下所示。
// android-7.0.0_r34: frameworks/base/core/java/android/app/SharedPreferencesImpl.java
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
......
QueuedWork.singleThreadExecutor().execute(writeToDiskRunnable);
}
// android-7.0.0_r34: frameworks/base/core/java/android/app/QueuedWork.java
public static ExecutorService singleThreadExecutor() {
synchronized (QueuedWork.class) {
if (sSingleThreadExecutor == null) {
sSingleThreadExecutor = Executors.newSingleThreadExecutor();
}
return sSingleThreadExecutor;
}
}
通過 Executors.newSingleThreadExecutor() 方法創(chuàng)建了一個 單個線程的線程池,因此任務是串行的,通過 apply() 方法創(chuàng)建的任務,都會添加到這個線程池內。
在 8.0 之后將寫入任務加入到 LinkedList 鏈表中,在 HandlerThread 中執(zhí)行寫入任務,android-10.0.0_r14 源碼如下所示。
// android-10.0.0_r14: frameworks/base/core/java/android/app/SharedPreferencesImpl.java
private void enqueueDiskWrite(final MemoryCommitResult mcr,
final Runnable postWriteRunnable) {
......
QueuedWork.queue(writeToDiskRunnable, !isFromSyncCommit);
}
// android-10.0.0_r14: frameworks/base/core/java/android/app/QueuedWork.java
private static final LinkedList<Runnable> sWork = new LinkedList<>();
public static void queue(Runnable work, boolean shouldDelay) {
Handler handler = getHandler(); // 獲取 handlerThread.getLooper() 生成 Handler 對象
synchronized (sLock) {
sWork.add(work); // 將寫入任務加入到 LinkedList 鏈表中
if (shouldDelay && sCanDelay) {
handler.sendEmptyMessageDelayed(QueuedWorkHandler.MSG_RUN, DELAY);
} else {
handler.sendEmptyMessage(QueuedWorkHandler.MSG_RUN);
}
}
}
在 8.0 之后通過調用 handlerThread.getLooper() 方法生成 Handler,任務都會在 HandlerThread 中執(zhí)行,所有通過 apply() 方法創(chuàng)建的任務,都會添加到 LinkedList 鏈表中。
當生命周期處于 handleStopService() 、 handlePauseActivity() 、 handleStopActivity() 的時候會調用 QueuedWork.waitToFinish() 會等待寫入任務執(zhí)行完畢,我們以其中 handlePauseActivity() 方法為例。
public void handlePauseActivity(IBinder token, boolean finished, boolean userLeaving,
int configChanges, PendingTransactionActions pendingActions, String reason) {
......
// 確保寫任務都已經完成
QueuedWork.waitToFinish();
......
}
}
正如你所看到的在 handlePauseActivity() 方法中,調用了 QueuedWork.waitToFinish() 方法,會等待所有的寫入執(zhí)行完畢,Google 在 8.0 之后對這個方法做了很大的優(yōu)化,一起來看一下 8.0 之前和 8.0 之后的區(qū)別。
注意:在 8.0 之前和 8.0 之后 waitToFinish() 方法實現邏輯各不相同
在 8.0 之前 waitToFinish() 方法只做了一件事,會一直等待寫入任務執(zhí)行完畢,我先來看看在 android-7.0.0_r34 源碼實現。
android-7.0.0_r34: frameworks/base/core/java/android/app/QueuedWork.java
private static final ConcurrentLinkedQueue<Runnable> sPendingWorkFinishers =
new ConcurrentLinkedQueue<Runnable>();
public static void waitToFinish() {
Runnable toFinish;
while ((toFinish = sPendingWorkFinishers.poll()) != null) {
toFinish.run(); // 相當于調用 `mcr.writtenToDiskLatch.await()` 方法
}
}
sPendingWorkFinishers 是 ConcurrentLinkedQueue 實例,apply 方法會將寫入任務添加到 sPendingWorkFinishers 隊列中,在 單個線程的線程池 中執(zhí)行寫入任務,線程的調度并不由程序來控制,也就是說當生命周期切換的時候,任務不一定處于執(zhí)行狀態(tài)
toFinish.run() 方法,相當于調用 mcr.writtenToDiskLatch.await() 方法,會一直等待
waitToFinish() 方法就做了一件事,會一直等待寫入任務執(zhí)行完畢,其它什么都不做,當有很多寫入任務,會依次執(zhí)行,當文件很大時,效率很低,造成 ANR 就不奇怪了,尤其像字節(jié)跳動這種大規(guī)模的 App
在 8.0 之后 waitToFinish() 方法做了很大的優(yōu)化,當生命周期切換的時候,會主動觸發(fā)任務的執(zhí)行,而不是一直在等著,我們來看看 android-10.0.0_r14 源碼實現。
android-10.0.0_r14: frameworks/base/core/java/android/app/QueuedWork.java
private static final LinkedList<Runnable> sFinishers = new LinkedList<>();
public static void waitToFinish() {
......
try {
processPendingWork(); // 主動觸發(fā)任務的執(zhí)行
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
try {
// 等待任務執(zhí)行完畢
while (true) {
Runnable finisher;
synchronized (sLock) {
finisher = sFinishers.poll(); // 從 LinkedList 中取出任務
}
if (finisher == null) { // 當 LinkedList 中沒有任務時會跳出循環(huán)
break;
}
finisher.run(); // 相當于調用 `mcr.writtenToDiskLatch.await()`
}
}
......
}
在 waitToFinish() 方法中會主動調用 processPendingWork() 方法觸發(fā)任務的執(zhí)行,在 HandlerThread 中執(zhí)行寫入任務。
另外還做了一個很重要的優(yōu)化,當調用 apply() 方法的時候,執(zhí)行磁盤寫入,都是全量寫入,在 8.0 之前,調用 N 次 apply() 方法,就會執(zhí)行 N 次磁盤寫入,在 8.0 之后,apply() 方法調用了多次,只會執(zhí)行最后一次寫入,通過版本號來控制的。
SharedPreferences 的另外一個缺點就是 apply() 方法無法獲取到操作成功或者失敗的結果,而 commit() 方法是可以接收 MemoryCommitResult 里面的一個 boolean 參數作為結果,來看一下它們的方法簽名。
public void apply() { ... }
public boolean commit() { ... }
SP 不能用于跨進程通信
我們在創(chuàng)建 SP 實例的時候,需要傳入一個 mode,如下所示:
val sp = getSharedPreferences("ByteCode", Context.MODE_PRIVATE)
Context 內部還有一個 mode 是 MODE_MULTI_PROCESS,我們來看一下這個 mode 做了什么
public SharedPreferences getSharedPreferences(File file, int mode) {
if ((mode & Context.MODE_MULTI_PROCESS) != 0 ||
getApplicationInfo().targetSdkVersion < android.os.Build.VERSION_CODES.HONEYCOMB) {
// 重新讀取 SP 文件內容
sp.startReloadIfChangedUnexpectedly();
}
return sp;
}
在這里就做了一件事,當遇到 MODE_MULTI_PROCESS 的時候,會重新讀取 SP 文件內容,并不能用 SP 來做跨進程通信。
到這里關于 SharedPreferences 部分分析完了,接下來分析一下 DataStore 為我們解決什么問題?
2.DataStore 解決了什么問題
Preferences DataStore 主要用來替換 SharedPreferences,Preferences DataStore 解決了 SharedPreferences 帶來的所有問題。
Preferences DataStore 相比于 SharedPreferences 優(yōu)點
DataStore 是基于 Flow 實現的,所以保證了在主線程的安全性 以事務方式處理更新數據,事務有四大特性(原子性、一致性、 隔離性、持久性) 沒有 apply() 和 commit() 等等數據持久的方法 自動完成 SharedPreferences 遷移到 DataStore,保證數據一致性,不會造成數據損壞 可以監(jiān)聽到操作成功或者失敗結果
另外 Jetpack DataStore 提供了 Proto DataStore 方式,用于存儲類的對象(typed objects ),通過 protocol buffers 將對象序列化存儲在本地,protocol buffers 現在已經應用的非常廣泛,無論是微信還是阿里等等大廠都在使用,我們在部分場景中也使用了 protocol buffers,在后續(xù)的文章會詳細的分析。
注意:
Preferences DataStore 只支持 Int , Long , Boolean , Float , String 鍵值對數據,適合存儲簡單、小型的數據,并且不支持局部更新,如果修改了其中一個值,整個文件內容將會被重新序列化,可以運行 AndroidX-Jetpack-Practice/DataStoreSimple 體驗一下,如果需要局部更新,建議使用 Room。
3.在項目中使用 Preferences DataStore
Preferences DataStore 主要應用在 MVVM 當中的 Repository 層,在項目中使用 Preferences DataStore 非常簡單,只需要 4 步。
1. 需要添加 Preferences DataStore 依賴
implementation "androidx.datastore:datastore-preferences:1.0.0-alpha01"
2. 構建 DataStore
private val PREFERENCE_NAME = "DataStore"
var dataStore: DataStore<Preferences> = context.createDataStore(
name = PREFERENCE_NAME3. 從 Preferences DataStore 中讀取數據
Preferences DataStore 以鍵值對的形式存儲在本地,所以首先我們應該定義一個 Key.
val KEY_BYTE_CODE = preferencesKey<Boolean>("ByteCode")
這里和我們之前使用 SharedPreferences 的有點不一樣,在 Preferences DataStore 中 Key 是一個 Preferences.Key<T> 類型,只支持 Int , Long , Boolean , Float , String,源碼如下所示:
inline fun <reified T : Any> preferencesKey(name: String): Preferences.Key<T> {
return when (T::class) {
Int::class -> {
Preferences.Key<T>(name)
}
String::class -> {
Preferences.Key<T>(name)
}
Boolean::class -> {
Preferences.Key<T>(name)
}
Float::class -> {
Preferences.Key<T>(name)
}
Long::class -> {
Preferences.Key<T>(name)
}
...... // 如果是其他類型就會拋出異常
}
}
當我們定義好 Key 之后,就可以通過 dataStore.data 來獲取數據
override fun readData(key: Preferences.Key<Boolean>): Flow<Boolean> =
dataStore.data
.catch {
// 當讀取數據遇到錯誤時,如果是 `IOException` 異常,發(fā)送一個 emptyPreferences 來重新使用
// 但是如果是其他的異常,最好將它拋出去,不要隱藏問題
if (it is IOException) {
it.printStackTrace()
emit(emptyPreferences())
} else {
throw it
}
}.map { preferences ->
preferences[key] ?: false
}
Preferences DataStore 是基于 Flow 實現的,所以通過 dataStore.data 會返回一個 Flow<T>,每當數據變化的時候都會重新發(fā)出 catch 用來捕獲異常,當讀取數據出現異常時會拋出一個異常,如果是 IOException 異常,會發(fā)送一個 emptyPreferences() 來重新使用,如果是其他異常,最好將它拋出去
4. 向 Preferences DataStore 中寫入數據
在 Preferences DataStore 中是通過 DataStore.edit() 寫入數據的,DataStore.edit() 是一個 suspend 函數,所以只能在協(xié)程體內使用,每當遇到 suspend 函數以掛起的方式運行,并不會阻塞主線程。
以掛起的方式運行,不會阻塞主線程 :也就是協(xié)程作用域被掛起, 當前線程中協(xié)程作用域之外的代碼不會阻塞。
首先我們需要創(chuàng)建一個 suspend 函數,然后調用 DataStore.edit() 寫入數據即可。
override suspend fun saveData(key: Preferences.Key<Boolean>) {
dataStore.edit { mutablePreferences ->
val value = mutablePreferences[key] ?: false
mutablePreferences[key] = !value
}
}
到這里關于 Preferences DataStore 讀取數據和寫入數據就已經分析完了,接下來分析一下如何遷移 SharedPreferences 到 DataStore。
4.遷移 SharedPreferences 到 DataStore
遷移 SharedPreferences 到 DataStore 只需要 2 步。
dataStore = context.createDataStore(
name = PREFERENCE_NAME,
migrations = listOf(
SharedPreferencesMigration(
context,
SharedPreferencesRepository.PREFERENCE_NAME
)
)
)

注意:只從 SharedPreferences 遷移一次,因此一旦遷移成功之后,應該停止使用 SharedPreferences。
5.相比于 MMKV 有什么不同之處
最后用一張表格來對比一下 MMKV、DataStore、SharedPreferences 的不同之處,如果發(fā)現錯誤,或者有其他不同之處,期待你來一起完善。

另外在附上一張 Google 分析的 SharedPreferences 和 DataStore 的區(qū)別

全文到這里就結束了,這篇文章主要分析了 SharedPreferences 和 DataStore 的優(yōu)缺點,以及為什么需要引入 DataStore 和如何使用 DataStore,為了節(jié)省篇幅源碼分析部分會在后續(xù)的文章中分析。
關于 SharedPreferences 和 DataStore 相關的代碼,已經上傳到了 GitHub 歡迎前去查看 AndroidX-Jetpack-Practice/DataStoreSimple ,可以運行一下示例項目,體驗一下 SharedPreferences 和 DataStore 效果。
參考文獻
推薦閱讀
? 耗時2年,Android進階三部曲第三部《Android進階指北》出版!
推薦我的技術博客
推薦一下我的獨立博客: liuwangshu.cn ,內含Android最強原創(chuàng)知識體系,一直在更新,歡迎體驗和收藏!
BATcoder技術群,讓一部分人先進大廠
你好,我是劉望舒,百度百科收錄的騰訊云TVP專家,著有暢銷書《Android進階之光》《Android進階解密》《Android進階指北》,蟬聯四屆電子工業(yè)出版社年度優(yōu)秀作者,谷歌開發(fā)者社區(qū)特邀講師。
前華為面試官,現大廠技術負責人。
歡迎添加我的微信 henglimogan ,備注:BATcoder,加入BATcoder技術群。
明天見(??ω??)
