【句匠|15】HarmonyOS ArkTS 本地状态持久化实战:让保存、删除和页面返回后的数据即时一致

本地学习类应用最难处理的不是“把一条数据写进 Preferences”,而是写完之后所有页面立刻一致。用户在答题页收藏一道题,返回首页后收藏数要变化;答错后错题本角标要立即增加;在收藏页清空错题,主页和“我的”页不能继续显示旧数量;修改考试时长后,下一次进入考试页必须读到新值;应用重启后,这些状态还要从本地恢复。
本文基于句匠项目的真实 HarmonyOS ArkTS 源码展开。核心文件是 librarya/src/main/ets/utils/UserDataManager.ets,并用 EntryAbility.ets、PracticePage.ets、FavoritePage.ets、SettingsPage.ets、ExamResultPage.ets、HomePage.ets 和 Index.ets 核对初始化、保存、删除、页面返回与 UI 派生过程。
本文唯一源码标识:com.jiaweikang.one18。
源码可证明的能力包括:使用 @kit.ArkData 的 Preferences 同步保存轻量 JSON 数据;启动时恢复收藏、笔记、错题、题库进度、章节进度、考试记录和三项学习设置;用 AppStorage 与 @StorageLink 在页面之间共享当前内存状态;新增、更新、删除、清空操作都返回新数组并由页面重新赋值。项目没有账号、云同步、跨设备数据库、备份导出、加密存储或服务端冲突合并,本文不会把本地一致性描述成云端能力。
一、先定义“一致”:磁盘、内存和页面三层同时成立
很多代码只完成了其中一层。例如调用 putSync() 后磁盘数据变了,但页面仍持有旧数组;或者页面先把数组改了,UI 看起来正确,却忘记 flushSync(),应用重启后数据消失。
句匠的真实链路分成三层:
- Preferences 保存可跨启动恢复的数据;
AppStorage保存当前应用进程内共享状态;- 各页面通过
@StorageLink读取和更新同一个键。

