本节摘要:Godot 用引用计数管理绝大多数对象——谁引用谁记账,计数归零立即回收;节点类是重要例外,挂树期间由树持有,摘树后必须显式释放。资源对象带缓存与共享语义,改一份等于改所有引用处。掌握这套规则,就能解释并预防"对象突然没了"、"内存越跑越多"两类高频故障。
一个经典迷案先摆出来:某段代码拿到一个节点的引用存进成员变量,跑了几帧后调用它的方法,游戏崩了,报错说对象已被释放。谁动的手?要破这个案子,得先看引擎的记账系统。
引擎里绝大多数对象由引用计数管生死:每个对象随身带一个计数器,被引用一次加一,引用失效减一,归零当场回收。这和带垃圾回收器的语言不同——不存在"先标记再统一清扫"的延迟,对象死得干脆。
# 用一个最简实验观察引用计数的行为 var arr: Array = [] func _ready() -> void: var res := Resource.new() # 计数为一,被局部变量持有 arr.append(res) # 计数为二,数组也持有 res = null # 局部变量放手,计数减一,仍为一 print(arr[0]) # 对象活着,正常打印 arr.clear() # 数组放手,计数归零,对象当场销毁 # 再次访问就是悬空引用,编辑器会报已释放错误
规则简明,坑在"谁在偷偷记账"。数组、字典、成员变量、信号绑定参数、闭包捕获,全都是持有人。一处忘了,对象就死不了——这是泄漏;一处多记,对象死太早——这是悬空。泄漏的方向是"该放手没放手",悬空的方向是"不该放手放了手",排查时先分清方向,效率翻倍。
节点不遵守纯引用计数,因为节点有"挂树"这层特殊身份。挂在树上的节点由父节点持有,删子节点要走父节点的方法;不挂树的"孤儿节点"则由创建者负责,用完必须手动释放,否则永远占着内存。
删除树上的节点,标准姿势是排队销毁:
func _on_enemy_died(enemy: Node) -> void: enemy.queue_free() # 本帧结束后安全销毁 # 对比:enemy.free() 立即销毁,本帧后续代码若再碰它即崩
为什么排队更安全?回到上一节的帧次序:此刻可能正处于节点遍历中段,当场抽走一个节点,遍历器就踩空了。排队销毁把真正的拆除推迟到本帧收尾、所有遍历结束之后。代价是你不能假设"调用后它立即消失"——同帧内它还活着,只是被打上了拆除标记。
孤儿节点的典型来源是代码动态创建后还没来得及挂树:
func spawn_effect() -> void: var fx :=preload("res://scene/effect.tscn").instantiate() # 示意:动态实例化 if fx == null: return add_child(fx) # 挂上树,交由树管 # 若在此处提前返回而没挂树,fx 就是孤儿,需要手动释放
⚠️ 常见坑:动态创建节点后因为某个条件提前退出函数,节点没挂树也没释放。玩家反复触发这段逻辑,内存占用一路爬坡。规矩是——实例化与挂树之间不留任何可能提前退出的分支;实在要留,每个出口配一次手动释放。
贴图、音效、预制场景、材质,在引擎里统一是资源对象。它遵守引用计数,但叠了两层特殊语义。
第一层是共享。同一张贴图被十个精灵引用,内存里只有一份;通过任一引用修改其属性,十个精灵同时受影响——改的是同一个对象。想单独改,先复制再改。
# 共享陷阱与复制解法 var mat := sprite.material # 拿到的是共享引用 mat.albedo_color = Color.RED # 所有用这份材质的精灵一起变红 var own := sprite.material.duplicate() # 复制出私有副本 own.albedo_color = Color.BLUE sprite.material = own # 只有这个精灵变蓝
第二层是缓存。资源一旦从磁盘装载,会留在缓存里;再次装载同名资源,拿到的是缓存里的同一份。好处是省内存省加载时间;坏处是"我明明改过了,重开场景怎么还在"——因为你改的是缓存里的共享对象,重载场景时它原样复用。开发期可用引擎的重新导入机制刷新缓存,运行期则要有意识地依赖或绕开这份共享。

引擎自带三件排查利器。内存监视器:运行时看总占用曲线,持续爬升说明有泄漏,台阶式波动属正常。远程场景树:运行中查看真实的树结构,节点只增不减,八成是动态创建后没销毁。对象计数统计:按类型统计存活对象数,哪类数字异常增长,凶手就锁定在哪类。
配合三问心法使用:这个对象谁持有?我放手了吗?放手的时机安全吗?三问答完,开头那个悬空迷案的答案自然浮出——成员变量持有的节点被别处销毁了,而变量不知道。解法也随之而来:要么改用更安全的获取方式,要么在节点销毁时同步清理引用(信号与退出回调都能做到,第 3 章展开)。
💡 关键直觉:引用计数制下,"持有"是一种责任而非便利。每写下一个持有的变量,心里就记一笔账,函数结束、对象销毁、场景切换,都是对账的时点。
把本节知识串成一个真实故障的处理过程。某游戏玩十分钟后明显变卡,重启恢复。排查路径:先开内存监视器看曲线——持续爬升,方向是泄漏;再开对象计数,按类型找增长异常项——特效类节点只增不减;打开远程场景树确认它们挂在哪——一群播放完毕的粒子特效仍留在树上。根因:特效场景自带"播放完自动隐藏",但隐藏不等于销毁,节点仍被父节点持有。修复:在特效脚本的动画完成信号里调用排队销毁。复盘要点:隐藏只是视觉状态,销毁才是生命周期终点;这条边界没划清,是泄漏类故障的头号来源。
把节点的关键时刻整理成一张对照表,排查时可以直接索引:构造时刻——对象刚分配,任何树关系都不存在,只适合初始化自身的基础成员;进树时刻——有了父亲与路径,但子节点未必到齐,适合做依赖父级的准备;就绪时刻——整棵子树到齐,初始化、连接信号、取得引用的标准时机;离树时刻——即将被拆,断开连接、清理缓存、保存状态的最后窗口。四个时刻各司其职,代码放错位置不必然报错,但必然埋雷——多数"偶现"故障的种子都在这里埋下。
💡 一条额外直觉:把"断开连接"当成与"建立连接"同等级的必写代码。建立时顺手写好对应的断开,离场时执行——这个习惯能预防本章以及第 3 章排错实录里将近一半的故障类别。
机器的节律与生死都清楚了。第 3 章回到开发者天天面对的层面:节点怎么拆、场景怎么组、信号怎么连。