Flutter-狀態(tài) (State) 管理

狀態(tài) (State) 管理介紹

當你使用 Futter 進行開發(fā)時,有時會需要在 app 的不同界面中,共享應(yīng)用程序的狀態(tài),在這里你可以找到許多有用的方案以及一些可以深思的問題。

在接下來的文檔里,你將會學(xué)習(xí)一些基礎(chǔ)的狀態(tài)管理知識。


圖片.png

狀態(tài)管理中的聲明式編程思維

如果你是從命令式框架(例如 Android SDK 或者 iOS UIKit)轉(zhuǎn)到 Flutter 應(yīng)用,那么,你需要開始從一個新的角度來考慮 app 開發(fā)了。

因此,很多在命令式框架下的假設(shè)可能并不適用于 Flutter。例如,在 Flutter 應(yīng)用中這是可行的,重新構(gòu)建你的部分界面,而不是直接去修改它。如果有需要的話,F(xiàn)lutter 甚至可以在每一幀上都很快做到這點。

Flutter 應(yīng)用是 聲明式 的,這也就意味著 Flutter 構(gòu)建的用戶界面就是應(yīng)用的當前狀態(tài)。


ui-equals-function-of-state.png

當你的 Flutter 應(yīng)用的狀態(tài)發(fā)生改變時(例如,用戶在設(shè)置界面中點擊了一個開關(guān)選項),你改變了狀態(tài),這會觸發(fā)用戶界面的重繪。去改變用戶界面本身是沒有必要的(例如 widget.setText )— 你改變了狀態(tài),那么用戶界面將重新構(gòu)建。

聲明式 UI 介紹 中你可以閱讀更多有關(guān)聲明式編程思維的信息。

聲明式的編程風格有許多好處。值得注意的是,用戶界面任何狀態(tài)的改變都只有一種編碼途徑。一旦給定任意狀態(tài),你就描述了用戶界面應(yīng)該長什么樣,并且它就是這樣。

剛開始的時候,這種編碼風格可能看起來不像命令式的那么直觀。這也是本章為什么出現(xiàn)在這的原因。
狀態(tài) (State) 管理介紹

短時 (ephemeral) 和應(yīng)用 (app) 狀態(tài)的區(qū)別

短時狀態(tài)

短時狀態(tài) (有時也稱 用戶界面(UI)狀態(tài) 或者 局部狀態(tài)) 是你可以完全包含在一個獨立 widget 中的狀態(tài)。

這是一個有點兒模糊的定義,這里有幾個例子。

  • 一個 PageView 組件中的當前頁面
  • 一個復(fù)雜動畫中當前進度
  • 一個 BottomNavigationBar 中當前被選中的 tab

widget 樹中其他部分不需要訪問這種狀態(tài)。不需要去序列化這種狀態(tài),這種狀態(tài)也不會以復(fù)雜的方式改變。

換句話說,不需要使用狀態(tài)管理架構(gòu)(例如 ScopedModel, Redux)去管理這種狀態(tài)。你需要用的只是一個 StatefulWidget

在下方你可以看到一個底部導(dǎo)航欄中當前被選中的項目是如何被被保存在 _MyHomepageState 類的 _index 變量中。在這個例子中, _index 是一個短時狀態(tài)。

class MyHomepage extends StatefulWidget {
  @override
  _MyHomepageState createState() => _MyHomepageState();
}

class _MyHomepageState extends State<MyHomepage> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: _index,
      onTap: (newIndex) {
        setState(() {
          _index = newIndex;
        });
      },
      // ... items ...
    );
  }
}

在這里,使用 setState() 和一個在有狀態(tài) Widget 的 State 類中的變量是很自然的。你的 app 中的其他部分不需要訪問 _index。這個變量只會在 MyHomepage widget 中改變。而且,如果用戶關(guān)閉并重啟這個 app,你不會介意 _index 重置回0.

應(yīng)用狀態(tài)

