第 12.4.2.5 节

持续任务与中止

作者:董佳辉

让导航任务跨越多次 tick

对应 BehaviorTree.CPP 3.8 官方文档:Reactive and Asynchronous behaviors。实现细节参考 3.8.7 的 action_node.cpp、reactive_sequence.cpp 和 bt_factory.h。

前两篇实践分别完成了最小行为树和节点间的数据传递。 如果还没有运行,可以先完成它们,再进入本篇。 本篇仍使用模拟数据,集中解决一个问题:导航还没结束时,节点怎样立即返回,后面怎样继续检查,以及外部条件变化后怎样中止它。

先明确本篇的模拟规则:程序用计数器表示导航进度,约定检查三次就算任务完成。 目标暂时用 loading_area 这样的字符串表示。 这段程序没有控制机器人移动,也没有读取机器人的实际位置;日志中的 arrived 表示满足了我们设定的模拟完成条件。 先用这个简单规则看清行为树的调用过程,后面再说明真实导航应替换哪一部分。

本例中的 ticks="3" 表示执行三次 onRunning(),不是三秒,也不是主程序只调用三次 tickRoot()。 开始任务还要单独调用一次 onStart(),所以正常完成需要四次根 tick,后面会逐次展开。

ReactiveSequence
├── NavigationAllowed   每次根 tick 都重新检查是否允许导航
└── NavigateToGoal      开始一次任务,后续 tick 检查进度
                         ├── 未完成:RUNNING
                         ├── 已到达:SUCCESS
                         └── 被中止:执行 onHalted()

用三个回调管理一个持续任务

SyncActionNode 用于一次调用就能给出结果的动作,不能用它返回 RUNNING。 持续任务使用 StatefulActionNode,实现下面三个回调:

回调调用时机本例做什么返回值
onStart()节点从 IDLE 开始执行读取目标和所需次数,将计数清零RUNNING、SUCCESS 或 FAILURE
onRunning()当前节点为 RUNNING,再次收到 tick将计数加一,判断是否达到所需次数RUNNING、SUCCESS 或 FAILURE
onHalted()正在 RUNNING 的节点被中止打印中止信息,清零模拟计数void

三个回调都没有函数参数,由 StatefulActionNode 在对应时机调用,不需要在 main() 中直接调用。 它们的声明分别是 BT::NodeStatus onStart() override、BT::NodeStatus onRunning() override 和 void onHalted() override;override 表示实现基类规定的虚函数。 回调通过当前节点的 getInput() 读取输入,通过对象成员保存执行进度。

onStart() 返回 RUNNING 后,下一次 tick 才进入 onRunning();运行期间不会每次都重新调用 onStart()。 两个有返回值的回调都不能返回 IDLE。

StatefulActionNode 本身不会自动创建工作线程。 本例的回调都在主程序驱动行为树的线程里执行,所以每次回调要尽快返回。 真实导航可以在 onStart() 中发送一次请求,在 onRunning() 中检查请求结果,在 onHalted() 中发起取消。

从主程序的一次 tick 找到回调调用者

主程序驱动行为树时,调用的是:

result = tree.tickRoot();

tickRoot() 没有参数,返回这一次根 tick 的 BT::NodeStatus。 本例中,条件允许导航时,调用会沿着下面的路径传递:

main() 调用 tree.tickRoot()
    ↓
ReactiveSequence 从第一个子节点开始执行
    ↓
NavigationAllowed 检查是否允许导航
    ↓ 返回 SUCCESS
轮到 NavigateToGoal 节点
    ↓
库中 StatefulActionNode 的 tick() 检查导航节点的当前状态
    ├── IDLE    → 调用 onStart()
    └── RUNNING → 调用 onRunning()
    ↓
回调返回的状态沿父节点传回 main() 的 result

NavigateToGoal 继承 BT::StatefulActionNode,因此直接使用了基类已经写好的这套分派规则。 我们实现 onStart()、onRunning() 和 onHalted(),决定每个时机具体做什么。 基类调用这些虚函数时,会执行当前 NavigateToGoal 对象提供的实现。 声明中的 override 用于让编译器检查是否正确覆盖了基类虚函数;实际调用由库的执行流程发起,不是由这个关键字发起。

