30 分钟开发一个简单的 WatchOS 2 APP
Apple Watch 和 watchOS 第一代产品只允许用户在 iPhone 设备上进行计算,然后将结果传输到手表上进行显示。在这个框架下,手表充当的功能在很大程度上只是手机的另一块小一些的显示器。而在 watchOS 2 中,Apple 开放了在手表端直接进行计算的能力,一些之前无法完成的 app 现在也可以进行构建了。本文将通过一个很简单的天气 app 的例子,讲解一下 watchOS 2 中新引入的一些特性的使用方法。
在 WWDC15 中涉及到 watchOS 2 的相关内容的 session 非常多,本文所参考的有:
- Introducing WatchKit for watchOS 2
- WatchKit In-Depth, Part 1
- WatchKit In-Depth, Part 2
- Introducing Watch Connectivity
- Building Watch Apps
- Creating Complications with ClockKit
项目简介
作为一个示例项目,我们就来构建一个最简单的天气 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。
UI
这个 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 中下载到目前为止的项目。
重构和 Model
很难有人一次性就把代码写得完美无瑕,这也是重构的意义。重构从来不是一个“等待项目完成后再开始”的活动,而是应该随着项目的展开和进行,一旦发现有可能存在问题的地方,就尽快进行改进。比如在上面我们将 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!到现在为止的项目可以在这里找到。