Druid 源码导读data-first 的 Rust GUI 工具包如何驱动一整棵控件树【免费下载链接】druidA>项目地址: https://gitcode.com/gh_mirrors/drui/druidDruid 是一个data-first数据优先的 Rust-native UI 工具包你先声明应用的数据模型再搭出一棵能显示并修改这份数据的控件树框架负责把数据变化一路传导到重绘。它由 linebender 团队打磨多年主笔是 Raph Levien 与 Colin Rofls。读完这篇带读你能用三行依赖把一个带按钮、带文本框的窗口跑起来说清楚Data/Lens/Widget/BoxConstraints四个概念各自卡在数据流的哪一环打开 druid/src/core.rs 这类文件看懂数据一变谁被判定为重画的判定代码判断一个具体的桌面应用该用 Druid、该用它的继任者 Xilem、还是该用别的框架。先花 30 秒看全貌仓库是三个 crate 叠出来的分层结构你日常 import 的只有最上面那层druid/ # 你 import 的 crate控件、布局、Data/Lens、AppLauncher ├── src/ │ ├── app.rs # AppLauncher / WindowDesc启动与窗口描述 │ ├── core.rs # WidgetPod事件路由 数据更新的中枢 │ ├── data.rs # Data trait 与各基础类型的 same 实现 │ ├── box_constraints.rs # 盒约束布局的最小单元 │ ├── contexts.rs # 五个 CtxEvent / Update / Layout / Paint / LifeCycle │ ├── lens/ # Lens trait、组合子、lens! 宏 │ └── widget/ # 40 内置控件Flex / Label / Button / Scroll / Split… druid-shell/ # 平台抽象层窗口/事件/剪贴板/菜单的六套后端 ├── src/backend/ │ ├── gtk/ ├── mac/ ├── windows/ │ ├── x11/ ├── wayland/ └── web/ druid-derive/ # 过程宏#[derive(Data)] 与 #[derive(Lens)]记住三个抓手就行druid管画什么、怎么连druid-shell管在哪个平台上落地druid-derive管帮你生成样板。后面所有机制都能在这三层里找到落点。它到底是什么又为什么会停更先说定位。Druid 的口号是 A>[dependencies] druid 0.8.3第二步平台前置。如果你在 Linux默认后端是 GTK需要先装 GTK 开发库# Ubuntu 系 sudo apt-get install libgtk-3-dev # OpenBSD pkg_add gtk3第三步最小可跑示例。这就是 README.md 里的经典计数器它一次性覆盖了AppLauncher、WindowDesc、Widget、Data四个核心角色use druid::widget::{Button, Flex, Label}; use druid::{AppLauncher, LocalizedString, PlatformError, Widget, WidgetExt, WindowDesc}; fn main() - Result(), PlatformError { let main_window WindowDesc::new(ui_builder()); let data 0_u32; AppLauncher::with_window(main_window) .log_to_console() .launch(data) } fn ui_builder() - impl Widgetu32 { let text LocalizedString::new(hello-counter).with_arg(count, |data: u32, _env| (*data).into()); let label Label::new(text).padding(5.0).center(); let button Button::new(increment) .on_click(|_ctx, data, _env| *data 1) .padding(5.0); Flex::column().with_child(label).with_child(button) }注意*data 1——按钮的on_click闭包拿到的是mut u32直接改数据改完之后框架自动把变化传回控件树。这一行就是data-first的全部含义。main之所以返回Result(), PlatformError是因为窗口实例化可能失败通常是致命错误。顺着数据流拆核心机制Druid 的运行可以拆成一条单向流水线定义数据 → 聚焦数据的一片 → 数据一变决定谁更新 → 布局收约束、绘制走 Piet。下面每一步都配真实源码。第 1 步用 Data 把模型变成可比较的值框架要判断数据到底变没变靠的是 druid/src/data.rs 里一个方法极少的 traitpub trait Data: Clone static { fn same(self, other: Self) - bool; }sane的设计要点是必须便宜文档写着These should be cheap to compare and cheap to clone。same返回true时两个值必须相等但两个相等的值不一定same比如分别分配的两份拷贝。这带来两条值得记住的实现细节// 浮点按位比较两个 NaN 只要位表示相同就算相等 impl Data for f64 { fn same(self, other: Self) - bool { self.to_bits() other.to_bits() } } // Arc 只比指针不碰内容 implT: ?Sized static Data for ArcT { fn same(self, other: Self) - bool { Arc::ptr_eq(self, other) } }整数、bool、char、String、时间类型、Option、Result、元组、数组、Range族、kurbo 几何类型都内置了实现。真正容易踩坑的是std的集合类型Vec、HashMap…没有Data因为逐元素比较可能很贵。两条出路——把集合包进Arc指针相等或开imfeature 用imcrate 的不可变集合。im::Vector的实现甚至做了分层优化data.rs 562-573 行小到能内联时逐元素比否则直接ptr_eq。实际建模用 derive字段还能加属性data.rs 73-87 行的文档示例#[derive(Clone, Data)] struct PathEntry { #[data(eq)] // PathBuf 没有 Data让它按 PartialEq 比较 path: std::path::PathBuf, priority: usize, #[data(ignore)] // 缓存/时间戳这类不属于数据模型的字段跳过比较 last_read: std::time::Instant, }第 2 步用 Lens 把焦点对准数据的一片应用长大到一定程度控件树里每个子树只关心数据的一小片。LensT, U就是从 T 中取出 U的坐标。它的两个方法都走闭包而不是直接返回引用druid/src/lens/lens.rs 26-43 行pub trait LensT: ?Sized, U: ?Sized { fn withV, F: FnOnce(U) - V(self, data: T, f: F) - V; fn with_mutV, F: FnOnce(mut U) - V(self, data: mut T, f: F) - V; }直白翻译不返回引用是为了允许 lens临时合成数据。例如对一个不可变列表lens 可以先克隆一份、让闭包改克隆、闭包返回后再把结果写回原引用——闭包形式才能容纳这种先拷贝再改的流程。LensExt给了组合能力lens.rs 45-228 行get/put读写、then串联两个 lens、index取索引、deref调解引用、in_arc带写时复制地操作Arc内容。最有意思的是map能把一个不在结构里但能算出来的值适配成 lens// 把 0–2 范围的量映射给只认 0–1 的 Slider let lens lens!((bool, f64), 1).map(|x| x / 2.0, |x, y| *x y * 2.0);对结构体、元组、可索引容器还能用lens!宏按需造 lenslens.rs 283-291 行这在操作别的 crate 定义、你没法加 derive的类型时尤其有用LensWrap::new(WidgetThatExpectsf64::new(), lens!(AppState, value));第 3 步数据一变框架如何决定谁该重画这是整套架构的心脏。控件实现 druid/src/widget/widget.rs 的Widgettrait它带一个关联数据类型T五个方法都能摸到这份数据其中event拿到的是可变引用fn event(mut self, ctx: mut EventCtx, event: Event, data: mut T, env: Env); fn lifecycle(mut self, ctx: mut LifeCycleCtx, event: LifeCycle, data: T, env: Env); fn update(mut self, ctx: mut UpdateCtx, old_data: T, data: T, env: Env); fn layout(mut self, ctx: mut LayoutCtx, bc: BoxConstraints, data: T, env: Env) - Size; fn paint(mut self, ctx: mut PaintCtx, data: T, env: Env);但真正决定重画谁的逻辑不在 trait 里而在WidgetPod容器持有的包裹子控件的壳。看 druid/src/core.rs 的WidgetPod::update1136-1154 行它先做早退pub fn update(mut self, ctx: mut UpdateCtx, data: T, env: Env) { if !self.state.request_update { match (self.old_data.as_ref(), self.env.as_ref()) { (Some(d), Some(e)) if d.same(data) e.same(env) { trace!(data and env are unchanged, returning early.); return; // 数据没变、环境没变 → 这一支子树整个跳过 } // … } } // …后面才真正调 self.inner.update(...) }这就是same存在的全部理由没有它每次任何数据改动都要遍历整棵树。再看LensWrapdruid/src/widget/lens_wrap.rs 94-106 行它在 update 时用 lens 取出旧值和新值各聚焦一次只有当这片数据真的变了才往下传lens.with(old_data, |old_data| { lens.with(data, |data| { if ctx.has_requested_update() || !old_data.same(data) || ctx.env_changed() { child.update(ctx, old_data, data, env); } else { trace!(skipping child update); } }) })直白翻译一个LensWrap就是一道数据隔离带——跟这片 lens 无关的改动根本传不进这棵子树。性能与复用性都从这一道隔离带来。第 4 步布局收约束绘制走 Piet布局协议强烈借鉴 Flutter 的 box layout model。每个控件的layout会收到一个 druid/src/box_constraints.rs 的约束本质就是两个字段pub struct BoxConstraints { min: Size, max: Size, } // 上界设为无穷任意非负尺寸都能满足 pub const UNBOUNDED: BoxConstraints BoxConstraints { min: Size::ZERO, max: Size::new(f64::INFINITY, f64::INFINITY), };配套还有tight只有唯一尺寸能满足、loosen最小值归零、constrain把尺寸夹进区间。约束总是朝远离零的方向取整到整数好让布局像素对齐。容器的典型套路是递归给每个子控件算好约束、layout完再set_origin、最后返回自身尺寸——子控件能先算非 flex 的再算 flex 的顺序由容器自定。绘制则交给底层Piet2D 图形抽象druid/src/lib.rs 里pub use druid_shell::{kurbo, piet};直接 re-export。paint的ctx可解引用到RenderContext能fill/stroke/画文字/施加变换与蒙版。各平台各自落地macOS 走piet-coregraphicsLinux/OpenBSD/FreeBSD 走piet-cairoWindows 走piet-direct2dWeb 走piet-web。把四步连起来一次完整的帧是event → update → layout → paintdruid/examples/custom_widget.rs 63-65 行的注释原话前一步的产物可以影响下一步。从组合控件到自定义绘制组合用 WidgetExt 把现成控件串起来不用手写impl Widget绝大多数新控件其实是组合。druid/src/widget/widget_ext.rs 的WidgetExt给每个控件挂了一串链式方法padding、center、fix_width、expand、background、scroll、lens、with_id、disabled_if… 组合出一个可滚动的按钮列表README 原例括号有笔误这里补全fn build_widget() - impl Widgetu32 { let mut col Flex::column(); for i in 0..30 { let button Button::new(format!(Button {}, i)).padding(5.0); col.add_child(button); } Scroll::new(col) }聚焦子结构LensWrap 与 lens! 宏当一个子树只需要数据的一片用Lens把它聚焦下来。druid/src/lib.rs 的文档示例33-81 行展示了完整闭环——derive 出的 lens 以与字段同名的关联常量暴露#[derive(Clone, Data, Lens)] struct HelloState { name: String } fn build_root_widget() - impl WidgetHelloState { let label Label::new(|data: HelloState, _env: Env| format!(Hello {}!, data.name)); // TextBox 本只管 String.lens(HelloState::name) 让它看到整个 HelloState 的 name 字段 let textbox TextBox::new() .with_placeholder(Who are we greeting?) .fix_width(200.0) .lens(HelloState::name); Flex::column().with_child(label).with_spacer(20.0).with_child(textbox) .expand() }.lens(...)内部就是 widget_ext.rs 204-206 行那个fn lens(...) - LensWrap...把WidgetString变成WidgetHelloState。类型来自别的 crate 时改用lens!(TheirType, field)现造。从零写一个绘制控件真要自绘就实现Widget。druid/examples/custom_widget.rs 的CustomWidget是最小范本——layout里决定尺寸、paint里用 Piet 画impl WidgetString for CustomWidget { fn layout(mut self, _ctx: mut LayoutCtx, bc: BoxConstraints, _d: String, _e: Env) - Size { // 有界就占满否则回落到 100x100 并夹回约束内 if bc.is_width_bounded() bc.is_height_bounded() { bc.max() } else { bc.constrain(Size::new(100.0, 100.0)) } } fn paint(mut self, ctx: mut PaintCtx, data: String, _env: Env) { let size ctx.size(); ctx.fill(size.to_rect(), Color::WHITE); // 铺满背景 let mut path BezPath::new(); path.move_to(Point::ORIGIN); path.quad_to((40.0, 50.0), (size.width, size.height)); ctx.stroke(path, Color::rgb8(0, 128, 0), 5.0); let layout TextLayout::String::from_text(data); layout.set_font(FontDescriptor::new(FontFamily::SERIF).with_size(24.0)); layout.rebuild_if_needed(ctx.text(), _env); layout.draw(ctx, (80.0, 40.0)); } }几个容易忽略的点paint里画文字最好把TextLayout存进控件复用而不是每帧重算ctx.with_save可以临时旋转/裁剪画布并在闭包结束后恢复paint_with_z_index能控制叠加次序。文件顶部那行#![windows_subsystem windows]不是摆设——见下一节。平台后端与 feature 开关的坑六套后端默认只走 GTKdruid-shell/src/backend/ 下摊开着六套实现gtk、mac、windows、x11、wayland、web。但 druid/Cargo.toml 的[features]里default [gtk]——默认 Linux 编译就是 GTK 后端。X11 与 Wayland 都是非默认选项而且源码在 Cargo.toml 第 26 行给wayland标了注释not ready for the prime time. Many things dont work yet.。X11 后端 README 也说它目前缺不少功能只是可以--featuresx11尝鲜。druid-shell 本身是为 Druid 写的但刻意做得够通用——druid-shell/examples/shello.rs 就是一个不依赖 druid 的独立外壳示例。feature 矩阵按功能裁剪依赖从 druid/src/lib.rs 的 Optional Features 文档和 druid/Cargo.toml 的[features]段可以把开关理成三类类别feature作用数据imim不可变集合并为它们补上Data实现数据serde部分 kurbo 几何类型的 Serde 支持数据chrono时间类型NaiveDate等的Data实现图形svgusvg/resvg/tiny-skia的矢量图支持图形image 各格式imagecrate 位图png/jpeg/gif/bmp/ico/tiff/webp/pnm/dds/tga/hdrimage-all全开后端x11/wayland实验性替代 GTK 的 Linux 后端集成raw-win-handle给WindowHandle实现HasRawWindowHandle一个反直觉的细节Cargo.toml 33-34 行注释不支持 AVIF因为 Druid 只用Image的解码能力而那条路不解 AVIF。按需开 feature 能显著压依赖树例如只要图就写features [im, svg, image]。平台差异与两个高频坑⚠️Windows 控制台窗口默认 Windows 会给应用额外开一个控制台。不想要就在 crate 顶部加#![windows_subsystem windows]druid/src/lib.rs 118-122 行的 Note for Windows apps 专门提醒custom_widget.rs第 8 行就是这么干的。⚠️集合没Data模型里塞了VecT/HashMap直接#[derive(Data)]会编译不过。要么包Arc要么开im。这套框架到底该不该用给个明确立场而不是和稀泥新生产项目官方明确不推荐 Druid请直接上Xilem——它被视作 Druid 的继任者性能与适用面都有根本性提升又继承了大量 Druid 设计。除非你有非常具体的理由锁定 Druid 版本否则别在新代码里引入它。研究/教学Druid 是极佳材料。约 40 个 examplescalc、flex、custom_widget、game_of_life、widget_gallery…、六套 shell 后端、druid-derive的 derive 与 UI 测试、druid/src/tests/ 里的布局与失效测试构成一套结构清晰、测试充分的>git clone https://gitcode.com/gh_mirrors/drui/druid【免费下载链接】druidA>项目地址: https://gitcode.com/gh_mirrors/drui/druid创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?