为 Apple Watch 写一个校巴时刻表工具

2020 年 WWDC,苹果更新了 SwiftUI 的第二版。我惊呼:Swift 终于能用了!这次我们不写 Web 和小程序!这是一篇 Native Code 的教程。

一转眼,SwiftUI 已经推出一年了。随着这个框架的流行,苹果软件开发的门槛也进一步降低,低到我也能踮踮脚够进去了。这个暑假,由于疫情的原因,原定去新加坡国立大学的暑期交流取消了。于是,计划已久的 SwiftUI 探寻就提前开始了。

# 目标简介

迫于在南科大,查询校巴的发车时间过于麻烦,因此写了一个 Apple Watch APP,用于查询南科大校园巴士最近的发车时间。作为 Swift 和 SwiftUI 的上手项目,代码逻辑非常简单,仅用于练习。项目完整代码已经上传到GitHub:https://github.com/whexy/SUSTechBus。

目前南科大的校巴一共有两条线:上山线(终点欣园)和下山线(终点科研楼)。在这个手表APP中,要分别显示这两个线路校巴运行的情况。南科大的校巴运行时刻表也有两个版本:工作日版和节假日版。在APP中我们要能灵活切换时刻表。

# 开始做之前

先谋划一下开发的架构。尽管这个应用实在是太简单——简单到所有代码可以放在一个文件里,但是那样做并不是一个好的开始。

SwiftUI 不同于往日的 UIKit,它并不主张MVC(Model, View, Controller)的模型,而是主张MVVM(Model, View, View Model)。也就是,并不存在一个Controller可以控制View的进行。**用 SwiftUI 就是戴着镣铐舞蹈。**它很强大,但不能用来随心所欲干所有的事。这点我深有体会。

Model 是对数据的抽象。在这个应用中,它应该被封装成校巴的时刻表。作为第一个SwiftUI应用,一切从简,我们就不去触碰数据管理(File System, Core Data, UserDefault, …)了,而是将所有运行时刻直接硬编码到代码中。

View 是我们需要构建的UI界面。我已经提前设计好了界面:

界面主要是文字构成,辅以两个SF Symbol图标。在watchOS 7.0中,苹果取消了重压操作,所以我们把两个功能按钮藏在主界面下面。

View Model 是一个有趣的概念。我们应该在 View Model 里把 Model 中的数据进一步转化成 View 中所需要的。此外,我们应该在这里列举所有用户可能的意图(intents)。例如点击“更新时间”,或者“变更运行模式”。

# 开始做!

# Model: Bus.swift

先来写最简单的Model。我们需要得知当前时间之前和之后的两趟巴士发车时间。

//在这里hardcode日程表,省略了部分数据。
let XinYuan: [String] = ["07:20", "07:25", ... , "22:00", "22:10", "22:30"]
let KeYanLou: [String] = ["07:00", "07:05", ..., "21:20", "21:30", "21:40"]
let XinYuanHoliday: [String] = ["07:20", "07:40", ..., "21:40", "22:00", "20:20"]
let KeYanLouHoliday: [String] = ["07:00", "07:20", ..., "21:20", "21:40", "22:00"]

private func getBusSchedule(_ current: Date, schedule: [String]) -> (String?, String?) {
var previous: String?
var next: String?
for time_string in schedule {
let time = dateFormatter.date(from: time_string)!
if Time(time) <= Time(current) {
previous = time_string
} else {
next = time_string
return (previous, next)
}
}
return (previous, next)
}

func getXinYuanBus(_ current: Date, weekday isOnWeekDay: Bool) -> (String?, String?) {
if isOnWeekDay {
return getBusSchedule(current, schedule: XinYuan)
} else {
return getBusSchedule(current, schedule: XinYuanHoliday)
}
}

这里我写了一个Time类用于忽略Date类的日期数据,只比较时间。更优雅的方式是给Date写extension,但这里没有做。

class Time: Comparable {
static func <(lhs: Time, rhs: Time) -> Bool {
lhs.hour == rhs.hour ? lhs.minute < rhs.minute : lhs.hour < rhs.hour
}

static func ==(lhs: Time, rhs: Time) -> Bool {
lhs.hour == rhs.hour && lhs.minute == rhs.minute
}

var hour: Int
var minute: Int

init(_ date: Date) {
let calendar = Calendar.current
let components = calendar.dateComponents([.hour, .minute], from: date)
self.hour = components.hour!
self.minute = components.minute!
}
}

# ViewModel: BusViewModel.swift

之后来写View Model。View Model主要负责把数据处理成方便 View 读取的形式(这样也方便测试)。我们主要用“计算属性”(Computed Property) 来实现。

var currentTime: String {
dayFormatter.string(from: currentDate)
}
var XinYuanPrevious: String {
bus.getXinYuanBus(currentDate, weekday: isOnWeekDay).0 ?? "空"
}
var XinYuanNext: String {
bus.getXinYuanBus(currentDate, weekday: isOnWeekDay).1 ?? "空"
}
var KeYanLouPrevious: String {
bus.getKeYanLouBus(currentDate, weekday: isOnWeekDay).0 ?? "空"
}
var KeYanLouNext: String {
bus.getKeYanLouBus(currentDate, weekday: isOnWeekDay).1 ?? "空"
}

我们还需要能切换运行模式的功能,这里用变量的get/set捕获来实现。

var isChange: Bool = false
var changedValue: Bool?
var isOnWeekDay: Bool {
get {
if !isChange {
return !calender.isDateInWeekend(currentDate)
}
else {
return changedValue!
}
}
set {
self.isChange = true
self.changedValue = newValue
}
}
mutating func swichModel() {
isOnWeekDay.toggle()
}

# View: BusView.swift

最后我们用SwiftUI来构建界面。首先构造主体部分。

用 HStack 和 VStack 控制界面的嵌套。

struct BusTableView: View {
var direction: String
var previous: String
var next: String
var iconName: String

var body : some View {
HStack {
Image(systemName: iconName)
.font(.title)
.padding(.trailing)
VStack(alignment: .leading) {
Group {
Text("\(direction)方向")
.font(.headline)
Text("上一班 \(previous)")
Text("下一班 \(next)")
.foregroundColor(.red)
}
}
}
}
}

之后用主体拼成整个界面。

套一个ScrollView,就能直接获得数码表管转动震动等等一系列特性了。

struct BusView: View {
@State private var busViewModel: BusViewModel = BusViewModel()
var body: some View {
ScrollView {
VStack {
VStack(alignment: .leading) {
BusTableView(direction: "欣园", previous: busViewModel.XinYuanPrevious, next: busViewModel.XinYuanNext, iconName: "chevron.up")
BusTableView(direction: "科研楼", previous: busViewModel.KeYanLouPrevious, next: busViewModel.KeYanLouNext, iconName: "chevron.down")
// 此处有一行小灰字 ...
// 此处有两个按钮 ...
}
}
}
}

小灰字是根据ViewModel中的一个属性变化,所以用Group套一下,可以获得一个if声明的功能。

struct BusWorkView: View {
var isOnWeekDay: Bool
var body: some View {
Group {
if isOnWeekDay {
Text("今天校巴按工作日运行")
.fontWeight(.light)
} else {
Text("今天校巴按节假日运行")
.fontWeight(.light)
}
}
}
}

滚动到下面,有两个按钮。

这两个按钮直接用Button套Text完成。这里我本来套用的是Label,还能搭配图标。很可惜目前 SwiftUI 的 Label 在 watchOS 7.0 Beta 里显示效果一塌糊涂(图标文字没法对齐),所以姑且用Text代替了。

VStack {
Button{
self.busViewModel.refreshBus()
} label: {
Text("更新时间")
}

Button {
self.busViewModel.swichModel()
} label:{
Text("变更运行模式")
}
}

这里用了还没有正式发布的 Swift 新语法:多重尾闭包 (Multiple trailing closure)。这个语法目前还没有中文译名。不过今年9月以后这个写法就会多起来了。

至此,我们就实现完了一个APP!完整代码在GitHub:https://github.com/whexy/SUSTechBus。这次实现的非常简单,后续还可以加入多条线路选择、向服务器订阅校巴时刻表、假期安排等功能(其实也很简单)。希望南科大校巴多开几条线路,这样下一次博客的内容就有了……