【Android】Flutter项目该如何选择状态管理?

前言

Flutter从2018年底首次在谷歌开发者大会上亮相至今已3年多,其发展也算如火如荼。中小企业中大受欢迎,大厂也相继投入技术研究。 但依然有不少开发者疑惑于为自己的项目要选择哪个状态管理框架,今天笔者将对社区内相对火热🔥的状态管理库(Provider、BLoC、GetX)做一个技术分析和对比,帮助大家更好地为项目找到合适的状态管理库。

状态管理原则

我们在开发过程中,为了提高项目的可维护度和性能,也为了让页面UI跟数据(本地或服务端数据)有效分离的同时又能有效同步,都会让项目保持清晰的目录结构、同时启用状态管理库。
而MVVM模式已然成为前端项目中的主流架构。

MVVM 即 mode + view + viewModel

  • model表示页面状态(即页面需要的数据)
  • view表示页面视图(即UI)
  • viewModel是中间层,负责model和view的双向通信,实现页面视图更新驱动,同时负责的业务逻辑(例如:条件判断、网络请求等)的处理。

通过MVVM可以实现视图、数据、业务逻辑完全分离,使项目数据流向清晰明朗,提高性能,提高可维护度
用户对页面的操作触发数据的处理,数据的变动驱动页面UI的刷新。所以单一数据源单向数据流是做好状态管理的关键。

  1. 单一数据源:此处的UI是由单独的数据进行绑定的,是完全可控的,不会随意受到到其他数据的影响;
  2. 单向数据流:用户、系统的操作,触发数据的处理,数据的改变最终驱动视图发生更新,这个流向必须是单向且可追溯的。即无论用户、系统做了多少操作,最终数据都是处理好了才去更新视图,不能在视图更新后又反向去触发数据处理。

Flutter中的状态管理库,基本也都遵循MVVM原则,所以在遵循这个原则的基础上,如何使得状态管理性能更好且易于使用,是这些库的设计宗旨。本篇文章我主要对比以下三个库:Provider、BLoC、GetX

Flutter中的状态管理

在Flutter中,状态管理一直是老生常谈的问题。直到Flutter将Provider替代Provide作为官方推荐的状态管理库,Flutter关于状态管理的争论才开始趋于平静,但2021年GetX异军突起,又让众多初学者开始争论究竟使用哪个库来做状态管理。

那么状态管理为何这么重要呢?这里有一个业务场景可以给大家体会下:

假设服务器每隔十秒通过websocket给APP推送一次数据,数据包含文章内容,同时也包含阅读数、点赞数。APP有两个页面,A页面显示文章列表,点击列表项进入B页面查看文章详情。每隔十秒服务器的消息到达后,需要实时更新A、B页面的内容。

  • 普通方式:为了实现以上场景,在Flutter中,我们需要在每个页面注册一个websocket接收器,每个页面收到websocket消息通知的时候,通过setState去更新页面视图;如果有10个页面,就需要定义10个接收器,每个接收器还需要分别处理数据然后setState更新视图。性能差不说,开发效率上也大打折扣,出错率极高。
  • 状态管理:在上面的例子中,我们希望只在一个地方接收数据,只要数据一改变,各个视图就实时更新,无需每个页面setState。假设我们有一个更新事件的发布者,然后每个页面都是监听者。发布者在接收到数据后发出更新事件,监听者收到的同事视图便马上更新(无需setState), 那开发的体验就很完美了。
    这就是典型的发布订阅者模式,大部分前端包括Flutter中的状态管理,都是基于这种设计模式。

Flutter中的发布订阅模式,可以使用stream流机制stream系统学习

以上面的需求为例:
1. 我们需要一个websocket接收器,收到消息后通过streamController.skin.add发布事件;
2. 页面中stream注册监听器streamController.stream.listen,在监听回调中通过setState刷新视图。 

事实上,Flutter目前已有的状态管理,如rxdart、BLoC、fluter_redux、provider、GetX等,都离不开对stream流进行封装,再加上对Flutter InheritedWidget的封装演化出StreamBuilder、BlocBuilder等布局组件,从而达到无需setState就能实时更新视图的效果Flutter状态管理的演变

BLoC

BLoC是谷歌提出的一种设计模式,利用stream流的方式实现界面的异步渲染和重绘,我们可以非常顺利的通过BLoC实现业务与界面的分离。一般情况下,我们会在项目中引入flutter_bloc这个库。

目录结构

一个BLoC状态管理,通常会有三个文件:bloc、event、state

