Apple Watch 和 watchOS 第一代產品只允許用戶在 iPhone 設備上進行計算,然後將結果傳輸到手表上進行顯示。在這個框架下,手表充當的功能在很大程度上只是手機的另一塊小一些的顯示器。而在 watchOS 2 中,Apple 開放了在手表端直接進行計算的能力,一些之前無法完成的 app 現在也可以進行構建了。本文將通過一個很簡單的天氣 app 的例子,講解一下 watchOS 2 中新引入的一些特性的使用方法。
在 WWDC15 中涉及到 watchOS 2 的相關內容的 session 非常多,本文所參考的有:
作為一個示例項目,我們就來構建一個最簡單的天氣 app 吧。本文將一步步帶你從零開始構建一個相對完整的 iOS + watch app。這個 app 的 iOS 端很簡單,從數據源取到數據,然後解析成天氣的 model 後,通過一個 PageViewController 顯示出來。為了讓 demo 更有說服力,我們將展示當前日期以及前後兩天的天氣情況,包括天氣狀況和氣溫。在手表端,我們希望構建一個類似的 app,可以展示這幾天的天氣情況。另外我們當然也介紹如何利用 watchOS 2 的一些新特性,比如 complications 和 Time Travel 等等。
雖然本文的重點是 watchOS,但是為了完整性,我們還是從開頭開始來構建這個 app 吧。因為不管是 watchOS 1 還是 2,一個手表 app 都是無法脫離手機 app 單獨存在和申請的。所以我們首先來做的是一個像模像樣的 iOS app 吧。
第一步當然是使用 Xcode 7 新建一個工程,這裡我們直接選擇 iOS App with WatchKit App,這樣 Xcode 將直接幫助我們建立一個帶有 watchOS app 的 iOS 應用。
在接下來的畫面中,我們選中 Include Complication 選項,因為我們希望制作一個包含有 Complication 的 watch app。
這個 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!到現在為止的項目可以在這裡找到。
更多詳情見請繼續閱讀下一頁的精彩內容: http://www.linuxidc.com/Linux/2015-08/121467p2.htm