引言
截至2026年上半年,Xilem 仍处于 Alpha 阶段(0.4.0),API 可能随版本更新而变化。本课代码基于 0.4.x 编写,实际使用时请以最新文档为准。
一、视图修饰符(View Modifiers)
在第一课中,我们学会了用 text_button 的闭包参数处理点击事件,用 label 显示文本。但这些组件的"默认行为"往往不够------我们需要禁用按钮、给标签加点击事件、调整样式。Xilem 通过视图修饰符来解决这个问题。
1.1 什么是视图修饰符
视图修饰符是附加在 View 上的方法调用,它不改变原始 View 的类型,而是返回一个新的包装类型,在原始 View 的基础上叠加额外行为。
rust
// 一个普通的按钮
text_button("点击我", |data: &mut AppState| { /* ... */ })
// 加了 .enabled() 修饰符的按钮------被禁用时不可点击
text_button("点击我", |data: &mut AppState| { /* ... */ })
.enabled(data.is_active)
修饰符的核心思想是组合:
每个修饰符都是一个独立的"层",叠加在原始 View 之上,最终产出的仍然是一个实现了 WidgetView 的类型,可以继续嵌套、组合。
1.2 常用修饰符一览
.enabled(bool) ------ 控制可交互性
接受一个 bool 值。当值为 false 时,该 View 变为"禁用"状态------按钮不可点击、输入框不可编辑,视觉上通常变灰。
rust
text_button("提交", |data: &mut AppState| data.submit())
.enabled(!data.form.is_empty())
效果:只有当表单不为空时,"提交"按钮才可点击。enabled 的值每次 app_logic 重新执行时都会被重新求值,所以它是响应式的------状态变了,按钮的可用状态自动跟着变。
.on_click(closure) ------ 为任意 View 添加点击事件
text_button 自身已经接受一个点击回调,但 label、flex 容器等 View 默认不响应点击。.on_click() 可以让任何 View 变得可点击:
rust
label("点我试试")
.on_click(|data: &mut AppState| data.clicked = true)
也可以给一个 flex 容器整体加点击事件:
rust
flex(Axis::Horizontal, (
label("整行可点击"),
label("→"),
))
.on_click(|data: &mut AppState| data.toggle_expand())
.tooltip(text) ------ 悬浮提示
鼠标悬停时显示提示文字:
rust
text_button("?", |data: &mut AppState| data.show_help())
.tooltip("点击显示帮助信息")
链式调用
修饰符可以链式叠加,顺序不影响语义:
rust
label("操作")
.on_click(|data: &mut AppState| data.do_action())
.enabled(data.can_act)
.tooltip("执行当前操作")
1.3 修饰符的工作原理
每个修饰符方法接收 self(原始 View),返回一个新的包装类型。例如 .enabled(true) 大致等价于:
rust
Enabled<View> { inner: view, is_enabled: true }
这个 Enabled 同样实现了 WidgetView,所以可以继续调用其他修饰符,也可以放进 flex 的元组中作为子 View。这就是 Xilem 的组合式 API 设计------所有 View 都是可组合的"积木块"。
二、列表渲染(for_each / ViewSequence)
第一课中,我们把固定数量的子 View 放进元组:(label(...), text_button(...), text_button(...))。但实际应用中,列表的长度往往是动态的------待办事项有几条、购物车有几个商品,都取决于运行时数据。
2.1 for_each 基础用法
for_each 是 Xilem 提供的列表渲染函数。它接收两个参数:
- 一个迭代器(数据源)
- 一个闭包(将每条数据映射为一个 View)
rust
struct AppState {
items: Vec<String>,
}
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
label(format!("共 {} 项", data.items.len())),
for_each(data.items.iter(), |item, _index| {
label(item.clone())
}),
))
}
关键点:
- data.items.iter() 产生一个 &String 的迭代器
- 闭包的第一个参数 item 是迭代器产出的每一项(这里是 &String)
- 闭包的第二个参数 _index 是当前项的索引(usize),不需要时可以加下划线忽略
- 闭包返回的 View 类型必须一致------这里每项都是一个 label
2.2 带交互的列表项
列表项不只是展示文本,通常还需要响应点击、删除等操作:
rust
struct TodoApp {
todos: Vec<TodoItem>,
}
struct TodoItem {
text: String,
done: bool,
}
fn app_logic(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
flex(Axis::Vertical, (
label("待办事项"),
for_each(data.todos.iter().enumerate(), |(idx, todo), _key| {
flex(Axis::Horizontal, (
label(format!(
"{} {}",
if todo.done { "✅" } else { "⬜" },
todo.text
)),
text_button("删除", move |data: &mut TodoApp| {
data.todos.remove(idx);
}),
))
}),
))
}
关键点:
- 用 .enumerate() 给迭代器加上索引,这样闭包中就能拿到 idx
- 闭包参数变成 |(idx, todo), _key|------第一个参数是迭代器产出的 (usize, &TodoItem) 元组
- 删除按钮的闭包用 move 关键字捕获 idx 的副本,这样即使列表后续变化,每个按钮记住的仍然是创建时的索引
2.3 ViewSequence trait
for_each 返回的类型实现了 ViewSequence trait。ViewSequence 是 Xilem 中处理"多个 View"的通用抽象------元组 (view1, view2, view3) 实现了它,for_each(...) 也实现了它。
这意味着 for_each 的返回值可以直接放进 flex 的元组中,和其他 View 混排:
rust
flex(Axis::Vertical, (
label("标题"), // 普通 View
for_each(data.items.iter(), |item, _| { // ViewSequence
label(item.clone())
}),
text_button("添加", |data: &mut AppState| { /* ... */ }), // 普通 View
))
2.4 列表渲染的性能特征
for_each 在底层通过 ViewSequence 的 diff 机制工作。当列表数据变化时,框架会比较新旧序列的差异,只更新实际变化的部分------新增的项会被创建,删除的项会被销毁,未变化的项会被复用。
对于大规模列表(成百上千项),Xilem 提供了虚拟滚动机制,只渲染可见区域内的项目,避免一次性创建所有 Widget。
三、组件拆分
当 app_logic 函数越来越长时,把所有 View 构建逻辑塞在一个函数里会变得难以维护。Xilem 的视图函数就是普通 Rust 函数,所以拆分方式和普通代码重构完全一样------提取子函数。
3.1 基本拆分方式
拆分前------所有逻辑堆在一个函数里:
rust
fn app_logic(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
flex(Axis::Vertical, (
// 标题栏
flex(Axis::Horizontal, (
label("待办事项"),
text_button("全部清除", |data: &mut TodoApp| data.todos.clear()),
)),
// 列表
for_each(data.todos.iter().enumerate(), |(idx, todo), _| {
flex(Axis::Horizontal, (
label(format!("{} {}", if todo.done { "✅" } else { "⬜" }, todo.text)),
text_button("删除", move |data: &mut TodoApp| { data.todos.remove(idx); }),
))
}),
// 状态栏
label(format!("共 {} 项,已完成 {} 项",
data.todos.len(),
data.todos.iter().filter(|t| t.done).count()
)),
))
}
拆分后------每个区域独立为一个函数:
rust
fn app_logic(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
flex(Axis::Vertical, (
header_bar(data),
todo_list(data),
status_bar(data),
))
}
fn header_bar(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
flex(Axis::Horizontal, (
label("待办事项"),
text_button("全部清除", |data: &mut TodoApp| data.todos.clear()),
))
}
fn todo_list(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
for_each(data.todos.iter().enumerate(), |(idx, todo), _| {
flex(Axis::Horizontal, (
label(format!("{} {}", if todo.done { "✅" } else { "⬜" }, todo.text)),
text_button("删除", move |data: &mut TodoApp| { data.todos.remove(idx); }),
))
})
}
fn status_bar(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
label(format!("共 {} 项,已完成 {} 项",
data.todos.len(),
data.todos.iter().filter(|t| t.done).count()
))
}
3.2 拆分的规则
- 子函数签名和 app_logic 完全一致:fn(data: &mut State) -> impl WidgetView + use<>
- 参数仍然是 &mut State:子函数直接访问和修改同一个状态结构体,不需要额外传参
- 子函数就是普通函数:可以在里面用 if/else、let、match,做任何 Rust 允许的操作
- 子函数之间可以互相调用:就像普通函数一样
3.3 带参数的子组件
有时子组件不需要访问完整状态,只需要部分数据。这时可以传普通参数:
/// 渲染单个待办项------不需要 &mut TodoApp,只需要这一项的数据和索引
rust
fn todo_item(text: &str, done: bool, idx: usize) -> impl WidgetView<TodoApp> + use<> {
flex(Axis::Horizontal, (
label(format!("{} {}", if done { "✅" } else { "⬜" }, text)),
text_button("删除", move |data: &mut TodoApp| {
data.todos.remove(idx);
}),
))
}
fn todo_list(data: &mut TodoApp) -> impl WidgetView<TodoApp> + use<> {
for_each(data.todos.iter().enumerate(), |(idx, todo), _| {
todo_item(&todo.text, todo.done, idx)
})
}
注意:
todo_item 的返回类型仍然是 impl WidgetView------它必须能操作完整的应用状态(因为"删除"按钮需要 &mut TodoApp),但它的输入参数只需要当前项的数据。
3.4 拆分的时机
不是所有代码都需要拆。建议遵循以下原则:
- 超过 30 行的视图函数考虑拆分
- 重复出现的 UI 模式提取为子组件
- 逻辑上独立的区域(标题栏、列表、状态栏)各自独立
四、memoize 性能优化
4.1 问题场景
每次状态变化时,app_logic 会被重新执行,所有子 View 都会被重新构建。对于大多数 View(label、text_button),构建成本极低------只是创建一个轻量级的描述对象。但如果某个子 View 的构建涉及昂贵计算(比如格式化大量数据、生成复杂布局),每次都重新构建就会造成性能浪费。
rust
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// 这个列表有 1000 项,每次状态变化都重新构建所有项的 View
for_each(data.items.iter(), |item, _| {
expensive_view(item)
}),
// 其实只有这个计数器变了
label(format!("计数: {}", data.counter)),
))
}
在上面的例子中,用户点击计数器按钮时,data.counter 变了,但 data.items 完全没变。理想情况下,列表部分应该被跳过,只更新计数标签。
4.2 memoize 的用法
memoize 是 Xilem 提供的性能优化工具。它的核心思想是:缓存子 View 的构建结果,只有当相关数据真正变化时才重新构建。
rust
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// 用 memoize 包裹列表部分
memoize(
data.items.clone(), // 作为比较基准的数据
|items: &Vec<Item>| {
for_each(items.iter(), |item, _| {
expensive_view(item)
})
},
),
// 计数器变化时,上面的 memoize 不会重新构建
label(format!("计数: {}", data.counter)),
))
}
工作原理:
- memoize 接收两个参数:一个数据值和一个构建闭包
- 首次执行时,memoize 调用闭包构建 View,同时缓存传入的数据值
- 后续执行时,memoize 将新传入的数据与缓存的数据做 PartialEq 比较
- 如果数据没变→ 直接复用缓存的 View,跳过闭包调用
- 如果数据变了→ 重新调用闭包构建新 View,更新缓存
4.3 使用要求
- 传给 memoize 的数据类型必须实现 PartialEq(用于比较新旧值是否相同)
- 数据需要 Clone(memoize 内部需要持有数据的副本用于下次比较)
- 闭包必须是纯函数------相同的输入必须产生相同的输出
4.4 什么时候该用 memoize
场景 是否需要 memoize
简单的 label、text_button ❌ 构建成本极低,不需要
几十个列表项 ❌ 通常不需要
成百上千项的列表 ✅ 列表数据未变时跳过重建
包含复杂计算结果的 View ✅ 避免重复计算
嵌套很深的子树 ✅ 减少 diff 开销
经验法则:先用着,遇到性能瓶颈时再用 memoize 优化。过早优化是万恶之源。
4.5 memoize 与组件拆分的关系
memoize 和组件拆分是正交的优化手段,可以同时使用:
rust
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
// 拆分:独立的子函数
// 优化:memoize 包裹,数据不变时跳过重建
memoize(
data.items.clone(),
|items: &Vec<Item>| todo_list_section(items),
),
counter_section(data),
))
}
五、综合实战:待办事项应用
把本课学到的四个知识点------修饰符、列表渲染、组件拆分、memoize------融合到一个完整的应用中。
5.1 完整代码
rust
use xilem::view::{Axis, flex, for_each, label, text_button, memoize};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};
use winit::error::EventLoopError;
// ===== 状态定义 =====
#[derive(Clone, PartialEq)]
struct TodoItem {
text: String,
done: bool,
}
struct AppState {
todos: Vec<TodoItem>,
input: String,
next_id: usize,
}
impl Default for AppState {
fn default() -> Self {
AppState {
todos: vec![
TodoItem { text: "学习 Xilem".to_string(), done: false },
TodoItem { text: "写一个待办应用".to_string(), done: false },
],
input: String::new(),
next_id: 2,
}
}
}
// ===== 主视图 =====
fn app_logic(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
header(),
input_area(data),
// memoize:只有 todos 变化时才重建列表
memoize(
data.todos.clone(),
|todos: &Vec<TodoItem>| todo_list(todos),
),
status_bar(data),
))
}
// ===== 子组件 =====
fn header() -> impl WidgetView<AppState> + use<> {
label("📝 待办事项").enabled(true)
}
fn input_area(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
flex(Axis::Horizontal, (
// 假设有一个 text_input 组件
text_input(&data.input, |data: &mut AppState, text| {
data.input = text;
}),
text_button("添加", |data: &mut AppState| {
if !data.input.is_empty() {
data.todos.push(TodoItem {
text: data.input.clone(),
done: false,
});
data.input.clear();
}
})
.enabled(!data.input.is_empty()), // 输入为空时禁用添加按钮
))
}
fn todo_list(todos: &[TodoItem]) -> impl WidgetView<AppState> + use<> {
flex(Axis::Vertical, (
for_each(todos.iter().enumerate(), |(idx, todo), _| {
flex(Axis::Horizontal, (
label(format!(
"{} {}",
if todo.done { "✅" } else { "⬜" },
todo.text
))
// 点击文本切换完成状态
.on_click(move |data: &mut AppState| {
data.todos[idx].done = !data.todos[idx].done;
}),
text_button("删除", move |data: &mut AppState| {
data.todos.remove(idx);
}),
))
}),
))
}
fn status_bar(data: &mut AppState) -> impl WidgetView<AppState> + use<> {
let total = data.todos.len();
let done = data.todos.iter().filter(|t| t.done).count();
let tip = if total == 0 {
"暂无待办事项"
} else if done == total {
"🎉 全部完成!"
} else {
""
};
flex(Axis::Horizontal, (
label(format!("共 {} 项,已完成 {} 项", total, done)),
label(tip.to_string()),
text_button("清除已完成", |data: &mut AppState| {
data.todos.retain(|t| !t.done);
})
.enabled(done > 0), // 没有已完成项时禁用
))
}
// ===== 启动 =====
fn main() -> Result<(), EventLoopError> {
let app = Xilem::new_simple(
AppState::default(),
app_logic,
WindowOptions::new("待办事项"),
);
app.run_in(EventLoop::with_user_event())?;
Ok(())
}
5.2 代码结构说明
组件 职责 用到的知识点
app_logic 主视图,组合所有子组件 组件拆分
header 标题 修饰符 .enabled()
input_area 输入框 + 添加按钮 修饰符 .enabled()
todo_list 待办列表 for_each + .on_click()
status_bar 状态统计 + 清除按钮 条件渲染 + .enabled()
memoize 调用 包裹 todo_list memoize 性能优化
5.3 数据流回顾
用户点击"添加"按钮
→ 闭包执行:data.todos.push(...),data.input.clear()
→ 状态变化,app_logic 重新执行
→ memoize 检测到 data.todos 变了 → 重建 todo_list
→ input_area 重新执行 → 输入框清空,按钮状态更新
→ status_bar 重新执行 → 计数更新
六、练习
练习一:可禁用的加减计数器
在第一课的加减计数器基础上,添加以下功能:
- 当数字 ≥ 10 时,"+1"按钮变为禁用状态(不可再点击)
- 当数字 ≤ 0 时,"-1"按钮变为禁用状态
- 添加一个"重置"按钮,将数字归零;当数字已经是 0 时,该按钮禁用
提示:使用 .enabled() 修饰符。
练习二:可删除的标签列表
定义状态:
struct TagApp {
tags: Vec,
}
初始时有 3 个标签:"Rust"、"Xilem"、"GUI"。
要求:
- 用 for_each 渲染所有标签
- 每个标签旁边有一个"×"删除按钮
- 点击"×"后删除对应标签
- 列表为空时,显示"暂无标签"
练习三:带 memoize 的成绩单
定义状态:
struct GradeBook {
students: Vec,
selected_course: String,
}
#derive(Clone, PartialEq)
struct Student {
name: String,
grades: Vec,
}
要求:
- 将学生列表部分用 memoize 包裹(以 students 作为比较基准)
- 切换 selected_course 时,学生列表不重建(因为 students 没变)
- 添加/删除学生时,列表重建
- 每个学生显示姓名和平均分
七、本课小结
知识点 核心概念 一句话记忆
视图修饰符 .enabled()、.on_click() 等方法叠加在 View 上 修饰符是 View 的"外壳",不改变内部结构
for_each 将迭代器的每一项映射为一个 View 动态列表用 for_each,固定列表用元组
组件拆分 将大视图函数拆成多个小函数 子函数签名和 app_logic 一致
memoize 缓存子 View,数据不变时跳过重建 先不优化,遇到瓶颈再用
下一课将介绍 Xilem 的 lens 机制------如何将大状态拆分为多个独立的子状态,让每个子组件只关注自己需要的那部分数据。
贴代码
练习一答案:可禁用的加减计数器
rust
#[derive(Default)]
struct Counter {
num: i32,
}
fn app_logic(data: &mut Counter) -> impl WidgetView<Counter> + use<> {
flex(
Axis::Vertical,
(
label(format!("{}", data.num)),
flex(
Axis::Horizontal,
(
text_button("+1", |data: &mut Counter| data.num += 1)
.enabled(data.num < 10),
text_button("-1", |data: &mut Counter| data.num -= 1)
.enabled(data.num > 0),
text_button("重置", |data: &mut Counter| data.num = 0)
.enabled(data.num != 0),
),
),
),
)
}
知识点:
- .enabled() 接收一个 bool 表达式,每次 app_logic 重新执行时都会被重新求值------状态变了,按钮的可用状态自动跟着变,不需要手动"监听"。
- 三个按钮各自独立判断启用条件:< 10、> 0、!= 0,互不干扰。
- 多个修饰符可以链式调用。如果还需要加 .on_click() 或 .tooltip(),直接继续往后链即可,顺序不影响语义。
练习二答案:可删除的标签列表
rust
struct TagApp {
tags: Vec<String>,
}
impl Default for TagApp {
fn default() -> Self {
TagApp {
tags: vec![
"Rust".to_string(),
"Xilem".to_string(),
"GUI".to_string(),
],
}
}
}
fn app_logic(data: &mut TagApp) -> impl WidgetView<TagApp> + use<> {
flex(
Axis::Vertical,
(
label("标签管理"),
one_of(
!data.tags.is_empty(),
// 有标签时:渲染列表
for_each(data.tags.iter().enumerate(), |(idx, tag), _| {
flex(
Axis::Horizontal,
(
label(tag.clone()),
text_button("×", move |data: &mut TagApp| {
data.tags.remove(idx);
}),
),
)
}),
// 无标签时:显示提示
label("暂无标签"),
),
),
)
}
知识点:
- for_each 配合 .enumerate() 可以在闭包中拿到当前项的索引 idx,用于定位要删除的元素。
- 删除按钮的闭包必须用 move 关键字------idx 是 usize(Copy 类型),move 会将 idx 的副本移入闭包,这样即使列表后续变化,每个按钮记住的仍然是创建时的索引。
- 列表为空时需要显示"暂无标签",但 for_each(...) 和 label(...) 是不同的 Rust 类型,不能直接放在 if/else 的两个分支中。one_of 正是为此设计的------它接收一个 bool 条件和两个不同种类的 View,在运行时根据条件选择渲染哪一个。
练习三答案:带 memoize 的成绩单
rust
use xilem::view::{Axis, flex, for_each, label, memoize};
use xilem::{EventLoop, WidgetView, WindowOptions, Xilem};
use winit::error::EventLoopError;
// ===== 状态定义 =====
#[derive(Clone, PartialEq)]
struct Student {
name: String,
grades: Vec<i32>,
}
struct GradeBook {
students: Vec<Student>,
selected_course: String,
}
impl Default for GradeBook {
fn default() -> Self {
GradeBook {
students: vec![
Student { name: "张三".to_string(), grades: vec![85, 92, 78] },
Student { name: "李四".to_string(), grades: vec![90, 88, 95] },
Student { name: "王五".to_string(), grades: vec![70, 65, 80] },
],
selected_course: "数学".to_string(),
}
}
}
// ===== 主视图 =====
fn app_logic(data: &mut GradeBook) -> impl WidgetView<GradeBook> + use<> {
flex(
Axis::Vertical,
(
label(format!("当前课程: {}", data.selected_course)),
flex(
Axis::Horizontal,
(
text_button("数学", |data: &mut GradeBook| {
data.selected_course = "数学".to_string();
}),
text_button("语文", |data: &mut GradeBook| {
data.selected_course = "语文".to_string();
}),
text_button("英语", |data: &mut GradeBook| {
data.selected_course = "英语".to_string();
}),
),
),
// memoize:只有 students 变化时才重建学生列表
memoize(
data.students.clone(),
|students: &Vec<Student>| student_list(students),
),
),
)
}
// ===== 子组件 =====
fn student_list(students: &[Student]) -> impl WidgetView<GradeBook> + use<> {
flex(
Axis::Vertical,
(
for_each(students.iter(), |student, _| {
let avg = if student.grades.is_empty() {
0
} else {
student.grades.iter().sum::<i32>() / student.grades.len() as i32
};
flex(
Axis::Horizontal,
(
label(student.name.clone()),
label(format!("平均分: {}", avg)),
),
)
}),
),
)
}
// ===== 启动 =====
fn main() -> Result<(), EventLoopError> {
let app = Xilem::new_simple(
GradeBook::default(),
app_logic,
WindowOptions::new("成绩单"),
);
app.run_in(EventLoop::with_user_event())?;
Ok(())
}
知识点:
- memoize 的第一个参数是比较基准------data.students.clone()。每次 app_logic 重新执行时,memoize 会将新的 students 与缓存的旧值做 PartialEq 比较。
- 切换课程时,只有 data.selected_course 变了,data.students 没变 → memoize 检测到数据相同 → 跳过 student_list 的重建,直接复用缓存的 View。
- 添加/删除学生时,data.students 变了 → memoize 检测到数据不同 → 重新调用闭包构建新的学生列表。
- 使用 memoize 的数据类型必须同时实现 Clone(memoize 内部需要持有副本用于下次比较)和 PartialEq(用于比较新旧值)。Student 结构体上的 #derive(Clone, PartialEq) 就是为此准备的。
- memoize 和组件拆分是正交的优化手段------student_list 既是独立的子函数(组件拆分),又被 memoize 包裹(性能优化),两者互不冲突。
本课知识点总结
知识点 核心概念 一句话记忆
.enabled(bool) 控制 View 的可交互性,false 时禁用 每次渲染自动重新求值,状态驱动
.on_click(closure) 为任意 View 添加点击事件 label、flex 等默认不可点击的 View 都能用
for_each(iter, closure) 将迭代器的每一项映射为一个 View 动态列表用 for_each,固定列表用元组
.enumerate() + move 在列表项闭包中捕获索引 move 将 idx 的副本移入闭包,防止索引错位
one_of(cond, view_a, view_b) 条件渲染两个不同类型的 View 解决 if/else 两个分支返回不同类型的问题
组件拆分 将大视图函数拆成多个小函数 子函数签名和 app_logic 一致:fn(&mut S) -> impl WidgetView + use<>
memoize(data, closure) 缓存子 View,数据不变时跳过重建 先不优化,遇到瓶颈再用;数据需 Clone + PartialEq