跳到正文

Wiki

持續任務與中止

約 23 分鐘閱讀

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

1.讓導航任務跨越多次 tick

對應 BehaviorTree.CPP 3.8 官方文件:Reactive and Asynchronous behaviors。實現細節參考 3.8.7 的 action_node.cpp、reactive_sequence.cpp 和 bt_factory.h。

前兩篇實踐分別完成了最小行為樹和節點間的資料傳遞。 如果還沒有執行,可以先完成它們,再進入本篇。 本篇仍使用模擬資料,集中解決一個問題:導航還沒結束時,節點怎樣立即返回,後面怎樣繼續檢查,以及外部條件變化後怎樣中止它。

先明確本篇的模擬規則:程式用計數器表示導航進度,約定檢查三次就算任務完成。 目標暫時用 loading_area 這樣的字串表示。 這段程式沒有控制機器人移動,也沒有讀取機器人的實際位置;日誌中的 arrived 表示滿足了我們設定的模擬完成條件。 先用這個簡單規則看清行為樹的呼叫過程,後面再說明真實導航應替換哪一部分。

本例中的 ticks="3" 表示執行三次 onRunning(),不是三秒,也不是主程式只調用三次 tickRoot()。 開始任務還要單獨呼叫一次 onStart(),所以正常完成需要四次根 tick,後面會逐次展開。

ReactiveSequence
├── NavigationAllowed   每次根 tick 都重新检查是否允许导航
└── NavigateToGoal      开始一次任务,后续 tick 检查进度
                         ├── 未完成:RUNNING
                         ├── 已到达:SUCCESS
                         └── 被中止:执行 onHalted()

2.用三個回撥管理一個持續任務

SyncActionNode 用於一次呼叫就能給出結果的動作,不能用它返回 RUNNING。 持續任務使用 StatefulActionNode,實現下面三個回撥:

回撥 呼叫時機 本例做什麼 返回值
onStart() 節點從 IDLE 開始執行 讀取目標和所需次數,將計數清零 RUNNING、SUCCESS 或 FAILURE
onRunning() 當前節點為 RUNNING,再次收到 tick 將計數加一,判斷是否達到所需次數 RUNNING、SUCCESS 或 FAILURE
onHalted() 正在 RUNNING 的節點被中止 列印中止資訊,清零模擬計數 void

三個回撥都沒有函式引數,由 StatefulActionNode 在對應時機呼叫,不需要在 main() 中直接呼叫。 它們的宣告分別是 BT::NodeStatus onStart() override、BT::NodeStatus onRunning() override 和 void onHalted() override;override 表示實現基類規定的虛擬函式。 回撥通過當前節點的 getInput() 讀取輸入,通過物件成員儲存執行進度。

onStart() 返回 RUNNING 後,下一次 tick 才進入 onRunning();執行期間不會每次都重新呼叫 onStart()。 兩個有返回值的回撥都不能返回 IDLE。

StatefulActionNode 本身不會自動建立工作執行緒。 本例的回撥都在主程式驅動行為樹的執行緒裡執行,所以每次回撥要儘快返回。 真實導航可以在 onStart() 中傳送一次請求,在 onRunning() 中檢查請求結果,在 onHalted() 中發起取消。

2.1.從主程式的一次 tick 找到回撥呼叫者

主程式驅動行為樹時,呼叫的是:

result = tree.tickRoot();

tickRoot() 沒有引數,返回這一次根 tick 的 BT::NodeStatus。 本例中,條件允許導航時,呼叫會沿著下面的路徑傳遞:

main() 调用 tree.tickRoot()
    ↓
ReactiveSequence 从第一个子节点开始执行
    ↓
NavigationAllowed 检查是否允许导航
    ↓ 返回 SUCCESS
轮到 NavigateToGoal 节点
    ↓
库中 StatefulActionNode 的 tick() 检查导航节点的当前状态
    ├── IDLE    → 调用 onStart()
    └── RUNNING → 调用 onRunning()
    ↓
回调返回的状态沿父节点传回 main() 的 result

