跳到正文
本頁目錄

數據驅動 UI 操作指南

本指南介紹如何使用 ll::ui 構建響應式 Minecraft Bedrock 表單。完整聲明、默認值、生命週期規則和底層 API 見數據驅動 UI API 參考。

LeviLamina API 採用微軟數據驅動 UI 框架簡介中的響應式概念,並將其適配為 C++ callback、Expected<T> 和 LeviLamina 協程。

開始之前

DDUI 代碼應在服務器線程運行。以下示例假設 Player& 來自服務器線程上的事件或命令回調,並且當前有效。

主要頭文件為:

C++
#include "ll/api/ui/form/CustomForm.h"
#include "ll/api/ui/form/MessageBox.h"

DDUI 與舊 ll::form API 是兩套獨立接口:

舊 ll::formDDUI ll::ui
靜態表單 Payload響應式數據綁定
從按下標排列的 Response 讀取輸入從類型化 Observable 讀取輸入
重新發送表單才能更新表單打開時調用 Observable::setData()
關閉時返回按鈕選擇行內按鈕 callback 可在表單打開時執行

構建完整 CustomForm

輸入控件必須使用可由客戶端寫入的 Observable;僅用於顯示的狀態應保持只讀。

C++
#include <string>
#include <vector>

#include "ll/api/ui/form/CustomForm.h"

void showSettings(Player& player) {
    using namespace ll::ui;

    ObservableString status{"就绪"};
    ObservableString name{"Steve", {.clientWritable = true}};
    ObservableBoolean pvp{true, {.clientWritable = true}};
    ObservableNumber difficulty{1.0, {.clientWritable = true}};
    ObservableNumber volume{50.0, {.clientWritable = true}};

    auto const nameSubscription = name.subscribe(
        [status](std::string const& value) mutable {
            status.setData("名称:" + value);
        }
    );

    CustomForm form{player, UIRawMessage::translate("example.settings.title")};
    form.header("常规")
        .label(status)
        .divider()
        .textField(
            "玩家名称",
            name,
            {.description = std::string{"本示例中显示的名称"}}
        )
        .toggle(
            "启用 PvP",
            pvp,
            {.description = std::string{"允许玩家战斗"}}
        )
        .dropdown(
            "难度",
            difficulty,
            {
                {"和平", 0.0, std::string{"无敌对生物"}},
                {"简单", 1.0, std::string{"伤害降低"}},
                {"普通", 2.0, std::string{"标准规则"}},
                {"困难", 3.0, std::string{"挑战提高"}},
            }
        )
        .slider(
            "音乐音量",
            volume,
            0.0,
            100.0,
            {
                .description = std::string{"百分比"},
                .step = 5.0,
            }
        )
        .spacer()
        .button(
            "重置",
            [name, pvp, difficulty, volume, status]() mutable {
                name.setData("Steve");
                pvp.setData(true);
                difficulty.setData(1.0);
                volume.setData(50.0);
                status.setData("已恢复默认值");
            },
            {.tooltip = std::string{"恢复默认值"}}
        )
        .closeButton();

    auto started = form.show(
        [name, pvp, difficulty, volume, nameSubscription](CustomForm::Result result) mutable {
            name.unsubscribe(nameSubscription);

            if (!result) {
                // 玩家离开、服务器停止或出现其他运行时错误。
                return;
            }

            auto const closeReason = result.value();
            auto const finalName = name.getData();
            auto const finalPvp = pvp.getData();
            auto const finalDifficulty = difficulty.getData();
            auto const finalVolume = volume.getData();

            // 在这里校验并持久化最终值。
            (void)closeReason;
            (void)finalName;
            (void)finalPvp;
            (void)finalDifficulty;
            (void)finalVolume;
        }
    );

    if (!started) {
        name.unsubscribe(nameSubscription);
        // 处理 started.error()。
    }
}

表單會複製 Observable,但所有副本共享狀態。因此,即使局部 Form Wrapper 已經離開作用域,完成 callback 仍能讀取最終值。

顯式 unsubscribe() 針對應用自己創建的 name 訂閱;表單內部的 Observable 橋接會在表單完成時自動斷開。

由服務端更新 UI

用作文本或 Option 的 Observable 都能在表單打開時改變。延遲更新仍必須回到服務器線程執行。

C++
#include <chrono>

#include "ll/api/thread/ServerThreadExecutor.h"
#include "ll/api/ui/form/CustomForm.h"

void showMonitor(Player& player) {
    using namespace std::chrono_literals;

    ll::ui::ObservableString status{"正在收集数据..."};
    ll::ui::ObservableBoolean detailsVisible{true};

    ll::ui::CustomForm form{player, "实时监控"};
    form.label(status, {.visible = detailsVisible})
        .button(
            "隐藏详情",
            [detailsVisible]() mutable {
                detailsVisible.setData(false);
            }
        )
        .closeButton();

    auto started = form.show();
    if (!started) {
        return;
    }

    ll::thread::ServerThreadExecutor::getDefault().executeAfter(
        [status]() mutable {
            status.setData("已收到服务端更新");
        },
        1s
    );
}

更新時不需要重建或重新顯示錶單。setData() 會通過現有 Binding 寫入新值;再次設置相同值會被 Observable<T> 抑制。

立即響應玩家輸入

另一個 UI 值依賴玩家輸入時,訂閱可由客戶端寫入的 Observable:

C++
ll::ui::ObservableNumber volume{50.0, {.clientWritable = true}};
ll::ui::ObservableString summary{"音量:50"};

auto const subscription = volume.subscribe(
    [summary](double const& value) mutable {
        summary.setData("音量:" + std::to_string(value));
    }
);

