注:此組件不夠成熟,筆者最近寫了一個更好的KV存儲組件:
https://juejin.cn/post/7018522454171582500
LightKV是基于Java NIO的輕量級,高性能,高可靠的key-value存儲組件。
一、起源
Android平臺常見的本地存儲方式, SDK內(nèi)置的有SQLite,SharedPreference等,開源組件有ACache, DiskLruCahce等,有各自的特點和適用性。
SharedPreference以其天然的 key-value API,二級存儲(內(nèi)存HashMap, 磁盤xml文件)等特點,為廣大開發(fā)者所青睞。
然而,任何工具都是有適用性的,參見文章《不要濫用SharedPreference》。
當然,其中一些缺點是其定位決定的,比如說不適合存儲大的key-value, 這個無可厚非;
不過有一些地方可以改進,比如存儲格式:xml解析速度慢,空間占用大,特殊字符需要轉(zhuǎn)義等特點,對于高頻變化的存儲,實非良策。
故此,有必要寫一個改良版的key-value存儲組件。
二、LightKV原理
2.1 存儲格式
我們希望文件可以流式解析,對于簡單key-value形式,完全可以自定義格式。
例如,簡單地依次保存key-value就好:
key|value|key|value|key|value……
value
關于value類型,我們需要支持一些常用的基礎類型:boolean, int, long, float, double, 以及String 和 數(shù)組(byte[])。
尤其是后者,更多的復合類型(比如對象)都可以通過String和數(shù)組轉(zhuǎn)化。
作為底層的組件,支持最基本的類型可以簡化復雜度。
對于String和byte[], 存儲時先存長度,再存內(nèi)容。
key
我們觀察到,在實際使用中key通常是預先定義好的;
故此,我們可以舍棄一定的通用性,用int來作為key, 而非用String。
有舍必有得,用int作為key,可以用更少的空間承載更多的信息。
public interface DataType {
int OFFSET = 16;
int MASK = 0xF0000;
int ENCODE = 1 << 20;
int BOOLEAN = 1 << OFFSET;
int INT = 2 << OFFSET;
int FLOAT = 3 << OFFSET;
int LONG = 4 << OFFSET;
int DOUBLE = 5 << OFFSET;
int STRING = 6 << OFFSET;
int ARRAY = 7 << OFFSET;
}
int的低16位用來定義key,
17-19位用來定義類型,
20位預留,
21位標記是否編碼(后面會講到),
32位(最高位)標記是否有效:為1時為無效,讀取時會跳過。
內(nèi)存緩存
SharePreference相對于ACache,DiskLruCache等多了一層內(nèi)存的存儲,于是他們的定位也就涇渭分明了:
后者通常用于存儲大對象或者文件等,他們只負責提供磁盤存儲,至于讀到內(nèi)存之后如果使用和管理,則不是他們的職責了。
太大的對象會占用太多的內(nèi)存,而SharePreference是長期持有引用,沒有空間限制和淘汰機制的,因此SharePreference適用于“輕量級存儲”, 而由此所帶來的收益就是讀取速度很快。
LightKV定位也是“輕量級存儲”,所以也會在內(nèi)存中存儲key-value,只不過這里用SparseArray來存儲。
2.2 存儲操作
上面提到, 存儲格式是簡單地key-value依次排列:
key|value|key|value|key|value……
這樣存放,讀取時可以流式地解析,甚至,寫入時可以增量寫入。
方案一、增量&異步
增量操作
- 新增:在尾部追加key|value即可;
- 刪除:為了避免字節(jié)移動,可以用標記的方法——將key的最高位標記為1;
- 修改:如果value長度不變,尋址到對應的位置,寫入value即可;否則,先“刪除”,再“新增”;
- GC: 解析文件內(nèi)容時(加載數(shù)據(jù)時進行解析),記錄被刪除的內(nèi)容的長度,大于設定閾值則清空文件,做一次全量寫入。 使用過程中,有“刪除”操作時,“刪除”之后,累加刪除的內(nèi)容的長度,若超過設定閾值則GC。
mmap
要想增量修改文件,需要具備隨機寫入的能力:
Java NIO會是不錯的選擇,甚至,可以用mmap(內(nèi)存映射文件)。
mmap還有一些優(yōu)點:
1、直接操作內(nèi)核空間:避免內(nèi)核空間和用戶空間之間的數(shù)據(jù)拷貝;
2、自動定時刷新:避免頻繁的磁盤操作;
3、進程退出時刷新:系統(tǒng)層面的調(diào)用,不用擔心進程退出導致數(shù)據(jù)丟失。
如果要說不足,就是在映射文件階段比常規(guī)的IO的打開文件消耗更多。
所以API中建議大文件時采用mmap,小文件的讀寫用建議用常規(guī)IO;而網(wǎng)上介紹mmap也多是舉例大文件的拷貝。
事實上如果小文件是高頻寫入的話,也是值得一試的,
比如騰訊的日志組件 xlog 和 存儲組件 MMKV, 都用了mmap。
mmap的寫入方式其實類似于異步寫入,只是不需要自己開線程去刷數(shù)據(jù)到磁盤,而是由操作系統(tǒng)去調(diào)度。
這樣的方式有利有弊:好處是寫入快,減少磁盤損耗;
缺點就是,和SharePreference的apply一樣,不具備原子性,沒有入原子性,一致性就得不到保障。
比如,數(shù)據(jù)寫入內(nèi)存后,在數(shù)據(jù)刷新到磁盤之前,發(fā)生系統(tǒng)級錯誤(如系統(tǒng)崩潰)或設備異常(如斷電,磁盤損壞等),此時會丟失數(shù)據(jù);
如果寫入內(nèi)存后,刷入磁盤前,有別的代碼讀取了剛才寫入的內(nèi)存,就有可能導致數(shù)據(jù)不一致。
不過,通常情況下,發(fā)生系統(tǒng)級錯誤和設備異常的概率較低,所以還是比較可靠的。
方案二、全量&同步
對于一些核心數(shù)據(jù),我們希望用更可靠的方式存儲。
怎么定義可靠呢?
首先原子性是要有的,所以只能同步寫入了;
然后是可用性和完整性:
程序異常,系統(tǒng)異常,或者硬件故障等都可能導致數(shù)據(jù)丟失或者錯誤;
需添加一些機制確保異常和故障發(fā)生時數(shù)據(jù)仍然完整可用。
查看SharedPreference源碼,其容錯策略是,
寫入前重命名主文件為備份文件的名字,成功寫入則刪除備份文件,
而打開文件階段,如果發(fā)現(xiàn)有備份文件,將備份文件重命名為主文件的名字。
從而,假如寫入數(shù)據(jù)時發(fā)生故障,再次重啟APP時可以從備份文件中恢復數(shù)據(jù)。
這樣的容錯策略,總體來說是不錯的方案,能保證大多數(shù)據(jù)情況下的數(shù)據(jù)可用性。
我們沒有采用該方案,主要是考慮該方案操作相對復雜,以及其他一些顧慮。
我們采用的策略是:冗余備份+數(shù)據(jù)校驗。
冗余備份
冗余備份來提高數(shù)據(jù)數(shù)據(jù)可用性的思想在很多地方有體現(xiàn),比如 RAID 1 磁盤陣列。
同樣,我們可以通過一份內(nèi)存寫兩個文件,這樣當一個文件失效,還有另外一個文件可用。
比方說一個文件失效的概率時十萬分之一,則兩個文件同時失效的概率是百億分之一。
總之,冗余備份可以大大減少數(shù)據(jù)丟失的概率。
有得必有失,其代價就是雙倍磁盤空間和寫入時間。
不過我們的定位是“輕量級存儲”,如果只存“核心數(shù)據(jù)”,數(shù)據(jù)量不會很大,所以總的來說收益大于代價。
就寫入時間方面,相比SharedPreference而言,重命名和刪除文件也是一種IO,其本質(zhì)是更新文件的“元數(shù)據(jù)”。
寫磁盤以頁(page)為單位,一頁通常為4K。


