HarmonyOS NEXT 企业级记账APP:深色模式与主题切换

HarmonyOS NEXT 企业级记账APP:深色模式与主题切换

深色模式与主题切换

本文是《HarmonyOS NEXT 企业级开发实战:30篇打造智能记账APP》系列的第23篇,对应 Git Tagv0.2.3。承接前序开发,本篇完善深色模式响应系统:SettingRepository基于PreferenceUtil持久化主题与深色模式开关,SettingView提供设置入口与Toggle交互,ThemeManager通过AppStorage全局刷新 UI。重点讲解 ArkTS 类型安全(消除any)与@Prop属性命名冲突两大编译陷阱。

前言

企业级应用的用户自定义能力决定产品成熟度。深色模式不仅关乎美观,更影响夜间使用的舒适度与续航。本章落地SettingRepository数据层、ThemeManager主题管理器、SettingView设置页,让用户掌控自己的主题体验。本文将带你:

  1. 设计SettingRepository单例仓储持久化主题设置
  2. 封装ThemeManager通过AppStorage全局响应主题切换
  3. 实现SettingView设置页与深色模式Toggle交互
  4. 消除 ArkTS 中any类型,保证类型安全
  5. 规避@Prop width/heightCustomComponent基类方法的命名冲突

企业级核心原则:功能必须完整、可恢复、可响应。参考 HarmonyOS NEXT 开发者文档 了解官方约定,配合 ArkUI 状态管理 掌握AppStorage全局状态机制。


一、需求分析

1.1 功能介绍

需求项说明
核心功能持久化主题模式(light/dark/auto)与深色模式开关,ThemeManager全局刷新 UI
数据源PreferenceUtil(基于@kit.ArkData的 preferences)
交互方式Toggle开关、点击列表项跳转、ConfirmDialog二次确认
视觉规范收入绿/支出红/预算蓝/统计紫,深色模式 token 由AppDarkColors提供
类型约束全量消除any,列表项使用具体类型而非Array<any>

1.2 业务流程

用户进入设置页 ↓ SettingView.aboutToAppear → SettingRepository.loadDarkMode() ↓ Toggle 切换 → SettingRepository.saveDarkMode(isOn) ↓ ThemeManager.switch(mode) → AppStorage.setOrCreate('color.xxx') ↓ 全局 @StorageLink 绑定的组件自动重新渲染

1.3 架构分层

主题系统采用三层架构,职责清晰分离:

层级职责
数据层SettingRepository持久化 theme / darkMode / language / currency
管理层ThemeManager维护当前模式,向AppStorage写入颜色 token
视图层SettingView提供交互入口,调用仓储读写设置

设计要点SettingRepository只负责"读写偏好",ThemeManager只负责"应用主题",两者解耦。SettingView不直接操作AppStorage,保证单向数据流。


二、SettingRepository 数据层

2.1 完整源码

实际项目中SettingRepository位于repository/SettingRepository.ets,采用英文类名与单例模式。它封装PreferenceUtil完成主题、语言、货币、深色模式的持久化。注意它没有data: Array<any>字段,也没有泛型save(item: any)方法,每个设置项都有独立的强类型方法。

// repository/SettingRepository.ets import { PreferenceUtil } from '../utils/PreferenceUtil'; export class SettingRepository { private static instance: SettingRepository; static getInstance(): SettingRepository { if (!SettingRepository.instance) { SettingRepository.instance = new SettingRepository(); } return SettingRepository.instance; } async saveTheme(mode: string): Promise<void> { await PreferenceUtil.getInstance().setString('setting_theme', mode); } async loadTheme(): Promise<string> { return await PreferenceUtil.getInstance().getString('setting_theme', 'auto'); } async saveLanguage(lang: string): Promise<void> { await PreferenceUtil.getInstance().setString('setting_language', lang); } async saveCurrency(currency: string): Promise<void> { await PreferenceUtil.getInstance().setString('setting_currency', currency); } async saveDarkMode(enabled: boolean): Promise<void> { await PreferenceUtil.getInstance().setBoolean('setting_dark_mode', enabled); } async loadDarkMode(): Promise<boolean> { return await PreferenceUtil.getInstance().getBoolean('setting_dark_mode', false); } }

