HarmonyOS APP《画伴梦工厂》开发第8篇:组合式 API 实践——从零搭建一个 Tab 主页

第1.8篇:组合式 API 实践------从零搭建一个 Tab 主页

难度 :⭐⭐ 进阶 | 前置知识:1.1 ~ 1.7

涉及源文件

  • products/default/src/main/ets/pages/Index.ets

本文定位:第一章综合实战篇,汇总前 7 篇文章的知识点,分析如何用 ArkUI 的组合式 API 搭建一个完整的 Tab 主页。


1. 引言

前 7 篇文章我们分别学习了:

  • 1.1 ArkUI 声明式 UI 基础
  • 1.2 常用组件与布局
  • 1.3 @State 与状态管理
  • 1.4 @Builder 封装复用
  • 1.5 @StorageLinkBreakpointSystem 响应式布局
  • 1.6 页面路由与生命周期
  • 1.7 资源管理与国际化

现在,是时候把它们融会贯通了。

「画伴梦工厂」的 Index.ets 是整个应用中最复杂的页面,包含了底部三 Tab 导航、首页内容区、AI 聊天创作页、作品播放详情页、成长分析页、我的世界页等多个功能模块。本篇将以它为例,展示如何用 ArkUI 的组合式 API 搭建一个结构清晰、功能完整的主页。

2. 架构总览

Index.ets 的页面结构可以用一棵树表示:

复制代码
Index (build)
├─ Stack (Bottom alignment)
│  ├─ Column
│  │  └─ CurrentPage() ← 根据状态切换
│  │     ├─ HomePage()     [currentTab === 0]
│  │     ├─ CreationPage() [currentTab === 1]
│  │     ├─ WorldPage()    [currentTab === 2]
│  │     ├─ WorksPage()    [secondaryPage === 1]
│  │     └─ AnalysisPage() [secondaryPage === 2]
│  └─ BottomBar() ← 底部导航栏

核心设计思想:通过 @State 状态控制所有页面的切换,避免实际的路由跳转,从而实现平滑的 Tab 切换体验。

3. 核心状态设计

typescript 复制代码
@Entry
@Component
struct Index {
  // ── Tab 控制 ──
  @State private currentTab: number = 0;      // 0:首页, 1:创作, 2:我的
  @State private secondaryPage: number = 0;   // 0:无, 1:作品, 2:分析

  // ── 数据状态 ──
  @State private savedWorks: VideoWork[] = [];
  @State private chatMessages: ChatMessage[] = [...];
  @State private selectedWorkIndex: number = 0;
  @State private videoProgress: number = 0;
  // ... 20+ 个 @State 变量

  // ── 响应式断点 ──
  @StorageLink('mainBreakpoint') currentBreakpoint: string = 'md';
}

洞察 :这是一个"大状态树"的设计模式------所有 UI 状态集中在一个组件内,通过 @State 驱动。优点是状态一致性容易保证;缺点是组件体积较大。对于中型应用,这种模式是完全可以接受的。

3.1 页面切换逻辑

typescript 复制代码
private openPrimaryTab(index: number): void {
  this.currentTab = Math.max(0, Math.min(NAV_ITEMS.length - 1, index));
  this.secondaryPage = SECONDARY_NONE;  // 关闭次级页面
  this.noticeText = '';
}

private openWorksSecondary(): void {
  this.secondaryPage = SECONDARY_WORKS;
  this.refreshWorks();
}

private closeSecondaryPage(): void {
  this.secondaryPage = SECONDARY_NONE;
}

CurrentPage() Builder 根据状态决定渲染哪个页面:

typescript 复制代码
@Builder
private CurrentPage() {
  if (this.secondaryPage === SECONDARY_WORKS) {
    this.WorksPage()
  } else if (this.secondaryPage === SECONDARY_ANALYSIS) {
    this.AnalysisPage()
  } else if (this.currentTab === 0) {
    this.HomePage()
  } else if (this.currentTab === 1) {
    this.CreationPage()
  } else {
    this.WorldPage()
  }
}

4. Tab 导航设计

底部 Tab 的每个图标使用 @Builder NavIcon 封装:

typescript 复制代码
@Builder
private NavIcon(index: number) {
  Column() {
    // 图标符号
    Text(NAV_ICONS[index])
      .fontSize(20)
      .fontColor(this.isPrimaryTabActive(index) ? this.brandPurple : '#8E94A6')

    // 文字标签
    Text(NAV_ITEMS[index])
      .fontSize(12)
      .fontColor(this.isPrimaryTabActive(index) ? this.brandPurple : '#747A92')
  }
  .layoutWeight(1)
  .backgroundColor(this.isPrimaryTabActive(index) ? '#FFFFFF' : Color.Transparent)
  .borderRadius(24)
  .onClick(() => { this.openPrimaryTab(index); })
}

4.2 BottomBar 容器

typescript 复制代码
@Builder
private BottomBar() {
  Stack({ alignContent: Alignment.Center }) {
    Row() {
      this.NavIcon(0)  // 首页
      this.NavIcon(1)  // 创作
      this.NavIcon(2)  // 我的
    }
    .width('100%')
    .height(54)
  }
  .width(this.bottomBarWidth())  // 响应式宽度
  .height(54)
  .backgroundColor('#FEF4FC')
  .backgroundBlurStyle(BlurStyle.BACKGROUND_THIN)
  .borderRadius(33)
  .shadow({ radius: 22, color: '#1A000000', offsetY: 8 })
}

设计亮点 :底部导航采用半透明毛玻璃效果(backgroundBlurStyle),并响应不同屏幕宽度(bottomBarWidth() 根据断点返回不同百分比),兼顾美观和适配。

5. 首页组合(HomePage)

首页是三个 Tab 中最复杂的页面,采用分层 @Builder 结构:

复制代码
HomePage()
├─ GridRow + GridCol (响应式网格)
│  └─ Hero 区域 (Stack)
│     ├─ Image (背景图)
│     ├─ 标题文字
│     └─ FeatureCard × 2 (拍照识别 / 自由涂鸦)
├─ SectionTitle('创作广场')
├─ 水平滚动作品列表 (Scroll + Row + WorkThumbnail)
├─ SectionTitle('今日灵感')
├─ InspirationPanel
└─ SafetyPanel

5.1 Hero 大图区

typescript 复制代码
GridRow({ columns: { sm: 4, md: 8, lg: 12 } }) {
  GridCol({ span: { sm: 4, md: 8, lg: 12 } }) {
    Stack({ alignContent: Alignment.TopStart }) {
      Image($r('app.media.hero_portal'))
        .width('100%')
        .height(this.heroHeight())        // 响应式高度
        .objectFit(ImageFit.Cover)
        .borderRadius(26)

      Column() {
        Text('让孩子笔下的')
          .fontSize(this.heroTitleSize()) // 响应式字号
        Text('画作活起来')
          .fontSize(this.heroTitleSize())
      }
      // 入口卡片定位在 Hero 底部的 FeatureCard
      Row() {
        this.FeatureCard('拍照识别', '把绘画变成动画', '', '#E5E2FB', 0)
        Blank().width(60)
        this.FeatureCard('自由涂鸦', '画布直达生成', '', '#FCF1DF', 1)
      }
      .position({ x: this.pageEdge() * 2 - 10, y: this.heroFeatureY() })
    }
  }
}

5.2 FeatureCard 入口卡片

