img

📖 引言

在《奇妙科学乐园》的开发过程中,我们遇到了一个极为隐蔽的环境差异问题:所有页面在DevEco Studio Previewer中预览时白屏无数据,但在真机和模拟器上运行一切正常。经过数小时的排查,我们发现了根本原因——**DevEco Studio Previewer不会调用UIAbility.onCreate()**。这意味着我们在EntryAbility.onCreate()中做的全部数据初始化(scienceDatauserPrefsquizEngineachievementManager)在Previewer环境中根本不会执行。

这个发现迫使我们对整个项目的初始化架构进行重新审视。最终,我们在每个页面的aboutToAppear()生命周期回调中添加了数据初始化兜底逻辑,配合轮询等待机制,确保无论在何种运行环境下,页面都能正确获取到所需数据。本文将从HarmonyOS页面生命周期的完整流程讲起,结合项目中真实的初始化兜底、定时器清理、Previewer兼容等实战场景,全面解析aboutToAppear/aboutToDisappear的正确使用方式。

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


🎯 学习目标

完成本文后,你将能够:

  • ✅ 理解HarmonyOS组件生命周期的完整流程和各阶段职责
  • ✅ 掌握aboutToAppear的正确使用场景:数据初始化、参数获取、资源注册
  • ✅ 掌握aboutToDisappear的正确使用场景:资源释放、定时器清理、事件解绑
  • ✅ 理解DevEco Studio Previewer与真机运行环境的初始化差异
  • ✅ 学会设计数据初始化兜底+轮询等待的健壮架构
  • ✅ 避免常见的生命周期管理陷阱:重复初始化、定时器泄漏、条件渲染问题

💡 需求分析

HarmonyOS生命周期体系概览

HarmonyOS有两套生命周期:UIAbility生命周期组件生命周期。两者职责不同,调用时机也不同。

生命周期层级 生命周期方法 调用时机 本项目用途
UIAbility onCreate() 应用启动时 初始化全局数据服务
UIAbility onDestroy() 应用销毁时 释放全局资源
UIAbility onWindowStageCreate() 窗口创建后 加载入口页面
UIAbility onForeground() 应用前台 恢复UI状态
UIAbility onBackground() 应用后台 暂停UI更新
组件 aboutToAppear() 组件即将显示 数据加载、参数获取、兜底初始化
组件 aboutToDisappear() 组件即将销毁 定时器清理、资源释放
组件 aboutToUpdate() 组件即将更新 Prop变化时同步内部状态
组件 onPageShow() 页面显示时 埋点统计(@Entry页面)
组件 onPageHide() 页面隐藏时 暂停定时器(@Entry页面)

本项目中生命周期管理的核心挑战

挑战 涉及页面 风险等级 解决方案
Previewer不调用onCreate MainTabs、Index、Topics aboutToAppear兜底初始化
异步数据加载时序不确定 Index、Topics 轮询等待+超时兜底
setInterval定时器未清理 Index、Topics aboutToDisappear清除
Tabs子组件生命周期不明确 Index、Topics、Profile 明确TabContent保活机制

🛠️ 核心实现

步骤1: UIAbility onCreate——正常环境下的数据初始化

功能说明

在正常的运行环境(真机、模拟器)中,EntryAbility.onCreate()是应用启动后第一个执行的初始化入口。我们在其中完成所有全局数据服务的初始化:scienceData(科普数据)、userPrefs(用户偏好)、quizEngine(答题引擎)、achievementManager(成就管理器)。

完整代码

// 文件路径:entry/src/main/ets/entryability/EntryAbility.ets
// 说明:应用入口Ability,负责全局服务初始化

import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';

import { userPrefs } from '../viewmodel/UserPreferences';
import { achievementManager } from '../viewmodel/AchievementManager';
import { scienceData } from '../viewmodel/ScienceData';
import { quizEngine } from '../viewmodel/QuizEngine';
import { Logger } from '../utils/Logger';

const DOMAIN = 0x0000;

export default class EntryAbility extends UIAbility {
    onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
        // 设置颜色模式
        try {
            this.context.getApplicationContext()
                .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
        } catch (err) {
            hilog.error(DOMAIN, 'testTag',
                'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
        }

        // 初始化所有全局数据服务
        this.initAppServices();
    }

