封面来自 Pauline Loroy on Unsplash
译自 https://www.hackingwithswift.com/articles/249/whats-new-in-swift-5-7
Swift 5.7 变化巨大,新特性中包括正则表达式, if let
速记语法,以及围绕 any
和 some
关键字的一致性改动。
在本文中,我会通过一些示例来介绍这些新特性。
if let
速记SE-0345 引入了新的速记语法,可以将可选型展开为 ** 同名 ** 的阴影变量。以后我们可以像下面这样解包了:
1 | var name: String? = "Linda" |
对比之前的写法:
1 | if let name = name { |
注意:这个变化并不适用于对象内的属性,所以像下面这样的代码无法通过编译:
1 | struct User { |
SE-0326 极大地提高了 Swift 对闭包使用参数和类型推断的能力,这意味着我们现在可以删除许多必须明确指定输入和输出类型的写法。
之前 Swift 处理闭包的书写难免琐碎,但从 Swift 5.7 开始,我们可以编写如下简化的代码:
1 | let scores = [100, 80, 85] |
在 Swift 5.7 之前则必须像下面这样书写:
1 | let oldResults = scores.map { score -> String in |
SE-0329 为 Swift 引入了一种新的标准化方式来引用时间和持续时间。它可以拆解为三个主要部分:
Clock
代表了一种测量时间流逝的方式。有两个内置时钟:连续时钟在系统处于睡眠状态时也会保持时间递增,而挂起时钟则不会。Instant
代表一个精确的瞬间。Instant
之间经过了多少时间。这个新特性对许多人来说来联想到的最直接的应用就是新升级的 Task
API:它现在可以用比纳秒更合理的术语来指定休眠时长:
1 | try await Task.sleep (until: .now + .seconds (1), clock: .continuous) |
这个新 API 还有一个好处是能够指定容差,使得系统在睡眠截止日期之后能够稍等片刻,以便最大限度地提高电源效率。所以,假如我们想 sleep 至少 1 秒,并且能接受它总共持续 1.5 秒,我们可以这样写:
1 | try await Task.sleep (until: .now + .seconds (1), tolerance: .seconds (0.5), clock: .continuous) |
时钟对于测量某些特定的工作也很有用。比如,我们想向用户展示文件导出过程花费了多长时间,可以使用时钟的
measure
闭包:
1 | let clock = ContinuousClock() |
Swift 5.7 引入了大量与正则表达式相关的改进,这是一整套相互关联的提案,包括:
Regex
类型/.../
而不单是 Regex
来共同创建正则表达式的方式。与其他语言和平台相比,正则表达式一直是 Swift 语言一个相当大的痛点。
现在,让我们从简单的例子开始:
1 | let message = "the cat sat on the mat" |
它们的真正威力在于也都接受正则表达式:
1 | print(message.ranges (of: /[a-z] at/)) |
如果您不熟悉正则表达式,下面是几条快速入门:
注意这些正则表达式是如何使用正则表达式字面量来生成的 —— 以 /
开始和结束。
除了正则表达式字面量,Swift 还提供了专门的 Regex
类型:
1 | do { |
这里两种方式有一个关键区别:当我们使用 Regex
从字符串创建正则表达式时,Swift 必须在运行时解析字符串以找出它应该使用的实际表达式。相比之下,使用正则表达式字面量允许 Swift 在编译时 检查你的正则表达式:它可以验证正则表达式不包含错误,并且还可以准确了解它将包含什么匹配项。
在编译时解析你的正则表达式,确保它们是有效的 —— 牛 🍺!
想知道这个差异有多强大,咱们来看下面的代码:
1 | let search1 = /My name is (.+?) and I'm (\d+) years old./ |
这会创建一个正则表达式来查找某些文本中的两个特定值,如果找到它们都会打印它们。但请注意 result
元组如何将其匹配项引用为 .1
和 .2
,因为 Swift 知道将发生哪些匹配项。 (.0
将返回整个匹配的字符串。)
事实上,正则表达式还允许我们命名匹配项,这些匹配项会流向生成的匹配元组:
1 | let search2 = /My name is (?<name>.+?) and I'm (?<age>\d+) years old./ |
这种安全性对于从字符串创建的正则表达式是不可能的。
但 Swift 更进一步,你还可以从类似于 SwiftUI 代码的 DSL 语言创建正则表达式。
例如,如果我们想匹配 “我的名字是 Taylor,我 26 岁” 的文本,我们可以写一个这样的正则表达式:
1 | let search3 = Regex { |
更棒的是,这种 DSL 方法能够对其找到的匹配项应用转换,如果我们使用 TryCapture
而不是 Capture
,在捕获失败或有错误抛出时,Swift 将自动认为整个正则表达式不匹配。因此,在我们的年龄匹配的例子中,我们可以编写以下代码来将年龄字符串转换为整数:
1 | let search4 = Regex { |
你甚至可以使用具有特定类型的变量将命名匹配组合在一起,如下所示:
1 | let nameRef = Reference(Substring.self) |
在这三个选项中,我怀疑正则表达式文字会得到最广泛的使用。尽管在 Swift 6 发布之前,默认情况下对它们的支持将被禁用。你可以把 “-Xfrontend -enable-bare-slash-regex” 添加到 Xcode 中的 Swift Flags 设置以启用这个语法特性。
SE-0347 扩展了 Swift 使用泛型参数类型的默认值的能力。这个特性似乎相当小众,但确实重要:如果你有一个泛型类型或函数,现在可以为默认表达式提供一个具体类型。
例如,我们可能有一个函数,它从任意类型的序列中返回 count
个随机项:
1 | func drawLotto1<T: Sequence>(from options: T, count: Int = 7) -> [T.Element] { |
这允许我们使用任何类型的序列来运行函数,例如字符串数组或者整数范围:
1 | print(drawLotto1 (from: 1...49)) |
SE-0347 允许我们为函数中的 T
参数提供一个具体类型作为默认值,同时允许我们保持使用字符串数组或任何其他序列类型的灵活性:
1 | func drawLotto2<T: Sequence>(from options: T = 1...49, count: Int = 7) -> [T.Element] { |
这样一来我们既可以使用自定义序列调用函数,也可以让默认值接管:
1 | print(drawLotto2 (from: ["Jenny", "Trixie", "Cynthia"], count: 2)) |
SE-0343 升级了 Swift 对顶级代码的支持 —— 想想 macOS 命令行工具项目中的 main.swift —— 以便它支持开箱即用的并发。这个变化看起来微不足道,但为了支持它需要相当多的工作。
实践上,这个变化意味着我们可以将这样的代码直接写入 main.swift 文件:
1 | let url = URL(string: "https://hws.dev/readings.json")! |
在这个变化以前,我们必须创建一个具有异步 main ()
方法的 @main
结构。因此说这个变化是一个不小的改进。
SE-0341 解锁了在使用更简单泛型的地方对参数声明使用 some
的能力。
举个例子,如果我们想编写一个检查数组是否排序的函数,Swift 5.7 及更高版本允许我们这样写:
1 | func isSorted(array: [some Comparable]) -> Bool { |
[some Comparable]
参数类型意味着此函数适用于包含某种类型的元素的数组,该类型遵循 Comparable
协议,这是等效通用代码的语法糖:
1 | func isSortedOld<T: Comparable>(array: [T]) -> Bool { |
当然,我们也可以写更长的约束扩展:
1 | extension Array where Element: Comparable { |
这种简化的泛型语法确实意味着我们不再有能力为我们的类型添加更复杂的约束,因为合成的泛型参数没有特定的名称。
** 重要提示:** 你可以在显式泛型参数和这种新的更简单语法之间切换,而不会破坏 API。
SE-0328 拓宽了不透明结果类型可以使用的范围。
例如,我们现在可以一次返回多个不透明类型:
1 | func showUserDetails() -> (some Equatable, some Equatable) { |
我们还可以返回不透明类型数组:
1 | func createUser() -> [some View] { |
甚至返回一个在调用时本身返回不透明类型的函数:
1 | func createDiceRoll() -> () -> some View { |
因此,这是 Swift 进化过程中保持一致性的另一个很好的例子。
]]>译自 https://www.hackingwithswift.com/articles/224/common-swiftui-mistakes-and-how-to-fix-them
欢迎关注微信公众号「Swift 花园」
SwiftUI 是一个庞大且复杂的框架。使用这个框架编程无疑是享受的,但犯错的机会也不少见。这篇文章我将带大家速览 SwiftUI 初学者常犯的一些错误,并提供修正方案。
其中的一些错误是由于简单的误解导致。由于 SwiftUI 太大,这种情况其实容易出现。而另一些错误则与深入理解 SwiftUI 的工作方式有关,还有一些是原有的思维方式导致 —— 你可能花了很多时间编写 view 和 modifier,但没有想到用 SwiftUI 的方式简化结果。
开门见山,你不需要猜我会给你准备什么菜,这里我直接把八条误用先简明扼要地罗列如下,然后我们逐条深入展开:
添加不必要的 View 和 Modifier
在需要用 @StateObject
的地方用了 @ObservedObject
Modifier 顺序错误
给属性包装器添加属性观察者
在需要用描边框的地方使用了描形状
Alert 和 Sheet 与可选状态的使用
尝试改变 SwiftUI 视图后面的东西
用错误的范围动态创建视图
让我们从最常见的一个误用开始,它会让我们编写更多的 SwiftUI 代码。这种误用的部分原因通常是我们在解决问题时编写了许多代码,但是最后忘记整理代码。还有的时候,则是旧习惯作祟,尤其是当编写者是从 UIKit 或者其他 UI 框架转到 SwiftUI 上。
比如,你可能希望用一个红色矩形填满屏幕?然后你像下面这样编写代码:
1 | Rectangle() |
的确,上面的代码可以工作 —— 它能准确地得到你想要的效果。但是其中一半的代码是不必要的,因为你只需要像下面这样写也能实现一样的效果:
1 | Color.red |
这是因为在 SwiftUI 中,所有的颜色和形状都自动遵循了 View 协议,你可以把它们直接当成视图来使用。
你可能也会经常看见形状裁切,因为为了实现特定形状,应用 clipShape ()
是件很自然的事情。例如,可以像下面这样让我们的红色矩形拥有圆角:
1 | Color.red |
但这也是不要的 —— 借助 cornerRadius ()
modifier,代码可以简化如下:
1 | Color.red |
移除这类的冗余代码需要时间,因为你需要转变思维习惯,这一点对于 SwiftUI 的初学者来说更加困难。 因此,假如你一开始采用了这些更长版本的代码,不必担忧,多加训练。
@StateObject
的地方用了 @ObservedObject
SwiftUI 提供了众多属性包装器,帮助我们构建数据响应式的用户界面,其中最重要的当属 @State
, @StateObject
和 @ObservedObject
。掌握它们的使用场景非常重要,因为误用它们会给你的代码带来各种问题。
第一个比较直接:@State
用于值类型属性,并且属性由当前视图拥有。因此,整数,字符串,数组等,都是应用 @State
的绝佳场景。
后两者则有点令人困惑,你可能会经常看到下面这样的代码:
1 | class DataModel: ObservableObject { |
可以明确的说,这么做是错误的,并且极有可能在你的应用中带来问题。
译者注:基于代码片段说这样写一定是错误的,这个表述是不严谨的。作者应该是隐含假设了
ContentView
是应用的顶级视图(通常来说,如果你不改工程模板的默认输出,ContentView
也确实是顶级视图)。对于顶级视图来说,SwiftUI 2.0 应当使用@StateObject
,它是为了解决@ObservedObject
或者@EnvironmentObject
对象的所有权问题。但是对于附属于顶级视图的视图层级,各子视图的数据源可以是@ObservedObject
或者@EnvironmentObject
,因为它们的生命周期受顶级视图管理,进而可以由顶级视图统一保证数据的可用性。
正如我前面说到的,@State
表示某个值类型属性由当前视图拥有,这里的 “拥有” 很重要。而 @StateObject
则相当于引用类型版本的 @State
。
因此,上面的代码应该改成这样:
1 | @StateObject model = DataModel() |
当你使用 @ObservedObject
来创建某个对象实例时,你的视图并不拥有这个对象实例,也就是说,这个实例可以在任何时候被销毁(译者注:视图无法了解也无法干预这个时机)。狡猾的是,对象在视图还需要用它时被销毁的情况只是偶尔发生,所以你可能认为你的代码很完美。
需要记住的重点是 @State
和 @StateObject
表示 “视图拥有数据”,而 @ObservedObject
和 @EnvironmentObject
则没有。
Modifier 的顺序在 SwiftUI 中至关重要。顺序错误不仅会导致布局在上视觉上的偏差,也会导致其行为的错误。
解释这个问题最经典的例子是 padding
和 background
的使用,如下:
1 | Text("Hello, World!") |
由于我们在 background
颜色之后应用 padding
,颜色只会被直接应用在文本周围,而不是被添加留白之后的文本周围。如果你希望留白和文本背景都是绿色,应该将代码改成下面这样:
1 | Text("Hello, World!") |
当你尝试调整视图位置时,这个原理会让事情变得更有趣。
例如,offset ()
modifier 会修改一个视图被渲染的位置,但并不实际改变视图的位置。也就是说,应用在 offset
之后的 modifier 表现得就像 offset
从未发生过。
尝试下面的代码:
1 | Text("Hello, World!") |
你会发现文本偏移了,但背景颜色没有偏移。现在,尝试交换 offset ()
和 background ()
的位置:
1 | Text("Hello, World!") |
现在你会看到文本和背景都移动了。
另外,position ()
modifier 会改变一个视图在其父节点中的渲染位置,但这一点是借助它先在视图周围应用一个可伸展尺寸的 frame 来实现的。
尝试下面的代码:
1 | Text("Hello, World!") |
你会发现背景颜色紧贴在文本四周,并且整个视图被放置在左上角。现在,尝试对调 background ()
和 position ()
:
1 | Text("Hello, World!") |
这一回你会发现整个屏幕都变成绿色了。还是因为 position ()
要求 SwiftUI 放置一个可伸缩尺寸的 frame 在文本视图周围,这导致视图自动占满了所有的可用空间。然后我们给视图上了绿色,所以整个屏幕呈现绿色。
你所应有的绝大多数 modifier 都创建了新视图 —— 应用一个 position
或者 background
时,你实际上是在将现有的视图包装起来。这个机制对于我们大有用处,我们可以多次应用 modifier,比如添加多层留白和背景:
1 | Text("Hello, World!") |
或者应用多个 shadows 以创建很深的阴影效果:
1 | Text("Hello, World!") |
某些情况下你可能会为属性包装器添加诸如 didSet
这样的属性观察者,但它不会如你预期的那样工作。
例如,如果你在使用滑块,希望在滑块值改变时执行某种动作,你可能会下面这样编写代码:
1 | struct ContentView: View { |
但是,这个 didSet
属性观察者永远都不会被调用,因为属性的值是由绑定直接修改的,而不是每次创建一个新值。
对此,SwiftUI 原生的方式是使用 onChange ()
modifier,如下:
1 | struct ContentView: View { |
不过,我个人更喜欢一种不同的方案:我使用基于 Binding 的扩展来返回新的绑定,其中的 get
和 set
包装的值和之前一样,但是在新值得到时也会调用处理函数:
1 | extension Binding { |
有了这个扩展,我们就可以把绑定的动作直接附着在滑块视图上:
1 | struct ContentView: View { |
挑选最适合你的方案。
不理解 stroke ()
和 strokeBorder
的区别是初学者常犯的错误。尝试下面的代码:
1 | Circle() |
注意看,你会发现圆的左边缘和右边缘怎么不见了?(译者:这里预设你是竖屏运行程序,高度大于宽高)这是因为 stroke ()
modifier 会把描边居中对齐在形状的轮廓线上,所以一个 20 个点的红色描边会绘制 10 个点到形状的边缘线外部,10 个点在边缘线内部 —— 这就导致了你看到圆形的左右超出屏幕的现象。
作为对照,strokeBorder ()
则是把整个描边都绘制在形状内部,所以它不会放大形状的边框。
1 | Circle() |
相比于使用 strokeBorder ()
,使用 stroke ()
有一个好处是它返回的是一个新形状,而不是一个新视图。这使得你可以创建出某些本来难以实现的效果,比如给一个形状描两次边:
1 | Circle() |
当你在学习使用 sheet 和可选型的时候,很容易想到把 sheet 的展示绑定到像下面这样的 Boolean:
1 | struct User: Identifiable { |
当然,这可以正确工作 —— 而且这个方案容易理解。但是一旦你越过了初级阶段,你就应当考虑换成可选型的实现方案。这个方案去掉了 Boolean,也不必强制解包。唯一的要求是你所监听的目标需要遵循 Identifiable
。
举个例子,我们可以在 selectedUser
发生变化的任何时候展示警告弹窗,就像下面这样:
1 | struct ContentView: View { |
这会使得你的代码更加易于读写,并且避免因为强制解包可能带来的麻烦。
SwiftUI 初学者最常犯的一个错误是他们常常试图去改变 SwiftUI 视图的背景。代码通常长下面这个样子:
1 | struct ContentView: View { |
这会展示一个白色的屏幕,中间是红色背景色只匹配文本区域的文本视图。而大多数人的本意其实是想让整个屏幕的背景呈现出红色。这个时候他们会想,SwiftUI 背后究竟是什么样的 UIKit 视图呢?
当然,背后肯定是有一个 UIKit 视图,它由 UIHostingController
管理,角色类似于一个 UIKit 视图控制器。但是假如你通过 SwiftUI 试图去踏足 UIKit 的领地,你的改动很可能会让 SwiftUI 呈现出奇怪的结果,或者你甚至都没法直接改动 UIKit。
实际上,想到达成大多数人想要的效果,SwiftUI 里的实现方式应该是像下面这样的:
1 | Text("Hello, World!") |
有多个 SwiftUI 视图的构造器允许我们传入范围,这个事实让许多复杂视图的创建过程变得十分简单。
例如,假设我们想要展示一个拥有 4 个项目的列表,我们只需要这样写:
1 | struct ContentView: View { |
这样写本身没问题,不过一旦你需要在运行时改变范围时,问题就来了。你看我已经用 @State
属性包装器把想要改变的行数变成可修改的,所以我们可以用一个按钮来修改它的值:
1 | Button("Add Row") { |
运行代码,点击按钮,Xcode 调试输出会输出警告,而列表视图纹丝不动 —— 这个方案不管用。
问题出在你既没有为列表的参数提供 Identifiable
协议实现,也没有提供指定的 id 参数,以此告诉 SwiftUI 这个范围会动态变化:(译者:实际上并不是 “告诉 SwiftUI 范围会动态变化”,而是明确范围的项怎样才算变化。Identifiable
或者 id 参数明确了两个项之间是如何区别。能够区别开的项目才能侦测变化)。
1 | List(0..<rowCount, id: \.self) { row in |
代码改成这样就没问题了。
封面来自 Zdeněk Macháček on Unsplash
]]>欢迎关注微信公众号「Swift 花园」
译自 https://swiftui-lab.com/nsuseractivity-with-swiftui/
译者注:作者为了扩展他的 SwiftUI 应用去研究了 NSUserActivity,发现关于 NSUserActivity 的大量信息都已经过时了。比如,绝大多数关于 Handoff 的文章都是 Handoff 特性刚有的时候发布的,而那个时候还没有 scene 的概念,所有逻辑都是通过 application 的 delegate 处理的。之后 scene 出现了,许多代码被移到了 scene delegate,原来的 Handoff 示例也就失效了。如果你是刚开始学习 NSUserActivity,一定会感到困惑,而现在 SwiftUI 也支持用户活动,但现在又没有 scene 了,变化更大,所以作者认为需要写一篇新的文档来介绍 SwiftUI 中 NSUserActivity 的使用。
NSUserActivity 令人费解的另一个原因是它是一个可以用来处理多个不相干功能的东西。它的各项属性只在某些情况下相关,多数情况下却是没有关联的。
下面是有关 NSUserActivity 的一些总结:
这篇文档会提供一系列示例,逐步介绍 SwiftUI 中提供的用于处理 NSUserActivity 的方法,其中上面提到的每一种情况都会有示例。
SwiftUI 中跟 NSUserActivity 有关的方法包括:onOpenURL (), userActivity (), onContinueUserActivity () 和 handlesExternalEvents ()。注意,这些方法只有当你的应用采用的是 SwiftUI 应用生命周期时才能工作。如果你的项目还是使用 scene delegate,引入这几个方法会在控制台输出下面的消息:
1 | Cannot use Scene methods for URL, NSUserActivity, and other External Events |
个人经验,上面的消息中提到的不可预测的结果,实际上完全可以预测:所有这些方法都将被忽略。
根据 Apple 的 官方文档,一个用户活动(user activity)对象代表了某个应用在某个时刻的状态:
An NSUserActivity object provides a lightweight way to capture the state of your app and put it to use later. You create user activity objects and use them to capture information about what the user was doing, such as viewing app content, editing a document, viewing a web page, or watching a video. When the system launches your app and an activity object is available, your app can use the information in that object to restore itself to an appropriate state.
某个 NSUserActivity 对象提供一种捕捉你的应用状态的轻量级方式。你创建 user activity 对象,并用它们来捕获用户正在做的事情的信息,比如查看应用内容,编辑文档,阅览网页,或者观看视频。当系统启动你的应用时,如果活动对象可用,你的应用可以利用对象中的信息把应用还原成合适的状态。
理解了概念,我们就可以区分用户活动中的两个关键时刻:其一,用户活动创建(稍后说明何时、如何创建);其二,系统决定启动或者恢复某个应用,并且为应用提供一个 NSUserActivity,以便应用展示相关的 UI。我们接下来会学习如何在应用中对用户活动做出反应。
注意,尽管一个应用可以有多个 scene,但某个时刻只有一个 scene 会获得用户活动。在本文中我们还将了解到获取用户活动的 scene 是如何被确定的…
Universal Links 对于把应用集成到网站十分有用。建立 Universal Links 需要几个步骤,Apple 为其提供了详细的文档:Universal Links。
在 SwiftUI 中使用 NSUserActivity 的所有用法中,Universal Links 是最容易实现的。尽管 Universal Links 本质上是使用 NSUserActivity 来启动或者恢复你的应用,但假如你的应用是走 SwiftUI 应用生命周期,你却根本看不到 NSUserActivity 的踪影!
在 UIKit 里实现 Universal Links ,一般是在 scene delegate 里这么做:
1 | func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { |
但现在没有了 scene delegate,我们只需要简单地使用 onOpenURL
方法,它会得到 URL
对象,而不是 NSUserActivity 对象:
1 | struct ContentView: View { |
我们可以给一个应用中的特定部分定义快捷指令。在 iOS 中,这个动作可以借助 “快捷指令” 应用来实现,但我们也可以在应用内通过代码实现。UIKit 有一些专门的 UI 元素来处理这件事,但 SwiftUI 中并没有直接可用的方法,所以这一节中的例子会包含一个 UIViewControllerRepresentable,它的作用是提供一个按钮,点击这个按钮可以打开系统的模态表单,让用户创建或者编辑快捷指令。
一旦快捷指令创建,我们就可以调用 Siri 指令来执行它。它会启动或者恢复我们的应用,并且通过 NSUserActivity 提供用户希望我们执行的快捷指令的细节信息。SwiftUI 对此为我们提供了一个 onContinueUserActivity ()
在下面的例子中,通过指令 “Hey Siri, show random animal” (或者某些其它的预置指令),系统会启动我们的应用并导航到某个随机的动物视图。
1 | import SwiftUI |
下面是用于创建快捷指令和编辑模态表单的 UIViewControllerRepresentable:
1 | import SwiftUI |
Spotlight 的搜索结果可以包含应用中的通用活动。为了让 Spotlight 学会你的活动,你需要在这些活动出现时对外发布,以便 Spotlight 发现它们。要在 SwiftUI 中发布 NSUserActivities,我们需要使用 userActivity () modifier。
在下面的例子中,我们有一个售卖冰淇淋的应用。每当我们选择了某个冰淇淋尺寸,应用将对外发布冰淇淋尺寸;每当有用户搜索冰淇淋时,我们的应用将出现在搜索结果中。如果用户选择了我们的应用对于的搜索结果项,我们的应用将被调起,并且将用户带到最后公布的冰淇淋尺寸。
注意,系统会优化 userActivity () 闭包的调用时机。然而不幸的是,这一点并没有文档说明。系统很聪明,知道该如何保存当前的信息,避免不停更新。在调试的时候,建议你最好在 userActivity 闭包中加入打印语句。
下面的例子中还包含了一个 “Forget” 按钮,这对于调试十分有帮助。它会清除掉已经发布的用户活动,以便将应用从 Spotlight 的搜索结果中移除。注意,NSUserActivity 有一个可选属性:expirationDate,如果将其置为 nil
,则活动永远不会过期。
1 | import SwiftUI |
基于已经介绍的方法,我们可以创建一个接力应用了。这将是一个可以在另外的设备上接力工作的应用。两个设备上的应用可以是同一个应用或者不同的应用。这种情况通常发生在我们分发了应用的两个不同版本的时候:比如一个是 iOS 版本,另一个是 macOS 版本。
为了让接力能够工作,相关的应用都需要注册在同一个开发者团队的标识下面,并且在所有参与的应用的 Info.plist 中配置 NSUserActivityTypes 实体。
有关于接力的更多实现细节,可以参考 Apple 的 网站。
下面的示例实现了一个简单的 web 浏览器,通过调用 userActivity () 发布用户正在浏览的页面以及他在页面上滚动的位置。
假如用户切换到了另一个设备,当同一个应用被调起或者恢复时,onContinueUserActivity () 闭包将被调用,应用可以借此打开同一个页面,并且滚动到他在之前设备上浏览到的页面位置。
用户活动可以通过 userInfo 字典的形式提供负载数据,它是我们存放特定于接力活动信息的地方。在这个示例中,即页面滚动位置(百分比)和所打开页面的 URL。此外,还包含了发布此活动的应用的 bundle id,它只是用来调试的信息,便于我们准确地知道发生的事情。
注意,示例的代码在 iOS 和 macOS 上都能工作,这是为了让你能够同时创建两个应用,并且测试 iOS 设备和 Mac 设备之间的接力。
最后,虽然跟 NSUserActivity 无关,这个示例还封装了一个 WKWebView,这是演示 javascript 事件(这个示例中的 onScroll
)如何更新你的 SwiftUI 视图绑定的绝佳示例。完整的 WebView 代码可以从下面的 gist 文件找到:WebView.swift
1 | import SwiftUI |
当系统启动或者恢复我们的应用的时候,它必须确定哪个 scene 能够接受到用户活动(某一个时刻只有一个能接收到)。为了帮助它做出这个决策,我们的应用可以用上 handlesExternalEvents () 方法。不幸运是,写这篇文档的时候 (Xcode 12, beta 6),这个方法貌似不起作用,而且 macOS 上虽然有支持,但缺少平台定义文件。
所以我这里将通过注释来说明它的工作方式,等将来真正可用时,我会更新这篇文章。
这个方法有两个版本。一个用于 WindowGroup 场景:
1 | func handlesExternalEvents(matching conditions: Set<String>) -> some Scene |
另一个用于视图:
1 | func handlesExternalEvents(preferring: Set<String>, allowing: Set<String>) -> some View |
两种版本中我们都会指定一个字符串 Set
,系统用它来跟 NSUserActivity 的 targetContentIdentifier 属性做比对。假如找到匹配项,对应的 scene 会被选用。如果不指定这个 Set
,或者没有找到匹配项,则实际行为由具体平台决定。例如,在 iPadOS 上会选择一个已经现有的 scene,而在 macOS 上,新的 scene 会被创建。
在只支持一个 scene 的系统上,这个方法将被忽略。
注意,targetContentIdentifier 在 UNNotificationContent 和 UIApplicationShortcutItem 中也有提供,因此 handlesExternalEvents () 大概率也会支持它们。
Apple 关于 NSUserActivity 的文档资料非常多,所以我建议你去查阅这些文档。但目前 缺少 SwiftUI 的示例。这篇文章的目的就是为你提供一些在 SwiftUI 中实践 NSUserActivity 的启动代码。
欢迎关注微信公众号「Swift 花园」
Swift 5.3 有不少变化,这其中包括多模式 catch 语句,多拖尾闭包,以及 Swift Package Manager 的一些重要改变。
本文会带你浏览一些主要的变化,同时提供参考代码,以便你可以自行尝试。以下是要介绍的新特性的清单:
where
语句didSet
语义SE-0276 引入了一个可以在单个 catch
块中捕获多个错误 case 的特性,这能让我们免除错误处理时的重复代码。
例如,下面的代码用枚举定义了错误的两种情况:
1 | enum TemperatureError: Error { |
当我们读取到温度时,既可以抛出两种错误中的某一个,也可以返回 “OK”:
1 | func getReactorTemperature() -> Int { |
在捕获错误的环节,SE-0276 允许我们用逗号分隔来表示我们要以相同方式处理 tooHot
和 tooCold
。
1 | do { |
处理的 case 可以是任意数量的。
SE-0279 引入了多拖尾闭包,这使得调用包含多个闭包的函数可以更简单地实现。
这个特性在 SwiftUI 中非常受欢迎。原来形如下面这样的代码:
1 | struct OldContentView: View { |
可以被改写成:
1 | struct NewContentView: View { |
理论上并不要求 label:
要跟在前一个闭包的 }
后面,所以你甚至可以像下面这样书写:
1 | struct BadContentView: View { |
不过,我会建议你当心代码的可读性 —— 像上面的 label
那样的代码块,在 Swift 里看起来更像是标签化的代码块,而不是 Button
构造器的第二个参数。
注: 有关 Swift 的多拖尾闭包特性的讨论非常热烈。我想提醒大家的是,像这种类型的语法变动一开始看起来可能会有点别扭,我们需要耐心,给它时间,在实践中体会它带来的结果。
SE-0266 使得我们可以为枚举生成 Comparable
实现,同时不要求我们声明关联值,或者要求关联值本身必须是 Comparable
的。这个特性让我们可以在同类型的枚举之间用 <
,>
和类似的比较操作符来进行比较。
例如,假设我们有一个枚举,它描述了衣服的尺寸,我们可以要求 Swift 为它自动生成 Comparable
实现,代码如下:
1 | enum Size: Comparable { |
然后我们就可以创建两个这个枚举的实例,并且用 <
进行比较:
1 | let shirtSize = Size.small |
自动生成的实现,也能很好地适应枚举的 Comparable
关联值。例如,假设我们有一个枚举,描述了某个队伍获取世界杯冠军的次数,代码可以这样实现:
1 | enum WorldCupResult: Comparable { |
然后我们用不同的值来创建枚举的不同实例,并且让 Swift 对它们进行排序:
1 | let americanMen = WorldCupResult.neverWon |
排序过程会把未获得世界杯冠军的队伍放在前面,然后是日本女子队,再然后是美国女子队 —— 两组 winner
的队被认为是大于两组 neverWon
的队,而 winner (stars: 4)
被认为是大于 winner (stars: 1)
。
SE-0269 使得我们可以在一些不必要的地方省略 self
。在这个改变之前,我们需要在所有的闭包当中对引用 self
的属性或者方法冠以 self.
,以便显式地明确语义。但有的时候由于闭包不可能产生引用循环,self
是多余的。
例如,之前我们需要把代码写成下面这样:
1 | struct OldContentView: View { |
对 self.cell (for:)
的调用不会产生引用循环,因为它是在结构体内使用。多亏了 SE-0269,上面的代码现在可以免去 self.
:
1 | struct NewContentView: View { |
这个特性对于大量使用闭包的框架非常有用,包括 SwiftUI 和 Combine。
SE-0281 引入了一个新的 @main
属性,它可以让我们声明程序的入口。这个特性使得我们可以精确地控制程序启动时要执行的代码,对于命令行程序尤其有帮助。
例如,当我们创建一个终端应用时,我们必须创建一个叫 main.swift 的文件,然后把启动代码放在里面:
1 | struct OldApp { |
Swift 会自动把 main.swift 看作最顶层的代码,创建 App
实例并且运行。即便在 SE-0281 之后这个做法都一直被延续,但现在你可以干掉 main.swift 了,转而使用 @main
属性来标记某个包含静态 main
方法的结构体或者类,让它充当程序入口:
1 |
|
上面的代码所在的程序运行时,Swift 会自动调用 NewApp.main ()
来启动程序流程。
新的 @main
属性对于 UIKit 和 AppKit 开发者来说可能有点属性,因为我们正是用 @UIApplicationMain
和 @NSApplicationMain
来标记 app 代理的。
不过,使用 @main
的时候有一些注意事项:
@main
属性@main
属性只能用在最顶层的类型上 —— 这个类型不继承自任何其他类where
语句SE-0267 引入一个新特性,你可以给泛型类型或者扩展添加带有 where
语句限定的函数。
例如,我们创建了一个简单的 Stack
,可以压栈,出栈元素:
1 | struct Stack<Element> { |
借助 SE-0267,我们现在可以添加一个 sorted ()
方法给这个 Stack
,并且要求这个方法只有在 Stack
的泛型参数 Element
遵循 Comparable
协议的时候才能使用:
1 | extension Stack { |
SE-0280 使得枚举可以参与 protocol witness matching,这是一种表述我们可以更容易地匹配协议要求的技术方式。
例如,你可以编写代码处理各种类型的数据,但是假如数据不见了怎么办呢?当然,你可以借助空合运算符,每次都提供一个默认值。不过。你可以借助协议来要求默认值,然后让各种类型遵循这个协议:
1 | protocol Defaultable { |
SE-0280 使得我们能对枚举做出一样的控制。比如,你有一个 padding
枚举,它能接收像素值,厘米值,或者是系统的默认值:
1 | enum Padding: Defaultable { |
这样的代码在 SE-0280 之前是无法实现的 —— Swift 会抱怨 Padding
不满足协议。但是,如果你仔细琢磨一下,协议其实是满足的:我们需要一个静态的 defaultValue
,它返回 Self
,换言之,就是某个遵循协议的具体类型,而这正是 Padding.defaultValue
提供的。
didSet
语义SE-0268 调整了 didSet
属性观察者的工作方式,以便它们能更高效地工作。对于这个优化你不需要改动任何代码,自动获得一个小小的性能提升。
在内部,Swift 做出的改变是在设置新值时不再查询旧值。如果你不使用旧值,也没有设置 willSet
,Swift 会即时修改数值。
假如你需要用到旧值,只需要引用 oldValue
即可,方式如下:
1 | didSet { |
SE-0277 引入了一个新的半精度浮点类型,Float16
。这个精度在图像编程和机器学习中十分常见。
新类型和 Swift 原来的其他类型相似:
1 | let first: Float16 = 5 |
Swift 5.3 为 Swift Package Manager (SPM) 带来了很多提升,恕我不能在这里一一举例。不过我们可以讨论一下 SPM 有哪些变化以及为什么会有这些变化。
首先,SE-0271 (Package Manager Resources) 使得 SPM 能包含诸如图片,音频,JSON 等类型的资源。这个机制可不只是把文件拷进最终的 app bundle 这么简单 —— 举个例子,我们可以应用一个自定义处理步骤到我们的 assets,比如为 iOS 优化图片。为此,新增的 Bundle.module
属性就是用来在运行时访问这些 assets 的。SE-0278 (Package Manager Localized Resources) 进一步支持了资源的本地化版本,例如提供适用某个国家的图片。
其次,SE-0272 (Package Manager Binary Dependencies) 使得 SPM 可以使用二进制包。这意味着像 Firebase 这样的闭源 SDK 现在也可以通过 SPM 集成了。
再次,SE-0273 (Package Manager Conditional Target Dependencies) 可以让我们指定为特定平台和配置使用依赖。例如,我们可能在为 Linux 平台编译时,额外需要某些特定的框架,或者我们在本地测试的时候需要一些依赖调试用的框架。
值得一提的是,SE-0271 的 “Future Directions” 一节中提到了对资源的安全访问 —— 这意味着像 Image ("avatar")
这样的代码之后会变成 Image (module.avatar)
。
The team used this skill on the white wolf guard. The specific skill is called “sacrifice”. 🤨
Whenever the wolf leader around him receives deadly ballistic damage, the heroic white wolf guard will take a deep leap and block this damage for his boss.
Here is the video demonstration on Youtube:
And here is the schematic diagram:
The realization of the jumping block is very interesting 🤪. It is a combination of mathematics and physics.
Let’s skip the unimportant part and look directly at the most critical code:
1 | /// <summary> |
the mathematical part, EvaluateNecessaryArrival
:
1 |
|
It’s not that difficult, right? 🙊 Hoping you enjoy this skill. 😬
]]>建议横屏阅读代码
在软件开发中,我们常常会遇到一种场景:随着产品功能的扩展,出现了多个具备高度相似性的功能单元。作为功能单元,它们可能有着相似的交互逻辑,提供同类的输入数据和输出数据。并且,对于用户来说,它们都在处理同一个的东西。
举个例子,比如一款修图 app ,它包含了一组编辑功能,每个功能都作用于一张图片,处理之后的图片,还可以作为其他功能的输入。作为编辑工具,在每个功能内部,可能还需要支持撤销和重做这样的用户操作。我们容易想到的是,这些功能间存在着许多可以进行复用设计的代码。
本文基于一次回顾发起,出于记录和分享的目的:一次代码重构,一款之前我参与开发的图像处理应用。
请看下面这幅图:
元素不多,让我解释一下。图中的 “内存图像管理 + 效果处理” 是一个 “黑盒子”。“逻辑黑盒” 有的时候是好事,有的时候是坏事。那这里的黑盒子算好事还是坏事呢? 既然对这种设计做了重构,多半是有痛点了。这里,我们重点探讨一下它的负面效应。
在具体业务场景下,这个黑盒子有两个问题:
黑盒子的两个问题在重构时都得到了解决,但第一个问题与本文要分享的设计思想关联不大,不做展开。
为了说明我们是如何解决第二个问题的,这里先引入两个概念:“流水线” 和 “例程”。相信对于从事编程类工作的读者来说,这两个词不会陌生。
流水线 pipeline,[计] 又称管道,管线。
例程 routine,[计] 程序;日常工作;例行公事
在我们的案例中,Pipeline
相当于内存中的图像状态机,提供了基本的图像管理功能,例如加入图像,删除图像,复制图像,移动图像,等等。Routine
相当于各个图像功能单元中的通用事务,比如说,对于每个图像功能单元,都需要在其开始运作时从某处获得一份初始的图像,并在其结束运作时输出一份 最终的 图像到另一处。我们还约定,Routine
中的事务会基于 Pipeline
来完成。可以具体解释成这样:每个 Routine
都会包含一组典型的图像处理动作,这些处理动作借助一个或者多个 Pipeline
的通用操作,以及每个 Pipeline
的差异化操作来完成(后面会具体说明这个 差异化的图像处理步骤)。
从这里开始,我们不妨把 “流水线” 的叫法直接替换成 “管道”,因为后面会用到一些比喻性的描述,我个人它们觉得基于 “管道” 一词衍生出来,会比用 “流水线” 来得更自然。接下来,我们对 “管道” 这个意象再做进一步的挖掘,可以设计出下面这些对应关系(表格中左侧的概念只是一种比喻,读者可自行体会,这里不会详细解读)
比喻 | 原对象 |
---|---|
“管道” | 图像状态机 |
“流体” | 图像 |
“节点” | 图像状态 |
“流动” | 图像状态流转 |
“锋面”(流体的最前端) | 当前正在处理的图像状态 |
“连通性” | 状态机内的图像以及图像状态机之间都是可串联的 |
“流体” 是一个名词,它对应的是图像,涉及到存储模型。根据 “流体” 的特性,我们可以想象,或者说推断,管道里的图像存储模型应该会被设计为平行结构。
请读者联想一个类比, <__化妆 / 整容 VS 软件上美化照片上的人脸__> ,再思考一下,两者在存储模型和工序这两个方面有什么异同?
回到正题,我配了五幅图来描述管道在具体实现中的五个特性:
“流动” 的 “流体” 会有一个 最前部 ,就好像水流的最前端,又称为 “锋面” (Waterfront),对应着这样一个事实:“管道” 中所有的图像,在同一时间里,只会有 唯一的 图像处于 可操作 的状态,这个状态代表着 图像的变化趋势 。具体到代码实现,可能会是一组带有 同步关键字 的方法,加上一个唯一的指向当前状态的指针。我们通过 引导 和 操刀 这个趋势,把图像 “引向” 最终要呈现出来的样子。在图示中,我有意使用了绿色代表原始的、最初的,使用红色代表成熟的、完全体的。Pipeline
专注于做一件事:把图像从一种状态转化为另外一种状态。这期间,可能要经历多个 “节点”,而 “锋面” 的意义就在于,它保证了一件事。那就是 Pipeline
的操刀者可以确信,这一刻只有他自己在引导图像的 “流向”,没有人会干扰到他。
“流动” 可以是双向的(相比生产车间的 “流水线”,“管道” 之所以更贴切,在于后者可以实现双向的流动,对应到图像,相当于实现反向编辑,或者说撤销到一个处理步骤之前的状态)
“流体” 如果 “分流”,则可以出现多个 “锋面”,对应着图像的 并行处理 。
如前所述,“流体” 其实就是图像,简单封装即可。我们主要实现的是 “节点”、“锋面”、 “流动” 和 “连通”。
我们先来看一种典型的图像处理过程中可能会采用的写法,代码为 swift 实现:
1 | // 图像 xyz |
当然,现代编程语言的语法特性,可以让你省去写各种 getter/setter 的样板代码,从而节省代码量。但这不是重点,重点在于 —— 上述这种代码无法复用。因为每一个图像的引用都被赋予了 具体 的含义:同样的写法不太可能完全地适用于另外一个图像处理场景。比如说,另外那个图像处理场景很可能不会用到描述为 ijk 的图像,可能会用到描述是 uvw 的图像。因此,采取这种写法会遇到的一个典型问题是:每新增一个图像处理场景,我们都需要新增若干个特定描述的图像声明。在编码层面,这无疑是一项繁冗的工作。
上面说的图像引用,其实正是我们的图像 “管道” 里的某个 “节点”。思考一个问题,如果要对 “节点” 实现代码复用,你会怎么做?稍微提示一下,关键在于 “具体” 这两个字。
是的,如果我们能想到,上面的写法中代码之所以不能复用,根源在于图像引用的用途已经被 具体定义(同时也是被具体 约束),那么我们就更有可能往这样一个方向思考问题的解决方案:能不能把图像引用 “去具体化” ,让它的含义在具体场景到来时才被赋予呢?
讲到这一层,有些读者可能已经想到一种数据结构 —— 字典。是的,没有什么奇淫巧技,只用字典,就能实现 “去具体化”,解决这个代码复用问题中的最大障碍 —— 既然无法预知我们可能需要处理什么样的图像,可能需要处理多少份图像,并且这些未知数总是易变的,那为什么不让具体场景的使用者来 动态添加 这些图像引用,并且为它们具名呢?图像部分被复用的代码,这里只声明了一样东西,就是从图像状态表述到图像引用的映射表。它提供了一个之前的写法不具备的特点,而这个特点是达成复用的必要前提:图像存取的方式是 统一的,有限的,从而是 可固化的。
1 | var stateTagToImageMap = [String:MyImage]() |
我们用一个 字符串标签 来表示图像的状态。对于图像 “管道” 的使用者来说,他只需要理解每个标签的含义,通过标签来存取图像并进行处理。在这些标签中,我们再提炼出几个具有通用含义的代表性标签:比如,original
代表 “最初的”,processed
代表 “加工完成的”,这正是前文提到的 占位符 。容易理解,在一份可复用的代码库中,你可以声明并且预置许多 占位符 。但你不会在这个代码库里声明同样数量的图像引用 —— 这样很奇怪对吧?哪怕从程序实现的角度来说,没有分配实际空间的引用并不一定会占据更多的内存。在后面列举的代码范例中,我们将会经常地用到 original
和 processed
这样的标签。
不妨阅读下面这段代码,这就是一种使用标签来操作对应图像的写法。
1 | // 显示两个处理步骤之后的图像 |
解决了 “节点” 的设计,我们再来看基于 “节点” 提炼出来的 “锋面” 要怎么设计。容易理解,“锋面” 是最前面的那个 “节点” ,具有 唯一性,对应具体的图像处理代码中就是 “当前正在被处理的那个图像”。在设计图像管道对外提供的处理 API 时,我们约定处理动作一定只能发生在这个 “当前的” 图像上,这样就能够保证我们的 “图像流” 总是按照我们想要的方向流动,并且在这个过程中,“图像流” 是不会被篡改的。这也是我们的图像编辑功能要实现撤销和重做功能的基本前提。
还是上面那段代码,现在可以去掉实际处理步骤的标签参数。因为我们约束了处理只能发生在 唯一的、当前的 图像上。
1 | // 显示一个处理步骤之后的图像 |
如果要求能够回撤到第一个处理步骤之后的状态,再做第二个处理步骤,并且第二个处理步骤的参数是可以改变的。可以这么做:
1 | // 显示一个处理步骤之后的图像,但我们在过程中保留了第一个步骤的状态 |
有了 “节点” 和 “锋面”,“流动” 和 “连通” 就有了作用的主体。对应到图像编辑功能,“流动” 其实就是图像从一个状态变成另外一个状态的过程。“连通” 则更好理解,一个管道出来的图像可以被另外一个管道接纳,由此构成管道之间的连接。连接在一起的每一节 “小管道” 各司其职,灵活组合,再构成更长跨度的 “大管道” 或者 “管道网络”,从而协同完成复杂的业务流程。
回归到代码,我们来看一组步骤稍多的图片处理工序,看它是如何体现出管道的 “流动性” 和 “连通性”。刨去内部的实现细节,整合或者忽略一些与管道设计思想关联不大的逻辑,以下代码在流程上算是比较接近实际生产环境了。虽然采用的是伪代码,相信读者可以看懂。
1 | /** 主功能区,不妨将它的例程称为 Main |
关于 “管道” 的设计思路和实现方案介绍到此。我们可以回顾一下,本文开始所提到 “黑盒子” 设计的第二个问题:“图像 管理 和图像 效果处理 被绑定在一起。”,在管道方案中是不是已经解决了呢?
]]>“管道” 设计的基石是 无差别地管理图像 ,被管理的每一个图像,由最初将其投入管道的创建者为其定义标签。最初的创建者和后来的协同者,只需要对这个标签的含义达成 共识 便可以进行协作。“管道” 的思想是模拟 “流体” 的运行方式来实现图像处理过程,通过 “节点” 的设定来 分解 处理步骤,通过 “锋面” 的操控来 聚焦 每个单步的操作,通过 连通性 来将 分治 的逻辑重新 串联 起来完成复杂的功能。
让我们用 MVVM (model-view-view model) 来构建一个应用,其中的每个 SwiftUI 视图都有自己的 model 。这会是一个拥有两个视图的 app : 一个电影列表以及一个用于添加电影的表单。新增的电影存在在 MovieStore
,它由两个 view models 共享。我们将通过 environment 来共享 MovieStore ,也就说,当我们需要时,会从 environment 中读取。
Movie 是一个很小的结构体,只存储了标题和评分。标题和评分都是可变的,因为我们需要在 AddMovieView 里更新它们。这个结构体也遵循 Identifiable
协议,因为我们将用 List 视图来展示所有的电影。List 需要能够标识内容中的每一项,而遵循这个协议是最简单的方式。
1 | struct Movie: Equatable, Identifiable { |
MovieStore 也很简单,不过实际的 app 会包含更多的逻辑:持久化,删除等等。我们用 Published
属性包装器来为订阅者自动提供发布。
1 | final class MovieStore { |
为了将共享的 MovieStore
插入环境,我们需要使用自定义的 EnvironmentKey 。自定义 key 仅仅只是一个遵循 EnvironmentKey
协议的自定义 key 。我们需要提供类型和默认值。
1 | struct MovieStoreKey: EnvironmentKey { |
如果我们不插入自己的 MovieStore
实例到 environment ,那就会使用 defaultValue 默认值。典型情况下,我们会在视图体系之外初始化这个特定实例。
MovieStore 作为依赖项,在构造函数被传给 view model 。我们将使用存储在 SceneDelegate 的实例。再次申明,在实际的 app 中,这种依赖项很可能是处于一个独立的容器或者别的类似的东西。 MovieListView 是我们要呈现的第一个视图,因此我们会初始化 view model , view ,并且插入 MovieStore 实例到 environment ,以便后续使用。 (movieStore keypath 是通过 EnvironmentValues 的 extension 来定义的)。
1 | final class SceneDelegate: UIResponder, UIWindowSceneDelegate { |
在 SwiftUI 中,view model 遵循 ObservableObject
协议,使用 @Published 属性包装器。 ObservableObject 的默认实现提供了 objectWillChange
publisher 。 @Published 属性包装器能在属性将要改变时自动发射这个 publisher 。在 MovieListView 中,我们用 @ObservedObject 属性包装器声明 view model 属性。这会使得该视图订阅 objectWillChange publisher ,并且在 objectWillChange 发动时自动刷新视图。
1 | extension MovieListView { |
1 | struct MovieListView: View { |
你会注意到,MovieStore 时用了两份,一份在 view model 中,一份放在环境中。
AddMovieView
和它的 view model 是在用户点击导航栏上的加号按钮时被创建的。环境属性包装器可以被用于获取整个环境或者借助特定键获取某个值。在这个案例中我们访问了整个环境对象,然后在需要的时候借助 MovieStoreKey 访问 MovieStore 。或者你也可以使用 @Environment (.movieStore) var movieStore 来代替。
AddMovieView
的 view model 是随着 MovieStore 一同被初始化的,它内部呈现了一个 Movie 实例。 Published 属性包装器和 MovieListView 的 view model 里的用法相似。 内部的 movie 对象是一个私有的属性, TextField 和 Picker 都采用双向 Binding 。 Binding 是一种 view 和 model 间的双向连接方式。另外,还有一个 canSave
属性,它是用来控制导航栏上的保存按钮是否启用。保持按钮只有在标题有填充的时才启用。
简单复习一下视图更新的流程:TextField 或者 Picker 会利用 Binding 来更新私有属性 newMovie
。 因为 newMovie 属性使用了 @Published 属性包装器,它会发射 ObservableObject 的 objectWillChange publisher 。 SwiftUI 自动订阅 objectWillChange ,因为 view model 的属性用了 @ObservedObject 。
1 | extension AddMovieView { |
我们创建了一个只有两个视图的简单 app 。两个视图都有各自的 view model ,并且都依赖 MovieStore 。一个 view model 中触发了 MovieStore 的改变,这些改变会被另一个 view model 观察到。另外,我们还了解了 SwiftUI 的 environment 以及如何从 view model 中触发 view 更新。
译自 Dynamic user notification on Apple Watch with SwiftUI
源码地址:WaterMyPlants
集成了推送或者本地通知的 app 可以定制 apple watch 上的通知。本文是关于如何在 apple watch 上实现动态通知的笔记。样例工程实现一个提醒给植物浇水的功能。我们会聚焦在添加通知视图,省略从 iOS app 发送通知的步骤。
如果工程里没有 App Watch app ,你需要添加它。在 Xcode 中,我们新增一个构建目标,并配置成包含通知场景。打开 New -> Target:
确保 User Interface 选择,并且 “Include Notification Scene” 选中。我们将会把它嵌入当前的 iOS app ,所以 “Embed in Companion App” 要选择当前 app 。值得一提的是,从 iOS 13 和 WatchOS 6 开始,Apple Watch app 已经可以独立存在了。
点击完成,Xcode 会询问激活新的 scheme ,点击激活,它会自动选择新建的目标,所以我们可以直接开始写代码了。先检查工程,会发现 Xcode 加了两个目标:watch app 和 extension。App 包含了 storyboard ,而 extension 包含了所有的代码。 storyboard 是提供基于 WKHostingController
的子类的 HostingController 演示用的场景。这个类负责承载你的 Apple Watch app 的 SwiftUI 视图。另外,还有两个场景,分别是静态和动态通知。我们对动态通知感兴趣,在 storyboard 里可以看见动态视图是由 NotificationController
提供的,它是 WKUserNotificationHostingController
的子类,承载通知的 SwiftUI 视图。这里就是我们给通知提供自定义界面的地方。如果通知的分类和 storyboard 里预先定义的匹配,就会选择动态通知视图。
NotificationController 的职责是消费用户通知的 payload ,并生成 SwiftUI 视图来展示它们。用户通知是从 didReceive
函数接收的,我们需要释放信息,用于视图。在本地测试的时候,我们可以把测试数据写在 PushNotificationPayload.apns
文件里。因为我们要展示的是关于植物的信息,所有我们添加一个植物对象到文件中。同时,我们还需要把通知分类修改成某个有含义的字符串。确保你设置新的分类时正确更新 storyboard 。
1 | { |
当我们访问 UNNotification.request.content.userInfo
拿到植物的信息时,我们可以用 Decodable
和 JSONDecoder
将代表植物的字典转换成值类型。 JSONDecoder
接收 JSON
数据,所以我们先用 JSONSerialization
包装数据,然后把包装的结果传给 JSONDecoder
。 或者我们也可以手动从 userInfo
字典里读取所有的值,然后创建出植物类型。留意,我们需要用 view model 来提供数据给 SwiftUI ,而不是直接使用 Plant
类型。
1 | struct Plant: Decodable { |
另外,我们想要添加三个用户可以执行的动作:标记植物已经浇水,推后提醒,或者安排明天再提醒。这些动作是用 UNNotificationAction
实例表示。当用户点击任意其中一个时,UNUserNotificationCenter
的委托方法会被调用,并且带有该动作的 identifier
。
1 | let doneTitle = NSLocalizedString("NotificationAction_Done", comment: "Done button title in notification.") |
NotificationController
的完整实现如下:
1 | final class NotificationController: WKUserNotificationHostingController<NotificationView> { |
上面提到 view model NotificationViewModel
为 NotificationView 提供文本,它主要处理日期的格式化字符。
1 | struct NotificationViewModel { |
SwiftUI 视图很简单,4 个文本。
1 | struct NotificationView: View { |
我们往一个 iOS app 中添加了 watch app ,实现一个通知分类的动态通知视图。我们学习了如何解析通知数据,添加动作按钮。下一步是在 companion iOS app 里基于按钮的 identifier 处理对应通知动作。
这一节的主题是从 compasion iOS app 的 CoreData 存储中获取数据,需要借助 WatchConnectivity framework 。
iOS app 用 CoreData 来存储植物列表,记录了每株植物上一次和下一次浇水的日期。在这里,没有 web 服务,所有的东西都存在设备上。那么如何把持久化存储中的数据拿给 WatchOS app 使用呢?
我们会用到 WatchConnectivity framework 来做 iOS 和 WatchOS app 之间的交互。连接是在 iOS 和 WatchOS app 上都激活 WCSession
来实现的。因此,第一步是添加一个管理 WCSession
的类到 iOS 工程,我们不妨称它为 WatchConnectivityProvider (稍后也会添加一个相似的类到 WatchOS app)。它的主要职能是建立 WCSession
,处理 WCSessionDelegate
,其中包含从 CoreData 存储拉取数据。因此,有一个叫 NSPersistentContainer
的参数会提供对 CoreData
栈的访问 (借由访问 performBackgroundTask 函数)。
1 | final class WatchConnectivityProvider: NSObject, WCSessionDelegate { |
WCSession
是通过调用 activate () 来激活,激活过程是异步的。激活的响应通过 session (_:activationDidCompleteWith:error:)
委托访问返回。
1 | func connect() { |
在 watchOS extension target 那边,我们会添加相似的代码,不过名字不一样,叫 “PhoneConnectivityProvider” 。当两个类都创建完成后,我们需要初始化并调用 connect ,分别在 SceneDelegate
(iOS) 和 ExtensionDelegate
(watchOS) 中完成。注意,在 iOS app 这边,我们需要实现两个委托方面,不过目前我们简单打印就可以了。
1 | func sessionDidBecomeInactive(_ session: WCSession) { |
为了测试 session ,我们需要先编译并运行,然后在编译运行 watchOS app 。如果一切工作正常, Xcode 调试窗口会打印出消息: “did finish activating session 1 (error: none)”. 这表明 session 已经建立并且正在运行,我们可以两个 app 间发送消息了。
因为 iOS 和 watchOS app 之间的通信依赖字典,所以第一步是定义一组两个 app 共享使用的 key 。这样可以减少误拼写的风险,所以我们可以添加新文件,并且同时包含到 iOS app target 和 watchOS extension target 中去。
1 | struct WatchCommunication { |
第二步是在 PhoneConnectivityProvider
(watchOS app extension target) 中实现一个 refreshAllPlants (completionHandler)
函数,用来发送消息给 iOS app ,并且等待植物数据的数组返回。 WCSession
有一个叫 sendMessage (_:replyHandler:errorHandler:)
的函数,我们可以用它来发送一个字典给 iOS app ,然后等待 reply handler 。我们会用 WatchCommunication.requestKey 和 WatchCommunication.Content.allPlants 枚举的 rawValue 来构建消息。这种模式便于后续扩展,你只要添加更到 case 到枚举就可以了。在 reply handler 中,我们期望得到一个字典的数组,描述所有的植物。让我们先看一眼完整的实现,然后再讨论字典是如何被转换成 Plant
值类型的。
1 | func refreshAllPlants(withCompletionHandler completionHandler: @escaping ([Plant]?) -> Void) { |
iOS app 上处理 CoreData 和 Plant 类型的是一个 NSManagedObject 子类的对象。watchOS app extension 定义了它自己的 Plant
值类型,因为它并没有 CoreData 栈。为了将字典转换成值类型,我们可以使用 “Storing struct in UserDefault” 中描述的方法,只需要额外配置 JSONDecoder
使用的 dateDecodingStrategy
为 secondsSince1970
。理由是我们希望以自 1970 年之后的秒数来存储日期。转换字典到值类型的过程用到了 JSONSerialization
,它只支持 NSString
, NSNumber
, NSArray
, NSDictionary
, 或者 NSNull
。
1 | // Plant value type in WatchOS app extension |
第三步是在 iOS app 端处理消息,并且提供数据给 watchOS app 。我们需要做的是实现 session 的委托,从 CoreData 栈中获取字典数据。 先看下完整实现,然后逐一拆解。
1 | func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { |
第一步是查看接收到的字典,看看 watchOS app 请求的是哪些内容。然后我们访问持久化存储,获取表示 Plant 的字典,把其他的日期转换成 1970 年后秒数的形式 (以便 watchOS app 能够在字典上使用 JSONSerialization
),然后把数据发送回 watchOS app 。注意,从 CoreData 中获取字典形式的 Plant 很容易:我们首先是请求 NSDictionary
类型的数据,并且将结果类型属性设置为 .dictionaryResultType
。对于各庞大的模型,我们可能还会用到属性集合 (propertiesToFetch) 。不过目前,所有的属性都被添加到字典中了。
1 | extension Plant { |
Xcode 中 watchOS app 的模板是借助 storyboard 初始化 HostingController
, 这个控制器负责提供初始的 SwiftUI 视图。
1 | class HostingController: WKHostingController<PlantListView> { |
PlantListView
是一个显示植物列表的简单视图,它用 PhoneConnectivityProvider
的 refreshAllPlants (withCompletionHandler:)
来处理刷新植物的逻辑。 SwiftUI 视图会在 view model 改变时自动更新。这是因为 view model 的 plants
属性使用了 @Published
属性包装器,而 view model 本身是 ObservableObject
,这是 SwiftUI 视图中为 view model 采用的属性包装器 (更多信息可以阅读 refreshing SwiftUI view in MVVM in SwiftUI) 。注意,这里的 view model 是 SwiftUI 视图显现时刷新内容的。
1 | final class PlantListViewModel: ObservableObject { |
PlantListView
用 PlantCell
来显示独立的视图。
1 | struct PlantCell: View { |
我们在 iOS 和 watchOS app 上都添加 WCSessions
,实现相关的委托方法以处理 session 和接收到的消息。然后,我们定义一个简单的通信模式,并在 watchOS app 端实现刷新植物的方法,在 iOS 端实现 CoreData 集成。当数据访问创建完成后,我们在 watchOS app 上用 SwiftUI 视图显示植物的列表。
要点:
要点:
要点
要点
要点:
检查理解的测验。
要点:
要点
要点
要点
要点
identifiable
的数据要点
要点
要点
要点
要点
要点
要点
要点:
要点:演示
要点
Home 界面
进阶:心智模式:干扰和专注,简单和复杂 (通知)
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
要点
在把假说 - 演绎法作为获取世界知识的最佳途径之前。第一个问题是:现实的本性?什么是现实?什么存在?因此,我们首先要问,获得的知识到底是关于什么的知识。解决这类问题的哲学领域叫做 本体论 (ontology) —— 对存在的研究。第二个问题涉及获取知识的方法。假设的确有一个现实:原则上是可知的,那么我们能获取哪些现实的知识?怎么获取?哲学领域中考虑这类问题的叫 认识论 (epistemology) —— 认识的研究或理论。
我们从后一个问题开始讲。假设有一个可知的现实,我们如何来获取知识。有很多不同的认识论观点,我们这里讨论两个最重要的观点。
第一个是 理性主义 (rationalism) 。理性主义者认为知识通过理性获得,用我们大脑的逻辑和理性思维能力,可以推断世界的真理,而无需借助经验。
哲学家柏拉图和笛卡尔结合了理性主义和其他观点,认为至少某些自然结构的抽象概念是天生的,是我们与生俱来的。就是说,我们的大脑能轻易理解这些概念,因为我们已经知道了,只要用推理回忆或辨识即可。
经验主义 (Empiricism) 反对这一观点,经验主义者认为感官经验才是最主要方式。一些绝对经验主义者甚至认为,这是获取世界知识的唯一方式。
亚里士多德被认为是第一个经验主义者,他认为关于自然的基本真理来自感官经验。我们可以通过演绎推理获取更多知识,但观察是所有知识的基础。亚里士多德不相信天赋观念 (innate ideas) 。其实,他创造了 “白板” 这个词,指出每个人生下来就是一块白板。我们的知识并不是预先确定的,大脑可以接受任何概念。
当然,亚里士多德不是激进的经验主义者。他不反对理性思维加入进来,他也不反对用一些抽象的、不能直接观察的概念。
我觉得伽利略算是温和的经验主义者。他很注重观察法和实验法,但他也极度依赖逻辑推理。伽利略有句名言 “自然之书以数学语言写就”。他完全不排斥运用思想实验,也在他的假说中引入不可观测的性质。
后来的经验主义者如培根,尤其是休谟和逻辑实证主义者,都是绝对经验主义者,坚信只有感官经验才能获得世界的真知。他们认为建立在无法被直接观测的共相上的论断没有意义。
现代的经验主义就是范弗拉森的建构经验主义。它强调感官经验在归纳法和演绎法中都有作用,但它也允许理论术语存在,对应不能直接观测的实体。建构经验主义的目标是提出实证上恰当的解释,只要它表述的世界与观测到的一致,即可被接受。建构经验主义者会说因为存在不可观测的性质,真假无法判断。这承认了知识是暂时的,因为未来总存在发现新的反例的可能。
我们来说说本体论 (ontology) 即现实的本质是什么。
有很多对立的观点。在深入各种哲学观点之前,我先来解释这些观点里两个主要区别。第一点,现实是否独立存在于人类思想之外。当我们感知世间万物,它们真的脱离我们而真实存在吗?或者只是我们思想构建的心理表征,只能说存在于我们的思想。第二点是关于本体状态的殊相 (particular) 和共相 (universal)。殊相是指具体实例或能观察到属性的事件;共相或者说不可观察的性质,就是指那些无法直接观察到的普遍性质。
我们来举个例子,爱是我们无法直接观察到的普遍性质,但能通过行为表达或具现。因此当我家猫爬上我的膝盖打个小盹,这就是爱这个共相的具体实例。重力是另一个不可观察的共相,重力可用具体实例来表现。例如,当我打翻猫食盆,它就会掉在地上。
我们来看一下不同的本体论观点,看看他们对殊相和共相问题,以及对现实是外部存在还是仅存于思想内的看法。
唯心主义 (Idealism) 哲学观认为我们感知的现实完全存在于脑内。在我们精神世界感知到之前,外部物质世界的存在与我们无关。现实其实是精神世界的映射,重力和爱是存在的,但仅在我们的思想里,与之相关的具体实例也是如此。唯心主义者会说:猫睡在我膝上,以及碗掉在地上,这都是你想出来的。
对唯心主义而言,共相或不可观察的性质,它们是否真的在外部独立存在无关紧要。因为他们认为殊相和共相都是存在的,但都是精神世界的映射。
和唯心主义相对的是唯物主义 (Materialism) 。唯物主义认为思想世界外有独立存在的世界,唯物主义还认为一切由独立的物质世界构成。这就是说一切都是实物相互作用的结果,包括我们的意识、情绪和思想,这些是我们大脑和物质世界相互作用的副产品。
和唯心主义刚好相反,这是物质对精神。唯物主义只关注世界的物质组成,和唯心主义类似,它并不关心如何区分殊相和共相。
现实主义 (Realism) 又有所不同,就像唯物主义者,现实主义者坚持外部现实世界独立于人类思想存在,但现实主义者还坚持,像爱和重力这种共相是真实的,其存在形式取决于现实主义的类型。柏拉图式的现实主义认为像重力、爱这种共相,真的存在但我们观察不到,其位于一个独立的抽象位面。科学现实主义则更温和,它认为把共相用于现象观察能得到坚实可靠的主张。
在科学现实主义中,像爱和重力这种共相,和可观察的殊相位于同样的本体状态。之所以能假设不可测性的存在,是因为其对构造成功的科学主张有用且经常很必需。
最后是唯名论 (名义主义,Nominalism) 。这个观点在共相方面与现实主义完全对立,它承认现实独立于人类思想存在,但否认共相的存在。唯名论认为没有重力或爱这回事儿,只有掉落的物品以及经常在你膝盖打呼噜的猫。根据唯名论,我们用重力和爱这些术语,只因为能帮助理解世界,但共相不是真实存在的。
至今为止我讲到的科学方法的发展,主要在自然科学领域。物理学 physics、天文学 astronomy、生物学 biology,但在 19 世纪下半叶, 社会科学 (social sciences) 开始登上历史舞台。
这段时期,人们又重拾了现实主义的本体论观点,即假设物质世界是真实的,我们感知的是外部世界,是独立于我们思想存在的。
认识论的观点越来越实证主义 (positivistic) ,这意味着科学家认为我们可以通过观察和实验来获取有关世界本质的知识。这种现实实证观点大多应用于自然现象方面,但随着社会科学发展并成为独特的科学领域,问题来了:现实主义观点是否适用于社会学和心理学现象呢?
根据 客观主义 (objectivism) 的观点,现实主义本体论立场确实适用于心理学和社会学现象,比如智力和社会凝聚力是外部的、独立的性质,是独立于我们的心理表征的。
客观主义可以建构主义 (constructivism) 做对比。
根据建构主义,社会现象的本质取决于所涉及的社会角色。这意味着现实不是独立和外在的,而被认为是基于观察者和情境的心理建构。比如,快乐或女性气质这些属性不是外在的,不是永恒的,也不能被客观定义。要如何看待这些属性以及它们的意义,取决于观察者的文化背景、社会族群及特定的历史时期。那么,如果心理现实和社会现实是建构的、主观的、难以捉摸的,我们如何了解它呢?怎样的认识论立场适合建构主义的本体论立场?
事实上,有一组互相联系的观念,统称为 解释主义 (interpretivism) 。解释主义的观点都假设研究者关于社会现象的经历或观察,可能与这些社会现象亲历者的经历大相径庭。所以重点应该放在参与者的角度来解读现象。
我想讲的三个解释主义观点是 解释学 (hermeneutics) 、 现象学 (phenomenology) 和 诠释社会学 (verstehen) ,它们在如何获得心理学和社会现实的理解上有些微差别。
先来看看解释学。这个术语来自神学,是关于解读经文。解释学旨在通过解读人们在社会情境下的行为 来解释社会现象。研究者需要将情境纳入考量,并试着理解人们如何看待这世界,以此来理解他们的行为。
现象学与解释学密切相关。它的首要前提是人不是无生命的对象,他们会思考和感知周遭的世界,而这会影响他们的行为。为了理解他们的行为,就需要调查他们给自己所经历的现象赋予的意义。这意味着调查人们如何从自身的角度探究世界。要切身了解他人对自己经历的理解,研究者需要尽可能地消除自己先入为主的观念。
诠释社会学是第三种解释主义观点,它与解释学和现象学有紧密联系。诠释社会学主要与社会学家马克斯・韦伯 (Max Weber) 相关。诠释社会学是指对社会现象的移情理解。研究者需要站在研究对象的立场,来解读他们如何看待世界,只有这样研究者才能解释他们的行为。比如,如果欧洲研究者想在一个与世隔绝的亚马逊部落中探究快乐。他们需要站在部落的角度,考虑到部落的社会情境。对部落来说,或许集体比个人更重要,这可能意味着快乐被认为是一种集体属性,甚至根本不适用于个人。现在,为了理解这种完全不同的世界观,研究者需要将自己沉浸在他们研究的人或族群的文化中。
当然,建构解释主义的观点存在一些问题。首先,有分层解读的问题 —— 研究者的解读;研究对象的解读;而将发现放进一个框架或关联一个理论时,又进行了解读。每多一层解读,就增大了误解的机会。第二个更严重的问题是结果缺乏可比性。在我们的例子中,快乐是主观的,在不同的文化中意义不同。我们不能就这么进行比较。这意味着我们永远无法提出普适解释或理论,而仅仅适用于特定人群或特定时段。第三个问题是参考系的不同。如果参考系与研究者相去甚远,研究者就很难站在研究对象的立场上,从而甚至难以发现社会情境中的相关方面。
建构 - 解释主义的观点常与科学的定性方法有关。换言之,观察是通过非结构化访谈或参与性观察进行的,而研究者是他们中的一份子。数据来源于一个或少数几个研究对象,通过解读文本或录制的素材对数据进行定性分析。反之,客观 - 实证观点于定量研究方法相关。得到的观察结果可以被计数或测量,所以多个研究对象的数据可以整合在一起,选取的研究对象代表更大的人群,或许可以支持一个普适解释。而且数据用量化统计手段来分析。
尽管定性方法通常与建构主义的科学观点相关,而定量方法与客观主义观点相关,这并不是限制我们仅使用定性或定量方法的理由。两种方法都各有优劣。对有些研究问题来说,定性方法更好;其他情况下 定量方法可能更合适。事实上,将两种方法互补结合在一起的方法,越来越受到欢迎。
当然最后,科学的总体目标是获得知识,但可以分为更多具体的目标,区分目标的方式有获取知识的类型以及获取知识的目的。
普遍性研究 (universalistic research) 试图提供能广泛使用的解释。
例如,假设玩暴力电脑游戏会导致攻击行为。这与具体游戏或特定玩家没有关系,因为我们假定的是玩暴力游戏和攻击性间的相关性,这适用于任何暴力游戏,如 GTA 、使命召唤等等;我们还假设相关性适用于男性和女性,任何年龄、任何文化背景的人。
普遍性研究致力描述或解释的现象,能用于所有人、所有群体或社会。
科学方法也能用于特殊性研究。特殊性研究致力描述或解释发生在特定环境下的现象,或者涉及特定群体。
例如,在荷兰将法定饮酒年龄从 16 岁升至 18 岁后,我们能调查荷兰青年酒精中毒住院人数的变化。关键是在特定的时间、地点、群体内调查影响的大小。不要指望在不同国家或十年里再次改变饮酒年龄会有同样结果。所以研究目的既可以是普遍性的,也可以是特殊性的。说得简短些就是:可获得普遍性或特定的知识。
基础研究 (fundamental research) 和 应用研究 (applied research) 间关系很近,重叠度很高。
应用研究为了直接解决问题,其开发和应用知识是为了提高人类福祉。假设我们想帮助抑郁人群,我们认为抑郁是孤独造成的。我们就可以建立一个项目,目的是减少孤独感以降低抑郁程度。我们让孤独抑郁的人去养只猫,来观察是否真的由于不再孤独降低了抑郁程度。
基础研究相较于应用研究旨在获取知识,就是为了增进了解。基础研究的唯一目的是加深了解身边的世界,不需要能立即应用和接解决问题。例如,调查孤独和抑郁间的相关性,用大规模调查来看是否越感觉孤独的人越抑郁,反之亦然。这里是为了揭示孤独和抑郁间的相关性。也许我们想看看是否男性女性都有这种相关性,不同文化和年龄也有这种相关性。但注意,我们不关心如何治疗抑郁,这里的目的更多的是了解相关性,不是帮助抑郁人群。
大多数基础研究是普遍性研究,但有时候基础研究也会是特殊性研究。例如,在非常特定的情形下的研究。好比我们调查玩暴力游戏和攻击行为的相关性,就在阿姆斯特丹特定的初犯少年犯群体中,他们都来自权贵阶级。在玩暴力游戏和攻击行为相关性方面,这个非常特定的问题群体能提供有趣的新见解。注意,我们不观察该群体如何改造或不再犯罪。
应用研究常是特殊性研究,旨在特定环境、特定群体中解决问题,但它也可以是普遍性的研究。以养猫来减少抑郁的研究为例,我们可以扩展这项应用研究,比较照顾友善易交流的猫和拒绝接触的猫的人群。这会更有针对性地帮助找到何种治疗有效,但这也加入了普遍性元素,我们还可以调查它对孤独的意义。仅仅有个活物存在就够了吗?还是需要有互动?很多时候,应用研究的结果会产生新的见解,这些见解会和介入或治疗相关,但它们也会提供基础的知识。
因此,两种研究类型会互相增强。
监控目标区域,进入、离开,Beacon 相关
KVO is key-value observing.
1 | updateSearchResults (for searchController: UISearchController) |
要做一个 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 $ 等),每个小时更新一次,但足够用了。
下面先了解一些基础知识。
管理 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 这种数据不可能精确预测,它受到很多因素影响,某些是人为的 (比如烧煤取暖这类日常活动)
Timeline 的设计还用到 watchOS 的另外一个特性,叫做 “Time Travel” ,它使得你可以在表盘上向前或者向后滚动时间,并更新 complication —— 这使得你可以看到诸如一场比赛中比分变化的过程,或者一只股票在一天中股价变化的过程。
watchOS 5 中这个特性被完全移除了,这意味着现在没办法看过去时间点的数据了。所以在实践中,实现处理过去数据这部分的 complication API 没有意义。
未来的数据仍然有价值 —— 虽然没有办法直接滚动操作了,但是 time travel 还可以工作,只不过是单方向固定节奏了。
有趣的是,这部分无用的 API 尚未被废弃,这意味着未来有回归的可能。
在 watchOS 5 中你可以选择多达 26 种样式的表盘。不同的表盘可以适应不同数量和形状的 complications 。这些形状或者 complication 空间的变体被称为 complication famlilies ,目前有 10-11 种 families 可用:
你可以支持其中任意多你想要的家族子集,当然,理想情况下一个好的 app 是支持所有这些家族,因为不同的人偏好不同的表盘。
项目中为了让事情简单一些,我们只添加了对 Modular Small 和 Circular Small 的支持(覆盖了 11 中表盘,如果没算错的话)。
由于资源的限制,你无法在 complication 空间随意绘制东西,你只能使用预定义的模板。模板限定了它们可以包含的内容类型和排布方式。你唯一的选择是选择一种模块,适配给定的空间,放入文本,图标或者值。
举个例子, Circular Small 家族有 6 种可用的模块:
.ringImage, 中间一个图标,然后一个围绕它的环,其他环的哪些部分被填充可以由你指定
.ringText, 中间是文本,然后一个围绕它的环 (实际中,文本通常是 1 到 3 位数字)
.simpleImage, 就是个简单的图标
.simpleText, 就是个标签
.stackImage, 上面一个小图标,下面是短的标签
.stackText, 两行短文本
大部分模块都只有极其有限的空间用于展示内容,有的时候你需要绞尽脑汁想如何利用给定的空间。如果实在想不出来,那么放弃对特定 complication 家族的支持也是可以的。
你的 app 会一些不同的状态 —— 例如,有或者没有数据,空或者非空数据列表,有或者没有进行中的活动等等。所以根据状态来选用不同的模块是可以的(比如,某些状态用数字,某些状态用图标)。每当你构建 timeline 时,你可以创建全新的模板对象并且用它们填充内容,所以只要你开心,甚至可以每次采用随机模板。
为了渲染不同类型内容的灵活性, 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 WatchKit App” 目标运行。
如上文所提,为了让事情更简单,我们只是实现 Modular Small 和 Circular Small complication 家族。不过默认情况下所有的 complication 家族都是启用的,所以你需要禁用掉其他的。
打开 “SmogWatch WatchKit Extension” target 的配置页,在 “General” tab 你会看见一个可以触发 complication 家族的列表:
接下来,要确定每个 complication 家族要采用什么模板。在 CloudKit 文档 中,找到 Modular Small 家族。在它的页面上,你会看到 7 种可用的模板类以及它们的效果截屏。
在我们的案例中,我们主要显示小数字,所以下面几种选项可能是合理的:
最后,我选择了像下面这样的样式:
这个方案解决了展示 app 是什么的类型,同时也支持解释不同类型参数的问题,缺陷是使得字体更小了,尤其是 3 个数字的情况。尽管仍然可读,但是 Circular Small 版本肯定效果不好。因此,对于 Circular Small ,选项相似,也选择了两行文本的版本。
上面这个可读性差很多,但是 Circular Small 是非常通用的 complication 家族,因此基本上对所有使用者都是一个挑战。Apple 自己的 complications ,比如世界时钟,日出、日落,看起来也没有好多少。我们这里可以放弃 “PM” ,但这样一来又搞不清 app 是干什么用的,所以折中,把 “PM10” 缩短为 “PM” 。有可能上面用图标效果会更好,读者可以尝试一下。
现在,打开样板代码 ComplicationController
类,这里已经数据源协议所有要求的方法了,一些是空实现,但其中大多数我们并不需要。
注意,所有的方法都是通过一个 handler callback 返回数据的。这使得你可以通过某些异步的方式加载要求的数据 —— 理论上,你是可以按需在用到时再加载这些数据,但实际上我们绝不应该这么做。
所有的方法都传入一个 CLKComplication 对象作为参数,它让你知道系统现在正为哪一种 complication 向你询问数据,这个对象只有一个字段叫 family ,这意味着在一个 Modular 表盘上,你无法区分同族的两个 complication 实例,但是不同族的可以。
因此,这个信息绝对是必须的 —— 不仅因为不同家族外观看起来不一样,也是为了让编译器匹配你返回的模板类型。
CLKComplicationDataSource
协议里只有 getSupportedTimeTravelDirections
和 getCurrentTimelineEntry
两个方法是必须得实现的,但我们会先从一个可选的方法开始讲。
我们要看的第一个方法是 getLocalizableSampleTemplate
,在文件的底部 —— 你有可能会需要在把 complication 添加到表盘之前先实现这个方法。
这个方法让你返回一个 complication 的 “样例” 外观,它是当用户在表盘配置视图中设置 complication 时用到的。这里应当展示一些随机数据,表现你的 complication 一般情况下的外观,就像你在应用的网站或者应用商店上放的截图那样的东西。
在这个方法中,我们需要返回一个 CLKComplicationTemplate
对象 —— 在实际的 timeline 中,我们也会返回一样的东西。不过这里不指定时间戳。对于两种 complication 家族,我们都用标准的 CLKSimpleTextProvider
来封装返回的文本。 在样例模板里,我们用 “50” 来代替真实值。
下面是代码:
1 | func getLocalizableSampleTemplate( |
记得总是返回匹配给定 complication 家族的模板。不幸的是,好像没有可以在编译期检查这个过程的机制。
这里,我们为两种家族都选用了 “stack text” 模板,因此都有 line1TextProvider
和 line2TextProvider
属性。如果你选择另外的配置的话,可能的属性有 imageProvider
, headerTextProvider
, ringStyle
等等。
如果系统向我们请求其他我们不支持的 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 的地方有一个空白的空间 —— 别急,我们接下来就着手处理这块。
这个方法告知系统你的 app 在过去、未来、两个方向或者只有当前时刻拥有数据。因为之前提到过去的数据已经不再使用了,所以只有返回 .forward
或者空列表是有意义的。由于我们并不需要预测未来的空气质量,所以我们只需要返回一个空的列表:
1 | func getSupportedTimeTravelDirections( |
这里返回的东西决定了系统是否会调用 getTimelineStartDate
, getTimelineEndDate
, getTimelineEntries (for:before:limit:withHandler:)
和 getTimelineEntries (for:after:limit:withHandler:)
这些方法,以询问你 timeline 在两个方向上延展的长度,时点。如果我们返回 []
,那么系统只会询问当前时点。
不过这些方法都是可选的,所以如果你都不实现它们, watchOS 会假定当前时点没有什么有趣的东西。
这是整个协议核心的代码,它是我们返回最新数据点的地方。
timeline 数据是以一个或者多个 CLKComplicationTimelineEntry
对象返回的。一个 timeline 实体其实就是一个时间戳加上一个或者多个指派的数据 provider ,里面填充着你需要的数据。实体借由时间戳验证。
目前我们还没有实际拥有数据,不过别担心 —— 我们可以先返回一个静态数值,比如 75 ,就像样例模板中的做法一样。我们使用当前时间作为时间戳,因为根据前面方法返回的设定,我们不会被询问任何在当前时点之前的时段数据。
下面是 getCurrentTimelineEntry
的初始版本:
1 | func getCurrentTimelineEntry( |
当你添加了以上两个方法,编译运行你的 app 到模拟器。你应该会在 complication 里看到我们配置的模板和数值:
如果你还是没有看到效果,那可能是因为系统缓存了之前编译版本的状态。为了强制加载 complication ,你可以进入编辑模式,切到不同的 complication ,退出编辑模式。 然后再进入编辑模式,切回你的 complication 。
在数据源协议中还有一些其他的协议,但针对我们的用途,我们只需要用到 getTimelineEntries (for:after:limit:withHandler:)
。这个方法询问我们早前传入的 timeline 时点之后的时点。当我们写的 app 需要提前了解某个时点时,会用到这个方法。例如,天气预报,日历事件,todo list 上预定的任务等。不过,大部分 app 只需要显示当前实体就够了。
我们在这个 app 中使用这个 API 的作用是,我们很可能需要在时点过去之后将未来版本的数据标记为过时。如果你查看的是 6 个小时前的空气质量,它很可能是没什么价值的,因为当前的空气很有可能已经发生显著的变化。在 Krakow ,这种变化可能发生在 2 个小时内。例如,起风或者风停了。所以,我们可能在几小时后自动隐藏掉当前数值,借助添加一个几小时后的 “重置” 数据来实现。如果我们成功地在每个小时更新了数据,那么备选的第二个时点的数据永远不会被展示,但是如果有些东西出错了,那么当时间变化足够长,会在时点到来时借助这个 API 来更新数据。
我认为 watchOS 之前应该也是这么干的,至少在 Time Travel 功能里是这么做的 —— 文档里也提到了。不过这本该是 getTimelineStartDate
和 getTimelineEndDate
方法存在的意义 —— 但是由于这两个 API 不起作用 (Time Travel),所以实现它们也没意义。
对于第一个版本,我们用使用 Małopolska 地区空气监控系统的公共数据 (仅限波兰) 。
前端通过一个挺复杂的 POST 请求,发送到 URL http://monitoring.krakow.pios.gov.pl/dane-pomiarowe/pobierz ,然后解析返回的 Json 数据。
这个主题并不是跟 watchOS 特定相关,它是特定于 web API —— 所以这里不详细描述,下面是拉取和解析数据的完整代码:
1 | import Foundation |
不要忘记在最后用 resume ()
启动任务。
上面的代码用了老式的 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 | private let CurrentLevelKey = "CurrentLevel" |
最后,我们需要添加一个例外域名到 WatchKit Extension target 的 App Transport Security 设置中,因为这个域名不支持 Https 。
1 | <key>NSAppTransportSecurity</key> |
为了实际加载数据,我们需要在某个地方调用这个类的方法。我们来看一看 ExtensionDelegate
这个类,它实现了 WKExtensionDelegate
—— 基本上就是一个 WatchKit 版本的 UIApplicationDelegate
。就像所有的 app 代理, WKExtensionDelegate
有许多生命周期方法,这些方法会被系统在各种时刻调用: applicationWillEnterForeground
, applicationDidBecomeActive
, applicationWillResignActive
和 applicationDidEnterBackground
等等。
这里头我们目前唯一会用到的是 applicationDidFinishLaunching
。这个方法会在 app 进程启动时被调用 —— 无论是通过 app launcher 或者通过 Xcode ,又或者从后台启动。只要是 app 需要被唤起,并且之前已经被系统清理掉的时候,这个周期都会运行 (通常在晚上,被系统杀死的情况经常发生) 。
无论何时, app 启动或者在后台重启,我们都希望借助这个机会立即拉取最新的数据,如果我们得到响应,重新加载所有活动的 complication (活动的 complication 指那些在当前选择的表盘上显示的 complication)。
所以我们将这样做:
1 | func applicationDidFinishLaunching() { |
为了拉取数据,我们调用了 KrakowPiosDataLoader 类,然后在有任何新数据的情况下重载加载 complications ,否则的话就不必了。在 watchOS 上,不要浪费时间做无用功,这是一条通用的准则。
为了重新加载 complications ,我们得拿到活动 complication 的列表,这是借由全局共享的 CLKComplicationServer
实例来获得的,并且也通过它的 reloadTimeline (for:)
方法来重新加载那些活动的 complication 。如果打算在已经存在的 timeline 实体后追加新的 timeline 实体,我们也可以用另一个相似方法 extendTimeline (for:)
,两者的区别是前者我们希望立刻用新数据替换掉之前的数据。
1 | func reloadActiveComplications() { |
上面的代码会触发一轮对你的 CLKComplicationDataSource
的调用 —— 有的时候是一会之后,不过通常几乎都是立刻发生。现在我们有了真实数据,我们可以回到之前写的 getCurrentTimelineEntry
方法,然后把占位的代码替换成实际的逻辑:
1 | func getCurrentTimelineEntry( |
现在,当我们运行 app 时,点击 home 按钮返回表盘时,我们会看到一个刚刚借助 API 加载的真实数据:
1 | ExtensionDelegate: applicationDidFinishLaunching () |
最后的拼图是确保我们可以按照有规律的间隔加载新的数据并重新加载 complication 。有一些场景你可以更新 complications :
注意,不管你采用哪种策略,对于我们刷新数据的频率以及完成刷新的用时,有许多限制。 (比如,每天不超过 50 个推送通知) —— 如果你用尽了所有的时间或者每天可用的推送数量,你将无法再在后台运行,有可能要等到第二天。对于这点约束,看起来没有什么特别好的方案可以绕过,你也不应该尝试去寻找这类方案。
既然我们知道城市监测站每小时发送一次新的测量数据,我们会使用计划好的后台刷新来更新我们的 complication ,并且会在 ExtensionDelegate
中完成。
为了确保我们的 app ,我们需要实现一样我称为 “后台刷新循环” 的东西:当 app 启动或者重启时,我们安排一次后台刷新,然后当 app 被这个后台刷新唤起时,我们做的第一件事就是安排下一次后台刷新,以确保若干时间后总有新的刷新被计划。
我们会在所有其他事情之前开始做刷新计划,因为我们无法知道在我们的 app 被挂起或者杀死之前还有多少可用的时间。否则,如果在我们设置下一次刷新之前 app 就被挂起,那么 app 就相当于没设闹钟就睡过去了,那么它将会睡过头。 😉
现在,让我们再看一下 applicationDidFinishLaunching
方法,我们需要在 web 请求发送之前增加一个新的方法调用 scheduleNextReload ()
:
1 | func applicationDidFinishLaunching() { |
在计划下一次刷新前,我们首先需要定出下一次刷新的时机。
为了优化后台刷新的耗时,尽可能利用好珍贵的后台时间,思考清楚我们的数据究竟需要在何时和以何种频率改变。一个很好的例子是 —— 证券交易只发生在工作时间,不在工作时间内,股票价格不会变化,所以在夜间重载不会改变的数据是没有意义的。
我对获取数据的 API 做了一些测试,新的数据几乎总是 1 个整小时的 0 到 10 分钟内添加。所以我决定每小时请求一次刷新,总是在每小时的 15 分做这件事 (10:15 , 然后 11:15, 然后 12:15 ,以此类推)。为了实现这种方式,我们需要一个辅助方法来让我们基于当前时间找到最接近 xx:15 的时间 —— 幸运的是,利用 NSCalendar API 很容易做到:
1 | func nextReloadTime(after date: Date) -> Date { |
最后,为了在计算好的未来时点请求更新,我们需要在 WKExtension
(等价于 UIApplication
) 上调用 scheduleBackgroundRefresh
:
1 | func scheduleNextReload() { |
你传入的日期是你希望你的 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 | func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) { |
让 watchOS 模拟器运行 app 后台刷新任务需要一些技巧,即使你已经安排它们在一会之后运行。如果你测试时发现不工作,可以尝试随机切换 app ,主屏和表盘,直到后台刷新任务可以工作。
就这些,我们完成了!🎉 我们得到一个每小时运行的 app ,从 web API 加载新数据,显示到你选择的表盘上,你只要抬腕就能看到它。
依我看来,为了构建一个带 complication 的最小可行的 watch app ,你需要做这些事:
CLKComplicationTimelineEntry
和 CLKComplicationTemplate
对象,以合适的方式展示你的内容。如果你只是构建一个静态的 complication ,永远不更新,就像 Apple 的 “launcher” 型的 complications , 比如 Breathe , Maps , Reminders 等等。那么,你只需要做这些事:
这种情况下你不需要计划后台刷新以更新 complication ,因为它永远不变。不过,由于 complication 需要链接到真实 app 时,也有大量工作需要做。 😉
工程中的代码可以从这个仓库找到: https://github.com/mackuba/SmogWatch (master 分支最新代码,或者对应这篇文档的 post2 分支的版本)。 它是 WTFPL-licensed ,所以尽管拿去用,你可以分享给我你都做出什么好玩的东西!
]]>相比 macOS,iOS 和 tvOS ,watchOS (目前为止) 并非是完全独立的平台,一定程度上依赖配对的 iPhone 。
watchOS 6.0 之后,watch app 可以独立发布和安装,也就说,应用生态上可以独立了。但是,某些功能要想发挥最大的效用,还要借助 iPhone 设备的计算能力。毕竟,后者目前还是要强大很多。可以这么理解,当需要用到 watch 本身不具备的硬件能力时,如视频拍摄,你仍可以把 watch 视为控制器。这个跟人们看待早期智能手表的视角一致。
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 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 发布。因此,对于因特网的连接方式,最新的建议是 借助 URLSession ,CloudKit 等直接下载数据到 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 实际上提供了一些其他的入口来交互,它们可能更重要。
通知实际上是 watch 的一个绝佳的应用场景。花不到一秒的时间看一眼手表,比从口袋里掏出手机来省事不少吧?许多人会告诉你,他们戴 watch 的主要用途就是看通知。
但是,通知用的好不好,对不对,主要还是取决于你的 app 类型,通知的目的。比如,你的目的是不定期的通知用户某些事情发生了,通知可以是你的 app 很重要的一部分。典型的,提醒事项 app 。
watchOS 上通知的 UI 有三种变体:
watch OS 6 允许推送绕过 iPhone ,只到达 Apple Watch 的远程通知。
watchOS 1 开始,引入了一种被叫做 glance 的界面,卡片式,可点击,水平滚动。借助 storyboard 上单独的场景构建。
watchOS 3 开始,glance 被废弃,由 dock 取代,后者是通过按压表侧的长按钮访问。它的工作方式和 glance 相似,但是卡片的外观是基于主 app 的实际 UI (类似 iOS 上的体验),通过系统对 app 生命周期某些节点的快照来实现。当你完成滚动,选择了某个 app 后,系统会唤醒这个 app ,不久之后这个 app 实际的 live 视图会更新 dock 的静态图片。
watch OS 4 之后,dock 变成竖向滚动,跟 iOS 的体验更相似。
“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 了, watchOS 5 以后,Siri 可以用于更多的用例,例如发消息,todo list 等等。
ARKit 3 带给人们不可思议的体验。通过 People Occlusion,ARKit 3 知道人们和 AR 对象在哪里,并适当地遮挡 scene。ARKit 3 使用 Motion Capture 跟踪人们的运动,将其输入到 AR scene 中。它还可以同时跟踪最多三张人脸,支持 collaborative sessions 等等。
现在 AR 内容能呈现在人们的身后和前面,使 AR 体验更加沉浸化。同时,在大多数环境能实现绿幕风格的效果。
用一台相机实时捕捉人们的运动。通过将人们身体的位置和运动,理解为一系列关节和骨骼,您可以使用运动和姿势作为 AR 体验的输入 —— 将人们放置在 AR 的中心。
现在,你可以在前置和后置摄像头上同时使用人脸和世界跟踪。例如,用户可以只使用他们的脸部,与后置摄像头的 AR 内容交互。
现在 ARKit 人脸跟踪可以一次跟踪最多三张人脸,使用 iPhone X, iPhone XS, iPhone XS Max, iPhone XR 和 iPad Pro 的 TrueDepth 摄像头。该摄像头为 Memoji 和 Snapchat 等前置摄像头的体验提供能力。
通过多人之间的实时协作会话,您可以构建一个协作的 world map,让您更快地开发 AR 体验,让用户像进入多人游戏一样,更快地进入共享的 AR 体验。
一次检测多达 100 幅图像,并自动地估计图像的物理大小。3D 对象检测更加稳定,因为对象在复杂环境中可以更好地被识别。现在,机器学习被用来更快地检测环境中的平面。
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 内容。
导入您自己的 USDZ 文件,或者利用内置 AR 库中数百个现成的虚拟对象。该库为各种 assets 利用 procedural content generation ,您可以自定义虚拟对象的大小、样式等。
增加移动、缩放等动画,比如虚拟对象的 “摆动” 或 “旋转”。当用户点击对象、靠近对象或触发其他触发器,您可以选择要进行的操作。您还可以利用 spatial audio 为您的 AR 场景添加真实感。
Reality Composer 被包含在 Xcode ,也是一款 iOS APP 。因此您可以在 iPhone 或 iPad 上 build 、 test 、 tune 和模拟 AR 体验。利用 live linking ,您可以在 Mac 和 iOS 之间快速切换,创建吸引人的复杂的 AR 体验。
有了 iOS 版的 Reality Composer,您可以记录 AR 体验运行位置的传感器和摄像机数据,然后在构建 APP 时在 iOS 上播放这些数据。
这个全新的高级框架是从头开始创建的,专门用于 AR ,包括 photo-realistic 渲染、相机特效、动画、物理等等。它还有一个 Swift API 。使用集成的 ARKit 、基于物理的渲染、变换和骨骼动画、 spatial audio 和刚体物理, RealityKit 使 AR 开发比以往任何时候都更快、更容易。
使用基于物理的真实材质、环境反射、 grounding shadows 、 camera noise 、 motion blur 等,将虚拟内容与现实世界无缝融合,使虚拟内容与现实世界几乎无法区分。
RealityKit 利用最新的 Metal 特性充分利用 GPU ,充分利用 CPU 缓存和多核,提供难以置信的流畅的视觉效果和物理模拟。因为它自动地伸展每个 iOS 设备的 AR 体验的性能,所以您只需要创建一个 AR 体验。
容易使用但功能强大的 RealityKit 使用 Swift 丰富的语言特性并提供完整的功能。因此您可以更快地构建增强现实体验,而无需抄模板代码。
RealityKit 使网络开发工作变得简单,例如保持一致的状态、优化网络流量、处理数据包丢失或 performing ownership transfers 。
最早寻求事物的自然或俗世解释而非神的解释的思想家,是古希腊学者泰勒斯 (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) 。
伽利略 (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) 。
第一次世界大战过后,由于不满于德国那群专注于知识的第一原理和世界的本质的唯心主义者的 形而上学 (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) ,其主张科学是为了产生基于大量经验的理论。知识需要观察,但不可观察的实体也是允许的。接受一个科学理论并不意味永远认同 —— 这是对世界的真实表述。建构经验主义者认为,只要在观察范围内,即可接受科学主张为真实。该命题是否真实代表了不可观察的实体,我们无需判断,这只是目前对观察结果的最佳解释,仅此而已。
为了知道为什么我们需要科学方法,让我们来看看在日常生活中人们的认知基于什么。
人们可能会基于 直觉 (intuition) 或 信念 (belief) 而认为某事是正确的。
让我们来看下我对我的猫 Misha 所拥有的坚定信念:我相信 Misha 在所有人中最爱我 —— 我就是知道他爱我胜过其他所有人,我能在内心深处感受到。那么这种信念算不算是知识的坚实基础?并不是,简单地相信某件事并不会让它成真。我们坚信不疑的事情可能最后被证明是假的。还有,如果有人持相反的观点会怎样?如果我的未婚夫认为 Misha 爱他多一些呢?
仅仅通过较量我们的观点,是无法得出谁对谁错的。
我们可以数每个观点的支持人数,然后得到一个大部分或是 一致同意的观点 (consensus) ,但是这也不能作为知识的坚实基础。仅因为大部分人认同某事是并不意味着它就是真的。几世纪以来,几乎人人都认为地球是平的,但结果证明他们错了 —— 地球是圆的。
另一个知识的来源就是 权威 (authority) 的观点。这也不是一个很好的来源。一些诸如政治领袖、专家、科学家等权威的观点,也只是一种观点。权威或许能接触到更多或更好的知识,但他们出于个人利益也会推动自己的观点被大众接受,他们的职业和名誉都依赖于此。假设我的未婚夫找到了一个懂猫语的人,那人声称 Misha 更爱我的未婚夫。当然我会对这一专业观点表示怀疑,尤其当他是受我未婚夫雇佣的时候。我可以找到我自己的猫咪专家来和未婚夫的猫语者打擂台。但这时我们又有两个相反的观点了 —— 我们需要的是 证据 (evidence) 。
那么我们该如何用证据来解决 Misha 更爱谁的争论呢?
假设我总是 观察 (observe) 到每次下班回家后 Misha 总会过来坐在我的腿上而不是我未婚夫的腿上。我就运用了对客观世界,也就是对下班后 Misha 坐谁的腿上的 观察 (ovservation) 来证明我说的 Misha 更爱我这个 命题 (statement) 。
以 随机观察 (casual observation) 取证得到的认识,比前面其他方法了解到的认识更有根据一些,但这仍然不够好。 因为人们并不十分擅长观察 。我们倾向于 选择性观察 ,并且记住与我们观念相符的事情。比如我可能恰好忘记了, Misha 在早饭的时候总是坐在我未婚夫的腿上。
除了选择性知觉外还存在许多 偏见 (bias) ,会让随机观察成为一个棘手的认识来源。我们运用 逻辑 (logic) 的能力也是如此。 逻辑推理 (logical reasoning) 得出的认识看上去是有坚实基础的,但 非形式逻辑推理 (informal logical reasoning) 并不总是具有一致性。人们在日常生活中进行推理时,总会不停地出现 谬误 (fallacy) 或 逻辑矛盾 (logical inconsistency) 。
如果想获得更准确的认识,确定我们对世界的解释是正确的,那就还需要点别的东西。我们不能依赖于主观的、无法证实的来源 —— 诸如信念、观点、舆论。我们也不能相信随机观察和非形式逻辑,因为它们可能被我们的信念严重扭曲。
我们需要 系统观察 ,摒弃任何偏见,辅以一致的逻辑。换言之 我们需要 科学方法 。
当我们试图解释世上事物运行原理时,为了确保知识有效,我们需要科学方法,而不是依靠观点、信念、随意观察和非形式逻辑。它们都太主观且容易出错。
科学方法基于 系统观察 (systematic observation) 和 逻辑一致性 (consistent logic) 。使用科学方法,增加了我们得出正确解释的机率。同时,我们也可以通过科学方法鉴定 科学主张 (scientific claim) 、 假说 (hypothesis) ,以及鉴定在我们实证研究中用来支撑假说的那些 经验证据 (empirical evidence) 的说服力。
科学方法有六大原则科学方法有六大原则。如果我们的研究符合这些原则,就能归为科学研究。
这个假说亦可以与其他科学论断一较高下,为我们的世界提供尽可能最好的解释。
第一条原则是:假说应该可以 在实证中检验 (empirically testable) ,即支持或反驳假说的经验证据、物证或观测结果都是可以收集的。
比如我假设家里的猫爱我多于我未婚夫。要实证检验这个假说,我们需要收集观察结果或数据。但如何观察这只猫对我们的喜爱程度呢?我们不能询问猫的看法。假设我们都认为猫是无法像人类那样表达爱意的,那就没什么好观察的了。所以这条假说不符合实证可检验的原则。
第二原则是:可重复性 (replicability) 。一项研究及其发现结果都应该具有可重复性。这意味着重复原研究是可以得到一致结果的。
如果预期结果只出现了一次,或出现次数极少,那这个结果可能只是巧合。如果一个假说能被重复确认,它会更为可靠。所以一项研究需要经受住重复和复制的考验。比如我说服未婚夫认同 “猫在谁腿上呆得久就更喜欢谁” 这个评判标准。现在我观察到这周猫趴在我腿上的时间,是它呆在未婚夫腿上时间的两倍。那意味着我的假说是正确的吗?猫咪就爱我多一点吗?如果接下来几周这个观察结果保持不变,那么我的假说就是靠谱的。但如果观察了一周这只猫就死了怎么办?如此一来我们就无法验证这个假说了,这个研究再也无法重复。
要验证结果是否可重复,我们必须按原研究的条件和过程进行重复。假如我们处理过程不同,于是得到了别的结果,这表明原研究不可重复吗?答案当然是否定的。重复失败可能是由于我们改变了程序。
第三原则是 客观性 (objectivity) ,指他人能自行重复该实验而无需求助原研究者。“客观” 的字面意思是谁来进行研究不重要。基于假说跟其程序的描述,每个人都应该得到相同的结论。因此研究者提出的假说、概念、程序应尽量客观。这要求清楚明确地定义所有研究元素,不给主观解释留任何余地。
假设我认为猫咪拿脸蹭我是示爱的表现,但我没有明确告诉我未婚夫这一条,那么我衡量爱意的程序就是主观的。即使我们同时对猫进行系统性观察,结果仍然会因观察者而异。较之我未婚夫,我会认为猫示爱次数更多。这个例子中的结论是主观的,因此不具有可比性,而我们经常对此毫无自觉。如果我们没有明确讨论并就示爱标志达成共识,那么我们的猫咪爱意衡量程序则不够客观。
第四原则是 透明性 (transparency) 。保持透明度与客观性原则密切相关。在科学界,不论是你的支持者还是批评者,任何人都应该可以重复得出你的结论。这要求研究者共享他们建立的假设 —— 如何定义概念、使用了什么研究程序,还有一切和进行精确复制相关的所有信息。
第五原则指出一个假说应该具有 可证伪性 。可证伪性是一个非常重要的原则。如果你能想象出一种情形会使观察与假说出现矛盾,那么它就是可证伪的。如果我们想不出有什么样的反例存在,那这个假说就不可能被推翻。
比如你问一个有坚定宗教信仰的人,有什么证据可以证明他们的信仰是虚假的?不管你提出什么样的反例,他们可能都要争辩。这些事实并不违背自己的忠诚信仰。这种仅源于纯信念的观点,比如宗教,是不属于科学范畴的。如果对任何形式的证据,该假说都不接受其为反例,那这个假说根本没有讨论意义,也不用再寻求证实,因为结论已经在那里了。
我们要讲的第六点也是最后的原则是 逻辑一致性 (logical consistency) 。一个假说应该保持逻辑上的一致或连贯。这是指假说不会有任何自相矛盾的地方。比如 ,一个支撑假说的子假设是否与假说冲突。
我们观察得出的结论也应该保持逻辑一致性。这就是说,在整个过程中,研究者对正面或反面证据的标准应该一致。
让我继续用猫的例子进行说明。我的假说是猫更爱我,所以预测他会在我腿上呆更久。但要是它趴在我未婚夫腿上更久呢?我会说猫能察觉到趴在我腿上让我不舒服,因为他爱我多一些所以照顾我情绪便趴得少了。显然,这就是逻辑不一致。为了让观察结果符合我的假说,我对观察结果进行了 “再解读”。顺便一提,这也会让我的假说无法证伪。不管猫是否常趴我腿上,我都会得出它爱我的结论。
总结一下,科学方法要求我们构造的假说为实证可检验的,这就是说观察结果可以支撑或反驳假说;可重复性,也就是假说是可以被重复测试的;客观性,指他人可以独立检验这个假说;透明性,指假说与结果都公之于人,以便他人检验;可证伪性,指找到反例的可能性是存在的。最后,逻辑一致性是指假说本身保持内在的一致性,支持或反驳假说的观察结论也应逻辑一致。
最后一点,科学方法只在态度端正的情况下才有效。为了提出更好的假说,研究者们应该对自己和他人的研究持批判态度,所以他们应该做到公开透明、乐于接受批判。如果别人有更好的解释,就放弃他们心爱的假说。如此,科学界才能像进化系统一样 —— 只有最合适、最可靠的假说存留下来。
我已经讲过了有关我们周围世界的命题、假说以及解释,我没有准确解释就用了这些通用术语。是时候阐明清楚了。
关于周遭世界的科学主张,可分为不同类别。一些科学主张会比别的主张描述或解释更多现象。同样,某些科学主张对于我们周围的世界,命题、假说以及解释提供了更可信的描述或解释。我们发现某些主张会更准确一些,比起别的来有更多证据支持。
在科学中,最基本的主张是 观察 (observation) 。观察可以是对世界准确或不准确的 表达((representation) 。
假设我观察我的猫:姜黄色毛,重 6.5 公斤。大多数科学家会接受这个观察结果,作为我们周围世界某一方面较为准确的投射 —— 假设体重秤有效且可靠的。但就解释力度而言,他们会发现这个观察非常无趣 —— 观察本身没有很大信息量,它不能描述属性间的一般联系,无法解释任何事。
但这不表示观察不重要。观察是经验科学的基础,但是观察本身不是很有用,观察本身是最无趣的科学主张,因为它没什么解释能力。观察在确定或反驳假说时会变得有用。
假说 (hypothesis) 是一种 命题 (hypothesis) ,它描述了 属性 (hypothesis) 间的 模式 (pattern) 或一般关系。假说也可以对它描述的模式进行解释。我们建立这样一个假说:姜黄色猫一般都会超重,概率比其他颜色的猫要高得多。我可以用解释来延伸这个假说,就是毛色和肥胖间的相关性。比如,通过证明控制姜黄毛色和发出饱腹信号的基因是连锁的。
假说的 可信度范围 (plausibility) 可以从非常不确定到非常确定。一个假说若是没有支持,那么它就是不确定的。比如这是个新且未经检验的假说。假说也能被很多实证研究强烈支持,从而变得更确定。
假设的一种特殊类型就是 定律 (law) ,定律是对 关系 (relation) 或模式非常精确的描述 —— 精确到总是能用 数学公式 (mathematical equations) 表达。它们通常被证明得很完整,所以它们如此精确。比如,我把猫食盆从 56 米高的地方扔下去,且我知道万有引力常数,然后用牛顿的万有引力定律就能很准确地预测这个碗掉到地上要花多长时间。
定律能做出很精确的预测 但它们通常不会解释其描述的关系。在这里,是距离、时间和重力之间的关系。当然,社会科学方面的定律很少能表达成公式。我们对个人和人群的了解还太少,还不能得到如此精度的固定行为模式,并用来推测出科学定律。
好,接下来我们来讲讲 “理论 (theory)” 的概念。在日常生活中,理论意味着 未被证明的 (unsubstantiated) 命题,有根据的猜想。但是在科学中,理论指的是许多相关现象的总体解释。在自然和行为科学中,理论由被经验证据强烈支持的各种假说构成。在社会科学中,更多的是定性研究和历史比较的方法。当理论经受住了逻辑上、历史上或定性分析的驳斥时,可以认为该理论高度可信。
在科学范畴里,理论的解释最为完善,是我们拥有的最接近准确的东西,因为它们由经科学方法审视并留存的假说构成。当然,这不意味着科学理论是确定或真实的。世上有许多证明完备的理论最终也被取代了,比如牛顿力学就为相对论让路了。 科学的世界里没有确定性,只有暂时的最佳解释 (provisional best explanation)。
回忆一下此前提到过的庭审的例子。被告方的辩护律师的观点是被告是无辜的,公诉方则试图说服陪审团和法官被告是有罪的。举证有罪的责任在于原告。被告只有在原告提供有力证据驳斥被告假定无罪的情况下才能被认定为有罪。
在审判时,有四种可能的结果。一,被告确实有罪且被判有罪,这是个正确的决定。二,被告确实无辜且被判无罪,这也是正确的决定。三,被告实际上是无辜的,但被判有罪,这是错误的决定。四,被告实际上有罪的,但是被判无罪,这也是错误的决定。
这也是我们在实施显著性检验时会发生的情况。辩方观点类似零假设为真,而被告有罪则等效于零假设为假。判被告有罪类似拒绝零假设,而无罪释放则等同于不拒绝零假设。这会导致四种可能的情形。其中的两种,你做了正确的决定,包括零假设的确为真并且你没有拒绝它以及零假设的确为假并且你拒绝了它。但也有两种你做了错误的决定,包括零假设为真而你拒绝了它以及零假设的确为假而你没有拒绝它。第一个错误我们称为 第一类错误 (type I error) ,或者说 伪阳性 (false positive) 。第个错误我们称为 第二类错误 (type II error) ,或者说 伪阴性 (false negative) 。
让我给你举个例子。想象你的零假设是:美国的持证水肺潜水者总体, 50% 有超过 35 小时的潜水经验。换言之, $ \pi = 0.5 $ 。备选假设是它是另外一个百分比,换言之, $ \pi \neq 0.5 $ 。你问了一组简单随机抽样的 500 个美国潜水者,你发现有 0.56 的比例有超过 35 小时的潜水经验。现在,假定你的零假设实际上是真的,当你决定基于你的样本数据拒绝零假设时,一个第一类错误就出现了。
如果零假设为真,抽样分布是像下面这样的:
如果你的显著性水平 $ \alpha $ 等于 0.05 ,通过查询 z 表得到临界值是 -1.96 和 1.96 。你的检验统计量落在拒绝域内。换言之,你要拒绝零假设。这件事情发生的概率是 0.025 加上 0.025 ,等于 0.05 。意味着第一类错误发生的概率等于显著性水平。
由此,你可能想到要降低显著性水平。
但是,这不一定是个好主意。如果你在零假设实际为真时降低了错误地拒绝它的概率,你实际上增加了零假设实际为假而你错误地没有拒绝它的概率。
犯第二类错误的概率我们称为 $ \beta $ 。
计算 $ \beta $ 相当复杂,它依赖各种因素,例如 $ \alpha $ 的值,样本容量以及参数的真实值。基于这个原因,我们并不会去计算 $ \beta $ 的值,但重要的是你需要意识到,当我们试图降低某一类错误的概率时,另一类错误的概率会上升。
当零假设为假时,并且你实施了检验,你希望检验的 功效 (power) 是高的。检验的功效是拒绝零假设的概率,给定它为假,换言之,一个检验的功效等于 1 减去第二类错误的概率,也就是 $ 1 - \beta $ 。
为什么功效这么重要呢?是这样的,当你要实施一项研究之前,它可以帮助你确定你需要多少的参与者。在你实施完研究之后,它能帮助你确定结论不是统计显著的。
最后一个提示,在实践中,你永远无从得知某个决定正确与否。我们唯一能做的是控制做出不正确决定的概率。
想象你是一个对鲸鲨感兴趣的潜水者,你想要知道这些巨大的动物平均的身长有多少。我们还假设你已经花费了很多年在世界各地研究了这些生物。这些年你已经测量了 258 头鲸鲨。因为你已经测量了世界各地的鲸鲨,我们假定这 258 头鲸鲨可以被看作一个简单随机样本。平均的长度等于 8.3 米,样本标准差是 3.4 米,并且鲸鲨长度的分布也近似正态分布。
在这一节中,我们将检验三种备选假设和一种零假设:鲸鲨总体的长度均值等于 8 米。第一个假设是总体均值不是 8 米。第二个假设是均值大于 8 米,第三个假设是总体均值小于 8 米。所有这几种情况中,我们都把显著性水平设为 0.10 。
首先,我们得检查我们的假定。如我之前说过的,鲸鲨的选择可以看作是简单随机抽样,并且我们也看到鲸鲨身长的分布近似正态。因此,我们没有理由预期总体分布会和正态分布差异巨大。再者,这也不是个问题,因为我们的样本量相当大。
现在,让我们计算检验统计量,它的值对于几个假设都是一样的,毕竟,样本均值和零假设一样。
代入公式, 8.3 减去 8 ,除以 3.4 除以 258 的平方根,等于大约 1.42 。
现在,我们开始第一个备选假设,它断言总体均值不是 8 米。我们画出相关的抽样分布,并显示零假设的值。我们需要基于 0.01 的显著性水平做双尾检验,查询 t 表格得到临界值 -1.66 和 1.66 ,检验统计量等于 1.42 不在拒绝域内因为我们不拒绝零假设。这意味着基于 0.10 的显著性水平,我们不能得出总体均值不是 8 的结论。
第二个备选假设是总体均值大于 8 。抽样分布一样,但这一次我们做右尾检验。查询 t 表格得临界值是 1.29 ,这一次检验统计量是落在拒绝域内。因此在这种情况下,我们拒绝零假设,并且下结论总体的均值的确大于 8 。
最后的备选假设是总体均值小于 8 。在这种情况下,我们做左尾检验,它是右尾的镜像,所以相关的临界值是 -1.29 。现在我们的检验统计量是 1.42 ,对于临界值时一个极端值,但它在分布的另一边。这意味着,它也不在拒绝域内,因此我们也不拒绝零假设。
最后的例子显示,画出抽样分布很重要。否则,你可能会无法注意到检验统计量相对于临界值处于分布的另一边。不论检验的结果如何,有两件事是可以确定的。第一,鲸鲨真的很大。第二,教程即将结束,我要放假啦~感谢阅读!
比较以下两种期望。一,你期望超过半数的持证美国潜水者有超过 35 小时的潜水经验。二,所有持证美国潜水者的平均潜水时长超过 35 小时。第一眼,两个期望看起来很相似。但是,在第一个例子中,你面对的是比例,你感兴趣的是潜水经验超过 35 小时的潜水者的比例。而第二个例子中,你关心的是均值。你想知道潜水时长的均值。因此,当实施显著性检验时,你需要特别注意你的方法。
这一节中,我将以分布计划的方式来引导你。想象你问了一个容量是 500 个持证潜水者的简单随机样本,他们的潜水时长是多少个小时。假设你发现 0.57 的比例有超过 35 小时的潜水经验,时长均值是 35.5 小时,均值是 8 小时。在我们的样本中,
潜水经验的变量分布近似正态。下面是分布计划全图:
第一步,评估你面对的是比例还是均值,这个我们已经讨论过。第一个例子是比例,第二个例子是均值。
第二步,形式化你的假设。在比例的例子中,零假设是:$ \pi = \pi_0 $ ,在均值的例子中,零假设是 $ \mu = \mu_0 $ 。我们可以有三种类型的备选假设:如果你做双尾检验,是 $ \pi\neq\pi_0 $ 或者 $ \mu\neq\mu_0 $ ;如果你做单尾的右尾检验,是 $ \pi\geq\pi_0 $ 或者 $ \mu\geq\mu_0 $ ;如果你做单尾的左尾检验,是 $ \pi\leq\pi_0 $ ,$ \mu\leq\mu_0 $ 。我们零假设是: $ \pi=0.5,\mu=35 $ ,备选假设是 $ \pi\geq0.5,\mu\geq35 $ 。因此我们需要实施右尾检验。
第三步,检查你的假定是否满足。在两个例子中,随机化都是必要的。你的样本必须通过随机抽样的方法收集,或者说,随机化的实验。在比例的例子中,根据零假设的比例,样本容量乘以比例以及 1 减去样本容量再乘以比例,必须等于或者大于 15 。均值的例子则要求总体分布近似正态。但实践中,这一点只有样本容量很小,且做的是单尾检验时才重要。对于我们的例子,所有的假定都满足。
第四步,确定显著性水平 $ \alpha $ 。常用的显著性水平是 0.05 。我们的检验将基于 $ \alpha=0.05 $ 。
第五步,计算检验统计量。在比例的例子中,公式是 $ z = \frac {p-\pi_0}{se_0} , se_0 = \sqrt {\frac {\pi_0 (1-\pi_0)}{n}} $ ,在均值的例子中,公式是 $ t = \frac {\bar x-\mu_0}{se_0} , se_0 = \frac {s}{n} $ 。注意,在比例的案例中,我们使用 z 分布,而在均值的案例中,我们使用 t 分布。
第六步,抽取相关的抽样分布,展示零假设和检验统计量,补上拒绝域和对应的临界值。在比例的案例中,
第七步,评估你的检验统计量是否落在拒绝域内。
第八步,决定是否拒绝零假设。
第九步,解释你的发现。
在下结论之前,值得提醒的是,不拒绝零值假设并不暗含你就可以接受零值假设。在第二个例子中,我们不拒绝零值假设,即潜水时长等于 35 小时的假设,但并不能得出潜水时长就等于 35 小时的结论。
假设你问样本容量为 500 的水肺潜水者他们潜水了多少个小时,均值是 36 小时,标准差是 8 小时,变量的样本分布近似于正态。基于样本信息,你希望推断总体的参数 $ \mu $ ,这是我们所知的推断统计学 —— 基于样本信息得出样本所在总体的结论。
推断统计学有两种方法。其一,通过均值的置信区间来推断区间估计。其二,用显著性检验来推断点估计。在这一节中,我将向你展示这两种方法其实关联密切。
假定你预期潜水时长的均值不是 35 小时,你将做一个显著性检验。我们对均值感兴趣,检验统计量如下:
零假设是: $ \mu = 35 $ ,备选假设是: $ \mu \neq 35 $ 。我们的假定满足,分析基于简单随机样本并且样本足够大,并且样本近似正态分布。检验统计量等于 36 减去 35 ,除以 8 除以 500 的平方根,等于 2.80 。抽样分布看起来是这样的。
我们可以通过查询 t 表找到对应双尾检验显著性水平 0.05 的临界点是 $ \pm1.984 $ 。因此我们的检验统计量落在拒绝域内。我们将拒绝零假设,得出潜水时长不等于 35 小时的结论。
现在,如果我们构造 95% 的置信区间,会发生什么呢?公式如下:样本均值,加减 95% 置信水平对应的 t 分数,乘以标准误差,这个标准误差等于标准差除以样本容量的平方根。相关的 t 分数是 1.984 ,代入公式,得到置信区间是 35.29 到 36.71 。由此我们有信心说,通过无限重复的抽样, 95% 的情况下区间会包含实际的总体均值。这个区间给了我们关于总体均值的一个有说服力的范围。和显著性测试一样,这个置信区间也告诉我们,总体的样本均值不是 35 。通常,双尾显著性检验的结果与置信区间的结果是一致的。
更准确的说,如果双尾显著性检验的 P 值等于或者小于 0.05 ,那么 95% 置信区间也不包含零假设的值。类似的,如果双尾检验的 P 值大于 0.05 ,那么 95% 置信区间将包含零假设的值。
这听起来很合理,对吧?它以下图表示。你会看到,观察值 36 落在拒绝域内,而对应的置信区间也不包含零假设的总体均值。
现在假设观察到的均值是 35.5 ,而不是 36 ,这样的话,我们的检验统计量将变成 1.40 ,它不落在拒绝域内。我们因此不拒绝零假设,相似的,置信区间的两个端点编程 34.79 和 36.21 ,则包含了零假设的均值 35 。
我们可以有信息说,通过无限重复抽样, 95% 的情况区间会包含实际的总体均值。这意味着零假设有说服力,我们不该拒绝零假设。也说明了,构建置信区间的方法和双尾假设检验的方法虽然看起来不同,但是数学上是相关的,彼此一致。
教程来到最后一个模块,很快我要放假了。明天我将背起行囊,出发去潜水。好吧,让我们再谨慎一点,我并不完全确定明天我能成行。我的航班可能延误,我可能睡过头误了航班,或者可能生病导致无法去潜水。总之,我期望明天出发,但我并不能完全地确定。或者说,不能 100% 确定。
当研究人员对于他们感兴趣的参数有所期待时,我们在讨论的是 统计假设 (statistical hypotheses) 。这一节将介绍统计假设。他们构成了 显著性检验 (significance testing) 方法中最主要的部分。一个统计假设,其实就是一个关于总体的期望。通常,假设会被形式化为一条对总体参数持有特定值或者落在特定范围的声明。这种声明是基于研究或者理论。基于样本的信息,我们评价一个假设靠谱与否。这个过程我们称为显著性检验,它是一种用样本数据来检验提前形式化的假设的方法。就像置信区间一样,显著性检验是一种推断统计学的方法。毕竟,我们也是用样本数据来推断关于总体参数的结论。
我们先来看 零假设检验 (null-hypothesis testing) ,在这种检验中,显著性检验基于两个假设, 零假设 (null hypothesis) 和 备选假设 (alternative hypothesis) 。零假设以 $ H_0 $ 注记,备选假设以 $ H_a $ 注记。零假设断言你感兴趣的参数是某个特定值。它通常代表变量之间没有关联的情况,或者组与组之间没有差异的情况。它是一个当你的样本数据表明它不太可能发生时需要被拒绝的假设。而备选假设断言你感兴趣的参数落在另一个范围。通常,零假设和备选假设互斥。如果你做显著性测试,假设零假设为真,除非你的数据有很强的反面证据。
想象一个法庭的庭审。被告方的辩护律师的观点是被告是无辜的,公诉方则试图说服陪审团和法官被告是有罪的。举证有罪的责任在于原告。被告只有在原告提供有力证据驳斥被告假定无罪的情况下才能被认定为有罪。这正是显著性检验里发生的事情。辩护无辜相当于零假设,而有罪预期则等同于备选假设。
在研究实践中,你对于参数的期望是以备选假设的方式出现,而零假设就是对立面,但它必须是一个单值,不能是一个范围。你只有在数据提供强力佐证时才能认定零假设成立。
举个例子,假设你有理由相信全体美国人中有 3% 有过水肺潜水的经历。那么这里的零假设和备选假设分别是什么呢?你的期望被视为备选假设。我们把它写下来, $ H_a:\pi\leq0.03 $ ,零假设是对立面,但必须表示为单一值,因此 $ H_0:\pi=0.03 $ 。
假设你对水肺潜水的最大深度很感兴趣。你有理由期望美国潜水者的最大潜水深度均值 不是 25 米。你的备选假设是 $ \mu\neq25 $ ,零假设是 $ \mu=25 $ 。很简单,不是吗?
记住这条:在显著性检验中,你总是假定你的零假设成立,如果你为备选假设找到足够的支撑,就拒绝零假设。如果你没有找到足够的证据,你就不能拒绝它。但没能拒绝零假设并不意味着零假设就是真的。你可以拿法庭的例子再回味一下。在庭审中,被告被假定无罪。如果有足够的证据证明他或者她有罪,则定罪。没有足够的证据,被告则不会被定罪。但这并不意味着你可以得出他或者她是无辜的结论。
假定你对有多少美国人有过水肺潜水经历这个问题感兴趣,你也有理由相信少于 3% 的美国人有这种经历。这意味着你的备选假设是 $ \pi\leq0.03 $ ,你的零假设是 $ \pi=0.03 $ 。
这一节中,我们将学习如何在对比例感兴趣时实施显著性检验。我们是这样来实施检验的:先假定我们感兴趣的总体参数有某个值,在我们收集到来自总体的样本后估计这个值的可能性。因为我们看到是一个样本,所以聚焦在抽样分布。我们可以决定,比如给定总体比例是 0.03 时样本比例的抽样分布。看下图,我们这样来实施检验:评估标准差(因为面对的是抽样分布,所以是标准误差),样本观察到的比例远离总体比例,这个标准误差的数值我们称为 检验统计量 (test statistic) 。
想象我们抽取了 1000 个美国人,受试者中有水肺潜水经历的人比例等于 0.02 。接下来,我们这么做:
你看到一个样本比例的抽样分布,我们假定零值假设为真,总体比例确实等于 0.03 。那么一个样本比例为 0.02 的总体,有多大的可能性其比例真的是 0.03 呢?为了回答这个问题,我们计算检验统计量,或者说样本统计和假定的总体参数之间的偏移幅度。标准误差远离均值的数量用 z 分数表示,我们可以计算样本统计量距离总体均值有多少个 z 分数。 公式如下:
先计算零假设的标准误差,它等于 0.03 乘以 0.97 再除以 1000 ,取平方根,大约是 0.005 。因为我们的检验统计量是 0.02 减去 0.03 ,除以 0.005 ,等于 -1.85 。这意味着当零假设为真时,我们的样本比例落在总体比例 1.85 个标准误差之下。 这是否足以拒绝零假设呢?
基于这个信息,我们可以查询 z 表格,对应的概率值是 0.0322 ,这个概率值我们称为 P 值 (P-value) 。 P 值告诉我们,基于总体比例是 0.03 的前提,要找到一个比例是 0.02 的样本,可能性是很低的。但是否低到可以拒绝零假设了呢?这取决于我们选择 显著性水平 (significance level) 。在我们实施检验之前,我们需要决定 P 值要达到多小以拒绝零假设。最常用的显著性水平是 0.05 ,这时我们说样本提供了足够的证据拒绝零假设。我们的 P 值是 0.3222 ,小于 0.05 。所以如果我们把显著性水平设置在 0.05 ,我们需要拒绝零假设。这也被我们称为 拒绝域 (reject region) 。
形成拒绝域边界的临界 z 值是 -1.64 ,你可以通过查询 z 表格得到它,它对应 0.05 概率的左尾。我们的检验统计量是 -1.85 ,落在拒绝域内。因此我们需要拒绝零假设,并且得出结论:美国有水肺潜水经历的人的比例低于 0.03 。我们说,这个结论是 统计显著的 (statistically significant) 。
在这个例子中,我们的检验基于备选假设是 $ \pi\leq0.03 $ 。因此,我们只聚焦在抽样分布的一边 —— 左边。这叫做 单尾检验 (one-tailed test) 。那如果我们的备选检验是 $ \pi\neq0.03 $ 呢?如果是那样的话,我们将不再只聚焦在分布的左边,而分布的两边。这种检验叫做 双尾检验 (two-tailed test) 。
如果我们还是采用 0.05 作为显著性水平,这意味着左边对应的累积概率是 0.025 ,右边也是。同样可以查询 z 表得到,对应的拒绝域的临界点分别是 -1.96 和 1.96 。现在,我们的检验统计量 -1.85 不再落在拒绝域内,意味着我们不能再拒绝零假设 $ \pi = 0.03 $ 。这说明,选择单尾或者双尾检验,对于结论有重大的差别。实践中,双尾检验要常用的多。我的建议是,只有你有非常好的理论依据时才使用单尾检验。
现在,让我们来改变显著性水平,看看会发生什么。比如,我们可以显著性水平设置为 0.01 ,这意味着我们在 P 值小于 0.01 时拒绝零假设。如果做单尾检验, 0.01 的显著性水平对于 -2.33 。
在我们的例子中,检验统计量没有落在拒绝域,因此不拒绝零假设。
如你所见,选择单尾或者双尾检验,会强烈地改变结果。需要记住的是,大部分单尾或者双尾检验都是基于 0.05 的显著性水平。
你好奇潜水者会在水下待多长时间吗?这个时间取决于他们的氧气罐,经验,潜水深度以及许多其他因素。假设你有理由期望美国潜水者在携带平均水平的氧气罐下潜到平均深度,可以待在水下超过 60 分钟,并且假设你也接触到了 100 个有经验的美国水肺潜水者,测量了他们在携带平均水平的氧气罐待在平均深度下的时长。这个样本的均值是 62 分钟,标准差是 5 分钟。
你预期潜水者可以在水下待超过 60 分钟,这导致了下面这样一个零假设: $ \mu = 60 $ ,备选假设是 $ \mu\geq60 $ 。我们实施一个关于总体均值的显著性检验,抽样分布如下:
这是一个均值等于 60 的样本均值的抽样分布, 60 是零假设的值。那么基于这样一个总体,一个样本的均值等于 60 的可能性有多大呢? 同样,为了回答这个问题,我们计算检验统计量,它是样本均值偏离总体均值的标准误差。你可能记得如何计算标准误差 —— 我们需要用总体的标准差,因为我们不知道这个值,需要用样本标准差估计。因为这隐含着额外的误差,我们引入 t 分布来取代 z 分布。
我们的检验统计量是通过下面的公式计算:
由样本均值减去零假设的均值,然后除以样本均值的标准误差。标准误差等于样本标准差除以样本容量的平方根。
我们先计算出标准误差, 5 除以 100 的平方根,得 0.5 。 62 减去 60 ,再除以 0.5 ,得到 4 。这是否足以拒绝零假设呢?仍然取决于显著性水平。让我们引入最常用的显著性水平 0.05 。做单尾检验,查询 t 表,临界值是 1.67 。
注意到我们的自由度是 99 ,但表里向下最接近的是 60 ,我们需要查看 $ t{90\%} $ ,因为右尾累积概率 0.05 。你需要记住, $ t{90\%} $ 代表置信水平为 90% ,也就表示分布的两尾加起来有 10% ,左右尾各 0.05 。
结果如下,我们的检验统计量 4 落在拒绝域内,意味着我们需要拒绝总体均值是 60 分钟的零假设。
我们可以总结,平均情况下,有经验的美国潜水者携带平均的氧气量潜到平均的深度,能够在水下待超过 60 分钟。如果我们的期望并不是超过 60 分钟,而是不等于 60 分钟呢?
这种情况下,我们做双尾检验。假设显著性水平设置为 0.01 ,左尾和右尾的累积概率分别为 0.005 。查表,对应的临界值分别为 -2.66 和 2.66 ,而我们的检验统计量是 4 。因此,我们还是要拒绝零假设,并对我们的发现做统计显著的结论。
因为我们现在做了双尾检验,所以我们的临时结论现在变成了有经验的美国潜水者在携带平均氧气量,潜到平均深度后,能待在水下的平均时间不等于 60 分钟。
还是前面的新生儿父母睡觉减少时长的例子,由于我们无法去询问你家乡所有的年轻父母。我们采取简单随机抽样。一个很关键的问题是 —— 样本应该取多大。应该是 50 个就够了?或者 至少 300 个或者 1000 个?
这一节中,我们将分布讨论对均值和比例感兴趣的不同情况。
先从均值开始。样本容量主要取决于三个因素。首先是你想要的精度。记得吗,置信区间是通过点估计加减一个误差界限来得到的。你允许多大的误差界限,如果你希望它越小,那么你的样本容量就得越大。其次,你的样本容量还依赖你想要的置信水平。越大的置信水平,需要越大的样本容量。最后,数据的变异性也影响样本容量的选择。你的变量的标准差越大,你需要的样本容量也越大。公式如下:
n 代表样本容量,m 代表误差界限,$ \sigma $ 代表总体的标准差, z 代表 z 分数。显而易见,你不知道总体的标准差,而且你还没抽取样本也不知道样本的标准差。所以,你需要利用 合理的猜测 (educated guess) 来估计一个值。
让我来演示这个过程是怎么样的。想象我们想要 95% 的置信水平,对应这个置信水平的 z 分数是 1.96 ,并且我不希望误差界限宽过 0.3 小时,就取 0.3 小时吧。现在我们可以填完这个公式的一部分了。现在我们需要对 $ \sigma $ 进行合理猜测了。如果已经存在这方面的研究而且你知道变量的标准差,可以简单地采纳这个标准差。但是,如果这样一个研究之前并未做过,就必须靠我们自己的猜测。我先假定某些父母根本没法睡,某些父母睡觉少于 5 个小时,并且父母们也不可能睡的比之前还多。因此,我们假定变量服从正态分布, 95% 的父母睡觉时间介于 0 到 5 个小时之间。均值是 2.5 小时,标准差是 1.25 小时。毕竟, 95% 是落在均值左右两个标准差范围内,两个标准差等于 2.5 ,那么一个标准差就是 1.25 。我们用这组数字完成公式,得到 66.69 ,取整,得出结论我们需要 67 个受试者。
对于比例感兴趣而不是均值的情况,也可以采取类似的方式。假设我们想知道新生儿在换尿布时便便的比例,我想要 99% 的置信水平,误差界限 0.10 。公式非常相似:
m 是 0.10 ,z 可由查表得 2.58 ,它对应 99% 置信水平。我们不知道的是 p 。还是采用合理的猜测,或者采用一种被称为 安全方法 (safe approach) 的方式。你会发现 $ p (1-p) $ 的最大值是 0.25 ,它发生在 p = 0.5 的时候,于是我们用 p = 0.5 完成公式,0.5 乘以 0.5 乘以 2.58 的平方除以 0.10 的平方,得到 166.41 ,即 167 位受试者。
在理想世界中,你可以去寻求大样本,比如说 1000 位受试者或者更多。但是,在现实世界中,我们的时间有限,资源有限,无法抽取大样本。因此,计算所需的样本容量可以帮助我们把成本降到最低。