@Swift开发者大会——如何打造易扩展的高性能图片组件

@Swift开发者大会——如何打造易扩展的高性能图片组件

内容提要

本文为第二届 @Swift 开发者大会同名主题分享讲稿带图版。

图片组件可以说是app开发中使用最多的组件之一,它既简单也不简单,如何设计和开发一个具有高扩展性,高性能的图片组件呢?本次分享将会从架构设计到性能优化等多方面,全面解析一个优秀图片组件的设计和开发原理,以及在性能优化和架构设计方面的一些经验和探索。


1
前言

讲到图片组件,这恐怕是我们在APP中使用最多的基础组件之一了。随着这几年APP设计变化,图片在APP中的占比越来大。瀑布流,图片墙这样的大面积图片页面也是层出不穷,用户对流畅性的要求也是越来越高。满满几屏的图片对APP的性能来说是个不小的考验。

所以说,一个优秀的图片组件是十分重要的。

接下来,我将主要从架构设计,核心性能优化两方面,分享如何开发这样一个易扩展的高性能图片组件。


2
从0开始打造易扩展的架构

在做软件设计时,根据不同的抽象层次可分为三种不同层次的模式:架构模式、设计模式、代码模式。架构关注的是软件总体的布局,框架性结构等,是一个系统的高层次策略。在设计一个组件的时候,我们首先就应该关注到它的架构。


设计架构的时候,首先我们应该思考,要它由哪些核心模块组成。对于一个典型的图片组件来说,就是下载、缓存、渲染三个部分。

2.1
架构1——最简单的图片组件

既然有了核心,我们很容易就设计出一个最简单的图片组件。这个组件由四个部分组成,分别是图片控件、内存缓存、硬盘缓存、下载器。

对于每一个图片请求,首先同步检查内存缓存中是否有该图片的缓存,如果有,立即将图片显示出来。否则进入下一步,检查磁盘缓存。

如果磁盘缓存中还是没有,则由下载模块发出网络请求,从网络上异步下载图片。下载完成后,缓存到磁盘缓存和内存缓存中,并显示。

这样一个具有基本能力的图片组件就搭建起来了,能满足大部分业务的常规需求。

2.2
架构2——更符合软件工程原则


这样的设计有个明显的问题,不符合我们软件工程的思想,逻辑和UI结合在了一起,ImageView既负责显示图片相关的逻辑,又负责管理请求,查询缓存,请求下载等功能。同时也缺少一个重要的接口,使得开发者请求图片必须通过view对象,而不能直接获取图片。

为了解决这个问题,我们引入了一个逻辑类,ImageManager,负责图片请求相关的逻辑,同时也提供了外部直接请求图片的接口。这样的设计更符合面向对象的原则。

磁盘缓存IO速度,和图片的下载速度是比较慢的,如果我们在一个图片加载完成之前再发出同样的请求,不可避免的会导致重复的磁盘读取和图片下载。

因此,在此基础上,我们还可以添加一个请求队列,实现对图片请求的去重。对于相同的请求,我们进行合并,在请求完成之后,批量进行回调。这样就可以防止同一个url重复请求导致的多次缓存查询和重复下载问题。

2.3
架构3——更加灵活和丰富的数据源

在到达这个阶段之后,一个网络图片组件已经基本成型,但是我们的脚步并没有停止,因为我们的目标是要打造一个通用的图片组件,支持的不仅仅是网络图片。

我们在原有架构的基础上做了调整,将下载模块升级为加载模块,将从不同位置加载的逻辑变为一个个数据源,采用设计模式中的职责链模式。对于每一个不同的请求分配到相应的DataSource进行处理。

同时,我们也调整了磁盘缓存的策略,它不再是请求查询的必经路径,比如相册图片,本地图片就不需要磁盘缓存。

另一方面,我们还对封装请求的结构体进行了改造。从原来的以url作为请求,改为以一个ImageRequest类进行封装,URLRequest也只是Request种的一种类型。

事实上,随着业务的发展,有些业务的图片请求已经并不能用简单的URL来表示了。这样的封装,使得请求、数据源的设计更加灵活,同时也能承载更加丰富的信息了。