NavigateToGoal 繼承 BT::StatefulActionNode,因此直接使用了基類已經寫好的這套分派規則。 我們實現 onStart()、onRunning() 和 onHalted(),決定每個時機具體做什麼。 基類呼叫這些虛擬函式時,會執行當前 NavigateToGoal 物件提供的實現。 宣告中的 override 用於讓編譯器檢查是否正確覆蓋了基類虛擬函式;實際呼叫由庫的執行流程發起,不是由這個關鍵字發起。

因此,第一次執行導航節點時,節點狀態為 IDLE,進入 onStart();它返回 RUNNING 後,框架儲存這個狀態。 下一次根 tick 再次執行到導航節點,基類看到它仍為 RUNNING,才進入 onRunning()。 這些規則對應 3.8.7 StatefulActionNode 的實現。

2.2.onStart 準備一次任務,返回後把執行權交回去

先看本篇稍後要填寫的導航 XML:

<NavigateToGoal goal="loading_area" ticks="3"/>

埠 goal 接收目標字串,埠 ticks 接收模擬完成所需的執行檢查次數。 onStart() 通過 getInput<std::string>("goal") 和 getInput<int>("ticks") 讀取並檢查它們,然後執行下面的關鍵語句。 這裡先逐段理解,後面的完整程式再統一複製到工程中:

goal_ = goal.value();              // 保存本次目标:loading_area
required_ticks_ = ticks.value();   // 保存完成条件:检查三次
completed_ticks_ = 0;              // 开始新任务,目前检查了零次
return BT::NodeStatus::RUNNING;    // 报告任务已开始,还没有完成
成員變數 本次儲存的值 含義
goal_ "loading_area" 本次導航目標
required_ticks_ 3 達到多少次執行檢查就算完成
completed_ticks_ 0 已經執行過多少次執行檢查

return RUNNING 會結束這一次函式呼叫,並把執行權交回父節點,最終回到主程式。 它報告的是“任務尚未完成”;函式返回後,沒有一個 onStart() 還在後臺繼續執行。 這幾個變數是節點物件的成員,物件仍然存在,所以儲存的目標和計數可以留到下一次回撥使用。

2.3.onRunning 每次加一,由節點自己判斷是否成功

下面只保留 onRunning() 中與計數相關的語句,便於看清完成條件:

++completed_ticks_;  // 每进入一次 onRunning,就把已检查次数加一

if (completed_ticks_ >= required_ticks_)
{
    return BT::NodeStatus::SUCCESS;  // 达到约定次数,报告模拟任务完成
}
return BT::NodeStatus::RUNNING;      // 次数还不够,等待后续 tick 再检查

++completed_ticks_ 相當於把原來的計數加一,再儲存回這個成員變數。 第一次進入 onRunning() 時,計數從 0 變成 1。接著判斷 1 >= 3,條件不成立,就跳過大括號,執行最後的 return RUNNING。 第二次進入時,計數從 1 變成 2,仍然返回 RUNNING。 第三次進入時,計數從 2 變成 3,3 >= 3 成立,執行大括號裡的 return SUCCESS,本次函式就結束了,不會再執行下面的 return RUNNING。

這裡之所以返回成功,是因為我們編寫節點時,把“計數達到三次”定義成了模擬任務的完成條件。 BehaviorTree.CPP 接收節點報告的狀態,再按樹的規則決定後續流程;它不會根據目標字串自行判斷機器人是否到達。 完整程式中的 std::cout 只負責列印日誌,列印 arrived 本身不會產生導航行為。

2.4.把四次根 tick 連起來看

先保持條件始終允許導航。主程式每次呼叫 tree.tickRoot(),都會先檢查 NavigationAllowed,再執行導航節點:

根 tick 導航回撥 計數怎樣變化 完成條件 本次根節點返回
第一次 onStart() 初始化為 0 本次只准備任務 RUNNING
第二次 onRunning() 0 → 1 1 >= 3,不成立 RUNNING
第三次 onRunning() 1 → 2 2 >= 3,不成立 RUNNING
第四次 onRunning() 2 → 3 3 >= 3,成立 SUCCESS

