Android高手笔记 - 存储优化

存储基础

存储分区:

定义:
  • 将设备中的存储划分为一些互不重叠的部分,每个部分都可以单独格式化,用作不同的目的;
  • 不同的分区可以使用不同的文件系统;
分类
  • /system:
    • 操作系统预留,用来存储系统文件和框架;
    • 存放Google提供的Android组件
    • 以只读方式 mount,稳定,安全,内容不会收到破坏或篡改;
  • /data:
    • 存储用户数据
    • 目的是为了实现数据隔离,如系统升级和恢复时会擦除/system分区而不影响/data分区,恢复出厂设置则只会擦除/data分区数据;
  • /cache:
    • 系统升级过程使用的分区,或recovery
  • /vendor:
    • 存储厂商对Android系统的修改
    • Android8.0隆重推出了“Treble”项目;
    • 厂商 OTA 时可以只更新自己的 /vendor 分区,以更低的成本,更轻松、快速地将设备更新到新版 Android 系统;
  • /storage:
    • 外置或内置sdcard

存储安全

权限控制
  • 存储安全首先考虑的是权限控制;
  • 每个应用都在自己的应用沙盒内运行;
  • Android4.3以前使用标准Linux的保护机制(微应用创建唯一的Linux UID);
  • Android4.3引入SELinux机制进一步定义Android应用沙盒边界(即使进程有root权限也需要先在专美的安全策略配置文件中赋予权限);
数据加密
  • Android中有两种设备加密方式
    • 全盘加密:
      • Android4.4引入,5.0默认打开
      • 将/data分区数据加解密, 略影响性能,新版本芯片会在硬件中提供直接支持;
      • 基于文件系统的加密,一旦设备被解锁,加密也就没有了
    • 文件级加密
      • Android7.0
      • 给每个文件都分配一个必须用用户的 passcode 推导出来的密钥
  • 设备加密方法对应用程序来说是透明的,敏感数据仍需用 RSA、AES、chacha20,TEA 等加密存储

常见存储方法

  • 存储就是把特定数据结构转化成可以被记录和还原的格式(如二进制, XML, JSON, Protocol Buffer等格式);
  • 对于闪存,一切都是二进制

关键要素

  • 正确性:
    • 是否稳定健壮
    • 是否支持多线程或跨进程同步操作
    • 有无数据校验和恢复(如采用双写或备份文件策略)
  • 时间开销:
    • 包括CPU时间和IO时间,如编解码或加解密过于复杂,会影响CPU时间;
  • 空间开销:
    • 相同数据使用不同编码方式,占用的存储空间也会不同;(如 XML > JSON > Protocol Buffer)
    • 还可引入压缩策略进一步减小存储空间.如zip,lzma;
    • 还需考虑内存空间占用量,是否会导致大量GC,OOM等
  • 安全:
    • 一些敏感数据,即使存储在/data/data中,仍需加密
    • 根据敏感度,数据量大小的不同,选择不同的加密方式
  • 开发成本:
    • 有些存储方案虽高大上,但业务落地成本高,尽量做到无缝接入,缩减开发成本
  • 兼容性:
    • 要考虑 向前 & 向后 兼容,老数据在升级时是否能迁移过来,新数据在老版本能否降级使用
    • 不同的语言是否支持转换

存储方法

1. SharedPreferences
  • 用来存储一些比较小的键值对集合(简单,轻量)
  • 缺点:
    • 多线程安全,但是跨进程不安全:没有使用跨进程的锁,即使设置为Context.MODE_MULTI_PROCESS,跨进程频繁读写仍有可能导致数据全部丢失(因为Android更希望我们在跨进程场景选择ContentProvider作为存储方式);
    • 加载缓慢:sp文件加载使用了异步线程,且没有设置线程优先级,会出现主线程等待低优先级线程锁的问题(建议用异步线程预加载启动过程用到的sp文件,sp数据分门别类存储在多个文件中)
    • 全量写入:无论commit()还是apply(),即使只改动一条数据,都会把整个内容全部写到文件,而且多次修改并不会合并为一次提交;
    • 卡顿:由于提供异步落盘的apply机制,崩溃或突然断电等情况可能导致数据丢失,所以当收到系统广播或onPause等一些时机,系统会强制把所有sp对象落地到磁盘,数据量大时会阻塞主线程,造成卡顿甚至ANR;(apply是先写的内存,再异步存到xml文件;commit是直接同步写到文件)
  • 可以通过复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如优化卡顿、合并多次 apply 操作、支持跨进程操作等: 替换系统SharedPreferences的实现
  • 对系统提供的 SharedPreferences 的小修小补虽然性能有所提升,但是依然不能彻底解决问题。基本每个大公司都会自研一套替代的存储方案,比如微信开源的 MMKV;
