按钮已经移动,点击却留在原地:UI 布局与命中区域必须同修订提交
游戏菜单切换语言后,“继续”按钮因为文本变长而向右扩宽,画面看起来已经更新;玩家点击新露出的右侧区域,事件却落到背景面板。按钮不是没有响应,旧矩形内仍然可以点击。这个现象说明渲染布局与输入命中读取了两份不同时间的结果。

固定案例中,旧视觉矩形是 (120, 40, 120, 48),命中区域在四周各扩 8 像素。异步文本测量把视觉宽度扩为 180,新命中区域应同时变成 (112, 32, 196, 64)。点 (305, 60) 位于新命中区域内,却在旧区域外,正好可以观察两种状态是否同步。
视觉变化不能证明交互布局已经成立
许多 UI 管线把布局理解为一组可逐项写入的属性:测量完成后先改 RectTransform,随后重建射线检测数据,再通知导航与无障碍系统。单线程顺序执行并不能消除中间状态。渲染循环、输入派发或另一个完成回调只要能在两次写入之间观察,就会得到“新画面配旧点击区”。
这里真正需要原子化的不是某个 double 或引擎属性,而是一次交互布局的可观察单位。探针把视觉矩形和命中矩形装入同一个候选,并让 LayoutOwner 独占活动快照的发布权:
readonly record struct Rect(double X, double Y, double Width, double Height)
{
public double Right => X + Width;
public double Bottom => Y + Height;
public bool IsValid =>
double.IsFinite(X) && double.IsFinite(Y) &&
double.IsFinite(Width) && double.IsFinite(Height) &&
Width > 0 && Height > 0;
public bool Contains(Rect other) =>
X <= other.X && Y <= other.Y &&
Right >= other.Right && Bottom >= other.Bottom;
public bool Contains(double x, double y) =>
x >= X && x <= Right && y >= Y && y <= Bottom;
}
sealed record LayoutSnapshot(long Revision, Rect VisualRect, Rect HitRect);
sealed record LayoutCandidate(long Revision, Rect? VisualRect, Rect? HitRect);
enum CommitResult
{
Committed,
MissingVisualRect,
MissingHitRect,
InvalidRect,
HitRectDoesNotContainVisual,
StaleRevision
}
sealed class LayoutOwner
{
public LayoutSnapshot Current { get; private set; }
public LayoutOwner(LayoutSnapshot initial) => Current = initial;
public CommitResult TryCommit(LayoutCandidate candidate)
{
if (candidate.Revision <= Current.Revision)
return CommitResult.StaleRevision;
if (candidate.VisualRect is not { } visual)
return CommitResult.MissingVisualRect;
if (candidate.HitRect is not { } hit)
return CommitResult.MissingHitRect;
if (!visual.IsValid || !hit.IsValid)
return CommitResult.InvalidRect;
if (!hit.Contains(visual))
return CommitResult.HitRectDoesNotContainVisual;
Current = new LayoutSnapshot(candidate.Revision, visual, hit);
return CommitResult.Committed;
}
public bool HitTest(double x, double y) => Current.HitRect.Contains(x, y);
}
HitRect 包含 VisualRect 是这个案例的产品合同:可见按钮不能出现不可点击的内部区域。它不是所有 UI 的普遍规则。拖拽手柄可以允许命中区比图形大,穿透装饰则可能根本没有命中区;这些语义应由组件类型显式决定,而不是删掉候选完整性校验。
另一种做法是在输入到来时临时根据当前视觉矩形重算命中范围。它对单个轴对齐按钮看似省掉了一份状态,却会让输入系统悄悄复制布局规则;遇到内边距、父级缩放或裁剪后,两条计算路径仍可能分歧。更重要的是,事件回放无法只凭修订号还原当时使用的点击边界。保留已验证的命中矩形,使渲染、输入与诊断日志都引用同一个空间事实。
缺一块数据时,旧布局比半份新布局可靠
缺失命中矩形的修订 2 会返回 MissingHitRect,活动修订保持 1,点 (305, 60) 仍为 False。完整候选随后用同一个修订提交,活动快照才整体切到 2,该点变为 True。技术图把正常与拒绝路径放在同一张状态变化中。

陈旧完成同样不能凭“内容合法”取得发布权。若修订 3 已经提交,较早发起的修订 2 即使包含两种有效矩形,也只能得到 StaleRevision。修订号表达当前界面意图的顺序;取消旧测量可以节省工作,却无法替代提交时校验,因为完成通知可能已经进入队列。
探针还拒绝 NaN 宽度、非正尺寸,以及不能完整覆盖视觉矩形的命中区域。八个固定用例重新执行如下:
$ dotnet build ./UiLayoutSnapshotLab.csproj -c Release
Build succeeded.
0 Warning(s)
0 Error(s)
exit=0 wall=8.55s
$ dotnet run --project ./UiLayoutSnapshotLab.csproj -c Release --no-build
tests pass=8 fail=0 skip=0 duration_ms=34.841
missing_hit=MissingHitRect revision_after_failure=1 point_305_60_before=False
complete=Committed revision_after_success=2 point_305_60_after=True
exit=0 wall=1.95s
这些数字只证明本次最小合同测试执行成功,不表示 Unity、Godot 或浏览器 UI 的布局性能。探针没有建立引擎对象,也没有测量字体整形、Canvas 重建或射线检测成本,因此不能据此声称快照方案更快。
引擎接入时,读取单位也必须是一份快照
在 Unity 中,候选可以由文本测量和布局 Job 产生,最终在主线程把位置、尺寸及 GraphicRaycaster 使用的数据一同装入活动视图模型;Godot 可以让 Control 宿主持有版本化布局结果,再统一更新绘制与 _gui_input 所依据的矩形。具体 API 不同,关键是渲染与输入在一帧或一次事件派发期间取得同一个 LayoutSnapshot 引用,不能循环中反复读取可能切换的全局字段。
复杂界面会扩大提交单位。父级裁剪变化若影响按钮可见区域,候选还要带上裁剪链摘要;方向键导航依赖邻接关系时,焦点图也必须属于同一修订;屏幕阅读器的语义边界若随布局移动,同样不能晚一拍。演进信号不是控件数量达到某个常数,而是一次视觉切换开始要求多个观察者同时认可新空间关系。
这个切片不覆盖旋转矩形、多边形命中、多指针捕获、滚动容器裁剪和无障碍树重建。它解决的是开篇那种更基础的分裂:按钮移动以后,画面与点击不会各自选择一份布局。只有完整候选通过几何与修订校验,新的交互空间才对外成立;失败和迟到结果继续停在候选区,旧界面仍保持可见、可点且可以解释。