第一章 基础

创建和绑定视图

1 创建项目和探索 Canvas

要点:

  1. 创建项目时 User Interface 选择 “SwiftUI”
  2. 基础扩展:演示 Xcode 界面布局,菜单
  3. SwiftUI 文件的两部分:描述视图内容和布局的主结构体,预览
  4. 显示隐藏 Canvas 面板快捷键
  5. 演示实时预览 (改变 body 里的代码) SwiftUI 开发体验的核心卖点之一
    进阶资源:声明式 UI 、Flutter 和 SwiftUI 的比较,拖尾闭包语法

2 自定义文本视图

要点:

  1. 演示从预览中的检视 UI 元素,顺便提一下 modifier ,演示 Font modifier
  2. 代码和视图的关系:source of truth (真理之源)
  3. 演示预览、检视器和代码的自动同步

3 通过堆叠组合视图

要点

  1. body 属性只返回一个视图
    进阶资源:关于 body 属性的文章
  2. 介绍三类 stacks ,HStack, VStack,ZStack
  3. 介绍 Xcode 的结构化编辑:嵌套、检视、提取等。通用的 ” 右键菜单 “ ,SwiftUI 专有的 Cmd + 点击
    进阶资源:Flutter
  4. 演示 embed in vstack
  5. 演示添加控件: + 按钮,快捷键
  6. 演示两层 Stack 嵌套,演示 Spacer 的使用。
    进阶资源:Spacer 的特性
  7. 演示 padding
    进阶资源:padding 的各种版本

4 创建自定义图像视图

要点

  1. 简介 image view ,常见图片相关概念:mask,边缘 (border),投影 (drop shadow)
  2. 基础扩展: 添加资源到 asset catalog
  3. 演示创建新的 SwiftUI 文件,CircleImage
  4. 演示 Image () 构造器,演示 clipShaper modifier, Circle mask
    进阶:mask 遮蔽
  5. 演示 overlay, shadow

5 让 UIKit 和 SwiftUI 一起工作

  1. 如何在 SwiftUI 中使用 UIKit 里的视图?关键词:wrap, UIViewRepresentable 协议
    进阶资源:wrap,协议
  2. [学习曲线] 跳过,后续再来说明。
    MapKit 相关知识点

6 构成视图 (Compose Views) —— (需要练习)

要点:

  1. 选择容器 —— stacks,文字、图像、Spacer、自定义视图
  2. 调整视图 —— modifier,属性
  3. 打开源链接,演示样例 app 界面变化过程。

检查理解的测验。


构建 List 和导航

1 了解样本数据

要点:

  1. 理解 Model ,粗浅理解为数据,相对于视图
    进阶资源:MVC 设计模式,结构体和类的区别,数据格式,json
  2. 带一些新的关键字,协议
  3. 进阶扩展:为什么要用扩展 Landmark 的 image 属性
  4. json 数据格式

2 创建 “行” 视图

要点

  1. 创建新 SwiftUI 文件,起名 LandmarkRow.swift
  2. 进阶扩展:存储属性和计算属性
  3. 介绍 Image resizable modifier

3 自定义预览

要点

  1. previewLayout
    进阶资源:previewDevice

4 创建列表

要点

  1. List 的元素可以是动态的、静态的或者混合的
    进阶资源:SwiftUI 的 List
  2. 使用 List
    进阶资源:

5 创建动态列表

要点

  1. List 接收 identifiable 的数据
    进阶资源:Swift keypath 语法,SwiftUI 的 List、ForEach
    进阶资源:闭包、拖尾闭包语法
  2. Identifiable 协议:id 属性的约定

6 在 List 之间建立导航

要点

  1. NavigationView
    进阶资源:SwiftUI 给视图 “赋能” 的方式
  2. 标题、导航按钮
    进阶资源:标准化 UI
  3. NavigationLink

7 数据传递

要点

  1. 演示改动

8 生成动态预览

要点

  1. 演示实现方式
    进阶:对移动开发的意义

处理用户输入

1. 标记最爱的地标

要点

  1. 添加一个图标:系统图标、缩放、着色
    进阶:SF Symbols,着色

2 过滤列表视图

要点

  1. @State 属性

3 添加控件以触发 State

要点

  1. 绑定:$ 语法

4 用 Observable Object 存储

要点

  1. ObservableObject 协议
  2. @Published 属性

5 在视图中接收模型对象

要点:

  1. 环境变量: EnvironmentOjbect 属性声明,environmentObject 传入
  2. where 语句

6 为每个地标创建按钮

要点:演示


第二章 绘制和动画


第三章 App 设计和布局

构成复杂界面

