动图魔方技术拆解 13:Preferences 实现作品列表、草稿和主题偏好持久化 SEO 信息SEO 标题动图魔方技术拆解 13Preferences 实现作品列表、草稿和主题偏好持久化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 工具项目目标不是把素材上传到服务器也不是依赖账号体系做云同步。因此一旦进入真实使用场景下面这些问题必须在端侧解决用户导出一个 GIF 后作品页要立刻出现记录。App 关闭后再次打开作品列表不能回到默认示例数据。编辑参数还没导出时用户需要把当前素材、比例、帧率、滤镜、字幕等保存成草稿。用户从草稿恢复后编辑器应该回到对应素材和参数状态。深色、浅色、跟随系统的主题选择要在下次启动时继续生效。这些数据不大也不适合上数据库。Preferences正好适合承接这种“键值型、本地、轻量、启动时读取”的数据。二、目标与边界本文重点回答 5 个问题为什么项目把作品、草稿、主题偏好统一收口到StorageService。WorkEntry与DraftEntry分别应该保存哪些字段。loadWorks()/loadDrafts()为什么要做字段归一化和兜底返回。Index.ets如何在导出、删除、清空、存草稿、恢复草稿和切换主题时同步持久化。Preferences 适合保存什么不适合保存什么。本文不展开的部分GIF 导出进度和取消逻辑已在第 12 篇覆盖。深浅色视觉 token 和页面适配细节会在第 15 篇继续拆。大型单页 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;这几个常量说明了当前本地存储只做三类数据works导出后的作品记录。drafts编辑器草稿列表。theme_mode主题偏好。把它们统一放在StorageService里有两个好处页面层不需要关心 Preferences 的名称、key 和flush()时机。以后如果要迁移存储结构只需要先改服务层而不是在多个页面函数里找散落的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而是只保存作品索引信息id用来做列表更新、删除和去重。title/type/meta/tag用于作品页展示。updatedAt用于排序和时间提示。filePath指向实际导出的 GIF 文件。sourceUris保留来源素材方便后续扩展再次编辑或追溯。对应的读取逻辑如下static async loadWorks(context: common.UIAbilityContext, fallback: WorkEntry[]): PromiseWorkEntry[] { 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; } }这段实现有两个关键点第一次启动或本地数据为空时返回fallback让作品页不至于直接空白。读取旧数据后重新组装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 里更可靠的做法导出完成后如果 App 被系统回收作品索引仍然已经写入。works状态和 Preferences 内容保持同步。页面切到作品页时看到的列表就是下次启动后能恢复的列表。保存逻辑本身也很简单static async saveWorks(context: common.UIAbilityContext, works: WorkEntry[]): Promisevoid { 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): Promisevoid { 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(): Promisevoid { this.works []; await StorageService.saveWorks(this.ctx(), []); this.statusText 作品记录已清空; }这类代码看起来没有导出流程复杂但它决定了本地列表的一致性。如果删除只发生在内存里下次启动又会从 Preferences 把旧作品读回来用户就会看到“删不掉”的假象。所以这里的规则很简单新增作品更新内存列表再保存。删除作品更新内存列表再保存。清空作品更新内存列表再保存。所有能改变作品列表的动作都必须走同一个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 类信息编辑模式图片、视频、GIF、3D 或浅 3D。输出参数比例、帧率、清晰度、时长、速度、倒放。视觉参数滤镜、字幕、亮度、对比度、字幕位置。素材和预览sourceUris与previewPath。保存草稿时页面先确保当前预览可用再组装DraftEntryprivate async saveDraft(): Promisevoid { 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}; }恢复草稿的关键是完整性。只恢复素材是不够的因为用户真正想恢复的是一次编辑现场用什么模式编辑。输出比例和帧率是什么。字幕、滤镜、亮度、对比度是否保留。预览路径是否可继续展示。页面是否回到编辑器。schedulePreviewRefresh()也很关键。它让恢复后的状态重新进入预览刷新链路避免页面显示的是旧预览或空预览。九、主题偏好Preferences 保存选择应用上下文负责生效主题偏好是第三类本地数据。读取逻辑如下static async loadThemeMode(context: common.UIAbilityContext): Promisestring { 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; } }这里没有直接信任本地值而是只接受light、dark、system三种枚举。任何异常值都回到system避免旧版本或异常写入导致主题状态不可预期。页面启动时会读取并应用private async loadThemeMode(): Promisevoid { const mode await StorageService.loadThemeMode(this.ctx()); this.themeMode mode; this.darkPreview mode dark; this.applyColorMode(mode); }切换主题时则同步更新 UI、系统 ColorMode 和 Preferencesprivate async setThemeMode(mode: string): Promisevoid { this.themeMode mode; this.darkPreview mode dark; this.applyColorMode(mode); await StorageService.saveThemeMode(this.ctx(), mode); this.statusText mode dark ? 已切换深色主题 : mode light ? 已切换浅色主题 : 已切换为跟随系统; }也就是说主题切换不是单纯改一个颜色变量而是同时完成三件事当前页面立刻响应。应用级 ColorMode 生效。下次启动继续使用同一偏好。十、页面与工程证据10.1 作品页承接导出后的本地记录作品页展示的是WorkEntry的结果标题、类型、尺寸/帧数/体积等meta信息和后续操作入口。它不是直接扫描文件夹而是依赖StorageService.loadWorks()读出的作品索引。10.2 导出完成后能马上形成作品闭环导出成功后新作品插入列表顶部并写回 Preferences。这个截图对应的是“导出结果能被页面看见也能被下次启动恢复”的闭环。10.3 主题偏好属于本地设置的一部分个人页里的主题模式并不适合每次启动都回到默认值。用theme_mode保存用户选择可以让深色、浅色、跟随系统的选择成为稳定偏好。十一、工程复盘把本地持久化拆开后可以得到 5 个比较实用的结论Preferences 适合保存轻量索引和设置项不适合保存 GIF 字节流这类大对象。作品记录保存的是filePath和展示元数据真正的文件仍然留在应用文件目录。草稿保存的是编辑器完整上下文不只是素材路径。读取本地 JSON 后做字段归一化可以提高版本迭代后的兼容性。每个会改变列表或偏好的动作都应该立即写回 Preferences避免 UI 状态和本地状态分裂。十二、验收清单验收项结果说明本地存储统一收口到StorageService通过作品、草稿、主题偏好都走同一服务Preferences 名称和 key 集中定义通过PREF_NAME、WORKS_KEY、DRAFTS_KEY、THEME_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在每个会改变状态的动作后及时保存模型层则用WorkEntry和DraftEntry明确本地数据边界。这样导出、草稿和主题才不是一次性页面状态而是能跨启动延续的本地体验。十四、下一篇衔接下一篇进入第 14 篇动图魔方技术拆解 14ArkUI 大型单页的 Tab 路由、状态拆分与空状态设计。到那一篇会继续看Index.ets但重点从本地存储切换到五个 Tab、编辑器入口、空状态和大型单页职责治理。