Flutter 當我們黏在一起:Provider

img

前言

依賴注入(Dependency Injection) 可以說是降低程式耦合度最簡單的方法,我們預先將某種”資源”注入到程式內,然後可以在任何地方自由提起該”資源”,完全不必理會這個資源在哪建立又放置在哪,我們只要知道有某種機制可以在我們需要時幫我們提取我們所需要的東西,接下來我們就嘗試如何在 Flutter 上做到該效果。

InheritedWidget

Flutter 提供一個有趣的 Widget - InheritedWidget 來協助我們傳遞資訊,我們可以看到官網的範例如下:
img
child 表示原本要呈現的 Widgetcolor 表示要添加的資源updateShouldNotify 方法主要是判斷是否要做變更通知,這邊主要是判斷所添加的資源是否有變化,簡單的說就是再利用一個 Widget 將原本的 Widget 與想要增加的額外資源綁在一起,這樣做跟直接將資源加到既有的 Widget 上有什麼差別呢?
BuildContext 提供了一個方法 inheritFromWidgetOfExactType 可以協助我們由目前的 Widget 所在的 Widget Tree 位置往上尋找第一個符合資源型態的資源,習慣上我們也會直接建立一個靜態的 of 方法來協助我們搜尋,這表示如果上層 Widget 已經有提供資源,我們可以直接利用 of 方法來抓取,不須重新建立,當然如果將資源綁在最上層(Root Widget),這個資源就會變成一個全域性的資源,在這個 App 上的任何 Widget 都可以存取到。

範例

接下來我們建立一個範例來說明,首先建立一個名為 IWidget 的 InheritedWidget,並宣告一個字串型別的 value 屬性來模擬額外添加的資源,程式碼如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
class IWidget extends InheritedWidget {
final String value;
IWidget({@required this.value, Widget child}) : super(child: child);

static IWidget of(BuildContext context) {
return context.inheritFromWidgetOfExactType(IWidget);
}

@override
bool updateShouldNotify(IWidget old) {
return old.value != value;
}
}

接著我們再增加一個名為 ValueWidget 的 StatelessWidget,他會去抓取 IWidgetvalue 屬性並透過 Text 呈現出來,在這邊我們增加 color 屬性
,藉以改變 Widget 的背景顏色,方便我們可以區分每個 Widget,並添加一個 child 屬性讓我們可以在 ValueWidget 裡面在放置其他 Widget,藉以模擬 Widget Tree 的階層特性,程式碼如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ValueWidget extends StatelessWidget {
final Widget child;
final Color color;
ValueWidget({Key key, this.color = Colors.white, this.child}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(8),
color: color,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (IWidget.of(context) != null)
Text('value:${IWidget.of(context).value}')
else
Text(
'value:null',
),
if (child != null) child,
],
),
),
);
}
}

Dart 2.2.2 版增加 Collection ifCollection for,讓我們可以在集合物件內使用 iffor,要使用的話需修改 pubspec.yaml 內 SDK 的最低版本。
img
img

接著我們建構一個 MyHomePage 的 Widget,在最外層放置一個 IWidget,並在裡面堆疊4層 ValueWidget,程式碼如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: IWidget(
value: 'AAA',
child: ValueWidget(
color: Colors.grey,
child: ValueWidget(
color: Colors.orange,
child: ValueWidget(
color: Colors.blue,
child: ValueWidget(
color: Colors.green,
),
),
),
),
),
);
}
}

最後再將 MyHomePage 添加到專案預設的專案預設的 MyApp 內,完整程式如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
textTheme: TextTheme(
body1: TextStyle(fontSize: 30),
),
),
home: MyHomePage(),
);
}
}

class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: IWidget(
value: 'AAA',
child: ValueWidget(
color: Colors.grey,
child: ValueWidget(
color: Colors.orange,
child: ValueWidget(
color: Colors.blue,
child: ValueWidget(
color: Colors.green,
),
),
),
),
),
);
}
}

