时光胶囊 App 的首页用一个 Entry 页面承载了四个完全不同的功能 Tab——时间线、封印、统计、设置。每个 Tab 的 UI 结构、数据来源、交互逻辑都不同但共享同一个底部导航栏和主题系统。这种单页多 Tab的架构在工具类 App 里很常见但要做到四个 Tab 互不干扰、状态独立需要在状态管理和页面组织上下功夫。完整效果一、四个 Tab 的职责Tabkey图标功能数据来源胶囊timeline时间线列表筛选capsules 数组封印create✍️创建新胶囊用户输入统计stats数据概览心情分布database.getStats()设置settings⚙️主题切换清空数据preferences二、20 个 State 的分组管理首页有 20 个 State 变量——这是整个 App 状态最集中的页面。按 Tab 分组后每个 Tab 只管理自己的状态全局状态2 个变量类型作用currentTabstring当前选中的 TabcurrentThemeThemeType当前主题capsulesCapsule[]所有胶囊数据时间线 Tab2 个变量类型作用activeFilterstring筛选条件all/sealed/ready/openedcurrentIndexnumber当前滚动位置封印 Tab7 个变量类型作用contentstring胶囊内容selectedMoodstring选中的心情 emojiselectedUnlocknumber解锁时间戳tagsstring[]标签列表newTagstring标签输入框内容isSavingboolean保存中状态unlockOptionsUnlockOption[]可选的解锁时间统计 Tab2 个变量类型作用statsSummaryStatsSummary统计概览moodStatsRecordstring, number心情分布数据设置 Tab2 个变量类型作用showClearConfirmboolean清空确认弹窗clearDoneboolean清空完成标记分组管理的原则每个 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.currentTabtab.key?this.getColors().primary:this.getColors().textSecondary).fontWeight(this.currentTabtab.key?FontWeight.Bold:FontWeight.Normal).margin({top:2})}.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({top:Spacing.sm,bottom:Spacing.sm}).onClick((){this.currentTabtab.keyif(tab.keystats||tab.keytimeline){this.loadStats()}})})}条件渲染 vs Tab 组件当前用 if-else 条件渲染——不是用 Tab 组件。根据currentTab的值选择渲染哪个 Builderif(this.currentTabtimeline){this.buildTimeline()}elseif(this.currentTabcreate){this.buildCreate()}elseif(this.currentTabstats){this.buildStats()}else{this.buildSettings()}为什么不用 Tab 组件Tab 组件会预加载所有 Tab 的内容——即使用户没切换到某个 Tab它的 UI 也已经渲染了。if-else 只渲染当前 Tab 的内容性能更好。在 4 个 Tab 的规模下if-else 的代码量可以接受。Tab 切换时的数据刷新.onClick((){this.currentTabtab.keyif(tab.keystats||tab.keytimeline){this.loadStats()}})切换到统计或时间线 Tab 时自动刷新数据。因为用户可能在封印 Tab 新增了胶囊——切换回时间线或统计时需要显示最新数据。封印和设置 Tab 不需要刷新——它们不显示胶囊列表。四、主题系统的架构ThemeType 枚举enumThemeType{CAPSULECAPSULE,// 暖金主题LIGHTLIGHT,// 亮色主题DARKDARK,// 暗色主题EYE_CAREEYE_CARE// 护眼主题}四个主题各有不同的颜色方案。getThemeColors(theme)根据当前主题返回对应的 ThemeColors 对象——所有颜色通过this.getColors()统一获取不在代码里硬编码颜色值。getColors() 的设计privategetColors():ThemeColors{returngetThemeColors(this.currentTheme)}每次 build 都重新获取颜色——保证主题切换后立即生效。this.getColors()在 build 里被大量调用——背景色、文字色、边框色、主色都通过它获取。切换主题后currentTheme变化getColors()返回新的颜色方案UI 自动刷新。主题的持久化privateasyncsaveTheme(theme:ThemeType):Promisevoid{constprefsawaitpreferences.getPreferences(this.context!,settings)awaitprefs.put(theme,theme)awaitprefs.flush()this.currentThemetheme}privateasyncloadTheme():Promisevoid{constprefsawaitpreferences.getPreferences(this.context!,settings)constthemeawaitprefs.get(theme,ThemeType.CAPSULE)this.currentThemethemeasThemeType}用 Preferences 持久化主题选择——重启后保留用户偏好。saveTheme写入 Preferences 并更新 StateloadTheme在初始化时读取。默认值是ThemeType.CAPSULE暖金主题。四个主题的标签key标签名效果CAPSULE暖金默认主题温暖的金色调LIGHT亮色标准亮色主题DARK暗色深色背景EYE_CARE护眼低蓝光护眼模式五、数据库层的初始化privatedatabase:CapsuleDatabase|nullnullprivatecontext:common.UIAbilityContext|nullnullprivateasyncinit():Promisevoid{this.contextgetContext(this)ascommon.UIAbilityContextthis.databasenewCapsuleDatabase(this.context)awaitthis.database.loadData()this.capsulesthis.database.getAllCapsules()this.unlockOptionsgetUnlockOptions()this.selectedUnlockthis.unlockOptions.length1?this.unlockOptions[1].getTime():Date.now()7*86400000this.loadStats()awaitthis.loadTheme()}初始化的五个步骤步骤操作作用1获取 context为数据库和 Preferences 提供上下文2创建 CapsuleDatabase初始化数据存储3loadData getAllCapsules加载所有胶囊数据4getUnlockOptions 设置默认解锁时间为封印 Tab 准备选项5loadStats loadTheme加载统计和主题默认解锁时间选第二个选项7 天后。unlockOptions[1]是7天后——大多数用户想封印一周这个默认值最合理。如果没有选项fallback 到Date.now() 7 * 864000007 天后的毫秒时间戳。database 为 null 的防御privatedatabase:CapsuleDatabase|nullnulldatabase 和 context 都初始化为 null——因为 init() 是异步的。在 init() 完成之前database 还是 null。所有使用 database 的地方都需要检查 nullif(!this.database)returnawaitthis.database.addCapsule(capsule)踩坑记录最初 sealCapsule 里没检查if (!this.database) return异步初始化未完成时点击封印按钮会报空指针异常。加了 null 检查后解决。六、时间线 Tab 的筛选逻辑privategetFiltered():Capsule[]{constnowDate.now()letlist:Capsule[][]if(this.activeFilterall){listthis.capsules.slice()}elseif(this.activeFiltersealed){listthis.capsules.filter((c:Capsule)!c.isOpenedc.unlockAtnow)}elseif(this.activeFilterready){listthis.capsules.filter((c:Capsule)!c.isOpenedc.unlockAtnow)}elseif(this.activeFilteropened){listthis.capsules.filter((c:Capsule)c.isOpened)}list.sort((a:Capsule,b:Capsule)b.createdAt-a.createdAt)returnlist}四种筛选条件筛选条件含义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是降序——时间戳大的更新的排前面。筛选标签的 UIForEach([all,sealed,ready,opened],(key:string){Text(this.getFilterLabel(key)).fontColor(this.activeFilterkey?#FFFFFF:this.getColors().text).backgroundColor(this.activeFilterkey?this.getColors().primary:this.getColors().surface).border({width:1,color:this.activeFilterkey?this.getColors().primary:this.getColors().border}).onClick(()this.activeFilterkey)})选中状态用主色背景白色文字未选中用表面色背景深色文字边框。和知识库 App 的笔记本选择器设计模式一致——通过背景色和文字颜色的双重变化区分选中状态。七、封印 Tab 的表单设计四个输入区域区域组件数据内容TextAreacontent心情MoodPicker 组件selectedMood解锁时间Flex ForEachselectedUnlock标签TextInput Flextags封印按钮的状态控制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().primarythis.content.trim() !this.isSaving两个条件同时满足才显示主色。内容为空时按钮灰色——防止用户提交空胶囊。保存中时按钮灰色——防止重复提交。sealCapsule 的异步流程privateasyncsealCapsule():Promisevoid{if(!this.content.trim()||!this.database)returnthis.isSavingtrueconstcapsulecreateCapsule(this.content.trim(),this.selectedMood,this.selectedUnlock,this.tags)awaitthis.database.addCapsule(capsule)this.capsulesthis.database.getAllCapsules()this.contentthis.selectedMoodthis.tags[]this.isSavingfalsethis.loadStats()this.currentTabtimeline}六个步骤检查非空 → 标记保存中 → 创建胶囊对象 → 写入数据库 → 重置表单 → 切换到时间线 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)}})心情进度条的宽度计算privategetMoodBarPct(emoji:string):string{constcntthis.getMoodCount(emoji)constmxthis.maxMoodVal()constpctmx0?Math.floor(cnt/mx*100):0returnString(pct)%}按最大值的百分比计算——最高的心情占 100% 宽度其他按比例缩小。和知识库 App 的标签进度条用同样的归一化逻辑。九、设置 Tab 的三个功能功能实现方式持久化主题切换saveTheme → Preferences是记忆洗牌路由跳转 MemoryShuffle—清空数据showClearConfirm 弹窗 → clearAll是主题切换的即时生效privateasyncsaveTheme(theme:ThemeType):Promisevoid{constprefsawaitpreferences.getPreferences(this.context!,settings)awaitprefs.put(theme,theme)awaitprefs.flush()this.currentThemetheme}先写 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 的 StackScrollColumn 更浅——因为底部导航栏不需要 Stack 层叠直接用两个 Column 上下排列。四个 Builder 的独立性每个 Builder 是一个独立的Builder方法——有自己的 Column/Scroll/List 结构。切换 Tab 时只渲染当前 Builder其他 Builder 不执行。这是 if-else 条件渲染的核心优势——只渲染需要的内容。