阅读视图

发现新文章,点击刷新页面。

Swift Runtime 符号在动态链接库丢失的排查之路

DanceCC 是字节 Mobile Infra 的一套编译工具链的品牌名,基于 Swift.org 的工具链进行了相关定制,包括调试优化,定制 Clang 插件特性,自研 Pass 做包大小和性能优化等等。在先前的文章中均有介绍。

背景

近期,有人发来反馈,他们在接入 DanceCC 的新版本工具链时,在调整了一些库的工具链选择后(即使用 Apple 工具链还是 DanceCC 工具链),重新编译出包,发生启动 Crash,堆栈如下:

1
2
3
"Symbol not found: __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E",
"Referenced from: <42049861-CE9C-3353-ADD2-76C05302E30B> /Volumes/VOLUME/*/App.app/Frameworks/AppStorageCore.framework/AppStorageCore",
"Expected in: <4A119B38-492C-3E7C-B249-E8F49F9D5B99> /Volumes/VOLUME/*/App.app/Frameworks/EEAtomic.framework/EEAtomic"

崩溃的核心原因在于:
__ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E 这个符号找不到,引用发生在 AppStorageCore 动态链接库中,加载发生在 EEAtomic 动态链接库中

符号丢失排查

首先查看 AppStorageCore 的 Load Command,判断其递归加载的动态库(LC_LOAD_DYLIB)包含 EEAtomic 和 LKCommonsLogging,只考虑非系统库(因为该符号必定不在系统库内):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Load command 11
cmd LC_LOAD_DYLIB
cmdsize 64
name @rpath/EEAtomic.framework/EEAtomic (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1.0.0
compatibility version 1.0.0
// ...
Load command 13
cmd LC_LOAD_DYLIB
cmdsize 80
name @rpath/LKCommonsLogging.framework/LKCommonsLogging (offset 24)
time stamp 2 Thu Jan 1 08:00:02 1970
current version 1.0.0
compatibility version 1.0.0

通过 nm 来查看符号分析:

  • EEAtomic:在 dSYM 中存在符号,为 local symbol。在二进制中符号消失(被 strip)
1
2
nm EEAtomic.framework.dSYM/Contents/Resources/DWARF/EEAtomic | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
000000000000c384 t __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
  • LKCommonsLogging:在 dSYM 中存在符号,为 local symbol。在二进制中符号消失(被 strip)
1
2
3
nm LKCommonsLogging.framework.dSYM/Contents/Resources/DWARF/LKCommonsLogging | grep
__ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
000000000000cab4 t __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
  • AppStorageCore:存在 undefined symbol,需要运行时可见
1
2
nm AppStorageCore.framework/AppStorageCore | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
U __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E

即然符号在 AppStorageCore 中未定义,那么应该在其递归加载的 EEAtomic/LKCommonsLogging 中,以 T(即 global)符号暴露出来,而现在不是。导致运行时找不到该符号 dyld 报错。我们需要进一步探究源头问题。

Swift 编译器符号哪里来?

通过 Demangle 可知,这个符号是

1
swift::swift50override_conformsToProtocol(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*, swift::TargetWitnessTable<swift::InProcess> const* (*)(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*))

其存在于编译器的内置静态库 libswiftCompatibility50.a

1
2
3
4
nm /Applications/Xcode-15.0.0.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos/libswiftCompatibility50.a | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E

T __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
U __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E

什么是 swiftCompatibility50

在 iOS 平台上,Swift Runtime 被内置于操作系统一份(在 /usr/lib/swift/libswiftCore.dylib,以及对应的 dyld shared cache 中),内置的版本取决于操作系统发行时刻。

如,在 iOS 12.4 版本上,内置的 Swift 5.0 的 Runtime,而现在的编译器是 Swift 5.9

由于 Swift 5 确认了“ ABI Stable ”的承诺,因此,Swift 编译器需要实现更新语法的 Backport 能力(比如 Concurrency,Opaque Result Type 等语言能力),有些语法会涉及到 Runtime 的更新,必然,需要对“已有的老版本 Swift Runtime ”打上补丁,提供这些老版本 Runtime 中缺少的符号和功能。

具体补丁根据复杂程度,会拆分多个编译器工具链提供的静态库,最终整体链接到 App 中。

举个例子,如果当前编译单元,用到需要 Swift 5.9+ 的运行时语法,那么编译器就需要打上这些补丁:

  • libswiftCompatibility50.a:包含了 Swift 5.0-5.1 的新增 Swfit Runtime API
  • libswiftCompatibility51.a:包含了 Swift 5.1-5.6 的新增 Swfit Runtime API
  • libswiftCompatibility56.a:包含了 Swift 5.6 到当前版本(写稿时即为 5.9)的新增 Swfit Runtime API

注意几个细节:

  1. 如果链接了低版本的.a(如50),那么一定会链接高版本的.a(51和56),低版本的.a中可能会直接依赖高版本.a的符号
  2. 不同版本的.a,如50/51/56,不会重复实现同名符号导致覆盖,每个.a提供的一堆 API 的完整实现,对齐到当前 Swift 版本(即5.9)的行为,即:
  • swift::swift_getTypeName:假设是 Swift 5.0 的新增 API,跳板会访问 __DATA,__swift50_hooks,那么它必须通过 libswiftCompatibility50.a 提供
  • swift::swift_getMangledTypeName:假设是 Swift 5.1 的新增 API,跳板会访问 __DATA,__swift51_hooks,那么它必须通过 libswiftCompatibility51.a 提供

如果接入了 Concurrency,也需要额外的运行时补丁,即:

  • libswiftCompatibilityConcurrency.a:Concurrency Backport

如果接入了 SwiftUI 等依赖@dynamicReplacement 的语法的代码,也需要额外的补丁,即:

  • libswiftCompatibilityDynamicReplacements.a:Dynamic replacement Backport

如果接入了 Swift 的 Paramters Pack 语法 each T,也需要额外的补丁,即:

  • libswiftCompatibilityPacks.a:Paramters Pack Backport

备注:傻瓜省流,当你 App 用到了 SwiftUI 框架,那么你会全部用到上述所有 6 个补丁,因为 SwiftUI 都涉及到这些😮‍💨

补丁机制怎么替换实现

Swift 编译器通过自己在二进制中定义了一个专属的 Section,用动态调用的形式来访问所有 Swift Runtime API

其中,对于 Swift Runtime 的 Hook 存在于 __DATA,__swift50_hooks(假设操作系统内置那份 Swift Runtime 版本是 5.0)
而 Swift Concurrency Backport 的 Hook 存在于 __DATA,__s55async_hook(Concurrency 自身是从 5.5 引入的,也支持补丁)

跳板会检查是否当前运行的 host 环境需要打补丁:

跳板通过 dyld API 去读取 Section 拿到函数指针,随后进行调用:

一句话总结,假设调用 swift::swift_getTypeName 这个 Swift 5.0 的 Runtime API,会进行以下逻辑(其他情形无非就是 MachO Section 和对应静态库不同罢了):

  1. 检查 swift::getOverride_swift_getTypeName 返回的函数指针
    1. swift:getOverride_swift_getTypeName 会从 __DATA,__swift50_hooks MachO Section,找到被链接进去的 libswiftCompatibility50 的符号
  2. 如果返回非空,直接调用 swift::getOverride_swift_getTypeName(App 链接的补丁实现)
  3. 如果返回空,调用 swift::swift_getTypeNameImpl(操作系统的内置实现)

从而实现了上述提到的“补丁机制”。因为通过宏,标记在所有 Swift 的 Runtime API 上,因此在编译时刻都确保支持了运行时支持补丁替换,达成了“向后兼容”。技术上实现其实很原始很简单。

编译器的魔法

那么问题来了,在工具链角度看,编译器,和链接器,是两个不同的独立工作流,在不侵入宿主业务的构建系统的前提下,“ Swift 编译器怎么样告知链接器,需要这些额外的补丁库链接到二进制中呢?”

答案是通过 LC_LINKER_OPTION,即 MachO 的一个 Load Command,允许每个 MachO 提供自己的“额外链接参数”。这个参数原本用于 Clang 社区提倡的 Auto-linking 能力,现在被 Swift 编译器也借过去。参考:深入 iOS 静态链接器(一)— ld64

举个例子,以 SwiftUI 的代码为例子,当你以最低部署版本 -target arm64-apple-ios12.0 进行编译时,编译器给 MachO 写入这些链接参数,告知给链接器:

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
Load command 44
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibility50
Load command 45
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibility51
Load command 46
cmd LC_LINKER_OPTION
cmdsize 56
count 1
string #1 -lswiftCompatibilityDynamicReplacements
Load command 47
cmd LC_LINKER_OPTION
cmdsize 48
count 1
string #1 -lswiftCompatibilityConcurrency
Load command 48
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibility56
Load command 49
cmd LC_LINKER_OPTION
cmdsize 40
count 1
string #1 -lswiftCompatibilityPacks

是没有正确链接补丁吗?

在 DanceCC 的编译器编译下,产出的产物就是上述的 LC_LINKER_OPTION,按理说链接器会正常进行链接,发生了什么?

链接参数对比如图:

通过检查链接参数,看起来似乎没什么问题,这里存在 Library Search Path:-L/path/to/swift-5.9-dancecc.xctoolchain/usr/lib/swift/iphoneos,即指向了工具链内置的 libswiftCompatibility50.a 所在目录,那究竟是什么原因导致符号丢失?

怀疑 libswiftCompatibility50.a 差异

首先进行黑盒对比,观察行为差异

在实际编译机器上进行了如下 4 项测试:

  1. 使用 Apple Clang + Apple libswiftCompatibility50
    1. 产生符号为 T(global)
  2. 使用 DanceCC Clang + DanceCC libswiftCompatibility50
    1. 产生符号为 t(local)
  3. 使用 Apple Clang + DanceCC libswiftCompatibility50
    1. 产生的符号为 t(local)
  4. 使用 DanceCC Clang + Apple libswiftCompatibility50
    1. 产生符号为 T(global)

结果如图:

可见,发生问题的地方不在于 linker,不在于 clang 本身,而在于工具链内置的 libswiftCompatibility50.a,其 visibility 有问题!

对比 libswiftCompatibility50.a 差异

我们将 Apple Xcode 15.0 内置的产物和 DanceCC 进行对比

首先一眼从二进制大小来看,DanceCC 的产物未免有些太小,很反常。进一步反汇编查看,发现 Apple 的.a 包含了 -embed-bitcode 的 LLVM Bitcode 内容。我们需要 strip 后再次进行对比

1
2
3
4
5
6
7
8
9
10
11
12
Section
sectname __bitcode
segname __LLVM
addr 0x0000000000000160
size 0x000000000007da30
offset 1288
align 2^0 (1)
reloff 0
nreloc 0
flags 0x00000000
reserved1 0
reserved2 0

我们关注丢失的符号的 visibility,查看(参考:How to know the visibility of a symbol in an object file):

1
objdump -Ct libswiftCompatibility50.a
  • Apple:
1
0000000000000000 g     F __TEXT,__text swift::swift50override_conformsToProtocol(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*, swift::TargetWitnessTable<swift::InProcess> const* (*)(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*))
  • DanceCC:
1
0000000000000000 g     F __TEXT,__text .hidden swift::swift50override_conformsToProtocol(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*, swift::TargetWitnessTable<swift::InProcess> const* (*)(swift::TargetMetadata<swift::InProcess> const*, swift::TargetProtocolDescriptor<swift::InProcess> const*))

对比直观图:

初步结论

DanceCC 在生成该符号时,设置了 visibility=hidden;而苹果的该符号设置为 visibility=default

定位对应的源码

通过直接在源码仓库搜索该符号,定位到来自这里的C++代码:

可见,这里没有显式的标记 visibility,由编译器生成。那么编译器为什么“不生成 default 的 visibility 呢?”

PS:对该符号的引用出现在其插桩的 Hook 实现里(./stdlib/toolchain/Compatibility50/Overrides.cpp

调查工具链自身的构建参数

注意一个小坑点:Xcode 14(LLVM 14)的 objdump 并不会显示 external hidden symbol,只有 Xcode 15(LLVM 15)的 objdump 会显示,会干扰排查,需要使用同一份二进制进行排查。

定位到原始编译单元产物(Overrides.cpp.o)的 visibility 就是 hidden,和后续流程无关

初步怀疑是以下语法存在问题,编译器识别 visibility 时错误设置为 hidden:
__attribute__((used, section("__DATA,__swift_hooks")))

当然,更有可能是编译器 clang 传入了全局的 -fvisibility=hidden 覆盖了默认值?需要进一步排查

确认是 CI 编译插入了-fvisibility=hidden

在 CI 加入 verbose 编译后,证明和猜想一致

从上述分析可知,当前编译单元(即 swiftCompatibility Target)不应该开启修改默认的 visibility 进行编译,否则就需要源码手动声明 visibility(default)

临时 Workaround

快速绕过改问题,可以对相关库依旧保持 DanceCC 工具链,让链接器以 local symbol 的形式对每个 Swift 库链接了一份 libswiftCompatibility50.a,即 force_load 了一份,使用链接器已有参数 -Wl,-force_load_swift_libs,参考:[lld-macho] Implement -force_load_swift_libs

虽然观察到 Apple 工具链利用了Auto-linking算法,会只对 dylib 被依赖方拷贝该符号,设置为 global symbol(上述问题就是 LKCommonsLogging,nm 显示为 T),dylib 依赖方不拷贝该符号,设置为 undefined symbol(上文就是 AppStorageCore,nm 显示为 U),有点反常(像是一个依赖树,只在树的根节点真正链接了 libswiftCompatibility50.a,兄弟节点不重复静态链接),可以参考下图(Apple 总二进制只 force_load 了 2 份,DanceCC 总二进制 force_load 了 4 份)

screenshot-20231226-184955

这两种集成仅有小量二进制差异,业务 8 个 dylibs,影响较小(一个 force_load 的 libswiftCompatibility50.a 占据 10KB)

修正方式

根据目前 Apple 内置二进制的解析结果,我们一期考虑直接无脑对齐,通过源码手动标记 visibility(“default”),不影响其他编译单元的构建逻辑:

  • libswiftCompatibility50.a:源码标记错误需要更改
    0000000000000088 g O __DATA,__swift_hooks _Swift50Overrides
  • libswiftCompatibility51.a:源码标记错误需要更改
    0000000000000000 g O __DATA,__swift51_hooks _Swift51Overrides
  • libswiftCompatibility56.a:不需要改,源码标记是正确的
    0000000000000000 g O __DATA,__s_async_hook .hidden _Swift56ConcurrencyOverrides

而目前对应修正,已经贡献上游:Fix the symbol visibility in Swift compatibility lib into default instead of hidden, solve auto-linking issue and match Apple’s behavior #70627

总结

这一篇文章不仅仅介绍了具体的一个开源 Swift.org 工具链,和 Apple 闭源工具链的差异,更为重要的是介绍了关于 Swift Runtime Backport 的一些机制流程,并且介绍了一些相关的排查经验,方便工具链开发者用于追查更多类似的行为不一致问题😂。

说起来短短一年期间,DanceCC 工具链已经大大小小修复了数十例子这种行为不对齐的问题,保障了内部业务的可用性。也因此可见 Apple 在其内网维护者庞大的一套自动化验证以及私有分支。如果对这套机制有兴趣的人,可以私聊我,来让这个 Swift.org 工具链能够真正的开源出来有价值,能够在更多的场景产生贡献。

LLDB调试信息裁剪传输方案

声明

此篇文章原作者就是我,版权所有。预计未来会刊登在《字节跳动终端技术》

公众号链接:

背景介绍

在如今,越来越多应用采取分布式构建系统,以及一些云IDE的兴起,在这种场景下,如何保证跨机器的编译产物,能够正常的在另一台机器进行正常的开发调试,是一个常见的问题。

传统的单机编译和链接流程中,编译器会在产物中嵌入当前编译单元的单机的路径,中间产物的路径;链接器在链接时,也会尝试写入链接器输入的所有Object File和Archive File的路径。在随后的调试器工作时,会通过读取MachO Executable的Section中,编码的调试信息和路径,以进行行断点的匹配,源码信息的展示等等能力。自然的,如果编译器或者链接器在处理时全部以当前机器的绝对路径进行编码,则跨机器的产物传输后,就不能正常的实现调试功能。

对此,大部分分布式构建解决方案提供了避免绝对路径,或者绝对路径对相对路径对映射方案,其依赖编译器或者链接器的特定参数注入,也可能会依赖dSYM Bundle这种二次链接产物来进行调试信息传输。但是前者其存在一定的项目接入成本,需要依赖其构建时所有二进制(尤其是外部引入的三方预编译好的二进制)都进行了相对路径的处理。而后者的dSYM Bundle对增量不友好,会严重影响开发-调试周期的平均耗时。

当然,解决思路有很多。我们曾经使用了分布式进行编译,单机进行链接(以保证编编码进MachO Executable的Section中的路径都在当前机器可访问),随后在调试器启动时设置Source-Map来映射预二进制的源码路径。但是在更复杂的分布式构建场景下,链接阶段也会进行分布式处理。因此,为了保障开发阶段的应用,在用户设备上也能正常安装,调试,我们提供了一系列的解决能力支持,这篇文章主要用于分享相关的解决方案思路。

OSO和dSYM Bundle

对于C/C++/Objc和Swift编译器,其会将调试信息(如编译单元的路径,函数和变量名,变量的寄存器/栈信息等),按照DWARF规范进行编码。DWARF规范得到的编码数据是二进制的,需要找到文件来实际存储。

在macOS/iOS等类UNIX系统的历史中,这个调试信息会写入到编译器输出的MachO Object File中,其中编译单元的源码路径会写入Symble Table的SO Symbol中,并编码到最终的MachO Executable中。但是这一设计会造成Debug Build的二进制过于庞大(相当于DWARF同时编码在Object File和Executable中并重复占用),对于无论是磁盘存储,还是移动应用分发这种场景都是一大痛点。

因此,在2005年,Apple的ld64链接器,不再直接编码DWARF到最终的MachO Executable中,而是引入了一个中间映射关系,称为Debug Map。其像指针一样记录了MachO Object File的路径,以及修改时间戳(防止用户重编译了Object File但是没有重链接Executable)。这样以来,调试器会从直接访问巨大二进制里的DWARF,转为先打开编译单元产物的DWARF计算偏移,随后读取,解决了这重复一倍的磁盘占用。

而这一设计类似SO Symbol指向源码路径,因此称这些MachO Object File为OSO(SO for Object)。

随之诞生的,还有dSYM Bundle。因为上述改动后,一个MachO Executable不再“内嵌”所有调试信息了,意味着你将一个MachO Executable传输到另一台机器上,需要同时带上所有的OSO,并且每一个路径都放置正确才行,和当时的很多构建流程,以及开发者的习惯不兼容。

因此,Apple开发了一套能够重新把调试信息聚合到一起的工具,也就是dsymutil。dsymutil会根据OSO的指引,打开所有的Object File解析DWARF,并修正地址偏移,去重,“链接”到最终的一个大的DWARF文件,并用MachO格式封装。这也是如今常见的分发调试信息的方式。

当然,凡事都有代价。dsymutil从工作流程上来看,就是一个类似“链接器”的工作,其也有类似的修正地址的rebase和bind动作,是严重的单进程CPU密集型应用,在大型项目中,对于上万个OSO文件,dsymutil会执行超过5分钟才可生成完毕,并且目前是不可增量的(*)。意味着就算改动1行代码,也需要额外等5分钟开销才能开始调试流程,因此主要用于最终发布阶段的调试信息分发和长期存储。

我们的解决方案

回到正题,在分布式场景下的调试能力,只有两种选择:

调试器运行在编译/链接器所在机器上

假设分布式的编译器不产生绝对路径(或者使用类似LTO的流程),我们保证链接器和调试器在同一台机器上。通过远程调试(从Remote Host启动LLDB,Attach到一个Local Process上)的能力,即可达到正确的效果,但是这存在一定的实践局限性:

  1. 调试器依赖古老的gbd-remote协议,在两个机器上以“同步单工(串行)”来传输信息,包括寄存器信息,线程信息,写入内存等等操作,其中不乏大量二进制的压缩数据,对带宽要求较高。在我们测试的大型应用上,一次的断点陷入和Frame Variable变量查看,需要传输接近100MB的信息。传统Xcode和iPhone真机调试,是通过usbmux来进行传输的,但是一旦我们使用TCP Socket替代来进行在我们测试的大型应用上,在带宽受限的环境下可能需要等待10-20秒才能完成。
  2. 各种调试指令的输入和输出,完全依赖网络传输(就算打印一个变量的值也是),导致网络间歇性中断/离线情况下会完全不可用,对云IDE场景会影响用户体验

综上,在实际的落地场景中,在测试效果达不到预期后,我们并没有沿着这条路继续探索,转而使用下文的方案。

调试器不运行在编译/链接器所在机器上

另一种场景就是,编译/链接的机器,和调试器所在机器完全分离。这部分在传统构建中,通常会采取dSYM Bundle + 二进制包来进行分发,随后进行调试的方案来处理,以保证调试产物的可迁移性。但还有痛点:

  1. 但是dSYM Bundle由于上文提到,无法实现“增量生成”,大型项目构建需要等待5分钟的生成,对于工程角度是不可接受的。
  2. OSO的设计导致其很难人工进行跨机器的传输(涉及到生成时间戳,路径收集,路径映射),且大型项目会有较多的预二进制对象文件,其为FAT Binary含有多个架构,会造成比较高的无用带宽开销

在实际的落地场景中,我们最后选择了在此方案的基础上,大幅度优化OSO的传输开销,“等待耗时”等,最终实现在大型项目中,全调试链路启动从6-8分钟(dSYM Bundle + 穿行传输),优化为2分钟(OSO + 并行传输)的优化效果。

跨机器OSO传输处理

我们的解决方案主要侧重于解决开发-调试周期的问题,因此尽量希望从整体视角来看,调试信息的传输能够更快。这可以细分为两个优化:

  1. 调试产物本身大小更小(假设带宽一定)
  2. 调试产物生成的时间更快,或者说能够“并行生成”来提前传输

OSO路径映射

上文也提到,最开始尝试了直接利用dSYM Bundle来进行产物传输,也参考了上游和业内的一些实践,包括New DWARFLinker,但是实践下来结果都不够理想。

因此,最后的落脚点放在采取OSO来存储调试信息,并进行优化。首先我们需要保证直接原封不动从编译机器A,传输OSO到用户机器B能够正常工作,根据前文的知识,首先就需要将编码OSO从绝对路径,转为相对路径。

我们尝试在不修改工具链的情况下进行调研,但是结果是令人沮丧的:

  1. ld64写入OSO时,虽然支持一个-oso_prefix参数,但是其作用是对所有OSO路径删除一个统一前缀,不能像clang编译器的-fdebug-prefix-map=A=B那样自定义替换为相对路径
  2. lldb读取解析OSO时,会当作绝对路径去读取,并没有提供直接的路径映射方案

既然没有办法直接用相对路径,我们还有另一个思路,就是通过绝对路径来进行映射(避免跨机器的前缀路径问题)。在这方面,我们同时提供了两个实现方案(供复杂系统选择):

  1. 针对能自定义DAP(Debugger Adapt Protool)的场景(云IDE场景):我们提供了一套VFS机制,能够对任意的绝对路径虚拟映射到本机的某个路径上。可以兼容Apple的LLDB.framework
    具体实践是利用fishhook,因为LLDB会以动态库形式加载到DAP进程内存,因此能够通过fishhook解决方案来重定向其文件系统的访问,从而走一层我们自己的映射表:
  2. 针对不能自定义DAP的场景(分布式构建场景):我们利用历史文章介绍过我们有自定义的LLDB.framework,在其内部集成了原生的VFS实现,能够读取和LLVM一样的vfsoverlay.yaml文件来映射目录
    具体实践是利用LLVM提供的工具类,通过settings set来设置vfsoverlay.yaml路径,提前生成好并在读取OSO相关逻辑时调用。

OSO大小优化

在解决了传输的路径映射问题之后,另一个优化重点就是如何缩小OSO的大小。我们采取了一个朴素且保守的方案:将OSO(本身是MachO Object)的所有非Debug Info相关的Segment和Section全部清空,并调整符号表和偏移量,让这个MachO Object成为“仅供调试使用的Object”。

此外,当然还有针对FAT Binary的处理,整体功能利用llvm-objcopy,我们实现了不同的裁剪策略(见下),减少了约60-70%的大小原开销。

这样设计的好处是,能够尽量减少对LLDB原生解析逻辑影响(实际LLDB仅改动1行代码),因此为了兼容性我们提供了两个不同的开关,具体行为如下:

  • –extract-oso-zero-fill:删除所有Reolcation,对所有非__DWARF Section都进行填0操作,增加压缩率,可搭配–arch
  • –extract-oso-strip:删除所有Relocation,所有非__DWARF Section都裁剪,并调整segment.filesize和segment.offset,指定segment.flags为ZERO_FILL,需要搭配内网的LLDB.framework才可以正确解读,可搭配–arch

除了裁剪以外,还自动进行了MachO Universal Binary的Slicing(保留单架构),也不用调用方自己唤起lipo(比较慢)

在大型项目的实践中,整体的OSO传输大小,从优化前的15GB左右,优化为最终的10GB大小,减少幅度高达1/3(取决于项目的预二进制的对象文件多少)

OSO并行传输

解决了OSO的传输的大小开销后,我们又产生了另一个优化方案:现有的流程提取OSO依赖链接器链接完成,但是实际上,OSO是编译器产出的结果,链接器仅仅做的是“收集并写入路径”。我们能不能自己做一个“仿造链接器”来完成一样的能力,达到并行提前裁剪和传输OSO呢?

答案是肯定的,我们利用Apple开源的ld64代码,结合一些构建系统提供的Build System监控(如Bazel的BEP,Xcode的XCBBuildService),在链接阶段开始的瞬间,并行唤起我们的仿造链接器进程,处理裁剪OSO和触发传输的逻辑。
screenshot-20231226-185102

在这样的优化之后,原始需要串行等待的2分钟(链接时间)+2分钟(传输OSO时间),被优化为纯粹的2分钟,优化幅度高达50%。

总结

现代构建系统和工具链的日益不断的结合,我们会越来越多涉及到这种类似双向配合才能达到的收益。在这个方案中,我们介绍了如何让调试器,与构建系统的分布式处理,能够协调合一,达到接近本地单机调试的开发体验(但是拥有更高的编译/链接构建速度)。

DanceCC工具链也会后续在更多领域,如编译器、链接器、调试器、LLVM子工具上进行更多的尝试,提供针对移动平台的全套解决方案。
引用链接

  1. Apple’s Linker & Deterministic Builds:milen.me — Apple’s Linker & Deterministic Builds
  2. Apple’s Lazy DWARF Scheme:https://wiki.dwarfstd.org/Apple's_%22Lazy%22_DWARF_Scheme.md
  3. gdb-remote:https://developer.apple.com/library/archive/documentation/DeveloperTools/gdb/gdb/gdb_33.html
  4. ld64:https://github.com/apple-opensource/ld64
  5. LLVM VFS:https://llvm.org/doxygen/classllvm_1_1vfs_1_1OverlayFileSystem.html

DanceCC工具链 Swift调试性能的优化方案

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动DanceCC工具链系列之Swift调试性能的优化方案》

原作者是我自己(李卓立 @dreampiggy)而非抄袭,这里在个人博客同时转发一下,去掉了招聘相关文案。不过依旧欢迎大家有兴趣的有志之士加入。

背景

通常来说,大型Swift项目常含有大量混编(Objc/C/C++甚至是Rust)代码,含有超过100个以上的Swift Module,并可能同时包含二进制部分和源码部分。而这种大型项目在目前的Xcode 13体验下非常不好,经常存在类似“断点陷入后变量面板卡顿转菊花”、“显示变量失效”等问题。而且一直存在于多个历史Xcode版本。

图1:Xcode变量区显示卡顿转菊花,测试使用Xcode 13.3和下文提到的复现Demo

这部分Apple Team迟迟不优化的原因在于,Apple公司的内部项目和外部项目开发模式的巨大差异。Apple内部产品,如系统应用,系统库,会直接内嵌到iOS固件中,并直接受益于dyld shared cache(参考WWDC 2017-App Startup Time: Past, Present, and Future[1])来提升加载速度。这意味着他们通常会将一个App,拆分为一个薄的主二进制,搭载以相当多的动态链接库(Dynamic Framework),以及插件(PlugIn)的模式来进行开发。

举个例子,我们以iOS的消息App(MobileSMS.app)为例子,使用iOS 15.4模拟器测试。可以看到其主二进制大小仅有844KB(x86_64架构)。通过otool -L查询链接,可以看到总计动态链接了22个动态链接库,其中有9个是非公开的,大都是支撑消息App的功能库,这些库占据了大量存储。

图2:消息App的动态链接库列表

而iOS平台的第三方开发者的工程,为了追求更快的冷启动时长,由于没有了dyld shared cache的优化(dyld 3提出的启动闭包只能优化非冷启动),很多项目会使用尽量少的动态链接库。加之开源社区的CocoaPods,Carthage,SwiftPM等包管理器的盛行导致的Swift Module爆炸增长,预二进制的Framework/XCFramework包装格式的滥用,加之闭源三方公司的SDK的集成,最终形成了一个无论是体积还是符号量都非常巨大的主二进制,以及相当长的Search Paths。

以公司内飞书应用的内测版为例子,在使用Debug,Onone模式编译,不剥离(Strip)任何符号情况下,可以看到其主二进制大小为1.1GB,动态链接库数量为105,但是仅包含Apple的系统库和Swift标准库。业务代码以静态链接库集成。

图3:公司飞书应用的动态链接库列表

上述这两种不同的工程结构,带来了非常显著的调试体验的差异,并且Apple公司近年来的Xcode Team和Debugger Team优化,并没有完全考虑部分第三方开发者常使用的,厚主二进制下的工程结构。

PS:理论上可以通过业务的工程结构的改造,在本地开发模式下,使用一个动态链接库包裹基础静态链接库的方式,减少主二进制大小(也会减少后续提到的DWARF搜索的耗时),但是大型项目推进工程结构的改造会是一个非常漫长的过程。

图4:一种减少主二进制大小的工程结构设计

解决方案:自定义LLDB工具链

经过调研,我们发现业界常见做法,无外乎这几种思路:

  1. 工程改造:缩减Swift Module/Search Path数量:可行,但是收益较低,且不可能无限制缩减
  2. 通过LLDB一些开关:可行,但是内部测试下依旧达不到理想的调试状态

我们致力于在字节跳动的移动端提供基础能力支持,因此提出了一套解决方案,不依赖业务工程结构的改造,而是从LLDB工具链上入手,提供定向的调试性能优化。

调研期间也确认到,借助自定义LLDB工具链,集成到Xcode IDE是完全可行的,包括iPhone模拟器、真机以及Mac应用。

图5:自定义LLDB工具链的文件结构,系列后续文章会单独讲解,这里不展开

而LLVM/LLDB本身的工具链代码,在Apple的开源范畴之内(仓库地址:https://github.com/apple/llvm-project) 通过严格追踪跟进上游的发布历史,分支模型,能够尽可能地保证工具链的代码和功能的一致性。

实际收益

经过后文提到的一系列优化手段,以公司内大型项目飞书测试,编译器采取Swift 5.6,Xcode选择13.3为例,对比调试性能:

项目 Xcode 13.3 自定义LLDB
v耗时 2分钟 40秒
po耗时 1分钟 5秒
p耗时 20秒 5秒

图6:切换自定义LLDB工具链

图7:调试优化演示,使用Xcode 13.3自定义LLDB,运行文中提到的耗时Demo(原po耗时约1分钟):

简述po/p/v的工作流程

在介绍我们自定义LLDB工具链的优化之前,首先来简述一下LLDB的核心调试场景的工作流程,方便后续理解优化的技术点。

我们一期的目的是主要优化核心的调试场景,包括最常见的“断点陷入到Xcode左侧变量区展示完毕”(v),“点击Show Description”(po),“勾选Show Types”(p)。这些对应LLDB原生的下面三个交互命令。

图8:LLDB的交互命令

Apple在WWDC 2019-LLDB: Beyond “po”[2]中,进行了较为详细的介绍,这里我们进一步详细解释其部分工作流程,为后文的具体优化技术点提供参考。建议可以搭配视频一并学习。

po [expr]

po是命令expression --object-description -- [expr]的alias

图9:po的流程

  1. 使用Swift编译器编译result = expr得到IR
1
2
3
4
// 精简版,实际较为复杂,源代码搜@LLDBDebuggerFunction关键字
func __lldb_expr() {
__lldb_result = expr
}
  1. 执行IR代码

    1. 在支持JIT的平台上使用JIT,不支持则使用LLVM的IRInterpreter
  2. 获取执行结果

  3. 使用Swift编译器编译result.description

    1. 实际上LLDB调用的是Swift标准库的私有方法:_DebuggerSupport.stringForPrintObject[3]
  4. 执行IR代码

  5. 获取执行结果字符串

  6. 对得到的字符串进行格式化输出

p [expr]

p是命令expression -- [expr]的alias

图10:p的流程

  1. 使用Swift编译器编译result = expr得到IR

  2. 执行IR代码

  3. 获取执行结果

  4. result进行Dynamic Type Resolve

    1. 利用Swift编译器提供的remoteAST,拥有源码的AST之后,会根据内存布局直接读取对象细节
    2. 也会利用Swift Reflection,即Mirror来进行读取,和remoteAST二选一
  5. 对得到的对象细节进行格式化输出

对比下来可以看到,po和p的最大不同点,在于表达式执行的结果,如何获取变量的描述这一点上。po会直接利用运行时的object description(支持CustomDebugStringConvertible[4]协议)拿到的字符串直接展示,并不真正了解对象细节。

图11:获取Object Description的实现细节(SwiftLanguageRuntime.cpp)

而p使用了Swift Runtime(Objc的话就是ISA,Method List那些,资料很多不赘述),拿到了对象细节(支持CustomReflectable[5]协议),进行按层遍历打印。不过值得注意的是,Swift Runtime依赖remoteAST(需要源码AST,即swiftmodule)或者Reflection(可能被Strip掉,并不一定有),意味着它强绑定了,编译时的Swift版本和调试时的LLDB的版本(牢记这一点)。并不像Objc那样有一个成熟稳定运行时,不依赖编译器也能动态得知任意的对象细节。

图12:Swift Dynamic Type Resolve的实现(SwiftLanguageRuntimeDynamicTypeResolution.cpp)

v [expr]

v是命令frame variable [expr]的alias

图13:v的流程

  1. 获取程序运行状态(寄存器/内存等)
  2. 递归开始
  3. 解释expr的每一层访问(->或者.),得知当前变量的内存布局
  4. 对当前变量进行Dynamic Type Resolve
  5. 递归结束
  6. 对得到的对象细节格式化输出

v的特点在于全程没有注入任何代码到程序中,也就是它是理论无副作用的。它的expr只支持访问对象的表达式(->/.等),不支持函数调用,并不是真正的C++/C/OC/Swift语法。

优化v

下述所有说明基于发稿日的Swift 5.6(优化思路也适配Swift 5.5)说明优化方案,后续不排除Apple或者LLVM上游进行其他优化替代,具有一定时效性。

(暂时)关闭swift-typeref-system

  • 关闭方式
1
settings set symbols.use-swift-typeref-typesystem false
  • 开关说明

Prefer Swift Remote Mirrors over Remote AST

这里的remoteAST和Swift Mirror的概念,上文介绍过,不同方案会影响Swift的Dynamic Type Resolve的性能。

经过实测,关闭之后,内部项目的复杂场景下,断点陷入耗时从原本的2分20秒,缩减为1分钟。这部分开关,目前已经通过Xcode自定义的LLDBInit[6]文件,在多个项目中设置。

注:和Apple同事沟通后,swift-typeref-typesystem是团队20年提出的新方案,目前有一些已知的性能问题,但是对Swift变量和类型展示有更好的兼容性。关闭以后会导致诸如,typealias的变量在p/v时展示会有差异,比如TimeInterval(alias为__C.Double)等。待Apple后续优化之后,建议恢复开启状态。

修复静态链接库错误地使用dlopen(Fixed in Swift 5.7)

简述问题:LLDB在SwiftASTContext::LoadOneModule时假设所有framework包装格式都是动态链接库,忽略了静态链接库的可能性。

在调试测试工程中,我们追踪日志发现,LLDB会尝试使用dlopen去加载静态链接库(Static Framework),这是很不符合预期的一点,因为对一个静态链接库进行dlopen是必定失败的,如日志所示(使用下文提到的复现Demo):

1
2
3
4
SwiftASTContextForExpressions::LoadOneModule() -- Couldn't import module AAStub: Failed to load linked library AAStub of module AAStub - errors:
Looking for "@rpath/AAStub.framework/AAStub", error: dlopen failed for unknown reasons.
Failed to find framework for "AAStub" looking along paths:
// ...

查看代码阅读发现,这里触发的时机是,LLDB在执行Swift变量Dynamic Type Resolve之前,因为需要激活remoteAST,需要加载源码对应的swiftmodule到内存中。

swiftmodule是编译器序列化的包含了AST的LLVM Bitcode[7]。除了AST之外,还有很多Metadata,如编译器版本,编译时刻的参数,Search Paths等(通过编译器参数-serialize-debugging-options记录)。另外,对Swift代码中出现的import语句,也会记录一条加载模块依赖。而主二进制在编译时会记录所有子模块的递归依赖。

LLDB在进行加载模块依赖时,会根据编译器得到的Search Paths,拼接上当前的Module Name,然后遍历进行dlopen。涉及较高的时间开销:N个Module,M个Search Path,复杂度O(NxM)(内部项目为400x1000数量级)。而在执行前。并未检测当前被加载的路径是否真正是一个动态链接库,最终产生了这个错误的开销。

  • 修复方案

我们的修复方案一期是进行了一次File Signature判定,只对动态链接库进行dlopen,在内部工程测试(约总计1000个Framework Search Path,400个Module)情况下,一举可以减少大约1分钟的额外开销。

  • 复现Demo

仓库地址:https://github.com/PRESIDENT810/slowDebugTest

这个Demo构造了100个Swift Static Framework,每个Module有100个编译单元,以此模拟复杂场景。

后文的一些测试数据优化,会反复提及这个Demo对比。

注:和Apple的同事沟通后,发现可以在上层进行来源区分:只有通过expression import UIKit这种用户交互输入的Module会进行dlopen检查,以支持调试期间注入外部动态库;其他情况统一不执行,因为这些模块的符号必然已经在当前被调试进程的内存中了。

Apple修复的PR:https://github.com/apple/llvm-project/pull/4077 预计在Swift 5.7上车

优化po/p

(暂时)关闭swift-dwarfimporter

  • 关闭方式
1
settings set symbols.use-swift-dwarfimporter false
  • 开关说明
1
Reconstruct Clang module dependencies from DWARF when debugging Swift code

这个开关的作用是,在开启情况下,Swift编译器遇到clang type(如C/C++/Objc)导入到Swift时,允许通过一个自定义代理实现,来从DWARF中读取类型信息,而不是借助编译器使用clang precompiled module[8],即pcm,以及ClangImporter导入桥接类型。

切换以后可能部分clang type的类型解析并不会很精确(比如Apple系统库的那种overlay framework,用原生Swift类型覆盖了同名C类型),但是能稍微加速解析速度,这是因为clang pcm和DWARF的解析实现差异。

禁用之后,对内部项目测试工程部分场景有正向提升约10秒,如果遇到问题建议保持默认的true。

优化External Module的查找路径逻辑

在混编工程中,Swift Module依赖一个C/OC的clang module是非常常见的事情。在这种情况下,LLDB需要同时使用编译器,加载到对应的clang module到内存中,用于进行C/OC Type到Swift Type的导入逻辑。

但是实际情况下,我们可能有一些Swift混编产物,是预二进制的产物,在非当前机器中进行的编译。这种情况下,对应编译器记录的的External Module的路径很可能是在当前机器找不到的。

LLDB的原始逻辑,会针对每一个可能的路径,分别由它的4种ObjectFile插件(为了支持不同的二进制格式)依次进行判断。每个ObjectFile插件会各自通过文件IO读取和解析Header。这是非常大的开销。

  • 优化方案

我们内部采取的策略比较激进,除了直接利用fstat进行前置的判断(而不是分别交给4个ObjectFile插件总计判断4次)外,还针对Mac机器的路径进行了一些特殊路径匹配规则,这里举个例子:

比如说,Mac电脑的编译产物绝对路径,一定是以/Users/${whoami}开头,所以我们可以先尝试获取当前调试器进程的uname(非常快且LLDB进程周期内不会变化),如果不匹配,说明编译产物一定不是在当前设备进行上产出的,直接跳过。

图14:特殊匹配规则,直接避免文件IO判定存在与否

通过这一项优化,在内部项目测试下(1000多个External Module路径,其中800+无效路径),可以减少首次变量显示v耗时约30秒。

增加共享的symbols缓存

我们使用内部项目进行性能Profile时,发现Module::FindTypesSymbolFile::FindTypes函数耗时调用占了主要的大头。这个函数的功能是通过DWARF(记录于Mach-O结构中),查找一个符号字符串是否包含在内。耗时主要是在需要进行一次性DWARF的解析,以及每次查找的section遍历。

LLDB本身是存在一个searched_symbol_files参数用来缓存,但是问题在于,这份缓存并不是存在于一个全局共享池中,而是在每个具体调用处的临时堆栈上。一旦调用方结束了调用,这份缓存会被直接丢弃。

图15:symbols缓存参数

  • 优化方案

我们在这里引入了一个共享的symbols缓存,保存了这份访问记录来避免多个不同调用方依然搜索到同一个符号,以空间换时间。实现方案比较简单。

内部工程实测,下来可以减少10-20秒的第一次访问开销,而每个symbol缓存占据字节约为8KB,一次调试周期约10万个符号占据800MB,对于Mac设备这种有虚拟内存的设备来说,内存压力不算很大。另外,也提供了关闭的开关。

优化不必要的同名symbols查找

另一项优化Module::FindTypesSymbolFile::FindTypes函数开销的方案是,原始的这两个函数会返回所有匹配到的列表,原因在于C++/Rust/Swift等支持重载的语言,会使用naming mangle来区分同一个函数名的不同类型的变种。这些符号名称会以同样的demangled name,记录到DWARF中。

但是调用方可能会关心同名类型的具体的变种(甚至包括是const还是非const),甚至有很多地方只取了第一个符号,搜索全部的Symbol File其实是一种浪费(在Swift 5.6版本中找到累积约10处调用只取了第一个)

  • 优化方案

我们对上述Module::FindTypesSymbolFile::FindTypes函数,提供了一个新的参数match_callback,用于提前过滤所需要的具体类型。类似于很多语言标准库提供sort函数中的stop参数。这样,如果只需要第一个找到的符号就可以提前终止搜索,而需要全部符号列表不受影响。

图16:symbols查找筛选参数

内部项目测试这项优化以后,可以减少C++/C/OC类型导入到Swift类型这种场景下,约5-10秒的第一次查找耗时。

其他优化

定向优化Dynamic Type Resolve的一些特例

在实际项目测试中,我们发现,Dynamic Type Resolve是有一些特例可以进行针对性的shortcut优化,剔除无用开销的。这部分优化仅对特定代码场景有效,并不通用。这里仅列举部分思路

  • 优化Core Foundation类型的Dynamic Type Resolve

Core Foundation类型(后文以CF类型指代),是Apple的诸多底层系统库的支撑。Objc的Founadtion的NS前缀的很多类型,也会Toll-Free Bridging[9]到CF类型上。而Swift也针对部分常用的CF类型支持了Briding。

CF类型的特点是,它内存布局类似Objc的Class ISA,但是又不是真正的Objc Class或者Swift imported Type,ISA固定是__NSCFType

而目前LLDB遇到在Swift堆栈中出现的CF类型,依旧把它当作标准的clang type进行C++/C那一套解析,还会递归寻找父类ivar,比较费时。我们可以利用这一特点提前判定而跳过无用的父类查找。

图17:筛选CF类型

这一项优化在特定场景(如使用CoreText和CoreVideo库和Swift混编)下,可以优化10-20秒的每次Dynamic Type Resolve耗时。

接下来

我们在之后会有一系列的相关话题,包括:

  • Xcode 13.3导致部分项目po提示Couldn’t realize type of self,有什么解决办法?
  • 如何极速构建,分发自定义LLVM/LLDB工具链,来让用户无缝部署?
  • 如何进行调试性能指标的监控和建设,包括Xcode原生的LLDB?

另外,这篇文章提到的非定制的优化和功能,均会向Apple或LLVM上游提交Patches,以回馈社区。

总结

这篇文章讲解了,大型Swift项目如何通过开关,以及自定义LLDB,优化Swift开发同学的调试速度,提高整体的研发效能。其中讲解了LLDB的部分工作流程,以及针对性优化的技术细节,以及实际效果。

我们的优化目标,不仅仅是服务于字节跳动移动端内部,更希望能推动业界的Swift和LLVM结合领域的相关发展,交流更多工具链方向的优化建设。

鸣谢

感谢飞书基础技术团队提供的一系列技术支持,以及最终业务试点提供的帮助推广。
感谢Apple同事Adrian Prantl在GitHub和邮件上进行的交流反馈,协助定位问题。

引用链接

  1. https://developer.apple.com/videos/play/wwdc2017/413/
  2. https://developer.apple.com/videos/play/wwdc2019/429/
  3. https://github.com/apple/swift/blob/release/5.6/stdlib/public/core/DebuggerSupport.swift#L242
  4. https://developer.apple.com/documentation/swift/customdebugstringconvertible
  5. https://developer.apple.com/documentation/swift/customreflectable
  6. https://lldb.llvm.org/man/lldb.html#configuration-files
  7. https://llvm.org/docs/BitCodeFormat.html
  8. https://clang.llvm.org/docs/Modules.html#id20
  9. https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Toll-FreeBridgin/Toll-FreeBridgin.html

SteamDeck完全指南-以一名主机玩家视角

熟悉我的人都知道我其实是一个游戏爱好者,只是很少在博客写非技术文章而已。我在11月左右,因为受不了某日厂的PC独占行为而决定入手Steam Deck,这也是我自从2017年彻底放弃PC阵营之后第一次重回PC游戏领域,因此这里从一个主机玩家的视角整理一下我自己对Steam Deck,SteamOS的一些指南,希望能帮助中文领域的类似玩家快速上手和方便折腾。

断断续续写了几个小时,后续不断把我遇到的一些折腾指南都在这里更新吧,可以借助目录树来查看感兴趣的内容。

Steam Deck购买

主机

Steam Deck官网:https://www.steamdeck.com/zh-cn/

主机的规格主要是:

  1. CPU:AMD APU,4核心Zen2架构
  2. GPU:AMD APU,RDNA2,1.6 teraflops
  3. RAM:16GB LPDDR5,不可选配
  4. 分辨率:1280x800
  5. 电池:40WH
  6. 重量:669克

主机款式目前分为以下三种:

  1. 64GB eMMC:$399,推荐自购SSD更换
  2. 256GB SSD:$529,不推荐
  3. 512GB SSD:$649,防眩光屏 + 一堆虚拟物品,不差钱用

大众的选择一般是64GB款 + 自购512G的SSD(M.2 2230)更换,更换教程也全网都有不麻烦(一把十字螺丝刀可搞定)。

64GB款国内一般直接TB代购在¥3100,自己直接走美区充值余额 + 海淘关税13% + 国内转运,成本在¥2900左右,就算再加上JD的512GB的SSD ¥500价格,最贵也就¥3600,实际算下来是很香的。

虽然这样说,实际上我当时为了尽快上手且为了图省事,一步到位购买了现货顶配版(¥5000),防炫光玻璃效果也是有的,肉疼就肉疼吧,当是早买早享受了:)

1

到手后拿起来比Switch重很多,实际体验下来并不适合长期举着玩,除了放在底座上手柄来玩大作外,推荐的掌机玩法要么架在桌子/腿上玩,要么直接侧躺靠着握把玩(主要是文字类型游戏,注意视力)

存储卡

Steam Deck支持TF存储卡扩展,我这里选择512GB以后自然没必要换SSD了,但是为了后续可能用到的空间,以及安装Windows到TF卡上,所以又300¥买了一个UH3的512G 闪迪TF卡(注意尽量选读写速度快的),直接在机身下方插入即可。

默认SteamOS会推荐格式化为EX4文件系统,但是你可以选择其他Linux支持的文件系统(甚至包括NTFS)。配置好以后,可以选择默认安装游戏到TF卡上,也可以把已安装的游戏,在两个存储中移动,和Switch的逻辑有点像,也和PC的Steam客户端的内容库管理逻辑一致。

后文的“游戏转移到存储卡”章节,会详细讲解游戏转移到存储卡的操作和注意事项。

配件

我作为主机玩家,且主要目的是为了玩日厂的JRPG,因此连接电视+手柄对我来说是必不可少的。

Steam Deck官方提供了一个基座(扩展坞):https://www.steamdeck.com/zh-cn/dock

接口规格为:

  1. 3个USB-A 3.1接口
  2. 1个HDMI 2.0接口
  3. 1个DP 1.4接口
  4. 1个供电接口
  5. 1个RJ45网口

值得注意的是,不同于Switch的基座,这个基座只提供USB Type-C的扩展能力和充电能力,完全不能提升游戏性能(实测,对比直接Type-C供电,游戏帧率一致)。

显示器输出虽然说最高支持4K 120Hz,实际上游戏压根带不动。推荐的游戏输出分辨率是1080P(后文提),另外还有一个注意点,官方基座MST(多显示器输出)需要同时接入HDMI和DP两个端口而不能二次转接,不过实际性能表现带单显示器已经极限,大部分人压根用不到

官方基座售价$89,国内现货800¥起步,完全不值得购买(不差钱另说),其实如果你不追求长期接电视/显示器,选择一个便宜的100¥以内的Type-C转HDMI头都可以解决

我最终选择了一个第三方的基座,除了没有DP接口其他规格完全一致,只需要¥250,铝合金质感比官方基座的塑料明显要好(就离谱)

2

Steam账号和支付

自从2017年彻底退出PC游戏之后,我的国区Steam账号终于又活过来了。(上次登录1400天前)

Steam账号体系和PS的账号体系更类似(对比Switch和Xbox那种随时跨区切换购买游戏而言),一个账号每3个月才能更改一次地区,且余额会按照汇率等价兑换。另外注意转区完成以后还需要进行一次有效购买才可以实际切换

但是,Steam对转区判定比较严格,需要挂对应地区IP代理,否则经常性转换可能被红信(即Steam的欺诈警告,会锁定账号的游戏购买)

由于不同区的游戏价格差异很大,可以参考SteamDB这里搜索对比一下,且有些游戏会锁国区(暴力血腥or小黄油),推荐的方式是类PS的账号体系的应对方式,我们注册多个地区的不同账号,通过在一台Steam Deck上登陆多账号,然后切换游玩(还可以利用家庭共享来让账号1游玩账号2的游戏,并成就和存档挂在账号1,老PS玩家很熟悉)

注册Steam外区账号

注册Steam外区账号需要使用对应地区的IP代理,这个随便找个比如Free Proxy,或者利用一些加速器自带的商店加速能力就可以切。

推荐注册的外区是阿根廷(大部分游戏的最低价地区,但锁本地信用卡和充值卡,后文讲),或者土耳其(目前截止12月,土耳其支持国内Visa/Mastercard双币卡,非常方便)

注册完成以后,需要进行一次商店定区,无论是外区货币充值还是使用占位符兑换码兑换一次都可以,保证商店页面显示的货币为外币即可。

另外,强烈建议注册后立即下载Steam手机版,登陆外区并绑定手机两步认证(不需要外区手机号,国区即可接),为后面的Steam市场开放和余额购买做准备。

以及,Steam在我离开这段时间,除了商店外,社区,好友和很多平台可能被网络屏蔽,建议自备路由器级别的代理或者设置WiFi的代理服务器

支付和余额购买

土耳其区因为暂时支持国内双币卡,就不多说了,这年头主机玩家没个Visa/Mastercard信用卡不太可能。注意账单地址选择一个真实的土耳其地址即可。当然你也可以选择下面提到的余额购买方式,也可以直接选择电子充值卡兑换码(一般和汇率持平)

阿根廷区因为最低价被大量玩家滥用,既锁本地信用卡,又在2021年关闭了电子充值卡的购买手段,目前我已知支付手段包括:

  1. 日韩国等地的电话亭余额充值:新号可以充,一般需要联系下当地认识的人,或者TB代充,目前我见到的汇率约为1000比索:48人民币,属于小亏级别,不推荐长期使用
  2. 当地信用卡代充:新号可以充,阿根廷区比较少见,原因是大量多账号用一个信用卡可能被Steam风控
  3. Steam市场购买余额:需要开通手机两步认证,且购买等价5$游戏不退款满7天后即可开放。利用Steam市场可以卖虚拟物品,自动转换汇率的特点,一般叫做饰品交易,俗称“挂刀”。本质上原因是Steam余额无法直接提现,因此会有人愿意在外部平台,以低价现金卖出虚拟物品,然后你以低价买到,再拿到市场高价卖出得到账户余额(Steam市场还会扣交易税15%)。这个TB也挺多的,目前我见到的汇率约为1000比索:38人民币,属于小赚水平

5

SteamOS使用

SteamOS的体验,简而言之可以说是把PC上的Steam客户端,做到了操作系统级别的体验,基本交互比PC上的大屏幕模式更舒服,且所有导航设置都适配了手柄(无论是主机手柄还是外接PS/Xbox手柄甚至是Switch手柄)

SteamOS基于Arch Linux,提供了游戏模式(大部分时间在这里)以及桌面模式。游戏模式自带了一个快捷菜单键,类似PS键,除了可以进行除了WiFi/蓝牙/飞行/亮度等调节,查看通知邀请啥的常见能力,最有意思的是可以进行性能配置,包括限制电量TDP来控制续航(只有40WH的电量,意味着功耗拉满25W,只能支撑1个半小时),锁帧率,以及开启采样技术等,后文提。

桌面模式是KDE,我第一次上手感觉和macOS的不太像,更像是Windows桌面的逻辑,不是很舒服,熟悉一段之后还好。文件管理器叫做Dolphin,使用起来反而更像macOS的Finder,标签页,边栏,打开终端啥的。终端模拟器叫做Konsole,比macOS的终端好用一点点。另外自带的软件商店叫做Flatpak,可以搜索各种应用如模拟器,Epic启动器等等。

桌面模式的Steam客户端可以进行一些复杂操作(实际上桌面模式上的Steam客户端和PC上操作完全一样),如添加非Steam游戏快捷方式,后文专门提及。

对于Windows游戏来说,SteamOS内置的Proton兼容层提供了转译。这个转译是API级别的(即实现了一套Win32 API,.NET API,以及DirectX转译Vulkan等),不是类似Apple M1对x86_64的指令集转译,在我测试游戏中表现挺好的,兼容性不错,帧率甚至超越Windows原生执行。

游戏外接显示器分辨率

SteamOS和桌面模式默认情况下连接显示器后,就能以显示器原生分辨率(我测试过1080P和4K分辨率)显示UI,但是这和游戏分辨率是两回事。

当启动游戏后,Steam默认配置会把游戏锁定在1280x800,很多游戏感到非常的模糊(比Switch接电视还低)。查了一下才发现需要每个游戏进行配置,选中游戏 -> 属性... -> 游戏分辨率,从”Default”改为”Native”(指的是显示器原生分辨率)

这点想吐槽的是竟然没有全局开关,也许是为了适配各种PC游戏配置要求和支持分辨率混乱的现状……不过输出1080P甚至4K之后,明显感觉Steam Deck性能吃紧,大部分近两年的3D游戏都很难30帧以上运行,只有2D游戏可以继续拉满。这里就要提到下面的性能配置,能利用超采样技术以达到分辨率和性能兼得。

性能配置和全局FSR

SteamOS的快捷菜单能进行各项性能配置,除了能有一个专门的浮层显示帧数和APU/RAM占用率等指标外,并且还能选择是全局配置还是仅当前游戏配置,非常灵活。看了下包括:

  1. 锁帧(一般默认60即可)
  2. 半速率着色(VRS):可以省电,按照其解释会稍微影响输出分辨率,但是我测试几个游戏后没看出明显差异(大作掌机模式必开)
  3. TDP限制:功耗限制,掌机模式必限制10W否则马上没电
  4. GPU锁频:可以追求极限帧率,供电模式下拉满,疑似怀疑长期可能会影响GPU寿命
  5. 缩放过滤器:低分辩率超采样到高分辨率,只推荐开FSR,其他采样效果一般

3

其中最有用的是这个全局FSR,Steam Deck的APU本身性能带一些CPU瓶颈的大作(如老头环),模拟器游戏等,明显会感到吃力,供电下只有30帧出头,基本属于不能玩水平(同期Switch/PS4:30帧流畅游玩)

不同于Windows下这种技术,需要每个游戏厂商自己单独支持,并在游戏内单独开启,这种全局FSR依赖了DirectX到Vulkan转译层直接全局干上,效果非常明显。

我用电视体验和实践下来,基本养成了习惯,对3D大作,先在Steam属性里改为输出1080P,然后游戏内设置720P的分辨率,最后菜单仅当前游戏配置FSR,这样3D模型会采样到1080P,同时帧率可以到40-60畅玩,只有2D纹理的文字会模糊点,但是体验明显会爽很多。

有一个注意的点,大部分游戏都需要在游戏内选择窗口模式而不是全屏模式,才能正常FSR生效(打开性能面板查看有一行”FSR:ON”)。虽然实际上游戏模式下,压根没有”窗口模式”全部都给你转译拉成全屏了,只有桌面模式才能窗口,这一点不知道是不是Bug?

总之基本所有3D游戏进去就找窗口模式+FSR就行,2D游戏发现有时候窗口模式会导致文字变模糊,所以不要动。总之PC游戏就是一个“配置灵活”(折腾)

Steam控制器映射

PC游戏,众所周知就是优先按照键鼠交互开发的,大部分游戏都没有一方对手柄进行支持(大作或者主机移植的游戏基本才有)。因此Steam Deck提供了我见到主机最多的手柄按键,以及每个游戏级别的映射布局。

进入游戏后按Steam键就能看到“控制器设置”,里面可以看到每一个控制器(比如我电视玩的时候用PS5手柄,就能看到两排)的布局。这里可以选择基本就是:

  1. 键鼠(基本就是右摇杆鼠标,L2左键 R2右键,左摇杆对应WASD,触摸板也是鼠标)
  2. 手柄(指的是游戏开发利用了SteamInput,XInput,DirectInput等原生控制器支持)
  3. 支持视角控制的手柄(区别是右触摸板可以控制精确视角,FPS游戏多,需要游戏支持)

当然,这里还可以切换到社区布局,下载其他人的布局映射文件,热门游戏都有很好的布局文件。另外这个配置会和你的Steam账户云保存起来,用PC加的配置也能用跨机器用),挺好的

另外,非Steam游戏(无论是通过Proton转译层的游戏,还是Chrome和Dolphin这种原生Linux),也可以进行映射,甚至能根据游戏名(可以改,下提)共享社区映射,这也是另一个我认为SteamOS比Windows好用的地方。

添加非Steam游戏

我们可以把桌面模式的应用添加到Steam吗?当然可以,比如SteamOS就会引导你添加Chrome到库里,这下真成了iPad之外的便携浏览器了。你可以添加各种工具,甚至VSCode,也有专门人分享的布局文件方便手柄操作这些软件😂

以及,在有时候我们不得已要用非Steam的Windows程序,如游戏启动器、汉化补丁、“学习版游戏”:),这时候我们肯定要Proton转译层,从命令行直接调用非常复杂我也懒得去看,利用把exe可执行程序添加到Steam库中,我们就能直接选择Proton转译并且享受各种便利(包括手柄映射,分辨率输出调整,截图管理,Shadercache等)