    /**
     * 初始化应用数据服务
     * 在真机/模拟器环境下,此方法在应用启动时被调用
     * 在Previewer环境下,此方法不会被调用(关键差异!)
     */
    private initAppServices(): void {
        try {
            const ctx = this.context;
            scienceData.init(ctx);       // 科普数据服务
            userPrefs.init(ctx);         // 用户偏好服务
            quizEngine.init(ctx);        // 答题引擎服务
            achievementManager.init(ctx); // 成就管理服务
            Logger.info('EntryAbility', '应用服务初始化完成');
        } catch (err) {
            Logger.error('EntryAbility', '应用服务初始化失败', err as Error);
        }
    }

    onWindowStageCreate(windowStage: window.WindowStage): void {
        // 加载主页面
        windowStage.loadContent('pages/MainTabs', (err) => {
            if (err.code) {
                hilog.error(DOMAIN, 'testTag',
                    'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
                return;
            }
        });
    }

    onDestroy(): void {
        hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy');
    }
}

代码解析

1. 初始化服务的顺序

// 初始化顺序设计考量:
// 1. scienceData(科普数据)—— 最基础的数据服务,所有页面都依赖
// 2. userPrefs(用户偏好)—— 需要Preferences异步IO,启动时间不确定
// 3. quizEngine(答题引擎)—— 依赖rawfile数据,但页面使用频率较低
// 4. achievementManager(成就管理)—— 依赖rawfile+Preferences,使用频率最低

// ✅ 按依赖关系和重要性排序初始化
// 核心数据服务优先,辅助功能服务靠后

原理/说明:

  • onCreate在应用进程创建时调用,此时this.context已经可用
  • 所有数据服务都使用单例模式,getInstance()保证全局唯一
  • 每个服务的init()方法内部有重复初始化保护(if (this.isInitialized) return

步骤2: Previewer环境兜底——aboutToAppear中的数据检查

功能说明

这是本文最核心的真实经验。DevEco Studio Previewer是一个轻量级预览工具,它直接渲染组件UI,但不启动完整的UIAbility生命周期。这意味着EntryAbility.onCreate()中的初始化代码在Previewer环境下完全不会执行

为解决这个问题,我们在每个需要数据的页面的aboutToAppear()中添加了"检查-初始化-轮询"的三级兜底机制。

2.1 MainTabs页面的兜底初始化

// 文件路径:entry/src/main/ets/pages/MainTabs.ets
// 说明:主Tabs容器页面的aboutToAppear兜底初始化

@Entry
@Component
struct MainTabs {
    @State currentIndex: number = 0;
    @State topicsCategory: string = 'all';
    @StorageLink('switchToTab') @Watch('onTabSwitch') switchToTab: string = '';
    @StorageLink('topicsCategory') @Watch('onCategoryChange') storageTopicsCategory: string = 'all';
    private tabsController: TabsController = new TabsController();

    aboutToAppear() {
        // ====== 第一级:检查数据是否已初始化 ======
        // 在真机/模拟器环境下,EntryAbility.onCreate已经完成了初始化
        // scienceData.getIsInitialized() 返回 true,直接跳过
        if (!scienceData.getIsInitialized()) {
            // ====== 第二级:兜底初始化 ======
            // Previewer环境下EntryAbility.onCreate不被调用
            // 此处主动执行初始化,确保Previewer也能展示数据
            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);
            }
        }

        // ====== 处理路由参数 ======
        const params = RouterUtil.getParams() as Record<string, Object>;
        if (params) {
            if (params.tabIndex !== undefined) {
                this.currentIndex = params.tabIndex as number;
                this.tabsController.changeIndex(this.currentIndex);
            }
            if (params.categoryId) {
                this.topicsCategory = params.categoryId as string;
            }
        }
    }

    // ... onTabSwitch、onCategoryChange、build方法省略 ...
}

代码解析

1. getContext(this)的可用性

// ✅ 组件内部获取Context的正确方式
const ctx = getContext(this);
// getContext(this) 在 aboutToAppear 中已经可用
// 返回的是 UIAbilityContext 或 UIContext

// ❌ 错误:在组件外部获取Context
// const ctx = AppStorage.get('context');  // 不可靠

// ❌ 错误:在构造函数中获取Context
// constructor() {
//     const ctx = getContext(this);  // 此时this可能还未绑定
// }

原理/说明:

  • getContext(this)是ArkUI框架提供的Context获取方法,在组件实例创建后即可使用
  • aboutToAppear是组件创建后的第一个生命周期回调,此时getContext(this)已可用
  • 返回的Context类型与运行环境有关:真机返回UIAbilityContext,Previewer返回模拟的Context

2.2 Index页面的三级兜底机制

