小组件简述
小组件可以在主屏幕上实现内容展示和功能跳转。 系统会向小组件获取时间线,根据当前时间对时间线上的数据进行展示。点击正在展示的视觉元素可以跳转到APP内,实现对应的功能。
小组件效果如下
开发思路浅谈
首先需要明确的是小组件是一个独立于 App 环境(即 App Extension),小组件的生命周期/存储空间/运行进程都和 App 不同。所以我们需要引入这个环境下的一些基础设施,比如网络通信框架,图片缓存框架,数据持久化框架等。
小组件本身的生命周期是一个很有意思的点。直白的来讲小组件的生命周期是和桌面进程一致的,但这不意味着小组件能随时的执行代码完成业务。小组件使用 Timeline 定义好的数据来渲染视图,我们的代码只能在刷新 Timeline (getTimeline)和创建快照(getSnapshot)时执行。一般而言,在刷新 Timeline 时获取网络数据,在创建快照时渲染合适的视图。
大多数情况下都需要使用数据来驱动视图展示。这个数据可以通过网络请求获得,也可以利用 App Groups 的共享机制从 App 中获取。
在刷新 Time Line 时获取到数据后,即可按照业务需求合成 Timeline。Timeline 是一个以 TimelineEntry 为元素的数组。 TimelineEntry 包含一个 date 的时间对象,用以告知系统在何时使用此对象来创建小组件的快照。也可以继承 TimelineEntry ,加入业务所需要的数据模型或其他信息。
为了使小组件展示视图,需要用 SwiftUI 来完成对小组件的布局和样式搭建。在下面会介绍如何实现布局和样式。
在用户点击小组件后,会打开 App,并调用 AppDelegate 的 openURL: 方法。我们需要在 openURL: 中处理这个事件,使用户直接跳转至所需的页面或调用某个功能。
最后,如果需要开放给用户小组件的自定义选项,则使用 Intents 框架,预先定义好数据结构,并在用户编辑小组件提供数据,系统会根据数据来绘制界面。用户选择的自定义数据都会在刷新 Time Line (getTimeline)和创建快照(getSnapshot)时以参数的形式提供出来,之后根据不同的自定义数据执行不同的业务逻辑即可。
App Extension
按照苹果的说法:App Extension 可以将自定义功能和内容扩展到应用程序之外,并在用户与其他应用程序或系统交互时向用户提供。例如,您的应用可以在主屏幕上显示为小部件。也就是说小组件是一种 App Extension,小组件的开发工作,基本都在 App Extension 的环境中。
App 和 App Extension有什么关系?
本质上是两个独立的程序,你的主程序既不可以访问 App Extension 的代码,也不可以访问其存储空间,这完完全全就是两个进程、两个程序。App Extension 依赖你的 App 本体作为载体,如果将 App 卸载,那么 App Extension 也不会存在于系统中了。而且 App Extension 的生命周期大多数都作用于特定的领域,根据用户触发的事件由系统控制来管理。
创建 App Extension 和配置文件
下面简述一下如何创建小组件的 App Extension并配置证书环境。
在 Xcode 中新增一个 Widget Extension(路径如下:File-New-Target-iOS选项卡-Widget Extension)。如果你需要小组件的自定义功能,则不要忘记勾选 Include Configuration Intent。
TimeLine
小组件实现的核心思想,小组件的内容变化都依赖于 Timeline 。小组件本质上是 Timeline 驱动的一连串静态视图。
理解 TimeLine
在前面提到过,Timeline 是一个以 TimelineEntry 为元素的数组。 TimelineEntry 包含一个 date 的时间对象,用以告知系统在何时使用此对象来创建小组件的快照。也可以继承 TimelineEntry ,加入业务所需要的数据模型或其他信息。
在生成新的 Timeline 之前,系统会一直使用上一次生成的 Timeline 来展示数据。
如果 Timeline 数组里面只有一个 entry ,那么视图就是一成不变的。假如需要小组件随着时间产生变化,可以在 Timeline 中生成多个 entry 并赋予他们合适的时间,系统就会在指定的时间使用 entry 来驱动视图。
获取数据
小组件中可以使用 URLSession网络请求,所以网络请求和 App 中基本一致
需要注意的点:
- 使用第三方框架需要引入小组件所在的 Target。
- 在刷新 Timeline 时调用网络请求。
- 如果需要和 App 共享信息,则需要通过 App Group 的方式存取。
图片的加载
图片加载则和 App 中不同。目前在 SwiftUI 中的 Image 视图不支持传入 URL 加载网络图片。也不能使用异步获取网络图片的 Data的方式完成网络图片的加载。
只能通过刷新 Timeline ,调用网络请求完成后,再去获取 Timeline 上所有的网络图片的 data。
预览状态的数据获取
在用户添加小组件时,会在预览界面看到小组件的视图。此时,系统会触发小组件的 placeholder 方法,我们需要在这个方法中返回一个 Timeline,用以渲染出预览视图。
为了保证用户的体验,需要为接口调用准备一份本地的兜底数据,确保用户可以在预览界面看到真实的视图。
Reload
所谓的小组件刷新,其实是刷新了 Timeline ,导致由 Timeline 数据驱动的小组件视图发生了改变。刷新方法分为以下两种:
- System reloads
- App-driven reloads
System reloads
由系统发起的 Timeline 刷新。系统决定每个不同的 Timeline 的 System Reloads 的频次。超过频次的刷新请求将不会生效。高频使用的小组件可以获得更多的刷新频次。
ReloadPolicy:
在生成 Timeline 时,我们可以定义一个 ReloadPolicy ,告诉系统更新 Timeline 的时机。ReloadPolicy 有三种形式:
- atEnd
在 Timeline 提供的所有 entry 显示完毕后刷新,也就是说只要还有没有显示的 entry 在就不会刷新当前时间线 - after(date)
date 是指定的下次刷新的时间,系统会在这个时间对 Timeline 进行刷新。 - never
ReloadPolicy 永远不会刷新 Timeline,最后一个 entry 也展示完毕之后 小组件就会一直保持那个 entry 的显示内容
Timeline Reload 的时机是由系统统一控制的,而为了保证性能,系统会根据各个 Reload 请求的重要等级来决定在某一时刻是否按照 APP 要求的刷新时机来刷新 Timeline。因此如果过于频繁的请求刷新 Timeline,很有可能会被系统限制从而不能达到理想的刷新效果。换句话说,上面所说的 atEnd, after(date) 中定义的刷新 Timeline 的时机可以看作刷新 Timeline 的最早时间,而根据系统的安排,这些时机可能会被延后。
App-driven reloads
由 App 触发小组件 Timeline 的刷新。当 App 在后台时,后台推送可以触发 reload;当 App 在前台时,通过 WidgetCenter 可以主动触发 reload 。
调用 WidgetCenter 可以根据 kind 标识符刷新部分小组件,也可以刷新全部小组件。
/// Reloads the timelines for all widgets of a particular kind.
/// - Parameter kind: A string that identifies the widget and matches the
/// value you used when you created the widget's configuration.
public func reloadTimelines(ofKind kind: String)
/// Reloads the timelines for all configured widgets belonging to the
/// containing app.
public func reloadAllTimelines()
Timeline -> TimelineEntryRelevance 智能堆栈相关API
当用户将小部件放入堆栈时,WidgetKit 使用relevance
时间线提供程序生成的条目的属性来确定它们与用户的相关性。对于您的提供商创建的每个时间线条目,您可以分配包含 ascore
和 a 的相关性duration
。分数是您选择的值,用于指示小部件的相关性,相对于提供者创建的跨时间线的条目。当条目的日期到达时,并且在您指定的持续时间内,WidgetKit 可能会将您的小部件旋转到堆栈的顶部,使其可见。
我们可以通过设置TimeLine中relevance的 score 和duration 来告诉系统,数据的重要性。这样系统就会根据score和duration,来判断是否需要将小组件放在堆顶,来现实你的小组件
点击事件
用户点击了小组件上的内容或功能入口时,需要在打开 App 后正确响应用户的需求,呈现给用户相应的内容或功能。 这需要分两部分来做,首先在小组件中对不同的点击区域定义不同的参数,之后在 App 的 openURL: 中根据不同的参数呈现不同的界面。
区分不同的点击区域
想要对于不同的区域定义不同的参数,需要把 widgetURL 和 Link 结合使用。
widgetURL
widgetURL 作用范围是整个小组件,且一个小组件上只能有一个 widgetURL 。多添加的 widgetURL 参数是不会生效的
代码如下:
struct HelloEntryView : View {
var entry: Provider.Entry
var body: some View {
GeometryReader { geo in
ZStack(alignment: .center) {
Image(entry.imageName)
.resizable()
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
Text(entry.userDefaultStr)
}
}.widgetURL(URL(string: "helloWidget://detail/\(entry.imageName)"))
}
}
Link
Link 作用范围是 Link 组件的实际大小。可以添加多个 Link ,在数量上是没有限制的。需要注意的是小组件的 systemSmall 类型下,不能使用 Link API。
struct HelloEntryView : View {
var entry: Provider.Entry
var body: some View {
GeometryReader { geo in
ZStack(alignment: .center) {
Image(entry.imageName)
.resizable()
.frame(width: geo.size.width, height: geo.size.height, alignment: .center)
Text(entry.userDefaultStr)
Link("link点击", destination: URL(string: "helloWidget://detail/\(entry.imageName)")!)
}
}.widgetURL(URL(string: "helloWidget://detail/\(entry.imageName)"))
}
}
落地 App 后的处理
点击小组件跳转 App 后会触发 AppDelegate 的 openURL 方法。在 openURL 方法中,通过解析 url 参数,明确用户需要的功能跳转或内容的展示,随后进行对应的实现。
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
parsUrl(url: url)
return true
}
func parsUrl(url: URL) {
let path = url.absoluteString
print(path)
if path.contains("helloWidget://detail") {
let slices = path.split(separator: "/")
if let imageName = slices.last {
let detailVc = DetailViewController(imageName: String(imageName))
self.navVc?.pushViewController(detailVc, animated: true)
}
}
}
App Groups 数据通信
因为 App 和 App Extension 是不能直接通讯的,所以需要共享信息时,需要使用 App Groups 来进行通讯。App Groups 有两种共享数据的方式,UserDefaults和FileManager。
在 Widget Extension 的 Target 中添加 App Groups,并保持和主程序相同的 App Group ID 。如果主程序中还没有App Groups,则需要这个时候同时增加主 App 的 App Groups,并定义好 Group ID。
UserDefaults 共享数据
使用 UserDefaults 的 UserDefaults(suiteName: "") 初始化实例。 suitename传入之前定义好的 App GroupID。
let userDefaults = UserDefaults(suiteName: "group.hjs.HelloWidget")
之后即可使用NSUserDefaults的实例的存取方法来储存和获取共享数据了。比如我们需要和小组件共享当前的用户信息,则可以如下操作。
let userDefaults = UserDefaults(suiteName: "group.hjs.HelloWidget")
let testKey = "groupTestKey"
let value = userDefaults?.value(forKey: testKey) as? String
FileManager 共享数据
使用 FileManager().containerURL(forSecurityApplicationGroupIdentifier: ""): 获取 App Group 共享的储存空间地址,即可进行文件的存取操作。
动态配置小组件
小组件支持用户在不打开应用的情况下配置自定义数据,使用 Intents 框架,可以定义用户在编辑小组件时看到的配置页面。 这里用的词的定义而不是绘制,是因为只能通过 Intents 来生成配置数据,系统会根据生成的数据来构建配置页面。
构建一个简单的自定义功能
构建一个简单的自定义功能需要两步:
创建和配置 IntentDefinition 文件
修改 Widget 的相关参数支持 ConfigurationIntent 。
-
创建和配置 IntentDefinition 文件
如果你在创建小组件 Target 时勾选了 Include Configuration Intent ,Xcode 会自动生成 IntentDefinition 文件。
假如没有勾选 Include Configuration Intent 选项,那么你需要手动添加 IntentDefinition 文件。
菜单 File -> New -> File 然后找到 Siri Intent Definition File 之后添加到小组件 Target 中。
创建文件后,打开 .intentdefinition 文件进行配置。
系统类型
特定的类型有近一步的自定义选项来定制输入 UI。例如,Decimal 类型可以选择采用输入框(Number Field)输入或者是滑块(Slider)输入,同时可以定制输入的上下限;Duration 类型可以定制输入值的单位为秒、分或者时;Date Components 可以指定输入日期还是时间,指定日期的格式等等。Enum 简单的理解就是 Enums 是写死在 .intentdefinition 文件中的静态配置,只有发版才可以更新。
Type Types 就灵活多了,可以在运行时动态的生成,一般而言我们使用 Types 来做自定义选项。
支持输入多个值
大部分类型的参数支持输入多个值,即输入一个数组。同时,支持根据不同的 Widget 大小,限制数组的固定长度。
控制配置项的显示条件
可以控制某一个配置项,只在另一个配置项含有任何/特定值时展示。如下图,日历 App 的 Up Next Widget,仅在 Mirror Calendar App 选项没有被选中时,才会显示 Calendars 配置项。
在 Intent 定义文件中,将某一个参数 A,设置为另一个参数 B 的 Parent Parameter ,这样,参数 B 的显示与否就取决于参数 A 的值。
例如,在下图中,calendar 参数仅在 mirrorCalendarApp 参数的值为 false 时展示:
- 替换 Widget 类中的 StaticConfiguration 为 IntentConfiguration
- 修改 IntentTimelineProvider 的继承,Provider 的继承改成 IntentTimelineProvider
使用接口数据构建自定义入口
在 Intent Target 中,找到 IntentHandler 文件,遵守 ConfigurationIntent 生成类中 DynamicCardSelectionIntentHandling
协议。
实现协议要求的 provideCardOptionsCollection:withCompletion:
方法。
在这个方法中,我们可以调用接口获取自定义数据,生成 completion block 所需要的数据源入参。
class IntentHandler: INExtension,DynamicCardSelectionIntentHandling {
func provideCardOptionsCollection(for intent: DynamicCardSelectionIntent, with completion: @escaping (INObjectCollection?, Error?) -> Void) {
var tempArray: [Card] = []
for index in 1...10 {
let name = "card\(index)"
let card = Card(identifier: name, display: name)
tempArray.append(card)
}
completion(INObjectCollection(items: tempArray),nil)
}
}
总结
小组件是一个优缺点都非常明显的事物,在桌面即点即用确实方便,但是交互方式的匮乏以及不能实时更新数据又是非常大的缺陷,。正如苹果所说:"Widgets are not mini-apps",不要用开发 App 的思维来做小组件,小组件只是由一连串数据驱动的静态视图。
小组件孱弱的交互能力和数据刷新机制是它的硬伤。苹果对于小组件的能力是非常克制的。在开发中,很多构思和需求都受限于框架能力无法实现,希望苹果在后续迭代中可以开放出新的能力。比如支持部分不需要启动 App 的交互形式存在。
但瑕不掩瑜,向用户展示喜欢的内容或提供用户想要的功能入口,放大小组件的优势,才是当前小组件的正确开发方式。
优势:
- 常驻桌面,大大增加了对产品的曝光。
- 利用网络接口和数据共享,可以展示与用户相关的个性化内容。
- 缩短了功能的访问路径。一次点击即可让用户触达所需功能。
- 可以多次重复添加,搭配自定义和推荐算法,添加多个小组件样式和数据都可以不同。
- 自定义配置简单。
- 多种尺寸,大尺寸可以承载复杂度高的内容展示。
缺点:
- 不能实时更新数据。
- 只能点击交互。
- 小组件的背景不能设置透明效果。
- 不能展示动态图像(视频/动图)。
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date())
}
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date())
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
var entries: [SimpleEntry] = []
// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
let entry = SimpleEntry(date: entryDate)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}