img

📖 引言

在上一篇文章中,我们详细介绍了《奇妙科学乐园》的 Git 版本管理与团队协作规范,从 .gitignore 分层配置到 atomgit.com 远程仓库的完整推送流程,为项目的代码安全与多人协作打下了坚实基础。然而,有了规范的版本管理之后,我们回过头来审视应用的骨架结构,发现一个核心问题:应用的页面导航架构还停留在最基础的 router.pushUrl 跳转模式

在最初的设计中,首页、科普、个人中心三个主页面通过路由互相跳转,每次切换都会销毁当前页面并重建目标页面。这种模式带来了严重的体验问题:首页的 Banner 轮播状态丢失、科普页的分类筛选被重置、个人中心的滚动位置不复存在。对6-12岁的儿童用户来说,频繁的页面重建意味着每次切换都要等待重新加载,耐心很快就会被消耗殆尽。

为了解决这个痛点,我们引入了 HarmonyOS 的 Tabs 容器组件,配合 @Builder 自定义 TabBar 和 TabsController 控制器,实现了页面保活、状态保持、外部联动切换的底部导航栏方案。本文将以 MainTabs.ets 的真实实现为蓝本,完整解析这套导航架构的设计思路与编码细节。

源码仓库https://atomgit.com/2301_79280419/WonderSciencePark


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解 Tabs 容器组件的核心属性:barPositionbarHeightonChange
  • ✅ 掌握使用 @Builder 自定义 TabBar 替代系统默认导航栏的完整方案
  • ✅ 学会通过 TabsController 实现跨组件的 Tab 切换控制
  • ✅ 理解 TabContent 页面保活机制与路由跳转模式的本质区别
  • ✅ 掌握 @StorageLink + @Watch 实现跨页面 Tab 联动切换
  • ✅ 解决自定义 TabBar 开发中的常见问题:状态同步、动画控制、外部跳转

💡 需求分析

为什么不使用路由跳转实现主页面切换?

在设计底部导航栏之前,我们需要先回答一个根本问题:为什么不用 router.pushUrl 在主页面间跳转?

对比维度路由跳转模式Tabs 容器模式
页面生命周期每次跳转销毁旧页面、创建新页面所有 TabContent 常驻内存,切换不销毁
状态保持❌ 页面重建后状态全部丢失✅ 各页面状态独立保持
切换动画系统默认转场动画(通常为推入/滑出)自定义滑动或淡入淡出效果
返回行为需要手动处理返回栈,容易叠加底部导航不属于路由栈,返回直接退出
性能开销每次创建/销毁有性能损耗首次创建后切换几乎零开销
适用场景深层页面跳转(如详情页)同级主页面切换(如首页/科普/我的)

《奇妙科学乐园》导航架构需求

《奇妙科学乐园》有三个一级主页面,对应底部导航栏的三个 Tab:

Tab 序号页面名称组件功能说明
0首页IndexBanner 轮播、分类网格、推荐文章
1科普Topics分类标签筛选、文章列表、搜索
2我的Profile用户信息、成就徽章、功能菜单

此外,还需要支持从子页面联动切换 Tab 的场景。例如,用户在首页点击某个分类卡片时,需要自动切换到科普 Tab 并筛选该分类;用户在答题完成页点击"查看收藏"时,需要切换到我的 Tab 的收藏子页面。这些跨组件的 Tab 切换需求是纯路由跳转模式难以优雅实现的。

导航架构需求清单

需求描述优先级
页面保活Tab 切换时各页面状态保持不丢失P0
自定义 TabBar替代系统默认 TabBar,实现儿童风格的图标+文字导航P0
外部联动切换子页面可通过 AppStorage 指令切换 TabP0
参数透传切换到科普 Tab 时可携带分类筛选参数P1
路由参数支持通过路由跳转到 MainTabs 时可指定初始 TabP1
数据初始化兜底Previewer 环境下 EntryAbility.onCreate 可能不调用P1

🛠️ 核心实现

步骤1: Tabs 容器基础架构搭建

功能说明

Tabs 是 HarmonyOS 提供的页面切换容器组件,通过 TabContent 子组件承载各个页面内容。在《奇妙科学乐园》中,我们将 MainTabs.ets 作为应用的 @Entry 入口页面,内部使用 Tabs 包裹三个 TabContent

1.1 MainTabs 组件定义与状态声明
// 文件路径:entry/src/main/ets/pages/MainTabs.ets
// 说明:主页面 - Tabs组件实现底部导航,页面保活

