AppExtention - today

声明: 本文转自王巍 ios

WWDC 2014 Session笔记 - iOS 通知中心扩展制做入门git

本文是个人 WWDC 2014 笔记 中的一篇,涉及的 Session 有github

总览

扩展 (Extension) 是 iOS 8 和 OSX 10.10 加入的一个很是大的功能点,开发者能够经过系统提供给咱们的扩展接入点 (Extension point) 来为系统特定的服务提供某些附加的功能。对于 iOS 来讲,可使用的扩展接入点有如下几个:swift

  • Today 扩展 - 在下拉的通知中心的 "今天" 的面板中添加一个 widget
  • 分享扩展 - 点击分享按钮后将网站或者照片经过应用分享
  • 动做扩展 - 点击 Action 按钮后经过判断上下文来将内容发送到应用
  • 照片编辑扩展 - 在系统的照片应用中提供照片编辑的能力
  • 文档提供扩展 - 提供和管理文件内容
  • 自定义键盘 - 提供一个能够用在全部应用的替代系统键盘的自定义键盘或输入法

系统为咱们提供的接入点虽然还比较有限,可是很多已是在开发者和 iOS 的用户中呼声很高的了。而经过利用这些接入点提供相应的功能,也能够极大地丰富系统的功能和可用性。本文将先不失通常性地介绍一下各类扩展的共通特性,而后再以一个实际的例子着重介绍下通知中心的 Today 扩展的开发方法,以期为 iOS 8 的扩展的学习提供一个平滑的入口。app

Apple 指出,iOS 8 中开发者的中心并不该该发生改变,依然应该是围绕 app。在 app 中提供优秀交互和有用的功能,如今是,未来也会是 iOS 应用开发的核心任务。而扩展在 iOS 中是不能以单独的形式存在的,也就是说咱们不能直接在 AppStore 提供一个扩展的下载,扩展必定是随着一个应用一块儿打包提供的。用户在安装了带有扩展的应用后,将能够在通知中心的今日界面中,或者是系统的设置中来选择开启仍是关闭你的扩展。而对于开发者来讲,提供扩展的方式是在 app 的项目中加入相应的扩展的 target。由于扩展通常来讲是展示在系统级别的 UI 或者是其余应用中的,Apple 特别指出,扩展应该保持轻巧迅速,而且专一功能单一,在不打扰或者中断用户使用当前应用的前提下完成本身的功能点。由于用户是能够本身选择禁用扩展的,因此若是你的扩展表现欠佳的话,极可能会遭到用户弃用,甚至致使他们将你的 app 也一并卸载。框架

扩展的生命周期

扩展的生命周期和包含该扩展的你的容器 app (container app) 自己的生命周期是独立的,准确地说。它们是两个独立的进程,默认状况下互相不该该知道对方的存在。扩展须要对宿主 app (host app,即调用该扩展的 app) 的请求作出响应,固然,经过进行配置和一些手段,咱们能够在扩展中访问和共享一些容器 app 的资源,这个咱们稍后再说。ide

由于扩展实际上是依赖于调用其的宿主 app 的,所以其生命周期也是由用户在宿主 app 中的行为所决定的。通常来讲,用户在宿主 app 中触发了该扩展后,扩展的生命周期就开始了:好比在分享选项中选择了你的扩展,或者向通知中心中添加了你的 widget 等等。而全部的扩展都是由 ViewController 进行定义的,在用户决定使用某个扩展时,其对应的 ViewController 就会被加载,所以你能够像在编写传统 app 的 ViewController 那样获取到诸如viewDidLoad 这样的方法,并进行界面构建及作相应的逻辑。扩展应该保持功能的单一专一,而且迅速处理任务,在执行完成必要的任务,或者是在后台预定完成任务后,通常须要尽快经过回调将控制权交回给宿主 app,至今生命周期结束。模块化

按照 Apple 的说法,扩展可使用的内存是远远低于 app 可使用的内存的。在内存吃紧的时候,系统更倾向于优先搞掉扩展,而不会是把宿主 app 杀死。所以在开发扩展的时候,也必定须要注意内存占用的限制。另外一点是好比像通知中心扩展,你的扩展可能会和其余开发人员的扩展共存,这样若是扩展阻塞了主线程的话,就会引发整个通知中心失去响应。这种状况下你的扩展和应用也就基本和用户说再见了..布局

扩展和容器应用的交互

扩展和容器应用自己并不共享一个进程,可是做为扩展,实际上是主体应用功能的延伸,确定不可避免地须要使用到应用自己的逻辑甚至界面。在这种状况下,咱们可使用 iOS 8 新引入的自制 framework 的方式来组织须要重用的代码,这样在连接 framework 后 app 和扩展就都能使用相同的代码了。post

另外一个常见需求就是数据共享,即扩展和应用互相但愿访问对方的数据。这能够经过开启 App Groups 和进行相应的配置来开启在两个进程间的数据共享。这包括了使用 NSUserDefaults 进行小数据的共享,或者使用NSFileCoordinator 和 NSFilePresenter 甚至是 CoreData 和 SQLite 来进行更大的文件或者是更复杂的数据交互。

另外,一直以来的自定义的 url scheme 也是从扩展向应用反馈数据和交互的渠道之一。

这些常见的手段和策略在接下来的 demo 中都会用到。一张图片能顶千言万语,而一个 demo 能顶千张图片。那么,咱们开始吧。

Timer Demo

Demo 作的应用是一个简单的计时器,即点击开始按钮后开始倒数计时,每秒根据剩余的时间来更新界面上的一个表示时间的 Label,而后在计时到 0 秒时弹出一个 alert,来告诉用户时间到,固然用户也可使用 Stop 按钮来提早打断计时。其实这个 Demo 就是个人很早以前作的一个番茄工做法的 app 的原型。

为了你们方便跟随这个 demo,我把初始的时候的代码放到 GitHub 的 start-project 这个 tag 上了。语言固然是要用 Swift,界面由于不是 demo 的重点,因此就很是简单能代表意思就行了。可是虽然简单,却也是利用了上一篇文章中所提到的 Size Classes 来完成的不一样屏幕的布局,因此至少能够说在思想上是完备的 iOS 8 兼容了 =_=..

初始工程运行起来的界面大概是这样的:

初始工程

简单说整个项目只有一个 ViewController,点击开始按钮时咱们经过设定但愿的计时时间来建立一个 Timer 实例,而后调用它的 start 方法。这个方法接收两个参数,分别是每次剩余时间更新,以及计时结束(不管是计时时间到的完成仍是计时被用户打断)时的回调方法。另外这个方法返回一个 tuple,用来表示是否开始成功以及可能的错误。

剩余时间更新的回调中刷新界面 UI,计时结束的回调里回收了 Timer 实例,而且显示了一个UIAlertController。用户经过点击 Stop 按钮能够直接调用 stop 方法来打断计时。直接简单,没什么其余的 trick。

咱们如今计划为这个 app 作一个 Today 扩展,来在通知中心中显示并更新当前的剩余时间,而且在计时完成后显示一个按钮,点击后能够回到 app 本体,并弹出一个完成的提示。

添加扩展 Target

第一步固然是为咱们的 app 添加扩展。正如在总览中所提到的,扩展是项目中的一个单独的 target。在 Xcode 6 中, Apple 为咱们准备了对应各种不一样扩展点的 target 模板,这使得向 app 中添加扩展很是容易。对于咱们如今想作的 Today 扩展,只需点选菜单的 File > New > Target...,而后选择 iOS 中的 Application Extension 的 Today Extension 就好了。

添加 target

在弹出的菜单中将新的 target 命名为 SimpleTimerTodayExtenstion,而且让 Xcode 自动生成新的 Scheme,以方便测试使用。咱们的工程中如今会多出一个和新建的 target 同名的文件夹,里面主要包含了一个 .swift 的 ViewController 程序文件,一个叫作 MainInterface 的 storyboard 文件和 Info.plist。其中在 plist 里 的NSExtension 中定义了这个 扩展的类型和入口,而配套的 ViewController 和 StoryBoard 就是咱们的扩展的具体内容和实现了。

咱们的主题程序在编译连接后会生成一个后缀为 .app 的包,里面包含主程序的二进制文件和各类资源。而扩展 target 将单独生成一个后缀名为 .appex 的文件包。这个文件包将随着主体程序被安装,并由用户选择激活或者添加(对于 Today widget 的话在通知中心 Today 视图中的编辑删增,对于其余的扩展的话,使用系统的设置进行管理)。咱们能够看到,如今项目的 Product 中已经新增了一个扩展了。

