当 Widget 遇到智能化

作者: kAzec, iOS 开发者,目前就职于字节跳动

Sessions: https://developer.apple.com/videos/play/wwdc2020/10194/

在阅读本文前,推荐先对新的 Widget 系统有个大致的了解,同时也推荐先熟悉 Apple 在 SiriKit 中引入的 Intents API。

概述

在 WWDC 2020 中,Apple 引入了 Widget (小挂件)这一全新的 App Extension,允许开发者在设备主屏幕、“今天”视图和 macOS 通知中心上显示自定义的小挂件。

该 session 首先介绍了如何通过 Intents API,让我们开发的 Widget 支持让用户进行个性化配置,并介绍了目前支持的配置参数的类型,如何自定义参数类型,并且支持为用户动态生成待选项列表。

之后介绍了 Widget 支持在 Smart Stack 中堆叠展示,同时可以通过开发者的配置让 Widget 智能地被系统展现:

  1. 通过复用 Intents 的“捐赠(donate)”的概念,让用户在合适的时机看到 TA 需要的内容。

  2. 通过为特定的 TimelineEntry 指定相关性信息,让用户能及时看到开发者认为和当前用户高度相关的内容。

让我们开始吧~

知识导图

基本概念

为了更好的解释相关概念,这个 session 使用了一个信用卡记账示例应用,这个应用实现了两个 Widget:

  • RecentPurchases: 显示某个信用卡账户最近的购买记录

  • DueDate: 显示某个信用卡账户消费额 & 还款日期

Tips:

Apple 并没有放出该 session 中演示的 demo 的源码,但是,另一 Widgets Code-along[1] 系列 session 中,所演示的 Emoji Rangers demo 提供了源码[2],同时也实现了本文所描述的配置能力,可以用作参考。

Widget 的可配置的参数列表是使用 Intents 进行配置的。Intents 是 Apple 在 WWDC 2016 引入的新概念,了解 SiriKit 的开发者对此肯定不会感到陌生(参见 Introducing SiriKit[3]Introduction to Siri Shortcuts[4])。

为了让用户可以自定义 RecentPurchases Widget 所显示的信用卡账户、对应的消费分类的功能,我们可以定义一个包含了两个参数的 Intent:

  • Card:Widget 所显示消费记录对应的信用卡账户

  • Category:Widget 所显示消费记录的特定分类

基于我们定义的这个 Intent,WidgetKit 会自动为我们生成如下图所示的配置界面,其中 Card 和 Category 参数分别对应一个配置行。

在用户进行 Widget 配置时,WidgetKit 可以通过 Intent Handling Protocol 从我们的 Widget ExtensionHost App 获取要显示的待选项列表(这一点在后面会详细讲到)。

最后,在获取 Widget Timeline 数据时,WidgetKit 会将用户所自定义的 Intent 实例传入我们的 Widget Extension,我们可以使用该 Intent 实例中的信息来返回个性化的 Timeline 数据。

Widget 支持配置的参数类型

Widget 支持配置从整型、字符串等基础类型到日期、URL 等高级类型的参数,同时支持自定义参数类型 & 自定义枚举类型。

所有支持的类型配置可见下图:

支持自定义输入模式

其中,特定的类型还有近一步的自定义选项来定制输入 UI。例如,Decimal 类型可以选择采用输入框(Number Field)输入或者是滑块(Slider)输入,同时可以定制输入的上下限;Duration 类型可以定制输入值的单位为或者;Date Components 可以指定输入日期还是时间,指定日期的格式等等。

支持自定义参数类型

除了系统自带的参数类型以外,也支持自定义参数类型。可以通过 Add Type... 添加自定义的参数类型,通过Add Enum... 添加自定义的枚举类型,Xcode 会自动生成/更新对应的 Swift 类型(在 Xcode 12 beta 中添加自定义类型后,大部分情况下需要重新 build 项目,或者重启 Xcode 才能看到生成的 Swift 类型????)。

可能有读者会注意到,上图中显示的第二个自定义枚举类型 Dynamic,支持动态生成所显示的待选项列表,这个我们会在后面详细介绍。

支持输入多个值

大部分类型的参数支持输入多个值,即输入一个数组。同时,支持根据不同的 Widget 大小,限制数组的固定长度。

