一个优雅的 Rust GUI 库

2024 年 3 月 28 日

@jkelleyrtp,
@ealmloff

故事

在 Dioxus Labs,我们有一条非正式的规则:每年只能重写一次。

我们的上次重写带来了一些令人惊叹的功能:模板、热重载和疯狂的性能。 然而,不要误会,重写是可怕的、耗时的,而且是一场巨大的赌博。 我们于 2024 年 1 月 1 日开始进行新的重写,并于 2 月 1 日完成,然后又花了一个半月的时间编写测试、消除错误和完善文档。 重写绝对不适合胆小的人。

如果您是新来的,Dioxus (dye•ox•us) 是一个用于在 Rust 中构建 GUI 的库。 最初,我将 Dioxus 构建为 Yew 的重写,目的是支持正确的服务器端渲染。 最终,Dioxus 变得很受欢迎,我们得到了一些很棒的赞助商,而我则全职工作。 我们已经从 1 个人(我)的团队成长为 4 人(!)的团队 – 完全来自精彩的 dioxus 社区。

现在,迪奥克斯有点不同了。 现实生活中,实际的公司正在通过 Dioxus 发布网络应用程序、桌面应用程序和移动应用程序。 曾经只是一个有趣的小副项目为一小部分应用程序提供了支持。 我们现在有简化整个应用程序开发生态系统的崇高目标。 Web、桌面、移动,所有端到端类型安全,速度极快,生活在一个代码库下。 梦想!

在 0.5 中,我们仔细研究了 Dioxus 需要如何改变才能实现这些目标。 我们从社区得到的要求很明确:让它更简单,让它更强大,让它更精致。

什么是新的?

这可能是 Dioxus 有史以来最大的版本,有如此多的新功能、错误修复和改进,我无法全部列出。 我们在 0.4.3 到 0.5.0 之间修改了超过 100,000 行代码(是的,超过 100,000 行),并进行了超过 1,400 次提交。 这是一个快速概述:

终生问题

为了让 Dioxus 更简单,我们想完全删除生命周期。 Rust 新手很容易被生命周期问题吓倒,甚至经验丰富的 Rustaceans 也会发现在晦涩的错误消息中费力费力。

在 dioxus 0.1-0.4 中,组件中的每个值都存在一个 'bump 寿命。 这个生命周期让您可以轻松地在事件侦听器中使用钩子、道具和范围,而无需克隆任何内容。 这是 Dioxus 发布时比 Yew 更容易使用的主要创新。


fn OldDioxusComponent(cx: Scope) -> Element {
    
    let mut state = use_state(cx, || 0);
    cx.render(rsx! {
        button {
            
            
            onclick: move |_event| *state += 1,
        }
    })
}

这对于钩子非常有用 最多 的时间。 每次您想要在 EventHandler 中使用某个值(onclick、oninput 等)时,生命周期都可以让您省略大量手动克隆。

然而,生命周期并不适用于未来。 Dioxus 的期货需要 'static 这意味着您始终需要在将来使用值之前克隆它们。 由于 future 可能需要在组件渲染时运行,因此它无法共享组件的生命周期。

// Scope and Element have the lifetime 'bump
fn OldDioxusComponent(cx: Scope) -> Element {
    // state has the lifetime 'bump
    let state = use_state(cx, || 0);

    cx.spawn({
        // Because state has the lifetime 'bump, we need to clone it to make it
        // 'static before we move it into the 'static future
        let state = state.clone();
        async move {
            println!("{state}");
        }
    });

    // ...
}

如果您不克隆该值,您将遇到如下错误:

4  |   fn OldDioxusComponent(cx: Scope) -> Element {
   |                         --
   |                         |
   |                         `cx` is a reference that is only valid in the function body
   |                         has type `&'1 Scoped<'1>`
...
8  | /     cx.spawn(async move {
9  | |         println!("{state}");
10 | |     });
   | |      ^
   | |      |
   | |______`cx` escapes the function body here
   |        argument requires that `'1` must outlive `'static`

该错误抱怨说 cx 必须比 'static 根本不提及钩子,这可能会非常令人困惑。

Dioxus 0.5 通过首先删除范围和 'bump 生命周期,然后引入一个新的 Copy 状态管理解决方案称为信号。 这是 dioxus 0.5 中该组件的样子:


fn NewComponent() -> Element {
    
    let mut state = use_signal(|| 0);

    
    spawn(async move {
        println!("{state}");
    });

    rsx! {
        button {
            
            onclick: move |_event| state += 1,
        }
    }
}

虽然这看起来似乎是一个相当无害的更改,但它对编写新组件的容易程度产生了令人印象深刻的巨大影响。 我想说,仅通过这一更改,构建新的 Dioxus 应用程序就容易了 2-5 倍。

再见范围和生命周期!

在新版本的 dioxus 中,范围和 'bump 生命周期已被删除! 这使得声明组件并在该组件中使用运行时函数变得更加容易:

现在,您可以通过直接接受 props 而不是范围参数来声明组件

#[component]
fn MyComponent(name: String) -> Element {
    rsx! { "Hello {name}!" }
}

在该组件内部,您可以直接使用运行时函数

spawn(async move {
    tokio::time::sleep(Duration::from_millis(100)).await
    // You can even use runtime functions inside futures and event handlers!
    let context: i32 = consume_context()
})

如今生命已逝, Element'static 这意味着您可以在钩子中使用它们,甚至可以通过上下文 API 提供它们。 这使得一些 API 像 Dioxus 中的虚拟列表 明显更容易。 我们期望社区中出现更多有趣的 API,因为您不需要成为 Rust 向导来实现虚拟化和离屏渲染等功能。

删除核心中所有不安全的内容

删除 'bump 生命周期和范围让我们有机会消除 Dioxus 中的许多不安全因素。 dioxus-core 0.5 不包含不安全代码 🎉

在我们计划在整个 0.5 发布周期中删除的各种依赖项中仍然存在一些不安全的因素,但要少得多:所有这些都非常容易削减,或者不幸的是由于 FFI 是必需的。

信号!

Dioxus 0.5 引入了信号作为组件的核心状态原语。 与现有的信号相比,信号有两个关键优势 use_stateuse_ref 钩子:它们总是 Copy 而且它们不需要手动订阅。

复制状态

SignalCopy,即使内部 T 价值观不是。 这是由我们的新功能启用的 [generational-box](https://crates.io/crates/generational-box) crate(以零不安全实现)。 信号甚至可以选择 Send+Sync 如果您需要在线程之间移动它们,则无需一整类专门的状态管理解决方案。

的组合 Copy + Send + Sync 信号和静态组件使得将状态移动到您需要的任何地方变得非常容易:

fn Parent() -> Element {
    
    
    let mut state = use_signal_sync(|| 0);

    spawn(async move {
        
        
        let value: i32: state();
    });

    
    std::thread::spawn(move || {
        loop {
            std::thread::sleep(Duration::from_millis(100));
            println!("{state}");
        }
    });

    rsx! {
        
        onclick: move |_| state += 1;
    }
}

Copy 状态,我们本质上在 Rust 中加入了一种轻型垃圾收集形式,它使用组件生命周期作为删除状态的触发器。 从内存角度来看,这与 0.4 基本相同,但具有不需要显式地 Clone 任何事物。

更智能的订阅

信号更智能地知道哪些组件在更改时重新运行。 仅当您读取组件中的信号值(而不是异步任务或事件处理程序中)时,组件才会重新运行。 在此示例中,单击按钮时只有子组件会重新渲染,因为只有子组件正在读取信号:

fn Parent() -> Element {
  let mut state = use_signal(|| 0);

  rsx! {
    button { onclick: move |_| state += 1, "increment" }
    Child { state }
  }
}


fn Child(state: Signal) -> Element {
  rsx! { "{state}" }
}

更智能的订阅让我们可以将几个不同的钩子合并到信号中。 例如,我们能够删除一个专门用于状态管理的整个箱子:费米。 费米本质上提供了 use_state 使用静态作为键的 API。 这意味着您可以声明一些全局状态,然后在组件中读取它:

static COUNT: Atom<i32> = Atom::new(|| 0);

fn Demo(cx: Scope) -> Element {
    let mut count = use_read_atom(cx, &COUNT);
    rsx! { "{count}" }
}

由于 fermi 不支持智能订阅,因此您必须显式声明使用权限 use_read/ use_write 订阅该值的钩子。 在 Dioxus 0.5 中,我们只使用信号,完全不需要任何类型的外部状态管理解决方案。



static COUNT: GlobalSignal<i32> = Signal::global(|| 0);



fn Demo() -> Element {
   rsx! { "{COUNT}" }
}

信号甚至可以与上下文 API 配合使用,因此您可以在应用程序中的组件之间快速共享状态:

fn Parent() -> Element {
  // Create a new signal and provide it to the context API
  // without a special use_shared_state hook
  let mut state = use_context_provider(|| Signal::new(0));

  rsx! {
    button { onclick: move |_| state += 1, "Increment" }
    Child {}
  }
}

fn Child() -> Element {
  // Get the state from the context API
  let state = use_context::>();
  rsx! { "{state}" }
}

智能订阅也适用于挂钩。 钩子就像 use_futureuse_memo 现在将自动将您在钩子内读取的信号添加到钩子的依赖项中:


static COUNT: GlobalSignal<i32> = Signal::global(|| 0);

fn App() -> Element {
    
    
    let memo = use_memo(move || COUNT() / 2);

    rsx! { "{memo}" }
}

CSS 热重载

作为资产系统大修的一部分,我们在资产目录中实现了 CSS 文件的热重载。 如果 RSX 中出现 CSS 文件, dx CLI 将监视该文件并立即将其更新传输到正在运行的应用程序。 这适用于 Web、桌面和全栈,并在未来以移动为中心的更新中提供移动支持。

与 Tailwind 观察器结合使用时,我们现在支持 Tailwind CSS 的热重载! 最重要的是,我们还支持 VSCode 中 Tailwind 类的 IDE 提示 自定义正则表达式扩展



更棒的是,您可以一次将这些更改流式传输到多个设备,从而在您的目标所有设备上解锁同时热重载:

事件系统重写

自发布以来,dioxus 使用合成事件系统来创建跨平台事件 API。 合成事件对于使事件跨平台工作甚至跨网络序列化它们非常有用,但它们确实有一些缺点。

Dioxus 0.5 最终公开了每个平台的底层事件类型以及跨平台 API 的特征。 这样做有两个优点:

  1. 您可以从平台事件类型获取所需的任何信息或将该类型传递给另一个库:
fn Button() -> Element {
    rsx! {
        button {
            onclick: move |event| {
                let web_sys_event: web_sys::MouseEvent = event.web_event();
                web_sys::console::log_1(&web_sys_event.related_target.into());
            }
        }
    }
}
  1. Dioxus 可以为应用程序不使用的事件捆绑拆分代码。 对于 hello world 示例,这会将 gzip 压缩后的大小缩小约 25%!

较小的捆绑

同样,这表面上看起来像是一个小变化,但它开辟了数十个新的用例和可能的库,您可以使用 dioxus 构建。

跨平台推出

Dioxus 0.5 引入了一个新的跨平台 API 来启动您的应用程序。 这使得同一应用程序可以轻松定位多个平台。 您现在可以启用 dioxus crate 上的一项功能并从 prelude 中调用启动函数,而不是引入单独的渲染器包:

[dependencies]
dioxus = "0.5"

[features]
default = []
desktop = ["dioxus/desktop"]
fullstack = ["dioxus/fullstack"]
server = ["dioxus/axum"]
web = ["dioxus/web"]
use dioxus::prelude::*;

fn main() {
    dioxus::launch(|| rsx!{ "hello world" })
}

通过该单一应用程序,您可以轻松定位:


dx serve 


dx serve 


dx serve 

CLI 现在足够智能,可以根据您的目标平台自动传递适当的构建功能。

资产系统测试版

目前,dioxus 中的资产(以及一般的 Web 应用程序)可能很难获得正确的结果。 指向您的资产的链接很容易就会过时,指向您的资产的链接在桌面应用程序和 Web 应用程序之间可能有所不同,并且您需要手动将要使用的资产添加到捆绑的应用程序中。 除此之外,资产可能是一个巨大的性能瓶颈。

我们以文档站点中的 dioxus 移动指南为例:

docsite_mobile_old.png

0.4 移动指南需要 7 秒加载并传输 9 MB 资源。 该页面有 6 个不同的大图像文件,这会显着减慢页面加载时间。 我们可以切换到更优化的图像格式,例如 avif ,但是手动转换每个屏幕截图既繁琐又耗时。

我们来看看0.5移动版新资产系统的指南:

docsite_mobile_new.png

新的移动指南加载时间不到 1 秒,并且仅需要 1/3 的资源且具有完全相同的图像!

Dioxus 0.5 引入了一个新的资产系统,称为 [manganis](https://github.com/DioxusLabs/collect-assets)。 Manganis 与 CLI 集成来检查、捆绑和优化应用程序中的资产。 该 API 目前不稳定,因此资产系统目前作为单独的 crate 发布。 在新的资产系统中,您只需将您的资产包装在 mg! 宏,CLI 将自动获取它们。 您可以阅读有关新资产系统的更多信息 曼加尼斯文档

随着我们继续迭代 0.5 版本,我们计划向 manganis 资源添加热重载,这样您就可以以交互方式向应用程序添加新功能,例如 CSS、图像、顺风类等,而无需强制完全重新加载。

桌面渲染速度提高 5 倍

Dioxus 实现了多项优化来加快 diff 渲染速度。 模板 让 dioxus 跳过对 rsx 宏的任何静态部分的比较。 然而,差异只是故事的一方面。 创建需要对 DOM 进行的更改列表后,您需要应用它们。

我们开发了 大锤 让 dioxus web 能够尽快应用这些突变。 它使得从 Rust 操作 DOM 几乎就像 与原生 JavaScript 一样快

在 dioxus 0.5 中,我们应用相同的技术来尽可能快地在网络上应用更改。 dioxus 0.5 使用二进制协议,而不是使用 json 将更改传达给桌面和实时视图渲染器。

对于渲染密集型工作负载,新渲染器只需 1/5 的时间即可在浏览器中应用更改,且延迟时间为 1/2。 这是我们在开发新的二进制协议时开发的基准之一。 在 dioxus 0.4 中,渲染器经常冻结。 在dioxus 0.5中,运行流畅:

二恶英0.4

二恶英 0.5

传播道具

创建组件时的一种常见模式是为特定元素提供一些附加功能。 当您包装元素时,对最终元素中设置的属性进行一些控制通常很有用。 dioxus 0.5 支持扩展特定元素并将属性传播到元素中,而不是手动复制元素中的每个属性:

#[derive(Props, PartialEq, Clone)]
struct Props {
    
    #[props(extends = img)]
    attributes: Vec,
}

fn ImgPlus(props: Props) -> Element {
    rsx! {
        
        img { ..props.attributes }
    }
}

rsx! {
  ImgPlus {
    
    width: "10px",
    height: "10px",
    src: "https://example.com/image.png",
  }
}

速记属性

我们添加的另一个巨大的生活质量功能是能够使用速记结构初始化语法将属性传递到元素和组件中。 我们厌倦了路过 class: class 并决定最终实现这个期待已久的功能,但代价是一些代码损坏。 现在,从 props 声明属性非常简单:

#[component]
fn ImgPlus(class: String, id: String, src: String) -> Element {
    rsx! {
        img { class, id, string }
    }
}

此功能适用于任何实现 IntoAttribute,这意味着信号也受益于速记初始化。 虽然信号作为属性尚未跳过比较,但我们计划将其添加为整个 0.5 发布周期的性能优化。

多行属性合并

此周期添加的另一个令人惊叹的功能是属性合并。 当使用像 tailwind 这样的库时,您偶尔会想要将某些属性设置为有条件的。 以前,您必须使用空字符串来格式化属性。 现在,您可以简单地添加一个带有条件的额外属性,并且该属性将使用空格作为分隔符来合并:

#[component]
fn Blog(enabled: bool) -> Element {
    rsx! {
        div {
            class: "bg-gray-200 border rounded shadow",
            class: if enabled { "text-white" }
        }
    }
}

当使用像 tailwind 这样的库时,这一点尤其重要,其中属性需要在编译时解析,但又需要在运行时动态解析。 此语法与 tailwind 编译器集成,消除了 tailwind-merge 等库的运行时开销。

服务器功能流媒体

Dioxus 0.5支持最新版本 服务器功能箱 支持流数据。 服务器功能现在可以选择将数据传输到客户端或从客户端传输数据。 这使得在服务器上执行一整类任务变得更加容易。

创建流服务器函数就像定义输出类型并从服务器函数返回 TextStream 一样简单。 流服务器功能非常适合在任何长时间运行的任务期间更新客户端。

我们在这里构建了一个人工智能文本生成示例: https://github.com/ealmloff/dioxus-streaming-llm 它使用 Kalosm 和本地 LLMS 来提供服务,本质上是 OpenAI 的 ChatGPT 端点在商用硬件上的克隆。

#[server(output = StreamingText)]
pub async fn mistral(text: String) -> Result {
   let text_generation_stream = todo!();
   Ok(TextStream::new(text_generation_stream))
}

旁注,这里使用的 AI 元框架 – Kalosm – 由 Dioxus 核心团队成员 ealmloff 维护,他的 AI GUI 应用程序 Floneum 是用 Dioxus 构建的!

全栈 CLI 平台

CLI 现在支持 fullstack 为客户端和服务器提供热重载和并行构建的平台。 您现在可以使用以下方式为您的全栈应用程序提供服务 dx 命令:

dx serve

# Or with an explicit platform
dx serve --platform fullstack

实时查看路由器支持

https://github.com/DioxusLabs/dioxus/pull/1505

@唐·阿朗索 在 dioxus 0.5 中添加了对路由器的实时查看支持。 路由器现在可以与您的实时查看应用程序一起使用!

自定义资产处理程序

https://github.com/DioxusLabs/dioxus/pull/1719

@willcrichton 向 dioxus 桌面添加了对自定义资产处理程序的支持。 自定义资产处理程序可让您高效地将数据从 rust 代码流式传输到浏览器,而无需通过 JavaScript。 这对于高带宽通信非常有用,例如 视频流

现在,您可以执行诸如使用 gstreamer 或 webrtc 等操作并将数据直接通过管道传输到 Web 视图中,而无需手动对帧进行编码/解码。

本机文件处理

这是一个较小的调整,但现在我们正确支持桌面文件删除:


以前我们只是为您提供了拦截文件删除的选项,但现在它已原生集成到事件系统中

错误处理

错误处理:您可以使用错误边界和 throw 特征来轻松处理应用程序中更高层的错误

Dioxus 提供了一种更简单的方法来处理错误:抛出错误。 抛出错误结合了错误状态和早期返回的最佳部分:您可以轻松地抛出错误 ?,但您保留有关错误的信息,以便可以在父组件中处理它。

您可以致电 throw 任何 Result 实现的类型 Debug 将其转变为错误状态,然后使用 ? 如果您确实遇到错误,请尽早返回。 您可以使用捕获错误状态 ErrorBoundary 如果在其任何子组件中抛出错误,该组件将呈现不同的组件。

fn Parent() -> Element {
  rsx! {
    ErrorBoundary {
        handle_error: |error| rsx! {
            "Oops, we encountered an error. Please report {error} to the developer of this application"
        },
        ThrowsError {}
    }
  }
}

fn ThrowsError() -> Element {
    let name: i32 = use_hook(|| "1.234").parse().throw()?;

    todo!()
}

你甚至可以筑巢 ErrorBoundary 用于捕获应用程序不同级别的错误的组件。

fn App() -> Element {
  rsx! {
    ErrorBoundary {
        handle_error: |error| rsx! {
            "Hmm, something went wrong. Please report {error} to the developer"
        },
        Parent {}
    }
  }
}

fn Parent() -> Element {
  rsx! {
    ErrorBoundary {
        handle_error: |error| rsx! {
            "The child component encountered an error: {error}"
        },
      ThrowsError {}
    }
  }
}

fn ThrowsError() -> Element {
  let name: i32 = use_hook(|| "1.234").parse().throw()?;

  todo!()
}

当您的代码生成不可恢复的错误时,此模式特别有用。 您可以优雅地捕获这些“全局”错误状态,而无需自己恐慌或处理每个错误的状态。

默认热重载和桌面“开发”模式

我们在 0.3 中提供了热重载,在 0.4 中将其添加到桌面,现在我们终于在 0.5 中默认启用它。 默认情况下,当您 dx serve 您的应用程序在开发模式下启用了热重载。

此外,我们还极大地改善了开发人员构建桌面应用程序的体验。 当我们无法热重载应用程序并且必须进行完全重新编译时,我们现在保留打开窗口的状态并恢复该状态。 这意味着您的应用程序不会在每次编辑时挡住整个屏幕,并且会保持其大小和位置,从而带来更神奇的体验。 一旦你玩过它,你就再也回不去了——它就是那么好。



dioxus 模板更新

通过此更新,我们最新的核心团队成员 Miles 投入了大量精力来彻底修改文档和模板。 我们现在拥有模板,可以在一个命令下为 Web、桌面、移动、tui 和全栈创建新的 dioxus 应用程序。

我们还更新了您使用时获得的默认应用程序 dx new 更接近传统的 create-react-app。 该模板现在包含资产、CSS 和一些基本部署配置。 另外,它还包含有用资源的链接,例如 dioxus-std、VSCode 扩展、文档、教程等。

新模板

Dioxus-社区和 Dioxus-std

Dioxus 社区很特别:discord 成员 marc 和 Doge 一直在努力为 0.5 版本更新重要的生态系统包。 在此版本中,图标、图表和 dioxus 特定标准库等重要包可以立即使用。 这 Dioxus Community 项目是一个新的 GitHub 组织,即使原始维护者下台,它也能保持重要的包保持最新状态。 如果您为 Dioxus 构建一个库,我们将很乐意帮助维护它,使其保持本质上的“第 2 层”支持。

dioxus_社区

即将推出

在某个时刻,我们不得不停止向此版本添加新功能。 有很多很酷的项目即将出现:

  • 稳定并更加深度整合资产体系
  • 捆绑分割输出 .wasm 直接 – 使用惰性组件
  • 孤岛和可恢复的交互性(序列化信号!)
  • 服务器组件并将 LiveView 合并到全栈中
  • 增强的开发工具(可能具有一些人工智能!)和测试框架
  • 完成移动大修
  • 使用 websocket、SSE、渐进式表单等进行全栈检修

先睹为快:使用 Servo 复兴 Dioxus-Blitz

我们现在不会对此说太多,但这里是“Blitz 2.0”的先睹为快……我们最终将伺服集成到 blitz 中,以便您可以使用与 Firefox 相同的 CSS 引擎通过 WGPU 进行本地渲染。 为了推动这项工作,我们全职聘请了才华横溢的 Nico Burns(我们布局库 Taffy 背后的奇才)。 稍后会详细介绍,但这里有一个小演示 谷歌网站 完全在 GPU 上以 900 FPS 渲染:

谷歌用闪电战渲染

诚然,当前的迭代还没有完全实现(google.com 实际上有点不稳定),但我们在这里进展很快,并且正在快速接近一些非常有用的东西。 如果您想查看并参与其中,则可以使用该存储库:

https://github.com/jkelleyrtp/stylo-dioxus

你可以如何贡献?

好了,新功能就到此为止了。 我们可能错过了一些东西(有很多新东西!)。 如果您和我们一样发现 Dioxus 令人兴奋,我们希望您能帮助我们彻底改变应用程序开发。 我们希望做出贡献,包括:

  • 将文档翻译成您的母语
  • 尝试“好的第一个问题”
  • 改进我们的文档
  • 为 CLI 做出贡献
  • 帮助回答来自 Discord 社区的问题

就是这样! 我们非常感谢社区的支持,并对 2024 年剩下的时间感到兴奋。

构建很酷的东西! ✌️

1711637808
#一个优雅的 #Rust #GUI #库
2024-03-28 14:41:23

Leave a Reply

Your email address will not be published. Required fields are marked *

近期新闻​

编辑精选​