Wiki
控制与装饰规则
1.控制与装饰规则
对应 BehaviorTree.CPP 4.x 官方文档:Learn the Basic Concepts → Introduction to BTs → Examples,并参考 Nodes Library 中的 Sequences、Fallbacks 与 Decorators。
上一篇已经知道:tick 从父节点向下传播,状态由子节点向上返回。现在进一步学习,父节点拿到 SUCCESS、FAILURE、RUNNING 后,具体怎样决定下一步。
| 需要表达的任务关系 | 常用节点 |
|---|---|
| 前一步成功后,继续下一步 | Sequence |
| 当前方案失败后,尝试备用方案 | Fallback |
| 修改一个节点的结果、次数或时限 | 装饰节点 |
本篇先讲普通 Sequence、普通 Fallback 和常用装饰节点,使用移动机器人示例。
C++ 是解释逻辑的伪代码;
XML 只展示树的片段,完整工程、节点注册和 XML 文件结构后面统一学习。
2.Sequence:依次完成一组步骤
Sequence 按子节点的排列顺序执行,只有前面的步骤成功,才继续后面的步骤:
Sequence
├── CheckBattery 检查电量是否足够
├── NavigateToGoal 导航到目标
└── ReportArrival 报告到达
| 当前子节点返回 | Sequence 接下来做什么 | Sequence 的结果 |
|---|---|---|
SUCCESS |
继续 tick 下一个子节点 | 所有子节点成功后,返回 SUCCESS |
FAILURE |
停止本次序列,不再 tick 后面的节点 | FAILURE |
RUNNING |
停在当前子节点,等待后续继续执行 | RUNNING |
例如 CheckBattery 返回 FAILURE,本轮不会发送导航请求,也不会报告到达;NavigateToGoal 返回 FAILURE,则跳过 ReportArrival。
2.1.一次 tick 可以继续执行多个步骤
假设电量足够,导航要经过几次检查才完成:
| 外部第几次 tick | 本次实际执行的子节点 | Sequence 返回 |
|---|---|---|
| 第 1 次 | CheckBattery → SUCCESS,接着 NavigateToGoal → RUNNING |
RUNNING |
| 第 2 次 | NavigateToGoal → RUNNING |
RUNNING |
| 第 3 次 | NavigateToGoal → SUCCESS,接着 ReportArrival → SUCCESS |
SUCCESS |
前一步在当前调用中成功后,Sequence 可以立刻继续 tick 下一步,不必等到下一轮。
普通 Sequence 在子节点 RUNNING 时,会记住当前位置。 因此第 2 次 tick 会继续导航,不会重新检查前面已经成功的 CheckBattery。
如果导航期间电量变低,这棵普通 Sequence 不会因此自动重新检查电量并中止导航。需要持续检查条件时,要使用合适的响应式结构,后面结合任务中止来学习。
2.2.用子节点下标理解执行位置
下面的 current_index 是节点保存的成员状态,初始为 0:
BT::NodeStatus Sequence::tick()
{
while (current_index < children.size())
{
BT::NodeStatus status = children[current_index].executeTick();
if (status == BT::NodeStatus::RUNNING)
{
// 保留位置,下次继续 tick 同一个子节点
return BT::NodeStatus::RUNNING;
}
if (status == BT::NodeStatus::FAILURE)
{
ResetChildren();
current_index = 0;
return BT::NodeStatus::FAILURE;
}
// 本段只解释未使用前置条件的情况,暂省略 SKIPPED 分支
++current_index;
}
ResetChildren();
current_index = 0;
return BT::NodeStatus::SUCCESS;
}
这段伪代码省略框架状态维护、SKIPPED 处理和非法状态检查,不能直接当作自定义控制节点实现。
ResetChildren() 表示结束本次执行并重置子节点,不能理解为撤销已经产生的业务效果。
这一行为可对照官方 4.10.0 sequence_node.cpp。
普通 Sequence 成功或失败结束后,位置都会回到开头;如果上层以后重新执行它,会从第一个子节点开始。
返回失败不会在当前 tick 中自动从头重试。
2.3.后面的节点不保证一定执行
下面这棵树不能保证导航失败后执行停止节点:
Sequence
├── NavigateToGoal
└── StopRobot
导航返回 FAILURE 时,Sequence 立即返回失败,StopRobot 会被跳过。
动作失败或被中止时需要的取消、停止和清理,应在动作及相关模块中正确实现。
仅把清理动作放在 Sequence 最后,不能保证它在所有退出路径上执行。
3.Fallback:按顺序尝试方案
Fallback 在其他框架中也常叫 Selector。
它按顺序尝试子节点,只要找到成功的方案,就结束本次选择:
Fallback
├── NavigateToPrimaryGoal 尝试主要目标
└── NavigateToBackupGoal 主要目标失败后,尝试备用目标
| 当前子节点返回 | Fallback 接下来做什么 | Fallback 的结果 |
|---|---|---|
FAILURE |
继续 tick 下一个子节点 | 全部失败后,返回 FAILURE |
SUCCESS |
不再尝试后面的子节点 | SUCCESS |
RUNNING |
继续等待当前方案,不启动后面的方案 | RUNNING |
RUNNING 不算失败。 主要目标还在导航时,不会因为“尚未到达”就立即启动备用目标。
例如:
| 外部第几次 tick | 本次实际执行的子节点 | Fallback 返回 |
|---|---|---|
| 第 1 次 | NavigateToPrimaryGoal → RUNNING |
RUNNING |
| 第 2 次 | NavigateToPrimaryGoal → FAILURE,接着 NavigateToBackupGoal → RUNNING |
RUNNING |
| 第 3 次 | NavigateToBackupGoal → SUCCESS |
SUCCESS |
普通 Fallback 在 RUNNING 时同样保留当前位置。
第 3 次继续备用导航,不会重新尝试前面已经失败的主要目标。
本次选择成功或全部失败结束后,它会重置位置;上层以后重新执行时,再从第一个子节点开始。具体可对照官方 4.10.0 fallback_node.cpp。
3.1.条件节点也能作为优先方案
例如“如果已经到达,就不用重新导航”:
Fallback
├── IsGoalReached
└── NavigateToGoal
IsGoalReached 返回 SUCCESS,这部分树就成功结束;返回 FAILURE,才启动导航。
这里的成功表示“到达目标这项需求已经满足”,所以一个条件判断也可以让整段任务成功,不一定非要执行动作。
4.对照两种控制规则
| 对比项 | 普通 Sequence | 普通 Fallback |
|---|---|---|
| 表达的关系 | 一组都需要成功的步骤 | 一组可以依次尝试的方案 |
| 哪种结果让它继续下一个子节点 | SUCCESS |
FAILURE |
| 哪种结果让它提前结束 | FAILURE |
SUCCESS |
当前子节点 RUNNING 时 |
返回 RUNNING,保留当前位置 |
返回 RUNNING,保留当前位置 |
| 所有子节点都执行完 | 全部成功,返回 SUCCESS |
全部失败,返回 FAILURE |
树结构中的前后顺序会影响结果。把子节点重新排序,可能改变先检查什么、先执行哪个方案,以及哪些节点会被跳过。
4.x 官方文档还介绍了 ReactiveSequence、ReactiveFallback 和带记忆的序列变体。本篇末尾先作对照,持续任务篇再运行完整程序;这里的“保留 RUNNING 位置”只是在说明普通节点的行为,不能套用到所有变体。
5.装饰节点:处理一个子节点的结果或执行方式
装饰节点始终只有一个直接子节点,子节点也可以是整棵子树的入口。
5.1.Inverter、ForceSuccess 与 ForceFailure
这三种节点主要处理子节点的返回结果:
| 子节点返回 | Inverter | ForceSuccess | ForceFailure |
|---|---|---|---|
SUCCESS |
FAILURE |
SUCCESS |
FAILURE |
FAILURE |
SUCCESS |
SUCCESS |
FAILURE |
RUNNING |
RUNNING |
RUNNING |
RUNNING |
Inverter 已在上一篇用路径检查解释过。这里补充两种强制结果的用途:
Sequence
├── NavigateToGoal
└── ForceSuccess
└── UploadArrivalLog
如果“导航到达”是主要任务,而“上传到达日志”允许失败,可以只给日志动作加 ForceSuccess。日志最终失败时,这个装饰节点仍返回成功;日志还在上传时,仍返回 RUNNING,不会立刻跳过它。
ForceFailure 则让已结束的子节点统一报告失败。例如,在 Fallback 中,执行完一段诊断记录后仍希望继续下一个方案,可以用它包装这段记录。
强制改变的是向父节点报告的状态,不会让失败的操作实际成功,也不会撤销已经完成的动作。使用前要确定,这个结果是否符合父节点所代表的任务含义。
5.2.RetryUntilSuccessful:失败后重新尝试
4.x 文档中的 RetryNode,在 XML 中使用的注册名称是 RetryUntilSuccessful:
<RetryUntilSuccessful num_attempts="3">
<NavigateToGoal/>
</RetryUntilSuccessful>
这里表示总共最多尝试 3 次,包含第一次:
| 子节点返回 | RetryUntilSuccessful 的处理 |
|---|---|
SUCCESS |
立即返回 SUCCESS,不再重试 |
FAILURE |
记一次失败;还有次数就重置子节点再试,用完则返回 FAILURE |
RUNNING |
返回 RUNNING,当前尝试继续,不消耗一次失败次数 |
例如导航结果依次为:
第 1 次尝试:RUNNING → FAILURE(此时不会再消耗一次tickexactlyonce(tickroot),会直接进入下一次尝试)
第 2 次尝试:RUNNING → RUNNING → SUCCESS
整体结果:SUCCESS,第 3 次尝试不会发生
每次完整尝试可以跨越多个 tick。
num_attempts="3" 限制的是尝试次数,不是整个装饰节点只能被 tick 三次。
根据官方 4.10.0 retry_node.cpp,
在正常由 BT::Tree 初始化唤醒机制的工程中,立即失败的子节点可使 Retry 发出唤醒信号并先返回 RUNNING;使用 tickExactlyOnce() 时,下一次尝试需要下一次根 tick。
使用会处理唤醒的 tickOnce() 或 tickWhileRunning() 时,一次外部调用又可能包含多次根 tick。
子节点已处于 RUNNING 后再失败,也可能在同一根 tick 立即启动下一次尝试。
这个节点没有自动增加重试间隔;对子节点反复检查,不等于等待环境或传感器数据变化。
5.3.Repeat:成功后继续重复
Repeat 用于重复完成一个操作:
<Repeat num_cycles="3">
<CaptureObservation/>
</Repeat>
这里假设 CaptureObservation 每次执行会采集并保存一份观测数据。任务是成功完成 3 次采集:
| 子节点返回 | Repeat 的处理 |
|---|---|
SUCCESS |
记一次成功;未到次数则重置子节点继续,达到次数后返回 SUCCESS |
FAILURE |
立即停止重复,返回 FAILURE |
RUNNING |
返回 RUNNING,继续当前这次操作,不增加已完成次数 |
它与 Retry 的区别是:Retry 失败后继续,Repeat 成功后继续。 num_cycles="3" 同样包含第一次执行,不是先做一次再额外做三次。
官方 4.10.0 repeat_node.cpp 也使用循环处理立即结束的子节点。
在正常树的唤醒机制下,同步子节点每次完成后,Repeat 可以发出唤醒并返回 RUNNING。使用 tickExactlyOnce() 与使用会处理唤醒的接口,外部看到的调用次数可能不同。num_cycles 只规定完成次数,不规定采集间隔;需要定时采样时,应另行实现时间或数据更新条件。
5.4.Timeout:给本次执行设置时限
Timeout 使用 msec 指定毫秒数,例如:
<Timeout msec="5000">
<NavigateToGoal/>
</Timeout>
这给本次导航设置 5 秒时限:
| 子节点情况 | Timeout 的处理 |
|---|---|
未超时就返回 SUCCESS |
返回 SUCCESS |
未超时就返回 FAILURE |
返回 FAILURE |
| 还在运行且未超时 | 返回 RUNNING |
| 时限到达且子节点仍在运行 | 中止子节点,随后返回 FAILURE |
在 4.10.0 timeout_node.h 中,计时从装饰节点本次执行开始,后续 tick 不会反复把这 5 秒重新计时。 到期处理会调用子节点的中止逻辑;超时失败结果在后续执行入口中返回给父节点。
导航动作需要在中止处理中取消实际导航请求。
Timeout 不能替代动作的取消实现,也不能把一个长时间堵在 tick() 里的同步函数自动变成可及时中止的异步任务。
6.组合示例:检查电量,再有限次尝试导航
把规则组合成一棵任务树:
Sequence
├── CheckBattery
├── RetryUntilSuccessful [num_attempts=3]
│ └── Timeout [msec=5000]
│ └── NavigateToGoal
└── ReportArrival
对应的 XML 片段:
<Sequence>
<CheckBattery/>
<RetryUntilSuccessful num_attempts="3">
<Timeout msec="5000">
<NavigateToGoal/>
</Timeout>
</RetryUntilSuccessful>
<ReportArrival/>
</Sequence>
阅读时,从外向内看任务约束:
| 所在层 | 职责 |
|---|---|
外层 Sequence |
电量检查通过后才执行导航;导航最终成功后才报告到达 |
RetryUntilSuccessful |
导航尝试失败时重新尝试,最多 3 次 |
Timeout |
限制每次尝试的运行时间 |
NavigateToGoal |
启动、检查并在必要时取消实际导航任务 |
一种可能的执行过程是:
CheckBattery → SUCCESS
↓
第 1 次导航尝试 → RUNNING → 超时并取消 → FAILURE
↓ Retry 重置并重新尝试
第 2 次导航尝试 → RUNNING → SUCCESS
↓ Retry 返回 SUCCESS
ReportArrival → SUCCESS
↓
Sequence → SUCCESS
如果 3 次都失败,Retry 返回 FAILURE,外层序列也失败,ReportArrival 不会执行。
若报告动作本身失败,外层序列同样会报告失败,因此报告动作应按需求定义好“成功”的含义。
这里的普通 Sequence 只在开始时检查电量,后续不会在导航或重试期间持续重查。
这个例子用于掌握顺序、重试和超时的组合;持续监控条件及任务切换,留到响应式行为章节。
6.1.装饰节点的内外顺序会改变含义
RetryUntilSuccessful [3 次]
└── Timeout [5 秒]
└── NavigateToGoal
这是上面的结构:每次尝试分别有 5 秒时限,一次超时算一次尝试失败,Retry 可以再试。
如果交换位置:
Timeout [5 秒]
└── RetryUntilSuccessful [3 次]
└── NavigateToGoal
则是所有尝试共享一个 5 秒时限。 只有导航自身先返回失败,Retry 才能在剩余时间内重试; 外层一旦超时,就中止整个重试子树,不再保证用完 3 次。
两种结构的节点相同,但任务约束不同。 读树时既要看子节点顺序,也要看装饰节点包住了哪一层。
7.在 v4 中组合 SKIPPED 与响应式规则
本篇前面的表格先解释三种业务结果。
加入前置条件后,普通 Sequence 和 Fallback 遇到 SKIPPED 都会继续后面的子节点;全部被跳过时返回 SKIPPED。
Inverter、ForceSuccess、ForceFailure 在 4.10.0 中会保留 SKIPPED,不会将“未执行”一律转换为成功或失败。
Retry 与 Repeat 的子节点被跳过时,也返回跳过。
| 控制节点 | 子节点 RUNNING 后,下次从哪里开始 | 典型用途 |
|---|---|---|
Sequence |
从正在运行的子节点继续 | 一次任务的固定步骤 |
ReactiveSequence |
每次从第一个子节点重新检查 | 前置条件变化时中止后面的持续动作 |
SequenceWithMemory |
运行时继续当前位置,失败和 halt 后仍保留位置;全部成功后从头开始 | 需要保留已完成步骤的流程 |
Fallback |
从正在运行的方案继续 | 顺序尝试方案 |
ReactiveFallback |
每次从最高优先级方案重新判断 | 高优先级需求出现时切换方案 |
4.10.0 的 SequenceWithMemory::halt() 不清除当前子节点索引,不能假定调用 haltTree 后所有控制节点的业务进度都被清空。需要每次任务从头开始时,优先采用普通 Sequence,或明确重建具有独立生命周期的树。
ReactiveSequence 重新执行的是前面的子节点,不专门识别 ConditionNode;前面放同步动作,也会重复执行它。AsyncSequence、AsyncFallback 则用于在同步步骤之间交还控制权;结合 tickOnce() 的唤醒处理,一次外部调用仍可能推进多步,不能把它们当作后台线程。
持续任务篇会用完整程序对照普通与响应式 Sequence。脚本篇再解释 _while 产生的 SKIPPED 为什么可能让外层 Sequence 继续,不能将它直接当成安全检查失败。
8.当前学习进度
本篇完成了 Introduction to BTs 中 Sequence、Fallback 和装饰节点的核心规则,并补充了机器人任务常用的重试、重复与超时组合。