大多数浏览器和
Developer App 均支持流媒体播放。
-
使用 Instruments 分析挂起
UI 元素通常是对现实世界交互的模仿,包括实时响应。如果 App 的用户交互明显延迟,也就是出现挂起,则可能会打破这种幻想并让用户产生挫败感。我们将向你展示如何使用 Instruments 来分析、理解和修复所有 Apple 平台 App 中的挂起。了解如何有效地查看 Instruments 跟踪文档、解释跟踪数据并记录其他分析数据,从而更好地了解特定的挂起。 如果你不熟悉使用 Instruments,我们建议你首先观看“Instruments 入门指南”。如果你想了解可以帮助你发现 App 挂起的其它工具,请查看“通过 Xcode 和设备端检测追踪挂起”。
章节
- 1:56 - What is a hang?
- 3:51 - What is instant?
- 4:39 - Event handling and rendering loop
- 8:25 - Keep main thread work below 100ms
- 9:15 - Busy main thread hang
- 14:26 - Too long or too often?
- 21:46 - LazyVGrid still hangs on iPad
- 24:31 - Fix: Use task modifier to load thumbnail asynchronously
- 25:52 - Asynchronous hangs
- 32:38 - Fix: Get off of the main actor
- 35:57 - Blocked Main Thread Hang
- 39:19 - Fix: Make shared property async
- 40:35 - Blocked Thread does not imply unresponsive app
资源
相关视频
WWDC23
WWDC22
WWDC21
Tech Talks
WWDC20
WWDC19
WWDC16
-
下载
♪ ♪
Joachim Kurz:欢迎观看 “用 Instruments 分析挂起” 我是 Joachim Instruments 团队的一名工程师 今天 我们来详细了解一下挂起 我将先向你简单介绍什么是挂起 为此 我们会谈到人类的感知 然后 我将简要介绍事件处理 与渲染循环 因为这是 理解挂起形成原因的基础 有了这些理论知识 我们将话题转向 Instruments 并共同讨论三个不同的挂起例示: 繁忙的主线程挂起、异步挂起 和阻塞的主线程挂起 对于每种情况 我将向你 展示如何识别它们 在分析它们时要关注什么 以及如何知道何时 将其他工具添加到你的文档中 来进一步了解更多细节 开始之前 作为本次讲座的一部分 稍微熟悉一下 Instruments 会效果更好 如果你曾经使用 Instruments 分析过 App 那你可以继续往下观看 否则 建议查看我们 2019 年 的讲座“Instruments 入门指南”
处理挂起时 通常分为三个步骤: 找到挂起 然后分析该挂起 来找到它发生的原因 最后修复它 (并验证该挂起的确已修复) 今天我们会假设 你已经找到了一个挂起 将重点放在分析部分 以及一些修复措施的讨论 如果你想了解更多 有关查找挂起的信息 可以看看我们的另一场讲座 WWDC22 的“通过 Xcode 和设备端检测追踪挂起” 那次讲座涵盖了我们用于查找挂起 的所有工具 包括 Instruments、 设备端挂起检测 (你可以在 iOS 开发者设置中启用) 和 Xcode Organizer 今天 我们将使用 Instruments 分析一个已经发现的挂起 为了更好地理解挂起 我们来谈谈人类的感知和灯的打开
我们需要一个灯泡和一根电线 啊 好多了 对于灯来说 当我插入 电线时它就会亮起 当我将电线拔掉时 它就会立刻熄灭 但如果有延迟会是什么样子? 我将电线插入 但需要一段时间才能打开 更奇怪的是 当我拔掉电线时 会发生同样的情况 从电线插入到灯打开 的时间只有 500 毫秒 但你可能已经开始好奇 这个盒子里到底发生了什么 灯不能即时开关 感觉有点奇怪 不过 在其他一些情况下 500 毫秒的延迟也无伤大雅 什么样的延迟是可以接受的 要取决于具体情况 假设你无意中听到这样的对话: “乌龟是如何交流的?” “用乌龟专用手机” 在这里 问题和答案之间 有一秒钟的延迟 感觉很自然 但电灯开关并非如此
这是为什么? 乌龟和独角兽的对话 是一种请求—响应式交互 但把电线插入一盏灯却是 直接操作一个真实的物体 真实的物体会立即做出反应 如果我们模拟一个真实的东西 它也应该立即做出反应 如果没有 就会带来虚假感
我说这是一盏真正的灯时 你没有任何意见 是因为在电缆插入到灯打开之间 没有任何延迟 但当出现明显的延迟时 你的大脑会突然说:“等等 这东西不是这样运作的” 但即时有多快呢? 什么延迟才会小到让我们注意不到?
这是基线 没有延迟
100 毫秒怎么样?
对我来说 感觉就像我在 打开它时注意到了一点点延迟 但在关闭它时却没有 除非我仔细观察才能发现 你的体验可能会有所不同 100 毫秒是一个阈值 明显较小的延迟其实是无法感知的 让我们试试 250 毫秒
250 毫秒感觉就 不再是即时的了 并不慢 但延迟绝对是明显的
这些感知阈值也为 我们的挂起报告提供了参考 对于离散交互 比如轻点一个按钮来说 低于大约 100 毫秒的延迟 通常感觉是即时的 在某些特殊情况下 你可能希望能更低于该值 但 100 毫秒是一个不错的目标 除此之外 还要根据具体情况而定 延迟在 250 毫秒以下 你可能都觉得还可以 如果超过这个时间 延迟就会变得明显 至少在潜意识里是如此 这是一个连续的尺度 但超过 250 毫秒后 肯定不会感觉是即时的 因此 我们的大多数工具 默认从 250 毫秒开始报告挂起 但我们称它们为“轻微挂起” 因为它们很容易被忽视 根据具体情况 这些挂起也可能 并不是问题 但通常情况并非如此 所有超过 500 毫秒的延迟 我们都认为肯定算是挂起 基于此 我们可以粗略使用以下阈值: 如果你想让某些东西感觉即时 那么延迟的目标是 100 毫秒或更短 如果你需要请求—响应式交互 500 毫秒内没有任何反馈 可能也可以接受 但实际上 在同一次交互中 经常是两者兼而有之 让我们看一个例子
我刚刚写完这封邮件 想发给 所有帮助准备这次讲座的同事 我已经准备发送邮件了 我将鼠标移至 Send 按钮并点击 片刻之后 电子邮件窗口移出屏幕 表明邮件正在发送 在这个过程中 你实际上 看到了两件事的发生 首先 按钮突出显示 然后有 500 毫秒的小延迟 接着电子邮件窗口移出屏幕 但这种延迟感觉没问题 因为我们已经知道 按钮突出显示表示系统 收到了我们的请求 我们将按钮视为“真实”的东西 我们希望它能“真实”地立即刷新
因此对于界面中的实际 UI 元素 我们通常希望 实现这种“即时”刷新 为了让我们的 UI 元素 “即时”响应 保持主线程免受非 UI 工作 的影响是至关重要的 想知道原因 让我们来仔细了解 事件处理和渲染循环 看看 Apple 平台是 如何处理事件的 以及用户输入触发屏幕更新的机制
在某个时间点 会出现人与设备的交互 我们无法控制这种情况发生的时间 首先 这个过程通常 会涉及一些硬件 例如鼠标或触摸屏 这些硬件会检测到交互 创建一个事件 并将其发送到操作系统 操作系统确定将该事件 交给哪个进程处理 并将其转发到该进程 例如你的 App 在 App 中 App 主线程 负责处理事件 这也是 大部分 UI 代码运行的地方 它决定着更新 UI 的方式 然后这个 UI 更新 被发送到渲染服务器 这是一个单独的进程 负责合成各个 UI 层 并渲染下一帧 最后 显示驱动获取 渲染服务器准备的位图 并相应地更新屏幕上的像素 如果你想了解更多 有关其工作原理的信息 可以查阅“提高 App 响应能力”的有关文档 对于大家来说 刚刚的概述足以 帮助你了解目前的进程 现在 当另一个事件 在此期间到来时 它们通常可以并行处理 但是 如果我们只看单个事件 是如何通过管线传输的 那我们仍需按顺序查看所有步骤 在我们进入主线程之前的 事件处理步骤 以及之后的渲染和更新显示步骤 在其持续时间内 通常是相当可预测的 当我们在交互中遇到明显的延迟时 原因往往是主线程上的 某些部分花费了太长时间 或者当事件进入时 主线程上还有其他任务在执行 所以需要等待其完成 才能处理这一事件 鉴于每次更新 UI 元素都需要 在主线程上花费一些时间 且为了感官上更加真实 我们希望 这些更新能在 100 毫秒内完成 理想情况下 主线程上的任何 活动都不应超过 100 毫秒 如果能更快一点 那就再好不过了 请注意 主线程上长时间运行的 活动也可能会导致卡顿 通常采用较低的阈值 以避免出现卡顿 你可以在我们的 Tech Talk 讲座 “探索 UI 动画卡顿和渲染循环” 以及我们关于“提高 App 响应能力” 的文档中了解更多关于卡顿的知识 今天 我们重点讨论挂起 我的一位同事在为我们的一款叫做 Backyard Birds 的 App 开发新功能时 刚刚在其中发现了一处挂起 让我们使用 Instruments 分析一下该 App
我准备好了 这个 App 的 Xcode 项目 要在 Instruments 中 分析该 App 我只需要点击 Product 菜单 再点击 Profile 然后 Xcode 就会构建该 App 并将其安装到设备上 但并不会启动 App
Xcode 还会打开 Instruments 并且以 Xcode 中配置的相同的 App 和设备为目标进行配置 在 Instruments 的模板选择器中 选择 Time Profiler 模板 如果你还不知道要寻找什么 并想更好地了解你的 App 正在做什么 Time Profiler 通常是一个很好的起点 上述操作将以 Time Profiler 为模板 创建一个新的 Instruments 文档 在这个新文件包含的工具中 Time Profiler 工具和 Hangs 工具 对我们的分析都很有用 点击工具栏左上角的 Record 按钮开始录制 Instruments 启动配置好的 App 并开始捕获数据
现在 Backyard Birds App 已经启动了 我轻点第一个花园进入细节视图 当我接着轻点 “Choose Background”按钮时 应该出现一个底部列表 显示可供选择的背景图片 现在让我这么做试试 我点击了按钮 但似乎卡住了 过了好一段时间 列表才出现 这是一个严重挂起
Instruments 一直在记录这一切 我在工具栏中点击 Stop 按钮 来停止录制 Instruments 也检测到了挂起 它测量挂起的持续时间 并根据严重程度标记相应的区间 在这种情况下 Instruments 显示发生了“严重挂起” 这也符合我们在使用该 App 时所经历的情况 Instruments 检测到主线程无响应 并将相应的间隔 标记为可能出现挂起 在我们的例子中 也确实发生了挂起 主线程无响应主要有两种情况 最简单的情况是 主线程只是 仍然忙于处理其他活动 在这种情况下 主线程将 显示大量 CPU 活动 另一种情况是主线程被阻塞 这通常是因为主线程 正在等待其他地方 完成某些工作 当线程被阻塞时 主线程上几乎没有 CPU 活动 你的情况决定了你下一步 应该采取哪些步骤 来确定发生了什么 回到 Instruments 我们需要 找到 Main Thread 文档中的最后一个轨道显示了 我们目标进程的轨道 其左侧有一个小的展开按钮 表明存在子轨道 点击该指示器 显示进程中 每个线程的单独轨道 然后 在此选择 Main Thread 轨道 这样做还会更新详细信息区域 显示 Profile 视图 该视图展示了整个记录时间内 在主线程上执行的 所有函数的调用树
但我们只关注挂起期间发生的事情 因此 我接着在时间线中右键点击 Hang-interval 打开子菜单 我可以在这里选择 Set Inspection Range 但我要按住 option 键 然后选择 Set Inspection Range and Zoom
这将放大间隔的范围 并将细节视图中显示的数据 过滤到选定的时间范围
虽然在整个挂起间隔期间 CPU 使用率不是 100% 但数值仍然相当高 大多数时候 CPU 使用率在 60% 到 90% 之间 这显然是主线程繁忙的情况 让我们看看 CPU 在做什么
我们可以在这个视图中仔细查看 调用树中的所有不同节点 但右侧有一个很好的总结: Heaviest Stack Trace 视图 当我点击该视图中的一个框架时 调用树视图会更新以显示该节点 这也向我们表明这个方法调用 已经在调用树中十分深入了
默认情况下 Heaviest Stack Trace 隐藏了 非源自源代码的后续函数调用 方便用户更容易地查看 源代码涉及的位置 我们可以通过点击 底部栏中的 Call Tree 按钮 并启用 Hide System Libraries 复选框 将类似的过滤器应用于调用树视图 这将从系统库中过滤出所有函数 并让我们更容易专注于自己的代码 调用树视图显示 几乎所有的回溯都包含 “BackgroundThumbnailView.body.getter”调用 看来我们应该让 body getter 再快一点 你觉得呢? 不尽然! 所以我们现在知道 存在主线程繁忙的情况 这意味着 CPU 正在做很多工作 我们还发现了有一种方法 在消耗大量 CPU 时间 但现在有两种不同的情况 我们在这个方法中 花费了大量的 CPU 时间 可能是因为这个方法本身 运行了很长时间 但也可能是这种方法 被调用了很多次 所以它才出现在这里 我们应该如何减少主线程上的工作 取决于具体情况
典型的调用堆栈结构如下 这里有一个 main 函数的调用 它会调用一些 UI 框架和其他东西 然后 在某个时刻 你的代码会被调用 如果这个函数只被调用一次 并且一次调用需要很长时间 比如这里的乌龟函数 那么我们就要查看一下 它调用了什么 如果它做了很多工作 那我们也许就能少做点 但也可能是 我们正在调查的这个 方法被调用了很多次 就像这里的独角兽一样 当然 它所做的工作 也就反复进行了很多次 这通常是因为有调用方 多次调用独角兽函数 例如从循环中调用 与其优化我们 关注的独角兽函数 不如研究如何减少 调用该函数的次数
这意味着我们下一步需要考虑的方向 取决于我们面临的具体情况 对于长时间运行的函数 例如我们的乌龟案例 我们应该看看它的实现和被调用者 我们需要往下看 然而 如果一个函数被 调用很多次 比如独角兽 那么更有效的做法是 查看是什么调用了它 并确定是否可以减少调用次数 我们需要向上看 但 Time Profiler 无法 告诉我们现在是哪种情况 我们假设对独角兽和 乌龟的调用是接连发生的 Time Profiler 通过间隔一定的时间 检查 CPU 上运行的内容 来收集数据 对于每个样本 它都会检查 CPU 上当前正在运行哪个函数 在此示例中 我们将获得 乌龟和独角兽分别出现四次的数据 但也可能是乌龟函数速度很快 而独角兽则需要更长的时间 或者其他情况的组合 所有这些场景都将在 Time Profiler 中创建相同的数据
要测量特定函数的执行 时间 请使用 os_signposts 我们在 2019 年的讲座中 介绍了如何做到这一点 详见“Instruments 入门指南” 但也有适用于各种技术的专门工具 可以准确地告诉你发生了什么 SwiftUI View Body 工具便是其中之一 要添加 SwiftUI Body 工具的话 点击工具栏右上角的加号按钮 即可打开 Instruments 库 这是 Instruments App 提供的 所有工具的列表 非常丰富 你甚至可以编写自定义工具
我在过滤器字段中输入“SwiftUI” 系统显示出两个工具 选择“View Body”工具 并将其拖到文档窗口中 完成添加 现在 由于上次记录时 该工具不在文档中 所以它没有可显示的数据 但没问题 我们就再记录一次吧 为了节省时间 我已经记录好了 我在文档中用 SwiftUI View Body 工具记录后 View Body 轨道现在 也显示出一些数据 SwiftUI 视图主体轨道中 有很多间隔 轨道有点窄 所以我按下 Ctrl+Plus 增加轨道高度 SwiftUI View Body 轨道 根据实现它们的库 对间隔进行分组 每个间隔 代表执行一次 body 视图 让我们再次放大挂起部分
第二轨中有很多橙色间隔 都被标记为 “BackgroundThumbnailView” 这准确地告诉我们 执行了多少个 body 视图 以及每一次执行需要多长时间 橙色表示特定 body 的执行时间 比我们在 SwiftUI 中的 目标时间稍长 但更大的问题似乎是有多少个间隔 在细节视图中 有所有 body 间隔的简要情况 点击 Backyard Birds 旁的展开按钮 可以查看 Backyard Birds 中 各个视图类型 这表明 BackgroundThumbnailView 的 body 执行了 70 次 平均持续时间约为 50 毫秒 总持续时间超过 3 秒 这几乎解释了我们所有的挂起时间 但是 当我们只需要 在前面显示 6 张图像时 70 次似乎有点多 在这种情况下 应该 减少调用 body 的次数 所以我们需要查看 body getter 的调用者 找出为什么它经常被调用 并看看如何减少调用次数 为了轻松找到相关代码 我再次选择 Main Thread 轨道 然后右键点击调用树中的 BackgroundThumbnailView.body.getter 节点 打开子菜单 然后 选择“Reveal in Xcode”
这就在 Xcode 中打开了 我们的 body 实现 让我们来看看如何使用这个视图 右键点击类型 然后依次选择“Find”和 “Find Selected Symbol in Workspace” Find 导航器中的第一个结果 就是我们要找的内容
这里 我们的 “BackgroundThumbnailView”被用于 GridRow 内的一个 ForEach 而它又处于一个 Grid 的另一个 ForEach 内 Grid 会在创建时立即 处理其全部内容 因此即使我们只需要 前几个背景缩略图 它也会计算所有的背景缩略图 但还有一个替代方案:LazyVGrid 它仅处理填满一个屏幕所需的视图 SwiftUI 中的许多视图 都有 lazy 变体 只处理所需视图 这通常是减少工作量的简单方法 然而 在渲染相同的内容时 eager 变体使用的内存要少得多 在默认情况下可以 使用常规的 eager 变体 但你发现由于前期工作过多而导致 性能问题时 请切换到 lazy 变体
我们在 WWDC 2020 上关于“SwiftUI 中的叠放、网格和大纲”的讲座中 介绍了这些 lazy 变体 并对其进行了更详细的描述 让我们分析一下更新后的代码 开始记录 再次轻点 Choose Background 按钮 重现这个挂起 现在 情况好多了 虽然还是有一点延迟 但已经没有之前那么严重了 Instruments 证实了这一点 我们记录的挂起时间 现在不到 400 毫秒 这是一个轻微挂起 “View Body”轨道还显示 我们现在只执行了 8 次 BackgroundThumbnail 的 body 符合我们的预期 也许这就足够了 轻微挂起并不是很明显 通过分析 iPad 上的 Backyard Birds 我们还可以确保该 App 在其他设备类型上也能正常运行
看 我正在 iPad 上运行 Backyard Birds 我打开了细节视图 轻点“Choose Background”按钮 这个列表需要很长时间才能出现 列表出现后 我们就能明白 为什么会这么慢了 因为我们的屏幕更大 空间更大 所以缩略图也更多了 Instruments 也记录了这次挂起
将检查范围集中在挂起间隔 我们再次看到更多的 BackgroundThumbnailView 主体 这也说得通 现在我们需要在整个屏幕上 渲染大约 40 个图像 因为此时屏幕可以容纳更多图像 因此 同样的代码在 iPhone 上运行基本正常 但在 iPad 上速度很慢 这只是因为屏幕变大了 这就是你应该同样修复 轻微挂起的原因之一 你在测试时看到的轻微挂起 条件更改后 可能对某些用户 来说是就是明显的挂起 我们现在 只渲染填充屏幕所需的视图 因此在减少调用频率方面 耗尽了优化潜力 让我们看看如何才能使 单次执行速度变得更快 我将检查范围设置为单个 BackgroundThumbnailView 的间隔 并切换回“Main Thread”轨道 Instruments 在 Heaviest Backtrace View 中 显示了 view body getter 并显示它调用了名叫 “BackyardBackground.thumbnail” 的属性获取器 这是一个模型对象 它提供了 要在我们的视图中显示的缩略图 该缩略图获取器调用了“UIImage imageByPreparingThumbnailOfSize:” 我们似乎是顺手处理了缩略图 这可能需要一些时间 在本例中 大约为 150 毫秒 这是我们应该在后台进行的工作 而不是让主线程被其占用 为了更好地理解我们可以做的改变 我想看看缩略图获取器 被调用的环境 我在 Heaviest Stack Trace 视图中 右键点击 “BackgroundThumbnailView.body.getter” 然后选择“Open in Source Viewer” 调用树视图会变为源代码查看器 显示了 body getter 的实现 并用 Time Profiler 示例 注释了实现的代码行 显示了代码在不同地方 花费了多少时间 我们的 body 实现 在这里非常简单; 只是使用背景返回的缩略图 创建一个新的 Image 视图 但这个缩略图的调用需要很长时间 我想到一个新的代码写法 为了跳转到 Xcode 我点击右上角的菜单按钮 并选择“Open file in Xcode”
和之前一样 此处显示了我们在 Xcode 中的源代码 可以随时进行修改 我现在要做的是在后台加载缩略图 并在加载过程中显示进度指示器 首先 我们需要一个状态变量 来保存加载的缩略图
然后 在 body 中 如果我们已经加载了图像 则将 在 Image 视图中使用该图像 否则会显示进度视图
现在剩下的就是 加载实际的缩略图 我们希望在进度视图 出现后就开始加载缩略图 这就是“.task”修饰符的作用
出现后 SwiftUI 将 为我们启动一个任务 即调用“thumbnail”获取器 并将结果赋给“image” 由此更新视图 让我们试试看! 现在我使用 Instruments 开始记录 轻点“Choose Background”按钮 列表立刻就出现了! 好极了! 我们看到了进度指示器 几秒钟后 我们的缩略图就显示出来了 成功 太棒了!
等等 Instruments 仍然 显示了将近 2 秒钟的挂起 现在发生的情况是 挂起的时间 稍微晚了一些 我们一起看看挂起 在 Backyard Birds App 中在哪里发生 我已经打开了细节视图 紧接着 我会再次轻点 “Choose Background”按钮 然后尝试轻点 Done 按钮 希望能直接删除工作表 好的 现在我依次点击 “Choose Background”和“Done” 我轻点了好多次 但在加载过程中 我的轻点被忽略了 这就是 Instruments 报告给我们的挂起 这次发生在工作表显示之后
这种挂起与之前的类型略有不同 我们已经讨论过主线程繁忙与 阻塞之间的区别 还有另一种给挂起分类的方式 就是挂起发生的原因与时间 我们称之为同步挂起和异步挂起
在这里 主线程正在进行一些工作 如果一个事件进入后 需要很长时间处理该事件 那么这就是挂起 假设我们控制住了这一因素 确保事件得到快速处理 但也许我们只是延迟了一些 工作 让主线程稍后再将其完成; 或者主线程处理了一些其他 工作 然后一个新事件进来了 所以该事件必须等待之前的工作 完成后才能得到处理 即使每个单独事件的 代码能很快处理完成 这仍然会导致挂起 在我们的平台上 挂起检测的工作方式是 查看主线程上的所有工作项 并检查相关项是否过长 如果是 则将其标记为潜在挂起 无论是否有用户输入 它都会这样做 因为用户输入可能在任何时候发生 然后我们就会面临真正挂起的出现 这意味着挂起检测也会检测到 这些异步或延迟情况 但它只测算潜在的延迟 而不是实际经历的延迟
我们之所以将异步挂起称为 异步挂起 是因为它们通常是 由主队列上正在进行的 “dispatch_async”活动 或在 main actor 上异步运行的 Swift Concurrency 引起的 但是 任何让主线程投入工作的 原因都可能导致挂起 我们看到的第一个挂起是同步挂起 我们轻点了一个按钮 触发了 长时间运行的活动 因此结果显示较晚
刚刚看到的挂起是异步或延迟挂起 轻点 Done 按钮本身实际上 并不会触发耗费较多时间的活动 但是主线程上仍有正在处理的任务 这导致了轻点没有被处理 因此 如果在此期间没有与 App 交互 使用 App 的人可能根本 不会注意到这个问题 但我们仍应修复这些问题 以防带来困扰 现在就开始修复吧 我又回到了 Instruments 我已经将选择范围设置为 刚刚的异步挂起 并进行了放大 在 view body track 的摘要视图中 Instruments 显示我们的 BackgroundThumbnailView 的 body getter 现在有 75 次调用 这是因为大多数缩略图 body getter 都被执行了两次 SwiftUI 创建了 40 个带有 进度指示器的视图来填充网格 但实际上最终只显示了 35 个 对于这 35 个 我们开始加载图像 加载完成后 视图会更新并再次调用 body 总共执行了 75 次 body getter
即使有所有 75 个 body getter 总执行时间也远远小于 1 毫秒 所以我们的 body getter 现在速度 已经很快了 这部分完成了 但是我们仍然有一个挂起 我再次点击“Main Thread”轨道 在 Heaviest Stack Trace 视图中 由 Instruments 可知 在主线程上耗时较长的 仍然是缩略图获取器 这一次 它被 “BackgroundThumbnailView.body.getter” 中的闭包调用 而不是直接被 body getter 调用 双击它 这是打开源代码查看器的快捷方式 现在这正是我们期望 在后台执行的代码 因为它在任务修饰符的闭包中 这段代码应该在此时运行 但不应该在主线程上运行 对于这样的问题 即 Swift Concurrency 任务 没有按照你期望的方式执行 我们有另一个辅助解决工具: Swift Concurrency Tasks 工具 我已经记录了添加 Swift Concurrency Tasks 工具后的相同行为 Swift Tasks 工具为文档 添加了一个摘要轨道 但对于我们的案例来说 更有趣的是 它为每个线程轨道贡献的数据 这里 在主线程轨道中 有一个来自 Swift Tasks 工具的新图表 单个轨道可以显示多个图表 通过点击 线程轨道标题中的向下箭头 我可以配置显示哪些图表 我可以选择另一个图表 比如 Time Profiler 的 CPU Usage 图表 或者在点击的同时按住 Command 键选择多个图表 所以现在 Instruments 同时显示了这个线程的 CPU Usage 以及该线程的 Swift Tasks 图表 再次放大挂起时间间隔 现在“Swift Tasks”轨清晰显示了 主线程上有大量任务在执行 将检查范围设置为其中之一 并检查 Profile 视图中的 Heaviest Stack Trace 确认该任务封装了 我们的缩略图处理工作
因此 这项工作如我们所愿 被正确地封装在一个任务中 但是该任务正在主线程上执行 这出乎了我们的意料 让我解释一下发生了什么 首先 body getter 继承了 SwiftUI 的 View 协议中的 @MainActor 注释 因为“body”在“View”协议中 被注释为“@MainActor” 所以当我们实现它时 body getter 也被隐式注释为 @MainActor 其次 “.task”修饰符的闭包 被注释为继承周围环境的 actor 而隔离 由于 body getter 被隔离到 MainActor 任务闭包也将被隔离 因此 默认情况下 在该闭包中运行的所有代码 都将运行在 main actor 上 并且由于 “thumbnail”获取器是同步的 因此它现在同步运行在主线程上
默认情况下 Swift Concurrency Tasks 继承了 周围环境的 actor 隔离 SwiftUI 的 .task 修饰符 也有同样的行为 有两种方法 可以摆脱 main actor 异步调用 未绑定到 main actor 的函数 允许任务脱离 main actor 在某些情况下 这是不可行的 那么 你可以通过使用 “Task.detached”显式地 将任务从周围的 actor 环境中分离出来 但这并不是最简捷的方法 而且创建一个单独的任务 比简单地暂停一个 现有任务成本更高 当相应的视图消失时 SwiftUI 还会自动取消通过任务修饰符 创建的任务 但这种取消不会像 Task.detached 那样 传播到新的非结构化任务中 要了解更多信息 请查看 WWDC22 上的 “Swift Concurrency 的可视化和优化” 以及我们关于提高 App 响应速度的文档 因为在我们的案例中 我们已经处于异步环境中 而且让缩略图函数转为 非隔离和异步状态十分容易 所以我们将选择第一个选项 这里是我们的缩略图加载代码 问题在于 由于继承了 body getter 的 main actor 隔离 这个任务 将在 main actor 上执行 而由于 thumbnail 获取器是同步的 它也将停留在 main actor 上 解决方法很简单 我们跳转到 thumbnail 获取器 的定义部分 使获取器成为异步 然后回到我们的视图结构……
因为我们的获取器现在是异步的 所以需要在它前面添加 await
这将允许“thumbnail”获取器在 Swift Concurrency 并发线程池而不是主线程上执行 我们来试试吧 再次进入细节视图 轻点 “Choose Background” 哇 好快啊! 不仅没有挂起 而且整体加载速度似乎也更快了 我几乎没有看到进度视图 Instruments 也确认现在没有挂起 这里的 CPU 使用率很高 让我放大一下 这是现在加载缩略图的地方 检查主线程 我们可以确认 主线程上的所有任务间隔 现在都非常短 向下滚动到其他线程轨迹 可以发现 我们的 Swift 任务 现在是在其他线程上并行执行的 而不是顺序执行 这样就可以更好地 利用我们的多核 CPU 由此 我们能够在几百毫秒内 处理完所有缩略图 而无需花费将近 1.5 秒 在这段时间内 主线程始终保持响应 因此我们现在已经 彻底解决了这个问题
我们现在已经调查并修复了 因主线程繁忙 而导致的主线程无响应问题 可以借由主线程在挂起期间使用 大量 CPU 的现象来识别该问题 我们还了解了挂起可以是同步的 即直接作为用户交互的一部分发生; 或者是异步的 即之前在主线程上调度的工作 导致传入的事件被延迟处理 以及 Instruments 如何检测这两种情况 我们通过减少工作和 在后台进行其他 无法减少的工作来修复挂起 并且只在更新 UI 时 回到主线程 但还有一种情况我们还没有考虑过 那就是主线程被阻塞 在这种情况下 主线程的 CPU 使用率会很低 其他维度同样适用于 被阻塞的主线程 但是分析这种情况还需要其他工具 现在我们来看一个例子
这里是另一个挂起的跟踪文件 我已经放大了挂起部分 挂起的时间很长 有几秒钟 在“Main Thread”轨道中 CPU 使用率图显示 最初 CPU 使用率还有数值 但随后就归零了 这显然是主线程阻塞的情况 我们讨论过 Time Profiler 如何 通过对 CPU 上运行的程序取样 来收集数据
当我们放大时 CPU 使用率 图表甚至会显示单个样本 因此 这里的每个标记都是 Time Profiler 采集的样本 右侧还有一些样本 但之后就没有了 但是当我选择一个 没有样本的时间范围时 Time Profiler 无法 告诉我们发生了什么 因为这段时间它没有记录任何数据 因此我们需要另一种工具: Thread States 工具 与之前的其他工具一样 你可以从 Instruments 库中 添加该工具 我已经再次记录了同样的挂起 不过这次添加了 “Thread State Trace”工具 现在这个工具有了一个新的轨道 但是和“Swift Concurrency” 工具一样 我们感兴趣的数据实际上在 “thread”轨道中 在主线程中有一个很长的 “blocked”时间间隔 超过 6 秒 这解释了我们 大部分的挂起持续时间 当我点击间隔的中间位置时 Instruments 的时间游标也会移动到那里 同时也会更新细节区域 中的 Narrative 视图 显示这个阻塞状态的条目 Narrative 视图 告诉我们线程的故事: 它在做什么 何时做的 以及为什么要做
对于选定的时间 它告诉我们线程被阻塞了 6.64 秒 阻塞的原因是调用了一个 系统调用 mach_msg2_trap 右侧又是一个回溯视图 但这个回溯并不是最重要的回溯 它不是某种集合 它是导致线程阻塞的 mach_msg2_trap 系统调用的 精确回溯 函数调用显示为底部的叶节点 其调用栈显示在上方 调用栈告诉我们 该系统调用是由于 分配了一个 MLModel 而发生的 而分配 MLModel 又是由于分配了一个 “ColorizingService”类型的对象而发生的 该对象作为该 colorizing service 上 名为“shared”的单例属性的 一部分而被调用 而 shared 又是由一个 body getter 中的闭包调用的 如果我们双击该闭包 就可以再次跳转到源代码查看器 并找到调用该属性的代码 这行代码看起来没什么问题 对吧? 让我们仔细看看
我们正在访问 ColorizingService 的共享属性 并将其存储在局部变量中 但这并不是毫无问题的 因为共享属性会在第一次访问时 创建共享的 ColorizingService 实例 这反过来又会 启动整个模型加载机制 从而阻塞线程 所以你可能会说: “让我们把它移到 ‘await’之后的 async 部分” 然而 与直觉相反 这并不能解决问题 “await”关键字 仅适用于后续代码中的 异步函数调用 在我们的示例中 “colorize”函数是“async”的 但“shared”属性不是 因为它是 static let 属性 所以在第一次被访问时 它会被延迟初始化 而这是同步发生的 await 关键字无法改变这一点 所以同步调用仍会在主线程上发生 我们可以用之前示例中的方法 来解决这个问题 将 shared 属性 也设置为“async” 从而脱离 main actor 一般来说 当你在其他地方 等待线程的工作时 这种情况是没有问题的 然而 阻塞线程的另一个 常见原因是锁或信号量 有关在 Swift Concurrency 中 使用锁和信号量时 应牢记的最佳实践和应避免的事项 请观看我们在 WWDC 21 的 讲座“Swift Concurrency:幕后”
在结束之前 我想谈谈 与阻塞主线程相关的另一个案例 这是我们刚才查看的跟踪内容 右侧是我们刚刚调查过的 主线程阻塞的挂起 但是在它的左边 还有一些其他的情况 主线程被阻塞了好几秒钟 但是 Instruments 并没有 将其标记为潜在的挂起 这里 主线程只是休眠了 因为没有用户输入 从操作系统的角度来看 主线程的确被阻塞了 但它只是在无事可做时不运行 主要是为了节省资源 一旦有输入 它就会被唤醒 并进行处理 因此 要确定阻塞线程 是否是响应速度问题 请查看 Hangs 工具 而不是线程状态工具
因此 主线程阻塞 并不意味着主线程无响应 同样 CPU 使用率高也不意味着 主线程没有响应 但如果主线程没有响应 意味着它要么被阻塞 要么主线程繁忙 我们的挂起检测 会考虑到所有这些细节 并且只会标记主线程 实际未响应的时间间隔 并将其显示为潜在挂起 如果你在本次讲座中 只能记住一件事 那一定是: 无论你在主线程上做什么工作 都应在 100 毫秒内完成 以便随时释放主线程 重新进行事件处理 时间越短越好 若想详细分析挂起 Instruments 会是你的最佳搭档 请记住繁忙主线程 和阻塞主线程之间的区别 并记住挂起也可能是 由主线程上的异步工作引起的 要解决挂起的问题 你需要 减少工作或将工作转移到后台 有时 甚至两者都要做到 而减少工作往往意味着使用 合适的 API 来完成工作 一般来说 在优化之前 应该先测量 并检查是否真的出现了挂起 当然有一些最佳实践 但并发和异步代码也更难调试 你经常会惊讶于 为什么有的进程那么快 为什么有的进程又那么慢 祝你在发现、分析和解决 所有挂起时玩得开心 感谢你的观看 ♪ ♪
-
-
19:38 - BackgroundThumbnailView
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground var body: some View { Image(uiImage: background.thumbnail) } }
-
19:58 - BackgroundSelectionView with Grid
var body: some View { ScrollView { Grid { ForEach(backgroundsGrid) { row in GridRow { ForEach(row.items) { background in BackgroundThumbnailView(background: background) .onTapGesture { selectedBackground = background } } } } } } }
-
20:03 - BackgroundSelectionView with Grid (simplified)
var body: some View { ScrollView { Grid { ForEach(backgroundsGrid) { row in GridRow { ForEach(row.items) { background in BackgroundThumbnailView(background: background) } } } } } }
-
20:26 - LazyVGrid variant
var body: some View { ScrollView { LazyVGrid(columns: [.init(.adaptive(minimum: BackgroundThumbnailView.thumbnailSize.width))]) { ForEach(BackyardBackground.allBackgrounds) { background in BackgroundThumbnailView(background: background) } } } }
-
24:05 - BackgroundThumbnailView
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground var body: some View { Image(uiImage: background.thumbnail) } }
-
24:59 - BackgroundThumbnailView with progress (but without loading)
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) } } }
-
25:26 - BackgroundThumbnailView with async loading on main thread
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = background.thumbnail } } } }
-
29:59 - BackgroundThumbnailView with async loading on main thread
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = background.thumbnail } } } }
-
31:41 - BackgroundThumbnailView with async loading on main thread (simplified)
struct BackgroundThumbnailView: View { // [...] var body: some View { // [...] ProgressView() .task { image = background.thumbnail } // [...] } }
-
33:40 - BackgroundThumbnailView with async loading on main thread
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = background.thumbnail } } } }
-
33:59 - synchronous thumbnail property
public var thumbnail: UIImage { get { // compute and cache thumbnail } }
-
34:03 - asynchronous thumbnail property
public var thumbnail: UIImage { get async { // compute and cache thumbnail } }
-
34:08 - BackgroundThumbnailView with async loading in background
struct BackgroundThumbnailView: View { static let thumbnailSize = CGSize(width:128, height:128) var background: BackyardBackground @State private var image: UIImage? var body: some View { if let image { Image(uiImage: image) } else { ProgressView() .frame(width: Self.thumbnailSize.width, height: Self.thumbnailSize.height, alignment: .center) .task { image = await background.thumbnail } } } }
-
38:52 - shared property causes blocked main thread
var body: some View { mainContent .task(id: imageMode) { defer { loading = false } do { var image = await background.thumbnail if imageMode == .colorized { let colorizer = ColorizingService.shared image = try await colorizer.colorize(image) } self.image = image } catch { self.error = error } } }
-
39:00 - shared property causes blocked main thread (simplified)
struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] let colorizer = ColorizingService.shared result = try await colorizer.colorize(image) } } }
-
39:10 - shared property causes blocked main thread + ColorizingService (simplified)
class ColorizingService { static let shared = ColorizingService() // [...] } struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] let colorizer = ColorizingService.shared result = try await colorizer.colorize(image) } } }
-
39:25 - shared synchronous property after await keyword still causes blocked main thread
class ColorizingService { static let shared = ColorizingService() // [...] } struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] result = try await ColorizingService.shared.colorize(image) } } }
-
class ColorizingService { static let shared = ColorizingService() func colorize(_ grayscaleImage: CGImage) async throws -> CGImage // [...] } struct ImageTile: View { // [...] // implicit @MainActor var body: some View { mainContent .task() { // inherits @MainActor isolation // [...] result = try await ColorizingService.shared.colorize(image) } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。