动图魔方技术拆解 13:Preferences 实现作品列表、草稿和主题偏好持久化
2026/7/5 7:17:15 网站建设 项目流程

SEO 信息

  • SEO 标题:动图魔方技术拆解 13:Preferences 实现作品列表、草稿和主题偏好持久化
  • SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,本文拆解工具类 App 最容易被低估的一层:本地持久化。StorageService.ets如何用@kit.ArkData的 Preferences 保存作品列表、编辑草稿和主题偏好,WorkEntry/DraftEntry为什么要做字段归一化,Index.ets如何在导出成功、存草稿、恢复草稿、删除作品和切换深浅色时保持 UI 状态与本地存储一致。文章结合真实工程代码、截图证据和验收清单,适合正在做 HarmonyOS 本地工具、ArkTS Preferences 数据模型或离线优先创作 App 的开发者参考。
  • 关键词:HarmonyOS, ArkTS, Preferences, ArkData, 本地持久化, WorkEntry, DraftEntry, StorageService, GIF 工具
  • 文章封面doc/csdn-series/covers/cover-13-preferences-storage-loop.jpg
  • 投稿方向:普通技术拆解 / 本地持久化与状态闭环
  • 项目环境:HarmonyOS SDK6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube

第 11、12 篇分别拆了后台导出和用户可感知的进度/取消体验,但导出成功并不等于功能闭环。用户下一次打开 App 时,作品是否还在?编辑到一半的参数能不能恢复?深浅色偏好会不会丢?这一篇聚焦StorageService.ets,把“动图魔方”里作品、草稿和主题偏好三类本地数据拆清楚。

一、真实工程问题背景

“动图魔方”是一个本地优先的 GIF 工具,项目目标不是把素材上传到服务器,也不是依赖账号体系做云同步。因此一旦进入真实使用场景,下面这些问题必须在端侧解决:

  1. 用户导出一个 GIF 后,作品页要立刻出现记录。
  2. App 关闭后再次打开,作品列表不能回到默认示例数据。
  3. 编辑参数还没导出时,用户需要把当前素材、比例、帧率、滤镜、字幕等保存成草稿。
  4. 用户从草稿恢复后,编辑器应该回到对应素材和参数状态。
  5. 深色、浅色、跟随系统的主题选择,要在下次启动时继续生效。

这些数据不大,也不适合上数据库。Preferences正好适合承接这种“键值型、本地、轻量、启动时读取”的数据。

二、目标与边界

本文重点回答 5 个问题:

  1. 为什么项目把作品、草稿、主题偏好统一收口到StorageService
  2. WorkEntryDraftEntry分别应该保存哪些字段。
  3. loadWorks()/loadDrafts()为什么要做字段归一化和兜底返回。
  4. Index.ets如何在导出、删除、清空、存草稿、恢复草稿和切换主题时同步持久化。
  5. Preferences 适合保存什么,不适合保存什么。

本文不展开的部分:

  1. GIF 导出进度和取消逻辑,已在第 12 篇覆盖。
  2. 深浅色视觉 token 和页面适配细节,会在第 15 篇继续拆。
  3. 大型单页 Tab 状态治理,会在第 14 篇单独拆。

三、先把持久化边界集中到一个服务

项目里的本地持久化入口很集中:

import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; import { DraftEntry, WorkEntry } from '../models/AppModels'; const PREF_NAME = 'gifrubiks_cube_store'; const WORKS_KEY = 'works'; const THEME_KEY = 'theme_mode'; const DRAFTS_KEY = 'drafts';

这几个常量说明了当前本地存储只做三类数据:

  1. works:导出后的作品记录。
  2. drafts:编辑器草稿列表。
  3. theme_mode:主题偏好。

把它们统一放在StorageService里有两个好处:

  1. 页面层不需要关心 Preferences 的名称、key 和flush()时机。
  2. 以后如果要迁移存储结构,只需要先改服务层,而不是在多个页面函数里找散落的preferences.getPreferences()

工具类 App 里很容易把本地存储写成“哪里用到哪里存”,但当作品、草稿、主题、设置项越来越多时,这种写法会快速变成维护负担。当前项目把存储边界收紧,是一个比较稳的选择。

四、作品列表:保存的是索引记录,不是 GIF 字节本身

