设置页通常是项目里最安静的一块代码。几个开关、一个主题枚举、一段 JSON,写进 Preferences,下次启动再读出来。它安静,是因为多数时间都在读自己写入的数据;它危险,也正因为开发阶段很少遇到“半年前的旧结构、手工调试留下的脏值、升级中断后的影子记录”同时出现。

本文不讨论如何把一个布尔值放进 Preferences,而是处理结构已经演进后的读取边界。演示项目叫 ConfigHarbor,页面为 ConfigRecoveryPage,诊断号 CFG-MIG-3013,演示时间 20:36。活动记录是 428 字节的旧配置,读取后发现两个结构问题,系统没有覆盖原文,而是把它移入隔离区,并恢复上一份已验证的 v3 配置。图片和数值均为可复查的演示设计,不冒充真实用户数据或设备测试结果。

一、最糟的修复,是把坏数据“修好”后立刻覆盖

假设旧版配置只有 theme、textScale 和 showHints。新版把主题改成 appearance,提示开关变成 hints.enabled,并增加 profileId。直接 JSON.parse() 之后用 as WorkspaceProfile 强转,编译器会安静,运行时却不会替你补字段。

更隐蔽的写法是给所有字段加默认值:textScale ?? 1.0、profileId ?? 'default'。这能让页面打开,但也把“旧版本可迁移”和“数据损坏”混成一类。缺失字段可能来自合法 v1,也可能来自写入中断;超出范围的 textScale = 2.75 更不应该被悄悄夹到 1.6 后覆盖原值。开发者失去了证据,用户也失去了回退可能。

因此,配置恢复需要三个动作彼此分离:识别原始版本、把已知旧结构迁移到当前结构、隔离无法证明安全的数据。默认值只用于“明确允许缺省”的字段,不能充当所有异常的消音器。

Zod 在这里承担的是运行时模式校验。它的 safeParse() 返回成功或失败的判别联合体,不需要用异常控制普通校验分支;错误对象还保留字段路径。Preferences 仍只负责轻量键值持久化,两者职责不能倒过来:Zod 不保证落盘原子性,Preferences 也不理解 JSON 业务结构。

二、版本号必须先被识别,迁移函数才有资格运行

ConfigHarbor 把每个历史结构保留为独立 schema。不要只维护一个“当前 schema”然后在字段上堆十几个 .optional();那样任何时代的数据都可能勉强通过,迁移路径反而不可证明。

这段代码解决什么问题:把 v1、v3 与未知输入分开,并让错误路径能精确显示到字段。

import * as z from 'zod';

const ProfileV1 = z.object({
  schemaVersion: z.literal(1),
  theme: z.enum(['day', 'night']),
  textScale: z.number().min(0.85).max(1.60),
  showHints: z.boolean()
}).strict();

const ProfileV3 = z.object({
  schemaVersion: z.literal(3),
  profileId: z.string().min(3).max(32),
  appearance: z.enum(['light', 'dark', 'system']),
  textScale: z.number().min(0.85).max(1.60),
  hints: z.object({ enabled: z.boolean() }).strict()
}).strict();

type WorkspaceProfile = z.infer<typeof ProfileV3>;

export function identify(raw: unknown): 'V1' | 'V3' | 'INVALID' {
  if (ProfileV3.safeParse(raw).success) return 'V3';
  if (ProfileV1.safeParse(raw).success) return 'V1';
  return 'INVALID';
}

.strict() 是这份合同的重要部分。若旧数据多出未识别字段,系统应先判断这些字段是否属于已知版本,而不是无声丢弃。某些业务可以选择 strip 或 passthrough,但那是显式策略,不应由默认行为偶然决定。

示例限制 textScale 在 0.85~1.60,只是 ConfigHarbor 的产品合同,不是 HarmonyOS 系统限制。文章把业务规则和平台约束分开标注,是为了避免读者把演示阈值当成 SDK 参数。

三、迁移是纯函数,持久化是另一个事务

一旦 v1 schema 校验通过,迁移函数才开始工作。纯函数只接收已验证的 v1,输出候选 v3,不读 Preferences,也不写日志。这样既方便用固定向量测试,也避免迁移到一半时留下半成品。

这段代码解决什么问题:把合法 v1 映射为 v3 候选,并再次通过当前 schema 复核。

interface MigrationResult {
  profile: WorkspaceProfile;
  fromVersion: number;
  ruleset: string;
}

