跳转至正文

状态变化时重建 UI

如何使用 ChangeNotifier 管理状态的说明。

学习使用 ListenableBuilder 在状态变化时自动重建 UI,并用 switch 表达式处理所有可能的状态。

你将完成的内容

使用 ListenableBuilder 自动重建 UI
用 switch 表达式处理所有可能的状态
用合适的样式构建完整的 View 层

步骤

1

简介

View 层就是你的 UI,在 Flutter 中,这指的是你应用中的 widget。就本教程而言,重要的是将 UI 与 ViewModel 的数据变化关联起来。 ListenableBuilder 是一个可以「监听」ChangeNotifier 的 widget,当其提供的 ChangeNotifier 调用 notifyListeners() 时会自动重建。

2

创建文章视图 widget

创建 ArticleView widget,用于管理页面的布局与 ViewModel 生命周期。由于必须在渲染前显式初始化数据获取,请将其实现为 StatefulWidget

先创建基本的 stateful 结构:

dart
import 'package:flutter/material.dart';

class ArticleView extends StatefulWidget {
  const ArticleView({super.key});

  @override
  State<ArticleView> createState() => _ArticleViewState();
}

class _ArticleViewState extends State<ArticleView> {
  // The view model will be instantiated here next.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Wikipedia Flutter')),
      body: const Center(child: Text('Loading...')),
    );
  }
}
3

实例化文章 ViewModel

接下来,初始化 ArticleViewModel 并将其与 state 的生命周期绑定。在 initState() 中提供 ViewModel 并执行 fetchArticle()

dart
class ArticleView extends StatefulWidget {
  const ArticleView({super.key});

  @override
  State<ArticleView> createState() => _ArticleViewState();
}

class _ArticleViewState extends State<ArticleView> {
  final ArticleViewModel viewModel = ArticleViewModel(ArticleModel());

  @override
  void initState() {
    super.initState();
    viewModel.fetchArticle();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Wikipedia Flutter')),
      body: const Center(child: Text('Loading...')),
    );
  }
}
4

更新应用以包含文章视图

通过更新 MainApp 以包含已完成的 ArticleView,将所有部分连接起来。

用以下更新版本替换现有的 MainApp

dart
class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: ArticleView());
  }
}

这一更改从基于控制台的测试切换到具备完善状态管理的完整 UI 体验。

5

监听状态变化

ListenableBuilder 包裹 UI 以监听状态变化,并向其传入 ChangeNotifier 对象。在本例中,ArticleViewModel 继承自 ChangeNotifier

dart
class ArticleView extends StatefulWidget {
  const ArticleView({super.key});

  @override
  State<ArticleView> createState() => _ArticleViewState();
}

class _ArticleViewState extends State<ArticleView> {
  final ArticleViewModel viewModel = ArticleViewModel(ArticleModel());

  @override
  void initState() {
    super.initState();
    viewModel.fetchArticle();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Wikipedia Flutter')),
      body: ListenableBuilder(
        listenable: viewModel,
        builder: (context, child) {
          return const Center(child: Text('Loading...'));
        },
      ),
    );
  }
}

ListenableBuilder 使用 builder 模式,它需要回调而不是 child widget 来构建其下方的 widget 树。这些 widget 很灵活,因为你可以在回调中执行操作,根据状态构建不同的 widget。

6

处理 ViewModel 的可能状态

回顾 ArticleViewModel,它有三个 UI 关心的属性:

  • Summary? summary
  • bool isLoading
  • Exception? error

根据这些属性的组合状态, UI 可以显示不同的 widget。利用 Dart 对 switch 表达式 的支持,以清晰、可读的方式处理所有可能的组合:

dart
class ArticleView extends StatefulWidget {
  const ArticleView({super.key});

  @override
  State<ArticleView> createState() => _ArticleViewState();
}

class _ArticleViewState extends State<ArticleView> {
  final ArticleViewModel viewModel = ArticleViewModel(ArticleModel());

  @override
  void initState() {
    super.initState();
    viewModel.fetchArticle();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Wikipedia Flutter')),
      body: Center(
        child: ListenableBuilder(
          listenable: viewModel,
          builder: (context, _) {
            return switch ((
              viewModel.isLoading,
              viewModel.summary,
              viewModel.error,
            )) {
              (true, _, _) => const CircularProgressIndicator(),
              (_, _, final Exception e) => Text('Error: $e'),
              (_, final summary?, _) => ArticlePage(
                summary: summary,
                nextArticleCallback: viewModel.fetchArticle,
              ),
              _ => const Text('Something went wrong!'),
            };
          },
        ),
      ),
    );
  }
}

这是声明式、响应式框架(如 Flutter)与 MVVM 等模式如何协同工作的绝佳示例: UI 根据状态渲染,并在状态变化需要时更新,但它不管理任何状态,也不管理自我更新的过程。业务逻辑与渲染完全彼此分离。

7

完成 UI

剩下要做的就是用 ViewModel 提供的属性和方法来构建 UI。

现在创建 ArticlePage widget 以显示实际文章内容。这个可复用 widget 接收摘要数据和回调函数:

dart
class ArticlePage extends StatelessWidget {
  const ArticlePage({
    super.key,
    required this.summary,
    required this.nextArticleCallback,
  });

  final Summary summary;
  final VoidCallback nextArticleCallback;

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('Article content will be displayed here...'),
    );
  }
}
8

添加可滚动布局

用可滚动的 Column 布局替换占位内容:

dart
class ArticlePage extends StatelessWidget {
  const ArticlePage({
    super.key,
    required this.summary,
    required this.nextArticleCallback,
  });

  final Summary summary;
  final VoidCallback nextArticleCallback;

  @override
  Widget build(BuildContext context) {
    return const SingleChildScrollView(
      child: Column(
        children: [Text('Article content will be displayed here...')],
      ),
    );
  }
}
9

添加文章内容与按钮

用文章 widget 和导航按钮完成布局:

dart
class ArticlePage extends StatelessWidget {
  const ArticlePage({
    super.key,
    required this.summary,
    required this.nextArticleCallback,
  });

  final Summary summary;
  final VoidCallback nextArticleCallback;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        children: [
          ArticleWidget(summary: summary),
          ElevatedButton(
            onPressed: nextArticleCallback,
            child: const Text('Next random article'),
          ),
        ],
      ),
    );
  }
}
10

创建 ArticleWidget

ArticleWidget 负责以合适的样式和条件渲染显示实际文章内容。

搭建基本文章结构

#

从接受 summary 参数的 widget 开始:

dart
class ArticleWidget extends StatelessWidget {
  const ArticleWidget({super.key, required this.summary});

  final Summary summary;

  @override
  Widget build(BuildContext context) {
    return const Text('Article content will be displayed here...');
  }
}

添加内边距与 Column 布局

#

用合适的内边距和布局包裹内容:

dart
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),
      child: Column(
        spacing: 10,
        children: [const Text('Article content will be displayed here...')],
      ),
    );
  }
}

添加条件图片显示

#

添加仅在可用时显示的文章图片:

dart
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),
      child: Column(
        spacing: 10,
        children: [
          if (summary.hasImage) Image.network(summary.originalImage!.source),
          const Text('Article content will be displayed here...'),
        ],
      ),
    );
  }
}

用带样式的文本内容完成

#

用带样式的标题、描述和摘录替换占位文本:

dart
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),
      child: Column(
        spacing: 10,
        children: [
          if (summary.hasImage) Image.network(summary.originalImage!.source),
          Text(
            summary.titles.normalized,
            overflow: TextOverflow.ellipsis,
            style: Theme.of(context).textTheme.displaySmall,
          ),
          if (summary.description != null)
            Text(
              summary.description!,
              overflow: TextOverflow.ellipsis,
              style: Theme.of(context).textTheme.bodySmall,
            ),
          Text(summary.extract),
        ],
      ),
    );
  }
}

该 widget 演示了几个重要的 UI 概念:

  • 条件渲染if 语句仅在内容可用时显示。

  • 文本样式:不同文本样式借助 Flutter 的主题系统建立视觉层次。

  • 合适间距spacing 参数提供一致的垂直间距。

  • 溢出处理TextOverflow.ellipsis 防止文本破坏布局。

11

运行完整应用

最后一次热重载应用。你现在应能看到:

  1. 初始文章加载时显示的加载指示器。

  2. 文章的标题、描述和摘要摘录。

  3. 图片(若文章有图片)。

  4. 用于加载另一篇随机文章的按钮。

要查看响应式 UI 的实际效果,点击 Next random article(下一篇随机文章)按钮。应用会显示加载状态、获取新数据,并自动更新显示。

12

回顾

你已完成的内容

以下是你本课构建与学习内容的摘要。
使用 ListenableBuilder 自动重建 UI

ListenableBuilder 会监听你的 ViewModel,并在每次调用 notifyListeners() 时自动重建其子 widget。在 MVVM 模式中,这是 ViewModel 与 View 之间的关键连接。

用 switch 表达式处理所有可能的状态

通过 switch 表达式,你为可能的状态组合提供了合适的用户界面,有条件地显示加载指示器、错误消息或实际文章内容。有了这种处理,UI 现在更加健壮和完整。

用合适的样式构建完整的 View 层

你创建了 ArticleViewArticlePageArticleWidget,包含条件渲染、文本样式、合适间距和溢出处理。这些是你将在每个 Flutter 应用中使用的核心 UI 模式。

完成了 MVVM 架构

你已构建包含 Model(数据操作)、 ViewModel(状态管理)和 View(响应式 UI)层的完整应用。这种关注点分离有助于代码更易测试、维护和扩展。

13

自测

ListenableBuilder 测验

1 / 2
ListenableBuilder 在 Flutter 中的作用是什么?
  1. 基于 ChangeNotifier 创建动画。

    不正确。

    ListenableBuilder 在状态变化时重建 UI,并非专门用于动画。

  2. 监听 ChangeNotifier,并在调用 notifyListeners() 时自动重建其子 widget。

    正确!

    ListenableBuilder 监听 Listenable,并在收到通知时重建其 builder 函数。

  3. 手动控制何时应重建 widget。

    不正确。

    调用 notifyListeners() 时重建是自动的;你无需手动控制。

  4. 缓存 widget 构建以提升性能。

    不正确。

    ListenableBuilder 用于响应式更新,而非缓存。

ListenableBuilder 何时重建其子 widget?
  1. 每次应用帧刷新时。

    不正确。

    ListenableBuilder 仅在收到通知时重建,而非每一帧。

  2. 当它监听的 Listenable 调用 notifyListeners() 时。

    正确!

    ListenableBuilder 订阅 Listenable,并在每次调用 notifyListeners() 时重建其 builder 函数。

  3. 仅在 widget 首次挂载时。

    不正确。

    它在每次调用 notifyListeners() 时都会重建,而不仅限于挂载时。

  4. 当父 widget 重建时。

    不正确。

    ListenableBuilder 根据 Listenable 重建,而非父 widget 重建。