欢迎关注微信公众号「Swift 花园」

CLLocationManager, CLLocationManagerDelegate

监控目标区域,进入、离开,Beacon 相关

MKMapView, MKCoordinateRegion, MKMapItem,

  • 坐标 CLLocationCoordinate2D (CoreLocation)
  • MKMapItem
  • 罗盘按钮 MKCompassButton
  • 用 Array:compactMap 获取一个可选型集合到非可选型且不包含 nil 的映射集合。

KVO Compliant (KVO 兼容)

KVO is key-value observing.

MKLocalSearch 查找附近,MKLocalSearchCompleter

UISearchResultsUpdating

1
updateSearchResults (for searchController: UISearchController)

欢迎关注微信公众号「Swift 花园」

要做一个 watch app,逻辑上,你会先想到从主 UI 开始。毕竟,notfication 和 complication 是可选的。人们说到 app ,通常指的就是主 UI 。

但是,如果要做一个在表盘用于浏览空气质量指数的 watch app ,你会先想到 complication 。 watchOS 设计的三大准则之一是 glanceable ,意味着用户能在扫一眼手表,以尽快的方式看到想要的信息,理想的时间最多几秒钟。 complication 可以让看到这些信息,比从 app 启动栏访问主 UI 快得多。

不同于 iOS ,watchOS 的应用并不要求主 UI 一定得是最常用的使用方式 —— 如果用例使得通知和 complication 更合理的话。主 UI 可以充当用户想要查看更具体信息或者特定的动作时的 “回退” 方案。

那么,为什么我们不跳过 view controller ,直接尝试构建一个 complication 呢?

以下是 Kuba 构建的一个简单的 MVP 版本的 watch app ,只有一个 complication (支持 1~2 种变体)。这个 app 没有 UI ,主试图只有一个黑盒子,一行 WKInterface* 对象相关的代码都没有。

这个没有 UI 的 watch app 的用途是获取空气质量的信息(PM10,PM2.5,$ NO_2 $ 等),每个小时更新一次,但足够用了。

下面先了解一些基础知识。


Complication 时间线

管理 complications 的 API 单独从 WatchKit 分离出来,位于 ClockKit 中,以 CLK 前缀标识。

有一些 complication 在你抬腕时就是可见的。当手表的屏幕亮起,你希望立即看到渲染的 complication widget ,它显示的数据当下就必须是最新的 —— 用户很可能只看了它一秒钟不到,因此没有时间在这个时候启动网络请求。

Apple 也不可能采用 7 天 24 小时的方式让应用在后台运行扩展 —— 电池撑不住。

所以工作方式实际上是这样的:你的应用指定一个 complication data source (CLKComplicationDataSource) ,然后每当它接收到新的数据时 (无论运行在前台或者后台),它告诉 complication server (CLKComplicationServer) 通过数据源刷新数据。数据源返回一个 timeline 数据 (一个 CLKComplicationTimelineEntry 的对象) —— timeline 告诉 watchOS complication 在给定时间点到下一个时间点之前应该显示什么数字、文本、图标或者它们的组合。系统缓存这份数据。并且在正确的时间点自动更新显示的内容 —— 你的 app 只有在需要返回 timeline 时才会被唤起,但实际上也可以做到不需要唤起。你可以预先准备一整天的内容,只要你的数据足够提前。

下图是一个经典的天气 app 的例子,点标记 timeline 实体,上面的线显示每个实体被展示的时长。

而这个是日历 app 的 complication :

取决于 app 类型,你需要的数据可能是未来的,过去的,两者都有,或者只需要当前状态。

在 Kuba 的案例中,他用的是过去的数据 —— 因为 PM10 这种数据不可能精确预测,它受到很多因素影响,某些是人为的 (比如烧煤取暖这类日常活动)

Time Travel

Timeline 的设计还用到 watchOS 的另外一个特性,叫做 “Time Travel” ,它使得你可以在表盘上向前或者向后滚动时间,并更新 complication —— 这使得你可以看到诸如一场比赛中比分变化的过程,或者一只股票在一天中股价变化的过程。

watchOS 5 中这个特性被完全移除了,这意味着现在没办法看过去时间点的数据了。所以在实践中,实现处理过去数据这部分的 complication API 没有意义。

未来的数据仍然有价值 —— 虽然没有办法直接滚动操作了,但是 time travel 还可以工作,只不过是单方向固定节奏了。

有趣的是,这部分无用的 API 尚未被废弃,这意味着未来有回归的可能。

Comlication 家族

在 watchOS 5 中你可以选择多达 26 种样式的表盘。不同的表盘可以适应不同数量和形状的 complications 。这些形状或者 complication 空间的变体被称为 complication famlilies ,目前有 10-11 种 families 可用:

  • Modular Small, 用于所有的经典模块化表盘,也可用于 Siri 表盘的角落
  • Modular Large, 只能用在模块化表盘中间唯一的位置
  • Circular Small, 用于一些不同的表盘 (e.g. Activity)
  • Utilitarian Small (有 “flat” 变体) 和 Utilitarian Large, 用于占据表盘一半以上空间,展示一条水平的内容 (它有一个模式,容易跟 Circular Small 混淆)
  • Extra Large, 只用在 X-Large 表盘上
  • Graphic Corner, Graphic Circular, Graphic Bezel 和 Graphic Rectangular, 只用在 Apple Watch 4 系列的 Infograph 表盘

你可以支持其中任意多你想要的家族子集,当然,理想情况下一个好的 app 是支持所有这些家族,因为不同的人偏好不同的表盘。

项目中为了让事情简单一些,我们只添加了对 Modular Small 和 Circular Small 的支持(覆盖了 11 中表盘,如果没算错的话)。

内容模板

由于资源的限制,你无法在 complication 空间随意绘制东西,你只能使用预定义的模板。模板限定了它们可以包含的内容类型和排布方式。你唯一的选择是选择一种模块,适配给定的空间,放入文本,图标或者值。

举个例子, Circular Small 家族有 6 种可用的模块:

.ringImage, 中间一个图标,然后一个围绕它的环,其他环的哪些部分被填充可以由你指定
.ringText, 中间是文本,然后一个围绕它的环 (实际中,文本通常是 1 到 3 位数字)
.simpleImage, 就是个简单的图标
.simpleText, 就是个标签
.stackImage, 上面一个小图标,下面是短的标签
.stackText, 两行短文本

大部分模块都只有极其有限的空间用于展示内容,有的时候你需要绞尽脑汁想如何利用给定的空间。如果实在想不出来,那么放弃对特定 complication 家族的支持也是可以的。

你的 app 会一些不同的状态 —— 例如,有或者没有数据,空或者非空数据列表,有或者没有进行中的活动等等。所以根据状态来选用不同的模块是可以的(比如,某些状态用数字,某些状态用图标)。每当你构建 timeline 时,你可以创建全新的模板对象并且用它们填充内容,所以只要你开心,甚至可以每次采用随机模板。

文本和图像 providers

为了渲染不同类型内容的灵活性, timeline 数据并非简单地以 String 或者 UIImage 对象的形式返回,而是借助某种可用的 provide 对象封装。这些 provider 使得你的内容可以更加动态,根据时间和上下文变化。

对于文本,最简单的选项是 CLKSimpleTextProvider ,你可以指定单一的字符串以及一个可选的简短版本,如果空间无法容纳完整字符串,则选取简短版本。

作为替代方案,有几种时间相关内容的 provider 可供选择:

  • CLKDateTextProvider 输出日期 (日 / 月)
  • CLKTimeTextProvider 输出特定时间 (小时 / 分钟)
  • CLKTimeIntervalTextProvider 输出时间范围 (from-to)
  • CLKRelativeDateTextProvider 输出自某个时间开始或者到某个时间结束 (例如 “2 小时后”)

上面最后一种会随着时间的流逝自动更新,你只需要配置一次目标时间戳,而不用每小时或者更频繁地手动更新,例如 “5 小时后”,“4 小时后” 等等。

对于图像,你通常用 CLKImageProvider 。它让你指定一个模板图像(被渲染为单色)和一个颜色。多少情况下,这个颜色会被忽略,因为大部分表盘都是以用户选定的单一颜色渲染所有的 complications 。有一个叫 CLKFullColorImageProvider 的模块可以以全彩的方式渲染图像,但只在新的 Infograph 表盘才用到。

