【共创季稿事节】动图魔方技术拆解 03:HarmonyOS 6.1 本地优先 GIF 工具:素材选择、文件 URI、相册保存与系统分享
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 工具时,最容易被忽略的一件事是“素材链路”本身就是核心能力。
如果素材入口设计错了,后面的抽帧、编码、导出再漂亮都落不了地。我在这个项目里一开始就给自己定了三条约束:
- 不做账号体系,不要求用户登录;
- 不申请网络权限,不把素材传到服务器;
- 优先复用系统提供的安全能力,而不是自己扩权扫相册。
这三条约束会直接影响实现方式。比如:
- 图片和视频不能假设应用拥有整个媒体库的长期权限;
- 保存到相册不能靠静默写库,而要走用户确认的系统授权路径;
- 分享不能依赖项目私有页面,只能交给系统
Want; - 作品和草稿状态必须保留在本地,保证再次打开应用还能继续编辑。
所以第 03 篇不再讲编码器,而是讲“动图魔方”为什么坚持围绕 URI、相册和系统分享设计一个本地优先工具闭环。
二、目标与边界
当前这一版素材链路的目标是:
- 支持从系统安全入口选择视频、图片和文档类素材;
- 对内部页面统一暴露
string[]URI 列表,不把页面层绑死到某一种媒体来源; - 导出后的 GIF 能保存到系统相册;
- 已导出的 GIF 能直接拉起系统分享;
- 作品记录、草稿和主题偏好只保存在本地。
边界也很明确:
- 这不是云端素材平台,不提供跨设备同步;
- 不申请
INTERNET,也不实现上传分发; - 不持有全局相册写权限,而是每次保存都走系统确认;
- 分享只负责把 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.ets、StorageService.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 = '未选择素材,请重新选择真实素材';
}
}
这样做有两个直接收益:
- 所有编辑器都能围绕
sourceUris共用后续导出逻辑; - 当用户取消选择或 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 '已保存到系统相册';
这条路比“直接申请写相册权限”更稳的地方在于:
- 权限边界清晰,每次保存都由系统弹窗显式确认;
- 不需要在
module.json5增加额外的相册写入权限声明; - 用户取消时,应用拿到的是明确结果,而不是模糊失败;
- 更符合这个项目“本地隐私模式”的产品定位。
同时服务里还做了两层前置校验:
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 '已拉起系统分享';
这里没有做任何平台特定逻辑,也没有自己维护分享目标名单。原因很现实:
- 这个项目的目标是导出作品,不是经营分享生态;
- 系统分享天然适配设备上已有应用;
- 出错时可以明确回退为“没有可分享目标”或“文件不可访问”。
对于工具类 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();
页面启动时会分别恢复:
- 已导出的作品记录;
- 草稿配置;
- 深浅色主题偏好。
这样即使没有账号系统,用户也不会每次打开应用都从零开始。这是“本地优先”比“本地临时缓存”更完整的一步。
五、异常与边界处理
5.1 取消选择不是错误,而是正常分支
无论是图片、视频还是文档选择器,用户取消都不应该让页面留在半初始化状态。因此 pickSource() 捕获异常后会统一清空 sourceUris 并提示重新选择真实素材。这比保留旧素材更安全,避免用户误以为当前选择已经更新成功。
5.2 文件路径和 URI 必须统一做转换
项目内部既有选择器返回的 URI,也有测试素材写到沙箱后的本地路径。如果不统一转换,页面预览、相册保存、系统分享这三条链路会各自维护一套规则,最终很容易出现“页面能显示、分享失败”或者“作品存在、保存失败”的割裂体验。
5.3 保存和分享都必须先验证真实文件还在
作品列表保存的是元数据,不是文件句柄。用户清缓存、重新安装,或者后续清理导出目录后,记录可能还在,但文件已经没了。SaveAlbumService 和 ShareService 在真正执行前都做了存在性判断,这一步是工具类应用非常典型、但很容易漏掉的防线。
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.json5 无 INTERNET |
| 保存到相册走系统确认弹窗 | 通过 | showAssetsCreationDialog() 已接入 |
分享通过系统 Want 拉起 |
通过 | ohos.want.action.sendData 已接入 |
| 作品与草稿只保存在本地 | 通过 | Preferences 持久化已接入 |
| 空状态与异常路径可回退 | 通过 | 有清空记录与文件存在性校验 |
八、小结
“动图魔方”的素材链路并没有追求“权限越大越方便”,而是刻意反过来做:权限越小、边界越清晰,越适合本地优先工具。
这一篇真正解决的是三个工程问题:
- 如何在不扩权的前提下接入图片、视频和 GIF 素材;
- 如何把 URI、保存、分享统一成一个可复用闭环;
- 如何在没有登录态和网络能力的前提下,让工具仍然具备可持续使用的状态管理。
对 HarmonyOS 工具类应用来说,这比单独会用一个媒体 API 更重要。因为用户最终感知到的不是“你用了什么 Kit”,而是“我选完素材之后,能不能稳定导出、保存、分享,并且下次打开还在”。
九、下一篇衔接
下一篇会切到更底层的编码实现,正式进入普通技术拆解篇:动图魔方技术拆解 06:从 GIF89a 文件结构看动图编码器设计。前面三篇先把入口、抽帧和本地素材闭环讲清楚,后面再拆 Header、Logical Screen Descriptor、Graphic Control Extension 和 Image Descriptor,工程上下游会更容易对齐。
更多推荐

所有评论(0)