简介:这份资源是面向iOS开发者的Swift实况图制作实战Demo,聚焦Live Photo的合成、展示与预览全流程,适合已掌握Swift基础、希望深入Photos框架与图像视频处理的中级开发者。压缩包共21个文件,约4.37MB,以5个swift源码文件为核心,配合storyboard界面、plist配置、json与xcassets资源,另含1个jpeg静态图与1个mp4视频素材,可直接在Xcode工程中运行调试。Demo围绕PHLivePhoto对象展开,涵盖素材准备、LivePhotoContent合成、PHLivePhotoRequestOptions参数配置、PHImageManager请求创建、UIImageView与PHLivePhotoView展示预览,以及通过PHAssetChangeObserver监听制作进度等关键环节,并封装了完整使用案例。目前已有1332人学习下载,读者可借此理解实况图由JPEG、MOV与元数据构成的结构,掌握视频裁剪、尺寸匹配与动态壁纸扩展思路,快速将Live Photo能力集成到自己的iOS应用中。
1. 实况图制作 Demo:把 Live Photo 的生成链路拆开看
iOS 上做 Live Photo 这件事,很多人第一反应是「相册里拍的那种会动的照片」,但真到要自己生成一张实况图时,往往卡在资源配对和元数据写入上。这个 swift-livePhoto-实况图制作demo 就是冲着这个场景来的:它把一张静态图加一段短视频,合成一张系统相册能识别、能长按播放的实况图。适合两类人——一类是 App 里要加「图片转实况」功能的 iOS 开发,另一类是单纯想搞懂 Live Photo 底层结构的 Swift 学习者。它不依赖复杂框架,核心就是 Photos 框架加 AVFoundation,把配对逻辑和元数据这两块讲透,跑起来门槛不高。
2. 项目结构与技术选型:为什么是 Photos + AVFoundation
2.1 目录骨架与模块职责
这个 Demo 的目录结构不复杂,但每个文件职责分得很清楚,我按常见工程组织方式梳理一下:
swift-livePhoto-实况图制作demo/ ├── LivePhotoDemo/ │ ├── AppDelegate.swift // 应用入口,权限申请时机 │ ├── ViewController.swift // 主界面,选图/选视频/生成按钮 │ ├── LivePhotoMaker.swift // 核心合成类,配对 + 元数据写入 │ ├── AssetPicker.swift // 相册资源选择封装 │ └── Info.plist // 相册读写权限描述 └── LivePhotoDemo.xcodeprojLivePhotoMaker.swift是整个项目的核心,其他文件都是围绕它做输入输出。AssetPicker.swift负责从相册拿图片和视频的PHAsset,ViewController只做 UI 触发和结果展示。这种拆分的好处是,你要把合成逻辑搬到自己的项目里,直接拖LivePhotoMaker.swift就行,不用连带 UI 一起搬。
技术选型上,实况图的本质是「一张 JPEG 加一段 MOV,通过 asset identifier 关联」,所以绕不开两个框架:Photos 负责把成品写回相册并让系统识别为 Live Photo,AVFoundation 负责读取视频轨道、必要时做裁剪或转码。没有用第三方库,因为系统框架已经够用,引入额外依赖反而增加包体积和审核风险。
2.2 实况图的资源配对原理
先讲清楚原理,后面代码才好懂。一张 Live Photo 在系统里其实是两个文件:
| 资源类型 | 格式 | 作用 |
|---|---|---|
| 静态图 | JPEG/HEIC | 长按前显示的画面 |
| 动态视频 | MOV (QuickTime) | 长按后播放的内容 |
两者靠一个叫asset identifier的字符串绑定,这个字符串同时写进图片的元数据和视频的元数据里。系统相册扫描时,发现两个资源的 identifier 一致,就把它们当成一张实况图展示。所以生成实况图的核心动作就两步:生成一个唯一 identifier,分别写进图片和视频的元数据,然后作为一个整体保存。
注意:identifier 必须是 UUID 格式的字符串,自己随便拼一个固定字符串也能用,但多张图之间会串,务必每次生成新的。
2.3 权限与工程配置
在动手写合成逻辑前,Info.plist里得先声明权限,否则选图和写相册会直接失败:
<key>NSPhotoLibraryUsageDescription</key> <string>需要访问相册以选择图片和视频</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>需要写入权限以保存生成的实况图</string>NSPhotoLibraryUsageDescription是读取权限,选图选视频要用;NSPhotoLibraryAddUsageDescription是写入权限,保存成品要用。两个都加,缺一个都会在对应操作时崩溃或静默失败。申请时机建议放在用户第一次点「选择图片」时,而不是 App 启动就弹窗,否则用户还没理解你要干嘛就被拒绝,后面再要权限就麻烦了。
3. 核心合成逻辑:从选资源到写入相册的完整链路
3.1 生成唯一 asset identifier
第一步是造一个 identifier,用 UUID 最稳妥:
// 生成实况图的唯一标识,图片和视频共用这一个 func makeAssetIdentifier() -> String { return UUID().uuidString }UUID().uuidString返回类似E621E1F8-C36C-495A-93FC-0C247A3E6E5F的字符串,全局唯一。这个值后面要同时塞进图片和视频的元数据,所以生成一次后要存下来传给两个写入函数,不能各生成各的,否则配对失败。
3.2 给静态图写入元数据
图片这边,用CGImageDestination把 identifier 写进 JPEG 元数据:
import ImageIO import MobileCoreServices // 把 asset identifier 写入图片元数据 func writeIdentifierToImage(_ imageData: Data, identifier: String) -> Data? { guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), let uti = CGImageSourceGetType(source) else { return nil } let mutableData = NSMutableData() guard let destination = CGImageDestinationCreateWithData( mutableData, uti, 1, nil) else { return nil } // 构造包含 identifier 的元数据字典 let metadata: [String: Any] = [ kCGImagePropertyMakerAppleDictionary as String: [ "17": identifier // 17 是 Apple 约定的 Live Photo 标识键 ] ] CGImageDestinationAddImageFromSource(destination, source, 0, metadata as CFDictionary) guard CGImageDestinationFinalize(destination) else { return nil } return mutableData as Data }这里的关键是kCGImagePropertyMakerAppleDictionary下的键"17",这是 Apple 私有的约定,值就是 identifier。很多人卡在这里,是因为不知道这个键名,网上搜到的方案五花八门。CGImageDestinationAddImageFromSource的第三个参数传 0 表示取第一帧,第四个参数就是元数据字典。写入失败通常返回 nil,要判空。
3.3 给视频写入元数据
视频这边用AVAssetExportSession重新导出,同时写入 identifier:
import AVFoundation // 把 asset identifier 写入视频元数据并导出 func writeIdentifierToVideo(_ videoURL: URL, identifier: String, completion: @escaping (URL?) -> Void) { let asset = AVAsset(url: videoURL) guard let export = AVAssetExportSession( asset: asset, presetName: AVAssetExportPresetPassthrough) else { completion(nil); return } let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("\(identifier).mov") // 构造视频元数据,键名同样是 Apple 约定的 let metadata: [AVMetadataItem] = [ AVMetadataItem(identifier: .quickTimeMetadataContentIdentifier, value: identifier as NSString) ] export.outputURL = outputURL export.outputFileType = .mov export.metadata = metadata export.exportAsynchronously { DispatchQueue.main.async { completion(export.status == .completed ? outputURL : nil) } } }AVAssetExportPresetPassthrough表示不重新编码,直接透传,速度快、画质无损,前提是源视频格式系统支持。AVMetadataItem的 identifier 用.quickTimeMetadataContentIdentifier,这是 QuickTime 容器里存内容标识的标准位置。导出是异步的,回调里判断export.status,成功才返回临时文件 URL。
3.4 保存为 Live Photo 到相册
图片和视频都处理好后,用PHAssetCreationRequest一次性写入:
import Photos // 把处理好的图片和视频作为一张实况图保存 func saveLivePhoto(imageURL: URL, videoURL: URL, completion: @escaping (Bool) -> Void) { PHPhotoLibrary.shared().performChanges { let request = PHAssetCreationRequest.forAsset() // 图片作为 photo 资源,视频作为 pairedVideo 资源 let imageOptions = PHAssetResourceCreationOptions() imageOptions.originalFilename = imageURL.lastPathComponent request.addResource(with: .photo, fileURL: imageURL, options: imageOptions) let videoOptions = PHAssetResourceCreationOptions() videoOptions.originalFilename = videoURL.lastPathComponent request.addResource(with: .pairedVideo, fileURL: videoURL, options: videoOptions) } completionHandler: { success, error in DispatchQueue.main.async { completion(success) } } }PHAssetCreationRequest.forAsset()创建一个资源创建请求,关键在addResource的 type 参数:图片用.photo,视频用.pairedVideo。.pairedVideo这个类型就是告诉系统「这是实况图的动态部分」,系统会自动根据两者元数据里的 identifier 完成配对。两个资源必须在同一个performChanges块里添加,分开写会变成两张独立资源。
3.5 完整调用链
把上面几步串起来,主流程就是:
// 完整生成流程 func createLivePhoto(imageData: Data, videoURL: URL) { let identifier = makeAssetIdentifier() guard let newImageData = writeIdentifierToImage(imageData, identifier: identifier) else { print("图片元数据写入失败"); return } // 图片数据落临时文件 let imageURL = FileManager.default.temporaryDirectory .appendingPathComponent("\(identifier).jpg") try? newImageData.write(to: imageURL) writeIdentifierToVideo(videoURL, identifier: identifier) { outputURL in guard let outputURL = outputURL else { print("视频元数据写入失败"); return } saveLivePhoto(imageURL: imageURL, videoURL: outputURL) { success in print(success ? "实况图已保存" : "保存失败") } } }顺序不能乱:先生成 identifier,再分别写入图片和视频,最后一起保存。任何一步失败都要中断,否则会往相册里塞半成品。临时文件用 identifier 命名,避免多次生成时文件名冲突。
4. 避坑与排查:实况图生成失败的几个高频原因
4.1 保存成功但相册里不动
现象:代码返回 success,相册里也能看到图,但长按没反应,就是一张普通照片。
原因:图片和视频的 identifier 不一致,或者其中一个没写进去。常见于图片元数据写入时键名写错,或者视频导出时 metadata 被覆盖。
解决:在保存前把两个资源的 identifier 打印出来比对,确认完全一致。图片侧检查kCGImagePropertyMakerAppleDictionary下的"17"键,视频侧检查.quickTimeMetadataContentIdentifier。
4.2 视频导出失败返回 nil
现象:writeIdentifierToVideo回调里 outputURL 为 nil,export.status 是 failed。
原因:源视频格式不被AVAssetExportPresetPassthrough支持,比如某些编码的 MP4 或高帧率视频。
解决:换成AVAssetExportPresetHighestQuality重新编码导出,牺牲一点速度换兼容性。或者先检查asset.isExportable再决定用哪个 preset。
4.3 权限被拒后无提示
现象:点选图没反应,或者保存时静默失败,控制台也没明显报错。
原因:用户之前拒绝过相册权限,PHPhotoLibrary的请求直接返回失败,但代码没处理这个分支。
解决:在操作前用PHPhotoLibrary.authorizationStatus(for: .readWrite)检查状态,被拒时引导用户去设置里开权限,而不是干等。
4.4 多张实况图 identifier 串了
现象:生成多张后,长按某张显示的是另一张的视频。
原因:identifier 用了固定值或复用了同一个,导致系统配对错乱。
解决:每次生成都调UUID().uuidString,绝不缓存复用。临时文件名也带上 identifier,避免文件覆盖。
4.5 模拟器上正常真机失败
现象:模拟器跑得好好的,真机上保存报错或长按无效。
原因:模拟器相册行为和真机有差异,且真机对元数据校验更严。
解决:真机测试时用系统相机拍的原生实况图做对照,检查自己生成的元数据格式是否完全对齐。真机上还要确认Info.plist权限描述文案不为空,空文案会被系统拒绝。
5. 二次开发:把 Demo 改成能用的功能模块
这个 Demo 跑通之后,真正要落地到自己的 App,还有几处得改。我一般会按下面的思路动手。
第一处是输入源。Demo 里图片和视频是分开选的,实际产品里更常见的是「用户选一张图,App 自动配一段默认视频」或者「用户选一段视频,App 抽一帧当封面」。前者需要你准备一段循环播放的短视频作为动态部分,后者要用AVAssetImageGenerator从视频里抽帧生成静态图。抽帧的代码大概是这样:
// 从视频抽第一帧作为静态图 func extractFirstFrame(from videoURL: URL) -> Data? { let asset = AVAsset(url: videoURL) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true // 处理旋转 let time = CMTime(seconds: 0, preferredTimescale: 600) guard let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) else { return nil } return UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) }appliesPreferredTrackTransform一定要设 true,否则竖屏视频抽出来的帧是横的,这是血泪经验。抽帧后拿到的 Data 再走前面的writeIdentifierToImage流程就行。
第二处是视频时长控制。实况图的动态部分系统建议 3 秒左右,太长会影响长按播放体验。如果用户选的视频超过 3 秒,要用AVAssetExportSession的timeRange裁剪:
// 裁剪视频到前 3 秒 let duration = CMTime(seconds: 3, preferredTimescale: 600) export.timeRange = CMTimeRange(start: .zero, duration: duration)timeRange的 start 和 duration 都是CMTime,裁剪后再导出,元数据写入逻辑不变。
第三处是错误处理和进度反馈。Demo 里失败就 print,产品里得给用户明确提示。建议把LivePhotoMaker的每个方法都改成带Result回调,UI 层根据错误类型展示不同文案,比如「视频格式不支持」「相册权限未开启」。生成过程如果视频较大,导出会耗时,加个 loading 或进度条,别让用户以为卡死了。
验证改造是否成功,我有个固定习惯:生成后立刻用PHAsset.fetchAssets把刚保存的资源查出来,检查mediaSubtypes是否包含.photoLive:
// 验证保存的资源确实是实况图 let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetID], options: nil) if let asset = fetchResult.firstObject { let isLive = asset.mediaSubtypes.contains(.photoLive) print("是否实况图: \(isLive)") }.photoLive这个 subtype 是系统给的权威判断,比自己看元数据靠谱。从那以后我每次改完合成逻辑,都强制走一遍这个校验,确认 subtype 对了才算完。希望帮到你。
本文还有配套的精品资源,点击获取