數據驅動 UI 操作指南
本指南介紹如何使用 ll::ui 構建響應式 Minecraft Bedrock 表單。完整聲明、默認值、生命週期規則和底層 API 見數據驅動 UI API 參考。
LeviLamina API 採用微軟數據驅動 UI 框架簡介中的響應式概念,並將其適配為 C++ callback、Expected<T> 和 LeviLamina 協程。
開始之前
DDUI 代碼應在服務器線程運行。以下示例假設 Player& 來自服務器線程上的事件或命令回調,並且當前有效。
主要頭文件為:
#include "ll/api/ui/form/CustomForm.h"
#include "ll/api/ui/form/MessageBox.h"DDUI 與舊 ll::form API 是兩套獨立接口:
舊 ll::form | DDUI ll::ui |
|---|---|
| 靜態表單 Payload | 響應式數據綁定 |
| 從按下標排列的 Response 讀取輸入 | 從類型化 Observable 讀取輸入 |
| 重新發送表單才能更新 | 表單打開時調用 Observable::setData() |
| 關閉時返回按鈕選擇 | 行內按鈕 callback 可在表單打開時執行 |
構建完整 CustomForm
輸入控件必須使用可由客戶端寫入的 Observable;僅用於顯示的狀態應保持只讀。
#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 都能在表單打開時改變。延遲更新仍必須回到服務器線程執行。
#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:
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。這樣無需修改表單結構,一個控件就能動態控制另一個控件的顯示、隱藏、啟用或禁用。
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() 等待最終關閉結果。該任務必須在服務器線程啟動或等待。
#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 的操作按鈕不同,它的結果會說明選擇了哪個按鈕。
#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 為空。
關閉表單
應選擇範圍最小、符合意圖的關閉操作:
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 重載。
#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 界面 |