扩展的product

若是你有心已经打开了 MainInterface 文件的话,能够注意到 Apple 已经为咱们准备了一个默认的 Hello World 的 label 了。咱们这时候只要运行主程序,扩展就会一并安装了。将 Scheme 设为 Simple Timer 的主程序,Cmd + R,而后点击 Home 键将 app 切到后台,拉下通知中心。这时候你应该能在 Toady 视图中找到叫作SimpleTimerTodayExtenstion 的项目,显示了一个 Hello World 的标签。若是没有的话,能够点击下面的编辑按钮看看是否是没有启用,若是在编辑菜单中也没有的话,恭喜你遇到了和 Session 视频里的演讲者一样的 bug,你可能须要删除应用,清理工程,而后再安装试试看。通常来讲卸载再安装能够解决如今的 beta 版大部分的没法加载的问题,若是仍是遇到问题的话,你还能够尝试重启设备(按照以往几年的 SDK 的状况来看,beta 版里这很正常,正式版中应该就没什么问题了)。

若是一切正常的话,你能看到的通知中心应该相似这样:

Hello World widget

这种方式运行的扩展咱们没法对其进行调试,由于咱们的调试器并无 attach 到这个扩展的 target 上。有两种方法让咱们调试扩展,一种是将 Scheme 设为以前 Xcode 为咱们生成的 SimpleTimerTodayExtenstion,而后运行时选择从 Today 视图进行运行,如图;另外一种是在扩展运行时使用菜单中的 Debug > Attach to Process > By Process Identifier (PID) or name,而后输入你的扩展的名字(在咱们的 demo 中是 com.onevcat.SimpleTimer.SimpleTimerTodayExtension)来把调试器挂载到进程上去。

调试扩展

在应用和扩展间共享数据 - App Groups

扩展既然是个 ViewController,那各类链接 IBOutlet,使用 viewDidLoad 之类的生命周期方法来设置 UI 什么的天然不在话下。咱们如今的第一个难点就是,如何获取应用主体在退出时计时器的剩余时间。只要知道了还剩多久以及什么时候退出,咱们就能在通知中心中显示出计时器正确的剩余时间了。

对 iOS 开发者来讲,沙盒限制了咱们在设备上随意读取和写入。可是对于应用和其对应的扩展来讲,Apple 在 iOS 8 中为咱们提供了一种可能性,那就是 App Groups。App Groups 为同一个 vender 的应用或者扩展定义了一组域,在这个域中同一个 group 能够共享一些资源。对于咱们的例子来讲,咱们只须要使用同一个 group 下的NSUserDefaults 就能在主体应用不活跃时向其中存储数据,而后在扩展初始化时从同一处进行读取就好了。

首先咱们须要开启 App Groups。得益于 Xcode 5 开始引入的 Capabilities,这变得很是简单(至少再也不须要去 developer portal 了)。选择主 target SimpleTimer,打开它的 Capabilities 选项卡,找到 App Groups 并打开开关,而后添加一个你能记得的 group 名字,好比 group.simpleTimerSharedDefaults。接下来你还须要为SimpleTimerTodayExtension 这个 target 进行一样的配置,只不过再也不须要新建 group,而是勾选刚才建立的 group 就行。

启用 App Groups

而后让咱们开始写代码吧!首先是在主体程序的 ViewController.swift 中添加一个程序失去前台的监听,在viewDidLoad 中加入:

NSNotificationCenter.defaultCenter()
    .addObserver(self, selector: "applicationWillResignActive",name: UIApplicationWillResignActiveNotification, object: nil)

而后是所调用的 applicationWillResignActive 方法:

@objc private func applicationWillResignActive() {
    if timer == nil {
        clearDefaults()
    } else {
        if timer.running {
            saveDefaults()
        } else {
            clearDefaults()
        }
    }
}

private func saveDefaults() {
    let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults")
    userDefault.setInteger(Int(timer.leftTime), forKey: "com.onevcat.simpleTimer.lefttime")
    userDefault.setInteger(Int(NSDate().timeIntervalSince1970), forKey: "com.onevcat.simpleTimer.quitdate")

    userDefault.synchronize()
}