import { Index } from './Index';
import { Topics } from './Topics';
import { Profile } from './Profile';
import { ThemeColors } from '../constants/AppConstants';
import { RouterUtil } from '../utils/RouterUtil';
import { scienceData } from '../viewmodel/ScienceData';
import { userPrefs } from '../viewmodel/UserPreferences';
import { quizEngine } from '../viewmodel/QuizEngine';
import { achievementManager } from '../viewmodel/AchievementManager';
import { Logger } from '../utils/Logger';
import { common } from '@kit.AbilityKit';

/**
 * Tab 项数据接口
 * @description 定义底部导航栏每个 Tab 的图标、标签等信息
 */
interface TabItem {
    /** Tab 序号,用于匹配当前选中状态 */
    index: number;
    /** 通用图标资源引用 */
    icon: ResourceStr;
    /** 选中状态的图标 */
    activeIcon: Resource;
    /** 未选中状态的图标 */
    inactiveIcon: Resource;
    /** Tab 标签文字 */
    label: string;
}

@Entry
@Component
struct MainTabs {
    /** 当前选中的 Tab 序号 */
    @State currentIndex: number = 0;
    /** 科普页的分类筛选参数 */
    @State topicsCategory: string = 'all';
    /** 监听 AppStorage 中的 Tab 切换指令 */
    @StorageLink('switchToTab') @Watch('onTabSwitch') switchToTab: string = '';
    /** 监听 AppStorage 中的分类变化 */
    @StorageLink('topicsCategory') @Watch('onCategoryChange') storageTopicsCategory: string = 'all';
    /** Tabs 控制器,用于编程式切换 Tab */
    private tabsController: TabsController = new TabsController();
    // ...
}

关键设计说明

  • @State currentIndex:记录当前选中 Tab 的序号,驱动自定义 TabBar 的高亮状态。
  • @StorageLink('switchToTab') + @Watch('onTabSwitch'):通过 AppStorage 全局状态实现跨组件的 Tab 切换指令监听。当子页面(如首页点击分类卡片)设置 AppStorage.setOrCreate('switchToTab', 'topics') 时,MainTabs 会自动切换到科普 Tab。
  • TabsController:Tabs 组件的控制器实例,提供 changeIndex() 方法实现编程式 Tab 切换。
1.2 Tab 数据配置
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:Tab 项配置数组

/** 底部导航栏的三个 Tab 项 */
private tabItems: TabItem[] = [
    {
        index: 0,
        icon: $r('app.media.icon_home'),
        activeIcon: $r('app.media.tab_home_active'),
        inactiveIcon: $r('app.media.tab_home_inactive'),
        label: '首页'
    },
    {
        index: 1,
        icon: $r('app.media.icon_tab_topics'),
        activeIcon: $r('app.media.tab_topics_active'),
        inactiveIcon: $r('app.media.tab_topics_inactive'),
        label: '科普'
    },
    {
        index: 2,
        icon: $r('app.media.icon_profile'),
        activeIcon: $r('app.media.tab_profile_active'),
        inactiveIcon: $r('app.media.tab_profile_inactive'),
        label: '我的'
    }
];

设计考量

  • 每个 Tab 项包含选中态未选中态两套图标资源,通过 activeIcon / inactiveIcon 区分,确保视觉反馈清晰。
  • 使用 $r('app.media.xxx') 引用资源文件,遵循 HarmonyOS 资源管理规范。
  • Tab 数组使用 interface TabItem 统一类型约束,方便后续扩展(如添加角标、红点等)。

步骤2: 自定义 TabBar 实现

功能说明

系统默认的 TabBar 样式较为单一,无法满足《奇妙科学乐园》儿童应用的视觉风格需求。我们通过 @Builder 装饰器构建自定义 TabBar,将系统默认导航栏隐藏(barHeight(0)),在 Tabs 外部手动放置自定义导航栏。

2.1 自定义 TabBar 构建器
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:使用 @Builder 构建自定义底部导航栏

/**
 * 自定义底部导航栏
 * @description 使用 @Builder 在 Tabs 外部构建自定义 TabBar,
 *              通过 ForEach 渲染 Tab 项,点击时通过 TabsController 切换
 */