因此,第一次执行导航节点时,节点状态为 IDLE,进入 onStart();它返回 RUNNING 后,框架保存这个状态。 下一次根 tick 再次执行到导航节点,基类看到它仍为 RUNNING,才进入 onRunning()。 这些规则对应 3.8.7 StatefulActionNode 的实现。

onStart 准备一次任务,返回后把执行权交回去

先看本篇稍后要填写的导航 XML:

<NavigateToGoal goal="loading_area" ticks="3"/>

端口 goal 接收目标字符串,端口 ticks 接收模拟完成所需的运行检查次数。 onStart() 通过 getInput<std::string>("goal") 和 getInput<int>("ticks") 读取并检查它们,然后执行下面的关键语句。 这里先逐段理解,后面的完整程序再统一复制到工程中:

goal_ = goal.value();              // 保存本次目标:loading_area
required_ticks_ = ticks.value();   // 保存完成条件:检查三次
completed_ticks_ = 0;              // 开始新任务,目前检查了零次
return BT::NodeStatus::RUNNING;    // 报告任务已开始,还没有完成
成员变量本次保存的值含义
goal_"loading_area"本次导航目标
required_ticks_3达到多少次运行检查就算完成
completed_ticks_0已经执行过多少次运行检查

return RUNNING 会结束这一次函数调用,并把执行权交回父节点,最终回到主程序。 它报告的是“任务尚未完成”;函数返回后,没有一个 onStart() 还在后台继续执行。 这几个变量是节点对象的成员,对象仍然存在,所以保存的目标和计数可以留到下一次回调使用。

onRunning 每次加一,由节点自己判断是否成功

下面只保留 onRunning() 中与计数相关的语句,便于看清完成条件:

++completed_ticks_;  // 每进入一次 onRunning,就把已检查次数加一

if (completed_ticks_ >= required_ticks_)
{
    return BT::NodeStatus::SUCCESS;  // 达到约定次数,报告模拟任务完成
}
return BT::NodeStatus::RUNNING;      // 次数还不够,等待后续 tick 再检查

++completed_ticks_ 相当于把原来的计数加一,再保存回这个成员变量。 第一次进入 onRunning() 时,计数从 0 变成 1。接着判断 1 >= 3,条件不成立,就跳过大括号,执行最后的 return RUNNING。 第二次进入时,计数从 1 变成 2,仍然返回 RUNNING。 第三次进入时,计数从 2 变成 3,3 >= 3 成立,执行大括号里的 return SUCCESS,本次函数就结束了,不会再执行下面的 return RUNNING。

这里之所以返回成功,是因为我们编写节点时,把“计数达到三次”定义成了模拟任务的完成条件。 BehaviorTree.CPP 接收节点报告的状态,再按树的规则决定后续流程;它不会根据目标字符串自行判断机器人是否到达。 完整程序中的 std::cout 只负责打印日志,打印 arrived 本身不会产生导航行为。

把四次根 tick 连起来看

先保持条件始终允许导航。主程序每次调用 tree.tickRoot(),都会先检查 NavigationAllowed,再执行导航节点:

根 tick导航回调计数怎样变化完成条件本次根节点返回
第一次onStart()初始化为 0本次只准备任务RUNNING
第二次onRunning()0 → 11 >= 3,不成立RUNNING
第三次onRunning()1 → 22 >= 3,不成立RUNNING
第四次onRunning()2 → 33 >= 3,成立SUCCESS

前面三次根 tick 返回 RUNNING,主程序等待一下,再发起下一次 tick。 第四次导航返回 SUCCESS,而前面的条件也已成功,ReactiveSequence 因此返回 SUCCESS;主程序发现任务完成,退出这次循环。 一次启动,加上三次运行检查,共四次根 tick。 本例两次 tick 之间等待 100 毫秒,实际执行时间还受打印、调试暂停和系统调度影响,计数不代表固定时长。

onHalted 处理运行中的任务被中断

onHalted() 走的是另一条分支。当父节点要求停止导航,或主程序调用 tree.haltTree() 时,中止沿树传到导航节点。 基类用于处理中止的 StatefulActionNode::halt() 没有参数,返回 void;它发现导航节点仍为 RUNNING,才调用 onHalted()。 本例在回调里面打印中止信息,并把计数清零。

正常完成:
onStart() → onRunning() → onRunning() → onRunning()
 RUNNING      RUNNING       RUNNING       SUCCESS

