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

发布时间:2026/8/2 4:11:57

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设置页让用户掌控自己的主题体验。本文将带你设计SettingRepository单例仓储持久化主题设置封装ThemeManager通过AppStorage全局响应主题切换实现SettingView设置页与深色模式Toggle交互消除 ArkTS 中any类型保证类型安全规避Prop width/height与CustomComponent基类方法的命名冲突企业级核心原则功能必须完整、可恢复、可响应。参考 HarmonyOS NEXT 开发者文档 了解官方约定配合 ArkUI 状态管理 掌握AppStorage全局状态机制。一、需求分析1.1 功能介绍需求项说明核心功能持久化主题模式light/dark/auto与深色模式开关ThemeManager全局刷新 UI数据源PreferenceUtil基于kit.ArkData的 preferences交互方式Toggle开关、点击列表项跳转、ConfirmDialog二次确认视觉规范收入绿/支出红/预算蓝/统计紫深色模式 token 由AppDarkColors提供类型约束全量消除any列表项使用具体类型而非Arrayany1.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: Arrayany字段也没有泛型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): Promisevoid { await PreferenceUtil.getInstance().setString(setting_theme, mode); } async loadTheme(): Promisestring { return await PreferenceUtil.getInstance().getString(setting_theme, auto); } async saveLanguage(lang: string): Promisevoid { await PreferenceUtil.getInstance().setString(setting_language, lang); } async saveCurrency(currency: string): Promisevoid { await PreferenceUtil.getInstance().setString(setting_currency, currency); } async saveDarkMode(enabled: boolean): Promisevoid { await PreferenceUtil.getInstance().setBoolean(setting_dark_mode, enabled); } async loadDarkMode(): Promiseboolean { return await PreferenceUtil.getInstance().getBoolean(setting_dark_mode, false); } }2.2 方法说明方法参数返回值说明saveTheme(mode)stringPromisevoid持久化主题模式light/dark/autoloadTheme()无Promisestring读取主题默认autosaveLanguage(lang)stringPromisevoid持久化语言设置saveCurrency(currency)stringPromisevoid持久化货币单位saveDarkMode(enabled)booleanPromisevoid持久化深色模式开关loadDarkMode()无Promiseboolean读取深色模式默认false2.3 偏好键约定SettingRepository使用统一的键名前缀setting_便于清理与排查setting_theme主题模式字符串setting_language语言代码setting_currency货币代码setting_dark_mode深色模式布尔值类型安全每个方法都使用具体类型string/boolean而非any。saveDarkMode接收boolean并调用setBooleanloadDarkMode返回Promiseboolean编译期即可发现传参错误。三、PreferenceUtil 持久化基础SettingRepository依赖的PreferenceUtil基于kit.ArkData的preferences模块提供强类型的存取能力。它同样是单例并在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): Promisevoid { this.pref await preferences.getPreferences(context, PREF_NAME); } async setString(key: string, value: string): Promisevoid { if (this.pref null) return; await this.pref.put(key, value); await this.pref.flush(); } async getString(key: string, defaultValue: string ): Promisestring { if (this.pref null) return defaultValue; let result: ESObject await this.pref.get(key, defaultValue); return result; } async setBoolean(key: string, value: boolean): Promisevoid { if (this.pref null) return; await this.pref.put(key, value); await this.pref.flush(); } async getBoolean(key: string, defaultValue: boolean false): Promiseboolean { 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 如下深浅两套由AppColors与AppDarkColors分别提供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)的执行步骤如下更新currentMode内存状态AppStorage.set写入theme_mode键触发StorageLink绑定刷新applyTheme根据模式分发到applyDarkColors/applyLightColors逐个setOrCreate颜色 token绑定该 token 的组件自动重渲染五、ArkTS 类型安全与 Prop 命名冲突5.1 消除 any 类型模板生成的代码常出现Arrayany与: any这会绕过 ArkTS 编译期类型检查埋下运行时隐患。ArkTS 严格模式禁止使用any。本项目的SettingRepository全量使用具体类型// ❌ 模板错误写法any 绕过类型检查 data: Arrayany []; async save(item: any): Promiseboolean { ... } ForEach(this.viewModel.data, (item: any) { ... }, (item: any) item.id) private handleEdit(item: any): void { ... } // ✅ 正确写法每个设置项独立强类型方法 async saveTheme(mode: string): Promisevoid { ... } async loadTheme(): Promisestring { ... } async saveDarkMode(enabled: boolean): Promisevoid { ... } async loadDarkMode(): Promiseboolean { ... }SettingView不再用ForEach(this.viewModel.data, (item: any) ...)渲染动态列表而是用**静态ListItem**逐项声明设置项每项类型确定无需item: any与item.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(): Promisevoid { 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(): Promisevoid { 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(): Promisevoid { await PreferenceUtil.getInstance().clear(); this.showClearConfirm false; ToastUtil.show(数据已清空); } }6.2 设置项清单SettingView用静态ListItem逐项声明类型确定、无需ForEach与item.id设置项交互行为深色模式Toggle开关saveDarkMode持久化提示重启生效导出数据点击聚合账单/分类 JSONToast 提示清空所有数据点击 →ConfirmDialog二次确认后PreferenceUtil.clear()关于点击RouterUtil.push(pages/AboutView)6.3 深色模式持久化要点Toggle.onChange回调的处理流程如下立即更新State darkMode保证开关即时响应调用SettingRepository.getInstance().saveDarkMode(isOn)持久化布尔值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): Promisevoid { 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覆盖颜色 tokenStorageLink双向绑定使所有订阅组件即时重绘无需手动通知。八、路由与集成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的执行流程如下排查所有Arrayany与: any声明定位模板残留为每个数据项定义具体类型或独立方法如saveTheme(mode: string)删除无用的data: Arrayany字段与泛型save(item: any)方法静态列表改用ListItem逐项声明移除ForEach与item.id键值生成器全量编译验证确保无any残留9.2 偏好键管理规范说明统一前缀设置类用setting_业务类用各自实体名强类型存取setBoolean/getBoolean与boolean对应勿混用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-pproductdefault10.2 验证清单验证项预期结果进入设置页读取loadDarkMode还原Toggle状态切换深色开关持久化setting_dark_modeToast 提示重启生效导出数据聚合 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.init11.4 偏好读取返回 null// 原因PreferenceUtil.init 未完成pref 为 null // 解决所有 get 方法已做空值守卫并返回 defaultValue确保 init 先于读取十二、Git 提交12.1 提交命令gitadd.gitcommit-mfeat(主题): 深色模式与主题切换 - 新增 SettingRepository 单例仓储强类型方法 - ThemeManager 通过 AppStorage 全局刷新 UI - 实现 SettingView 设置页与 Toggle 交互 - 消除 any 类型全量类型安全 - 修复 Prop width/height 命名冲突说明12.2 变更日志## [v0.2.3] - 2026-07-27 ### Added - repository/SettingRepository.etssaveTheme/loadTheme/saveDarkMode/loadDarkMode - theme/ThemeManager.etsAppStorage 颜色 token 管理 - pages/SettingView.ets设置页 Toggle ConfirmDialog ### Changed - 消除 Arrayany 与 : any 模板残留 - main_pages.json 新增 SettingView / AboutView 路由附录运行效果截图总结本文完整介绍了深色模式与主题切换的全流程涵盖SettingRepository数据层、PreferenceUtil持久化基础、ThemeManager主题管理器、SettingView页面实现以及 ArkTS 类型安全与Prop命名冲突两大陷阱。通过本篇你可以设计强类型的SettingRepository单例仓储消除any使用PreferenceUtil完成主题与深色模式持久化通过ThemeManagerAppStorage实现全局主题响应实现SettingView设置页与Toggle/ConfirmDialog交互规避Prop width/height与CustomComponent基类方法的命名冲突下一篇预告继续推进 HarmonyLedger 系列的后续功能模块。如果这篇文章对你有帮助欢迎点赞、收藏、关注你的支持是我持续创作的动力在评论区告诉我你最想了解的鸿蒙开发话题我会优先安排下一篇内容也可以为下期主题投票。相关资源本篇源码GitHub Tag v0.2.3HarmonyOS NEXT 开发者文档developer-docArkUI 状态管理 AppStoragestate-managementArkUI Toggle 组件toggleArkUI List 组件list鸿蒙数据存储 preferencesdata-storageArkUI 自定义组件arkui-ts

相关新闻