模拟 Infograph 表盘上的 complications 还用到一些 CLKGaugeProvider —— 它们是用于配置新表盘角落里的彩色弧线。


出发!

首先,创建工程,使用模板 “watchOS > iOS App with WatchKit App” ,确保 “Include Complication” checkbox 勾选。

工程将包含 3 个 targets:

  • SmogWatch, 它是 iOS app (这个案例里我们基本不碰这部分)
  • SmogWatch WatchKit App, UI 部分,只包含了 storyboard, (包括主 UI 和可能的 notification 场景) 以及 asset catalog 。
  • SmogWatch WatchKit Extension, 包含所有的 WatchKit 代码

在导航栏中选择 “SmogWatch WatchKit App” 目标运行。

设计模板

如上文所提,为了让事情更简单,我们只是实现 Modular Small 和 Circular Small complication 家族。不过默认情况下所有的 complication 家族都是启用的,所以你需要禁用掉其他的。

打开 “SmogWatch WatchKit Extension” target 的配置页,在 “General” tab 你会看见一个可以触发 complication 家族的列表:

接下来,要确定每个 complication 家族要采用什么模板。在 CloudKit 文档 中,找到 Modular Small 家族。在它的页面上,你会看到 7 种可用的模板类以及它们的效果截屏。

在我们的案例中,我们主要显示小数字,所以下面几种选项可能是合理的:

  • 显示数字,例如 “75” - 可读性没问题,但是第一眼看数字代表什么不明显
  • 以一个圆来显示数字 - 弧应该怎么算,没有上限怎么办?
  • 以上面是图标,下面是数字的方式显示
  • 以两行文本显示

最后,我选择了像下面这样的样式:

这个方案解决了展示 app 是什么的类型,同时也支持解释不同类型参数的问题,缺陷是使得字体更小了,尤其是 3 个数字的情况。尽管仍然可读,但是 Circular Small 版本肯定效果不好。因此,对于 Circular Small ,选项相似,也选择了两行文本的版本。

上面这个可读性差很多,但是 Circular Small 是非常通用的 complication 家族,因此基本上对所有使用者都是一个挑战。Apple 自己的 complications ,比如世界时钟,日出、日落,看起来也没有好多少。我们这里可以放弃 “PM” ,但这样一来又搞不清 app 是干什么用的,所以折中,把 “PM10” 缩短为 “PM” 。有可能上面用图标效果会更好,读者可以尝试一下。


实现 Complication 数据源

现在,打开样板代码 ComplicationController 类,这里已经数据源协议所有要求的方法了,一些是空实现,但其中大多数我们并不需要。

注意,所有的方法都是通过一个 handler callback 返回数据的。这使得你可以通过某些异步的方式加载要求的数据 —— 理论上,你是可以按需在用到时再加载这些数据,但实际上我们绝不应该这么做。

所有的方法都传入一个 CLKComplication 对象作为参数,它让你知道系统现在正为哪一种 complication 向你询问数据,这个对象只有一个字段叫 family ,这意味着在一个 Modular 表盘上,你无法区分同族的两个 complication 实例,但是不同族的可以。

因此,这个信息绝对是必须的 —— 不仅因为不同家族外观看起来不一样,也是为了让编译器匹配你返回的模板类型。

CLKComplicationDataSource 协议里只有 getSupportedTimeTravelDirectionsgetCurrentTimelineEntry 两个方法是必须得实现的,但我们会先从一个可选的方法开始讲。

样例模板

我们要看的第一个方法是 getLocalizableSampleTemplate ,在文件的底部 —— 你有可能会需要在把 complication 添加到表盘之前先实现这个方法。

这个方法让你返回一个 complication 的 “样例” 外观,它是当用户在表盘配置视图中设置 complication 时用到的。这里应当展示一些随机数据,表现你的 complication 一般情况下的外观,就像你在应用的网站或者应用商店上放的截图那样的东西。

在这个方法中,我们需要返回一个 CLKComplicationTemplate 对象 —— 在实际的 timeline 中,我们也会返回一样的东西。不过这里不指定时间戳。对于两种 complication 家族,我们都用标准的 CLKSimpleTextProvider 来封装返回的文本。 在样例模板里,我们用 “50” 来代替真实值。

下面是代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func getLocalizableSampleTemplate(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTemplate?) -> Void)
{
switch complication.family {
case .modularSmall:
let template = CLKComplicationTemplateModularSmallStackText()
template.line1TextProvider = CLKSimpleTextProvider(text: "PM10")
template.line2TextProvider = CLKSimpleTextProvider(text: "50")
handler (template)

case .circularSmall:
let template = CLKComplicationTemplateCircularSmallStackText()
template.line1TextProvider = CLKSimpleTextProvider(text: "PM")
template.line2TextProvider = CLKSimpleTextProvider(text: "50")
handler (template)

default:
preconditionFailure("Complication family not supported")
}
}

记得总是返回匹配给定 complication 家族的模板。不幸的是,好像没有可以在编译期检查这个过程的机制。

这里,我们为两种家族都选用了 “stack text” 模板,因此都有 line1TextProviderline2TextProvider 属性。如果你选择另外的配置的话,可能的属性有 imageProviderheaderTextProviderringStyle 等等。

如果系统向我们请求其他我们不支持的 complication 类别的话,我们在默认 case 抛出断言 —— 但这不应该发生,因为我们已经禁用所有其他类型的 complication 。用 preconditionFailure 触发崩溃是为了确保自己不忘掉一些东西,最终版本其实应该返回 nil 更安全。

之所以先说这部分,是因为无论你在这个模板返回了什么,它都会被系统缓存。如果你改变了代码再次运行,你不会看到任何效果 —— 你需要从模拟器中删除 app ,重新安装以便更新版本。

现在,当你运行 app ,你可以编辑表盘(通过用力按压 MacBook 的 touchpad ,或者在菜单 Hardware > Touch Pressure),选择一个 complication 空间,并且选择你的 complication :

注意,默认你的 app 名是 app target 的完整名,这会有点长。为了把它改成更可读的,打开 WatchKit app target 的 Info.plist (注意,是 app 而不是 extension 的) 然后把 “Bundle display name” 改成 “SmogWatch” 。

当你退出编辑模式并返回表盘,你会看到你放置 complication 的地方有一个空白的空间 —— 别急,我们接下来就着手处理这块。

getSupportedTimeTravelDirections

这个方法告知系统你的 app 在过去、未来、两个方向或者只有当前时刻拥有数据。因为之前提到过去的数据已经不再使用了,所以只有返回 .forward 或者空列表是有意义的。由于我们并不需要预测未来的空气质量,所以我们只需要返回一个空的列表:

1
2
3
4
5
6
func getSupportedTimeTravelDirections(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void)
{
handler ([])
}

这里返回的东西决定了系统是否会调用 getTimelineStartDategetTimelineEndDategetTimelineEntries (for:before:limit:withHandler:)getTimelineEntries (for:after:limit:withHandler:) 这些方法,以询问你 timeline 在两个方向上延展的长度,时点。如果我们返回 [] ,那么系统只会询问当前时点。

不过这些方法都是可选的,所以如果你都不实现它们, watchOS 会假定当前时点没有什么有趣的东西。

getCurrentTimelineEntry

这是整个协议核心的代码,它是我们返回最新数据点的地方。

timeline 数据是以一个或者多个 CLKComplicationTimelineEntry 对象返回的。一个 timeline 实体其实就是一个时间戳加上一个或者多个指派的数据 provider ,里面填充着你需要的数据。实体借由时间戳验证。

目前我们还没有实际拥有数据,不过别担心 —— 我们可以先返回一个静态数值,比如 75 ,就像样例模板中的做法一样。我们使用当前时间作为时间戳,因为根据前面方法返回的设定,我们不会被询问任何在当前时点之前的时段数据。

