img

引言

在前面 91 篇文章中,我们分别从架构设计、数据模型、组件封装、路由导航、性能优化等维度,逐一拆解了《奇妙科学乐园》的各个技术模块。然而,所有这些模块最终都要汇聚到一个最核心的页面——首页 Index.ets。首页是用户打开应用后看到的第一个页面,承载着 Banner 轮播、分类探索、科普推荐三大核心功能区域,以及骨架屏加载态和错误兜底态两大状态管理机制。它是一个将数据加载、状态管理、子组件编排、路由导航、跨 Tab 通信融为一体的综合性页面。

如果说前面 91 篇是零部件的生产线,那么本文就是最终组装环节:将 BannerCarousel、TopicCard、ScienceData、RouterUtil、AppStorage、骨架屏、错误状态等所有"零件"组装到 Index.ets 这一个页面中,实现一条从数据初始化到 UI 渲染的完整链路。

本文将从数据加载策略、三态切换机制、骨架屏实现、渐变头部区域、探索主题 Grid、科普推荐列表、跨 Tab 导航通信七个维度,对 Index.ets 进行全量拆解,力求让读者看完本文后,能够独立实现一个生产级的首页。

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

🔗 相关链接


学习目标

完成本文后,你将能够:

  • ✅ 理解首页数据加载的完整流程:scienceData 初始化检查 -> 轮询等待 -> 数据填充 -> 状态切换
  • ✅ 掌握三态切换模式的设计与实现:加载中(骨架屏)、加载失败(错误兜底)、正常展示
  • ✅ 实现首页骨架屏的精确占位:头部渐变区域 + 分类网格 + 文章列表三段式占位
  • ✅ 运用 linearGradient 实现渐变头部背景,营造品牌视觉氛围
  • ✅ 编排 Swiper 轮播 + Grid 网格 + ForEach 列表的复合布局
  • ✅ 通过 AppStorage 实现首页向科普 Tab 的跨组件通信与分类联动
  • ✅ 理解 aboutToDisappear 中清理定时器的防内存泄漏机制

需求分析

首页整体布局结构

《奇妙科学乐园》首页是一个可滚动的纵向页面,自上而下分为三大核心区域,外加两种异常状态:

正常状态 UI 结构:
┌──────────────────────────────────────────┐
│  渐变头部区域(#ff6b6b -> #ffa502)        │
│  ┌─ Row(Logo + 标题 + 搜索图标)──┐      │
│  │  🔬  奇妙科学乐园         🔍    │      │
│  └───────────────────────────────┘      │
│  ┌─ BannerCarousel ──────────────┐      │
│  │  Swiper 4Banner自动轮播      │      │
│  │  自定义指示器圆点               │      │
│  └───────────────────────────────┘      │
├──────────────────────────────────────────┤
│  探索主题区(白色卡片容器)                │
│  Row: "探索主题"    "全部 >"             │
│  ┌────────┬────────┬────────┐           │
│  │  🚀    │  🌿    │  🌊    │           │
│  │太空探索 │自然世界 │海洋生物 │           │
│  │走进宇宙 │发现之美 │探索深海 │           │
│  ├────────┼────────┼────────┤           │
│  │  🤖    │  🧠    │  🌈    │           │
│  │科技发明 │人体奥秘 │天气现象 │           │
│  │感受科技 │认识身体 │解读风云 │           │
│  └────────┴────────┴────────┘           │
├──────────────────────────────────────────┤
│  科普知识区(白色卡片容器)                │
│  Row: "科普知识"    "更多 >"             │
│  ┌──────────────────────────────┐       │
│  │  TopicCard 1(推荐文章)      │       │
│  ├──────────────────────────────┤       │
│  │  TopicCard 2(推荐文章)      │       │
│  ├──────────────────────────────┤       │
│  │  TopicCard 3(推荐文章)      │       │
│  └──────────────────────────────┘       │
└──────────────────────────────────────────┘

三态切换机制

首页的 build() 方法中通过 isLoadinghasError 两个布尔状态变量控制 UI 展示:

状态 条件 展示内容 对应代码分支
加载中 isLoading === true 骨架屏占位 this.SkeletonContent()
加载失败 hasError === true 错误提示 + 重试按钮 this.ErrorContent()
正常展示 isLoading === false && hasError === false 完整首页内容 Column + Scroll

数据依赖关系

首页依赖以下数据源,全部通过 scienceData 单例获取:

数据项 获取方法 用途 消费组件
categories scienceData.getAllCategories() 探索主题分类网格 Grid + ForEach
recommendedTopics scienceData.getRecommendedTopics(3) 科普推荐列表(前3篇) ForEach + TopicCard
Banner 数据 BannerCarousel 组件内部硬编码 轮播图展示 Swiper

跨 Tab 导航通信

首页有两个导航入口需要跨 Tab 跳转:

导航入口 目标 通信方式 数据传递
点击分类卡片 科普 Tab 对应分类 AppStorage('switchToTab') + AppStorage('topicsCategory') category.id
点击"全部 >" / "更多 >" 科普 Tab 全部列表 AppStorage('switchToTab') + AppStorage('topicsCategory') 'all'
点击 Banner 科普 Tab 对应分类 AppStorage('switchToTab') + AppStorage('topicsCategory') banner.category

核心实现

步骤1: 文件导入与常量定义

1.1 功能说明

Index.ets 位于 entry/src/main/ets/pages/Index.ets,是首页的核心文件。文件头部需要导入所有被首页直接使用的组件、工具类、数据模型和常量。

1.2 完整导入代码

/*
 * 文件用途:首页 - 应用入口页面
 * 创建时间:2026-07-13
 * 兼容环境:HarmonyOS
 * 版本:v3.0
 * 风险提示:依赖scienceData初始化完成,未初始化时显示骨架屏
 */

import { BannerCarousel, BannerItem } from '../components/home/BannerCarousel';
import { TopicCard } from '../components/topic/TopicCard';
import { scienceData } from '../viewmodel/ScienceData';
import { Topic } from '../model/Topic';
import { Category } from '../model/Category';
import { RouteUrls } from '../constants/RouteUrls';
import { ThemeColors } from '../constants/AppConstants';
import { RouterUtil, RouterOptions, RouterParams } from '../utils/RouterUtil';
import { promptAction } from '@kit.ArkUI';
import { Logger } from '../utils/Logger';

const TAG = 'Index';

// 骨架屏占位颜色
const SKELETON_COLOR = '#e2e8f0';

1.3 导入分组说明

按照统一的导入规范,首页的导入分为三组:

分组 导入项 说明
系统库 promptAction(@kit.ArkUI) Toast 提示
业务组件 BannerCarousel, TopicCard 首页使用的子组件
数据/工具 scienceData, Topic, Category, RouteUrls, ThemeColors, RouterUtil, Logger 数据模型、路由、日志

正确 vs 错误对比

// ✅ 正确:按分组排列导入,组间空行
import { BannerCarousel, BannerItem } from '../components/home/BannerCarousel';
import { TopicCard } from '../components/topic/TopicCard';
import { scienceData } from '../viewmodel/ScienceData';

import { Logger } from '../utils/Logger';

// ❌ 错误:所有导入堆在一起,无分组
import { BannerCarousel, BannerItem } from '../components/home/BannerCarousel';
import { TopicCard } from '../components/topic/TopicCard';
import { Logger } from '../utils/Logger';
import { scienceData } from '../viewmodel/ScienceData';

步骤2: 状态变量定义与组件结构

2.1 功能说明

Index 组件使用 @State 装饰器声明响应式状态变量,这些变量驱动三态切换和 UI 更新。组件不使用 @Entry 装饰器(因为它是被 MainTabs 嵌入的子组件),使用 @Component + export struct 导出。

2.2 组件状态声明

@Component
export struct Index {
  // 推荐科普文章列表(首页展示前3篇)
  @State recommendedTopics: Topic[] = [];
  // 全部分类数据
  @State categories: Category[] = [];
  // 用户昵称(默认"小科学家")
  @State userName: string = '小科学家';
  // 是否正在加载(控制骨架屏/正常状态切换)
  @State isLoading: boolean = true;
  // 是否加载失败(控制错误状态/正常状态切换)
  @State hasError: boolean = false;
  // 轮询定时器ID(用于清理)
  private loadTimer: number = -1;

2.3 状态变量职责分析

状态变量 装饰器 初始值 驱动的 UI 变化时机
recommendedTopics @State [] 科普知识区 TopicCard 列表 loadData() 调用时
categories @State [] 探索主题区 Grid 网格 loadData() 调用时
userName @State '小科学家' 当前未在UI中使用(预留) 用户设置后
isLoading @State true build() 三态切换 aboutToAppear / retryLoad
hasError @State false build() 三态切换 startPolling 超时

设计决策:为什么 isLoading 默认为 true?

// ✅ 正确:默认 isLoading = true,首帧渲染骨架屏
@State isLoading: boolean = true;

// ❌ 错误:默认 isLoading = false,首帧会出现空白闪烁
@State isLoading: boolean = false;

首页组件挂载时,scienceData 可能尚未初始化完成。将 isLoading 默认设为 true,确保用户看到的第一帧是骨架屏,而不是一个空白的页面。等数据加载完成后,再切换到正常状态,给用户一个流畅的加载体验。


步骤3: 数据加载生命周期——aboutToAppear

3.1 功能说明

aboutToAppear() 是首页生命周期的起点。在这个方法中,首页需要检查 scienceData 是否已经初始化完成,并据此决定是直接加载数据、尝试初始化、还是启动轮询等待。

3.2 完整生命周期代码

  aboutToAppear() {
    // 快速路径:scienceData 已经初始化,直接加载数据
    if (scienceData.getIsInitialized()) {
      this.loadData();
    } else {
      // 慢速路径:尝试主动初始化(Previewer环境EntryAbility.onCreate可能不被调用)
      try {
        const ctx = getContext(this);
        scienceData.init(ctx);
      } catch (err) {
        Logger.error(TAG, '页面级数据初始化失败', err as Error);
      }
      // 初始化后再次检查
      if (scienceData.getIsInitialized()) {
        this.loadData();
      } else {
        // 仍未就绪,启动轮询等待(最多3秒)
        this.startPolling();
      }
    }
  }

3.3 数据加载流程图

aboutToAppear()
    │
    ├── scienceData 已初始化? ── YES ──> loadData() ──> 显示正常UI
    │
    └── NO ──> 尝试 scienceData.init(ctx)
                  │
                  ├── init 成功? ── YES ──> loadData() ──> 显示正常UI
                  │
                  └── init 失败或异步 ──> startPolling()
                                              │
                                              ├── 轮询检测到已初始化 ──> loadData()
                                              │
                                              └── 3秒超时 ──> hasError = true ──> 显示错误UI

3.4 为什么需要三级加载策略?

环境 scienceData 状态 处理策略 原因
真机/模拟器(正常流程) EntryAbility.onCreate 已调用 直接 loadData() init 已完成,无需等待
Previewer(正常流程) EntryAbility.onCreate 未被调用 页面级 init + loadData() 手动补充初始化
异步加载(rawfile 读取慢) init 调用后数据未就绪 startPolling() 轮询 等待异步 IO 完成
加载超时 3 秒后仍未就绪 hasError = true 兜底展示错误状态

步骤4: loadData() 与 startPolling() 实现

4.1 loadData()——数据填充方法

  /**
   * 从scienceData加载数据
   */
  private loadData(): void {
    this.isLoading = false;
    this.hasError = false;
    // 清理轮询定时器(如果存在)
    if (this.loadTimer >= 0) {
      clearInterval(this.loadTimer);
      this.loadTimer = -1;
    }
    // 加载推荐文章(前3篇)和全部分类
    this.recommendedTopics = scienceData.getRecommendedTopics(3);
    this.categories = scienceData.getAllCategories();
    Logger.info(TAG, `数据加载完成: 分类${this.categories.length}个, 文章${this.recommendedTopics.length}篇`);
  }

4.2 startPolling()——轮询等待机制

  /**
   * 轮询等待数据初始化,3秒超时后显示错误状态
   */
  private startPolling(): void {
    let elapsed = 0;
    const interval = 500;
    this.loadTimer = setInterval(() => {
      elapsed += interval;
      if (scienceData.getIsInitialized()) {
        // 数据就绪,立即加载
        this.loadData();
      } else if (elapsed >= 3000) {
        // 超时3秒仍未初始化,显示错误状态
        if (this.loadTimer >= 0) {
          clearInterval(this.loadTimer);
          this.loadTimer = -1;
        }
        this.isLoading = false;
        this.hasError = true;
        Logger.error(TAG, '数据加载超时(3秒),请检查rawfile是否正常');
      }
    }, interval);
  }

4.3 轮询机制关键参数

参数 说明
轮询间隔 500ms 平衡实时性和性能
最大超时 3000ms 6 次轮询后超时
清理机制 clearInterval loadData 成功或超时后清理

正确 vs 错误对比

// ✅ 正确:加载成功后清理定时器,防止内存泄漏
private loadData(): void {
    if (this.loadTimer >= 0) {
      clearInterval(this.loadTimer);
      this.loadTimer = -1;
    }
    // ...
}

// ❌ 错误:忘记清理定时器,导致定时器持续运行
private loadData(): void {
    this.isLoading = false;
    this.recommendedTopics = scienceData.getRecommendedTopics(3);
    // loadTimer 未清理!
}

步骤5: aboutToDisappear()——生命周期清理

5.1 功能说明

当首页组件被销毁时(虽然实际场景中首页被 Tabs 包裹通常不会被销毁,但作为防御性编程仍然需要),必须清理轮询定时器,防止内存泄漏。

5.2 清理代码

  aboutToDisappear() {
    if (this.loadTimer >= 0) {
      clearInterval(this.loadTimer);
    }
  }

正确 vs 错误对比

// ✅ 正确:aboutToDisappear 中清理所有资源
aboutToDisappear() {
    if (this.loadTimer >= 0) {
      clearInterval(this.loadTimer);
    }
}

// ❌ 错误:不实现 aboutToDisappear,定时器可能泄漏
// 组件没有 aboutToDisappear 方法

步骤6: retryLoad()——错误恢复机制

6.1 功能说明

当数据加载超时后,用户可以通过点击"重新加载"按钮触发 retryLoad()。该方法将页面状态重置为加载中,然后重新尝试加载数据。

6.2 重试逻辑

  /**
   * 重试加载数据
   */
  private retryLoad(): void {
    // 重置为加载中状态,显示骨架屏
    this.isLoading = true;
    this.hasError = false;
    if (scienceData.getIsInitialized()) {
      // 已初始化则直接加载
      this.loadData();
    } else {
      // 未初始化则重新轮询
      this.startPolling();
    }
  }

6.3 重试状态流转

错误状态 (hasError=true)
    │
    └── 用户点击"重新加载" ──> retryLoad()
                                  │
                                  ├── isLoading = true, hasError = false
                                  │
                                  ├── scienceData 已初始化? ──> loadData()
                                  │
                                  └── 未初始化 ──> startPolling() ──> 成功/超时

步骤7: 导航方法实现

7.1 功能说明

首页提供四个导航方法,分别用于跳转到文章详情、分类列表、科普 Tab、搜索功能。

7.2 导航代码

  // 跳转到文章详情页
  goToTopicDetail(topic: Topic): void {
    const params: RouterParams = { topicId: topic.id };
    const options: RouterOptions = {
      url: RouteUrls.TOPIC_DETAIL,
      params: params
    };
    RouterUtil.pushUrl(options, 'Index');
  }

  // 跳转到指定分类的科普列表(跨Tab)
  goToCategory(category: Category): void {
    // 使用AppStorage通知MainTabs切换到科普Tab并筛选分类
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    AppStorage.setOrCreate<string>('topicsCategory', category.id);
  }

  // 跳转到科普Tab全部列表(跨Tab)
  goToTopics(): void {
    // 使用AppStorage通知MainTabs切换到科普Tab
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    AppStorage.setOrCreate<string>('topicsCategory', 'all');
  }

  // 搜索功能(当前为占位)
  goToSearch(): void {
    promptAction.showToast({
      message: '搜索功能开发中...',
      duration: 1500
    });
  }

  // Banner点击处理
  onBannerClick(banner: BannerItem): void {
    if (banner.category) {
      AppStorage.setOrCreate<string>('switchToTab', 'topics');
      AppStorage.setOrCreate<string>('topicsCategory', banner.category);
    }
  }

7.3 导航方式对比

导航目标 导航方式 原因
文章详情 RouterUtil.pushUrl() 页面级跳转,需要传参 topicId
科普 Tab(分类) AppStorage('switchToTab') 跨 Tab 切换,需通知 MainTabs
科普 Tab(全部) AppStorage('switchToTab') 跨 Tab 切换
Banner 分类 AppStorage('switchToTab') 跨 Tab 切换

正确 vs 错误对比

// ✅ 正确:跨Tab切换使用 AppStorage,不使用 router
goToCategory(category: Category): void {
    AppStorage.setOrCreate<string>('switchToTab', 'topics');
    AppStorage.setOrCreate<string>('topicsCategory', category.id);
}

// ❌ 错误:跨Tab切换使用 router.pushUrl 会打开新页面栈
goToCategory(category: Category): void {
    const options: RouterOptions = {
      url: RouteUrls.TOPICS,
      params: { categoryId: category.id }
    };
    RouterUtil.pushUrl(options, 'Index');
}

步骤8: 骨架屏实现——SkeletonContent

8.1 功能说明

骨架屏是首页加载状态的视觉占位。它模拟了正常 UI 的布局结构,用灰色矩形块代替真实内容,让用户感知到"内容即将出现"。Index 的骨架屏精确复刻了三个区域的布局:头部渐变区域、探索主题区、科普知识区。

8.2 骨架屏完整代码

  /**
   * 骨架屏:首页加载中占位
   */
  @Builder
  SkeletonContent() {
    Scroll() {
      Column() {
        // ── 头部渐变区域骨架 ──
        Column()
          .width('100%')
          .height(230)
          .margin({ left: 16, right: 16 })
          .linearGradient({
            angle: 135,
            colors: [['#ff6b6b', 0], ['#ffa502', 1]]
          })
          .borderRadius(20)
          .opacity(0.3);

        // ── 探索主题区骨架 ──
        Column() {
          // 标题占位条
          Column()
            .width('40%')
            .height(20)
            .backgroundColor(SKELETON_COLOR)
            .borderRadius(8)
            .margin({ bottom: 16 });

          // 分类卡片网格占位(6个灰色块)
          Grid() {
            ForEach([1, 2, 3, 4, 5, 6], (_: number) => {
              GridItem() {
                Column()
                  .width('100%')
                  .height(90)
                  .backgroundColor(SKELETON_COLOR)
                  .borderRadius(12);
              }
            }, (_: number, index: number) => index.toString());
          }
          .columnsTemplate('1fr 1fr 1fr')
          .columnsGap(12)
          .rowsGap(16)
          .width('100%');
        }
        .width('100%')
        .padding(16)
        .backgroundColor(ThemeColors.BG_PRIMARY)
        .borderRadius(16)
        .margin({ left: 16, right: 16, bottom: 14 });

        // ── 科普知识区骨架 ──
        Column() {
          // 标题占位条
          Column()
            .width('40%')
            .height(20)
            .backgroundColor(SKELETON_COLOR)
            .borderRadius(8)
            .margin({ bottom: 14 });

          // 文章卡片占位(3个灰色块)
          Column()
            .width('100%')
            .height(100)
            .backgroundColor(SKELETON_COLOR)
            .borderRadius(12)
            .margin({ bottom: 8 });
          Column()
            .width('100%')
            .height(100)
            .backgroundColor(SKELETON_COLOR)
            .borderRadius(12)
            .margin({ bottom: 8 });
          Column()
            .width('100%')
            .height(100)
            .backgroundColor(SKELETON_COLOR)
            .borderRadius(12);
        }
        .width('100%')
        .padding(16)
        .backgroundColor(ThemeColors.BG_PRIMARY)
        .borderRadius(16)
        .margin({ left: 16, right: 16, bottom: 16 });
      }
      .width('100%');
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off);
  }

8.3 骨架屏设计要点

区域 占位元素 尺寸 说明
头部渐变 单个渐变矩形 height: 230 半透明(opacity: 0.3)模拟渐变效果
分类标题 灰色短条 width: 40%, height: 20 模拟"探索主题"文字
分类网格 6个灰色块 3x2 Grid, height: 90 匹配正常UI的3列布局
推荐标题 灰色短条 width: 40%, height: 20 模拟"科普知识"文字
推荐列表 3个灰色块 height: 100, gap: 8 模拟 TopicCard 卡片

正确 vs 错误对比

// ✅ 正确:骨架屏精确复刻正常UI的布局结构
Column() {
    // 头部区域骨架
    Column()
      .height(230)
      .linearGradient({ angle: 135, colors: [['#ff6b6b', 0], ['#ffa502', 1]] })
      .opacity(0.3);

    // 分类网格骨架
    Grid() {
      ForEach([1, 2, 3, 4, 5, 6], () => { /* 灰色块 */ });
    }
    .columnsTemplate('1fr 1fr 1fr');  // 与正常UI一致
}

// ❌ 错误:骨架屏使用单一灰色块,不匹配真实布局
Column() {
    Column()
      .width('100%')
      .height(600)
      .backgroundColor(SKELETON_COLOR);
}

步骤9: 错误状态实现——ErrorContent

9.1 功能说明

错误状态在数据加载超时(3秒)后展示。设计上采用"头部正常 + 内容区错误"的混合策略:头部渐变区域和 Banner 轮播图正常展示(因为 Banner 数据是组件内部硬编码的,不依赖 scienceData),仅数据依赖区域替换为错误提示和重试按钮。

9.2 错误状态完整代码

  /**
   * 错误状态:数据加载失败兜底
   */
  @Builder
  ErrorContent() {
    Column() {
      // ── 头部区域正常显示(Banner不依赖数据)──
      Column() {
        Row() {
          Image($r('app.media.icon_logo'))
            .width(32)
            .height(32)
            .objectFit(ImageFit.Contain)
            .fillColor(ThemeColors.TEXT_WHITE);

          Column() {
            Text('奇妙科学乐园')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(ThemeColors.TEXT_WHITE);
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 });

          Image($r('app.media.icon_search'))
            .width(20)
            .height(20)
            .objectFit(ImageFit.Contain)
            .fillColor(ThemeColors.TEXT_WHITE);
        }
        .width('100%')
        .padding({ top: 16, left: 4, right: 4, bottom: 16 });

        BannerCarousel({
          onBannerClick: (banner: BannerItem) => this.onBannerClick(banner)
        });
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 16 })
      .linearGradient({
        angle: 135,
        colors: [['#ff6b6b', 0], ['#ffa502', 1]]
      })
      .borderRadius(20)
      .clip(true);

      // ── 错误提示区域 ──
      Column() {
        Image($r('app.media.icon_empty'))
          .width(48)
          .height(48)
          .margin({ bottom: 12 });
        Text('数据加载失败')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor(ThemeColors.TEXT_PRIMARY)
          .margin({ bottom: 8 });
        Text('请尝试重新加载')
          .fontSize(14)
          .fontColor(ThemeColors.TEXT_SECONDARY)
          .margin({ bottom: 24 });
        Button('重新加载')
          .type(ButtonType.Capsule)
          .height(40)
          .padding({ left: 32, right: 32 })
          .fontSize(15)
          .backgroundColor(ThemeColors.PRIMARY)
          .onClick(() => {
            this.retryLoad();
          });
      }
      .width('100%')
      .layoutWeight(1)
      .justifyContent(FlexAlign.Center)
      .padding(32);
    }
    .width('100%')
    .height('100%')
    .backgroundColor(ThemeColors.BG_SECONDARY);
  }