BLOC目录结构.png
简单使用方法
  1. 当一个组件需要使用到BLoC状态管理时,需要在调用组件之前,需要声明下BLoC的提供者,具体写法如下:

    BlocProvider<BadgesBloc>(create: (context)=> BadgesBloc(),child:UserPage()); 
    
  2. 当页面有多个BLoC提供者,或者整个App通用的BLoC提供者,即可提前在加载App之前全局声明。可以使用MultiBlocProvider进行声明,具体写法如下:

    MultiBlocProvider(
        providers: [
            BlocProvider<BadgesBloc>(create:(context) => BadgesBloc()),
            BlocProvider(create: (context) =>XXX()),
        ],
        child: MaterialApp(),
    ) 
    
  3. 页面布局将使用BlocBuilder创建widget,用户在页面中通过BlocProvider.of(context).add()发起事件

    /// 布局示例
    BlocBuilder<BadgesBloc, BadgesState>(
    // 接收bloc返回的state,视图与state中的变量进行绑定
    builder: (context, state) {
        var isShowBadge = false;
        if (state is BadgesInitialState) {
            isShowBadge = state.unReadNotification;
        }
        return Badge(
            showBadge: isShowBadge,
            shape: BadgeShape.circle,
            position: BadgePosition(top: -3, right: -3),
            child: Icon(Icons.notifications_none, color: Color(0xFFFFFFFF),),
        );
    }) 
    
    /// 页面发起事件
    // 发出的重设Badge的事件,事件要求传参为bool
    BlocProvider.of<BadgesBloc>(context).add(ResetBadgeEvent(true)); 
    
  4. 此时在bloc中就会接收到事件,判断发起的事件是event中的哪个事件,然后返回对应的state,具体写法如下:

    @override
    Stream<BadgesState> mapEventToState(BadgesEvent event) async* {
        if (event is ResetBadgeEvent) {
            yield BadgesInitialState(event.unReadNotification); 
        } 
    }
    
    Stream<BadgesInitialState> _mapGetActivityCountState(isShow) async* {
        // 此处更改状态的值,让上面的视图代码可以根据此值进行更新
        yield BadgesInitialState(isShow);
    } 
    
  5. 补上event、state的代码截图

event.png
state.png
优缺点
  1. 【优点】BLoC的目录结构清晰,完全符合mvvm的习惯。对于工程化项目来说会比较受欢迎,,团队协作起来会减少出错的概率,大家都跟着一个模式去做,维护性也提高了;
  2. 【优点】业务流清晰。使用dart stream事件流作为基础原理,event和state都是事件驱动的,用户行为触发事件,事件处理完推出状态流,稳定的数据流向往往能提高代码的可靠性;
  3. 【缺点】BLoC使用起来相对复杂,需要创建多个文件。虽然官方引入了cubit,把event组合到bloc文件中,但强烈的结构化依然让不少初学者难以入门;
  4. 【缺点】颗粒度的把控相对困难。通过BlocBuilder构建的视图,在state变更时,视图都会rebuild,想要控制颗粒度只能把bloc再拆细,这会极大的增加代码复杂度和工作量;不过这个问题可通过引入freezed生成代码,然后通过buildWhen等方式减少视图刷新的频次。

Provider

Provider是Flutter官方开发维护的,也是近些年官方最为推荐的状态管理库。Provider 是建立在 InheretedWidget 之上做了封装,大大减少了我们需要编写的代码量。其特点是:不复杂、好理解,可控度高。我们会在项目中引入provider这个库。

简单使用方法
  1. 当组件需要使用到Provider状态管理时,需要在调用组件之前,需要声明下Provider的提供者,具体写法如下:
ChangeNotifierProvider<LoginViewModel>.value(
    notifier: LoginViewModel(),
    child:LoginPage(),
) 
  1. 当一个页面有多个Provider提供者,或者整个App有几个通用的Provider提供者,有多个页面都需要使用,即可提前在加载App之前全局声明。可以使用MultiProvider进行声明:
MultiProvider(
    providers: [
        ChangeNotifierProvider<LoginViewModel>( create: (_) => LoginViewModel(),),
        ChangeNotifierProvider<HomeViewModel>( create: (_) => HomeViewModel(),),
    ],
    child: MaterialApp()
) 
  1. 页面布局需创建一个Provider对象,之后直接在widget中绑定viewModel中的数据或者触发事件即可
