跳到正文

Wiki

節點之間傳資料

約 21 分鐘閱讀

本文由簡體中文內容確定性轉換,並受版本化術語表保護。

1.用 Ports 與 Blackboard 傳遞機器人目標

對應 BehaviorTree.CPP 3.8 官方文件:Tutorial - Basics 中的 Blackboard and ports 和 Ports with generic types,並參考 3.8.7 tree_node.h、basic_types.h 與 blackboard.h。

上一篇已經給出“檢查電量 → 選擇目標 → 列印目標”的完整工程。 如果還沒有執行,可以先按上一篇的實踐部分建立工程; 本篇另建一個獨立示例,保留前一個工程,方便對照。

接下來把字串目標換成機器人常用的座標資料,集中解決幾個相關問題:不同名稱的埠怎樣連線,XML 文本怎樣變成 C++ 型別,節點怎樣讀取並修改同一份資料,外部程式怎樣與樹交換資料。

本篇仍使用同步節點計算和列印資料,實際移動與導航留到“持續任務與中止”學習。


2.從外部位姿計算一個任務目標

假設移動機器人當前位姿為 (1.0, 2.0, 0.0), 需要沿當前朝向前進 2 米, 再給目標的 x 座標增加 0.5 米的示例偏移:

外部程序初始化 Blackboard
├── robot_pose       = Pose2D{1.0, 2.0, 0.0}
└── travel_distance  = 2.0
         ↓
Sequence
├── CalculateGoal    根据起点、朝向和距离计算目标,写入 mission_goal
├── OffsetGoal       读取 mission_goal,修改后写回同一个条目
├── PrintGoal        从 mission_goal 读取计算结果
└── PrintGoal        从 XML 固定文本读取另一组坐标
         ↓
外部程序读取 mission_goal

這裡的 Pose2D 是自行定義的 C++ 結構體,包含 x、y、yaw,不依賴 ROS 訊息型別。 位置單位為米,朝向單位為弧度,示例中的座標使用同一個參考座標系。

計算時使用:

goal.x   = start.x + distance × cos(start.yaw)
goal.y   = start.y + distance × sin(start.yaw)
goal.yaw = start.yaw

起始位姿(1.0, 2.0, 0.0),起始朝向為 0,所以朝前(是x)2米,先得到 (3.0, 2.0, 0.0); 加上 x 偏移後,最終任務目標為 (3.5, 2.0, 0.0)。這是座標計算示例,不包含路徑規劃、碰撞檢查或導航控制。


3.埠名稱與 Blackboard 鍵各管一層

本篇用以下連線傳遞目標:

<CalculateGoal start="{robot_pose}" distance="{travel_distance}" goal="{mission_goal}"/>
<OffsetGoal goal="{mission_goal}" offset_x="0.5"/>
<PrintGoal target="{mission_goal}"/>

讀這段 XML 時,把節點介面和儲存位置分開看:

名稱 所屬位置 作用
CalculateGoal 的 goal 輸出埠 計算節點寫出目標的介面
OffsetGoal 的 goal 雙向埠 偏移節點讀取並更新目標的介面
PrintGoal 的 target 輸入埠 列印節點讀取目標的介面
mission_goal Blackboard 鍵 儲存這幾個埠共同使用的目標資料

生產者的埠叫 goal,消費者的埠叫 target,仍然可以連線,因為 XML 把它們對映到同一個 {mission_goal},而且使用相同的 Pose2D 型別。

C++ 中使用的是節點自己的埠名稱:setOutput("goal", ...) 或 getInput<Pose2D>("target")。 將來把 Blackboard 鍵改為 delivery_goal,只需統一調整 XML 中的對映,節點實現不必跟著改名。

3.1.三種埠方向

宣告 讀寫方法 適合的職責
BT::InputPort<T> getInput<T>() 讀取起點、距離、目標等輸入
BT::OutputPort<T> setOutput() 產生並寫出一個結果
BT::BidirectionalPort<T> 先讀取,再按需寫出 讀取已有目標,修改後寫回