1 Home 视图

要点

Home 界面
进阶:心智模式:干扰和专注,简单和复杂 (通知)

2 分类列表

要点

  1. 分类 => 建立层级 (“抽屉”、容器)=> 寻找
  2. 用字典将地标分组
  3. 重温 List、ForEach、keypath 的用法

3 增加地标分类的内容

要点

  1. 重温 Stack
  2. ScrollView (.horizontal), ForEach

4 构成 Home 视图

要点

  1. 改造 CategoryRow
  2. listRowInsets、EdgeInsets

5 添加导航

要点

  1. 重温 NavigationLink
  2. Image .renderingMode , Text .foregroundColor
  3. 重温 @State, 重点: sheet
    进阶:alert, 内建的环境变量 .presentationMode
  4. 导航栏按钮 navigationBarItems

和 UI 控件合作

1 显示用户资料

2 编辑模式

要点

  1. editMode
  2. 条件视图,wrappedValue

3 定义资料编辑器

要点

  1. Divider
  2. Toggle
  3. Picker
  4. DatePicker

4 延后编辑的生效

要点

  1. 编辑草稿
  2. 可取消的编辑
  3. onAppear, onDisappear

第四章 Framework 集成

与 UIKit 对接

1 创建视图来表示 UIPageViewController

要点

  1. UIViewControllerRepresentable, #makeUIViewController, #updateUIViewController
    进阶资源:回调
  2. 用 SwiftUI 的 view 来构建 UIPageViewController 中的 controller
  3. map

2 创建 ViewController 的数据源

要点

  1. 使用 Coordinator, UIPageViewControllerDataSource

3 用 SwiftUI 中的视图状态跟踪 Page

要点

  1. @State, @Binding 的传递
  2. UIPageViewControllerDelegate

4 添加一个自定义页面控制

要点

  1. UIViewRepresentable
  2. UIPageControl, UIControl
    进阶资源:“控件”
  3. target-action pattern , *delegate#selector
  4. @objc 关键字
  5. 可选进阶:起名字,一个名字在多个层次中使用
    PageControl, UIPageControl
  6. 可选进阶:PageViewController 和 PageControl 之间如何联动

创建 watchOS App

1 添加 watchOS 目标

要点

  1. 激活 watch app 的 scheme
  2. Supports Running Without iOS App Installation
    进阶内容:watchOS 6.0, watch app 和 iOS app 的关系

2 在目标间共享文件

要点

  1. 文件 inspector, Target membership 段
  2. watchkit app 和 watchkit extension 的区别
    进阶内容:watch app 的结构

3 创建细节视图

要点

  1. 适配不同尺寸的屏幕
  2. where 语句
  3. scaleToFill, scaleToFit

4 添加 watchOS 的地图视图

要点

  1. WKInterfaceObjectRepresentable
    进阶:为什么不能像复用 CircleImage 那样直接复用 iOS 里写好的地图视图?SwiftUI 的定位(learn once, apply everywhere)
  2. 添加到 detail 界面

5 创建跨平台的列表视图

要点

  1. 泛型 类型推断
  2. 对应修改 iOS scheme 的 Home 视图,LandmarkList 的初始化,重温拖尾闭包。
  3. LandmarkList 预览怎么解决? #if, #else, #endif, typealias

6 (在 watch app 中)添加 Landmarks List

要点

  1. 切换 scheme
  2. 思考题:watch app 为什么没有 Home ?

7 创建自定义的通知接口

要点

  1. 如何正确地使用通知?
  2. 构建通知视图:swift 的 init 模式
  3. 通知控制:WKUserNotificationHostingController, #didReceive,
  4. UNNotification
  5. 通知配置:Notification Category, apns 文件模拟远程通知
  6. 切换到 Notification scheme

创建 macOS app

1 创建 macOS 目标

要点

  1. Deployment Target
    进阶资源: 软件兼容(向前兼容) 版本号

2 共享数据和 Assets

要点

  1. Membership

3 Row 视图

4 组装视图

要点

  1. List (selection: Binding)

5 过滤器视图

要点

  1. 相同控件在不同平台上的外观
  2. preview 中样例的 .constant 用法
  3. 数组相加
  4. 通用规范:复杂条件,注意使用括号

6 组合列表和过滤器视图

7 复用 CircleImage

要点

  1. 属性默认值

8 macOS 上的地图视图

要点

  1. 善用 extension 分治代码

9 构建细节视图

10 Master Detail 视图

要点

  1. mac (iPad) 上的应用布局,NavigationView

认识论 (Epistemology)

