DanceCC 是字节 Mobile Infra 的一套编译工具链的品牌名,基于 Swift.org 的工具链进行了相关定制,包括调试优化,定制 Clang 插件特性,自研 Pass 做包大小和性能优化等等。在先前的文章中均有介绍。
近期,有人发来反馈,他们在接入 DanceCC 的新版本工具链时,在调整了一些库的工具链选择后(即使用 Apple 工具链还是 DanceCC 工具链),重新编译出包,发生启动 Crash,堆栈如下:
1 | "Symbol not found: __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E", |
崩溃的核心原因在于:__ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E
这个符号找不到,引用发生在 AppStorageCore 动态链接库中,加载发生在 EEAtomic 动态链接库中
首先查看 AppStorageCore 的 Load Command,判断其递归加载的动态库(LC_LOAD_DYLIB)包含 EEAtomic 和 LKCommonsLogging,只考虑非系统库(因为该符号必定不在系统库内):
1 | Load command 11 |
通过 nm 来查看符号分析:
1 | nm EEAtomic.framework.dSYM/Contents/Resources/DWARF/EEAtomic | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E |
1 | nm LKCommonsLogging.framework.dSYM/Contents/Resources/DWARF/LKCommonsLogging | grep |
1 | nm AppStorageCore.framework/AppStorageCore | grep __ZN5swift34swift50override_conformsToProtocolEPKNS_14TargetMetadataINS_9InProcessEEEPKNS_24TargetProtocolDescriptorIS1_EEPFPKNS_18TargetWitnessTableIS1_EES4_S8_E |
即然符号在 AppStorageCore
中未定义,那么应该在其递归加载的 EEAtomic/LKCommonsLogging 中,以 T(即 global)符号暴露出来,而现在不是。导致运行时找不到该符号 dyld 报错。我们需要进一步探究源头问题。
通过 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 | 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 |
在 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+ 的运行时语法,那么编译器就需要打上这些补丁:
注意几个细节:
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,也需要额外的运行时补丁,即:
如果接入了 SwiftUI 等依赖@dynamicReplacement 的语法的代码,也需要额外的补丁,即:
如果接入了 Swift 的 Paramters Pack 语法 each T,也需要额外的补丁,即:
备注:傻瓜省流,当你 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 和对应静态库不同罢了):
swift::getOverride_swift_getTypeName
返回的函数指针swift:getOverride_swift_getTypeName
会从 __DATA,__swift50_hooks
MachO Section,找到被链接进去的 libswiftCompatibility50 的符号swift::getOverride_swift_getTypeName
(App 链接的补丁实现)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 | Load command 44 |
在 DanceCC 的编译器编译下,产出的产物就是上述的 LC_LINKER_OPTION,按理说链接器会正常进行链接,发生了什么?
链接参数对比如图:
通过检查链接参数,看起来似乎没什么问题,这里存在 Library Search Path:-L/path/to/swift-5.9-dancecc.xctoolchain/usr/lib/swift/iphoneos
,即指向了工具链内置的 libswiftCompatibility50.a 所在目录,那究竟是什么原因导致符号丢失?
在实际编译机器上进行了如下 4 项测试:
结果如图:
可见,发生问题的地方不在于 linker,不在于 clang 本身,而在于工具链内置的 libswiftCompatibility50.a,其 visibility 有问题!
我们将 Apple Xcode 15.0 内置的产物和 DanceCC 进行对比
首先一眼从二进制大小来看,DanceCC 的产物未免有些太小,很反常。进一步反汇编查看,发现 Apple 的.a 包含了 -embed-bitcode
的 LLVM Bitcode 内容。我们需要 strip 后再次进行对比
1 | Section |
我们关注丢失的符号的 visibility,查看(参考:How to know the visibility of a symbol in an object file):
1 | objdump -Ct libswiftCompatibility50.a |
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*)) |
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++代码:
./stdlib/toolchain/Compatibility50/Overrides.h
./stdlib/toolchain/Compatibility50/ProtocolConformance.cpp
可见,这里没有显式的标记 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 加入 verbose 编译后,证明和猜想一致
从上述分析可知,当前编译单元(即 swiftCompatibility Target)不应该开启修改默认的 visibility 进行编译,否则就需要源码手动声明 visibility(default)
快速绕过改问题,可以对相关库依旧保持 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 份)
这两种集成仅有小量二进制差异,业务 8 个 dylibs,影响较小(一个 force_load 的 libswiftCompatibility50.a 占据 10KB)
根据目前 Apple 内置二进制的解析结果,我们一期考虑直接无脑对齐,通过源码手动标记 visibility(“default”),不影响其他编译单元的构建逻辑:
0000000000000088 g O __DATA,__swift_hooks _Swift50Overrides
0000000000000000 g O __DATA,__swift51_hooks _Swift51Overrides
0000000000000000 g O __DATA,__s_async_hook .hidden _Swift56ConcurrencyOverrides
这一篇文章不仅仅介绍了具体的一个开源 Swift.org 工具链,和 Apple 闭源工具链的差异,更为重要的是介绍了关于 Swift Runtime Backport 的一些机制流程,并且介绍了一些相关的排查经验,方便工具链开发者用于追查更多类似的行为不一致问题😂。
说起来短短一年期间,DanceCC 工具链已经大大小小修复了数十例子这种行为不对齐的问题,保障了内部业务的可用性。也因此可见 Apple 在其内网维护者庞大的一套自动化验证以及私有分支。如果对这套机制有兴趣的人,可以私聊我,来让这个 Swift.org 工具链能够真正的开源出来有价值,能够在更多的场景产生贡献。
]]>此篇文章原作者就是我,版权所有。预计未来会刊登在《字节跳动终端技术》
公众号链接:
在如今,越来越多应用采取分布式构建系统,以及一些云IDE的兴起,在这种场景下,如何保证跨机器的编译产物,能够正常的在另一台机器进行正常的开发调试,是一个常见的问题。
传统的单机编译和链接流程中,编译器会在产物中嵌入当前编译单元的单机的路径,中间产物的路径;链接器在链接时,也会尝试写入链接器输入的所有Object File和Archive File的路径。在随后的调试器工作时,会通过读取MachO Executable的Section中,编码的调试信息和路径,以进行行断点的匹配,源码信息的展示等等能力。自然的,如果编译器或者链接器在处理时全部以当前机器的绝对路径进行编码,则跨机器的产物传输后,就不能正常的实现调试功能。
对此,大部分分布式构建解决方案提供了避免绝对路径,或者绝对路径对相对路径对映射方案,其依赖编译器或者链接器的特定参数注入,也可能会依赖dSYM Bundle这种二次链接产物来进行调试信息传输。但是前者其存在一定的项目接入成本,需要依赖其构建时所有二进制(尤其是外部引入的三方预编译好的二进制)都进行了相对路径的处理。而后者的dSYM Bundle对增量不友好,会严重影响开发-调试周期的平均耗时。
当然,解决思路有很多。我们曾经使用了分布式进行编译,单机进行链接(以保证编编码进MachO Executable的Section中的路径都在当前机器可访问),随后在调试器启动时设置Source-Map来映射预二进制的源码路径。但是在更复杂的分布式构建场景下,链接阶段也会进行分布式处理。因此,为了保障开发阶段的应用,在用户设备上也能正常安装,调试,我们提供了一系列的解决能力支持,这篇文章主要用于分享相关的解决方案思路。
对于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上)的能力,即可达到正确的效果,但是这存在一定的实践局限性:
综上,在实际的落地场景中,在测试效果达不到预期后,我们并没有沿着这条路继续探索,转而使用下文的方案。
另一种场景就是,编译/链接的机器,和调试器所在机器完全分离。这部分在传统构建中,通常会采取dSYM Bundle + 二进制包来进行分发,随后进行调试的方案来处理,以保证调试产物的可迁移性。但还有痛点:
在实际的落地场景中,我们最后选择了在此方案的基础上,大幅度优化OSO的传输开销,“等待耗时”等,最终实现在大型项目中,全调试链路启动从6-8分钟(dSYM Bundle + 穿行传输),优化为2分钟(OSO + 并行传输)的优化效果。
我们的解决方案主要侧重于解决开发-调试周期的问题,因此尽量希望从整体视角来看,调试信息的传输能够更快。这可以细分为两个优化:
上文也提到,最开始尝试了直接利用dSYM Bundle来进行产物传输,也参考了上游和业内的一些实践,包括New DWARFLinker,但是实践下来结果都不够理想。
因此,最后的落脚点放在采取OSO来存储调试信息,并进行优化。首先我们需要保证直接原封不动从编译机器A,传输OSO到用户机器B能够正常工作,根据前文的知识,首先就需要将编码OSO从绝对路径,转为相对路径。
我们尝试在不修改工具链的情况下进行调研,但是结果是令人沮丧的:
既然没有办法直接用相对路径,我们还有另一个思路,就是通过绝对路径来进行映射(避免跨机器的前缀路径问题)。在这方面,我们同时提供了两个实现方案(供复杂系统选择):
在解决了传输的路径映射问题之后,另一个优化重点就是如何缩小OSO的大小。我们采取了一个朴素且保守的方案:将OSO(本身是MachO Object)的所有非Debug Info相关的Segment和Section全部清空,并调整符号表和偏移量,让这个MachO Object成为“仅供调试使用的Object”。
此外,当然还有针对FAT Binary的处理,整体功能利用llvm-objcopy,我们实现了不同的裁剪策略(见下),减少了约60-70%的大小原开销。
这样设计的好处是,能够尽量减少对LLDB原生解析逻辑影响(实际LLDB仅改动1行代码),因此为了兼容性我们提供了两个不同的开关,具体行为如下:
除了裁剪以外,还自动进行了MachO Universal Binary的Slicing(保留单架构),也不用调用方自己唤起lipo(比较慢)
在大型项目的实践中,整体的OSO传输大小,从优化前的15GB左右,优化为最终的10GB大小,减少幅度高达1/3(取决于项目的预二进制的对象文件多少)
解决了OSO的传输的大小开销后,我们又产生了另一个优化方案:现有的流程提取OSO依赖链接器链接完成,但是实际上,OSO是编译器产出的结果,链接器仅仅做的是“收集并写入路径”。我们能不能自己做一个“仿造链接器”来完成一样的能力,达到并行提前裁剪和传输OSO呢?
答案是肯定的,我们利用Apple开源的ld64代码,结合一些构建系统提供的Build System监控(如Bazel的BEP,Xcode的XCBBuildService),在链接阶段开始的瞬间,并行唤起我们的仿造链接器进程,处理裁剪OSO和触发传输的逻辑。
在这样的优化之后,原始需要串行等待的2分钟(链接时间)+2分钟(传输OSO时间),被优化为纯粹的2分钟,优化幅度高达50%。
现代构建系统和工具链的日益不断的结合,我们会越来越多涉及到这种类似双向配合才能达到的收益。在这个方案中,我们介绍了如何让调试器,与构建系统的分布式处理,能够协调合一,达到接近本地单机调试的开发体验(但是拥有更高的编译/链接构建速度)。
DanceCC工具链也会后续在更多领域,如编译器、链接器、调试器、LLVM子工具上进行更多的尝试,提供针对移动平台的全套解决方案。
引用链接
熟悉我的人都知道我其实是一个游戏爱好者,只是很少在博客写非技术文章而已。我在11月左右,因为受不了某日厂的PC独占行为而决定入手Steam Deck,这也是我自从2017年彻底放弃PC阵营之后第一次重回PC游戏领域,因此这里从一个主机玩家的视角整理一下我自己对Steam Deck,SteamOS的一些指南,希望能帮助中文领域的类似玩家快速上手和方便折腾。
断断续续写了几个小时,后续不断把我遇到的一些折腾指南都在这里更新吧,可以借助目录树来查看感兴趣的内容。
Steam Deck官网:https://www.steamdeck.com/zh-cn/
主机的规格主要是:
主机款式目前分为以下三种:
大众的选择一般是64GB款 + 自购512G的SSD(M.2 2230)更换,更换教程也全网都有不麻烦(一把十字螺丝刀可搞定)。
64GB款国内一般直接TB代购在¥3100,自己直接走美区充值余额 + 海淘关税13% + 国内转运,成本在¥2900左右,就算再加上JD的512GB的SSD ¥500价格,最贵也就¥3600,实际算下来是很香的。
虽然这样说,实际上我当时为了尽快上手且为了图省事,一步到位购买了现货顶配版(¥5000),防炫光玻璃效果也是有的,肉疼就肉疼吧,当是早买早享受了:)
到手后拿起来比Switch重很多,实际体验下来并不适合长期举着玩,除了放在底座上手柄来玩大作外,推荐的掌机玩法要么架在桌子/腿上玩,要么直接侧躺靠着握把玩(主要是文字类型游戏,注意视力)
Steam Deck支持TF存储卡扩展,我这里选择512GB以后自然没必要换SSD了,但是为了后续可能用到的空间,以及安装Windows到TF卡上,所以又300¥买了一个UH3的512G 闪迪TF卡(注意尽量选读写速度快的),直接在机身下方插入即可。
默认SteamOS会推荐格式化为EX4文件系统,但是你可以选择其他Linux支持的文件系统(甚至包括NTFS)。配置好以后,可以选择默认安装游戏到TF卡上,也可以把已安装的游戏,在两个存储中移动,和Switch的逻辑有点像,也和PC的Steam客户端的内容库管理逻辑一致。
后文的“游戏转移到存储卡”章节,会详细讲解游戏转移到存储卡的操作和注意事项。
我作为主机玩家,且主要目的是为了玩日厂的JRPG,因此连接电视+手柄对我来说是必不可少的。
Steam Deck官方提供了一个基座(扩展坞):https://www.steamdeck.com/zh-cn/dock
接口规格为:
值得注意的是,不同于Switch的基座,这个基座只提供USB Type-C的扩展能力和充电能力,完全不能提升游戏性能(实测,对比直接Type-C供电,游戏帧率一致)。
显示器输出虽然说最高支持4K 120Hz,实际上游戏压根带不动。推荐的游戏输出分辨率是1080P(后文提),另外还有一个注意点,官方基座MST(多显示器输出)需要同时接入HDMI和DP两个端口而不能二次转接,不过实际性能表现带单显示器已经极限,大部分人压根用不到
官方基座售价$89,国内现货800¥起步,完全不值得购买(不差钱另说),其实如果你不追求长期接电视/显示器,选择一个便宜的100¥以内的Type-C转HDMI头都可以解决
我最终选择了一个第三方的基座,除了没有DP接口其他规格完全一致,只需要¥250,铝合金质感比官方基座的塑料明显要好(就离谱)
自从2017年彻底退出PC游戏之后,我的国区Steam账号终于又活过来了。(上次登录1400天前)
Steam账号体系和PS的账号体系更类似(对比Switch和Xbox那种随时跨区切换购买游戏而言),一个账号每3个月才能更改一次地区,且余额会按照汇率等价兑换。另外注意转区完成以后还需要进行一次有效购买才可以实际切换
但是,Steam对转区判定比较严格,需要挂对应地区IP代理,否则经常性转换可能被红信(即Steam的欺诈警告,会锁定账号的游戏购买)
由于不同区的游戏价格差异很大,可以参考SteamDB这里搜索对比一下,且有些游戏会锁国区(暴力血腥or小黄油),推荐的方式是类PS的账号体系的应对方式,我们注册多个地区的不同账号,通过在一台Steam Deck上登陆多账号,然后切换游玩(还可以利用家庭共享来让账号1游玩账号2的游戏,并成就和存档挂在账号1,老PS玩家很熟悉)
注册Steam外区账号需要使用对应地区的IP代理,这个随便找个比如Free Proxy,或者利用一些加速器自带的商店加速能力就可以切。
推荐注册的外区是阿根廷(大部分游戏的最低价地区,但锁本地信用卡和充值卡,后文讲),或者土耳其(目前截止12月,土耳其支持国内Visa/Mastercard双币卡,非常方便)
注册完成以后,需要进行一次商店定区,无论是外区货币充值还是使用占位符兑换码兑换一次都可以,保证商店页面显示的货币为外币即可。
另外,强烈建议注册后立即下载Steam手机版,登陆外区并绑定手机两步认证(不需要外区手机号,国区即可接),为后面的Steam市场开放和余额购买做准备。
以及,Steam在我离开这段时间,除了商店外,社区,好友和很多平台可能被网络屏蔽,建议自备路由器级别的代理或者设置WiFi的代理服务器
土耳其区因为暂时支持国内双币卡,就不多说了,这年头主机玩家没个Visa/Mastercard信用卡不太可能。注意账单地址选择一个真实的土耳其地址即可。当然你也可以选择下面提到的余额购买方式,也可以直接选择电子充值卡兑换码(一般和汇率持平)
阿根廷区因为最低价被大量玩家滥用,既锁本地信用卡,又在2021年关闭了电子充值卡的购买手段,目前我已知支付手段包括:
SteamOS的体验,简而言之可以说是把PC上的Steam客户端,做到了操作系统级别的体验,基本交互比PC上的大屏幕模式更舒服,且所有导航设置都适配了手柄(无论是主机手柄还是外接PS/Xbox手柄甚至是Switch手柄)
SteamOS基于Arch Linux,提供了游戏模式(大部分时间在这里)以及桌面模式。游戏模式自带了一个快捷菜单键,类似PS键,除了可以进行除了WiFi/蓝牙/飞行/亮度等调节,查看通知邀请啥的常见能力,最有意思的是可以进行性能配置,包括限制电量TDP来控制续航(只有40WH的电量,意味着功耗拉满25W,只能支撑1个半小时),锁帧率,以及开启采样技术等,后文提。
桌面模式是KDE,我第一次上手感觉和macOS的不太像,更像是Windows桌面的逻辑,不是很舒服,熟悉一段之后还好。文件管理器叫做Dolphin,使用起来反而更像macOS的Finder,标签页,边栏,打开终端啥的。终端模拟器叫做Konsole,比macOS的终端好用一点点。另外自带的软件商店叫做Flatpak,可以搜索各种应用如模拟器,Epic启动器等等。
桌面模式的Steam客户端可以进行一些复杂操作(实际上桌面模式上的Steam客户端和PC上操作完全一样),如添加非Steam游戏快捷方式,后文专门提及。
对于Windows游戏来说,SteamOS内置的Proton兼容层提供了转译。这个转译是API级别的(即实现了一套Win32 API,.NET API,以及DirectX转译Vulkan等),不是类似Apple M1对x86_64的指令集转译,在我测试游戏中表现挺好的,兼容性不错,帧率甚至超越Windows原生执行。
SteamOS和桌面模式默认情况下连接显示器后,就能以显示器原生分辨率(我测试过1080P和4K分辨率)显示UI,但是这和游戏分辨率是两回事。
当启动游戏后,Steam默认配置会把游戏锁定在1280x800,很多游戏感到非常的模糊(比Switch接电视还低)。查了一下才发现需要每个游戏进行配置,选中游戏
-> 属性...
-> 游戏分辨率
,从”Default”改为”Native”(指的是显示器原生分辨率)
这点想吐槽的是竟然没有全局开关,也许是为了适配各种PC游戏配置要求和支持分辨率混乱的现状……不过输出1080P甚至4K之后,明显感觉Steam Deck性能吃紧,大部分近两年的3D游戏都很难30帧以上运行,只有2D游戏可以继续拉满。这里就要提到下面的性能配置,能利用超采样技术以达到分辨率和性能兼得。
SteamOS的快捷菜单能进行各项性能配置,除了能有一个专门的浮层显示帧数和APU/RAM占用率等指标外,并且还能选择是全局配置还是仅当前游戏配置,非常灵活。看了下包括:
其中最有用的是这个全局FSR,Steam Deck的APU本身性能带一些CPU瓶颈的大作(如老头环),模拟器游戏等,明显会感到吃力,供电下只有30帧出头,基本属于不能玩水平(同期Switch/PS4:30帧流畅游玩)
不同于Windows下这种技术,需要每个游戏厂商自己单独支持,并在游戏内单独开启,这种全局FSR依赖了DirectX到Vulkan转译层直接全局干上,效果非常明显。
我用电视体验和实践下来,基本养成了习惯,对3D大作,先在Steam属性里改为输出1080P,然后游戏内设置720P的分辨率,最后菜单仅当前游戏配置FSR,这样3D模型会采样到1080P,同时帧率可以到40-60畅玩,只有2D纹理的文字会模糊点,但是体验明显会爽很多。
有一个注意的点,大部分游戏都需要在游戏内选择窗口模式而不是全屏模式,才能正常FSR生效(打开性能面板查看有一行”FSR:ON”)。虽然实际上游戏模式下,压根没有”窗口模式”全部都给你转译拉成全屏了,只有桌面模式才能窗口,这一点不知道是不是Bug?
总之基本所有3D游戏进去就找窗口模式+FSR就行,2D游戏发现有时候窗口模式会导致文字变模糊,所以不要动。总之PC游戏就是一个“配置灵活”(折腾)
PC游戏,众所周知就是优先按照键鼠交互开发的,大部分游戏都没有一方对手柄进行支持(大作或者主机移植的游戏基本才有)。因此Steam Deck提供了我见到主机最多的手柄按键,以及每个游戏级别的映射布局。
进入游戏后按Steam键就能看到“控制器设置”,里面可以看到每一个控制器(比如我电视玩的时候用PS5手柄,就能看到两排)的布局。这里可以选择基本就是:
当然,这里还可以切换到社区布局,下载其他人的布局映射文件,热门游戏都有很好的布局文件。另外这个配置会和你的Steam账户云保存起来,用PC加的配置也能用跨机器用),挺好的
另外,非Steam游戏(无论是通过Proton转译层的游戏,还是Chrome和Dolphin这种原生Linux),也可以进行映射,甚至能根据游戏名(可以改,下提)共享社区映射,这也是另一个我认为SteamOS比Windows好用的地方。
我们可以把桌面模式的应用添加到Steam吗?当然可以,比如SteamOS就会引导你添加Chrome到库里,这下真成了iPad之外的便携浏览器了。你可以添加各种工具,甚至VSCode,也有专门人分享的布局文件方便手柄操作这些软件😂
以及,在有时候我们不得已要用非Steam的Windows程序,如游戏启动器、汉化补丁、“学习版游戏”:),这时候我们肯定要Proton转译层,从命令行直接调用非常复杂我也懒得去看,利用把exe可执行程序添加到Steam库中,我们就能直接选择Proton转译并且享受各种便利(包括手柄映射,分辨率输出调整,截图管理,Shadercache等)
按照官方说明,添加方式需要进入到桌面模式(不理解为啥游戏模式没有入口),右键库选择“添加非Steam游戏到我的库中”,默认会显示桌面模式安装的原生Linux应用列表,我们不管,选择新增路径。
此时弹出的文件管理器中,可以选择不同硬盘的程序,比如机身里的(/home/deck/
下),TF卡里的(/run/media/deck/TF卡序号/
),无论是EXFAT还是ntfs文件系统的都能添加(用这个可以实现SteamOS和Windows双系统共享一个游戏,进度靠Steam云存档)。注意下方扩展名类型要选为”All”不然无法显示exe可执行程序
路径选择完成后再点一次“添加所选程序”就可以在库里看到了,默认游戏名是exe可执行程序文件名,右键属性
可以改名一下,然后选择兼容层选择最新版本的Proton(还有一个Proton Experimental不过我没用过),双击测试执行效果即可。
Proton兼容层是基于Wine的改进项目,虽然开源且在任意Linux上可用,但是Steam Deck用的版本有Value内部定制,对游戏的兼容性,以及执行速度有着非常明显的优势(以后就不用Wine了)😂
其中,Steam Deck有自己的一个Deck Verified标签,分为四档,其实一般来说除了不支持的档位以外都可以认为是可玩的。
另外,Deck Verified是有时效性的,有些游戏可能最新的SteamOS更新后就能正常运行,但是依然显示不支持,这种情况可以借助社区提供的:ProtonDB网页,查看其他玩家上传的实际体验(不过有些可能不是Steam Deck用户而是Linux PC+开源Proton用户,不可全信)
当然,实际上我买游戏时也不怎么看Deck Verified,实际能不能跑下载下来测一下便知(目前我库里不能跑的是2/80,极少),反正Steam不像主机厂商,游戏2小时内可以无条件退款,因此自己测试自己的库里的游戏才是最可靠的。
SteamOS在游戏模式下的设置-存储,可以看到当前的存储设备(机身存储+存储卡),以及其对应的游戏列表。默认插入存储卡SteamOS会推荐你格式化为ext4文件系统(但并不意味着SteamOS只识别ext4,btrfs,ntfs都支持)。
有一个坑是,在游戏模式下我们不能添加存储卡,我们只能进入桌面模式,右键Steam选择设置
->下载
->内容库
,此时会打开一个和游戏模式页面一模一样的UI,除了多了一个加号(离谱)。
点击加号会让你选择需要的路径来当作新的Steam库路径,一般我们直接选择存储卡的路径(/run/media/deck/存储卡ID
)就行。之后选择游戏选择移动就行。
接下来,为了设置启动时存储卡自动挂载,进入到桌面模式右键右下角状态栏,选择“Configure Removable Devices”,开启“mount at login“和”mount when attached“,就能开机自动挂载存储卡而不需要手动进入桌面模式选择了。
说明一个坑(Bug?):我的TF存储卡在后续制作Win To Go双系统时,格式化了为ntfs文件系统,虽然SteamOS有着Linux Kernel级别的ntfs支持了,但是偶现启动后会不以read-write挂载ntfs分区,而是以read-only挂载,会导致这里存储消失,与此同时你也不能在文件管理器里给TF卡写入任何内容。更新:经过大佬提示,发现需要在Windows 11中关闭快速启动(参考:How to Disable Fast Startup on Windows 10?),否则会导致无法read-write挂载NTFS存储卡。
出现这种状况后,你需要手动进入到桌面模式,右键状态栏的存储管理,重新Unmount再Mount一次,才可恢复正常,复现概率随机。只能说希望Valve尽快修复这类问题🙏
这两个是SteamOS的存储容量“其他”的罪魁祸首。
Steam游戏默认配置会开启Shadercache,因为Steam Deck硬件配置的唯一性,基本你安装所有的游戏,都会提前下载好离线编译好的Shader,不再需要运行时编译,大大减少游戏第一次加载和场景卡顿。甚至非Steam游戏也会把运行时转译编译的Shader缓存起来,这项功能是全局生效的(不同于Windows下,需要游戏厂商支持)
Shadercache路径在/home/deck/.local/share/Steam/steamapps/shadercache
下(这是Steam客户端根路径)
而Compatdata,是Proton游戏兼容层产生的文件夹,又称pfx,其本质是一个沙盒文件夹。Proton因为是模拟Windows的运行环境,其背后会做一个精简的Windows目录树(纯净大小约为180MB),分配给这个游戏,这个游戏对Windows系统的所有修改都只在这个沙盒中生效,包括注册表,存档文件,甚至可能是恶意破坏删除文件(非常Nice),也不会影响其他游戏。
在卸载游戏后,Shardarcache会被自动清理,没什么问题(注意自己添加的非Steam游戏不会清理,需要手动清理!)。但是有一个神奇的Bug是,这个Compatdata在卸载游戏后,竟然不会自动删除(无论是Steam游戏还是非Steam游戏),继续占用磁盘(180MB每个),挺膈应的。
对这两个文件,我找到了一个作者写的好用的工具Steam Deck: Shader Cache Killer来解决这个问题
使用也很简单,按照说明下载好以后会自动添加两个快捷方式到库里,打开zShaderCacheKiller.sh
就能看到所有的Shadercache/compatdata目录和对应游戏的名称,AppID信息,可筛选Non-steam和Uninstalled游戏。然后选择delete就行。
它还有另一个工具zShaderCacheMover.sh
,能把这两个文件夹移动到存储卡上而不是机身存储,对64GB小机身存储用户很重要(不会真有64G的不换硬盘吧)
值得注意的是,目前这个工具对非Steam游戏的名称识别并不好,必须你最近启动过这个游戏一次,才可以在列表显示(看代码是通过读了$STEAM/logs/content_log.txt
日志解析的,但是这个日志会定时清理……)。原因是非Steam游戏的AppID是根据“游戏名”+“路径名”的哈希得到,所以不能反推出原游戏名和路径名。
为了防止错误删除了正在玩的沙盒(包括游戏存档),简单傻瓜做法就是定期清理,记录已安装的这些非Steam游戏的AppID(或者无脑就是每次执行清理前先手动启动一次后再清理);或者你也可以全部运行一次当前库里所有非Steam游戏,不能识别的AppID自然是已经被卸载的。
Steam商店的游戏都有一个专门的元信息,其可以在SteamDB的Information和Configuration下看到,包括
其中我最近就遇到了一个问题,是这个游戏有一个自己的启动器(可能是C#写的),但是Steam游戏模式下,启动器无法弹出而卡住,只有桌面模式能弹出。我想跳过直接执行另一个实际游戏的exe可执行程序(与启动器搏斗和折腾……)
找了一圈,改名符号链接在Proton下也有兼容问题,最终还是简单粗暴,利用这个Steam Metadata Editor,可以直接修改库里面游戏的启动入口,包括启动的路径,默认参数等。
此外,它还可以添加多个启动入口,比如有些游戏包括类似游戏本体,启动器,创意工坊Mod编辑器,DLC章节啥的,可以添加不同的入口,方便管理,也不用自己手动对每个exe改成非Steam游戏添加入库。
注意这个工具建议在桌面模式用,不要在Steam客户端启动时修改保存(提前右键退出),才可以生效。
前面说了那么多都是SteamOS的用法,但是毕竟Steam Deck本质是一台Portable PC,那么它当然可以安装Windows(官方声明支持Windows 10/11)
虽然Windows系统我从大学就没再用过了(基本只有Mac+主机),但是按照网上的教程摸索差不多实现了从TF卡启动Windows,可以用来逼不得已的情况下跑一些只能在Windows上的软件(Proton兼容失效或者性能有异常的情况)
Win To Go制作工具,Windows自带的工具限制必须是“硬盘而非可移动存储设备”才行,比较麻烦还得用DiskGenius改分区,我参考教程用了这个WTG辅助工具,下载好Windows 11的镜像,一键写入到TF卡中
在写好Win To Go到TF卡之后,插入TF卡关机,然后同时长按音量减键和电源键,听到响声后放手,就会进入到启动选项页面。
在Boot Manager中选择”EFI SD/MMD Card”,然后就会进入Windows安装的引导流程了,后面就按照网上常见Windows安装流程走。注意家庭版是需要微软账号和联网的,默认Windows 11有网卡驱动,Windows 10没有,所以建议用专业版😂
安装完成后,会发现默认不再进入SteamOS了,继续进入启动选项页面,这里会出现一个”Windows Boot Manager”在首位,实际上我们Win To Go选择TF卡依旧也能启动,和他没啥关系,可以删除(参考后文“如何默认启动SteamOS而非Windows”)。
Steam官方提供了Windows的驱动,可以直接下载好并拷贝到Windows中安装,参考:Steam Deck - Windows Resources
Windows 11上,对inf格式驱动,需要右键并选择“显示更多选项”以查看“安装”选项。
安装Windows之后默认不再会进入SteamOS了,而SteamOS也没提供默认启动选项的能力(这个EFI引导做得比较垃圾),并且一个神奇的Bug,导致无论怎么改默认值,只要启动过一次Windows系统,”Windows Boot Manager”就会默认跑到第一位导致下一次永远默认Windows
我选择的方式是,直接通过禁用”Windows Boot Manager”这个EFI启动项,让SteamOS默认启动,如有需要,长按进入并选择”EFI SD/MMD Card”以启动Windows
具体修改方式可以在SteamOS也可以在Windows下操作,本质都是修改EFI的配置信息,以SteamOS举例子:
efibootmgr
,会列举查看到对应每个启动选项的数字编号,如Boot0004 *Windows Boot Manager
sudo efibootmgr -b 0004 -A
,注意如果没有设置Root密码需要提前用passwd
设置一次efibootmgr
,此时会不再显示代表激活状态的*
,如Boot0004 Windows Boot Manager
重启可以验证一下,启动列表不再显示这个即可证明成功,默认进SteamOS或者选择EFI SD/MMD Card
进入Windows
其实感觉对于主机玩家来说,Steam Deck的一大缺点,同时也是一大优点就是“可折腾”(Hackable),你总有一种方式,能在掌上玩到PC上的游戏。而对于主机本身配置就很少。Steam Deck它在我看来和Switch压根不是一个竞争对手,而更像是互补。
我能拿着Steam Deck去把打折时买的Roguelike游戏和文字游戏打通,插在电视上跑着40帧带有创意工坊Mod的AAA大作(比如FF7 RE笑),但是绝对不会在Switch上高价买这些PC键鼠设计且没有社区和创意工坊的游戏。
它更像是一个夹在PS5这种沉浸式沙发体验,和Switch掌机轻度娱乐和聚会游戏之间的设备,并且对纯Mac党(没有任何其他Windows设备)是一个非常好的替代品。
就这么多吧,这篇文章大部分都是介绍自己实际遇到的一些问题和教程,希望能对有心入Steam Deck或者遇到类似场景的人提供一些帮助吧:)
]]>ImageIO是Apple提供的上层框架,用于处理常见图像格式的编解码支持。这篇文章主要讲述了三个子话题:WebP/AVIF的支持进展,IOSurafce和硬件解码优化50%内存开销,以及CGImageSource机制变化导致的线程安全问题
ImageIO的定位是上层的支持框架,其封装了诸多的苹果的底层解码器,开源编解码器,硬件HEVC/ProRes加速器等等底层细节,致力于提供和上层UI框架(如UIKit/CoreGraphics)的可交互性。
在早些年的时候,我写过一系列文章,介绍了其API使用的基本流程(参考:《iOS平台图片编解码入门教程(Image/IO篇)》),以及有关其惰性解码的机制(参考:《主流图片加载库所使用的预解码究竟干了什么》)。
实话说,自从重心从iOS开发,转移到做LLVM工具链相关工作之后,我本以为不会再写这些上层iOS框架的文章了,但是SDWebImage这个开源库依旧没有合我预期的新Maintainer,来作为交接,因此现在还是忍不住先写这一篇吐槽和说明文章。
这篇文章会介绍,自iOS 13时代之后,苹果在ImageIO上做的一系列优化(“机制变化”),以及对开发者生态带来的影响。
自从HEVC/HEIF在苹果高调提供支持之后,由于硬件解码器的加持,本以为苹果会对其他竞争的媒体格式不再抱有兴趣,但实际上并非如此
WebP作为Google主导的无专利费的图像格式,其诞生后就一直跟随Chrome推广到各大Web站点,如今已经占据了互联网的一大部分(虽然其兄弟的WebM视频编码并没有这么热门)。
早在iOS 11时代,我就呼吁并提Radar希望Apple的ImageIO能够支持原生的WebP,而最终,时隔3年,在iOS 14上,ImageIO终于迎来了其内置的WebP支持,并且能够在Mac,iPhone上的各种原生系统应用中,预览WebP图像了。
那么,ImageIO对WebP的支持到底如何呢?答案其实很简单,ImageIO直接内置了开源的libwebp的一份源码和VP8的支持,并且去掉了编码的能力支持,所以能够以软件解码的形式支持WebP,不支持硬件解码。
换言之,使用这个ImageIO的系统解码器解码WebP,和使用我写的SDWebImageWebPCoder没有本质上的巨大差异(最多是一些编译器优化导致的差异),而后者还支持WebP编码(虽然耗时很慢)
AVIF是基于AV1视频编码的新兴图像格式,作为HEVC的无专利费的竞争对手。AVIF与AV1,HEIF和HEVC,这两大阵营的关系一直是在相互竞争中不断发展的。而各大视频站如YouTube,Netflix,以及国内的Bilibli都在积极的推广这一视频格式,减少CDN带宽和专利费的成本。
而随着Apple在2018年加入AOM-Alliance for Open Media之后,我就预测有朝一日能够看到苹果拥抱这一开源标准。在2021年WebKit的开源部分曾经接受了PR并支持AVIF软件解码。而在2022的今年,iOS 16/macOS 13搭载的Safari 16,已经正式宣布支持了AVIF
虽然目前没有在其他系统应用中可以直接预览AVIF,但是我们已经看到这一趋势。在ImageIO的反编译结果中也看到了对.avif
的处理和UTI的识别,虽然目前其本身只是会fallback到AVCI(AVC编码的HEIF,并不是AV1),但是我相信,后续OS版本一定会带来其对应的原生SDK和应用层的整体支持,甚至未来可以看到新iPhone搭载AV1的硬件解码器。
PS:广告时间,我之前也尝试过一些利用开源AV1解码器实现的AVIF解码库,以及macOS专用的Finder QuickLook插件,在未来到来之前,依旧可以发挥其最后的功用:)
1 | brew install avifquicklook |
IOSurface,作为iOS平台上古老的一套在多进程,CPU与GPU之间共享内存的方案,在早期iOS 4时代就已经诞生,但是一直仅仅作为系统私有的底层XPC通信用的数据格式
而从iOS 13之后,苹果对硬件解码的支持的图像格式的上屏渲染,大量使用了IOSurface,抛弃了原有的“主线程触发CGImage的惰性解码”的模式。
也就是说,《主流图片加载库所使用的预解码究竟干了什么》这篇文章关于ImageIO的部分已经彻底过时了,至少对于JPEG/HEIF而言是这样。
如何验证这一点呢?可以从一个简单的Demo,我们这里有一个4912*7360
分辨率的JPEG和HEIC图(链接),使用UIImageView渲染上屏,开启Instruments,对比内存占用
IOSurface:
1 | // JPEG/HEIF格式限定,iOS 13,arm64真机限定 |
CGImage:
1 | // JPEG/HEIF格式限定,iOS 13,arm64真机限定 |
数据较多,直接看IOSurface的结果,可以发现,除了峰值上HEIC出现了翻倍,最终稳定占用都为51.72MB
而直接用CGImage(或者你换用模拟器而不是真机),则结果为137.9MB(RGBA8888)
备注:
UIImage.preparingForDisplay()
也利用了它的能力UIImage(contentsOfFile:)
和这里的UIImage(data:)
,在iOS 15上并无明显差异,但是在低版本如iOS 13/14上,可能出现UIImage(data:)
对于HEIC格式,无法利用IOSurface的Bug,因此更推荐使用文件路径的接口反编译可以发现,苹果系统库的内部流程,已经废弃了CGImage来传递这种硬件解码器的数据Buffer,而直接使用IOSurface,以换取更小的内存开销,达到同分辨率下RGBA8888的内存占用的37.5%(即3/8),同分辨率下RGB888的内存占用的50%(即1/2)
你可能会表示很震惊,因为数学公式告诉我们,一个Bitmap Buffer的内存占用为:
1 | Bytes = BytesPerPixel * Width * Height |
而要实现这个无Alpha通道的50%内存占用,简单计算就知道,意味着BytesPerPixel
只有1.5,也就是说12个Bit,存储了3个256(2^8)色彩信息,换句话说0-255的数字用4个Bit表示!
你觉得数学上可能吗?答案是否定的,因为实际上是用了色度采样,并不是完整的0-255的数字,学过数字图像处理的同学都应该有所了解。
打开调试器,给IOSurface的initWithProperties:
下断点,发现这个创建的IOSurface很有意思,PixelFormat = 875704438('420v')
,即kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
,看来使用了YUV 4:2:0的采样方式
因此,这里应该对应有两个Plane,分别对应了Y和U两个采样的平面,最终由GPU渲染时进行处理。这里不采取YUV 4:4:4的原因是,大多数JPEG/HEIF的无透明度的图像,在肉眼来看,采样损失的色度人眼差异不大,这一优化能节省50%内存占用,无疑是值得的。
值得注意的是,这里苹果处理具体采样的逻辑也是和原图像编码有关的,如果YUV 4:4:4编码的,则最终CMPhoto可能依旧会采取YUV 4:4:4进行解码并直接上屏,苹果专门的策略类来进行处理。
不过,除了这一点,为什么只有真机能支持色度采样呢?答案和Core Animation的跨进程上屏有关。
之前有文章分享过之前,iOS的UI渲染是依赖于SpringBoard进程的中的CARenderServer子线程来处理的,因此这就有一个问题,我们如何才能将在App进程的Bitmap Buffer传给另一个进程的CARenderServer呢?
在iOS 13之前我们的方案,就是利用mmap,直接分配内存。但是mmap的问题在于,在最终Metal渲染管线传输时,我们依旧要经过一次额外的把Bitmap Buffer转为Texture并拷贝到显存的流程,因此这一套历史工作的横竖还有一些局限性。
在A12+真机的设备上,这一步借助IOSurface来实现跨CPU内存和GPU显存的高效沟通。
参考苹果的文档以及一些相关资料
IOSurface的资源管理本质上是Kernel-level而不是User-level的mmap的buffer,Kernel已经实现了一套高效的传输模型,借助Lock/Unlock来避免多个进程或者CPU/GPU之间发生资源冲突,因此这是上述优化的一个必要条件。
1 | let surface: IOSurface |
现在揭秘了苹果优化JPEG和HEIF硬件解码内存开销之后,下一个问题是:
作为开发者,我如果加载一个JPEG/HEIF网络图,有办法也利用这个优化吗?
答:可以,但是使用时需要遵守以下几个原则:
UIImage(data:)
UIImage(contentsOfFile:)
加载CGImageSourceCreateImageAtIndex
返回的是惰性解码的占位CGImage,而CGImageSourceCreateThumbnailAtIndex
返回的是解码后的CGImage(也就根源上无法利用IOSurface优化)UIImage.preparingForDisplay()
,甚至是如果仅有CGImage的情况下,也推荐创建一个临时UIImage再来调用。其原理是,对于上文提到的,惰性解码的占位CGImage,CMPhoto能间接进行IOSurface的创建(利用后文讲到的CGImageGetImageSourcce
),达到偷梁换柱的作用,而手动创建CGContext并没有这样的能力(可以参考#3368)如果遵守以上几点,那么我们依旧可以利用到这个优化,节省内存占用。否则会退化到传统的RGBA8888的内存开销上。尤其是关于第4点,苹果这个设计本想让开发者淡化IOSurface和CGImage的差异,但是我感觉反而增加了理解成本和性能优化成本。
另外,ImageIO和UIKit并没有提供更详细的IOSurface的公开API,只有其内部流程,本质间接使用了以下私有接口:
-[UIImage initWithIOSurface:]
CGImageSourceCreateIOSurfaceAtIndex
诚然,我们都知道能够直接调用任意的Objective-C/C API的姿势,这里也不再展开,只是需要注意,上文提到的这些优化,都存在特定iPhone硬件(A12+)和格式(JPEG/HEIF)的限定,需要注意检查可用性。
此外,从实践来看,苹果UIKit和ImageIO的上层接口,都更推荐文件路径的形式(因为可以优化为mmap读取,文件扩展名的Hint等逻辑),如果我还继续维护SDWebImage下去的话,未来也许会提供基于URLSessionDownloadTask以及文件路径模式的解码方案,或许就能更好地支持这一点。
曾经,在我的最佳设计模式观念里,一个Producer,产出的Product,永远不应该反向持有Producer本身。但是这个想法被ImageIO团队打破了
在iOS 15放出后的很长一段时间里,SDWebImage遇到一个奇怪的崩溃问题#3273,从堆栈来看是典型的多线程同时访问了CFData(CFDataGetBytes)导致的野指针。起初我对此并没有在意,以为又是小概率问题,并且@kinarobin提了一个可能的CGImageSource过度释放的修复后,我就关闭了这个问题。但是随后越来越多用户依旧反馈这个崩溃,因此重新打开仔细看了一下,发现了其背后的玄机。
玄机在于,iOS 15之后,Core Animation在主线程渲染CGImage时,会调用一个新增的奇怪的接口CGImageGetImageSource
。如果带着疑问进一步追踪调用堆栈,发现在调用CGImageSourceCreateImageAtIndex
时,ImageIO会通过CGImageSetImageSource
绑定一个CGImageSource实例,到CGImage本身的成员变量(实际来说,是绑定到了其结构体指针存储的CGImageProperty字典)。随后,Core Animation会通过获取到这个CGImageSource,后续在渲染时间接调用CGImageSource的相关接口。持有链条为 UIImage -> UIImageCGContent -> CGImageSource
这一机制改变,同时带来了一个隐患是:ImageIO它不再线程安全了。而且开发者不能修改Core Animation代码来强制加锁。
主要原因是,CGImageSource支持渐进式解码,而第三方自定义UIImage的子类时,有可能自己创建并持有这个渐进式解码的CGImageSource,并不断更新数据。在SDWebImage本身的设计中,我们通过加锁来保证,所有的对渐进式解码的调用,以及更新数据的方法,均能被同一把锁保护。
而当我们产出的CGImage,传递给了Core Animation,它无法访问这一把锁,而直接获取CGImageSource,并调用其相关的解码调用,就会出现多线程不安全的崩溃问题。
总而言之,这一设计模式的打破,即把Product和本不应该关心的Producer一起交给了外部用户,但是外部用户无法保障Producer的生命周期和调用,最终导致了这样的问题。
最终,针对这个问题,SDWebImage提供了两套解决思路,第一个思路是直接通过CGContext提取得到自己的Bitmap Buffer,得到一个新的CGImage,切断整个持有链,最简单粗暴的修复,代价是全量关闭惰性解码无法用户控制,可能带来更高的内存占用(#3387,修复在5.13.4版本上)
第二个思路是,通过抹除掉CGImage持有的这些额外信息,采取通过CGImageCreate重新创建一个复制的CGImage,但是依旧保留了惰性解码的可选能力(#3425,方案在5.14.0版本上)。顺便提一句,通常动图(GIF/AWebP)都不支持硬件解码且切换帧频率较高,关闭惰性解码依旧是小动图的最佳实践。
PS:对感兴趣的小伙伴详细解释一下,第二个解决思路利用了CGImageProperty(类似于CGImage上存储的一个字典,按Key-Value形式存取)的时机特性,使用CGImageCreate
重建CGImage时会完全丢失所有CGImageProperty(只有CGImageCreateCopy
能够保留)。
而上文提到的CGImageGetImageSource/CGImageSetImageSource
这些私有接口,本质上是操作这个com.apple.ImageIO.imageSourceReadRef
的Key(全局变量kImageIO_imageSourceReadRef
),Value存储了ImageIO的C++对象,并可以还原回一个CGImageSourceRef指针。一旦我们把CGImageProperty丢失掉,那么就能打断这个持有链条。
总结起来,ImageIO Team做出如此重大的设计模式改变,并没有在任何公开渠道同步过开发者,也没有提供公开接口能够控制这个行为,或者至少,没有暴露对应的CGImageSetImageSource
接口,导致第三方开发者不得不采取曲线救国的解决方案去Workaround,这一点很值得让人吐槽。
这篇文章看似讲了三个话题,其实背后有着一贯的缘由背景:
早期的ImageIO和各种上层框架的设计,是针对iPhone的低内存的机型做了深入优化,希望能尽量利用惰性解码,mmap缓存,换取较低内存开消,并且对各种无硬件解码的开源格式完全不感兴趣。
而最近几年,随着苹果芯片团队的努力,高内存,M1的统一内存,以及高性能芯片的诞生,苹果已经有充足的能力能够通过软件解码,共享内存,越来越多硬件解码器技术来满足主流的多媒体图像支持,本身这是一件好事。
不过问题在于历史遗下来的API,依旧保持了之前的设计缺陷,Apple团队却一直在,通过越来越Trick和Hack的方式解决问题,并没有给开发者可感知的新机制和手段来跟进优化(除开这一点吐槽,AppKit上的NSImage的NSImageRep这种代理对象设计,比UIImage的私有类UIImageContent设计要适宜的多,也灵活的多)
个人看法:软硬件一体加之闭源,会导致开源社区的实现,永远无法及时跟上其一体的私有集成,最终会捆绑到开发者和用户(开发者越强依赖苹果API和SDK,就会越强迫用户更新OS版本,进而捆绑硬件换代销售),这并不是一个好的现象🙃
SDWebImage开源项目如今缺少长久维护的Maintainer,如果你对iOS/macOS框架开发感兴趣,对图像渲染和Apple平台有所涉猎,对Swift/Objective-C大型开源项目贡献有所期待,可以在我的GitHub上,以Email,Twitter私信等方式联系我。
]]>此篇文章在字节跳动的技术公众号已经刊登:《字节跳动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方案,但是目标侧重点和我们略有不同,并且截至发稿日未有完整的结论,因此仅在引用链接提及供读者延伸阅读。
Apple在其LLDB和早期Xcode集成中,为了不侵入一些容易改动的上层逻辑,引入了LLDB Plugin的设计和支持。
每个Plugin是一个动态链接库,需要实现特定的C++/C入口函数,由LLDB主进程在运行时通过dladdr找到函数入口并加载进内存。目前有两种Plugin的接口形式(网上常见第一种)
1 | namespace lldb { |
这种Plugin,需要用户在脚本中手动按需加载,并常驻在内存中:
plugin load /path/to/plugin.dylib
1 | extern "C" bool LLDBPluginInitialize(void); |
将编译的动态库放入以下两个目录,即可自动被加载,无法手动控制时机,在当前调试Session结束时卸载:
1 | /path/to/LLDB.framework/Resources/Plugins |
正常流程中,Xcode开始调试时会启动一个lldb-rpc-server的进程,这个进程会加载Xcode默认工具链,或指定工具链中的LLDB.framework,并且通过这个动态库中暴露出的Script Bridge API调用LLDB的各功能。
监控流程中,我们向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时,需要一份含有要部署的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 | /// |
C++的成员函数的函数指针第一个应该是this指针,这里用self命名。也可以调用原实现先获取结果,再根据结果进行相关的统计逻辑。
1 | /// |
目前耗时监控包含下列场景:
经过观察,我们发现当在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
方法被调用,再记录此时时间戳算出耗时。
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命令
Attach进程时,lldb-rpc-server会调用SBTarget::Attach方法,常见于真机调试的场景。
这里在调用前后记录时间戳,计算出耗时即可。
Launch进程时,lldb-rpc-server会调用SBTarget::Launch方法,常见于模拟器启动并调试的场景。
这里在调用前后记录时间戳,计算出耗时即可。
为了进一步还原耗时的细节,除了标记场景的类型以外,我们还会统一记录这些非敏感信息:
在内网提供的版本中,也通过外部环境变量,得知对应的App的仓库标识,用于在内网的数据统计平台上展示和区分。
如图,这是内网大型Swift工程,飞书iOS App接入DanceCC工具链之后,某时间的耗时数据,可以明显看出,DanceCC相比于Xcode的变量显示耗时,优化了接近一个数量级。
除了基本的耗时时间收集以外,我们还希望能够及时发现新增的极端耗时场景和新问题,因此设计了一套极端耗时情况下的调试器堆栈收集机制,目前只要发现,展示变量场景和输入expr命令耗时超过10秒种,则会记录LLDB.framework的当前调用堆栈的每个函数耗时,并将数据上报到后台进行统计和人工分析。
堆栈收集使用了log timers dump所产出的堆栈和耗时信息,本质上是LLDB代码中通过LLDB_SCOPED_TIMER()
宏记录的函数,其会使用编译器的__PRETTY_FUNCTION__
能力来在运行时得到一个用于人类可读的函数名。
在获取到调用前和调用后的两条堆栈后,我们会对每个函数进行Diff计算和排序,将最耗时的前10条进行了采样记录,使用字符串一同上传到统计后台中。
无论是App还是工具链,在做性能优化的同时,数据指标建设是必不可少的。这篇文章讲述的监控方案,在后续迭代DanceCC工具链的时候,能够明确相关的优化对实际的调试体验有所帮助,能避免了主观和片面的测试来评估调试器的可用性。
除了调试器之外,DanceCC工具链还包括诸如链接器,编译器,LLVM子工具(如dsymutil)等相关优化,系列文章也会进一步进行相关的分享,敬请期待。
此篇文章在字节跳动的技术公众号已经刊登:《字节跳动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工具链上入手,提供定向的调试性能优化。
调研期间也确认到,借助自定义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分钟):
在介绍我们自定义LLDB工具链的优化之前,首先来简述一下LLDB的核心调试场景的工作流程,方便后续理解优化的技术点。
我们一期的目的是主要优化核心的调试场景,包括最常见的“断点陷入到Xcode左侧变量区展示完毕”(v),“点击Show Description”(po),“勾选Show Types”(p)。这些对应LLDB原生的下面三个交互命令。
图8:LLDB的交互命令
Apple在WWDC 2019-LLDB: Beyond “po”[2]中,进行了较为详细的介绍,这里我们进一步详细解释其部分工作流程,为后文的具体优化技术点提供参考。建议可以搭配视频一并学习。
po是命令expression --object-description -- [expr]
的alias
图9:po的流程
result = expr
得到IR1 | // 精简版,实际较为复杂,源代码搜@LLDBDebuggerFunction关键字 |
执行IR代码
获取执行结果
使用Swift编译器编译result.description
执行IR代码
获取执行结果字符串
对得到的字符串进行格式化输出
p是命令expression -- [expr]
的alias
图10:p的流程
使用Swift编译器编译result = expr
得到IR
执行IR代码
获取执行结果
对result
进行Dynamic Type Resolve
对得到的对象细节进行格式化输出
对比下来可以看到,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是命令frame variable [expr]
的alias
图13:v的流程
expr
的每一层访问(->或者.),得知当前变量的内存布局v的特点在于全程没有注入任何代码到程序中,也就是它是理论无副作用的。它的expr只支持访问对象的表达式(->/.等),不支持函数调用,并不是真正的C++/C/OC/Swift语法。
下述所有说明基于发稿日的Swift 5.6(优化思路也适配Swift 5.5)说明优化方案,后续不排除Apple或者LLVM上游进行其他优化替代,具有一定时效性。
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后续优化之后,建议恢复开启状态。
简述问题:LLDB在SwiftASTContext::LoadOneModule
时假设所有framework包装格式都是动态链接库,忽略了静态链接库的可能性。
在调试测试工程中,我们追踪日志发现,LLDB会尝试使用dlopen去加载静态链接库(Static Framework),这是很不符合预期的一点,因为对一个静态链接库进行dlopen是必定失败的,如日志所示(使用下文提到的复现Demo):
1 | SwiftASTContextForExpressions::LoadOneModule() -- Couldn't import module AAStub: Failed to load linked library AAStub of module AAStub - errors: |
查看代码阅读发现,这里触发的时机是,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分钟的额外开销。
仓库地址: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上车
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。
在混编工程中,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秒。
我们使用内部项目进行性能Profile时,发现Module::FindTypes
和SymbolFile::FindTypes
函数耗时调用占了主要的大头。这个函数的功能是通过DWARF(记录于Mach-O结构中),查找一个符号字符串是否包含在内。耗时主要是在需要进行一次性DWARF的解析,以及每次查找的section遍历。
LLDB本身是存在一个searched_symbol_files
参数用来缓存,但是问题在于,这份缓存并不是存在于一个全局共享池中,而是在每个具体调用处的临时堆栈上。一旦调用方结束了调用,这份缓存会被直接丢弃。
图15:symbols缓存参数
我们在这里引入了一个共享的symbols缓存,保存了这份访问记录来避免多个不同调用方依然搜索到同一个符号,以空间换时间。实现方案比较简单。
内部工程实测,下来可以减少10-20秒的第一次访问开销,而每个symbol缓存占据字节约为8KB,一次调试周期约10万个符号占据800MB,对于Mac设备这种有虚拟内存的设备来说,内存压力不算很大。另外,也提供了关闭的开关。
另一项优化Module::FindTypes
和SymbolFile::FindTypes
函数开销的方案是,原始的这两个函数会返回所有匹配到的列表,原因在于C++/Rust/Swift等支持重载的语言,会使用naming mangle来区分同一个函数名的不同类型的变种。这些符号名称会以同样的demangled name,记录到DWARF中。
但是调用方可能会关心同名类型的具体的变种(甚至包括是const还是非const),甚至有很多地方只取了第一个符号,搜索全部的Symbol File其实是一种浪费(在Swift 5.6版本中找到累积约10处调用只取了第一个)
我们对上述Module::FindTypes
和SymbolFile::FindTypes
函数,提供了一个新的参数match_callback
,用于提前过滤所需要的具体类型。类似于很多语言标准库提供sort函数中的stop参数。这样,如果只需要第一个找到的符号就可以提前终止搜索,而需要全部符号列表不受影响。
图16:symbols查找筛选参数
内部项目测试这项优化以后,可以减少C++/C/OC类型导入到Swift类型这种场景下,约5-10秒的第一次查找耗时。
在实际项目测试中,我们发现,Dynamic Type Resolve是有一些特例可以进行针对性的shortcut优化,剔除无用开销的。这部分优化仅对特定代码场景有效,并不通用。这里仅列举部分思路
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耗时。
我们在之后会有一系列的相关话题,包括:
另外,这篇文章提到的非定制的优化和功能,均会向Apple或LLVM上游提交Patches,以回馈社区。
这篇文章讲解了,大型Swift项目如何通过开关,以及自定义LLDB,优化Swift开发同学的调试速度,提高整体的研发效能。其中讲解了LLDB的部分工作流程,以及针对性优化的技术细节,以及实际效果。
我们的优化目标,不仅仅是服务于字节跳动移动端内部,更希望能推动业界的Swift和LLVM结合领域的相关发展,交流更多工具链方向的优化建设。
感谢飞书基础技术团队提供的一系列技术支持,以及最终业务试点提供的帮助推广。
感谢Apple同事Adrian Prantl在GitHub和邮件上进行的交流反馈,协助定位问题。
因为众所周知的原因,苹果的Xcode版本会不断提高自己的最低安装版本,在Xcode 13.0-13.2.1上,这个最低安装版本是macOS 11
而随着Xcode 13.3正式版放出,这个最低部署版本在最后关头被提升到了macOS 12
一般来说,各位开发者或者众多基建,总有各种各样的原因需要暂时留在老版本的macOS系统上,但是又希望使用新Xcode版本自带的Toolchain进行一些工作开发调试,有些是主观问题,有些是客观限制:
举例子:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --setglobalstate off
绕过GUI配置,导致一些公司采取Apple Device Management管理的电脑,无法正常关闭TCP拦截,会导致一些服务异常这些都是闲聊,进入正题。那么有没有办法能够绕开,或者从原理上来讲,是否这个系统绑定的最低部署版本限制是必要的?
下面来说明具体的逆向流程,和进行绕过的简单Step-by-step手法
先说明测试机器Mac环境和Xcode环境:
首先,作为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是一个画着❎的样子,直接打开会提示如下:
LSMinimumSystemVersion
作为iOS/macOS开发者,我们第一想到的就是,是否是Xcode.app对应的Info.plist中,设置了和最低部署版本相关的字段导致拒绝载入呢?
我们用另一个Xcode(或者plistutil)打开Xcode.app/Contents/Info.plist
,果然发现了对应的字段:
这个LSMinimumSystemVersion是Mac应用标准的声明最低部署版本的方式,修改为你的机器当前OS版本之后保存,执行
1 | touch /Applications/Xcode-13.3.0.app |
重新尝试双击。不错,这次我们打开了,初看起来不错(直到我们正式开始编译)!
但是只要创建一下工程并执行编译,就会发现,各种命令行工具的调用是有问题的,比如我们先通过xcode-select设置为当前的Xcode 13.2,尝试执行:
但是我们如果直接找到,执行对应绝对路径的clang,是可以执行的
并且,我们可以直接检查clang这个二进制,是否链接时设置了target,这部分可以使用otool -l读取machO Header查看到:
好,最低部署版本是macOS 10.14.6;那现在我们有充分的证据说明,一定可以在我当前的电脑运行clang,而上述提示应该是xcrun这个调度器,添加了额外的判断。
通过搜索关键词,可以在Xcode的strings输出中找到这句“Executable requires at least”的关键字:参考仓库:Xcode.app-strings
libxcodebuildLoader
我们定位到这个libxcodebuildLoader.dylib
,拖进Hopper尝试反编译理解他检查的原理,伪代码如下:
1 | void _checkMinimumOSVersion(int arg0) { |
好,阅读伪代码以及查阅资料可知:
xcrun
会先一步调用到xcodebuild
,检查DVTMinimumSystemVersion
这个变量的值是否和当前OS版本匹配。
而这个变量,竟然是通过CFBundleCopyInfoDictionaryForURL打开的。
参考苹果的函数说明,它除了常规的打开一个.bundle的文件夹,解析为NSBundle.infoDictionary以外,竟然能打开存在于二进制__TEXT,__info_plist
中的数据来解析为一个字典。所以我们接下来去找xcodebuild
的二进制看看。
参考:
_NSGetExecutablePath
:函数说明,大概理解获取当前程序的可执行路径xcodebuild
同时,出于好奇,我们可以再把xcodebuild
拖进Hopper去尝试理解,发现它整个程序竟然只有一个main函数,逻辑其实都在libxcodebuildLoader.dylib
中
1 | void _main(int arg0, int arg1) { |
DVTMinimumSystemVersion
其实大家也发现了,xcodebuild
二进制本身竟然内嵌了一段XML!我使用llvm-objdump
把它直接提取了出来:
1 |
|
看到我们关心的DVTMinimumSystemVersion
和LSMinimumSystemVersion
都在里面。其实也侧面证明了,真正的最低部署版本是macOS 11.0,而不是macOS 12.0(12.0只是苹果为了间接Push Developer去频繁更新macOS的阴谋罢了😂)
那下一步,要做的事情就是用魔改xcodebuild
并重新codesign。修改的方式多种多样,你暴力使用Hex Editor也是最简单。但是我更好奇的是这个__TEXT,__info_plist
的machO段和节的相关说明。
在网上搜索了一下相关资料,很容易就找到了感兴趣的资料:
基本解释得很明确清晰,如果你有源码,可以直接利用ld64的参数 --sectcreate __TEXT,__info_plist path_to/Info.plist
来注入你的Info.plist信息。没有源码可以手动修改machO结构并签名即可。
对于我此次跑Xcode 13.3来说,我选择最傻瓜最直观的Hex Editor修改(我用的是开源小工具HexFiend),只需要把12.0
修改为11.0
即可满足我的需要,并重新codesign一波。
codesign:
1 | codesign --remove-signature /Applications/Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild |
测试一下CLI,很正常
Xcode-13.3.0.app/Contents/Info.plist
中的LSMinimumSystemVersion
的值为11.0
Xcode-13.3.0.app/Contents/Developer/usr/bin/xcodebuild
中的DVTMinimumSystemVersion
的二进制为11.0
,或者使用我这个已经替换好的(建议还是手动参考上面步骤[修改DVTMinimumSystemVersion]替换,授人以渔而不是授人以鱼)自己很早之前曾经写过一些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和最低部署版本的坑,这里单独列举一下以防后人重复踩坑。
标准的配置下,我们可以直接在Xcode里创建一个Asset Catalog,然后拖入想要的文件。注意我们可以在右侧针对不同的配置设置不同的文件内容。
最终一个Data Asset的输入大概的形式是这样子的:
1 | Image.xcassets |
可以看到除了后缀名以外,其他的结构和普通的imageset保持一致。
在执行Xcode标准的Copy Bundle Resources
的Build Phase之后,可以看到我们的Data Asset会被编译为一个Assets.car文件,这个格式也是老熟人了。
类似于图像,由于Data Asset最终会编译到Car中,无法直接获取文件路径(Flutter/H5等跨平台库又需要使用Bridge方案来调用Native接口)
在运行时,我们需要使用Fondation提供的专门类NSDataAsset相关接口,来获取真正的NSData,接口比较简单直观:
1 | /** 如果是非Main Bundle,要获取Bundle |
看起来比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文件。
1 | s.ios.deployment_target = "8.0" |
看起来非常正常,但是实际上行为就是有所不同。于是简单开始从源头排查差异。
我们搜索查看Xcode最终编译的命令。负责编译xcassets的命令是actool。我们可以看到,在com.apple.actool.compilation-results
这里有打印所有的输出,是符合预期的。
1 | 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') |
检索产物Assets.car,也符合预期:
同样的,我们查看编译命令:
1 | 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') |
此时,在actool的编译结果中,我们发现,原本预期应该在Data Asset的ad_btn_hand.webp
和ad_btn_triangle.webp
两个文件,竟然直接拷贝到了.bundle的根路径,而不是Assets.car中!
对比两者的命令,只有--minimum-deployment-target
这一项有差距,宿主A是iOS 10.0,宿主B是iOS 8.0。
经过再次Demo验证,确定了是这个导致了行为的差异!
SDK运行时需要获取这些代码,经过查看,这里的代码是假设按照.bundle根路径存在Data Asset的文件名的方式去取的,因此在宿主A中会出现异常:
1 | // 伪代码 |
本质原因了解清楚后,进一步排查这个疑问:
为什么宿主A和宿主B,对于一个SDK的Pod,最低部署版本不一致?
因为SDK的Podspec的最低部署版本已经指明了iOS 8,按理说在哪个宿主集成都应该走的是路径的逻辑,而不应该受限于宿主iOS App自己的编译最低部署版本。
查看宿主A,发现宿主A使用了CocoaPods的插件,在Pod Project Generate的时候,强制修改了所有Pod,伪代码如下:
1 | all_targets.each do |target| |
导致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 | NSString *bundlePath = [[NSBundle bundleForClass:self.class].resourcePath stringByAppendingPathComponent:@"Image.bundle"]; |
矢量图,指的是通过一系列数学描述,能够进行无损级别的变化和缩放的一种图像。相比于标量图(如JPEG等标量图压缩格式),能够在绘制时进行任意大小伸缩而不产生模糊,甚至能够实现动态着色,动画等等一系列交互。
在当今移动端设备尺寸越来越复杂,各种操作系统级别的夜间主题(或者Dark Mode)越来越提倡的场景下,如果依旧使用标量图,我们需要针对不同的屏幕大小(如2x,3x),和对应主题场景(Light/Dark),提供NxM数量级的标量图,对于App大小开销是很大的。因此,使用矢量图是一个非常有效的解决方案。这个系列文章,就是主要侧重讲解iOS端上的矢量图解决方案。
第一章是关于SVG及其相应衍生方案的解决方案,后续会有其他矢量图相关的PDF章节,Lottie等。他们各自有不同的细节场景区分和优缺点。
SVG作为目前在Web上最流行的矢量格式,在iOS端的支持可以说是一言难尽。在这里,我从各个方向上总结了截至目前已有的实现(公开的方案,企业内部实现无从得知),方便对比选择最适合自己场景的选择。
Symbol Image,是Apple在WWDC 2019和iOS 13上提供的矢量图解析方案。
之所以名称叫做Symbol Image,源自于这个技术方案的实现细节,它最早诞生于SVG字体规范:OpenType-SVG。这个规范是Adobe提出的,并且得到了包括Microsoft在内的多家公司支持。Apple自己的CoreText字体框架,其实早早就在iOS 11时代内部支持了SVG类型的font table。
Symbol Image的整体API设计,其实不像是图像,更像是一种字体(和Icon Font类似)。
对于同一个Symbol Image,它可以看作是一个SVG Path的集合。前面提到,Symbol Image基于OpenType-SVG字体,对于字体来说,我们都知道字重的概念,用来决定渲染时候的线条粗细程度。
因此Symbol Image也有9个字重:Ultralight,Thin,Light,Regular,Medium,Semibold,Bold,Heavy,Black。与此同时,Symbol Image对每一个字重,支持了3种大小,分别是Small,Medium和Large。这也就是说,一个Symbol Image最多可以有27种大小字重的样式选择。
一般来说,从头构建一个Symbol Image会非常复杂,Apple推荐的方式,是通过使用SF Symbols App,来导出一个SVG模版,再通过Sketch来进行图层编辑。
从原始的SVG数据来看,每一个Symbol Image包含的所有样式都是一个单独的Path节点,对应了图标的绘制。如果要新建一个Symbol Image,需要完全删除Path节点,重新绘制矢量路径。
1 | <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200"> |
导入Symbol Image的方式非常简单,你只需要将制作好的Symbol Image,向Xcode的Asset Catalog窗口拖动,就可以集成。Xcode可以会展示对应的预览效果。
另外,实际上产生的文件夹后缀为.symbolset
,这个不同于普通的Asset Image(后缀名.imageset
),也就意味着你可以同时引入一个同名的Symbol Image和普通Image。
对于iOS 13系统提供的自带Symbol Image,UIKit提供了init(systemName:)方法来获取,对于App自行提供的Symbol Image,我们使用init(named:)方法。
注意,你可以同时包含一个Symbol Image和普通的Asset Image,共享一个Name。这样设计的好处,在WWDC上有介绍,是为了兼容iOS 12等低系统版本,在iOS 13上,Symbol Image优先级永远高于普通Asset Image,在iOS 12会自动fallback。
1 | let imageView = UIImageView() |
对于Symbol Image来说,我们可以指定在运行时需要的字重
1 | let regularSymbolImage = UIImage(named: "my.symbol.image") |
另外,我们还可以配合AttributedString使用,只要使用TextAttachment传入对应的Symbol Image即可。
1 | let textView = UITextView() |
优点:
缺点:
CoreSVG是iOS 13支持Symbol Image的背后的底层SVG渲染引擎,使用C++编写。
截至目前,CoreSVG依然属于Private Framework,社区也有很多人向Apple提了反馈并建议开放出来,可能在之后的WWDC 2020我们能够得知更多的消息。
注意!以下方法均为使用了CoreSVG的Private API,可能随着操作系统变动会有改变,并且有审核风险,如果需要线上使用,请自行进行代码混淆等方案。
目前Xcode不支持直接拖动SVG文件来集成到Asset Catalog,因为拖动SVG默认会当作Symbol Image处理。
但是我们可以通过一个取巧的方式来实现,Xcode支持PDF矢量图(从iOS 11与Xcode 9开始支持,PDF章会讲解)。因此,我们可以将SVG后缀改成PDF,然后拖动到Xcode中,最后再修改回SVG后缀名,并且同步.imageset/Contents.json
里面的文件名即可,如下:
当你添加好SVG图像后,可以通过Name,以和PDF矢量图一样的方式来引入和使用,如下
1 | UIImageView *imageView = [UIImageView new]; |
从运行时来看,加入Asset Catalog的SVG矢量图的UIImage,含有对应的CGSVGDocumentRef对象,并且也包含了一个标量图的缩略图,可以供缩略图或者其他系统API来调用。并且在Xcode的Interface Builder上也会有明显的SVG标识(类似PDF)
除了能够通过Asset Catalog添加SVG图像,通过CoreSVG,我们可以在运行时去解析网络数据下载得到的SVG数据,为此能提供更为广阔的应用场景。
1 | UIImageView *imageView = [UIImageView new]; |
一些UIKit的视图,或者一些图像处理,对矢量图支持并没有考虑,或者是我们在做性能优化时,需要将矢量图光栅化得到对应的标量图。CoreSVG提供了和CoreGraphics的PDF类似的接口,允许你去绘制得到对应的标量图。
1 | CGSVGDocumentRef document; // 原始SVG Document |
目前,CoreSVG没有提供类似于PDF的修改元素的接口,我们只能直接对SVGDocument进行导出。或许随着未来框架的开放,会有类似于目前CoreGraphics对PDF进行编辑的高级接口。
1 | // 获取SVG Document |
优点
缺点
SVGKit是最早的iOS上开源SVG渲染方案,已经有8年之久。SVGKit内部支持两种渲染模式,一种是通过CPU渲染(CoreGraphics重绘制),一种是通过GPU渲染(CALayer树组合)。有着不同的兼容性和性能。
示例
1 | // CPU渲染 |
优点
缺点
Macaw是一个矢量绘制框架,提供了非常简单的DSL语法来描述矢量路径绘制的场景。它本身不是和SVG强绑定的,但是对SVG格式提供了兼容和支持
示例
1 | let node = try! SVGParser.parse(path: "/path/to/svg") |
优点
缺点
SwiftSVG是一个专门针对SVG Path等常见特性的矢量图解析框架,他不侧重于完整的SVG/1.1规范支持,而是保证了基本的绘制实现的正确性,并且支持导出SVG的Path到UIBezierPath
示例
1 | let svgURL = URL(string: "https://openclipart.org/download/181651/manhammock.svg")! |
优点
缺点
VectorDrawable是Android平台上官方提供的一套矢量图解决方案,他是以一个类似SVG的XML表达形式,来描述矢量图的绘制方式。
从整体设计上看,VectorDrawable基本上是对SVG的精简和二次改造,大部分的元素在SVG中都有对应的概念,并且样式属性也一一对应。甚至,Android Studio支持直接将SVG导出成VectorDrawable文件并直接集成。
在iOS上平台上,Uber内部开源了一套自己在用的VectorDrawable实现:Cyborg,通过利用CoreGraphics和CoreAnimation来渲染VectorDrawable文件。
VectorDrawable提供了一个专门用于矢量图的View,并且能够制定对应的Theme(Theme是用来支持不同资源的Dark Mode切换的)。
1 | // Bundle加载 |
如果这个不满足,你也可以通过CALayer来做渲染,做更为细致的调节。并且VectorDrawable也提供了一些定制项(如设置tintColor)
优点
缺点
SVG-Native是由Adobe主导提出的一个W3C规范,目前处于Draft Stage,不过由于Apple,Google的赞同,大概率会在2020年内通过,并且正式规范定稿。
SVG-Native基于目前的SVG/1.1版本,是SVG/1.1的真子集(即一个SVG-Native图一定可以被浏览器正确渲染)。
注:曾经W3C有一个SVG Tiny的规范,但是它是针对移动浏览器场景的,和SVG-Native解决的问题是不一样的。
它针对移动平台,桌面平台等非浏览器场景做了针对性定制,废弃了一些Native端非常困难实现的功能,包括:
可以看出,这些剥离的功能都是和浏览器场景完全绑定的,不适用于通用的App内渲染矢量图的用途。SVG-Native更适合桌面/移动的App,渲染器实现也会精简很多,容易单元测试,并且可供操作系统内嵌集成。
Adobe提供了一个目前Draft规范的渲染实现SVG Native Viewer,目前提供了多种渲染引擎的桥接,包括我们熟悉的CoreGraphics和Skia。
SVG-Native解码器,能够以标量图的方式,渲染SVG到一个指定大小的CGContext上,性能目前看足够快(和CoreSVG对比)。目前一般是通过重写drawRect来让View大小变化时进行重绘。
1 | - (void)drawRect:(NSRect)dirtyRect { |
优点
缺点
总结一下关于SVG的相关解决方案,可以看出,没有一种Case能够涵盖所有场景,当然,这和Apple本身对矢量图支持的建设有一定关系,大部分建设依赖于开源社区。因此,通常情况下需要根据自己具体的实际需要来选择,比如:
Apple Watch作为苹果智能穿戴设备领域的重头,自从第一代发布已经经历了6次换代产品,操作系统的迭代也已经更新到了watchOS 6。
不同于iPhone的App,watchOS上的大部分App都侧重于健康管理,并且UI交互以直观,快速为基准。在2015年WWDC上,苹果发布的watchOS的同时,面向开发者发布了WatchKit,以用于构建watchOS App。
这篇主要讲了关于WatchOS上的App的架构介绍,基本概念,并深入分析了WatchKit的UI渲染逻辑,也谈了一些WatchOS和SwiftUI相关的问题。
其实写这个文章的最主要的原因,是在于自己前段时间写库时候,在SwiftUI与watchOS的集成中,遇到了相当多的问题,迫使我对WatchKit进行了一些探索和逆向分析,这里共享出来,主要原因有多个:
一个标准WatchKit App,可以分为至少两个部分:
如果没有接触过WatchKit,推荐参考这篇文章快速概览了解一下:NSHipster - WatchKit。只需要知道,我们的核心的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本身设计的是一个完整的客户端-服务端架构,在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依旧把它保留为私有,包括:
开发者在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 | @implementation WKInterfaceObject |
这里的提到服务端,在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。
在服务端启动后,它会加载Storyboard中的UI。对每一个客户端的Interface Controller,实际上服务端对应会创建一个View Controller,对应UIViewController的生命周期,会转发到客户端,触发对应的Interface Controller的willActivate/didAppear方法。
因此,watchOS创建了一个SPInterfaceViewController子类来统一做这个事情,它继承自SPViewController,父类又继承自UIViewController,使用客户端传来的Interface Controller ID来绑定起来。
对于UI来说,每一种WKInterfaceObject,其实都会有一个原生的继承自UIView的类去做真正的渲染,比如:
UIControl
UIImageView
UIImageView
MKMapView
UIControl
UIView
SPInterfaceViewController的主要功能,就是根据Storyboard提供的信息,构造出对应这些UIView的树结构,并且初始化对应的值渲染到UI上(比如说,Image有初始化的Name,Label有初始的Text)。实际上,这些具体的初始化值,都存储在Storyboard中,比如说,这里是一个简单的包含Table,每个TableRow是一个居中的Label,它对应的结构化数据如下:
1 | { |
这些信息会在运行时用于构建真正的View Tree。
值得注意的是,watchOS由于本身的UI,这些SPInterfaceViewController的rootView,一定是一个容器的View。比如说一般的多种控件平铺的Storyboard会自带SPInterfaceGroupView
,一个可滚动的Storyboard会自带一个SPCollectionView
,等等。这里是简单的伪代码:
1 | @implementation SPInterfaceViewController |
UI创建好以后,实际上我们的Extension代码会触发很多Interface object的刷新,比如说更新Label的文案,Image的图片等等,这些会从客户端触发消息,然后在服务端统一由AppDelegate接收到,来根据viewControllerID找到对应先前创建的SPInterfaceViewController。
1 | @interface SPApplicationDelegate : NSObject <SPExtensionConnectionDelegate, UIApplicationDelegate> |
因此,拿到UIViewController以后,WatchKit会根据前面传来的interfaceProperty来定位,找到一个需要更新的View。然后向对应的UIView对象,发送对应的property和value,以更新UI。
1 | @interface SPInterfaceImageView : UIImageView |
后续的流程,就完全交给UIKit和CALayer来进行渲染了。
通过这张图,其实完整的流程,我们可以通过调用栈清晰看到,如图各个阶段:
可以看出来,其实WatchKit这边主要的工作就是抽象了一层Interface Object而不让开发者直接更新UIView。在watchOS 1时代这是一个非常好的设计,因为Extension进程在iPhone中,而App进程在Apple Watch上。但是到了watchOS 2以后,依然保留了这一套设计方案,实际上开发者能自定义的UI很有限。
watchOS除了本身的App功能外,还有一些其他特性,比如这里提到的Long-Look Notification。这是在Apple Watch收到推送通知时候展示的页面,它实际上类似于iOS上的Notification Extension,可以进行自定义的UI。
苹果这里面对Notification提供了3种类型,根据能不能动态更新UI/能不能响应用户点击可以分为:
和普通的WatchKit UI一样,Notification依然使用Storyboard构建。并且有单独的Storyboard Entry Point。在代码里面通过WKUserNotificationInterfaceController的方法didReceive(_:)
,来处理接收到通知后的UI刷新,存储同步等等逻辑。
如图所示,整体的生命周期比较简单,可以参考苹果的文档即可:Customizing Your Long-Look Interface
按照之前说的,WatchOS的Native App中,使用了SPApplicationDelegate作为它的AppDelegate,也直接实现了UNUserNotificationCenterDelegate相关方法。
当有推送通知出现时,如果watchOS App正处于前台,会触发一系列UserNotification的通知。类似于UIKit的逻辑,就不再赘述。
如果watchOS App未启动,那么会被后台启动(且不触发UserNotification的通知),对应Storyboard中的WKUserNotificationInterfaceController实例会被初始化。加载完成UI后,会调用willActivate()
方法并自动弹起。
其实可以看出来,WatchKit主要做的事情,是在于watchOS App未启动时,需要对用户提供的WKUserNotificationInterfaceController,桥接对应的UserNotification接口和生命周期。
userNotificationCenter:willPresentNotification:withCompletionHandler:
被调用,它会向客户端发送消息,触发WKUserNotificationInterfaceController的didReceive(_:)
方法userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:
被调用,如果App不支持dynamic interactive notification,它会直接关闭通知,并唤起watchOS App到前台performNotificationDefaultAction
,performDismissAction
关闭通知(系统不再自动关闭通知),另外,系统给通知的最下方提供了一个默认的Dismiss按钮,点击后会强制关闭。个人见解:之所以watchOS非要封装一层,主要原因是watchOS 1时代,不支持自定义通知;在watchOS 2时代,UserNotification这个框架还不存在,UIKit和AppKit都各自有一套接收Notification的实现,而WatchKit也照猫画虎搞了一套(当时就用的UILocalNotification)。UserNotification这个跨平台的通知库,是伴随着watchOS 3才出现的,但是已经晚了,因此WatchKit继续在已有的这个WKUserNotificationInterfaceController上新增功能。
其实可以看到,WKUserNotificationInterfaceController实际上提供的接口,基本完全等价于UserNotifications + UserNotificationsUI,方法名类似,有兴趣的话自行参考官方文档对比一下watchOS Custom Notification Tutorial 和 iOS Custom Notification Tutorial
在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在watchOS上的快速上手,没有什么比Apple官方文档要直观的了,有兴趣参考:SwiftUI Tutorials - Creating a watchOS App
这里不会专门介绍SwiftUI的基础知识,后续我可能也会写一篇SwiftUI原理性介绍的文章。但是这篇文章,主要侧重一些SwiftUI在watchOS的独有特性和注意点,以及一些自己发现的坑。
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初始化方法,允许你代码中动态创建,这里是全部的列表:
桥接了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 | @implementation WKInterfaceMap |
另外,这种使用init注册的WKInterfaceObject,会保留一个对应UIView的weak引用,可以在运行时通过私有的_interfaceView
拿到。SwiftUI内部在布局的时候也用到了这个Native UIView来实现。
通过从Native watchOS App的布局分析上来看,SwiftUI参考iOS上的方案,依旧是用了一个单独的UIHostingView来插入到Native App的视图层级中,也有对应的UIHostingController。
但是不同于iOS的是,SwiftUI会对每一个Push/Present出来的新View(与是否用了上面提到的WKInterfaceObjectRepresentable无关,这样设计的原因见下),额外套了一个叫做SPHostingViewController的类,它继承自上文提到的SPViewController。
每个UIHostingController套在了SPHostingViewController的Child VC中,对应View通过约束定成一样的frame,可以看作是一个容器的关系。
当你的SwiftUI View,含有至少一个WatchKit Interface Object之后,这个SPHostingViewController就起到了很大作用。它需要调度和处理上文提到的WatchKit消息。SPHostingViewController内部存储了所有interface的property,Native UIView列表,通过遍历来进行分发,走普通的WatchKit流程。它相当于起到一个转发代理的作用,让这些WatchKit的Interface Object实现不需要修改代码能正常使用。
到这里其实事情还算简单,但是还有一种更为复杂的情形。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。
在历史上,所有的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,在代码里面需要对此提前判断。
独立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。
SwiftUI为watchOS App提供了一个新的出路,它可以说是真正的能够发挥开发者能力来实现精致的App,而不再受限于系统提供的基本控件。而WatchKit,也已经完成了它的使命。相信之后的SwiftUI Native App将会为watchOS创造一片新的生态,Apple Watch也能真正摆脱“iPhone外设”这一个尴尬的局面。
Hopper,全称Hopper Disassembler,是一个macOS和Linux平台上的反汇编IDE。提供了诸如伪代码,子程序,脚本,Debugger,Hex编辑等等一些列工具。相比于其他知名的反汇编工具如IDA,最大的好处是对平台特性,也就是Objective-C的反汇编有优化,提供非常贴近原始代码的伪代码(IDA目前则会是保留诸如objc_msgSend的伪代码),并且新版本也对Swift提供了一定的反汇编符号优化,因此作为探究iOS平台上的SDK实现,可以说是一利器。
Hopper本身目前是收费的软件,提供了免费的使用(30分钟)。官方下载地址为:https://www.hopperapp.com
Mac版本后解压,拖到Application下即可使用。
对于个人使用,价格不菲,有两种方案,个人比较推荐第一种
对于只是尝鲜或者轻度使用,其实使用免费版即可。网上现在也有针对旧版本的Cracked版本,不过存在一些问题和崩溃。如果是在需要,可尝试链接
Hopper提供了一个教程,可以参考官方简易教程
针对我们的场景:分析iOS的SDK内实现或者问题,我这里提供了一个Step By Step的过程,教你如何查找问题。
首先,我们需要获取一份iOS SDK的二进制Mach-O文件。最简单的方式,是通过Xcode提供的iPhone模拟器去获取它。在获取之前,我们先了解一下iOS SDK对应的二进制文件路径。
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 系统提的库和二进制,可以简单分以下几类,按照需要选择对应的相对根路径:
/System/Library/Framework
/System/Library/PrivateFrameworks
/Applications
/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固件中提取),见下文。
在真机上,为了加快动态库的加载,并减少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,市面上一些工具其实早已过期,要么不支持arm64,要么存在Bug。但实际上,Apple自己开源的dyld源代码,就已经包含了这样一个命令行工具,叫做dsc_extractor
,我们这里直接用来源码来编译一份来使用即可。
进入opensource.apple.com选择最新的macOS的版本,我这里例子使用的是我写这篇文章最新公开的 macOS 10.14.1
然后下载两份代码,一份是dyld,一份是CommonCrypto
为了编译,需要一点小技巧,但是对于iOS开发者我觉得挺简单
dyld.xcodeproj
macosx.internal
改成公开的macOS
dsc_extractor.cpp
,看到最后有一个test program
,把上面的#if 0
改成#if 1
CommonDigestSPI.h
的私有头文件,这个在下载的CommonCrypto工程中,拖进来改一下引用方式即可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了。
现在我们已经有了一个UIKitCore的Mach-O文件了,我们打开Hopper来载入它。我们可以使用Command+Shift+O来选择一个Mach-O文件,也可以将文件拖动到Hopper界面上来打开。
载入Mach-O文件后,Hopper会弹出框来选择具体分析的内容,大部分情况直接确认即可。如果是分析其他类型的文件,可能有特例如下:
载入开始后,一般需要等待一段时间来分析(下方会有进度条),等待分析完成后,你可以将当前分析的结果,保存成一个.hop
结尾的文件,未来就不再需要分析了,非常有用(注:免费版不可用)。
左侧有一个符号框,从左到右依次表示:
@"", ""
写的内容每项内容都支持搜索,一般来说取决于我们要解决的问题,有大概几个场景
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,可以在弹出的窗口中搜索或者依次检查)
Objective-C会使用到Block,而Block由于其实现原理,会生成对应的C方法,Hopper目前原生解析的Block语法并不是很直观,这里提供一个简单的说明。
其实Hopper反编译出来就是Block实现的原理,如果对于Block实现原理不清楚,建议可以先看一遍《这个教程》
1 | dispatch_async(dispatch_get_main_queue(), ^{ |
Hopper原生反编译如下,实际Block代码会单独在另一个C方法中,在block implemented at:
提示对应的方法中
1 | dispatch_async([objc_retainAutoreleaseReturnValue(*__dispatch_main_q) retain], ^ {/* block implemented at ___29-[ViewController viewDidLoad]_block_invoke */ } }); |
如果Block捕获了变量,那么根据Block的实现原理,可以知道这些变量在Block中可见的变量都是被值宝贝,对于NSObject就是指针
如果使用__block
修饰,那么会保留原始的变量的指针,对于NSObject就是对象指针的指针,我们可以通过这个简单识别。
比如对于这样代码:
1 | NSObject *obj = [NSObject new]; |
实际反编译出来的结果长这样:
1 | int ___29-[ViewController viewDidLoad]_block_invoke(int arg0, int arg1) { |
对应的arg0
就是第一个参数,而最后参数对应的是block_impl_0
实现结构体,可以忽略。
一些带有inline数值计算的方法,会被苹果的clang在编译时优化,实际上并不是你看到的头文件的样子,这种就需要我们枚举出来,人肉还原回他的实现,举个例子:
这样的代码:
1 | CGRect rect = CGRectMake(0, 1, 2, 3); |
反编译结果:
1 | intrinsic_movsd(xmm1, *double_value_1); |
可以看到有mov
之类的汇编命令调用,其实这就是为了压栈其实大部分场景我们只要熟悉简单的mov
add
sub
mul
几个基本的汇编命令的意义即可。
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非@objc
和dynamic
的属性和方法,会类似于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。
反编译本身就是二进制安全中的灰色地带,而且还有类似二进制加固等攻防模式,并不是万能方式去了解一个程序运行的方式。还需要配合自己的编写代码经验,才能更好地解决问题
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开发文档,参考:Quick Look Programming Guide
但是文档已经稍显过时,遇到的一个坑点也没有提示,因此这里更详细直观的介绍一下QuickLook开发的流程。
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 | Project |
QuickLook Plugin支持两种情形的功能展示:一个是对文件,按下空格来展示的窗口预览,在使用Option+空格进行全屏预览时候也会展示,后面都称作Preview
另一个是用来给Finder,来提供一个缩略图展示,这样一些图像格式,视频格式,在Finder中就能直接看到对应的缩略图,而不是一个僵硬的默认图标。后文都称作Thumbnail
由于QuickLook的核心,是希望对指定的文件格式,提供一个展示的UI和缩略图。那么在继续进一步写代码之前,我们必须得首先清楚自己需要的文件格式是什么,并了解UTI的概念。如果这一步骤处理的有问题,你的QuickLook Plugin是无法按预期的想法,被调用的。
在继续下一步之前,你需要对你想支持的文件格式,选择一个UTI (Uniform Type Identifiers).
QuickLook,在用户按下空格开始Preview的时候,会根据每个QuickLook Plugin注册的UTI,依次去询问,直到找到第一个返回成功的,最后来判定选择哪个Plugin进行展示。
建立好模版之后,打开Info.plist
,在顶层的LSItemContentTypes
项里面,添加你的Plugin所能支持的UTI,是一个数组,会按照先后顺序匹配,一般建议只写自己能准确识别的UTI,如果是一个通配的Plugin(如通用图片预览,通用代码预览),可以使用UTI继承关系的父级(public.image
, public.source-code
等)
1 | <key>CFBundleDocumentTypes</key> |
在配置好Plugin支持的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对应一段XML的定义,其中声明了它的类型(继承关系),UTI字符串,简介名称,扩展名,标准链接等等,基本的格式如下,很容易理解。这里是自己定义的一个AVIF格式的描述
1 | <dict> |
如果你想支持QuickLook的文件格式,已经有了系统分配的UTI,或者第三方App定义好的UTI,那么你要做的,就是导入一个UTI。
如果要导入UTI,你需要在Info.plist
中,使用UTImportedTypeDeclarations
这个项,来导入对应的UTI描述内容,值是一个数组,数组每项都是上面提到的UTI定义。
PS:对于导入UTI来说,你其实并不需要完整的把别人的声明抄过来,只要存在UTTypeIdentifier
项即可,但是这样写能更清晰了解对应的格式描述。
1 | <key>UTImportedTypeDeclarations</key> |
反之,如果你想支持的QuickLook的文件格式,不存在已有的UTI,那么你需要新增一个并导出。
如果要导出UTI,你需要在Info.plist
中,使用UTExportedTypeDeclarations
这个项,来导出对应的UTI描述内容,值是一个数组,数组每项都是上面提到的UTI定义。
1 | <key>UTExportedTypeDeclarations</key> |
值得注意的一个坑点,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:
Script Editor
,创建一个新文档Application
,名称随便写一个Dummy.app
,导出Dummy.app/Contents/Info.plist
UTExportedTypeDeclarations
项目Dummy.app
,放到工程下,直接拖进来当作资源,添加到Copy Bundle Resource
过程中这样一波操作以后,你最后构建得到的QuickLook Plugin,就能自带一个导出的UTI,然后被系统识别,最终被真正加载。
准备好上述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 | NSString *path = [(__bridge NSURL *)url path]; |
下一步就是绘制和渲染我们的UI,QuickLook支持两种方式渲染:
这里假设已经了解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 | CGFloat width = CGImageGetWidth(cgImgRef); |
这样基本就完成了,我们绘制了一个完整的图像到CGContext上,QuickLook会渲染到屏幕上,大小是我们指定的图像大小。
如果你的QuickLook插件,需要有一个异步的处理和等待,同时可以实现这个取消的入口函数,来减少CPU占用,优化一下用户体验
1 | void CancelPreviewGeneration(void *thisInterface, QLPreviewRequestRef preview) |
比如说,对于大图像解吗,可以中断解码提前释放内存。
QuickLook Preview还有另一种渲染方式,就是使用QuickLook预置的文件类型支持,来提供相应的数据。对应文档:Dynamically Generating Previews
我们需要使用QLPreviewRequestSetDataRepresentation,来提供一个预置支持格式的Data Buffer给QuickLook。
支持的格式有:
kQLPreviewPropertyAttachmentDataKey
带上CSS的数据1 | NSImage *image; |
值得一提的是,得益于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)
说完了关于Preview的实现代码,现在再来看看关于如何生成Finder用到的文件缩略图
Thumbnail也支持两种模式
第一种方式,和上文一模一样,这里就不再赘述了。我们可以看看第二种方式。我们只需要提供一个CGImage,或者一个Image/IO支持的图像格式的Image Data即可
1 | // 如果是原生支持的格式,使用QLThumbnailRequestSetImageWithData |
对应在Finder中缩略图如下:
作为一个插件,要调试起来比起一般的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中填写成上述的参数。
这样,你可以给你的对应代码下上断点,当你再次点击Run来运行时,会自动触发单步调试,检查存在的问题。
整体看下来,QuickLook Plugin的开发流程并没有多么复杂,其实你要做的就是用已有的Core Graphics绘制知识,并不涉及到AppKit相关概念,对于iOS开发者也能快速上手。
其中的坑,主要在于没有文档说明新增UTI,需要绑定一个App,而不是QuickLook Plugin本身能够声明的,对应也介绍了一个聪明的方式绕过这一限制。希望能帮助到有同样需求的人。
自己的AVIF QuickLook Plugin也终于完工,欢迎有兴趣的人尝试,并且给一点Star:
这里还有一些推荐和自己用到的QuickLook Plugin,也列举出来,能大大提升日常使用效率哦
很多图片库,都会有一个类似叫做Force-Decode,Decode For Display之类的感念,很多人可能对这个过程到底是为了解决什么问题不清楚,这里写一个文章来说明它。
这里列举了各个图片库各自的说法,其实讲的都是完全相同的一个概念。
forceDecode
, decompressImages
的概念decodeForDisplay
的概念backgroundDecode
的概念为了解释这个过程具体的解决问题,需要至少了解苹果的系统解码器的工作流程。
Image/IO库是苹果提供的,跨所有Apple平台的系统解码器,支持常见的各种图像格式(JPEG/PNG/TIFF/GIF/HEIF/BMP等)的编码和解码。同时,有丰富的接口来和诸如Core Graphics库协作。
常见的网络图像解码,由于拿到的是一个压缩格式,肯定需要想办法转换到对应的UIImage。UIImage可以分为CGImage-based和CIImage-based,后者相对开销大一些,主要是用作滤镜等处理,不推荐使用。所以基本上各种图片库解码,为了解码压缩格式,得到一个CGImage,都是用了Image/IO的这个API:
CGImageSourceCreateImageAtIndex
实际上,Image/IO,除了调用具体的解码器产生图像的Bitmap以外,为了和Core Graphics库协作,也直接以CGImage这个数据结构来传递,但是他采取了一种惰性解码的创建方式。因此这里首先要了解CGImage初始化的接口和对应的行为:
这里面其他参数都好理解,具体看一个provider
参数,这里面需要传入一个CGDataProviderRef
,它是一个关于描述怎么样去获取这个Bitmap Buffer二进制数据的结构。再来看看CGDataProvider的初始化方法,这时候发现它有多种初始化方式,决定了后面的行为。
这个方法,允许接受一个CGDataProviderCallbacks
参数,看说明,可以知道,这个callbacks是一系列函数指针回调,目的是提供一个sequential-access的访问模式,同时Data Buffer会被copy出去。同时,由于传入的是callbacks,可以做到不立即提供Data Buffer,而是在未来需要的时候再触发。
这个方法,类似于CGDataProviderCreate
,但是注明了这个callbacks生成的Data Buffer不会被Copy,Core Graphics只会直接访问返回的Data Buffer指针,需要自己管理好内存。
这个方法,需要提供一个CFData,同时也不会Copy这个CFData。在Release的同时由Core Graphics自动释放CFData的内存,开发者不需要管理内存。
剩余的具体初始化方法可以看文档说明,总而言之,CGDataProvider提供了各种各样的访问模式,如直接访问,拷贝访问,惰性访问等。而现在问题就来了,前面说到,Image/IO创建CGImage的时候,也需要提供一个DataProvider来指明图像的Bitmap Buffer数据从哪里获取,它是具体用了什么方式呢?
答案是使用了一个私有APICGImageCreateWithImageProvider
,经过查看,这个方式实际类似CGDataProviderCreateDirect
,也就是通过一组callbacks,提供了一个直接访问,允许惰性提供Data Buffer的方式。换句话说,这也就意味着,Image/IO,其实采取的是一种惰性解码方式。解码器只预先扫描一遍压缩格式的容器,提取元信息,但是不产生最终的Bitmap Buffer,而是通过惰性回调的方式,才生成Bitmap Buffer。
换句话说,通过所有CGImageSourceCreateImageAtIndex
这种API生成的CGImage,其实它的backing store(就是Bitmap)还没有立即创建,他只是一个包含了一些元信息的空壳Image。这个CGImage,在最终需要获取它的Bitmap Buffer的时候(即,通过相应的API,如CGDataProviderCopyData
,CGDataProviderRetainBytePtr
),才会触发最后的Bitmap Buffer的创建和内存分配。
理解到上面Image/IO的惰性解码行为,理解了上面一点,现在说明Force Decode所解决的问题。
众所周知,iOS应用的渲染模式,是完全基于Core Animation和CALayer的(macOS上可选,另说)。因此,当一个UIImageView需要把图片呈现到设备的屏幕上时候,其实它的Pipeline是这样的:
这个流程看起来没有什么问题,但是注意,Core Animation库自身,虽然支持异步线程渲染(在macOS上可以手动开启),但是UIKit的这套内建的pipeline,全部都是发生在主线程的。
因此,当一个CGImage,是采取了惰性解码(通过Image/IO生成出来的),那么将会在主线程触发先前提到的惰性解码callback(实际上Core Animation的调用,触发了一个CGDataProviderRetainBytePtr
),这时候Image/IO的具体解码器,会根据先前的图像元信息,去分配内存,创建Bitmap Buffer,这一步骤也发生在主线程。
这个流程带来的问题在于,主线程过多的频繁操作,会造成渲染帧率的下降。实验可以看出,通过原生这一套流程,对于一个1000*1000的PNG图片,第一次滚动帧率大概会降低5-6帧(iPhone 5S上当年有人的测试)。后续帧率不受影响,因为是惰性解码,解码完成后的Bitmap Buffer会复用。
所以,最早不知是哪个团队的人(可能是FastImageCache,不确定)发现,并提出了另一种方案:通过预先调用获取Bitmap,强制Image/IO产生的CGImage解码,这样到最终渲染的时候,主线程就不会触发任何额外操作,带来明显的帧率提升。后面的一系列图片库,都互相效仿,来解决这个问题。
具体到解决方案上,目前主流的方式,是通过CGContext开一个额外的画布,然后通过CGContextDrawImage
来画一遍原始的空壳CGImage,由于在CGContextDrawImage
的执行中,会触发到CGDataProviderRetainBytePtr
,因此这时候Image/IO就会立即解码并分配Bitmap内存。得到的产物用来真正产出一个CGImage-based的UIImage,交由UIImageView渲染。
上面解释了ForceDecode具体解决的问题,当然,这个方案肯定存在一定的问题,不然苹果研发团队早已经改变了这套Pipeline流程了
由此可见,这是一个拿空间换时间的策略。但是实际上,iOS设备早期的内存都是非常有限的,UIKit整套渲染机制很多地方采取的都是时间换空间,因此最终苹果没有使用这套Pipeline,而是依赖于高性能的硬件解码器+其他优化,来保证内存开销稳定。当然,作为图片库和开发者,这就属于仁者见仁的策略了。如大量小图渲染的时候,开启Force Decode能明显提升帧率,同时内存开销也比较稳定。
当我们说完Image/IO系统库和Force Decode关系后,再来看看另一种情形。近些年来,一些新兴的图像压缩格式,如WebP,得益于开源,高压缩率,更好的动图支持,得到了很多开发者青睐。
然而,这些图像格式,并没有被iOS系统解码器所支持,也没有对应的硬件解码。因此,现有的图片库在支持新图像格式的时候,都采取了使用CPU进行软件解码来处理。这些软件解码器,大部分是为了跨平台而实用的,因此,一般都有一个接口直接产出一个Bitmap Buffer来用于渲染。如WebP的官方解码器libwebp,就有这样一个接口:
1 | WEBP_EXTERN VP8StatusCode WebPDecode(const uint8_t* data, size_t data_size, WebPDecoderConfig* config); |
上面我们知道CGImage和CGDataProvider的不同初始化方式,开发者面临这样的接口,有两个选择:
当然,为了最大程度的利用苹果系统的那套Pipeline和现有代码流程,第一直觉的使用方式当然是方案2。然而,理想是丰满的,现实是骨感的。之所以Image/IO能够采取惰性解码这一套流程,最大的原因在于Image/IO的原生图像格式都是硬件解码,且解码速度足够快
同样的方式,套用到WebP上,反而会带来更大的问题。首先,WebP格式自身的压缩算法采取了VP8,比起JPEG/GIF的压缩算法要复杂的多,开销大。第二,libwebp只有软件解码的实现,无法利用硬件来加快解码速度。
注:YY的作者有专门跑过测试,对于iPhone 6上,同样压缩比的有损JPEG和WebP相比,解码速度慢大概50%-100%,无损的PNG和WebP相比比较接近。参考:https://blog.ibireme.com/wp-content/uploads/2015/11/image_benchmark.xlsx
所以,主流图片库最终的选择方式,都是方案1,即立即生成了一个含有Bitmap Buffer的CGImage。这样,到最终UIImageView渲染的时候,也不会有额外的主线程解码的开销,除了需要提前分配内存以外别的还好。
前面说到,对于WebP等非硬件解码器支持的图片压缩格式,大多数图片库采取了方案1。但是现有的一些图片库(如SDWebImage/YYWebImage),仍然对这个非空壳的CGImage,执行了Force Decode的过程,按理论上说已经有了Bitmap Buffer,不会触发主线程解码,这又是为什么?
这个原因,是源于先前的Force Decode的实现机制,利用到了CGContextDrawImage
这个接口。
CGContextDrawImage
,内部实现非常复杂,因为对于一个CGImage来说,他只是Bitmap Buffer+图像元信息的合集,但是一个CGContext,是有一个固定的ColorSpace,渲染模式等等信息,是和具体的上下文相关的。
因此,当通过这个API画在一个画布上时,会触发很多细节的逻辑,这里举几个比较有影响的。
CGContextDrawImage
的时候,会直接把取到的Bitmap Buffer,立马提交到render server进程上(通过mmap),这样最后在渲染Pipeline(前文提到)中,就可以省去第5步(拷贝CGImage的Bitmap Buffer到Surface(Metal或者OpenGL ES Texture)上)。见下:其实对于大部分图片库的Force Decode来说,因为都开的是一个和CGImage同大小的空白画布,这里主要是第1和第4项会影响到性能。一些图片库,因此依旧保留了Force Decode的流程,也有各种各样的具体缘由。
了解了为什么对于WebP等软件解码,依然使用Force Decode的缘由,再来看看这种Case下的优缺点
可以看出,这也是一个类似空间换时间的策略。当然,这个策略的优势没有Image/IO那样大,因为实际上转换和拷贝内存的性能开销,比起解码和创建Bitmap Buffer都是非常低的。但是一些图片库把这个选择权利交给了用户,而自己不做这个策略选择。
PS小轶闻:SDWebImage其实最早只有对Image/IO的那个ForceDecode流程,后来在4.0加入WebP支持的时候,也不清楚这个流程影响,顺便就一块使用这套流程了。可以说是所谓的误打误撞。
这篇文章基本介绍了Image/IO的惰性解码流程,以及Force Decode这套流程它所解决的问题,以及优缺点。无论对图片库作者,还是图片库进阶使用者,都解释了相关的疑问。希望对图片编解码方向有兴趣的同学可以多多学习交流。
]]>这篇文章介绍了关于CocoaPods的资源管理行为,对于Pod库作者是必须了解的知识。同时介绍了CocoaPods使用Asset Catalog的注意事项。如果已经了解某方面知识,可以大致略过直接看结论。
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是一个构建工具,它完全基于Pods的spec文件规则,在Podfile引入后,生成对应构建Xcode Target。也就是它是一个声明式构建工具(区别于Makefile这种过程式的构建工具)。对于资源的管理,目前有两个方式进行声明并引入,即resources
和resource_bundles
,参考podspec syntax
虽然Podspec中包含所有待构建库的声明,但于CocoaPods也会根据Podfile的配置,动态调整最终的Xcode工程的配置,根据是否开启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.png
和A/2.plist
,这个1.png
和2.plist
,最终会出现在ipa包的展开根路径中。
1 | | Info.plist |
优点:
缺点:
A/1.png, B/1.png
两个文件同时匹配(B是另一个库的文件夹),将会出现冲突,CocoaPods采取的方式是暴力合并,会有一个被替换掉。因此,这要求所有资源文件命名本身,加入特定的前缀以避免冲突。类似的不止是图片,所有资源如bundle
, js
, css
都可能存在这个问题,难以排查。而且由于这种拷贝到根路径的机制,这个问题不可从根源避免。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.png
和2.plist
,会是以这个Target建立一个Bundle父文件夹。然后这些Bundle父文件夹,拷贝到App Main Bundle根路径下,最后得到这样一个ipa结构。
1 | | Info.plist |
优点:
库名+Resource
,则库之间不会不出现同样的Key值)。缺点:
NSBundle.mainBundle pathForResource:ofType:
取本地Bundle中一个文件路径,这时候需要更新为[NSBundle bundleWithPath:] pathForResource:ofType:
的代码调用。对于UIImage imageNamed:
方法,它也支持Bundle,看情况需要更新。举例子说明,原来使用方式为:
1 | // 直接访问路径 |
现在需要更新为:
1 | // 路径变化,这步骤可以封装库级别的工具方法,或者宏,Static对象,都行 |
总体来说,结合优缺点,大部分的组件库,对于通用资源的引用,应当避免使用resources
,而转为使用resource_bundles
声明。能够从源头上避免冲突。改动成本也不算大,封装个库内部的工具方法/宏替换下即可。
Tips:如果在使用resource_bundles
的情况下,我还想避免Xcode的图片优化策略(如无损的图片等),这时候可以采取将图片放入一个自己建立的Bundle文件夹中,然后resource_bundles
引入这个Bundle本身,注意路径需要再加一层。
当使用了use_framework!之后,CocoaPods会对每个Pod单独建立一个动态链接库的Target,每个Pod最后会直接以Framework集成到App中。而资源方面,由于Framework本身就能承载资源,所有的资源都会被拷贝到Framework文件夹中而不再使用单独的脚本处理。
resources
在使用resources
声明时,同不使用use_framework!相比,改动的点在于这些Pod库资源的路径。此时,这些Pod库资源会被拷贝到Pod库自己的Fraemwork根路径下,而不在App Main Bundle的根路径下。
1 | | Info.plist |
优点:
缺点:
resource_bundles
不同,不需要额外拼接一层Key值的名称。直接使用bundleForClass即可,比较简单1 | // 使用bundleforClass替代mainBundle即可 |
resource_bundles
在使用resource_bundles
声明时,同不使用use_framework!相比,改动的点在于对应这些Key生成的Bundle的位置。此时,这些生成的Bundle父文件夹,会放入Pod库自己的Framework的根路径下。而每个Pod库Framework本身,在App Main Bundle的Frameworks
文件夹下。
1 | | Info.plist |
优点:同上
缺点:同上。但有点区别,在于Bundle的路径变化。此时,NSBundle bundleForClass:
不再等价于mainBundle了,因此对应代码更新示例里面,一定不能用mainBundle而要用bundleForClass替代。传入的Class是哪一个Pod库的Class,就会取到对应Pod库Framework里面的Bundle文件夹。
1 | // 再抄一遍,害怕忘记了,此时不能用mainBundle的resourcePath去拼接 |
前面花了大篇章说了关于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
.
resources
不同于普通资源那种暴力拷贝的方式,CocoaPods这下没法暴力拷贝这个编译产物的Assets.car
到根路径了,因为它会直接覆盖掉App本身的编译产物。所以,CocoaPods采取的方案,是合并Asset Catalog。首先会编译得到工程App的Assets.car
,然后通过便利所有Pod的resources
引入的xcassets
,使用atool工具进行多个Asset Catalog合并,最后输出到App Main Bundle根路径下的Asset.car
里。
1 | | Info.plist |
优点:
缺点:
Assets.car
会根据合并顺序替换掉之前的。因此还是得每个Asset Catalog中资源名也得添加前缀resource_bundles
类似对于普通资源的处理,如果使用resource_bundles
,对于每个Key生成的Bundle父文件夹,会把生成的Assets.car
拷贝到这个Bundle父文件夹中。如果当前Pod库引用了多个xcasset文件,对引用的这几个做合并。
1 | | Info.plist |
优点:同普通资源
缺点:同普通资源。代码使用方面,由于之前提到的UIImage API,对于同路径下的Assets.car
编译产物,和散落的普通图片名,代码使用方式是一致的,因此这里也没有额外的变化。
resources
在使用use_framework!的情况下,对应编译产物Assets.car
会被拷贝到Pod库Framework的根路径下,其他的行为类似。
1 | | Info.plist |
优点:同普通资源
缺点:同普通资源,代码使用方面也同普通资源的情形
resource_bundles
在使用use_framework!的情况下,也会创建Key为名称的父Bundle文件夹,拷贝到Pod库Framework根路径下,然后对应编译产物Assets.car
放到了这个自动生成Bundle文件夹下,其他行为类似。
1 | | Info.plist |
优点:同普通资源
缺点:同普通资源,代码使用方面也同普通资源的情形
可以看出,CocoaPods,对待普通资源和Asset Catalog都支持,唯一的行为不同的点,在于普通资源如果发生重名,不会进行合并而是直接替换。但是Asset Catalog如果出现多个引用,会进行合并。
虽然表面看起来,我们分析了总共会有 使用resource还是resource_bundle * 是否使用use_framework! * 普通资源还是Asset Catalog,8种情形。但是实际上从世纪代码使用上,由于Asset Catalog和普通图片API可以统一,同时动态/静态的Bundle位置也可以统一处理,实际上只有两种Case:
推荐做法,对于每个需要引入资源的库,以库名+Resource
为Key(不强制,推荐),然后引入资源,Asset Catalog。代码必须更新,以使用对应的Bundle名来获取。参考上面的代码:
1 | NSString *bundlePath = [[NSBundle bundleForClass:DemoLib.class].resourcePath stringByAppendingPathComponent:@"DemoLibResource.bunbdle"]; |
除非你能保证分所有资源都已加入前缀,而且目前代码不好更改的情况下,可以保持继续使用主工程的直接访问mainBundle的代码;其他的任何情况,使用NSBundle bundleForClass:
来获取Bundle,然后加载路径,或者使用UIImage imageNamed:inBundle:compatibleWithTraitCollection
加载图片。
1 | NSBundle *bundle = [NSBundle bundleForClass:DemoLib.class]; |
对于Pod库开发者,需要尽量使用resource_bundle
来处理资源,同时,Pod自身代码可能需要更新,以使用正确的方式加载图片或者其他Bundle资源。并且,对于图片资源,如果无特殊用处,建议都建立Asset Catalog以利用App Thinning。
这篇教程,是系列教程的第三篇,前篇名为iOS平台图片编解码入门教程(第三方编解码篇)。由于vImage已经属于较为底层框架,这一篇将不会特别着重图片封装格式的编解码,会介绍一些Bitmap级别的操作,包括了图像的色彩转换,Alpha合成、基本几何变换等实际用法。由于教程侧重是图像格式,所以不会介绍vImage强大的Convolution等知识,这方面涉及到数字图像处理的复杂知识,不是教程的目标
vImage是Apple的Accelerate库的一部分,侧重于高性能的图像Bitmap级别的处理。库本身全部是C的接口,而且不同于Core系列的(Core Graphics/Core Foundation)C接口,是比较贴近传统C语言的接口,不会有XXXRef这种贴心的定义,而且很多接口需要自己手动分配内存。
vImage按照功能,可以分为Alpha Compositing(Alpha合成)、Geometry(几何变换)、Conversion(色彩转换)、Convolution(卷积,用于图像滤镜)Morphology(形态学处理)等。这里主要介绍的,就是色彩转换,Alpha合成,以及几何变换的内容。
首先需要对vImage的基本接口有所了解,有这么几个概念:
vImage_Buffer
: 对应Bitmap的数据,只有最基本的width、height、rowBytes(stride)以及datavImage_CGImageFormat
: 每个vImage的功能,会提供不同色彩格式的类似接口,比如会有ARGB8888,Planar8的同样功能。这里ARGB8888指的是ARGB排列,每通道占8个Bit,也就是一个Piexel占32Bit。而vImage还有一个常见的色彩格式Plane8,指的是只有一个通道(平面),按照顺序排列,比如{R, R, R, R}
这样,更方便进行计算vImage_Flags
: 每个vImage接口,都会有一个flags
参数来控制一些选项,比如说可以自己定义内存分配,背景色填充策略,重采样策略等,默认的是kvImageNoFlags
vImage_Error
: 每个vImage的接口,都会返回这个result,来让用户确认是否成功,以及失败的原因,在Debug下比较有帮助为了统一期间,以下的内容,都是基于ARGB8888色彩格式的输入来说明的。其他的情况处理,参考同名接口的不同格式即可。
色彩转换指的是将图像的Bitmap格式,从一个色彩格式,比如ARGB8888,转换到另一个色彩格式,比如说RGB888的功能。对于RGB来说,一般来说就是通道的增加和减少。当然还有RGB转为Planar8的情况。
vImage对这些色彩转换的功能,统一提供了方法vImageConvert_AtoB
,比如ARGB8888转RGB888,就可以用下面的代码来处理。顺便通过这个代码,来简单了解vImage的API的基本用法。
先来定义几个简单的结构体,方便后续使用:
1 | // 为了方便,我们首先直接定义好ARGB8888的format结构体,后续需要多次使用 |
接着,就是完整的转换代码:
1 | + (CGImageRef)nonAlphaImageWithImage:(CGImageRef)aImage |
除了一系列vImageConvert_AtoB
的转换,vImage还提供了一个非常抽象的接口,叫做vImageConvert_AnyToAny
,只需要你提供一个input format,一个output format,就可以直接转换。这个接口比较强大,不仅能够handler所有支持的色彩格式,而且还能支持CVImageBuffer
(通过这个vImageConverter
来构造)。所以一般如果做库封装,做一些色彩转换的case的时候,就可以试着用这个接口。
因此,我们之前的ARGB8888ToRGB888的色彩转换,可以这样写,更为通用。示例代码:
1 | vImageConverterRef converter = vImageConverter_CreateWithCGImageFormat(&vImageFormatARGB8888, &vImageFormatRGB888, NULL, kvImageNoFlags, &ret); |
Alpha合成指的是将两张含有Alpha通道的图(被Blend的叫做bottom,Blend的叫做top),通过一定的公式合成成为一张新的含Alpha通道的图,一般来说用于给图像添加遮罩、覆盖等,常见的图像处理软件都有这个功能。其实本质上来说,Alpha合成,就是对图像的每一个像素值,进行这样一个计算:
1 | resultAlpha = (topAlpha * 255 + (255 - topAlpha) |
公式看起来比较复杂,因此这里顺便可以介绍一下关于premultiplied-alpha的概念,直观地说,就是将(r, g, b, a)
预先乘以了对应的alpha通道的值,成为(r * a, g * a, b * a, a)
。这个带来的好处,就是Alpha合成的时候,可以少一次乘法,而且简化了计算,成为这样子:
1 | resultColor = (topColor + (((255 - topAlpha) |
在vImage中,已经提供了一个接口来专门处理Alpha合成,针对nonpremultiplied的,是vImageAlphaBlend_ARGB8888
,而针对premultiplied,是vImagePremultipliedAlphaBlend_ARGB8888
。需要注意的是,这个接口要求的两个buffer,宽度和高度必须相等,因此,我们对于Color和Image的遮罩,需要进行处理,保证这两个buffer满足要求。
这个用处,一般是用来做图像的遮罩的,可以对图像整体盖一层有透明度的颜色,比如说夜间模式,纯色滤镜等。根据上面说的,如果需要对一个Bitmap使用vImage进行Alpha Blend,我们需要保证两个buffer的宽度和高度相同,因此可以使用vImageBufferFill_ARGB8888
填充整个Color来构造一个与输入图像Buffer相同宽高的新buffer,然后用它来进行Alpha Blend。
代码示例:
1 | CGImageRef aImage; // 输入的bottom Image |
上面说到了关于Color的Alpha Blend,不同于Color这种需要填充全部宽度,如果对于一个Image需要进行Alpha Blend,我们大部分情况都是需要制定一个起始点的,因为不能保证所有输入的两个Image的宽高相同。因此设计的时候,可以给用户提供一个point参数,以这个坐标点开始来绘制Alpha Blend,类似于很多图像编辑软件提供的图层功能。
由于vImage的Alpha Blend需要两个等宽高的Buffer,因此我们需要对用户提供的Top Image进行处理,通过平移变换移动到指定的Point以后,填充其余部分为Clear Color。最后进行Alpha Blend即可。
1 | CGImageRef aImage, bImage; // 输入的bottom Image和top Image |
几何变换,指的是将一个原始的Bitmap,通过线性方法进行处理,实现比如平移、缩放、旋转、错切等操作的图像处理技术。
可能大部分人已经知道了(之前也说过),Core Graphics的坐标系统,和UIKit的坐标系统,在Y坐标上是相反的。UIKit的使用的是Y轴正向垂直向下的左手系,而Core Graphics和普通的右手系直角坐标系相同。vImage也遵守了右手系,因此之后介绍的变换都是按照右手系的,如果想处理UIKit的坐标系,自己转换一下即可(一般就是取image.height - offsetY
即可)
关于要介绍的的这些几何变换,虽然都最后可以统一到到线性变换上,只不过效率上可能相比单独的方法来说有所损耗,因此单独对每个功能所需要的vImage接口进行了介绍。关于线性变换不太理解的,可以参考一下之前的一篇教程:Core Graphics仿射变换知识
缩放是最简单的一个处理过程,但是由于缩放之后,之前的同一个像素点,现在可能会映射到4个或者更多像素点,或者是原本4个像素点,现在需要映射到1个像素点。这就会涉及到一个叫做图像重采样的过程。具体来说,就是对每一个像素,所在的Bitmap的子矩阵(比如3x3),通过一定的算法计算,得到对应的缩放以后的中心像素的值。同时,这个像素值可能变成浮点数,还需要进行处理,最后填到采样后的Bitmap相应的位置上。常见的简单处理有最邻近算法、双线性算法、双立方算法等。
vImage默认使用的是Lanczos Algorithm,具体的介绍可以参考Wikipedia和DSP相关的书籍。这里有一个直观的对比表现网页。如果想要更高画质的算法,可以提供kvImageHighQualityResampling
参数,来使用Lanczos5
算法。或者可以使用之后要谈的相对底层一点的错切API,来自定义你的重采样过程。
vImage提供了自带的vImageScale_ARGB8888
方法,这里就简单举个例子(之前重复代码的都略过):
1 | CGSize size; // 目标大小 |
裁剪是指的将原始Bitmap,只裁出来指定矩形大小的部分,其余部分直接丢弃的过程。虽然vImage没有提供直接的API来处理这个流程(当然你是可以用vecLib的方法,直接对Bitmap进行矩阵操作,但是有点过于小题大做了)。但是实际上,这就是一个平移变换能够搞定的事情。我们只需要对输入目标的坐标的CGRect
进行转换,将原始图像平移之后,再限制输出的Bitmap的大小,这样平移超出部分就会自动被裁掉。不需额外的处理,示例代码如下:
1 | CGRect rect; // 输入的目标rect |
镜像顾名思义,就是将图像沿着某个轴进行翻转,比如沿X轴就是水平镜像,同一个像素点,对应的X坐标不变,Y坐标变为高度减去本身的Y坐标即可。
vImage对应的API,是vImageVerticalReflect_ARGB8888
和vImageHorizontalReflect_ARGB8888
,使用起来也比较简单。直接上一个简单的示例:
1 | BOOL horizontal; |
旋转也是非常常见一个图像几何几何变化。具体坐标的变化就是对旋转的角度,求对应三角函数到X轴和Y轴的投影结果,比较直观。
vImage对旋转也提供了一个非常方便的API,角度是弧度值,按照顺时针方向进行。另外,由于输出的Buffer的大小会限制图像大小,而旋转后可能超出原图大小,我们需要对输出的大小也计算出对应的新的大小。示例代码:
1 | CGFloat radians; //旋转的弧度 |
错切是一种特殊的线性变换,直观的介绍可以从Wikipedia上看,也可以参考之前的另一篇教程。主要的参数有一个m值,表示对应参考坐标的缩放倍数。
在vImage中,错切变换是相对底层的接口,实际上,线性变换是通过这三个接口(错切、旋转、镜像)来实现的。错切的接口,比如水平错切对应的是vImageHorizontalShear_ARGB8888
,参数算是最多的一个,稍微详细介绍一下:
srcOffsetToROI_X
: 错切定位点水平偏移量,具体指的就是左上角那个像素点,在经过旋转的映射后,水平偏移的距离,会影响最后图像(除去Buffer的宽度限制)的整体宽度srcOffsetToROI_Y
: 错切定位点的垂直偏移量,类似水平值xTranslate
: 错切完成后的水平平移距离shearSlope
: 错切的弧度值,顺时针filter
: 用来自定义重采样的方法,一般用自带的vImageNewResamplingFilter
,或者也可以提供一个函数指针构造对应的重采样过程。会用到一个scale参数,表示这个重采样对应的缩放倍数,也就是错切的m值backgroundColor
: 背景填充色对应的示例代码:
1 | CGVector offset; // 定位点偏移量 |
最后再来说通用的线性变换吧,这个其实在之前的功能中已经用到过了,vImage有兼容Core Graphics的CGAffineTransform
的结构体vImage_CGAffineTransform
,两个结构体对应的内存布局是一样的,直接强制转换过去就可以了,不需要单独赋一遍。关于通用线性变换的内容就不再赘述了,有兴趣可以查看相关资料,或者之前的教程:Core Graphics仿射变换知识
示例代码:
1 | CGAffineTransform transform; // 输入的CG变换矩阵 |
vImage是一个比较底层的图像Bitmap处理的库,在这里介绍了关于色彩转换、Alpha合成、几何变换等基本知识。相比于简单的Core Graphics的处理,能够提供更为复杂的参数控制,并且带来较高的性能。对于很多图像密集处理软件处理来说,用Core Graphics显的比较低效,因此可以考虑vImage。
但是vImage强大之处远不在这里,里面还包含了类似图像卷积,形态处理等,可以对复杂滤镜进行支持,类似于GPUImage。这些功能都需要数字图像处理相关知识,在这种教程系列就不会介绍了。
对于这篇教程的示例代码,其实我写了个非常简单的库,放到GitHub上了:vImageProcessor,有兴趣的可以去参考一下,希望能够用于自己的图片处理相关框架中。
由于自己完全是业余兴趣,工作和图像处理基本不相关,并不打算深入学习数字图像处理的知识,因此这个教程可能就会暂时告一段落了。最后,之所以写这篇教程,是因为自己想要参考一下vImage的教程,却发现只会搜出来一堆互相抄袭的内容,而且大部分都是关于图像滤镜的,对于图像处理本身不会太多介绍。我希望这系列教程,能给同样对图像编解码、图像处理有一点兴趣的人,提供一个相对简单且清晰的入门概览吧。
]]>这篇教程,是系列教程的第二篇,前篇名为《iOS平台图片编解码入门教程(Image/IO篇)》。这篇主要讲第三方解码器如何在iOS平台上处理(和Image/IO的几大要点一一对应),更会介绍一些基本的Bitmap概念,总结通用的处理方法,毕竟授人以鱼不如授人以渔
对于图片编解码来说,Apple自带的Image/IO确实非常的易用,但是对于Image/IO不支持的图像格式就能无能为力了。截止到iOS 11,Image/IO不支持WebP,BPG,对于一些需要依赖WebP的业务就比较麻烦了(WebP的优点就不再介绍了)。不过我们可以自己集成第三方的图片解码器,去支持这些需要的的格式。
一般来说,我们需要根据自己想要支持的图像格式,选择相对应的编解码器,进行编解码。这里我们以WebP的解码库libwebp为例子,其他解码器需要根据对应解码器的API处理,基本概念类似。
不像Image/IO那样封装了整套流程,第三方解码的关键之处,就是在于获取到图像的Bitmap数据,通常情况就是RGBA的矢量表示。
简单解释一下,Bitmap可以理解为连续排列像素(Pixel)的二维数组,一个像素包括4个通道(Components)的点,每个点的位数叫做色深(常见的32位色,指的就是1个像素有4通道,每个通道8位),而像素的通道排列顺序按照对应的RGBA Mode顺序排列,比如说RGBA8888(大端序),就是这样一串连续的值:
1 | {uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha, uint8_t red, uint8_t green, uint8_t blue, uint8_t alpha...} |
这样的话,在内存中,一般就可以用uint8_t bitmap[width * components * 8][height]
来表示。
有了这样的知识,对照着就能看懂CGImage的BitmapInfo所表示的信息了。
只要Bitmap数据到手,后面的过程其实都大同小异。第三方解码器主要处理图像编码数据到原始Bitmap的解码过程,后续就可以通过固定的方式来得到CGImage或者UIImage,用于上层UI组件的渲染。
根据自己的需要,可以选择对应的编码器,来获取图像Bitmap。这里我们以WebP的解码库libwebp为例子,其他解码器需要根据对应解码器的API处理,基本概念类似。
示例代码:
1 | NSData *data; // 待解码的图像二进制数据 |
截止到这里,我们基本上调用通过第三方解码库的接口,就完成了获取Bitmap的工作。一般来说,以RGBA来说,最少需要知道以下信息:图像RGBA数组,数组大小,图像宽度、高度、是否含有Alpha通道这几个,以便开始下一步的创建CGImage的过程
有了图像的Bitmap数据之后,可以通过CGImageCreate
来生成CGImage。对于RGBA的输入,需要的参数基本比较固定,以下代码基本上可以参考来复用。(需要注意,iOS上只支持premultiplied-alpha,macOS可以支持非premultiplied-alpha)
示例代码:
1 | // 通过RGBA数组,创建一个DataProvider。最后一个参数是一个函数指针,用来在创建完成后清理内存用的 |
这一步其实是可选的,但是建议都加上这一步骤。虽然我们之前通过RGBA创建了CGImage,但是实际上,CALayer和上层的UIImageView这些渲染的时候,要求的色彩是限定的,不然会有额外的内存和渲染消耗,我们解码出来的rgba的格式可能并不是按照这样的色彩空间排列,因此建议进行一次重绘,即将CGImage重绘到一个CGBitmapContext之上。这个代码比较简单。
示例代码:
1 | CGImageRef imageRef; |
有了最终的用于显示CGImage,那么我们就可以生成一个UIImage来给UI组件显示了。注意如果需要有特殊的scale,orientation处理(比如说图像可能有额外的EXIF Orientation信息),需要在这一步加上。
由于是C接口,需要手动清理内存,除了CGImage相关的,也需要清理第三方库自己的内存分配。对于错误提前返回的清理内存,灵活运用__attribute__((cleanup))
,设置一个返回函数前清理的Block,可以减少犯错的可能性
示例代码:
1 | UIImage *image = [UIImage imageWithCGImage:imageRef scale:1 orientation:UIImageOrientationUp]; |
动态图的解码过程,其实很直观的想,我们目标就是需要对所有动图帧,都拿到Bitmap,解码到CGImage和UIImage就行了。这样想的话,其实步骤就比较明确了。
步骤:
不同解码器可能对于动图有特殊的解码过程,拿libwebp举例来说,libwebp的动图,需要用到它的demux模块,其他解码器自行参考对应的API。
同时,这里需要额外介绍一些概念。一般来说,动图格式的话不会直接将每帧原始的Bitmap都编码到文件中,这样得到的文件过于庞大(帧数 * 每帧Bitmap)。因此,会有Dispose Method的方式(可以参考WebP规范Disposal method (D): 1 bit,移动端图片格式调研)。简单点来说,对于动图来说,每一帧有一个参考画布,在前一帧画完以后,后一帧可以利用前一帧已画好的图像,仅仅改变前后变化的部分,从而减小整体大小。因此我们创建动图时,需要准备好一个CGBitmapContext当作画布,根据Disposal Method(如果为None,不清空canvas;如果为Background,清空为Background Color,一般就是直接清空成透明)
有了所有帧的Bitmap后,转成CGImage,UIImage,最后生成动图UIImage,这个在系列前篇已经介绍过了,不再赘述
示例代码:
1 | - (void)decodeWebP { |
渐进式解码的概念,在系列前篇中已经介绍过了,一般来说,第三方解码器支持渐进式解码的接口都比较类似,通过提供二进制流不断进行Update,每次能够得到当前解码的部分的Bitmap,最后可以拿到完整的Bitmap。之后只需要参考静态图对应步骤即可。
这里还是以libwebp的接口为例,libwebp需要使用它的WebPIDecoder接口,来专门进行渐进式解码。注意,libwebp渐进式解码出来的Bitmap不会将未解码的部分自动填空,会保留随机的内存地址置,要么手动清空,要么画的时候仅仅画解码出来的高度部分。
示例代码:
1 | NSData *data; // 输入的原始图像格式的二进制数据 |
编码过程其实比解码过程要简单得多,因为实际上,我们可以通过自带的接口,直接拿到当前UIImage的Bitmap数据,因此只要将Bitmap交给第三方编码库来进行编码,最后输出数据即可。
静态图的过程其实就可以直接分为两步:
UIImage本身能够直接通过方法拿到对应的CGImage,这样只需要调用CGImageGetDataProvider
就可以拿到对应的Bitmap数据的DataProvider了,直接上代码吧。
示例代码:
1 | UIImage *image; |
我们还是以libwebp来对WebP进行编码,libwebp对于静态图片的编码非常简单(动态图片需要调用另一套mux的API,在动图章节讲)
示例代码:
1 | uint8_t *data = NULL; //编码输出的二进制数据 |
对于动态图来说,也就是将多帧的Bitmap输入到编码器即可。对于libwebp的动态图编码,需要利用到它的mux模块,它能够将多个编码成WebP的二进制流,最后mux合并一次,最终得到了动态WebP。因此我们需要利用之前的静态图编码的步骤,只需要依次遍历取图并编码,最后使用mux处理即可。
步骤:
示例代码:
1 | NSData *data; |
第三方编解码其实相对于Image/IO来说,主要难度其实在于需要获取的Bitmap。开发者需要一点基本的图像知识,再者就是要能会用第三方编解码器的接口(一般来说第三方编解码器就是C或者C++写的,与OC和Swift交互也非常方便,至少不用像Java JNI那样调用)。之后只要按照通用的步骤,去编码和解码即可。
到这里的话,一般的大部分格式的编解码就基本没有问题了。当然,关于进阶的方面,比如图像的编解码性能优化,进阶的图像处理(Bitmap的几何变化,Alpha合成,位数转换等等)这就需要用到更低层的库vImage了,会在之后的系列教程中进行介绍。
]]>这篇教程是系列教程的第一篇,主要是面向于没有怎么接触过iOS平台上图像编解码的人的,不会涉及到多媒体处理中的数字信号处理、图像编码的深入知识。这是系列最简单的一篇,之后会有关于第三方编解码,以及vImage的另两篇教程。
Image/IO是Apple提供的一套用于图片编码解码的系统库,对外是一层非常直观易用的C的接口。上层的UIKit,Core Image,还有Core Graphics中的CGImage处理,都是依赖Image/IO库的。因此,掌握Image/IO的基本编解码操作,对一些图像相关的数据处理是非常必要的。这篇教程就主要从简单的用法,说明Image/IO的用法,完整的文档,可以参考Apple Image/IO
解码,指的是讲已经编码过的图像封装格式的数据,转换为可以进行渲染的图像数据。具体来说,iOS平台上就指的是将一个输入的二进制Data,转换为上层UI组件渲染所用的UIImage对象。
Image/IO的解码,支持了常见的图像格式,包括PNG(包括APNG)、JPEG、GIF、BMP、TIFF(具体的,可以通过CGImageSourceCopyTypeIdentifiers
来打印出来,不同平台不完全一致)。在iOS 11之后另外支持了HEIC(即使用了HEVC编码的HEIF格式)。
对于解码操作,我们可以分为静态图(比如JPEG,PNG)和动态图(比如GIF,APNG)的两种,分别进行说明一下解码的过程。
静态图的解码,基本可以分为以下步骤:
CGImageSouce,表示的是一个待解码数据的输入。之后的一系列操作(读取元数据,解码)都需要到这个Source,与解码流程一一对应。
CGImageSource可以通过不同的几个接口构造(这里先忽略渐进式解码的接口):
CGImageSourceCreateWithData
: 从一个内存中的二进制数据(CGData)中创建ImageSource,相对来说最为常用的一个CGImageSourceCreateWithURL
: 从一个URL(支持网络图的HTTP URL,或者是文件系统的fileURL)创建ImageSource,CGImageSourceCreateWithDataProvider
:从一个DataProvide中创建ImageSource,DataProvider提供了很多种输入,包括内存,文件,网络,流等。很多CG的接口会用到这个来避免多个额外的接口。示例代码:
1 | CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL); |
创建好CGImageSource之后,我们是可以立即解码。但是很多情况下,我们需要获取一些相关的图像信息,包括图像的格式,图像数量,EXIF元数据等。在真正解码之前,我们可以拿到这些数据,进行一些处理,之后再开始解码过程。
其中,这些信息可以直接在CGImageSource上获取:
CGImageSourceGetType
CGImageSourceGetCount
其他的,需要通过获取属性列表来查询。对于图像容器的属性(EXIF等),我们需要使用CGImageSourceCopyProperties
即可,然后根据不同的Key去获取对应的信息。
其实苹果还有一套CGImageSourceCopyMetadataAtIndex
,对应的数据不是字典,而是一个CGImageMetadata
,再通过其他方法去取。这套API使用起来也是可以的,读取数据和前者是完全兼容一致的,优点是能够进行自定义扩展(比如说你有非标准的图像信息想自己添加和删除)。一般来说使用前者就足够了。
示例代码:
1 | CGImageSourceRef source; |
当然,前面这个指的是图像容器的属性,而真正的获取图像的元信息,需要使用CGImageSourceCopyPropertiesAtIndex
,对于静态图来说,index始终传0即可。
示例代码:
1 | NSDictionary *imageProperties = (__bridge NSDictionary *) CGImageSourceCopyPropertiesAtIndex(source, 0, NULL); |
通过Image/IO解码到CGImage确实非常简单,整个解码只需要一个方法CGImageSourceCreateImageAtIndex
。对于静态图来说,index始终是0,调用之后会立即开始解码,直到解码完成。
值得注意的是,Image/IO所有的方法都是线程安全的,而且基本上也都是同步的,因此确保大图像文件的解码最好不要放到主线程。
示例代码:
1 | CGImageRef imageRef = CGImageSourceCreateImageAtIndex(source, 0, NULL); |
解码得到CGImage后,就基本完成了,我们可以直接构造对应的UIImage用于UI组件渲染。其中UIImage的orientation,可以通过之前的EXIF元信息获得(注意,需要转换EXIF的方向,到UIImageOrientation的方向)。然后就完成了,比较简单。
示例代码:
1 | // UIImageOrientation和CGImagePropertyOrientation枚举定义顺序不同,封装一个方法搞一个switch case就行 |
前面的情况,主要介绍了是静态图(也就是说,取的index都是0的情况 )。对于动态图来说,我们可以通过CGImageSourceGetCount
来获取动图的帧数,之后就比较简单了,通过循环遍历每一帧,重复2-4步骤生成对应的UIImage,最后通过UIImage自带的animatedImageWithImages:duration:
来生成一张动图即可。但是关于这里有坑,在下面说明。
步骤:
由于遍历很简单,就不重复了,这里我们以一个GIF为例,简单说明一下解码过程,直观易懂。
示例代码:
1 | NSUInteger frameCount = CGImageSourceGetCount(source); //帧数 |
这样处理的话,大部分情况下基本是可以接受的。但是这里有一个坑:UIImage这个animatedImages的接口,只会根据你传入的images的数量,平均分配传入的totalDuration的展示时长。但是大部分动图格式(GIF,APNG,WebP等等),都是不同帧不同时长的,这就会导致最后看到的动图每帧时长乱掉。
对于这个的解决方式也有。简单来说,就是通过对特定图像帧重复特定次数,以填充满整个应该播放的时长。其实实现也比较简单,我们可以对所有帧的时长,求一个最大公约数gcd
,这样的话,只需要每帧重复播放duration / gcd
次数,最终的总时长各帧repeat * duraion
的和,就可以实现这个了,有兴趣可以看看我参与维护的SDWebImage的代码。
示例代码:
1 | NSUInteger durations[frameCount]; |
渐进式解码(Progressive Decoding),即不需要完整的图像流数据,允许解码部分帧(大部分情况下,会是图像的部分区域),对部分使用了渐进式编码的格式(参考:渐进式编码),则更可以解码出相对模糊但完整的图像。
比如说,JPEG支持三种方式的渐进式编码,包括Baseline,interlaced,以及progressive(参考:iOS 处理图片的一些小 Tip)
Baseline | Interlaced | Progressive |
---|---|---|
对于Image/IO的渐进式解码,其实和静态图解码的过程类似。但是第一步创建CGImageSource时,需要使用专门的CGImageSourceCreateIncremental
方法,之后每次有新的数据(下载或者其他流输入)输入后,需要使用CGImageSourceUpdateData
(或者CGImageSourceUpdateDataProvider
)来更新数据。注意这个方法需要每次传入所有至今为止解码的数据,不仅仅是当前更新的数据。
之后的过程,就和普通的解码一致,就不再说明了。
示例代码:
1 | NSData *data; |
编码过程,这里指的就是将一个UIImage表示的图像,编码为对应图像格式的数据,输出一个NSData的过程。Image/IO提供的对应概念,叫做CGImageDestination,表示一个输出。之后的编码相关的操作,和这个Destination一一对应。
静态图的编码,基本可以分为以下步骤:
CGImageDestination的创建也有三个接口,你需要提供一个输出的目标来输出解码后的数据。同时,由于编码需要提供文件格式,你需要指明对应编码的文件格式,用的是UTI Type。对于静态图来说,第三个参数的数量都写1即可。
CGImageDestinationCreateWithData
:指定一个可变二进制数据作为输出CGImageDestinationCreateWithURL
:指定一个文件路径作为输出CGImageDestinationCreateWithDataConsumer
:指定一个DataConsumer作为输出示例代码:
1 | CFStringRef imageUTType; //目标格式,比如kUTTypeJPEG |
接下来就是添加图像了,由于CGImage只是包含基本的图像信息,很多额外信息比如说EXIF都已经丢失了,如果我们需要,可以添加对应的元信息。不像解码那样提供了两个API分别获取元信息和图像。使用的接口是CGImageDestinationAddImage
。
当然,如果有自定义的元信息,可以通过另外的CGImageDestinationAddImageAndMetadata
来添加CGImageMetadata
,这个上面解码也说到过,这里就不解释了。
此外,还有一个ImageIO最强大的功能,叫做CGImageDestinationAddImageFromSource
(这个东西可以媲美vImageConvert_AnyToAny
,后续教程会谈到),这个能够从一个任意的CGImageSource,添加一个图像帧到任意一个CGImageDestination。这个一般的用途,就是专门给图像转换器用的,比如说从图像格式A,转换到图像格式B。我们不需要先解码到A的UIImage,再通过编码到B的NSData,直接在中间就进行了转换。能够极大地提升转换效率(Image/IO底层就是通过vImage,传的是Bitmap的引用,没有额外的消耗)。不过这篇教程侧重于Image/IO的编码和解码,转换可以自行参考处理,不再详细说明了。
示例代码:
1 | CGImageRef imageRef = image.CGImage; // 待编码的CGImage |
当添加完成所有需要编码的CGImage之后,最后一步,就是进行编码,得到图像格式的数据。这里直接用一个方法CGImageDestinationFinalize
即可,编码得到的数据,会写入最早初始化时提供的Data或者DataConsumer。
示例代码:
1 | if (CGImageDestinationFinalize(destination) == NO) { |
动态图的编码,其实不像解码那样困难。只需要准备好所有的动态图的帧,按照帧的顺序进行一一添加即可。基本步骤可以概括为:
在进行动态图编码时,创建CGImageDestination的时候需要提供动态图的张数。即在CGImageDestinationCreateWithData
的参数中,将count
设置为需要编码的总张数。
另外,在遍历图像帧的过程,其实只需要不断地按顺序添加就行了,如果需要设置额外元信息,也需要按顺序设置到当前帧上。相对于解码来说简单多了。其他的没有什么大的区别。我们这里还是以GIF为例,简单说明一下。
示例代码:
1 | NSArray<UIImage *> *images; |
Image/IO封装了非常简单直观的接口来处理图像编解码,对于任何开发者来说都能轻易上手。而且性能方面很多格式都有Apple自己的硬件解码器来做保证。另外,对于图像转换,Image/IO所提供的这种Source-Destination的操作能够非常方便地在不同格式之间转换,有兴趣的人务必可以试试。
不过遗憾的是,Image/IO的接口设计并没有提供可以扩展或者插件化的地方,不支持的图像格式就比较无能为力了。关于这个问题,请期待系列教程第二篇——第三方编解码教程。
]]>这篇文章讲的是有关近期自己参与的几个开源项目的经历以及感受,不过巧合的是内容都和APNG和WebP这两种图像格式相关,阅读前建议先简单略读一下之前写的一篇文章:客户端上动态图格式对比和解决方案
SDWebImage是iOS平台上非常著名的图片下载、缓存库,而今年发布的SDWebImage 4.0在架构、接口变动并带来性能优化的同时,还支持了Animated WebP,因此我就高兴地去实验了一下,本想着可以替代之前使用的YYImage。但是一测试就发现渲染不正常,追回去看源码,发现SDWebImage的实现可以说是Too naive,压根没有按照WebP规范实现,大部分Animated WebP动图渲染都挂了,完全不可用(连测试都过不了,更别说生产环境了)。演示Demo在此:AnimatedWebPDemo
总结出来的具体问题有以下几个:
UIImage.animatedImages
是非常弱的,SD并没有提供额外的抽象,而是直接用的这个接口。这带来的最大的问题,是UIImage需要提供一个图片数组和总时长,但是会对数组中每个图片平均分配时长。这与Animated WebP的规范就是不同的,后者允许对每帧设置一个不同的持续时长。image
属性,是不支持设置循环次数的,会默认无限循环播放。而有些Animated WebP图片需要有循环次数。既然知道这么多坑,想着SD毕竟是主流框架,就赶紧提了Issue,但是过了一周多,SD社区依然没有任何回应。于是尝试自己一个个解决。最后的成果也比较好,上述4个问题都得到了解决。
这个问题,可以直接通过libwebp的API,修改来使用canvas大小而不是frame大小,确保每帧最后的图像大小相同。其中,为了优化性能,对于透明的且frame比canvas要小的帧,绘制出来等价于将frame平移,然后所有剩余部分填充透明值。在使用CGBitmapContext的时候,可以直接在要传入的Bitmap矢量数据上做变换,减少绘制带来的开销(不过CGBitmapContext本身应该有优化,对于这个开销影响不大,但参考YYImage里面有这一步处理)
在绘制每帧时,按照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 | [imageView sd_setImageWithURL:webpURL completed:^(UIImage * _Nullable image, NSError * _Nullable error, SDImageCacheType cacheType, NSURL * _Nullable imageURL) { |
这也算是一个解决方式吧。
在写完这些,跑过单元测试,提交了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是一个转换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项目,主要是贡献了两个功能。
由于整个外部命令行工具(有四个工具,其中cwebp
和webpmux
是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上改用静态链接。
关于Python的单元测试,由于这是一个简单的命令行工具,最后就通过引入pytest,直接对main函数和外部工具进行了测试,写起来也特别简单(自动匹配文件名和类名这点挺好)。用起来感觉比起Objective-C和Java的工具要好用多了。
在CI Build上,对于Linux和macOS的话,一般都会使用GitHub官方合作的Travis CI,配置使用yml语法,再加上一系列的Bash命令。而Windows上使用的Appveyor也非常好用,自带了VS 2012,2015,2017
,Msys2
,MinGW-w64
,cmake
等一系列工具,上手开箱即用。配置的话注意要使用CMD或者PowerShell,如果不熟悉,甚至可以用Msys2装一些UNIX工具来搞定(好处之一)。
总体来说,这个项目主要是苦力活,不过也算熟悉了一下UNIX工具在Windows上移植的一种手段,而且还学习到了pytest和开源项目的CI Build方式,也算有点意思吧。
iSparta是一个图形化的APNG和WebP转换工具,包含了很多功能(APNG合成,WebP转换,图片压缩等),虽说是开源项目,但是上一次提交已经是三年前了。而我最希望的APNG转换Animated WebP功能却没有实现(这也难怪,三年前Animated WebP规范还没出来)。大概看了一眼,使用的是NW.js(其实用的是改名前叫做node-webkit
的东西),是一个和Electron类似的,使用前端技术栈来构建跨平台应用的框架,本质上都是一个Chromium的运行环境来提供渲染,再加上node.js来提供JS Runtime。上手相对容易。
基本上的目标,是为了提供更好的GUI工具,因此主要就参考了一下iSparta的Issue,解决这几个问题:
由于我并不是专业前端出身(大二学过一段时间前端基本知识和Node.js简单应用,也接触过React Native),经过近两天的奋斗,才终于磕磕碰碰完成。期间遇到过各种问题(NW.js的问题,node第三方库的问题,跨平台行为不一致的问题等等),不过在这里略过说一下重点吧。
关于这个功能,自然可以想到上面的apng2webp命令行工具,不过由于apng2webp本身是Python写的脚本来调用外部工具,没必要在NW.js里打包一个Python环境。因此最后就决定直接在JS里,实现了相同逻辑的脚本来完成。不过实话说这部分花费的时间不长,在GUI布局上才是重头。大体框架参考了项目中的已有写法,但CSS的部分由于实在生疏(原项目有一些布局Hack),最后使用了flexbox布局来搞定的。
在网页端支持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是固定的)。
其实现在看看自己平时用到的应用,Atom
、VS Code
、GitKraken
、钉钉
,这些看起来已经足够复杂,也都能够用这种前端技术栈构建起来了。以前自己如果提到跨平台桌面客户端应用,第一反应就是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
。
说实话,参与开源项目的时候,你会发现一些社区是很有意思的,你能够和不认识的人去合作,还能够直观感受到其他人对项目的关注,更能够接触很多你之前从没有接触过的技术栈。我不能说自己是一个愿意花费大量个人时间去贡献开源事业的人,但是其实很多项目参与门槛不是那么高,无论是你自己平时用到的软件、类库,甚至是一个小工具、脚本、翻译、教程,都可以试着参与一下。我觉得程序员的知识,并不是为了单纯为了打工搬砖,能够把自己的想法与他人分享也是一个相当大的乐趣,不是吗?
]]>虽然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 | // Person.m |
对于很多情况,我们需要对外暴露属性是readonly的,以防止使用者手动修改,但是内部流程的时候也需要这个属性,并且希望是readwrite的,这个在类扩展中直接可以重新声明已有的属性,并修改属性修饰符。
1 | // Person.h |
注意,由于类扩展是可以在任何地方声明的(不限于.m实现文件),我们也可以把属性修饰符的修改,放到Private Header(可以用+Private
后缀,也可以参考UIKit等框架起名为UIKitInternal.h
)中,这样引入了Private Header的地方可以readwrite,没有引入的地方是readonly。
1 | // Person+Private.h |
但是很多时候,我们希望一些属性是私有的,即类实现处和引入了Private Header的地方才可以访问。这种时候就需要采取别的方式了。常见的方法是通过类扩展(主要针对类的实现文件可见)或者使用关联对象(主要针对类的实现文件不可见,如其他第三方库的类)两种方式。
类扩展,不同于Category,最大的优势在于可以直接添加实例变量ivar到类的本身实现中,而Category是无法添加实例变量的。而在类扩展中声明的属性,也可以自动在编译期合成,同普通类声明属性的方式相同,不了解的参见:CustomizingExistingClasses。因此,实际上类扩展非常适合隐藏私有属性。
1 | // Person+Private.h |
对于通常case来说,这是非常好的解决方法(不用任何额外代码)。但是有一个问题,如果你想自定义这个属性的存取方法(比如,实例变量的惰性初始化),那就会遇到问题。因为属性合成的ivar,是只在类本身实现中创建的,在Category中无法创建,而且类的实现只能实现一次(在原始的Person.m
中实现)。试想一下这样子的情况,就会出现编译错误:
1 | // Person+Private.m |
第一种解决方案:
最简单的方式,就是直接把自定义的存取方法写在类本身实现文件中,然后在Category中暴露头文件,并用@dynamic
来标记这个属性(否则由于Category看不到编译器自动生成的getter和setter会报warning)。自定义存取方式就和普通的写法一模一样。这相当于是一种把内部属性暴露出来的方法。不过容易导致耦合(因为其实我们的私有属性目标是用于和外部类交互的,不希望放到Private Category以外)。
1 | //Person.m |
第二种解决方案:
当然,聪明的你自然会想到,既然Category没法定义ivar,那直接在类扩展中声明一个ivar不就行了。于是你可以这样写,但是这会出现一个编译警告:
1 | // Person+Private.h |
由于在类扩展中已经定义了属性,那么这个类在编译期间会自动合成存取方法,而在Private Category中覆盖就会覆盖本身合成的方法(虽然我们确实需要这样),但由于可以在多处定义Category,并且方法覆盖的顺序不定,无法保证你的存取方法就是真实想要的,所以这是编译警告。对于这种需要自定义存取方法的私有属性的case,应该在类扩展中定义ivar,在Private Category中定义属性并实现。注意由于在类扩展定义了ivar,不会自动生成getter+setter,需要自行同时定义setter和getter,注意对不同属性修饰符,比如copy
的话setter需要用[-copy]
,weak
的话ivar要标注__weak
等。
1 | // Person+Private.h |
由于Objective-C的属性,其实就是ivar+getter方法+setter方法,我们可以在使用的地方通过Runtime来获取ivar。但是这种方式实际上来说是用的人非常少。第一个是复杂,第二个是不好使用一个通用的宏进行转换(因为ivar需要计算offset,根据不同类型的type encoding还不同……),而且对于这种需求来说优点大材小用了。因此我们一般都是使用关联对象(不了解的参见:Associated Object)
使用了关联对象后,为了方便不必要繁琐地书写objc_getAssociatedObject
、objc_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 |
strong
或者retain
,就是所有对象的默认属性存取行为,隐含着对对象进行retain而使引用计数+1。这个可直接通过关联对象的行为设置。
宏:
1 |
示例:
1 | @property (nonatomic, strong) NSNumber *number; |
copy
属性修饰,表示在调用setter的时候,首先需要对对象进行copy
操作,然后再表示strong
,在Objective-C中其实就是发送了copyWithZone:
消息。这个可直接通过关联对象的行为设置。
宏:
1 |
示例:
1 | @property (nonatomic, copy) NSString *name; |
unsafe_unretained
和assign
的语义是相同的,前者是ARC下加入的,而后者从MRC开始存在。一般来说,对于原始类型(int
、double
、BOOL
、NSInteger
)这些,由于本身就是copy by value,而且不存在对象和引用计数管理,因此属性声明用assign
(很少见写unsafe_unretained
,虽然允许)。
而对于对象而言,一般如果想表示不改变任何引用计数的弱引用,现在都用的是weak
,因为unsafe_unretained
不会像weak
那样,在对象引用计数降到0被销毁后,自动置nil,而会保持指向的地址,因此可能随时都成为野指针而不安全。但是由于历史代码缘故,还有很少的代码库在用,姑且暂时保留。
这里我们定义一个宏,仅用于表示对象的unsafe_unretained
和assign
。这个可直接通过关联对象的行为设置。而对于原始类型的属性,参见下面的assign
宏:
1 |
示例:
1 | @property (nonatomic, unsafe_unretained) NSObject *unsafeObject; |
区别于上面针对对象的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 |
由于装箱方式不同,拆箱方式肯定不同。不过只要拿到箱子之后,自己根据类型来进行相应拆箱即可。
示例:
1 | @property (nonatomic, assign) int age; |
weak
属性指的是一个弱引用,不改变对象的引用计数,同时和assign
和unsafe_unretained
的最大区别,在于有着自动置nil的安全性质。一旦weak对象被销毁,该引用不会成为一个野指针,而会被立即置为nil,保证了安全。对于如今的现代Objective-C,能表示弱引用全部使用weak,应当避免使用assign
和unsafe_unretained
表示一个弱引用(就算考虑上性能问题,weak立即置nil采用了一个全局的weak表,由Runtime管理,开销和手动release基本一致,不太可能成为性能问题)。
由于weak
的特殊性(全局weak表),关联对象本身就没有提供weak的语义行为,但是我们可以来模拟一个等价的行为。
第一种解决方案:
我们使用一个WeakContainer,只包含一个weak的属性,来存放真实的weak引用对象。这样,通过关联对象把整个WeakContainer关联到Category的属性上,然后存取使用的时候进行装箱和拆箱,解决方案即可。不过唯一的缺点是由于需要引入一个WeakContainer类,无法做到Header Only。
1 | @interface WeakObjectContainer : NSObject |
宏:
1 |
第二种解决方案:
为了做到Header only,我们需要借助一个匿名的block,首先定义一个weak引用指向属性值,然后block捕获它。这样子,只要把block关联到对象上,那么在getter的时候,通过直接执行block返回这个weak对象,就可以拿到真正的弱引用(实现时,block要用copy,而且要判空)。
宏:
1 |
示例:
1 | @property (nonatomic, weak) id delegate; |
自定义存取方法一般类的属性写法类似。比如说想要惰性初始化(即只有在第一次调用getter的时候,才会初始化属性)这里就不用_name
来操作ivar,而是通过setter(当然也能用__SET_*
宏来直接操作关联对象)就可以了。
示例:
1 | - (NSString *)name |
Objective-C没有真正意义上的私有方法,毕竟是C语言的超集嘛。但是Objective-C提供了一个类扩展语法,允许定义方法的接口。因此,只要我们在.m实现文件中定义了一些内部方法,就可以对外隐藏(当然,class-dump selector这些是可以直接调用的)
1 | // Person.m |
但一些情况下,我们需要很多库内部使用的类的私有方法(私有方法和内部方法虽然都不对外可见,但是其实目标不一样,私有方法一般是一些可以直接设置实例的状态,内部数据的危险方法,用于库内部的一些类之间,互相调用来使用。而内部方法一般放一些复杂流程处理,工具方法,是为了简化代码逻辑而使用的)这些方法需要和公开头文件的方法分开,保持对外隐藏。这时候就得用到Category。
我们可以把想要隐藏的私有方法,全部放到一个Private Category里面,库内部其他需要操作的地方,引用这个头文件即可。
1 | // Person+Private.h |
对于公开类,我们有可能在实现中定义很多内部的方法,这些方法可能依赖一些上下文,或者是只在类扩展里面定义的属性(而不是在我们的Private分类里面)。当我们在库的其他地方,也想使用这些内部方法时,但是方法定义不在Private Header中(虽然实际上在类内部已经实现了)。我们需要一种方式来暴露类的内部方法。
1 | //Person.m |
第一种解决方案(错误示范):
使用一个Private Category,在头文件中暴露这个方法。但是由于是类本身而不是Category的方法,编译器会报找不到internalMethod
的实现的warning(虽然它确实在本身的类中实现了)。我们是可以警告编译器,忽略warning,因为你知道实际上这个方法已经有了实现,只不过头文件没有暴露罢了。但是这种方法忽略警告,会忽略所有Private Category的方法检查,假如Person+Private.h中定义的方法真的没有在Person+Private.m中实现,也不会有任何警告,所以非常不推荐。
1 | //Person+Private.h |
第二种解决方案:
使用类拓展(而不是Private Category)来暴露一个内部方法,实际上这才是最佳的方式,因为类扩展并不局限于任何地方,而且可以在任何.h或者.m中进行声明。实际上,类扩展只有@interface而不能有@implementation,是方法的接口而不是实现,不会出现方法重定义或者覆盖的问题。这样,我们在类扩展中加入实际类的内部方法即可。
1 | //Person+Private.h |
因为类扩展在编译器检查时,是需要对类本身实现的方法进行检查的,因此假如Person类本身没有实现internalMethod,编译器会报warning,这也保证了正确性。
Objective-C毕竟已经几十年的语言了,语法层面上对抽象隐藏支持的就不好,不像Swift提供了四种访问控制关键字:public
、internal
、fileprivate
、private
,而且支持Module,再也不用担心命名和重定义问题了。不过Swift的现状,在Swift 4.0 ABI还不能稳定的情况下,代码库分发就只能使用源代码,这点对于很多开发者还有企业的影响确实比较大。不过了解Objective-C的实现也不是什么坏事,毕竟谁不定总会有需要写的的时候。希望这些代码库的接口与实现隐藏的方法,能够帮到一些平时没有接触过代码库开发的人吧。
对各种客户端来说,无论是Web还是移动端,图片占据的容量和传输资源一定是非常大的。对于静态图,我们常见的PNG和JPEG格式在压缩率和画质无损上都存在着不尽如人意的地方,而动图格式的GIF更是存在着很多问题,比如因此,在很多情况下,我们需要迁移到新的图片格式。
为什么我们不用GIF呢,GIF由于时代限制,存在的天生的问题。GIF的规范最新版本是在1989年制定的,一个24位色都没有普及的时代,因此,GIF规范只支持256色索引颜色,并且只能通过抖动、差值等方式模拟较多丰富的颜色。更为悲剧的是,它的alpha通道只有1bit,换言之,一个像素要么完全透明,要么完全不透明,而不像现在PNG的RGBA的8bit alpha通道,alpha值也可以和RGB一样都有255个透明值。这导致了所有GIF的图片带上透明度以后,边缘会出现明显的锯齿。所以如果你的客户端需要展示带透明度的动图,GIF基本上可以不考虑
实际的在线Demo,建议用Safari或者Chrome+插打开:http://apng.onevcat.com/demo
APNG是Mozilla在2008年发布的图片格式,本质上是在PNG的基础上加上一个扩展,而且非常简单即可实现。因此能够完全支持RGBA。规范可以参见APNG Specification。
虽然这个规范没有加入PNG开发组,但是很多浏览器已经支持了APNG。
最主推的是Apple的Safari(OS X 10.10以后的Safari,以及iOS 8以后的Safari和内置WebView),已经完全支持。Firefox亲儿子当然一直是支持的。Chrome桌面端已经从Chrome 59开始支持,现在就差Edge了。具体支持程度参见浏览器兼容性。
APNG的优势,在于时间比较长,各种动图制作工具,优化工具都有相应的项目来支持。而且在iOS上的WebView里面是除GIF外,唯一官方支持的动图格式,因此如果做移动端开发需要WebView页引入动图,APNG还是必不可少的。
当然,APNG终究是在PNG的基础上扩展,并没有引入特别出色的压缩算法,而且遗憾的是,短期内APNG还没有引入到Chrome,也就意味着Android平台的WebView也没有原生支持,因此,移动开发又会面临两端兼容性问题,这个后话再说。
APNG图形化制作工具和在线预览:iSparta
APNG大小优化:APNG Optimizer
APNG Chrome插件:APNG for Chrome
WebP是Google在2010年发布的图片格式,完全开源,使用了VP8(就是WebM视频所用到的解码器)作为帧压缩编码器,而且在Chrome,Android上得到了原生的支持,具体规范参见:WebP
同样的支持RGBA,而且静态WebP的压缩率比起同质量PNG平均要高上20%左右。现在各大App厂商已经有开始迁移WebP。除了静态的WebP,还有动态WebP格式(Animated WebP)支持,不过动态WebP需要libwebp 0.4以后才正式支持,并需要mux和demux模块,如果自行编译需要注意。
Google官方提供了libwebp这个解码库在各个平台的二进制版本和Makefile,并且可以定制开启的功能。不过由于不像APNG那样基于PNG扩展,相关的工具很欠缺,基本全靠WebP Project提供的工具。
cwebp
:PNG/JPEG -> WebPdwebp
:WebP -> PNG/JPEGvwebp
:WebP命令行预览工具webpmux
:多张WebP制作动态WebPgif2webp
:GIF -> 动态WebP
基本上来说,手动制作WebP会比较麻烦,因为Google没有提供WebP Optimizer之类的东西,如果我有100帧基本无差别的图使用webpmux合成动图,最终输出的文件大小会比较大。因此,一般推荐的做法,是先通过PNG制作APNG(比如iSparata),经过APNG Optimizer之后,再从APNG转换到动态WebP,这个流程可以用这个项目来一键搞定。
同时,也可以使用ffmpeg来转换视频到Animated WebP,一般使用MOV封装格式(UE常用的Pr导出的MOV可以支持alpha通道)。不过经过测试转换出来的Anmimated WebP大小相对比较大的(尤其同样的lossless下),不如PNG->APNG->Animtated Webp这个流程效果好。
apng2webp:APNG -> Animated WebP
ffmpeg:MOV -> Animated WebP
像国内的微博桌面版,提供的动图是通过PNG配合CSS Spirit,靠着不断JS轮播切换PNG子图所拼出来的,这个带来的带宽消耗会是非常高的,因为完全是多张图片混合,除非有着兼容性包袱(IE之类),一般不推荐使用。
APNG 浏览器支持
WebP 浏览器支持,注意Animated WebP支持
APNG:
Animated WebP:
WebP:
WebView:
YYImage,对显示动态图,使用了一个UIImageView的子类YYAnimatedImageView,通过直接插入了一个CALayer来作为图片的渲染layer,并用CADisplayLink
这个帧定时器来刷新动图帧,通过异步线程处理解码,还有一些C的动态分配和回收内存来避免非常高的内存占用,保证了性能。并且自动处理了从视图消失以及滚动(可以切换到RunLoopCommonMode来滚动时候依然显示动图而不暂停)情况的问题,实现也非常有意思,有兴趣的人可以看一看。
APNG:
Animated WebP:
WebView:
Android基本上对APNG可以说是没有什么支持的,所以如果是移动开发两个平台兼顾,建议同时准备APNG(for iOS WebView)和Animated WebP,客户端上建议都是用Animated WebP,因为VP8的解码速度相对于APNG有一些优势。
这个是一个非常大的坑,在Safari for iOS(Safari for macOS正常)和Chrome预览APNG和Animated WebP的时候,动图的循环次数为对应原图的loop+1。比如Animated WebP有100帧,loop为2,那么Chrome会循环总计展示300帧
刚开始我以为是移动端实现库的问题,毕竟Google和Apple这种大厂一般不会出现问题。但是再参阅了APNG和Animated WebP的规范,发现确实是Safari和Chrome本身的问题,可以参考APNG规范中的num_plyas
字段,和WebP规范的loop_count
字段
1 | Loop Count: 16 bits (uint16) |
规范提到的伪代码描述也表示,loop count为0表示无限循环展示首帧到尾帧,而loop count >= 1,展示首帧到尾帧loop count次。
1 | assert VP8X.flags.hasAnimation |
同样的,APNG对应的num_plays
字段意思是一样的,大家可以使用这个在线测试用例,Safari表现错误而多循环了一次:https://philip.html5.org/tests/apng/tests.html#num-plays-1
解决办法:
由于不能更改浏览器的实现,部分情况也不好引入JS来手动实现,因此,对于APNG,一般只用在iOS的WebView上,因此可以直接制作APNG图的时候,把循环减一。而Animated WebP,可以在客户端实现加一个Hack,如果loop不是0手动减一,保持和Web一致性(当然,也可以专门提供一个loop count加一的图给Chrome/Android的WebView),希望之后两大浏览器是否可以把这个Bug修复了(当然,不排除联合一起更改了规范的可能性)
GIF作为一个动图格式已经太过于古老了,尤其是当前移动和Web站需要引入各种动态表情,头像的时候,GIF的透明问题已经是不可接受的。WebP长期发展也是比较看好(相比APNG没有进入PNG开发组,基本不再活跃),开源外加无授权费用,或许能够和WebM一样,成为互联网下首选的图片和视频格式。而移动客户端,在很多种需求下(动态表情,用户标志,广告)等上面,采用这种APNG和Animated WebP就能够轻松解决。
]]>