第一個 state 管理 - Provider 的使用與深入

紅寶鐵軌客
來關注...
關注/停止關注:紅寶鐵軌客
關注有什麼好處?:當作者有新文章發佈時,「思書」就會自動通知您,讓您更容易與作者互動。
現在就加入《思書》,你就可以關注本作者了!
《思書》是一個每個人的寫作與論壇平台,特有的隱私管理,讓你寫作不再受限,討論更深入真實,而且免費。 趕快來試試!
還未加入《思書》? 現在就登錄! 已經加入《思書》── 登入
寫程式中、折磨中、享受中 ......
728   0  
·
2021/07/11
·
29分鐘


自由的切換 UI 黑暗或是明亮模式

我們的 UI 現在會依照作業系統的預設自動切換到黑暗或是明亮模式,很棒,只是,好像,還不夠好,能不能讓使用者自由的切換呢?

要怎麼做呢?請各位先想一想!

延續我們的程式碼,如果有人現在才加入,目前階段的程式碼請自行參考本書:程式碼備份中的 Code milestone 3


使用情境 UX

這是最主要的問題,也就是使用者要怎麼使用,當然越簡單越直覺越好,關於什麼是好用的 UX 絕不在本書範圍內,在這本書中,我就是那個獨裁者,我說這樣寫好就是好,不接受討論,因為光是討論什麼是「好」的 UX,就絕對可以吵翻天了,本書專注在「寫」程式,不討論 UX。

這是一個很簡單的應用,要切換 UI 模式就一定要有一個「開關」,唯一的問題是這個「開關」應該要放在那裡,嘿嘿,打開我們目前的 App,很好,我們早就有一個抄來的 drawer 抽屜(什麼?忘記了嗎?就是 MyHomePage 左上角的那個三條線的 menu 啦),這個位置很好,我們就來把這個「開關」放在 drawer 抽屜裡,使用情境 UX 結案,不受理上訴!


State management 管理

可是,drawer 抽屜裡的「開關」在下層的 widget,怎麼能改變上層 widget 的設定呢?

圖 1:state 的觀念

這個問題很巨大,黑暗或明亮模式的切換是在最上層的 MyApp 中,而 drawer 的「開關」是在它下層的 MyHomePage 中,如上圖,看來我們需要一個「電話」,讓下層的 MyHomePage 中的「開關」改變時,會通知上層的 MyApp 改變 themeMode, 就像是圖中的藍色虛線機制。

感覺上,這就跟 StatefulWidget 中的 setState() 很像,只是這次要跨 widgets,Flutter 有這種機制嗎?

嘿嘿嘿,Flutter 還真有這個機制 Widget,就叫做 InheritedWidget,而且,我們在一開始的文章中就有提到這個 widget,可見它很重要,但是那時我們只是要讓各位讀者知道,透過這個 Inherited widget,Flutter 可以在 Widget 中分享資訊,現在,我們真的需要這個機制了。

只是,我真的很不想深入瞭解這個 Inherited widget,因為它很囉唆難用,連 Flutter 官網也不建議使用,不信可以看這個影片,而且現在已經有太多的能人異士將 Inherited widget 打包,變裝成更好用的 package(s) 了,是的,是有加 s 的多數,所以不止一個,這些 packages 都是用管理 state,所以就被統稱為:State management,「State 管理」。

用那一個 State 管理方法呢?

Flutter 中有很多種管理與使用方法,讓我們快速的了解一下:

  • setState():這是一個很底層方法,各位已經看過了,用在 stateful widget;
  • InheritedWidget & InheritedModel:這也是一個很底層方法,很囉唆難用,所以才會有下面的改裝 packages;
  • Provider:flutter 建議大家使用這方法,Flutter 官方說它簡單又好用,是真的嗎?
  • Redux / Fish-Redux:這是 Flutter 早期最流行的方法,在 React 上已經有很多人使用了,早期的 Flutter 只支援幾種 state 管理方式時,Redux 就是其中的一種;
  • BLoC / Rx:BLoC 是 Business Logic Component 的縮寫,這是用 stream 的非同步方法,也是目前最流行的方法;
  • 其他:GetIt、MobX、Flutter Commands、Binder、GetX、Riverpod,⋯⋯ Flutter 有太多的 State 管理 package 了,我相信以後一定還會有更多新的,因為各有優勢,state 管理也太重要了,所以好還要更好。

哇,有那麼多種,各位心裡一定會想,這要學到何年何月啊,真的,所以有人就用 State Wars 狀態大戰來形容 Flutter 中的各種 state 管理,這其中,目前最受歡迎也討論最多的是 Redux 跟 BLoC,但是,我們初學,我覺得了解 state 的觀念最重要,應該先學簡單的,所以我將使用 Flutter 官方建議的 provider 方法作為入門,Flutter 官方說它又簡單又好用,希望是真的。


安裝 Provider package 

在 android studio 中,找到如下圖中的 run anything 的 commands 執行,按下它:  

依照 provider 的安裝說明,輸入下面的指令,按 return 執行:

flutter pub add provider

