Wiki
組合實際任務
本文由簡體中文內容確定性轉換,並受版本化術語表保護。
1.把已有節點組合成移動機械人任務
對應 BehaviorTree.CPP 3.8 官方文檔:Using SubTrees、Port Remapping 和 Use multiple XML files。實現細節參考 3.8.7 的 xml_parsing.cpp、blackboard.h 和 retry_node.cpp。
前面的實踐已經分別給出了端口、Blackboard 和持續動作。 本篇把它們組合成一個完整主題:機械人先確保電量足夠,再依次前往取貨點和放貨點;每段導航允許有限次嘗試,成功後報告結果,嘗試耗盡後報告任務失敗。
本篇的導航仍用回調計數模擬,充電簡化為一次電量更新,全部運行都不需要連接機械人。 先完成前面幾篇的實踐,再運行本篇,便於把注意力集中在任務組織與子樹邊界上。
MainTree
└── Sequence:一次运输任务
├── Fallback:确保电量(如果电量不OK,则充电)
│ ├── BatteryOK
│ └── ChargeBattery
└── Fallback:执行任务,或报告失败(如果任务执行失败,则报Failure)
├── Sequence
│ ├── SubTree:NavigateLeg,前往取货点
│ ├── SubTree:NavigateLeg,前往放货点
│ └── ReportMission
└── ForceFailure
└── ReportFailure
NavigateLeg:每处 SubTree 在建树时创建自己的节点和局部 Blackboard
└── RetryUntilSuccessful:最多尝试两次
└── NavigateToGoal:跨多次 tick 检查本次导航
電量檢查失敗時,Fallback 繼續執行模擬充電;充電成功後,外層 Sequence 才進入導航。
導航序列返回 RUNNING 時,任務 Fallback 也返回 RUNNING,不會提前執行失敗報告。
2.複用子樹時明確數據邊界
兩段導航執行規則相同,只是目標和結果條目不同,因此可以共用 NavigateLeg 這份 XML 定義。
兩處 SubTree 在建樹時分別創建不同的節點對象和局部 Blackboard,內部的 target、reached 名稱可以相同,再分別連接到主樹的不同條目。運行期間重複 tick 或重試會繼續使用已有實例。
主树 Blackboard 第一次 NavigateLeg 的局部 Blackboard
pickup_goal = loading_area ──映射──→ target
pickup_reached ←─映射── reached
主树 Blackboard 第二次 NavigateLeg 的局部 Blackboard
dropoff_goal = unloading_area ─映射──→ target
dropoff_reached ←─映射── reached
本篇使用官方 3.8 端口映射教程中的普通 SubTree 寫法:
<SubTree ID="NavigateLeg" target="pickup_goal" reached="pickup_reached"/>
這裏屬性名 target、reached 是子樹局部 Blackboard 的鍵,屬性值 pickup_goal、pickup_reached 是父 Blackboard 的鍵名。
它們建立映射關係,子樹對 reached 的寫入會反映到主樹的 pickup_reached。
| 所在位置 | 寫法 | 含義 |
|---|---|---|
| 普通節點端口 | goal="{target}" |
從當前 Blackboard 的 target 讀取輸入 |
3.8 的普通 SubTree 邊界 |
target="pickup_goal" |
將子樹的 target 映射到父樹的 pickup_goal |
| 普通節點的輸出端口 | reached_goal="{reached}" |
寫入當前 Blackboard 的 reached,再由映射連接到父樹 |
不要把本篇 SubTree 的映射值改成 {pickup_goal}。
3.8.7 的普通 SubTree 分支直接把屬性值當作父鍵名,不會替你去掉花括號,之後就可能找不到正確輸入。
源碼中還提供了使用不同映射語義的 SubTreePlus,本篇統一使用普通 SubTree,按表中的邊界規則填寫。
子樹邊界的鍵名也不等於 C++ 葉節點的端口名。
本例經過的是:goal 端口 → 局部 target → 父樹 pickup_goal,每一層分別聲明自己的接口。
3.建立組合任務工程
本篇直接複製整個 bt_running 文件夾,再把副本重命名為 bt_mission,保留原來的持續任務工程供後續對照。
在系統文件管理器中完成下面幾步:
- 打開
bt_running所在的父目錄,複製並粘貼整個文件夾,將副本重命名為bt_mission,讓兩個工程位於同一層目錄。 - 在新副本
bt_mission中,刪除複製過來的build和install文件夾(如果存在)。其中的構建緩存和安裝結果屬於舊工程,新工程稍後會重新生成。 - 在 VSCode 中選擇 文件 → 新建窗口,再選擇 文件 → 打開文件夾,打開新的
bt_mission。
框架繼續來自 CMake工程模板,保留 C++20、CMake 3.22、預設格式版本 3,以及優先查找 BehaviorTree.CPP 3.8.7 的依賴配置。 Ubuntu 22.04 的 CMake 3.22.1 可以繼續使用。複用 Humble 安裝包時,VSCode 的配置、運行和調試進程仍需繼承 ROS 2 環境。
在頂層 CMakeLists.txt 中,將項目聲明改為:
project(bt_mission VERSION 1.0.0 LANGUAGES C CXX)
src/main.cpp 和 trees/mission.xml 已經隨工程複製過來,稍後用本篇內容替換。
在左側資源管理器的 trees 目錄中新建 navigation.xml,用於保存導航子樹。參與本篇構建的目錄為:
bt_mission/
├── CMakeLists.txt
├── CMakePresets.json
├── cmake/
│ ├── ProjectOptions.cmake
│ └── BehaviorTree.cmake
├── src/
│ ├── CMakeLists.txt
│ └── main.cpp
└── trees/
├── mission.xml 任务入口,引用导航子树
└── navigation.xml 可复用的 NavigateLeg 定义
BT_XML_PATH 仍指向 trees/mission.xml,不需要因為增加了第二個 XML 而修改 CMake。

4.實現任務所需的節點
導航節點沿用上一篇的生命週期,在此基礎上增加“前幾次嘗試失敗”的模擬參數,以及成功後寫出到達目標的輸出端口。
| 節點 | 類型 | 主要職責 |
|---|---|---|
BatteryOK |
ConditionNode |
讀取電量,達到 30 時返回成功 |
ChargeBattery |
SyncActionNode |
將模擬電量更新為 80 |
NavigateToGoal |
StatefulActionNode |
分多次檢查導航,成功時寫出 reached_goal |
ReportMission |
SyncActionNode |
讀取兩段導航的結果並報告完成 |
ReportFailure |
簡單同步動作 | 輸出失敗信息,表示報告動作已執行 |
用以下完整程序替換 src/main.cpp 的原有內容,再保存。
四個節點類和一個簡單動作都在這個文件中,方便先把組合任務跑通。
#include "behaviortree_cpp_v3/bt_factory.h"
#include <chrono>
#include <cmath>
#include <iostream>
#include <stdexcept>
#include <string>
class BatteryOK : public BT::ConditionNode
{
public:
BatteryOK(const std::string& name, const BT::NodeConfiguration& config)
: BT::ConditionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<double>("level")};
}
BT::NodeStatus tick() override
{
auto level = getInput<double>("level");
if (!level)
{
throw BT::RuntimeError("Cannot read input [level]: ", level.error());
}
if (!std::isfinite(level.value()) || level.value() < 0.0 || level.value() > 100.0)
{
throw std::runtime_error("battery level must be between 0 and 100");
}
const auto result = level.value() >= 30.0
? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
std::cout << "[BatteryOK] level=" << level.value()
<< " -> " << BT::toStr(result) << '\n';
return result;
}
};
class ChargeBattery : public BT::SyncActionNode
{
public:
ChargeBattery(const std::string& name, const BT::NodeConfiguration& config)
: BT::SyncActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::OutputPort<double>("level")};
}
BT::NodeStatus tick() override
{
// 本例把充电简化为一次数据更新,真实充电应实现为持续任务。
auto written = setOutput("level", 80.0);
if (!written)
{
throw BT::RuntimeError("Cannot write output [level]: ", written.error());
}
std::cout << "[ChargeBattery] level=80\n";
return BT::NodeStatus::SUCCESS;
}
};
class NavigateToGoal : public BT::StatefulActionNode
{
public:
NavigateToGoal(const std::string& name, const BT::NodeConfiguration& config)
: BT::StatefulActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("goal"),
BT::InputPort<int>("ticks", 2, "每次导航尝试的检查次数"),
BT::InputPort<int>("fail_attempts", 1, "模拟前几次尝试失败"),
BT::OutputPort<std::string>("reached_goal")};
}
BT::NodeStatus onStart() override
{
auto goal = getInput<std::string>("goal");
auto ticks = getInput<int>("ticks");
auto failures = getInput<int>("fail_attempts");
if (!goal)
{
throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
}
if (!ticks)
{
throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
}
if (!failures)
{
throw BT::RuntimeError("Cannot read input [fail_attempts]: ", failures.error());
}
if (goal.value().empty() || ticks.value() < 1 || failures.value() < 0)
{
throw std::runtime_error("invalid navigation simulation parameters");
}
goal_ = goal.value();
required_ticks_ = ticks.value();
failed_attempts_ = failures.value();
completed_ticks_ = 0;
// 每个节点实例分别保留尝试次数,用于模拟先失败、后成功。
++attempt_;
std::cout << "[NavigateToGoal] start goal=" << goal_
<< ", attempt=" << attempt_
<< ", completed_ticks=" << completed_ticks_ << '\n';
return BT::NodeStatus::RUNNING;
}
BT::NodeStatus onRunning() override
{
++completed_ticks_;
std::cout << "[NavigateToGoal] check goal=" << goal_
<< ", attempt=" << attempt_
<< ", completed_ticks=" << completed_ticks_
<< '/' << required_ticks_ << '\n';
if (completed_ticks_ < required_ticks_)
{
return BT::NodeStatus::RUNNING;
}
if (attempt_ <= failed_attempts_)
{
std::cout << "[NavigateToGoal] failed goal=" << goal_
<< ", attempt=" << attempt_ << '\n';
return BT::NodeStatus::FAILURE;
}
auto written = setOutput("reached_goal", goal_);
if (!written)
{
throw BT::RuntimeError("Cannot write output [reached_goal]: ", written.error());
}
std::cout << "[NavigateToGoal] arrived goal=" << goal_
<< ", attempt=" << attempt_ << '\n';
return BT::NodeStatus::SUCCESS;
}
void onHalted() override
{
std::cout << "[NavigateToGoal] halted goal=" << goal_ << '\n';
completed_ticks_ = 0;
}
private:
std::string goal_;
int required_ticks_ = 0;
int completed_ticks_ = 0;
int failed_attempts_ = 0;
int attempt_ = 0;
};
class ReportMission : public BT::SyncActionNode
{
public:
ReportMission(const std::string& name, const BT::NodeConfiguration& config)
: BT::SyncActionNode(name, config)
{}
static BT::PortsList providedPorts()
{
return {BT::InputPort<std::string>("pickup"),
BT::InputPort<std::string>("dropoff")};
}
BT::NodeStatus tick() override
{
auto pickup = getInput<std::string>("pickup");
auto dropoff = getInput<std::string>("dropoff");
if (!pickup)
{
throw BT::RuntimeError("Cannot read input [pickup]: ", pickup.error());
}
if (!dropoff)
{
throw BT::RuntimeError("Cannot read input [dropoff]: ", dropoff.error());
}
std::cout << "[ReportMission] pickup=" << pickup.value()
<< ", dropoff=" << dropoff.value() << '\n';
return BT::NodeStatus::SUCCESS;
}
};
BT::NodeStatus ReportFailure(BT::TreeNode&)
{
std::cout << "[ReportFailure] transport mission failed\n";
return BT::NodeStatus::SUCCESS;
}
int main()
{
try
{
BT::BehaviorTreeFactory factory;
factory.registerNodeType<BatteryOK>("BatteryOK");
factory.registerNodeType<ChargeBattery>("ChargeBattery");
factory.registerNodeType<NavigateToGoal>("NavigateToGoal");
factory.registerNodeType<ReportMission>("ReportMission");
factory.registerSimpleAction("ReportFailure", ReportFailure);
auto blackboard = BT::Blackboard::create();
blackboard->set("battery", 20.0);
blackboard->set("pickup_goal", std::string("loading_area"));
blackboard->set("dropoff_goal", std::string("unloading_area"));
auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);
int root_tick = 1;
std::cout << "[main] tick=" << root_tick << " begin\n";
BT::NodeStatus result = tree.tickRoot();
std::cout << "[main] tick=" << root_tick
<< " -> " << BT::toStr(result) << '\n';
while (result == BT::NodeStatus::RUNNING)
{
tree.sleep(std::chrono::milliseconds(100));
++root_tick;
std::cout << "[main] tick=" << root_tick << " begin\n";
result = tree.tickRoot();
std::cout << "[main] tick=" << root_tick
<< " -> " << BT::toStr(result) << '\n';
}
std::cout << "Tree result: " << BT::toStr(result) << '\n';
if (result == BT::NodeStatus::SUCCESS)
{
std::cout << "[main] pickup_reached="
<< blackboard->get<std::string>("pickup_reached") << '\n';
std::cout << "[main] dropoff_reached="
<< blackboard->get<std::string>("dropoff_reached") << '\n';
}
return result == BT::NodeStatus::SUCCESS ? 0 : 1;
}
catch (const std::exception& error)
{
std::cerr << "Error: " << error.what() << '\n';
return 2;
}
}
ticks 和 fail_attempts 都是本例的模擬參數。ticks="2" 表示一次導航在兩次 onRunning() 檢查後給出結果;fail_attempts="1" 表示同一個節點實例的第一次嘗試失敗,後續嘗試成功。節點的 attempt_ 從 0 開始,在每次 onStart() 中遞增。
attempt_ 是教學模擬器自己的計數,和 Retry 裝飾節點內部的重試計數不是同一個變量。這裏故意保留它,才能在重啓導航後模擬“第一次失敗、第二次成功”。每次 Launch 都會創建新的樹;如果實際程序複用同一棵樹處理多個任務,應明確什麼時候重置業務成員和結果條目。
4.1.對照本例中的 API 參數
本篇主要組合前面已經使用過的接口。註冊和端口接口的原型見 第一個完整程序,外部 Blackboard 的讀寫見 節點之間傳數據,回調與驅動循環見 持續任務與中止。對照本例時,重點看每個實參對應哪一層名稱:
| 調用 | 參數含義 | 返回值 |
|---|---|---|
factory.registerNodeType<NavigateToGoal>("NavigateToGoal") |
模板參數是 C++ 節點類;第一個函數參數是 XML 中使用的註冊名 | void,註冊類型,尚未啓動導航 |
factory.registerSimpleAction("ReportFailure", ReportFailure) |
第一個參數是註冊名;第二個參數是回調函數本身,樹執行到 <ReportFailure/> 時才調用它 |
註冊函數返回 void;回調另行返回自己的節點狀態 |
blackboard->set("battery", 20.0) |
第一個參數是 Blackboard 鍵,第二個參數是值;這裏省略 <double>,由 20.0 推導類型 |
void,寫入電量 |
factory.createTreeFromFile(BT_XML_PATH, blackboard) |
第一個參數是入口 XML 路徑;第二個參數是根樹使用的 Blackboard 共享指針 | BT::Tree,包含創建好的節點實例 |
setOutput("reached_goal", goal_) |
第一個參數是當前節點的輸出端口名;第二個參數是保存的目標字符串 | BT::Result,本例檢查寫出是否成功 |
blackboard->get<std::string>("pickup_reached") |
模板參數指定讀取類型;第一個函數參數是根 Blackboard 中的結果鍵 | std::string 值;鍵不存在或類型不兼容時拋出異常 |
setOutput() 的 reached_goal 是節點端口名,get() 的 pickup_reached 是主程序要讀取的 Blackboard 鍵,兩者由後面的 XML 映射連接。ReportFailure() 回調返回 SUCCESS 表示報告動作完成;整項運輸任務的失敗結果由 XML 中包裹它的 ForceFailure 保留,不能把回調返回值與註冊函數返回值混為一談。
5.用兩個 XML 文件組織任務
5.1.trees/mission.xml
用下面的 XML 替換 trees/mission.xml 的原有內容,再保存。
<root main_tree_to_execute="MainTree">
<include path="navigation.xml"/>
<BehaviorTree ID="MainTree">
<Sequence name="transport_job">
<Fallback name="ensure_battery">
<BatteryOK level="{battery}"/>
<ChargeBattery level="{battery}"/>
</Fallback>
<Fallback name="run_or_report_failure">
<Sequence>
<SubTree ID="NavigateLeg"
target="pickup_goal" reached="pickup_reached"/>
<SubTree ID="NavigateLeg"
target="dropoff_goal" reached="dropoff_reached"/>
<ReportMission pickup="{pickup_reached}"
dropoff="{dropoff_reached}"/>
</Sequence>
<ForceFailure>
<ReportFailure/>
</ForceFailure>
</Fallback>
</Sequence>
</BehaviorTree>
</root>
主樹中兩次 SubTree 使用相同的 ID="NavigateLeg",但分別映射到取貨點和放貨點的父鍵。NavigateLeg 是 XML 中定義的行為樹名稱,NavigateToGoal 才是註冊到工廠的 C++ 葉節點名稱。
5.2.trees/navigation.xml
將下面的內容保存到新建的 trees/navigation.xml。
<root>
<BehaviorTree ID="NavigateLeg">
<RetryUntilSuccessful num_attempts="2">
<NavigateToGoal goal="{target}" reached_goal="{reached}"
ticks="2" fail_attempts="1"/>
</RetryUntilSuccessful>
</BehaviorTree>
</root>
<include path="navigation.xml"/> 會在加載主 XML 時引入子樹定義,路徑相對於包含它的 mission.xml 所在目錄。主程序通過 BT_XML_PATH 的完整路徑加載入口,所以從不同工作目錄啓動,仍能找到同目錄的 navigation.xml。
C++ 中已經註冊了葉節點;兩個 XML 負責定義和連接任務。工廠加載入口文件時會處理 include 和 BehaviorTree ID,因此不需要再為 NavigateLeg 編寫一個 C++ 節點類。
截圖預留:主樹和導航子樹。 展示兩個 XML 文件,以及兩處
SubTree映射到不同父鍵的位置。
6.在 VSCode 中運行完整任務
保存所有文件,選擇 Linux Debug,執行 Configure → Build,將運行目標設為 bt_mission,再點擊 Launch。日誌中的 [main] tick=N begin 和同號的返回狀態標出一次根 tick 的範圍;導航的 check 行記錄自增後的檢查次數。完整輸出為:
[main] tick=1 begin
[BatteryOK] level=20 -> FAILURE
[ChargeBattery] level=80
[NavigateToGoal] start goal=loading_area, attempt=1, completed_ticks=0
[main] tick=1 -> RUNNING
[main] tick=2 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=1/2
[main] tick=2 -> RUNNING
[main] tick=3 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=loading_area, attempt=1
[NavigateToGoal] start goal=loading_area, attempt=2, completed_ticks=0
[main] tick=3 -> RUNNING
[main] tick=4 begin
[NavigateToGoal] check goal=loading_area, attempt=2, completed_ticks=1/2
[main] tick=4 -> RUNNING
[main] tick=5 begin
[NavigateToGoal] check goal=loading_area, attempt=2, completed_ticks=2/2
[NavigateToGoal] arrived goal=loading_area, attempt=2
[NavigateToGoal] start goal=unloading_area, attempt=1, completed_ticks=0
[main] tick=5 -> RUNNING
[main] tick=6 begin
[NavigateToGoal] check goal=unloading_area, attempt=1, completed_ticks=1/2
[main] tick=6 -> RUNNING
[main] tick=7 begin
[NavigateToGoal] check goal=unloading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=unloading_area, attempt=1
[NavigateToGoal] start goal=unloading_area, attempt=2, completed_ticks=0
[main] tick=7 -> RUNNING
[main] tick=8 begin
[NavigateToGoal] check goal=unloading_area, attempt=2, completed_ticks=1/2
[main] tick=8 -> RUNNING
[main] tick=9 begin
[NavigateToGoal] check goal=unloading_area, attempt=2, completed_ticks=2/2
[NavigateToGoal] arrived goal=unloading_area, attempt=2
[ReportMission] pickup=loading_area, dropoff=unloading_area
[main] tick=9 -> SUCCESS
Tree result: SUCCESS
[main] pickup_reached=loading_area
[main] dropoff_reached=unloading_area