/// 创建provider对象
var loginVM = Provider.of<LoginViewModel>(context);
Column(
    children: <Widget>[
        new Padding(
            padding: EdgeInsets.only(top: 85),
            child: new Container(
                height: 85.h, width: 486.w,
                child: TextFormField(
                    // 绑定viewModel的数据
                    controller: loginVM.userNameController,
                    decoration: InputDecoration(
                        hintText: "请输入用户名",
                        icon: Icon(Icons.person),
                        hintStyle: TextStyle(color: Colors.grey, fontSize: 24.sp),
            ),
            validator: (value) {
                return value.trim().length > 0 ? null : "必填选项"; }
                )
            )
        ),
        new Padding(
            padding: EdgeInsets.only(top: 40),
            child: new Container(
                height: 90.h, width: 486.w,
                child: new RaisedButton(
                       // 点击触发viewModel中的方法
                      onPressed: () { loginVM.loginHandel(context)},
            color: const Color(0xff00b4ed), shape: StadiumBorder(),
            child: new Text( "登录",
                    style: new TextStyle(color: Colors.white, fontSize: 32.sp),
            ),
        ),
    )
)] 

  1. 再来看viewModel中的写法,class必须继承ChangeNotifier。当有数据需要更新的时候,调用notifyListeners(),页面就会刷新
provider_1.png

provider_2.png
实现原理
  1. Provider主要是对 InheritedWidget 组件进行上层封装,使其更易用,通过ChangeNotifier来处理数据,从而减少了InheritedWidget的大量模版代码。
  2. 从源码上我们可以看到Provider直接继承于InheritedProvider,通过工厂构造函数Provider.value传入model和child节点,然后通过context.dependOnInheritedWidgetOfExactType<_InheritedProviderScope<T?>>();对值进行监听。
  3. 而_InheritedProviderScope就是继承于InheritedWidget的,所以Provider的实现真的很简单,有用过InheritedWidget的小伙伴可以去看下源码。
优缺点
  1. 【优点】使用简单。model继承ChangeNotifier,没有更多的布局widget,只需要通过context.read / context.watch操作或者监听model即可;
  2. 【优点】颗粒度把控简单。为了解决widget重新build太频繁的问题,官方推出了context.select来监听对象的部分属性。也可使用Consumer/Selector进行布局;
  3. 【优点】基于官方InheritedWidget的封装,不存在任何风险,很稳定且不会给性能方面加负担
  4. 【缺点】context强关联,有Flutter开发经验的都知道,context大多时候基本都是在widget中才能获取到,在其他地方想随时获取 BuildContext 是不切实际的,也就意味着大多时候只能在view层去获取到Provider提供的信息。

GetX

GetX是一个轻量级且强大的状态管理库,这个库试图完成很多工作,它不仅支持状态管理,也支持路由、国际化、Theme等一大堆功能。GetX在Flutter状态管理中绝对算是异军突起,一经发布就因其简单且全面的优势,引得一大批簇拥者;我并未认真研究GetX,但简单接触后我个人并不喜欢,这种全家桶式的库会让我们的项目相对局限,同时让项目开发者处于没有进步且被动的局面。

简单使用方法

我们直接使用 GetX 演示官方example"计数器",

  • 每次点击都能改变状态
  • 在不同页面之间切换
  • 在不同页面之间共享状态
  • 将业务逻辑与界面分离
  1. 把MaterialApp变成GetMaterialApp
void main() => runApp(GetMaterialApp(home: Home())); 

  1. 创建你的业务逻辑类,将变量、方法和控制器放在里面。 通过".obs "使变量成为可观察的
class Controller extends GetxController{
  var count = 0.obs;
  increment() => count++;
} 

  1. 创建界面
class Home extends StatelessWidget {

  @override
  Widget build(context) {

    // 使用Get.put()实例化你的类,使其对当下的所有子路由可用。
    final Controller c = Get.put(Controller());

    return Scaffold(
      // 使用Obx(()=>每当改变计数时,就更新Text()。
      appBar: AppBar(title: Obx(() => Text("Clicks: ${c.count}"))),

      // 用一个简单的Get.to()即可代替Navigator.push那8行,无需上下文!
      body: Center(child: ElevatedButton(
              child: Text("Go to Other"), onPressed: () => Get.to(Other()))),
      floatingActionButton:
          FloatingActionButton(child: Icon(Icons.add), onPressed: c.increment));
  }
}

class Other extends StatelessWidget {
  // 你可以让Get找到一个正在被其他页面使用的Controller,并将它返回给你。
  final Controller c = Get.find();

  @override
  Widget build(context){
     // 访问更新后的计数变量
     return Scaffold(body: Center(child: Text("${c.count}")));
  }
} 

可以看出确实使用非常的简单,而且已经不太遵循MVC和MVVM结构了,但影响不大,能高效的开发才是国内团队最关心的问题。更多详情见GetX readme

实现原理

实现原理这块,我只简单解析这三点:① 如何做到数据驱动;② 如何管理路由;③ 脱离了context后,资源该如何回收。

  1. GetX通过.obx或Obx(builder)对变量实现订阅,以实现数据一改变就通知视图改变的效果。这块的实现原理还是离不开dart的stream流,通过源码我们可以知道两者最终都是继承于RxNotifier,而RxNotifier with NotifyManagerNotifyManager就是提供streamSubscription的扩展类;
class RxNotifier<T> = RxInterface<T> with NotifyManager<T>;

mixin NotifyManager<T> {
  // 注释:通过GetStream提供onListen;onPause;onResume的回调
  GetStream<T> subject = GetStream<T>(); 
  // 注释:Map对象,后续通过key-value键值对进行通知
  final _subscriptions = <GetStream, List<StreamSubscription>>{};

  bool get canUpdate => _subscriptions.isNotEmpty;

  // 注释:内部方法,订阅内部流的更改
  void addListener(GetStream<T> rxGetx) {
    if (!_subscriptions.containsKey(rxGetx)) {
      final subs = rxGetx.listen((data) {
        if (!subject.isClosed) subject.add(data);
      });
      final listSubscriptions =
          _subscriptions[rxGetx] ??= <StreamSubscription>[];
      // 发出通知
      listSubscriptions.add(subs);
    }
  }

  StreamSubscription<T> listen(
    void Function(T) onData, {
    Function? onError,
    void Function()? onDone,
    bool? cancelOnError,
  }) =>
      subject.listen(
        onData,
        onError: onError,
        onDone: onDone,
        cancelOnError: cancelOnError ?? false,
      );

  /// 注释:关闭订阅,释放资源
  void close() {
    _subscriptions.forEach((getStream, _subscriptions) {
      for (final subscription in _subscriptions) {
        subscription.cancel();
      }
    });

    _subscriptions.clear();
    subject.close();
  }
} 

  1. GetX的路由管理也是通过封装Flutter的Navigator,比如:Get.toName()就是通过GetX提供的全局NavigatorState还是调用了pushNamed
Future<T?>? toNamed<T>(
  String page, {
  dynamic arguments,
  int? id,
  bool preventDuplicates = true,
  Map<String, String>? parameters,
}) {
  if (preventDuplicates && page == currentRoute) {
    return null;
  }

  if (parameters != null) {
    final uri = Uri(path: page, queryParameters: parameters);
    page = uri.toString();
  }

  // 注释:global(id).currentState的就是GetMaterialApp.router中的navigatorKey
  return global(id).currentState?.pushNamed<T>(
        page,
        arguments: arguments,
      );
} 

  1. 从第一点我们知道了如何进行数据状态的管理,同时NotifyManager也提供了close的方法去释放资源,到这里我们不禁会问:那啥时候去调用close释放资源呢?
    答案是:通过widget的dispose生命钩子调用close,从而释放资源。
@override
void dispose() {
  if (widget.dispose != null) widget.dispose!(this);
  if (_isCreator! || widget.assignId) {
    if (widget.autoRemove && GetInstance().isRegistered<T>(tag: widget.tag)) {
      GetInstance().delete<T>(tag: widget.tag);
    }
  }
  _subs.cancel();
  // 注释:在这里释放资源
  _observer.close();
  controller = null;
  _isCreator = null;
  super.dispose();
} 

优缺点
  1. 【优点】使用最简单,用起来确实很简单,极易上手;脱离context,随时随地想用就用,解决了BLoC和Provider的痛点;
  2. 【优点】全家桶式功能,使用GetX后,我们无需再单独去做路由管理、国际化、主题、全局context等,甚至还支持服务端开发;
  3. 【缺点】第2点优点同样也是缺点,GetX帮我们封装了很多本来Flutter就提供的Api,减少了开发者很多的工作。这会让项目极度依赖GetX,而在Flutter更新迭代这么快的情况下,谁也不敢保证GetX全家桶的更新节奏,一旦更新慢了,开发者只能等GetX(当然能够参与社区开源的另当别论)。另外,GetX的使用真的太基础了,让初学者易上手的同时,技术也容易停留于表面

总结

image.png

除了上诉的几种方案,还有其他的库,如redux / fish_redux/ RiverPod,这些库有的过于复杂,有的刚出不久,笔者调研过程中有留意但并没有用过,活跃度确实也没有上面方案多。

总之,BLoC适合相对大的工程化项目团队使用,架构清晰;Provider很纯粹,也很好用;GetX全家桶一把梭,极度适合新手开发者......

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容