本文记录开发过程中常见存储方法的优缺点,希望可以在日常工作中如何做出更好的选择。

注意:如果出现图片不显示问题请移步 图片不显示问题

本文主要记载

  • 1 如何选择持久化存储方案?
    • 1.1 考察存储关键要素
    • 1.2 SharedPreferences
    • 1.3 ContentProvider
    • 1.4 SQLite 优化那些事儿
      • 1.4.1 OCR
      • 1.4.2 并发
      • 1.4.3 索引查询优化
      • 1.4.4 页大小与缓存大小
      • 1.4.5 其他优化
    • 1.5 SQLite 的其他特征
      • 1.5.1 损坏与恢复
      • 1.5.2 加密与安全
  • 2 如何优化数据存储?
    • 2.1 对象的序列化
      • 2.1.1 Serializable
        • Serializable 的原理
        • Serializable 的进阶
        • Serializable 的注意事项
      • 2.1.2 Parcelable
        • Parcelable 的永久存储
        • Parcelable 的注意事项
      • 2.1.3 Serial
    • 2.2 数据的序列化
      • 2.2.1 Json
      • 2.2.2 Protocol Buffers
  • 3 Demo 相关练习
    • 3.1 重写 SharedPreferencesImpl

1 如何选择持久化存储方案?

1.1 考察存储关键要素

  1. 正确性: 选择存储方案的时候,第一个需要判断它是否靠谱。这套存储方案设计是否完备,有没有支持多线程或者多进程操作。内部是否健壮,有没有考虑异常情况下数据的校验和恢复,比如采取双写或者备份文件策略,即使主文件因为系统底层导致损坏,也可以一定程度恢复大部分数据。

  2. 时间开销:这里说的时间开销包括了 CPU 时间和 I/O 时间,在 I/O 优化中我就多次提到相比 CPU 和内存,I/O 存储的速度是非常慢的。但是如果存储方法中比如编解码或者加密/解密等设计的比较复杂,整个数据存储过程也会出现 CPU 时间变的更长的情况。

  3. 控件开销:即使相同的数据如果使用不同的编码方式,最后占用的存储空间也会有所不同。举一个简单的例子,相同的数据所占的空间大小是 XML>JSON>Protocol Buffer。除了编码方式的差异,在一些场景我们可能还需要引入压缩策略来进一步减少存储空间,例如zip、Izma 等。数据存储的空间开销还需要考虑内存空间的占用量,整个存储过程会不会导致应用出现大量 GC 、OOM 等

  4. 安全:应用中可能会有一些非常敏感的数据,即使它们存储在 /data/data 中,我们依然必须将它们加密。例如微信的聊天数据是存储在加密的数据库中,一些账号相关的数据我们也要单独做加密落地。根据加密强度的不同,可以选择 RSA 、AES、chacha20、TEA这些常用的加密算法

  5. 开发成本:有些存储方案看起来非常高大上,但是需要业务做很大改造才能接入。这里我们当然希望能无缝的接入到业务中,在整个开发过程越简单越好

  6. 兼容性:业务不停地向前演进,我们的存储字段或者格式有时候也会不得不有所变化。兼容性首先要考虑的是向前、向后的兼容性,老的数据在升级时能否迁移过来,新的数据在老版本能否降级使用。兼容性另外一个需要考虑的可能是多语言的问题,不同的语言是否支持转换

数据存储方法不能脱离场景来考虑,我们不可能把这六个要素都做成最完美。如果首要考虑的是正确性,那我们可能需要采用冗余、双写等方案,那就要容忍对时间开销产生的额外影响。同样如果非常在意安全,加解密环节的开销也必不可小。如果想针对启动场景,我们希望选择在初始化时间和读取时间更有优势的方案。


1.2 SharedPreferences

