动图魔方技术拆解 13:Preferences 实现作品列表、草稿和主题偏好持久化
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 SDK
6.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[]): 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;
}
}
这段实现有两个关键点:
- 第一次启动或本地数据为空时,返回
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[]): 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 把旧作品读回来,用户就会看到“删不掉”的假象。
所以这里的规则很简单:
- 新增作品:更新内存列表,再保存。
- 删除作品:更新内存列表,再保存。
- 清空作品:更新内存列表,再保存。
所有能改变作品列表的动作,都必须走同一个 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。
保存草稿时,页面先确保当前预览可用,再组装 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}`;
}
恢复草稿的关键是完整性。只恢复素材是不够的,因为用户真正想恢复的是一次编辑现场:
- 用什么模式编辑。
- 输出比例和帧率是什么。
- 字幕、滤镜、亮度、对比度是否保留。
- 预览路径是否可继续展示。
- 页面是否回到编辑器。
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';
}
}
这里没有直接信任本地值,而是只接受 light、dark、system 三种枚举。任何异常值都回到 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' ? '已切换浅色主题' : '已切换为跟随系统';
}
也就是说,主题切换不是单纯改一个颜色变量,而是同时完成三件事:
- 当前页面立刻响应。
- 应用级 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 篇:动图魔方技术拆解 14:ArkUI 大型单页的 Tab 路由、状态拆分与空状态设计。到那一篇会继续看 Index.ets,但重点从本地存储切换到五个 Tab、编辑器入口、空状态和大型单页职责治理。
更多推荐


所有评论(0)