2026年Xilem批判性回顾

Lobsters Hottest 工具

摘要

对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

Lobsters Hottest

本文在 2026 年的背景下对比了 Zig 和 Rust,认为编程代理通过自动化生成 Rust 代码,削弱了 Zig 在人机交互体验上的优势。

你讨厌XML吗?(2010)

Hacker News Top

一篇2010年的反思性博客文章,探讨了开发者讨厌XML的原因,追溯了炒作和反弹的周期,并讨论了使用XML进行数据建模和互操作性所面临的挑战。

Bun 的问题可能在于公开开发

Lobsters Hottest

一篇分析 Bun 实验性使用 LLM 将其 Zig 代码库转译到 Rust 所引发的争议的文章,强调公众的强烈反应源于透明的开发实践而非实验本身。

@GoSailGlobal: https://x.com/GoSailGlobal/status/2052573500800700560

X AI KOLs Timeline

SWE-WebDev Bench 是 arXiv 上的一篇论文,评测了 6 个主流 vibe coding 平台(Lovable、Replit Agent3、Vercel v0-Max、Base44、Emergent E1-OPUS、QwikBuild),发现所有平台工程综合分都没超过 60%,前端 UI 漂亮但后端、安全、生产就绪度集体翻车,需要 12-60 小时人工修复才能上线。