2026年Xilem批判性回顾
摘要
对Rust GUI框架Xilem的批判性回顾,重点指出了严重的开发者体验问题、架构死胡同和组织问题。作者认为当前架构不可行,需要进行重大变革。
<p><a href="https://lobste.rs/s/bccwjx/critical_review_xilem_2026">评论</a></p>
查看缓存全文
缓存时间: 2026/08/12 20:24
# 2026 年对 Xilem 的批判性回顾 - HackMD 来源:https://hackmd.io/@s_haMSbyTAOWfoXc1aYNUg/Hka74gCwZg
# 2026 年对 Xilem 的批判性回顾
这是一篇姗姗来迟的 Xilem 现状回顾,重点放在痛点与必要的改变上。我的观点(我相信许多参与过 Xilem 开发的人都认同)是:当前的架构离“可用”还差得很远。用 Xilem 编写非平凡的应用程序非常痛苦,人们没有在使用它,其他框架要方便得多。Xilem 必须改变,否则就会消亡。
## 开发者体验
坦率地说,Xilem 的开发者体验(DX)糟糕透顶。存在几个反复出现的问题:
- Xilem 仍未找到一种可扩展的方式来组合组件和管理复杂状态。
- 架构非常依赖泛型,如果用户不知道如何取悦 trait 求解器,这可能会让编译时间爆炸。未来的编译器版本或许能解决这个问题,但目前这仍然是一个陷阱。
- 重度泛型的抽象会导致难以理解的错误信息。即使错误指向了真正的根本原因,从错误信息中找到那个原因也总是比应该的困难得多。
- 初次使用的用户体验有许多小毛病,比如必须在组件函数的签名中写出 `+ use<>`。
这些问题并非因为缺乏关心:Philip、Daniel 和我(以及其他许多人)花了大量时间试图找到更好的 UI 组合方式,并改进错误信息。根据经验,我们的努力并未结出果实,因为 Xilem 饱受深层的架构*和*组织问题之苦,而我们未能解决这些问题。
## 总体组织问题
Xilem 一直在几个方向之间摇摆:纯粹的研究项目、由企业资助的旨在展示 Linebender 技术栈潜力的项目,以及面向日常编码的通用 GUI 框架。不同目标之间的拉扯导致了系统性问题,这并不是因为某个特定维护者的决策,而是因为项目的整体漂移。最大的问题在于:
- **过早优化:** Xilem 的架构一直由追求极致性能的努力所驱动。鉴于我们至今没有对 Xilem 进行基准测试,这些努力大多基于信念。
- **复杂性成瘾:** Xilem 过于复杂。它的本质复杂性本来就高,*而且*我们一直在通过添加更多泛型参数、更多 trait、更多复杂性来解决问题。
- **范围蔓延:** 鉴于架构仍在迭代中,这个框架过于庞大、功能过于丰富。它有一个原生后端*和*一个 Web 后端、一个 tokio 运行时、大约 30 个 trait(如果算上 `xilem_web` 则有 187 个)、`View` trait 大约有 70 个实现(仅 `xilem_core` 中就有 21 个),等等。拥有这么多*东西*使得迭代架构变得困难,这也是为什么试图改变它的人往往最终选择白手起家的项目。这种总是添加更多——更多泛型、更多复杂性、更多特性——的倾向意味着,我们很难进行架构变更实验,因为每次变更都有很大的爆炸半径。这是一个重大问题,因为架构迫切需要改变。
## 架构问题
在我看来,Xilem 的架构陷入了多个死胡同。
### 双向数据绑定行不通
Xilem 的设计美学围绕组合对中央状态的可变访问展开。例如,假设你为应用状态定义了如下结构:
```rust
struct AppState {
posts: Posts,
users: UserList,
notifications: Notifications,
}
```
Xilem 表达这种结构的“理想”方式是一个显示帖子的组件、一个显示用户的组件、一个显示通知的组件,以及一个调用其他三个组件的根组件:
```rust
fn post_logic(posts: &mut Posts) -> impl View<...> {
...
}
fn users_logic(users: &mut UserList) -> impl View<...> {
...
}
fn notif_logic(notifications: &mut Notifications) -> impl View<...> {
...
}
fn app_logic(state: &mut AppState) -> impl View<...> {
v_stack(
post_logic(&mut state.posts),
users_logic(&mut state.users),
notif_logic(&mut state.notifications),
)
}
```
理想情况下,`post_logic` 不仅会读取帖子列表,还会根据事件等*修改它*。这表面上类似于 Rust 中的不重叠借用(disjoint borrows)概念:只要字段不同,你就可以可变地借用同一个结构体值的多个字段。更广泛地说,Xilem 试图拥抱**双向数据绑定**,即“此元素/组件读取此值”的设计模式同时意味着“此元素/组件*修改*此值”。这就是点击复选框应直接更新某个数据模型的 `is_checked` 字段的想法:
```rust
fn view(app_state: &mut State) -> impl View<...> {
checkbox("Check me!", app_state.is_checked, |app_state: &mut State, checked: bool| {
app_state.is_checked = checked;
})
}
```
这种对双向绑定的关注有着悠久的历史:
- Druid 的架构完全围绕双向绑定构建。
- Raph 的[关于 Xilem 的原始文章](https://raphlinus.github.io/rust/gui/2022/05/07/ui-architecture.html)明确通过 `Adapt` 节点展示了“修改子状态以修改父状态”的模式(又称透镜/lensing)。
- Daniel 的 [A mirage of a future xilem](https://xi.zulipchat.com/#narrow/channel/354396-xilem/topic/A.20mirage.20of.20a.20future.20Xilem/with/554031736),也就是 [Non-contiguous app state](https://github.com/linebender/xilem/pull/1444),是试图为 Xilem 提供更多从子组件修改父状态的方式。
我的看法很简单:**双向数据绑定在 Rust 中行不通**(在其他语言中也勉强可用)。Xilem 对双向绑定的执着意味着每个视图都需要携带一个 `State` 泛型参数,即使大多数视图并不需要它,这导致了更差的编译错误信息和更差的接口(见下一节)。此外,让每个组件同时读取应用程序状态的一部分并修改它,会产生笨拙的函数签名:
```rust
fn post_logic(posts: &mut PostData) -> impl View {
...
}
```
经过多年在 Xilem 上的工作,我可以自信地说:至少一半的问题来自将双向绑定融入了其 API。
### 高阶组件过于复杂
Xilem 借鉴了 React,定义了大量包装元素和高阶组件(HOC):
- `Provides`
- `WithContext`
- `Fork`
- `Lens`
- `MapMessage`
- `MapState`
- `Memoize`
- `Frozen`
- `RunOnce`
(我把它们统称为高阶组件,尽管严格来说只有 `Lens`、`Memoize` 和 `Frozen` 接受 `fn() -> View`。)
这些组件的签名如下:
```rust
pub fn map_action(
view: V,
map_fn: F,
) -> MapMessage< V, State, ParentAction, ChildAction, Context, impl Fn(Arg<'_, State>, MessageResult) -> MessageResult + 'static, >
where
State: ViewArgument,
ParentAction: 'static,
ChildAction: 'static,
V: View,
F: Fn(Arg<'_, State>, ChildAction) -> ParentAction + 'static,
{
MapMessage { ... }
}
```
这*不是*可接受的公共 API,然而 `map_action` 却是一个公共函数。使用 HOC 的代码看起来像这样:
```rust
pub fn view(&mut self, mastodon: Mastodon) -> impl WidgetView<...> {
let user = self.user_id.clone();
fork(
virtual_scroll(
self.statuses.len(),
|timeline: &mut Self, idx| {
// 如果我们“接近”最后下载的条目。
if idx + BUFFER >= timeline.statuses.len() {
// 发起下一个请求
timeline.pending_id = true;
timeline.requests.send(TimelineRequest { ... });
}
// ...
},
),
worker_raw(
move |proxy, mut recv: Receiver| {
let user = user.clone();
let mastodon = mastodon.clone();
async move {
// 对于每个请求,加载请求的状态
while let Some(next) = recv.recv().await {
let result = mastodon.get_account_statuses(...).await;
drop(proxy.message(result));
}
}
},
|timeline: &mut Self, sender| {
timeline.requests = sender;
},
|timeline: &mut Self, resp| {
match resp {
Ok(mut instance) => {
// ...
timeline.pending_id = false;
timeline.statuses.append(&mut instance.json);
}
Err(e) => {
// ...
}
}
Navigation::None
},
),
)
}
```
这并非*完全*不可读:如果你熟悉 `virtual_scroll`、`fork` 和 `worker`,你可以猜到:
- 这显示了一个虚拟列表。
- 它创建了一个带有三个回调的 worker:
- 在首次构建时(但*不是*重新构建时),回调 1 作为后台任务与队列接收器一起被生成。该任务持续轮询队列,处理其中的工作项,并将结果发送给 `proxy`。
- 在首次构建时,回调 2 会收到队列发送器,并将其赋值给本地状态。
- 每当后台任务向 `proxy` 发送结果时,回调 3 会收到该结果并将其推入本地状态。
- 当用户滚动并揭示新条目时,虚拟滚动会从本地状态取出队列发送器,并为这些条目的数据发送请求。
这是一个极端的例子,因为 `worker` 元素和 `RawProxy` 即使按 Xilem 的标准也很难用,但它说明了在尝试组合 Xilem 组件时你会得到的意大利面式逻辑,尤其是当你尝试同时组合状态和效果时。
### 视图不应有副作用
Xilem 包含几个带有副作用的视图;其想法是,该视图第一次被构建时副作用发生,之后重建视图就不会再做任何事情。基于副作用的视图包括:
- `without_elements`
- `fork`
- `run_once`
- `task`
- `worker`
我还认为 `WindowView` 在多窗口应用中的工作方式也是如此:我们返回一个窗口及选项的列表,如果该列表包含上一帧不存在的窗口(以 `WindowId` 为键),我们就用提供的选项创建这些新窗口。这并不太符合人们通常创建窗口的方式,并且它产生了一堆额外的复杂性(例如,在重建时不起作用的窗口创建选项),而这些我们本可以通过一个 `create_window()` API 来避免。
总的来说,响应式模型在你想要表达“这就是我希望 UI 看起来的样子,我不在乎具体怎么实现”时处于最佳状态。副作用(以及创建新窗口)不符合这种工作流程。我们应该有一种一流的方式来在后台线程上调度工作,而不是必须通过一个特殊的视图来声明。
## 架构解决方案:一等组件
这是我的第一个提议:我们明确定义一个非泛型的 `Component` 类型,将所有本地状态和副作用通过该类型传递,并教导人们编写 Xilem 应用时各处都使用这个 `Component` 类型。组件身份将基于字符串类型的键(例如 `status_icon_42`),并且在构建视图树时急切地检查这些键。Xilem 代码将如下所示:
```rust
fn child_component(x: &String) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
// ...
})
}
fn parent_component(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
let x = make_string();
let x2 = format!("{x}-2");
// ...
flex((
child_component(&x).resolve(ctx, Key::new(&x)),
child_component(&x2).resolve(ctx, Key::new(&x2)),
))
})
}
```
一些说明:
- `parent_component` 和 `child_component` 都返回 `Component`,而不是 `impl View`。没有用于应用程序状态的类型参数,也没有 `+ use<>` 注解。闭包是类型擦除的。
- 因为每个组件都必须立即解析,`child_component` 可以借用 `&String`。
- 闭包接受 `&mut CompCtx`,用于任何与副作用相关的事情,以及 `meta`,一个 ZST 标记,我称之为“元数据令牌”。
### 本地状态
组件有本地状态,可以通过 `CompCtx` 访问:
```rust
fn my_component(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
let count = ctx.local_state();
flex((
button("+", ...),
button("-", ...),
label(format!("Total is {count}")),
))
})
}
```
事件回调可以修改本地状态。回调与元数据令牌一起传递:
```rust
flex((
button("+", meta, |_e, count, _| {
*count += 1;
}),
button("-", meta, |_e, count, _| {
*count -= 1;
}),
label(format!("Total is {count}")),
))
```
事件回调也可以返回一个事件,该事件将由组件向上传递:
```rust
fn fancy_button(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
button("I'm fancy", meta, |e, (), _| {
FancyClick(e)
})
})
}
```
(我之前撒谎了,`Component` 类型并非*完全*非泛型。)
元数据令牌是我称之为 [“类型走私”](https://xi.zulipchat.com/#narrow/channel/354396-xilem/topic/Inference.20Hint.20Tokens/with/555335483) 的模式的一部分。因为这个类型在组件和事件处理器之间几乎保证相同,所有中间类型都可以被类型擦除。因此,不再是:
```rust
pub fn button(
text: impl Into,
callback: impl Fn(ButtonEvent, &mut State) -> Action,
// ...
) -> Button;
```
而是:
```rust
pub fn button(
text: impl Into,
meta: Metadata,
callback: impl Fn(ButtonEvent, &mut State) -> Action,
// ...
) -> Button;
```
不再是:
```rust
pub fn flex<State: ViewArgument, Seq: Sequence>(
axis: Axis,
sequence: Seq,
// ...
) -> Flex;
```
而是:
```rust
pub fn flex(
axis: Axis,
sequence: Seq,
// ...
) -> Flex;
```
这意味着一些类型检查将从编译时移到运行时;我相信这是正确的权衡,因为元数据令牌保证你永远不会遇到向下转型错误,除非你非常刻意地尝试。
### 副作用
副作用应该发生在组件回调中,而不是视图重建期间。组件回调是与交互式小部件关联的回调,或者是由组件显式请求的回调:
```rust
fn my_component(...) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
ctx.side_effect(|(), ctx: &mut EventCtx| {
println!("Hello");
ctx.do_things();
});
// ...
})
}
```
大多数代码应该避免副作用,而是发送数据请求。请求与某个键绑定,如果键发生变化则重新发送,并在组件被移除时取消。该请求由用户代码在后台 worker 中处理,直到该 worker 发送响应(或收到取消令牌)。因此,一个加载图像的组件可能看起来像这样:
```rust
fn user_avatar(username: &str) -> Component {
Component::new(|ctx: &mut CompCtx, meta| {
let avatar_data = ctx.request(UserAvatarRequest::new(username));
if let Ok(data) = avatar_data {
// ...
}
})
}
```
后台 worker 代码可能看起来像:
```rust
fn background_loop(ctx: &WorkerCtx, ...) {
loop {
// ...
if let Some((request, id)) = ctx.poll::<UserAvatarRequest>() {
// ...
ctx.complete(id, avatar_data);
}
}
}
```
最后一个例子更具推测性;我不太确定后台 worker 具体会是什么;例如,它可能运行在 tokio 执行器上。重要的是它与 UI 代码分离。
这将是对事件系统的一次大规模改革:
- `MessageResult` 类型将被移除。
- `RawProxy` 将被替代。
- `map_action` 和 `map_result` 将被移除。
- Views
相似文章
2026 年的 Zig 与 Rust
本文在 2026 年的背景下对比了 Zig 和 Rust,认为编程代理通过自动化生成 Rust 代码,削弱了 Zig 在人机交互体验上的优势。
Redox本月更新 - 2026年5月 - Redox - 你的下一代操作系统
Redox OS,一个用Rust编写的类Unix微内核操作系统,分享了其2026年5月的更新,包括宣布夏季代码项目、实现EEVDF调度器,以及I/O事件和文件系统inode缓存的显著性能改进。
你讨厌XML吗?(2010)
一篇2010年的反思性博客文章,探讨了开发者讨厌XML的原因,追溯了炒作和反弹的周期,并讨论了使用XML进行数据建模和互操作性所面临的挑战。
Bun 的问题可能在于公开开发
一篇分析 Bun 实验性使用 LLM 将其 Zig 代码库转译到 Rust 所引发的争议的文章,强调公众的强烈反应源于透明的开发实践而非实验本身。
@GoSailGlobal: https://x.com/GoSailGlobal/status/2052573500800700560
SWE-WebDev Bench 是 arXiv 上的一篇论文,评测了 6 个主流 vibe coding 平台(Lovable、Replit Agent3、Vercel v0-Max、Base44、Emergent E1-OPUS、QwikBuild),发现所有平台工程综合分都没超过 60%,前端 UI 漂亮但后端、安全、生产就绪度集体翻车,需要 12-60 小时人工修复才能上线。