Android 高质量开发之存储优化

数据的交互及存储是任何开发都绕不过的事情,Android平台涉及数据存储有:SharedPreferences存储数据、文件存储数据、SQLite数据库存储数据、使用ContentProvider存储数据、网络存储数据。

一、引言

Android 提供了很多种持久化存储的方案,存储就是把特定的数据结构转化成可以被记录和还原的格式,这个数据格式可以是二进制的,也可以是 XML、JSON、Protocol Buffer 这些格式。既然有那么多存储的方案,那我们在选择数据存储方法时,一般需要考虑哪些关键要素呢?

在选择数据存储方法时,一般会考虑下面这几要数:

  • 正确性
  • 时间开销
  • 空间开销
  • 安全
  • 开发成本
  • 兼容性

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

总的来说,我们需要结合应用场景选择合适的数据存储方法。

二、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 操作、支持跨进程操作等。具体:

public class MyApplication extends Application {
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode)
    {
        return SharedPreferencesImpl.getSharedPreferences(name, mode);
    }
}

对系统提供的 SharedPreferences 的小修小补虽然性能有所提升,但是依然不能彻底解决问题。

三、ContentProvider

为什么 Android 系统不把 SharedPreferences 设计成跨进程安全的呢?那是因为 Android 系统更希望我们在这个场景选择使用 ContentProvider 作为存储方式。ContentProvider 作为 Android 四大组件中的一种,为我们提供了不同进程甚至是不同应用程序之间共享数据的机制。

Android 系统中比如相册、日历、音频、视频、通讯录等模块都提供了 ContentProvider 的访问支持。它的使用也比较简单。

当然,在使用过程也需要注意以下几点。

3.1 启动性能

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

3.2 稳定性

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

Android 的 Binder 传输是有大小限制的,一般来说限制是 1~2 MB。ContentProvider 的接口调用参数和 call 函数调用并没有使用匿名共享机制,比如要批量插入很多数据,那么就会出现一个插入数据的数组,如果这个数组太大了,那么这个操作就可能会出现数据超大异常。

3.3 安全性

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

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


四、序列化

对于大部分的开发者来说,我们不一定有精力去“创造”一种数据序列化的格式。

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

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

4.1 Serializable

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

Serializable 的原理

Serializable 的原理是通过 ObjectInputStream 和 ObjectOutputStream 来实现的,ObjectOutputStream的部分源码实现:

private void writeFieldValues(Object obj, ObjectStreamClass classDesc)  {
    for (ObjectStreamField fieldDesc : classDesc.fields()) {
        ...
        Field field = classDesc.checkAndGetReflectionField(fieldDesc);
        ...
    }
    ...
}

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

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

Serializable 的进阶

既然 Serializable 性能那么差,那它有哪些优势呢?Serializable 序列化支持替代默认流程,它会先反射判断是否存在我们自己实现的序列化方法 writeObject 或反序列化方法 readObject。通过这两个方法,我们可以对某些字段做一些特殊修改,也可以实现序列化的加密功能。

writeReplace 和 readResolve 方法。这两个方法代理序列化的对象,可以实现自定义返回的序列化实例。那它有什么用呢?我们可以通过它们实现对象序列化的版本兼容,例如通过 readResolve 方法可以把老版本的序列化对象转换成新版本的对象类型。

Serializable 的序列化与反序列化的调用流程如下。

// 序列化
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 异常。

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

4.2 Parcelable

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

Parcelable 的永久存储

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

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

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

// 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);
}
Parcelable 的注意事项

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

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

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

  • 系统版本的兼容性。由于 Parcelable 设计本意是在内存中使用的,我们无法保证所有 Android 版本的Parcel.cpp实现都完全一致。如果不同系统版本实现有所差异,或者有厂商修改了实现,可能会存在问题。

  • 数据前后兼容性。Parcelable 并没有版本管理的设计,如果我们类的版本出现升级,写入的顺序及字段类型的兼容都需要格外注意,这也带来了很大的维护成本。

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

4.3 数据的序列化

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

JSON

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

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

  • 相比对象序列化方案,速度更快,体积更小。

  • 相比二进制的序列化方案,结果可读,易于排查问题。

  • 使用方便,支持跨平台、跨语言,支持嵌套引用。

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

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

便利性。例如支持 JSON 转换成 JavaBean 对象,支持注解,支持更多的数据类型等。

性能。减少反射,减少序列化过程内存与 CPU 的使用,特别是在数据量比较大或者嵌套层级比较深的时候效果会比较明显。

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

Protocol Buffers

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

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

