iOS 性能优化:优化 App 的持久化策略
作者:RyRYanZhong,iOS 开发,字节跳动研发工程师
Sessions: https://developer.apple.com/videos/play/wwdc2019/419/
本文发表于2019/07/01《WWDC2019内参》
这个 Session 覆盖了 App 储存文件的方方面面,对于经常需要写入沙盒的 app 来说,提供了很多好的 guideline,以及底层原理的分析.
使用 HEIC 格式图片
苹果建议我们本地的图片切换成使用 HEIC 格式这种更高效的图片格式,HEIC 格式本身比 jpeg 小 50%,因此下载和上传都更快,在磁盘中存取也更快,同时也支持透明度和无损压缩,在单个 HEIC 图片容器中可支持储存多个图片.
将图片放入 AssetCatalog
AssetCatalog 原生支持了 AppSlicing,可以根据下载机型的不同,slice 出对应的图片放入安装包中,而不是直接将 @2x,@3x 等倍图全部打入安装包,可以有效减少包体积。另外 image 的加载也会更快,尤其是启动时,apple 声称可以比不使用 AssetCatalog 的情况提升 10% 的图片加载速度,所以使用 heic 加 assetcatalog 组合,会有奇效
文档数据元数据 File System Metadata
当 Metadata 的数据写入经常会发生,而且 IO 开销是很大的,例如你的 App 中有一个 plist 文件记录上一次的启动时间,每次启动 App,都读取该 plist 获取上次启动时间,然后写入当前时间这个简单的操作会发生一次读取 IO,三次写入 IO,还有一次 fsync() 的操作,并且以下行为都会造成 File System Metadata 写入
创建文件
删除文件
重命名文件
更新文件
而 File System Metadata 包括以下元素
文件名
大小
地理位置
修改时间
等等....
例如当我们写一个 240byte 的 NSDictionary 到文件时,首先是 update file system tree
基于写时复制(copy on write)策略,不会马上更新 file system tree 的结点,而是创建一个结点的拷贝
每一次操作,都会有自己的 transactionid,这个写操作就会生成一个新的结点,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 的函数
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 操作,即使你写入一个很小的数据,由于文件本身携带的 metadata 操作,也可能会产生数据量是写入 data 本身几倍大的 IO 开销
举个例子
以下是 file activity instruments 监控我们创建,读取,和更新上述这个 plist 时,引起了 12 个独立的 IO 操作
面是单单的更新 plist 操作,调用了系统的writeToFile
函数,最后再调用栈上系统为我们调用了 fsync,所以数据就会直接由 OS cache 层一直写入到 Disk cache 层,并从 OS cache 层被清除,如果在写入后我们仍然要继续使用数据,就会失去了 OS cache 这一层的缓存,而需要重新开启 IO 去磁盘中读取数据
因此使用 plist 这类文件来储存需要频繁读写的数据,是非常不合适的
CoreData
由苹果推出的 CoreData 其底层其实是基于 sqlite 实现的,也是苹果推荐开发者使用的数据缓存系统,因为它可以管理对象关系,创建关系型的数据库,可以注册属性观察与通知,自动版本检测,自动解决写冲突,并且在内部集成了 iCloud Kit (iOS13 或以后)
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 次数
例如我们有 3 个 Transaction,修改 DB 上同一页的数据,这会造成 DB 上同一页的数据被修改 3 次
但如果将这 3 个操作放在一个 Transaction 里,写操作就会被合并成一次
FileSizeandPrivacy
当我们从 DB 中删除数据时,在 DB 中储存该数据的空间会被设置为可用,但被删除的数据是实际上有可能仍然在磁盘上直至有新数据写入,如果是涉及安全和敏感的数据,可以使用PRAGMA schema.auto_vacuume=INCREMENTAL
这种删除模式,该模式会总动清理被删除的数据,并且在 iOS13 是默认模式
不要使用 VACUUM
VACUUM 是性能比较差的清扫空闲页的方式,例如我们要 VACUUM 下面这个包含 6 个空闲页(灰 色)的 DB,这时会先将所有有效数据写到 journal 里
然后清空 DB
最后再将 journal 的数据重新全部 insert 到 DB
最后再删除 journal
所有数据都最少执行了两次写操作,由于数据被copy了一份,也会占用更多的内存
因此建议使用PRAGMA schema.auto_vacuume=INCREMENTAL
,原理如下
例如我们要清理下面两个空闲页
write ahead log 会预先记录末尾两个准备被移动的页,以及他们的父结点
然后将末尾的页数据更新到空闲页上
比起直接使用 VACUUM 的全量删除和写入,这个模式只更新了需要被清理的空闲页的数据,明显更高效
关于 sqlite 部分的总结
首先是保持 DB 连接的开启,尤其是需要频繁读写 DB 的应用,如果现在的设计模式是每次读写都做成独立的一次连接开启与关闭,将会造成不必要的额外开销
在日志模式上使用 WAL mode,会自动帮你合并日志操作,一次性执行多个 statement 时优先考虑合并成一个 transaction,并且在需要清理 DB 的空闲数据时,使用 auto vacuum incremental。以上都是让你的 sqlite db 能更高效运行的方法
总结
这次 wwdc 总结了储存策略的方方面面,磁盘 IO 的开销可能是国内开发者很少注意到的一个点,估计将一些小数据作为文件存在磁盘然后运行过程不断读写的项目不在少数,一个简单的writeToFiles
的 api 就会导致多层的 cache IO 操作,IO 不但导致发热和耗电,而且发生在主线程也会造成卡顿。如果你的项目暂时没做过这方面的优化,用 instrument 做一下 debug 可能会发现不少"惊喜"。
推荐阅读
✨ iOS 性能优化:使用 MetricKit 2.0 收集数据
iOS 性能优化:用 Xcode Organizer 诊断性能问题
关注我们
我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。
这篇文章的内容来自于《WWDC19 内参》。关注【老司机技术周报】,回复「2020」, 即可领取。