9.3 错误状态设计策略

设计决策 说明
头部正常展示 Banner 数据硬编码在组件内部,不依赖 scienceData,可以正常展示
数据区替换为错误 分类和文章数据依赖 scienceData,加载失败时替换为错误提示
提供重试按钮 Button('重新加载') 调用 retryLoad(),给用户恢复路径
图标 + 文字 + 按钮 三层信息架构:视觉提示 -> 文字说明 -> 操作引导

步骤10: build() 方法——三态切换与正常UI

10.1 功能说明

build() 方法是首页的 UI 入口。它通过 if-else if-else 三分支实现三态切换,其中正常状态分支包含完整的首页 UI 结构:渐变头部 + BannerCarousel + 探索主题 Grid + 科普推荐列表。

10.2 build() 三态切换框架

  build() {
    if (this.isLoading) {
      // ── 第一态:加载中 → 骨架屏 ──
      Column() {
        this.SkeletonContent();
      }
      .width('100%')
      .height('100%')
      .backgroundColor(ThemeColors.BG_SECONDARY);
    } else if (this.hasError) {
      // ── 第二态:加载失败 → 错误兜底 ──
      this.ErrorContent();
    } else {
      // ── 第三态:正常展示 → 完整首页 ──
      Column() {
        Scroll() {
          Column() {
            // 渐变头部 + BannerCarousel
            // 探索主题 Grid
            // 科普推荐 TopicCard 列表
          }
          .width('100%');
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        .edgeEffect(EdgeEffect.Spring);
      }
      .width('100%')
      .height('100%')
      .backgroundColor(ThemeColors.BG_SECONDARY);
    }
  }

10.3 正常状态——渐变头部区域

            // 头部渐变区域
            Column() {
              Row() {
                Image($r('app.media.icon_logo'))
                  .width(32)
                  .height(32)
                  .objectFit(ImageFit.Contain)
                  .fillColor(ThemeColors.TEXT_WHITE);

                Column() {
                  Text('奇妙科学乐园')
                    .fontSize(20)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(ThemeColors.TEXT_WHITE);
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 });

                Image($r('app.media.icon_search'))
                  .width(20)
                  .height(20)
                  .objectFit(ImageFit.Contain)
                  .fillColor(ThemeColors.TEXT_WHITE)
                  .onClick(() => {
                    this.goToSearch();
                  });
              }
              .width('100%')
              .padding({ top: 16, left: 4, right: 4, bottom: 16 });

              BannerCarousel({
                onBannerClick: (banner: BannerItem) => this.onBannerClick(banner)
              });
            }
            .width('100%')
            .padding({ left: 16, right: 16, bottom: 16 })
            .linearGradient({
              angle: 135,
              colors: [['#ff6b6b', 0], ['#ffa502', 1]]
            })
            .borderRadius(20)
            .clip(true);

