PHImageManager 踩坑记

如何正确使用 requestImage 和 requestAVAsset 方法

2020-09-18

TOC

前言

最近在APP里做相册导入功能开发的时候发现一个BUG,在iOS14系统中去导入iCloud视频时会出现导入成功拿到了视频在相册里的URL地址,但是丢到 AVPlayer 或者装配到 AVURLAsset 里使用的时候会报没有权限打开的错误:

Error Domain=NSCocoaErrorDomain Code=257 “The file “xxx” couldn’t be opened because you don’t have permission to view it.”

在网络上搜寻了一番,第一个找到的答案是这个,说是获取的 PHAsset 姿势不对,需要使用 localIdentifier 去获取,然后实际去试了一番没区别。

还有一种说法是 requestAVAsset 获取的地址无法使用 Data(contentsOf:) 这类方法访问,可以使用 AVPlayer 等类进行访问,但是实测的话正好相反。

无奈之下只好开始自己摸索解决方案了,正好手上的设备可以复现问题,下面回顾一下解决过程。

requestAVAsset 的坑

先看一下请求代码:

let requestOption = PHVideoRequestOptions()
requestOption.isNetworkAccessAllowed = true
requestOption.deliveryMode = .mediumQualityFormat
PHImageManager.default().requestAVAsset(forVideo: asset, options: requestOption) {
}

可以注意到这里有个参数选项:

  • isNetworkAccessAllowed 代表当目标是iCloud视频时可以使用网络进行实时下载。
  • deliveryMode 表示下载的质量,这里使用的是 medium 质量。
  • 还有一个未设置的默认属性 version,其默认值是 current,即返回当前版本,可能是编辑过的版本。在网上有些人貌似使用这个时也遇到无法正常返回的错误,此时可以将其设置为 original 版本。

以上参数使用上看起来没有任何问题,请求也会正常返回无错误。但是思考报错的原因的话是涉及到访问权限的,所以把返回的asset打印了出来看到如下地址:

file:///var/mobile/Media/PhotoData/Metadata/DCIM/100APPLE/IMG_0221.medium.MP4

此地址使用 Data(contentsOf:) 方法获取能正常拿到数据,但是使用 AVPlayer 播放就会报一开始的没有访问权限的错误。

因为本地视频是可以正常导入的,所以继续尝试获取本地已下载的视频,打印地址如下:

file:///var/mobile/Media/DCIM/101APPLE/IMG_1336.MOV

仔细观察地址会发现不正常的地址跟上面正常地址对比中间多了一些路径 /PhotoData/Metadata/,大概猜想这个目录是具有特殊权限才导致 AVPlayer 无法正常访问。

再倒推过来如果下载下来的地址能跟本地视频保持一致是否就可以访问了呢?然后再去观察文件名发现还多了个 .medium. 的字样,跟我们设置的 deliveryMode 相对应,然后看到有个值是 highQualityFormat 注释写着是 best quality ,只能用这个去试一试了,结果一切恢复了正常。

这个现象我开始注意到的是在X系列的设备 + iOS14系统 + iCloud视频才会出现,比较奇葩的是整个问题在我的6S设备上都不存在,尽管地址都是如上面所述的那样,但是不同设备不同系统都有着不一样的表现。

最后只能推测这个就是苹果今年埋下的又一个焦头烂额的坑。

requestImage 的坑

这个方法倒没有遇到上面 requestAVAsset 那样的权限问题,也有可能是没有遇到 AVPlayer 这样的使用场景所以没遇到,但是在使用方式上还是有挺多注意的地方。

如果看过此方法的文档注释会发现有10几行的一大串,主要注意的部分是在 PHImageRequestOptions 这个类里:

  • 如果 deliveryMode 选的是 opportunistic,同时 isSynchronous 设置为 falseresultHandler 可能会被调用两次,第一次先返回一个低质量的图片,第二次返回最终正确质量的图片。当触发第一次返回时返回的信息中 PHImageResultIsDegradedKey 这个键对应的值会存在并且值为 true,可以依据这个来识别是否是低质量的图片回调。这两次返回的行为有些场景下可能会造成一些意想不到的BUG,比如你依赖这个回调进行屏幕刷新可能刷新多次,或者使用 DispatchGroup 来组织这个请求时如果多次触发 group.leave() 的话毫无疑问会产生崩溃。
  • 如果 deliveryMode 选的是其他,或者 isSynchronous 设置为 trueresultHandler 只会调用一次。但是如果后续修改参数时不小心忽略了上述两次返回的现象会埋下一个坑,比较稳妥的做法是不管用到哪种参数配置都在 resultHandler 里面进行 PHImageResultIsDegradedKey 的判断,确保万无一失。
  • isSynchronous 设置为 true 时是真正的同步请求,会卡住当前线程直到结束,也就是没机会进行取消,如果不想卡住线程或者需要取消操作一定记得设置 false 触发异步操作。

总结

当前市面上的大多数APP都会跟系统的相册打交道,多数可能只是简单的写入操作,或者有读取操作也只考虑了本地已存在的部分。当考虑iCloud这部分的时候整个测试和问题排查难度就上升了好多好多,就上面 requestAVAsset 方法的问题就排查了好些时间,尝试了以前的所有能搜到的解决方案和相关文档都没能解决,最后只能硬着头皮去一个一个参数去尝试和调试,索性可用的参数数量不多很快就试出来了。不过可悲的是过往的这些问题可能都是出现在不同系统版本中,作为开发者的我们只能是不断的保持关注度,同时进行踩坑和总结。

最后,由 resultHandler 的一个坑收尾,不管是使用 requestImage 还是 requestAVAsset 方法都会有一个回调就是 resultHandler。其中返回的信息中会有 PHImageResultIsInCloudKey 这个键类型,顾名思义就是代表是否是iCloud图片或者视频。

但是这里要注意的是一个iCloud素材成功通过这些方法下载下来时这个键是不存在的,而由于一些限制没能从iCloud成功下载时这个键才会存在,比如没有网络,比如 isNetworkAccessAllowed 设为了 false。在没有网络这种情况下还有一个惊喜就是 error 为空。

所以,会有如下蛋疼的回调处理:

PHImageManager.default().requestImage(for: asset, targetSize: targetSize, contentMode: .aspectFill, options: requestOption) { (image, dict) in
        let isDegraded = dict?[PHImageResultIsDegradedKey] as? Bool ?? false
        /// 在highQualityFormat模式不会返回Degraded图片,这里是为了以防万一
        /// 如果后续不小心修改了deliveryMode可能导致多次触发回调
        if isDegraded {
            return
        }

        let error = dict?[PHImageErrorKey] as? Error
        let isCloud = dict?[PHImageResultIsInCloudKey] as? Bool
        if error != nil || isCloud == true {
            resultHandler(nil, error, dict)
        } else {
            resultHandler(image, nil, dict)
        }
}