按照官方说明,添加方式需要进入到桌面模式(不理解为啥游戏模式没有入口),右键库选择“添加非Steam游戏到我的库中”,默认会显示桌面模式安装的原生Linux应用列表,我们不管,选择新增路径。

此时弹出的文件管理器中,可以选择不同硬盘的程序,比如机身里的(/home/deck/下),TF卡里的(/run/media/deck/TF卡序号/),无论是EXFAT还是ntfs文件系统的都能添加(用这个可以实现SteamOS和Windows双系统共享一个游戏,进度靠Steam云存档)。注意下方扩展名类型要选为”All”不然无法显示exe可执行程序

路径选择完成后再点一次“添加所选程序”就可以在库里看到了,默认游戏名是exe可执行程序文件名,右键属性可以改名一下,然后选择兼容层选择最新版本的Proton(还有一个Proton Experimental不过我没用过),双击测试执行效果即可。

Proton兼容层

Proton兼容层是基于Wine的改进项目,虽然开源且在任意Linux上可用,但是Steam Deck用的版本有Value内部定制,对游戏的兼容性,以及执行速度有着非常明显的优势(以后就不用Wine了)😂

其中,Steam Deck有自己的一个Deck Verified标签,分为四档,其实一般来说除了不支持的档位以外都可以认为是可玩的。

