iOS8 Day-by-Day-- Day2 -- 分享应用扩展

开场白

在iOS8中,有一个重量级的特性,那就是应用扩展。不论是第三方的应用还是Apple自家的应用,开发者们都可以根据这些应用自身的特点通过应用扩展提升应用与系统之间的交互性、应用的用户体验等等。目前在iOS中提供6种应用场景的扩展:

  • 通知界面widget
  • 分享扩展
  • Action
  • 照片编辑扩展
  • 资源存储扩展
  • 自定义键盘

我们在今后的文章中都会介绍以上这些场景的应用扩展,但今天我们的主角是分享扩展。

关于分享其实我们都不陌生,比如我们打开一个应用,在某个内容页会有系统提供的分享按钮,点击该按钮后会出现一个应用图标列表(比如Twitter,Flickr,新浪微博等),意在让我们选择一个希望分享内容的应用。分享扩展就是能让我们可以增加这个列表,让我们自己的应用图标能出现在分享列表里。

这里需要注意的是,这篇文章的内容有一定的难度,Apple提供的应用扩展特性还是比较复杂的。本文会列举最常用的使用场景作为示例来讲解分享扩展,当然你也可以在我们提供的示例基础上设计属于你自己的应用界面风格。Apple在应用扩展方面提供了非常详尽的文档,如果你在学习这篇文章的过程中有不懂的地方,你可以参阅Apple官方文档。

本文的示例应用名为“ShareAlike”,我们会通过该示例应用向大家展示如何实现图片、文本信息的分享。该示例的代码可以在Github中下载。

创建一个分享扩展

首先我们要清楚,应用扩展不能单独存在,它必须要依附一个应用程序,我们称之为应用扩展的载体应用。应用扩展以二进制文件形式存在于载体应用中。Xcode为每一种应用扩展都提供了一个模板,但是我们必须通过给一个应用程序添加Target的方式创建应用扩展的模板,创建好模板后,会在工程中增加一个应用扩展模块以及必要的属性文件。

我们执行一个分享操作主要是通过一系列界面来完成,所以分享扩展的主要部分就是UI展现。因此我们可以看到分享扩展模板为我们提供了一个继承了SLComposeServiceViewController的类以及一个storyboard文件。SLComposeServiceViewController为我们提供了一些常规的行为(包括字数、图片展示、文本输入、发送和取消按钮),并且这些行为对应的界面都符合iOS UI的标准。在本文的示例中我们基本都会用到这些默认的行为。

除了UIViewController的标准方法外,SLComposeServiceViewController还提供了一些与分享功能生命周期相关的属性和方法:

  • presentationAnimationDidFinish()方法可以让我们执行大数据量的分享任务,我们在分享图片时会用到它。
  • contentText是一个String类型的属性,它存储分享者编辑的一些文字描述。
  • didSelectPost()函数在点击Post按钮时调用。它是上传分享数据的入口。
  • didSelectCancel()函数在点击Cancel按钮时调用。
  • isContentValid()函数在分享者编辑的文字有变化时调用。
  • charactersRemaining是一个Int类型的属性,当描述内容有字数限制时,它表示当前还能编写的字数,如果超出字数,该属性展示的字数会变红,并用负数表示已经超出了多少字数。

只要你的应用工程里添加了分享扩展,那么用户就能很轻松的将选中的内容分享到你的应用中。我们会一步一步展示如何实现这些有用的功能,但首先我们要学习如何编译、运行和调试。

编译,运行和调试

在添加一个扩展Target时,Xcode会让你激活扩展的Scheme。在运行工程之前,如果我们选择app的Scheme,那么会直接运行app,如果选择扩展的Scheme,那么会出现让你选择在哪个应用程序中运行该扩展,也就是选择一个扩展的Host应用,然后就可以针对扩展进行调试了。通常情况下运行分享扩展最好的Host应用是照片应用(Photos),所以我们一般都选择照片应用,然后调试分享扩展。

下面有几个步骤能帮助我们熟悉这个过程:

  • 不管是选择一个扩展的Host应用还是使用扩展的载体应用充当Host应用,它们都应该有比较容易能分享的内容。在示例项目中,ShareAlike扩展使用它的载体应用充当Host应用,载体应用包含一张图片和一个分享按钮,点击分享按钮时会弹出标准的分享内容界面:

@IBAction func handleShareSampleTapped(sender: AnyObject) {
shareContent(sharingText: "Highland Cow",
sharingImage: sharingContentImageView.image)
}