//MMKV的使用:

//1. 添加依赖
implementation 'com.tencent:mmkv-static:1.2.7'
//2. 在Application中初始化
String rootDir = MMKV.initialize(this);
LjyLogUtil.d("mmkv root: " + rootDir);
//3. 使用
private void testMMKV() {
    MMKV kv = MMKV.defaultMMKV();

    kv.encode("bool", true);
    boolean bValue = kv.decodeBool("bool");

    kv.encode("int", Integer.MIN_VALUE);
    int iValue = kv.decodeInt("int");

    kv.encode("string", "Hello from mmkv");
    String str = kv.decodeString("string");
}
//4. 如果业务需要多进程访问,那么在初始化的时候加上标志位 MMKV.MULTI_PROCESS_MODE
MMKV mmkv = MMKV.mmkvWithID("InterProcessKV", MMKV.MULTI_PROCESS_MODE);
mmkv.encode("bool", true);
//5. SharedPreferences 迁移
private void testImportSharedPreferences() {
    //SharedPreferences preferences = getSharedPreferences("myData", MODE_PRIVATE);
    MMKV preferences = MMKV.mmkvWithID("myData");
    // 迁移旧数据
    {
        SharedPreferences old_man = getSharedPreferences("myData", MODE_PRIVATE);
        preferences.importFromSharedPreferences(old_man);
        old_man.edit().clear().commit();
    }
    // 跟以前用法一样
    SharedPreferences.Editor editor = preferences.edit();
    editor.putBoolean("bool", true);
    editor.putInt("int", Integer.MIN_VALUE);
    editor.putLong("long", Long.MAX_VALUE);
    editor.putFloat("float", -3.14f);
    editor.putString("string", "hello, imported");
    HashSet set = new HashSet();
    set.add("W"); set.add("e"); set.add("C"); set.add("h"); set.add("a"); set.add("t");
    editor.putStringSet("string-set", set);
    // 无需调用 commit()
    //editor.commit();
}
2. ContentProvider
  • 四大组件之一,提供不同进程甚至不同应用程序之间共享数据的机制;
  • Android 系统中,如相册、日历、音频、视频、通讯录等模块都提供了 ContentProvider 的访问支持;
  • 启动性能:
    • ContentProvider 的生命周期默认在 Application onCreate() 之前,而且都是在主线程创建的。我们自定义的 ContentProvider 类的构造函数、静态代码块、onCreate 函数都尽量不要做耗时的操作,会拖慢启动速度。
  • 多进程模式:
    • 和 AndroidManifest 中的 multiprocess 属性结合使用。这样调用进程会直接在自己进程里创建一个 push 进程的 Provider 实例,就不需要跨进程调用了。需要注意的是,这样也会带来 Provider 的多实例问题
  • 稳定性:
    • 利用了 Android 的 Binder 和匿名共享内存机制。
    • 通过 Binder 传递 CursorWindow 对象内部的匿名共享内存的文件描述符。这样在跨进程传输中,结果数据并不需要跨进程传输,而是在不同进程中通过传输的匿名共享内存文件描述符来操作同一块匿名内存,这样来实现不同进程访问相同数据的目的。
    • 基于 mmap 的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候,可能不一定划算。所以 ContentProvider 提供了一种 call 函数,它会直接通过 Binder 来传输数据。
    • Android 的 Binder 传输是有大小限制的,一般来说限制是 1~2MB。ContentProvider 的接口调用参数和 call 函数调用并没有使用匿名共享机制,比如要批量插入很多数据,那么就会出现一个插入数据的数组,如果这个数组太大了,那么这个操作就可能会出现数据超大异常。
  • 安全性
    • 虽然 ContentProvider 为应用程序之间的数据共享提供了很好的安全机制,但是如果 ContentProvider 是 exported,当支持执行 SQL 语句时就需要注意 SQL 注入的问题。另外如果我们传入的参数是一个文件路径,然后返回文件的内容,这个时候也要校验合法性,不然整个应用的私有数据都有可能被别人拿到,在 intent 传递参数的时候可能经常会犯这个错误。
    • 支持权限校验,支持应用间的数据传递
  • 正确性:支持跨进程
  • 时间开销:对启动和跨进程传递数据有影响
  • 空间开销:不限制数据内容
  • 开发成本:系统支持,但开发较为复杂
  • 兼容性:支持前后兼容
  • 相对比较笨重,适合传输较大数据

对象的序列化

