[譯]Flutter - 使用Provider實現狀態管理

小紅星閃啊閃發表於2020-04-04

這篇文章好的的地方在於它不僅講了Flutter Provider如何管理State的,還講述了一個Flutter App可以採用哪一種架構。這種架構是基於clean architectureFilledStacks這兩種架構原則的(這裡可能理解或者表達有誤,請指正)。但是文中最後採用的還是MVVM的模式。

更加重要的一點,就是本文要講述的Provider其實就是一種widget。搭配著Consumer這個widget一起使用,達到**UI = f(state)**這個state變化,UI跟著變的效果。

最後,還是那句話要看原文的請到這裡,文章本身有質量,而且寫的不難。

正文

Flutter團隊建議初學者使用Provider來管理state。但是Provider到底是什麼,該如何使用?

Provider是一個UI工具。如果你對於架構、state和架構之間有疑惑,那麼並不只有你是這樣。本文會幫助你理清這些概念,讓你知道如何從無到有寫一個app。

本文會帶你學習Provider管理state的方方面面。這裡我們來寫一個計算匯率的app,就叫做MoolaX。在寫這個app的時候你會提升你的Flutter技能:

  1. app架構
  2. 實現一個Provider
  3. 熟練管理app的state
  4. 根據state的更改來更新UI

注意:本文假設你已經知道Dart和如何寫一個Flutter的app了。如果在這方面還有不清楚的話請移步Flutter入門

開始

點選“下載材料”來下載專案的程式碼。然後你就可以一步一步的跟著本文新增程式碼完成開發。

本文使用了Android Studio,但是Visual Studio Code也是可以用的。(其實VS Code更好用,譯者觀點)。

在MoolaX裡你可以選擇不同的貨幣。App執行起來是這樣的:

最終效果

開啟初始專案,解壓後的starter目錄。Android Studio會出現一個彈出框,點選Get dependencies

在初始專案裡已經包含了一部分程式碼,本教程會帶著你新增必要的程式碼,讓你輕鬆學會下文的內容。

現在這個app執行起來的時候是這樣的:

[譯]Flutter - 使用Provider實現狀態管理

搭建App的架構

如果你沒聽說過clean architecture,再繼續之前請閱讀這篇文章。

主旨就是把核心業務邏輯從UI、資料庫、網路請求和第三方包中分離出來。為什麼?核心業務邏輯相對並不會那麼頻繁的更改。

[譯]Flutter - 使用Provider實現狀態管理

UI不應該直接請求網路。也不應該把資料庫讀寫的程式碼寫的到處都是。所有的資料都應該從一個統一的地方發出,這就是業務邏輯。

這就形成了一個外掛系統。即使你更換了一個資料庫,app的其他部分也不會有任何的感知。你可以從一個移動端UI更換的一個桌面UI,app的其他部分也並不用關心。這對於開發一個易於維護、擴充套件的app來說十分有效。

使用Provider管理state

MoolaX的架構就符合這個原則。業務邏輯處理匯率相關的計算。Local Storage、網路請求和Flutter的UI、Provider這些全部都互相獨立。

[譯]Flutter - 使用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。

[譯]Flutter - 使用Provider實現狀態管理

使用Provider管理state系列對state和provider做了更加全面的解析。Provider有很多種,不過多數不在本文的範圍內。

和業務邏輯通訊

文字的架構模式受到了FilledStacks的啟發。它可以讓架構足夠有條理而又不會太過複雜。對初學者也很友好。

這個模型非常類似於MVVM(Model View ViewModel)。

model就是從資料庫或者網路請求得到的資料。view就是UI,也可以是一個screen或者widget。viewmodel就是在UI和資料中間的業務邏輯,並提供了UI可以展示的資料。但是它對UI並無感知。這一單和MVP不同。viewmodel也不應該知道資料從哪裡來。

[譯]Flutter - 使用Provider實現狀態管理

在MoolaX裡,每頁都有獨立的view model。資料可以從網路和本地儲存獲得。處理這部分內容的類叫做services。MoolaX的架構基本是這樣的:

[譯]Flutter - 使用Provider實現狀態管理

注意如下幾點:

  • UI頁面監聽view model的改變,也會給view model傳送事件
  • view model不會感知到UI的具體細節
  • 業務邏輯與貨幣抽象互動。它不會感知資料是從網路請求得來還是從本地儲存得來。

理論部分到此結束,現在開始程式碼部分!

建立核心業務邏輯

專案的目錄結構如下:

[譯]Flutter - 使用Provider實現狀態管理

Models

我們來看看mdels目錄:

[譯]Flutter - 使用Provider實現狀態管理

這些就是業務邏輯要用到的資料結構了。類職責協同卡片模型是一個很好的方法可以確定哪些model是需要的。卡片如下:

[譯]Flutter - 使用Provider實現狀態管理

最後會用到CurrencyRate兩個model。他們代表了先進和匯率,就算你沒喲計算機也需要這兩個。

View Model

view mode的職責就是拿到資料,然後轉化成UI可用的格式。

展開view_models目錄。你會看到兩個view model,一個是給結算頁用的,一個是給選擇匯率頁用的。

[譯]Flutter - 使用Provider實現狀態管理

開啟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();
  }
}
複製程式碼

解釋:

  1. 使用ChangeNotifier來實現UI對view model的監聽。這個類在Flutterfoundation包。
  2. view model類繼承了ChangeNotifier類。另一個選項是使用mixin。ChangeNotifier裡有一個notifyListeners()方法,你後面會用到。
  3. 一個service來負責獲取和儲存貨幣以及匯率資料。CurrencyService是一個抽象類,它的具體實現隱藏在view model之外。你可以任意更換不同的實現。
  4. 任意可以訪問這個view mode的例項都可以訪問到一個貨幣列表,然後從裡面選出一個最喜歡的。UI會使用這個列表來建立一個可選的listview。
  5. 在獲取到貨幣列表或者修改了最喜歡的貨幣之後,都會呼叫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,分別是:匯率交換,儲存以及網路請求。看下面的架構圖,所有服務都在右邊紅色的框表示:

[譯]Flutter - 使用Provider實現狀態管理

  1. 建立一個抽象類,在裡面新增所有會用到的方法
  2. 給抽象類寫一個具體的實現類

因為每次建立一個service的方式都差不多,我們就用網路請求為例。初始專案中已經包含了匯率服務儲存服務了。

建立一個抽象service類

開啟web_api.dart

[譯]Flutter - 使用Provider實現狀態管理

你會看到如下的程式碼:

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());
}
複製程式碼

解釋:

  1. GetIt是一個叫做get_it的service 定位包。這裡已經預先新增到pubspec.yaml裡了。get_it會通過一個全域性的單例來保留所有註冊的物件。
  2. 這個方法就是用來註冊服務的。在構建UI之前就需要呼叫這個方法了。
  3. 你可以把你的服務註冊為延遲載入的單例。註冊為單例也就是說你每次取回的是同一個例項。註冊為一個延遲載入的單例等於,在第一次使用的時候,只有在用的時候才會初始化。
  4. 你也可以使用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,點選右上角的心形。

[譯]Flutter - 使用Provider實現狀態管理

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;
  }
},
複製程式碼

注意下面的幾點:

  1. 如同FakeWebApi,這個類也實現了WebApi。它包含了從api.exchangeratesapi.io獲取資料的邏輯。然而,app的其他部分並不知道這一點,所以如果你想換到別的web api,毫無疑問這裡就是你唯一可以更改的地方。
  2. 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()方法,注意如下的更改:

  1. servie定位器返回一個view model的例項
  2. 使用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);
                },
              ),
            );
          },
        ),
      ),
    );
  }
複製程式碼

程式碼解析:

  1. 新增ChangeNotifierProvider,一個特殊型別的provider,它監聽了來自view model的修改。
  2. ChangeNotifierProvider有一個create方法。這個方法給子wdiget提供了view model值。在這裡你已經有了view model的引用,那就直接使用。
  3. Consumer,當view model的notifyListeners()告知更改發生的時候重新build介面。Consumer的builder方法向下傳遞了view model值。這個view model是從ChangeNotifierProvider傳下來的。
  4. 使用model裡的資料來重新build介面。注意UI裡只有很少的邏輯。
  5. 既然你有了view model的引用,那麼完全可以呼叫裡面的方法。toggleFavoriteStatus()呼叫了notifyListeners()

再次執行app。

[譯]Flutter - 使用Provider實現狀態管理

在大型app中使用Provider

你可以按照本文所述的方式新增更多的介面。一旦你習慣了為每個介面新增view model就可以考慮為某些類建立基類來減少重複程式碼。本文沒有這麼做,因為這樣的話理解這些程式碼要花更多的時間。

其他的架構和狀態管理方法

如果你不喜歡本文所述的架構,可以考慮BLoC模式。BLoC模式入門也是一個很好的起點。你會發現BLoC模式也不像傳說的那麼難以理解。

還有其他的,不過Provider和BLoC是目前最普遍採用的。

相關文章