typescript 复制代码
@Builder
private FeatureCard(title: string, desc: string, icon: string, color: string, modeIndex: number) {
  Row() {
    Column() {
      Text(title).fontSize(15).fontWeight(FontWeight.Bold).fontColor(this.ink)
      Text(desc).fontSize(12).fontColor('#8A8FA4').margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .margin({ left: 8 })
  }
  .layoutWeight(1)
  .height(72)
  .backgroundColor(color)
  .borderRadius(12)
  .shadow({ radius: 12, color: '#18000000', offsetY: 4 })
  .onClick(() => { this.openCreationFlow(modeIndex); })
}

5.3 水平滚动作品列表

typescript 复制代码
Scroll() {
  Row() {
    ForEach(WORKS, (item: Artwork) => {
      this.WorkThumbnail(item)
    }, (item: Artwork) => item.id.toString())
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.height(126)

每个缩略图包含一个彩色背景占位 + 图片封面:

typescript 复制代码
@Builder
private WorkThumbnail(item: Artwork) {
  Column() {
    Stack() {
      Rect().width(96).height(78).fill(item.color).radius(12)
      Image(this.getWorkCover(item.id - 1)).width(96).height(78).borderRadius(12)
    }
    Text(item.title).fontSize(11).maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .onClick(() => {
    this.selectWork(item.id - 1);
    this.openWorksSecondary();
  })
}

6. 创作页组合(CreationPage)

创作页是 AI 对话交互的核心页面,由三部分组成:

复制代码
CreationPage()
├─ Header('智能问答', subtitle, showBack=true)
├─ Scroll (消息列表)
│  └─ ChatBubble × N (用户消息 / AI 回复)
└─ ChatInputBar (底部输入区,覆盖在 Scroll 之上)

6.1 消息气泡 ChatBubble

typescript 复制代码
@Builder
private ChatBubble(message: ChatMessage) {
  Column() {
    Text(message.text).fontSize(13).lineHeight(20)

    if (message.imageUri !== '') {
      Image(message.imageUri).width('100%').height(168).borderRadius(14)
      Row() {
        Button('重新生成').onClick(() => { /* ... */ })
        Button('去生成视频').onClick(() => { this.generateVideoFromChat(message); })
      }
    }
  }
  .width(message.role === 'user' ? '82%' : '100%')
  .backgroundColor(message.role === 'user' ? this.brandPurple : '#FFFFFF')
  .borderRadius(16)
  .margin({
    left: message.role === 'user' ? 54 : 0,
    right: message.role === 'user' ? 0 : 28
  })
}

6.2 底部输入区 ChatInputBar

输入区固定在 Stack 底部,覆盖在消息列表之上:

typescript 复制代码
@Builder
private ChatInputBar() {
  Column() {
    // 语音状态提示
    if (this.voiceStatus !== '') { /* 状态行 */ }

    Row() {
      Button('语音').onClick(() => { this.startVoiceInput(); })
      TextInput({ placeholder: '说出角色、场景和故事', text: this.chatInput })
      Button('发送').onClick(() => { this.sendChatPrompt(); })
    }
  }
  .padding({ left: 16, right: 16, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .shadow({ radius: 18, color: '#18000000', offsetY: -5 })
  .margin({ bottom: 82 })  // 避免被底部 Tab 遮挡
}

设计技巧StackBottom 对齐 + margin({ bottom: 82 }) 确保输入区悬浮在底部 Tab 栏之上,同时消息列表滚动到底部时不会被遮挡。

7. 作品详情页(WorksPage)

作品播放页是次级页面(secondaryPage === SECONDARY_WORKS),完整结构如下:

复制代码
WorksPage()
├─ Header('动画播放', title, showBack=true)
├─ Stack (播放器 + 进度条覆盖层)
│  ├─ AnimationVideoScene (Video 组件)
│  └─ 进度条 Row (播放/暂停 + 时间 + Progress)
├─ 作品信息卡片
│  ├─ 标题 + 编辑按钮
│  ├─ 类型标签 + 日期 + 评分
│  ├─ Pill 标签云
│  ├─ 故事描述
│  └─ ActionButton × 5 (重播/收藏/分享/碰一碰/下载)
└─ SectionTitle('推荐作品')
   └─ 水平推荐列表

7.1 Video 组件与进度控制

typescript 复制代码
Video({
  src: this.getWorkVideo(this.selectedWorkIndex),
  previewUri: this.getCurrentWorkCover(),
  controller: this.videoController
})
.width('100%')
.height(this.videoSceneHeight())
.controls(false)           // 隐藏默认控件
.autoPlay(true)
.onPrepared((event) => {
  this.videoDuration = Math.max(1, event.duration);
})
.onUpdate((event) => {
  this.videoProgress = Math.min(this.videoDuration, event.time);
})
.onFinish(() => {
  this.isPlaying = false;
  this.videoProgress = this.videoDuration;
})

使用自定义进度条覆盖层代替系统控件,提供更统一的视觉体验:

typescript 复制代码
Row() {
  Text(this.isPlaying ? 'II' : '>')  // 播放/暂停
  Text(this.formatVideoTime(this.videoProgress))
  Progress({ value: this.videoProgress, total: this.videoDuration })
  Text(this.formatVideoTime(this.videoDuration))
}

7.2 操作按钮

typescript 复制代码
Row() {
  this.ActionButton('重播', 'R')
  this.ActionButton('收藏', 'F')
  this.ActionButton('分享', 'S')
  this.ActionButton('碰一碰', 'H')
  this.ActionButton('下载', 'D')
}

每个按钮的激活状态由对应的 @State 布尔变量控制:

typescript 复制代码
@Builder
private ActionButton(text: string, icon: string) {
  Column() {
    this.IconBubble(icon, 36,
        (text === '收藏' && this.favoriteActive) ||
        (text === '分享' && this.sharedActive) ||
        (text === '碰一碰' && this.knockShareReady) ||
        (text === '下载' && this.downloadedActive))
    Text(text).fontSize(11).fontColor(this.ink).margin({ top: 6 })
  }
  .onClick(() => {
    if (text === '重播') this.replayVideo();
    else if (text === '收藏') this.favoriteActive = !this.favoriteActive;
    else if (text === '分享') this.shareCurrentVideo();
    else if (text === '碰一碰') this.enableKnockShare();
    else this.saveCurrentVideo();
  })
}

8. 响应式适配细节

页面中大量使用 BreakPointType 实现响应式布局:

typescript 复制代码
private pageEdge(): number {
  return new BreakPointType<number>({ sm: 12, md: 24, lg: 36, xl: 56 })
    .getValue(this.currentBreakpoint);
}

private panelWidth(): string {
  return new BreakPointType<string>({ sm: '90%', md: '86%', lg: '74%', xl: '64%' })
    .getValue(this.currentBreakpoint);
}

private heroHeight(): number {
  return new BreakPointType<number>({ sm: 420, md: 430, lg: 460, xl: 480 })
    .getValue(this.currentBreakpoint);
}

规律总结:小屏幕用更大的百分比宽度(90%),大屏幕用更小的百分比(64%),配合更大的内边距和字号,让布局在手机、平板、折叠屏上都呈现最佳视觉效果。

9. 知识链路回顾

通过 Index.ets 的完整分析,我们可以清楚地看到前 7 篇文章的知识点如何组合在一起:

知识点 所在文章 在 Index.ets 中的应用
@Entry / @Component 1.1 组件声明 (struct Index)
基础组件 (Text/Image/Stack/Column) 1.2 页面中几乎所有 UI 元素
@State 状态管理 1.3 currentTabsecondaryPagechatMessages 等 20+ 状态
@Builder 封装复用 1.4 18 个 @Builder 方法(NavIcon/HomePage/ChatBubble 等)
@StorageLink + BreakpointSystem 1.5 currentBreakpoint + 响应式尺寸计算
Router 路由 1.6 pushUrl 跳转到独立页面,aboutToAppear 中初始化
$r() / $rawfile() 1.7 引用图片和视频资源

这些知识点在 Index.ets 中不是孤立使用的,而是相互配合:

  • @State 驱动的 currentTab 控制 CurrentPage() 的渲染分支
  • @Builder 封装了可复用的 UI 片段(如 NavIcon、Header、SectionTitle)
  • 生命周期方法 aboutToAppear 中完成路由参数读取、系统服务注册和定时器启动
  • 响应式断点控制所有尺寸相关的数值
  • 资源引用贯穿整个页面的图片和视频显示

10. 总结

本篇通过对「画伴梦工厂」Index.ets 的全面剖析,展示了如何使用 ArkUI 的组合式 API 构建一个生产级别的多 Tab 主页。

核心设计原则:

  1. 状态驱动视图 ------所有页面切换由 @State 变量控制
  2. Builder 拆分职责 ------每个功能区域封装为独立的 @Builder
  3. 响应式适配 ------通过 BreakPointType 实现多屏幕适配
  4. 生命周期管理 ------aboutToAppear/aboutToDisappear 中做好初始化和清理
  5. 路由解耦 ------Tab 内切换用状态控制,出 Tab 跳转用 pushUrl

至此,第一章(鸿蒙 ArkUI 基础与组合式 API)全部完结。我们从最基础的声明式 UI 语法开始,一路学习了组件、布局、状态管理、Builder 封装、响应式布局、路由与生命周期、资源管理,最终完成了这个综合性 Tab 主页的分析。


下章预告:第二章 鸿蒙多媒体与交互开发。我们将深入学习 Video 播放、语音识别、动画系统、碰一碰分享等多媒体和交互能力,敬请期待!


思考题Index.ets 有将近 2700 行代码。如果让你重构,你会怎么拆分?例如:把 HomePage、CreationPage、WorksPage 分别拆成独立的 @Component 文件?需要考虑状态共享的问题,你会如何设计?