时光胶囊 App 的首页用一个 @Entry 页面承载了四个完全不同的功能 Tab——时间线、封印、统计、设置。每个 Tab 的 UI 结构、数据来源、交互逻辑都不同,但共享同一个底部导航栏和主题系统。这种"单页多 Tab"的架构在工具类 App 里很常见,但要做到四个 Tab 互不干扰、状态独立,需要在状态管理和页面组织上下功夫。

完整效果
在这里插入图片描述
在这里插入图片描述

一、四个 Tab 的职责

Tab key 图标 功能 数据来源
胶囊 timeline 🔮 时间线列表+筛选 capsules 数组
封印 create ✍️ 创建新胶囊 用户输入
统计 stats 📊 数据概览+心情分布 database.getStats()
设置 settings ⚙️ 主题切换+清空数据 preferences

二、20 个 @State 的分组管理

首页有 20 个 @State 变量——这是整个 App 状态最集中的页面。按 Tab 分组后,每个 Tab 只管理自己的状态:

全局状态(2 个)

变量 类型 作用
currentTab string 当前选中的 Tab
currentTheme ThemeType 当前主题
capsules Capsule[] 所有胶囊数据

时间线 Tab(2 个)

变量 类型 作用
activeFilter string 筛选条件(all/sealed/ready/opened)
currentIndex number 当前滚动位置

封印 Tab(7 个)

变量 类型 作用
content string 胶囊内容
selectedMood string 选中的心情 emoji
selectedUnlock number 解锁时间戳
tags string[] 标签列表
newTag string 标签输入框内容
isSaving boolean 保存中状态
unlockOptions UnlockOption[] 可选的解锁时间

统计 Tab(2 个)

变量 类型 作用
statsSummary StatsSummary 统计概览
moodStats Record<string, number> 心情分布数据

设置 Tab(2 个)

变量 类型 作用
showClearConfirm boolean 清空确认弹窗
clearDone boolean 清空完成标记

分组管理的原则: 每个 Tab 的 @State 只在该 Tab 的 UI 里使用。切换 Tab 时,其他 Tab 的 @State 保留但不影响当前 Tab 的渲染。比如用户在"封印" Tab 填了内容但没保存,切换到"时间线" Tab 后内容还在——因为 content 是 @State,不会因为 Tab 切换而丢失。

三、Tab 切换的实现

