普通视图

发现新文章,点击刷新页面。
昨天以前小猪的博客

探秘越来越复杂的ImageIO框架

作者 DreamPiggy
2022年11月8日 07:45

ImageIO是Apple提供的上层框架,用于处理常见图像格式的编解码支持。这篇文章主要讲述了三个子话题:WebP/AVIF的支持进展,IOSurafce和硬件解码优化50%内存开销,以及CGImageSource机制变化导致的线程安全问题

ImageIO的定位是上层的支持框架,其封装了诸多的苹果的底层解码器,开源编解码器,硬件HEVC/ProRes加速器等等底层细节,致力于提供和上层UI框架(如UIKit/CoreGraphics)的可交互性。

在早些年的时候,我写过一系列文章,介绍了其API使用的基本流程(参考:《iOS平台图片编解码入门教程(Image/IO篇)》),以及有关其惰性解码的机制(参考:《主流图片加载库所使用的预解码究竟干了什么》)。

实话说,自从重心从iOS开发,转移到做LLVM工具链相关工作之后,我本以为不会再写这些上层iOS框架的文章了,但是SDWebImage这个开源库依旧没有合我预期的新Maintainer,来作为交接,因此现在还是忍不住先写这一篇吐槽和说明文章。

这篇文章会介绍,自iOS 13时代之后,苹果在ImageIO上做的一系列优化(“机制变化”),以及对开发者生态带来的影响。

WebP/AVIF新兴图像格式支持

自从HEVC/HEIF在苹果高调提供支持之后,由于硬件解码器的加持,本以为苹果会对其他竞争的媒体格式不再抱有兴趣,但实际上并非如此

WebP

WebP作为Google主导的无专利费的图像格式,其诞生后就一直跟随Chrome推广到各大Web站点,如今已经占据了互联网的一大部分(虽然其兄弟的WebM视频编码并没有这么热门)。

早在iOS 11时代,我就呼吁并提Radar希望Apple的ImageIO能够支持原生的WebP,而最终,时隔3年,在iOS 14上,ImageIO终于迎来了其内置的WebP支持,并且能够在Mac,iPhone上的各种原生系统应用中,预览WebP图像了。

那么,ImageIO对WebP的支持到底如何呢?答案其实很简单,ImageIO直接内置了开源的libwebp的一份源码和VP8的支持,并且去掉了编码的能力支持,所以能够以软件解码的形式支持WebP,不支持硬件解码。

screenshot-20221107-210603

换言之,使用这个ImageIO的系统解码器解码WebP,和使用我写的SDWebImageWebPCoder没有本质上的巨大差异(最多是一些编译器优化导致的差异),而后者还支持WebP编码(虽然耗时很慢)

AVIF

AVIF是基于AV1视频编码的新兴图像格式,作为HEVC的无专利费的竞争对手。AVIF与AV1,HEIF和HEVC,这两大阵营的关系一直是在相互竞争中不断发展的。而各大视频站如YouTube,Netflix,以及国内的Bilibli都在积极的推广这一视频格式,减少CDN带宽和专利费的成本。

而随着Apple在2018年加入AOM-Alliance for Open Media之后,我就预测有朝一日能够看到苹果拥抱这一开源标准。在2021年WebKit的开源部分曾经接受了PR并支持AVIF软件解码。而在2022的今年,iOS 16/macOS 13搭载的Safari 16,已经正式宣布支持了AVIF

虽然目前没有在其他系统应用中可以直接预览AVIF,但是我们已经看到这一趋势。在ImageIO的反编译结果中也看到了对.avif的处理和UTI的识别,虽然目前其本身只是会fallback到AVCI(AVC编码的HEIF,并不是AV1),但是我相信,后续OS版本一定会带来其对应的原生SDK和应用层的整体支持,甚至未来可以看到新iPhone搭载AV1的硬件解码器。

screenshot-20221107-211812

PS:广告时间,我之前也尝试过一些利用开源AV1解码器实现的AVIF解码库,以及macOS专用的Finder QuickLook插件,在未来到来之前,依旧可以发挥其最后的功用:)

1
brew install avifquicklook

IOSurface和硬件解码优化

IOSurface,作为iOS平台上古老的一套在多进程,CPU与GPU之间共享内存的方案,在早期iOS 4时代就已经诞生,但是一直仅仅作为系统私有的底层XPC通信用的数据格式

而从iOS 13之后,苹果对硬件解码的支持的图像格式的上屏渲染,大量使用了IOSurface,抛弃了原有的“主线程触发CGImage的惰性解码”的模式。

也就是说,《主流图片加载库所使用的预解码究竟干了什么》这篇文章关于ImageIO的部分已经彻底过时了,至少对于JPEG/HEIF而言是这样。

如何验证这一点呢?可以从一个简单的Demo,我们这里有一个4912*7360分辨率的JPEG和HEIC图(链接),使用UIImageView渲染上屏,开启Instruments,对比内存占用

IOSurface:

1
2
3
// JPEG/HEIF格式限定,iOS 13,arm64真机限定
let image = UIImage(contentsOfFile: largeJpegUrl.path)
self.imageView.image = image

CGImage:

1
2
3
4
5
// JPEG/HEIF格式限定,iOS 13,arm64真机限定
let source = CGImageSourceCreateWithURL(data as CFURL, nil)!
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil)!
let image = UIImage(cgImage: cgImage)
self.imageView.image = image

数据较多,直接看IOSurface的结果,可以发现,除了峰值上HEIC出现了翻倍,最终稳定占用都为51.72MB

而直接用CGImage(或者你换用模拟器而不是真机),则结果为137.9MB(RGBA8888)

  • JPEG(IOSurface)

screenshot-20221107-220103.png

  • HEIC(IOSurface)

screenshot-20221107-220104.png

备注:

  1. 在iOS 15+之后,这部分的Responsible Library会变成CMPhoto,iOS 15+新增的UIImage.preparingForDisplay()也利用了它的能力
  2. 使用UIImage(contentsOfFile:)和这里的UIImage(data:),在iOS 15上并无明显差异,但是在低版本如iOS 13/14上,可能出现UIImage(data:)对于HEIC格式,无法利用IOSurface的Bug,因此更推荐使用文件路径的接口

50%内存开销的奥秘

反编译可以发现,苹果系统库的内部流程,已经废弃了CGImage来传递这种硬件解码器的数据Buffer,而直接使用IOSurface,以换取更小的内存开销,达到同分辨率下RGBA8888的内存占用的37.5%(即3/8),同分辨率下RGB888的内存占用的50%(即1/2)

你可能会表示很震惊,因为数学公式告诉我们,一个Bitmap Buffer的内存占用为:

1
Bytes = BytesPerPixel * Width * Height

而要实现这个无Alpha通道的50%内存占用,简单计算就知道,意味着BytesPerPixel只有1.5,也就是说12个Bit,存储了3个256(2^8)色彩信息,换句话说0-255的数字用4个Bit表示!

你觉得数学上可能吗?答案是否定的,因为实际上是用了色度采样,并不是完整的0-255的数字,学过数字图像处理的同学都应该有所了解。

打开调试器,给IOSurface的initWithProperties:下断点,发现这个创建的IOSurface很有意思,PixelFormat = 875704438('420v'),即kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,看来使用了YUV 4:2:0的采样方式

screenshot-20221107-215148

因此,这里应该对应有两个Plane,分别对应了Y和U两个采样的平面,最终由GPU渲染时进行处理。这里不采取YUV 4:4:4的原因是,大多数JPEG/HEIF的无透明度的图像,在肉眼来看,采样损失的色度人眼差异不大,这一优化能节省50%内存占用,无疑是值得的。

值得注意的是,这里苹果处理具体采样的逻辑也是和原图像编码有关的,如果YUV 4:4:4编码的,则最终CMPhoto可能依旧会采取YUV 4:4:4进行解码并直接上屏,苹果专门的策略类来进行处理。

IOSurface和跨进程Buffer

不过,除了这一点,为什么只有真机能支持色度采样呢?答案和Core Animation的跨进程上屏有关。

之前有文章分享过之前,iOS的UI渲染是依赖于SpringBoard进程的中的CARenderServer子线程来处理的,因此这就有一个问题,我们如何才能将在App进程的Bitmap Buffer传给另一个进程的CARenderServer呢?

在iOS 13之前我们的方案,就是利用mmap,直接分配内存。但是mmap的问题在于,在最终Metal渲染管线传输时,我们依旧要经过一次额外的把Bitmap Buffer转为Texture并拷贝到显存的流程,因此这一套历史工作的横竖还有一些局限性。

在A12+真机的设备上,这一步借助IOSurface来实现跨CPU内存和GPU显存的高效沟通。

参考苹果的文档以及一些相关资料

IOSurface的资源管理本质上是Kernel-level而不是User-level的mmap的buffer,Kernel已经实现了一套高效的传输模型,借助Lock/Unlock来避免多个进程或者CPU/GPU之间发生资源冲突,因此这是上述优化的一个必要条件。

1
2
3
4
5
6
let surface: IOSurface
surface.lock(options: .readOnly, seed: nil)
defer { surface.unlock(options: .readOnly, seed: nil) }

// Use surface.baseAddress to read the pixel data
// Make sure to step by bytesPerRow for each row

开发者的痛,我的Public API呢?

现在揭秘了苹果优化JPEG和HEIF硬件解码内存开销之后,下一个问题是:

作为开发者,我如果加载一个JPEG/HEIF网络图,有办法也利用这个优化吗?