雙向埠同時提供讀寫介面;它不會自動完成修改,也不保證條目已經有值。 本例先執行 CalculateGoal,再執行 OffsetGoal,就是為了在讀寫目標前先產生有效資料。

getInput() 將值讀到本地變數,修改這個變數不會直接改變 Blackboard。 偏移節點需要明確呼叫 setOutput() 寫回結果。


4.XML 文本與 C++ 型別怎樣對應

4.1.數值可以來自固定文本、Blackboard 或預設值

計算節點的距離輸入宣告為 double,下面三種配置對應三種來源:

XML 配置 取值方式
distance="2.0" 將固定文本轉換成 double
distance="{travel_distance}" 從 Blackboard 讀取對應條目
省略 distance 屬性 使用節點宣告的預設值,本例為 1.0

BehaviorTree.CPP 已經提供常用數值型別的文本轉換。 預設值通過 providedPorts() 宣告; 下面完整程式使用 3.8 的三引數寫法 BT::InputPort<double>("distance", 1.0, "前进距离,单位为米")。

預設值用於沒有顯式配置該輸入的情況。 如果 XML 已經寫成 {unknown_distance},但該條目沒有有效值,讀取仍會失敗,不會自動退回預設值。

4.2.自定義型別需要約定文本格式

本篇約定 Pose2D 的文本格式為三個用分號分隔的實數:

x;y;yaw
2.0;3.0;1.57

因此,<PrintGoal target="2.0;3.0;1.57"/> 需要把字串轉換成 Pose2D。 做法是在 BT 名稱空間中,為 convertFromString<Pose2D>() 提供模板特化,再分別轉換三個數值。 特化放在使用該型別宣告埠的節點類之前。

兩條資料路徑有所區別:

C++ 计算出的 Pose2D → setOutput() → Blackboard → getInput<Pose2D>()
                                           直接读取对应类型的值

XML 中的 "2.0;3.0;1.57" → getInput<Pose2D>() → convertFromString<Pose2D>()
                                           先解析文本,再得到值

Blackboard 可以儲存結構體,不需要把所有資料先轉成字串。 XML 文本轉換也不會讓任意兩個 C++ 型別自動互相相容;生產者、消費者和外部程式仍應統一資料型別。


5.沿用框架建立資料傳遞工程

本篇繼續使用上一篇的 BT 練習版,目錄來自 CMake工程模板, 構建方式見 CMakePresets與構建安裝。 需要支援 C++20 的編譯器、CMake 3.22 或更新版本和 Ninja。 Ubuntu 22.04 可以繼續使用 CMake 3.22.1,沿用上一篇的工程即可;C++20 和預設格式版本 3 都直接沿用當前模板。

如果還沒有建立上一篇的框架工程,先按上一篇準備好工程檔案。 本篇直接複製整個 bt_first_tree 資料夾,再把副本重新命名為 bt_ports,保留原來的工程供後續對照。

在系統檔案管理器中完成下面幾步:

  1. 開啟 bt_first_tree 所在的父目錄,複製並貼上整個資料夾,將副本重新命名為 bt_ports,讓兩個工程位於同一層目錄。
  2. 在新副本 bt_ports 中,刪除複製過來的 build 和 install 資料夾(如果存在)。其中的構建快取和安裝結果屬於舊工程,新工程稍後會重新生成。
  3. 在 VSCode 中選擇 檔案 → 新建視窗,再選擇 檔案 → 開啟資料夾,開啟新的 bt_ports。

cmake、src、trees 目錄及其中的檔案都已隨工程複製過來,不需要重新建立。 稍後直接修改頂層專案名,並用本篇內容替換已有的 src/main.cpp 和 trees/mission.xml。

新工程的構建目錄會在 Configure、Build 時由自己的 preset 重新生成。參與本篇學習的檔案為:

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

在新的頂層 CMakeLists.txt 中,將 project(...) 一行改為:

project(bt_ports VERSION 1.0.0 LANGUAGES C CXX)

