AtomGit Flutter鸿蒙客户端:收藏仓库


收藏(Star)功能
在代码托管平台中,Star 功能有两个作用:一是表达对项目的认可(类似社交媒体的"点赞"),二是将仓库加入收藏夹方便日后查找。AtomGit Flutter 客户端在两个位置实现了 Star 功能:
- 收藏列表页(StarredReposScreen):查看所有已 Star 的仓库,支持分页加载
- 仓库详情页(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 逻辑链:
- 位置检查:
pixels >= maxScrollExtent - 200。200px 的缓冲区意味着用户在还需要轻微滑动到底部时就已触发加载。当用户真正到达底部时,新数据大概率已经返回并展示 - 数据检查:
provider.hasMore。API 已告知没有更多数据时不再发起请求 - 状态检查:
!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);
},
);
}
三种状态的优先级:
- 错误(无缓存) → ErrorRetryWidget + 重试。如果已有缓存数据,不展示错误(保持用户浏览体验)
- 加载完成但空列表 → 空状态引导
- 有数据 → 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 PUT 和 DELETE 方法——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"让用户误以为收藏了。
_checkStarred 在 load() 方法中与仓库详情请求串行执行:
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 的 GestureDetector 在 onTap 为 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 调用(毫秒级),toggleStar 的 CircularProgressIndicator 是 14x14 的极小 Widget,对性能无影响。
更多推荐


所有评论(0)