用户点开“应用内提示”,开关瞬间变蓝;回到桌面、重新打开应用,开关却又变回灰色。更麻烦的是,界面可能一直显示“设置成功”,而底层 Preferences 的写盘操作已经抛出了异常。这不是颜色主题的小瑕疵,而是屏幕状态与持久状态谁说了算的问题。一个设置页面看起来只有三行开关,真正决定质量的却是初始化时机、同一批设置的存储边界,以及失败时怎样向用户交代。

这篇使用独立的小应用“简序设置”,不接入此前工单、能耗或网络检测工程。页面分为“显示”“提醒”“数据”三个区域,提供“深色模式”“应用内提示”“自动检查”三个选项。它们在示例中只控制应用自己的配色、消息提示和本地检查开关;“应用内提示”不是操作系统通知推送,因此不申请通知权限,也不会因为切换开关而调用任何未说明的后台任务能力。项目采用 Stage 模型、ArkTS 声明式 UI、@State、Toggle 和 Preferences。设计目标很具体:页面加载一次、每次仅修改一个设置项、写盘成功才确认新值,失败则保持原值并明确提示。

分组设置与可靠写盘的技术封面

一、一个开关,至少有三种状态

开关表面上只有开与关,工程上还要考虑第三种状态:操作正在提交。若用户在写盘尚未结束时连续点击两个开关,第一个异步方法尚在等待 flush(),第二个又基于旧状态生成新快照,就可能出现后一份配置覆盖前一项更改的情况。这里不追求用户在一秒内无限点击的“丝滑”,而是采用简单、可解释的单实例写盘门闩:busy 为真时暂时禁用所有开关,成功后一次性替换页面快照。

另一个容易忽略的地方是默认值。第一次启动时文件里没有 settings_v1,这应当被理解为“按预置默认值运行”,而不是证明已经写过一份配置。默认值规定为深色模式关闭、应用内提示开启、自动检查关闭。如果读取到的确实是空键,可以应用默认值;如果读到的是破损 JSON、缺失字段或错误类型,就不能把它当成第一次启动。对于后者,页面应进入只读保护状态,避免随便点一下开关就覆盖原本有问题的数据。

// entry/src/main/ets/model/SettingsSnapshot.ets
export interface SettingsSnapshot {
  nightMode: boolean;
  showHints: boolean;
  autoCheck: boolean;
}
export type SettingKey = 'nightMode' | 'showHints' | 'autoCheck';

export function defaultSettings(): SettingsSnapshot {
  return { nightMode: false, showHints: true, autoCheck: false };
}

export function isSettings(value: SettingsSnapshot): boolean {
  return typeof value.nightMode === 'boolean' &&
    typeof value.showHints === 'boolean' &&
    typeof value.autoCheck === 'boolean';
}

保存时选择单个 JSON 快照而不是三个独立键,原因在于这三个选项需要作为同一份界面配置一起读取。在普通示例中,一次 put('settings_v1', JSON.stringify(next)) 比分三次 put 更容易明确“页面显示的是哪个版本”。这并不意味着偏好存储自动获得关系型数据库那样的事务保障;面对突然断电、跨进程写入或格式迁移,仍需要额外设计。当前边界是单进程、单页面、少量布尔配置。

二、配置仓库只认提交成功的快照

数据访问放在 store/SettingsStore.ets,UI 不直接接触 put 或 flush。使用 preferences.getPreferences(context, 'panel_settings') 取得实例,存储键固定为 settings_v1;如果使用 XML 偏好存储,put 更新缓存而 flush 负责持久化。官方文档指出 Preferences 不适用于未经协调的多进程共享写入,因此这套代码不做进程间互斥的承诺。

// entry/src/main/ets/store/SettingsStore.ets
import { common } from '@kit.AbilityKit';
import { preferences } from '@kit.ArkData';
import { SettingsSnapshot, SettingKey,
  defaultSettings, isSettings } from '../model/SettingsSnapshot';

export class SettingsStore {
  private pref?: preferences.Preferences;
  private current: SettingsSnapshot = defaultSettings();
  private ready: boolean = false;

