深色模式与主题切换
本文是《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,列表项使用具体类型而非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) | string | Promise<void> | 持久化主题模式(light/dark/auto) |
loadTheme() | 无 | Promise<string> | 读取主题,默认auto |
saveLanguage(lang) | string | Promise<void> | 持久化语言设置 |
saveCurrency(currency) | string | Promise<void> | 持久化货币单位 |
saveDarkMode(enabled) | boolean | Promise<void> | 持久化深色模式开关 |
loadDarkMode() | 无 | Promise<boolean> | 读取深色模式,默认false |
2.3 偏好键约定
SettingRepository使用统一的键名前缀setting_,便于清理与排查:
setting_theme:主题模式字符串setting_language:语言代码setting_currency:货币代码setting_dark_mode:深色模式布尔值
类型安全:每个方法都使用具体类型(
string/boolean),而非any。saveDarkMode接收boolean并调用setBoolean,loadDarkMode返回Promise<boolean>,编译期即可发现传参错误。
三、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): 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可能为null(init未完成时),所有存取方法都做了空值守卫,避免空指针崩溃。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 | #F2F2F7 | AppDarkColors.Background | 页面背景 |
color.card | #FFFFFF | AppDarkColors.CardBackground | 卡片背景 |
color.text.primary | #1C1C1E | AppDarkColors.PrimaryText | 主文本 |
color.text.secondary | #8E8E93 | AppDarkColors.SecondaryText | 次文本 |
color.separator | #E5E5EA | AppDarkColors.Separator | 分割线 |
4.3 切换执行流程
switch(mode)的执行步骤如下:
- 更新
currentMode内存状态 AppStorage.set写入theme_mode键,触发@StorageLink绑定刷新applyTheme根据模式分发到applyDarkColors/applyLightColors- 逐个
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: 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/height | chartWidth/chartHeight | 与第 19/20/21 篇一致 |
| 进度条厚度 | height | barHeight | ProgressBar真实采用 |
| 列表/卡片 | width/height | listWidth/cardHeight | 一律加业务前缀 |
| 任意尺寸 | width/height | xxxWidth/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逐项声明,类型确定、无需ForEach与item.id:
| 设置项 | 交互 | 行为 |
|---|---|---|
| 深色模式 | Toggle开关 | saveDarkMode持久化,提示重启生效 |
| 导出数据 | 点击 | 聚合账单/分类 JSON,Toast 提示 |
| 清空所有数据 | 点击 →ConfirmDialog | 二次确认后PreferenceUtil.clear() |
| 关于 | 点击 | RouterUtil.push('pages/AboutView') |
6.3 深色模式持久化要点
Toggle.onChange回调的处理流程如下:
- 立即更新
@State darkMode,保证开关即时响应 - 调用
SettingRepository.getInstance().saveDarkMode(isOn)持久化布尔值 ToastUtil.show('重启应用后生效')提示用户深色模式需重启应用生效
类型安全:
onChange((isOn: boolean) => ...)回调参数明确为boolean,saveDarkMode也接收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的执行流程如下:
- 排查所有
Array<any>与: any声明,定位模板残留 - 为每个数据项定义具体类型或独立方法(如
saveTheme(mode: string)) - 删除无用的
data: Array<any>字段与泛型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-pproduct=default10.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.init11.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/height与CustomComponent基类方法的命名冲突
下一篇预告:继续推进 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