如果你想在你的應(yīng)用中的多個部分之間共享一個非短時的狀態(tài),并且在用戶會話期間保留這個狀態(tài),我們稱之為應(yīng)用狀態(tài)(有時也稱共享狀態(tài))。

應(yīng)用狀態(tài)的一些例子:

  • 用戶選項
  • 登錄信息
  • 一個社交應(yīng)用中的通知
  • 一個電商應(yīng)用中的購物車
  • 一個新聞應(yīng)用中的文章已讀/未讀狀態(tài)

為了管理應(yīng)用狀態(tài),你需要研究你的選項。你的選擇取決于你的應(yīng)用的復(fù)雜度和限制,你的團隊之前的經(jīng)驗以及其他方面。請繼續(xù)閱讀。

沒有明確的規(guī)則

需要說明的是,你可以使用 State 和 setState() 管理你的應(yīng)用中的所有狀態(tài)。實際上Flutter團隊在很多簡單的示例程序(包括你每次使用 flutter create 命令創(chuàng)建的初始應(yīng)用)中正是這么做的。

也可以用另外一種方式。比如,在一個特定的應(yīng)用中,你可以指定底部導(dǎo)航欄中被選中的項目不是一個短時狀態(tài)。你可能需要在底部導(dǎo)航欄類的外部來改變這個值,并在對話期間保留它。在種情況下 _index 就是一個應(yīng)用狀態(tài)。

沒有一個明確、普遍的規(guī)則來區(qū)分一個變量屬于短時狀態(tài)還是應(yīng)用狀態(tài),有時你不得不在此之間重構(gòu)。比如,剛開始你認為一些狀態(tài)是短時狀態(tài),但隨著應(yīng)用不斷增加功能,有些狀態(tài)需要被改變?yōu)閼?yīng)用狀態(tài)。

因此,請有保留地遵循以下這張流程圖:


ephemeral-vs-app-state.png

當我們就 React 的 setState 和 Redux 的 Store 哪個好這個問題問 Redux 的作者 Dan Abramov 時, 他如此回答:

“經(jīng)驗原則是: 選擇能夠減少麻煩的方式

總之,在任何 Flutter 應(yīng)用中都存在兩種概念類型的狀態(tài),短時狀態(tài)經(jīng)常被用于一個單獨 widget 的本地狀態(tài),通常使用 StatesetState() 來實現(xiàn)。其他的是你的應(yīng)用應(yīng)用狀態(tài),在任何一個 Flutter 應(yīng)用中這兩種狀態(tài)都有自己的位置。如何劃分這兩種狀態(tài)取決于你的偏好以及應(yīng)用的復(fù)雜度。

簡單的應(yīng)用狀態(tài)管理

現(xiàn)在大家已經(jīng)了解了 聲明式的編程思維短時 (ephemeral) 與應(yīng)用 (app) 狀態(tài) 之間的區(qū)別,現(xiàn)在可以學(xué)習(xí)如何管理簡單的全局應(yīng)用狀態(tài)。

在這里,我們打算使用 provider package。如果你是 Flutter 的初學(xué)者,而且也沒有很重要的理由必須選擇別的方式來實現(xiàn)(Redux、Rx、hooks 等等),那么這就是你應(yīng)該入門使用的。provider 非常好理解而且不需要寫很多代碼。它也會用到一些在其它實現(xiàn)方式中用到的通用概念。

即便如此,如果你已經(jīng)從其它響應(yīng)式框架上積累了豐富的狀態(tài)管理經(jīng)驗的話,那么可以在 狀態(tài) (State) 管理參考 中找到相關(guān)的 package 和教程。

示例

圖片.png

為了演示效果,我們實現(xiàn)下面這個簡單應(yīng)用。
程序有三個獨立的頁面:一個登陸提示,一個類別頁面,一個購物車頁面(分別用 MyLoginScreen, MyCatalog,MyCart widget 來展示)。雖然看上去是一個購物應(yīng)用程序,但是你也可以和社交網(wǎng)絡(luò)應(yīng)用類比(把類別頁面替換成朋友圈,把購物車替換成關(guān)注的人)。