1. Serializable

  • Java 原生的序列化机制
  • 可以通过 Serializable 将对象持久化存储,也可以通过 Bundle 传递 Serializable 的序列化数据。
  • 原理:通过 ObjectInputStream 和 ObjectOutputStream 实现
  • 不仅要序列化当前对象,还要递归序列化对象引用的其他对象;
  • 因为存在大量反射和GC影响,性能较差
  • 信息多导致文件大,又会导致IO读写性能问题
  • writeObject 和 readObject:
    • Serializable 序列化支持替代默认流程,它会先反射判断是否存在我们自己实现的序列化方法 writeObject 或反序列化方法 readObject。通过这两个方法,我们可以对某些字段做一些特殊修改,也可以实现序列化的加密功能
  • writeReplace 和 readResolve:
    • 代理序列化的对象,可以实现自定义返回的序列化实例。可以通过它们实现对象序列化的版本兼容,例如通过 readResolve 方法可以把老版本的序列化对象转换成新版本的对象类型。
  • 不被序列化的字段:
    • 类的 static 变量以及被声明为 transient 的字段,默认的序列化机制都会忽略该字段,不会进行序列化存储。当然我们也可以使用进阶的 writeReplace 和 readResolve 方法做自定义的序列化存储。
  • serialVersionUID:
    • 在类实现了 Serializable 接口后,我们需要添加一个 Serial Version ID,它相当于类的版本号。这个 ID 我们可以显式声明也可以让编译器自己计算。通常我建议显式声明会更加稳妥,因为隐式声明假如类发生了一点点变化,进行反序列化都会由于 serialVersionUID 改变而导致 InvalidClassException 异常。
  • 序列化允许重构:
    • 序列化允许一定数量的类变种,甚至重构之后也是如此, ObjectInputStream 仍可以很好地将其读出来。
      • 将新字段添加到类中
      • 将字段从 static 改为非 static
      • 将字段从 transient 改为非 transient
  • 序列化并不安全:
    • 序列化二进制格式完全编写在文档中,并且完全可逆;当通过 RMI 进行远程方法调用时,通过连接发送的对象中的任何 private 字段几乎都是以明文的方式出现在套接字流中;
    • 序列化允许 “hook” 序列化过程,并在序列化之前和反序列化之后保护(或模糊化)字段数据(重写writeObject,readObject)
      private void writeObject(java.io.ObjectOutputStream stream)
              throws java.io.IOException {
          // "Encrypt"/obscure the sensitive data
          age = age << 2;
          stream.defaultWriteObject();
      }
      
      private void readObject(java.io.ObjectInputStream stream)
              throws java.io.IOException, ClassNotFoundException {
          stream.defaultReadObject();
      
          // "Decrypt"/de-obscure the sensitive data
          age = age << 2;
      }
    
  • 序列化的数据可以被签名和密封:
    • 通过使用 writeObject 和 readObject 可以实现密码加密和签名管理,但其实还有更好的方式。
    • 最简单的是将它放在一个 javax.crypto.SealedObject 和/或 java.security.SignedObject 包装器中。两者都是可序列化的,所以将对象包装在 SealedObject 中可以围绕原对象创建一种 “包装盒”。必须有对称密钥才能解密,而且密钥必须单独管理。
    • 同样,也可以将 SignedObject 用于数据验证,并且对称密钥也必须单独管理。
  • 序列化允许将代理放在流中
    • 如果首要问题是序列化,那么最好指定一个 flyweight 或代理放在流中。为原始 Person 提供一个 writeReplace 方法,可以序列化不同类型的对象来代替它。类似地,如果反序列化期间发现一个 readResolve 方法,那么将调用该方法,将替代对象提供给调用者
    //使用代理 , writeReplace 和 readResolve
      /**
       * @author LiuJinYang
       * @date 2020/3/23
       */
      public class Person implements Serializable {
          private String name;
          private int age;
      
          public Person() {
      
          }
      
          public Person(String name, int age) {
              if (age < 0)
                  throw new IllegalArgumentException("age can't < 0");
              this.name = name;
              this.age = age;
          }
      
          public String getName() {
              return name;
          }
      
          public void setName(String name) {
              this.name = name;
          }
      
          public int getAge() {
              return age;
          }
      
          public void setAge(int age) {
              this.age = age;
          }
      
          private static class PersonProxy implements Serializable {
              private final String name;
              private final int age;
      
              public PersonProxy(Person person) {
                  this.name = person.getName();
                  this.age = person.getAge();
              }
      
              private Object readResolve() {
                  Person person = new Person(name, age);
                  return person;
              }
          }
      
          private Object writeReplace() {
              return new PersonProxy(this);
          }
      
          //此方法不会执行,
          private void writeObject(ObjectOutputStream out) {
              System.out.println("Person.writeObject()");
          }
      
          //防止攻击者伪造数据违反约束条件
          private Object readObject(ObjectInputStream in) throws InvalidObjectException {
              System.out.println("Person.readObject()");
              throw new InvalidObjectException("Proxy required");
          }
      }
    
    • 这种技巧是少数几种不需要读/写平衡的技巧之一。例如,一个类被重构成另一种类型后的版本可以提供一个 readResolve 方法,以便静默地将被序列化的对象转换成新类型。类似地,它可以采用 writeReplace 方法将旧类序列化成新版本。
  • 考虑用序列化代理代替序列化实例
    • 序列化代理方法可以阻止伪字节流攻击以及内部域的盗用攻击;
  • 信任,但要验证
    • 对于序列化的对象,这意味着验证字段,以确保在反序列化之后它们仍具有正确的值,”以防万一”。为此,可以实现 ObjectInputValidation 接口,并覆盖 validateObject() 方法。如果调用该方法时发现某处有错误,则抛出一个 InvalidObjectException。
  • 谨慎的实现Serializable接口
    • 虽然直接开销很低,但长期开销却很大
  • 保护性的编写readObject方法
    • 当一个对象被反序列化时,对于客户端不应该拥有的对象引用,如果哪个域包含了这样的对象引用, 就必须要做保护性拷贝,这是非常重要的;
  • 对于实例控制,枚举类型优先于readResolve
    • 如果依赖readResolve进行实例控制,带有对象引用类型的所有实例域则都必须声明为transient的(或者基本类型);(否则攻击者可能在readResolve运行前,保护指向反序列化对象的引用)

