Appearance
渲染故障定位与练习
本页把浏览器渲染管线、浏览器运行时与帧调度、浏览器内核渲染细节和 Flutter 对照放回同一个排障框架。目标不是背性能名词,而是在“界面不对、卡、慢、假成功”时知道先证明什么。
先区分四种正确性
一次交互不是只有“成功”与“失败”。至少要分别验证:
| 正确性 | 问题 | 常见反例 | 证据 |
|---|---|---|---|
| 状态正确 | 应用内存中的状态是否按事件转换 | 收到旧响应后覆盖了新搜索结果 | 状态转移日志、请求 ID、单元测试 |
| 呈现正确 | 当前状态是否被正确投影为画面 | 数据已变,列表仍显示旧项 | DOM/控件树快照、截图、语义树 |
| 交互正确 | 点击、焦点、无障碍和手势是否指向同一对象 | 弹窗视觉消失但焦点仍困在其中 | 键盘回归、命中测试、辅助技术读回 |
| 事实正确 | 用户看到确认后,共享事实是否真的成立 | 乐观更新显示已保存,刷新后记录消失 | 服务端读回、版本号、审计或追踪 |
不要用后一个问题的成功替前一个问题背书。接口返回 200 不等于画面正确;动画流畅不等于操作已确认;截图正确不等于键盘和屏幕阅读器可用。
一条排障主线
text
用户报告现象
-> 能否稳定复现?记录输入、设备、网络、时间和版本
-> 状态是否正确?检查事件顺序、请求关联、状态机
-> UI 描述是否正确?检查派生数据、条件、树结构、key/identity
-> 更新是否正确落到宿主?检查 DOM/控件/RenderObject/Layer 或字符缓冲
-> 宿主是否按预算呈现?检查布局、绘制、栅格化、合成、主线程
-> 用户是否真的完成任务?检查焦点、命中、无障碍、服务端确认每次只向下一层推进一次。尚未证明状态正确时,先不要把问题归为“浏览器性能”;尚未检查呈现树时,也不要直接上 GPU 诊断。
现象到机制的对照
| 用户看到的现象 | 高概率边界 | 先收集什么 | 典型根因 |
|---|---|---|---|
| 点击后数值短暂变了又回去 | 状态与异步结果合并 | 请求 ID、开始/结束顺序、状态变更记录 | 旧响应覆盖新状态;乐观更新回滚规则缺失 |
| 同一列表项内容串位 | UI 描述与身份 | 列表 key、节点复用记录 | 使用位置当身份;Element/DOM 复用到错误对象 |
| 页面整体抖动或文字换行反复变化 | 布局 | 布局无效化、尺寸来源、字体加载时序 | 读写布局交替;未保留图片尺寸;字体替换 |
| 动画卡顿但点击最终成功 | 帧预算与主线程 | 长任务、每帧工作量、合成轨迹 | 大量 JavaScript/Dart、强制布局、昂贵绘制 |
| 滚动时白块或图片晚到 | 栅格化与资源 | 图层、瓦片、图片解码、网络优先级 | 栅格线程来不及;图片过大;主线程阻塞提交 |
| 看得到按钮却点不中 | 呈现与命中测试 | 覆盖层、变换、pointer events、语义树 | 透明层遮挡;坐标系/transform 不一致 |
| 屏幕阅读器读到隐藏内容或读不到新内容 | 语义树与生命周期 | accessibility tree、焦点、live region | 视觉树与语义树不同步;焦点恢复缺失 |
| 刷新或换设备后刚才“成功”的内容消失 | 本地画面与共享事实 | 服务端读回、命令 ID、重试/幂等记录 | 将本地缓存或乐观状态误标为已确认 |
性能不是一个指标
“卡”至少可能意味着以下不同成本。测量前先写明用户动作和可接受阈值,而不是笼统地追求更高帧率。
| 成本 | 浏览器中的常见证据 | Flutter 中的常见证据 | 常见修复方向 |
|---|---|---|---|
| 输入等待 | Input delay、长任务、事件处理时长 | UI thread 被事件/构建占满 | 缩短同步处理,分片或移出关键路径 |
| 更新计算 | JavaScript、框架协调、Style | build、Element 更新、状态通知范围 | 缩小更新范围,稳定 identity,避免重复派生 |
| 布局 | Layout、forced reflow | layout phase、约束传播 | 让尺寸来源稳定,避免布局读写来回切换 |
| 绘制 | Paint、raster、重绘矩形 | paint、raster thread | 减小绘制区域,避免昂贵效果与无效重绘 |
| 合成 | Compositor、layer updates、frame presentation | layer tree、engine scene submission | 只在合适场景分层,减少不必要图层与纹理上传 |
| 资源就绪 | 网络、解码、字体、图片 | 图片解码、着色器编译、资源加载 | 预留尺寸、按需加载、预热有成本的资源 |
帧率只是最后的症状。交互触发一次更新时,应首先回答:哪条线程在做什么,哪一步超过了可用时间,丢的是输入响应、动画连续性还是最终呈现?
浏览器与 Flutter 的共同排障点
两种宿主内部名词不同,但以下判断可以直接迁移:
- 先找身份:浏览器的节点/key、Flutter 的 Key/Element,都决定已有状态和渲染对象是否应被复用。
- 再找约束:浏览器布局依赖格式化上下文与尺寸约束;Flutter 布局依赖父传下来的 Constraints。尺寸为什么变化,是两边共同的第一问题。
- 再找无效化范围:一个状态变化是否让整页/整棵子树重新计算,还是只影响一个局部。
- 最后找提交与呈现:更新已经算完,是否来得及交给宿主、栅格化并在下一帧呈现。
不要把概念硬对等。例如浏览器的 DOM、Blink Layout Object 与 Flutter 的 Widget、Element、RenderObject 并不是一一同构;可迁移的是“描述、身份、布局、绘制、合成、命中和反馈”的职责分离。
最小验证工具箱
不同项目工具不同,但证据类型应完整:
| 证据 | 证明什么 | 不能证明什么 |
|---|---|---|
| 状态日志与请求关联 | 事件是否按预期进入状态机 | 用户是否真的看见正确画面 |
| 截图与录屏 | 可见画面、闪烁、布局和动画 | 焦点、无障碍与共享事实 |
| DOM/控件/语义树快照 | 当前可交互和可访问结构 | 实际帧预算或网络确认 |
| 性能轨迹 | 主线程、布局、绘制、栅格和合成成本 | 业务状态转移是否正确 |
| 网络与服务端读回 | 请求、响应和共享事实 | 终端是否正确展示或解释结果 |
| 端到端操作回放 | 一条真实用户链路是否闭环 | 所有边界情况都覆盖了 |
练习:从一条保存链路开始
选择一个“编辑标题后保存”的最小功能,按以下顺序完成。无论 Web、Flutter 还是 TUI,都不要跳过前一步。
练习 A:写出状态机
text
clean
-> editing
-> submitting(requestId, draft)
-> confirmed(version)
-> failed(requestId, reason, draft)验收:描述双击保存、超时但服务已写入、旧响应后到三种情况分别会怎样。
练习 B:写出 UI 投影表
| 状态 | 输入框 | 保存按钮 | 提示 | 是否允许离开 |
|---|---|---|---|---|
| editing | 可编辑 | 可点击 | 无 | 是,保留草稿 |
| submitting | 是否锁定由产品决定,但需一致 | 禁用或转为取消 | 正在保存,未确认 | 需明确未保存风险 |
| confirmed | 显示服务端返回值 | 可再次编辑 | 已保存,带版本或时间 | 是 |
| failed | 保留草稿 | 可重试 | 失败原因与下一步 | 是 |
验收:任何截图中的控件状态都能反推到唯一状态,不出现“看着已保存、实际仍提交中”。
练习 C:测量一次卡顿
- 人为让保存后更新一个很长的列表。
- 录制浏览器性能轨迹或 Flutter DevTools timeline。
- 标出事件处理、更新计算、布局、绘制、栅格化和呈现分别占了什么时间。
- 只做一个最小改动,例如缩小状态订阅范围、虚拟化列表或稳定列表 identity。
- 再测一次,说明减少的是哪一环,而不只报告“更快了”。
练习 D:验证共享事实
- 保存时断网或让响应超时。
- 重试同一个命令,而不是再创建一条。
- 刷新页面或换一台终端读回。
- 记录 UI 如何从“不确定”收敛到“已确认”或“失败”。
验收:状态、画面和服务端读回三份证据相互一致。
继续阅读
- 回到专题总览确认本页在整体骨架中的位置。
- 阅读浏览器渲染管线理解浏览器如何形成首帧。
- 阅读浏览器运行时与帧调度理解为什么更新正确仍可能不流畅。
- 阅读Flutter 对照把同一问题迁移到自绘引擎。