鸿蒙 PC Markdown 编辑器设置持久化模型:Preferences、类型收敛与失败降级

编辑器设置的价值不在于面板上能点,而在于选择能够跨会话稳定恢复,又不会因为旧值、损坏值或写入失败破坏文档工作流。OhMarkdown 当前持久化自动保存策略、图片资源目录、图片拖放语义、界面语言策略和侧栏宽度。这五类数据形态不同,却共享同一个小型 Preferences 文件和同一套设计原则:稳定键、有限枚举、读取解析、显式 flush、UI 先行与失败可见。

实现位于公开仓库 https://gitcode.com/VON-/codex_md_oh。自动保存与图片设置来自 G3 提交 57aea970a02ce389a5e57,语言设置提交为 ed13ee0,侧栏宽度提交为 358eb3f,当前布局验证基线为 0d8d38b。本文只讨论已经落地的 Preferences,不把搜索选项、主题同步或云配置写成现有持久化能力。

先区分设置、文档与恢复数据

Preferences 适合少量、低频、可独立回退的用户偏好。自动保存策略是一个枚举,图片目录和拖放模式是枚举,语言是策略值,侧栏宽度是有限数字。它们即使丢失,也只恢复默认,不应造成文档内容损坏。

Markdown 正文、撤销历史、未保存恢复记录和保存前备份不属于设置。正文通过文件系统和安全保存事务处理;恢复记录写到应用私有文件并有代际控制;保存前备份保留原内容与编码格式。把这些数据塞入 Preferences 会遇到容量、事务、序列化和恢复一致性问题。

这种分离建立了风险等级:设置写失败可以继续当前会话并提示;文档保存失败必须回滚或保留备份。统一存储 API不代表统一错误语义。

一个文件与五个稳定键

SettingsService.ets 统一使用 ohmarkdown-settings,每项拥有不会随界面翻译变化的 kebab-case 键。键是升级契约,不使用中文标签,也不使用组件位置。

const SETTINGS_FILE_NAME: string = 'ohmarkdown-settings';
const AUTO_SAVE_POLICY_KEY: string = 'auto-save-policy';
const ASSET_DIRECTORY_RULE_KEY: string = 'asset-directory-rule';
const ASSET_DROP_MODE_KEY: string = 'asset-drop-mode';
const APPLICATION_LANGUAGE_KEY: string = 'application-language';
const SIDEBAR_WIDTH_KEY: string = 'sidebar-width';

集中定义避免不同 Builder 写错键,也方便审查已有配置。使用一个文件减少 Preferences 实例数量;五项彼此独立,每次 put 只改变单键。当前没有 schema version,因为所有值都有解析回退,且尚无需要跨键原子迁移的结构。

未来若设置关系变复杂,例如导出模板包含多字段或快捷键映射需要版本化,应单独设计 schema,而不是继续把任意 JSON 字符串堆进同一文件。

枚举值是持久化协议

自动保存使用 offafter-delayon-focus-loss;图片拖放使用 copy、move、reference;语言使用 default、zh-Hans-CN、en-US。界面显示可以是“关闭”“延迟保存”“失焦保存”,但写入值永远不随语言变化。

export enum AutoSavePolicy {
  OFF = 'off',
  AFTER_DELAY = 'after-delay',
  ON_FOCUS_LOSS = 'on-focus-loss'
}

export function parseAutoSavePolicy(value: preferences.ValueType): AutoSavePolicy {
  if (value === AutoSavePolicy.AFTER_DELAY) {
    return AutoSavePolicy.AFTER_DELAY;
  }
  if (value === AutoSavePolicy.ON_FOCUS_LOSS) {
    return AutoSavePolicy.ON_FOCUS_LOSS;
  }
  return AutoSavePolicy.OFF;
}

解析采用白名单而不是类型断言。未知字符串、数字或旧版本值都回到 OFF。自动保存默认关闭是保守选择:设置损坏时不应突然开始写用户文件。图片拖放默认 COPY,同样比 MOVE 更安全;语言默认跟随系统,侧栏默认 264 vp。

枚举一旦写入用户配置就成为兼容协议。重命名界面文字无影响,重命名枚举值则需要迁移。开发者不能把内部变量名变化直接传播到存储值。

不同设置有不同的安全默认值