2. Externalizable

  • 继承自 Serializable 接口,并定义了 writeExternal 和 readExternal 这两个方法,从而让开发者对序列化过程拥有更多的控制权,方便的实现自定义操作,同时可以实现一些使用 Serializable 接口无法实现的功能;
  • 例如实现 Serializable 接口的对象,其中 static 和 transient 类型的成员变量默认是不会被序列化的,而通过实现 Externalizable 接口开发者可以对 static 和 transient 类型的成员变量进行手动序列化的。
  • Externalizable 接口的序列化机制跟 Serializable 接口一样,都是基于反射机制的,性能方面也是比较差的。

3. Parcelable

  • Parcelable 只会在内存中进行序列化操作,并不会将数据存储到磁盘里
  • 在时间开销和使用成本的权衡上,Parcelable 机制选择的是性能优先。
    • 所以它在写入和读取的时候都需要手动添加自定义代码,使用起来相比 Serializable 会复杂很多。但是正因为这样,Parcelable 才不需要采用反射的方式去实现序列化和反序列化。
存在两个问题
  1. 系统版本的兼容性
    • 我们无法保证所有 Android 版本的Parcel.cpp实现都完全一致。如果不同系统版本实现有所差异,或者有厂商修改了实现,可能会存在问题。
  2. 数据前后兼容性
    • Parcelable 并没有版本管理的设计,如果我们类的版本出现升级,写入的顺序及字段类型的兼容都需要格外注意,这也带来了很大的维护成本。
  • 所以一般来说,如果需要持久化存储的话,一般还是不得不选择性能更差的 Serializable 方案。
Serializable与Parcelable区别
  • Serializable:Java 序列化接口,在硬盘上读写,读写过程中有大量临时变量的生成,内部执行大量的i/o操作,效率很低。
  • Parcelable:Android 序列化接口,效率高, 使用麻烦, 在内存中读写(AS有相关插件 一键生成所需方法),对象不能保存到磁盘中;

4. Serial(推荐)

  • Twitter 开源的高性能序列化方案Serial
  • 优点
    • 序列化与反序列化耗时,以及落地的文件大小都有很大的优势;
    • 由于没有使用反射,相比起传统的反射序列化方案更加高效;
    • 开发者对于序列化过程的控制较强,可定义哪些 Object、Field 需要被序列化;
    • 有很强的 debug 能力,可以调试序列化的过程;
    • 有很强的版本管理能力,可以通过版本号和 OptionalFieldException 做兼容;
  • 使用
//1. 将一个对象序列化为byte[]
final Serial serial = new ByteBufferSerial();
final byte[] serializedData = serial.toByteArray(object, ExampleObject.SERIALIZER)
//2. 将对象从byte[]反序列化为object
final ExampleObject object = serial.fromByteArray(serializedData, ExampleObject.SERIALIZER)
//目前库中默认提供的序列化实现类是ByteBufferSerial,它的产物是byte[]。使用者也可以自行更换实现类,不用拘泥于byte[]。

