对于Android平台的Facebook来说,快速而高效的展示图片是很重要的。不过近年来,我们在如何高效地存储图片方面遇到了很多问题。图片所需空间很大,而设备上空间很小。每个像素占用4个字节,分别为红、绿、蓝和α透明度。如果一个手机的屏幕尺寸是480*800像素,一张全屏的图片就占用1.5MB的内存。通常手机的内存很少,并且Android设备在众多应用程序之间会平均分配自身的内存。在一些设备上,Facebook程序内存被限制在16MB,可是仅仅一张图片就占用了十分之一!
当你的应用程序运行超出内存时会发生什么?它会crash。我们打算通过创建我们称之为Fresco的库来解决这个问题。它能管理图片及其所占内存,Crash便随之消失了。
内存区
要了解Facebook的做法,我们需要了解Android上可用的不同内存堆。
严格来说,Java堆是一个棘手问题,每个应用程序由设备制造商所限制。所有通过Java创建出来的对象都存在于此,这是一个存储器使用相对稳定的区域。内存会被垃圾回收,所以当应用程序不再需要内存时,系统会对其自动回收。
不巧的是,垃圾回收的过程恰好是一个难题。为了做到较为彻底的垃圾回收,当系统执行垃圾回收时,Andoid必须停止应用程序。这就是在你使用应用程序的时候,遇到短暂卡顿或停滞的众多常见原因之一。这会困扰用户的使用,同时他们可能会尝试滚动屏幕或者按下按钮,却只看到应用程序在响应之前莫名其妙的等待。
相比之下,native层的堆是通过C++创建出来的。它会有更多的可用内存。应用程序仅仅被设备上可用内存所限制。然而,C++程序管理其所释放的每一个字节,就是说他们会内存泄露,并且最终崩溃。
Android有另一个内存区,叫做ashmem。它的操作很像native层的堆,但是有额外的系统调用。Android可以“取下”内存而不是释放内存。这是一种懒释放,内存只是在系统真正需要更多内存的时候才释放。当Android重新”固定”内存的时候,如果原有的数据没有被释放掉,会仍然存在。
可回收Bitmap
Ashmen不直接和Java应用程序打交道,但有少数例外的情况,图片就是其中之一。当你创建一个解码的(未被压缩的)被称为位图的图片,Android的API允许你指明这个图片是可回收的。
1 2 3 |
BitmapFactory.Options = new BitmapFactory.Options(); options.inPurgeable = true; Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length, options); |
可回收的bitmap存在于ashmem。然而,垃圾回收器不会自动回收它们。Android的系统库在系统绘制图片的时候“固定”内存,在图片销毁的时候“取下”内存。在任何情况下,内存都可以被系统回收。如果被回收的图片需要再次绘制,系统会在运行时再次解码图片。
这似乎是一个完美的解决方案,但问题是运行时解码发生在UI线程。解码是CPU耗时的操作,与此同时UI会停滞。出于这个原因,Google目前不建议使用该功能。他们现在推荐使用一个不同的标志位,叫做inBitmap。然而,这个标志位在Android 3.0之前是不存在的。即使如此,除非应用程序中大部分图片是相同大小的,否则这是没有用的。对于Facebook来说,这种情况不可能出现。直到Android 4.4出现之后,这一限制被打破。然而我们需要一个解决方案,那就是在Android 2.3设备上Facebook用户如何使用的问题。
兼而有之
我们找到一个解决方案,无论在快速的UI还是在高效内存使用上都可以两全其美。如果我们在UI线程之后提前占用内存,并确保它不被回收掉,然后我们可以在ashmem中持有图片而不会遇到UI停滞的情况。碰巧的是,Android Native Development Kit (NDK)具有这样的函数,叫做AndroidBitmap_lockPixels。这个函数原本计划紧跟调用unlockPixels之后,用于再次回收内存。
当意识到不必要这么做的时候,我们取得了突破。即便我们调用lockPixels时没有一个匹配的unlockPixels,也可以在Java堆内存中创建一张图片并不拖慢UI线程。在完成了少量C++代码之后,我们便回家了。
用C++思维写Java代码
正如我们在蜘蛛侠中学到的,“权力越大责任就越大”。持有可回收的位图可以既不使用垃圾回收,也不使用ashmem内置的回收特性,从而避免内存泄露。我们确实可以自力更生。
在C++中,通常的解决办法是创建实现引用计数的智能的指针类。这些类充分利用C++语言的特性,拷贝构造函数、赋值运算符和确定性析构函数。Java中没有这个语法优势,Java中垃圾回收被假定可以打理一切。因此我们终究要找到一个方法,能够在Java中实现C++风格式的保障性。
我们充分利用两个类来做到这一点。其中一个被称为SharedReference,它有两个方法addReference和deleteReference。调用者应该在它们获取到底层对象或者超出范围的时候调用。一旦引用计数为0,就要回收资源(比如Bitmap.recycle)。
但显而易见的是,在Java开发者在调用这些函数的时候,它会带来很高的易错性。我们选择Java语言来避免这些!因此在SharedReference的顶层,我们创建了CloseableReference接口。这不仅实现了Java Closeable接口,也实现了Cloneable接口。它的构造器和clone()方法调用了addReference(),并且close()方法调用了deleteReference()。因此Java开发者仅仅需要遵循这两个简单的规则:
在给一个新对象分配CloseableReference时,调用.clone()。在越界止前,调用.close(),这些通常写在finally代码块中。这些规则在避免内存泄露方面很有效,并且让我们体验到在诸如Android上的Facebook和Messenger这些大型Java应用程序中的native内存管理方式。
这不仅仅是一个加载器,更是一个管道。在移动设备上展示图片包含很多步骤:
目前有几个优秀的开源库执行这些序列,仅举几例,比如Picasso、Universal Image Loader、Glide和Volley。他们对于Android的发展做出了巨大的贡献。我们相信我们的新库在一些重要方面会更加成功。
认为这几步是一种管道而不是加载器这本身就很重要。每一步骤都应该尽可能独立,输入一些参数并得到输出结果。一些操作可能是并行的,另一些则是串行的。其中一些只能在特定条件之下执行。至于它们执行在哪些线程上,会有特殊的需求。此外,当我们考虑渐进式图像的时候,整个描述图会变得越来越复杂。许多人在很慢的联网状态下使用Facebook,我们希望这些用户可以尽快看到图片,即使是在图片未下载完成之前。
不用担心,使用streaming吧
过去,Java上的异步代码常常通过类似Future的机制来执行。代码被提交给其他线程,同时检查一个类似Future的对象,看是否能得出结果。然而,假设只会有一个结果。当处理渐进式图像的时候,我们希望有一个完整而持续的结果。
我们的解决方案是使用Future的更广义的版本,叫做DataSource。它提供了一个订阅方法,其调用者一定要传递一个DataSubscriber和一个Executor。该DataSubscriber从DataSource的中间值和最终结果那里接收通知,并且提供一个简单的方法来区分两者。因为我们常常处理对象,这就需要一个明确的close调用,DataSource本身就实现了Closeable。
深入分析之后,其实上述的每一个格子用一个新的框架来实现,叫做Producer/Consumer。绘制图示的灵感来源于ReactiveX框架。我们的系统和RxJava有相似的接口,但其更适于移动设备,并内建了Closeables支持。
该接口保持简单。Producer仅有一个方法produceResults,其中包含了一个Consumer对象。Consumer反过来也有有一个onNewResult方法。
我们用此系统组成了一个生产链。设想我们有这样的一个生产者,其工作是转换 I 类型到 O 类型。实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public class OutputProducer<I, O> implements Producer<O> { private final Producer<I> mInputProducer; public OutputProducer(Producer<I> inputProducer) { this.mInputProducer = inputProducer; } public void produceResults(Consumer<O> outputConsumer, ProducerContext context) { Consumer<I> inputConsumer = new InputConsumer(outputConsumer); mInputProducer.produceResults(inputConsumer, context); } private static class InputConsumer implements Consumer<I> { private final Consumer<O> mOutputConsumer; public InputConsumer(Consumer<O> outputConsumer) { mOutputConsumer = outputConsumer; } public void onNewResult(I newResult, boolean isLast) { O output = doActualWork(newResult); mOutputConsumer.onNewResult(output, isLast); } } } |
这让我们串联起一系列复杂的步骤,并使之保持逻辑上独立。
动画,从单一到复杂
Stickers是存储GIF和WebP格式的动画,深受Facebook用户的喜爱。为了支持它们面临了全新的挑战。一个动画不是一个位图而是一系列位图,其中每一个都要被解码,需要在内存中存储并展示。对于大型动画来说,在内存存储其每一帧是不现实的。
我们建立了AnimatedDrawable,一个可以提供绘制动画的Drawable,以及两个后端处理,一个是GIF,另一个是WebP。AnimatedDrawable实现了标准的Android Animatable接口,因此调用者可以随时开始和停止动画。为了优化内存利用,我们缓存所有内存中足够小的帧,但是如果他们太大的话,我们会在运行时解码。这些操作完全取决于调用者。
两个后端处理都是用C++代码实现的。我们保留两个编码数据和解析元数据的备份,比如宽和高。我们引用计数的数据,在Java端它允许多个Drawables同时访问单一的WebP图片。
我是如何爱你的?让我Drawee这些方法……
当图片正在从网络上下载,我们希望显示占位的图片。如果他们下载失败,我们就显示错误的提示。当图片如期而至,我们便使用快速的淡入动画。我们经常放缩图片,甚至是使用matrix矩阵,并通过硬件加速将图片调整为期望的尺寸。我们瞄准图片中央放缩,图片中央在别处可能是很有用的聚焦点。有时我们想要显示圆角,甚至是正圆形的图片。所有的操作都应该是快速和顺滑的。
我们以前的实现涉及Android View对象,比如在必要的时机对ImageView替换为占位的View。事实证明,这是相当缓慢的。改变Views会强制Android系统执行一次完整的布局传递过程,当用户滑动的时候会明显发生一些用户不愿发生的事情。一个更明智的做法就是使用Android的Drawables,可以在运行时替换它们。
所以我们创建了Drawee。这是一个展示图片的类MVC架构。这个模型叫做DraweeHierarchy。它被用作一个Drawables的层次结构,其中每一个都实现了一个特定的功能,比如成像、分层、淡入或者放缩,这些构成了图像的基础。
DraweeControllers构建了图像管道,或者说是任何图像加载器,同时也考虑到了到后端图像处理。他们从管道接收到了事件回馈并决定如何处理它们。无论在是占位,错误条件或是图片加载完成情况下,它们都操控了DraweeHierarchy的展示内容。
DraweeViews只有有限的功能,但是它们起着决定性作用。它们监听Android系统事件,传递这些view是否显示在屏幕上的消息。当不在屏幕上时,DraweeView会告诉DraweeController去关闭图片使用的资源。这就避免了内存泄露。此外,控制器将告诉图片管道取消网络请求,尽管图片还没有请求结束。因此,就像Facebook经常做的那样,滑动一个很长的图片列表时不会打断网络加载。
有了这些措施,显示图片的繁重工作便不存在了。仅仅调用一个实例化的DraweeView,指定一个URI,同时可以把其它一些参数作为可选项。一切便水到渠成。开发者不需要担心管理图片内存或者流式地更新图片。一切都由库来接管。
Fresco
我们精心构建这个工具集来展示和管理图片,想要分享给Android开发者社区。我们很高兴地宣布,该项目从此开源了。
湿壁画技术是一个数百年来风靡全球的的绘画技术。伴随此名,从文艺复兴时期大师Raphael到斯里兰卡的Sigiriya艺术家都使用了这个形式,我们以如此伟大的艺术家为荣。我们不能相其媲美。我们真心希望Android应用开发者乐于使用我们的库,就像我们乐于构建它一样。