第一個 state 管理 - Provider 的使用與深入
WriterShelf™ is a unique multiple pen name blogging and forum platform. Protect relationships and your privacy. Take your writing in new directions. ** Join WriterShelf**
WriterShelf™ is an open writing platform. The views, information and opinions in this article are those of the author.
Article info
This article is part of:
Categories:
Tags:
Date:
Published: 2021/07/11 - Updated: 2021/10/18
Total: 8688 words
Like
or Dislike
About the Author
很久以前就是個「寫程式的」,其實,什麼程式都不熟⋯⋯
就,這會一點點,那會一點點⋯⋯
More to explore
自由的切換 UI 黑暗或是明亮模式
我們的 UI 現在會依照作業系統的預設自動切換到黑暗或是明亮模式,很棒,只是,好像,還不夠好,能不能讓使用者自由的切換呢?
要怎麼做呢?請各位先想一想!
延續我們的程式碼,如果有人現在才加入,目前階段的程式碼請自行參考本書:程式碼備份中的 Code milestone 3。
使用情境 UX
這是最主要的問題,也就是使用者要怎麼使用,當然越簡單越直覺越好,關於什麼是好用的 UX 絕不在本書範圍內,在這本書中,我就是那個獨裁者,我說這樣寫好就是好,不接受討論,因為光是討論什麼是「好」的 UX,就絕對可以吵翻天了,本書專注在「寫」程式,不討論 UX。
這是一個很簡單的應用,要切換 UI 模式就一定要有一個「開關」,唯一的問題是這個「開關」應該要放在那裡,嘿嘿,打開我們目前的 App,很好,我們早就有一個抄來的 drawer 抽屜(什麼?忘記了嗎?就是 MyHomePage 左上角的那個三條線的 menu 啦),這個位置很好,我們就來把這個「開關」放在 drawer 抽屜裡,使用情境 UX 結案,不受理上訴!
State management 管理
可是,drawer 抽屜裡的「開關」在下層的 widget,怎麼能改變上層 widget 的設定呢?
這個問題很巨大,黑暗或明亮模式的切換是在最上層的 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 中有很多種管理與使用方法,讓我們快速的了解一下:
哇,有那麼多種,各位心裡一定會想,這要學到何年何月啊,真的,所以有人就用 State Wars 狀態大戰來形容 Flutter 中的各種 state 管理,這其中,目前最受歡迎也討論最多的是 Redux 跟 BLoC,但是,我們初學,我覺得了解 state 的觀念最重要,應該先學簡單的,所以我將使用 Flutter 官方建議的 provider 方法作為入門,Flutter 官方說它又簡單又好用,希望是真的。
安裝 Provider package
在 android studio 中,找到如下圖中的 run anything 的 commands 執行,按下它:
依照 provider 的安裝說明,輸入下面的指令,按 return 執行:
這是我們第一次使用 Flutter 套件管理 pub,讓我們來對 pub 做一個快速的了解:
當執行後,你應該就會看到以下的執行的結果如下,隨著版本的不同,可能會不一樣,只要沒有顯示錯誤就好:
讓我們來仔細看看,到底這行
add
provider 指令到底做了什麼:讓我們再來看看,這行指令到底幫我們把 pubspec.yaml 改成怎樣了,打開它,我們發現,哈,只在 dependencies 下面加了一行:
provider: ^5.0.0
^5.0.0 是什麼?它就是版本管理,你可能會想,為什麼要版本管理,就用最新的版本就好,可以實務上,當使用外加的套件時,套件更新不一定是好事,有時套件更新會帶來新的 bug 或是更改不同的用法,甚至需要使用的程式改寫,所以限制版本的使用是很重要的,常用寫法有:
所以 ^5.0.0 就是允許 pub upgrade 時,可以把 provider 更新到 5.x.x 內的最高版本。
再打開 pubspec.lock 看看,一開頭就寫得很清楚,這是由 pub 產生的,這裡面也很清楚的列出了所有使用中的 package 名稱,版本等資訊,所以當你不清楚使用中的 package 版本時,看這裡最快,但是記住,不要亂改。
既然我們剛開始寫,也發現有更新的版本,那就讓我們來做個更新吧:
好啦,我兩個都執行了,套件是沒有辦法更新,有 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:
好啦,我們把 state 的資料部分寫好了,好像真的不難,接下來我們來看如何打電話來改變 state。
通知 state 改變
state 就是狀態,以後就不翻譯了。
資料有了,再來就是要建立一個「開關」讓使用者可以切換,之前已經決定將「開關」放在 MyHomePage 的 drawer 抽屜裡,但是也不能亂放啊,未來一定也會有其他的設定也要放在這個抽屜裡,所以我的設計是:
做好的樣子如下圖,我先將放上,大家也可以先想想看你會怎麼做,當按下那三條線的 drawer 抽屜後,我們的 UI 開關就出現了,如下:
有想法了嗎?廢話不多說,先看看我的碼,也許你有更好的寫法!
ToggleButton 是由一個 boolean List 按照排列順序來控制的,我們要在 Drawer 裡面使用 ToggleButton,就要先建立一個控制 ToggleButton 的 List。
我的做法是先在 _MyHomePageState 中,建立了一個 _themeModeSelected() method:
lib/screens/my_home_page.dart 的 _MyHomePageState 中:
context.read().getMode
來讀取 state 的值,這種寫法比較少見,但是我很喜歡,我覺得簡單又清楚,大家在網路上比較常看到的寫法是Provider.of(context, listen: false).getMode
,其實這兩個寫法是一樣的:再來就是建立 ToggleButton 的 widget 了,文件可以看這裡,但是我覺得看以下的程式碼就夠了,在我們這個 App 中,ToggleButton 的 widget 是加在 _MyHomePageState 的 Draw widget 裡面。
lib/screens/my_home_page.dart 中的 _MyHomePageState 之 Draw 部分:
不要被這近百行的程式碼嚇到,Flutter 就是長得嚇人,其實這裡面不過就是:
/lib/theme/style.dart:的新增部分。
/lib/theme/custom_widgets.dart:別忘了要 import 到使用的 screen 內。
好啦,切換的通知開關寫好了,再來就是接收 state 的部分了。
接收 state 改變
要寫接收 state 改變的程式碼前,我們第一個要考慮的就是,接收端要寫在那裡,這有以下的影響:
我們這個 App 的接收端,也就是 consumer/provider.of 的位置倒是很好決定,因為 themeMode 的設定是在 MyApp 裡面,雖然它是 widget 頭,但是我們 state 的接收位置還是只能寫在這,情勢所逼不得不如此。實務上,如同我們前面所說,我們要盡量放在最低層,特別避免放樹頭,樹頭一改,整棵樹都要重畫了。
要接收資料又要被通知,要使用的 provider 選項就是 ChangeNotifierProvider,是的,provider 有好幾多個不同的使用選項,等一下我們會再一一介紹,下面的程式碼是一個很標準的寫法,將 ChangeNotifierProvider 寫在 runApp 裡,也就是 widget 的最高處,再用 consumer 來接收變化。這種寫法的好處是所有的 widgets 都可以讀到 state 資料,也可以被通知,但是缺點就是效率差一些。
最常見的 ChangeNotifierProvider 用法
lib/main.dart:
Yes!趕快試試我們的 App,你應該可以自由的切換改變 App 的 theme 了。
現在我們終於看到完整的 provider state 管理流程了:
很簡單嘛,但是要真搞懂,還是不容易啊。
ChangeNotifierProvider 與 consumer/builder 合在一起的用法
除了上面這種最流行的用法,還可以直接把 ChangeNotifierProvider 跟 consumer 或 context.watch() 合在一起使用,只是一定要用 consumer 或是 builder 隔開,這樣才能讀到上層的provider 變數,下面就是使用 Builder + context.watch() 的寫法。
lib/main.dart:我將 import 及 route 部分省略了,它們就跟上面的一樣,沒變。
這種寫法的重點就是 ChangeNotifierProvider 的 child 一定要先用 Builder 包起來,如:第 12 行。
Builder 這個 Widget 很單純,它會將他的 builder 所指到的 Widget,包在一個 StatelessWidget 內,會這樣做的原因通常就只有一個:為了讀取 context,糟糕,我們又遇到 context 這個怪物了,好吧,讓我們面對它,瞭解他。
什麼是 Context?the BuildContext
官方的解釋是:關於這個 widget 在 widget tree 的位置。
來來來,看懂的舉手...... 左看右看...... 沒人!還好不是只有我笨,大家都一樣。
看不懂只好自己讀書了,讀了一堆文章與網路討論後,我發現這個 context 好像是用來連結 widget 這個虛擬世界與真實的畫面的,好像每一個畫面都有一個獨立的 context,好像...... 只是,大家都說不清楚,完全一個瞎子摸象,各自表述⋯⋯ 這時,我突然頓悟,也許平台的細節並不重要,畢竟,Flutter 是一個平台,我們用平台的目的就是要簡潔及開發快速, 花時間去搞懂平台內部怎麼運作的,好像怪怪的,所以應該倒過來,我們來看看 context 用在哪裡,以下是 context 的一些用法:
嗯,很有趣,歸納起來,context 對我們而言,其實好像就只是:
第二點其實很少人使用,所以 context 的最主要用途都是第一點,而且大部分都是用 context 來讀取父母的狀態,就好像是我們學過的導航:Navigator.of(context).pushNamed('myRoute'),就是用它來讀取父母的 navigator 狀態,然後再推一個 route 進去。所以,
context 的重點就是:
我覺得目前知道這樣就夠了,還想要了解更多的話,這篇討論我覺得值得一讀,我上面的介紹也很多是參考他的發文。
那爲什麼在上面這段程式碼中,要使用 Builder 呢?其實第 16 行的 watch 就等於是 Provider.of<T>(context),沒有 Builder 的話 Provider.of 就讀不到上層 context 的內容了,
最簡潔的用法:不用 consumer
最常用的 consumer 其實可以被 context.watch<t>() 來取代,一樣會接收通知,而且程式碼更精簡,但是很奇怪,很少人用。
lib/main.dart:也是將 import 及 route 部分移除了,它們就跟前面的一樣,沒變。
你看看,這樣寫多精簡,又很清楚,我喜歡,只是在網路上,幾乎沒人用,我是決定就用它了。
我最後是用:
但是網路上大家都用:
兩個是一樣的啦,用 Provider 時,listen 設成 true 就是 watch,設成 false 就是read,大家就選自己看的順眼的用吧,我是喜歡 watch 跟 read,字少。
效能與 ChangeNotifer 的限制
各位應該也注意到了,我們的 MyApp 是個 stateless widget,不是說 stateless 不會 rebuild 嗎?其實 stateless 在這以下的兩個情況下時,是會 rebuild 的:
當你的 widget 常常會被 rebuild 時,你就要考慮優化效能了,方法有很多,常用的有:
不過啊,用說的都很容易,真正做起來並不簡單,牽一髮而動全身啊,這部分真的需要經驗,在一開始建立 widget 樹時,就最好能考慮到。
一般來說,ChangeNotifier 適合使用在 listeners 接聽數不多的場景,因為他的效能是接聽數的平方,當有三個接聽數時,就需要傳送通知 9 次了。
寫到這裡,再回頭想想 Flutter 的官方說 provider package 又簡單又好用時,突然間,我覺的我好笨,深受打擊,Provider 是不能說複雜,但是也不簡單啊,因為寫到這裡還只是介紹了 provider package 的冰山一角,provider package 不只有 ChangeNotifierProvider,它還有好多其他使用方式啊 ⋯⋯
Provider 的各種型態(用法)
各位知道 Provider 這個 package 有幾種用法嗎?不嚇你,請看下表,常常我們不過是就只要管理一個 app 的 state,用 provider 馬上就會遇到選擇障礙:
state class with 'ChangeNotifier'
分享 state 的內容及 rebuild,
state class with 'Listenable',
官方說:你應該不會用這個,請使用
ChangeNotiferProvider。
通知 rebuild
分享 state 的內容及 rebuild
分享 state 的內容及 rebuild
其中一個 state 依賴另一個 state。
兩個以上的 states,
其中一個 state 依賴另一個 state。
還好,有人說只要:
所以,鬆了一口氣,我們已經會用 ChangeNotifierProvider 了,可以算是學會 Provider 這個 package 了,太好了。
Lazy Loading
provider 基本上還沒有被使用時,它的 instance 是不會建立的,只有需要用到的第一次時,才會建立,這種行為在電腦的世界裡就叫做 Lazy loading,這是的好功能,可以省時又省記憶體,但是總有些時候你必須要 App 一啟動就把某個 provider instance 建立起來,這也很簡單,就加一個 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 裡:
這樣執行時就可以在 Android Studio 的 console 把追蹤的點 print 出值來,又快又方便,還比較真實。事實上,就是上面這行放在 MyHomePage 的 print('home') 告訴了我一個大發現,當我們改變 ThemeMode 時,會照成 MyHomePage 多 rebuild 一到三次,大部分是多 rebuild 兩次,而且只有當「黑暗」與「明亮」真實有變換時,切換到系統如果真正的 ThemeMode 沒變是不會多 rebuild 的,我查了一下午,完全找不到原因,也找不到討論,只能想像是因為 Theme 也是 InheritedWidget 的一員,所以當 theme 改變時,它也呼叫了 rebuild,還好,Theme 改變不會常發生,不然效能會是大問題。
好啦,我們就先停在這裡,天啊,這篇文章有八千多字⋯⋯ Provider 一點也不簡單啊。
最後的程式碼,請自行參考本書:程式碼備份中的 Code milestone 4。
想要知道怎麼將使用者的設定存起來嗎?我們在《Hive 實作:儲存使用者的喜好 - Hive 安裝、設定、起始與基本用法》會說明怎麼儲存使者的預設喜好。