前面的文章我們已經簡單的介紹了 InheritedWidget
以及一些主流的狀態管理插件。本篇我們進一步分析各自的優缺點以及稍微深入學習它們的使用。
在選擇該使用哪個插件時,pub.dev 提供的數據非常值得參考:
名稱 | LIKES | PUB POINTS | POPULARITY | Flutter Favorite |
---|---|---|---|---|
Riverpod | ~2360 | 160 | 100% | |
provider | ~10010 | 160 | 100% | v |
BLoC | ~2789 | 160 | 100% | v |
GetX | ~14371 | 150 | 100% | |
get_it 和 watch_it | ~4036 | 160 | 100% |
隨著開發的應用功能越來越多我們幾乎需要在不同畫面之間或者組件之間的分享狀態。舉例來說登入之後的會員相關資料,商品和購物車等等。
而這些資料傳遞的機制和你直接開發 Android 或 iOS 原生的概念和機制並不相同,例如 Flutter 允許重新建構渲染局部畫面而不是「修改」。Flutter 的宣告式風格意味著 Flutter 可以基於當前的狀態反應 UI。
例如我們在使用者資料設定頁面點擊了一個選項開關,其實就是變更狀態然後觸發畫面重新渲染,這裡沒有命令式的 UI 變更,改變的是狀態,然後 UI 重新構建。
例如下面的這種操作就屬於命令式
public void onClick(View v) {
button.setText("已點擊");
button.setBackgroundColor(Color.RED);
}
首先,我們說到「狀態」時指的是當應用程式執行時,儲存在記憶體中的資料。包含資源(圖片、字體),變數,動畫狀態等。而狀態大至上分成 2 種類型臨時局部和全域。
局部狀態通常是單一組件中的狀態,例如當前畫面中組件的狀態。換句話說,對於這種類型的狀態,我們不需要使用狀態管理技術。我們所需要的只是一個 StatefulWidget
。
全域狀態是一種非局部的狀態,我們希望在應用程式的多個地方共用它們,例如使用者登入資料,購物車的資料。
在 Flutter 中內建已經支援了 InheritedWidget
來共用狀態,但遇到跨頁面的情況,處理起來依舊不是很容易。因此,開發社群出現了各種狀態管理套件來解決這個問題。
如果是 Flutter 初學者,還不明白也沒有太多理由和動機選擇其他插件時。建議可以選擇 Provider 開始。Provider 非常容易理解,且使用起來程式碼精簡。其實作的概念也常被其他插件使用。
舉例來說我們的購物應用程式有兩個頁面 Catalog
和 Cart
。假設這個範例中我們從 MyApp
往下有 Catalog
和 Cart
頁面組件,而 Catalog
下面就是一般的 AppBar
和 ListItem
。其中它們都須存取狀態,比如 ListItem
需要可以被加入購物車,同時在購物車也要可以看到項目。
在 Flutter 中,通常會將狀態放置在使用組件的上層。
為什麼?在像 Flutter 這類型宣告風格的框架中,如果我們希望變更介面,那麼就須重新構建渲染。並沒有簡單的方式可以實作如 Cart.updateWith()
的方式。換句話說,就是沒有從外部命令式的操作,如果你自己架構這種方式,那麼就會和框架的概念衝突。
// 不好,請不要這麼做
void tapHandler() {
var cart = getCartWidget();
cart.updateWith(item);
}
即便你想辦法實作出了上面的方式,你還需要在 Cart
組件中進行對應的處理
// 不好,請不要這麼做
Widget build(BuildContext context) {
return SomeWidget(...)
}
void updateWith(Item item) {
// 須對應操作變更 UI
}
還需要考慮目前的狀態以及套用新的資料,如此程式很快就會變得很複雜。
在 Flutter 中,每次內容有變更,都會重新渲染構建新組件,即 Cart()
而不是依靠 Cart.updateWith()
,如果需要修改內容,我們只能在上層組件的 build
方法中構建新組件,也就是內容須在結構上層。簡單來說就是狀態須在 Cart
上層結構
// OK, 單純處理狀態
void tapHandler(BuildContext context) {
var cartModel = getCartModel(context);
cartModel.add(item);
}
現在 Cart
單純只需要專注在 UI 部分
Widget build(BuildContext context) {
var cartModel getCartModel(context);
return SomeWidget(...);
}
在上面的範例,狀態內容儲存在 MyApp
。每當改變,就會重建 Cart
。因此,Cart
不用擔心生命週期的問題,單純就是顯示得到的狀態即可。每當狀態變更就是整個重新建立替換舊的。
這個概念和 React 不可變 Immutable 是非常相似的。
當使用者點擊 Catalog
中的項目時,我們會將其加入購物車,但由於購物車的結構階層在 ListItem
之上。
因此有過 React 開發經驗的人很直覺的會想到,那我們就由上層提供 callback
給項目組件使用。Dart 的函式也屬於 first-class 也就是可以和數字、字串等一樣直接被使用,因此我們可以直接將其傳遞到其他地方。Catalog
可以實作如下:
@override
Widget build(BuildContext context) {
return SomeWidget(
ListItem(tapCallback),
);
}
void tapCallback(Item item) {
print('user tapped on $item');
}
上面的作法是可行的,但對於一個應用程式的狀態我們通常會需要在許多不同的地方修改變更,你可以到處傳遞 callback 很快程式碼就會快速膨脹。
幸好 Flutter 支援向後代傳遞資料的機制,例如我們前面提到的 InheritedWidget
,還有 InheritedNotifier
,InheritedModel
等等。這裡我們不會詳細介紹它們,主要是它們都屬於比較低層的機制。
於是,我們要介紹的 provider
登場,它幫我們簡化了那些底層組件的使用。
首先我們須安裝套件
$ flutter pub add provider
後續可以匯入 import 'package:provider/provider.dart';
。使用 provider
後,我們就不用在擔心 InheritedWidget
和 callback。取而代之的是我們需要學習理解 3 個概念:
ChangeNotifier
是 Flutter SDK 中的一個簡單的類別,我們可以訂閱 ChangeNotifier
那麼它就會向我們通知「變更」。屬於一種觀察者模式的形式。
在 provider
中,ChangeNotifier
是封裝應用程式狀態的一種方式。對於單純的應用程式,只需要一個 ChangeNotifier
。比較複雜的情況則會需要多個 ChangeNotifier
。這裡我們只是先介紹關於 ChangeNotifier
的概念,但實際上如果使用 provider
是不需要使用 ChangeNotifier
。
以上面購物車的案例來說,我們想要在 ChangeNotifier
管理購物車的狀態,則可以建立一個資料模型
import 'dart:collection';
import 'package:flutter/foundation.dart';
class CartModel extends ChangeNotifier {
final List<Item> _items = [];
UnmodifiableListView<Item> get items => UnmodifiableListView(_items);
int get totalPrice => _items.fold(0, (sum, item) => sum + item.price);
void add(Item item) {
_items.add(item);
// 重點,通知所有監聽者狀態已經改變
notifyListeners();
}
void remove(Item item) {
_items.remove(item);
notifyListeners();
}
void removeAll() {
_items.clear();
notifyListeners();
}
}
class Item {
final String name;
final double price;
Item({required this.name, required this.price});
}
關於使用 ChangeNotifier
,唯一須知道的重點就是呼叫 notifyListeners()
,在資料發送變更之後呼叫,觸發 UI 介面的更新。
ChangeNotifier
在 flutter:foundation
中,如同我們上面匯入的,其並沒有相依其他 Flutter 高階類別。
ChangeNotifierProvider
是 provider
套件提供的一個組件,其主要功能是向其後代組件提供一個 ChangeNotifier
的物件實例。這個機制使得狀態管理變得直觀簡單。
有了這個概念我們大概了解應該把 ChangeNotifierProvider
放在哪;也就是需要存取的組件上層,以上面的例子來說就是 Catalog
和 Cart
組件之上。有了這個概念你可能會思考是不是那就放在最上層結構就好,但這樣可能會污染全域。最佳實作是將 ChangeNotifierProvider
放到剛好覆蓋全部要使用該狀態的組件的階層。
MyApp
└── ChangeNotifierProvider<CartModel>
└── HomePage
├── Catalog(需要存取 CartModel)
└── Cart(需要存取 CartModel)
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CartModel(),
child: const MyApp(),
),
);
}
我們定義了一個 create
其概念就是 builder
來建立 CartModel
。 ChangeNotifierProvider
會自動處理除非必要,不然不會重新建立 CartModel
。另外,當不再需要此資源的時候會自動 dispose()
。
如果你需要提供多個物件
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => CartModel()),
Provider(create: (context) => SomeOtherClass()),
],
child: const MyApp(),
),
);
}
ChangeNotifierProvider
和 Provider
在功能上有一些差異,主要在於它們如何管理狀態和通知 UI 更新。
簡單說 ChangeNotifierProvider
須搭配 ChangeNotifier
而 Provider
不需要:
class Counter {
int value = 0;
void increment() {
value++;
}
}
void main() {
runApp(
Provider(
create: (context) => Counter(),
child: const MyApp(),
),
);
}
現在我們通過 ChangeNotifierProvider
加入了 CartModel
,那麼該如何使用狀態呢;通過 Consumer
組件:
return Consumer<CartModel>(
builder: (context, cart, child) {
return Text('金額: ${cart.totalPrice}');
},
);
我們必須指定希望存取的型別,這個範例中就是 CartModel
,如果沒有設定泛型則 provider
不知道該如何存取。
Consumer
組件必要的參數為 builder
,其為一個函式,每當 ChangeNotifier
通知變更時執行,也就是 notifyListeners()
呼叫的時候執行。
這個 builder
函式有三個引數,第一個是 context
,第二個是 ChangeNotifier
的物件實例也就是存取狀態的地方。第三個 child
是為了優化性能。如果放進 Consumer
的是一個比較大型耗費效能的組件結構,可能遇到狀態變更時,UI 沒發生變化時使用:
return Consumer<CartModel>(
builder: (context, cart, child) => Stack(
children: [
if (child != null) child,
Text("金額 ${cart.totalPrice}"),
],
),
child: ExpensiveWidget(),
)
同樣的 Consumer
盡可能也不要放置到太高階層,否則很容易造成大量的重新渲染問題。
有時候,我們希望可以存取 CartModel
,但我們的局部 UI 不需要根據資料模型來更新 UI 。什麼意思?比如說「清除購物車」按鈕,按鈕本身不需要根據 CartModel
的變化而重新渲染,我們只希望使用 CartModel.removeAll()
。我們當然還是可以使用 Consumer<CartModel>
但那樣會造成效能浪費,也就是重新渲染一些本不需要重新渲染的組件,如我們這裡說的按鈕。這就是 Provider.of
配合 listen: false
參數發揮作用的場景。使用 Provider.of<CartModel>(context, listen: false)
讓我們可以我們存取 CartModel
而不會建立這種相依關係。
Provider.of<CartModel>(context, listen: false).removeAll();
到此我們快速的概覽了 Provider 幾個重要的使用方式。
如果你是 Flutter 初學者,那麼建議先掌握 provider
這種狀態管理,其開發工具也是非常容易使用。而它的缺點就是對於粒度的控制,很容易造成額外不需要的渲染。另外也不太適合擴展。
BLoC(Business Logic Component)也是一套狀態管理,其核心概念就是分離表現層和商業邏輯,使用事件驅動的方式管理應用狀態。屬於響應式風格,概念上類似於 Rx 、Flux,實作上支援了多元的方式較為複雜。
BLoC 主要有 3 個核心概念:UI 觸發「事件」、「狀態」處理每個時間點的資料快照、Bloc
負責處理事件,更新狀態。首先我們得先認識「流」Stream
的概念才能比較知道它在幹嘛。
Stream
是一非同步資料的序列。為了使用 BloC 套件,最核心是了解 Stream
的基本概念。若你還不熟悉 Stream
的概念可以想像一根水管,Stream
就是水管,而資料就是水。在 Dart 我們使用 async*
函式來建立一個 Stream
Stream<int> clock(int max) async* {
for (int = 0; i < max; i++) {
yield i;
}
}
通過 async*
我們可以使用 yield
來傳出 Stream
中的資料,上面範例就是把數字傳出。
接著,我們可以使用 Stream
Future<int> sumStream(Stream<int> stream) async {
int sum = 0;
await for (int value in stream) {
sum += value;
}
return sum;
}
通過上面的 async
非同步函式我們可以使用 await
和 Future
來取得數字。
void main() async {
Stream<int> stream = clock(10);
int sum = await sumStream(stream);
print(sum); // 45
}
有了 Stream 的概念,我們就可以進一步認識 BLoC 的其他組件。Cubit
類別繼承了 BlocBase
其可以被擴展管理任何型別的狀態。Cubit
可以公開觸發狀態變更的函式。
Cubit
輸出狀態,通知 UI 組件根據目前狀態重新渲染內容。
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
接著,我們就可以使用 Cubit
公開的方法和 stream
Future<void> main() async {
final cubit = CounterCubit();
final subscription = cubit.stream.listen(print);
cubit.increment();
await subscription.cancel();
await cubit.close();
}
類似於 Cubit
,Bloc
是一個進階的類別,利用事件驅動 state
變更,而不是函式。Bloc
同樣繼承 BlocBase
但可以在直接 emit
狀態變更
sealed class CounterEvent {} // 限制同一個文件中定義的類別可以繼承或實現這個密封類別。
final class CounterIncrementPressed extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) {
emit(state + 1);
});
}
}
Future<void> main() async {
final bloc = CounterBloc();
print(bloc.state); // 0
bloc.add(CounterIncrementPressed());
await Future.delayed(Duration.zero);
print(bloc.state); // 1
await bloc.close();
}
本篇教學並非詳細的 BLoC 教學,只是盡可能讓你理解概略的使用概念和風格。
下面我們將實作一個簡單的範例來了解如何應用 BLoC
首先,一樣是安裝 flutter_bloc
$ flutter pub add flutter_bloc
接著我們建立一個 lib/data_bloc.dart
檔案。
上面我們提到 BLoC 是基於事件來變更狀態,通常我們會定義事件:
import 'package:flutter_bloc/flutter_bloc.dart';
abstract class DataEvent {}
class FetchDataEvent extends DataEvent {}
有了事件後我們來定義「狀態」,為了簡化範例我們一樣在 lib/data_bloc.dart
增加程式碼
abstract class DataState {}
class Initial extends DataState {}
class Loading extends DataState {}
class Fetched extends DataState {
final String data;
Fetched(this.data);
}
定義了「事件」和「狀態」我們就能實作 DataBloc
類別:
class DataBloc extends Bloc<DataEvent, DataState> {
DataBloc(): super(Initial()) {
on<FetchDataEvent>(_onFetchDataEvent);
}
void _onFetchDataEvent(FetchDataEvent event, Emitter<DataState> emit) async {
emit(Loading());
await Future.delayed(const Duration(seconds: 2));
emit(Fetched("讀取完成"));
}
}
最後我們來實作 lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'data_bloc.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: BlocProvider(
create: (context) => DataBloc(), child: const HomePage()),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final dataBloc = BlocProvider.of<DataBloc>(context);
return Scaffold(
appBar: AppBar(title: const Text('BLoC 範例')),
body: Center(
child: BlocBuilder<DataBloc, DataState>(
builder: (context, state) {
if (state is Initial) {
return const Text('點擊按鈕讀取資料');
} else if (state is Loading) {
return const CircularProgressIndicator();
} else if (state is Fetched) {
return Text(state.data);
}
return Container();
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => dataBloc.add(FetchDataEvent()),
child: const Icon(Icons.refresh),
),
);
}
}
如果熟悉 Dart 和 Flutter 的話,關注點分離和可測試性以及效能的部分都是 BLoC 的好處。不過學習曲線比較不容易,程式碼會比較多且複雜。如果你熟悉 React Flux/Redux 的概念學習起來可能覺得相對輕鬆。
GetX 是一個極度輕量的解決方案,結合高效能、自動注入和路由管理。專注於效能,我們不用直接使用 Stream
和 ChangeNotifier
,同時保持語法極為簡潔。
要使用 GetX 我們須在專案安裝
$ flutter pub add get
為了體現 GetX 的簡潔性,我們將重寫 Flutter 預設新專案的計數器範例。
首先我們在 lib/main.dart
匯入套件
import 'package:get/get.dart';
將你的 MaterialApp
替換為 GetMaterialApp
,這樣就可以使用 GetX 的功能了。注意 GetMaterialApp
並不是自行修改版本的 MaterialApp
它只是一個事先設定好的組件,內部預設使用 MaterialApp
作為子組件,我們依舊可以設定參數。GetMaterialApp
的任務是建立路由並注入。如果只需使用 GetX
進行狀態管理可以不使用 GetMaterialApp
,但如果要使用路由,Sanckbar,多語系,對話框等是必要的,也就是如果需要使用 Get.to()
Get.back()
的情況。
第二步我們建立處理商業邏輯的 Controller
class Controller extends GetxController {
var count = 0.obs;
void increment() => count++;
}
第三步處理 View 的部分,注意使用 GetX
我們不需使用 StatefulWidget
。
import 'package:flutter/material.dart';
import 'package:get/get.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'GetX 範例',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const Home(),
);
}
}
class Controller extends GetxController {
var count = 0.obs;
void increment() => count++;
}
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
final Controller controller = Get.put(Controller());
return Scaffold(
appBar: AppBar(
title: Obx(() => Text('次數:${controller.count}')),
),
body: Center(
child: Column(
children: [
ElevatedButton(
onPressed: controller.increment,
child: const Text('增加'),
),
ElevatedButton(
onPressed: () => Get.to(const About()),
child: const Text('關於'),
),
],
),
),
);
}
}
class About extends StatelessWidget {
const About({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
這個範例展示了 GetX 的核心功能:狀態管理、依賴注入和路由管理。通過使用 .obs
和 Obx
,我們實現了響應式風格。Get.put()
用於依賴注入,而 Get.to()
用於導航。這種方法大大簡化了程式碼,提高了開發效率。但 GetX 也是有缺點,GetX 試圖解決比較多的問題,可能導致專案變的複雜。另外因為 GetX 依賴注入可能會引起 Hot Reload 相關問題。
總的來說,GetX 確實提供了簡單且強大的狀態管理解決方案,特別適合快速開發和小型項目。然而,對於大型或需要嚴格測試的專案,則比較不合適。
對於 Riverpod 我們前面把它和狀態管理混在一起比較確實不太恰當,確切的說 Riverpod 是一個為 Dart/Flutter 設計的「宣告式響應式快取框架」。Riverpod 關注大型應用程式邏輯的部分。它支援網路請求,內建錯誤處理和快取機制,並在必要時自動重新讀取資料。狀態管理只是它眾多功能中的一部分。
Riverpod 希望處理的問題,概念上類似於 React 的 TanStack Query。
現代的應用程式很少在內部提供全部所需的資料。資料通常是通過非同步的方式從伺服器取得。
問題是處理非同步並不容易。雖然 Flutter 內建支援狀態管理以及 UI 更新,但仍有很多限制,其中需要解決的問題包含
這些問題通常伴隨著以下功能的實現,導致處理起來並不容易:
上面提到的功能實作起來都會有些挑戰,但是卻能提供更好的使用者體驗。社群的套件很多嘗試解決這些問題。
受到 Flutter 組件的啟發,於是 Riverpod 誕生,通過提供一種新的獨特方式撰寫商業邏輯來解決這些問題。
在許多方面,Riverpod 可以當作是用於狀態的組件。通過這個新方式,複雜的功能可以輕鬆完成,剩下的就是專注在我們的 UI 介面。
$ flutter pub add flutter_riverpod
$ flutter pub add riverpod_annotation
$ flutter pub add dev:riverpod_generator
$ flutter pub add dev:build_runner
$ flutter pub add dev:custom_lint
$ flutter pub add dev:riverpod_lint
安裝完畢之後我們可以執行程式碼產生器功能
$ flutter pub run build_runner watch
隨著你深入學習 Flutter 你會發現類似的程式碼產生器功能很常被使用。這個指令的用途就是持續監視我們的專案檔案,每當我們編輯時若有需要就會自動產生程式碼。常見如 JSON 序列化等產生對應的物件程式。有了這個指令,我們開發時可以更加方便。
Riverpod 內建支援 riverpod_lint
套件提供 Lint 規則協助我們撰寫程式碼。如按照上面安裝這些套件已經安裝了但須額外的設定啟動功能。要使用這些功能我們需在 analysis_options.yaml
加入設定:
analyzer:
plugins:
- custom_lint
查閱完整的規範。注意:這些加入的警告並不會出現在 dart analyze
指令,如果 CI 或指令介面需要這些警告請執行:
$ dart run custom_lint
到此我們安裝完了 Riverpod 可以直接在 lib/main.dart
實作,這裡我們只簡單的展示一下關於狀態的使用
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
// 這行程式碼的用途是將 main.g.dart 文件作為這個 Dart 檔案的一部分。
// 通常用於將自動生成的程式碼與手動編寫的程式碼分開。
// 也就是 `flutter pub run build_runner watch` 產生的檔案
part 'main.g.dart';
// 建立一個 provider 儲存資料
@riverpod
String name(NameRef ref) {
return '哈囉, Riverpod';
}
@riverpod
class CounterNotifier extends _$CounterNotifier {
@override
int build() => 0;
void increment() => state++;
}
void main() {
runApp(
// 為了讓組件可以讀取 provider 我們需要將它包在 ProviderScope 裡面
ProviderScope(
child: MyApp(),
),
);
}
// 繼承 ConsumerWidget 提供 ref 給子組件
class MyApp extends ConsumerWidget {
MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final String name = ref.watch(nameProvider);
final int counter = ref.watch(counterNotifierProvider);
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text(name),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(counter.toString()),
ElevatedButton(
onPressed: () {
ref.read(counterNotifierProvider.notifier).increment();
},
child: const Text('增加'),
),
],
))),
);
}
}
到此我們快速的概覽了幾個主流的套件。從官方推薦的 Provider 到其實我個人認為比較好理解的 GetX 再到 Riverpod 。每一個套件的出現都有其動機以及想要解決的問題,例如 Provider 因為 InheritedWidget 層次的問題導致作者探索出一個新的管理方式 Riverpod 甚至解決了更多問題。 希望這篇文章能對於你選擇狀態管理有些幫助和理解。