模型设计原则
本项目中的所有数据模型都遵循不可变性(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 是数据量最丰富的模型,承载了仓库的所有元信息。
classRepository{finalint id;// AtomGit 内部 IDfinalStringname;// 仓库名(不含 owner)finalStringfullName;// 完整名称 "owner/repo"finalString?path;// GitLab 风格的 URL 安全路径finalString?description;// 描述(Markdown)finalbool isPrivate;// 是否私有finalbool isFork;// 是否是从其他仓库 Fork 的finalString?language;// 主要编程语言finalint stargazersCount;// Star 数量finalint forksCount;// Fork 数量finalint watchersCount;// Watcher 数量finalint openIssuesCount;// 开放的 Issue 数量finalString?defaultBranch;// 默认分支名finalDateTimecreatedAt;// 创建时间finalDateTimeupdatedAt;// 最后更新时间finalDateTime?pushedAt;// 最后推送时间finalString?homepage;// 项目主页 URLfinalString?license;// 许可证(如 "MIT", "Apache-2.0")finalUserProfile?owner;// 仓库所有者信息(嵌套对象)constRepository({requiredthis.id,requiredthis.name,// ... 所有字段});}fromJson 工厂
factoryRepository.fromJson(Map<String,dynamic>json){returnRepository(id:parseInt(json['id']),name:parseString(json['name']),fullName:parseString(json['full_name']),path:json['path']asString?,description:json['description']asString?,isPrivate:json['private']==true,isFork:json['fork']==true,language:json['language']asString?,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']asString?,createdAt:parseDateTime(json['created_at'])??DateTime.now(),updatedAt:parseDateTime(json['updated_at'])??DateTime.now(),pushedAt:parseDateTime(json['pushed_at']),homepage:json['homepage']asString?,license:json['license']?['spdx_id']asString?,owner:json['owner']!=null?UserProfile.fromJson(json['owner']asMap<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']asString?,json['license']返回{"key": "mit", "name": "MIT License", "spdx_id": "MIT"}。使用?.安全链式访问,如果license为 null,整个表达式返回 null 而非抛出NoSuchMethodError。
ownerAndName 计算属性
({Stringowner,Stringname})?getownerAndName{// 策略 1:从 fullName 拆分(格式 "owner/name")finalparts=fullName.split('/');if(parts.length==2&&parts[0].isNotEmpty&&parts[1].isNotEmpty){return(owner:parts[0],name:parts[1]);}// 策略 2:从 owner.login + path/name 组合finalownerLogin=owner?.login;finalrepoPath=path??name;if(ownerLogin!=null&&ownerLogin.isNotEmpty&&repoPath.isNotEmpty){return(owner:ownerLogin,name:repoPath);}// 无法解析returnnull;}返回类型({String owner, String name})?是 Dart 3 Record 特性的优雅应用。Record 是匿名的、不可变的结构体,不需要单独声明一个类。
使用方式:
finalinfo=repo.ownerAndName;if(info!=null){Navigator.pushNamed(context,'/repo',arguments:{'owner':info.owner,'name':info.name,});}UserProfile
classUserProfile{finalint id;finalStringlogin;// 用户名(唯一标识)finalString?name;// 显示名(可空)finalString?avatarUrl;// 头像 URLfinalString?htmlUrl;// AtomGit 个人页 URLfinalString?bio;// 个人简介finalString?company;// 公司finalString?location;// 位置finalString?email;// 邮箱finalString?blog;// 博客 URLfinalint followers;// 关注者数量finalint following;// 正在关注的数量finalint publicRepos;// 公开仓库数量finalint publicGists;// 公开 Gist 数量finalDateTimecreatedAt;// 注册时间finalDateTimeupdatedAt;// 最后更新时间}字符串字段的两种处理方式:
// 强制非空(有默认值兜底)login:parseString(json['login']),// 用户名必须有// 可空(允许 null)name:json['name']asString?,// 显示名未填写时为 nullbio:json['bio']asString?,// 个人简介未填写时为 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 合一模型
classIssue{finalint id;finalint number;// 仓库内唯一编号(#1, #2, ...)finalStringtitle;// 标题finalString?body;// 正文(Markdown)finalStringstate;// 'open' | 'closed'finalUserProfile?user;// 作者finalList<String>labels;// 标签名列表finalint commentsCount;// 评论数finalbool isPullRequest;// true = PR, false = IssuefinalDateTimecreatedAt;// 创建时间finalDateTimeupdatedAt;// 最后更新时间}区分 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 模型
classComment{finalint id;finalStringbody;// 正文(Markdown)finalUserProfile?user;// 评论者finalDateTimecreatedAt;finalDateTimeupdatedAt;factoryComment.fromJson(Map<String,dynamic>json){returnComment(id:parseInt(json['id']),body:parseString(json['body']),user:json['user']!=null?UserProfile.fromJson(json['user']asMap<String,dynamic>):null,createdAt:parseDateTime(json['created_at'])??DateTime.now(),updatedAt:parseDateTime(json['updated_at'])??DateTime.now(),);}}简洁的评论模型,核心字段是body(Markdown 格式)和user(评论者)。
FileNode
文件节点的结构体现了文件系统的层级特征:
classFileNode{finalStringname;// 文件名或目录名finalStringpath;// 完整路径(从仓库根目录起)finalString?sha;// Git SHA(文件校验和)finalint?size;// 文件大小(字节,目录为 null)finalStringtype;// 'blob'(文件)或 'tree'(目录)finalList<FileNode>?children;// 子节点(目录时递归嵌套)boolgetisDirectory=>type=='tree';factoryFileNode.fromJson(Map<String,dynamic>json){returnFileNode(name:parseString(json['name']),path:parseString(json['path']),sha:json['sha']asString?,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 层被消费。不可变性保证了两层之间的安全共享。