开源鸿蒙训练营进行到第8天任务从“能看”变成了“能用”。DAY8DAY13这六天的主线是底部导航栏Tab选项卡训练营给的任务是“打卡记录”要做一个带底部导航的应用把打卡业务完整跑通。说实话刚看到这个题目时我觉得有点简单无非就是放几个按钮切页面。真做起来才发现底部导航栏牵扯到页面容器的选择、状态管理方案、数据持久化、懒加载机制、自定义UI等一系列问题任何一个环节没想透后面都要返工。这篇文章就把这六天的完整过程、核心代码和踩过的坑梳理出来给正在啃ArkTS和ArkUI的开发者一个参考。1. 先看清需求DAY8DAY13到底在解决什么问题1.1 底部导航栏不是“放几个按钮”那么简单移动应用里底部导航栏是最常见的导航形态从设计规律上讲一般不超过5个Tab我们这次定了3个首页、打卡记录、我的。训练营的任务要求不只是把3个页面切来切去还要完成一个完整的打卡闭环用户进入应用、点击打卡、数据落盘、记录展示、状态同步。这意味着底部导航栏背后至少叠着三层逻辑页面层Tab怎么定义、内容怎么切换、切换动画怎么控制状态层选中哪个Tab、打卡状态变化后其他页面怎么感知数据层打卡记录存哪里、怎么读、跨天之后怎么处理如果只把Tabs组件拖进去就完事那只能算UI稿还原离“功能可用”还差得远。训练营把这项任务排在DAY8DAY13就是想让学员把这三层串起来而不是停留在组件调用层面。另外标题里那个“打卡记录”有两个含义。一个是训练营自己的机制每天要提交代码、写学习日志另一个是应用里的业务用户每天打卡一次。这两个含义在语境里重合了做项目的时候会莫名有一种“双重打卡”的监督感还挺有意思。我后面提到“打卡”都是指应用内的业务功能。1.2 六天的节奏设计从静态搭建到状态打通训练营每天的安排大体是上午讲知识点、下午动手写代码晚上提交打卡。DAY8DAY13对应的学习路径拆开来看是这样的天数主题划分核心产出DAY8Tabs组件基础语法与页面容器选型可切换的3个空页面无自定义样式DAY9自定义TabBar与视觉细节图标文字的自定义TabBar含选中态DAY10页面状态管理与跨Tab数据通信打卡状态在首页和记录页实时同步DAY11角标、动画、细节交互补全未读角标、切换动画、中间凸起按钮DAY12打卡业务与数据持久化Preferences存储、跨天重置DAY13真机联调与性能排查真机验证、懒加载优化、问题修复这个节奏其实有讲究。前2天解决“能不能跑起来”中间2天解决“数据怎么流通”后面2天解决“脱离开发机还能不能稳”。很多自学的人卡在DAY9到DAY10这一段就是页面做得很漂亮但状态一联动就乱套。训练营故意把状态管理放在自定义UI之后讲也是怕学员一上来被状态搞晕。1.3 为什么不用Navigation而选TabsArkUI里能做页面切换的容器不少Navigation、Tabs、Stack 条件渲染都行。DAY8的开场就花了不少时间对比Tabs和Navigation。训练营给的结论非常清晰平级页面频繁切换用Tabs有层级关系的页面跳转用Navigation。底部导航栏的业务语义就是几个平级入口来回切换用户不会从“首页”跳进“我的”再跳进“记录”所以Tabs天然合适。而Navigation更适合“列表页→详情页”这种有进有退的场景。如果首页里点了某条记录要看详情那就是Tabs之外再套一层Navigation两者不冲突是叠加关系。我后面就把“我的”页面里加了一个Navigation子页面来做设置项跳转体验上是合理的。不要一上来就想着用Navigation去模拟底部导航非要把事情搞复杂。官方提供的Tabs组件就是为这个场景设计的直接用它省下自己管理页面栈的麻烦。2. Tabs组件核心机理官方容器组件这样用才顺手2.1 基本写法与关键参数拆解Tabs组件在ArkUI里的结构非常固定外层是Tabs容器内层放若干个TabContent子组件每个TabContent通过.tabBar()方法指定底部按钮内容。先看DAY8结束时的基础版代码Entry Component struct MainPage { State currentIndex: number 0 private controller: TabsController new TabsController() build() { Tabs({ barPosition: BarPosition.End, controller: this.controller }) { TabContent() { HomePage() } .tabBar(首页) TabContent() { RecordPage() } .tabBar(记录) TabContent() { MinePage() } .tabBar(我的) } .scrollable(false) .animationDuration(300) .onChange((index: number) { this.currentIndex index }) } }几个参数值得单独拿出来说barPosition: BarPosition.End——把Tab栏固定在页面底部。默认是Start也就是顶部如果忘了设置底部导航栏就跑到顶上去了效果非常诡异。controller: TabsController——TabsController是一个外部控制器可以用来在代码里主动切换Tab比如点击“去打卡”按钮后跳转到第二个Tabcontroller.changeIndex(1)就搞定了。scrollable(false)——禁用手势滑动切换。底部导航的交互预期是点击切换滑动切换在手势冲突时很容易误触比如页面内部有横向滚动的列表时滑着滑着就换Tab了体验很割裂。animationDuration(300)——切换动画时长。这个看产品需求有人喜欢干脆利落的无动画有人喜欢有点过渡300毫秒是Android和iOS两端都常见的值。TabContent之间是平级关系它们的内容是并排在Tabs内部的不是通过路由跳转。这一点和传统前端里用display:none切换页面内容的思路一样组件在struct里都在只是根据当前索引决定渲染优先级。再说一个容易踩的细节State currentIndex看起来可有可无因为Tabs自己也能记住当前页。但当你需要根据当前页动态调整导航栏、或者给某些Tab做差异化样式时这个状态就是唯一的数据源。DAY10做状态同步时这个currentIndex就派上用场了所以别偷懒不写。2.2 TabContent的懒加载机制默认行为与lazy属性这大概是DAY10最核心的一个知识点。TabContent默认是懒加载的意味着用户不切到某个Tab这个Tab的内容组件就不会创建。这样做的好处是减少首屏渲染压力首页不用等另外两个页面的组件树一起构建。但懒加载带来的问题也很直接如果TabContent的状态只存在组件内部切走再切回来状态就丢了。比如打卡记录页里有一个从Preferences读出来的“已连续打卡3天”的展示用户看完切到首页再切回来如果记录页被重新加载了它需要重新读一次Preferences。这倒还问题不大真正头疼的是首页展示的“今日已打卡”状态。如果打卡动作发生在记录页首页这个状态怎么感知答案是状态不能只活在组件里必须放到组件之外能被多方感知的地方。这也是为什么DAY10、DAY12要把状态管理单拎出来讲因为它和懒加载机制是伴生问题。如果想关闭懒加载让页面在应用启动时就全部创建可以在TabContent上设置lazy(false)TabContent() { RecordPage() } .lazy(false) .tabBar(记录)这么做了之后首次启动会变慢因为3个页面同时构建了。我的建议是能不开就不开大多数场景下用全局状态管理解决数据同步问题比关懒加载这种粗暴手段优雅得多。只有在页面状态实在无法抽离、重建开销又很大的时候才考虑lazy(false)。2.3 页面保活和数据同步的平衡既然默认懒加载那怎么保证用户从“记录页”打卡之后回到“首页”立刻看到最新结果我的最终方案是数据落Preferences运行态状态放AppStorage。Preferences负责持久化AppStorage负责运行时跨组件同步。页面即使被重建也能从AppStorage里拿到最新值不需要等Preferences异步读取。代码层面是这样做的。先定义一个公共的存储入口文件初始化后全局引用import { AppStorage } from ohos.arkui.advanced.AppStorage import { preferences } from ohos.data.preferences let prefInstance: preferences.Preferences | null null export async function initPreferences(context: Context) { prefInstance await preferences.getPreferences(context, check_in_store) } export function getPref(): preferences.Preferences { if (!prefInstance) { throw new Error(Preferences has not been initialized) } return prefInstance }然后在主页面的aboutToAppear里把Preferences里的值同步到AppStorageaboutToAppear(): void { const pref getPref() pref.get(today_checked, false).then((value) { AppStorage.setOrCreate(todayChecked, value) }) }各页面统一用StorageProp(todayChecked)或者StorageLink(todayChecked)来绑定这个全局状态。打卡成功后执行AppStorage.setOrCreate(todayChecked, true)所有绑定这个key的组件都会自动刷新。页面被懒加载重建也没有关系重建时读到的还是AppStorage里的最新值。这套组合在DAY10验证之后一直用到DAY13上线没有出过状态不一致的问题。3. 自定义TabBar从默认样式到中间凸起、角标3.1 为什么默认tabBar不够用ArkUI默认的tabBar只支持两样东西文本和图标。简单场景够用但这次任务有3个需求默认样式满足不了选中态要求图标文字同时变色默认样式对选中态的控制不够灵活需要突出一个“打卡”Tab做成大按钮悬浮凸起的效果展示未读角标类似“我的”Tab上冒出一个红点数字这些都指向同一个结论tabBar必须走自定义路线。ArkUI支持通过Builder函数来定义tabBar的内容自由度很高本质上是把tabBar区域当成了普通UI区域来布局。3.2 Builder构造自定义TabBarDAY9重点就来写这个。基础的自定义TabBar大概长这样Builder tabBarBuilder(title: string, normalIcon: Resource, selectedIcon: Resource, index: number) { Column() { Image(this.currentIndex index ? selectedIcon : normalIcon) .width(24) .height(24) Text(title) .fontSize(10) .fontColor(this.currentIndex index ? #FF7500 : #999999) .margin({ top: 2 }) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .onClick(() { this.controller.changeIndex(index) this.currentIndex index }) }然后在TabContent里这样引用TabContent() { HomePage() } .tabBar(this.tabBarBuilder(首页, $r(app.media.ic_home_normal), $r(app.media.ic_home_selected), 0))这套写法的核心逻辑是Tab栏本质上也是一块UI可以像普通组件一样组合和布局。选中态的判断依据就是外层Tabs的currentIndex数据流是Tabs切换 → onChange更新currentIndex → tabBarBuilder重新计算每个Tab的样式。这里有个体验细节手写onClick调用controller.changeIndex是有意为之的。默认的点击行为已经存在但加上显式调用后代码可读性更好将来要加点击埋点也方便。实测下来ArkUI里的点击事件和控制器的changeIndex不会产生冲突。3.3 中间凸起按钮的实现细节凸起按钮的视觉效果就是把按钮“顶”出Tab栏之外核心做法是给中间Tab的内容加一个向上的偏移量Builder middleTabBuilder() { Stack() { Column() { Image($r(app.media.ic_check_in_normal)) .width(28) .height(28) Text(打卡) .fontSize(10) .fontColor(#FFFFFF) .margin({ top: 2 }) } .width(64) .height(64) .borderRadius(32) .backgroundColor(#FF7500) .justifyContent(FlexAlign.Center) .shadow({ radius: 8, color: #3300CC88, offsetY: 4 }) } .width(100%) .height(100%) .justifyContent(FlexAlign.Center) .offset({ y: -16 }) }注意几点外层Column必须给width(100%)和height(100%)否则offset的相对基准会出问题凸起按钮的点击区域和视觉区域一致都是偏移后的位置阴影不要太大否则会和上面的页面内容重叠产生视觉脏边凸起Tab和其他普通Tab在高度坐标上是错开的所以整个TabBar区域的布局高度要留有空间。不要让凸起按钮的阴影被TabBar的父容器裁剪掉遇到裁剪问题优先在Tabs外层调整clip相关设置。3.4 角标红点与数字的实现ArkUI里给Tab加角标有现成的Badge组件用法是把Badge包在目标组件外层Badge({ count: this.unreadCount, position: BadgePosition.TopRight, style: { badgeColor: #FF3B30, badgeSize: 16, fontSize: 10 } }) { Image(...) .width(24) .height(24) }unreadCount是一个State变量当“我的”页面里的消息状态变化时直接改这个值角标数字自动跟着更新。这里有一个实际踩到的坑Badge里的count为0时仍然会显示一个小圆点。如果不想要这个残留要么手动控制count为0时不渲染Badge要么用条件渲染包一层if (this.unreadCount 0) { Badge({ count: this.unreadCount, ... }) { ... } } else { Image(...) }DAY11的检查项之一就是角标的边界处理这种细节CtrlF搜不出来只能靠实机观察。4. 页面状态管理与打卡数据持久化4.1 打卡功能的状态模型先理清打卡业务的状态一共有3个今日是否已打卡布尔值累计打卡天数数字连续打卡次数数字用于连续打卡激励这三个状态在首页、记录页、我的页面都存在感很强。首页要展示“今日打卡”的大按钮状态记录页要画一周的打卡日历格子我的页面要显示累计数据。这些状态如果每个页面都自己存一份最终一定会有某个页面读到的数据是旧的。必须找一个全局的数据源作为唯一事实来源我用的是AppStorage Preferences组合这个在2.2已经提到过。4.2 使用Preferences做轻量持久化Preferences是鸿蒙里的轻量级键值对存储类似Android的SharedPreferences适合存配置类小数据。打卡记录这种数据量级一天一条一年也就365条完全在Preferences的舒适区内。核心读写逻辑封装在一个模块里import { preferences } from ohos.data.preferences import { util } from ohos.util export async function checkInToday(): Promiseboolean { const pref getPref() const today getTodayKey() const hasChecked await pref.get(today, false) if (hasChecked) { return false // 今天已经打卡过了 } await pref.put(today, true) await pref.flush() return true } function getTodayKey(): string { const date new Date() const year date.getFullYear() const month date.getMonth() 1 const day date.getDate() return check_in_${year}_${month}_${day} }日期字符串作为存储主键是我比较推荐的方案。好处有两个天然按天隔离不需要额外维护“今天”的字段统计连续打卡时只需要读取最近N天的key做判断连续打卡的计算逻辑是这样的从今天往昨天、前天依次检查遇到没有打卡记录的日期就停止累加次数。export async function getContinuousDays(): Promisenumber { const pref getPref() let count 0 const today new Date() for (let i 0; i 365; i) { const day new Date(today.getTime() - i * 86400000) const key check_in_${day.getFullYear()}_${day.getMonth() 1}_${day.getDate()} const checked await pref.get(key, false) if (checked) { count } else { break } } return count }注意跨天问题。如果应用在23:59打卡00:01再打开today的key就必须自动变成新的一天。getTodayKey每次调用都基于当前时间所以天然支持跨天重置不需要额外定时器。4.3 页面内状态绑定与更新有了存储层页面UI层的状态绑定就清晰了。以首页的打卡按钮为例Component export struct HomePage { StorageLink(todayChecked) todayChecked: boolean false async onCheckInTap() { const result await checkInToday() if (result) { this.todayChecked true // 触发提示比如Toast } } build() { Column({ space: 12 }) { Text(this.todayChecked ? 今日已打卡 : 今日未打卡) .fontSize(20) .fontColor(this.todayChecked ? #00CC88 : #666666) Button(this.todayChecked ? 已完成 : 立即打卡) .enabled(!this.todayChecked) .onClick(() this.onCheckInTap()) } } }这里用的是StorageLink而不是StorageProp。区别在于StorageLink可以对这个全局状态做双向同步修改它会写回AppStorageStorageProp只能读不能写。打卡按钮这种需要触发修改的场景必须用StorageLink。记录页只需要展示用StorageProp就够了。状态修改后为什么会立刻刷新因为AppStorage本身是一个全局响应式存储所有绑定了同一个key的组件都会收到变更通知和内层的State机制是联动的。理解这一点之后跨页面刷新就再也不用手动发事件了。4.4 为什么不用数据库DAY12有人提问打卡记录为什么不用关系型数据库。训练营给了一个量化标准数据量低于一万条、字段简单、无复杂查询时键值对存储的性价比远高于数据库。打卡记录本质上就是“日期→是否打卡”的映射最多再加一些用户配置。一年365条10年也就3650条。用关系型数据库反而要引入建表、升级、迁移等一整套复杂度开发成本高收益几乎为零。Preferences的flush落盘足够可靠读取一次毫秒级返回。真正需要换成数据库的信号是数据字段开始膨胀比如每条打卡要存图片、位置、时长、需要按范围查询统计、需要多表关联。打卡V2.0版本如果加了图片打卡再迁移也不迟。4.5 Preferences初始化时机的坑这里有一个值得单独提的坑Preferences必须在UI aboutToAppear里异步初始化不能在组件字段初始化时用。因为getPreferences是一个异步操作而且需要application context组件字段初始化时机太早可能拿不到context。正确的初始化方式是在EntryAbility的onCreate里做async onCreate(): Promisevoid { await initPreferences(this.context) }或者懒加载模式第一次用到时再初始化。总之别在global scope直接getPreferences会拿到undefined context然后报错。5. 六天踩坑实录常见问题速查与排查心得5.1 问题速查表下表是这六天遇到的典型问题以及最终确认的原因和解决方式。先给速查表后面挑几个展开讲。问题现象根因解决办法tabBar显示在页面顶部未设置barPosition或设成了Start改为BarPosition.End自定义TabBar点击没反应Builder内部忘了写onClick在TabBar自定义UI中显式调用changeIndex切换到其他Tab再回来页面状态丢失TabContent默认懒加载组件被重建状态提升到AppStorage用StorageLink绑定Preferences读到的值是undefined未等待初始化完成就使用在onCreate中完成preferences初始化角标count归零后还有残留圆点Badge组件机制用条件渲染包裹count0不渲染凸起按钮的阴影被裁剪父容器默认clip调整Tabs外层或TabBar容器裁剪属性真机上图标颜色和预览器不一致图片资源格式或色彩配置差异统一使用Resource对象避免直接引用路径字符串打卡后首页不刷新AppStorage key拼写不一致统一key常量管理不硬编码字符串5.2 排查过程实录真机上图片不显示这个坑D13才暴露出来。真机运行后自定义TabBar的图标全部显示为空白但预览器里一切正常。一开始怀疑图片资源没打包进去检查了资源目录没有问题。后来打印日志发现图片路径的Resource对象是合法的但还是空白。最后定位到问题我在某些地方使用字符串路径比如/resources/base/media/xx.png引用图片而ArkUI在真机上对字符串形式的媒体资源路径支持有限必须使用$r(app.media.xxx)这种资源引用方式。预览器对字符串路径做了宽松处理真机没有。修复很简单把所有字符串路径改成$r格式。但这个坑很容易被忽视因为预览器不会报错。排查建议是遇到预览器正常但真机异常的资源问题先检查资源引用方式再检查资源是否被裁剪了。5.3 排查过程实录打卡后记录页不刷新这个问题发生在DAY11联调时。流程是这样的首页点击打卡 → AppStorage.todayChecked设为true → 切到记录页记录页今天的格子没变绿。一开始怀疑AppStorage失效但打印日志发现值确实已经变了。问题出在记录页自己维护了一个State的本地布尔值只在aboutToAppear时从AppStorage读一次之后就不再监听了。修复方案有两种把记录页里的关键状态全改成StorageProp/StorageLink让框架自动刷新用LocalStorage的event机制监听变更第一个方案明显更干净。记录页面板改成StorageLink(todayChecked) todayChecked: boolean false StorageLink(totalDays) totalDays: number 0这样AppStorage里任何变化都会直接驱动UI刷新不需要手动同步。这个问题的本质还是“状态存储位置不对”。只要是跨页面共享的状态就不要放在页面组件内部一定放AppStorage或者ViewModel层。页面组件内部只保留纯UI状态比如弹窗是否展示、列表是否在加载。5.4 调试技巧日志分段和慢加载定位DAY13讲了一个实用的调试方法在TabContent的aboutToAppear和build里分别打日志记录时间戳可以看出每个页面从“开始构建”到“渲染完成”的耗时。aboutToAppear(): void { console.info(RecordPage aboutToAppear at ${Date.now()}) }用这种方法我发现记录页的首次加载花了800多毫秒原因是页面上的7天打卡网格用了多个if分支判断而主线程在构建时做了大量字符串日期格式化。优化方式是把日期格式化提前到aboutToAppear生成好数组build里只做渲染首帧渲染降到了300毫秒以内。日志不是越高频越好关键是标注关键时序排查状态不同步问题时加日志的锚点就放在状态变更的代码行和UI读取状态的位置两边对比时间戳立刻能定位问题出在写入侧还是读取侧。5.5 关于开发机选型DAY8开始前我还纠结了一下是用模拟器还是真机。这六天下来结论很明确底部导航栏这种涉及手势、页面切换、状态同步的功能一定优先用真机调试。模拟器能跑通逻辑但很多体验细节——凸起按钮的触感、页面切换动画的流畅度、图片资源加载——和真机差距明显。鸿蒙的真机调试也不麻烦打开开发者模式后用USB连接选择设备就行。远程调试在部分场景下也行但性能和事件响应不如本地连接稳定。6. 从打卡功能到组件化思维这六天沉淀下来的方法6.1 把页面拆成“容器 组件”两层DAY13的代码review阶段训练营老师提了一个让我印象很深的观点Tabs里的每个TabContent不应该承载所有业务逻辑它应该只是一个“容器页”内部再拆成功能组件。打卡记录页如果直接堆代码会变成几百行的大组件星期网格、统计卡片、列表、打卡按钮全部挤在build里。我的最终结构是RecordPage.ets容器页负责拿数据、组合子组件WeekCalendar.ets七天打卡网格纯展示组件接收propsStatCard.ets累计/连续天数卡片CheckInButton.ets打卡动作组件处理点击逻辑这样拆的好处是WeekCalendar可以独立复用将来做“历史记录”页面时直接引用StatCard的样式调整不影响其他部分“打卡按钮”的点击逻辑单独测试也方便。ArkUI的组件化语法和结构给这种拆分提供了很好的基础每个Component就是一个独立文件props通过构造参数传入子组件通过Prop接收。如果不做这个拆分后面每个页面都会变成巨型代码块维护起来非常痛苦。6.2 状态管理的分层原则这六天里我对状态管理的理解越来越具体。一个组件里的状态大致分四层每层适合的范围不同层级适用性用什么组件私有UI状态动画开关、弹窗显示、临时选中State父子组件共享组件内部数据传递Prop / Link跨多层传递全局主题、全局用户操作AppStorage / StorageLink跨应用持久化配置、打卡记录等业务数据Preferences原则很简单状态尽量下沉不要什么东西都往AppStorage放。只有真正需要跨页面共享的数据才提升到全局。这个原则贯彻下来代码里80%的字段还是State全局状态只留了todayChecked、totalDays、unreadCount几个核心字段。6.3 对比一下“一步到位”和“逐步迭代”如果重新来一次我大概率会直接把DAY13时候的状态管理设计放到DAY8第一天。但训练营让我体会到“从简单到复杂逐步迭代”也有它的价值。DAY8的代码里没有AppStorage没有自定义TabBar所有状态都是State页面之间也不通信。如果用最终方案倒推一开始就上一堆全局状态很可能会因为过度设计而卡在状态初始化的坑里爬不出来。先把最小闭环跑通再逐层加复杂度的路线更适合学习。但如果你已经有基础直接套最终方案也没问题。关键是心里要清楚每一层是在解决哪个具体问题不要为了“架构优雅”去设计架构。DAY8的简单版本和DAY13的完整版本对比放在一起能清楚看到每加一层解决了一个什么问题这比只写最终版学到了更多。6.4 从训练营打卡到工程实践的距离训练营的“打卡记录”项目本质上是把底部导航栏、自定义UI、状态管理、持久化存储这几个技术点整合在一起的一次综合训练。做完一遍这些点才真正串起来了。对照我个人之前的开发经验这套东西放到实际工程里还有几个需要再迭代的方向打卡记录数据量变大时从Preferences平滑迁移到SQLite的数据库方案多语言适配日期格式在不同语言环境下要调整无障碍支持凸起按钮和TabBar需要补充无障碍标签夜间模式自定义TabBar的选中色要跟随主题变化训练营教的是一套骨架往骨架里填肉还得靠真实项目和真实用户反馈。底部导航栏和打卡功能虽然基础但把它做到稳定、流畅、状态准确背后要考虑的点一点也不少。六天学习落地的核心体会是写组件只是起步真正拉开差距的是对状态和数据的理解。Tabs组件文档看半天不如亲手把打卡状态从页面A传到页面B再落盘磁盘遇到问题再回头看文档理解完全不同层次。这也是训练营打卡机制的意义所在——每天的记录逼着你把当天的代码和心得沉淀下来回头翻一翻踩过的坑都变成了资料库。