普通视图

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

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

作者 DreamPiggy
2023年12月27日 01:49

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调试信息裁剪传输方案

作者 DreamPiggy
2023年4月5日 01:01

声明

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

公众号链接:

背景介绍

在如今,越来越多应用采取分布式构建系统,以及一些云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调试性能的优化方案

作者 DreamPiggy
2022年5月8日 01:13

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动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 LLDB耗时监控统计方案

作者 DreamPiggy
2022年9月8日 04:12

声明

此篇文章在字节跳动的技术公众号已经刊登:《字节跳动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

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

作者 DreamPiggy
2022年3月26日 04:47

因为众所周知的原因,苹果的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最低部署版本的坑

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

背景

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

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

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

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

Data Asset初见

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

1625559957403_29bd9b59c2bbaa1f363122a8276779b6

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

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

- A.dataset

-- Contents.json

-- 1.zip

-- 2.webp

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

Data Asset产物

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

1625559957281_7b787078bbae747abaf28cde1a513955

Data Asset获取代码

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

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

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

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

NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];

*/

NSBundle *bundle = [NSBundle mainBundle];

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

NSData *data = asset.data;

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

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

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

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

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

1625559957352_30cb4a561799eec3da92fa1c607c101e

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

s.subspec 'Core' do |ss|

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

end

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

宿主A

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

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

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

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



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

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

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

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

1625559957323_ceced1da15007185b48893a6eda48754

宿主B

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

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

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

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



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

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

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

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

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

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

1625559957272_fa17b69e9dd37090291bc0a6952baa38

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

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

SDK调用代码

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

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

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

NSbundle* bundle = [NSBundle bundleWithPath:bundlePath];



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

NSData *triangleData = [NSData dataWithContentsOfFile:trianglePath];

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

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

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

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

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

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

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

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

old.to_f < 10.0 ? '10.0' : old

end

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

definitions = 'space'

definitions

end

end

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

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

结论

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

这里有两个改进方案:

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

NSBundle *bundle = [NSBundle bundleWithPath:bundlePath];

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

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

NSData *data = asset.data;

if (!data) {

// Fallback到路径

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

}

WatchKit渲染原理以及SwiftUI桥接

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

WatchKit渲染原理以及SwiftUI桥接

背景

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

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

watchkit-app.jpg

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

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

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

WatchKit架构介绍

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

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

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

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

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

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

WatchKit UI布局原理

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

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

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

  • CoreAnimation
  • Metal
  • OpenGL/ES
  • GLKit

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

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

客户端

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

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

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

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

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

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

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

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

服务端

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

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

watchkit1

UI初始化

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
controllerClass = "InterfaceController";
items = (
{
property = interfaceTable;
rows = {
default = {
color = EFF1FB24;
controllerClass = "ElementRowController";
items = (
{
alignment = center;
fontScale = 1;
property = elementLabel;
text = Label;
type = label;
verticalAlignment = center;
}
);
type = group;
width = 1;
};
};
type = table;
}
);
title = Catalog;
}

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

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

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

UI更新

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

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

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

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

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

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

总结流程

watchkit2

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

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

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

WatchKit与Long-Look Notification

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

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

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

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

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

Long-Look Notification原理

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

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

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

watchkit4

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

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

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

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

WatchKit和SwiftUI

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

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

swiftui

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

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

SwiftUI与WatchKit桥接

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

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

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

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

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

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

桥接原理

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

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

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

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

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

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

watchkit-swiftui2

SwiftUI与watchOS Native App

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

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

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

watchkit3

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

watchkit-swiftui1

SwiftUI与Long-Look Notification

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

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

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

Independent watchOS App

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

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

开发配置

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

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

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

App Slicing

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

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

总结

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

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

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

watchkit-twitte

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

参考资料

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

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

Hopper简介

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

Hopper安装

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

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

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

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

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

Hopper使用

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

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

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

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

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

Xcode 11:

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

Xcode 10:

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

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

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

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

/

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

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

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

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

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

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

获取dyld shared cache

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

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

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

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

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

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

解压dyld shared cache

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

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

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

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

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

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

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

1
dsc_extractor ./dyld_shared_cache_arm64e ./output

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

载入Hopper

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

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

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

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

符号分析

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

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

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

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

Class-dump与私有头文件

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

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

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

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

伪代码分析

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

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

分析调用关系

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

常见的分析姿势

Block

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

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

简单Block

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

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

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

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

捕获变量

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

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

比如对于这样代码:

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

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

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

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

CGRectMake等inline的C方法

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

