BottomNavigationBar trong Flutter

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ệntrạ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ật selectedIndex).
  • 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ăng dataVersion).

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 {}
Dart

Giả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ận index để chỉ định tab mới.
  • UpdateSearchQuery nhận query để 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ăng dataVersion.

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,
  });
}
Dart

Giả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();
  }
}
Dart

Giả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ên currentState 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 cho StreamController để 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 trong State 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'),
              ),
            ],
          ),
        );
      },
    );
  }
}
Dart

Giải thích:

  • MyHomePage:
  • Khởi tạo NavigationManager trong initState và đóng trong dispose.
  • Sử dụng StreamBuilder để điều khiển BottomNavigationBar và các Navigator.
  • 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 trong StreamBuilder để 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
HTML

Nguyê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ạo navigation/index.dart:
  export 'navigation_event.dart';
  export 'navigation_state.dart';
  export 'navigation_manager.dart';
Dart

Sau đó, 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ục screens/:
  lib/
  ├── screens/
  │   ├── home_screen.dart
  │   ├── search_screen.dart
  │   ├── profile_screen.dart
Dart

Mẹo chuyên nghiệp:

  • Sử dụng tên file và lớp nhất quán (ví dụ: navigation_event.dart cho NavigationEvent).
  • 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();
  }
}
Dart

Thê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,
  });
}
Dart

Cậ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),
                ),
            ],
          ),
        );
      },
    );
  }
}
Dart

Mẹ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)),
    );
  });
}
Dart

Giả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ý trong NavigationManager.
  • Tách file giúp dễ quản lý mã trong dự án lớn.

5. Bài tập thực hành

  1. Thêm sự kiện ToggleTheme để đổi giao diện sáng/tối, cập nhật NavigationState với thuộc tính isDarkTheme.
  2. Tách các màn hình vào thư mục screens/ và kiểm tra ứng dụng.
  3. Viết unit test cho ToggleTheme trong navigation_manager_test.dart.
  4. 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!

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *