Fresco应用过程中的心得体会

背景

Fresco是FackBook开源的Android端图片加载框架。去年在一家电商工作负责Android端APP的开发,由于行业的特殊性,会有大量的图片需要显示。当时APP内使用的是UIL来加载图片,而且公司要求Android设备兼容到2.3版本。所以崩溃统计中有大量的OOM,在UIL上进行了几次优化之后,效果并不明显。比较了当时的主流图片加载框架后,我们选择了Fresco来替换UIL。

接下来我会给大家简单介绍替换过程中遇到的问题以及解决方案。以及过程中对于Fresco的各种特性的理解。

在调查过程中,我们对UIL、Picasso、Glide、Fresco进行了比较,也参考了很多网上的对比文章。对Fresco的优缺点有了初步的了解:

优点

  • 功能更强大,支持所有格式的图片。比如GIF、WebP。(这里简单说一下WebP,WEBP是Google推出的一种新的图片格式,相对于同质量的PNG图片,体积能减小40%左右,对于电商等大量使用图片的项目,是不二的选择。但是Android系统,4.0之后自动支持WebP,而带透明度的WebP则要在4.2.2的版本中才有原生支持。而这些Fresco已经在框架中给予了兼容处理,简单方便)
  • 内存使用上更先进。在5.0以前的系统中,Fresco将Bitmap缓存在ashmem中,减少了对Java堆内存的使用,从而降低了OOM发生的概率。5.0以后由于Android系统的机制调整,Fresco不能再使用ashmem存储bitmap。
  • 自定义网络接口。以前在使用UIL的时候,网络请求部分是框架自己实现的,对于请求过程不能自己控制,在特殊情况下很不方便。当然Picasso、Glide也能够进行自定义,这个一定意义上来说,应该算是UIL的缺点,不是Fresco的优点。Fresco默认是使用OKHttp来进行请求,我们的项目使用的Volley,修改起来也非常简单。

缺点

  • 体积较大,原来的UIL大约150KB。而Fresco因为需要对Ashmem内存进行管理,处理图片格式转化等,核心代码均为C++编写。如果兼容不同的CPU类型的话,体积以M计。考虑到Android平台的CPU类型多以arm为主,零散存在一些x86/64的类型。所以兼容此两种类型的cpu就足够了。即使这样,替换完成后,安装包大小仍然增加了2M左右。不过Fresco也已经认识到了这个问题,在0.9.0之后,对Fresco各个功能模块进行了拆分,比如对于不同API情况下WebP的支持,GIF的支持。如果只是简单使用Fresco,可以不引入这些模块,从而能够减少apk的体积。
  • 使用略不方便。Fresco不能使用ImageView作为图片载体,而是要使用Fresco提供的View或其子View。如果是一个新的项目,可能没什么,对于我们一个成熟的项目,替换起来还是有很大的工作量在其中。但是幸好是一劳永逸的事情,所以忍着弄完了也就好了。此处的话,建议大家对于View做一层封装隔离,这样后续如果有任何改动,也能够很简单的切换回来。在很长一段时间内MImageView的父类,一直在ImageView和DraweeView之间切换。这里很重要的一点就是,对第三方的图片加载库要再做一层包装,以统一的接口提供给项目内部使用,后续切换图片库的时候基本可以做到项目无感知。
  • 没有针对ListView或者RecyclerView的优化。UIL等框架提供了对外接口,在ListView在滑动时,停止图片加载,一定程度上提高了流畅度。Fresco没有类似的接口提供出来。

相关知识准备

** Android内存种类 **

  1. Java堆内存。这也是很多人对图片OOM不解的原因,明明手机有2G的运存,为什么还是会OOM呢?因为Android系统对于每个进程的内存使用大小是有限制的,不同的手机限制不同。可以查看 /system/build.prop。
  • dalvik.vm.heapstartsize 为初始堆大小;
  • dalvik.vm.heapgrowthlimit 普通情况下堆内存的最大值,超过后APP便会OOM;
  • dalvik.vm.heapsize 如果APP使用的内存较多,可以在manifest中配置largeHeap为true,这样堆内存的最大值就由heapsize决定了,也是每个进程可使用的对内存的极限值;
    Android可以通过ActivityManager.MemInfo来获取当前剩余的堆内存大小,从而采取不同的加载策略;
  1. Native内存。对于很多大型手游,比如NBA、CF等,内存占用肯定超过了Android堆内存的限制,为什么能够正常运行呢?因为他们核心代码都是C、C来编写,然后通过JNI进行调用。主要的内存占用都是通过C申请native内存。Native内存使用上并没有Java堆内存的限制。
  2. Asheme内存(匿名共享内存)。关于匿名共享内存的介绍大家可以看一下老罗的博客Android系统匿名共享内存

** Bitmap的内存使用和Options中的一些特性 **

  • 2.3及以前是存储在native中的,以后是放在Java堆上的,Android官方文档
  • inBitmap选项。支持bitmap内存的重用,但是4.4以前会有严格限制。具体的一些介绍可以看知乎讨论或官方文档。
  • inPurgeable选项。这个选项被设置为true后,decode出来的bitmap是在Ashmem内存中,GC无法回收它。当该bitmap被使用时会被pin住,使用完后unpin,这样系统就可以释放掉这部分内存。但是这个选项在5.0之后被废弃了。

原理

