L
← 返回技术文章
约 12 分钟

在 APP 中上传和展示 Live Photo 的实践

从系统相册提取、统一媒体模型、原子上传、压缩策略到跨平台渲染,梳理 Live Photo 在 Flutter 应用中的完整工程链路。

FlutterLive Photo移动端媒体处理

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,主要需要解决几个问题:

  1. 如何从系统相册中完整提取静态图和动态视频;
  2. 如何在业务层统一描述普通图片、视频、Live Photo 和 Motion Photo;
  3. 如何分别上传 Live Photo 的多个文件,并在上传后重新建立它们之间的关联;
  4. 如何根据设备能力和页面场景,选择不同的展示方式;
  5. 如何控制图片和视频的尺寸,避免动态图片带来过高的加载成本。

从系统相册中获取 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 中拆分为两条上传链路:

  1. 静态主图走 trace image 上传;
  2. 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 组装后提交给业务服务器。

如果不是直传方案,也可以使用 diohttp,通过 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

简单来说,就是先把下面两个问题分开:

  1. 这是什么媒体;
  2. 当前设备和当前页面适合怎么展示它。

页面本身只需要按照 MediaRenderPlan 渲染,不再关心底层协议和平台差异。

例如,一张 Motion Photo 可以拥有不同的展示计划:

  • 在推荐流中,只显示静态 poster,避免滚动时为每一个条目创建视频播放器;
  • 在详情页中,允许用户点击播放 companion 视频;
  • 在 iOS 上,如果资源是完整 Live Photo,并且本地原始文件可用,可以调用原生 Live Photo 能力;
  • 在 Android 上,如果资源符合 Motion Photo 格式,可以使用 Android 对应的动态图片展示能力;
  • 在 Web 或能力不足的设备上,退回到 poster 加普通视频播放;
  • 如果 companion 文件缺失,则只显示 poster,但仍然可以显示 Live / Motion badge,表明它原本是一张动态图片。

因此,MediaRenderPlan 本质上是一个已经计算完成的渲染决策结果。

页面只需要知道:

  • 当前显示静态图还是动态内容;
  • 是否允许点击播放;
  • 是否使用原生能力;
  • 是否需要展示动态图片标识;
  • 是否需要降级。

不需要再从原始媒体字段中临时推导。


MediaAssetDraft 与 MediaRenderPlan 的关系

MediaAssetDraftMediaRenderPlan 都是在统一业务语义,但它们解决的是两个不同阶段的问题。

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 表面上只是一张会动的图片,但真正做进应用之后,它更像一个小型媒体系统。图片、视频、协议、对象存储、格式兼容和渲染策略一个都不会缺席。功能看起来轻巧,背后照例站着一群不肯下班的数据结构。