
收藏Star功能在代码托管平台中Star 功能有两个作用一是表达对项目的认可类似社交媒体的点赞二是将仓库加入收藏夹方便日后查找。AtomGit Flutter 客户端在两个位置实现了 Star 功能收藏列表页StarredReposScreen查看所有已 Star 的仓库支持分页加载仓库详情页RepoDetailScreen切换 Star/Unstar 状态查询当前用户是否已 Star本文将这两部分合并讲解——前两节讲收藏列表后续章节讲详情页的 Star 切换。一、收藏列表StarredReposProviderAPI 端点收藏仓库列表通过认证专用的/user/starred端点获取finalresponseawait_apiClient.get(/user/starred,queryParams:{per_page:30,page:page.toString(),},);端点特点需要认证未登录时 API 返回 401。StarredReposScreen 本身不检查登录状态假设从 ProfileTab 进入时已经登录但 Provider 的 load 方法会捕获 ApiException 处理认证失败全量列表返回所有 Star 过的仓库无上限通过分页处理按 Star 时间降序最近 Star 的排在最前面StarredReposProviderclassStarredReposProviderextendsChangeNotifier{finalAtomGitApiClient_apiClient;ListRepository_repositories[];bool _isLoadingfalse;String?_error;bool _hasMorefalse;int _page1;ListRepositorygetrepositoriesList.unmodifiable(_repositories);boolgetisLoading_isLoading;String?geterror_error;boolgethasMore_hasMore;}使用List.unmodifiable暴露仓库列表防止外部意外修改内部数据。这是防御性编程的最佳实践——Provider 是数据的唯一所有者外部只能读取。load 方法首次加载Futurevoidload()async{_page1;_isLoadingtrue;_errornull;notifyListeners();try{finalresponseawait_apiClient.get(/user/starred,queryParams:{per_page:30,page:1,},);finalitemsparseListdynamic(response.data)??[];_repositoriesitems.whereTypeMapString,dynamic().map(Repository.fromJson).toList();_hasMore_repositories.length30;}onApiExceptioncatch(e){_errore.message;}catch(e){_error加载收藏列表失败;}finally{_isLoadingfalse;notifyListeners();}}_hasMore的判断逻辑当 API 返回的数据量等于per_page30时认为还有下一页。这是一个乐观猜测——API 返回正好 30 条时下一页可能存在也可能恰好是最后一页。极端情况用户收藏恰好是 30 的倍数会导致一次不必要的下一页请求但 fetch 后发现为空就会停止。loadMore 方法分页加载与页码回退FuturevoidloadMore()async{if(_isLoading||!_hasMore)return;_page;_isLoadingtrue;notifyListeners();try{finalresponseawait_apiClient.get(/user/starred,queryParams:{per_page:30,page:_page.toString(),},);finalitemsparseListdynamic(response.data)??[];finalnewRepositems.whereTypeMapString,dynamic().map(Repository.fromJson).toList();_repositories.addAll(newRepos);_hasMorenewRepos.length30;}onApiExceptioncatch(e){_errore.message;_page--;// 关键翻页失败时回退页码}catch(e){_page--;// 任何异常都回退}finally{_isLoadingfalse;notifyListeners();}}页码回退是整个分页系统中最关键的细节。假如没有_page--load完成后_page1→ 用户滚动触发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, _isLoadingtrue, notifyListeners() → API 请求 GET /user/starred?page2per_page30 → [网络成功] │ → items 解析 → _repositories.addAll(items) │ → _hasMore items.length 30 │ → _isLoadingfalse, notifyListeners() │ → [网络失败] → catch ApiException → _error e.message → _page: 2→1 (回退) → _isLoadingfalse, notifyListeners() → UI 显示错误但保留已加载的数据 → 用户可手动重试StarredReposScreen 的实现classStarredReposScreenextendsStatelessWidget{overrideWidgetbuild(BuildContextcontext){returnChangeNotifierProvider(create:(_)StarredReposProvider(context.readAtomGitApiClient())..load(),child:const_StarredBody(),);}}Provider 在create中通过级联操作符..load()立即触发数据加载。滚动监听class_StarredBodyStateextendsState_StarredBody{final_scrollControllerScrollController();overridevoidinitState(){super.initState();_scrollController.addListener(_onScroll);}overridevoiddispose(){_scrollController.dispose();super.dispose();}void_onScroll(){finalprovidercontext.readStarredReposProvider();if(_scrollController.position.pixels_scrollController.position.maxScrollExtent-200provider.hasMore!provider.isLoading){provider.loadMore();}}}三个触发条件组成 AND 逻辑链位置检查pixels maxScrollExtent - 200。200px 的缓冲区意味着用户在还需要轻微滑动到底部时就已触发加载。当用户真正到达底部时新数据大概率已经返回并展示数据检查provider.hasMore。API 已告知没有更多数据时不再发起请求状态检查!provider.isLoading。当前没有正在进行的加载请求时才能发起新请求防止滚动过程中多次触发状态展示Widget_buildBody(StarredReposProviderprovider){// 优先级 1错误且没有缓存数据if(provider.error!nullprovider.repositories.isEmpty){returnErrorRetryWidget(message:provider.error!,onRetry:()provider.load(),);}// 优先级 2空结果if(provider.repositories.isEmpty!provider.isLoading){returnconstCenter(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正常列表returnListView.builder(controller:_scrollController,itemCount:provider.repositories.length(provider.hasMore?1:0),itemBuilder:(context,index){if(indexprovider.repositories.length){returnconstPadding(padding:EdgeInsets.all(16),child:Center(child:CircularProgressIndicator()),);}finalrepoprovider.repositories[index];return_buildRepoItem(repo);},);}三种状态的优先级错误无缓存→ ErrorRetryWidget 重试。如果已有缓存数据不展示错误保持用户浏览体验加载完成但空列表→ 空状态引导有数据→ ListView 底部加载指示器列表项构建Widget_buildRepoItem(Repositoryrepo){finalinforepo.ownerAndName;returnRepoCard(repo:repo,onTap:info!null?(){Navigator.pushNamed(context,/repo,arguments:{owner:info.owner,name:info.name,});}:null,);}使用ownerAndName计算属性提取仓库的 owner 和 name。如果提取失败info为 nullonTap为 nullRepoCard 的 InkWell 不展示水波纹暗示用户无法点击。错误处理的设计考量loadMore 中的错误与 load 中的错误两者对 error 的处理不同load 失败设置_errorUI 展示 ErrorRetryWidget。_repositories为空页面内容不可用loadMore 失败设置_error但 UI 不展示 ErrorRetryWidget因为_repositories不为空。用户可以看到已加载的仓库同时知道加载更多失败了// _buildBody 中的判断if(provider.error!nullprovider.repositories.isEmpty){returnErrorRetryWidget(/* ... */);}provider.repositories.isEmpty是区分首次加载失败和加载更多失败的关键条件。用户触发的重试对于首次加载失败ErrorRetryWidget 的onRetry调用provider.load()从头开始。对于加载更多失败当前实现没有提供显式的重试加载更多按钮。_page--让用户的下一次滚动自然成为重试——用户稍微向上滚动再向下滚动到底部_onScroll再次触发此时_page已回退到正确的页码。与其他仓库列表的对比特性收藏仓库首页热门仓库搜索仓库API/user/starred/search/repositories/search/repositories排序Star 时间降序Stars 降序可配置分页标准分页_page无标准分页_page错误回退有_page--不适用有_page--空状态“还没有收藏任何仓库”“暂无仓库”“未找到仓库”登录要求必须登录登录后显示更多可选ProviderStarredReposProviderHomeTab 方法RepoSearchProvider二、Star 切换API 客户端的 put / delete 方法收藏列表负责查看而 Star/Unstar 操作在仓库详情页中执行。这两项操作需要 HTTPPUT和DELETE方法——AtomGitApiClient原本只有get()和post()为支持 Star/Unstar 新增了两个方法FutureApiResponseput(Stringpath,{MapString,dynamic?body})async{finaluri_buildUri(path);try{finalresponseawait_httpClient.put(uri,headers:_headers,body:body!null?jsonEncode(body):null,);return_processResponse(response);}onhttp.ClientExceptioncatch(e){throwApiException.networkError(网络连接失败:${e.message});}}FutureApiResponsedelete(Stringpath)async{finaluri_buildUri(path);try{finalresponseawait_httpClient.delete(uri,headers:_headers);return_processResponse(response);}onhttp.ClientExceptioncatch(e){throwApiException.networkError(网络连接失败:${e.message});}}两者与get()/post()遵循相同的模式构建 URI → 附加认证头 → 调用http.Client对应方法 → 通过_processResponse统一处理响应、速率限制和错误映射。delete不需要body参数——AtomGit 的 Star API 不需要请求体。三、Star 切换RepoDetailProviderStar 功能涉及三个关键方法全部位于RepoDetailProvider中。查询 Star 状态进入仓库详情页时需要知道当前用户是否已 Star 该仓库从而显示正确的图标Futurevoid_checkStarred(Stringowner,StringrepoName)async{try{finalencodedPath/user/starred/${Uri.encodeComponent(owner)}/${Uri.encodeComponent(repoName)};finalresponseawait_apiClient.get(encodedPath);_isStarredresponse.statusCode204;}onApiException{_isStarredfalse;}}这里不解析响应体——AtomGit Star 查询 API 的约定是204 No Content 表示已 Star404 表示未 Star。任何异常包括 404 抛出的ApiException都视为未 Star这是一种防御性策略宁可显示未 Star让用户多点击一次也不要错误显示已 Star让用户误以为收藏了。_checkStarred在load()方法中与仓库详情请求串行执行finalresponseawait_apiClient.get(encodedPath);await_checkStarred(owner,repoName);先获取仓库数据再查 Star 状态。顺序执行而非并行——Star 查询通常在毫秒级完成并行带来的收益微乎其微而串行避免了Future.wait的混合类型问题FutureApiResponse与Futurevoid不能放在同一个 wait 中。切换 StarFuturevoidtoggleStar()async{if(_isStarring||_repositorynull)return;finalownerAndName_repository!.ownerAndName;if(ownerAndNamenull)return;_isStarringtrue;notifyListeners();try{finalencodedPath/user/starred/${Uri.encodeComponent(ownerAndName.owner)}/${Uri.encodeComponent(ownerAndName.name)};if(_isStarred){await_apiClient.delete(encodedPath);_isStarredfalse;}else{await_apiClient.put(encodedPath);_isStarredtrue;}}onApiException{// 操作失败保持原状态}finally{_isStarringfalse;notifyListeners();}}**防抖机制_isStarring**是整个 toggle 的核心用户点击 Star → _isStarring true, notifyListeners() → 图标变为 CircularProgressIndicatoronTap 设为 null → API 请求进行中... → 用户疯狂点击 → 无效第一个 return 拦截 → API 返回结果 → _isStarring false, notifyListeners() → 图标恢复可交互这与收藏列表中的_isLoading不同——_isStarring是操作级锁_isLoading是页面级锁。两者的作用域不同不能混用。失败处理Star 操作失败时仅捕获ApiException不做任何状态变更——_isStarred保持原值UI 显示原来失败前的图标。用户感知到点了没反应可以重试。这是比弹出错误提示更好的体验——Star 是轻量操作用户不需要为一次失败的网络请求被中断。操作失败的恢复策略与翻页不同Star 操作失败后没有隐式的重试机制。翻页失败时_page--让用户的下一次滚动自动重试这是一个自然行为——用户会持续向下滚动。Star 失败后用户需要主动再次点击。这是有意的设计差异翻页用户滚动是一个持续性动作自动重试符合用户的意图Star用户点击是一个离散动作是否重试应该由用户决定——失败可能是没登录或没权限自动重试没有意义四、Star 按钮 UI仓库详情页的头部信息区中Star 不再是静态的_StatItem而是一个完整的交互式GestureDetectorGestureDetector(onTap:provider.isStarring?null:()provider.toggleStar(),child:provider.isStarring?constSizedBox(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,),),constSizedBox(width:4),Text(repo.stargazersCount.toString(),style:Theme.of(context).textTheme.bodySmall),三种视觉状态状态_isStarred_isStarring图标颜色交互未收藏falsefalsestar_border空心灰色可点击已收藏truefalsestar实心琥珀色可点击请求中任意trueCircularProgressIndicator主题色禁用_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 状态APIGET /user/starredGET/PUT/DELETE /user/starred/{owner}/{repo}ProviderStarredReposProviderRepoDetailProvider分页有_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对性能无影响。