起源
就在前幾日,有幸拜讀到 HiDhl 的文章,繼騰訊開(kāi)源類似功能的MMKV之后,Google官方維護(hù)的 Jetpack DataStore 組件橫空出世——這是否意味著無(wú)論是騰訊三方還是Google官方的角度,SharedPreferences都徹底告別了這個(gè)時(shí)代?
無(wú)論是MMKV的支持者還是DataStore的擁躉,SharedPreferences似乎都不值一提;值得深思的是,筆者通過(guò)面試或者其它方式,和一些同行交流時(shí),卻遇到了以下的情形:
在談及SharedPreferences和MMKV,大多數(shù)人都能對(duì)前者的 缺陷,以及后者性能上若干 數(shù)量級(jí)的優(yōu)勢(shì) 娓娓道來(lái);但是,在針對(duì)前者的短板進(jìn)行細(xì)節(jié)化的討論時(shí),往往卻得不到更深入性的結(jié)果,簡(jiǎn)單列舉幾個(gè)問(wèn)題如下:
-
SharedPreferences是如何保證線程安全的,其內(nèi)部的實(shí)現(xiàn)用到了哪些鎖? - 進(jìn)程不安全是否會(huì)導(dǎo)致數(shù)據(jù)丟失?
- 數(shù)據(jù)丟失時(shí),其最終的屏障——文件備份機(jī)制是如何實(shí)現(xiàn)的?
- 如何實(shí)現(xiàn)進(jìn)程安全的
SharedPreferences?
除此之外,站在 設(shè)計(jì)者的角度 上,還有一些與架構(gòu)相關(guān),且同樣值得思考的問(wèn)題:
- 為什么
SharedPreferences會(huì)有這些缺陷,如何對(duì)這些缺陷做改進(jìn)的嘗試? - 為什么不惜推倒重來(lái),推出新的
DataStore組件來(lái)代替前者? - 令
Google工程師掣肘,時(shí)隔今日,這些缺陷依然存在的最根本性原因是什么?
而想要解除這些潛藏在內(nèi)心最深處的困惑,就不得不從SharedPreferences本身的設(shè)計(jì)與實(shí)現(xiàn)講起了。
本文大綱如下:

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