前面三次根 tick 返回 RUNNING,主程式等待一下,再發起下一次 tick。 第四次導航返回 SUCCESS,而前面的條件也已成功,ReactiveSequence 因此返回 SUCCESS;主程式發現任務完成,退出這次迴圈。 一次啟動,加上三次執行檢查,共四次根 tick。 本例兩次 tick 之間等待 100 毫秒,實際執行時間還受列印、除錯暫停和系統排程影響,計數不代表固定時長。

2.5.onHalted 處理執行中的任務被中斷

onHalted() 走的是另一條分支。當父節點要求停止導航,或主程式呼叫 tree.haltTree() 時,中止沿樹傳到導航節點。 基類用於處理中止的 StatefulActionNode::halt() 沒有引數,返回 void;它發現導航節點仍為 RUNNING,才呼叫 onHalted()。 本例在回撥裡面列印中止資訊,並把計數清零。

正常完成:
onStart() → onRunning() → onRunning() → onRunning()
 RUNNING      RUNNING       RUNNING       SUCCESS

中途中止:
onStart() → onRunning() → 收到中止要求 → onHalted()
 RUNNING      RUNNING

正常返回 SUCCESS 不會因此觸發 onHalted();它不是每次任務結束都執行的公共收尾函式。 onHalted() 返回 void,負責取消和清理,不向父節點返回 SUCCESS 或 FAILURE。後面的條件中止示例中,樹的 FAILURE 來自前置條件失敗及控制節點的執行規則。 如果接入真實導航,需要在這個回撥裡實現對應的取消或停止邏輯。


3.沿用配置建立持續任務工程

本篇直接複製整個 bt_ports 資料夾,再把副本重新命名為 bt_running,保留原來的資料傳遞工程供後續對照。

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

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

使用的是前兩篇已經準備好的 BT 練習版配置,框架來源見 CMake工程模板。Ubuntu 22.04 可以繼續使用 CMake 3.22.1;Ninja、支援 C++20 的編譯器和 VSCode CMake Tools 的準備方式與前兩篇相同。複用 Humble 安裝的行為樹庫時,配置、執行和除錯程序仍需繼承 ROS 2 環境。

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

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

頂層 CMakeLists.txt 的專案名改為:

project(bt_running VERSION 1.0.0 LANGUAGES C CXX)

其他構建配置保持原樣,BT_XML_PATH 會自動指向這個工程的 trees/mission.xml。

3.1.src/main.cpp

用下面的完整程式替換 src/main.cpp 的原有內容,再儲存。 先保留 cancel_after_two_ticks = false,執行正常完成的情況。

#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

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

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<bool>("allowed")};
    }

    BT::NodeStatus tick() override
    {
        auto allowed = getInput<bool>("allowed");
        if (!allowed)
        {
            throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
        }
        std::cout << "[NavigationAllowed] allowed="
                  << (allowed.value() ? "true" : "false") << '\n';
        return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
    }
};

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", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();             // 保存本次目标,供后续回调使用
        required_ticks_ = ticks.value();  // 保存模拟完成所需的检查次数,本例为 3
        completed_ticks_ = 0;             // 每次开始新任务,都从零次检查开始
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        // 结束本次回调并报告“尚未完成”,后续 tick 再进入 onRunning。
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次进入这个回调,模拟检查次数就加一;函数返回后成员计数仍会保留。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        // 本例把“检查次数达到要求”当作完成条件,没有查询机器人的真实位置。
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            // 已满足模拟完成条件,向父节点报告成功,本次函数到这里结束。
            return BT::NodeStatus::SUCCESS;
        }
        // 尚未达到要求,结束本次检查,等待后续 tick 再进入这个回调。
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 运行中的任务收到中止要求时,基类才会调用这个回调。
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;          // 本次目标
    int required_ticks_ = 0;    // 模拟完成所需的运行检查次数,由 onStart 读取
    int completed_ticks_ = 0;   // 已完成的运行检查次数,可跨多次回调保存
};