其他配置原樣沿用:BehaviorTree.cmake 繼續優先使用已安裝的 3.8.7,未找到時再獲取同版本原始碼; src/CMakeLists.txt 自動以新專案名建立 bt_ports,並將新工程的 trees/mission.xml 路徑交給 BT_XML_PATH。

在 VSCode 編輯器中,用下面的完整 C++ 和 XML 分別替換 src/main.cpp 與 trees/mission.xml 的原有內容,再儲存。本篇繼續使用 CMake Tools 配置、構建和執行。

5.1.src/main.cpp

#include "behaviortree_cpp_v3/bt_factory.h"

#include <cmath>
#include <exception>
#include <iomanip>
#include <iostream>
#include <string>

struct Pose2D
{
    double x;
    double y;
    double yaw;
};

// 统一坐标的打印方式,便于观察每一步的数据
std::ostream& operator<<(std::ostream& stream, const Pose2D& pose)
{
    return stream << '(' << pose.x << ", " << pose.y << ", " << pose.yaw << ')';
}

namespace BT
{
template <>
inline Pose2D convertFromString<Pose2D>(StringView text)
{
    // XML 中的位姿格式为 x;y;yaw,朝向单位为弧度
    const auto parts = splitString(text, ';');
    if (parts.size() != 3)
    {
        throw RuntimeError("Pose2D requires x;y;yaw");
    }

    Pose2D pose{
        convertFromString<double>(parts[0]),
        convertFromString<double>(parts[1]),
        convertFromString<double>(parts[2])};

    if (!std::isfinite(pose.x) || !std::isfinite(pose.y) || !std::isfinite(pose.yaw))
    {
        throw RuntimeError("Pose2D values must be finite");
    }
    return pose;
}
}  // namespace BT

class CalculateGoal : public BT::SyncActionNode
{
public:
    CalculateGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {
            BT::InputPort<Pose2D>("start"),
            BT::InputPort<double>("distance", 1.0, "前进距离,单位为米"),
            BT::OutputPort<Pose2D>("goal")};
    }

    BT::NodeStatus tick() override
    {
        const auto start = getInput<Pose2D>("start");
        const auto distance = getInput<double>("distance");
        if (!start)
        {
            throw BT::RuntimeError("Cannot read input [start]: ", start.error());
        }
        if (!distance)
        {
            throw BT::RuntimeError("Cannot read input [distance]: ", distance.error());
        }
        if (!std::isfinite(distance.value()) || distance.value() < 0.0)
        {
            throw BT::RuntimeError("Input [distance] must be finite and non-negative");
        }

        // 沿起始朝向计算目标,计算结果仍是一个 Pose2D 对象
        const Pose2D& origin = start.value();
        const Pose2D goal{
            origin.x + distance.value() * std::cos(origin.yaw),
            origin.y + distance.value() * std::sin(origin.yaw),
            origin.yaw};

        const auto written = setOutput<Pose2D>("goal", goal);
        if (!written)
        {
            throw BT::RuntimeError("Cannot write output [goal]: ", written.error());
        }
        std::cout << "[CalculateGoal] distance=" << distance.value()
                  << ", goal=" << goal << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

class OffsetGoal : public BT::SyncActionNode
{
public:
    OffsetGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {
            BT::BidirectionalPort<Pose2D>("goal"),
            BT::InputPort<double>("offset_x", 0.0, "目标在参考坐标系 x 方向的偏移")};
    }

    BT::NodeStatus tick() override
    {
        const auto input = getInput<Pose2D>("goal");
        const auto offset = getInput<double>("offset_x");
        if (!input)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", input.error());
        }
        if (!offset)
        {
            throw BT::RuntimeError("Cannot read input [offset_x]: ", offset.error());
        }
        if (!std::isfinite(offset.value()))
        {
            throw BT::RuntimeError("Input [offset_x] must be finite");
        }

        // 先修改本地副本,再通过双向端口写回同一个条目
        Pose2D goal = input.value();
        goal.x += offset.value();
        const auto written = setOutput<Pose2D>("goal", goal);
        if (!written)
        {
            throw BT::RuntimeError("Cannot write output [goal]: ", written.error());
        }
        std::cout << "[OffsetGoal] goal=" << goal << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

class PrintGoal : public BT::SyncActionNode
{
public:
    PrintGoal(const std::string& name, const BT::NodeConfiguration& config)
        : BT::SyncActionNode(name, config)
    {}

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<Pose2D>("target")};
    }

    BT::NodeStatus tick() override
    {
        const auto target = getInput<Pose2D>("target");
        if (!target)
        {
            throw BT::RuntimeError("Cannot read input [target]: ", target.error());
        }
        std::cout << "[PrintGoal:" << name() << "] target=" << target.value() << '\n';
        return BT::NodeStatus::SUCCESS;
    }
};