下面是 getCurrentTimelineEntry 的初始版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func getCurrentTimelineEntry(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void)
{
let entry: CLKComplicationTimelineEntry

switch complication.family {
case .modularSmall:
let template = CLKComplicationTemplateModularSmallStackText()
template.line1TextProvider = CLKSimpleTextProvider(text: "PM10")
template.line2TextProvider = CLKSimpleTextProvider(text: "75")
entry = CLKComplicationTimelineEntry(date: Date(), complicationTemplate: template)

case .circularSmall:
let template = CLKComplicationTemplateCircularSmallStackText()
template.line1TextProvider = CLKSimpleTextProvider(text: "PM")
template.line2TextProvider = CLKSimpleTextProvider(text: "75")
entry = CLKComplicationTimelineEntry(date: Date(), complicationTemplate: template)

default:
preconditionFailure("Complication family not supported")
}

handler (entry)
}

当你添加了以上两个方法,编译运行你的 app 到模拟器。你应该会在 complication 里看到我们配置的模板和数值:

如果你还是没有看到效果,那可能是因为系统缓存了之前编译版本的状态。为了强制加载 complication ,你可以进入编辑模式,切到不同的 complication ,退出编辑模式。 然后再进入编辑模式,切回你的 complication 。

可选的方法

在数据源协议中还有一些其他的协议,但针对我们的用途,我们只需要用到 getTimelineEntries (for:after:limit:withHandler:) 。这个方法询问我们早前传入的 timeline 时点之后的时点。当我们写的 app 需要提前了解某个时点时,会用到这个方法。例如,天气预报,日历事件,todo list 上预定的任务等。不过,大部分 app 只需要显示当前实体就够了。

我们在这个 app 中使用这个 API 的作用是,我们很可能需要在时点过去之后将未来版本的数据标记为过时。如果你查看的是 6 个小时前的空气质量,它很可能是没什么价值的,因为当前的空气很有可能已经发生显著的变化。在 Krakow ,这种变化可能发生在 2 个小时内。例如,起风或者风停了。所以,我们可能在几小时后自动隐藏掉当前数值,借助添加一个几小时后的 “重置” 数据来实现。如果我们成功地在每个小时更新了数据,那么备选的第二个时点的数据永远不会被展示,但是如果有些东西出错了,那么当时间变化足够长,会在时点到来时借助这个 API 来更新数据。

我认为 watchOS 之前应该也是这么干的,至少在 Time Travel 功能里是这么做的 —— 文档里也提到了。不过这本该是 getTimelineStartDategetTimelineEndDate 方法存在的意义 —— 但是由于这两个 API 不起作用 (Time Travel),所以实现它们也没意义。


从网络上获取真实数据

对于第一个版本,我们用使用 Małopolska 地区空气监控系统的公共数据 (仅限波兰) 。

前端通过一个挺复杂的 POST 请求,发送到 URL http://monitoring.krakow.pios.gov.pl/dane-pomiarowe/pobierz ,然后解析返回的 Json 数据。

这个主题并不是跟 watchOS 特定相关,它是特定于 web API —— 所以这里不详细描述,下面是拉取和解析数据的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import Foundation

private let DataURL = "http://monitoring.krakow.pios.gov.pl/dane-pomiarowe/pobierz"

class KrakowPiosDataLoader {
let dateFormatter: DateFormatter = {
let d = DateFormatter()

// 不确定下面是否必须,安全起见
//see https://developer.apple.com/library/archive/qa/qa1480/
d.locale = Locale(identifier: "en_US_POSIX")

d.dateFormat = "dd.MM.yyyy"

// 确保我们用的是 CET 时区 —— 比如说你是在莫斯科
// 你在 2 月 19 号午夜之后请求 19.02.2019 (这时候在波兰还是 2 月 18 号)
// 你将拿不到数据
d.timeZone = TimeZone(identifier: "Europe/Warsaw")!

return d
}()

let dataStore = DataStore()

let session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForResource = 10.0
return URLSession(configuration: config)
}()

func queryString() -> String {
let query: [String: Any] = [
"measType": "Auto",
"viewType": "Parameter",
"dateRange": "Day",
"date": dateFormatter.string (from: Date()),

//hardcoded ID for PM10 on a specific station
//we'll make it configurable later
"viewTypeEntityId": "pm10",
"channels": [148]
]

let jsonData = try! JSONSerialization.data (withJSONObject: query, options: [])
let json = String(data: jsonData, encoding: .utf8)!

//don't ask me, that's what the API expects
return "query=\(json)"
}

func fetchData(_ completion: @escaping (Bool) -> ()) {
var request = URLRequest(url: URL(string: DataURL)!)
request.httpBody = queryString ().data (using: .utf8)!
request.httpMethod = "POST"

NSLog("KrakowPiosDataLoader: sending request to %@ with %@ ...",
DataURL, queryString ())

let task = session.dataTask (with: request) { (data, response, error) in
var success = false

if let error = error {
NSLog("KrakowPiosDataLoader: received error: %@", "\(error)")
} else {
NSLog("KrakowPiosDataLoader: received response: %@",
data != nil ? "\(data!.count) bytes" : "(nil)")
}

if let data = data {
if let obj = try? JSONSerialization.jsonObject (with: data, options: []) {
if let json = obj as? [String: Any] {
if let data = json ["data"] as? [String: Any] {
if let series = data ["series"] as? [[String: Any]] {

//there would be more than one data series if we passed
//multiple "channel IDs" (e.g. for more than 1 station)
if let first = series.first {
if let points = first ["data"] as? [[String]] {

//the data series is an array of up to 26 hourly
//measurements; we only take the last one for now
if let point = points.last {
let date = Date(
timeIntervalSince1970: Double(point [0])!
)
let value = Double(point [1])!

self.dataStore.currentLevel = value
self.dataStore.lastMeasurementDate = date

NSLog("KrakowPiosDataLoader: saving data: " +
"%.0f at %@", value, "\(date)")

success = true
}
}
}
}
}
}
}
}

if !success {
NSLog("KrakowPiosDataLoader: no data found")
}

completion (success)
}

task.resume ()
}
}

不要忘记在最后用 resume () 启动任务。

小结:

  • 我们向 API 请求 PM10 的数据,硬编码请求当天和特点地点。
  • 我们只取最后的测量结果 (多数情况下是最近一两个小时的数据)
  • 如果我们拿到数据,存储一个数字,表示 PM10 的浓度,以及测量的时间点
  • 通知调用方我们拿到或者没有拿到数据

上面的代码用了老式的 Json 解析方法,因为我认为这样比较容易理解。

我用老式的 NSLog 而不是 Swift 的 print () ,是因为后者只会显示在 Xcode 的控制台,并不会记录到系统日志,所以在控制台 app 的诊断日志里看到,在某些情况下你需要在 app 没有连接 mac 时跟踪它的行为。

还有,注意我们是在前台请求数据,用最基本的 URL session 。这不是通常我们最理想的应用方案 —— 理想的,所有的请求都应该是在后台 URL sessions 中完成,不过这里只是一个最小可用原型,先保持这样吧。

不过我们通过把超时时间设置为每次请求不超过 10 秒钟来限制了 URL session。 这里 timeoutIntervalForResource 的用法,而不是 timeoutIntervalForRequest 或者 timeoutInterval 很重要,因为自上一次接收到数据包后,后面两个只会在空闲时间工作,而我们希望控制总的请求时间。之所以要控制总时间,是因为看起来这里边有一个针对后台任务的硬性限制,并且没有在文档中提到:如果一个 app 超出了 15 秒的后台运行时间,它会被立即杀死,崩溃报告如下:

Termination Reason: CAROUSEL, Background App Refresh watchdog transgression. Exhausted wall time allowance of 15.00 seconds. Termination Description: SPRINGBOARD, CSLHandleBackgroundRefreshAction watchdog transgression: eu.mackuba.SmogWatch.watchkitapp.watchkitextension exhausted real (wall clock) time allowance of 15.00 seconds (…)

为了便于你了解最后一次检测的时间点,我们把 lastMeasurementDate 时间戳存进了 DataStore ,这是一个我们可以用来实现之前提到的 “过时数据 特性的潜在结构。

那么这个 DataStore 究竟是什么?其实只是 UserDefaults

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private let CurrentLevelKey = "CurrentLevel"
private let LastMeasurementDate = "LastMeasurementDate"

class DataStore {
let defaults = UserDefaults.standard

var currentLevel: Double? {
get { return defaults.object (forKey: CurrentLevelKey) as? Double }
set { defaults.set(newValue, forKey: CurrentLevelKey) }
}

var lastMeasurementDate: Date? {
get { return defaults.object (forKey: LastMeasurementDate) as? Date }
set { defaults.set(newValue, forKey: LastMeasurementDate)}
}
}