// Utility methods
func shareContent(#sharingText: String?, sharingImage: UIImage?) {
var itemsToShare = [AnyObject]()


if let text = sharingText {
itemsToShare.append(text)
}
if let image = sharingImage {
itemsToShare.append(image)
}


let activityViewController = UIActivityViewController(activityItems: itemsToShare, applicationActivities: nil)
presentViewController(activityViewController, animated: true, completion: nil)
}

图片

  • 在扩展中编写相关代码。
  • 调试或测试时,选择载体应用的Scheme编译并运行,当点击Share Sample时,调试器会自动关联到扩展的进程上,简言之也就是可以捕获到你在分享扩展代码中设置的断点。
  • 你也可以选择模拟器中的其他应用作为Host应用来使用分享扩展。但是调试器不会捕获到你设置的断点,也就是无法进行调试。你可以使用这种方式来测试扩展的运行情况,通常分享扩展最佳的Host应用是Photos。

分享扩展的分享内容属性设置

与在Info.plist文件中设置应用一样,在扩展中也有这个文件。在该文件中有一个属性代表在分享应用列表中,我们扩展的显示名称(分享应用列表中图标下的文字)。

这个属性是Bundle display nameCFBundleDisplayName):

图片

你还可以在Info.plist文件中定义你的分享扩展可以处理什么样的分享任务,比如说是否可以处理分享视频?

在该文件中有一个名为NSExtension字典类型属性,展开该属性后,可以看到NSExtensionAttributes属性,同样是字典类型,再展开该属性我们可以看到NSExtenionActivationRule属性,依然是字典类型的属性。可以看到NSExtension中有Boolean类型的、String类型的、Number类型的或者字典类型的各种属性。在NSExtenionActivationRule属性中就有设置分享图片数量,分享视频、文件、URL数量的属性:

图片

每一个属性表示的意思从字面上就可以理解,比如NSExtensionActivationSupportsImageWithMaxCount,类型为Number,值为1。意思就是我们的分享扩展支持的最大分享图片数为1,我们可以测试一下看看:

图片
图片

从上图中可以看到,当我们选择了一张照片时,在分享应用列表中有我们的ShareAlike,如果选择了两张照片,分享应用列表中就没有我们的扩展了。

验证用户输入的内容

到目前为止,你大概已经知道如何创建一个扩展并设置它的相关属性了,现在让我们看看如何实现一些自定义的扩展行为。首先,我们要知道如何验证用户的输入内容。比如我们通常情况下都需要的一个验证,就是限制用户输入的文字数量,我们可以通过SLComposeServiceViewController来实现它。

在之前我们提到过SLComposeServiceViewController类的一些属性方法,其中有一个方法叫isContentValid() -> Bool。该方法的作用时随时监听着分享界面文本域的变化,也就是说一旦用户输入或删除文字,都会触发该方法。该方法如果返回true,那说明用户输入的内容合法,并可以使用Post按钮完成分享,如果返回false,那说明输入内容不合法,并且不能使用Post按钮。下面的代码片段展示了如何实现文字数量限制功能:


let sc_maxCharactersAllowed = 25

override func isContentValid() -> Bool {
if let currentMessage = contentText {
let currentMessageLength = countElements(currentMessage)
charactersRemaining = sc_maxCharactersAllowed - currentMessageLength

if Int(charactersRemaining) < 0 {
return false
}
}
return true
}

上面代码中的contentTextSLComposeServiceViewController中的属性,类型为String!,它存储着分享界面中TextView中的文本信息。charactersRemaining也是SLComposeServiceViewController中的属性,类型为NSNumber,他表示还有多少合法的字数以及超出的字数,并显示在分享界面的左下角。你可以用countElements()方法获取输入文本的字数,用于计算charactersRemaining的值并判断是否超出了限定的最大字数。至于Post按钮的可用和禁用由charactersRemaining决定,不需要我们额外设置。

图片
图片

上传分享内容

到目前为止,我们已经知道了如何创建一个应用扩展、如何对应用扩展进行配置、如何控制用户的操作行为等。但是作为分享扩展,它的遵旨应该是将文本、图片、视频等资源上传至某个网络客户端(比如FaceBook、Twitter、新浪微博等)。下面就让我们看看应该如何做吧。