答:可以,但是使用时需要遵守以下几个原则:

  1. 对JPEG/HEIF网络图,如果仅有内存中的数据,则优先考虑使用UIImage(data:)
  2. 如果能够将数据下载到本地存储产生文件路径,则优先考虑使用UIImage(contentsOfFile:)加载
  3. 如果直接使用ImageIO接口,需要注意,调用CGImageSourceCreateImageAtIndex返回的是惰性解码的占位CGImage,而CGImageSourceCreateThumbnailAtIndex返回的是解码后的CGImage(也就根源上无法利用IOSurface优化)
  4. 如果要进行预解码,在iOS 15之后,请不要使用老文章写的,使用CGContext提取Bitmap Buffer的方案,优先调用UIImage.preparingForDisplay(),甚至是如果仅有CGImage的情况下,也推荐创建一个临时UIImage再来调用。其原理是,对于上文提到的,惰性解码的占位CGImage,CMPhoto能间接进行IOSurface的创建(利用后文讲到的CGImageGetImageSourcce),达到偷梁换柱的作用,而手动创建CGContext并没有这样的能力(可以参考#3368

如果遵守以上几点,那么我们依旧可以利用到这个优化,节省内存占用。否则会退化到传统的RGBA8888的内存开销上。尤其是关于第4点,苹果这个设计本想让开发者淡化IOSurface和CGImage的差异,但是我感觉反而增加了理解成本和性能优化成本。

另外,ImageIO和UIKit并没有提供更详细的IOSurface的公开API,只有其内部流程,本质间接使用了以下私有接口:

  • -[UIImage initWithIOSurface:]
  • CGImageSourceCreateIOSurfaceAtIndex

诚然,我们都知道能够直接调用任意的Objective-C/C API的姿势,这里也不再展开,只是需要注意,上文提到的这些优化,都存在特定iPhone硬件(A12+)和格式(JPEG/HEIF)的限定,需要注意检查可用性。

此外,从实践来看,苹果UIKit和ImageIO的上层接口,都更推荐文件路径的形式(因为可以优化为mmap读取,文件扩展名的Hint等逻辑),如果我还继续维护SDWebImage下去的话,未来也许会提供基于URLSessionDownloadTask以及文件路径模式的解码方案,或许就能更好地支持这一点。

不再安全的ImageIO

曾经,在我的最佳设计模式观念里,一个Producer,产出的Product,永远不应该反向持有Producer本身。但是这个想法被ImageIO团队打破了

从一个崩溃说起

在iOS 15放出后的很长一段时间里,SDWebImage遇到一个奇怪的崩溃问题#3273,从堆栈来看是典型的多线程同时访问了CFData(CFDataGetBytes)导致的野指针。起初我对此并没有在意,以为又是小概率问题,并且@kinarobin提了一个可能的CGImageSource过度释放的修复后,我就关闭了这个问题。但是随后越来越多用户依旧反馈这个崩溃,因此重新打开仔细看了一下,发现了其背后的玄机。

玄机在于,iOS 15之后,Core Animation在主线程渲染CGImage时,会调用一个新增的奇怪的接口CGImageGetImageSource。如果带着疑问进一步追踪调用堆栈,发现在调用CGImageSourceCreateImageAtIndex时,ImageIO会通过CGImageSetImageSource绑定一个CGImageSource实例,到CGImage本身的成员变量(实际来说,是绑定到了其结构体指针存储的CGImageProperty字典)。随后,Core Animation会通过获取到这个CGImageSource,后续在渲染时间接调用CGImageSource的相关接口。持有链条为 UIImage -> UIImageCGContent -> CGImageSource

screenshot-20221107-185536

崩溃的背后

这一机制改变,同时带来了一个隐患是:ImageIO它不再线程安全了。而且开发者不能修改Core Animation代码来强制加锁。

主要原因是,CGImageSource支持渐进式解码,而第三方自定义UIImage的子类时,有可能自己创建并持有这个渐进式解码的CGImageSource,并不断更新数据。在SDWebImage本身的设计中,我们通过加锁来保证,所有的对渐进式解码的调用,以及更新数据的方法,均能被同一把锁保护。

而当我们产出的CGImage,传递给了Core Animation,它无法访问这一把锁,而直接获取CGImageSource,并调用其相关的解码调用,就会出现多线程不安全的崩溃问题。

总而言之,这一设计模式的打破,即把Product和本不应该关心的Producer一起交给了外部用户,但是外部用户无法保障Producer的生命周期和调用,最终导致了这样的问题。

Workaround方案

最终,针对这个问题,SDWebImage提供了两套解决思路,第一个思路是直接通过CGContext提取得到自己的Bitmap Buffer,得到一个新的CGImage,切断整个持有链,最简单粗暴的修复,代价是全量关闭惰性解码无法用户控制,可能带来更高的内存占用(#3387,修复在5.13.4版本上)

第二个思路是,通过抹除掉CGImage持有的这些额外信息,采取通过CGImageCreate重新创建一个复制的CGImage,但是依旧保留了惰性解码的可选能力(#3425,方案在5.14.0版本上)。顺便提一句,通常动图(GIF/AWebP)都不支持硬件解码且切换帧频率较高,关闭惰性解码依旧是小动图的最佳实践。

PS:对感兴趣的小伙伴详细解释一下,第二个解决思路利用了CGImageProperty(类似于CGImage上存储的一个字典,按Key-Value形式存取)的时机特性,使用CGImageCreate重建CGImage时会完全丢失所有CGImageProperty(只有CGImageCreateCopy能够保留)。

而上文提到的CGImageGetImageSource/CGImageSetImageSource这些私有接口,本质上是操作这个com.apple.ImageIO.imageSourceReadRef的Key(全局变量kImageIO_imageSourceReadRef),Value存储了ImageIO的C++对象,并可以还原回一个CGImageSourceRef指针。一旦我们把CGImageProperty丢失掉,那么就能打断这个持有链条。

screenshot-20221107-202706

总结起来,ImageIO Team做出如此重大的设计模式改变,并没有在任何公开渠道同步过开发者,也没有提供公开接口能够控制这个行为,或者至少,没有暴露对应的CGImageSetImageSource接口,导致第三方开发者不得不采取曲线救国的解决方案去Workaround,这一点很值得让人吐槽。

总结

这篇文章看似讲了三个话题,其实背后有着一贯的缘由背景:

早期的ImageIO和各种上层框架的设计,是针对iPhone的低内存的机型做了深入优化,希望能尽量利用惰性解码,mmap缓存,换取较低内存开消,并且对各种无硬件解码的开源格式完全不感兴趣。

而最近几年,随着苹果芯片团队的努力,高内存,M1的统一内存,以及高性能芯片的诞生,苹果已经有充足的能力能够通过软件解码,共享内存,越来越多硬件解码器技术来满足主流的多媒体图像支持,本身这是一件好事。

不过问题在于历史遗下来的API,依旧保持了之前的设计缺陷,Apple团队却一直在,通过越来越Trick和Hack的方式解决问题,并没有给开发者可感知的新机制和手段来跟进优化(除开这一点吐槽,AppKit上的NSImage的NSImageRep这种代理对象设计,比UIImage的私有类UIImageContent设计要适宜的多,也灵活的多)

个人看法:软硬件一体加之闭源,会导致开源社区的实现,永远无法及时跟上其一体的私有集成,最终会捆绑到开发者和用户(开发者越强依赖苹果API和SDK,就会越强迫用户更新OS版本,进而捆绑硬件换代销售),这并不是一个好的现象🙃

招募

SDWebImage开源项目如今缺少长久维护的Maintainer,如果你对iOS/macOS框架开发感兴趣,对图像渲染和Apple平台有所涉猎,对Swift/Objective-C大型开源项目贡献有所期待,可以在我的GitHub上,以Email,Twitter私信等方式联系我。

iOS端矢量图解决方案汇总(SVG篇)

作者 DreamPiggy
2020年3月31日 03:01

iOS端矢量图解决方案汇总(SVG篇)

简介

矢量图,指的是通过一系列数学描述,能够进行无损级别的变化和缩放的一种图像。相比于标量图(如JPEG等标量图压缩格式),能够在绘制时进行任意大小伸缩而不产生模糊,甚至能够实现动态着色,动画等等一系列交互。

intro_raster_to_vecto

在当今移动端设备尺寸越来越复杂,各种操作系统级别的夜间主题(或者Dark Mode)越来越提倡的场景下,如果依旧使用标量图,我们需要针对不同的屏幕大小(如2x,3x),和对应主题场景(Light/Dark),提供NxM数量级的标量图,对于App大小开销是很大的。因此,使用矢量图是一个非常有效的解决方案。这个系列文章,就是主要侧重讲解iOS端上的矢量图解决方案。

第一章是关于SVG及其相应衍生方案的解决方案,后续会有其他矢量图相关的PDF章节,Lottie等。他们各自有不同的细节场景区分和优缺点。

SVG作为目前在Web上最流行的矢量格式,在iOS端的支持可以说是一言难尽。在这里,我从各个方向上总结了截至目前已有的实现(公开的方案,企业内部实现无从得知),方便对比选择最适合自己场景的选择。

Symbol Image

Symbol Image,是Apple在WWDC 2019和iOS 13上提供的矢量图解析方案。

之所以名称叫做Symbol Image,源自于这个技术方案的实现细节,它最早诞生于SVG字体规范:OpenType-SVG。这个规范是Adobe提出的,并且得到了包括Microsoft在内的多家公司支持。Apple自己的CoreText字体框架,其实早早就在iOS 11时代内部支持了SVG类型的font table。

制作Symbol Image

Symbol Image的整体API设计,其实不像是图像,更像是一种字体(和Icon Font类似)。

对于同一个Symbol Image,它可以看作是一个SVG Path的集合。前面提到,Symbol Image基于OpenType-SVG字体,对于字体来说,我们都知道字重的概念,用来决定渲染时候的线条粗细程度。

因此Symbol Image也有9个字重:Ultralight,Thin,Light,Regular,Medium,Semibold,Bold,Heavy,Black。与此同时,Symbol Image对每一个字重,支持了3种大小,分别是Small,Medium和Large。这也就是说,一个Symbol Image最多可以有27种大小字重的样式选择。

一般来说,从头构建一个Symbol Image会非常复杂,Apple推荐的方式,是通过使用SF Symbols App,来导出一个SVG模版,再通过Sketch来进行图层编辑。

Sketch2

从原始的SVG数据来看,每一个Symbol Image包含的所有样式都是一个单独的Path节点,对应了图标的绘制。如果要新建一个Symbol Image,需要完全删除Path节点,重新绘制矢量路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
<!--glyph: "uni100665.medium", point size: 100.000000, font version: "Version 15.0d7e11", template writer version: "5"-->
<g id="Notes">
</g>
<g id="Guides">
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
<path d="M 54.9316 0 L 57.666 0 L 30.5664 -70.459 L 28.0762 -70.459 L 0.976562 0 L 3.66211 0 L 12.9395 -24.4629 L 45.7031 -24.4629 Z M 29.1992 -67.0898 L 29.4434 -67.0898 L 44.8242 -26.709 L 13.8184 -26.709 Z"/>
</g>
<g id="Symbols">
<g id="Medium-M" transform="matrix(1 0 0 1 1682.22 1126)">
<path d="M 64.3555 -18.6035 C 67.8223 -18.6035 69.9219 -20.752 70.0195 -24.3164 C 70.166 -40.2832 70.5078 -59.7656 70.6543 -75.1953 C 70.6543 -78.7598 67.9688 -81.3477 64.3555 -81.3477 C 60.6934 -81.3477 58.0078 -78.7598 58.0078 -75.1953 C 58.1543 -59.7656 58.4961 -40.2832 58.6914 -24.3164 C 58.7891 -20.752 60.8887 -18.6035 64.3555 -18.6035 Z M 17.1875 -41.0645 C 18.1641 -40.0391 19.7266 -40.0879 20.7031 -41.1621 C 29.2969 -50.2441 39.9414 -56.0547 51.8066 -58.3984 L 51.6113 -73.3398 C 34.8145 -70.4102 19.5801 -61.7676 10.498 -50.7812 C 9.76562 -49.9023 9.76562 -48.6816 10.6445 -47.7539 Z M 108.057 -41.1133 C 108.984 -40.1367 110.498 -40.1367 111.523 -41.2109 L 117.969 -47.7539 C 118.896 -48.6816 118.896 -49.9023 118.164 -50.7812 C 108.984 -61.6699 93.7988 -70.3613 77.0508 -73.291 L 76.9043 -58.3496 C 88.7695 -56.0059 99.3164 -50.0488 108.057 -41.1133 Z M 36.6699 -21.5332 C 37.793 -20.4102 39.2578 -20.5078 40.2832 -21.7285 C 43.457 -25.1465 47.7051 -28.0273 52.3926 -29.7852 L 51.9531 -45.4102 C 42.4805 -43.0664 34.375 -37.9883 29.1992 -31.8359 C 28.3691 -30.8594 28.4668 -29.6875 29.3457 -28.8086 Z M 88.5254 -21.5332 C 89.5508 -20.459 90.8691 -20.5078 91.9922 -21.582 L 99.2676 -28.8086 C 100.195 -29.6875 100.293 -30.8594 99.4629 -31.8359 C 94.2871 -37.9395 86.1816 -43.0176 76.709 -45.4102 L 76.3184 -29.6875 C 81.0547 -27.8809 85.3516 -24.9512 88.5254 -21.5332 Z M 64.3555 6.25 C 69.043 6.25 72.8516 2.53906 72.8516 -2.09961 C 72.8516 -6.73828 69.043 -10.4492 64.3555 -10.4492 C 59.668 -10.4492 55.8105 -6.73828 55.8105 -2.09961 C 55.8105 2.53906 59.668 6.25 64.3555 6.25 Z"/>
</g>
</g>
</svg>

导入Symbol Image

导入Symbol Image的方式非常简单,你只需要将制作好的Symbol Image,向Xcode的Asset Catalog窗口拖动,就可以集成。Xcode可以会展示对应的预览效果。

截屏2020-03-30下午6.08.56

另外,实际上产生的文件夹后缀为.symbolset,这个不同于普通的Asset Image(后缀名.imageset),也就意味着你可以同时引入一个同名的Symbol Image和普通Image。

截屏2020-03-30下午6.09.18

使用Symbol Image

对于iOS 13系统提供的自带Symbol Image,UIKit提供了init(systemName:)方法来获取,对于App自行提供的Symbol Image,我们使用init(named:)方法。

注意,你可以同时包含一个Symbol Image和普通的Asset Image,共享一个Name。这样设计的好处,在WWDC上有介绍,是为了兼容iOS 12等低系统版本,在iOS 13上,Symbol Image优先级永远高于普通Asset Image,在iOS 12会自动fallback。

1
2
3
4
5
6
7
8
9
10
11
let imageView = UIImageView()
let symbolImage = UIImage(named: "my.symbol.image")
// 默认配置下,这个symbol image是template的,意味着他不会含有颜色,颜色由UIView级别tintColor决定
imageView.image = symbolImage

// 如果确定要获取系统Symbol Image
let systemSymbolImage = UIImage(systemName: "wifi.exclamationmark")

// 如果要指定颜色
let redSymbolImage = symbolImage.withTintColor(.red, renderingMode: .alwaysOrigin)
imageView.image = redSymbolImage

对于Symbol Image来说,我们可以指定在运行时需要的字重

1
2
3
4
5
let regularSymbolImage = UIImage(named: "my.symbol.image")
// 指定你想要的字号,字重,这里是18号,Bold 字重,Large 大小
let symbolConfiguration = UImage.SymbolConfiguration(pointSize: 18, weight: .large, scale: .large)
let boldSymbolImage = regularSymbolImage.applyingSymbolConfiguration(symbolConfiguration)
imageView.image = boldSymbolImage

另外,我们还可以配合AttributedString使用,只要使用TextAttachment传入对应的Symbol Image即可。

1
2
3
4
5
6
let textView = UITextView()
// 可以微调Symbol Image与文字的对齐
let baselineSymbolImage = symbolImage.withBaselineOffset(fromBottom: 1.0)
let imageAttachment = NSTextAttachment(image: baselineSymbolImage)
let imageString = NSAttributedString(attachment: imageAttachment)
textView.attributedText = imageString

优缺点

优点:

  • iOS原生支持,工具链完善
  • SwiftUI原生支持,截止目前Image能唯一使用的矢量方案(排除UIViewRepresentable)
  • 支持和AttributedString无缝混合,类似Icon Font

缺点:

  • iOS 13+ Only
  • 通过字体属性控制大小,取决于UI场景,做到Pixel级别的拉伸会是一个问题
  • 需要单独制作Symbol Image,跨平台,Web使用痛点

CoreSVG

CoreSVG是iOS 13支持Symbol Image的背后的底层SVG渲染引擎,使用C++编写。

截至目前,CoreSVG依然属于Private Framework,社区也有很多人向Apple提了反馈并建议开放出来,可能在之后的WWDC 2020我们能够得知更多的消息。

注意!以下方法均为使用了CoreSVG的Private API,可能随着操作系统变动会有改变,并且有审核风险,如果需要线上使用,请自行进行代码混淆等方案。

通过Asset Catalog使用SVG

目前Xcode不支持直接拖动SVG文件来集成到Asset Catalog,因为拖动SVG默认会当作Symbol Image处理。

但是我们可以通过一个取巧的方式来实现,Xcode支持PDF矢量图(从iOS 11与Xcode 9开始支持,PDF章会讲解)。因此,我们可以将SVG后缀改成PDF,然后拖动到Xcode中,最后再修改回SVG后缀名,并且同步.imageset/Contents.json里面的文件名即可,如下:

EUR_hKSUwAA1-65

当你添加好SVG图像后,可以通过Name,以和PDF矢量图一样的方式来引入和使用,如下

1
2
3
4
5
UIImageView *imageView = [UIImageView new];
UIImage *svgImage = [UIImage imageNamed:@"my_svg"];
imageView.image = svgImage;
// 然后我们可以自由缩放ImageView的大小,会自动触发矢量绘制
imageView.frame = CGRectMake(0, 0, 1000, 1000);

从运行时来看,加入Asset Catalog的SVG矢量图的UIImage,含有对应的CGSVGDocumentRef对象,并且也包含了一个标量图的缩略图,可以供缩略图或者其他系统API来调用。并且在Xcode的Interface Builder上也会有明显的SVG标识(类似PDF)

EUU_DLPU8AM5KHD

加载任意SVG数据(网络)

除了能够通过Asset Catalog添加SVG图像,通过CoreSVG,我们可以在运行时去解析网络数据下载得到的SVG数据,为此能提供更为广阔的应用场景。

1
2
3
4
5
UIImageView *imageView = [UIImageView new];
NSData *data;
CGSVGDocumentRef document = CGSVGDocumentCreateFromData((__bridge CFDataRef)data, NULL);
UIImage *svgImage = [UIImage _imageWithCGSVGDocument:document];
imageView.image = svgImage;

渲染SVG矢量图到标量图

一些UIKit的视图,或者一些图像处理,对矢量图支持并没有考虑,或者是我们在做性能优化时,需要将矢量图光栅化得到对应的标量图。CoreSVG提供了和CoreGraphics的PDF类似的接口,允许你去绘制得到对应的标量图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
CGSVGDocumentRef document; // 原始SVG Document
CGSize targetSize; // 指定标量图大小
BOOL preserveAspectRatio; // 是否保持宽高比

// 获取SVG的canvas大小,本质上是按照SVG规范,将viewPort和viewBox计算得出的
CGSize size = CGSVGDocumentGetCanvasSize(document);
// 计算Transform
CGFloat xRatio = targetSize.width / size.width;
CGFloat yRatio = targetSize.height / size.height;
CGFloat xScale = preserveAspectRatio ? MIN(xRatio, yRatio) : xRatio;
CGFloat yScale = preserveAspectRatio ? MIN(xRatio, yRatio) : yRatio;

CGAffineTransform scaleTransform = CGAffineTransformMakeScale(xScale, yScale);
CGSize scaledSize = CGSizeApplyAffineTransform(size, scaleTransform);
CGAffineTransform translationTransform = CGAffineTransformMakeTranslation(targetSize.width / 2 - scaledSize.width / 2, targetSize.height / 2 - scaledSize.height / 2);
// 开始CGContext绘制
UIGraphicsBeginImageContextWithOptions(targetSize, NO, 0);
CGContextRef context = UIGraphicsGetCurrentContext();
// UIKit坐标系和CG坐标系转换
CGContextTranslateCTM(context, 0, targetSize.height);
CGContextScaleCTM(context, 1, -1);
// 应用Transform
CGContextConcatCTM(context, translationTransform);
CGContextConcatCTM(context, scaleTransform);
// 绘制SVG Document
CGContextDrawSVGDocument(context, document);
// 获取标量图
image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

SVG导出

目前,CoreSVG没有提供类似于PDF的修改元素的接口,我们只能直接对SVGDocument进行导出。或许随着未来框架的开放,会有类似于目前CoreGraphics对PDF进行编辑的高级接口。

1
2
3
4
5
6
7
8
9
// 获取SVG Document
UIImage *svgImage;
CGSVGDocumentRef document = [svgImage _CGSVGDocument];
NSURL *url = [NSURL fileURLWithPath:@"/tmp/output.svg"];
NSMutableData *data = [NSMutableData data];
// 导出到Data
CGSVGDocumentWriteToData(document, (__bridge CFMutableDataRef)data, NULL);
// 或者文件
CGSVGDocumentWriteToURL(document, (__bridge CFURLRef)url, NULL);

优缺点

优点

  • 能够支持目前已有的大量SVG,在Android和Web端复用
  • Apple原生支持,稳定性有一定保证,并且随系统升级会持续优化
  • 性能高,CoreSVG利用了CoreGraphics系统库和内部的SPI做矢量绘制,目前性能最好

缺点

  • 目前是私有Framework,有审核和使用风险
  • 可能存在一些SVG元素兼容问题,需要不断摸索
  • SwiftUI不支持,需要使用UIViewRepresentable

三方SVG库

SVGKit

SVGKit是最早的iOS上开源SVG渲染方案,已经有8年之久。SVGKit内部支持两种渲染模式,一种是通过CPU渲染(CoreGraphics重绘制),一种是通过GPU渲染(CALayer树组合)。有着不同的兼容性和性能。

示例

1
2
3
4
5
6
// CPU渲染
SVGKImageView *imageView = [SVGKFastImageView new];
// GPU渲染
imageView = [SVGKLayeredImageView new];
SVGKImage *svgImage = [[SVGKImage alloc] initWithData:data];
imageView.image = svgImage;

优点

  • 支持纯Objective-C
  • 如果是支持的图像,性能相对较高(1000个级别的Path可在1秒内渲染)

缺点

  • 社区不再维护,大量Issue无人跟进解决
  • 不遵循语义版本号,用分支发布更新,下游无法依赖
  • 部分SVG特性虽然声明支持,但存在问题,如Gradient等,缺少单测
  • 不支持SVG动画

Macaw

Macaw是一个矢量绘制框架,提供了非常简单的DSL语法来描述矢量路径绘制的场景。它本身不是和SVG强绑定的,但是对SVG格式提供了兼容和支持

示例

1
2
3
let node = try! SVGParser.parse(path: "/path/to/svg")
let imageView = SVGView()
imageView.node = node

优点

  • 目前最活跃和成熟的iOS端SVG开源框架(在GitHub上)
  • 支持DSL去直接生成矢量图,修改节点等,非常强大
  • 支持SVG动画(部分特性)

缺点

  • 部分SVG特性特性声明不支持
  • SVG性能渲染差(相对于SVGKit),依赖大量的的CPU绘制操作(非CALayer组合),可能需要结合异步绘制框架

SwiftSVG

SwiftSVG是一个专门针对SVG Path等常见特性的矢量图解析框架,他不侧重于完整的SVG/1.1规范支持,而是保证了基本的绘制实现的正确性,并且支持导出SVG的Path到UIBezierPath

示例

1
2
3
4
5
6
let svgURL = URL(string: "https://openclipart.org/download/181651/manhammock.svg")!
let hammock = UIView(SVGURL: svgURL) { (svgLayer) in
svgLayer.fillColor = UIColor(red:0.52, green:0.16, blue:0.32, alpha:1.00).cgColor
svgLayer.resizeToFit(self.view.bounds)
}
self.view.addSubview(hammock)

优点

  • 性能相对MacPaw较好
  • 对Path,Circle等常见元素,有着良好的兼容性和完整单测,基本上只用这些特性的SVG不存在问题
  • 支持导出UIBezierPath,可以用作一些描边的交互
  • 提供了便携方法,能直接读取Xcode的Data Asset,URL等

缺点

  • 基本上只针对Path,Circle等元素有良好的支持,其他的Gradient,Text等均不支持
  • 不支持SVG动画

VectorDrawable

VectorDrawable是Android平台上官方提供的一套矢量图解决方案,他是以一个类似SVG的XML表达形式,来描述矢量图的绘制方式。

截屏2020-03-30下午5.44.59

从整体设计上看,VectorDrawable基本上是对SVG的精简和二次改造,大部分的元素在SVG中都有对应的概念,并且样式属性也一一对应。甚至,Android Studio支持直接将SVG导出成VectorDrawable文件并直接集成。

在iOS上平台上,Uber内部开源了一套自己在用的VectorDrawable实现:Cyborg,通过利用CoreGraphics和CoreAnimation来渲染VectorDrawable文件。

使用VectorDrawable渲染

VectorDrawable提供了一个专门用于矢量图的View,并且能够制定对应的Theme(Theme是用来支持不同资源的Dark Mode切换的)。

1
2
3
4
5
6
// Bundle加载
let vectorView = VectorView(theme: myTheme)
vectorView.drawable = VectorDrawable.named("MyDrawable")

// Data加载
vectorView.drawable = VectorDrawable.create(from: data)

如果这个不满足,你也可以通过CALayer来做渲染,做更为细致的调节。并且VectorDrawable也提供了一些定制项(如设置tintColor)

优缺点

优点

  • 能够和Android端复用,并且由于可由SVG生成,意味着Web端也可复用设计资源
  • 性能良好,无论官方还是Example测试,除去CoreSVG外都是最快的渲染速度

缺点

  • 目前iOS实现不支持动画(AnimatedVectorDrawable)
  • 部分SVG实现VectorDrawable不支持,需要设计资源修改
  • Uber内部开源,可能存在未来持续社区建设和维护成本,需要评估

SVG-Native

SVG-Native是由Adobe主导提出的一个W3C规范,目前处于Draft Stage,不过由于Apple,Google的赞同,大概率会在2020年内通过,并且正式规范定稿。

SVG-Native基于目前的SVG/1.1版本,是SVG/1.1的真子集(即一个SVG-Native图一定可以被浏览器正确渲染)。

注:曾经W3C有一个SVG Tiny的规范,但是它是针对移动浏览器场景的,和SVG-Native解决的问题是不一样的。

它针对移动平台,桌面平台等非浏览器场景做了针对性定制,废弃了一些Native端非常困难实现的功能,包括:

  • scripting: 不依赖JavaScript环境
  • animations: 不支持动画
  • filters: 不支持滤镜,部分效果(如文字滤镜)依赖实现复杂
  • masks: 不支持蒙层
  • patterns: 不支持仿制图章,Color Pattern
  • texts: 不内嵌文字,文字使用Path绘制
  • events: 点击事件等,因为没有Script交互自然不需要
  • CSS3:CSS3是一个完整布局系统,大量属性远远超过SVG的功能,如Flexbox,Media-Query,都是不必要的,只有基本的渲染属性

可以看出,这些剥离的功能都是和浏览器场景完全绑定的,不适用于通用的App内渲染矢量图的用途。SVG-Native更适合桌面/移动的App,渲染器实现也会精简很多,容易单元测试,并且可供操作系统内嵌集成。

使用

Adobe提供了一个目前Draft规范的渲染实现SVG Native Viewer,目前提供了多种渲染引擎的桥接,包括我们熟悉的CoreGraphics和Skia。

SVG-Native解码器,能够以标量图的方式,渲染SVG到一个指定大小的CGContext上,性能目前看足够快(和CoreSVG对比)。目前一般是通过重写drawRect来让View大小变化时进行重绘。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (void)drawRect:(NSRect)dirtyRect {
[super drawRect:dirtyRect];

Document* d = [[[self window] windowController] document];
SVGNative::SVGDocument* doc = [d getSVGDocument];
if (!doc)
return;
NSGraphicsContext* nsGraphicsContext = [NSGraphicsContext currentContext];
CGContextRef ctx = (CGContextRef) [nsGraphicsContext CGContext];
SVGNative::CGSVGRenderer* renderer = static_cast<SVGNative::CGSVGRenderer*>(doc->Renderer());
CGRect r(dirtyRect);
CGAffineTransform m = {1.0, 0.0, 0.0, -1.0, 0.0, r.size.height};
CGContextConcatCTM(ctx, m);
renderer->SetGraphicsContext(ctx);
doc->Render(r.size.width, r.size.height);
renderer->ReleaseGraphicsContext();
}

优缺点

优点

  • W3C规范,可以确保未来规范的准确性,并且操作系统提供商,如Apple更容易集成
  • SVG-Native是SVG1.1的真子集,意味者可以复用到Web上
  • SVG-Native会是未来的OpenType-SVG实现,意味着Adobe字体或者设计师群体更容易接受

缺点

  • SVG-Native是SVG真子集,意味着目前的SVG设计资源,需要适配修改才可支持
  • 截至目前,SVG-Native依然处于Draft阶段,稳定,推广普及需要较长时间
  • SVG-Native目前只有Adobe的解析器实现,部分特性在CoreGraphics上工作并不良好
  • 目前没有看到动画的支持

总结

总结一下关于SVG的相关解决方案,可以看出,没有一种Case能够涵盖所有场景,当然,这和Apple本身对矢量图支持的建设有一定关系,大部分建设依赖于开源社区。因此,通常情况下需要根据自己具体的实际需要来选择,比如:

  • 只考虑Path,Circle等矢量路径:使用SwiftSVG、Macaw即可
  • 考虑和Android复用:使用VectorDrawable
  • 不考虑iOS 13以下兼容:优先用Symbol Image和CoreSVG
  • 考虑SVG动画:Macaw
  • 面向未来:SVG-Native

参考资料

主流图片加载库所使用的预解码究竟干了什么

作者 DreamPiggy
2019年1月18日 22:09

主流图片加载库所使用的预解码究竟干了什么

很多图片库,都会有一个类似叫做Force-Decode,Decode For Display之类的感念,很多人可能对这个过程到底是为了解决什么问题不清楚,这里写一个文章来说明它。

这里列举了各个图片库各自的说法,其实讲的都是完全相同的一个概念。

  • SDWebImage:使用了forceDecode, decompressImages的概念
  • YYWebImage:使用了decodeForDisplay的概念
  • Kingfisher:使用了backgroundDecode的概念

为什么需要这个过程,解决了什么问题

为了解释这个过程具体的解决问题,需要至少了解苹果的系统解码器的工作流程。

Image/IO和惰性解码

Image/IO库是苹果提供的,跨所有Apple平台的系统解码器,支持常见的各种图像格式(JPEG/PNG/TIFF/GIF/HEIF/BMP等)的编码和解码。同时,有丰富的接口来和诸如Core Graphics库协作。

常见的网络图像解码,由于拿到的是一个压缩格式,肯定需要想办法转换到对应的UIImage。UIImage可以分为CGImage-based和CIImage-based,后者相对开销大一些,主要是用作滤镜等处理,不推荐使用。所以基本上各种图片库解码,为了解码压缩格式,得到一个CGImage,都是用了Image/IO的这个API:

CGImageSourceCreateImageAtIndex

实际上,Image/IO,除了调用具体的解码器产生图像的Bitmap以外,为了和Core Graphics库协作,也直接以CGImage这个数据结构来传递,但是他采取了一种惰性解码的创建方式。因此这里首先要了解CGImage初始化的接口和对应的行为:

CGImageCreate

这里面其他参数都好理解,具体看一个provider参数,这里面需要传入一个CGDataProviderRef,它是一个关于描述怎么样去获取这个Bitmap Buffer二进制数据的结构。再来看看CGDataProvider的初始化方法,这时候发现它有多种初始化方式,决定了后面的行为。

这个方法,允许接受一个CGDataProviderCallbacks参数,看说明,可以知道,这个callbacks是一系列函数指针回调,目的是提供一个sequential-access的访问模式,同时Data Buffer会被copy出去。同时,由于传入的是callbacks,可以做到不立即提供Data Buffer,而是在未来需要的时候再触发。

这个方法,类似于CGDataProviderCreate,但是注明了这个callbacks生成的Data Buffer不会被Copy,Core Graphics只会直接访问返回的Data Buffer指针,需要自己管理好内存。

这个方法,需要提供一个CFData,同时也不会Copy这个CFData。在Release的同时由Core Graphics自动释放CFData的内存,开发者不需要管理内存。

剩余的具体初始化方法可以看文档说明,总而言之,CGDataProvider提供了各种各样的访问模式,如直接访问,拷贝访问,惰性访问等。而现在问题就来了,前面说到,Image/IO创建CGImage的时候,也需要提供一个DataProvider来指明图像的Bitmap Buffer数据从哪里获取,它是具体用了什么方式呢?

答案是使用了一个私有APICGImageCreateWithImageProvider,经过查看,这个方式实际类似CGDataProviderCreateDirect,也就是通过一组callbacks,提供了一个直接访问,允许惰性提供Data Buffer的方式。换句话说,这也就意味着,Image/IO,其实采取的是一种惰性解码方式。解码器只预先扫描一遍压缩格式的容器,提取元信息,但是不产生最终的Bitmap Buffer,而是通过惰性回调的方式,才生成Bitmap Buffer。

换句话说,通过所有CGImageSourceCreateImageAtIndex这种API生成的CGImage,其实它的backing store(就是Bitmap)还没有立即创建,他只是一个包含了一些元信息的空壳Image。这个CGImage,在最终需要获取它的Bitmap Buffer的时候(即,通过相应的API,如CGDataProviderCopyDataCGDataProviderRetainBytePtr),才会触发最后的Bitmap Buffer的创建和内存分配。

Image/IO和Force Decode

理解到上面Image/IO的惰性解码行为,理解了上面一点,现在说明Force Decode所解决的问题。

众所周知,iOS应用的渲染模式,是完全基于Core Animation和CALayer的(macOS上可选,另说)。因此,当一个UIImageView需要把图片呈现到设备的屏幕上时候,其实它的Pipeline是这样的:

  1. 一次Runloop完结 ->
  2. Core Animation提交渲染树CA::render::commit ->
  3. 遍历所有Layer的contents ->
  4. UIImageView的contents是CGImage ->
  5. 拷贝CGImage的Bitmap Buffer到Surface(Metal或者OpenGL ES Texture)上 ->
  6. Surface(Metal或者OpenGL ES)渲染到硬件管线上

这个流程看起来没有什么问题,但是注意,Core Animation库自身,虽然支持异步线程渲染(在macOS上可以手动开启),但是UIKit的这套内建的pipeline,全部都是发生在主线程的。

因此,当一个CGImage,是采取了惰性解码(通过Image/IO生成出来的),那么将会在主线程触发先前提到的惰性解码callback(实际上Core Animation的调用,触发了一个CGDataProviderRetainBytePtr),这时候Image/IO的具体解码器,会根据先前的图像元信息,去分配内存,创建Bitmap Buffer,这一步骤也发生在主线程。

屏幕快照 2019-01-18 下午1.44.45

这个流程带来的问题在于,主线程过多的频繁操作,会造成渲染帧率的下降。实验可以看出,通过原生这一套流程,对于一个1000*1000的PNG图片,第一次滚动帧率大概会降低5-6帧(iPhone 5S上当年有人的测试)。后续帧率不受影响,因为是惰性解码,解码完成后的Bitmap Buffer会复用。

所以,最早不知是哪个团队的人(可能是FastImageCache,不确定)发现,并提出了另一种方案:通过预先调用获取Bitmap,强制Image/IO产生的CGImage解码,这样到最终渲染的时候,主线程就不会触发任何额外操作,带来明显的帧率提升。后面的一系列图片库,都互相效仿,来解决这个问题。

具体到解决方案上,目前主流的方式,是通过CGContext开一个额外的画布,然后通过CGContextDrawImage来画一遍原始的空壳CGImage,由于在CGContextDrawImage的执行中,会触发到CGDataProviderRetainBytePtr,因此这时候Image/IO就会立即解码并分配Bitmap内存。得到的产物用来真正产出一个CGImage-based的UIImage,交由UIImageView渲染。

ForceDecode的优缺点

上面解释了ForceDecode具体解决的问题,当然,这个方案肯定存在一定的问题,不然苹果研发团队早已经改变了这套Pipeline流程了

优点

  • 可以提升,图像第一次渲染到屏幕上时候的性能和滚动帧率

缺点

  • 提前解码会立即分配Bitmap Buffer的内存,增加了内存压力。举例子对于一张大图(2048*2048像素,32位色)来说,就会立即分配16MB(2048 * 2048 * 4 Bytes)的内存。

由此可见,这是一个拿空间换时间的策略。但是实际上,iOS设备早期的内存都是非常有限的,UIKit整套渲染机制很多地方采取的都是时间换空间,因此最终苹果没有使用这套Pipeline,而是依赖于高性能的硬件解码器+其他优化,来保证内存开销稳定。当然,作为图片库和开发者,这就属于仁者见仁的策略了。如大量小图渲染的时候,开启Force Decode能明显提升帧率,同时内存开销也比较稳定。

WebP和软件解码

当我们说完Image/IO系统库和Force Decode关系后,再来看看另一种情形。近些年来,一些新兴的图像压缩格式,如WebP,得益于开源,高压缩率,更好的动图支持,得到了很多开发者青睐。

然而,这些图像格式,并没有被iOS系统解码器所支持,也没有对应的硬件解码。因此,现有的图片库在支持新图像格式的时候,都采取了使用CPU进行软件解码来处理。这些软件解码器,大部分是为了跨平台而实用的,因此,一般都有一个接口直接产出一个Bitmap Buffer来用于渲染。如WebP的官方解码器libwebp,就有这样一个接口:

1
WEBP_EXTERN VP8StatusCode WebPDecode(const uint8_t* data, size_t data_size, WebPDecoderConfig* config);

上面我们知道CGImage和CGDataProvider的不同初始化方式,开发者面临这样的接口,有两个选择:

  1. 使用CGDataProviderCreateWithData,直接把产出的Bitmap buffer存储到CGImage中
  2. 参考Image/IO,使用CGDataProviderCreateDirect,使用惰性解码

当然,为了最大程度的利用苹果系统的那套Pipeline和现有代码流程,第一直觉的使用方式当然是方案2。然而,理想是丰满的,现实是骨感的。之所以Image/IO能够采取惰性解码这一套流程,最大的原因在于Image/IO的原生图像格式都是硬件解码,且解码速度足够快

同样的方式,套用到WebP上,反而会带来更大的问题。首先,WebP格式自身的压缩算法采取了VP8,比起JPEG/GIF的压缩算法要复杂的多,开销大。第二,libwebp只有软件解码的实现,无法利用硬件来加快解码速度。

注:YY的作者有专门跑过测试,对于iPhone 6上,同样压缩比的有损JPEG和WebP相比,解码速度慢大概50%-100%,无损的PNG和WebP相比比较接近。参考:https://blog.ibireme.com/wp-content/uploads/2015/11/image_benchmark.xlsx

所以,主流图片库最终的选择方式,都是方案1,即立即生成了一个含有Bitmap Buffer的CGImage。这样,到最终UIImageView渲染的时候,也不会有额外的主线程解码的开销,除了需要提前分配内存以外别的还好。

WebP软件解码和Force Decode

前面说到,对于WebP等非硬件解码器支持的图片压缩格式,大多数图片库采取了方案1。但是现有的一些图片库(如SDWebImage/YYWebImage),仍然对这个非空壳的CGImage,执行了Force Decode的过程,按理论上说已经有了Bitmap Buffer,不会触发主线程解码,这又是为什么?

这个原因,是源于先前的Force Decode的实现机制,利用到了CGContextDrawImage这个接口。

CGContextDrawImage,内部实现非常复杂,因为对于一个CGImage来说,他只是Bitmap Buffer+图像元信息的合集,但是一个CGContext,是有一个固定的ColorSpace,渲染模式等等信息,是和具体的上下文相关的。

因此,当通过这个API画在一个画布上时,会触发很多细节的逻辑,这里举几个比较有影响的。

  1. 首先会根据CGImage的ColorSpace转换到CGContext的ColorSpace(比如说CGImage使用了sRGB,CGContext用了P3+宽色域),需要去对Bitmap的每个像素做转换;如果Bitmap排列(如CGImage采取RGB888,CGContext采取BGRA8888)不同,也会以CGContext为准进行转换。
  2. CGContext如果有Blend Mode,也会在此流程中做Alpha合成。
  3. 如果CGContext和大小和CGImage不同,会触发对应的重采样过程,开发者可以控制重采样的质量高低
  4. 还有一个关于内存管理的,由于CGContext目标就是为了做渲染层,因此它依赖这个假设,当你调用CGContextDrawImage的时候,会直接把取到的Bitmap Buffer,立马提交到render server进程上(通过mmap),这样最后在渲染Pipeline(前文提到)中,就可以省去第5步(拷贝CGImage的Bitmap Buffer到Surface(Metal或者OpenGL ES Texture)上)。见下:

其实对于大部分图片库的Force Decode来说,因为都开的是一个和CGImage同大小的空白画布,这里主要是第1和第4项会影响到性能。一些图片库,因此依旧保留了Force Decode的流程,也有各种各样的具体缘由。

WebP软件解码进行Force Decode的优缺点

了解了为什么对于WebP等软件解码,依然使用Force Decode的缘由,再来看看这种Case下的优缺点

优点

  1. 能够提前把Bitmap Buffer转移到渲染进程上,减少了未来渲染时的内存拷贝操作(虽然比起解码来说,这部分时间相当的小)
  2. 如果原始解码出来的Bitmap Buffer,iOS硬件屏幕不直接支持(如RGB888,CMYK),会提前转换好,避免渲染时主线程的转换
  3. (?)可以从Xcode视觉上看起来App占用内存变小,因为Bitmap Buffer提前拷贝到render进程了

缺点

  1. 在已经有Bitmap Buffer的情况下,再开一个画布,并触发Draw,大图会出现一个临时的内存峰值(约250%~300%原始Bitmap Buffer的占用)

可以看出,这也是一个类似空间换时间的策略。当然,这个策略的优势没有Image/IO那样大,因为实际上转换和拷贝内存的性能开销,比起解码和创建Bitmap Buffer都是非常低的。但是一些图片库把这个选择权利交给了用户,而自己不做这个策略选择。

PS小轶闻:SDWebImage其实最早只有对Image/IO的那个ForceDecode流程,后来在4.0加入WebP支持的时候,也不清楚这个流程影响,顺便就一块使用这套流程了。可以说是所谓的误打误撞。

总结

这篇文章基本介绍了Image/IO的惰性解码流程,以及Force Decode这套流程它所解决的问题,以及优缺点。无论对图片库作者,还是图片库进阶使用者,都解释了相关的疑问。希望对图片编解码方向有兴趣的同学可以多多学习交流。

iOS平台图片编解码入门教程(vImage篇)

作者 DreamPiggy
2017年11月12日 18:32

这篇教程,是系列教程的第三篇,前篇名为iOS平台图片编解码入门教程(第三方编解码篇)。由于vImage已经属于较为底层框架,这一篇将不会特别着重图片封装格式的编解码,会介绍一些Bitmap级别的操作,包括了图像的色彩转换,Alpha合成、基本几何变换等实际用法。由于教程侧重是图像格式,所以不会介绍vImage强大的Convolution等知识,这方面涉及到数字图像处理的复杂知识,不是教程的目标

基本介绍

vImage是Apple的Accelerate库的一部分,侧重于高性能的图像Bitmap级别的处理。库本身全部是C的接口,而且不同于Core系列的(Core Graphics/Core Foundation)C接口,是比较贴近传统C语言的接口,不会有XXXRef这种贴心的定义,而且很多接口需要自己手动分配内存。

vImage按照功能,可以分为Alpha Compositing(Alpha合成)、Geometry(几何变换)、Conversion(色彩转换)、Convolution(卷积,用于图像滤镜)Morphology(形态学处理)等。这里主要介绍的,就是色彩转换,Alpha合成,以及几何变换的内容。

首先需要对vImage的基本接口有所了解,有这么几个概念:

  • vImage_Buffer: 对应Bitmap的数据,只有最基本的width、height、rowBytes(stride)以及data
  • vImage_CGImageFormat: 每个vImage的功能,会提供不同色彩格式的类似接口,比如会有ARGB8888,Planar8的同样功能。这里ARGB8888指的是ARGB排列,每通道占8个Bit,也就是一个Piexel占32Bit。而vImage还有一个常见的色彩格式Plane8,指的是只有一个通道(平面),按照顺序排列,比如{R, R, R, R}这样,更方便进行计算
  • vImage_Flags: 每个vImage接口,都会有一个flags 参数来控制一些选项,比如说可以自己定义内存分配,背景色填充策略,重采样策略等,默认的是kvImageNoFlags
  • vImage_Error: 每个vImage的接口,都会返回这个result,来让用户确认是否成功,以及失败的原因,在Debug下比较有帮助

为了统一期间,以下的内容,都是基于ARGB8888色彩格式的输入来说明的。其他的情况处理,参考同名接口的不同格式即可。

色彩转换

色彩转换指的是将图像的Bitmap格式,从一个色彩格式,比如ARGB8888,转换到另一个色彩格式,比如说RGB888的功能。对于RGB来说,一般来说就是通道的增加和减少。当然还有RGB转为Planar8的情况。

vImage对这些色彩转换的功能,统一提供了方法vImageConvert_AtoB,比如ARGB8888转RGB888,就可以用下面的代码来处理。顺便通过这个代码,来简单了解vImage的API的基本用法。

先来定义几个简单的结构体,方便后续使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 为了方便,我们首先直接定义好ARGB8888的format结构体,后续需要多次使用
static vImage_CGImageFormat vImageFormatARGB8888 = (vImage_CGImageFormat) {
.bitsPerComponent = 8, // 8位
.bitsPerPixel = 32, // ARGB4通道,4*8
.colorSpace = NULL, // 默认就是sRGB
.bitmapInfo = kCGImageAlphaFirst | kCGBitmapByteOrderDefault, // 表示ARGB
.version = 0, // 或许以后会有版本区分,现在都是0
.decode = NULL, // 和`CGImageCreate`的decode参数一样,可以用来做色彩范围映射的,NULL就是[0, 1.0]
.renderingIntent = kCGRenderingIntentDefault, // 和`CGImageCreate`的intent参数一样,当色彩空间超过后如何处理
};
// RGB888的format结构体
static vImage_CGImageFormat vImageFormatRGB888 = (vImage_CGImageFormat) {
.bitsPerComponent = 8, // 8位
.bitsPerPixel = 24, // RGB3通道,3*8
.colorSpace = NULL,
.bitmapInfo = kCGImageAlphaNone | kCGBitmapByteOrderDefault, // 表示RGB
.version = 0,
.decode = NULL,
.renderingIntent = kCGRenderingIntentDefault,
};
// 字节对齐使用,vImage如果不是64字节对齐的,会有额外开销
static inline size_t vImageByteAlign(size_t size, size_t alignment) {
return ((size + (alignment - 1)) / alignment) * alignment;
}

接着,就是完整的转换代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
+ (CGImageRef)nonAlphaImageWithImage:(CGImageRef)aImage
{
// 首先,我们声明input和output的buffer
__block vImage_Buffer a_buffer = {}, output_buffer = {};
@onExit {
// 由于vImage的API需要手动管理内存,避免内存泄漏
// 为了方便错误处理清理内存,可以使用clang attibute的cleanup(这里是libextobjc的宏)
// 如果不这样,还有一种方式,就是使用goto,定义一个fail:的label,所有return NULL改成`goto fail`;
if (a_buffer.data) free(a_buffer.data);
if (output_buffer.data) free(output_buffer.data);
};

// 首先,创建一个buffer,可以用vImage提供的CGImage的便携构造方法,里面需要传入原始数据所需要的format,这里就是ARGB8888
vImage_Error a_ret = vImageBuffer_InitWithCGImage(&a_buffer, &vImageFormatARGB8888, NULL, aImage, kvImageNoFlags);
// 所有vImage的方法一般都有一个result,判断是否成功
if (a_ret != kvImageNoError) return NULL;
// 接着,我们需要对output buffer开辟内存,这里由于是RGB888,对应的rowBytes是3 * width,注意还需要64字节对齐,否则vImage处理会有额外的开销。
output_buffer.width = a_buffer.width;
output_buffer.height = a_buffer.height;
output_buffer.rowBytes = vImageByteAlign(output_buffer.width * 3, 64);
output_buffer.data = malloc(output_buffer.rowBytes * output_buffer.height);
// 这里使用vImage的convert方法,转换色彩格式
vImage_Error ret = vImageConvert_ARGB8888toRGB888(&a_buffer, &output_buffer, kvImageNoFlags);
if (ret != kvImageNoError) return NULL;
// 此时已经output buffer已经转换完成,输出回CGImage
CGImageRef outputImage = vImageCreateCGImageFromBuffer(&output_buffer, &vImageFormatRGB888, NULL, NULL, kvImageNoFlags, &ret);
if (ret != kvImageNoError) return NULL;

return outputImage;
}

任意色彩格式转换

除了一系列vImageConvert_AtoB的转换,vImage还提供了一个非常抽象的接口,叫做vImageConvert_AnyToAny,只需要你提供一个input format,一个output format,就可以直接转换。这个接口比较强大,不仅能够handler所有支持的色彩格式,而且还能支持CVImageBuffer(通过这个vImageConverter来构造)。所以一般如果做库封装,做一些色彩转换的case的时候,就可以试着用这个接口。

因此,我们之前的ARGB8888ToRGB888的色彩转换,可以这样写,更为通用。示例代码:

1
2
3
vImageConverterRef converter = vImageConverter_CreateWithCGImageFormat(&vImageFormatARGB8888, &vImageFormatRGB888, NULL, kvImageNoFlags, &ret);
if (ret != kvImageNoError) return NULL;
ret = vImageConvert_AnyToAny(converter, &a_buffer, &output_buffer, NULL, kvImageNoFlags);

Alpha合成

Alpha合成

Alpha合成指的是将两张含有Alpha通道的图(被Blend的叫做bottom,Blend的叫做top),通过一定的公式合成成为一张新的含Alpha通道的图,一般来说用于给图像添加遮罩、覆盖等,常见的图像处理软件都有这个功能。其实本质上来说,Alpha合成,就是对图像的每一个像素值,进行这样一个计算:

1
2
3
4
5
resultAlpha = (topAlpha * 255 + (255 - topAlpha)
* bottomAlpha + 127) / 255
resultColor = (topAlpha * topColor + (((255 - topAlpha)
* bottomAlpha + 127) / 255) * bottomColor + 127)
/ resultAlpha

公式看起来比较复杂,因此这里顺便可以介绍一下关于premultiplied-alpha的概念,直观地说,就是将(r, g, b, a)预先乘以了对应的alpha通道的值,成为(r * a, g * a, b * a, a)。这个带来的好处,就是Alpha合成的时候,可以少一次乘法,而且简化了计算,成为这样子:

1
2
resultColor = (topColor + (((255 - topAlpha)
* bottomAlpha + 127) / 255) * bottomColor + 127)

在vImage中,已经提供了一个接口来专门处理Alpha合成,针对nonpremultiplied的,是vImageAlphaBlend_ARGB8888,而针对premultiplied,是vImagePremultipliedAlphaBlend_ARGB8888。需要注意的是,这个接口要求的两个buffer,宽度和高度必须相等,因此,我们对于Color和Image的遮罩,需要进行处理,保证这两个buffer满足要求。

Alpha Blend Color

这个用处,一般是用来做图像的遮罩的,可以对图像整体盖一层有透明度的颜色,比如说夜间模式,纯色滤镜等。根据上面说的,如果需要对一个Bitmap使用vImage进行Alpha Blend,我们需要保证两个buffer的宽度和高度相同,因此可以使用vImageBufferFill_ARGB8888填充整个Color来构造一个与输入图像Buffer相同宽高的新buffer,然后用它来进行Alpha Blend。

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CGImageRef aImage; // 输入的bottom Image
CGColorRef color; // 输入的color
__block vImage_Buffer a_buffer = {}, b_buffer = {}, output_buffer = {}; // 分别是bottom buffer,top buffer和最后的output buffer
Pixel_8888 pixel_color = {0};
const double *components = CGColorGetComponents(color);
const size_t components_size = CGColorGetNumberOfComponents(color);
// 对CGColor进行转换到Pixel_8888
if (components_size == 2) {
// white, alpha
pixel_color[0] = components[1] * 255;
} else {
// red, green, blue, (alpha)
pixel_color[0] = components_size == 3 ? 255 : components[3] * 255;
pixel_color[1] = components[0] * 255;
pixel_color[2] = components[1] * 255;
pixel_color[3] = components[2] * 255;
}
// 填充color到top buffer
vImage_Error b_ret = vImageBufferFill_ARGB8888(&b_buffer, pixel_color , kvImageNoFlags);
if (b_ret != kvImageNoError) return NULL;
// Alpha Blend
vImage_Error ret = vImageAlphaBlend_ARGB8888(&b_buffer, &a_buffer, &output_buffer, kvImageNoFlags);

Alpha Blend Image

上面说到了关于Color的Alpha Blend,不同于Color这种需要填充全部宽度,如果对于一个Image需要进行Alpha Blend,我们大部分情况都是需要制定一个起始点的,因为不能保证所有输入的两个Image的宽高相同。因此设计的时候,可以给用户提供一个point参数,以这个坐标点开始来绘制Alpha Blend,类似于很多图像编辑软件提供的图层功能。

由于vImage的Alpha Blend需要两个等宽高的Buffer,因此我们需要对用户提供的Top Image进行处理,通过平移变换移动到指定的Point以后,填充其余部分为Clear Color。最后进行Alpha Blend即可。

1
2
3
4
5
6
7
8
9
10
11
12
CGImageRef aImage, bImage; // 输入的bottom Image和top Image
__block vImage_Buffer a_buffer = {}, b_buffer = {}, c_buffer = {}, output_buffer = {};
//c buffer指的是将top Image进行处理后的临时buffer,使得宽高同bottom image相同
// 这里我们使用到了线性变换的平移变换,以(0,0)放置top image,然后偏移point个像素点,其余部分填充clear color,即可得到这个处理后的c buffer
CGAffineTransform transform = CGAffineTransformMakeTranslation(point.x, point.y);
vImage_CGAffineTransform cg_transform = *((vImage_CGAffineTransform *)&transform);
Pixel_8888 clear_color = {0};
vImage_Error c_ret = vImageAffineWarpCG_ARGB8888(&b_buffer, &c_buffer, NULL, &cg_transform, clear_color, kvImageBackgroundColorFill);
if (c_ret != kvImageNoError) return NULL;
// 略过output buffer初始化
// 将bottom image和处理后的c buffer进行Alpha Blend
vImage_Error ret = vImageAlphaBlend_ARGB8888(&c_buffer, &a_buffer, &output_buffer, kvImageNoFlags);

几何变换

几何变换,指的是将一个原始的Bitmap,通过线性方法进行处理,实现比如平移、缩放、旋转、错切等操作的图像处理技术。

可能大部分人已经知道了(之前也说过),Core Graphics的坐标系统,和UIKit的坐标系统,在Y坐标上是相反的。UIKit的使用的是Y轴正向垂直向下的左手系,而Core Graphics和普通的右手系直角坐标系相同。vImage也遵守了右手系,因此之后介绍的变换都是按照右手系的,如果想处理UIKit的坐标系,自己转换一下即可(一般就是取image.height - offsetY即可)

关于要介绍的的这些几何变换,虽然都最后可以统一到到线性变换上,只不过效率上可能相比单独的方法来说有所损耗,因此单独对每个功能所需要的vImage接口进行了介绍。关于线性变换不太理解的,可以参考一下之前的一篇教程:Core Graphics仿射变换知识

缩放

缩放

缩放是最简单的一个处理过程,但是由于缩放之后,之前的同一个像素点,现在可能会映射到4个或者更多像素点,或者是原本4个像素点,现在需要映射到1个像素点。这就会涉及到一个叫做图像重采样的过程。具体来说,就是对每一个像素,所在的Bitmap的子矩阵(比如3x3),通过一定的算法计算,得到对应的缩放以后的中心像素的值。同时,这个像素值可能变成浮点数,还需要进行处理,最后填到采样后的Bitmap相应的位置上。常见的简单处理有最邻近算法、双线性算法、双立方算法等。

vImage默认使用的是Lanczos Algorithm,具体的介绍可以参考Wikipedia和DSP相关的书籍。这里有一个直观的对比表现网页。如果想要更高画质的算法,可以提供kvImageHighQualityResampling参数,来使用Lanczos5算法。或者可以使用之后要谈的相对底层一点的错切API,来自定义你的重采样过程。

vImage提供了自带的vImageScale_ARGB8888方法,这里就简单举个例子(之前重复代码的都略过):

1
2
3
4
5
6
7
8
CGSize size; // 目标大小
output_buffer.width = MAX(size.width, 0);
output_buffer.height = MAX(size.height, 0);
output_buffer.rowBytes = vImageByteAlign(output_buffer.width * 4, 64);
output_buffer.data = malloc(output_buffer.rowBytes * output_buffer.height);
if (!output_buffer.data) return NULL;
// 进行缩放,输出到output buffer中
vImage_Error ret = vImageScale_ARGB8888(&a_buffer, &output_buffer, NULL, kvImageHighQualityResampling);

裁剪

裁剪是指的将原始Bitmap,只裁出来指定矩形大小的部分,其余部分直接丢弃的过程。虽然vImage没有提供直接的API来处理这个流程(当然你是可以用vecLib的方法,直接对Bitmap进行矩阵操作,但是有点过于小题大做了)。但是实际上,这就是一个平移变换能够搞定的事情。我们只需要对输入目标的坐标的CGRect进行转换,将原始图像平移之后,再限制输出的Bitmap的大小,这样平移超出部分就会自动被裁掉。不需额外的处理,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CGRect rect; // 输入的目标rect
output_buffer.width = MAX(CGRectGetWidth(rect), 0); // 输出宽度
output_buffer.height = MAX(CGRectGetHeight(rect), 0); // 输出高度
output_buffer.rowBytes = vImageByteAlign(output_buffer.width * 4, 64);
output_buffer.data = malloc(output_buffer.rowBytes * output_buffer.height);
if (!output_buffer.data) return NULL;

// 使用平移来处理,X轴Y轴分别平移负向的minX,minY即可
CGFloat tx = CGRectGetMinX(rect);
CGFloat ty = CGRectGetMinY(rect);
CGAffineTransform transform = CGAffineTransformMakeTranslation(-tx, -ty);
vImage_CGAffineTransform cg_transform = *((vImage_CGAffineTransform *)&transform);
Pixel_8888 clear_color = {0};
vImage_Error ret = vImageAffineWarpCG_ARGB8888(&a_buffer, &output_buffer, NULL, &cg_transform, clear_color, kvImageBackgroundColorFill);

镜像

镜像顾名思义,就是将图像沿着某个轴进行翻转,比如沿X轴就是水平镜像,同一个像素点,对应的X坐标不变,Y坐标变为高度减去本身的Y坐标即可。

vImage对应的API,是vImageVerticalReflect_ARGB8888vImageHorizontalReflect_ARGB8888,使用起来也比较简单。直接上一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
BOOL horizontal;
__block vImage_Buffer a_buffer = {}, output_buffer = {};
// 省略
vImage_Error ret;
if (horizontal) {
// 水平镜像
ret = vImageHorizontalReflect_ARGB8888(&a_buffer, &output_buffer, kvImageHighQualityResampling);
} else {
// 垂直镜像
ret = vImageVerticalReflect_ARGB8888(&a_buffer, &output_buffer, kvImageHighQualityResampling);
}

旋转

旋转也是非常常见一个图像几何几何变化。具体坐标的变化就是对旋转的角度,求对应三角函数到X轴和Y轴的投影结果,比较直观。

vImage对旋转也提供了一个非常方便的API,角度是弧度值,按照顺时针方向进行。另外,由于输出的Buffer的大小会限制图像大小,而旋转后可能超出原图大小,我们需要对输出的大小也计算出对应的新的大小。示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
CGFloat radians; //旋转的弧度
CGSize size = CGSizeMake(a_buffer.width, a_buffer.height);
// 这里直接借用CG的方法来计算旋转后的大小,方便
CGAffineTransform transform = CGAffineTransformMakeRotation(radians);
size = CGSizeApplyAffineTransform(size, transform); output_buffer.width = ABS(size.width);
output_buffer.height = ABS(size.height);
output_buffer.rowBytes = vImageByteAlign(output_buffer.width * 4, 64);
output_buffer.data = malloc(output_buffer.rowBytes * output_buffer.height);
if (!output_buffer.data) return NULL;
Pixel_8888 clear_color = {0};
// 旋转操作,多余部分填充Clear Color
vImage_Error ret = vImageRotate_ARGB8888(&a_buffer, &output_buffer, NULL, radians, clear_color, kvImageBackgroundColorFill | kvImageHighQualityResampling);

错切

错切

错切是一种特殊的线性变换,直观的介绍可以从Wikipedia上看,也可以参考之前的另一篇教程。主要的参数有一个m值,表示对应参考坐标的缩放倍数。

在vImage中,错切变换是相对底层的接口,实际上,线性变换是通过这三个接口(错切、旋转、镜像)来实现的。错切的接口,比如水平错切对应的是vImageHorizontalShear_ARGB8888,参数算是最多的一个,稍微详细介绍一下:

  • srcOffsetToROI_X: 错切定位点水平偏移量,具体指的就是左上角那个像素点,在经过旋转的映射后,水平偏移的距离,会影响最后图像(除去Buffer的宽度限制)的整体宽度
  • srcOffsetToROI_Y: 错切定位点的垂直偏移量,类似水平值
  • xTranslate: 错切完成后的水平平移距离
  • shearSlope: 错切的弧度值,顺时针
  • filter: 用来自定义重采样的方法,一般用自带的vImageNewResamplingFilter,或者也可以提供一个函数指针构造对应的重采样过程。会用到一个scale参数,表示这个重采样对应的缩放倍数,也就是错切的m值
  • backgroundColor: 背景填充色

对应的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CGVector offset; // 定位点偏移量
CGFloat translation; // 水平平移量
CGFloat slope; // 旋转弧度
CGFloat scale; // 对应错切的m值
output_buffer.width = MAX(a_buffer.width - offset.dx, 0); //这里需要同时减去水平定位点的偏移
output_buffer.height = MAX(a_buffer.height - offset.dy, 0); // 同理
output_buffer.rowBytes = vImageByteAlign(output_buffer.width * 4, 64);
output_buffer.data = malloc(output_buffer.rowBytes * output_buffer.height);
if (!output_buffer.data) return NULL;

Pixel_8888 clear_color = {0};
// 这里示例就用默认的重采样方法
ResamplingFilter resampling_filter = vImageNewResamplingFilter(scale, kvImageHighQualityResampling);
vImage_Error ret;
if (horizontal) {
// 水平错切
ret = vImageHorizontalShear_ARGB8888(&a_buffer, &output_buffer, offset.dx, offset.dy, translation, slope, resampling_filter, clear_color, kvImageBackgroundColorFill);
} else {
// 垂直错切
ret = vImageVerticalShear_ARGB8888(&a_buffer, &output_buffer, offset.dx, offset.dy, translation, slope, resampling_filter, clear_color, kvImageBackgroundColorFill);
}
vImageDestroyResamplingFilter(resampling_filter);

线性变换

最后再来说通用的线性变换吧,这个其实在之前的功能中已经用到过了,vImage有兼容Core Graphics的CGAffineTransform的结构体vImage_CGAffineTransform,两个结构体对应的内存布局是一样的,直接强制转换过去就可以了,不需要单独赋一遍。关于通用线性变换的内容就不再赘述了,有兴趣可以查看相关资料,或者之前的教程:Core Graphics仿射变换知识

示例代码:

1
2
3
4
5
CGAffineTransform transform; // 输入的CG变换矩阵
vImage_CGAffineTransform cg_transform = *((vImage_CGAffineTransform *)&transform); // 结构一样,直接强转
Pixel_8888 clear_color = {0};
// 线性变换
vImage_Error ret = vImageAffineWarpCG_ARGB8888(&a_buffer, &output_buffer, NULL, &cg_transform, clear_color, kvImageBackgroundColorFill | kvImageHighQualityResampling);

总结

vImage是一个比较底层的图像Bitmap处理的库,在这里介绍了关于色彩转换、Alpha合成、几何变换等基本知识。相比于简单的Core Graphics的处理,能够提供更为复杂的参数控制,并且带来较高的性能。对于很多图像密集处理软件处理来说,用Core Graphics显的比较低效,因此可以考虑vImage。

但是vImage强大之处远不在这里,里面还包含了类似图像卷积,形态处理等,可以对复杂滤镜进行支持,类似于GPUImage。这些功能都需要数字图像处理相关知识,在这种教程系列就不会介绍了。

对于这篇教程的示例代码,其实我写了个非常简单的库,放到GitHub上了:vImageProcessor,有兴趣的可以去参考一下,希望能够用于自己的图片处理相关框架中。

由于自己完全是业余兴趣,工作和图像处理基本不相关,并不打算深入学习数字图像处理的知识,因此这个教程可能就会暂时告一段落了。最后,之所以写这篇教程,是因为自己想要参考一下vImage的教程,却发现只会搜出来一堆互相抄袭的内容,而且大部分都是关于图像滤镜的,对于图像处理本身不会太多介绍。我希望这系列教程,能给同样对图像编解码、图像处理有一点兴趣的人,提供一个相对简单且清晰的入门概览吧。

iOS平台图片编解码入门教程(第三方编解码篇)

作者 DreamPiggy
2017年10月31日 02:05

这篇教程,是系列教程的第二篇,前篇名为《iOS平台图片编解码入门教程(Image/IO篇)》。这篇主要讲第三方解码器如何在iOS平台上处理(和Image/IO的几大要点一一对应),更会介绍一些基本的Bitmap概念,总结通用的处理方法,毕竟授人以鱼不如授人以渔

第三方编解码

对于图片编解码来说,Apple自带的Image/IO确实非常的易用,但是对于Image/IO不支持的图像格式就能无能为力了。截止到iOS 11,Image/IO不支持WebP,BPG,对于一些需要依赖WebP的业务就比较麻烦了(WebP的优点就不再介绍了)。不过我们可以自己集成第三方的图片解码器,去支持这些需要的的格式。

一般来说,我们需要根据自己想要支持的图像格式,选择相对应的编解码器,进行编解码。这里我们以WebP的解码库libwebp为例子,其他解码器需要根据对应解码器的API处理,基本概念类似。

解码

不像Image/IO那样封装了整套流程,第三方解码的关键之处,就是在于获取到图像的Bitmap数据,通常情况就是RGBA的矢量表示。

简单解释一下,Bitmap可以理解为连续排列像素(Pixel)的二维数组,一个像素包括4个通道(Components)的点,每个点的位数叫做色深(常见的32位色,指的就是1个像素有4通道,每个通道8位),而像素的通道排列顺序按照对应的RGBA Mode顺序排列,比如说RGBA8888(大端序),就是这样一串连续的值:

1
{uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha, uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha...}

这样的话,在内存中,一般就可以用uint8_t bitmap[width * components * 8][height]来表示。

有了这样的知识,对照着就能看懂CGImage的BitmapInfo所表示的信息了。

Bitmap的表示

静态图

只要Bitmap数据到手,后面的过程其实都大同小异。第三方解码器主要处理图像编码数据到原始Bitmap的解码过程,后续就可以通过固定的方式来得到CGImage或者UIImage,用于上层UI组件的渲染。

  1. 第三方解码器获取图像Bitmap
  2. 通过Bitmap创建CGImage
  3. CGImage重绘(可选)
  4. 生成上层的UIImage,清理

1. 第三方解码器获取图像Bitmap

根据自己的需要,可以选择对应的编码器,来获取图像Bitmap。这里我们以WebP的解码库libwebp为例子,其他解码器需要根据对应解码器的API处理,基本概念类似。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
NSData *data; // 待解码的图像二进制数据

WebPData webpData;
WebPDataInit(&webpData);
webpData.bytes = data.bytes;
webpData.size = data.length;

// 初始化Config,用于存储图像的Bitmap,大小等信息
WebPDecoderConfig config;
if (!WebPInitDecoderConfig(&config)) {
return nil;
}

// 判断是否能够解码
if (WebPGetFeatures(webpData.bytes, webpData.size, &config.input) != VP8_STATUS_OK) {
return nil;
}

// 设定输出的Bitmap色彩空间,有Alpha通道选择RGBA,没有选择RGB
bool has_alpha = config.input.has_alpha;
config.output.colorspace = has_alpha ? MODE_rgbA : MODE_RGB;
config.options.use_threads = 1;

// 真正开始解码,输出RGBA数据到Config的output中
if (WebPDecode(webpData.bytes, webpData.size, &config) != VP8_STATUS_OK) {
return nil;
}

// 获取图像的大小
int width = config.input.width;
int height = config.input.height;
if (config.options.use_scaling) {
width = config.options.scaled_width;
height = config.options.scaled_height;
}

//RGBA矢量和对应的大小
uint8_t *rgba = config.output.u.RGBA.rgb;
size_t rgbaSize = config.output.u.RGBA.size;

截止到这里,我们基本上调用通过第三方解码库的接口,就完成了获取Bitmap的工作。一般来说,以RGBA来说,最少需要知道以下信息:图像RGBA数组,数组大小,图像宽度、高度、是否含有Alpha通道这几个,以便开始下一步的创建CGImage的过程

2. 通过Bitmap创建CGImage

有了图像的Bitmap数据之后,可以通过CGImageCreate来生成CGImage。对于RGBA的输入,需要的参数基本比较固定,以下代码基本上可以参考来复用。(需要注意,iOS上只支持premultiplied-alpha,macOS可以支持非premultiplied-alpha)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 通过RGBA数组,创建一个DataProvider。最后一个参数是一个函数指针,用来在创建完成后清理内存用的
CGDataProviderRef provider = CGDataProviderCreateWithData(NULL, rgba, rgbaSize, FreeImageData);
// 目标色彩空间,我们这里用的就是RGBA
CGColorSpaceRef colorSpaceRef = CGColorSpaceCreateDeviceRGB();
// Bitmap数据,如果含有Alpha通道,设置Premultiplied Alpha。没有就忽略Alpha通道
CGBitmapInfo bitmapInfo = has_alpha ? kCGBitmapByteOrder32Big | kCGImageAlphaPremultipliedLast : kCGBitmapByteOrder32Big | kCGImageAlphaNoneSkipLast;
// 通道数,有alpha就是4,没有就是3
size_t components = has_alpha ? 4 : 3;
// 这个是用来做色彩空间变换的指示,如果超出色彩空间,比如P3转RGBA,默认会进行兼容转换
CGColorRenderingIntent renderingIntent = kCGRenderingIntentDefault;
// 每行字节数(RGBA数组就是连续排列的多维数组,一行就是宽度*通道数),又叫做stride,因为Bitmap本质就是Pixel(uint_8)的二维数组,需要知道何时分行
size_t bytesPerRow = components * width;
// 创建CGImage,参数分别意义为:宽度,高度,每通道的Bit数(RGBA自然是256,对应8Bit),每行字节数,色彩空间,Bitmap信息,数据Provider,解码数组(这个传NULL即可,其他值的话,会将经过变换比如premultiplied-alpha之后的Bitmap写回这个数组),是否过滤插值(这个一般不用开,可以在专门的图像锐化里面搞),色彩空间变换指示
CGImageRef imageRef = CGImageCreate(width, height, 8, components * 8, bytesPerRow, colorSpaceRef, bitmapInfo, provider, NULL, NO, renderingIntent);
// 别忘了清理DataProvider,此时会调用之前传入的清理函数
CGDataProviderRelease(provider);

static void FreeImageData(void *info, const void *data, size_t size) {
free((void *)data);
}

3. CGImage重绘

这一步其实是可选的,但是建议都加上这一步骤。虽然我们之前通过RGBA创建了CGImage,但是实际上,CALayer和上层的UIImageView这些渲染的时候,要求的色彩是限定的,不然会有额外的内存和渲染消耗,我们解码出来的rgba的格式可能并不是按照这样的色彩空间排列,因此建议进行一次重绘,即将CGImage重绘到一个CGBitmapContext之上。这个代码比较简单。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
CGImageRef imageRef;
size_t canvasWidth = CGImageGetWidth(imageRef)
size_t canvasHeight = CGImageGetHeight(imageRef)
CGBitmapInfo bitmapInfo;
if (! has_alpha) {
bitmapInfo = kCGBitmapByteOrder32Big | kCGImageAlphaNoneSkipLast;
} else {
bitmapInfo = kCGBitmapByteOrder32Big | kCGImageAlphaPremultipliedLast;
}

// 参数意思很简单,由于没有数据,对于RGBA可以自动计算bytesPerRow
CGContextRef canvas = CGBitmapContextCreate(NULL, canvasWidth, canvasHeight, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
if (!canvas) {
return nil;
}

CGImageRef imageRef = image.CGImage;
size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
// 画CGImage上去
CGContextDrawImage(canvas, CGRectMake(0, 0, width, height), imageRef);
CGImageRef newImageRef = CGBitmapContextCreateImage(canvas);

4. 生成上层的UIImage,清理

有了最终的用于显示CGImage,那么我们就可以生成一个UIImage来给UI组件显示了。注意如果需要有特殊的scale,orientation处理(比如说图像可能有额外的EXIF Orientation信息),需要在这一步加上。
由于是C接口,需要手动清理内存,除了CGImage相关的,也需要清理第三方库自己的内存分配。对于错误提前返回的清理内存,灵活运用__attribute__((cleanup)),设置一个返回函数前清理的Block,可以减少犯错的可能性

示例代码:

1
2
3
4
5
UIImage *image = [UIImage imageWithCGImage:imageRef scale:1 orientation:UIImageOrientationUp];

// 各种清理,省略
CGImageRelease(newImageRef);
CGContextRelease(canvas);

动态图

动态图的解码过程,其实很直观的想,我们目标就是需要对所有动图帧,都拿到Bitmap,解码到CGImage和UIImage就行了。这样想的话,其实步骤就比较明确了。

步骤:

  1. 第三方解码器生成每帧的Bitmap
  2. 重复静态图的2-3
  3. 生成动图UIImage(参考Image/IO)

1. 第三方解码器生成每帧的Bitmap

不同解码器可能对于动图有特殊的解码过程,拿libwebp举例来说,libwebp的动图,需要用到它的demux模块,其他解码器自行参考对应的API。

同时,这里需要额外介绍一些概念。一般来说,动图格式的话不会直接将每帧原始的Bitmap都编码到文件中,这样得到的文件过于庞大(帧数 * 每帧Bitmap)。因此,会有Dispose Method的方式(可以参考WebP规范Disposal method (D): 1 bit移动端图片格式调研)。简单点来说,对于动图来说,每一帧有一个参考画布,在前一帧画完以后,后一帧可以利用前一帧已画好的图像,仅仅改变前后变化的部分,从而减小整体大小。因此我们创建动图时,需要准备好一个CGBitmapContext当作画布,根据Disposal Method(如果为None,不清空canvas;如果为Background,清空为Background Color,一般就是直接清空成透明)

有了所有帧的Bitmap后,转成CGImage,UIImage,最后生成动图UIImage,这个在系列前篇已经介绍过了,不再赘述

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
- (void)decodeWebP {
// 前期准备代码,直接略过,假设已经创建好canvas
WebPDemuxer *demuxer;
WebPIterator iter;
if (!WebPDemuxGetFrame(demuxer, 1, &iter)) {
return nil;
}

NSMutableArray<UIImage *> *images = [NSMutableArray array];
double durations[frameCount];

do {
@autoreleasepool {
UIImage *image = [self drawnWebpImageWithCanvas:canvas iterator:iter];
int duration = iter.duration;
[images addObject:image];
durations[frame_num] = duration;
}

} while (WebPDemuxNextFrame(&iter));

// 创建UIImage动图,和Image/IO的相同,这里就直接封装成方法,略过
UIImage *animatedImage = [self animatedImageWithImages:images durations:durations];

// 清理……略
WebPDemuxReleaseIterator(&iter);
}

- (nullable UIImage *)drawnWebpImageWithCanvas:(CGContextRef)canvas iterator:(WebPIterator)iter {
// 这里是调用的前面静态图绘制的方法
UIImage *image = [self rawWebpImageWithData:iter.fragment];
if (!image) {
return nil;
}

size_t canvasWidth = CGBitmapContextGetWidth(canvas);
size_t canvasHeight = CGBitmapContextGetHeight(canvas);
CGSize size = CGSizeMake(canvasWidth, canvasHeight);
CGFloat tmpX = iter.x_offset;
CGFloat tmpY = size.height - iter.height - iter.y_offset;
CGRect imageRect = CGRectMake(tmpX, tmpY, iter.width, iter.height);
// Blend
BOOL shouldBlend = iter.blend_method == WEBP_MUX_BLEND;

// 如果BlendMode开启,该帧应当混合画画布上,否则,应该覆盖,也就是清空指定范围后再重画
if (!shouldBlend) {
CGContextClearRect(canvas, imageRect);
}
CGContextDrawImage(canvas, imageRect, image.CGImage);
CGImageRef newImageRef = CGBitmapContextCreateImage(canvas);

image = [UIImage imageWithCGImage:newImageRef];
CGImageRelease(newImageRef);

// Dispose如果是Background,表示解码下一帧需要清空画布
if (iter.dispose_method == WEBP_MUX_DISPOSE_BACKGROUND) {
CGContextClearRect(canvas, imageRect);
}

return image;
}

渐进式解码

渐进式解码的概念,在系列前篇中已经介绍过了,一般来说,第三方解码器支持渐进式解码的接口都比较类似,通过提供二进制流不断进行Update,每次能够得到当前解码的部分的Bitmap,最后可以拿到完整的Bitmap。之后只需要参考静态图对应步骤即可。

这里还是以libwebp的接口为例,libwebp需要使用它的WebPIDecoder接口,来专门进行渐进式解码。注意,libwebp渐进式解码出来的Bitmap不会将未解码的部分自动填空,会保留随机的内存地址置,要么手动清空,要么画的时候仅仅画解码出来的高度部分。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
NSData *data; // 输入的原始图像格式的二进制数据
UIImage *image;
WebPIDecoder *idec = WebPINewRGB(MODE_rgbA, NULL, 0, 0);
if (!idec) {
return nil;
}

// 这里需要更新全部的数据,当然libwebp也有仅仅更新新增数据(非全部)的接口
VP8StatusCode status = WebPIUpdate(idec, data.bytes, data.length);
if (status != VP8_STATUS_OK && status != VP8_STATUS_SUSPENDED) {
return nil;
}

int width = 0;
int height = 0;
int last_y = 0; // 已经解码出来的Bitmap数据的高度,即对应有效Bitmap的行数
int stride = 0;
// 然后可以拿到Bitmap数据和相应的图像信息了
uint8_t *rgba = WebPIDecGetRGB(_idec, &last_y, &width, &height, &stride);

// 和静图解码的过程类似,构造一个DataProvider,略过
CGDataProviderRef provider;
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Big | kCGImageAlphaPremultipliedLast;
size_t components = 4;

//这里说一个坑,libwebp不能保证last_y以下的数据是全空的,所以一定注意,仅仅解码last_y范围内的Bitmap
CGImageRef imageRef = CGImageCreate(width, last_y, 8, components * 8, components * width, CGColorSpaceCreateDeviceRGB(), bitmapInfo, provider, NULL, NO, kCGRenderingIntentDefault);

// 为了能得到完整的图片高度,创建一个canvas来画图,不画的部分保持透明状态即可
CGContextRef canvas = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);

// 仅仅画last_y高度的图像(不是全部),注意CoreGraphics的坐标系统是右手系的,与UIKit的坐标相反
CGContextDrawImage(canvas, CGRectMake(0, height - last_y, width, last_y), imageRef);

// 拿到CGImage
CGImageRef newImageRef = CGBitmapContextCreateImage(canvas);
// 创建UIImage
image = [[UIImage alloc] initWithCGImage:newImageRef];
// 各种清理,省略

编码

编码过程其实比解码过程要简单得多,因为实际上,我们可以通过自带的接口,直接拿到当前UIImage的Bitmap数据,因此只要将Bitmap交给第三方编码库来进行编码,最后输出数据即可。

静态图

静态图的过程其实就可以直接分为两步:

  1. UIImage获取Bitmap
  2. 调用编码器进行编码

1. UIImage获取Bitmap

UIImage本身能够直接通过方法拿到对应的CGImage,这样只需要调用CGImageGetDataProvider就可以拿到对应的Bitmap数据的DataProvider了,直接上代码吧。

示例代码:

1
2
3
4
5
6
7
8
9
10
UIImage *image;
NSData *webpData; // Bitmap数据的容器
CGImageRef imageRef = image.CGImage;

size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);
size_t bytesPerRow = CGImageGetBytesPerRow(imageRef); // 大部分编码器需要知道bytesPerRow,或者叫做stride
CGDataProviderRef dataProvider = CGImageGetDataProvider(imageRef);
CFDataRef dataRef = CGDataProviderCopyData(dataProvider);
uint8_t *rgba = (uint8_t *)CFDataGetBytePtr(dataRef);

2. 调用编码器进行编码

我们还是以libwebp来对WebP进行编码,libwebp对于静态图片的编码非常简单(动态图片需要调用另一套mux的API,在动图章节讲)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
uint8_t *data = NULL; //编码输出的二进制数据
float quality = 100.0; // libwebp可以选择编码质量,影响输出文件大小和编码速度
size_t size = WebPEncodeRGBA(rgba, (int)width, (int)height, (int)bytesPerRow, quality, &data);
CFRelease(dataRef); // 编码后清理Bitmap数据
rgba = NULL;

if (size) {
// success
webpData = [NSData dataWithBytes:data length:size];
}
if (data) {
WebPFree(data);
}

动态图

对于动态图来说,也就是将多帧的Bitmap输入到编码器即可。对于libwebp的动态图编码,需要利用到它的mux模块,它能够将多个编码成WebP的二进制流,最后mux合并一次,最终得到了动态WebP。因此我们需要利用之前的静态图编码的步骤,只需要依次遍历取图并编码,最后使用mux处理即可。

步骤:

  1. 遍历每帧Bitmap,编码

1. 遍历每帧Bitmap,编码

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
NSData *data;
NSArray<UIImgae *> *images;
double durations[frameCount];
int loopCount;
// 创建mux
WebPMux *mux = WebPMuxNew();
if (!mux) {
return nil;
}
// 遍历带编码的每帧
for (size_t i = 0; i < framesCount; i++) {
NSData *webpData = [self encodedWebpDataWithImage:images[i]]; // 单帧编码后数据
int duration = (int)durations[i] * 1000; // 单帧持续时长
// 设置WebP每帧属性,包括Data,Blend,Disposal等
WebPMuxFrameInfo frame = {.bitstream.bytes = webpData.bytes //省略}
if (WebPMuxPushFrame(mux, &frame, 0) != WEBP_MUX_OK) {
WebPMuxDelete(mux);
return nil;
}
}

// 设置动图本身的属性
WebPMuxAnimParams params;
params.bgcolor = 0, params.loop_count = loopCount;
WebPMuxSetAnimationParams(mux, &params);

WebPData outputData;
// 最后进行编码,拿到输出的二进制
WebPMuxError error = WebPMuxAssemble(mux, &outputData);
WebPMuxDelete(mux);
if (error != WEBP_MUX_OK) {
return nil;
}
data = [NSData dataWithBytes:outputData.bytes length:outputData.size];
WebPDataClear(&outputData);

总结

第三方编解码其实相对于Image/IO来说,主要难度其实在于需要获取的Bitmap。开发者需要一点基本的图像知识,再者就是要能会用第三方编解码器的接口(一般来说第三方编解码器就是C或者C++写的,与OC和Swift交互也非常方便,至少不用像Java JNI那样调用)。之后只要按照通用的步骤,去编码和解码即可。

到这里的话,一般的大部分格式的编解码就基本没有问题了。当然,关于进阶的方面,比如图像的编解码性能优化,进阶的图像处理(Bitmap的几何变化,Alpha合成,位数转换等等)这就需要用到更低层的库vImage了,会在之后的系列教程中进行介绍。

iOS平台图片编解码入门教程(Image/IO篇)

作者 DreamPiggy
2017年10月31日 01:32

这篇教程是系列教程的第一篇,主要是面向于没有怎么接触过iOS平台上图像编解码的人的,不会涉及到多媒体处理中的数字信号处理、图像编码的深入知识。这是系列最简单的一篇,之后会有关于第三方编解码,以及vImage的另两篇教程。

Image/IO

Image/IO是Apple提供的一套用于图片编码解码的系统库,对外是一层非常直观易用的C的接口。上层的UIKit,Core Image,还有Core Graphics中的CGImage处理,都是依赖Image/IO库的。因此,掌握Image/IO的基本编解码操作,对一些图像相关的数据处理是非常必要的。这篇教程就主要从简单的用法,说明Image/IO的用法,完整的文档,可以参考Apple Image/IO

解码

解码,指的是讲已经编码过的图像封装格式的数据,转换为可以进行渲染的图像数据。具体来说,iOS平台上就指的是将一个输入的二进制Data,转换为上层UI组件渲染所用的UIImage对象。

Image/IO的解码,支持了常见的图像格式,包括PNG(包括APNG)、JPEG、GIF、BMP、TIFF(具体的,可以通过CGImageSourceCopyTypeIdentifiers来打印出来,不同平台不完全一致)。在iOS 11之后另外支持了HEIC(即使用了HEVC编码的HEIF格式)。

对于解码操作,我们可以分为静态图(比如JPEG,PNG)和动态图(比如GIF,APNG)的两种,分别进行说明一下解码的过程。

静态图

静态图的解码,基本可以分为以下步骤:

  1. 创建CGImageSource
  2. 读取图像格式元数据(可选)
  3. 解码得到CGImage
  4. 生成上层的UIImage,清理

1. 创建ImageSource

CGImageSouce,表示的是一个待解码数据的输入。之后的一系列操作(读取元数据,解码)都需要到这个Source,与解码流程一一对应。

CGImageSource可以通过不同的几个接口构造(这里先忽略渐进式解码的接口):

  • CGImageSourceCreateWithData: 从一个内存中的二进制数据(CGData)中创建ImageSource,相对来说最为常用的一个
  • CGImageSourceCreateWithURL: 从一个URL(支持网络图的HTTP URL,或者是文件系统的fileURL)创建ImageSource,
  • CGImageSourceCreateWithDataProvider:从一个DataProvide中创建ImageSource,DataProvider提供了很多种输入,包括内存,文件,网络,流等。很多CG的接口会用到这个来避免多个额外的接口。

示例代码:

1
2
3
4
CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
if (!source) { // 一般这时候都是输入图像数据的格式不支持
return nil;
}

2. 读取图像格式元数据

创建好CGImageSource之后,我们是可以立即解码。但是很多情况下,我们需要获取一些相关的图像信息,包括图像的格式,图像数量,EXIF元数据等。在真正解码之前,我们可以拿到这些数据,进行一些处理,之后再开始解码过程。

其中,这些信息可以直接在CGImageSource上获取:

  • 图像格式:CGImageSourceGetType
  • 图像数量(动图):CGImageSourceGetCount

其他的,需要通过获取属性列表来查询。对于图像容器的属性(EXIF等),我们需要使用CGImageSourceCopyProperties即可,然后根据不同的Key去获取对应的信息。

其实苹果还有一套CGImageSourceCopyMetadataAtIndex,对应的数据不是字典,而是一个CGImageMetadata,再通过其他方法去取。这套API使用起来也是可以的,读取数据和前者是完全兼容一致的,优点是能够进行自定义扩展(比如说你有非标准的图像信息想自己添加和删除)。一般来说使用前者就足够了。

示例代码:

1
2
3
4
5
6
CGImageSourceRef source;
NSDictionary *properties = (__bridge NSDictionary *)CGImageSourceCopyProperties(source, NULL);
NSUInteger fileSize = [properties[kCGImagePropertyFileSize] unsignedIntegerValue]; // 没什么用的文件大小

NSDictionary *exifProperties = properties[(__bridge NSString *)kCGImagePropertyExifDictionary]; // EXIF信息
NSString *exifCreateTime = exirProperties[(__bridge NSString *)kCGImagePropertyExifDateTimeOriginal]; // EXIF拍摄时间

当然,前面这个指的是图像容器的属性,而真正的获取图像的元信息,需要使用CGImageSourceCopyPropertiesAtIndex,对于静态图来说,index始终传0即可。

示例代码:

1
2
3
4
5
NSDictionary *imageProperties = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(source, 0, NULL);
NSUInteger width = [imageProperties[(__bridge NSString *)kCGImagePropertyPixelWidth] unsignedIntegerValue]; //宽度,像素值
NSUInteger height = [imageProperties[(__bridge NSString *)kCGImagePropertyPixelHeight] unsignedIntegerValue]; //高度,像素值
BOOL hasAlpha = [imageProperties[(__bridge NSString *)kCGImagePropertyHasAlpha] boolValue]; //是否含有Alpha通道
CGImagePropertyOrientation exifOrientation = [imageProperties[(__bridge NSString *)kCGImagePropertyOrientation] integerValue]; // 这里也能直接拿到EXIF方向信息,和前面的一样。如果是iOS 7,就用NSInteger取吧 :)

3. 解码得到CGImage

通过Image/IO解码到CGImage确实非常简单,整个解码只需要一个方法CGImageSourceCreateImageAtIndex。对于静态图来说,index始终是0,调用之后会立即开始解码,直到解码完成。

值得注意的是,Image/IO所有的方法都是线程安全的,而且基本上也都是同步的,因此确保大图像文件的解码最好不要放到主线程。

示例代码:

1
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0, NULL);

4. 生成上层的UIImage,清理

解码得到CGImage后,就基本完成了,我们可以直接构造对应的UIImage用于UI组件渲染。其中UIImage的orientation,可以通过之前的EXIF元信息获得(注意,需要转换EXIF的方向,到UIImageOrientation的方向)。然后就完成了,比较简单。

示例代码:

1
2
3
4
5
6
7
// UIImageOrientation和CGImagePropertyOrientation枚举定义顺序不同,封装一个方法搞一个switch case就行
UIImageOrientation imageOrientation = [self imageOrientationFromExifOrientation:exifOrientation];
UIImage *image = [[UIImage imageWithCGImage:imageRef scale:[UIScreen mainScreen].scale orientation:imageOrientation];

// 清理,都是C指针,避免内存泄漏
CGImageRelease(imageRef);
CFRelease(source)

动态图

前面的情况,主要介绍了是静态图(也就是说,取的index都是0的情况 )。对于动态图来说,我们可以通过CGImageSourceGetCount来获取动图的帧数,之后就比较简单了,通过循环遍历每一帧,重复2-4步骤生成对应的UIImage,最后通过UIImage自带的animatedImageWithImages:duration:来生成一张动图即可。但是关于这里有坑,在下面说明。

步骤:

  1. 静态图的步骤1
  2. 遍历所有图像帧,重复静态图的步骤2-4
  3. 生成动图UIImage

1. 生成动图UIImage

由于遍历很简单,就不重复了,这里我们以一个GIF为例,简单说明一下解码过程,直观易懂。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NSUInteger frameCount = CGImageSourceGetCount(source); //帧数
NSMutableArray <UIImage *> *images = [NSMutableArray array];
double totalDuration = 0;
for (size_t i = 0; i < frameCount; i++) {
NSDictionary *frameProperties = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(source, i, NULL);
NSDictionary *gifProperties = frameProperties[(NSString *)kCGImagePropertyGIFDictionary]; // GIF属性字典
double duration = [gifProperties[(NSString *)kCGImagePropertyGIFUnclampedDelayTime] doubleValue]; // GIF原始的帧持续时长,秒数
CGImagePropertyOrientation exifOrientation = [frameProperties[(__bridge NSString *)kCGImagePropertyOrientation] integerValue]; // 方向
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, i, NULL); // CGImage
UIImageOrientation imageOrientation = [self imageOrientationFromExifOrientation:exifOrientation];
UIImage *image = [[UIImage imageWithCGImage:imageRef scale:[UIScreen mainScreen].scale orientation:imageOrientation];
totalDuration += duration;
[images addObject:image];
}

// 最后生成动图
UIImage *animatedImage = [UIImage animatedImageWithImages:images duration:totalDuration];

这样处理的话,大部分情况下基本是可以接受的。但是这里有一个坑:UIImage这个animatedImages的接口,只会根据你传入的images的数量,平均分配传入的totalDuration的展示时长。但是大部分动图格式(GIF,APNG,WebP等等),都是不同帧不同时长的,这就会导致最后看到的动图每帧时长乱掉。

对于这个的解决方式也有。简单来说,就是通过对特定图像帧重复特定次数,以填充满整个应该播放的时长。其实实现也比较简单,我们可以对所有帧的时长,求一个最大公约数gcd,这样的话,只需要每帧重复播放duration / gcd次数,最终的总时长各帧repeat * duraion的和,就可以实现这个了,有兴趣可以看看我参与维护的SDWebImage的代码

示例代码:

1
2
3
4
5
6
7
8
9
NSUInteger durations[frameCount];
NSUInteger const gcd = gcdArray(frameCount, durations);
for (size_t i = 0; i < frameCount; i++) {
NSUInteger duration = durations[i];
NSUInteger repeatCount = duration / gcd;
for (size_t j = 0; j < repeatCount; j++) {
[animatedImages addObject:image];
}
}

渐进式解码

渐进式解码(Progressive Decoding),即不需要完整的图像流数据,允许解码部分帧(大部分情况下,会是图像的部分区域),对部分使用了渐进式编码的格式(参考:渐进式编码),则更可以解码出相对模糊但完整的图像。

比如说,JPEG支持三种方式的渐进式编码,包括Baseline,interlaced,以及progressive(参考:iOS 处理图片的一些小 Tip)

Baseline Interlaced Progressive

对于Image/IO的渐进式解码,其实和静态图解码的过程类似。但是第一步创建CGImageSource时,需要使用专门的CGImageSourceCreateIncremental方法,之后每次有新的数据(下载或者其他流输入)输入后,需要使用CGImageSourceUpdateData(或者CGImageSourceUpdateDataProvider)来更新数据。注意这个方法需要每次传入所有至今为止解码的数据,不仅仅是当前更新的数据。

之后的过程,就和普通的解码一致,就不再说明了。

示例代码:

1
2
3
4
5
6
7
8
NSData *data;
bool finished = data.length == totalLength;
CGImageSourceRef source;
// 更新数据
CGImageSourceUpdateData(source, (__bridge CFDataRef)data, finished);

// 和普通解码过程一样
CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0, NULL);

编码

编码过程,这里指的就是将一个UIImage表示的图像,编码为对应图像格式的数据,输出一个NSData的过程。Image/IO提供的对应概念,叫做CGImageDestination,表示一个输出。之后的编码相关的操作,和这个Destination一一对应。

静态图

静态图的编码,基本可以分为以下步骤:

  1. 创建CGImageDestination
  2. 添加图像格式元数据(可选)和CGImage
  3. 编码得到NSData,清理

1. 创建CGImageDestination

CGImageDestination的创建也有三个接口,你需要提供一个输出的目标来输出解码后的数据。同时,由于编码需要提供文件格式,你需要指明对应编码的文件格式,用的是UTI Type。对于静态图来说,第三个参数的数量都写1即可。

  • CGImageDestinationCreateWithData:指定一个可变二进制数据作为输出
  • CGImageDestinationCreateWithURL:指定一个文件路径作为输出
  • CGImageDestinationCreateWithDataConsumer:指定一个DataConsumer作为输出

示例代码:

1
2
3
4
5
6
7
CFStringRef imageUTType; //目标格式,比如kUTTypeJPEG
// 创建一个CGImageDestination
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)imageData, imageUTType, 1, NULL);
if (! destination) {
// 无法编码,基本上是因为目标格式不支持
return nil;
}

