← 返回全部文章

幂等性陷阱

发布于:
candyrushunitytoolingevidence

事情起于一个 bug 报告:三块面板上的按钮,内衬看起来不太对。它先是变成了一次对 「UI 几何值该怎么写」的系统性返工,然后又变成了一整天的排查——而那个 bug 正是这 次返工自己造出来的。两半都值得记下来:前一半是我会再做一次的模式,后一半是我希望 别人不要踩的坑。

问题:几何值成了口口相传的东西

Unity 的 uGUI 没有样式层。一个按钮的尺寸就躺在它的 RectTransform 里,是一串裸 数字,由某个人在某个 prefab 里、某一天敲进去的。乘上二十几个 prefab 和一年的迭 代,你就会得到一堆没有任何人刻意引入的漂移。

量一量我们自己的,挺让人清醒的。整个项目里按钮宽度聚集在 400、420、430、446、 450、385 和 360。高度是 186、180、175、160、150、145、120、110 和 100。有一块模态 面板是 1000×1400,和一个四实例的 980×1360 聚类差了大约 2%。这些差异都不是决策, 是长着一张合理面孔的手误和漂移。

带着十五年 Web 的经验,答案的形状是明显的:这正是 design token 要解决的事。CSS 自 定义属性、Tailwind 的间距刻度、Material 的字号阶梯——挑一组封闭的合法值,给它们 命名,然后让人没法敲出这组之外的数字。

三张封闭词汇表

手写几何值被换成了三个枚举:

  • ButtonSize —— Small (120)、Medium (160)、Large (186)
  • ButtonWidth —— Narrow (210)、Compact (300)、Wide (446)
  • ModalSize —— 面板表面,外加一个 FullBleed

每个都带一个 UseRectTransform 成员,意思是「这个元素自己管尺寸,别碰它」。这个值 被刻意设成不是默认值。一个从没被选过尺寸的元素会保留它原本被写成的样子,并被 审计工具标出来——在这里悄悄替一个尺寸进去,恰好会藏起这次改动本来要找的那批 东西。

迁移工具把这套词汇表铺到了大约三十个 prefab 上,之后由一个审计器持续把关。数字都 收在一个文件里,改一个值只要改一处,而不是三十处。

让这件事真正值得做的那个洞察

最初那个 bug——按钮在小尺寸下「看着不对」——根本不是尺寸问题,而是比例问题。 按钮里那块彩色底板,距离按钮边缘是一个固定像素的内衬。在 186 这个设计高度上,底板 占按钮的约 81%。高度 120 时只剩 70%,高度 100 时只剩 64%。按钮变小了,底板不成比例 地更小。

所以现在内衬是从高度推导出来的,而不是从实例上沿用:

public const float DesignHeight = 186f;
public const float PlateBottomRatio = 44.9f / DesignHeight;
public const float PlateTopRatio    =  8.9f / DesignHeight;

矮按钮现在和高按钮是同一个比例,而不是被拙劣地缩了一下。记住这两个比值,它们 一会儿还会回来。

陷阱

要落实这套词汇表,就得有东西真的把数字写到 transform 上。我们把它放进了 OnValidate,这样编辑器里看到的就是游戏里会看到的:

private void OnValidate()
{
    if (Application.isPlaying) return;
    EditorApplication.delayCall += () =>
    {
        Undo.RecordObject(transform, "Apply button size");
        ApplySize();
        EditorUtility.SetDirty(this);
        EditorUtility.SetDirty(transform);
    };
}

看起来人畜无害。它花掉了一天。

两个症状。主菜单场景存不住——Ctrl+S 确实写了文件,版本控制显示没有待提交改动,然后 「未保存」的星号立刻又回来了,重启编辑器也照旧。另外,每一个含按钮的 prefab,只要 一打开就会盖上两个新的属性 override:m_fontSize: 44 和 m_TextStyleHashCode。

四个错误答案

接下来这段才是有教益的部分。四个假设,全都是读源码推出来的,全错:

  1. 序列化的值过期了。 把源 prefab 的 m_fontSize 设成 44,让它和实例算出来的 一致。override 又回来了——而且值和源一模一样。
  2. 是某个文字自适应组件在写。 靠读代码找到一个看起来很像的嫌疑人,却从没检查 过它到底在不在。它一个受影响的 prefab 里都没有。
  3. 它只盖一次,之后就收敛了。 从没量过。纯属不实。
  4. 加个幂等性守卫就好了。 思路是对的。没起作用,原因值得单开一节。

每一个都花掉一轮「改—跑—看」。没有一个动手量过任何东西。

终于开始测量

结束这一切的,是三十行编辑器代码,订阅了 Unity 的三个事件: Undo.postprocessModifications(同步触发——给出确切的 property path、前后值,以及 一条点名调用者的堆栈)、ObjectChangeEvents.changesPublished(抓那些绕开 undo 系统 的写入),以及 EditorSceneManager.sceneDirtied(说出是谁把场景弄脏的)。

一次捕获。一条堆栈:

UnityEditor.EditorUtility:SetDirty (UnityEngine.Object)
CandyRush.Runtime.UI.Buttons.ButtonBehavior:<OnValidate>b__54_0 ()
UnityEditor.EditorApplication:Internal_CallDelayFunctions ()

我们自己的代码,被点了名,就在把场景弄脏的那一行上。四个假设什么也没产出;一次 测量给出了答案。

到底发生了什么

三个事实叠在一起构成了这个 bug,而且每一个都值得单独知道。

1. RectTransform 的 setter 是赋值,不是比较

往 rect.sizeDelta 里写一个完全相同的值,仍然会把 layout 标脏。Unity 不检查值 有没有变。这会触发一次 layout 重建,重建又会在按钮标题上重跑 TextMeshPro 的自动字号 求解器,求解器再去给 m_fontSize 赋值。

2. Unity 记录的是「被赋过值」,不是「和源不同」

这是最关键的一条,它一口气解释了所有症状。Unity 的 prefab override 系统是一本 「哪些属性在这个实例上被赋过值」的账本,而不是一份对源的实时 diff。哪怕赋的值 和 prefab 完全一样,也会生成一条记录。

你可以亲眼看到:在一个 prefab 实例上改一个属性,再把它改回原值。Inspector 里那条 蓝色 override 竖条还在。记录仍然存在,只是它现在存的正好是源的值。只有显式地右键 → Revert 才会删掉它。

这一条也回过头解释了:为什么第一个假设根本不可能奏效。它攻击的是差异,而差异 从来就不是触发条件。

3. 由 layout 驱动的值被烤进了文件里

顺带我们还发现,一个 ContentSizeFitter 挂在了某个 LayoutGroup 的子物体上——这 个组合在 Unity 的 issue tracker 上有四条独立记录,症状是场景一打开就变脏、而且永远 干净不了。Unity 自己的 Inspector 就会警告:"A child of a layout group should not have a Content Size Fitter component." 我们那个两个轴都是 Unconstrained,也就是说 它什么都没做,只是在 driven-property tracker 上注了个册,顺便把场景弄脏。

删掉一个毫无作用的组件,就清掉了一大片噪音。

那个撑不过 YAML 的浮点数

幂等性守卫——先算出目标值,若已经一致就提前返回——才是正确的修法。它没起作用,而 原因是我今年最喜欢的一个 bug。

那个守卫用精确浮点相等去比底板内衬。回想一下 PlateBottomRatio = 44.9f / 186f。把它乘上按钮高度再序列化。Unity 的 YAML 保留七位 有效数字。读回来,再和一个当场算出的值比一比:

高度计算值序列化后精确相等?
120 (Small)28.96774292028.967740000否
160 (Medium)38.62365722738.623660000否
186 (Large)44.90000152644.900000000是

对每一个 Small 和 Medium 按钮,守卫都会永远报告「变了」。它在每次反序列化时触发、 写 rect、弄脏场景、重跑自动字号——正是它被写出来要阻止的那套行为。旁边那个 sizeDelta 检查没事,因为 Unity 给 Vector2 的 operator== 重载带了 epsilon。只有 那两处裸浮点比较是错的。

修法是 Mathf.Approximately。而教训更宽:永远不要用 == 去比较一个计算出的浮点 数和一个序列化过的浮点数。 序列化往返是有损的,你的相等判断不是。

最初的决定对吗?

花一天去调一个由你自己的架构造出来的问题,这本身就是「架构错了」的有力论据。我不 认为它错了,而且值得把理由说清楚。

策略——封闭词汇表、单一事实源、审计器——正是整个行业最后收敛到的做法。Unity 自家的 UI Toolkit 就是拿 USS 样式表在做这件事,而 uGUI 已经进入维护模式。大团队用 prefab variant、组件 preset,或者一套靠命名约定的 prefab 矩阵来解决;微软的 MRTK 直接 以 PressableButton_SIZE_STYLE 的排列组合发按钮。没有任何一种世界线里,「让所有人 随便敲数字」是更好的选择。

落实机制才是我们挑了最难那根杠杆的地方。从 OnValidate 里写 RectTransform,一 次性碰上三处有文档记载的利刃:SetDirty 会把整个场景标脏;根 RectTransform 的属性 在 prefab 系统里有特殊处理;而 Unity 手册明确要求,在实例上调用 Undo.RecordObject 之后必须再调 PrefabUtility.RecordPrefabInstancePropertyModifications——这个调用我们 的代码没有写。

但 uGUI 也没给你别的地方可放。想在 uGUI 里用 token,就必须自己造落实机制;而一个 带编辑期 OnValidate 的运行时组件,用不了 Unity 推荐的 SerializedObject 路径,因为 同一个方法也会从 Awake 跑。在这份菜单里,这个选择是站得住的。缺陷大概是十行代 码,不是设计。

三件值得抄走的事

任何会写资产的工具,在什么都没变时必须是 no-op。 这不是优化,是正确性属性。一 个会把相同值重写一遍的工具,会制造幻影 diff、脏文件,以及在所有盯着这些对象的其他 系统里的连锁副作用。

两个假设失败,就停止猜测、开始造仪器。 读代码然后推断很诱人,因为它感觉像在 前进,而且前期不花钱。在这里它花了一天。那个探针花了三十分钟,产出一条带名字的 堆栈。这条现在是一条常设规则,探针也还留在项目里。

搞清楚编辑器上那些指示到底是什么意思。 大量困惑来自把三个互不相干的信号混为 一谈:星号(内存里有改动没落盘)、蓝色 override 竖条(实例的修改列表里有一条记录)、 以及值是否相等(那条记录的值是否恰好和源一样)。保存永远不会消掉蓝条。它们是不同 的系统,把它们当成一个,会让你一直在错的地方找。

词汇表活下来了。审计器、单一来源的数字、以及一个能用一次捕获而不是四个猜测找到下 一个同型 bug 的诊断工具,也都活下来了。用一天换这些,不算亏。