在 APP 中上传和展示 Live Photo 的实践
从系统相册提取、统一媒体模型、原子上传、压缩策略到跨平台渲染,梳理 Live Photo 在 Flutter 应用中的完整工程链路。
Live Photo 是什么?
在 Flutter 中实现 iOS 实况图(Live Photo)的上传,本质上是将同一时刻拍摄的静态图片(.HEIC 或 .JPEG)与动态视频(.MOV)组合成一个完整的媒体资产,上传至服务器。
因此,Live Photo 看起来是一张图片,但从文件结构上说,它并不是单独的一个文件,而是由静态图、动态视频以及相关元数据共同组成的一组媒体资源。
安卓中的 Motion Photo 在具体格式和实现上可能与 iOS Live Photo 存在差别,但本质也接近:将一张静态图片和一段短视频关联起来,对外表现为一个动态图片实体。
在应用中管理 Live Photo 的问题
普通图片通常只有一个文件,但 Live Photo / Motion Photo 本质上是:
- 静态图片;
- 动态视频;
- 用于描述两者关联关系的元数据。
如果只上传静态图片,用户对动态图片的感知就会丢失;如果简单地把它当成普通视频处理,又会破坏图片场景里的使用体验,例如 Feed、详情页和聊天预览。
在这些场景中,用户通常仍然希望它首先表现为一张图片,而不是一个带进度条和播放控件的视频。
因此,在应用中支持 Live Photo,主要需要解决几个问题:
- 如何从系统相册中完整提取静态图和动态视频;
- 如何在业务层统一描述普通图片、视频、Live Photo 和 Motion Photo;
- 如何分别上传 Live Photo 的多个文件,并在上传后重新建立它们之间的关联;
- 如何根据设备能力和页面场景,选择不同的展示方式;
- 如何控制图片和视频的尺寸,避免动态图片带来过高的加载成本。
从系统相册中获取 Live Photo
Flutter 官方提供的图片选择器目前不能完整提取 Live Photo,因此更适合使用专门的相册资源管理库,例如 photo_manager。
在系统相册中,Live Photo 对用户表现为一个整体,但在程序中,需要通过 photo_manager 分别获取它对应的图片文件和视频文件路径。
获取到的也不仅仅是两个文件。
原始资源中还可能包含完整的 EXIF 信息,例如:
- GPS 位置;
- 拍摄时间;
- 设备型号;
- 图片尺寸;
- 编码格式;
- HDR 能力;
- 图片与 companion 视频之间的关联信息。
不过,考虑到用户隐私和业务实际需要,开发者通常不应该把整包 EXIF 原样上传到服务器,而是只保留展示和播放所需要的白名单字段,例如:
- 尺寸;
- MIME 类型;
- HDR / Motion 能力;
- companion key;
- 上传容器;
- 编码信息。
GPS、设备型号等与核心展示无关的信息,则可以在客户端提取阶段直接舍弃。毕竟支持一张会动的图片,没有必要顺手把用户在哪拍的、用什么设备拍的也一起抄家式上传。
使用统一媒体模型描述资源
拿到系统相册资源之后,不建议让后续业务代码继续直接处理文件路径和平台格式,而是先把资源转换为统一的业务模型,例如 MediaAssetDraft。
MediaAssetDraft 可以同时描述:
- 普通图片;
- 普通视频;
- Live Photo;
- Motion Photo;
- HDR 图片;
- 其他包含 companion 文件的动态媒体。
这样可以避免业务代码到处判断:
- 这是普通图片还是 Live Photo;
- 这是 iOS Live Photo 还是 Android Motion Photo;
- 它有没有 companion 视频;
- 它是不是 HDR;
- 它是否需要单独生成封面;
- 它包含一个文件还是多个文件。
这些媒体都先被转换为同一个数据结构,区别只体现在模型内部的字段上。
例如,普通图片可能只有一个静态文件路径;Live Photo 则可能同时包含静态图片路径和 companion 视频路径,并额外带有媒体类型、封面、尺寸和编码信息。
类似“封面”这样的字段也并不是动态图片独有。在一些成熟的软件应用中,普通视频、GIF 或经过转码的图片同样可能拥有独立封面。因此,这些能力更适合作为统一媒体模型的一部分,而不是为 Live Photo 单独堆一套业务逻辑。
也就是说,业务层看到的应该是统一语义,而不是底层文件格式。
Live Photo 的上传流程
完整的动态图片应当被视为一个原子资产。
也就是说,一张 Live Photo 的主图和 companion 视频必须共同成功,才能算上传完成。如果主图上传成功,但 companion 视频没有成功返回 storage key,就应该认为整个资产上传失败,而不应该静默降级成普通图片。
静默降级虽然看起来比较“健壮”,实际上很容易制造数据不一致:用户明明选择了一张 Live Photo,最终服务端却只保存了一张普通图片,并且这个变化没有被明确感知。软件最擅长的事情之一,就是用“容错”这个优雅词汇掩盖悄悄丢数据的事实。
在当前实现中,Live Photo 的上传并不是单个请求,而是从 MediaAssetDraft 中拆分为两条上传链路:
- 静态主图走 trace image 上传;
- companion 视频走 trace video 上传。
两路上传分别完成后,前端拿到对象存储服务返回的两个 storage key,再在协议组装阶段把它们组合为一个完整的媒体描述符,例如 MessageContent.document.media。
随后,前端将这个已经组装好的媒体协议对象发送给业务服务器。后端负责对协议进行校验、归一化和清洗,并最终持久化。
完整流程可以表示为:
系统相册中的 Live Photo
│
▼
photo_manager
│
▼
MediaAssetDraft
│
├───────────────────────┐
│ │
▼ ▼
静态主图文件 companion 视频文件
│ │
▼ ▼
trace image 上传 trace video 上传
│ │
▼ ▼
image storage key video storage key
│ │
└───────────┬───────────┘
▼
前端组装媒体协议
MessageContent.document.media
│
▼
发送给业务后端
│
▼
校验 / 归一化 / 清洗 / 持久化
这个过程中,MediaAssetDraft 负责描述“用户选择了什么资源”,上传模块负责把其中的不同文件分别送到对象存储,而协议组装层负责在拿到 storage key 后重新建立它们之间的关联。
如果采用的是 S3 或其他对象存储直传方案,前端可以先分别上传两个文件,再将 storage key 组装后提交给业务服务器。
如果不是直传方案,也可以使用 dio 或 http,通过 multipart/form-data 一次性将图片和视频发送给后端,再由后端完成上传和协议组装。
两种方式的差别主要在于文件上传发生在哪里,但无论采用哪种方式,Live Photo 都应该作为一个完整资产被提交和持久化。
动态图片的压缩策略
虽然希望尽可能完整地保留媒体资源,但现在手机厂商互相卷成像质量已经卷到不知天地为何物,文件大小也开始变得夸张。
以本人自用的 iPhone SE 3 为例,这已经是一款发布约四年的入门机型,但一张原始 Live Photo 的静态图片尺寸也能达到 3024 × 4032,文件大小约为 3 MB。
如果加上 companion 视频,一张动态图片的完整资源可能达到数 MB。在普通家用网络环境下,初次加载时间很容易超过一秒。如果 Feed 中连续出现多张动态图片,网络和内存开销都会迅速膨胀。
根据将图片上传到微信再下载的试验结果,可以推理出一种可供参考的优化策略:
- 普通图片和 Live Photo 的 still 图在上传前进入压缩准备流程;
- 动态图片的 still 图使用相对保守的
2560长边; - companion 视频导出为上传用的 MP4;
- companion 视频设置
10 MB作为最大约束; - 如果重编码后的文件并没有比原文件更小,则保留原文件。
以原始 Live Photo 总体积只有 3 MB 左右的情况为例,实际处理后的结果可能是:
- still 图压缩后为几百 KB 到一两 MB;
- companion 视频约为 1 到 3 MB;
- 如果压缩结果不比原文件更小,则记录
reason=not-smaller并跳过替换; 10 MB只是用于防止某些 Motion Photo 或异常长 companion 视频的大小失控。
这里的核心并不是无条件压缩,而是根据实际收益决定是否替换原文件。
如果原文件本来就已经足够小,重新编码不仅不会减少体积,反而可能损失画质、增加处理时间。这种情况下,强行压缩只是为了让 CPU 显得自己参与过工作。
展示端的统一决策
上传解决的是“怎么保存”,展示解决的是“现在应该怎么播放”。
由于 Android、iOS、Web 和不同设备之间的媒体能力存在差异,展示层不应该直接到处判断:
这是 iOS 吗?
这是 Android 吗?
这是 Live Photo 吗?
当前页面是不是详情页?
本地文件还在吗?
设备支持原生播放吗?
更合适的做法,是根据媒体描述、平台能力和当前页面场景,提前生成一个 MediaRenderPlan。
简单来说,就是先把下面两个问题分开:
- 这是什么媒体;
- 当前设备和当前页面适合怎么展示它。
页面本身只需要按照 MediaRenderPlan 渲染,不再关心底层协议和平台差异。
例如,一张 Motion Photo 可以拥有不同的展示计划:
- 在推荐流中,只显示静态 poster,避免滚动时为每一个条目创建视频播放器;
- 在详情页中,允许用户点击播放 companion 视频;
- 在 iOS 上,如果资源是完整 Live Photo,并且本地原始文件可用,可以调用原生 Live Photo 能力;
- 在 Android 上,如果资源符合 Motion Photo 格式,可以使用 Android 对应的动态图片展示能力;
- 在 Web 或能力不足的设备上,退回到 poster 加普通视频播放;
- 如果 companion 文件缺失,则只显示 poster,但仍然可以显示 Live / Motion badge,表明它原本是一张动态图片。
因此,MediaRenderPlan 本质上是一个已经计算完成的渲染决策结果。
页面只需要知道:
- 当前显示静态图还是动态内容;
- 是否允许点击播放;
- 是否使用原生能力;
- 是否需要展示动态图片标识;
- 是否需要降级。
不需要再从原始媒体字段中临时推导。
MediaAssetDraft 与 MediaRenderPlan 的关系
MediaAssetDraft 和 MediaRenderPlan 都是在统一业务语义,但它们解决的是两个不同阶段的问题。
MediaAssetDraft 负责描述:
这是什么。
它主要服务于资源选择、文件处理、压缩和上传阶段。
MediaRenderPlan 负责描述:
它现在应该怎么展示。
它主要服务于 Feed、聊天、详情页和跨平台渲染阶段。
可以将完整的数据流理解为:
系统相册资源
│
▼
MediaAssetDraft
│
├── 文件压缩与格式准备
│
├── 静态图 / 视频分别上传
│
└── 前端组装媒体协议
│
▼
服务端媒体数据
│
▼
结合平台能力与页面场景
│
▼
MediaRenderPlan
│
▼
Feed / Chat / Detail / Web
MediaAssetDraft 的内部字段负责解释媒体本身是什么,MediaRenderPlan 负责决定它在当前环境中怎么展示。
这两部分分开之后,上传协议才能保持稳定,UI 中的判断逻辑也不会散落得到处都是。
其他一些关于格式的问题
HEIC 是苹果在 iOS 11 之后开始广泛使用的图片格式。iPhone 7 及后续设备在满足相关系统设置时,可以将照片默认存储为 HEIC。
和 JPG 相比,HEIC 通常可以在相近画质下占用更少的存储空间,同时还支持更丰富的图像信息。
HEIC 可以在 iOS 11、macOS High Sierra 10.13 及更新版本中使用,但在部分 Windows 环境中,系统自带看图工具可能无法直接打开,需要额外安装对应的图像扩展或在上传阶段进行格式转换。
需要注意的是,HEIC 本身只是静态图片格式,并不等于 Live Photo。
Live Photo 是由静态图片、动态视频和关联信息组成的完整媒体资产。它的静态图片可以是 HEIC,也可以在某些情况下使用 JPEG;对应的 companion 视频通常为 MOV,上传后也可以根据业务兼容性转码为 MP4。
安卓的 Motion Photo 和 iOS Live Photo 在文件封装、元数据和原生播放能力上可能存在差别,但从应用业务模型来看,都可以抽象为:
静态 poster
+
companion 视频
+
关联元数据
只要在获取阶段正确识别资源,在上传阶段保留两者关系,并在展示阶段根据平台能力生成对应的渲染计划,就不需要让所有业务页面分别理解这些平台格式的具体差异。
总结
在应用中支持 Live Photo,并不只是让图片“动起来”,而是需要建立一套完整的媒体处理链路:
提取相册资源
↓
转换为 MediaAssetDraft
↓
分别处理和上传静态图、companion 视频
↓
前端使用 storage key 组装媒体协议
↓
后端校验并持久化
↓
生成 MediaRenderPlan
↓
根据页面和设备能力渲染
其中最关键的两个抽象是:
MediaAssetDraft:统一描述媒体是什么;MediaRenderPlan:统一决定媒体怎么展示。
前者避免上传和业务逻辑被平台文件格式绑架,后者避免 UI 到处散落设备判断和降级逻辑。
Live Photo 表面上只是一张会动的图片,但真正做进应用之后,它更像一个小型媒体系统。图片、视频、协议、对象存储、格式兼容和渲染策略一个都不会缺席。功能看起来轻巧,背后照例站着一群不肯下班的数据结构。