当然设置url的接口还是保留的,提供了组件最简单的使用方式,本质上却改为创建了一个URLRequest。

2.4
架构4——支持图片处理

这样一个架构是否满足了所有的需求呢?显然还不够。有的图片并不是直接展示,而是需要进行处理,比如套用一个滤镜。前面的架构设计缺少了在图片显示之前对图片进行处理的基本能力。

因此,我们在原有的架构上再添加一个模块,图像处理模块,一方面解决上述的图片处理的问题。另一方面,在这个模块,对加载的图片进行一次绘制。这是由于iOS

的特性,UIImage加载之后并没有立即解码,而是在显示或其他需要的时候解码,我们需要进行一次绘制,强制系统进行解码。

2.5
架构5——第三方解码器


上面的架构已经比较完善了,但是随着业务发展,我们对图片要求也变高了。我们希望使用更新的图片格式,以满足对质量的要求。这就需要接入第三方解码器。

于是这里我们再增加一个解码器模块,用于提供对数据的解码支持。当然,并不是所有的数据源返回都是二进制数据,所以解码器也不是必经的路径。我们根据数据源返回的数据类型判断。对于返回UIImage的数据源我们直接使用,对于返回NSData的数据源则通过解码器解码。


3
向更高的性能前进


作为一个基础组件,功能是核心,效率是根本。我们设计这个组件的初衷是什么?是提高效率。这里有两层含义,一方面是提高开发效率,一方面是提高执行效率。可以说,没有效率的图片组件是没有价值的。

3.1
渲染性能优化

要提高效率,首先应该要找出性能的瓶颈,包括CPU、内存、IO等各个方面。接下来我们首先从图片渲染下手,讲讲到底是谁吃掉了我们的CPU,而我们又应该如何避免。

3.1.1
谁吃掉了我们的CPU


因此我们构造了一个常见的图片墙场景,很多图片Cell,进行上下滑动。这种图片墙在我们平时用的APP中是非常常见的,同时也是对性能挑战较大的场景。

我们使用Instruments进行分析。通过TimeProfile,我们可以轻易的查看一段时间内各个函数占用的CPU。我们观察了在滑动过程中的CPU占用情况。

结果非常惊人,在滑动过程中,主线程高达79%的CPU时间消耗在了一个函数上

CA::Render::prepare_image,它到底是什么,它做了什么事?

通过观察,我们发现里面实际调用了图片解码函数CA::Render::create_image_from_provider,将图片进行解码。原因是UIImage在加载的时候实际上并没有对图片进行解码,而是延迟到图片被显示或者其他需要解码的时候。这种策略节约了内存,但是却会在显示的时候占用大量的主线程CPU时间进行解码,导致界面卡顿。

3.1.2
优化

那问题发现了,我们就应该思考一下解决方案。如果我们不在主线程进行解码,而是在后台线程预先解码会有什么样的改变呢?我们通过CoreGraphic绘制UIImage,促使UIImage强制解码,然后再次观察滑动过程中的CPU占用。

效果十分明显,滑动过程中的CPU消耗降低了四分之三,UIImage不再需要在显示的时候进行解码了。这代表我们的优化方案取得了成效。

3.1.3
解码API性能对比(单线程)


UIImage解码的方法有很多种,那到底哪种效率高呢?我们应该使用多线程解码还是单线程呢?是否有Alpha通道对图片的解码有影响吗?为了解答这些问题,我们做了一个测试,选取了4种常用的API进行解码性能对比。

1、 使用UIGraphic创建Context,并调用UIImage的drawInRect函数。这个方法虽然是UI开头的,但是确实线程安全的,我们可以在文档找到相应的资料。事实上从iOS4开始,
UIImage,UIFont的绘制函数已经是线程安全的了。

2、 创建带Alpha的CGContext,然后把CGImage绘制上去

3、 创建不带Alpha的CGContext,然后把CGImage绘制上去

4、 ImageIO创建UIImage有个选项shouldCacheImmediately,设为true之后创建UIImage同时就会进行解码

下面是解码50张1000*1000图片的耗时图。进行对比,我们可以发现:

1、 使用ImageIO进行解码的效率是远高于其他方式的。

