优化App的持久化策略

转载自'小专栏'RyRYanZhong'

这个 session 覆盖了 app 储存文件的方方面面, 对于经常需要写入沙盒的 app来说, 提供了很多好的 guideline, 以及底层原理的分析.

使用HEIC格式图片

苹果建议我们本地的图片切换成使用 HEIC 格式这种更高效的图片格式, HEIC格式本身比jpeg小50%, 因此下载和上传都更快, 在磁盘中存取也更快, 同时也支持透明度和无损压缩, 在单个 HEIC 图片容器中可支持储存多个图片.

将图片放入asset catalog

asset catalog 原生支持了 app slicing, 可以根据下载机型的不同, slice 出对应的图片放入安装包中, 而不是直接将2x, 3x等倍图全部打入安装包, 可以有效减少包体积. 另外image的加载也会更快, 尤其是启动时, apple 声称可以比不使用 asset catalog 的情况提升10%的图片加载速度, 所以使用 heic 加 asset catalog 组合, 会有奇效

文档数据元数据 File system metadata

image

档 metadata 的数据写入经常会发生, 而且 IO 开销是很大的, 例如你的 app 中有一个 plist 文件记录上一次的启动时间, 每次启动 app, 都读取该 plist 获取上次启动时间, 然后写入当前时间这个简单的操作会发生一次读取 IO, 三次写入 IO, 还有一次 fsync() 的操作, 并且以下行为都会造成 File system metadata 写入

  • 创建文件
  • 删除文件
  • 重命名文件
  • 更新文件

而 File system metadata 包括以下元素

  • 文件名
  • 大小
  • 地理位置
  • 修改时间
  • 等等....

例如当我们写一个240byte的 NSDictionary 到文件时, 首先是 update file system tree


image

基于写时复制 (copy on write)策略, 不会马上更新 file system tree 的结点, 而是创建一个结点的拷贝


image

每一次操作, 都会有自己的 transaction id, 这个写操作就会生成一个新的结点, ID也会被更新, 一个简单的写入240byte的数据进入disk的时候, 会同步导致以下数据的更新. 包括:
更新file system node (4k),
更新object map (4k),
metadata总大小: 8k
文件本身: 本身是240byte, 但ios的写入文件最小单位是4k
所以总共是12k
因此每次更新数据到 disk 里都是有代价的, 如果我们只是需要创建一些临时数据, 例如字典, 数组这类数据, 建议不要把这些数据直接写入到磁盘中, 直接在内存中使用并销毁就可以了, 如果这类原始数据有持久化的需求, 应该通过一个中间类来统一管理内存写入到 disk 的逻辑, 尽量减少你的app需要的文件数量

syncing to disk

OS cache: 性能最好的一层, 使用 logical I/O, 由于是储存在内存中, 所以 I/O操作很高效 (使用logical I/O)
Disk cache: 磁盘储存的物理映射. (使用 physical I/O)
permanent storage: 最终用于持久化数据的介质, 对于iOS来说, 就是闪存 (使用 physical I/O)
缓存有以上几个层级, 对于 app 来说, 离 cpu 越近的 cache, 性能就越好, 但同时我们也希望cache能确实地落在磁盘中. 数据在内存当中时对于app而言速度是最快的, 也没有任何的 IO 开销, 但是当我们需要将数据从内存一层一层地注入到闪存时, 就需要注意 IO 开销了.

下面介绍几个将数据从 OS cache 层逐步 flush 到 Permanent Storage 的函数


image

fsync()

该函数用于将数据从 OS cache 层写入到 Disk cache 层, 但数据可能不是立即写到 permanent storage 层, 如果没有代码的明确指令, 实际上是由设备的固件决定数据什么时候从 disk cache 进入 permanent cache, 并且写入的顺序是没有保障的, 从OS cache写入 Disk cache 的顺序并不决定从 disk cache 进入 permanent cache的顺序 因此fsync()的过度使用是昂贵的, 这个函数是会直接导致 IO 的发生, 其实我们没有必要手动显式地调用这个函数, 因为 OS 本身会周期性的调用fsync()来写入数据, 因为大多数情况下没有必要手动触发fsync()

FULLSYNC

该函数用于将数据从 OS cache 层写入到 permanent cache 层, 并且会触发所有已经存在于 disk cache 上的数据写入到 permanen cache, 并且OS本身会周期性的调用该函数, 因此理由同上, 大多数情况下没有必要手动触发

文件序列化格式的选择

开发者一般会使用 Plist, XML, JSON 这三个常见的格式, 这些都是常见的数据格式, 便于使用而且普适性高, 也易于解析, 适合不是频繁读写的数据, 但是每次改动都是全量的读写, 导致整个文件读取和重新写入, 就会引起上面所说的从OS cache层到 permanent cache 层的IO操作, 即使你写入一个很小的数据, 由于文件本身携带的 meta data 操作, 也可能会产生数据量是写入data本身几倍大的IO开销

