优化-存储优化

Android分区

分区名 解释
/system 操作系统预留,用来存储系统文件和框架
/data 存储用户数据
/cache 系统升级过程使用的分区或者recovery
/vendor 用来存储厂商对Android系统的修改
/storage 外置或者内置sdcard
  • /system 分区:它是存放所有Google提供的Android组件的地方。这个分区只能以只读方式mount。这样主要是基于稳定性和安全性考虑,即使发生用户突然断点的情况,也依然需要保证/system分区的内容不会收到破坏和篡改。
  • /data 分区:它是所有用户数据存放的地方。主要是为了实现数据隔离,即系统升级和恢复的时候会擦除整个 /system 分区,但不会影响 /data 的用户数据。而恢复出厂设置,只会擦除 /data 的数据。
  • /vendor 分区:它是存放厂商特殊系统修改的地方。特别是在 Android 8.0 以后,隆重推出了 “Treble”项目。厂商 OTA 时可以只更新自己的 /vendor 分区即可,让厂商能够以更低的成本,更轻松、更快速地将设备更新到最新版 Android 系统。

Android 存储安全

除了分区隔离,存储安全也是 Android 系统非常重要的一部分,存储安全首先考虑的是权限控制。

第一,权限控制
Android 的每个应用都在自己的应用沙盒内运行,在 Android 4.3 之前的版本中,这些沙盒使用了标准 Linux 的保护机制,通过为每个应用创建独一无二的 Linux UID 来定义。简单来说,我们需要保证微信不能访问淘宝的数据,并且在没有权限的情况下也不能访问系统的一些保护文件。
在 Android 4.3 引入了SELinux(Security Enhanced Linux)机制进一步定义 Android 应用沙盒的边界。那它有什么特别的呢?它的作用是即使我们进程有 root 权限也不能为所欲为,如果想在 SELinux 系统中干任何事情,都必须先在专门的安全策略配置文件中赋予权限。

第二,数据加密

常见的数据存储方法

存储的数据格式可以是二进制的,也可以是XML、JSON、Protocol Buffer。
对于闪存来说,一切归根到底还是二进制的,XML、JSON它们只是提供了一套通用的二进制编解码格式规范。我们选择存储方法时,一般考虑以下的一些关键要素:

  1. 关键要素
关键要素 说明
正确性 选择存储方案的时候,第一个需要判断它是否靠谱。这套存储方案设计是否完备,有没有支持多线程或者跨进程同步操作。内部是否健壮,有没有考虑异常情况下数据的校验和恢复,比如采用双写或者备份文件策略,即使主文件因为系统底层导致损坏,也可以一定程度上恢复大部分数据
时间开销 这里说的时间开销包括了CPU时间和I/O时间,在I/O优化中我就多次提到相比CPU和内存,I/O存储额速度是非常慢的。但是如果存储方法中比如编解码或者加密/解密等设计的比较复杂,整个数据存储过程也会出现CPU时间变得更长的情况
空间开销 即使相同的数据如果使用不同的编码方式,最后占用的存储空间也会有所不同。举一个简单的例子,相同的数据所占的空间大小是XML>JSON>Protocol Buffer。除了编码方式的差异,在一些场景我们可能还需要引入压缩策略来进一步减少存储空间,例如zip、lzma等。数据存储的空间开销还需要考虑内存空间的占用量,整个存储过程会不会导致应用出现大量GC、OOM等
安全 应用中可能会有一些非常敏感的数据,即使它们存储在 /data/data 中,我们依然必须将它们加密。例如微信的聊天数据是存储在加密的数据库中,一些账号相关的数据我们也要单独做加密落地。根据加密强度的不同,可以选择RSA、AES、chacha20、TEA这些常用的加密算法
开发成本 有些存储方案看起来非常高大上,但是需要业务做很大改造才能接入。
兼容性 业务不停地向前演进,我们的存储字段或者格式有时候也会不得不有所变化。兼容性首先要考虑的是向前、向后的兼容性,老的数据在升级时能否迁移过来,新的数据在老版本能否降级使用。兼容性另外一个需要考虑的可能是多语言的问题,不同的语言是否支持转换