2.2 方法说明

方法参数返回值说明
saveTheme(mode)stringPromise<void>持久化主题模式(light/dark/auto)
loadTheme()Promise<string>读取主题,默认auto
saveLanguage(lang)stringPromise<void>持久化语言设置
saveCurrency(currency)stringPromise<void>持久化货币单位
saveDarkMode(enabled)booleanPromise<void>持久化深色模式开关
loadDarkMode()Promise<boolean>读取深色模式,默认false

2.3 偏好键约定

SettingRepository使用统一的键名前缀setting_,便于清理与排查:

  1. setting_theme:主题模式字符串
  2. setting_language:语言代码
  3. setting_currency:货币代码
  4. setting_dark_mode:深色模式布尔值

类型安全:每个方法都使用具体类型(string/boolean),而非anysaveDarkMode接收boolean并调用setBooleanloadDarkMode返回Promise<boolean>,编译期即可发现传参错误。


三、PreferenceUtil 持久化基础

SettingRepository依赖的PreferenceUtil基于@kit.ArkDatapreferences模块,提供强类型的存取能力。它同样是单例,并在EntryAbility启动时init(context)

// utils/PreferenceUtil.ets(核心方法节选) import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; const PREF_NAME = 'harmonyledger'; export class PreferenceUtil { private pref: preferences.Preferences | null = null; private static instance: PreferenceUtil | null = null; static getInstance(): PreferenceUtil { if (PreferenceUtil.instance === null) { PreferenceUtil.instance = new PreferenceUtil(); } return PreferenceUtil.instance; } async init(context: common.Context): Promise<void> { this.pref = await preferences.getPreferences(context, PREF_NAME); } async setString(key: string, value: string): Promise<void> { if (this.pref === null) return; await this.pref.put(key, value); await this.pref.flush(); } async getString(key: string, defaultValue: string = ''): Promise<string> { if (this.pref === null) return defaultValue; let result: ESObject = await this.pref.get(key, defaultValue); return '' + result; } async setBoolean(key: string, value: boolean): Promise<void> { if (this.pref === null) return; await this.pref.put(key, value); await this.pref.flush(); } async getBoolean(key: string, defaultValue: boolean = false): Promise<boolean> { if (this.pref === null) return defaultValue; let result: ESObject = await this.pref.get(key, defaultValue); return result === true || result === 'true'; } }

注意pref可能为nullinit未完成时),所有存取方法都做了空值守卫,避免空指针崩溃。getBoolean同时兼容true'true'两种返回形态,提升健壮性。


四、ThemeManager 主题管理器

4.1 完整源码

ThemeManager位于theme/ThemeManager.ets,负责维护当前主题模式并向AppStorage写入颜色 token,所有通过@StorageLink绑定的组件会自动响应。

// theme/ThemeManager.ets import { AppColors } from './Colors'; import { AppDarkColors } from './DarkColors'; export enum ThemeMode { LIGHT = 'light', DARK = 'dark', AUTO = 'auto' } export class ThemeManager { private static readonly KEY_THEME_MODE = 'theme_mode'; private static currentMode: ThemeMode = ThemeMode.AUTO; static init(mode: ThemeMode = ThemeMode.AUTO): void { ThemeManager.currentMode = mode; AppStorage.setOrCreate(ThemeManager.KEY_THEME_MODE, mode); ThemeManager.applyTheme(mode); } static switch(mode: ThemeMode): void { ThemeManager.currentMode = mode; AppStorage.set(ThemeManager.KEY_THEME_MODE, mode); ThemeManager.applyTheme(mode); } static applyTheme(mode: ThemeMode): void { if (mode === ThemeMode.DARK) { ThemeManager.applyDarkColors(); } else { ThemeManager.applyLightColors(); } } private static applyLightColors(): void { AppStorage.setOrCreate('color.background', AppColors.Background); AppStorage.setOrCreate('color.card', AppColors.CardBackground); AppStorage.setOrCreate('color.text.primary', AppColors.PrimaryText); AppStorage.setOrCreate('color.text.secondary', AppColors.SecondaryText); AppStorage.setOrCreate('color.separator', AppColors.Separator); } private static applyDarkColors(): void { AppStorage.setOrCreate('color.background', AppDarkColors.Background); AppStorage.setOrCreate('color.card', AppDarkColors.CardBackground); AppStorage.setOrCreate('color.text.primary', AppDarkColors.PrimaryText); AppStorage.setOrCreate('color.text.secondary', AppDarkColors.SecondaryText); AppStorage.setOrCreate('color.separator', AppDarkColors.Separator); } static getCurrentMode(): ThemeMode { return ThemeManager.currentMode; } }

