本篇记录我在开发iOS小组件Widget的知识点记录。

周末给自己练手的App加了两个小组件,踩了一些坑最终完成了小组件的开发,效果如下:

左边的小组件主要用于浏览,数据从主App获取;右侧的小组件用于便捷打开编辑功能。

「外显关键数据」和「便捷便捷入口」也是我自己使用小组件体验以来,觉得小组件最优秀的功能点。

关于小组件的文章网上有很多,这篇文章只记录我踩到坑的地方。

一、初识小组件

(一)恶心的编译报错

在给App新增Widget extension之后,十有八九你会遇到这个编译报错:

我把关键信息抽出来,报错内容大致如下:

1
SendProcessControlEvent:toPid: encountered an error: Error Domain=com.apple.dt.deviceprocesscontrolservice UserInfo={NSLocalizedDescription=Failed to get descriptors for extensionBundleID 

这个报错是什么意思呢?

报错指的是启动Widget时,找不到 extensionBundleID 的内容了。这是什么意思呢?

因为Widget Extension里是可以指定多个小组件,所以我们需要指定一个默认的小组件版本。

那怎么处理呢?

这时我们可以去Google,可以发现处理这个问题有两个步骤:

  • 重启手机
  • 给scheme设置_XCWidgetKind,指定默认的小组件

然而当你这么处理后,你会发现有时会生效(编译没问题),有时还会有问题,那怎么办?

根据我在网上搜罗到的信息,得出的结论如下:

这个报错指的就是在scheme设置_XCWidgetKind,和是否重启手机无关(不要浪费时间在重启上了)。

所以推荐的处理方法有两种:

  1. 如果真机报错,可以尝试使用模拟器;反过来也一样。
  2. 新建的项目工程报错可能性更小,可以新建工程专门写小组件,然后移植到主工程里。

正如Apple论坛上针对这个问题留言所说:”SwiftUI是个Baby,我们应该照顾他”。

2023年05月02日00:46:53 更新:

导致这个报错的原因就是没有指定 widget extension 的 支持iOS机型而已:

(二)小组件的刷新时机

小组件的内容变化都依赖于 Timeline 。小组件本质上是 Timeline 驱动的一连串静态视图。

没错,你看到的一些会跟随时间刷新的小组件,本质上也是一连串静态视图。小组件是怎么做的呢?

正如上图所示,Timeline 是一个以 TimelineEntry 为元素的数组。 TimelineEntry 包含一个 date 的时间对象,用以告知系统在何时使用此对象来创建小组件的快照。也可以继承 TimelineEntry ,加入业务所需要的数据模型或其他信息。

以我开发的秒级刷新组件为例,虽然说它的名字叫做秒级刷新,但我们实际上是一次性计算好60个entry(小组件的数据),entry 就是我们在特定时间需要展示的数据。

发现了吗,这样做就相当于我们提前把未来时间点UI视图要展示的数据提前计算好了,这点和我们平时写App主工程逻辑不同,值得注意。

系统先回调我们来获取 timeline 数据,再在特定时间来回调我们渲染界面,代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
var entries: [TodayCountEntry] = []

// Generate a timeline consisting of five entries an hour apart, starting from the current date.
let currentDate = Date()
// 每秒刷新一次,更新count
for secondOffset in 0 ..< 60 {
let entryDate = Calendar.current.date(byAdding: .second, value: secondOffset, to: currentDate)!
var reviewCount : Int = 0
let defaults = UserDefaults(suiteName: "group.com.memory.app")
if let data = defaults?.object(forKey: "reviewCountDic") as? Data {
if let nsDictionary = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? NSDictionary {
let dictionary = nsDictionary as? [String: String]
// 成功将数据转换为NSDictionary
// 在这里使用dictionary变量来访问数据
let days = "\(Int(Date().timeIntervalSince1970 / 86400) + 1)"
if let value = dictionary?[days] {
// 将时间戳转换为天数,并使用天数作为Key从Dictionary中获取数据
if let intVal = Int(value) {
reviewCount = intVal
} else {
}
}
}
}
let entry = TodayCountEntry(date: entryDate, reviewCount: reviewCount)
print("update timeline")
entries.append(entry)
}

let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}

二、小组件数据共享

(一)App Group归档问题

小组件是Extension,本质上也是一个独立的App,如果小组件想和主工程App共用数据,能做到吗?

答案是可以,可以使用AppGroup来完成主应用与小组件应用的通讯,App Group是很早的接口了,出发点是为了解决跨App的数据共享问题,比如:

1
公司的旗下有两个App,当客户已经登录一个App A的情况下,再登录另一个App B时,B不再需要繁琐的登录过程就可以直接使用A已经登录的信息。但是iOS系统下有这么一个安全机制:每个应用都有自己对应的沙盒,每个沙盒之间都是相互独立的,互不能访问(没有越狱的情况下)。

既然连跨App的数据共享都能搞定,那么App-Extension使用App Group进行数据共享也是水到渠成了。

在开发时这里有个值得注意的问题:数据归档编码问题。

App和Extension进行数据共享时,假设我们共享的是一个Dictionary字典,我们在App中将字典数据写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.memory.app"];

NSMutableDictionary *dic = [NSMutableDictionary dictionary];
UInt64 curTimeStamp = [[NSDate date] timeIntervalSince1970];
NSInteger totalCount = 100;
for(int i = 0 ; i < totalCount; i ++){
NSInteger reviewCount = [[BNBasicDataService shareInstance] getTargetReviewTaskCountFromTimeStamp:curTimeStamp];
curTimeStamp += 24 * 60 * 60;
NSInteger days = curTimeStamp / (24 * 60 * 60);
if (reviewCount > 0) {
[dic setObject:[NSString stringWithFormat:@"%ld",(long)reviewCount] forKey:[NSString stringWithFormat:@"%ld",(long)days]];
}
}
NSDictionary *immutableDictionary = [NSDictionary dictionaryWithDictionary:dic];
NSData *data = [NSKeyedArchiver archivedDataWithRootObject:immutableDictionary requiringSecureCoding:NO error:nil];
[defaults setObject:data forKey:@"reviewCountDic"];
[defaults synchronize];

这里需要注意的是,既然OC我们使用的是[NSKeyedArchiver archivedDataWithRootObject:]将数据进行归档二进制化,那么在Swift读取App Group时,也需要使用NSKeyedArchiver将二进制对象进行取消归档。

因为Swift中我们经常使用PropertyListEncoder将Dictionary对象转换为Data类型,但在这个情况下如果使用PropertyListEncoder去取数据,数据则会异常。

因此在将数据从OC转换为Swift时,你需要使用相同的编码方式来确保数据的一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var reviewCount : Int = 0
let defaults = UserDefaults(suiteName: "group.com.memory.app")
if let data = defaults?.object(forKey: "reviewCountDic") as? Data {
if let nsDictionary = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? NSDictionary {
let dictionary = nsDictionary as? [String: String]
// 成功将数据转换为NSDictionary
// 在这里使用dictionary变量来访问数据
let days = "\(Int(Date().timeIntervalSince1970 / 86400) + 1)"
if let value = dictionary?[days] {
// 将时间戳转换为天数,并使用天数作为Key从Dictionary中获取数据
if let intVal = Int(value) {
reviewCount = intVal
} else {
}
}
}
}

(二)共享Swift通知

数据共享完成了,接下来我们想实现App和Extension的互相通信,尤其是App -> Extension的通信是非常有必要的,设想这么一个场景:

如果Widget需要使用App的数据进行渲染,那么App中数据发生了变更,是不是就要立刻通知Widget去App重新取数据?不然就会出现Widget数据滞后的情况。

如果你查询资料,会看到网上宣扬使用 CFNotificationCenter 实现跨进程的App通信, CFNotificationCenter 原理上是能做到的。

但经过我的测试,在App->Extension这条通路上,使用 CFNotificationCenter 发出的通知,Extension是收不到的。

所以我们还是回归App文档,看看苹果建议我们怎么搞:

当 App 在前台时,通过 WidgetCenter 可以主动触发 reload 。

这句话是什么意思呢?表征意思是,我们可以创建一个桥梁Swift类(需要共享到App和Extension两个target),在OC项目App工程需要变更时,App主动调用Swift类进行Extension的刷新即可。

1
2
3
4
5
6
7
8
9
10
桥梁Swift类

import Foundation
import WidgetKit

class ViewUpdate: NSObject {
@objc public func updateView() -> () {
WidgetCenter.shared.reloadAllTimelines()
}
}
1
2
3
4
OC类 App

ViewUpdate *update = [[ViewUpdate alloc] init];
[update updateView];

这种方式最简单,效果最好。不过正如我上面强调的,你需要给桥梁Swift类同时加入到两个target中才会生效:

三、审核相关

Widget是依附于主APP之上的,提交的时候一起审核。