IOS 8 App Extension编程 TodayViewController

总览
扩展 (Extension) 是 iOS 8 和 OSX 10.10 加入的一个非常大的功能点,开发者可以通过系统提供给我们的扩展接入点 (Extension point) 来为系统特定的服务提供某些附加的功能。对于 iOS 来说,可以使用的扩展接入点有以下几个:
1. Today 扩展 - 在下拉的通知中心的 "今天" 的面板中添加一个 widget
2. 分享扩展 - 点击分享按钮后将网站或者照片通过应用分享
3. 动作扩展 - 点击 Action 按钮后通过判断上下文来将内容发送到应用
4. 照片编辑扩展 - 在系统的照片应用中提供照片编辑的能力
5. 文档提供扩展 - 提供和管理文件内容
6. 自定义键盘 - 提供一个可以用在所有应用的替代系统键盘的自定义键盘或输入法



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

初始工程
 
简单说整个项目只有一个 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 命名为 SimpleTimerTodayExtenstion,并且让 Xcode 自动生成新的 Scheme,以方便测试使用。我们的工程中现在会多出一个和新建的 target 同名的文件夹,里面主要包含了一个 .swift 的 ViewController 程序文件,一个叫做 MainInterface 的 storyboard 文件和 Info.plist。其中在 plist 里 的 NSExtension 中定义了这个 扩展的类型和入口,而配套的 ViewController 和 StoryBoard 就是我们的扩展的具体内容和实现了。

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



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

如果一切正常的话,你能看到的通知中心应该类似这样:



这种方式运行的扩展我们无法对其进行调试,因为我们的调试器并没有 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 就行。



然后让我们开始写代码吧!首先是在主体程序的 ViewController.swift 中添加一个程序失去前台的监听(这里的主体程序是swift写的oc的另外处理),在 viewDidLoad 中加入:

  1. NSNotificationCenter.defaultCenter()   
  1.     .addObserver(self, selector: "applicationWillResignActive",name: UIApplicationWillResignActiveNotification, object: nil) 
