Node._exit_tree
最后同步日期:2026-04-16 | Godot 官方原文 — Node._exit_tree
Node._exit_tree
定义
想象你住在一栋公寓楼里——有一天你要搬家了。搬家之前,你需要做的事情包括:退订水电燃气、归还钥匙、清理房间、告诉邻居你要走了。_exit_tree 就是这个"搬家收尾"的时刻:当一个节点即将离开场景树(SceneTree)的时候,引擎会自动调用这个方法。
简单来说,_exit_tree 是节点离开场景树前的"告别仪式"。在这个方法里,你应该做这些事情:
- 断开信号连接(告诉邻居"我要走了,不用再给我发消息了")
- 停止计时器和动画(关掉水电)
- 保存需要保留的数据(把重要物品打包带走)
- 清理临时创建的资源(打扫房间)
_exit_tree 和 _enter_tree 是一对好兄弟。_enter_tree 是"搬进来"时调用,_exit_tree 是"搬出去"时调用。如果一个节点被反复添加到场景树、又反复移除,那么这两个方法也会被反复调用。
一句话总结
_exit_tree 就是节点的"退房手续"——在节点离开场景树之前,给你最后一次机会做清理和收尾工作。
函数签名
public override void _ExitTree()func _exit_tree() -> void参数说明
| 参数 | 类型 | 必需 | 说明 |
|---|---|---|---|
| — | — | — | 此方法没有参数 |
返回值
无返回值(void)。
代码示例
基础用法
最简单的用法——在 _exit_tree 中打印一条消息,验证节点正在离开场景树:
using Godot;
public partial class MyNode : Node
{
public override void _ExitTree()
{
// 节点即将离开场景树时自动执行
GD.Print("节点正在离开场景树!");
// 运行结果: 节点正在离开场景树!
// (当节点被移除或场景切换时,在 Output 面板中可以看到这行输出)
}
}extends Node
func _exit_tree() -> void:
# 节点即将离开场景树时自动执行
print("节点正在离开场景树!")
# 运行结果: 节点正在离开场景树!
# (当节点被移除或场景切换时,在 Output 面板中可以看到这行输出)实际场景
在实际开发中,_exit_tree 最常用来做清理工作:断开信号、停止计时器、保存状态。下面是一个敌人角色的典型写法——敌人被击败时需要断开信号并播放死亡特效:
using Godot;
public partial class Enemy : CharacterBody2D
{
// 导出属性:可在编辑器 Inspector 面板中调整
[Export] public int ExMaxHealth = 50;
[Export] public float ExAttackPower = 10f;
// 内部变量:脚本内部使用
private int _currentHealth;
private Timer _attackTimer;
private AnimationPlayer _animPlayer;
private bool _isDead = false;
public override void _Ready()
{
_currentHealth = ExMaxHealth;
_attackTimer = GetNode<Timer>("AttackTimer");
_animPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
// 连接信号
_attackTimer.Timeout += OnAttackTimerTimeout;
_attackTimer.Start();
GD.Print("敌人就绪!血量=" + _currentHealth);
// 运行结果: 敌人就绪!血量=50
}
public override void _ExitTree()
{
// 1. 断开信号连接(防止离开场景树后信号仍然触发)
if (_attackTimer != null)
{
_attackTimer.Timeout -= OnAttackTimerTimeout;
}
// 2. 停止计时器
if (_attackTimer != null)
{
_attackTimer.Stop();
}
// 3. 记录离开原因
if (_isDead)
{
GD.Print("敌人被击败,正在清理资源...");
// 运行结果: 敌人被击败,正在清理资源...
}
else
{
GD.Print("敌人离开场景树(场景切换等原因)");
// 运行结果: 敌人离开场景树(场景切换等原因)
}
}
public void TakeDamage(int damage)
{
_currentHealth -= damage;
if (_currentHealth <= 0)
{
_isDead = true;
QueueFree(); // 这会触发 _ExitTree
}
}
private void OnAttackTimerTimeout()
{
GD.Print("敌人发动攻击!伤害=" + ExAttackPower);
// 运行结果: 敌人发动攻击!伤害=10
}
}extends CharacterBody2D
# 导出属性:可在编辑器 Inspector 面板中调整
@export var ex_max_health: int = 50
@export var ex_attack_power: float = 10.0
# 内部变量:脚本内部使用
var _current_health: int
var _attack_timer: Timer
var _anim_player: AnimationPlayer
var _is_dead: bool = false
func _ready() -> void:
_current_health = ex_max_health
_attack_timer = get_node("AttackTimer")
_anim_player = get_node("AnimationPlayer")
# 连接信号
_attack_timer.timeout.connect(_on_attack_timer_timeout)
_attack_timer.start()
print("敌人就绪!血量=" + str(_current_health))
# 运行结果: 敌人就绪!血量=50
func _exit_tree() -> void:
# 1. 断开信号连接(防止离开场景树后信号仍然触发)
if _attack_timer:
_attack_timer.timeout.disconnect(_on_attack_timer_timeout)
# 2. 停止计时器
if _attack_timer:
_attack_timer.stop()
# 3. 记录离开原因
if _is_dead:
print("敌人被击败,正在清理资源...")
# 运行结果: 敌人被击败,正在清理资源...
else:
print("敌人离开场景树(场景切换等原因)")
# 运行结果: 敌人离开场景树(场景切换等原因)
func take_damage(damage: int) -> void:
_current_health -= damage
if _current_health <= 0:
_is_dead = true
queue_free() # 这会触发 _exit_tree
func _on_attack_timer_timeout() -> void:
print("敌人发动攻击!伤害=" + str(ex_attack_power))
# 运行结果: 敌人发动攻击!伤害=10进阶用法
下面展示一个更完整的场景:在 _exit_tree 中保存游戏状态、处理对象池(Object Pool)回收逻辑,以及理解子节点先于父节点退出的调用顺序:
using Godot;
using System.Collections.Generic;
public partial class BulletPoolManager : Node
{
// 导出属性
[Export] public PackedScene ExBulletScene;
[Export] public int ExPoolSize = 20;
// 内部变量
private readonly List<Node> _activeBullets = new();
private readonly List<Node> _inactiveBullets = new();
private int _totalFired = 0;
private bool _isPaused = false;
public override void _Ready()
{
// 预创建子弹对象
for (int i = 0; i < ExPoolSize; i++)
{
var bullet = ExBulletScene.Instantiate<Node>();
bullet.SetProcess(false);
bullet.Visible = false;
AddChild(bullet);
_inactiveBullets.Add(bullet);
}
GD.Print($"子弹池就绪!池大小={ExPoolSize}");
// 运行结果: 子弹池就绪!池大小=20
}
public override void _ExitTree()
{
// 1. 保存统计信息(在离开场景树前持久化数据)
SaveStatistics();
// 2. 清理所有活跃子弹的信号
foreach (var bullet in _activeBullets)
{
// 断开子弹可能存在的信号
if (bullet is Area2D area)
{
area.BodyEntered -= OnBulletHit;
}
}
// 3. 判断是暂停还是真正退出
if (_isPaused)
{
GD.Print("子弹池暂停,资源保留");
// 运行结果: 子弹池暂停,资源保留
}
else
{
GD.Print("子弹池销毁,已保存统计: 共发射 " + _totalFired + " 颗子弹");
// 运行结果: 子弹池销毁,已保存统计: 共发射 42 颗子弹
}
}
/// <summary>
/// 从池中取出一个子弹
/// </summary>
public Node GetBullet()
{
if (_inactiveBullets.Count > 0)
{
var bullet = _inactiveBullets[0];
_inactiveBullets.RemoveAt(0);
_activeBullets.Add(bullet);
bullet.SetProcess(true);
bullet.Visible = true;
_totalFired++;
GD.Print("取出子弹,剩余可用=" + _inactiveBullets.Count);
// 运行结果: 取出子弹,剩余可用=19
return bullet;
}
GD.Print("子弹池已耗尽!");
// 运行结果: 子弹池已耗尽!
return null;
}
/// <summary>
/// 将子弹放回池中
/// </summary>
public void ReturnBullet(Node bullet)
{
_activeBullets.Remove(bullet);
_inactiveBullets.Add(bullet);
bullet.SetProcess(false);
bullet.Visible = false;
GD.Print("回收子弹,剩余活跃=" + _activeBullets.Count);
// 运行结果: 回收子弹,剩余活跃=0
}
private void SaveStatistics()
{
// 将统计数据保存到配置文件
var config = new ConfigFile();
config.SetValue("stats", "total_fired", _totalFired);
config.Save("user://bullet_stats.cfg");
}
private void OnBulletHit(Node2D body)
{
GD.Print("子弹命中目标: " + body.Name);
// 运行结果: 子弹命中目标: Enemy
}
}extends Node
# 导出属性
@export var ex_bullet_scene: PackedScene
@export var ex_pool_size: int = 20
# 内部变量
var _active_bullets: Array[Node] = []
var _inactive_bullets: Array[Node] = []
var _total_fired: int = 0
var _is_paused: bool = false
func _ready() -> void:
# 预创建子弹对象
for i in range(ex_pool_size):
var bullet = ex_bullet_scene.instantiate()
bullet.set_process(false)
bullet.visible = false
add_child(bullet)
_inactive_bullets.append(bullet)
print("子弹池就绪!池大小=%d" % ex_pool_size)
# 运行结果: 子弹池就绪!池大小=20
func _exit_tree() -> void:
# 1. 保存统计信息(在离开场景树前持久化数据)
_save_statistics()
# 2. 清理所有活跃子弹的信号
for bullet in _active_bullets:
# 断开子弹可能存在的信号
if bullet is Area2D:
bullet.body_entered.disconnect(_on_bullet_hit)
# 3. 判断是暂停还是真正退出
if _is_paused:
print("子弹池暂停,资源保留")
# 运行结果: 子弹池暂停,资源保留
else:
print("子弹池销毁,已保存统计: 共发射 %d 颗子弹" % _total_fired)
# 运行结果: 子弹池销毁,已保存统计: 共发射 42 颗子弹
## 从池中取出一个子弹
func get_bullet() -> Node:
if _inactive_bullets.size() > 0:
var bullet = _inactive_bullets.pop_front()
_active_bullets.append(bullet)
bullet.set_process(true)
bullet.visible = true
_total_fired += 1
print("取出子弹,剩余可用=%d" % _inactive_bullets.size())
# 运行结果: 取出子弹,剩余可用=19
return bullet
print("子弹池已耗尽!")
# 运行结果: 子弹池已耗尽!
return null
## 将子弹放回池中
func return_bullet(bullet: Node) -> void:
_active_bullets.erase(bullet)
_inactive_bullets.append(bullet)
bullet.set_process(false)
bullet.visible = false
print("回收子弹,剩余活跃=%d" % _active_bullets.size())
# 运行结果: 回收子弹,剩余活跃=0
func _save_statistics() -> void:
# 将统计数据保存到配置文件
var config = ConfigFile.new()
config.set_value("stats", "total_fired", _total_fired)
config.save("user://bullet_stats.cfg")
func _on_bullet_hit(body: Node2D) -> void:
print("子弹命中目标: " + body.name)
# 运行结果: 子弹命中目标: Enemy注意事项
可以多次调用:与
_ready不同,_exit_tree可以被多次调用。如果你把一个节点从场景树中移除,然后又重新添加进去,再移除,那么每次移除时_exit_tree都会被调用。因此,在_exit_tree中做清理时要注意不要重复释放已经释放过的资源。子节点先退出,父节点后退出:调用顺序和
_enter_tree正好相反。_enter_tree是"父先进、子后进",而_exit_tree是"子先出、父后出"。这意味着当父节点的_exit_tree执行时,所有子节点已经退出了场景树。此时不要再尝试访问子节点,因为它们可能已经不可用了。queue_free会触发_exit_tree:当你调用QueueFree()(GDScript 中是queue_free())时,引擎会在当前帧结束后将节点从场景树移除并销毁,这个过程会触发_exit_tree。所以你不需要在queue_free之前手动做清理——把清理逻辑放在_exit_tree里就好。断开信号很重要:如果你在
_ready或_enter_tree中连接了信号,一定要在_exit_tree中断开它们。否则即使节点已经离开场景树,信号回调仍然可能被触发,导致空引用错误或意外行为。C# 中使用-=取消订阅,GDScript 中使用disconnect()。不要在
_exit_tree中调用queue_free:_exit_tree是在节点即将离开场景树的过程中被调用的。在这个方法里再调用queue_free会导致重复释放或产生不可预期的行为。如果你需要销毁节点,应该在其他地方(比如_process或某个事件回调中)调用queue_free。场景切换会触发:当你切换到另一个场景时(使用
GetTree().ChangeSceneToFile()),当前场景中的所有节点都会依次调用_exit_tree。这是做全局清理(如保存进度、断开网络连接)的好地方。C# 差异:C# 中方法名用 PascalCase(
_ExitTree),GDScript 中用 snake_case(_exit_tree)。C# 中需要override关键字来重写,GDScript 中只需直接定义同名函数即可。