這是我們第一次使用 Flutter 套件管理 pub,讓我們來對 pub 做一個快速的了解:

  • pub 是 Dart/Flutter 的套件管理程式,我們是使用 flutter,所以就是呼叫 flutter pub,如果是用 dart 就是 dart pub;
  • pub 有很多指令,詳細請看 pub 的文件說明,常用的就是:
    • add:新增 package;
    • get:依照 pubspec.lock 中的版本,取得所有的「相關聯 dependencies」的 package;
    • upgrade:跟 get 一樣,但是「不會」依照 pubspec.lock 中的版本,會根據 pubspec.yaml 中的版本指示,升級 package 版本;
  • pubspec.yaml:這是這個 flutter app 中的 Package 的版本使用規劃藍圖。如果不用 flutter pub 的指令,可以在這裡用手工設定。在開發程式時,我們很常會只使用 package 的某一個版本,不一定會使用最新版,原因很多,一般來說,很常是因為如果使用新版本的 package,那整的 app 就必需要重新測試;或是,新版本帶來新的問題。pub 指令會自動更新這個檔案,但是不一定是你要的版本。
  • pubspec.lock:這是執行 pub 後所產生的版本資訊,也是「目前」App 所使用的每一個 package 版本資訊,基本上不能亂改,pub 會依照這資訊來更新版本。

當執行後,你應該就會看到以下的執行的結果如下,隨著版本的不同,可能會不一樣,只要沒有顯示錯誤就好:

flutter pub add provider
Resolving dependencies...
  async 2.6.1 (2.7.0 available)
  charcode 1.2.0 (1.3.1 available)
  meta 1.3.0 (1.4.0 available)
  test_api 0.3.0 (0.4.1 available)
Got dependencies!

讓我們來仔細看看,到底這行 add provider 指令到底做了什麼:

  1. pub 發現,要使用 provide package,App 中還必須要安裝 provider 依賴的:Async、charcode、meta、test_api 這些 packages;
  2. 除了 provider 沒有安裝外,Async、charcode、meta、test_api 已經安裝了,但是有更新的版本;
  3. 更新 pubspec.yaml 及 pubspec.lock;

讓我們再來看看,這行指令到底幫我們把 pubspec.yaml 改成怎樣了,打開它,我們發現,哈,只在 dependencies 下面加了一行:

provider: ^5.0.0

^5.0.0 是什麼?它就是版本管理,你可能會想,為什麼要版本管理,就用最新的版本就好,可以實務上,當使用外加的套件時,套件更新不一定是好事,有時套件更新會帶來新的 bug 或是更改不同的用法,甚至需要使用的程式改寫,所以限制版本的使用是很重要的,常用寫法有

  • provider: any — 任何版本都可以;
  • provider: 5.1.3 — 只能使用5.1.2版本;
  • provider: >=5.1.3 — 大於或等於 5.1.2 的版本都行;
  • provider: >5.1.3 — 大於 5.2.1 的版本都好;
  • provider: <=5.1.3 — 小於等於 5.1.2 版本;
  • provider: <5.1.3 — 小於 5.1.2 版本;
  • provider: ^5.1.3 — 等於 >=5.1.2 <6.0.0,所以 upgrade 時可以到小於 6.0 的最大版本。

所以 ^5.0.0 就是允許 pub upgrade 時,可以把 provider 更新到 5.x.x 內的最高版本。

再打開 pubspec.lock 看看,一開頭就寫得很清楚,這是由 pub 產生的,這裡面也很清楚的列出了所有使用中的 package 名稱,版本等資訊,所以當你不清楚使用中的 package 版本時,看這裡最快,但是記住,不要亂改。

既然我們剛開始寫,也發現有更新的版本,那就讓我們來做個更新吧:

  • flutter pub upgrade:這會更新套件的版本,可是我們才剛剛 pub add 一個新套件,應該不會有更新的相關套件。
  • flutter upgrade:這是更新 flutter 的版本,小版本更新一般沒問題,大版本,如 1.x 到 2.x 你就要很小心了,通常會有很多不能用,你在做更新前,一定要小心,確定有讀過升級說明才做。

好啦,我兩個都執行了,套件是沒有辦法更新,有 dependency constraints 限制,那就不管了。Flutter SDK 更新到了 2.2.3。


The State 狀態

要管理一個 state 就要先建立一個 state,state 就是一個狀態管理,在程式碼的管理中,我們一般來說會將它放在 provider 目錄裡,但是我喜歡放在 bloc 目錄中,因為 bloc 就是 business logic =商業邏輯,延伸意義就是狀態機state machine),很合乎 state 管理的原意。

我們這個新建的 state 只是用來切換 UI 的黑暗、明亮及系統模式,單純的簡單,我們就把它取名為 MyThemeMode 吧。

lib/bloc/my_theme_mode.dart:

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

