资讯动态

成为全栈·Flutter App 篇·分类、标签与搜索:移动端内容发现

发布时间:2026/10/14 19:36:38 来源:尧图企业网站定制
成为全栈·Flutter App 篇·分类、标签与搜索移动端内容发现上一篇把首页接上了真实接口。接下来是内容发现的三个入口分类、标签、搜索。这三个功能看起来是三个页面实际做完才发现它们共享一个核心难题而且这个难题的解法写在契约里——如果我们没仔细读契约会写出三份互相不兼容的代码。先说分类树。这个需求本身很清楚无限级分类树点父节点看全部后代的文章每行右边显示文章数。接口返回了树。我递归渲染就行了。写完发现文章数显示不出来。那个不存在的 articleCount我先是检查了生成的模型ApiCategoryNode长这样classApiCategoryNode{ApiCategoryNode.fromJson(MapString,dynamicvalue):jsonMap.unmodifiable(value);finalMapString,dynamicjson;int?getid(json[id]asnum?)?.toInt();String?getnamejson[name]asString?;String?getslugjson[slug]asString?;String?getdescriptionjson[description]asString?;int?getsortOrder(json[sortOrder]asnum?)?.toInt();ListApiCategoryNodegetchildren(json[children]asList???[]).map((e)ApiCategoryNode.fromJson(MapString,dynamic.from(easMap))).toList();}六个字段id/name/slug/description/sortOrder/children。没有articleCount。第一反应是后端忘了返回。但翻到契约才明白——这不是遗漏是有意的设计。因为父分类的文章数 它自己和所有后代的文章数之和。如果把这个数存在分类表里每次写文章都要更新整条祖先链而分类树可能很深、可能有并发写。所以契约把它拆成了两个接口树给结构统计单独给。这个决定的连带影响是Category和CategoryStat是两个 schema不是同一个。而articleCount只存在于后者。于是页面要发两个请求还要把两个结果拼起来child:AsyncPane(ListApiCategoryNode,Mapint,int)(load:()async{finalcategoriesawaitref.read(repositoryProvider).categories();finalstatsawaitref.read(repositoryProvider).read(Endpoints.categoriesStats)asList;return(categories,{for(finalcinstats)c[id]asint:c[articleCount]asint},);},注意泛型是(ListApiCategoryNode, Mapint, int)这个元组——因为这两个数据必须一起到齐才能渲染。分开渲染意味着树先出来、数字后补那还不如一起等。而AsyncPane恰好能处理这种一次 load 内部串两个请求的情况它只关心load()返回什么。最后那个集合字面量{for (final c in stats) c[id] as int: c[articleCount] as int}把统计数组变成 id→count 的映射。用 Map 而不是 List 是为了 O(1) 查——渲染每个节点都要查它的数字分类最多几十个但这个习惯值得养成。为什么计数可以查不到树里某个分类如果不在统计结果里比如它是新加的、统计还没跑counts[n.id] ?? 0会得到 0。这时候显示0 篇是撒谎——用户点进去可能明明有 8 篇文章。不过这里我选择了显示 0理由是统计接口和树接口来自同一套后端不一致的情况基本不存在而如果真的不一致显示空白反而会让用户以为是 bug。这是个小判断但它提醒我一件事?? 0这个默认值的合理性取决于缺数据意味着什么。在这里它意味着没查到而我判断没查到和确实是 0在用户看来没区别所以可以合并。换个场景——比如文章总数——同样的写法就是错的。无限级树的渲染递归函数就够树的渲染本身是标准递归没什么可讲的Widgetnode(BuildContextcontext,ApiCategoryNoden,Mapint,intcounts)n.children.isEmpty?ListTile(leading:constSizedBox(width:15),minLeadingWidth:15,horizontalTitleGap:8,contentPadding:AppInsets.pageHorizontal,title:Text(n.name??),trailing:Text(${counts[n.id]??0}篇,style:context.text.labelSmall),onTap:()context.push(/browse?category${n.slug}title${Uri.encodeComponent(n.name ?? 分类文章)},),):ExpansionTile(leading:constPrototypeIcon(chevr,size:15),title:Text(n.name??),children:[ListTile(title:Text(全部${n.name}文章),onTap:()context.push(/browse?category${n.slug}title${Uri.encodeComponent(n.name ?? 分类文章)},),),for(finalchildinn.children)Padding(padding:AppInsets.nestedLeft,child:node(context,child,counts)),],);但有两个细节值得说。一、叶子节点和父节点的交互不同。父节点用ExpansionTile可展开看子分类叶子用普通ListTile点击直接进列表。如果叶子也给个展开箭头点了没反应那是最让人恼火的交互。二、父节点额外插了一条全部XX文章。因为父节点本身不是叶子它不能只用来导航——用户看到前端这个分类想要的是前端下面所有文章而不是前端这一层的文章。页面里那句PageIntro把这个规则写清楚了按学习路径划分父分类包含全部后代分类的文章。这是契约之外的产品约定。如果后端只按category_id 前端过滤用户点进去看到的就只有直接挂在前端下的那几篇和他预期不符。这种语义必须在界面上说清楚否则用户会以为数据错了。导航参数用应用路由不是 API 地址注意onTap里那一串context.push(/browse?category${n.slug}title${Uri.encodeComponent(n.name ?? 分类文章)});这里有个我差点写错的地方我想当然地用了n.id后来改成了n.slug。理由是可读性和稳定性。路由里的 URL 会出现在浏览器历史、可能被分享、可能被收藏。用 slug 而不是 id 有两个好处人看得懂分类改名后 id 不变、slug 可能变但用户收藏的旧链接至少有据可查。而title参数需要Uri.encodeComponent——分类名是用户可编辑的内容里面可能有空格、、#。不转义的话分类 标签这种名字会直接把 URL 结构冲掉点进去就 404 了。这是个很容易漏的 bug测试时用的分类名都是前端“后端这种纯中文无特殊字符所以没问题等有人建了个叫AB” 的分类才暴露。顺带一句/browse这个路由是应用路由M4-05 会讲 go_router不是 API 地址。两者容易混淆因为都在项目里出现。标签一个纯列表页标签页比分类简单得多因为它不需要树finalpoliciesCachePolicyTable();// ...ResourceFamily.statistics:_ReadRule(CachePolicy(Duration(minutes:5),_day,disk:true),_Shape.list,),标签和分类统计在缓存表里属于同一个家族dictionaries/statistics缓存 5 分钟到 1 天落盘。给标签这么长的缓存是合理的因为它们几乎不变——分类和标签是运营配置不是用户内容。真正需要新鲜的是文章列表本身。搜索返回的东西不一样搜索是这个系列里唯一一个响应形状完全不同的接口M4-07 提过但没细说。看仓库里那行代码if(pathEndpoints.search)datadata[articles];正常列表返回{list, pagination}搜索返回{articles: [...]}。为什么因为搜索的结果页需要展示找到多少条但搜索通常不分页——用户输入关键词得到一个相关性排序的列表没有第 2 页的概念至少 v1 没有。所以服务端干脆不分页直接给一个扁平的列表加总数。这就是 M4-07 里那张表的实际应用端点返回分页GET /articles{list, pagination}有GET /articles/search{articles: []}无GET /me/likes[]裸数组无GET /me/history{list: [{article, progress}]}有四种形状四种处理方式全在 Repository 里消化。页面调用repo.articles(path: searchEndpoint)拿到的永远是PageResultArticle。而搜索页自己是怎么用的AsyncPaneListApiTag(load:ref.read(repositoryProvider).tags,builder:(tags,reload)Padding(padding:AppInsets.page,child:Wrap(spacing:8,runSpacing:8,children:[for(finaltintags.take(8))ActionChip(label:Text(t.name??),onPressed:()search(t.name),),],),),)搜索入口页展示搜索历史 热门标签点标签直接以它为关键词搜索。没有搜索结果也要有内容可看——一个空白的搜索框在用户进来时是完全无方向的。tags.take(8)是有意的搜索入口页不是标签页塞满标签会让首屏很长。真正的标签页在/tags路由。搜索历史存在本地不上传服务端。这是产品判断不是技术限制——搜索历史属于个人行为不该进用户资料。这也和 M1 定的注册只收必要的四件事username / email / password / nickname一致。三个入口的共性分类、标签、搜索做完回头看它们共享了四件事共性体现都是纯读取不涉及写操作不需要mutationTags不需要乐观更新都是低频变更只有搜索例外缓存策略给得很长分类统计 5 分钟到 1 天都复用同一个AsyncPane同一套加载中/错误/保旧值逻辑都读同一个 Repository响应形状差异在 Repository 消化页面不感知第四点是最省事的地方。如果每个页面各写一套请求搜索那处很可能忘了处理{articles}而不是{list}而这个 bug 只在点搜索时才出现——测试环境和手动点一遍很容易漏过。一条我一开始想省掉的事分类页那个AsyncPane里我最初是把两个请求写成并行的// 错误示范并行发但没有处理部分失败finalcategoriesawait...categories();finalstatsawait...read(categoriesStats);改成这样是因为我在意请求总数——首页会同时开好几个两个串行请求会让最坏情况下的等待时间翻倍。但这么写有问题两个请求串行意味着第二个要等第一个。而它们其实没有依赖关系可以并发。正确写法是Future.waitfinalresultsawaitFuture.wait([ref.read(repositoryProvider).categories(),ref.read(repositoryProvider).read(Endpoints.categoriesStats),]);finalcategoriesresults[0]asListApiCategoryNode;finalstatsresults[1]asList;这两个改动的差别不大但反映了一个更值得琢磨的点串行和并行不只是快一点慢一点它会改变错误的性质。串行时第一个失败会抛异常第二个根本不发——用户看到的是分类加载失败但其实统计接口是好的。并行时两个都发第二个失败会让Future.wait抛异常而第一个的结果白拿到了。并行下部分失败要怎么处理取决于业务。这里的答案是整体失败因为树和数字必须配套但代码里必须写清楚这是有意的选择否则下一个人会以为写漏了错误处理。小结分类、标签、搜索这三个功能技术上都不复杂但它们是读契约这件事最好的例子articleCount不在Category里—— 拆分统计接口是为了避免深层祖先链的并发写代价是客户端要发两个请求再拼起来。搜索返回{articles}而不是{list, pagination}—— 因为搜索不分页。四种响应形状在 Repository 里统一页面完全不感知。分类树要能任意深度—— 递归渲染不难难的是父分类包含全部后代这个语义必须在界面上说明。导航用 slug 而不是 id—— 顺带提醒分类名是用户输入encodeComponent不能省。真正省下来的力气来自复用AsyncPane管加载状态Repository 管响应形状CachePolicyTable管缓存时长。这三个页面本身只有几十行而且没有一行和网络细节有关。下一篇讲 Markdown 阅读器——那是另一个量级的复杂度把一份不可控的文本安全地变成 Widget。延伸阅读Dio Repository统一响应信封与模型适配用真实 API 构建首页焦点、最新与热门内容多级分类、标签与 URL让内容导航既可读又可索引如果这篇文章对你有帮助欢迎订阅我的 CSDN 专栏「成为全栈」 专栏地址https://blog.csdn.net/fungleo/category_13204651.html 本系列配套代码仓库https://github.com/fengcms/become-a-full-stack-developer

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