另外,Deck Verified是有时效性的,有些游戏可能最新的SteamOS更新后就能正常运行,但是依然显示不支持,这种情况可以借助社区提供的:ProtonDB网页,查看其他玩家上传的实际体验(不过有些可能不是Steam Deck用户而是Linux PC+开源Proton用户,不可全信)

当然,实际上我买游戏时也不怎么看Deck Verified,实际能不能跑下载下来测一下便知(目前我库里不能跑的是2/80,极少),反正Steam不像主机厂商,游戏2小时内可以无条件退款,因此自己测试自己的库里的游戏才是最可靠的。

游戏转移到存储卡

SteamOS在游戏模式下的设置-存储,可以看到当前的存储设备(机身存储+存储卡),以及其对应的游戏列表。默认插入存储卡SteamOS会推荐你格式化为ext4文件系统(但并不意味着SteamOS只识别ext4,btrfs,ntfs都支持)。

有一个坑是,在游戏模式下我们不能添加存储卡,我们只能进入桌面模式,右键Steam选择设置->下载->内容库,此时会打开一个和游戏模式页面一模一样的UI,除了多了一个加号(离谱)。

点击加号会让你选择需要的路径来当作新的Steam库路径,一般我们直接选择存储卡的路径(/run/media/deck/存储卡ID)就行。之后选择游戏选择移动就行。

