跳到正文
本頁目錄

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.hUI 專用 Observable 和值類型變體
ll/api/ui/form/CustomForm.h類型化響應式自定義表單及全部組件選項
ll/api/ui/form/MessageBox.h雙按鈕消息對話框
ll/api/ui/base/ScreenSession.hDDUI 界面生命週期和全局關閉操作
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:

C++
using TextValue = std::variant<
    std::string,
    UIRawMessage,
    ObservableString,
    ObservableUIRawMessage
>;

std::string 和 UIRawMessage 是靜態值;ObservableString 與 ObservableUIRawMessage 的值變化後會自動同步到客戶端。

UIRawMessage

UIRawMessage 是穩定字段 text、translate、with 和 rawtext 的類型化表示,對應微軟的 UIRawMessage 參考。

C++
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;
};
C++
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 選項:

C++
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
trueServer Observable 到 UI,同時允許通過校驗的客戶端改動回寫 Observable

傳給 dropdown()、slider()、textField() 或 toggle() 的值 Observable 必須開啟 clientWritable。把只讀 Observable 傳給這些輸入控件,會在構建表單時拋出 std::invalid_argument。

標籤、說明、可見性、禁用狀態、Slider 邊界等完全由服務端控制的值,應使用默認的只讀 Observable。無必要地開啟客戶端寫入,會擴大客戶端可提交的數據範圍。

C++
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。

C++
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,從而動態更新。

C++
using BooleanValue = std::variant<bool, ObservableBoolean>;
using NumberValue  = std::variant<double, ObservableNumber>;
類型成員默認值
ButtonOptionsdisabled、tooltip、visiblefalse、空、true
DividerOptionsvisibletrue
DropdownOptionsdescription、disabled、visible空、false、true
SliderOptionsdescription、disabled、step、visible空、false、1.0、true
SpacingOptionsvisibletrue
TextFieldOptionsdescription、disabled、visible空、false、true
TextOptionsvisibletrue
ToggleOptionsdescription、disabled、visible空、false、true
C++
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。

C++
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 沒有再定義一套關閉原因枚舉。

枚舉項數值含義
ProgrammaticClose0通過實例的 close() 關閉指定表單
ProgrammaticCloseAll1通過 closeScreen(player) 關閉 DDUI 界面
ClientCanceled2客戶端取消或關閉界面
UserBusy3玩家正忙於其他 UI
InvalidForm4Bedrock 拒絕了界面數據或佈局

玩家離開和服務器停止會讓 C++ 結果攜帶 ll::Error,不會為此另造關閉原因。

關閉玩家的全部 DDUI 界面

C++
#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,普通表單代碼不需要直接使用它。

C++
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。

C++
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

C++
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 不同,後者的生命週期不會產生取消效果。

C++
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 或 MessageBoxstd::logic_error
向輸入控件傳入只讀 Observable,或傳入非有限的高級數值std::invalid_argument

通用 Observable 回調異常會繼續拋出,詳見 Data。DDUI 完成回調和底層 Binding Listener 屬於引擎回調邊界,逃出的異常會被 LeviLamina 捕獲並記錄。

與 Script API 的差異

C++ API 保持穩定版 DDUI 行為,但不是 TypeScript 的逐字源碼翻譯:

Microsoft Script APILeviLamina 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 組件未實現

相關模塊

官方參考