2. 添加图像格式元数据(可选)和CGImage

接下来就是添加图像了,由于CGImage只是包含基本的图像信息,很多额外信息比如说EXIF都已经丢失了,如果我们需要,可以添加对应的元信息。不像解码那样提供了两个API分别获取元信息和图像。使用的接口是CGImageDestinationAddImage

当然,如果有自定义的元信息,可以通过另外的CGImageDestinationAddImageAndMetadata来添加CGImageMetadata,这个上面解码也说到过,这里就不解释了。

此外,还有一个ImageIO最强大的功能,叫做CGImageDestinationAddImageFromSource (这个东西可以媲美vImageConvert_AnyToAny,后续教程会谈到),这个能够从一个任意的CGImageSource,添加一个图像帧到任意一个CGImageDestination。这个一般的用途,就是专门给图像转换器用的,比如说从图像格式A,转换到图像格式B。我们不需要先解码到A的UIImage,再通过编码到B的NSData,直接在中间就进行了转换。能够极大地提升转换效率(Image/IO底层就是通过vImage,传的是Bitmap的引用,没有额外的消耗)。不过这篇教程侧重于Image/IO的编码和解码,转换可以自行参考处理,不再详细说明了。

示例代码:

1
2
3
4
5
6
7
8
CGImageRef imageRef = image.CGImage; // 待编码的CGImage