然后是所调用的 applicationWillResignActive 方法:
    1. @objc private func applicationWillResignActive() { 
    1.     if timer == nil { 
    1.         clearDefaults() 
    1.     } else { 
    1.         if timer.running { 
    1.             saveDefaults() 
    1.         } else { 
    1.             clearDefaults() 
    1.         } 
    1.     } 
    1.   
    1. private func saveDefaults() {   
    1.     let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults"
    1.     userDefault.setInteger(Int(timer.leftTime), forKey: "com.onevcat.simpleTimer.lefttime"
    1.     userDefault.setInteger(Int(NSDate().timeIntervalSince1970), forKey: "com.onevcat.simpleTimer.quitdate"
    1.   
    1.     userDefault.synchronize() 
    1.   
    1. private func clearDefaults() {   
    1.     let userDefault = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults"
    1.     userDefault.removeObjectForKey("com.onevcat.simpleTimer.lefttime"
    1.     userDefault.removeObjectForKey("com.onevcat.simpleTimer.quitdate"
    1.   
    1.     userDefault.synchronize() 

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

    接下来,我们可以到扩展的 TodayViewController.swift 中去获取这些数据了。在扩展 ViewController 的 viewDidLoad 中,添加以下代码:
         
         
    1. let userDefaults = NSUserDefaults(suiteName: "group.simpleTimerSharedDefaults")   
    2. let leftTimeWhenQuit = userDefaults.integerForKey("com.onevcat.simpleTimer.lefttime")   
    3. let quitDate = userDefaults.integerForKey("com.onevcat.simpleTimer.quitdate"
    4.  
    5. let passedTimeFromQuit = NSDate().timeIntervalSinceDate(NSDate(timeIntervalSince1970: NSTimeInterval(quitDate))) 
    6.  
    7. let leftTime = leftTimeWhenQuit - Int(passedTimeFromQuit) 
    8.  
    9. lblTImer.text = "\(leftTime)"   
     
    当然别忘了把 StoryBoard 的那个 label 拖出来:
         
         
    1. @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 中。

    确认在应用中 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 的文档和实际代码为准)。

    接下来,在扩展的 ViewController 中也链接 SimpleTimerKit 并加入 import SimpleTimerKit,我们就可以在扩展中使用 Timer 了。将刚才的直接设置 label 的代码去掉,换成下面的:
           
           
    1. override func viewDidLoad() {   
    2.     //... 
    3.   
    4.     if (leftTime > 0) { 
    5.         timer = Timer(timeInteral: NSTimeInterval(leftTime)) 
    6.         timer.start(updateTick: { 
    7.                 [weak self] leftTick in self!.updateLabel() 
    8.             }, stopHandler: nil) 
    9.     } else { 
    10.         //Do nothing now 
    11.     } 
    12.   
    13. private func updateLabel() {   
    14.     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 中加入以下方法:
            
            
    1. private func showOpenAppButton() {   
    2.     lblTimer.text = "Finished" 
    3.     preferredContentSize = CGSizeMake(0, 100) 
    4.   
    5.     let button = UIButton(frame: CGRectMake(0, 50, 50, 63)) 
    6.     button.setTitle("Open", forState: UIControlState.Normal) 
    7.     button.addTarget(self, action: "buttonPressed:", forControlEvents: UIControlEvents.TouchUpInside) 
    8.   
    9.     view.addSubview(button)         
     
    在设定 preferredContentSize 时,指定的宽度都是无效的,系统会自动将其处理为整屏的宽度,所以扔个 0 进去就好了。在这里添加按钮时我偷了个懒,本来应该使用Auto Layout 和添加约束的,但是这并不是我们这个 demo 的重点。另一方面,为了代码清晰明了,就直接上坐标了。
     
    然后添加这个按钮的 action:
            
            
    1. @objc private func buttonPressed(sender: AnyObject!) { 
    2.     extensionContext.openURL(NSURL(string: "simpleTimer://finished"), completionHandler: nil) 
     
    我们将传递的 URL 的 scheme 是 simpleTimer,以 host 的 finished 作为参数,就可以通知主体应用计时完成了。然后我们需要在计时完成时调用 showOpenAppButton 来显示按钮,更新 viewDidLoad 中的内容:
            
            
    1. override func viewDidLoad() {   
    2.     //... 
    3.     if (leftTime > 0) { 
    4.         timer = Timer(timeInteral: NSTimeInterval(leftTime)) 
    5.         timer.start(updateTick: { 
    6.             [weak self] leftTick in self!.updateLabel() 
    7.             }, stopHandler: { 
    8.                 [weak self] finished in self!.showOpenAppButton() 
    9.             }) 
    10.     } else { 
    11.         showOpenAppButton() 
    12.     } 
    最后一步是在主体应用的 target 里设置合适的 URL Scheme:

    然后在 AppDelegate.swift 中捕获这个打开事件,并检测计时是否完成,然后做出相应:
            
            
    1. func application(application: UIApplication!, openURL url: NSURL!, sourceApplication: String!, annotation: AnyObject!) -> Bool {   
    2.     if url.scheme == "simpleTimer" { 
    3.         if url.host == "finished" { 
    4.             NSNotificationCenter.defaultCenter() 
    5.                 .postNotificationName(taskDidFinishedInWidgetNotification, object: nil) 
    6.         } 
    7.         return true 
    8.     } 
    9.   
    10.     return false 
     
    在这个例子里,我们发了个通知。而在 ViewController 中我们可以一开始就监听这个通知,然后收到后停止计时并弹出提示就行了。当然我们可能需要一些小的重构,比如添加是手动打断还是计时完成的判断以弹出不一样的对话框等等,这些都很简单再次就不赘述了。

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

    其他
    其实在 Xcode 为我们生成的模板文件中,还有这么一段代码也很重要:
              
              
    1. func widgetPerformUpdateWithCompletionHandler(completionHandler: ((NCUpdateResult) -> Void)!) {   
    2.     // Perform any setup necessary in order to update the view. 
    3.   
    4.     // If an error is encoutered, use NCUpdateResult.Failed 
    5.     // If there's no update required, use NCUpdateResult.NoData 
    6.     // If there's an update, use NCUpdateResult.NewData 
    7.   
    8.     completionHandler(NCUpdateResult.NewData) 
     
    对于通知中心扩展,即使你的扩展现在不可见 (也就是用户没有拉开通知中心),系统也会时不时地调用实现了 NCWidgetProviding 的扩展的这个方法,来要求扩展刷新界面。这个机制和 iOS 7 引入的后台机制是很相似的。在这个方法中我们一般可以做一些像 API 请求之类的事情,在获取到了数据并更新了界面,或者是失败后都使用提供的 completionHandler 来向系统进行报告。
     
    值得注意的一点是 Xcode (至少现在的 beta 4) 所提供的模板文件的 ViewController 里虽然有这个方法,但是它默认并没有 conform 这个接口,所以要用的话,我们还需要在类声明时加上 NCWidgetProviding。
     
    总结
    这个 Demo 主要涉及了通知中心的 Toady widget 的添加和一般交互。其实扩展是一个相当大块的内容,对于其他像是分享或者是 Action 的扩展,其使用方式又会有所不同。但是核心的概念,生命周期以及与本体应用交互的方法都是相似的。Xcode 在我们创建扩展时就为我们提供了非常好的模版文件,更多的时候我们要做的只不过是在相应的方法内填上我们的逻辑,而对于配置方面基本不太需要操心,这一点还是非常方便的。
     
    就为了扩展这个功能,我已经迫不及待地想用上 iOS 8 了..不论是使用别人开发的扩展还是自己开发方便的扩展,都会让这个世界变得更美好。











    • 0
      点赞
    • 0
      收藏
      觉得还不错? 一键收藏
    • 0
      评论

    “相关推荐”对你有帮助么?

    • 非常没帮助
    • 没帮助
    • 一般
    • 有帮助
    • 非常有帮助
    提交
    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值