本地状态持久化封面

本地学习类应用最难处理的不是“把一条数据写进 Preferences”,而是写完之后所有页面立刻一致。用户在答题页收藏一道题,返回首页后收藏数要变化;答错后错题本角标要立即增加;在收藏页清空错题,主页和“我的”页不能继续显示旧数量;修改考试时长后,下一次进入考试页必须读到新值;应用重启后,这些状态还要从本地恢复。

本文基于句匠项目的真实 HarmonyOS ArkTS 源码展开。核心文件是 librarya/src/main/ets/utils/UserDataManager.ets,并用 EntryAbility.etsPracticePage.etsFavoritePage.etsSettingsPage.etsExamResultPage.etsHomePage.etsIndex.ets 核对初始化、保存、删除、页面返回与 UI 派生过程。

本文唯一源码标识:com.jiaweikang.one18

源码可证明的能力包括:使用 @kit.ArkData 的 Preferences 同步保存轻量 JSON 数据;启动时恢复收藏、笔记、错题、题库进度、章节进度、考试记录和三项学习设置;用 AppStorage@StorageLink 在页面之间共享当前内存状态;新增、更新、删除、清空操作都返回新数组并由页面重新赋值。项目没有账号、云同步、跨设备数据库、备份导出、加密存储或服务端冲突合并,本文不会把本地一致性描述成云端能力。

一、先定义“一致”:磁盘、内存和页面三层同时成立

很多代码只完成了其中一层。例如调用 putSync() 后磁盘数据变了,但页面仍持有旧数组;或者页面先把数组改了,UI 看起来正确,却忘记 flushSync(),应用重启后数据消失。

句匠的真实链路分成三层:

  1. Preferences 保存可跨启动恢复的数据;
  2. AppStorage 保存当前应用进程内共享状态;
  3. 各页面通过 @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。这样存储名、序列化方式、putSyncflushSync 不会散落到 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 写入、键名和解析。

十七、源码边界与可复用结论

句匠的本地状态方案可以总结为五条可迁移规则:

  1. Preferences 访问集中在服务层,页面不直接读写存储 API;
  2. Ability 启动时恢复数据,并为每个共享键提供确定默认值;
  3. 业务方法根据主键计算新数组,先持久化,再返回结果;
  4. 页面把结果重新赋给 @StorageLink,驱动所有关联页面即时一致;
  5. 列表数量、角标和统计尽量从真实数组派生,避免额外计数双写。

这套方案适合当前项目的轻量本地学习数据。它不是事务数据库,也不是云同步系统。源码中的真实限制包括:同步全量 JSON 写入、异常静默、缺少数据版本、批量清空不具备原子性、考试结果缺少防重复标识。

在 HarmonyOS 5.0 及以上 ArkTS 项目里,“保存成功”的工程含义不应只看 UI 提示。至少要确认:当前页面状态已更新、其他页面共享状态一致、应用重启后能够恢复、失败时不会伪装成可靠落盘。把这四层验证完整,才是真正可复核的本地持久化闭环。

AI 辅助声明

本文由 AI 辅助生成和整理,内容已根据 com.jiaweikang.one18 项目中的 UserDataManager.etsEntryAbility.etsPracticePage.etsFavoritePage.etsSettingsPage.etsExamResultPage.etsHomePage.etsIndex.ets 逐项复核;能力描述仅覆盖本地 Preferences、AppStorage 和页面共享状态的真实源码范围。

Logo

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

更多推荐