Wiki
控制與裝飾規則
本文由簡體中文內容確定性轉換,並受版本化術語表保護。
1.控制與裝飾規則
對應 BehaviorTree.CPP 4.x 官方文檔:Learn the Basic Concepts → Introduction to BTs → Examples,並參考 Nodes Library 中的 Sequences、Fallbacks 與 Decorators。
上一篇已經知道:tick 從父節點向下傳播,狀態由子節點向上返回。現在進一步學習,父節點拿到 SUCCESS、FAILURE、RUNNING 後,具體怎樣決定下一步。
| 需要表達的任務關係 | 常用節點 |
|---|---|
| 前一步成功後,繼續下一步 | Sequence |
| 當前方案失敗後,嘗試備用方案 | Fallback |
| 修改一個節點的結果、次數或時限 | 裝飾節點 |
本篇先講普通 Sequence、普通 Fallback 和常用裝飾節點,使用移動機械人示例。
C++ 是解釋邏輯的偽代碼;
XML 只展示樹的片段,完整工程、節點註冊和 XML 文件結構後面統一學習。
2.Sequence:依次完成一組步驟
Sequence 按子節點的排列順序執行,只有前面的步驟成功,才繼續後面的步驟:
Sequence
├── CheckBattery 检查电量是否足够
├── NavigateToGoal 导航到目标
└── ReportArrival 报告到达
| 當前子節點返回 | Sequence 接下來做什麼 | Sequence 的結果 |
|---|---|---|
SUCCESS |
繼續 tick 下一個子節點 | 所有子節點成功後,返回 SUCCESS |
FAILURE |
停止本次序列,不再 tick 後面的節點 | FAILURE |
RUNNING |
停在當前子節點,等待後續繼續執行 | RUNNING |
例如 CheckBattery 返回 FAILURE,本輪不會發送導航請求,也不會報告到達;NavigateToGoal 返回 FAILURE,則跳過 ReportArrival。
2.1.一次 tick 可以繼續執行多個步驟
假設電量足夠,導航要經過幾次檢查才完成:
| 外部第幾次 tick | 本次實際執行的子節點 | Sequence 返回 |
|---|---|---|
| 第 1 次 | CheckBattery → SUCCESS,接着 NavigateToGoal → RUNNING |
RUNNING |
| 第 2 次 | NavigateToGoal → RUNNING |
RUNNING |
| 第 3 次 | NavigateToGoal → SUCCESS,接着 ReportArrival → SUCCESS |
SUCCESS |
前一步在當前調用中成功後,Sequence 可以立刻繼續 tick 下一步,不必等到下一輪。
普通 Sequence 在子節點 RUNNING 時,會記住當前位置。 因此第 2 次 tick 會繼續導航,不會重新檢查前面已經成功的 CheckBattery。
如果導航期間電量變低,這棵普通 Sequence 不會因此自動重新檢查電量並中止導航。需要持續檢查條件時,要使用合適的響應式結構,後面結合任務中止來學習。
2.2.用子節點下標理解執行位置
下面的 current_index 是節點保存的成員狀態,初始為 0:
BT::NodeStatus Sequence::tick()
{
while (current_index < children.size())
{
BT::NodeStatus status = children[current_index].executeTick();
if (status == BT::NodeStatus::RUNNING)
{
// 保留位置,下次继续 tick 同一个子节点
return BT::NodeStatus::RUNNING;
}
if (status == BT::NodeStatus::FAILURE)
{
ResetChildren();
current_index = 0;
return BT::NodeStatus::FAILURE;
}
// 本段只解释未使用前置条件的情况,暂省略 SKIPPED 分支
++current_index;
}
ResetChildren();
current_index = 0;
return BT::NodeStatus::SUCCESS;
}
這段偽代碼省略框架狀態維護、SKIPPED 處理和非法狀態檢查,不能直接當作自定義控制節點實現。
ResetChildren() 表示結束本次執行並重置子節點,不能理解為撤銷已經產生的業務效果。
這一行為可對照官方 4.10.0 sequence_node.cpp。
普通 Sequence 成功或失敗結束後,位置都會回到開頭;如果上層以後重新執行它,會從第一個子節點開始。
返回失敗不會在當前 tick 中自動從頭重試。
2.3.後面的節點不保證一定執行
下面這棵樹不能保證導航失敗後執行停止節點:
Sequence
├── NavigateToGoal
└── StopRobot
導航返回 FAILURE 時,Sequence 立即返回失敗,StopRobot 會被跳過。
動作失敗或被中止時需要的取消、停止和清理,應在動作及相關模塊中正確實現。
僅把清理動作放在 Sequence 最後,不能保證它在所有退出路徑上執行。
3.Fallback:按順序嘗試方案
Fallback 在其他框架中也常叫 Selector。
它按順序嘗試子節點,只要找到成功的方案,就結束本次選擇:
Fallback
├── NavigateToPrimaryGoal 尝试主要目标
└── NavigateToBackupGoal 主要目标失败后,尝试备用目标
| 當前子節點返回 | Fallback 接下來做什麼 | Fallback 的結果 |
|---|---|---|
FAILURE |
繼續 tick 下一個子節點 | 全部失敗後,返回 FAILURE |
SUCCESS |
不再嘗試後面的子節點 | SUCCESS |
RUNNING |
繼續等待當前方案,不啓動後面的方案 | RUNNING |
RUNNING 不算失敗。 主要目標還在導航時,不會因為“尚未到達”就立即啓動備用目標。
例如:
| 外部第幾次 tick | 本次實際執行的子節點 | Fallback 返回 |
|---|---|---|
| 第 1 次 | NavigateToPrimaryGoal → RUNNING |
RUNNING |
| 第 2 次 | NavigateToPrimaryGoal → FAILURE,接着 NavigateToBackupGoal → RUNNING |
RUNNING |
| 第 3 次 | NavigateToBackupGoal → SUCCESS |
SUCCESS |
普通 Fallback 在 RUNNING 時同樣保留當前位置。
第 3 次繼續備用導航,不會重新嘗試前面已經失敗的主要目標。
本次選擇成功或全部失敗結束後,它會重置位置;上層以後重新執行時,再從第一個子節點開始。具體可對照官方 4.10.0 fallback_node.cpp。
3.1.條件節點也能作為優先方案
例如“如果已經到達,就不用重新導航”:
Fallback
├── IsGoalReached
└── NavigateToGoal
IsGoalReached 返回 SUCCESS,這部分樹就成功結束;返回 FAILURE,才啓動導航。
這裏的成功表示“到達目標這項需求已經滿足”,所以一個條件判斷也可以讓整段任務成功,不一定非要執行動作。
4.對照兩種控制規則
| 對比項 | 普通 Sequence | 普通 Fallback |
|---|---|---|
| 表達的關係 | 一組都需要成功的步驟 | 一組可以依次嘗試的方案 |
| 哪種結果讓它繼續下一個子節點 | SUCCESS |
FAILURE |
| 哪種結果讓它提前結束 | FAILURE |
SUCCESS |
當前子節點 RUNNING 時 |
返回 RUNNING,保留當前位置 |
返回 RUNNING,保留當前位置 |
| 所有子節點都執行完 | 全部成功,返回 SUCCESS |
全部失敗,返回 FAILURE |
樹結構中的前後順序會影響結果。把子節點重新排序,可能改變先檢查什麼、先執行哪個方案,以及哪些節點會被跳過。
4.x 官方文檔還介紹了 ReactiveSequence、ReactiveFallback 和帶記憶的序列變體。本篇末尾先作對照,持續任務篇再運行完整程序;這裏的“保留 RUNNING 位置”只是在説明普通節點的行為,不能套用到所有變體。
5.裝飾節點:處理一個子節點的結果或執行方式
裝飾節點始終只有一個直接子節點,子節點也可以是整棵子樹的入口。
5.1.Inverter、ForceSuccess 與 ForceFailure
這三種節點主要處理子節點的返回結果:
| 子節點返回 | Inverter | ForceSuccess | ForceFailure |
|---|---|---|---|
SUCCESS |
FAILURE |
SUCCESS |
FAILURE |
FAILURE |
SUCCESS |
SUCCESS |
FAILURE |
RUNNING |
RUNNING |
RUNNING |
RUNNING |
Inverter 已在上一篇用路徑檢查解釋過。這裏補充兩種強制結果的用途:
Sequence
├── NavigateToGoal
└── ForceSuccess
└── UploadArrivalLog
如果“導航到達”是主要任務,而“上傳到達日誌”允許失敗,可以只給日誌動作加 ForceSuccess。日誌最終失敗時,這個裝飾節點仍返回成功;日誌還在上傳時,仍返回 RUNNING,不會立刻跳過它。
ForceFailure 則讓已結束的子節點統一報告失敗。例如,在 Fallback 中,執行完一段診斷記錄後仍希望繼續下一個方案,可以用它包裝這段記錄。
強制改變的是向父節點報告的狀態,不會讓失敗的操作實際成功,也不會撤銷已經完成的動作。使用前要確定,這個結果是否符合父節點所代表的任務含義。
5.2.RetryUntilSuccessful:失敗後重新嘗試
4.x 文檔中的 RetryNode,在 XML 中使用的註冊名稱是 RetryUntilSuccessful:
<RetryUntilSuccessful num_attempts="3">
<NavigateToGoal/>
</RetryUntilSuccessful>
這裏表示總共最多嘗試 3 次,包含第一次:
| 子節點返回 | RetryUntilSuccessful 的處理 |
|---|---|
SUCCESS |
立即返回 SUCCESS,不再重試 |
FAILURE |
記一次失敗;還有次數就重置子節點再試,用完則返回 FAILURE |
RUNNING |
返回 RUNNING,當前嘗試繼續,不消耗一次失敗次數 |
例如導航結果依次為:
第 1 次尝试:RUNNING → FAILURE(此时不会再消耗一次tickexactlyonce(tickroot),会直接进入下一次尝试)
第 2 次尝试:RUNNING → RUNNING → SUCCESS
整体结果:SUCCESS,第 3 次尝试不会发生
每次完整嘗試可以跨越多個 tick。
num_attempts="3" 限制的是嘗試次數,不是整個裝飾節點只能被 tick 三次。
根據官方 4.10.0 retry_node.cpp,
在正常由 BT::Tree 初始化喚醒機制的工程中,立即失敗的子節點可使 Retry 發出喚醒信號並先返回 RUNNING;使用 tickExactlyOnce() 時,下一次嘗試需要下一次根 tick。
使用會處理喚醒的 tickOnce() 或 tickWhileRunning() 時,一次外部調用又可能包含多次根 tick。
子節點已處於 RUNNING 後再失敗,也可能在同一根 tick 立即啓動下一次嘗試。
這個節點沒有自動增加重試間隔;對子節點反覆檢查,不等於等待環境或傳感器數據變化。
5.3.Repeat:成功後繼續重複
Repeat 用於重複完成一個操作:
<Repeat num_cycles="3">
<CaptureObservation/>
</Repeat>
這裏假設 CaptureObservation 每次執行會採集並保存一份觀測數據。任務是成功完成 3 次採集:
| 子節點返回 | Repeat 的處理 |
|---|---|
SUCCESS |
記一次成功;未到次數則重置子節點繼續,達到次數後返回 SUCCESS |
FAILURE |
立即停止重複,返回 FAILURE |
RUNNING |
返回 RUNNING,繼續當前這次操作,不增加已完成次數 |
它與 Retry 的區別是:Retry 失敗後繼續,Repeat 成功後繼續。 num_cycles="3" 同樣包含第一次執行,不是先做一次再額外做三次。
官方 4.10.0 repeat_node.cpp 也使用循環處理立即結束的子節點。
在正常樹的喚醒機制下,同步子節點每次完成後,Repeat 可以發出喚醒並返回 RUNNING。使用 tickExactlyOnce() 與使用會處理喚醒的接口,外部看到的調用次數可能不同。num_cycles 只規定完成次數,不規定採集間隔;需要定時採樣時,應另行實現時間或數據更新條件。
5.4.Timeout:給本次執行設置時限
Timeout 使用 msec 指定毫秒數,例如:
<Timeout msec="5000">
<NavigateToGoal/>
</Timeout>
這給本次導航設置 5 秒時限:
| 子節點情況 | Timeout 的處理 |
|---|---|
未超時就返回 SUCCESS |
返回 SUCCESS |
未超時就返回 FAILURE |
返回 FAILURE |
| 還在運行且未超時 | 返回 RUNNING |
| 時限到達且子節點仍在運行 | 中止子節點,隨後返回 FAILURE |
在 4.10.0 timeout_node.h 中,計時從裝飾節點本次執行開始,後續 tick 不會反覆把這 5 秒重新計時。 到期處理會調用子節點的中止邏輯;超時失敗結果在後續執行入口中返回給父節點。
導航動作需要在中止處理中取消實際導航請求。
Timeout 不能替代動作的取消實現,也不能把一個長時間堵在 tick() 裏的同步函數自動變成可及時中止的異步任務。
6.組合示例:檢查電量,再有限次嘗試導航
把規則組合成一棵任務樹:
Sequence
├── CheckBattery
├── RetryUntilSuccessful [num_attempts=3]
│ └── Timeout [msec=5000]
│ └── NavigateToGoal
└── ReportArrival
對應的 XML 片段:
<Sequence>
<CheckBattery/>
<RetryUntilSuccessful num_attempts="3">
<Timeout msec="5000">
<NavigateToGoal/>
</Timeout>
</RetryUntilSuccessful>
<ReportArrival/>
</Sequence>
閲讀時,從外向內看任務約束:
| 所在層 | 職責 |
|---|---|
外層 Sequence |
電量檢查通過後才執行導航;導航最終成功後才報告到達 |
RetryUntilSuccessful |
導航嘗試失敗時重新嘗試,最多 3 次 |
Timeout |
限制每次嘗試的運行時間 |
NavigateToGoal |
啓動、檢查並在必要時取消實際導航任務 |
一種可能的執行過程是:
CheckBattery → SUCCESS
↓
第 1 次导航尝试 → RUNNING → 超时并取消 → FAILURE
↓ Retry 重置并重新尝试
第 2 次导航尝试 → RUNNING → SUCCESS
↓ Retry 返回 SUCCESS
ReportArrival → SUCCESS
↓
Sequence → SUCCESS
如果 3 次都失敗,Retry 返回 FAILURE,外層序列也失敗,ReportArrival 不會執行。
若報告動作本身失敗,外層序列同樣會報告失敗,因此報告動作應按需求定義好“成功”的含義。
這裏的普通 Sequence 只在開始時檢查電量,後續不會在導航或重試期間持續重查。
這個例子用於掌握順序、重試和超時的組合;持續監控條件及任務切換,留到響應式行為章節。
6.1.裝飾節點的內外順序會改變含義
RetryUntilSuccessful [3 次]
└── Timeout [5 秒]
└── NavigateToGoal
這是上面的結構:每次嘗試分別有 5 秒時限,一次超時算一次嘗試失敗,Retry 可以再試。
如果交換位置:
Timeout [5 秒]
└── RetryUntilSuccessful [3 次]
└── NavigateToGoal
則是所有嘗試共享一個 5 秒時限。 只有導航自身先返回失敗,Retry 才能在剩餘時間內重試; 外層一旦超時,就中止整個重試子樹,不再保證用完 3 次。
兩種結構的節點相同,但任務約束不同。 讀樹時既要看子節點順序,也要看裝飾節點包住了哪一層。
7.在 v4 中組合 SKIPPED 與響應式規則
本篇前面的表格先解釋三種業務結果。
加入前置條件後,普通 Sequence 和 Fallback 遇到 SKIPPED 都會繼續後面的子節點;全部被跳過時返回 SKIPPED。
Inverter、ForceSuccess、ForceFailure 在 4.10.0 中會保留 SKIPPED,不會將“未執行”一律轉換為成功或失敗。
Retry 與 Repeat 的子節點被跳過時,也返回跳過。
| 控制節點 | 子節點 RUNNING 後,下次從哪裏開始 | 典型用途 |
|---|---|---|
Sequence |
從正在運行的子節點繼續 | 一次任務的固定步驟 |
ReactiveSequence |
每次從第一個子節點重新檢查 | 前置條件變化時中止後面的持續動作 |
SequenceWithMemory |
運行時繼續當前位置,失敗和 halt 後仍保留位置;全部成功後從頭開始 | 需要保留已完成步驟的流程 |
Fallback |
從正在運行的方案繼續 | 順序嘗試方案 |
ReactiveFallback |
每次從最高優先級方案重新判斷 | 高優先級需求出現時切換方案 |
4.10.0 的 SequenceWithMemory::halt() 不清除當前子節點索引,不能假定調用 haltTree 後所有控制節點的業務進度都被清空。需要每次任務從頭開始時,優先採用普通 Sequence,或明確重建具有獨立生命週期的樹。
ReactiveSequence 重新執行的是前面的子節點,不專門識別 ConditionNode;前面放同步動作,也會重複執行它。AsyncSequence、AsyncFallback 則用於在同步步驟之間交還控制權;結合 tickOnce() 的喚醒處理,一次外部調用仍可能推進多步,不能把它們當作後台線程。
持續任務篇會用完整程序對照普通與響應式 Sequence。腳本篇再解釋 _while 產生的 SKIPPED 為什麼可能讓外層 Sequence 繼續,不能將它直接當成安全檢查失敗。
8.當前學習進度
本篇完成了 Introduction to BTs 中 Sequence、Fallback 和裝飾節點的核心規則,並補充了機械人任務常用的重試、重複與超時組合。