一篇文章,告别Flutter状态管理争论,问题和解决

2024-03-26 12:44

本文主要是介绍一篇文章,告别Flutter状态管理争论,问题和解决,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

起因

每隔一段时间,都会出现一个新的状态管理框架,最近在YouTube上也发现了有人在推signals, 一个起源于React的状态管理框架,人们总是乐此不疲的发明各种好用或者为了解决特定问题而产生的方案,比如Bloc, 工具会推陈出新,新的语法会带来更便捷的方式,但原理和优缺点是更重要的一面,我们接下来聊聊这一点。

原理

状态管理的起点是值的改变也就是通常代码中的set方法, 状态的终点在Flutter或者其他UI框架中,对单个绘制节点进行setDirty来标记一个需要重新绘制节点,最后生成新的持续帧来承接新的数据,在Flutter中通过setState 来实现。当我们明确了状态管理的起点终点, 中间对于值的操作和缓存,diff算法,状态传递,状态转移过程等是这些状态管理框架们根据要解决的实际问题来做的权衡(trade off)。

  • figure 1 状态管理的空间

state_manage_space.png

需要权衡什么?

易用性

在Flutter开发中,有一句著名的话你可以不了解Flutter,但不能不知Getx, 这点很像Java服务端的spring框架, 提供了一套完备的开发工具,也许不是最合适于特定项目,但一定合适于简单项目。那Getx的权衡是什么? 笔者觉得托管,完备简单,是Getx的最大权衡,比如,你需要全局使用Getx的组件,来包裹所有的Widget,这样Getx就可以托管你的程序,依赖注入,主题,屏幕尺寸,路由都可以被Getx管理。比如简单的Obx函数将StatefulWidget封装,通过全局的NotifyManager来注册,管理被捕获的值。

总的来说,Getx通过巧妙的架构设计,侵入式的托管式框架,确实做到了在简单项目中的面向Getx开发

所以我们知道在状态管理时,第一个权衡,简单好上手

与Getx简单相反的是Bloc的复杂, Bloc 提供了全面且复杂的状态管理模式,Predictable可预测的以及Bloc的设计模式的结合使框架有很多的模版,是的更细致的状态更新管理,隔离的状态称为可能, 后面会结合Riverpod来解释这一优势。

状态传递

第一个需要权衡的点是状态转移,如果使用原生StatefulWidget,当我们Widget数开始庞大起来,不可避免的要进行组件的拆分降低维护难度,或者出于重用的目的,这样的操作势必会进行状态的转移,在这个维度就是百花齐放了,Getx和Rivierpod是将依赖管理状态相结合,通过全局的状态声明状态获取来达到这个目的,例如

/// 声明并加入到全局Scope

class DayChecked extends _$DayChecked {DateTime build() {return DateTime.now();}
}
/// 任意地方使用
final focusedDay = ref.watch(dayCheckedProvider); // 读取
ref.read(dayCheckedProvider.notifier).set(focusedDay) // 修改// Getx用法
class ScheduleState {Rx<DateTime> focusedDay = DateTime.now().obs;
}
class ScheduleLogic extends GetxController {final ScheduleState state = ScheduleState();
}
// 使用
Get.find<ScheduleLogic>().state.focusedDay

全局的状态管理易用程度是中等的,Getx和flutter_hooks 都提供了局部的状态声明,这在Widget内部状态处理时更方便的,hook的概念起源于React,状态管理相对于Getx是一种更轻量的解决方案,稍后我们提及其他方面的优势。我们可以认为Hook更适合内部状态变化。

全局的状态管理带来一个较为严重的问题,虽然可以通过组合别的方式来解决,但确实不够完备。

例如:TodoList中,任务的子父级关系的,虽然是同一个页面,使用同一个状态管理类,但状态应该是独自的,全局的状态管理解决这个问题的方法是通过入参,这种形式,例如Getx中Get.find(tag)和riverpod的build(params)来生成多个状态管理.

  // 使用参数化区分不同状态logic = Get.find<TaskDetailLogic>(tag: task.id);ref.watch(taskDetailProvider(task.id));

这样会带来一个比较棘手的问题,我们必须将logic或者参数tag一级一级的传递,或者通过callback一级一级的回传操作,来保证状态的正确读取和修改,这点在复杂项目当中是很棘手的,当锚定的入参过多时,我们会考虑父子Widget传递logicstate,保证状态的正确引用,但这丧失了灵活性和Widget重用的范围。

