LightKV是基於Java NIO的輕量級,高性能,高可靠的key-value存儲組件。html
Android平臺常見的本地存儲方式, SDK內置的有SQLite,SharedPreference等,開源組件有ACache, DiskLruCahce等,有各自的特色和適用性。
SharedPreference以其自然的 key-value API,二級存儲(內存HashMap, 磁盤xml文件)等特色,爲廣大開發者所青睞。
然而,任何工具都是有適用性的,參見文章《不要濫用SharedPreference》。
固然,其中一些缺點是其定位決定的,好比說不適合存儲大的key-value, 這個無可厚非;
不過有一些地方能夠改進,好比存儲格式:xml解析速度慢,空間佔用大,特殊字符須要轉義等特色,對於高頻變化的存儲,實非良策。
故此,有必要寫一個改良版的key-value存儲組件。java
咱們但願文件能夠流式解析,對於簡單key-value形式,徹底能夠自定義格式。
例如,簡單地依次保存key-value就好:
key|value|key|value|key|value……git
關於value類型,咱們須要支持一些經常使用的基礎類型:boolean, int, long, float, double, 以及String 和 數組(byte[])。
尤爲是後者,更多的複合類型(好比對象)均可以經過String和數組轉化。
做爲底層的組件,支持最基本的類型能夠簡化複雜度。
對於String和byte[], 存儲時先存長度,再存內容。github
咱們觀察到,在實際使用中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時爲無效,讀取時會跳過。segmentfault
SharePreference相對於ACache,DiskLruCache等多了一層內存的存儲,因而他們的定位也就涇渭分明瞭:
後者一般用於存儲大對象或者文件等,他們只負責提供磁盤存儲,至於讀到內存以後若是使用和管理,則不是他們的職責了。
太大的對象會佔用太多的內存,而SharePreference是長期持有引用,沒有空間限制和淘汰機制的,所以SharePreference適用於「輕量級存儲」, 而由此所帶來的收益就是讀取速度很快。
LightKV定位也是「輕量級存儲」,因此也會在內存中存儲key-value,只不過這裏用SparseArray來存儲。數組
上面提到, 存儲格式是簡單地key-value依次排列:
key|value|key|value|key|value……
這樣存放,讀取時能夠流式地解析,甚至,寫入時能夠增量寫入。
緩存
新增:在尾部追加key|value便可;
刪除:爲了不字節移動,能夠用標記的方法——將key的最高位標記爲1;
修改:若是value長度不變,尋址到對應的位置,寫入value便可;不然,先「刪除」,再「新增」;
GC: 解析文件內容時記錄刪除的內容的長度,大於設定閾值則清空文件,作一次全量寫入。安全
要想增量修改文件,須要具有隨機寫入的能力:
Java NIO會是不錯的選擇,甚至,能夠用mmap(內存映射文件)。
mmap還有一些優勢:
一、直接操做內核空間:避免內核空間和用戶空間之間的數據拷貝;
二、自動定時刷新:避免頻繁的磁盤操做;
三、進程退出時刷新:系統層面的調用,不用擔憂進程退出致使數據丟失。app
若是要說不足,就是在映射文件階段比常規的IO的打開文件消耗更多。
因此API中建議大文件時採用mmap,小文件的讀寫用建議用常規IO;而網上介紹mmap也可能是舉例大文件的拷貝。
事實上若是小文件是高頻寫入的話,也是值得一試的,
好比騰訊的日誌組件 xlog 和 存儲組件 MMKV, 都用了mmap。
mmap的寫入方式其實相似於異步寫入,只是不須要本身開線程去刷數據到磁盤,而是由操做系統去調度。
這樣的方式有利有弊:好處是寫入快,減小磁盤損耗;
缺點就是,和SharePreference的apply同樣,不具有原子性,沒有入原子性,一致性就得不到保障。
好比,數據寫入內存後,在數據刷新到磁盤以前,發生系統級錯誤(如系統崩潰)或設備異常(如斷電,磁盤損壞等),此時會丟失數據;
若是寫入內存後,刷入磁盤前,有別的代碼讀取了剛纔寫入的內存,就有可能致使數據不一致。
不過,一般狀況下,發生系統級錯誤和設備異常的機率較低,因此仍是比較可靠的。
對於一些核心數據,咱們但願用更可靠的方式存儲。
怎麼定義可靠呢?
首先原子性是要有的,因此只能同步寫入了;
而後是可用性和完整性:
程序異常,系統異常,或者硬件故障等均可能致使數據丟失或者錯誤;
需添加一些機制確保異常和故障發生時數據仍然完整可用。
查看SharedPreference源碼,其容錯策略是,
寫入前重命名主文件爲備份文件的名字,成功寫入則刪除備份文件,
而打開文件階段,若是發現有備份文件,將備份文件重命名爲主文件的名字。
從而,假如寫入數據時發生故障,再次重啓APP時能夠從備份文件中恢復數據。
這樣的容錯策略,整體來講是不錯的方案,能保證大多數據狀況下的數據可用性。
咱們沒有采用該方案,主要是考慮該方案操做相對複雜,以及其餘一些顧慮。
咱們採用的策略是:冗餘備份+數據校驗。
冗餘備份來提升數據數據可用性的思想在不少地方有體現,好比 RAID 1 磁盤陣列。
一樣,咱們能夠經過一分內存寫兩個文件,這樣當一個文件失效,還有另一個文件可用。
比方說一個文件失效的機率時十萬分之一,則兩個文件同時失效的機率是百億分之一。
總之,冗餘備份能夠大大減小數據丟失的機率。
有得必有失,其代價就是雙倍磁盤空間和寫入時間。
不過咱們的定位是「輕量級存儲」,若是隻存「核心數據」,數據量不會很大,因此總的來講收益大於代價。
就寫入時間方面,相比SharedPreference而言,重命名和刪除文件也是一種IO,其本質是更新文件的「元數據」。
寫磁盤以頁(page)爲單位,一頁一般爲4K。
向文件寫入1個字節和2497字節,在磁盤寫入階段是等價的(都須要佔用4K的字節)。
數據量較少時,寫入兩份文件,相比於「重命名->寫數據->刪除文件」的操做,區別不大。
數據校驗的方法一般是對數據進行一些的運算,將運算結果放在數據後;讀取時作一樣運算,而後和以前的結果對比。
常見的方法有奇偶校驗,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用同一份便可;
二者的特色在前面「方案一」和「方案二」中有所闡述了,根據具體需求靈活使用便可。
對於用XML來存儲的SharePreferences來講,打開其文件便可一覽全部key-value,
即便開發者對value進行編碼,key仍是能夠看到的。
SharePreferences的文件不是存在App下的目錄,在沙盒之中嗎?
無root權限下,對於其餘應用(非系統),沙盒確實是不可訪問的;
可是對於APP逆向者(黑色產業?)來講,SharePreferences文件不過是囊中之物,或可從中一窺APP的關鍵,以助其破解APP。
故此,混淆內容文件,或可增長一點破解成本。
對於APP來講,沒有絕對的安全,只是破解成本與收益之間的博弈,這裏就很少做展開了。
LightKV因爲採用流式存儲,並且key是用int類型,因此不容易看出其文件內容;
可是若是value是明文字符串,仍是能夠看到部份內容的,以下圖:
LightKV提供了混淆value(String和byte[]類型)的接口:
public interface Encoder {
byte[] encode(byte[] src);
byte[] decode(byte[] des);
}
複製代碼
開發者能夠按照本身的規則實現編碼和解碼。
經過該接口能夠作不少擴展:
混淆後,打開文件,都是亂碼。
值得一提的是,只能對String和byte[]類型的value混淆。
由於基礎類如long, double等,以二進制形式寫入,用文本的形式打開,本就是很差閱讀的,無需再做混淆。
前面咱們看到,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 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();
}
}
複製代碼
固然,以上只是衆多封裝方法中的一種,具體使用中,不一樣的開發者有不一樣的偏好。
對於LightKV而言,key的定義方法以下:
一、最好一個文件對應一個統必定義key的類,如上面的「Keys」;
二、key的賦值,按類型從1到65534均可以定義,而後和對應的DataType作「|」運算(解析的時候須要據此判斷類型)。
相對於SharePreferences,LightKV有更多的初始化選項,故而用構造者模式來構建對象。 下面逐一說明各個參數和對應的特性。
若須要對value混淆,只需在構造LightKV時傳入Encoder,
而後聲明key時和DataType.ENCODE作「|」運算便可。
保存和讀取時,LightKV會將key和DataType.ENCODE作「&」運算,若不爲0,則調用Encoder進行編碼(保存)或解碼(讀取)。
SharePreferences的加載在新建立的的線程中加載的, 在完成加載以前阻塞讀和寫:
LightKV一樣實現了異步加載, 並且能夠指定 Executor,固然也能夠選擇不異步加載(不傳Executor便可)。
須要提醒的是,雖然提供了異步加載,可是有時候沒有異步加載的效果。
好比對象初始化的同時當即調用get或者put方法,會阻塞當前線程直到加載完成,這樣和同步加載沒什麼區別。
建議寫法,在進程初始化的時候調用data(), 以觸發數據的加載:
fun inti(context: Context) {
// 僅初始化對象,不作get和put
AppData.data()
// 其餘初始化工做
}
複製代碼
public interface Logger {
void e(String tag, Throwable e);
}
複製代碼
大多數組件都不能保證運行期不發生異常,發生異常時,開發者一般會把異常信息打印到日誌文件(有的還會上傳雲端)。
故此,LightKV提供了打印日誌接口,傳入實現類便可。
在Builder的最後,調用 sync() 和 async() 可分辨建立AsyncKV和SyncKV。
各自的特色前面也交代過了,靈活選取便可。
若是不是存一些十分重要的數據(好比賬號信息等),用AsyncKV便可。
寫完初始化參數,定義好key, 編寫 get 和 set方法以後,
就能夠訪問數據了:
String account = AppData.getString(AppData.Keys.ACCOUNT)
if(TextUtils.isEmpty(account)){
AppData.putString(AppData.Keys.ACCOUNT, "foo@gmail.com")
}
複製代碼
藉助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。
測試機器:小米 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爲參考,從效率,安全和易用性等方面,提供更好的存儲方式。
dependencies {
implementation 'com.horizon.lightkv:lightkv:1.0.7'
}
複製代碼
項目地址: github.com/No89757/Lig…
參考文章: www.cnblogs.com/mingfeng002… cloud.tencent.com/developer/a… segmentfault.com/r/125000000… www.jianshu.com/p/ad9756fe2… www.jianshu.com/p/07664dc4c…