  async open(ctx: common.UIAbilityContext): Promise<SettingsSnapshot> {
    this.pref = await preferences.getPreferences(ctx, 'panel_settings');
    const raw = await this.pref.get('settings_v1', '');
    if (typeof raw !== 'string') { throw new Error('设置内容类型错误'); }
    if (raw.length > 0) {
      let decoded: SettingsSnapshot;
      try {
        decoded = JSON.parse(raw) as SettingsSnapshot;
        if (!decoded || !isSettings(decoded)) {
          throw new Error('设置字段校验失败');
        }
      } catch (e) { throw new Error('设置文件需要人工检查'); }
      this.current = decoded;
    }
    this.ready = true;
    return { ...this.current };
  }

  async save(key: SettingKey, value: boolean): Promise<SettingsSnapshot> {
    if (!this.ready || !this.pref) { throw new Error('仓库未就绪'); }
    const next: SettingsSnapshot = { ...this.current };
    switch (key) {
      case 'nightMode': next.nightMode = value; break;
      case 'showHints': next.showHints = value; break;
      case 'autoCheck': next.autoCheck = value; break;
    }
    try {
      await this.pref.put('settings_v1', JSON.stringify(next));
      await this.pref.flush();
      this.current = next;
      return { ...next };
    } catch (e) {
      this.ready = false; // 本次失败后停止写入,避免继续覆盖
      throw e;
    }
  }
}

open() 成功读入后,仓库才允许 save()。save() 先从上次确认的 current 拷贝出 next,改变一个指定字段;只有 flush() 返回成功才更新 current。如果失败,仓库把自己置为不可写,由页面保持旧快照。值得注意的是:失败回滚指的是页面与已确认的仓库状态,不是承诺把文件系统里的每个字节恢复成原样。put 已改变 Preferences 的内存缓存,如果 flush 出错,就应停止后续提交,检查环境并重新初始化,而不是继续在一个语义不明的实例上叠加新操作。

这种处理也能避免异步加载造成的误操作。open() 期间开关必须禁用,否则界面初值与磁盘值可能相互覆盖。项目可以额外提供“检查设置文件”入口用于维护,但不应在 catch 中直接把非法 JSON 重写为默认值;那会让格式故障被悄悄掩盖。

DevEco Studio 白色主题中,设置页代码、工程树与右侧预览保持同一状态

三、从按钮事件回到 @State,不要反过来

SettingsPage.ets 通过 @State 持有唯一的已确认快照、ready、busy 与状态提示。生命周期初始化调用 open();每个 Toggle 只报告用户希望写入的目标值,不抢先修改快照。正常情况下页面从 false/true/false 显示三行选项。切换时将 busy 设为真;保存成功才将新的快照赋给 @State,失败则刷新旧快照以纠正临时的开关视觉变化。

// entry/src/main/ets/pages/SettingsPage.ets
import { common } from '@kit.AbilityKit';
import { SettingsStore } from '../store/SettingsStore';
import { SettingKey, SettingsSnapshot,
  defaultSettings } from '../model/SettingsSnapshot';

@Entry
@Component
struct SettingsPage {
  private store: SettingsStore = new SettingsStore();
  @State private values: SettingsSnapshot = defaultSettings();
  @State private busy: boolean = false;
  @State private ready: boolean = false;
  @State private tip: string = '正在读取设置';

  aboutToAppear(): void { this.load(); }

  private async load(): Promise<void> {
    try {
      const ctx = getContext(this) as common.UIAbilityContext;
      this.values = await this.store.open(ctx);
      this.ready = true;
      this.tip = '当前设置已就绪';
    } catch (e) {
      this.ready = false;
      this.tip = '设置读取失败,已暂停修改';
    }
  }

  private async change(key: SettingKey, value: boolean): Promise<void> {
    if (!this.ready || this.busy) { return; }
    this.busy = true;
    try {
      this.values = await this.store.save(key, value);
      this.tip = '设置已保存到本机';
    } catch (e) {
      this.values = { ...this.values }; // 明确恢复原 UI 快照
      this.ready = false;
      this.tip = '保存失败,已保留上一次确认的设置';
    } finally { this.busy = false; }
  }

