Data-driven UI(數據驅動 UI)
ll/api/ui/ · 通用
概述
ll::ui 是 LeviLamina 為 Minecraft Bedrock 數據驅動 UI(DDUI)提供的類型化 C++ 接口。DDUI 將表單數據放在同步 DataStore 中,因此表單保持打開時,文本、可見性、禁用狀態、數值範圍和輸入值都能實時變化。
高級 API 提供 CustomForm 和 MessageBox,不向調用方暴露布局文檔和屬性路徑。需要打開資源包自定義界面時,仍可直接使用底層 ScreenSession、Property 和 Binding<T>。
本 API 對應 @minecraft/server-ui 2.1.0 引入的穩定功能。微軟的數據驅動 UI 框架簡介介紹了響應式模型;精確的兼容基線是 Mojang 的 @minecraft/server-ui 2.1.0 綁定元數據。
穩定版範圍
這裡不隱含支持後續 preview 功能。即使 Microsoft Learn 的 stable 視圖顯示了帶“預發行”警告的項目,當前 C++ API 也不包含 CustomForm::image() 和對應圖片選項。
頭文件
| 頭文件 | 用途 |
|---|---|
ll/api/ui/base/UIRawMessage.h | 字面量、翻譯、替換參數和組合 UI 文本 |
ll/api/ui/base/Observable.h | UI 專用 Observable 和值類型變體 |
ll/api/ui/form/CustomForm.h | 類型化響應式自定義表單及全部組件選項 |
ll/api/ui/form/MessageBox.h | 雙按鈕消息對話框 |
ll/api/ui/base/ScreenSession.h | DDUI 界面生命週期和全局關閉操作 |
ll/api/ui/base/DataStore.h | 底層 Property、Binding 和 Subscription 句柄 |
ll/api/data/Observable.h | 通用 Observable 模板,詳見 Data |
應該使用哪一層
| 需求 | API |
|---|---|
| 構建響應式菜單或輸入表單 | CustomForm |
| 提出一個雙按鈕問題 | MessageBox |
| 界面打開時更新內容和輸入值 | UI Observable |
| 提供翻譯文本或組合文本 | UIRawMessage |
| 打開資源包中的自定義 DDUI 界面 | ScreenSession |
| 綁定自定義界面的數據路徑 | Property 和 Binding<T> |
vanilla 的 Custom Form 和 Message Box 預設應優先使用高級表單類。它們會構造原生數據結構、橋接 Observable、校驗客戶端寫入,並在表單結束時清理自己擁有的 DataStore 狀態。
線程與生命週期
表單、Session、Property、Binding 和 Subscription 的操作必須在服務器線程執行,包括銷燬仍持有活動 Session 的最後一個句柄。showAsync() 返回的任務也必須在服務器線程等待或啟動。
UI Observable 按值複製進表單,但其 ll::data::Observable<T> 基類會在副本之間共享狀態。因此,在外部保留一份副本就是讀取和更新表單值的標準方式。Observable 與可見表單綁定期間,setData() 會寫入玩家的 DDUI DataStore,也應在服務器線程調用。
CustomForm 和 MessageBox 只能移動,不能複製。show() 成功後,內部 Runtime 會自行保持存活,直到 Bedrock 完成該界面,因此外層對象不必留在當前調用棧中。如果後續代碼需要針對性調用 close(),則仍需保留外層對象。
文本值
絕大多數文本參數接受 TextValue:
using TextValue = std::variant<
std::string,
UIRawMessage,
ObservableString,
ObservableUIRawMessage
>;std::string 和 UIRawMessage 是靜態值;ObservableString 與 ObservableUIRawMessage 的值變化後會自動同步到客戶端。
UIRawMessage
UIRawMessage 是穩定字段 text、translate、with 和 rawtext 的類型化表示,對應微軟的 UIRawMessage 參考。
class UIRawMessage {
public:
static UIRawMessage text(std::string value);
static UIRawMessage translate(std::string key);
static UIRawMessage translate(
std::string key,
std::vector<std::string> substitutions
);
static UIRawMessage translate(
std::string key,
UIRawMessage substitutions
);
static UIRawMessage rawText(std::vector<UIRawMessage> messages);
bool operator==(UIRawMessage const&) const noexcept;
};using ll::ui::UIRawMessage;
auto literal = UIRawMessage::text("服务器在线");
auto translated = UIRawMessage::translate("gui.ok");
auto substituted = UIRawMessage::translate(
"example.player_count",
std::vector<std::string>{"5", "20"}
);
auto combined = UIRawMessage::rawText({
UIRawMessage::text("状态:"),
UIRawMessage::translate("gui.yes")
});翻譯由客戶端按玩家語言完成。高級 API 不接受也不暴露文本 JSON。
UI Observable
四個 UI Observable 都繼承自通用 ll::data::Observable<T>,只額外保存一個不可變的 UI 選項:
struct ObservableOptions {
bool clientWritable{false};
};
template <class T>
class UIObservable : public data::Observable<T> {
public:
explicit UIObservable(T initial, ObservableOptions options = {});
bool isClientWritable() const noexcept;
};
class ObservableBoolean : public UIObservable<bool> {};
class ObservableNumber : public UIObservable<double> {};
class ObservableString : public UIObservable<std::string> {};
class ObservableUIRawMessage : public UIObservable<UIRawMessage> {};它們分別對應微軟穩定版的 ObservableBoolean、ObservableNumber、ObservableString 和 ObservableUIRawMessage。
clientWritable 決定橋接方向:
| 值 | 數據流向 |
|---|---|
false(默認) | 僅 Server Observable 到 UI |
true | Server Observable 到 UI,同時允許通過校驗的客戶端改動回寫 Observable |
傳給 dropdown()、slider()、textField() 或 toggle() 的值 Observable 必須開啟 clientWritable。把只讀 Observable 傳給這些輸入控件,會在構建表單時拋出 std::invalid_argument。
標籤、說明、可見性、禁用狀態、Slider 邊界等完全由服務端控制的值,應使用默認的只讀 Observable。無必要地開啟客戶端寫入,會擴大客戶端可提交的數據範圍。
ll::ui::ObservableString status{"等待中"};
ll::ui::ObservableString name{"Steve", {.clientWritable = true}};
auto const id = name.subscribe([status](std::string const& value) mutable {
status.setData("名称:" + value);
});無論是否允許客戶端寫入,服務端調用 setData() 都會更新所有綁定位置。客戶端寫入只會在明確開放的路徑上被接受,底層 Binding 會校驗類型、有限數和更新版本,拒絕重放更新。
CustomForm
微軟對應類型見 CustomForm。
class CustomForm {
public:
using Result = Expected<ScreenCloseReason>;
using Callback = brstd::move_only_function<void(Result)>;
using ButtonCallback = brstd::move_only_function<void()>;
CustomForm(Player& player, TextValue title);
CustomForm& button(TextValue, ButtonCallback, ButtonOptions = {});
CustomForm& closeButton();
CustomForm& divider(DividerOptions = {});
CustomForm& dropdown(
TextValue,
ObservableNumber,
std::vector<DropdownItemData>,
DropdownOptions = {}
);
CustomForm& header(TextValue, TextOptions = {});
CustomForm& label(TextValue, TextOptions = {});
CustomForm& slider(
TextValue,
ObservableNumber,
NumberValue min,
NumberValue max,
SliderOptions = {}
);
CustomForm& spacer(SpacingOptions = {});
CustomForm& textField(TextValue, ObservableString, TextFieldOptions = {});
CustomForm& toggle(TextValue, ObservableBoolean, ToggleOptions = {});
Expected<> show(Callback callback = {});
coro::CoroTask<Result> showAsync();
Expected<> close();
bool isShowing() const noexcept;
};所有組件方法都返回 *this,可以鏈式構造;組件顯示順序就是調用順序。
組件
| 方法 | 用途 | 必需的響應式值 |
|---|---|---|
button(label, callback, options) | 添加操作按鈕 | 無;表單保持打開時回調可重複執行 |
closeButton() | 啟用預設底部關閉按鈕和右上角關閉控件 | 無;標籤使用客戶端翻譯鍵 gui.close |
divider(options) | 添加水平分隔線 | 無 |
dropdown(label, value, items, options) | 添加帶數值的選擇列表 | 可由客戶端寫入的 ObservableNumber |
header(text, options) | 添加強調顯示的分區標題 | 無 |
label(text, options) | 添加普通只讀文本 | 無 |
slider(label, value, min, max, options) | 添加數值滑條 | 可由客戶端寫入的 ObservableNumber |
spacer(options) | 添加垂直空白 | 無 |
textField(label, text, options) | 添加文本輸入框 | 可由客戶端寫入的 ObservableString |
toggle(label, toggled, options) | 添加開關輸入 | 可由客戶端寫入的 ObservableBoolean |
NumberValue 可以是 double 或 ObservableNumber。Slider 的最小值、最大值和步長都可用 Observable,因此表單打開時仍可動態改變。高級表單和底層 Binding 中的所有數值都必須是有限數。
每個 DropdownItemData 包含 label、數值 value 和可選 description。Dropdown 的 Observable 綁定控件數值,應使用與資源包控件所需 Item Value 一致的值。
Options
所有 Option 成員均為可選。省略時使用下表默認值。TextValue、BooleanValue 或 NumberValue 類型的成員也可傳 Observable,從而動態更新。
using BooleanValue = std::variant<bool, ObservableBoolean>;
using NumberValue = std::variant<double, ObservableNumber>;| 類型 | 成員 | 默認值 |
|---|---|---|
ButtonOptions | disabled、tooltip、visible | false、空、true |
DividerOptions | visible | true |
DropdownOptions | description、disabled、visible | 空、false、true |
SliderOptions | description、disabled、step、visible | 空、false、1.0、true |
SpacingOptions | visible | true |
TextFieldOptions | description、disabled、visible | 空、false、true |
TextOptions | visible | true |
ToggleOptions | description、disabled、visible | 空、false、true |
struct DropdownItemData {
TextValue label;
double value;
std::optional<TextValue> description;
};顯示和關閉
show(callback) 是直接回調路徑。其 Expected<> 只說明表單是否成功準備並交給 Bedrock,不包含最終關閉原因;最終的 Expected<ScreenCloseReason> 由 callback 收到。
showAsync() 返回協程任務,任務值就是同一個最終結果。普通 callback 版本的 show() 內部不會創建協程。
每個表單只能顯示一次。第一次調用 show() 或 showAsync() 後,再調用任意結構修改方法都會拋出 std::logic_error,即使後續準備或啟動失敗也是如此。再次調用 show() 會返回錯誤。
close() 只關閉當前表單;表單尚未進入 Showing 狀態時會返回錯誤。已經請求關閉或已經關閉後再次調用 close() 會直接成功,不會重複發請求。等待 Bedrock 返回關閉結果期間,isShowing() 仍然為 true。
表單結束時,內部 Observable 橋接、客戶端寫權限、原生訂閱和自有 DataStore Property 都會被釋放。
MessageBox
微軟對應類型見 MessageBox,結果結構見 MessageBoxResult。
struct MessageBoxResult {
ScreenCloseReason closeReason;
std::optional<std::uint32_t> selection;
};
class MessageBox {
public:
using Result = Expected<MessageBoxResult>;
using Callback = brstd::move_only_function<void(Result)>;
MessageBox(Player& player, TextValue title);
MessageBox& body(TextValue);
MessageBox& button1(TextValue, std::optional<TextValue> tooltip = {});
MessageBox& button2(TextValue, std::optional<TextValue> tooltip = {});
Expected<> show(Callback callback = {});
coro::CoroTask<Result> showAsync();
Expected<> close();
bool isShowing() const noexcept;
};body()、兩個按鈕標籤和兩個 tooltip 都可使用靜態值或 Observable。顯示前應設置兩個按鈕的標籤。
selection 保持 Bedrock 穩定版 DDUI 語義:
| 值 | 含義 |
|---|---|
1 | 玩家選擇 button1 |
2 | 玩家選擇 button2 |
| 空 | 對話框關閉時沒有選擇按鈕 |
callback、協程、只能顯示一次、顯示後鎖定結構、close() 和清理規則均與 CustomForm 相同。
Screen 結果
ScreenCloseReason 是 Bedrock 現有 DataDrivenScreenClosedReason 的別名,LeviLamina 沒有再定義一套關閉原因枚舉。
| 枚舉項 | 數值 | 含義 |
|---|---|---|
ProgrammaticClose | 0 | 通過實例的 close() 關閉指定表單 |
ProgrammaticCloseAll | 1 | 通過 closeScreen(player) 關閉 DDUI 界面 |
ClientCanceled | 2 | 客戶端取消或關閉界面 |
UserBusy | 3 | 玩家正忙於其他 UI |
InvalidForm | 4 | Bedrock 拒絕了界面數據或佈局 |
玩家離開和服務器停止會讓 C++ 結果攜帶 ll::Error,不會為此另造關閉原因。
關閉玩家的全部 DDUI 界面
#include "ll/api/ui/base/ScreenSession.h"
ll::ui::closeScreen(player);closeScreen(Player&) 請求客戶端關閉該玩家的全部 DDUI 界面,對應穩定版 Script API Manager 的 close-all 操作。它返回 void,因為這是全局關閉請求,沒有單個 Session 結果可以同步返回。各活動 Session 會分別以 ScreenCloseReason::ProgrammaticCloseAll 完成。
只需關閉一個已知界面時,應使用 CustomForm::close()、MessageBox::close() 或 ScreenSession::close()。
底層 ScreenSession
ScreenSession 用於打開資源包提供的自定義 DDUI Screen。高級表單已經管理自己的 Session 和 Property,普通表單代碼不需要直接使用它。
enum class ScreenSessionState : std::uint8_t {
Ready,
Showing,
Closed,
};
class ScreenSession {
public:
using Result = Expected<ScreenCloseReason>;
using Callback = brstd::move_only_function<void(Result)>;
ScreenSession(Player&, std::string screenId);
ScreenSession(Player&, std::string screenId, uint instanceId);
ScreenSession(Player&, std::string screenId, NoInstanceIdTag);
ScreenSessionState getState() const noexcept;
std::optional<uint> getInstanceId() const noexcept;
std::optional<uint> getFormId() const noexcept;
Expected<Property> createProperty(
std::string datastore,
std::string property,
cereal::DynamicValue const& value
) const;
Expected<Property> createProperty(
std::string datastore,
std::string property,
std::string const& json
) const;
Expected<Property> borrowProperty(
std::string datastore,
std::string property
) const;
Expected<> show(Callback callback = {}) const;
coro::CoroTask<Result> showAsync() const;
Expected<> close() const;
};雙參數構造函數會自動生成 Instance ID。自定義 Screen 協議要求固定 ID 時可顯式傳入;不使用 Instance ID 時傳 NoInstanceId。Bedrock Form ID 只有在開始顯示後才會被設置。
createProperty() 創建或替換由 Session 擁有的數據,Session 關閉時會清空自有數據。borrowProperty() 只關聯玩家已有的 DataStore Property,Session 清理時不會刪除借用的數據。
構造結構化數據時應優先使用 cereal::DynamicValue 重載。字符串重載僅作為底層兼容接口存在;高級表單不使用也不暴露 JSON。
auto data = cereal::DynamicValue::object({
{"title", "状态"},
{"visible", true},
{"count", 0.0},
});
ll::ui::ScreenSession session{player, "example:status_screen"};
auto property = session.createProperty("example", "status", data);
if (!property) {
// 处理 property.error()。
return;
}與高級表單一樣,每個 Session 只能顯示一次。複製 ScreenSession 會共享同一實現和生命週期。
Property 與 Binding
using BindingValueTypes = meta::TypeList<
double,
bool,
std::string,
cereal::DynamicValue
>;
class Property {
public:
Expected<> set(cereal::DynamicValue const& value) const;
Expected<> setJson(std::string const& json) const;
Expected<> erase() const;
template <BindingValue T>
Expected<Binding<T>> bind(std::string path) const;
template <BindingValue T>
Expected<Binding<T>> bindClientWritable(std::string path) const;
explicit operator bool() const noexcept;
};
template <BindingValue T>
class Binding {
public:
using Callback = brstd::move_only_function<void(T const&)>;
Expected<T> get() const;
Expected<> set(T value) const;
Expected<Subscription> listen(Callback callback) const;
Expected<> setClientWritable(bool writable) const;
bool isClientWritable() const noexcept;
explicit operator bool() const noexcept;
};
class Subscription {
public:
Subscription() noexcept;
~Subscription();
Subscription(Subscription const&) = delete;
Subscription& operator=(Subscription const&) = delete;
Subscription(Subscription&&) noexcept;
Subscription& operator=(Subscription&&) noexcept;
void reset() noexcept;
explicit operator bool() const noexcept;
};Binding Path 使用 Bedrock DDUI 路徑語法,並相對於所屬 Property 解析。同一路徑再次綁定成不同 C++ 類型會返回錯誤。Number Binding 和 DynamicValue 中的所有嵌套數值都會拒絕 NaN 與無窮大。
bindClientWritable<T>() 等價於先 bind<T>(),再 setClientWritable(true)。只應向客戶端確實需要編輯的路徑開放權限。客戶端更新會校驗 Binding 類型和版本;非法寫入或重放不會到達 Listener,並會盡可能恢復最後一個合法值。
listen() 返回僅可移動的 RAII Subscription。需要通知期間必須保留它;銷燬它或調用 reset() 會取消對應 Listener。這與 ll::data::Observable<T>::SubscriptionId 不同,後者的生命週期不會產生取消效果。
auto binding = property->bindClientWritable<double>("count");
if (!binding) {
return;
}
auto subscription = binding->listen([](double const& value) {
// 处理通过校验的服务端或客户端 DataStore 更新。
});
if (!subscription) {
return;
}
auto write = binding->set(42.0);Property 和 Binding<T> 是非擁有句柄;其 ScreenSession 失效後,這些句柄也不可用。Session 關閉後 Subscription 同樣不再活動。
錯誤模型
與 Bedrock 交互的操作返回 Expected<T> 或 Expected<>。使用結果前必須檢查成功與否。錯誤可能來自無效句柄、玩家失效、Binding 類型不符、路徑非法、DDUI 服務不可用、生命週期轉換非法或界面數據被拒絕。
表單構造階段有兩類編程錯誤異常:
| 條件 | 異常 |
|---|---|
開始顯示後繼續修改 CustomForm 或 MessageBox | std::logic_error |
| 向輸入控件傳入只讀 Observable,或傳入非有限的高級數值 | std::invalid_argument |
通用 Observable 回調異常會繼續拋出,詳見 Data。DDUI 完成回調和底層 Binding Listener 屬於引擎回調邊界,逃出的異常會被 LeviLamina 捕獲並記錄。
與 Script API 的差異
C++ API 保持穩定版 DDUI 行為,但不是 TypeScript 的逐字源碼翻譯:
| Microsoft Script API | LeviLamina C++ API |
|---|---|
show() 返回 Promise | 直接 show(callback),另提供 showAsync() |
| Script 異常 | 運行時失敗使用 Expected<T>;非法構造或修改使用標準異常 |
| 以 Callback 本身作為取消訂閱 Token | 每份狀態使用 std::uint32_t 訂閱 ID |
uiManager.closeAllForms(player) | ll::ui::closeScreen(player) |
| Raw Message 對象字面量 | 類型化 UIRawMessage 工廠 |
| Script Runtime 管理表單佈局對象 | 類型化組件,不公開佈局或 JSON |
ObservableString::getFilteredText() | 當前 C++ 公共 API 未導出 |
image() 等 preview 組件 | 未實現 |
相關模塊
- 數據驅動 UI 操作指南 - callback、協程、動態狀態和 MessageBox 完整示例
- Data -
ll::data::Observable<T>完整語義 - Expected - 處理
Expected<T>和ll::Error - Coroutine - 啟動和等待
CoroTask<T> - 舊 Form API - 與 DDUI 分離的靜態
ll::form表單