int main()
{
    // 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。(用于测试onHalted()触发)
    const bool cancel_after_two_ticks = false;

    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");

        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            std::cout << "--- tick " << ++tick_count << '\n';
            // 从根驱动一次;执行到导航节点时,由基类按当前状态选择回调。
            result = tree.tickRoot();
            std::cout << "Root returned: " << BT::toStr(result) << '\n';
            if (result != BT::NodeStatus::RUNNING)
            {
                break;
            }

            if (cancel_after_two_ticks && tick_count == 2)
            {
                blackboard->set("navigation_allowed", false);
                std::cout << "[main] navigation is no longer allowed\n";
            }
            // 等待放在主循环中,节点回调本身不长时间阻塞。
            tree.sleep(std::chrono::milliseconds(100));
        }

        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

goal_、required_ticks_、completed_ticks_ 是節點物件的成員,能夠跨越多次回撥儲存當前任務的資料。 main() 中的 tick_count 則統計根節點執行次數,兩者含義不同。

3.2.tree.sleep() 的引數與作用

前一篇的 tickRootWhileRunning() 在庫內部等待並重復 tick。 本篇需要在兩次 tick 之間模擬條件變化,因此把迴圈寫在 main() 中,並直接使用 BT::Tree 的等待介面:

void sleep(std::chrono::system_clock::duration timeout);

作用:讓驅動樹的當前執行緒等待一段時間,為下一次 tick 留出間隔。唯一的引數 timeout 是最長等待時長;std::chrono::milliseconds(100) 構造一個表示 100 毫秒 的時長物件,可以傳給這個引數。這個等待也可以被庫的喚醒通知提前結束。

返回值:void。tree.sleep() 本身不會執行節點,也不會建立導航執行緒;等待結束後,主程式仍需呼叫 tree.tickRoot()。後者沒有引數,返回這一次根 tick 的 BT::NodeStatus。本例在結果為 RUNNING 時繼續迴圈,在成功或失敗時退出。

其餘註冊、埠和 Blackboard 介面沿用前兩篇的含義。例如 blackboard->set("navigation_allowed", true) 中,第一個引數是鍵名,第二個引數是布林值,模板型別由 true 推導為 bool;InputPort<int>("ticks", 3, "...") 的三個引數依次是埠名、預設值和說明。

3.3.trees/mission.xml

用下面的 XML 替換 trees/mission.xml 的原有內容,再儲存。

<root main_tree_to_execute="MainTree">
    <BehaviorTree ID="MainTree">
        <ReactiveSequence>
            <NavigationAllowed allowed="{navigation_allowed}"/>
            <NavigateToGoal goal="loading_area" ticks="3"/>
        </ReactiveSequence>
    </BehaviorTree>
</root>

普通節點埠仍然使用前一篇的寫法:條件節點從父程式寫入的 {navigation_allowed} 讀取布林值,導航節點從固定文本讀取目標和次數。


4.先觀察一次完整的導航

儲存全部檔案,在 CMake Tools 中選擇 Linux Debug,執行 Configure → Build,將執行目標設為 bt_running,再點選 Launch。本例的完整輸出為:

--- tick 1
[NavigationAllowed] allowed=true
[NavigateToGoal] start goal=loading_area
Root returned: RUNNING
--- tick 2
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=1/3
Root returned: RUNNING
--- tick 3
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=2/3
Root returned: RUNNING
--- tick 4
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=3/3
[NavigateToGoal] arrived goal=loading_area
Root returned: SUCCESS
Tree result: SUCCESS

這組輸出對應前面的四次根 tick 表:第一次只有 start,表示進入 onStart();後面三次出現 progress,分別表示進入三次 onRunning()。 最後一次先列印 progress=3/3,隨後因為計數達到要求,列印 arrived 並返回 SUCCESS,所以主程式最後顯示 Tree result: SUCCESS。 這次完成的是我們約定的模擬任務,整個過程中不會呼叫 onHalted()。

主程式在 RUNNING 時繼續 tick,在 SUCCESS 或 FAILURE 時結束本次任務,並用 tree.sleep() 留出等待間隔。等待放在主迴圈裡,節點檢查本身保持簡短;把幾秒鐘的等待直接塞進 onRunning(),會同時拖慢整棵樹對條件變化的響應。