@Builder
CustomTabBar() {
    Row() {
        ForEach(this.tabItems, (item: TabItem) => {
            Column() {
                // 根据当前选中状态显示不同图标
                Image(this.currentIndex === item.index ? item.activeIcon : item.inactiveIcon)
                    .width(24)
                    .height(24)
                    .margin({ bottom: 2 })
                    .objectFit(ImageFit.Contain);
                // Tab 标签文字,选中态使用主题色,未选中使用灰色
                Text(item.label)
                    .fontSize(12)
                    .fontColor(this.currentIndex === item.index
                        ? ThemeColors.PRIMARY
                        : ThemeColors.TEXT_HINT)
                    .fontWeight(this.currentIndex === item.index
                        ? FontWeight.Medium
                        : FontWeight.Normal);
            }
            .layoutWeight(1)
            .height('100%')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
                // 避免重复切换当前 Tab
                if (this.currentIndex !== item.index) {
                    this.tabsController.changeIndex(item.index);
                }
            });
        }, (item: TabItem) => item.index.toString());
    }
    .width('100%')
    .height(64)                          // 导航栏高度 64vp
    .padding({ bottom: 8 })              // 底部安全距离
    .backgroundColor(ThemeColors.BG_PRIMARY)
    .border({ width: { top: 1 }, color: ThemeColors.BORDER_COLOR });  // 顶部分割线
}

关键实现细节

  • @Builder 装饰器:将 TabBar 抽离为独立的构建函数,使 build() 方法保持简洁。
  • ForEach 渲染:遍历 tabItems 数组动态生成 Tab 项,第三个参数使用 item.index.toString() 作为键值生成函数。
  • 状态驱动样式:通过 this.currentIndex === item.index 判断当前选中状态,动态切换图标和文字颜色。
  • tabsController.changeIndex():点击 Tab 项时,通过控制器编程式切换 Tab 页面,而不是直接修改 currentIndexcurrentIndexonChange 回调更新,确保数据流单向)。
2.2 自定义 TabBar 与系统 TabBar 对比
// TabBar 实现方式对比
// 文件路径:entry/src/main/ets/pages/MainTabs.ets

// ❌ 方式一:使用系统默认 TabBar(样式受限)
// 系统默认 TabBar 无法自定义高度、图标大小、间距、背景色等
Tabs({ barPosition: BarPosition.End }) {
    TabContent() { Index() }.tabBar('首页')
    TabContent() { Topics() }.tabBar('科普')
    TabContent() { Profile() }.tabBar('我的')
}

// ✅ 方式二:自定义 TabBar(推荐,本项目采用)
// 1. 将 Tabs 的 barHeight 设为 0,隐藏系统 TabBar
// 2. 在 Tabs 外部使用 @Builder 自定义导航栏
// 3. 通过 TabsController 联动切换

// 最终布局结构
Column() {
    Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
        TabContent() { Index() }
        TabContent() { Topics({ initialCategory: this.topicsCategory }) }
        TabContent() { Profile() }
    }
    .barHeight(0)          // 隐藏系统默认 TabBar
    .layoutWeight(1)       // Tabs 占据剩余空间

    this.CustomTabBar();   // 自定义 TabBar 放在 Tabs 下方
}
对比项系统 TabBar自定义 TabBar
图标自定义有限,仅支持系统图标完全自由,可使用任意资源图片
高度控制不可自定义完全可控
样式定制仅支持少量属性完全自由布局
动画效果系统预设可添加自定义动画
扩展性高,可添加角标、红点、动画等
实现复杂度简单中等,需要手动管理状态同步

步骤3: Tabs 容器与页面组装

功能说明

Tabs 容器与自定义 TabBar 组装为完整的主页面结构。关键在于 barPosition 设置为 BarPosition.End(底部)、barHeight(0) 隐藏系统导航栏、onChange 监听切换事件更新选中状态。

3.1 完整的 build 方法
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:Tabs 容器与自定义 TabBar 的完整组装

build() {
    Column() {
        // Tabs 容器:包含三个 TabContent,barPosition 设为底部
        Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
            // 首页 Tab
            TabContent() {
                Index();
            }

            // 科普 Tab,通过 initialCategory 透传分类参数
            TabContent() {
                Topics({ initialCategory: this.topicsCategory });
            }

            // 我的 Tab
            TabContent() {
                Profile();
            }
        }
        .barHeight(0)        // 隐藏系统默认 TabBar(由自定义 TabBar 替代)
        .onChange((index: number) => {
            // Tab 切换时同步更新 currentIndex,驱动自定义 TabBar 高亮
            this.currentIndex = index;
        })
        .layoutWeight(1)     // Tabs 占据 Column 剩余全部空间

        // 自定义底部导航栏(放在 Tabs 外部、Column 底部)
        this.CustomTabBar();
    }
    .width('100%')
    .height('100%')
    .backgroundColor(ThemeColors.BG_SECONDARY);
}