  build() {
    Column({ space: 14 }) {
      Text('简序设置').fontSize(28).fontWeight(FontWeight.Bold)
      Text(this.tip).fontSize(13).fontColor('#64748B')
      Text('显示').fontSize(15).fontColor('#64748B')
      Row() {
        Text('深色模式').fontSize(18)
        Blank()
        Toggle({ type: ToggleType.Switch, isOn: this.values.nightMode })
          .enabled(this.ready && !this.busy)
          .onChange((v: boolean) => { this.change('nightMode', v); })
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
      Text('提醒').fontSize(15).fontColor('#64748B')
      Row() {
        Text('应用内提示').fontSize(18)
        Blank()
        Toggle({ type: ToggleType.Switch, isOn: this.values.showHints })
          .enabled(this.ready && !this.busy)
          .onChange((v: boolean) => { this.change('showHints', v); })
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
      Text('数据').fontSize(15).fontColor('#64748B')
      Row() {
        Text('自动检查').fontSize(18)
        Blank()
        Toggle({ type: ToggleType.Switch, isOn: this.values.autoCheck })
          .enabled(this.ready && !this.busy)
          .onChange((v: boolean) => { this.change('autoCheck', v); })
      }.width('100%').padding(16).backgroundColor('#FFFFFF').borderRadius(14)
    }.padding(20).width('100%').height('100%').backgroundColor('#F5F8FC')
  }
}

页面用的是三个显式 Row,而不是为了“高级感”叠加复杂的动态配置元数据。项目只有三项固定偏好,明确代码反而便于审计和教学。当设置项扩展到几十条,再把名称、说明、分组和类型抽成只读配置模型,按需使用 List/ListItemGroup 才更合适。这里 @State 的赋值是页面更新入口;Toggle 的 onChange 只是用户动作入口,两者不能互换。

示例为了保持三个选项的同一张配置快照,把“深色模式”当作持久的偏好值;如果需要立即让整个应用切换深浅主题,还需把它接入应用级主题样式与各页面配色,并考虑前后台恢复。当前示例的页面仍使用浅色底,不能因为开关保存成功就把它描述成全应用主题系统已经实现。同理,“自动检查”只是保存偏好,真正的任务调度必须有单独的生命周期与权限设计。

四、把正确性验证写成用户能复现的动作

从点击到写盘确认的状态时序,以及失败后的界面回退

先在全新安装状态打开设置页。界面应显示“深色模式关、应用内提示开、自动检查关”,提示为“当前设置已就绪”;在首次未点击之前,不能把默认值解释成用户做过选择。打开“应用内提示”原本已经开启,这时可以把它关闭,预期成功提交后只改变 showHints,另外两项不动。退出再进入,检查选项从 settings_v1 恢复。验证重复点击时,应看到 busy 期间所有开关暂时不可操作,保存完成后才开放下一次输入。

针对异常,必须人为安排可控的测试条件,而不是把公网结果当作已经发生的事实。可以在调试实现中注入一个抛错的存储适配器,构造 put 或 flush 失败:页面应提示失败、三个开关保留之前确认的值、后续修改受到保护。再准备非法 JSON 和缺字段的快照,观察 open() 是否拒绝把它静默改成默认值。最后切换应用前后台、重新打开页面,确认初始化只有一个来源,不存在旧页面先提交、后加载又覆盖它的问题。

性能方面无需编造写盘耗时。配置体积小不代表可以对每个触摸移动事件反复 flush;对于真正高频变化的偏好,可以引入合理的去抖、显式保存或者专门的任务队列。本例只有用户主动切换时才写入,并在等待阶段禁用操作,着重验证一致性而不是追求没有证据的“零延迟”。若后续支持账号同步,单机 settings_v1 只代表本地快照,需要额外引入版本号、冲突解决与迁移策略。

一个可信的设置中心并不依赖多少圆角、阴影和动画,而取决于它如何对待失败。用户点下的目标值、屏幕最终显示的值、重新启动后的值应该能用同一套规则解释;当系统无法确认写盘时,最有价值的反馈不是一个热闹的成功提示,而是清楚地保留原设置,并阻止后续变更继续扩大不确定性。这也是为什么页面状态管理需要跟持久化边界一起设计。

官方参考:Toggle 组件、Preferences 用户偏好存储、List 容器。

Logo

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

更多推荐