一嘹裂、前言
KV存儲(chǔ)無(wú)論對(duì)于客戶(hù)端還是服務(wù)端都是重要的構(gòu)件妄壶。
對(duì)于Android客戶(hù)端而言,最常見(jiàn)的莫過(guò)于SDK提供的SharePreferences(以下簡(jiǎn)稱(chēng)SP)寄狼,但其低效率和ANR問(wèn)題飽受詬病丁寄。
官方后來(lái)又推出了基于Kotlin的DataStore泊愧,不過(guò)測(cè)試下來(lái)發(fā)現(xiàn)寫(xiě)入效率很低。
微信開(kāi)源了MMKV删咱,寫(xiě)入速度比前者高不少,但是讀取相對(duì)較慢摘能,同時(shí)也存在其他一些缺點(diǎn)续崖。
1.1 SP的不足
關(guān)于SP的缺點(diǎn)網(wǎng)上有不少討論袜刷,這里主要提兩個(gè)點(diǎn):
保存速度較慢
SP用內(nèi)存層用HashMap保存莺丑,磁盤(pán)層則是用的XML文件保存。
每次更改梢莽,都需要將整個(gè)HashMap序列化為XML格式的報(bào)文然后整個(gè)寫(xiě)入文件萧豆。
歸結(jié)其較慢的原因:
1、不能增量寫(xiě)入昏名;
2涮雷、序列化比較耗時(shí)。可以能會(huì)導(dǎo)致ANR
public void apply() {
// ...省略無(wú)關(guān)代碼...
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) {
// ...省略無(wú)關(guān)代碼...
// Make sure any pending writes are now committed.
if (!r.isPreHoneycomb()) {
QueuedWork.waitToFinish();
}
}
Activity stop時(shí)會(huì)等待SP的寫(xiě)入任務(wù)轻局,如果SP的寫(xiě)入任務(wù)多且執(zhí)行慢的話(huà)洪鸭,可能會(huì)阻塞主線(xiàn)程較長(zhǎng)時(shí)間,輕則卡頓仑扑,重則ANR览爵。
1.2 MMKV的不足
沒(méi)有類(lèi)型信息,不支持getAll
MMKV的存儲(chǔ)用類(lèi)似于Protobuf的編碼方式镇饮,只存儲(chǔ)key和value本身蜓竹,沒(méi)有存類(lèi)型信息(Protobuf用tag標(biāo)記字段,信息更少)储藐。
由于沒(méi)有記錄類(lèi)型信息俱济,MMKV無(wú)法自動(dòng)反序列化,也就無(wú)法實(shí)現(xiàn)getAll接口钙勃。讀取相對(duì)較慢
SP在加載的時(shí)候已經(jīng)將value反序列化存在HashMap中了蛛碌,讀取的時(shí)候索引到之后就能直接引用了。
而MMKV每次讀取時(shí)都需要重新解碼辖源,除了時(shí)間上的消耗之外蔚携,還需要每次都創(chuàng)建新的對(duì)象。
不過(guò)這不是大問(wèn)題同木,相對(duì)SP沒(méi)有差很多浮梢。-
需要引入so, 增加包體積
引入MMKV需要增加的體積還是不少的跛十,且不說(shuō)jar包和aidl文件彤路,光是一個(gè)arm64-v8a的so就有四百多K。
雖然說(shuō)現(xiàn)在APP體積都不小洲尊,但畢竟增加體積對(duì)打包坞嘀、分發(fā)和安裝時(shí)間都多少有些影響丽涩。
文件只增不減
MMKV的擴(kuò)容策略還是比較激進(jìn)的继准,而且擴(kuò)容之后不會(huì)主動(dòng)trim size移必。
比方說(shuō)崔泵,假如有一個(gè)大value憎瘸,讓其擴(kuò)容至1M含思,后面刪除該value含潘,后面即使觸發(fā)GC遏弱,哪怕有效內(nèi)容有幾K漱逸,文件大小還是保持在1M饰抒。-
可能會(huì)丟失數(shù)據(jù)
前面的問(wèn)題總的來(lái)說(shuō)都不是什么“要緊”的問(wèn)題袋坑,但是這個(gè)丟失數(shù)據(jù)確實(shí)是硬傷婆誓。
MMKV官方有這么一段表述:通過(guò) mmap 內(nèi)存映射文件洋幻,提供一段可供隨時(shí)寫(xiě)入的內(nèi)存塊文留,App 只管往里面寫(xiě)數(shù)據(jù)厂庇,由操作系統(tǒng)負(fù)責(zé)將內(nèi)存回寫(xiě)到文件权旷,不必?fù)?dān)心 crash 導(dǎo)致數(shù)據(jù)丟失拄氯。
這個(gè)表述對(duì)一半不對(duì)一半译柏。
如果數(shù)據(jù)完成寫(xiě)入到內(nèi)存塊鄙麦,如果系統(tǒng)不崩潰,即使進(jìn)程崩潰恨胚,系統(tǒng)也會(huì)將buffer刷入磁盤(pán)寒波;
但是如果在刷入磁盤(pán)之前發(fā)生系統(tǒng)崩潰或者斷電等俄烁,數(shù)據(jù)就丟失了页屠,不過(guò)這種情況發(fā)生的概率不大矛双;
另一種情況是數(shù)據(jù)寫(xiě)一半的時(shí)候進(jìn)程崩潰或者被殺死蟆豫,然后系統(tǒng)會(huì)將已寫(xiě)入的部分刷入磁盤(pán),再次打開(kāi)時(shí)文件可能就不完整了帮辟。
例如玩焰,MMKV在剩余空間不足時(shí)會(huì)回收無(wú)效的空間昔园,如果這期間進(jìn)程中斷默刚,數(shù)據(jù)可能會(huì)不完整澜搅。
MMKV官方的說(shuō)明可以佐證:
CRC校驗(yàn)失敗之后勉躺,MMKV有兩種應(yīng)對(duì)策略:直接丟棄所有數(shù)據(jù),或者嘗試讀取數(shù)據(jù)(用戶(hù)可以在初始化時(shí)設(shè)定)概说。
嘗試讀取數(shù)據(jù)不一定能恢復(fù)數(shù)據(jù)糖赔,甚至可能會(huì)讀到一些錯(cuò)誤的數(shù)據(jù)放典,得看運(yùn)氣壳影。
這個(gè)過(guò)程是比較容易復(fù)現(xiàn)的,下面是其中一種復(fù)現(xiàn)路徑:
- 新增和刪除若干key-value
得到數(shù)據(jù)如下:
插入一個(gè)大字符串掺栅,觸發(fā)擴(kuò)容氧卧,擴(kuò)容前會(huì)觸發(fā)垃圾回收
-
斷點(diǎn)打在執(zhí)行memmove的循環(huán)中沙绝,執(zhí)行一部分memmove, 然后在手機(jī)上殺死進(jìn)程
再次打開(kāi)APP,數(shù)據(jù)丟失
相比之下谬以,SP雖然低效为黎,但至少有相應(yīng)的機(jī)制確保數(shù)據(jù)完整性,頂多可能會(huì)丟失最新的update炕檩;
而MMKV則有可能會(huì)丟失整個(gè)文件的數(shù)據(jù)笛质。
二、FastKV
在總結(jié)了之前的經(jīng)驗(yàn)和感悟之后敲霍,筆者實(shí)現(xiàn)了一個(gè)高效且可靠的版本柴我,且將其命名為: FastKV艘儒。
2.1 特性
FastKV有以下特性:
- 讀寫(xiě)速度快
- FastKV采用二進(jìn)制編碼彤悔,編碼后的體積相對(duì)XML等文本編碼要小很多。
- 增量編碼:FastKV記錄了各個(gè)key-value相對(duì)文件的偏移量,更新數(shù)據(jù)時(shí)敞斋,可以直接在對(duì)應(yīng)的位置寫(xiě)入數(shù)據(jù)截汪。
- 默認(rèn)用mmap的方式記錄數(shù)據(jù),更新數(shù)據(jù)時(shí)直接寫(xiě)入到內(nèi)存即可植捎,沒(méi)有IO阻塞衙解。
- 支持多種寫(xiě)入模式
- 除了mmap這種非阻塞的寫(xiě)入方式,F(xiàn)astKV也支持常規(guī)的阻塞式寫(xiě)入方式焰枢,
并且支持同步阻塞和異步阻塞(分別類(lèi)似于SharePreferences的commit和apply)蚓峦。
- 除了mmap這種非阻塞的寫(xiě)入方式,F(xiàn)astKV也支持常規(guī)的阻塞式寫(xiě)入方式焰枢,
- 支持多種類(lèi)型
- 支持常用的boolean/int/float/long/double/String等基礎(chǔ)類(lèi)型。
- 支持ByteArray (byte[])济锄。
- 支持存儲(chǔ)自定義對(duì)象暑椰。
- 內(nèi)置Set<String>的編碼器 (為了方便兼容SharePreferences)。
- 支持多進(jìn)程
- 項(xiàng)目提供了支持多進(jìn)程的存儲(chǔ)類(lèi)(MPFastKV)。
- 支持監(jiān)聽(tīng)文件內(nèi)容變化妇穴,其中一個(gè)進(jìn)程修改文件瞒滴,所有進(jìn)程皆可感知。
- 方便易用
- FastKV提供了了豐富的API接口琼蚯,開(kāi)箱即用峦睡。
- 提供的接口其中包括getAll()和putAll()方法粱快,
所以遷移SharePreferences等框架的數(shù)據(jù)到FastKV很方便,當(dāng)然蓄坏,遷移FastKV的數(shù)據(jù)到其他框架也很方便渔彰。
- 穩(wěn)定可靠
- 通過(guò)double-write等方法確保數(shù)據(jù)的完整性。
- 在API拋IO異常時(shí)自動(dòng)降級(jí)處理汞斧。
- 代碼精簡(jiǎn)
- FastKV由純Java實(shí)現(xiàn),編譯成jar包后體積只有幾十K啡邑。
2.2 實(shí)現(xiàn)原理
2.2.1 編碼
文件的布局:
[data_len | checksum | key-value | key-value|....]
- data_len: 占4字節(jié), 記錄所有key-value所占字節(jié)數(shù)。
- checksum: 占8字節(jié)耘子,記錄key-value部分的checksum。
key-value的數(shù)據(jù)布局:
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| delete_flag | external_flag | type | key_len | key_content | value |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 1bit | 1bit | 6bits | 1 byte | | |
delete_flag :標(biāo)記當(dāng)前key-value是否刪除弓摘。
external_flag: 標(biāo)記value部分是否寫(xiě)到額外的文件渊啰。
注:對(duì)于數(shù)據(jù)量比較大的value,放在主文件一者占用內(nèi)存杆煞,二者會(huì)影響其他key-value的訪問(wèn)性能凳寺,因此闻书,單獨(dú)用一個(gè)文件來(lái)保存該value, 并在主文件中記錄其文件名晃择。type: value類(lèi)型宫屠,目前支持boolean/int/float/long/double/String/ByteArray以及自定義對(duì)象创葡。
key_len: 記錄key的長(zhǎng)度,key_len本身占1字節(jié),所以支持key的最大長(zhǎng)度為255菇晃。
key_content: key的內(nèi)容本身册倒,utf8編碼。
value: 基礎(chǔ)類(lèi)型的value, 直接編碼(little-end)磺送;
其他類(lèi)型驻子,先記錄長(zhǎng)度(用varint編碼),再記錄內(nèi)容估灿。
String采用UTF-8編碼崇呵,ByteArray無(wú)需編碼,自定義對(duì)象實(shí)現(xiàn)Encoder接口馅袁,分別在Encoder的encode/decode方法中序列化和反序列化域慷。
2.2.2 存儲(chǔ)
- mmap
為了提高寫(xiě)入性能,F(xiàn)astKV默認(rèn)采用mmap的方式寫(xiě)入汗销。 - 降級(jí)
當(dāng)mmap API發(fā)生IO異常時(shí)犹褒,降級(jí)到常規(guī)的blocking I/O,同時(shí)為了不影響當(dāng)前線(xiàn)程弛针,會(huì)將寫(xiě)入放到異步線(xiàn)程中執(zhí)行叠骑。 - 數(shù)據(jù)完整性
如果在寫(xiě)入一部分的過(guò)程中發(fā)生中斷(進(jìn)程或系統(tǒng)),則文件可能會(huì)不完整削茁。
故此宙枷,需要用一些方法確保數(shù)據(jù)的完整性。
當(dāng)用mmap的方式打開(kāi)時(shí)茧跋,F(xiàn)astKV采用double-write的方式:數(shù)據(jù)依次寫(xiě)入A/B兩個(gè)文件(如果寫(xiě)入A過(guò)程中崩潰慰丛,B仍是完整的,如果A完整寫(xiě)入了瘾杭,則B寫(xiě)入時(shí)崩潰也不要緊)诅病;
加載數(shù)據(jù)時(shí),通過(guò)checksum、標(biāo)記睬隶、數(shù)據(jù)合法性檢驗(yàn)等方法驗(yàn)證文件是否完整锣夹,若其中一個(gè)文件是損壞的,則用完整的文件覆蓋之苏潜。
double-write可以防止進(jìn)程崩潰后數(shù)據(jù)不完整银萍,但由于mmap是系統(tǒng)定時(shí)刷盤(pán),若在刷盤(pán)前系統(tǒng)崩潰或者斷電恤左,仍會(huì)丟失未落盤(pán)的更新(之前的數(shù)據(jù)還在)贴唇;對(duì)于非常重要的key-value,在寫(xiě)入后飞袋,可接著調(diào)用force()強(qiáng)制將臟頁(yè)刷盤(pán)戳气。 - 更新策略(增/刪/改)
新增:寫(xiě)入到數(shù)據(jù)的尾部。
刪除:delete_flag設(shè)置為1巧鸭。
修改:如果value部分的長(zhǎng)度和原來(lái)一樣瓶您,則直接寫(xiě)入原來(lái)的位置;
否則纲仍,先寫(xiě)入key-value到數(shù)據(jù)尾部呀袱,再標(biāo)記原來(lái)位置的delete_flag為1(刪除),最后再更新文件的data_len和checksum郑叠。 - gc/truncate
刪除key-value時(shí)會(huì)收集信息(統(tǒng)計(jì)刪除的個(gè)數(shù)夜赵,以及所在位置,占用空間等)乡革。
GC的觸發(fā)時(shí)機(jī):
1寇僧、新增key-value時(shí)剩余空間不足,且已刪除的空間達(dá)到閾值沸版,且騰出刪除空間后足夠?qū)懭氘?dāng)前key-value, 則觸發(fā)GC嘁傀;
2、刪除key-value時(shí)推穷,如果刪除空間達(dá)到閾值心包,或者刪除的key-value個(gè)數(shù)達(dá)到閾值,則觸發(fā)GC馒铃。
GC后如果空閑的空間達(dá)到設(shè)定閾值,則觸發(fā)truncate(縮小文件大泻弁铩)区宇。 - 多進(jìn)程支持
FileLock實(shí)現(xiàn)進(jìn)程互斥,F(xiàn)ileObserver實(shí)現(xiàn)文件變更監(jiān)聽(tīng)值戳。
A文件mmap寫(xiě)入议谷,內(nèi)存共享;B文件FileChannel寫(xiě)入堕虹,觸發(fā)FileObserver回調(diào)(寫(xiě)入mmap不會(huì)出觸發(fā)FileObserver回調(diào))卧晓。
2.3 使用方法
2.3.1 導(dǎo)入
dependencies {
implementation 'io.github.billywei01:fastkv:2.1.4'
}
2.3.2 初始化
FastKVConfig.setLogger(FastKVLogger)
FastKVConfig.setExecutor(Dispatchers.Default.asExecutor())
初始化可以按需設(shè)置日志接口和Executor芬首。
2.3.3 基本用法
// FastKV kv = new FastKV.Builder(path, name).build();
FastKV kv = new FastKV.Builder(context, name).build();
if(!kv.getBoolean("flag")){
kv.putBoolean("flag" , true);
}
int count = kv.getInt("count");
if(count < 10){
kv.putInt("count" , count + 1);
}
Builder的構(gòu)造可傳Context或者path。
如果傳Context的話(huà)逼裆,會(huì)在內(nèi)部目錄的'files'目錄下創(chuàng)建'fastkv'目錄來(lái)作為文件的保存路徑郁稍。
2.3.4 存儲(chǔ)自定義對(duì)象
FastKV.Encoder<?>[] encoders = new FastKV.Encoder[]{LongListEncoder.INSTANCE};
FastKV kv = new FastKV.Builder(context, name).encoder(encoders).build();
List<Long> list = new ArrayList<>();
list.add(100L);
list.add(200L);
list.add(300L);
kv.putObject("long_list", list, LongListEncoder.INSTANCE);
List<Long> list2 = kv.getObject("long_list");
除了支持基本類(lèi)型外,F(xiàn)astKV還支持寫(xiě)入對(duì)象胜宇,只需在構(gòu)建FastKV實(shí)例時(shí)傳入對(duì)象的編碼器即可耀怜。
編碼器為實(shí)現(xiàn)FastEncoder接口的對(duì)象。
上面LongListEncoder就實(shí)現(xiàn)了FastEncoder接口桐愉,代碼實(shí)現(xiàn)可參考:LongListEncoder
編碼對(duì)象涉及序列化/反序列化财破。
這里推薦筆者的另外一個(gè)框架:https://github.com/BillyWei01/Packable
2.3.5 數(shù)據(jù)加密
如需對(duì)數(shù)據(jù)進(jìn)行加密,在創(chuàng)建FastKV實(shí)例時(shí)傳入
FastCipher 的實(shí)現(xiàn)即可从诲。
FastKV kv = FastKV.Builder(path, name)
.cipher(yourCihper)
.build()
項(xiàng)目中有舉例Cipher的實(shí)現(xiàn)左痢,可參考:AESCipher
2.3.6 遷移 SharePreferences 到 FastKV
FastKV實(shí)現(xiàn)了SharedPreferences接口,并且提供了遷移SP數(shù)據(jù)的方法系洛。
用法如下:
public class SpCase {
public static final String NAME = "common_store";
// 原本的獲取SP的方法
// public static final SharedPreferences preferences = GlobalConfig.appContext.getSharedPreferences(NAME, Context.MODE_PRIVATE);
// 導(dǎo)入原SP數(shù)據(jù)
public static final SharedPreferences preferences = FastKV.adapt(AppContext.INSTANCE.getContext(), NAME);
}
2.3.7 遷移 MMKV 到 FastKV
由于MMKV沒(méi)有實(shí)現(xiàn) 'getAll' 接口俊性,所以無(wú)法像SharePreferences一樣一次性遷移。
但是可以封裝一個(gè)KV類(lèi)碎罚,創(chuàng)建 'getInt'磅废,'getString' ... 等方法,并在其中做適配處理荆烈。
可參考:MMKV2FastKV
2.3.8 多進(jìn)程
項(xiàng)目提供了支持多進(jìn)程的實(shí)現(xiàn):MPFastKV拯勉。
MPFastKV除了支持多進(jìn)程讀寫(xiě)之外,還實(shí)現(xiàn)了SharedPreferences的接口憔购,包括支持注冊(cè)O(shè)nSharedPreferenceChangeListener ;
其中一個(gè)進(jìn)程修改了數(shù)據(jù)宫峦,所有的進(jìn)程都會(huì)感知(通過(guò)OnSharedPreferenceChangeListener回調(diào))。
可參考 MultiProcessTestActivity 和 TestService
需要提醒的是玫鸟,由于支持多進(jìn)程需要維護(hù)更多的狀態(tài)导绷,MPFastKV 的寫(xiě)入要比FastKV慢不少,
所以在不需要多進(jìn)程訪問(wèn)的情況下屎飘,盡量用FastKV妥曲。
2.3.9 Kotlin 委托
Kotlin是兼容Java的,所以Kotlin下也可以直接用FastKV或者SharedPreferences的API钦购。
此外檐盟,Kotlin還提供了“委托屬性”這一語(yǔ)法糖,可以用于改進(jìn)key-value API訪問(wèn)押桃。
可參考:KVData
三葵萎、 性能測(cè)試
- 測(cè)試數(shù)據(jù):搜集APP中的SharePreferences匯總的部份key-value數(shù)據(jù)(經(jīng)過(guò)隨機(jī)混淆)得到總共六百多個(gè)key-value。
分別截取其中一部分,構(gòu)造正態(tài)分布的輸入序列羡忘,進(jìn)行多次測(cè)試谎痢。 - 測(cè)試機(jī)型:華為P30 Pro
- 測(cè)試代碼:Benchmark
測(cè)試結(jié)果如下:
更新:
25 | 50 | 100 | 200 | 400 | 600 | |
---|---|---|---|---|---|---|
SP-commit | 114 | 172 | 411 | 666 | 2556 | 5344 |
DataStore | 231 | 625 | 1717 | 4421 | 7629 | 13639 |
SQLiteKV | 192 | 382 | 1025 | 1565 | 4279 | 5034 |
SP-apply | 3 | 9 | 35 | 118 | 344 | 516 |
MMKV | 4 | 8 | 5 | 8 | 10 | 9 |
FastKV | 3 | 6 | 4 | 6 | 8 | 10 |
查詢(xún):
25 | 50 | 100 | 200 | 400 | 600 | |
---|---|---|---|---|---|---|
SP-commit | 1 | 3 | 2 | 1 | 2 | 3 |
DataStore | 57 | 76 | 115 | 117 | 170 | 216 |
SQLiteKV | 96 | 161 | 265 | 417 | 767 | 1038 |
SP-apply | 0 | 1 | 0 | 1 | 3 | 3 |
MMKV | 0 | 1 | 1 | 5 | 8 | 11 |
FastKV | 0 | 1 | 1 | 3 | 3 | 1 |
每次執(zhí)行Benchmark獲取到的結(jié)果有所浮動(dòng),尤其是APP啟動(dòng)后執(zhí)行多次卷雕,部分KV會(huì)變快(JIT優(yōu)化)节猿。
以上數(shù)據(jù)是取APP冷啟動(dòng)后第一次Benchmark的數(shù)據(jù)。
四爽蝴、結(jié)語(yǔ)
本文探討了當(dāng)下Android平臺(tái)的各類(lèi)KV存儲(chǔ)方式沐批,提出并實(shí)現(xiàn)了一種新的存儲(chǔ)組件,著重解決了KV存儲(chǔ)的效率和數(shù)據(jù)可靠性問(wèn)題蝎亚。
目前代碼已上傳Github: https://github.com/BillyWei01/FastKV