HarmonyOS 弦乐调音器开发实战 05:UIAbility 如何串起启动、窗口与配置更新
拿到一个 HarmonyOS Stage 模型应用时,看到EntryAbility.ets并不等于已经理解了启动过程。应用对象何时建立共享状态,主窗口何时出现,页面何时加载,窗口尺寸何时可读,Preferences 服务又是在什么时候初始化,这些动作如果混在一句“应用启动后进入首页”里,很多时序风险会被遮住。弦乐调音器根目录的原生 ArkTS 工程给出了一个可以逐行核对的实例:UIAbility 负责建立会话状态,WindowStage 负责承载页面,窗口对象负责报告尺寸和系统栏,设置与历史服务则在内容加载成功后异步初始化。
本文只分析根 ArkTS 工程,不讨论 Flutter 子工程,也不重复第 01 篇的双工程版本辨识。文中的六张图都是依据当前源码绘制的证据图,不是当前版本运行截图。系列基线在 2026-08-03 记录 HDC 为[Empty],所以本文不会把编译通过或源码回调写成已经完成实机启动、前后台恢复和备份恢复验证。
一、与前四篇的分界:从“这是 Stage 工程”深入到“Stage 何时做什么”
第 01 篇已经确认根工程的apiType是stageMode,入口是EntryAbility → pages/Index;第 02 篇沿 AudioCapturer 和音高检测链路展开;第 03 篇关注参考音、历史和 AppStorage;第 04 篇则切换到 Flutter 与 ArkTS 插件。本文不再复述版本号、双工程目录或算法参数,而是回答一个更窄的问题:原生进程从创建 Ability 到页面可见之间,哪些代码在什么位置执行。
项目相对路径:entry/build-profile.json5
{ "apiType": "stageMode", "buildOption": { "resOptions": { "copyCodeResource": { "enable": false } } },这段配置只确认模块按 Stage 模型构建,并不能替代生命周期源码。真正的主入口还要继续核对module.json5中的mainElement、Ability 的srcEntry,以及 UIAbility 类中的回调实现。把这三个层次分开,能避免把“构建模型”“入口声明”和“运行时动作”混成同一个结论。
二、模块声明只负责找到入口,不负责执行页面业务
entry 模块把EntryAbility指定为主元素,并声明它能够响应系统桌面入口动作。系统可以据此找到 Ability 类,但模块描述不会自动初始化主题、AppStorage、窗口监听或业务服务。那些行为必须在 Ability 源码里才能得到证明。
项目相对路径:entry/src/main/module.json5
"mainElement": "EntryAbility", "deviceTypes": [ "phone", "tablet", "2in1" ], "deliveryWithInstall": true, "installationFree": false, "pages": "$profile:main_pages", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets",这里还能看到 phone、tablet、2in1 三类设备声明,但本文不会把它扩写成三类设备已经运行成功。设备声明与响应式页面如何实现会放在第 07 篇;此处只取它作为 Ability 所属模块的静态配置事实。
三、onCreate 先建立进程内默认状态
EntryAbility继承UIAbility。onCreate()的第一组动作是尝试让应用颜色模式保持跟随系统,然后用 AppStorage 建立默认键。这里的默认值具有“先让页面可用”的意义:即使 SettingsService 尚未完成 Preferences 加载,页面也能读取主题、A4、当前乐器、调音模式和窗口宽度等初值。
项目相对路径:entry/src/main/ets/entryability/EntryAbility.ets
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)); } AppStorage.setOrCreate('appContext', this.context); AppStorage.setOrCreate('currentTheme', 'dark'); AppStorage.setOrCreate('themePreference', 'system'); AppStorage.setOrCreate('systemIsDark', false);随后建立的 A4、显示单位、指针灵敏度、自动息屏、采样率、位深、输入源、当前乐器、调音模式、隐私与麦克风授权状态,同样都是默认会话值。setOrCreate的语义也值得注意:键存在时更新其值,键不存在时创建;它仍只是 AppStorage 会话状态,不等于 Preferences 持久化。本文能确认的是默认键在 Ability 创建时被准备,不能据此声称每个设置都已在底层业务生效;第 03 篇已经逐项审计过消费者边界。
四、系统主题的首轮解析也发生在 onCreate
默认值建立后,代码读取this.context.config.colorMode。若当前配置为深色,就把systemIsDark设为 true,并把currentTheme解析成 dark;否则解析成 light。这个顺序意味着页面最初可能先拿到 hard-coded 默认值,再在同一个onCreate回调中被系统配置结果校正。
需要精确区分三个值:themePreference表示用户选择的偏好,systemIsDark表示当前系统配置,currentTheme表示页面最终采用的运行主题。它们不是同一个字段。手动主题、限定资源目录和系统栏颜色之间的双轨关系会在第 09 篇展开,本文只说明 Ability 是这条状态链的起点。
五、onWindowStageCreate 才进入主窗口阶段
Ability 创建完成不代表页面已经装入窗口。onWindowStageCreate()接到 WindowStage 后调用loadContent('pages/Index'),并在回调中检查错误码。只有内容加载成功,后续的主窗口读取、窗口尺寸监听以及服务初始化才会继续执行。
项目相对路径:entry/src/main/ets/entryability/EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');这段早返回很关键:如果pages/Index加载失败,当前回调不会继续初始化窗口监听和两项服务。因此“Ability 已创建”与“首页内容已加载”必须分开记录。文章或日志分析时,也不能只看到Ability onCreate就判定首页已经成功显示。
六、窗口宽度先从物理像素换算成 vp
内容加载成功后,代码异步取得主窗口,读取默认显示设备的densityPixels,再用窗口矩形的物理像素宽度除以密度,四舍五入后写入 AppStorage 的windowWidth。初始值 360 只是兜底,真正的宽度来自窗口属性。
项目相对路径:entry/src/main/ets/entryability/EntryAbility.ets
const disp = display.getDefaultDisplaySync(); const dp = disp.densityPixels > 0 ? disp.densityPixels : 3; const rect = mainWin.getWindowProperties().windowRect; AppStorage.setOrCreate('windowWidth', Math.round(rect.width / dp)); } catch (_e) {} mainWin.on('windowSizeChange', (size: window.Size) => { try { const disp = display.getDefaultDisplaySync(); const dp = disp.densityPixels > 0 ? disp.densityPixels : 3; AppStorage.setOrCreate('windowWidth', Math.round(size.width / dp)); } catch (_e) {} });同一逻辑还绑定到windowSizeChange,所以自由窗口或设备形态变化时会重新计算宽度。当前源码没有保存监听句柄,也没有在onWindowStageDestroy()中看到对应的off,这是可以记录的资源释放缺口。另一个边界是代码使用默认显示设备密度;在复杂多显示场景中是否始终与主窗口所在显示一致,需要真实设备和官方接口语义进一步验证,不能从源码直接作肯定结论。
七、服务初始化被放在页面加载成功之后
SettingsService.getInstance().init(this.context)和HistoryService.getInstance().init(this.context)都位于loadContent成功回调里。这样做让页面外壳能够先装入窗口,再由服务异步读取 Preferences。代价是页面首轮渲染与服务完成加载之间可能存在时间差,因此项目使用 AppStorage 默认值托底,并让页面在生命周期或 refresh tick 中再次刷新数据。
这两个init()都用 Promise catch 记录错误,没有阻塞loadContent回调,也没有把失败状态送到 UI。由此可确认“初始化被发起”,不能写成“设置和历史一定在首帧之前恢复完毕”。想验证真实时序,需要 HiLog 时间戳、首帧记录和服务加载完成日志;本轮没有连接设备,不能给出启动耗时数字。
八、配置更新与系统栏属于不同层次
当系统配置变化时,onConfigurationUpdate()更新systemIsDark。只有themePreference === 'system'时,它才继续修改currentTheme。系统栏内容颜色则由独立的applyStatusBar()根据当前运行主题设置。
项目相对路径:entry/src/main/ets/entryability/EntryAbility.ets
onConfigurationUpdate(newConfig: Configuration): void { const sysDark = newConfig.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK; AppStorage.setOrCreate('systemIsDark', sysDark); const themePref = AppStorage.get<string>('themePreference') ?? 'system'; if (themePref === 'system') { AppStorage.setOrCreate('currentTheme', sysDark ? 'dark' : 'light'); } } static applyStatusBar(win: window.Window, theme: string): void { const contentColor = theme === 'dark' ? '#FFFFFF' : '#000000'; win.setWindowSystemBarProperties({ statusBarContentColor: contentColor, navigationBarContentColor: contentColor }).catch((_e: Error) => {}); }这里没有设置状态栏背景色,只设置内容颜色;失败也被静默捕获。源码能证明调用路径存在,不能证明每个设备、窗口模式和系统版本上都已达到正确对比度。
九、前后台与销毁回调目前主要是观察点
onForeground()、onBackground()、onDestroy()和onWindowStageDestroy()当前只输出 HiLog。真正的音频客户端停止、定时器清理、参考音释放,大多由页面组件的aboutToDisappear()或 active 状态变化处理。这种职责划分不一定错误,但它意味着不能看到 Ability 回调名称就宣称已经实现完整的全局资源回收。
如果进程直接进入后台但页面没有按预期触发相应组件路径,哪些资源仍然存活,需要设备日志验证。本文只记录当前代码所有权:Ability 负责入口、窗口、配置和服务启动;各页面负责自身音频、定时器与播放生命周期。
十、BackupExtension 入口不等于已经完成数据备份
module 配置声明了EntryBackupAbility,backup_config.json也写着allowToBackupRestore: true。但是继续查看扩展源码,onBackup()和onRestore()只有日志与一个已完成 Promise,没有枚举 Preferences、复制文件、校验版本或处理恢复冲突。
项目相对路径:entry/src/main/ets/entrybackupability/EntryBackupAbility.ets
export default class EntryBackupAbility extends BackupExtensionAbility { async onBackup() { hilog.info(DOMAIN, 'testTag', 'onBackup ok'); await Promise.resolve(); } async onRestore(bundleVersion: BundleVersion) { hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); await Promise.resolve(); } }因此最准确的表述是“工程已声明备份扩展入口,回调可被系统发现”,而不是“调音历史和用户设置已完成系统备份”。要把它升级为真实能力,还需要明确备份对象、数据格式、容量、版本迁移、隐私约束、恢复幂等性,并用卸载/重装或系统迁移流程验证恢复结果。
十一、用证据分层结束启动分析
本文可以确认:entry 使用 Stage 模型;系统通过 module 配置找到 EntryAbility;onCreate()建立 AppStorage 默认状态并解析首轮系统主题;onWindowStageCreate()加载pages/Index;内容加载成功后读取主窗口、监听宽度并发起设置与历史服务初始化;系统配置变化时只在“跟随系统”偏好下更新当前主题;备份扩展虽已声明,但没有真实数据处理实现。
本文不能确认:当前包在 phone、tablet、2in1 上的启动耗时,窗口监听在所有形态下是否准确,切前后台后音频是否无残留,系统栏在所有主题下是否可读,或备份恢复是否真的保存任何记录。系列基线记录 HDC 为[Empty],所以这些结论必须继续标为未验证。用这种方式拆解 UIAbility,Stage 模型就不再是项目模板中的一个名词,而成为一条可以逐节点审计的启动与窗口责任链。