Flutter ListenableBuilder让界面自动响应数据变化(十)

前言

在前两篇文章中,我们完成了 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 有三个状态属性:loadingsummaryerrorMessage。我们用 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(),
    );
  }
}

六、运行效果

热重载后,你会看到完整的交互流程:

  1. 应用启动 → 显示加载转圈圈(loading = true)
  2. 数据返回 → 显示文章标题、描述、图片和正文(summary 有值)
  3. 点击"Next random article" → 转圈圈 → 新文章出现
  4. 如果网络出错 → 显示错误信息(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 进阶章节,学习自适应布局、高级滚动、导航等更深入的主题。

我们下篇文章见!

参考资料:Flutter 官方教程 - Use ListenableBuilder to update app UI

相关推荐
yuki_uix1 小时前
深拷贝:JavaScript 引用类型的完全复制之道
前端·javascript
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript
宁雨桥2 小时前
前端设计模式面试题大全
前端·设计模式
Cg136269159742 小时前
JS函数表示
前端·html
在屏幕前出油2 小时前
02. FastAPI——路由
服务器·前端·后端·python·pycharm·fastapi
勿芮介2 小时前
【大模型应用】在window/linux上卸载OpenClaw
java·服务器·前端
摸鱼仙人~2 小时前
前端面试手写核心 Cheat Sheet(终极精简版)
前端
Ashley_Amanda3 小时前
深入浅出Web Dynpro:SAP企业级Web应用开发全面解析
前端
方安乐3 小时前
概念:前端工程化实践
前端