中途中止:
onStart() → onRunning() → 收到中止要求 → onHalted()
 RUNNING      RUNNING

正常返回 SUCCESS 不会因此触发 onHalted();它不是每次任务结束都执行的公共收尾函数。 onHalted() 返回 void,负责取消和清理,不向父节点返回 SUCCESS 或 FAILURE。后面的条件中止示例中,树的 FAILURE 来自前置条件失败及控制节点的执行规则。 如果接入真实导航,需要在这个回调里实现对应的取消或停止逻辑。


沿用配置建立持续任务工程

本篇直接复制整个 bt_ports 文件夹,再把副本重命名为 bt_running,保留原来的数据传递工程供后续对照。

在系统文件管理器中完成下面几步:

  1. 打开 bt_ports 所在的父目录,复制并粘贴整个文件夹,将副本重命名为 bt_running,让两个工程位于同一层目录。
  2. 在新副本 bt_running 中,删除复制过来的 build 和 install 文件夹(如果存在)。其中的构建缓存和安装结果属于旧工程,新工程稍后会重新生成。
  3. 在 VSCode 中选择 文件 → 新建窗口,再选择 文件 → 打开文件夹,打开新的 bt_running。

使用的是前两篇已经准备好的 BT 练习版配置,框架来源见 CMake工程模板。Ubuntu 22.04 可以继续使用 CMake 3.22.1;Ninja、支持 C++20 的编译器和 VSCode CMake Tools 的准备方式与前两篇相同。复用 Humble 安装的行为树库时,配置、运行和调试进程仍需继承 ROS 2 环境。

cmake、src、trees 目录及其中的文件都已随工程复制过来,不需要重新建立。 稍后直接修改顶层项目名,并用本篇内容替换已有的 src/main.cpp 和 trees/mission.xml。参与本篇构建的目录为:

bt_running/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/
│   ├── ProjectOptions.cmake
│   └── BehaviorTree.cmake
├── src/
│   ├── CMakeLists.txt
│   └── main.cpp
└── trees/
    └── mission.xml

顶层 CMakeLists.txt 的项目名改为:

project(bt_running VERSION 1.0.0 LANGUAGES C CXX)

其他构建配置保持原样,BT_XML_PATH 会自动指向这个工程的 trees/mission.xml。

src/main.cpp

用下面的完整程序替换 src/main.cpp 的原有内容,再保存。 先保留 cancel_after_two_ticks = false,运行正常完成的情况。

#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

class NavigationAllowed : public BT::ConditionNode
{
public:
    NavigationAllowed(const std::string& name, const BT::NodeConfiguration& config)
        : BT::ConditionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<bool>("allowed")};
    }

    BT::NodeStatus tick() override
    {
        auto allowed = getInput<bool>("allowed");
        if (!allowed)
        {
            throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
        }
        std::cout << "[NavigationAllowed] allowed="
                  << (allowed.value() ? "true" : "false") << '\n';
        return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
    }
};

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", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();             // 保存本次目标,供后续回调使用
        required_ticks_ = ticks.value();  // 保存模拟完成所需的检查次数,本例为 3
        completed_ticks_ = 0;             // 每次开始新任务,都从零次检查开始
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        // 结束本次回调并报告“尚未完成”,后续 tick 再进入 onRunning。
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次进入这个回调,模拟检查次数就加一;函数返回后成员计数仍会保留。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        // 本例把“检查次数达到要求”当作完成条件,没有查询机器人的真实位置。
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            // 已满足模拟完成条件,向父节点报告成功,本次函数到这里结束。
            return BT::NodeStatus::SUCCESS;
        }
        // 尚未达到要求,结束本次检查,等待后续 tick 再进入这个回调。
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 运行中的任务收到中止要求时,基类才会调用这个回调。
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;          // 本次目标
    int required_ticks_ = 0;    // 模拟完成所需的运行检查次数,由 onStart 读取
    int completed_ticks_ = 0;   // 已完成的运行检查次数,可跨多次回调保存
};

int main()
{
    // 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。(用于测试onHalted()触发)
    const bool cancel_after_two_ticks = false;

    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");

        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            std::cout << "--- tick " << ++tick_count << '\n';
            // 从根驱动一次;执行到导航节点时,由基类按当前状态选择回调。
            result = tree.tickRoot();
            std::cout << "Root returned: " << BT::toStr(result) << '\n';
            if (result != BT::NodeStatus::RUNNING)
            {
                break;
            }

            if (cancel_after_two_ticks && tick_count == 2)
            {
                blackboard->set("navigation_allowed", false);
                std::cout << "[main] navigation is no longer allowed\n";
            }
            // 等待放在主循环中,节点回调本身不长时间阻塞。
            tree.sleep(std::chrono::milliseconds(100));
        }

        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

goal_、required_ticks_、completed_ticks_ 是节点对象的成员,能够跨越多次回调保存当前任务的数据。 main() 中的 tick_count 则统计根节点执行次数,两者含义不同。

tree.sleep() 的参数与作用

前一篇的 tickRootWhileRunning() 在库内部等待并重复 tick。 本篇需要在两次 tick 之间模拟条件变化,因此把循环写在 main() 中,并直接使用 BT::Tree 的等待接口:

void sleep(std::chrono::system_clock::duration timeout);

作用:让驱动树的当前线程等待一段时间,为下一次 tick 留出间隔。唯一的参数 timeout 是最长等待时长;std::chrono::milliseconds(100) 构造一个表示 100 毫秒 的时长对象,可以传给这个参数。这个等待也可以被库的唤醒通知提前结束。

返回值:void。tree.sleep() 本身不会执行节点,也不会创建导航线程;等待结束后,主程序仍需调用 tree.tickRoot()。后者没有参数,返回这一次根 tick 的 BT::NodeStatus。本例在结果为 RUNNING 时继续循环,在成功或失败时退出。

其余注册、端口和 Blackboard 接口沿用前两篇的含义。例如 blackboard->set("navigation_allowed", true) 中,第一个参数是键名,第二个参数是布尔值,模板类型由 true 推导为 bool;InputPort<int>("ticks", 3, "...") 的三个参数依次是端口名、默认值和说明。

trees/mission.xml

用下面的 XML 替换 trees/mission.xml 的原有内容,再保存。

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <ReactiveSequence>
            <NavigationAllowed allowed="{navigation_allowed}"/>
            <NavigateToGoal goal="loading_area" ticks="3"/>
        </ReactiveSequence>
    </BehaviorTree>
</root>

普通节点端口仍然使用前一篇的写法:条件节点从父程序写入的 {navigation_allowed} 读取布尔值,导航节点从固定文本读取目标和次数。


先观察一次完整的导航

保存全部文件,在 CMake Tools 中选择 Linux Debug,执行 Configure → Build,将运行目标设为 bt_running,再点击 Launch。本例的完整输出为:

--- tick 1
[NavigationAllowed] allowed=true
[NavigateToGoal] start goal=loading_area
Root returned: RUNNING
--- tick 2
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=1/3
Root returned: RUNNING
--- tick 3
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=2/3
Root returned: RUNNING
--- tick 4
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=3/3
[NavigateToGoal] arrived goal=loading_area
Root returned: SUCCESS
Tree result: SUCCESS

这组输出对应前面的四次根 tick 表:第一次只有 start,表示进入 onStart();后面三次出现 progress,分别表示进入三次 onRunning()。 最后一次先打印 progress=3/3,随后因为计数达到要求,打印 arrived 并返回 SUCCESS,所以主程序最后显示 Tree result: SUCCESS。 这次完成的是我们约定的模拟任务,整个过程中不会调用 onHalted()。

主程序在 RUNNING 时继续 tick,在 SUCCESS 或 FAILURE 时结束本次任务,并用 tree.sleep() 留出等待间隔。等待放在主循环里,节点检查本身保持简短;把几秒钟的等待直接塞进 onRunning(),会同时拖慢整棵树对条件变化的响应。

3.8.7 的 tickRoot() 在返回终态时还会重置根节点状态,因此应保存它的返回值作为本次任务结果。稍后查看 rootNode()->status() 时可能已经是 IDLE,不能据此否定刚刚返回的 SUCCESS 或 FAILURE。


条件改变后中止正在运行的导航

在 main() 中将已有的设置改为:

const bool cancel_after_two_ticks = true;

保存后 Build → Launch。主程序会在第二次根 tick 结束后,将 navigation_allowed 改成 false,模拟外部系统不再允许继续导航。输出为:

