在这里插入图片描述

在这里插入图片描述

收藏(Star)功能

在代码托管平台中,Star 功能有两个作用:一是表达对项目的认可(类似社交媒体的"点赞"),二是将仓库加入收藏夹方便日后查找。AtomGit Flutter 客户端在两个位置实现了 Star 功能:

  1. 收藏列表页(StarredReposScreen):查看所有已 Star 的仓库,支持分页加载
  2. 仓库详情页(RepoDetailScreen):切换 Star/Unstar 状态,查询当前用户是否已 Star

本文将这两部分合并讲解——前两节讲收藏列表,后续章节讲详情页的 Star 切换。


一、收藏列表:StarredReposProvider

API 端点

收藏仓库列表通过认证专用的 /user/starred 端点获取:

final response = await _apiClient.get(
  '/user/starred',
  queryParams: {
    'per_page': '30',
    'page': page.toString(),
  },
);

端点特点:

  • 需要认证:未登录时 API 返回 401。StarredReposScreen 本身不检查登录状态(假设从 ProfileTab 进入时已经登录),但 Provider 的 load 方法会捕获 ApiException 处理认证失败
  • 全量列表:返回所有 Star 过的仓库,无上限(通过分页处理)
  • 按 Star 时间降序:最近 Star 的排在最前面

StarredReposProvider

class StarredReposProvider extends ChangeNotifier {
  final AtomGitApiClient _apiClient;

  List<Repository> _repositories = [];
  bool _isLoading = false;
  String? _error;
  bool _hasMore = false;
  int _page = 1;

  List<Repository> get repositories =>
      List.unmodifiable(_repositories);
  bool get isLoading => _isLoading;
  String? get error => _error;
  bool get hasMore => _hasMore;
}

使用 List.unmodifiable 暴露仓库列表,防止外部意外修改内部数据。这是防御性编程的最佳实践——Provider 是数据的唯一所有者,外部只能读取。

load 方法(首次加载)

Future<void> load() async {
  _page = 1;
  _isLoading = true;
  _error = null;
  notifyListeners();

  try {
    final response = await _apiClient.get(
      '/user/starred',
      queryParams: {
        'per_page': '30',
        'page': '1',
      },
    );

    final items = parseList<dynamic>(response.data) ?? [];
    _repositories = items
        .whereType<Map<String, dynamic>>()
        .map(Repository.fromJson)
        .toList();

    _hasMore = _repositories.length >= 30;
  } on ApiException catch (e) {
    _error = e.message;
  } catch (e) {
    _error = '加载收藏列表失败';
  } finally {
    _isLoading = false;
    notifyListeners();
  }
}

_hasMore 的判断逻辑:当 API 返回的数据量等于 per_page(30)时,认为还有下一页。这是一个"乐观猜测"——API 返回正好 30 条时,下一页可能存在也可能恰好是最后一页。极端情况(用户收藏恰好是 30 的倍数)会导致一次不必要的下一页请求,但 fetch 后发现为空就会停止。

loadMore 方法(分页加载)与页码回退

Future<void> loadMore() async {
  if (_isLoading || !_hasMore) return;

  _page++;
  _isLoading = true;
  notifyListeners();

  try {
    final response = await _apiClient.get(
      '/user/starred',
      queryParams: {
        'per_page': '30',
        'page': _page.toString(),
      },
    );

    final items = parseList<dynamic>(response.data) ?? [];
    final newRepos = items
        .whereType<Map<String, dynamic>>()
        .map(Repository.fromJson)
        .toList();

    _repositories.addAll(newRepos);
    _hasMore = newRepos.length >= 30;
  } on ApiException catch (e) {
    _error = e.message;
    _page--;  // 关键:翻页失败时回退页码
  } catch (e) {
    _page--;  // 任何异常都回退
  } finally {
    _isLoading = false;
    notifyListeners();
  }
}

页码回退是整个分页系统中最关键的细节。

假如没有 _page--load 完成后 _page=1 → 用户滚动触发 loadMore_page 变为 2 → API 请求失败 → _page 停留在 2 → 用户再次滚动触发 loadMore_page 变为 3 → API 请求第 3 页 → 第 2 页的数据永久丢失。

_page-- 时:API 请求失败 → _page 回退到 1 → 下次 loadMore_page 再次变为 2 → 重试第 2 页 → 数据完整。

这个设计在正常的 API 请求流中看起来多余(每次成功请求不会触发回退),但在网络不稳定的移动场景中至关重要——地铁、电梯、地下通道等环境网络频繁切换,请求失败是常态而非异常。

