鸿蒙 ArkUI 实战:从零构建“今日热榜“——一个完整的滚动频道信息流应用




一、引言
1.1 从 Demo 到实战
在前三篇 Tab 教程中,我们系统学习了 Tabs 组件的基础用法、自定义技巧和进阶能力。但理论学习的最终目的是动手构建真实的应用。
本文将以一个完整的"今日热榜"应用为案例,展示如何将前面学到的所有 Tab 知识融会贯通,构建一个类今日头条风格的滚动频道信息流页面。
1.2 最终效果预览
「今日热榜」是一个包含 20 个频道、两种浏览模式、支持分类切换和骨架屏加载的新闻信息流应用。它的核心交互流程:
App 启动 → 顶部标题栏 + 频道数量提示
→ 20 个滚动频道标签(BarMode.Scrollable)
→ 当前频道的内容信息流
→ 底部状态栏(当前频道名 + 页码 + 模式)
可选:打开「分类」Toggle → 切换为「嵌套分类」模式
→ 显示排序标签 + 刷新按钮
→ 骨架屏加载动画
→ 图文混合卡片
1.3 本文目标
读完本文,你将掌握:
- 如何用
Tabs+BarMode.Scrollable构建大规模频道导航 - 如何设计可切换的两种内容模式(聚合/分类)
- 如何用
@Builder构建信息流卡片 - 如何实现骨架屏(Skeleton)加载效果
- 如何用数据驱动架构管理 20 个频道的内容
- 真实项目中的代码组织和性能优化策略
二、项目架构设计
2.1 整体结构
「今日热榜」的页面结构分为五个层级,从上到下依次是:
┌──────────────────────────────────┐
│ 顶部标题栏 │ ← 品牌标识 + 实时状态指示
├──────────────────────────────────┤
│ 控制栏(频道数 + 模式切换) │ ← 用户控制区
├──────────────────────────────────┤
│ Tabs 频道导航(Scrollable) │ ← 20 个可滚动标签
├──────────────────────────────────┤
│ 内容区域(信息流 / 嵌套分类) │ ← 核心内容
├──────────────────────────────────┤
│ 底部状态栏 │ ← 当前频道 + 页码信息
└──────────────────────────────────┘
这种分层结构清晰地将导航、控制、内容、状态四个区域分离,每一层各司其职。
2.2 数据模型
整个页面由三个核心数据源驱动:
① 频道数据(channels)
private channels: string[] = [
'推荐', '视频', '热点', '科技', '数码',
'体育', '娱乐', '游戏', '汽车', '财经',
'军事', '教育', '时尚', '美食', '旅游',
'健康', '房产', '亲子', '宠物', '漫画',
];
20 个频道涵盖了资讯类 App 的主要分类。之所以选择 20 个,是因为这个数量能充分展示 BarMode.Scrollable 的优势——当标签数量超过屏幕宽度时,用户可以左右滑动浏览所有频道。
② 新闻内容池(newsPool)
private newsPool: string[][] = [
['全球AI峰会开幕', '市场震荡加剧', '新型电池技术突破'],
['短视频创作者大会', '年度最佳微电影', '视频创作工具更新'],
// ... 每个频道对应 3 条新闻标题
];
这是一个二维字符串数组。外层索引对应频道,内层数组存储该频道的新闻标题。设计为每个频道 3 条标题,配合卡片生成逻辑,可以渲染出丰富的列表。
③ 状态变量
@State currentIndex: number = 0; // 当前选中的频道索引
@State sortBy: number = 0; // 嵌套模式下的排序方式
@State showNested: boolean = false; // 是否显示嵌套分类模式
@State loading: boolean = false; // 加载状态(控制骨架屏显隐)
四个 @State 变量管理着页面的所有交互状态。任何状态变化都会触发 UI 的自动更新——这是 ArkUI 声明式 UI 的核心优势。
2.3 颜色系统
private colors: string[] = [
'#FFF5F5', '#F0FFF4', '#FFFFF0', '#F0F5FF', '#F5F0FF',
// ... 20 种柔和的浅色
];
每个频道的内容卡片使用不同的背景色,增加视觉多样性。颜色数组与频道数组长度一致(20 个),确保每个频道都有对应的颜色。
三、顶部导航与控制栏
3.1 品牌标题栏
Row() {
Text('今日热榜').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
Row() {
Text('📊').fontSize(16)
Text('实时').fontSize(11).fontColor('#FF6B6B').fontWeight(FontWeight.Bold)
Row().width(6).height(6).backgroundColor('#FF6B6B').borderRadius(3).margin({ left: 4 })
}
.padding({ left: 8, right: 10, top: 3, bottom: 3 })
.backgroundColor('#FFF0F0').borderRadius(10).margin({ left: 8 })
}
.width('100%').padding({ left: 16, right: 16, top: 44, bottom: 6 })
.justifyContent(FlexAlign.SpaceBetween)
标题栏的设计包含三个元素:
- 品牌名称「今日热榜」—— 加粗、深色、大字号
- 实时状态标签「📊 实时」—— 红色文字 + 红色闪烁圆点,暗示数据实时更新
- 左右布局—— 品牌名在左,状态标签在右,通过
SpaceBetween实现
padding({ top: 44 }) 为状态栏预留空间,避免内容被系统状态栏遮挡。
3.2 控制栏
Row() {
Row() {
Row().width(6).height(6).backgroundColor('#FF6B6B').borderRadius(3).margin({ right: 4 })
Text(this.channels.length + ' 频道 · 滑动切换').fontSize(11).fontColor('#bbb')
}
Row() {
Text('分类').fontSize(11).fontColor('#999').margin({ right: 4 })
Toggle({ type: ToggleType.Switch, isOn: this.showNested })
.onChange((v: boolean) => { this.showNested = v })
}
}
控制栏提供两个信息:
- 左侧:频道数量和操作提示(“20 频道 · 滑动切换”)
- 右侧:模式切换开关(“分类” Toggle)
Toggle 开关绑定 this.showNested 状态变量。当用户打开开关,showNested 变为 true,内容区域从「聚合模式」切换为「分类模式」,UI 立即响应。
四、Tabs 频道导航(Scrollable 模式)
4.1 核心实现
Tabs({ index: this.currentIndex, controller: this.controller }) {
ForEach(this.channels, (ch: string, i: number) => {
TabContent() {
if (this.showNested) {
this.NestedFeed(ch, i)
} else {
this.NewsFeed(ch, i)
}
}
.tabBar(this.ChannelTab(ch, i))
}, (ch: string) => ch)
}
.barMode(BarMode.Scrollable)
.scrollable(true)
.animationDuration(350)
.onChange((i: number) => { this.currentIndex = i })
.layoutWeight(1).width('100%')
关键配置:
| 属性 | 值 | 作用 |
|---|---|---|
barMode |
BarMode.Scrollable |
20 个标签横向可滚动 |
scrollable |
true |
内容区可左右滑动切换频道 |
animationDuration |
350 |
切换动画时长 350ms |
layoutWeight |
1 |
Tabs 填充剩余所有空间 |
4.2 频道标签设计
@Builder
ChannelTab(ch: string, i: number) {
Column({ space: 3 }) {
Text(ch).fontSize(14)
.fontColor(i === this.currentIndex ? '#FF6B6B' : '#666')
.fontWeight(i === this.currentIndex ? FontWeight.Bold : FontWeight.Regular)
if (i === this.currentIndex) {
Row().width(18).height(3).backgroundColor('#FF6B6B').borderRadius(2)
}
}
.width('100%').padding({ top: 10, bottom: 12 })
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
标签设计遵循清晰的视觉层级:
- 选中态:红色 + 加粗 + 底部 3vp 红色横线指示器
- 未选中态:灰色 + 正常字重
指示器使用 if (i === this.currentIndex) 条件渲染——只在选中时显示。width(18).height(3) 的尺寸设计让指示器简洁不张扬。
4.3 数据驱动内容
ForEach 遍历 this.channels 数组生成 20 个 TabContent:
ForEach(this.channels, (ch: string, i: number) => {
TabContent() { ... }
.tabBar(this.ChannelTab(ch, i))
}, (ch: string) => ch)
第三个参数 (ch: string) => ch 是键值生成器,ArkUI 用它来追踪每个 Tab 的身份。当数组内容变化时(但本示例中 channels 是静态的),键值帮助框架精准地增删改对应的组件。
性能提示:即使 channels 是静态的,提供稳定的键值也是一个好习惯。它让框架知道"这些都是不同的实体",避免因索引变化导致的渲染异常。
五、聚合模式:新闻信息流
5.1 模式概述
聚合模式(NewsFeed)是默认的内容展示方式。每个频道下显示:
- 频道头部:频道名称 + 更新数量
- 新闻卡片列表:6 条新闻卡片
- 底部提示:“没有更多了”
5.2 信息流容器
@Builder
NewsFeed(ch: string, idx: number) {
Scroll() {
Column() {
// 频道头部
Row() {
Text(ch).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333')
Text('今日更新 36 条').fontSize(11).fontColor('#bbb').margin({ left: 8 })
}
.width('100%').padding({ left: 4, bottom: 8 })
// 新闻卡片
ForEach([1, 2, 3, 4, 5, 6], (n: number) => {
this.NewsCard(ch, idx, n)
}, (n: number) => ch + '_c' + n)
// 底部提示
Row() {
Row().width('30%').height(1).backgroundColor('#eee')
Text('没有更多了').fontSize(12).fontColor('#ddd')
Row().width('30%').height(1).backgroundColor('#eee')
}
.width('100%').justifyContent(FlexAlign.Center).padding(16)
}
.width('100%').padding(16)
}
}
Scroll + Column 的组合是 ArkUI 中最常见的滚动列表实现方式。Scroll 提供滚动能力,Column 提供垂直排列布局。
5.3 新闻卡片设计
@Builder
NewsCard(ch: string, idx: number, n: number) {
Column() {
// 标题
Text(this.getNews(ch, n)).fontSize(16)
.fontWeight(FontWeight.Medium).fontColor('#222')
.lineHeight(24).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// 摘要
Text('据相关媒体报道,这是来自「' + ch + '」频道的第 ' + n + ' 条资讯。')
.fontSize(13).fontColor('#888').margin({ top: 6 })
.lineHeight(20).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
// 底部信息栏
Row() {
Row() {
Text('🏷️ ' + ch).fontSize(10).fontColor('#FF6B6B')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#FFF0F0').borderRadius(4)
}
Row({ space: 10 }) {
Text('👁️ ' + this.fmt(Math.floor(Math.random() * 80000 + 5000)))
.fontSize(11).fontColor('#bbb')
Text('💬 ' + Math.floor(Math.random() * 300 + 10))
.fontSize(11).fontColor('#bbb')
Text('⏱️ ' + (Math.floor(Math.random() * 50 + 1)) + '分钟前')
.fontSize(11).fontColor('#ccc')
}
}
.width('100%').justifyContent(FlexAlign.SpaceBetween).margin({ top: 10 })
// 分割线
Row().width('100%').height(1).backgroundColor('#f0f0f0').margin({ top: 12 })
}
.width('100%').padding(14)
.backgroundColor('#fff').borderRadius(12)
.shadow({ radius: 4, color: '#04000000', offsetY: 2 })
.margin({ bottom: 8 })
}
新闻卡片采用三段式结构:
第一段:标题
- 字号 16fp,中粗字重,深色文字
maxLines(2)限制最多显示两行textOverflow(Ellipsis)超出时显示省略号lineHeight(24)保证行间距舒适
第二段:摘要
- 字号 13fp,灰色文字,两行限制
- 内容为格式化字符串,包含频道名和序号
第三段:底部信息栏
- 左侧:频道标签(红色背景胶囊按钮)
- 右侧:阅读量(👁️)、评论数(💬)、发布时间(⏱️)
- 使用
SpaceBetween左右分栏布局
视觉包装:
- 白色背景卡片 +
borderRadius(12)圆角 - 轻微阴影
shadow({ radius: 4, color: '#04000000', offsetY: 2 }) - 底部 8vp 间距分隔卡片
🎨 卡片设计原则:信息密度适中,视觉呼吸感强。标题是用户第一眼关注的内容,放在最上方;摘要提供更多上下文;底部信息区放置辅助数据。
5.4 标题数据获取
getNews(ch: string, n: number): string {
let idx: number = this.channels.indexOf(ch)
if (idx < 0) { idx = 0 }
let pool: string[] = this.newsPool[idx % this.newsPool.length]
return pool[(n - 1) % pool.length]
}
这个方法通过频道名查找对应的新闻标题池,然后根据序号 n 取出对应的标题。(n - 1) % pool.length 确保序号循环使用新闻池中的标题。
六、分类模式:嵌套 Tabs 与排序
6.1 模式切换
当用户打开控制栏的「分类」Toggle 开关时:
if (this.showNested) {
this.NestedFeed(ch, i)
} else {
this.NewsFeed(ch, i)
}
@State showNested 的变化触发条件渲染,内容区域在「聚合模式」和「分类模式」之间切换。
6.2 分类标签栏
Scroll() {
Row({ space: 8 }) {
ForEach(this.sortTabs, (label: string, si: number) => {
Text(label).fontSize(13)
.fontColor(si === this.sortBy ? '#fff' : '#666')
.padding({ left: 16, right: 16, top: 6, bottom: 6 })
.backgroundColor(si === this.sortBy ? '#FF6B6B' : '#f0f0f0')
.borderRadius(16)
.onClick(() => { this.sortBy = si })
}, (label: string) => label)
Text('🔄').fontSize(16).width(30).height(30)
.backgroundColor('#f5f5f5').borderRadius(15)
.textAlign(TextAlign.Center)
.onClick(() => {
this.loading = true
setTimeout(() => { this.loading = false }, 800)
})
}
.width('100%').padding({ left: 4 })
}
.scrollable(ScrollDirection.Horizontal)
分类标签栏包括三个排序按钮和一个刷新按钮:
- 推荐排序:按推荐算法排列
- 最新发布:按时间倒序排列
- 最多阅读:按热度排列
- 🔄 刷新按钮:触发骨架屏加载动画
标签同样使用条件渲染区分选中态——红色填充表示选中,灰色填充表示未选中。borderRadius(16) 创造出胶囊按钮的视觉效果。
Scroll 组件的 .scrollable(ScrollDirection.Horizontal) 允许分类标签在内容过多时横向滚动。
6.3 骨架屏加载
if (this.loading) {
Column({ space: 10 }) {
ForEach([1, 2, 3], (s: number) => {
this.SkeletonCard()
}, (s: number) => 'sk' + s)
}.width('100%')
} else {
// 显示实际内容
}
点击刷新按钮时,this.loading 设为 true,内容区域切换为骨架屏:
@Builder
SkeletonCard() {
Row() {
Row().width(64).height(64).backgroundColor('#f0f0f0').borderRadius(10)
Column({ space: 6 }) {
Row().width('80%').height(14).backgroundColor('#f0f0f0').borderRadius(4)
Row().width('60%').height(12).backgroundColor('#f5f5f5').borderRadius(4)
Row().width('40%').height(10).backgroundColor('#f8f8f8').borderRadius(4)
}
.layoutWeight(1).margin({ left: 10 })
}
.width('100%').padding(12).backgroundColor('#fff').borderRadius(10)
}
骨架屏(Skeleton Screen)是一种加载占位技术。在数据尚未加载完成时,用灰色方块模拟内容的轮廓,给用户"内容正在加载"的心理预期,避免空白页面的突兀感。
三个灰色条的宽度递减(80% → 60% → 40%),模拟了标题、摘要、副文本的视觉层次。
⏱️ 加载模拟:实际项目中,
setTimeout应替换为真实的数据请求。这里用 800ms 的延时来展示骨架屏的过渡效果。
6.4 分类内容卡片
@Builder
NestedCard(ch: string, idx: number, n: number) {
Row() {
// 左侧图标
Column() {
Text(['📰', '📱', '💻', '🎮'][n % 4]).fontSize(28)
}
.width(64).height(64)
.backgroundColor(this.colors[(idx + n * 3) % this.colors.length])
.borderRadius(10)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
// 右侧文字
Column({ space: 4 }) {
Text(this.getNews(ch, n)).fontSize(15)
.fontWeight(FontWeight.Medium).fontColor('#222')
.lineHeight(22).maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Row({ space: 8 }) {
Text(['新华社', '澎湃新闻', '科技日报', '环球网'][n % 4])
.fontSize(10).fontColor('#bbb')
Text(Math.floor(Math.random() * 50 + 1) + '分钟前')
.fontSize(10).fontColor('#ccc')
}.width('100%')
}
.layoutWeight(1).margin({ left: 10 })
.alignItems(HorizontalAlign.Start).justifyContent(FlexAlign.Center)
}
.width('100%').padding(12)
.backgroundColor('#fff').borderRadius(10)
.shadow({ radius: 3, color: '#04000000', offsetY: 1 })
.margin({ bottom: 8 })
}
分类模式的卡片采用左图右文布局,与聚合模式的全文字卡片形成视觉差异:
- 左侧:64×64vp 的色块 + emoji 图标,模拟新闻缩略图
- 右侧:标题 + 来源 + 时间
配色使用 this.colors[(idx + n * 3) % this.colors.length] 计算,保证每个频道、每条新闻都有不同的背景色。(idx + n * 3) 的算法让颜色在频道和序号两个维度上都有变化。
6.5 两种模式对比
| 对比维度 | 聚合模式(NewsFeed) | 分类模式(NestedFeed) |
|---|---|---|
| 卡片布局 | 纯文字卡片 | 左图右文 |
| 卡片数量 | 6 条 | 4 条 |
| 排序功能 | 无 | 3 种排序 + 刷新 |
| 加载动画 | 无 | 骨架屏 |
| 信息密度 | 高(含阅读/评论/时间) | 中(含来源/时间) |
| 视觉风格 | 信息流式 | 图文混排 |
两种模式满足不同的浏览需求:聚合模式适合快速刷信息,分类模式适合精确查找内容。
七、底部状态栏
Row() {
Row() {
Text('🔥').fontSize(10)
Text(this.channels[this.currentIndex]).fontSize(11).fontColor('#FF6B6B')
.fontWeight(FontWeight.Bold)
Text(' · ' + (this.currentIndex + 1) + '/' + this.channels.length)
.fontSize(11).fontColor('#ccc')
}
Text(this.showNested ? '分类模式' : '聚合模式').fontSize(11).fontColor('#ddd')
}
.width('100%').justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 16, right: 16, top: 6, bottom: 10 })
底部状态栏提供当前浏览位置的上下文信息:
- 左侧:🔥 + 当前频道名 + 页码(如 “推荐 · 1/20”)
- 右侧:当前模式名称(“聚合模式” / “分类模式”)
页码格式 (this.currentIndex + 1) + '/' + this.channels.length 清晰地告诉用户当前所在位置和总频道数。
八、数据流与状态管理
8.1 数据流图
用户点击频道标签
↓
Tabs.onChange 触发
↓
this.currentIndex 更新
↓
Content 区域使用新索引渲染
ChannelTab 高亮新标签
底部状态栏更新频道名和页码
用户切换分类开关
↓
this.showNested 更新
↓
if 条件切换 NewsFeed / NestedFeed
8.2 状态变量生命周期
| 变量 | 类型 | 初始值 | 变化时机 |
|---|---|---|---|
currentIndex |
number |
0 | 用户点击/滑动切换频道 |
sortBy |
number |
0 | 用户点击排序标签 |
showNested |
boolean |
false | 用户操作 Toggle 开关 |
loading |
boolean |
false | 用户点击刷新按钮 |
所有状态都用 @State 装饰,确保任何变化都能触发 UI 更新。
8.3 无状态组件:纯 UI 方法
getNews() 和 fmt() 是纯函数(无副作用),不依赖 @State 变量,只是根据输入参数计算返回值。这类方法应该保持无状态、可测试、无副作用。
getNews(ch: string, n: number): string {
let idx: number = this.channels.indexOf(ch)
if (idx < 0) { idx = 0 }
let pool: string[] = this.newsPool[idx % this.newsPool.length]
return pool[(n - 1) % pool.length]
}
fmt(n: number): string {
if (n >= 10000) return (n / 10000).toFixed(1) + '万'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return n.toString()
}
fmt() 方法实现了数字格式化:
- 10000+ → “1.0万”
- 1000+ → “1.0k”
- 其他 → 原样输出
这在显示阅读量等数据时非常实用。
九、性能优化分析
9.1 TabContent 的渲染策略
ArkUI 的 Tabs 组件采用同时创建、按需显示的策略——所有 TabContent 在初始化时都被创建,但只有当前激活的 TabContent 是可见的。
这意味着:
- 优点:切换 Tab 时没有创建延迟,瞬间切换
- 缺点:20 个 TabContent 同时存在,每个包含 6 张卡片,总计 120 张卡片
对于当前的轻量级卡片(纯文字 + 简单布局),120 张卡片的内存开销在可接受范围内。但如果每个卡片包含图片、复杂交互,就需要考虑优化。
9.2 条件渲染 vs 始终渲染
在嵌套模式中,我们使用了 if/else 条件渲染:
if (this.showNested) {
this.NestedFeed(ch, i)
} else {
this.NewsFeed(ch, i)
}
这意味着每次切换模式时,旧的 Builder 组件树被销毁,新的被创建。如果内容非常重,这种切换可能会有延迟。但对于我们的场景,切换瞬间完成,不需要额外优化。
9.3 ForEach 键值的重要性
ForEach(this.channels, (ch: string, i: number) => {
TabContent() { ... }
}, (ch: string) => ch)
稳定的键值(ch 字符串本身)确保 ArkUI 能正确识别每个 Tab。如果使用 i(索引)作为键值,当数组变化时可能会出现渲染错乱。
9.4 Scroll 嵌套的性能考量
在嵌套模式中,我们有两层 Scroll:
- 分类标签栏的横向 Scroll
- 内容列表的纵向 Scroll
两层 Scroll 嵌套在 ArkUI 中是支持的,但需要注意手势冲突——当用户在内容区上下滑动时,不会误触横向标签的滚动。
十、代码组织与可维护性
10.1 @Builder 拆分策略
整个页面被拆分为 6 个 @Builder 方法:
| Builder | 职责 | 调用方 |
|---|---|---|
ChannelTab |
渲染频道标签 | Tabs.tabBar |
NewsFeed |
聚合模式信息流 | TabContent |
NewsCard |
新闻卡片(聚合模式) | NewsFeed |
NestedFeed |
分类模式内容 | TabContent |
NestedCard |
图文卡片(分类模式) | NestedFeed |
SkeletonCard |
骨架屏占位 | NestedFeed |
每个 Builder 只做一件事,职责单一。这种拆分方式让代码易于理解和修改。
10.2 数据与 UI 分离
所有的数据定义(channels、newsPool、colors)都放在结构体顶部,与 UI 代码分离。如果需要修改频道列表或新闻内容,只需要改数据部分,不需要动 UI 代码。
10.3 常量管理
private channels: string[] = [ /* 20 个频道 */ ];
private sortTabs: string[] = ['推荐排序', '最新发布', '最多阅读'];
private colors: string[] = [ /* 20 种颜色 */ ];
private newsPool: string[][] = [ /* 20 个频道 × 3 条新闻 */ ];
所有的「魔法值」都被提取为命名清晰的成员变量。这不仅提高了代码可读性,也为后续的国际化、主题定制等需求做好了准备。
十一、真实项目中的扩展方向
11.1 网络数据接入
当前示例使用本地硬编码数据。真实项目应该接入网络 API:
@State newsData: Map<string, NewsItem[]> = new Map()
aboutToAppear() {
this.fetchChannels()
}
fetchChannels() {
// 调用网络 API 获取频道列表和新闻数据
fetch('https://api.example.com/channels')
.then(response => response.json())
.then(data => {
this.newsData = data
})
}
11.2 图片加载
卡片中的 emoji 可以替换为真实图片:
Image({ src: item.thumbnailUrl })
.width(64).height(64)
.borderRadius(10)
.objectFit(ImageFit.Cover)
使用 Image 组件加载网络图片,并通过 objectFit(ImageFit.Cover) 控制图片裁剪方式。
11.3 下拉刷新
Scroll() {
// 内容
}
.scrollable(ScrollDirection.Vertical)
.onReachStart(() => {
// 触发下拉刷新
this.loading = true
this.refreshData()
})
通过 Scroll 组件的 onReachStart 事件检测下拉动作,触发数据刷新。
11.4 点击跳转详情
@Builder
NewsCard(ch: string, idx: number, n: number) {
Column() {
// 卡片内容
}
.onClick(() => {
router.pushUrl({
url: 'pages/NewsDetail',
params: { channel: ch, id: n }
})
})
}
为卡片添加点击事件,跳转到新闻详情页面,并传递频道和文章 ID 参数。
11.5 频道管理
实际应用中,用户可能需要自定义频道顺序:
@State userChannels: string[] = ['推荐', '视频', '热点'] // 用户自定义顺序
// 支持拖拽排序
onMove(from: number, to: number) {
let arr = [...this.userChannels]
arr.splice(to, 0, arr.splice(from, 1)[0])
this.userChannels = arr
}
十二、常见问题与调试
12.1 Tabs 切换不触发 onChange
检查:是否在 Tabs 构造函数中正确传入了 index 和 controller。
Tabs({ index: this.currentIndex, controller: this.controller })
确保 this.controller 是成员变量(private controller = new TabsController()),而不是在 build() 中创建的局部变量。
12.2 Scrollable 标签无法滚动
检查:是否设置了 .barMode(BarMode.Scrollable)。
如果使用默认的 BarMode.Fixed,所有标签等宽占满整行,无法滚动。只有在 Scrollable 模式下才能滚动。
12.3 骨架屏不显示
检查:this.loading 是否正确更新。
.onClick(() => {
this.loading = true
setTimeout(() => { this.loading = false }, 800)
})
确保 this.loading 是 @State 变量,并且值确实发生了变化。
12.4 卡片中的随机数据导致渲染闪烁
问题:每次状态更新时,Math.random() 重新计算,导致数字变化。
解决方案:在数据初始化时生成固定值,而不是在渲染时计算。
interface NewsItem {
title: string;
views: number;
comments: number;
time: string;
}
12.5 @Builder 参数类型限制
在 ArkUI 中,@Builder 方法的参数必须使用具体类型(如 string、number),不能使用联合类型(如 string | number)。
// ❌ 不支持的写法
@Builder BadBuilder(text: string | number) { ... }
// ✅ 正确的写法
@Builder GoodBuilder(text: string) { ... }
@Builder GoodBuilder2(num: number) { ... }
十三、小结
13.1 核心知识点回顾
本文通过「今日热榜」这个真实案例,展示了以下核心技术的综合运用:
| 技术点 | 应用位置 | 核心作用 |
|---|---|---|
Tabs + BarMode.Scrollable |
频道导航 | 20 个频道横向滚动 |
TabContent + ForEach |
频道内容 | 数据驱动生成页面内容 |
@Builder 组件拆分 |
6 个 Builder | UI 代码模块化 |
@State 状态管理 |
4 个状态变量 | 数据驱动 UI 更新 |
条件渲染 if/else |
模式切换 | 动态切换聚合/分类模式 |
Scroll 滚动容器 |
信息流 | 内容纵向滚动 |
| 骨架屏 Skeleton | 加载状态 | 提升用户体验 |
maxLines + textOverflow |
卡片标题 | 文字截断控制 |
| 链式 API 调用 | 全部 UI | 简洁的声明式语法 |
SpaceBetween 布局 |
多处分栏 | 左右两端对齐 |
13.2 架构设计原则
- 分层清晰:导航区、控制区、内容区、状态区职责分离
- 数据驱动:所有 UI 变化由
@State触发 - 组件复用:
@Builder将卡片、标签等 UI 片段封装复用 - 条件渲染:
if/else控制不同模式的切换 - 关注分离:数据定义、UI 渲染、辅助方法各司其职
13.3 从实战到进阶
这个「今日热榜」应用虽然只有 312 行代码,但它涵盖了一个真实信息流 App 的核心交互模式。如果你能完全理解每一行代码的作用,那么构建更复杂的应用——比如完整的新闻客户端、社交媒体信息流、电商商品列表——都只是在这个基础上的扩展和深化。
下一步你可以尝试:
- 添加启动页:在
aboutToAppear中加载网络数据 - 接入真实 API:用
fetch或http模块获取新闻数据 - 添加详情页:点击卡片跳转到文章详情
- 实现频道管理:支持用户自定义频道和排序
- 添加搜索功能:在标题栏集成搜索入口
- 适配折叠屏:在大屏上显示双栏布局
附录
A:完整源码结构
NewsChannel.ets (312 行)
├── 数据定义 (L1-47)
│ ├── @State 状态变量
│ ├── TabsController
│ ├── channels (20 个频道)
│ ├── sortTabs (3 种排序)
│ ├── colors (20 种颜色)
│ └── newsPool (20×3 新闻标题)
├── build() 主布局 (L49-110)
│ ├── 顶部标题栏
│ ├── 控制栏
│ ├── Tabs 频道导航
│ └── 底部状态栏
├── @Builder ChannelTab (L113-125)
├── @Builder NewsFeed (L128-151)
├── @Builder NewsCard (L154-192)
├── @Builder NestedFeed (L195-247)
├── @Builder NestedCard (L250-282)
├── @Builder SkeletonCard (L285-297)
├── getNews() (L300-305)
└── fmt() (L308-311)
B:API 速查
| API | 用途 | 在本项目中的使用 |
|---|---|---|
Tabs({ index, controller }) |
标签页容器 | 频道导航 |
TabContent() { }.tabBar() |
标签页 + 标签 | 每个频道对应的内容 |
.barMode(BarMode.Scrollable) |
可滚动标签 | 20 个频道横向滑动 |
.scrollable(boolean) |
页面滑动切换 | 内容区左右滑动 |
.animationDuration(ms) |
切换动画时长 | 350ms 平滑切换 |
.onChange(callback) |
切换事件 | 更新 currentIndex |
Scroll() |
可滚动容器 | 信息流纵向滚动 |
ForEach(arr, builder, key) |
列表渲染 | 生成频道和卡片 |
@Builder |
自定义构建函数 | 6 个 UI 片段 |
@State |
状态管理 | 4 个交互状态 |
if/else |
条件渲染 | 模式切换/骨架屏 |
maxLines(n) |
文字行数限制 | 标题最多两行 |
textOverflow(Ellipsis) |
超出省略号 | 标题截断 |
shadow() |
阴影 | 卡片立体感 |
borderRadius() |
圆角 | 卡片/标签圆角 |
更多推荐



所有评论(0)