AtomGit Flutter鸿蒙客户端:数据模型

模型设计原则
本项目中的所有数据模型都遵循不可变性(Immutability)原则——所有字段声明为 final,对象创建后无法修改。这不是 Flutter 或 Dart 的强制要求,而是从工程实践出发的设计选择。
不可变模型带来的好处:
- 安全的 Widget 树。Flutter 通过
identical比较判断 Widget 是否需要重建。同一个不可变对象可以被多个 Widget 安全共享,因为没有人能修改它。 - 可预测性。从 Provider 获取的数据在任何地方被读取都是一致的,不会出现"读到一半被另一个线程改了"的情况。
- 调试友好。可以直接比较两个 Repository 对象来判断数据是否变化(引用比较等价于内容比较)。
模型总览
| 模型 | 文件位置 | 字段数 | 用途 |
|---|---|---|---|
Repository |
repo/models/repository.dart |
18 | 仓库信息 |
UserProfile |
user/models/user_profile.dart |
16 | 用户信息 |
Issue |
issue/models/issue.dart |
12 | Issue/PR 主体 |
Comment |
issue/models/issue.dart |
5 | Issue 评论 |
FileNode |
code/models/file_node.dart |
6 | 文件树节点 |
Repository:最核心的模型
Repository 是数据量最丰富的模型,承载了仓库的所有元信息。
class Repository {
final int id; // AtomGit 内部 ID
final String name; // 仓库名(不含 owner)
final String fullName; // 完整名称 "owner/repo"
final String? path; // GitLab 风格的 URL 安全路径
final String? description; // 描述(Markdown)
final bool isPrivate; // 是否私有
final bool isFork; // 是否是从其他仓库 Fork 的
final String? language; // 主要编程语言
final int stargazersCount; // Star 数量
final int forksCount; // Fork 数量
final int watchersCount; // Watcher 数量
final int openIssuesCount; // 开放的 Issue 数量
final String? defaultBranch; // 默认分支名
final DateTime createdAt; // 创建时间
final DateTime updatedAt; // 最后更新时间
final DateTime? pushedAt; // 最后推送时间
final String? homepage; // 项目主页 URL
final String? license; // 许可证(如 "MIT", "Apache-2.0")
final UserProfile? owner; // 仓库所有者信息(嵌套对象)
const Repository({
required this.id,
required this.name,
// ... 所有字段
});
}
fromJson 工厂