int main()
{
    try
    {
        // 在编辑器中修改这个模拟距离,再构建运行;XML 路径由构建配置提供
        const double distance = 2.0;
        std::cout << std::fixed << std::setprecision(2);

        // 外部程序在创建树之前,准备带有明确 C++ 类型的输入
        auto blackboard = BT::Blackboard::create();
        blackboard->set<Pose2D>("robot_pose", Pose2D{1.0, 2.0, 0.0});
        blackboard->set<double>("travel_distance", distance);

        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<CalculateGoal>("CalculateGoal");
        factory.registerNodeType<OffsetGoal>("OffsetGoal");
        factory.registerNodeType<PrintGoal>("PrintGoal");
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        const BT::NodeStatus result = tree.tickRootWhileRunning();
        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        if (result == BT::NodeStatus::SUCCESS)
        {
            // 树执行后,外部程序可以从同一块 Blackboard 取得输出
            const Pose2D goal = blackboard->get<Pose2D>("mission_goal");
            std::cout << "[main] mission_goal=" << goal << '\n';
        }
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

getInput<T>() 返回的是帶有成功或錯誤資訊的結果;檢查有效後,再用 .value() 取得值。setOutput() 同樣有返回結果,本例也進行檢查;型別衝突等情況還可能丟擲異常,由 main() 統一顯示。

本例把缺少必要輸入、文本格式錯誤等作為配置或資料錯誤報告,退出碼為 2。實際任務中可以根據節點職責選擇返回 FAILURE 或使用預設值,不必把所有情況都處理成異常。

5.2.建立、寫入和讀取 Blackboard 的 API

上一篇介紹了節點內部的 getInput() 和 setOutput()。 本篇在 main() 中直接訪問 Blackboard,使用的是另一組介面。 下面列出本例用到的函式原型;宣告用於閱讀,不用再複製到工程中。

// Blackboard 类中的成员声明
static BT::Blackboard::Ptr create(BT::Blackboard::Ptr parent = {});

template<typename T>
void set(const std::string& key, const T& value);

template<typename T>
T get(const std::string& key) const;
呼叫 引數 返回值與作用
BT::Blackboard::create() 本例不傳引數;可選引數 parent 指定父 Blackboard,預設空指標 返回 BT::Blackboard::Ptr,也就是 std::shared_ptr<BT::Blackboard>,用於持有這塊 Blackboard
blackboard->set<Pose2D>("robot_pose", Pose2D{1.0, 2.0, 0.0}) 模板引數 Pose2D 指定值型別;第一個引數是鍵名,第二個引數是要儲存的值 返回 void,建立或更新條目;型別不相容等錯誤可能丟擲異常
blackboard->get<Pose2D>("mission_goal") 模板引數指定期望讀取的型別;唯一的函式引數是鍵名 返回一個 Pose2D 值;本例使用的這個過載在鍵不存在或型別轉換失敗時丟擲異常

這裡的 key 是 Blackboard 鍵。 節點內部的 getInput("target")、setOutput("goal", value) 接收的則是 節點埠名,由 XML 決定它們連線哪個鍵。

factory.createTreeFromFile(BT_XML_PATH, blackboard) 的第一個引數仍是 XML 路徑,第二個引數把剛準備好的 Blackboard 交給根樹。 這裡傳遞的是共享指標,因此節點執行時讀寫的就是 main() 中持有的同一塊資料。 get<Pose2D>() 返回值的副本,修改取得的 goal 變數不會自動更新 Blackboard; 需要更新時仍應呼叫 set()。

5.3.帶預設值的輸入埠與雙向埠

本篇第一次使用 InputPort<T>() 的三引數過載:

template<typename T = void>
std::pair<std::string, BT::PortInfo> InputPort(
    BT::StringView name,
    const T& default_value,
    BT::StringView description);

以 BT::InputPort<double>("distance", 1.0, "前进距离,单位为米") 為例:

專案 含義
模板引數 double 輸入值的 C++ 型別
第一個引數 "distance" 節點自己的埠名稱,對應 XML 中的 distance 屬性
第二個引數 1.0 沒有配置該輸入時使用的預設值
第三個引數 埠的文字說明,用於描述介面,不參與距離計算
返回值 一項埠宣告,由 providedPorts() 收集後交給工廠

在 3.8.7 中,這個預設值過載需要三個引數;沒有說明文字時可以把第三個引數寫成 ""。前面見過的兩引數形式 InputPort<T>(name, description) 中,第二個引數是說明文字,不能把兩種過載混為一談。

BT::BidirectionalPort<Pose2D>("goal") 宣告雙向埠:模板引數指定 Pose2D 型別,第一個引數指定埠名;可選的第二個引數也是說明文字。它同樣返回一項埠宣告。OffsetGoal 隨後用 getInput<Pose2D>("goal") 取得原值,再用 setOutput("goal", goal) 寫回修改後的值;宣告雙向埠本身不會執行讀寫。

5.4.拆分文本與轉換自定義型別的 API

下面兩個介面來自 BT 名稱空間:

std::vector<BT::StringView> splitString(
    const BT::StringView& strToSplit, char delimeter);

template<typename T>
T convertFromString(BT::StringView str);
呼叫 引數 返回值
splitString(text, ';') 第一個引數是待拆分的文本;第二個引數是分隔字元 ';' std::vector<BT::StringView>,本例應得到 x、y、yaw 三個文本片段
convertFromString<double>(parts[0]) 模板引數 double 指定目標型別;函式引數是一個數值文本片段 轉換後的 double;無效文本會報告轉換錯誤
convertFromString<Pose2D>(text) 模板引數 Pose2D 指定自定義型別;函式引數是完整的位姿文本 本例特化產生的 Pose2D;欄位數量錯誤、數值無效等情況丟擲異常

BT::StringView 是不擁有字元資料的字串檢視,拆分得到的片段仍引用原始文本。本例在轉換函式內立即使用它們,不把這些檢視儲存為節點成員。

template <> 表示為 Pose2D 提供這個函式模板的特化。節點讀取 XML 固定文本時,getInput<Pose2D>() 會通過庫的轉換機制呼叫它,主程式不需要再手動轉換一次;從 Blackboard 讀取已經儲存的 Pose2D 時則直接取得對應型別的值。

5.5.連續驅動行為樹與輔助函式

tree.tickRootWhileRunning() 把重複 tick 的迴圈放在庫內部,其原型為:

BT::NodeStatus tickRootWhileRunning(
    std::chrono::milliseconds sleep_time = std::chrono::milliseconds(10));

唯一的可選引數 sleep_time 表示兩次 tick 之間的最長等待時間,預設 10 毫秒。本例省略它。 函式先執行一次根 tick;只有結果為 RUNNING 時才等待並繼續 tick,直到得到最終結果,返回 BT::NodeStatus。 本例全部是同步節點,正常情況下第一次根 tick 就得到 SUCCESS。

程式中另外幾個輔助介面可以按下面理解:

寫法 引數與結果
name() 節點成員函式,無引數,返回當前例項名稱的 const std::string&;本例兩個列印節點分別叫 computed 和 literal
std::isfinite(value) 傳入一個浮點數,返回 bool,檢查它是否為有限值
std::cos(yaw)、std::sin(yaw) 傳入以弧度表示的角度,返回餘弦、正弦值,用於分解前進距離
std::fixed、std::setprecision(2) 前者設定固定小數格式,後者的引數 2 指定顯示兩位小數;只改變輸出格式,不改變 Blackboard 中的資料

5.6.trees/mission.xml

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <Sequence name="goal_pipeline">
            <CalculateGoal start="{robot_pose}"
                           distance="{travel_distance}"
                           goal="{mission_goal}"/>
            <OffsetGoal goal="{mission_goal}" offset_x="0.5"/>
            <PrintGoal name="computed" target="{mission_goal}"/>
            <PrintGoal name="literal" target="2.0;3.0;1.57"/>
        </Sequence>
    </BehaviorTree>
</root>

兩個 PrintGoal 是同一 C++ 型別的不同例項。name 幫助區分輸出,target 則分別配置為 Blackboard 引用和固定文本。


6.在 VSCode 中執行並跟蹤資料變化

儲存所有修改後,在新視窗的 CMake 面板中選擇 Linux Debug,對應 linux-debug Configure 預設,再點選 Configure。確認配置成功後,選擇同名的 Build 預設並點選 Build。

截圖預留:bt_ports 配置和構建成功。 展示當前 linux-debug 預設、構建成功資訊,以及新工程名。

在 Launch 中選擇 bt_ports,點選執行按鈕,在整合終端中檢視輸出。新視窗的執行目標應是 bt_ports,與上一篇的 bt_first_tree 區分開。

截圖預留:選擇 bt_ports 執行目標。 展示 CMake Tools 的 Launch 目標和執行按鈕。

預期輸出:

[CalculateGoal] distance=2.00, goal=(3.00, 2.00, 0.00)
[OffsetGoal] goal=(3.50, 2.00, 0.00)
[PrintGoal:computed] target=(3.50, 2.00, 0.00)
[PrintGoal:literal] target=(2.00, 3.00, 1.57)
Tree result: SUCCESS
[main] mission_goal=(3.50, 2.00, 0.00)

完成工程後,分別檢查固定值、預設值、缺失輸入和錯誤格式等情況,確認埠型別、轉換行為與錯誤輸出符合對應示例的說明。

資料變化可以對應到每一步:

執行位置 讀取什麼 寫入或使用什麼
main() 初始化 編輯器中設定的模擬距離、示例位姿 robot_pose 和 travel_distance
CalculateGoal 起點與距離 寫入 mission_goal = (3.0, 2.0, 0.0)
OffsetGoal 已有目標與 x 偏移 更新 mission_goal = (3.5, 2.0, 0.0)
PrintGoal:computed mission_goal 列印當前值
PrintGoal:literal XML 固定文本 轉換後列印,不修改 mission_goal
main() 讀取結果 mission_goal 取得樹計算後的目標

所有節點都是快速同步動作,本次序列在一次根節點 tick 內完成。Sequence 負責先後順序,Ports 與 Blackboard 負責傳值;共同對映到一個鍵不會自動安排生產者先執行。

與上一篇相同,程式使用構建配置提供的 BT_XML_PATH,自動讀取當前原始碼工程的 trees/mission.xml。在 CMake Tools 中啟動程式時,直接使用同一份檔案,無需另外設定 XML 路徑引數。

6.1.改變外部輸入,節點取得新的數值

保持 XML 不變,在編輯器中將 src/main.cpp 的 main() 內模擬距離改為:

const double distance = 4.0;

儲存後點擊 Build → Launch。這裡改的是 C++,因此需要先重新構建。

這次先計算出 (5.0, 2.0, 0.0),再偏移到 (5.5, 2.0, 0.0)。computed 例項和 main() 都取得新結果,literal 例項仍列印 XML 中的固定座標。

對照完成後,將模擬距離恢復為 2.0 並點選 Build,後面繼續使用初始設定。

這裡每次執行程式都會重新建立 Blackboard。 程式持續執行時,也可以由外部更新輸入,再發起下一次任務; 應讓更新與樹執行有明確的排程關係,本例沒有引入併發讀寫。

6.2.在 XML 中切換距離來源

將 CalculateGoal 的 distance 屬性改為固定文本:

<CalculateGoal start="{robot_pose}" distance="3.0" goal="{mission_goal}"/>

儲存 trees/mission.xml 後再次點選 Launch,計算節點使用 3 米,最終目標為 (4.5, 2.0, 0.0)。外部程式仍然寫入 travel_distance,但該節點已不再從這個條目讀取距離。

如果完全省略 distance 屬性:

<CalculateGoal start="{robot_pose}" goal="{mission_goal}"/>

則使用宣告的預設值 1 米,最終目標為 (2.5, 2.0, 0.0)。這兩種修改都只調整 XML,儲存後點擊 Launch 即可,無需手動先點 Build。繼續後面的示例前,可以恢復主示例的 distance="{travel_distance}"。

6.3.用斷點觀察目標被讀取和寫回

恢復主示例的距離和 XML 配置,儲存並點選 Build。 在 CalculateGoal::tick() 列印目標的語句處,以及 OffsetGoal::tick() 列印目標的語句處分別設定斷點,再點選 CMake Tools 的 Debug。

第一次暫停時,在變數視窗展開本地變數 goal,可以看到 (3.0, 2.0, 0.0);按 F5 繼續到偏移節點的斷點,同名本地變數的 x 座標已經變成 3.5。再繼續執行,終端會顯示完整結果。兩次斷點所在的函式不同,本地變數也分別屬於各自回撥。


7.輸入錯誤要在讀取位置明確處理

本例常見的資料問題可以按發生位置區分:

修改 發生什麼 本例的處理
將 target 改為 {unknown_goal} 找不到有效的輸入值 getInput() 失敗,報告讀取錯誤
將固定座標改為 2.0;3.0 欄位數量不符合約定 轉換函式報告 Pose2D requires x;y;yaw
將距離改為 distance="far" 文本無法轉換成 double 讀取失敗,報告轉換錯誤
把 OffsetGoal 移到 CalculateGoal 前面 目標還沒有被寫入 讀取雙向埠失敗,後面的節點不再執行

這些 XML 對照都在編輯器中修改並儲存,再點選 Launch,在整合終端中檢視錯誤資訊。

工廠可以在建立樹時建立埠和條目的關係,但這不代表每個輸入已經有業務值。資料是否準備好,仍要看外部初始化與前面節點的執行結果。

外部讀取也要使用對應型別。這裡呼叫 blackboard->get<Pose2D>("mission_goal"),不能因為打印出來像文本,就改成讀取 std::string。直接訪問 Blackboard 的 get() 與節點的 getInput() 是不同介面,不應假定它會執行相同的文本轉換。


8.Blackboard 資料不會隨節點狀態自動清空

本例在 CalculateGoal 中產生目標,在 OffsetGoal 中更新目標;樹任務結束後,main() 仍能讀到最終值。 節點結束或被重置到 IDLE,不會自動刪除這些 Blackboard 條目。

這也意味著,上一次任務留下的值不能自動代表下一次任務已經準備好。 組織重複任務時,應明確哪些輸入由外部更新,哪些輸出由當前任務重新產生,以及失敗後是否允許繼續使用已有結果。

本例中的輸出節點每次成功執行都會重新寫入目標,再由後續節點讀取。後面使用子樹時,還需要明確不同 Blackboard 範圍之間的資料對映;共享一個鍵的寫法不能直接套用到所有子樹。


9.當前學習進度

本篇把輸入、輸出、雙向埠、自定義型別轉換以及外部 Blackboard 讀寫串成了完整工程。 下一篇學習持续任务与中止:用 StatefulActionNode 模擬導航,分別處理啟動、執行檢查和取消,讓任務能夠跨越多次 tick。