export function migrateV1(input: z.infer<typeof ProfileV1>): MigrationResult {
  const candidate: unknown = {
    schemaVersion: 3,
    profileId: 'workspace-main',
    appearance: input.theme === 'night' ? 'dark' : 'light',
    textScale: input.textScale,
    hints: { enabled: input.showHints }
  };

  const checked = ProfileV3.safeParse(candidate);
  if (!checked.success) {
    throw new Error('MIGRATION_OUTPUT_INVALID');
  }
  return {
    profile: checked.data,
    fromVersion: 1,
    ruleset: 'profile-migration-v3'
  };
}

这里没有把 2.75 夹紧到 1.60。如果原始 v1 已经违反 v1 合同,它就不是“可迁移旧配置”,而是损坏输入,应进入隔离路径。迁移函数擅自修正会让错误来源无法追踪,也可能改变用户真实偏好。

另一个容易被忽略的边界是 Zod 版本和 ArkTS 工程兼容性。本文使用的是 Zod 4 风格 API;接入前要在目标 SDK、构建模式和发布产物上执行编译与启动验证。第三方库的类型能通过编辑器提示,不代表所有动态特性都已适配。实际项目应锁定依赖版本并保留最小回归样例。

四、影子键先落盘,回读成功后再切换活动指针

Preferences 官方指南说明:默认 XML 模式的修改主要发生在内存中,需要调用 flush() 持久化;同一个文件对应一个内存实例,数据应保持轻量。本例利用这些边界实现应用层两阶段提交,不宣称 Preferences 自带跨键事务。

键位约定如下:

  • workspace_profile.active:当前活动 JSON;
  • workspace_profile.shadow:待验证的新 JSON;
  • workspace_profile.last_good:上一份已验证配置;
  • workspace_profile.quarantine:隔离证据的摘要,不保存敏感明文。

这段代码解决什么问题:先写影子记录并 flush,回读复核通过后再替换活动配置,避免迁移中断直接破坏唯一副本。

import { preferences } from '@kit.ArkData';

export class ProfileRepository {
  constructor(private readonly store: preferences.Preferences) {}

  async commitMigrated(profile: WorkspaceProfile): Promise<void> {
    const encoded = JSON.stringify(profile);
    this.store.putSync('workspace_profile.shadow', encoded);
    await this.store.flush();

    const readBack = this.store.getSync('workspace_profile.shadow', '') as string;
    const parsed = safeJson(readBack);
    const verified = ProfileV3.safeParse(parsed);
    if (!verified.success) throw new Error('SHADOW_VERIFY_FAILED');

    const oldActive = this.store.getSync('workspace_profile.active', '') as string;
    if (oldActive.length > 0) {
      this.store.putSync('workspace_profile.last_good', oldActive);
    }
    this.store.putSync('workspace_profile.active', readBack);
    this.store.deleteSync('workspace_profile.shadow');
    await this.store.flush();
  }
}

两次 flush() 之间如果进程终止,下一次启动会看到 shadow,但 active 仍在。恢复协调器可以重新校验 shadow,再决定继续切换或删除。若只写 active 一次,无法区分“新配置本身错误”和“写入过程未完成”。

保存 last_good 也不是无限历史。配置体积小并不意味着可以无限复制;通常保留一份已验证回退即可。Preferences 建议用于轻量数据,不适合堆积大型审计日志。隔离信息只记录 schema 版本、错误路径、长度、摘要和时间,不记录可能包含账号或隐私内容的原始 JSON。

下图是与代码字段一致的 DevEco Studio 风格演示配图。模拟器在右侧显示恢复状态,底部 HiLog 明确写出 issues=2、fallback=v3 与 shadow=cleared。它用于解释调试路径,不是实际 IDE 运行凭证。

五、读取协调器要区分“可迁移”和“必须隔离”

项目启动时,协调器按固定顺序工作:读 active 字符串;解析 JSON;尝试当前 v3;再尝试明确支持的 v1;两者都失败时记录隔离摘要,并尝试 last_good。顺序不能颠倒,否则当前结构可能先被旧 schema 的宽松规则误接收。

这段代码解决什么问题:将读取、识别、迁移、隔离和回退串成一条可观测但不破坏原证据的流程。

export interface RecoveryView {
  state: 'ACTIVE' | 'MIGRATED' | 'RECOVERED' | 'DEFAULTED';
  profile: WorkspaceProfile;
  issuePaths: string[];
}

