LightKV是基於Java NIO的輕量級,高效能,高可靠的key-value儲存元件。
一、起源
Android平臺常見的本地儲存方式, SDK內建的有SQLite,SharedPreference等,開源元件有ACache, DiskLruCahce等,有各自的特點和適用性。 SharedPreference以其天然的 key-value API,二級儲存(記憶體HashMap, 磁碟xml檔案)等特點,為廣大開發者所青睞。 然而,任何工具都是有適用性的,參見文章《不要濫用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 和 陣列(byte[])。 尤其是後者,更多的複合型別(比如物件)都可以通過String和陣列轉化。 作為底層的元件,支援最基本的型別可以簡化複雜度。 對於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;
}
複製程式碼
key的低16位用來定義key, 17-19位用來定義型別, 20位預留, 21位標記是否編碼(後面會講到), 32位(最高位)標記是否有效:不為0時為無效,讀取時會跳過。
記憶體快取
SharePreference相對於ACache,DiskLruCache等多了一層記憶體的儲存,於是他們的定位也就涇渭分明瞭: 後者通常用於儲存大物件或者檔案等,他們只負責提供磁碟儲存,至於讀到記憶體之後如果使用和管理,則不是他們的職責了。 太大的物件會佔用太多的記憶體,而SharePreference是長期持有引用,沒有空間限制和淘汰機制的,因此SharePreference適用於“輕量級儲存”, 而由此所帶來的收益就是讀##取速度很快。 LightKV定位也是“輕量級儲存”,所以也會在記憶體中儲存key-value,只不過這裡用SparseArray來儲存。
2.2、 儲存操作
上面提到, 儲存格式是簡單地key-value依次排列: key|value|key|value|key|value…… 這樣存放,讀取時可以流式地解析,甚至,寫入時可以增量寫入。
方案一、增量&非同步
增量操作
新增:在尾部追加key|value即可; 刪除:為了避免位元組移動,可以用標記的方法——將key的最高位標記為1; 修改:如果value長度不變,定址到對應的位置,寫入value即可;否則,先“刪除”,再“新增”; GC: 解析檔案內容時記錄刪除的內容的長度,大於設定閾值則清空檔案,做一次全量寫入。
mmap
要想增量修改檔案,需要具備隨機寫入的能力: Java NIO會是不錯的選擇,甚至,可以用mmap(記憶體對映檔案)。 mmap還有一些優點: 1、直接操作核心空間:避免核心空間和使用者空間之前的資料拷貝; 2、自動定時重新整理:避免頻繁的磁碟操作; 3、程式退出時重新整理:系統層面的呼叫,不用擔心程式退出導致資料丟失。
如果要說不足,就是在對映檔案階段比常規的IO的開啟檔案消耗更多。 所以API中建議大檔案時採用mmap,小檔案的讀寫用建議用常規IO;而網上介紹mmap也多時舉例大檔案的拷貝。 事實上如果小檔案是高頻寫入的話,也是值得一試的, 比如騰訊的日誌元件 xlog 和 儲存元件 MMKV, 都用了mmap。
mmap的寫入方式其實類似於非同步寫入,只是不需要自己開執行緒去刷資料到磁碟,而是由作業系統去排程。 這樣的方式有利有弊,好處是寫入快,減少磁碟損耗; 不足之處就是,和SharePreference的apply一樣,不具備原子性,沒有入原子性,一致性就得不到保障。 比如,資料寫入記憶體後,在資料重新整理到磁碟之前,發生系統級錯誤(如系統崩潰)或裝置異常(如斷電,磁碟損壞等),此時會丟失資料; 如果寫入記憶體後,刷入磁碟前,有別的程式碼讀取了剛才寫入的記憶體,就有可能導致資料不一致。 不過,通常情況下,發生系統級錯誤和裝置異常的概率較低,所以還是比較可靠的。
方案二、全量&同步
對於一些核心資料,我們希望用更可靠的方式儲存。 怎麼定義可靠呢? 首先原子性是要有的,所以只能同步寫入了; 然後是可用性和完整性: 程式異常,系統異常,或者硬體故障等都肯能導致資料丟失或者錯誤。
檢視SharedPreference原始碼,其容錯策略是, 寫入前重新命名主檔案為備份檔案的名字,成功寫入則刪除備份檔案, 而開啟檔案階段,如果發現有備份檔案,將備份檔案重新命名為主檔案的名字。 從而,假如寫入資料時發生故障,再次重啟APP時可以從備份檔案中恢復資料。 這樣的容錯策略,總體來說是不錯的方案,能保證大多資料情況下的資料可用性。 我們沒有采用該方案,主要是考慮該方案操作相對複雜,以及其他一些顧慮。
我們採用的策略是:冗餘備份+資料校驗。
冗餘備份
冗餘備份來提高資料資料可用性的思想在很多地方有體現,比如 RAID 1 磁碟陣列。 同樣,我們可以通過一份記憶體寫兩個檔案,這樣當一個檔案失效,還有另外一個檔案可用。 比方說一個檔案失效的概率時十萬分之一,則兩個檔案同時失效的概率是百億分之一。 總之,冗餘備份可以大大減少資料丟失的概率。 有得必有失,其代價就是雙倍磁碟空間和寫入時間。
不過我們的定位是“輕量級儲存”,如果只存“核心資料”,資料量不會很大,所以總的來說收益大於代價。 就寫入時間方面,相比SharedPreference而言,重新命名和刪除檔案也是一種IO,其本質是更新檔案的“後設資料”。 寫磁碟以頁(page)為單位,一頁通常為4K。 向檔案寫入一個位元組和1K位元組,在磁碟寫入階段是等價的(除非這1K跨頁-_-)。 資料量較少時,寫入兩份檔案,相比於“重新命名->寫資料->刪除檔案”的操作,區別不大。
資料校驗
資料校驗的方法通常是對資料進行一些的運算,將運算結果放在資料後;讀取時做同樣運算,然後和之前的結果對比。 常見的方法有奇偶校驗,CRC, MD5, SHA等。 奇偶校驗多被應用於計算機硬體的錯誤檢測中; 軟體層面,通常是計算雜湊。 眾多Hash演算法中,我們選擇 64bit 的 MurmurHash, 關於MurmurHash可檢視筆者的另一篇文章《漫談雜湊函式》。
在考慮分組寫入還全量寫入,分組校驗還是全量校驗時, 分組的話,細節多,程式碼複雜,還是選擇全量的方式吧。 也就是,收集所有key|value到buffer, 然後計算hash, 放到資料後,一併寫入次磁碟。
魚和熊掌
不同的應用場景有不同的需求。 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, 即使開發者對value進行編碼,key還是可以看到的。 SharePreferences的檔案不是存在App下的目錄,在沙盒之中嗎? 無root許可權下,對於其他應用(非系統),沙盒確實是不可訪問的; 但是對於APP逆向者(黑色產業?)來說,SharePreferences檔案不過是囊中之物,或可從中一窺APP的關鍵,以助其破解APP。 故此,混淆內容檔案,或可增加一點破解成本。 對於APP來說,沒有絕對的安全,只是破解成本與收益之間的博弈,這裡就不多作展開了。
LightKV由於採用流式儲存,而且key是用int型別,所以不容易看出其檔案內容; 但是如果value是明文字串,還是可以看到部分內容的,如下圖:
LightKV提供了混淆value(String和Array型別)的介面:
public interface Encoder {
byte[] encode(byte[] src);
byte[] decode(byte[] des);
}
複製程式碼
開發者可以按照自己的規則實現編碼和解碼。 LightKV專案中有編碼和解碼的Demo, 感興趣的讀者可以Download下來參考一下。 經過混淆後的結果,開啟檔案看到的都是無關的亂碼:
肯能有人會說,這樣的做法殺敵一千,自損八百啊! 沒事,LightKV的toString方法可以檢視檔案的內容:
而且,這個“後門”通常在DEBUG時才有效,除非釋出RELEASE時不混淆…… 具體原理,我們後面細細道來,先說下使用方法。
三、使用方法
前面我們看到,SyncKV和AsyncKV都繼承於LightKV, 二者在記憶體中的儲存格式是一致的,都是SparseArray, 所以get方法封裝在LightKV中,然後各自實現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 putInt(String key, int value) {
editor.putInt(key, value);
editor.commit();
}
private static int getInt(String key) {
return sp.getInt(key, 0);
}
}
複製程式碼
public class AppData {
private static final SyncKV DATA =
new LightKV.Builder(GlobalConfig.getAppContext(), "app_data")
.logger(AppLogger.getInstance())
.executor(AsyncTask.THREAD_POOL_EXECUTOR)
.keys(Keys.class)
.encoder(new ConfuseEncoder())
.sync();
public interface Keys {
int SHOW_COUNT = 1 | DataType.INT;
int ACCOUNT = 1 | DataType.STRING | DataType.ENCODE;
int TOKEN = 2 | DataType.STRING | DataType.ENCODE;
}
public static SyncKV data() {
return DATA;
}
// ......
public static int getInt(int key) {
return DATA.getInt(key);
}
public static void putInt(int key, int value) {
DATA.putInt(key, value);
DATA.commit();
}
}
複製程式碼
當然,以上只是眾多封裝方法中的一種,具體使用中,不同的開發者有不同的偏好。
對於LightKV而言,key的定義方法如下:
- 1.最好一個檔案對應一個統一定義key的類,如上面的“Keys”;
-
- key的賦值,按型別從1到65534都可以定義,然後和對應的DataType做“|”運算(解析的時候需要據此判斷型別);
-
- String和byte[]型別,如果需要混淆的話,再與DataType.ENCODE做“|”運算。
相對於SharePreferences,LightKV有更多的初始化選項,故而用構造者模式來構建物件。 下面逐一分析各個引數和對應的特性:
3.1 錯誤日誌
public interface Logger {
void e(String tag, Throwable e);
}
複製程式碼
大多陣列件都不能保證執行期不發生異常,發生異常時,開發者通常會把異常資訊列印到日誌檔案(有的還會上傳雲端)。 故此,LightKV提供了列印日誌介面,傳入實現類即可。
3.2 非同步載入
SharePreferences的載入是在新建立的的執行緒中進行的, 在完成載入之前,阻塞讀和寫: LightKV也實現了非同步載入和阻塞讀寫,但是實現相對簡單(至少筆者是這麼認為的-_-), 還有就是,使用者可以使用自己的執行緒池,也可以選擇不非同步載入(不傳Executor即可)。
private final Object mWaiter = new Object();
LightKV(Executor executor, /*省略其他引數*/) {
if (executor == null) {
getData(path, keyDefineClass);
} else {
synchronized (mWaiter) {
executor.execute(new Runnable() {
@Override
public void run() {
getData(path, keyDefineClass);
}
});
try {
// wait util loadData() get the object lock(lock of LightKV)
mWaiter.wait();
} catch (InterruptedException ignore) {
}
}
}
}
private synchronized void getData(String path, Class keyDefineClass) {
// we got the object lock, notify waiter to continue the procedure on that thread
synchronized (mWaiter) {
mWaiter.notify();
}
// loading data
}
public synchronized String getString(int key) {
// return value
}
複製程式碼
值得提醒的是,雖然提供了非同步載入,但是有時候沒有非同步載入的效果, 比如物件初始化的同時立即呼叫get或者put方法(會阻塞當前執行緒直到載入完成)。
建議寫法:
fun inti(context: Context) {
// 僅初始化物件,以觸發載入,不做get和put
AppData.data()
// 其他初始化工作
}
複製程式碼
3.3 混淆配置
上一章最後一節講到內容混淆,最後提到DEBUG時可以通過toString方法檢視檔案內容。 其實漏講了一些,比如說,構建LightKV時如果不傳入 Keys.class , 則無法檢視。 其原理為,構建LightKV時,如果傳入Keys.class, 可以通過反射獲取各個key的定義值以及名字, 這樣toString時就可以據此列印檔案內容了。
private SparseArray<String> mKeyArray;
private void getKeyArray(Class keyDefineClass) {
if (keyDefineClass == null) {
return;
}
Field[] fields = keyDefineClass.getDeclaredFields();
mKeyArray = new SparseArray<>(fields.length);
for (Field field : fields) {
if (field.getType() == int.class
&& Modifier.isPublic(field.getModifiers())
&& Modifier.isFinal(field.getModifiers())) {
mKeyArray.put(field.getInt(keyDefineClass), field.getName());
}
}
}
複製程式碼
在RELEASE版本,開發者通常會做程式碼混淆。 通常情況下,如果不做特別的混淆配置,常量會內聯到呼叫處,定義的地方會被“優化”掉。
public class a
{
// ......
// 原來的 Keys
public static abstract interface a {}
}
複製程式碼
因此,RELEASE版本呼叫toString看不到檔案的內容。 不過,如果APP被除錯了,還是可以從SparseArray中看到內容的……
傳入Keys.class還有另一個用途:
隨著程式碼的演進,有的key-value或許再也用不到了,但資料還是存在檔案中的。 有沒有什麼辦法在不用的時候從檔案中也一併移除呢? LightKV支援這種操作。 其原理是,在讀取檔案之前獲取Keys的key的定義值(前面有說到),讀取檔案的key|value時,有對應的key定義則存如SparseArray, 否則忽略。 等下一次重新整理資料,沒有定義的資料也就被刷掉了。 不過這個操作的前提是Keys的定義沒有被“優化”掉-_-如果需要該特性,可以配置“允許混淆”,但是保持成員:
-keepclassmembers,allowobfuscation interface **.Keys {*;}
-keepclassmembers,allowobfuscation interface **$Keys {*;}
複製程式碼
如此,最終生成沒有被“優化”但被混淆的“Keys”:
public static abstract interface a
{
public static final int a = 131073;
public static final int b = 1441793;
public static final int c = 1441794;
public static final int d = 1507329;
}
複製程式碼
如果你不需要檢視全部內容,也不在意空間佔用,可以不傳Keys.class,也無需額外的混淆配置。
四、評測
倉促之間,準備的測試用例可能不是很科學,僅供參考-_-
測試用例中,對支援的7種型別各配置5個key, 共35對key|value。 測試機器:小米 note 1, 16G儲存
儲存空間
儲存方式 | 檔案大小(kb) |
---|---|
AsyncKV | 4 |
SyncKV | 1.7 |
SharePreferences | 3.3 |
AsyncKV由於採用mmap的開啟方式,需要對映一塊磁碟空間到記憶體,為了減少碎片,故而一次對映一頁(4K)。 SyncKV由於儲存格式比較緊湊,所以檔案大小相比SharePreferences要小; 但是由於SyncKV採用雙備份,所以總大小和SharePreferences差不多。
資料量都少於4K時,其實三者相差無幾; 當儲存內容變多時,AsyncKV反而會更少佔用,因為其儲存格式和SyncKV一樣,但是隻用存一份。
載入效能
儲存方式 | 載入耗時(毫秒) |
---|---|
AsyncKV | 10.46 |
SyncKV | 1.56 |
SharePreferences | 4.99 |
前面也提到,mmap在開啟檔案比常規開啟檔案消耗更多,故而API文件中建議大檔案時才用mmap。 測試結果確實顯示mmap在讀取階段確實比較耗時,但是,如果開啟後頻繁寫入,那就體現出mmap的優勢了。
寫入效能
理想中的寫入是各組key|value全寫到記憶體,然後統一呼叫一次commit, 這樣寫入是最快的。 然而實際使用中,各組key|value的寫入通常是隨機的,所以下面測試結果,都是每次put後立即提交。 AsyncKV例外,因為其定位就是減少IO,讓系統核心自己去提交更新。
儲存方式 | 寫入耗時(毫秒) |
---|---|
AsyncKV | 2.25 |
SyncKV | 75.34 |
SharePreferences-apply | 6.90 |
SharePreferences-commit | 279.14 |
AsyncKV 和 SharePreferences-apply 這兩種方式,提交到記憶體後立即返回,所以耗時較少; SyncKV 和 SharePreferences-commit,都是在當前執行緒提交記憶體和磁碟,故而耗時較長。 無論是同步寫入還是非同步寫入,LightKV都要比SharePreferences快。
五、總結
SharePreferences是Android平臺輕量且方便的key-value儲存元件,然而不少可以改進的地方。 LightKV 以 SharePreferences 為參考,從效率,安全和細節方面,提供更好的儲存方式。
六、下載
repositories {
jcenter()
}
dependencies {
implementation 'com.horizon.lightkv:lightkv:1.0.1'
}
複製程式碼
專案地址:LightKV
參考文章:
http://www.cnblogs.com/mingfeng002/p/5970221.html https://blog.csdn.net/u010335298/article/details/72884644 https://cloud.tencent.com/developer/article/1066229 https://segmentfault.com/r/1250000007474916?shareId=1210000007474917 https://zhuanlan.zhihu.com/p/26697193 https://sites.google.com/site/murmurhash