Hướng Nghiệp Dữ Liệu

Hướng dẫn Bloc/Cubit cho người mới (ví dụ thực tế)

Được viết bởi Admin|0 lượt xem
Hướng dẫn Bloc/Cubit cho người mới (ví dụ thực tế)

Tóm tắt nhanh

Hướng dẫn chi tiết cách sử dụng Bloc và Cubit cho state management trong Flutter với ví dụ thực tế dễ hiểu.

Mục lục bài viết (42)

Bloc và Cubit là pattern state management mạnh mẽ trong Flutter. Bài viết này sẽ hướng dẫn bạn sử dụng chúng với ví dụ thực tế.

Bloc vs Cubit

Cubit

  • ✅ Đơn giản hơn
  • ✅ Ít boilerplate
  • ✅ Phù hợp cho logic đơn giản

Bloc

  • ✅ Events và States rõ ràng
  • ✅ Traceable
  • ✅ Phù hợp cho logic phức tạp

Setup

1. Dependencies

[object Object]

2. Install

[object Object]

Cubit Example

1. Tạo Cubit

[object Object]

2. Sử dụng Cubit

[object Object]

Bloc Example

1. Define Events

[object Object]

2. Define States

[object Object]

3. Create Bloc

[object Object]

4. Sử dụng Bloc

[object Object]

Real-world Example: Todo App

1. Todo Cubit

[object Object]

Best Practices

1. Separate Files

[object Object]

2. Use Equatable

[object Object]

Cubit hay Bloc: chọn theo tiêu chí nào

Đây là câu hỏi gần như chắc chắn xuất hiện khi phỏng vấn, và câu trả lời tốt là câu trả lời theo hoàn cảnh chứ không phải theo cảm tính.

Chọn Cubit khi:

  • Mỗi hành động của người dùng tương ứng một hàm rõ ràng: loadCourses(), toggleFavorite(id), submitForm().
  • Luồng trạng thái ngắn, ít nhánh, không cần ghi lại lịch sử.
  • Bạn muốn ít file hơn: một file cubit thay vì ba file event/state/bloc.

Chọn Bloc khi:

  • Có nhiều sự kiện cùng loại nhưng khác tham số, và bạn muốn chúng đi qua một cửa duy nhất (SearchChanged, FilterApplied).
  • Cần kiểm soát thứ tự và tính đồng thời của các sự kiện (ví dụ: người dùng gõ tìm kiếm liên tục, chỉ lần gõ cuối cùng được xử lý).
  • Luồng nghiệp vụ nhiều bước, cần ghi lại chuỗi sự kiện để tái hiện lỗi.
  • Cần nhóm làm việc thống nhất một mô hình, đặc biệt khi đội có nhiều người mới.

Một cách kiểm tra nhanh: nếu bạn thấy mình viết if (event is X) rồi bên trong lại có thêm nhiều if con, có thể bạn đang cần thêm event type thay vì thêm nhánh. Ngược lại, nếu mỗi event chỉ có đúng một nơi xử lý và không cần tham số phức tạp, Cubit sẽ gọn hơn.

Luồng dữ liệu một chiều: vì sao mô hình này dễ lần lỗi

Cả Cubit và Bloc đều theo một chiều: sự kiện đi vào, trạng thái đi ra, giao diện chỉ đọc trạng thái. Giao diện không gọi trực tiếp hàm thay đổi dữ liệu, cũng không tự tính toán trạng thái hiển thị.

Lợi ích thực tế của ràng buộc này:

  • Khi giao diện sai, bạn biết chắc nguyên nhân nằm ở state, không phải ở một chuỗi cập nhật rải rác nhiều nơi.
  • Mọi thay đổi trạng thái đều đi qua một chỗ, nên có thể ghi log, đếm số lần, hoặc chặn ở một điểm duy nhất.
  • Test không cần dựng giao diện: bạn đưa sự kiện vào và so sánh danh sách trạng thái phát ra.

Đổi lại, bạn phải viết nhiều file hơn và phải nghĩ trước về tập trạng thái. Với dự án nhỏ, chi phí này có thể không đáng; với dự án có nghiệp vụ phức tạp, nó tiết kiệm rất nhiều thời gian sửa lỗi về sau.

Viết state cho đúng

Trạng thái là phần quan trọng nhất và cũng là phần bị làm ẩu nhất. Ba nguyên tắc:

1. Mô hình hoá trạng thái, đừng dùng nhiều cờ rời.

[object Object]

Với sealed class, giao diện dùng switch và trình biên dịch buộc bạn xử lý đủ nhánh. Bạn không thể quên trường hợp rỗng hay lỗi.