factory Repository.fromJson(Map<String, dynamic> json) {
return Repository(
id: parseInt(json['id']),
name: parseString(json['name']),
fullName: parseString(json['full_name']),
path: json['path'] as String?,
description: json['description'] as String?,
isPrivate: json['private'] == true,
isFork: json['fork'] == true,
language: json['language'] as String?,
stargazersCount: parseInt(json['stargazers_count']),
forksCount: parseInt(json['forks_count']),
watchersCount: parseInt(json['watchers_count']),
openIssuesCount: parseInt(json['open_issues_count']),
defaultBranch: json['default_branch'] as String?,
createdAt: parseDateTime(json['created_at']) ?? DateTime.now(),
updatedAt: parseDateTime(json['updated_at']) ?? DateTime.now(),
pushedAt: parseDateTime(json['pushed_at']),
homepage: json['homepage'] as String?,
license: json['license']?['spdx_id'] as String?,
owner: json['owner'] != null
? UserProfile.fromJson(
json['owner'] as Map<String, dynamic>)
: null,
);
}
JSON 键名映射
API 返回的字段使用 snake_case,Dart 模型使用 camelCase。映射关系:
| API 字段 (JSON) | Dart 字段 | 解析函数 |
|---|---|---|
id |
id |
parseInt |
name |
name |
parseString |
full_name |
fullName |
parseString |
path |
path |
as String? |
private |
isPrivate |
== true |
fork |
isFork |
== true |
stargazers_count |
stargazersCount |
parseInt |
forks_count |
forksCount |
parseInt |
watchers_count |
watchersCount |
parseInt |
open_issues_count |
openIssuesCount |
parseInt |
default_branch |
defaultBranch |
as String? |
created_at |
createdAt |
parseDateTime |
updated_at |
updatedAt |
parseDateTime |
pushed_at |
pushedAt |
parseDateTime |
license |
license |
嵌套访问 .spdx_id |
布尔字段的安全处理
isPrivate: json['private'] == true,
isFork: json['fork'] == true,
使用 == true 而非 as bool。API 可能返回 true(bool)、"true"(String)、或根本不存在该字段(null)。== true 只在值严格等于 true 时返回 true,其他情况(null、false、“true”)都返回 false。
嵌套对象的访问
owner 字段是 UserProfile? 类型,从 JSON 中的 owner 嵌套对象反序列化。这体现了 API 设计的常见模式——在列表端点中将关联对象的部分信息内联返回,减少 API 请求次数。
license 字段的提取:
license: json['license']?['spdx_id'] as String?,
json['license'] 返回 {"key": "mit", "name": "MIT License", "spdx_id": "MIT"}。使用 ?. 安全链式访问,如果 license 为 null,整个表达式返回 null 而非抛出 NoSuchMethodError。
ownerAndName 计算属性
({String owner, String name})? get ownerAndName {
// 策略 1:从 fullName 拆分(格式 "owner/name")
final parts = fullName.split('/');
if (parts.length == 2 &&
parts[0].isNotEmpty &&
parts[1].isNotEmpty) {
return (owner: parts[0], name: parts[1]);
}
// 策略 2:从 owner.login + path/name 组合
final ownerLogin = owner?.login;
final repoPath = path ?? name;
if (ownerLogin != null &&
ownerLogin.isNotEmpty &&
repoPath.isNotEmpty) {
return (owner: ownerLogin, name: repoPath);
}
// 无法解析
return null;
}
返回类型 ({String owner, String name})? 是 Dart 3 Record 特性的优雅应用。Record 是匿名的、不可变的结构体,不需要单独声明一个类。
使用方式:
final info = repo.ownerAndName;
if (info != null) {
Navigator.pushNamed(context, '/repo', arguments: {
'owner': info.owner,
'name': info.name,
});
}
UserProfile
class UserProfile {
final int id;
final String login; // 用户名(唯一标识)
final String? name; // 显示名(可空)
final String? avatarUrl; // 头像 URL
final String? htmlUrl; // AtomGit 个人页 URL
final String? bio; // 个人简介
final String? company; // 公司
final String? location; // 位置
final String? email; // 邮箱
final String? blog; // 博客 URL
final int followers; // 关注者数量
final int following; // 正在关注的数量
final int publicRepos; // 公开仓库数量
final int publicGists; // 公开 Gist 数量
final DateTime createdAt; // 注册时间
final DateTime updatedAt; // 最后更新时间
}
字符串字段的两种处理方式:
// 强制非空(有默认值兜底)
login: parseString(json['login']), // 用户名必须有
// 可空(允许 null)
name: json['name'] as String?, // 显示名未填写时为 null
bio: json['bio'] as String?, // 个人简介未填写时为 null
强制非空的字段使用 parseString(有空字符串兜底),可空字段使用 as String?(保留 null 语义)。这反映了业务语义的差异——login 是强制存在的标识符,bio 是可选的自述。
UserProfile 的双重角色
UserProfile 在应用中有两种使用场景:
- 独立使用:ProfileScreen 中展示用户完整信息时,UserProfile 是页面的核心数据
- 嵌套使用:作为 Repository.owner 或 Issue.user 时,UserProfile 只携带部分字段(API 可能不返回 email、blog 等只在独立查询时才返回的字段)
两种场景使用同一个模型,因为字段的语义是一致的。API 未返回的字段自然为 null,UI 根据是否为 null 决定是否展示。
Issue 与 Comment
Issue/PR 合一模型
class Issue {
final int id;
final int number; // 仓库内唯一编号(#1, #2, ...)
final String title; // 标题
final String? body; // 正文(Markdown)
final String state; // 'open' | 'closed'
final UserProfile? user; // 作者
final List<String> labels; // 标签名列表
final int commentsCount; // 评论数
final bool isPullRequest; // true = PR, false = Issue
final DateTime createdAt; // 创建时间
final DateTime updatedAt; // 最后更新时间
}
区分 Issue 和 PR 的关键:
isPullRequest: json['pull_request'] != null,
在 GitHub/AtomGit 的 API 中,PR 在底层就是一个特殊的 Issue。API 在返回列表时会同时包含 Issue 和 PR,但 PR 额外携带一个 pull_request 字段(包含 PR 特有的信息,如合并状态、源分支等)。通过检测该字段是否存在来区分类型。
这使得同一个 Issue 模型可以服务于两个不同的 API 端点:
/repos/{o}/{r}/issues→type='issue'→ 过滤掉isPullRequest=true的结果/repos/{o}/{r}/pulls→type='pr'→ 过滤掉isPullRequest=false的结果
// IssueProvider 中的类型过滤
_issues = items
.whereType<Map<String, dynamic>>()
.map(Issue.fromJson)
.where((issue) => type == 'pr'
? issue.isPullRequest
: !issue.isPullRequest)
.toList();
Labels 解析
labels: (parseList<dynamic>(json, 'labels') ?? [])
.whereType<Map<String, dynamic>>()
.map((l) => parseString(l['name']))
.toList(),
Labels 是标签对象列表到字符串列表的转换。API 返回格式为:
"labels": [
{"name": "bug", "color": "d73a4a", "description": "Something isn't working"},
{"name": "help wanted", "color": "008672", "description": null}
]
只提取 name 字段得到 ["bug", "help wanted"],用于 Chip 列表展示。
Comment 模型
class Comment {
final int id;
final String body; // 正文(Markdown)
final UserProfile? user; // 评论者
final DateTime createdAt;
final DateTime updatedAt;
factory Comment.fromJson(Map<String, dynamic> json) {
return Comment(
id: parseInt(json['id']),
body: parseString(json['body']),
user: json['user'] != null
? UserProfile.fromJson(
json['user'] as Map<String, dynamic>)
: null,
createdAt: parseDateTime(json['created_at']) ?? DateTime.now(),
updatedAt: parseDateTime(json['updated_at']) ?? DateTime.now(),
);
}
}
简洁的评论模型,核心字段是 body(Markdown 格式)和 user(评论者)。
FileNode
文件节点的结构体现了文件系统的层级特征:
class FileNode {
final String name; // 文件名或目录名
final String path; // 完整路径(从仓库根目录起)
final String? sha; // Git SHA(文件校验和)
final int? size; // 文件大小(字节,目录为 null)
final String type; // 'blob'(文件)或 'tree'(目录)
final List<FileNode>? children; // 子节点(目录时递归嵌套)
bool get isDirectory => type == 'tree';
factory FileNode.fromJson(Map<String, dynamic> json) {
return FileNode(
name: parseString(json['name']),
path: parseString(json['path']),
sha: json['sha'] as String?,
size: parseInt(json['size']),
type: parseString(json['type']),
children: (parseList<dynamic>(json, 'entries') ?? [])
.whereType<Map<String, dynamic>>()
.map(FileNode.fromJson) // 递归!
.toList(),
);
}
}
递归结构是 FileNode 的最大特点。children 是 List<FileNode>?——每个子节点同样是 FileNode 对象,可以有自己的 children。这使得任意深度的目录树都能用同一个模型表示。
isDirectory 是计算属性而非存储字段——不占用 JSON 字段,从 type 推导而来。
size 是 int?(目录为 null),因为 API 不返回目录的大小信息。
所有模型的共同模式
1. 不可变性
所有字段使用 final 声明,构造后不可修改。
2. fromJson 工厂构造函数
命名构造函数 fromJson 是标准的 Dart JSON 反序列化模式,接收 Map<String, dynamic> 返回模型实例。
3. 安全解析
整数字段统一使用 parseInt,字符串字段使用 parseString,日期字段使用 parseDateTime。不直接使用 as int、as String 等强制类型转换。这是整个项目"永不崩溃"哲学在数据层的体现。
4. 可空字段保留 null
未填写的可选字段保留 null,不填充无意义的默认值。例如 description: null(未填写)和 description: ""(显式清空)在业务上是不同的语义。
5. 没有 toJson
当前应用是只读客户端——只需要从 API 读取数据展示给用户,不需要向 API 发送 JSON。因此所有模型都没有实现 toJson 方法。这是 YAGNI 原则(You Aren’t Gonna Need It)的直接应用——不需要的代码不写。
如果未来需要支持创建 Issue、修改仓库信息等写入操作,可以在对应模型上添加 toJson,不会影响现有代码。
模型之间的关联关系
Repository
└── owner: UserProfile? ← 仓库所有者
Issue
├── user: UserProfile? ← Issue 作者
└── labels: List<String> ← 标签名列表
Comment
└── user: UserProfile? ← 评论者
FileNode
└── children: List<FileNode>? ← 递归子节点
UserProfile 是最常被嵌套的模型。Repository 和 Issue 都包含 owner/user 字段,类型均为 UserProfile。这种嵌套意味着在反序列化 Repository 时,会递归调用 UserProfile.fromJson 来解析嵌套的用户数据。
数据流中的模型
模型在整个数据流中作为传输载体:
API JSON Response
→ jsonDecode → Map<String, dynamic>
→ fromJson → Model (不可变对象)
→ Provider._items (List<Model>)
→ Provider.notifyListeners()
→ Widget build (读取 provider.items)
→ UI 渲染
模型在 Provider 层被存储,在 Widget 层被消费。不可变性保证了两层之间的安全共享。
更多推荐

所有评论(0)