前言
在前两篇文章中,我们完成了 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。
整个链条串起来就是:
scss
用户点击"下一篇"
→ ViewModel 调用 model.getRandomArticleSummary()
→ 数据返回,ViewModel 调用 notifyListeners()
→ ListenableBuilder 收到通知
→ 自动重新执行 builder 函数
→ UI 展示新文章
二、创建 ArticleView
2.1 基本结构
ArticleView 是整个页面的容器,它持有 ViewModel 并用 ListenableBuilder 监听变化:
scala
// 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 表达式根据它们的组合决定显示什么:
javascript
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 包含文章内容和一个"加载下一篇"的按钮:
scala
// 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 负责展示文章的具体内容:图片、标题、描述、正文。
less
// 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:
scala
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 课 |
数据流动的完整链路:
scss
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 进阶章节,学习自适应布局、高级滚动、导航等更深入的主题。
我们下篇文章见!