起源
就在前幾日,有幸拜讀到 HiDhl 的文章,繼騰訊開源類似功能的MMKV
之后,Google
官方維護的 Jetpack DataStore
組件橫空出世——這是否意味著無論是騰訊三方還是Google
官方的角度,SharedPreferences
都徹底告別了這個時代?
無論是MMKV
的支持者還是DataStore
的擁躉,SharedPreferences
似乎都不值一提;值得深思的是,筆者通過面試或者其它方式,和一些同行交流時,卻遇到了以下的情形:
在談及SharedPreferences
和MMKV
,大多數(shù)人都能對前者的 缺陷,以及后者性能上若干 數(shù)量級的優(yōu)勢 娓娓道來;但是,在針對前者的短板進行細節(jié)化的討論時,往往卻得不到更深入性的結果,簡單列舉幾個問題如下:
-
SharedPreferences
是如何保證線程安全的,其內(nèi)部的實現(xiàn)用到了哪些鎖? - 進程不安全是否會導致數(shù)據(jù)丟失?
- 數(shù)據(jù)丟失時,其最終的屏障——文件備份機制是如何實現(xiàn)的?
- 如何實現(xiàn)進程安全的
SharedPreferences
?
除此之外,站在 設計者的角度 上,還有一些與架構相關,且同樣值得思考的問題:
- 為什么
SharedPreferences
會有這些缺陷,如何對這些缺陷做改進的嘗試? - 為什么不惜推倒重來,推出新的
DataStore
組件來代替前者? - 令
Google
工程師掣肘,時隔今日,這些缺陷依然存在的最根本性原因是什么?
而想要解除這些潛藏在內(nèi)心最深處的困惑,就不得不從SharedPreferences
本身的設計與實現(xiàn)講起了。
本文大綱如下:
一、SharedPreferences的前世今生
我們知道,就在不久前2019年的Google I/O
大會上,官方推出了Jetpack Security
組件,旨在保證文件和SharedPreferences
的安全性,SharedPreferences
的包裝類,EncryptedSharedPreferences
隆重登場。
不僅如此,Android 8.0
前后的源碼中,SharedPreferences
內(nèi)部的實現(xiàn)也略有不同。由此可見,Android
官方一直在盡力“拯救”SharedPreferences
。
因此,在毅然決然拋棄SharedPreferences
投奔新的解決方案之前,我們有必要重新認識一下它。
1、設計與實現(xiàn):建立基本結構
SharedPreferences
是Android
平臺上 輕量級的存儲類,用來保存App
的各種配置信息,其本質是一個以 鍵值對(key-value
)的方式保存數(shù)據(jù)的xml
文件,其保存在/data/data/shared_prefs
目錄下。
對于21世紀初,那個Android
系統(tǒng)誕生的時代而言,使用xml
文件保存應用輕量級的數(shù)據(jù)絕對是一個不錯的主意。那個時代的json
才剛剛出生不久,雖然也漸漸成為了主流的 輕量級數(shù)據(jù)交換格式 ,但是其更多的優(yōu)勢還是在于 可讀性,這也是筆者猜測沒有使用json
而使用xml
保存的原因之一。
現(xiàn)在我們?yōu)檫@個 輕量級的存儲類 建立了最基礎的模型,通過xml
中的鍵值對,將對應的數(shù)據(jù)保存到本地的文件中。這樣,每次讀取數(shù)據(jù)時,通過解析xml
文件,得到指定key
對應的value
;每次更新數(shù)據(jù),也通過文件中key
更新對應的value
。
2、讀操作的優(yōu)化
通過這樣的方式,雖然我們建立了一個最簡單的 文件存儲系統(tǒng),但是性能實在不敢恭維,每次讀取一個key
對應的值都要重新對文件進行一次讀的操作?顯然需要盡量避免笨重的I/O
操作。
因此設計者針對讀操作進行了簡單的優(yōu)化,當SharedPreferences
對象第一次通過Context.getSharedPreferences()
進行初始化時,對xml
文件進行一次讀取,并將文件內(nèi)所有內(nèi)容(即所有的鍵值對)緩到內(nèi)存的一個Map
中,這樣,接下來所有的讀操作,只需要從這個Map
中取就可以了:
final class SharedPreferencesImpl implements SharedPreferences {
private final File mFile; // 對應的xml文件
private Map<String, Object> mMap; // Map中緩存了xml文件中所有的鍵值對
}
復制代碼
讀者不禁會有疑問,雖然節(jié)省了I/O
的操作,但另一個視角分析,當xml
中數(shù)據(jù)量過大時,這種 內(nèi)存緩存機制 是否會產(chǎn)生 高內(nèi)存占用 的風險?
這也正是很多開發(fā)者詬病SharedPreferences
的原因之一,那么,從事物的兩面性上來看,高內(nèi)存占用 真的是設計者的問題嗎?
不盡然,因為SharedPreferences
的設計初衷是數(shù)據(jù)的 輕量級存儲 ,對于類似應用的簡單的配置項(比如一個boolean
或者int
類型),即使很多也并不會對內(nèi)存有過高的占用;而對于復雜的數(shù)據(jù)(比如復雜對象序列化后的字符串),開發(fā)者更應該使用類似Room
這樣的解決方案,而非一股腦存儲到SharedPreferences
中。
因此,相對于「SharedPreferences
會導致內(nèi)存使用過高」的說法,筆者更傾向于更客觀的進行總結:
雖然 內(nèi)存緩存機制 表面上看起來好像是一種 空間換時間 的權衡,實際上規(guī)避了短時間內(nèi)頻繁的I/O
操作對性能產(chǎn)生的影響,而通過良好的代碼規(guī)范,也能夠避免該機制可能會導致內(nèi)存占用過高的副作用,所以這種設計是 值得肯定 的。
3、寫操作的優(yōu)化
針對寫操作,設計者同樣設計了一系列的接口,以達到優(yōu)化性能的目的。
我們知道對鍵值對進行更新是通過mSharedPreferences.edit().putString().commit()
進行操作的——edit()
是什么,commit()
又是什么,為什么不單純的設計初mSharedPreferences.putString()
這樣的接口?
設計者希望,在復雜的業(yè)務中,有時候一次操作會導致多個鍵值對的更新,這時,與其多次更新文件,我們更傾向將這些更新 合并到一次寫操作 中,以達到性能的優(yōu)化。
因此,對于SharedPreferences
的寫操作,設計者抽象出了一個Editor
類,不管某次操作通過若干次調用putXXX()
方法,更新了幾個xml
中的鍵值對,只有調用了commit()
方法,最終才會真正寫入文件:
// 簡單的業(yè)務,一次更新一個鍵值對
sharedPreferences.edit().putString().commit();
// 復雜的業(yè)務,一次更新多個鍵值對,仍然只進行一次IO操作(文件的寫入)
Editor editor = sharedPreferences.edit();
editor.putString();
editor.putBoolean().putInt();
editor.commit(); // commit()才會更新文件
復制代碼
了解到這一點,讀者應該明白,通過簡單粗暴的封裝,以達到類似SPUtils.putXXX()
這種所謂代碼量的節(jié)省,從而忽略了Editor.commit()
的設計理念和使用場景,往往是不可取的,從設計上來講,這甚至是一種 倒退 。
另外一個值得思考的角度是,本質上文件的I/O
是一個非常重的操作,直接放在主線程中的commit()
方法某些場景下會導致ANR
(比如數(shù)據(jù)量過大),因此更合理的方式是應該將其放入子線程執(zhí)行。
因此設計者還為Editor
提供了一個apply()
方法,用于異步執(zhí)行文件數(shù)據(jù)的同步,并推薦開發(fā)者使用apply()
而非commit()
。
看起來Editor
+apply()
方法對寫操作做了很大的優(yōu)化,但更多的問題隨之而來,比如子線程更新文件,必然會引發(fā) 線程安全問題;此外,apply()
方法真的能夠像我們預期的一樣,能夠避免ANR
嗎?答案是并不能,這個我們后文再提。
4、數(shù)據(jù)的更新 & 文件數(shù)量的權衡
隨著業(yè)務復雜度的上升,需要面對新的問題是,xml
文件中的數(shù)據(jù)量愈發(fā)龐大,一次文件的寫操作成本也愈發(fā)高昂。
xml
中數(shù)據(jù)是如何更新的?讀者可以簡單理解為 全量更新 ——通過上文,我們知道xml
文件中的數(shù)據(jù)會緩存到內(nèi)存的mMap
中,每次在調用editor.putXXX()
時,實際上會將新的數(shù)據(jù)存入在mMap
,當調用commit()
或apply()
時,最終會將mMap
的所有數(shù)據(jù)全量更新到xml
文件里。
由此可見,xml
中數(shù)據(jù)量的大小,的確會對 寫操作 的成本有一定的影響,因此,設計者更建議將 不同業(yè)務模塊的數(shù)據(jù)分文件存儲 ,即根據(jù)業(yè)務將數(shù)據(jù)存放在不同的xml
文件中。
因此,不同的xml
文件應該對應不同的SharedPreferences
對象,如果想要對某個xml
文件進行操作,就通過傳不同的文件標識符,獲取對應的SharedPreferences
:
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// name參數(shù)就是文件名,通過不同文件名,獲取指定的SharedPreferences對象
}
復制代碼
因此,當xml
文件過大時,應該考慮根據(jù)業(yè)務,細分為若干個小的文件進行管理;但過多的小文件也會導致過多的SharedPreferences
對象,不好管理且易混淆。實際開發(fā)中,開發(fā)者應根據(jù)業(yè)務的需要進行對應的平衡。
二、線程安全問題
SharedPreferences
是線程安全的嗎?
毫無疑問,SharedPreferences
是線程安全的,但這只是對成品而言,對于我們目前的實現(xiàn),顯然還有一定的差距,如何保證線程安全呢?
——那,為了保證線程安全,怎么著不得加個鎖吧。
加個鎖?那是起步!3把鎖,你還別嫌多。你得研究開發(fā)寫代碼時的心理,舍得往代碼里吭哧吭哧加鎖的開發(fā),壓根不在乎再加2把。
1、保證復雜流程代碼的可讀性
為了保證SharedPreferences
是線程安全的,Google
的設計者一共使用了3把鎖:
final class SharedPreferencesImpl implements SharedPreferences {
// 1、使用注釋標記鎖的順序
// Lock ordering rules:
// - acquire SharedPreferencesImpl.mLock before EditorImpl.mLock
// - acquire mWritingToDiskLock before EditorImpl.mLock
// 2、通過注解標記持有的是哪把鎖
@GuardedBy("mLock")
private Map<String, Object> mMap;
@GuardedBy("mWritingToDiskLock")
private long mDiskStateGeneration;
public final class EditorImpl implements Editor {
@GuardedBy("mEditorLock")
private final Map<String, Object> mModified = new HashMap<>();
}
}
復制代碼
對于這樣復雜的類而言,如何提高代碼的可讀性?SharedPreferencesImpl
做了一個很好的示范:通過注釋明確寫明加鎖的順序,并為被加鎖的成員使用@GuardedBy
注解。
對于簡單的 讀操作 而言,我們知道其原理是讀取內(nèi)存中mMap
的值并返回,那么為了保證線程安全,只需要加一把鎖保證mMap
的線程安全即可:
public String getString(String key, @Nullable String defValue) {
synchronized (mLock) {
String v = (String)mMap.get(key);
return v != null ? v : defValue;
}
}
復制代碼
那么,對于 寫操作 而言,我們也能夠通過一把鎖達到線程安全的目的嗎?
2、保證寫操作的線程安全
對于寫操作而言,每次putXXX()
并不能立即更新在mMap
中,這是理所當然的,如果開發(fā)者沒有調用apply()
方法,那么這些數(shù)據(jù)的更新理所當然應該被拋棄掉,但是如果直接更新在mMap
中,那么數(shù)據(jù)就難以恢復。
因此,Editor
本身也應該持有一個mEditorMap
對象,用于存儲數(shù)據(jù)的更新;只有當調用apply()
時,才嘗試將mEditorMap
與mMap
進行合并,以達到數(shù)據(jù)更新的目的。
因此,這里我們還需要另外一把鎖保證mEditorMap
的線程安全,筆者認為,不和mMap
公用同一把鎖的原因是,在apply()
被調用之前,getXXX
和putXXX
理應是沒有沖突的。
代碼實現(xiàn)參考如下:
public final class EditorImpl implements Editor {
@Override
public Editor putString(String key, String value) {
synchronized (mEditorLock) {
mEditorMap.put(key, value);
return this;
}
}
}
復制代碼
而當真正需要執(zhí)行apply()
進行寫操作時,mEditorMap
與mMap
進行合并,這時必須通過2把鎖保證mEditorMap
與mMap
的線程安全,保證mMap
最終能夠更新成功,最終向對應的xml
文件中進行更新。
文件的更新理所當然也需要加一把鎖:
// SharedPreferencesImpl.EditorImpl.enqueueDiskWrite()
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
復制代碼
最終,我們一共通過使用了3把鎖,對整個寫操作的線程安全進行了保證。
篇幅限制,本文不對源碼進行詳細引申,有興趣的讀者可參考
SharedPreferencesImpl.EditorImpl
類的apply()
源碼。
3、擺脫不掉的ANR
apply()
方法設計的初衷是為了規(guī)避主線程的I/O
操作導致ANR
問題的產(chǎn)生,那么,ANR
的問題真得到了有效的解決嗎?
并沒有,在 字節(jié)跳動技術團隊 的 這篇文章 中,明確說明了線上環(huán)境中,相當一部分的ANR
統(tǒng)計都來自于SharedPreference
,由此可見,apply()
并沒有完全規(guī)避掉這個問題,那么導致ANR
的原因又是什么呢。
經(jīng)過我們的優(yōu)化,SharedPreferences
的確是線程安全的,apply()
的內(nèi)部實現(xiàn)也的確將I/O
操作交給了子線程,可以說其本身是沒有問題的,而其原因歸根到底則是Android
的另外一個機制。
在apply()
方法中,首先會創(chuàng)建一個等待鎖,根據(jù)源碼版本的不同,最終更新文件的任務會交給QueuedWork.singleThreadExecutor()
單個線程或者HandlerThread
去執(zhí)行,當文件更新完畢后會釋放鎖。
但當Activity.onStop()
以及Service
處理onStop
等相關方法時,則會執(zhí)行 QueuedWork.waitToFinish()
等待所有的等待鎖釋放,因此如果SharedPreferences
一直沒有完成更新任務,有可能會導致卡在主線程,最終超時導致ANR
。
什么情況下
SharedPreferences
會一直沒有完成任務呢? 比如太頻繁無節(jié)制的apply()
,導致任務過多,這也側面說明了SPUtils.putXXX()
這種粗暴的設計的弊端。
Google
為何這么設計呢?字節(jié)跳動技術團隊的這篇文章中做出了如下猜測:
無論是 commit 還是 apply 都會產(chǎn)生 ANR,但從 Android 之初到目前 Android8.0,Google 一直沒有修復此 bug,我們貿(mào)然處理會產(chǎn)生什么問題呢。Google 在 Activity 和 Service 調用 onStop 之前阻塞主線程來處理 SP,我們能猜到的唯一原因是盡可能的保證數(shù)據(jù)的持久化。因為如果在運行過程中產(chǎn)生了 crash,也會導致 SP 未持久化,持久化本身是 IO 操作,也會失敗。
如此看來,導致這種缺陷的原因,其設計也的確是有自身的考量的,好在 這篇文章 末尾也提出了一個折衷的解決方案,有興趣的讀者可以了解一下,本文不贅述。
三、進程安全問題
1、如何保證進程安全
SharedPreferences
是否進程安全呢?讓我們打開SharedPreferences
的源碼,看一下最頂部類的注釋:
/**
* ...
* This class does not support use across multiple processes.
* ...
*/
public interface SharedPreferences {
// ...
}
復制代碼
由此,由于沒有使用跨進程的鎖,SharedPreferences
是進程不安全的,在跨進程頻繁讀寫會有數(shù)據(jù)丟失的可能,這顯然不符合我們的期望。
那么,如何保證SharedPreferences
進程的安全呢?
實現(xiàn)思路很多,比如使用文件鎖,保證每次只有一個進程在訪問這個文件;或者對于Android
開發(fā)而言,ContentProvider
作為官方倡導的跨進程組件,其它進程通過定制的ContentProvider
用于訪問SharedPreferences
,同樣可以保證SharedPreferences
的進程安全;等等。
篇幅原因,對實現(xiàn)有興趣的讀者,可以參考 百度 或文章末尾的 參考資料。
2、文件損壞 & 備份機制
SharedPreferences
再次迎來了新的挑戰(zhàn)。
由于不可預知的原因(比如內(nèi)核崩潰或者系統(tǒng)突然斷電),xml
文件的 寫操作 異常中止,Android
系統(tǒng)本身的文件系統(tǒng)雖然有很多保護措施,但依然會有數(shù)據(jù)丟失或者文件損壞的情況。
作為設計者,如何規(guī)避這樣的問題呢?答案是對文件進行備份,SharedPreferences
的寫入操作正式執(zhí)行之前,首先會對文件進行備份,將初始文件重命名為增加了一個.bak
后綴的備份文件:
// 嘗試寫入文件
private void writeToFile(...) {
if (!backupFileExists) {
!mFile.renameTo(mBackupFile);
}
}
復制代碼
這之后,嘗試對文件進行寫入操作,寫入成功時,則將備份文件刪除:
// 寫入成功,立即刪除存在的備份文件
// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();
復制代碼
反之,若因異常情況(比如進程被殺)導致寫入失敗,進程再次啟動后,若發(fā)現(xiàn)存在備份文件,則將備份文件重名為源文件,原本未完成寫入的文件就直接丟棄:
// 從磁盤初始化加載時執(zhí)行
private void loadFromDisk() {
synchronized (mLock) {
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
}
復制代碼
現(xiàn)在,通過文件備份機制,我們能夠保證數(shù)據(jù)只會丟失最后的更新,而之前成功保存的數(shù)據(jù)依然能夠有效。
四、小結
綜合來看,SharedPreferences
那些一直被關注的問題,從設計的角度來看,都是有其自身考量的。
我們可以看到,雖然SharedPreferences
其整體是比較完善的,但是為什么相比較MMKV
和Jetpack DataStore
,其性能依然有明顯的落差呢?
這個原因更加綜合且復雜,即使筆者也還是處于淺顯的了解層面,比如后兩者在其數(shù)據(jù)序列化方面都選用了更先進的protobuf
協(xié)議,MMKV
自身的數(shù)據(jù)的 增量更新 機制等等,有機會的話會另起新的一篇進行分享。
反過頭來,相對于對組件之間單純進行 好 和 不好 的定義,筆者更認為通過辯證的方式去看待和學習它們,相信即使是SharedPreferences
,學習下來依然能夠有所收獲。
作者:卻把清梅嗅
鏈接:https://juejin.im/post/6884505736836022280