向文件寫入1個字節(jié)和2497字節(jié),在磁盤寫入階段是等價的(都需要占用4K的字節(jié))。
數(shù)據(jù)量較少時,寫入兩份文件,相比于“重命名->寫數(shù)據(jù)->刪除文件”的操作,區(qū)別不大。
數(shù)據(jù)校驗
數(shù)據(jù)校驗的方法通常是對數(shù)據(jù)進行一些的運算,將運算結(jié)果放在數(shù)據(jù)后;讀取時做同樣運算,然后和之前的結(jié)果對比。
常見的方法有奇偶校驗,CRC, MD5, SHA等。
奇偶校驗多被應用于計算機硬件的錯誤檢測中; 軟件層面,通常是計算散列。
眾多Hash算法中,我們選擇 64bit 的 MurmurHash, 關于MurmurHash可查看筆者的另一篇文章《漫談散列函數(shù)》。
在考慮分組寫入還全量寫入,分組校驗還是全量校驗時,
分組的話,細節(jié)多,代碼復雜,還是選擇全量的方式吧。
也就是,收集所有key|value到buffer, 然后計算hash, 放到數(shù)據(jù)后,一并寫入次磁盤。
魚和熊掌
不同的應用場景有不同的需求。
LightKV同時提供了快速寫入的mmap方式,和更可靠寫入的同步寫入方式。
它們有相同的API,只是存儲機制不一樣。
public abstract class LightKV {
final SparseArray<Object> mData = new SparseArray<>();
//......
}
public class AsyncKV extends LightKV {
private FileChannel mChannel;
private MappedByteBuffer mBuffer;
//......
}
public class SyncKV extends LightKV {
private FileChannel mAChannel;
private FileChannel mBChannel;
private ByteBuffer mBuffer;
//......
}
AsyncKV由于不具備一致性,所以也沒有必要冗余備份了,寫一份就好,以求更高的寫入效率和更少磁盤寫入。
SyncKV由于要做冗余備份,所以需要打開兩個文件,而buffer用同一份即可;
兩者的特點在前面“方案一”和“方案二”中有所闡述了,根據(jù)具體需求靈活使用即可。
2.3 混淆操作
對于用XML來存儲的SharePreferences來說,打開其文件即可一覽所有key-value, 即使開發(fā)者對value進行編碼,key還是可以看到的。
SharePreferences的文件不是存在App下的目錄,在沙盒之中嗎?
無root權限下,對于其他應用(非系統(tǒng)),沙盒確實是不可訪問的;
但是對于APP逆向者(黑色產(chǎn)業(yè)?)來說,SharePreferences文件不過是囊中之物,或可從中一窺APP的關鍵,以助其破解APP。
故此,混淆內(nèi)容文件,或可增加一點破解成本。
對于APP來說,沒有絕對的安全,只是破解成本與收益之間的博弈,這里就不多作展開了。
LightKV由于采用流式存儲,而且key是用int類型,所以不容易看出其文件內(nèi)容;
但是如果value是明文字符串,還是可以看到部分內(nèi)容的,如下圖:

LightKV提供了混淆value(String和byte[]類型)的接口:
public interface Encoder {
byte[] encode(byte[] src);
byte[] decode(byte[] des);
}
開發(fā)者可以按照自己的規(guī)則實現(xiàn)編碼和解碼。
通過該接口可以做很多擴展:
- 1、嚴格的加密;
- 2、數(shù)據(jù)壓縮;
- 3、內(nèi)容混淆(事實上前二者都有混淆的功能)
混淆后,打開文件,都是亂碼。

值得一提的是,只能對String和byte[]類型的value混淆。
因為基礎類如long, double等,以二進制形式寫入,用文本的形式打開,本就是不好閱讀的,無需再作混淆。
三、使用方法
前面我們看到,SyncKV和AsyncKV都繼承于LightKV, 二者在內(nèi)存中的存儲格式是一致的,都是SparseArray,
所以get方法封裝在LightKV中,然后各自實現(xiàn)put方法。
方法列表如下圖:

和SharePreferences類似,也有contains, remove, clear 和 commit 方法,甚至于,具體用法也很類似:
public class AppData {
private static final SharedPreferences sp =
GlobalConfig.getAppContext().getSharedPreferences("app_data", Context.MODE_PRIVATE);
private static final SharedPreferences.Editor editor = sp.edit();
private static final String ACCOUNT = "account";
private static final String TOKEN = "token";
private static void putString(String key, String value) {
editor.putString(key, value);
editor.commit();
}
private static String getString(String key) {
return sp.getString(key, "");
}
}
public class AppData {
private static final SyncKV DATA =
new LightKV.Builder(GlobalConfig.getAppContext(), "app_data")
.logger(AppLogger.getInstance())
.executor(AsyncTask.THREAD_POOL_EXECUTOR)
.encoder(new ConfuseEncoder())
.sync();
public interface Keys {
int SHOW_COUNT = 1 | DataType.INT;
int ACCOUNT = 2 | DataType.STRING | DataType.ENCODE;
int TOKEN = 3 | DataType.STRING | DataType.ENCODE;
}
public static SyncKV data() {
return DATA;
}
public static String getString(int key) {
return DATA.getString(key);
}
public static void putString(int key, String value) {
DATA.putString(key, value);
DATA.commit();
}
}
當然,以上只是眾多封裝方法中的一種,具體使用中,不同的開發(fā)者有不同的偏好。
對于LightKV而言,key的定義方法如下:
1、最好一個文件對應一個統(tǒng)一定義key的類,如上面的“Keys”;
2、key的賦值,按類型從1到65534都可以定義,然后和對應的DataType做“|”運算(解析的時候需要據(jù)此判斷類型)。
相對于SharePreferences,LightKV有更多的初始化選項,故而用構(gòu)造者模式來構(gòu)建對象。
下面逐一說明各個參數(shù)和對應的特性。
3.1 內(nèi)容混淆
若需要對value混淆,只需在構(gòu)造LightKV時傳入Encoder,
然后聲明key時和DataType.ENCODE做“|”運算即可。
保存和讀取時,LightKV會將key和DataType.ENCODE做“&”運算,若不為0,則調(diào)用Encoder進行編碼(保存)或解碼(讀取)。
3.2 異步加載
SharePreferences的加載在新創(chuàng)建的的線程中加載的, 在完成加載之前阻塞讀和寫:
LightKV同樣實現(xiàn)了異步加載, 而且可以指定 Executor,當然也可以選擇不異步加載(不傳Executor即可)。
需要提醒的是,雖然提供了異步加載,但是有時候沒有異步加載的效果。
比如對象初始化的同時立即調(diào)用get或者put方法,會阻塞當前線程直到加載完成,這樣和同步加載沒什么區(qū)別。
建議寫法,在進程初始化的時候調(diào)用data(), 以觸發(fā)數(shù)據(jù)的加載:
fun init(context: Context) {
// 僅初始化對象,不做get和put
AppData.data()
// 其他初始化工作
}
3.3 錯誤日志
public interface Logger {
void e(String tag, Throwable e);
}
大多數(shù)組件都不能保證運行期不發(fā)生異常,發(fā)生異常時,開發(fā)者通常會把異常信息打印到日志文件(有的還會上傳云端)。
故此,LightKV提供了打印日志接口,傳入實現(xiàn)類即可。
3.4 選擇模式
在Builder的最后,調(diào)用 sync() 和 async() 可分辨創(chuàng)建AsyncKV和SyncKV。
各自的特點前面也交代過了,靈活選取即可。
如果不是存一些十分重要的數(shù)據(jù)(比如帳號信息等),用AsyncKV即可。
3.5 訪問數(shù)據(jù)
寫完初始化參數(shù),定義好key, 編寫 get 和 set方法之后,
就可以訪問數(shù)據(jù)了:
String account = AppData.getString(AppData.Keys.ACCOUNT)
if(TextUtils.isEmpty(account)){
AppData.putString(AppData.Keys.ACCOUNT, "foo@gmail.com")
}
3.6 Kotlin下的用法
借助Kotlin的委托屬性,筆者拓展了LightKV的API, 提供了更方便的用法。
object AppData : KVData() {
override val data: LightKV by lazy {
LightKV.Builder(GlobalConfig.appContext, "app_data")
.logger(AppLogger)
.executor(AsyncTask.THREAD_POOL_EXECUTOR)
.encoder(GzipEncoder)
.async()
}
var showCount by int(1)
var account by string(2)
var token by string(3)
var secret by array(4 or DataType.ENCODE)
}
val account = AppData.account
if (TextUtils.isEmpty(account)) {
AppData.account = "foo@gmail.com"
}
與Java版的API相比,key的聲明更加簡單,而且可以像訪問變量一樣訪問key對應的value。
四、評測
倉促之間,準備的測試用例可能不是很科學,僅供參考-_-
測試用例中,對支持的7種類型各配置5個key, 共35對key|value。
4.1 存儲空間
| 存儲方式 | 文件大小(kb) |
|---|---|
| AsyncKV | 4 |
| SyncKV | 1.7 |
| SharePreferences | 3.3 |
AsyncKV由于采用mmap的打開方式,需要映射一塊磁盤空間到內(nèi)存,為了減少碎片,故而一次映射一頁(4K)。
SyncKV由于存儲格式比較緊湊,所以文件大小相比SharePreferences要??;
但是由于SyncKV采用雙備份,所以總大小和SharePreferences差不多。
數(shù)據(jù)量都少于4K時,其實三者相差無幾;
當存儲內(nèi)容變多時,AsyncKV反而會更少占用,因為其存儲格式和SyncKV一樣,但是只用存一份。
4.2 寫入性能
理想中的寫入是各組key|value全寫到內(nèi)存,然后統(tǒng)一調(diào)用一次commit, 這樣寫入是最快的。
然而實際使用中,各組key|value的寫入通常是隨機的,所以下面測試結(jié)果,都是每次put后立即提交。
AsyncKV例外,因為其定位就是減少IO,讓系統(tǒng)內(nèi)核自己去提交更新。
測試機器1:小米 note 1 (2018年5月)
| 存儲方式 | 寫入耗時(毫秒) |
|---|---|
| AsyncKV | 2.25 |
| SyncKV | 75.34 |
| SharePreferences-apply | 6.90 |
| SharePreferences-commit | 279.14 |
測試機器2:華為P30 pro (2020年3月)
| 存儲方式 | 寫入耗時(毫秒) |
|---|---|
| AsyncKV | 0.31 |
| SyncKV | 8.31 |
| SharePreferences-apply | 1.9 |
| SharePreferences-commit | 30.81 |
(新機器寫入速度確實快很多-_-)
AsyncKV 和 SharePreferences-apply 這兩種方式,提交到內(nèi)存后立即返回,所以耗時較少;
SyncKV 和 SharePreferences-commit,都是在當前線程提交內(nèi)存和磁盤,故而耗時較長。
無論是同步寫入還是異步寫入,LightKV都要比SharePreferences快:
在同步寫入方面,SharePreferences-commit耗時比SyncKV多3到4倍;
在異步寫入方面,AsyncKV也比SharePreferences-apply也要快很多。
至于加載性能,筆者比較了華為P30pro和小米Note1的機器,發(fā)現(xiàn)AsyncKV的loading時間在小米note上相對較慢,而在華為P30pro上則相對較快,所以就不貼出數(shù)據(jù)了。
文末有github鏈接,讀者可自行run一下benchmark。
然后就是讀取性能,SharePreferences是從HashMap中讀取,LightKV是從SparseArray中讀取,兩種數(shù)據(jù)結(jié)構(gòu)的優(yōu)點和缺點網(wǎng)上已經(jīng)有很多討論了,這次就不多作比較了。
五、總結(jié)
SharePreferences是Android平臺輕量且方便的key-value存儲組件,然而不少可以改進的地方。
LightKV以SharePreferences為參考,從效率,安全和易用性等方面,提供更好的存儲方式。
六、下載
dependencies {
implementation 'com.horizon.lightkv:lightkv:1.0.7'
}
項目地址:
https://github.com/BillyWei001/LightKV
參考文章:
http://www.cnblogs.com/mingfeng002/p/5970221.html
https://cloud.tencent.com/developer/article/1066229
https://segmentfault.com/r/1250000007474916?shareId=1210000007474917
http://www.itdecent.cn/p/ad9756fe21c8
http://www.itdecent.cn/p/07664dc4c51a