在把假说 - 演绎法作为获取世界知识的最佳途径之前。第一个问题是:现实的本性?什么是现实?什么存在?因此,我们首先要问,获得的知识到底是关于什么的知识。解决这类问题的哲学领域叫做 本体论 (ontology) —— 对存在的研究。第二个问题涉及获取知识的方法。假设的确有一个现实:原则上是可知的,那么我们能获取哪些现实的知识?怎么获取?哲学领域中考虑这类问题的叫 认识论 (epistemology) —— 认识的研究或理论。

我们从后一个问题开始讲。假设有一个可知的现实,我们如何来获取知识。有很多不同的认识论观点,我们这里讨论两个最重要的观点。

第一个是 理性主义 (rationalism) 。理性主义者认为知识通过理性获得,用我们大脑的逻辑和理性思维能力,可以推断世界的真理,而无需借助经验。

哲学家柏拉图和笛卡尔结合了理性主义和其他观点,认为至少某些自然结构的抽象概念是天生的,是我们与生俱来的。就是说,我们的大脑能轻易理解这些概念,因为我们已经知道了,只要用推理回忆或辨识即可。

经验主义 (Empiricism) 反对这一观点,经验主义者认为感官经验才是最主要方式。一些绝对经验主义者甚至认为,这是获取世界知识的唯一方式。

亚里士多德被认为是第一个经验主义者,他认为关于自然的基本真理来自感官经验。我们可以通过演绎推理获取更多知识,但观察是所有知识的基础。亚里士多德不相信天赋观念 (innate ideas) 。其实,他创造了 “白板” 这个词,指出每个人生下来就是一块白板。我们的知识并不是预先确定的,大脑可以接受任何概念。

当然,亚里士多德不是激进的经验主义者。他不反对理性思维加入进来,他也不反对用一些抽象的、不能直接观察的概念。

我觉得伽利略算是温和的经验主义者。他很注重观察法和实验法,但他也极度依赖逻辑推理。伽利略有句名言 “自然之书以数学语言写就”。他完全不排斥运用思想实验,也在他的假说中引入不可观测的性质。

后来的经验主义者如培根,尤其是休谟和逻辑实证主义者,都是绝对经验主义者,坚信只有感官经验才能获得世界的真知。他们认为建立在无法被直接观测的共相上的论断没有意义。

现代的经验主义就是范弗拉森的建构经验主义。它强调感官经验在归纳法和演绎法中都有作用,但它也允许理论术语存在,对应不能直接观测的实体。建构经验主义的目标是提出实证上恰当的解释,只要它表述的世界与观测到的一致,即可被接受。建构经验主义者会说因为存在不可观测的性质,真假无法判断。这承认了知识是暂时的,因为未来总存在发现新的反例的可能。


本体论 (Ontology)

我们来说说本体论 (ontology) 即现实的本质是什么。

有很多对立的观点。在深入各种哲学观点之前,我先来解释这些观点里两个主要区别。第一点,现实是否独立存在于人类思想之外。当我们感知世间万物,它们真的脱离我们而真实存在吗?或者只是我们思想构建的心理表征,只能说存在于我们的思想。第二点是关于本体状态的殊相 (particular) 和共相 (universal)。殊相是指具体实例或能观察到属性的事件;共相或者说不可观察的性质,就是指那些无法直接观察到的普遍性质。

我们来举个例子,爱是我们无法直接观察到的普遍性质,但能通过行为表达或具现。因此当我家猫爬上我的膝盖打个小盹,这就是爱这个共相的具体实例。重力是另一个不可观察的共相,重力可用具体实例来表现。例如,当我打翻猫食盆,它就会掉在地上。

我们来看一下不同的本体论观点,看看他们对殊相和共相问题,以及对现实是外部存在还是仅存于思想内的看法。

唯心主义 (Idealism) 哲学观认为我们感知的现实完全存在于脑内。在我们精神世界感知到之前,外部物质世界的存在与我们无关。现实其实是精神世界的映射,重力和爱是存在的,但仅在我们的思想里,与之相关的具体实例也是如此。唯心主义者会说:猫睡在我膝上,以及碗掉在地上,这都是你想出来的。

对唯心主义而言,共相或不可观察的性质,它们是否真的在外部独立存在无关紧要。因为他们认为殊相和共相都是存在的,但都是精神世界的映射。

和唯心主义相对的是唯物主义 (Materialism) 。唯物主义认为思想世界外有独立存在的世界,唯物主义还认为一切由独立的物质世界构成。这就是说一切都是实物相互作用的结果,包括我们的意识、情绪和思想,这些是我们大脑和物质世界相互作用的副产品。

