自定义Share Extension

作者:@nixzhu


在iOS上,若一个app要接收从其它app过来的数据,通常的做法是实现一个分享扩展。例如,从相册分享图片到你的app中,或者从Safari分享链接到你的app中。

如果你的需求比较简单,那继承SLComposeServiceViewController使用系统提供的UI将最方便。但如果设计上有更复杂的要求,你就只能通过自定义UIViewController来做了。

通常,分享扩展的起始界面由MainInterface.storyboard指定,如果你不想使用Storyboard,也可以修改分享扩展中的Info.plist来指定一个NSExtensionPrincipalClass ,它可以直接继承自UIViewController,你可以在此找到更详细的说明。不过,我们也可以直接修改Storyboard,增加一个View Controller并指定其为Initial View Controller,然后让这个View Controller使用我们自定义的UIViewController。

如果你的自定义UI不是全屏的,我会建议你在之前的Initial View Controller里增加一个Container View,形如:

Container View

这样,基本的UI框架就OK了。如果你要做动画,那在第一个控制器里让Container View动画即可,后续的控制器可以利用delegate来让第一个控制器做事。

UI确定后,接下来考虑获取分享数据。假设从系统相册分享图片。

在App Extension里,UIViewController新增了一个属性var extensionContext: NSExtensionContext?,通过它,我们可以让Initial View Controller准备好数据。我们先给NSExtensionContext增加一个扩展方法:

  1. extension NSExtensionContext {
  2. func circle_images(in vc: UIViewController, completion: @escaping (_ images: [ShareInfo.Image]) -> Void) {
  3. let extensionContext = self
  4. guard let extensionItems = extensionContext.inputItems as? [NSExtensionItem] else {
  5. return completion([])
  6. }
  7. var images: [ShareInfo.Image] = []
  8. let imageTypeIdentifier = kUTTypeImage as String
  9. let group = DispatchGroup()
  10. for extensionItem in extensionItems {
  11. for attachment in extensionItem.attachments as! [NSItemProvider] {
  12. if attachment.hasItemConformingToTypeIdentifier(imageTypeIdentifier) {
  13. group.enter()
  14. var previewImage: UIImage?
  15. var fileURL: URL?
  16. let loadGroup = DispatchGroup()
  17. loadGroup.enter()
  18. attachment.loadPreviewImage(options: [:]) { secureCoding, _ in
  19. defer {
  20. loadGroup.leave()
  21. }
  22. previewImage = secureCoding as? UIImage
  23. }
  24. let previewImagePreferredSize = CGSize(width: 300, height: 300)
  25. loadGroup.enter()
  26. attachment.loadItem(forTypeIdentifier: imageTypeIdentifier, options: nil) { secureCoding, _ in
  27. defer {
  28. loadGroup.leave()
  29. }
  30. if let url = secureCoding as? URL {
  31. fileURL = url
  32. } else if let image = secureCoding as? UIImage {
  33. if let data = UIImageJPEGRepresentation(image, 0.9) {
  34. let imageName = "\(UUID().uuidString).jpg"
  35. let tempImageURL = FileManager.default.temporaryDirectory.appendingPathComponent(imageName)
  36. do {
  37. try data.write(to: tempImageURL)
  38. fileURL = tempImageURL
  39. let fixedSize = image.size.circle_fixed(forPreferredSize: previewImagePreferredSize)
  40. previewImage = image.yy_imageByResize(to: fixedSize)
  41. } catch {
  42. vc.circle_alert(message: "\(error)")
  43. }
  44. }
  45. }
  46. }
  47. loadGroup.notify(queue: .main) { [weak self] in
  48. defer {
  49. group.leave()
  50. }
  51. guard let fileURL = fileURL else { return }
  52. if previewImage == nil {
  53. if let image = UIImage(contentsOfFile: fileURL.path) {
  54. let fixedSize = image.size.circle_fixed(forPreferredSize: previewImagePreferredSize)
  55. previewImage = image.yy_imageByResize(to: fixedSize)
  56. }
  57. }
  58. guard let previewImage = previewImage else { return }
  59. let image = ShareInfo.Image(
  60. previewImage: previewImage,
  61. fileURL: fileURL
  62. )
  63. images.append(image)
  64. }
  65. }
  66. }
  67. }
  68. group.notify(queue: .main) {
  69. completion(images)
  70. }
  71. }
  72. }

除开两层DispatchGroup,这个方法没有难以理解的东西。但要注意的是,loadItem回调里的secureCoding既可能是一个文件URL也可能是一个UIImage(还有其他的可能性,参考The Struggle with Action Extensions)。而且,loadPreviewImage的回调里不一定能找到UIImage。但通常,若从系统相册分享,Preview Image一般都存在;若从其它地方分享,secureCoding一般都是UIImage或文件URL,Preview Image不一定存在(虽然,按照Apple的建议,数据提供方有责任提供Preview Image)。

其中,ShareInfo是一个类似这样的结构:

  1. struct ShareInfo {
  2. struct Image {
  3. let previewImage: UIImage
  4. let fileURL: URL
  5. }
  6. var images: [Image] = []
  7. //...
  8. }

注意我们用fileURL来指定原图片,而不是直接将其数据拿到生成UIImage,这里有内存占用的考量。因为在Share Extension中,我们可以使用的内存比较有限(iPhone 7上大约70MB),如果用户选择了很多图片,而我们又全部生成UIImage,那内存很可能暴涨,我们的Share Extension进程就会被iOS强制杀掉。此外,用户选择的图片可能是GIF,你可能也需要对它进行特殊的判断,超过一定的大小可能要提示用户或者放弃分享。

对于其它数据,例如Text、Web URL或者File,你可以写出类似的扩展方法。

有了数据之后,就是具体的分享操作了。如果你的app架构合理,例如使用了Framework来封装核心功能,并能在扩展中使用这些Framework,那么你会比较轻松。不然,你要整理一些分享扩展中用到的逻辑,提取代码公用。此外,就是再次关注使用fileURL时的内存占用,你可能需要一些锁机制,一次只处理一个fileURL,让内存能被及时回收。

分享完成或者放弃分享后,正确调用extensionContext的

  1. func completeRequest(returningItems items: [Any]?, completionHandler: ((Bool) -> Swift.Void)? = nil)

  1. func cancelRequest(withError error: Error)

来确保分享扩展被正确释放。

如果你需要在后台发送,直接使用Background Task可能不行(参考What we learned building the Tumblr iOS share extension)。我使用的一个hack是先调用completeRequest,但在其completionHandler里等待一个信号量。这样分享扩展并不会立即被释放,让你的上传有时间在后台完成(完成后再发送信号量)。

最后,如果你的app会作为系统分享的数据源,除了数据的质量外,你有责任准备Preview Image,请参考NSItemProvider相关的API。

广告时间:「圈子」1.1版现已上线,终于可以自由建圈了,欢迎尝试!或者加入我创建的「可爱的Bug」圈来分享你与Bug的故事。我相信,被说出来的Bug将无处遁形。


欢迎转载,但请一定注明出处! https://github.com/nixzhu/dev-blog