SharedPreferences 使用非常简便,但也是诟病比较多的存储方法。

  1. 跨进程不安全:由于没有使用跨进程的锁,就算使用 MODE_MULTI_PROCESS,SharedPreferences 在跨进程频繁读写有可能导致数据全部丢失。根据线上统计,SP 大约会有万分之一的损坏率。
  2. 加载缓慢:SharedPreferences 文件的加载使用了异步线程,而且加载线程并没有设置线程优先级,如果这个时候主线程读取数据就需要等待文件加载线程的结束。这就导致出现主线程等待低优先级线程锁的问题,比如一个 100KB 的 SP 文件读取等待时间大约需要 50~100ms,我建议提前用异步线程预加载启动过程用到的 SP 文件。
  3. 全量写入:无论是调用 commit() 还是 apply(),即使我们只改动其中的一个条目,都会把整个内容全部写到文件。而且即使我们多次写入同一个文件,SP 也没有将多次修改合并为一次,这也是性能差的重要原因之一。
  4. 卡顿:由于提供了异步落盘的 apply 机制,在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用 onPause 等一些时机,系统会强制把所有的 SharedPreferences 对象数据落地到磁盘。如果没有落地完成,这时候主线程会被一直阻塞。这样非常容易造成卡顿,甚至是 ANR,从线上数据来看 SP 卡顿占比一般会超过 5%。

更多机制 彻底搞懂 SharedPreferences

更好的替代 SharedPreferences 的存储方案微信的 MMKV


1.3 ContentProvider

ContentProvider 作为 Android 四大组件中的一种,为我们提供了不同进程甚至是不同应用程序之间共享数据的机制。

为什么 Android 系统不把 SharedPreferences 设计成跨进程安全的呢?那是因为 Android 系统更希望我们在这个场景选择使用 ContentProvider 作为存储方式。

Android 系统中比如相册、日历、音频、视频、通讯录等模块都提供了 ContentProvider 的访问支持。具体使用你可以参考其他文章。

ContentProvider 的一些注意事项:

  1. 启动性能:ContentProvider 的生命周期默认在 Application onCreate() 之前,而且都是在主线程创建的。我们自定义的 ContentProvider 类的构造函数、静态代码块、onCreate 函数都尽量不要做耗时的操作,会拖慢启动速度。

  2. 稳定性:ContentProvider 在进行跨进程数据传递时,利用了 Android 的 Binder 和匿名共享内存机制。就是通过 Binder 传递 CursorWindow 对象内部的匿名共享内存的文件描述符。这样在跨进程传输中,结果数据并不需要跨进程传输,而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存,这样来实现不同进程访问相同数据的目的。

    基于 mmap 的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候,可能不一定划算。所以 ContentProvider 提供了一种 call 函数,它会直接通过 Binder 来传输数据。Android 的 Binder 传输是有大小限制的,一般来说限制是 1~2MB。ContentProvider 的接口调用参数和 call 函数调用并没有使用匿名共享机制,比如要批量插入很多数据,那么就会出现一个插入数据的数组,如果这个数组太大了,那么这个操作就可能会出现数据超大异常。

  3. 安全性:虽然 ContentProvider 为应用程序之间的数据共享提供了很好的安全机制,但是如果 ContentProvider 是 exported(是否可以被其他应用调用),当支持执行 SQL 语句时就需要注意 SQL 注入 的问题。另外如果我们传入的参数是一个文件路径,然后返回文件的内容,这个时候也要校验合法性,不然整个应用的私有数据都有可能被别人拿到,在 intent传递参数的时候可能经常会犯这个错误。

总的来说,ContentProvider 这套方案实现相对比较笨重,适合传输大的数据。


1.4 SQLite 优化那些事儿

1.4.1 OCR

说到 SQLite Android 中最常用的开源库有 greenDAO 和 Google 官方的 Room ,它们都是 ORM 框架

ORM(Object Relational Mapping)也就是对象关系映射,用面向对象的概念把数据库中表和对象关联起来,可以让我们不用关心数据库底层的实现。

ORM 框架会带来什么问题?

使用 ORM 框架真的非常简单,但是简易性是需要牺牲部分执行效率为代价的,具体的损耗跟 ORM 框架写得好不好很有关系。但可能更大的问题是思维固化,最后可能连简单的 SQL 语句都不会写了。