接下来,为了设置启动时存储卡自动挂载,进入到桌面模式右键右下角状态栏,选择“Configure Removable Devices”,开启“mount at login“和”mount when attached“,就能开机自动挂载存储卡而不需要手动进入桌面模式选择了。

说明一个坑(Bug?):我的TF存储卡在后续制作Win To Go双系统时,格式化了为ntfs文件系统,虽然SteamOS有着Linux Kernel级别的ntfs支持了,但是偶现启动后会不以read-write挂载ntfs分区,而是以read-only挂载,会导致这里存储消失,与此同时你也不能在文件管理器里给TF卡写入任何内容。更新:经过大佬提示,发现需要在Windows 11中关闭快速启动(参考:How to Disable Fast Startup on Windows 10?),否则会导致无法read-write挂载NTFS存储卡。

出现这种状况后,你需要手动进入到桌面模式,右键状态栏的存储管理,重新Unmount再Mount一次,才可恢复正常,复现概率随机。只能说希望Valve尽快修复这类问题🙏

疑难解答

定期清理Shadercache/Compatdata

这两个是SteamOS的存储容量“其他”的罪魁祸首。

Steam游戏默认配置会开启Shadercache,因为Steam Deck硬件配置的唯一性,基本你安装所有的游戏,都会提前下载好离线编译好的Shader,不再需要运行时编译,大大减少游戏第一次加载和场景卡顿。甚至非Steam游戏也会把运行时转译编译的Shader缓存起来,这项功能是全局生效的(不同于Windows下,需要游戏厂商支持)

Shadercache路径在/home/deck/.local/share/Steam/steamapps/shadercache下(这是Steam客户端根路径)

