前言
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官方的性能優化的指導,推薦閱讀。