iOS 中的通过 PhotoKit 提供访问 “照片 App” 中的照片和视频,本文主要讲解使用 PhotoKit 浏览相册中的照片和视频、导出相册中的照片、导出相册中的视频、修改相册中的照片和新增相册中的照片。

Camera Sea

完整代码:

浏览相册中的照片和视频

第一步,获取相册使用权限:

PHPhotoLibrary.requestAuthorization { [weak self] status in
    guard let self = self else { return }
    switch status {
    case .authorized:
        self.imageManager = PHCachingImageManager()
        self.fetchAlbums()
        self.album = self.albums.first
        DispatchQueue.main.async { [weak self] in
            self?.updateAlbum()
            self?.tableView.reloadData()
        }
    default:
        DispatchQueue.main.async {
            DTMessageBar.error(message: "相册未授权", position: .bottom)
        }
    }
}

第二步,获取 Albums 和 Assets,先介绍一些核心类:

  • PHImageManager 是操作相册的中心类,为了缓存和提升访问性能,这里使用子类 PHCachingImageManager。
  • PHAssetCollection 是一组 Assets 的抽象,可以理解为相册。
  • PHAsset 是一个 Asset 的抽象,可以是照片、视频或 Live Photo。

获取 PHAssetCollection:

var collections = PHAssetCollection.fetchAssetCollections(with: .smartAlbum,
                                                          subtype: .albumRegular,
                                                          options: nil)
var index = 0
while index < collections.count {
    let collection = collections.object(at: index)
    var subtypes: [PHAssetCollectionSubtype] = [.smartAlbumRecentlyAdded]
    if #available(iOS 9.0, *) {
        if mode.type != .video {
            subtypes.append(.smartAlbumScreenshots)
        }
    }
    if mode.type != .photo {
        subtypes.append(.smartAlbumVideos)
    }
    if subtypes.contains(collection.assetCollectionSubtype) {
        assets = fetchAssets(in: collection)
        album = Album(collection: collection, assets: assets)
        albums.append(album)
    }
    index += 1
}

collections = PHAssetCollection.fetchAssetCollections(with: .album, subtype: .albumRegular, options: nil)
index = 0
while index < collections.count {
    let collection = collections.object(at: index)
    assets = fetchAssets(in: collection)
    album = Album(collection: collection, assets: assets)
    albums.append(album)
    index += 1
}

获取 PHAsset,通过 NSPredicate 设置一些查询限定条件:

private func fetchAssets(in collection: PHAssetCollection? = nil) -> PHFetchResult<PHAsset> {
    if let collection = collection {
        let fetchOptions = PHFetchOptions()
        switch mode.type {
        case .photo:
            fetchOptions.predicate = NSPredicate(format: "mediaType = %d", PHAssetMediaType.image.rawValue)
        case .video:
            fetchOptions.predicate = NSPredicate(format: "mediaType = %d", PHAssetMediaType.video.rawValue)
        case .all:
            break
        }
        fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        return PHAsset.fetchAssets(in: collection, options: fetchOptions)
    } else {
        let fetchOptions = PHFetchOptions()
        fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        switch mode.type {
        case .photo:
            return PHAsset.fetchAssets(with: .image, options: fetchOptions)
        case .video:
            return PHAsset.fetchAssets(with: .video, options: fetchOptions)
        case .all:
            return PHAsset.fetchAssets(with: fetchOptions)
        }
    }
}

第三步,显示照片和视频的缩略图,PHAsset 包含的只是照片和视频的信息,缩略图要通过 PHImageManager 的 requestImage 方法异步获取:

func requestImage(for asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?, resultHandler: @escaping (UIImage?, [AnyHashable : Any]?) -> Void) -> PHImageRequestID

PHImageRequestOptions 中的 PHImageRequestOptionsDeliveryMode 决定提供的照片质量和次数。

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    guard let assets = album?.assets else { return UICollectionViewCell() }
    let asset = assets.object(at: indexPath.row)
    if asset.mediaType == .image {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: photoCellIdentifier, for: indexPath) as! LibraryPhotoCell
        cell.photoImageView.image = nil
        cell.representedAssetIdentifier = asset.localIdentifier
        if let imageManager = imageManager {
            let requestOptions = PHImageRequestOptions()
            requestOptions.isNetworkAccessAllowed = true
            imageManager.requestImage(for: asset,
                                      targetSize: thumbnailSize,
                                      contentMode: .aspectFill,
                                      options: requestOptions,
                                      resultHandler: { photo, _ in
                                        DispatchQueue.main.async {
                                            if cell.representedAssetIdentifier == asset.localIdentifier {
                                                cell.photoImageView.image = photo
                                            }
                                        }
            })
        }
        let index = selectedAssets.firstIndex { $0.localIdentifier == asset.localIdentifier }
        if let index = index {
            cell.setCheckNumber(index + 1)
            cell.toggleMask(isShow: false)
        } else {
            cell.setCheckNumber(nil)
            cell.toggleMask(isShow: selectedAssets.count >= mode.config.limitOfPhotos)
        }
        return cell
    } else {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: videoCellIdentifier, for: indexPath) as! LibraryVideoCell
        cell.photoImageView.image = nil
        cell.representedAssetIdentifier = asset.localIdentifier
        if let imageManager = imageManager {
            let requestOptions = PHImageRequestOptions()
            requestOptions.isNetworkAccessAllowed = true
            imageManager.requestImage(for: asset,
                                      targetSize: thumbnailSize,
                                      contentMode: .aspectFill,
                                      options: requestOptions,
                                      resultHandler: { photo, _ in
                                        DispatchQueue.main.async {
                                            if cell.representedAssetIdentifier == asset.localIdentifier {
                                                cell.photoImageView.image = photo
                                            }
                                        }
            })
        }
        cell.setDuration(asset.duration)
        if asset.duration >= TimeInterval(mode.config.minDuration),
            asset.duration <= TimeInterval(mode.config.maxDuration) {
            cell.toggleMask(isShow: !selectedAssets.isEmpty)
        } else {
            cell.toggleMask(isShow: true)
        }
        return cell
    }
}