为 Widget 添加个性化配置能力

下面,基于前述的信用卡实例应用中的 RecentPurchases Widget,对如何为 Widget 添加个性化配置的能力做逐步、详细的介绍。

确定 Widget 可配置的选项

RecentPurchases Widget 目前一次只能显示一张卡片的消费记录等信息,所以很自然,我们会想要让这个 Widget 支持自定义要显示的信用卡账号。同时,多个消费记录可能属于不同的类别(category),那么很自然的,我们可以让用户选择只显示某一个类别的消费记录。

所以,我们希望RecentPurchases Widget 可以支持自定义信用卡账号(card)以及消费类别(category)两个参数。

在 Xcode 中自定义 Intent 类型

首先,我们在工程中的一个 Intent 定义文件中 (如项目中没有已有文件,可以通过 Choose File > New > File 选择 SiriKit Intent Definition File 添加)中,定义一个新的 Intent,叫做 ViewRecentPurchasesIntent

需要注意以下几点:

  • Intent 的 Category 选择为 View(即用于展示/配置 UI)

  • 选中 Intent is eligible for widgets

  • 取消选中 Siri can ask value for run(除非该 Intent 也用于 Siri Shortcuts)

在定义完新的 Intent 类型后,Xcode 会自动生成对应的 Swift 类型文件(以及相对应的 IntentHandling 协议,见下),我们在 Widget Extension & Host App 均可以使用这个 Intent 类型。(Xcode 12 beta 中添加新的 Intent 类型后可能需要重新 Build target,或者重启 Xcode 才能看到/使用新的类型)

实现 IntentHandling 协议

在示例应用中的 card 参数,用户添加了那些信用卡账号只有运行时才知道,对于那些需要在运行时确定有哪些待选项的参数,我们可以选中 Options are provided dynamically 选项,并实现对应的 IntentHandling 协议。

一般我们通过创建一个 Intent Extension target 来处理和系统 Intents 相关的交互。

关于什么是 Intent Handling,如何提供某个 Intent 的 Handler 实现可以参考 SiriKit Programming Guide[5] 中的 Siri Intents 部分内容。

以之前定义的 ViewRecentPurchasesIntent 为例,Xcode 会自动生成一个 ViewRecentPurchasesIntentHandling 协议。通过指定一个 Handler 类,并实现下面两个方法:

  • provideCardOptionsCollection(for:with:)在用户点击 Widget 中 Card 配置项的时候,WidgetKit 会展示上图右侧中的列表 UI,其中的数据由这个方法异步返回。

  • defaultCard(for:)我们可以通过实现该方法,在用户首次添加我们的 Widget 时,对于该 Widget 的某一个可配置项返回一个默认的参数值。例如在图示的实现中,我们返回了用户的主要信用卡(Primary Card)。

Tips:

  • 通过使用 INObjectCollection(ps:) 构造器,传入 INObjectSection 数组,可以分区展示待选项列表。

  • 自定义 Intent 类型继承自 INObject,通过重载/设置 displayStringsubtitleString 等属性,可以定制自定义类型在待选项列表中的显示内容。

  • Intent Handling 协议中定义的 defaultXXX(for:) 方法被标记为 optional,但是依然推荐实现,因为一个好的默认视图对我们的 Widget 来说是十分重要的。

你可能注意到了待选项列表上方的搜索框。默认情况下,搜索框会对我们所返回的全部内容进行搜索过滤。但是,当待选数据较多,或者说待选数据取决于用户具体输入时,我们可以打开 Intent handler provides search results as the user types 选项,实现对待选项列表的实时更新。

在打开该选项后,Xcode 会为生成的 IntentHandling 协议的 provideCardOptionsCollection(for:with:) 方法添加一个 searchTerm 参数:

当用户在搜索框中输入字符时,WidgetKit 会调用该方法对待选项列表进行更新。首次显示待选项列表时,该参数值为 nil。

切换至 Intent-based API

现在我们定义了用于配置 RecentPurchases Widget 的 Intent 类型,同时实现了对应的 IntentHandling 协议。下面我们可以将 RecentPurchases Widget 切换至 Intent-based API 用以展示用户自定义的内容。

  1. StaticConfiguration 切换至 IntentConfiguration,并传入所配置的 Intent 类型(示例中为 ViewRecentPurchasesIntent.self)。

  2. TimelineProvider 切换至 IntentTimelineProvider,并更新相关的方法(snapshot(for:with:completion:)timeline(for:with:completion:)),添加 intent 参数,修改实现,使用 intent 参数中的配置信息,返回个性化的 TimelineEntry 数据。