--- tick 1
[NavigationAllowed] allowed=true
[NavigateToGoal] start goal=loading_area
Root returned: RUNNING
--- tick 2
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=1/3
Root returned: RUNNING
[main] navigation is no longer allowed
--- tick 3
[NavigationAllowed] allowed=false
[NavigateToGoal] halted goal=loading_area, progress=1
Root returned: FAILURE
Tree result: FAILURE

第三次根 tick 先检查 NavigationAllowed。条件失败后,ReactiveSequence 中止还在运行的导航,触发 onHalted(),并将失败结果返回给主程序。此时导航不会再收到第三次根 tick 对应的运行检查,所以打印的进度仍是 1。

这次中止的先后关系为:

第二次根 tick 结束:导航仍为 RUNNING,已检查一次
    ↓
main() 将 Blackboard 中的 navigation_allowed 改为 false
    ↓ 此时只是更新数据
第三次根 tick:NavigationAllowed 读取新值,返回 FAILURE
    ↓
ReactiveSequence 中止正在运行的导航
    ↓
StatefulActionNode::halt() 调用 NavigateToGoal::onHalted()
    ↓
ReactiveSequence 返回 FAILURE,传回 main()

因此,写入 false 本身没有直接调用 onHalted();本例要等下一次根 tick 重新检查条件,才会触发中止。 onHalted() 中的日志报告被中断时的进度,随后清零计数;它没有返回 FAILURE,失败结果由前置条件和父控制节点决定。

本例的取消只是清理模拟计数。对真实机器人,onHalted() 中还需要实现对应的取消请求或停止逻辑,行为树本身不会替你完成设备控制。


对照 Sequence 怎样处理同一个变化

保持取消设置为 true,将 XML 中的 <ReactiveSequence> 和 </ReactiveSequence> 分别改为 <Sequence> 和 </Sequence>。 保存 XML 后直接 Launch,无需手动重新编译 C++。

这次虽然主程序仍会打印 navigation is no longer allowed,导航却继续执行到成功。 在本次任务的 RUNNING 期间,普通 Sequence 从正在运行的导航继续,不会每次都重新检查已经成功的前置条件。

本例中的行为SequenceReactiveSequence
正常导航期间检查条件开始时一次每次根 tick
第二次根 tick 后将条件改成 false导航继续完成下一次根 tick 中止导航
是否调用导航的 onHalted()否是
这次任务的结果SUCCESSFAILURE

这个差别发生在一次尚未结束的任务内。 普通 Sequence 结束并重置后,下一次任务仍会从前面的条件重新开始。

  • Sequence:直接继续导航,跳过前面已经成功的条件。
  • ReactiveSequence:从第一个子节点重新执行,再检查条件;如果条件变成 FAILURE,就中止正在运行的导航。

ReactiveSequence它每次都从第一个子节点开始执行,前一个成功了,才继续下一个。 比如前面放的不是条件检查,而是一个打印日志的动作:

ReactiveSequence
├── PrintMessage     打印“准备导航”,然后返回 SUCCESS
└── NavigateToGoal   执行导航,未完成时返回 RUNNING
第一次 tick:打印“准备导航” → 启动导航
第二次 tick:又打印“准备导航” → 检查导航进度
第三次 tick:又打印“准备导航” → 检查导航进度
……

完成对照后,将 XML 恢复为 ReactiveSequence,再进行后面的观察。


主程序主动退出时先停止树

前面的中止由条件失败触发。 主程序也可以主动决定停止当前任务,并调用 tree.haltTree(),让中止沿树传播。

保持 cancel_after_two_ticks = true,将 main() 中修改 navigation_allowed 的整个 if 代码块替换为:

            if (cancel_after_two_ticks && tick_count == 2)
            {
                std::cout << "[main] stop requested\n";
                tree.haltTree();
                std::cout << "Tree halted: " << BT::toStr(tree.rootNode()->status()) << '\n';
                return 3;
            }
#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

class NavigationAllowed : public BT::ConditionNode
{
public:
    NavigationAllowed(const std::string& name, const BT::NodeConfiguration& config)
        : BT::ConditionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<bool>("allowed")};
    }

    BT::NodeStatus tick() override
    {
        auto allowed = getInput<bool>("allowed");
        if (!allowed)
        {
            throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
        }
        std::cout << "[NavigationAllowed] allowed="
                  << (allowed.value() ? "true" : "false") << '\n';
        return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
    }
};

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", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();
        required_ticks_ = ticks.value();
        completed_ticks_ = 0;
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次只检查一步,立即把执行权交回行为树。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            return BT::NodeStatus::SUCCESS;
        }
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;
    int required_ticks_ = 0;
    int completed_ticks_ = 0;
};