parseAssetDirectoryRule 只在值明确等于 SHARED_ASSETS 时选择共享目录,否则回到文档专属 assets。这样未知值不会让图片跨文档共享。parseAssetDropMode 只接受 MOVE 和 REFERENCE,其他回到 COPY,避免意外删除源文件或引用越界。

export function parseAssetDropMode(value: preferences.ValueType): AssetDropMode {
  if (value === AssetDropMode.MOVE) {
    return AssetDropMode.MOVE;
  }
  if (value === AssetDropMode.REFERENCE) {
    return AssetDropMode.REFERENCE;
  }
  return AssetDropMode.COPY;
}

默认值不是统一的“第一个选项”,而是按失败后果选择。语言未知值回跟随系统,不锁死在某个错误地区;侧栏非法值回默认,不使用 0;自动保存未知值回关闭;资源目录回文档隔离;拖放回复制。这体现了“设置失败不能扩大破坏面”。

解析函数纯粹、同步,便于 UnitTestBuild 覆盖。I/O 函数只负责 get/put,领域安全由解析层承担,职责清晰。

数字设置必须拒绝 NaN 与 Infinity

侧栏宽度是唯一数字设置。只判断 typeof value === 'number' 不够,因为 NaN 和 Infinity 也属于 number,会污染 ArkUI 布局。解析先用 Number.isFinite,再钳制 220 至 480。

export const DEFAULT_SIDEBAR_WIDTH: number = 264;
export const MIN_SIDEBAR_WIDTH: number = 220;
export const MAX_SIDEBAR_WIDTH: number = 480;

export function parseSidebarWidth(value: preferences.ValueType): number {
  if (typeof value !== 'number' || !Number.isFinite(value)) {
    return DEFAULT_SIDEBAR_WIDTH;
  }
  return Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, value));
}

存储层范围还不是最终有效宽度。WorkspaceShell 加载后按当前窗口预算再次 clamp,保证编辑器至少保留 520 vp。静态解析保护数据,动态 clamp 保护当前布局,两者不能互相替代。

写入也调用 parseSidebarWidth。即使未来某个调用点忘记 clamp,持久层仍不会保存越界值。双重验证对边界设置是合理冗余。

语言保存的是策略而非解析结果

语言是最容易错误持久化的设置。用户选择“跟随系统”时,当前界面可能解析为中文,但 Preferences 必须写 default。若写 zh-Hans-CN,重启后无法区分跟随与固定中文,系统改成英文也不会响应。

export async function loadApplicationLanguage(
  context: Context
): Promise<ApplicationLanguage> {
  const settings = await preferences.getPreferences(context, SETTINGS_FILE_NAME);
  const value = await settings.get(
    APPLICATION_LANGUAGE_KEY,
    ApplicationLanguage.SYSTEM
  );
  return parseApplicationLanguage(String(value));
}

解析兼容 zh...en...,未知值回 SYSTEM。设置面板选中态绑定持久策略 applicationLanguage,当前资源语言绑定 Ability 提供的 language。这两个状态分离,才可能在中文系统下正确显示“跟随系统 selected=true”。

模拟器选择跟随系统后强制停止并重启,辅助功能树仍返回该项选中;这是 Preferences 语义正确的直接证据。

每次写入都显式 flush

保存函数模式一致:获取同一 Preferences,put 单键,然后 flush。flush 的意义是在方法 resolve 前请求持久落盘,而不是只更新内存缓存。用户可能切换设置后立刻关闭或强制停止应用,语言与侧栏测试正是这样验证。

export async function saveSidebarWidth(context: Context, width: number): Promise<void> {
  const settings = await preferences.getPreferences(context, SETTINGS_FILE_NAME);
  await settings.put(SIDEBAR_WIDTH_KEY, parseSidebarWidth(width));
  await settings.flush();
}

写入不是跨五个键的事务。每次用户动作只改一个设置,单键 flush 足够。若未来提供“应用/取消”式设置对话框,需要考虑一次提交多项、失败回滚和 schema 版本,不能继续假设独立即时保存。

拖动侧栏只在 gesture end flush,避免每帧 I/O;方向键每次 16 vp 是独立完成动作,立即 flush。自动保存、资源模式和语言点击频率低,直接保存即可。

一次性异步初始化

WorkspaceShell 在编辑器 Ready 后调用 initializeDocumentReliability,用 settingsInitialized 防止重复加载。五项并行式发起各自 Promise,成功更新对应 @State,失败设置各自默认。