//3. 定义Serializer
//需要给每个被序列化的对象单独定义一个Serializer
//Serializers中需要给每个field明确的定义write和read操作,对于有继承关系的序列化类,需要被递归的进行定义
//Serializers是无状态的,所以我们可以将其写为object的内部类,并通过 SERIALIZER 作为名称来访问它
public static class ExampleObject {

    public static final ObjectSerializer SERIALIZER = new ExampleObjectSerializer();

    public final int num;
    public final SubObject obj;

    public ExampleObject(int num, @NotNull SubObject obj) {
        this.num = num;
        this.obj = obj;
    }

    ...

    private static final class ExampleObjectSerializer extends ObjectSerializer {
        @Override
        protected void serializeObject(@NotNull SerializationContext context, @NotNull SerializerOutput output,
                @NotNull ExampleObject object) throws IOException {
            output
                .writeInt(object.num) // first field
                .writeObject(object.obj, SubObject.SERIALIZER); // second field
        }

        @Override
        @NotNull
        protected ExampleObject deserializeObject(@NotNull SerializationContext context, @NotNull SerializerInput input,
                int versionNumber) throws IOException, ClassNotFoundException {
            final int num = input.readInt(); // first field
            final SubObject obj = input.readObject(SubObject.SERIALIZER); // second field
            return new ExampleObject(num, obj);
        }
    }
}
//这个内部类和 parcelable 中的 Parcelable.Creator 极为相似,都是按顺序对变量进行读写操作。
public static final Parcelable.Creator CREATOR = new Creator() {

    @Override
    public Person createFromParcel(Parcel source) {
        Person person = new Person();
        person.mName = source.readString();
        person.mSex = source.readString();
        person.mAge = source.readInt();
        return person;
    }

    //供反序列化本类数组时调用的方法
    @Override
    public Person[] newArray(int size) {
        return new Person[size];
    }
};

//4.BuilderSerializer
//通过builder模式构建的类或是有多个构造方法的类,可以使用 BuilderSerializer 来做序列化。
//只需要继承 BuilderSerializer ,并实现 createBuilder 方法(仅return当前class的builder即可)
//和 deserializeToBuilder 方法(在这个方法中可以得到builder对象,这里将那些反序列化完毕的参数重新设置给builder)
public static class ExampleObject {
    ...

    public ExampleObject(@NotNull Builder builder) {
        this.num = builder.mNum;
        this.obj = builder.mObj;
    }

    ...

    public static class Builder extends ModelBuilder {
        ...
    }

    private static final class ExampleObjectSerializer extends BuilderSerializer {
        @Override
        @NotNull
        protected Builder createBuilder() {
            return new Builder();
        }

        @Override
        protected void serializeObject(@NotNull SerializationContext context, @NotNull SerializerOutput output,
                @NotNull ExampleObject object) throws IOException {
            output.writeInt(object.num)
                .writeObject(object.obj, SubObject.SERIALIZER);
        }

         @Override
        protected void deserializeToBuilder(@NotNull SerializationContext context, @NotNull SerializerInput input,
                @NotNull Builder builder, int versionNumber) throws IOException, ClassNotFoundException {
            builder.setNum(input.readInt())
                .setObj(input.readObject(SubObject.SERIALIZER));
        }
    }
}
//5. 通过版本号这个字段来处理新老版本的问题
final Serializer SERIALIZER = new ExampleObjectSerializer(1);
...

@Override
@NotNull
protected ExampleObject deserializeObject(@NotNull SerializationContext context, @NotNull SerializerInput input, int versionNumber) throws IOException, ClassNotFoundException {
    final int num = input.readInt();
    final SubObject obj = input.readObject(SubObject.SERIALIZER);
    final String name;
    if (versionNumber < 1) {
        name = DEFAULT_NAME;
    } else {
        name = input.readString();
    }
    return new ExampleObject(num, obj, name);
}
//6. 简单参数的序列化
//像 Integer 、 String 、 Size、Rect 等对象本身就十分简单,所以无需进行版本控制。
//而使用 ObjectSerializer 会让这些对象添加2-3字节的信息。
//所以,当不需要版本控制的时候,使用 ValueSerializer 是一个最佳选择:
public static final Serializer BOOLEAN = new ValueSerializer() {
    @Override
    protected void serializeValue(@NotNull SerializationContext context, @NotNull SerializerOutput output, @NotNull Boolean object) throws IOException {
        output.writeBoolean(object);
    }

    @NotNull
    @Override
    protected Boolean deserializeValue(@NotNull SerializationContext context, @NotNull SerializerInput input) throws IOException {
        return input.readBoolean();
    }
};
  • Serial 跟 Serializable(Externalizable)的关键区别是性能,跟 Parcelable 的关键区别是能够序列化到的介质