10.4 渐变头部布局分析

Column(渐变容器)
├── linearGradient: angle 135, #ff6b6b → #ffa502
├── borderRadius: 20(顶部圆角)
├── clip(true)(裁剪超出圆角的子元素)
│
├── Row(Logo行)
│   ├── Image(icon_logo)          // 32x32 Logo
│   ├── Column                    // layoutWeight(1) 占据中间空间
│   │   └── Text('奇妙科学乐园')  // 20fp 加粗 白色
│   └── Image(icon_search)        // 20x20 搜索图标
│
└── BannerCarousel               // Swiper 轮播组件

正确 vs 错误对比

// ✅ 正确:clip(true) 确保圆角裁剪生效
Column()
  .borderRadius(20)
  .clip(true);

// ❌ 错误:不使用 clip(true),子组件图片会溢出圆角
Column()
  .borderRadius(20);
  // 图片矩形四个角会超出圆角边界

步骤11: 正常状态——探索主题 Grid 区域

11.1 功能说明

探索主题区是一个白色圆角卡片容器,内部使用 Grid + ForEach 渲染分类卡片。顶部标题行包含"探索主题"标题和"全部 >"链接,点击"全部 >"跳转到科普 Tab 全部列表。

11.2 探索主题区完整代码

            // 探索主题区
            Column() {
              // 标题行:左侧标题 + 右侧"全部 >"链接
              Row() {
                Text('探索主题')
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(ThemeColors.TEXT_PRIMARY)
                  .layoutWeight(1);
                Text('全部 >')
                  .fontSize(14)
                  .fontColor(ThemeColors.PRIMARY)
                  .onClick(() => {
                    this.goToTopics();
                  });
              }
              .width('100%')
              .margin({ bottom: 16 });

              // 分类网格:3列等宽布局
              Grid() {
                ForEach(this.categories, (category: Category) => {
                  GridItem() {
                    Column() {
                      Image(category.iconImage)
                        .width(48)
                        .height(48)
                        .borderRadius(24)
                        .objectFit(ImageFit.Cover)
                        .margin({ bottom: 8 });

                      Text(category.name)
                        .fontSize(14)
                        .fontWeight(FontWeight.Medium)
                        .fontColor(ThemeColors.TEXT_PRIMARY)
                        .margin({ bottom: 2 });

                      Text(category.description)
                        .fontSize(11)
                        .fontColor('#999');
                    }
                    .alignItems(HorizontalAlign.Center)
                    .onClick(() => {
                      this.goToCategory(category);
                    });
                  }
                }, (category: Category) => category.id);
              }
              .columnsTemplate('1fr 1fr 1fr')
              .columnsGap(12)
              .rowsGap(16)
              .width('100%');
            }
            .width('100%')
            .padding(16)
            .backgroundColor(ThemeColors.BG_PRIMARY)
            .borderRadius(16)
            .margin({ left: 16, right: 16, bottom: 14 })
            .border({ width: 1, color: '#e8e8e8' })
            .shadow({ radius: 8, color: 'rgba(0, 0, 0, 0.06)', offsetY: 4 });

