1. Giới thiệu: Tại sao cần kết hợp Riverpod với MVVM?
Bạn đã từng rơi vào tình huống code Flutter của mình ngày càng phình to, khó hiểu và mỗi lần sửa một tính năng lại lo sợ ảnh hưởng đến chỗ khác? Hay bạn tự hỏi state management nên để ở đâu, UI rebuild khi nào và làm sao để test?
Đó là những câu hỏi mà bất kỳ lập trình viên Flutter nào cũng gặp phải khi dự án bắt đầu lớn dần.
State management trong Flutter có gì khó?
Flutter là reactive framework – UI được xây dựng từ state. Khi state thay đổi, UI tự động rebuild. Điều này nghe có vẻ đơn giản, nhưng thực tế lại đặt ra nhiều thách thức:
- State nằm ở đâu? Trong Widget hay bên ngoài?
- Làm sao để Widget A có thể cập nhật state của Widget B?
- Làm sao để tránh rebuild không cần thiết, gây giật lag?
- Làm sao để code dễ bảo trì, dễ test và dễ mở rộng?
Nếu không có một kiến trúc rõ ràng, bạn sẽ dễ dàng rơi vào tình trạng code “spaghetti” – state được truyền xuống qua constructor, Widget phình to vì chứa cả logic và UI, khó phân biệt đâu là business logic đâu là presentation logic.
MVVM giải quyết vấn đề gì trong tổ chức code?
MVVM (Model-View-ViewModel) là một mô hình kiến trúc phần mềm, giúp tách biệt rõ ràng các thành phần:
| Thành phần | Vai trò |
|---|---|
| Model | Dữ liệu và business logic (ví dụ: class Product, repository, service) |
| View | Giao diện người dùng (Widget trong Flutter) – hiển thị state và nhận tương tác |
| ViewModel | Cầu nối giữa Model và View – quản lý state của View, xử lý logic UI (không phải business logic) |
Nhờ sự tách biệt này:
- View chỉ lo hiển thị, không chứa logic xử lý.
- ViewModel chứa state và cách thay đổi state, dễ test.
- Model chứa dữ liệu và business logic, có thể tái sử dụng.
Tại sao Riverpod là lựa chọn tốt để đồng hành cùng MVVM?
Riverpod là một thư viện state management mạnh mẽ, được xây dựng bởi tác giả của Provider. So với các giải pháp khác, Riverpod nổi bật vì:
- Type-safe: Bắt lỗi ngay từ compile time.
- Không phụ thuộc vào Widget tree: Provider có thể được truy cập từ bất kỳ đâu, không cần
BuildContext. - Hỗ trợ dependency injection: Dễ dàng inject các dependency vào ViewModel.
- Khả năng kết hợp providers: Dễ dàng kết hợp nhiều nguồn state với nhau.
- Hỗ trợ async state: Xử lý loading/error/data một cách tự nhiên với
AsyncValuevàAsyncNotifier.
Trong MVVM, Riverpod đóng vai trò là cầu nối giữa View (Widget) và ViewModel – cung cấp ViewModel cho View và cho phép View lắng nghe sự thay đổi state.
2. Cài đặt dependencies
Trước khi bắt đầu, hãy thêm các package cần thiết vào pubspec.yaml:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.5.1 # Cập nhật phiên bản mới nhất
equatable: ^2.0.5
dev_dependencies:
flutter_test:
sdk: flutter
Chạy flutter pub get để tải về.
3. Hiểu đúng MVVM trong Flutter
Trước khi đi vào code, cần hiểu rõ MVVM trong bối cảnh Flutter – không áp đặt nguyên bản từ Android hay iOS.
Các thành phần: Model – View – ViewModel
Model:
- Đại diện cho dữ liệu và business logic.
- Có thể là các class đơn giản (DTO), service gọi API, repository lưu trữ dữ liệu.
- Không phụ thuộc vào UI.
View:
- Là các Widget Flutter.
- Nhiệm vụ duy nhất: hiển thị state từ ViewModel và gửi sự kiện tương tác người dùng lên ViewModel.
- Không chứa logic xử lý, không gọi API trực tiếp, không thao tác dữ liệu.
ViewModel:
- Quản lý state mà View cần hiển thị.
- Cung cấp các action (method) để View gọi khi có tương tác.
- Nhận dữ liệu từ Model (service/repository) và biến đổi thành state phù hợp cho View.
- Không biết gì về Widget – là class Dart thuần.
Vai trò của ViewModel trong Flutter (khác với Android như thế nào)
Trên Android (native), ViewModel thường gắn với vòng đời của Activity/Fragment và được giữ khi xoay màn hình.
Trong Flutter, vì mọi thứ đều là Widget và vòng đời khác, ViewModel là một class Dart thuần – không extends StatefulWidget, không extends ChangeNotifier (nếu dùng Riverpod).
ViewModel trong Flutter với Riverpod thường được xây dựng dưới dạng AsyncNotifier (hoặc Notifier) – chứa state và các phương thức thay đổi state.
Mối quan hệ giữa Widget (View) và ViewModel
[View] ---(watch)---> [ViewModel] ---(gọi)--> [Model/Repository]
| |
|---(gọi action)-------->|
| |
|<---(state mới)---------|
- View watch ViewModel để lắng nghe state.
- Khi người dùng tương tác, View gọi một action trên ViewModel.
- ViewModel cập nhật state → Riverpod thông báo cho View → View rebuild với state mới.
4. Riverpod – Công cụ kết nối View và ViewModel
Provider là gì và tại sao dùng cho ViewModel?
Provider trong Riverpod là một đối tượng “lưu trữ” một giá trị và cung cấp cách để truy xuất giá trị đó. Provider có thể:
- Được tạo một lần và tồn tại trong suốt vòng đời app.
- Có thể phụ thuộc vào provider khác.
- Tự động quản lý vòng đời.
Trong MVVM, mỗi ViewModel thường được expose qua một Provider, giúp View có thể dễ dàng lấy được instance của ViewModel và lắng nghe state.
Các loại Provider phù hợp với ViewModel
| Loại Provider | Khi nào dùng |
|---|---|
| Provider | Giá trị chỉ đọc, không thay đổi (ví dụ: repository, service) |
| StateProvider | State đơn giản (int, String, enum) – ít dùng trong MVVM phức tạp |
| NotifierProvider | State phức tạp, có action thay đổi state, đồng bộ (không async) |
| AsyncNotifierProvider (khuyến nghị) | State phức tạp, có action, bất đồng bộ (gọi API, CRUD) |
| FutureProvider | Một lần fetch dữ liệu bất đồng bộ, không có action |
Lưu ý: Riverpod 2.x giới thiệu Notifier và AsyncNotifier thay thế cho StateNotifier cũ. Với các tác vụ async (gọi API, CRUD), luôn dùng AsyncNotifier để có sẵn loading/error state.
Sự khác biệt giữa Riverpod và GetIt/Service Locator
| Riverpod Provider | GetIt (Service Locator) | |
|---|---|---|
| Cách lấy | Qua ref.watch / ref.read |
Qua `GetIt.I.get |
| ()` | ||
| Phụ thuộc | Có thể watch provider khác, tự động cập nhật | Thủ công, không tự động |
| Vòng đời | Tự động quản lý (autoDispose) | Phải tự dispose |
| Testing | Dễ dàng override provider | Phải override registration |
| Reactive | Có – UI tự rebuild khi state thay đổi | Không – phải tự gọi setState |
Riverpod không chỉ là một service locator, mà còn là một reactive state management – View tự động rebuild khi state thay đổi.
5. Thiết kế ViewModel với Riverpod (Step-by-Step)
Bây giờ, chúng ta sẽ đi vào thực hành. Giả sử ta đang xây dựng màn hình quản lý danh sách sản phẩm (Product List) với các chức năng:
- Hiển thị danh sách sản phẩm
- Thêm sản phẩm mới
- Sửa tên sản phẩm
- Xóa sản phẩm
- Lọc sản phẩm theo tên (local)
Bước 1: Xác định Model (Product)
// models/product.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
@immutable
class Product extends Equatable {
final String id;
final String name;
final double price;
const Product({
required this.id,
required this.name,
required this.price,
});
Product copyWith({String? id, String? name, double? price}) {
return Product(
id: id ?? this.id,
name: name ?? this.name,
price: price ?? this.price,
);
}
@override
List<Object?> get props => [id, name, price];
}
Bước 2: Xác định Repository (business logic)
Repository chứa các method gọi API (hoặc giả lập). Đây là business logic, không phải UI logic.
// repositories/product_repository.dart
import 'package:riverpod/riverpod.dart';
import '../models/product.dart';
/// Provider cho repository
final productRepositoryProvider = Provider
<ProductRepository>((ref) {
return ProductRepository();
});
class ProductRepository {
// Giả lập database
final List
<Product> _products = [
const Product(id: '1', name: 'Laptop', price: 15000000),
const Product(id: '2', name: 'Chuột không dây', price: 500000),
const Product(id: '3', name: 'Bàn phím cơ', price: 1200000),
];
Future<List<Product>> getProducts() async {
// Giả lập độ trễ mạng
await Future.delayed(const Duration(milliseconds: 500));
return _products;
}
Future
<Product> addProduct(Product product) async {
await Future.delayed(const Duration(milliseconds: 500));
final newProduct = product.copyWith(id: DateTime.now().millisecondsSinceEpoch.toString());
_products.add(newProduct);
return newProduct;
}
Future
<Product> updateProduct(Product product) async {
await Future.delayed(const Duration(milliseconds: 500));
final index = _products.indexWhere((p) => p.id == product.id);
if (index != -1) {
_products[index] = product;
return product;
}
throw Exception('Sản phẩm không tồn tại');
}
Future
<void> deleteProduct(String id) async {
await Future.delayed(const Duration(milliseconds: 500));
_products.removeWhere((p) => p.id == id);
}
}
Bước 3: Xây dựng ViewModel với AsyncNotifier
Vì các action đều là async (gọi repository), ta dùng AsyncNotifier. State của nó là AsyncValue<List<Product>> – tự động quản lý loading, data, error.
// viewmodels/product_viewmodel.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/product.dart';
import '../repositories/product_repository.dart';
class ProductViewModel extends AsyncNotifier<List<Product>> {
late final ProductRepository _repository;
@override
Future<List<Product>> build() async {
// Inject repository
_repository = ref.watch(productRepositoryProvider);
// Load dữ liệu ban đầu
return await _loadProducts();
}
Future<List<Product>> _loadProducts() async {
try {
return await _repository.getProducts();
} catch (e) {
// AsyncNotifier tự chuyển thành AsyncError
throw Exception('Không thể tải danh sách sản phẩm: $e');
}
}
/// Thêm sản phẩm
Future
<void> addProduct(String name, double price) async {
// Chuyển sang trạng thái loading
state = const AsyncLoading();
try {
final newProduct = await _repository.addProduct(
Product(id: '', name: name, price: price),
);
// Lấy danh sách hiện tại (nếu có)
final currentList = state.value ?? [];
state = AsyncData([...currentList, newProduct]);
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
/// Cập nhật sản phẩm
Future
<void> updateProduct(Product product) async {
state = const AsyncLoading();
try {
final updated = await _repository.updateProduct(product);
final currentList = state.value ?? [];
state = AsyncData(
currentList.map((p) => p.id == product.id ? updated : p).toList(),
);
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
/// Xóa sản phẩm
Future
<void> deleteProduct(String id) async {
state = const AsyncLoading();
try {
await _repository.deleteProduct(id);
final currentList = state.value ?? [];
state = AsyncData(currentList.where((p) => p.id != id).toList());
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
}
/// Provider cho ViewModel, sử dụng autoDispose để tự hủy khi không còn ai watch
final productViewModelProvider = AsyncNotifierProvider.autoDispose<ProductViewModel, List<Product>>(
ProductViewModel.new,
);
/// Provider riêng cho filter (state đơn giản, không async)
final filterProvider = StateProvider
<String>((ref) => '');
Giải thích:
build()trả vềFuture<List<Product>>– Riverpod tự động wrap thànhAsyncValue.- Mỗi action đều set
state = const AsyncLoading()trước khi gọi API, sau đó setAsyncDatahoặcAsyncError– UI sẽ tự động phản ánh. state.valuelấy dữ liệu hiện tại (nếu có) – an toàn vì chỉ dùng trongtryblock.- Filter được tách riêng thành
StateProvidervì nó là UI state, không liên quan đến async data.
6. Kết nối ViewModel vào Widget (View)
Cấu trúc View
// screens/product_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../viewmodels/product_viewmodel.dart';
import '../models/product.dart';
class ProductListScreen extends ConsumerWidget {
const ProductListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Lắng nghe async state
final asyncProducts = ref.watch(productViewModelProvider);
final filter = ref.watch(filterProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Quản lý sản phẩm'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => _showAddDialog(context, ref),
),
],
),
body: Column(
children: [
_buildSearchBar(ref, filter),
Expanded(
child: asyncProducts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Lỗi: $err'),
ElevatedButton(
onPressed: () => ref.invalidate(productViewModelProvider),
child: const Text('Thử lại'),
),
],
),
),
data: (products) => _buildProductList(products, filter, ref),
),
),
],
),
);
}
Widget _buildSearchBar(WidgetRef ref, String filter) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: const InputDecoration(
hintText: 'Tìm kiếm sản phẩm...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
),
onChanged: (value) => ref.read(filterProvider.notifier).state = value,
controller: TextEditingController(text: filter),
),
);
}
Widget _buildProductList(List
<Product> products, String filter, WidgetRef ref) {
final filtered = products
.where((p) => p.name.toLowerCase().contains(filter.toLowerCase()))
.toList();
if (filtered.isEmpty) {
return const Center(child: Text('Không có sản phẩm nào'));
}
return ListView.builder(
itemCount: filtered.length,
itemBuilder: (context, index) {
final product = filtered[index];
return ListTile(
title: Text(product.name),
subtitle: Text('${product.price.toStringAsFixed(0)} VND'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () => _showEditDialog(context, ref, product),
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
ref.read(productViewModelProvider.notifier).deleteProduct(product.id);
},
),
],
),
);
},
);
}
// Dialog thêm sản phẩm (có xử lý dispose controller)
void _showAddDialog(BuildContext context, WidgetRef ref) {
final nameController = TextEditingController();
final priceController = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Thêm sản phẩm'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Tên sản phẩm'),
),
TextField(
controller: priceController,
decoration: const InputDecoration(labelText: 'Giá (VND)'),
keyboardType: TextInputType.number,
),
],
),
actions: [
TextButton(
onPressed: () {
nameController.dispose();
priceController.dispose();
Navigator.pop(dialogContext);
},
child: const Text('Hủy'),
),
ElevatedButton(
onPressed: () async {
final name = nameController.text.trim();
final price = double.tryParse(priceController.text.trim()) ?? 0;
if (name.isEmpty || price <= 0) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
const SnackBar(content: Text('Vui lòng nhập đầy đủ thông tin')),
);
return;
}
// Gọi action
await ref.read(productViewModelProvider.notifier).addProduct(name, price);
nameController.dispose();
priceController.dispose();
if (dialogContext.mounted) Navigator.pop(dialogContext);
},
child: const Text('Thêm'),
),
],
);
},
);
}
// Dialog sửa sản phẩm
void _showEditDialog(BuildContext context, WidgetRef ref, Product product) {
final nameController = TextEditingController(text: product.name);
final priceController = TextEditingController(text: product.price.toString());
showDialog(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Sửa sản phẩm'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Tên sản phẩm'),
),
TextField(
controller: priceController,
decoration: const InputDecoration(labelText: 'Giá (VND)'),
keyboardType: TextInputType.number,
),
],
),
actions: [
TextButton(
onPressed: () {
nameController.dispose();
priceController.dispose();
Navigator.pop(dialogContext);
},
child: const Text('Hủy'),
),
ElevatedButton(
onPressed: () async {
final name = nameController.text.trim();
final price = double.tryParse(priceController.text.trim()) ?? 0;
if (name.isEmpty || price <= 0) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
const SnackBar(content: Text('Vui lòng nhập đầy đủ thông tin')),
);
return;
}
final updatedProduct = product.copyWith(name: name, price: price);
await ref.read(productViewModelProvider.notifier).updateProduct(updatedProduct);
nameController.dispose();
priceController.dispose();
if (dialogContext.mounted) Navigator.pop(dialogContext);
},
child: const Text('Lưu'),
),
],
);
},
);
}
}
Giải thích:
- Sử dụng
ConsumerWidget– có sẵnrefđể watch/read. asyncProducts.when()xử lý 3 trạng thái một cách rõ ràng.- Khi có lỗi, cung cấp nút “Thử lại” bằng cách gọi
ref.invalidate(provider)– Riverpod sẽ tự động gọi lạibuild(). - Các
TextEditingControllerđược dispose trongonPressedcủa nút Hủy hoặc Thêm/Lưu để tránh memory leak. - Có validation input và hiển thị snackbar khi nhập sai.
7. Chạy thử và giải thích luồng hoạt động
- Mở màn hình:
build()của ProductViewModel chạy → gọi_loadProducts()→ state làAsyncLoading→ UI hiển thịCircularProgressIndicator. - Load thành công: state thành
AsyncData([...])→ UI hiển thị danh sách sản phẩm. - Nhập từ khóa tìm kiếm:
filterProviderthay đổi → UI rebuild với danh sách đã lọc (không gọi API). - Thêm sản phẩm: Gọi
addProduct()→ state =AsyncLoading→ UI hiển thị loading (có thể thêm overlay). Khi thành công →AsyncDatavới danh sách mới → UI cập nhật. - Lỗi: Nếu repository throw exception, state =
AsyncError→ UI hiển thị thông báo lỗi.
8. Mở rộng: Tổ chức nhiều ViewModel và phụ thuộc
Chia sẻ dữ liệu giữa các ViewModel
ViewModel không nên biết về nhau trực tiếp. Dùng Provider để chia sẻ dữ liệu.
// user_provider.dart
final currentUserProvider = Provider<User?>((ref) => null);
// cart_viewmodel.dart
class CartViewModel extends AsyncNotifier<List<CartItem>> {
@override
Future<List<CartItem>> build() async {
final user = ref.watch(currentUserProvider);
if (user == null) return [];
return await _repository.getCart(user.id);
}
}
Sử dụng family cho nhiều instance
final productDetailProvider = AsyncNotifierProvider.family<ProductDetailViewModel, ProductDetailState, String>(
(ref, productId) => ProductDetailViewModel(productId: productId),
);
Cấu trúc thư mục gợi ý
lib/
├── core/
│ ├── di/ # Providers toàn cục
│ └── network/ # Dio, interceptor
├── features/
│ └── product/
│ ├── models/
│ │ └── product.dart
│ ├── repositories/
│ │ └── product_repository.dart
│ ├── viewmodels/
│ │ └── product_viewmodel.dart
│ └── screens/
│ └── product_list_screen.dart
└── main.dart
9. Những lưu ý khi áp dụng Riverpod + MVVM
Không để business logic quá nặng trong ViewModel
ViewModel chỉ nên chứa UI logic – cách state thay đổi để phản ứng với tương tác người dùng. Business logic (gọi API, tính toán phức tạp, validation) nên để ở Repository hoặc UseCase.
// ❌ Sai: ViewModel gọi API trực tiếp
class ProductViewModel extends AsyncNotifier<List<Product>> {
Future
<void> addProduct(String name, double price) async {
final response = await http.post(...); // Business logic trong VM
// ...
}
}
// ✅ Đúng: ViewModel gọi Repository
class ProductViewModel extends AsyncNotifier<List<Product>> {
Future
<void> addProduct(String name, double price) async {
final newProduct = await _repository.addProduct(...);
// ...
}
}
Tránh rebuild không cần thiết
- Dùng
selectđể chỉ watch phần state cần thiết. - Tách Widget con để mỗi Widget chỉ watch state của riêng nó.
- Tránh dùng
ref.watchtrongbuildcủa Widget cha nếu không cần toàn bộ state.
Ví dụ tối ưu với select:
final isLoading = ref.watch(productViewModelProvider.select((state) => state.isLoading));
Quản lý vòng đời và cleanup
final productProvider = AsyncNotifierProvider.autoDispose<ProductViewModel, List<Product>>(
ProductViewModel.new,
);
// Hoặc trong Notifier:
class ProductViewModel extends AsyncNotifier<List<Product>> {
@override
Future<List<Product>> build() async {
ref.onDispose(() {
// Dọn dẹp tài nguyên nếu có
});
return await _loadProducts();
}
}
10. Tổng kết
Lợi ích của việc kết hợp Riverpod và MVVM
| Lợi ích | Mô tả |
|---|---|
| Tách biệt rõ ràng | View chỉ lo UI, ViewModel quản lý state, Model xử lý dữ liệu |
| Dễ test | ViewModel là class Dart thuần, có thể test độc lập |
| Dễ bảo trì | Mỗi thành phần có trách nhiệm rõ ràng, dễ tìm và sửa lỗi |
| Tối ưu rebuild | Riverpod hỗ trợ select và tách Widget con |
| Hỗ trợ async | AsyncNotifier xử lý loading/error một cách tự nhiên |
| Dependency Injection | Dễ dàng inject repository/service vào ViewModel |
Gợi ý các chủ đề mở rộng
- Quản lý async state với Riverpod – xử lý loading/error chi tiết hơn.
- Testing Riverpod trong MVVM – viết unit test cho ViewModel.
- So sánh Riverpod và Bloc – lựa chọn phù hợp cho dự án.
11. Câu hỏi thường gặp (FAQ)
1. ViewModel trong Flutter có phải là StatefulWidget?
Không. ViewModel là một class Dart thuần, không phải Widget. Nó chứa state và logic xử lý, được cung cấp qua Provider. Widget (View) chỉ lắng nghe và hiển thị.
2. Có nhất thiết phải dùng AsyncNotifier không?
Không bắt buộc, nhưng đây là cách được khuyến khích nhất trong Riverpod cho MVVM khi có async (gọi API). Nếu state hoàn toàn đồng bộ, bạn có thể dùng Notifier.
3. Tôi có thể dùng Riverpod mà không cần MVVM không?
Có, Riverpod độc lập với kiến trúc. MVVM chỉ là một cách tổ chức code. Bài viết này tập trung vào cách kết hợp để tối ưu hóa maintainability.
4. Có nên dùng ChangeNotifierProvider trong MVVM không?
Hạn chế. ChangeNotifier khuyến khích mutable state, không an toàn và khó test. Dùng Notifier hoặc AsyncNotifier (mới trong Riverpod 2.x) tốt hơn.
5. AsyncNotifier khác gì với FutureProvider?
FutureProvider chỉ fetch một lần, không có action để thay đổi state từ UI. AsyncNotifier vừa fetch dữ liệu, vừa expose các method (add, update, delete) để UI tương tác.
6. Làm thế nào để test ViewModel với Riverpod?
Bạn có thể dùng ProviderContainer để override provider và kiểm tra state. Chi tiết xem bài viết Testing Riverpod trong MVVM.