HarmonyOS应用开发实战:猫猫大作战-设置页面完整实战——Toggle 开关、Slider 滑块与设置持久化

上图是「猫猫大作战」主菜单:标题区、难度选择、开始游戏、成就、新手教程,以及最下方蓝边框的「⚙ 设置」按钮——点击它,router.pushUrl 会把玩家带进本文的主角:设置页面。
上图是本文的完整知识地图:设置页面被拆成状态层(九个 @State)、组件层(三个 @Builder + 四种交互组件)与数据层(两个单例 + preferences 键值库),箭头标出了「改状态 → 改单例 → 落盘」的核心数据流。本文按这张图逐层拆解。
前言
设置页面是游戏类应用的静默刚需:平时没人注意它,但音效、音量、振动、显示开关、玩家名称这些偏好,决定了玩家每一次交互的体验。在「猫猫大作战」里,设置页面(SettingsPage.ets)一共 318 行,却串起了 Toggle 开关、Slider 滑块、TextInput 输入框、AlertDialog 确认弹窗四种交互组件,以及 DataStorage 单例的 preferences 持久化封装。
本篇围绕这个真实页面,讲清楚六件事:
- 设置页面的三组结构怎么用
@Builder抽成可复用的「组标题 / 开关行 / 滑块行」; - 九个设置项为什么用九个
@State,启动时如何恢复; Toggle与Slider的完整参数体系与「三件事」回调范式;saveSetting / getSetting如何做带默认值的类型收窄;- 清除数据的「确认弹窗 + 三层重置」完整链路;
- 扩展实战:用
Radio单选组给设置页加语言切换。
提示:本文假设你已经掌握 ArkTS 基础语法与
@State、@Builder装饰器用法。项目源码位于dazhuozha/entry/src/main/ets/pages/SettingsPage.ets与components/DataStorage.ets,持久化基础可回顾本系列第 134 篇。
一、设置页面功能拆解:入口与三组设置
1.1 从主菜单进入设置页
先看入口。主菜单 Index.ets 里的设置按钮是一颗蓝边白底的圆角按钮,点击后通过 router.pushUrl 跳转:
// 来源:entry/src/main/ets/pages/Index.ets 主菜单
Button('⚙ 设置')
.width('70%')
.height(48)
.fontSize(16)
.fontColor('#3498DB')
.backgroundColor('#FFFFFF')
.borderRadius(24)
.border({ width: 2, color: '#3498DB' })
.margin({ top: 12 })
.onClick(() => {
router.pushUrl({ url: 'pages/SettingsPage' });
})
页面路径 pages/SettingsPage 必须先注册进 resources/base/profile/main_pages.json,否则 pushUrl 会抛路由错误(本系列第 78 篇讲过注册规则)。
1.2 页面骨架:标题栏 + Scroll + 三组卡片
设置页面的 build() 骨架是「固定标题栏 + 可滚动内容区」两段式:
build() {
Column() {
// 标题栏:返回 + 居中标题 + 占位
Row() {
Button('返回')
.fontSize(16)
.fontColor('#2ECC71')
.backgroundColor(Color.Transparent)
.onClick(() => { router.back(); })
Text('设置')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#2C3E50')
.layoutWeight(1)
.textAlign(TextAlign.Center)
Blank().width(60) // 与返回按钮等宽,保证标题真正居中
}
.width('100%').height(56)
.backgroundColor('#FFFFFF')
.shadow({ radius: 2, color: 'rgba(0,0,0,0.1)', offsetY: 1 })
// 内容区:可滚动
Scroll() {
Column() {
this.SettingGroup('音效设置')
Column() { /* 音效/音乐/振动 */ }
.width('100%').backgroundColor('#FFFFFF')
.borderRadius(8).margin({ left: 16, right: 16 })
this.SettingGroup('游戏设置')
Column() { /* 玩家名称/预告/连击/动画 */ }
this.SettingGroup('其他')
Column() { /* 清除数据/关于游戏 */ }
}
.width('100%').padding({ top: 16, bottom: 16 })
}
.width('100%').layoutWeight(1)
.scrollBar(BarState.Off)
}
.width('100%').height('100%')
.backgroundColor('#F5F5F5')
}
内容被组织成三组白底圆角卡片,浮在 #F5F5F5 的灰底上——这是 HarmonyOS 设置类页面最经典的「分组卡片」视觉语言:
| 分组 | 设置项 | 交互组件 | 持久化 key |
|---|---|---|---|
| 音效设置 | 游戏音效 / 音效音量 / 背景音乐 / 音乐音量 / 振动反馈 | Toggle + Slider | soundEnabled、soundVolume、musicEnabled、musicVolume、vibrationEnabled |
| 游戏设置 | 玩家名称 / 显示预告猫咪 / 显示连击提示 / 显示得分动画 | TextInput + Toggle | playerName、showPreview、showComboHint、showScoreAnimation |
| 其他 | 清除游戏数据 / 关于游戏 | Button + AlertDialog | 全量 clear |
提示:
scrollBar(BarState.Off)隐藏了滚动条。设置项不多时,系统滚动条会破坏分组卡片的整体感;如果设置项超过一屏,建议保留滚动条方便玩家定位。
二、九个 @State:设置项的状态设计
2.1 一个设置项一个 @State
设置页的状态声明极其朴素——一个设置项对应一个 @State,不做对象嵌套:
// 来源:entry/src/main/ets/pages/SettingsPage.ets
@Entry
@Component
struct SettingsPage {
// 音效设置
@State soundEnabled: boolean = true;
@State soundVolume: number = 50;
@State musicEnabled: boolean = true;
@State musicVolume: number = 50;
@State vibrationEnabled: boolean = true;
// 游戏设置
@State showPreview: boolean = true;
@State showComboHint: boolean = true;
@State showScoreAnimation: boolean = true;
// 玩家名称
@State playerName: string = '玩家';
// 弹窗状态
@State showClearDialog: boolean = false;
private soundManager = SoundManager.getInstance();
private storage: DataStorage | null = null;
}
为什么不用一个 @State settings: SettingsConfig 对象装下所有设置?因为 V1 状态装饰器对对象嵌套属性的观察是浅层的——settings.soundEnabled = true 这种深属性赋值不会触发重渲染(本系列第 46 篇专门踩过这个坑)。九个平铺的 @State 虽然啰嗦,但每一次赋值都可靠地驱动 UI 更新。
2.2 aboutToAppear:启动时恢复上次设置
页面出现时,在 aboutToAppear 里把持久化的值逐项读回 @State:
async aboutToAppear() {
// 初始化存储单例
this.storage = DataStorage.getInstance();
await this.storage.init(getContext(this) as Context);
// 音效设置从 SoundManager 读取(它自己也持久化了一份)
this.soundEnabled = this.soundManager.isEnabled();
this.soundVolume = this.soundManager.getVolume() * 100;
// 其余设置从 DataStorage 读取,缺省走默认值
this.musicEnabled = await this.storage.getSetting('musicEnabled', true) as boolean;
this.musicVolume = await this.storage.getSetting('musicVolume', 50) as number;
this.vibrationEnabled = await this.storage.getSetting('vibrationEnabled', true) as boolean;
this.showPreview = await this.storage.getSetting('showPreview', true) as boolean;
this.showComboHint = await this.storage.getSetting('showComboHint', true) as boolean;
this.showScoreAnimation = await this.storage.getSetting('showScoreAnimation', true) as boolean;
this.playerName = await this.storage.getSetting('playerName', '玩家') as string;
}
恢复流程是固定的四步:
DataStorage.getInstance().init(context)——拿到preferences实例;- 从
soundManager读音效开关与音量(音效是全局单例,进入页面前就已生效); getSetting(key, 默认值)逐项读取其余七项;- 回填
@State,页面按上次设置渲染。
提示:
getSetting的第二个参数是默认值——首次安装、key 不存在、读取异常三种情况都会返回它。这行代码同时完成了「兜底」和「类型示范」,是设置读取的关键设计。
三、三个 @Builder:渲染骨架的复用设计
3.1 SettingGroup:组标题
设置页有六七个「行」,但「组」的形态只有一种——一行灰色小字标题。抽成 @Builder:
// 设置组标题
@Builder
SettingGroup(title: string) {
Text(title)
.fontSize(14)
.fontColor('#7F8C8D')
.width('100%')
.padding({ left: 16, bottom: 8 })
}
3.2 SettingItemWithToggle:开关行
开关行是「左侧标签 + 右侧 Toggle」的横向结构,通过参数把标签、当前值、回调全部抽象掉:
// 设置项 - 开关
@Builder
SettingItemWithToggle(label: string, isOn: boolean, onChange: (isOn: boolean) => void) {
Row() {
Text(label)
.fontSize(16)
.fontColor('#2C3E50')
.layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: isOn })
.onChange(onChange)
.selectedColor('#2ECC71')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
}
调用侧只剩一行,五个开关项共用同一套渲染逻辑:
this.SettingItemWithToggle('游戏音效', this.soundEnabled, async (isOn: boolean) => {
this.soundEnabled = isOn;
this.soundManager.setEnabled(isOn);
await this.storage?.saveSetting('soundEnabled', isOn);
})
3.3 SettingItemWithSlider:滑块行
滑块行在开关行的基础上,把右侧换成 Slider + 百分比文本:
// 设置项 - 滑块
@Builder
SettingItemWithSlider(label: string, value: number, onChange: (value: number) => void) {
Row() {
Text(label)
.fontSize(16)
.fontColor('#2C3E50')
.layoutWeight(1)
Row() {
Slider({
value: value,
min: 0,
max: 100,
step: 1,
style: SliderStyle.OutSet
})
.blockColor('#2ECC71')
.trackColor('#E0E0E0')
.selectedColor('#2ECC71')
.width(100)
.onChange(onChange)
Text(`${Math.round(value)}%`)
.fontSize(14)
.fontColor('#7F8C8D')
.margin({ left: 8 })
}
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
}
三个 @Builder 把「组标题、开关行、滑块行」变成积木:新增一个设置项 = 声明一个 @State + 调一次 Builder + 在 aboutToAppear 加一行恢复。这就是声明式 UI 的复用红利。
四、Toggle 开关组件深入
4.1 ToggleType 的三种形态
Toggle 是 ArkUI 的开关组件,官方文档将其与 Checkbox、Radio 归为选择类组件。它的形态由 type 决定:
| 形态 | 说明 | 典型场景 |
|---|---|---|
ToggleType.Switch | 滑动开关(iOS 风格圆角胶囊) | 设置项的启用/禁用 |
ToggleType.Checkbox | 复选框(勾选样式) | 多选标签、协议勾选 |
ToggleType.Button | 按钮型(含文本内容) | 状态切换按钮 |
// 三种形态对比
Toggle({ type: ToggleType.Switch, isOn: true }) // 设置页用的就是它
Toggle({ type: ToggleType.Checkbox, isOn: false })
Toggle({ type: ToggleType.Button, isOn: this.playing }) {
Text(this.playing ? '暂停' : '播放')
}
4.2 onChange 回调的「三件事」范式
回看 3.2 的调用侧代码,Toggle 的 onChange 回调固定做三件事:
- 改
@State:this.soundEnabled = isOn——UI 即时刷新; - 改单例:
this.soundManager.setEnabled(isOn)——音效立即生效,不用重启页面; - 落盘:
await this.storage?.saveSetting('soundEnabled', isOn)——下次启动仍记住。
这三步的顺序不能乱:先改状态保证 UI 反馈零延迟,再改单例保证功能即时生效,最后异步落盘不阻塞交互。设置页里五个开关项的回调全部遵循同一范式,只是 key 与单例方法不同。
4.3 条件渲染:开关控制滑块显隐
一个体验细节:音量滑块只在对应开关打开时才显示——「游戏音效」关了,「音效音量」滑块就没有存在的意义:
// 音效开关
this.SettingItemWithToggle('游戏音效', this.soundEnabled, async (isOn: boolean) => {
this.soundEnabled = isOn;
this.soundManager.setEnabled(isOn);
await this.storage?.saveSetting('soundEnabled', isOn);
})
// 音效音量:跟随开关显隐
if (this.soundEnabled) {
this.SettingItemWithSlider('音效音量', this.soundVolume, async (value: number) => {
this.soundVolume = value;
this.soundManager.setVolume(value / 100);
await this.storage?.saveSetting('soundVolume', value);
})
}
if (this.soundEnabled) 条件渲染让滑块彻底从组件树上移除,而不是 visibility 隐藏——组件销毁意味着没有隐藏态的布局占位,卡片高度自然收紧。
五、Slider 滑块组件深入
5.1 核心参数速览
Slider 用于在一个区间内拖动选择数值,本项目用它做 0~100 的音量调节:
| 参数 | 类型 | 作用 | 本项目取值 |
|---|---|---|---|
value | number | 当前值(受控) | this.soundVolume |
min / max | number | 区间边界 | 0 / 100 |
step | number | 步长 | 1 |
style | SliderStyle | 滑块风格 | SliderStyle.OutSet |
onChange | 回调 | 值变化时触发 | (value) => {...} |
SliderStyle 有两种:OutSet 滑块浮在轨道外侧(本项目选择,视觉更「游戏」),InSet 滑块嵌在轨道内(更接近系统控件风格)。
5.2 三色定制
Slider 的视觉由三个颜色属性共同决定:
-
blockColor('#2ECC71')——滑块圆钮的颜色; -
trackColor('#E0E0E0')——轨道背景(未选中部分)的颜色; -
selectedColor('#2ECC71')——轨道已选部分(当前值左侧)的颜色。
绿钮 + 绿色已选轨道 + 灰色背景轨道,玩家一眼就能读出「当前音量在区间里的位置」。百分比文本 ${Math.round(value)}% 用 Math.round 取整,避免拖动过程中出现 53.7% 这类抖动小数。
5.3 高频 onChange 的落盘策略
Slider 拖动时 onChange 会连续触发(每一步都回调)。当前实现是每次回调都 saveSetting + flush,一次性拖动可能触发几十次磁盘写入。功能正确,但有更稳妥的优化思路:
提示:
onChange的完整签名是(value: number, mode: SliderChangeMode) => void,mode区分Begin(按下)、Moving(拖动)、End(松手)、Click(点按)。**「Moving 时只改内存,End 时才落盘」**是高频写场景的标准做法——value / 100实时驱动音量,flush只在松手那一次执行。
// 优化版:拖动中实时生效,松手才落盘
Slider({ value: this.soundVolume, min: 0, max: 100, step: 1, style: SliderStyle.OutSet })
.onChange((value: number, mode: SliderChangeMode) => {
this.soundVolume = value;
this.soundManager.setVolume(value / 100); // 内存态:实时生效
if (mode === SliderChangeMode.End) { // 松手:落盘一次
this.storage?.saveSetting('soundVolume', value);
}
})
六、TextInput:玩家名称输入
6.1 基本用法与空值兜底
玩家名称放在「游戏设置」组的第一行,用 TextInput 承接,右侧宽度收窄到 120:
Row() {
Text('玩家名称')
.fontSize(16)
.fontColor('#2C3E50')
.layoutWeight(1)
TextInput({ placeholder: '请输入玩家名称', text: this.playerName })
.width(120)
.height(36)
.fontSize(14)
.fontColor('#2C3E50')
.backgroundColor('#F5F5F5')
.borderRadius(8)
.onChange(async (value: string) => {
this.playerName = value || '玩家'; // 空值兜底
await this.storage?.saveSetting('playerName', this.playerName);
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
两个细节值得注意:
-
text: this.playerName:受控初值,aboutToAppear恢复的名字会正确显示在输入框里; -
value || '玩家':玩家清空输入框时立刻回落到默认名,避免空字符串一路存进排行榜。
6.2 玩家名称如何影响排行榜
设置项的价值在于被消费。游戏结束时,主页面从存储读取这个名字写进排行榜:
// 来源:entry/src/main/ets/pages/Index.ets 游戏结束结算
const canEnter = await storage.canEnterLeaderboard(this.score);
if (canEnter) {
// 获取玩家名称(从设置中读取,如果没有则使用默认值)
const playerName = await storage.getSetting('playerName', '玩家') as string;
await storage.addToLeaderboard({
playerName: playerName,
score: this.score,
difficulty: this.selectedDifficulty,
timestamp: Date.now()
});
}
设置页写入 → 排行榜读取,两个页面通过 preferences 的同一个 key 解耦协作——谁也不依赖谁的存在,这正是「配置与消费分离」的典型落地。
七、设置持久化:DataStorage 的读写封装
7.1 saveSetting / getSetting:带默认值的类型收窄
DataStorage 单例为设置项提供了两个通用方法,值类型限定为 string | number | boolean——正好覆盖设置项的全部形态:
// 来源:entry/src/main/ets/components/DataStorage.ets
// 保存设置值
async saveSetting(key: string, value: string | number | boolean): Promise<void> {
if (!this.preferences) return;
try {
await this.preferences.put(key, value);
await this.preferences.flush();
} catch (error) {
console.error(`保存设置失败: ${key}`, error);
}
}
// 获取设置值
async getSetting(key: string, defaultValue: string | number | boolean): Promise<string | number | boolean> {
if (!this.preferences) return defaultValue;
try {
const value = await this.preferences.get(key, defaultValue);
// 根据defaultValue的类型进行转换
if (typeof defaultValue === 'boolean') {
return value as boolean;
} else if (typeof defaultValue === 'number') {
return value as number;
} else {
return value as string;
}
} catch (error) {
console.error(`获取设置失败: ${key}`, error);
return defaultValue;
}
}
设计上有三个要点:
put之后立刻flush——put只写内存缓存,flush才真正同步到文件,漏掉flush是「重启丢设置」的头号原因;- 默认值即类型签名——调用方传
true就拿回 boolean,传50就拿回 number,靠typeof defaultValue分支完成类型收窄; - 异常静默兜底——任何读写失败都返回默认值,设置项永远「可用」,绝不让存储故障砸了页面。
7.2 preferences 底层:put / get / flush
再往下就是系统 @ohos.data.preferences 的三件套。初始化在 DataStorage.init 里完成,存储文件名是 cat_battle_war:
private readonly STORE_NAME = 'cat_battle_war';
async init(context: Context): Promise<void> {
try {
this.preferences = await preferences.getPreferences(context, this.STORE_NAME);
} catch (error) {
console.error('Failed to init preferences:', error);
}
}
整个应用(设置、存档、统计、排行榜)共享这一个 preferences 实例,DataStorage 单例保证 init 只执行一次,详情可回顾本系列第 134 篇。
7.3 设置项 key 清单
九个设置项的 key、类型与默认值汇总如下,写代码时直接对照:
| key | 类型 | 默认值 | 消费方 |
|---|---|---|---|
soundEnabled | boolean | true | SoundManager |
soundVolume | number | 50 | SoundManager |
musicEnabled | boolean | true | 背景音乐模块 |
musicVolume | number | 50 | 背景音乐模块 |
vibrationEnabled | boolean | true | 振动反馈模块 |
showPreview | boolean | true | 主页面预告猫 |
showComboHint | boolean | true | 连击提示浮层 |
showScoreAnimation | boolean | true | 得分动画 |
playerName | string | ‘玩家’ | 排行榜 |
提示:key 是字符串字面量,写错一个字母不会报错,只会「读不到值、永远走默认」。建议像本节一样维护一张 key 清单表,或在
DataStorage里集中定义常量。
八、清除数据:AlertDialog 确认与三层重置
8.1 AlertDialog.show 双按钮确认
「清除游戏数据」是设置页里唯一的破坏性操作,必须二次确认。项目用系统 AlertDialog.show 的双按钮形态:
Button('清除')
.fontSize(14)
.fontColor('#E74C3C')
.backgroundColor(Color.Transparent)
.border({ width: 1, color: '#E74C3C' })
.borderRadius(4)
.onClick(() => {
AlertDialog.show({
title: '确认清除',
message: '确定要清除所有游戏数据吗?此操作不可恢复。',
primaryButton: {
value: '取消',
action: () => {}
},
secondaryButton: {
value: '清除',
fontColor: '#E74C3C',
action: async () => {
await this.storage?.clearGameSave(); // ① 清存档
await this.storage?.clearAllSettings(); // ② 清设置
// ③ 重置内存状态(见 8.2)
}
}
});
})
破坏性操作的颜色语义:按钮本体红字红边框空心化(弱化存在感)、弹窗确认键 fontColor: '#E74C3C'(强化警示)、取消键为默认样式。红色只出现在「执行破坏」的入口上,玩家不会误触。
8.2 清除的三个层次
点击「清除」后,数据从三个层面同时消失:
- 磁盘存档:
clearGameSave()删除gameSave键——进行中的一局没了; - 磁盘设置:
clearAllSettings()调preferences.clear()清空全部键值——高分、统计、设置、排行榜一起归零; - 内存状态:九个
@State逐个赋回默认值,SoundManager单例同步复位——当前页面立即回到出厂样子:
// 重置状态为默认值
this.soundEnabled = true;
this.soundVolume = 50;
this.musicEnabled = true;
this.musicVolume = 50;
this.vibrationEnabled = true;
this.showPreview = true;
this.showComboHint = true;
this.showScoreAnimation = true;
// 更新音效管理器
this.soundManager.setEnabled(true);
this.soundManager.setVolume(0.5);
提示:
preferences.clear()清的是整个存储文件的所有 key,不止设置项。如果只想清设置、保留排行榜,就不能用clear(),而要逐个delete(key)——破坏范围要和产品语义对齐。
九、扩展实战:Radio 单选实现语言切换
设置页目前还没有语言项,但项目里已经备好了 LanguageManager 单例(支持简体中文、英语、日语等)。给它配一个 Radio 单选组,就是设置页最自然的下一块积木。
9.1 LanguageManager 单例
LanguageManager 用 Map<Language, LanguageTexts> 维护多语言资源,getText(path) 按点分路径取文本:
// 来源:entry/src/main/ets/components/LanguageManager.ets(节选)
export enum Language {
ZH_CN = 'zh-CN',
ZH_TW = 'zh-TW',
EN = 'en',
JA = 'ja',
KO = 'ko'
}
export class LanguageManager {
private static instance: LanguageManager;
private currentLanguage: Language = Language.ZH_CN;
static getInstance(): LanguageManager { /* 省略 */ }
setLanguage(language: Language): void {
this.currentLanguage = language;
}
getTexts(): LanguageTexts {
const texts = languageResources.get(this.currentLanguage);
if (texts) {
return texts;
}
return languageResources.get(Language.ZH_CN)!; // 兜底回中文
}
}
9.2 用 Radio 组搭语言选择行
Radio 的互斥靠 group 参数——同组之内天然单选,无需自己管理「选中谁」:
// 扩展方案:设置页语言选择行
@State currentLang: string = 'zh-CN';
Row() {
Text('语言')
.fontSize(16)
.fontColor('#2C3E50')
.layoutWeight(1)
Row({ space: 12 }) {
Radio({ value: 'zh-CN', group: 'lang' })
.checked(this.currentLang === 'zh-CN')
.onChange((isOn: boolean) => this.switchLang(isOn, 'zh-CN'))
Text('中文').fontSize(14)
Radio({ value: 'en', group: 'lang' })
.checked(this.currentLang === 'en')
.onChange((isOn: boolean) => this.switchLang(isOn, 'en'))
Text('English').fontSize(14)
Radio({ value: 'ja', group: 'lang' })
.checked(this.currentLang === 'ja')
.onChange((isOn: boolean) => this.switchLang(isOn, 'ja'))
Text('日本語').fontSize(14)
}
}
.width('100%').height(56).padding({ left: 16, right: 16 })
// 切换语言:同一个「三件事」范式
async switchLang(isOn: boolean, lang: string) {
if (!isOn) return; // 单选组会先回调旧项的 false
this.currentLang = lang; // ① 改 @State
LanguageManager.getInstance().setLanguage(lang as Language); // ② 改单例
await this.storage?.saveSetting('language', lang); // ③ 落盘
}
语言选择遵循与 Toggle 完全相同的三件事范式——范式统一后,新增任何设置项的心智成本都趋近于零。更多 i18n 方案($r 资源、zh_CN/en_US 目录分流)可回顾本系列第 29 篇。
十、踩坑记录
10.1 坑一:Toggle 的 isOn 不跟随 @State 回流
Toggle({ isOn: this.soundEnabled }) 的 isOn 是初始化参数,后续 @State 变化时部分场景不会自动同步到组件内部状态:
// ❌ 清除数据后,九个 @State 已重置,但 Toggle 仍显示旧状态
// ✅ 正确做法:把 isOn 作为受控值的关键路径,重置时确保赋值发生在
// 同一个 @State 变更批次里(本项目的重置代码正是这么做的),
// 若仍不同步,可用 ToggleController 调 toggle() 强制刷新
判断标准:改 @State 后开关视觉没变,但回调逻辑正常——就是 isOn 没回流,优先检查是不是把 isOn 传成了字面量或局部变量。
10.2 坑二:滑块拖动高频 flush
如 5.3 所述,onChange 每一步都触发。除了用 SliderChangeMode.End 收口,还要注意 flush 本身是异步全文件写,设置项多时频繁 flush 会放大 IO 压力。两条原则:
-
内存态(音量即时生效)与持久态(落盘)分离;
-
落盘时机收敛到交互结束(
End/onChange防抖)。
10.3 坑三:getSetting 返回值直接用
getSetting 返回 Promise<string | number | boolean> 联合类型,不断言就当 boolean 用会在严格模式下报错:
// ❌ 编译不过:boolean | number | string 不能赋给 boolean
this.musicEnabled = await this.storage.getSetting('musicEnabled', true);
// ✅ 正确:按默认值类型断言
this.musicEnabled = await this.storage.getSetting('musicEnabled', true) as boolean;
设置页源码里每个 getSetting 调用都带 as boolean / as number / as string,这不是冗余,而是 ArkTS 严格模式的硬要求(本系列第 117 篇)。
十一、性能与最佳实践
- 一个设置项一个
@State:避免嵌套对象的浅观察陷阱,赋值即刷新,简单可靠。 - 交互组件抽
@Builder:组标题、开关行、滑块行三块积木覆盖全部设置形态,新增项零样式成本。 - onChange 三件事范式:改状态 → 改单例 → 落盘,顺序固定、全页统一,排查问题只看一处。
- 读写都带默认值:
getSetting(key, default)同时解决首装、缺 key、异常三种情况,设置永远可用。 - 高频写收敛落盘时机:滑块类连续交互只在结束时
flush,磁盘写入次数从几十次降到一次。 - 破坏性操作双层防护:视觉弱化(空心红边)+ 行为确认(AlertDialog 双按钮),并保证清除后内存与磁盘同步重置。
总结
本篇以「猫猫大作战」设置页面为锚点,完整拆解了设置系统的三层结构:状态层(九个平铺 @State + aboutToAppear 四步恢复)、组件层(三个 @Builder 积木 + Toggle/Slider/TextInput/AlertDialog 四种交互)、数据层(DataStorage 单例的类型收窄读写 + preferences 落盘)。核心方法论只有一条:所有 onChange 都执行「改状态 → 改单例 → 落盘」三件事——范式统一,设置系统就永远不会失控。
下一篇预告:我们将拆解「猫猫大作战」的新手教程页面(TutorialPage.ets)——Swiper 引导页、步骤指示器、跳过确认与教程完成奖励的综合实战。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
-
「猫猫大作战」项目源码:本仓库
dazhuozha/entry/src/main/ets/pages/SettingsPage.ets、components/DataStorage.ets -
系列索引:本仓库
articles/INDEX.md
更多推荐



所有评论(0)