2. Dùng Equatable cho state có dữ liệu. Bloc chỉ phát state khi state mới khác state cũ. Nếu bạn không định nghĩa bằng nhau, hai state có cùng dữ liệu vẫn bị coi là khác, và giao diện vẽ lại vô ích.

[object Object]

Với danh sách, lưu ý so sánh theo phần tử: nếu Course cũng có Equatable, so sánh danh sách sẽ đúng theo nội dung.

3. State phải là bất biến. Không sửa trực tiếp danh sách bên trong state rồi gọi emit — Bloc sẽ so sánh thấy cùng một object và bỏ qua việc phát state, khiến giao diện không cập nhật. Hãy tạo bản mới:

[object Object]

Xử lý bất đồng bộ và chống race

Vấn đề kinh điển: người dùng gõ từ khoá tìm kiếm liên tục, kết quả của lần gõ trước về sau và ghi đè kết quả mới. Bloc xử lý việc này bằng cách kiểm soát tính đồng thời của các sự kiện.

Mặc định, các sự kiện trong cùng một handler được xử lý tuần tự. Với tìm kiếm, điều đó nghĩa là request cũ phải xong mới chạy request mới — chậm. Cách xử lý đúng là cho phép huỷ lần cũ và chỉ chạy lần mới nhất:

[object Object]

Hai chi tiết rất quan trọng trong đoạn trên:

  • transformer: restartable() huỷ xử lý trước đó khi có sự kiện mới cùng loại. Nhờ vậy chỉ lần gõ cuối cùng được hoàn tất.
  • Kiểm tra emit.isDone sau mỗi await. Khi người dùng đã rời màn hình và bloc bị đóng, việc emit tiếp sẽ gây lỗi. Đây là nguyên nhân của rất nhiều lỗi chỉ xuất hiện khi người dùng thao tác nhanh.

Bốn kiểu xử lý đồng thời thường dùng: sequential() (mặc định, chạy lần lượt), droppable() (bỏ qua sự kiện mới khi đang xử lý — hợp cho nút bấm), restartable() (huỷ cũ, chạy mới — hợp cho tìm kiếm), concurrent() (chạy song song — hợp cho các tác vụ độc lập).

Đặt Bloc ở đâu và vòng đời của nó

BlocProvider quyết định phạm vi và vòng đời. Ba trường hợp:

[object Object]

Quy tắc cần nhớ: dùng create khi bạn muốn Bloc tự quản lý vòng đời; dùng .value khi Bloc đã tồn tại ở nơi khác và bạn chỉ muốn cung cấp nó xuống dưới. Lỗi phổ biến là dùng .value trong build của một màn hình rồi tự thắc mắc vì sao bloc không được dọn dẹp, hoặc ngược lại dùng create và vô tình tạo một bloc mới mỗi lần widget cha vẽ lại.

Với route, nên khởi tạo bloc ngay khi màn hình được mở (thêm sự kiện Started trong create) thay vì trong initState — cách này giữ mọi khởi tạo ở một chỗ.

Đọc state trong giao diện: bốn widget, bốn mục đích

Nhiều người chỉ dùng BlocBuilder cho mọi việc, dẫn tới việc vẽ lại không cần thiết và khó xử lý tác dụng phụ. Phân chia đúng như sau:

[object Object]

Điểm quan trọng nhất trong đoạn này là listenWhen và buildWhen. Thiếu chúng, BlocListener sẽ chạy mỗi lần state đổi — nghĩa là điều hướng hoặc thông báo có thể xảy ra nhiều lần, và đây chính là nguyên nhân của lỗi "hiện thông báo hai lần" mà rất nhiều người gặp khi mới dùng Bloc.

Ví dụ thực tế: luồng đăng nhập đầy đủ

Đây là luồng phù hợp nhất để thấy Bloc có lợi thế, vì có nhiều trạng thái và nhiều nhánh xử lý.

[object Object]

Những gì mô hình này làm được mà setState khó làm gọn:

  • Không thể bấm gửi hai lần vì droppable() bỏ qua sự kiện mới khi đang xử lý.
  • Mỗi loại lỗi có thông báo riêng, và canRetry cho giao diện biết có nên hiện nút thử lại không.
  • Điều hướng sau khi đăng nhập được xử lý tập trung ở BlocListener, không rải trong hàm gửi.
  • Trạng thái đang gửi là một trạng thái riêng, nên nút bấm có thể tự vô hiệu hoá và hiện vòng xoay ở đúng chỗ.

Ví dụ thực tế: danh sách có tải thêm

Với danh sách phân trang, state cần biết thêm hai thứ: đang tải thêm và đã hết dữ liệu.

[object Object]

