Skip to content

Wiki

持续任务与中止

About 24 min read

Existing English translations were reused. 116 current source blocks remain in Simplified Chinese, without triggering paid translation.

1.让导航任务跨越多次 tick

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

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

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

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

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

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

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() 中发起取消。

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

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

result = tree.tickExactlyOnce();

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

main() 调用 tree.tickExactlyOnce()
    ↓
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()。 这些规则对应 4.10.0 StatefulActionNode 的实现。

2.2.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() 还在后台继续执行。 这几个变量是节点对象的成员,对象仍然存在,所以保存的目标和计数可以留到下一次回调使用。

2.3.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 本身不会产生导航行为。

2.4.把四次根 tick 连起来看

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

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

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

2.5.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 来自前置条件失败及控制节点的执行规则。 如果接入真实导航,需要在这个回调里实现对应的取消或停止逻辑。


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

先完成本系列 节点之间传数据 中的 bt4_ports 工程。本篇复制这个文件夹,将副本重命名为 bt4_running,分别保存数据传递与持续任务两个练习。

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

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

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

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

project(bt4_running VERSION 1.0.0 LANGUAGES C CXX)

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

3.1.src/main.cpp

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

#include "behaviortree_cpp/bt_factory.h"

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

class NavigationAllowed : public BT::ConditionNode
{
public:
    NavigationAllowed(const std::string& name, const BT::NodeConfig& 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::NodeConfig& 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.tickExactlyOnce();
            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 则统计根节点执行次数,两者含义不同。

3.2.tree.sleep() 的参数与作用

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

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

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

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

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

3.3.trees/mission.xml

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

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

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


4.先观察一次完整的导航

保存全部文件,在 CMake Tools 中选择 Linux Debug,执行 Configure → Build,将运行目标设为 bt4_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(),会同时拖慢整棵树对条件变化的响应。

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


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

在 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() 中还需要实现对应的取消请求或停止逻辑,行为树本身不会替你完成设备控制。


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

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

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

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

截图预留:对照 Sequence 怎样处理同一个变化。 展示本节 v4.10.0 工程的对应操作或运行结果。

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

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

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

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

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


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

前面的中止由条件失败触发。 主程序也可以主动决定停止当前任务,并调用 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/bt_factory.h"

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

class NavigationAllowed : public BT::ConditionNode
{
public:
    NavigationAllowed(const std::string& name, const BT::NodeConfig& 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::NodeConfig& 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.tickExactlyOnce();
            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() 清理中断时的进度,分别明确处理下一次任务的起点。

7.1.停止树与查看状态的 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。注意,4.10.0 的 tickExactlyOnce() 在返回 SUCCESS 或 FAILURE 时也会把根节点保存的状态重置为 IDLE;判断一次 tick 的执行结果,应使用 tickExactlyOnce() 的返回值。

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

8.在 VSCode 中观察三个回调

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

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

#include "behaviortree_cpp/bt_factory.h"

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

class NavigationAllowed : public BT::ConditionNode
{
public:
    NavigationAllowed(const std::string& name, const BT::NodeConfig& 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::NodeConfig& 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.tickExactlyOnce();
            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.tickExactlyOnce(),帮助理解回调是怎样被库调用的。

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

结束这次调试,将 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 对照、主动停止和无效次数,并结合断点观察回调顺序与节点状态。


9.接入真实导航时替换什么

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

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

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


10.当前学习进度

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


v4 学习路线 · 上一篇:节点之间传数据 · 下一篇:组合实际任务