这种情况下,在全局状态和Widget内部状态中间,需要一种状态管理, 局部状态管理的概念就能够很好的解决这个问题,Bloc是这种方式的代表,通过BlocProvider提供一个局部, 被挂载到局部的状态,可以被context.read<SignInBloc>()获取相关联的状态。riverpod也能做到局部状态,只是他的设计初衷并不是如此,所以使用局部状态时,会显得很蹩脚,分离context,使用ref, 也导致官方并不推荐使用局部状态的方式,这将会造成混乱。

figure 2 状态范围(Scope)

scope.png

状态操作

使用Riverpod的同学会特别喜欢Flutter Hook, 因为riverpod对状态的声明和操作是分离的,尽管代码上他们封装在一起,但我们知道,这是OOP的写法, 便于理解,因为在一些语言中,方法状态是分离的,对象中内聚函数,第一个参数是Self, 同样,生成式的Riverpod通过另一种方式实现了分离。如下


abstract class NotifierBase<State> {NotifierProviderElement<NotifierBase<State>, State> get _element;State get state {_element.flush();return _element.requireState;}set state(State value) {// ignore: invalid_use_of_protected_member_element.setState(value);}Ref<State> get ref;

我们暂时关心如何通过设计达到这个目的,我们需要关心的是状态的操作是内部的,也就是被限制的,在ReactHook中,类似于[set, get], 返回的值并没有直接修改的能力,相较于Getx的 1.obs和 flutter hook的useState(1),是一个更加内聚的设计,但丧失了灵活性,两者需要在不同场景下结合使用

状态的声明有两种,基于Stream或者Listenable,两者都是响应式中重要的组成部分,不同的状态管理框架会权衡选择或者组合使用,例如,riverpod是基于Listenable, Bloc基于Stream, Getx则是混合使用的,我们大部分情况下不需要考虑状态的声明, 状态的修改是更影响开发体验,例如我们使用riverpod必须使用模版方法改变内部的值,然后借用freezed生成不可变对象, 保证值被完整的更新。或者实现equal方法主动进行diff并通知值的变化。例如:bloc+freezed


class SignInState with _$SignInState {const factory SignInState({String? password,required Option<String> passwordError,}) = _SignInState;factory SignInState.initial() => SignInState(passwordError: none(),);
}
class SignInBloc extends Bloc<SignInEvent, SignInState> {SignInBloc() : super(SignInState.initial()) {on<SignInEvent>((event, emit) async {await event.map(passwordChanged: (PasswordChanged value) {emit(state.copyWith(password: value.password,emailError: none(),),);},);});}
}

这种状态的修改对简单状态修改来说比较冗余,对层级复杂状态的修改,freezed就会很累赘,试想一下一个三级以上的嵌套, 例如:


class Task with _$Task {const factory Task({String? id,Alert? alert,}) = _Task;
}
class Alert with _$Alert {const factory Alert({String? id,Trigger? trigger,}) = _Alert;
}
class Trigger with _$Trigger {const factory Trigger({String? id,required DateTime time,}) = _Trigger;
}
class TaskDetail extends _$TaskDetail {Task build() {return Task();}/// 修改其中的一个值changeTrigger(DateTime dateTime) {state = state.copyWith(alert: state.alert?.copyWith(trigger: state.alert?.trigger?.copyWith(time: dateTime)));}
}

这是一个很常见的场景,在riverpod的官方文档中,我们可以看到将网络请求作为状态返回。这种在实际的复杂项目中是极其不推荐使用freezed或者说,引入不可变性, 这会导致灾难,虽然可以使用unfreezed放开部分权限,但这违背了freezed的设计初衷多层级且要修改 的Http请求并不适合作为状态返回,我们的状态管理,需要针对是ViewModel, 这种情况下,一般解决方案会有两种。