初始電量為 20,因此先檢查失敗,再由 ChargeBattery 更新為 80。兩段導航分別經歷第一次失敗、第二次成功,最後 ReportMission 和主程序都能讀到兩個到達結果。
| 執行位置 | 本次使用的數據 | 結果 |
|---|---|---|
| 電量 Fallback | 主樹的 battery |
低電量時更新到 80 |
| 第一次 NavigateLeg | pickup_goal 映射到局部 target |
寫出 pickup_reached |
| 第二次 NavigateLeg | dropoff_goal 映射到另一個局部 target |
寫出 dropoff_reached |
| ReportMission | 主樹的兩個結果鍵 | 輸出任務完成信息 |
| main | 從根 Blackboard 讀取兩個結果 | 確認跨子樹的數據傳遞 |
電量檢查是這次任務的啓動準備。進入導航後,外層普通 Sequence 會從正在執行的位置繼續,電量檢查不會每次都重跑。如果任務要求導航期間持續檢查某個條件,可以沿用上一篇的 ReactiveSequence 方式組織檢查層。
7.從 main() 走到第一次根 tick
先把“準備一棵樹”和“驅動這棵樹”分開。程序進入 main() 後,按下面的順序準備任務:
| 執行位置 | 已完成的工作 | 此時是否已經開始導航 |
|---|---|---|
| 創建工廠並註冊節點 | 讓工廠知道 XML 中的節點名稱對應哪個 C++ 類型或回調 | 尚未開始,也沒有因此調用 onStart() |
| 創建並填寫 Blackboard | 設置 battery=20、取貨點 loading_area 和放貨點 unloading_area |
尚未開始 |
createTreeFromFile() |
讀取主 XML 和被包含的 XML,創建節點實例及兩份導航子樹,建立 Blackboard 映射 | 尚未開始 |
第一次 tree.tickRoot() |
從根節點進入控制節點,再執行本次能夠到達的葉節點 | 此時才會啓動取貨導航 |
樹創建完成、第一次根 tick 尚未執行時,兩份 NavigateToGoal 對象的 goal_ 都是空字符串,四個整數成員也都是 0。XML 中的 ticks="2" 和 fail_attempts="1" 不會直接給這些 C++ 成員賦值;本例要等進入 onStart(),調用 getInput() 後,才把它們保存到成員中。
兩處 SubTree 在建樹時就分別實例化了導航節點。第一次 tick 不會再創建一個取貨節點,重試也不會重新構造節點;後面始終是在已有對象上切換狀態、調用回調。
7.1.先分清本例中的幾種次數
| 名稱 | 誰維護它 | 本例中的準確含義 |
|---|---|---|
root_tick |
main() |
調用 tree.tickRoot() 的序號,從 1 開始打印 |
completed_ticks_ |
當前導航節點 | 本次嘗試已經執行多少次 onRunning();每次 onStart() 都清零 |
required_ticks_ |
當前導航節點 | 從 ticks="2" 讀到的閾值;本次嘗試檢查兩次後,才判斷成功或失敗 |
attempt_ |
當前導航節點 | 這個對象第幾次執行 onStart();每次啓動加一 |
failed_attempts_ |
當前導航節點 | 從 fail_attempts="1" 讀到的模擬規則;attempt_ <= 1 時模擬失敗 |
num_attempts="2" |
當前 Retry 節點 | 允許最多兩次總嘗試,即首次嘗試加一次重試 |
completed_ticks_ 不包含 onStart() 那次調用,也不是整棵樹累計 tick 的次數。第一次根 tick 啓動取貨導航後,取貨節點是 attempt_=1、completed_ticks_=0,並不是已經完成了一次檢查。
Retry 內部另有失敗計數 try_count_:當前這輪開始時為 0,孩子返回一次 FAILURE 後變為 1,孩子持續返回 RUNNING 時不會消耗新的嘗試次數。孩子成功,或失敗次數達到上限、Retry 結束這一輪時,它會把自己的計數清零;導航對象的 attempt_ 則仍然保留。這兩個計數用途不同,不應當成一個變量。
8.按九次根 tick 推演完整任務
保持本篇默認參數:初始電量 20、ticks="2"、fail_attempts="1"、num_attempts="2"。下面每一行對應主程序調用一次 tree.tickRoot(),也對應日誌中一組同號的 begin 和返回狀態。
兩列成員值統一寫成 (attempt_, completed_ticks_),記錄的是該次根 tick 返回時的值。取貨和放貨屬於兩個不同對象,必須分別觀察。
| 根 tick | 本次按順序發生的事情 | 取貨節點成員值 | 放貨節點成員值 | 根返回 |
|---|---|---|---|---|
| 1 | 檢查電量失敗,充電到 80;啓動取貨導航,onStart() 返回 RUNNING |
(1, 0) |
(0, 0) |
RUNNING |
| 2 | 取貨導航進入 onRunning(),檢查次數從 0 加到 1,尚未達到 2 |
(1, 1) |
(0, 0) |
RUNNING |
| 3 | 取貨檢查次數加到 2,第一次嘗試失敗;Retry 重置孩子並立即再次調用 onStart() |
(2, 0) |
(0, 0) |
RUNNING |
| 4 | 取貨的第二次嘗試進入 onRunning(),檢查次數加到 1 |
(2, 1) |
(0, 0) |
RUNNING |
| 5 | 取貨檢查次數加到 2,成功寫出結果;序列繼續啓動放貨導航 | (2, 2) |
(1, 0) |
RUNNING |
| 6 | 放貨導航進入 onRunning(),檢查次數加到 1 |
(2, 2) |
(1, 1) |
RUNNING |
| 7 | 放貨檢查次數加到 2,第一次嘗試失敗;Retry 立即重新啓動它 | (2, 2) |
(2, 0) |
RUNNING |
| 8 | 放貨的第二次嘗試進入 onRunning(),檢查次數加到 1 |
(2, 2) |
(2, 1) |
RUNNING |
| 9 | 放貨檢查次數加到 2,成功寫出結果;執行 ReportMission,整棵樹成功 |
(2, 2) |
(2, 2) |
SUCCESS |
表中的成員值不等於節點狀態。例如,取貨成功後,父節點可以把它的狀態重置為 IDLE,但 attempt_=2、completed_ticks_=2 仍保存在對象裏。框架重置執行狀態,不會自動清空我們自己聲明的 C++ 成員。
8.1.第一次根 tick 為什麼做了不止一件事
第一次 tree.tickRoot() 從根 Sequence 開始,執行路徑是:
transport_job:Sequence
├── ensure_battery:Fallback
│ ├── BatteryOK:读取 20,返回 FAILURE
│ └── ChargeBattery:写入 80,返回 SUCCESS
│ → ensure_battery 返回 SUCCESS
└── run_or_report_failure:Fallback
└── 导航 Sequence
└── 取货 SubTree → Retry → NavigateToGoal::onStart()
→ attempt_:0 → 1
→ completed_ticks_:清零为 0
→ 返回 RUNNING,一层层传回根节点
這裏電量條件的 FAILURE 被它的 Fallback 處理了,根節點沒有因此返回失敗。充電同步完成,外層 Sequence 就在同一次調用中繼續啓動導航。一次根 tick 是從根開始推進一次執行過程,並不是只執行一個葉節點。
onStart() 返回 RUNNING 後,本次調用不會繼續進入這個導航節點的 onRunning()。主程序收到根的 RUNNING,等待 100 ms,再發起下一次根 tick;第二次才執行取貨導航的 onRunning()。
從第二次開始,普通 Sequence 會繼續正在運行的孩子,外層繼續導航分支,內層繼續當前導航子樹。電量檢查不會每次都重跑。100 ms 是本例主循環兩次調用之間的等待時間,ticks="2" 本身不表示兩秒,也不表示啓動後只需兩次根 tick。
8.2.第三次根 tick:失敗後為什麼又進入 onStart()
進入第三次根 tick 時,取貨導航仍是 RUNNING,成員是 attempt_=1、completed_ticks_=1。這次調用按下面的順序執行:
onRunning()先執行++completed_ticks_,值從1變成2。2 < required_ticks_不成立,繼續判斷attempt_ <= failed_attempts_,即1 <= 1,成立,於是導航返回FAILURE。- Retry 收到孩子的失敗,將自己的失敗計數從
0加到1。最多允許兩次嘗試,所以還有一次機會。 - Retry 將失敗孩子的狀態恢復為
IDLE,在本次根 tick 中再次執行這個孩子。同一個導航對象因此進入onStart(),把completed_ticks_從2清成0,再把attempt_從1加到2。 - 新的
onStart()返回RUNNING,Retry 和上層控制節點也返回RUNNING,控制權回到main()。
所以第三次的日誌會在同一組 tick=3 中出現 failed 和新的 start。葉節點曾經失敗,不代表這一次根 tick 的最終結果也是失敗。 本次失敗由 Retry 處理,任務 Fallback 沒有收到最終的 FAILURE,因此也不會執行 ReportFailure。
注意 completed_ticks_ 在這一次根 tick 內經歷了 1 → 2 → 0,表格只記錄最後的 0。attempt_ 沒有清零,因為本例用它保留“同一個對象第一次失敗、第二次成功”的模擬進度。
8.3.第五次根 tick:取貨成功後為什麼立刻啓動放貨
第四次已經把取貨節點推進到 attempt_=2、completed_ticks_=1。第五次進入 onRunning() 後,檢查次數加到 2;此時 2 <= failed_attempts_,即 2 <= 1,不成立,因此通過 setOutput() 寫出到達結果並返回 SUCCESS。
這個輸出先寫入取貨子樹的局部 reached,再通過映射更新主樹的 pickup_reached。Retry 返回成功,導航 Sequence 接着執行第二個孩子,於是在同一次根 tick 中調用放貨導航的 onStart()。
放貨導航是另一個對象,它此前的 attempt_ 是 0,所以啓動後變為 1,不是沿着取貨節點加成 3。它的 completed_ticks_ 為 0,返回 RUNNING,整次根 tick 也就返回 RUNNING。
第六到第八次按相同方式推進放貨導航。第九次放貨成功,寫出 dropoff_reached,隨後 ReportMission 在本次調用中讀取兩個結果並返回成功。根返回 SUCCESS 後,while 循環結束,main() 打印最終狀態與 Blackboard 結果,返回退出碼 0;程序不會再執行第十次根 tick。
9.RUNNING、失敗重試和中止分別進入哪個回調
StatefulActionNode 根據當前執行狀態選擇回調:IDLE 時進入 onStart(),RUNNING 時進入 onRunning()。要判斷是否重新開始,既要看孩子返回什麼,也要看父節點隨後怎樣處理它。
| 發生的情況 | 誰處理後續流程 | 本例的回調與成員變化 |
|---|---|---|
導航返回 RUNNING |
父節點保留當前執行位置,等待後續根 tick | 下次進入 onRunning(),attempt_ 不變,檢查次數繼續增加 |
導航返回 FAILURE,Retry 還有預算 |
Retry 記錄失敗,將孩子恢復為 IDLE 並再次執行 |
再進 onStart(),completed_ticks_ 清零,attempt_ 加一 |
導航返回 FAILURE,Retry 預算耗盡 |
Retry 向上返回失敗,上層進入失敗報告分支 | 不再啓動這段導航,不會出現第三次 onStart() |
導航返回 SUCCESS |
父序列推進到下一個孩子 | 該次嘗試結束;後面的導航使用另一個節點對象 |
正在 RUNNING 的導航被中止 |
例如外部調用 tree.haltTree(),或上層控制節點停止這個運行分支 |
進入 onHalted(),本例清零 completed_ticks_;以後再次啓動才進入 onStart() |
返回 FAILURE 本身不會自動重試,也不會因此自動調用 onHalted()。 第三次和第七次 tick 中,Retry 處理的是已經返回失敗的孩子,因此沒有進入它的 onHalted()。正常默認流程沒有主動中止運行中的導航,也就不會打印 halted。
本例的 onHalted() 沒有把 attempt_ 清零。若中止後複用同一個對象,它下次進入 onStart() 時仍會在原來的 attempt_ 上加一。實際任務若要求“每輪任務重新計算嘗試次數”,需要另外明確業務成員和結果條目的重置時機,不能只依賴節點狀態變成 IDLE。
以上生命週期與重試行為可對照 3.8.7 的 action_node.cpp 和 retry_node.cpp。九次根 tick 的具體推演針對本篇程序及默認參數;換成同步失敗的動作或其他驅動接口時,應重新分析一次調用會推進到哪裏。
10.有限重試與失敗報告各自負責一層
num_attempts="2" 表示最多兩次總嘗試,不是首次嘗試之後再額外重試兩次。
導航返回 RUNNING 時,裝飾節點繼續等待這一次嘗試;只有導航返回 FAILURE,才會記錄一次失敗並決定是否重新開始。
前面的第 3 次和第 7 次根 tick 已經展示了尚有預算時的處理:本例導航在 onRunning() 中失敗,Retry 隨即在同一次根 tick 內啓動下一次嘗試;新的 onStart() 返回 RUNNING 後,控制權回到主循環。下面再看預算耗盡時,失敗如何一路傳遞到根節點。
將 navigation.xml 中的 num_attempts="2" 改為 num_attempts="1",只保存 XML,再點擊 Launch。此時第一次失敗已經耗盡次數,輸出為:
[main] tick=1 begin
[BatteryOK] level=20 -> FAILURE
[ChargeBattery] level=80
[NavigateToGoal] start goal=loading_area, attempt=1, completed_ticks=0
[main] tick=1 -> RUNNING
[main] tick=2 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=1/2
[main] tick=2 -> RUNNING
[main] tick=3 begin
[NavigateToGoal] check goal=loading_area, attempt=1, completed_ticks=2/2
[NavigateToGoal] failed goal=loading_area, attempt=1
[ReportFailure] transport mission failed
[main] tick=3 -> FAILURE
Tree result: FAILURE
這次只執行了三次根 tick:第 1 次啓動取貨,第 2 次完成第一次檢查,第 3 次完成第二次檢查並失敗。Retry 的失敗計數變為 1,已經達到 num_attempts="1",因此沒有第二次 onStart();取貨節點停留在 attempt_=1、completed_ticks_=2。
失敗在第 3 次根 tick 內繼續向上傳遞:取貨 Retry 返回 FAILURE → 導航 Sequence 返回 FAILURE → 任務 Fallback 轉到失敗報告分支 → ReportFailure 返回 SUCCESS → 外面的 ForceFailure 返回 FAILURE → 任務 Fallback 和根 Sequence 最終都返回 FAILURE。
所以沒有出現放貨點導航,也沒有執行 ReportMission。main() 收到根的 FAILURE 後退出循環,不讀取成功結果,返回退出碼 1。ReportFailure 的成功只表示報告已執行,並沒有把失敗的運輸任務改成成功。
如果恢復 num_attempts="2",但將 fail_attempts 改為 2,取貨的兩次嘗試都會失敗,任務會在第 5 次根 tick 結束。此時取貨節點是 attempt_=2、completed_ticks_=2;不會出現第三次啓動,放貨導航仍未執行。
失敗報告分支外面包了一層 ForceFailure:
ForceFailure
└── ReportFailure 报告动作执行完毕,返回 SUCCESS
↓
该分支向任务 Fallback 返回 FAILURE
ReportFailure 返回成功表示日誌報告已經執行;ForceFailure 保留的是運輸任務失敗的結果。這樣完成失敗處理後,整棵樹仍返回 FAILURE,主程序也不會進入成功結果的讀取分支。
11.分別修改電量、重試和映射來對照
每次對照只改一項,完成後恢復原值,再進行下一項。修改 C++ 後先 Build → Launch;只改 XML 時保存後直接 Launch。
| 修改位置 | 修改內容 | 觀察到的結果 |
|---|---|---|
main() 初始化 |
battery 從 20.0 改為 80.0 |
電量檢查直接成功,不執行模擬充電 |
navigation.xml |
fail_attempts 從 1 改為 0 |
兩段導航都在第一次嘗試成功 |
navigation.xml |
保持最多兩次嘗試,將 fail_attempts 改為 2 |
取貨點兩次都失敗,任務失敗,放貨點不執行 |
mission.xml 的第一次 SubTree |
將 target="pickup_goal" 改成 target="{pickup_goal}" |
普通 SubTree 映射到錯誤的父鍵,讀取 goal 報錯 |
mission.xml 的第一次 SubTree |
刪除 reached="pickup_reached" |
到達結果只寫到該子樹局部鍵,主樹讀取 pickup 時缺少輸入 |
最後兩種是數據配置錯誤,主程序打印 Error: 並返回 2。它們不同於導航嘗試耗盡時的任務 FAILURE,後者正常返回任務失敗碼 1。
如果遺漏輸出映射,子樹內部仍然可以完成一次導航;真正缺失的是通往主樹結果條目的連接。局部鍵名字相同,不代表所有子樹自動共享同一個存儲位置。
12.在斷點中核對回調、計數和節點實例
恢復主示例的電量與 XML,先 Build,再設置下面幾處斷點並點擊 Debug。斷點通常停在所在語句執行之前,要區分“即將自增”和“已經自增”。
| 斷點位置 | 要觀察的內容 |
|---|---|
main() 第一次根 tick 的調用語句 |
此時 root_tick=1,樹已創建,導航尚未啓動;單步進入可沿控制節點找到第一次 onStart() |
onStart() 中 ++attempt_ 之後的打印語句 |
此時清零和自增已完成;取貨兩次暫停分別為 (attempt_, completed_ticks_)=(1, 0)、(2, 0) |
onRunning() 中 ++completed_ticks_ 之後的打印語句 |
此時本次自增已完成;同一次嘗試兩次暫停分別看到 completed_ticks_=1、2 |
main() 中根 tick 調用之後的狀態打印語句 |
此時 result 已更新;第 3 次即使出現導航失敗,根結果仍為 RUNNING,第 9 次才是 SUCCESS |
將 goal_、attempt_、completed_ticks_、required_ticks_、failed_attempts_ 和 this 加到監視窗口。在 main() 的棧幀中觀察 root_tick、result;進入節點回調後,可以切回 main() 棧幀查看當前是第幾次根 tick。第一次調用返回前,result 尚未完成初始化;在後續循環中,調用尚未返回時,result 仍是上一次賦值的值。要等調用返回、賦值完成,再判斷本次根結果。
第三次根 tick 中,先在取貨的 onRunning() 看到 (1, 2);繼續執行,又會在同一個對象的 onStart() 中看到 (2, 0),此時 root_tick 仍是 3。第五次則先看到取貨 (2, 2),繼續後進入放貨的 onStart(),看到 (1, 0),而 root_tick 仍是 5。
比較 this 指針:取貨點的兩次啓動使用同一個對象,放貨點的對象地址不同。這個過程對應的是“同一份子樹定義,兩次獨立實例化”。複用的是結構和實現,任務成員與未映射的局部 Blackboard 條目分別屬於各自實例。
完成工程後,分別運行電量充足、首次成功、重試耗盡、錯誤輸入映射和遺漏輸出映射等對照,檢查返回狀態、重試次數與錯誤信息。同時從不同工作目錄啓動程序,確認入口 XML 路徑不依賴當前終端所在目錄。
13.當前學習進度
到這裏,教程已經給出了從最小程序到組合任務的完整實踐路徑:
| 文檔 | 對應的實踐主題 |
|---|---|
| 第一個完整程序 | 註冊節點、加載 XML、執行最小樹,再逐步增加條件和端口 |
| 節點之間傳數據 | 自定義類型、輸入輸出與雙向端口、外部 Blackboard |
| 持續任務與中止 | 多次 tick、StatefulActionNode、條件變化和主動中止 |
| 本篇 | 子樹隔離與映射、多 XML 文件、有限重試和失敗處理 |
基礎編程實踐的文檔到此結束。
接下來進入工具實踐:使用Groot1編輯與調試。沿用已有節點,在圖形界面中編輯 XML、觀察運行狀態,再回放成功與中止的日誌。