Bitmap 解码优化

2,765 阅读5分钟

一般情况下,开发时直接选择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)126081
Nexus5(Android 4.4)12608108

从这个数据中,可以看出读磁盘次数和系统版本有关,并且相差巨大。

问题出在哪里?

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内部流程图如下:

image.png

暂时无法在飞书文档外展示此内容

上图可以看到,decodeFile 的实现流程最终读磁盘的是BufferedInputStream,并且这个Buffer大小为DECODE_BUFFER_SIZE=16 X 1024

在看一下4.4 系统以上的实现流程:

image.png

暂时无法在飞书文档外展示此内容

首先明确的是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)decodeStream252
decodeFile261
M1 2S (Android 5.0)decodeStream182
decodeFile49108

结果:

在低版本系统中decodeFile 和decodeStream 耗时几乎一致,在高版本系统中decodeStream速度是decodeFile的3倍多

结论

  • 解码Bitmap 要使用decodeStream,同时给decodeStream的文件流是BufferedInputStream(可以根据文件大小指定恰当的大小,避免浪费)
  • decodeResoure 是项目中常见的API,同样也存在此问题,可以使用decodeResourceStream代替

扩展

《Android 移动性能实战》一书总结之经验:

原则标准优先级规则来源
避免住线程I/O避免主线程操作文件和数据库P050%的卡顿问题都是由于主线程I/O引起的
用apply 代替commit(SP),也可使用mmkvP1同/异步操作
提前初始化SPP1在多进程和旧版本的Android中,初始化过程的I/O是主线程指定的(所有线程问题均可参考)
减少I/O读写量减少使用select *P1减少从数据库读取的数据量、减少耗时
利用缓存减少重复读写P2内存缓存命中率极高、投入产出高
数据库减少使用AUTOINCREMENTP1因为要多操作一个表(后面分析为什么会多操作一个表),所有Insert耗时减少2-4倍
使用合适的数据库分页P0Sqlite 读/写磁盘是以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压缩大量小文件时建议使用ZipInputStreamP2

扩展一下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
}