這篇文章好的的地方在於它不僅講了Flutter Provider如何管理State的,還講述了一個Flutter App可以採用哪一種架構。這種架構是基於clean architecture和FilledStacks這兩種架構原則的(這裡可能理解或者表達有誤,請指正)。但是文中最後採用的還是MVVM的模式。
更加重要的一點,就是本文要講述的Provider其實就是一種widget。搭配著Consumer
這個widget一起使用,達到**UI = f(state)**這個state
變化,UI跟著變的效果。
最後,還是那句話要看原文的請到這裡,文章本身有質量,而且寫的不難。
正文
Flutter團隊建議初學者使用Provider來管理state。但是Provider到底是什麼,該如何使用?
Provider是一個UI工具。如果你對於架構、state和架構之間有疑惑,那麼並不只有你是這樣。本文會幫助你理清這些概念,讓你知道如何從無到有寫一個app。
本文會帶你學習Provider管理state的方方面面。這裡我們來寫一個計算匯率的app,就叫做MoolaX。在寫這個app的時候你會提升你的Flutter技能:
- app架構
- 實現一個Provider
- 熟練管理app的state
- 根據state的更改來更新UI
注意:本文假設你已經知道Dart和如何寫一個Flutter的app了。如果在這方面還有不清楚的話請移步Flutter入門。
開始
點選“下載材料”來下載專案的程式碼。然後你就可以一步一步的跟著本文新增程式碼完成開發。
本文使用了Android Studio,但是Visual Studio Code也是可以用的。(其實VS Code更好用,譯者觀點)。
在MoolaX裡你可以選擇不同的貨幣。App執行起來是這樣的:
開啟初始專案,解壓後的starter目錄。Android Studio會出現一個彈出框,點選Get dependencies。
在初始專案裡已經包含了一部分程式碼,本教程會帶著你新增必要的程式碼,讓你輕鬆學會下文的內容。
現在這個app執行起來的時候是這樣的:
搭建App的架構
如果你沒聽說過clean architecture,再繼續之前請閱讀這篇文章。
主旨就是把核心業務邏輯從UI、資料庫、網路請求和第三方包中分離出來。為什麼?核心業務邏輯相對並不會那麼頻繁的更改。
UI不應該直接請求網路。也不應該把資料庫讀寫的程式碼寫的到處都是。所有的資料都應該從一個統一的地方發出,這就是業務邏輯。
這就形成了一個外掛系統。即使你更換了一個資料庫,app的其他部分也不會有任何的感知。你可以從一個移動端UI更換的一個桌面UI,app的其他部分也並不用關心。這對於開發一個易於維護、擴充套件的app來說十分有效。
使用Provider管理state
MoolaX的架構就符合這個原則。業務邏輯處理匯率相關的計算。Local Storage、網路請求和Flutter的UI、Provider這些全部都互相獨立。
Local storage使用的是shared preferences,但是這個和app的其他部分沒有關聯。同理網路請求如何獲取資料和app的其他部分也沒有任何關聯。
接下來要理解的是UI、Flutter和Provider都在同一個部分裡。Flutter就是一個UI框架,Provider是這個框架裡的一個widget。
Provider是架構嗎?不是。 Provider是狀態管理嗎?不是,至少在這個app裡不是。
state是app的變數的當前值。這些變數是app的業務邏輯的一部分,分散、管理在不同的model物件裡。所以,業務邏輯管理了state,而不是Provider。
所以,Provider到底是什麼呢?
它是狀態管理的helper,它是一個widget。通過這個widget可以把model物件傳遞給它的子widget。
Consumer
widget,屬於Provider 包的一部分,監聽了Provider暴露的mode值的改變,並重新build它的全部子widget。
使用Provider管理state系列對state和provider做了更加全面的解析。Provider有很多種,不過多數不在本文的範圍內。
和業務邏輯通訊
文字的架構模式受到了FilledStacks的啟發。它可以讓架構足夠有條理而又不會太過複雜。對初學者也很友好。
這個模型非常類似於MVVM(Model View ViewModel)。
model就是從資料庫或者網路請求得到的資料。view就是UI,也可以是一個screen或者widget。viewmodel就是在UI和資料中間的業務邏輯,並提供了UI可以展示的資料。但是它對UI並無感知。這一單和MVP不同。viewmodel也不應該知道資料從哪裡來。
在MoolaX裡,每頁都有獨立的view model。資料可以從網路和本地儲存獲得。處理這部分內容的類叫做services。MoolaX的架構基本是這樣的:
注意如下幾點:
- UI頁面監聽view model的改變,也會給view model傳送事件
- view model不會感知到UI的具體細節
- 業務邏輯與貨幣抽象互動。它不會感知資料是從網路請求得來還是從本地儲存得來。
理論部分到此結束,現在開始程式碼部分!
建立核心業務邏輯
專案的目錄結構如下:
Models
我們來看看mdels目錄:
這些就是業務邏輯要用到的資料結構了。類職責協同卡片模型是一個很好的方法可以確定哪些model是需要的。卡片如下:
最後會用到Currency
和Rate
兩個model。他們代表了先進和匯率,就算你沒喲計算機也需要這兩個。
View Model
view mode的職責就是拿到資料,然後轉化成UI可用的格式。
展開view_models目錄。你會看到兩個view model,一個是給結算頁用的,一個是給選擇匯率頁用的。
開啟choose_favorites_viewmodel.dart。你會看到下面的程式碼:
// 1
import 'package:flutter/foundation.dart';
// 2
class ChooseFavoritesViewModel extends ChangeNotifier {
// 3
final CurrencyService _currencyService = serviceLocator<CurrencyService>();
List<FavoritePresentation> _choices = [];
List<Currency> _favorites = [];
// 4
List<FavoritePresentation> get choices => _choices;
void loadData() async {
// ...
// 5
notifyListeners();
}
void toggleFavoriteStatus(int choiceIndex) {
// ...
// 5
notifyListeners();
}
}
複製程式碼
解釋:
- 使用
ChangeNotifier
來實現UI對view model的監聽。這個類在Flutterfoundation
包。 - view model類繼承了
ChangeNotifier
類。另一個選項是使用mixin。ChangeNotifier
裡有一個notifyListeners()
方法,你後面會用到。 - 一個service來負責獲取和儲存貨幣以及匯率資料。
CurrencyService
是一個抽象類,它的具體實現隱藏在view model之外。你可以任意更換不同的實現。 - 任意可以訪問這個view mode的例項都可以訪問到一個貨幣列表,然後從裡面選出一個最喜歡的。UI會使用這個列表來建立一個可選的listview。
- 在獲取到貨幣列表或者修改了最喜歡的貨幣之後,都會呼叫
notifyListeners()
方法發出通知。UI會接受到通知,並作出更新。
在choose_favorites_viewmodel.dart檔案還有另外的一個類:FavoritePresentation
:
class FavoritePresentation {
final String flag;
final String alphabeticCode;
final String longName;
bool isFavorite;
FavoritePresentation(
{this.flag, this.alphabeticCode, this.longName, this.isFavorite,});
}
複製程式碼
這個類就是為UI展示用的。這裡儘量不儲存任何與UI無關的內容。
在ChooseFavoritesViewModel
,用下面的程式碼替換掉loadData()
方法
void loadData() async {
final rates = await _currencyService.getAllExchangeRates();
_favorites = await _currencyService.getFavoriteCurrencies();
_prepareChoicePresentation(rates);
notifyListeners();
}
void _prepareChoicePresentation(List<Rate> rates) {
List<FavoritePresentation> list = [];
for (Rate rate in rates) {
String code = rate.quoteCurrency;
bool isFavorite = _getFavoriteStatus(code);
list.add(FavoritePresentation(
flag: IsoData.flagOf(code),
alphabeticCode: code,
longName: IsoData.longNameOf(code),
isFavorite: isFavorite,
));
}
_choices = list;
}
bool _getFavoriteStatus(String code) {
for (Currency currency in _favorites) {
if (code == currency.isoCode)
return true;
}
return false;
}
複製程式碼
loadData
獲取一列匯率。接著,_prepareChoicePresentation()
方法把列表轉化成UI可以直接顯示的格式。_getFavoriteStatus()
決定了一個貨幣是否為最喜歡貨幣。
接著使用下面的程式碼替換掉toggleFavoriteStatus()
方法:
void toggleFavoriteStatus(int choiceIndex) {
final isFavorite = !_choices[choiceIndex].isFavorite;
final code = _choices[choiceIndex].alphabeticCode;
_choices[choiceIndex].isFavorite = isFavorite;
if (isFavorite) {
_addToFavorites(code);
} else {
_removeFromFavorites(code);
}
notifyListeners();
}
void _addToFavorites(String alphabeticCode) {
_favorites.add(Currency(alphabeticCode));
_currencyService.saveFavoriteCurrencies(_favorites);
}
void _removeFromFavorites(String alphabeticCode) {
for (final currency in _favorites) {
if (currency.isoCode == alphabeticCode) {
_favorites.remove(currency);
break;
}
}
_currencyService.saveFavoriteCurrencies(_favorites);
}
複製程式碼
只要這個方法被呼叫,view model就會呼叫貨幣服務儲存新的最喜歡貨幣。同時因為notifyListeners
方法也被呼叫了,所以UI也會立刻顯示最新的修改。
恭喜你,你已經完成了view model了。
總結一下,你的view model類需要做的就是繼承ChangeNotifier
類並在需要更新UI的地方呼叫notifyListeners()
方法。
Services
我們這裡有三種service,分別是:匯率交換,儲存以及網路請求。看下面的架構圖,所有服務都在右邊紅色的框表示:
- 建立一個抽象類,在裡面新增所有會用到的方法
- 給抽象類寫一個具體的實現類
因為每次建立一個service的方式都差不多,我們就用網路請求為例。初始專案中已經包含了匯率服務和儲存服務了。
建立一個抽象service類
開啟web_api.dart:
你會看到如下的程式碼:
import 'package:moolax/business_logic/models/rate.dart';
abstract class WebApi {
Future<List<Rate>> fetchExchangeRates();
}
複製程式碼
這是一個抽象類,所以它並不具體做什麼。然而,它還是會反映出app需要它做什麼:它應該從網路請求一串匯率回來。具體如何實現由你決定。
使用假資料
在web_api裡,新建一個檔案web_api_fake.dart。之後複製如下程式碼進去:
import 'package:moolax/business_logic/models/rate.dart';
import 'web_api.dart';
class FakeWebApi implements WebApi {
@override
Future<List<Rate>> fetchExchangeRates() async {
List<Rate> list = [];
list.add(Rate(
baseCurrency: 'USD',
quoteCurrency: 'EUR',
exchangeRate: 0.91,
));
list.add(Rate(
baseCurrency: 'USD',
quoteCurrency: 'CNY',
exchangeRate: 7.05,
));
list.add(Rate(
baseCurrency: 'USD',
quoteCurrency: 'MNT',
exchangeRate: 2668.37,
));
return list;
}
}
複製程式碼
這個類實現了抽象WebApi
類,反回了某些寫死的資料。現在你可以繼續編寫其他部分的程式碼了,網路請求的部分可以放心了。什麼時候準備好了,可以回來實現真正的網路請求。
新增一個Service定位器
即使抽象類都實現了,你還是要告訴app去哪裡找這些抽象類的具體實現類。
有一個service定位器可以很快完成這個功能。一個service定位器是一個依賴注入的替代。它可以用來把一個service和app的其他部分解耦。
在ChooseFavoriatesViewModel
裡有這麼一行:
final CurrencyService _currencyService = serviceLocator<CurrencyService>();
複製程式碼
serviceLocator
是一個單例物件,它回到你用到的所有的service。
在services目錄下,開啟service_locator.dart。你會看到下面的程式碼:
// 1
GetIt serviceLocator = GetIt.instance;
// 2
void setupServiceLocator() {
// 3
serviceLocator.registerLazySingleton<StorageService>(() => StorageServiceImpl());
serviceLocator.registerLazySingleton<CurrencyService>(() => CurrencyServiceFake());
// 4
serviceLocator.registerFactory<CalculateScreenViewModel>(() => CalculateScreenViewModel());
serviceLocator.registerFactory<ChooseFavoritesViewModel>(() => ChooseFavoritesViewModel());
}
複製程式碼
解釋:
GetIt
是一個叫做get_it的service 定位包。這裡已經預先新增到pubspec.yaml
裡了。get_it會通過一個全域性的單例來保留所有註冊的物件。- 這個方法就是用來註冊服務的。在構建UI之前就需要呼叫這個方法了。
- 你可以把你的服務註冊為延遲載入的單例。註冊為單例也就是說你每次取回的是同一個例項。註冊為一個延遲載入的單例等於,在第一次使用的時候,只有在用的時候才會初始化。
- 你也可以使用service定位器來註冊view model。這樣在UI裡可以很容易拿到他們的引用。當然view models都是註冊為一個factory了。每次取回來的都是一個新的view model例項。
注意程式碼是在哪裡呼叫setupServiceLocator()
的。開啟main.dart檔案:
void main() {
setupServiceLocator(); // <--- here
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Moola X',
theme: ThemeData(
primarySwatch: Colors.indigo,
),
home: CalculateCurrencyScreen(),
);
}
}
複製程式碼
註冊FakeWebApi
現在來註冊FakeWebApi
。
serviceLocator.registerLazySingleton<WebApi>(() => FakeWebApi());
複製程式碼
使用CurrencyServiceImpl
替換CurrencyServiceFake
:
serviceLocator.registerLazySingleton<CurrencyService>(() => CurrencyServiceImpl());
複製程式碼
初始專案裡使用了CurrencyServiceFake
,這樣才能執行起來。
引入缺失的類:
import 'web_api/web_api.dart';
import 'web_api/web_api_fake.dart';
import 'currency/currency_service_implementation.dart';
複製程式碼
執行app,點選右上角的心形。
Web API的具體實現
前面註冊了假的web api實現,app已經可以執行了。下面就需要從真的web伺服器上獲取真正的資料了。在services/web_api目錄下,新建檔案web_api_implementation.dart。新增如下的程式碼:
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:moolax/business_logic/models/rate.dart';
import 'web_api.dart';
// 1
class WebApiImpl implements WebApi {
final _host = 'api.exchangeratesapi.io';
final _path = 'latest';
final Map<String, String> _headers = {'Accept': 'application/json'};
// 2
List<Rate> _rateCache;
Future<List<Rate>> fetchExchangeRates() async {
if (_rateCache == null) {
print('getting rates from the web');
final uri = Uri.https(_host, _path);
final results = await http.get(uri, headers: _headers);
final jsonObject = json.decode(results.body);
_rateCache = _createRateListFromRawMap(jsonObject);
} else {
print('getting rates from cache');
}
return _rateCache;
}
List<Rate> _createRateListFromRawMap(Map jsonObject) {
final Map rates = jsonObject['rates'];
final String base = jsonObject['base'];
List<Rate> list = [];
list.add(Rate(baseCurrency: base, quoteCurrency: base, exchangeRate: 1.0));
for (var rate in rates.entries) {
list.add(Rate(baseCurrency: base,
quoteCurrency: rate.key,
exchangeRate: rate.value as double));
}
return list;
}
},
複製程式碼
注意下面的幾點:
- 如同
FakeWebApi
,這個類也實現了WebApi
。它包含了從api.exchangeratesapi.io獲取資料的邏輯。然而,app的其他部分並不知道這一點,所以如果你想換到別的web api,毫無疑問這裡就是你唯一可以更改的地方。 - exchangeratesapi.io慷慨的提供了給定資料的貨幣的匯率,都不要額外的token。
開啟service_localtor.dart,把FakeWebApi()
修改為WebApiImp()
,並更新對應的import語句。
import 'web_api/web_api_implementation.dart';
void setupServiceLocator() {
serviceLocator.registerLazySingleton<WebApi>(() => WebApiImpl());
// ...
}
複製程式碼
實現Provider
現在總算輪到Provider了。這篇怎麼說也是一個Provider的教程!
我們等了這麼久才開始Provider的部分,你應該意識到了Provider其實是一個app的很小一部分。它只是用來方便在更改發生的時候方便把值傳遞給子widget,但也不是架構或者狀態管理的系統。
在pubspec.yaml裡找到Provider包:
dependencies:
provider: ^4.0.1
複製程式碼
有一個比較特殊的Provider:ChangeNotifierProvider
。它監聽實現了ChangeNotifier
的view model的修改。
在ui/views目錄下,開啟choose_favorites.dart檔案。這個檔案的內容替換為如下的程式碼:
import 'package:flutter/material.dart';
import 'package:moolax/business_logic/view_models/choose_favorites_viewmodel.dart';
import 'package:moolax/services/service_locator.dart';
import 'package:provider/provider.dart';
class ChooseFavoriteCurrencyScreen extends StatefulWidget {
@override
_ChooseFavoriteCurrencyScreenState createState() =>
_ChooseFavoriteCurrencyScreenState();
}
class _ChooseFavoriteCurrencyScreenState
extends State<ChooseFavoriteCurrencyScreen> {
// 1
ChooseFavoritesViewModel model = serviceLocator<ChooseFavoritesViewModel>();
// 2
@override
void initState() {
model.loadData();
super.initState();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Choose Currencies'),
),
body: buildListView(model),
);
}
// Add buildListView() here.
}
複製程式碼
你會發現buildListView()
方法,注意如下的更改:
- servie定位器返回一個view model的例項
- 使用
StatefulWidget
,它包含了initState()
方法。這裡你可以告訴view model載入貨幣資料。
在build()
方法下,新增如下的buildListView()
實現:
Widget buildListView(ChooseFavoritesViewModel viewModel) {
// 1
return ChangeNotifierProvider<ChooseFavoritesViewModel>(
// 2
create: (context) => viewModel,
// 3
child: Consumer<ChooseFavoritesViewModel>(
builder: (context, model, child) => ListView.builder(
itemCount: model.choices.length,
itemBuilder: (context, index) {
return Card(
child: ListTile(
leading: SizedBox(
width: 60,
child: Text(
'${model.choices[index].flag}',
style: TextStyle(fontSize: 30),
),
),
// 4
title: Text('${model.choices[index].alphabeticCode}'),
subtitle: Text('${model.choices[index].longName}'),
trailing: (model.choices[index].isFavorite)
? Icon(Icons.favorite, color: Colors.red)
: Icon(Icons.favorite_border),
onTap: () {
// 5
model.toggleFavoriteStatus(index);
},
),
);
},
),
),
);
}
複製程式碼
程式碼解析:
- 新增
ChangeNotifierProvider
,一個特殊型別的provider,它監聽了來自view model的修改。 ChangeNotifierProvider
有一個create
方法。這個方法給子wdiget提供了view model值。在這裡你已經有了view model的引用,那就直接使用。Consumer
,當view model的notifyListeners()
告知更改發生的時候重新build介面。Consumer的builder方法向下傳遞了view model值。這個view model是從ChangeNotifierProvider
傳下來的。- 使用
model
裡的資料來重新build介面。注意UI裡只有很少的邏輯。 - 既然你有了view model的引用,那麼完全可以呼叫裡面的方法。
toggleFavoriteStatus()
呼叫了notifyListeners()
。
再次執行app。
在大型app中使用Provider
你可以按照本文所述的方式新增更多的介面。一旦你習慣了為每個介面新增view model就可以考慮為某些類建立基類來減少重複程式碼。本文沒有這麼做,因為這樣的話理解這些程式碼要花更多的時間。
其他的架構和狀態管理方法
如果你不喜歡本文所述的架構,可以考慮BLoC模式。BLoC模式入門也是一個很好的起點。你會發現BLoC模式也不像傳說的那麼難以理解。
還有其他的,不過Provider和BLoC是目前最普遍採用的。