当演示悄无声息地崩溃时
崩溃至少能告诉你出了问题。悄无声息的失败,发布给用户和合作伙伴的是一个光鲜却空洞的躯壳。能赢得信任的引擎,是那种拒绝低声细语的引擎。
有一类 bug 比崩溃更糟。崩溃至少会告诉你有什么地方不对。这个周六早上我坐下来面对的,是另外一种。演示能启动。场景能加载。UI 能渲染。暂停菜单没有响应。游戏内容是占位的。没有错误。没有异常。日志里连一行高于 INFO 级别的都没有。引擎已经悄无声息地失败了,还在礼貌地走着一个能工作的游戏该有的流程。
这篇文章讲的是如何让你的引擎拒绝这样做。
在用户看来这个演示是什么样子
干净地启动。启动画面。菜单画面。点击”开始游戏”。场景切换。一个看起来干净的场景。中间一艘飞船。右上角一个分数显示。
飞船不会动。输入是失灵的。呼出暂停菜单时,它在视觉上是存在的,但对点击没有任何反应。分数显示为零,并且一直是零。没有敌人可以射击。这个世界是一个礼貌的、空洞的、看起来能工作的躯壳。
对一个开发者来说,读到这里,三十秒内结论就很明显了:这是一个占位。真正的游戏内容预制体缺失了。引擎回退到了一个降级模式,却忘了告诉任何人。
对一个用户来说,这是最糟糕的一种失败。这个应用看起来并没有坏。它看起来是完成的,而且很糟糕。
真正出问题的地方
两个截然不同的问题,各自都很微妙。
场景加载器在悄无声息地降级。 SceneContentLoader 负责寻找游戏内容预制体并将它们实例化到活动场景中。当预制体缺失时(可能因为构建问题、缺失的资产包,或者配置不匹配),它会回退到一个占位配置。这个兜底逻辑本应记录一条清楚的错误。它却是以 INFO 级别记录的。这条错误信息被埋在一个未经过滤的日志流里,和另外三百行 INFO 一起。任何运行这个演示、粗略浏览日志的人都不会看到任何令人警觉的东西。
暂停菜单的画布缺少一个 GraphicRaycaster。 这是 Unity 那边的事。一个没有 GraphicRaycaster 组件的画布无法接收指针事件。暂停菜单被正确地实例化了,正确地渲染了,却对用户输入完全”耳聋”。这个组件的缺失,是通过一次把若干个画布配置合并为一个的重构悄悄渗入的。这次合并把其中一个画布上的 raycaster 给漏掉了。
两个 bug 有着相同的味道。有什么东西悄悄地出了错。代码路径继续执行。用户看到的是一个看起来能工作、实际上却不能工作的东西。
我是怎么找到它们的
审查花的时间比修复长。修复只花了一个周六下午。审查花了一个上午。我想把这个模式写下来,因为它还会再次出现。
第一个线索是分数卡在零上。我以为是上周末的那个 bug(分数翻倍计算)被过度修正了。错了。分数是零,是因为没有敌人。没有敌人,是因为游戏内容预制体没有加载进来。预制体没有加载进来,是因为场景内容加载器回退到了占位模式,而且是在 INFO 级别而不是 ERROR 级别报告的这件事。
一旦我知道了这一点,第二个 bug 就变得很明显了。暂停菜单没有响应是同一个演示里的另一个问题,在同一个周六的排查过程中浮出水面。这个审查模式是”如果你找到了一个悄无声息的失败,就去找找它周围有没有别的”。
我修复了什么
一组小而精确的改动。
把占位警告提升为 ERROR。 当场景内容加载器找不到真正的游戏内容预制体、回退到占位模式时,它现在会以 ERROR 级别记录一条消息”PLACEHOLDER MODE: gameplay prefabs missing, demo will not function correctly”。ERROR 严重级别使它能扛过任何合理的日志过滤。”PLACEHOLDER MODE”这几个确切的字,你在日志里一 grep 就能立刻认出来。
加入一个 EnsureCrossPlatformInputManager 步骤。 即使在占位模式下,输入系统也应该能工作。如果一个开发者正在占位模式下测试这个演示(因为他们在没有完整资产包的情况下开发 UI),他们需要能够与菜单交互。启动流程现在会确保任何场景里、包括占位场景里,都存在一个 CrossPlatformInputManager 单例。
为需要的画布自动加上 GraphicRaycaster。 防御性修复是让画布生成辅助函数检查 GraphicRaycaster 组件,如果缺失就把它加上。这不会掩盖未来同样形状的 bug,但它把这种特定的失败模式从可能性中排除了。
在 AutoBootstrap 中加入详细的启动日志。 演示启动时,日志现在会打印一段简短的摘要,说明加载了哪个场景、以哪种模式加载的(真实模式还是占位模式),以及有哪些子系统在场。一个读日志的开发者能在十秒内回答”这个演示是不是以我预期的模式启动的”这个问题。在今天早上之前,这个答案需要读三百行日志再去推断。
为画布辅助函数加一个单元测试。 因为这个 bug 是一个缺失的组件,正确的测试类型是断言在辅助函数运行之后该组件确实存在。这个测试现在已经在套件里了。如果有人以再次丢掉 raycaster 的方式重构了这个画布辅助函数,这个测试会大声地失败。
这件事能推广出什么
从今天上午能带走的三个模式。
悄无声息的失败是最糟糕的失败模式。 任何你的代码回退到降级模式的地方,这个兜底逻辑都必须放声尖叫。不是在 INFO 级别。是在 ERROR 级别。带有一个 grep 能找到的字符串。如果用户无法判断这个兜底逻辑是否被触发了,三天后读日志的开发者也无法判断。
一个由配置驱动的系统中缺失的组件,需要一个测试。 Unity、Unreal,任何一个场景是由若干组件配置而成的引擎:配置都可能悄无声息地漂移。防御手段是一小组断言”类型 X 的场景具有组件 A、B、C”的测试。枯燥的测试。关键的测试。值得写。
按邻近性审计。 当你发现一个悄无声息的失败时,去看看每一个相邻的系统。bug 是成堆出现的。同一次丢掉 raycaster 的重构,可能也在其他画布上丢掉了其他组件。同一次埋没了占位警告的日志纪律疏漏,可能也埋没了其他警告。要调查整个”街区”,而不只是最初发现的那一个点。
合作伙伴和开发者应该从中获得什么
如果你正在构建任何引擎具有多种降级模式(资产缺失、网络中断、供应商 SDK 不可用)的东西,引擎必须响亮地告诉你它现在处于哪种模式,每次都要说。悄无声息的降级是一个缺陷。
如果你正在评估一个引擎作为合作伙伴,去问那个团队他们是如何处理悄无声息的失败的。正确的答案是”我们以 ERROR 级别、用可被 grep 到的字符串把它们暴露出来,而且我们有审计流程去找它们”。错误的答案是”我们还没见过那个问题”。
如果你在运行一个智能体驱动的工作流,而你的智能体在做重构,这些重构偶尔会意外丢掉组件,或者意外把日志严重级别降级。修复办法是一个小小的 CI 关卡,验证关键组件的存在,以及关键日志消息在正确的严重级别下能存活下来。不算光鲜。但有效。
周六下午。演示在出问题时会开口说话。暂停菜单又能响应点击了。那个礼貌又空洞的演示派对结束了。
回去继续构建。
一个会告诉你它处于哪种模式的引擎
RakuAI 会以 ERROR 级别、用可被 grep 到的字符串响亮地暴露每一种降级模式,并且有审计流程优先找出悄无声息的失败。这就是一个空间运行时欠在其上构建的团队的那份可靠性纪律。