HarmonyOS时光胶囊——单页四Tab的架构设计与状态管理
时光胶囊 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 条件渲染的核心优势——只渲染需要的内容。
更多推荐



所有评论(0)