[Flutter] 別把 TextEditingController 放進 Bloc 裡

開始使用 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 TextEditingController as part of the bloc. Blocs should ideally be platform-agnostic and have no dependency on Flutter. If you need to use TextEditingControllers I would recommend creating a StatefulWidget and maintaining them as part of the State class. Then you can interface with the control in response to state changes via BlocListener. If you don’t need TextEditingControllers then the solution provided by @Gene-Dana using onChanged with TextField works well and eliminates the need to have a StatefulWidget.

基本上,他不贊成把 TextEditingController 放進 Bloc 裡,因為這樣會導致 Bloc 對 Flutter 跟 Widgets 有相依性。其實我個人是蠻同意他的看法。從架構的角度來看,presentation (Widgets) 應該是比 business logic (Bloc) 更上層,所以應該是 presentation 對 business logic 有相依性,不應該反過來。如果 Bloc 對 Flutter 跟 Widgets 有相依性,這樣就很難對 Bloc 做測試,而且物件的重複使用性也會大打折扣。

❌ 不好的做法:把 TextEditingController 放進 Bloc

bloc/search_bloc.dart
Dart
import 'package:flutter/widgets.dart'; // ⚠️ Bloc 不應該 import Flutter
import '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'];
}
@override
Future<void> close() {
// ❌ 問題三:Bloc 要負責 dispose UI 物件,職責不清
textController.dispose();
return super.close();
}
}
search_page.dart
Dart
// 搭配上面錯誤的 Bloc
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
@override
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 同步

bloc/search_bloc.dart
Dart
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'];
}
}
search_page.dart
Dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
// ✅ TextEditingController 屬於 UI 層,由 State 負責管理生命週期
late final TextEditingController _textController;
@override
void initState() {
super.initState();
_textController = TextEditingController();
}
@override
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));
}
@override
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,這樣做雖然還是讓我感到不舒服,但是從軟體架構的角度來看,我覺得這個作法是相當正確的。

發表迴響