和唯心主义刚好相反,这是物质对精神。唯物主义只关注世界的物质组成,和唯心主义类似,它并不关心如何区分殊相和共相。

现实主义 (Realism) 又有所不同,就像唯物主义者,现实主义者坚持外部现实世界独立于人类思想存在,但现实主义者还坚持,像爱和重力这种共相是真实的,其存在形式取决于现实主义的类型。柏拉图式的现实主义认为像重力、爱这种共相,真的存在但我们观察不到,其位于一个独立的抽象位面。科学现实主义则更温和,它认为把共相用于现象观察能得到坚实可靠的主张。

在科学现实主义中,像爱和重力这种共相,和可观察的殊相位于同样的本体状态。之所以能假设不可测性的存在,是因为其对构造成功的科学主张有用且经常很必需。

最后是唯名论 (名义主义,Nominalism) 。这个观点在共相方面与现实主义完全对立,它承认现实独立于人类思想存在,但否认共相的存在。唯名论认为没有重力或爱这回事儿,只有掉落的物品以及经常在你膝盖打呼噜的猫。根据唯名论,我们用重力和爱这些术语,只因为能帮助理解世界,但共相不是真实存在的。


方法 (Approaches)

至今为止我讲到的科学方法的发展,主要在自然科学领域。物理学 physics、天文学 astronomy、生物学 biology,但在 19 世纪下半叶, 社会科学 (social sciences) 开始登上历史舞台。

这段时期,人们又重拾了现实主义的本体论观点,即假设物质世界是真实的,我们感知的是外部世界,是独立于我们思想存在的。

认识论的观点越来越实证主义 (positivistic) ,这意味着科学家认为我们可以通过观察和实验来获取有关世界本质的知识。这种现实实证观点大多应用于自然现象方面,但随着社会科学发展并成为独特的科学领域,问题来了:现实主义观点是否适用于社会学和心理学现象呢?

根据 客观主义 (objectivism) 的观点,现实主义本体论立场确实适用于心理学和社会学现象,比如智力和社会凝聚力是外部的、独立的性质,是独立于我们的心理表征的。

客观主义可以建构主义 (constructivism) 做对比。

根据建构主义,社会现象的本质取决于所涉及的社会角色。这意味着现实不是独立和外在的,而被认为是基于观察者和情境的心理建构。比如,快乐或女性气质这些属性不是外在的,不是永恒的,也不能被客观定义。要如何看待这些属性以及它们的意义,取决于观察者的文化背景、社会族群及特定的历史时期。那么,如果心理现实和社会现实是建构的、主观的、难以捉摸的,我们如何了解它呢?怎样的认识论立场适合建构主义的本体论立场?

事实上,有一组互相联系的观念,统称为 解释主义 (interpretivism) 。解释主义的观点都假设研究者关于社会现象的经历或观察,可能与这些社会现象亲历者的经历大相径庭。所以重点应该放在参与者的角度来解读现象。

我想讲的三个解释主义观点是 解释学 (hermeneutics)现象学 (phenomenology)诠释社会学 (verstehen) ,它们在如何获得心理学和社会现实的理解上有些微差别。

先来看看解释学。这个术语来自神学,是关于解读经文。解释学旨在通过解读人们在社会情境下的行为 来解释社会现象。研究者需要将情境纳入考量,并试着理解人们如何看待这世界,以此来理解他们的行为。

现象学与解释学密切相关。它的首要前提是人不是无生命的对象,他们会思考和感知周遭的世界,而这会影响他们的行为。为了理解他们的行为,就需要调查他们给自己所经历的现象赋予的意义。这意味着调查人们如何从自身的角度探究世界。要切身了解他人对自己经历的理解,研究者需要尽可能地消除自己先入为主的观念。

诠释社会学是第三种解释主义观点,它与解释学和现象学有紧密联系。诠释社会学主要与社会学家马克斯・韦伯 (Max Weber) 相关。诠释社会学是指对社会现象的移情理解。研究者需要站在研究对象的立场,来解读他们如何看待世界,只有这样研究者才能解释他们的行为。比如,如果欧洲研究者想在一个与世隔绝的亚马逊部落中探究快乐。他们需要站在部落的角度,考虑到部落的社会情境。对部落来说,或许集体比个人更重要,这可能意味着快乐被认为是一种集体属性,甚至根本不适用于个人。现在,为了理解这种完全不同的世界观,研究者需要将自己沉浸在他们研究的人或族群的文化中。

