在这里插入图片描述

模型设计原则

本项目中的所有数据模型都遵循不可变性(Immutability)原则——所有字段声明为 final,对象创建后无法修改。这不是 Flutter 或 Dart 的强制要求,而是从工程实践出发的设计选择。

不可变模型带来的好处:

  1. 安全的 Widget 树。Flutter 通过 identical 比较判断 Widget 是否需要重建。同一个不可变对象可以被多个 Widget 安全共享,因为没有人能修改它。
  2. 可预测性。从 Provider 获取的数据在任何地方被读取都是一致的,不会出现"读到一半被另一个线程改了"的情况。
  3. 调试友好。可以直接比较两个 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 在应用中有两种使用场景:

  1. 独立使用:ProfileScreen 中展示用户完整信息时,UserProfile 是页面的核心数据
  2. 嵌套使用:作为 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}/issuestype='issue' → 过滤掉 isPullRequest=true 的结果
  • /repos/{o}/{r}/pullstype='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 的最大特点。childrenList<FileNode>?——每个子节点同样是 FileNode 对象,可以有自己的 children。这使得任意深度的目录树都能用同一个模型表示。

isDirectory 是计算属性而非存储字段——不占用 JSON 字段,从 type 推导而来。

sizeint?(目录为 null),因为 API 不返回目录的大小信息。

所有模型的共同模式

1. 不可变性

所有字段使用 final 声明,构造后不可修改。

2. fromJson 工厂构造函数

命名构造函数 fromJson 是标准的 Dart JSON 反序列化模式,接收 Map<String, dynamic> 返回模型实例。

3. 安全解析

整数字段统一使用 parseInt,字符串字段使用 parseString,日期字段使用 parseDateTime。不直接使用 as intas 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 层被消费。不可变性保证了两层之间的安全共享。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