一文读懂数据存储方法的六个关键要素
常见的数据存储方法
Android 为我们提供了很多种持久化存储的方案, 在具体介绍它们之前,你需要先问一下自己, 什么是存储?
每个人可能都会有自己的答案,在我看来, 存储就是把特定的数据结构转化成可以被记录和还原的格式,这个数据格式可以是二进制的,也可以是 XML、JSON、Protocol Buffer 这些格式
对于闪存来说,一切归根到底还是二进制的,XML、JSON 它们只是提供了一套通用的二进制编解码格式规范。既然有那么多存储的方案,那我们在选择数据存储方法时, 一般需要考虑哪些关键要素呢?
1. 关键要素
在选择数据存储方法时,我一般会想到下面这几点:
那上面这些要素哪个最重要呢?数据存储方法不能脱离场景来考虑,我们不可能把这六个要素都做成最完美
我来解释一下这句话;如果首要考虑的是正确性,那我们可能需要采用冗余、双写等方案,那就要容忍对时间开销产生的额外影响。同样如果非常在意安全,加解密环节的开销也必不可小。如果想针对启动场景,我们希望选择在初始化时间和读取时间更有优势的方案
2. 存储选项
总的来说,我们需要结合应用场景选择合适的数据存储方法。那 Android 为应用开发者提供了哪些存储数据的方法呢?综合来看,有下面几种方法
- SharedPreferences
- ContentProvider
- 文件
- 数据库
今天我先来讲 SharedPreferences 和 ContentProvider 这两个存储方法
第一,SharedPreferences 的使用
SharedPreferences是 Android 中比较常用的存储方法, 它可以用来存储一些比较小的键值对集合;虽然 SharedPreferences 使用非常简便,但也是我们诟病比较多的存储方法。它的性能问题比较多,我可以轻松地说出它的 “七宗罪”
-
跨进程不安全;由于没有使用跨进程的锁,就算使用MODE_MULTI_PROCESS,SharedPreferences 在跨进程频繁读写有可能导致数据全部丢失。根据线上统计,SP 大约会有万分之一的损坏率
-
加载缓慢;SharedPreferences 文件的加载使用了异步线程,而且加载线程并没有设置线程优先级,如果这个时候主线程读取数据就需要等待文件加载线程的结束。这就导致出现主线程等待低优先级线程锁的问题,比如一个 100KB 的 SP 文件读取等待时间大约需要 50~100ms,我建议提前用异步线程预加载启动过程用到的 SP 文件
-
全量写入;无论是调用 commit() 还是 apply(),即使我们只改动其中的一个条目,都会把整个内容全部写到文件。而且即使我们多次写入同一个文件,SP 也没有将多次修改合并为一次,这也是性能差的重要原因之一
-
卡顿;由于提供了异步落盘的 apply 机制,在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用 onPause 等一些时机,系统会强制把所有的 SharedPreferences 对象数据落地到磁盘。如果没有落地完成,这时候主线程会被一直阻塞。这样非常容易造成卡顿,甚至是 ANR,从线上数据来看 SP 卡顿占比一般会超过 5%
坦白来讲,系统提供的 SharedPreferences 的应用场景是用来存储一些非常简单、轻量的数据; 我们不要使用它来存储过于复杂的数据,例如 HTML、JSON 等。而且 SharedPreference 的文件存储性能与文件大小相关,每个 SP 文件不能过大,我们不要将毫无关联的配置项保存在同一个文件中,同时考虑将频繁修改的条目单独隔离出来
我们也可以替换通过复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如 优化卡顿、合并多次 apply 操作、支持跨进程操作等; 具体如何替换呢?在今天的 Sample 中我也提供了一个简单替换实现
public class MyApplication extends Application {
@Override
public SharedPreferences getSharedPreferences(String name, int mode)
{
return SharedPreferencesImpl.getSharedPreferences(name, mode);
}
}
对系统提供的 SharedPreferences 的小修小补虽然性能有所提升,但是依然不能彻底解决问题; 基本每个大公司都会自研一套替代的存储方案,比如微信最近就开源了MMKV
下面是 MMKV 对于 SharedPreferences 的“六要素”对比
你可以参考 MMKV 的实现原理和性能测试报告,里面有一些非常不错的思路。例如利用文件锁保证跨进程的安全、使用 mmap 保证数据不会丢失、选用性能和存储空间更好的 Protocol Buffer 代替 XML、支持增量更新等
根据 I/O 优化的分析,对于频繁修改的配置使用 mmap 的确非常合适,使用者不用去理解 apply() 和 commit() 的差别,也不用担心数据的丢失。同时,我们也不需要每次都提交整个文件,整体性能会有很大提升
第二,ContentProvider 的使用
为什么 Android 系统不把 SharedPreferences 设计成跨进程安全的呢? 那是因为 Android 系统更希望我们在这个场景选择使用 ContentProvider 作为存储方式。ContentProvider 作为 Android 四大组件中的一种,为我们提供了不同进程甚至是不同应用程序之间共享数据的机制
Android 系统中比如相册、日历、音频、视频、通讯录等模块都提供了 ContentProvider 的访问支持。它的使用十分简单
当然,在使用过程也有下面几点需要注意
- 启动性能
ContentProvider 的生命周期默认在 Application onCreate() 之前,而且都是在主线程创建的。我们自定义的 ContentProvider 类的构造函数、静态代码块、onCreate 函数都尽量不要做耗时的操作,会拖慢启动速度
可能很多同学都不知道 ContentProvider 还有一个多进程模式,它可以和 AndroidManifest 中的 multiprocess 属性结合使用。这样调用进程会直接在自己进程里创建一个 push 进程的 Provider 实例,就不需要跨进程调用了。需要注意的是,这样也会带来 Provider 的多实例问题。
- 稳定性
ContentProvider 在进行跨进程数据传递时,利用了 Android 的 Binder 和匿名共享内存机制。简单来说,就是通过 Binder 传递 CursorWindow 对象内部的匿名共享内存的文件描述符。这样在跨进程传输中,结果数据并不需要跨进程传输,而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存,这样来实现不同进程访问相同数据的目的
基于 mmap 的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候,可能不一定划算。所以 ContentProvider 提供了一种 call 函数,它会直接通过 Binder 来传输数据
Android 的 Binder 传输是有大小限制的,一般来说限制是 1~2MB。ContentProvider 的接口调用参数和 call 函数调用并没有使用匿名共享机制,比如要批量插入很多数据,那么就会出现一个插入数据的数组,如果这个数组太大了,那么这个操作就可能会出现数据超大异常。
- 安全性
虽然 ContentProvider 为应用程序之间的数据共享提供了很好的安全机制,但是如果 ContentProvider 是 exported,当支持执行 SQL 语句时就需要注意 SQL 注入的问题。另外如果我们传入的参数是一个文件路径,然后返回文件的内容,这个时候也要校验合法性,不然整个应用的私有数据都有可能被别人拿到,在 intent 传递参数的时候可能经常会犯这个错误。
最后我给你总结一下 ContentProvider 的“六要素”优缺点
总的来说,ContentProvider 这套方案实现相对比较笨重,适合传输大的数据
总结
话不多说,今天关于数据储存的相关内容就到这里了, 有想要了解更多 Android 进阶技术知识的朋友: 可以私信 发送 ”进阶“ 免费给大家提供一份 Android 进阶技术知识学习笔记;给大家 查漏补缺;攀登上更高的高峰
就像文章中所说的 虽然 SharedPreferences 和 ContentProvider 都是我们日常经常使用的存储方法,但是里面的确会有大大小小的暗坑; 所以我们需要充分了解它们的优缺点,这样在工作中可以更好地使用和优化
如何在合适的场景选择合适的存储方法是存储优化的必修课,你应该学会通过 正确性、时间开销、空间开销、安全、开发成本以及兼容性这六大关键要素 来分解某个存储方法
在设计某个存储方案的时候也是同样的道理,我们无法同时把所有的要素都做得最好,因此要学会
取舍和选择; 在存储的世界里不存在全局最优解,我们要找的是局部的最优解; 这个时候更应明确自己的诉求,大胆牺牲部分关键点的指标,将自己场景最关心的要素点做到最好
加油!各位 Android 开发者们