这里推荐微信的 WCDB

SQLite 优化:

1.4.2 并发

SQLite 锁机制简介 SQLite 封锁机制

为了进一步提高并发性能,我们还可以打开 WAL(Write-Ahead Logging)模式。WAL 模式会将修改的数据单独写到一个 WAL 文件中,同时也会引入了 WAL 日志文件锁。通过 WAL 模式读和写可以完全地并发执行,不会互相阻塞。

1
mSQLiteDataBase.rawExecSQL("PRAGMA schema.journal_mode = WAL;")

通过 连接池 与 WAL 模式,我们可以很大程度上增加 SQLite 的读写并发,大大减少由于并发导致的等待耗时。

1.4.3 索引查询优化

SQLite 索引的原理 MySQL 索引背后的数据结构及算法原理 官方文档 Query Planning

这里的关键在于如何正确的建立索引,很多时候我们以为已经建立了索引,但事实上并没有真正生效。例如使用了 BETWEEN、LIKE、OR 这些操作符、使用表达式或者 case when 等。

1
2
3
4
# BETWEEN:myfiedl索引无法生效
SELECT * FROM mytable WHERE myfield BETWEEN 10 and 20;
# 转换成:myfiedl索引可以生效
SELECT * FROM mytable WHERE myfield >= 10 AND myfield <= 20;

建立索引是有代价的,需要一直维护索引表的更新。比如对于一个很小的表来说就没必要建索引;如果一个表经常是执行插入更新操作,那么也需要节制的建立索引。总的来说有几个原则:

  • 建立正确的索引。这里不仅需要确保索引在查询中真正生效,我们还希望可以选择最高效的索引。如果一个表建立太多的索引,那么在查询的时候 SQLite 可能不会选择最好的来执行。
  • 单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑,如果查询结果集过大,还是希望可以通过复合索引直接在索引表返回查询结果。
  • 索引字段的选择。整型类型索引效率会远高于字符串索引,而对于主键 SQLite 会默认帮我们建立索引,所以主键尽量不要用复杂字段。

总的来说索引优化是 SQLite 优化中最简单同时也是最有效的,但是它并不是简单的建一个索引就可以了,有的时候我们需要进一步调整查询语句甚至是表的结构,这样才能达到最好的效果。

1.4.4 页大小与缓存大小

在 I/O 文件系统中,数据库就像一个小文件系统一样,事实上它内部也有页和缓存的概念。

跟文件系统的页缓存(Page Cache)一样,SQLite 会将读过的页缓存起来,用来加快下一次读取速度。页大小默认是 1024Byte,缓存大小默认是 1000 页。

1
2
mSQLiteDataBase.rawExecSQL("PRAGMA page_size = 1024;")
mSQLiteDataBase.rawExecSQL("PRAGMA cache_size = 1000;")

图片来源于 Android 开发高手课

注意:在微信的内部测试中,如果使用 4KB 的 page size 性能提升可以在 5%~10%。但是考虑到历史数据的迁移成本,最终还是使用 1024Byte。所以这里建议大家在新建数据库的时候,就提前选择 4KB 作为默认的 page size 以获得更好的性能。

1.4.5 其他优化

  • 慎用 select*,需要使用多少列,就选取多少列。
  • 正确地使用事务。
  • 预编译与参数绑定,缓存被编译后的 SQL 语句。
  • 定期整理或者清理无用或可删除的数据,例如朋友圈数据库会删除比较久远的数据,如果用户访问到这部分数据,重新从网络拉取即可。

总结:通过引进 ORM,可以大大的提升我们的开发效率。通过正确的建立索引,可以提升 SQLite 的查询速度。通过 WAL 模式和连接池,可以提高 SQLite 的并发性能。通过调整默认的页大小和缓存大小,可以提升 SQLite 的整体性能。


1.5 SQLite 的其他特征

除了 SQLite 的优化经验,还有一些其他的

1.5.1 损坏与恢复