上述的要素哪个最重要,这个就得结合实际的应用场景来考虑了。

如果首要考虑的是正确性,那么可能需要采用冗余、双写等方案,那就要容忍对时间开销产生的额外影响。

同样如果非常在意安全,加解密环节的开销也必不可小。

如果想针对启动场景,我们希望选择在初始化时间和读取时间更有优势的方案。

  1. 存储选项
  • SharedPreferences
  • ContentProvider
  • 文件
  • 数据库

SharedPreferences

特点:

  1. 存储一些比较小的键值对集合。

  2. 存储一些非常简单、轻量的数据。(不要存HTML、JSON等过于复杂的数据。且每个SP文件不能过大。)

缺点:

  1. 跨进程不安全(有万分之一的损坏率)

  2. 加载缓慢(使用了异步线程,而加载线程并没有设置线程优先级,如果这个时候主线程读取数据就需要等待文件加载线程的结束。会出现主线程等待低优先级线程锁的问题,比如一个100KB的SP文件读取等待时间大约需要50~100ms,所以建议提前用异步线程预加载启动过程用到的SP文件。)

  3. 全量写入(无论调用commit()还是apply(),即使只改动一条,都会把整个内容全部写到文件。)

  4. 卡顿(由于提供了异步落盘的apply机制,在崩溃 或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用onPause等一些时机,系统会强制把所有的SharedPreferences对象数据落地到磁盘。如果没有落地完成,这时候主线程就会被一直阻塞。**这样非常容易造成卡顿,甚至是ANR,从线上数据来看SP卡顿占比一般会超过5%**)

针对SharedPreferences的问题,做些优化或替代方案

  1. 可以替换通过 复写 Application 的 getSharedPreferences 方法替换系统默认实现,比如优化卡顿、合并多次 apply 操作、支持跨进程操作等。

    1
    2
    3
    4
    5
    6
    public class MyApplication extends Application {
    @Override
    public SharedPreferences getSharedPreferences(String name, int mode) {
    return SharedPreferencesImpl.getSharedPreferences(name, mode);
    }
    }
  2. 上述的依然不能彻底解决问题。基本每个大公司都会自己研究一套替代的存储方案,比如微信开源的 MMKV

关键要素 SharedPrefences MMKV
正确性 差
跨进程和apply机制导致数据丢失
优
使用mmap和文件锁保证数据完整
时间开销 差
全量写入、卡顿
优
使用mmap;修改插入文件尾部,无需全量写入
安全 差
安全明文存储,没有支持加密和权限校验,不适合存放敏感数据
良
使用Protocol Buffer,不是完全明文。没有支持加密和权限校验,不适合存放敏感数据
开发成本 优
系统支持,非常简单
良
需要引入单独库,有一定的改造成本
兼容性 优
支持前后兼容
优
支持前后兼容,支持导入 SharedPrefences 历史数据,但注意转换后版本无法回退

ContentProvider 的使用

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

Android 系统中比如相册、日历、音频、视频、通讯录等模块都提供了 ContentProvider 的访问支持。它的使用十分简单,你可以参考官方文档。

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

  • 启动性能

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

    Android/存储优化1

    可能很多同学都不知道 ContentProvider 还有一个多进程模式,它可以和 AndroidManifest 中的 multiprocess 属性结合使用。这样调用进程会直接在自己进程里创建一个 push 进程的 Provider 实例,就不需要跨进程调用了。需要注意的是,这样也会带来 Provider 的多实例问题。

  • 稳定性

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

    Android/存储优化2

    正如我前面 I/O 优化所讲的,基于 mmap 的匿名共享内存机制也是有代价的。当传输的数据量非常小的时候,可能不一定划算。所以 ContentProvider 提供了一种 call 函数,它会直接通过 Binder 来传输数据。

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

  • 安全性

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

关键要素 ContentProvider
正确性 优
支持跨进程
时间开销 中
对启动也有影响,跨进程传递数据也有影响
空间开销 优
不限定数据内容
安全 优
系统支持,但开发较为复杂
兼容性 优
支持前后兼容

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

文件

数据库