前言
在前两篇文章中,我们完成了 MVVM 架构的 Model 层(HTTP 请求)和 ViewModel 层(ChangeNotifier 状态管理)。但界面仍然是一个"Loading..."的占位页面——数据虽然拿到了,却没有展示出来。
今天这篇文章基于官方教程的「Use ListenableBuilder to update app UI」章节,我们将实现 MVVM 的最后一层——View 层。通过 ListenableBuilder,UI 会自动监听 ViewModel 的变化,在数据更新时自动重绘。
完成这一课后,维基百科阅读器就能完整运行了!
一、ListenableBuilder 是什么?
还记得上一课的 ChangeNotifier 吗?ViewModel 调用 notifyListeners() 时会广播"数据变了"。但谁在"收听"这个广播呢?答案就是 ListenableBuilder。
ListenableBuilder 是一个 Widget,它能自动监听一个 ChangeNotifier(或任何 Listenable)。当被监听的对象调用 notifyListeners() 时,ListenableBuilder 会自动重新执行它的 builder 函数,重绘 UI。
整个链条串起来就是:
用户点击"下一篇"
→ ViewModel 调用 model.getRandomArticleSummary()
→ 数据返回,ViewModel 调用 notifyListeners()
→ ListenableBuilder 收到通知
→ 自动重新执行 builder 函数
→ UI 展示新文章
二、创建 ArticleView
2.1 基本结构
ArticleView 是整个页面的容器,它持有 ViewModel 并用 ListenableBuilder 监听变化:
// ArticleView:页面级组件(MVVM 的 View 层入口)
// 职责:创建 ViewModel,用 ListenableBuilder 监听状态变化
class ArticleView extends StatelessWidget {
ArticleView({super.key});
// 创建 ViewModel,传入 Model
// ViewModel 在构造时会自动发起第一次数据请求
final ArticleViewModel viewModel = ArticleViewModel(ArticleModel());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Wikipedia Flutter'),
),
// ListenableBuilder 监听 viewModel
// 当 viewModel 调用 notifyListeners() 时,builder 自动重新执行
body: ListenableBuilder(
// listenable:要监听的对象(必须是 ChangeNotifier 或 Listenable)
listenable: viewModel,
// builder:每次 notifyListeners() 被调用时,这个函数会重新执行
// 它根据 viewModel 的当前状态返回对应的 Widget
builder: (context, child) {
// 下一步:根据状态返回不同的界面
return const Center(child: Text('UI will update here'));
},
),
);
}
}
2.2 用 switch 表达式处理所有状态
ViewModel 有三个状态属性:loading、summary、errorMessage。我们用 Dart 的 switch 表达式根据它们的组合决定显示什么:
builder: (context, child) {
// 将三个状态属性组合成一个元组(tuple),用 switch 匹配
// 这样可以覆盖所有可能的状态组合,不会遗漏
return switch ((
viewModel.loading, // bool
viewModel.summary, // Summary?
viewModel.errorMessage, // String?
)) {
// 模式 1:正在加载 → 显示转圈圈
// loading=true 时,忽略其他两个属性(用 _ 通配符)
(true, _, _) => const Center(
child: CircularProgressIndicator(),
),
// 模式 2:加载完成,有错误信息 → 显示错误提示
// loading=false,errorMessage 是非空 String
(false, _, String message) => Center(
child: Text(message),
),
// 模式 3:加载完成,无数据无错误 → 未知错误
// 理论上不应该出现,但作为兜底处理
(false, null, null) => const Center(
child: Text('An unknown error has occurred'),
),
// 模式 4:加载完成,有文章数据 → 显示文章内容
// summary 是非空的 Summary 对象
(false, Summary summary, null) => ArticlePage(
summary: summary,
onPressed: viewModel.getRandomArticleSummary,
),
};
},
这就是声明式 UI 的精髓——你不需要手动判断"什么时候该隐藏加载圈、什么时候该显示内容"。你只需要描述"在每种状态下界面长什么样",Flutter 会自动处理切换。
三、创建 ArticlePage
ArticlePage 包含文章内容和一个"加载下一篇"的按钮:
// ArticlePage:文章页面
// 接收 Summary 数据和一个回调函数
// 职责:展示文章内容 + 提供"下一篇"按钮
class ArticlePage extends StatelessWidget {
const ArticlePage({
super.key,
required this.summary,
required this.onPressed,
});
final Summary summary; // 文章摘要数据
final VoidCallback onPressed; // 点击"下一篇"时的回调
@override
Widget build(BuildContext context) {
// SingleChildScrollView 让内容可以滚动
// 当文章较长时不会溢出屏幕
return SingleChildScrollView(
child: Column(
children: [
// 文章内容组件
ArticleWidget(summary: summary),
// "下一篇"按钮
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton(
// 点击时调用 viewModel.getRandomArticleSummary
// 触发新一轮:请求数据 → 更新状态 → 通知 UI → 重绘
onPressed: onPressed,
child: const Text('Next random article'),
),
),
],
),
);
}
}
四、创建 ArticleWidget
ArticleWidget 负责展示文章的具体内容:图片、标题、描述、正文。
// ArticleWidget:文章内容展示组件
// 职责:将 Summary 数据渲染为图片 + 标题 + 描述 + 正文
class ArticleWidget extends StatelessWidget {
const ArticleWidget({super.key, required this.summary});
final Summary summary;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
// Column + spacing 让子组件之间有统一的 10 像素间距
child: Column(
spacing: 10.0,
children: [
// ===== 条件渲染:只在有图片时才显示 =====
// summary.hasImage 是 Summary 类中的 getter
// 检查 originalImage 或 thumbnail 是否可用
if (summary.hasImage)
Image.network(
// 从网络加载图片
// ! 是空断言操作符,因为 hasImage 已确认非空
summary.originalImage!.source,
),
// ===== 标题 =====
Text(
summary.titles.normalized,
// TextOverflow.ellipsis:文字超长时显示省略号 "..."
overflow: TextOverflow.ellipsis,
// 使用主题中的 displaySmall 样式(大号标题)
style: TextTheme.of(context).displaySmall,
),
// ===== 描述(可选)=====
// 并非所有文章都有描述,所以用 if 条件渲染
if (summary.description != null)
Text(
summary.description!,
overflow: TextOverflow.ellipsis,
// bodySmall 样式(小号正文,适合副标题/描述)
style: TextTheme.of(context).bodySmall,
),
// ===== 正文摘要 =====
Text(
summary.extract, // 文章的前几句话(纯文本)
),
],
),
);
}
}
几个值得关注的 UI 技巧:
- 条件渲染:
if (condition) Widget()在 Flutter 的children列表中直接使用,只在条件为真时添加该组件 - 文字溢出处理:
TextOverflow.ellipsis防止超长标题撑破布局 - 主题字体:用
TextTheme.of(context)获取统一的字体样式,保持视觉层次
五、更新 MainApp
最后,把 MainApp 中的占位页面替换为 ArticleView:
class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// 将占位的 Scaffold 替换为完整的 ArticleView
// ArticleView 内部已经包含了 Scaffold、AppBar 等
home: ArticleView(),
);
}
}
六、运行效果
热重载后,你会看到完整的交互流程:
- 应用启动 → 显示加载转圈圈(loading = true)
- 数据返回 → 显示文章标题、描述、图片和正文(summary 有值)
- 点击"Next random article" → 转圈圈 → 新文章出现
- 如果网络出错 → 显示错误信息(errorMessage 有值)
整个过程你不需要写任何"切换界面"的命令式代码——ListenableBuilder 自动根据 ViewModel 的状态决定显示什么。
七、MVVM 完整回顾
至此,三层架构全部完成:
| 层级 | 类名 | 职责 | 课程 |
|---|---|---|---|
| Model | ArticleModel | 发起 HTTP 请求,解析 JSON | 第 10 课 |
| ViewModel | ArticleViewModel | 管理状态(loading/summary/error),调用 notifyListeners | 第 11 课 |
| View | ArticleView + ArticlePage + ArticleWidget | 用 ListenableBuilder 监听变化,展示 UI | 第 12 课 |
数据流动的完整链路:
API → ArticleModel.getRandomArticleSummary()
→ JSON → Summary 对象
→ ArticleViewModel.summary = ...
→ notifyListeners()
→ ListenableBuilder 重新执行 builder
→ switch 匹配状态
→ 显示 ArticlePage + ArticleWidget
八、本节知识点小结
ListenableBuilder: 监听 ChangeNotifier 的 Widget。当被监听对象调用 notifyListeners() 时,自动重新执行 builder 函数重绘 UI。是连接 ViewModel 和 View 的关键组件。
switch 表达式处理状态: 将多个状态属性组合为元组,用模式匹配覆盖所有可能的组合。确保每种状态都有对应的 UI,不会遗漏。
条件渲染: 在 Column 的 children 列表中直接使用 if (condition) Widget(),只在条件为真时添加组件。适合处理可选数据(如文章图片、描述)。
声明式 UI: 你只需描述"每种状态下界面长什么样",Flutter 和 ListenableBuilder 自动处理状态切换和界面更新。不需要手动控制组件的显示/隐藏。
九、下一步学习
恭喜你完成了维基百科阅读器的全部功能!你已经掌握了 MVVM 架构、HTTP 请求、状态管理和响应式 UI 的核心知识。接下来的官方教程会进入 Flutter UI 102 进阶章节,学习自适应布局、高级滚动、导航等更深入的主题。
我们下篇文章见!