前言
Flutter 以其高还原度,匹配原生的性能和高开发效率,已经成为主流的移动跨平台技术。在不断发展过程中,也衍生出了很多优秀的开发框架,帮助开发者提高开发效率和降低开发成本。Fish Redux 就是一款优秀的 Flutter 状态管理框架。
目前零售移动在很多业务中都用到 Flutter,也是基于主流的 Fish Redux + Flutter Boost 模式。新技术的落地总是会伴随着各种踩坑,其中比较深刻的,是 Flutter 界面卡顿的问题,最终通过深入分析 Fish Redux 状态管理机制解决了该问题,也总结了一些经验供大家参考。
优化实践
问题背景
商家反馈在收银机上使用进出存单据功能很卡,操作界面切换按钮点击反应都很慢。从商家反馈的视频和我们实际操作的视频中,明显可以感受到在界面过渡、数据加载、点击操作、列表滑动,弹框都存在肉眼可见的卡顿,特别是在一些配置不怎么好的收银设备上。针对这些现象,我们将问题分为两大类:
1、数据加载等耗时操作卡顿
2、UI渲染卡顿
对问题进行分类之后,就开始使用 DevTool 中提供的性能视图对卡顿界面视图渲染情况进行了分析。针对库存盘点场景选取了严重卡顿的操作:添加商品、修改商品数据、动画展示、网络数据请求和加载。
界面布局
添加商品
StockCheckOrderEditMainState:顶层 State
从列表添加一个商品之后,可以看到整个界面都进行了重绘,绘制范围明显不合理。
修改商品数据
修改数据与添加商品类似,也是也是进行了全局刷新
网络数据请求和加载
在网络数据回来之后,发现 Dart_StringToUTF8 耗时长,深入排查之后发现,是 JSON 数据驼峰和下划线转换导致。
经过初步排查之后,基本确定了问题是存在耗时操作和更新渲染范围过大导致。对于渲染范围问题,项目中基本都是按照官方推荐的方式进行了很多界面的组件拆分和复用,为什么没有达到局部渲染的效果呢?带着这个问题,对 Fish Redux 刷新机制进行了探究。
Fish Redux 简介
此部分做一些核心概念介绍,已经了解过的同学可以跳过。
Fish Redux 是一个以 Redux 作为数据管理的思想,以数据驱动视图,组装式的 Flutter 应用框架,里面有几个很重要的角色: State、Effect、Reducer 和 Action。
图中的T代表某一个类型的 State,UI 交互产生了交互 action,effect 处理对应的交互 action 之后,又会产生数据更新 action,reducer 收到数据更新 action 之后完成 state 的更新,最终驱动了 UI 的更新,进入下一个循环。
组件(Component)是对视图展现和逻辑功能的封装,一个复杂的界面通常都是由一个个组件组合而成,大组件使用 Dependencies 完成所依赖的小组件、适配器的注册。
Component = View + Effect(可选) + Reducer(可选) + Dependencies(可选)
只有实现了 Reducer 的组件才能拥有自刷新的能力,否则都是跟随父组件更新而更新。
Page 是一个页面级的 Component,类似于 Android 中的 Activity,redux 中的 store 就是存储在 Page 组件中,Page 中的所有 Component 都共用这个 store。store 负责 reducer 事件分发。Page 中还有一个 DispatchBus 类型的 bus 属性,负责 Effect 事件分发。
Fish Redux 刷新机制
视图创建
在了解界面刷新流程之前,需要先了解一下整个界面的构建流程。构建过程主要任务是构建视图+事件注册。
/// component.dart
abstract class Component<T> extends Logic<T> implements AbstractComponent<T> {
@override
Widget buildComponent(
Store<Object> store,
Get getter, {
required DispatchBus bus,
required Enhancer<Object> enhancer,
}) {...}
}
Component 实现了 AbstractComponent 接口,实现了 buildComponent 方法。框架从触发顶层组件的。
buildComponent 开始整个视图的绘制流程,容器组件将创建自己的ComponentWidget 以及触发子组件 ComponentWidget 的创建,就这样完成整个视图的创建。ComponentWidget 中完成 ComponentState 的创建,在 ComponentState 的 initState 中,会调用 store 的的 subscribe 方法将自己的 onNotify 方法注册到 store 的 listener 中,这样就完成了监听reducer事件监听。
/// component.dart
class ComponentState<T> extends State<ComponentWidget<T>> {
void initState() {
/// ...
/// 注册监听
_ctx.registerOnDisposed(widget.store.subscribe(() => _ctx.onNotify()));
}
}
Effect 的注册是在 Component 的 createContext 方法创建 ComponentContext 时,在ComponentContext 的父类 LogicContext 构造方法中,调用bus.registerReceiver(_effectDispatch) 完成的。
/// logic.dart
abstract class LogicContext<T> extends ContextSys<T> with _ExtraMixin {
LogicContext({...}){
/// ...
/// Register inter-component broadcast
registerOnDisposed(bus.registerReceiver(_effectDispatch));
}
}
这样,就完成了 Effect 与 Reducer 的事件监听。
事件分发与处理
Effect 与 Reducer 的事件处理流程存在重合和不一致的地方,一致的点就是入口都是 dispatch 方法(这个地方有一个隐性要求:Effect 与 Reducer 事件不能一致,否则会死循环),都会先从自己的组件开始寻找能处理这个事件的监听者,如果找不到就会交给顶层组件进行分发。不一致的点是 effect 不关心处理结果,reducer 关心处理结果。
Effect处理流程
流程就比较简单,因为 bus 中已经存储了所有 effect 处理,这个时候只需要遍历一下_dispatchList 就可以广播处理消息了。
Reducer 处理流程
Effect 与 Reducer 的事件处理流程存在重合和不一致的地方,一致的点就是入口都是 dispatch 方法(这个地方有一个隐性要在整个界面创建完成后,父组件通过 connector 将子组件的 reducer 组合在一起,这样在处理事件时,就可以访问到子组件的reducer。而在 Fish Redux 中,reducer 的事件都从是 store 中开始,事件发生后,从根节点开始向下找寻可以处理这个事件的 reducer,如果没有找到就返回原有 state,找到之后会调用其更新方法,更新 state,并且把新的 state 返回。
/// combine_reducers.dart
Reducer<T>? combineReducers<T>(Iterable<Reducer<T>?>? reducers) {
final List<Reducer<T>?>? notNullReducers =
reducers?.where((Reducer<T>? r) => r != null).toList(growable: false);
/// ... 前置处理
return (T state, Action action) {
T nextState = state;
for (Reducer<T>? reducer in notNullReducers) {
/// 这里有问题,必须要重新赋值对象
final T? _nextState = reducer?.call(nextState, action);
nextState = _nextState!;
}
assert(nextState != null);
return nextState;
};
}
而 reducer 的事件是从 store 中发出的。store 的创建是在 Page 组件中,在创建 store 时,会实现dispatch 方法,内容就是分发 reducer 事件,完成分发之后,就会得到整个 page 最新的 state 状态,然后进行 state 更新事件的广播,通知所有组件进行更新。
// create_store.dart
Store<T> _createStore<T>(final T preloadedState, final Reducer<T> reducer) {
// 前置处理
return Store<T>()
..getState = (() => _state)
..dispatch = (Action action) {
// 前置校验
try {
_isDispatching = true;
// reducer 分发处理
_state = _reducer(_state, action);
} finally {
_isDispatching = false;
}
final List<_VoidCallback> _notifyListeners = _listeners.toList(
growable: false,
);
// 广播更新消息
for (_VoidCallback listener in _notifyListeners) {
listener();
}
_notifyController.add(_state);
}//..更多属性初始化
}
而组件的更新逻辑,就是收到更新时间之后,调用 shouldUpdate 方法判断是否需要更新界面, shouldUpdate 默认实现就是判断前后state是否相等。
/// context.dart
class ComponentContext<T> extends LogicContext<T> implements ViewUpdater<T> {
@override
void onNotify() {
final T now = state;
// 默认是 !identical(_latestState, now)
if (shouldUpdate(_latestState, now)) {
_widgetCache = null;
markNeedsBuild?.call();
_latestState = now;
}
}
}
// markNeedsBuild 实现
markNeedsBuild: () {
if (mounted) {
setState(() {});
}
}
但是按道理我们实现了组件化之后,调用的更新方法也是子组件的,应该只刷新子组件才对,但是从实际的表现来看,是会导致整个界面都刷新,说明 Page 的 state 也变了。
Connector 机制
其实在这个过程中,有一个重要且比较容易被忽视的角色,就是 Connector,Connector 存在两个子类:MutableConn 和 ImmutableConn,ImmutableConn 处理更新时,如果是子 state 发生变化,只会更新父 state 中对子 state 的引用,对父 state 没有影响。
// ImmutableConn
SubReducer<T> subReducer(Reducer<P> reducer) {
return (T state, Action action, bool isStateCopied) {
/// ... 前置处理
final P newProps = reducer(props, action);
final bool hasChanged = !identical(newProps, props);
if (hasChanged) {
final T result = set(state, newProps);
/// ... 中间处理
return result;
}
return state;
};
}
MutableConn 处理更新时,如果是子 state 发生变化,不仅会更新子 state,还会将父 state 进行 clone 更新,这样就会导致传递更新导致一个小组件更新触发整个界面更新。了解了这个特性之后,前面的问题就可以得到解释了。
// MutableConn
SubReducer<T> subReducer(Reducer<P> reducer) {
return (T state, Action action, bool isStateCopied) {
/// ... 前置处理
final P newProps = reducer(props, action);
final bool hasChanged = newProps != props;
final T copy = (hasChanged && !isStateCopied) ? _clone<T>(state) : state;
if (hasChanged) {
set(copy, newProps);
}
return copy;
};
}
解决方案
数据加载耗时
对于数据加载耗时,最终是定位到使用的 Recase 库存在性能问题。在网络数据请求之后,在业务中需要针对 json 的 key 进行驼峰和下滑线的转换,而 Recase 库在处理转换时,存在对象重复创建和转换逻辑不够高效的问题。针对这点,我们自己实现了转换的逻辑,并且增加了对于 key 转换的缓存,将之前随数据条数增加导致耗时增加的情况变为随不同 key 增加导致耗时增加。大大提升了转换的效率。
class ReCase {
/// 重复创建常量对象
final RegExp _upperAlphaRegex = RegExp(r'[A-Z]');
final symbolSet = {' ', '.', '/', '_', '\\', '-'};
List<String> _groupIntoWords(String text) {
// 重复创建临时对象
StringBuffer sb = StringBuffer();
/// ... 转换逻辑
return words;
}
/// ... 其他逻辑
}
/// 使用场景
/// 在单个单词时并没有太多问题,但是如果用于处理json数据,
/// 在数量大时积累耗时会很长,并且也占用的内存也会增加
final result = ReCase('test_test').camelCase
UI渲染卡顿
完成了 Fish Redux 刷新机制的分析之后,其实解决方案也比较清晰了。从刷新机制中,可以得出两个解决方案
1、重写 shouldUpdate 方法
在原则上,如果当前组件只是将其他组件组合在一起,自己并没有特殊的业务逻辑时,可以直接将 shouldUpdate 返回 false,因为子组件完全可以管理自己的状态。有一个判断点:当前组件的 view.dart 中是否只是简单的 buildComponent,一般是不需要更新的。
/// view.dart
Widget buildView(T state, Dispatch dispatch, ViewService viewService) {
return viewService.buildComponent(key)
}
///
class DemoComponent extends Component<T> {
DemoComponent() : super(
shouldUpdate: (_,__) => false,
/// 其他,
);
}
其他情况可以根据当前 state 中的影响界面刷新的子 state 进行判断实现精细化更新。
2、事件分发与处理
修改 connector 类型可以阻断更新传递从而达到减少更新范围的效果,如果明确父组件是不会更新的,就可以在依赖子组件时,使用 ImmutableConn 进行依赖连接,这样就不需要担心子组件更新会影响到父组件。
结合零售的实际情况,最终是采用了方案 1 进行 shouldUpdate 重写,因为在实际业务中,父子组件的联动效果还是存在,不能直接切断联系,还是根据实际场景进行条件刷新,这样在保证业务正确性的同时优化性能。
结果
通过优化更新逻辑,优化数据转换效率,再配合热数据内存缓存、优化动画和更细粒度的组件抽离之后,卡顿的Flutter界面流程度提升 60%,再也没有出现明显的卡顿现象。
在整个治理卡顿的过程中,重新学习了一遍 Fish Redux,体会到框架的优秀,特别是针对复杂的项目,其模板化的开发方式有效降低了理解和沟通成本,每个角色各司其职,在处理问题时方向明确,不需要担心“牵一发动全身”的问题。有一个总结经验就是:如果在使用Fish Redux遇到一些卡顿问题,大概率是组件没有划分或者划分不够细。网上在很多Flutter性能优化的建议总结,特别是Flutter官方的性能优化的指导,推荐阅读。