最后,我们需要添加一个例外域名到 WatchKit Extension target 的 App Transport Security 设置中,因为这个域名不支持 Https 。

1
2
3
4
5
6
7
8
9
10
11
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>monitoring.krakow.pios.gov.pl</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>

显示真实数据

为了实际加载数据,我们需要在某个地方调用这个类的方法。我们来看一看 ExtensionDelegate 这个类,它实现了 WKExtensionDelegate —— 基本上就是一个 WatchKit 版本的 UIApplicationDelegate 。就像所有的 app 代理, WKExtensionDelegate 有许多生命周期方法,这些方法会被系统在各种时刻调用: applicationWillEnterForegroundapplicationDidBecomeActiveapplicationWillResignActiveapplicationDidEnterBackground 等等。

这里头我们目前唯一会用到的是 applicationDidFinishLaunching 。这个方法会在 app 进程启动时被调用 —— 无论是通过 app launcher 或者通过 Xcode ,又或者从后台启动。只要是 app 需要被唤起,并且之前已经被系统清理掉的时候,这个周期都会运行 (通常在晚上,被系统杀死的情况经常发生) 。

无论何时, app 启动或者在后台重启,我们都希望借助这个机会立即拉取最新的数据,如果我们得到响应,重新加载所有活动的 complication (活动的 complication 指那些在当前选择的表盘上显示的 complication)。

所以我们将这样做:

1
2
3
4
5
6
7
8
9
func applicationDidFinishLaunching() {
NSLog("ExtensionDelegate: applicationDidFinishLaunching ()")

KrakowPiosDataLoader().fetchData { success in
if success {
self.reloadActiveComplications ()
}
}
}

为了拉取数据,我们调用了 KrakowPiosDataLoader 类,然后在有任何新数据的情况下重载加载 complications ,否则的话就不必了。在 watchOS 上,不要浪费时间做无用功,这是一条通用的准则。

为了重新加载 complications ,我们得拿到活动 complication 的列表,这是借由全局共享的 CLKComplicationServer 实例来获得的,并且也通过它的 reloadTimeline (for:) 方法来重新加载那些活动的 complication 。如果打算在已经存在的 timeline 实体后追加新的 timeline 实体,我们也可以用另一个相似方法 extendTimeline (for:) ,两者的区别是前者我们希望立刻用新数据替换掉之前的数据。

1
2
3
4
5
6
7
func reloadActiveComplications() {
let server = CLKComplicationServer.sharedInstance ()

for complication in server.activeComplications ?? [] {
server.reloadTimeline (for: complication)
}
}

上面的代码会触发一轮对你的 CLKComplicationDataSource 的调用 —— 有的时候是一会之后,不过通常几乎都是立刻发生。现在我们有了真实数据,我们可以回到之前写的 getCurrentTimelineEntry 方法,然后把占位的代码替换成实际的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func getCurrentTimelineEntry(
for complication: CLKComplication,
withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void)
{
let store = DataStore()
let entry: CLKComplicationTimelineEntry
let date: Date
let valueLabel: String

if let lastMeasurement = store.lastMeasurementDate, let level = store.currentLevel {
valueLabel = String(Int(level.rounded ()))
date = lastMeasurement
} else {
valueLabel = "--"
date = Date()
}

switch complication.family {
case .modularSmall:
let template = CLKComplicationTemplateModularSmallStackText()
template.line1TextProvider = CLKSimpleTextProvider(text: "PM10")
template.line2TextProvider = CLKSimpleTextProvider(text: valueLabel)
entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template)

case .circularSmall:
let template = CLKComplicationTemplateCircularSmallStackText()
template.line1TextProvider = CLKSimpleTextProvider(text: "PM")
template.line2TextProvider = CLKSimpleTextProvider(text: valueLabel)
entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template)

default:
preconditionFailure("Complication family not supported")
}

handler (entry)
}

现在,当我们运行 app 时,点击 home 按钮返回表盘时,我们会看到一个刚刚借助 API 加载的真实数据:

1
2
3
4
5
6
ExtensionDelegate: applicationDidFinishLaunching ()
KrakowPiosDataLoader: sending request to http://monitoring.krakow.pios.gov.pl/dane-pomiarowe/pobierz with
query={"viewTypeEntityId": "pm10", "measType": "Auto", "viewType": "Parameter", "dateRange": "Day",
"date": "24.02.2019", "channels": [148]} ...
KrakowPiosDataLoader: received response: 1553 bytes
KrakowPiosDataLoader: saving data: 46.4462 at 2019-02-24 10:00:00 +0000


安排更新

最后的拼图是确保我们可以按照有规律的间隔加载新的数据并重新加载 complication 。有一些场景你可以更新 complications :

  • 当你的 app 处于前台时,你总是可以做这件事 —— 但你无法依赖它定期发生。
  • 当你接受到一些静默的推送通知时,尤其是专门为这种用途设计的 (借助 PushKit framework ,采用 PKPushTypeComplication 类型) —— 当你的数据以不规则间隔更新时,这种机制会有用 —— 当外部发生一些事件时。
  • 当 iPhone app 以某种方式接收到新的数据并把它传输给 watch 时
  • 通过计划定期的后台刷新 —— 当你希望拉取数据而不是被动等推送时,这种方式更好。

注意,不管你采用哪种策略,对于我们刷新数据的频率以及完成刷新的用时,有许多限制。 (比如,每天不超过 50 个推送通知) —— 如果你用尽了所有的时间或者每天可用的推送数量,你将无法再在后台运行,有可能要等到第二天。对于这点约束,看起来没有什么特别好的方案可以绕过,你也不应该尝试去寻找这类方案。

既然我们知道城市监测站每小时发送一次新的测量数据,我们会使用计划好的后台刷新来更新我们的 complication ,并且会在 ExtensionDelegate 中完成。

为了确保我们的 app ,我们需要实现一样我称为 “后台刷新循环” 的东西:当 app 启动或者重启时,我们安排一次后台刷新,然后当 app 被这个后台刷新唤起时,我们做的第一件事就是安排下一次后台刷新,以确保若干时间后总有新的刷新被计划。

我们会在所有其他事情之前开始做刷新计划,因为我们无法知道在我们的 app 被挂起或者杀死之前还有多少可用的时间。否则,如果在我们设置下一次刷新之前 app 就被挂起,那么 app 就相当于没设闹钟就睡过去了,那么它将会睡过头。 😉

现在,让我们再看一下 applicationDidFinishLaunching 方法,我们需要在 web 请求发送之前增加一个新的方法调用 scheduleNextReload () :

1
2
3
4
5
6
7
8
9
10
11
func applicationDidFinishLaunching() {
NSLog("ExtensionDelegate: applicationDidFinishLaunching ()")

scheduleNextReload ()

KrakowPiosDataLoader().fetchData { success in
if success {
self.reloadActiveComplications ()
}
}
}

计算下一次刷新时间

在计划下一次刷新前,我们首先需要定出下一次刷新的时机。

为了优化后台刷新的耗时,尽可能利用好珍贵的后台时间,思考清楚我们的数据究竟需要在何时和以何种频率改变。一个很好的例子是 —— 证券交易只发生在工作时间,不在工作时间内,股票价格不会变化,所以在夜间重载不会改变的数据是没有意义的。

我对获取数据的 API 做了一些测试,新的数据几乎总是 1 个整小时的 0 到 10 分钟内添加。所以我决定每小时请求一次刷新,总是在每小时的 15 分做这件事 (10:15 , 然后 11:15, 然后 12:15 ,以此类推)。为了实现这种方式,我们需要一个辅助方法来让我们基于当前时间找到最接近 xx:15 的时间 —— 幸运的是,利用 NSCalendar API 很容易做到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func nextReloadTime(after date: Date) -> Date {
let calendar = Calendar(identifier: .gregorian)
let targetMinutes = DateComponents(minute: 15)

var nextReloadTime = calendar.nextDate (
after: date,
matching: targetMinutes,
matchingPolicy: .nextTime
)!

// 如果和当前时间间隔小于 5 分钟,那么跳过,尝试下一个小时
if nextReloadTime.timeIntervalSince (date) < 5 * 60 {
nextReloadTime.addTimeInterval (3600)
}

return nextReloadTime
}