举个例子

以下是 file activity instruments 监控我们创建, 读取, 和更新上述这个 plist 时, 引起了12个独立的 IO 操作


image

面是单单的更新plist操作, 调用了系统的writeToFile函数, 最后再调用栈上系统为我们调用了fsync, 所以数据就会直接由OS cache层一直写入到 Disk cache 层, 并从 OS cache 层被清除, 如果在写入后我们仍然要继续使用数据, 就会失去了OS cache这一层的缓存, 而需要重新开启IO去磁盘中读取数据
因此使用plist这类文件来储存需要频繁读写的数据, 是非常不合适的

image

Core Data

由苹果推出的 Core Data 其底层其实是基于sqlite实现的, 也是苹果推荐开发者使用的数据缓存系统, 因为它可以管理对象关系, 创建关系型的数据库, 可以注册属性观察与通知, 自动版本检测, 自动解决写冲突, 并且在内部集成了 iCloudKit (iOS 13或以后)

sqlite

关于直接使用sqlite, 苹果特别提出了关闭与开启连接的开销, 每次开启和关闭DB的连接, 都会触发sqlite的一致性检测, 日志恢复, 日志标志位设置等等操作, 因此apple建议不要过多的开启和关闭连接, 而是在app的生命周期里, 开发者尽可能的保持连接一直开启, 例如可以建立一个独立的子线程来保持与DB的连接, 然后全局通过那个子线程去操作DB

日志

关于日志, 开发者平时可能对于 sqlite 日志的 mode 没有过多的关注, 但其实日志 mode 的不同对性能同样有很大的影响, Delete Mode是sqlite的默认日志mode, 但WAL Mode是更推荐的日志mode, 首先是因为更少的写操作, 这个日志模式会自动组合多个写操作到同一页, 同时也使用更少的 barrier, 支持多个读操作与写操作并发, 并且支持数据快照, 例如我们要写入4页的DB, WAL Mode并不会分别写在这4个页中, 而且统一写在Write Ahead log file中

事务 Transaction

在多个 INSERT, UPDATE, DELETE 操作时, 建议使用 Transaction, 可有效减低 IO 次数


image

例如我们有3个 Transaction, 修改 DB 上同一页的数据, 这会造成 DB 上同一页的数据被修改3次


image

但如果将这3个操作放在一个Transaction里, 写操作就会被合并成一次

File Size and Privacy

当我们从 DB 中删除数据时, 在 DB 中储存该数据的空间会被设置为可用, 但被删除的数据是实际上有可能仍然在磁盘上直至有新数据写入, 如果是涉及安全和敏感的数据, 可以使用
PRAGMA schema.auto_vacuume=INCREMENTAL这种删除模式, 该模式会总动清理被删除的数据, 并且在iOS13 是默认模式

不要使用 VACUUM

VACUUM 是性能比较差的清扫空闲页的方式, 例如我们要 VACUUM 下面这个包含6个空闲页(灰
色)的 DB, 这时会先将所有有效数据写到 journal 里


image

然后清空DB


image

最后再将 journal 的数据重新全部 insert 到 DB


image

最后再删除 journal
所有数据都最少执行了两次写操作, 由于数据被 copy 了一份, 也会占用更多的内存

因此建议使用 PRAGMA schema.auto_vacuume=INCREMENTAL, 原理如下
例如我们要清理下面两个空闲页

image

write ahead log会预先记录末尾两个准备被移动的页, 以及他们的父结点


image

然后将末尾的页数据更新到空闲页上


image

比起直接使用 VACUUM 的全量删除和写入, 这个模式只更新了需要被清理的空闲页的数据, 明显更高效

关于sqlite部分的总结

首先是保持 DB 连接的开启, 尤其是需要频繁读写 DB 的应用, 如果现在的设计模式是每次读写都做成独立的一次连接开启与关闭, 将会造成不必要的额外开销
在日志模式上使用 WAL mode, 会自动帮你合并日志操作, 一次性执行多个 statement 时优先考虑合并成一个 transaction, 并且在需要清理DB的空闲数据时, 使用 auto vacuum incremental. 以上都是让你的sqlite db能更高效运行的方法

总结

这次 wwdc 总结了储存策略的方方面面, 磁盘 IO 的开销可能是国内开发者很少注意到的一个点, 估计将一些小数据作为文件存在磁盘然后运行过程不断读写的项目不在少数, 一个简单的writeToFiles的api就会导致多层的 cache IO 操作, IO 不但导致发热和耗电, 而且发生在主线程也会造成卡顿. 如果你的项目暂时没做过这方面的优化, 用 instrument 做一下debug可能会发现不少"惊喜".

你可能感兴趣的:(优化App的持久化策略)