Ở giao diện, phần cuối danh sách phản ánh ba trạng thái: đang tải (vòng xoay), còn dữ liệu (không hiện gì hoặc hiện nút), đã hết (hasReachedMax). Cách chặn gọi trùng nằm ở tầng bloc: kiểm tra hasReachedMax và bỏ qua khi đang tải — nhờ vậy giao diện không cần tự dựng cờ.

Test Bloc: lợi ích lớn nhất của mô hình này

Đây là lý do đáng dùng Bloc ngay cả khi dự án nhỏ: bạn test được toàn bộ luồng nghiệp vụ mà không cần dựng giao diện.

[object Object]

Test thứ ba là ví dụ rõ nhất cho giá trị của mô hình: kiểm tra rằng bấm gửi hai lần chỉ tạo một lần xử lý — điều rất khó kiểm tra nếu logic nằm rải trong giao diện.

Quan sát và debug

Khi danh sách state dài, việc biết chính xác state nào đã phát ra giúp rút ngắn thời gian sửa lỗi rất nhiều. Bloc có sẵn cơ chế quan sát toàn cục:

[object Object]

Hãy đặt trong điều kiện chỉ chạy ở môi trường phát triển để không làm chậm và không ghi log dữ liệu người dùng trong bản phát hành.

Lỗi thường gặp khi mới dùng Bloc

  1. Cannot add new events after calling close. Xảy ra khi thêm sự kiện vào bloc đã bị đóng, thường do gọi từ một tác vụ bất đồng bộ hoàn thành muộn. Kiểm tra vòng đời trước khi add.
  2. Quên emit.isDone sau await, gây lỗi hoặc phát state sau khi màn hình đã bị xoá.
  3. Giao diện hiện thông báo hai lần. Thiếu listenWhen, hoặc BlocListener nằm trong widget bị dựng lại.
  4. State mới có cùng dữ liệu nhưng bị coi là khác, làm giao diện vẽ lại liên tục. Cần Equatable và so sánh theo giá trị.
  5. Sửa trực tiếp danh sách trong state rồi emit(state) — state không thật sự đổi nên không có gì được phát đi.
  6. Dùng BlocProvider.value cho bloc mới tạo trong build, khiến bloc bị tạo lại nhiều lần hoặc không được dọn dẹp.
  7. Để bloc khổng lồ quản lý cả màn hình, gộp cả trạng thái ô nhập, bộ lọc và dữ liệu. Hãy tách theo phạm vi: mỗi bloc cho một vùng trách nhiệm rõ ràng.
  8. Không kiểm tra state hiện tại trong handler, dẫn tới việc thêm dữ liệu vào state sai loại hoặc gọi lại khi đã hết dữ liệu.

Câu hỏi thường gặp

Có nên dùng Bloc cho mọi màn hình không?

Không. Với một màn hình hiển thị tĩnh hoặc trạng thái rất đơn giản, setState hoặc một ChangeNotifier nhỏ là đủ và ít file hơn. Bloc phát huy giá trị khi luồng trạng thái có nhiều bước và bạn cần test kỹ.

Có cần Equatable khi state đã là sealed class không?

Cần, nếu state chứa dữ liệu và bạn muốn Bloc bỏ qua những lần phát state có nội dung giống hệt lần trước. Không cần đối với các state không mang dữ liệu và được biểu diễn bằng lớp riêng.

Bloc có tương thích với Provider không?

Có. Bloc dùng InheritedWidget bên dưới nên không xung đột với Provider. Nhiều dự án dùng Provider cho phần nhẹ (chủ đề, cài đặt) và Bloc cho luồng nghiệp vụ. Điều cần tránh là dùng cả hai cho cùng một phần trạng thái — xem thêm bài hướng dẫn Provider.

Làm sao tránh việc gọi API hai lần khi màn hình dựng lại?

Khởi tạo dữ liệu trong create của BlocProvider (chỉ chạy một lần) hoặc trong handler của sự kiện khởi tạo, không gọi trong build. Nếu vẫn gặp tình trạng gọi hai lần, hãy kiểm tra xem BlocProvider có vô tình nằm trong một widget bị dựng lại hay không.

Nên dùng bloc_test hay tự viết test?

Dùng bloc_test vì nó xử lý sẵn việc so sánh danh sách state, chờ các tác vụ bất đồng bộ và dọn dẹp bloc sau mỗi test. Tự viết cũng được nhưng bạn sẽ phải làm lại ba việc đó ở mọi test.

Checklist Bloc/Cubit trước khi kết thúc tính năng