核心属性解析

  • barPosition: BarPosition.End:将 Tab 栏置于底部。虽然我们隐藏了系统 TabBar,但这个设置确保了 TabContent 的布局正确(内容区在上方,为底部导航栏留出空间)。
  • barHeight(0):将系统默认 TabBar 高度设为 0,完全隐藏。这是自定义 TabBar 方案的关键一步。
  • controller: this.tabsController:绑定控制器实例,使自定义 TabBar 的点击事件能够控制 Tab 切换。
  • onChange:Tab 切换的回调事件。无论是用户滑动切换还是通过 changeIndex() 编程式切换,都会触发此回调。我们在回调中更新 currentIndex,从而驱动自定义 TabBar 的视觉状态同步。
  • layoutWeight(1):让 Tabs 容器占据 Column 中除了自定义 TabBar(高度 64vp)之外的所有剩余空间。
3.2 Tabs 核心属性速查
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:Tabs 组件常用属性速查表

/**
 * Tabs 组件核心属性
 * @see https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/ts-container-tabs-V5
 */

// ====== 构造参数 ======
// barPosition: 导航栏位置
//   BarPosition.Start  - 顶部(默认)
//   BarPosition.End    - 底部
// controller: TabsController 实例,用于编程式控制

// ====== 常用属性 ======
// .barHeight(0)              - 导航栏高度,设为 0 隐藏系统 TabBar
// .barWidth('100%')          - 导航栏宽度
// .barBackgroundColor()      - 导航栏背景色
// .scrollable(true)          - Tab 超过一屏时是否可滚动
// .animationDuration(300)    - 切换动画时长(毫秒)
// .vertical(false)           - 是否垂直模式(默认水平)

// ====== 事件回调 ======
// .onChange((index: number) => {})     - Tab 切换回调
// .onAnimationStart((index, targetIndex, event) => {})  - 切换动画开始
// .onAnimationEnd((index, event) => {})                  - 切换动画结束
// .onGestureSwipe((index, event) => {})                  - 手势滑动回调

// ====== TabContent 属性 ======
// .tabBar()                   - 系统默认 TabBar 内容(使用自定义时可省略)
// .scrollable()               - 内容是否可滚动

步骤4: TabsController 编程式控制

功能说明

TabsControllerTabs 组件的控制器,提供了编程式切换 Tab 的能力。在本项目中,有以下场景需要编程式切换:

  1. 自定义 TabBar 点击:用户点击底部导航栏的 Tab 项
  2. 子页面联动切换:首页点击分类卡片跳转到科普 Tab
  3. 路由参数指定:通过路由跳转到 MainTabs 时指定初始 Tab
4.1 TabsController 核心方法
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:TabsController 核心方法演示

/**
 * TabsController 控制器
 * @description 提供 Tabs 容器的编程式控制能力
 */
// 创建控制器实例
private tabsController: TabsController = new TabsController();

// ====== 核心方法 ======

/**
 * 切换到指定索引的 Tab
 * @param index - 目标 Tab 的索引值(从 0 开始)
 * @description 会触发 onChange 回调
 */
this.tabsController.changeIndex(index: number): void;

/**
 * 获取当前显示的 Tab 索引
 * @returns 当前 Tab 的索引值
 */
this.tabsController.get currentIndex(): number;
4.2 初始化时处理路由参数
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:aboutToAppear 中处理路由参数,支持外部指定初始 Tab

aboutToAppear() {
    // Previewer环境下EntryAbility.onCreate可能不被调用,此处做数据初始化兜底
    if (!scienceData.getIsInitialized()) {
        try {
            const ctx = getContext(this);
            scienceData.init(ctx);
            userPrefs.init(ctx);
            quizEngine.init(ctx);
            achievementManager.init(ctx);
            Logger.info('MainTabs', 'Previewer环境数据初始化完成');
        } catch (err) {
            Logger.error('MainTabs', 'Previewer环境数据初始化失败', err as Error);
        }
    }

    // 处理路由参数:支持外部跳转时指定初始 Tab 和分类
    const params = RouterUtil.getParams() as Record<string, Object>;
    if (params) {
        // 指定初始 Tab 索引
        if (params.tabIndex !== undefined) {
            this.currentIndex = params.tabIndex as number;
            this.tabsController.changeIndex(this.currentIndex);
        }
        // 指定科普页的分类筛选参数
        if (params.categoryId) {
            this.topicsCategory = params.categoryId as string;
        }
    }
}

使用场景示例

// 从外部跳转到 MainTabs 并指定初始 Tab
// 例如:从通知页面跳转到科普 Tab
const options: RouterOptions = {
    url: 'pages/MainTabs',
    params: { tabIndex: 1, categoryId: 'space' }
};
RouterUtil.pushUrl(options, 'NotificationPage');

// 这样用户打开应用后就会直接看到科普页的"太空探索"分类内容

步骤5: 跨组件 Tab 联动切换

功能说明

这是本项目导航架构中最精妙的部分。在首页(Index 组件)中,用户点击分类卡片或"全部 >"按钮时,需要切换到科普 Tab 并携带分类参数。由于 IndexMainTabs 的子组件,不能直接访问 tabsController,我们通过 AppStorage + @Watch 实现跨层级通信。

5.1 MainTabs 中的监听逻辑
// 文件路径:entry/src/main/ets/pages/MainTabs.ets(续)
// 说明:通过 @StorageLink + @Watch 实现跨组件 Tab 联动

/**
 * 监听 AppStorage 中的 Tab 切换指令
 * @description 当子页面通过 AppStorage 发送切换指令时触发
 */
onTabSwitch(): void {
    if (this.switchToTab === 'topics') {
        // 切换到科普 Tab(index = 1)
        this.currentIndex = 1;
        this.tabsController.changeIndex(1);
        // 同步分类筛选参数
        if (this.storageTopicsCategory && this.storageTopicsCategory !== '') {
            this.topicsCategory = this.storageTopicsCategory;
        }
        // 清除指令,避免重复触发(重要!)
        AppStorage.setOrCreate<string>('switchToTab', '');
        AppStorage.setOrCreate<string>('topicsCategory', 'all');
    }
}

/**
 * 监听 AppStorage 中的分类变化
 * @description 当子页面仅发送分类参数时,自动切换到科普 Tab
 */
onCategoryChange(): void {
    if (this.storageTopicsCategory &&
        this.storageTopicsCategory !== '' &&
        this.storageTopicsCategory !== 'all') {
        this.topicsCategory = this.storageTopicsCategory;
        // 如果当前不在科普 Tab,自动切换过去
        if (this.currentIndex !== 1) {
            this.currentIndex = 1;
            this.tabsController.changeIndex(1);
        }
    }
}

数据流设计要点

  • 发送端(子页面如 Index):通过 AppStorage.setOrCreate('switchToTab', 'topics') 发送切换指令。
  • 接收端MainTabs):通过 @StorageLink('switchToTab') @Watch('onTabSwitch') 监听变化,执行切换逻辑。
  • 指令清除:切换完成后立即清除 AppStorage 中的指令值,防止用户手动切换 Tab 时再次触发旧指令。这是一个容易被忽略但非常关键的细节。
5.2 子页面发送切换指令
// 文件路径:entry/src/main/ets/pages/Index.ets
// 说明:首页子页面通过 AppStorage 通知 MainTabs 切换 Tab

/**
 * 跳转到指定分类的科普列表
 * @param category - 分类对象
 * @description 通过 AppStorage 发送切换指令和分类参数,
 *              MainTabs 的 @Watch 会自动响应
 */
goToCategory(category: Category): void {
    // 发送切换到科普 Tab 的指令
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    // 携带分类参数
    AppStorage.setOrCreate<string>('topicsCategory', category.id);
}

/**
 * 跳转到科普列表(全部分类)
 * @description 用户点击"全部 >"按钮时触发
 */
goToTopics(): void {
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    AppStorage.setOrCreate<string>('topicsCategory', 'all');
}

/**
 * Banner 点击跳转
 * @param banner - Banner 数据项
 */
onBannerClick(banner: BannerItem): void {
    if (banner.category) {
        AppStorage.setOrCreate<string>('switchToTab', 'topics');
        AppStorage.setOrCreate<string>('topicsCategory', banner.category);
    }
}
5.3 联动切换的数据流时序
┌──────────────────────────────────────────────────────────────┐
│              跨组件 Tab 联动切换时序图                          │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  用户操作                                                     │
│    │                                                         │
│    ▼                                                         │
│  Index.goToCategory()                                        │
│    │                                                         │
│    ├── AppStorage.set('switchToTab', 'topics')               │
│    └── AppStorage.set('topicsCategory', 'space')             │
│              │                                               │
│              ▼                                               │
│  MainTabs @Watch('onTabSwitch') 触发                         │
│    │                                                         │
│    ├── currentIndex = 1                                      │
│    ├── tabsController.changeIndex(1)                         │
│    │       │                                                 │
│    │       ▼                                                 │
│    │   Tabs 切换到 TabContent(1)                             │
│    │       │                                                 │
│    │       ▼                                                 │
│    │   Tabs.onChange(1) 触发                                 │
│    │       │                                                 │
│    │       ▼                                                 │
│    │   currentIndex = 1(再次确认同步)                       │
│    │                                                         │
│    ├── topicsCategory = 'space'                              │
│    │       │                                                 │
│    │       ▼                                                 │
│    │   Topics 组件 @Prop initialCategory 响应更新             │
│    │       │                                                 │
│    │       ▼                                                 │
│    │   Topics.aboutToUpdate() 触发                           │
│    │       │                                                 │
│    │       ▼                                                 │
│    │   Topics.loadTopics('space') 加载对应分类数据             │
│    │                                                         │
│    ├── AppStorage.set('switchToTab', '')     ← 清除指令       │
│    └── AppStorage.set('topicsCategory', 'all') ← 重置分类     │
│                                                              │
│  结果:用户看到科普页展示"太空探索"分类的文章列表               │
│                                                              │
└──────────────────────────────────────────────────────────────┘

