Python实战社群
Java实战社群
长按识别下方二维码,按需求添加
扫码关注添加客服
进Python社群▲
扫码关注添加客服
进Java社群▲
作者 | PJHubs
来源 | http://pjhubs.com/,点击阅读原文查看作者更多文章
iOS14 的 Widget 和 iOS14 之前的 Widget 已经完成了统一,之前老样式的 Widget 只能通过在老版本上进行查看,后续仅支持 iOS14 目前的 Widget。只能使用 SwiftUI 进行开发。
快速、关联性、个性化
看一眼,就能够获取到重点内容
内容才是最重要的
相册 Widget 注意到的话,会发现展示的照片总是某个时刻下最棒的那一张,而不是最新的。
Widget 不是 mini app,应该看作是把 app 的内容在主屏幕的映射关系。官方给出的数据,一般我们会在一天的时间里进入主屏幕超过 90 次,并在主屏幕上短暂停留。
Widget 有三个尺寸,但不强迫每个尺寸都实现,因为不是所有 app 都适合全尺寸 widget 展示,但推荐都实现(猜测就是要给用户最大自由度。
不能滚动和不能添加开关等其它系统控件。
不支持视频和动图。
小组件并不是在主屏幕上实时展示的。
系统的时钟 Widget 的事实刷新 UI 是个系统级 app 才能拥有的对待。
SwiftUI 中对 Text 组件新增了可以实时展示时间的 API。
Text(Date(), style: .time)
不要把小尺寸组件直接拉伸成中或者大尺寸小组件。
Small 尺寸组件只能接受单次点击。
小组件内部按照 16pt 设定布局边距。
小组件内部有圆形素材,应该使用 11pt 边距。
小组件内部边界有圆角时要做得跟小组件本身的圆角半径同心。
不同设备上的小组件本身圆角值不一样,不能直接写死圆角值。
SwiftUI 中提供了一个圆角容器。
字体官方推荐使用 SF 系列,可自定义。
不要放入 app logo 和 name。
控制允许用户选择的小组件类型
@main
struct PJWidget: Widget {
private let kind: String = "PJWidget"
public var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
PJWidgetEntryView(entry: entry)
}
.configurationDisplayName("PJWidget")
.description("2333")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
在构建 entryView 时,根据当前选择的 widgetFamily 值来返回不同的样式。
struct PJWidgetEntryView: View {
var entry: Provider.Entry
@Environment(\.widgetFamily) var family
@ViewBuilder
var body: some View {
switch family {
case .systemSmall:
PJAvatarView(entry.name)
default:
Text("PJHubs")
}
}
}
使用 Xcode Widget Extension 模版创建完后,会自动给默认 Widget 加上 @main 修饰符标记出当前 app Widget 的入口。
换句话说,此时我们进入到「Widget 搜索」,找到我们的 app,只会看到一个 Widget。
@main
struct SwiftUIWidgetDemo: Widget {
let kind: String = "SwiftUIWidgetDemo"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
SwiftUIWidgetDemoEntryView(entry: entry)
}
.configurationDisplayName("西瓜作者-数据日报")
.description("你的数据精简日报")
.supportedFamilies([.systemSmall])
}
}
@main
struct Widgets: WidgetBundle {
@WidgetBundleBuilder
var body: some Widget {
SwiftUIWidgetDemo()
SwiftUIWidgeMediumDemo()
SwiftUIWidgeMediumFansDemo()
}
}
注意:最多只允许塞入五个 Widget 样式。
说起 @main 大家可能会先想到之前的 @UIApplicationMain 这个修饰词,说到 @UIApplicationMain 可能又会想到 main.swift 或者 main.m 等等这些文件。总的来说,它们之间是存在某种神秘联系的!我们来写一个简单的 Swift 代码:
class demoSwift {
class func test() {
print("world!")
}
}
demoSwift.test()
此时使用 swiftc demo.swift 后会得到一个可执行文件,看上去 Swift 的语法让新上手的同学令人感到愉快,不会再有类 C 系那种必须写一系列又臭又长的 main 函数初始化流程,但本质上真的不用写了吗?
我们来看看中间代码。
# 查看生成的中间代码
swiftc demo.swift -emit-sil
sil_stage canonical
import Builtin
import Swift
import SwiftShims
class demoSwift {
class func test()
@objc deinit
init()
}
// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer>>):
%2 = metatype $@thick demoSwift.Type // user: %4
// function_ref static demoSwift.test()
%3 = function_ref @$s4demo0A5SwiftC4testyyFZ : $@convention(method) (@thick demoSwift.Type) -> () // user: %4
%4 = apply %3(%2) : $@convention(method) (@thick demoSwift.Type) -> ()
%5 = integer_literal $Builtin.Int32, 0 // user: %6
%6 = struct $Int32 (%5 : $Builtin.Int32) // user: %7
return %6 : $Int32 // id: %7
} // end sil function 'main'
// ... 以下省略
可以看到,所谓的对新人友好都是假的,全都是编译期间 swiftc 做的自动化插入,自动给我们的方法插入了与之前类似的流程,如果我们需要多文件编译依赖,要有一个 main.swift 作为入口文件进行索引其他文件进行编译。
@UIApplicationMain 出现后,我们不再需要 main.swift 文件来做入口切割,可通过自定义类并加上该标记即可,这个好处在 Swift 5.3 中正式推广到语言层面,我们仅需使用 @main 即可标记出 Swift 文件的入口,不再是 Cocoa 特性,进而替代掉了 @UIApplicationMain。
Widget 提供了用户可配置数据源的方式,可以通过此类方式来绕过 Widget 成组后最大上限五个的限制。提供两种配置方式
StaticConfiguration。用户不可自定义数据源,参考头条 Widget。
IntentCOnfiguration。允许用户选择配置,参考下图。
其中 IntentConfiguration 可提供给用户有限的“自由”,自行选择对应 Widget 下需要展示的数据源。利用了基于 Intents.framework 框架实现,并可以直接复用 SiriKit 的功能来达到 Widget 的智能化(后文再叙)。
配置 IntentConfiguration 的步骤如下:
创建对应的 IntentConfiguration 文件。
新增用户可配置的数据类型
配置新增数据类型相关信息
在对应类型的 Widget 中判断数据视图
struct SwiftUIWidgetDemoMediumEntryView : View {
var entry: Provider.Entry
@ViewBuilder
var body: some View {
switch entry.configuration.countType {
case .money:
// 此处需传入数据源
MediumWidgetFansView()
default:
// 此处需传入数据源
MediumWidgetView()
}
}
}
从实际问题看 SwiftUI 和 Combine 编程 已说明,可以前往了解。
struct Provider: IntentTimelineProvider {
// NOTE: 小组件占位视图,第一次添加或 loading 状态中的视图
func placeholder(in context: Context) -> SimpleEntry {
SimpleEntry(date: Date(), count: 0, image: nil, configuration: ConfigurationIntent())
}
// NOTE: 第一次添加或小组件第一次被展示时调用
func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
let entry = SimpleEntry(date: Date(), count: 0, image: nil, configuration: configuration)
completion(entry)
}
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
// ...
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
placeholder方法中返回 widget 在初始化 loading 过程中的占位 UI。
每一个 widget 都必须提供。
默认内容展示。
没有任何用户相关数据。
当系统无法显示你的小组件数据时会出现。
无法被告知什么时候应该展示占位图。这是系统行为,系统需要的时候就会要求展示,例如用户更换了 widget 尺寸等。
这个视图是系统行为,只要我们使用的是标准 SwiftUI 组件,会自动根据组件类型,如 Image、Text 结合我们自定义的颜色和背景来自动完成占位图的设置,如果我们不想要的系统自定义的话,也可以在方法中自行返回自定义的占位组件。
注意点:在构建 PlaceHolderView 时,Session 中所给的 isPlaceHoler 通过属性的方式去做已经不行了,得通过以下方式来进行(如果我们需要预览的话):
PJWidgetEntryView(entry: SimpleEntry(date: Date())).redacted(reason: .placeholder)
Widget 的目的非常简单,目前在 Widget 上所做的事情,全都是为了引导用户可以轻松的点击小组件和通过 deepLink 跳转到我们的 app 中。而从 Widget 跳转到 app 中针对不同类型的 Widget 有共有两种跳转方式。
.widgetURL
三种类型的小组件均可使用该方式进行跳转。
struct SmallWidgetView: View {
var body: some View {
VStack(alignment:.leading) {
Text("PJHubs")
}
.widgetURL(URL(string: "urlschema://pjhubsWidgetURL"))
}
}
Link
只有 Medium 和 Large 类型的小组件可以使用该方式进行跳转。
Small 类型小组件编译没问题,点击后无回调。
struct MediumWidgetView: View {
var body: some View {
Link(destination:URL(string: "urlschema://pjhubsLink")!) {
VStack(alignment:.leading) {
Text("PJHubs")
}
}
}
}
SceneDelegate.m 中的内容为:
可以直接复用以往主工程通过消息通知 push 的逻辑流程来打开 widget 上挂载的 schema。
#import "SceneDelegate.h"
#import "WidgetURLViewController.h"
#import "LinkViewController.h"
@implementation SceneDelegate
- (void)scene:(UIScene *)scene openURLContexts:(NSSet *)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];
}
}
}
@end
注意点
调试 Widget Deep Link 跳转时需要切换到 app target 下进行调试,一直在用 Widget target 调,发现断点一直走不进去,才猛的想起来,我在 widget target 里能断在 app target 里才奇了怪了。
更新 widget 的内容后,需要 build 一遍 widget target,然后再回到 app target 走 app 生命周期相关方法。
首先创建或确定要被引入 SwiftUI 中的 OC 视图,下文以 OCView 替代。
#import "OCView.h"
@implementation OCView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
[self initView];
}
return self;
}
- (void)initView {
self.backgroundColor = [UIColor whiteColor];
UILabel *textLabel = [[UILabel alloc] init];
textLabel.text = @"这是 OC View";
textLabel.font = [UIFont systemFontOfSize:30];
[textLabel sizeToFit];
textLabel.frame = CGRectMake((self.frame.size.width - textLabel.frame.size.width)/2, (self.frame.size.height - textLabel.frame.size.height)/2, textLabel.frame.size.width, textLabel.frame.size.height);
[self addSubview:textLabel];
}
@end
创建一个 OCWidgetView.swift 文件,用于封装 SwiftUI 视图。
import Foundation
import SwiftUI
struct OCWidgetView: UIViewRepresentable {
func makeUIView(context: Context) -> OCView {
return OCView()
}
func updateUIView(_ uiView: OCView, context: Context) {
}
}
SwiftUI 中提供了 Coordinator 这个理念来作为 OCView 可能存在的各种 delegate 相关回调事件,在 SwiftUI 中同样可以进行使用,在此不做展开。
此时就可以在 SwiftUI 中引入 OCWidgetView 了!
struct MediumWidgetFansView: View {
var body: some View {
VStack(alignment:.leading) {
OCWidgetView()
}
}
}
Widget 的 UI 部分只能使用 SwiftUI 框架下的 UI 组件,不能使用任何 UIKit 相关的组件,就算用 SwiftUI 包一层也不行(UIViewRepresentable),强行使用的话,会在 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 处于活跃状态。
import WidgetKit
@objcMembers class PJWidgetCenter: NSObject {
class func reloadWidgetTimeline() {
WidgetCenter.shared.reloadAllTimelines();
WidgetCenter.shared.reloadTimelines(ofKind: "What kind of widget?")
}
}
支持所有 widget 刷新或某一个 widget。
#import "ViewController.h"
#import "SwiftUIWidget-Swift.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[PJWidgetCenter reloadWidgetTimeline];
}
getTimeline 方法支持异步操作,我们如果需要动态的走网络请求拉取构造 timeline 数据,可以直接丢出一个异步回调。
struct Provider: IntentTimelineProvider {
// ...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
networkHandler {
let timeline = Timeline(entries: $0, policy: .atEnd)
completion(timeline)
}
}
func networkHandler(completion: @escaping ([SimpleEntry]) -> Void) {
URLSession.shared.dataTask(with: URL(string: "http://pjhubs.com")!) { (data, response, error) in
let originalDict = try? JSONSerialization.jsonObject(with: data!, options: JSONSerialization.ReadingOptions.allowFragments) as? NSDictionary
print(originalDict as Any)
completion([SimpleEntry(date: Date(), count: 1234, image: UIImage(named: "avatar")!, configuration: ConfigurationIntent())])
}.resume()
}
}
注意请求间隔、Timeline 更新时间和数据转换等问题。
以在 Widget 展示用户头像举例,在以往的开发经历中,我们都不希望有同步操作阻塞主线程从而造成 app 卡顿,故在 Widget 中我们也会自然而然的在“图片展示”这一环节中套用异步请求资源的思路去做,但这在 Widget 中是不被允许的,我们需要转变一个思路。
以下这种把图片资源延后到 UI 层的做法可以拉取成功,但不会被加载。
struct SmallWidgetView: View {
@State var networkImage: UIImage?
var body: some View {
VStack(alignment:.leading) {
HStack {
Image(uiImage: self.networkImage ?? UIImage(named: "avatar")!)
// ...
.onAppear(perform: getNetworkImage)
}
// ...
}
// ...
}
func getNetworkImage() {
URLSession.shared.dataTask(with: URL(string: "https://tu.sioe.cn/gj/qiege/image.jpg")!) { (data, _, _) in
self.networkImage = UIImage(data: data!)
}.resume()
}
}
解决这一问题目前有三种方法但都是一种思路,核心就是把图片的加载过程从异步转化为同步,这个同步的过程可以是在 getTimeline 初始化时间线时,也可以是在构造 Widget UI 层逻辑时。
UserDefault
FileManager
CoreData
以下为在 getTimeline 初始化时间线时的事例:
struct SmallWidgetView: View {
var uiImage: UIImage?
var body: some View {
VStack(alignment:.leading) {
HStack {
Image(uiImage: uiImage ?? UIImage(named: "avatar")!)
// ...
}
// ...
}
// ...
}
}
struct Provider: IntentTimelineProvider {
// ...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
var entries: [SimpleEntry] = []
let currentDate = Date()
for hourOffset in 0 ..< 3 {
let entryDate = Calendar.current.date(byAdding: .second, value: hourOffset, to: currentDate)!
// NOTE: 在处理 timeline 时就把资源加载好
var image: UIImage? = nil
if let imageData = try? Data(contentsOf: URL(string: "https://tu.sioe.cn/gj/qiege/image.jpg")!) {
image = UIImage(data: imageData)
}
let entry = SimpleEntry(date: entryDate, count: Int.random(in: 0...100), image: image, configuration: configuration)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
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。
#import "ViewController.h"
#import "SwiftUIWidget-Swift.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[PJWidgetCenter reloadWidgetTimeline];
});
}
@end
Swift 处理过程(非必需)
import WidgetKit
import Foundation
@objcMembers class PJWidgetCenter: NSObject {
class func reloadWidgetTimeline() {
if let userDefaults = UserDefaults(suiteName: "group.com.pjhubs.swiftuiwidge") {
userDefaults.setValue("2333", forKey: "integer")
}
WidgetCenter.shared.reloadAllTimelines();
}
}
Widget
struct Provider: IntentTimelineProvider {
@AppStorage("integer", store: UserDefaults(suiteName: "group.com.pjhubs.swiftuiwidge"))
var intString: String = ""
//...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
var entries: [SimpleEntry] = []
let currentDate = Date()
for hourOffset in 0 ..< 5 {
let entryDate = Calendar.current.date(byAdding: .second, value: hourOffset, to: currentDate)!
// ...
let entry = SimpleEntry(date: entryDate, count: Int(intString)!, image: image, configuration: configuration)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
推荐看完 为小组件添加智能和配置 https://developer.apple.com/videos/play/wwdc2020/10194/
基于 iOS12 引入的 Intent.framework,目前有两种提高 Widget 在智能堆叠中展示的办法。
用户行为捐赠(系统推断)
数据源评分展示(评估函数判分)
WWDC20 Session - 为小组件添加智能和配置视频截图。
我们可以把一些自定义的关键组合信息构造出一个 intent 捐赠给系统,通过 Intent.framework,系统不但可以把这些信息传递给我们 app widget 还可以传递到 spotlight 等其它依赖 Intent 的场景从而减少进入特定场景/app 的步骤。
转换成我们的产品视角,当作者每天都在 14 点查看自己的视频播放量这一个指标数据,可以在作者进入到指标页面时,通过构造 Intent 实例进行捐赠给系统,当累计到一定次数(不定)后,系统会在每天用户 14 点前后解锁进入主屏时,在「智能堆叠」Widget 中自动翻滚到我们加入其中的 Widget 并展示出对应的播放量 Widget。
如果我们想要在特定时间主动突出小组件在智能堆叠上的展示机会,可以使用「数据源评分展示」策略,在构造 Timeline 时可以给不同的数据实体塞入不同的评分,从而达到在不同时间节点或特定时间节点下的突出展示。
转换成我们的产品视角,当作者新发布了一个视频,可能想要在未来的一天、两天甚至一周内关注视频本身的播放量这一指标,我们可以通过固定分数和持续时间来达到提升展示,从而关闭其它数据源更新时的
struct Provider: IntentTimelineProvider {
// ...
func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) {
// ...
for hourOffset in 0 ..< 3 {
// ...
if (hourOffset == 1) {
let revelance = TimelineEntryRelevance(score: 2000, duration: 60);
let entry = SimpleEntry(date: entryDate, count: 2000, image: image, configuration: configuration, relevance: revelance)
entries.append(entry)
} else {
let revelance = TimelineEntryRelevance(score: 10, duration: 0);
let entry = SimpleEntry(date: entryDate, count: Int(intString)!, image: image, configuration: configuration, relevance: revelance)
entries.append(entry)
}
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
需要注意的是,我们给在第二分钟时要展现的数据分数 Relevance 分数设置为 2000,其它数据的分数设置为 10 分,此时运行 Widget 并等待到第二分钟,智能堆叠的 Widget 并不会一定翻转到我们的 Widget 上,但 Widget 上的数据是确确实实被更新了的,同时也说明了系统并不会一定认为当前数据比同一个 Widget 下的其它数据评分高,就一定为在智能堆叠上必须展示我们的 Widget,只是说在智能堆叠执行翻转时,我们的 Widget 会获得比其它 Widget 可能会获得更高的展示机会。
并且该评分也仅仅只是和当前 Widget 内的数据源做的对比。
程序员专栏 扫码关注填加客服 长按识别下方二维码进群
近期精彩内容推荐:
几句话,离职了
中国男性的私密数据大赏,女生勿入!
为什么很多人用“ji32k7au4a83”作密码?
一个月薪 12000 的北京程序员的真实生活 !
在看点这里好文分享给更多人↓↓