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…