// 文件路径:entry/src/main/ets/pages/Index.ets
// 说明:首页组件的三级数据初始化兜底机制

@Component
export struct Index {
    @State recommendedTopics: Topic[] = [];
    @State categories: Category[] = [];
    @State userName: string = '小科学家';
    @State isLoading: boolean = true;
    @State hasError: boolean = false;
    private loadTimer: number = -1;

    aboutToAppear() {
        // ====== 第一级:直接检查数据是否已就绪 ======
        if (scienceData.getIsInitialized()) {
            // 真机环境:EntryAbility.onCreate已完成初始化
            // 直接加载数据,无需等待
            this.loadData();
        } else {
            // ====== 第二级:尝试主动初始化 ======
            // Previewer环境兜底:主动调用scienceData.init()
            try {
                const ctx = getContext(this);
                scienceData.init(ctx);
            } catch (err) {
                Logger.error(TAG, '页面级数据初始化失败', err as Error);
            }

            // 初始化后再次检查
            if (scienceData.getIsInitialized()) {
                // 同步初始化成功(rawfile可用的情况)
                this.loadData();
            } else {
                // ====== 第三级:轮询等待异步初始化完成 ======
                // 某些情况下init()是异步的(如Preferences IO操作)
                // 启动轮询等待,最多等待3秒
                this.startPolling();
            }
        }
    }

    /**
     * 从scienceData加载数据
     */
    private loadData(): void {
        this.isLoading = false;
        this.hasError = false;
        if (this.loadTimer >= 0) {
            clearInterval(this.loadTimer);
            this.loadTimer = -1;
        }
        this.recommendedTopics = scienceData.getRecommendedTopics(3);
        this.categories = scienceData.getAllCategories();
        Logger.info(TAG,
            `数据加载完成: 分类${this.categories.length}个, 文章${this.recommendedTopics.length}篇`);
    }

    /**
     * 轮询等待数据初始化
     * 每500ms检查一次,最多等待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);
    }
}

代码解析

1. 三级兜底的执行逻辑

aboutToAppear() 三级兜底流程
═══════════════════════════════════════════════════════════════

              scienceData已初始化?
              │
         ┌────┴────┐
         │         │
        YES        NO
         │         │
    直接加载      尝试主动init()
    loadData()       │
              ┌─────┴─────┐
              │           │
           init成功      init失败/异步
              │           │
         loadData()   启动轮询等待
                      startPolling()
                           │
                    ┌──────┴──────┐
                    │             │
                3秒内初始化完成   3秒超时
                    │             │
               loadData()     hasError = true
               (成功加载)     (显示错误状态+重试按钮)

═══════════════════════════════════════════════════════════════

2. 为什么需要第三级轮询?

// scienceData.init() 内部的初始化过程分析:

// 同步部分(立即可用):
// 1. 读取 rawfile/categories.json → 同步IO,立即完成
// 2. 读取 rawfile/topics.json → 同步IO,立即完成
// 3. 解析JSON数据 → CPU计算,立即完成
// 4. 设置 isInitialized = true

// 异步部分(需要等待):
// 某些环境下,rawfile读取可能涉及异步IO
// 或者 Preferences 加载是异步的(userPrefs.loadAllData 是 async)

// 因此,init() 返回后 isInitialized 可能还没变为 true
// 需要轮询等待

// ✅ 轮询方案是Previewer环境下的最后保障
// 3秒超时避免无休止等待,超时后显示错误+重试按钮

步骤3: 定时器清理——aboutToDisappear的正确使用

功能说明

aboutToDisappear在组件即将从组件树中移除时调用。对于使用了setIntervalsetTimeout等定时器的组件,必须在aboutToDisappear中清除这些定时器,否则会导致内存泄漏。在《奇妙科学乐园》中,IndexTopics两个页面都使用了轮询定时器,因此都需要在aboutToDisappear中清理。

3.1 Index页面的定时器清理

// 文件路径:entry/src/main/ets/pages/Index.ets
// 说明:aboutToDisappear中清除轮询定时器

@Component
export struct Index {
    @State recommendedTopics: Topic[] = [];
    @State categories: Category[] = [];
    @State isLoading: boolean = true;
    @State hasError: boolean = false;
    /** 轮询定时器ID,-1表示未启动 */
    private loadTimer: number = -1;

    aboutToAppear() {
        // ... 初始化逻辑(见步骤2.2)...
    }