3.8.7 的 tickRoot() 在返回終態時還會重置根節點狀態,因此應儲存它的返回值作為本次任務結果。稍後檢視 rootNode()->status() 時可能已經是 IDLE,不能據此否定剛剛返回的 SUCCESS 或 FAILURE。


5.條件改變後中止正在執行的導航

在 main() 中將已有的設定改為:

const bool cancel_after_two_ticks = true;

儲存後 Build → Launch。主程式會在第二次根 tick 結束後,將 navigation_allowed 改成 false,模擬外部系統不再允許繼續導航。輸出為:

--- tick 1
[NavigationAllowed] allowed=true
[NavigateToGoal] start goal=loading_area
Root returned: RUNNING
--- tick 2
[NavigationAllowed] allowed=true
[NavigateToGoal] progress=1/3
Root returned: RUNNING
[main] navigation is no longer allowed
--- tick 3
[NavigationAllowed] allowed=false
[NavigateToGoal] halted goal=loading_area, progress=1
Root returned: FAILURE
Tree result: FAILURE

第三次根 tick 先檢查 NavigationAllowed。條件失敗後,ReactiveSequence 中止還在執行的導航,觸發 onHalted(),並將失敗結果返回給主程式。此時導航不會再收到第三次根 tick 對應的執行檢查,所以列印的進度仍是 1。

這次中止的先後關係為:

第二次根 tick 结束:导航仍为 RUNNING,已检查一次
    ↓
main() 将 Blackboard 中的 navigation_allowed 改为 false
    ↓ 此时只是更新数据
第三次根 tick:NavigationAllowed 读取新值,返回 FAILURE
    ↓
ReactiveSequence 中止正在运行的导航
    ↓
StatefulActionNode::halt() 调用 NavigateToGoal::onHalted()
    ↓
ReactiveSequence 返回 FAILURE,传回 main()

因此,寫入 false 本身沒有直接呼叫 onHalted();本例要等下一次根 tick 重新檢查條件,才會觸發中止。 onHalted() 中的日誌報告被中斷時的進度,隨後清零計數;它沒有返回 FAILURE,失敗結果由前置條件和父控制節點決定。

本例的取消只是清理模擬計數。對真實機器人,onHalted() 中還需要實現對應的取消請求或停止邏輯,行為樹本身不會替你完成裝置控制。


6.對照 Sequence 怎樣處理同一個變化

保持取消設定為 true,將 XML 中的 <ReactiveSequence> 和 </ReactiveSequence> 分別改為 <Sequence> 和 </Sequence>。 儲存 XML 後直接 Launch,無需手動重新編譯 C++。

這次雖然主程式仍會列印 navigation is no longer allowed,導航卻繼續執行到成功。 在本次任務的 RUNNING 期間,普通 Sequence 從正在執行的導航繼續,不會每次都重新檢查已經成功的前置條件。

本例中的行為 Sequence ReactiveSequence
正常導航期間檢查條件 開始時一次 每次根 tick
第二次根 tick 後將條件改成 false 導航繼續完成 下一次根 tick 中止導航
是否呼叫導航的 onHalted() 否 是
這次任務的結果 SUCCESS FAILURE

這個差別發生在一次尚未結束的任務內。 普通 Sequence 結束並重置後,下一次任務仍會從前面的條件重新開始。

  • Sequence:直接繼續導航,跳過前面已經成功的條件。
  • ReactiveSequence:從第一個子節點重新執行,再檢查條件;如果條件變成 FAILURE,就中止正在執行的導航。

ReactiveSequence它每次都從第一個子節點開始執行,前一個成功了,才繼續下一個。 比如前面放的不是條件檢查,而是一個列印日誌的動作:

ReactiveSequence
├── PrintMessage     打印“准备导航”,然后返回 SUCCESS
└── NavigateToGoal   执行导航,未完成时返回 RUNNING
第一次 tick:打印“准备导航” → 启动导航
第二次 tick:又打印“准备导航” → 检查导航进度
第三次 tick:又打印“准备导航” → 检查导航进度
……

完成對照後,將 XML 恢復為 ReactiveSequence,再進行後面的觀察。


7.主程式主動退出時先停止樹