class MyThemeMode with ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system; // must be initial

  //MyThemeMode(){
  //  notifyListeners();
  //}

  // getter
  ThemeMode get getMode {
    return _themeMode;
  }

  void setModeTo(ThemeMode vMode) async {
    _themeMode = vMode;
    notifyListeners();
  }
}
  • 第 1~2 行:其實我們只要 import material 就夠了,因為它已經包含 foundation package 了,但是我想要讓大家清楚知道,如果只是要使用 Provider/ChangeNotifer,那只要 import foundation 就好,它已經包含在 flutter 的核心內,而我們只是剛好同時也要使用 material 的 ThemeMode,而 material 又已經包含 foundation,所以我們只要 import material 就夠了。
  • 第 3 行:Class 名稱跟 with...... ChangeNotifier......?為什麼是 with 而不是 extends 呢?
    • 在 dart 中,with = mixin,其實在 dart 中就是多重繼承,dart 只能 extends 一個 class,所以 ChangeNotifier 算寫的很好,可以方便使用,它可以用 extends、也可以用 with 來繼承,這有一個好處,當你建立的 class 需要 extends 其他 class 時,還是可以再加用 with ChangeNotifer,例如:Class MyModel extends OtherClass with ChangeNotifer。
    • 在其他的語言中,mixin 比較像是 include,也就是將兩個程式碼 class 混合,讓程式碼重複使用,這其實也很像多重繼承,“extends” vs. “implements” vs. “with” 一直都是工程師打屁的話題,每個語言各有它特定的用途,用就對了。
    • 繼承 ChangeNotifer 的目的就跟它的名稱一樣,是要使用它的「改變通知」機制,也就是我們前面形容的那個「電話」。
  • 第 5 行:我們要管理的 state 狀態 = 這個 private 變數 _themeMode,它的型態就是 material 的 ThemeMode,我們先把它預設成 ThemeMode.system,以後再來看要不要存預設值。
  • 第 7~9 行:目前還沒有 constructor,先佔個位,等等有用,一般寫程式時,我都會把 constructor 先留下來,大部分的 class 都有預設的,沒有也太奇怪了。
  • 第 11~14 行:很簡單的 getter 寫法,好處是以後我們要讀狀態時,只要寫 x.getMode 不用寫 x.getMode(),少寫個括弧,哈哈哈哈哈,不只是懶啦,我覺得這樣程式更精簡。
  • 第 15~19 行:這是核心了,我們透過這個 setModeTo 方法來:
    • 改變 private 變數 _themeMode,也就是改變 state,改變狀態!
    • 第 18 行:notifyListeners() 就是打「電話」通知所有在「聽 listen」MyThemeMode 這個「號碼」的程式。所以記住了,我們這個「電話」,它的號碼就是「MyThemeMode」,打出去就是 notifyListeners(),等一下我們就會看到怎麼接聽。
    • 第 16 行的 Async 在這裡可有可無,不過實務上,大家都會寫 async,async 是指非同步,也就是不要讓程式停著等回應,會放手讓其他程式繼續跑,畢竟我們都不知道等著接聽的程式會要執行多久,放手是最好的策略。

好啦,我們把 state 的資料部分寫好了,好像真的不難,接下來我們來看如何打電話來改變 state。


通知 state 改變

state 就是狀態,以後就不翻譯了。

資料有了,再來就是要建立一個「開關」讓使用者可以切換,之前已經決定將「開關」放在 MyHomePage 的 drawer 抽屜裡,但是也不能亂放啊,未來一定也會有其他的設定也要放在這個抽屜裡,所以我的設計是:

  • 在 drawer 抽屜裡包一個 ListView widget,這樣以後要再增加就很方便;
  • 我們需要一個 3 態的開關,能夠選擇明亮、黑暗及系統模式,找了一下,我覺得 Flutter 內建的 material ToggleButtons widget 正合我意,就決定用它了!

做好的樣子如下圖,我先將放上,大家也可以先想想看你會怎麼做,當按下那三條線的 drawer 抽屜後,我們的 UI 開關就出現了,如下:

有想法了嗎?廢話不多說,先看看我的碼,也許你有更好的寫法!

ToggleButton 是由一個 boolean List 按照排列順序來控制的,我們要在 Drawer 裡面使用 ToggleButton,就要先建立一個控制 ToggleButton 的 List。

我的做法是先在 _MyHomePageState 中,建立了一個 _themeModeSelected() method:

lib/screens/my_home_page.dart 的 _MyHomePageState 中:

class _MyHomePageState extends State<MyHomePage> {
  //int _counter = 0;
  final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();