// 可选元信息,比如EXIF方向
CGImagePropertyOrientation exifOrientation = kCGImagePropertyOrientationDown;
NSMutableDictionary *frameProperties = [NSMutableDictionary dictionary];
imageProperties[(__bridge_transfer NSString *) kCGImagePropertyExifDictionary] = @(exifOrientation);
// 添加图像和元信息
CGImageDestinationAddImage(destination, imageRef, (__bridge CFDictionaryRef)frameProperties);

3. 编码得到NSData,清理

当添加完成所有需要编码的CGImage之后,最后一步,就是进行编码,得到图像格式的数据。这里直接用一个方法CGImageDestinationFinalize即可,编码得到的数据,会写入最早初始化时提供的Data或者DataConsumer。

示例代码:

1
2
3
4
5
6
if (CGImageDestinationFinalize(destination) == NO) {
// 编码失败
imageData = nil;
}
// 编码成功,清理……
CFRelease(destination);

动态图

动态图的编码,其实不像解码那样困难。只需要准备好所有的动态图的帧,按照帧的顺序进行一一添加即可。基本步骤可以概括为:

  1. 静态图的步骤1,提供帧数
  2. 遍历所有图像帧,重复静态图的步骤2
  3. 静态图的步骤3

1. 提供帧数,遍历图像帧