步骤6: 独立 BottomTabBar 组件(备用方案)

功能说明

除了在 MainTabs 内部使用 @Builder 自定义 TabBar 之外,项目中还提供了独立的 BottomTabBar 组件(components/common/BottomTabBar.ets),用于非 Tabs 架构的页面。该组件通过路由跳转方式实现页面切换。

6.1 独立 BottomTabBar 组件
// 文件路径:entry/src/main/ets/components/common/BottomTabBar.ets
// 说明:独立的底部导航栏组件,用于非 Tabs 架构的页面

import { ThemeColors } from '../../constants/AppConstants';
import { RouterUtil } from '../../utils/RouterUtil';

/** Tab 项数据接口(独立组件版本,包含路由地址) */
export interface TabItem {
    index: number;
    icon: ResourceStr;
    activeIcon: Resource;
    inactiveIcon: Resource;
    label: string;
    /** 页面路由地址,用于路由跳转模式 */
    pageUrl: string;
}

@Component
export struct BottomTabBar {
    @State currentIndex: number = 0;

    private tabs: TabItem[] = [
        {
            index: 0,
            icon: $r('app.media.icon_home'),
            activeIcon: $r('app.media.tab_home_active'),
            inactiveIcon: $r('app.media.tab_home_inactive'),
            label: '首页',
            pageUrl: 'pages/Index'
        },
        {
            index: 1,
            icon: $r('app.media.icon_tab_topics'),
            activeIcon: $r('app.media.tab_topics_active'),
            inactiveIcon: $r('app.media.tab_topics_inactive'),
            label: '科普',
            pageUrl: 'pages/Topics'
        },
        {
            index: 2,
            icon: $r('app.media.icon_profile'),
            activeIcon: $r('app.media.tab_profile_active'),
            inactiveIcon: $r('app.media.tab_profile_inactive'),
            label: '我的',
            pageUrl: 'pages/Profile'
        }
    ];

    aboutToAppear() {
        // 根据当前路由状态确定选中哪个 Tab
        const state = RouterUtil.getState();
        const currentPage = state?.path;
        const currentTab = this.tabs.find(tab => tab.pageUrl === currentPage);
        if (currentTab) {
            this.currentIndex = currentTab.index;
        }
    }

    /**
     * 单个 Tab 项构建器
     * @param tab - Tab 项数据
     * @param isActive - 是否为当前选中状态
     */
    @Builder
    TabItemBuilder(tab: TabItem, isActive: boolean) {
        Column() {
            Image(isActive ? tab.activeIcon : tab.inactiveIcon)
                .width(24)
                .height(24)
                .margin({ bottom: 2 })
                .objectFit(ImageFit.Contain);
            Text(tab.label)
                .fontSize(11)
                .fontColor(isActive ? ThemeColors.PRIMARY : ThemeColors.TEXT_HINT);
        }
        .width('100%')
        .height('100%')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
            if (!isActive) {
                // 使用 replaceUrl 避免路由栈堆积
                RouterUtil.replaceUrl({ url: tab.pageUrl }, 'BottomTabBar');
            }
        });
    }

    build() {
        Row() {
            ForEach(this.tabs, (tab: TabItem) => {
                this.TabItemBuilder(tab, this.currentIndex === tab.index);
            }, (tab: TabItem) => tab.index.toString());
        }
        .width('100%')
        .height(56)
        .backgroundColor(ThemeColors.BG_PRIMARY)
        .border({ width: { top: 1 }, color: ThemeColors.BORDER_COLOR })
        .position({ x: 0, y: 0 })
        .bottom(0);  // 固定在页面底部
    }
}
6.2 两种 TabBar 方案对比
// 两种 TabBar 实现方案对比
// 文件路径:entry/src/main/ets/components/common/BottomTabBar.ets

