官方也無力回天?Android SharedPreferences的設計與實現(xiàn)

起源

就在前幾日,有幸拜讀到 HiDhl文章,繼騰訊開源類似功能的MMKV之后,Google官方維護的 Jetpack DataStore 組件橫空出世——這是否意味著無論是騰訊三方還是Google官方的角度,SharedPreferences都徹底告別了這個時代?

無論是MMKV的支持者還是DataStore的擁躉,SharedPreferences似乎都不值一提;值得深思的是,筆者通過面試或者其它方式,和一些同行交流時,卻遇到了以下的情形:

在談及SharedPreferencesMMKV,大多數(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):建立基本結構

SharedPreferencesAndroid平臺上 輕量級的存儲類,用來保存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()時,才嘗試將mEditorMapmMap進行合并,以達到數(shù)據(jù)更新的目的。

因此,這里我們還需要另外一把鎖保證mEditorMap的線程安全,筆者認為,不和mMap公用同一把鎖的原因是,在apply()被調用之前,getXXXputXXX理應是沒有沖突的。

代碼實現(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()進行寫操作時,mEditorMapmMap進行合并,這時必須通過2把鎖保證mEditorMapmMap的線程安全,保證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其整體是比較完善的,但是為什么相比較MMKVJetpack DataStore,其性能依然有明顯的落差呢?

這個原因更加綜合且復雜,即使筆者也還是處于淺顯的了解層面,比如后兩者在其數(shù)據(jù)序列化方面都選用了更先進的protobuf協(xié)議,MMKV自身的數(shù)據(jù)的 增量更新 機制等等,有機會的話會另起新的一篇進行分享。

反過頭來,相對于對組件之間單純進行 不好 的定義,筆者更認為通過辯證的方式去看待和學習它們,相信即使是SharedPreferences,學習下來依然能夠有所收獲。

作者:卻把清梅嗅
鏈接:https://juejin.im/post/6884505736836022280

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,461評論 6 532
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,538評論 3 417
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,423評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,991評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,761評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,207評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,268評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,419評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,959評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,653評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,901評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,678評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,978評論 2 374