4.2 颜色 Token 一览

ThemeManager维护的全局颜色 token 如下,深浅两套由AppColorsAppDarkColors分别提供:

Token Key浅色值深色值用途
color.background#F2F2F7AppDarkColors.Background页面背景
color.card#FFFFFFAppDarkColors.CardBackground卡片背景
color.text.primary#1C1C1EAppDarkColors.PrimaryText主文本
color.text.secondary#8E8E93AppDarkColors.SecondaryText次文本
color.separator#E5E5EAAppDarkColors.Separator分割线

4.3 切换执行流程

switch(mode)的执行步骤如下:

  1. 更新currentMode内存状态
  2. AppStorage.set写入theme_mode键,触发@StorageLink绑定刷新
  3. applyTheme根据模式分发到applyDarkColors/applyLightColors
  4. 逐个setOrCreate颜色 token,绑定该 token 的组件自动重渲染

五、ArkTS 类型安全与 @Prop 命名冲突

5.1 消除 any 类型

模板生成的代码常出现Array<any>: any,这会绕过 ArkTS 编译期类型检查,埋下运行时隐患。ArkTS 严格模式禁止使用any。本项目的SettingRepository全量使用具体类型:

// ❌ 模板错误写法:any 绕过类型检查 data: Array<any> = []; async save(item: any): Promise<boolean> { ... } ForEach(this.viewModel.data, (item: any) => { ... }, (item: any) => item.id) private handleEdit(item: any): void { ... } // ✅ 正确写法:每个设置项独立强类型方法 async saveTheme(mode: string): Promise<void> { ... } async loadTheme(): Promise<string> { ... } async saveDarkMode(enabled: boolean): Promise<void> { ... } async loadDarkMode(): Promise<boolean> { ... }

SettingView不再用ForEach(this.viewModel.data, (item: any) => ...)渲染动态列表,而是用**静态ListItem**逐项声明设置项,每项类型确定,无需item: anyitem.id键值生成器。

5.2 @Prop width/height 属性名冲突

本系列第 19/20/21 篇已详述:ArkUI 中@Component装饰的struct隐式继承CustomComponent,其width()/height()是保留的链式布局方法。用@Prop width/@Prop height声明同名属性会触发编译错误:

错误: Property 'width' in type 'XXX' is not assignable to the same property in base type 'CustomComponent'. 错误: Property 'height' in type 'XXX' is not assignable to the same property in base type 'CustomComponent'.

根本原因:子类属性类型number与基类方法类型((value: Length) => XXX) & number不兼容。width/height在 ArkUI 中是保留的布局方法名,禁止作为@Prop属性名。

解决方案是添加业务前缀,全系列统一采用chartWidth/chartHeight

// ❌ 错误写法:与基类方法冲突 @Prop width: number = 300; @Prop height: number = 200; Canvas(this.ctx).width(this.width).height(this.height) // ✅ 正确写法:使用业务前缀避免冲突 @Prop chartWidth: number = 300; @Prop chartHeight: number = 200; Canvas(this.ctx).width(this.chartWidth).height(this.chartHeight)

5.3 命名规范建议

场景不推荐推荐说明
画布尺寸width/heightchartWidth/chartHeight与第 19/20/21 篇一致
进度条厚度heightbarHeightProgressBar真实采用
列表/卡片width/heightlistWidth/cardHeight一律加业务前缀
任意尺寸width/heightxxxWidth/xxxHeight规避基类方法名

