数据绑定与RPCEvent
在学习数据绑定和RPCEvent之前,了解UI组件与数据之间的关系非常重要。
客户端的数据绑定
如果UI组件是数据驱动的,它在数据模型中的角色通常属于以下类别之一:
- 数据消费者:被动接收数据并渲染它。
- 数据生产者:产生可能变化的数据(实践中纯生产者很少见)。
- 数据消费者 + 生产者:既显示数据,也修改数据。
数据消费者与 IDataConsumer<T>
被动接收数据的组件实现了 IDataConsumer<T> 接口,例如 Label 和 ProgressBar。
此接口允许您绑定一个 IDataProvider<T>,它负责提供更新后的数据值。
当您想要显示动态文本或变化的进度值时,这很有用。
var valueHolder = new AtomicInteger(0);
// 绑定一个 DataSource 来通知标签和进度条的值变化
new Label().bindDataSource(SupplierDataSource.of(() ->
Component.literal("绑定值: ").append(String.valueOf(valueHolder.get())))),
new ProgressBar()
.bindDataSource(SupplierDataSource.of(() -> valueHolder.get() / 100f))
.label(label -> label.bindDataSource(SupplierDataSource.of(() ->
Component.literal("进度: ").append(String.valueOf(valueHolder.get())))))
let valueHolder = {
"value": 0
}
// 绑定一个 DataSource 来通知标签和进度条的值变化
new Label().bindDataSource(SupplierDataSource.of(() => `绑定值: ${valueHolder.value}`)),
new ProgressBar()
.bindDataSource(SupplierDataSource.of(() => valueHolder.value / 100))
.label(label => label.bindDataSource(SupplierDataSource.of(() => `进度: ${valueHolder.value}`)))
数据生产者与 IObservable<T>
产生可变数据的组件实现了 IObservable<T> 接口。
大多数数据驱动的组件都属于此类,例如 Toggle、TextField、Selector。
此接口允许您绑定一个 IObserver<T>,每当组件的值发生变化时,它都会收到通知。
例如,要观察 TextField 的变化:
var valueHolder = new AtomicInteger(0);
// 绑定一个 Observer 来观察文本框的值变化
new TextField()
.setNumbersOnlyInt(0, 100)
.setValue(String.valueOf(valueHolder.get()))
// 绑定一个 Observer 来更新值持有器
.bindObserver(value -> valueHolder.set(Integer.parseInt(value)))
// 实际上,等同于 setTextResponder
//.setTextResponder(value -> valueHolder.set(Integer.parseInt(value)))
let valueHolder = {
"value": 0
}
// 绑定一个 Observer 来观察文本框的值变化
new TextField()
.setNumbersOnlyInt(0, 100)
.setValue(valueHolder.value)
// 绑定一个 Observer 来更新值持有器
.bindObserver(value => valueHolder.value = int(value))
// 实际上,等同于 setTextResponder
//.setTextResponder(value => valueHolder.value = int(value))
Note
像 Toggle、Selector 和 TextField 这样的组件同时支持 IDataConsumer<T> 和 IObservable<T>,因为它们负责同时显示数据和修改数据。
客户端与服务器之间的数据绑定
如果您的UI仅在客户端运行,IDataConsumer<T> 和 IObservable<T> 通常就足够了。它们覆盖了观察和更新本地数据的大部分需求。
但是,许多UI是基于容器的UI,实际数据存储在服务器上。在这种情况下,您通常希望:
- 在客户端UI组件中显示服务器端数据。
- 将客户端UI上所做的更改同步回服务器。
这被称为双向数据绑定。
sequenceDiagram
autonumber
Server->>Client UI: 同步初始数据(如果允许s->c)
Note right of Client UI: 初始化UI状态
Server->>Server: 检测服务器端数据变化
Server->>Client UI: 同步更新后的数据(如果允许s->c)
Note right of Client UI: 更新UI显示
Client UI->>Client UI: UI交互改变值
Client UI->>Server: 同步更改的数据(如果允许c->s)
Note left of Server: 应用服务器端更新
这听起来可能很复杂,但LDLib2完全抽象了这个过程。
使用 DataBindingBuilder<T>
使用 DataBindingBuilder<T>,您无需自己编写任何同步逻辑。您只需要描述:
- 数据存储在哪里
- 如何读取它
- 如何应用更新
简单的双向绑定
// 服务器端值
// boolean bool = true;
// String string = "hello";
// ItemStack item = new ItemStack(Items.APPLE);
new Switch()
.bind(DataBindingBuilder.bool(() -> bool, value -> bool = value).build());
new TextField()
.bind(DataBindingBuilder.string(() -> string, value -> string = value).build());
new ItemSlot()
.bind(DataBindingBuilder.itemStack(() -> item, stack -> item = stack).build());
// 服务器端值
// let bool = true;
// let string = "hello";
// let item = new ItemStack(Items.APPLE);
new Switch()
.bind(DataBindingBuilder.bool(() => bool, v => bool = v).build());
new TextField()
.bind(DataBindingBuilder.string(() => string, v => string = v).build());
new ItemSlot()
.bind(DataBindingBuilder.itemStack(() => item, v => item = v).build());
例如,在:
- 第一个lambda定义服务器如何向客户端提供数据。
- 第二个lambda定义客户端更改如何更新服务器数据。
单向绑定(仅服务器→客户端)
有时,您不希望客户端的更改影响服务器,例如仅用于显示的 Label。
LDLib2 允许您明确控制同步策略。
SyncStrategy 概览
NONE完全不进行同步。CHANGED_PERIODIC仅当数据变化时同步(默认:每tick一次)。ALWAYS强制每tick同步,即使未更改(谨慎使用)。
自定义 IBinding<T>
DataBindingBuilder<T> 为常见数据类型提供了内置绑定。
对于自定义类型(例如 int[]),您可以创建自己的绑定。
// 服务器端值
// int[] data = new int[]{1, 2, 3};
new BindableValue<int[]>().bind(
DataBindingBuilder.create(
() -> data,
v -> data = v
).build()
);
Warning
默认并非所有类型都受支持。 参见类型支持。 不支持的类型需要自定义类型访问器。
如果某个类型是只读的(参见类型支持):
- getter必须返回一个稳定的、非空的实例。
- 您必须定义类型和初始值。
使用 INBTSerializable 的示例:
// 服务器端值
// INBTSerializable<CompoundTag> data = ...;
new BindableValue<INBTSerializable>().bind(
DataBindingBuilder.create(
() -> data,
v -> {
// 实例已更新,只需在此做出反应
}
)
.initialValue(data).syncType(INBTSerializable.class)
.build()
);
这确保了正确的同步,并避免了只读对象的歧义。
客户端的 Getter 和 Setter
您可能想知道,为什么我们只在服务器端定义 getter 和 setter 逻辑,而不在客户端定义。
这是因为所有支持 bind 方法的组件都继承自 IBindable<T>。
对于这些组件,LDLib2 会自动为数据同步设置相应的客户端 getter 和 setter 逻辑。
在大多数情况下,这种默认行为就足够了,无需额外配置。
但是,如果您想完全控制客户端如何处理传入的数据,或它发送回服务器的数据,您可以手动定义自己的客户端 getter 和 setter 逻辑。
一体化 - BindableUIElement<T>
您可能已经注意到,几乎所有数据驱动的组件——如 TextArea、SearchComponent、Switch 等——都是构建在 BindableUIElement<T> 之上的。
BindableUIElement<T> 是一个包装的 UI 元素,它实现了以下所有接口:
这意味着它既可以显示数据,又可以产生数据变化,同时支持客户端-服务器同步。
Info
BindableValue<T> 实际上是一个实用组件,并且设置了 display: CONTENTS;,这意味着它在生命周期内不会影响布局。
如果您想实现自己的 UI 组件并支持客户端和服务器之间的双向数据绑定,您可以简单地扩展这个类。
对于不实现 IBindable<T> 的组件——比如基础的 UIElement——您仍然可以通过在内部附加一个 BindableValue<T> 来实现数据绑定。
下面的示例展示了如何将服务器端数据同步到客户端,并用它来控制元素的宽度:
复杂用法示例
好了,让我们再做一个更复杂的例子,为 Selector 绑定一个存储在服务器端的 String 列表(作为候选项)。
// 方法1,我们同步 String[]
// 代表存储在服务器端的值
// var candidates = new ArrayList<>(List.of("a", "b", "c", "d"));
var selector1 = new Selector<String>();
selector1.addChild(
// 一个用于同步候选项的占位元素值,它不会影响布局
new BindableValue<String[]>().bind(DataBindingBuilder.create(
() -> candidates.toArray(String[]::new), Consumers.nop())
.c2sStrategy(SyncStrategy.NONE) // 仅 s -> c
.remoteSetter(candidates -> {
selector1.setCandidates(Arrays.stream(candidates).toList());
})
.build()
)
);
// 方法2,我们同步 List<String>
// 代表存储在服务器端和客户端的值
// var candidates = new ArrayList<>(List.of("a", "b", "c", "d"));
var selector2 = new Selector<String>();
// 因为对于ldlib2同步系统来说,List是只读值。您必须获取 List<String> 的实际类型。
Type type = new TypeToken<List<String>>(){}.getType();
selector2.addChild(
// 一个用于同步候选项的占位元素值,它不会影响布局
new BindableValue<List<String>>().bind(DataBindingBuilder.create(
() -> candidates, Consumers.nop())
.syncType(type)
.initialValue(candidates)
.c2sStrategy(SyncStrategy.NONE) // 仅 s -> c
.remoteSetter(selector2::setCandidates)
.build()
)
);
root.addChildren(selector1, selector2);
- 方法1 同步一个
String[],这种方法直截了当,按预期工作。 - 方法2 同步一个
List<String>。由于Collection<T>在 LDLib2 中被视为只读类型,您必须显式提供initialValue并指定实际类型(包括泛型)。
这确保了绑定系统能够正确识别和跟踪数据。
UI RPCEvent
乍一看,数据绑定系统似乎可以处理大多数同步需求,但在实践中,情况并非总是如此。
例如,如果您想在用户点击按钮时执行服务器端逻辑,数据绑定显然不合适。
现在考虑一个更复杂的场景:将 FluidSlot 绑定到服务器端的 IFluidHandler。
这看起来似乎可以用数据绑定实现。如果它仅用于服务器到客户端的显示,它工作得很好。
然而,一旦涉及交互,双向同步就变得危险。
如果允许客户端修改值,它可以轻松发送恶意数据包来操纵服务器端的 IFluidHandler。
正确的做法是
- 仅对显示使用服务器到客户端的数据绑定
- 将客户端交互(例如点击
FluidSlot)发送到服务器 - 在服务器端处理交互
- 如果服务器状态发生变化,则通过数据绑定将其同步回客户端
简而言之,我们需要一种机制来在客户端和服务器之间发送交互数据。
这种机制被称为 UI RPCEvent。
以按钮为例,如果您阅读过 UI 事件 部分,您已经知道UI事件可以发送到服务器并触发逻辑。
在内部,这是使用 RPCEvent 实现的。
// 在服务器端触发 ui 事件
var button = new UIElement().addServerEventListener(UIEvents.MOUSE_DOWN, e -> {
// 在服务器端执行某些操作
});
// 使用 RPCEvent 的等效实现
var clickEvent = RPCEventBuilder.simple(UIEvent.class, event -> {
// 在服务器端执行某些操作
});
new UIElement().addEventListener(UIEvents.MOUSE_DOWN, e -> {
e.currentElement.sendEvent(clickEvent, e);
}).addRPCEvent(clickEvent);
您可以使用 RPCEventBuilder 来构建一个 RPCEvent,并在需要时向服务器发送数据。
Note
发送 RPC 事件时,传递给 sendEvent 的参数必须与在 RPCEventBuilder 中定义的参数完全匹配,包括顺序和类型,并且不要忘记使用 addRPCEvent 添加它们。
否则,事件将无法被正确分发。
带返回值的 RPCEvent
有时您可能希望向服务器发送请求以查询数据,并期望服务器返回结果。 例如,要求服务器执行加法并返回结果,您可以这样定义:
var queryAdd = RPCEventBuilder.simple(int.class, int.class, int.class, (a, b) -> {
// 在服务器端计算结果并返回
return a + b;
});
new UIElement().addEventListener(UIEvents.MOUSE_DOWN, e -> {
e.currentElement.<Integer>sendEvent(queryAdd, result -> {
// 在客户端接收结果
assert result == 2;
}, 1, 2);
}).addRPCEvent(queryAdd);
向客户端发送事件
在实践中,UI RPC 事件主要设计用于客户端 → 服务器的通信,并带有可选的响应发送回客户端。 这与大多数实际用例相匹配,即服务器拥有数据和逻辑,而客户端仅发送交互请求。
因此,LDLib2 不提供专用的 UI 级别 API 用于服务器 → 客户端的 RPC 事件。
然而,如果您确实需要从服务器主动向客户端发送事件,可以通过使用通用的 RPC 数据包 系统来实现。
下面是一个示例,展示了服务器如何向客户端发送 RPC 数据包,以及客户端如何定位和操作特定的 UI 元素。
var element = new UIElement().setId("my_element");
// 在任意位置注释您的数据包方法
@RPCPacket("rpcEventToClient")
public static void rpcPacketTest(RPCSender sender, String message, boolean message2) {
if (sender.isRemote()) {
var player = Minecraft.getInstance().player;
if (player != null && player.containerMenu instanceof IModularUIHolderMenu uiHolderMenu) {
uiHolderMenu.getModularUI().select("#my_element").findFirst().ifPresent(element -> {
// 在客户端使用您的元素执行某些操作。
});
}
}
}
// 向远程/服务器发送数据包
RPCPacketDistributor.rpcToAllPlayers("rpcEventToClient", "Hello from server!", false)
这种方法使您能够完全控制服务器发起的客户端逻辑,同时保持 UI RPC 系统简单并专注于交互驱动的工作流程。
Tip
当使用带有容器绑定的 FluidSlot 时,其实现已经使用了 服务器 → 客户端 (s→c) 的只读数据同步 结合 用于交互的 RPC 事件。
您无需自己处理同步策略。
FluidSlot.bind(...) 的实现也是一个很好的参考,可以了解数据同步和基于 RPC 的交互如何协同工作。