    aboutToDisappear() {
        // 清除轮询定时器,防止内存泄漏
        if (this.loadTimer >= 0) {
            clearInterval(this.loadTimer);
            this.loadTimer = -1;
        }
    }

    private loadData(): void {
        this.isLoading = false;
        this.hasError = false;
        // 加载成功后也清除定时器
        if (this.loadTimer >= 0) {
            clearInterval(this.loadTimer);
            this.loadTimer = -1;
        }
        this.recommendedTopics = scienceData.getRecommendedTopics(3);
        this.categories = scienceData.getAllCategories();
    }
}

3.2 Topics页面的定时器清理

// 文件路径:entry/src/main/ets/pages/Topics.ets
// 说明:科普列表页的定时器清理

@Component
export struct Topics {
    @Prop initialCategory: string = 'all';
    @State currentCategory: string = 'all';
    @State searchKeyword: string = '';
    @State topicList: Topic[] = [];
    @State categories: Category[] = [];
    @State isLoading: boolean = true;
    @State hasError: boolean = false;
    private topicDataSource: TopicDataSource = new TopicDataSource();
    /** 轮询定时器ID */
    private loadTimer: number = -1;

    aboutToAppear() {
        if (this.initialCategory) {
            this.currentCategory = this.initialCategory;
        }
        if (scienceData.getIsInitialized()) {
            this.loadData();
        } else {
            // Previewer环境兜底初始化
            try {
                const ctx = getContext(this);
                scienceData.init(ctx);
            } catch (err) {
                Logger.error(TOPIC_DATA_SOURCE_TAG, '页面级数据初始化失败', err as Error);
            }
            if (scienceData.getIsInitialized()) {
                this.loadData();
            } else {
                this.startPolling();
            }
        }
    }

    aboutToDisappear() {
        // 清除轮询定时器
        if (this.loadTimer >= 0) {
            clearInterval(this.loadTimer);
        }
    }

    // ... startPolling、loadData、loadTopics等方法省略 ...
}

代码解析

1. 定时器清理的正确模式

// ✅ 正确:使用标志位管理定时器生命周期
private loadTimer: number = -1;  // -1表示定时器未启动

// 启动定时器时
this.loadTimer = setInterval(() => { /* ... */ }, 500);

// 清除定时器时(三处都需要清除):
// 1. loadData()成功后清除
if (this.loadTimer >= 0) {
    clearInterval(this.loadTimer);
    this.loadTimer = -1;
}
// 2. startPolling()超时后清除
if (this.loadTimer >= 0) {
    clearInterval(this.loadTimer);
    this.loadTimer = -1;
}
// 3. aboutToDisappear()组件销毁时清除
if (this.loadTimer >= 0) {
    clearInterval(this.loadTimer);
}

// ❌ 错误:只在aboutToDisappear中清除
// 如果轮询成功后没有清除,定时器还会继续执行
// 虽然逻辑分支不会重复执行(isInitialized为true后直接返回)
// 但定时器本身还在运行,浪费CPU资源

// ❌ 错误:不清除定时器
// setInterval会无限执行,即使组件已经不需要了
// 在Tabs的TabContent中,组件不会频繁销毁重建
// 但如果组件因条件渲染被移除,定时器就会泄漏

2. Tabs + TabContent场景下的生命周期特点

// Tabs容器中TabContent的子组件生命周期特点:
//
// ✅ TabContent默认保活:
// Tabs() {
//     TabContent() { Index() }    // 首次创建后常驻内存
//     TabContent() { Topics() }   // 首次创建后常驻内存
//     TabContent() { Profile() }  // 首次创建后常驻内存
// }
//
// 生命周期调用时序:
// - 首次显示TabContent(0)时:Index.aboutToAppear() 被调用
// - 切换到TabContent(1)时:Topics.aboutToAppear() 被调用
//   (但Index.aboutToDisappear()不会被调用——TabContent保活)
// - 再切回TabContent(0)时:
//   (不会再次调用Index.aboutToAppear()——组件已存在)
//
// ⚠️ 关键结论:
// aboutToAppear只在首次创建时调用一次
// aboutToDisappear在Tabs保活模式下几乎不会被调用
// 因此在Tabs场景下,aboutToDisappear的定时器清理是"保险措施"
// 真正的定时器清理应该在loadData()成功后立即执行

原理/说明:

  • Tabs+TabContent组合默认实现页面保活,子组件一旦创建就不会销毁
  • aboutToAppear只在组件首次加入组件树时调用
  • aboutToDisappear在正常Tab切换时不会被调用,只有组件从组件树中移除(如路由返回、条件渲染消失)时才调用
  • 因此在Tabs场景下,loadData()成功后的定时器清除是主要手段,aboutToDisappear是兜底保障

