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 啟用事務(wù)源碼分析》
《SQLite 數(shù)據(jù)庫 WAL 模式工作原理簡介》
《SQLite 數(shù)據(jù)庫鎖機(jī)制與事務(wù)簡介》
《SQLite 數(shù)據(jù)庫優(yōu)化那些事兒》
在上篇《Android 之不要濫用 SharedPreferences》一文,詳細(xì)為大家分析了關(guān)于 SharedPreferences 存儲機(jī)制以及對它的不當(dāng)使用,可能引發(fā)的“嚴(yán)重后果”。本文也是建立在該基礎(chǔ)之上進(jìn)一步對 SharedPreferences 可能導(dǎo)致數(shù)據(jù)丟失場景進(jìn)行分析。如果你對 SharedPreferences 機(jī)制還不熟悉的話,可以先去參考下。
先來簡單回顧下:SharedPreferences 是 Android 中比較常用的存儲方法,它可以用來存儲一些比較小的鍵值對集合。雖然 SharedPreferences 使用非常簡便,但也是我們詬病比較多的存儲方法。它的性能問題比較多,我可以輕松說出它的“幾宗罪”。
跨進(jìn)程不安全。由于沒有使用跨進(jìn)程的鎖,就算使用 MODE_MULTI_PROCESS,SharedPreferences 在跨進(jìn)程頻繁讀寫有可能導(dǎo)致數(shù)據(jù)全部丟失。根據(jù)線上統(tǒng)計,SharedPreferences 大約會有萬分之一的損壞率。
加載緩慢。SharedPreferences 文件的加載使用了異步線程,而且加載線程并沒有設(shè)置優(yōu)先級,如果這個時候讀取數(shù)據(jù)就需要等待文件加載線程的結(jié)束。這就導(dǎo)致主線程等待低優(yōu)先線程鎖的問題,比如一個 100KB 的 SP 文件讀取等待時間大約需要 50 ~ 100ms,并且建議大家提前用預(yù)加載啟動過程用到的 SP 文件。
全量寫入。無論是 commit() 還是 apply(),即使我們只改動其中一個條目,都會把整個內(nèi)容全部寫到文件。而且即使我們多次寫同一個文件,SP 也沒有將多次修改合并為一次,這也是性能差的重要原因之一。
卡頓。由于提供了異步落盤的 apply 機(jī)制,在崩潰或者其它一些異常情況可能會導(dǎo)致數(shù)據(jù)丟失。所以當(dāng)應(yīng)用收到系統(tǒng)廣播,或者被調(diào)用 onPause 等一些時機(jī),系統(tǒng)會強(qiáng)制把所有的 SharedPreferences 對象的數(shù)據(jù)落地到磁盤。如果沒有落地完成,這時候主線程會被一直阻塞。這樣非常容易造成卡頓,甚至是ANR,從線上數(shù)據(jù)來看 SP 卡頓占比一般會超過 5%。
坦白來講,系統(tǒng)提供的 SharedPreferences 的應(yīng)用場景是用來存儲一些非常簡單、輕量的數(shù)據(jù)。我們不要使用它存儲過于復(fù)雜的數(shù)據(jù),例如 HTML、JSON 等。而且 SharedPreferences 的文件存儲性能與文件大小有關(guān),每個 SP 文件不能過大,我們不要將毫無關(guān)聯(lián)的配置項保存在同一個文件中,同時考慮將頻繁修改的條目單獨隔離出來。
數(shù)據(jù)丟失分析
SharedPrefenerces 提供了線程安全操作(內(nèi)部有大量Synchronized方法),但是并不能保證跨進(jìn)程數(shù)據(jù)的安全,也就是在跨進(jìn)程訪問時可能會導(dǎo)致文件損壞(但并不局限于多進(jìn)程場景)。
1、疑問:文件為什么會損壞?
為什么會文件損壞?在回答該問題之前先要明確一下什么是文件損壞?一個文件的格式或者內(nèi)容,如果沒有按照應(yīng)用程序?qū)懭氲慕Y(jié)果都屬于文件損壞。它不只是文件格式錯誤,文件內(nèi)容丟失可能才是最常出現(xiàn)的,SharedPreferences 跨進(jìn)程讀寫就非常容易出現(xiàn)數(shù)據(jù)丟失的情況。
我們可以從應(yīng)用程序、文件系統(tǒng)和磁盤三個角度來審視這個問題。
應(yīng)用程序。大部分的 I/O 方法都不是原子操作的,文件的跨進(jìn)程或者多線程寫入、使用一個已經(jīng)關(guān)閉的文件描述符 fd 來操作文件,它們都有可能導(dǎo)致數(shù)據(jù)被覆蓋或者刪除。事實上,大部分的文件損壞都是因為應(yīng)用程序代碼設(shè)計考慮不當(dāng)導(dǎo)致的,并不是文件系統(tǒng)或者磁盤的問題。
文件系統(tǒng)。雖說內(nèi)核崩潰或者系統(tǒng)突然斷電都有可能導(dǎo)致文件系統(tǒng)損壞,不過文件系統(tǒng)也做了很多的保護(hù)措施。例如 system 分區(qū)保證只讀不可寫,增加異常檢查和恢復(fù)機(jī)制等。
在文件系統(tǒng)這一層,更多是因為斷電而導(dǎo)致的寫入丟失。為了提升 I/O 性能,文件系統(tǒng)把數(shù)據(jù)寫入到 Page Cache 中,然后等待合適的時機(jī)才會真正的寫入磁盤。當(dāng)然我們也可以通過 fsync、msync 這些接口強(qiáng)制寫入磁盤。(SharedPreferences 在落盤時就使用了sync 機(jī)制)
- 磁盤。手機(jī)上使用的閃存是電子式的存儲設(shè)備,所以資料傳輸過程可能會發(fā)生電子遺失等現(xiàn)象導(dǎo)致數(shù)據(jù)錯誤。不過閃存也會使用 ECC、多級編碼等方式增加數(shù)據(jù)的可靠性,一般來說出現(xiàn)這種情況的可能性比較小。
接下來還是結(jié)合源碼的角度與大家一起重點探討下 SharedPreferences 的落盤機(jī)制:
2、SharedPreferences 的備份文件
再回顧下我們通過 Context.getSharedPreferences(name),得到的實際類型是:SharedPreferencesImpl。有關(guān) SharedPreferencesImpl 的機(jī)制在上篇文章中已經(jīng)詳細(xì)分析過。SharedPreferencesImpl 的構(gòu)造方法,如下圖:
注意源碼中 mBackupFile 變量,本文也是重點圍繞該變量進(jìn)行分析。
從這可以看出,mBackupFile 是原始文件的備份文件,如:.../config.xml.bak(config 為 SharedPreferences 的文件名)。
3、mBackupFile 備份文件的作用
無論我們使用 SharedPreferences 的 commit() 或 apply() 提交數(shù)據(jù),都會調(diào)用到 writeToFile 方法:
這里只給大家簡單貼下調(diào)用棧,commit 提交方法如下:
enqueueDiskWrite 方法如下:
可以看到 wirteToFile 方法的調(diào)用時機(jī),這也是我們要重點追蹤的方法。wirteToFile 方法的作用是將我們前面一系列的 putXxx 或 remove 后的數(shù)據(jù)落盤到存儲設(shè)備(在移動設(shè)備一般指的是 Flash 閃存)。
4、寫入文件分析
由于 writeToFile 方法內(nèi)容較多,我們分上下兩個部分分析:
省去部分日志代碼,代碼中也標(biāo)注了詳細(xì)的注釋:
首先如果源文件存在(SharedPreferences 文件,這里相對它的備份文件而言),判斷如果要寫入的數(shù)據(jù)是否真正發(fā)生變化,如果未發(fā)生變化則直接 return,這算是一層優(yōu)化,避免無謂的 I/O 操作。
注意判斷數(shù)據(jù)是否真正發(fā)生變化是在 EditorImpl 的 commmitToMemory() 方法中,在上篇文章中也有分析到:當(dāng)前一系列操作數(shù)據(jù)發(fā)生在 EditorImpl 的 mModified(Map)變量中,該方法會比較 mModified 與 SharedPreferencesImpl 中 mMap 后修正最后一次 mMap 中數(shù)據(jù),如果數(shù)據(jù)發(fā)生改變,如下圖:
繼續(xù)向下分析,mBackupFile.exists() 方法判斷當(dāng)前是否存在備份文件,如果不存在,則將原始文件重名為備份文件。此時如果存在該文件的備份文件,則直接將源文件丟棄:mFile.delete()。
writeToFile 方法的下半部分分析,如下圖:
由于代碼篇幅較長,省去部分。
創(chuàng)建 mFile 文件的輸出流,這里很明白是要寫入數(shù)據(jù)使用,系統(tǒng)將真正寫入數(shù)據(jù)的操作都封裝在 XmlUtils 中,然后強(qiáng)制 sync 落盤到閃存。
熟悉 I/O 的朋友都知道,我們應(yīng)用程序平時用到的 read/write 操作都屬于標(biāo)準(zhǔn) I/O,也就是緩存 I/O(Buffered I/O)。它的關(guān)鍵特性有:
(1)對于讀操作來說,當(dāng)應(yīng)用程序讀取某塊數(shù)據(jù)的時候,如果這塊數(shù)據(jù)已經(jīng)存放在頁緩沖中,那么這塊數(shù)據(jù)就可以立即返回給應(yīng)用程序,而不需要經(jīng)過實際的物理讀盤操作。
(2)對于寫操作來說,應(yīng)用程序也會將數(shù)據(jù)先寫到頁緩沖(Page Cache)中去,數(shù)據(jù)是否被立即寫到磁盤上去取決于應(yīng)用所采用寫操作的機(jī)制。默認(rèn)系統(tǒng)采用的是延遲寫機(jī)制,應(yīng)用程序只需要將數(shù)據(jù)寫到頁緩沖中去就可以了,完全不需要等數(shù)據(jù)全部被寫回到磁盤,系統(tǒng)會負(fù)責(zé)定期地將放在頁緩沖中的數(shù)據(jù)刷到磁盤上。
SharedPreferences 在寫入文件時采用強(qiáng)制落盤機(jī)制來保證數(shù)據(jù) “不丟失”:FileUtils.sync()。
如果上面步驟沒有發(fā)生任何異常,則刪除備份文件,還記得前面說過,在新的寫入文件之前,先將原始文件備份嗎?如下圖:
如果寫入過程未發(fā)生異常,則直接 return,表示本次寫入成功。如果寫入過程發(fā)生異常,則直接將源文件刪除:mFile.delete()。catch() 異常后的代碼調(diào)用,刪除源文件 mFile.delete(),如下圖:
此時不知道會不會有這樣一個疑問?數(shù)據(jù)都丟失了?
讓我們再來看下 ShardPreferencesImpl 構(gòu)造方法(源碼上圖已貼出)的最后 startLoadFromDisk() 方法,如下圖:(只貼出與 Backup 文件相關(guān))
檢查源文件的備份文件是否存在:mBackupFile.exists(),如果存在,則將源文件刪除:mFile.delete(),然后將備份文件修改為源文件:mBackupFile.renameTo(mFile)。后續(xù)操作就是從備份文件加載相關(guān)數(shù)據(jù)到內(nèi)存 mMap 容器中了。
小結(jié)
SharedPreferences 的寫入操作,首先是將源文件備份:mFile.renameTo(mBackupFile) 再寫入所有數(shù)據(jù),只有寫入成功,并且通過 sync 完成落盤后,才會將 Backup(.bak) 文件刪除。如果寫入過程中進(jìn)程被殺,或者關(guān)機(jī)等非正常情況發(fā)生。進(jìn)程再次啟動后如果發(fā)現(xiàn)該 SharedPreferences 存在 Backup 文件,就將 Backup 文件重名為源文件,原本未完成寫入的文件就直接丟棄,這樣最多也就是未完成寫入的數(shù)據(jù)丟失,它能保證最后一次落盤(真正落盤)成功后的數(shù)據(jù)。也正式這個 BackUp 機(jī)制,導(dǎo)致多進(jìn)程可能會丟失新寫入的數(shù)據(jù)。但也不是只有多進(jìn)程場景才會發(fā)生數(shù)據(jù)丟失的情況。
1、Context.MODE_MULTI_PROCESS 到底做了什么?
在《Android之不要濫用SharedPreferences》只是簡單給大家提到:使用 Context.MODE_MULTI_PROCESS 只是重新從文件加載了一遍 SharedPreferences 數(shù)據(jù),不要指望這貨能夠跨進(jìn)程通信。如下圖:
關(guān)于 SharedPreferences 的創(chuàng)建過程在上篇文章中已經(jīng)做過詳細(xì)介紹,不再贅述,這里主要關(guān)注紅線框中部分:startReloadIfChangedUnexpectedly 方法跟蹤:
hasFileChangedUnexpectedly 方法如果返回 false 直接 return。
否則 startLoadFromDisk(關(guān)于 startLoadFromDisk 方法的作用已經(jīng)多次說明過,不再贅述)。hasFileChangedUnexpectedly 方法如下圖:
SharedPreferences 中會記錄最后修改時間以及文件大小,當(dāng)使用 Context.MODE_MULTI_PROCESS 時,此時會通過 StructStat(Os.stat() 返回) 計算得到,然后與當(dāng)前最后同步時間和文件大小進(jìn)行比較,如果不匹配就會觸發(fā) startLoadFromDisk 方法執(zhí)行,既重新加載文件內(nèi)容到內(nèi)存 mMap 中。
2、SharedPreferences 的監(jiān)控
SharedPreferences 中為我們提供了 OnSharedPreferenceChangeListener 數(shù)據(jù)改變回調(diào):
需要注意 onSharedPreferenceChanged() 的回調(diào)時機(jī)在 commit() 和 apply() 有所區(qū)別:
(1)使用 commit() 提交時,onSharedPreferenceChanged() 回調(diào)時機(jī)是在數(shù)據(jù)落盤完成之后(不代表一定成功,有可能發(fā)生異常)
(2)使用 apply() 提交時,onSharedPreferenceChanged() 回調(diào)時機(jī)是在完成數(shù)據(jù)內(nèi)存替換之后,既 mModified 中數(shù)據(jù)提交到 mMap 完成之后(前者是對我們一系列putXxx() 或 remove() 做保存,后者是寫入文件時使用)。
(3)系統(tǒng)保存 OnSharedPreferenceChangeListener 對象在 WeakHashMap 中:
不熟悉 WeakHashMap 的機(jī)制可以去了解下,故如果在局部創(chuàng)建 OnSharedPreferenceChangeListener 對象,在方法體結(jié)束后生命周期即結(jié)束。
通過 OnSharedPreferenceChangeListener 回調(diào)我們可以監(jiān)控任意 SharedPreferences 提交的 key:value,比如較大的數(shù)據(jù)直接給出警告;也可以監(jiān)控單個 SharedPreferences 文件是否過大。
- SharedPrefenerces 的優(yōu)化
我們也可以替換通過復(fù)寫 Application 的 getSharedPreferences 方法替換系統(tǒng)默認(rèn)實現(xiàn),比如優(yōu)化卡頓、合并多次 apply 操作、支持跨進(jìn)程操作等。具體如何實現(xiàn)參考這里。
對系統(tǒng)提供的 SharedPreferences 的小修小補(bǔ)雖然性能有所提升,但是依然不能徹底解決問題。基本每個大公司都會自研一套替代的存儲方案,比如微信最近就開源了MMKV。
最后
SharedPreferences 是我們?nèi)粘=?jīng)常使用的存儲方法,但是里面的確會有大大小小的暗坑。所以我們需要充分了解它們的優(yōu)缺點,這樣在工作中可以更好地使用和優(yōu)化。
總的來說,我們需要結(jié)合應(yīng)用場景選擇合適的數(shù)據(jù)存儲方法。除了 SharedPreferences,Android 還為應(yīng)用開發(fā)者提供了其它存儲數(shù)據(jù)的方法。你可以參考 Android 存儲優(yōu)化系列專題中其他存儲方法分析。
文中分析如有不妥或更好的分析結(jié)果,還請大家指出!如果你喜歡我的文章,就請留個贊吧!
推薦閱讀
- Android 之不要濫用 SharedPreferences
- Android 官方存儲選項