int main()
{
    // 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。
    const bool cancel_after_two_ticks = true;

    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");

        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            std::cout << "--- tick " << ++tick_count << '\n';
            result = tree.tickRoot();
            std::cout << "Root returned: " << BT::toStr(result) << '\n';
            if (result != BT::NodeStatus::RUNNING)
            {
                break;
            }

            if (cancel_after_two_ticks && tick_count == 2)
            {
                // blackboard->set("navigation_allowed", false);
                // std::cout << "[main] navigation is no longer allowed\n";
                std::cout << "[main] stop requested\n";
                tree.haltTree();
                std::cout << "Tree halted: " << BT::toStr(tree.rootNode()->status()) << '\n';
                return 3;
            }
            // 等待放在主循环中,节点回调本身不长时间阻塞。
            tree.sleep(std::chrono::milliseconds(100));
        }

        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

保存后 Build → Launch。程序会在第二次根 tick 后直接停止树,输出末尾为:

[main] stop requested
[NavigateToGoal] halted goal=loading_area, progress=1
Tree halted: IDLE

这次没有再执行第三次根 tick,也没有让条件返回失败。主程序主动停止任务,并打印停止后的根状态 IDLE。示例用退出码 3 表示主动停止;正常成功为 0,任务失败为 1,异常报告为 2。

haltTree() 会重置节点状态,但不会自动清空 Blackboard,也不会替你重置所有 C++ 成员。 本例在 onStart() 初始化本次进度,在 onHalted() 清理中断时的进度,分别明确处理下一次任务的起点。

停止树与查看状态的 API

上面的停止代码使用了三个无参数成员函数:

接口所属对象返回值与作用
haltTree()BT::Tree返回 void,停止当前树并将节点状态重置为 IDLE;运行中的 StatefulActionNode 会进入 onHalted()
rootNode()BT::Tree返回 BT::TreeNode*,指向根节点;空树会返回 nullptr,本例已经成功创建树,因此可以访问根节点
status()BT::TreeNode返回 BT::NodeStatus,读取节点当前保存的状态,不执行 tick

tree.rootNode()->status() 先取得根节点指针,再读取状态,最后由 BT::toStr(...) 转成字符串用于打印。这里在 haltTree() 后读取,所以得到 IDLE。注意,3.8.7 的 tickRoot() 在返回 SUCCESS 或 FAILURE 时也会把根节点保存的状态重置为 IDLE;判断一次 tick 的执行结果,应使用 tickRoot() 的返回值。

ReactiveSequence          ← 根节点,rootNode() 指向它
├── NavigationAllowed     ← 它的第一个子节点
└── NavigateToGoal        ← 它的第二个子节点


在 VSCode 中观察三个回调

先观察正常完成时怎样从启动进入运行检查

恢复完整程序中原来的条件更新代码块,将 cancel_after_two_ticks 改为 false, XML 使用 ReactiveSequence。

#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

class NavigationAllowed : public BT::ConditionNode
{
public:
    NavigationAllowed(const std::string& name, const BT::NodeConfiguration& config)
        : BT::ConditionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<bool>("allowed")};
    }

    BT::NodeStatus tick() override
    {
        auto allowed = getInput<bool>("allowed");
        if (!allowed)
        {
            throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
        }
        std::cout << "[NavigationAllowed] allowed="
                  << (allowed.value() ? "true" : "false") << '\n';
        return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
    }
};

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", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();
        required_ticks_ = ticks.value();
        completed_ticks_ = 0;
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次只检查一步,立即把执行权交回行为树。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            return BT::NodeStatus::SUCCESS;
        }
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;
    int required_ticks_ = 0;
    int completed_ticks_ = 0;
};

int main()
{
    // 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。
    const bool cancel_after_two_ticks = false;

    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");

        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            std::cout << "--- tick " << ++tick_count << '\n';
            result = tree.tickRoot();
            std::cout << "Root returned: " << BT::toStr(result) << '\n';
            if (result != BT::NodeStatus::RUNNING)
            {
                break;
            }

            if (cancel_after_two_ticks && tick_count == 2)
            {
                blackboard->set("navigation_allowed", false);
                std::cout << "[main] navigation is no longer allowed\n";
                return 3;
            }
            // 等待放在主循环中,节点回调本身不长时间阻塞。
            tree.sleep(std::chrono::milliseconds(100));
        }

        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