保存訂閱 ID,並在所屬狀態結束時調用 unsubscribe()。忽略 ID 會讓 callback 在共享 Observable 狀態存活期間一直有效;該返回值不是 RAII Token。

動態組件 Option

Option 值可以是 Observable。這樣無需修改表單結構,一個控件就能動態控制另一個控件的顯示、隱藏、啟用或禁用。

C++
ll::ui::ObservableBoolean advanced{false, {.clientWritable = true}};
ll::ui::ObservableBoolean advancedVisible{false};
ll::ui::ObservableBoolean basicDisabled{false};

auto const subscription = advanced.subscribe(
    [advancedVisible, basicDisabled](bool const& enabled) mutable {
        advancedVisible.setData(enabled);
        basicDisabled.setData(enabled);
    }
);

ll::ui::CustomForm form{player, "模式"};
form.toggle("高级模式", advanced)
    .label("高级控件", {.visible = advancedVisible})
    .button("基础操作", [] {}, {.disabled = basicDisabled})
    .closeButton();

開始顯示後,表單結構不可再修改,但所有綁定值仍可改變。

使用協程路徑

showAsync() 等待最終關閉結果。該任務必須在服務器線程啟動或等待。

C++
#include "ll/api/coro/CoroTask.h"
#include "ll/api/thread/ServerThreadExecutor.h"
#include "ll/api/ui/form/CustomForm.h"

ll::coro::CoroTask<> showSettingsAsync(Player& player) {
    ll::ui::ObservableBoolean enabled{false, {.clientWritable = true}};

    ll::ui::CustomForm form{player, "异步设置"};
    form.toggle("已启用", enabled).closeButton();

    auto result = co_await form.showAsync();
    if (!result) {
        // 处理 result.error()。
        co_return;
    }

    auto const closeReason = result.value();
    auto const finalValue = enabled.getData();
    (void)closeReason;
    (void)finalValue;
}

void launchSettings(Player& player) {
    showSettingsAsync(player).launch(
        ll::thread::ServerThreadExecutor::getDefault()
    );
}

不要對同一個表單同時調用 show() 與 showAsync();每個表單或 Session 只能顯示一次。

顯示 MessageBox

MessageBox 是雙按鈕對話框。與 CustomForm 的操作按鈕不同,它的結果會說明選擇了哪個按鈕。

C++
#include "ll/api/ui/form/MessageBox.h"

void confirmReset(Player& player) {
    ll::ui::MessageBox box{
        player,
        ll::ui::UIRawMessage::translate("example.reset.title")
    };

    box.body("是否重置全部已保存设置?")
        .button1(
            ll::ui::UIRawMessage::translate("gui.yes"),
            std::string{"此操作无法撤销"}
        )
        .button2(ll::ui::UIRawMessage::translate("gui.no"));

    auto started = box.show([](ll::ui::MessageBox::Result result) {
        if (!result || !result->selection) {
            return;
        }

        if (*result->selection == 1) {
            // 选择 button1:执行重置。
        } else if (*result->selection == 2) {
            // 选择 button2:保持数据不变。
        }
    });

    if (!started) {
        // 处理 started.error()。
    }
}

按鈕編號從 1 開始:button1 產生 1,button2 產生 2。玩家 Busy、程序關閉或其他未選擇按鈕的關閉結果中,selection 為空。

關閉表單

應選擇範圍最小、符合意圖的關閉操作:

C++
auto targeted = form.close(); // 只关闭这个 CustomForm。

ll::ui::closeScreen(player);  // 关闭该玩家的全部 DDUI Screen。

form.close() 返回 Expected<>,並要求表單正在顯示。closeScreen(player) 返回 void;各活動 Session 稍後分別收到自己的 ProgrammaticCloseAll 完成結果。

打開資源包自定義 Screen

只有資源包定義了 Screen 及其數據協議時才直接使用 ScreenSession。結構化數據應優先使用 cereal::DynamicValue,而不是兼容 JSON 重載。

C++
#include "ll/api/ui/base/ScreenSession.h"

#include "mc/deps/cereal/schema/dynamic/DynamicValue.h"

void showCustomScreen(Player& player) {
    auto data = cereal::DynamicValue::object({
        {"title", "运行状态"},
        {"count", 0.0},
    });

    ll::ui::ScreenSession session{player, "example:runtime_status"};
    auto property = session.createProperty("example", "runtime_status", data);
    if (!property) {
        return;
    }

    auto count = property->bind<double>("count");
    if (!count) {
        return;
    }

    auto shown = session.show();
    if (!shown) {
        return;
    }

    count->set(1.0);
}

ScreenSession 必須保持存活,其 Property 和 Binding<T> 句柄才能繼續使用。生產環境中的自定義 Screen Owner 應保留 Session,直到完成 callback 執行。

常見錯誤

現象原因和修正
構造輸入控件時拋異常值 Observable 是隻讀的;構造時傳 {.clientWritable = true}
show() 成功但拿不到關閉原因show() 只報告啟動結果;傳 callback 或使用 showAsync()
Binding 訂閱立即失效丟棄了 Binding::listen() 返回的 RAII Subscription;需要保留它
Observable callback 一直不停止Observable 使用顯式 ID;調用 unsubscribe(id)
嘗試顯示後,結構方法拋異常第一次 Show Attempt 就會鎖定結構;創建新表單
修改普通變量後 UI 不更新綁定 Observable,並調用其 setData()
close() 報告表單未顯示只在成功啟動後、完成前調用
NaN 或無窮大被拒絕DDUI 數值必須為有限數
只想關閉一個界面使用該表單的 close(),不要使用 closeScreen(player),它會關閉該玩家的所有 DDUI 界面

延伸閱讀