  List<bool> _themeModeSelected() {
    switch (context.read<MyThemeMode>().getMode) {
      // toggle btn in order of Light/Dart/System
      case ThemeMode.dark:
        return [false, true, false];
      case ThemeMode.light:
        return [true, false, false];
      default: // anything else is ThemeMode.System, this also avoid potential nullable warning.
        return [false, false, true];
    }
  }
  • 第 5~15 行:這個 _themeModeSelected method 就是將讀到的 MyThemeMode state 轉變為 設定 ToggleButton 的 List,我們有三個按鈕,按下去的是 true,沒按的是 false,按鈕的排列順序是「明亮/黑暗/系統」,所以黑暗模式就是 [ False, True, False],等一下的 ToggleButton 就會依照我們的設定來顯示。這個寫法我自己也不是很喜歡,因為萬一按鈕的排列順序一變動,就要很小心的跟著改變,但是這寫法超簡單,這點我喜歡,也歡迎大家分享一下其他寫法。
    • 第 5 行:這行是重點也是 provider 的核心 功能,我們用 context.read().getMode 來讀取 state 的值,這種寫法比較少見,但是我很喜歡,我覺得簡單又清楚,大家在網路上比較常看到的寫法是 Provider.of(context, listen: false).getMode,其實這兩個寫法是一樣的:
      • context.read = Provider.of(context, listen: false),只讀取 state。
      • context.watch = Provider.of(context, listen: true),當 notifyListeners() 執行後,就會通知 context.watch,告知 state 有變化了,同時所在的 widget 也會更新。
      • context.read/watch 就是 Provider.of(context, listen: false/true) 的簡寫,喜歡用那一個寫法就隨喜了,但是:
        • context.read 有多一些人為的限制,它在 Widget build() 內被限制使用,作者認爲不安全,但是可以改寫成 Provider.of... 我是覺得這樣很怪,但是我不是 provider 的作者,所以,如果在 build() 內時,context.read 不行用時,就換 Provider.of 寫法試試,詳情在文件內。 
      • 另外還有一個常用的是 context.select,它可以被「部分內容變動時」通知,寫法如:context.select((Person p) => p.name),這可以只讀取部分 state 內容。
  • 第 8~13 行:原來 state 儲存的值就是 Material 的 ThemeMode, 目前 Flutter 的 ThemeMode 也就只有三個:dart/light/system,所以我們的 state 也就只有三個,我喜歡這個寫法,又簡潔又易懂。還有,我們其實只判斷 dart 或是 light,這是為了避免 flutter 未來新增 ThemeMode 時,造成程式不相容,所以如果不是 dart 或 light,就用 default 設成 system,這樣不止容錯提高,程式碼也更簡短。


再來就是建立 ToggleButton 的 widget 了,文件可以看這裡,但是我覺得看以下的程式碼就夠了,在我們這個 App 中,ToggleButton 的 widget 是加在 _MyHomePageState 的 Draw widget 裡面。

lib/screens/my_home_page.dart 中的 _MyHomePageState 之 Draw 部分:

drawer: Drawer(
  child: ListView(
    padding: Theme.of(context).cardTheme.margin,
    children: <Widget>[
      Container(
        // top placeholder block, iPhone front camera will block this.
        height: 50,
        color: Colors.transparent,
      ),
      Container(
        color: Theme.of(context).colorScheme.background,
        margin: Theme.of(context).cardTheme.margin,
        height: 90,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Text(
                'Select your favor theme',
                style: Theme.of(context).textTheme.button,
              ),
              ToggleButtons(
                children: [
                  customIconOnTextWidget('light', Icons.light_mode, Theme.of(context).textTheme.caption),
                  customIconOnTextWidget('dark', Icons.dark_mode, Theme.of(context).textTheme.caption),
                  customIconOnTextWidget('system', Icons.devices, Theme.of(context).textTheme.caption),
                ],
                onPressed: (int index) {
                  //setState(() {
                    for (int vI = 0; vI < _themeModeSelected().length; vI++) {
                      if (vI == index) {
                        _themeModeSelected()[vI] = true;
                      } else {
                        _themeModeSelected()[vI] = false;
                      }
                    }
                    switch (index) {
                      // btn order: 0/light, 1/dark, 2/System
                      case 0:
                        //Provider.of<MyThemeMode>(context, listen: false).setModeTo(ThemeMode.light);
                        context.read<MyThemeMode>().setModeTo(ThemeMode.light);
                        break;
                      case 1:
                        context.read<MyThemeMode>().setModeTo(ThemeMode.dark);
                        break;
                      case 2:
                        context.read<MyThemeMode>().setModeTo(ThemeMode.system);
                        break;
                    }
                  //}); // setState()
                },
                isSelected: _themeModeSelected(),
              ),
            ],
          ),
        ),
      ),
      Container(
        // This is a placeholder for future reference.
        color: Theme.of(context).colorScheme.background,
        margin: Theme.of(context).cardTheme.margin,
        height: 90,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              ElevatedButton(
                onPressed: _closeDrawer,
                child: Icon(Icons.close),
              ),
            ],
          ),
        ),
      ),
    ],
    //separatorBuilder: (BuildContext context, int index) => const Divider(),
  ),
),

不要被這近百行的程式碼嚇到,Flutter 就是長得嚇人,其實這裡面不過就是:

  • ListView(): 裡面有
    • Container(): 卡位用,因為 iPhone 的前相機會擋掉螢幕上方,所以我們要避免使用。
    • Container(): 裡面有
      • Text():說明文字 
      • ToggleButtons():我們的 state 開關
    • Container(): 
      • ElevatedButton():離開按鈕
  • 第 29 行:重點!必讀!我把 setState() 給 remark 起來了,各位請先自己想想看為什麼?如果我們沒有使用 provider,正常的情況下,按下 ToggleButtons 就應該要 rebuild widget,不然怎麼能顯示按鈕已經被切換了呢?那為什麼現在不用呢?
    • 答案:還記得我們這篇文章一開始的圖 1:state 的觀念嗎?這是很重要的一個 state 觀念,當我們的 state 送出 notifyListeners 後,provider 就會通知所有在等待接收的 widget 做 rebuild,而我們要改變 ThemeMode 的接收 widget 是 MyApp(),MyHomePage() 是它的子 widget,在 Flutter 中,任何一個 widget rebuild 時,它的子孫 widgets 也會跟著 rebuild,所以當 MyApp() widget rebuild 時,自然下面所有的 widget 都會跟著 rebuild 了,也就因為如此,所以我們這裡就不用再 setState() rebuild 了啦,基本上,MyApp()  是 widget 頭,所以等於整個 App 都已經 rebuild 了。
    • 這也帶出了另一個很重要的效率問題,當接收的 widget 越是上層時,它的下層所有的 widget 都要全部 rebuild,越多 widget rebuild 效率越差,所以 state 管理的接收 widget 要盡量放在最低,越低越好,這樣才能減少 rebuild 的 widget 數目。
  • 第 40~47 行:當 ToggleButton 按下去後,這裡就呼叫我們 MyThemeMode 的 setModeTo method 了,而這個 method 裡面就有很關鍵的 notifyListeners(),就是它會打「電話」通知所有正在等待接聽中的 Widget!(例如有 context.watch 的 widget)
    • 第 41 行:前面我們已經提到 context.read().setModeTo(ThemeMode.light) 也可以寫成第 40 行的 Provider.of(context, listen: false).setModeTo(ThemeMode.light),兩者是一樣的,在這裡,我覺得反而是 Provider.of 寫法比較傳神,只是 context.read() 還是簡短些,而且我都是用 context.xxx 所以就繼續使用了。
  • 延續我們之前的強烈建議,所有的顏色及樣式都盡量使用 Theme.of(context),而且都要設定在 style.dart 中,不只顏色是使用 Theme 的 colorScheme,我們也使用 Theme 中的 size 設定,如:cardTheme.margin。
  • 小小提示一下:有沒有覺得 Provider.of 跟 Theme.of 很像?!是的,它們其實都是繼承 InheritedWidget,所以也都可以跨 widget 分享資料,原來我們早就已經在使用 InheritedWidget 了。
  • 新增的style.dart 部分如下:

/lib/theme/style.dart:的新增部分。

final themeCustom(Dark 及 Light) = ThemeData(
  ...
  textTheme: myBaseTextTheme,
  cardTheme: myCardTheme,
);

const myBaseTextTheme = TextTheme(
  ...
  button: TextStyle(height: 2.0),
  caption: TextStyle(fontFamily: 'JBMono'),
);

const myCardTheme = CardTheme(
  margin: EdgeInsets.all(5),
);
  • ToggleButton 內有三個一樣的按鈕樣式,上面是 icon 下面是文字,一樣的程式碼寫三遍絕對不夠 DRY,所以我們就建立了一個自定的「 custom widget」叫 customIconOnTextWidget(),獨立存檔,我還是放在 lib/theme 這個目錄裡,因爲也算是樣式,取名為 custom_widgets.dart,要不要這樣做,看個人的喜好了:

/lib/theme/custom_widgets.dart:別忘了要 import 到使用的 screen 內。

// custom widgets

//  Icon on Text with style
//    Use: Draw Toggle btn,
Widget customIconOnTextWidget(String label, IconData icon, TextStyle? textStyle) {
  return Column(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      Icon(icon),
      Text(label, style: textStyle),
    ],
  );
}

好啦,切換的通知開關寫好了,再來就是接收 state 的部分了。


接收 state 改變

要寫接收 state 改變的程式碼前,我們第一個要考慮的就是,接收端要寫在那裡,這有以下的影響:

  • widget rebuild 的效能:還記得前不久我們提到當我們的 state 送出 notifyListeners 後,provider 就會通知所有在等待接收的 widget 做 refresh,但是記住,不只這個接收的 widget 會 rebuild ,它的所有子子孫孫 widgets 也會跟著 rebuild子子孫孫 widgets 越多,當然 rebuild 會更慢。

我們這個 App 的接收端,也就是 consumer/provider.of 的位置倒是很好決定,因為 themeMode 的設定是在 MyApp 裡面,雖然它是 widget 頭,但是我們 state 的接收位置還是只能寫在這,情勢所逼不得不如此。實務上,如同我們前面所說,我們要盡量放在最低層,特別避免放樹頭,樹頭一改,整棵樹都要重畫了。

要接收資料又要被通知,要使用的 provider 選項就是 ChangeNotifierProvider,是的,provider 有好幾多個不同的使用選項,等一下我們會再一一介紹,下面的程式碼是一個很標準的寫法,將 ChangeNotifierProvider 寫在 runApp 裡,也就是 widget 的最高處,再用 consumer 來接收變化。這種寫法的好處是所有的 widgets 都可以讀到 state 資料,也可以被通知,但是缺點就是效率差一些。


最常見的 ChangeNotifierProvider 用法

lib/main.dart:

import 'package:flutter/material.dart';
import 'package:happy_recorder/screens/audio_session.dart';
import 'package:happy_recorder/screens/my_home_page.dart';
import 'package:happy_recorder/screens/page_404.dart';
import 'package:happy_recorder/theme/style.dart';
import 'package:happy_recorder/blocs/my_theme_mode.dart';
import 'package:provider/provider.dart';

