BottomNavigationBar trong Flutter
Tôi rất vui được chia sẻ kiến thức chuyên sâu về cách viết một Bloc (Business Logic Component) chuyên nghiệp, dễ bảo trì, dễ nâng cấp, và có cấu trúc rõ ràng trong Flutter, sử dụng StreamController thuần túy (không dùng thư viện thứ ba như flutter_bloc
) để phù hợp với yêu cầu trước đó của bạn. Tôi sẽ hướng dẫn bạn từng bước cụ thể, từ việc thiết kế sự kiện, trạng thái, đến cách xử lý trong Bloc, kèm theo các nguyên tắc và ví dụ minh họa. Cuối cùng, tôi sẽ cung cấp một ví dụ hoàn chỉnh dựa trên ứng dụng BottomNavigationBar với Navigator, tương tự như bạn đã yêu cầu, nhưng tập trung vào việc giải thích quy trình và cách tổ chức mã chuyên nghiệp.
1. Tổng quan về Bloc và mục tiêu
Bloc là một mẫu thiết kế (design pattern) giúp tách biệt logic nghiệp vụ khỏi giao diện người dùng trong Flutter. Nó sử dụng Stream để quản lý trạng thái và xử lý sự kiện, đảm bảo:
- Tính tách biệt: Logic nghiệp vụ (Bloc) độc lập với giao diện (Widgets).
- Dễ bảo trì: Mã được tổ chức rõ ràng, dễ đọc, dễ sửa đổi.
- Dễ nâng cấp: Thêm tính năng mới chỉ cần cập nhật sự kiện và trạng thái.
- Hiệu suất: Chỉ rebuild các widget cần thiết thông qua
StreamBuilder
. - Dễ kiểm thử: Logic nằm trong Bloc, có thể viết unit test riêng.
Mục tiêu của hướng dẫn này:
- Hiểu cách thiết kế sự kiện và trạng thái.
- Xây dựng một Bloc (dùng
StreamController
) để xử lý sự kiện và phát trạng thái. - Tổ chức mã chuyên nghiệp với các file riêng biệt.
- Áp dụng vào ứng dụng thực tế (BottomNavigationBar với Navigator).
- Đảm bảo mã dễ bảo trì và nâng cấp.
2. Các bước cụ thể để viết Bloc chuyên nghiệp
Tôi sẽ chia quá trình thành các bước chi tiết, giải thích từng bước và áp dụng vào một ví dụ thực tế là ứng dụng BottomNavigationBar với các tab Home
, Search
, Profile
, hỗ trợ chuyển đổi tab, truyền từ khóa tìm kiếm, xóa từ khóa, và làm mới dữ liệu.
Bước 1: Thiết kế sự kiện (Events)
Sự kiện là các hành động hoặc yêu cầu từ người dùng (hoặc hệ thống) mà Bloc cần xử lý. Ví dụ: nhấn vào một tab, nhập từ khóa tìm kiếm, hoặc làm mới dữ liệu.
Nguyên tắc thiết kế sự kiện:
- Đơn giản và cụ thể: Mỗi sự kiện đại diện cho một hành động duy nhất.
- Tính bất biến: Sự kiện nên là immutable (không thay đổi sau khi tạo).
- Tách biệt file: Đặt định nghĩa sự kiện trong file riêng để dễ bảo trì.
- Sử dụng lớp trừu tượng: Tạo một lớp cha trừu tượng (
NavigationEvent
) để nhóm các sự kiện liên quan.
Ví dụ:
Trong ứng dụng BottomNavigationBar, chúng ta cần các sự kiện:
ChangeTab
: Chuyển đổi tab (cập nhậtselectedIndex
).UpdateSearchQuery
: Cập nhật từ khóa tìm kiếm.ClearSearchQuery
: Xóa từ khóa.RefreshData
: Làm mới dữ liệu (tăngdataVersion
).
Mã sự kiện (lib/navigation/navigation_event.dart
):
// Định nghĩa các sự kiện
abstract class NavigationEvent {}
class ChangeTab extends NavigationEvent {
final int index;
ChangeTab(this.index);
}
class UpdateSearchQuery extends NavigationEvent {
final String query;
UpdateSearchQuery(this.query);
}
class ClearSearchQuery extends NavigationEvent {}
class RefreshData extends NavigationEvent {}
DartGiải thích:
NavigationEvent
là lớp trừu tượng, tất cả sự kiện đều kế thừa từ nó.ChangeTab
nhậnindex
để chỉ định tab mới.UpdateSearchQuery
nhậnquery
để lưu từ khóa.ClearSearchQuery
không cần tham số vì nó chỉ xóa từ khóa.RefreshData
không cần tham số vì nó chỉ tăngdataVersion
.
Mẹo chuyên nghiệp:
- Đặt tên sự kiện rõ ràng, theo dạng động từ (như
Change
,Update
,Clear
,Refresh
). - Nếu sự kiện phức tạp, thêm các thuộc tính để mang dữ liệu (như
index
,query
). - Tách file để dễ quản lý, đặc biệt khi có nhiều sự kiện.
Bước 2: Thiết kế trạng thái (State)
Trạng thái là dữ liệu mà Bloc quản lý và gửi đến giao diện để hiển thị. Trạng thái phản ánh tình trạng hiện tại của ứng dụng.
Nguyên tắc thiết kế trạng thái:
- Tập trung: Chỉ lưu trữ dữ liệu cần thiết cho giao diện.
- Bất biến: Trạng thái nên là immutable, tạo mới mỗi khi thay đổi.
- Tách biệt file: Đặt định nghĩa trạng thái trong file riêng.
- Dễ mở rộng: Thiết kế trạng thái để dễ thêm thuộc tính mới.
Ví dụ:
Trong ứng dụng, trạng thái cần lưu:
selectedIndex
: Chỉ số tab hiện tại (0, 1, 2).searchQuery
: Từ khóa tìm kiếm.dataVersion
: Phiên bản dữ liệu để làm mới danh sách.
Mã trạng thái (lib/navigation/navigation_state.dart
):
// Định nghĩa trạng thái
class NavigationState {
final int selectedIndex;
final String searchQuery;
final int dataVersion;
NavigationState({
required this.selectedIndex,
required this.searchQuery,
required this.dataVersion,
});
}
DartGiải thích:
NavigationState
chứa ba thuộc tính cần thiết.- Các thuộc tính đều là
final
để đảm bảo bất biến. - Constructor sử dụng
required
để bắt buộc cung cấp giá trị.
Mẹo chuyên nghiệp:
- Chỉ lưu dữ liệu cần thiết cho giao diện, tránh lưu trạng thái trung gian (như biến tạm trong logic).
- Nếu trạng thái phức tạp, cân nhắc chia thành nhiều lớp trạng thái (ví dụ:
InitialState
,LoadingState
,SuccessState
). - Đảm bảo trạng thái có thể mở rộng (thêm thuộc tính mới mà không phá vỡ mã cũ).
Bước 3: Tạo Bloc xử lý sự kiện và phát trạng thái
Bloc là nơi xử lý sự kiện và tạo ra trạng thái mới. Trong trường hợp này, chúng ta dùng StreamController để xây dựng Bloc (thay vì flutter_bloc
).
Nguyên tắc thiết kế Bloc:
- Tách biệt logic: Bloc chỉ xử lý logic nghiệp vụ, không chứa mã giao diện.
- Quản lý Stream: Sử dụng hai
StreamController
: một cho sự kiện (input), một cho trạng thái (output). - Xử lý bất đồng bộ: Hỗ trợ các tác vụ bất đồng bộ (như gọi API) nếu cần.
- Vòng đời rõ ràng: Đóng
StreamController
khi không còn cần. - Tách file: Đặt logic Bloc trong file riêng (
navigation_manager.dart
).
Các thành phần của Bloc:
- _eventController: Nhận sự kiện từ UI.
- _stateController: Phát trạng thái mới đến UI.
- currentState: Lưu trạng thái hiện tại để tham chiếu khi xử lý sự kiện.
- Logic xử lý: Ánh xạ sự kiện thành trạng thái mới.
Mã Bloc (lib/navigation/navigation_manager.dart
):
import 'dart:async';
import 'navigation_event.dart';
import 'navigation_state.dart';
class NavigationManager {
final _eventController = StreamController<NavigationEvent>.broadcast();
final _stateController = StreamController<NavigationState>.broadcast();
Stream<NavigationState> get state => _stateController.stream;
Sink<NavigationEvent> get events => _eventController.sink;
NavigationManager() {
var currentState = NavigationState(
selectedIndex: 0,
searchQuery: '',
dataVersion: 0,
);
_eventController.stream.listen((event) {
if (event is ChangeTab) {
currentState = NavigationState(
selectedIndex: event.index,
searchQuery: currentState.searchQuery,
dataVersion: currentState.dataVersion,
);
} else if (event is UpdateSearchQuery) {
currentState = NavigationState(
selectedIndex: currentState.selectedIndex,
searchQuery: event.query,
dataVersion: currentState.dataVersion,
);
} else if (event is ClearSearchQuery) {
currentState = NavigationState(
selectedIndex: currentState.selectedIndex,
searchQuery: '',
dataVersion: currentState.dataVersion,
);
} else if (event is RefreshData) {
currentState = NavigationState(
selectedIndex: currentState.selectedIndex,
searchQuery: currentState.searchQuery,
dataVersion: currentState.dataVersion + 1,
);
}
_stateController.add(currentState);
});
_stateController.add(currentState);
}
void dispose() {
_eventController.close();
_stateController.close();
}
}
DartGiải thích:
- _eventController: Nhận sự kiện từ UI qua
events
(Sink). - _stateController: Phát trạng thái mới đến UI qua
state
(Stream). - Constructor:
- Khởi tạo
currentState
với giá trị mặc định. - Lắng nghe
_eventController.stream
để xử lý sự kiện. - Phát trạng thái ban đầu qua
_stateController
. - Xử lý sự kiện:
- Sử dụng
is
để kiểm tra loại sự kiện. - Tạo
NavigationState
mới dựa trêncurrentState
và dữ liệu từ sự kiện. - Giữ nguyên các thuộc tính không thay đổi để đảm bảo tính nhất quán.
- dispose: Đóng cả hai
StreamController
để tránh rò rỉ bộ nhớ.
Mẹo chuyên nghiệp:
- Sử dụng
broadcast
choStreamController
để nhiều widget có thể lắng nghe cùng một Stream. - Xử lý lỗi bằng cách thêm
try-catch
trong_eventController.stream.listen
nếu có tác vụ bất đồng bộ. - Nếu logic phức tạp, chia nhỏ xử lý thành các hàm riêng (ví dụ:
_handleChangeTab
,_handleUpdateSearchQuery
).
Bước 4: Tích hợp Bloc vào giao diện
Giao diện (Widgets) sử dụng StreamBuilder
để lắng nghe trạng thái từ Bloc và gửi sự kiện qua NavigationManager.events
.
Nguyên tắc tích hợp:
- StreamBuilder: Sử dụng để rebuild widget khi trạng thái thay đổi.
- Truyền Bloc: Truyền
NavigationManager
vào các widget cần truy cập trạng thái hoặc gửi sự kiện. - Tối ưu rebuild: Chỉ đặt
StreamBuilder
ở các widget cần cập nhật. - Quản lý vòng đời: Khởi tạo và đóng
NavigationManager
trongState
của widget cha.
Ví dụ:
Ứng dụng BottomNavigationBar với ba tab (Home
, Search
, Profile
).
Mã giao diện (lib/main.dart
):
import 'package:flutter/material.dart';
import 'navigation/navigation_event.dart';
import 'navigation/navigation_state.dart';
import 'navigation/navigation_manager.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Bottom Navigation with Navigator',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
late final NavigationManager _navigationManager;
final List<GlobalKey<NavigatorState>> _navigatorKeys = [
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
GlobalKey<NavigatorState>(),
];
@override
void initState() {
super.initState();
_navigationManager = NavigationManager();
}
@override
void dispose() {
_navigationManager.dispose();
super.dispose();
}
Widget _buildNavigator(int index) {
return StreamBuilder<NavigationState>(
stream: _navigationManager.state,
initialData: NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0),
builder: (context, snapshot) {
final state = snapshot.data!;
return Offstage(
offstage: state.selectedIndex != index,
child: Navigator(
key: _navigatorKeys[index],
onGenerateRoute: (settings) {
Widget page;
if (index == 0) {
page = HomeScreen(navigationManager: _navigationManager);
} else if (index == 1) {
page = SearchScreen(navigationManager: _navigationManager);
} else {
page = ProfileScreen(navigationManager: _navigationManager);
}
return MaterialPageRoute(builder: (context) => page);
},
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Bottom Navigation with Navigator'),
),
body: Stack(
children: [
_buildNavigator(0),
_buildNavigator(1),
_buildNavigator(2),
],
),
bottomNavigationBar: StreamBuilder<NavigationState>(
stream: _navigationManager.state,
initialData: NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0),
builder: (context, snapshot) {
final state = snapshot.data!;
return BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Home',
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: 'Search',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
currentIndex: state.selectedIndex,
selectedItemColor: Colors.blue,
unselectedItemColor: Colors.grey,
onTap: (index) {
_navigationManager.events.add(ChangeTab(index));
},
);
},
),
);
}
}
class HomeScreen extends StatelessWidget {
final NavigationManager navigationManager;
const HomeScreen({super.key, required this.navigationManager});
@override
Widget build(BuildContext context) {
return StreamBuilder<NavigationState>(
stream: navigationManager.state,
initialData: NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0),
builder: (context, snapshot) {
final state = snapshot.data!;
return Column(
children: [
Expanded(
child: ListView.builder(
key: ValueKey(state.dataVersion),
itemCount: 50,
itemBuilder: (context, index) {
return ListTile(
title: Text('Mục ${index + 1} (Phiên bản: ${state.dataVersion})'),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: () {
navigationManager.events.add(RefreshData());
},
child: const Text('Làm mới danh sách'),
),
),
],
);
},
);
}
}
class SearchScreen extends StatelessWidget {
final NavigationManager navigationManager;
const SearchScreen({super.key, required this.navigationManager});
@override
Widget build(BuildContext context) {
final controller = TextEditingController();
return StreamBuilder<NavigationState>(
stream: navigationManager.state,
initialData: NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0),
builder: (context, snapshot) {
final state = snapshot.data!;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Nhập từ khóa tìm kiếm',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
navigationManager.events.add(UpdateSearchQuery(value));
},
),
const SizedBox(height: 16),
Text('Từ khóa hiện tại: ${state.searchQuery}'),
],
),
);
},
);
}
}
class ProfileScreen extends StatelessWidget {
final NavigationManager navigationManager;
const ProfileScreen({super.key, required this.navigationManager});
@override
Widget build(BuildContext context) {
return StreamBuilder<NavigationState>(
stream: navigationManager.state,
initialData: NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0),
builder: (context, snapshot) {
final state = snapshot.data!;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Hồ sơ của bạn', style: TextStyle(fontSize: 24)),
Text('Từ khóa tìm kiếm: ${state.searchQuery}', style: const TextStyle(fontSize: 20)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
navigationManager.events.add(ClearSearchQuery());
},
child: const Text('Xóa từ khóa'),
),
],
),
);
},
);
}
}
DartGiải thích:
- MyHomePage:
- Khởi tạo
NavigationManager
tronginitState
và đóng trongdispose
. - Sử dụng
StreamBuilder
để điều khiểnBottomNavigationBar
và cácNavigator
. - Gửi
ChangeTab
khi người dùng nhấn vào tab. - HomeScreen:
- Hiển thị danh sách với
ListView
. - Làm mới danh sách khi
dataVersion
thay đổi (ValueKey
). - Gửi
RefreshData
khi nhấn nút. - SearchScreen:
- Nhập từ khóa và gửi
UpdateSearchQuery
. - Hiển thị từ khóa hiện tại.
- ProfileScreen:
- Hiển thị từ khóa và gửi
ClearSearchQuery
khi nhấn nút xóa.
Mẹo chuyên nghiệp:
- Sử dụng
initialData
trongStreamBuilder
để tránh trạng thái null. - Truyền
NavigationManager
qua constructor thay vì sử dụng global instance để dễ kiểm soát vòng đời. - Tối ưu
StreamBuilder
bằng cách chỉ sử dụng ở các widget cần cập nhật.
Bước 5: Tổ chức mã chuyên nghiệp
Để mã dễ bảo trì và nâng cấp, cần tổ chức cấu trúc thư mục và tuân thủ các nguyên tắc:
Cấu trúc thư mục:
lib/
├── main.dart
├── navigation/
│ ├── navigation_event.dart
│ ├── navigation_state.dart
│ ├── navigation_manager.dart
HTMLNguyên tắc tổ chức:
- Tách biệt trách nhiệm: Sự kiện, trạng thái, và logic Bloc trong các file riêng.
- Barrel file: Nếu có nhiều file trong
navigation/
, tạonavigation/index.dart
:
export 'navigation_event.dart';
export 'navigation_state.dart';
export 'navigation_manager.dart';
DartSau đó, trong main.dart
, chỉ cần import 'navigation/index.dart';
.
- Tách màn hình: Nếu ứng dụng lớn, tách
HomeScreen
,SearchScreen
,ProfileScreen
vào thư mụcscreens/
:
lib/
├── screens/
│ ├── home_screen.dart
│ ├── search_screen.dart
│ ├── profile_screen.dart
DartMẹo chuyên nghiệp:
- Sử dụng tên file và lớp nhất quán (ví dụ:
navigation_event.dart
choNavigationEvent
). - Giữ mỗi file nhỏ gọn, chỉ xử lý một trách nhiệm duy nhất.
- Tài liệu hóa mã bằng comment hoặc README để đội nhóm dễ hiểu.
Bước 6: Xử lý bất đồng bộ và lỗi (nâng cao)
Trong các ứng dụng thực tế, Bloc thường cần xử lý các tác vụ bất đồng bộ (như gọi API) và lỗi. Dù ví dụ hiện tại đơn giản, tôi sẽ hướng dẫn cách mở rộng:
Xử lý bất đồng bộ:
Giả sử UpdateSearchQuery
cần gọi API để xác thực từ khóa.
Sửa navigation_manager.dart
:
class NavigationManager {
final _eventController = StreamController<NavigationEvent>.broadcast();
final _stateController = StreamController<NavigationState>.broadcast();
Stream<NavigationState> get state => _stateController.stream;
Sink<NavigationEvent> get events => _eventController.sink;
NavigationManager() {
var currentState = NavigationState(
selectedIndex: 0,
searchQuery: '',
dataVersion: 0,
);
_eventController.stream.listen((event) async {
if (event is ChangeTab) {
currentState = NavigationState(
selectedIndex: event.index,
searchQuery: currentState.searchQuery,
dataVersion: currentState.dataVersion,
);
} else if (event is UpdateSearchQuery) {
try {
// Giả lập gọi API
await Future.delayed(Duration(seconds: 1)); // Thay bằng API thực
currentState = NavigationState(
selectedIndex: currentState.selectedIndex,
searchQuery: event.query,
dataVersion: currentState.dataVersion,
);
} catch (e) {
// Xử lý lỗi
currentState = NavigationState(
selectedIndex: currentState.selectedIndex,
searchQuery: currentState.searchQuery,
dataVersion: currentState.dataVersion,
);
// Có thể thêm trạng thái lỗi nếu cần
}
} else if (event is ClearSearchQuery) {
// ... (giữ nguyên)
} else if (event is RefreshData) {
// ... (giữ nguyên)
}
_stateController.add(currentState);
});
_stateController.add(currentState);
}
void dispose() {
_eventController.close();
_stateController.close();
}
}
DartThêm trạng thái lỗi:
Sửa navigation_state.dart
để hỗ trợ lỗi:
class NavigationState {
final int selectedIndex;
final String searchQuery;
final int dataVersion;
final String? error; // Thêm trường lỗi
NavigationState({
required this.selectedIndex,
required this.searchQuery,
required this.dataVersion,
this.error,
});
}
DartCập nhật giao diện:
Trong SearchScreen
, hiển thị lỗi nếu có:
class SearchScreen extends StatelessWidget {
final NavigationManager navigationManager;
const SearchScreen({super.key, required this.navigationManager});
@override
Widget build(BuildContext context) {
final controller = TextEditingController();
return StreamBuilder<NavigationState>(
stream: navigationManager.state,
initialData: NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0),
builder: (context, snapshot) {
final state = snapshot.data!;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Nhập từ khóa tìm kiếm',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
navigationManager.events.add(UpdateSearchQuery(value));
},
),
const SizedBox(height: 16),
Text('Từ khóa hiện tại: ${state.searchQuery}'),
if (state.error != null)
Text(
'Lỗi: ${state.error}',
style: TextStyle(color: Colors.red),
),
],
),
);
},
);
}
}
DartMẹo chuyên nghiệp:
- Thêm trạng thái tải (
isLoading
) nếu tác vụ bất đồng bộ lâu. - Sử dụng
sealed class
(Dart 3) để định nghĩa trạng thái chi tiết hơn (nếu cần). - Ghi log lỗi để dễ debug trong môi trường sản xuất.
Bước 7: Kiểm thử Bloc
Để đảm bảo Bloc hoạt động đúng và dễ bảo trì, viết unit test cho NavigationManager
.
Mã kiểm thử (test/navigation_manager_test.dart
):
import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/navigation/navigation_event.dart';
import 'package:your_app/navigation/navigation_state.dart';
import 'package:your_app/navigation/navigation_manager.dart';
void main() {
late NavigationManager navigationManager;
setUp(() {
navigationManager = NavigationManager();
});
tearDown(() {
navigationManager.dispose();
});
test('Initial state is correct', () {
expectLater(
navigationManager.state,
emits(NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0)),
);
});
test('ChangeTab updates selectedIndex', () async {
navigationManager.events.add(ChangeTab(1));
await expectLater(
navigationManager.state,
emits(NavigationState(selectedIndex: 1, searchQuery: '', dataVersion: 0)),
);
});
test('UpdateSearchQuery updates searchQuery', () async {
navigationManager.events.add(UpdateSearchQuery('test'));
await expectLater(
navigationManager.state,
emits(NavigationState(selectedIndex: 0, searchQuery: 'test', dataVersion: 0)),
);
});
test('ClearSearchQuery clears searchQuery', () async {
navigationManager.events.add(UpdateSearchQuery('test'));
navigationManager.events.add(ClearSearchQuery());
await expectLater(
navigationManager.state,
emits(NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 0)),
);
});
test('RefreshData increments dataVersion', () async {
navigationManager.events.add(RefreshData());
await expectLater(
navigationManager.state,
emits(NavigationState(selectedIndex: 0, searchQuery: '', dataVersion: 1)),
);
});
}
DartGiải thích:
- setUp: Khởi tạo
NavigationManager
trước mỗi test. - tearDown: Đóng
NavigationManager
sau mỗi test. - Test cases: Kiểm tra trạng thái ban đầu và từng sự kiện.
- expectLater: Kiểm tra Stream phát ra trạng thái đúng.
Mẹo chuyên nghiệp:
- Viết test cho mọi sự kiện và trạng thái.
- Sử dụng
emitsInOrder
để kiểm tra thứ tự trạng thái nếu cần. - Mock các tác vụ bất đồng bộ (như API) bằng package
mockito
nếu cần.
3. Nguyên tắc viết Bloc chuyên nghiệp
- Single Responsibility: Mỗi Bloc chỉ xử lý một nhóm logic liên quan (ví dụ:
NavigationManager
cho điều hướng và dữ liệu tìm kiếm). - Immutability: Sự kiện và trạng thái nên bất biến.
- Tách biệt file: Sự kiện, trạng thái, và logic Bloc trong các file riêng.
- Dễ kiểm thử: Tách logic khỏi giao diện để dễ viết unit test.
- Tài liệu hóa: Thêm comment hoặc README để giải thích cách sử dụng Bloc.
- Xử lý lỗi: Luôn chuẩn bị cho trường hợp lỗi, đặc biệt với tác vụ bất đồng bộ.
- Tối ưu hiệu suất: Sử dụng
StreamBuilder
cẩn thận, chỉ ở các widget cần rebuild.
4. Kết quả
Ứng dụng hoạt động như sau:
- Home: Danh sách 50 mục, làm mới khi nhấn nút (tăng
dataVersion
). - Search: Nhập từ khóa, gửi đến
Profile
. - Profile: Hiển thị từ khóa, xóa từ khóa.
- BottomNavigationBar: Chuyển tab mượt mà.
Hiệu suất:
- Chỉ rebuild các widget trong
StreamBuilder
. - Navigator với
Offstage
giảm tải bộ nhớ. - Không dùng
setState
, tránh rebuild toàn bộ.
Tính bảo trì và nâng cấp:
- Thêm sự kiện mới: Chỉ cần thêm lớp trong
navigation_event.dart
. - Thêm trạng thái mới: Cập nhật
NavigationState
và xử lý trongNavigationManager
. - Tách file giúp dễ quản lý mã trong dự án lớn.
5. Bài tập thực hành
- Thêm sự kiện
ToggleTheme
để đổi giao diện sáng/tối, cập nhậtNavigationState
với thuộc tínhisDarkTheme
. - Tách các màn hình vào thư mục
screens/
và kiểm tra ứng dụng. - Viết unit test cho
ToggleTheme
trongnavigation_manager_test.dart
. - Tạo barrel file
navigation/index.dart
và cập nhật import.
6. Kết luận
Hướng dẫn này đã cung cấp quy trình chi tiết để viết Bloc chuyên nghiệp:
- Sự kiện: Định nghĩa hành động người dùng, tách vào
navigation_event.dart
. - Trạng thái: Lưu dữ liệu giao diện, tách vào
navigation_state.dart
. - Bloc: Xử lý sự kiện, phát trạng thái, tách vào
navigation_manager.dart
. - Tích hợp: Sử dụng
StreamBuilder
để kết nối Bloc với giao diện. - Tổ chức: Tách file, sử dụng cấu trúc rõ ràng.
- Kiểm thử: Viết unit test để đảm bảo chất lượng.
Mã ví dụ trên dễ bảo trì, dễ nâng cấp, và phù hợp cho dự án lớn. Nếu bạn muốn tôi triển khai bài tập (như ToggleTheme
), mở rộng với tác vụ bất đồng bộ, hoặc giải thích sâu hơn về bất kỳ bước nào, hãy cho tôi biết!