if (!this.settingsInitialized) {
  this.settingsInitialized = true;
  const context = this.getHostContext();
  if (context) {
    loadApplicationLanguage(context).then((language) => {
      this.applicationLanguage = language;
    }).catch(() => {
      this.applicationLanguage = ApplicationLanguage.SYSTEM;
    });

    loadSidebarWidth(context).then((width) => {
      this.sidebarWidth = this.clampSidebarWidth(width);
    }).catch(() => {
      this.sidebarWidth = this.clampSidebarWidth(DEFAULT_SIDEBAR_WIDTH);
    });
  }
}

单项失败不阻止其他设置恢复,也不阻止文档可靠性定时器启动。语言策略读失败不会关闭自动保存读取;侧栏失败不会影响图片模式。这样的故障隔离比 Promise.all 一处 reject 后全部回默认更稳健。

初始状态在代码中已经是安全默认,所以异步读取期间界面可用。读取完成可能产生一次从 264 到保存宽度的布局调整;当前设备可接受。若未来追求完全无跳动,可在首屏前读取,但会增加启动阻塞,需要测量权衡。

UI 先更新,持久化失败单独报告

设置点击通常先更新内存状态,让界面即时反馈,再异步保存。保存失败不强制回滚视觉选择,因为平台资源或拖放策略可能已经作用于当前会话;回滚还可能触发第二次失败。状态栏会说明无法保存,重启后可能恢复旧值。

语言更新先调用平台 API,再修改选中态和 Web;Preferences 失败显示“无法保存界面语言设置”。侧栏拖动结束后保存失败显示“无法保存侧栏宽度”。图片模式保存成功时显示下一次拖放语义,失败显示 Unable to save。

private persistSidebarWidth(): void {
  const context = this.getHostContext();
  if (!context) return;
  saveSidebarWidth(context, this.sidebarWidth).catch(() => {
    this.operationStatus = resolveEditorLanguage(this.language) === 'zh-CN' ?
      '无法保存侧栏宽度' : 'Unable to save sidebar width';
  });
}

这是一致性中的“会话优先”模型:当前状态立即生效,持久状态尽力跟上并对失败可见。文档保存不能采用同样轻量策略,因为内容落盘失败必须有备份与恢复;设置风险较低,模型可以更简单。

自动保存策略影响文件时序

虽然设置值很小,自动保存策略的作用很大。恢复 AFTER_DELAY 后需要立即根据当前 dirty、URI、冲突和 operation 状态决定是否安排定时器;不能只更新按钮。初始化成功回调因此调用 scheduleDelayedAutoSave()

策略更新时先取消旧定时器,再为 AFTER_DELAY 安排新任务;ON_FOCUS_LOSS 由失焦路径触发;OFF 不产生自动写。保存策略值本身不代表自动保存一定执行,文档状态机仍检查未命名文档、外部冲突、Mixed EOL 和进行中的操作。

Preferences 只提供“用户希望什么”,文件可靠性层决定“当前是否安全执行”。这种边界防止一个设置值绕过冲突保护。设置文章必须说明其业务效应,而不能只展示 get/put。

图片设置必须服从安全边界

图片目录规则选择文档专属 .assets 或共享 assets,拖放模式选择复制、移动、仅引用。Preferences 恢复这些选择,但 AssetService 仍验证路径、MIME、大小、符号链接和授权目录。

即使设置为 REFERENCE,拖入外部图片也不会绕过边界,服务会拒绝“不在受管理资源目录”。MOVE 先复制成功,再删除源;删除失败会删除目标回滚。设置只是请求语义,不是安全授权。

这说明持久化枚举不能被服务层盲信。用户选择和系统事实是两回事:选择可以跨重启,文件路径每次都必须重新验证。默认 COPY 进一步降低未知设置导致源文件删除的风险。

真实应用截图与持久化证据

下面截图来自 MateBook Pro 2in1 模拟器,设置面板同时展示界面语言、自动保存、图片资源目录和拖放模式。它证明多个设置在同一原生侧栏中有清晰互斥状态,而不是散落在不同页面。

在这里插入图片描述

语言选择 English 后强制停止并重启,英文仍选中;最终切回跟随系统并重启,辅助功能树返回 selected=true。侧栏拖到 392 vp 后强制停止并重启,辅助功能 description 再次为 392 vp。两条设备路径分别覆盖枚举策略和有限数字。