WorkEntry的模型很克制:

export interface WorkEntry { id: string; title: string; type: string; meta: string; tag: string; updatedAt: string; filePath?: string; sourceUris?: string[]; }

这里没有把 GIF 字节流塞进 Preferences,而是只保存作品索引信息:

  1. id用来做列表更新、删除和去重。
  2. title/type/meta/tag用于作品页展示。
  3. updatedAt用于排序和时间提示。
  4. filePath指向实际导出的 GIF 文件。
  5. sourceUris保留来源素材,方便后续扩展再次编辑或追溯。

对应的读取逻辑如下:

static async loadWorks(context: common.UIAbilityContext, fallback: WorkEntry[]): Promise<WorkEntry[]> { try { const store = await preferences.getPreferences(context, PREF_NAME); const raw = await store.get(WORKS_KEY, ''); if (typeof raw !== 'string' || raw.length === 0) { return fallback; } const parsed = JSON.parse(raw) as WorkEntry[]; return parsed.map((item: WorkEntry) => { const normalized: WorkEntry = { id: item.id, title: item.title, type: item.type, meta: item.meta, tag: item.tag, updatedAt: item.updatedAt, filePath: item.filePath, sourceUris: item.sourceUris ?? [] }; return normalized; }); } catch (err) { return fallback; } }

这段实现有两个关键点:

  1. 第一次启动或本地数据为空时,返回fallback,让作品页不至于直接空白。
  2. 读取旧数据后重新组装WorkEntry,并对sourceUris?? []兜底。

字段归一化很重要。因为 App 一旦发布,旧版本写入的 Preferences 可能长期存在。如果后续模型新增字段,直接信任JSON.parse()的结果,很容易在页面使用时踩到undefined

五、导出成功后,作品列表要立即写回本地

第 12 篇讲过导出成功后的状态闭环,这里重点看作品持久化:

const preset = this.createPreset(`${this.titleOf(this.editorType)}_${this.works.length + 1}`); const work = await ExportService.exportGif(this.ctx(), preset, signal); const next = this.works.slice(); next.unshift(work); this.works = next; await StorageService.saveWorks(this.ctx(), next); this.page = 'works'; this.statusText = `已导出:${work.meta}`;

这里没有等到页面退出时再保存,而是在导出成功后立即落盘。这是工具类 App 里更可靠的做法:

  1. 导出完成后如果 App 被系统回收,作品索引仍然已经写入。
  2. works状态和 Preferences 内容保持同步。
  3. 页面切到作品页时看到的列表,就是下次启动后能恢复的列表。

保存逻辑本身也很简单:

static async saveWorks(context: common.UIAbilityContext, works: WorkEntry[]): Promise<void> { try { const store = await preferences.getPreferences(context, PREF_NAME); await store.put(WORKS_KEY, JSON.stringify(works)); await store.flush(); } catch (err) { } }

flush()是这里不能漏掉的一步。它把内存里的 Preferences 变更真正刷到持久层,避免“页面上看起来保存了,但重启后又丢了”的问题。

六、删除和清空也必须走同一条保存链路

作品页里的删除不是只改 UI 数组:

private async deleteWork(id: string): Promise<void> { const next = this.works.slice().filter((item: WorkEntry) => item.id !== id); this.works = next; await StorageService.saveWorks(this.ctx(), next); this.statusText = '作品记录已删除'; } private async clearWorks(): Promise<void> { this.works = []; await StorageService.saveWorks(this.ctx(), []); this.statusText = '作品记录已清空'; }

这类代码看起来没有导出流程复杂,但它决定了本地列表的一致性。如果删除只发生在内存里,下次启动又会从 Preferences 把旧作品读回来,用户就会看到“删不掉”的假象。

所以这里的规则很简单:

  1. 新增作品:更新内存列表,再保存。
  2. 删除作品:更新内存列表,再保存。
  3. 清空作品:更新内存列表,再保存。

所有能改变作品列表的动作,都必须走同一个saveWorks()

七、草稿保存:记录的是编辑器完整上下文

草稿比作品复杂,因为草稿不是一个导出结果,而是“编辑器当前状态”的快照。DraftEntry保存的字段明显更多:

export interface DraftEntry { id: string; title: string; editorType: string; ratio: string; fps: string; quality: string; speed: string; reversed: boolean; filter: string; subtitle: string; subtitleSize: string; subtitleColor: string; subtitlePosition: string; brightness: number; contrast: number; trimStartPct: number; trimEndPct: number; duration: number; frameDuration: number; rotateSpeed: number; sourceUris: string[]; previewPath?: string; updatedAt: string; }

这说明草稿需要覆盖 4 类信息:

  1. 编辑模式:图片、视频、GIF、3D 或浅 3D。
  2. 输出参数:比例、帧率、清晰度、时长、速度、倒放。
  3. 视觉参数:滤镜、字幕、亮度、对比度、字幕位置。
  4. 素材和预览:sourceUrispreviewPath

保存草稿时,页面先确保当前预览可用,再组装DraftEntry

private async saveDraft(): Promise<void> { await this.ensureLivePreview(); const now = new Date(); const draft: DraftEntry = { id: `draft_${now.getTime()}`, title: `${this.titleOf(this.editorType)}草稿_${this.drafts.length + 1}`, editorType: this.editorType, ratio: this.selectedRatio, fps: this.selectedFps, quality: this.selectedQuality, speed: this.selectedSpeed, reversed: this.reversed, filter: this.selectedFilter, subtitle: this.subtitleText, subtitleSize: this.subtitleSize, subtitleColor: this.subtitleColor, subtitlePosition: this.subtitlePosition, brightness: this.brightnessLevel, contrast: this.contrastLevel, trimStartPct: this.trimStartPct, trimEndPct: this.trimEndPct, duration: this.duration, frameDuration: this.frameDuration, rotateSpeed: this.rotateSpeed, sourceUris: this.sourceUris.slice(), previewPath: this.livePreviewPath, updatedAt: this.formatNow(now) }; const next = this.drafts.slice(); next.unshift(draft); this.drafts = next; await StorageService.saveDrafts(this.ctx(), next); this.statusText = `已存草稿:${draft.title}`; }

这里使用sourceUris.slice(),不是直接把数组引用塞进去。它避免后续编辑器继续改素材列表时,把已经保存的草稿对象也一起改掉。

八、恢复草稿:不能只恢复素材,还要恢复参数

草稿恢复对应的是restoreDraft()

private restoreDraft(draft: DraftEntry): void { this.editorType = draft.editorType; this.selectedRatio = draft.ratio; this.selectedFps = draft.fps; this.selectedQuality = draft.quality; this.selectedSpeed = draft.speed; this.reversed = draft.reversed; this.selectedFilter = draft.filter; this.subtitleText = draft.subtitle; this.subtitleSize = draft.subtitleSize; this.subtitleColor = draft.subtitleColor; this.subtitlePosition = draft.subtitlePosition; this.brightnessLevel = draft.brightness; this.contrastLevel = draft.contrast; this.trimStartPct = draft.trimStartPct; this.trimEndPct = draft.trimEndPct; this.duration = draft.duration; this.frameDuration = draft.frameDuration; this.rotateSpeed = draft.rotateSpeed; this.sourceUris = draft.sourceUris.slice(); this.livePreviewPath = draft.previewPath ?? ''; this.livePreviewUri = this.livePreviewPath.length > 0 ? this.toDisplayUri(this.livePreviewPath) : ''; this.livePreviewStatus = this.livePreviewUri.length > 0 ? '已恢复草稿预览' : ''; this.page = 'editor'; this.schedulePreviewRefresh(); this.statusText = `已恢复草稿:${draft.title}`; }

恢复草稿的关键是完整性。只恢复素材是不够的,因为用户真正想恢复的是一次编辑现场:

  1. 用什么模式编辑。
  2. 输出比例和帧率是什么。
  3. 字幕、滤镜、亮度、对比度是否保留。
  4. 预览路径是否可继续展示。
  5. 页面是否回到编辑器。

schedulePreviewRefresh()也很关键。它让恢复后的状态重新进入预览刷新链路,避免页面显示的是旧预览或空预览。

九、主题偏好:Preferences 保存选择,应用上下文负责生效

主题偏好是第三类本地数据。读取逻辑如下:

static async loadThemeMode(context: common.UIAbilityContext): Promise<string> { try { const store = await preferences.getPreferences(context, PREF_NAME); const raw = await store.get(THEME_KEY, 'system'); if (raw === 'light' || raw === 'dark' || raw === 'system') { return raw; } return 'system'; } catch (err) { return 'system'; } }

