技术博客

NOTIFICATION 全家桶:ENTER_TREE / EXIT_TREE / READY 的顺序保证

第 25 / 149 篇 · 2026-02-21 · godot · 场景树源码分析Node

上一篇讲了 _ready 和 _process 的调用链,但它们是通知机制的特例。真正通用的那套东西——notification(int what)——贯穿了引擎所有生命周期事件:进树、出树、可见性变化、窗口事件,甚至画布重绘。这篇把通知体系拆开:编号是怎么分配的、一次通知从 C++ 到脚本走了哪条路、退出树时那个 reversed 参数是干嘛的。

通知编号:一段分治的整数空间

通知就是一个 int。scene/main/node.h 里 Node 的枚举(4.2 源码,节选):

enum {
    NOTIFICATION_ENTER_TREE = 10,
    NOTIFICATION_EXIT_TREE = 11,
    NOTIFICATION_MOVED_IN_PARENT = 12,
    NOTIFICATION_READY = 13,
    NOTIFICATION_PAUSED = 14,
    NOTIFICATION_UNPAUSED = 15,
    NOTIFICATION_PHYSICS_PROCESS = 16,
    NOTIFICATION_PROCESS = 17,
    NOTIFICATION_PARENTED = 18,
    NOTIFICATION_UNPARENTED = 19,
    NOTIFICATION_SCENE_INSTANTIATED = 20,
    NOTIFICATION_PATH_RENAMED = 23,
    NOTIFICATION_CHILD_ORDER_CHANGED = 24,
    NOTIFICATION_INTERNAL_PROCESS = 25,
    NOTIFICATION_POST_ENTER_TREE = 27,
    NOTIFICATION_DISABLED = 28,
    NOTIFICATION_ENABLED = 29,
    NOTIFICATION_RESTARTED = 30,
};

每个类占一段,子类复用父类的段并往后排:Object 占 0~1(POSTINITIALIZE、PREDELETE),Node 占 10~30,CanvasItem 接上 30~36(DRAW=30、ENTER_CANVAS=32、EXIT_CANVAS=33、VISIBLE=34、HIDDEN=35),Control 再接 40~45(RESIZED=40、FOCUS_ENTER=43、THEME_CHANGED=45),窗口系统和 OS 事件占 1000/2000 段(WM_CLOSE_REQUEST 之类)。数字不连续没关系——通知编号只要求"同层不撞车",因为它本质是全局广播的枚举,不是数组下标。

这个设计的关键取舍是:通知不像信号那样有注册表,发通知就是调一次虚函数分发,O(1) 开销,没有订阅者列表要遍历。代价是松耦合变成了无耦合——你知道某个通知发给了所有节点,但没有任何地方登记"谁关心它"。引擎内部事件(帧循环、树结构变化)用通知正合适,因为这些事件的订阅者本来就是"全体节点";而游戏逻辑事件该用信号,因为那边你需要明确的收发双方。

分发路径:notification 到 _notification

Object::notification(int p_notification, bool p_reversed) 的实现很短(core/object/object.cpp):

void Object::notification(int p_notification, bool p_reversed) {
    _notificationv(p_notification, p_reversed);
}

_notificationv 是虚函数,继承链上每层做自己的 switch 然后传给基类。Object 层的关键骨架(core/object/object.cpp,有删减):

void Object::_notificationv(int p_notification, bool p_reversed) {
    if (p_reversed) {
        // 反向:脚本的 _notification 先行,C++ 各级处理殿后
        GDVIRTUAL_CALL(_notification, p_notification);
    }
    // ... 继承链逐级往下分发,各级 C++ 做自己的 switch ...
    if (!p_reversed) {
        // 正向:C++ 各级先处理完,脚本最后收到
        GDVIRTUAL_CALL(_notification, p_notification);
    }
}

脚本侧的 _notification(what) 就是在这里由 GDVIRTUAL_CALL 调进去的。所以一次通知的实际流向是:Object 入口 → Node 的 C++ switch(维护内部状态)→ 脚本的 _notification。C++ 侧先处理意味着脚本收到 NOTIFICATION_ENTER_TREE 时,data.tree、data.viewport 已经填好了——你在 _enter_tree 里调 get_tree() 不会拿到 null,这是顺序保证的,不是运气。