而Compatdata,是Proton游戏兼容层产生的文件夹,又称pfx,其本质是一个沙盒文件夹。Proton因为是模拟Windows的运行环境,其背后会做一个精简的Windows目录树(纯净大小约为180MB),分配给这个游戏,这个游戏对Windows系统的所有修改都只在这个沙盒中生效,包括注册表,存档文件,甚至可能是恶意破坏删除文件(非常Nice),也不会影响其他游戏。

在卸载游戏后,Shardarcache会被自动清理,没什么问题(注意自己添加的非Steam游戏不会清理,需要手动清理!)。但是有一个神奇的Bug是,这个Compatdata在卸载游戏后,竟然不会自动删除(无论是Steam游戏还是非Steam游戏),继续占用磁盘(180MB每个),挺膈应的。

对这两个文件,我找到了一个作者写的好用的工具Steam Deck: Shader Cache Killer来解决这个问题

使用也很简单,按照说明下载好以后会自动添加两个快捷方式到库里,打开zShaderCacheKiller.sh就能看到所有的Shadercache/compatdata目录和对应游戏的名称,AppID信息,可筛选Non-steam和Uninstalled游戏。然后选择delete就行。

它还有另一个工具zShaderCacheMover.sh,能把这两个文件夹移动到存储卡上而不是机身存储,对64GB小机身存储用户很重要(不会真有64G的不换硬盘吧)

值得注意的是,目前这个工具对非Steam游戏的名称识别并不好,必须你最近启动过这个游戏一次,才可以在列表显示(看代码是通过读了$STEAM/logs/content_log.txt日志解析的,但是这个日志会定时清理……)。原因是非Steam游戏的AppID是根据“游戏名”+“路径名”的哈希得到,所以不能反推出原游戏名和路径名。

为了防止错误删除了正在玩的沙盒(包括游戏存档),简单傻瓜做法就是定期清理,记录已安装的这些非Steam游戏的AppID(或者无脑就是每次执行清理前先手动启动一次后再清理);或者你也可以全部运行一次当前库里所有非Steam游戏,不能识别的AppID自然是已经被卸载的。

修改游戏启动路径以及自定义启动参数

Steam商店的游戏都有一个专门的元信息,其可以在SteamDB的Information和Configuration下看到,包括

  1. 显示的游戏名
  2. 发行商信息
  3. 发行日期
  4. 启动入口(如启动的exe可执行程序路径,默认参数是什么)

其中我最近就遇到了一个问题,是这个游戏有一个自己的启动器(可能是C#写的),但是Steam游戏模式下,启动器无法弹出而卡住,只有桌面模式能弹出。我想跳过直接执行另一个实际游戏的exe可执行程序(与启动器搏斗和折腾……)

找了一圈,改名符号链接在Proton下也有兼容问题,最终还是简单粗暴,利用这个Steam Metadata Editor,可以直接修改库里面游戏的启动入口,包括启动的路径,默认参数等。

此外,它还可以添加多个启动入口,比如有些游戏包括类似游戏本体,启动器,创意工坊Mod编辑器,DLC章节啥的,可以添加不同的入口,方便管理,也不用自己手动对每个exe改成非Steam游戏添加入库。

注意这个工具建议在桌面模式用,不要在Steam客户端启动时修改保存(提前右键退出),才可以生效。

安装Windows

前面说了那么多都是SteamOS的用法,但是毕竟Steam Deck本质是一台Portable PC,那么它当然可以安装Windows(官方声明支持Windows 10/11)

虽然Windows系统我从大学就没再用过了(基本只有Mac+主机),但是按照网上的教程摸索差不多实现了从TF卡启动Windows,可以用来逼不得已的情况下跑一些只能在Windows上的软件(Proton兼容失效或者性能有异常的情况)

Win To Go制作

Win To Go制作工具,Windows自带的工具限制必须是“硬盘而非可移动存储设备”才行,比较麻烦还得用DiskGenius改分区,我参考教程用了这个WTG辅助工具,下载好Windows 11的镜像,一键写入到TF卡中

从TF卡启动并安装Windows

在写好Win To Go到TF卡之后,插入TF卡关机,然后同时长按音量减键和电源键,听到响声后放手,就会进入到启动选项页面。

在Boot Manager中选择”EFI SD/MMD Card”,然后就会进入Windows安装的引导流程了,后面就按照网上常见Windows安装流程走。注意家庭版是需要微软账号和联网的,默认Windows 11有网卡驱动,Windows 10没有,所以建议用专业版😂

安装完成后,会发现默认不再进入SteamOS了,继续进入启动选项页面,这里会出现一个”Windows Boot Manager”在首位,实际上我们Win To Go选择TF卡依旧也能启动,和他没啥关系,可以删除(参考后文“如何默认启动SteamOS而非Windows”)。

4

Windows驱动

Steam官方提供了Windows的驱动,可以直接下载好并拷贝到Windows中安装,参考:Steam Deck - Windows Resources

Windows 11上,对inf格式驱动,需要右键并选择“显示更多选项”以查看“安装”选项。

疑难解答

如何默认启动SteamOS而非Windows

安装Windows之后默认不再会进入SteamOS了,而SteamOS也没提供默认启动选项的能力(这个EFI引导做得比较垃圾),并且一个神奇的Bug,导致无论怎么改默认值,只要启动过一次Windows系统,”Windows Boot Manager”就会默认跑到第一位导致下一次永远默认Windows

我选择的方式是,直接通过禁用”Windows Boot Manager”这个EFI启动项,让SteamOS默认启动,如有需要,长按进入并选择”EFI SD/MMD Card”以启动Windows

具体修改方式可以在SteamOS也可以在Windows下操作,本质都是修改EFI的配置信息,以SteamOS举例子:

  1. 打开Konsole
  2. 输入efibootmgr,会列举查看到对应每个启动选项的数字编号,如Boot0004 *Windows Boot Manager
  3. 输入sudo efibootmgr -b 0004 -A,注意如果没有设置Root密码需要提前用passwd设置一次
  4. 再次输入efibootmgr,此时会不再显示代表激活状态的*,如Boot0004 Windows Boot Manager

重启可以验证一下,启动列表不再显示这个即可证明成功,默认进SteamOS或者选择EFI SD/MMD Card进入Windows

总结

其实感觉对于主机玩家来说,Steam Deck的一大缺点,同时也是一大优点就是“可折腾”(Hackable),你总有一种方式,能在掌上玩到PC上的游戏。而对于主机本身配置就很少。Steam Deck它在我看来和Switch压根不是一个竞争对手,而更像是互补。

我能拿着Steam Deck去把打折时买的Roguelike游戏和文字游戏打通,插在电视上跑着40帧带有创意工坊Mod的AAA大作(比如FF7 RE笑),但是绝对不会在Switch上高价买这些PC键鼠设计且没有社区和创意工坊的游戏。

它更像是一个夹在PS5这种沉浸式沙发体验,和Switch掌机轻度娱乐和聚会游戏之间的设备,并且对纯Mac党(没有任何其他Windows设备)是一个非常好的替代品。

就这么多吧,这篇文章大部分都是介绍自己实际遇到的一些问题和教程,希望能对有心入Steam Deck或者遇到类似场景的人提供一些帮助吧:)

探秘越来越复杂的ImageIO框架

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私信等方式联系我。

Xcode LLDB耗时监控统计方案

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动DanceCC工具链系列之Xcode LLDB耗时监控统计方案》

背景介绍

《Swift 调试性能的优化方案 》一文中,我们介绍了如何使用自定义的工具链,来针对性优化调试器的性能,解决大型Swift项目的调试痛点。

在经过内部项目的接入以及一段时间的试用之后,为了精确测量经过优化后的LLDB调试Xcode项目效率提升效果,衡量项目收益,需要开发一套能够同时获取Xcode官方工具链与DanceCC工具链调试耗时的耗时监控方案。

一般来说,LLDB内置的工作耗时,可以通过输入log timers dump来获取粗略的累计耗时,但是这个耗时只包括了源代码中插入了LLDB_SCOPED_TIMER()宏的函数,并不代表完整的真实耗时。并且这个耗时统计需要用户手动触发,如果要单独获取某次操作的耗时还需要先进行reset操作清空之前的耗时记录;对于我们目前的需求而言不够精确也不够自动。

因此DanceCC提出了一套专门的方案。方案原理基于LLDB Plugin,利用Fishhook,从LLDB的Script Bridge API层面拦截Xcode对LLDB调用,以此来进行耗时监控统计。

注:LLDB论坛也有贡献者,讨论另一套内置的LLDB metries方案,但是目标侧重点和我们略有不同,并且截至发稿日未有完整的结论,因此仅在引用链接提及供读者延伸阅读。

方案原理

LLDB Plugin

Apple在其LLDB和早期Xcode集成中,为了不侵入一些容易改动的上层逻辑,引入了LLDB Plugin的设计和支持。

每个Plugin是一个动态链接库,需要实现特定的C++/C入口函数,由LLDB主进程在运行时通过dladdr找到函数入口并加载进内存。目前有两种Plugin的接口形式(网上常见第一种)

  • 新Plugin接口:
1
2
3
namespace lldb {
bool PluginInitialize(SBDebugger debugger);
}

这种Plugin,需要用户在脚本中手动按需加载,并常驻在内存中:
plugin load /path/to/plugin.dylib

  • 老Plugin接口:
1
2
extern "C" bool LLDBPluginInitialize(void);
extern "C" void LLDBPluginTerminate(void);

将编译的动态库放入以下两个目录,即可自动被加载,无法手动控制时机,在当前调试Session结束时卸载:

1
2
/path/to/LLDB.framework/Resources/Plugins
~/Library/Application Support/LLDB/PlugIns

注入动态库

1

正常流程中,Xcode开始调试时会启动一个lldb-rpc-server的进程,这个进程会加载Xcode默认工具链,或指定工具链中的LLDB.framework,并且通过这个动态库中暴露出的Script Bridge API调用LLDB的各功能。

2

监控流程中,我们向lldbinit文件中添加了command script import ~/.dancecc/dancecc_lldb.py,用于在LLDB启动时加载脚本,脚本内会执行plugin load ~/.dancecc/libLLDBStatistics.dylib,加载监控动态库。

监控动态库在被加载时,因为被加载的动态库和LLDB.framework不在一个MachO Image中,我们能够通过Fishhook方案,对LLDB.framework暴露出的我们关心的Script Bridge API进行hook。

hook成功之后,每次Xcode对Script Bridge API进行调用都会先进入我们的监控逻辑。此时我们记录时间戳来计时,然后再进入LLDB.framework中的逻辑,获取结果后返回给lldb-rpc-server,并在Xcode的GUI中展示。

Hook SB API

Hook SB API时,需要一份含有要部署的LLDB.framework的头文件(Xcode并未内置)。由于上述的流程使用了动态链接的LLDB.framework,我们选择了Swift 5.6的产物,并tbd化避免仓库膨胀。

由于LLDB Script Bridge API相对稳定,因此可以使用一个动态库实现,通过运行时来应对不同版本的API变化(极少出现,截止发文调研5.5~5.7之间Xcode并没有改变调用接口)。

