主菜单与设置入口

上图是「猫猫大作战」主菜单:标题区、难度选择、开始游戏、成就、新手教程,以及最下方蓝边框的「⚙ 设置」按钮——点击它,router.pushUrl 会把玩家带进本文的主角:设置页面。


上图是本文的完整知识地图:设置页面被拆成状态层(九个 @State)、组件层(三个 @Builder + 四种交互组件)与数据层(两个单例 + preferences 键值库),箭头标出了「改状态 → 改单例 → 落盘」的核心数据流。本文按这张图逐层拆解。

前言


设置页面是游戏类应用的静默刚需:平时没人注意它,但音效、音量、振动、显示开关、玩家名称这些偏好,决定了玩家每一次交互的体验。在「猫猫大作战」里,设置页面(SettingsPage.ets)一共 318 行,却串起了 Toggle 开关Slider 滑块TextInput 输入框AlertDialog 确认弹窗四种交互组件,以及 DataStorage 单例的 preferences 持久化封装。

本篇围绕这个真实页面,讲清楚六件事:

  1. 设置页面的三组结构怎么用 @Builder 抽成可复用的「组标题 / 开关行 / 滑块行」;
  2. 九个设置项为什么用九个 @State,启动时如何恢复;
  3. ToggleSlider 的完整参数体系与「三件事」回调范式;
  4. saveSetting / getSetting 如何做带默认值的类型收窄
  5. 清除数据的「确认弹窗 + 三层重置」完整链路;
  6. 扩展实战:用 Radio 单选组给设置页加语言切换。

提示:本文假设你已经掌握 ArkTS 基础语法与 @State@Builder 装饰器用法。项目源码位于 dazhuozha/entry/src/main/ets/pages/SettingsPage.etscomponents/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 + SlidersoundEnabled、soundVolume、musicEnabled、musicVolume、vibrationEnabled
游戏设置玩家名称 / 显示预告猫咪 / 显示连击提示 / 显示得分动画TextInput + ToggleplayerName、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;
}

恢复流程是固定的四步:

  1. DataStorage.getInstance().init(context)——拿到 preferences 实例;
  2. soundManager 读音效开关与音量(音效是全局单例,进入页面前就已生效);
  3. getSetting(key, 默认值) 逐项读取其余七项;
  4. 回填 @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 的开关组件,官方文档将其与 CheckboxRadio 归为选择类组件。它的形态由 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 的调用侧代码,ToggleonChange 回调固定做三件事:

  1. @Statethis.soundEnabled = isOn——UI 即时刷新;
  2. 改单例this.soundManager.setEnabled(isOn)——音效立即生效,不用重启页面;
  3. 落盘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 的音量调节:

参数类型作用本项目取值
valuenumber当前值(受控)this.soundVolume
min / maxnumber区间边界0 / 100
stepnumber步长1
styleSliderStyle滑块风格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) => voidmode 区分 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;
  }
}

设计上有三个要点:

  1. put 之后立刻 flush——put 只写内存缓存,flush 才真正同步到文件,漏掉 flush 是「重启丢设置」的头号原因;
  2. 默认值即类型签名——调用方传 true 就拿回 boolean,传 50 就拿回 number,靠 typeof defaultValue 分支完成类型收窄;
  3. 异常静默兜底——任何读写失败都返回默认值,设置项永远「可用」,绝不让存储故障砸了页面。

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类型默认值消费方
soundEnabledbooleantrueSoundManager
soundVolumenumber50SoundManager
musicEnabledbooleantrue背景音乐模块
musicVolumenumber50背景音乐模块
vibrationEnabledbooleantrue振动反馈模块
showPreviewbooleantrue主页面预告猫
showComboHintbooleantrue连击提示浮层
showScoreAnimationbooleantrue得分动画
playerNamestring‘玩家’排行榜

提示: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 清除的三个层次

点击「清除」后,数据从三个层面同时消失:

  1. 磁盘存档clearGameSave() 删除 gameSave 键——进行中的一局没了;
  2. 磁盘设置clearAllSettings()preferences.clear() 清空全部键值——高分、统计、设置、排行榜一起归零;
  3. 内存状态:九个 @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 单例

LanguageManagerMap<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 篇)。

十一、性能与最佳实践

  1. 一个设置项一个 @State:避免嵌套对象的浅观察陷阱,赋值即刷新,简单可靠。
  2. 交互组件抽 @Builder:组标题、开关行、滑块行三块积木覆盖全部设置形态,新增项零样式成本。
  3. onChange 三件事范式:改状态 → 改单例 → 落盘,顺序固定、全页统一,排查问题只看一处。
  4. 读写都带默认值getSetting(key, default) 同时解决首装、缺 key、异常三种情况,设置永远可用。
  5. 高频写收敛落盘时机:滑块类连续交互只在结束时 flush,磁盘写入次数从几十次降到一次。
  6. 破坏性操作双层防护:视觉弱化(空心红边)+ 行为确认(AlertDialog 双按钮),并保证清除后内存与磁盘同步重置。

总结

本篇以「猫猫大作战」设置页面为锚点,完整拆解了设置系统的三层结构:状态层(九个平铺 @State + aboutToAppear 四步恢复)、组件层(三个 @Builder 积木 + Toggle/Slider/TextInput/AlertDialog 四种交互)、数据层DataStorage 单例的类型收窄读写 + preferences 落盘)。核心方法论只有一条:所有 onChange 都执行「改状态 → 改单例 → 落盘」三件事——范式统一,设置系统就永远不会失控。

下一篇预告:我们将拆解「猫猫大作战」的新手教程页面(TutorialPage.ets)——Swiper 引导页、步骤指示器、跳过确认与教程完成奖励的综合实战。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