/**
 * 方案选择建议
 *
 * ┌──────────────────┬─────────────────────┬─────────────────────┐
 * │                  │ @Builder 自定义      │ 独立组件 + 路由      │
 * │                  │ (MainTabs.ets)     │ (BottomTabBar.ets)│
 * ├──────────────────┼─────────────────────┼─────────────────────┤
 * │ 页面保活         │ ✅ TabContent 保活   │ ❌ 每次路由重建      │
 * │ 状态保持         │ ✅ 天然支持          │ ❌ 需手动保存恢复    │
 * │ 跨 Tab 联动      │ ✅ AppStorage 直通   │ ❌ 需要额外通信      │
 * │ 实现复杂度       │ 中等                │ 简单                │
 * │ 适用场景         │ 主页面一级导航       │ 独立页面需要导航栏   │
 * │ 本项目采用       │ ✅ 主入口页面        │ 备用,当前未使用     │
 * └──────────────────┴─────────────────────┴─────────────────────┘
 *
 * 结论:对于主页面一级导航,强烈推荐 Tabs + @Builder 自定义 TabBar 方案。
 *       独立 BottomTabBar 组件作为备用方案保留,适用于特殊场景。
 */

⚠️ 常见问题

问题1: 自定义 TabBar 点击后图标和文字没有切换

现象:点击底部导航栏的 Tab 项,页面内容切换了,但图标和文字的高亮状态没有更新。

原因:在 onClick 中直接修改了 currentIndex,但没有通过 TabsController 切换,或者 onChange 回调没有正确更新 currentIndex

解决方案

// ❌ 错误做法:只修改 currentIndex,不通过控制器切换
.onClick(() => {
    this.currentIndex = item.index;  // 只改了状态,Tabs 没有切换
})

// ✅ 正确做法:通过 TabsController 切换,onChange 回调会自动更新 currentIndex
.onClick(() => {
    if (this.currentIndex !== item.index) {
        this.tabsController.changeIndex(item.index);  // 控制器切换
    }
})

// 同时确保 onChange 回调中更新 currentIndex
.onChange((index: number) => {
    this.currentIndex = index;  // 关键:同步更新选中状态
})

原理:数据流应该是单向的:用户点击 -> TabsController.changeIndex() -> Tabs 内部切换 -> onChange 回调 -> 更新 currentIndex -> UI 刷新。不要在 onClick 中直接修改 currentIndex,否则会破坏数据流的单向性。

问题2: AppStorage 联动切换只触发一次

现象:第一次从首页点击分类卡片可以切换到科普 Tab,但之后再点击就没有反应了。

原因:切换完成后没有清除 AppStorage 中的指令值。当 switchToTab 的值不变时,@Watch 不会再次触发。

解决方案

// ❌ 错误做法:切换后不清除指令
onTabSwitch(): void {
    if (this.switchToTab === 'topics') {
        this.currentIndex = 1;
        this.tabsController.changeIndex(1);
        // 缺少清除操作!下次设置相同值时 @Watch 不会触发
    }
}

// ✅ 正确做法:切换完成后立即清除指令
onTabSwitch(): void {
    if (this.switchToTab === 'topics') {
        this.currentIndex = 1;
        this.tabsController.changeIndex(1);
        if (this.storageTopicsCategory && this.storageTopicsCategory !== '') {
            this.topicsCategory = this.storageTopicsCategory;
        }
        // 关键:清除指令,避免重复触发
        AppStorage.setOrCreate<string>('switchToTab', '');
        AppStorage.setOrCreate<string>('topicsCategory', 'all');
    }
}

问题3: TabContent 中的页面每次切换都会重新加载

现象:切换 Tab 后,页面内容闪烁,数据重新加载。

原因:在 TabContent 中直接写了组件的初始化逻辑,或者组件没有正确管理状态。如果组件在 aboutToAppear 中执行了耗时操作(如网络请求、文件读取),每次 Tab 切换都会重新执行。

解决方案

// ❌ 错误做法:每次 aboutToAppear 都重新加载数据
aboutToAppear() {
    // 每次组件创建都会执行,但如果 TabContent 不会销毁子组件,则不会重复调用
    this.loadData();  // 确认是否真的重复调用了
}

// ✅ 正确做法:Tabs + TabContent 天然保活,子组件的 aboutToAppear 只在首次创建时调用
// 如果发现重复加载,检查以下几点:
// 1. 是否在 TabContent 外部引用了组件(导致组件被销毁重建)
// 2. 是否使用了 if/else 条件渲染(导致组件树变化)
// 3. 是否在 onChange 中手动触发了数据刷新

// Tabs 的 TabContent 默认不会销毁子组件,这是其核心优势
Tabs() {
    TabContent() {
        Index();  // 只在首次显示时创建,之后切换不会重新创建
    }
    TabContent() {
        Topics({ initialCategory: this.topicsCategory });
    }
    TabContent() {
        Profile();
    }
}

问题4: barHeight(0) 后 TabContent 内容被截断

现象:隐藏系统 TabBar 后,TabContent 的底部内容被自定义 TabBar 遮挡。

原因barHeight(0) 只是隐藏了导航栏的视觉区域,但 TabContent 的内容区域计算可能仍然考虑了默认的导航栏高度。