Fresco之所以受到大家的关注,是因为对于内存使用的处理上独辟蹊径,极大的减少了Java堆内存的使用(5.0以下的系统)。前边我们提到inPurgeable选项开启后,bitmap占用的内存会改到Ashmem中,为了让bitmap不被自动unpinned,可以通过jni函数 AndroidBitmap_lockPixels()函数来强行pin bitmap,从而达到了在ashmem中缓存bitmap 的效果。同时我们可以调用AndroidBitmap_unlockPixels()来接触bitmap的pin状态,然后就可以调用bitmap的recycle来回收掉内存了。

缓存机制

  • bitmap缓存。
  • 未解码内存缓存。这个也是Fresco与其他框架不同的地方。这个缓存是原始的未解码的数据,使用时需要先解码,如果有调整大小、旋转或者编码转换等工作,也需要在这个过程中进行。对于这层缓存存在的意义我也没有弄的很明白,只能自己猜测。很有可能是因为解码后的bitmap对于内存的占用更大,而未解码内存的内存占用会相对小很多。做了这样一层缓存之后,内存中能缓存更多的数据。
  • 文件缓存。 将数据缓存到SD卡上。

前端的架构分析

Fresco前端接口采用了MVC的结构

  • Model DraweeHierarchy及其实现类等,主要负责要展现的各种Drawable的组织和控制,根据不同选项返回不同数据。
    这里的主要实现类是GenericDraweeHierarchy,包含了各种Drawable,用于展示不同类型的Drawable。
    通过包装的方法,将N个不同的Drawable包装到topDrawable中,交给View去展现。

  • View DraweeView及其子类等,主要负责各种Drawable的展现
    主要类DraweeView和GenericDraweeView

  • Controller DraweeController以及其实现类等,主要负责根据各种逻辑,将Model的数据展现到View上
    主要接口和类: DraweeController 以及其实现类 AbstractDraweeController、PipelineDraweeController

  • MVC结构除了以上三个主要类型,还有一个DraweeHolder,由View持有,包含Model和Controller的引用,作为三者交互的桥梁

图片请求过程
  1. 图片展示的请求自DraweeView 的onAttach发起,通过DraweeHolder将状态传递给DraweeController;

  2. AbstractDraweeController调用submitRequest的方法发出请求,并对请求操作添加订阅事件,从而处理请求结果;

    • onResultInternal、onFailureInternal、onProgressUpdateInternal;
    • 请求的进一步发起在getDataSource()中触发;最后的调用位置为PipelineDraweeControllerBuilder.java类的getDataSourceForRequest();
  3. 进一步跟踪,来到ImagePipeline.submitFetchRequest()函数,进一步查看,发现getDataSource()最后的返回结果是CloseableProducerToDataSourceAdapter.java;

    • 在submitFetchRequest的时候,ImagePipeline 通过mProducerSequenceFactory.getDecodeImageProducerSequence()来获取了一系列的生产者,来进行数据的生产;
    • 在AbstractProducerToDataSourceAdapter中,包装了一个Consumer来承接所有的返回数据,然后回调Adapter自身函数进行处理。Adapter自身处理过程中,根据结果的情况会回调两个观察者
      1. Controller在发起请求时,添加的订阅事件DataSubscriber;
      2. ImagePipeline的成员变量RequestListener,这个Listener并不是每个Request都有一个,而是在ImagePipeLine创建的时候传入的;
  4. 最后我们就来到了Fresco获取图片的后台代码,主要是通过生产者/消费者模式来实现的;

网络请求中的生产者与消费者模型

在接收到前端的图片请求后,后端对请求进行处理。入口位置为ProducerSequenceFactory.getDecodeImageProducerSequence()。后端采用了生产者与消费者模型,通过生产者模式,将所有的生产者进行层层包装,最终生成一个Producer,返回给外部调用者;针对网络图片完整的包装情况如下:

  • 第一层:内存访问:从内存查找已有的数据
    • BitmapMemoryCacheGetProducer 从MemoryCache中查找数据,存在直接返回
    • ThreadHandoffProducer 将不同的请求分配到后台线程中处理
    • BitmapMemoryCacheKeyMultiplexProducer 用于合并相同请求用
    • newBitmapMemoryCacheProducer 从MemoryCache获取数据,和将从底层获取的数据存储起来(后一条作用更重要)
  • 第二层:未解码内存访问:从内存中查找未解码的数据
    • DecodeProducer 对下层获取的数据进行解码操作(TODO 研究一个ImageDecodeOptions,)同时此处的decodeImage操作也是很重要的一段代码,关系到Fresco对Ashmem的使用
    • ResizeAndRotateProducer 旋转和缩放(只针对JEPG格式进行)
    • AddImageTransformMetaDataProducer    对图片的META进行解析
    • EncodedCacheKeyMultiplexProducer    合并相同请求
    • EncodedMemoryCacheProducer 缓存和读取内存编码数据(实际上此处没有做任何编码操作,只是将EncodeImage中的流数据塞到了内存中)
  • 第三层:从磁盘加载
    • DiskCacheProducer 磁盘数据的读写操作
    • WebpTranscodeProducer 对于不同系统版本、不同格式的webp进行兼容处理
  • 第四层:从网络请求数据
    • NetworkFetchProducer 网络请求

应用过程中的问题

  1. 设置ResizeOptions,由于服务器传回的图片大小是统一的,所以要自己在解码的时候添加resize的代码进行优化,对于没有准确设置大小的,将屏幕大小传入;
  2. 对于低内存的手机,在对Fresco进行初始化的时候,要将图片的config参数降低设置,从而能够降低每个图片上的内存占用;

其他相关资料

git上一个相对简介但是很透彻的分析
源码解析系列
另一个源码解析
其中后两个的解析都仅限于前端的代码,即请求的整体流程,没有分析核心代码部分,但是对于简单使用足够了。