好久不见,今天我来填坑一下 iOS 实况图的处理。其实实况图在 iOS 是有些年头了的,但是由于去年下半年因为微信支持朋友圈发实况图所以又被大伙关注起来了(Android 设备的小伙伴也别急,国内厂商们也纷纷开始自己定义实况图了,虽然目前是群魔乱舞。但也有好消息 Google 也准备推出 Android 这边实况图的定义规范参考 Motion Photo format 1.0 如果厂商们能遵守那也是善事一件)。

好了,不扯远了还是继续讲回来 iOS 这边实况图,其实实况图的本质就是一张照片和一小段视频,点击(或者长按)照片之后自动播放视频。如果我们将 iOS 上的实况图传到百度网盘或者 Google 相册之类的云服务商的文件存储上面可能会得到一个 xxx.livp 的文件,livp 文件的本质其实是一个压缩包其中就包含一张 heic 的照片和一个 mov 视频(也有可能是 jpeg 和 mp4 等图片和视频文件格式)。这个其实是没有标准的主要取决于厂商实现。

接下来我们看看如何实现使用 Swift 获取实况照片并且将其打包为 xxx.livp 文件方便传输使用,以及如何将 livp 文件存储回 iOS 相册中。

let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
options.includeAllBurstAssets = false
options.includeAssetSourceTypes = [.typeCloudShared, .typeUserLibrary, .typeiTunesSynced]
let allAssets = PHAsset.fetchAssets(with: options)

首先通过我们 PHAsset.fetchAssets 接口获取到设备的全部媒体资源(当然,你首先得申请授权我这里就只贴简单的示例代码了)

import Foundation
import Photos
import System

struct LivePhotoFile {
    public static let FILE_SUFFIX = "livp"
    public static let FILE_MIME = "image/x-livp"
    public let asset: PHAsset

    enum LivePhotoFileError: LocalizedError {
        case Error(String)
    }

    init(asset: PHAsset) throws {
        if !LivePhotoFile.hasPhotoLive(asset: asset) {
            throw NSError(domain: "asset is not of type photoLive", code: -1)
        }
        self.asset = asset
    }

    // 判断 PHAsset 是否为实况图
    public static func hasPhotoLive(asset: PHAsset) -> Bool {
        return asset.mediaSubtypes == .photoLive || asset.mediaSubtypes.contains(.photoLive)
    }
}

我这里定义了一个 LivePhotoFile 结构体,并且实现了 LivePhotoFile.hasPhotoLive 静态函数用于判断 PHAsset 是否为一个实况图资源,这里有个小坑 mediaSubtypes 其实是有可能为一个数组的,也就是说它既有可能直接是 PHAssetMediaSubtype 类型也有可能是 [PHAssetMediaSubtype] 类型,所以我这里除了判断它是否等于 PHAssetMediaSubtype.photoLive 类型还判断了是否包含实况图类型。

struct LivePhotoFile {
    public static let FILE_SUFFIX = "livp"
    public static let FILE_MIME = "image/x-livp"
    public let asset: PHAsset
    private let assetResources: [PHAssetResource]

    enum LivePhotoFileError: LocalizedError {
        case Error(String)
    }

    init(asset: PHAsset) throws {
        if !LivePhotoFile.hasPhotoLive(asset: asset) {
            throw NSError(domain: "asset is not of type photoLive", code: -1)
        }
        self.asset = asset
        assetResources = PHAssetResource.assetResources(for: asset)
    }

    // 判断 PHAsset 是否为实况图
    public static func hasPhotoLive(asset: PHAsset) -> Bool {
        return asset.mediaSubtypes == .photoLive || asset.mediaSubtypes.contains(.photoLive)
    }
}

现在实现一下构造函数,在构造函数中将 PHAsset 的 PHAssetResource 资源全部获取出来,在这里我们其实是能看出来在 iOS 中并不是说拍摄之后存储了一个实况图这样的文件,而是靠系统相册的数据库来将一个照片和视频关联起来给定一个实况图的资源类型,不仅仅是实况图其实你会发现 iOS 相册中编辑过的图片能够保留编辑操作记录也是通过这样的实现方式做到的。

struct LivePhotoFile {
    public static let FILE_SUFFIX = "livp"
    public static let FILE_MIME = "image/x-livp"
    public let asset: PHAsset
    private let assetResources: [PHAssetResource]

    enum LivePhotoFileError: LocalizedError {
        case Error(String)
    }

    init(asset: PHAsset) throws {
        if !LivePhotoFile.hasPhotoLive(asset: asset) {
            throw NSError(domain: "asset is not of type photoLive", code: -1)
        }
        self.asset = asset
        assetResources = PHAssetResource.assetResources(for: asset)
    }

