起源
就在前幾日饰剥,有幸拜讀到 HiDhl 的文章坠宴,繼騰訊開源類似功能的MMKV
之后粉私,Google
官方維護的 Jetpack DataStore
組件橫空出世——這是否意味著無論是騰訊三方還是Google
官方的角度,SharedPreferences
都徹底告別了這個時代壁顶?
無論是MMKV
的支持者還是DataStore
的擁躉珠洗,SharedPreferences
似乎都不值一提;值得深思的是若专,筆者通過面試或者其它方式许蓖,和一些同行交流時,卻遇到了以下的情形:
在談及SharedPreferences
和MMKV
调衰,大多數(shù)人都能對前者的 缺陷膊爪,以及后者性能上若干 數(shù)量級的優(yōu)勢 娓娓道來;但是嚎莉,在針對前者的短板進行細節(jié)化的討論時米酬,往往卻得不到更深入性的結(jié)果,簡單列舉幾個問題如下:
-
SharedPreferences
是如何保證線程安全的趋箩,其內(nèi)部的實現(xiàn)用到了哪些鎖赃额? - 進程不安全是否會導(dǎo)致數(shù)據(jù)丟失?
- 數(shù)據(jù)丟失時叫确,其最終的屏障——文件備份機制是如何實現(xiàn)的跳芳?
- 如何實現(xiàn)進程安全的
SharedPreferences
?
除此之外启妹,站在 設(shè)計者的角度 上筛严,還有一些與架構(gòu)相關(guān),且同樣值得思考的問題:
- 為什么
SharedPreferences
會有這些缺陷饶米,如何對這些缺陷做改進的嘗試桨啃? - 為什么不惜推倒重來车胡,推出新的
DataStore
組件來代替前者? - 令
Google
工程師掣肘照瘾,時隔今日匈棘,這些缺陷依然存在的最根本性原因是什么?
而想要解除這些潛藏在內(nèi)心最深處的困惑析命,就不得不從SharedPreferences
本身的設(shè)計與實現(xiàn)講起了主卫。
本文大綱如下:
一、SharedPreferences的前世今生
我們知道鹃愤,就在不久前2019年的Google I/O
大會上簇搅,官方推出了Jetpack Security
組件,旨在保證文件和SharedPreferences
的安全性软吐,SharedPreferences
的包裝類瘩将,EncryptedSharedPreferences
隆重登場。
不僅如此凹耙,Android 8.0
前后的源碼中姿现,SharedPreferences
內(nèi)部的實現(xiàn)也略有不同。由此可見肖抱,Android
官方一直在盡力“拯救”SharedPreferences
备典。
因此,在毅然決然拋棄SharedPreferences
投奔新的解決方案之前意述,我們有必要重新認識一下它提佣。
1、設(shè)計與實現(xiàn):建立基本結(jié)構(gòu)
SharedPreferences
是Android
平臺上 輕量級的存儲類欲险,用來保存App
的各種配置信息镐依,其本質(zhì)是一個以 鍵值對(key-value
)的方式保存數(shù)據(jù)的xml
文件匹涮,其保存在/data/data/shared_prefs
目錄下天试。
對于21世紀初,那個Android
系統(tǒng)誕生的時代而言然低,使用xml
文件保存應(yīng)用輕量級的數(shù)據(jù)絕對是一個不錯的主意喜每。那個時代的json
才剛剛出生不久,雖然也漸漸成為了主流的 輕量級數(shù)據(jù)交換格式 雳攘,但是其更多的優(yōu)勢還是在于 可讀性带兜,這也是筆者猜測沒有使用json
而使用xml
保存的原因之一。
現(xiàn)在我們?yōu)檫@個 輕量級的存儲類 建立了最基礎(chǔ)的模型吨灭,通過xml
中的鍵值對刚照,將對應(yīng)的數(shù)據(jù)保存到本地的文件中。這樣喧兄,每次讀取數(shù)據(jù)時无畔,通過解析xml
文件啊楚,得到指定key
對應(yīng)的value
;每次更新數(shù)據(jù)浑彰,也通過文件中key
更新對應(yīng)的value
恭理。
2、讀操作的優(yōu)化
通過這樣的方式郭变,雖然我們建立了一個最簡單的 文件存儲系統(tǒng)颜价,但是性能實在不敢恭維,每次讀取一個key
對應(yīng)的值都要重新對文件進行一次讀的操作诉濒?顯然需要盡量避免笨重的I/O
操作周伦。
因此設(shè)計者針對讀操作進行了簡單的優(yōu)化,當SharedPreferences
對象第一次通過Context.getSharedPreferences()
進行初始化時未荒,對xml
文件進行一次讀取横辆,并將文件內(nèi)所有內(nèi)容(即所有的鍵值對)緩到內(nèi)存的一個Map
中,這樣茄猫,接下來所有的讀操作狈蚤,只需要從這個Map
中取就可以了:
final class SharedPreferencesImpl implements SharedPreferences {
private final File mFile; // 對應(yīng)的xml文件
private Map<String, Object> mMap; // Map中緩存了xml文件中所有的鍵值對
}
復(fù)制代碼
讀者不禁會有疑問,雖然節(jié)省了I/O
的操作划纽,但另一個視角分析脆侮,當xml
中數(shù)據(jù)量過大時,這種 內(nèi)存緩存機制 是否會產(chǎn)生 高內(nèi)存占用 的風險勇劣?
這也正是很多開發(fā)者詬病SharedPreferences
的原因之一靖避,那么,從事物的兩面性上來看比默,高內(nèi)存占用 真的是設(shè)計者的問題嗎幻捏?
不盡然,因為SharedPreferences
的設(shè)計初衷是數(shù)據(jù)的 輕量級存儲 命咐,對于類似應(yīng)用的簡單的配置項(比如一個boolean
或者int
類型)篡九,即使很多也并不會對內(nèi)存有過高的占用;而對于復(fù)雜的數(shù)據(jù)(比如復(fù)雜對象序列化后的字符串)醋奠,開發(fā)者更應(yīng)該使用類似Room
這樣的解決方案榛臼,而非一股腦存儲到SharedPreferences
中。
因此窜司,相對于「SharedPreferences
會導(dǎo)致內(nèi)存使用過高」的說法沛善,筆者更傾向于更客觀的進行總結(jié):
雖然 內(nèi)存緩存機制 表面上看起來好像是一種 空間換時間 的權(quán)衡,實際上規(guī)避了短時間內(nèi)頻繁的I/O
操作對性能產(chǎn)生的影響塞祈,而通過良好的代碼規(guī)范金刁,也能夠避免該機制可能會導(dǎo)致內(nèi)存占用過高的副作用,所以這種設(shè)計是 值得肯定 的。
3尤蛮、寫操作的優(yōu)化
針對寫操作漠秋,設(shè)計者同樣設(shè)計了一系列的接口,以達到優(yōu)化性能的目的抵屿。
我們知道對鍵值對進行更新是通過mSharedPreferences.edit().putString().commit()
進行操作的——edit()
是什么庆锦,commit()
又是什么,為什么不單純的設(shè)計初mSharedPreferences.putString()
這樣的接口轧葛?
設(shè)計者希望搂抒,在復(fù)雜的業(yè)務(wù)中,有時候一次操作會導(dǎo)致多個鍵值對的更新尿扯,這時求晶,與其多次更新文件,我們更傾向?qū)⑦@些更新 合并到一次寫操作 中衷笋,以達到性能的優(yōu)化芳杏。
因此,對于SharedPreferences
的寫操作辟宗,設(shè)計者抽象出了一個Editor
類爵赵,不管某次操作通過若干次調(diào)用putXXX()
方法,更新了幾個xml
中的鍵值對泊脐,只有調(diào)用了commit()
方法空幻,最終才會真正寫入文件:
// 簡單的業(yè)務(wù),一次更新一個鍵值對
sharedPreferences.edit().putString().commit();
// 復(fù)雜的業(yè)務(wù)容客,一次更新多個鍵值對秕铛,仍然只進行一次IO操作(文件的寫入)
Editor editor = sharedPreferences.edit();
editor.putString();
editor.putBoolean().putInt();
editor.commit(); // commit()才會更新文件
復(fù)制代碼
了解到這一點,讀者應(yīng)該明白缩挑,通過簡單粗暴的封裝但两,以達到類似SPUtils.putXXX()
這種所謂代碼量的節(jié)省,從而忽略了Editor.commit()
的設(shè)計理念和使用場景供置,往往是不可取的谨湘,從設(shè)計上來講,這甚至是一種 倒退 士袄。
另外一個值得思考的角度是悲关,本質(zhì)上文件的I/O
是一個非常重的操作,直接放在主線程中的commit()
方法某些場景下會導(dǎo)致ANR
(比如數(shù)據(jù)量過大)娄柳,因此更合理的方式是應(yīng)該將其放入子線程執(zhí)行。
因此設(shè)計者還為Editor
提供了一個apply()
方法艘绍,用于異步執(zhí)行文件數(shù)據(jù)的同步赤拒,并推薦開發(fā)者使用apply()
而非commit()
。
看起來Editor
+apply()
方法對寫操作做了很大的優(yōu)化,但更多的問題隨之而來挎挖,比如子線程更新文件这敬,必然會引發(fā) 線程安全問題;此外蕉朵,apply()
方法真的能夠像我們預(yù)期的一樣崔涂,能夠避免ANR
嗎?答案是并不能始衅,這個我們后文再提冷蚂。
4、數(shù)據(jù)的更新 & 文件數(shù)量的權(quán)衡
隨著業(yè)務(wù)復(fù)雜度的上升汛闸,需要面對新的問題是蝙茶,xml
文件中的數(shù)據(jù)量愈發(fā)龐大,一次文件的寫操作成本也愈發(fā)高昂诸老。
xml
中數(shù)據(jù)是如何更新的隆夯?讀者可以簡單理解為 全量更新 ——通過上文,我們知道xml
文件中的數(shù)據(jù)會緩存到內(nèi)存的mMap
中别伏,每次在調(diào)用editor.putXXX()
時蹄衷,實際上會將新的數(shù)據(jù)存入在mMap
,當調(diào)用commit()
或apply()
時厘肮,最終會將mMap
的所有數(shù)據(jù)全量更新到xml
文件里宦芦。
由此可見,xml
中數(shù)據(jù)量的大小轴脐,的確會對 寫操作 的成本有一定的影響调卑,因此,設(shè)計者更建議將 不同業(yè)務(wù)模塊的數(shù)據(jù)分文件存儲 大咱,即根據(jù)業(yè)務(wù)將數(shù)據(jù)存放在不同的xml
文件中恬涧。
因此,不同的xml
文件應(yīng)該對應(yīng)不同的SharedPreferences
對象碴巾,如果想要對某個xml
文件進行操作怕篷,就通過傳不同的文件標識符,獲取對應(yīng)的SharedPreferences
:
@Override
public SharedPreferences getSharedPreferences(String name, int mode) {
// name參數(shù)就是文件名疏遏,通過不同文件名溉旋,獲取指定的SharedPreferences對象
}
復(fù)制代碼
因此,當xml
文件過大時煮仇,應(yīng)該考慮根據(jù)業(yè)務(wù)劳跃,細分為若干個小的文件進行管理;但過多的小文件也會導(dǎo)致過多的SharedPreferences
對象浙垫,不好管理且易混淆刨仑。實際開發(fā)中郑诺,開發(fā)者應(yīng)根據(jù)業(yè)務(wù)的需要進行對應(yīng)的平衡。
二杉武、線程安全問題
SharedPreferences
是線程安全的嗎辙诞?
毫無疑問,SharedPreferences
是線程安全的轻抱,但這只是對成品而言飞涂,對于我們目前的實現(xiàn),顯然還有一定的差距祈搜,如何保證線程安全呢较店?
——那,為了保證線程安全夭问,怎么著不得加個鎖吧泽西。
加個鎖?那是起步缰趋!3把鎖捧杉,你還別嫌多。你得研究開發(fā)寫代碼時的心理秘血,舍得往代碼里吭哧吭哧加鎖的開發(fā)味抖,壓根不在乎再加2把。
1灰粮、保證復(fù)雜流程代碼的可讀性
為了保證SharedPreferences
是線程安全的仔涩,Google
的設(shè)計者一共使用了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<>();
}
}
復(fù)制代碼
對于這樣復(fù)雜的類而言熔脂,如何提高代碼的可讀性?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;
}
}
復(fù)制代碼
那么,對于 寫操作 而言硕舆,我們也能夠通過一把鎖達到線程安全的目的嗎秽荞?
2、保證寫操作的線程安全
對于寫操作而言抚官,每次putXXX()
并不能立即更新在mMap
中扬跋,這是理所當然的,如果開發(fā)者沒有調(diào)用apply()
方法耗式,那么這些數(shù)據(jù)的更新理所當然應(yīng)該被拋棄掉胁住,但是如果直接更新在mMap
中趁猴,那么數(shù)據(jù)就難以恢復(fù)刊咳。
因此彪见,Editor
本身也應(yīng)該持有一個mEditorMap
對象,用于存儲數(shù)據(jù)的更新娱挨;只有當調(diào)用apply()
時余指,才嘗試將mEditorMap
與mMap
進行合并,以達到數(shù)據(jù)更新的目的跷坝。
因此酵镜,這里我們還需要另外一把鎖保證mEditorMap
的線程安全,筆者認為柴钻,不和mMap
公用同一把鎖的原因是淮韭,在apply()
被調(diào)用之前,getXXX
和putXXX
理應(yīng)是沒有沖突的贴届。
代碼實現(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ù)制代碼
而當真正需要執(zhí)行apply()
進行寫操作時靠粪,mEditorMap
與mMap
進行合并,這時必須通過2把鎖保證mEditorMap
與mMap
的線程安全毫蚓,保證mMap
最終能夠更新成功占键,最終向?qū)?yīng)的xml
文件中進行更新。
文件的更新理所當然也需要加一把鎖:
// SharedPreferencesImpl.EditorImpl.enqueueDiskWrite()
synchronized (mWritingToDiskLock) {
writeToFile(mcr, isFromSyncCommit);
}
復(fù)制代碼
最終元潘,我們一共通過使用了3把鎖畔乙,對整個寫操作的線程安全進行了保證。
篇幅限制翩概,本文不對源碼進行詳細引申牲距,有興趣的讀者可參考
SharedPreferencesImpl.EditorImpl
類的apply()
源碼。
3钥庇、擺脫不掉的ANR
apply()
方法設(shè)計的初衷是為了規(guī)避主線程的I/O
操作導(dǎo)致ANR
問題的產(chǎn)生牍鞠,那么,ANR
的問題真得到了有效的解決嗎上沐?
并沒有皮服,在 字節(jié)跳動技術(shù)團隊 的 這篇文章 中,明確說明了線上環(huán)境中参咙,相當一部分的ANR
統(tǒng)計都來自于SharedPreference
龄广,由此可見,apply()
并沒有完全規(guī)避掉這個問題蕴侧,那么導(dǎo)致ANR
的原因又是什么呢择同。
經(jīng)過我們的優(yōu)化,SharedPreferences
的確是線程安全的净宵,apply()
的內(nèi)部實現(xiàn)也的確將I/O
操作交給了子線程敲才,可以說其本身是沒有問題的裹纳,而其原因歸根到底則是Android
的另外一個機制。
在apply()
方法中紧武,首先會創(chuàng)建一個等待鎖剃氧,根據(jù)源碼版本的不同,最終更新文件的任務(wù)會交給QueuedWork.singleThreadExecutor()
單個線程或者HandlerThread
去執(zhí)行阻星,當文件更新完畢后會釋放鎖朋鞍。
但當Activity.onStop()
以及Service
處理onStop
等相關(guān)方法時,則會執(zhí)行 QueuedWork.waitToFinish()
等待所有的等待鎖釋放妥箕,因此如果SharedPreferences
一直沒有完成更新任務(wù)滥酥,有可能會導(dǎo)致卡在主線程,最終超時導(dǎo)致ANR
畦幢。
什么情況下
SharedPreferences
會一直沒有完成任務(wù)呢坎吻? 比如太頻繁無節(jié)制的apply()
,導(dǎo)致任務(wù)過多宇葱,這也側(cè)面說明了SPUtils.putXXX()
這種粗暴的設(shè)計的弊端瘦真。
Google
為何這么設(shè)計呢?字節(jié)跳動技術(shù)團隊的這篇文章中做出了如下猜測:
無論是 commit 還是 apply 都會產(chǎn)生 ANR贝搁,但從 Android 之初到目前 Android8.0吗氏,Google 一直沒有修復(fù)此 bug,我們貿(mào)然處理會產(chǎn)生什么問題呢雷逆。Google 在 Activity 和 Service 調(diào)用 onStop 之前阻塞主線程來處理 SP弦讽,我們能猜到的唯一原因是盡可能的保證數(shù)據(jù)的持久化。因為如果在運行過程中產(chǎn)生了 crash膀哲,也會導(dǎo)致 SP 未持久化往产,持久化本身是 IO 操作,也會失敗某宪。
如此看來仿村,導(dǎo)致這種缺陷的原因,其設(shè)計也的確是有自身的考量的兴喂,好在 這篇文章 末尾也提出了一個折衷的解決方案蔼囊,有興趣的讀者可以了解一下,本文不贅述衣迷。
三畏鼓、進程安全問題
1、如何保證進程安全
SharedPreferences
是否進程安全呢壶谒?讓我們打開SharedPreferences
的源碼云矫,看一下最頂部類的注釋:
/**
* ...
* This class does not support use across multiple processes.
* ...
*/
public interface SharedPreferences {
// ...
}
復(fù)制代碼
由此,由于沒有使用跨進程的鎖汗菜,SharedPreferences
是進程不安全的让禀,在跨進程頻繁讀寫會有數(shù)據(jù)丟失的可能挑社,這顯然不符合我們的期望。
那么巡揍,如何保證SharedPreferences
進程的安全呢?
實現(xiàn)思路很多痛阻,比如使用文件鎖,保證每次只有一個進程在訪問這個文件吼肥;或者對于Android
開發(fā)而言录平,ContentProvider
作為官方倡導(dǎo)的跨進程組件麻车,其它進程通過定制的ContentProvider
用于訪問SharedPreferences
缀皱,同樣可以保證SharedPreferences
的進程安全;等等动猬。
篇幅原因啤斗,對實現(xiàn)有興趣的讀者,可以參考 百度 或文章末尾的 參考資料赁咙。
2钮莲、文件損壞 & 備份機制
SharedPreferences
再次迎來了新的挑戰(zhàn)。
由于不可預(yù)知的原因(比如內(nèi)核崩潰或者系統(tǒng)突然斷電)彼水,xml
文件的 寫操作 異常中止崔拥,Android
系統(tǒng)本身的文件系統(tǒng)雖然有很多保護措施,但依然會有數(shù)據(jù)丟失或者文件損壞的情況凤覆。
作為設(shè)計者链瓦,如何規(guī)避這樣的問題呢?答案是對文件進行備份盯桦,SharedPreferences
的寫入操作正式執(zhí)行之前慈俯,首先會對文件進行備份,將初始文件重命名為增加了一個.bak
后綴的備份文件:
// 嘗試寫入文件
private void writeToFile(...) {
if (!backupFileExists) {
!mFile.renameTo(mBackupFile);
}
}
復(fù)制代碼
這之后拥峦,嘗試對文件進行寫入操作贴膘,寫入成功時,則將備份文件刪除:
// 寫入成功略号,立即刪除存在的備份文件
// Writing was successful, delete the backup file if there is one.
mBackupFile.delete();
復(fù)制代碼
反之刑峡,若因異常情況(比如進程被殺)導(dǎo)致寫入失敗,進程再次啟動后玄柠,若發(fā)現(xiàn)存在備份文件突梦,則將備份文件重名為源文件,原本未完成寫入的文件就直接丟棄:
// 從磁盤初始化加載時執(zhí)行
private void loadFromDisk() {
synchronized (mLock) {
if (mBackupFile.exists()) {
mFile.delete();
mBackupFile.renameTo(mFile);
}
}
}
復(fù)制代碼
現(xiàn)在随闪,通過文件備份機制阳似,我們能夠保證數(shù)據(jù)只會丟失最后的更新,而之前成功保存的數(shù)據(jù)依然能夠有效铐伴。
四撮奏、小結(jié)
綜合來看俏讹,SharedPreferences
那些一直被關(guān)注的問題,從設(shè)計的角度來看畜吊,都是有其自身考量的泽疆。
我們可以看到,雖然SharedPreferences
其整體是比較完善的玲献,但是為什么相比較MMKV
和Jetpack DataStore
殉疼,其性能依然有明顯的落差呢?
這個原因更加綜合且復(fù)雜捌年,即使筆者也還是處于淺顯的了解層面瓢娜,比如后兩者在其數(shù)據(jù)序列化方面都選用了更先進的protobuf
協(xié)議,MMKV
自身的數(shù)據(jù)的 增量更新 機制等等礼预,有機會的話會另起新的一篇進行分享眠砾。
反過頭來,相對于對組件之間單純進行 好 和 不好 的定義托酸,筆者更認為通過辯證的方式去看待和學(xué)習它們褒颈,相信即使是SharedPreferences
,學(xué)習下來依然能夠有所收獲励堡。
作者:卻把清梅嗅
鏈接:https://juejin.im/post/6884505736836022280