当然,建构解释主义的观点存在一些问题。首先,有分层解读的问题 —— 研究者的解读;研究对象的解读;而将发现放进一个框架或关联一个理论时,又进行了解读。每多一层解读,就增大了误解的机会。第二个更严重的问题是结果缺乏可比性。在我们的例子中,快乐是主观的,在不同的文化中意义不同。我们不能就这么进行比较。这意味着我们永远无法提出普适解释或理论,而仅仅适用于特定人群或特定时段。第三个问题是参考系的不同。如果参考系与研究者相去甚远,研究者就很难站在研究对象的立场上,从而甚至难以发现社会情境中的相关方面。

建构 - 解释主义的观点常与科学的定性方法有关。换言之,观察是通过非结构化访谈或参与性观察进行的,而研究者是他们中的一份子。数据来源于一个或少数几个研究对象,通过解读文本或录制的素材对数据进行定性分析。反之,客观 - 实证观点于定量研究方法相关。得到的观察结果可以被计数或测量,所以多个研究对象的数据可以整合在一起,选取的研究对象代表更大的人群,或许可以支持一个普适解释。而且数据用量化统计手段来分析。

尽管定性方法通常与建构主义的科学观点相关,而定量方法与客观主义观点相关,这并不是限制我们仅使用定性或定量方法的理由。两种方法都各有优劣。对有些研究问题来说,定性方法更好;其他情况下 定量方法可能更合适。事实上,将两种方法互补结合在一起的方法,越来越受到欢迎。


目标

当然最后,科学的总体目标是获得知识,但可以分为更多具体的目标,区分目标的方式有获取知识的类型以及获取知识的目的。

普遍性研究 (universalistic research) 试图提供能广泛使用的解释。

例如,假设玩暴力电脑游戏会导致攻击行为。这与具体游戏或特定玩家没有关系,因为我们假定的是玩暴力游戏和攻击性间的相关性,这适用于任何暴力游戏,如 GTA 、使命召唤等等;我们还假设相关性适用于男性和女性,任何年龄、任何文化背景的人。

普遍性研究致力描述或解释的现象,能用于所有人、所有群体或社会。

科学方法也能用于特殊性研究。特殊性研究致力描述或解释发生在特定环境下的现象,或者涉及特定群体。

例如,在荷兰将法定饮酒年龄从 16 岁升至 18 岁后,我们能调查荷兰青年酒精中毒住院人数的变化。关键是在特定的时间、地点、群体内调查影响的大小。不要指望在不同国家或十年里再次改变饮酒年龄会有同样结果。所以研究目的既可以是普遍性的,也可以是特殊性的。说得简短些就是:可获得普遍性或特定的知识。

基础研究 (fundamental research)应用研究 (applied research) 间关系很近,重叠度很高。

应用研究为了直接解决问题,其开发和应用知识是为了提高人类福祉。假设我们想帮助抑郁人群,我们认为抑郁是孤独造成的。我们就可以建立一个项目,目的是减少孤独感以降低抑郁程度。我们让孤独抑郁的人去养只猫,来观察是否真的由于不再孤独降低了抑郁程度。

基础研究相较于应用研究旨在获取知识,就是为了增进了解。基础研究的唯一目的是加深了解身边的世界,不需要能立即应用和接解决问题。例如,调查孤独和抑郁间的相关性,用大规模调查来看是否越感觉孤独的人越抑郁,反之亦然。这里是为了揭示孤独和抑郁间的相关性。也许我们想看看是否男性女性都有这种相关性,不同文化和年龄也有这种相关性。但注意,我们不关心如何治疗抑郁,这里的目的更多的是了解相关性,不是帮助抑郁人群。

大多数基础研究是普遍性研究,但有时候基础研究也会是特殊性研究。例如,在非常特定的情形下的研究。好比我们调查玩暴力游戏和攻击行为的相关性,就在阿姆斯特丹特定的初犯少年犯群体中,他们都来自权贵阶级。在玩暴力游戏和攻击行为相关性方面,这个非常特定的问题群体能提供有趣的新见解。注意,我们不观察该群体如何改造或不再犯罪。

应用研究常是特殊性研究,旨在特定环境、特定群体中解决问题,但它也可以是普遍性的研究。以养猫来减少抑郁的研究为例,我们可以扩展这项应用研究,比较照顾友善易交流的猫和拒绝接触的猫的人群。这会更有针对性地帮助找到何种治疗有效,但这也加入了普遍性元素,我们还可以调查它对孤独的意义。仅仅有个活物存在就够了吗?还是需要有互动?很多时候,应用研究的结果会产生新的见解,这些见解会和介入或治疗相关,但它们也会提供基础的知识。

因此,两种研究类型会互相增强。


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)

要做一个 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 ,所以尽管拿去用,你可以分享给我你都做出什么好玩的东西!

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 项目