export async function loadProfile(repo: ProfileRepository,
  activeText: string, lastGoodText: string): Promise<RecoveryView> {
  const activeRaw = safeJson(activeText);
  const current = ProfileV3.safeParse(activeRaw);
  if (current.success) {
    return { state: 'ACTIVE', profile: current.data, issuePaths: [] };
  }

  const old = ProfileV1.safeParse(activeRaw);
  if (old.success) {
    const migrated = migrateV1(old.data);
    await repo.commitMigrated(migrated.profile);
    return { state: 'MIGRATED', profile: migrated.profile, issuePaths: [] };
  }

  const paths = current.error.issues.map(i => i.path.join('.'));
  const fallback = ProfileV3.safeParse(safeJson(lastGoodText));
  if (fallback.success) {
    return { state: 'RECOVERED', profile: fallback.data, issuePaths: paths };
  }
  return { state: 'DEFAULTED', profile: defaultProfile(), issuePaths: paths };
}

safeJson() 必须返回 unknown,不要返回 any。JSON 解析成功只证明语法成立,不证明对象结构正确。默认配置也要经过 ProfileV3 的单元测试或启动自检,避免“兜底本身不符合 schema”这种尴尬问题。

示例为了篇幅直接返回 issuePaths;生产日志还要去重和限长。Zod issue 的 message 可能包含输入细节,不适合原样上传。诊断页面可以显示 profileId 缺失、textScale 越界,但远程日志更适合记录 missing:profileId、too_big:textScale 这类枚举化结果。

六、运行结果先给结论,再允许查看证据

演示主页面在 20:36 显示 CFG-MIG-3013 已恢复:活动配置 428 B、检测问题 2、回退来源 last_good / v3,最终状态为 RECOVERED。恢复后的可用配置是 workspace-main、深色外观、文字比例 1.15、提示开启。

这张图刻意不把红色警示铺满页面。用户需要先知道应用已经使用可验证配置继续运行,而不是被内部字段淹没。技术人员再进入诊断页查看具体 issue、隔离摘要和影子键状态。

如果 last_good 也无效,状态才进入 DEFAULTED。默认启动不代表问题消失,隔离记录仍应保留,直到用户主动重置或下一次成功提交。不要在每次启动时反复把同一个坏 active 覆盖成默认值,那会让错误计数失真,也让复现线索消失。

七、诊断页承担的是解释责任,不是“报错好看”

详情页展示两条 issue:profileId / missing 与 textScale / too_big / 2.75 > 1.60;同时列出 shadow=cleared、last_good=v3 verified、隔离摘要 sha256:7f2a…91c0。红圈只圈住两个结构问题,箭头指向“原始 active 未覆盖”。

这组信息能回答四个关键问题:坏在哪里、使用了什么回退、有没有未完成影子写入、原始证据是否仍可定位。它没有声称自动修复了用户偏好,也没有把摘要当加密。摘要用于一致性辨认,若原始配置包含低熵敏感值,依然要控制日志和诊断页面访问范围。

恢复流程还要处理生命周期。Preferences 的 change 监听在默认 XML 模式下会在 flush() 后触发;若页面订阅它,应保存回调引用并在不再使用时 off('change', callback)。调用 removePreferencesFromCache 或 deletePreferences 会取消订阅,重新获取实例后要重新注册。删除文件是不可恢复动作,不应作为遇到校验失败时的第一反应。

八、迁移测试要覆盖中断点,而不只是输入样例

普通单元测试会写“v1 输入得到 v3 输出”,这只覆盖纯函数。完整验收至少再加以下场景:

  1. active 是合法 v3:不得迁移、不得写 shadow;
  2. active 是合法 v1:写 shadow、flush、回读、切换、删除 shadow;
  3. shadow 写入后进程中断:下次启动不得丢失 active;
  4. active JSON 语法错误:隔离摘要,不执行迁移;
  5. active 结构错误但 last_good 合法:状态为 RECOVERED;
  6. active 与 last_good 都无效:使用经过校验的默认值,状态为 DEFAULTED;
  7. textScale = 2.75:不得自动夹紧后覆盖;
  8. Zod 或配置结构升级:用固定旧样本跑回归,检查 issue 路径是否仍可读。

如果应用存在多进程并发读写,默认 XML Preferences 并不适用。官方指南指出 API 18 起可选择 GSKV,并要求先用 isStorageTypeSupported() 判断平台支持;一旦选定存储模式,不能随意切换。本文 Demo 是单进程、小数据量 XML 场景,不把应用层影子键方案包装成跨进程事务。