在进行动态图编码时,创建CGImageDestination的时候需要提供动态图的张数。即在CGImageDestinationCreateWithData的参数中,将count设置为需要编码的总张数。

另外,在遍历图像帧的过程,其实只需要不断地按顺序添加就行了,如果需要设置额外元信息,也需要按顺序设置到当前帧上。相对于解码来说简单多了。其他的没有什么大的区别。我们这里还是以GIF为例,简单说明一下。

示例代码:

1
2
3
4
5
6
7
8
NSArray<UIImage *> *images;
float durations[frameCount];
for (size_t i = 0; i < frameCount; i++) {
float frameDuration = durations[i];
CGImageRef frameImageRef = images[i].CGImage;
NSDictionary *frameProperties = @{(__bridge_transfer NSString *)kCGImagePropertyGIFDictionary : @{(__bridge_transfer NSString *)kCGImagePropertyGIFUnclampedDelayTime : @(frameDuration)}};
CGImageDestinationAddImage(imageDestination, frameImageRef, (__bridge CFDictionaryRef)frameProperties);
}

总结

Image/IO封装了非常简单直观的接口来处理图像编解码,对于任何开发者来说都能轻易上手。而且性能方面很多格式都有Apple自己的硬件解码器来做保证。另外,对于图像转换,Image/IO所提供的这种Source-Destination的操作能够非常方便地在不同格式之间转换,有兴趣的人务必可以试试。