最佳实践:ArkTS 中凡涉及自定义尺寸的@Prop属性都应添加业务前缀;凡涉及数据传递都应使用具体类型而非any,从根源上保证类型安全与编译通过。


六、SettingView 页面实现

6.1 完整源码

SettingView是设置页@Entry,提供深色模式Toggle、数据导出、清空数据、关于等入口。它直接调用SettingRepository.getInstance()读写设置,无需中间 ViewModel。

// pages/SettingView.ets import { AppColors } from '../theme/Colors'; import { AppFontSize } from '../theme/Typography'; import { AppSpace } from '../theme/Spacing'; import { RouterUtil } from '../utils/RouterUtil'; import { SettingRepository } from '../repository/SettingRepository'; import { PreferenceUtil } from '../utils/PreferenceUtil'; import { ToastUtil } from '../utils/ToastUtil'; import { ConfirmDialog } from '../components/dialog/ConfirmDialog'; @Entry @Component struct SettingView { @State darkMode: boolean = false; @State showClearConfirm: boolean = false; aboutToAppear(): void { this.loadSettings(); } private async loadSettings(): Promise<void> { this.darkMode = await SettingRepository.getInstance().loadDarkMode(); } build() { Column() { Row() { Image($r('app.media.icon_back')).width(24).height(24).fillColor(AppColors.PrimaryText) .onClick(() => { RouterUtil.back(); }) Text('设置').fontSize(AppFontSize.XL).fontWeight(FontWeight.Bold).layoutWeight(1).textAlign(TextAlign.Center) }.width('100%').height(56).alignItems(VerticalAlign.Center) List({ space: AppSpace.SM }) { // 深色模式 ListItem() { Row() { Text('深色模式').fontSize(AppFontSize.MD).fontColor(AppColors.PrimaryText).layoutWeight(1) Toggle({ type: ToggleType.Switch, isOn: this.darkMode }) .onChange((isOn: boolean) => { this.darkMode = isOn; SettingRepository.getInstance().saveDarkMode(isOn); ToastUtil.show('重启应用后生效'); }) } .width('100%') .height(56) .padding({ left: AppSpace.MD, right: AppSpace.MD }) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) } // 数据导出 ListItem() { Row() { Text('导出数据').fontSize(AppFontSize.MD).fontColor(AppColors.PrimaryText).layoutWeight(1) Image($r('app.media.icon_arrow_right')).width(20).height(20).fillColor(AppColors.SecondaryText) } .width('100%') .height(56) .padding({ left: AppSpace.MD, right: AppSpace.MD }) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) .onClick(() => { this.exportData(); }) } // 清空数据 ListItem() { Row() { Text('清空所有数据').fontSize(AppFontSize.MD).fontColor(AppColors.Expense).layoutWeight(1) } .width('100%') .height(56) .padding({ left: AppSpace.MD, right: AppSpace.MD }) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) .onClick(() => { this.showClearConfirm = true; }) } // 关于 ListItem() { Row() { Text('关于').fontSize(AppFontSize.MD).fontColor(AppColors.PrimaryText).layoutWeight(1) Image($r('app.media.icon_arrow_right')).width(20).height(20).fillColor(AppColors.SecondaryText) } .width('100%') .height(56) .padding({ left: AppSpace.MD, right: AppSpace.MD }) .backgroundColor(AppColors.CardBackground) .borderRadius(AppSpace.CardRadius) .onClick(() => { RouterUtil.push('pages/AboutView'); }) } } .layoutWeight(1) .margin({ top: AppSpace.MD }) if (this.showClearConfirm) { ConfirmDialog({ title: '确认清空', message: '清空后将删除所有账单、分类和预算数据,此操作不可恢复!', confirmText: '清空', confirmColor: AppColors.Expense, onConfirm: () => { this.doClear(); }, onCancel: () => { this.showClearConfirm = false; } }) } } .height('100%').padding({ left: AppSpace.XL, right: AppSpace.XL, top: AppSpace.MD }) .backgroundColor(AppColors.Background) } private async exportData(): Promise<void> { const billsJson = await PreferenceUtil.getInstance().getString('bills', '[]'); const categoriesJson = await PreferenceUtil.getInstance().getString('categories', '[]'); interface ExportData { bills: string; categories: string; exportTime: string; } const data: ExportData = { bills: billsJson, categories: categoriesJson, exportTime: new Date().toISOString() }; const json = JSON.stringify(data, null, 2); ToastUtil.show('数据已准备(JSON格式)'); } private async doClear(): Promise<void> { await PreferenceUtil.getInstance().clear(); this.showClearConfirm = false; ToastUtil.show('数据已清空'); } }

