肇鑫的技术博客

业精于勤,荒于嬉

OptionSet与NSPredicate

在Swift中,我们习惯了使用contains来比较OptionSet,这个方法使用起来十分简单,就不赘述了。某些情况下,我们必须使用NSPredicate来进行比较OptionSet,由于Objective-C不支持contains,所以比较的方法有所不同。特别的,我们有时需要考虑组合后的特性,因为不是数学简单的相等关系,有时候理解起来存在一定的困难,容易出错。

照片库搜索遇到的问题

我打算搜索照片库,需要排除掉实况照片和截图,我的代码一开始是这么写的:

let notLivePhotoPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "mediaSubtypes != %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

这段代码运行之后,结果为空。我不是很理解,但是我还是想办法更改了代码:

let livePhotoPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "mediaSubtypes == %d", PHAssetMediaSubtype.photoScreenshot.rawValue)
let notLivePhotoPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: livePhotoPredicate)
let notScreenshotPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: screenshotPredicate)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

这段代码运行的结果倒是符合预期。这是为什么呢?难道这两段代码不应该是等价的吗?

于是我重新查看苹果的文档,结果我发现,PHAssetMediaSubtype不是enum,而是OptionSet。在NSPredicate中,比较OptionSet不能用相等,而应该使用:

let livePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoLive.rawValue)
let screenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) != 0", PHAssetMediaSubtype.photoScreenshot.rawValue)

上面的代码运行同样符合预期。那如果将代码改成如下呢?

let notLivePhotoPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoLive.rawValue)
let notScreenshotPredicate = NSPredicate(format: "(mediaSubtypes & %d) == 0", PHAssetMediaSubtype.photoScreenshot.rawValue)
let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [notLivePhotoPredicate, notScreenshotPredicate])

又不工作了。这是?

这是因为类型是OptionSet,比较不是简单的是否相等的算术关系。比如不工作的最后那段代码,如果是Set,那么结果永远是空集。

最终我的代码:

let combinedTypes:PHAssetMediaSubtype = [.photoLive, .photoScreenshot]
let combinedTypesPredicate =  NSPredicate(format: "(mediaSubtypes & %d) != 0", combinedTypes.rawValue)
let notCombinedTypesPredicate = NSCompoundPredicate(notPredicateWithSubpredicate: combinedTypesPredicate)

参考

NSPredicate syntax for PHFetchOption keys

An Error Causing by IndexSet

I got an error of 3301 of PHPhotosError, when using insertAssets(_:at:).

The Limits of Photo Library

The first mistake I found was that my result was reordered by date, but there were two photos with the same date. That meant I had inserted two indexes with the same value to the indexSet, which meant the count of indexSet was smaller than the companied array.

So I created a placeholder date to replace the date matched the item of the array, that made the count of the indexSet and array equal.

But the error was still shown. So I had to investigate again. This time I found the two photos with the same date were the same photo. So when inserting the photos, when the second copy of the same photo was inserted, the photo library had no change and the indexes behind the second copy were all mismatched.

The fix was to excluded the photos from the inserted album that existed in the container, like:

var albumPhotoSet = ...
let containerPhotoSet = ...
albumPhotoSet.substract(containerPhotoSet)
let albums = Array(albumSet)
...

The error still existed.

IndexSet Behaviors Differently from Set

After reading the document of insertAssets(_:at:), I found that IndexSet is a set, but behaviors differently.

let set:Set<Int> = [1, 3, 2, 4, 5]
print(set) // [3, 1, 2, 4, 5], [1, 4, 3, 5, 2], result is radom of 1...5

let indexSet:IndexSet = [1, 3, 2, 4, 5]
print(indexSet) // 5 indexes
print(indexSet.map {$0}) // [1, 2, 3, 4, 5], always

Since the IndexSet is sorted automatically instead of the order it is given. The array it is companied must have the same order.

var albums = Array(albumSet)
albums.sort() // by date
...