步骤4: aboutToUpdate——Prop变化时的状态同步

功能说明

在MainTabs中,科普Tab通过@Prop将分类参数传递给Topics组件。当用户从首页点击某个分类时,topicsCategory状态变量更新,触发Topics组件的aboutToUpdate回调。我们在其中同步内部分类状态并重新加载数据。

完整代码

// 文件路径:entry/src/main/ets/pages/Topics.ets
// 说明:aboutToUpdate监听@Prop变化,同步内部状态

@Component
export struct Topics {
    @Prop initialCategory: string = 'all';
    @State currentCategory: string = 'all';
    @State topicList: Topic[] = [];
    // ... 其他状态声明 ...

    aboutToAppear() {
        // 初始化时同步@Prop值
        if (this.initialCategory) {
            this.currentCategory = this.initialCategory;
        }
        // ... 数据加载逻辑 ...
    }

    /**
     * 组件即将更新时调用
     * 当父组件传递的@Prop值发生变化时触发
     * 用于同步内部状态并触发数据重新加载
     */
    aboutToUpdate(): void {
        // 仅当分类参数确实变化且搜索为空时才更新
        if (this.initialCategory && this.initialCategory !== this.currentCategory
            && this.searchKeyword === '') {
            this.currentCategory = this.initialCategory;
            if (scienceData.getIsInitialized()) {
                this.loadTopics();
            }
        }
    }

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

    // ... 其他方法 ...
}

代码解析

1. aboutToUpdate与@Prop的配合

// 数据流分析:
// MainTabs.topicsCategory 更新
//   ↓ @Prop传递
// Topics.initialCategory 更新
//   ↓ 框架检测到组件需要更新
// Topics.aboutToUpdate() 被调用
//   ↓ 同步内部状态
// this.currentCategory = this.initialCategory
//   ↓ 重新加载
// this.loadTopics()

// ✅ 正确:aboutToUpdate中同步@Prop到@State
aboutToUpdate(): void {
    if (this.initialCategory !== this.currentCategory) {
        this.currentCategory = this.initialCategory;
        this.loadTopics();
    }
}

// ❌ 错误:直接在build中读取@Prop作为筛选条件
// build() {
//     List() {
//         ForEach(scienceData.getTopicsByCategory(this.initialCategory), ...)
//     }
// }
// 问题:每次重新渲染都会调用getTopicsByCategory,性能浪费

步骤5: 完整的生命周期管理模板

功能说明

将项目中积累的生命周期管理最佳实践抽象为一个通用模板,供所有需要数据加载的页面复用。

完整代码

// 通用页面生命周期管理模板
// 适用场景:需要异步数据加载的页面组件

@Component
export struct DataLoadingPage {
    // ====== 状态声明 ======
    @State isLoading: boolean = true;   // 加载中标记
    @State hasError: boolean = false;    // 错误标记
    @State dataList: SomeData[] = [];    // 数据列表

    private loadTimer: number = -1;       // 轮询定时器

    // ====== 组件创建时 ======
    aboutToAppear() {
        if (dataService.getIsInitialized()) {
            // 第一级:数据已就绪,直接加载
            this.loadData();
        } else {
            // 第二级:尝试兜底初始化
            try {
                const ctx = getContext(this);
                dataService.init(ctx);
            } catch (err) {
                Logger.error(TAG, '兜底初始化失败', err as Error);
            }
            if (dataService.getIsInitialized()) {
                this.loadData();
            } else {
                // 第三级:轮询等待
                this.startPolling();
            }
        }
    }

    // ====== Prop变化时 ======
    aboutToUpdate() {
        // 监听外部参数变化,按需重新加载
    }

    // ====== 组件销毁时 ======
    aboutToDisappear() {
        // 清理所有定时器
        if (this.loadTimer >= 0) {
            clearInterval(this.loadTimer);
            this.loadTimer = -1;
        }
    }

    // ====== 数据加载 ======
    private loadData(): void {
        this.isLoading = false;
        this.hasError = false;
        // 成功后立即清除定时器
        if (this.loadTimer >= 0) {
            clearInterval(this.loadTimer);
            this.loadTimer = -1;
        }
        // 加载数据
        this.dataList = dataService.getData();
    }

