模型设计原则本项目中的所有数据模型都遵循不可变性Immutability原则——所有字段声明为final对象创建后无法修改。这不是 Flutter 或 Dart 的强制要求而是从工程实践出发的设计选择。不可变模型带来的好处安全的 Widget 树。Flutter 通过identical比较判断 Widget 是否需要重建。同一个不可变对象可以被多个 Widget 安全共享因为没有人能修改它。可预测性。从 Provider 获取的数据在任何地方被读取都是一致的不会出现读到一半被另一个线程改了的情况。调试友好。可以直接比较两个 Repository 对象来判断数据是否变化引用比较等价于内容比较。模型总览模型文件位置字段数用途Repositoryrepo/models/repository.dart18仓库信息UserProfileuser/models/user_profile.dart16用户信息Issueissue/models/issue.dart12Issue/PR 主体Commentissue/models/issue.dart5Issue 评论FileNodecode/models/file_node.dart6文件树节点Repository最核心的模型Repository 是数据量最丰富的模型承载了仓库的所有元信息。classRepository{finalint id;// AtomGit 内部 IDfinalStringname;// 仓库名不含 ownerfinalStringfullName;// 完整名称 owner/repofinalString?path;// GitLab 风格的 URL 安全路径finalString?description;// 描述Markdownfinalbool 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.0finalUserProfile?owner;// 仓库所有者信息嵌套对象constRepository({requiredthis.id,requiredthis.name,// ... 所有字段});}fromJson 工厂factoryRepository.fromJson(MapString,dynamicjson){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]asMapString,dynamic):null,);}JSON 键名映射API 返回的字段使用 snake_caseDart 模型使用 camelCase。映射关系API 字段 (JSON)Dart 字段解析函数ididparseIntnamenameparseStringfull_namefullNameparseStringpathpathas String?privateisPrivate trueforkisFork truestargazers_countstargazersCountparseIntforks_countforksCountparseIntwatchers_countwatchersCountparseIntopen_issues_countopenIssuesCountparseIntdefault_branchdefaultBranchas String?created_atcreatedAtparseDateTimeupdated_atupdatedAtparseDateTimepushed_atpushedAtparseDateTimelicenselicense嵌套访问.spdx_id布尔字段的安全处理isPrivate:json[private]true,isFork:json[fork]true,使用 true而非as bool。API 可能返回truebool、trueString、或根本不存在该字段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/namefinalpartsfullName.split(/);if(parts.length2parts[0].isNotEmptyparts[1].isNotEmpty){return(owner:parts[0],name:parts[1]);}// 策略 2从 owner.login path/name 组合finalownerLoginowner?.login;finalrepoPathpath??name;if(ownerLogin!nullownerLogin.isNotEmptyrepoPath.isNotEmpty){return(owner:ownerLogin,name:repoPath);}// 无法解析returnnull;}返回类型({String owner, String name})?是 Dart 3 Record 特性的优雅应用。Record 是匿名的、不可变的结构体不需要单独声明一个类。使用方式finalinforepo.ownerAndName;if(info!null){Navigator.pushNamed(context,/repo,arguments:{owner:info.owner,name:info.name,});}UserProfileclassUserProfile{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]),// 用户名必须有// 可空允许 nullname: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 未返回的字段自然为 nullUI 根据是否为 null 决定是否展示。Issue 与 CommentIssue/PR 合一模型classIssue{finalint id;finalint number;// 仓库内唯一编号#1, #2, ...finalStringtitle;// 标题finalString?body;// 正文MarkdownfinalStringstate;// open | closedfinalUserProfile?user;// 作者finalListStringlabels;// 标签名列表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→typeissue→ 过滤掉isPullRequesttrue的结果/repos/{o}/{r}/pulls→typepr→ 过滤掉isPullRequestfalse的结果// IssueProvider 中的类型过滤_issuesitems.whereTypeMapString,dynamic().map(Issue.fromJson).where((issue)typepr?issue.isPullRequest:!issue.isPullRequest).toList();Labels 解析labels:(parseListdynamic(json,labels)??[]).whereTypeMapString,dynamic().map((l)parseString(l[name])).toList(),Labels 是标签对象列表到字符串列表的转换。API 返回格式为labels:[{name:bug,color:d73a4a,description:Something isnt working},{name:help wanted,color:008672,description:null}]只提取name字段得到[bug, help wanted]用于 Chip 列表展示。Comment 模型classComment{finalint id;finalStringbody;// 正文MarkdownfinalUserProfile?user;// 评论者finalDateTimecreatedAt;finalDateTimeupdatedAt;factoryComment.fromJson(MapString,dynamicjson){returnComment(id:parseInt(json[id]),body:parseString(json[body]),user:json[user]!null?UserProfile.fromJson(json[user]asMapString,dynamic):null,createdAt:parseDateTime(json[created_at])??DateTime.now(),updatedAt:parseDateTime(json[updated_at])??DateTime.now(),);}}简洁的评论模型核心字段是bodyMarkdown 格式和user评论者。FileNode文件节点的结构体现了文件系统的层级特征classFileNode{finalStringname;// 文件名或目录名finalStringpath;// 完整路径从仓库根目录起finalString?sha;// Git SHA文件校验和finalint?size;// 文件大小字节目录为 nullfinalStringtype;// blob文件或 tree目录finalListFileNode?children;// 子节点目录时递归嵌套boolgetisDirectorytypetree;factoryFileNode.fromJson(MapString,dynamicjson){returnFileNode(name:parseString(json[name]),path:parseString(json[path]),sha:json[sha]asString?,size:parseInt(json[size]),type:parseString(json[type]),children:(parseListdynamic(json,entries)??[]).whereTypeMapString,dynamic().map(FileNode.fromJson)// 递归.toList(),);}}递归结构是 FileNode 的最大特点。children是ListFileNode?——每个子节点同样是 FileNode 对象可以有自己的 children。这使得任意深度的目录树都能用同一个模型表示。isDirectory是计算属性而非存储字段——不占用 JSON 字段从type推导而来。size是int?目录为 null因为 API 不返回目录的大小信息。所有模型的共同模式1. 不可变性所有字段使用final声明构造后不可修改。2. fromJson 工厂构造函数命名构造函数fromJson是标准的 Dart JSON 反序列化模式接收MapString, 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: ListString ← 标签名列表 Comment └── user: UserProfile? ← 评论者 FileNode └── children: ListFileNode? ← 递归子节点UserProfile 是最常被嵌套的模型。Repository 和 Issue 都包含 owner/user 字段类型均为UserProfile。这种嵌套意味着在反序列化 Repository 时会递归调用UserProfile.fromJson来解析嵌套的用户数据。数据流中的模型模型在整个数据流中作为传输载体API JSON Response → jsonDecode → MapString, dynamic → fromJson → Model (不可变对象) → Provider._items (ListModel) → Provider.notifyListeners() → Widget build (读取 provider.items) → UI 渲染模型在 Provider 层被存储在 Widget 层被消费。不可变性保证了两层之间的安全共享。
AtomGit Flutter鸿蒙客户端:数据模型
发布时间:2026/6/10 17:57:16
模型设计原则本项目中的所有数据模型都遵循不可变性Immutability原则——所有字段声明为final对象创建后无法修改。这不是 Flutter 或 Dart 的强制要求而是从工程实践出发的设计选择。不可变模型带来的好处安全的 Widget 树。Flutter 通过identical比较判断 Widget 是否需要重建。同一个不可变对象可以被多个 Widget 安全共享因为没有人能修改它。可预测性。从 Provider 获取的数据在任何地方被读取都是一致的不会出现读到一半被另一个线程改了的情况。调试友好。可以直接比较两个 Repository 对象来判断数据是否变化引用比较等价于内容比较。模型总览模型文件位置字段数用途Repositoryrepo/models/repository.dart18仓库信息UserProfileuser/models/user_profile.dart16用户信息Issueissue/models/issue.dart12Issue/PR 主体Commentissue/models/issue.dart5Issue 评论FileNodecode/models/file_node.dart6文件树节点Repository最核心的模型Repository 是数据量最丰富的模型承载了仓库的所有元信息。classRepository{finalint id;// AtomGit 内部 IDfinalStringname;// 仓库名不含 ownerfinalStringfullName;// 完整名称 owner/repofinalString?path;// GitLab 风格的 URL 安全路径finalString?description;// 描述Markdownfinalbool 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.0finalUserProfile?owner;// 仓库所有者信息嵌套对象constRepository({requiredthis.id,requiredthis.name,// ... 所有字段});}fromJson 工厂factoryRepository.fromJson(MapString,dynamicjson){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]asMapString,dynamic):null,);}JSON 键名映射API 返回的字段使用 snake_caseDart 模型使用 camelCase。映射关系API 字段 (JSON)Dart 字段解析函数ididparseIntnamenameparseStringfull_namefullNameparseStringpathpathas String?privateisPrivate trueforkisFork truestargazers_countstargazersCountparseIntforks_countforksCountparseIntwatchers_countwatchersCountparseIntopen_issues_countopenIssuesCountparseIntdefault_branchdefaultBranchas String?created_atcreatedAtparseDateTimeupdated_atupdatedAtparseDateTimepushed_atpushedAtparseDateTimelicenselicense嵌套访问.spdx_id布尔字段的安全处理isPrivate:json[private]true,isFork:json[fork]true,使用 true而非as bool。API 可能返回truebool、trueString、或根本不存在该字段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/namefinalpartsfullName.split(/);if(parts.length2parts[0].isNotEmptyparts[1].isNotEmpty){return(owner:parts[0],name:parts[1]);}// 策略 2从 owner.login path/name 组合finalownerLoginowner?.login;finalrepoPathpath??name;if(ownerLogin!nullownerLogin.isNotEmptyrepoPath.isNotEmpty){return(owner:ownerLogin,name:repoPath);}// 无法解析returnnull;}返回类型({String owner, String name})?是 Dart 3 Record 特性的优雅应用。Record 是匿名的、不可变的结构体不需要单独声明一个类。使用方式finalinforepo.ownerAndName;if(info!null){Navigator.pushNamed(context,/repo,arguments:{owner:info.owner,name:info.name,});}UserProfileclassUserProfile{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]),// 用户名必须有// 可空允许 nullname: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 未返回的字段自然为 nullUI 根据是否为 null 决定是否展示。Issue 与 CommentIssue/PR 合一模型classIssue{finalint id;finalint number;// 仓库内唯一编号#1, #2, ...finalStringtitle;// 标题finalString?body;// 正文MarkdownfinalStringstate;// open | closedfinalUserProfile?user;// 作者finalListStringlabels;// 标签名列表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→typeissue→ 过滤掉isPullRequesttrue的结果/repos/{o}/{r}/pulls→typepr→ 过滤掉isPullRequestfalse的结果// IssueProvider 中的类型过滤_issuesitems.whereTypeMapString,dynamic().map(Issue.fromJson).where((issue)typepr?issue.isPullRequest:!issue.isPullRequest).toList();Labels 解析labels:(parseListdynamic(json,labels)??[]).whereTypeMapString,dynamic().map((l)parseString(l[name])).toList(),Labels 是标签对象列表到字符串列表的转换。API 返回格式为labels:[{name:bug,color:d73a4a,description:Something isnt working},{name:help wanted,color:008672,description:null}]只提取name字段得到[bug, help wanted]用于 Chip 列表展示。Comment 模型classComment{finalint id;finalStringbody;// 正文MarkdownfinalUserProfile?user;// 评论者finalDateTimecreatedAt;finalDateTimeupdatedAt;factoryComment.fromJson(MapString,dynamicjson){returnComment(id:parseInt(json[id]),body:parseString(json[body]),user:json[user]!null?UserProfile.fromJson(json[user]asMapString,dynamic):null,createdAt:parseDateTime(json[created_at])??DateTime.now(),updatedAt:parseDateTime(json[updated_at])??DateTime.now(),);}}简洁的评论模型核心字段是bodyMarkdown 格式和user评论者。FileNode文件节点的结构体现了文件系统的层级特征classFileNode{finalStringname;// 文件名或目录名finalStringpath;// 完整路径从仓库根目录起finalString?sha;// Git SHA文件校验和finalint?size;// 文件大小字节目录为 nullfinalStringtype;// blob文件或 tree目录finalListFileNode?children;// 子节点目录时递归嵌套boolgetisDirectorytypetree;factoryFileNode.fromJson(MapString,dynamicjson){returnFileNode(name:parseString(json[name]),path:parseString(json[path]),sha:json[sha]asString?,size:parseInt(json[size]),type:parseString(json[type]),children:(parseListdynamic(json,entries)??[]).whereTypeMapString,dynamic().map(FileNode.fromJson)// 递归.toList(),);}}递归结构是 FileNode 的最大特点。children是ListFileNode?——每个子节点同样是 FileNode 对象可以有自己的 children。这使得任意深度的目录树都能用同一个模型表示。isDirectory是计算属性而非存储字段——不占用 JSON 字段从type推导而来。size是int?目录为 null因为 API 不返回目录的大小信息。所有模型的共同模式1. 不可变性所有字段使用final声明构造后不可修改。2. fromJson 工厂构造函数命名构造函数fromJson是标准的 Dart JSON 反序列化模式接收MapString, 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: ListString ← 标签名列表 Comment └── user: UserProfile? ← 评论者 FileNode └── children: ListFileNode? ← 递归子节点UserProfile 是最常被嵌套的模型。Repository 和 Issue 都包含 owner/user 字段类型均为UserProfile。这种嵌套意味着在反序列化 Repository 时会递归调用UserProfile.fromJson来解析嵌套的用户数据。数据流中的模型模型在整个数据流中作为传输载体API JSON Response → jsonDecode → MapString, dynamic → fromJson → Model (不可变对象) → Provider._items (ListModel) → Provider.notifyListeners() → Widget build (读取 provider.items) → UI 渲染模型在 Provider 层被存储在 Widget 层被消费。不可变性保证了两层之间的安全共享。