狀態(tài) (State) 管理介紹
當(dāng)你使用 Futter 進(jìn)行開發(fā)時,有時會需要在 app 的不同界面中颜骤,共享應(yīng)用程序的狀態(tài)唧喉,在這里你可以找到許多有用的方案以及一些可以深思的問題。
在接下來的文檔里忍抽,你將會學(xué)習(xí)一些基礎(chǔ)的狀態(tài)管理知識八孝。
狀態(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)用的當(dāng)前狀態(tài)。
當(dāng)你的 Flutter 應(yīng)用的狀態(tài)發(fā)生改變時(例如,用戶在設(shè)置界面中點擊了一個開關(guān)選項)孕惜,你改變了狀態(tài)愧薛,這會觸發(fā)用戶界面的重繪。去改變用戶界面本身是沒有必要的(例如 widget.setText )— 你改變了狀態(tài)衫画,那么用戶界面將重新構(gòu)建毫炉。
在 聲明式 UI 介紹 中你可以閱讀更多有關(guān)聲明式編程思維的信息。
聲明式的編程風(fēng)格有許多好處削罩。值得注意的是碘箍,用戶界面任何狀態(tài)的改變都只有一種編碼途徑。一旦給定任意狀態(tài)鲸郊,你就描述了用戶界面應(yīng)該長什么樣,并且它就是這樣货邓。
剛開始的時候秆撮,這種編碼風(fēng)格可能看起來不像命令式的那么直觀。這也是本章為什么出現(xiàn)在這的原因换况。
狀態(tài) (State) 管理介紹
短時 (ephemeral) 和應(yīng)用 (app) 狀態(tài)的區(qū)別
短時狀態(tài)
短時狀態(tài) (有時也稱 用戶界面(UI)狀態(tài) 或者 局部狀態(tài)) 是你可以完全包含在一個獨(dú)立 widget 中的狀態(tài)职辨。
這是一個有點兒模糊的定義,這里有幾個例子戈二。
- 一個
PageView
組件中的當(dāng)前頁面 - 一個復(fù)雜動畫中當(dāng)前進(jìn)度
- 一個
BottomNavigationBar
中當(dāng)前被選中的 tab
widget 樹中其他部分不需要訪問這種狀態(tài)。不需要去序列化這種狀態(tài),這種狀態(tài)也不會以復(fù)雜的方式改變鲁猩。
換句話說憔古,不需要使用狀態(tài)管理架構(gòu)(例如 ScopedModel, Redux)去管理這種狀態(tài)。你需要用的只是一個 StatefulWidget
鲜滩。
在下方你可以看到一個底部導(dǎo)航欄中當(dāng)前被選中的項目是如何被被保存在 _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ù)雜度和限制梅尤,你的團(tuán)隊之前的經(jīng)驗以及其他方面柜思。請繼續(xù)閱讀岩调。
沒有明確的規(guī)則
需要說明的是,你可以使用 State 和 setState() 管理你的應(yīng)用中的所有狀態(tài)赡盘。實際上Flutter團(tuán)隊在很多簡單的示例程序(包括你每次使用 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)。比如蔬啡,剛開始你認(rèn)為一些狀態(tài)是短時狀態(tài)诲侮,但隨著應(yīng)用不斷增加功能,有些狀態(tài)需要被改變?yōu)閼?yīng)用狀態(tài)箱蟆。
因此沟绪,請有保留地遵循以下這張流程圖:
當(dāng)我們就 React 的 setState 和 Redux 的 Store 哪個好這個問題問 Redux 的作者 Dan Abramov 時, 他如此回答:
“經(jīng)驗原則是: 選擇能夠減少麻煩的方式”
總之,在任何 Flutter 應(yīng)用中都存在兩種概念類型的狀態(tài)空猜,短時狀態(tài)經(jīng)常被用于一個單獨(dú) widget 的本地狀態(tài)绽慈,通常使用 State
和 setState()
來實現(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 和教程裹虫。
示例
為了演示效果肿嘲,我們實現(xiàn)下面這個簡單應(yīng)用。
程序有三個獨(dú)立的頁面:一個登陸提示筑公,一個類別頁面雳窟,一個購物車頁面(分別用 MyLoginScreen, MyCatalog,MyCart widget 來展示)匣屡。雖然看上去是一個購物應(yīng)用程序封救,但是你也可以和社交網(wǎng)絡(luò)應(yīng)用類比(把類別頁面替換成朋友圈,把購物車替換成關(guān)注的人)捣作。
類別頁面包含一個自定義的 app bar (MyAppBar) 以及一個包含元素列表的可滑動的視圖 (MyListItems)誉结。
這是應(yīng)用程序?qū)?yīng)的可視化的 widget 樹。
所以我們有至少 6 個 Widget 的子類券躁。他們中有很多需要訪問一些全局的狀態(tài)搓彻。比如,MyListItem 會被添加到購物車中嘱朽。但是它可能需要檢查和自己相同的元素是否已經(jīng)被添加到購物車中。
這里我們出現(xiàn)了第一個問題:我們把當(dāng)前購物車的狀態(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.
}
你可能需要考慮當(dāng)前 UI 的狀態(tài)党觅,然后把最新的數(shù)據(jù)添加進(jìn)去。但是這樣的方式很難避免出現(xiàn) bug斋泄。
在 Flutter 中杯瞻,每次當(dāng) 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 的生命周期中宅粥。當(dāng)它發(fā)生改變的時候,它會從上層重構(gòu) MyCart 电谣。因為這個機(jī)制秽梅,所以 MyCart 無需考慮生命周期的問題—它只需要針對 contents 聲明所需顯示內(nèi)容即可。當(dāng)內(nèi)容發(fā)生改變的時候剿牺,舊的 MyCart widget 就會消失企垦,完全被新的 widget 替代。
這就是我們所說的 widget 是不可變的晒来。因為它們會直接被替換钞诡。
現(xiàn)在我們知道在哪里放置購物車的狀態(tài),接下來看一下如何讀取該狀態(tài)湃崩。
讀取狀態(tài)
當(dāng)用戶點擊類別頁面中的一個元素荧降,它會被添加到購物車?yán)铩H欢?dāng)購物車在 widget 樹中攒读,處于 MyListItem 的層級之上時朵诫,又該如何訪問狀態(tài)呢?
一個簡單的實現(xiàn)方法是提供一個回調(diào)函數(shù)薄扁,當(dāng) 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)來說你需要在不同的地方進(jìn)行修改,可能需要大量傳遞回調(diào)函數(shù)—日缨。
幸運(yùn)的是 Flutter 在 widget 中存在一種機(jī)制钱反,能夠為其子孫節(jié)點提供數(shù)據(jù)和服務(wù)。(換言之匣距,不僅僅是它的子節(jié)點诈铛,所有在它下層的 widget 都可以)。就像你所了解的墨礁, Flutter 中的 Everything is a Widget?幢竹。這里的機(jī)制也是一種 widget —InheritedWidget, InheritedNotifier, InheritedModel等等。我們這里不會詳細(xì)解釋他們恩静,因為這些 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. 購物車?yán)锏纳唐芬晥D無法改變
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()
囱稽。當(dāng)模型發(fā)生改變并且需要更新 UI 的時候可以調(diào)用該方法。而剩下的代碼就是 CartModel
和它本身的業(yè)務(wù)邏輯二跋。
ChangeNotifier
是 flutter: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捉片。當(dāng) ChangeNotifier 發(fā)生變化的時候會調(diào)用 builder 這個函數(shù)。(換言之汞舱,當(dāng)你在模型中調(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 下面有一個龐大的子樹,當(dāng)模型發(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 按鈕能夠清空購物車的所有商品镊靴。它不需要顯示購物車?yán)锏膬?nèi)容嫉髓,只需要調(diào)用 clear() 方法观腊。
我們可以使用 Consumer<CartModel> 來實現(xiàn)這個效果,不過這么實現(xiàn)有點浪費(fèi)算行。因為我們讓整體框架重構(gòu)了一個無需重構(gòu)的 widget梧油。
所以這里我們可以使用 Provider.of,并且將 listen 設(shè)置為 false州邢。
Provider.of<CartModel>(context, listen: false).add(item);
在 build 方法中使用上面的代碼儡陨,當(dāng) notifyListeners 被調(diào)用的時候,并不會使 widget 被重構(gòu)量淌。
把代碼集成在一起
你可以在文章中 查看這個示例骗村。如果你想?yún)⒖忌晕⒑唵我稽c的示例,可以看看 Counter 應(yīng)用程序是如何 基于 provider
實現(xiàn)的呀枢。
如果你已經(jīng)學(xué)會了并且準(zhǔn)備使用 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