HarmonyOS 实战|中式美食排行榜页:综合评分、人气切换与首屏静态视觉兜底 HarmonyOS 实战中式美食排行榜页综合评分、人气切换与首屏静态视觉兜底排行榜页看起来像一个简单列表但在菜谱类应用里它承担的是“快速帮用户做选择”的职责。用户不一定知道今天要吃什么但会自然相信“综合榜”“高分榜”“人气榜”这些入口因为它们把大量菜品压缩成可扫描的决策线索。这篇复盘基于已上架 HarmonyOS 应用 **中式美食**对应工程环境为 HarmonyOS 6.1.0(23)、ArkTS、ArkUI V2、Stage 模型。本文拆解 RankPage.ets 如何在同一份 DishRepository 数据上完成三种排序口径并通过首屏静态视觉兜底、透明点击热区、动态榜单列表让排行榜既有视觉入口也有真实可交互的数据能力。这张图对应的是排行榜页的核心闭环用户先看到一个强视觉的综合榜首屏点击榜单或切换模式后进入真实列表列表项继续跳转到菜品详情页。视觉不是摆设它负责降低进入成本数据排序才是页面长期可维护的基础。本章导读| 章节位置 | 关键文件 | 解决的问题 | 验收入口 | | --- | --- | --- | --- | | 页面入口 | view/pages/RankPage.ets | 综合榜、高分榜、人气榜三种模式切换 | 首页“热门排行” | | 数据来源 | model/repository/DishRepository.ets | 统一读取内置菜谱和用户菜谱 | getAll() | | 排序规则 | compositeScore() / Computed list | 把评分与评价量拆成可解释排序 | 切换三个榜单 | | 首屏兜底 | rank_v3_first_screen_composite | 静态视觉首屏与真实点击热区结合 | 首次进入排行榜 | | 跳转闭环 | onOpenDetail(id) | 榜单项进入菜谱详情页 | 点击任意榜单项 |排行榜首先是一个决策页面在中式美食里排行榜不是单纯展示“数据好看”的页面而是解决三个用户问题| 用户问题 | 对应榜单 | 设计目标 | | --- | --- | --- | | 不知道吃什么想看综合推荐 | 综合榜 | 平衡评分和热度 | | 想找口碑稳定的菜 | 高分榜 | 直接按 rating 排序 | | 想看大家都在看的菜 | 人气榜 | 直接按 reviewCount 排序 |如果只做一个榜单用户会把“高分但冷门”和“热门但评分一般”的菜混在一起理解。三榜切换能把推荐理由拆开综合榜负责默认决策高分榜负责人群口碑人气榜负责热度反馈。页面状态要少但语义要清楚RankPage 的状态集中在三个字段typescripttype RankMode composite | rating | popular;Local mode: RankMode composite;Local all: Dish[] [];Local showCompositeRank: boolean true;这三个字段分别回答三个问题| 状态 | 含义 | 为什么需要 | | --- | --- | --- | | mode | 当前排序口径 | 控制综合/高分/人气切换 | | all | 当前参与排行的菜品数据 | 避免每次 build 都重新取仓库 | | showCompositeRank | 是否展示首屏静态视觉 | 首次进入时给更强入口感 |页面出现时只做一次数据读取typescriptaboutToAppear(): void {this.all DishRepository.getInstance().getAll();}这里使用 getAll() 而不是直接引用静态数据是因为前面自定义菜谱已经接入了 DishRepository。排行榜页天然能拿到内置菜和用户自建菜页面层不需要知道数据来自哪里。综合榜不能只按评分排菜谱榜单如果只按评分排序会出现一个问题一条评价的 5 分菜可能排在几百条评价的 4.8 分菜前面。中式美食的综合榜用了一个轻量公式typescriptprivate compositeScore(d: Dish): number {return d.rating * Math.log(d.reviewCount 10) / Math.log(10);}这个公式把评分和评价量一起考虑| 因子 | 作用 | | --- | --- | | rating | 代表口碑质量 | | reviewCount | 代表用户反馈规模 | | 10 | 避免评价量太低时分数过小 | | log10 | 降低超大评价量的碾压效应 |它不是复杂推荐算法但很适合本地菜谱应用可解释、可复现、无需服务端并且用户能直观理解“评分高且被更多人看过的菜会更靠前”。三种榜单共享同一个计算出口Computed list 是排行榜真正的输出口typescriptComputed get list(): Dish[] {const arr: Dish[] this.all.slice();if (this.mode rating) {arr.sort((a: Dish, b: Dish): number b.rating - a.rating);} else if (this.mode popular) {arr.sort((a: Dish, b: Dish): number b.reviewCount - a.reviewCount);} else {arr.sort((a: Dish, b: Dish): number this.compositeScore(b) - this.compositeScore(a));}return arr.slice(0, 30);}这段代码有三个值得保留的工程习惯| 写法 | 价值 | | --- | --- | | this.all.slice() | 不污染原始数组顺序 | | 分支只切排序规则 | 列表渲染不用关心模式细节 | | slice(0, 30) | 控制首屏和滚动性能不一次性展示过多 |排序逻辑集中后UI 层只需要消费 this.list。未来要新增“新菜榜”“家常榜”也可以继续扩展 RankMode不用重写列表卡片。首屏视觉兜底不是假页面RankPage 里有一个特殊逻辑typescriptprivate useCompositeRank(): boolean {return this.showCompositeRank getImage(rank_v3_first_screen_composite) ! undefined;}当静态首屏图存在时页面优先展示 CompositeRankFirstScreen()。这不是为了绕过真实 UI而是为了在首屏给用户更明确的“榜单入口”感。排行榜这种页面第一眼的信任感很重要。typescriptif (this.useCompositeRank()) {this.CompositeRankFirstScreen();} else {Column() {this.Header();this.Hero();Scroll() {Column() {this.TabRow();ForEach(this.list, (d: Dish, i: number) {this.RankRow(d, i);}, (d: Dish, i: number) i : d.id);}}}}这套结构把视觉和数据分成两层| 层 | 责任 | | --- | --- | | 静态首屏 | 强化榜单氛围承接首次进入 | | 透明点击区 | 把首屏图上的榜单项映射到真实详情 | | 动态列表 | 支持模式切换和后续维护 |透明点击热区解决视觉稿和路由的连接首屏图不是纯图片。页面在上面铺了透明点击区域typescriptForEach(this.rankDishIds, (id: string, index: number) {Column().width(100%).height(118).backgroundColor(#00000000).position({ x: 0, y: 98 index * 124 }).onClick((): void this.onOpenDetail(id));}, (id: string) id);这里的 rankDishIds 是首屏榜单的菜品 ID 顺序typescriptprivate rankDishIds: string[] [d024, d003, d001, d005, d002];这种做法适合已经设计好首屏视觉的场景。它的关键是图片上展示的菜品顺序必须和 rankDishIds 保持一致否则用户点击“第一名”却进了另一道菜会破坏信任。切换榜单时要退出首屏模式用户点击顶部模式区域后会调用typescriptprivate enterRankMode(mode: RankMode): void {this.mode mode;this.showCompositeRank false;}这一步非常重要。首屏只是入口用户开始切换模式后页面必须进入真实列表态。否则视觉图仍停留在综合榜用户会以为“高分榜没有变化”。| 操作 | 状态变化 | 页面结果 | | --- | --- | --- | | 首次进入 | showCompositeRanktrue | 展示综合榜首屏 | | 点击高分榜 | moderatingshowCompositeRankfalse | 展示动态高分列表 | | 点击人气榜 | modepopularshowCompositeRankfalse | 展示动态人气列表 | | 点击榜单菜品 | 调用 onOpenDetail(id) | 进入详情页 |列表卡片负责扫描效率动态列表的每一行由 RankRow 生成结构上包含名次、图片、菜名、摘要和跳转提示。typescriptBuilder RankRow(d: Dish, idx: number) {Row() {Stack() {Column().width(36).height(36).borderRadius(18).backgroundColor(idx 3 ? AppColors.brandPrimary : AppColors.bgSoft);Text((idx 1).toString()).fontSize(idx 3 ? AppFonts.lg : AppFonts.md).fontColor(idx 3 ? AppColors.textOnBrand : AppColors.textSub).fontWeight(FontWeight.Bold);}.margin({ right: 12 });Column() {Text(d.name).fontSize(AppFonts.md).fontColor(AppColors.textMain).fontWeight(FontWeight.Bold).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis });Text(d.summary).fontSize(AppFonts.xs).fontColor(AppColors.textSub).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis });}.layoutWeight(1);}.onClick((): void { this.onOpenDetail(d.id); });}排行榜是高密度页面卡片不能太花。前三名用品牌色强化后面的名次保持低调菜名一行、摘要两行保证用户能连续扫描。工程验收记录| 检查项 | 操作方式 | 通过标准 | | --- | --- | --- | | 首屏兜底 | 进入排行榜页 | 有首屏图时展示静态综合榜 | | 榜单项点击 | 点击首屏前五个热区 | 进入对应菜品详情页 | | 模式切换 | 点击综合/高分/人气 | 列表排序口径切换 | | 数据完整性 | 读取 DishRepository.getAll() | 内置菜和用户菜都参与排序 | | 空数据状态 | 模拟空列表 | 展示 EmptyState | | 列表性能 | 展示排序结果 | 最多渲染前 30 条 | | 文本溢出 | 长菜名、长摘要 | 不撑破卡片 |常见问题复盘| 问题 | 原因 | 处理方式 | | --- | --- | --- | | 高分榜看起来和综合榜一样 | 切换模式后仍停留首屏图 | 点击模式时设置 showCompositeRankfalse | | 首屏点击进错详情 | 图片顺序和 rankDishIds 不一致 | 每次换首屏图同步检查 ID 顺序 | | 用户自建菜不参与排行 | 页面直接读静态数据 | 使用 DishRepository.getAll() | | 超大评价量碾压评分 | 直接按评论数排序 | 综合榜使用 rating * log10(reviewCount 10) | | 列表滚动卡顿 | 一次渲染全部菜谱 | 只取前 30 条 |本章小结- 中式美食排行榜页不是简单列表而是把“综合推荐、高分口碑、人气热度”拆成三种可解释决策入口。- 静态首屏图负责第一眼信任感透明点击热区和动态列表负责真实交互二者要保持菜品 ID 一致。- 排序逻辑集中在 Computed list页面渲染只消费结果后续扩展新榜单会更稳。