    // 判断 PHAsset 是否为实况图
    public static func hasPhotoLive(asset: PHAsset) -> Bool {
        return asset.mediaSubtypes == .photoLive || asset.mediaSubtypes.contains(.photoLive)
    }

    // 获取指定类型资源
    func getResourceItem(type: PHAssetResourceType) throws -> PHAssetResource {
        if type != .photo, type != .pairedVideo {
            throw LivePhotoFileError.Error("loadResourceFile unsupported type \(type)")
        }
        let item = assetResources.first(where: { $0.type == type })
        guard let item else { throw LivePhotoFileError.Error("\(type == .photo ? "photo" : "video") not exist for PHAssetResource") }
        return item
    }

    // 获取图片资源
    func getImageFile() throws -> (URL, String) {
        let res = try getResourceItem(type: .photo)
        return try PhotoUtils.loadAssetFile(resource: res, asset: asset)
    }

    // 获取视频资源
    func getVideoFile() throws -> (URL, String) {
        let res = try getResourceItem(type: .pairedVideo)
        return try PhotoUtils.loadAssetFile(resource: res, asset: asset)
    }
}

分别定义获取实况图照片资源的 getImageFile 和获取视频资源的 getVideoFile 函数

import Photos
import System
import ZipArchive

struct LivePhotoFile {
    public static let FILE_SUFFIX = "livp"
    public static let FILE_MIME = "image/x-livp"
    public let asset: PHAsset
    private let assetResources: [PHAssetResource]

    enum LivePhotoFileError: LocalizedError {
        case Error(String)
    }

    ……

    // 获取指定类型资源
    func getResourceItem(type: PHAssetResourceType) throws -> PHAssetResource {
        if type != .photo, type != .pairedVideo {
            throw LivePhotoFileError.Error("loadResourceFile unsupported type \(type)")
        }
        let item = assetResources.first(where: { $0.type == type })
        guard let item else { throw LivePhotoFileError.Error("\(type == .photo ? "photo" : "video") not exist for PHAssetResource") }
        return item
    }

    // 获取图片资源
    func getImageFile() throws -> (URL, String) {
        let res = try getResourceItem(type: .photo)
        return try PhotoUtils.loadAssetFile(resource: res, asset: asset)
    }

    // 获取视频资源
    func getVideoFile() throws -> (URL, String) {
        let res = try getResourceItem(type: .pairedVideo)
        return try PhotoUtils.loadAssetFile(resource: res, asset: asset)
    }

    // 构造 Livp 文件
    func genLivpFile() throws -> (URL, String) {
        // 分别获取图片和视频资源
        let imageRes = try getResourceItem(type: .photo)
        let videoRes = try getResourceItem(type: .pairedVideo)

        // 生成 .livp 文件
        let fileManager = FileManager.default
        let fileTmpURL = PhotoUtils.getTemporaryFilePath(name: UUID().uuidString) // 生成临时文件随机路径
        try fileManager.createDirectory(at: fileTmpURL, withIntermediateDirectories: true, attributes: nil)

        // 构造媒体资源输出文件路径
        let tmpImageURL = URL(fileURLWithPath: fileTmpURL.path).appendingPathComponent(imageRes.originalFilename, conformingTo: .item)
        let tmpVideoURL = URL(fileURLWithPath: fileTmpURL.path).appendingPathComponent(videoRes.originalFilename, conformingTo: .item)

        // print("tzmax: genLivpFile fileTMPdir:\(fileTmpURL.path) imageURL:\(tmpImageURL.path) videoURL:\(tmpVideoURL.path)")

        // 保存资源至文件中
        _ = try PhotoUtils.loadAssetFile(resource: imageRes, asset: asset, fileURL: tmpImageURL)
        _ = try PhotoUtils.loadAssetFile(resource: videoRes, asset: asset, fileURL: tmpVideoURL)

        // 压缩文件
        let outPath = PhotoUtils.getTemporaryFilePath(id: asset.localIdentifier, ext: ".livp")
        if fileManager.fileExists(atPath: outPath.path) {
            // 判断存在旧文件,先删除
            try fileManager.removeItem(at: outPath)
        }
        if !SSZipArchive.createZipFile(atPath: outPath.path, withContentsOfDirectory: fileTmpURL.path) {
            throw LivePhotoFileError.Error("archive create livp file failed")
        }

        // 清理临时文件
        LivePhotoFile.clearCache(file: fileTmpURL, make: "genLivpFile")

        // print("tzmax: genLivpFile livpFilePath:\(outPath.path)")

        return (outPath, LivePhotoFile.FILE_MIME)
    }

    // 清理缓存
    private static func clearCache(file: URL, make: String = "putPhoto") {
        do {
            try FileManager.default.removeItem(at: file)
        } catch {
            print("tzmax: LivePhotoFile \(make) failed to clear cache, path:\(file.path), \(error)")
        }
    }
}

