Skip to content

Wiki

组合实际任务

About 29 min read

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

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,保留原来的持续任务工程供后续对照。

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

  1. 打开 bt_running 所在的父目录,复制并粘贴整个文件夹,将副本重命名为 bt_mission,让两个工程位于同一层目录。
  2. 在新副本 bt_mission 中,删除复制过来的 build 和 install 文件夹(如果存在)。其中的构建缓存和安装结果属于旧工程,新工程稍后会重新生成。
  3. 在 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。这次调用按下面的顺序执行:

  1. onRunning() 先执行 ++completed_ticks_,值从 1 变成 2。
  2. 2 < required_ticks_ 不成立,继续判断 attempt_ <= failed_attempts_,即 1 <= 1,成立,于是导航返回 FAILURE。
  3. Retry 收到孩子的失败,将自己的失败计数从 0 加到 1。最多允许两次尝试,所以还有一次机会。
  4. Retry 将失败孩子的状态恢复为 IDLE,在本次根 tick 中再次执行这个孩子。同一个导航对象因此进入 onStart(),把 completed_ticks_ 从 2 清成 0,再把 attempt_ 从 1 加到 2。
  5. 新的 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、观察运行状态,再回放成功与中止的日志。