自动保存已有“延迟保存”设置与完成状态截图,图片拖放已有复制、移动、仅引用设备证据。不同设置的业务验证分散在对应功能测试中,不能用一个设置面板截图替代文件行为验证。

单元测试与构建门禁

ArkTS UnitTestBuild 覆盖自动保存策略解析、语言解析、语言标签映射、侧栏合法值、非法回退和上下限。图片模式的服务测试验证导入语义。Playwright 30/30 保护 Web 功能,Debug HAP 与 ohosTest HAP 构建通过,MateBook Pro 2in1 模拟器 ohosTest 7/7

最终 Debug HAP 为 1,520,352 字节,SHA-256 367ab8650479aa1fa8fe73bd1ebadd9a53f46659c850c2e388fc799d5cb88e5b;ohosTest HAP 为 2,360,824 字节,SHA-256 b7230037b51044fe16168d2c835fb891e1c70f675941a1046165bc895217592c。两份未签名,仅用于测试追踪。

Preferences 测试需要分层:纯解析不依赖设备,I/O 与强制停止恢复需要模拟器,业务效应由自动保存、图片、语言、布局各自用例覆盖。只测 put 成功不能证明设置真的被消费。

并发、时序与最后写入

当前设置操作频率低,每个键由单一 UI 控件写入,不存在复杂并发编辑。侧栏拖动只在结束写,语言按钮可能快速连续点击,多个异步 flush 的完成顺序理论上可能与点击顺序不同。Preferences put 在调用时更新同一实例,通常最后调用值生效,但当前没有显式写入序号。

如果用户极快地中英来回点击并立即杀进程,极端时序值得专门压力测试。后续可以为设置保存建立串行队列或每键 generation,保证最后选择最终 flush。当前真实设备正常交互和重启路径已通过,但文章不把未测并发写入宣称完全解决。

初始化读取也可能与用户早期点击竞争。工作台在 Editor Ready 后很快读取,实际窗口很短;更严谨架构可在设置加载完成前禁用对应控件,或用 generation 防止旧读取覆盖新点击。当前没有观察到该问题,仍是明确演进点。

隐私、安全与同步边界

Preferences 不保存文档内容、最近搜索词、文件路径列表或用户账户。当前设置都在应用本地,不上传、不跨设备同步,也没有新增网络权限。语言 default 只表示跟随系统,不记录系统语言历史。

键和值由应用控制,读取后仍白名单解析。它们不能直接进入 JavaScript 代码、文件路径或 shell;语言先映射再 JSON 序列化给 Web,侧栏只作为有限数字,图片枚举仍受 AssetService 验证。

未来云同步需要逐项产品决策。侧栏宽度在不同屏幕上可能不适合同步;跟随系统必须保留策略;图片 MOVE 是操作偏好但可能带来跨设备风险。不能因为“设置都在一个文件”就默认全部同步。

已知限制与后续架构

当前没有设置 schema version、迁移日志、统一保存队列、批量应用/取消、导入导出或恢复默认按钮。五项简单设置依靠解析回退已经足够;随着快捷键、字体、主题和导出模板增加,应在结构复杂前引入版本与分类。

错误状态目前通过状态栏字符串展示,没有持久诊断页面。设置写失败后不自动重试。搜索选项故意不持久化,主题仍跟随系统,不应在文档中误列为已保存。

新增设置时至少要回答:安全默认是什么、旧值如何解析、是否需要立即 flush、应用时机、写失败是否回滚、是否影响文件、是否值得跨设备同步、怎样做强制停止验证。只有字段和 UI 不足以完成设置功能。

结论

OhMarkdown 的设置层用一个 Preferences 文件承载五类有限偏好,以稳定键和领域枚举隔离界面翻译,用解析函数处理未知值,用安全默认降低后果,用显式 flush 支持强制停止恢复,再由各业务服务保留自己的安全判断。语言和 392 vp 侧栏都已在真实模拟器重启后恢复。

这种模型保持了适合当前阶段的克制:不引入数据库,不把文档塞进设置,不让配置绕过文件安全,也不把写入失败伪装成成功。对鸿蒙 PC Markdown 编辑器而言,可预测的本地偏好是长期产品体验的基础,而它的可靠性来自边界和降级,不只是 Preferences API 本身。

Logo

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

更多推荐