完整的 loadMore 时序

用户滚动到底部
  → _onScroll 检测
  → loadMore() 调用
  → _page: 1→2, _isLoading=true, notifyListeners()
  → API 请求 GET /user/starred?page=2&per_page=30
  → [网络成功]
  │   → items 解析 → _repositories.addAll(items)
  │   → _hasMore = items.length >= 30
  │   → _isLoading=false, notifyListeners()
  │
  → [网络失败]
      → catch ApiException → _error = e.message
      → _page: 2→1 (回退)
      → _isLoading=false, notifyListeners()
      → UI 显示错误但保留已加载的数据
      → 用户可手动重试

StarredReposScreen 的实现

class StarredReposScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) =>
          StarredReposProvider(context.read<AtomGitApiClient>())
            ..load(),
      child: const _StarredBody(),
    );
  }
}

Provider 在 create 中通过级联操作符 ..load() 立即触发数据加载。

滚动监听

class _StarredBodyState extends State<_StarredBody> {
  final _scrollController = ScrollController();

  
  void initState() {
    super.initState();
    _scrollController.addListener(_onScroll);
  }

  
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _onScroll() {
    final provider = context.read<StarredReposProvider>();
    if (_scrollController.position.pixels >=
            _scrollController.position.maxScrollExtent - 200 &&
        provider.hasMore &&
        !provider.isLoading) {
      provider.loadMore();
    }
  }
}

三个触发条件组成 AND 逻辑链:

  1. 位置检查pixels >= maxScrollExtent - 200。200px 的缓冲区意味着用户在还需要轻微滑动到底部时就已触发加载。当用户真正到达底部时,新数据大概率已经返回并展示
  2. 数据检查provider.hasMore。API 已告知没有更多数据时不再发起请求
  3. 状态检查!provider.isLoading。当前没有正在进行的加载请求时才能发起新请求,防止滚动过程中多次触发

状态展示

Widget _buildBody(StarredReposProvider provider) {
  // 优先级 1:错误且没有缓存数据
  if (provider.error != null && provider.repositories.isEmpty) {
    return ErrorRetryWidget(
      message: provider.error!,
      onRetry: () => provider.load(),
    );
  }

  // 优先级 2:空结果
  if (provider.repositories.isEmpty && !provider.isLoading) {
    return const Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(Icons.star_border, size: 64, color: Colors.grey),
          SizedBox(height: 16),
          Text('还没有收藏任何仓库'),
          SizedBox(height: 8),
          Text('浏览仓库时点击星标即可收藏',
              style: TextStyle(color: Colors.grey)),
        ],
      ),
    );
  }

  // 优先级 3:正常列表
  return ListView.builder(
    controller: _scrollController,
    itemCount: provider.repositories.length +
        (provider.hasMore ? 1 : 0),
    itemBuilder: (context, index) {
      if (index >= provider.repositories.length) {
        return const Padding(
          padding: EdgeInsets.all(16),
          child: Center(child: CircularProgressIndicator()),
        );
      }
      final repo = provider.repositories[index];
      return _buildRepoItem(repo);
    },
  );
}

三种状态的优先级:

  1. 错误(无缓存) → ErrorRetryWidget + 重试。如果已有缓存数据,不展示错误(保持用户浏览体验)
  2. 加载完成但空列表 → 空状态引导
  3. 有数据 → ListView + 底部加载指示器

列表项构建

Widget _buildRepoItem(Repository repo) {
  final info = repo.ownerAndName;
  return RepoCard(
    repo: repo,
    onTap: info != null
        ? () {
            Navigator.pushNamed(context, '/repo', arguments: {
              'owner': info.owner,
              'name': info.name,
            });
          }
        : null,
  );
}

使用 ownerAndName 计算属性提取仓库的 owner 和 name。如果提取失败(info 为 null),onTap 为 null,RepoCard 的 InkWell 不展示水波纹,暗示用户无法点击。

错误处理的设计考量

loadMore 中的错误与 load 中的错误

两者对 error 的处理不同:

  • load 失败:设置 _error,UI 展示 ErrorRetryWidget。_repositories 为空,页面内容不可用
  • loadMore 失败:设置 _error,但 UI 不展示 ErrorRetryWidget(因为 _repositories 不为空)。用户可以看到已加载的仓库,同时知道加载更多失败了
// _buildBody 中的判断
if (provider.error != null && provider.repositories.isEmpty) {
  return ErrorRetryWidget(/* ... */);
}