Node::_notification 里处理的通知列表值得扫一眼,因为它揭示了很多"引擎替你做的事":PAUSED 里停掉内部处理循环,PREDELETE 里校验"不允许在子线程释放树内节点"(就是那句 Attempted to free a node ... from a thread 报错的出处),DRAG_BEGIN/END 里挂接拖拽数据。

reversed 参数:退出时为什么反过来

Object::notification 的第二个参数平时没人注意,但 Node::_propagate_exit_tree 里它是 true(node.cpp):

void Node::_propagate_exit_tree() {
    data.blocked++;
    for (KeyValue &K : data.children) {
        K.value->_propagate_exit_tree();   // 先递归子节点,和进入时相反
    }
    data.blocked--;

    data.inside_tree = false;              // 状态先拆除
    notification(NOTIFICATION_EXIT_TREE, true);   // reversed = true
    emit_signal(SceneStringNames::get_singleton()->tree_exited);
    GDVIRTUAL_CALL(_exit_tree);
}

reversed 的语义:C++ 基类的内部处理和脚本的 _notification 的先后对调。正常方向(进入树)是引擎先把内部状态建好,再让脚本看;反方向(退出树)是让脚本先看到通知、引擎再拆内部状态。这保证了两边对称的不变量:脚本在 _enter_tree 和 _exit_tree 里看到的 data.tree 都是有效的。

注意上面代码里 data.inside_tree = false 在通知之前——所以 _exit_tree 里 is_inside_tree() 已经返回 false 了,get_tree() 返回的还是旧树指针(data.tree 到下一次 enter 才更新)。这就是退出回调里对树状态判断容易踩的坑。

为什么是通知而不是虚函数

每个通知都做成 C++ 虚函数(_on_enter_tree() 之类)技术上可行,Godot 没这么做的原因和上一篇讲 GDVIRTUAL_CALL 的原因一样:调用目标跨越语言边界。_notification 的接收方可能是 GDScript、C# 或 GDExtension,C++ 虚表在编译期就固化了,挂不进脚本。通知机制实际上是手工实现的多语言虚表:what 是编号,GDVIRTUAL_IS_OVERRIDDEN 缓存是"这一格有没有实现"。

但通知比纯虚表多一点东西:它是可转发的。Viewport 收到 NOTIFICATION_DRAW 后会转发给挂在自己身上的 CanvasItem 树;SubViewport 可以决定不转发。这种"接收方再决定广播给谁"的模式,用一对一虚函数表达会很别扭。

一个节点的完整通知履历

把一次生命周期里的通知按触发次序排出来(数字是通知编号):

18 挂接 PARENTED 10 进树 ENTER_TREE 27 POST_ENTER 13 就绪 READY 14/15 PAUSED 等 11 出树 EXIT_TREE 19 脱离 UNPARENTED 1 删除 PREDELETE 每帧另有 16/17(物理/普通处理);20 场景实例根进树时收到一次

几个容易记混的点:

  • 18(PARENTED)早于 10(ENTER_TREE):前者在 add_child 里、节点还没进树时就发了;后者要等父节点在树里才会级联下来。所以 PARENTED 里 get_parent() 可用、get_tree() 是 null。
  • 27(POST_ENTER_TREE)夹在进入和 READY 之间:子树全部 enter 完、ready 还没开始,是"整棵子树已进树但没人初始化"的窗口。多数时候用不上,但做需要遍历全子树又不依赖 ready 的逻辑时它比 ENTER_TREE 合适。
  • 20(SCENE_INSTANTIATED)只发给实例根:普通 add_child(new Node) 不会收到,编辑器靠它区分"这个节点是场景实例的根"。
  • 1(PREDELETE)是 Object 层的,任何 Object 删除前都会走,Node 在里头做线程校验和子树级联。

实用结论

写 _notification(what) 时建议用 match 而不是一长串 if,编号比较就是整数比较,没有字符串开销。自定义通知不要拍脑袋选数——官方文档的建议是避开引擎已占用的段,用足够大的值(2000 以上),并在脚本里定义成 const 集中管理。

最重要的是别把通知当信号用。通知是"广播给全世界、没人签收"的,如果你的逻辑需要明确的"谁发的、发给谁",用信号。引擎生命周期事件用通知,游戏事件用信号,这条分界线想清楚能省掉大量调试时间。

本文代码引自 Godot 4.2.2-stable 的 core/object/object.cpp、scene/main/node.h、scene/main/node.cpp,均有删减。