自定义 Widget 配置界面

  • 通过对 WidgetConfiguration 添加下面两个 modifier,自定义配置界面标题、描述文案。

  • 通过在 Widget Extension target 的 Build Settings 页面中,配置 Widget 的 Global Accent Color Name 和  Widget Background Color Name,自定义配置界面的强调色和背景色。对应的颜色资源需要添加在 target 中的 Assets Catalog 中。

控制配置项的显示条件

你可以控制某一个配置项,只在另一个配置项含有任何/特定值时展示。如下图,日历 App 的 Up Next Widget,仅在 Mirror Calendar App 选项没有被选中时,才会显示 Calendars 配置项。

在 Intent 定义文件中,将某一个参数 A,设置为另一个参数 B 的 Parent Parameter,这样,参数 B 的显示与否就取决于参数 A 的值。

例如,在下图中,calendar 参数仅在 mirrorCalendarApp 参数的值为 false 时展示:

让 Widget 智能地被系统展现

在 iOS 14 中,随着 Widget 一并引入的还有 Smart Stack,即 Widget 智能堆栈。用户可以将多个 Widget 堆叠显示,通过上下滑动切换正在显示的 Widget。

为了让堆栈能够在合适的时机展示合适的 Widget,Apple 引入了一套类似 Siri Suggestions 的,基于 Intents donation 以及 Relevance 内容相关性的竞标机制。

Widget 智能三要素

  • Timely:Widget 应该在合适的时机,展示用户感兴趣的内容。

  • Glanceable:Widget 展示的内容应该是简洁、直观、一目了然的。

  • Obvious value:Widget 应该展示对用户来说最有价值/相关性高的内容。

一个优秀的 Widget 应该是一目了然的,并会在合适的时机,提供用户最感兴趣的内容。

例如,对于一个天气 App 来说,某个用户可能习惯在早上 8:00 左右打开天气 App 查看当日天气,那么我们希望用户在每天早上 8:00 打开手机时,能够在主屏幕上直接看到天气 App 的 Widget。

此外,我们也希望在有雷雨天气时,主动在 Smart Stack 中显示天气 App 的 Widget,让用户对恶劣天气做好准备。

为此,WidgetKit 提供了两种机制来实现上述目标。

基于用户行为(Custom Intent donations)

在 iOS 12 中,Apple 引入了 Siri Shortcuts & Custom intent donations(参见 Introduction to Siri Shortcuts[6])。当用户在宿主 App 内进行某一操作时,App 可以主动 donate 一个 Intent 实例告知系统用户进行了此操作,从而让系统了解用户的行为规律。这一信息过去被用于在 Spotlight 中预测用户可能进行的操作,而在 iOS 14 中,同样的信息也可以让系统预测 Smart Stack 中某一 Widget 适合的展示时机。

对于 Widget 来说,利用这一机制的前提是实现了可配置化的能力。Widget 的可配置化是基于自定义 Intent 类型实现的,那么同样的 Intent 也可以被 donate 给系统。

Demo

以前述的 RecentPurchases Widget 为例,我们希望系统能掌握用户查看特定信用卡消费记录的规律,并在合适的时机展示对应卡片的 Widget,下面进行逐步介绍:

  1. 将 Widget 对应的 Intent 标记为 Intent is eligible for Siri Suggestions

  2. Suggestions 部分添加 Supported Combinations,我们只关注用户所查看的信用卡账户,所以添加一个只有 card 参数的 combination。

  3. 在宿主 App 内显示对应的信用卡消费记录时,创建并 donate 一个 ViewRecentPurchasesIntent

Under the hood

让系统理解用户在 App 内的行为的关键在于配置合适的 Supported Combinations,直译过来是**“支持的参数组合”**。通过配置一个或多个参数组合,我们可以告诉系统用户在 App 内的行为的哪些特征(特征由对应的 Intent Parameter 决定)是值得关注的。