数据的序列化

1. JSON

优点
  • 相比对象序列化方案,速度更快,体积更小
  • 相比二进制的序列化方案,结果可读,易于排查问题
  • 使用方便,支持跨平台、跨语言,支持嵌套引用
json库
  • Android 自带的 JSON 库、Google 的Gson、阿里巴巴的Fastjson、美团的MSON。
  • 各种库主要从两方面做优化
    • 便利性:例如支持 JSON 转换成 JavaBean 对象,支持注解,支持更多的数据类型等;
    • 性能:减少反射,减少序列化过程内存与 CPU 的使用,特别是在数据量比较大或者嵌套层级比较深的时候效果会比较明显;
  • Gson 的兼容性最好,一般情况下它的性能与 Fastjson 相当, 但是在数据量极大的时候,Fastjson 的性能更好。

2. Protocol Buffers

  • 性能:使用了二进制编码压缩,相比 JSON 体积更小,编解码速度也更快
  • 兼容性:跨语言和前后兼容性都不错,也支持基本类型的自动转换,但是不支持继承与引用类型。
  • 使用成本:Protocol Buffers 的开发成本很高,需要定义.proto 文件,并用工具生成对应的辅助类。(耦合,侵入性强)
  • Google 后面还推出了压缩率更高的 FlatBuffers

数据库

  • 讲存储优化一定绕不开数据库
  • 对于大数据的存储场景,我们需要考虑稳定性、性能和可扩展性
  • 对于数据库,在移动端使用最多的是SQLite,当然也有其他一些如创业团队的Realm、Google 的LevelDB等

ORM

  • 可能很多高级开发工程师都不完全了解 SQLite 的内部机制,也不能正确地写出高效的 SQL 语句;
  • 大部分应用为了提高开发效率,会引入 ORM 框架;
  • Object Relational Mapping, 也就是对象关系映射,用面向对象的概念把数据库中表和对象关联起来;(不用关注底层数据库实现)
  • 最常用的 ORM 框架有开源greenDAO和 Google 官方的Room;其具体使用可以参考我的另一篇文章组件化架构 - 5. 数据存储 & GreenDao,Room,这里就不过多介绍了;
ORM 框架带来的问题
  1. ORM框架使用非常简单,但是以牺牲部分执行效率为代价的;
  2. 让开发者思维固化,最后可能连简单的 SQL 语句都不会写了;

WCDB

  • 微信团队开源的WCDB是一个高效、完整、易用的移动数据库框架,基于SQLCipher,支持iOS, macOS和Android。
  • 使用
//1. 添加依赖(通过 Maven 接入)
 implementation 'com.tencent.wcdb:wcdb-android:1.0.0'

//2. 选择接入的 CPU 架构
android {
    defaultConfig {
        ndk {
            // 只接入 armeabi-v7a 和 x86 架构
            abiFilters 'armeabi-v7a', 'x86'
        }
    }
}

//3. 迁移到 WCDB
//WCDB Android 使用与 Android SDK SQLite 框架几乎一样的接口,
//如果你的 APP 之前使用 Android SDK 的数据库接口,
//只需要将 import 里的 android.database.* 改为 com.tencent.wcdb.*,
//以及 android.database.sqlite.* 改为 com.tencent.wcdb.database.* 即可。 
//若之前使用 SQLCipher Android Binding,也需要对应修改 import。

//4. 从非加密数据库迁移到加密数据库
//需要使用 SQL 函数 sqlcipher_export() 进行迁移
ATTACH 'old_database' AS old;
SELECT sqlcipher_export('main', 'old');   -- 从 'old' 导入到 'main'
DETACH old;

//5. 从 SQLCipher Android 迁移
//关键改动点为 密码转换为byte[] 以及 传入SQLiteCipherSpec描述加密方式
String passphrase = "passphrase";
SQLiteCipherSpec cipher = new SQLiteCipherSpec()  // 加密描述对象
    .setPageSize(1024)        // SQLCipher 默认 Page size 为 1024
    .setSQLCipherVersion(3);  // 1,2,3 分别对应 1.x, 2.x, 3.x 创建的 SQLCipher 数据库
    // 如以前使用过其他PRAGMA,可添加其他选项
SQLiteDatabase db = SQLiteDatabase.openOrCreateDatabase(
    "path/to/database",     // DB 路径
    passphrase.getBytes(),  // WCDB 密码参数类型为 byte[]
    cipher,                 // 上面创建的加密描述对象
    null,                   // CursorFactory
    null                    // DatabaseErrorHandler
    // SQLiteDatabaseHook 参数去掉了,在cipher里指定参数可达到同样目的
);

//6. 更好的与 Android Jetpack 的组件互动
//WCDB 现在已经正式介入 Room 以提供 ORM 以及数据绑定的功能,并能与 Android Jetpack 其他组件互动。
//6.1 在接入 Room 的基础上,gradle 里加上 WCDB 的 room 组件
dependencies {
    implementation 'com.tencent.wcdb:room:1.0.8'  // 代替 room-runtime,同时也不需要再引用 wcdb-android
    annotationProcessor 'android.arch.persistence.room:compiler:1.1.1' // compiler 需要用 room 的
}
//6.2 代码里面,打开 RoomDatabase 时,指定 WCDBOpenHelperFactory 作为 openFactory
SQLiteCipherSpec cipherSpec = new SQLiteCipherSpec()  // 指定加密方式,使用默认加密可以省略
        .setPageSize(4096)
        .setKDFIteration(64000);

WCDBOpenHelperFactory factory = new WCDBOpenHelperFactory()
        .passphrase("passphrase".getBytes())  // 指定加密DB密钥,非加密DB去掉此行
        .cipherSpec(cipherSpec)               // 指定加密方式,使用默认加密可以省略
        .writeAheadLoggingEnabled(true)       // 打开WAL以及读写并发,可以省略让Room决定是否要打开
        .asyncCheckpointEnabled(true);        // 打开异步Checkpoint优化,不需要可以省略

AppDatabase db = Room.databaseBuilder(this, AppDatabase.class, "app-db")
                //.allowMainThreadQueries()   // 允许主线程执行DB操作,一般不推荐
                .openHelperFactory(factory)   // 重要:使用WCDB打开Room
                .build();
// 其他使用与 Room 一样

//7. WCDB 在初始化的时候可以指定连接池的大小
public static SQLiteDatabase openDatabase (String path, 
                    SQLiteDatabase.CursorFactory factory, 
                    int flags, 
                    DatabaseErrorHandler errorHandler, 
                    int poolSize)

进程与线程并发

  • 使用 SQLite经常会遇到SQLiteDatabaseLockedException,归根到底是因为并发导致,SQLite 的并发有两个维度,一个是多进程并发,一个是多线程并发;
多进程并发
  • SQLite 默认是支持多进程并发操作的(通过文件锁控制),SQLite 锁的粒度并没有非常细,它针对的是整个 DB 文件,可以参考
    • SQLite锁机制简介
    • Sqlite学习笔记(五)&&SQLite封锁机制
  • 简单来说,多进程可以同时获取 SHARED 锁来读取数据,但是只有一个进程可以获取 EXCLUSIVE 锁来写数据库;
  • 在 EXCLUSIVE 模式下,数据库连接在断开前都不会释放 SQLite 文件的锁,从而避免不必要的冲突,提高数据库访问的速度。
多线程并发
  • SQLite 支持多线程并发模式,需要开启下面的配置,当然系统 SQLite 会默认开启多线程Multi-thread 模式
PRAGMA SQLITE_THREADSAFE = 2
  • 跟多进程的锁机制一样,为了实现简单,SQLite 锁的粒度都是数据库文件级别,并没有实现表级甚至行级的锁。
  • 同一个句柄同一时间只有一个线程在操作,这个时候我们需要打开连接池 Connection Pool。
  • 跟多进程类似,多线程可以同时读取数据库数据,但是写数据库依然是互斥的
  • SQLite 提供了 Busy Retry 的方案,即发生阻塞时会触发 Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作
  • 为了进一步提高并发性能,我们还可以打开WAL(Write-Ahead Logging)模式。WAL 模式会将修改的数据单独写到一个 WAL 文件中,同时也会引入了 WAL 日志文件锁。通过 WAL 模式读和写可以完全地并发执行,不会互相阻塞。
PRAGMA schema.journal_mode = WAL
  • 但是需要注意的是,写之间是仍然不能并发。如果出现多个写并发的情况,依然有可能会出现 SQLiteDatabaseLockedException。这个时候我们可以让应用中捕获这个异常,然后等待一段时间再重试。
...
} catch (SQLiteDatabaseLockedException e) {
    if (sqliteLockedExceptionTimes < (tryTimes - 1)) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e1) {
        }
    }
    sqliteLockedExceptionTimes++;
}
- 总的来说通过连接池与 WAL 模式,我们可以很大程度上增加 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;
  • 建立索引是有代价的,需要一直维护索引表的更新
    • 建立正确的索引: 确保生效,且要高效
    • 单列索引、多列索引与复合索引的选择;
    • 索引字段的选择:整型效率远高于字符串,主键会默认建立索引,所以不要用复杂字段做主键;