选择 linux-debug,先 Build,然后在下面几行设置断点,点击 CMake Tools 的 Debug:

断点位置暂停时看什么对应前面的讲解
onStart() 中打印 start 的语句goal_ 为 "loading_area",required_ticks_ 为 3,completed_ticks_ 为 0第一次根 tick 只准备任务
onRunning() 中的 ++completed_ticks_第一次暂停时计数为 0,按 F10 执行这一行后变成 1每次运行检查只把计数加一
onRunning() 中的 if (completed_ticks_ >= required_ticks_)三次到这里时,计数依次为 1、2、3,要求始终为 3前两次条件不成立,第三次成立

断点暂停在某一行时,先看执行这一行之前的值;F10 表示单步执行,F5 表示继续运行到下一个断点。 同一次 onRunning() 中,先执行加一,再到达 if 判断,所以在这两个位置看到的计数会不同。

第一次和第二次到达 if 时,按 F10 会跳过成功分支,执行最后的 return RUNNING。 第三次到达 if 时,计数已经为 3;继续单步会进入打印 arrived 和 return SUCCESS 的分支。 它返回后,主程序拿到本次根 tick 的 SUCCESS,结束循环。

在回调断点暂停时,还可以展开 VSCode 的 调用堆栈(CALL STACK),查看是谁调用了当前回调。

从当前回调沿调用者向外看,可以对应到导航节点的执行、父控制节点,以及 main() 中的 tree.tickRoot(),帮助理解回调是怎样被库调用的。

再观察运行中的任务怎样被中止

结束这次调试,将 cancel_after_two_ticks 改为 true,保存后 Build。 保留 onStart() 和 onRunning() 的断点,并在 onHalted() 打印中止信息的语句处增加断点,再次点击 Debug。

这次先进入 onStart(),随后只进入一次 onRunning(),将计数加到 1。 第二次根 tick 结束后,主程序把允许标志改为 false;继续运行,第三次根 tick 会因为条件失败而进入 onHalted(),此时仍能看到目标为 loading_area、计数为 1。 在清零语句上单步后,计数变成 0。这次执行不会再走到 3 >= 3 的成功分支。

执行过程实际观察到的回调
关闭取消onStart() 一次,onRunning() 三次,最后返回 SUCCESS
开启取消onStart() 一次,onRunning() 一次,随后进入 onHalted(),本次根 tick 返回 FAILURE

要观察本篇的取消逻辑,使用条件变化或 haltTree() 的对照。 VSCode 的“停止调试”控制的是调试进程,不能用它来保证程序一定执行到 onHalted()。

全部观察完成后,恢复最初的完整程序和 XML,再将取消设置改回 false。 本篇完整程序及正常完成、条件中止、普通 Sequence 对照、主动停止、无效次数和断点观察,已在 Ubuntu 22.04 的 ysu_ros_humble 容器中,使用 C++20、CMake 3.22.1、GCC 11.4、BehaviorTree.CPP 3.8.7 验证。 测试使用临时工程和 Ninja,没有修改容器内已有练习工程。


接入真实导航时替换什么

本篇用 completed_ticks_ >= required_ticks_ 作为成功条件,是为了让回调过程容易观察。 接入真实导航时,应把这个计数模拟换成导航系统的请求和结果检查:

回调真实导航中对应的工作
onStart()发送一次导航请求,保存后续检查需要的任务信息
onRunning()检查该请求的最新结果;仍在执行就返回 RUNNING,确认到达后返回 SUCCESS,任务失败时返回 FAILURE
onHalted()对仍在执行的请求发起取消,并处理当前任务需要的清理

成功条件由节点实现决定,再通过返回值告诉行为树。 因此,理解本篇代码时,要把“库根据状态选择哪个回调”和“节点根据什么条件报告成功”分开看:前者由 StatefulActionNode 提供,后者由我们编写的导航节点实现。


当前学习进度

本篇补齐了持续动作的生命周期、主循环驱动和中止处理。 下一篇组合实际任务复用持续导航,把电量检查、子树数据映射、有限重试和多个 XML 文件组织成一个完整任务。