This time everything worked.

Conclusion

When inserting photos to an album, there are three steps.

  1. Sorted the photos of the album the same order you wanted for the final result.
  2. Exclude the photos already existed in the album from the inserting photos.
  3. Reorder the inserting photos.
  4. Merge the photos in the album and the inserting photos, reorder the result, and get the indexes of the inserting photos.
  5. Insert the inserting photos to the album.

Something Apple Didn't Say Right On Photos Framework

I am developing an app basing on Photos framework. However, I find that Photos framework is something mainly for Photos.app and not well documented.

I will listed them for further references.

System Default Alert Message May Confuse Users.

When removing an album from photo library, you must use class func deleteAssetCollections(_ assetCollections: NSFastEnumeration). The system will automatically represents an alert to ask the user whether to remove the album. However, there is a message says that only the album is removed, the photos inside the album are kept.

The message is accurate for Photos.app. However, if we want to remove the photos together with the album, there is no API for that, so we have to call the remove API twice, one for photos, the other for album. There will be two alerts shown and the alert message mentioned may confuse the user as the photos are removed this time.

Folders

Folder can contain other Folders, but not its parents.

Folder, which is called PHCollectionList is said can contain other folders. That leads me to say what if folder A contains folder B and B contains A, which make the relation recursive. So I created an example, and I found that when A contains B and B contains A, photo library would rise an error. So the recursive relation is not allowed.

The Collection operations are all movement, not copying.

When putting other collections into one folder, whether you use addChildCollections(_:) or insertChildCollections(_:at:) is irrelevant. The collections are just moved inside the folder, not copied.

This rule also makes there is not duplicated folders in photo library.

The result of fetchCollections(in:options:) is shallow.

Although fetchCollections(in:options:) says it returns "By default, the returned PHFetchResult object contains all collections in the specified collection list." The document is not accurate. It only returns the shallow collections, not all collections. For example, we have three folders, A, B and C. The relations of them is: A -> B -> C, A contains B, B contains C.

let collections = PHCollection.fetchCollections(in: A, options: nil)
print(collections.count)
// 1. Print 1 instead of 2. As folder B is the only collection with shallow search.

Other constrains of folder operations

PHPhotoLibrary.shared().performChanges {
    let requestA = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderA")
    let requestB = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderB")
    let folderB = requestB.placeholderForCreatedCollectionList
    let requestC = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderC")
    let folderC = requestC.placeholderForCreatedCollectionList
    requestA.addChildCollections([folderB] as NSFastEnumeration) // move B to A, works
    requestB.addChildCollections([folderC] as NSFastEnumeration) // move C to B, works
    requestA.addChildCollections([folderC] as NSFastEnumeration) // move C to A, doesn't work
} completionHandler: { success, error in
    if success {
        print("success")
    } else {
        print(error?.localizedDescription ?? "nil")
    }
}

Result in Photos.app: A -> B -> C
test_1

Line 9 doesn't work. I guess because C has been already in A by contained by B.

However, code below works.

PHPhotoLibrary.shared().performChanges {
    let requestA = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderA")
    let requestB = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderB")
    let folderB = requestB.placeholderForCreatedCollectionList
    let requestC = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderC")
    let folderC = requestC.placeholderForCreatedCollectionList
    requestA.addChildCollections([folderB] as NSFastEnumeration) // move B to A, works
    requestA.addChildCollections([folderC] as NSFastEnumeration) // move C to A, works
    requestB.addChildCollections([folderC] as NSFastEnumeration) // move C to B, works
} completionHandler: { success, error in
    if success {
        print("success")
    } else {
        print(error?.localizedDescription ?? "nil")
    }
}

Apple avoids structural complexity by design.

In Photos.app on macOS, you can only move the collections besides the target folder on the same level. This avoid the complexity of the model structure. In fact, you can move any folder to the target folder. However, that will make not every move operation working, which may confuse user that doesn't familiar with the rules.