计划后台刷新

最后,为了在计算好的未来时点请求更新,我们需要在 WKExtension (等价于 UIApplication) 上调用 scheduleBackgroundRefresh

1
2
3
4
5
6
7
8
9
10
11
func scheduleNextReload() {
let targetDate = nextReloadTime (after: Date())

NSLog("ExtensionDelegate: scheduling next update at %@", "\(targetDate)")

WKExtension.shared ().scheduleBackgroundRefresh (
withPreferredDate: targetDate,
userInfo: nil,
scheduledCompletion: { _ in }
)
}

你传入的日期是你希望你的 app 被唤起的时间。当然,系统会把它看做一种提示 —— 你的 app 实际被唤起的时间还可能取决于各种因素(我猜测这其中包含电量,充电状态,网络访问,你请求刷新的频率,你每次刷新的耗时,等等)。所以,不要假定你的 app 总是能在固定的间隔运行。

不过,基于我的测试,在实践中一个拥有一个活动 complication ,每隔一个小时更新的 app ,通常在 10 秒以后的请求时间,在白天的表现比在夜间充电的表现要好很多,或者 app 运行频繁,或者 app 处于 dock 但是没有 complication 时,后台任务被调用的机会更少。不在 dock 也不没有 complication 的 app ,几乎不被调用。

scheduledCompletion 块在文档中被描述为 “A block that is called by the system after the background app refresh task has completed” ,但是实际上它是在下一个刷新任务计划完成时就被立即执行。不过由于它是一个可选的参数,你可以提供一个空的块。至于 userInfo ,它可以传递一些元数据给后台任务的 handler ,但这里我们用不上。

处理后台任务

watchOS 上的后台刷新是通过在各种时刻从后台唤起你的 app ,然后调用代理方法 handle (_ backgroundTasks:) ,传给它一个或者多个取决于上下文的 “后台任务”。这个方法对于你的 app 后台事务至关重要,不管你构建的 app 是什么类型,几乎一定要在这里做些事情。

任务的类型有不少,但你应当做跟当时接收到的任务相关的工作。比如有的任务是处理 URLSession 返回的数据,有的任务是处理 iPhone 返回的数据,有的任务是处理 Siri 快捷方式,但是这里我们要处理的是一种通过之前的 scheduleBackgroundRefresh 发起的任务 —— 这是一种最普通的 WKApplicationRefreshBackgroundTask 。这种任务意味着你的 app 是由于你自己的请求而被唤起的,以便你可以运行一些后台的 URL 请求,更新你的 complication 等等。

当 app 在后台被唤起时,在 handle (_ backgroundTasks:) 方法中,我们做的事情跟启动时的差不多 —— 我们计划下一次刷新,并尝试更新数据。注意,我们只用了 WKApplicationRefreshBackgroundTask ,忽略其他的任务类型。不过,在完成任务后,记得总是调用 setTaskCompletedWithSnapshot () 方法,这很关键,即便对于那些被你忽略掉并且不处理的任务。不过,调用这个方法表明我们的事干完了,在这之后我们的 app 可能会被挂起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
switch task {
case let backgroundTask as WKApplicationRefreshBackgroundTask:
NSLog("ExtensionDelegate: handling WKApplicationRefreshBackgroundTask")

scheduleNextReload ()

KrakowPiosDataLoader().fetchData { success in
if success {
self.reloadActiveComplications ()
}

NSLog("ExtensionDelegate: completed WKApplicationRefreshBackgroundTask")
backgroundTask.setTaskCompletedWithSnapshot (false)
}

default:
task.setTaskCompletedWithSnapshot (false)
}
}
}

让 watchOS 模拟器运行 app 后台刷新任务需要一些技巧,即使你已经安排它们在一会之后运行。如果你测试时发现不工作,可以尝试随机切换 app ,主屏和表盘,直到后台刷新任务可以工作。


总结

就这些,我们完成了!🎉 我们得到一个每小时运行的 app ,从 web API 加载新数据,显示到你选择的表盘上,你只要抬腕就能看到它。

依我看来,为了构建一个带 complication 的最小可行的 watch app ,你需要做这些事:

  1. 确定你的 app 想要在 complication 上展示的最重要的东西。
  2. 确定你的 complication 内容什么时候改变,你的 timeline 存放什么以及存在哪里。
  3. 浏览 complication 家族以及对应可用的模板,确定哪些最适合你
  4. 实现从 web 或者系统 API 加载数据的代码
  5. 实现 complication 数据源要求的方法,以构建 CLKComplicationTimelineEntryCLKComplicationTemplate 对象,以合适的方式展示你的内容。
  6. 确保你的 app 定期更新,用计划的后台刷新或者借助推送通知 (分析你的数据变化的模式,以便优化后台时间)
  7. 测试,测试,再测试,用任何你能想到的场景和组合 🙂

如果你只是构建一个静态的 complication ,永远不更新,就像 Apple 的 “launcher” 型的 complications , 比如 Breathe , Maps , Reminders 等等。那么,你只需要做这些事:

  1. 为所有支持的 complications 挑选一个图标。
  2. 实现数据源方法,用 “single icon” 模板返回单一实体的 timeline 。

这种情况下你不需要计划后台刷新以更新 complication ,因为它永远不变。不过,由于 complication 需要链接到真实 app 时,也有大量工作需要做。 😉

工程中的代码可以从这个仓库找到: https://github.com/mackuba/SmogWatch (master 分支最新代码,或者对应这篇文档的 post2 分支的版本)。 它是 WTFPL-licensed ,所以尽管拿去用,你可以分享给我你都做出什么好玩的东西!

欢迎关注微信公众号「Swift 花园」

watchOS 应用

相比 macOS,iOS 和 tvOS ,watchOS (目前为止) 并非是完全独立的平台,一定程度上依赖配对的 iPhone 。

watchOS 6.0 之后,watch app 可以独立发布和安装,也就说,应用生态上可以独立了。但是,某些功能要想发挥最大的效用,还要借助 iPhone 设备的计算能力。毕竟,后者目前还是要强大很多。可以这么理解,当需要用到 watch 本身不具备的硬件能力时,如视频拍摄,你仍可以把 watch 视为控制器。这个跟人们看待早期智能手表的视角一致。

Watch app 和扩展

watchOS app 跟 iOS app 最显著的差异是前者被严格的分成了两部分。第一部分称为 Watch app —— 有点混淆对吧?正常理解,两部分加起来才是一个完整的 app 。但字面上,这个主要由 UI 构成的部分就叫 Watch app ,所以我们干脆以 UI 来代表,第二个部分是 WatchKit 扩展。两部分有各自独立的数据容器,如果需要共享容器中的文件,需要用到 App Groups

watch OS 6 引入 SwiftUI 后,情况变得有些复杂。因为 SwiftUI 中,UI 即代码。原来的 watch app 部分只有一个 hosting view 。

这些年 watchOS 的变化

watchOS 1 中,app UI 运行在 watch 上,但扩展运行在 iPhone 上。扩展可以很容易地与设备上的其他 iOS app 通信,但扩展和 watch UI 之间的通信是设备间的,因此整个 app 运行很慢。

watchOS 2 中,扩展被移到了 watch 上。watch app 和 iOS app 通信需要借助 WatchConnectivty framework 。因为扩展处于 watch 上,所以能用到 SDK 自然变少了。当然,后来各种缺失的 SDK 也被陆续添加到 watchOS 中。

watchOS 4 中,扩展和 UI 被合为一个进程运行。当然,这一点对开发者来说相对无感,唯一的效果是 app 运行的更快了。

watch OS 5 以前,WatchKit app 需要依赖 iPhone 的连接来完成大部分通信。它只能连接 iPhone 连接过的 “已知 Wi-Fi 网络” 。watch OS 5 引入了连接全新 WiFi 网络的能力。

在 watch OS 5 及之前的版本,watch app 总是要求有一个伴生的 iOS app 。watch app 是内置在 iOS app bundle 中,它的安装也是通过先安装 iOS app ,再间接下载到 watch 上来完成的。最近的 watch OS 6 ,watch app 真正意义上宣布独立。你既可以采用之前的 iOS app + watch app 的方式, 也可以只开发独立的 watch app 。watch app 不再是内置在 iOS app 中,两者被分隔在各自平台的 App Store 发布。因此,对于因特网的连接方式,最新的建议是 借助 URLSessionCloudKit 等直接下载数据到 watch ,只有在真的需要跟 iPhone 交换数据时才用到 WatchConnectivity


