SEO 信息

  • SEO 标题:【共创季稿事节】动图魔方技术拆解 03:HarmonyOS 6.1 本地优先 GIF 工具素材链路实战
  • SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,拆解一个不依赖登录态、不申请网络权限的 GIF 工具如何完成本地优先素材闭环:PhotoViewPicker 选择图片和视频,DocumentViewPicker 接入 GIF/文件 URI,showAssetsCreationDialog 保存到系统相册,startAbility 拉起系统分享,并用 Preferences 持久化作品与草稿。
  • 关键词:HarmonyOS, ArkTS, PhotoViewPicker, DocumentViewPicker, URI, showAssetsCreationDialog, 系统分享, Preferences, GIF 工具
  • 文章封面https://i-blog.csdnimg.cn/direct/03cd5328a2814281895ddb2cf61001d2.png
  • 投稿方向:HarmonyOS 6.1 创新特性适配实战
  • 项目环境:HarmonyOS SDK 6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube

“动图魔方”从一开始就不是云端创作工具,而是一个本地优先的鸿蒙 GIF 工具。用户不需要登录,不需要网络权限,也不需要把素材上传到服务器。真正难的不是“能不能选到一张图”,而是如何把素材选择、文件 URI、相册保存、系统分享和本地持久化串成一条稳定闭环。

一、真实工程问题背景

做 GIF 工具时,最容易被忽略的一件事是“素材链路”本身就是核心能力。

如果素材入口设计错了,后面的抽帧、编码、导出再漂亮都落不了地。我在这个项目里一开始就给自己定了三条约束:

  1. 不做账号体系,不要求用户登录;
  2. 不申请网络权限,不把素材传到服务器;
  3. 优先复用系统提供的安全能力,而不是自己扩权扫相册。

这三条约束会直接影响实现方式。比如:

  1. 图片和视频不能假设应用拥有整个媒体库的长期权限;
  2. 保存到相册不能靠静默写库,而要走用户确认的系统授权路径;
  3. 分享不能依赖项目私有页面,只能交给系统 Want
  4. 作品和草稿状态必须保留在本地,保证再次打开应用还能继续编辑。

所以第 03 篇不再讲编码器,而是讲“动图魔方”为什么坚持围绕 URI、相册和系统分享设计一个本地优先工具闭环。

二、目标与边界

当前这一版素材链路的目标是:

  1. 支持从系统安全入口选择视频、图片和文档类素材;
  2. 对内部页面统一暴露 string[] URI 列表,不把页面层绑死到某一种媒体来源;
  3. 导出后的 GIF 能保存到系统相册;
  4. 已导出的 GIF 能直接拉起系统分享;
  5. 作品记录、草稿和主题偏好只保存在本地。

边界也很明确:

  1. 这不是云端素材平台,不提供跨设备同步;
  2. 不申请 INTERNET,也不实现上传分发;
  3. 不持有全局相册写权限,而是每次保存都走系统确认;
  4. 分享只负责把 GIF 文件交给系统,不自建分享面板。

entry/src/main/module.json5 也能看出这个边界:当前仅声明了 ohos.permission.KEEP_BACKGROUND_RUNNING,没有网络权限,也没有额外的媒体库写入权限。

"requestPermissions": [
  {
    "name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
  }
]

三、链路拆分:从素材入口到本地闭环

这条本地优先链路在项目里被拆成了四层:

层级 责任 对应文件
素材入口层 统一选择视频、图片、文档 URI entry/src/main/ets/services/MediaService.ets
编辑页编排层 根据功能入口调用不同选择器,并保存 sourceUris entry/src/main/ets/pages/Index.ets
导出后落地层 保存 GIF 到系统相册 entry/src/main/ets/services/SaveAlbumService.ets
分发与持久化层 系统分享、作品记录、草稿存储 entry/src/main/ets/services/ShareService.etsStorageService.ets