    // ====== 轮询等待 ======
    private startPolling(): void {
        let elapsed = 0;
        const interval = 500;
        const maxWait = 3000;
        this.loadTimer = setInterval(() => {
            elapsed += interval;
            if (dataService.getIsInitialized()) {
                this.loadData();
            } else if (elapsed >= maxWait) {
                // 超时兜底
                if (this.loadTimer >= 0) {
                    clearInterval(this.loadTimer);
                    this.loadTimer = -1;
                }
                this.isLoading = false;
                this.hasError = true;
                Logger.error(TAG, `数据加载超时(${maxWait}ms)`);
            }
        }, interval);
    }

    // ====== 重试加载 ======
    private retryLoad(): void {
        this.isLoading = true;
        this.hasError = false;
        if (dataService.getIsInitialized()) {
            this.loadData();
        } else {
            this.startPolling();
        }
    }

    // ====== UI渲染 ======
    build() {
        if (this.isLoading) {
            // 骨架屏
            this.SkeletonContent();
        } else if (this.hasError) {
            // 错误状态 + 重试按钮
            this.ErrorContent();
        } else {
            // 正常内容
            this.NormalContent();
        }
    }
}

代码解析

1. 三态UI的完整覆盖

// 每个需要数据加载的页面都必须覆盖三种UI状态:

// 1. isLoading = true → 骨架屏(加载占位)
// ✅ 提供视觉反馈,告诉用户"正在加载"
// ✅ 避免页面突然闪烁

// 2. hasError = true → 错误状态(提示 + 重试按钮)
// ✅ 友好的错误提示
// ✅ 提供重试操作,用户可以自行恢复

// 3. isLoading=false && hasError=false → 正常内容
// ✅ 展示实际数据

// ❌ 错误:只显示正常内容,没有加载中状态
// 用户看到的是空白页面或闪烁

// ❌ 错误:加载失败后没有重试机制
// 用户只能退出重进,体验极差

步骤6: 真实经验——Previewer与真机的环境差异总结

功能说明

在《奇妙科学乐园》的整个开发过程中,Previewer与真机之间的环境差异是我们反复踩坑的地方。以下是完整的差异总结和应对策略。

差异对比表

Previewer vs 真机/模拟器 环境差异完整对比
═══════════════════════════════════════════════════════════════

特性                  Previewer              真机/模拟器
───────────────────────────────────────────────────────────────
UIAbility.onCreate     ❌ 不调用               ✅ 正常调用
getContext(this)      ✅ 可用(模拟Context)    ✅ 可用(真实Context)
rawfile读取            ⚠️ 可能失败             ✅ 正常
Preferences IO        ❌ 不可用               ✅ 正常
router跳转             ⚠️ 部分支持             ✅ 完整支持
AppStorage             ✅ 可用                 ✅ 可用
@StorageLink          ✅ 可用                 ✅ 可用
定时器(setInterval)    ✅ 可用                 ✅ 可用
$hilog日志             ❌ 不可用               ✅ 可用
页面保活(TabContent)   ⚠️ 可能不一致            ✅ 稳定

═══════════════════════════════════════════════════════════════

应对策略:
1. 数据初始化:aboutToAppear中做兜底(check → init → poll)
2. Context获取:使用getContext(this),兼容两种环境
3. 数据源:rawfile失败时回退Mock数据(ScienceData已实现)
4. Preferences:Previewer中跳过,使用内存缓存兜底
5. 日志:使用Logger封装,Previewer中降级为console

═══════════════════════════════════════════════════════════════

ScienceData的Mock数据回退机制

// 文件路径:entry/src/main/ets/viewmodel/ScienceData.ets
// 说明:rawfile加载失败时自动回退Mock数据

export class ScienceDataService {
    private isInitialized: boolean = false;

    /**
     * 初始化数据服务
     * rawfile加载失败时自动回退到Mock数据
     * @param context - 应用上下文
     */
    init(context: common.UIAbilityContext | common.Context): void {
        if (this.isInitialized) {
            Logger.info('ScienceData', '数据已初始化,跳过重复初始化');
            return;
        }
        try {
            // 尝试从rawfile加载
            const rawCategories = loadJsonData<RawCategory[]>(context, 'categories.json');
            this.categories = resolveCategoryResources(rawCategories);
            const rawTopics = loadJsonData<Topic[]>(context, 'topics.json');
            this.topics = rawTopics;
            // ...
            this.isInitialized = true;
        } catch (error) {
            // rawfile加载失败,回退到Mock数据
            // Previewer环境正常行为:rawfile可能不可用
            const errMsg = error instanceof Error ? error.message : String(error);
            Logger.warn('ScienceData',
                `rawfile加载失败,回退到Mock数据(Previewer环境正常行为): ${errMsg}`);
            this.loadMockData();
        }
    }