11.3 Grid 配置参数

属性 说明
columnsTemplate '1fr 1fr 1fr' 3列等宽布局
columnsGap 12 列间距 12vp
rowsGap 16 行间距 16vp
ForEach key category.id 使用唯一 ID 作为 key

11.4 分类卡片内部结构

GridItem
└── Column(垂直居中排列)
    ├── Image(category.iconImage)    // 48x48 圆角图标
    ├── Text(category.name)          // 分类名称 14fp
    └── Text(category.description)    // 描述 11fp 灰色

正确 vs 错误对比

// ✅ 正确:ForEach 使用 category.id 作为 key,保证列表稳定性
ForEach(this.categories, (category: Category) => { /* ... */ },
  (category: Category) => category.id);

// ❌ 错误:使用 index 作为 key,列表更新时可能出现闪烁
ForEach(this.categories, (category: Category, index: number) => { /* ... */ },
  (_: Category, index: number) => index.toString());

步骤12: 正常状态——科普推荐列表区域

12.1 功能说明

科普知识区展示 scienceData 推荐的前 3 篇文章,使用 ForEach 遍历 recommendedTopics 数组,为每篇文章渲染一个 TopicCard 组件。点击卡片跳转到文章详情页。

12.2 科普推荐区完整代码

            // 科普知识区
            Column() {
              // 标题行
              Row() {
                Text('科普知识')
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(ThemeColors.TEXT_PRIMARY)
                  .layoutWeight(1);
                Text('更多 >')
                  .fontSize(14)
                  .fontColor(ThemeColors.PRIMARY)
                  .onClick(() => {
                    this.goToTopics();
                  });
              }
              .width('100%')
              .margin({ bottom: 14 });

              // 推荐文章列表
              ForEach(this.recommendedTopics, (topic: Topic) => {
                TopicCard({
                  topic: topic,
                  onItemClick: (t: Topic) => this.goToTopicDetail(t)
                });
              }, (topic: Topic) => topic.id.toString());
            }
            .width('100%')
            .padding(16)
            .backgroundColor(ThemeColors.BG_PRIMARY)
            .borderRadius(16)
            .margin({ left: 16, right: 16, bottom: 16 })
            .border({ width: 1, color: '#e8e8e8' })
            .shadow({ radius: 8, color: 'rgba(0, 0, 0, 0.06)', offsetY: 4 });

