【共创季稿事节】动图魔方技术拆解 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,工程上下游会更容易对齐。
