news 2026/10/2 12:53:27

HarmonyOS 应用实战:校园树洞投稿(三)话题——Grid 两列网格与热度聚合

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS 应用实战:校园树洞投稿(三)话题——Grid 两列网格与热度聚合

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; }
字段渲染位置说明
idForEachkey唯一主键,第六节对比三种 key
emoji卡片顶部 34 号每话题一个情绪符号,构成"情绪光谱"
name# 名称15 号粗体与投稿页词表同名同序,落库才能对上
desc11 号灰字一句话定位,maxLines(1)防溢出
count11 号紫字热度,本篇的主角
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 } ];

两个值得注意的约定:

  1. 前 5 个话题与首页横滑带、投稿页词表完全同名同序(深夜emo / 学习焦虑 / 人际关系 / 青春疼痛 / 治愈瞬间),话题页多出的第 6 个「未来规划」是浏览入口的扩展。这套约定保证三处 UI 引用同一个命名空间——投稿时存的topic字符串,在这里聚合、在首页过滤,全部命中。词表若有偏差,投稿就会掉进"存在但永远不显示"的暗数据。
  2. 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) }

三个数值配合出一条稳定基线:

  1. height(this.safeTop + 56)—— 总高 = 安全区 + 固定 56 的内容高。对比首页的双行 Header(由内容自适应撑开),锁定 56 的好处是:不受字体度量差异影响,切页时基线零抖动。
  2. padding({ top: this.safeTop })—— 内容从状态栏之下开始,总高里已预留这段。
  3. 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%') }

五个技术点逐个拆:

  1. '1fr 1fr'是分数单位——fr即 fraction,两列各占剩余宽度的 1/2。卡片宽度 =(屏宽 − 左右 padding 32 − 列间距 12) ÷ 2,全程不用写一个具体像素;改三列只需把字符串改成'1fr 1fr 1fr'。
  2. GridItem内部必须width('100%')——GridItem自身尺寸等于 cell 尺寸,但内部的Column不会自动撑满它。少了这一行,卡片会缩成内容宽,背景和描边全部露馅,这是Grid的第一坑。
  3. maxLines三件套缺一不可——maxLines(1)+textOverflow({ overflow: TextOverflow.Ellipsis })+ 撑满宽度。只有maxLines没有溢出策略,文本会被硬裁剪而不是显示省略号;没有撑满宽度,就没有"溢出"可谈。
  4. columnsGap(12).rowsGap(12)—— 与首页卡片区space: 12、卡片内space: 8构成全应用三级间距系统:12 = 卡片之间,8 = 卡片内行间,14/16 = 区块之间。
  5. 卡片内四段式层级—— 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); }

两条纪律:

  1. 排序前先拿到新数组。topicStats()内部用map生成全新对象数组,sort原地改动它不伤共享常量;如果直接对模块级常量TOPIC_META.sort(...),下次进页面顺序就乱了——这是隐蔽性极强的状态污染。
  2. 格式化只发生在渲染边界。count在数据层始终是number(可比较、可排序、可累加),只在Text(hotCount(t.count))的最后一刻变成'1.3k 条树洞'。一旦数据层存了格式化字符串,所有聚合逻辑都要先反解析。

六、三种 key 的选型:本页为什么用id

三个页面正好用过三种ForEachkey,可以做一次对比:

页面key 函数数据源为什么
首页横滑(t: string) => t5 个话题名词表天然唯一且不重排
投稿心情(m: string, idx: number) => m + idx5 个 emojiemoji 可能重复,静态不重排场景拼索引兜底
话题网格(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) } }

三个复用点值得强调:

  1. HoleCard原样复用—— 首页渲染全部,这里渲染过滤后的子集,卡片组件一行没改。这是抽组件的直接回报。
  2. 空态分支if (this.holes.length === 0)—— 「未来规划」这类本地没有内容的话题,进来不是白屏而是一句引导文案。空态是列表页最容易漏的分支。
  3. params只传话题名—— 不传整个Topic对象。路由参数应传"键"而不是"值":详情页拿键去HoleStore查最新数据,而不是依赖跳转瞬间的快照,这样别处更新后详情页也是对的。

九、Grid 与 List 的选型

维度Grid+columnsTemplateList+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 无冲突
底部安全区滚到底最后一行未被导航条遮挡

十一、可演进方向

方向现状改法收益
实时热度静态 1289topicStats()聚合mine增量投稿后数字立刻 +1
热度排序固定顺序sort按count降序热门话题置顶
数字格式化count + ' 条树洞'hotCount()千位缩写1.3k 式紧凑展示
响应式列数固定两列columnsTemplate()按 600vp 断点平板三列
点击去向Toastrouter.pushUrl+ TopicDetail目录页真正可达
空态无if (holes.length === 0)引导文案冷启动不白屏

十二、本篇小结

话题页 70 行,但它的三个决定都不在行数里:'1fr 1fr'用分数单位让列宽免于像素计算、t.id.toString()让 key 在排序后依然正确、topicStats()让热度从写死数字变成map + filter的实时聚合。目录用网格、正文用列表,加上"词表三页同名同序"的约定,这页才算真正接进了四页数据流。

下一篇 (四)我的 讲统计端:负 margin 悬浮卡的实现原理,以及三个统计数字如何从静态字符串变成按mine聚合的真实值。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 12:49:49

OpenRig 实战指南:构建本地化 Codex 兼容 AI 编程环境

1. OpenRig 是什么&#xff1a;一个被误读的开源项目命名陷阱OpenRig 这个词在当前技术社区里&#xff0c;正经历一场典型的“语义漂移”——它既不是官方发布的成熟框架&#xff0c;也不是某个知名组织背书的标准化工具&#xff0c;而更像是一组散落在 GitHub、Discourse 论坛…

作者头像 李华
网站建设 2026/10/2 12:49:39

桌面应用开发技术专题 篇八:授权和防护技术

文章目录 系列文章 架构哲学 核心硬性约束 四层立体防御模型 组件选型 底层密码学组件:信任的基石 离线授权业务 SDK:开箱即用的盾牌 硬件指纹与二进制保护:对抗逆向的迷雾 密码学算法 哈希算法(Hash):完整性校验与指纹生成 对称加密算法(Symmetric):数据加密与防窃取…

作者头像 李华