
Android 存儲優(yōu)化系列專題
- SharedPreferences 系列
《Android 之不要濫用 SharedPreferences》
《Android 之不要濫用 SharedPreferences(2)— 數(shù)據(jù)丟失》
- ContentProvider 系列(待更)
《Android 存儲選項之 ContentProvider 啟動過程源碼分析》
《Android 存儲選項之 ContentProvider 深入分析》
- 對象序列化系列
《Android 對象序列化之你不知道的 Serializable》
《Android 對象序列化之 Parcelable 深入分析》
《Android 對象序列化之追求完美的 Serial》
- 數(shù)據(jù)序列化系列(待更)
《Android 數(shù)據(jù)序列化之 JSON》
《Android 數(shù)據(jù)序列化之 Protocol Buffer 使用》
《Android 數(shù)據(jù)序列化之 Protocol Buffer 源碼分析》
- SQLite 存儲系列
《Android 存儲選項之 SQLiteDatabase 創(chuàng)建過程源碼分析》
《Android 存儲選項之 SQLiteDatabase 源碼分析》
《數(shù)據(jù)庫連接池 SQLiteConnectionPool 源碼分析》
《SQLiteDatabase 啟用事務源碼分析》
《SQLite 數(shù)據(jù)庫 WAL 模式工作原理簡介》
《SQLite 數(shù)據(jù)庫鎖機制與事務簡介》
《SQLite 數(shù)據(jù)庫優(yōu)化那些事兒》
在上篇《Android 之不要濫用 SharedPreferences》一文,詳細為大家分析了關于 SharedPreferences 存儲機制以及對它的不當使用,可能引發(fā)的“嚴重后果”。本文也是建立在該基礎之上進一步對 SharedPreferences 可能導致數(shù)據(jù)丟失場景進行分析。如果你對 SharedPreferences 機制還不熟悉的話,可以先去參考下。
先來簡單回顧下:SharedPreferences 是 Android 中比較常用的存儲方法,它可以用來存儲一些比較小的鍵值對集合。雖然 SharedPreferences 使用非常簡便,但也是我們詬病比較多的存儲方法。它的性能問題比較多,我可以輕松說出它的“幾宗罪”。
跨進程不安全。由于沒有使用跨進程的鎖,就算使用 MODE_MULTI_PROCESS,SharedPreferences 在跨進程頻繁讀寫有可能導致數(shù)據(jù)全部丟失。根據(jù)線上統(tǒng)計,SharedPreferences 大約會有萬分之一的損壞率。
加載緩慢。SharedPreferences 文件的加載使用了異步線程,而且加載線程并沒有設置優(yōu)先級,如果這個時候讀取數(shù)據(jù)就需要等待文件加載線程的結(jié)束。這就導致主線程等待低優(yōu)先線程鎖的問題,比如一個 100KB 的 SP 文件讀取等待時間大約需要 50 ~ 100ms,并且建議大家提前用預加載啟動過程用到的 SP 文件。
全量寫入。無論是 commit() 還是 apply(),即使我們只改動其中一個條目,都會把整個內(nèi)容全部寫到文件。而且即使我們多次寫同一個文件,SP 也沒有將多次修改合并為一次,這也是性能差的重要原因之一。
卡頓。由于提供了異步落盤的 apply 機制,在崩潰或者其它一些異常情況可能會導致數(shù)據(jù)丟失。所以當應用收到系統(tǒng)廣播,或者被調(diào)用 onPause 等一些時機,系統(tǒng)會強制把所有的 SharedPreferences 對象的數(shù)據(jù)落地到磁盤。如果沒有落地完成,這時候主線程會被一直阻塞。這樣非常容易造成卡頓,甚至是ANR,從線上數(shù)據(jù)來看 SP 卡頓占比一般會超過 5%。
坦白來講,系統(tǒng)提供的 SharedPreferences 的應用場景是用來存儲一些非常簡單、輕量的數(shù)據(jù)。我們不要使用它存儲過于復雜的數(shù)據(jù),例如 HTML、JSON 等。而且 SharedPreferences 的文件存儲性能與文件大小有關,每個 SP 文件不能過大,我們不要將毫無關聯(lián)的配置項保存在同一個文件中,同時考慮將頻繁修改的條目單獨隔離出來。
數(shù)據(jù)丟失分析
SharedPrefenerces 提供了線程安全操作(內(nèi)部有大量Synchronized方法),但是并不能保證跨進程數(shù)據(jù)的安全,也就是在跨進程訪問時可能會導致文件損壞(但并不局限于多進程場景)。
1、疑問:文件為什么會損壞?
為什么會文件損壞?在回答該問題之前先要明確一下什么是文件損壞?一個文件的格式或者內(nèi)容,如果沒有按照應用程序?qū)懭氲慕Y(jié)果都屬于文件損壞。它不只是文件格式錯誤,文件內(nèi)容丟失可能才是最常出現(xiàn)的,SharedPreferences 跨進程讀寫就非常容易出現(xiàn)數(shù)據(jù)丟失的情況。
我們可以從應用程序、文件系統(tǒng)和磁盤三個角度來審視這個問題。
應用程序。大部分的 I/O 方法都不是原子操作的,文件的跨進程或者多線程寫入、使用一個已經(jīng)關閉的文件描述符 fd 來操作文件,它們都有可能導致數(shù)據(jù)被覆蓋或者刪除。事實上,大部分的文件損壞都是因為應用程序代碼設計考慮不當導致的,并不是文件系統(tǒng)或者磁盤的問題。
文件系統(tǒng)。雖說內(nèi)核崩潰或者系統(tǒng)突然斷電都有可能導致文件系統(tǒng)損壞,不過文件系統(tǒng)也做了很多的保護措施。例如 system 分區(qū)保證只讀不可寫,增加異常檢查和恢復機制等。
在文件系統(tǒng)這一層,更多是因為斷電而導致的寫入丟失。為了提升 I/O 性能,文件系統(tǒng)把數(shù)據(jù)寫入到 Page Cache 中,然后等待合適的時機才會真正的寫入磁盤。當然我們也可以通過 fsync、msync 這些接口強制寫入磁盤。(SharedPreferences 在落盤時就使用了sync 機制)
- 磁盤。手機上使用的閃存是電子式的存儲設備,所以資料傳輸過程可能會發(fā)生電子遺失等現(xiàn)象導致數(shù)據(jù)錯誤。不過閃存也會使用 ECC、多級編碼等方式增加數(shù)據(jù)的可靠性,一般來說出現(xiàn)這種情況的可能性比較小。
接下來還是結(jié)合源碼的角度與大家一起重點探討下 SharedPreferences 的落盤機制:
2、SharedPreferences 的備份文件
再回顧下我們通過 Context.getSharedPreferences(name),得到的實際類型是:SharedPreferencesImpl。有關 SharedPreferencesImpl 的機制在上篇文章中已經(jīng)詳細分析過。SharedPreferencesImpl 的構(gòu)造方法,如下圖:

注意源碼中 mBackupFile 變量,本文也是重點圍繞該變量進行分析。

從這可以看出,mBackupFile 是原始文件的備份文件,如:.../config.xml.bak(config 為 SharedPreferences 的文件名)。
3、mBackupFile 備份文件的作用
無論我們使用 SharedPreferences 的 commit() 或 apply() 提交數(shù)據(jù),都會調(diào)用到 writeToFile 方法:

這里只給大家簡單貼下調(diào)用棧,commit 提交方法如下:

enqueueDiskWrite 方法如下:

可以看到 wirteToFile 方法的調(diào)用時機,這也是我們要重點追蹤的方法。wirteToFile 方法的作用是將我們前面一系列的 putXxx 或 remove 后的數(shù)據(jù)落盤到存儲設備(在移動設備一般指的是 Flash 閃存)。
4、寫入文件分析
由于 writeToFile 方法內(nèi)容較多,我們分上下兩個部分分析:

省去部分日志代碼,代碼中也標注了詳細的注釋:
首先如果源文件存在(SharedPreferences 文件,這里相對它的備份文件而言),判斷如果要寫入的數(shù)據(jù)是否真正發(fā)生變化,如果未發(fā)生變化則直接 return,這算是一層優(yōu)化,避免無謂的 I/O 操作。
注意判斷數(shù)據(jù)是否真正發(fā)生變化是在 EditorImpl 的 commmitToMemory() 方法中,在上篇文章中也有分析到:當前一系列操作數(shù)據(jù)發(fā)生在 EditorImpl 的 mModified(Map)變量中,該方法會比較 mModified 與 SharedPreferencesImpl 中 mMap 后修正最后一次 mMap 中數(shù)據(jù),如果數(shù)據(jù)發(fā)生改變,如下圖:

繼續(xù)向下分析,mBackupFile.exists() 方法判斷當前是否存在備份文件,如果不存在,則將原始文件重名為備份文件。此時如果存在該文件的備份文件,則直接將源文件丟棄:mFile.delete()。
writeToFile 方法的下半部分分析,如下圖:

由于代碼篇幅較長,省去部分。
創(chuàng)建 mFile 文件的輸出流,這里很明白是要寫入數(shù)據(jù)使用,系統(tǒng)將真正寫入數(shù)據(jù)的操作都封裝在 XmlUtils 中,然后強制 sync 落盤到閃存。

熟悉 I/O 的朋友都知道,我們應用程序平時用到的 read/write 操作都屬于標準 I/O,也就是緩存 I/O(Buffered I/O)。它的關鍵特性有:
(1)對于讀操作來說,當應用程序讀取某塊數(shù)據(jù)的時候,如果這塊數(shù)據(jù)已經(jīng)存放在頁緩沖中,那么這塊數(shù)據(jù)就可以立即返回給應用程序,而不需要經(jīng)過實際的物理讀盤操作。
(2)對于寫操作來說,應用程序也會將數(shù)據(jù)先寫到頁緩沖(Page Cache)中去,數(shù)據(jù)是否被立即寫到磁盤上去取決于應用所采用寫操作的機制。默認系統(tǒng)采用的是延遲寫機制,應用程序只需要將數(shù)據(jù)寫到頁緩沖中去就可以了,完全不需要等數(shù)據(jù)全部被寫回到磁盤,系統(tǒng)會負責定期地將放在頁緩沖中的數(shù)據(jù)刷到磁盤上。
SharedPreferences 在寫入文件時采用強制落盤機制來保證數(shù)據(jù) “不丟失”:FileUtils.sync()。
如果上面步驟沒有發(fā)生任何異常,則刪除備份文件,還記得前面說過,在新的寫入文件之前,先將原始文件備份嗎?如下圖:

如果寫入過程未發(fā)生異常,則直接 return,表示本次寫入成功。如果寫入過程發(fā)生異常,則直接將源文件刪除:mFile.delete()。catch() 異常后的代碼調(diào)用,刪除源文件 mFile.delete(),如下圖:

此時不知道會不會有這樣一個疑問?數(shù)據(jù)都丟失了?
讓我們再來看下 ShardPreferencesImpl 構(gòu)造方法(源碼上圖已貼出)的最后 startLoadFromDisk() 方法,如下圖:(只貼出與 Backup 文件相關)

檢查源文件的備份文件是否存在:mBackupFile.exists(),如果存在,則將源文件刪除:mFile.delete(),然后將備份文件修改為源文件:mBackupFile.renameTo(mFile)。后續(xù)操作就是從備份文件加載相關數(shù)據(jù)到內(nèi)存 mMap 容器中了。
小結(jié)
SharedPreferences 的寫入操作,首先是將源文件備份:mFile.renameTo(mBackupFile) 再寫入所有數(shù)據(jù),只有寫入成功,并且通過 sync 完成落盤后,才會將 Backup(.bak) 文件刪除。如果寫入過程中進程被殺,或者關機等非正常情況發(fā)生。進程再次啟動后如果發(fā)現(xiàn)該 SharedPreferences 存在 Backup 文件,就將 Backup 文件重名為源文件,原本未完成寫入的文件就直接丟棄,這樣最多也就是未完成寫入的數(shù)據(jù)丟失,它能保證最后一次落盤(真正落盤)成功后的數(shù)據(jù)。也正式這個 BackUp 機制,導致多進程可能會丟失新寫入的數(shù)據(jù)。但也不是只有多進程場景才會發(fā)生數(shù)據(jù)丟失的情況。
1、Context.MODE_MULTI_PROCESS 到底做了什么?
在《Android之不要濫用SharedPreferences》只是簡單給大家提到:使用 Context.MODE_MULTI_PROCESS 只是重新從文件加載了一遍 SharedPreferences 數(shù)據(jù),不要指望這貨能夠跨進程通信。如下圖:

關于 SharedPreferences 的創(chuàng)建過程在上篇文章中已經(jīng)做過詳細介紹,不再贅述,這里主要關注紅線框中部分:startReloadIfChangedUnexpectedly 方法跟蹤:

hasFileChangedUnexpectedly 方法如果返回 false 直接 return。
否則 startLoadFromDisk(關于 startLoadFromDisk 方法的作用已經(jīng)多次說明過,不再贅述)。hasFileChangedUnexpectedly 方法如下圖:

SharedPreferences 中會記錄最后修改時間以及文件大小,當使用 Context.MODE_MULTI_PROCESS 時,此時會通過 StructStat(Os.stat() 返回) 計算得到,然后與當前最后同步時間和文件大小進行比較,如果不匹配就會觸發(fā) startLoadFromDisk 方法執(zhí)行,既重新加載文件內(nèi)容到內(nèi)存 mMap 中。
2、SharedPreferences 的監(jiān)控
SharedPreferences 中為我們提供了 OnSharedPreferenceChangeListener 數(shù)據(jù)改變回調(diào):

需要注意 onSharedPreferenceChanged() 的回調(diào)時機在 commit() 和 apply() 有所區(qū)別:
(1)使用 commit() 提交時,onSharedPreferenceChanged() 回調(diào)時機是在數(shù)據(jù)落盤完成之后(不代表一定成功,有可能發(fā)生異常)
(2)使用 apply() 提交時,onSharedPreferenceChanged() 回調(diào)時機是在完成數(shù)據(jù)內(nèi)存替換之后,既 mModified 中數(shù)據(jù)提交到 mMap 完成之后(前者是對我們一系列putXxx() 或 remove() 做保存,后者是寫入文件時使用)。
(3)系統(tǒng)保存 OnSharedPreferenceChangeListener 對象在 WeakHashMap 中:

不熟悉 WeakHashMap 的機制可以去了解下,故如果在局部創(chuàng)建 OnSharedPreferenceChangeListener 對象,在方法體結(jié)束后生命周期即結(jié)束。
通過 OnSharedPreferenceChangeListener 回調(diào)我們可以監(jiān)控任意 SharedPreferences 提交的 key:value,比如較大的數(shù)據(jù)直接給出警告;也可以監(jiān)控單個 SharedPreferences 文件是否過大。
- SharedPrefenerces 的優(yōu)化
我們也可以替換通過復寫 Application 的 getSharedPreferences 方法替換系統(tǒng)默認實現(xiàn),比如優(yōu)化卡頓、合并多次 apply 操作、支持跨進程操作等。具體如何實現(xiàn)參考這里。

對系統(tǒng)提供的 SharedPreferences 的小修小補雖然性能有所提升,但是依然不能徹底解決問題?;久總€大公司都會自研一套替代的存儲方案,比如微信最近就開源了MMKV。
最后
SharedPreferences 是我們?nèi)粘=?jīng)常使用的存儲方法,但是里面的確會有大大小小的暗坑。所以我們需要充分了解它們的優(yōu)缺點,這樣在工作中可以更好地使用和優(yōu)化。
總的來說,我們需要結(jié)合應用場景選擇合適的數(shù)據(jù)存儲方法。除了 SharedPreferences,Android 還為應用開發(fā)者提供了其它存儲數(shù)據(jù)的方法。你可以參考 Android 存儲優(yōu)化系列專題中其他存儲方法分析。
文中分析如有不妥或更好的分析結(jié)果,還請大家指出!如果你喜歡我的文章,就請留個贊吧!
推薦閱讀
- Android 之不要濫用 SharedPreferences
- Android 官方存儲選項