解决方案

// ✅ 正确做法:使用 layoutWeight 确保布局正确
Column() {
    Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) {
        TabContent() { Index() }
        TabContent() { Topics({ initialCategory: this.topicsCategory }) }
        TabContent() { Profile() }
    }
    .barHeight(0)          // 隐藏系统 TabBar
    .layoutWeight(1)       // 关键:让 Tabs 填充剩余空间

    this.CustomTabBar();   // 自定义 TabBar 固定高度 64vp
}
.width('100%')
.height('100%')

// 这样 Tabs 会自动计算正确的内容区域高度
// = 总高度 - 自定义 TabBar 高度(64vp)

问题5: Previewer 环境下页面白屏或数据不显示

现象:在 DevEco Studio Previewer 中预览时,页面显示空白或加载失败。

原因:Previewer 环境下 EntryAbility.onCreate 可能不被调用,导致数据服务 scienceData 未初始化。

解决方案

// 文件路径:entry/src/main/ets/pages/MainTabs.ets
// 说明:在 aboutToAppear 中做数据初始化兜底

aboutToAppear() {
    // 检查数据是否已初始化(EntryAbility.onCreate 中已初始化)
    if (!scienceData.getIsInitialized()) {
        try {
            const ctx = getContext(this);
            // 兜底初始化所有数据服务
            scienceData.init(ctx);
            userPrefs.init(ctx);
            quizEngine.init(ctx);
            achievementManager.init(ctx);
            Logger.info('MainTabs', 'Previewer环境数据初始化完成');
        } catch (err) {
            Logger.error('MainTabs', 'Previewer环境数据初始化失败', err as Error);
        }
    }
    // ... 后续路由参数处理
}

问题6: 自定义 TabBar 在不同设备上的高度适配

现象:在平板或大屏设备上,底部导航栏高度显得不协调。

解决方案

// ✅ 根据设备类型动态调整 TabBar 高度
@Builder
CustomTabBar() {
    Row() {
        // ... ForEach 渲染 Tab 项
    }
    .width('100%')
    // 根据屏幕宽度判断设备类型
    .height(this.isTablet ? 72 : 64)
    .padding({ bottom: this.isTablet ? 12 : 8 })
    // ...
}

// 通过媒体查询或屏幕尺寸判断设备类型
@State isTablet: boolean = false;

aboutToAppear() {
    const displayInfo = display.getDefaultDisplaySync();
    const screenWidth = px2vp(displayInfo.width);
    this.isTablet = screenWidth >= 600;  // 600vp 以上视为平板
}

📝 本章小结

本文以《奇妙科学乐园》项目的主页面导航架构为蓝本,系统讲解了 HarmonyOS 中 Tabs 容器与自定义 TabBar 的完整实现方案,涵盖以下核心内容:

  1. Tabs 容器基础:通过 barPosition: BarPosition.End 设置底部导航模式,barHeight(0) 隐藏系统默认 TabBar,onChange 事件同步选中状态,三个 TabContent 分别承载首页、科普、个人中心。

  2. @Builder 自定义 TabBar:使用 @Builder 装饰器构建自定义导航栏,通过 ForEach 动态渲染 Tab 项,根据 currentIndex 状态动态切换图标和文字样式,完全替代系统默认 TabBar,实现儿童应用风格的底部导航。

  3. TabsController 编程式控制:创建 TabsController 实例绑定到 Tabs 组件,通过 changeIndex() 方法实现编程式 Tab 切换,支持路由参数指定初始 Tab。

  4. 跨组件 Tab 联动:通过 @StorageLink + @Watch 实现 MainTabs 与子组件(如 Index)之间的 Tab 切换通信,子页面通过 AppStorage.setOrCreate() 发送指令,MainTabs 监听后执行切换并清除指令,避免重复触发。

  5. Previewer 环境兜底:在 aboutToAppear 中检测数据服务是否已初始化,未初始化时主动执行兜底初始化,确保 Previewer 环境下也能正常显示。

  6. 独立 BottomTabBar 组件:提供了基于路由跳转的备用 TabBar 组件,适用于非 Tabs 架构的特殊场景。

这套导航架构的核心优势在于页面保活:三个主页面在首次创建后常驻内存,切换时不会销毁重建,状态自然保持。配合 AppStorage 联动机制,子页面可以无缝触发 Tab 切换和参数传递,为用户提供了流畅的一级导航体验。

下一篇文章,我们将进入滚动容器与长列表性能优化的学习,介绍 Scroll 组件的基础用法以及 LazyForEach 虚拟滚动在科普文章列表中的性能优化实践。


🔗 相关链接

更多推荐