2、 CGContext是否带Alpha,不会对绘制时间有明显的影响。

3、 UIGraphic解码效率稍低,但和CGContext方法差别不大,主要原因可能是由于线程安全的方法加锁引起。

3.1.4
解码API性能对比(多线程)


那如果我们使用多线程进行解码会有什么不同呢?于是我们再次做了测试。

1、 使用UIGraphic和CGContext的解码时间明显减短,接近单线程ImageIO的解码时间。

2、 ImageIO的解码时间明显增长,这个十分令人惊讶。

我们得出结论,使用ImageIO单线程解码,已经能最大限度的发挥硬件的运算能力,多线程并不能能够有解码能力的提升。

3.2
内存占用优化

对于图片,可能比较少有人会关注它的内存,事实上对于iOS这样对App管理比较严格的系统,我们更应该小心。在实验室,我们对app占用的内存和稳定性之间的关系进行了测试,拿iPhone6这样的机型举例,使用300M以上的内存,就会对程序的稳定性产生明显的影响。

想要了优化,我们首先应该了解iOS的内存。

3.2.1
内存的类型

打开Instruments中的Activity Monitor,可以发现iOS的内存分为5栏:Physical Memory Wired,Active,Inactive,Used,Free。

那么这些到底有什么用呢?互相之间有什么区别呢?

3.2.2
不同类型内存的区别

Wired内存被系统使用,几乎无法被直接操作。应用程序无法直接使用Wired内存。但是也有一些API会使用这部分内存,手机没有独立显存,GPU使用的共享显存也属于这个部分。它无法被Allocations显示出来,所以我们使用Allocations测试的内存和APP实际使用的内存

Active内存是当前正在运行的应用程序使用的内存。由于虚拟内存的帮助,并不是一个程序的所有内存都被包含在这里。如果你打开Activity Monitor,你可以看到应用程序真正占用的物理内存和虚拟内存。当没有Inactive和Free内存的时候,就会触发操作系统的页面置换,在别的程序需要使用该内存之前,将内存写入磁盘。

Inactive内存是一个最近刚刚被一个不在运行的程序使用过的内存。由于局部性原理(Temporal Locality),操作系统保留对这块内存的追踪。这使得启动一个内存被追踪的程序将会非常迅速。Inactive内存将会在别的应用程序需要内存的时候被回收。

Free内存就是字面的意思,空闲内存。

似乎少讲了一个:Physical
Memory Used。顾名思义,被使用的物理内存,它等于Wired+Active+Inactive。

3.2.3
占用内存的3种方式

大家都知道加载图片会占用内存,但是很少人知道具体会如何占用内存。事实上,图片有3种方式占用内存。

常见的解码方式有以下几种

1、使用UIGraphic配合UIImage 的drawInRect

2、使用CGContext配合CGContextDrawImage

3、使用CGImageSource的shouldCacheImmediately

4、设置到UIImageView的image属性中,然后显示

前三种可以分为一类,强制解码

第四种由于苹果对于UIImageView的特殊优化需要单独分为一类

还有一类是CoreAnimation对非字节对齐图片的的copy

对于情况1:一个传统的RGBA图像,系统在分配了一块4*width*height大小的内存,被称为CG Raster Data,用于存储图像数据。占用ActiveMemory。

对于情况2:事情就不是这样了,由于苹果进行的优化,它们占用的不再是ActiveMemory而是WiredMemory。这就表示这部分内存不会受到系统对APP的内存限制,导致memory warning 甚至被系统杀死。

而且,在解码过程中,苹果似乎使用了纹理压缩算法,使得UIImageView解码的图片占用的实际内存约为我们自己解码占用内存的50%左右。

经过测试,在iphone6上,如果一个APP内存中只存储了图片,使用UIImageView的方式大约可以使用4倍于使用其他解码方式的内存数量。

对于情况3:字节对齐,可能很多人没有去了解,它占用的也是WiredMemory。我们就来讲讲什么是字节对齐,为什么要进行字节对齐。

3.2.4
字节对齐

在iOS上,有一个很容易被大家忽略的内存占用。CoreAnimation在显示图像的时候,会对没有字节对齐的图片进行copy