不过遗憾的是,Image/IO的接口设计并没有提供可以扩展或者插件化的地方,不支持的图像格式就比较无能为力了。关于这个问题,请期待系列教程第二篇——第三方编解码教程。

近期参与的APNG和WebP开源项目的经历及感受

作者 DreamPiggy
2017年7月26日 06:44

这篇文章讲的是有关近期自己参与的几个开源项目的经历以及感受,不过巧合的是内容都和APNG和WebP这两种图像格式相关,阅读前建议先简单略读一下之前写的一篇文章:客户端上动态图格式对比和解决方案

SDWebImage

SDWebImage是iOS平台上非常著名的图片下载、缓存库,而今年发布的SDWebImage 4.0在架构、接口变动并带来性能优化的同时,还支持了Animated WebP,因此我就高兴地去实验了一下,本想着可以替代之前使用的YYImage。但是一测试就发现渲染不正常,追回去看源码,发现SDWebImage的实现可以说是Too naive,压根没有按照WebP规范实现,大部分Animated WebP动图渲染都挂了,完全不可用(连测试都过不了,更别说生产环境了)。演示Demo在此:AnimatedWebPDemo

总结出来的具体问题有以下几个:

  1. SD绘制每帧的canvas大小不正确,在代码中,直接取得当前帧frame的大小,而非整个canvas的大小。这就导致最后生成的所有帧图片的数组中,每帧的图像大小不一致。这样渲染就会出现Bug(把所有帧拉伸到最大的那个图像大小上)。
  2. SD的实现没有考虑过WebP Disposal Method,这个在很多动图中都会用到,因为能够重复利用前一帧的画布,来大幅减少最后生成动图的体积。常见的动图格式如GIF、APNG生成工具一般都采用这种Disposal,不然最终文件体积较大(但Google提供的WebP工具暂时没有自带这种优化的方式,一般使用第三方工具处理)。
  3. UIKit自带的UIImage.animatedImages是非常弱的,SD并没有提供额外的抽象,而是直接用的这个接口。这带来的最大的问题,是UIImage需要提供一个图片数组和总时长,但是会对数组中每个图片平均分配时长。这与Animated WebP的规范就是不同的,后者允许对每帧设置一个不同的持续时长。
  4. UIImageView直接设置image属性,是不支持设置循环次数的,会默认无限循环播放。而有些Animated WebP图片需要有循环次数。