这里没有直接信任本地值,而是只接受lightdarksystem三种枚举。任何异常值都回到system,避免旧版本或异常写入导致主题状态不可预期。

页面启动时会读取并应用:

private async loadThemeMode(): Promise<void> { const mode = await StorageService.loadThemeMode(this.ctx()); this.themeMode = mode; this.darkPreview = mode === 'dark'; this.applyColorMode(mode); }

切换主题时则同步更新 UI、系统 ColorMode 和 Preferences:

private async setThemeMode(mode: string): Promise<void> { this.themeMode = mode; this.darkPreview = mode === 'dark'; this.applyColorMode(mode); await StorageService.saveThemeMode(this.ctx(), mode); this.statusText = mode === 'dark' ? '已切换深色主题' : mode === 'light' ? '已切换浅色主题' : '已切换为跟随系统'; }

也就是说,主题切换不是单纯改一个颜色变量,而是同时完成三件事:

  1. 当前页面立刻响应。
  2. 应用级 ColorMode 生效。
  3. 下次启动继续使用同一偏好。

十、页面与工程证据

10.1 作品页承接导出后的本地记录

作品页展示的是WorkEntry的结果:标题、类型、尺寸/帧数/体积等meta信息和后续操作入口。它不是直接扫描文件夹,而是依赖StorageService.loadWorks()读出的作品索引。

10.2 导出完成后能马上形成作品闭环

导出成功后,新作品插入列表顶部并写回 Preferences。这个截图对应的是“导出结果能被页面看见,也能被下次启动恢复”的闭环。

10.3 主题偏好属于本地设置的一部分

个人页里的主题模式并不适合每次启动都回到默认值。用theme_mode保存用户选择,可以让深色、浅色、跟随系统的选择成为稳定偏好。

十一、工程复盘

把本地持久化拆开后,可以得到 5 个比较实用的结论:

  1. Preferences 适合保存轻量索引和设置项,不适合保存 GIF 字节流这类大对象。
  2. 作品记录保存的是filePath和展示元数据,真正的文件仍然留在应用文件目录。
  3. 草稿保存的是编辑器完整上下文,不只是素材路径。
  4. 读取本地 JSON 后做字段归一化,可以提高版本迭代后的兼容性。
  5. 每个会改变列表或偏好的动作,都应该立即写回 Preferences,避免 UI 状态和本地状态分裂。

十二、验收清单

验收项结果说明
本地存储统一收口到StorageService通过作品、草稿、主题偏好都走同一服务
Preferences 名称和 key 集中定义通过PREF_NAMEWORKS_KEYDRAFTS_KEYTHEME_KEY
作品列表读取具备 fallback通过空数据或异常时返回DEFAULT_WORKS
作品字段读取后做归一化通过sourceUris ?? []等兜底处理
导出成功后立即保存作品列表通过StorageService.saveWorks()在成功路径中调用
删除和清空作品会同步本地状态通过deleteWork()/clearWorks()都写回 Preferences
草稿保存覆盖完整编辑上下文通过参数、素材、预览路径、时间都写入DraftEntry
草稿恢复后回到编辑页并刷新预览通过restoreDraft()设置页面并调用schedulePreviewRefresh()
主题偏好只接受合法枚举通过light/dark/system之外回退到system
主题切换同时更新 UI、ColorMode 和 Preferences通过setThemeMode()中同步执行

十三、小结

第 13 篇拆的是一个不炫但很关键的能力:让工具 App 记住用户已经做过的事。在“动图魔方”里,StorageService用 Preferences 把作品索引、编辑草稿和主题偏好统一收口;Index.ets在每个会改变状态的动作后及时保存;模型层则用WorkEntryDraftEntry明确本地数据边界。这样导出、草稿和主题才不是一次性页面状态,而是能跨启动延续的本地体验。

十四、下一篇衔接

下一篇进入第 14 篇:动图魔方技术拆解 14:ArkUI 大型单页的 Tab 路由、状态拆分与空状态设计。到那一篇会继续看Index.ets,但重点从本地存储切换到五个 Tab、编辑器入口、空状态和大型单页职责治理。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询