这一层次很关键,因为页面层只关心“拿到了哪些 URI”,而不需要知道背后到底是图片、视频还是文档选择器。这让视频转 GIF、图片拼 GIF、GIF 再编辑三条链路都能复用同一套页面状态。

四、关键实现

4.1 用系统选择器拿素材,而不是假设拥有整库权限

MediaService 里把三种入口都统一成了 MediaPickResult,返回 uris: string[] 和提示信息:

export class MediaService {
  static async pickVideo(): Promise<MediaPickResult> {
    const pickerView = new photoAccessHelper.PhotoViewPicker();
    const options = new photoAccessHelper.PhotoSelectOptions();
    options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.VIDEO_TYPE;
    options.maxSelectNumber = 1;
    const result = await pickerView.select(options);
    return {
      uris: result.photoUris,
      message: result.photoUris.length > 0 ? '已选择视频素材' : '未选择视频'
    };
  }

  static async pickImages(): Promise<MediaPickResult> {
    const pickerView = new photoAccessHelper.PhotoViewPicker();
    const options = new photoAccessHelper.PhotoSelectOptions();
    options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
    options.maxSelectNumber = 100;
    const result = await pickerView.select(options);
    return {
      uris: result.photoUris,
      message: result.photoUris.length > 0 ? `已选择 ${result.photoUris.length} 张图片` : '未选择图片'
    };
  }

  static async pickDocument(context: common.UIAbilityContext): Promise<MediaPickResult> {
    const documentPicker = new picker.DocumentViewPicker(context);
    const options = new picker.DocumentSelectOptions();
    const uris = await documentPicker.select(options);
    return {
      uris: uris,
      message: uris.length > 0 ? `已选择 ${uris.length} 个文件` : '未选择文件'
    };
  }
}

这里最重要的不是调用了哪一个 API,而是“素材来源被压平为 URI 列表”。页面层只接收结果,不关心底层是 PhotoViewPicker 还是 DocumentViewPicker。这就是本地优先工具的第一条原则:先把素材访问边界固定住,再往上做功能。

4.2 页面层只维护 sourceUris,不耦合具体来源

Index.ets 里真正接住这条链路的是 pickSource()。它根据当前编辑器类型选择入口,但最终都回写到同一个 @State sourceUris: string[]

private async pickSource(): Promise<void> {
  try {
    let result: MediaPickResult;
    if (this.editorType === 'video') {
      result = await MediaService.pickVideo();
    } else if (this.editorType === 'image' || this.editorType === 'depth' || this.editorType === 'threeD') {
      result = await MediaService.pickImages();
    } else {
      result = await MediaService.pickDocument(this.ctx());
    }
    this.sourceUris = result.uris.slice();
    this.statusText = result.message;
  } catch (err) {
    this.sourceUris = [];
    this.statusText = '未选择素材,请重新选择真实素材';
  }
}

这样做有两个直接收益:

  1. 所有编辑器都能围绕 sourceUris 共用后续导出逻辑;
  2. 当用户取消选择或 URI 无效时,状态回退路径非常统一,不会出现某个页面残留脏状态。

为了让 URI 能在页面上直接预览,项目还额外做了一个 toDisplayUri() 适配,把沙箱路径或选择器 URI 统一转成可供 Image 使用的地址:

private toDisplayUri(source?: string): string {
  if (!source || source.length === 0) {
    return '';
  }
  if (source.indexOf('://') >= 0) {
    return source;
  }
  try {
    return fileUri.getUriFromPath(source);
  } catch (err) {
    return source;
  }
}

这一层适配看起来不起眼,但它决定了“选择成功”是不是只停留在日志里,还是能真正反馈到页面预览和作品列表里。

4.3 保存到相册走 showAssetsCreationDialog,避免静默扩权

导出后的 GIF 不是直接塞进系统库,而是先检查文件是否还在沙箱里,再调用 showAssetsCreationDialog() 让用户确认目标相册位置:

const srcUri = fileUri.getUriFromPath(filePath);
const helper = photoAccessHelper.getPhotoAccessHelper(context);
const configs: photoAccessHelper.PhotoCreationConfig[] = [
  {
    title: SaveAlbumService.sanitizeTitle(title),
    fileNameExtension: 'gif',
    photoType: photoAccessHelper.PhotoType.IMAGE,
    subtype: photoAccessHelper.PhotoSubtype.DEFAULT
  }
];

const destUris = await helper.showAssetsCreationDialog([srcUri], configs);
if (!destUris || destUris.length === 0) {
  return '已取消保存到相册';
}
srcFile = fs.openSync(srcUri, fs.OpenMode.READ_ONLY);
destFile = fs.openSync(destUris[0], fs.OpenMode.READ_WRITE);
fs.copyFileSync(srcFile.fd, destFile.fd);
return '已保存到系统相册';

这条路比“直接申请写相册权限”更稳的地方在于:

  1. 权限边界清晰,每次保存都由系统弹窗显式确认;
  2. 不需要在 module.json5 增加额外的相册写入权限声明;
  3. 用户取消时,应用拿到的是明确结果,而不是模糊失败;
  4. 更符合这个项目“本地隐私模式”的产品定位。

同时服务里还做了两层前置校验:

if (!filePath || filePath.length === 0) {
  return '当前作品没有可保存的导出文件';
}
try {
  if (!fs.accessSync(filePath)) {
    return '导出文件已不存在,请重新导出';
  }
} catch (err) {
  return '导出文件不可访问,请重新导出';
}

这能防止作品记录还在、但真实导出文件已经被清理掉时,页面还盲目弹系统保存流程。

4.4 用系统分享能力分发 GIF,而不是自己拼渠道面板

ShareService 的思路很直接:只构造一个 Want,把 GIF 文件 URI 交给系统。

const uri = fileUri.getUriFromPath(path);
const want: Want = {
  action: 'ohos.want.action.sendData',
  type: 'image/gif',
  uri: uri,
  flags: 0x00000001,
  parameters: {
    'ability.params.stream': uri,
    'ohos.extra.param.key.contentTitle': '动图魔方导出作品'
  }
};
await context.startAbility(want);
return '已拉起系统分享';

这里没有做任何平台特定逻辑,也没有自己维护分享目标名单。原因很现实:

  1. 这个项目的目标是导出作品,不是经营分享生态;
  2. 系统分享天然适配设备上已有应用;
  3. 出错时可以明确回退为“没有可分享目标”或“文件不可访问”。

对于工具类 App 来说,这样的职责边界比做一个“看起来更完整”的伪分享页更靠谱。

4.5 本地持久化只保存必要状态,保证再次打开还能接着用

本地优先不只是素材选择不联网,还包括状态也不依赖远端。StorageService 里用 Preferences 保存了作品、草稿和主题模式:

const PREF_NAME = 'gifrubiks_cube_store';
const WORKS_KEY = 'works';
const THEME_KEY = 'theme_mode';
const DRAFTS_KEY = 'drafts';

await store.put(WORKS_KEY, JSON.stringify(works));
await store.put(DRAFTS_KEY, JSON.stringify(drafts));
await store.put(THEME_KEY, mode);
await store.flush();

页面启动时会分别恢复:

  1. 已导出的作品记录;
  2. 草稿配置;
  3. 深浅色主题偏好。

这样即使没有账号系统,用户也不会每次打开应用都从零开始。这是“本地优先”比“本地临时缓存”更完整的一步。

五、异常与边界处理

5.1 取消选择不是错误,而是正常分支

无论是图片、视频还是文档选择器,用户取消都不应该让页面留在半初始化状态。因此 pickSource() 捕获异常后会统一清空 sourceUris 并提示重新选择真实素材。这比保留旧素材更安全,避免用户误以为当前选择已经更新成功。

5.2 文件路径和 URI 必须统一做转换

项目内部既有选择器返回的 URI,也有测试素材写到沙箱后的本地路径。如果不统一转换,页面预览、相册保存、系统分享这三条链路会各自维护一套规则,最终很容易出现“页面能显示、分享失败”或者“作品存在、保存失败”的割裂体验。

5.3 保存和分享都必须先验证真实文件还在

作品列表保存的是元数据,不是文件句柄。用户清缓存、重新安装,或者后续清理导出目录后,记录可能还在,但文件已经没了。SaveAlbumServiceShareService 在真正执行前都做了存在性判断,这一步是工具类应用非常典型、但很容易漏掉的防线。

5.4 测试素材只是验证链路,不替代真实入口

项目里还有 TestAssetService,会把内置测试图片、视频、GIF 复制到 cacheDir/test_assets 里,方便开发期快速验证:

const baseDir = `${context.cacheDir}/test_assets`;
fs.mkdirSync(baseDir, true);
return {
  videoUris: await TestAssetService.copyAssets(context, baseDir, VIDEO_ASSETS),
  imageUris: await TestAssetService.copyAssets(context, baseDir, IMAGE_ASSETS),
  gifUris: await TestAssetService.copyAssets(context, baseDir, GIF_ASSETS)
};

它的价值是回归测试,而不是代替真实素材入口。真正上线后的用户闭环,仍然要靠系统选择器、相册保存和系统分享完成。

六、截图与日志证据

6.1 编辑页真实展示了系统安全访问提示

系统安全访问与真实素材入口

这张图能证明项目不是直接扫描媒体库,而是明确围绕系统安全访问能力设计素材入口。

6.2 选择器已弹起,说明图片/GIF 素材路径走的是系统入口

系统素材选择器已弹起

这一状态对应 PhotoViewPicker / DocumentViewPicker 的真实交互,而不是本地写死数据。

6.3 作品页存在分享按钮,闭环不是停留在导出完成

作品页分享按钮

这张图说明导出的 GIF 已经进入作品列表,并且可以继续走系统分享,而不是只在内存里显示“导出成功”。

6.4 清空后的作品页验证了本地记录状态分支

作品页清空后的空状态

这对应 StorageService.saveWorks() 之后的真实界面,也证明作品列表状态并不是模拟文案。

七、工程验收清单

验收项 结果 说明
视频入口走系统 PhotoViewPicker 通过 MediaService.pickVideo() 已落地
图片入口走系统 PhotoViewPicker 通过 MediaService.pickImages() 已落地
GIF/文档入口走 DocumentViewPicker 通过 MediaService.pickDocument() 已落地
页面层统一接收 sourceUris 通过 Index.ets 统一维护状态
不申请网络权限 通过 module.json5INTERNET
保存到相册走系统确认弹窗 通过 showAssetsCreationDialog() 已接入
分享通过系统 Want 拉起 通过 ohos.want.action.sendData 已接入
作品与草稿只保存在本地 通过 Preferences 持久化已接入
空状态与异常路径可回退 通过 有清空记录与文件存在性校验

八、小结

“动图魔方”的素材链路并没有追求“权限越大越方便”,而是刻意反过来做:权限越小、边界越清晰,越适合本地优先工具。

这一篇真正解决的是三个工程问题:

  1. 如何在不扩权的前提下接入图片、视频和 GIF 素材;
  2. 如何把 URI、保存、分享统一成一个可复用闭环;
  3. 如何在没有登录态和网络能力的前提下,让工具仍然具备可持续使用的状态管理。

对 HarmonyOS 工具类应用来说,这比单独会用一个媒体 API 更重要。因为用户最终感知到的不是“你用了什么 Kit”,而是“我选完素材之后,能不能稳定导出、保存、分享,并且下次打开还在”。

九、下一篇衔接

下一篇会切到更底层的编码实现,正式进入普通技术拆解篇:动图魔方技术拆解 06:从 GIF89a 文件结构看动图编码器设计。前面三篇先把入口、抽帧和本地素材闭环讲清楚,后面再拆 Header、Logical Screen Descriptor、Graphic Control Extension 和 Image Descriptor,工程上下游会更容易对齐。

Logo

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

更多推荐