    /**
     * 加载Mock兜底数据
     * 用于Previewer等rawfile不可用的环境
     */
    private loadMockData(): void {
        this.categories = getMockCategories();
        this.topics = getMockTopics();
        this.experiments = [];
        this.isInitialized = true;
        Logger.info('ScienceData',
            `Mock数据加载完成: 分类${this.categories.length}个, 文章${this.topics.length}篇`);
    }
}

⚠️ 常见问题与解决方案

问题1: Previewer中页面白屏,真机正常

现象:
DevEco Studio Previewer中所有页面显示空白,但真机和模拟器上一切正常。

原因:
Previewer不调用UIAbility.onCreate(),数据服务未初始化,页面在等待数据但永远不会到来。

错误代码:

// ❌ 错误:只在UIAbility.onCreate中初始化,没有兜底
aboutToAppear() {
    // 假设scienceData已经在onCreate中初始化了
    this.dataList = scienceData.getData();  // Previewer中getData返回空数组
    // 页面显示空白,没有加载提示,没有错误提示
}

正确代码:

// ✅ 正确:aboutToAppear中做数据检查和兜底初始化
aboutToAppear() {
    if (scienceData.getIsInitialized()) {
        this.loadData();
    } else {
        try {
            scienceData.init(getContext(this));
        } catch (err) { /* ... */ }
        if (scienceData.getIsInitialized()) {
            this.loadData();
        } else {
            this.startPolling();  // 轮询等待异步初始化
        }
    }
}

问题2: 定时器未清理导致内存泄漏

现象:
应用长时间运行后出现卡顿,hilog输出大量重复日志。

原因:
setInterval启动后没有清除,即使数据已经加载完成,定时器仍在不断执行。

错误代码:

// ❌ 错误:startPolling启动定时器后,只在超时时清除
private startPolling(): void {
    this.loadTimer = setInterval(() => {
        if (scienceData.getIsInitialized()) {
            this.loadData();
            // 缺少清除定时器的逻辑!
            // 即使loadData成功,定时器仍在运行
        } else if (elapsed >= 3000) {
            clearInterval(this.loadTimer);
        }
    }, 500);
}

// ❌ 错误:aboutToDisappear中没有清理
aboutToDisappear() {
    // 什么都没做
}

正确代码:

// ✅ 正确:三处清除定时器
private loadData(): void {
    // 清除1:loadData成功后清除
    if (this.loadTimer >= 0) {
        clearInterval(this.loadTimer);
        this.loadTimer = -1;
    }
    // 加载数据...
}

private startPolling(): void {
    this.loadTimer = setInterval(() => {
        if (scienceData.getIsInitialized()) {
            this.loadData();  // loadData内部会清除定时器
        } else if (elapsed >= 3000) {
            // 清除2:超时后清除
            if (this.loadTimer >= 0) {
                clearInterval(this.loadTimer);
                this.loadTimer = -1;
            }
            this.hasError = true;
        }
    }, 500);
}

aboutToDisappear() {
    // 清除3:组件销毁时兜底清除
    if (this.loadTimer >= 0) {
        clearInterval(this.loadTimer);
        this.loadTimer = -1;
    }
}

问题3: aboutToAppear被重复调用

现象:
组件的数据加载逻辑被执行了多次,出现重复请求或重复日志。

原因:
如果在build()方法中使用了条件渲染(if/else),组件可能被反复创建和销毁。

错误代码:

// ❌ 错误:build中条件渲染导致组件反复创建销毁
build() {
    Column() {
        if (this.showContent) {
            // showContent每次变化都会重新创建Index组件
            // Index.aboutToAppear()会被反复调用
            Index();
        }
    }
}

// ❌ 错误:aboutToAppear中重复初始化
aboutToAppear() {
    // 每次都执行初始化,没有防重复保护
    scienceData.init(getContext(this));
    this.loadData();
    this.loadData();  // 重复加载
}

正确代码:

// ✅ 正确:数据服务有重复初始化保护
// ScienceData.init() 内部:
if (this.isInitialized) {
    return;  // 已初始化,直接返回
}

// ✅ 正确:避免build中条件渲染导致组件反复创建
// 使用组件的visible属性代替条件渲染
build() {
    Index().visibility(this.showContent ? Visibility.Visible : Visibility.Hidden);
    // 组件始终存在,只是隐藏/显示,不会触发aboutToAppear/Disappear
}