private func clearDefaults() {
    let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults")
    userDefault.removeObjectForKey("com.onevcat.simpleTimer.lefttime")
    userDefault.removeObjectForKey("com.onevcat.simpleTimer.quitdate")

    userDefault.synchronize()
}

这样,在应用切到后台时,若是正在计时,咱们就将当前的剩余时间和退出时的日期存到了 NSUserDefaults中。这里注意,可能通常咱们在使用 NSUserDefaults 时更多地是使用 standardUserDefaults,可是这里咱们须要这两个数据可以被扩展访问到的话,咱们必须使用在 App Groups 中定义的名字来使用 NSUserDefaults

接下来,咱们能够到扩展的 TodayViewController.swift 中去获取这些数据了。在扩展 ViewController 的viewDidLoad 中,添加如下代码:

let userDefaults = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults")
let leftTimeWhenQuit = userDefaults.integerForKey("com.onevcat.simpleTimer.lefttime")
let quitDate = userDefaults.integerForKey("com.onevcat.simpleTimer.quitdate")

let passedTimeFromQuit = NSDate().timeIntervalSinceDate(NSDate(timeIntervalSince1970: NSTimeInterval(quitDate)))

let leftTime = leftTimeWhenQuit - Int(passedTimeFromQuit)

lblTImer.text = "\(leftTime)"

固然别忘了把 StoryBoard 的那个 label 拖出来:

@IBOutlet weak var lblTImer: UILabel!

再次运行程序,并开始一个计时,而后按 Home 键切到后台,拉出通知中心,perfect,咱们的扩展可以和主程序进行数据交互了:

读取数据

在应用和扩展间共享代码 - Framework

接下来的任务是在 Today 界面中进行计时,来刷新咱们的界面。这部分代码其实咱们已经写过(固然..确切来讲是我写的,你可能只是看过),没错,就是应用中的 Timer.swift 文件。咱们只须要在扩展的 ViewController 中用剩余时间建立一个 Timer 的实例,而后在更新的 callback 里设置 label 就行了嘛。可是问题是,这部分代码是在应用中的,咱们要如何在扩展中也能使用它呢?

一个最直接也是最简单的想法天然是把 Timer.swift 加入到扩展 target 的编译文件中去,这样在扩展中天然也就可使用了。可是 iOS 8 开始 Apple 为咱们提供了一个更好的选择,那就是作成 Framework。单个文件可能不会以为有什么差异,可是随着须要共用的文件数量和种类的增长,将单个文件逐一添加到不一样 target 这种管理方法很快就会将事情弄成一团乱麻。你须要考虑每个新加或者删除的文件影响的范围,以及它们分别须要适用何处,这简直就是人间地狱。提供一个统一漂亮的 framework 会是更多人但愿的选择(其实也差很少成为事实标准了)。使用 framework 进行模块化的另外一个好处是能够得益于良好的访问控制,以保证你不会接触到不该该使用的东西,而后,Swift 的 namespace 是基于模块的,所以你也再也不须要担忧命名冲突等等一摊子 objc 时代的烦心事儿。

如今让咱们把 Timer.swift 放到 framework 里吧。首先咱们新建一个 framework 的 target。File > New > Target... 中选择 Framework & Library,选中 Cocoa Touch Framework (配图中的另外几个选项可能在你的 Xcode 中是没有的,请无视它们,这是历史遗留问题),而后肯定。按照 Apple 对 framework 的命名规范,也许 SimpleTimerKit 会是一个不错的名字。

创建框架

接下来,咱们将 Timer.swift 从应用中移动到 framework 中。很简单,首先将其从应用的 target 中移除,而后加入到新建的 SimpleTimerKit 的 Compile Sources 中。

添加 framework 文件

确认在应用中 link 了新的 framwork,而且在 ViewController.swift 中加上 import SimpleTimerKit 后试着编译看看...好多错误,基本都是 ViewController 中说找不到 Timer 之类的。这是由于原来的实现是在同一个 module 中的,默认的 internal 的访问层级就可让 ViewController 访问到关于 Timer 和相应方法的信息。可是如今它们处于不一样的 module 中,因此咱们须要对 Timer.swift 的访问权限进行一些修改,在须要外部访问的地方加上 public 关键字。关于 Swift 中的访问控制,能够参考 Apple 关于 Swift 的这篇官方博客,简单说就是 private 只容许本文件访问,不写的话默认是 internal,容许统一 module 访问,而要提供给别的 module 使用的话,须要声明为 public。修改后的 Timer.swift 文件大概是这个样子的。

