技术博客

Control 的布局通知:NOTIFICATION_RESIZED 何时触发

第 38 / 149 篇 · 2025-03-05 · godot · 场景树UI源码分析

写自定义 UI 时最常见的困惑:在 _notification 里等 NOTIFICATION_RESIZED 做自适应布局,有时立刻收到,有时要等一帧。差别不在你,在 Control 内部有两条触发路径——一条同步、一条延迟,走哪条取决于尺寸是被谁改的。

RESIZED:set_size 的副产品

Control::set_size()(scene/gui/control.cpp)在尺寸真的变化时当场发通知:

// scene/gui/control.cpp(Godot 4.2.2,有删减)
void Control::set_size(const Size2 &p_size) {
    Size2 new_size = p_size;
    if (new_size == data.size) {
        return; // 没变就什么都不发
    }
    data.size = new_size;
    _resized(); // → NOTIFICATION_RESIZED
}

注意两点:尺寸没变就不发(所以初始化成相同尺寸不会触发);触发是同步的,发生在赋值之后立刻。这个通知设计给"我需要自己的新尺寸"的场景——比如重算内部子控件的摆放、刷新自定义绘制。在 _ready 里读 size 拿到的可能还是 (0,0),因为这时布局可能还没跑过;等 RESIZED 才是可靠时机。

容器重排:延迟的 SORT_CHILDREN

直接父节点是 Container 时,你的 set_size 会被容器覆盖——容器布局的优先级更高。Container 的机制完全另一条路:子节点增删、最小尺寸变化、anchor 变化时,容器不立即重排,而是调 queue_sort() 给自己打个标记:

void Container::queue_sort() {
    pending_sort = true;
    callable_mp(this, &Container::sort_children).call_deferred();
}

sort_children 即 NOTIFICATION_SORT_CHILDREN:容器在这个通知里对所有子节点算最终矩形(fit_child_in_rect 之类),然后对子节点逐个 set_size / set_position——子节点的 RESIZED 此时才被触发。整条链是:子节点报告"我变了" → 容器标记待排序 → 帧尾 deferred 回调执行排序 → 子节点收到 RESIZED。

所以 RESIZED 的延迟不是 Control 的锅,是容器把重排推迟到了安全时点(同一帧内多个子节点连续变化时只排一次序,这是延迟的真正动机:$n$ 个子节点各变一次,同步重排要排 $n$ 次,延迟合并后只排一次)。

anchor 变化和窗口变化

anchor 路径又是同步的:set_anchor 里直接调 _size_changed(),同步重算 offset 和 size,同步发 RESIZED。窗口被用户拖拽改变大小时,根 Control 的 anchor 不变但尺寸跟着 RootViewport 变,这条路径同样是同步的——所以"拖窗口立刻响应"和"容器内重排等一帧"两种体验并存,都是各自路径的必然结果。

实战建议

把布局逻辑挂对通知:自适应内容(根据 size 重排自己内部)挂 NOTIFICATION_RESIZED;子控件被容器定位(要等父级最终矩形)不要靠 RESIZED 的次数做假设,直接读最终 position/size。自定义 Container 子类里,重写在 NOTIFICATION_SORT_CHILDREN 里,别在 _process 里手动排——_process 里排等于每帧否决容器的延迟合并机制,白白多算。

set_anchor / 拖窗口 同步路径 Control 尺寸更新 立即生效 NOTIFICATION_RESIZED 子控件在此自适应 子控件增删 / 变尺寸 容器内路径 queue_sort() 只打标记 帧尾 deferred SORT_CHILDREN fit_child 子控件在此收到通知 延迟路径把同帧多次变化合并成一次排序;RESIZED 在两条路径的末端触发,时机不同但语义相同

本文代码引自 Godot 4.2.2-stable 的 scene/gui/control.cpp、scene/gui/container.cpp,均有删减。