跳到正文

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。