修改合适的访问权限后,接下来咱们就能够将这个 framework 连接到扩展的 target 了。连接之后编译什么的能够经过,可是会多一个警告:

警告

这是由于做为插件,须要遵照更严格的沙盒限制,因此有一些 API 是不能使用的。为了不这个警告,咱们须要在 framework 的 target 中声明在咱们使用扩展可用的 API。具体在 SimpleTimerKit 的 target 的 General 选项卡中,将 Deployment Info 中的 Allow app extension API only 勾选上就能够了。关于在扩展里不能使用的 API,都已经被 Apple 标上了 NS_EXTENSION_UNAVAILABLE,在这里有一份简单的列表可供参考,基原本说都是 runtime 的东西以及一些会让用户迷惑或很危险的操做(固然这个标记的方法极可能会不断变更,最终一切请以 Apple 的文档和实际代码为准)。

开启 Extension Only

接下来,在扩展的 ViewController 中也连接 SimpleTimerKit 并加入 import SimpleTimerKit,咱们就能够在扩展中使用 Timer 了。将刚才的直接设置 label 的代码去掉,换成下面的:

override func viewDidLoad() {
    //...

    if (leftTime > 0) {
        timer = Timer(timeInteral: NSTimeInterval(leftTime))
        timer.start(updateTick: {
                [weak self] leftTick in self!.updateLabel()
            }, stopHandler: nil)
    } else {
        //Do nothing now
    }
}

private func updateLabel() {
    lblTimer.text = timer.leftTimeString
}

咱们在扩展里也像在 app 内同样,建立 Timer,给定回调,坐等界面刷新。运行看看,先进入应用,开始一个计时。而后退出,打开通知中心。通知中心中如今也开始计时了,并且确实是从剩余的时间开始的,一切都很完美:

通知中心计时

经过扩展启动主体应用

最后一个任务是,咱们想要在通知中心计时完毕后,在扩展上呈现一个 "完成啦" 的按钮,并经过点击这个按钮能回到应用,并在应用内弹出结束的 alert。

这其实最关键的在于咱们要如何启动主体容器应用,以及向其传递数据。可能不少同窗会想到 URL Scheme,没错经过 URL Scheme 咱们确实能够启动特定应用并携带数据。可是一个问题是为了经过 URL 启动应用,咱们通常须要调用 UIApplication 的 openURL 方法。若是细心的刚才看了 NS_EXTENSION_UNAVAILABLE 的同窗可能会发现这个方法是被禁用的(这也是很 make sense 的一件事情,由于说白了扩展经过 sharedApplication 拿到的实际上是宿主应用,宿主应用表示凭什么要让你拿到啊!)。为了完成一样的操做,Apple 为扩展提供了一个NSExtensionContext 类来与宿主应用进行交互。用户在宿主应用中启动扩展后,宿主应用提供一个上下文给扩展,里面最主要的是包含了 inputItems 这样的待处理的数据。固然对咱们如今的需求来讲,咱们只要用到它的openURL(URL:,completionHandler:) 方法就行了。

另外,咱们可能还须要调整一下扩展 widget 的尺寸,以让咱们有更多的空间显示按钮,这能够经过设定preferredContentSize 来作到。在 TodayViewController.swift 中加入如下方法:

private func showOpenAppButton() {
    lblTimer.text = "Finished"
    preferredContentSize = CGSizeMake(0, 100)

    let button = UIButton(frame: CGRectMake(0, 50, 50, 63))
    button.setTitle("Open", forState: UIControlState.Normal)
    button.addTarget(self, action: "buttonPressed:", forControlEvents: UIControlEvents.TouchUpInside)

    view.addSubview(button)        
}

在设定 preferredContentSize 时,指定的宽度都是无效的,系统会自动将其处理为整屏的宽度,因此扔个 0 进去就行了。在这里添加按钮时我偷了个懒,原本应该使用Auto Layout 和添加约束的,可是这并非咱们这个 demo 的重点。另外一方面,为了代码清晰明了,就直接上坐标了。

