一般情况下,开发时直接选择Google提供的解码API,但是API的使用不当会导致I/O型的性能问题,今天就探索一下怎样做才能做到最优的Bitmap 解码,这可是现代化项目的重要组成部分。
背景 :
随着Android SDK的升级,Google 修改了Bitmap 解码的API实现,也是这个修改埋下了一个性能的问题,这个问题可以简单的描述为:
- 解码Bitmap 不要使用decodeFile, 因为在Android 4.4 以上系统效率不高
- 解码Bitmap使用decodeStream,同时传入的文件流为Buffered InputStream
- decodeResource同样存在性能问题,请使用decodeResourceStream
腾讯工程师使用I/O 监控工具发现,
一个指定的图片,比如a.png 大小为1702字节,需要读取磁盘215次(具体代码逻辑先不用考虑,但是要有一个意识,
即:解码为什么读取了215次,215次是多还是少?)
随机,他们将相同的代码部署到不同的环境中,具体环境如下:
| 图片大小 | 读取磁盘数 | |
|---|---|---|
| 三星9300(Android4.3) | 12608 | 1 |
| Nexus5(Android 4.4) | 12608 | 108 |
从这个数据中,可以看出读磁盘次数和系统版本有关,并且相差巨大。
问题出在哪里?
Bitmap 相关的解码方法全部在BitmapFactory 中,比如:
- decodeFile()
- decodeResource()
- decodeByteArray()
- decodeFileDescriptor()
- decodeStream()
- decodeResourceStream()
在我们项目中搜索了一下:使用情况为
- decodeFile 28次
- decodeResource 19次
- decodeByteArray 4次
- decodeFileDescriptor 5次
- decodeStream 2次
- decodeResourceStream 0次
基本上可以得到一个结论,大家平时常用的就是decodeFile, 注意:腾讯当时发现问题的代码就是decodeFile这个API
简单分析一下这个API:
4.3 系统API内部流程图如下:
暂时无法在飞书文档外展示此内容
上图可以看到,decodeFile 的实现流程最终读磁盘的是BufferedInputStream,并且这个Buffer大小为DECODE_BUFFER_SIZE=16 X 1024
在看一下4.4 系统以上的实现流程:
暂时无法在飞书文档外展示此内容
首先明确的是4.4以上直接调用了nativeDecodeStream, 然而Native在decode图片时,每次都要实际去读磁盘,所有导致了读磁盘次数的增加
(读取次数越多I/O型资源性能越差)
解决方案:
决定读磁盘次数的是传给nativeDecodeStream 的文件流是否使用了Buffer。
- 首先在4.3 系统上(目前项目不需要考虑)
- 4.4 以上如果使用decodeFile ,生成文件流只能是FileInoutStream,无法修改大小
那我们看一下4.4以上源码:
public static Bitmap decodeFile(String pathName) {
return decodeFile(pathName, null);
}
分析结果:无法传递Buffer,那怎么办呢?
寻找一个能指定Buffer的API,即:
public static Bitmap decodeStream(@Nullable InputStream is, @Nullable Rect outPadding,
@Nullable Options opts) {
//....
}
DecodeStream 可以传入InputStream,并且可以穿入BUfferedInputSteam类型的文件流。
腾讯工程师对此验证了结果:
| 机型(版本) | 系统API | 耗时 | 读磁盘次数 |
|---|---|---|---|
| 19300(Android 4.3) | decodeStream | 25 | 2 |
| decodeFile | 26 | 1 | |
| M1 2S (Android 5.0) | decodeStream | 18 | 2 |
| decodeFile | 49 | 108 |
结果:
在低版本系统中decodeFile 和decodeStream 耗时几乎一致,在高版本系统中decodeStream速度是decodeFile的3倍多
结论
- 解码Bitmap 要使用decodeStream,同时给decodeStream的文件流是BufferedInputStream(可以根据文件大小指定恰当的大小,避免浪费)
- decodeResoure 是项目中常见的API,同样也存在此问题,可以使用decodeResourceStream代替
扩展
《Android 移动性能实战》一书总结之经验:
| 原则 | 标准 | 优先级 | 规则来源 |
|---|---|---|---|
| 避免住线程I/O | 避免主线程操作文件和数据库 | P0 | 50%的卡顿问题都是由于主线程I/O引起的 |
| 用apply 代替commit(SP),也可使用mmkv | P1 | 同/异步操作 | |
| 提前初始化SP | P1 | 在多进程和旧版本的Android中,初始化过程的I/O是主线程指定的(所有线程问题均可参考) | |
| 减少I/O读写量 | 减少使用select * | P1 | 减少从数据库读取的数据量、减少耗时 |
| 利用缓存减少重复读写 | P2 | 内存缓存命中率极高、投入产出高 | |
| 数据库减少使用AUTOINCREMENT | P1 | 因为要多操作一个表(后面分析为什么会多操作一个表),所有Insert耗时减少2-4倍 | |
| 使用合适的数据库分页 | P0 | Sqlite 读/写磁盘是以page为单位的,在3.12.0版本之前,sqlite默认page size 1kb,3.12.0 开始page size 调整为4KB | |
| 频繁查询的表使用索引 | P0 | 索引可以极大的减少读磁盘的数据量,极大的提升效率 | |
| 避免无效索引 | P0 | 无效索引的问题通常是严重的,除了触发全表扫描,产生大量的冗余读/写之外,还降低谢入性能 | |
| 减少I/O操作次数 | 使用8KB Buffer 读/写 | P0 | 可以减少2~3倍的耗时 |
| 批量更新数据库使用事务 | P0 | 启用事务,根据业务规模,会大量减少I/O读/写量和操作次数 | |
| ZIP压缩大量小文件时建议使用ZipInputStream | P2 |
扩展一下BitmapFactory:
inline fun BitmapFactory.decodeBitmap(
pathName: String,
outPadding: Rect? = null,
ops: BitmapFactory.Options? = null
): Bitmap? {
var stream: InputStream? = null
var bis: BufferedInputStream? = null
var bitmap: Bitmap? = null
try {
stream = FileInputStream(pathName)
//默认就是8KB
bis = BufferedInputStream(stream)
bitmap = BitmapFactory.decodeStream(bis, outPadding, ops)
} catch (e: Exception) {
/* do nothing.
If the exception happened on open, bm will be null.
*/
Log.e("BitmapFactory", "Unable to decode stream: $e")
} finally {
stream?.close()
bis?.close()
}
return bitmap
}