Flutter redux 進階
Flutter Redux初代實現侷限性
UT不好覆蓋
- 頁面
初代實現一個頁面的結構是這樣的:
class XXXScreen extends StatefulWidget { @override _XXXScreenState createState() => _XXXScreenState(); } class _XXXScreenState extends State<XXXScreen> { @override Widget build(BuildContext context) { return StoreConnector<AppState, _XXXViewModel>( converter: (store) => _XXXViewModel.fromStore(store), builder: (BuildContext context, _XXXViewModel vm) => Container()); } } 複製程式碼
會有兩個問題:UI檢視和Redux資料通用邏輯耦和在一起,無發通過mock資料來對UI進行UT;大家習慣套路程式碼,上來就是一個stful,不會想是不是stless更科學點(事實上初代實現80%的Screen是Statefull的,重構後90%都能寫成Stateless,提升了頁面重新整理效率)。
- API call
我們的API就是一個靜態方法:
static fetchxxx() { final access = StoreContainer.access; final apiFuture = Services.rest.get( '/zpartner_api/${access.path}/${access.businessGroupUid}/xxxx/'); Services.asyncRequest( apiFuture, xxxRequestAction(), (json) => xxxSuccessAction(payload: xxxInfo.fromJson(json)), (errorInfo) => xxxFailureAction(errorInfo: errorInfo)); } 複製程式碼
優點是簡單,有java味,缺點是:靜態方法無法使用ofollow,noindex">mockIto ;一個Api call觸發,那就發出去了,無法撤銷無法重試;自然也無法進行UT覆蓋。
不夠Functional
上面提到的頁面和API call都體現了不Functional,還有我們初代Reducer的寫法也是大家很熟悉的OO寫法
class xxxReducer { xxxState reducer(xxxState state, ActionType action) { switch (action.runtimeType) { case xxxRequestAction: return state.copyWith(isLoading: ); case xxxSuccessAction: return state.copyWith(isLoading: ); case xxxFailureAction: return state.copyWith(isLoading: ); default: return state; } } } 複製程式碼
從上到下流水寫法,static,switch case這都是我們OO的老朋友。但既然Dart是偏前端特性,Functional才是科學的方向啊。
引入Middleware必要性
業務已經寫完,小夥伴邊自測邊寫UT,為了達到50%的coverage可以說是非常蛋疼了。某大佬眉頭一皺發現問題並不簡單,UT不好寫,是不是結構搓?於是召集大家討論一波,得出這些侷限性。改還是不改是個問題,不改開發算是提前完成,反正Rn也沒有寫UT;改的話,改動量是巨大的。大家都停下手中的工作,思考並深刻討論這個問題,於是我們從三個方向衡量這個問題:
業務影響
離排期提測時間只有1個星期,加入Middleware會有80%的程式碼需要挪動,改完還要補UT,重新自測。emmm,工作量超大。和產品溝通了下,其實這個業務就是技術重構性質,線上Rn多跑一個禮拜也無礙,測試組也恰好特別忙,delay一週他們覺得ok。傾向改。
技術棧影響
從長遠看,改動是進步的。對UT友好,更嚴謹的結構,也更Functional。小夥伴們覺得自己也能駕馭,不過是多寫點套路程式碼~,技術棧傾向改。
夥伴支援度
引入Middleware帶來的好處能否讓小夥伴願意加班把自己的模組都改寫了,還補上UT?實踐出真知,所以大家討論決定,用半天時間理解並改寫一個小模組,再投票決定是否改。討論很激烈,話題一度跑偏。。。
討論下來,最終決定是改,一星期後大家都說,真香!
改動點
增刪
刪掉原來Service的static API定義,加入Middleware和Repository。Middleware負責網路請求,資料處理,並根據資料狀態進行Action的分發。Repository功能是定義了一個數據來源(可能來源於網路,也可能是資料庫),因為引入Dio,所以會很精簡,形式上可以看成是一個Endpoint定義。
- Middleware
class XXXMiddlewareFactory extends MiddlewareFactory { XXXMiddlewareFactory(AppRepository repository) : super(repository); @override List<Middleware<AppState>> generate() { return [ TypedMiddleware<AppState, FetchAction>(_fetchXXX), ]; } void _fetchXXX(Store<AppState> store, FetchAction action, NextDispatcher next) { Services.asyncRequest( () => repository.fetch(), FetchRequestAction(), (json) => FetchSuccessAction(), (errorInfo) => FetchFailureAction(errorInfo: errorInfo)); } } 複製程式碼
- Repository
Future<Response> fetchXXX(String uid) { return Services.rest.get( '/xxx_api/${path}/${groupUid}/manual_activities/$uid/'); } 複製程式碼
修改
Screen把UI都抽到Presentation裡,它依賴一個vm。資料填充並驅動UI變化,這樣UI也可以寫很全面的UT。Reducer則是利用Flutter_redux庫提供的combineReducers方法,將原來一個大的Reducer粒度切到最小。方便寫UT和業務增量迭代。
- Screen
class XXXPresentation extends StatelessWidget { final XXXViewModel vm; const XXXPresentation({Key key, this.vm}) : super(key: key); @override Widget build(BuildContext context) { return Container(); } } class XXXScreen extends StatelessWidget { static const String routeName = 'xxx_screen'; @override Widget build(BuildContext context) { return StoreConnector<AppState, XXXViewModel>( distinct: true, onInit: (store) { store.dispatch(FetchXXXAction(isRefresh: true)); }, onDispose: (store) => store.dispatch(XXXResetAction()), converter: XXXViewModel.fromStore, builder: (context, vm) { return XXXPresentation(vm: vm); }, ); } } class XXXViewModel { static XXXViewModel fromStore(Store<AppState> store) { return XXXViewModel(); } } 複製程式碼
- Reducer
@immutable class XXXState { final bool isLoading; XXXState({this.isLoading, }); XXXState copyWith({bool isLoading, }) { return XXXState( isLoading: isLoading ?? this.isLoading, ); } XXXState.initialState() : isLoading = false; } final xXXReducer = combineReducers<XXXState>([ TypedReducer<XXXState, Action>(_onRequest), ]); XXXState _onRequest(XXXState state, Action action) => state.copyWith(isLoading: false); 複製程式碼
UT整合
現在的coverage是48%,核心模組有80%+,有必要的話達到95%以上時完全ok的。原因是解耦以後方方面面都可以UT了
- widget(純)
// 官方文件寫的清楚明白 https://flutter.io/docs/testing 複製程式碼
- Utils
被多次使用的才會抽成工具類,純邏輯也很容易寫測試,UT應該先滿上。
group('test string util', () { test('isValidPhone', () { var boolNull = StringUtil.isValidPhone(null); var boolStarts1 = StringUtil.isValidPhone('17012341234'); var boolStarts2 = StringUtil.isValidPhone('27012341234'); var boolLength10 = StringUtil.isValidPhone('1701234123'); var boolLength11 = StringUtil.isValidPhone('17012341234'); expect(boolNull, false); expect(boolStarts1, true); expect(boolStarts2, false); expect(boolLength10, false); expect(boolLength11, true); }); } 複製程式碼
- Presentation
業務的載體。對於比較核心的業務,無論是流程規範定義還是資料邊界條件都可以用UT來自動化保障。
group('test login presentation', () { Store<AppState> store; setUp(() { store = Store<AppState>(reduxReducer, initialState: initialReduxState(), distinct: true); StoreContainer.setStoreForTest(store); }); testWidgets('test loading', (WidgetTester tester) async { final vm = LoginViewModel(isLoading: true, isSendPinSuccess: false); await TestHelper.pumpWidget(tester, store, LoginPresentation(vm: vm)); expect(find.byType(CupertinoActivityIndicator), findsOneWidget); ... }); testWidgets('test has data',(WidgetTester tester) async { ... }); testWidgets('test has no data',(WidgetTester tester) async { ... }); } 複製程式碼
- Reducer
存放資料,可以用UT來驗證特定Action是否改變了特定的資料。
group('notificationReducer', () { test('FetchMessageUnreadRequestAction', () { store.dispatch(FetchMessageUnreadRequestAction()); expect(store.state.notification.isLoading, true); }); test('FetchMessageUnreadSuccessAction', () { final payload = MessageUnreadInfo.initialState(); store.dispatch(FetchMessageUnreadSuccessAction(payload: payload)); expect(store.state.notification.messageUnreadInfo, payload); expect(store.state.notification.isLoading, false); }); ... } 複製程式碼
- Middleware
叫中介軟體代表它不是必須,是可以被插拔,可以疊加多個的。每個中介軟體會有一個明確的任務,我們引入的中介軟體在這裡是處理網路資料,根據情況發對應Action。
group('Middleware', () { final repo = MockAppRepository(); Store<AppState> store; setUpAll(() async { await mockApiSuc(repo); }); setUp(() { store = Store<AppState>(reduxReducer, initialState: initialReduxState(), middleware: initialMiddleware(repo), distinct: true); StoreContainer.setStoreForTest(store); }); group('NotificationMiddlewareFactory', () { test('FetchMessageUnreadAction', () { store.dispatch(FetchMessageUnreadAction()); verify(repo.fetchMessagesUnread()); }); test('FetchMessageForHomeAction', () { store.dispatch(FetchMessageForHomeAction()); verify(repo.fetchMessagesForHome()); }); ... } 複製程式碼