为了性能,底层渲染图像时不是一个像素一个像素渲染,而是一块一块渲染,数据是一块块地取,就可能遇到这一块连续的内存数据里结尾的数据不是图像的内容,是内存里其他的数据。所以在渲染之前CoreAnimation要把数据拷贝一份进行处理,确保每一块都是图像数据,对于不足一块的数据置空。

块的大小跟CPU 的缓存有关,ARMv7是32byte,A9是64byte,在A9下CoreAnimation应该是按64byte作为一块数据去读取和渲染,让图像数据对齐64byte就可以避免CoreAnimation再拷贝一份数据。能节约内存和进行copy的时间。

3.2.5
绘制成需要的大小

除了从系统和API层面对内存进行优化,我们还可以从图片的使用方式上进行优化。

对于常规的APP来说,大部分图片的显示区域大小是固定的,在图片显示出来之后就不会进行变化了。所以保存并展示一张比显示区域大的图片是十分浪费的。所以,对于这样的图片,如果我们在图片显示之前根据显示区域的大小进行缩放,并保存缩放过的图片。不但能在显示的时候省去缩放运算的开销,还能节约大量的内存。

3.2.6
图片解码显示流程

综合上面所述的优化方式,我们对于图片设计了这样的处理流程。

对于输入图片,我们首先判断是否需要支持无极缩放。如果不是,则通过CGContext绘制成显示区域的大小。否则,则通过UIImageView的drawViewHierarchyInRect进行解码,并通过UI缩放实现各种缩放效果。

对于图片显示区域变化的处理,如果是原大小解码的,则直接进行UI缩放,这种方法能支持使用动画。否则则重新发起图片请求,对图片进行重新绘制。

3.3
缓存优化

缓存是图片组件的核心模块之一,好的缓存模块能提高图片的利用率,减少资源重复加载的开销。对于缓存我们的核心指标就是缓存的增删查找速度和缓存的命中率。

3.3.1
内存缓存

我们通过3个手段提高这两个指标

第一:使用LRU+FIFO双队列的改进算法,提高缓存的命中率,解决进入新页面的突发大量图片的缓存污染问题。

第二:使用缓存模糊匹配算法。对于图片请求,如果发现缓存中有比请求大小更大的图片,则也视为命中缓存。

第三:使用C++编写缓存,组合链表和哈希表的存储结构,可以把LRU队列的增删查的时间复杂度将为O(1)

3.3.2
缓存模块架构

现在我们具体来看一下缓存的设计。缓存模块由一个HashTable,一个FIFO队列,一个LRU队列组成。

通过哈希表实现缓存的快速查询,通过FIFO队列和LRU队列实现缓存的淘汰逻辑。缓存的每个节点也是一个哈希表。也就是说对于每次缓存查询我们最多需要进行两次哈希表的查询就可以定位目标了。

每次查询,通过图片的url定位到一级缓存节点,再通过图片属性定位到二级缓存节点。那这里的属性指的是图片的大小,经过的处理步骤等参数。

比如第一张图片的属性就是大小为100X100,灰度图。第二章图片的属性是100X100经过AspectFit缩放过的图片。

如果开启缓存的模糊匹配,那么在定位2级节点的时候,只要找到图片大小大于请求大小的同类图片,就会被视为缓存命中。

解释了为什么需要二级缓存,我们再讲讲FIFO+LRU双队列的作用。这主要是为了解决突发的大量图片请求对缓存污染的问题。

对于单LRU队列,想象这样一个场景,用户进入了一个大量图片的页面后返回,大量的新图片涌入,直接将LRU队列清空。使用双队列则可以避免类似的问题,大量的新图片只会清空FIFO队列,而LRU队列中保存的数据还继续存在。


4
总结与后续


图形图像作为一个APP开发中的基础的部分,其实有很多东西可以深挖。

后面我应该还会写一篇关于iOS视频AR全景特效的文章,分析实时渲染技术在APP开发中的应用,如何构造一个复杂轨迹的粒子系统,如何使用纹理压缩技术,大幅度降低图片的内存开销等。

编辑于 2017-10-20 16:07