parent.add_child(node) 是 Godot 里调用频率最高的 API 之一,但它的实现不是"往数组里塞个指针"。一次挂接要处理命名冲突、级联通知整棵子树、把新节点接入 process 调度——对一棵深 5 层的子树,这是一次 O(子树节点数) 的级联操作。这篇读 scene/main/node.cpp(4.2.2-stable,该文件约 3800 行)的相关段,把挂接和脱离的完整路径拆开。
add_child 的前置检查
Node::add_child 本体先做一串防御性校验,任何一个失败都是 ERR_FAIL_MSG 直接拒绝:
// scene/main/node.cpp,Node::add_child(简化)
void Node::add_child(Node *p_child, bool p_force_readable_name, InternalMode p_internal) {
ERR_FAIL_NULL(p_child);
ERR_FAIL_MSG(p_child == this, "Can't add child to itself.");
ERR_FAIL_MSG(p_child->data.parent, "Node already has a parent, ...");
// 环形检查:p_child 不能是当前节点的祖先
ERR_FAIL_MSG(is_ancestor_of(p_child), ...);
_add_child_nocheck(p_child, p_child->get_name(), p_internal);
}
环形检查值得展开:如果允许把祖先挂成后代的孩子,树变环,之后所有递归遍历(通知传播、路径解析、销毁)都会无限递归爆栈。is_ancestor_of 顺着 data.parent 链向上爬,复杂度 $O(\text{深度})$。这就是为什么 Godot 不用"先挂上再发现环"的懒检查——树结构正确性必须在挂接点一次保证。
命名冲突:@ 后缀是怎么来的
_add_child_nocheck 的第一步不是挂接,是名字合法化。节点的 data.name 在兄弟间必须唯一(get_node("Name") 靠它定位),冲突时的行为:
- 默认模式:同名孩子已存在时,新节点被改名——在名字后追加
@或数字(这就是场景运行时偶发出现Enemy@2、Enemy@3的来源); force_readable_name = true:生成更可读的Name2、Name3后缀。实例化 PackedScene 时内部用这个模式,保证动态实例化的节点名字好看;- 名字里的
.、/、:、@、%会被替换掉,因为这些字符在 NodePath 语法里有语义(路径分隔、子路径、唯一名标记)。
改名这个行为有个实战后果:不要假设 add_child 后 node.name 还是你设的值。动态生成节点后要立即读回 node.name 或干脆用引用而不是名字定位它。
挂接本体与通知级联
_add_child_nocheck 的核心动作(顺序很重要):
void Node::_add_child_nocheck(Node *p_child, const StringName &p_name, InternalMode p_internal) {
p_child->data.name = p_name;
// 1. 加入兄弟数组 + 名字索引哈希表
data.children.push_back(p_child);
data.children_by_name[p_child->data.name] = p_child; // 概念示意
p_child->data.parent = this;
// 2. 如果自己在树内,级联通知孩子整棵子树"进树了"
if (data.inside_tree) {
p_child->_propagate_enter_tree();
// 3. 级联 ready(若父节点已 ready)
p_child->_propagate_ready();
// 4. 接入 process 调度
p_child->_propagate_process_owner(...);
}
// 5. 更新 tree 的节点计数、发射 child_order_changed
}
注意顺序:ENTER_TREE 先于 READY。_propagate_enter_tree() 自顶向下发 NOTIFICATION_ENTER_TREE(父先子后),_propagate_ready() 自底向上发 NOTIFICATION_READY(子先父后,_ready 回调在这里触发)。这个"进树自顶向下、就绪自底向上"的对偶是刻意设计:进树时父节点有机会先配置子节点,就绪时子节点先初始化完,父节点的 _ready 里才能安全访问子节点的就绪状态。
挂接的成本由此可以量化:设新挂子树有 $n$ 个节点,则
$$ T_{\text{add\_child}} = O(n) \times \left( T_{\text{notification}} + T_{\text{ready}} \right) $$
其中 $T_{\text{ready}}$ 包含每个节点的 GDScript _ready 虚调用。这就是"战斗中实例化大场景会掉帧"的机制层面原因——不是内存分配慢,是几百个节点的脚本回调在同一帧里挤爆了。
脱离:remove_child 与 _propagate_exit_tree
Node::remove_child 是对称操作,但有个关键不对称点:
- 脱离时级联
NOTIFICATION_EXIT_TREE,自底向上发(子先父后); - 脱离不清 process 状态,不销毁数据,节点变成"树外对象"——引用还在,但不再被迭代、不再收到通知;
- 脱离的节点成了孤儿(Orphaned Node,见 ObjectDB 那篇),
remove_child之后忘记queue_free()是泄漏的头号来源。
还有个常被误用的细节:queue_free() 不是 remove_child + delete 的组合,它只是标记"本帧结束时 free"。节点在标记后仍挂在树上、仍会收到本帧剩余的通知,直到帧末 SceneTree 统一处理删除队列时才真正脱离并析构。在 _process 里 queue_free() 一个节点后继续访问它的子节点,本帧内是安全的——这个语义是稳定的,可以依赖。但跨帧就不保证了,持有被 queue_free 节点的引用要在下一帧前用 is_instance_valid() 复检。
挂接时序示意
编辑器与运行时的差异
编辑器里 add_child 还有一层额外行为:编辑器会监听 SceneTree 的 node_added 信号刷新场景树面板,并把挂接操作包进 UndoRedo 栈。这就是为什么插件代码里直接 add_child 的节点"关不掉撤销历史里多出来的条目"——正确姿势是走 get_undo_redo() 提交动作(编辑器插件篇第 8 条展开)。运行时导出版没有这层,同样的挂接代码在编辑器里比在导出游戏里慢一截,测实例化性能要用导出包测,编辑器数字没有意义。
实战推论
- 池化优于反复实例化。理解了挂接成本就知道对象池为什么有效:池化复用的节点只做一次
_propagate_enter_tree,之后visible = false雪藏,避开通知级联和_ready回调。 call_deferred("add_child", ...)的常见用法本质是避开"在通知回调里改树结构"的雷区——NOTIFICATION_ENTER_TREE处理过程中再 add_child 会触发嵌套级联,顺序极难推理。延迟到帧末统一挂接,时序扁平可控。move_child()只改顺序不重挂。调整绘制顺序/Tab 顺序用它,代价是 O(1) 数组搬移加一次 child_order_changed;用 remove + add 重挂实现同样效果是 O(n) 通知级联,差两个量级。- 内部节点(internal child)模式:
add_child(node, false, Node.INTERNAL_MODE_FRONT)挂的孩子在get_children()里默认不可见,编辑器场景树也不显示。引擎自己的子视口、滚动条都是这么藏的,插件作者藏辅助节点可以用同一机制。 - 树外挂接是廉价的:父节点不在树里时,
add_child只走前三步(校验、命名、数组),通知级联整体推迟到父节点进树时一次性发生。所以"先在树外组装好整棵子树再一次挂进树"是实例化大场景的正确姿势——级联只跑一遍,且发生在同一帧的受控位置。 - owner 和挂接无关但常被混淆:
add_child不设 owner,owner = get_tree().edited_scene_root这类赋值要手动做(或编辑器自动做)。动态挂接的节点没 owner 就不会被场景保存写盘——运行时生成、保存时想留的节点别忘了这一步,owner 篇会展开讲。 - 节点的 data 块:
Node::Data结构体(node.cpp 内部)集中存了 parent、children 数组、name、owner、process 标志等全部运行时状态。翻 node.cpp 时先认清这个结构体,挂接/脱离/process 调度的所有操作都是在它上面做的,代码会好读很多。
本文代码引自 Godot 4.2.2-stable 的 scene/main/node.cpp(add_child、_add_child_nocheck、_propagate_enter_tree、_propagate_ready、remove_child、_propagate_exit_tree),均有删减与简化。