页大小与缓存大小
  • 数据库就像一个小文件系统,内部也有页和缓存的概念;
    • 页:最小存储单位
    • 属于同一个表不同的页以 B 树(B-tree)的方式组织索引,每一个表都是一棵 B 树
    • 每个页永远只存放一个表或者一组索引的数据,即不可能同一个页存放多个表或索引的数据
  • SQLite 会将读过的页缓存起来,用来加快下一次读取速度。页大小默认是 1024Byte,缓存大小默认是 1000 页;
    • 增大 page size 并不能不断地提升性能,在拐点以后可能还会有副作用
    • 建议在新建数据库时,就提前选择 4KB 作为默认的 page size 以获得更好的性能。
其他优化
  • 慎用“select*”,需要使用多少列,就选取多少列
  • 正确地使用事务
  • 预编译与参数绑定,缓存被编译后的 SQL 语句
  • 对于 blob 或超大的 Text 列,可能会超出一个页的大小,导致出现超大页。建议将这些列单独拆表,或者放到表字段的后面。
  • 定期整理或者清理无用或可删除的数据(如果用户访问到这部分数据,重新从网络拉取即可)

损坏与恢复

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

加密与安全

  • 数据库的安全主要有两个方面,一个是防注入,一个是加密
    • 防注入可以通过静态安全扫描
    • 加密一般会使用 SQLCipher 支持,SQLite 的加解密都是以页为单位,默认会使用 AES 算法加密,加 / 解密的耗时跟选用的密钥长度有关。
    • 关于 WCDB 加解密的使用,可以参考: 微信移动数据库组件 WCDB(四) — Android 特性篇

全文搜索

  • 微信全文搜索优化之路
  • 移动客户端多音字搜索

监控

  • 每写一个 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>?)
  • WCDB 增加了SQLiteTrace的监控模块
  • 微信开源的 Matrix 里面有一个智能化分析 SQLite 语句的工具:Matrix SQLiteLint – SQLite 使用质量检测。它根据分析 SQL 语句的语法树,结合我们日常数据库使用的经验,抽象出索引使用不当、select*等六大问题。
  • 美团也开源了它们内部的 SQL 优化工具 SQLAdvisor
    • SQL解析在美团的应用
    • 美团点评SQL优化工具SQLAdvisor开源
Matrix SQLiteLint
  • 接入Matrix SQLiteLint,查看是否存在不合理的 SQLite 使用。
//1. 添加依赖
debugImplementation "com.tencent.matrix:matrix-sqlite-lint-android-sdk:${MATRIX_VERSION}"
releaseImplementation "com.tencent.matrix:matrix-sqlite-lint-android-sdk-no-op:${MATRIX_VERSION}"
//2. Application的onCreate中调用下面方法
private void prepareSQLiteLint() {
    SQLiteLintPlugin plugin = (SQLiteLintPlugin) Matrix.with().getPluginByClass(SQLiteLintPlugin.class);
    if (plugin == null) {
        return;
    }
    plugin.addConcernedDB(new SQLiteLintConfig.ConcernDb(getWritableDatabase())
            .setWhiteListXml(R.xml.sqlite_lint_whitelist)
            .enableAllCheckers());
}

参考

  • Android开发高手课-存储优化(上):常见的数据存储方法有哪些?
  • 应用沙盒
  • Android 中的安全增强型 Linux
  • 设备加密
  • 全盘加密
  • 文件级加密
  • Android开发高手课-存储优化(中):如何优化数据存储?
  • 替换系统SharedPreferences的实现
  • MMKV
  • Java 对象序列化
  • EffectiveJava-10-序列化
  • Serial
  • 05 | Twitter 的高性能序列化框架 Serial(一)基本用法和概念
  • Google Protocol Buffer 的使用和原理
  • FlatBuffers 体验
  • Android开发高手课-存储优化(下):数据库SQLite的使用和优化
  • 微信WCDB进化之路 - 开源与开始
  • WCDB
  • 组件化架构 - 5. 数据存储 & GreenDao,Room
  • SQLite锁机制简介
  • Sqlite学习笔记(五)&&SQLite封锁机制
  • 微信iOS SQLite源码优化实践
  • sqlite索引的原理
  • MySQL索引背后的数据结构及算法原理
  • SQlite源码分析
  • 全面解析SQLite.pdf
  • Matrix SQLiteLint – SQLite 使用质量检测
  • SQL解析在美团的应用
  • 美团点评SQL优化工具SQLAdvisor开源
  • 《SQLite 权威指南(第 2 版)》

我是今阳,如果想要进阶和了解更多的干货,欢迎关注微信公众号 “今阳说” 接收我的最新文章

你可能感兴趣的:(Android高手笔记 - 存储优化)