导出相册中的照片

和前面获取照片缩略图的代码一样,只是设置的照片质量不同:

private func fetchPhotos(completionBlock: @escaping (_ photos: [UIImage]?) -> Void) {
    guard let imageManager = imageManager else { return }
    let total = selectedAssets.count
    var current = 0
    var success = 0
    var photos = [UIImage](repeating: UIImage(), count: selectedAssets.count)
    let requestOptions = PHImageRequestOptions()
    requestOptions.deliveryMode = .highQualityFormat
    requestOptions.isNetworkAccessAllowed = true
    for (index, asset) in selectedAssets.enumerated() {
        imageManager.requestImage(for: asset,
                                  targetSize: PHImageManagerMaximumSize,
                                  contentMode: .aspectFit,
                                  options: requestOptions,
                                  resultHandler: { photo, _ in
                                    DispatchQueue.main.async {
                                        current += 1
                                        if let photo = photo {
                                            success += 1
                                            photos[index] = photo
                                        }
                                        if current == total {
                                            completionBlock(success == total ? photos : nil)
                                        }
                                    }
        })
    }
}

导出相册中的视频

PHImageManager 获取相册中的视频资源有如下几个方法:

  • requestPlayerItem 获取可以直接播放的 AVPlayerItem。
  • requestAVAsset 获取可以直接播放和编辑的 AVAsset。
  • requestExportSession 获取可以导出到目录文件的 AVAssetExportSession。

这里使用 requestExportSession 获取 AVAssetExportSession,PHImageRequestOptions 和 PHVideoRequestOptions 都有关于从 iCloud 获取照片和视频的功能,isNetworkAccessAllowed 允许获取从 iCloud 获取照片和视频,progressHandler 获取从 iCloud 下载照片和视频的进度,PHImageManager cancelImageRequest 可以取消整个获取照片和视频的异步任务:

asset.duration >= TimeInterval(mode.config.minDuration),
asset.duration <= TimeInterval(mode.config.maxDuration) {
DTMessageHUD.hud()
guard let videoFile = MediaViewController.getMediaFileURL(name: "video", ext: "mp4"),
    let imageManager = imageManager else {
        DTMessageHUD.dismiss()
        DTMessageBar.error(message: "创建视频文件失败", position: .bottom)
        return
}
let requestOptions = PHVideoRequestOptions()
requestOptions.isNetworkAccessAllowed = true
let presets = AVAssetExportSession.allExportPresets()
var preset = presets.first ?? ""
if presets.contains(AVAssetExportPreset1280x720) {
    preset = AVAssetExportPreset1280x720
} else if presets.contains(AVAssetExportPresetMediumQuality) {
    preset = AVAssetExportPresetMediumQuality
}
imageManager.requestExportSession(forVideo: asset, options: requestOptions,
                                  exportPreset: preset) { [weak self] sess, _ in
                                    self?.exportVideo(videoFile, with: sess)
}

接着,AVAssetExportSession 通过 exportAsynchronously 异步导出相册中视频到沙盒中,cancelExport 取消导出,status 和 progress 获取状态和进度:

private func exportVideo(_ video: URL, with session: AVAssetExportSession?) {
    guard let session = session else {
        DispatchQueue.main.async {
            DTMessageHUD.dismiss()
            DTMessageBar.error(message: "获取视频导出会话失败", position: .bottom)
        }
        return
    }
    session.outputURL = video
    session.outputFileType = .mp4
    session.shouldOptimizeForNetworkUse = true
    session.exportAsynchronously { [weak self] in
        DispatchQueue.main.async { [weak self] in
            switch session.status {
            case .completed:
                DTMessageHUD.dismiss()
                self?.previewVideo(video)
            default:
                DTMessageHUD.dismiss()
                DTMessageBar.error(message: "视频文件导出失败", position: .bottom)
            }
        }
    }
}

修改相册中的照片

if asset.canPerform(.content) {
    asset.requestContentEditingInput(with: nil) { (contentEditingInput, _) in
        do {
            guard let contentEditingInput = contentEditingInput else { return }
            let contentEditingOutput = PHContentEditingOutput(contentEditingInput: contentEditingInput)
            let formatIdentifier = Bundle.main.bundleIdentifier ?? ""
            let cropping = "cropping".data(using: .utf8, allowLossyConversion: false)!
            contentEditingOutput.adjustmentData = PHAdjustmentData(formatIdentifier: formatIdentifier,
                                                                   formatVersion: "0.1",
                                                                   data: cropping)
            try croppedPhotoJPEG.write(to: contentEditingOutput.renderedContentURL,
                                       options: .atomicWrite)
            PHPhotoLibrary.shared().performChanges({
                let request = PHAssetChangeRequest(for: self.asset)
                request.contentEditingOutput = contentEditingOutput
            }, completionHandler: { (success, error) in
                if success {
                    print("modify success")
                } else {
                    print("modify fail \(error?.localizedDescription ?? "")")
                }
            })
        } catch {
            print("modify error \(error)")
        }
    }
}

新增相册中的照片

PHPhotoLibrary.shared().performChanges({
    let request = PHAssetChangeRequest.creationRequestForAsset(from: croppedPhoto)
}) { (success, _) in
    if success {
        print("save success")
    } else {
        print("save fail")
    }
}