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 工具,项目目标不是把素材上传到服务器,也不是依赖账号体系做云同步。因此一旦进入真实使用场景,下面这些问题必须在端侧解决:

  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、编辑器入口、空状态和大型单页职责治理。

Logo

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

更多推荐