技术博客

节点树的挂接与脱离:add_child() 背后做了什么

第 24 / 149 篇 · 2026-06-18 · godot · 场景树源码分析Node

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 的完整时序 1. 前置校验:非空/无父/非祖先 失败即 ERR_FAIL_MSG 拒绝 2. 命名合法化与冲突消解 重名追加 @ 后缀或数字 3. 挂接:children 数组 + 名字索引 data.parent 指回父节点 4. _propagate_enter_tree(自顶向下) NOTIFICATION_ENTER_TREE 父先子后 5. _propagate_ready(自底向上) 子先调 _ready,父最后 关键事实 · 挂接是 O(子树大小) 的级联, 大场景实例化掉帧的根因 · 父在树外时 add_child 不发任何 通知;等父进树时整棵子树 一次性级联 · remove_child 后节点成孤儿, 不 free 就泄漏 · queue_free 是帧末删除标记, 本帧内节点仍可正常访问 · add_child 后名字可能被改, 定位用引用而不是字符串 · child_order_changed 信号每帧 可能发多次,别接重逻辑

编辑器与运行时的差异

编辑器里 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),均有删减与简化。