
SEO 信息SEO 标题【共创季稿事节】动图魔方技术拆解 03HarmonyOS 6.1 本地优先 GIF 工具素材链路实战SEO 摘要基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”拆解一个不依赖登录态、不申请网络权限的 GIF 工具如何完成本地优先素材闭环PhotoViewPicker选择图片和视频DocumentViewPicker接入 GIF/文件 URIshowAssetsCreationDialog保存到系统相册startAbility拉起系统分享并用Preferences持久化作品与草稿。关键词HarmonyOS, ArkTS, PhotoViewPicker, DocumentViewPicker, URI, showAssetsCreationDialog, 系统分享, Preferences, GIF 工具文章封面https://i-blog.csdnimg.cn/direct/03cd5328a2814281895ddb2cf61001d2.png投稿方向HarmonyOS 6.1 创新特性适配实战项目环境HarmonyOS SDK6.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 } ]三、链路拆分从素材入口到本地闭环这条本地优先链路在项目里被拆成了四层层级责任对应文件素材入口层统一选择视频、图片、文档 URIentry/src/main/ets/services/MediaService.ets编辑页编排层根据功能入口调用不同选择器并保存sourceUrisentry/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(): PromiseMediaPickResult { 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(): PromiseMediaPickResult { 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): PromiseMediaPickResult { 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(): Promisevoid { 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工程上下游会更容易对齐。