微信 SQLite 数据库修复实践

微信移动端数据库组件WCDB系列(二) — 数据库修复三板斧

WCDB Android 数据库修复

1.5.2 加密与安全

数据库的安全主要有两个方面,一个是防注入,一个是加密。防注入可以通过静态安全扫描的方式,而加密一般会使用 SQLCipher 支持。

SQLite 的加解密都是以页为单位,默认会使用 AES 算法加密,加 / 解密的耗时跟选用的密钥长度有关。

WCDB 加解密的使用

1.5.3 全文搜索

图片来源于 Android 开发高手课

微信全文搜索优化之路

移动客户端多音字搜索

总结:关于 SQLite的这些特性,我们需要根据自己的项目情况综合考虑。假如某个数据库存储的数据并不重要,这个时候万分之一的数据损坏率我们并不会关心。同样是否需要使用数据库加密,也要根据存储的数据是不是敏感内容。

SQLite 源码分析

全面解析 SQLite


2 如何优化数据存储?

2.1 对象的序列化

应用程序中的对象存储在内存中,如果我们想把对象存储下来或者在网络上传输,这个时候就需要用到对象的序列化和反序列化。

对象序列化就是把一个 Object 对象所有的信息表示成一个字节序列,这包括 Class 信息、继承关系信息、访问权限、变量类型以及数值信息等。

2.1.1 Serializable

Serializable 是 Java 原生的序列化机制,在 Android 中也有被广泛使用。我们可以通过 Serializable 将对象持久化存储,也可以通过 Bundle 传递 Serializable 的序列化数据。

Serializable 的原理

Serializable 的原理是通过 ObjectInputStream 和 ObjectOutputStream 来实现的,以 Android 6.0 的源码为例,可以看到 ObjectOutputStream 的部分源码实现:

1
2
3
4
5
private void writeFieldValues(Object obj, ObjectStreamClass classDesc)  {
for (ObjectStreamField fieldDesc : classDesc.fields()) {
...
Field field = classDesc.checkAndGetReflectionField(fieldDesc);
...

整个序列化过程使用了大量的反射和临时变量,而且在序列化对象的时候,不仅会序列化当前对象本身,还需要递归序列化对象引用的其他对象。

整个过程计算非常复杂,而且因为存在大量反射和 GC 的影响,序列化的性能会比较差。另外一方面因为序列化文件需要包含的信息非常多,导致它的大小比 Class 文件本身还要大很多,这样又会导致 I/O 读写上的性能问题。

Serializable 的进阶

既然 Serializable 性能那么差,那它有哪些优势呢?可能很多同学都不知道它还有一些进阶的用法,你可以参考《Java 对象序列化,您不知道的 5 件事》 这篇文章。

自定义序列化:

  • writeObject 和 readObject 方法。Serializable 序列化支持替代默认流程,它会先反射判断是否存在我们自己实现的序列化方法 writeObject 或反序列化方法 readObject 。通过这两个方法,我们可以对某些字段做一些特殊修改,也可以实现序列化的加密功能。
  • writeReplace 和 readResolve 方法。这两个方法代理序列化的对象,可以实现自定义返回的序列化实例。那它有什么用呢?我们可以通过它们实现对象序列化的版本兼容,例如通过 readResolve 方法可以把老版本的序列化对象转换成新版本的对象类型。

Serializable 的序列化与反序列化的调用流程

1
2
3
4
5
6
7
// 序列化
E/test:SerializableTestData writeReplace
E/test:SerializableTestData writeObject

// 反序列化
E/test:SerializableTestData readObject
E/test:SerializableTestData readResolve

Serializable 的注意事项

Serializable 虽然使用非常简单,但是也有一些需要注意的事项字段。

  • 不被序列化的字段。类的 static 变量以及被声明为 transient 的字段,默认的序列化机制都会忽略该字段,不会进行序列化存储。当然我们也可以使用进阶的 writeReplace 和 readResolve 方法做自定义的序列化存储。

  • serialVersionUID。在类实现了 Serializable 接口后,我们需要添加一个 Serial Version ID,它相当于类的版本号。这个 ID 我们可以显式声明也可以让编译器自己计算。通常我建议显式声明会更加稳妥,因为隐式声明假如类发生了一点点变化,进行反序列化都会由于 serialVersionUID 改变而导致 InvalidClassException 异常。

    1
    private static final long serialVersionUID = 1234567890L;
  • 构造方法。Serializable 的反序列默认是不会执行构造函数的,它是根据数据流中对 Object 的描述信息创建对象的。如果一些逻辑依赖构造函数,就可能会出现问题,例如一个静态变量只在构造函数中赋值,当然我们也可以通过进阶方法做自定义的反序列化修改。

2.1.2 Parcelable

由于 Java 的Serializable 的性能较低,Android 需要重新设计一套更加轻量且高效的对象序列化和反序列化机制。Parcelable 正是在这个背景下产生的,它核心的作用就是为了解决 Android 中大量跨进程通信的性能问题。

Parcelable 的永久存储

Parcelable 的原理十分简单,它的核心实现都在 Parcel.cpp 。

你可以发现 Parcel 序列化和 Java 的 Serializable 序列化差别还是比较大的,Parcelable 只会在内存中进行序列化操作,并不会将数据存储到磁盘里。

当然我们也可以通过 Parcel.java 的 marshall 方法获取 byte 数组,然后存在文件中从而实现 Parcelable 的永久存储。

1
2
3
4
5
6
7
8
// Returns the raw bytes of the parcel.
public final byte[] marshall() {
return nativeMarshall(mNativePtr);
}
// Set the bytes in data to be the raw bytes of this Parcel.
public final void unmarshall(byte[] data, int offset, int length) {
nativeUnmarshall(mNativePtr, data, offset, length);
}
1
2
3
4
5
6
7
8
9
10
11
ProcessCpuTracker pct=new ProcessCpuTracker();
Parcel parcel = Parcel.obtain();
parcel.writeValue(pct);
byte[] bytes = parcel.marshall();
parcel.recycle();

Parcel thatParcel = Parcel.obtain();
thatParcel.unmarshall(bytes, 0, bytes.length);
thatParcel.setDataPosition(0);
ProcessCpuTracker that = (ProcessCpuTracker)thatParcel.readValue(ProcessCpuTracker.class.getClassLoader());
thatParcel.recycle();

Parcelable 的注意事项

在时间开销和使用成本的权衡上,Parcelable 机制选择的是性能优先。

所以它在写入和读取的时候都需要手动添加自定义代码,使用起来相比 Serializable 会复杂很多。但是正因为这样,Parcelable 才不需要采用反射的方式去实现序列化和反序列化。

虽然通过取巧的方法可以实现 Parcelable 的永久存储,但是它也存在两个问题。

  • 系统版本的兼容性。由于 Parcelable 设计本意是在内存中使用的,我们无法保证所有 Android 版本的 Parcel.cpp 实现都完全一致。如果不同系统版本实现有所差异,或者有厂商修改了实现,可能会存在问题。
  • 数据前后兼容性。Parcelable 并没有版本管理的设计,如果我们类的版本出现升级,写入的顺序及字段类型的兼容都需要格外注意,这也带来了很大的维护成本。

一般来说,如果需要持久化存储的话,一般还是不得不选择性能更差的 Serializable 方案。

2.1.3 Serial

直击痛点的方案,Twitter 开源的 Serial

图片来源于 Android 开发高手课

从图中数据上看来,Serial 在序列化与反序列化耗时,以及落地的文件大小都有很大的优势。从实现原理上看,Serial 就像是把 Parcelable 和 Serializable 的优点集合在一起的方案。

  • 由于没有使用反射,相比起传统的反射序列化方案更加高效,具体你可以参考上面的测试数据。
  • 开发者对于序列化过程的控制较强,可定义哪些 Object、Field 需要被序列化。
  • 有很强的 debug 能力,可以调试序列化的过程。
  • 有很强的版本管理能力,可以通过版本号和 OptionalFieldException 做兼容。

2.2 数据的序列化

Serial 性能看起来还不错,但是对象的序列化要记录的信息还是比较多,在操作比较频繁的时候,对应用的影响还是不少的,这个时候我们可以选择使用数据的序列化。

2.2.1 Json

JSON 是一种轻量级的数据交互格式,它被广泛使用在网络传输中,很多应用与服务端的通信都是使用 JSON 格式进行交互。

JSON 的确有很多得天独厚的优势,主要有:

  • 相比对象序列化方案,速度更快,体积更小。
  • 相比二进制的序列化方案,结果可读,易于排查问题。
  • 使用方便,支持跨平台、跨语言,支持嵌套引用。

因为每个应用基本都会用到 JSON,所以每个大厂也基本都有自己的 “ 轮子 ” 。例如 Android 自带的 JSON 库、Google 的 Gson 、阿里巴巴的 Fastjson 、美团的 MSON 。

各个自研的 JSON 方案主要在下面两个方面进行优化:

  • 便利性。例如支持 JSON 转换成 JavaBean 对象,支持注解,支持更多的数据类型等。
  • 性能。减少反射,减少序列化过程内存与 CPU 的使用,特别是在数据量比较大或者嵌套层级比较深的时候效果会比较明显。

图片来源于 Android 开发高手课

在数据量比较少的时候,系统自带的 JSON 库还稍微有一些优势。但在数据量大了之后,差距逐渐被拉开。总的来说,Gson 的兼容性最好,一般情况下它的性能与 Fastjson 相当。但是在数据量极大的时候,Fastjson 的性能更好。

2.2.2 Protocol Buffers

相比对象序列化方案,JSON 的确速度更快、体积更小。不过为了保证 JSON 的中间结果是可读的,它并没有做二进制的压缩,也因此 JSON 的性能还没有达到极致。

如果应用的数据量非常大,又或者对性能有更高的要求,此时 Protocol Buffers 是一个非常好的选择。它是 Google 开源的跨语言编码协议,Google 内部的几乎所有 RPC 都在使用这个协议。

下面我来总结一下它的优缺点。

  • 性能。使用了二进制编码压缩,相比 JSON 体积更小,编解码速度也更快,感兴趣的同学可以参考protocol-buffers 编码规则。
  • 兼容性。跨语言和前后兼容性都不错,也支持基本类型的自动转换,但是不支持继承与引用类型。
  • 使用成本。Protocol Buffers 的开发成本很高,需要定义 .proto 文件,并用工具生成对应的辅助类。辅助类特有一些序列化的辅助方法,所有要序列化的对象,都需要先转化为辅助类的对象,这让序列化代码跟业务代码大量耦合,是侵入性较强的一种方式。

对于 Android 来说,官方的 Protocol Buffers 会导致生成的方法数很多。我们可以修改它的自动代码生成工具,例如在微信中,每个 .proto 生成的类文件只会包含一个方法即 op 方法。

1
2
3
4
5
6
7
public class TestProtocal extends  com.tencent.mm.protocal.protobuf {
@Override
protected final int op(int opCode, Object ...objs) throws IOException {
if (opCode == OPCODE_WRITEFIELDS) {
...
} else if (opCode == OPCODE_COMPUTESIZE) {
...

Google 后面还推出了压缩率更高的 FlatBuffers,对于它的使用你可以参考《FlatBuffers 体验》。最后,我再结合 “六要素”,综合对比一下 Serial、JSON、Protocol Buffers 这三种序列化方案。

图片来源于 Android 开发高手课

3 Demo 相关练习

3.1 重写 SharedPreferencesImpl

Chapter12 SharedPreferencesImpl 这是一个简单的示例。

通过继承 SharedPreferences 重写 SharedPreferencesImpl ,并通过 Application 的 getSharedPreferences 方法,替换系统 SharedPreferences 的具体实现。还可以替换它的存储结构、增加加密等其他自定义功能。

感谢

极客时间 Android开发高手课