前面的中止由條件失敗觸發。 主程式也可以主動決定停止當前任務,並呼叫 tree.haltTree(),讓中止沿樹傳播。

保持 cancel_after_two_ticks = true,將 main() 中修改 navigation_allowed 的整個 if 程式碼塊替換為:

            if (cancel_after_two_ticks && tick_count == 2)
            {
                std::cout << "[main] stop requested\n";
                tree.haltTree();
                std::cout << "Tree halted: " << BT::toStr(tree.rootNode()->status()) << '\n';
                return 3;
            }
#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

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

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<bool>("allowed")};
    }

    BT::NodeStatus tick() override
    {
        auto allowed = getInput<bool>("allowed");
        if (!allowed)
        {
            throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
        }
        std::cout << "[NavigationAllowed] allowed="
                  << (allowed.value() ? "true" : "false") << '\n';
        return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
    }
};

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", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();
        required_ticks_ = ticks.value();
        completed_ticks_ = 0;
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次只检查一步,立即把执行权交回行为树。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            return BT::NodeStatus::SUCCESS;
        }
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;
    int required_ticks_ = 0;
    int completed_ticks_ = 0;
};

int main()
{
    // 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。
    const bool cancel_after_two_ticks = true;

    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");

        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            std::cout << "--- tick " << ++tick_count << '\n';
            result = tree.tickRoot();
            std::cout << "Root returned: " << BT::toStr(result) << '\n';
            if (result != BT::NodeStatus::RUNNING)
            {
                break;
            }

            if (cancel_after_two_ticks && tick_count == 2)
            {
                // blackboard->set("navigation_allowed", false);
                // std::cout << "[main] navigation is no longer allowed\n";
                std::cout << "[main] stop requested\n";
                tree.haltTree();
                std::cout << "Tree halted: " << BT::toStr(tree.rootNode()->status()) << '\n';
                return 3;
            }
            // 等待放在主循环中,节点回调本身不长时间阻塞。
            tree.sleep(std::chrono::milliseconds(100));
        }

        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

儲存後 Build → Launch。程式會在第二次根 tick 後直接停止樹,輸出末尾為:

[main] stop requested
[NavigateToGoal] halted goal=loading_area, progress=1
Tree halted: IDLE

這次沒有再執行第三次根 tick,也沒有讓條件返回失敗。主程式主動停止任務,並列印停止後的根狀態 IDLE。示例用退出碼 3 表示主動停止;正常成功為 0,任務失敗為 1,異常報告為 2。

haltTree() 會重置節點狀態,但不會自動清空 Blackboard,也不會替你重置所有 C++ 成員。 本例在 onStart() 初始化本次進度,在 onHalted() 清理中斷時的進度,分別明確處理下一次任務的起點。

7.1.停止樹與檢視狀態的 API

上面的停止程式碼使用了三個無引數成員函式:

介面 所屬物件 返回值與作用
haltTree() BT::Tree 返回 void,停止當前樹並將節點狀態重置為 IDLE;執行中的 StatefulActionNode 會進入 onHalted()
rootNode() BT::Tree 返回 BT::TreeNode*,指向根節點;空樹會返回 nullptr,本例已經成功建立樹,因此可以訪問根節點
status() BT::TreeNode 返回 BT::NodeStatus,讀取節點當前儲存的狀態,不執行 tick

tree.rootNode()->status() 先取得根節點指標,再讀取狀態,最後由 BT::toStr(...) 轉成字串用於列印。這裡在 haltTree() 後讀取,所以得到 IDLE。注意,3.8.7 的 tickRoot() 在返回 SUCCESS 或 FAILURE 時也會把根節點儲存的狀態重置為 IDLE;判斷一次 tick 的執行結果,應使用 tickRoot() 的返回值。

ReactiveSequence          ← 根节点,rootNode() 指向它
├── NavigationAllowed     ← 它的第一个子节点
└── NavigateToGoal        ← 它的第二个子节点


8.在 VSCode 中觀察三個回撥

8.1.先觀察正常完成時怎樣從啟動進入執行檢查

恢復完整程式中原來的條件更新程式碼塊,將 cancel_after_two_ticks 改為 false, XML 使用 ReactiveSequence。