12.3 TopicCard 调用参数

参数 说明
topic 当前遍历的 Topic 对象 包含标题、分类、内容、阅读量等
onItemClick goToTopicDetail() 点击回调,通过 RouterUtil 跳转详情页

正确 vs 错误对比

// ✅ 正确:TopicCard 使用 topic.id 作为 ForEach key
ForEach(this.recommendedTopics, (topic: Topic) => {
    TopicCard({ topic: topic, onItemClick: (t) => this.goToTopicDetail(t) });
}, (topic: Topic) => topic.id.toString());

// ❌ 错误:回调中直接修改 props
ForEach(this.recommendedTopics, (topic: Topic) => {
    TopicCard({ topic: topic, onItemClick: () => { topic.readCount++; } });
    // 直接修改 @Prop 数据,违反单向数据流原则
}, (topic: Topic) => topic.id.toString());

步骤13: Scroll 容器配置

13.1 功能说明

正常状态下的首页外层使用 Scroll 容器包裹,支持纵向滚动浏览全部内容。Scroll 容器配置了 edgeEffect(EdgeEffect.Spring) 实现弹性滚动效果。

13.2 Scroll 配置代码

        Scroll() {
          Column() {
            // 头部渐变区域
            // 探索主题区
            // 科普知识区
          }
          .width('100%');
        }
        .width('100%')
        .layoutWeight(1)            // 占满剩余空间
        .scrollBar(BarState.Off)   // 隐藏滚动条
        .edgeEffect(EdgeEffect.Spring);  // iOS风格弹性滚动