provider.repositories.isEmpty 是区分"首次加载失败"和"加载更多失败"的关键条件。

用户触发的重试

对于首次加载失败,ErrorRetryWidget 的 onRetry 调用 provider.load(),从头开始。

对于加载更多失败,当前实现没有提供显式的"重试加载更多"按钮。_page-- 让用户的下一次滚动自然成为重试——用户稍微向上滚动再向下滚动到底部,_onScroll 再次触发,此时 _page 已回退到正确的页码。

与其他仓库列表的对比

特性 收藏仓库 首页热门仓库 搜索仓库
API /user/starred /search/repositories /search/repositories
排序 Star 时间降序 Stars 降序 可配置
分页 标准分页 _page 标准分页 _page
错误回退 _page-- 不适用 _page--
空状态 “还没有收藏任何仓库” “暂无仓库” “未找到仓库”
登录要求 必须登录 登录后显示更多 可选
Provider StarredReposProvider HomeTab 方法 RepoSearchProvider

二、Star 切换:API 客户端的 put / delete 方法

收藏列表负责"查看",而 Star/Unstar 操作在仓库详情页中执行。这两项操作需要 HTTP PUTDELETE 方法——AtomGitApiClient 原本只有 get()post(),为支持 Star/Unstar 新增了两个方法:

Future<ApiResponse> put(String path,
    {Map<String, dynamic>? body}) async {
  final uri = _buildUri(path);
  try {
    final response = await _httpClient.put(
      uri,
      headers: _headers,
      body: body != null ? jsonEncode(body) : null,
    );
    return _processResponse(response);
  } on http.ClientException catch (e) {
    throw ApiException.networkError('网络连接失败: ${e.message}');
  }
}

Future<ApiResponse> delete(String path) async {
  final uri = _buildUri(path);
  try {
    final response = await _httpClient.delete(uri, headers: _headers);
    return _processResponse(response);
  } on http.ClientException catch (e) {
    throw ApiException.networkError('网络连接失败: ${e.message}');
  }
}

两者与 get() / post() 遵循相同的模式:构建 URI → 附加认证头 → 调用 http.Client 对应方法 → 通过 _processResponse 统一处理响应、速率限制和错误映射。delete 不需要 body 参数——AtomGit 的 Star API 不需要请求体。

三、Star 切换:RepoDetailProvider

Star 功能涉及三个关键方法,全部位于 RepoDetailProvider 中。

查询 Star 状态

进入仓库详情页时,需要知道当前用户是否已 Star 该仓库,从而显示正确的图标:

Future<void> _checkStarred(String owner, String repoName) async {
  try {
    final encodedPath =
        '/user/starred/${Uri.encodeComponent(owner)}/${Uri.encodeComponent(repoName)}';
    final response = await _apiClient.get(encodedPath);
    _isStarred = response.statusCode == 204;
  } on ApiException {
    _isStarred = false;
  }
}

这里不解析响应体——AtomGit Star 查询 API 的约定是 204 No Content 表示已 Star,404 表示未 Star。任何异常(包括 404 抛出的 ApiException)都视为未 Star,这是一种防御性策略:宁可显示"未 Star"让用户多点击一次,也不要错误显示"已 Star"让用户误以为收藏了。

_checkStarredload() 方法中与仓库详情请求串行执行:

final response = await _apiClient.get(encodedPath);
await _checkStarred(owner, repoName);

先获取仓库数据,再查 Star 状态。顺序执行而非并行——Star 查询通常在毫秒级完成,并行带来的收益微乎其微,而串行避免了 Future.wait 的混合类型问题(Future<ApiResponse>Future<void> 不能放在同一个 wait 中)。

切换 Star

Future<void> toggleStar() async {
  if (_isStarring || _repository == null) return;
  final ownerAndName = _repository!.ownerAndName;
  if (ownerAndName == null) return;

  _isStarring = true;
  notifyListeners();

  try {
    final encodedPath =
        '/user/starred/${Uri.encodeComponent(ownerAndName.owner)}/${Uri.encodeComponent(ownerAndName.name)}';
    if (_isStarred) {
      await _apiClient.delete(encodedPath);
      _isStarred = false;
    } else {
      await _apiClient.put(encodedPath);
      _isStarred = true;
    }
  } on ApiException {
    // 操作失败,保持原状态
  } finally {
    _isStarring = false;
    notifyListeners();
  }
}

**防抖机制(_isStarring)**是整个 toggle 的核心:

用户点击 Star
  → _isStarring = true, notifyListeners()
  → 图标变为 CircularProgressIndicator,onTap 设为 null
  → API 请求进行中...
  → 用户疯狂点击 → 无效(第一个 return 拦截)
  → API 返回结果 → _isStarring = false, notifyListeners()
  → 图标恢复可交互

这与收藏列表中的 _isLoading 不同——_isStarring 是操作级锁,_isLoading 是页面级锁。两者的作用域不同,不能混用。

失败处理:Star 操作失败时仅捕获 ApiException 不做任何状态变更——_isStarred 保持原值,UI 显示原来(失败前)的图标。用户感知到"点了没反应",可以重试。这是比弹出错误提示更好的体验——Star 是轻量操作,用户不需要为一次失败的网络请求被中断。

操作失败的恢复策略

与翻页不同,Star 操作失败后没有隐式的重试机制。翻页失败时 _page-- 让用户的下一次滚动自动重试(这是一个自然行为——用户会持续向下滚动)。Star 失败后用户需要主动再次点击。这是有意的设计差异:

  • 翻页:用户滚动是一个持续性动作,自动重试符合用户的意图
  • Star:用户点击是一个离散动作,是否重试应该由用户决定——失败可能是"没登录"或"没权限",自动重试没有意义

四、Star 按钮 UI

仓库详情页的头部信息区中,Star 不再是静态的 _StatItem,而是一个完整的交互式 GestureDetector

GestureDetector(
  onTap: provider.isStarring ? null : () => provider.toggleStar(),
  child: provider.isStarring
      ? const SizedBox(
          width: 14, height: 14,
          child: CircularProgressIndicator(strokeWidth: 2),
        )
      : Icon(
          provider.isStarred ? Icons.star : Icons.star_border,
          size: 14,
          color: provider.isStarred ? Colors.amber : Colors.grey,
        ),
),
const SizedBox(width: 4),
Text(repo.stargazersCount.toString(),
    style: Theme.of(context).textTheme.bodySmall),

三种视觉状态:

状态 _isStarred _isStarring 图标 颜色 交互
未收藏 false false star_border(空心) 灰色 可点击
已收藏 true false star(实心) 琥珀色 可点击
请求中 任意 true CircularProgressIndicator 主题色 禁用

_isStarring 期间用 CircularProgressIndicator 替换整个图标区域(14x14),视觉上明确告诉用户"正在处理"。onTap 设为 null 禁用点击——Flutter 的 GestureDetectoronTap 为 null 时不展示水波纹,用户能看到和感觉到按钮已禁用。

Star 计数(stargazersCount 旁边的文本)在当前实现中是静态的——不存在乐观更新。点击 Star/Unstar 后,计数不会立即改变,需要刷新页面才能看到最新值。这是因为 AtomGit API 的 Star 端点不返回更新后的计数。未来可以在 toggleStar 成功回调中对 _repository.stargazersCount+1 / -1 的本地调整,失败时回退。

五、与收藏列表的对比

维度 收藏列表 详情页 Star
功能 查看所有已 Star 仓库 切换 Star/Unstar 状态
API GET /user/starred GET / PUT / DELETE /user/starred/{owner}/{repo}
Provider StarredReposProvider RepoDetailProvider
分页 有(_page 计数) 不适用
防抖 _isLoading _isStarring
错误回退 _page-- 保持原状态
失败重试 用户滚动触发隐式重试 用户手动点击重试

两个 Provider 职责分离——详情页负责"操作收藏",收藏列表页负责"查看收藏"。不存在交叉依赖或状态同步:Star 操作后,如果用户进入收藏列表页,StarredReposProvider.load() 会重新请求 API,自然获取最新数据。

性能考量

收藏列表可能很长(数百个仓库)。几个优化点:

分页加载(每次 30 条)。首次只加载一页,用户滚动到底部后再加载后续数据。避免一次性加载数百条数据导致长等待时间和内存压力。

ListView.builder 的惰性构建。Flutter 的 ListView.builder 只构建屏幕可见区域 + 缓存区的 item。即使收藏了 300 个仓库,内存中只有约 20-30 个 Widget 实例。

ScrollController 的及时释放。在 dispose 中释放 _scrollController 防止内存泄漏。

Star 切换无额外开销_checkStarred 是单次 API 调用(毫秒级),toggleStarCircularProgressIndicator 是 14x14 的极小 Widget,对性能无影响。

Logo

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

更多推荐