// 底部导航栏
Row() {
  ForEach(this.tabs, (tab: TabItem) => {
    Column() {
      Text(tab.icon).fontSize(20)
      Text(tab.label).fontSize(10)
        .fontColor(this.currentTab === tab.key ? this.getColors().primary : this.getColors().textSecondary)
        .fontWeight(this.currentTab === tab.key ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }.layoutWeight(1).alignItems(HorizontalAlign.Center)
    .padding({ top: Spacing.sm, bottom: Spacing.sm })
    .onClick(() => {
      this.currentTab = tab.key
      if (tab.key === 'stats' || tab.key === 'timeline') { this.loadStats() }
    })
  })
}

在这里插入图片描述

条件渲染 vs Tab 组件

当前用 if-else 条件渲染——不是用 Tab 组件。 根据 currentTab 的值选择渲染哪个 Builder:

if (this.currentTab === 'timeline') { this.buildTimeline() }
else if (this.currentTab === 'create') { this.buildCreate() }
else if (this.currentTab === 'stats') { this.buildStats() }
else { this.buildSettings() }

为什么不用 Tab 组件? Tab 组件会预加载所有 Tab 的内容——即使用户没切换到某个 Tab,它的 UI 也已经渲染了。if-else 只渲染当前 Tab 的内容,性能更好。在 4 个 Tab 的规模下,if-else 的代码量可以接受。

Tab 切换时的数据刷新

.onClick(() => {
  this.currentTab = tab.key
  if (tab.key === 'stats' || tab.key === 'timeline') { this.loadStats() }
})

切换到统计或时间线 Tab 时自动刷新数据。 因为用户可能在封印 Tab 新增了胶囊——切换回时间线或统计时需要显示最新数据。封印和设置 Tab 不需要刷新——它们不显示胶囊列表。

四、主题系统的架构

ThemeType 枚举

enum ThemeType {
  CAPSULE = 'CAPSULE',   // 暖金主题
  LIGHT = 'LIGHT',       // 亮色主题
  DARK = 'DARK',         // 暗色主题
  EYE_CARE = 'EYE_CARE'  // 护眼主题
}

四个主题各有不同的颜色方案。 getThemeColors(theme) 根据当前主题返回对应的 ThemeColors 对象——所有颜色通过 this.getColors() 统一获取,不在代码里硬编码颜色值。

getColors() 的设计

private getColors(): ThemeColors {
  return getThemeColors(this.currentTheme)
}

每次 build 都重新获取颜色——保证主题切换后立即生效。 this.getColors() 在 build 里被大量调用——背景色、文字色、边框色、主色都通过它获取。切换主题后 currentTheme 变化,getColors() 返回新的颜色方案,UI 自动刷新。

主题的持久化

private async saveTheme(theme: ThemeType): Promise<void> {
  const prefs = await preferences.getPreferences(this.context!, 'settings')
  await prefs.put('theme', theme)
  await prefs.flush()
  this.currentTheme = theme
}

private async loadTheme(): Promise<void> {
  const prefs = await preferences.getPreferences(this.context!, 'settings')
  const theme = await prefs.get('theme', ThemeType.CAPSULE)
  this.currentTheme = theme as ThemeType
}

在这里插入图片描述

用 Preferences 持久化主题选择——重启后保留用户偏好。 saveTheme 写入 Preferences 并更新 @State,loadTheme 在初始化时读取。默认值是 ThemeType.CAPSULE(暖金主题)。

四个主题的标签

key 标签名 效果
CAPSULE 暖金 默认主题,温暖的金色调
LIGHT 亮色 标准亮色主题
DARK 暗色 深色背景
EYE_CARE 护眼 低蓝光护眼模式

五、数据库层的初始化

private database: CapsuleDatabase | null = null
private context: common.UIAbilityContext | null = null

private async init(): Promise<void> {
  this.context = getContext(this) as common.UIAbilityContext
  this.database = new CapsuleDatabase(this.context)
  await this.database.loadData()
  this.capsules = this.database.getAllCapsules()
  this.unlockOptions = getUnlockOptions()
  this.selectedUnlock = this.unlockOptions.length > 1
    ? this.unlockOptions[1].getTime() : Date.now() + 7 * 86400000
  this.loadStats()
  await this.loadTheme()
}

初始化的五个步骤

步骤 操作 作用
1 获取 context 为数据库和 Preferences 提供上下文
2 创建 CapsuleDatabase 初始化数据存储
3 loadData + getAllCapsules 加载所有胶囊数据
4 getUnlockOptions + 设置默认解锁时间 为封印 Tab 准备选项
5 loadStats + loadTheme 加载统计和主题

默认解锁时间选第二个选项(7 天后)。 unlockOptions[1] 是"7天后"——大多数用户想封印一周,这个默认值最合理。如果没有选项,fallback 到 Date.now() + 7 * 86400000(7 天后的毫秒时间戳)。

database 为 null 的防御

private database: CapsuleDatabase | null = null

database 和 context 都初始化为 null——因为 init() 是异步的。 在 init() 完成之前,database 还是 null。所有使用 database 的地方都需要检查 null:

if (!this.database) return
await this.database.addCapsule(capsule)

踩坑记录: 最初 sealCapsule 里没检查 if (!this.database) return,异步初始化未完成时点击封印按钮会报空指针异常。加了 null 检查后解决。

六、时间线 Tab 的筛选逻辑

private getFiltered(): Capsule[] {
  const now = Date.now()
  let list: Capsule[] = []
  if (this.activeFilter === 'all') {
    list = this.capsules.slice()
  } else if (this.activeFilter === 'sealed') {
    list = this.capsules.filter((c: Capsule) => !c.isOpened && c.unlockAt > now)
  } else if (this.activeFilter === 'ready') {
    list = this.capsules.filter((c: Capsule) => !c.isOpened && c.unlockAt <= now)
  } else if (this.activeFilter === 'opened') {
    list = this.capsules.filter((c: Capsule) => c.isOpened)
  }
  list.sort((a: Capsule, b: Capsule) => b.createdAt - a.createdAt)
  return list
}

在这里插入图片描述

四种筛选条件

筛选 条件 含义
all 全部 显示所有胶囊
sealed 未开启 && 解锁时间未到 正在等待的胶囊
ready 未开启 && 解锁时间已到 可以开启的胶囊
opened 已开启 已经读过的胶囊

this.capsules.slice() 复制数组——避免 filter 直接修改原数组。 all 筛选不需要过滤,但 slice 保证返回新数组,和 filter 的行为一致。

筛选结果按创建时间降序

list.sort((a: Capsule, b: Capsule) => b.createdAt - a.createdAt)

最新的胶囊排在最前面。 b.createdAt - a.createdAt 是降序——时间戳大的(更新的)排前面。

筛选标签的 UI

ForEach(['all', 'sealed', 'ready', 'opened'], (key: string) => {
  Text(this.getFilterLabel(key))
    .fontColor(this.activeFilter === key ? '#FFFFFF' : this.getColors().text)
    .backgroundColor(this.activeFilter === key ? this.getColors().primary : this.getColors().surface)
    .border({ width: 1, color: this.activeFilter === key ? this.getColors().primary : this.getColors().border })
    .onClick(() => this.activeFilter = key)
})

选中状态用主色背景+白色文字,未选中用表面色背景+深色文字+边框。 和知识库 App 的笔记本选择器设计模式一致——通过背景色和文字颜色的双重变化区分选中状态。

七、封印 Tab 的表单设计

四个输入区域

区域 组件 数据
内容 TextArea content
心情 MoodPicker 组件 selectedMood
解锁时间 Flex + ForEach selectedUnlock
标签 TextInput + Flex tags

封印按钮的状态控制

Row() {
  Text(this.isSaving ? '封印中…' : '🔒 封印胶囊')
    .fontSize(FontSize.lg).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width('100%').height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor(this.content.trim() && !this.isSaving ? this.getColors().primary : this.getColors().border)
.onClick(() => this.sealCapsule())

按钮颜色根据状态变化:

  • 内容为空或保存中:灰色(getColors().border
  • 内容非空且未保存:主色(getColors().primary

this.content.trim() && !this.isSaving 两个条件同时满足才显示主色。 内容为空时按钮灰色——防止用户提交空胶囊。保存中时按钮灰色——防止重复提交。

sealCapsule 的异步流程

private async sealCapsule(): Promise<void> {
  if (!this.content.trim() || !this.database) return
  this.isSaving = true
  const capsule = createCapsule(this.content.trim(), this.selectedMood, this.selectedUnlock, this.tags)
  await this.database.addCapsule(capsule)
  this.capsules = this.database.getAllCapsules()
  this.content = ''
  this.selectedMood = '😊'
  this.tags = []
  this.isSaving = false
  this.loadStats()
  this.currentTab = 'timeline'
}

六个步骤: 检查非空 → 标记保存中 → 创建胶囊对象 → 写入数据库 → 重置表单 → 切换到时间线 Tab。

保存后自动切换到时间线 Tab——用户能立即看到新胶囊。 这比停留在封印 Tab 更友好——用户封印后想确认"我的胶囊创建成功了吗"。

八、统计 Tab 的数据可视化

四维统计卡片

Flex({ wrap: FlexWrap.Wrap }) {
  Column() {
    Text(String(this.statsSummary.total)).fontSize(FontSize.hero)
      .fontColor(this.getColors().primary).fontWeight(FontWeight.Bold)
    Text('总数').fontSize(FontSize.xs).fontColor(this.getColors().textSecondary)
  }.width('48%')
  // ... opened/waiting/ready 同结构
}

2×2 网格布局——Flex + FlexWrap.Wrap + width(‘48%’)。 每个卡片占 48% 宽度,加上 2% 的 margin 实现两列排列。和知识库 App 的标签网格用同样的 Flex 布局模式。

心情分布的进度条

ForEach(MOOD_OPTIONS, (opt: MoodOption) => {
  Row() {
    Text(opt.emoji).fontSize(FontSize.lg).width(36)
    Text(opt.label).fontSize(FontSize.sm).width(48)
    Row().height(14).width(this.getMoodBarPct(opt.emoji))
      .backgroundColor(this.getMoodCount(opt.emoji) > 0 ? this.getColors().primary : this.getColors().border)
      .borderRadius(BorderRadius.sm).layoutWeight(1)
    Text(String(this.getMoodCount(opt.emoji))).fontSize(FontSize.sm).width(28).textAlign(TextAlign.End)
  }
})

心情进度条的宽度计算:

private getMoodBarPct(emoji: string): string {
  const cnt = this.getMoodCount(emoji)
  const mx = this.maxMoodVal()
  const pct = mx > 0 ? Math.floor(cnt / mx * 100) : 0
  return String(pct) + '%'
}

按最大值的百分比计算——最高的心情占 100% 宽度,其他按比例缩小。 和知识库 App 的标签进度条用同样的归一化逻辑。

九、设置 Tab 的三个功能

功能 实现方式 持久化
主题切换 saveTheme → Preferences
记忆洗牌 路由跳转 MemoryShuffle
清空数据 showClearConfirm 弹窗 → clearAll

主题切换的即时生效

private async saveTheme(theme: ThemeType): Promise<void> {
  const prefs = await preferences.getPreferences(this.context!, 'settings')
  await prefs.put('theme', theme)
  await prefs.flush()
  this.currentTheme = theme
}

先写 Preferences 再更新 @State。 this.currentTheme = theme 触发 build 重新执行,getColors() 返回新主题的颜色,所有 UI 元素立即刷新。

十、整体嵌套结构

Column                                    页面容器
  ├→ Column (内容区)                      layoutWeight(1)
  │    ├→ if (currentTab === 'timeline')
  │    │    └→ @Builder buildTimeline     标题+筛选+列表
  │    ├→ if (currentTab === 'create')
  │    │    └→ @Builder buildCreate       表单+封印按钮
  │    ├→ if (currentTab === 'stats')
  │    │    └→ @Builder buildStats        统计卡片+心情分布
  │    └→ if (currentTab === 'settings')
  │         └→ @Builder buildSettings     主题+功能+关于
  └→ Row (底部导航栏)                      固定 60px
       └→ ForEach(tabs) + @Builder Nav    4 个 Tab 按钮

嵌套深度 3 层:Column → Column(内容区) → 各 Builder。 比知识库 App 的 Stack+Scroll+Column 更浅——因为底部导航栏不需要 Stack 层叠,直接用两个 Column 上下排列。

四个 Builder 的独立性

每个 Builder 是一个独立的 @Builder 方法——有自己的 Column/Scroll/List 结构。切换 Tab 时只渲染当前 Builder,其他 Builder 不执行。这是 if-else 条件渲染的核心优势——只渲染需要的内容。

Logo

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

更多推荐