Appearance
跨层故障链:从用户现象回到机器证据
先读系统栈总图。本页不假设问题一定在浏览器、Flutter 或 GPU。它的工作是把用户的一句话,拆回一串可以分别证伪的节点。
text
现象
-> 输入是否到达?
-> 应用线程是否被调度?
-> 状态是否转换?
-> 引擎是否生成了新的渲染工作?
-> 驱动是否接受并提交?
-> GPU 是否完成?
-> 新 buffer 是否被合成和扫描?
-> 用户看到的是否是共享事实?不要把“没反应”当成一个问题
| 用户描述 | 可能断点 | 最先要的证据 | 容易犯的错 |
|---|---|---|---|
| 点了没反应 | 输入路由、主线程、状态机、焦点 | 输入事件、应用日志、主线程 trace | 先改 CSS 或重写组件 |
| 点了很久才变 | 调度、长任务、锁竞争、缺页 | 输入到 handler 的等待时间、CPU profile | 直接归咎 GPU |
| 状态已变,画面未变 | 脏标记、布局/绘制、frame 提交 | 状态日志、DOM/RenderObject、frame timeline | 认为网络慢 |
| 动画卡,最终状态对 | 每帧 CPU、栅格、GPU、vsync | 每帧时间线、GPU/合成轨道 | 只看平均 FPS |
| 滚动白块 | 图块、图片解码、显存、合成器 | tile/raster 轨道、内存、资源解码 | 以为数据没加载 |
| 画面显示已保存,刷新后没有 | 本地投影与共享事实 | requestId、服务端读回、审计记录 | 用截图证明保存成功 |
第一条原则:沿时间线找缺口
一条交互应至少留下这些时间点。没有某个时间点,不要猜下游。
text
t0 用户输入被设备采样
t1 内核/窗口系统把事件交给目标进程
t2 应用 handler 开始运行
t3 本地状态完成转换
t4 引擎提交新一帧
t5 GPU/合成完成该帧
t6 显示器呈现该帧
t7 服务端确认或拒绝该命令
t8 应用把确认后的状态呈现出来t0 -> t2 大,先看输入、调度和主线程;t2 -> t4 大,先看应用与引擎;t4 -> t6 大,先看驱动、GPU、合成与显示;t6 -> t8 大,先看网络、共享事实和结果合并。不要跨过中间点直接给结论。
四条可操作的故障链
1. 点击延迟
text
鼠标/触摸报告
-> 内核输入队列
-> 窗口命中测试
-> 浏览器/应用主线程
-> handler检查顺序:
- 系统层是否已经出现输入事件;外接设备、蓝牙、触控驱动和窗口焦点都会在这里出问题。
- 目标进程是否在运行,还是被 CPU 饱和、锁、I/O 或系统调度延后。
- handler 是否立即返回;长同步 JSON、布局读取、图像处理和 GC/内存压力都会延后下一帧。
- 若 handler 已运行但 UI 没变,才进入状态和渲染树检查。
在浏览器中,DevTools Performance 的 input delay 与 Long Task 能覆盖第 2 到第 3 步;在 Flutter 中,用 DevTools Timeline 区分 UI thread、raster thread 与 Dart 代码。若需要证明 OS 层排队,应使用平台工具,例如 macOS Instruments、Linux perf/ftrace 或 Windows WPA,而不是只看应用控制台。
2. 一帧没有赶上刷新
text
状态改变
-> layout / paint / display list
-> command buffer
-> driver queue
-> GPU execution
-> compositor acquire buffer
-> display scanout这里的关键不是“60 FPS”,而是某次工作是否在下一次可用呈现窗口前完成。CPU 迟到时,GPU 可能空闲;GPU 迟到时,主线程可能空闲。两种情况的修复方向相反。
| 证据 | 更像哪类瓶颈 | 下一步 |
|---|---|---|
| 主线程长脚本、layout 或 paint 跨帧 | 应用/引擎 CPU 侧 | 缩小更新和布局范围,拆分长工作 |
| 栅格线程忙、进入视口的 tile 未就绪 | 绘制/解码/显存压力 | 降低单 tile 复杂度,控制图片与滤镜 |
| 提交已完成但 GPU work 跨帧 | GPU 或驱动队列 | 检查 overdraw、纹理大小、shader、同步等待 |
| GPU 完成但显示仍旧帧 | surface/compositor/vsync | 检查 buffer ownership、合成器和显示路径 |
3. 视觉与交互不一致
画面上有一个按钮,不代表输入系统或辅助技术也认为它存在。
text
渲染树 / layer tree
!=
命中测试区域
!=
焦点顺序
!=
无障碍语义树覆盖层、裁剪、变换、透明元素、异步销毁与焦点恢复都可能让四份结构不同步。验证时至少做一次真实点击、Tab 键导航和屏幕阅读器/Accessibility tree 读取。截图只能证明像素。
4. 本地“成功”与服务端事实分离
text
本地状态: submitting(requestId)
-> 乐观画面: 已保存? 不能直接下结论
-> 网络与服务端写入
-> confirmed(version) 或 failed(reason)
-> 最终画面渲染链越快,越容易掩盖这个问题。所有可重试写入都需要稳定命令关联;超时不等于失败,也不等于成功。最终验收必须有服务端读回、版本号、审计记录或等价证据。
证据分层
| 层 | 典型工具 | 能证明什么 | 不能证明什么 |
|---|---|---|---|
| 应用 | 状态日志、单元测试、请求关联 | 状态机和业务分支 | GPU 或显示是否呈现 |
| 引擎 | DevTools、Flutter DevTools、渲染树检查 | layout、paint、frame 调度 | 内核是否真的调度了设备 |
| OS | Instruments、perf、ETW/WPA、ftrace | 线程、CPU、I/O、上下文切换 | 应用业务语义 |
| 驱动/GPU | GPU trace、厂商 profiler、frame debugger | 队列、shader、纹理、GPU 时间 | 服务端确认是否正确 |
| 显示与用户 | 高刷录屏、present trace、真实交互 | 是否真正翻帧、是否可操作 | 为什么上游生成了错误画面 |
工具名随平台变,但证据职责不变。不要把某一个 profiler 的一条轨道当成全机真相。
一个完整排障记录应有的内容
text
现象:120Hz 设备滚动列表时偶发白块
条件:设备、OS、GPU、应用版本、刷新率、缓存状态、复现动作
假设 A:主线程阻塞滚动
证据:主线程 2ms,compositor 可滚动;否定 A
假设 B:新图块栅格/图片解码未赶上
证据:raster queue 堆积,白块出现时缺少 ready tile;支持 B
改动:降低首屏图片解码压力并预留尺寸
复测:同条件下白块消失;仍需覆盖低显存设备这比“优化了滚动性能”有用,因为它说明了问题在哪一段、为什么修复可能有效、在哪些条件下还不确定。
与其他页面的关系
- 硬件与显示链说明 GPU 和 scanout 到底承担了什么。
- 内核与驱动说明中断、调度、DMA、队列和 fence 如何连接应用与设备。
- 平台图形栈、引擎与应用说明状态变更怎样成为 surface 和图形命令。
- 故障定位与练习把同一方法拉回浏览器与 Flutter 的具体工具。