本文知识目录
引言
在 2020 的 WWDC 上苹果发布了 WidgetKit
小组件,重新设计后的 Widgets,其展示不再局限于负一屏,而且支持在 macOS 和 iOS 的主屏幕上提供动态信息和个性化内容。苹果对小组件定位重点在于信息展示,而非应用程序的快捷方式或者小程序。因此,它的运行限制较多。如何在有限的条件里做好用户体验,就成为了雪球设计师和产品的重要挑战。
WidgetKit 简介
为了提供符合苹果美学与规范的小组件,团队在雪球小组件的产品设计前期就召集相关设计师和产品经理,一起学习了 WWDC20 中关于 Widgets 的 Sessions。
Widgets 核心要素
苹果对于优秀 Widget 的介绍如下:
- 在快速切换应用的主屏幕里,复杂交互的应用界面并不切合用户需求,简单明了的内容才是用户关注的重点。
- Widget 会在恰当时机展示正确的内容, 同时支持预渲染、复用,并提供灵活可控的更新策略来保证内容即时性。
- Widget 提供了个性化定制,可针对不同尺寸及用户配置来展示不同的内容。
Widgets 的限制
Widget 设计的初衷是简单明了的在恰当的时机展示一些带有个性化定制的内容,为了不让主屏幕的整体使用体验变得复杂,Widget 限制了很多能力:
- 不提供动画,仅支持静态页面展示。
- 不支持拖拽、滚动等复杂的交互,不支持 Switch 等控件。
- 仅支持点击指定的 URL 跳转到 App。
- 更新频率由系统通过机器学习来动态分配。
Widget 的生命周期
运行在主屏幕的小组件,其生命周期理论上与桌面进程一致。不过由于系统的限制,小组件只能在预定的时间节点提交刷新请求,由系统来决定是否需要进行响应。在其生命周期中,小组件的逻辑有三次被系统调用的机会:
- 当用户编辑主屏幕添加小组件时,先触发
placeholder(in:)
来优先显示占位效果。 - 在预览状态,触发
getSnapshot(for:in:completion:)
创建快照以提供相对完整的信息展示。 - 最终在主屏上成功添加小组件后,将执行
getTimeline(for:in:completion:)
获取未来时间节点上的数据和相关更新策略。
Tips: 一般而言,在创建快照时渲染合适的视图以供预览,在刷新 timeline 时获取网络数据。
要理解小组件的生命周期,读懂 timeline 的概念就十分重要了。小组件的内容变化都依赖于此。
timeline 本质上是基于时间驱动的一连串静态视图。
通过 timeline Provider 提供在未来特定的时间节点的一连串 TimelineEntry
数据,并且可以设置更新策略:
- after:在特定时间后触发更新。
- atEnd:在 timeline 中所有的 entry 都展示完之后更新。
- never:仅在主 App 触发更新。
当然,所提供的这些数据是否更新,最终仍取决于系统。对于股价行情和热点事件这些不可预测的信息,我们选采用 after
策略,在一定时间后更新信息。同时我们会在主应用退到后台时,调用 WidgetCenter
来触发定向更新。
timeline 刷新
苹果提供了两种刷新小组件的方式,System reloads
和 App-driven reload
。
System reload
由系统发起,刷新频次也由系统控制。为了保证性能,系统会根据各个 reload 请求的重要程度来控制是否刷新 timeline。因此,过于频繁的提交刷新请求可能无法达到预期。
App-driven reloads
由 App 触发小组件的 timeline 刷新。当主程序在后台时,可通过后台推送触发刷新;当主程序在前台时,可以通过 WidgetCenter
实现。不过 WidgetCenter
API 仅提供了 Swift 版本,对于像雪球采用了 Swift + ObjC 混编的项目无法直接在 AppDelegate 中使用,需要通过 bridge 的方式暴露到 ObjC 来完成调用。
雪球 Widget
Widget 设计
雪球希望为用户提供其所关心的市场行情和及时的交易信息。而借助小组件这一新的内容展现形式,能够在一定程度上帮助用户聚集和触达他所关注的信息。
然而 💡 想法无限,时间有限,我们最终决定先尝试几个实用功能:
分别选取了用户所关心的大盘行情、自选股、热门话题以及雪球日历。
另外针对自选股还支持了大、中、小三种尺寸:
关于 Widget 设计可以参考这两个 Session:
0x01 环境搭建
首先,Widgets 小组件本质上是存在于主项目之外的独立进程 (即 App Extension) ,它拥有自己的生命周期和存储空间,系统会根据用户触发的事件进行管理。App Extension 依赖主应用程序为载体,如果将主应用程序卸载,那么 App Extension 也将不复存在。
因此,我们需要为小组件搭建基础设施,如网络通信,图片缓存,数据持久化等等。除此之外,你还需要熟悉 Swift 并且了解 SwiftUI 的一些基础用法。
网络通信
小组件中可以使用 URLSession
,而雪球在 Swift 2.0 已接入 Swift,且基于 Alamofire + SwiftyJSON
封装了一层内部网络库。仅需简单抽离即完成了小组件网络库的封装。
作为独立于主工程的小组件,我们需要在 Podfile
中为小组件的 target 引入所需的依赖框架。
1target 'Snowball' do
2 ...
3
4 target 'SnowballWidgets' do
5 inherit! :search_paths
6
7 pod 'Alamofire', '~> 5.0.2'
8 pod 'SwiftyJSON', '~> 5.0.0'
9 ...
10 end
11end
具体网络实封装本文不做展开。
数据共享
由于 App Extension 不能直接同主程序直接通讯,不过苹果提供了 App Groups
的方式来进行通讯。App Groups
有两种共享数据的方式:UserDefaults
和 FileManager
。
对于将要展示的用户自选数据,必须要先获取登录授权信息。同时雪球的认证机制比较复杂,其完整功能存在于内部独立的 OAuth SDK 中,无法直接在 extension 中使用。因此,我们决定通过 UserDefaults
来共享 access_token 及用户偏好设置,如涨跌颜色等。
UserDefaults 共享数据
共享 UserDefaults 数据要设置 suitename
对为应项目的 App GroupID,有两种设置方式:
1init?(suiteName suitename: String?)
2// or
3func addSuite(named suiteName: String)
设置之后即可使用该实例来储存和获取共享数据了。我们以 access_token 为例:
1public extension UserDefaults {
2
3 @objc
4 static var shared: UserDefaults {
5 if let value = Bundle.main.object(forInfoDictionaryKey: "GroupIdentifier") as? String {
6 return UserDefaults(suiteName: value) ?? .stander
7 } else {
8 return UserDefaults(suiteName: "group.xxx") ?? .stander
9 }
10 }
11}
这里我们通过读取 Info.plist
中预设的 GroupIdentifier
作为 App GroupID。用来解决内网发布所用的企业证书和 App Store 的个人证书不同,导致 App GroupID 不同的问题。
完整的 Widget token 获取流程如下:
- 用户在雪球登录后,从服务端获取 token。
- 雪球 App 退到后台时,将 token 写入 App Group。
- 刷新 Widget 时,从 App Group 获取 token 来进行接口请求。
- 用户在雪球退出后,将 token 从 App Group 中删除。
不过,这个流程中还有一处缺陷,就是当用户首次添加小组件时,可能并未在雪球 App 上完成登录操作。这里就需要添加未登录 ⏰。
最后,对于数据量较大的文件共享,可以通过 FileManager
的 containerURL(forSecurityApplicationGroupIdentifier:)
获取 App Group 共享的储存空间地址,进行文件的存取操作。
由于我们在 Userdefaults 中所使用到 key 都需要在主工程中赋值,在 Widget 中读取。为了避免 key 的多处重复定义,以及方便 API 的统一调用,将公共逻辑抽离到单独文件中,通过 Comple Source
分别在主工程和 Widget 中引用,来实现逻辑共用。
1@propertyWrapper
2public struct UserDefaultsWrapper<Value> {
3
4 let key: String
5 let defaultValue: Value
6 var storage: UserDefaults = .shared
7
8 public var wrappedValue: Value {
9 get {
10 let value = storage.value(forKey: key) as? Value
11 return value ?? defaultValue
12 }
13 set {
14 storage.setValue(newValue, forKey: key)
15 }
16 }
17}
18
19public extension UserDefaultsWrapper where Value: ExpressibleByNilLiteral {
20 init(key: String, storage: UserDefaults = .shared) {
21 self.init(key: key, defaultValue: nil, storage: storage)
22 }
23}
24
25public extension UserDefaults {
26
27 @UserDefaultsWrapper(key: "xxx.Widgets.token")
28 @objc static var WidgetToken: String?
29
30 @UserDefaultsWrapper(key: "xxx.Widgets.stockColor", defaultValue: 0)
31 @objc static var WidgetStockColor: Bool
32
33 ...
34}
这里还利用了 @propertyWrapper 特性,将 key 收口,使用者仅需关心对应的属性即可。
0x02 SwiftUI & Custom View
本节我们简单谈谈使用 SwiftUI 来开发 Widget 的一些小细节和注意事项。
由于雪球工程历史包袱较大,在上面进行 Widget 的开发调试效率较低,且无法充分利用 SwiftUI 的 preview 特性进行 UI 调试。基于这个考虑,笔者直接在新建的 Xcode 工程中进行 SwiftUI 构建,完成后再同步回主工程。
对于界面开发而言,工作量最大的就是进行元素布局,在 SwiftUI
中每个元素是如何确定位置和大小的呢 ?
大致分为三步:
- 父视图为子视图提供预估尺寸大小。
- 子视图计算自己的实际尺寸大小。
- 父视图根据自身和子视图的尺寸以及属性,来计算子视图的布局。
其中在第二步,子视图计算自身尺寸时,SwiftUI
提供了三种设置尺寸的方式:
- 无需设置,根据内容自行计算,如 Text。
- 手动设置
frame + position
- 设置 aspectRatio 宽高比,例如 Image
详细可参照:WWDC19 - Building Custom Views with SwiftUI
自选背景实现
在 Widget 设计一节中,可以看到在自选、热门话题及雪球日历均有一个浅色渐变的 logo 背景,只是颜色不同。
由于背景是撑满整个 Widget 且 logo 位于顶点的相对位置,即顶部或底部。因此,这里采用了手动布局 frame + position
的方式。
1func logoPosition(_ contentSize: CGSize) -> CGPoint {
2
3 let offset: CGFloat = logoSize / 3
4
5 if edge == .top {
6 return CGPoint(x: contentSize.width - offset, y: offset)
7 } else {
8 return CGPoint(x: contentSize.width - offset, y: contentSize.height - offset)
9 }
10 }
另外为了方便对比调试,直接添加了多种状态的预览视图:
1struct LogoView_Previews: PreviewProvider {
2
3 static var previews: some View {
4 Group {
5 LogoBackgroundView(edge: .top, style: .custom(.blue99))
6
7 LogoBackgroundView(edge: .top, style: .custom(.gold))
8
9 LogoBackgroundView(edge: .bottom, style: .custom(.gold))
10 .environment(\.colorScheme, .dark)
11 }
12 .previewContext(WidgetPreviewContext(family: .systemMedium))
13 }
14}
完整代码和预览效果如下:
自选列表项实现
自选列表项在整个小组件中算是相对复杂的布局了,需要支持用户的不同登录状态、自选展示内容和数量的可编辑、不同的画布尺寸以及用户涨跌幅颜色配置等。不过相比 UIKit 而言简,SwiftUI 进行页面编写直不要太爽,以自选列表项 PortfolioItemView
为例,仅需 50 行不到的代码就能完成 UI 与数据逻辑。
效果如下:
另外,当获取的股票数据正常时,会将整个视图用 Link
包装,以响应用户点击。股票链接将会跳转到雪球上对应的个股页。
这里需要 ⚠️ 的是,在 Widget 中自定义的 Button 事件时无法被响应的,我们能做的仅仅是配置 Link
。除了 Link
控件之外,还可以通过 WidgetURL
来设置跳转链接,不过它仅针对最后一次设置的 URL 生效。
0x03 编辑你的自选
作为证券交易平台,雪球支持用户添加几百只的股票作为其关注标的,而自选小组件最多可展示的股票数量仅 6 只。为此,我们需要提供能够根据用户的选择来展示对应自选的配置项。而该功能需要利用 Intents
框架来定义股票选择界面,之后系统会根据事先定义好的数据来构建配置页。
交互效果如下:
Tips: 关于如何创建和使用
Intent
框架,推荐 WWDC20 - Widgets Code-along, part 3: Advancing timelines。
配置 IntentDefinition
当我们创建小组件时就可以选择对应的 Intent 配置:
Include Configuration Intent
选框决定了 Xcode 所使用带配置,☑️ 表示支持用户配置,反之则不支持。
- StaticConfiguration:无配置属性的 Widget。
- IntentConfiguration:可动态配置的 Widget。
Tips:动态配置的功能是集成在 SiriKit 中,这是因为在 iOS 中 Springboard 上的很多配置均与 SiriKit 相关。
勾选了 Include Configuration Intent
,Xcode 会自动生成 IntentDefinition
文件,并且在编译通过后会自动生成一个名称为 PortfolioSelectionIntent
的 ConfigurationIntent 类。类名可通过 Custom Class 来指定。
每当我们更新 IntentDefintion
配置,需要重新编译以生成对应的方法。
自选小组件的 IntentDefintion
文件如下:
这里我们定义了一个 Portfolio
类用于处理股票数据,属于自定义的参数模版同样也由系统生成。另外,我们勾选了 Dynamic Optional 中的 provides search results
用于支持用户输入结果查询。
编译后系统自动生成的 PortfolioSelectionIntent
类:
最终我们在 Widget 的 IntentTimelineProvider 入口关联 Intent:
1typealias Intent = PortfolioSelectionIntent
处理 IntentHandler
完成绑定后,还需要新建一个 PortfolioSelectionIntentHandler
的 Target 用来处理和响应用户输入。
最后在 IntentHandler
文件中实现 PortfolioSelectionIntentHandling
协议即可。如果在开发过程找不到对应的协议,可以确认一下对应的 .intentdefinition
文件是否添加到 target 中。当用户完成操作后,系统会通过 IntentTimelineProvider 来刷新小组件。在 getTimeline(for:in:completion:)
的 configuration 中将返回包含了用户的所挑选的自选股票。
至此,整个小组件开发就告一段落。大家可以前往 AppStore 体验
总结
在实际的 Widget 开发上涉及到多样的知识盲区,也算是摸着石头过河。另外,得益于 SwiftUI 的高效开发模式,使得 Widget 这样轻交互的 UI 能够被快速开发出来,还顺带推广了一波 Swift 可谓一 🐟 多吃。
尽管 Widget 在功能和交互上同 App 有着极大的限制,但是它也大大提高了用户主屏幕的丰富程度,且适合展示像股票行情和新闻资讯类信息。
Widget 特点总结如下:
-
仅支持展示文本和静态图片资源,亦无法保证实时刷新。
-
增加了产品的曝光入口,自定义配置结合智能堆叠,为用户带来更多个性化的内容。
-
缩短了功能的访问路径,一键触达用户所需,提供用户想要的功能入口。