注:此組件不夠成熟洪己,筆者最近寫了一個更好的KV存儲組件:
https://juejin.cn/post/7018522454171582500
LightKV是基于Java NIO的輕量級妥凳,高性能,高可靠的key-value存儲組件答捕。
一逝钥、起源
Android平臺常見的本地存儲方式, SDK內置的有SQLite,SharedPreference等拱镐,開源組件有ACache, DiskLruCahce等艘款,有各自的特點和適用性。
SharedPreference以其天然的 key-value API沃琅,二級存儲(內存HashMap, 磁盤xml文件)等特點哗咆,為廣大開發(fā)者所青睞。
然而益眉,任何工具都是有適用性的晌柬,參見文章《不要濫用SharedPreference》。
當然郭脂,其中一些缺點是其定位決定的年碘,比如說不適合存儲大的key-value, 這個無可厚非;
不過有一些地方可以改進展鸡,比如存儲格式:xml解析速度慢屿衅,空間占用大,特殊字符需要轉義等特點莹弊,對于高頻變化的存儲涤久,實非良策。
故此忍弛,有必要寫一個改良版的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ù)組轉化啦鸣。
作為底層的組件,支持最基本的類型可以簡化復雜度来氧。
對于String和byte[], 存儲時先存長度诫给,再存內容香拉。
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時為無效鹤啡,讀取時會跳過。
內存緩存
SharePreference相對于ACache蹲嚣,DiskLruCache等多了一層內存的存儲递瑰,于是他們的定位也就涇渭分明了:
后者通常用于存儲大對象或者文件等,他們只負責提供磁盤存儲,至于讀到內存之后如果使用和管理,則不是他們的職責了矗漾。
太大的對象會占用太多的內存,而SharePreference是長期持有引用您朽,沒有空間限制和淘汰機制的狂丝,因此SharePreference適用于“輕量級存儲”换淆, 而由此所帶來的收益就是讀取速度很快。
LightKV定位也是“輕量級存儲”几颜,所以也會在內存中存儲key-value倍试,只不過這里用SparseArray來存儲。
2.2 存儲操作
上面提到, 存儲格式是簡單地key-value依次排列:
key|value|key|value|key|value……
這樣存放蛋哭,讀取時可以流式地解析县习,甚至,寫入時可以增量寫入谆趾。
方案一躁愿、增量&異步
增量操作
- 新增:在尾部追加key|value即可;
- 刪除:為了避免字節(jié)移動沪蓬,可以用標記的方法——將key的最高位標記為1彤钟;
- 修改:如果value長度不變,尋址到對應的位置跷叉,寫入value即可逸雹;否則营搅,先“刪除”,再“新增”梆砸;
- GC: 解析文件內容時(加載數(shù)據時進行解析)转质,記錄被刪除的內容的長度,大于設定閾值則清空文件帖世,做一次全量寫入休蟹。 使用過程中,有“刪除”操作時狮暑,“刪除”之后鸡挠,累加刪除的內容的長度,若超過設定閾值則GC搬男。
mmap
要想增量修改文件拣展,需要具備隨機寫入的能力:
Java NIO會是不錯的選擇,甚至缔逛,可以用mmap(內存映射文件)备埃。
mmap還有一些優(yōu)點:
1、直接操作內核空間:避免內核空間和用戶空間之間的數(shù)據拷貝褐奴;
2按脚、自動定時刷新:避免頻繁的磁盤操作;
3敦冬、進程退出時刷新:系統(tǒng)層面的調用辅搬,不用擔心進程退出導致數(shù)據丟失。
如果要說不足脖旱,就是在映射文件階段比常規(guī)的IO的打開文件消耗更多堪遂。
所以API中建議大文件時采用mmap,小文件的讀寫用建議用常規(guī)IO萌庆;而網上介紹mmap也多是舉例大文件的拷貝溶褪。
事實上如果小文件是高頻寫入的話,也是值得一試的践险,
比如騰訊的日志組件 xlog 和 存儲組件 MMKV, 都用了mmap猿妈。
mmap的寫入方式其實類似于異步寫入,只是不需要自己開線程去刷數(shù)據到磁盤巍虫,而是由操作系統(tǒng)去調度彭则。
這樣的方式有利有弊:好處是寫入快,減少磁盤損耗占遥;
缺點就是俯抖,和SharePreference的apply一樣,不具備原子性筷频,沒有入原子性蚌成,一致性就得不到保障前痘。
比如,數(shù)據寫入內存后担忧,在數(shù)據刷新到磁盤之前芹缔,發(fā)生系統(tǒng)級錯誤(如系統(tǒng)崩潰)或設備異常(如斷電,磁盤損壞等)瓶盛,此時會丟失數(shù)據最欠;
如果寫入內存后,刷入磁盤前惩猫,有別的代碼讀取了剛才寫入的內存芝硬,就有可能導致數(shù)據不一致。
不過轧房,通常情況下拌阴,發(fā)生系統(tǒng)級錯誤和設備異常的概率較低,所以還是比較可靠的奶镶。
方案二迟赃、全量&同步
對于一些核心數(shù)據,我們希望用更可靠的方式存儲厂镇。
怎么定義可靠呢纤壁?
首先原子性是要有的,所以只能同步寫入了捺信;
然后是可用性和完整性:
程序異常酌媒,系統(tǒng)異常,或者硬件故障等都可能導致數(shù)據丟失或者錯誤迄靠;
需添加一些機制確保異常和故障發(fā)生時數(shù)據仍然完整可用秒咨。
查看SharedPreference源碼,其容錯策略是梨水,
寫入前重命名主文件為備份文件的名字拭荤,成功寫入則刪除備份文件茵臭,
而打開文件階段疫诽,如果發(fā)現(xiàn)有備份文件,將備份文件重命名為主文件的名字旦委。
從而奇徒,假如寫入數(shù)據時發(fā)生故障,再次重啟APP時可以從備份文件中恢復數(shù)據缨硝。
這樣的容錯策略摩钙,總體來說是不錯的方案,能保證大多數(shù)據情況下的數(shù)據可用性查辩。
我們沒有采用該方案胖笛,主要是考慮該方案操作相對復雜网持,以及其他一些顧慮。
我們采用的策略是:冗余備份+數(shù)據校驗长踊。
冗余備份
冗余備份來提高數(shù)據數(shù)據可用性的思想在很多地方有體現(xiàn)功舀,比如 RAID 1 磁盤陣列。
同樣身弊,我們可以通過一份內存寫兩個文件辟汰,這樣當一個文件失效,還有另外一個文件可用阱佛。
比方說一個文件失效的概率時十萬分之一帖汞,則兩個文件同時失效的概率是百億分之一。
總之凑术,冗余備份可以大大減少數(shù)據丟失的概率翩蘸。
有得必有失,其代價就是雙倍磁盤空間和寫入時間淮逊。
不過我們的定位是“輕量級存儲”鹿鳖,如果只存“核心數(shù)據”,數(shù)據量不會很大壮莹,所以總的來說收益大于代價翅帜。
就寫入時間方面,相比SharedPreference而言命满,重命名和刪除文件也是一種IO涝滴,其本質是更新文件的“元數(shù)據”。
寫磁盤以頁(page)為單位胶台,一頁通常為4K歼疮。
向文件寫入1個字節(jié)和2497字節(jié),在磁盤寫入階段是等價的(都需要占用4K的字節(jié))诈唬。
數(shù)據量較少時韩脏,寫入兩份文件,相比于“重命名->寫數(shù)據->刪除文件”的操作铸磅,區(qū)別不大赡矢。
數(shù)據校驗
數(shù)據校驗的方法通常是對數(shù)據進行一些的運算,將運算結果放在數(shù)據后阅仔;讀取時做同樣運算吹散,然后和之前的結果對比。
常見的方法有奇偶校驗八酒,CRC, MD5, SHA等空民。
奇偶校驗多被應用于計算機硬件的錯誤檢測中; 軟件層面,通常是計算散列羞迷。
眾多Hash算法中界轩,我們選擇 64bit 的 MurmurHash, 關于MurmurHash可查看筆者的另一篇文章《漫談散列函數(shù)》画饥。
在考慮分組寫入還全量寫入,分組校驗還是全量校驗時浊猾,
分組的話荒澡,細節(jié)多,代碼復雜与殃,還是選擇全量的方式吧单山。
也就是,收集所有key|value到buffer, 然后計算hash, 放到數(shù)據后幅疼,一并寫入次磁盤米奸。
魚和熊掌
不同的應用場景有不同的需求。
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用同一份即可哈扮;
兩者的特點在前面“方案一”和“方案二”中有所闡述了,根據具體需求靈活使用即可蚓再。
2.3 混淆操作
對于用XML來存儲的SharePreferences來說滑肉,打開其文件即可一覽所有key-value, 即使開發(fā)者對value進行編碼,key還是可以看到的摘仅。
SharePreferences的文件不是存在App下的目錄靶庙,在沙盒之中嗎?
無root權限下娃属,對于其他應用(非系統(tǒng))六荒,沙盒確實是不可訪問的;
但是對于APP逆向者(黑色產業(yè)膳犹?)來說恬吕,SharePreferences文件不過是囊中之物签则,或可從中一窺APP的關鍵须床,以助其破解APP。
故此渐裂,混淆內容文件豺旬,或可增加一點破解成本钠惩。
對于APP來說,沒有絕對的安全族阅,只是破解成本與收益之間的博弈篓跛,這里就不多作展開了。
LightKV由于采用流式存儲坦刀,而且key是用int類型愧沟,所以不容易看出其文件內容;
但是如果value是明文字符串鲤遥,還是可以看到部分內容的沐寺,如下圖:
LightKV提供了混淆value(String和byte[]類型)的接口:
public interface Encoder {
byte[] encode(byte[] src);
byte[] decode(byte[] des);
}
開發(fā)者可以按照自己的規(guī)則實現(xiàn)編碼和解碼。
通過該接口可以做很多擴展:
- 1盖奈、嚴格的加密混坞;
- 2、數(shù)據壓縮钢坦;
- 3究孕、內容混淆(事實上前二者都有混淆的功能)
混淆后,打開文件爹凹,都是亂碼厨诸。
值得一提的是,只能對String和byte[]類型的value混淆禾酱。
因為基礎類如long, double等,以二進制形式寫入,用文本的形式打開,本就是不好閱讀的,無需再作混淆泳猬。
三、使用方法
前面我們看到宇植,SyncKV和AsyncKV都繼承于LightKV, 二者在內存中的存儲格式是一致的得封,都是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做“|”運算(解析的時候需要據此判斷類型)。
相對于SharePreferences跋选,LightKV有更多的初始化選項涕癣,故而用構造者模式來構建對象。
下面逐一說明各個參數(shù)和對應的特性前标。
3.1 內容混淆
若需要對value混淆坠韩,只需在構造LightKV時傳入Encoder,
然后聲明key時和DataType.ENCODE做“|”運算即可。
保存和讀取時炼列,LightKV會將key和DataType.ENCODE做“&”運算同眯,若不為0,則調用Encoder進行編碼(保存)或解碼(讀任ㄑ肌)须蜗。
3.2 異步加載
SharePreferences的加載在新創(chuàng)建的的線程中加載的, 在完成加載之前阻塞讀和寫:
LightKV同樣實現(xiàn)了異步加載, 而且可以指定 Executor,當然也可以選擇不異步加載(不傳Executor即可)目溉。
需要提醒的是明肮,雖然提供了異步加載,但是有時候沒有異步加載的效果缭付。
比如對象初始化的同時立即調用get或者put方法柿估,會阻塞當前線程直到加載完成,這樣和同步加載沒什么區(qū)別陷猫。
建議寫法秫舌,在進程初始化的時候調用data(), 以觸發(fā)數(shù)據的加載:
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的最后零抬,調用 sync() 和 async() 可分辨創(chuàng)建AsyncKV和SyncKV镊讼。
各自的特點前面也交代過了,靈活選取即可平夜。
如果不是存一些十分重要的數(shù)據(比如帳號信息等)蝶棋,用AsyncKV即可。
3.5 訪問數(shù)據
寫完初始化參數(shù)忽妒,定義好key, 編寫 get 和 set方法之后,
就可以訪問數(shù)據了:
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的打開方式,需要映射一塊磁盤空間到內存蹂楣,為了減少碎片俏站,故而一次映射一頁(4K)。
SyncKV由于存儲格式比較緊湊痊土,所以文件大小相比SharePreferences要幸拊;
但是由于SyncKV采用雙備份赁酝,所以總大小和SharePreferences差不多犯祠。
數(shù)據量都少于4K時,其實三者相差無幾酌呆;
當存儲內容變多時衡载,AsyncKV反而會更少占用,因為其存儲格式和SyncKV一樣隙袁,但是只用存一份痰娱。
4.2 寫入性能
理想中的寫入是各組key|value全寫到內存,然后統(tǒng)一調用一次commit, 這樣寫入是最快的菩收。
然而實際使用中梨睁,各組key|value的寫入通常是隨機的,所以下面測試結果娜饵,都是每次put后立即提交而姐。
AsyncKV例外,因為其定位就是減少IO划咐,讓系統(tǒng)內核自己去提交更新拴念。
測試機器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 這兩種方式,提交到內存后立即返回褐缠,所以耗時較少政鼠;
SyncKV 和 SharePreferences-commit,都是在當前線程提交內存和磁盤队魏,故而耗時較長公般。
無論是同步寫入還是異步寫入万搔,LightKV都要比SharePreferences快:
在同步寫入方面,SharePreferences-commit耗時比SyncKV多3到4倍官帘;
在異步寫入方面瞬雹,AsyncKV也比SharePreferences-apply也要快很多。
至于加載性能刽虹,筆者比較了華為P30pro和小米Note1的機器酗捌,發(fā)現(xiàn)AsyncKV的loading時間在小米note上相對較慢,而在華為P30pro上則相對較快涌哲,所以就不貼出數(shù)據了胖缤。
文末有github鏈接,讀者可自行run一下benchmark阀圾。
然后就是讀取性能哪廓,SharePreferences是從HashMap中讀取,LightKV是從SparseArray中讀取初烘,兩種數(shù)據結構的優(yōu)點和缺點網上已經有很多討論了涡真,這次就不多作比較了。
五肾筐、總結
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.reibang.com/p/ad9756fe21c8
http://www.reibang.com/p/07664dc4c51a