再見 MMKV,自己擼一個FastKV,快的一批
BATcoder技術 群,讓一部分人先進大廠
大家好,我是劉望舒,騰訊最具價值專家,著有三本業內知名暢銷書,連續五年蟬聯電子工業出版社年度優秀作者, 百度百科收錄的資深技術專家。
前華為面試官、獨角獸公司技術總監。
想要 加入 BATcoder技術群,公號回覆 BAT
即可。
作者:呼嘯長風
http://juejin.cn/post/7018522454171582500
1 前言
KV儲存無論對於客戶端還是服務端都是重要的構件。
對於Android客戶端而言,最常見的莫過於SDK提供的SharePreferences(以下簡稱SP),但其低效率和ANR問題飽受詬病。
18年年末微信開源了MMKV,寫入速度比前者高不少。
後來官方又推出了基於Kotlin的DataStore,測試下來,發現寫入效率很低。
我之前寫過一個叫LightKV的儲存元件,不過當時認知不足,設計不夠成熟。
1.1 SP的不足
關於SP的缺點網上有不少討論,這裡主要提兩個點:
-
儲存速度較慢
SP用記憶體層用HashMap儲存,磁碟層則是用的XML檔案儲存。
每次更改,都需要將整個HashMap序列化為XML格式的報文然後整個寫入檔案。
歸結其較慢的原因:
1、不能增量寫入;
2、序列化比較耗時。
-
可以能會導致ANR
public void apply() { // ...省略無關程式碼... QueuedWork.addFinisher(awaitCommit); Runnable postWriteRunnable = new Runnable() { @Override public void run() { awaitCommit.run(); QueuedWork.removeFinisher(awaitCommit); } }; SharedPreferencesImpl.this.enqueueDiskWrite(mcr, postWriteRunnable); }
public void handleStopActivity(IBinder token, boolean show, int configChanges, PendingTransactionActions pendingActions, boolean finalStateRequest, String reason) { // ...省略無關程式碼... // Make sure any pending writes are now committed. if (!r.isPreHoneycomb()) { QueuedWork.waitToFinish(); } }
Activity stop時會等待SP的寫入任務,如果SP的寫入任務多且執行慢的話,可能會阻塞主執行緒較長時間,輕則卡頓,重則ANR。
1.2 MMKV的不足
沒有型別資訊,不支援getAll:
MMKV的儲存用類似於Protobuf的編碼方式,只儲存key和value本身,沒有存型別資訊(Protobuf用tag標記欄位,資訊更少)。
由於沒有記錄型別資訊,MMKV無法自動反序列化,也就無法實現getAll介面。
讀取相對較慢:
SP在載入的時候已經將value反序列化存在HashMap中了,讀取的時候索引到之後就能直接引用了。
而MMKV每次讀取時都需要重新解碼,除了時間上的消耗之外,還需要每次都建立新的物件。
不過這不是大問題,相對SP沒有差很多。
需要引入so, 增加包體積:
引入MMKV需要增加的體積還是不少的,且不說jar包和aidl檔案,光是一個arm64-v8a的so就有四百多K。
雖然說現在APP體積都不小,但畢竟增加體積對打包、分發和安裝時間都多少有些影響。
檔案只增不減:
MMKV的擴容策略還是比較激進的,而且擴容之後不會主動trim size。
比方說,假如有一個大value,讓其擴容至1M,後面刪除該value,哪怕有效內容只剩幾K,檔案大小還是保持在1M。
可能會丟失資料:
前面的問題總的來說都不是什麼“要緊”的問題,但是這個丟失資料確實是硬傷。
MMKV官方有這麼一段表述:
通過 mmap 記憶體對映檔案,提供一段可供隨時寫入的記憶體塊,App 只管往裡面寫資料,由作業系統負責將記憶體回寫到檔案,不必擔心 crash 導致資料丟失。
這個表述對一半不對一半。
如果資料完成寫入到記憶體塊,如果系統不崩潰,即使程序崩潰,系統也會將buffer刷入磁碟。
但是如果在刷入磁碟之前發生系統崩潰或者斷電等,資料就丟失了,不過這種情況發生的概率不大。
另一種情況是資料寫一半的時候程序崩潰或者被殺死,然後系統會將已寫入的部分刷入磁碟,再次開啟時檔案可能就不完整了。
例如,MMKV在剩餘空間不足時會回收無效的空間,如果這期間程序中斷,資料可能會不完整。MMKV官方的說明可以佐證:
CRC校驗失敗之後,MMKV有兩種應對策略:直接丟棄所有資料,或者嘗試讀取資料(使用者可以在初始化時設定)。
嘗試讀取資料不一定能恢復資料,甚至可能會讀到一些錯誤的資料,得看運氣。
這個過程是比較容易復現的,下面是其中一種復現路徑:
1、新增和刪除若干key-value 得到資料如下:
2、插入一個大字串,觸發擴容,擴容前會觸發垃圾回收。
3、斷點打在執行 memmove 的迴圈中,執行一部分 memmove , 然後在手機上殺死程序。
4、再次開啟APP,資料丟失。
相比之下,SP雖然低效,但至少不會丟失資料。
2.FastKV
在總結了之前的經驗和感悟之後,筆者實現了一個高效且可靠的版本,且將其命名為: FastKV。
2.1 特性
FastKV有以下特性:
1、讀寫速度快
FastKV採用二進位制編碼,編碼後的體積相對XML等文字編碼要小很多。
增量編碼:FastKV記錄了各個key-value相對檔案的偏移量,更新資料時,可以直接在對應的位置寫入資料。
預設用mmap的方式記錄資料,更新資料時直接寫入到記憶體即可,沒有IO阻塞。
2、支援多種寫入模式
除了mmap這種非阻塞的寫入方式,FastKV也支援常規的阻塞式寫入方式, 並且支援同步阻塞和非同步阻塞(分別類似於 SharePreferences 的 commit 和 apply )。
3、支援多種型別
支援常用的 boolean/int/float/long/double/String 等基礎型別。
支援 ByteArray ( byte[] )。
支援儲存自定義物件。
內建StringSet編碼器 (為了相容 SharePreferences )。
4、方便易用
FastKV提供了了豐富的API介面,開箱即用。
提供的介面其中包括 getAll() 和 putAll() 方法, 所以遷移 SharePreferences 等框架的資料到FastKV很方便,當然,遷移FastKV的資料到其他框架也很方便。
5、穩定可靠
通過 double-write 等方法確保資料的完整性。
在API拋IO異常時提供降級處理。
6、程式碼精簡
FastKV由純Java實現,編譯成jar包後體積僅30多K。
2.2 實現原理
2.2.1 編碼
檔案的佈局:
[data_len | checksum | key-value | key-value|....]
data_len : 佔4位元組, 記錄所有key-value所佔位元組數。
checksum: 佔8位元組,記錄key-value部分的checksum。
key-value的資料佈局:
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | delete_flag | external_flag | type | key_len | key_content | value | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | 1bit | 1bit | 6bits | 1 byte | | |
delete_flag :標記當前key-value是否刪除。
external_flag : 標記value部分是否寫到額外的檔案。
注:對於資料量比較大的value,放在主檔案會影響其他key-value的訪問效能,因此,單獨用一個檔案來儲存該value, 並在主檔案中記錄其檔名。
type : value型別,目前支援 boolean/int/float/long/double/String/ByteArray 以及自定義物件。
key_len : 記錄key的長度,key_len本身佔1位元組,所以支援key的最大長度為255。
key_content : key的內容本身,utf8編碼。
value : 基礎型別的value, 直接編碼(little-end);
其他型別,先記錄長度(用varint編碼),再記錄內容。
String採用UTF-8編碼,ByteArray無需編碼,自定義物件實現Encoder介面,分別在Encoder的 encode/decode 方法中序列化和反序列化。
2.2.2 儲存
1、mmap
為了提高寫入效能,FastKV預設採用 mmap 的方式寫入。
2、降級
當mmap API發生IO異常時,降級到常規的blocking I/O,同時為了不影響當前執行緒,會將寫入放到非同步執行緒中執行。
3、資料完整性
如果在寫入一部分的過程中發生中斷(程序或系統),則檔案可能會不完整。
故此,需要用一些方法確保資料的完整性。
當用mmap的方式開啟時,FastKV採用 double-write 的方式:資料依次寫入A/B兩個檔案,確保任何時刻總有一個檔案完整的;
載入資料時,通過checksum、標記、資料合法性檢驗等方法驗證資料的正確性。
double-write 可以防止程序崩潰後資料不完整,但由於 mmap 是系統定時刷盤,若在刷盤前系統崩潰或者斷電,仍會丟失未落盤的更新(之前的資料還在);對於非常重要的key-value,在寫入後,可接著呼叫 force() 強制將髒頁刷盤。
4、更新策略(增/刪/改)
新增:寫入到資料的尾部。
刪除: delete_flag 設定為1。
修改:如果value部分的長度和原來一樣,則直接寫入原來的位置;否則,先寫入key-value到資料尾部,再標記原來位置的 delete_flag 為1(刪除),最後再更新檔案的data_len和checksum。
5、gc/truncate
刪除key-value時會收集資訊(統計刪除的個數,以及所在位置,佔用空間等)。
GC的觸發點有兩個:
1、新增key-value時剩餘空間不足,且已刪除的空間達到閾值,且騰出刪除空間後足夠寫入當前key-value, 則觸發GC。
2、刪除key-value時,如果刪除空間達到閾值,或者刪除的key-value個數達到閾值,則觸發GC。
GC後如果不用的空間達到設定閾值,則觸發truncate(縮小檔案大小)。
2.3 使用方法
2.3.1 匯入
dependencies { implementation 'io.github.billywei01:fastkv:1.0.2' }
2.3.2 初始化
FastKVConfig.setLogger(FastKVLogger) FastKVConfig.setExecutor(ChannelExecutorService(4))
初始化可以按需設定日誌回撥和Executor。
建議傳入自己的執行緒池,以複用執行緒。
日誌介面提供三個級別的回撥,按需實現即可。
public interface Logger { void i(String name, String message); void w(String name, Exception e); void e(String name, Exception e); }
2.3.3 資料讀寫
基本用法
FastKV kv = new FastKV.Builder(path, name).build(); if(!kv.getBoolean("flag")){ kv.putBoolean("flag" , true); }
儲存自定義物件
FastKV.Encoder<?>[] encoders = new FastKV.Encoder[]{LongListEncoder.INSTANCE}; FastKV kv = new FastKV.Builder(path, name).encoder(encoders).build(); String objectKey = "long_list"; List<Long> list = new ArrayList<>(); list.add(100L); list.add(200L); list.add(300L); kv.putObject(objectKey, list, LongListEncoder.INSTANCE); List<Long> list2 = kv.getObject("long_list");
FastKV支援儲存自定義物件,為了載入檔案時能自動反序列化,需在構建FastKV例項時傳入物件的編碼器。
編碼器為實現 FastKV.Encoder 的物件。
比如上面的 LongListEncoder 的實現如下:
public class LongListEncoder implements FastKV.Encoder<List<Long>> { public static final LongListEncoder INSTANCE = new LongListEncoder(); @Override public String tag() { return "LongList"; } @Override public byte[] encode(List<Long> obj) { return new PackEncoder().putLongList(0, obj).getBytes(); } @Override public List<Long> decode(byte[] bytes, int offset, int length) { PackDecoder decoder = PackDecoder.newInstance(bytes, offset, length); List<Long> list = decoder.getLongList(0); decoder.recycle(); return (list != null) ? list : new ArrayList<>(); } }
編碼物件涉及序列化/反序列化。
這裡推薦筆者的 另外一個框架 :
http://github.com/BillyWei01/Packable
2.3.4 For Android
Android平臺上的用法和常規用法一致,不過Android平臺多了 SharePreferences API,以及支援Kotlin。
FastKV的API相容 SharePreferences , 可以很輕鬆地遷移 SharePreferences 的資料到FastKV。
相關用法可 參考 :
http://github.com/BillyWei01/FastKV/blob/main/android_case_CN.md
3.效能測試
測試資料 :蒐集APP中的 SharePreferenses 彙總的部分key-value資料(經過隨機混淆)得到總共四百多個key-value。由於日常使用過程中部分key-value訪問多,部分訪問少,所以構造了一個正態分佈的訪問序列。
比較物件 : SharePreferences/DataStore/MMKV
測試機型 :榮耀20S
測試結果:
寫入(ms) | 讀取(ms) | |
---|---|---|
SharePreferences | 1258 | 3 |
DataStore | 16650 | 3 |
MMKV | 25 | 9 |
FastKV | 16 | 1 |
1、 SharePreferences 提交用的是apply, 耗時依然不少。
2、 DataStore 的寫入很慢。
3、MMKV的讀取比 SharePreferences/DataStore 要慢一些,寫入則比之快許多。
4、FastKV無論讀取還是寫入都比其他方式要快。
結語
本文探討了當下Android平臺的各類KV儲存方式,提出並實現了一種新的儲存元件,著重解決了KV儲存的效率和資料可靠性問題。
目前程式碼已上傳Github, 地址 :
http://github.com/BillyWei01/FastKV
- 厲害了!自己寫個App 啟動任務框架
- 一個解決滑動衝突的新思路,做到檢視之間無縫地巢狀滑動!
- 谷歌官方改了兩次的知識點,你一定要知道!
- Android 最新架構詳解 | MVI = 響應式程式設計 單向資料流 唯一可信資料來源 !
- 說兩件事~
- 最新的動畫布局來了,一文帶你瞭解!
- Gradle:你必須掌握的開發常見技巧~
- Kotlin DSL 實戰:像 Compose 一樣寫程式碼!
- 厲害了,Android自定義樹狀圖控制元件來了!
- 一文帶你全面掌握Android元件化核心!
- 為什麼大廠開始全面轉向Compose?
- 谷歌限制俄羅斯使用Android系統,俄或將轉用 HarmonyOS!
- 鴻蒙OS、安卓、iOS測試對比,結果出乎意料!
- 最詳細的Android圖片壓縮攻略,讓你一次過足癮(建議收藏)
- Android字型漸變效果實戰!
- 攔截控制元件點選 - 巧用ASM處理防抖!
- Android正確的保活方案,拒絕陷入需求死迴圈!
- 再見 MMKV,自己擼一個FastKV,快的一批
- 白嫖一個Android專案的類圖生成工具!(建議收藏)
- 日常需求做的挺好,面試就被底層原理放倒