而后添加这个按钮的 action:

@objc private func buttonPressed(sender: AnyObject!) {
    extensionContext.openURL(NSURL(string: "simpleTimer://finished"), completionHandler: nil)
}

咱们将传递的 URL 的 scheme 是 simpleTimer,以 host 的 finished 做为参数,就能够通知主体应用计时完成了。而后咱们须要在计时完成时调用 showOpenAppButton 来显示按钮,更新 viewDidLoad 中的内容:

override func viewDidLoad() {
    //...
    if (leftTime > 0) {
        timer = Timer(timeInteral: NSTimeInterval(leftTime))
        timer.start(updateTick: {
            [weak self] leftTick in self!.updateLabel()
            }, stopHandler: {
                [weak self] finished in self!.showOpenAppButton()
            })
    } else {
        showOpenAppButton()
    }
}

最后一步是在主体应用的 target 里设置合适的 URL Scheme:

设置 url scheme

而后在 AppDelegate.swift 中捕获这个打开事件,并检测计时是否完成,而后作出相应:

func application(application: UIApplication!, openURL url: NSURL!, sourceApplication: String!, annotation: AnyObject!) -> Bool {
    if url.scheme == "simpleTimer" {
        if url.host == "finished" {
            NSNotificationCenter.defaultCenter()
                .postNotificationName(taskDidFinishedInWidgetNotification, object: nil)
        }
        return true
    }

    return false
}

在这个例子里,咱们发了个通知。而在 ViewController 中咱们能够一开始就监听这个通知,而后收到后中止计时并弹出提示就好了。固然咱们可能须要一些小的重构,好比添加是手动打断仍是计时完成的判断以弹出不同的对话框等等,这些都很简单再次就不赘述了。

完成

至此,咱们就完成了一个很基本的通知中心扩展,完整的项目能够在 GitHub repo 的 master 上找到。这个计时器如今在应用中只在前台或者通知中心显示时工做,若是你退出应用后再打开应用,其实这段时间内是没有计时的。所以这个项目以后可能的改进就是在返回应用的时候添加一下计时的断定,来更新计时器的剩余时间,或者是已经完成了的话就直接结束计时。

其余

其实在 Xcode 为咱们生成的模板文件中,还有这么一段代码也很重要:

func widgetPerformUpdateWithCompletionHandler(completionHandler: ((NCUpdateResult) -> Void)!) {
    // Perform any setup necessary in order to update the view.

    // If an error is encoutered, use NCUpdateResult.Failed
    // If there's no update required, use NCUpdateResult.NoData
    // If there's an update, use NCUpdateResult.NewData

    completionHandler(NCUpdateResult.NewData)
}

对于通知中心扩展,即便你的扩展示在不可见 (也就是用户没有拉开通知中心),系统也会时不时地调用实现了NCWidgetProviding 的扩展的这个方法,来要求扩展刷新界面。这个机制和 iOS 7 引入的后台机制是很类似的。在这个方法中咱们通常能够作一些像 API 请求之类的事情,在获取到了数据并更新了界面,或者是失败后都使用提供的 completionHandler 来向系统进行报告。

值得注意的一点是 Xcode (至少如今的 beta 4) 所提供的模板文件的 ViewController 里虽然有这个方法,可是它默认并无 conform 这个接口,因此要用的话,咱们还须要在类声明时加上 NCWidgetProviding

总结

这个 Demo 主要涉及了通知中心的 Toady widget 的添加和通常交互。其实扩展是一个至关大块的内容,对于其余像是分享或者是 Action 的扩展,其使用方式又会有所不一样。可是核心的概念,生命周期以及与本体应用交互的方法都是类似的。Xcode 在咱们建立扩展时就为咱们提供了很是好的模版文件,更多的时候咱们要作的只不过是在相应的方法内填上咱们的逻辑,而对于配置方面基本不太须要操心,这一点仍是很是方便的。

就为了扩展这个功能,我已经火烧眉毛地想用上 iOS 8 了..不管是使用别人开发的扩展仍是本身开发方便的扩展,都会让这个世界变得更美好。

相关文章
相关标签/搜索