多于一个用户接口

iOS app 通常有一个主要的用户入口。人们想到 iOS app 的时候,通常想到的是主界面上的图标。当然,也有各种扩展可以访问 app 的不同部分,但是通常被认为是主 app 的附属。你使用 app 的主要姿势是打开主 app 。

来到 watchOS ,情况大不相同。主 UI ,根据你的用例,很有可能不是最常被使用的部分。其主要原因在于 iPhone 和 Apple Watch 完全不同的交互模式。你不可能像在 iPhone 上那样在 watch 的屏幕上花很长的时间浏览内容吧?很显然,那很不舒服。

对于 watchOS ,Apple 一直重复的关键词是 glances 或者说 glanceable 。期望的 app 交互方式是:抬起手腕,看表,做一两个点击(或者甚至都不点击),或者转一下数字表冠,然后放下手腕,回到现实。这一系列动作的平均时间是以秒计的。实际上,建议是在 2 秒内让用户找到目标信息 (glanceable) 或者执行动作 (actionable) 。

如果你用过 watchOS app ,你应该知道通过主 app 找到目标信息需要一点技巧。首先,你要在主屏上那一堆六边形网格中找到 app ,然后点击,等待加载,然后在 app 的不同屏之间寻找你要的东西。基于此,也取决于你的 app 类型,极有可能你的主 UI 只会偶尔被用到。 WatchKit app 实际上提供了一些其他的入口来交互,它们可能更重要。

Notification

通知实际上是 watch 的一个绝佳的应用场景。花不到一秒的时间看一眼手表,比从口袋里掏出手机来省事不少吧?许多人会告诉你,他们戴 watch 的主要用途就是看通知。

但是,通知用的好不好,对不对,主要还是取决于你的 app 类型,通知的目的。比如,你的目的是不定期的通知用户某些事情发生了,通知可以是你的 app 很重要的一部分。典型的,提醒事项 app 。

watchOS 上通知的 UI 有三种变体:

  • 只有预制的静态信息
  • 非交互式的动态信息
  • 可交互的动态信息,watchOS 5 引入

watch OS 6 允许推送绕过 iPhone ,只到达 Apple Watch 的远程通知。

Glances / Dock

watchOS 1 开始,引入了一种被叫做 glance 的界面,卡片式,可点击,水平滚动。借助 storyboard 上单独的场景构建。

watchOS 3 开始,glance 被废弃,由 dock 取代,后者是通过按压表侧的长按钮访问。它的工作方式和 glance 相似,但是卡片的外观是基于主 app 的实际 UI (类似 iOS 上的体验),通过系统对 app 生命周期某些节点的快照来实现。当你完成滚动,选择了某个 app 后,系统会唤醒这个 app ,不久之后这个 app 实际的 live 视图会更新 dock 的静态图片。

watch OS 4 之后,dock 变成竖向滚动,跟 iOS 的体验更相似。

Complications

Complications“ 是 Apple 给表盘上的各种 widget 取的一个比较有逼格的名字。

Complications 有很多不同的家族,为不同的表盘设计 —— 圆形的,矩形的,小的,大的。这些 complictions 的共同点是展示信息的空间极其有限,一直可见(激活状态),因此需要保持最新状态。

你可以想象,complication 的特点是不可能通过让 app 持续运行在后台,并且完全访问表盘的方式来实现的。因为这样做电池撑不住。

Apple 的解决方案是你需要周期性的提前提供一个包含给定时间范围的 timeline 数据给 complication 用于显示。系统存储这份数据,到时间点了自动切换到正确的状态。你不能在 complication 里随意显示内容 —— 你只能从给定的 complication 家族中选择预先定义好的模板,然后填充一些精心准备的,允许系统在必要时简化以便适配可用空间的数据。

这里面的一个挑战是:如何找出有用的东西,填充到这么小的空间里 —— 同时这也是一个能简化工作的约束,因为你只有有限的选项。

Apple 一开始就说了,complications 只对部分 app 有意义 —— 因而并非每个 app 都有一些关键信息,可以展示为一个数字或者一行文本。不过,从 watchOS 3 开始,官方建议所有的 app 都实现一个 complication ,即便这个 complication 只是一个静态的启动器。(个人认为这个要求对用户的意义在于,用户可以在表盘上添加特定 app 的 complication ,仅仅作为启动器也是有价值的)。技术层面,系统可以针对当前表格的启动器,做一些优化,以便 app 启动更快。

Siri

最后一个入口就是 Siri 了, watchOS 5 以后,Siri 可以用于更多的用例,例如发消息,todo list 等等。


资料

MacKuba 关于 WatchKit 的文章 1

MacKuba 关于 WatchKit 的文章 2

MacKuba 的一个 apple watch 项目

欢迎关注微信公众号「Swift 花园」

关键技术

  • 运动跟踪 (motion tracking) 使得手机理解和追踪它相对于世界的位置。
  • 环境理解 (environmental understanding) 使得手机可以侦测各种表面的尺寸和位置:水平的,竖直的,成一定角度的,比如地面,咖啡桌或者墙面。
  • 光线评估 (light estimation) 使得手机可以估计环境当前的光照条件。

ARKit

ARKit 3

ARKit 3 带给人们不可思议的体验。通过 People Occlusion,ARKit 3 知道人们和 AR 对象在哪里,并适当地遮挡 scene。ARKit 3 使用 Motion Capture 跟踪人们的运动,将其输入到 AR scene 中。它还可以同时跟踪最多三张人脸,支持 collaborative sessions 等等。

People Occlusion

现在 AR 内容能呈现在人们的身后和前面,使 AR 体验更加沉浸化。同时,在大多数环境能实现绿幕风格的效果。

Motion Capture

用一台相机实时捕捉人们的运动。通过将人们身体的位置和运动,理解为一系列关节和骨骼,您可以使用运动和姿势作为 AR 体验的输入 —— 将人们放置在 AR 的中心。

Simultaneous Front and Back Camera

现在,你可以在前置和后置摄像头上同时使用人脸和世界跟踪。例如,用户可以只使用他们的脸部,与后置摄像头的 AR 内容交互。

Multiple Face Tracking

现在 ARKit 人脸跟踪可以一次跟踪最多三张人脸,使用 iPhone X, iPhone XS, iPhone XS Max, iPhone XR 和 iPad Pro 的 TrueDepth 摄像头。该摄像头为 Memoji 和 Snapchat 等前置摄像头的体验提供能力。

Collaborative Sessions

通过多人之间的实时协作会话,您可以构建一个协作的 world map,让您更快地开发 AR 体验,让用户像进入多人游戏一样,更快地进入共享的 AR 体验。

其他改进

一次检测多达 100 幅图像,并自动地估计图像的物理大小。3D 对象检测更加稳定,因为对象在复杂环境中可以更好地被识别。现在,机器学习被用来更快地检测环境中的平面。

Reality Composer

Reality Composer 是一款针对 iOS 和 Mac 的功能强大的新应用,可以轻松创建交互式 AR 体验,而无需 3D 经验。可以使用 live linking ,在您的 Mac、iPhone 和 iPad 之间无缝地移动。 RealityKit 是一个全新的高级框架,包含 photo-realistic 渲染、相机特效、动画、物理等功能,专为 AR 而构建。

现在,任何人都可以快速地原型化和制作 AR 体验的内容,这些内容可以使用 Xcode 集成到应用程序中或者导出到 AR Quick Look 。Reality Composer 让您在 iOS 和 Mac 上构建动画和交互,以丰富您的 3D 内容。

Built-in AR Library

导入您自己的 USDZ 文件,或者利用内置 AR 库中数百个现成的虚拟对象。该库为各种 assets 利用 procedural content generation ,您可以自定义虚拟对象的大小、样式等。

Animations and Audio

增加移动、缩放等动画,比如虚拟对象的 “摆动” 或 “旋转”。当用户点击对象、靠近对象或触发其他触发器,您可以选择要进行的操作。您还可以利用 spatial audio 为您的 AR 场景添加真实感。

Seamless Tools

Reality Composer 被包含在 Xcode ,也是一款 iOS APP 。因此您可以在 iPhone 或 iPad 上 build 、 test 、 tune 和模拟 AR 体验。利用 live linking ,您可以在 Mac 和 iOS 之间快速切换,创建吸引人的复杂的 AR 体验。

Record and Play

有了 iOS 版的 Reality Composer,您可以记录 AR 体验运行位置的传感器和摄像机数据,然后在构建 APP 时在 iOS 上播放这些数据。

RealityKit

这个全新的高级框架是从头开始创建的,专门用于 AR ,包括 photo-realistic 渲染、相机特效、动画、物理等等。它还有一个 Swift API 。使用集成的 ARKit 、基于物理的渲染、变换和骨骼动画、 spatial audio 和刚体物理, RealityKit 使 AR 开发比以往任何时候都更快、更容易。

World-class Rendering RealityKit

使用基于物理的真实材质、环境反射、 grounding shadows 、 camera noise 、 motion blur 等,将虚拟内容与现实世界无缝融合,使虚拟内容与现实世界几乎无法区分。

Scalable Performance

RealityKit 利用最新的 Metal 特性充分利用 GPU ,充分利用 CPU 缓存和多核,提供难以置信的流畅的视觉效果和物理模拟。因为它自动地伸展每个 iOS 设备的 AR 体验的性能,所以您只需要创建一个 AR 体验。

Swift API

容易使用但功能强大的 RealityKit 使用 Swift 丰富的语言特性并提供完整的功能。因此您可以更快地构建增强现实体验,而无需抄模板代码。

Shared AR Experiences

RealityKit 使网络开发工作变得简单,例如保持一致的状态、优化网络流量、处理数据包丢失或 performing ownership transfers 。


欢迎关注微信公众号「Swift 花园」

古典时期 (Classical Period)

最早寻求事物的自然或俗世解释而非神的解释的思想家,是古希腊学者泰勒斯 (Thales)、毕达哥拉斯 (Pythagoras) 和德谟克利特 (Democritus) 等人。但最先思考如何获得知识的 是两千三百多年前的柏拉图 (Plato) 和亚里士多德 (Aristotle) 。

对柏拉图来说,外部世界及其中的事物,只是它们理想形式的不完美投射或影子。这些理想形式往往被描绘成在墙上投下影子。

柏拉图是一个 哲学实在论者 (philosophical realist) 。他认为 现实 (reality) 即理想世界,是独立于人的思想之外存在的。对他来说,这些理想并非只是我们心中的抽象概念,它们真实存在,但独立于物质世界之外。

他认为既然我们看到的物质世界是真实的 不完美投射 (imperfect reflection) ,我们就不能从感官经历中认识到 现实的本质 (the true nature of reality) 。他坚信理想形式的知识只能通过推理论证获得。所以柏拉图被称为 理性主义者 (rationalist)

他的学生亚里士多德也是个实在论者。他认为现实独立存在于人类的思想之外。但对亚里士多德来说,现实就是物质世界,没有单独的抽象形象存在。在如何了解事物本质上,他也与柏拉图意见相左。亚里士多德是 经验主义者 (empiricist) 。他认为感官经验准确地代表了现实。所以我们可以用感官去理解现实。他认为,归根结底知识来源于观察。但这并不意味着他仅对观察感兴趣,他依然将推理论证视作了解和解释自然的最佳方式。事实上,他建立了 形式逻辑 (formal logic) ,更确切地说是 三段论 (syllogism)

这有一个三段论的例子。

“所有人都会死去,而所有希腊人都是人类,因此所有希腊人都会死。”

如果两个前提为真,那么结论必然为真。把此结论当作新三段论的前提,我们就能积累知识。但这只在前提确实为真的情况下成立。看看这个:

“所有哺乳动物都长毛,而猫都是哺乳动物,所以猫全都长毛。”

第一个前提是错的,这就意味着结论不一定对,不是积累知识的好基础。

那么如何确保前提是正确的呢?你可以用另一个三段论来证明它。但当然你需要不停地证明这些前提,有一套你认为毫无争议的初始前提。亚里士多德认为,这些 基础前提 (fundamental premises) ,可以通过观察世界的基本形式或规律来决定。不巧他并未意识到他的某些观察选择性太强,导致一些基础前提在我们现在看来错得彻彻底底。比如,以他的观察为依据,昆虫有四条腿,男人比女人的牙齿多。

亚里士多德可能看到蜉蝣用四条腿走路得出了这个结论,但实际上蜉蝣与其他昆虫一样都有六条腿。也有可能他检查了自己和那些男性朋友的牙齿,但只查看了女佣的牙齿,她们更有可能因为营养不良而牙齿变少。他并没有意识到这点,他的观察是不准确的。即便如此,他和柏拉图的观念仍在近两千年内占据了主流。直到 16 世纪末期,人们才意识到他们的观点是有缺陷的。

他们时代之后的科学方法是如何发展的呢?古希腊人有很多科技进步。比如,托勒密 (Ptolemy) 将行星运动描述为:地球位于宇宙的中心,静止不动;其他行星,包括太阳都沿着各自的小轨道绕地球运动。必须要加上这些大环套小环,才能解释行星有时逆行的奇怪现象。

托勒密的模型能进行准确的预测,但人们并不认为它描述了真实的行星运动,它只能解释现象。

希腊城邦消亡后,在罗马帝国的兴衰更替间,以及中世纪的最初几百年,几乎没有任何科学进展。柏拉图和之后亚里士多德的哲学观点一直是主流,直到 16 世纪末新的科学革命拉开了启蒙时代的序幕。

让我们来看看哪些进步最终引导了革命。

首先,阿拉伯和波斯学者比如伊本・哈桑 (Ibn al-Hasan) ,比鲁尼 (Al Biruni) 和伊本西纳 (Ibn Sina) 开始使用系统观察和实验。强调无偏见的观察,而不只是逻辑推理。之后,在前人的基础上,英国的格罗斯泰斯特 (Grosseteste) 和罗杰・培根 (Roger Bacon) 提倡使用归纳 (induction) 与演绎 (deduction) 。

归纳 (Induction) ,即通过特定观察得到一般解释。 演绎 (Deduction) ,即用一般解释预测特定结果。

第三个重大发展是印刷机的发明,这为科学革命创造了极好的条件。更多学者的成果得到了广泛的传播,其中就包括哥白尼 (Copernicus) 的《天体运行论》(De Revolutionibus Orbium Coelestium) ,这是引领科学革命的第四个重大发展。在哥白尼的新行星运动模型中,行星包括地球都围绕太阳运动。这与当时的宗教教义不符,教会接受亚里士多德和托勒密的模型,即地球位于宇宙中心。很多历史学家认为,哥白尼不敢发表他的成果,因为他害怕教会会因为违背教义惩罚他。但他最终还是发表了他的新模型,但加上了给教皇的特别献词,声称如果托勒密能炮制一个轨道怪异且只能解释现象的模型,那么他也应该享有同等的自由。他暗示他的模型仅仅是个实用的模型,而不是确切的表述。

他是否真心相信这一点也不得而知,不久他就与世长辞了。在这之后 60 年都没有引发骚动。很多人认为,科学革命和启蒙时代由哥白尼而始,但其他人认为荣誉应该归于第一个拒绝向天主教低头,坚持认为日心说模型才是现实的真实描述的人。

这个人就是伽利略・伽利雷 (Galileo Galilei) 。


启发 (Enlightenment)

伽利略 (Galileo) 被认为是现代科学之父,因为他开始将科学从哲学、伦理学和神学中分离,这些原来都在天主教的严格管控之下。

有人已经默默拥护基于 观察和实验 (observation and experimentation) 的科学方法,而不是使用 神学推理 (theological reasoning),但伽利略是第一个这样光明正大做的人
。他也反对亚里士多德的几个理论,这些理论被天主教视为教义。

例如,他驳斥了亚里士多德说重物落地比轻物更快的观点。伽利略用了思想实验来证明这个观点,表明除了观察,他也重视 逻辑推理 (logical reasoning) 。当然,他最出名的是质疑亚里士多德和托勒密有关地球是宇宙中心的观点。他支持哥白尼的日心,即太阳是宇宙中心。伽利略对金星进行了系统的观察,发现行星只有绕太阳转而不是绕地球转才说得通。

哥白尼认为,日心说模型恰恰解释了这个现象,说明该模型准确预测了我们对行星的观察,但他却说这模型并非反映物理现实。相反,伽利略却毫无顾忌地声称地球就是绕着太阳转的。

天主教不喜欢伽利略离经叛道的想法。他们对伽利略进行宗教审判,把他软禁起来直到去世。

发明笛卡尔坐标系的 勒内・笛卡尔 (René Descartes) 和伽利略是同一时代的。虽然笛卡尔也反对亚里士多德许多的观点,但他同意知识应当基于 第一原理 (First principle) 。因为他认为我们的感官和思想很容易被欺骗,他决定放弃所有哪怕只有一点点疑问的想法。一旦移除了所有的怀疑,就只剩下一点可以确定 —— 即他在思考,那么他一定存在。我思故我在。

他最终得出结论:我们只能用 推理 (reasoning) 来了解世界本质。

弗朗西斯・培根 (René Descartes) 和笛卡尔一样,认为科学知识应当基于第一原理。但和笛卡尔相反,培根坚持认为这只能通过 归纳法 (inductive methods) 。归纳法就是把对特定实例的观察,用于总结普遍规律或解释。假设我每次碰到的天鹅都是白色的,我就能归纳出普遍规律:所有天鹅都是白色的。培根相信,不仅仅是第一原理,所有的知识都只能用归纳法获取,也就是这种基于感官经验总结出解释的方法。这就是为什么他被视作 经验主义 (empiricism) 之父。经验主义就是关于经验或观察。

大卫・休谟 (René Descartes) 把经验主义发挥到了极致 —— 只接受感官数据为知识来源,且摈弃与直接观察结果不符的理论概念。他认为现实的本质只由物体的特征组成,而不是物体本身。经验主义的极端形式叫做 怀疑主义 (skepticism) 。我来给你举个例子,比如有个实物 —— 一只猫。什么使得这只猫能成为猫 是它的各种属性 它的尾巴、胡须、颜色、皮毛、体型。如果把组成猫的属性都移走,那剩下的是啥都没了,猫的本质埋藏在其特征中。

休谟也指出了归纳法的问题:即使持续反复观察一个现象,但也没法保证下一次观察到的和前一次相同。

至少在欧洲人的长期观念中,所有的天鹅目击记录都证明天鹅是白色的。直到在澳州发现黑天鹅后,才知道原来还有黑的。换句话说,即使再多的验证观察,也不能确实证明关于世界的科学命题是真的。所以如果你要求所有的知识都只能基于观察,那么你永远不能确定你知道些什么。

19 世纪初,部分是针对休谟的怀疑主义, 德国唯心主义 (German Idealism) 的哲学运动流行起来。唯心主义者相信我们的精神构筑了现实,我们对世界的体验是精神的重构,因此科学探索应当专注于通过自身推理能获得什么。唯心主义者主要关注的问题是非物质的东西,像自我、上帝、本质、存在和因果,他们也因使用模糊和过度复杂的语言而受到强烈批评。

在十九世纪第二次工业革命前夕,科学家开始对唯心主义者的形而上学失去耐心。在科学、医药和技术飞速发展的时代,他们对存在本质的思考变得越来越没用。在 20 世纪初,一种新的科学哲学登上舞台,提议彻底返回经验主义,这项运动叫做 逻辑实证主义 (logical positivism)


现代科学 (Modern Science)

第一次世界大战过后,由于不满于德国那群专注于知识的第一原理和世界的本质的唯心主义者的 形而上学 (metaphysics) ,一群数学家、科学家和哲学家组成了维也纳学派 (Vienna Circle)。

维也纳学派的成员,摩里茲・石里克 (Moritz Schlick)、奥托・诺伊拉特 (Otto Neurath) 和鲁道夫・卡纳普 (Rudolf Carnap) 认为唯心主义者关于自我存在的问题毫无意义,因为这些问题无法回答。他们提出了新的科学哲学思想 —— 逻辑实证主义 (Logical Positivism) 。它重新将科学定义成是研究对世界有意义的命题。那么,要使一个命题有意义,它必须可验证,也就是有验证标准。这意味着要能确定命题的真实性。

有意义的命题有两种: 分析命题 (analytic statements)综合命题 (synthetic statements)

分析命题会 同义反复 (tautological) ,一定是真的。比如说,单身汉未婚,所有正方形都有四条边。这些是 先验命题 (priori statements) ,如定义和纯逻辑命题。它们不取决于世界的状态,因此也不需要通过观察来验证。它们可以被运用在数学和逻辑中,分析命题的新组合可以用形式逻辑验证。

综合命题依存于世界的状态。比如说,所有单身汉都快乐和所有猫天生都有尾巴。这些命题是 后验的 (posteriori) —— 它们只能通过观察验证。逻辑实证主义者认为,这些命题应始终可公开研究。同时,命题不许提及无法观察的实体,如电子或重力,因为它们无法被直接观察到。如果命题提及了无法观察的实体 或不是同义反复的,或不合逻辑的,或经验上不可验证的,那么它就是无意义的。这样一来,形而上学、神学和伦理学之类的主题,就完美地从科学中被剔除了。

当然,源自观察的标准和验证无法处理归纳法问题。明确证明或验证一个命题的确切证据永远都不够。总有可能在未来发现矛盾的地方。所以就把验证标准的强度弱化了,只要求确认而不是验证就可以了。

另一个非常严格的规则也必须改变了 —— 不许提及无法观察的实体造成了大麻烦:像电子、重力和抑郁这种无法被直接观察,但它们在科学解释中是不可或缺的。

这些以及归纳法问题,使逻辑实证主义出现了一个更温和版本 —— 逻辑经验主义 (logical empiricism)

卡尔・波普尔 (Karl Popper) 也被戏称为维也纳学派的官方反对者,是他们的主要批判者之一。他认为要区分命题是否有意义,应建立在 可证伪性 (falsification) 的标准上,而不是能否 证实 (verification)

他认为,我们永远不能用观察来确切验证或证明一个命题,但我们可以用与之矛盾的证据有力驳斥。他认为一个命题必须有可证伪性才有意义。他提出科学家应该积极进行冒险的实验,它们能把找到与假说矛盾的证据的可能性最大化。如果我们找到了这样的反面证据,就能从中找到改进假说的线索。 只有反面证据缺失时 该假说才能暂时成立。

现在,威拉德・冯・奥曼・奎因 (Willard Van Orman Quine) 证明,这个标准也有问题。在杜恒 - 奎因论题 (Duhem-Quine thesis) 中,他认为没有假说能够被单独验证,总有 背景假设 (background assumptions)辅助假设 (supporting hypotheses) 来支持。如果找到了反面证据,根据波普尔的理论,我们的科学解释是错的,应该被驳回。但根据奎因的理论,我们总可以驳回某个背景假设或辅助假说,而不是科学解释本身。这样就挽救了原始假说。

托马斯・库恩 (Thomas Kuhn) 指出,科学并非脱胎于验证或证伪原则的严格应用。如果数据与假设不符,假设不会被立刻驳回或修改,科学是在一定的框架或 范式 (paradigm) 内进行。建立的假说要适用这个范式,意外结果使假说需要修正。但只要其适合范式即可。但当越来也多的反面证据累积起来,危机就出现了 这就导致了 范式转换 (paradigm shift) 。新的范式被采用,然后循而往复。

即使变成较弱形式的逻辑经验主义,逻辑实证主义也不能从波普尔、奎因等人的批判中幸存。所以,我们发展出一种更实用的科学哲学。如今的科学家采用 假说 - 演绎法 (hypothetico-deductive method),其结合了归纳和演绎的方法;要求可证伪性;仅在假说有临时支持时接受重复确认。

哲学层面上,很多科学家可能会喜欢巴斯・范・弗拉森的 建构经验主义 (constructive empiricism) ,其主张科学是为了产生基于大量经验的理论。知识需要观察,但不可观察的实体也是允许的。接受一个科学理论并不意味永远认同 —— 这是对世界的真实表述。建构经验主义者认为,只要在观察范围内,即可接受科学主张为真实。该命题是否真实代表了不可观察的实体,我们无需判断,这只是目前对观察结果的最佳解释,仅此而已。