6.2 设置项清单

SettingView用静态ListItem逐项声明,类型确定、无需ForEachitem.id

设置项交互行为
深色模式Toggle开关saveDarkMode持久化,提示重启生效
导出数据点击聚合账单/分类 JSON,Toast 提示
清空所有数据点击 →ConfirmDialog二次确认后PreferenceUtil.clear()
关于点击RouterUtil.push('pages/AboutView')

6.3 深色模式持久化要点

Toggle.onChange回调的处理流程如下:

  1. 立即更新@State darkMode,保证开关即时响应
  2. 调用SettingRepository.getInstance().saveDarkMode(isOn)持久化布尔值
  3. ToastUtil.show('重启应用后生效')提示用户深色模式需重启应用生效

类型安全onChange((isOn: boolean) => ...)回调参数明确为booleansaveDarkMode也接收boolean,全程无any


七、深色模式全局响应

7.1 AppStorage + @StorageLink 联动

ThemeManager将颜色写入AppStorage后,任何用@StorageLink绑定同一 key 的组件都会自动重渲染:

// 任意组件中绑定全局颜色 token @StorageLink('color.background') bgColor: string = '#F2F2F7'; @StorageLink('color.text.primary') textColor: string = '#1C1C1E'; build() { Column() { Text(' Hello') .fontColor(this.textColor) } .backgroundColor(this.bgColor) }

7.2 应用启动初始化

ThemeManager.init应在EntryAbility.onCreate中调用,读取上次保存的主题并应用:

// EntryAbility.ets(节选) async onCreate(want, launchParam): Promise<void> { await PreferenceUtil.getInstance().init(this.context); const mode = await SettingRepository.getInstance().loadTheme(); // mode 为字符串,映射为 ThemeMode 枚举后初始化 ThemeManager.init(ThemeMode.AUTO); }

7.3 主题切换全局响应

// 通过 AppStorage + @StorageLink 全局响应 @StorageLink('color.background') bgColor: string = '#F2F2F7'; // ThemeManager.switch 后所有绑定自动刷新

关键技术ThemeManager.switch调用AppStorage.set覆盖颜色 token,@StorageLink双向绑定使所有订阅组件即时重绘,无需手动通知。


八、路由与集成

8.1 路由配置

SettingView作为独立@Entry页面,需在main_pages.json注册:

// main_pages.json { "src": [ "pages/MainView", "pages/HomeView", "pages/StatisticsView", "pages/BudgetView", "pages/ProfileView", "pages/AddBillView", "pages/EditBillView", "pages/SearchView", "pages/SettingView", "pages/AboutView" ] }

8.2 入口跳转

SettingView通常从ProfileView(我的)页跳入:

// components/tabs/ProfileView.ets(节选) .onClick(() => { RouterUtil.push('pages/SettingView'); })

九、最佳实践

9.1 类型安全落地步骤