class IWidget extends InheritedWidget {
final String value;
IWidget({@required this.value, Widget child}) : super(child: child);

static IWidget of(BuildContext context) {
return context.inheritFromWidgetOfExactType(IWidget);
}

@override
bool updateShouldNotify(IWidget old) {
return old.value != value;
}
}

class ValueWidget extends StatelessWidget {
final Widget child;
final Color color;
ValueWidget({Key key, this.color = Colors.white, this.child}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(8),
color: color,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (IWidget.of(context) != null)
Text('value:${IWidget.of(context).value}')
else
Text(
'value:null',
),
if (child != null) child,
],
),
),
);
}
}

執行專案可以看到如下的效果,不管哪一層的 ValueWidget 都可以輕鬆地透過 IWidget.of(context).value 來讀取到 IWidgetvalue
img
接著我們在第2層 ValueWidget 內先插入一個 IWidget,並設定 value:BBB

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: IWidget(
value: 'AAA',
child: ValueWidget(
color: Colors.grey,
child: ValueWidget(
color: Colors.orange,
child: IWidget(
value: 'BBB',
child: ValueWidget(
color: Colors.blue,
child: ValueWidget(
color: Colors.green,
),
),
),
),
),
),
);
}
}

儲存後透過 Flutter hot reload 機制可以馬上看到變化,由呈現的 value 可以知道所有的 ValueWidget 都會抓到離自己最近的 IWidget
img
接下來我們把最外層的 IWidget 拿掉。

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: ValueWidget(
color: Colors.grey,
child: ValueWidget(
color: Colors.orange,
child: IWidget(
value: 'BBB',
child: ValueWidget(
color: Colors.blue,
child: ValueWidget(
color: Colors.green,
),
),
),
),
),
);
}
}

我們可以看到最外2層的 ValueWidget 因為搜尋不到 IWidget,所以顯示為 null
img

Provider

今年的 Google I/O 也提到 Flutter 的狀態管理議題 - Pragmatic State Management in Flutter (Google I/O’19),有別於以往今年多介紹另一個 Package - **Provider**。

A dependency injection system built with widgets for widgets. provider is mostly syntax sugar for InheritedWidget, to make common use-cases straightforward.

從說明頁面可以看到 Provider 提供了依賴注入(Dependency Injection)的功能,當然也直接講明他就是 InheritedWidget 語法糖,連到 GitHub 去看provider.dart 核心的原始碼,可以知道它確實繼承自 InheritedWidget,特別的是它提供泛型的宣告來指定所要添加的資源,並將資源的變數強制訂為 value,所以相同的我們可以透過 of 方法來取的 value 的資料。

provider.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class _Provider<T> extends InheritedWidget {
const _Provider({
Key key,
@required this.value,
UpdateShouldNotify<T> updateShouldNotify,
Widget child,
}) : _updateShouldNotify = updateShouldNotify,
super(key: key, child: child);

final T value;
final UpdateShouldNotify<T> _updateShouldNotify;

@override
bool updateShouldNotify(_Provider<T> oldWidget) {
if (_updateShouldNotify != null) {
return _updateShouldNotify(oldWidget.value, value);
}
return oldWidget.value != value;
}
}

範例一

接下來我們將原本的範例修改為 Provider 版本,首先在 pubspec.yaml 內引用 provider。
img

在 VS Code 內儲存時會自動幫我們執行flutter package get 指令,幫我們下載 package。

接著將 MyHomePage 內的 IWidget 修改為 Provider.value

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import 'package:provider/provider.dart';

class MyHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Provider.value(
value: 'AAA',
child: ValueWidget(
color: Colors.grey,
child: ValueWidget(
color: Colors.orange,
child: Provider.value(
value: 'BBB',
child: ValueWidget(
color: Colors.blue,
child: ValueWidget(
color: Colors.green,
),
),
),
),
),
),
);
}
}

