Flutter for OpenHarmony:从零搭建今日资讯App(二十三)数据模型设计的艺术

数据模型是整个应用的骨架。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_urlpublished_at(下划线式),我们的模型用的是imageUrlpublishedAt(驼峰式)。在这里做转换。

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定义,每次访问时计算。如果计算开销大,可以考虑缓存结果。

使用代码生成简化工作

手写fromJsontoJsoncopyWith==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==hashCodetoString等:

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后,会自动生成:

  • fromJsontoJson
  • copyWith方法
  • ==hashCode
  • toString

代码量大大减少,而且不容易出错。

什么时候用代码生成

项目初期:手写更灵活,方便调整。

项目稳定后:用代码生成更省事,减少重复劳动。

模型很多时:强烈建议用代码生成,手写太累了。

需要处理多种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开发资源,与其他开发者交流经验,共同进步。

相关推荐
前端不太难2 小时前
Flutter / RN / iOS,在状态重构容忍度上的本质差异
flutter·ios·重构
kirk_wang2 小时前
Flutter艺术探索-Flutter错误处理:try-catch与异常捕获
flutter·移动开发·flutter教程·移动开发教程
[H*]2 小时前
Flutter框架跨平台鸿蒙开发——Text组件基础使用
flutter
时光慢煮2 小时前
基于 Flutter × OpenHarmony 开发的 JSON 解析工具实践
flutter·json
[H*]2 小时前
Flutter框架跨平台鸿蒙开发——Pattern Matching模式匹配
android·javascript·flutter
世人万千丶2 小时前
鸿蒙跨端框架 Flutter 学习:GetX 全家桶:从状态管理到路由导航的极简艺术
学习·flutter·ui·华为·harmonyos·鸿蒙
夜雨声烦丿2 小时前
Flutter 框架跨平台鸿蒙开发 - 电影票房查询 - 完整开发教程
flutter·华为·harmonyos
小白阿龙3 小时前
鸿蒙+flutter 跨平台开发——从零打造手持弹幕App实战
flutter·华为·harmonyos·鸿蒙
[H*]3 小时前
Flutter框架跨平台鸿蒙开发——文件下载器综合应用
flutter