九、结语:配置不是“能 parse 的 JSON”,而是一份版本化合同

在把方案带进真实项目之前,还有几组细节值得单独拉出来。它们不属于某一行 API,却决定配置恢复是可靠机制,还是另一个“启动时尽量修一修”的黑盒。

1. unknown 是入口类型,不是写法洁癖

持久化字符串经过 JSON.parse 后,很多代码立即写成 WorkspaceProfile。这样等于在最需要运行时证据的地方跳过了证据。类型断言只影响编译器,不会验证对象是否真的有 profileId,也不会阻止 textScale 成为字符串。

让 safeJson() 返回 unknown,迫使后续代码经过 schema,才能把“语法可解析”和“结构可使用”分开。即使不用 Zod,也应该保持相同边界:外部输入一律 unknown,经校验后才进入业务类型。Preferences 中的数据来自本应用,不代表永远可信;旧版本、调试工具、写入中断和历史 bug 都可能把它变成外部输入。

错误处理也不要把所有失败都 catch 成空对象。JSON 语法错误、schema 版本不支持、字段越界和 shadow 回读失败,是四种不同问题。它们对应的处理分别可能是隔离、提示升级、回退和停止切换。一个 catch { return defaultProfile() } 会把这些分支全部抹平。

2. 版本识别不能只看一个数字

schemaVersion: 1 只是候选线索,不是通行证。恶意或损坏数据完全可以带着数字 1,却缺少 theme,或者把 showHints 写成字符串。正确顺序是用完整 ProfileV1 校验结构,而不是看到版本号就强行执行迁移。

未知更高版本也应明确拒绝。例如应用回滚后读到 v4,旧应用不应该把它当成损坏 v1 修复,更不应该以默认值覆盖。可以进入 UNSUPPORTED_FUTURE_VERSION,保留 active 并提示使用更新版本。这个分支与 INVALID 分开,能避免回滚场景破坏新数据。

历史 schema 也不需要无限保留。团队可以定义支持窗口,比如当前 v3 直接读取、v2 与 v1 可迁移,更旧版本只允许通过某个中间版本升级。删除迁移器之前,要先用产品升级分布证明对应版本已经退出,而不是因为代码“看着旧”就移除。

3. strict、strip 与 passthrough 是产品选择

本文对本地配置使用 .strict(),因为出现未知字段可能说明 schema 识别错误,也可能说明应用回滚。另一些场景会选择 strip,允许服务端增加不影响客户端的字段;插件配置则可能需要 passthrough,保留扩展字段再交给插件。

三种策略没有普遍正确答案。关键是把选择写在 schema 附近,并用测试锁住。若开发者只是因为 strict 报错太多就改成宽松对象,短期内页面能开,长期却会积累无法解释的字段。相反,对本来就允许扩展的协议强制 strict,也会把正常前向兼容变成故障。

ConfigHarbor 的配置由单一应用版本生产和消费,扩展字段没有业务意义,因此未知字段进入隔离更合适。隔离记录只保存字段名,不保存字段值,既能提示“出现未识别配置”,也降低日志带出敏感数据的风险。

4. 影子提交不是平台事务的替代品

shadow → flush → readBack → active → flush 是应用层恢复协议。它能让启动逻辑判断上一次迁移停在哪个阶段,却不能让多个键在系统层成为原子事务。若业务要求严格事务、复杂查询或多表关联,应评估关系型数据库等更合适的存储能力,而不是继续给 Preferences 叠协议。

即使在轻量场景,切换步骤也要有唯一 writer。两个页面同时迁移同一个 profile,可能各自写 shadow 并覆盖。Owner 可以在启动阶段串行执行恢复,完成后再向页面暴露只读快照;设置修改通过单一 repository 排队写入。不要让每个 @Component 都拿 Preferences 实例自行迁移。

shadow 回读成功后,还应比较预期摘要或关键字段。单纯再次 safeParse 只能证明新文本符合 v3,不能证明它就是本次准备提交的对象。可以对规范化 JSON 计算摘要,或逐字段比较 migration result 与 readBack。摘要不是安全签名,但能捕获编码或写入路径上的意外替换。

5. last_good 的含义必须稳定

