第39篇|设置开关退出就复原:用偏好写队列和回滚状态兜住
摘要:设置页开关最容易被写成 @State 一改就完事。实际项目里,用户快速连点、离开页面、写入失败、重启应用都会暴露问题:UI 显示已开启,持久化里还是旧值。更稳的做法是让设置写入走串行队列,页面可以乐观展示,但失败必须回滚,并用重启验证证明真的保存成功。
我处理过一个提醒开关问题:用户打开"每日提醒",页面显示已开启,退出再进又变回关闭。日志里没有崩溃,因为 UI 状态确实变了,只是 Preferences 写入被后一次旧值覆盖。更麻烦的是用户快速点三次,最后界面和持久化各拿一个值。这个问题不能靠"加个 await"随便修,要把写入路径收成一个队列。

这篇文章解决四个实际问题:
- 区分页面乐观状态和已落盘状态。
- 用 SettingWriteQueue 串行处理同一个 key 的写入。
- 写入失败时回滚 UI,并给用户可理解提示。
- 用退出重进和杀进程重启验证持久化真实生效。


先确认复原发生在哪个时刻
设置复原可能发生在返回页面时,也可能是杀进程重启后。两个现象对应的根因不同,不能只看开关 UI。
| 位置 | 现象 | 风险 | 处理方向 |
|---|---|---|---|
| 返回页面复原 | 页面重新读取旧缓存 | 写入还没完成 | 等待写入确认或展示保存中 |
| 重启后复原 | 持久化失败 | 只改了内存状态 | 统一写入 Preferences |
| 快速连点错乱 | 并发写同一个 key | 后完成的旧值覆盖新值 | 串行队列 |
| 写入失败无提示 | catch 后吞错 | 用户以为保存成功 | 失败回滚 |
这一步的价值是先把问题放回运行链路。只要能确认问题停在哪个位置,后面就不用靠猜测改页面。
页面只能发意图,不能直接散写偏好
设置项看起来简单,但长期维护时会有默认值、迁移、校验和失败回滚。页面直接写 Preferences 会让这些逻辑散得到处都是。
| 模块 | 应该负责 | 不应该负责 |
|---|---|---|
| SettingsPage | 展示值、保存中、错误文案 | Preferences API |
| SettingsService | 设置项校验和保存流程 | 具体 UI 布局 |
| SettingWriteQueue | 同 key 串行写入 | 业务默认值 |
| PreferenceStore | 读写持久化 | 连点策略 |
| Migration | 旧 key 到新 key | 页面事件 |
边界定清后,代码就不会在页面、服务和回调之间来回复制同一段判断。后续新增场景也能先判断应该落在哪一层。
设置状态要有 pending 标记
ts
export interface SettingItemState {
key: string
value: boolean
persistedValue: boolean
pending: boolean
errorText: string
}
export function createSettingState(key: string, value: boolean): SettingItemState {
return {
key,
value,
persistedValue: value,
pending: false,
errorText: ''
}
}
value 是页面当前展示,persistedValue 是最后一次确认落盘的值。失败时页面能回滚到真实持久化状态,而不是随便猜一个默认值。
这类模型最好放在 models 或 common 中,页面、Service 和仓储都使用同一份类型,避免各层用字符串互相猜。
写队列保证同一个 key 顺序执行
ts
export class SettingWriteQueue {
private jobs: Map<string, Promise<void>> = new Map()
enqueue(key: string, task: () => Promise<void>): Promise<void> {
const previous = this.jobs.get(key) ?? Promise.resolve()
const next = previous.then(task).finally(() => {
if (this.jobs.get(key) === next) {
this.jobs.delete(key)
}
})
this.jobs.set(key, next)
return next
}
}
队列按 key 串行,不会让两个提醒开关互相阻塞,但能保证同一个 key 的后一次操作不会被更早的写入覆盖。
Service 的目标不是把所有逻辑都塞进去,而是把跨页面、跨生命周期或需要持久化的判断集中起来。页面只表达用户动作。
页面先乐观更新,再等确认
ts
@Component
struct ReminderSettingRow {
@State item: SettingItemState = createSettingState('daily_reminder', false)
private service: SettingsService = createSettingsService()
async toggleReminder(nextValue: boolean): Promise<void> {
const before = this.item.persistedValue
this.item = { ...this.item, value: nextValue, pending: true, errorText: '' }
const saved = await this.service.saveBoolean(this.item.key, nextValue)
if (!saved) {
this.item = { ...this.item, value: before, pending: false, errorText: '保存失败,请重试' }
return
}
this.item = { ...this.item, persistedValue: nextValue, pending: false, errorText: '' }
}
}
乐观更新能让交互及时,但 pending 和回滚不能省。否则用户看到的是"已开启",持久化里却可能还是关闭。
页面层要控制展示和交互节奏,但不应该拥有底层事实。这样页面重建、横竖屏变化或返回前台时,都能重新从 Service 拿到可信结果。
SettingsService 统一读写默认值
ts
export class SettingsService {
constructor(
private readonly store: PreferenceStore,
private readonly queue: SettingWriteQueue
) {}
async loadBoolean(key: string, defaultValue: boolean): Promise<boolean> {
const value = await this.store.getBoolean(key)
return value ?? defaultValue
}
async saveBoolean(key: string, value: boolean): Promise<boolean> {
return this.queue.enqueue(key, async () => {
await this.store.putBoolean(key, value)
await this.store.flush()
}).then(() => true).catch(() => false)
}
}
读默认值和写入确认都在 Service 层。页面不用知道是否需要 flush,也不会因为多个页面同时写同一个 key 出现竞争。
实际项目里,很多问题不是第一次进入页面暴露,而是在冷启动、返回前台、页面重建或回调晚到时暴露。把这段生命周期补上,修复才算闭环。
失败路径要回滚而不是假装成功
| 情况 | 页面表现 | 工程处理 |
|---|---|---|
| 写入失败 | 回滚到 persistedValue | 展示保存失败 |
| 用户快速连点 | 同 key 排队 | 最后一次结果一致 |
| 读取旧 key | 迁移后删除旧 key | 避免重启读回旧值 |
| 页面离开时 pending | 保留保存中提示或禁用返回 | 不要直接丢状态 |
兜底路径不是可有可无。用户遇到异常时,页面至少要给出当前状态、可执行动作和可排查线索。
排查时找散写偏好
powershell
rg -n "putBoolean|getBoolean|Preferences|flush|daily_reminder" entry features common
rg -n "@State.*reminder|persistedValue|pending|SettingWriteQueue" entry features common
rg -n "onClick|onChange|Toggle|Switch" entry features common
第一组命令找旧路径,第二组命令找新边界,第三组命令看生命周期或回调位置。命中后不要只看字段名,要判断它是否仍在直接改页面状态。
验证清单
| 验证项 | 预期结果 |
|---|---|
| 打开开关后返回再进 | 仍保持新值 |
| 杀进程重启 | 仍读取到新值 |
| 快速连点三次 | 最终 UI 和持久化一致 |
| 模拟写入失败 | UI 回滚并显示错误 |
| 旧版本迁移 | 旧 key 不再覆盖新 key |
这些验证最好在真机或模拟器里按顺序走一遍。只看源码很难发现冷启动、重启和回调晚到这类问题。
常见问题和处理方式
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 退出就复原 | 只改 @State | 必须走 SettingsService |
| 偶现反向 | 并发写入乱序 | 同 key 串行队列 |
| 失败无感 | catch 后吞错 | 回滚并提示 |
| 重启读旧值 | 默认值和旧 key 散落 | 集中迁移和读取 |
如果现象没有出现在表里,仍然建议按"输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底"的顺序排查。
小结:设置项要把显示和落盘分清
开关状态不是点一下就结束。页面可以先展示用户选择,但服务层必须串行写入、确认落盘、失败回滚,最后用重启验证。这样设置页才不会出现看似保存成功、实际重进复原的问题。
| 默认值和旧 key 散落 | 集中迁移和读取 |
如果现象没有出现在表里,仍然建议按"输入来源 -> 中间状态 -> 持久化或页面展示 -> 失败兜底"的顺序排查。
小结:设置项要把显示和落盘分清
开关状态不是点一下就结束。页面可以先展示用户选择,但服务层必须串行写入、确认落盘、失败回滚,最后用重启验证。这样设置页才不会出现看似保存成功、实际重进复原的问题。