从实际问题看 SwiftUI 和 Widget 编程
Widget 的实现依赖 SwiftUI,SwiftUI 部分的内容推荐大家自行学习,给出了一些质量较高的文章,可以配置 WWDC19/20 SwiftUI 相关 session 搭配食用。
参考资料
- 推荐看完 WWDC20 Widget Code-Along 三个环节
- TimelineProvider
- 小组件编程临摹课程
- 当 Widget 遇到智能化
Demo 工程:SwiftUIWidget
前言
iOS14 的 Widget 和 iOS14 之前的 Widget 已经完成了统一,之前老样式的 Widget 只能通过在老版本上进行查看,后续仅支持 iOS14 目前的 Widget。只能使用 SwiftUI 进行开发。
Widget 核心
- 快速、关联性、个性化
- 看一眼,就能够获取到重点内容
- 内容才是最重要的
- 相册 Widget 注意到的话,会发现展示的照片总是某个时刻下最棒的那一张,而不是最新的。
Widget 不是 mini app,应该看作是把 app 的内容在主屏幕的映射关系。官方给出的数据,一般我们会在一天的时间里进入主屏幕超过 90 次,并在主屏幕上短暂停留。
Widget 类型
Widget 有三个尺寸,但不强迫每个尺寸都实现,因为不是所有 app 都适合全尺寸 widget 展示,但推荐都实现(猜测就是要给用户最大自由度。
- 不能滚动和不能添加开关等其它系统控件。
- 不支持视频和动图。
- 小组件并不是在主屏幕上实时展示的。
- 系统的时钟 Widget 的事实刷新 UI 是个系统级 app 才能拥有的对待。
- SwiftUI 中对
Text
组件新增了可以实时展示时间的 API。Text(Date(), style: .time)
- 不要把小尺寸组件直接拉伸成中或者大尺寸小组件。
- Small 尺寸组件只能接受单次点击。
- 小组件内部按照 16pt 设定布局边距。
- 小组件内部有圆形素材,应该使用 11pt 边距。
- 小组件内部边界有圆角时要做得跟小组件本身的圆角半径同心。
- 不同设备上的小组件本身圆角值不一样,不能直接写死圆角值。
- SwiftUI 中提供了一个圆角容器。
- 字体官方推荐使用 SF 系列,可自定义。
- 不要放入 app logo 和 name。
Widget 如何成组?
控制允许用户选择的小组件类型
1 | @main |
在构建 entryView
时,根据当前选择的 widgetFamily
值来返回不同的样式。
1 | struct PJWidgetEntryView: View { |
使用 Xcode Widget Extension 模版创建完后,会自动给默认 Widget 加上 @main
修饰符标记出当前 app Widget 的入口。
换句话说,此时我们进入到「Widget 搜索」,找到我们的 app,只会看到一个 Widget。
1 | @main |
1 | @main |
注意:最多只允许塞入五个 Widget 样式。
Tips:What is @main
?
说起 @main
大家可能会先想到之前的 @UIApplicationMain
这个修饰词,说到 @UIApplicationMain
可能又会想到 main.swift 或者 main.m 等等这些文件。总的来说,它们之间是存在某种神秘联系的!我们来写一个简单的 Swift 代码:
1 | class demoSwift { |
此时使用 swiftc demo.swift
后会得到一个可执行文件,看上去 Swift 的语法让新上手的同学令人感到愉快,不会再有类 C 系那种必须写一系列又臭又长的 main 函数初始化流程,但本质上真的不用写了吗?
我们来看看中间代码。
1 | 查看生成的中间代码 |
1 | sil_stage canonical |
可以看到,所谓的对新人友好都是假的,全都是编译期间 swiftc
做的自动化插入,自动给我们的方法插入了与之前类似的流程,如果我们需要多文件编译依赖,要有一个 main.swift 作为入口文件进行索引其他文件进行编译。@UIApplicationMain
出现后,我们不再需要 main.swift 文件来做入口切割,可通过自定义类并加上该标记即可,这个好处在 Swift 5.3 中正式推广到语言层面,我们仅需使用 @main
即可标记出 Swift 文件的入口,不再是 Cocoa 特性,进而替代掉了 @UIApplicationMain
。
Widget 用户如何配置数据?
Widget 提供了用户可配置数据源的方式,可以通过此类方式来绕过 Widget 成组后最大上限五个的限制。提供两种配置方式
- StaticConfiguration。用户不可自定义数据源,参考头条 Widget。
- IntentCOnfiguration。允许用户选择配置,参考下图。
其中 IntentConfiguration 可提供给用户有限的“自由”,自行选择对应 Widget 下需要展示的数据源。利用了基于 Intents.framework 框架实现,并可以直接复用 SiriKit 的功能来达到 Widget 的智能化(后文再叙)。
配置 IntentConfiguration 的步骤如下:
- 创建对应的 IntentConfiguration 文件。
- 新增用户可配置的数据类型
- 配置新增数据类型相关信息
- 在对应类型的 Widget 中判断数据视图
1 | struct SwiftUIWidgetDemoMediumEntryView : View { |
Tips: What is @ViewBuilder
从实际问题看 SwiftUI 和 Combine 编程 已说明,可以前往了解。
Widget 如何跳转到对应的页面?
预览视图
1 | struct Provider: IntentTimelineProvider { |
placeholder方法中返回 widget 在初始化 loading 过程中的占位 UI。
- 每一个 widget 都必须提供。
- 默认内容展示。
- 没有任何用户相关数据。
- 当系统无法显示你的小组件数据时会出现。
- 无法被告知什么时候应该展示占位图。这是系统行为,系统需要的时候就会要求展示,例如用户更换了 widget 尺寸等。
这个视图是系统行为,只要我们使用的是标准 SwiftUI 组件,会自动根据组件类型,如 Image
、Text
结合我们自定义的颜色和背景来自动完成占位图的设置,如果我们不想要的系统自定义的话,也可以在方法中自行返回自定义的占位组件。
注意点:在构建 PlaceHolderView
时,Session 中所给的 isPlaceHoler
通过属性的方式去做已经不行了,得通过以下方式来进行(如果我们需要预览的话):
1 | PJWidgetEntryView(entry: SimpleEntry(date: Date())).redacted(reason: .placeholder) |
跳转
Widget 的目的非常简单,目前在 Widget 上所做的事情,全都是为了引导用户可以轻松的点击小组件和通过 deepLink 跳转到我们的 app 中。而从 Widget 跳转到 app 中针对不同类型的 Widget 有共有两种跳转方式。
.widgetURL
- 三种类型的小组件均可使用该方式进行跳转。
1
2
3
4
5
6
7
8struct SmallWidgetView: View {
var body: some View {
VStack(alignment:.leading) {
Text("PJHubs")
}
.widgetURL(URL(string: "urlschema://pjhubsWidgetURL"))
}
}
- 三种类型的小组件均可使用该方式进行跳转。
Link
- 只有 Medium 和 Large 类型的小组件可以使用该方式进行跳转。
- Small 类型小组件编译没问题,点击后无回调。
1
2
3
4
5
6
7
8
9struct MediumWidgetView: View {
var body: some View {
Link(destination:URL(string: "urlschema://pjhubsLink")!) {
VStack(alignment:.leading) {
Text("PJHubs")
}
}
}
}
SceneDelegate.m 中的内容为:
- 可以直接复用以往主工程通过消息通知 push 的逻辑流程来打开 widget 上挂载的 schema。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22#import "SceneDelegate.h"
#import "WidgetURLViewController.h"
#import "LinkViewController.h"
SceneDelegate
- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
if (URLContexts.allObjects.count != 0) {
UIOpenURLContext *urlContext = URLContexts.allObjects.firstObject;
NSURL *url = urlContext.URL;
if ([url.absoluteString isEqualToString:@"urlschema://pjhubsWidgetURL"]) {
[self.window.rootViewController presentViewController:[WidgetURLViewController new] animated:YES completion:nil];
}
if ([url.absoluteString isEqualToString:@"urlschema://pjhubsLink"]) {
[self.window.rootViewController presentViewController:[LinkViewController new] animated:YES completion:nil];
}
}
}
- 可以直接复用以往主工程通过消息通知 push 的逻辑流程来打开 widget 上挂载的 schema。
注意点
- 调试 Widget Deep Link 跳转时需要切换到 app target 下进行调试,一直在用 Widget target 调,发现断点一直走不进去,才猛的想起来,我在 widget target 里能断在 app target 里才奇了怪了。
- 更新 widget 的内容后,需要 build 一遍 widget target,然后再回到 app target 走 app 生命周期相关方法。
Tips: 如果 OC View 想要被使用在 SwiftUI 中。
- 首先创建或确定要被引入 SwiftUI 中的 OC 视图,下文以 OCView 替代。
1 | #import "OCView.h" |
- 创建一个 OCWidgetView.swift 文件,用于封装 SwiftUI 视图。
1 | import Foundation |
SwiftUI 中提供了 Coordinator
这个理念来作为 OCView
可能存在的各种 delegate 相关回调事件,在 SwiftUI 中同样可以进行使用,在此不做展开。
此时就可以在 SwiftUI 中引入 OCWidgetView 了!
1 | struct MediumWidgetFansView: View { |
Widget 的 UI 部分只能使用 SwiftUI 框架下的 UI 组件,不能使用任何 UIKit 相关的组件,就算用 SwiftUI 包一层也不行(UIViewRepresentable),强行使用的话,会在 Widget 视图上得到一个黄色背景红叉:
Widget 如何更新数据?
刷新时机
Timeline 刷新
Widget 需要通过 Timeline 来进行数据刷新,但其刷新的时机由系统控制,但有时我们设置了刷新间隔时间也不一定会在该时间点进行刷新。
如果我们完全依赖 Widget 自身的数据更新策略,每次间隔 1s 刷新数据,每次更新时拉取 5 个数据,设置 Timeline policy为 .atEnd
,也即当 timeline 中的数据用完后立即拉取下一条数据,则最短也许要 1min 时间才能拉取下一条 timeline(自测)。
atEnd
: 拉取到最后一个数据后重新拉取。atAfter
: 在指定时间后以一定时间间隔拉取数据。never
:该 timeline 不需要刷新。
不是都需要一次在 timeline 中构造好多个数据实体,可以一次返回一个,刷新间隔设置为 1min(或其它时间),这样比较适合对数据实时性要求较高的产品。系统并不少按照我们所规定的那样执行逻辑,系统考虑的因素用官方的话语来说,要结合耗电量等等问题综合给到不同 Widget 的刷新时机和此时,但总的来说,经常被查看的 widget 会获得更多的刷新机会。
主动刷新
如果我们想要在 app 内主动同步 widget 上所展示的消息,或在当前时刻必须刷新,如“开言英语” Widget 用户登录前后的 UI 表现不同等,在这种需求背景下,我们可以使用 WidgetKit 的 WidgetCenter API 来完成。
WidgetCenter.shared.reloadAllTimelines()
。reloadAllTimelines
方法会重新 load 所属 app 内的所有已配置的 Widget,重新拉取 Timeline。
需要注意的是,WidgetKit 为 Swift Only,想要在 OC 工程中使用该方法刷新 Widget Timeline 得通过 Swift 包一层,且要求 app 处于活跃状态。
1 | import WidgetKit |
支持所有 widget 刷新或某一个 widget。
1 | #import "ViewController.h" |
数据来源getTimeline
方法支持异步操作,我们如果需要动态的走网络请求拉取构造 timeline 数据,可以直接丢出一个异步回调。
1 | struct Provider: IntentTimelineProvider { |
注意请求间隔、Timeline 更新时间和数据转换等问题。
数据共享
以在 Widget 展示用户头像举例,在以往的开发经历中,我们都不希望有同步操作阻塞主线程从而造成 app 卡顿,故在 Widget 中我们也会自然而然的在“图片展示”这一环节中套用异步请求资源的思路去做,但这在 Widget 中是不被允许的,我们需要转变一个思路。
以下这种把图片资源延后到 UI 层的做法可以拉取成功,但不会被加载。
1 | struct SmallWidgetView: View { |
解决这一问题目前有三种方法但都是一种思路,核心就是把图片的加载过程从异步转化为同步,这个同步的过程可以是在 getTimeline
初始化时间线时,也可以是在构造 Widget UI 层逻辑时。
- UserDefault
- FileManager
- CoreData
以下为在 getTimeline 初始化时间线时的事例:
1 | struct SmallWidgetView: View { |
1 | struct Provider: IntentTimelineProvider { |
Widget 在最初放出的 beta 版本中是可以支持图片资源的异步回调的,但后来又改成了目前的这种只能通过同步的方式进行资源获取。
如果出现不同类型的 widget 需要复用图片资源,可以使用系统内轻量级 cache 方法(如:NSCache
等)来完成在 A 类型 Widget 下已经加载完成的图片资源,后续用户再手动添加 B 类型 Widget 后可以加速 Widget 渲染。
当我们需要从 app target 传递数据到 widget target 时,可以组成 App Groups,通过 UserDefualt
来完成数据传递,注意两个 target 都需要增加 app groups。
在 app target 中设置测试代码,10s 后刷新 widget 的显示内容,以此来模拟真实 app 中主工程触发某个网络事件,等待延时后同步数据给 Widget。
App target
1 |
|
Swift 处理过程(非必需)
1 | import WidgetKit |
Widget
1 | struct Provider: IntentTimelineProvider { |
Widge 如何在「智能堆叠」中提高展示?
推荐看完 为小组件添加智能和配置
基于 iOS12 引入的 Intent.framework,目前有两种提高 Widget 在智能堆叠中展示的办法。
- 用户行为捐赠(系统推断)
- 数据源评分展示(评估函数判分)
用户行为捐赠
WWDC20 Session - 为小组件添加智能和配置视频截图。
我们可以把一些自定义的关键组合信息构造出一个 intent 捐赠给系统,通过 Intent.framework,系统不但可以把这些信息传递给我们 app widget 还可以传递到 spotlight 等其它依赖 Intent 的场景从而减少进入特定场景/app 的步骤。
转换成我们的产品视角,当作者每天都在 14 点查看自己的视频播放量这一个指标数据,可以在作者进入到指标页面时,通过构造 Intent 实例进行捐赠给系统,当累计到一定次数(不定)后,系统会在每天用户 14 点前后解锁进入主屏时,在「智能堆叠」Widget 中自动翻滚到我们加入其中的 Widget 并展示出对应的播放量 Widget。
数据源评分展示
如果我们想要在特定时间主动突出小组件在智能堆叠上的展示机会,可以使用「数据源评分展示」策略,在构造 Timeline 时可以给不同的数据实体塞入不同的评分,从而达到在不同时间节点或特定时间节点下的突出展示。
转换成我们的产品视角,当作者新发布了一个视频,可能想要在未来的一天、两天甚至一周内关注视频本身的播放量这一指标,我们可以通过固定分数和持续时间来达到提升展示,从而关闭其它数据源更新时的
1 | struct Provider: IntentTimelineProvider { |
需要注意的是,我们给在第二分钟时要展现的数据分数 Relevance 分数设置为 2000,其它数据的分数设置为 10 分,此时运行 Widget 并等待到第二分钟,智能堆叠的 Widget 并不会一定翻转到我们的 Widget 上,但 Widget 上的数据是确确实实被更新了的,同时也说明了系统并不会一定认为当前数据比同一个 Widget 下的其它数据评分高,就一定为在智能堆叠上必须展示我们的 Widget,只是说在智能堆叠执行翻转时,我们的 Widget 会获得比其它 Widget 可能会获得更高的展示机会。
并且该评分也仅仅只是和当前 Widget 内的数据源做的对比。