问题4: aboutToDisappear在Tabs中不触发

现象:
切换Tab时,aboutToDisappear没有被执行。

原因:
这是Tabs + TabContent的正常行为。TabContent默认保活,切换Tab不会销毁子组件,因此不会触发aboutToDisappear

解决方案:

// Tabs场景下,aboutToDisappear是兜底保障
// 主要的清理逻辑应该在业务完成时立即执行

// ✅ 正确:loadData()成功后立即清除定时器
private loadData(): void {
    if (this.loadTimer >= 0) {
        clearInterval(this.loadTimer);
        this.loadTimer = -1;
    }
    // ...
}

// aboutToDisappear作为最后保障
aboutToDisappear() {
    if (this.loadTimer >= 0) {
        clearInterval(this.loadTimer);
        this.loadTimer = -1;
    }
}

// 注意:如果需要在Tab切换时执行特定逻辑
// 不要依赖aboutToDisappear,而应该使用Tabs的onChange回调
// .onChange((index: number) => {
//     // Tab切换时的逻辑
// })

问题5: getContext(this)返回undefined

现象:
在组件中调用getContext(this)返回undefined,导致初始化失败。

原因:
在组件构造函数或build()方法中调用getContext(this)可能无法获取到有效Context。

错误代码:

// ❌ 错误:在build()中调用getContext
build() {
    const ctx = getContext(this);
    // ctx可能为undefined
}

// ❌ 错误:在全局作用域调用getContext
const ctx = getContext(undefined);  // 错误

正确代码:

// ✅ 正确:在aboutToAppear()中调用getContext
aboutToAppear() {
    const ctx = getContext(this);
    // aboutToAppear在组件绑定完成后调用,getContext可用
    scienceData.init(ctx);
}

📝 本章小结

核心知识点

本文以《奇妙科学乐园》项目中最核心的环境兼容性问题为线索,系统讲解了HarmonyOS页面生命周期管理的完整实践,主要包括:

1. UIAbility生命周期与组件生命周期的区别

  • UIAbility.onCreate()在应用启动时调用,适合全局数据初始化
  • 组件的aboutToAppear()在组件创建时调用,适合页面级数据加载
  • DevEco Studio Previewer不调用UIAbility.onCreate(),是最关键的环境差异

2. aboutToAppear的三级兜底初始化机制

  • 第一级:检查数据是否已就绪(真机环境直接通过)
  • 第二级:尝试主动初始化(Previewer环境兜底)
  • 第三级:轮询等待异步初始化完成(应对异步IO延迟)

3. aboutToDisappear的定时器清理职责

  • setInterval/setTimeout等定时器必须在aboutToDisappear中清除
  • 在Tabs保活场景下,aboutToDisappear是兜底保障
  • 主要的清理逻辑应该在业务完成时(如loadData())立即执行

4. 三态UI的完整覆盖

  • 每个数据加载页面必须覆盖:加载中(骨架屏)、加载失败(错误+重试)、正常内容
  • isLoadinghasError两个布尔状态变量驱动三态切换

最佳实践总结

aboutToAppear兜底初始化模板

aboutToAppear() {
    if (dataService.getIsInitialized()) {
        this.loadData();
    } else {
        try { dataService.init(getContext(this)); } catch (err) { /* log */ }
        if (dataService.getIsInitialized()) {
            this.loadData();
        } else {
            this.startPolling();
        }
    }
}

aboutToDisappear定时器清理模板

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

数据服务重复初始化保护

init(context: Context): void {
    if (this.isInitialized) {
        return;  // 防止重复初始化
    }
    try {
        // 正常加载...
        this.isInitialized = true;
    } catch (error) {
        this.loadMockData();  // 回退到Mock数据
    }
}

下一步预告

在下一篇文章中,我们将:

  • 📚 深入解析组件间通信的更多模式,包括@Emit事件、emitter事件总线
  • 🏗️ 探讨大型HarmonyOS应用的状态管理架构设计
  • ⏳ 实现全局主题切换、多语言国际化等高级功能

🔗 相关链接


💡 提示: 建议结合项目源码中的EntryAbility.ets(UIAbility初始化)、MainTabs.ets(容器级兜底)、Index.ets(页面级三级兜底)和ScienceData.ets(Mock回退)四个文件对照阅读,理解从"正常环境初始化"到"Previewer环境兜底"的完整保障链路。

Logo

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

更多推荐