跳到正文

Wiki

组合实际任务

约 29 分钟阅读

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、观察运行状态,再回放成功与中止的日志。