这样的代码:

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

反编译结果:

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

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

Swift

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

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

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

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

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

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

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

总结

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

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

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

参考

一步步带你开发macOS QuickLook Plugin

作者 DreamPiggy
2019年4月16日 23:02

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,也列举出来,能大大提升日常使用效率哦

CocoaPods的资源管理和Asset Catalog优化

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

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

Asset Catalog和App Thinning

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

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

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

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

CocoaPods的资源管理

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

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

不使用use_framework!

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

  • resources字段

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

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

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

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

优点:

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

缺点:

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

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

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

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

优点:

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

缺点:

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

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

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

现在需要更新为:

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

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

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

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

使用use_framework!

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

  • resources

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

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

优点:

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

缺点:

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

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

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

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

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

CocoaPods与Asset Catalog图片资源

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

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

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

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

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

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

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

不使用use_framework!

  • resources

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

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

优点:

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

缺点:

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

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

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

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

使用use_framework!

  • resources

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

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

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

  • resource_bundles

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

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

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

最佳实践和总结

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

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

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

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

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

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

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

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

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

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

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

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

SDWebImage

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

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

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

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

Canvas大小问题

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

Disposal Method支持

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

每帧持续时长相等问题

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

循环次数问题

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

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

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

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

感受

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

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

apng2webp

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

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

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

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

Windows的支持

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

CI和单元测试

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

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

感受

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

iSparta

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

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

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

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

APNG支持Animated WebP

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

i18n国际化

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

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

感受

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

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

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

总结

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

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

Objective-C代码库的实现隐藏

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

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

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

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

隐藏内部属性

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

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

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

@property (nonatomic, strong) NSObject *internalObject;

@end

改变属性修饰符

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

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

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

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

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

@end

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

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

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

@end

隐藏私有属性

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

类扩展(Class Extension)

通常情形

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

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

@property (nonatomic, strong) NSString *privateID;

@end

自定义存取方法

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

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

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

return _privateID;
}

第一种解决方案:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//Person.m

@interface Person ()

@property (nonatomic, strong) NSString *privateID;

@end

@implementation Person

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

return _privateID;
}

@end

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

@property (nonatomic, strong) NSString *privateID;

@end

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

@end

第二种解决方案:

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

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

@property (nonatomic, strong) NSString *privateID;

@end

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

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

return _privateID;
}

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

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

@end

@interface Person (Private)

@property (nonatomic, strong) NSString *privateID;

@end

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

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

return _privateID;
}

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

分类(Category)和关联对象

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

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

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

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

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

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

strong(retain)

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

宏:

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

示例:

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

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

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

copy

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

宏:

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

示例:

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

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

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

unsafe_unretained

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

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

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

宏:

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

示例:

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

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

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

assign

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

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

宏:

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

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

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@property (nonatomic, assign) int age;
@property (nonatomic, assign) CGRect frame;

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

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

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

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

weak

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

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

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

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

@property (nonatomic, weak) id object;

+ (instancetype)containerWithObject:(id)object;

@end

@implementation WeakObjectContainer

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

@end

宏:

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

第二种解决方案:

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

宏:

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

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

示例:

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

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

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

自定义存取方法

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

示例:

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

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

隐藏内部方法

类扩展实现类的内部方法

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

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

@interface Person ()

- (void)internalMethod;

@end

@implementation Person

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

@end

隐藏私有方法

分类实现类的私有方法

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

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

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

@interface Person (Private)

- (void)privateMethod;

@end

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

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

@end

暴露公开类的内部方法

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

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

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

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

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

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

@interface Person (Private)

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

@end

//Person+Private.m

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

第二种解决方案:

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

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

@interface Person ()

- (void)internalMethod;

@end

@interface Person (Private)

- (void)privateMethod;

@end

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

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

总结

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

资料

  1. 完整Category属性宏

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

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

ReactiveCocoa和RxSwift

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

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

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

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

ReactiveCocoa

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

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

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

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

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

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

1
2
3
hel
hell
hello

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

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

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

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

}];

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

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

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

RxSwift

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

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

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

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

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

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

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

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

Promise

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

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

Bacon

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

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

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

Functional

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

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

ReactiveX

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public enum Event<Element> {
/// Next element is produced.
case next(Element)

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

/// Sequence completed successfully.
case completed
}

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

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

理论:

1
Monad Event <$> subscribe

示例:

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

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

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

理论:

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

示例:

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

Promise / Future

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

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

原理:

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

示例:

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

总结

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

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

#参考资料

❌
❌