2023年6月13日

|

解密 SwiftUI 背后的 AttributeGraph(一)

事情起源于Forumate项目,定位发现在 watchOS 10 上的某段代码会出现 AG 的循环提示

Forumate#28

并在业务场景下大概率导致 crash

完整源代码: HtmlText.swift


=== AttributeGraph: cycle detected through attribute 33496 ===
=== AttributeGraph: cycle detected through attribute 33496 ===
=== AttributeGraph: cycle detected through attribute 32904 ===
crash

后来整理得到最小化复现代码,发现在 iOS 16 下也会出现同样问题,最小代码如下

import SwiftUI
@main
struct AGIssueApp: App {
    var body: some Scene {
        WindowGroup {
            TabView {
                NavigationSplitView {
                    AView()
                } detail: {
                    Text("a")
                }
            }
        }
    }
}
struct AView: View {
    var body: some View {
        if let nsAttributedString = try? NSAttributedString(data: Data(), options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil) {
            Text("A")
        }
    }
}

熟悉 SwiftUI 的大部分同学看到这里会发出疑问:看起来这里没有任何奇怪的引用,都是最简单的 case

为什么会触发底层 runtime 的 warning 甚至是 crash 呢?下面就让我们一起来揭开 AG 的神秘面纱

通过开启 AG 的相关环境变量(AG_PRINT_CYCLES=2 & AG_TRAP_CYCLES=1),我们可以获取更多信息并让其在发生循环处断言崩溃

crash

或者我们也可以在 AG::Graph::printCycle 处添加符号断点进入


# Tip: 如何给 AG::Graph::printCycle 添加符号断点
# 在 lldb 窗口中输入 `image list` 拿到当前的image列表,找到AttributeGraph路径
image list
[  0] DC8290D3-58BA-3A61-8963-669F301C3087 0x000000019c384000 /Applications/Xcode-14.3.0.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/AttributeGraph.framework/AttributeGraph 
# 打开终端App,跳转到对应framework路径后,使用
nm AttributeGraph | grep print_cycle
# 即可拿到可用于 Xcode 符号断点的symbol (需要去掉开头第一个 _ )
# `__ZNK2AG5Graph11print_cycleENS_4data3ptrINS_4NodeEEE`

由于 SwiftUI 从 iOS 16 开始就不再提供符号,因此从函数堆栈中我们看不出太多有用信息

通过内部的AG工具我们可以拿到此时的AG Graph状态图,但是发现frame 0 - frame 3确实是有节点依赖链接上的,但是其他节点是孤立的,不会发生循环

比如 frame 3 为 32792 = 0xac88,通过索引发现没有和已知的其他节点有任何关联

graph

进一步地,我们可以首先在函数入口处,比如 Line 6 打上断点

然后进入 Memory Graph 视图,查找当前的 AttributeGraph 实例

memory graph

在 lldb 窗口中,通过内部工具给任意一个 graph instance 注入生命周期日志行为

本质是只有一个 AGGraph,其他的instance为它的“替身”

通过分析最终的日志我们观察到如下行为

我们给上面日志给出的5个循环堆栈节点标记为 [节点1, 节点2, 节点3, 节点4, 节点5, 节点1]


节点1开始更新 -> 节点2开始更新 -> 节点3开始更新 -> 节点6开始更新 -> ... -> 节点6完成更新 -> 节点7开始更新
节点4更新 -> 节点5更新 -> 节点1更新 ❌ // 触发循环检查
  • 为什么最终cycle stack中没有节点6
    • 这里是一个堆栈行为,后续的正常节点6通过“开始更新”,“完成更新”完成了入栈出栈,所以我们的日志中看不到节点6
  • 为什么节点7更新会触发节点4更新
    • 从依赖关系上看,二者毫无关联,看起来线索就在这里断掉了

stack1stack2

我们把目光移回开头的崩溃堆栈,可以观察到程序首先是在AG下进行节点的更新,然后触发了 SwiftUI.View 的 body 求值,而在 AView.body 的求值中,我们调用了 NSAttributedString 的某个初始化方法

extension NSAttributedString {
    @available(iOS 7.0, *)
    public init(data: Data, options: [NSAttributedString.DocumentReadingOptionKey : Any] = [:], documentAttributes dict: AutoreleasingUnsafeMutablePointer<NSDictionary?>?) throws
}

在这个方法的实现中它会触发 RunLoop 的更新,SwiftUI 侧会监听 RunLoop 的更新并唤起 Graph 的更新

stack3

注意 frame 7中的 mov w2, #0x1

这里就解释了上面困扰我们的问题,在节点7的更新过程中触发了 AView 的 body 求值,里面的某个 API 触发 RunLoop 更新调用导致重新触发了 AG::Graph::Update,最终导致环的出现

结论

根本原因是 NSAttributedString 的初始化方法的调用,修复方案为将这段计算在上游提前计算完成或者在 AView 上关联 task 中完成其计算并更新 State

struct HtmlText: View {
    let html: String
    init(rawHtml: String) {
        self.html = rawHtml
    }
    
    @State private var nsAttributedString: NSAttributedString?
    var body: some View {
        if let nsAttributedString,
           let attributedString = try? AttributedString(nsAttributedString, including: \.swiftUI) {
            Text(attributedString)
        } else {
            Text(html)
                .task {
                    nsAttributedString = try? NSAttributedString(data: Data(html.utf8), options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil)
                }
        }
    }
}

Forumate PR

同时也通过 Apple 的 Feedback 系统提交了相关反馈 FB12373757

思考

既然 NSAttributedString 的初始化方法是根本原因,为什么去掉 TableView 或者 NavigationSplitView 的任意一个后该问题无法复现呢?

通过咨询我司内部的 AG 资深大佬和个人猜测得知,有一些小的Graph碎片更新 AG 会通过 CFRunLoopObserverCreate 监听 Runtime 的空闲时机(如 CFRunLoopActivity.beforeWaiting)进行打包更新。

如果没有这两层Container,就不会产生对应的碎片,即使监听到了 RunLoop 更新,最终也不会触发成环

致谢

最后非常感谢字节的 DanceUI 团队和 RetVal 提供的技术指导和工具支持

本文的操作环境如下,后续随着 SwiftUI 版本不同,AG行为可能存在差异

  • iOS 16.1 ~ iOS 16.4 Simulator
  • Xcode 14.2 ~ 14.3
  • macOS 13.4