既然知道这么多坑,想着SD毕竟是主流框架,就赶紧提了Issue,但是过了一周多,SD社区依然没有任何回应。于是尝试自己一个个解决。最后的成果也比较好,上述4个问题都得到了解决。

Canvas大小问题

这个问题,可以直接通过libwebp的API,修改来使用canvas大小而不是frame大小,确保每帧最后的图像大小相同。其中,为了优化性能,对于透明的且frame比canvas要小的帧,绘制出来等价于将frame平移,然后所有剩余部分填充透明值。在使用CGBitmapContext的时候,可以直接在要传入的Bitmap矢量数据上做变换,减少绘制带来的开销(不过CGBitmapContext本身应该有优化,对于这个开销影响不大,但参考YYImage里面有这一步处理)

Disposal Method支持

在绘制每帧时,按照Animated WebP规范,共享一个全局的CGContext当作canvas,根据每帧不同的Disposal Method,如果为Disposal Background,则在绘制完当前帧后清空CGContext,否则的话不处理,保留到下一帧继续绘制,最终测试和YYImage行为一致。

每帧持续时长相等问题

这个问题相对比较麻烦,因为你无法改动UIKit实现方式。最后想了一个比较Trick的方式。思路也简单,考虑这样的情况:第1帧持续时间:50ms,第2帧持续时间:100ms,第3帧持续时间:150ms,总共时长300ms。在依然使用UIImage的接口情况下(即数组每帧时长平均分配),那就可以提供一个[1, 2, 2, 3, 3, 3](元素表示帧的编号)的图像数组,总时长300ms。这样的话平均分到每个元素是50ms,表面上看是6帧但实际渲染是3帧,也能达到最后的显示效果。这样实现的话,只要求一个所有帧持续时间的gcd,然后对每帧图像,按该帧所占的比例重复添加多次就可以了。

循环次数问题

由于SD的接口问题(用到了UIImageView的sd_setImageWithURL),是直接设置到UIImageView.image上的,而不是animationImages。而直接设置image会无视掉animationRepeatCount这个本来用于设置循环次数的属性。但如果SD框架自动设置animationImages 属性的话,可能对使用者现有代码有影响(因为使用者还是用的image属性而不是animationImages属性),因此最后的解决方案,是在UIImage的扩展中,单独提供了一个sd_webpLoopCount的属性来获取循环次数,使用者可以自行设置UIImageView的属性,来实现指定循环次数。

举个例子,一般情形下(显示的动图超过循环次数后停到最后一帧上)就可以这样子用。

1
2
3
4
5
6
7
[imageView sd_setImageWithURL:webpURL completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) {
imageView.image = image.images.lastObject;
imageView.animationDuration = image.duration;
imageView.animationRepeatCount = image.sd_webpLoopCount;
imageView.animationImages = image.images;
[imageView startAnimating];
}];

这也算是一个解决方式吧。

感受

在写完这些,跑过单元测试,提交了Pull request之后,回头来看,才能真正感到YYImage的实力。

YYImage通过一个抽象层YYImageFrame,来把GIF、APNG和Animated WebP三种格式统一到一起,并且提供了Encoder和Decoder可以在三种格式来互相转换(这是重点)。关于绘制部分,还使用到了Accelerate Framework,通过vImage的GPU加速的Bitmap变换来替代部分CGBitmapContext绘制。在缓存上,由于SD的抽象层存在,他使用了ImageIO来直接缓存CGImageSource(SD采用的是缓存了WebP的rawData),效率提升了很高也减少缓存大小(速度对比的话,可以从那个Demo工程看到,checkout到fix_sd_animated_webp_canvas_size分支上运行)。想想还是挺佩服ibireme这个人的,看来以后还要多使用YYKit并多学习。

apng2webp

apng2webp是一个转换APNG到Animated WebP图片的命令行工具,使用Python脚本 + 外部命令行工具来实现。在之前的工作需求中,使用到来优化APNG的大小,并且产出Animated WebP来让客户端使用。