应用扩展相比载体应用或者Host应用来说,它是一个轻量级的、处理单一功能任务的组件,所以用户一般不会因为使用扩展而停止当前使用的应用或关闭正在看的内容。你可以试想一下,如果一个Host应用使用某个应用扩展让它处理一个简单的任务时,该扩展因为同时占用了Host应用的运行内存从而使Host应用不可用、退出以至于崩溃,这是多么令人发指的事情。因此,所有的上传操作都应该在后台执行(iOS7中NSURLSession类对实现该功能很有帮助)。你可能会认为跟着去年iOS7 Day-by-Day系列文章就能很容易的实现该功能,但事实并非如此。

事情往往不是你想象的那么简单。首先提取分享的内容就不是一件容易的事(比如图片),而且你还要将它们分享出去,其次应用扩展没有任何写硬盘的权限。关于第二点,有人可能会有疑问:为什么应用扩展需要写硬盘的权限呢?因为所有后台上传的网络程序,在上传时都会先将上传内容缓存在硬盘中,然后从硬盘获取缓存的内容开始上传。为了能模拟硬盘缓存,我们需要在Host应用中创建一个存放分享内容的容器,并且要允许应用扩展使用该容器缓存分享内容。我们会在下面的内容中讲解如何实现该功能,但首先,我们先来看看如何提出分享内容中的图片。

提取要分享的图片

SLComposeServiceViewController类中有一个属性叫extensionContext,它存储着与当前应用扩展有关联的所有数据,其中包含一个NSInputItem类型的数组,名叫inputItems。每个NSInputItem都含有一个attachments集合,它们的类型都为NSItemProvider。这些attachments存储的就是分享内容中的媒体资源,比如图片、视频、文件或链接。


func imageFromExtensionItem(extensionItem: NSExtensionItem, callback: (image: UIImage?)->Void) {
for attachment in extensionItem.attachments as [NSItemProvider] {
...
}
}

上面这个方法的作用是从分享内容中提取图片(UIImage),注意这个方法没有返回值,而是用一个闭包回调函数来替代。

为了确定attachments中是否包含了图片类型的资源,我们需要用到hasItemConformingToTypeIdentifier()方法。


if(attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as NSString)) {
...
}

该方法的参数类型是NSString,并且类型标示符kUTTypeImage属于MobileCoreServices框架中的属性,所以我们要引入MobileCoreServices框架:


import MobileCoreServices

该框架中还包含几种类型标示符:

  • kUTTypeImage
  • kUTTypeMovie
  • kUTTypeAudio
  • kUTTypeSpreadsheet

现在你可以确定attachments中至少包含了一个图片资源,并且需要将该图片资源提取出来。因为执行该任务会消耗很高的内存资源,所以为了确保在执行时UI界面不会出现无响应的情况,它应该在后台队列中执行。我们可以使用loadItemForTypeIdentifier()方法,与刚才的方法类似,该方法也有类型标示符参数,并且用闭包实现对图片的操作:


// Marshal on to a background thread
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, UInt(0))) {
attachment.loadItemForTypeIdentifier(kUTTypeImage as NSString, options: nil) {
(imageProvider, error) -> Void in
...
}
}

如果你使用Objective-C,那么你可以用block将返回结果强制转换为你期望的类型(比如UIImage),然后实现回调。但是在Swift中就行不通了,所以上面代码中的imageProvider变量的类型应该是NSSecureCoding,然后你可以将它强转为UIImage类型:


var image: UIImage? = nil
if let e = error {
println("Item loading error: \(e.localizedDescription)")
}
image = imageProvider as? UIImage
dispatch_async(dispatch_get_main_queue()) {
callback(image: image)
}

大家注意,在dispatch_async中我使用了callback()函数,也就是imageFromExtensionItem方法的第二个参数,并将刚才强转为UIImage类型的image对象作为其参数。

这段代码可以让用户在输入分享文本信息的同时将attachments中的图片提取出来。看起来非常完美:


var attachedImage: UIImage?

override func presentationAnimationDidFinish() {
// Only interested in the first item
let extensionItem = extensionContext.inputItems[0] as NSExtensionItem
// Extract an image (if one exists)
imageFromExtensionItem(extensionItem) {
image in
if image {
dispatch_async(dispatch_get_main_queue()) {
self.attachedImage = image
}
}
}
}

该方法的作用就是提取出图片,并将提取出的图片赋值给我们定义的attachedImage变量。

执行后台上传的任务

一旦用户输入完要分享的文本信息,按下Post按钮后,分享扩展就应该将所有内容通过Web服务上传到某个地方。本文的示例为了达到这个目的,我们在视图控制器中定义了一个常量属性sc_uploadURL,值为一个URL,也就是一个服务地址:


let sc_uploadURL = "http://requestb.in/oha28noh"

这个URL是Request Bin的服务,Request Bin可以给你提供一个临时的URL,用于测试一些网络操作。上面代码中的这个URL对你们来说没有什么用,因为这是我申请的,你们可以去requestb.in申请一个自己的URL用于测试。

在前面我们提到过,应用扩展不应该和Host应用抢占内存资源,它应该在后台执行相关任务。因此,当Post按钮被按下时,在当前的Host应用中我们不会看到任何有关应用扩展的执行痕迹,像同步、网络操作等。此时,我们就需要使用NSURLSession给我们提供的API来实现后台的网络操作。

当用户点击Post按钮后会调用didSelectPost()方法,它的代码片段应该是这样的:


override func didSelectPost() {
// Perform upload
...

// Inform the host that we're done, so it un-blocks its UI.
extensionContext.completeRequestReturningItems(nil, completionHandler: nil)
}

设置NSURLSession很简单,有规范的设置流程:


let configName = "com.shinobicontrols.ShareAlike.BackgroundSessionConfig"
let sessionConfig = NSURLSessionConfiguration.backgroundSessionConfigurationWithIdentifier(configName)
// Extensions aren't allowed their own cache disk space. Need to share with application
sessionConfig.sharedContainerIdentifier = "group.ShareAlike"
let session = NSURLSession(configuration: sessionConfig)

我们要特别注意上面代码片段中sharedContainerIdentifier的设置,它给NSURLSession使用的共享容器(用于缓存分享内容)指定了一个名称,这个容器也是扩展载体应用的一部分(在这个例子中,载体应用就是ShareAlike),所以我们要通过Xcode对载体应用进行设置:

  1. 在工程设置面板中选择Capabilities页签,然后在左侧Targets栏中选中载体应用。
  2. 开启载体应用的App Groups
  3. 创建一个App Group,起一个合适的名称,但是必须要以group.开头。在我们的示例中,我们刚才设置的名称为group.ShareAlike
  4. Xcode会进行一系列检查,然后创建App Group。

图片

然后选中应用扩展,开启App Groups,然后选择刚才创建好的App Group。

图片

这些App Group都是登记在你的开发者账号下的,这样才能确保只有你的应用可以使用这些共享容器。

Xcode会为每个工程创建一个.entitlements授权文件,里面就包含有共享容器的访问名称。

现在NSURLSession就已经设置成功了,你还需要创建一个URL request对象来发送请求:


// Prepare the URL Request
let request = urlRequestWithImage(attachedImage, text: contentText)

urlRequestWithImage函数的作用是构造一个URL请求结构,通过HTTP Post方式发送一些JSON格式的数据。这其中就包含了文本内容和图片的元数据信息:


func urlRequestWithImage(image: UIImage?, text: String) -> NSURLRequest? {
let url = NSURL.URLWithString(sc_uploadURL)
let request = NSMutableURLRequest(URL: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.addValue("application/json", forHTTPHeaderField: "Accept")
request.HTTPMethod = "POST"

var jsonObject = NSMutableDictionary()
jsonObject["text"] = text
if let image = image {
jsonObject["image_details"] = extractDetailsFromImage(image)
}

// Create the JSON payload
var jsonError: NSError?
let jsonData = NSJSONSerialization.dataWithJSONObject(jsonObject, options: nil, error: &jsonError)
if jsonData {
request.HTTPBody = jsonData
} else {
if let error = jsonError {
println("JSON Error: \(error.localizedDescription)")
}
}

return request
}

这个方法实际上并不是创建一个上传图片的请求,虽然也可以这样做,但这里我们将图片的详细信息(元数据)提取出来进行上传,下面代码是提取图片详细信息的方法:


func extractDetailsFromImage(image: UIImage) -> NSDictionary {
var resultDict = NSMutableDictionary()
resultDict["height"] = image.size.height
resultDict["width"] = image.size.width
resultDict["orientation"] = image.imageOrientation.toRaw()
resultDict["scale"] = image.scale
resultDict["description"] = image.description
return resultDict.copy() as NSDictionary
}

最后你就可以通过resume()在后台发起上传请求了:


// Create the task, and kick it off
let task = session.dataTaskWithRequest(request)
task.resume()

如果你按下Post按钮完成分享后,在requestb.in中,你应该可以看到如下的结果:

图片

原文地址:iOS8 Day-by-Day :: Day 2 :: Sharing Extension

分享到: