普通视图

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

探秘越来越复杂的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私信等方式联系我。

CocoaPods资源管理—Data Asset最低部署版本的坑

作者 DreamPiggy
2021年7月17日 00:16

背景

自己很早之前曾经写过一些CocoaPods管理Resource资源的文章:CocoaPods的资源管理和Asset Catalog优化 ,当时列举了对普通图片类型的管理方式和一些用法,也普及了一下UIImage获取Bundle去加载不在mainBundle图像的方式。

但是苹果早在iOS 9,Xcode 7时代,苹果就已经推出了Data Asset的概念,并在随后的Xcode,尤其是Xcode 10中,为Data Asset提供了App Slicing的能力(即App Store提审包会根据选择的不同设备/内存/分辨率/GPU/CPU,最终下载到唯一匹配的一份文件),这个功能渐渐地开始被一些国内开发者使用。

在NSHipster这里,有一篇专门的文章介绍:《NSDataAsset

不过,这篇文章主要的内容是,最近有同事踩到一个关于Data Asset和最低部署版本的坑,这里单独列举一下以防后人重复踩坑。

Data Asset初见

标准的配置下,我们可以直接在Xcode里创建一个Asset Catalog,然后拖入想要的文件。注意我们可以在右侧针对不同的配置设置不同的文件内容。

1625559957403_29bd9b59c2bbaa1f363122a8276779b6

最终一个Data Asset的输入大概的形式是这样子的:

1
2
3
4
5
6
7
8
9
Image.xcassets

- A.dataset

-- Contents.json

-- 1.zip

-- 2.webp

可以看到除了后缀名以外,其他的结构和普通的imageset保持一致。

Data Asset产物

在执行Xcode标准的Copy Bundle Resources的Build Phase之后,可以看到我们的Data Asset会被编译为一个Assets.car文件,这个格式也是老熟人了。

1625559957281_7b787078bbae747abaf28cde1a513955

Data Asset获取代码

类似于图像,由于Data Asset最终会编译到Car中,无法直接获取文件路径(Flutter/H5等跨平台库又需要使用Bridge方案来调用Native接口)

在运行时,我们需要使用Fondation提供的专门类NSDataAsset相关接口,来获取真正的NSData,接口比较简单直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
/** 如果是非Main Bundle,要获取Bundle

NSString *bundlePath = [[NSBundle bundleForClass:self.class].resourcePath stringByAppendingPathComponent:@"A.bundle"];

NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];

*/

NSBundle *bundle = [NSBundle mainBundle];

NSDataAsset *asset = [[NSDataAsset alloc] initWithName:@"TestImageAnimated" bundle:bundle];

NSData *data = asset.data;

看起来比UIImage的相关接口简单理解多了,对吧。

坑-最低部署版本影响行为

然而最近有同事发现,他们的一个SDK,使用了Data Asset,在不同的宿主App中行为不一致。某个宿主中可以能访问到数据,另一个一直访问不到。前来咨询(?)了我,因此做了一番排查,发现了一个坑:

先说结论:Data Asset的编译单元,在最低部署版本iOS 9以下时,不会产出Asset.car而是直接拷贝了文件到原Bundle路径下;只有iOS 9及以上才会产出Asset.car

如图,这是SDK的资源。SDK使用了CocoaPods进行托管,Podspec里面使用了resource_bundles来提供对外的资源。这里的Data Asset里面内容是一个WebP文件。

1625559957352_30cb4a561799eec3da92fa1c607c101e

1
2
3
4
5
6
7
s.ios.deployment_target = "8.0"

s.subspec 'Core' do |ss|

ss.resource_bundle = {'splashResourceCore' => ['TTAdSplashSDK/Assets/splashResource/CoreImage.xcassets', 'TTAdSplashSDK/Assets/splashResource/ShakeMusic.mp3']}

end

看起来非常正常,但是实际上行为就是有所不同。于是简单开始从源头排查差异。

宿主A

我们搜索查看Xcode最终编译的命令。负责编译xcassets的命令是actool。我们可以看到,在com.apple.actool.compilation-results这里有打印所有的输出,是符合预期的。

1
2
3
4
5
6
7
8
9
10
11
12
13
CompileAssetCatalog /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle /Users/bytedance/TTiOS/subs/tt_splash_sdk/TTAdSplashSDK/Assets/splashResource/CoreImage.xcassets (in target 'TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore' from project 'TTAdSplashSDK')

cd /Users/bytedance/TTiOS/subs/tt_splash_sdk/Example/Pods

/Applications/Xcode.app/Contents/Developer/usr/bin/actool --output-format human-readable-text --notices --warnings --export-dependency-info /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Intermediates.noindex/TTAdSplashSDK.build/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore.build/assetcatalog_dependencies --output-partial-info-plist /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Intermediates.noindex/TTAdSplashSDK.build/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore.build/assetcatalog_generated_info.plist --compress-pngs --enable-on-demand-resources NO --optimization space --filter-for-device-model iPhone13,2 --filter-for-device-os-version 14.5 --development-region en --target-device iphone --target-device ipad --minimum-deployment-target 10.0 --platform iphonesimulator --compile /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle /Users/bytedance/TTiOS/subs/tt_splash_sdk/TTAdSplashSDK/Assets/splashResource/CoreImage.xcassets



/* com.apple.actool.compilation-results */

/Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Intermediates.noindex/TTAdSplashSDK.build/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore.build/assetcatalog_generated_info.plist

/Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle/Assets.car

检索产物Assets.car,也符合预期:

1625559957323_ceced1da15007185b48893a6eda48754

宿主B

同样的,我们查看编译命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
CompileAssetCatalog /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle /Users/bytedance/TTiOS/subs/tt_splash_sdk/TTAdSplashSDK/Assets/splashResource/CoreImage.xcassets (in target 'TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore' from project 'TTAdSplashSDK')

cd /Users/bytedance/TTiOS/subs/tt_splash_sdk/Example/Pods

/Applications/Xcode.app/Contents/Developer/usr/bin/actool --output-format human-readable-text --notices --warnings --export-dependency-info /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Intermediates.noindex/TTAdSplashSDK.build/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore.build/assetcatalog_dependencies --output-partial-info-plist /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Intermediates.noindex/TTAdSplashSDK.build/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore.build/assetcatalog_generated_info.plist --compress-pngs --enable-on-demand-resources NO --optimization space --filter-for-device-model iPhone13,2 --filter-for-device-os-version 14.5 --development-region en --target-device iphone --target-device ipad --minimum-deployment-target 8.0 --platform iphonesimulator --compile /Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle /Users/bytedance/TTiOS/subs/tt_splash_sdk/TTAdSplashSDK/Assets/splashResource/CoreImage.xcassets



/* com.apple.actool.compilation-results */

/Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Intermediates.noindex/TTAdSplashSDK.build/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy-splashResourceCore.build/assetcatalog_generated_info.plist

/Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle/Assets.car

/Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle/ad_btn_hand.webp

/Users/bytedance/Library/Developer/Xcode/DerivedData/TTAdSplashSDK-bouxjwktwlrwfthejbcmzvcqddie/Build/Products/Debug-iphonesimulator/TTAdSplashSDK-Core-Interactive-Privacy/splashResourceCore.bundle/ad_btn_triangle.webp

此时,在actool的编译结果中,我们发现,原本预期应该在Data Asset的ad_btn_hand.webpad_btn_triangle.webp两个文件,竟然直接拷贝到了.bundle的根路径,而不是Assets.car中!

1625559957272_fa17b69e9dd37090291bc0a6952baa38

对比两者的命令,只有--minimum-deployment-target这一项有差距,宿主A是iOS 10.0,宿主B是iOS 8.0。

经过再次Demo验证,确定了是这个导致了行为的差异!

SDK调用代码

SDK运行时需要获取这些代码,经过查看,这里的代码是假设按照.bundle根路径存在Data Asset的文件名的方式去取的,因此在宿主A中会出现异常:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 伪代码

NSString *bundlePath = [[NSBundle bundleForClass:TTAdSplashManager.class].resourcePath stringByAppendingPathComponent:@"splashResourceCore.bundle"];

NSbundle* bundle = [NSBundle bundleWithPath:bundlePath];



NSString *trianglePath = [bundle.resourcePath stringByAppendingPathComponent:@"ad_btn_triangle.webp"];

NSData *triangleData = [NSData dataWithContentsOfFile:trianglePath];

self.imageView.image = [UIImage imageWithData:triangleData];

进一步排查最低部署版本变化

本质原因了解清楚后,进一步排查这个疑问:

为什么宿主A和宿主B,对于一个SDK的Pod,最低部署版本不一致?

因为SDK的Podspec的最低部署版本已经指明了iOS 8,按理说在哪个宿主集成都应该走的是路径的逻辑,而不应该受限于宿主iOS App自己的编译最低部署版本。

查看宿主A,发现宿主A使用了CocoaPods的插件,在Pod Project Generate的时候,强制修改了所有Pod,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
all_targets.each do |target|

target.set_build_settings('IPHONEOS_DEPLOYMENT_TARGET') do |_, old|

old.to_f < 10.0 ? '10.0' : old

end

target.set_build_settings('ASSETCATALOG_COMPILER_OPTIMIZATION') do |_, old|

definitions = 'space'

definitions

end

end

导致SDK的编译Assets.car时,--minimum-deployment-target传入了iOS 10.0,Data Asset编译到Assets.car里

而宿主B,并没有这个逻辑,按照iOS 8.0传入,Data Asset散落在Bundle根路径。

结论

从这个坑可以看到,最低部署版本,这个编译配置,设置时需要谨慎。由于iOS App不会针对不同的部署版本,单独打一份独立的ipa包(类似PC等平台),所以很多工具链对针对最低部署版本,有着可能不同的兼容性行为,iOS系统快速迭代的节奏下尤其是这样。

这里有两个改进方案:

  1. 对于宿主,除非你清楚知道改变最低部署版本的副作用,否则要慎重处理外部Pod的最低部署版本,建议在修改后进行一定的回归测试,或者针对白名单来进行修改。
  2. 对于SDK作者,如果没有用到Data Asset的特性(App Slicing),可以考虑直接不用Data Asset而直接放到Bundle中,省去踩坑的问题。如果需要利用Data Asset,并且你无法保证引入方宿主会对你的Pod做额外的修改,可以考虑这种兼容代码来判断:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NSString *bundlePath = [[NSBundle bundleForClass:self.class].resourcePath stringByAppendingPathComponent:@"Image.bundle"];

NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];

// 如果编译时的最低部署版本iOS 9以上,Data Asset需要用NSDataAsset类获取,否则用直接取路径

NSDataAsset *asset = [[NSDataAsset alloc] initWithName:@"TestImageAnimated" bundle:bundle]; // 此处是Asset名,不是文件名!

NSData *data = asset.data;

if (!data) {

// Fallback到路径

data = [NSData dataWithContentsOfFile:[bundlePath stringByAppendingPathComponent:"TestImageAnimated.webp"]]; // 此处是文件名,注意!

}

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

参考资料

WatchKit渲染原理以及SwiftUI桥接

作者 DreamPiggy
2019年12月11日 01:04

WatchKit渲染原理以及SwiftUI桥接

背景

Apple Watch作为苹果智能穿戴设备领域的重头,自从第一代发布已经经历了6次换代产品,操作系统的迭代也已经更新到了watchOS 6。

不同于iPhone的App,watchOS上的大部分App都侧重于健康管理,并且UI交互以直观,快速为基准。在2015年WWDC上,苹果发布的watchOS的同时,面向开发者发布了WatchKit,以用于构建watchOS App。

watchkit-app.jpg

这篇主要讲了关于WatchOS上的App的架构介绍,基本概念,并深入分析了WatchKit的UI渲染逻辑,也谈了一些WatchOS和SwiftUI相关的问题。

其实写这个文章的最主要的原因,是在于自己前段时间写库时候,在SwiftUI与watchOS的集成中,遇到了相当多的问题,迫使我对WatchKit进行了一些探索和逆向分析,这里共享出来,主要原因有多个:

  1. 能够了解WatchKit的背后实现细节,回答诸如这种问题:“为什么WatchKit使用Interface Object的概念,而不能叫做View“
  2. 能够理解WatchKit的架构设计,作为库开发者提升自己的分层抽象,架构能力,甚至可以自己做一套类似WatchKit的实现(上层封装布局框架或者DSL)
  3. 了解到SwiftUI和WatchKit之间的坑点在于什么,在开发时候遇到奇怪问题能够进行分析归因
  4. 实在被逼无奈的时候,可以考虑利用渲染机制走UIKit(注意私有API风险)

WatchKit架构介绍

一个标准WatchKit App,可以分为至少两个部分:

  • Watch App Target:只有Storyboard和资源,用来提供静态的UI层级,你不允许动态构建View树(可以隐藏和恢复)
  • Watch Extension:管理所有逻辑代码,Interface Controller转场,更新UI

如果没有接触过WatchKit,推荐参考这篇文章快速概览了解一下:NSHipster - Watch​Kit。只需要知道,我们的核心的UI构造单元,是Interface Object和Interface Controller,类似于UIKit的View和ViewController。

Interface Controller用于管理页面展示元素的生命周期,而Interface Object是管理Storyboard上UI元素的单元,且只能触发更新,无法获取当前的UI状态(setter-only)。

在watchOS 1时代,WatchKit采取的架构是WatchKit Extension代码,运行在iPhone设备上,于Apple Watch使用无线通信来更新UI,并且由于运行在iPhone上,可以直接访问到App的共享沙盒和UserDefaults。这受当时早期的Apple Watch硬件和定位导致的一种局限性。

在watchOS 2时代,为了解决1时候的更新UI延迟问题,WatchKit进行了改造,将Extension代码放到Apple Watch中执行,就在同样的进程当中,避免额外的传输。为了解决和iPhone的存储同步问题,与此同时推出了WatchConnectivity框架,可以与iPhone App进行通信。

WatchKit UI布局原理

WatchKit本身设计的是一个完整的客户端-服务端架构,在watchOS 1时代,由于我们的Extension进程在iPhone手机上,而App进程在Apple Watch上,因此通信方式必定是真正的网络传输,苹果采取了WiFi-Direct+私有协议,来传输对应的数据。

watchOS 1时代的App性能表现很糟糕,一旦iPhone和Apple Watch距离较远,整个watchOS App功能基本是无法使用,只能重新连接。

在watchOS 2上,苹果取巧的把Extension进程放到了Apple Watch本身,而上层已有的WatchKit代码不需要大幅改变。但是,Apple并没有因为这个架构改变,而提供真正的UIKit给开发者。类似的,一些贯穿于iOS/macOS/tvOS的基本框架,Apple依旧把它保留为私有,包括:

  • CoreAnimation
  • Metal
  • OpenGL/ES
  • GLKit

开发者在watchOS上,除了使用WatchKit以外,只能采取SceneKit或者SpriteKit这种高级游戏引擎,来开发你的watchOS App。

虽然苹果这样做,有很多具体的原因,比如说兼容代码,比如性能考量,甚至还有从技术层面上强迫统一UI风格等等。不过随着watchOS 6的发布,watchOS终于有真正的UI框架了。

客户端

WatchKit的客户端,指的是Apple Watch App自带的WatchKit Extension部分。

在watchOS 1上,客户端的进程位于iPhone当中,而不是和Apple Watch在一起。之间的传输需要走网络协议。在watchOS 2中,之间的传输依旧保持了一层抽象,但是实际上最终等价于同进程代码的调用。

由Storyboard创建的WKInterfaceObject,一定会有与之绑定的WKInterfaceController,这些Controller会保留一个viewControllerID,用于向服务端定位具体的UIKit ViewController(后面提到)

WKInterfaceObject的所有公开API相关属性设置,比如width height,alpha, image等,均会最终转发到一个_sendValueChanged:forProperty:方法上。Value是对应的对象(CGFloat会转换为NSNumber,部分属性会使用字典),Property是这些属性对应的名称(如width,height,image,text等)。

根据是否WatchKit 2,会做不同的处理。WatchKit 2会经过Main Queue Dispatch分发,而Watch 1采取的是自定义的一个通信协议,通过和iPhone直连的WiFi和私有协议传输。

简单来说,等价于如下伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@implementation WKInterfaceObject
- (void)setWidth:(CGFloat)width {
[self _sendValueChanged:@(width) forProperty:@"width"];
}

- (void)_sendValueChanged:(id<NSCoding>)value forProperty:(NSString *)property {
NSDictionary *message = @{
@"viewController": self.viewControllerID,
@"key": "wkInterfaceObject",
@"value": value,
@"property": property,
@"interfaceProperty": self.interfaceProperty
};
[[SPExtensionConnection remoteObjectProxy] sendMessage:message];
}
@end

服务端

这里的提到服务端,在watchOS 1时代其实就是Apple Watch上单独跑的进程,而在watchOS 2上,它和Extension都是在Apple Watch上,也实际上运行在同一个进程中。

对于每个watchOS App,它实际可以当作一个UIKit App。它的main函数入口是一个叫做WKExtensionMain的方法,里面做了一些Extension的初始化以后,就直接调用了
有UIApplicationMain。watchOS App有AppDelegate(类名为SPApplicationDelegate),会有一个全屏的root UIWindow当作key window。

watchkit1

UI初始化

在服务端启动后,它会加载Storyboard中的UI。对每一个客户端的Interface Controller,实际上服务端对应会创建一个View Controller,对应UIViewController的生命周期,会转发到客户端,触发对应的Interface Controller的willActivate/didAppear方法。

因此,watchOS创建了一个SPInterfaceViewController子类来统一做这个事情,它继承自SPViewController,父类又继承自UIViewController,使用客户端传来的Interface Controller ID来绑定起来。

对于UI来说,每一种WKInterfaceObject,其实都会有一个原生的继承自UIView的类去做真正的渲染,比如:

SPInterfaceViewController的主要功能,就是根据Storyboard提供的信息,构造出对应这些UIView的树结构,并且初始化对应的值渲染到UI上(比如说,Image有初始化的Name,Label有初始的Text)。实际上,这些具体的初始化值,都存储在Storyboard中,比如说,这里是一个简单的包含Table,每个TableRow是一个居中的Label,它对应的结构化数据如下:

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
{
controllerClass = "InterfaceController";
items = (
{
property = interfaceTable;
rows = {
default = {
color = EFF1FB24;
controllerClass = "ElementRowController";
items = (
{
alignment = center;
fontScale = 1;
property = elementLabel;
text = Label;
type = label;
verticalAlignment = center;
}
);
type = group;
width = 1;
};
};
type = table;
}
);
title = Catalog;
}

这些信息会在运行时用于构建真正的View Tree。

值得注意的是,watchOS由于本身的UI,这些SPInterfaceViewController的rootView,一定是一个容器的View。比如说一般的多种控件平铺的Storyboard会自带SPInterfaceGroupView,一个可滚动的Storyboard会自带一个SPCollectionView,等等。这里是简单的伪代码:

1
2
3
4
5
6
7
@implementation SPInterfaceViewController
- (void)loadView {
Class rootViewClass;
UIView *rootView = [[rootViewClass alloc] initWithItemDescription:self.rootItemDescription bundle:self.bundle stringsFileName:self.stringsFileName];
self.view = rootView;
}
@end

UI更新

UI创建好以后,实际上我们的Extension代码会触发很多Interface object的刷新,比如说更新Label的文案,Image的图片等等,这些会从客户端触发消息,然后在服务端统一由AppDelegate接收到,来根据viewControllerID找到对应先前创建的SPInterfaceViewController。

1
2
3
4
5
6
7
8
9
10
11
@interface SPApplicationDelegate : NSObject <SPExtensionConnectionDelegate, UIApplicationDelegate>
@end

@implementation SPApplicationDelegate
- (void)extensionConnection:(SPExtensionConnection *)connection interfaceViewController:(NSString *)viewControllerID setValue:(id)value forKey:(NSString *)key property:(NSString *)key {
if ([key isEqualToString:@"wkInterfaceObject"]) {
SPInterfaceViewController *vc = [SPInterfaceViewController viewControllerForIdentifier:viewControllerID];
[vc setInterfaceValue:value forKey:key property:property];
}
}
@end

因此,拿到UIViewController以后,WatchKit会根据前面传来的interfaceProperty来定位,找到一个需要更新的View。然后向对应的UIView对象,发送对应的property和value,以更新UI。

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface SPInterfaceImageView : UIImageView
@end
@implementation SPInterfaceImageView
- (void)setInterfaceItemValue:(id)value property:(NSString *)property {
if ([property isEqualToString:@"width"]) {
self.width = value.doubleValue;
}
if ([property isEqualToString:@"image"]) {
self.image = value;
}
// ...
}
@end

后续的流程,就完全交给UIKit和CALayer来进行渲染了。

总结流程

watchkit2

通过这张图,其实完整的流程,我们可以通过调用栈清晰看到,如图各个阶段:

  1. 开发者调用WKInterfaceObject的UI方法
  2. 客户端的WKInterfaceObject统一封装发送消息
  3. 传输层传输消息(watchOS 1走网络,watchOS 2实际上就Dispatch到main queue)
  4. 服务端接收到消息,消息分发给对应的ViewController
  5. ViewController分发消息给rootView(会递归处理)
  6. View解码消息,得到对应的需要设置的UIKit属性和值
  7. 调用UIKit的UI更新方法

可以看出来,其实WatchKit这边主要的工作就是抽象了一层Interface Object而不让开发者直接更新UIView。在watchOS 1时代这是一个非常好的设计,因为Extension进程在iPhone中,而App进程在Apple Watch上。但是到了watchOS 2以后,依然保留了这一套设计方案,实际上开发者能自定义的UI很有限。

WatchKit与Long-Look Notification

watchOS除了本身的App功能外,还有一些其他特性,比如这里提到的Long-Look Notification。这是在Apple Watch收到推送通知时候展示的页面,它实际上类似于iOS上的Notification Extension,可以进行自定义的UI。

苹果这里面对Notification提供了3种类型,根据能不能动态更新UI/能不能响应用户点击可以分为:

  • Static Notification(固定UI,点击后关闭)
  • Dynamic Notification(可以更新UI,点击后关闭)
  • Dynamic Interactive Notification(可以更新UI,可以响应交互,不默认关闭)

和普通的WatchKit UI一样,Notification依然使用Storyboard构建。并且有单独的Storyboard Entry Point。在代码里面通过WKUserNotificationInterfaceController的方法didReceive(_:),来处理接收到通知后的UI刷新,存储同步等等逻辑。

如图所示,整体的生命周期比较简单,可以参考苹果的文档即可:Customizing Your Long-Look Interface

Long-Look Notification原理

按照之前说的,WatchOS的Native App中,使用了SPApplicationDelegate作为它的AppDelegate,也直接实现了UNUserNotificationCenterDelegate相关方法。

当有推送通知出现时,如果watchOS App正处于前台,会触发一系列UserNotification的通知。类似于UIKit的逻辑,就不再赘述。

如果watchOS App未启动,那么会被后台启动(且不触发UserNotification的通知),对应Storyboard中的WKUserNotificationInterfaceController实例会被初始化。加载完成UI后,会调用willActivate()方法并自动弹起。

watchkit4

其实可以看出来,WatchKit主要做的事情,是在于watchOS App未启动时,需要对用户提供的WKUserNotificationInterfaceController,桥接对应的UserNotification接口和生命周期。

  1. 当SPApplicationDelegate的userNotificationCenter:willPresentNotification:withCompletionHandler:被调用,它会向客户端发送消息,触发WKUserNotificationInterfaceController的didReceive(_:)方法
  2. 当用户点击了Notification上面的按钮时,SPApplicationDelegate的userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:被调用,如果App不支持dynamic interactive notification,它会直接关闭通知,并唤起watchOS App到前台
  3. 如果支持dynamic interactive notification(watchOS 5/iOS 12),那么用户点击的Button/Slider之类,会调用WKUserNotificationInterfaceController上绑定的Target-Action,开发者需要手动在交互完毕后调用performNotificationDefaultActionperformDismissAction关闭通知(系统不再自动关闭通知),另外,系统给通知的最下方提供了一个默认的Dismiss按钮,点击后会强制关闭。

个人见解:之所以watchOS非要封装一层,主要原因是watchOS 1时代,不支持自定义通知;在watchOS 2时代,UserNotification这个框架还不存在,UIKit和AppKit都各自有一套接收Notification的实现,而WatchKit也照猫画虎搞了一套(当时就用的UILocalNotification)。UserNotification这个跨平台的通知库,是伴随着watchOS 3才出现的,但是已经晚了,因此WatchKit继续在已有的这个WKUserNotificationInterfaceController上新增功能。

其实可以看到,WKUserNotificationInterfaceController实际上提供的接口,基本完全等价于UserNotifications + UserNotificationsUI,方法名类似,有兴趣的话自行参考官方文档对比一下watchOS Custom Notification TutorialiOS Custom Notification Tutorial

WatchKit和SwiftUI

在WWDC 2019上,苹果发布了新的全平台UI框架,SwiftUI。SwiftUI是一个声明式的UI框架,大量使用了Swift语法特性和API接口设计,提倡Single Source of Truth而不是UIKit一直以来的View State Mutation。

为什么专门要讲SwiftUI,因为实际上,SwiftUI才是Apple Watch上真正的完整UI框架,而WatchKit由于设计上的问题,无法实现Owning Every Pixel这一点,在我心中它的定位更类似于TVML的级别。

swiftui

关于SwiftUI在watchOS上的快速上手,没有什么比Apple官方文档要直观的了,有兴趣参考:SwiftUI Tutorials - Creating a watchOS App

这里不会专门介绍SwiftUI的基础知识,后续我可能也会写一篇SwiftUI原理性介绍的文章。但是这篇文章,主要侧重一些SwiftUI在watchOS的独有特性和注意点,以及一些自己发现的坑。

SwiftUI与WatchKit桥接

SwiftUI,允许桥接目前已有的WatchKit的Interface Object,就如在iOS上允许桥接UIKit一样。但是它能做的事情和概念其实完全不一样。

在iOS上,你能通过代码/Storyboard来构建你自己的UIView子类,并且你能构造自己的ViewController管理生命周期事件。这些都能通过SwiftUI的UIViewRepresentable来桥接而来。与此同时,你还可以在你的UIKit代码中,来引入SwiftUI的View。你可以使用UIHostingController当作Child VC,甚至是对应的UIView(UIHostingController.view是一个私有类_UIHostingView,继承自UIView),是一种双向的桥接。

但是,正如之前提到,WatchKit设计是严重Storyboard Based,你不允许继承Interface Object。你不能使用SwiftUI来引入Storyboard自己构建好的Interface Object/Controller层级。不过相反的是,你可以使用WKHostingController,在Storyboard中去present或者push一个新的SwiftUI页面,实际是一种单向的桥接。

SwiftUI提供的WKInterfaceObjectRepresentable,实际上它只允许你去绑定一些已有的系统UI到SwiftUI中(因为SwiftUI目前还不支持这些控件,比如InlineMovie,MapKit,不排除以后有原生实现)。这些对应的WatchKit Interface Object,在watchOS 6上面都加入了对应的init初始化方法,允许你代码中动态创建,这里是全部的列表:

  • WKInterfaceActivityRing
  • WKInterfaceHMCamera
  • WKInterfaceInlineMovie
  • WKInterfaceMap
  • WKInterfaceMovie
  • WKInterfaceSCNScene
  • WKInterfaceSKScene

桥接了Interface Object的View可以像普通的SwiftUI View一样使用,常见的SwiftUI的modifier(比如.frame, .background)也可以正常work。但是有一些系统UI有着自己提供的最小布局(比如MapKit),超过这个限制会导致渲染异常,建议采取scaleTransform处理。另外,请不要同时调用Interface Object的setWidth等概念等价的布局方法,这会导致更多的问题。

桥接原理

上文提到的所有可动态创建的Interface Object,根据我们之前的探索,它现在是没有绑定任何viewControllerID的,具体SwiftUI是怎么做的呢?

答案是,SwiftUI会对这些init创建的interfaceObject,手动通过UUID构造一个单独的新字符串,然后用这个UUID,创建一个新ViewController到WatchKit App中,插入到对应HostingController的视图栈里面。

它的初始化UI状态,通过一个单独的属性拿到(由每个子类实现,比如MapView,默认的经纬度是0,0)。整体伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation WKInterfaceMap
- (instancetype)init {
NSString *UUID = [NSUUID UUID].UUIDString;
NSString *property = [NSString stringWithFormat:@"%@_%@", [self class], UUID];
return [self _initForDynamicCreationWithInterfaceProperty:property];
}

- (NSDictionary *)interfaceDescriptionForDynamicCreation {
return @{
@"type" : @"map",
@"property" : self.interfaceProperty,
};
}
@end

另外,这种使用init注册的WKInterfaceObject,会保留一个对应UIView的weak引用,可以在运行时通过私有的_interfaceView拿到。SwiftUI内部在布局的时候也用到了这个Native UIView来实现。

watchkit-swiftui2

SwiftUI与watchOS Native App

通过从Native watchOS App的布局分析上来看,SwiftUI参考iOS上的方案,依旧是用了一个单独的UIHostingView来插入到Native App的视图层级中,也有对应的UIHostingController。

但是不同于iOS的是,SwiftUI会对每一个Push/Present出来的新View(与是否用了上面提到的WKInterfaceObjectRepresentable无关,这样设计的原因见下),额外套了一个叫做SPHostingViewController的类,它继承自上文提到的SPViewController。

每个UIHostingController套在了SPHostingViewController的Child VC中,对应View通过约束定成一样的frame,可以看作是一个容器的关系。

watchkit3

当你的SwiftUI View,含有至少一个WatchKit Interface Object之后,这个SPHostingViewController就起到了很大作用。它需要调度和处理上文提到的WatchKit消息。SPHostingViewController内部存储了所有interface的property,Native UIView列表,通过遍历来进行分发,走普通的WatchKit流程。它相当于起到一个转发代理的作用,让这些WatchKit的Interface Object实现不需要修改代码能正常使用。

watchkit-swiftui1

SwiftUI与Long-Look Notification

到这里其实事情还算简单,但是还有一种更为复杂的情形。SwiftUI支持创建自定义的watchOS Long-Look UI。它提供了一个对应的WKUserNotificationHostingController(继承自WKUserNotificationInterfaceController),就像WatchOS App一样。

但是,试想一下:既然SwiftUI支持桥接系统Interface Object,如果我在这里的HostingView中,再放一个WatchKit Interface Object,会怎么样呢?答案依然是支持。

SPHostingViewController这个类兼容了这种极端Case,它转发所有收到的Remote/Local Notification,承担了原本WatchKit的WKUserNotificationInterfaceController的一部分责任(因为继承链的关系,它不是WKUserNotificationInterfaceController子类,但是实现了类似的功能)。因此实际上,SPHostingViewController内部除了上面提到的property, Native UIView列表外,还存储了对应Notification Action的列表,用于转发用户点击在通知上的动作来刷新UI。

Independent watchOS App

在历史上,所有的watchOS App,都必须Bundle在一个iOS App中,换句话说,就算你的watchOS App是一个简单的计算器,不需要任何iPhone的联动和同步功能,你也必须创建一个能够在iOS上的App Store审核通过的App。因此制作一个watchOS App的前提变得更复杂,它需要一个iOS App。而且以这里的计算器来说,你不可以直接套一个简单空壳的iOS App,引导用户只使用Apple Watch,因为iOS App Store的审核将不会通过。这也是造成watchOS App匮乏的一个问题。

从watchOS 6之后,由于上述的一系列开发工具上和模式上的改动,苹果听取了开发者的意见,能够允许你创造一个独立的watchOS App,它不再不需要任何iOS App,直接从Apple Watch上安装,下载,运行。watchOS App也不再必须和iOS App有所关联。

开发配置

将一个已有的非独立watchOS App转变为独立App比较简单,你只需要在Xcode中选中的watchOS Extension Target,勾选Supports Running Without iOS App Installation即可。

注意,独立watchOS App目前并不意味着你不能使用WatchConnectivity来同步iPhone的数据。你依然可以在你的Extension Target中声明你对应的iOS App的Bundle ID。

注意,如果用户没有下载这个watchOS App对应的iOS App,那么WatchConnectivity的WCSession.companionAppInstalled的方法会直接返回NO,就算强制调用sendMessage:,也会返回不可用的Error,在代码里面需要对此提前判断。

App Slicing

独立watchOS App会利用App Slicing,而非独立App不会。Apple Watch从Series 4开始采取了64位的CPU,而与此同时,由于用户的iPhone的CPU架构和Apple Watch的CPU架构是无关的(你可以在iPhone 11上配对一个Apple Watch Series 3,对吧),而watchOS App又是捆绑在ipa中的,这就导致你的ipa包中,始终会含有两份watchOS的二进制(armv7k arm64_32),用户下载完成后,在同步手表时只会用到一份,并且原始ipa中依旧会保留这份二进制。这是一种带宽和存储浪费。

对于独立watchOS App,可以直接从watchOS App Store下载,那么将只下载Slicing之后的部分,节省近一半的带宽/存储。值得注意的是,就算是独立watchOS App,依然可以从iPhone手机上操作,来直接安装到Apple Watch中,因为在Apple Watch小屏幕上的App Store搜索文本和语音输入的体验并不是很好。

总结

通过上面完整的原理分析,可以看到,WatchKit这一个UI框架,通过一种客户端/服务端的方案,由于抽象了连接,即使watchOS 1到watchOS 2产生了如此大的架构变化,对上层的API基本保持了相对不变。这一点对于库开发者值得参考,通过良好的架构设计能够平滑迁移。

不过实际从各个社交渠道的反馈,开发者对于WatchKit的态度并不是那么乐观,由于隐藏了所有真正能够操作屏幕像素的方案(无法使用Metal这种底层接口,也没有UIKit这种上层接口),导致WatchOS App的生态环境实际上并不是那么理想,很多App都是非常简单和玩具级别的项目。虽然这是可以归因于Apple Watch本身硬件性能的限制,但是和WatchKit提供的接口也脱离不了关系。

如果让我来重新设计WatchKit,可能在watchOS 2时代,就会彻底Deprecate目前的WatchKit,而是取而代之采取公开精简的UIKit实现来让开发者最大化利用硬件(类似于目前的UIKit在tvOS上的现状),同时,提供一个新的WatchUIKit来提供所有专为Apple Watch设计的UI和功能,比如Digital Crown,比如Activity Ring。

watchkit-twitte

SwiftUI为watchOS App提供了一个新的出路,它可以说是真正的能够发挥开发者能力来实现精致的App,而不再受限于系统提供的基本控件。而WatchKit,也已经完成了它的使命。相信之后的SwiftUI Native App将会为watchOS创造一片新的生态,Apple Watch也能真正摆脱“iPhone外设”这一个尴尬的局面。

参考资料

发现iOS SDK的Bug - Hopper使用教程向

作者 DreamPiggy
2019年6月14日 05:39

Hopper简介

Hopper,全称Hopper Disassembler,是一个macOS和Linux平台上的反汇编IDE。提供了诸如伪代码,子程序,脚本,Debugger,Hex编辑等等一些列工具。相比于其他知名的反汇编工具如IDA,最大的好处是对平台特性,也就是Objective-C的反汇编有优化,提供非常贴近原始代码的伪代码(IDA目前则会是保留诸如objc_msgSend的伪代码),并且新版本也对Swift提供了一定的反汇编符号优化,因此作为探究iOS平台上的SDK实现,可以说是一利器。

Hopper安装

Hopper本身目前是收费的软件,提供了免费的使用(30分钟)。官方下载地址为:https://www.hopperapp.com

Mac版本后解压,拖到Application下即可使用。

对于个人使用,价格不菲,有两种方案,个人比较推荐第一种

  • Per User:收费为¥700,允许同一时间唯一激活,不绑定机器硬件
  • Per Computer,收费¥900,和一台电脑的机器硬件绑定

对于只是尝鲜或者轻度使用,其实使用免费版即可。网上现在也有针对旧版本的Cracked版本,不过存在一些问题和崩溃。如果是在需要,可尝试链接

Hopper使用

Hopper提供了一个教程,可以参考官方简易教程

针对我们的场景:分析iOS的SDK内实现或者问题,我这里提供了一个Step By Step的过程,教你如何查找问题。

获取需要反汇编的二进制文件

首先,我们需要获取一份iOS SDK的二进制Mach-O文件。最简单的方式,是通过Xcode提供的iPhone模拟器去获取它。在获取之前,我们先了解一下iOS SDK对应的二进制文件路径。

  • Xcode自带模拟器,对应系统根路径:

Xcode 11:

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot

Xcode 10:

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/

  • 已下载的历史版本固件的模拟器,对应系统根路径,自己根据版本版本修改中间的数字:

/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS\ 11.4.simruntime/Contents/Resources/RuntimeRoot/

  • 真机的系统根路径,不用说了吧:

/

iOS 系统提的库和二进制,可以简单分以下几类,按照需要选择对应的相对根路径:

  • 公开Framework: /System/Library/Framework
  • 私有Framework:/System/Library/PrivateFrameworks
  • 系统App:/Applications
  • UNIX动态库: /usr/lib

这里我们以Xcode 10自带的iOS 12 SDK,UIKitCore为例(注意,UIKit从iOS 12开始,为了支持部署到macOS,将代码基本全盘移动到了私有Framework的UIKitCore.framework中,UIKit.framework只是一个外层的壳),我们就能直接去访问这个路径,获取它的Mach-O二进制:

/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore

一般来说,iPhone模拟器提供的二进制Mach-O即可够用,虽然它实际上是x86_64架构的编译产物,但是基本上的逻辑和真机上是一致的。如果涉及到需要只能在真机可用的库,如AVFoundation的摄像机,建议可以从真机中提取(也可以从iOS的IPSW固件中提取),见下文。

真机获取系统库的二进制文件

获取dyld shared cache

在真机上,为了加快动态库的加载,并减少iOS 占用磁盘的体积,dyld采取了一个缓存,将多个Mach-O文件合并到一起,由系统启动后就预热。因此,实际上系统库(公开和私有)的路径上,只有Framework和其中的资源文件,却没有对应的Mach-O二进制。我们需要首先获取到这个cache,然后解压出来对应的二进制。参考dyld_shared_cache

对应dyld shared cache路径(以arm64机器为例):/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64

当然,除了使用已经越狱的真机,我们还可以通过IPSW,即iOS的固件包,来直接提取对应的dyld shared cache,并解压得到对应的Mach-O文件。

IPSW可以从这个网页上下载,选择你的设备以及iOS版本号,就可以下载对应的IPSW文件。

将下载的IPSW解压(建议使用zip命令行,或者BetterZip之类的解压软件,Mac自带的解压似乎会报错),可以找到一个最大容量的DMG文件,双击即可加载

加载后就是完整的iOS系统根路径了,从对应路径下找到dyld shared cache。

解压dyld shared cache

为了解压dyld shared cache,市面上一些工具其实早已过期,要么不支持arm64,要么存在Bug。但实际上,Apple自己开源的dyld源代码,就已经包含了这样一个命令行工具,叫做dsc_extractor,我们这里直接用来源码来编译一份来使用即可。

进入opensource.apple.com选择最新的macOS的版本,我这里例子使用的是我写这篇文章最新公开的 macOS 10.14.1

然后下载两份代码,一份是dyld,一份是CommonCrypto

为了编译,需要一点小技巧,但是对于iOS开发者我觉得挺简单

  1. 用Xcode,打开dyld代码中的dyld.xcodeproj
  2. 修改Build Settings中,把对应的Base SDK,从macosx.internal改成公开的macOS
  3. 进入dsc_extractor.cpp,看到最后有一个test program,把上面的#if 0改成#if 1
  4. 我在编译新版本时发现依赖了一个叫做CommonDigestSPI.h的私有头文件,这个在下载的CommonCrypto工程中,拖进来改一下引用方式即可
  5. 选择dsc_extractor,Archive得到一个产物,叫做dsc_extractor.bundle,然而他实际就是一个Mach-O二进制,直接删掉后缀,chmod+x,即可使用

如果上面的编译比较麻烦,可以直接下载我这里编译好的一份二进制,然后放到你的PATH路径下:dsc_extractor

然后我们可以使用dsc_extractor来解压我们提取到的dyld shared cache,很简单的命令

1
dsc_extractor ./dyld_shared_cache_arm64e ./output

会得到所有dyld shared cache中的二进制Mach-O文件,按照路径排列,然后我们就可以用自己想反编译的库,如UIKitCore,来使用Hopper了。

载入Hopper

现在我们已经有了一个UIKitCore的Mach-O文件了,我们打开Hopper来载入它。我们可以使用Command+Shift+O来选择一个Mach-O文件,也可以将文件拖动到Hopper界面上来打开。

载入Mach-O文件后,Hopper会弹出框来选择具体分析的内容,大部分情况直接确认即可。如果是分析其他类型的文件,可能有特例如下:

  • 分析一个.a或者.dylib,并且该二进制由多个.a或者.dylib合成,这时候会提示你选择具体的某个编译产物
  • 分析一个FAT Binary,这时候会提示你选择具体的某一个架构的文件

载入开始后,一般需要等待一段时间来分析(下方会有进度条),等待分析完成后,你可以将当前分析的结果,保存成一个.hop结尾的文件,未来就不再需要分析了,非常有用(注:免费版不可用)。

符号分析

左侧有一个符号框,从左到右依次表示:

  • Labels: C/C++Objective-C的符号,包括类名,方法名,全局变量等
  • Proc:子程序,对应C/C++的函数,Objective-C的方法,Block代码段等
  • Str:常量段,包括了所有C/C++Objective-C字面量,即代码中直接用@"", ""写的内容

每项内容都支持搜索,一般来说取决于我们要解决的问题,有大概几个场景

  1. 分析特定方法的实现:使用Proc搜索
  2. 运行时抛出的异常或者Log:使用Str搜索关键字
  3. 得知一个类的所有方法:可以使用Label,但更好的方式是通过Class-dump获取头文件(见下)

Class-dump与私有头文件

Class-dump是一个能够解析Mach-O文件,对应的Objective-C符号,以生成一个完整的头文件的工具。得益于Objective-C运行时和符号的特点,可以方便的还原回基本接近原始的类声明代码。具体使用也很简单,参见项目的Readme,编译得到二进制,放到PATH中,然后执行:

1
class-dump UIKitCore.framework -r -o output -H

对于重头戏,关于iOS SDK的所有头文件,早有专人建立了一个在线网站去分析,点击跳转:iOS Runtime Headers

在这个网页上,可以支持Framework/类/方法级别的搜索,支持点击头文件跳转链接,非常的方便,一般的分析iOS SDK都可以采取这个网页的结果来辅助分析。

伪代码分析

当我们了解到需要分析的符号方法后,下一步一般就会进行伪代码分析。在Hopper中,点击到一个子程序入口,然后点击上方的这个像是if (b)代码的图标,即可打开伪代码分析框

对于简单的代码,我们基本上能够还原回100%可读的Objective-C代码,由于ARC时便一起,我们可以看到对应的Retain和Realse调用

分析调用关系

我们可以通过对应的子程序页面,右键选择”References To Selector”,来查看所有对这个Selector的调用。(由于Objective-C运行时的特点,只能是Selector级别的调用,如果有不同类的同名Selector,可以在弹出的窗口中搜索或者依次检查)

常见的分析姿势

Block

Objective-C会使用到Block,而Block由于其实现原理,会生成对应的C方法,Hopper目前原生解析的Block语法并不是很直观,这里提供一个简单的说明。

其实Hopper反编译出来就是Block实现的原理,如果对于Block实现原理不清楚,建议可以先看一遍《这个教程》

简单Block

1
2
3
dispatch_async(dispatch_get_main_queue(), ^{
printf("%d", 1);
});

Hopper原生反编译如下,实际Block代码会单独在另一个C方法中,在block implemented at:提示对应的方法中

1
2
3
4
5
6
dispatch_async([objc_retainAutoreleaseReturnValue(*__dispatch_main_q) retain], ^ {/* block implemented at ___29-[ViewController viewDidLoad]_block_invoke */ } });

void ___29-[ViewController viewDidLoad]_block_invoke(void * _block) {
printf("%d", 0x1);
return;
}

捕获变量

如果Block捕获了变量,那么根据Block的实现原理,可以知道这些变量在Block中可见的变量都是被值宝贝,对于NSObject就是指针

如果使用__block修饰,那么会保留原始的变量的指针,对于NSObject就是对象指针的指针,我们可以通过这个简单识别。

比如对于这样代码:

1
2
3
4
5
6
NSObject *obj = [NSObject new];
__block NSObject *obj2 = [NSObject new];
[self testBlock:^(int value){
NSLog(@"%@", obj);
obj2 = nil;
}];

实际反编译出来的结果长这样:

1
2
3
4
5
int ___29-[ViewController viewDidLoad]_block_invoke(int arg0, int arg1) {
NSLog(@"%@", *(arg0 + 0x20));
rax = objc_storeStrong(*(*(arg0 + 0x28) + 0x8) + 0x28, 0x0);
return rax;
}

对应的arg0就是第一个参数,而最后参数对应的是block_impl_0实现结构体,可以忽略。

CGRectMake等inline的C方法

一些带有inline数值计算的方法,会被苹果的clang在编译时优化,实际上并不是你看到的头文件的样子,这种就需要我们枚举出来,人肉还原回他的实现,举个例子:

这样的代码:

1
2
CGRect rect = CGRectMake(0, 1, 2, 3);
NSLog(@"%@", NSStringFromCGRect(rect));

反编译结果:

1
2
3
4
5
intrinsic_movsd(xmm1, *double_value_1);
intrinsic_movsd(xmm2, *double_value_2);
intrinsic_movsd(xmm3, *double_value_3);
_CGRectMake(&var_30, _cmd, rdx, rcx);
NSLog(@"%@", [NSStringFromCGRect(*(&var_30 + 0x10), *(&var_30 + 0x18)) retain]);

可以看到有mov之类的汇编命令调用,其实这就是为了压栈其实大部分场景我们只要熟悉简单的mov add sub mul几个基本的汇编命令的意义即可。

Swift

Swift作为Apple一致力推的下一代官方编程语言,随着iOS 13的发布,现在已经可以作为第一优先的SDK支持语言了,iOS 13上出现了4个Swift Only的库,因此对于Swift相关的反编译需求,也会慢慢出现。然而,不同于动态性强的Objective-C代码,Swift天生的静态强类型语言特性,造成了相当高的反编译难度(堪比C++开O2优化),在这里基本不细讲,只是大概说一下目前的状况。

Hopper从v4开始支持了对Swift符号的符号化,我们不再需要使用swift来反解决mangled的符号名。

由于Swift支持完整的命名空间,查询符号需要带上完整的符号

同时,Swift由于clang的优化,会讲很多编译器检查到的频繁的代码调用,自动转换为一个以sub开头的函数,以减少二进制大小。

对于Swift非@objcdynamic的属性和方法,会类似于C++的虚函数表,实际上的调用都是编译器展开的地址偏移,而不像Objective-C那样有符号可查。这种时候我们需要就是类似C++反编译那样,通过分析Swift class或者struct的属性,来对照偏移量得知调用。

对于Swift的会触发运行态的一些语法,需要你对Swift语言实现有了解,比如Protocol Extension Where子句,会生成Protocol Witness,我们可以在Hooper中搜索到它

可以看到,目前的Hopper对Swift有相应的支持,但受限于Swift的语言性质很难直观阅读,必要时候还是需要一些汇编,以及传统C++的反编译分析模式去对待它

总结

这篇教程基本上是从我个人的使用经验来介绍,以工具和流程为主,主要是为了给目标iOS平台,且不是专攻二进制安全的人来阅读。

其实对大部分iOS平台开发者,最主要的目的,其实是为在发现一些iOS SDK表现奇怪的行为,或者Crash时,能够有一定的分析和判断能力,去尝试定位原因,绕过问题,并最终能够有底气,去向Apple提交Bug Report。

反编译本身就是二进制安全中的灰色地带,而且还有类似二进制加固等攻防模式,并不是万能方式去了解一个程序运行的方式。还需要配合自己的编写代码经验,才能更好地解决问题

参考

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

作者 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这套流程它所解决的问题,以及优缺点。无论对图片库作者,还是图片库进阶使用者,都解释了相关的疑问。希望对图片编解码方向有兴趣的同学可以多多学习交流。

CocoaPods的资源管理和Asset Catalog优化

作者 DreamPiggy
2018年11月27日 01:54

这篇文章介绍了关于CocoaPods的资源管理行为,对于Pod库作者是必须了解的知识。同时介绍了CocoaPods使用Asset Catalog的注意事项。如果已经了解某方面知识,可以大致略过直接看结论。

Asset Catalog和App Thinning

Asset Catalog,是Xcode提供的一项图片资源管理方式。每个Asset表示一个图片资源,但是可以对应一个或者多个实际PNG图,比如可以提供@1x, @2x, @3x多张尺寸的图以适配;在macOS上,还可以通过指定日间和夜间不同Appearances的两套图片。

这种资源,在编译时会被压缩,然后在App运行时,可以通过API动态根据设备scale factor(Mac上日夜间设置)来选择对应的真实的图片渲染。

App Thinning,是苹果平台(iOS/tvOS/watchOS)上的一个用于优化App包下载资源大小的方案。在App包提交上传到App Store后,苹果后台服务器,会对不同的设备,根据设备的scale factor,重新把App包进行精简,这样不同设备从App Store下载需要的容量不同,3x设备不需要同时下载1x和2x的图。

但是,这套机制直接基于Asset Catalog,换言之,只有在Asset Catalog中引入的图片,才可以利用这套App Thinning。直接拷贝到App Bundle中的散落图片,所有设备还是都会全部下载。因此如何尽量提升Asset Catalog利用率,是一个很大的包大小优化点。

CocoaPods的资源管理

CocoaPods是一个构建工具,它完全基于Pods的spec文件规则,在Podfile引入后,生成对应构建Xcode Target。也就是它是一个声明式构建工具(区别于Makefile这种过程式的构建工具)。对于资源的管理,目前有两个方式进行声明并引入,即resourcesresource_bundles,参考podspec syntax

虽然Podspec中包含所有待构建库的声明,但于CocoaPods也会根据Podfile的配置,动态调整最终的Xcode工程的配置,根据是否开启use_framework!,以下的资源声明最终的行为有所不同,这里分开介绍。

不使用use_framework!

当不使用use_framework!时,最终对Pod库,会创建单独的静态链接库.a的Target,然后CocoaPods会对主工程App Target增加自己写的脚本来帮助我们拷贝Pod的资源。

  • resources字段

对应参数是一个数组,里面可以使用类似A/*.png通配符匹配。所有匹配到的资源,如图片。

pod install完成后,CocoaPods会插入一个生成的脚本[CP] Copy Bundle Resource(注意,这并非Xcode本身构建过程),拷贝到编译完成后的App Main Bundle的根路径下。

也就是说,如果匹配到了一个A/1.pngA/2.plist,这个1.png2.plist,最终会出现在ipa包的展开根路径中。

1
2
3
4
5
| Info.plist
| 1.png
| 2.plist
| News
| xxx

优点:

  1. 最简单暴力,而且由于固定了资源的路径在根路径上,如果先前在主工程目录中使用的代码,不需要更改一行即可继续使用(原因是主工程的你拖一个图片文件夹,Xcode的构建过程默认就是把资源放到App Main Bundle的根路径上的)。

缺点:

  1. 严重的命名冲突问题,由于通配符会拷贝所有文件到根路径,因此如果出现如下 A/1.png, B/1.png两个文件同时匹配(B是另一个库的文件夹),将会出现冲突,CocoaPods采取的方式是暴力合并,会有一个被替换掉。因此,这要求所有资源文件命名本身,加入特定的前缀以避免冲突。类似的不止是图片,所有资源如bundle, js, css都可能存在这个问题,难以排查。而且由于这种拷贝到根路径的机制,这个问题不可从根源避免。
  2. 无法享用任何Xcode的优化,Xcode对于所有内建的Copy Bundle Resource中添加的PNG/JPEG图片,会进行一次压缩减少大小(注意,这和App Thinning不一样)。而CocoaPods这种使用自己的Shell脚本暴力拷贝,源文件和Bundle的文件是完全一样的。
  • resource_bundles字段

对应参数是一个字典,里面的Key表示你所希望的一组资源的资源名,常见值是库名+Resource,Value是一个数组,里面和resources一样允许通配符匹配资源。

pod install完成后,CocoaPods会对所有的Pods中声明了resource_bundles资源,以Key为名称建立一个单独的Bundle Target,然后根据Value匹配的值,把这些图片资源全部加到这个Target的Xcode内建Copy Bundle Resource过程中。然后通过一个Shell脚本添加到App Main Bundle中。假设我们这样写 'DemoLibResource' => [A/1.png, 'A/2.plist']匹配到了一个1.png2.plist,会是以这个Target建立一个Bundle父文件夹。然后这些Bundle父文件夹,拷贝到App Main Bundle根路径下,最后得到这样一个ipa结构。

1
2
3
4
5
6
| Info.plist
| DemoLibResource.bundle
|- 1.png
|- 2.plist
| News
| xxx

优点:

  1. 解决了命名冲突问题,由于使用了一级的Key值,作为一个单独的父文件夹隔离,不同的Pods库不太可能出现命名冲突(遵守库名+Resource,则库之间不会不出现同样的Key值)。
  2. 能利用Xcode本身的优化过程,由于单独构建了一个Target,使用Xcode原生的Copy Bundle Resource过程,PNG图片等会自动享受压缩

缺点:

  1. 由于最终资源产物增加了一级Resource Key的父文件夹,如果有先前依赖Main Bundle路径位置的加载代码,需要进行更新。典型的用法如NSBundle.mainBundle pathForResource:ofType:取本地Bundle中一个文件路径,这时候需要更新为[NSBundle bundleWithPath:] pathForResource:ofType:的代码调用。对于UIImage imageNamed:方法,它也支持Bundle,看情况需要更新。

举例子说明,原来使用方式为:

1
2
3
4
// 直接访问路径
NSString *plistPath = [NSBundle.mainBundle pathForResource:@"test" ofType:@"plist"];
// 获取Bundle中的UIImage,只是示例,推荐使用Asset Catalog替代这种裸的图片引用
UIImage *image = [UIImage imageNamed:@"1"];

现在需要更新为:

1
2
3
4
5
6
7
8
9
10
// 路径变化,这步骤可以封装库级别的工具方法,或者宏,Static对象,都行
// 由于采取了Static Library而没有使用use_framework!,因此此时[NSBundle bundleForClass:]和mainBundle是相同的,原因是类其实在mainBundle的二进制中,而不是Framework中。但是为了代码统一,建议都使用bundleForClass:(后面讲)

// NSString *bundlePath = [NSBundle.mainBundle.resourcePath stringByAppendingPathComponent:@"DemoLibResource.bunbdle"]; // 虽然也能Work,为了统一代码(开启use_framework!)用下面的更好
NSString *bundlePath = [[NSBundle bundleForClass:DemoLib.class].resourcePath stringByAppendingPathComponent:@"DemoLibResource.bunbdle"];
NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];
// 直接访问路径
NSString *plistPath = [bundle pathForResource:@"test" ofType:@"plist"];
// 获取Bundle中的UIImage,只是示例,推荐使用Asset Catalog替代这种裸的图片引用
UIImage *image = [UIImage imageNamed:@"1" inBundle:bundle compatibleWithTraitCollection:nil];

总体来说,结合优缺点,大部分的组件库,对于通用资源的引用,应当避免使用resources,而转为使用resource_bundles声明。能够从源头上避免冲突。改动成本也不算大,封装个库内部的工具方法/宏替换下即可。

Tips:如果在使用resource_bundles的情况下,我还想避免Xcode的图片优化策略(如无损的图片等),这时候可以采取将图片放入一个自己建立的Bundle文件夹中,然后resource_bundles引入这个Bundle本身,注意路径需要再加一层。

使用use_framework!

当使用了use_framework!之后,CocoaPods会对每个Pod单独建立一个动态链接库的Target,每个Pod最后会直接以Framework集成到App中。而资源方面,由于Framework本身就能承载资源,所有的资源都会被拷贝到Framework文件夹中而不再使用单独的脚本处理。

  • resources

在使用resources声明时,同不使用use_framework!相比,改动的点在于这些Pod库资源的路径。此时,这些Pod库资源会被拷贝到Pod库自己的Fraemwork根路径下,而不在App Main Bundle的根路径下。

1
2
3
4
5
6
7
| Info.plist
| Frameworks
|- DemoLib.framework
|-- 1.png
|-- 2.plist
| News
| xxx

优点:

  1. 虽然在不使用use_framework!的情况下,这种声明会造成命名冲突。但是在使用use_framework!的情况下,由于资源本身被拷贝到Framework中,已经能最大程度减少冲突,因此这时候一般不需要考虑名称冲突问题

缺点:

  1. 在use_framework!的情况下,能够保证代码一行不改,但是使用use_framework!后就不行了。原因在于此时Bundle资源路径已经发生变化,到Framework自身的文件夹中而不是App Main Bundle中,需要进行更新。这个更新的路径和resource_bundles不同,不需要额外拼接一层Key值的名称。直接使用bundleForClass即可,比较简单
1
2
3
4
5
6
// 使用bundleforClass替代mainBundle即可
NSBundle *bundle = [NSBundle bundleForClass:DemoLib.class];
// 直接访问路径
NSString *plistPath = [bundle pathForResource:@"test" ofType:@"plist"];
// 获取Bundle中的UIImage,只是示例,推荐使用Asset Catalog替代这种裸的图片引用
UIImage *image = [UIImage imageNamed:@"1" inBundle:bundle compatibleWithTraitCollection:nil];
  • resource_bundles

在使用resource_bundles声明时,同不使用use_framework!相比,改动的点在于对应这些Key生成的Bundle的位置。此时,这些生成的Bundle父文件夹,会放入Pod库自己的Framework的根路径下。而每个Pod库Framework本身,在App Main Bundle的Frameworks文件夹下。

1
2
3
4
5
6
7
8
| Info.plist
| Frameworks
|- DemoLib.framework
|-- DemoLibResource.bundle
|--- 1.png
|--- 2.plist
| News
| xxx

优点:同上
缺点:同上。但有点区别,在于Bundle的路径变化。此时,NSBundle bundleForClass:不再等价于mainBundle了,因此对应代码更新示例里面,一定不能用mainBundle而要用bundleForClass替代。传入的Class是哪一个Pod库的Class,就会取到对应Pod库Framework里面的Bundle文件夹。

1
2
3
// 再抄一遍,害怕忘记了,此时不能用mainBundle的resourcePath去拼接
NSString *bundlePath = [[NSBundle bundleForClass:DemoLib.class].resourcePath stringByAppendingPathComponent:@"DemoLibResource.bunbdle"];
NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];

CocoaPods与Asset Catalog图片资源

前面花了大篇章说了关于CocoaPods处理通用的资源引用的方式,是为了业务库作者能有清晰认识到,在从主工程沉库代码后,需要怎么样更改来处理资源。

现在回到正题说一下Pod库中的Assets Catalog需要怎么样处理以利用App Thinning。Assets Catalog的好处都有啥已经说过了,因此我们需要尽量保证大部分情况下优先使用Assets Catalog而非将图片拷贝至App Bunlde中(虽然Xcode会压缩优化,但是这种方式无论如何都无法利用App Thinning)。

Assets Catalog本身的文件夹xcassets一定不会出现在最终的App包中,它在编译时会产生一个二进制产物Assets.car,而这个二进制目前只能由UIKit的方法,去读取产生一个UIImage内存对象,其他代码无法直接访问原始的图片文件路径和ImageData。同时,按照官方文档的说明,UIImage imageNamed:inBundle:compatibleWithTraitCollection: 实际上,会优先去查找指定Bundle(UIImage imageNamed:即为mainBundle)的路径下的Assets.car文件并展开,然后找不到再去寻找Bundle路径下同名的图片文件。所以,从API使用上来看,一个图片具体是在散落在Bundle根路径下,还是在被编译到Bundle路径下的Assets.car中,代码应该是一致的。

值得说明的是,CocoaPods不会自动根据你在Spec中的声明,创建Asset Catalog,你必须通过Xcode手动创建,添加,然后在Spec中引入它。类似这样。

1
spec.resources = ['A/DemoLib.xcassets']

有了这些知识,我们就结合前面的CocoaPods资源处理策略,以及UIKit的行为,再来回顾上述这些声明的行为,以及我们应该怎么样从代码上去使用。

下面的例子统一都以上面这个示例举例子,假设这个Asset Catalog中含有1.png, 1@2x.png, 1@3x.png.

不使用use_framework!

  • resources

不同于普通资源那种暴力拷贝的方式,CocoaPods这下没法暴力拷贝这个编译产物的Assets.car到根路径了,因为它会直接覆盖掉App本身的编译产物。所以,CocoaPods采取的方案,是合并Asset Catalog。首先会编译得到工程App的Assets.car,然后通过便利所有Pod的resources引入的xcassets,使用atool工具进行多个Asset Catalog合并,最后输出到App Main Bundle根路径下的Asset.car里。

1
2
3
4
| Info.plist
| Assets.car (编译进去了1.png)
| News
| xxx

优点:

  1. 继承了普通资源的处理方式,由于采取了Asset Catalog合并,原来主工程代码不需要更改一行可继续使用。相当于库的Assets Catalog资源直接添加到主工程Assets Catalog中

缺点:

  1. 一贯的命名冲突问题,由于Asset Catalog还会和主工程以及其他Pod库进行合并,一旦出现了重名的资源,最终编译产物Assets.car会根据合并顺序替换掉之前的。因此还是得每个Asset Catalog中资源名也得添加前缀
  • resource_bundles

类似对于普通资源的处理,如果使用resource_bundles,对于每个Key生成的Bundle父文件夹,会把生成的Assets.car拷贝到这个Bundle父文件夹中。如果当前Pod库引用了多个xcasset文件,对引用的这几个做合并。

1
2
3
4
5
| Info.plist
| DemoLibResource.bundle
|- Assets.car(含有1.png)
| News
| xxx

优点:同普通资源
缺点:同普通资源。代码使用方面,由于之前提到的UIImage API,对于同路径下的Assets.car编译产物,和散落的普通图片名,代码使用方式是一致的,因此这里也没有额外的变化。

使用use_framework!

  • resources

在使用use_framework!的情况下,对应编译产物Assets.car会被拷贝到Pod库Framework的根路径下,其他的行为类似。

1
2
3
4
5
6
| Info.plist
| Frameworks
|- DemoLib.framework
|-- Assets.car
| News
| xxx

优点:同普通资源
缺点:同普通资源,代码使用方面也同普通资源的情形

  • resource_bundles

在使用use_framework!的情况下,也会创建Key为名称的父Bundle文件夹,拷贝到Pod库Framework根路径下,然后对应编译产物Assets.car放到了这个自动生成Bundle文件夹下,其他行为类似。

1
2
3
4
5
6
7
| Info.plist
| Frameworks
|- DemoLib.framework
|-- DemoLibResource.bundle
|--- Assets.car
| News
| xxx

优点:同普通资源
缺点:同普通资源,代码使用方面也同普通资源的情形

最佳实践和总结

可以看出,CocoaPods,对待普通资源和Asset Catalog都支持,唯一的行为不同的点,在于普通资源如果发生重名,不会进行合并而是直接替换。但是Asset Catalog如果出现多个引用,会进行合并。

虽然表面看起来,我们分析了总共会有 使用resource还是resource_bundle * 是否使用use_framework! * 普通资源还是Asset Catalog,8种情形。但是实际上从世纪代码使用上,由于Asset Catalog和普通图片API可以统一,同时动态/静态的Bundle位置也可以统一处理,实际上只有两种Case:

使用resource_bundle:推荐,避免命名冲突

推荐做法,对于每个需要引入资源的库,以库名+Resource为Key(不强制,推荐),然后引入资源,Asset Catalog。代码必须更新,以使用对应的Bundle名来获取。参考上面的代码:

1
2
NSString *bundlePath = [[NSBundle bundleForClass:DemoLib.class].resourcePath stringByAppendingPathComponent:@"DemoLibResource.bunbdle"];
NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];

使用resource:不推荐,因为会导致命名冲突。

除非你能保证分所有资源都已加入前缀,而且目前代码不好更改的情况下,可以保持继续使用主工程的直接访问mainBundle的代码;其他的任何情况,使用NSBundle bundleForClass:来获取Bundle,然后加载路径,或者使用UIImage imageNamed:inBundle:compatibleWithTraitCollection加载图片。

1
NSBundle *bundle = [NSBundle bundleForClass:DemoLib.class];

对于Pod库开发者,需要尽量使用resource_bundle来处理资源,同时,Pod自身代码可能需要更新,以使用正确的方式加载图片或者其他Bundle资源。并且,对于图片资源,如果无特殊用处,建议都建立Asset Catalog以利用App Thinning。

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

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

Objective-C代码库的实现隐藏

作者 DreamPiggy
2017年6月5日 01:46

虽然Swift现在是开发iOS推荐入手的最佳语言,但是对于代码库而言,最大的一个问题是Swift ABI仍然没有定下(今年发布的的Swift 4.0,依然放弃ABI稳定性,而注重于Swift源代码3.x->4.0的兼容性)。所以这就意味着Swift 3.x编译的二进制库,在Swift 4.0将无法链接,只能重新代码编译。看来这又将是Objective-C这门古老的语法,能够作为一些framework首选开发语言的一年。

对于一个代码库来说,有时候我们为了隐藏一些实现的细节,或者内部处理流程,需要编译到二进制进行分发,并提供Public Header来供其他开发者调用。

因此,开发代码库的时候,需要明确哪些API是对外公开的,可以由其他开发者调用。那些是库内部之间互相调用的,不应该由外部使用者调用。而Objective-C不像C++提供了private关键字来限制直接访问成员变量和成员方法。因此,就需要尽量避免私有属性和私有方法的定义出现在头文件中。只要不引入私有的头文件,那就无法直接访问这些属性和方法。

隐藏内部属性

私有属性,可以分成两种,一种是希望放到类内部而纯粹不想暴露给任何人的,可以叫做内部属性。一种是希望暴露到Private Header中,只限于引入该头文件的地方进行访问。

内部属性的声明非常简单,我们可以直接使用类扩展声明属性,而编译器会自动生成getter和setter,不需要任何额外工作。

1
2
3
4
5
6
// Person.m
@interface Person ()

@property (nonatomic, strong) NSObject *internalObject;

@end

改变属性修饰符

对于很多情况,我们需要对外暴露属性是readonly的,以防止使用者手动修改,但是内部流程的时候也需要这个属性,并且希望是readwrite的,这个在类扩展中直接可以重新声明已有的属性,并修改属性修饰符。

1
2
3
4
5
6
7
8
9
10
11
12
// Person.h
@interface Person

@property (nonatomic, strong, readonly) NSNumber *number;

@end
// Person.m
@interface Person ()

@property (nonatomic, strong, readwrite) NSNumber *number;

@end

注意,由于类扩展是可以在任何地方声明的(不限于.m实现文件),我们也可以把属性修饰符的修改,放到Private Header(可以用+Private后缀,也可以参考UIKit等框架起名为UIKitInternal.h)中,这样引入了Private Header的地方可以readwrite,没有引入的地方是readonly。

1
2
3
4
5
6
// Person+Private.h
@interface Person ()

@property (nonatomic, strong, readwrite) NSNumber *number;

@end

隐藏私有属性

但是很多时候,我们希望一些属性是私有的,即类实现处和引入了Private Header的地方才可以访问。这种时候就需要采取别的方式了。常见的方法是通过类扩展(主要针对类的实现文件可见)或者使用关联对象(主要针对类的实现文件不可见,如其他第三方库的类)两种方式。

类扩展(Class Extension)

通常情形

类扩展,不同于Category,最大的优势在于可以直接添加实例变量ivar到类的本身实现中,而Category是无法添加实例变量的。而在类扩展中声明的属性,也可以自动在编译期合成,同普通类声明属性的方式相同,不了解的参见:CustomizingExistingClasses。因此,实际上类扩展非常适合隐藏私有属性。

1
2
3
4
5
6
// Person+Private.h
@interface Person ()

@property (nonatomic, strong) NSString *privateID;

@end

自定义存取方法

对于通常case来说,这是非常好的解决方法(不用任何额外代码)。但是有一个问题,如果你想自定义这个属性的存取方法(比如,实例变量的惰性初始化),那就会遇到问题。因为属性合成的ivar,是只在类本身实现中创建的,在Category中无法创建,而且类的实现只能实现一次(在原始的Person.m中实现)。试想一下这样子的情况,就会出现编译错误:

1
2
3
4
5
6
7
8
9
10
11
// Person+Private.m
@implementation Person (Private)

- (NSString *)privateID
{
if (!_privateID) { //Compile Error: undeclared identifier:_privateID
_privateID = @"foo";
}

return _privateID;
}

第一种解决方案:

最简单的方式,就是直接把自定义的存取方法写在类本身实现文件中,然后在Category中暴露头文件,并用@dynamic来标记这个属性(否则由于Category看不到编译器自动生成的getter和setter会报warning)。自定义存取方式就和普通的写法一模一样。这相当于是一种把内部属性暴露出来的方法。不过容易导致耦合(因为其实我们的私有属性目标是用于和外部类交互的,不希望放到Private Category以外)。

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
//Person.m

@interface Person ()

@property (nonatomic, strong) NSString *privateID;

@end

@implementation Person

- (NSString *)privateID
{
if (!_privateID) {
_privateID = @"foo";
}

return _privateID;
}

@end

//Person+Private.h
@interface Person (Private)

@property (nonatomic, strong) NSString *privateID;

@end

//Person+Private.m
@implementation Person (Private)
@dynamic privateID;

@end

第二种解决方案:

当然,聪明的你自然会想到,既然Category没法定义ivar,那直接在类扩展中声明一个ivar不就行了。于是你可以这样写,但是这会出现一个编译警告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Person+Private.h
@interface Person () {
NSString *_privateID;
}

@property (nonatomic, strong) NSString *privateID;

@end

// Person+Private.m
@implementation Person (Private)

// Compile Warning: category override method from class
- (NSString *)privateID
{
if (!_privateID) {
_privateID = @"foo";
}

return _privateID;
}

由于在类扩展中已经定义了属性,那么这个类在编译期间会自动合成存取方法,而在Private Category中覆盖就会覆盖本身合成的方法(虽然我们确实需要这样),但由于可以在多处定义Category,并且方法覆盖的顺序不定,无法保证你的存取方法就是真实想要的,所以这是编译警告。对于这种需要自定义存取方法的私有属性的case,应该在类扩展中定义ivar,在Private Category中定义属性并实现。注意由于在类扩展定义了ivar,不会自动生成getter+setter,需要自行同时定义setter和getter,注意对不同属性修饰符,比如copy的话setter需要用[-copy]weak的话ivar要标注__weak等。

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
// Person+Private.h
@interface Person () {
NSString *_privateID;
}

@end

@interface Person (Private)

@property (nonatomic, strong) NSString *privateID;

@end

// Person+Private.m
@implementation Person (Private)

- (NSString *)privateID
{
if (!_privateID) {
_privateID = @"foo";
}

return _privateID;
}

- (void)setPrivateID:(NSString *)privateID
{
_privateID = privateID;
}

分类(Category)和关联对象

由于Objective-C的属性,其实就是ivar+getter方法+setter方法,我们可以在使用的地方通过Runtime来获取ivar。但是这种方式实际上来说是用的人非常少。第一个是复杂,第二个是不好使用一个通用的宏进行转换(因为ivar需要计算offset,根据不同类型的type encoding还不同……),而且对于这种需求来说优点大材小用了。因此我们一般都是使用关联对象(不了解的参见:Associated Object

使用了关联对象后,为了方便不必要繁琐地书写objc_getAssociatedObjectobjc_setAssociatedObject,我们可以定义一些宏来方便使用。由于属性是包括了语义和引用计数相关内容的,因此针对不同的属性修饰符,需要采用不同的宏来保证属性的语义。

属性修饰符的语义,可以参考clang官网的说明:Objective-C Automatic Reference Counting,如下:

assign implies __unsafe_unretained ownership.
copy implies __strong ownership, as well as the usual behavior of copy semantics on the setter.
retain implies __strong ownership.
strong implies __strong ownership.
unsafe_unretained implies __unsafe_unretained ownership.
weak implies __weak ownership.

由于属性修饰符只会影响setter,而不是getter,我们可以定义一个通用宏。对应的setter就需要单独根据情况编写。

1
2
#import <objc/runtime.h>
#define __GET_PROPERTY(property) objc_getAssociatedObject(self, @selector(property));

strong(retain)

strong或者retain,就是所有对象的默认属性存取行为,隐含着对对象进行retain而使引用计数+1。这个可直接通过关联对象的行为设置。

宏:

1
#define __SET_STRONG(property) objc_setAssociatedObject(self, @selector(property), property, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

示例:

1
2
3
4
5
6
7
8
9
10
11
@property (nonatomic, strong) NSNumber *number;

- (NSNumber *)number
{
return __GET_PROPERTY(number);
}

- (void)setNumber:(NSNumber *)number
{
__SET_STRONG(number)
}

copy

copy属性修饰,表示在调用setter的时候,首先需要对对象进行copy操作,然后再表示strong,在Objective-C中其实就是发送了copyWithZone:消息。这个可直接通过关联对象的行为设置。

宏:

1
#define __SET_COPY(property) objc_setAssociatedObject(self, @selector(property), property, OBJC_ASSOCIATION_COPY_NONATOMIC);

示例:

1
2
3
4
5
6
7
8
9
10
11
@property (nonatomic, copy) NSString *name;

- (NSString *)name
{
return __GET_PROPERTY(name);
}

- (void)setName:(NSString *)name
{
__SET_COPY(name);
}

unsafe_unretained

unsafe_unretainedassign的语义是相同的,前者是ARC下加入的,而后者从MRC开始存在。一般来说,对于原始类型(intdoubleBOOLNSInteger)这些,由于本身就是copy by value,而且不存在对象和引用计数管理,因此属性声明用assign(很少见写unsafe_unretained,虽然允许)。

而对于对象而言,一般如果想表示不改变任何引用计数的弱引用,现在都用的是weak,因为unsafe_unretained不会像weak那样,在对象引用计数降到0被销毁后,自动置nil,而会保持指向的地址,因此可能随时都成为野指针而不安全。但是由于历史代码缘故,还有很少的代码库在用,姑且暂时保留。

这里我们定义一个宏,仅用于表示对象的unsafe_unretainedassign。这个可直接通过关联对象的行为设置。而对于原始类型的属性,参见下面的assign

宏:

1
#define __SET_UNSAFE_UNRETAINED(property) objc_setAssociatedObject(self, @selector(property), property, OBJC_ASSOCIATION_ASSIGN);

示例:

1
2
3
4
5
6
7
8
9
10
11
@property (nonatomic, unsafe_unretained) NSObject *unsafeObject;

- (NSObject *)unsafeObject
{
return __GET_PROPERTY(unsafeObject);
}

- (void)setUnsafeObject:(NSObject *)unsafeObject
{
__SET_UNSAFE_UNRETAINED(unsafeObject);
}

assign

区别于上面针对对象的unsafe_unretained assign语义,这里的assign特指对原始类型的属性修饰符。由于Runtime的Associated Object一定是一个Object,因此我们需要把原始类型进行装箱,封装为一个Object,在getter中拆箱,拿到真实的原始数据。这个过程由于我们一定是一个Object箱子,只装一个真实的原始数据,因此没有必要进行copy(箱子是唯一的,但是内容的原始数据来源是copy by value)。可以用strong来修饰。

对于不同的原始类型,装箱的方式不同,一般来说,对于数值类型(int、double、NSInteger),可以使用NSNumber来装箱。对于其他类型,比如结构体,可以使用NSValue来进行装箱(比如CGRect,NSRange, Pointer)。对于不同的装箱来说方式不同,因此不好在宏里面进行处理,直接接收一个装好箱的value就可以了。

宏:

1
#define __SET_ASSIGN(property, value) objc_setAssociatedObject(self, @selector(property), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

由于装箱方式不同,拆箱方式肯定不同。不过只要拿到箱子之后,自己根据类型来进行相应拆箱即可。

示例:

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
@property (nonatomic, assign) int age;
@property (nonatomic, assign) CGRect frame;

- (int)age
{
NSNumber *value = __GET_PROPERTY(age);
return value.intValue;
}

- (void)setAge:(int)age
{
__SET_ASSIGN(age, @(age));
}

- (CGRect)frame
{
NSValue *value = __GET_PROPERTY(frame);
return value.CGRectValue;
}

- (void)setFrame:(CGRect)frame
{
NSValue *value = [NSValue valueWithCGRect:frame];
__SET_ASSIGN(frame, value);
}

weak

weak属性指的是一个弱引用,不改变对象的引用计数,同时和assignunsafe_unretained的最大区别,在于有着自动置nil的安全性质。一旦weak对象被销毁,该引用不会成为一个野指针,而会被立即置为nil,保证了安全。对于如今的现代Objective-C,能表示弱引用全部使用weak,应当避免使用assignunsafe_unretained表示一个弱引用(就算考虑上性能问题,weak立即置nil采用了一个全局的weak表,由Runtime管理,开销和手动release基本一致,不太可能成为性能问题)。

由于weak的特殊性(全局weak表),关联对象本身就没有提供weak的语义行为,但是我们可以来模拟一个等价的行为。

第一种解决方案:
我们使用一个WeakContainer,只包含一个weak的属性,来存放真实的weak引用对象。这样,通过关联对象把整个WeakContainer关联到Category的属性上,然后存取使用的时候进行装箱和拆箱,解决方案即可。不过唯一的缺点是由于需要引入一个WeakContainer类,无法做到Header Only。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@interface WeakObjectContainer : NSObject

@property (nonatomic, weak) id object;

+ (instancetype)containerWithObject:(id)object;

@end

@implementation WeakObjectContainer

+ (instancetype)containerWithObject:(id)object
{
WeakObjectContainer *container = [[WeakObjectContainer alloc] init];
container.object = object;
return container;
}

@end

宏:

1
2
3
#import "WeakObjectContainer.h"
#define __SET_WEAK(property) objc_setAssociatedObject(self, @selector(property), [WeakObjectContainer containerWithObject:property], OBJC_ASSOCIATION_RETAIN_NONATOMIC);
#define __GET_WEAK(property) [objc_getAssociatedObject(self, @selector(property)) object];

第二种解决方案:

为了做到Header only,我们需要借助一个匿名的block,首先定义一个weak引用指向属性值,然后block捕获它。这样子,只要把block关联到对象上,那么在getter的时候,通过直接执行block返回这个weak对象,就可以拿到真正的弱引用(实现时,block要用copy,而且要判空)。

宏:

1
2
3
4
5
#define __SET_WEAK(property) id __weak __weak_object = property; \
id (^__weak_block)() = ^{ return __weak_object; }; \
objc_setAssociatedObject(self, @selector(property), __weak_block, OBJC_ASSOCIATION_COPY);

#define __GET_WEAK(property) objc_getAssociatedObject(self, @selector(property)) ? ((id (^)())objc_getAssociatedObject(self, @selector(property)))() : nil;

示例:

1
2
3
4
5
6
7
8
9
10
11
@property (nonatomic, weak) id delegate;

- (id)delegate
{
return __GET_WEAK(delegate);
}

- (void)setDelegate:(id)delegate
{
__SET_WEAK(delegate);
}

自定义存取方法

自定义存取方法一般类的属性写法类似。比如说想要惰性初始化(即只有在第一次调用getter的时候,才会初始化属性)这里就不用_name来操作ivar,而是通过setter(当然也能用__SET_* 宏来直接操作关联对象)就可以了。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (NSString *)name
{
NSString *name = __GET_PROPERTY(name);
if (!name) {
name = @"foo";
[self setName:name];
}
return name;
}

- (void)setName:(NSString *)name
{
__SET_COPY(name);
}

隐藏内部方法

类扩展实现类的内部方法

Objective-C没有真正意义上的私有方法,毕竟是C语言的超集嘛。但是Objective-C提供了一个类扩展语法,允许定义方法的接口。因此,只要我们在.m实现文件中定义了一些内部方法,就可以对外隐藏(当然,class-dump selector这些是可以直接调用的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Person.m

@interface Person ()

- (void)internalMethod;

@end

@implementation Person

- (void)internalMethod
{
//...
}

@end

隐藏私有方法

分类实现类的私有方法

但一些情况下,我们需要很多库内部使用的类的私有方法(私有方法和内部方法虽然都不对外可见,但是其实目标不一样,私有方法一般是一些可以直接设置实例的状态,内部数据的危险方法,用于库内部的一些类之间,互相调用来使用。而内部方法一般放一些复杂流程处理,工具方法,是为了简化代码逻辑而使用的)这些方法需要和公开头文件的方法分开,保持对外隐藏。这时候就得用到Category。

我们可以把想要隐藏的私有方法,全部放到一个Private Category里面,库内部其他需要操作的地方,引用这个头文件即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Person+Private.h

@interface Person (Private)

- (void)privateMethod;

@end

// Person+Private.m
@implementation Person (Private)

- (void)privateMethod
{
//...
}

@end

暴露公开类的内部方法

对于公开类,我们有可能在实现中定义很多内部的方法,这些方法可能依赖一些上下文,或者是只在类扩展里面定义的属性(而不是在我们的Private分类里面)。当我们在库的其他地方,也想使用这些内部方法时,但是方法定义不在Private Header中(虽然实际上在类内部已经实现了)。我们需要一种方式来暴露类的内部方法。

1
2
3
4
5
6
7
8
9
10
11
12
//Person.m

- (void)publicMethod
{
//...
[self internalMethod];
}
//我们想暴露这个方法给其他引用了Private Header的地方使用
- (void)internalMethod
{
//...
}

第一种解决方案(错误示范):

使用一个Private Category,在头文件中暴露这个方法。但是由于是类本身而不是Category的方法,编译器会报找不到internalMethod的实现的warning(虽然它确实在本身的类中实现了)。我们是可以警告编译器,忽略warning,因为你知道实际上这个方法已经有了实现,只不过头文件没有暴露罢了。但是这种方法忽略警告,会忽略所有Private Category的方法检查,假如Person+Private.h中定义的方法真的没有在Person+Private.m中实现,也不会有任何警告,所以非常不推荐。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//Person+Private.h

@interface Person (Private)

- (void)internalMethod; //在类本身实现中的内部方法,想要暴露出去
- (void)privateMethod;

@end

//Person+Private.m

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wincomplete-implementation"
@implementation Person (Private)
//...
@end
#pragma clang diagnostic pop

第二种解决方案:

使用类拓展(而不是Private Category)来暴露一个内部方法,实际上这才是最佳的方式,因为类扩展并不局限于任何地方,而且可以在任何.h或者.m中进行声明。实际上,类扩展只有@interface而不能有@implementation,是方法的接口而不是实现,不会出现方法重定义或者覆盖的问题。这样,我们在类扩展中加入实际类的内部方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Person+Private.h

@interface Person ()

- (void)internalMethod;

@end

@interface Person (Private)

- (void)privateMethod;

@end

//Person+Private.m
@implementation Person (Private)
//...
@end

因为类扩展在编译器检查时,是需要对类本身实现的方法进行检查的,因此假如Person类本身没有实现internalMethod,编译器会报warning,这也保证了正确性。

总结

Objective-C毕竟已经几十年的语言了,语法层面上对抽象隐藏支持的就不好,不像Swift提供了四种访问控制关键字:publicinternalfileprivateprivate,而且支持Module,再也不用担心命名和重定义问题了。不过Swift的现状,在Swift 4.0 ABI还不能稳定的情况下,代码库分发就只能使用源代码,这点对于很多开发者还有企业的影响确实比较大。不过了解Objective-C的实现也不是什么坏事,毕竟谁不定总会有需要写的的时候。希望这些代码库的接口与实现隐藏的方法,能够帮到一些平时没有接触过代码库开发的人吧。

资料

  1. 完整Category属性宏

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

作者 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就能够轻松解决。

FRP对比—ReactiveCocoa、RxSwift、Bacon以及背后的Functional

作者 DreamPiggy
2016年11月17日 22:57

ReactiveCocoa和RxSwift

iOS的开发上,Objective-C可以说既是一个巨大的成功,也是一个巨大的限制。Cocoa Touch提供的原生API本身就是目标当年的事件驱动和消息派发的GUI编程模型,并且专门为Objective-C这门类smalltalk的消息式OO语言设计的,更为尴尬的是iOS上没有OS X上自带的Data Binding。种种原因,导致Target-Acion,KVO,Notification,Apple式MVC架构才会一直成为iOS开发的主流。然而,做过开发的都知道,这套架构在大型App,尤其复杂是网络请求和人机交互特别多的情况下,非常容易让整个App架构变得难以维护。

Apple式的MVC,又称为Massive View Controller,会让你整个业务代码和UI绑定代码充斥同一个文件中,并且导致很多人经常会在View中,直接#include一个Modeld的头文件,然后起一个configureInfo: 的方法,直接在里面把Model的数据拿来绑定到View的属性上。不信?试试搜一遍你所有的View,把Model的头文件删掉,看看能否编译通过。

1
2
var userCell = tableView.dequeueReusableCellWithIdentifier("identifier") as UserCell
userCell.configureWithUser(user)

MVP架构或许是你的救星,不过实际上,MVP只是一个工程化的解决问题,把Massive View Controller变成Massive View Presenter,带来相对明确的架构分层的副作用就是近乎两倍的代码量。而在这种情况下,MVVM的架构就是一个非常大的突破,和MVP一样把View/ViewController扔到一起,但是引入单独的ViewModel,通过View到ViewModel的单向绑定,ViewModel对Model的订阅,既避免了MVC造成的代码混乱,又减少了MVP的造成的重复代码。而实践上,提到MVVM,就得 提到ReactiveCocoa或者RxSwift,这两者都是FRP的GUI框架实现。

ReactiveCocoa

为了统一术语,ReactiveCocoa中的概念这里都描述成Rx中类似的概念,本质上都是一样的东西

ReactiveCocoa把事件流的接口,定义为RACStream。而实际上,通常的事件流实现都是RACSignal对象,这个Signal是一个冷事件流(也可以叫做push-driven),即有订阅者订阅后,才会开始从头依次发送事件。而对应的冷事件流接口叫做RACMulticastConnection,即没有订阅者也会发送事件流。热事件可以通过publishmulticast转换到热事件流,这对于很多请求,比如WebSocket这种不需要重入的事件流来说很有用。

另外,为了支持Objective-C语言上对泛函性的缺乏,提供了另一个事件流的实现RACSequence对象,用来处理集合类型的事件流。

一旦订阅之后,事件流就可以解包,拿到不同状态下的数据,Objective-C的接口就是和Rx类似的三种:
void (^next)(id) :拿到事件本身,事件流本身继续流动
void (^error)(NSError *):处理错误事件,error和completed后事件流均结束,两种状态必局其一
void (^completed)():事件流正常结束的处理

1
2
3
4
5
6
7
8
[[self.usernameTextField.rac_textSignal
filter:^BOOL(id value){
NSString*text = value;
return text.length > 3;
}]
subscribeNext:^(id x){
NSLog(@"%@", x);
}];

这里,rac_textSignal就是一个事件源,而后面的filter,是一个操作符,对事件流的事件变换到真正订阅者关心的数据,最后的subscribeNext是一个便捷方法,订阅并生命next状态的处理方式。整个流程模拟的是一个TextFiled的用户输入事件流的走向,用户的所有输入,一旦超过3个文本,就会流动并且打印出来,注意冷事件流是整个流从头开始的

1
2
3
hel
hell
hello

就如上一篇简介中提到的那样,我们可以不断添加新的操作符,来灵活处理我们的关心的事件流。虽然Objective-C本身没有任何泛函性的接口,但是ReactiveCocoa封装的RACSequence本身提供了相当丰富的操作符,包括常见的map,flatmap,filter,combine,switch等,比如你可以把用户名和密码框的检验事件应用combineLastest来确保二者永远同时满足才允许登陆。

1
2
3
4
5
RACSignal *signUpActiveSignal =
[RACSignal combineLatest:@[validUsernameSignal, validPasswordSignal]
reduce:^id(NSNumber *usernameValid, NSNumber *passwordValid) {
return @([usernameValid boolValue] && [passwordValid boolValue]);
}];

为了和非Reactive代码和谐相处,ReactiveCocoa提供了一个RACSubject类型,是用来处理有副作用的流的,即这个流是可变的。你可以手动创造一个新的流,并不断调用sendNext:来手动发送事件给其他订阅者,这就类似了传统的消息事件绑定机制。这个对于一些条件下,比如类似连续加载页面的信号,视图跳转等等有一定的作用,不过对于网络请求等,应当使用RACSignal

1
2
3
4
5
[[_button rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(id x) {  
[self.loggerSubject sendNext:@"pop"];
[self.navigationController popViewControllerAnimated:YES];

}];

如前面所说,ReactiveCocoa是一个方便打造MVVM架构的框架,提供的RAC宏可以方便的进行单向绑定,把事件结果同你的UI对象属性绑定起来,避免了繁琐的代码处理,达到Reactive Programming

1
2
3
4
5
RAC(self.passwordTextField, backgroundColor) =
[validPasswordSignal
map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}];

简单来看,ReactiveCocoa真不愧是Cocoa,所有的设计围绕Cocoa的设计模式,提供了方便的宏,并且弱化了泛函概念,提供了很多副作用处理的方式,不像Rx那样纯粹。然而随着Objective-C语言的慢慢淡化,整个项目之后也转为依赖ReactiveSwift的实现。在当前iOS开发的情况下,如果使用Objective-C语言,那么这就是不二的FRP之选。但是如果使用Swift,最好使用正统的RxSwift。

RxSwift

ReactiveX,也就是Rx,是一个大的语言无关的FRP架构设计,只要你了解了一个语言下的用法,那么就可以达到learn once, write everywhere(跑……)

在Rx中,事件流定义为一个Observable,而订阅者对应的是Disposable接口(RxJava里面对应的就是Subscriber),事件流可以通过subscribe来订阅,也是对应了三个状态onNextonErroronCompleted

这里,就以RxSwift为准,介绍一下简单的区别。首先,得益于Swift的语法,ReactiveCocoa的Data Binding也变得更简单了,不需要宏包裹和提前声明,看起来更为清晰

1
2
3
4
5
6
let subscription = primeTextField.rx.text           // Observable<String>
.map { WolframAlphaIsPrime(Int($0) ?? 0) } // Observable<Observable<Prime>>
.concat() // Observable<Prime>
.filter { $0.isPrime } // Observable<Prime>
.map { "number \($0.n) is prime" } // Observable<String>
.bindTo(resultLabel.rx.text) // bind to label text

另外,Rx中,不同于ReactiveCocoa,事件流本身都是Observable,至于是冷还是热,是通过publishconnect操作得到的,不同于ReactiveCocoa中的RACSignalRACMulticastConnection这种分开的设计,导致必须用对应的操作符,在Rx中,所有的操作符都是一致的表现,这点是一个非常大的改进。

同时,Rx的操作符也是最丰富的,什么liftswitch这种常用的,在ReactiveCocoa中就得自己组合一套。当然,Rx的自定义操作符也很多简单,你只需要一个T -> Observable<T>类型的函数来定义

1
2
3
4
5
extension ObservableType {
func replaceWith<R>(value: R) -> Observable<R> {
return map { _ in value }
}
}

整体上看,Rx是如今比较有名,并且成套的FRP解决方案,并且迁移到不同平台上的学习成本非常低。ReactiveCocoa本身如今已经分离为Swift版和Objective-C版,并且后者不再继续维护,因此对于混合Objective-C和Swift,或者纯Swift项目,RxSwift是一个构建MVVM和FRP架构的不二选择。

Promise

为什么这里要提到Promise呢,因为Reactive Programming需要处理的很多,就是对异步请求和频繁事件响应的处理。而Promise是一个比较流行的JavaScript平台异步解决方案。在和FRP的配合上面,可以通过不断的then组合成需要的Promise事件,并且Promise的超集,也就是Future,本身就有搭配不同的Future操作符来达到类似于Rx的组合效果。

不过,Promise的目的,在于对异步请求流程的控制,而本身并没有对事件流的管理。原始的Promise虽然有着类似Rx的事件流类似特点:不可变性可组合性,但是关键区别在于Promise自身是单次流动,数据流只会从then开始走到结束或者catch掉,无法多次重新流动;不支持流程中断取消;需要配合其他框架层面的东西,来达到完整事件流和GUI数据绑定,这里就得提到Bacon

Bacon

Bacon是JavaScript上的一个FRP框架,借鉴于知名的EventStream所实现的事件流,Bacon在这之上完成了FRP所需要的一切:事件流,变换,数据绑定,比起正统的RxJS来说,提供了更适合Web前端应用的的EventStreamProperty,不需要被RxJS的Hot/Cold Observerable烦扰。并且原生支持了所有惰性求值,在benchmark上比起RxJS有着不错的性能优势。

  • 示例——计数器
1
2
3
4
5
6
7
let plus = $("#plus").asEventStream("click").map(1)
let minus = $("#minus").asEventStream("click").map(-1)
let both = plus.merge(minus)
.scan(0, add) // add +1 or -1 base on click eventstream
.onValue(sum => $("#sum").text(sum))
.onError(e => console.log(e))
.onEnd(() => alert('total: ' + $("#sum").text));

除了专门提供的EventStream和Propery的两种Observable,并且提供了更好的事件源支持,你可以从原生的DOM事件来触发事件源,可以从Promise来触发(这是一个大的优势),甚至从callback或者自定义的binder都可以。在RxJS的基础上有了比较大的提升。不过具体工程上讲两者都是Rx实现的FRP,取舍还要看自己的特定选择(幸好我不做前端)

Functional

由于自己也不是Haskell Guy,仅仅接触过一点点JS、Closure和Swift这些有泛函编程思想的语言 ,如果想具体了解函数式编程中,关于FunctorApplicative以及Monad的知识,推荐花上10分钟看一下简单的图文教程:分别有原文(推荐)Swift版JS版

下面这些内容,默认为已经掌握了上述简单理解,如果看不太懂可以回过头重新看一下对应的Functional知识

ReactiveX

Rx的Observable的本质就是一个Event Monad,即上下文(就是图文教程中包裹的盒子)为Event的一个Monad,这里的Event定义,可以对应语言的struct或者enum,包括了nexterrorcomplete三个上下文即可。这里截取的是Swift语言的实现,map方法实现拆装箱(类似Optional,即Haskell的Maybe)

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
public enum Event<Element> {
/// Next element is produced.
case next(Element)

/// Sequence terminated with an error.
case error(Swift.Error)

/// Sequence completed successfully.
case completed
}

extension Event {
/// Maps sequence elements using transform. If error happens during the transform .error
/// will be returned as value
public func map<Result>(_ transform: (Element) throws -> Result) -> Event<Result> {
do {
switch self {
case let .next(element):
return .next(try transform(element))
case let .error(error):
return .error(error)
case .completed:
return .completed
}
}
catch let e {
return .error(e)
}
}
}

而Rx的subscribe方法就是一个解包,也就是Monad<Event>.map(),接收一个(Event) -> void的参数。或者使用更一般直观的三个参数onNext: (Element) -> VoidonError: (Error) -> VoidonCompleted: (Void) -> Void方法(在其他语言实践上,RxJS就是三个function参数,而RxJava为了支持Java7可以使用匿名内部类)

理论:

1
Monad Event <$> subscribe

示例:

1
2
3
4
5
6
7
8
9
10
11
12
let subscription = Observable<Int>.interval(0.3)
.subscribe { event in
print(event) // unwraped event
}

let cancel = searchWikipedia("me")
.subscribe(onNext: { results in
print(results)
}, onError: { error in
print(error)
})

Rx的Operator是Functor,也就是说(Event) -> Event,因此可以通过Monad不断bind你想要的组合子,直到最终符合UI控件需要的数据

理论:

1
Monad Event >>= map >>= concat >>= filter >>= map <$> subscribe

示例:

1
2
3
4
5
let subscription = primeTextField.rx.text           // Observable<String>
.map { WolframAlphaIsPrime(Int($0) ?? 0) } // Observable<Observable<Prime>>
.concat() // Observable<Prime>
.filter { $0.isPrime } // Observable<Prime>
.map { $0.intValue } // Observable<Int>

Promise / Future

Promise本质上也是一个Monad,包裹的上下文就是resolvereject
你可能反驳说Promise.then(f)中的f,可以是value => value,而并不是一个被Promise包裹的类型啊。但是实际上,由于JavaScript类型的动态性,Promise.then中直接返回value类型是个语法糖罢了,实际上会处理为value => Promise.resolve(value)

1
2
3
4
5
Promise.resolve(1)
.then(v => v+1) //便捷写法罢了,返回的是resolved状态的Promise对象
.then(v => Promise.resolve(v+1)) //完整写法
.then(v => Promise.reject('error ' + v)) //想要返回rejected状态,无便捷方法
.catch(e => console.log(e)) // error 3

原理:

1
Monad Promise >>= then >>= then >>= catch >>= then

示例:

1
2
3
4
5
6
7
8
9
10
11
Promise.resolve(1)
.then(v => {
return v + 1; // 1
}.then(v => {
throw new Error('error'); //reject
}.catch(e => {
console.log(e); // error
return Promise.resolve(0);
}.then(v => {
console.log('end', v); // end 0
}

总结

FRP本身发展时间并不长,主要是因为当年的GUI程序的复杂度和需求变化成都,和现如今相比有着明显的差距。传统的事件驱动在构件原型和简单交互的App确实非常简单,但随着架构的发展和业务增多,到了连MVP都无法承担的地步,MVVM的提出和相应的FRP框架就是一个救命稻草。

虽然现如今来说,FRP的主要问题在于入门门槛相对高一点,不过在我看来,这就和当年Web走向Angular和React一样,都是需要一段时间过渡的。在Android平台上,RxJava已经获得了相当大的成功和推广,ReactiveCocoa可能在国内并不如RxJava那样出名,但估计在日后,FRP+MVVM+Reactive Native+Redux这种混合App架构将会得到更大推广和发展,如果Apple或者Google再加一把推手,到那时候才可以说Reactive Programming的时代真正到来了吧。

#参考资料

❌
❌