JSON Serialization / Deserialization trong Flutter và Dart
1. Tổng quan về JSON Serialization và Deserialization
Trong quá trình phát triển ứng dụng bằng Flutter và Dart, JSON Serialization và Deserialization là hai cơ chế quan trọng giúp ứng dụng trao đổi dữ liệu với máy chủ, lưu trữ thông tin và chuyển đổi dữ liệu giữa các định dạng khác nhau.
Khi xây dựng một ứng dụng thực tế như website bán hàng, ứng dụng quản lý phòng tập, ứng dụng đặt vé hoặc hệ thống thương mại điện tử, dữ liệu thường được truyền qua REST API dưới định dạng JSON. Tuy nhiên, trong Dart, dữ liệu của ứng dụng thường được tổ chức thành các đối tượng thuộc những class cụ thể, chẳng hạn như User, Product, Order hoặc Category.
JSON và đối tượng Dart có cấu trúc khác nhau. Vì vậy, ứng dụng cần một cơ chế chuyển đổi dữ liệu để có thể gửi thông tin từ đối tượng Dart sang JSON và chuyển dữ liệu JSON nhận được thành các đối tượng Dart có thể sử dụng trong chương trình.
JSON Serialization là quá trình chuyển đổi dữ liệu từ đối tượng trong chương trình thành một định dạng có thể lưu trữ hoặc truyền đi, thường là JSON.
JSON Deserialization là quá trình chuyển đổi dữ liệu JSON thành cấu trúc dữ liệu hoặc đối tượng mà chương trình có thể xử lý.
Trong Dart, hai quá trình này thường được sử dụng cùng nhau khi làm việc với API, cơ sở dữ liệu, bộ nhớ cục bộ và các hệ thống trao đổi dữ liệu giữa những thành phần khác nhau.
Ví dụ, khi ứng dụng gửi thông tin đăng ký tài khoản lên máy chủ, dữ liệu có thể được chuyển từ đối tượng User thành JSON. Ngược lại, khi máy chủ trả về thông tin người dùng, ứng dụng sẽ chuyển JSON nhận được thành đối tượng User để hiển thị tên, email và những thông tin liên quan trên giao diện.
2. Khái niệm JSON
JSON là viết tắt của JavaScript Object Notation, một định dạng biểu diễn dữ liệu có cấu trúc, được sử dụng phổ biến trong giao tiếp giữa các ứng dụng và hệ thống.
Mặc dù có nguồn gốc từ JavaScript, JSON không bị giới hạn trong ngôn ngữ này. Dart, Java, Python, C#, PHP, Go, JavaScript và nhiều ngôn ngữ lập trình khác đều có thể xử lý JSON thông qua các thư viện hoặc công cụ hỗ trợ.
JSON được ưa chuộng nhờ cú pháp tương đối đơn giản, dễ đọc, dễ tạo và phù hợp để truyền dữ liệu qua HTTP. Các hệ thống Backend thường trả về JSON khi ứng dụng gửi yêu cầu đến REST API.
Một đối tượng JSON thường bao gồm các cặp khóa và giá trị. Khóa được đặt trong dấu ngoặc kép, còn giá trị có thể là chuỗi, số, giá trị Boolean, null, đối tượng hoặc mảng.
Ví dụ:
{
"id": 101,
"name": "Nguyen Van Huy",
"email": "[email protected]",
"age": 21,
"isActive": true,
"address": {
"city": "Ho Chi Minh City",
"country": "Vietnam"
},
"skills": [
"Flutter",
"Dart",
"REST API"
]
}
Trong ví dụ trên, JSON biểu diễn thông tin của một người dùng với nhiều kiểu dữ liệu khác nhau.
idlà một giá trị số nguyên.namevàemaillà các giá trị chuỗi.agelà một giá trị số.isActivelà giá trị Boolean.addresslà một đối tượng JSON lồng nhau.skillslà một mảng chứa nhiều chuỗi.
Cấu trúc này cho phép hệ thống tổ chức dữ liệu rõ ràng và biểu diễn được những đối tượng có nhiều thuộc tính.
2.1. Các kiểu dữ liệu được hỗ trợ trong JSON
JSON hỗ trợ một số kiểu dữ liệu cơ bản:
| Kiểu dữ liệu | Ví dụ | Ý nghĩa |
|---|---|---|
| String | "Flutter" |
Chuỗi ký tự |
| Number | 100, 19.5 |
Số nguyên hoặc số thực |
| Boolean | true, false |
Giá trị đúng hoặc sai |
| Null | null |
Không có giá trị |
| Object | {"id": 1} |
Tập hợp các cặp khóa và giá trị |
| Array | [1, 2, 3] |
Danh sách các giá trị |
JSON không có các kiểu dữ liệu đặc thù của Dart như DateTime, Duration, BigInt hay các đối tượng được tạo từ class tự định nghĩa. Khi cần truyền những dữ liệu này qua JSON, chương trình phải chuyển đổi chúng sang các kiểu JSON phù hợp.
Ví dụ, đối tượng DateTime có thể được biểu diễn bằng chuỗi ISO 8601:
{
"createdAt": "2026-10-15T10:30:00.000Z"
}
Khi nhận dữ liệu, ứng dụng có thể chuyển chuỗi này thành một đối tượng DateTime để thực hiện các phép tính thời gian hoặc định dạng ngày tháng.
2.2. JSON Object và Map trong Dart
Trong Dart, JSON Object thường được xử lý thông qua cấu trúc Map<String, dynamic>.
Ví dụ:
Map<String, dynamic> user = {
'id': 101,
'name': 'Nguyen Van Huy',
'isActive': true,
};
Cấu trúc này có những điểm tương đồng với một đối tượng JSON. Tuy nhiên, cần phân biệt rằng Map<String, dynamic> là cấu trúc dữ liệu trong Dart, còn JSON là định dạng văn bản dùng để biểu diễn dữ liệu.
Một Map có thể chứa trực tiếp những giá trị Dart như DateTime, nhưng các giá trị đó không thể tự động được mã hóa thành JSON nếu không có cơ chế chuyển đổi phù hợp.
Để chuyển Map thành chuỗi JSON, Dart cung cấp jsonEncode(). Để chuyển chuỗi JSON thành cấu trúc dữ liệu Dart, Dart cung cấp jsonDecode().
import 'dart:convert';
void main() {
Map<String, dynamic> user = {
'id': 101,
'name': 'Nguyen Van Huy',
'isActive': true,
};
String jsonString = jsonEncode(user);
print(jsonString);
Map<String, dynamic> decodedUser = jsonDecode(jsonString);
print(decodedUser['name']);
}
Kết quả đầu tiên là một chuỗi JSON biểu diễn dữ liệu người dùng. Sau khi giải mã, chương trình có thể truy cập lại các giá trị thông qua những khóa tương ứng.
3. JSON Serialization trong Dart
JSON Serialization là quá trình chuyển đổi dữ liệu Dart thành dữ liệu JSON. Trong các ứng dụng sử dụng lập trình hướng đối tượng, quá trình này thường liên quan đến việc chuyển đổi một instance của class thành Map<String, dynamic>, sau đó mã hóa Map thành chuỗi JSON khi cần truyền hoặc lưu trữ.
Giả sử ứng dụng có class Product để đại diện cho một sản phẩm trong cửa hàng.
class Product {
final int id;
final String name;
final double price;
Product({
required this.id,
required this.name,
required this.price,
});
}
Một đối tượng có thể được khởi tạo như sau:
final product = Product(
id: 1,
name: 'Mechanical Keyboard',
price: 1290000,
);
Đối tượng product chứa dữ liệu theo cấu trúc được định nghĩa bởi class Product. Tuy nhiên, đối tượng này không tự động trở thành một chuỗi JSON chỉ vì các thuộc tính của nó có thể biểu diễn bằng JSON.
Để chuyển đổi dữ liệu, có thể định nghĩa phương thức toJson() trong class.
class Product {
final int id;
final String name;
final double price;
Product({
required this.id,
required this.name,
required this.price,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'price': price,
};
}
}
Phương thức toJson() trả về một Map<String, dynamic> chứa các thuộc tính cần được chuyển đổi.
Khi cần tạo chuỗi JSON, sử dụng:
import 'dart:convert';
final product = Product(
id: 1,
name: 'Mechanical Keyboard',
price: 1290000,
);
final jsonString = jsonEncode(product.toJson());
print(jsonString);
Kết quả:
{
"id": 1,
"name": "Mechanical Keyboard",
"price": 1290000
}
Đây là một ví dụ về quá trình Serialization. Đối tượng Dart được chuyển thành Map, sau đó jsonEncode() chuyển Map thành chuỗi JSON.
Trong ứng dụng thực tế, phương thức toJson() có thể được sử dụng khi gửi yêu cầu tạo sản phẩm, cập nhật hồ sơ người dùng, gửi thông tin đơn hàng hoặc lưu dữ liệu xuống bộ nhớ cục bộ.
4. JSON Deserialization trong Dart
JSON Deserialization là quá trình chuyển đổi dữ liệu JSON thành cấu trúc dữ liệu mà ứng dụng có thể sử dụng, thường là các đối tượng thuộc class được định nghĩa trước.
Khi một API trả về dữ liệu sản phẩm, kết quả có thể là chuỗi JSON hoặc một đối tượng JSON đã được giải mã thành Map<String, dynamic>.
Ví dụ, dữ liệu nhận được từ máy chủ:
{
"id": 1,
"name": "Mechanical Keyboard",
"price": 1290000
}
Để sử dụng dữ liệu này dưới dạng đối tượng Product, có thể định nghĩa phương thức tạo đối tượng từ JSON thông qua constructor fromJson().
class Product {
final int id;
final String name;
final double price;
Product({
required this.id,
required this.name,
required this.price,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
);
}
}
Trong đoạn code trên, Product.fromJson() nhận một Map<String, dynamic> và trả về một đối tượng Product.
Từ dữ liệu JSON, chương trình đọc các trường id, name và price, sau đó truyền chúng vào constructor để tạo đối tượng mới.
Ví dụ sử dụng:
final Map<String, dynamic> json = {
'id': 1,
'name': 'Mechanical Keyboard',
'price': 1290000,
};
final product = Product.fromJson(json);
print(product.name);
print(product.price);
Kết quả:
Mechanical Keyboard
1290000.0
Phương thức fromJson() thường được khai báo bằng factory constructor vì việc khởi tạo đối tượng cần xử lý dữ liệu đầu vào trước khi trả về instance. Cách khai báo này cũng cho phép constructor thực hiện logic chuyển đổi hoặc kiểm tra dữ liệu khi cần thiết.
Trong trường hợp ứng dụng nhận dữ liệu trực tiếp dưới dạng chuỗi JSON, cần sử dụng jsonDecode() trước khi gọi fromJson().
import 'dart:convert';
void main() {
const jsonString = '''
{
"id": 1,
"name": "Mechanical Keyboard",
"price": 1290000
}
''';
final Map<String, dynamic> json =
jsonDecode(jsonString) as Map<String, dynamic>;
final product = Product.fromJson(json);
print(product.name);
}
Như vậy, Deserialization thường bao gồm hai bước: giải mã chuỗi JSON thành cấu trúc dữ liệu Dart, sau đó chuyển cấu trúc đó thành đối tượng thuộc class tương ứng.
5. Sự khác biệt giữa Serialization và Deserialization
Serialization và Deserialization là hai quá trình bổ sung cho nhau trong việc trao đổi dữ liệu giữa ứng dụng và các hệ thống bên ngoài.
| Tiêu chí | Serialization | Deserialization |
|---|---|---|
| Hướng chuyển đổi | Đối tượng Dart sang JSON | JSON sang đối tượng Dart |
| Mục đích | Gửi hoặc lưu dữ liệu | Đọc và sử dụng dữ liệu |
| Phương thức thường dùng | toJson() |
fromJson() |
| Hàm hỗ trợ của Dart | jsonEncode() |
jsonDecode() |
| Đầu ra thường gặp | Map hoặc chuỗi JSON |
Map hoặc đối tượng Dart |
| Tình huống sử dụng | Tạo hoặc cập nhật dữ liệu qua API | Đọc danh sách sản phẩm từ API |
Một quy trình trao đổi dữ liệu hoàn chỉnh thường sử dụng cả hai cơ chế.
Khi người dùng tạo một đơn hàng, ứng dụng chuyển đối tượng đơn hàng thành JSON để gửi lên máy chủ. Sau khi máy chủ xử lý yêu cầu, ứng dụng nhận phản hồi JSON và chuyển đổi thành đối tượng đơn hàng để hiển thị mã đơn hàng, tổng tiền và trạng thái thanh toán.
Hai quá trình này giúp duy trì cấu trúc dữ liệu rõ ràng giữa các lớp trong ứng dụng và hạn chế việc thao tác trực tiếp với các chuỗi JSON ở nhiều nơi trong mã nguồn.
6. Quy trình chuyển đổi dữ liệu JSON trong ứng dụng Flutter
Trong một ứng dụng Flutter có kết nối Backend, dữ liệu thường đi qua nhiều lớp khác nhau. Người dùng thao tác trên giao diện, ứng dụng gửi yêu cầu đến API, máy chủ xử lý dữ liệu và trả về phản hồi. JSON đóng vai trò là định dạng trung gian giúp các thành phần này trao đổi thông tin.
Quy trình tổng quát có thể được mô tả như sau:
Người dùng thao tác trên giao diện
|
v
Flutter UI Layer
|
v
Model Dart Object
|
Serialization
|
v
JSON String
|
v
REST API
|
v
Backend
|
v
JSON Response
|
Deserialization
|
v
Model Dart Object
|
v
Hiển thị dữ liệu
Quy trình này có thể thay đổi tùy theo kiến trúc ứng dụng. Một số ứng dụng sử dụng Repository, Service, Provider, Riverpod hoặc BLoC để quản lý dữ liệu. Tuy nhiên, nguyên tắc chuyển đổi giữa JSON và đối tượng Dart vẫn tương tự.
Ví dụ, trong ứng dụng bán hàng, khi người dùng mở trang chi tiết sản phẩm, ứng dụng có thể gửi yêu cầu GET /api/products/1. Backend trả về dữ liệu sản phẩm dưới dạng JSON. Lớp xử lý API nhận phản hồi, giải mã JSON và tạo đối tượng Product. Sau đó, dữ liệu được chuyển đến lớp quản lý trạng thái và cuối cùng hiển thị trên giao diện.
Ngược lại, khi người dùng cập nhật tên sản phẩm, ứng dụng có thể tạo đối tượng dữ liệu mới, chuyển thành JSON và gửi yêu cầu PUT hoặc PATCH đến Backend.
Việc phân chia rõ các bước giúp mã nguồn dễ bảo trì, dễ kiểm thử và thuận tiện khi thay đổi cấu trúc dữ liệu.
7. Xây dựng Model hỗ trợ JSON
Model là lớp đại diện cho một thực thể dữ liệu trong ứng dụng. Mỗi model thường chứa các thuộc tính, constructor và những phương thức cần thiết để tạo, đọc hoặc chuyển đổi dữ liệu.
Trong Flutter, model thường được sử dụng để biểu diễn người dùng, sản phẩm, đơn hàng, bài viết, danh mục và các thực thể khác.
Một model tốt nên thể hiện rõ cấu trúc dữ liệu, kiểu dữ liệu của từng thuộc tính và cách chuyển đổi dữ liệu từ bên ngoài vào ứng dụng.
Ví dụ, xây dựng model User:
class User {
final int id;
final String name;
final String email;
final bool isActive;
const User({
required this.id,
required this.name,
required this.email,
required this.isActive,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
email: json['email'] as String,
isActive: json['isActive'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
'isActive': isActive,
};
}
}
Model này có hai phương thức quan trọng.
fromJson()chuyển dữ liệu JSON đã giải mã thành đối tượngUser.toJson()chuyển đối tượngUserthànhMap<String, dynamic>.
Thuộc tính được khai báo bằng final để không thể gán lại giá trị sau khi đối tượng được khởi tạo. Điều này giúp model có tính bất biến ở cấp thuộc tính, giảm nguy cơ dữ liệu bị thay đổi ngoài ý muốn.
Tuy nhiên, tính bất biến này không tự động đảm bảo mọi dữ liệu bên trong đối tượng đều bất biến. Nếu model chứa List hoặc Map có thể sửa đổi, những cấu trúc lồng nhau đó vẫn cần được xử lý riêng khi yêu cầu tính bất biến hoàn toàn.
7.1. Vai trò của constructor fromJson()
Constructor fromJson() chịu trách nhiệm tạo một đối tượng hợp lệ từ dữ liệu nhận được.
Ví dụ, nếu API trả về:
{
"id": 10,
"name": "Tran Minh Tuan",
"email": "[email protected]",
"isActive": true
}
Ứng dụng có thể sử dụng:
final user = User.fromJson(json);
print(user.id);
print(user.name);
print(user.email);
Việc chuyển dữ liệu thành đối tượng giúp những phần còn lại của chương trình sử dụng các thuộc tính có kiểu rõ ràng thay vì liên tục truy cập vào những khóa của Map.
Ví dụ, user.name thể hiện trực tiếp thuộc tính tên người dùng. Trong khi đó, json['name'] có kiểu giá trị tổng quát hơn và cần kiểm soát kiểu dữ liệu trước khi sử dụng.
7.2. Vai trò của phương thức toJson()
Phương thức toJson() xác định những thuộc tính sẽ được chuyển đổi khi dữ liệu được gửi đi hoặc lưu trữ.
Ví dụ:
final user = User(
id: 10,
name: 'Tran Minh Tuan',
email: '[email protected]',
isActive: true,
);
final Map<String, dynamic> data = user.toJson();
Biến data có thể được truyền vào jsonEncode() hoặc được sử dụng trực tiếp bởi thư viện HTTP có hỗ trợ gửi Map dưới dạng JSON.
Một điểm cần lưu ý là không phải mọi thuộc tính trong model đều nhất thiết phải được gửi lên máy chủ. Đối với dữ liệu nhạy cảm hoặc dữ liệu chỉ phục vụ giao diện, cần lựa chọn trường xuất ra một cách có chủ đích.
Ví dụ, mật khẩu không nên được đưa vào JSON phản hồi chỉ vì model có thuộc tính mật khẩu. Việc kiểm soát trường dữ liệu là một phần quan trọng của thiết kế ứng dụng an toàn.
8. Xử lý dữ liệu JSON có thuộc tính tùy chọn và giá trị null
Dữ liệu trả về từ API không phải lúc nào cũng đầy đủ. Một số thuộc tính có thể không tồn tại, có giá trị null hoặc chỉ xuất hiện trong một số trường hợp nhất định.
Ví dụ, hồ sơ người dùng có thể chứa tên, email, số điện thoại và ảnh đại diện. Trong đó, ảnh đại diện có thể chưa được thiết lập.
{
"id": 10,
"name": "Tran Minh Tuan",
"email": "[email protected]",
"avatar": null
}
Nếu model khai báo avatar là String không nullable, việc gán giá trị null sẽ gây lỗi kiểu dữ liệu.
Cách xử lý phù hợp là sử dụng kiểu nullable String?.
class User {
final int id;
final String name;
final String? avatar;
const User({
required this.id,
required this.name,
this.avatar,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as int,
name: json['name'] as String,
avatar: json['avatar'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'avatar': avatar,
};
}
}
Khi hiển thị ảnh đại diện, ứng dụng có thể kiểm tra giá trị trước khi sử dụng.
if (user.avatar != null) {
print('Avatar: ${user.avatar}');
} else {
print('Người dùng chưa có ảnh đại diện');
}
8.1. Phân biệt trường bị thiếu và trường có giá trị null
Hai trường hợp sau không hoàn toàn giống nhau:
{
"name": "An"
}
{
"name": "An",
"avatar": null
}
Trong trường hợp đầu tiên, khóa avatar không xuất hiện. Trong trường hợp thứ hai, khóa tồn tại nhưng giá trị là null.
Khi sử dụng json['avatar'], cả hai trường hợp đều có thể trả về null. Vì vậy, nếu cần phân biệt chúng, chương trình phải kiểm tra sự tồn tại của khóa.
if (json.containsKey('avatar')) {
print('Trường avatar có xuất hiện trong JSON');
} else {
print('Trường avatar không xuất hiện trong JSON');
}
Sự khác biệt này đặc biệt quan trọng khi cập nhật dữ liệu bằng API.
Chẳng hạn, trong một yêu cầu cập nhật hồ sơ:
- Không gửi trường
avatarcó thể có nghĩa là giữ nguyên ảnh hiện tại. - Gửi
"avatar": nullcó thể có nghĩa là xóa ảnh đại diện.
Ý nghĩa cụ thể phụ thuộc vào quy ước của Backend. Ứng dụng cần tuân theo hợp đồng API thay vì tự giả định cách xử lý.
8.2. Sử dụng giá trị mặc định
Một số thuộc tính có thể được gán giá trị mặc định khi dữ liệu không tồn tại.
Ví dụ, số lượng sản phẩm có thể mặc định bằng 0 nếu API cho phép thiếu trường này.
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
name: json['name'] as String,
price: (json['price'] as num).toDouble(),
quantity: (json['quantity'] as int?) ?? 0,
);
}
Trong ví dụ này, toán tử ?? trả về 0 khi giá trị bên trái là null.
Tuy nhiên, không nên áp dụng giá trị mặc định một cách tùy tiện. Nếu trường bắt buộc như id hoặc giá tiền bị thiếu, việc tự gán giá trị có thể che giấu lỗi dữ liệu từ máy chủ.
9. Chuyển đổi JSON có cấu trúc lồng nhau
Trong các ứng dụng thực tế, JSON thường không chỉ chứa các trường dữ liệu đơn giản. Một đối tượng có thể bao gồm những đối tượng khác hoặc danh sách các đối tượng.
Ví dụ, một đơn hàng có thể bao gồm thông tin khách hàng, danh sách sản phẩm và địa chỉ giao hàng.
{
"id": 5001,
"customer": {
"id": 10,
"name": "Tran Minh Tuan"
},
"shippingAddress": {
"city": "Ho Chi Minh City",
"street": "Nguyen Trai"
},
"items": [
{
"productId": 101,
"quantity": 2
},
{
"productId": 102,
"quantity": 1
}
]
}
Để xử lý cấu trúc này một cách rõ ràng, nên tạo các model riêng cho từng thực thể.
class Customer {
final int id;
final String name;
const Customer({
required this.id,
required this.name,
});
factory Customer.fromJson(Map<String, dynamic> json) {
return Customer(
id: json['id'] as int,
name: json['name'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
};
}
}
class ShippingAddress {
final String city;
final String street;
const ShippingAddress({
required this.city,
required this.street,
});
factory ShippingAddress.fromJson(Map<String, dynamic> json) {
return ShippingAddress(
city: json['city'] as String,
street: json['street'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'city': city,
'street': street,
};
}
}
class OrderItem {
final int productId;
final int quantity;
const OrderItem({
required this.productId,
required this.quantity,
});
factory OrderItem.fromJson(Map<String, dynamic> json) {
return OrderItem(
productId: json['productId'] as int,
quantity: json['quantity'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'productId': productId,
'quantity': quantity,
};
}
}
Sau khi xây dựng các model thành phần, model Order có thể sử dụng trực tiếp những đối tượng này.
class Order {
final int id;
final Customer customer;
final ShippingAddress shippingAddress;
final List<OrderItem> items;
const Order({
required this.id,
required this.customer,
required this.shippingAddress,
required this.items,
});
factory Order.fromJson(Map<String, dynamic> json) {
return Order(
id: json['id'] as int,
customer: Customer.fromJson(
json['customer'] as Map<String, dynamic>,
),
shippingAddress: ShippingAddress.fromJson(
json['shippingAddress'] as Map<String, dynamic>,
),
items: (json['items'] as List<dynamic>)
.map(
(item) => OrderItem.fromJson(
item as Map<String, dynamic>,
),
)
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'customer': customer.toJson(),
'shippingAddress': shippingAddress.toJson(),
'items': items.map((item) => item.toJson()).toList(),
};
}
}
Cách triển khai này mang lại một số lợi ích:
- Mỗi model chịu trách nhiệm xử lý dữ liệu của một thực thể.
- Cấu trúc lồng nhau được biểu diễn rõ ràng bằng các đối tượng Dart.
- Các model thành phần có thể tái sử dụng trong nhiều chức năng.
- Việc kiểm thử và bảo trì từng phần trở nên thuận tiện hơn.
- Mã nguồn không phải xử lý quá nhiều khóa JSON trong cùng một class.
Đối với ứng dụng có nhiều model phức tạp, cách tổ chức này hiệu quả hơn so với việc sử dụng một Map<String, dynamic> lớn cho toàn bộ dữ liệu.
10. Chuyển đổi danh sách JSON thành danh sách đối tượng
API thường trả về một danh sách khi ứng dụng yêu cầu danh sách sản phẩm, người dùng hoặc đơn hàng.
Ví dụ:
[
{
"id": 1,
"name": "Keyboard",
"price": 1290000
},
{
"id": 2,
"name": "Mouse",
"price": 450000
},
{
"id": 3,
"name": "Monitor",
"price": 3990000
}
]
Sau khi gọi jsonDecode(), dữ liệu này thường trở thành List<dynamic>. Để sử dụng dưới dạng List<Product>, cần chuyển từng phần tử thành một đối tượng Product.
import 'dart:convert';
void main() {
const jsonString = '''
[
{
"id": 1,
"name": "Keyboard",
"price": 1290000
},
{
"id": 2,
"name": "Mouse",
"price": 450000
}
]
''';
final List<dynamic> decoded = jsonDecode(jsonString);
final List<Product> products = decoded
.map(
(item) => Product.fromJson(
item as Map<String, dynamic>,
),
)
.toList();
for (final product in products) {
print('${product.name}: ${product.price}');
}
}
Phương thức map() áp dụng một hàm chuyển đổi cho từng phần tử trong danh sách. Sau đó, toList() tạo danh sách kết quả.
Khi API trả về hàng trăm hoặc hàng nghìn sản phẩm, việc chuyển đổi từng phần tử giúp ứng dụng làm việc với kiểu dữ liệu nhất quán.
Tuy nhiên, đối với dữ liệu rất lớn, cần quan tâm đến bộ nhớ, thời gian xử lý và chiến lược phân trang. Việc tạo hàng chục nghìn đối tượng cùng lúc có thể gây ra độ trễ, đặc biệt trên thiết bị cấu hình thấp.
Trong trường hợp danh sách lớn, nên kết hợp phân trang từ Backend, tải dữ liệu theo từng phần và chỉ tạo những đối tượng cần thiết cho chức năng đang sử dụng.
11. Sử dụng thư viện json_serializable
Khi ứng dụng có nhiều model, việc viết thủ công fromJson() và toJson() cho từng class sẽ tốn thời gian và dễ phát sinh lỗi.
Một model đơn giản có thể chỉ có ba thuộc tính, nhưng model thực tế có thể chứa hàng chục trường, các đối tượng lồng nhau, danh sách, giá trị nullable và những quy tắc chuyển đổi riêng.
Thư viện json_serializable hỗ trợ tự động tạo mã Serialization và Deserialization dựa trên khai báo của model.
Thư viện này thường được sử dụng cùng với:
json_annotation: cung cấp annotation để mô tả cách chuyển đổi dữ liệu.json_serializable: tạo mã chuyển đổi JSON.build_runner: chạy quá trình sinh mã.
11.1. Cài đặt thư viện
Trong dự án Flutter, chạy lệnh:
flutter pub add json_annotation
flutter pub add --dev json_serializable
flutter pub add --dev build_runner
Các package được thêm vào pubspec.yaml theo vai trò tương ứng. Sau khi cài đặt, có thể xây dựng model sử dụng annotation.
11.2. Khai báo model với annotation
Tạo file product.dart:
import 'package:json_annotation/json_annotation.dart';
part 'product.g.dart';
@JsonSerializable()
class Product {
final int id;
final String name;
final double price;
const Product({
required this.id,
required this.name,
required this.price,
});
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}
Trong đoạn code trên:
@JsonSerializable()đánh dấu class cần sinh mã JSON.part 'product.g.dart'liên kết file mã được sinh với file model._$ProductFromJson()là hàm chuyển JSON thành đối tượng._$ProductToJson()là hàm chuyển đối tượng thànhMap.
Hai hàm bắt đầu bằng _ là những hàm được sinh trong file product.g.dart. Thông thường, lập trình viên không cần tự viết hoặc chỉnh sửa trực tiếp các hàm này.
11.3. Sinh mã tự động
Sau khi khai báo model, chạy:
dart run build_runner build --delete-conflicting-outputs
Lệnh này yêu cầu build_runner thực hiện quá trình sinh mã và xử lý những file đầu ra xung đột nếu có.
Sau khi quá trình hoàn tất, thư mục dự án sẽ có file product.g.dart.
Khi thêm hoặc thay đổi thuộc tính của model, cần chạy lại lệnh sinh mã để cập nhật phần Serialization và Deserialization tương ứng.
Trong quá trình phát triển, có thể sử dụng chế độ theo dõi:
dart run build_runner watch --delete-conflicting-outputs
Chế độ watch theo dõi những thay đổi của file nguồn và tự động chạy lại quá trình sinh mã khi cần thiết.
11.4. Sử dụng model được sinh mã
Sau khi sinh mã thành công, cách sử dụng model tương tự như model viết thủ công.
import 'dart:convert';
void main() {
final product = Product(
id: 1,
name: 'Mechanical Keyboard',
price: 1290000,
);
final jsonString = jsonEncode(product.toJson());
print(jsonString);
final jsonMap =
jsonDecode(jsonString) as Map<String, dynamic>;
final decodedProduct = Product.fromJson(jsonMap);
print(decodedProduct.name);
}
Ưu điểm của phương pháp này là giảm lượng mã lặp lại, giữ các model nhất quán và hạn chế sai sót khi thêm thuộc tính.
Tuy nhiên, mã sinh tự động không loại bỏ hoàn toàn nhu cầu kiểm tra dữ liệu. Nếu API trả về kiểu dữ liệu không phù hợp với model, quá trình chuyển đổi vẫn có thể thất bại. Những trường hợp đặc biệt như định dạng ngày tháng, tên khóa không giống nhau hoặc cấu trúc dữ liệu tùy chỉnh có thể cần annotation hoặc bộ chuyển đổi bổ sung.
12. Tùy chỉnh quy tắc chuyển đổi JSON
Trong thực tế, tên trường trên Backend đôi khi khác với tên thuộc tính trong Dart. Ngoài ra, dữ liệu có thể sử dụng định dạng thời gian hoặc quy ước biểu diễn riêng.
Thay vì buộc toàn bộ mã nguồn phải sử dụng đúng tên trường của API, có thể tùy chỉnh cách ánh xạ dữ liệu.
12.1. Ánh xạ tên trường JSON
Giả sử API trả về:
{
"product_id": 101,
"product_name": "Wireless Mouse"
}
Trong Dart, có thể muốn sử dụng tên thuộc tính theo quy ước camelCase.
@JsonSerializable()
class Product {
@JsonKey(name: 'product_id')
final int productId;
@JsonKey(name: 'product_name')
final String productName;
const Product({
required this.productId,
required this.productName,
});
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}
Annotation @JsonKey(name: ...) cho phép ánh xạ tên khóa JSON sang thuộc tính Dart tương ứng.
Nhờ đó, ứng dụng vẫn sử dụng tên thuộc tính dễ đọc, trong khi không cần thay đổi cấu trúc dữ liệu của Backend.
12.2. Chuyển đổi dữ liệu ngày tháng
JSON không hỗ trợ trực tiếp kiểu DateTime. Do đó, ngày tháng thường được biểu diễn dưới dạng chuỗi.
Ví dụ:
{
"id": 1,
"createdAt": "2026-10-15T10:30:00.000Z"
}
Với model viết thủ công, có thể sử dụng DateTime.parse():
class Article {
final int id;
final DateTime createdAt;
const Article({
required this.id,
required this.createdAt,
});
factory Article.fromJson(Map<String, dynamic> json) {
return Article(
id: json['id'] as int,
createdAt: DateTime.parse(
json['createdAt'] as String,
),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'createdAt': createdAt.toUtc().toIso8601String(),
};
}
}
Khi chuyển đối tượng thành JSON, toUtc() đưa thời gian về UTC trước khi tạo chuỗi ISO 8601. Cách này phù hợp khi hệ thống quy ước lưu trữ thời gian theo UTC.
Nếu Backend sử dụng Unix timestamp hoặc một định dạng ngày tháng khác, cần thực hiện chuyển đổi tương ứng. Không nên giả định mọi API đều trả về cùng một định dạng thời gian.
12.3. Bỏ qua trường không cần thiết
Một số thuộc tính chỉ phục vụ nội bộ hoặc giao diện, không cần xuất hiện trong JSON.
Với json_serializable, có thể sử dụng includeToJson: false hoặc includeFromJson: false tùy mục đích.
Ví dụ:
@JsonSerializable()
class Product {
final int id;
final String name;
@JsonKey(includeToJson: false)
final bool isSelected;
const Product({
required this.id,
required this.name,
this.isSelected = false,
});
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
Map<String, dynamic> toJson() => _$ProductToJson(this);
}
Trong ví dụ này, isSelected phục vụ trạng thái lựa chọn sản phẩm trên giao diện và không được xuất ra JSON. Do không đặt includeFromJson: false, cấu hình này không mặc nhiên loại bỏ trường khỏi quá trình đọc JSON; nếu cần bỏ qua cả hai chiều, cần cấu hình rõ ràng cho cả hai.
Đối với những trường không nên xuất hiện trong dữ liệu gửi đi, việc loại bỏ trường ở tầng Serialization giúp tránh vô tình truyền dữ liệu nội bộ không cần thiết.
13. Xử lý lỗi trong quá trình Serialization và Deserialization
Dữ liệu nhận được từ API có thể không đúng định dạng mong đợi. Nguyên nhân có thể xuất phát từ thay đổi API, dữ liệu thiếu trường, giá trị sai kiểu, chuỗi JSON không hợp lệ hoặc phản hồi lỗi từ máy chủ.
Nếu ứng dụng không xử lý những trường hợp này, quá trình chuyển đổi có thể phát sinh exception và làm gián đoạn chức năng.
13.1. Lỗi cú pháp JSON
Ví dụ:
import 'dart:convert';
void main() {
const jsonString = '{"id": 1, "name": "An"';
try {
final data = jsonDecode(jsonString);
print(data);
} on FormatException catch (error) {
print('JSON không hợp lệ: ${error.message}');
}
}
Chuỗi đầu vào thiếu dấu ngoặc đóng nên không phải JSON hợp lệ. Hàm jsonDecode() sẽ phát sinh FormatException.
Việc bắt lỗi giúp ứng dụng có thể ghi nhận nguyên nhân và xử lý phù hợp thay vì để lỗi lan truyền không kiểm soát.
13.2. Lỗi kiểu dữ liệu
Giả sử model yêu cầu trường id là số nguyên, nhưng API trả về:
{
"id": "101",
"name": "Keyboard"
}
Giá trị id là chuỗi thay vì số nguyên. Nếu sử dụng:
final id = json['id'] as int;
chương trình sẽ phát sinh lỗi kiểu dữ liệu.
Nếu hợp đồng API cho phép id được biểu diễn dưới dạng chuỗi số, có thể viết logic chuyển đổi tường minh:
int parseId(dynamic value) {
if (value is int) {
return value;
}
if (value is String) {
final parsed = int.tryParse(value);
if (parsed != null) {
return parsed;
}
}
throw const FormatException('ID không hợp lệ');
}
Cách làm này cho phép kiểm soát những kiểu dữ liệu được chấp nhận.
Tuy nhiên, không nên tự động chuyển đổi mọi kiểu dữ liệu nếu Backend đã quy định rõ ràng. Việc chuyển đổi quá linh hoạt có thể che giấu lỗi từ nguồn dữ liệu và làm phát sinh những kết quả không mong muốn.
13.3. Kết hợp xử lý lỗi với model
Có thể bọc quá trình giải mã và tạo đối tượng trong try-catch.
Product? parseProduct(String jsonString) {
try {
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
throw const FormatException(
'Dữ liệu sản phẩm phải là một JSON object',
);
}
return Product.fromJson(decoded);
} on FormatException catch (error) {
print('Lỗi định dạng JSON: ${error.message}');
return null;
} on TypeError catch (error) {
print('Lỗi kiểu dữ liệu: $error');
return null;
}
}
Hàm trên trả về Product?, nghĩa là kết quả có thể là một đối tượng Product hoặc null nếu chuyển đổi thất bại.
Trong ứng dụng thực tế, không nên chỉ in lỗi rồi bỏ qua mọi trường hợp. Tùy vào chức năng, có thể cần hiển thị thông báo phù hợp, ghi log phục vụ gỡ lỗi, thử tải lại dữ liệu hoặc chuyển sang trạng thái lỗi trên giao diện.
Ngoài ra, cần phân biệt lỗi giải mã JSON với lỗi HTTP. Một yêu cầu có thể thất bại do mất kết nối, mã trạng thái 401, 404 hoặc 500 trước khi ứng dụng nhận được dữ liệu JSON hợp lệ.
14. Kết hợp JSON với HTTP Client và REST API
Serialization và Deserialization thường được sử dụng cùng các thư viện HTTP Client như http hoặc dio.
Trong Flutter, thư viện HTTP chịu trách nhiệm gửi yêu cầu và nhận phản hồi. Model chịu trách nhiệm biểu diễn dữ liệu và chuyển đổi dữ liệu theo cấu trúc của ứng dụng.
Ví dụ, sử dụng package http để gửi yêu cầu lấy danh sách sản phẩm.
Cài đặt:
flutter pub add http
Ví dụ Service:
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProductService {
final String baseUrl;
ProductService({
required this.baseUrl,
});
Future<List<Product>> getProducts() async {
final response = await http.get(
Uri.parse('$baseUrl/products'),
);
if (response.statusCode != 200) {
throw Exception(
'Không thể tải sản phẩm: ${response.statusCode}',
);
}
final decoded = jsonDecode(
utf8.decode(response.bodyBytes),
);
if (decoded is! List) {
throw const FormatException(
'API phải trả về một danh sách sản phẩm',
);
}
return decoded
.map(
(item) => Product.fromJson(
item as Map<String, dynamic>,
),
)
.toList();
}
}
Trong ví dụ này, Service thực hiện các công việc:
- Tạo URL cho API.
- Gửi yêu cầu HTTP
GET. - Kiểm tra mã trạng thái phản hồi.
- Giải mã nội dung JSON.
- Kiểm tra cấu trúc dữ liệu nhận được.
- Chuyển từng phần tử thành đối tượng
Product. - Trả về danh sách sản phẩm có kiểu rõ ràng.
Việc sử dụng response.bodyBytes kết hợp utf8.decode() giúp giải mã nội dung UTF-8 một cách tường minh, hữu ích khi dữ liệu có ký tự tiếng Việt. Trong một số trường hợp, việc đọc trực tiếp response.body cũng phù hợp nếu phản hồi được mã hóa và khai báo charset đúng cách.
Service không nên phụ thuộc trực tiếp vào Widget. Điều này giúp có thể sử dụng lại logic tải sản phẩm ở nhiều màn hình khác nhau và dễ kiểm thử hơn.
14.1. Gửi dữ liệu JSON lên Backend
Đối với yêu cầu tạo sản phẩm, ứng dụng có thể sử dụng Serialization để tạo phần thân của yêu cầu HTTP.
Future<void> createProduct(Product product) async {
final response = await http.post(
Uri.parse('$baseUrl/products'),
headers: {
'Content-Type': 'application/json; charset=UTF-8',
'Accept': 'application/json',
},
body: jsonEncode(product.toJson()),
);
if (response.statusCode != 201 &&
response.statusCode != 200) {
throw Exception(
'Tạo sản phẩm thất bại: ${response.statusCode}',
);
}
}
Trong ví dụ trên, product.toJson() chuyển đối tượng thành Map, còn jsonEncode() tạo chuỗi JSON được gửi trong phần thân của yêu cầu HTTP.
Header Content-Type cho Backend biết nội dung gửi lên là JSON. Header Accept thể hiện loại phản hồi mà ứng dụng mong muốn nhận được.
Mã trạng thái thành công phụ thuộc vào thiết kế API. 201 Created thường được sử dụng khi tạo tài nguyên thành công, trong khi một số API trả về 200 OK.
Trong các hệ thống thực tế, cần kiểm tra tài liệu API để xử lý chính xác mã trạng thái, cấu trúc phản hồi và các trường hợp lỗi.
15. Vị trí của JSON Serialization trong kiến trúc ứng dụng
Trong một dự án Flutter có quy mô lớn, việc đặt toàn bộ logic chuyển đổi JSON trực tiếp trong Widget có thể khiến mã nguồn khó quản lý.
Một kiến trúc có phân chia trách nhiệm rõ ràng thường bao gồm các thành phần sau:
Presentation Layer: Chứa các Widget và màn hình giao diện. Thành phần này chịu trách nhiệm hiển thị dữ liệu và tiếp nhận thao tác của người dùng.
State Management Layer: Quản lý trạng thái giao diện, trạng thái tải dữ liệu, lỗi và kết quả trả về từ các thao tác.
Repository Layer: Cung cấp giao diện truy cập dữ liệu, có thể kết hợp dữ liệu từ API và bộ nhớ cục bộ.
Data Source hoặc Service Layer: Thực hiện các yêu cầu HTTP, truy cập dữ liệu và xử lý phản hồi từ nguồn dữ liệu.
Model Layer: Định nghĩa các class và logic chuyển đổi JSON thành đối tượng Dart hoặc ngược lại.
Luồng dữ liệu có thể được tổ chức như sau:
UI / Widget
|
v
State Management
|
v
Repository
|
v
API Service
|
v
HTTP Client
|
v
Backend API
Ở chiều nhận dữ liệu, phản hồi JSON được xử lý qua các bước giải mã và chuyển đổi thành model trước khi được chuyển lên Repository và lớp quản lý trạng thái.
Không phải dự án nào cũng cần đầy đủ các lớp trên. Với ứng dụng nhỏ, có thể sử dụng một Service và một số model đơn giản. Với ứng dụng lớn, việc phân chia trách nhiệm giúp hạn chế sự phụ thuộc giữa giao diện và nguồn dữ liệu.
Điểm quan trọng là logic chuyển đổi JSON nên được đặt ở vị trí hợp lý, có thể tái sử dụng và kiểm thử độc lập.
16. JSON Serialization với các công cụ quản lý trạng thái
Các thư viện quản lý trạng thái như Provider, Riverpod và BLoC không trực tiếp thay thế cơ chế Serialization hoặc Deserialization. Chúng quản lý cách trạng thái được cập nhật và truyền đến giao diện, còn việc chuyển đổi JSON thường được thực hiện trong model, Service hoặc Repository.
Ví dụ, với Riverpod, một provider có thể gọi Repository để lấy danh sách sản phẩm. Repository sử dụng Service gọi API, sau đó Service chuyển JSON thành List<Product>.
Khi dữ liệu đã được chuyển đổi thành model, Riverpod cung cấp kết quả cho Widget thông qua trạng thái bất đồng bộ.
final productsProvider =
FutureProvider<List<Product>>((ref) async {
final service = ref.watch(productServiceProvider);
return service.getProducts();
});
Đoạn code trên giả định dự án đã định nghĩa productServiceProvider và cấu hình Riverpod phù hợp.
Widget không cần trực tiếp xử lý các khóa JSON. Thay vào đó, giao diện làm việc với đối tượng Product và tập trung vào việc hiển thị thông tin.
Cách tổ chức này mang lại một số lợi ích:
- Giao diện ít phụ thuộc vào cấu trúc phản hồi thô của API.
- Logic chuyển đổi dữ liệu có thể được tái sử dụng.
- Trạng thái tải, thành công và thất bại được quản lý tập trung.
- Việc thay đổi nguồn dữ liệu ít ảnh hưởng đến giao diện.
- Unit test có thể kiểm tra model và Service độc lập với Widget.
Khi Backend thay đổi cấu trúc JSON, ứng dụng thường chỉ cần điều chỉnh các model, bộ chuyển đổi hoặc lớp truy cập dữ liệu liên quan, thay vì sửa trực tiếp nhiều màn hình.
17. Các nguyên tắc khi thiết kế JSON Model
Để hệ thống Serialization và Deserialization hoạt động ổn định, cần tuân thủ một số nguyên tắc thiết kế.
17.1. Sử dụng kiểu dữ liệu rõ ràng
Nên ưu tiên khai báo thuộc tính bằng các kiểu dữ liệu cụ thể như int, double, String, bool, DateTime và các model tự định nghĩa.
Việc sử dụng dynamic cho toàn bộ thuộc tính khiến trình biên dịch không thể kiểm tra đầy đủ kiểu dữ liệu và làm tăng nguy cơ phát sinh lỗi trong quá trình chạy.
dynamic vẫn hữu ích khi tiếp nhận JSON chưa được kiểm tra, nhưng nên chuyển dữ liệu sang kiểu phù hợp càng sớm càng tốt.
17.2. Đồng bộ với hợp đồng API
Tên trường, kiểu dữ liệu, giá trị mặc định và cấu trúc JSON cần phù hợp với tài liệu API.
Nếu Backend thay đổi tên khóa từ productName thành product_name, model cần được điều chỉnh hoặc bổ sung quy tắc ánh xạ tương ứng.
Không nên giả định API luôn trả về dữ liệu hoàn chỉnh hoặc luôn giữ nguyên cấu trúc trong mọi phiên bản.
17.3. Không đặt quá nhiều logic nghiệp vụ trong model chuyển đổi
Model có thể chứa logic chuyển đổi dữ liệu và một số quy tắc liên quan trực tiếp đến biểu diễn dữ liệu. Tuy nhiên, những thao tác như gọi API, thanh toán, gửi email hoặc xử lý quyền truy cập nên thuộc các lớp có trách nhiệm phù hợp.
Việc tách biệt logic giúp model dễ kiểm thử và tránh tạo ra sự phụ thuộc không cần thiết.
17.4. Kiểm tra dữ liệu đầu vào
Dữ liệu JSON nhận được từ bên ngoài cần được kiểm tra trước khi sử dụng trong các thao tác quan trọng.
Ví dụ, giá sản phẩm cần có kiểu dữ liệu phù hợp, số lượng không được âm nếu nghiệp vụ không cho phép, và những trường bắt buộc không nên bị thiếu.
Cần phân biệt giữa kiểm tra kiểu dữ liệu và kiểm tra nghiệp vụ. Một giá trị có thể đúng kiểu double nhưng vẫn không hợp lệ nếu giá tiền âm.
17.5. Tránh lặp lại mã chuyển đổi
Nếu dự án có nhiều model, nên cân nhắc sử dụng json_serializable hoặc những công cụ sinh mã phù hợp.
Cách tiếp cận này giúp giảm lỗi đánh máy, duy trì tính nhất quán và đơn giản hóa việc cập nhật model.
17.6. Bảo vệ dữ liệu nhạy cảm
Không nên đưa token, mật khẩu hoặc thông tin nhạy cảm vào JSON chỉ vì những dữ liệu đó đang tồn tại trong đối tượng Dart.
Cần kiểm soát trường được gửi lên Backend và tránh ghi dữ liệu nhạy cảm vào log. Serialization chỉ chuyển đổi dữ liệu; bản thân nó không phải cơ chế mã hóa hay bảo vệ thông tin.
17.7. Viết kiểm thử cho quá trình chuyển đổi
Model nên được kiểm thử với dữ liệu hợp lệ, dữ liệu thiếu trường, giá trị null, cấu trúc lồng nhau và các kiểu dữ liệu không phù hợp.
Việc kiểm thử giúp phát hiện sớm sự không tương thích giữa ứng dụng và API, đặc biệt khi Backend thay đổi cấu trúc phản hồi.
18. Kiểm thử JSON Serialization và Deserialization
Unit test giúp xác minh rằng quá trình chuyển đổi dữ liệu hoạt động đúng như mong đợi mà không cần khởi chạy toàn bộ ứng dụng Flutter.
Một bài kiểm thử cơ bản có thể xác nhận rằng dữ liệu JSON được chuyển thành model chính xác.
Ví dụ:
import 'package:flutter_test/flutter_test.dart';
void main() {
group('Product JSON', () {
test('fromJson chuyển đổi dữ liệu chính xác', () {
final json = {
'id': 1,
'name': 'Keyboard',
'price': 1290000.0,
};
final product = Product.fromJson(json);
expect(product.id, 1);
expect(product.name, 'Keyboard');
expect(product.price, 1290000.0);
});
test('toJson tạo Map chính xác', () {
const product = Product(
id: 1,
name: 'Keyboard',
price: 1290000.0,
);
final json = product.toJson();
expect(json['id'], 1);
expect(json['name'], 'Keyboard');
expect(json['price'], 1290000.0);
});
});
}
Ví dụ này giả định class Product có các thuộc tính và phương thức chuyển đổi tương ứng.
Ngoài những trường hợp cơ bản, nên kiểm thử thêm:
- JSON thiếu trường bắt buộc.
- Giá trị
nullở những trường nullable. - Trường dữ liệu có kiểu không đúng.
- Danh sách JSON rỗng.
- JSON có cấu trúc lồng nhau.
- Chuỗi ngày tháng không hợp lệ.
- Tên trường khác với tên thuộc tính Dart.
- Các giá trị mặc định và quy tắc ánh xạ tùy chỉnh.
Có thể sử dụng kiểm thử vòng lặp chuyển đổi để xác nhận dữ liệu không bị thay đổi ngoài ý muốn khi chuyển từ đối tượng sang JSON rồi trở lại đối tượng.
Tuy nhiên, không phải mọi đối tượng đều có thể khôi phục hoàn toàn chỉ từ JSON. Những thuộc tính không được xuất ra JSON, các trường chỉ phục vụ giao diện hoặc những giá trị được tính toán từ dữ liệu khác có thể không được giữ nguyên sau quá trình chuyển đổi.
19. Các lỗi thường gặp và cách khắc phục
| Lỗi | Nguyên nhân phổ biến | Cách xử lý |
|---|---|---|
FormatException |
Chuỗi JSON sai cú pháp | Kiểm tra nội dung phản hồi |
TypeError |
Giá trị không đúng kiểu khai báo | Kiểm tra kiểu dữ liệu trước khi ép kiểu |
Null không thể gán cho kiểu không nullable |
Trường JSON bị thiếu hoặc có giá trị null |
Sử dụng kiểu nullable hoặc giá trị mặc định phù hợp |
| Không tìm thấy trường dữ liệu | Tên khóa JSON khác với model | Kiểm tra API hoặc dùng @JsonKey |
| Lỗi khi xử lý danh sách | Phản hồi không phải JSON array như mong đợi | Kiểm tra cấu trúc trước khi chuyển đổi |
Không tìm thấy file .g.dart |
Chưa chạy sinh mã hoặc file chưa được cập nhật | Chạy lại build_runner |
| Dữ liệu ngày tháng sai | Định dạng thời gian không tương thích | Sử dụng bộ chuyển đổi thời gian phù hợp |
| JSON gửi lên thiếu trường | Phương thức toJson() chưa bao gồm trường cần thiết |
Kiểm tra model và hợp đồng API |
Khi gặp lỗi, nên xác định rõ lỗi phát sinh ở giai đoạn nào: giải mã chuỗi JSON, tạo đối tượng, gửi yêu cầu HTTP hay xử lý phản hồi. Việc phân biệt các giai đoạn giúp quá trình gỡ lỗi nhanh và chính xác hơn.
Không nên khắc phục mọi lỗi bằng cách ép kiểu tùy tiện hoặc thay toàn bộ thuộc tính thành dynamic. Cách làm này có thể khiến chương trình tiếp tục chạy trong một số trường hợp nhưng làm giảm khả năng phát hiện dữ liệu không hợp lệ.
20. Kết luận
JSON Serialization và Deserialization là kiến thức nền tảng khi phát triển ứng dụng Flutter có trao đổi dữ liệu với Backend hoặc các hệ thống bên ngoài.
Serialization chuyển đổi dữ liệu từ đối tượng Dart thành cấu trúc JSON, phục vụ việc gửi yêu cầu, lưu trữ hoặc truyền dữ liệu. Deserialization thực hiện chiều ngược lại, chuyển dữ liệu JSON thành các cấu trúc Dart có kiểu rõ ràng để ứng dụng xử lý.
Trong Dart, jsonEncode() và jsonDecode() cung cấp các thao tác cơ bản để mã hóa và giải mã JSON. Các phương thức toJson() và fromJson() giúp kết nối dữ liệu JSON với model. Khi dự án phát triển lớn hơn, json_serializable, json_annotation và build_runner có thể được sử dụng để tự động sinh mã chuyển đổi.
Việc thiết kế model rõ ràng, xử lý trường nullable, kiểm tra dữ liệu đầu vào, kiểm thử quá trình chuyển đổi và phân chia trách nhiệm giữa các lớp sẽ giúp ứng dụng dễ bảo trì và đáng tin cậy hơn.
Nắm vững cơ chế này cũng tạo nền tảng để tiếp tục học các nội dung liên quan như REST API, HTTP Client, Repository Pattern, quản lý trạng thái với Riverpod hoặc BLoC, lưu trữ dữ liệu cục bộ và xây dựng kiến trúc Flutter có khả năng mở rộng.








Khoá học lập trình game con rắn cho trẻ em