void main() {
  return runApp(ChangeNotifierProvider<MyThemeMode>(
    create: (_) => new MyThemeMode(),
    child: MyApp(),
  ));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<MyThemeMode>(
      builder: (context, myThemeMode, child) => MaterialApp(
        title: 'Happy Recorder',
        theme: themeCustomLight, // default theme
        darkTheme: themeCustomDark, // dark theme
        themeMode: myThemeMode.getMode,
        initialRoute: '/',
        routes: {
          '/': (context) => MyHomePage(title: 'Happy Recorder v0.1'),
          '/audio': (context) => AudioSession(
              arIndex: (ModalRoute.of(context)!.settings.arguments as int)),
        },
        onUnknownRoute: (RouteSettings settings) {
          return MaterialPageRoute(
              builder: (_) => Page404(routeName: settings.name));
        },
      ),
    );
  }
}
  • 第 10~14 行:ChangeNotifierProvider 是 Provider 最常被使用的選項,它一定要使用有繼承 ChangeNotifer 的 class 當 state,可以讓我們同時接收 state 的資料,也可以 refresh/rebuild 任何有接聽的 widget 及其子孫。我們要讀取一個 state 的資料,就必須要宣告這個 state 是那一個 class,在我們的 App 中,這個 state 的 class 就是 MyThemeMode,所以就把它放在<>中。
    • 第 11 行:要使用 provider 還必須要在 ChangeNotifierProvider 的中指定這個 state class 的 instance,如果這個 instance 已經存在,我們就要改用 ChangeNotifierProvider.value 及 value 來指定;如果還沒不存在,就像我們現在,就要用 create 來建立這個 instance。
    • 題外話,instance 中文要怎麼翻譯呢?這真難,我認為最接近的解釋是:把 Class 想成是一個蛋糕模型,做出來的蛋糕就是個「實物」,這樣的解釋也最接近 wiki 的說明,日文是翻譯成「事例」,也可以參考,不過,我覺得就用英文啦,電腦的世界,常常越翻譯越難懂。
    • 第 12 行:這個 child 很神奇,我找不到任何說明介紹,只能用試的,看有什麼限制。
      • 顧名思義,這就是指定那一個 widget tree 要使用 ChangeNotifierProvider;
      • 一定要指定 child widget,雖然它不是 require 的 constructor;
      • 這個 widget tree 裡一定要有 title,所以隨便指定,一定要有 title 不就是只能選 root widget 嗎?
      • 希望能有高人指點一下,目前看起來就只能寫在 root widget 前...... 
  • 第 19~20 行:這個 consumer 就是接聽者,所以當 state 裡面的 notifyListeners() 發出通知時,這裡就會被通知,而且這個 widget 就會被 rebuild,這種寫法最多人用,好處是乾淨清楚,缺點是字有點多。
    • consumer 一定要指定是接聽那一個 state,這個 state 的 class 一定要被 ChangeNotifierProvider 宣告,所以我說每一個 state 的 class 就好像一個獨特的電話號碼,一個 App 中也就是因為可以使有不同的 state class,所以可以同時有很多個 state。使用很簡單,就把這個 state 的 class 包在<>中就好了。還有一點,Consumer 其實就是 Provider.of(context) 的縮寫,
    • consumer 一定要有 builder 函數,它是用來 rebuild 它所 return 的 widget,builder 有三個參數:
      • Context,也就是 BuildContext,這是 flutter 中的 widget tree 位置,很虛無飄渺的東西,等一下我們要另闢專章來聊它,現在我們就先當猴子,照著打吧。
      • myThemeMode:這個就是被前面 ChangeNotifierProvider 所 create 建立出來的 MyThemeMode 之 instance 名稱,你可以隨喜改名,叫要它阿狗阿貓都行,接下來你就是用這個 instance 來存取 state 了,很方便又彈性,我想這也是為什麼很多人喜歡用 consumer 這個方法的原因吧。
      • child:可以不用寫,大部分的人也都沒用它,可是這是個好用的 child,這個 child 可以讓你把事先做好的 Widget 夾帶進去,因為這個 consumer 裡面會常常被更新,如果裡面有一個超級複雜的 widget,那它每一次更新就會被重做一次,這會是個效能惡夢,所以最好的方式就是在外面先做好這個 widget,再用 child 夾帶進去,這樣,consumer,或是 builder 就只要用就好。我們現在離介紹超級複雜的 widget 還很遠,如果真的很急,一定要看展示碼,就請跳到未來的這篇:《Hive 實作:儲存錄音紀錄 - CRUD、Key 排序、Filtered、Sort 等進階用法》 了。
Consumer<A>(
  builder: (_, a, child) {
    return Bar(a: a, child: child);
  }
)
  • 第 24 行:就是這道光!myThemeMode.getMode,就是這行程式碼改變了我們的 ThemeMode,myThemeMode 就是我們在 consumer 裡面替 MyThemeMode 取名的 instance 名稱,所以 myThemeMode.getMode 就會去讀取目前存在 MyThemeMode 這個 state 中的值,到此,大功告成。

Yes!趕快試試我們的 App,你應該可以自由的切換改變 App 的 theme 了。

現在我們終於看到完整的 provider state 管理流程了:

  • 按下 ToggleButton 後:
    • state 中的值被改變了;
    • notifyListeners() 發出通知給所有的 consumer / provider.of;
  • consumer 接到通知後:
    • builder 啟動,重建它指定的 widget;
    • widget 重建時會讀取 state 被改變的值;

很簡單嘛,但是要真搞懂,還是不容易啊。


ChangeNotifierProvider 與 consumer/builder 合在一起的用法

除了上面這種最流行的用法,還可以直接把 ChangeNotifierProvider 跟 consumer 或 context.watch() 合在一起使用,只是一定要用 consumer 或是 builder 隔開,這樣才能讀到上層的provider 變數,下面就是使用 Builder + context.watch() 的寫法

lib/main.dart:我將 import 及 route 部分省略了,它們就跟上面的一樣,沒變。

import ...

void main() {
  return runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<MyThemeMode>(
      create: (_) => new MyThemeMode(),
      child: Builder(
        builder: (context) => MaterialApp(
          title: 'Happy Recorder',
          theme: themeCustomLight, // default theme
          darkTheme: themeCustomDark, // dark theme
          themeMode: context.watch<MyThemeMode>().getMode,
          initialRoute: '/',
          routes: ....
        ),
      ),
    );
  }
}

這種寫法的重點就是 ChangeNotifierProvider 的 child 一定要先用 Builder 包起來,如:第 12 行。

