HarmonyOS应用<奇妙科学乐园>开发第92篇:首页全链路开发——Banner+分类+推荐+骨架屏

引言
在前面 91 篇文章中,我们分别从架构设计、数据模型、组件封装、路由导航、性能优化等维度,逐一拆解了《奇妙科学乐园》的各个技术模块。然而,所有这些模块最终都要汇聚到一个最核心的页面——首页 Index.ets。首页是用户打开应用后看到的第一个页面,承载着 Banner 轮播、分类探索、科普推荐三大核心功能区域,以及骨架屏加载态和错误兜底态两大状态管理机制。它是一个将数据加载、状态管理、子组件编排、路由导航、跨 Tab 通信融为一体的综合性页面。
如果说前面 91 篇是零部件的生产线,那么本文就是最终组装环节:将 BannerCarousel、TopicCard、ScienceData、RouterUtil、AppStorage、骨架屏、错误状态等所有"零件"组装到 Index.ets 这一个页面中,实现一条从数据初始化到 UI 渲染的完整链路。
本文将从数据加载策略、三态切换机制、骨架屏实现、渐变头部区域、探索主题 Grid、科普推荐列表、跨 Tab 导航通信七个维度,对 Index.ets 进行全量拆解,力求让读者看完本文后,能够独立实现一个生产级的首页。
🔗 相关链接
- 项目源码:Atomgit仓库
- 上一篇:项目复盘方法论——全链路问题根因分析
- 下一篇:科普列表页开发——搜索/分类筛选/LazyForEach
学习目标
完成本文后,你将能够:
- ✅ 理解首页数据加载的完整流程:scienceData 初始化检查 -> 轮询等待 -> 数据填充 -> 状态切换
- ✅ 掌握三态切换模式的设计与实现:加载中(骨架屏)、加载失败(错误兜底)、正常展示
- ✅ 实现首页骨架屏的精确占位:头部渐变区域 + 分类网格 + 文章列表三段式占位
- ✅ 运用 linearGradient 实现渐变头部背景,营造品牌视觉氛围
- ✅ 编排 Swiper 轮播 + Grid 网格 + ForEach 列表的复合布局
- ✅ 通过 AppStorage 实现首页向科普 Tab 的跨组件通信与分类联动
- ✅ 理解 aboutToDisappear 中清理定时器的防内存泄漏机制
需求分析
首页整体布局结构
《奇妙科学乐园》首页是一个可滚动的纵向页面,自上而下分为三大核心区域,外加两种异常状态:
正常状态 UI 结构:
┌──────────────────────────────────────────┐
│ 渐变头部区域(#ff6b6b -> #ffa502) │
│ ┌─ Row(Logo + 标题 + 搜索图标)──┐ │
│ │ 🔬 奇妙科学乐园 🔍 │ │
│ └───────────────────────────────┘ │
│ ┌─ BannerCarousel ──────────────┐ │
│ │ Swiper 4张Banner自动轮播 │ │
│ │ 自定义指示器圆点 │ │
│ └───────────────────────────────┘ │
├──────────────────────────────────────────┤
│ 探索主题区(白色卡片容器) │
│ Row: "探索主题" "全部 >" │
│ ┌────────┬────────┬────────┐ │
│ │ 🚀 │ 🌿 │ 🌊 │ │
│ │太空探索 │自然世界 │海洋生物 │ │
│ │走进宇宙 │发现之美 │探索深海 │ │
│ ├────────┼────────┼────────┤ │
│ │ 🤖 │ 🧠 │ 🌈 │ │
│ │科技发明 │人体奥秘 │天气现象 │ │
│ │感受科技 │认识身体 │解读风云 │ │
│ └────────┴────────┴────────┘ │
├──────────────────────────────────────────┤
│ 科普知识区(白色卡片容器) │
│ Row: "科普知识" "更多 >" │
│ ┌──────────────────────────────┐ │
│ │ TopicCard 1(推荐文章) │ │
│ ├──────────────────────────────┤ │
│ │ TopicCard 2(推荐文章) │ │
│ ├──────────────────────────────┤ │
│ │ TopicCard 3(推荐文章) │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────────────┘
三态切换机制
首页的 build() 方法中通过 isLoading 和 hasError 两个布尔状态变量控制 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 篇文章中讨论的几乎所有技术模块(数据加载、组件封装、路由导航、状态管理、性能优化)融合到一个具体的业务场景中。理解首页的完整实现,就等于理解了整个应用的核心架构模式。
更多推荐


所有评论(0)