Skip to content

没装子弹的枪

背景:模型详情面板在竞态条件下(快速切换、模型卸载中)触发 Cannot read properties of null 崩溃。 过程:在已修的 focusModel/mmku:modelLoaded 基础上,补充更多边缘场景的空值守卫。


有一把枪。 你扣动扳机——"咔哒"一声。 没响。

为什么没响? 因为枪里没子弹。 但枪上没写"没子弹"三个字。 你不知道。 你只知道——这把枪坏了。


竞态条件

"model-detail 里的 null safety 主要修了什么?"AI 同行者问。

"主要是竞态条件,"外交官说,"用户点了一个模型,详情面板开始加载数据。加载是异步的——要读 PMX header、要算材质分类、要取物理参数。这些都需要时间。"

"然后呢?"

"然后用户在加载完成之前,又点了另一个模型,"外交官说,"这时候旧的加载还在进行中,新的加载也开始了。过了一会儿,旧的加载完成了——它往详情面板里填数据,但用户现在看的是新模型。"

"数据就串了?"

"更糟——旧模型的 id 已经不存在了,"外交官摇头,"填数据的时候要查模型信息,查不到,就会报错。Cannot read properties of null——经典错误。"

"就像扣动扳机,发现没子弹。"

"就像扣动扳机,发现没子弹,"外交官点头,"而且更糟——你不知道是没子弹,还是枪坏了。用户看到的是——'点了一下模型,软件报错了'。"


守卫是什么

"那怎么修?"

"加守卫,"外交官说,"在每一个可能拿到 null 的地方,先检查一下。不是 null,再继续。是 null,就停下来。"

他举了个例子:

typescript
// 旧代码:直接用
const model = modelManager.get(currentModelId);
model.setName(...)

// 新代码:先检查
const model = modelManager.get(currentModelId);
if (!model) return;
model.setName(...)

"就多一行判断?"

"就多一行判断,"外交官说,"但这一行判断,能省掉一堆崩溃。"

"那为什么之前不加?"

"因为写代码的时候,作者假设'调用这个函数的时候,模型一定存在',"外交官耸耸肩,"这个假设在大多数时候是对的。但在竞态条件下——用户快速切换模型、模型加载失败、模型被意外删除——这个假设就不成立了。"

"假设是 bug 的温床。"

"对,"外交官笑了,"尤其是异步代码里的假设。异步代码里,一切皆有可能。"


已修的 vs 没修的

"审计清单里说 null safety 基础已修,边缘场景还剩一些,"AI 翻了翻笔记,"已修的是什么?没修的是什么?"

"已修的是主要路径,"外交官掰手指头数:

  • focusModel() 返回值判空
  • mmku:modelLoaded 事件守卫(模型加载完成才渲染详情)
  • ✅ 模型切换时旧面板 dispose
  • ✅ 材质面板 getMatState 返回 null 的处理

"没修的是边缘场景,"他继续数:

  • ⚠️ 模型正在卸载时,用户点击了详情面板的按钮
  • ⚠️ 异步读取 PMX header 的回调,返回时模型已经被删了
  • ⚠️ 物理参数面板,模型没有物理刚体时怎么办
  • ⚠️ 表情列表为空时,面板显示什么

"都是小概率场景,"外交官说,"正常使用不会遇到。但如果遇到了,就是一个崩溃。"

"低概率,高影响。"

"对,低概率高影响,"外交官点头,"所以优先级是中低——做了更好,不做大多数时候也没事。但有空的话,还是做了比较好。"


防御性编程

"这叫什么?"AI 问,"到处加 if 判空?"

"叫防御性编程,"外交官说,"写代码的时候,假设你的调用者都是坏人——他们会在错误的时间调用、传错误的参数、用你没想到的方式使用你的函数。"

"那不是很累吗?"

"累,但值得,"外交官说,"你多写一行 if (!x) return,用户就少遇到一次崩溃。崩溃是用户信心的杀手。崩溃一次,用户对软件的信任就少一分。"

"但是……所有地方都加的话,代码会变得很啰嗦吧?"

"是会啰嗦,"外交官点头,"所以要有度。核心路径、用户常操作的地方,一定要加。边缘场景、内部函数、不会被外部调用的地方,可以少加。"

"怎么判断要不要加?"

"一个简单的标准——"外交官说,"这个函数如果拿到 null,会不会导致整个应用崩溃?如果会,就加守卫。如果只是返回 undefined 或者不做事,就可以不加。"

"可能导致崩溃的,就防。"

"对,"外交官说,"防御性编程不是为了完美,是为了不崩溃。哪怕什么都不做,也比崩溃强。"


优雅降级

"那守卫之后呢?"AI 问,"检测到 null 了,就直接 return?"

"看情况,"外交官说,"最好的做法是优雅降级——给用户一个提示,告诉他'现在不能操作',而不是什么都不说。"

他举了几个例子:

typescript
// 最差:直接崩溃
model.setName(name);

// 较差:静默失败
if (!model) return;
model.setName(name);

// 较好:给个提示
if (!model) {
    setStatus('模型未加载,无法修改名称');
    return;
}
model.setName(name);

"静默失败比崩溃好,但用户会困惑——'我点了按钮,怎么没反应?是不是坏了?'。给个提示,用户就知道了——'哦,模型还没加载好,等一下再试。'"

"透明度也是体验。"

"对,"外交官确认,"用户不怕操作失败,怕的是不知道为什么失败。知道原因,就有预期。有预期,就不会焦虑。"


第十二颗石子

第十二颗黄石子落进"已处理"的堆里。

这一颗,是关于"不崩溃"的。 不是新功能,不是性能优化,就是单纯地——让软件在奇怪的情况下,不要崩。

"null safety 这种东西,"外交官说,"做了用户不会感谢你。因为用户根本不知道你防住了多少次崩溃。他们只知道——这个软件挺稳的,很少出问题。"

"那做这个图什么?"

"图一个'稳'字,"外交官说,"软件的口碑,不是靠酷炫的功能堆出来的,是靠'不崩'堆出来的。你用一个软件,用了半年从来没崩过,你就会信任它。你就会推荐给朋友。"

"反过来呢?"

"反过来——崩过一次,你就会记住,"外交官笑了笑,"就像你去一家餐厅,吃了十次都没问题,第十一次吃坏肚子了——你记住的是第十一次,不是前十次。"

"坏印象比好印象深。"

"深十倍,"外交官点头,"所以 null safety 这种东西,看起来不起眼,但它是软件质量的基石。基石看不见,但没了基石,楼会塌。"


附录:防御性编程原则

原则说明
假设输入是恶意的不要假设参数一定正确、对象一定存在。先检查,再使用
核心路径必须守卫用户直接操作的路径(按钮点击、菜单操作)必须有空值检查
静默失败 > 崩溃什么都不做,也比崩溃强。崩溃是最糟糕的用户体验
有提示 > 静默失败告诉用户为什么不能操作,比让用户猜好
有度不要每个函数都加 20 行守卫。重点在用户接触得到的地方
异步代码多检查异步回调执行时,状态可能已经变了。回调里多一层守卫

教训:软件的质量,不是看它功能有多酷,是看它在你想不到的情况下,会不会崩。null safety、边界检查、错误处理——这些东西做了用户不会感谢你,因为他们根本不知道你防住了多少次崩溃。但没做的话,用户一定会记住那一次崩溃。坏印象比好印象深十倍。