  • 主动通知 ref.notifyListener
  • 将多层级数据结构展开(flat)

这取决于对数据操作的频率和数据字段的难度,需要在实际使用当中进行权衡,需要额外关注的是,如果我们需要记录状态的变化,则freezed的不可变性会是更好的解决方案,需要优先考虑。

第二个常见的场景是需要持久化存储的时候, 不可变性限制了很多,比如不能修改内部变量,不能添加方法(可以通过extension),不能够使用继承, 例如 Hive 的Object就不能被继承来实现更改,当然,因为不可变的特性,也无法修改。

总的来说,在Getx中,模版中的state概念类似ViewModel的载体,内部是单独的小的state, 我们可以直接监听小的state,这也是Getx很灵活的一点。其他的框架则更多偏向于state整体的管理和更新(bloc中cubit很像但需要太多模版代码),freezed的限制和使用可以参考我的另一篇文章,不再赘述,因为这种生成式或者将来添加的都会遇到类似的问题,所以多聊一点。

状态的结束

riverpod在官网提及自己的优势是,自动生命周期管理。这点确实是状态管理的一个难点,也是开发者经常会忽略的一个点,当我们使用StatefulWidget时我们并不需要考虑这个问题,因为state随着Widget结束而释放,但我们使用Stream或者Listenable时,如何释放,变成了一个棘手的问题,flutter hook通过包装的Element, LinkedList<_Entry<HookState<Object?, Hook<Object?>>>>? _needDispose;, 调用use时加入dispose列表。riverpod通过侵入Widget树,通过Element监听生命周期,并管理依赖。

