Apple Watch 和 watchOS 第一代产品只容许用户在 iPhone 设备上进行计算,而后将结果传输到手表上进行显示。在这个框架下,手表充当的功能在很大程度上只是手机的另外一块小一些的显示器。而在 watchOS 2 中,Apple 开放了在手表端直接进行计算的能力,一些以前没法完成的 app 如今也能够进行构建了。本文将经过一个很简单的天气 app 的例子,讲解一下 watchOS 2 中新引入的一些特性的使用方法。html
本文是个人 WWDC15 笔记中的一篇,在 WWDC15 中涉及到 watchOS 2 的相关内容的 session 很是多,本文所参考的有:ios
做为一个示例项目,咱们就来构建一个最简单的天气 app 吧。本文将一步步带你从零开始构建一个相对完整的 iOS + watch app。这个 app 的 iOS 端很简单,从数据源取到数据,而后解析整天气的 model 后,经过一个 PageViewController 显示出来。为了让 demo 更有说服力,咱们将展现当前日期以及先后两天的天气状况,包括天气情况和睦温。在手表端,咱们但愿构建一个相似的 app,能够展现这几天的天气状况。另外咱们固然也介绍如何利用 watchOS 2 的一些新特性,好比 complications 和 Time Travel 等等。git
虽然本文的重点是 watchOS,可是为了完整性,咱们仍是从开头开始来构建这个 app 吧。由于无论是 watchOS 1 仍是 2,一个手表 app 都是没法脱离手机 app 单独存在和申请的。因此咱们首先来作的是一个像模像样的 iOS app 吧。github
第一步固然是使用 Xcode 7 新建一个工程,这里咱们直接选择 iOS App with WatchKit App,这样 Xcode 将直接帮助咱们创建一个带有 watchOS app 的 iOS 应用。json
在接下来的画面中,咱们选中 Include Complication 选项,由于咱们但愿制做一个包含有 Complication 的 watch app。swift
这个 app 的 UI 部分比较简单,我将使用到的素材都放到了这里。你能够下载这些素材,并把它们解压并拖拽到项目 iOS app 的 Assets.xcassets 里去:数组
接下来,咱们来构建 UI 部分。咱们想要使用 PageViewController 来做为 app 的导航,首先,在 Main.StoryBoard 中删掉原来的 ViewController,并新加一个 Page View Controller,而后在它的 Attributes Inspector 中将 Transition Style 改成 Scroll,并勾选上 Is Initial View Controller。这将使这个 view controller 成为整个 app 的入口。缓存
接下来,咱们须要将这个 Page View Controller 和代码关联起来。首先将 ViewController.swift 文件中,将 ViewController 的继承关系从 UIViewController
改成 UIPageViewController
。服务器
class ViewController: UIPageViewController { ... }
而后咱们就能够在 StoryBoard 文件中将刚才的 Page View Controller 的 class 改成咱们的ViewController
了。网络
另外咱们还须要一个实际展现天气的 View Controller。建立一个继承自 UIViewController
的WeatherViewController
,而后将 WeatherViewController.swift 的内容替换为:
import UIKit class WeatherViewController: UIViewController { enum Day: Int { case DayBeforeYesterday = -2 case Yesterday case Today case Tomorrow case DayAfterTomorrow } var day: Day? }
这里仅只是定义了一个 Day
的枚举,它将用来标记这个 WeatherViewController
所表明的日期 (可能你会说把 Day
在 ViewController 里并非很好的选择,没错,可是放在这里有助于咱们快速搭建 app,在以后咱们会对此进行重构)。接下来,咱们在 StoryBoard 中添加一个 ViewController,并将它的 class 改成 WeatherViewController
。咱们能够在这里构建 UI,对于这个 demo 来讲,一个简单的背景,加上表示天气的图标和表示温度的标签就足够了。由于这里并非一个关于 Auto Layout 或是 Size Class 的 demo,因此就不详细一步步地作了,我随意拖了拖 UI 和约束,最后结果以下图所示。
接下来就是从 StoryBoard 中把须要的 IBOutlet 拖出来。咱们须要天气图标,最高最低温度的 label。完成这些 UI 工做以后的项目能够在 GitHub 的这个 tag 下找到,若是你不想本身完成这些步骤的话,也能够直接使用这个 tag 的源文件来继续下面的 demo。固然,若是你对 AutoLayout 和 Interface Builder 还不熟悉的话,这会是一个很好的机会来从简单的布局入手,去理解对 IB 的使用。关于更多 IB 和 StoryBoard 的教程,推荐 Raywenderlich 的这两篇系列文章:Storyboards Tutorial in Swift 和 Auto Layout Tutoria。
而后咱们能够考虑先把 Page View Controller 的框架实现出来。在 ViewController.swift
中,咱们首先在 ViewController
类中加入如下方法:
func weatherViewControllerForDay(day: WeatherViewController.Day) -> UIViewController { let vc = storyboard?.instantiateViewControllerWithIdentifier("WeatherViewController") as! WeatherViewController let nav = UINavigationController(rootViewController: vc) vc.day = day return nav }
这将从当前的 StroyBoard 里寻找 id 为 "WeatherViewController" 的 ViewController,而且初始化它。咱们但愿能为每一天的天气显示一个 title,一个比较理想的作法就是直接将咱们的 WeatherViewController 嵌套在 navigation controller 里,这样咱们就能够直接使用 navigation bar 来显示标题,而不用去操心它的布局了。咱们刚才并无为WeatherViewController
指定 id,在 StoryBoard 中,找到 WeatherViewController,而后在 Identity 里添加便可:
接下来咱们来实现 UIPageViewControllerDataSource
。在 ViewController.swift
的viewDidLoad
里加入:
dataSource = self let vc = weatherViewControllerForDay(.Today) setViewControllers([vc], direction: .Forward, animated: true, completion: nil)
首先它将 viewController
本身设置为 dataSource。而后设定了初始须要表示的 viewController。对于 UIPageViewControllerDataSource
的实现,咱们在同一文件中加入一个ViewController
的 extension 来搞定:
extension ViewController: UIPageViewControllerDataSource { func pageViewController(pageViewController: UIPageViewController, viewControllerBeforeViewController viewController: UIViewController) -> UIViewController? { guard let nav = viewController as? UINavigationController, viewController = nav.viewControllers.first as? WeatherViewController, day = viewController.day else { return nil } if day == .DayBeforeYesterday { return nil } guard let earlierDay = WeatherViewController.Day(rawValue: day.rawValue - 1) else { return nil } return self.weatherViewControllerForDay(earlierDay) } func pageViewController(pageViewController: UIPageViewController, viewControllerAfterViewController viewController: UIViewController) -> UIViewController? { guard let nav = viewController as? UINavigationController, viewController = nav.viewControllers.first as? WeatherViewController, day = viewController.day else { return nil } if day == .DayAfterTomorrow { return nil } guard let laterDay = WeatherViewController.Day(rawValue: day.rawValue + 1) else { return nil } return self.weatherViewControllerForDay(laterDay) } }
这两个方法分别根据输入的 View Controller 对象来肯定前一个和后一个 View Controller,若是返回 nil
则说明没有以前/后的页面了。另外,咱们可能还想要先将 title 显示出来,以肯定如今的架构是否正确工做。在 WeatherViewController.swift
的 Day 枚举里添加以下属性:
var title: String { let result: String switch self { case .DayBeforeYesterday: result = "前天" case .Yesterday: result = "昨天" case .Today: result = "今天" case .Tomorrow: result = "明天" case .DayAfterTomorrow: result = "后天" } return result }
而后将 day
属性改成:
var day: Day? { didSet { title = day?.title } }
运行 app,如今咱们应该能够在五个页面之间进行切换了。你也能够从 GitHub 上对应的 tag 中下载到目前为止的项目。
很难有人一次性就把代码写得完美无瑕,这也是重构的意义。重构历来不是一个“等待项目完成后再开始”的活动,而是应该随着项目的展开和进行,一旦发现有可能存在问题的地方,就尽快进行改进。好比在上面咱们将 Day
放在了 WeatherViewController
中,这显然不是一个很好地选择。这个枚举更接近于 Model 层的东西而非控制层,咱们应该将它迁移到另外的地方。一样如今还须要实现的还有天气的 Model,即表征天气情况和高低温度的对象。咱们将这些内容提取出来,放到一个 framework 中去,以便使用的维护。
咱们首先对现有的 Day
进行迁移。建立一个新的 Cocoa Touch Framework target,命名为WatchWeatherKit
。在这个 target 中新建 Day.swift
文件,其中内容为:
public enum Day: Int { case DayBeforeYesterday = -2 case Yesterday case Today case Tomorrow case DayAfterTomorrow public var title: String { let result: String switch self { case .DayBeforeYesterday: result = "前天" case .Yesterday: result = "昨天" case .Today: result = "今天" case .Tomorrow: result = "明天" case .DayAfterTomorrow: result = "后天" } return result } }
这就是原来存在于 WeatherViewController
中的代码,只不过将必要的内容申明为了 public
,这样咱们才能在别的 target 中使用它们。咱们如今能够将原来的 Day 整个删除掉了,接下来,咱们在 WeatherViewController.swift
和 ViewController.swift
最上面加入 import WatchWeatherKit
,并将 WeatherViewController.Day
改成 Day
。如今 Day
枚举就被隔离出 View Controller 了。
而后实现天气的 Model。在 WatchWeatherKit
里新建 Weather.swift
,并书写以下代码:
import Foundation public struct Weather { public enum State: Int { case Sunny, Cloudy, Rain, Snow } public let state: State public let highTemperature: Int public let lowTemperature: Int public let day: Day public init?(json: [String: AnyObject]) { guard let stateNumber = json["state"] as? Int, state = State(rawValue: stateNumber), highTemperature = json["high_temp"] as? Int, lowTemperature = json["low_temp"] as? Int, dayNumber = json["day"] as? Int, day = Day(rawValue: dayNumber) else { return nil } self.state = state self.highTemperature = highTemperature self.lowTemperature = lowTemperature self.day = day } }
Model 包含了天气的状态信息和最高最低温度,咱们稍后会用一个 JSON 字符串中拿到字典,而后初始化它。若是字典中信息不全的话将直接返回 nil
表示天气对象建立失败。到此为止的项目能够在 GitHub 的 model tag 中找到。
接下来的任务是获取天气的 JSON,做为一个 demo 咱们彻底能够用一个本地文件替代网络请求部分。不过由于以后在介绍 watch app 时会用到使用手表进行网络请求,因此这里咱们仍是从网络来获取天气信息。为了简单,假设咱们从服务器收到的 JSON 是这个样子的:
{"weathers": [ {"day": -2, "state": 0, "low_temp": 18, "high_temp": 25}, {"day": -1, "state": 2, "low_temp": 9, "high_temp": 14}, {"day": 0, "state": 1, "low_temp": 12, "high_temp": 16}, {"day": 1, "state": 3, "low_temp": 2, "high_temp": 6}, {"day": 2, "state": 0, "low_temp": 19, "high_temp": 28} ]}
其中 day
0 表示今天,state
是天气情况的代码。
咱们已经有 Weather
这个 Model 类型了,如今咱们须要一个 API Client 来获取这个信息。在WeatherWatchKit
target 中新建一个文件 WeatherClient.swift
,并填写如下代码:
import Foundation public let WatchWeatherKitErrorDomain = "com.onevcat.WatchWeatherKit.error" public struct WatchWeatherKitError { public static let CorruptedJSON = 1000 } public struct WeatherClient { public static let sharedClient = WeatherClient() let session = NSURLSession.sharedSession() public func requestWeathers(handler: ((weather: [Weather?]?, error: NSError?) -> Void)?) { guard let url = NSURL(string: "https://raw.githubusercontent.com/onevcat/WatchWeather/master/Data/data.json") else { handler?(weather: nil, error: NSError(domain: NSURLErrorDomain, code: NSURLErrorBadURL, userInfo: nil)) return } let task = session.dataTaskWithURL(url) { (data, response, error) -> Void in if error != nil { handler?(weather: nil, error: error) } else { do { let object = try NSJSONSerialization.JSONObjectWithData(data!, options: .AllowFragments) if let dictionary = object as? [String: AnyObject] { handler?(weather: Weather.parseWeatherResult(dictionary), error: nil) } } catch _ { handler?(weather: nil, error: NSError(domain: WatchWeatherKitErrorDomain, code: WatchWeatherKitError.CorruptedJSON, userInfo: nil)) } } } task!.resume() } }
其实咱们的 client 如今有点过分封装和耦合,不过做为 demo 来讲的话还不错。它如今只有一个方法,就是从网络源请求一个 JSON 而后进行解析。解析的代码 parseWeatherResult
咱们放在了 Weather
中,以一个 extension 的形式存在:
// MARK: - Parsing weather request extension Weather { static func parseWeatherResult(dictionary: [String: AnyObject]) -> [Weather?]? { if let weathers = dictionary["weathers"] as? [[String: AnyObject]] { return weathers.map{ Weather(json: $0) } } else { return nil } } }
咱们在 ViewController 中使用这个方法便可获取到天气信息,就能够构建咱们的 UI 了。在ViewController.swift
中,加入一个属性来存储天气数据:
var data: [Day: Weather]?
而后更改 viewDidLoad
的代码:
override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view, typically from a nib. dataSource = self let vc = UIViewController() vc.view.backgroundColor = UIColor.whiteColor() setViewControllers([vc], direction: .Forward, animated: true, completion: nil) UIApplication.sharedApplication().networkActivityIndicatorVisible = true WeatherClient.sharedClient.requestWeathers { (weather, error) -> Void in UIApplication.sharedApplication().networkActivityIndicatorVisible = false if error == nil && weather != nil { for w in weather! where w != nil { self.data[w!.day] = w } let vc = self.weatherViewControllerForDay(.Today) self.setViewControllers([vc], direction: .Forward, animated: false, completion: nil) } else { let alert = UIAlertController(title: "Error", message: error?.description ?? "Unknown Error", preferredStyle: .Alert) alert.addAction(UIAlertAction(title: "OK", style: .Default, handler: nil)) self.presentViewController(alert, animated: true, completion: nil) } } }
在这里一开始使用了一个临时的 UIViewController
来做为 PageViewController 在网络请求时的初始视图控制 (虽然在咱们的例子中这个初始视图就是一块白屏幕)。接下来进行网络请求,并把获得的数据存储在 data
变量中以待使用。以后咱们须要把这些数据传递给不一样日期的 ViewController,在 weatherViewControllerForDay
方法中,换为对 weather 作设定,而非day
:
func weatherViewControllerForDay(day: Day) -> UIViewController { let vc = self.storyboard?.instantiateViewControllerWithIdentifier("WeatherViewController") as! WeatherViewController let nav = UINavigationController(rootViewController: vc) vc.weather = data[day] return nav }
同时咱们还须要修改一下 WeatherViewController
,将原来的:
var day: Day? { didSet { title = day?.title } }
改成
var weather: Weather? { didSet { title = weather?.day.title } }
另外还须要在 UIPageViewControllerDataSource
的两个方法中,把对应的viewController.day
换为 viewController.weather?.day
。最后咱们要作的是在WeatherViewController
的 viewDidLoad
中根据 model 更新 UI:
override func viewDidLoad() { super.viewDidLoad() lowTemprature.text = "\(weather!.lowTemperature)℃" highTemprature.text = "\(weather!.highTemperature)℃" let imageName: String switch weather!.state { case .Sunny: imageName = "sunny" case .Cloudy: imageName = "cloudy" case .Rain: imageName = "rain" case .Snow: imageName = "snow" } weatherImage.image = UIImage(named: imageName) }
一个可能的改进是新建一个
WeatherViewModel
来将对 View 的内容和 Model 的映射关系代码从 ViewController 里分理出去,若是有兴趣的话你能够本身研究下。
到此咱们的 iOS 端的代码就所有完成了,运行一下看看,Perfect!到如今为止的项目能够在这里找到。
终于进入正题了,咱们能够开始设计和制做 watch app 了。
首先咱们把须要的图片添加到 watch app target 的 Assets.xcassets 中,这样在以后用户安装 app 时这些图片将被存放在手表中,咱们能够直接快速地从手表本地读取。UI 的设计很是简单,在 Watch app 的 Interface.storyboard 中,咱们先将表明天气状态的图片和温度标签拖拽到 InterfaceController 中,并将它们链接到 InterfaceController.swift
中的 IBOutlet 去。
@IBOutlet var weatherImage: WKInterfaceImage! @IBOutlet var highTempratureLabel: WKInterfaceLabel! @IBOutlet var lowTempratureLabel: WKInterfaceLabel!
接下来,咱们将它复制四次,并用 next page 的 segue 串联起来,并设置它们的 title。这样,在最后的 watch app 里咱们就会有五个能够左右 scorll 滑动的页面,分别表示从前天到后天的五个日子。
为了标记和区分这五个 InterfaceController 实例。由于使用 next page 级联的 WKInterfaceController 会被依次建立,因此咱们能够在 awakeWithContext
方法中用一个静态变量计数。在这里,咱们想要将序号为 2 的 InterfaceController (也就是表明 “今天” 的那个) 设为当前 page。在 InterfaceController.swift
里添加一个静态变量:
static var index = 0
而后在 awakeWithContext
方法中加入:
InterfaceController.index = InterfaceController.index + 1 if (InterfaceController.index == 2) { becomeCurrentPage() }
和 iOS app 相似,咱们但愿可以使用框架来组织代码。watch app 中的天气 model 和网络请求部分的内容其实和 iOS app 中的是彻底同样的,咱们没有理由重复开发。在一个 watch app 中,其实 app 自己只负责图形显示,实际的代码都是在 extension 中的。在 watchOS 2 以前,由于 extension 是在手机端,和 iOS app 处于一样的物理设备中,因此咱们能够简单地将为 iOS app 中建立的框架使用在 watch extension target 中。可是在 watchOS 2 中发生了变化,由于 extension 如今直接将运行在手表上,咱们没法与 iOS app 共享同一个框架了。取而代之,咱们须要为手表 app 建立新的属于本身的 framewok,而后将合适的文件添加到这个 framework 中去。
为项目新建一个 target,类型选择为 Watch OS 的 Watch Framework。
接下来,咱们把以前的 Day.swift
,Weather.swift
和 WeatherClient.swift
三个文件添加到这个新的 target (在这里咱们叫它 WatchWeatherWatchKit) 里去。咱们将在新的这个 watch framework 中重用这三个文件。这样作相较于直接把这三个文件放到 watch extension target 中来讲,会更易于管理组织和模块分割,也是 Apple 所推荐的使用方式。
接下来咱们须要手动在 watch extension 里将这个新的 framework 连接进来。在 WatchWeather WatchKit Extension
target 的 General 页面中,将 WatchWeatherWatchKit
添加到 Embedded Binaries 中。Xcode 将会自动把它加到 Link Binary With Libraries 里去。这时候若是你尝试编译 watch app,可能会获得一个警告:"Linking against dylib not safe for use in application extensions"。这是由于不管是 iOS app 的 extension 仍是 watchOS 的 extension,所能使用的 API 都只是完整 iOS SDK 的子集。编译器没法肯定咱们所动态连接的框架是否含有一些 extension 没法调用的 API。要解决这个警告,咱们能够经过在 WatchWeatherWatchKit
的 Build Setting 中将 "Require Only App-Extension-Safe API" 设置为 YES
来将 target 里可用的 API 限制在 extension 中。
是时候来实现咱们的 app 了。首先一刻都不能再忍受的是 InterfaceController.swift
中的index
。咱们既然有了 WatchWeatherWatchKit
,就能够利用已有的模型将这里写得更清楚。在InterfaceController.swift
中,首先在文件上面 import WatchWeatherWatchKit
,而后修改index
的定义,并添加一个字典来临时保存这些 Interface Controller,以便以后使用:
static var index = Day.DayBeforeYesterday.rawValue static var controllers = [Day: InterfaceController]()
将刚才咱们的在 awakeWithContext
中添加的内容删掉,改成:
override func awakeWithContext(context: AnyObject?) { super.awakeWithContext(context) // Configure interface objects here. guard let day = Day(rawValue: InterfaceController.index) else { return } InterfaceController.controllers[day] = self InterfaceController.index = InterfaceController.index + 1 if day == .Today { becomeCurrentPage() } }
如今表意就要清楚很多了。
接下来就是获取天气信息了。和 iOS app 中同样,咱们能够直接使用 WeatherClient
来获取。在InterfaceController.swift
中加入如下代码:
var weather: Weather? { didSet { if let w = weather { updateWeather(w) } } } func request() { WeatherClient.sharedClient.requestWeathers({ [weak self] (weathers, error) -> Void in if let weathers = weathers { for weather in weathers where weather != nil { guard let controller = InterfaceController.controllers[weather!.day] else { continue } controller.weather = weather } } else { // 2 let action = WKAlertAction(title: "Retry", style: .Default, handler: { () -> Void in self?.request() }) let errorMessage = (error != nil) ? error!.description : "Unknown Error" self?.presentAlertControllerWithTitle("Error", message: errorMessage, preferredStyle: .Alert, actions: [action]) } }) }
若是咱们获取到了天气,就设置 weather
属性并调用 updateWeather
方法依次对相应的 InterfaceController 的 UI 进行设置。若是出现了错误,咱们这里简单地用一个 watchOS 2 中新加的 alert view 来进行提示并让用户重试。在这个方法的下面加上更新 UI 的方法updateWeather
:
func updateWeather(weather: Weather) { lowTempratureLabel.setText("\(weather.lowTemperature)℃") highTempratureLabel.setText("\(weather.highTemperature)℃") let imageName: String switch weather.state { case .Sunny: imageName = "sunny" case .Cloudy: imageName = "cloudy" case .Rain: imageName = "rain" case .Snow: imageName = "snow" } weatherImage.setImageNamed(imageName) }
咱们只须要网络请求进行一次就能够了,因此在这里咱们用一个 once_token 来限定一开始的 request 只执行一次。在 InterfaceController.swift
中加上一个类变量:
static var token: dispatch_once_t = 0
而后在 awakeWithContext
的最后用 dispatch_once
来开始请求:
dispatch_once(&InterfaceController.token) { () -> Void in self.request() }
最后,在 willActivate
中也须要刷新 UI:
override func willActivate() { super.willActivate() if let w = weather { updateWeather(w) } }
应该就这么多了。选定手表 scheme,运行程序,除了图标的尺寸不太对以及网络请求时还显示默认的天气情况和温度之外,其余的看起来还不赖:
至于显示默认值的问题,咱们能够经过简单地在 StoryBoard 中将图和标签内容设为空来改善,在此就再也不赘述了。
值得一提的是,若是你多测试几回,好比关闭整个 app (或者模拟器),而后再运行的话,可能会有必定概率遇到下面这样的错误:
若是你还记得的话,这个 1000 错误就是咱们定义在 WeatherClient.swift
中的CorruptedJSON
错误。调试一下,你就会发如今请求返回时获得的数据存在问题,会获得一个内容被完整复制了一遍的返回 (好比正确的数据 {a:1},可是咱们获得的是 {a:1} {a:1})。虽然我不是太明白为何会出现这样的情况,但这应该是 NSURLSession
在 watchOS SDK 上的一个缓存上的 bug。我以后会尝试向 Apple 提交一个 radar 来汇报这个问题。如今的话,咱们能够经过设置不带缓存的 NSURLSessionConfiguration
来绕开这个问题。将 WeatherClient 中的 session
属性改成如下便可:
let session = NSURLSession(configuration: NSURLSessionConfiguration.ephemeralSessionConfiguration())
至此,咱们的 watch app 本体就完成了。到这一步为止的项目能够在这个 tag 找到。Notification 和 Glance 两个特性相对简单,基本只是界面的制做,为了节省篇幅 (其实这篇文章已经够长了,若是你须要休息一下的话,这里会是一个很好地机会),就再也不详细说明了。你能够分别在这里和这里找到开发二者所须要的一切知识。
在下一节中,咱们将着重于 watchOS 2 的新特性。首先是 complications。
Complications 是 watchOS 2 新加入的特性,它是表盘上除了时间之外的一些功能性的小部件。好比咱们的天气 app 里,将今天的天气情况显示在表盘上就是一个很是理想的应用场景,这样用户就不须要打开你的 app 就能看到今天的天气情况了 (其实今天的天气的话用户抬头望窗外就能知道。若是是一个实际的天气 app 的话,显示明天或者两小时后的天气情况会更理想,可是做为 demo 就先这样吧..)。咱们在这一小节中将为刚才的天气 app 实现一个 complication。
Complications 能够是不一样的形状,如图所示:
根据用户表盘选择的不一样,表盘上对应的可用的 complications 形状也各不相同。若是你想要你的 complication 在全部表盘上都能使用的话,你须要实现全部的形状。掌管 complications 或者说是表盘相关的框架并非咱们一直使用的 WatchKit,而是一个 watchOS 2 中全新框架,ClockKit。ClockKit 会提供一些模板给咱们,并在必定时间点向咱们请求数据。咱们依照模板使用咱们的数据来实现 complication,最后 ClockKit 负责帮助咱们将其渲染在表盘上。在 ClockKit 请求数据时,它会唤醒咱们的 watch extension。咱们须要在 extension 中实现数据源,并以一段时间线的方式把数据提供给 ClockKit。这样作有两个好处,首先 ClockKit 能够一次性获取到不少数据,这样它就能在合适的时候更新 complication 的显示,而没必要再次唤醒 extension 来请求数据。其次,由于有一条时间线的数据,咱们就可使用 Time Travel 来查看 complication 已通过去的和即将到来的情况,这在某些场合下会十分方便。
理论已经说了不少了,来实际操做一下吧。
首先,由于咱们在新建项目的时候已经选择了包含 complications,因此咱们并不须要再进行额外的配置就能够开始了。若是你不当心没有选中这个选项,或者是想在已有项目中进行添加的话,你就须要手动配置,在 extension 的 target 里的 Complications Configuration 中指定数据源的 class 和支持的形状。在运行时,系统会使用在这个设置中指定的类型名字去初始化一个的实例,而后调用这个实例中实现的数据源方法。咱们要作的就是在被询问这些方法时,尽快地提供须要的数据。
第一步是实现数据源,这在在咱们的项目中已经配置好了,就是ComplicationController.swift
。这是一个实现了 CLKComplicationDataSource
的类型,打开文件能够看到全部的方法都已经有默认空实现了,咱们如今要作的就是把这些空填上。其中最关键的是 getCurrentTimelineEntryForComplication:withHandler:
,咱们须要经过这个方法来提供当前表盘所要显示的 complication。罗马不是一天建成的,项目也不是。咱们先提供一个 dummy 的数据来让流程运做起来。在 ComplicationController.swift 中,将这个方法的内容换成:
func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: ((CLKComplicationTimelineEntry?) -> Void)) { // Call the handler with the current timeline entry var entry : CLKComplicationTimelineEntry? let now = NSDate() // Create the template and timeline entry. if complication.family == .ModularSmall { let imageTemplate = CLKComplicationTemplateModularSmallSimpleImage() imageTemplate.imageProvider = CLKImageProvider(backgroundImage:UIImage(named: "sunny")!, backgroundColor: nil) // Create the entry. entry = CLKComplicationTimelineEntry(date: now, complicationTemplate: imageTemplate) } else { // ...configure entries for other complication families. } // Pass the timeline entry back to ClockKit. handler(entry) }
在这个方法中,系统会提供给咱们所须要的 complication 的类型,咱们要作的是使用合适的系统所提供的模板 (这里是 CLKComplicationTemplateModularSmallSimpleImage
) 以及咱们本身的数据,来构建一个 CLKComplicationTimelineEntry
对象,而后再 handler 中返回给系统。结合天气 app 的特色,咱们这里选择了一个小的简单图片的模板。另外由于篇幅有限,这里只实现了.ModularSmall
。在实际的项目中,你应该支持尽可能多的 complication 类型,这样能够保证你的用户在不一样的表盘上都能使用。
在提供具体的数据时,咱们使用 template 的 imageProvider
或者 textProvider
。在咱们如今使用的这个模板中,只有一个简单的 imageProvider
,咱们从 extension 的 Assets Category 中获取并设置合适的图像就能够了 (对于 .ModularSmall
来讲,须要图像的尺寸为 52px 和 58px 的 @2x。关于其余模板的图像尺寸要求,能够参考文档)。
运行程序,选取一个带有 ModularSmall
complication 的表盘 (若是是在模拟器的话,可使用 Shift+Cmd+2 而后点击表盘来打开表盘选择界面),而后在 complication 中选择 WatchWeather,能够看到如下的结果:
看起来不错,咱们的小太阳已经在界面上熠熠生辉了,接下来就是要实现把实际的数据替换进来。对于 complication 来讲,咱们须要以尽量快的速度去调用 handler 来向系统提供数据。咱们并无那么多时间去从网络上获取数据,因此须要使用以前在 watch app 或者是 iOS app 中获取到的数据来组织 complication。为了在 complication 中能直接获取数据,咱们须要在用 Client 获取到数据后把它存在本地。这里咱们用 UserDefaults 就已经足够了。在 Weather.swift
中加入如下 extension:
public extension Weather { static func storeWeathersResult(dictionary: [String: AnyObject]) { let userDefault = NSUserDefaults.standardUserDefaults() userDefault.setObject(dictionary, forKey: kWeatherResultsKey) userDefault.setObject(NSDate(), forKey: kWeatherRequestDateKey) userDefault.synchronize() } public static func storedWeathers() -> (requestDate: NSDate?, weathers: [Weather?]?) { let userDefault = NSUserDefaults.standardUserDefaults() let date = userDefault.objectForKey(kWeatherRequestDateKey) as? NSDate let weathers: [Weather?]? if let dic = userDefault.objectForKey(kWeatherResultsKey) as? [String: AnyObject] { weathers = parseWeatherResult(dic) } else { weathers = nil } return (date, weathers) } }
这里咱们须要知道获取到这组数据时的时间,咱们以当前时间做为获取时间进行存储。一个更加合适的作法应该是在请求的返回中包含每一个天气情况所对应的时间信息。可是由于咱们并无真正的服务器,也并不是实际的请求,因此这里就先简单粗暴地用本地时间了。接下来,在每次请求成功后,咱们调用 storeWeathersResult
将结果存储起来。在 WeatherClient.swift
中,把
dispatch_async(dispatch_get_main_queue(), { () -> Void in handler?(weathers: Weather.parseWeatherResult(dictionary), error: nil) })
这段代码改成:
dispatch_async(dispatch_get_main_queue(), { () -> Void in let weathers = Weather.parseWeatherResult(dictionary) if weathers != nil { Weather.storeWeathersResult(dictionary) } handler?(weathers: weathers, error: nil) })
接下来咱们还须要另一项准备工做。Complication 的时间线是以一组CLKComplicationTimelineEntry
来表示的,一个 entry 中包含了 template 和对应的 NSDate
。watchOS 将在当前时间超过这个 NSDate
时表示。因此若是咱们须要显示当天的天气状况的话,就须要将对应的日期设定为当日的 0 点 0 分。对于其余几个日期的天气来讲,这个情况也是同样的。咱们须要添加一个方法来经过 Weather 的 day
属性和请求的当日日期来返回一个对应 entry 中须要的日期。为了运算简便,咱们这里引入一个第三方框架,SwiftDate。将这个项目导入咱们 app,而后在 Weather.swift
中添加:
public extension Weather { public func dateByDayWithRequestDate(requestDate: NSDate) -> NSDate { let dayOffset = day.rawValue let date = requestDate.set(componentsDict: ["hour":0, "minute":0, "second":0])! return date + dayOffset.day } }
接下来咱们就能够更新 ComplicationController.swift
的内容了。首先咱们须要实现getTimelineStartDateForComplication:withHandler:
和getTimelineEndDateForComplication:withHandler:
来告诉系统咱们所能提供 complication 的日期区间:
func getTimelineStartDateForComplication(complication: CLKComplication, withHandler handler: (NSDate?) -> Void) { var date: NSDate? = nil let (reqestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { if w!.day == .DayBeforeYesterday { date = w!.dateByDayWithRequestDate(requestDate) break } } } handler(date) } func getTimelineEndDateForComplication(complication: CLKComplication, withHandler handler: (NSDate?) -> Void) { var date: NSDate? = nil let (reqestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { if w!.day == .DayAfterTomorrow { date = w!.dateByDayWithRequestDate(requestDate) + 1.day - 1.second break } } } handler(date) }
最先的时间是前天的 00:00,这是毫无疑问的。可是最晚的可显示时间并非后天的 00:00,而是 23:59:59,这里必定须要注意。
另外,为了以后建立 template 能容易一些,咱们添加一个由 Weather.State
建立 template 的方法:
private func templateForComplication(complication: CLKComplication, weatherState: Weather.State) -> CLKComplicationTemplate? { let imageTemplate: CLKComplicationTemplate? if complication.family == .ModularSmall { imageTemplate = CLKComplicationTemplateModularSmallSimpleImage() let imageName: String switch weatherState { case .Sunny: imageName = "sunny" case .Cloudy: imageName = "cloudy" case .Rain: imageName = "rain" case .Snow: imageName = "snow" } (imageTemplate as! CLKComplicationTemplateModularSmallSimpleImage).imageProvider = CLKImageProvider(backgroundImage:UIImage(named: imageName)!, backgroundColor: nil) } else { imageTemplate = nil } return imageTemplate }
接下来就是实现核心的三个提供时间轴的方法了,虽然很长,可是作的事情却差很少:
func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: ((CLKComplicationTimelineEntry?) -> Void)) { // Call the handler with the current timeline entry var entry : CLKComplicationTimelineEntry? // Create the template and timeline entry. let (requestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { let weatherDate = w!.dateByDayWithRequestDate(requestDate) if weatherDate == NSDate.today() { if let template = templateForComplication(complication, weatherState: w!.state) { entry = CLKComplicationTimelineEntry(date: weatherDate, complicationTemplate: template) } } } } // Pass the timeline entry back to ClockKit. handler(entry) } func getTimelineEntriesForComplication(complication: CLKComplication, beforeDate date: NSDate, limit: Int, withHandler handler: (([CLKComplicationTimelineEntry]?) -> Void)) { // Call the handler with the timeline entries prior to the given date var entries = [CLKComplicationTimelineEntry]() let (requestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { let weatherDate = w!.dateByDayWithRequestDate(requestDate) if weatherDate < date { if let template = templateForComplication(complication, weatherState: w!.state) { let entry = CLKComplicationTimelineEntry(date: weatherDate, complicationTemplate: template) entries.append(entry) if entries.count == limit { break } } } } } handler(entries) } func getTimelineEntriesForComplication(complication: CLKComplication, afterDate date: NSDate, limit: Int, withHandler handler: (([CLKComplicationTimelineEntry]?) -> Void)) { // Call the handler with the timeline entries after to the given date var entries = [CLKComplicationTimelineEntry]() let (requestDate, weathers) = Weather.storedWeathers() if let weathers = weathers, requestDate = requestDate { for w in weathers where w != nil { let weatherDate = w!.dateByDayWithRequestDate(requestDate) if weatherDate > date { if let template = templateForComplication(complication, weatherState: w!.state) { let entry = CLKComplicationTimelineEntry(date: weatherDate, complicationTemplate: template) entries.append(entry) if entries.count == limit { break } } } } } handler(entries) }
代码来讲很是简单。getCurrentTimelineEntryForComplication
中咱们找到今天的 Weather
对象,而后构建合适的 entry。而对于 beforeDate
和 afterDate
两个版本的方法,按照系统提供的 date
咱们须要组织在这个 date
以前或者以后的全部 entry,并将它们放到一个数组中去调用回调。这两个方法中还为咱们提供了一个 limit
参数,咱们的结果数应该不超过这个数字。在实现这三个方法后,咱们的时间线就算是构建完毕了。
另外,咱们还能够经过实现 getPlaceholderTemplateForComplication:withHandler:
来提供一个在表盘定制界面是会用到的占位图像。
func getPlaceholderTemplateForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTemplate?) -> Void) { // This method will be called once per supported complication, and the results will be cached handler(templateForComplication(complication, weatherState: .Sunny)) }
这样,在自定义表盘界面咱们也能够在选择到咱们的 complication 时看到表示咱们的 complication 的样式了:
ComplicationController
中最后须要实现的是 getNextRequestedUpdateDateWithHandler
。系统会在你的 watch app 被运行时更新时间线,另外要是你的 app 一直没有被运行的话,你能够经过这个方法提供给系统一个参考时间,用来建议系统应该在何时为你更新时间线。这个时间应该尽量长,以节省电池的电量。在咱们的天气的例子中,天天一次更新也许会是个不错的选择:
func getNextRequestedUpdateDateWithHandler(handler: (NSDate?) -> Void) { // Call the handler with the date when you would next like to be given the opportunity to update your complication content handler(NSDate.tomorrow()); }
你也许会注意到,由于咱们这里要是不开启 watch app 的话,其实天气数据时不会更新的,这样咱们设定刷新时间线彷佛并无什么意义 - 由于不开 watch app 的话数据并不会变化,而开了 watch app 的话时间线就会直接被刷新。这里咱们考虑到了以后使用 Watch Connectivity 从手机端刷新 watch 数据的可能性,因此作了天天刷新一次的设置。咱们在稍后会详细将这方面内容。
另外,咱们还须要记得在 watch app 数据更新以后,强制 reload 一下 complication 的数据。在 ComplicationController.swift 中加入:
static func reloadComplications() { let server = CLKComplicationServer.sharedInstance() for complication in server.activeComplications { server.reloadTimelineForComplication(complication) } }
而后在 InterfaceController.swift
的 request
中,在请求成功返回后调用一下这个方法就能够了。
如今,咱们的 watch app 已经支持 complication 了。同时,由于咱们努力提供了以前和以后的数据,咱们免费获得了 Time Travel 的支持。如今你不只能够在表盘上看到今天的天气,经过旋转 Digital Crown 你还能了解到以前和以后的天气情况了:
到这里为止的项目代码能够在 complication tag 中找到。
在 watchOS 1 时代,watch 的 extension 是和 iOS app 同样,存在于手机里的。因此在 watch extension 和 iOS app 之间共享数据是比较简单的,和其余 extension 相似,使用 app group 将 app 本体和 extension 设为同一组 app,就能够在一个共享容器中共享数据了。可是这在 watchOS 2 中发生了改变。由于 watchOS 2 的手表 extension 是直接存在于手表中的,因此以前的 app group 的方法对于 watch app 来讲已经失效。Watch extension 如今会使用本身的一套数据存储 (若是你以前注意到了的话,咱们在请求数据后将它存到了 UserDefaults 中,可是手机和手表的 UserDefaults 是不一样的,因此咱们不用担忧数据被不当心覆盖)。若是咱们想要在 iOS 设备和手表之间共享数据的话,咱们须要使用新的 Watch Connectivity 框架。
WatchConnectivity
框架所扮演的角色就是 iOS app 和 watch extension 之间的桥梁,利用这个框架你能够在二者之间互相传递数据。在这个例子中,咱们会用 WatchConnectivity
来改善咱们的天气 app 的表现 -- 咱们打算实现不管在手表仍是 iOS app 中,天天最多只进行一次请求。在一个设备上请求后,咱们会把数据传递到配对的另外一个设备上,这样在另外一个设备上打开 app 时,就能够直接显示天气情况,而再也不须要请求一次了。
咱们在 iOS app 和 watchOS app 中均可以使用 WatchConnectivity。首先咱们须要检查设备上是否能使用 session,由于在一部分设备 (好比 iPad) 上,这个框架是不能使用的。这能够经过WCSession.isSupported()
来判断。在确认平台上可使用后,咱们能够设定 delegate 来监听事件,而后开始这个 session。当咱们有一个已经启动的 session 后,就能够经过框架的方法来向配对的另外一个设备发送数据了。
大体来讲数据发送分为后台发送和即时消息两类。当 iOS app 和 watch app 都在前台的时候,咱们能够经过 -sendMessage:replyHandler:errorHandler:
来在二者之间发送消息,这在 iOS app 和 watch app 之间须要互动的时候是很是有用的。另外一种是后台发送,在 iOS 或 watch app 中有一者不在前台时,咱们就须要考虑使用这种方式。后台通信有三种方式:经过 Application Context,经过 User Info,以及传送文件。文件传送简单明了就是传递一个文件,另外两个都是传递一个字典,不一样之处在于 Application Context 将会使用新的数据覆盖原来的内容,而 User Info 则可使屡次内容造成队列进行传送。每种方式都会在另一方的 session 开始运行后调用相应的 delegate 方法,因而咱们就能知道有数据发送过来了。
结合天气 app 的特色,咱们应该选择使用 Application Context 来收发数据。这篇文章已经太长了,因此咱们这里只作从 iOS 到 watchOS 的发送了。由于反过来的代码其实彻底同样,我会在 repo 中完成,在这里就再也不重复一遍了。
首先是在 iOS app 中启动 session。在 ViewController.swift
中添加一个属性:var session: WCSession?
,而后在 viewDidLoad:
中添加:
if WCSession.isSupported() { session = WCSession.defaultSession() session!.delegate = self session!.activateSession() }
为了让 self
成为 session 的 delegate,咱们须要声明 ViewController
实现WCSessionDelegate
。这里咱们先在文件最后添加一个空的 extension 便可:
extension ViewController: WCSessionDelegate { }
注意咱们必定须要设定 session 的 delegate,即便它什么都没有作。一个没有 delegate 的 session 是不能被启动或正确使用的。
而后就是发送数据了。在 requestWeathers
的回调中,数据请求一切正常的分支最后,添加一段
if error == nil && weather != nil { //... if let dic = Weather.storedWeathersDictionary() { do { try self.session?.updateApplicationContext(dic) } catch _ { } } } else { ... }
这里的 storedWeathersDictionary
是个新加入的方法,它返回存储在 User Defaults 中的内容的字典表现形式 (咱们在请求返回的时候就已经将结果内容存储在 User Defaults 里了,但愿你还记得)。
在 watchOS app 一侧,咱们相似地启动一个 session。在 InterfaceController.swift
的awakeWithContext
中的 dispatch_once
里,添加
if WCSession.isSupported() { InterfaceController.session = WCSession.defaultSession() InterfaceController.session!.delegate = self InterfaceController.session!.activateSession() }
而后添加一个 extension 来接收传输过来的数据:
extension InterfaceController: WCSessionDelegate { func session(session: WCSession, didReceiveApplicationContext applicationContext: [String : AnyObject]) { guard let dictionary = applicationContext[kWeatherResultsKey] as? [String: AnyObject] else { return } guard let date = applicationContext[kWeatherRequestDateKey] as? NSDate else { return } Weather.storeWeathersResult(dictionary, requestDate: date) } }
最后,在请求数据以前咱们能够判断一下已经存储在 User Defaults 中的内容是不是今天请求的。若是是的话,就再也不须要进行请求,而是直接使用存储的内容来刷新界面,不然的话进行请求并存储。将原来的 self.request()
改成:
dispatch_async(dispatch_get_main_queue()) { () -> Void in if self.shouldRequest() { self.request() } else { let (_, weathers) = Weather.storedWeathers() if let weathers = weathers { self.updateWeathers(weathers) } } }
若是你只是单纯地 copy 这些代码的话,在以前项目的基础上应该是不能编译的。这是由于在这里我并无列举出全部的改动,而只是写出了关于 WatchConnectivity 的相关内容。这里涉及到了每次启动或者从后台切换到 app 时都须要检测并刷新界面,因此咱们还须要一些额外的重构来达到这个目的。这些内容咱们在此也略过了。同理,在 watchOS app 须要请求,而且请求结束的时候,咱们也能够如前所述,经过几乎同样的代码和方式将请求获得的内容发回给 iOS app。这样,当咱们打开 iOS app 时,也就不须要再次进行网络请求了。
这部分的完整的代码能够在这个 repo 的最终的 tag 上找到,您能够尝试本身实现一下,也能够直接找这里的代码进行参考。若是后续还有修正的话,我会直接在 master 上进行。