
数据模型是整个应用的骨架。UI再漂亮,交互再流畅,如果数据模型设计得不好,后面的开发会处处受限。今天这篇文章,咱们就来聊聊怎么设计一个好的数据模型,以及在Flutter里怎么优雅地实现它。
为什么数据模型这么重要
很多新手开发者不重视数据模型,觉得不就是定义几个字段嘛,有什么难的?等到项目做大了,才发现问题一堆:
字段命名混乱 :有的地方叫imageUrl,有的地方叫image_url,有的地方叫img,改起来要命。
类型不统一:有的地方id是String,有的地方是int,到处都要做类型转换。
缺少必要字段:后来发现需要某个字段,但之前没设计,要改的地方太多。
序列化麻烦:从API拿到的JSON和本地存储的格式不一样,到处写转换代码。
好的数据模型设计,能让这些问题在一开始就避免掉。
从需求出发设计模型
设计数据模型之前,先想清楚这个模型要表示什么,会在哪些场景下使用。
对于新闻文章来说,需要哪些信息?
唯一标识:每篇文章要有个唯一的id,用于去重、收藏、浏览历史等功能。
内容信息:标题、摘要、正文链接。标题和摘要在列表页展示,正文链接用于跳转详情。
媒体信息:封面图片。有些文章有图,有些没有,所以这个字段要可空。
元数据:发布时间、来源、分类、标签。这些信息用于展示和筛选。
想清楚这些,模型的字段就确定了。
NewsArticle模型的实现
来看看项目里的新闻文章模型:
dart
class NewsArticle {
final String id;
final String title;
final String summary;
final String? imageUrl;
final String url;
final String publishedAt;
final String source;
final String category;
final List<String> tags;
NewsArticle({
required this.id,
required this.title,
required this.summary,
this.imageUrl,
required this.url,
required this.publishedAt,
required this.source,
required this.category,
this.tags = const [],
});
来拆解一下这段代码的设计思路:
字段类型的选择
id用String而不是int:虽然很多API返回的id是数字,但用String更通用。有些API的id是UUID格式,有些是数字,统一用String就不用担心类型问题。
imageUrl用String?:问号表示可空。不是所有文章都有封面图,强制要求有图会导致很多文章无法展示。
publishedAt用String而不是DateTime:API返回的时间格式各不相同,有的是ISO 8601,有的是时间戳,有的是自定义格式。用String存储原始值,需要展示时再解析,更灵活。
tags用List:标签是个列表,一篇文章可能有多个标签。默认值是空列表,避免null检查。
required和可选参数
dart
NewsArticle({
required this.id, // 必须
required this.title, // 必须
required this.summary, // 必须
this.imageUrl, // 可选,默认null
required this.url, // 必须
required this.publishedAt, // 必须
required this.source, // 必须
required this.category, // 必须
this.tags = const [], // 可选,默认空列表
});
required关键字表示这个参数必须传,不传会编译报错。这比运行时报错好多了,能在开发阶段就发现问题。
imageUrl没有required,也没有默认值,所以它的类型必须是可空的String?。
tags有默认值const [],所以即使不传也不会是null。
为什么用final
所有字段都用final修饰,表示一旦创建就不能修改。这是不可变对象的设计模式。
不可变对象有几个好处:
线程安全:多个地方同时访问同一个对象,不用担心数据被意外修改。
易于调试:对象创建后状态不变,出问题时更容易定位。
适合状态管理:Provider、Riverpod这些状态管理方案都推荐使用不可变对象。
如果需要修改某个字段,创建一个新对象:
dart
final updatedArticle = NewsArticle(
id: article.id,
title: '新标题', // 修改这个字段
summary: article.summary,
// ... 其他字段
);
后面会讲怎么用copyWith方法简化这个操作。
JSON序列化:fromJson和toJson
数据模型最重要的功能之一是和JSON互转。从API获取数据要把JSON转成对象,存储到本地要把对象转成JSON。
toJson方法
dart
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'summary': summary,
'imageUrl': imageUrl,
'url': url,
'publishedAt': publishedAt,
'source': source,
'category': category,
'tags': tags,
};
}
toJson把对象转成Map<String, dynamic>,这个Map可以直接用jsonEncode转成JSON字符串。
注意key的命名用的是驼峰式(camelCase),和Dart的命名规范一致。如果后端API用的是下划线式(snake_case),需要在这里做转换。
fromJson工厂构造函数
dart
factory NewsArticle.fromJson(Map<String, dynamic> json) {
return NewsArticle(
id: json['id'],
title: json['title'],
summary: json['summary'],
imageUrl: json['imageUrl'],
url: json['url'],
publishedAt: json['publishedAt'],
source: json['source'],
category: json['category'],
tags: List<String>.from(json['tags'] ?? []),
);
}
factory关键字表示这是一个工厂构造函数。工厂构造函数和普通构造函数的区别是:普通构造函数必须返回当前类的新实例,工厂构造函数可以返回任意实例(包括缓存的实例、子类实例等)。
这里用工厂构造函数主要是语义上的考虑:fromJson是"从JSON创建对象",用factory更符合这个语义。
tags的处理 :List<String>.from(json['tags'] ?? [])做了两件事。?? []处理tags为null的情况,List<String>.from确保列表元素都是String类型。
处理不同API的数据格式
实际项目中,可能要对接多个API,每个API返回的数据格式都不一样。怎么办?
为每个API写一个专门的工厂构造函数:
dart
factory NewsArticle.fromSpaceflightJson(Map<String, dynamic> json) {
return NewsArticle(
id: json['id'].toString(),
title: json['title'] ?? '',
summary: json['summary'] ?? '',
imageUrl: json['image_url'],
url: json['url'] ?? '',
publishedAt: json['published_at'] ?? '',
source: json['news_site'] ?? '航天新闻',
category: 'space',
tags: [],
);
}
这是对接Spaceflight News API的工厂方法。来看看它处理了哪些差异:
字段名不同 :API返回的是image_url和published_at(下划线式),我们的模型用的是imageUrl和publishedAt(驼峰式)。在这里做转换。
id类型不同 :API返回的id是数字,用toString()转成字符串。
缺少某些字段:API没有返回category和tags,在这里填上默认值。
来源字段名不同 :API用的是news_site,我们用的是source。
再来看另一个API的工厂方法:
dart
factory NewsArticle.fromAnimeJson(Map<String, dynamic> json) {
return NewsArticle(
id: json['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(),
title: json['title'] ?? '',
summary: json['synopsis'] ?? json['description'] ?? '',
imageUrl: json['thumbnail'] ?? json['image'],
url: json['url'] ?? '',
publishedAt: json['datetime'] ?? json['date'] ?? DateTime.now().toIso8601String(),
source: '动漫新闻网',
category: 'anime',
tags: [],
);
}
这个API的数据格式更乱:
id可能为null :用?.toString()安全调用,如果为null就用当前时间戳作为id。
摘要字段名不确定 :可能是synopsis,也可能是description,用??链式处理。
图片字段名不确定 :可能是thumbnail,也可能是image。
时间字段名不确定 :可能是datetime,也可能是date,都没有就用当前时间。
这种设计的好处是:所有的格式转换逻辑都集中在模型类里 ,使用的地方不用关心数据是从哪个API来的,拿到的都是统一格式的NewsArticle对象。
在API服务中使用模型
看看API服务是怎么使用这些工厂方法的:
dart
Future<List<NewsArticle>> fetchSpaceNews({int limit = 20, int offset = 0}) async {
try {
final response = await http.get(
Uri.parse('$spaceflightNewsUrl?limit=$limit&offset=$offset'),
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final results = data['results'] as List;
return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
}
return [];
} catch (e) {
return [];
}
}
关键的一行是:
dart
return results.map((json) => NewsArticle.fromSpaceflightJson(json)).toList();
map遍历JSON数组,对每个元素调用fromSpaceflightJson转成NewsArticle对象,最后toList()转成列表。
这样,API服务返回的就是List<NewsArticle>,调用方不用关心JSON长什么样。
添加copyWith方法
前面说过,不可变对象要修改字段需要创建新对象。如果每次都手动写所有字段,太麻烦了。copyWith方法可以简化这个操作:
dart
NewsArticle copyWith({
String? id,
String? title,
String? summary,
String? imageUrl,
String? url,
String? publishedAt,
String? source,
String? category,
List<String>? tags,
}) {
return NewsArticle(
id: id ?? this.id,
title: title ?? this.title,
summary: summary ?? this.summary,
imageUrl: imageUrl ?? this.imageUrl,
url: url ?? this.url,
publishedAt: publishedAt ?? this.publishedAt,
source: source ?? this.source,
category: category ?? this.category,
tags: tags ?? this.tags,
);
}
所有参数都是可选的。传了的参数用新值,没传的用原来的值。
使用起来很方便:
dart
// 只修改标题
final updated = article.copyWith(title: '新标题');
// 修改多个字段
final updated = article.copyWith(
title: '新标题',
summary: '新摘要',
);
imageUrl的特殊处理
注意imageUrl本身就是可空的,copyWith里也是可空的。这会导致一个问题:怎么把imageUrl从有值改成null?
dart
// 这样写不行,imageUrl还是原来的值
final updated = article.copyWith(imageUrl: null);
因为imageUrl ?? this.imageUrl,传null会用原来的值。
解决方法是用一个特殊的标记:
dart
NewsArticle copyWith({
String? id,
String? title,
String? summary,
Object? imageUrl = _sentinel, // 用Object?类型
// ...
}) {
return NewsArticle(
// ...
imageUrl: imageUrl == _sentinel
? this.imageUrl
: imageUrl as String?,
// ...
);
}
const _sentinel = Object();
这样,不传imageUrl时用原值,传null时设为null,传字符串时用新值。
不过对于大部分场景,简单的??处理就够了。
添加相等性判断
默认情况下,两个对象即使所有字段都相同,==比较也是false(因为比较的是内存地址)。
dart
final a = NewsArticle(id: '1', title: 'Hello', ...);
final b = NewsArticle(id: '1', title: 'Hello', ...);
print(a == b); // false
如果需要按内容比较,要重写==和hashCode:
dart
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is NewsArticle &&
other.id == id &&
other.title == title &&
other.summary == summary &&
other.imageUrl == imageUrl &&
other.url == url &&
other.publishedAt == publishedAt &&
other.source == source &&
other.category == category &&
_listEquals(other.tags, tags);
}
@override
int get hashCode {
return Object.hash(
id,
title,
summary,
imageUrl,
url,
publishedAt,
source,
category,
Object.hashAll(tags),
);
}
bool _listEquals(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
identical检查是否是同一个对象(内存地址相同),如果是就直接返回true,避免后续比较。
hashCode要和==保持一致:如果两个对象相等,它们的hashCode必须相同。
简化方案:只比较id
对于新闻文章来说,id相同就认为是同一篇文章,不用比较所有字段:
dart
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is NewsArticle && other.id == id;
}
@override
int get hashCode => id.hashCode;
这样简单多了,而且更符合业务逻辑。
添加toString方法
调试时经常需要打印对象,默认的toString输出是Instance of 'NewsArticle',没什么用。重写一下:
dart
@override
String toString() {
return 'NewsArticle(id: $id, title: $title, source: $source)';
}
只输出关键字段,不用全部输出,不然太长了。
数据验证
有时候需要验证数据是否合法,可以加个验证方法:
dart
bool get isValid {
return id.isNotEmpty &&
title.isNotEmpty &&
url.isNotEmpty;
}
String? validate() {
if (id.isEmpty) return 'id不能为空';
if (title.isEmpty) return '标题不能为空';
if (url.isEmpty) return '链接不能为空';
return null; // null表示验证通过
}
isValid返回布尔值,快速判断是否合法。
validate返回错误信息,方便知道具体哪里不合法。
在创建对象时可以加验证:
dart
factory NewsArticle.fromJson(Map<String, dynamic> json) {
final article = NewsArticle(
id: json['id'] ?? '',
title: json['title'] ?? '',
// ...
);
if (!article.isValid) {
throw FormatException('Invalid article data: ${article.validate()}');
}
return article;
}
扩展模型:添加计算属性
有些信息可以从现有字段计算出来,不用单独存储:
dart
// 是否有封面图
bool get hasImage => imageUrl != null && imageUrl!.isNotEmpty;
// 发布时间的DateTime对象
DateTime? get publishedDateTime {
try {
return DateTime.parse(publishedAt);
} catch (e) {
return null;
}
}
// 格式化的发布时间
String get formattedPublishTime {
final dateTime = publishedDateTime;
if (dateTime == null) return '未知时间';
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 60) {
return '${difference.inMinutes}分钟前';
} else if (difference.inHours < 24) {
return '${difference.inHours}小时前';
} else if (difference.inDays < 7) {
return '${difference.inDays}天前';
} else {
return '${dateTime.month}月${dateTime.day}日';
}
}
// 摘要的截断版本
String get shortSummary {
if (summary.length <= 100) return summary;
return '${summary.substring(0, 100)}...';
}
这些计算属性用get定义,每次访问时计算。如果计算开销大,可以考虑缓存结果。
使用代码生成简化工作
手写fromJson、toJson、copyWith、==、hashCode很繁琐,而且容易出错。可以用代码生成工具自动生成。
json_serializable
json_serializable可以自动生成JSON序列化代码:
yaml
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.7.0
dart
import 'package:json_annotation/json_annotation.dart';
part 'news_article.g.dart';
@JsonSerializable()
class NewsArticle {
final String id;
final String title;
final String summary;
@JsonKey(name: 'image_url') // 指定JSON字段名
final String? imageUrl;
final String url;
@JsonKey(name: 'published_at')
final String publishedAt;
final String source;
final String category;
final List<String> tags;
NewsArticle({
required this.id,
required this.title,
required this.summary,
this.imageUrl,
required this.url,
required this.publishedAt,
required this.source,
required this.category,
this.tags = const [],
});
factory NewsArticle.fromJson(Map<String, dynamic> json) =>
_$NewsArticleFromJson(json);
Map<String, dynamic> toJson() => _$NewsArticleToJson(this);
}
运行flutter pub run build_runner build,会自动生成news_article.g.dart文件,包含_$NewsArticleFromJson和_$NewsArticleToJson函数。
@JsonKey(name: 'image_url')指定JSON里的字段名和Dart里的字段名不同。
freezed
freezed更强大,可以生成copyWith、==、hashCode、toString等:
yaml
dependencies:
freezed_annotation: ^2.4.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^2.4.0
json_serializable: ^6.7.0
dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'news_article.freezed.dart';
part 'news_article.g.dart';
@freezed
class NewsArticle with _$NewsArticle {
const factory NewsArticle({
required String id,
required String title,
required String summary,
String? imageUrl,
required String url,
required String publishedAt,
required String source,
required String category,
@Default([]) List<String> tags,
}) = _NewsArticle;
factory NewsArticle.fromJson(Map<String, dynamic> json) =>
_$NewsArticleFromJson(json);
}
运行build_runner后,会自动生成:
fromJson和toJsoncopyWith方法==和hashCodetoString
代码量大大减少,而且不容易出错。
什么时候用代码生成
项目初期:手写更灵活,方便调整。
项目稳定后:用代码生成更省事,减少重复劳动。
模型很多时:强烈建议用代码生成,手写太累了。
需要处理多种API格式时:手写更灵活,可以为每个API写专门的工厂方法。
设计其他模型
除了新闻文章,项目里可能还需要其他模型。
分类模型
dart
class Category {
final String id;
final String name;
final String icon;
final int articleCount;
const Category({
required this.id,
required this.name,
required this.icon,
this.articleCount = 0,
});
}
用户模型
dart
class User {
final String id;
final String nickname;
final String? avatar;
final String? email;
final DateTime createdAt;
const User({
required this.id,
required this.nickname,
this.avatar,
this.email,
required this.createdAt,
});
}
评论模型
dart
class Comment {
final String id;
final String articleId;
final String userId;
final String content;
final DateTime createdAt;
final int likeCount;
const Comment({
required this.id,
required this.articleId,
required this.userId,
required this.content,
required this.createdAt,
this.likeCount = 0,
});
}
模型之间的关系
有时候模型之间有关联关系:
dart
class ArticleDetail {
final NewsArticle article;
final List<Comment> comments;
final User? author;
final bool isFavorited;
const ArticleDetail({
required this.article,
required this.comments,
this.author,
this.isFavorited = false,
});
}
ArticleDetail包含了文章、评论列表、作者信息,是一个聚合模型,用于详情页展示。
常见问题和最佳实践
问题一:字段太多怎么办
如果一个模型有几十个字段,考虑拆分成多个模型:
dart
// 不好的设计
class Article {
final String id;
final String title;
// ... 30个字段
}
// 好的设计
class Article {
final String id;
final String title;
final ArticleContent content;
final ArticleMetadata metadata;
final ArticleStats stats;
}
class ArticleContent {
final String summary;
final String body;
final List<String> images;
}
class ArticleMetadata {
final String source;
final String category;
final List<String> tags;
final DateTime publishedAt;
}
class ArticleStats {
final int viewCount;
final int likeCount;
final int commentCount;
}
问题二:API返回的字段可能缺失
用??提供默认值,或者在工厂方法里做判断:
dart
factory NewsArticle.fromJson(Map<String, dynamic> json) {
return NewsArticle(
id: json['id']?.toString() ?? '',
title: json['title'] ?? '无标题',
summary: json['summary'] ?? json['description'] ?? '',
// ...
);
}
问题三:需要支持多种序列化格式
除了JSON,可能还需要支持其他格式。可以用扩展方法:
dart
extension NewsArticleXml on NewsArticle {
String toXml() {
return '''
<article>
<id>$id</id>
<title>$title</title>
<summary>$summary</summary>
</article>
''';
}
}
写在最后
数据模型设计看起来简单,但要做好需要考虑很多细节。
从设计角度,要想清楚模型表示什么、有哪些字段、字段类型是什么、哪些可空哪些必填。
从实现角度,要处理好JSON序列化、不同API格式的适配、相等性判断、复制修改等功能。
从维护角度,要保持命名一致、类型统一、代码整洁。模型多了可以考虑用代码生成工具。
好的数据模型是应用的基础。花时间把模型设计好,后面的开发会顺畅很多。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。