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 应用。

step-1

在接下来的画面中,我们选中 Include Complication 选项,因为我们希望制作一个包含有 Complication 的 watch app。

step-2

UI

这个 app 的 UI 部分比较简单,我将使用到的素材都放到了这里。你可以下载这些素材,并把它们解压并拖拽到项目 iOS app 的 Assets.xcassets 里去:

step-3

接下来,我们来构建 UI 部分。我们想要使用 PageViewController 来作为 app 的导航,首先,在 Main.StoryBoard 中删掉原来的 ViewController,并新加一个 Page View Controller,然后在它的 Attributes Inspector 中将 Transition Style 改为 Scroll,并勾选上 Is Initial View Controller。这将使这个 view controller 成为整个 app 的入口。

step-4

接下来,我们需要将这个 Page View Controller 和代码关联起来。首先将 ViewController.swift 文件中,将 ViewController 的继承关系从 UIViewController 改为 UIPageViewController

class ViewController: UIPageViewController {  
    ...
}

然后我们就可以在 StoryBoard 文件中将刚才的 Page View Controller 的 class 改为我们的 ViewController 了。

step-5

另外我们还需要一个实际展示天气的 View Controller。创建一个继承自 UIViewControllerWeatherViewController,然后将 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 和约束,最后结果如下图所示。

step-6

接下来就是从 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 里添加即可:

step-7

接下来我们来实现 UIPageViewControllerDataSource。在 ViewController.swiftviewDidLoad 里加入:

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 中下载到目前为止的项目。

step-8

重构和 Model

很难有人一次性就把代码写得完美无瑕,这也是重构的意义。重构从来不是一个“等待项目完成后再开始”的活动,而是应该随着项目的展开和进行,一旦发现有可能存在问题的地方,就尽快进行改进。比如在上面我们将 Day 放在了 WeatherViewController 中,这显然不是一个很好地选择。这个枚举更接近于 Model 层的东西而非控制层,我们应该将它迁移到另外的地方。同样现在还需要实现的还有天气的 Model,即表征天气状况和高低温度的对象。我们将这些内容提取出来,放到一个 framework 中去,以便使用的维护。

step-9

我们首先对现有的 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.swiftViewController.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。最后我们要做的是在 WeatherViewControllerviewDidLoad 中根据 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!到现在为止的项目可以在这里找到。

step-10

更多详情见请继续阅读下一页的精彩内容:

  • 1
  • 2
  • 下一页

相关内容

    暂无相关文章