HarmonyOS 应用实战:校园树洞投稿(三)话题——Grid 两列网格与热度聚合
项目编号:42-treehole-post 技术栈:HarmonyOS ArkTS · ArkUI 声明式 UI 本篇页面:话题
Func2Tab.ets(70 行) 系列导航:(一)首页 · (二)投稿 ·(三)话题· (四)我的 源码开源:https://gitee.com/codenestFlow/HarmonyOSHub
一、本篇聚焦
在四页数据流里(见 (一)首页 第十节),话题页是目录端:它不展示树洞正文,而是把内容按topic字段重新组织成入口,count字段就是这里的"热度"。
本篇解决四个问题:Grid的columnsTemplate怎么算列宽、count怎么格式化和排序、静态数组怎么升级为按HoleStore实时聚合、点击话题格子之后怎么跳到只属于它的话题详情页。
二、数据模型:目录项的五个字段
interface Topic { id: number; emoji: string; name: string; desc: string; count: number; }| 字段 | 渲染位置 | 说明 |
|---|---|---|
id | ForEachkey | 唯一主键,第六节对比三种 key |
emoji | 卡片顶部 34 号 | 每话题一个情绪符号,构成"情绪光谱" |
name | # 名称15 号粗体 | 与投稿页词表同名同序,落库才能对上 |
desc | 11 号灰字 | 一句话定位,maxLines(1)防溢出 |
count | 11 号紫字 | 热度,本篇的主角 |
private topics: Topic[] = [ { id: 1, emoji: '🌙', name: '深夜emo', desc: '那些说不出口的情绪', count: 1289 }, { id: 2, emoji: '📚', name: '学习焦虑', desc: '考试、论文、绩点', count: 986 }, { id: 3, emoji: '💕', name: '人际关系', desc: '友情、爱情、室友', count: 756 }, { id: 4, emoji: '💔', name: '青春疼痛', desc: '成长中的迷茫', count: 645 }, { id: 5, emoji: '☀️', name: '治愈瞬间', desc: '生活里的小确幸', count: 1102 }, { id: 6, emoji: '🎯', name: '未来规划', desc: '关于梦想和方向', count: 534 } ];两个值得注意的约定:
- 前 5 个话题与首页横滑带、投稿页词表完全同名同序(
深夜emo / 学习焦虑 / 人际关系 / 青春疼痛 / 治愈瞬间),话题页多出的第 6 个「未来规划」是浏览入口的扩展。这套约定保证三处 UI 引用同一个命名空间——投稿时存的topic字符串,在这里聚合、在首页过滤,全部命中。词表若有偏差,投稿就会掉进"存在但永远不显示"的暗数据。 count不是等差数列(1289 / 986 / 756 / 645 / 1102 / 534),有头部有腰部,方便验证第五节的千位格式化与排序效果。
三、页面骨架与 Header 的固定高度算术
build() { Column() { this.Header() Scroll() { Column({ space: 12 }) { this.TopicGrid() } .width('100%') .padding({ left: D.pad, right: D.pad, top: 14, bottom: D.pad + this.safeBottom + 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width('100%').height('100%').backgroundColor(C.bg) }骨架与首页、投稿页同构:Header在Scroll外、Scroll用layoutWeight(1)吃满剩余高度、底部安全区公式D.pad + this.safeBottom + 20。三页共用同一套骨架,沉浸式适配只需在 Ability 里做一次(见 (一)首页 第四节)。
但本页 Header 的实现不同:
@Builder Header() { Row() { Text('话题广场').fontSize(20).fontWeight(FontWeight.Bold).fontColor(C.text) } .width('100%').height(this.safeTop + 56).padding({ top: this.safeTop, left: D.pad, right: D.pad }) .backgroundColor(C.card).alignItems(VerticalAlign.Bottom) }三个数值配合出一条稳定基线:
height(this.safeTop + 56)—— 总高 = 安全区 + 固定 56 的内容高。对比首页的双行 Header(由内容自适应撑开),锁定 56 的好处是:不受字体度量差异影响,切页时基线零抖动。padding({ top: this.safeTop })—— 内容从状态栏之下开始,总高里已预留这段。alignItems(VerticalAlign.Bottom)—— 文本在 56 高的行内贴底。若居中,未来一旦加副标题,基线就会移动;贴底则只往上生长。
选型原则:行数会变(带副标题、搜索框)用内容自适应高度;行数固定就锁定高度。本页属于后者。
四、TopicGrid:columnsTemplate的布局算术
@Builder TopicGrid() { Grid() { ForEach(this.topics, (t: Topic) => { GridItem() { Column({ space: 8 }) { Text(t.emoji).fontSize(34) Text('# ' + t.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(C.text) Text(t.desc).fontSize(11).fontColor(C.textDim).maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(t.count + ' 条树洞').fontSize(11).fontColor(C.primary) } .width('100%').padding(16).backgroundColor(C.card).borderRadius(D.rLg).border({ width: 1, color: C.stroke }) .onClick(() => { promptAction.showToast({ message: t.name }); }) } }, (t: Topic) => t.id.toString()) }.columnsTemplate('1fr 1fr').columnsGap(12).rowsGap(12).width('100%') }五个技术点逐个拆:
'1fr 1fr'是分数单位——fr即 fraction,两列各占剩余宽度的 1/2。卡片宽度 =(屏宽 − 左右 padding 32 − 列间距 12) ÷ 2,全程不用写一个具体像素;改三列只需把字符串改成'1fr 1fr 1fr'。GridItem内部必须width('100%')——GridItem自身尺寸等于 cell 尺寸,但内部的Column不会自动撑满它。少了这一行,卡片会缩成内容宽,背景和描边全部露馅,这是Grid的第一坑。maxLines三件套缺一不可——maxLines(1)+textOverflow({ overflow: TextOverflow.Ellipsis })+ 撑满宽度。只有maxLines没有溢出策略,文本会被硬裁剪而不是显示省略号;没有撑满宽度,就没有"溢出"可谈。columnsGap(12).rowsGap(12)—— 与首页卡片区space: 12、卡片内space: 8构成全应用三级间距系统:12 = 卡片之间,8 = 卡片内行间,14/16 = 区块之间。- 卡片内四段式层级—— emoji(34) → 话题名(15 粗) → 描述(11 灰) → 热度(11 紫),字号与颜色双通道递减,不用分隔线也能分出四个层级。
演进:响应式列数。手机两列、平板/折叠屏展开态三列:
import { display } from '@kit.ArkUI'; private columnsTemplate(): string { const w: number = px2vp(display.getDefaultDisplaySync().width); return w >= 600 ? '1fr 1fr 1fr' : '1fr 1fr'; } // TopicGrid 结尾改为: // .columnsTemplate(this.columnsTemplate()).columnsGap(12).rowsGap(12).width('100%')因为列宽是fr等分,加一列不需要重算任何尺寸;600vp 是折叠屏展开的常见宽度阈值。这是当初选columnsTemplate而不是写死卡片宽度的直接回报。
五、热度不是字符串拼接:格式化与排序
第 61 行是本篇最有演进价值的一行:
Text(t.count + ' 条树洞') // 1289 + ' 条树洞' = "1289 条树洞"两个问题:千位以上没有分隔读起来费劲;热度永远乱序展示。两者都应发生在数据层 / 展示边界,而不是 UI 结构里:
// common/Format.ets —— 展示层格式化 export function hotCount(n: number): string { if (n >= 10000) { return trimZero((n / 10000).toFixed(1)) + 'w'; // 12345 -> '1.2w' } if (n >= 1000) { return trimZero((n / 1000).toFixed(1)) + 'k'; // 1289 -> '1.3k' } return n.toString(); } function trimZero(s: string): string { return s.replace(/\.0$/, ''); // '1.0' -> '1' } // Func2Tab.ets —— 数据层排序 private reload(): void { const stats: TopicStat[] = HoleStore.topicStats(); // map 已生成全新数组 this.topics = stats.sort((a: TopicStat, b: TopicStat): number => b.count - a.count); }两条纪律:
- 排序前先拿到新数组。
topicStats()内部用map生成全新对象数组,sort原地改动它不伤共享常量;如果直接对模块级常量TOPIC_META.sort(...),下次进页面顺序就乱了——这是隐蔽性极强的状态污染。 - 格式化只发生在渲染边界。
count在数据层始终是number(可比较、可排序、可累加),只在Text(hotCount(t.count))的最后一刻变成'1.3k 条树洞'。一旦数据层存了格式化字符串,所有聚合逻辑都要先反解析。
六、三种 key 的选型:本页为什么用id
三个页面正好用过三种ForEachkey,可以做一次对比:
| 页面 | key 函数 | 数据源 | 为什么 |
|---|---|---|---|
| 首页横滑 | (t: string) => t | 5 个话题名 | 词表天然唯一且不重排 |
| 投稿心情 | (m: string, idx: number) => m + idx | 5 个 emoji | emoji 可能重复,静态不重排场景拼索引兜底 |
| 话题网格 | (t: Topic) => t.id.toString() | 6 个完整对象 | 有唯一主键,最标准写法 |
一句话总结:有主键用主键,没主键用唯一内容,内容都可能重复时才动索引——且仅限数组静态不变的场景。一旦数组会增删或重排(如本节加入排序后),基于索引的 key 会导致节点错误复用:ArkUI 按 key 匹配旧节点,'😊2'对上了却换了位置,界面与数据的对应关系就断了。
七、从静态常量到聚合:topicStats()的实时热度
静态count的问题很明显:投稿页发出一条「深夜emo」,话题广场的 1289 纹丝不动。让它动起来,需要把目录页接到HoleStore上:
// common/HoleStore.ets(节选,完整结构见首页篇第十节) export interface TopicStat { id: number; emoji: string; name: string; desc: string; count: number; } const TOPIC_META: TopicStat[] = [ { id: 1, emoji: '🌙', name: '深夜emo', desc: '那些说不出口的情绪', count: 1289 }, { id: 2, emoji: '📚', name: '学习焦虑', desc: '考试、论文、绩点', count: 986 }, { id: 3, emoji: '💕', name: '人际关系', desc: '友情、爱情、室友', count: 756 }, { id: 4, emoji: '💔', name: '青春疼痛', desc: '成长中的迷茫', count: 645 }, { id: 5, emoji: '☀️', name: '治愈瞬间', desc: '生活里的小确幸', count: 1102 }, { id: 6, emoji: '🎯', name: '未来规划', desc: '关于梦想和方向', count: 534 } ]; export class HoleStore { static topicStats(): TopicStat[] { return TOPIC_META.map((t: TopicStat): TopicStat => { // 只统计本机新投稿:线上基数 + 本地增量,种子数据不重复计数 const added: number = HoleStore.list.filter((h: Hole) => h.topic === t.name && h.mine).length; return { id: t.id, emoji: t.emoji, name: t.name, desc: t.desc, count: t.count + added }; }); } }话题页侧的改造:
@StorageProp('holeVersion') version: number = 0; // 订阅数据版本(首页篇第十节) @State topics: TopicStat[] = []; aboutToAppear(): void { this.reload(); } private reload(): void { const stats: TopicStat[] = HoleStore.topicStats(); this.topics = stats.sort((a: TopicStat, b: TopicStat): number => b.count - a.count); }从此形成闭环:投稿页publish()→bump()版本号 +1 → 话题页重拉 → 「深夜emo」的 1289 变 1290,排序位次还可能上移。filter里带&& h.mine的理由:种子树洞本来就是「线上基数 1289」的一部分,全部计入会双算;只加mine才是"我贡献的增量"。
八、点击话题之后:路由参数与HoleCard复用
Toast 之后应该是话题详情页。跳转与传参:
import { router } from '@kit.ArkUI'; // TopicGrid 里替换 Toast: .onClick(() => { router.pushUrl({ url: 'pages/TopicDetail', params: { topic: t.name } }); })新页面TopicDetail.ets取参、按话题过滤,然后复用首页篇抽出的HoleCard((一)首页 第九节):
// pages/TopicDetail.ets import { router } from '@kit.ArkUI'; import { C, D } from '../common/Theme'; import { Hole, HoleStore } from '../common/HoleStore'; import { HoleCard } from '../components/HoleCard'; @Entry @Component struct TopicDetail { @StorageProp('safeTop') safeTop: number = 0; @StorageProp('safeBottom') safeBottom: number = 0; @State topic: string = ''; @State holes: Hole[] = []; aboutToAppear(): void { const p: Record<string, string> = router.getParams() as Record<string, string>; if (p !== undefined && p.topic !== undefined) { this.topic = p.topic; this.holes = HoleStore.byTopic(this.topic); // 首页篇第十节的 byTopic } } build() { Column() { Row({ space: 10 }) { Text('‹').fontSize(22).fontColor(C.text).onClick(() => { router.back(); }) Text('# ' + this.topic).fontSize(18).fontWeight(FontWeight.Bold).fontColor(C.text) Blank() Text(this.holes.length + ' 条').fontSize(12).fontColor(C.textDim) } .width('100%').height(this.safeTop + 56) .padding({ top: this.safeTop, left: D.pad, right: D.pad }) .backgroundColor(C.card).alignItems(VerticalAlign.Bottom) Scroll() { Column({ space: 12 }) { if (this.holes.length === 0) { Text('这个话题还没有树洞,去投稿页种下第一条吧 🌱') .fontSize(13).fontColor(C.textDim).margin({ top: 120 }) } ForEach(this.holes, (h: Hole) => { HoleCard({ hole: h, onHug: (id: number) => { HoleStore.toggleHug(id); this.holes = HoleStore.byTopic(this.topic); } }) }, (h: Hole) => h.id.toString()) } .width('100%') .padding({ left: D.pad, right: D.pad, top: 14, bottom: D.pad + this.safeBottom + 20 }) } .layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top) } .width('100%').height('100%').backgroundColor(C.bg) } }三个复用点值得强调:
HoleCard原样复用—— 首页渲染全部,这里渲染过滤后的子集,卡片组件一行没改。这是抽组件的直接回报。- 空态分支
if (this.holes.length === 0)—— 「未来规划」这类本地没有内容的话题,进来不是白屏而是一句引导文案。空态是列表页最容易漏的分支。 params只传话题名—— 不传整个Topic对象。路由参数应传"键"而不是"值":详情页拿键去HoleStore查最新数据,而不是依赖跳转瞬间的快照,这样别处更新后详情页也是对的。
九、Grid 与 List 的选型
| 维度 | Grid+columnsTemplate | List+GridContainer/Column换行 |
|---|---|---|
| 列宽控制 | fr等分,精确 | 需手工算宽或用栅格容器 |
| 间距控制 | columnsGap/rowsGap各自独立 | 受space单一值约束 |
| 懒加载 | 支持按需加载 | 支持 |
| 适用 | 话题/商品等目录型内容 | 时间线等流式内容 |
话题页选Grid的本质原因:目录项是等权重的平行入口,两列对称天然合理;而首页的树洞是不等长的正文流,单列Column + ForEach才不会让长文挤压短文。目录用网格、正文用列表,这个分界比"哪个更新潮"重要得多。
十、实机验证
1. 首屏:两列三行六个话题格,第一行# 深夜emo 1289 条树洞 / # 学习焦虑 986 条树洞,GridItem内部Column撑满格子,描边完整。
2. 点治愈瞬间→ Toast「治愈瞬间」。闭包捕获各自的t,六个格子参数正确。
3. 点深夜emo→ Toast「深夜emo」。验证每格独立点击,无串扰。
4. 完整视图:滚动到底,最后一行(☀️ 治愈瞬间 1102 / 🎯 未来规划 534)完整可见,底部安全区留白足够。
验收清单:
| 项 | 验证方式 | 结果 |
|---|---|---|
| 两列等分 | 目测左右列宽 | 完全相等 |
| 格子撑满 | 描边贴 cell 边缘 | 正常(width('100%')生效) |
| 描述截断 | maxLines(1)+ Ellipsis | 未换行、未硬裁 |
| key 唯一 | t.id.toString() | 1~6 无冲突 |
| 底部安全区 | 滚到底最后一行 | 未被导航条遮挡 |
十一、可演进方向
| 方向 | 现状 | 改法 | 收益 |
|---|---|---|---|
| 实时热度 | 静态 1289 | topicStats()聚合mine增量 | 投稿后数字立刻 +1 |
| 热度排序 | 固定顺序 | sort按count降序 | 热门话题置顶 |
| 数字格式化 | count + ' 条树洞' | hotCount()千位缩写 | 1.3k 式紧凑展示 |
| 响应式列数 | 固定两列 | columnsTemplate()按 600vp 断点 | 平板三列 |
| 点击去向 | Toast | router.pushUrl+ TopicDetail | 目录页真正可达 |
| 空态 | 无 | if (holes.length === 0)引导文案 | 冷启动不白屏 |
十二、本篇小结
话题页 70 行,但它的三个决定都不在行数里:'1fr 1fr'用分数单位让列宽免于像素计算、t.id.toString()让 key 在排序后依然正确、topicStats()让热度从写死数字变成map + filter的实时聚合。目录用网格、正文用列表,加上"词表三页同名同序"的约定,这页才算真正接进了四页数据流。
下一篇 (四)我的 讲统计端:负 margin 悬浮卡的实现原理,以及三个统计数字如何从静态字符串变成按mine聚合的真实值。