Wiki
组合实际任务
1.把已有节点组合成移动机器人任务
对应 BehaviorTree.CPP 3.8 官方文档:Using SubTrees、Port Remapping 和 Use multiple XML files。实现细节参考 3.8.7 的 xml_parsing.cpp、blackboard.h 和 retry_node.cpp。
前面的实践已经分别给出了端口、Blackboard 和持续动作。 本篇把它们组合成一个完整主题:机器人先确保电量足够,再依次前往取货点和放货点;每段导航允许有限次尝试,成功后报告结果,尝试耗尽后报告任务失败。
本篇的导航仍用回调计数模拟,充电简化为一次电量更新,全部运行都不需要连接机器人。 先完成前面几篇的实践,再运行本篇,便于把注意力集中在任务组织与子树边界上。
MainTree
└── Sequence:一次运输任务
├── Fallback:确保电量(如果电量不OK,则充电)
│ ├── BatteryOK
│ └── ChargeBattery
└── Fallback:执行任务,或报告失败(如果任务执行失败,则报Failure)
├── Sequence
│ ├── SubTree:NavigateLeg,前往取货点
│ ├── SubTree:NavigateLeg,前往放货点
│ └── ReportMission
└── ForceFailure
└── ReportFailure
NavigateLeg:每处 SubTree 在建树时创建自己的节点和局部 Blackboard
└── RetryUntilSuccessful:最多尝试两次
└── NavigateToGoal:跨多次 tick 检查本次导航
电量检查失败时,Fallback 继续执行模拟充电;充电成功后,外层 Sequence 才进入导航。
导航序列返回 RUNNING 时,任务 Fallback 也返回 RUNNING,不会提前执行失败报告。
2.复用子树时明确数据边界
两段导航执行规则相同,只是目标和结果条目不同,因此可以共用 NavigateLeg 这份 XML 定义。
两处 SubTree 在建树时分别创建不同的节点对象和局部 Blackboard,内部的 target、reached 名称可以相同,再分别连接到主树的不同条目。运行期间重复 tick 或重试会继续使用已有实例。
主树 Blackboard 第一次 NavigateLeg 的局部 Blackboard
pickup_goal = loading_area ──映射──→ target
pickup_reached ←─映射── reached
主树 Blackboard 第二次 NavigateLeg 的局部 Blackboard
dropoff_goal = unloading_area ─映射──→ target
dropoff_reached ←─映射── reached
本篇使用官方 3.8 端口映射教程中的普通 SubTree 写法:
<SubTree ID="NavigateLeg" target="pickup_goal" reached="pickup_reached"/>
这里属性名 target、reached 是子树局部 Blackboard 的键,属性值 pickup_goal、pickup_reached 是父 Blackboard 的键名。
它们建立映射关系,子树对 reached 的写入会反映到主树的 pickup_reached。
| 所在位置 | 写法 | 含义 |
|---|---|---|
| 普通节点端口 | goal="{target}" |
从当前 Blackboard 的 target 读取输入 |
3.8 的普通 SubTree 边界 |
target="pickup_goal" |
将子树的 target 映射到父树的 pickup_goal |
| 普通节点的输出端口 | reached_goal="{reached}" |
写入当前 Blackboard 的 reached,再由映射连接到父树 |
不要把本篇 SubTree 的映射值改成 {pickup_goal}。
3.8.7 的普通 SubTree 分支直接把属性值当作父键名,不会替你去掉花括号,之后就可能找不到正确输入。
源码中还提供了使用不同映射语义的 SubTreePlus,本篇统一使用普通 SubTree,按表中的边界规则填写。
子树边界的键名也不等于 C++ 叶节点的端口名。
本例经过的是:goal 端口 → 局部 target → 父树 pickup_goal,每一层分别声明自己的接口。
3.建立组合任务工程
本篇直接复制整个 bt_running 文件夹,再把副本重命名为 bt_mission,保留原来的持续任务工程供后续对照。
在系统文件管理器中完成下面几步:
- 打开
bt_running所在的父目录,复制并粘贴整个文件夹,将副本重命名为bt_mission,让两个工程位于同一层目录。 - 在新副本
bt_mission中,删除复制过来的build和install文件夹(如果存在)。其中的构建缓存和安装结果属于旧工程,新工程稍后会重新生成。 - 在 VSCode 中选择 文件 → 新建窗口,再选择 文件 → 打开文件夹,打开新的
bt_mission。
框架继续来自 CMake工程模板,保留 C++20、CMake 3.22、预设格式版本 3,以及优先查找 BehaviorTree.CPP 3.8.7 的依赖配置。 Ubuntu 22.04 的 CMake 3.22.1 可以继续使用。复用 Humble 安装包时,VSCode 的配置、运行和调试进程仍需继承 ROS 2 环境。
在顶层 CMakeLists.txt 中,将项目声明改为:
project(bt_mission VERSION 1.0.0 LANGUAGES C CXX)
src/main.cpp 和 trees/mission.xml 已经随工程复制过来,稍后用本篇内容替换。
在左侧资源管理器的 trees 目录中新建 navigation.xml,用于保存导航子树。参与本篇构建的目录为:
bt_mission/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/
│ ├── ProjectOptions.cmake
│ └── BehaviorTree.cmake
├── src/
│ ├── CMakeLists.txt
│ └── main.cpp
└── trees/
├── mission.xml 任务入口,引用导航子树
└── navigation.xml 可复用的 NavigateLeg 定义
BT_XML_PATH 仍指向 trees/mission.xml,不需要因为增加了第二个 XML 而修改 CMake。