last_good 不是“上一次 active”,而是“上一次经过当前可接受 schema 验证且完成持久化的 active”。如果把任何旧值在切换前复制过去,损坏 active 也可能污染回退槽。仓库应只在确认 oldActive 合法时更新 last_good,或者在每次成功加载时刷新它。

回退成功后是否立即把 last_good 覆盖回 active,也要谨慎。本文选择返回 RECOVERED 状态并保留坏 active 证据,由后续明确的“应用已验证配置”动作完成新提交。这样诊断页还能显示问题来源。若产品要求无感恢复,也至少应先落隔离摘要和发生次数,再写回。

默认配置和 last_good 的优先级也不同。last_good 是用户历史选择,默认配置是产品兜底;只要 last_good 通过当前 schema,就应优先于默认值。若默认配置随版本变化,不要把变化误当成用户主动选择。

6. 隔离记录要可定位,也要受限

演示隔离摘要 sha256:7f2a…91c0 用于把一次诊断与一份原始文本对应起来。实际实现可以保存完整摘要、字节长度、检测时间、识别到的版本、issue code 与 path、应用版本和迁移规则版本。不要保存完整配置,也不要保存 Zod 的任意 message,因为 message 可能在升级后变化或包含输入描述。

隔离记录还需要数量上限。一次坏数据若每次启动都新增一条,可能形成新的存储膨胀。可以用 digest + appVersion + ruleset 作为去重键,只增加 seenCount 和 lastSeenAt。用户完成重置后,是否删除隔离记录取决于隐私与诊断政策,不能默认永久保留。

诊断页面不应成为公开调试入口。字段路径虽然看似无害,配置名、插件名或账号分区仍可能暴露业务结构。发布版本可以只显示恢复状态和诊断号,详细 issue 留给受控日志或内部构建。

7. 三方库升级也属于迁移风险

Zod 的 schema 语义、错误结构和类型推断会随大版本演进。项目锁定依赖还不够,还要保留输入向量和期望 issue code/path。升级库时先跑这些向量,再看包体、启动耗时与 ArkTS 构建结果。若只确认 ohpm install 成功,真正的差异可能要等坏数据出现才暴露。

对于只需要少量字段判断的配置,引入完整库也不是唯一答案。手写 validator 可能更轻,但要承担错误路径、组合规则和类型同步成本。本文选择 Zod,是因为演示同时需要多版本 schema、结构化 issue 和推断类型;真实项目应根据复杂度、包体和兼容性做取舍,而不是因为库流行就默认引入。

库不可用时也不要降级成无校验强转。更安全的降级是阻止迁移、继续使用 last_good 或默认配置,并记录 VALIDATOR_UNAVAILABLE。配置恢复的底线是“不确定就不覆盖”,不是“没有校验器就猜一下”。

8. 把恢复状态纳入产品体验

配置恢复常被当成内部实现,用户却能感知结果:主题突然改变、字号回到默认、提示重新出现。若发生 RECOVERED,设置页可以以克制方式提示“部分设置已恢复为上次可用版本”,并提供重新检查或重置入口。不要直接显示 schema、Zod 或 JSON 等内部术语。

如果只有非关键展示设置损坏,应用可以继续启动;如果配置包含交易环境、账号分区或安全策略,默认继续可能不合适。schema 本身不能决定业务严重度,协调器还需要把字段按关键等级分类。关键字段无合法回退时应阻断相关功能,而不是整个应用统一“恢复成功”。

演示中的主题、字号和提示属于低风险偏好,所以 last_good 恢复合理。文章没有把这个结论推广到密钥、凭据或合规同意记录;这些数据本就不应该按同一套普通 Preferences 配置处理。

Preferences 解决的是轻量持久化,Zod 解决的是运行时结构判断,迁移函数解决的是已知版本之间的语义映射。三个层次都清楚,应用才有能力面对旧数据和坏数据,而不是靠类型断言把风险推到页面渲染时。

ConfigHarbor 最终保留了一个很朴素的原则:先验证,后迁移;先写影子,后切活动;无法证明安全的数据只隔离,不擅自美化。这样多写的代码并不炫,但它让一次版本升级从“希望用户没脏数据”变成可测试、可回退、可解释的工程过程。

参考资料:

  • 华为 HarmonyOS 用户首选项持久化指南(2026-09-09 更新):https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/data-persistence-by-preferences
  • 华为 HarmonyOS Preferences API 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references/js-apis-data-preferences
  • Zod 官方 Basic usage(safeParse、错误处理与类型推断):https://zod.dev/basics
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