Skip to main content

當狀態變更時重建 UI

說明如何使用 ChangeNotifier 管理狀態的指南。

學習使用 ListenableBuilder 自動重建 UI,並以 switch 運算式處理所有可能的狀態。

你將完成的事項

使用 ListenableBuilder 自動重建 UI
以 switch 運算式處理所有可能的狀態
建置具備適當樣式的完整 View 層

Steps

1

介紹

View 層是你的 UI,在 Flutter 中,這指的是應用程式的元件 (Widget)。 就本教學而言,重要的部分在於將 UI 與 ViewModel 的資料變更連結起來,使其能夠回應。 ListenableBuilder 是一個能夠「監聽」ChangeNotifier 的元件,當所提供的 ChangeNotifier 呼叫 notifyListeners() 時,它會自動重建。

2

建立文章 View 元件

建立 ArticleView 元件,用來管理頁面的版面配置和 ViewModel 的生命週期。 由於它必須在渲染之前明確初始化資料擷取,因此將其實作為 StatefulWidget

從建立基本的有狀態結構開始:

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

實例化文章 View Model

接著,初始化 ArticleViewModel 並將其對應到狀態的生命週期。 在 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

更新應用程式以加入文章 View

透過更新 MainApp 以加入已完成的 ArticleView,將所有部分串聯在一起。

用以下更新後的版本取代現有的 MainApp

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

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

這個變更將從基於主控台的測試切換到具備適當狀態管理的完整 UI 體驗。

5

監聽狀態變更

將 UI 包覆在 ListenableBuilder 中以監聽狀態變更,並傳入一個 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) 模式, 這個模式需要一個回呼(callback)而非 child 元件來建置其下方的元件樹。 這些元件非常靈活,因為你可以在回呼(callback)中執行操作, 根據狀態建置不同的元件。

6

處理 View Model 的可能狀態

回想 ArticleViewModel,它有三個 UI 會關注的屬性:

  • Summary? summary
  • bool isLoading
  • Exception? error

根據這些屬性的組合狀態,UI 可以顯示不同的元件。 使用 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 根據狀態渲染,並在狀態需要時自動更新, 但 UI 本身不管理任何狀態或更新自身的過程。 商業邏輯與渲染完全分離。

7

完成 UI

剩下的唯一工作是使用 View Model 提供的屬性和方法來建置 UI。

現在建立一個 ArticlePage 元件,用於顯示實際的文章內容。 這個可重複使用的元件接受摘要資料和一個回呼(callback)函式:

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

加入可捲動的版面配置

用可捲動的直欄版面配置取代佔位符:

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

加入文章內容與按鈕

以文章元件和導覽按鈕完成版面配置:

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 參數的元件開始:

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...');
  }
}

加入內距(padding)和直欄版面配置

#

將內容包覆在適當的內距(padding)和版面配置中:

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),
        ],
      ),
    );
  }
}

這個元件展示了幾個重要的 UI 概念:

  • 條件式渲染if 陳述式只在內容可用時才顯示。
  • 文字樣式: 不同的文字樣式利用 Flutter 的主題系統建立視覺層次。
  • 適當的間距spacing 參數提供一致的垂直間距。
  • 溢位處理TextOverflow.ellipsis 防止文字破壞版面配置。
11

執行完整的應用程式

最後一次熱重載應用程式。你現在應該會看到:

  1. 初始文章載入時的讀取轉圈動畫。
  2. 文章的標題、描述和摘要摘錄。
  3. 圖片(如果文章有的話)。
  4. 載入另一篇隨機文章的按鈕。

要查看響應式 UI 的實際運作, 請點擊 Next random article 按鈕。 應用程式會顯示載入狀態,擷取新資料,並自動更新畫面。

12

回顧

你完成了什麼

以下是你在本課程中建置和學習內容的摘要。
使用 ListenableBuilder 自動重建 UI

ListenableBuilder 監聽你的 ViewModel,並在每次呼叫 notifyListeners() 時自動重建其子元件。 在 MVVM 模式中,這是 ViewModel 與 View 之間的關鍵連結。

以 switch 運算式處理所有可能的狀態

使用 switch 運算式,你為所有可能的狀態組合提供了適當的使用者介面, 條件式地顯示載入轉圈動畫、錯誤訊息, 或實際的文章內容。 有了這樣的處理,UI 現在更加健全且完整。

建置具備適當樣式的完整 View 層

你建立了具備條件式渲染、文字樣式、 適當間距和溢位處理的 ArticleViewArticlePageArticleWidget。 這些是你在每個 Flutter 應用程式中都會用到的核心 UI 模式。

完成 MVVM 架構

你已建置了一個具備 Model(資料操作)、 ViewModel(狀態管理)和 View(響應式 UI)層的完整應用程式。 這種關注點分離有助於讓你的程式碼更易於測試、維護和擴展。

13

自我測試

ListenableBuilder 測驗

1 / 2
ListenableBuilder 在 Flutter 中的用途是什麼?
  1. 監聽 ChangeNotifier,並在 `notifyListeners()` 被呼叫時自動重建其子元件。

    That's right!

    ListenableBuilder 監聽一個 Listenable,並在收到通知時重建其 builder 函式。

  2. 根據 ChangeNotifier 建立動畫。

    Not quite

    ListenableBuilder 是在狀態變更時重建 UI,而非專門用於動畫。

  3. 手動控制元件何時應該重建。

    Not quite

    當 notifyListeners() 被呼叫時,重建會自動發生;你無法手動控制它。

  4. 快取元件建置以提升效能。

    Not quite

    ListenableBuilder 是關於響應式更新,而非快取。

ListenableBuilder 何時會重建其子元件?
  1. 只在元件首次掛載時。

    Not quite

    它在每次 `notifyListeners()` 被呼叫時都會重建,不只是在掛載時。

  2. 當父元件重建時。

    Not quite

    ListenableBuilder 根據 Listenable 重建,而非根據父元件的重建。

  3. 每次應用程式的畫面更新時。

    Not quite

    ListenableBuilder 只在收到通知時重建,而非每一幀都重建。

  4. 當它所監聽的 Listenable 呼叫 notifyListeners() 時。

    That's right!

    ListenableBuilder 訂閱 Listenable,並在每次呼叫 `notifyListeners()` 時重建其 builder 函式。