開始使用 Bloc 之後,想到了一個問題:如果都已經把狀態變數放進 Bloc 裡了,那 TextEditingController 這類的東西是否也可以移進去呢?
在還沒使用 Bloc 的時候,我們會把狀態變數跟 TextEditingController 放在 State 裡面。但在使用 Bloc 之後,我們會開始把狀態變數放在 Bloc 裡。這時候就會遇到一個尷尬的問題。照理說,如果我們只需要處理狀態變數,使用了 Bloc 之後,我們就可以擺脫 StatefulWidget 跟 State。但如果我們同時又使用了 TextEditingController 這類的東西,那我們就沒辦法不使用 StatefulWidget 跟 State。
就是因為同時使用 Bloc + StatefulWidget + State 讓我不是很愉快,所以我就異想天開:那… 把 TextEditingController 也一起移進去不就好了嗎?這樣 StatefulWidget 跟 State 就不需要存在啦。於是我就去 google 看看,這個做法到底是不是一個 good practice,然後就找到了這篇。
[Question] What is the best practices to work with TextEditingControllers.
Bloc 的作者 Felix Angelov 親自解答了這個問題:
I would highly recommend against maintaining
TextEditingControlleras part of the bloc. Blocs should ideally be platform-agnostic and have no dependency on Flutter. If you need to useTextEditingControllersI would recommend creating aStatefulWidgetand maintaining them as part of theStateclass. Then you can interface with the control in response to state changes viaBlocListener. If you don’t needTextEditingControllersthen the solution provided by @Gene-Dana usingonChangedwithTextFieldworks well and eliminates the need to have aStatefulWidget.
基本上,他不贊成把 TextEditingController 放進 Bloc 裡,因為這樣會導致 Bloc 對 Flutter 跟 Widgets 有相依性。其實我個人是蠻同意他的看法。從架構的角度來看,presentation (Widgets) 應該是比 business logic (Bloc) 更上層,所以應該是 presentation 對 business logic 有相依性,不應該反過來。如果 Bloc 對 Flutter 跟 Widgets 有相依性,這樣就很難對 Bloc 做測試,而且物件的重複使用性也會大打折扣。
❌ 不好的做法:把 TextEditingController 放進 Bloc
import 'package:flutter/widgets.dart'; // ⚠️ Bloc 不應該 import Flutterimport 'package:flutter_bloc/flutter_bloc.dart';// --- Events ---abstract class SearchEvent {}class SearchSubmitted extends SearchEvent {}// --- States ---abstract class SearchState {}class SearchInitial extends SearchState {}class SearchLoading extends SearchState {}class SearchSuccess extends SearchState { final List<String> results; SearchSuccess(this.results);}// --- Bloc ---class SearchBloc extends Bloc<SearchEvent, SearchState> { // ❌ 問題一:TextEditingController 屬於 Flutter Widget 層 // Bloc 理應與平台無關,不應該持有 UI 物件 final TextEditingController textController = TextEditingController(); SearchBloc() : super(SearchInitial()) { on<SearchSubmitted>(_onSearchSubmitted); } Future<void> _onSearchSubmitted( SearchSubmitted event, Emitter<SearchState> emit, ) async { emit(SearchLoading()); // ❌ 問題二:直接從 controller 讀取文字,耦合 UI 狀態 final query = textController.text; final results = await _fakeSearch(query); emit(SearchSuccess(results)); } Future<List<String>> _fakeSearch(String query) async { await Future.delayed(const Duration(seconds: 1)); return ['$query result 1', '$query result 2']; } Future<void> close() { // ❌ 問題三:Bloc 要負責 dispose UI 物件,職責不清 textController.dispose(); return super.close(); }}
// 搭配上面錯誤的 Blocclass SearchPage extends StatelessWidget { const SearchPage({super.key}); Widget build(BuildContext context) { final bloc = context.read<SearchBloc>(); return Column( children: [ // ❌ 直接使用 Bloc 裡的 controller,UI 與 Bloc 強耦合 TextField(controller: bloc.textController), ElevatedButton( onPressed: () => bloc.add(SearchSubmitted()), child: const Text('Search'), ), BlocBuilder<SearchBloc, SearchState>( builder: (context, state) { if (state is SearchLoading) return const CircularProgressIndicator(); if (state is SearchSuccess) { return Column( children: state.results.map((r) => Text(r)).toList(), ); } return const SizedBox(); }, ), ], ); }}
✅ 好的做法:TextEditingController 留在 State,用 BlocListener 同步
import 'package:flutter_bloc/flutter_bloc.dart';// --- Events ---abstract class SearchEvent {}class SearchSubmitted extends SearchEvent { final String query; SearchSubmitted(this.query); // ✅ 透過 event 傳入資料,Bloc 不持有 UI 物件}class SearchCleared extends SearchEvent {}// --- States ---abstract class SearchState {}class SearchInitial extends SearchState {}class SearchLoading extends SearchState {}class SearchSuccess extends SearchState { final List<String> results; SearchSuccess(this.results);}class SearchFailure extends SearchState {}// --- Bloc ---class SearchBloc extends Bloc<SearchEvent, SearchState> { SearchBloc() : super(SearchInitial()) { on<SearchSubmitted>(_onSearchSubmitted); on<SearchCleared>(_onSearchCleared); } Future<void> _onSearchSubmitted( SearchSubmitted event, Emitter<SearchState> emit, ) async { emit(SearchLoading()); try { // ✅ Bloc 只處理純業務邏輯,完全不碰 Flutter UI final results = await _fakeSearch(event.query); emit(SearchSuccess(results)); } catch (_) { emit(SearchFailure()); } } void _onSearchCleared( SearchCleared event, Emitter<SearchState> emit, ) { emit(SearchInitial()); } Future<List<String>> _fakeSearch(String query) async { await Future.delayed(const Duration(seconds: 1)); return ['$query result 1', '$query result 2']; }}
import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';class SearchPage extends StatefulWidget { const SearchPage({super.key}); State<SearchPage> createState() => _SearchPageState();}class _SearchPageState extends State<SearchPage> { // ✅ TextEditingController 屬於 UI 層,由 State 負責管理生命週期 late final TextEditingController _textController; void initState() { super.initState(); _textController = TextEditingController(); } void dispose() { // ✅ UI 物件由 UI 層 dispose,職責清晰 _textController.dispose(); super.dispose(); } void _onSubmit() { final query = _textController.text.trim(); if (query.isEmpty) return; // ✅ 把文字當作 event payload 傳給 Bloc,而非讓 Bloc 自己讀取 context.read<SearchBloc>().add(SearchSubmitted(query)); } Widget build(BuildContext context) { return BlocListener<SearchBloc, SearchState>( listener: (context, state) { // ✅ 用 BlocListener 監聽狀態變化,在適當時機操作 UI 物件 if (state is SearchInitial) { _textController.clear(); } if (state is SearchFailure) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('搜尋失敗,請再試一次')), ); } }, child: Column( children: [ TextField( controller: _textController, decoration: const InputDecoration(labelText: '搜尋'), onSubmitted: (_) => _onSubmit(), ), Row( children: [ ElevatedButton( onPressed: _onSubmit, child: const Text('搜尋'), ), TextButton( onPressed: () => context.read<SearchBloc>().add(SearchCleared()), child: const Text('清除'), ), ], ), BlocBuilder<SearchBloc, SearchState>( builder: (context, state) { return switch (state) { SearchLoading() => const CircularProgressIndicator(), SearchSuccess(:final results) => Column( children: results.map((r) => Text(r)).toList(), ), SearchFailure() => const Text('發生錯誤'), _ => const SizedBox(), }; }, ), ], ), ); }}
好吧,為了 TextEditingController 只好繼續留著 StatefulWidget 跟 State,這樣做雖然還是讓我感到不舒服,但是從軟體架構的角度來看,我覺得這個作法是相當正確的。