類別頁面包含一個自定義的 app bar (MyAppBar) 以及一個包含元素列表的可滑動的視圖 (MyListItems)。

這是應(yīng)用程序?qū)?yīng)的可視化的 widget 樹。


simple-widget-tree.png

所以我們有至少 6 個 Widget 的子類。他們中有很多需要訪問一些全局的狀態(tài)。比如,MyListItem 會被添加到購物車中。但是它可能需要檢查和自己相同的元素是否已經(jīng)被添加到購物車中。

這里我們出現(xiàn)了第一個問題:我們把當前購物車的狀態(tài)放在哪合適呢?

提高狀態(tài)的層級

在 Flutter 中,有必要將存儲狀態(tài)的對象置于 widget 樹中對應(yīng) widget 的上層。

為什么呢?在類似 Flutter 的聲明式框架中,如果你想要修改 UI,那么你需要重構(gòu)它。并沒有類似 MyCart.updateWith(somethingNew) 的簡單調(diào)用方法。換言之,你很難通過外部調(diào)用方法修改一個 widget。即便你自己實現(xiàn)了這樣的模式,那也是和整個框架不相兼容。

// BAD: DO NOT DO THIS
void myTapHandler() {
  var cartWidget = somehowGetMyCartWidget();
  cartWidget.updateWith(item);
}

即使你實現(xiàn)了上面的代碼,也得處理 MyCart widget 中的代碼:

// BAD: DO NOT DO THIS
Widget build(BuildContext context) {
  return SomeWidget(
    // The initial state of the cart.
  );
}

void updateWith(Item item) {
  // Somehow you need to change the UI from here.
}

你可能需要考慮當前 UI 的狀態(tài),然后把最新的數(shù)據(jù)添加進去。但是這樣的方式很難避免出現(xiàn) bug。

在 Flutter 中,每次當 widget 內(nèi)容發(fā)生改變的時候,你就需要構(gòu)造一個新的。你會調(diào)用 MyCart(contents)(構(gòu)造函數(shù)),而不是 MyCart.updateWith(somethingNew)(調(diào)用方法)。因為你只能通過父類的 build 方法來構(gòu)建新 widget,如果你想修改 contents,就需要調(diào)用 MyCart 的父類甚至更高一級的類。

// GOOD
void myTapHandler(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  cartModel.add(item);
}

這里 MyCart 可以在各種版本的 UI 中調(diào)用同一個代碼路徑。

// GOOD
Widget build(BuildContext context) {
  var cartModel = somehowGetMyCartModel(context);
  return SomeWidget(
    // Just construct the UI once, using the current state of the cart.
    // ···
  );
}

在我們的例子中,contents會存在于 MyApp 的生命周期中。當它發(fā)生改變的時候,它會從上層重構(gòu) MyCart 。因為這個機制,所以 MyCart 無需考慮生命周期的問題—它只需要針對 contents 聲明所需顯示內(nèi)容即可。當內(nèi)容發(fā)生改變的時候,舊的 MyCart widget 就會消失,完全被新的 widget 替代。


simple-widget-tree-with-cart.png

這就是我們所說的 widget 是不可變的。因為它們會直接被替換。

現(xiàn)在我們知道在哪里放置購物車的狀態(tài),接下來看一下如何讀取該狀態(tài)。

讀取狀態(tài)

當用戶點擊類別頁面中的一個元素,它會被添加到購物車里。然而當購物車在 widget 樹中,處于 MyListItem 的層級之上時,又該如何訪問狀態(tài)呢?

一個簡單的實現(xiàn)方法是提供一個回調(diào)函數(shù),當 MyListItem 被點擊的時候可以調(diào)用。Dart 的函數(shù)都是 first class 對象,所以你可以以任意方式傳遞它們。所以在 MyCatalog 里你可以使用下面的代碼:

@override
Widget build(BuildContext context) {
  return SomeWidget(
    // Construct the widget, passing it a reference to the method above.
    MyListItem(myTapCallback),
  );
}

void myTapCallback(Item item) {
  print('user tapped on $item');
}

這段代碼是沒問題的,但是對于全局應(yīng)用狀態(tài)來說你需要在不同的地方進行修改,可能需要大量傳遞回調(diào)函數(shù)—。

幸運的是 Flutter 在 widget 中存在一種機制,能夠為其子孫節(jié)點提供數(shù)據(jù)和服務(wù)。(換言之,不僅僅是它的子節(jié)點,所有在它下層的 widget 都可以)。就像你所了解的, Flutter 中的 Everything is a Widget?。這里的機制也是一種 widget —InheritedWidget, InheritedNotifier, InheritedModel等等。我們這里不會詳細解釋他們,因為這些 widget 都太底層。

我們會用一個 package 來和這些底層的 widget 打交道,就是 provider package 。

provider package 中,你無須關(guān)心回調(diào)或者 InheritedWidgets。但是你需要理解三個概念:

  • ChangeNotifier
  • ChangeNotifierProvider
  • Consumer

ChangeNotifier

ChangeNotifier 是 Flutter SDK 中的一個簡單的類。它用于向監(jiān)聽器發(fā)送通知。換言之,如果被定義為 ChangeNotifier,你可以訂閱它的狀態(tài)變化。(這和大家所熟悉的觀察者模式相類似)。

在 provider 中,ChangeNotifier 是一種能夠封裝應(yīng)用程序狀態(tài)的方法。對于特別簡單的程序,你可以通過一個 ChangeNotifier 來滿足全部需求。在相對復(fù)雜的應(yīng)用中,由于會有多個模型,所以可能會有多個 ChangeNotifier。(不是必須得把 ChangeNotifier 和 provider 結(jié)合起來用,不過它確實是一個特別簡單的類)。

在我們的購物應(yīng)用示例中,我們打算用 ChangeNotifier 來管理購物車的狀態(tài)。我們創(chuàng)建一個新類,繼承它,像下面這樣:

class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart. 內(nèi)部的,購物車的私有狀態(tài)
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart. 購物車里的商品視圖無法改變

  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42). 現(xiàn)在全部商品的總價格(假設(shè)他們加起來 $42)
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This is the only way to modify the cart from outside. 將 [item] 添加到購物車。這是唯一一種能從外部改變購物車的方法。
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

唯一一行和 ChangeNotifier 相關(guān)的代碼就是調(diào)用 notifyListeners()。當模型發(fā)生改變并且需要更新 UI 的時候可以調(diào)用該方法。而剩下的代碼就是 CartModel 和它本身的業(yè)務(wù)邏輯。

ChangeNotifierflutter:foundation 的一部分,而且不依賴 Flutter 中任何高級別類。測試起來非常簡單(你都不需要使用 widget 測試)。比如,這里有一個針對 CartModel 簡單的單元測試:

test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
});

ChangeNotifierProvider

ChangeNotifierProvider widget 可以向其子孫節(jié)點暴露一個 ChangeNotifier 實例。它屬于 provider package。

我們已經(jīng)知道了該把 ChangeNotifierProvider 放在什么位置:在需要訪問它的 widget 之上。在 CartModel 里,也就意味著將它置于 MyCart 和 MyCatalog 之上。

你肯定不愿意把 ChangeNotifierProvider 放的級別太高(因為你不希望破壞整個結(jié)構(gòu))。但是在我們這里的例子中,MyCart 和 MyCatalog 之上只有 MyApp。

void main() {
  runApp(
    ChangeNotifierProvider(
      builder: (context) => CartModel(),
      child: MyApp(),
    ),
  );
}

