Appearance
没装子弹的枪
背景:模型详情面板在竞态条件下(快速切换、模型卸载中)触发
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、边界检查、错误处理——这些东西做了用户不会感谢你,因为他们根本不知道你防住了多少次崩溃。但没做的话,用户一定会记住那一次崩溃。坏印象比好印象深十倍。