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 上進行。