ValueWidget 內將讀取方式由 IWidget.of 改為 Provider.of<String>

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ValueWidget extends StatelessWidget {
final Widget child;
final Color color;
ValueWidget({Key key, this.color = Colors.white, this.child}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(8),
color: color,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
if (Provider.of<String>(context) != null)
Text('value:${Provider.of<String>(context)}')
else
Text(
'value:null',
),
if (child != null) child,
],
),
),
);
}
}

重新執行專案可以看到一樣的效果,只是 IWidgetProvider 替換掉了,也就是說其實 Provider 幫我們撰寫好 InheritedWidget 的部分。
img

範例二:StreamProvider

在上一篇文章 Dart 敗部復活賽:Flutter for Web 我們用 RxDart 這個 package 來做到跨 Widget 的同步通知,現在我們將它替換成 StreamController 來處理,並透過 Provider 的 Stream 版本 StreamProvider 來注入 StreamController。
首先在 pubspec.yaml 內引用 provider。
img
接著在 lib\rd.dart 內 將全域變數的 BehaviorSubject<bool> 替換為 StreamController<bool>,並移除 Widget 內的 StreamSubscription 變數與 initStatedispose 方法,因為我們不需要在訂閱通知取消訂閱通知
img
接著我們在需要取值得地方一樣透過 Provider.of 來讀取資料。
img
接著將修改值的方法由 subject.add 替換為 controller.add
img
因為我們必須要在目前 Widget 之前(上一層)先注入才有辦法讀取到資料,所以我們直接在 MyApp 內的 RxPage 外邊包一層 StreamProvider
img
完整程式碼如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

final controller = StreamController<bool>();

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: StreamProvider.value(
initialData: false,
stream: controller.stream,
child: RxPage(),
),
);
}
}

class RxPage extends StatefulWidget {
@override
_RxPageState createState() => _RxPageState();
}

class _RxPageState extends State<RxPage> {

@override
Widget build(BuildContext context) {
List<Widget> list = <Widget>[];
bool isLogin = Provider.of<bool>(context);
if (isLogin) {
list.add(UserAccountsDrawerHeader(
accountEmail: Text("jonnyhuang@outlooj.com"),
accountName: Text("Jonny"),
currentAccountPicture: CircleAvatar(
child: Text("J"),
),
));
} else {
list.add(DrawerHeader(
decoration: BoxDecoration(
color: Colors.orange,
),
child: Text("Guest"),
));
}
list.add(ListTile(
title: Text(isLogin ? "登出" : "登入"),
trailing: Icon(Icons.exit_to_app),
onTap: () {
controller.add(!isLogin);
},
));

return Scaffold(
appBar: AppBar(
title: Text('RxDart'),
),
drawer: Drawer(
child: ListView(
children: list,
),
),
body: Page1Page(),
);
}
}

class Page1Page extends StatefulWidget {
@override
_Page1PageState createState() => _Page1PageState();
}

class _Page1PageState extends State<Page1Page> {

@override
Widget build(BuildContext context) {
bool isLogin = Provider.of<bool>(context);
return Scaffold(
body: Container(
color: isLogin ? Colors.green : Colors.grey,
alignment: Alignment.center,
child: MaterialButton(
child: Text(isLogin ? "登出" : "登入"),
onPressed: () {
controller.add(!isLogin);
},
),
),
);
}
}

執行專案可以看到操作效果跟原來的一樣。
img

後記

就如同官方說明一樣 Provider 是 InheritedWidget 的語法糖,當你了解到 InheritedWidget 的特性時也會明白 Provider 如何做到依賴注入,所以它不是要改變我們的程式架構,相反的在我們既有的撰寫模式下套用它可以讓程式變得更簡潔。

MultiProvider

就如同官網範例 MultiProvider 可以協助我們很方便的注入多個 Provider,但是每多註冊一個 Provider 就等於多一層 Widget,Widget 的成本很低,所以效能的差異是可以忽略的,不過如果利用 Dart DevTools 來看 Widget Tree 時應該就會有些抱怨,因為多了很多層,有時候我們可以將多個資源合併在一起來減少堆疊次數。
img