#include "behaviortree_cpp_v3/bt_factory.h"

#include <chrono>
#include <iostream>
#include <stdexcept>
#include <string>

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

    static BT::PortsList providedPorts()
    {
        return {BT::InputPort<bool>("allowed")};
    }

    BT::NodeStatus tick() override
    {
        auto allowed = getInput<bool>("allowed");
        if (!allowed)
        {
            throw BT::RuntimeError("Cannot read input [allowed]: ", allowed.error());
        }
        std::cout << "[NavigationAllowed] allowed="
                  << (allowed.value() ? "true" : "false") << '\n';
        return allowed.value() ? BT::NodeStatus::SUCCESS : BT::NodeStatus::FAILURE;
    }
};

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", 3, "完成导航所需的 onRunning 调用次数")};
    }

    BT::NodeStatus onStart() override
    {
        auto goal = getInput<std::string>("goal");
        auto ticks = getInput<int>("ticks");
        if (!goal)
        {
            throw BT::RuntimeError("Cannot read input [goal]: ", goal.error());
        }
        if (!ticks)
        {
            throw BT::RuntimeError("Cannot read input [ticks]: ", ticks.error());
        }
        if (goal.value().empty() || ticks.value() < 1)
        {
            throw std::runtime_error("goal must not be empty and ticks must be positive");
        }

        goal_ = goal.value();
        required_ticks_ = ticks.value();
        completed_ticks_ = 0;
        // 真实导航通常在这里发送一次请求,本例只保存模拟任务状态。
        std::cout << "[NavigateToGoal] start goal=" << goal_ << '\n';
        return BT::NodeStatus::RUNNING;
    }

    BT::NodeStatus onRunning() override
    {
        // 每次只检查一步,立即把执行权交回行为树。
        ++completed_ticks_;
        std::cout << "[NavigateToGoal] progress=" << completed_ticks_
                  << '/' << required_ticks_ << '\n';
        if (completed_ticks_ >= required_ticks_)
        {
            std::cout << "[NavigateToGoal] arrived goal=" << goal_ << '\n';
            return BT::NodeStatus::SUCCESS;
        }
        return BT::NodeStatus::RUNNING;
    }

    void onHalted() override
    {
        // 真实导航在这里取消请求、停止当前任务并清理资源。
        std::cout << "[NavigateToGoal] halted goal=" << goal_
                  << ", progress=" << completed_ticks_ << '\n';
        completed_ticks_ = 0;
    }

private:
    std::string goal_;
    int required_ticks_ = 0;
    int completed_ticks_ = 0;
};

int main()
{
    // 先用 false 跑通;改为 true 后,在第二次根 tick 后禁止继续导航。
    const bool cancel_after_two_ticks = false;

    try
    {
        BT::BehaviorTreeFactory factory;
        factory.registerNodeType<NavigationAllowed>("NavigationAllowed");
        factory.registerNodeType<NavigateToGoal>("NavigateToGoal");

        auto blackboard = BT::Blackboard::create();
        blackboard->set("navigation_allowed", true);
        auto tree = factory.createTreeFromFile(BT_XML_PATH, blackboard);

        BT::NodeStatus result = BT::NodeStatus::RUNNING;
        int tick_count = 0;
        while (result == BT::NodeStatus::RUNNING)
        {
            std::cout << "--- tick " << ++tick_count << '\n';
            result = tree.tickRoot();
            std::cout << "Root returned: " << BT::toStr(result) << '\n';
            if (result != BT::NodeStatus::RUNNING)
            {
                break;
            }

            if (cancel_after_two_ticks && tick_count == 2)
            {
                blackboard->set("navigation_allowed", false);
                std::cout << "[main] navigation is no longer allowed\n";
                return 3;
            }
            // 等待放在主循环中,节点回调本身不长时间阻塞。
            tree.sleep(std::chrono::milliseconds(100));
        }

        std::cout << "Tree result: " << BT::toStr(result) << '\n';
        return result == BT::NodeStatus::SUCCESS ? 0 : 1;
    }
    catch (const std::exception& error)
    {
        std::cerr << "Error: " << error.what() << '\n';
        return 2;
    }
}