Builder 這個 Widget 很單純,它會將他的 builder 所指到的 Widget,包在一個 StatelessWidget 內,會這樣做的原因通常就只有一個:為了讀取 context,糟糕,我們又遇到 context 這個怪物了,好吧,讓我們面對它,瞭解他。

什麼是 Context?the BuildContext

官方的解釋是:關於這個 widget 在 widget tree 的位置。

來來來,看懂的舉手...... 左看右看...... 沒人!還好不是只有我笨,大家都一樣。

看不懂只好自己讀書了,讀了一堆文章與網路討論後,我發現這個 context 好像是用來連結 widget 這個虛擬世界與真實的畫面的,好像每一個畫面都有一個獨立的 context,好像...... 只是,大家都說不清楚,完全一個瞎子摸象,各自表述⋯⋯ 這時,我突然頓悟,也許平台的細節並不重要,畢竟,Flutter 是一個平台,我們用平台的目的就是要簡潔及開發快速, 花時間去搞懂平台內部怎麼運作的,好像怪怪的,所以應該倒過來,我們來看看 context 用在哪裡,以下是 context 的一些用法:

  • context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
  • context.findAncestorStateOfType<ScaffoldState>();
  • context.dependOnInheritedWidgetOfExactType<MediaQuery>();
  • context.dependOnInheritedWidgetOfExactType<_LocalizationsScope>();
  • context.findRootAncestorStateOfType<NavigatorState>();
  • MediaQuery.of(context).size;

嗯,很有趣,歸納起來,context 對我們而言,其實好像就只是:

  1. 跟 widget 的父母互動:例如:找 root 啦,找父母的狀態啦,讀取 InheritedWidget 繼承的 state 等等 ⋯⋯
  2. 查詢真實的畫面大小。

第二點其實很少人使用,所以 context 的最主要用途都是第一點,而且大部分都是用 context 來讀取父母的狀態,就好像是我們學過的導航:Navigator.of(context).pushNamed('myRoute'),就是用它來讀取父母的 navigator 狀態,然後再推一個 route 進去。所以,

context 的重點就是:

  1. 紀錄父母的狀態
  2. 每一次 build 就會建立一個 context

我覺得目前知道這樣就夠了,還想要了解更多的話,這篇討論我覺得值得一讀,我上面的介紹也很多是參考他的發文。

那爲什麼在上面這段程式碼中,要使用 Builder 呢?其實第 16 行的 watch 就等於是 Provider.of<T>(context),沒有 Builder 的話 Provider.of 就讀不到上層 context 的內容了,


最簡潔的用法:不用 consumer

最常用的 consumer 其實可以被 context.watch<t>() 來取代,一樣會接收通知,而且程式碼更精簡,但是很奇怪,很少人用。

lib/main.dart:也是將 import 及 route 部分移除了,它們就跟前面的一樣,沒變。

import ...

void main() {
  return runApp(ChangeNotifierProvider<MyThemeMode>(
    create: (_) => new MyThemeMode(),
    child: MyApp(),
  ));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Happy Recorder',
      theme: themeCustomLight, // default theme
      darkTheme: themeCustomDark, // dark theme
      themeMode: context.watch<MyThemeMode>().getMode,
      //themeMode: Provider.of<MyThemeMode>(context, listen: true).getMode,
      initialRoute: '/',
      ...
    );
  }
}

你看看,這樣寫多精簡,又很清楚,我喜歡,只是在網路上,幾乎沒人用,我是決定就用它了。

  • 第 17、18 行:兩個寫法是一樣的,各位可以換換試試。

我最後是用:

context.watch<t>() 

但是網路上大家都用:

 Provider.of<t>(context, listen: true)

兩個是一樣的啦,用 Provider 時,listen 設成 true 就是 watch,設成 false 就是read,大家就選自己看的順眼的用吧,我是喜歡 watch 跟 read,字少。


效能與 ChangeNotifer 的限制

各位應該也注意到了,我們的 MyApp 是個 stateless widget,不是說 stateless 不會 rebuild 嗎?其實 stateless 在這以下的兩個情況下時,是會 rebuild 的:

  1. 它的父母 rebuild 了,
  2. 內有相關的 InheritedWidget 改變了,provider 就是繼承 InheritedWidge,所以也會 rebuild。

當你的 widget 常常會被 rebuild 時,你就要考慮優化效能了,方法有很多,常用的有:

  • 盡量減少被更新 rebuild 的 widget 數目(有點廢話,但是這是優化的核心思想),
  • 盡量使用 const widgets 及 const constructor,
  • 將 stateless widget 改成 stateful widget,這樣就可以 caching 常用的子樹,及使用 GlobalKeys,
  • 將大的 widget 打散,把常需要更新 rebuild widget 放在 widget 樹的末端,也就是樹葉,

不過啊,用說的都很容易,真正做起來並不簡單,牽一髮而動全身啊,這部分真的需要經驗,在一開始建立 widget 樹時,就最好能考慮到。

一般來說,ChangeNotifier 適合使用在 listeners 接聽數不多的場景,因為他的效能是接聽數的平方,當有三個接聽數時,就需要傳送通知 9 次了。

寫到這裡,再回頭想想 Flutter 的官方說 provider package 又簡單又好用時,突然間,我覺的我好笨,深受打擊,Provider 是不能說複雜,但是也不簡單啊,因為寫到這裡還只是介紹了 provider package 的冰山一角,provider package 不只有 ChangeNotifierProvider,它還有好多其他使用方式啊 ⋯⋯


Provider 的各種型態(用法)

各位知道 Provider 這個 package 有幾種用法嗎?不嚇你,請看下表,常常我們不過是就只要管理一個 app 的 state,用 provider 馬上就會遇到選擇障礙:

型態(用法) 用來做什麼?
Provider 分享 state 的內容,但不會 rebuild
ChangeNotifierProvider 分享 state 的內容及 rebuild,
state class with 'ChangeNotifier'
ListenableProvider 用法跟 ChangeNotifierProvider 一樣,
分享 state 的內容及 rebuild,
state class with 'Listenable',
官方說:你應該不會用這個,請使用
ChangeNotiferProvider。
ValueListenableProvider 當 state.value 的值改變時,
通知 rebuild 
StreamProvider 當 state 是 stream 時使用,
分享 state 的內容及 rebuild
FutureProvider 當 state 是 future 時使用,
分享 state 的內容及 rebuild
MultiProvider 使用兩個 states 以上時的美麗寫法
ProxyProvider Provider 但是有兩個以上的 states,
其中一個 state 依賴另一個 state。
ChangeNotifierProxyProvider ChangeNotifierProvider 但是有
兩個以上的 states,
其中一個 state 依賴另一個 state。
其他 ⋯⋯ 

還好,有人說只要:

  • 會用 ChangeNotifierProvider 就夠了,其他可以忽略,不用學;
  • 也許偶爾會用到 Provider 來純分享 state 不用 rebuild,但是機會不多;
  • Futures 及 Streams 可以放在 ChangeNotifierProvider 中,所以 FutureProvider 及 StreamProvider 也不需要;
  • MultiProvider 及 ProxyProvider 用的機會太少。

所以,鬆了一口氣,我們已經會用 ChangeNotifierProvider 了,可以算是學會 Provider 這個 package 了,太好了。

Lazy Loading

provider 基本上還沒有被使用時,它的 instance 是不會建立的,只有需要用到的第一次時,才會建立,這種行為在電腦的世界裡就叫做 Lazy loading,這是的好功能,可以省時又省記憶體,但是總有些時候你必須要 App 一啟動就把某個 provider instance 建立起來,這也很簡單,就加一個 Lazy: false 就好,如下:

MyProvider(
  create: (_) => Something(),
  lazy: false,
)


除錯 debug 

Provider 或是其他 state management 剛開發時常常不動作,一定會要 debug,還好,Flutter 內建的devTools 算好用,你可以選最右邊的 Provider tab 來查看 state 內容是不是有改變:

你也可以選 debugger 來設定 break point 追蹤程式執行到哪,也可以查看變數的值:

剛用 devTools 一定不熟悉,但是摸索一下,應該很快就能找到用法,真不會用就看官方文件吧,只是文件超多頁 ⋯⋯

除了 devTool 外,很多時候我會先用 print(),把它放在想要追蹤的點上,看看它有沒有被執行到,例如放在 lib/screen/my_home_page.dart 的 _MyHomePageState 之 build 裡:

@override
  Widget build(BuildContext context) {
    print('home');
    return Scaffold(

這樣執行時就可以在 Android Studio 的 console 把追蹤的點 print 出值來,又快又方便,還比較真實。事實上,就是上面這行放在 MyHomePage 的 print('home') 告訴了我一個大發現,當我們改變 ThemeMode 時,會照成 MyHomePage 多 rebuild 一到三次,大部分是多 rebuild 兩次,而且只有當「黑暗」與「明亮」真實有變換時,切換到系統如果真正的 ThemeMode 沒變是不會多 rebuild 的,我查了一下午,完全找不到原因,也找不到討論,只能想像是因為 Theme 也是 InheritedWidget 的一員,所以當 theme 改變時,它也呼叫了 rebuild,還好,Theme 改變不會常發生,不然效能會是大問題。

好啦,我們就先停在這裡,天啊,這篇文章有八千多字⋯⋯ Provider 一點也不簡單啊。


最後的程式碼,請自行參考本書:程式碼備份中的 Code milestone 4


想要知道怎麼將使用者的設定存起來嗎?我們在《Hive 實作:儲存使用者的喜好 - Hive 安裝、設定、起始與基本用法》會說明怎麼儲存使者的預設喜好。




喜歡作者的文章嗎?馬上按「關注」,當作者發佈新文章時,思書™就會 email 通知您。

思書是公開的寫作平台,創新的多筆名寫作方式,能用不同的筆名探索不同的寫作內容,無限寫作創意,如果您喜歡寫作分享,一定要來試試! 《 加入思書》

思書™是自由寫作平台,本文為作者之個人意見。


文章資訊

本文摘自:
Categories:
Tags:
Date:
Published: 2021/07/11 - Updated: 2021/10/18
Total: 8688 words


分享這篇文章:
關於作者

很久以前就是個「寫程式的」,其實,什麼程式都不熟⋯⋯
就,這會一點點,那會一點點⋯⋯




參與討論!
現在就加入《思書》,馬上參與討論!
《思書》是一個每個人的寫作與論壇平台,特有的隱私管理,用筆名來區隔你討論內容,讓你的討論更深入,而且免費。 趕快來試試!
還未加入《思書》? 現在就登錄! 已經加入《思書》── 登入


看看作者的其他文章


看看思書的其他文章



×
登入
申請帳號

需要幫助
關於思書

暗黑模式?
字體大小
成人內容未過濾
更改語言版本?