在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

一、引言

1.1 从 Demo 到实战

在前三篇 Tab 教程中,我们系统学习了 Tabs 组件的基础用法、自定义技巧和进阶能力。但理论学习的最终目的是动手构建真实的应用

本文将以一个完整的"今日热榜"应用为案例,展示如何将前面学到的所有 Tab 知识融会贯通,构建一个类今日头条风格的滚动频道信息流页面。

1.2 最终效果预览

「今日热榜」是一个包含 20 个频道、两种浏览模式、支持分类切换和骨架屏加载的新闻信息流应用。它的核心交互流程:

App 启动 → 顶部标题栏 + 频道数量提示
        → 20 个滚动频道标签(BarMode.Scrollable)
        → 当前频道的内容信息流
        → 底部状态栏(当前频道名 + 页码 + 模式)

可选:打开「分类」Toggle → 切换为「嵌套分类」模式
                          → 显示排序标签 + 刷新按钮
                          → 骨架屏加载动画
                          → 图文混合卡片

1.3 本文目标

读完本文,你将掌握:

  1. 如何用 Tabs + BarMode.Scrollable 构建大规模频道导航
  2. 如何设计可切换的两种内容模式(聚合/分类)
  3. 如何用 @Builder 构建信息流卡片
  4. 如何实现骨架屏(Skeleton)加载效果
  5. 如何用数据驱动架构管理 20 个频道的内容
  6. 真实项目中的代码组织和性能优化策略

二、项目架构设计

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)

标题栏的设计包含三个元素:

  1. 品牌名称「今日热榜」—— 加粗、深色、大字号
  2. 实时状态标签「📊 实时」—— 红色文字 + 红色闪烁圆点,暗示数据实时更新
  3. 左右布局—— 品牌名在左,状态标签在右,通过 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)是默认的内容展示方式。每个频道下显示:

  1. 频道头部:频道名称 + 更新数量
  2. 新闻卡片列表:6 条新闻卡片
  3. 底部提示:“没有更多了”

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

  1. 分类标签栏的横向 Scroll
  2. 内容列表的纵向 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 分离

所有的数据定义(channelsnewsPoolcolors)都放在结构体顶部,与 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 构造函数中正确传入了 indexcontroller

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 方法的参数必须使用具体类型(如 stringnumber),不能使用联合类型(如 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 架构设计原则

  1. 分层清晰:导航区、控制区、内容区、状态区职责分离
  2. 数据驱动:所有 UI 变化由 @State 触发
  3. 组件复用@Builder 将卡片、标签等 UI 片段封装复用
  4. 条件渲染if/else 控制不同模式的切换
  5. 关注分离:数据定义、UI 渲染、辅助方法各司其职

13.3 从实战到进阶

这个「今日热榜」应用虽然只有 312 行代码,但它涵盖了一个真实信息流 App 的核心交互模式。如果你能完全理解每一行代码的作用,那么构建更复杂的应用——比如完整的新闻客户端、社交媒体信息流、电商商品列表——都只是在这个基础上的扩展和深化。

下一步你可以尝试:

  1. 添加启动页:在 aboutToAppear 中加载网络数据
  2. 接入真实 API:用 fetchhttp 模块获取新闻数据
  3. 添加详情页:点击卡片跳转到文章详情
  4. 实现频道管理:支持用户自定义频道和排序
  5. 添加搜索功能:在标题栏集成搜索入口
  6. 适配折叠屏:在大屏上显示双栏布局

附录

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() 圆角 卡片/标签圆角
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