Bloc là mô hình khá nghiêm ngặt, nhưng có vài lỗi vẫn lặp lại ở hầu hết dự án. Rà nhanh danh sách sau:

  • Mỗi bloc có phạm vi trách nhiệm rõ ràng, không gộp cả trạng thái ô nhập lẫn dữ liệu màn hình.
  • State dùng sealed class cho các trạng thái chính, không dùng bốn cờ rời rạc.
  • State chứa dữ liệu có Equatable để tránh phát state trùng.
  • Không sửa trực tiếp danh sách bên trong state rồi emit(state).
  • Mọi handler có await đều kiểm tra emit.isDone sau đó.
  • Sự kiện có tính chất thay thế nhau (tìm kiếm, làm mới) có transformer phù hợp.
  • Nút bấm gửi form dùng droppable() hoặc kiểm tra trạng thái để chặn gửi trùng.
  • BlocListener có listenWhen để tránh điều hướng hoặc hiện thông báo nhiều lần.
  • BlocBuilder có buildWhen ở những vùng nặng.
  • Bloc theo màn hình dùng BlocProvider(create:) để tự dọn dẹp; không dùng .value cho bloc tự tạo trong build.
  • Có ít nhất một test cho nhánh lỗi và một test cho trường hợp người dùng bấm hai lần.

Khi nào nên cân nhắc công cụ khác

Bloc mạnh ở luồng nghiệp vụ nhiều bước, nhưng không phải lựa chọn tối ưu cho mọi trường hợp. Ba tình huống nên xem lại:

  1. Màn hình chỉ hiển thị dữ liệu tĩnh từ một nguồn, không có hành động phức tạp. Ở đây ChangeNotifier hoặc FutureBuilder là đủ và ít file hơn — xem bài hướng dẫn Provider.
  2. Trạng thái cục bộ trong một widget như đóng/mở bộ lọc, hiện/ẩn bảng. setState là lựa chọn đúng, không cần bloc.
  3. Đội ngũ chưa quen mô hình hướng sự kiện. Chi phí học có thể lớn hơn lợi ích nếu dự án nhỏ; hãy bắt đầu bằng Cubit rồi chuyển dần sang Bloc khi cần thêm sự kiện.

Điều quan trọng nhất khi chọn công cụ: giữ nhất quán trong toàn dự án. Trộn ba mô hình trong cùng một tính năng khiến người đọc code phải liên tục đổi cách suy nghĩ, và đó là chi phí lớn hơn mọi khác biệt kỹ thuật nhỏ. Bảng so sánh chi tiết ở bài so sánh Provider, Riverpod, Bloc, GetX.

Đặt tên cho sự kiện và trạng thái

Phần này ảnh hưởng lớn tới việc đọc code, nhưng ít khi được bàn tới. Vài quy ước nên thống nhất ngay từ đầu dự án:

  • Sự kiện ở dạng đã xảy ra: LoginSubmitted, CourseListRefreshed, FilterApplied. Người đọc hiểu ngay đây là việc đã xảy ra, không phải việc cần làm.
  • Trạng thái ở dạng mô tả tình trạng: LoginSubmitting, CourseListLoaded, CartEmpty. Nhìn tên là biết giao diện nên hiển thị gì.
  • Thêm hậu tố chỉ phạm vi khi dự án có nhiều bloc: CourseListLoading rõ hơn Loading. Trong code giao diện, việc phải cuộn lên xem bloc nào đang được dùng là dấu hiệu tên quá chung.
  • Một sự kiện chỉ nên làm một việc. Nếu bạn đặt tên CourseFetchedAndFilteredApplied, có thể bạn đang cần hai sự kiện.

Với Cubit, hãy đặt tên hàm theo ý định nghiệp vụ chứ không theo kỹ thuật: submitOrder() dễ hiểu hơn postToApi(), và refreshCourses() rõ hơn setState2(). Quy ước này quan trọng vì tên hàm là tài liệu đầu tiên người khác đọc khi mở file.

Kết luận

Bloc và Cubit:

  • ✅ Cubit: Đơn giản, ít boilerplate
  • ✅ Bloc: Events/States rõ ràng, traceable
  • ✅ Pattern: Predictable state management
  • ✅ Testing: Dễ test

Tóm tắt:

  1. Cubit: Cho logic đơn giản
  2. Bloc: Cho logic phức tạp
  3. Events/States: Define rõ ràng
  4. Best Practices: Separate files, use Equatable

Sử dụng Bloc/Cubit sẽ giúp state management predictable và testable! 🚀


Học Flutter bài bản theo lộ trình

Nếu bạn muốn học có người sửa bài thay vì tự ghép tài liệu, Flutter Hướng Nghiệp đào tạo theo 3 cấp:

Lớp online trực tiếp qua Zoom/Google Meet, có bản ghi lại — không bắt buộc đến cơ sở.
Xem lịch khai giảng hoặc so sánh 5 cách học lập trình mobile nếu còn phân vân.