如果你在 Flutter 里处理过动态照片,应该对 motion_photos 这个名字不陌生。插件本身不大,原本在 iOS / Android 上跑得挺好,但只要把目标换成鸿蒙,很多人第一反应是“重新封装个 method channel 就能跑”,结果真联调时才发现:HEIC 解码、动态照片标记、媒体库权限,每一个点都在挑战预期。这篇文章记录的就是我把 motion_photos 三方库做鸿蒙化适配的完整过程,包括动态照片格式的底层逻辑、鸿蒙媒体资产接口的差异、HEIC 跨端解码的取舍,以及最终打通 Flutter 与鸿蒙原生层时踩过的坑。如果你打算在鸿蒙应用里解析 iPhone 或安卓手机导出的动态照片,或者想把现有 Flutter 插件迁移到鸿蒙,这篇基本能帮你少走两周弯路。
1. 为什么动态照片在鸿蒙上是个精密活
1.1 动态照片到底算什么“照片”
很多人以为动态照片就是一张会动的图片,像 GIF 一样。真正接触底层格式后你会知道,一张动态照片往往是一个“静态主图 + 短时视频轨”的组合打包结构。苹果的 Live Photo 是这样,安卓的 Motion Photo 也是这样,华为、小米的自家动态照片本质上没有跳出这个框架,只是封装方式不同。
主图通常采用 HEIC 编码,视频轨则记录按下快门前后的几秒钟画面。这种设计带来的问题很直接:你不能只靠 Image 解码库完成解析,还得把视频轨单独抽出来。HEIC 本身跨端支持又差,标准 Flutter Image 组件无法直接显示,所以动态照片解析从来不是单一技术点,而是“图片解码 + 视频抽取 + 数据封装”的连环任务。
1.2 鸿蒙媒体资产体系的“方言”
鸿蒙的相册访问接口跟 Android 和 iOS 都不一样,它把媒体文件抽象成了“媒体资产”而不是“文件路径”。你在 Android 上可以用 MediaMetadataRetriever 直接抽视频帧,在 iOS 上可以用 PHAsset 拿到 Live Photo 的资源组合,但鸿蒙这边是通过 PhotoAccessHelper 查询资源,再拿到 file asset 的 FD(文件描述符)去做后续解码。
更特殊的是,鸿蒙对动态照片有自己的标记体系。不是所有 HEIC 文件都会被判定为动态照片,它需要同时满足图片 + 视频轨 + 系统动态照片属性这几个条件。所以适配时首先要解决的问题是:如何在海量图片中精准识别出那些“真正的动态照片”。
单从这一点看,鸿蒙的媒体库更接近 iOS 的资源管理思路,但 API 形式又和 Android 类似,这就导致直接沿用旧插件的逻辑行不通,必须为鸿蒙单独写一套原生实现。
1.3 适配目标怎么定才不算跑偏
我在项目里给这个模块定下的目标很明确:在鸿蒙手机上,用原来 motion_photos 插件暴露给 Flutter 层的接口,完成动态照片的解析,返回给业务侧一张可显示的主图和一个可播放的视频轨。边界则画得很清楚:不负责动态照片的编辑、滤镜、合成,也不负责把普通照片变成动态照片。
为什么这么定?因为动态照片的完整生命周期里,解析是最容易被复用、也最容易被“平台方言”卡住的一段。只要把解析做成一个稳定的黑盒,业务层就能完全无视底层是 Android 还是鸿蒙。后续就算鸿蒙媒体库 API 升级,也只需要换原生层实现,Flutter 侧一行代码都不用改。
这个目标的隐含要求是:接口的入参最好统一用 URI 或 assetId,返回值统一用图片字节 + 视频路径,不暴露任何平台私有字段。后面我会详细说这个数据结构怎么设计。
2. 动手前先拆解 motion_photos 插件的原有实现
2.1 插件原本替我们做了什么
motion_photos 作为一个 Flutter 插件,核心工作可以拆成三步:识别动态照片标记、解析主图资源、提取视频播放地址或视频帧。
在 Android 端,插件通常利用 MediaMetadataRetriever 读取 metadata,或者直接扫描 URI 的 MIME type 以及关联文件来判断动态照片。拿到主图后会转成 byte array,通过 MethodChannel 回传 Flutter;视频部分则会返回一个临时文件路径或者 content URI。
在 iOS 端,插件则依赖 PHAsset 资源列表中的 adjustment 资源和原始资源组合,用 PHImageManager 请求图片数据,同时把 Live Photo 的 paired video asset 转成一个 AVAsset 或临时 mp4 路径。
这个结构本身不算复杂,但插件的“平台能力边界”决定了鸿蒙适配不能只做一个小修小补。
2.2 鸿蒙能力与原版的差异到底在哪里
鸿蒙这边的媒体能力其实很完整,但接口体系和 Android/iOS 完全不同。
以资源识别为例:Android 可以用 MediaMetadataRetriever.METADATA_KEY_VIDEO_FRAME 或第三方库去嗅探视频轨;iOS 可以用 PHAsset.mediaSubtypes 里的 PHAssetMediaSubtypePhotoLive;鸿蒙则在 PhotoAsset 中提供了动态照片相关属性(不同 SDK 版本字段名可能略有差异),需要先查这个标记,再决定是否继续解析。
再比如解码:Android 有 BitmapFactory,iOS 有 UIImage,鸿蒙则用 OH_ImageSource 创建 PixelMap。名字、流程、错误处理方式都不一样,直接套旧代码的结果就是编译都过不了。
我踩过最典型的一个坑是:在 Android 上拿视频轨时,MediaMetadataRetriever 能直接给出一帧缩略图,但鸿蒙的 ImageSource 只能解码主图,视频轨必须用 AVDemuxer 去做 track 抽取。这两个能力完全不在一个 API 模块里,如果没意识到这一点,很容易以为“解析不了视频”。
2.3 我为什么没有选择“另起炉灶”
有人可能会问,既然鸿蒙有自己完整的媒体库 API,为什么不直接用 ArkTS 写一套独立的动态照片解析逻辑,还要在 Flutter 里包一层?
这个问题的核心是项目架构。我当时所在的项目是一个 Flutter 主工程,动态照片解析只是其中一个媒体能力模块,而且现有业务层已经深度依赖 motion_photos 的接口。如果另起炉灶,业务层要重写,数据流要改,联调成本会成倍增加。
所以最合理的方式是保留 motion_photos 的对外接口,在鸿蒙侧按照同样的 method channel 协议实现一套原生逻辑。业务侧不用感知底层是 Android、iOS 还是鸿蒙,仍然调用同一个解析方法。这种“协议兼容、实现隔离”的思路,其实是 Flutter 插件做多端适配比较通用且稳妥的做法。
3. 鸿蒙侧动态照片解析:核心实现与避坑
3.1 权限与媒体库查询:先拿到资源再说
任何媒体解析都绕不开权限。在鸿蒙上读取相册图片和视频,需要在 module.json5 里声明ohos.permission.READ_IMAGEVIDEO(不同 API 版本权限名可能有差异,建议以当前 SDK 的权限列表为准),然后在页面或 UIAbility 中通过requestPermissionsFromUser申请动态权限。
这里有一个很容易忽略的点:动态照片本身既是图片又是视频,权限申请时如果只申请图片权限,部分系统版本会因为视频轨而拒绝访问。保险的做法是同时申请图片和视频的读权限,并且在权限回调里做二次校验。
拿到权限之后,要查询动态照片资源。以 HarmonyOS NEXT API 12 的写法为参考,大致流程是:
import photoAccessHelper from '@ohos.file.photoAccessHelper'; import { image } from '@kit.ImageKit'; // 示例代码,API 版本不同字段名会有差异 const context = getContext(this); const phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context); let queryOptions = new photoAccessHelper.PhotoQueryOptions(); queryOptions.uri = 'file://media/Photo/...'; // 或通过 assetId 构造 let photoAsset = await phAccessHelper.getAssetByUri(queryOptions.uri);拿到 PhotoAsset 后,先判断动态照片标记。以常见实现为例,可以查看photoAsset.photoType或photoAsset.motionPhoto,如果标记为 true,再走后续解析流程。
3.2 从 PhotoAsset 中取出 HEIC 主图
动态照片的主图一般是 HEIC 编码。鸿蒙侧可以通过 ImageSource 直接解码 HEIC,不需要引入额外的三方解码库。
基本思路是:用photoAsset.open('r')拿到 FD(文件描述符),再通过 ImageSource 创建 PixelMap。示例代码如下:
let fd = await photoAsset.open('r'); let imageSource = image.createImageSource(fd); let pixelMap = await imageSource.createPixelMap({ desiredSize: { width: 1080, height: 1080 }, desiredPixelFormat: image.ImagePixelFormat.RGBA_8888 }); // pixelMap 可以再编码成 JPEG/PNG 字节,用于跨端传输 let encodedImage = await imageSource.createImagePacker(); let data = await encodedImage.packing(pixelMap, { format: 'image/jpeg', quality: 92 });为什么我建议在鸿蒙侧先把 HEIC 转成 JPEG?因为标准 Flutter 引擎的 Image 组件不支持 HEIC 直接展示,如果非要传 HEIC 字节出去,Flutter 侧还得再引一个 heic 解码库,解码性能、内存控制、错误处理都会多出很多不可控因素。鸿蒙系统自带 HEIC 解码,这里的转换成本比较低,换来的是 Flutter 侧完全无感。
如果你希望保留原始 HEIC 字节用于无损传输,那就不要做 JPEG 编码,而是直接读源文件字节。这种方案可以加一个returnOriginal参数,让调用方自己决定。
3.3 提取视频轨:动态照片的心脏
动态照片能“动”起来,全靠视频轨。鸿蒙侧做视频轨抽取,需要用到 AVDemuxer 和 AVMuxer 的组合。流程是先从 FD 创建数据源,然后遍历轨道,找到视频轨道后把 AVPacket 写入新的 mp4 容器。
这里没有现成的“一句话 API”,必须自己处理轨道循环、时间戳、关键帧等逻辑。代码思路如下:
import { media } from '@kit.MediaKit'; let avSource = await media.createAVSource(fd); let trackInfo = await avSource.getTrackInfo(); let videoTrackIndex = -1; for (let i = 0; i < trackInfo.length; i++) { if (trackInfo[i].trackType === media.AVMediaType.AV_MEDIA_TYPE_VIDEO) { videoTrackIndex = trackInfo[i].trackIndex; break; } } let avDemuxer = await media.createAVDemuxer(fd); let avMuxer = await media.createAVMuxer(tmpFilePath, media.ContainerFormatType.CFT_MPEG_4); let trackDesc = { trackType: media.AVMediaType.AV_MEDIA_TYPE_VIDEO, codecType: trackInfo[videoTrackIndex].codecType, trackIndex: 0 }; await avMuxer.addTrack(trackDesc); await avDemuxer.selectTrack(videoTrackIndex); let packet = new media.AVPacket(); while (avDemuxer.readSample(videoTrackIndex, packet) === media.AVCodecServiceErrorCode.AV_OK) { await avMuxer.writeSample(0, packet); } await avMuxer.stop();这段示例中,我把视频轨直接转封装成了一个 mp4 文件。转封装的好处是保留了原始视频的编码格式和帧率,不会因为重新编码而损耗画质,速度也快很多。代价是生成的文件可能比较大,需要在用完临时文件后及时清理。
3.4 把解析结果封装成 Flutter 通道的数据结构
原生层完成解析后,需要把结果通过 MethodChannel 返回 Flutter。这里的数据结构设计很关键,我最终用的是下面的格式:
class MotionPhotoResult { final Uint8List imageBytes; // 主图,已转成 JPEG 字节 final String videoPath; // 动态照片视频的临时文件路径 final bool isMotionPhoto; // 是否真的解析出了动态照片 }返回 JSON 或 map 时,imageBytes用Uint8List传输,videoPath用字符串传给 Flutter 侧的 video_player。
为什么 video 不传字节?因为动态照片的视频通常有几秒钟,体积可能是几 MB 到几十 MB,直接通过 MethodChannel 传字节会导致 UI 卡顿和数据通道堵塞。传路径让 Flutter 侧通过文件访问,是最稳妥、也最省内存的方案。
4. HEIC 跨端实战:从鸿蒙原生到 Flutter 的完整链路
4.1 跨端传输方案选型与取舍
做跨端数据流设计时,我列过三个方案:
第一个方案是MethodChannel直接返回字节。优点是简单直接,适合小图、低分辨率缩略图;缺点是 Flutter 主岛对通道消息的大小很敏感,超过 2MB 就可能引发丢帧甚至 OOM。
第二个方案是EventChannel分片传输。适合大文件,但开发复杂度高,需要自己处理流控和分段重组,收益不成比例。
第三个方案是文件路径传递。适合视频和大图,鸿蒙原生层先写好临时文件,Flutter 侧通过路径读取,两边的生命周期还要自己管理。
最终我采用的是混合方案:主图在鸿蒙侧压缩到 1080p 以内,转成 JPEG 后不超过 1.5MB,直接走 MethodChannel;视频一律写临时文件,Flutter 侧只拿路径。
4.2 Flutter 侧 Dart 代码如何对接
Flutter 侧要做的事情其实很少,核心是封装一个与 motion_photos 原接口风格接近的方法。代码如下:
import 'package:flutter/services.dart'; class MotionPhotoHarmony { static const MethodChannel _channel = MethodChannel('motion_photos_harmony'); static Future<MotionPhotoResult?> parse(String uri) async { try { final result = await _channel.invokeMapMethod('parseMotionPhoto', { 'uri': uri, }); if (result == null) return null; return MotionPhotoResult( imageBytes: result['imageBytes'] as Uint8List?, videoPath: result['videoPath'] as String?, isMotionPhoto: result['isMotionPhoto'] as bool? ?? false, ); } on PlatformException catch (e) { // 记得抛给业务层,或走降级逻辑 return null; } } }这里我特别建议:isMotionPhoto不要作为“是否解析成功”的唯一判断依据。有一次我们拿到的照片确实有动态照片标记,但视频轨已经损坏,解析出来的 videoPath 是空的,如果业务侧只看 isMotionPhoto,就会展示一张不会动的“动态照片”,体验很怪。
4.3 内存与文件缓存策略
跨端传输只是第一步,真正让动态照片模块稳定运行,内存和文件管理同样重要。
主图解码时,避免一次加载全尺寸 HEIC。我在 ImageSource 创建 PixelMap 时限制了 desiredSize,通常按 1080p 处理,这样内存占用大概是全尺寸解码的四分之一甚至更少。如果你需要缩略图,可以限制到 512×512,速度会更快。
临时视频文件的管理是个容易被忽视的坑。鸿蒙原生层每次解析都生成了一个 mp4,如果不清除,长期运行后缓存会越来越大。我建议在 Flutter 侧保留文件路径的引用,业务层播放完,或者页面销毁时主动删除;原生层也可以做一个 LRU 缓存,超过限制自动清理。
另外要注意,photoAsset.open('rw')打开 FD 后一定要及时关闭。动态照片的 FD 数量有限,泄漏超过上限,后续查询资源会直接失败。这个错误不会立刻崩,但会在持续使用后冒出来,排查起来非常隐蔽。
5. 常见问题排查与性能调优实录
5.1 权限明明申请了却拿不到资源
这类问题几乎每个鸿蒙适配项目都会遇到。最常见的三个原因:
- 权限声明写在
module.json5里,但忘记触发运行时权限申请。 - 申请权限时只申请了图片权限,导致动态照片中的视频轨部分无法访问。
- 在 UIAbility 生命周期中申请权限的时机不对,被系统拒绝。
排查方法是:打开设置里的应用信息,看权限列表是否真的包含了媒体库读取权限;同时加一段隐私权限检查日志,动态打印授权状态。
5.2 motionPhoto 标记为 false 但实际确实存在视频轨
这种情况在部分鸿蒙版本上遇到过。照片可能是第三方应用写入的动态照片,虽然内容包含视频轨,但系统的motionPhoto属性没有被正确赋值。
解决办法是:不能完全依赖motionPhoto一个字段,还要做兜底判断。我的做法是:先看系统属性,如果为 true 就正常解析;如果为 false,再尝试创建一个 ImageSource,如果失败或者检查 FD 里存在视频轨道,就把它当作隐藏动态照片处理。
5.3 HEIC 解码黑屏或花屏
黑屏问题大多是因为 PixelMap 的desiredPixelFormat设置不正确。有些设备上默认格式不是 RGBA_8888,解码出来的 PixelMap 颜色通道错位,传到 Flutter 后自然显示异常。解决方法很直接:强制指定 RGBA_8888,同时在 Flutter 侧用ui.decodeImageFromPixels验证字节结构。
花屏问题则可能出在 JPEG 编码质量参数上。遇到质量参数设置过高导致编码失败的案例,可以改用默认质量,或者退到 PNG 格式。
5.4 大数据传输卡死与丢帧
MethodChannel 传大数据卡顿,基本是字节体积过大导致的。我第一次传输一张 4K HEIC 转出的 8MB JPEG,Flutter UI 直接卡了两秒。后面改成 1080p 压缩后,字节控制在 1MB 左右,卡顿感才基本消除。
如果你确实需要完整数据,建议不要走 MethodChannel。可以先把数据写到应用缓存目录,再传路径。这样 Flutter 侧用文件读取,性能会好很多。
5.5 性能测试清单
我整理过一套简单的动态照片解析测试清单,分享给团队同学使用:
| 测试项 | 测试场景 | 预期指标 |
|---|---|---|
| 首帧解码耗时 | 1080p 动态照片主图 | 小于 800ms |
| 视频轨转封装耗时 | 5 秒 1080p 动态照片 | 小于 2s |
| 内存峰值 | 同时解析 3 张动态照片 | 不超过 300MB |
| 临时文件大小 | 单段视频 | 不大于原文件体积 |
| 连续解析稳定性 | 连续解析 50 张 | 无 FD 泄漏、无 OOM |
这套清单在真机上跑一遍,基本能覆盖大多数稳定性问题。
6. 我的调优心得与后续扩展方向
6.1 踩坑后沉淀的几条铁律
这次鸿蒙化适配做完,我最大的心得是:不要试图在 Flutter 侧解决平台能力缺失问题。
HEIC 解码、视频轨道抽取、动态照片属性判断,这些能力都是系统媒体库的一部分,Flutter 侧介入越深,复杂度越高。把底层逻辑留给鸿蒙原生层,在通道层只传递结果,是维护成本最低的方案。
第二条铁律是:给业务层留好“降级路径”。动态照片解析天生依赖系统状态,用户可能只授权了部分媒体,可能照片损坏,可能权限被系统回收。Flutter 侧如果只解析成功和失败两种状态,遇到损坏数据就会很被动。我们后来加了isMotionPhoto=false但主图仍然返回的降级逻辑,让业务层至少能展示静态照片,而不是白屏。
第三条铁律:临时文件必须要有生命周期管理。鸿蒙的临时目录不是无底洞,如果每次动态照片解析都生成一个 mp4 但没人清理,跑上一天存储就满了。
6.2 后续可以进一步做的事
能力上,动态照片模块后续可以继续扩展。比如增加缩略图缓存,用一个小尺寸 JPEG 加速列表页展示;比如支持视频轨转 GIF,满足分享需求;再比如对多张动态照片做批量解析,通过并发队列控制同时解码的数量,避免大量动态照片同时出现时把内存打满。
架构上,可以考虑把方法通道升级成 federated plugin 结构,把鸿蒙实现单独放进motion_photos_harmony包中,这样主项目可以按需依赖,维护边界更清晰。
这次适配真正让我意识到,所谓“鸿蒙级精密媒体资产专家”,并不是把 API 背熟,而是要在不同系统之间找到稳定的协议层,让上层业务不被平台差异绑架。动态照片只是一个开始,后面还有更复杂的媒体能力在等着继续打磨。