总结一下它的优缺点:

  • 性能。使用了二进制编码压缩,相比 JSON 体积更小,编解码速度也更快,感兴趣的同学可以参考protocol-buffers 编码规则。

  • 兼容性。跨语言和前后兼容性都不错,也支持基本类型的自动转换,但是不支持继承与引用类型。

  • 使用成本。Protocol Buffers 的开发成本很高,需要定义.proto 文件,并用工具生成对应的辅助类。辅助类特有一些序列化的辅助方法,所有要序列化的对象,都需要先转化为辅助类的对象,这让序列化代码跟业务代码大量耦合,是侵入性较强的一种方式。

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

 /**
   * Protobuf enum {@code Transport}
   */
  public enum Transport
      implements com.google.protobuf.Internal.EnumLite {
    /**
     * <code>BLUETOOTH_LOW_ENERGY = 0;</code>
     */
    BLUETOOTH_LOW_ENERGY(0),
    /**
     * <code>BLUETOOTH_RFCOMM = 1;</code>
     */
    BLUETOOTH_RFCOMM(1),
    /**
     * <code>BLUETOOTH_IAP = 2;</code>
     */
    BLUETOOTH_IAP(2),
    UNRECOGNIZED(-1),
    ;

    /**
     * <code>BLUETOOTH_LOW_ENERGY = 0;</code>
     */
    public static final int BLUETOOTH_LOW_ENERGY_VALUE = 0;
    /**
     * <code>BLUETOOTH_RFCOMM = 1;</code>
     */
    public static final int BLUETOOTH_RFCOMM_VALUE = 1;
    /**
     * <code>BLUETOOTH_IAP = 2;</code>
     */
    public static final int BLUETOOTH_IAP_VALUE = 2;


    @Override
    public final int getNumber() {
      if (this == UNRECOGNIZED) {
        throw new IllegalArgumentException(
            "Can't get the number of an unknown enum value.");
      }
      return value;
    }

Google 后面还推出了压缩率更高的 FlatBuffers。

五、SQLite

前面讲到的存储方法的使用场景:少量的 Key Value 数据可以直接使用 SharedPreferences,稍微复杂一些的数据类型也可以通过序列化成 JSON 或者 Protocol Buffers 保存,并且在开发中获取或者修改数据也很简单。

不过这几种方法中,数据量在几百上千条这个量级时它们的性能还可以接受,但如果是几万条的呢?而且如何实现快速地对某几个联系人的数据做增删改查呢?

对于大数据的存储场景,我们需要考虑稳定性、性能和可扩展性,讲存储优化一定绕不开数据库,而数据库这个主题又非常大。那么考虑到我们大多是从事移动开发的工作,这里重点说说移动端数据库 SQLite 的使用和优化。

虽然市面上有很多的数据库,但受限于库体积和存储空间,适合移动端使用的还真不多。

5.1 ORM

坦白说可能很多 BAT 的高级开发工程师都不完全了解 SQLite 的内部机制,也不能正确地写出高效的 SQL 语句。大部分应用为了提高开发效率,会引入 ORM 框架。ORM(Object Relational Mapping)也就是对象关系映射,用面向对象的概念把数据库中表和对象关联起来,可以让我们不用关心数据库底层的实现。

Android 中最常用的 ORM 框架有开源 GreenDAO 和 Google 官方的 Room,那使用 ORM 框架会带来什么问题呢?

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

5.2 进程与线程并发

如果在项目中有使用 SQLite,那么下面这个SQLiteDatabaseLockedException就是经常会出现的一个问题。

android.database.sqlite.SQLiteDatabaseLockedException: database is locked
  at android.database.sqlite.SQLiteDatabase.dbopen
  at android.database.sqlite.SQLiteDatabase.openDatabase
  at android.database.sqlite.SQLiteDatabase.openDatabase

SQLiteDatabaseLockedException 归根到底是因为并发导致,而 SQLite 的并发有两个维度,一个是多进程并发,一个是多线程并发。下面我们分别来讲一下它们的关键点。

5.3 多进程并发

SQLite 默认是支持多进程并发操作的,它通过文件锁来控制多进程的并发。SQLite 锁的粒度并没有非常细,它针对的是整个 DB 文件,内部有 5 个状态。

简单来说,多进程可以同时获取 SHARED 锁来读取数据,但是只有一个进程可以获取 EXCLUSIVE 锁来写数据库。在 EXCLUSIVE 模式下,数据库连接在断开前都不会释放 SQLite 文件的锁,从而避免不必要的冲突,提高数据库访问的速度。

5.4 多线程并发

相比多进程,多线程的数据库访问可能会更加常见。SQLite 支持多线程并发模式,需要开启下面的配置,当然系统 SQLite 会默认开启多线程Multi-thread 模式。

跟多进程的锁机制一样,为了实现简单,SQLite 锁的粒度都是数据库文件级别,并没有实现表级甚至行级的锁。还有需要说明的是,同一个句柄同一时间只有一个线程在操作,这个时候我们需要打开连接池 Connection Pool。

如果使用 WCDB 在初始化的时候可以指定连接池的大小,在微信中我们设置的大小是 4。

public static SQLiteDatabase openDatabase (String path, 
                    SQLiteDatabase.CursorFactory factory, 
                    int flags, 
                    DatabaseErrorHandler errorHandler, 
                    int poolSize)

跟多进程类似,多线程可以同时读取数据库数据,但是写数据库依然是互斥的。

5.5 查询优化

说到数据库的查询优化,你第一个想到的肯定是建索引,那我就先来讲讲 SQLite 的索引优化。

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

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

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

  • 建立正确的索引。这里不仅需要确保索引在查询中真正生效,我们还希望可以选择最高效的索引。如果一个表建立太多的索引,那么在查询的时候 SQLite 可能不会选择最好的来执行。

  • 单列索引、多列索引与复合索引的选择。索引要综合数据表中不同的查询与排序语句一起考虑,如果查询结果集过大,还是希望可以通过复合索引直接在索引表返回查询结果。

  • 索引字段的选择。整型类型索引效率会远高于字符串索引,而对于主键 SQLite 会默认帮我们建立索引,所以主键尽量不要用复杂字段。

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

5.6 其他优化

关于 SQLite 的使用优化还有很多很多,例如:

  • 慎用“select*”,需要使用多少列,就选取多少列。

  • 正确地使用事务。

  • 预编译与参数绑定,缓存被编译后的 SQL 语句。

  • 对于 blob 或超大的 Text 列,可能会超出一个页的大小,导致出现超大页。建议将这些列单独拆表,或者放到表字段的后面。

  • 定期整理或者清理无用或可删除的数据,例如朋友圈数据库会删除比较久远的数据,如果用户访问到这部分数据,重新从网络拉取即可。

在日常的开发中,我们都应该对这些知识有所了解,通过引进 ORM,可以大大的提升我们的开发效率。通过 WAL 模式和连接池,可以提高 SQLite 的并发性能。通过正确的建立索引,可以提升 SQLite 的查询速度。通过调整默认的页大小和缓存大小,可以提升 SQLite 的整体性能。

5.7 SQLite 的其他特性

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

加密与安全

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

SQLite 的加解密都是以页为单位,默认会使用 AES 算法加密,加 / 解密的耗时跟选用的密钥长度有关。下面是WCDB Android Benchmark的数据,详细的信息请查看链接里的说明,从结论来说对 Create 来说影响会高达到 10 倍。

5.8 SQLite 的监控

首先我想说,正确使用索引,正确使用事务。对于大型项目来说,参与的开发人员可能有几十几百人,开发人员水平参差不齐,很难保证每个人都可以正确而高效地使用 SQLite,所以这次时候需要建立完善的监控体系。

本地测试

作为一名靠谱的开发工程师,我们每写一个 SQL 语句,都应该先在本地测试。我们可以通过 EXPLAIN QUERY PLAN 测试 SQL 语句的查询计划,是全表扫描还是使用了索引,以及具体使用了哪个索引等。

sqlite> EXPLAIN QUERY PLAN SELECT * FROM t1 WHERE a=1 AND b>2;
QUERY PLAN
|--SEARCH TABLE t1 USING INDEX i2 (a=? AND b>?)


关于 SQLite 命令行与 EXPLAIN QUERY PLAN 的使用,可以参考Command Line Shell For SQLite以及EXPLAIN QUERY PLAN。

耗时监控

本地测试过于依赖开发人员的自觉性,所以很多时候我们依然需要建立线上大数据的监控。我们想要监控某些特定的模块,可以通过这些接口监控数据库 busy、损耗以及执行耗时。针对耗时比较长的 SQL 语句,需要进一步检查是 SQL 语句写得不好,还是需要建立索引。

六、总结

数据存储是一个开发人员的基本功,如何在合适的场景选择合适的存储方法是存储优化的必修课,你应该学会通过正确性、时间开销、空间开销、安全、开发成本以及兼容性这六大关键要素来分解某个存储方法。

在设计某个存储方案的时候也是同样的道理,我们无法同时把所有的要素都做得最好,因此要学会取舍和选择,在存储的世界里不存在全局最优解,我们要找的是局部的最优解。这个时候更应明确自己的诉求,大胆牺牲部分关键点的指标,将自己场景最关心的要素点做到最好。