  void dispose() {runOnDispose(); // 魔法for (final sub in _dependencies.entries) {sub.key._providerDependents.remove(this);sub.key._onRemoveListener();}_dependencies.clear();_externalDependents.clear();}

其中runOnDispose() 就是riverpod的魔法, 可以通过他来自动取消Http请求的UI相关的耗时作业。 对于状态结束的监听和操作每个框架的实现细节不一致,但本质上却是要回归到Element 的生命周期函数,以及针对Stream和Listenable两种方式的Dispose的处理。

状态依赖和传递

我们大部分时间,大部分需求都是在处理简单任务,例如,将数据库数据或服务接口整合,然后布局,填充样式,最终渲染到页面。简单任务通常是简单的流程,这也是为什么官方更推荐riverpod的原因,因为它更契合这种场景,我们不需要做业务层级的分离,只需要在某个地方声明某个网络请求provider,然后监听结果,或者修改结果。这是非常理想的情况,稍微复杂的情况,就需要状态之间的依赖和传递了,例如这个场景,

这个场景下,任务列表是依赖于选中日期的,不同的状态处理框架提供了不同的解决方法,虽然本质上都是对Stream和Listenable的监听,但对于状态的依赖管理易用性是有很大差别的,Getx的方式会比较普通,扩展了Stream的函数,但仍需要主动Listen, riverpod的写法更巧妙,在语法上有很大的改善,比如当前这个场景。我们如果使用riverpod, 代码如下

(dependencies: [DayChecked, TaskList])
class DayTasks extends _$DayTasks {Future<List<TaskModel>> build() async {final checkedDay = ref.watch(dayCheckedProvider);// get task by checkDayretunr await serivce.get(checkedDay);}
}

区别于flutter_hook的函数是不明确的,例如实现相同功能,可能需要如下代码

    final checkedDate = useRef(DateTime.now());final taskList = useState(<TaskModel>[]);useEffect(() {taskList.value = service.get(checkedDate);}, [checkedDate.value]);

对于状态的传递,方程式的传递是相对优雅的,在数学上,我们知道y = 2x, 或者 z = 3x + 2y + 1 这样的简单等式, 能够优雅的以纯函数式来实现状态的转移是极其优雅的,对于这样的依赖传递形式的状态转移方程,riverpod的实现要优雅且严谨很多,更不容易出错。设想一个场景,用户是否为VIP, 这是一个简单状态,大多数情况下比用户信息更频繁的去读取,这个字段也需要被设计到用户信息接口,如果我们对UI层只提供简单的VIP状态,我们可以使用如下写法。

()
class UserConifg extends _$UserConifg {UserModel build() {return UserModel(avatar: '', id: "1", nickName: "nickName", pay: true);}
}(dependencies: [UserConifg])
class Vip extends _$Vip {bool build() {final user = ref.watch(userConifgProvider);return user.pay;}
}

上述方式体现了这种方式的优雅,我们甚至可以将UserConifg放到别的包下,将Vip状态和UI关联,这种方式优雅的结构了复杂场景下的状态转移高内聚,低耦合的原则。回到线性方程,它也符合y = 2x代数方程的思维。

总结:

任何领域都大概率都没有银弹, 软件开发领域也是如此,我们创造工具,使用工具,改进工具,才有软件的繁荣。不一定非要讨论那个框架或技术有高低差异。在实际开发中,稳定,熟悉是稳定三角的另外两个重要的方面,不同的框架的缺点,总会有一些或者优雅,或者败絮其中的解决方案,在项目中,最重要的适合,合适的工具会让我们开发过程事半功倍,其次是稳定性学习难度, 不过,一切都需要合适的权衡(trade off)动态的去匹配当下最重要的事。

这篇关于一篇文章,告别Flutter状态管理争论,问题和解决的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/848549

相关文章

线上Java OOM问题定位与解决方案超详细解析

《线上JavaOOM问题定位与解决方案超详细解析》OOM是JVM抛出的错误,表示内存分配失败,:本文主要介绍线上JavaOOM问题定位与解决方案的相关资料,文中通过代码介绍的非常详细,需要的朋... 目录一、OOM问题核心认知1.1 OOM定义与技术定位1.2 OOM常见类型及技术特征二、OOM问题定位工具

C++右移运算符的一个小坑及解决

《C++右移运算符的一个小坑及解决》文章指出右移运算符处理负数时左侧补1导致死循环,与除法行为不同,强调需注意补码机制以正确统计二进制1的个数... 目录我遇到了这么一个www.chinasem.cn函数由此可以看到也很好理解总结我遇到了这么一个函数template<typename T>unsigned

Vue3绑定props默认值问题

《Vue3绑定props默认值问题》使用Vue3的defineProps配合TypeScript的interface定义props类型,并通过withDefaults设置默认值,使组件能安全访问传入的... 目录前言步骤步骤1:使用 defineProps 定义 Props步骤2:设置默认值总结前言使用T

SpringBoot 多环境开发实战(从配置、管理与控制)

《SpringBoot多环境开发实战(从配置、管理与控制)》本文详解SpringBoot多环境配置,涵盖单文件YAML、多文件模式、MavenProfile分组及激活策略,通过优先级控制灵活切换环境... 目录一、多环境开发基础(单文件 YAML 版)(一)配置原理与优势(二)实操示例二、多环境开发多文件版

JWT + 拦截器实现无状态登录系统

《JWT+拦截器实现无状态登录系统》JWT(JSONWebToken)提供了一种无状态的解决方案:用户登录后,服务器返回一个Token,后续请求携带该Token即可完成身份验证,无需服务器存储会话... 目录✅ 引言 一、JWT 是什么? 二、技术选型 三、项目结构 四、核心代码实现4.1 添加依赖(pom

504 Gateway Timeout网关超时的根源及完美解决方法

《504GatewayTimeout网关超时的根源及完美解决方法》在日常开发和运维过程中,504GatewayTimeout错误是常见的网络问题之一,尤其是在使用反向代理(如Nginx)或... 目录引言为什么会出现 504 错误?1. 探索 504 Gateway Timeout 错误的根源 1.1 后端

Web服务器-Nginx-高并发问题

《Web服务器-Nginx-高并发问题》Nginx通过事件驱动、I/O多路复用和异步非阻塞技术高效处理高并发,结合动静分离和限流策略,提升性能与稳定性... 目录前言一、架构1. 原生多进程架构2. 事件驱动模型3. IO多路复用4. 异步非阻塞 I/O5. Nginx高并发配置实战二、动静分离1. 职责2

Redis实现高效内存管理的示例代码

《Redis实现高效内存管理的示例代码》Redis内存管理是其核心功能之一,为了高效地利用内存,Redis采用了多种技术和策略,如优化的数据结构、内存分配策略、内存回收、数据压缩等,下面就来详细的介绍... 目录1. 内存分配策略jemalloc 的使用2. 数据压缩和编码ziplist示例代码3. 优化的

解决升级JDK报错:module java.base does not“opens java.lang.reflect“to unnamed module问题

《解决升级JDK报错:modulejava.basedoesnot“opensjava.lang.reflect“tounnamedmodule问题》SpringBoot启动错误源于Jav... 目录问题描述原因分析解决方案总结问题描述启动sprintboot时报以下错误原因分析编程异js常是由Ja

SpringBoot集成XXL-JOB实现任务管理全流程

《SpringBoot集成XXL-JOB实现任务管理全流程》XXL-JOB是一款轻量级分布式任务调度平台,功能丰富、界面简洁、易于扩展,本文介绍如何通过SpringBoot项目,使用RestTempl... 目录一、前言二、项目结构简述三、Maven 依赖四、Controller 代码详解五、Service