選擇 linux-debug,先 Build,然後在下面幾行設定斷點,點選 CMake Tools 的 Debug:

斷點位置 暫停時看什麼 對應前面的講解
onStart() 中列印 start 的語句 goal_ 為 "loading_area",required_ticks_ 為 3,completed_ticks_ 為 0 第一次根 tick 只准備任務
onRunning() 中的 ++completed_ticks_ 第一次暫停時計數為 0,按 F10 執行這一行後變成 1 每次執行檢查只把計數加一
onRunning() 中的 if (completed_ticks_ >= required_ticks_) 三次到這裡時,計數依次為 1、2、3,要求始終為 3 前兩次條件不成立,第三次成立

斷點暫停在某一行時,先看執行這一行之前的值;F10 表示單步執行,F5 表示繼續執行到下一個斷點。 同一次 onRunning() 中,先執行加一,再到達 if 判斷,所以在這兩個位置看到的計數會不同。

第一次和第二次到達 if 時,按 F10 會跳過成功分支,執行最後的 return RUNNING。 第三次到達 if 時,計數已經為 3;繼續單步會進入列印 arrived 和 return SUCCESS 的分支。 它返回後,主程式拿到本次根 tick 的 SUCCESS,結束迴圈。

在回撥斷點暫停時,還可以展開 VSCode 的 呼叫堆疊(CALL STACK),檢視是誰呼叫了當前回調。

從當前回撥沿呼叫者向外看,可以對應到導航節點的執行、父控制節點,以及 main() 中的 tree.tickRoot(),幫助理解回撥是怎樣被庫呼叫的。

8.2.再觀察執行中的任務怎樣被中止

結束這次除錯,將 cancel_after_two_ticks 改為 true,儲存後 Build。 保留 onStart() 和 onRunning() 的斷點,並在 onHalted() 列印中止資訊的語句處增加斷點,再次點選 Debug。

這次先進入 onStart(),隨後只進入一次 onRunning(),將計數加到 1。 第二次根 tick 結束後,主程式把允許標誌改為 false;繼續執行,第三次根 tick 會因為條件失敗而進入 onHalted(),此時仍能看到目標為 loading_area、計數為 1。 在清零語句上單步後,計數變成 0。這次執行不會再走到 3 >= 3 的成功分支。

執行過程 實際觀察到的回撥
關閉取消 onStart() 一次,onRunning() 三次,最後返回 SUCCESS
開啟取消 onStart() 一次,onRunning() 一次,隨後進入 onHalted(),本次根 tick 返回 FAILURE

要觀察本篇的取消邏輯,使用條件變化或 haltTree() 的對照。 VSCode 的“停止除錯”控制的是除錯程序,不能用它來保證程式一定執行到 onHalted()。

全部觀察完成後,恢復最初的完整程式和 XML,再將取消設定改回 false。 執行時依次檢查正常完成、條件中止、普通 Sequence 對照、主動停止和無效次數,並結合斷點觀察回撥順序與節點狀態。


9.接入真實導航時替換什麼

本篇用 completed_ticks_ >= required_ticks_ 作為成功條件,是為了讓回撥過程容易觀察。 接入真實導航時,應把這個計數模擬換成導航系統的請求和結果檢查:

回撥 真實導航中對應的工作
onStart() 傳送一次導航請求,儲存後續檢查需要的任務資訊
onRunning() 檢查該請求的最新結果;仍在執行就返回 RUNNING,確認到達後返回 SUCCESS,任務失敗時返回 FAILURE
onHalted() 對仍在執行的請求發起取消,並處理當前任務需要的清理

成功條件由節點實現決定,再通過返回值告訴行為樹。 因此,理解本篇程式碼時,要把“庫根據狀態選擇哪個回撥”和“節點根據什麼條件報告成功”分開看:前者由 StatefulActionNode 提供,後者由我們編寫的導航節點實現。


10.當前學習進度

本篇補齊了持續動作的生命週期、主迴圈驅動和中止處理。 下一篇组合实际任务複用持續導航,把電量檢查、子樹資料對映、有限重試和多個 XML 檔案組織成一個完整任務。