能够方便的获取实况图的视频和图片资源后,我们只需要创建一个临时目录将照片和视频资源拷贝进去,然后使用 zip 方式将其压缩到一起然后输出为 .livp 文件即可。这里我压缩文件使用的是 ZipArchive ,最终将打包好的 livp 文件上传到后端即可(后端的实现就是解压 livp 文件使用照片生成缩略图,以及提供视频预览服务),接着我们继续实现将一个下载好的 livp 文件保存回 iOS 相册中

import Photos
import System
import ZipArchive

struct LivePhotoFile {
    public static let FILE_SUFFIX = "livp"
    public static let FILE_MIME = "image/x-livp"
    public let asset: PHAsset
    private let assetResources: [PHAssetResource]

    ……

    // 将实况图资源保存至相册
    public static func putPhoto(file: URL, clean: Bool = true) throws -> String? {
        let destinationURL = PhotoUtils.getTemporaryFilePath(name: UUID().uuidString) // 临时存储路径
        // 清理缓存
        defer {
            // 清理媒体资源解压目录
            clearCache(file: destinationURL)
            // 清理源文件资源
            if clean {
                clearCache(file: file)
            }
        }
        // 解压媒体资源
        if !SSZipArchive.unzipFile(atPath: file.path, toDestination: destinationURL.path) {
            throw LivePhotoFileError.Error("unarchive livp file failed")
        }

        let (imageFile, videoFile) = try getAssetsFiles(forDir: destinationURL)
        guard let imageFile, let videoFile else {
            throw LivePhotoFileError.Error("livp file \(imageFile == nil ? "image" : "video") resources are missing")
        }

        var assetLocalIdentifier: String?
        try PHPhotoLibrary.shared().performChangesAndWait {
            // 创建保存实况图资源请求
            let creationRequest = PHAssetCreationRequest.forAsset()
            let options = PHAssetResourceCreationOptions()
            creationRequest.addResource(with: PHAssetResourceType.photo, fileURL: imageFile, options: options)
            creationRequest.addResource(with: PHAssetResourceType.pairedVideo, fileURL: videoFile, options: options)
            assetLocalIdentifier = creationRequest.placeholderForCreatedAsset?.localIdentifier
        }
        return assetLocalIdentifier
    }

    // 通过 live 资源文件夹获取图片和视频资源
    private static func getAssetsFiles(forDir: URL) throws -> (URL?, URL?) {
        let fs = FileManager.default
        let isDir: UnsafeMutablePointer<ObjCBool> = .allocate(capacity: 2)
        let notExist = fs.fileExists(atPath: forDir.path, isDirectory: isDir)
        if !notExist || isDir.pointee.boolValue != true {
            throw LivePhotoFileError.Error("\(forDir) notExist or isNotDirectory")
        }
        guard let fileEnum = fs.enumerator(at: forDir, includingPropertiesForKeys: nil),
              let fileList = fileEnum.allObjects as? [URL]
        else {
            throw LivePhotoFileError.Error("\(forDir) it is an empty folder")
        }
        // print("tzmax: genLivpFile fileList:\(fileList.count)")
        var imageFile: URL?
        var videoFile: URL?
        for item in fileList {
            let fileName = item.lastPathComponent
            // 通过文件后缀获取 UTType
            guard let fileExt = FilePath(fileName).extension, let utType = UTType(filenameExtension: fileExt) else {
                print("tzmax: LivePhotoFile getAssetsFiles skip unknown file type, fileName:\(fileName)")
                continue
            }
            // print("tzmax: genLivpFile item:\(item) fileName:\(fileName)")
            if imageFile != nil, videoFile != nil {
                break
            }
            if utType.isSubtype(of: .image) {
                // 获取到图片资源路径
                imageFile = item
                continue
            }
            if utType.isSubtype(of: .audiovisualContent) {
                // 获取到视频资源路径
                videoFile = item
                continue
            }
            print("tzmax: LivePhotoFile getAssetsFiles skip unrecognized resources, fileName:\(fileName)")
        }
        return (imageFile, videoFile)
    }

    // 清理缓存
    private static func clearCache(file: URL, make: String = "putPhoto") {
        do {
            try FileManager.default.removeItem(at: file)
        } catch {
            print("tzmax: LivePhotoFile \(make) failed to clear cache, path:\(file.path), \(error)")
        }
    }
}

先是实现了 getAssetsFiles 函数接收一个 livp 文件的 URL 将其解压后返回对应的照片和视频文件 URL,随后实现 putPhoto 函数将照片和视频文件分别构造 PHAssetResource 实例,最后保存时将两个 PHAssetResource 资源添加到相同的 PHAsset 资源中并保存至相册。