4.实现任务所需的节点
导航节点沿用上一篇的生命周期,在此基础上增加“前几次尝试失败”的模拟参数,以及成功后写出到达目标的输出端口。
| 节点 | 类型 | 主要职责 |
|---|---|---|
BatteryOK |
ConditionNode |
读取电量,达到 30 时返回成功 |
ChargeBattery |
SyncActionNode |
将模拟电量更新为 80 |
NavigateToGoal |
StatefulActionNode |
分多次检查导航,成功时写出 reached_goal |
ReportMission |
SyncActionNode |
读取两段导航的结果并报告完成 |
ReportFailure |
简单同步动作 | 输出失败信息,表示报告动作已执行 |
用以下完整程序替换 src/main.cpp 的原有内容,再保存。
四个节点类和一个简单动作都在这个文件中,方便先把组合任务跑通。
#include "behaviortree_cpp_v3/bt_factory.h"
#include <chrono>
#include <cmath>
#include <iostream>
#include <stdexcept>
#include <string>
class BatteryOK : public BT::ConditionNode
{
public:
BatteryOK(const std::string& name, const BT::NodeConfiguration& config)
: BT::ConditionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<double>("level")};
}
BT::NodeStatus tick() override
{
auto level = getInput<double>("level");
if (!level)
{
throw BT::RuntimeError("Cannot read input [level]: ", level.error());
}
if (!std::isfinite(level.value()) || level.value() < 0.0 || level.value() > 100.0)
{
throw std::runtime_error("battery level must be between 0 and 100");
}
const auto result = level.value() >= 30.0
? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
std::cout << "[BatteryOK] level=" << level.value()
<< " -> " << BT::toStr(result) << '\n';
return result;
}
};
class ChargeBattery : public BT::SyncActionNode
{
public:
ChargeBattery(const std::string& name, const BT::NodeConfiguration& config)
: BT::SyncActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::OutputPort<double>("level")};
}
BT::NodeStatus tick() override
{
// 本例把充电简化为一次数据更新,真实充电应实现为持续任务。
auto written = setOutput("level", 80.0);
if (!written)
{
throw BT::RuntimeError("Cannot write output [level]: ", written.error());
}
std::cout << "[ChargeBattery] level=80\n";
return BT::NodeStatus::SUCCESS;
}
};
class NavigateToGoal : public BT::StatefulActionNode
{
public:
NavigateToGoal(const std::string& name, const BT::NodeConfiguration& config)
: BT::StatefulActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("goal"),
BT::InputPort<int>("ticks", 2, "每次导航尝试的检查次数"),
BT::InputPort<int>("fail_attempts", 1, "模拟前几次尝试失败"),
BT::OutputPort<std::string>("reached_goal")};
}
BT::NodeStatus onStart() override
{
auto goal = getInput<std::string>("goal");
auto ticks = getInput<int>("ticks");
auto failures = getInput<int>("fail_attempts");
if (!goal)
{
throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
}
if (!ticks)
{
throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
}
if (!failures)
{
throw BT::RuntimeError("Cannot read input [fail_attempts]: ", failures.error());
}
if (goal.value().empty() || ticks.value() < 1 || failures.value() < 0)
{
throw std::runtime_error("invalid navigation simulation parameters");
}
goal_ = goal.value();
required_ticks_ = ticks.value();
failed_attempts_ = failures.value();
completed_ticks_ = 0;
// 每个节点实例分别保留尝试次数,用于模拟先失败、后成功。
++attempt_;
std::cout << "[NavigateToGoal] start goal=" << goal_
<< ", attempt=" << attempt_
<< ", completed_ticks=" << completed_ticks_ << '\n';
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
++completed_ticks_;
std::cout << "[NavigateToGoal] check goal=" << goal_
<< ", attempt=" << attempt_
<< ", completed_ticks=" << completed_ticks_
<< '/' << required_ticks_ << '\n';
if (completed_ticks_ < required_ticks_)
{
return BT::NodeStatus::RUNNING;
}
if (attempt_ <= failed_attempts_)
{
std::cout << "[NavigateToGoal] failed goal=" << goal_
<< ", attempt=" << attempt_ << '\n';
return BT::NodeStatus::FAILURE;
}
auto written = setOutput("reached_goal", goal_);
if (!written)
{
throw BT::RuntimeError("Cannot write output [reached_goal]: ", written.error());
}
std::cout << "[NavigateToGoal] arrived goal=" << goal_
<< ", attempt=" << attempt_ << '\n';
return BT::NodeStatus::SUCCESS;
}
void onHalted() override
{
std::cout << "[NavigateToGoal] halted goal=" << goal_ << '\n';
completed_ticks_ = 0;
}
private:
std::string goal_;
int required_ticks_ = 0;
int completed_ticks_ = 0;
int failed_attempts_ = 0;
int attempt_ = 0;
};
class ReportMission : public BT::SyncActionNode
{
public:
ReportMission(const std::string& name, const BT::NodeConfiguration& config)
: BT::SyncActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("pickup"),
BT::InputPort<std::string>("dropoff")};
}
BT::NodeStatus tick() override
{
auto pickup = getInput<std::string>("pickup");
auto dropoff = getInput<std::string>("dropoff");
if (!pickup)
{
throw BT::RuntimeError("Cannot read input [pickup]: ", pickup.error());
}
if (!dropoff)
{
throw BT::RuntimeError("Cannot read input [dropoff]: ", dropoff.error());
}
std::cout << "[ReportMission] pickup=" << pickup.value()
<< ", dropoff=" << dropoff.value() << '\n';
return BT::NodeStatus::SUCCESS;
}
};
BT::NodeStatus ReportFailure(BT::TreeNode&)
{
std::cout << "[ReportFailure] transport mission failed\n";
return BT::NodeStatus::SUCCESS;
}
int main()
{
try
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<BatteryOK>("BatteryOK");
factory.registerNodeType<ChargeBattery>("ChargeBattery");
factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
factory.registerNodeType<ReportMission>("ReportMission");
factory.registerSimpleAction("ReportFailure", ReportFailure);
auto blackboard = BT::Blackboard::create();
blackboard->set("battery", 20.0);
blackboard->set("pickup_goal", std::string("loading_area"));
blackboard->set("dropoff_goal", std::string("unloading_area"));
auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);
int root_tick = 1;
std::cout << "[main] tick=" << root_tick << " begin\n";
BT::NodeStatus result = tree.tickRoot();
std::cout << "[main] tick=" << root_tick
<< " -> " << BT::toStr(result) << '\n';
while (result == BT::NodeStatus::RUNNING)
{
tree.sleep(std::chrono::milliseconds(100));
++root_tick;
std::cout << "[main] tick=" << root_tick << " begin\n";
result = tree.tickRoot();
std::cout << "[main] tick=" << root_tick
<< " -> " << BT::toStr(result) << '\n';
}
std::cout << "Tree result: " << BT::toStr(result) << '\n';
if (result == BT::NodeStatus::SUCCESS)
{
std::cout << "[main] pickup_reached="
<< blackboard->get<std::string>("pickup_reached") << '\n';
std::cout << "[main] dropoff_reached="
<< blackboard->get<std::string>("dropoff_reached") << '\n';
}
return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}
catch (const std::exception& error)
{
std::cerr << "Error: " << error.what() << '\n';
return 2;
}
}
ticks 和 fail_attempts 都是本例的模拟参数。ticks="2" 表示一次导航在两次 onRunning() 检查后给出结果;fail_attempts="1" 表示同一个节点实例的第一次尝试失败,后续尝试成功。节点的 attempt_ 从 0 开始,在每次 onStart() 中递增。
attempt_ 是教学模拟器自己的计数,和 Retry 装饰节点内部的重试计数不是同一个变量。这里故意保留它,才能在重启导航后模拟“第一次失败、第二次成功”。每次 Launch 都会创建新的树;如果实际程序复用同一棵树处理多个任务,应明确什么时候重置业务成员和结果条目。
4.1.对照本例中的 API 参数
本篇主要组合前面已经使用过的接口。注册和端口接口的原型见 第一个完整程序,外部 Blackboard 的读写见 节点之间传数据,回调与驱动循环见 持续任务与中止。对照本例时,重点看每个实参对应哪一层名称:
| 调用 | 参数含义 | 返回值 |
|---|---|---|
factory.registerNodeType<NavigateToGoal>("NavigateToGoal") |
模板参数是 C++ 节点类;第一个函数参数是 XML 中使用的注册名 | void,注册类型,尚未启动导航 |
factory.registerSimpleAction("ReportFailure", ReportFailure) |
第一个参数是注册名;第二个参数是回调函数本身,树执行到 <ReportFailure/> 时才调用它 |
注册函数返回 void;回调另行返回自己的节点状态 |
blackboard->set("battery", 20.0) |
第一个参数是 Blackboard 键,第二个参数是值;这里省略 <double>,由 20.0 推导类型 |
void,写入电量 |
factory.createTreeFromFile(BT_XML_PATH, blackboard) |
第一个参数是入口 XML 路径;第二个参数是根树使用的 Blackboard 共享指针 | BT::Tree,包含创建好的节点实例 |
setOutput("reached_goal", goal_) |
第一个参数是当前节点的输出端口名;第二个参数是保存的目标字符串 | BT::Result,本例检查写出是否成功 |
blackboard->get<std::string>("pickup_reached") |
模板参数指定读取类型;第一个函数参数是根 Blackboard 中的结果键 | std::string 值;键不存在或类型不兼容时抛出异常 |
setOutput() 的 reached_goal 是节点端口名,get() 的 pickup_reached 是主程序要读取的 Blackboard 键,两者由后面的 XML 映射连接。ReportFailure() 回调返回 SUCCESS 表示报告动作完成;整项运输任务的失败结果由 XML 中包裹它的 ForceFailure 保留,不能把回调返回值与注册函数返回值混为一谈。
5.用两个 XML 文件组织任务
5.1.trees/mission.xml
用下面的 XML 替换 trees/mission.xml 的原有内容,再保存。
<root main_tree_to_execute="MainTree">
<include path="navigation.xml"/>
<BehaviorTree ID="MainTree">
<Sequence name="transport_job">
<Fallback name="ensure_battery">
<BatteryOK level="{battery}"/>
<ChargeBattery level="{battery}"/>
</Fallback>
<Fallback name="run_or_report_failure">
<Sequence>
<SubTree ID="NavigateLeg"
target="pickup_goal" reached="pickup_reached"/>
<SubTree ID="NavigateLeg"
target="dropoff_goal" reached="dropoff_reached"/>
<ReportMission pickup="{pickup_reached}"
dropoff="{dropoff_reached}"/>
</Sequence>
<ForceFailure>
<ReportFailure/>
</ForceFailure>
</Fallback>
</Sequence>
</BehaviorTree>
</root>
主树中两次 SubTree 使用相同的 ID="NavigateLeg",但分别映射到取货点和放货点的父键。NavigateLeg 是 XML 中定义的行为树名称,NavigateToGoal 才是注册到工厂的 C++ 叶节点名称。
5.2.trees/navigation.xml
将下面的内容保存到新建的 trees/navigation.xml。
<root>
<BehaviorTree ID="NavigateLeg">
<RetryUntilSuccessful num_attempts="2">
<NavigateToGoal goal="{target}" reached_goal="{reached}"
ticks="2" fail_attempts="1"/>
</RetryUntilSuccessful>
</BehaviorTree>
</root>
<include path="navigation.xml"/> 会在加载主 XML 时引入子树定义,路径相对于包含它的 mission.xml 所在目录。主程序通过 BT_XML_PATH 的完整路径加载入口,所以从不同工作目录启动,仍能找到同目录的 navigation.xml。
C++ 中已经注册了叶节点;两个 XML 负责定义和连接任务。工厂加载入口文件时会处理 include 和 BehaviorTree ID,因此不需要再为 NavigateLeg 编写一个 C++ 节点类。
截图预留:主树和导航子树。 展示两个 XML 文件,以及两处
SubTree映射到不同父键的位置。
6.在 VSCode 中运行完整任务
保存所有文件,选择 Linux Debug,执行 Configure → Build,将运行目标设为 bt_mission,再点击 Launch。日志中的 [main] tick=N begin 和同号的返回状态标出一次根 tick 的范围;导航的 check 行记录自增后的检查次数。完整输出为:
[main] tick=1 begin
[BatteryOK] level=20 -> FAILURE
[ChargeBattery] level=80
[NavigateToGoal] start goal=loading_area, attempt=1, completed_ticks=0
[main] tick=1 -> RUNNING
[main] tick=2 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=1/2
[main] tick=2 -> RUNNING
[main] tick=3 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=loading_area, attempt=1
[NavigateToGoal] start goal=loading_area, attempt=2, completed_ticks=0
[main] tick=3 -> RUNNING
[main] tick=4 begin
[NavigateToGoal] check goal=loading_area, attempt=2, completed_ticks=1/2
[main] tick=4 -> RUNNING
[main] tick=5 begin
[NavigateToGoal] check goal=loading_area, attempt=2, completed_ticks=2/2
[NavigateToGoal] arrived goal=loading_area, attempt=2
[NavigateToGoal] start goal=unloading_area, attempt=1, completed_ticks=0
[main] tick=5 -> RUNNING
[main] tick=6 begin
[NavigateToGoal] check goal=unloading_area, attempt=1, completed_ticks=1/2
[main] tick=6 -> RUNNING
[main] tick=7 begin
[NavigateToGoal] check goal=unloading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=unloading_area, attempt=1
[NavigateToGoal] start goal=unloading_area, attempt=2, completed_ticks=0
[main] tick=7 -> RUNNING
[main] tick=8 begin
[NavigateToGoal] check goal=unloading_area, attempt=2, completed_ticks=1/2
[main] tick=8 -> RUNNING
[main] tick=9 begin
[NavigateToGoal] check goal=unloading_area, attempt=2, completed_ticks=2/2
[NavigateToGoal] arrived goal=unloading_area, attempt=2
[ReportMission] pickup=loading_area, dropoff=unloading_area
[main] tick=9 -> SUCCESS
Tree result: SUCCESS
[main] pickup_reached=loading_area
[main] dropoff_reached=unloading_area

初始电量为 20,因此先检查失败,再由 ChargeBattery 更新为 80。两段导航分别经历第一次失败、第二次成功,最后 ReportMission 和主程序都能读到两个到达结果。
| 执行位置 | 本次使用的数据 | 结果 |
|---|---|---|
| 电量 Fallback | 主树的 battery |
低电量时更新到 80 |
| 第一次 NavigateLeg | pickup_goal 映射到局部 target |
写出 pickup_reached |
| 第二次 NavigateLeg | dropoff_goal 映射到另一个局部 target |
写出 dropoff_reached |
| ReportMission | 主树的两个结果键 | 输出任务完成信息 |
| main | 从根 Blackboard 读取两个结果 | 确认跨子树的数据传递 |
电量检查是这次任务的启动准备。进入导航后,外层普通 Sequence 会从正在执行的位置继续,电量检查不会每次都重跑。如果任务要求导航期间持续检查某个条件,可以沿用上一篇的 ReactiveSequence 方式组织检查层。
7.从 main() 走到第一次根 tick
先把“准备一棵树”和“驱动这棵树”分开。程序进入 main() 后,按下面的顺序准备任务:
| 执行位置 | 已完成的工作 | 此时是否已经开始导航 |
|---|---|---|
| 创建工厂并注册节点 | 让工厂知道 XML 中的节点名称对应哪个 C++ 类型或回调 | 尚未开始,也没有因此调用 onStart() |
| 创建并填写 Blackboard | 设置 battery=20、取货点 loading_area 和放货点 unloading_area |
尚未开始 |
createTreeFromFile() |
读取主 XML 和被包含的 XML,创建节点实例及两份导航子树,建立 Blackboard 映射 | 尚未开始 |
第一次 tree.tickRoot() |
从根节点进入控制节点,再执行本次能够到达的叶节点 | 此时才会启动取货导航 |
树创建完成、第一次根 tick 尚未执行时,两份 NavigateToGoal 对象的 goal_ 都是空字符串,四个整数成员也都是 0。XML 中的 ticks="2" 和 fail_attempts="1" 不会直接给这些 C++ 成员赋值;本例要等进入 onStart(),调用 getInput() 后,才把它们保存到成员中。
两处 SubTree 在建树时就分别实例化了导航节点。第一次 tick 不会再创建一个取货节点,重试也不会重新构造节点;后面始终是在已有对象上切换状态、调用回调。
7.1.先分清本例中的几种次数
| 名称 | 谁维护它 | 本例中的准确含义 |
|---|---|---|
root_tick |
main() |
调用 tree.tickRoot() 的序号,从 1 开始打印 |
completed_ticks_ |
当前导航节点 | 本次尝试已经执行多少次 onRunning();每次 onStart() 都清零 |
required_ticks_ |
当前导航节点 | 从 ticks="2" 读到的阈值;本次尝试检查两次后,才判断成功或失败 |
attempt_ |
当前导航节点 | 这个对象第几次执行 onStart();每次启动加一 |
failed_attempts_ |
当前导航节点 | 从 fail_attempts="1" 读到的模拟规则;attempt_ <= 1 时模拟失败 |
num_attempts="2" |
当前 Retry 节点 | 允许最多两次总尝试,即首次尝试加一次重试 |
completed_ticks_ 不包含 onStart() 那次调用,也不是整棵树累计 tick 的次数。第一次根 tick 启动取货导航后,取货节点是 attempt_=1、completed_ticks_=0,并不是已经完成了一次检查。
Retry 内部另有失败计数 try_count_:当前这轮开始时为 0,孩子返回一次 FAILURE 后变为 1,孩子持续返回 RUNNING 时不会消耗新的尝试次数。孩子成功,或失败次数达到上限、Retry 结束这一轮时,它会把自己的计数清零;导航对象的 attempt_ 则仍然保留。这两个计数用途不同,不应当成一个变量。
8.按九次根 tick 推演完整任务
保持本篇默认参数:初始电量 20、ticks="2"、fail_attempts="1"、num_attempts="2"。下面每一行对应主程序调用一次 tree.tickRoot(),也对应日志中一组同号的 begin 和返回状态。
两列成员值统一写成 (attempt_, completed_ticks_),记录的是该次根 tick 返回时的值。取货和放货属于两个不同对象,必须分别观察。
| 根 tick | 本次按顺序发生的事情 | 取货节点成员值 | 放货节点成员值 | 根返回 |
|---|---|---|---|---|
| 1 | 检查电量失败,充电到 80;启动取货导航,onStart() 返回 RUNNING |
(1, 0) |
(0, 0) |
RUNNING |
| 2 | 取货导航进入 onRunning(),检查次数从 0 加到 1,尚未达到 2 |
(1, 1) |
(0, 0) |
RUNNING |
| 3 | 取货检查次数加到 2,第一次尝试失败;Retry 重置孩子并立即再次调用 onStart() |
(2, 0) |
(0, 0) |
RUNNING |
| 4 | 取货的第二次尝试进入 onRunning(),检查次数加到 1 |
(2, 1) |
(0, 0) |
RUNNING |
| 5 | 取货检查次数加到 2,成功写出结果;序列继续启动放货导航 | (2, 2) |
(1, 0) |
RUNNING |
| 6 | 放货导航进入 onRunning(),检查次数加到 1 |
(2, 2) |
(1, 1) |
RUNNING |
| 7 | 放货检查次数加到 2,第一次尝试失败;Retry 立即重新启动它 | (2, 2) |
(2, 0) |
RUNNING |
| 8 | 放货的第二次尝试进入 onRunning(),检查次数加到 1 |
(2, 2) |
(2, 1) |
RUNNING |
| 9 | 放货检查次数加到 2,成功写出结果;执行 ReportMission,整棵树成功 |
(2, 2) |
(2, 2) |
SUCCESS |
表中的成员值不等于节点状态。例如,取货成功后,父节点可以把它的状态重置为 IDLE,但 attempt_=2、completed_ticks_=2 仍保存在对象里。框架重置执行状态,不会自动清空我们自己声明的 C++ 成员。
8.1.第一次根 tick 为什么做了不止一件事
第一次 tree.tickRoot() 从根 Sequence 开始,执行路径是:
transport_job:Sequence
├── ensure_battery:Fallback
│ ├── BatteryOK:读取 20,返回 FAILURE
│ └── ChargeBattery:写入 80,返回 SUCCESS
│ → ensure_battery 返回 SUCCESS
└── run_or_report_failure:Fallback
└── 导航 Sequence
└── 取货 SubTree → Retry → NavigateToGoal::onStart()
→ attempt_:0 → 1
→ completed_ticks_:清零为 0
→ 返回 RUNNING,一层层传回根节点
这里电量条件的 FAILURE 被它的 Fallback 处理了,根节点没有因此返回失败。充电同步完成,外层 Sequence 就在同一次调用中继续启动导航。一次根 tick 是从根开始推进一次执行过程,并不是只执行一个叶节点。
onStart() 返回 RUNNING 后,本次调用不会继续进入这个导航节点的 onRunning()。主程序收到根的 RUNNING,等待 100 ms,再发起下一次根 tick;第二次才执行取货导航的 onRunning()。
从第二次开始,普通 Sequence 会继续正在运行的孩子,外层继续导航分支,内层继续当前导航子树。电量检查不会每次都重跑。100 ms 是本例主循环两次调用之间的等待时间,ticks="2" 本身不表示两秒,也不表示启动后只需两次根 tick。
8.2.第三次根 tick:失败后为什么又进入 onStart()
进入第三次根 tick 时,取货导航仍是 RUNNING,成员是 attempt_=1、completed_ticks_=1。这次调用按下面的顺序执行:
onRunning()先执行++completed_ticks_,值从1变成2。2 < required_ticks_不成立,继续判断attempt_ <= failed_attempts_,即1 <= 1,成立,于是导航返回FAILURE。- Retry 收到孩子的失败,将自己的失败计数从
0加到1。最多允许两次尝试,所以还有一次机会。 - Retry 将失败孩子的状态恢复为
IDLE,在本次根 tick 中再次执行这个孩子。同一个导航对象因此进入onStart(),把completed_ticks_从2清成0,再把attempt_从1加到2。 - 新的
onStart()返回RUNNING,Retry 和上层控制节点也返回RUNNING,控制权回到main()。
所以第三次的日志会在同一组 tick=3 中出现 failed 和新的 start。叶节点曾经失败,不代表这一次根 tick 的最终结果也是失败。 本次失败由 Retry 处理,任务 Fallback 没有收到最终的 FAILURE,因此也不会执行 ReportFailure。
注意 completed_ticks_ 在这一次根 tick 内经历了 1 → 2 → 0,表格只记录最后的 0。attempt_ 没有清零,因为本例用它保留“同一个对象第一次失败、第二次成功”的模拟进度。
8.3.第五次根 tick:取货成功后为什么立刻启动放货
第四次已经把取货节点推进到 attempt_=2、completed_ticks_=1。第五次进入 onRunning() 后,检查次数加到 2;此时 2 <= failed_attempts_,即 2 <= 1,不成立,因此通过 setOutput() 写出到达结果并返回 SUCCESS。
这个输出先写入取货子树的局部 reached,再通过映射更新主树的 pickup_reached。Retry 返回成功,导航 Sequence 接着执行第二个孩子,于是在同一次根 tick 中调用放货导航的 onStart()。
放货导航是另一个对象,它此前的 attempt_ 是 0,所以启动后变为 1,不是沿着取货节点加成 3。它的 completed_ticks_ 为 0,返回 RUNNING,整次根 tick 也就返回 RUNNING。
第六到第八次按相同方式推进放货导航。第九次放货成功,写出 dropoff_reached,随后 ReportMission 在本次调用中读取两个结果并返回成功。根返回 SUCCESS 后,while 循环结束,main() 打印最终状态与 Blackboard 结果,返回退出码 0;程序不会再执行第十次根 tick。
9.RUNNING、失败重试和中止分别进入哪个回调
StatefulActionNode 根据当前执行状态选择回调:IDLE 时进入 onStart(),RUNNING 时进入 onRunning()。要判断是否重新开始,既要看孩子返回什么,也要看父节点随后怎样处理它。
| 发生的情况 | 谁处理后续流程 | 本例的回调与成员变化 |
|---|---|---|
导航返回 RUNNING |
父节点保留当前执行位置,等待后续根 tick | 下次进入 onRunning(),attempt_ 不变,检查次数继续增加 |
导航返回 FAILURE,Retry 还有预算 |
Retry 记录失败,将孩子恢复为 IDLE 并再次执行 |
再进 onStart(),completed_ticks_ 清零,attempt_ 加一 |
导航返回 FAILURE,Retry 预算耗尽 |
Retry 向上返回失败,上层进入失败报告分支 | 不再启动这段导航,不会出现第三次 onStart() |
导航返回 SUCCESS |
父序列推进到下一个孩子 | 该次尝试结束;后面的导航使用另一个节点对象 |
正在 RUNNING 的导航被中止 |
例如外部调用 tree.haltTree(),或上层控制节点停止这个运行分支 |
进入 onHalted(),本例清零 completed_ticks_;以后再次启动才进入 onStart() |
返回 FAILURE 本身不会自动重试,也不会因此自动调用 onHalted()。 第三次和第七次 tick 中,Retry 处理的是已经返回失败的孩子,因此没有进入它的 onHalted()。正常默认流程没有主动中止运行中的导航,也就不会打印 halted。
本例的 onHalted() 没有把 attempt_ 清零。若中止后复用同一个对象,它下次进入 onStart() 时仍会在原来的 attempt_ 上加一。实际任务若要求“每轮任务重新计算尝试次数”,需要另外明确业务成员和结果条目的重置时机,不能只依赖节点状态变成 IDLE。
以上生命周期与重试行为可对照 3.8.7 的 action_node.cpp 和 retry_node.cpp。九次根 tick 的具体推演针对本篇程序及默认参数;换成同步失败的动作或其他驱动接口时,应重新分析一次调用会推进到哪里。
10.有限重试与失败报告各自负责一层
num_attempts="2" 表示最多两次总尝试,不是首次尝试之后再额外重试两次。
导航返回 RUNNING 时,装饰节点继续等待这一次尝试;只有导航返回 FAILURE,才会记录一次失败并决定是否重新开始。
前面的第 3 次和第 7 次根 tick 已经展示了尚有预算时的处理:本例导航在 onRunning() 中失败,Retry 随即在同一次根 tick 内启动下一次尝试;新的 onStart() 返回 RUNNING 后,控制权回到主循环。下面再看预算耗尽时,失败如何一路传递到根节点。
将 navigation.xml 中的 num_attempts="2" 改为 num_attempts="1",只保存 XML,再点击 Launch。此时第一次失败已经耗尽次数,输出为:
[main] tick=1 begin
[BatteryOK] level=20 -> FAILURE
[ChargeBattery] level=80
[NavigateToGoal] start goal=loading_area, attempt=1, completed_ticks=0
[main] tick=1 -> RUNNING
[main] tick=2 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=1/2
[main] tick=2 -> RUNNING
[main] tick=3 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=loading_area, attempt=1
[ReportFailure] transport mission failed
[main] tick=3 -> FAILURE
Tree result: FAILURE
这次只执行了三次根 tick:第 1 次启动取货,第 2 次完成第一次检查,第 3 次完成第二次检查并失败。Retry 的失败计数变为 1,已经达到 num_attempts="1",因此没有第二次 onStart();取货节点停留在 attempt_=1、completed_ticks_=2。
失败在第 3 次根 tick 内继续向上传递:取货 Retry 返回 FAILURE → 导航 Sequence 返回 FAILURE → 任务 Fallback 转到失败报告分支 → ReportFailure 返回 SUCCESS → 外面的 ForceFailure 返回 FAILURE → 任务 Fallback 和根 Sequence 最终都返回 FAILURE。
所以没有出现放货点导航,也没有执行 ReportMission。main() 收到根的 FAILURE 后退出循环,不读取成功结果,返回退出码 1。ReportFailure 的成功只表示报告已执行,并没有把失败的运输任务改成成功。
如果恢复 num_attempts="2",但将 fail_attempts 改为 2,取货的两次尝试都会失败,任务会在第 5 次根 tick 结束。此时取货节点是 attempt_=2、completed_ticks_=2;不会出现第三次启动,放货导航仍未执行。
失败报告分支外面包了一层 ForceFailure:
ForceFailure
└── ReportFailure 报告动作执行完毕,返回 SUCCESS
↓
该分支向任务 Fallback 返回 FAILURE
ReportFailure 返回成功表示日志报告已经执行;ForceFailure 保留的是运输任务失败的结果。这样完成失败处理后,整棵树仍返回 FAILURE,主程序也不会进入成功结果的读取分支。
11.分别修改电量、重试和映射来对照
每次对照只改一项,完成后恢复原值,再进行下一项。修改 C++ 后先 Build → Launch;只改 XML 时保存后直接 Launch。
| 修改位置 | 修改内容 | 观察到的结果 |
|---|---|---|
main() 初始化 |
battery 从 20.0 改为 80.0 |
电量检查直接成功,不执行模拟充电 |
navigation.xml |
fail_attempts 从 1 改为 0 |
两段导航都在第一次尝试成功 |
navigation.xml |
保持最多两次尝试,将 fail_attempts 改为 2 |
取货点两次都失败,任务失败,放货点不执行 |
mission.xml 的第一次 SubTree |
将 target="pickup_goal" 改成 target="{pickup_goal}" |
普通 SubTree 映射到错误的父键,读取 goal 报错 |
mission.xml 的第一次 SubTree |
删除 reached="pickup_reached" |
到达结果只写到该子树局部键,主树读取 pickup 时缺少输入 |
最后两种是数据配置错误,主程序打印 Error: 并返回 2。它们不同于导航尝试耗尽时的任务 FAILURE,后者正常返回任务失败码 1。
如果遗漏输出映射,子树内部仍然可以完成一次导航;真正缺失的是通往主树结果条目的连接。局部键名字相同,不代表所有子树自动共享同一个存储位置。
12.在断点中核对回调、计数和节点实例
恢复主示例的电量与 XML,先 Build,再设置下面几处断点并点击 Debug。断点通常停在所在语句执行之前,要区分“即将自增”和“已经自增”。
| 断点位置 | 要观察的内容 |
|---|---|
main() 第一次根 tick 的调用语句 |
此时 root_tick=1,树已创建,导航尚未启动;单步进入可沿控制节点找到第一次 onStart() |
onStart() 中 ++attempt_ 之后的打印语句 |
此时清零和自增已完成;取货两次暂停分别为 (attempt_, completed_ticks_)=(1, 0)、(2, 0) |
onRunning() 中 ++completed_ticks_ 之后的打印语句 |
此时本次自增已完成;同一次尝试两次暂停分别看到 completed_ticks_=1、2 |
main() 中根 tick 调用之后的状态打印语句 |
此时 result 已更新;第 3 次即使出现导航失败,根结果仍为 RUNNING,第 9 次才是 SUCCESS |
将 goal_、attempt_、completed_ticks_、required_ticks_、failed_attempts_ 和 this 加到监视窗口。在 main() 的栈帧中观察 root_tick、result;进入节点回调后,可以切回 main() 栈帧查看当前是第几次根 tick。第一次调用返回前,result 尚未完成初始化;在后续循环中,调用尚未返回时,result 仍是上一次赋值的值。要等调用返回、赋值完成,再判断本次根结果。
第三次根 tick 中,先在取货的 onRunning() 看到 (1, 2);继续执行,又会在同一个对象的 onStart() 中看到 (2, 0),此时 root_tick 仍是 3。第五次则先看到取货 (2, 2),继续后进入放货的 onStart(),看到 (1, 0),而 root_tick 仍是 5。
比较 this 指针:取货点的两次启动使用同一个对象,放货点的对象地址不同。这个过程对应的是“同一份子树定义,两次独立实例化”。复用的是结构和实现,任务成员与未映射的局部 Blackboard 条目分别属于各自实例。
完成工程后,分别运行电量充足、首次成功、重试耗尽、错误输入映射和遗漏输出映射等对照,检查返回状态、重试次数与错误信息。同时从不同工作目录启动程序,确认入口 XML 路径不依赖当前终端所在目录。
13.当前学习进度
到这里,教程已经给出了从最小程序到组合任务的完整实践路径:
| 文档 | 对应的实践主题 |
|---|---|
| 第一个完整程序 | 注册节点、加载 XML、执行最小树,再逐步增加条件和端口 |
| 节点之间传数据 | 自定义类型、输入输出与双向端口、外部 Blackboard |
| 持续任务与中止 | 多次 tick、StatefulActionNode、条件变化和主动中止 |
| 本篇 | 子树隔离与映射、多 XML 文件、有限重试和失败处理 |
基础编程实践的文档到此结束。
接下来进入工具实践:使用Groot1编辑与调试。沿用已有节点,在图形界面中编辑 XML、观察运行状态,再回放成功与中止的日志。