例如在上面的 Demo 实现中,我们制定了包含一个参数 card 的参数组合,那么系统会提取所有包含同一 card 参数的行为数据,归纳总结出用户在特定的时间,查看某一信用卡账户信息的行为规律。而在匹配最合适的 Widget 时,相应的会去查找所有配置展示了对应卡片的 Widget,不论该 Widget 的另一个参数 category 的取值。

例如上图的例子中,系统最后匹配到了两个 Widget:Acme Card - GroceryAceme Card - Travel

而如果我们在参数组合中添加 category,则在匹配 Widget 时,会同时考虑两个参数,最后匹配到 Acme Card - Grocery

App 主动提供相关性信息(Providing Relevance info)

在之前的学习中我们已经了解了如何使用 TimelineProvider/IntentTimelineProvider 来提供不同时间点的 Widget 渲染所需的数据。

Timeline 数据由一个个 TimelineEntry 组成,一个 TimelineEntry 除了包含 Widget 对应显示的时刻信息、 Widget 渲染的内容数据外,还可以包含一个 TimelineEntryRelevance 对象,用来表示这个 entry 的相关性。

TimelineEntryRelevance 信息包含两个 scoreduration 两个属性:

score

A value that indicates the relevance of an entry compared to other entries in the past.

score 值的高低,反应了在对应的时间点,Widget 所展示的内容和用户的相关程度(或者说用户可能感兴趣的程度、对用户的重要程度)。

例如以前述的 RecentPurchases 挂件为例,我们将 score 值设置为要展示的消费记录的金额,那么相应的,金额越大的消费记录越有可能被系统展示给用户。

需要注意的是,score 值是一个相对值,它不会用来和别的 App 所提供的值进行比较,只会用于和过去该 App 所提供过的所有值进行比较。

score 值小于或等于0时,系统认为对应时刻的 Widget 内容对用户来说是完全不重要的(比如显示为空占位图视图时),所以不会主动展示该 Widget 。

duration

The length of time following the entry's date that the widget has the relevance score set.

简而言之,duration 值代表了一个 TimelineEntry 的用户相关性所持续的时长。这个持续过程可以跨越多个 entries,直到下一个指定了 non-nil relevance 值的 TimelineEntry 把它覆盖,又或者是指定的持续时间结束。

例如,下图所示体育比赛信息挂件中,在 6:30 指定了一个长达三小时的相关性信息,由于后续(6:40、7:02、9:30)的 TimelineEntry 并没有提供新的 relevance 信息,那么后续的挂件展示依然会继承之前所指定的相关性数据。

duration 的值为 0 时,WidgetKit 认为该 entry 对应内容的相关性会持续到下一个提供了 relevance 信息的 TimelineEntry 被展示。"

推荐阅读

✨ Apple Widget:下一个顶级流量入口?

✨ 为 Widgets 构建 SwiftUI 视图

✨ WidgetKit 入门指北

《Widgets 边看边写》第一部分:冒险开始了

《Widgets 边看边写》第二部分:Timelines 的基本使用

《Widgets 边看边写》第三部分:Timelines的进阶使用

关注我们

我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。

支持作者

这篇文章的内容来自于 《WWDC20 内参》。在这里给大家推荐一下这个专栏,专栏目前已经创作了 101 篇文章,只需要 29.9 元。点击【阅读原文】,就可以购买继续阅读 ~

WWDC 内参 系列是由老司机周报、知识小集合以及 SwiftGG 几个技术组织发起的。已经做了几年了,口碑一直不错。 主要是针对每年的 WWDC 的内容,做一次精选,并号召一群一线互联网的 iOS 开发者,结合自己的实际开发经验、苹果文档和视频内容做二次创作。

参考资料

[1]

Widgets Code-along: https://developer.apple.com/news/?id=yv6so7ie

[2]

源码: https://developer.apple.com/documentation/widgetkit/building_widgets_using_widgetkit_and_swiftui

[3]

Introducing SiriKit: https://developer.apple.com/videos/play/wwdc2016/217/

[4]

Introduction to Siri Shortcuts: https://developer.apple.com/videos/play/wwdc2018/211

[5]

SiriKit Programming Guide: https://developer.apple.com/documentation/sirikit#//apple_ref/doc/uid/TP40016875

[6]

Introduction to Siri Shortcuts: https://developer.apple.com/videos/play/wwdc2018/211