PAYATHON 2026

SwiftUI List 中 PhaseAnimator 引发 AttributeGraph 循环崩溃怎么办?

排障赵师傅

可以先稳定行标识,把动画提取到只接收普通值的子视图中,并在编辑期间移除动画器,保留 PhaseAnimator 的阶段逻辑。这些改动还需要验证,不能保证解决原生侧滑删除时的崩溃。SwiftUI 没有公开接口能将 PhaseAnimator 调度到独立于 List 布局的更新周期。

仅凭这段日志,还无法确认问题是 @Observable 与 PhaseAnimator 的框架缺陷,也无法认定某个系统版本已经修复。

纠正 trigger: 的使用方式

原代码使用:

.phaseAnimator([0.5, 1.0], trigger: viewModel.isOverdue)

带 trigger: 的重载会在触发值变化时运行阶段序列。isOverdue 持续为 true 不会让动画持续脉动;从 true 变为 false 也会触发一次序列。Apple 的触发式动画文档

如果需要在逾期时持续脉动,应通过条件控制动画视图是否存在,并使用不带 trigger: 的循环重载。这能纠正动画行为,但不能单独作为崩溃修复。Apple 的循环式动画文档

拆分子视图的作用

AttributeGraph: cycle detected 表示 SwiftUI 检测到了属性依赖环。日志中的属性编号不足以定位某个业务属性,崩溃发生在私有框架内部也不能直接证明依赖环来自列表布局。

将动画提取到只接收 Bool 等普通值的子视图中,可以缩小动画代码直接接触模型的范围,方便做对照排查。子视图仍属于同一棵 SwiftUI 视图树,拆分后并不会获得独立的依赖图。

示例只读取属性,没有创建 $viewModel.title 之类的绑定,因此不需要 @Bindable。通过普通存储属性持有 @Observable 对象时,SwiftUI 仍能跟踪 body 中读取的可观察属性。Apple 的 Observation 迁移说明

保留 PhaseAnimator 的改法

下面的示例假设 TaskItem 已经是项目中的 @Observable 类型。逾期且允许动画时,视图会创建循环动画器;进入编辑模式或开启“减弱动态效果”后,则显示静态图标。

import SwiftUI

private enum PulsePhase: Double, CaseIterable {
    case dim = 0.5
    case bright = 1.0
}

private struct OverdueIcon: View {
    let isAnimating: Bool

    var body: some View {
        Group {
            if isAnimating {
                Image(systemName: "clock.fill")
                    .phaseAnimator(PulsePhase.allCases) { content, phase in
                        content.opacity(phase.rawValue)
                    } animation: { _ in
                        .easeInOut(duration: 0.6)
                    }
            } else {
                Image(systemName: "clock.fill")
            }
        }
        .frame(width: 24, height: 24)
    }
}

struct TaskRow: View {
    let viewModel: TaskItem

    @Environment(\.editMode) private var editMode
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    private var isEditing: Bool {
        editMode?.wrappedValue.isEditing ?? false
    }

    var body: some View {
        HStack {
            Text(viewModel.title)

            Spacer()

            OverdueIcon(
                isAnimating: viewModel.isOverdue
                    && !isEditing
                    && !reduceMotion
            )
        }
    }
}

条件分支会直接移除动画器。退出编辑模式后,阶段序列会重新开始,不会从暂停位置继续。固定图标尺寸只是为了让两个分支占用相同的空间,无法隔离依赖图。

还要检查 ForEach 的身份是否稳定:

// 假设 TaskItem 遵循 Identifiable,且 id 在对象生命周期内保持不变。
ForEach(tasks) { task in
    TaskRow(viewModel: task)
}

对于可删除、可重排的数据,每个数据项都应有保持不变的标识。避免用数组下标表示身份,也不要在 body 中生成新的 UUID。

.id(UUID()) 会在视图重新计算时不断改变身份,导致 SwiftUI 重建视图及其动画状态。这能解释闪烁;即使因此不再崩溃,也不足以证明依赖环已经修复。

编辑模式不能可靠覆盖侧滑删除

editMode 可以控制编辑状态下的动画,但无法可靠地通知所有侧滑、拖动及布局操作何时开始、何时结束。上述代码需要分别验证编辑模式删除、侧滑删除和重排,不能只测其中一种。

在 .onDelete 中设置暂停标记后立即删除数据,并不能保证动画器已经提前退出。回调执行时,侧滑交互已经发生;同一次更新中的状态修改,也不能保证按预想分成两次布局。

以下做法同样不能保证等到 List 布局完成:

  • DispatchQueue.main.async
  • Task.yield()
  • 固定延迟若干毫秒

.transaction { $0.animation = nil } 可以帮助排查动画事务的影响,但不能作为停止 PhaseAnimator 或隔离依赖图的通用开关。

仍然崩溃时,保留阶段模型并替换驱动方式

如果拆分后仍能稳定复现,而项目中的 withAnimation 确实能避开问题,可以改用可取消任务,按顺序驱动各个阶段。这样可以保留明确的阶段定义,暂时停用 PhaseAnimator。

下面沿用前面的 PulsePhase:

private struct SequencedPulseIcon: View {
    @State private var phase: PulsePhase = .dim

    var body: some View {
        Image(systemName: "clock.fill")
            .opacity(phase.rawValue)
            .frame(width: 24, height: 24)
            .task {
                phase = .dim

                while !Task.isCancelled {
                    let next: PulsePhase =
                        phase == .dim ? .bright : .dim

                    withAnimation(.easeInOut(duration: 0.6)) {
                        phase = next
                    }

                    do {
                        try await Task.sleep(
                            for: .milliseconds(600)
                        )
                    } catch {
                        return
                    }
                }
            }
    }
}

将 OverdueIcon 动画分支中的内容替换为 SequencedPulseIcon() 即可。任务随视图生命周期取消,睡眠被取消后会直接退出,避免行被移除后继续推进阶段。这里的等待时间用于编排动画,与 List 何时完成布局无关。

这个方案依据的是项目中已观察到的 withAnimation 表现,仍需在目标设备上验证,尚不能确认它适用于所有系统版本。

如何缩小问题范围

先固定数据和行标识,再分别测试静态图标、独立子视图中的 PhaseAnimator,以及任务驱动的阶段动画。每组都要检查侧滑删除、编辑模式删除和重排。

如果行身份稳定、动画闭包只读、子视图只接收普通值时仍能复现,就整理最小工程,通过 Feedback Assistant 提交完整崩溃报告、复现步骤,以及实际设备系统和 Xcode 的构建号。现有信息不足以认定题述版本存在已知缺陷,也无法承诺升级到某个版本就能解决。

备注:内容仅供参考。