| 一致性层 | 句匠实现 | 验证方式 |
|---|---|---|
| 持久层 | preferences.Preferences | 重启应用后数据仍存在 |
| 应用内存层 | AppStorage | 不同页面读取相同键 |
| 页面响应层 | @StorageLink | 赋新数组后列表、角标、统计立即更新 |
一次收藏操作的完整路径是:
用户点击收藏
→ PracticePage 调用 UserDataManager.toggleFavorite()
→ 服务计算新数组
→ Preferences putSync + flushSync
→ 服务返回新数组
→ 页面赋给 @StorageLink
→ HomePage / MinePage / FavoritePage 读取到新状态
这条链路里,“返回新数组并重新赋值”与“写入 Preferences”同样重要。前者负责当前界面及时刷新,后者负责下次启动恢复。
二、数据模型先明确业务主键
句匠没有把所有学习数据塞进一个不透明对象,而是定义了不同记录类型:
export interface FavoriteRecord {
questionId: string
bankId: string
createdAt: string
}
export interface NoteRecord {
questionId: string
bankId: string
content: string
updatedAt: string
}
export interface WrongRecord {
questionId: string
bankId: string
wrongAt: string
}
收藏、笔记和错题都以 questionId 作为主要定位字段,同时保留 bankId,让页面可以回到题库上下文。时间字段用于列表展示和最近操作排序。
学习进度使用题库维度:
export interface BankProgress {
bankId: string
finished: number
correct: number
lastChapterId: string
updatedAt: string
}
export interface ChapterProgress {
bankId: string
chapterId: string
finished: number
correct: number
}
考试记录则保留一次结果的完整统计:
export interface ExamHistory {
bankId: string
score: number
total: number
correct: number
durationSec: number
finishedAt: string
}
这些模型决定了更新策略。收藏和错题不允许同一题重复堆积,因此写入前先过滤或查找;题库进度是累计更新;考试历史则按每次考试新增一条。
当前记录都存成 JSON 字符串,没有 Schema 版本字段。对于现有轻量本地数据可以工作;若未来字段变更或数据规模扩大,应增加版本与迁移逻辑,不能假设旧版本 JSON 永远能直接断言成新接口。
三、EntryAbility 在首屏加载前恢复全部共享状态
句匠在 EntryAbility.onCreate() 中调用:
UserDataManager.init(this.context)
初始化发生在 windowStage.loadContent('pages/SplashPage') 之前。这样主页和后续页面第一次构建时,AppStorage 已经有本地数据或安全默认值。
UserDataManager.init() 先取得名为 dialect_quiz 的 Preferences:
UserDataManager.prefs = preferences.getPreferencesSync(
context,
{ name: UserDataManager.STORE_NAME }
)
然后按键读取字符串并解析:
const favStr = UserDataManager.prefs
.getSync(UserDataManager.K_FAV, '[]') as string
const noteStr = UserDataManager.prefs
.getSync(UserDataManager.K_NOTES, '[]') as string
const wrongStr = UserDataManager.prefs
.getSync(UserDataManager.K_WRONG, '[]') as string
AppStorage.setOrCreate<FavoriteRecord[]>(
'favoriteRecords',
JSON.parse(favStr) as FavoriteRecord[]
)
AppStorage.setOrCreate<NoteRecord[]>(
'noteRecords',
JSON.parse(noteStr) as NoteRecord[]
)
AppStorage.setOrCreate<WrongRecord[]>(
'wrongRecords',
JSON.parse(wrongStr) as WrongRecord[]
)
设置项也按对应类型恢复:
AppStorage.setOrCreate<string>(
'dailyReminderTime',
JSON.parse(reminderStr) as string
)
AppStorage.setOrCreate<number>(
'examDurationSec',
JSON.parse(durationStr) as number
)
AppStorage.setOrCreate<boolean>(
'autoNextQuestion',
JSON.parse(autoNextStr) as boolean
)
这一阶段使用 setOrCreate:键不存在时创建,已有键时不强制覆盖。冷启动时键通常尚未建立,本地数据会成为初值;同一进程中若初始化被意外重复调用,已有共享状态不会被无条件覆盖。
四、解析失败时用完整默认状态保证可启动
初始化代码把读取和解析放进一个 try/catch。任何异常发生后,会建立完整默认值:
} catch (_) {
AppStorage.setOrCreate<FavoriteRecord[]>('favoriteRecords', [])
AppStorage.setOrCreate<NoteRecord[]>('noteRecords', [])
AppStorage.setOrCreate<WrongRecord[]>('wrongRecords', [])
AppStorage.setOrCreate<BankProgress[]>('bankProgress', [])
AppStorage.setOrCreate<ExamHistory[]>('examHistory', [])
AppStorage.setOrCreate<ChapterProgress[]>('chapterProgress', [])
AppStorage.setOrCreate<string>('dailyReminderTime', '09:00')
AppStorage.setOrCreate<number>('examDurationSec', 1800)
AppStorage.setOrCreate<boolean>('autoNextQuestion', false)
}
这避免一段损坏 JSON 直接阻断 Ability 启动。所有消费页面都能拿到确定类型:数组至少为空,提醒时间至少为 09:00,考试时长至少为 1800 秒,自动下一题至少为关闭。
不过当前实现有一个真实边界:所有键放在同一个 try 中。某一个键解析失败后,catch 会尝试为全部键设置默认值;由于 setOrCreate 不覆盖已经创建的键,异常发生前已成功写入 AppStorage 的值可能保留,异常发生后的键使用默认值。结果可用,但不是“每个键独立恢复”。
如果需要更精细的容错,可以为每类数据写独立解析函数,逐键校验和降级。当前源码没有这层封装,文章只把它作为演进建议。
五、persist 是所有写入操作的唯一磁盘出口
UserDataManager 把 Preferences 写入集中到私有方法:
private static persist(
key: string,
value: Object | string | number | boolean
): void {
if (UserDataManager.prefs === null) return
try {
UserDataManager.prefs.putSync(
key,
JSON.stringify(value)
)
UserDataManager.prefs.flushSync()
} catch (_) {}
}
每个业务方法只负责计算新值,再调用 persist。这样存储名、序列化方式、putSync 与 flushSync 不会散落到 UI 页面。

这里有三个工程含义:
putSync把值写入 Preferences 对象;flushSync立即刷盘,使应用重启后可恢复;- 所有值通过
JSON.stringify统一为字符串,与初始化时JSON.parse对称。
同时也要如实指出当前风险。prefs === null 时方法直接返回,写入异常也被空 catch 吞掉。页面仍会拿到新数组并更新 UI,因此可能出现“当前页面显示已保存,但磁盘没有成功写入”的情况。源码没有错误返回值、日志或用户提示。
更稳的接口可以返回 boolean 或结果对象,让页面区分“内存已更新”和“持久化已确认”。但在本文对应的真实实现中,写入失败是静默的,不能宣传为强事务或可靠落盘。
六、收藏切换:去重、写盘、返回新数组
收藏是最典型的即时一致操作:
static toggleFavorite(
records: FavoriteRecord[],
questionId: string,
bankId: string
): FavoriteRecord[] {
const idx = records.findIndex(
record => record.questionId === questionId
)
let result: FavoriteRecord[]
if (idx >= 0) {
const next = [...records]
next.splice(idx, 1)
result = next
} else {
result = [{
questionId,
bankId,
createdAt: nowStr()
}, ...records]
}
UserDataManager.persist(UserDataManager.K_FAV, result)
return result
}
已有记录时先复制数组再删除,不直接修改调用方传入的原数组;新增时把新收藏放在数组头部。无论新增还是取消,都得到新的数组引用。
答题页点击收藏后必须接住返回值:
this.favRecords = UserDataManager.toggleFavorite(
this.favRecords,
q.id,
q.bankId
)
favRecords 是:
@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []
重新赋值会把新数组写回共享状态。收藏页、首页和“我的”页同样链接 favoriteRecords,因此切换页面或返回后都读取到同一份进程内状态,不需要重新从 Preferences 查询。
如果页面只调用 toggleFavorite() 却忽略返回值,磁盘可能已经变化,但当前 @StorageLink 仍是旧数组,UI 不会及时一致。这就是服务返回新值而页面必须重新赋值的原因。
七、笔记的空字符串就是删除语义
笔记接口同时处理新增、更新和删除:
static upsertNote(
records: NoteRecord[],
questionId: string,
bankId: string,
content: string
): NoteRecord[] {
const filtered = records.filter(
record => record.questionId !== questionId
)
let result: NoteRecord[]
if (content.trim().length === 0) {
result = filtered
} else {
result = [{
questionId,
bankId,
content: content.trim(),
updatedAt: nowStr()
}, ...filtered]
}
UserDataManager.persist(UserDataManager.K_NOTES, result)
return result
}
先过滤旧记录,再根据内容决定是否插入新记录。这样同一题只保留一条最新笔记,不会因为每次编辑都追加而产生重复。
答题页保存弹窗时:
this.noteRecords = UserDataManager.upsertNote(
this.noteRecords,
q.id,
q.bankId,
this.noteText
)
this.showNoteDialog = false
收藏页编辑笔记也调用同一个方法:
this.noteRecords = UserDataManager.upsertNote(
this.noteRecords,
this.editingQuestionId,
this.editingBankId,
this.editingNoteText
)
因此两个入口遵循相同规则:保存空白内容相当于删除,非空内容会 trim() 后置顶。页面返回后不必重新加载,因为两个入口都把新数组赋回相同的 noteRecords 键。
这一设计适合“每题一条纯文本笔记”。源码没有多笔记、富文本、附件、撤销历史或冲突合并,不能把 upsert 的名称扩展解释成数据库级更新能力。
八、错题状态由答题结果自动驱动
用户选项提交后,答题页根据正确性更新错题集合:
const correct = key === q.answer
this.records.push({
questionId: q.id,
selected: key,
correct
})
if (!correct) {
this.wrongRecords = UserDataManager.addWrong(
this.wrongRecords,
q.id,
q.bankId
)
} else if (this.mode === 'wrong') {
this.wrongRecords = UserDataManager.removeWrong(
this.wrongRecords,
q.id
)
}
addWrong() 会先删除同题旧记录,再把最新错误放到头部:
static addWrong(
records: WrongRecord[],
questionId: string,
bankId: string
): WrongRecord[] {
const filtered = records.filter(
record => record.questionId !== questionId
)
const result: WrongRecord[] = [{
questionId,
bankId,
wrongAt: nowStr()
}, ...filtered]
UserDataManager.persist(UserDataManager.K_WRONG, result)
return result
}
在错题练习模式中答对后,removeWrong() 用过滤结果替换原数组。这样“答错加入、纠正后移除”的业务规则和持久化操作处于同一服务。
Index.ets 通过:
@StorageLink('wrongRecords')
wrongRecords: WrongRecord[] = []
直接用 wrongRecords.length 绘制主导航徽标。首页的错题卡片、“我的”页统计和收藏页列表也读取同一键。因此答题页修改后,角标和统计可随共享状态更新,不需要维护额外的 wrongCount。
用数组长度派生计数比单独保存一个计数更安全,因为不会出现删除记录后忘记递减计数的双写问题。
九、学习进度按业务键做累加,而不是覆盖全部统计
完成一轮练习后,答题页计算本轮已答数和正确数,再更新题库进度:
const correctCount =
this.records.filter(record => record.correct).length
this.progressList = UserDataManager.updateProgress(
this.progressList,
this.bankId,
this.records.length,
correctCount,
this.chapterId
)
服务先按 bankId 查找已有记录:
const idx = records.findIndex(
record => record.bankId === bankId
)
找到后复制数组并替换目标项:
const updated: BankProgress = {
bankId,
finished: old.finished + addFinished,
correct: old.correct + addCorrect,
lastChapterId: chapterId,
updatedAt: nowStr()
}
next = [...records]
next[idx] = updated
没有记录时才创建新项。章节进度则使用 bankId + chapterId 作为联合定位条件。
这种写法避免直接修改 old.finished,保持新对象和新数组引用。首页的统计服务、题库卡片进度条、学习统计页和排行榜页都链接 bankProgress,完成练习返回后可直接显示新的已答数和正确率。
当前更新是同步的进程内读改写,没有并发事务或跨进程锁。项目本身是本地单进程交互流程,源码没有证明它能处理多个设备或多个写入者同时合并进度。
十、考试历史在结果页追加,但要防重复进入
考试结果页出现时会保存一次记录:
aboutToAppear(): void {
const params =
router.getParams() as ExamResultParams | undefined
if (params) {
this.bankId = params.bankId
this.score = params.score
this.total = params.total
this.correct = params.correct
this.durationSec = params.durationSec
}
this.examHistory = UserDataManager.addExamHistory(
this.examHistory,
this.bankId,
this.score,
this.total,
this.correct,
this.durationSec
)
}
addExamHistory() 每次都会在数组头部新增:
const result: ExamHistory[] = [{
bankId,
score,
total,
correct,
durationSec,
finishedAt: nowStr()
}, ...records]
正常流程中,答题页通过 replaceUrl 进入结果页,一次考试对应一次结果保存。首页和考试 Tab 链接 examHistory,返回后可以立即看到考试次数变化。
但这里存在一个可复核的重复风险:保存动作在 aboutToAppear() 中,没有考试记录 ID 或“已保存”标记。如果同一个结果页实例因为生命周期再次出现,或入口被重复触发,可能追加重复历史。文章不能忽略这个边界。
更稳的做法是为考试生成唯一 ID,保存前按 ID 去重;或者在结果页增加当前实例的 @State saved 防重,并确保参数无效时不写入空记录。当前源码尚未实现这些措施。
十一、设置项要同时更新 StorageLink 和 Preferences
设置页把三个可持久化选项链接到 AppStorage:
@StorageLink('dailyReminderTime')
dailyReminderTime: string = '09:00'
@StorageLink('examDurationSec')
examDurationSec: number = 1800
@StorageLink('autoNextQuestion')
autoNextQuestion: boolean = false
选择新考试时长时,源码先更新共享状态和页面显示值,再调用服务持久化:
private selectExamDuration(value: number): void {
this.examDurationSec = value
this.displayExamDurationSec = value
UserDataManager.saveExamDurationSec(value)
this.settingsRevision++
this.activePicker = ''
this.showTip(
`考试时长已设置为 ${Math.floor(value / 60)} 分钟`
)
}
PracticePage 同样链接 examDurationSec:
@StorageLink('examDurationSec')
examDurationSec: number = 1800
因此设置页保存后,新进入或当前响应共享状态的练习页能读取新时长;应用重启后,UserDataManager.init() 又从 Preferences 恢复。
源码为设置页另外维护 displayExamDurationSec 等展示状态,并在 aboutToAppear() 同步一次。这可以隔离选择器的界面草稿与共享值,但也要求每个保存方法同时维护两者。当前实现通过 settingsRevision 触发相关 UI 更新,属于页面内部策略,不应泛化为所有设置页都必须复制一份 display 状态。
十二、清空操作为什么要逐项返回空数组
设置页清空全部学习数据时,没有直接调用 Preferences 的整库清除,而是逐项执行服务方法:
private clearAllLearningData(): void {
this.favRecords = UserDataManager.clearFavorites()
this.noteRecords = UserDataManager.clearNotes()
this.wrongRecords = UserDataManager.clearWrong()
this.progressList = UserDataManager.clearProgress()
this.chapterProgressList =
UserDataManager.clearChapterProgress()
this.examHistory = UserDataManager.clearExamHistory()
this.displayFavCount = 0
this.displayNoteCount = 0
this.displayWrongCount = 0
this.displayProgressCount = 0
this.displayExamCount = 0
this.dataRevision++
this.pendingClear = false
this.showTip('所有学习数据已清除')
}
每个 clear 方法都写入对应 Preferences 键并返回空数组:
static clearFavorites(): FavoriteRecord[] {
const result: FavoriteRecord[] = []
UserDataManager.persist(UserDataManager.K_FAV, result)
return result
}
逐项赋值能让所有 @StorageLink 立即收到空数组,页面返回后列表、角标和统计同步清空。若只删除磁盘键,不更新内存层,当前进程里的其他页面仍会显示旧数据。
清空操作还使用二次点击确认:
private requestClearAll(): void {
if (this.pendingClear) {
this.clearAllLearningData()
} else {
this.pendingClear = true
this.showTip('再次点击红色按钮确认清除所有学习数据')
}
}
这是不可逆本地数据操作的必要保护。当前源码没有备份、撤销或导出,清空后只能从默认空状态继续使用。
逐项清空也有一致性边界:多个 persist 不是一个事务。如果中途某项写盘失败,磁盘层可能部分清空;页面层仍会全部变成空数组。若产品对“全部清空”要求原子性,需要设计事务型存储或失败回滚,Preferences 的这组独立写入并不提供该保证。
十三、页面返回后即时一致靠共享键,不靠重新查询
首页的统计数据来自四个 @StorageLink:
@StorageLink('bankProgress')
progressList: BankProgress[] = []
@StorageLink('examHistory')
examHistory: ExamHistory[] = []
@StorageLink('favoriteRecords')
favRecords: FavoriteRecord[] = []
@StorageLink('wrongRecords')
wrongRecords: WrongRecord[] = []
首页调用:
private myStats() {
return StatService.summarize(
this.progressList,
this.examHistory,
this.favRecords,
this.wrongRecords
)
}
“我的”页、学习统计页和排行榜页也链接相同键。用户从答题页返回时,不需要每个页面在 aboutToAppear() 里重新读取 Preferences;答题页已经把服务返回的新数组写入 AppStorage。
这形成了清晰的单向数据流:
页面事件
→ UserDataManager 计算并持久化
→ 页面把返回值赋给 StorageLink
→ AppStorage 更新
→ 其他页面根据共享数据重新派生显示
关键是不要让页面同时直接调用 Preferences。否则一部分页面从 AppStorage 读,一部分页面从磁盘读,很容易在同一进程内出现两个真源。
句匠当前把 Preferences 访问集中在 UserDataManager,UI 只调用业务方法,符合服务边界。AppStorage 是当前运行状态的共享真源,Preferences 是跨启动恢复源,两者通过服务返回值保持同步。
十四、当前实现的性能与可靠性边界
persist() 每次业务变更都会 putSync + flushSync。对于收藏、设置、答题完成等低频轻量操作,这种实现简单直接;但它也意味着写盘发生在调用线程。
需要关注的场景包括:
- 连续答题时每次错误都同步写错题数组;
- 数组持续增长后,每次都序列化完整 JSON;
- 考试历史长期累积后,写入字符串越来越大;
- 多次设置操作连续触发刷盘;
- 写盘失败没有日志和反馈。
当前源码没有批量队列、异步写入、最大记录数或数据压缩,不能声称已经做了存储性能优化。更合理的演进顺序是先测量数组规模和写入耗时,再决定:
| 数据形态 | 当前选择 | 规模增长后的候选 |
|---|---|---|
| 三个设置值 | Preferences | 继续使用 Preferences |
| 少量收藏、笔记 | Preferences JSON | 可继续,增加版本校验 |
| 大量考试历史 | Preferences JSON | 考虑 RDB 与分页 |
| 题目级统计明细 | 当前未单独存储 | 结构化查询时考虑 RDB |
| 云端同步 | 当前不存在 | 另行设计账号、冲突与隐私 |
优化不能破坏即时一致。即使改成异步持久化,页面内存状态、失败补偿和重试策略也要明确。
十五、验证保存、删除和返回一致性的测试矩阵
持久化测试必须同时观察当前页面、其他页面和重启后的状态:
| 操作 | 当前页预期 | 返回其他页预期 | 重启后预期 |
|---|---|---|---|
| 收藏一道题 | 收藏态立即点亮 | 收藏数与列表增加 | 记录仍存在 |
| 再次取消收藏 | 收藏态立即取消 | 列表与计数减少 | 记录不再出现 |
| 保存笔记 | 弹窗关闭,笔记态变化 | 收藏页显示最新内容 | 内容仍存在 |
| 保存空笔记 | 当前笔记被删除 | 笔记列表移除 | 不恢复空记录 |
| 答错一道题 | 错题状态增加 | Index 角标与首页数量变化 | 错题仍存在 |
| 错题模式答对 | 当前题从错题移除 | 错题列表和角标减少 | 删除保持 |
| 完成练习 | 进度累计 | 首页、题库卡、统计页更新 | 累计值保持 |
| 修改考试时长 | 设置页显示新值 | 练习页使用新时长 | 重启后保持 |
| 清空全部数据 | 所有计数归零 | 列表、角标、统计都为空 | 重启后仍为空 |
| 注入损坏 JSON | 不应启动崩溃 | 使用安全默认值 | 记录降级情况 |
还要专门验证重复进入考试结果页,观察考试历史是否重复追加。这个场景是当前源码的残余风险,应在后续修复前保留测试记录。
十六、常见问题与排查方法
| 现象 | 先看哪里 | 常见根因 | 修复方向 |
|---|---|---|---|
| UI 更新但重启后丢失 | persist() | prefs 为空、写盘异常被吞掉 | 返回写入结果并记录错误 |
| 磁盘已写但当前页没更新 | 页面调用处 | 忽略服务返回的新数组 | 赋值给对应 @StorageLink |
| 同一道题重复收藏 | toggleFavorite() | 新增前未按 questionId 查找 | 先查找或过滤再新增 |
| 空笔记仍占列表 | upsertNote() | 空字符串没有删除语义 | trim 后不插入新记录 |
| 错题角标与列表不一致 | 是否单独存计数 | 计数和数组双写 | 直接从数组长度派生 |
| 完成练习后进度不变 | updateProgress 返回值 | 只调用服务未重新赋值 | 更新 progressList |
| 清空后返回仍看到旧数据 | AppStorage 是否更新 | 只清磁盘未清内存 | 每项返回空数组并赋值 |
| 某个 JSON 损坏导致数据全空 | init 的整体 catch | 所有键共用一个异常边界 | 改为逐键解析与降级 |
| 考试历史重复 | aboutToAppear() | 页面再次出现重复追加 | 增加考试 ID 或保存标记 |
| 大量记录时操作卡顿 | 同步全量 JSON 写入 | 每次 stringify + flush | 测量后迁移结构化存储 |
排查时先分清是“内存层没更新”还是“持久层没落盘”。当前页面立即错误,多半与新数组赋值或共享键有关;只有重启后错误,则优先检查 Preferences 写入、键名和解析。
十七、源码边界与可复用结论
句匠的本地状态方案可以总结为五条可迁移规则:
- Preferences 访问集中在服务层,页面不直接读写存储 API;
- Ability 启动时恢复数据,并为每个共享键提供确定默认值;
- 业务方法根据主键计算新数组,先持久化,再返回结果;
- 页面把结果重新赋给
@StorageLink,驱动所有关联页面即时一致; - 列表数量、角标和统计尽量从真实数组派生,避免额外计数双写。
这套方案适合当前项目的轻量本地学习数据。它不是事务数据库,也不是云同步系统。源码中的真实限制包括:同步全量 JSON 写入、异常静默、缺少数据版本、批量清空不具备原子性、考试结果缺少防重复标识。
在 HarmonyOS 5.0 及以上 ArkTS 项目里,“保存成功”的工程含义不应只看 UI 提示。至少要确认:当前页面状态已更新、其他页面共享状态一致、应用重启后能够恢复、失败时不会伪装成可靠落盘。把这四层验证完整,才是真正可复核的本地持久化闭环。
AI 辅助声明
本文由 AI 辅助生成和整理,内容已根据 com.jiaweikang.one18 项目中的 UserDataManager.ets、EntryAbility.ets、PracticePage.ets、FavoritePage.ets、SettingsPage.ets、ExamResultPage.ets、HomePage.ets 与 Index.ets 逐项复核;能力描述仅覆盖本地 Preferences、AppStorage 和页面共享状态的真实源码范围。
更多推荐



所有评论(0)