[译]Flutter - 使用Provider实现状态管理

4,844 阅读13分钟

这篇文章好的的地方在于它不仅讲了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运行起来的时候是这样的:

搭建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是需要的。卡片如下:

最后会用到CurrencyRate两个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();
  }
}

解释:

  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,分别是:汇率交换,存储以及网络请求。看下面的架构图,所有服务都在右边红色的框表示:

  1. 创建一个抽象类,在里面添加所有会用到的方法
  2. 给抽象类写一个具体的实现类

因为每次创建一个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());
}

解释:

  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,点击右上角的心形。

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。

在大型app中使用Provider

你可以按照本文所述的方式添加更多的界面。一旦你习惯了为每个界面添加view model就可以考虑为某些类创建基类来减少重复代码。本文没有这么做,因为这样的话理解这些代码要花更多的时间。

其他的架构和状态管理方法

如果你不喜欢本文所述的架构,可以考虑BLoC模式。BLoC模式入门也是一个很好的起点。你会发现BLoC模式也不像传说的那么难以理解。

还有其他的,不过Provider和BLoC是目前最普遍采用的。