为什么要转换APNG到Animated WebP呢,其实是因为APNG这个规范由于没有进入到PNG标准规范中,一直处于一个不温不火的地步,网上的APNG动图数量也不多,很多网页的PNG图片上传也不支持。虽然如今各大浏览器都对APNG提供了支持(Chrome 59正式支持了APNG,iOS很早从8.0支持,FireFox就是亲爹一直推动),但是客户端上,Android端没有相对靠谱的解码和渲染组件能够使用。反倒是Animated WebP借助Google亲爹推动,成为Android天生支持的图像格式,并且iOS上也有YYImage来提供支持。随着WebP的流行,越来越多设备估计都会支持WebP和Animated WebP,甚至最终超越GIF这个广为流行,但是已有30年历史,只支持256色和1位alpha通道的古老动图格式。

这次对apng2webp项目,主要是贡献了两个功能。

  1. Windows的支持,即现在三大桌面端命令行均可使用
  2. CI自动Build和Test

Windows的支持

由于整个外部命令行工具(有四个工具,其中cwebpwebpmux是Google官方提供的,有Windows Build,另两个是源码编译)都是UNIX工具链下的,依赖几个C++库也挺常见,但是尝试过使用VS 2015源码编译跪了,使用vcpkg这个非常新的Windows上的C++包管理工具,又爆了一堆link error。对于我这种C++菜鸟来说,最后只好选择了直接上Mysys2和MinGW-w64,一键pacman -S安装依赖,cmake makefile可用,跑了一遍测试也没问题,确实非常方便。由于MinGW-w64的编译产物,会依赖于libgcc,winpthreads,为了使最后的分发方便,于是在Windows上改用静态链接。

CI和单元测试

关于Python的单元测试,由于这是一个简单的命令行工具,最后就通过引入pytest,直接对main函数和外部工具进行了测试,写起来也特别简单(自动匹配文件名和类名这点挺好)。用起来感觉比起Objective-C和Java的工具要好用多了。

在CI Build上,对于Linux和macOS的话,一般都会使用GitHub官方合作的Travis CI,配置使用yml语法,再加上一系列的Bash命令。而Windows上使用的Appveyor也非常好用,自带了VS 2012,2015,2017Msys2MinGW-w64cmake等一系列工具,上手开箱即用。配置的话注意要使用CMD或者PowerShell,如果不熟悉,甚至可以用Msys2装一些UNIX工具来搞定(好处之一)。

感受

总体来说,这个项目主要是苦力活,不过也算熟悉了一下UNIX工具在Windows上移植的一种手段,而且还学习到了pytest和开源项目的CI Build方式,也算有点意思吧。

iSparta

iSparta是一个图形化的APNG和WebP转换工具,包含了很多功能(APNG合成,WebP转换,图片压缩等),虽说是开源项目,但是上一次提交已经是三年前了。而我最希望的APNG转换Animated WebP功能却没有实现(这也难怪,三年前Animated WebP规范还没出来)。大概看了一眼,使用的是NW.js(其实用的是改名前叫做node-webkit的东西),是一个和Electron类似的,使用前端技术栈来构建跨平台应用的框架,本质上都是一个Chromium的运行环境来提供渲染,再加上node.js来提供JS Runtime。上手相对容易。

基本上的目标,是为了提供更好的GUI工具,因此主要就参考了一下iSparta的Issue,解决这几个问题:

  1. 支持APNG转换Animated WebP
  2. 支持i18n国际化

由于我并不是专业前端出身(大二学过一段时间前端基本知识和Node.js简单应用,也接触过React Native),经过近两天的奋斗,才终于磕磕碰碰完成。期间遇到过各种问题(NW.js的问题,node第三方库的问题,跨平台行为不一致的问题等等),不过在这里略过说一下重点吧。

APNG支持Animated WebP

关于这个功能,自然可以想到上面的apng2webp命令行工具,不过由于apng2webp本身是Python写的脚本来调用外部工具,没必要在NW.js里打包一个Python环境。因此最后就决定直接在JS里,实现了相同逻辑的脚本来完成。不过实话说这部分花费的时间不长,在GUI布局上才是重头。大体框架参考了项目中的已有写法,但CSS的部分由于实在生疏(原项目有一些布局Hack),最后使用了flexbox布局来搞定的。

i18n国际化

在网页端支持i18n国际化,这是确实是以前未接触过的地方。考虑到这个项目有大量散落的HTML文本中硬编码了中文文字,而又没有使用类似于Angular、React这种先进的技术来支持模板,因此就需要自行解决。最开始思考了使用服务端渲染的解决方案(即NW.js当作浏览器,本地起node使用express当作服务端,来返回渲染好对应国际化后的HTML),但是遇到了问题,当作纯浏览器后,NW.js无法再使用node端的本地包,这也就意味着无法调用外部的命令行工具(相当于RPC了)。因此这种方案不可行。

再经过尝试后,最后使用的解决方案,是引入了node-i18n和模板引擎(这里用的是doT)。在项目目录下准备好i18n的文本资源(框架支持的是JSON格式)。然后在NW.js应用启动时加载一个空body的页面,执行JS来获取i18n后的字符串,再将这些字符串渲染到只有body的模板中,最后把国际化完成后的HTML body插入到原始的页面的body中。整个过程没有多余的开销(避免了模板未渲染前被显示出来,而且可以缓存模板结果,因为实际上给定一种locale,模板生成的HTML是固定的)。

感受

其实现在看看自己平时用到的应用,AtomVS CodeGitKraken钉钉,这些看起来已经足够复杂,也都能够用这种前端技术栈构建起来了。以前自己如果提到跨平台桌面客户端应用,第一反应就是Qt,不过现在看来,如果对前端技术栈有所了解,对性能和实时性要求不高,是可以使用Electron或者NW.js这种框架来构建。虽然曾经见过有人批判这些框架(体积庞大-打包了Chromium和Node;内存占用高,效率低下-WebKit渲染而不是原生UI组件),reddit上甚至有讨论说这是新一代的Adobe Flash。

但我个人看来,不排斥这样的框架,只是感觉如今的解决方案并不是十分完美,这些前端栈技术写的客户端最大的问题其实是代码复用问题,基本上是各家有自己的一套组件,而且很多解决方案很Trick。我觉得更为理想的情况,是能够提供一套完整的解决方案,包含了开箱即用的UI组件(并非指Bootstrap这种通用Web UI组件,而是专门针对桌面客户端优化的,符合客户端的交互方式),能够开发,构建,测试,打包一站式自动处理,足够多的Native桥接(这也是一大痛点,见过一些应用又回过头在Electron里面使用Flash),更多的优化,比如共享Chromium容器-不必每个应用的带上200MB的运行环境。

总体来说,Electron或者NW.js这些框架的前途还是比较光明的,毕竟传统意义上的桌面应用开发成本还是太高,尤其是互联网公司的产品,追求跨平台的情况下,在成本,人力还有技术难点考虑来看,也是一个不错的选择。

总结

其实,这三个开源项目都是属于一时兴起才去贡献的,并不是为了而去专门寻找的,至于为什么都是WebP相关,或许真的是巧合吧。参与这些开源项目,虽然花费了一定的时间精力,但是获得的知识面上的提升确实非常大,包括但不限于:WebP规范Accelerate Framework跨平台C++移植Python单元测试CI配置NW.js前端i18n

说实话,参与开源项目的时候,你会发现一些社区是很有意思的,你能够和不认识的人去合作,还能够直观感受到其他人对项目的关注,更能够接触很多你之前从没有接触过的技术栈。我不能说自己是一个愿意花费大量个人时间去贡献开源事业的人,但是其实很多项目参与门槛不是那么高,无论是你自己平时用到的软件、类库,甚至是一个小工具、脚本、翻译、教程,都可以试着参与一下。我觉得程序员的知识,并不是为了单纯为了打工搬砖,能够把自己的想法与他人分享也是一个相当大的乐趣,不是吗?

客户端上动态图格式对比和解决方案

作者 DreamPiggy
2017年3月6日 22:51

对各种客户端来说,无论是Web还是移动端,图片占据的容量和传输资源一定是非常大的。对于静态图,我们常见的PNG和JPEG格式在压缩率和画质无损上都存在着不尽如人意的地方,而动图格式的GIF更是存在着很多问题,比如因此,在很多情况下,我们需要迁移到新的图片格式。

GIF

为什么我们不用GIF呢,GIF由于时代限制,存在的天生的问题。GIF的规范最新版本是在1989年制定的,一个24位色都没有普及的时代,因此,GIF规范只支持256色索引颜色,并且只能通过抖动、差值等方式模拟较多丰富的颜色。更为悲剧的是,它的alpha通道只有1bit,换言之,一个像素要么完全透明,要么完全不透明,而不像现在PNG的RGBA的8bit alpha通道,alpha值也可以和RGB一样都有255个透明值。这导致了所有GIF的图片带上透明度以后,边缘会出现明显的锯齿。所以如果你的客户端需要展示带透明度的动图,GIF基本上可以不考虑

实际的在线Demo,建议用Safari或者Chrome+插打开:http://apng.onevcat.com/demo

APNG

APNG是Mozilla在2008年发布的图片格式,本质上是在PNG的基础上加上一个扩展,而且非常简单即可实现。因此能够完全支持RGBA。规范可以参见APNG Specification

虽然这个规范没有加入PNG开发组,但是很多浏览器已经支持了APNG。
最主推的是Apple的Safari(OS X 10.10以后的Safari,以及iOS 8以后的Safari和内置WebView),已经完全支持。Firefox亲儿子当然一直是支持的。Chrome桌面端已经从Chrome 59开始支持,现在就差Edge了。具体支持程度参见浏览器兼容性

APNG的优势,在于时间比较长,各种动图制作工具,优化工具都有相应的项目来支持。而且在iOS上的WebView里面是除GIF外,唯一官方支持的动图格式,因此如果做移动端开发需要WebView页引入动图,APNG还是必不可少的。

当然,APNG终究是在PNG的基础上扩展,并没有引入特别出色的压缩算法,而且遗憾的是,短期内APNG还没有引入到Chrome,也就意味着Android平台的WebView也没有原生支持,因此,移动开发又会面临两端兼容性问题,这个后话再说。

APNG,Chrome需要59或者更高

相关APNG工具

APNG图形化制作工具和在线预览:iSparta
APNG大小优化:APNG Optimizer
APNG Chrome插件:APNG for Chrome

WebP

WebP是Google在2010年发布的图片格式,完全开源,使用了VP8(就是WebM视频所用到的解码器)作为帧压缩编码器,而且在Chrome,Android上得到了原生的支持,具体规范参见:WebP

同样的支持RGBA,而且静态WebP的压缩率比起同质量PNG平均要高上20%左右。现在各大App厂商已经有开始迁移WebP。除了静态的WebP,还有动态WebP格式(Animated WebP)支持,不过动态WebP需要libwebp 0.4以后才正式支持,并需要mux和demux模块,如果自行编译需要注意。

Google官方提供了libwebp这个解码库在各个平台的二进制版本和Makefile,并且可以定制开启的功能。不过由于不像APNG那样基于PNG扩展,相关的工具很欠缺,基本全靠WebP Project提供的工具。

cwebp:PNG/JPEG -> WebP
dwebp:WebP -> PNG/JPEG
vwebp:WebP命令行预览工具
webpmux:多张WebP制作动态WebP
gif2webp:GIF -> 动态WebP

Animated WebP,Safari不支持

WebP工具

基本上来说,手动制作WebP会比较麻烦,因为Google没有提供WebP Optimizer之类的东西,如果我有100帧基本无差别的图使用webpmux合成动图,最终输出的文件大小会比较大。因此,一般推荐的做法,是先通过PNG制作APNG(比如iSparata),经过APNG Optimizer之后,再从APNG转换到动态WebP,这个流程可以用这个项目来一键搞定。
同时,也可以使用ffmpeg来转换视频到Animated WebP,一般使用MOV封装格式(UE常用的Pr导出的MOV可以支持alpha通道)。不过经过测试转换出来的Anmimated WebP大小相对比较大的(尤其同样的lossless下),不如PNG->APNG->Animtated Webp这个流程效果好。

apng2webp:APNG -> Animated WebP
ffmpeg:MOV -> Animated WebP

其他粗暴的解决方案

像国内的微博桌面版,提供的动图是通过PNG配合CSS Spirit,靠着不断JS轮播切换PNG子图所拼出来的,这个带来的带宽消耗会是非常高的,因为完全是多张图片混合,除非有着兼容性包袱(IE之类),一般不推荐使用。

暴力实现

APNG和WebP各平台实现

Web

APNG 浏览器支持
WebP 浏览器支持,注意Animated WebP支持

iOS

APNG:

Animated WebP:

WebP:

  • SDWebImage,注意SD使用的libwebp并没有加入mux和demux,故无法支持Animated WebP

WebView:

  • UIWebView,WKWebView和SafariViewController均只支持APNG(iOS 8以后),不支持Webp和Animated WebP

YYImage,对显示动态图,使用了一个UIImageView的子类YYAnimatedImageView,通过直接插入了一个CALayer来作为图片的渲染layer,并用CADisplayLink这个帧定时器来刷新动图帧,通过异步线程处理解码,还有一些C的动态分配和回收内存来避免非常高的内存占用,保证了性能。并且自动处理了从视图消失以及滚动(可以切换到RunLoopCommonMode来滚动时候依然显示动图而不暂停)情况的问题,实现也非常有意思,有兴趣的人可以看一看。

Android

APNG:

Animated WebP:

WebView:

  • Android 4.3以后才支持带lossless和alpha的WebP

Android基本上对APNG可以说是没有什么支持的,所以如果是移动开发两个平台兼顾,建议同时准备APNG(for iOS WebView)和Animated WebP,客户端上建议都是用Animated WebP,因为VP8的解码速度相对于APNG有一些优势。

存在的坑

Web和移动端对于APNG和Animated WebP循环次数不同

这个是一个非常大的坑,在Safari for iOS(Safari for macOS正常)和Chrome预览APNG和Animated WebP的时候,动图的循环次数为对应原图的loop+1。比如Animated WebP有100帧,loop为2,那么Chrome会循环总计展示300帧

刚开始我以为是移动端实现库的问题,毕竟Google和Apple这种大厂一般不会出现问题。但是再参阅了APNG和Animated WebP的规范,发现确实是Safari和Chrome本身的问题,可以参考APNG规范中的num_plyas字段,和WebP规范loop_count字段

1
2
3
Loop Count: 16 bits (uint16)
The number of times to loop the animation. 0 means infinitely.
This chunk MUST appear if the Animation flag in the VP8X chunk is set. If the Animation flag is not set and this chunk is present, it SHOULD be ignored.

规范提到的伪代码描述也表示,loop count为0表示无限循环展示首帧到尾帧,而loop count >= 1,展示首帧到尾帧loop count次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
assert VP8X.flags.hasAnimation
canvas ← new image of size VP8X.canvasWidth x VP8X.canvasHeight with
background color ANIM.background_color.
loop_count ← ANIM.loopCount
dispose_method ← ANIM.disposeMethod
if loop_count == 0:
loop_count = ∞
frame_params ← nil
assert next chunk in image_data is ANMF
for loop = 0..loop_count - 1
clear canvas to ANIM.background_color or application defined color
until eof or non-ANMF chunk
frame_params.frameX = Frame X
frame_params.frameY = Frame Y
frame_params.frameWidth = Frame Width Minus One + 1
frame_params.frameHeight = Frame Height Minus One + 1
frame_params.frameDuration = Frame Duration
#......
Show the contents of the canvas for
frame_params.frameDuration * 1ms.

同样的,APNG对应的num_plays字段意思是一样的,大家可以使用这个在线测试用例,Safari表现错误而多循环了一次:https://philip.html5.org/tests/apng/tests.html#num-plays-1

解决办法:
由于不能更改浏览器的实现,部分情况也不好引入JS来手动实现,因此,对于APNG,一般只用在iOS的WebView上,因此可以直接制作APNG图的时候,把循环减一。而Animated WebP,可以在客户端实现加一个Hack,如果loop不是0手动减一,保持和Web一致性(当然,也可以专门提供一个loop count加一的图给Chrome/Android的WebView),希望之后两大浏览器是否可以把这个Bug修复了(当然,不排除联合一起更改了规范的可能性)

总结

GIF作为一个动图格式已经太过于古老了,尤其是当前移动和Web站需要引入各种动态表情,头像的时候,GIF的透明问题已经是不可接受的。WebP长期发展也是比较看好(相比APNG没有进入PNG开发组,基本不再活跃),开源外加无授权费用,或许能够和WebM一样,成为互联网下首选的图片和视频格式。而移动客户端,在很多种需求下(动态表情,用户标志,广告)等上面,采用这种APNG和Animated WebP就能够轻松解决。

❌
❌