Wiki
組合實際任務
本文由簡體中文內容確定性轉換,並受版本化術語表保護。
1.把已有節點組合成移動機器人任務
對應 BehaviorTree.CPP 4.x 官方文件:Using SubTrees、Port Remapping 和 Use multiple XML files。實現細節參考 4.10.0 的 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
通過 SubTree 呼叫導航子樹,並配置它與主樹之間的資料連線:
<SubTree ID="NavigateLeg" target="{pickup_goal}" reached="{pickup_reached}"/>
屬性名 target、reached 是子樹區域性 Blackboard 的鍵;花括號中的名稱指向父 Blackboard 的條目。子樹寫入區域性 reached 時,會通過對映更新父樹的 pickup_reached。
| 所在位置 | 寫法 | 含義 |
|---|---|---|
| 普通節點輸入 | goal="{target}" |
從當前 Blackboard 的 target 讀取 |
| v4 SubTree 邊界 | target="{pickup_goal}" |
子樹 target 對映到父樹 pickup_goal |
| v4 SubTree 固定值 | target="loading_area" |
給子樹區域性 target 一個固定字串 |
| 子樹內部輸出 | reached_goal="{reached}" |
寫入區域性鍵,再通過邊界對映寫回父樹 |
target="pickup_goal" 是固定字串,不會讀取父樹同名鍵。 如果要讀取父樹的目標,應寫成 target="{pickup_goal}"。忘記花括號時,程式可能不報錯,卻把字串 pickup_goal 當作實際導航目標。因此執行後還要檢查目標內容和結果資料。
子樹邊界的鍵名也不等於 C++ 葉節點的埠名。
本例經過的是:goal 埠 → 區域性 target → 父樹 pickup_goal,每一層分別宣告自己的介面。
3.建立組合任務工程
本篇從本系列 持續任務與中止 中的 bt4_running 工程開始。先完成它的執行與中止練習,再複製整個資料夾,將副本重新命名為 bt4_mission,用於編寫組合任務。
在系統檔案管理器中完成下面幾步:
- 開啟
bt4_running所在的父目錄,複製並貼上整個資料夾,將副本重新命名為bt4_mission,讓兩個工程位於同一層目錄。 - 在新副本
bt4_mission中,刪除複製過來的build和install資料夾(如果存在)。其中的構建快取和安裝結果屬於舊工程,新工程稍後會重新生成。 - 在 VSCode 中選擇 檔案 → 新建視窗,再選擇 檔案 → 開啟資料夾,開啟新的
bt4_mission。
框架繼續來自 CMake工程模板,保留 C++20、CMake 3.22、預設格式版本 3,以及優先查詢 BehaviorTree.CPP 4.10.0 的依賴配置。 Ubuntu 22.04 的 CMake 3.22.1 可以繼續使用。複用 Humble 安裝包時,VSCode 的配置、執行和除錯程序仍需繼承 ROS 2 環境。
在頂層 CMakeLists.txt 中,將專案宣告改為:
project(bt4_mission VERSION 1.0.0 LANGUAGES C CXX)
src/main.cpp 和 trees/mission.xml 已經隨工程複製過來,稍後用本篇內容替換。
在左側資源管理器的 trees 目錄中新建 navigation.xml,用於儲存導航子樹。參與本篇構建的目錄為:
bt4_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/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::NodeConfig& 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::NodeConfig& 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::NodeConfig& 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::NodeConfig& 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.tickExactlyOnce();
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.tickExactlyOnce();
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 BTCPP_format="4" 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 BTCPP_format="4">
<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++ 節點類。

6.在 VSCode 中執行完整任務
儲存所有檔案,選擇 Linux Debug,執行 Configure → Build,將執行目標設為 bt4_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.tickExactlyOnce() |
從根節點進入控制節點,再執行本次能夠到達的葉節點 | 此時才會啟動取貨導航 |
樹建立完成、第一次根 tick 尚未執行時,兩份 NavigateToGoal 物件的 goal_ 都是空字串,四個整數成員也都是 0。XML 中的 ticks="2" 和 fail_attempts="1" 不會直接給這些 C++ 成員賦值;本例要等進入 onStart(),呼叫 getInput() 後,才把它們儲存到成員中。
兩處 SubTree 在建樹時就分別例項化了導航節點。第一次 tick 不會再建立一個取貨節點,重試也不會重新構造節點;後面始終是在已有物件上切換狀態、呼叫回撥。
7.1.先分清本例中的幾種次數
| 名稱 | 誰維護它 | 本例中的準確含義 |
|---|---|---|
root_tick |
main() |
呼叫 tree.tickExactlyOnce() 的序號,從 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.tickExactlyOnce(),也對應日誌中一組同號的 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.tickExactlyOnce() 從根 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。
以上生命週期與重試行為可對照 4.10.0 的 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" |
讀到固定字串 pickup_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 檔案、有限重試和失敗處理 |
基礎程式設計實踐的文件到此結束。
接下來進入 v4 擴充套件實踐,學習指令碼與前後置條件、全域性 Blackboard 與資料更新,再使用 Groot2 觀察狀態和回放日誌。