消除any的执行流程如下:

  1. 排查所有Array<any>: any声明,定位模板残留
  2. 为每个数据项定义具体类型或独立方法(如saveTheme(mode: string)
  3. 删除无用的data: Array<any>字段与泛型save(item: any)方法
  4. 静态列表改用ListItem逐项声明,移除ForEachitem.id键值生成器
  5. 全量编译验证,确保无any残留

9.2 偏好键管理

规范说明
统一前缀设置类用setting_,业务类用各自实体名
强类型存取setBoolean/getBooleanboolean对应,勿混用setString
默认值兜底getString/getBoolean均传defaultValue,避免首启 null
空值守卫PreferenceUtil内部pref === null检查,避免init未完成崩溃

9.3 备份完整性

// 备份必须包含所有实体 + 设置 const billsJson = await PreferenceUtil.getInstance().getString('bills', '[]'); const categoriesJson = await PreferenceUtil.getInstance().getString('categories', '[]'); const data = { bills: billsJson, categories: categoriesJson, exportTime: new Date().toISOString() }; const json = JSON.stringify(data, null, 2);

十、运行验证

10.1 构建命令

hvigorw assembleHap--modemodule-pproduct=default

10.2 验证清单

验证项预期结果
进入设置页读取loadDarkMode还原Toggle状态
切换深色开关持久化setting_dark_mode,Toast 提示重启生效
导出数据聚合 JSON 并 Toast 提示
清空数据ConfirmDialog二次确认后清空偏好
关于跳转路由跳转AboutView
编译通过any类型错误,无width/height冲突报错

十一、常见问题

11.1 主题不刷新

// 原因:未用 @StorageLink 绑定 AppStorage 颜色 token // 解决:所有主题色通过 AppStorage + @StorageLink 同步 @StorageLink('color.background') bgColor: string = '#F2F2F7';

11.2 Toggle 状态不还原

// 原因:aboutToAppear 未调用 loadDarkMode // 解决:在 aboutToAppear 中读取持久化值赋给 @State darkMode this.darkMode = await SettingRepository.getInstance().loadDarkMode();

11.3 深色模式未生效

// 原因:ThemeManager.init 未在 EntryAbility 启动时调用 // 解决:在 EntryAbility.onCreate 中 init PreferenceUtil 后调用 ThemeManager.init

11.4 偏好读取返回 null

// 原因:PreferenceUtil.init 未完成,pref 为 null // 解决:所有 get 方法已做空值守卫并返回 defaultValue,确保 init 先于读取

十二、Git 提交

12.1 提交命令

gitadd.gitcommit-m"feat(主题): 深色模式与主题切换 - 新增 SettingRepository 单例仓储(强类型方法) - ThemeManager 通过 AppStorage 全局刷新 UI - 实现 SettingView 设置页与 Toggle 交互 - 消除 any 类型,全量类型安全 - 修复 @Prop width/height 命名冲突说明"

12.2 变更日志

## [v0.2.3] - 2026-07-27 ### Added - repository/SettingRepository.ets(saveTheme/loadTheme/saveDarkMode/loadDarkMode) - theme/ThemeManager.ets(AppStorage 颜色 token 管理) - pages/SettingView.ets(设置页 + Toggle + ConfirmDialog) ### Changed - 消除 Array<any> 与 : any 模板残留 - main_pages.json 新增 SettingView / AboutView 路由

附录:运行效果截图


总结

本文完整介绍了深色模式与主题切换的全流程,涵盖SettingRepository数据层、PreferenceUtil持久化基础、ThemeManager主题管理器、SettingView页面实现,以及 ArkTS 类型安全与@Prop命名冲突两大陷阱。通过本篇你可以:

  • 设计强类型的SettingRepository单例仓储,消除any
  • 使用PreferenceUtil完成主题与深色模式持久化
  • 通过ThemeManager+AppStorage实现全局主题响应
  • 实现SettingView设置页与Toggle/ConfirmDialog交互
  • 规避@Prop width/heightCustomComponent基类方法的命名冲突

下一篇预告:继续推进 HarmonyLedger 系列的后续功能模块。


如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!在评论区告诉我你最想了解的鸿蒙开发话题,我会优先安排下一篇内容,也可以为下期主题投票。


相关资源

  • 本篇源码:GitHub Tag v0.2.3
  • HarmonyOS NEXT 开发者文档:developer-doc
  • ArkUI 状态管理 AppStorage:state-management
  • ArkUI Toggle 组件:toggle
  • ArkUI List 组件:list
  • 鸿蒙数据存储 preferences:data-storage
  • ArkUI 自定义组件:arkui-ts