一、本课目标
把一个"胖"状态拆成多个"瘦"子状态,让每个子组件只关注自己需要的那部分数据。掌握 Xilem 的 lens 机制------它是怎么做到"从父状态中切出一块给子组件用,子组件的修改又能自动写回父状态"的。
二、问题场景:为什么需要 Lens?
回顾第16课的计数器,所有逻辑都写在一个 app_logic 里,状态也只有一个 Counter。这在小程序里没问题,但当你开始构建真实应用时,状态会迅速膨胀:
rust
struct AppState {
count: i32,
title: String,
settings: Settings,
todos: Vec<TodoItem>,
// ... 还可能继续增长
}
如果所有子组件都接收 &mut AppState,会产生两个问题:
耦合问题------一个只负责显示标题的组件,却能访问和修改 count、todos、settings 等完全无关的字段。组件的职责边界被打破了。
复用问题------你想把"计数器"这个组件抽出来,放到另一个项目里用。但它绑死了 AppState 类型,离开这个类型就没法编译。你没法把一个"只需要 i32 就能工作"的计数器,复用到另一个"只需要 u64 就能工作"的场景里。
Lens 就是为了解决这两个问题而存在的。
三、Lens 的核心思想
Lens 这个词来自光学------"透镜"。它的作用就像一副放大镜:你透过它看一个复杂的整体,但只聚焦在其中的一个局部。
在 Xilem 中,lens 是一个 View 适配器,它做两件事:
- 读取:从父状态中提取出一个子字段(&Parent → &Child)
- 写回:把子组件对子字段的修改,映射回父状态(&mut Child 的修改 → 反映到 &mut Parent)
用一句话概括:lens 把一个"操作大状态的 View"变成一个"操作小状态的 View"。
四、完整示例:计数器 + 标题编辑器
4.1 Cargo.toml
toml
[package]
name = "xilem-lens-demo"
version = "0.1.0"
edition = "2024"
[dependencies]
xilem = "0.4"
winit = "0.30"
4.2 main.rs
rust
use winit::error::EventLoopError;
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};
// ---- 父状态 ----
#[derive(Default)]
struct AppState {
count: i32,
title: String,
}
// ---- 子组件 1:计数器(只操作 i32)----
fn counter_view(data: &mut i32) -> impl WidgetView<i32> + use<> {
flex(Axis::Horizontal, (
label(format!("计数: {}", data)),
text_button("+1", |data: &mut i32| *data += 1),
text_button("-1", |data: &mut i32| *data -= 1),
))
}
// ---- 子组件 2:标题编辑器(只操作 String)----
fn title_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("标题: {}", data)),
text_button("改名", |data: &mut String| {
data.push_str(" ✨");
}),
text_button("清空", |data: &mut String| {
data.clear();
}),
))
}
// ---- 根视图:用 lens 把父状态切给子组件 ----
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// lens 第一参数:子 View(操作 i32 的 counter_view)
// lens 第二参数:闭包,从 &mut AppState 中取出 &mut i32
lens(
|data: &mut i32| counter_view(data),
|data: &mut AppState| &mut data.count,
),
// lens 第一参数:子 View(操作 String 的 title_view)
// lens 第二参数:闭包,从 &mut AppState 中取出 &mut String
lens(
|data: &mut String| title_view(data),
|data: &mut AppState| &mut data.title,
),
// 根视图自己的控件:同时访问两个字段
text_button("全部重置", |data: &mut AppState| {
data.count = 0;
data.title.clear();
}),
))
}
fn main() -> Result<(), EventLoopError> {
let app = Xilem::new_simple(
AppState {
count: 0,
title: "我的应用".to_string(),
},
app_logic,
WindowOptions::new("Lens Demo"),
);
app.run_in(EventLoop::with_user_event())?;
Ok(())
}
五、逐行拆解
5.1 状态定义
rust
#[derive(Default)]
struct AppState {
count: i32,
title: String,
}
父状态有两个字段:count(i32)和 title(String)。这是整个应用的唯一状态源。
5.2 子组件:counter_view
rust
fn counter_view(data: &mut i32) -> impl WidgetView<i32> + use<> {
flex(Axis::Horizontal, (
label(format!("计数: {}", data)),
text_button("+1", |data: &mut i32| *data += 1),
text_button("-1", |data: &mut i32| *data -= 1),
))
}
注意这个函数的签名:参数是 &mut i32,不是 &mut AppState。它完全不知道 AppState 的存在------它只知道"我手里有一个 i32,我可以读它、改它"。
这就是解耦:counter_view 不依赖任何具体的父状态类型。你可以把它放到任何一个需要计数器功能的应用里,只要那个应用能通过 lens 提供一个 &mut i32 就行。
5.3 子组件:title_view
rust
fn title_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("标题: {}", data)),
text_button("改名", |data: &mut String| {
data.push_str(" ✨");
}),
text_button("清空", |data: &mut String| {
data.clear();
}),
))
}
同理,title_view 只认识 String,不知道 AppState。
5.4 根视图:用 lens 连接
rust
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
lens(
|data: &mut i32| counter_view(data),
|data: &mut AppState| &mut data.count,
),
lens(
|data: &mut String| title_view(data),
|data: &mut AppState| &mut data.title,
),
text_button("全部重置", |data: &mut AppState| {
data.count = 0;
data.title.clear();
}),
))
}
这里是关键。lens 接收两个参数:
第一个参数:子 View 函数。它接收子状态类型(&mut i32 或 &mut String),返回一个 WidgetView<子状态类型>。
第二个参数:lens 闭包。它接收 &mut AppState(父状态的可变引用),返回 &mut 子状态类型(子字段的可变引用)。这个闭包就是"透镜"------它告诉框架"从父状态中取出哪个字段"。
当用户点击"+1"按钮时,数据流是这样的:
- counter_view 内部的闭包 |data: &mut i32| *data += 1 修改了 &mut i32
- 这个 &mut i32 实际上是通过 lens 闭包 |data: &mut AppState| &mut data.count 从 AppState 中取出来的
- 所以修改 &mut i32 等价于修改 AppState.count
- 框架检测到 AppState 被修改 → 重新调用 app_logic → 界面更新
六、数据流对比
不用 lens(第16课的方式)
用户点击 → 闭包直接修改 AppState.count → 框架检测 AppState 变化 → 重新执行 app_logic → 整个 View Tree 重建
所有子组件都直接操作 AppState,每个子组件都能访问所有字段。
用 lens(本课的方式)
用户点击 → 闭包修改 &mut i32 → lens 闭包把修改映射回 AppState.count → 框架检测 AppState 变化 → 重新执行 app_logic → 整个 View Tree 重建
子组件只操作自己类型的子状态,lens 负责在子状态和父状态之间做双向映射。
注意:在 Xilem 的当前实现中,app_logic 仍然是整体重新执行的。lens 的核心价值不在于"减少重建次数",而在于代码组织------让子组件的类型签名更精确、职责更单一、可复用性更强。
七、运行
shell
cargo run
弹出一个窗口,上方是计数器(显示数值 + "+1" "-1" 按钮),中间是标题编辑器(显示标题 + "改名" "清空" 按钮),底部是"全部重置"按钮。
点击"+1",计数加一,标题不受影响。点击"改名",标题追加 "✨",计数不受影响。点击"全部重置",两者同时归零。
八、lens 的底层原理
lens 在 xilem_core 中对应的 View 类型是 Lens:
rust
pub struct Lens<C, F> {
child: C,
lens: F,
}
- C 是子 View 的类型
- F 是 lens 闭包的类型
当框架调用 build 或 rebuild 时,Lens View 会:
- 用 lens 闭包从父状态中取出子字段的 &mut 引用
- 把这个 &mut 引用传给子 View 的 build/rebuild
- 子 View 对 &mut 的任何修改,都会直接反映到父状态的对应字段上(因为它们指向同一块内存)
这就是 Rust 引用的力量------不需要任何额外的"写回"逻辑,&mut 本身就是双向的。
九、lens vs 直接传父状态
直接传 &mut AppState 的特点是:子组件能访问所有字段,绑定 AppState 类型,类型签名为 fn(&mut AppState) -> impl WidgetView,职责边界模糊,但代码量少(不需要 lens 调用)。
用 lens 传 &mut i32 的特点是:子组件只能访问一个字段,可在任何提供 i32 的场景复用,类型签名为 fn(&mut i32) -> impl WidgetView,职责边界清晰,但代码量稍多(需要 lens 包装)。
小项目中直接传父状态完全没问题。当项目变大、子组件变多时,lens 的价值才真正体现出来。
十、练习
练习一:填空题------补全 lens 调用
以下代码中有一个带颜色和形状两个字段的应用状态,以及两个子组件。请补全 app_logic 中的 lens 调用。
rust
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{WidgetView};
#[derive(Default)]
struct AppState {
color: String,
shape: String,
}
fn color_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("颜色: {}", data)),
text_button("变红", |data: &mut String| *data = "红色".to_string()),
))
}
fn shape_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("形状: {}", data)),
text_button("变圆", |data: &mut String| *data = "圆形".to_string()),
))
}
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// 填空①:用 lens 把 color_view 连接到 data.color
lens(______, ______),
// 填空②:用 lens 把 shape_view 连接到 data.shape
lens(______, ______),
))
}
要求:
- 填空①:写出 lens 的两个参数(子 View 闭包 + lens 闭包)
- 填空②:同上
练习二:改造题------三色灯控制器
在练习一的基础上,增加一个 brightness 字段(u8 类型,0-255),并新增一个 brightness_view 子组件:
要求:
- AppState 增加 brightness: u8 字段,默认值为 128
- 新增 brightness_view(data: &mut u8) 子组件,显示当前亮度值,提供 "+10" 和 "-10" 按钮(注意边界:不超过 255,不低于 0)
- 在 app_logic 中用 lens 把 brightness_view 连接到 data.brightness
- 增加一个"恢复默认"按钮,把三个字段都恢复为默认值
rust
#[derive(Default)]
struct AppState {
color: String,
shape: String,
brightness: u8,
}
// 你的代码:实现 brightness_view 和 app_logic
练习三:思考题------lens 闭包能不能返回计算值?
假设你写了这样的代码:
rust
lens(
|data: &mut i32| counter_view(data),
|data: &mut AppState| {
let mut temp = data.count * 2;
&mut temp
},
)
问题:
- 这段代码能编译吗?
- 如果不能,编译器会报什么错?
- 为什么 lens 闭包必须返回父状态中某个字段的引用,而不能返回一个临时变量的引用?
练习四:改造题------嵌套状态 + 多层 lens
要求:
- 定义嵌套状态结构:
rust
struct Settings {
theme: String,
font_size: u32,
}
struct AppState {
count: i32,
settings: Settings,
}
- 实现 Default,theme 默认为 "light",font_size 默认为 14
- 写一个 theme_view(data: &mut String) 子组件,显示当前主题,提供"切换"按钮(在 "light" 和 "dark" 之间切换)
- 写一个 font_size_view(data: &mut u32) 子组件,显示当前字号,提供"+"和"-"按钮(每次加减 2,最小为 8)
- 在 app_logic 中,用两次嵌套 lens 把 theme_view 和 font_size_view 连接到 data.settings.theme 和 data.settings.font_size
提示:第一层 lens 从 AppState 取出 &mut Settings,第二层 lens 从 Settings 取出 &mut String 或 &mut u32。
// 你的代码:实现 Default、theme_view、font_size_view、app_logic、main
练习答案与知识要点
练习答案
练习一答案
rust
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// 填空①
lens(
|data: &mut String| color_view(data),
|data: &mut AppState| &mut data.color,
),
// 填空②
lens(
|data: &mut String| shape_view(data),
|data: &mut AppState| &mut data.shape,
),
))
}
解读:lens 的两个参数顺序是固定的:第一个是子 View 函数(接收子状态类型,返回 WidgetView<子状态类型>),第二个是 lens 闭包(接收 &mut 父状态,返回 &mut 子字段)。两个 lens 调用的结构完全对称,只是字段名不同。
练习二答案
rust
use winit::error::EventLoopError;
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};
#[derive(Default)]
struct AppState {
color: String,
shape: String,
brightness: u8,
}
impl Default for AppState {
fn default() -> Self {
AppState {
color: "白色".to_string(),
shape: "方形".to_string(),
brightness: 128,
}
}
}
fn color_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("颜色: {}", data)),
text_button("变红", |data: &mut String| *data = "红色".to_string()),
))
}
fn shape_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("形状: {}", data)),
text_button("变圆", |data: &mut String| *data = "圆形".to_string()),
))
}
fn brightness_view(data: &mut u8) -> impl WidgetView<u8> + use<> {
flex(Axis::Horizontal, (
label(format!("亮度: {}", data)),
text_button("+10", |data: &mut u8| {
*data = data.saturating_add(10);
}),
text_button("-10", |data: &mut u8| {
*data = data.saturating_sub(10);
}),
))
}
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
lens(
|data: &mut String| color_view(data),
|data: &mut AppState| &mut data.color,
),
lens(
|data: &mut String| shape_view(data),
|data: &mut AppState| &mut data.shape,
),
lens(
|data: &mut u8| brightness_view(data),
|data: &mut AppState| &mut data.brightness,
),
text_button("恢复默认", |data: &mut AppState| {
*data = AppState::default();
}),
))
}
fn main() -> Result<(), EventLoopError> {
let app = Xilem::new_simple(
AppState::default(),
app_logic,
WindowOptions::new("三色灯"),
);
app.run_in(EventLoop::with_user_event())?;
Ok(())
}
解读:这道题考察的是"为新的基本类型字段添加 lens"。brightness_view 的参数类型是 &mut u8,和之前的 &mut i32、&mut String 是同一个模式------子组件只认识自己的基本类型,不关心父状态长什么样。saturating_add 和 saturating_sub 是 Rust 标准库提供的方法,它们在做加法/减法时自动处理溢出:超过 255 就停在 255,低于 0 就停在 0,不会 panic。这比手动写 if *data > 245 更简洁也更安全。
练习三答案
不能编译。
编译器会报错,大意是:temp 是一个局部变量,函数返回后它就会被销毁,你不能返回一个指向局部变量的引用("returns a reference to data owned by the current function")。
原因:lens 闭包返回的 &mut 引用,必须指向父状态中某个实际存在的字段。因为框架需要在子组件的整个生命周期内持有这个引用,而局部变量在闭包返回时就被销毁了,引用会变成悬垂引用。
更深层的原因是:lens 的双向映射依赖"子引用和父字段指向同一块内存"这个事实。如果你返回一个临时变量的引用,子组件修改的是临时变量,而不是父状态的字段------修改就丢失了,"写回"机制就断了。
lens 闭包的第二个参数必须是一个字段访问表达式(如 &mut data.count),不能是任何计算结果或临时值。
练习四答案
rust
use winit::error::EventLoopError;
use xilem::view::{Axis, flex, label, text_button, lens};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};
struct Settings {
theme: String,
font_size: u32,
}
impl Default for Settings {
fn default() -> Self {
Settings {
theme: "light".to_string(),
font_size: 14,
}
}
}
struct AppState {
count: i32,
settings: Settings,
}
impl Default for AppState {
fn default() -> Self {
AppState {
count: 0,
settings: Settings::default(),
}
}
}
fn counter_view(data: &mut i32) -> impl WidgetView<i32> + use<> {
flex(Axis::Horizontal, (
label(format!("计数: {}", data)),
text_button("+1", |data: &mut i32| *data += 1),
))
}
fn theme_view(data: &mut String) -> impl WidgetView<String> + use<> {
flex(Axis::Horizontal, (
label(format!("主题: {}", data)),
text_button("切换", |data: &mut String| {
if *data == "light" {
*data = "dark".to_string();
} else {
*data = "light".to_string();
}
}),
))
}
fn font_size_view(data: &mut u32) -> impl WidgetView<u32> + use<> {
flex(Axis::Horizontal, (
label(format!("字号: {}", data)),
text_button("+", |data: &mut u32| *data += 2),
text_button("-", |data: &mut u32| {
if *data > 8 {
*data -= 2;
}
}),
))
}
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// 第一层:直接从 AppState 取 count
lens(
|data: &mut i32| counter_view(data),
|data: &mut AppState| &mut data.count,
),
// 第二层:嵌套 lens
// 外层 lens:从 AppState 取出 &mut Settings
// 内层 lens:从 Settings 取出 &mut String(theme)
lens(
|settings: &mut Settings| {
lens(
|data: &mut String| theme_view(data),
|settings: &mut Settings| &mut settings.theme,
)
},
|data: &mut AppState| &mut data.settings,
),
// 第三层:嵌套 lens
// 外层 lens:从 AppState 取出 &mut Settings
// 内层 lens:从 Settings 取出 &mut u32(font_size)
lens(
|settings: &mut Settings| {
lens(
|data: &mut u32| font_size_view(data),
|settings: &mut Settings| &mut settings.font_size,
)
},
|data: &mut AppState| &mut data.settings,
),
))
}
fn main() -> Result<(), EventLoopError> {
let app = Xilem::new_simple(
AppState::default(),
app_logic,
WindowOptions::new("Nested Lens"),
);
app.run_in(EventLoop::with_user_event())?;
Ok(())
}
解读:这道题考察的是"嵌套状态的多次 lens"。核心技巧是:外层 lens 从 AppState 取出 &mut Settings,内层 lens 从 Settings 取出具体字段。
注意两个嵌套 lens 共享同一个外层 lens 闭包 |data: &mut AppState| &mut data.settings,但内层分别取不同的字段(theme 和 font_size)。这是合法的,因为两个 lens 在不同时刻被框架调用,不会同时持有对 data.settings 的可变借用。
嵌套 lens 的读法是从外到内:先读外层 lens 的第二个参数("从谁取"),再读内层 lens 的第二个参数("取哪个字段")。写数据时方向相反:内层闭包修改子字段 → 外层闭包把修改映射回父状态。
知识要点
要点一:lens 的本质是"可变引用的传递链"
lens 不是什么魔法------它只是把一个 &mut Parent 通过闭包转换成 &mut Child,然后把 &mut Child 交给子组件。子组件对 &mut Child 的修改,因为 Rust 引用的语义,会直接反映到 Parent 的对应字段上。没有拷贝,没有回调,没有事件总线------就是普通的可变引用。
要点二:lens 闭包必须返回字段的引用,不能返回临时值
lens 闭包的第二个参数必须形如 |data: &mut Parent| &mut data.field。它返回的引用必须指向父状态中实际存在的字段,不能是局部变量、计算结果或 clone 出来的值。原因有二:一是生命周期------临时值在闭包返回后就被销毁,引用变成悬垂引用;二是语义------修改临时值不会反映到父状态,"写回"机制就断了。
要点三:子组件的类型签名决定可复用性
fn counter_view(data: &mut i32) -> impl WidgetView 这个签名意味着:这个组件可以在任何能提供 &mut i32 的场景中复用。它不依赖 AppState,不依赖任何具体的父状态类型。如果你把它放到另一个项目中,只要那个项目能通过 lens 提供一个 &mut i32,这个组件就能直接用------一行代码都不用改。
这就是 lens 的核心价值:让组件的类型签名尽可能精确,只声明自己真正需要的数据。
要点四:嵌套 lens 的读法------从外到内
当状态有多层嵌套时(如 AppState.settings.theme),需要多次 lens。嵌套 lens 的读法是:外层 lens 负责"从父取子",内层 lens 负责"从子取孙"。每一层只做一次字段访问,层层递进。
要点五:lens 解决的是代码组织问题,不是性能问题
在 Xilem 当前的实现中,即使用了 lens,app_logic 仍然是整体重新执行的。lens 不会让框架"只重建变化的子树"------它解决的是代码层面的耦合和复用问题,不是运行时的性能问题。如果你需要性能优化,应该用 memoize(后续课程会讲)。
下一课可以讲 map_state------它和 lens 类似但更灵活,允许你在映射时做计算和转换,而不仅仅是字段访问。要继续吗?