請注意我們定義了一個 builder 來創(chuàng)建一個 CartModel 的實例。ChangeNotifierProvider 非常聰明,它 不會 重復(fù)實例化 CartModel,除非在個別場景下。如果該實例已經(jīng)不會再被調(diào)用,ChangeNotifierProvider 也會自動調(diào)用 CartModel 的 dispose() 方法。

如果你想提供更多狀態(tài),可以使用 MultiProvider:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(builder: (context) => CartModel()),
        Provider(builder: (context) => SomeOtherClass()),
      ],
      child: MyApp(),
    ),
  );
}

Consumer

現(xiàn)在 CartModel 已經(jīng)通過 ChangeNotifierProvider 在應(yīng)用中與 widget 相關(guān)聯(lián)。我們可以開始調(diào)用它了。

完成這一步需要通過 Consumer widget。

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text("Total price: ${cart.totalPrice}");
  },
);

我們必須指定要訪問的模型類型。在這個示例中,我們要訪問 CartModel 那么就寫上 Consumer<CartModel>。

Consumer widget 唯一必須的參數(shù)就是 builder。當 ChangeNotifier 發(fā)生變化的時候會調(diào)用 builder 這個函數(shù)。(換言之,當你在模型中調(diào)用 notifyListeners() 時,所有和 Consumer 相關(guān)的 builder 方法都會被調(diào)用。)

builder 在被調(diào)用的時候會用到三個參數(shù)。第一個是 context。在每個 build 方法中都能找到這個參數(shù)。

builder 函數(shù)的第二個參數(shù)是 ChangeNotifier 的實例。它是我們最開始就能得到的實例。你可以通過該實例定義 UI 的內(nèi)容。

第三個參數(shù)是 child,用于優(yōu)化目的。如果 Consumer 下面有一個龐大的子樹,當模型發(fā)生改變的時候,該子樹 并不會 改變,那么你就可以僅僅創(chuàng)建它一次,然后通過 builder 獲得該實例。

return Consumer<CartModel>(
  builder: (context, cart, child) => Stack(
        children: [
          // Use SomeExpensiveWidget here, without rebuilding every time.
          child,
          Text("Total price: ${cart.totalPrice}"),
        ],
      ),
  // Build the expensive widget here.
  child: SomeExpensiveWidget(),
);

最好能把 Consumer 放在 widget 樹盡量低的位置上。你總不希望 UI 上任何一點小變化就全盤重新構(gòu)建 widget 吧。

// DON'T DO THIS 別這么寫
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);

換成:

// 這么寫
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

有的時候你不需要模型中的 數(shù)據(jù) 來改變 UI,但是你可能還是需要訪問該數(shù)據(jù)。比如,ClearCart 按鈕能夠清空購物車的所有商品。它不需要顯示購物車里的內(nèi)容,只需要調(diào)用 clear() 方法。

我們可以使用 Consumer<CartModel> 來實現(xiàn)這個效果,不過這么實現(xiàn)有點浪費。因為我們讓整體框架重構(gòu)了一個無需重構(gòu)的 widget。

所以這里我們可以使用 Provider.of,并且將 listen 設(shè)置為 false。

Provider.of<CartModel>(context, listen: false).add(item);

在 build 方法中使用上面的代碼,當 notifyListeners 被調(diào)用的時候,并不會使 widget 被重構(gòu)。

把代碼集成在一起

你可以在文章中 查看這個示例。如果你想?yún)⒖忌晕⒑唵我稽c的示例,可以看看 Counter 應(yīng)用程序是如何 基于 provider 實現(xiàn)的。

如果你已經(jīng)學(xué)會了并且準備使用 provider 的時候,別忘了先在 pubspec.yaml 中添加相應(yīng)的依賴。

name: my_name
description: Blah blah blah.

# ...

dependencies:
  flutter:
    sdk: flutter

  provider: ^3.0.0

dev_dependencies:
  # ...
現(xiàn)在你可以 import 'package:provider/provider.dart';,開始寫代碼吧。

狀態(tài) (State) 管理參考

https://flutter.cn/docs/development/data-and-backend/state-mgmt/options

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容