13.3 Scroll 配置要点

属性 说明
layoutWeight 1 在父 Column 中占满剩余高度
scrollBar BarState.Off 隐藏滚动条,保持界面整洁
edgeEffect EdgeEffect.Spring 弹性回弹效果,提升滚动体验

进阶优化

优化1: 三态分支的性能考量

当前实现中,三态切换通过 if-else if-else 实现,每次状态切换都会完全重建 UI 树。对于首页这种复杂页面,这是可以接受的,因为三态切换只会发生一两次(加载中 -> 正常 或 加载中 -> 错误 -> 正常)。

// ✅ 正确:三态切换是低频操作,if-else 完全够用
build() {
    if (this.isLoading) {
      // 骨架屏
    } else if (this.hasError) {
      // 错误状态
    } else {
      // 正常UI
    }
}

// ❌ 错误:用 Visibility 控制三态,三个UI树同时存在,浪费内存
build() {
    Column() {
      this.SkeletonContent().visibility(this.isLoading ? Visibility.Visible : Visibility.Hidden);
      this.ErrorContent().visibility(this.hasError ? Visibility.Visible : Visibility.Hidden);
      // 正常UI...
    }
}

优化2: 轮询间隔的合理选择

// ✅ 正确:500ms 间隔 + 3秒超时,平衡体验和性能
const interval = 500;
const MAX_ELAPSED = 3000;