对于hook C++函数的方式,这里借用了Fishhook进行替换。原C++的函数地址,可通过dlsym调用得到。注意C++函数名使用mangled后的名称(在tbd文件中可找到)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
///
/// Hook a SB API using the stub method defined with the macros above
///
#define LLDB_HOOK_METHOD(MANGLED, CLASS, METHOD) \
Logger::Log("Hook "#CLASS"::"#METHOD" started!"); \
ptr_##MANGLED.pvoid = dlsym(RTLD_DEFAULT, #MANGLED); \
if (!ptr_##MANGLED.pvoid) { \
Logger::Log(dlerror()); \
return; \
} \
if (rebind_symbols((struct rebinding[1]){{#MANGLED, (void *) hook_##MANGLED, (void **) & ptr_##MANGLED.pvoid }}, 1) < 0) { \
Logger::Log(dlerror()); \
return; \
} \
Logger::Log("Hook "#CLASS"::"#METHOD" succeed!");

C++的成员函数的函数指针第一个应该是this指针,这里用self命名。也可以调用原实现先获取结果,再根据结果进行相关的统计逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
///
/// Call the original implementation for member function
///
#define LLDB_CALL_HOOKED_METHOD(MANGLED, SELF, ...) (SELF->*(ptr_##MANGLED.pmember))(__VA_ARGS__)
最终整体代码中Hook一个API就可以写为:
// 假设期望Hook方法为:char * ClassA::MethodB(int foo, double bar)
// 这里写被Hook的方法实现
LLDB_GEN_HOOKED_METHOD(mangled, char *, ClassA, MethodB, int foo, double bar) {
return LLDB_CALL_HOOKED_METHOD(mangled, self, 1, 2.0);
}
// 这里是执行Hook(只执行一次)
LLDB_HOOK_METHOD(mangled, ClassA, MethodB);

耗时监控场景

目前耗时监控包含下列场景:

  • 展示frame变量
  • 展开变量的子变量
  • 输入expr命令(p, po命令也是expr命令的alias)
  • Attach进程耗时
  • Launch进程耗时

展示frame变量场景

经过观察,我们发现当在Xcode中进入断点,GUI显示当前frame的变量时,lldb-rpc-server调用SB API的流程为先调用SBFrame::GetVariables方法,返回一个表示当前frame中所有变量的SBValueList对象,然后再调用一系列方法获取它们的详细信息,最后调用SBListener::GetNextEvent等待下一个event出现。

因此我们计算展示frame变量的流程为,当SBFrame::GetVariables方法被调用时记录当前时间戳,等待直至SBListener::GetNextEvent方法被调用,再记录此时时间戳算出耗时。

展示子变量场景

经过观察,我们发现当在Xcode中展开变量,需要显示当前变量的子变量时,lldb-rpc-server调用SB API的流程为先调用SBValue::GetNumChildren方法,返回表示当前变量中子变量的数目,然后再调用SBValue::GetChildAtIndex获取这些子变量以及它们的的详细信息,最后调用SBListener::GetNextEvent等待下一个event出现。

因此我们计算展示frame变量的流程为,当SBValue::GetNumChildren方法被调用时记录当前时间戳,等待直至SBListener::GetNextEvent方法被调用,再记录此时时间戳算出耗时。

输入expr命令场景

Xcode中用户直接从debug console中输入LLDB命令的方式是不走SB API的,因此无法直接通过hook的方式获取耗时。我们发现大多数开发者,都习惯在debug console中使用po/expr等命令而不是GUI点击输入框。因此我们专门做了支持,通过SB API的OverrideCallback方法进行了拦截。

LLDB.framework暴露了一个用于注册在LLDB命令前调用自定义callback的接口:SBCommandInterpreter::SetCommandOverrideCallback;我们利用了这个接口注册了一个用于拦截并获取用户输入命令的callback函数,这个callback会记录当前耗时,然后调用SBDebugger::HandleCommand来处理用户输入的命令。但是当SBDebugger::HandleCommand被调用时,我们注册的callback一样会生效,并再次进入我们拦截的callback流程中。

为了解决这个递归调用自己的问题,我们通过一个static bool isTrapped变量表示当前进入的expr命令是否被OverrideCallback拦截过。如果未被拦截,将isTrapped置true表示expr命令已经被拦截,则调用HandleCommand方法重新处理expr命令,此时进入的HandleCommand方法同样会被OverrideCallback拦截到,但是此时isTrapped已经被置true,因此callback返回false不再进入拦截分支,而是走原有逻辑正常执行expr命令
3

Attach进程场景

Attach进程时,lldb-rpc-server会调用SBTarget::Attach方法,常见于真机调试的场景。
这里在调用前后记录时间戳,计算出耗时即可。

Launch进程场景

Launch进程时,lldb-rpc-server会调用SBTarget::Launch方法,常见于模拟器启动并调试的场景。
这里在调用前后记录时间戳,计算出耗时即可。

上报部分

数据上报

为了进一步还原耗时的细节,除了标记场景的类型以外,我们还会统一记录这些非敏感信息:

  • 正在调试的进程名,用于区分多调试Session并存的场景
  • 正在调试的App的Bundle ID
  • 当前断点位置在哪个文件
  • 当前断点位置在哪一行
  • 当前断点位置在哪个函数
  • 当前断点位置在哪个Module
  • 表示当前使用的工具链是Xcode的还是DanceCC的
  • 表示当前使用的Swift版本(与Xcode版本一一对应)

在内网提供的版本中,也通过外部环境变量,得知对应的App的仓库标识,用于在内网的数据统计平台上展示和区分。

如图,这是内网大型Swift工程,飞书iOS App接入DanceCC工具链之后,某时间的耗时数据,可以明显看出,DanceCC相比于Xcode的变量显示耗时,优化了接近一个数量级。
4
5

极端耗时场景堆栈收集

除了基本的耗时时间收集以外,我们还希望能够及时发现新增的极端耗时场景和新问题,因此设计了一套极端耗时情况下的调试器堆栈收集机制,目前只要发现,展示变量场景和输入expr命令耗时超过10秒种,则会记录LLDB.framework的当前调用堆栈的每个函数耗时,并将数据上报到后台进行统计和人工分析。

堆栈收集使用了log timers dump所产出的堆栈和耗时信息,本质上是LLDB代码中通过LLDB_SCOPED_TIMER()宏记录的函数,其会使用编译器的__PRETTY_FUNCTION__能力来在运行时得到一个用于人类可读的函数名。

在获取到调用前和调用后的两条堆栈后,我们会对每个函数进行Diff计算和排序,将最耗时的前10条进行了采样记录,使用字符串一同上传到统计后台中。
6

总结

无论是App还是工具链,在做性能优化的同时,数据指标建设是必不可少的。这篇文章讲述的监控方案,在后续迭代DanceCC工具链的时候,能够明确相关的优化对实际的调试体验有所帮助,能避免了主观和片面的测试来评估调试器的可用性。

除了调试器之外,DanceCC工具链还包括诸如链接器,编译器,LLVM子工具(如dsymutil)等相关优化,系列文章也会进一步进行相关的分享,敬请期待。

引用链接

  1. https://mp.weixin.qq.com/s/MTt3Igy7fu7hU0ooE8vZog
  2. https://reviews.llvm.org/rG4272cc7d4c1e1a8cb39595cfe691e2d6985f7161
  3. https://github.com/facebook/fishhook
  4. https://lldb.llvm.org/design/sbapi.html
  5. https://discourse.llvm.org/t/rfc-lldb-telemetry-metrics/64588

Swift 调试性能的优化方案

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动DanceCC工具链系列之Swift调试性能的优化方案》

原作者是我自己(李卓立 @dreampiggy)而非抄袭,这里在个人博客同时转发一下,去掉了招聘相关文案。不过依旧欢迎大家有兴趣的有志之士加入。

背景

通常来说,大型Swift项目常含有大量混编(Objc/C/C++甚至是Rust)代码,含有超过100个以上的Swift Module,并可能同时包含二进制部分和源码部分。而这种大型项目在目前的Xcode 13体验下非常不好,经常存在类似“断点陷入后变量面板卡顿转菊花”、“显示变量失效”等问题。而且一直存在于多个历史Xcode版本。

图1:Xcode变量区显示卡顿转菊花,测试使用Xcode 13.3和下文提到的复现Demo

这部分Apple Team迟迟不优化的原因在于,Apple公司的内部项目和外部项目开发模式的巨大差异。Apple内部产品,如系统应用,系统库,会直接内嵌到iOS固件中,并直接受益于dyld shared cache(参考WWDC 2017-App Startup Time: Past, Present, and Future[1])来提升加载速度。这意味着他们通常会将一个App,拆分为一个薄的主二进制,搭载以相当多的动态链接库(Dynamic Framework),以及插件(PlugIn)的模式来进行开发。

举个例子,我们以iOS的消息App(MobileSMS.app)为例子,使用iOS 15.4模拟器测试。可以看到其主二进制大小仅有844KB(x86_64架构)。通过otool -L查询链接,可以看到总计动态链接了22个动态链接库,其中有9个是非公开的,大都是支撑消息App的功能库,这些库占据了大量存储。

图2:消息App的动态链接库列表

而iOS平台的第三方开发者的工程,为了追求更快的冷启动时长,由于没有了dyld shared cache的优化(dyld 3提出的启动闭包只能优化非冷启动),很多项目会使用尽量少的动态链接库。加之开源社区的CocoaPods,Carthage,SwiftPM等包管理器的盛行导致的Swift Module爆炸增长,预二进制的Framework/XCFramework包装格式的滥用,加之闭源三方公司的SDK的集成,最终形成了一个无论是体积还是符号量都非常巨大的主二进制,以及相当长的Search Paths。

以公司内飞书应用的内测版为例子,在使用Debug,Onone模式编译,不剥离(Strip)任何符号情况下,可以看到其主二进制大小为1.1GB,动态链接库数量为105,但是仅包含Apple的系统库和Swift标准库。业务代码以静态链接库集成。

图3:公司飞书应用的动态链接库列表

上述这两种不同的工程结构,带来了非常显著的调试体验的差异,并且Apple公司近年来的Xcode Team和Debugger Team优化,并没有完全考虑部分第三方开发者常使用的,厚主二进制下的工程结构。

PS:理论上可以通过业务的工程结构的改造,在本地开发模式下,使用一个动态链接库包裹基础静态链接库的方式,减少主二进制大小(也会减少后续提到的DWARF搜索的耗时),但是大型项目推进工程结构的改造会是一个非常漫长的过程。

图4:一种减少主二进制大小的工程结构设计

解决方案:自定义LLDB工具链

经过调研,我们发现业界常见做法,无外乎这几种思路:

  1. 工程改造:缩减Swift Module/Search Path数量:可行,但是收益较低,且不可能无限制缩减
  2. 通过LLDB一些开关:可行,但是内部测试下依旧达不到理想的调试状态

我们致力于在字节跳动的移动端提供基础能力支持,因此提出了一套解决方案,不依赖业务工程结构的改造,而是从LLDB工具链上入手,提供定向的调试性能优化。

调研期间也确认到,借助自定义LLDB工具链,集成到Xcode IDE是完全可行的,包括iPhone模拟器、真机以及Mac应用。

图5:自定义LLDB工具链的文件结构,系列后续文章会单独讲解,这里不展开

而LLVM/LLDB本身的工具链代码,在Apple的开源范畴之内(仓库地址:https://github.com/apple/llvm-project) 通过严格追踪跟进上游的发布历史,分支模型,能够尽可能地保证工具链的代码和功能的一致性。

实际收益

经过后文提到的一系列优化手段,以公司内大型项目飞书测试,编译器采取Swift 5.6,Xcode选择13.3为例,对比调试性能:

项目 Xcode 13.3 自定义LLDB
v耗时 2分钟 40秒
po耗时 1分钟 5秒
p耗时 20秒 5秒

图6:切换自定义LLDB工具链

图7:调试优化演示,使用Xcode 13.3自定义LLDB,运行文中提到的耗时Demo(原po耗时约1分钟):

简述po/p/v的工作流程

在介绍我们自定义LLDB工具链的优化之前,首先来简述一下LLDB的核心调试场景的工作流程,方便后续理解优化的技术点。

我们一期的目的是主要优化核心的调试场景,包括最常见的“断点陷入到Xcode左侧变量区展示完毕”(v),“点击Show Description”(po),“勾选Show Types”(p)。这些对应LLDB原生的下面三个交互命令。

图8:LLDB的交互命令

Apple在WWDC 2019-LLDB: Beyond “po”[2]中,进行了较为详细的介绍,这里我们进一步详细解释其部分工作流程,为后文的具体优化技术点提供参考。建议可以搭配视频一并学习。

po [expr]

po是命令expression --object-description -- [expr]的alias

图9:po的流程

  1. 使用Swift编译器编译result = expr得到IR
1
2
3
4
// 精简版,实际较为复杂,源代码搜@LLDBDebuggerFunction关键字
func __lldb_expr() {
__lldb_result = expr
}
  1. 执行IR代码

    1. 在支持JIT的平台上使用JIT,不支持则使用LLVM的IRInterpreter
  2. 获取执行结果

  3. 使用Swift编译器编译result.description

    1. 实际上LLDB调用的是Swift标准库的私有方法:_DebuggerSupport.stringForPrintObject[3]
  4. 执行IR代码

  5. 获取执行结果字符串

  6. 对得到的字符串进行格式化输出

p [expr]

p是命令expression -- [expr]的alias

图10:p的流程

  1. 使用Swift编译器编译result = expr得到IR

  2. 执行IR代码

  3. 获取执行结果

  4. result进行Dynamic Type Resolve

    1. 利用Swift编译器提供的remoteAST,拥有源码的AST之后,会根据内存布局直接读取对象细节
    2. 也会利用Swift Reflection,即Mirror来进行读取,和remoteAST二选一
  5. 对得到的对象细节进行格式化输出

对比下来可以看到,po和p的最大不同点,在于表达式执行的结果,如何获取变量的描述这一点上。po会直接利用运行时的object description(支持CustomDebugStringConvertible[4]协议)拿到的字符串直接展示,并不真正了解对象细节。

图11:获取Object Description的实现细节(SwiftLanguageRuntime.cpp)

而p使用了Swift Runtime(Objc的话就是ISA,Method List那些,资料很多不赘述),拿到了对象细节(支持CustomReflectable[5]协议),进行按层遍历打印。不过值得注意的是,Swift Runtime依赖remoteAST(需要源码AST,即swiftmodule)或者Reflection(可能被Strip掉,并不一定有),意味着它强绑定了,编译时的Swift版本和调试时的LLDB的版本(牢记这一点)。并不像Objc那样有一个成熟稳定运行时,不依赖编译器也能动态得知任意的对象细节。

图12:Swift Dynamic Type Resolve的实现(SwiftLanguageRuntimeDynamicTypeResolution.cpp)

v [expr]

v是命令frame variable [expr]的alias

图13:v的流程

  1. 获取程序运行状态(寄存器/内存等)
  2. 递归开始
  3. 解释expr的每一层访问(->或者.),得知当前变量的内存布局
  4. 对当前变量进行Dynamic Type Resolve
  5. 递归结束
  6. 对得到的对象细节格式化输出

v的特点在于全程没有注入任何代码到程序中,也就是它是理论无副作用的。它的expr只支持访问对象的表达式(->/.等),不支持函数调用,并不是真正的C++/C/OC/Swift语法。

优化v

下述所有说明基于发稿日的Swift 5.6(优化思路也适配Swift 5.5)说明优化方案,后续不排除Apple或者LLVM上游进行其他优化替代,具有一定时效性。

(暂时)关闭swift-typeref-system

  • 关闭方式
1
settings set symbols.use-swift-typeref-typesystem false
  • 开关说明

Prefer Swift Remote Mirrors over Remote AST

这里的remoteAST和Swift Mirror的概念,上文介绍过,不同方案会影响Swift的Dynamic Type Resolve的性能。

经过实测,关闭之后,内部项目的复杂场景下,断点陷入耗时从原本的2分20秒,缩减为1分钟。这部分开关,目前已经通过Xcode自定义的LLDBInit[6]文件,在多个项目中设置。

注:和Apple同事沟通后,swift-typeref-typesystem是团队20年提出的新方案,目前有一些已知的性能问题,但是对Swift变量和类型展示有更好的兼容性。关闭以后会导致诸如,typealias的变量在p/v时展示会有差异,比如TimeInterval(alias为__C.Double)等。待Apple后续优化之后,建议恢复开启状态。

修复静态链接库错误地使用dlopen(Fixed in Swift 5.7)

简述问题:LLDB在SwiftASTContext::LoadOneModule时假设所有framework包装格式都是动态链接库,忽略了静态链接库的可能性。

在调试测试工程中,我们追踪日志发现,LLDB会尝试使用dlopen去加载静态链接库(Static Framework),这是很不符合预期的一点,因为对一个静态链接库进行dlopen是必定失败的,如日志所示(使用下文提到的复现Demo):

1
2
3
4
SwiftASTContextForExpressions::LoadOneModule() -- Couldn't import module AAStub: Failed to load linked library AAStub of module AAStub - errors:
Looking for "@rpath/AAStub.framework/AAStub", error: dlopen failed for unknown reasons.
Failed to find framework for "AAStub" looking along paths:
// ...

查看代码阅读发现,这里触发的时机是,LLDB在执行Swift变量Dynamic Type Resolve之前,因为需要激活remoteAST,需要加载源码对应的swiftmodule到内存中。

swiftmodule是编译器序列化的包含了AST的LLVM Bitcode[7]。除了AST之外,还有很多Metadata,如编译器版本,编译时刻的参数,Search Paths等(通过编译器参数-serialize-debugging-options记录)。另外,对Swift代码中出现的import语句,也会记录一条加载模块依赖。而主二进制在编译时会记录所有子模块的递归依赖。

LLDB在进行加载模块依赖时,会根据编译器得到的Search Paths,拼接上当前的Module Name,然后遍历进行dlopen。涉及较高的时间开销:N个Module,M个Search Path,复杂度O(NxM)(内部项目为400x1000数量级)。而在执行前。并未检测当前被加载的路径是否真正是一个动态链接库,最终产生了这个错误的开销。

  • 修复方案

我们的修复方案一期是进行了一次File Signature判定,只对动态链接库进行dlopen,在内部工程测试(约总计1000个Framework Search Path,400个Module)情况下,一举可以减少大约1分钟的额外开销。

  • 复现Demo

仓库地址:https://github.com/PRESIDENT810/slowDebugTest

这个Demo构造了100个Swift Static Framework,每个Module有100个编译单元,以此模拟复杂场景。

后文的一些测试数据优化,会反复提及这个Demo对比。

注:和Apple的同事沟通后,发现可以在上层进行来源区分:只有通过expression import UIKit这种用户交互输入的Module会进行dlopen检查,以支持调试期间注入外部动态库;其他情况统一不执行,因为这些模块的符号必然已经在当前被调试进程的内存中了。

Apple修复的PR:https://github.com/apple/llvm-project/pull/4077 预计在Swift 5.7上车

优化po/p

(暂时)关闭swift-dwarfimporter

  • 关闭方式
1
settings set symbols.use-swift-dwarfimporter false
  • 开关说明
1
Reconstruct Clang module dependencies from DWARF when debugging Swift code

这个开关的作用是,在开启情况下,Swift编译器遇到clang type(如C/C++/Objc)导入到Swift时,允许通过一个自定义代理实现,来从DWARF中读取类型信息,而不是借助编译器使用clang precompiled module[8],即pcm,以及ClangImporter导入桥接类型。

切换以后可能部分clang type的类型解析并不会很精确(比如Apple系统库的那种overlay framework,用原生Swift类型覆盖了同名C类型),但是能稍微加速解析速度,这是因为clang pcm和DWARF的解析实现差异。

禁用之后,对内部项目测试工程部分场景有正向提升约10秒,如果遇到问题建议保持默认的true。

优化External Module的查找路径逻辑

在混编工程中,Swift Module依赖一个C/OC的clang module是非常常见的事情。在这种情况下,LLDB需要同时使用编译器,加载到对应的clang module到内存中,用于进行C/OC Type到Swift Type的导入逻辑。

但是实际情况下,我们可能有一些Swift混编产物,是预二进制的产物,在非当前机器中进行的编译。这种情况下,对应编译器记录的的External Module的路径很可能是在当前机器找不到的。

LLDB的原始逻辑,会针对每一个可能的路径,分别由它的4种ObjectFile插件(为了支持不同的二进制格式)依次进行判断。每个ObjectFile插件会各自通过文件IO读取和解析Header。这是非常大的开销。

  • 优化方案

我们内部采取的策略比较激进,除了直接利用fstat进行前置的判断(而不是分别交给4个ObjectFile插件总计判断4次)外,还针对Mac机器的路径进行了一些特殊路径匹配规则,这里举个例子:

比如说,Mac电脑的编译产物绝对路径,一定是以/Users/${whoami}开头,所以我们可以先尝试获取当前调试器进程的uname(非常快且LLDB进程周期内不会变化),如果不匹配,说明编译产物一定不是在当前设备进行上产出的,直接跳过。

图14:特殊匹配规则,直接避免文件IO判定存在与否

通过这一项优化,在内部项目测试下(1000多个External Module路径,其中800+无效路径),可以减少首次变量显示v耗时约30秒。

增加共享的symbols缓存

我们使用内部项目进行性能Profile时,发现Module::FindTypesSymbolFile::FindTypes函数耗时调用占了主要的大头。这个函数的功能是通过DWARF(记录于Mach-O结构中),查找一个符号字符串是否包含在内。耗时主要是在需要进行一次性DWARF的解析,以及每次查找的section遍历。

LLDB本身是存在一个searched_symbol_files参数用来缓存,但是问题在于,这份缓存并不是存在于一个全局共享池中,而是在每个具体调用处的临时堆栈上。一旦调用方结束了调用,这份缓存会被直接丢弃。

图15:symbols缓存参数

  • 优化方案

我们在这里引入了一个共享的symbols缓存,保存了这份访问记录来避免多个不同调用方依然搜索到同一个符号,以空间换时间。实现方案比较简单。

内部工程实测,下来可以减少10-20秒的第一次访问开销,而每个symbol缓存占据字节约为8KB,一次调试周期约10万个符号占据800MB,对于Mac设备这种有虚拟内存的设备来说,内存压力不算很大。另外,也提供了关闭的开关。

优化不必要的同名symbols查找

另一项优化Module::FindTypesSymbolFile::FindTypes函数开销的方案是,原始的这两个函数会返回所有匹配到的列表,原因在于C++/Rust/Swift等支持重载的语言,会使用naming mangle来区分同一个函数名的不同类型的变种。这些符号名称会以同样的demangled name,记录到DWARF中。

但是调用方可能会关心同名类型的具体的变种(甚至包括是const还是非const),甚至有很多地方只取了第一个符号,搜索全部的Symbol File其实是一种浪费(在Swift 5.6版本中找到累积约10处调用只取了第一个)

  • 优化方案

我们对上述Module::FindTypesSymbolFile::FindTypes函数,提供了一个新的参数match_callback,用于提前过滤所需要的具体类型。类似于很多语言标准库提供sort函数中的stop参数。这样,如果只需要第一个找到的符号就可以提前终止搜索,而需要全部符号列表不受影响。

图16:symbols查找筛选参数

内部项目测试这项优化以后,可以减少C++/C/OC类型导入到Swift类型这种场景下,约5-10秒的第一次查找耗时。

其他优化

定向优化Dynamic Type Resolve的一些特例

在实际项目测试中,我们发现,Dynamic Type Resolve是有一些特例可以进行针对性的shortcut优化,剔除无用开销的。这部分优化仅对特定代码场景有效,并不通用。这里仅列举部分思路

  • 优化Core Foundation类型的Dynamic Type Resolve

Core Foundation类型(后文以CF类型指代),是Apple的诸多底层系统库的支撑。Objc的Founadtion的NS前缀的很多类型,也会Toll-Free Bridging[9]到CF类型上。而Swift也针对部分常用的CF类型支持了Briding。

CF类型的特点是,它内存布局类似Objc的Class ISA,但是又不是真正的Objc Class或者Swift imported Type,ISA固定是__NSCFType

而目前LLDB遇到在Swift堆栈中出现的CF类型,依旧把它当作标准的clang type进行C++/C那一套解析,还会递归寻找父类ivar,比较费时。我们可以利用这一特点提前判定而跳过无用的父类查找。

图17:筛选CF类型

这一项优化在特定场景(如使用CoreText和CoreVideo库和Swift混编)下,可以优化10-20秒的每次Dynamic Type Resolve耗时。

接下来

我们在之后会有一系列的相关话题,包括:

  • Xcode 13.3导致部分项目po提示Couldn’t realize type of self,有什么解决办法?
  • 如何极速构建,分发自定义LLVM/LLDB工具链,来让用户无缝部署?
  • 如何进行调试性能指标的监控和建设,包括Xcode原生的LLDB?

另外,这篇文章提到的非定制的优化和功能,均会向Apple或LLVM上游提交Patches,以回馈社区。

总结

这篇文章讲解了,大型Swift项目如何通过开关,以及自定义LLDB,优化Swift开发同学的调试速度,提高整体的研发效能。其中讲解了LLDB的部分工作流程,以及针对性优化的技术细节,以及实际效果。

我们的优化目标,不仅仅是服务于字节跳动移动端内部,更希望能推动业界的Swift和LLVM结合领域的相关发展,交流更多工具链方向的优化建设。

鸣谢

感谢飞书基础技术团队提供的一系列技术支持,以及最终业务试点提供的帮助推广。
感谢Apple同事Adrian Prantl在GitHub和邮件上进行的交流反馈,协助定位问题。

引用链接

  1. https://developer.apple.com/videos/play/wwdc2017/413/
  2. https://developer.apple.com/videos/play/wwdc2019/429/
  3. https://github.com/apple/swift/blob/release/5.6/stdlib/public/core/DebuggerSupport.swift#L242
  4. https://developer.apple.com/documentation/swift/customdebugstringconvertible
  5. https://developer.apple.com/documentation/swift/customreflectable
  6. https://lldb.llvm.org/man/lldb.html#configuration-files
  7. https://llvm.org/docs/BitCodeFormat.html
  8. https://clang.llvm.org/docs/Modules.html#id20
  9. https://developer.apple.com/library/archive/documentation/General/Conceptual/CocoaEncyclopedia/Toll-FreeBridgin/Toll-FreeBridgin.html

一段简单逆向之旅-绕开Xcode 13.3最低macOS 12.0限制

因为众所周知的原因,苹果的Xcode版本会不断提高自己的最低安装版本,在Xcode 13.0-13.2.1上,这个最低安装版本是macOS 11

而随着Xcode 13.3正式版放出,这个最低部署版本在最后关头被提升到了macOS 12

Why?

一般来说,各位开发者或者众多基建,总有各种各样的原因需要暂时留在老版本的macOS系统上,但是又希望使用新Xcode版本自带的Toolchain进行一些工作开发调试,有些是主观问题,有些是客观限制:

举例子:

  1. macOS 12禁止了sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off绕过GUI配置,导致一些公司采取Apple Device Management管理的电脑,无法正常关闭TCP拦截,会导致一些服务异常
  2. macOS 12加强了Kernel Extension的安全性,导致GitHub Action和Circle CI截止2022年3月底,迟迟无法更新他们的虚拟化集群到macOS 12,只有11.6的最新版本

这些都是闲聊,进入正题。那么有没有办法能够绕开,或者从原理上来讲,是否这个系统绑定的最低部署版本限制是必要的?

先放结论

  1. 可以绕过这个macOS 12的最低安装版本限制运行
  2. 这个限制是非必要的,绕过以后所有功能正常可用(构建,独立工具集,调试,连接iPhone)

下面来说明具体的逆向流程,和进行绕过的简单Step-by-step手法

安装Xcode

先说明测试机器Mac环境和Xcode环境:

  1. macOS 11.4 (20F71)
  2. Xcode Version 13.2 (13C90):需要保留一份以防万一
  3. Xcode Version 13.3 (13E113):目标安装的版本

首先,作为iOS/macOS开发者,我们肯定会使用dmg的格式,或者使用Xcodes.app来安装我们的Xcode 13.3了(App Store安装Xcode曾经出的坑:App Store version of Xcode 13.2 causing problems for developers,我是不会再用了)

安装完毕后,我们在Finder中看到的Xcode.app是一个画着❎的样子,直接打开会提示如下:

1648210752406_af87e2021743b9639d35f86714f127c7

绕过GUI部分的限制

修改LSMinimumSystemVersion

作为iOS/macOS开发者,我们第一想到的就是,是否是Xcode.app对应的Info.plist中,设置了和最低部署版本相关的字段导致拒绝载入呢?

我们用另一个Xcode(或者plistutil)打开Xcode.app/Contents/Info.plist,果然发现了对应的字段:

1648210752770_b314e1b23bdd01fe803ede1383e29491

这个LSMinimumSystemVersion是Mac应用标准的声明最低部署版本的方式,修改为你的机器当前OS版本之后保存,执行

1
2
touch /Applications/Xcode-13.3.0.app
killall Finder

重新尝试双击。不错,这次我们打开了,初看起来不错(直到我们正式开始编译)!

1648210752322_3d4e9ae78eaab94de9cfc7d1f8eaecf0

绕过CLI部分的限制

神奇的xcrun

但是只要创建一下工程并执行编译,就会发现,各种命令行工具的调用是有问题的,比如我们先通过xcode-select设置为当前的Xcode 13.2,尝试执行:

1648210752844_f21e111a081ff5dfc61dab7137109a39
1648210752266_e83287242af496cbbec7817b4b60f9f0

但是我们如果直接找到,执行对应绝对路径的clang,是可以执行的

1648210752227_fd8cd9f89407475fe62c0489dc61a232

并且,我们可以直接检查clang这个二进制,是否链接时设置了target,这部分可以使用otool -l读取machO Header查看到:

1648210752133_02c1257232efb1e2adebcb51ea25ceb0

好,最低部署版本是macOS 10.14.6;那现在我们有充分的证据说明,一定可以在我当前的电脑运行clang,而上述提示应该是xcrun这个调度器,添加了额外的判断。

通过搜索关键词,可以在Xcode的strings输出中找到这句“Executable requires at least”的关键字:参考仓库:Xcode.app-strings

反编译libxcodebuildLoader

我们定位到这个libxcodebuildLoader.dylib,拖进Hopper尝试反编译理解他检查的原理,伪代码如下:

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
62
63
64
65
66
67
68
69
70
void _checkMinimumOSVersion(int arg0) {
var_2C = 0x0;
rbx = 0x0;
rax = _NSGetExecutablePath(0x0, &var_2C);
rdi = var_2C;
if (rdi != 0x0) {
rbx = malloc(rdi);
}
rax = _NSGetExecutablePath(rbx, &var_2C);
if (rax != 0x0) goto loc_282c;

loc_26fc:
rax = [NSString stringWithUTF8String:rbx];
rax = [rax retain];
r14 = rax;
r15 = [[NSURL fileURLWithPath:rax] retain];
if (rbx != 0x0) {
free(rbx);
}
rbx = CFBundleCopyInfoDictionaryForURL(r15);
CFRelease(r15);
if (rbx == 0x0) goto loc_2867;

loc_276a:
r13 = [CFDictionaryGetValue(rbx, @ DVTMinimumSystemVersion ) retain];
CFRelease(rbx);
if ((r13 == 0x0) || ([r13 length] == 0x0)) goto loc_280c;

loc_27a6:
r12 = *_objc_msgSend;
r15 = [[DVTVersion versionWithStringValue:r13] retain];
rax = [DVTVersion currentSystemVersion];
rax = [rax retain];
rbx = r12;
r12 = rax;
rdx = r15;
if ([rax isEqualToOrNewerThanVersion:rdx] == 0x0) goto loc_288c;

loc_27fb:
[r12 release];
[r15 release];
goto loc_280c;

loc_280c:
[r13 release];
[r14 release];
return;

loc_288c:
r15 = [(rbx)(r15, @selector(stringValue), rdx) retain];
rax = (rbx)(r12, @selector(stringValue), rdx);
rax = [rax retain];
r14 = [(rbx)(@class(NSString), @selector(stringWithFormat:), @ Executable requires at least macOS %@, but is being run on macOS %@, and so is exiting. , r15, rax) retain];
[rax release];
[r15 release];
fprintf(**___stderrp, %s\n , (rbx)(objc_retainAutorelease(r14), @selector(UTF8String), @ Executable requires at least macOS %@, but is being run on macOS %@, and so is exiting. ));
goto loc_292b;

loc_292b:
exit(0x1);
return;

loc_2867:
fwrite( Unable to open executable info dictionary; xcodebuild may be corrupt and should be reinstalled.\n , 0x60, 0x1, **___stderrp);
goto loc_292b;

loc_282c:
_DVTAssertionFailureHandler(*_self, *__cmd, void checkMinimumOSVersion() , /Library/Caches/com.apple.xbs/Sources/IDETools/IDETools-20008/xcodebuildLoader/xcodebuildLoader.m , 0x69, @ 0 , @ Couldn't get executable path to self! );
return;
}

好,阅读伪代码以及查阅资料可知:

xcrun会先一步调用到xcodebuild,检查DVTMinimumSystemVersion这个变量的值是否和当前OS版本匹配。

而这个变量,竟然是通过CFBundleCopyInfoDictionaryForURL打开的。

参考苹果的函数说明,它除了常规的打开一个.bundle的文件夹,解析为NSBundle.infoDictionary以外,竟然能打开存在于二进制__TEXT,__info_plist中的数据来解析为一个字典。所以我们接下来去找xcodebuild的二进制看看。

1648210752247_62087827de34d7b56eea1e48208b261e

参考:

  1. _NSGetExecutablePath函数说明,大概理解获取当前程序的可执行路径

反编译xcodebuild

同时,出于好奇,我们可以再把xcodebuild拖进Hopper去尝试理解,发现它整个程序竟然只有一个main函数,逻辑其实都在libxcodebuildLoader.dylib

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
void _main(int arg0, int arg1) {
r14 = arg1;
rbx = arg0;
rax = dlopen( @rpath/libxcodebuildLoader.dylib , 0x1);
if (rax == 0x0) goto loc_100002b7f;

loc_100002b5c:
rax = dlsym(rax, XcodeBuildMain );
if (rax == 0x0) goto loc_100002bd8;

loc_100002b70:
(rax)(rbx, r14);
return;

loc_100002bd8:
rax = dlerror();
rax = [NSString stringWithUTF8String:rax];
rax = [rax retain];
rax = objc_retainAutorelease(rax);
r15 = rax;
rax = [rax UTF8String];
rsi = Error loading symbol: %s\n ;
goto loc_100002c2f;

loc_100002c2f:
fprintf(**___stderrp, rsi);
rbx = [_prunedErrorMessage() retain];
[r15 release];
if (rbx != 0x0) {
_main.cold.1(rbx, @selector(UTF8String));
}
return;

loc_100002b7f:
rax = dlerror();
rax = [NSString stringWithUTF8String:rax];
rax = [rax retain];
rax = objc_retainAutorelease(rax);
r15 = rax;
rax = [rax UTF8String];
rsi = Error loading required libraries. If there is an ongoing installation please wait for it to complete. Otherwise reinstall. (%s)\n ;
goto loc_100002c2f;
}

修改DVTMinimumSystemVersion

其实大家也发现了,xcodebuild二进制本身竟然内嵌了一段XML!我使用llvm-objdump把它直接提取了出来:

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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildMachineOSBuild</key>
<string>21E160</string>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleDisplayName</key>
<string>xcodebuild</string>
<key>CFBundleIdentifier</key>
<string>com.apple.dt.xcodebuild</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>xcodebuild</string>
<key>CFBundleShortVersionString</key>
<string>13.3</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>MacOSX</string>
</array>
<key>CFBundleVersion</key>
<string>20008</string>
<key>DTCompiler</key>
<string>com.apple.compilers.llvm.clang.1_0</string>
<key>DTPlatformBuild</key>
<string>21E185d</string>
<key>DTPlatformName</key>
<string>macosx</string>
<key>DTPlatformVersion</key>
<string>12.3</string>
<key>DTSDKBuild</key>
<string>21E185d</string>
<key>DTSDKName</key>
<string>macosx12.3.internal</string>
<key>DTXcode</key>
<string>1330</string>
<key>DTXcodeBuild</key>
<string>13E112a</string>
<key>DVTMinimumSystemVersion</key>
<string>12.0</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
</dict>
</plist>

看到我们关心的DVTMinimumSystemVersionLSMinimumSystemVersion都在里面。其实也侧面证明了,真正的最低部署版本是macOS 11.0,而不是macOS 12.0(12.0只是苹果为了间接Push Developer去频繁更新macOS的阴谋罢了😂)

那下一步,要做的事情就是用魔改xcodebuild并重新codesign。修改的方式多种多样,你暴力使用Hex Editor也是最简单。但是我更好奇的是这个__TEXT,__info_plist的machO段和节的相关说明。

在网上搜索了一下相关资料,很容易就找到了感兴趣的资料:

  1. The Power Of Plist:解释Info.plist可以内嵌在二进制中
  2. Gimmedebugah: how to embedded a Info.plist into arbitrary binaries:对任意已有二进制注入Info.plist
  3. llvm-objcopy:拷贝修改machO结构到新machO

基本解释得很明确清晰,如果你有源码,可以直接利用ld64的参数 --sectcreate __TEXT,__info_plist path_to/Info.plist来注入你的Info.plist信息。没有源码可以手动修改machO结构并签名即可。

对于我此次跑Xcode 13.3来说,我选择最傻瓜最直观的Hex Editor修改(我用的是开源小工具HexFiend),只需要把12.0修改为11.0即可满足我的需要,并重新codesign一波。

1648210752222_811d0d800592f601f309e48b732f1194

codesign:

1
2
codesign --remove-signature /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild
sudo /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild -license

测试一下CLI,很正常

1648210752374_0a5d6ef92b0e270d6164a05153565572

最终替换步骤

  1. 修改Xcode-13.3.0.app/Contents/Info.plist中的LSMinimumSystemVersion的值为11.0
  2. 替换Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild中的DVTMinimumSystemVersion的二进制为11.0,或者使用我这个已经替换好的(建议还是手动参考上面步骤[修改DVTMinimumSystemVersion]替换,授人以渔而不是授人以鱼)

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

背景

自己很早之前曾经写过一些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篇)

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桥接

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使用教程向

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。

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

参考

一步步带你开发macOS QuickLook Plugin

QuickLook简介

QuickLook 是macOS上提供的一项快速展示文档预览的功能,只需要按下空格就可以快速查看各种文件格式的信息,包括文本,代码,图片,音频,视频等等。

由于QuickLook需要支持不断扩展的文件格式,因此macOS专门提供了一个QuickLook Plugin,能让开发者对自己的文件格式提供一个自定义的完整的UI显示,不必依赖macOS系统更新来支持缤纷复杂的格式。

之前一段时间,出于兴趣做了一个AVIF (AV1 Image File Format)的解码器封装,AV1作为现在流行的HEVC(H.265)潜在未来竞争者,有着开源,无专利限制,更高的压缩比等等优势,比起HEVC晚诞生了5年。

目前AVIF虽然发布了第一版规范,但是缺少相应的周边工具链的支持,在macOS上想要找一个简单的Image Viewer都没找到,调试起来异常困难,因此抽空顺便做了一个简单的Quick Look Plugin,来让自己能直接空格预览AVIF图像。

在做QuickLook Plugin的过程中,感觉有一些小坑需要记下来,因此这篇文章,目标就是一个简单的入门教程,讲解如何做一个QuickLook Plugin,来对自己喜爱但又不被系统支持的文件格式,提供更好的用户体验支持。

QuickLook Plugin工程

虽然苹果提供了完善的QuickLook Plugin开发文档,参考:Quick Look Programming Guide

但是文档已经稍显过时,遇到的一个坑点也没有提示,因此这里更详细直观的介绍一下QuickLook开发的流程。

  • 新建Xcode工程,选择这个Quick Look Plug-In模板

![屏幕快照 2019-04-16 上午11.45.25](https://lf3-client-infra.bytetos.com/obj/client-infra-images/lizhuoli/f7dac35688c54f2e9ac1a605b4295a39/2022-07-14/image/2019/04/16/屏幕快照 2019-04-16 上午11.45.25.png)

  • 打开你的模版,你会发现如下的结构
1
2
3
4
5
Project
- GenerateThumbnailForURL.c // 用来提供Finder缩略图的代码
- GeneratePreviewForURL.c // 用来生成Preview用的绘制代码
- main.c // 插件入口文件,不要修改它
- Info.plist // 描述插件支持的UTI类型的,后面会讲

QuickLook Plugin支持两种情形的功能展示:一个是对文件,按下空格来展示的窗口预览,在使用Option+空格进行全屏预览时候也会展示,后面都称作Preview

另一个是用来给Finder,来提供一个缩略图展示,这样一些图像格式,视频格式,在Finder中就能直接看到对应的缩略图,而不是一个僵硬的默认图标。后文都称作Thumbnail

由于QuickLook的核心,是希望对指定的文件格式,提供一个展示的UI和缩略图。那么在继续进一步写代码之前,我们必须得首先清楚自己需要的文件格式是什么,并了解UTI的概念。如果这一步骤处理的有问题,你的QuickLook Plugin是无法按预期的想法,被调用的。

绑定文件格式和UTI

在继续下一步之前,你需要对你想支持的文件格式,选择一个UTI (Uniform Type Identifiers).

QuickLook,在用户按下空格开始Preview的时候,会根据每个QuickLook Plugin注册的UTI,依次去询问,直到找到第一个返回成功的,最后来判定选择哪个Plugin进行展示。

建立好模版之后,打开Info.plist,在顶层的LSItemContentTypes项里面,添加你的Plugin所能支持的UTI,是一个数组,会按照先后顺序匹配,一般建议只写自己能准确识别的UTI,如果是一个通配的Plugin(如通用图片预览,通用代码预览),可以使用UTI继承关系的父级(public.image, public.source-code等)

1
2
3
4
5
6
7
8
9
10
11
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>QLGenerator</string>
<key>LSItemContentTypes</key>
<array>
<string>public.avif</string> <!--Here!-->
</array>
</dict>
</array>

在配置好Plugin支持的UTI之后,你还需要根据具体UTI的分配来源,来使用导入或者导出。

找出已有的UTI

你可以通过使用如下命令,查看一个文件对应的UTL

1
mdls test.avif

查看输出的kMDItemContentType,如果是以dyn开头,表明没有被注册过,而是系统分配的一个动态UTI(用于任意不支持的类型和代码兼容,参考Dynamic Type Identifiers

否则,形如public.png这种,标示是一个已有的UTI,可以导入来直接使用

1
kMDItemContentType ="dyn.ah62d4rv4ge80c7xmq2"

如果你是一个比较执着的人,想了解具体的每一个UTI,是由系统或者还是某个第三方App注册的,你可以使用如下命令,导出完整的系统UTI报表,来进行搜索。

1
/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister -dump

UTI定义

一个UTI对应一段XML的定义,其中声明了它的类型(继承关系),UTI字符串,简介名称,扩展名,标准链接等等,基本的格式如下,很容易理解。这里是自己定义的一个AVIF格式的描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.image</string>
</array>
<key>UTTypeDescription</key>
<string>AVIF image</string>
<key>UTTypeIdentifier</key>
<string>public.avif</string>
<key>UTTypeReferenceURL</key>
<string>https://aomediacodec.github.io/av1-avif/</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>avif</string>
</array>
<key>public.mime-type</key>
<string>image/avif</string>
</dict>
</dict>

导入UTI

如果你想支持QuickLook的文件格式,已经有了系统分配的UTI,或者第三方App定义好的UTI,那么你要做的,就是导入一个UTI。

如果要导入UTI,你需要在Info.plist中,使用UTImportedTypeDeclarations这个项,来导入对应的UTI描述内容,值是一个数组,数组每项都是上面提到的UTI定义。

PS:对于导入UTI来说,你其实并不需要完整的把别人的声明抄过来,只要存在UTTypeIdentifier项即可,但是这样写能更清晰了解对应的格式描述。

1
2
3
4
5
6
7
8
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>public.png</string>
<!--...-->
</dict>
</array>

导出UTI

反之,如果你想支持的QuickLook的文件格式,不存在已有的UTI,那么你需要新增一个并导出。

如果要导出UTI,你需要在Info.plist中,使用UTExportedTypeDeclarations这个项,来导出对应的UTI描述内容,值是一个数组,数组每项都是上面提到的UTI定义。

1
2
3
4
5
6
7
8
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>public.avif</string>
<!--...-->
</dict>
</array>

QuickLook Plugin和导出UTI

值得注意的一个坑点,macOS系统注册UTI规则,会注册当前硬盘上所有的.app后缀的App包,里面所含有的导出UTI,而遗憾的是,作为QuickLook Plugin,最后编译得到的产物,不是以.app为后缀名的,而是一个.qlgenerator

因此,这就导致,如果你新增了一个UTI,但是你的QuickLook Plugin,没有任何宿主App来提供导出UTI,最终macOS会不认这个UTI,因此你的QuickLook Plugin不会被调用。这可能是苹果早期认为,QuickLook Plugin是和一个App绑定的(如Keynote和Keynote QuickLook插件的关系),独立存在的QuickLook Plugin并没有特别处理……

这个坑花费了一些时间,经过一番StackOverflow和GitHub搜索,最终找到了一个非常聪明(Trick)的解决方案:

构造一个临时占位的Dummy.app包,专门用于导出UTI,在打包的时候直接将这个Dummy.app拷贝到对应QuickLook Plugin的包中即可

我们可以使用macOS自带的Script Editor.app,来创建一个空壳App:

  1. 打开Script Editor,创建一个新文档
  2. 直接Save,类型选择Application,名称随便写一个Dummy.app,导出
  3. 用文本编辑器,打开Dummy.app/Contents/Info.plist
  4. 参考上文提到的UTI导出方式,添加对应的UTExportedTypeDeclarations项目
  5. 将这个Dummy.app,放到工程下,直接拖进来当作资源,添加到Copy Bundle Resource过程中

未命名3

这样一波操作以后,你最后构建得到的QuickLook Plugin,就能自带一个导出的UTI,然后被系统识别,最终被真正加载。

用于Preview的代码绘制实现

准备好上述UTI的配置后,现在再来看看代码。首先我们侧重看一下用于提供Preview的UI的代码。

对应的文件是GeneratePreviewForURL.c。如果要使用Objective-C,或者C++代码,你可以更改对应的文件名为.m或者.cpp即可,以下示例是以Objective-C代码为主

入口调用函数原型为下:

1
GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options)

其实对于大多数QuickLook插件,我们关注的基本上只有这个url参数,他对应的是文件的File URL,可以拿到对应被选中的文件Data Buffer。

1
2
NSString *path = [(__bridge NSURL *)url path];
NSData *data = [NSData dataWithContentsOfFile:path];

下一步就是绘制和渲染我们的UI,QuickLook支持两种方式渲染:

  • 使用Core Graphics自定义绘制
  • 使用预置支持的数据格式,动态生成Data

使用Core Graphics绘制

这里假设已经了解Core Graphics绘制的基本知识,如果有不了解请提前查阅苹果的教程:Quartz 2D Programming Guide.

在拿到Data以后,该怎么绘制取决于你的QuickLook插件的功能,比如说,我想做的一个AVIF图像预览Quick Look插件,那么就希望触发解码,以拿到CGImage和Bitmap Buffer来绘制。

1
CGImageRef cgImgRef = [AVIFDecoder createAVIFImageWithData:data];

下一步,我们需要获取一个CGContext来绘制,使用QLPreviewRequestCreateContext,传入入口函数透传进来的preview,会得到一个CGContext,来作为上下文进行绘制。同时,还需要了解绘制的大小,标题等等选项,来提供合适的渲染UI。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
CGFloat width = CGImageGetWidth(cgImgRef);
CGFloat height = CGImageGetHeight(cgImgRef);

// Add image dimensions to title
NSString *newTitle = [NSString stringWithFormat:@"%@ (%d x %d)", [path lastPathComponent], (int)width, (int)height];

NSDictionary *newOpt = @{(NSString *)kQLPreviewPropertyDisplayNameKey : newTitle,
(NSString *)kQLPreviewPropertyWidthKey : @(width),
(NSString *)kQLPreviewPropertyHeightKey : @(height)};

// Draw image
CGContextRef ctx = QLPreviewRequestCreateContext(preview, CGSizeMake(width, height), YES, NULL);
CGContextDrawImage(ctx, CGRectMake(0,0,width,height), cgImgRef);
QLPreviewRequestFlushContext(preview, ctx);

// Cleanup
CGImageRelease(cgImgRef);
CGContextRelease(ctx);

这样基本就完成了,我们绘制了一个完整的图像到CGContext上,QuickLook会渲染到屏幕上,大小是我们指定的图像大小。

如果你的QuickLook插件,需要有一个异步的处理和等待,同时可以实现这个取消的入口函数,来减少CPU占用,优化一下用户体验

1
void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview)

比如说,对于大图像解吗,可以中断解码提前释放内存。

使用预置类型生成数据渲染

QuickLook Preview还有另一种渲染方式,就是使用QuickLook预置的文件类型支持,来提供相应的数据。对应文档:Dynamically Generating Previews

我们需要使用QLPreviewRequestSetDataRepresentation,来提供一个预置支持格式的Data Buffer给QuickLook。

支持的格式有:

  • Image: 系统Image/IO解码库支持的图像压缩格式
  • PDF:PDF数据
  • HTML:WebKit支持的HTML字符串,注意如果有本地的CSS,需要使用kQLPreviewPropertyAttachmentDataKey带上CSS的数据
  • XML:WebKit支持的XML字符串
  • RTF:macOS支持的富文本格式(NSAttributedString可以转换的到)
  • Text:纯文本字符串
  • Movie:系统CoreVideo库支持的视频压缩格式
  • Audio:系统CoreAudio库支持的音频压缩格式
1
2
3
NSImage *image;
NSData *imageData = [image TIFFRepresentation];
QLPreviewRequestSetDataRepresentation(preview, (__bridge CFDataRef)data, kUTTypeImage, NULL);

在其他App中使用Preview

值得一提的是,得益于macOS完整的软件生态,你的QuickLook Plugin的Preview UI,不仅仅会出现在Finder中空格弹出的预览,甚至于Xcode和一些第三方App内置的预览(即用到了QLPreviewPanel来展示UI的地方),都能触发你的插件,所以可以说是非常舒服。

在Xcode中缩略图如下:

![屏幕快照 2019-04-16 下午1.49.04](https://lf3-client-infra.bytetos.com/obj/client-infra-images/lizhuoli/f7dac35688c54f2e9ac1a605b4295a39/2022-07-14/image/2019/04/16/屏幕快照 2019-04-16 下午1.49.04.png)

用于Thumbnail的代码绘制实现

说完了关于Preview的实现代码,现在再来看看关于如何生成Finder用到的文件缩略图

Thumbnail也支持两种模式

  1. 使用同Preview的,基于Core Graphics绘制逻辑
  2. 更为简单的API,使用CGImage或者Image Data

第一种方式,和上文一模一样,这里就不再赘述了。我们可以看看第二种方式。我们只需要提供一个CGImage,或者一个Image/IO支持的图像格式的Image Data即可

1
2
3
4
5
6
7
8
9
10
// 如果是原生支持的格式,使用QLThumbnailRequestSetImageWithData

// 否则,自己解码器输出一个CGImage,然后传进去
CGImageRef cgImgRef;
if (cgImgRef) {
QLThumbnailRequestSetImage(thumbnail, cgImgRef, nil);
CGImageRelease(cgImgRef);
} else {
QLThumbnailRequestSetImageAtURL(thumbnail, url, nil);
}

对应在Finder中缩略图如下:

调试QuickLook插件

作为一个插件,要调试起来比起一般的App要麻烦一些。不过好在macOS提供了一个专门的QuickLook调试命令,苹果也有专门文档介绍

我们可以使用如下的命令,以public.avif的UTI,对test.avif文件,触发一次Quick Look的Preview,来查看渲染是否正确。

1
qlmanage -d2 -p test.avif -c public.avif

同时,为了能够Debug单步调试,我们使用Xcode的Debug Scheme,通过将Execulable改成/usr/bin/qlmanage,在Arguments中填写成上述的参数。

未命名

未命名2

这样,你可以给你的对应代码下上断点,当你再次点击Run来运行时,会自动触发单步调试,检查存在的问题。

总结

整体看下来,QuickLook Plugin的开发流程并没有多么复杂,其实你要做的就是用已有的Core Graphics绘制知识,并不涉及到AppKit相关概念,对于iOS开发者也能快速上手。

其中的坑,主要在于没有文档说明新增UTI,需要绑定一个App,而不是QuickLook Plugin本身能够声明的,对应也介绍了一个聪明的方式绕过这一限制。希望能帮助到有同样需求的人。

自己的AVIF QuickLook Plugin也终于完工,欢迎有兴趣的人尝试,并且给一点Star:

这里还有一些推荐和自己用到的QuickLook Plugin,也列举出来,能大大提升日常使用效率哦

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

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

很多图片库,都会有一个类似叫做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优化

这篇文章介绍了关于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篇)

这篇教程,是系列教程的第三篇,前篇名为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平台图片编解码入门教程(第三方编解码篇)

这篇教程,是系列教程的第二篇,前篇名为《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篇)

这篇教程是系列教程的第一篇,主要是面向于没有怎么接触过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开源项目的经历及感受

这篇文章讲的是有关近期自己参与的几个开源项目的经历以及感受,不过巧合的是内容都和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

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

❌