// ❌ 错误:100ms 间隔 + 10秒超时,轮询过于频繁
const interval = 100;
const MAX_ELAPSED = 10000;

优化3: 跨 Tab 通信的数据清除

// ✅ 正确:MainTabs 中切换后清除 AppStorage 指令,防止重复触发
onTabSwitch(): void {
    if (this.switchToTab === 'topics') {
      this.currentIndex = 1;
      this.tabsController.changeIndex(1);
      AppStorage.setOrCreate<string>('switchToTab', '');   // 清除指令
      AppStorage.setOrCreate<string>('topicsCategory', 'all');
    }
}

常见问题

Q1: 为什么首页不使用 @Entry 装饰器?

Index.ets 不使用 @Entry 是因为它被 MainTabs 的 TabContent 嵌入使用,不需要作为独立的页面入口。只有 MainTabs 使用 @Entry 作为应用的真正入口页面。

Q2: 为什么需要在 aboutToAppear 中检查 scienceData 的初始化状态?

因为 Index 被 MainTabs 的 TabContent 包裹,在 Previewer 环境中,EntryAbility.onCreate() 可能不会被调用,导致 scienceData 未初始化。首页需要自行检查并尝试初始化,确保 Previewer 环境也能正常展示 UI。

Q3: 骨架屏和错误状态为什么用 @Builder 而不是独立组件?

骨架屏和错误状态只在首页使用,不需要复用。使用 @Builder 可以直接访问当前组件的状态变量(isLoading、hasError),省去了通过 props 传递数据的开销。如果未来需要复用,可以提取为独立组件。

Q4: goToCategory 为什么要用 AppStorage 而不是 router?

因为首页和科普列表页(Topics)位于同一个 Tabs 的不同 TabContent 中。使用 router.pushUrl() 会打开一个新的页面栈,而用户期望的是在同一页面内切换 Tab。AppStorage 通过 MainTabs 中的 @StorageLink('switchToTab') 监听器实现 Tab 切换,更符合用户预期。


总结

本文对《奇妙科学乐园》首页 Index.ets 进行了全链路拆解,覆盖了以下核心内容:

模块 核心技术点
数据加载 三级加载策略:直接加载 -> 页面级初始化 -> 轮询等待
三态切换 isLoading + hasError 双布尔值驱动 if-else 分支
骨架屏 @Builder + 精确占位模拟真实布局结构
错误兜底 头部保留 + 数据区替换为错误提示 + 重试按钮
渐变头部 linearGradient + clip(true) + Row + BannerCarousel
分类网格 Grid + ForEach + columnsTemplate('1fr 1fr 1fr')
推荐列表 ForEach + TopicCard + RouterUtil.pushUrl
跨 Tab 导航 AppStorage('switchToTab') + AppStorage('topicsCategory')
生命周期管理 aboutToDisappear 清理定时器防内存泄漏

首页是一个综合性的实战页面,它将前面 91 篇文章中讨论的几乎所有技术模块(数据加载、组件封装、路由导航、状态管理、性能优化)融合到一个具体的业务场景中。理解首页的完整实现,就等于理解了整个应用的核心架构模式。


下一篇第93篇:科普列表页开发——搜索/分类筛选/LazyForEach

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

Logo

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

更多推荐