iOS14 新特性 -WidgetKit 开发与实践

  • 2020-11-10
  • 本文字数:4466 字

    阅读完需:约 15 分钟

iOS14 新特性-WidgetKit 小部件 通过桌面编辑添加,将小部件放在 iOS 主屏幕或 macOS 通知中心上,使用户可以随时访问应用中的内容。同时小部件也可以保持更新,因此用户始终可以一目了然地获得最新信息,同时点击区域可以 Deep Link 跳转到主 APP 任意界面中。

在 2020 年苹果发布会推出 Widget 之后,贝壳就第一时间做出了尝试, 期间苹果中国提供了很多支持与帮助,目前已在贝壳和链家 APP 上线。

小部件具有三种不同的大小(小,中和大),可以显示各种信息。用户可以个性化小部件以查看特定于其需求的详细信息,并以最适合他们的方式安排其小部件。

不同分辨率机型,三种卡片的尺寸也不同:

1 如何开发 WidgetKit?

前期准备:

Xcode 12 及 Bate 版, iOS 14 及 Bate 版, 了解 SwiftUI 控件

1.1 创建 Widget

首先,File->New->Target:

有两种配置可供选择:

  • StaticConfiguration: 对于一个没有用户可配置属性的 Widget。

例如,显示一般市场信息的股票市场 Widget,或显示趋势标题的新闻 Widget。

  • IntentConfiguration: 对于一个具有用户可配置属性的 Widget 来说,你可以使用 SiriKit 自定义意图来定义属性。您使用 SiriKit 自定义意图来定义属性。

例如,一个天气 Widget 需要一个城市的邮政编码或邮政编码,或者一个包裹跟踪 Widget 需要一个跟踪号码。

下图中「Include Configuration Intent」复选框决定了 Xcode 使用哪种配置。选择 Include Configuration Intent 表示支持用户配置;不需要,则不勾选。

1.2 Widget 初始化配置

对象解析

  • kind

识别 Widget 的字符串。

如果包含多个 widget 后可作为唯一的标识符。

  • Provider

符合 TimelineProvider 的对象。

一个符合 TimelineProvider 的对象,它能产生一个时间线,告诉 WidgetKit 何时渲染 Widget。

时间线包含一个你定义的自定义 TimelineEntry 类型。

时间线条目标识了你希望 WidgetKit 更新 Widget 内容的日期。

在自定义类型中包含你的 Widget 的视图需要渲染的属性。

  • Placeholder

一个 SwiftUI 视图,WidgetKit 用来在第一次渲染 Widget。

占位符是您的 Widget 的通用表示,没有特定的配置或数据。

  • Content Closure(内容闭合)

一个包含 SwiftUI 视图的封闭。

WidgetKit 调用它来渲染 Widget 的内容,从提供者那里传递一个 TimelineEntry 参数。

函数解析

1) placeholder

占位视图,在数据加载前展示,在 xcode12 bate3 和 bate4 中有所有所变化:

  func placeholder(in context: Context) -> SimpleEntry {        SimpleEntry(date: Date(), model: LJWidgetModel.preview_widget)    }
复制代码

2) getSnapshot

快照,在添加组件库中展示

func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {        let entry = SimpleEntry(date: Date(), model: LJWidgetModel.preview_widget)        completion(entry)}
复制代码

3) getTimeline

时间轴,控制刷新时机

 func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {        let date = Calendar.current.date(byAdding: .hour, value:12, to: Date()) ?? Date()        LJWidgetAPI.loadData{ (model, error) in            guard let model = model else {                let timeline = Timeline(entries: [SimpleEntry(date: date, model: LJWidgetModel.preview_widget)], policy: .after(date))                completion(timeline)                return            }            let timeline = Timeline(entries: [SimpleEntry(date: date, model: model)], policy: .after(date))            completion(timeline)        }    }
复制代码

4) SimpleEntry

数据模型,类似 model

struct SimpleEntry: TimelineEntry {    let date: Date    let model: LJWidgetModel}
复制代码

5) WidgetEntryView

主内容,展示区分小中大卡片 ,可根据 family 来区分

struct LJWidgetEntryView : View {    var entry: Provider.Entry    @Environment(\.widgetFamily) var family    @ViewBuilder    var body: some View {        switch family {        case .systemSmall:            let small = entry.model.small            LJWidgetSmall(small)                .previewLayout(.sizeThatFits)        case .systemMedium:            let medium = entry.model.medium           LJWidgetMedium(medium)            .previewLayout(.sizeThatFits)        case .systemLarge:            let large = entry.model.large            LJWidgetLarge(large)                .previewLayout(.sizeThatFits)        @unknown default:            Text("unknown")        }    }}
复制代码

6) Widget

主界面控制器。kind 为标识符

struct LJWidget: Widget {    let kind: String = "LJWidget"    var body: some WidgetConfiguration {        StaticConfiguration(kind: kind, provider: Provider()) { entry in            LJWidgetEntryView(entry: entry)        }      .configurationDisplayName("My Widget")        .description("This is an example widget.")        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])    }}Widget_Previews  struct LJWidgetLarge_Previews: PreviewProvider {    static var previews: some View {        Group {            LJWidgetLarge(LJWidgetLargeModel.preview)                .frame(width: 329.0, height: 345.0)                .previewLayout(.sizeThatFits)                .colorScheme(.light)            LJWidgetLarge(LJWidgetLargeModel.preview)                .frame(width: 329.0, height: 345.0)                .previewLayout(.sizeThatFits)                .colorScheme(.dark)        }    }
复制代码

Preview 预览

界面开发是 SwiftUI (Apple 要求),可参考:https://developer.apple.com/documentation/widgetkit/creating-a-widget-extension

1.3 Widget 刷新机制

  • 自动刷新

如下图,本身维护一个时间轴,在创建时填充不同时间节点,当到达时间节点位置时,触发刷新。after 会在消耗完时间点后,再次填充,保持循环运行。

  • 手动刷新

一种是 push notification 推送来更新 widget,另一种则是客户端内通过调用接口主动 reload。

2 OC 项目和 Swift 混编

如果原有老项目为 OC 项目,想要实现和 Swift 的相互调用,需用到桥接文件,另外针对引入 Swift 依赖库,会引起包体积增加,大小大概 7~8M 左右,可以在 ipa 包内查看:

2.1 Swift 引用 OC 代码

创建 xxx(工程名)-Bridging-Header 头文件, 并在 Build Setting -> Objective-C Bridging Header 设置其路径

这样我们在桥接文件内,通过 import “xxx.h”引用 OC 的组件库,就可以在 Swift 使用了。

2.2 OC 引用 Swift 代码

桥接文件不需要手动创建,系统帮我创建好了,可以查看 xxx-swift,在文件夹查找不到,但是引用头文件后,可以点击进入查看:

3 主 APP 和 Widget 间通信

因为 Widget 为新的 Target 项目,和之前主项目是两个进程的关系,所以如果想要资源共享,比如登录状态值 token, 网络配置等,就需要用到共享区域来实现通信。苹果提供的共享方式有:

方式一:APPGroup 方式

1) 配置好证书,是 Gourp 功能正常使用中,主 APP 和 Widget 保持统一 key

当我们配置完以后,会在文件目录下多出来一个.entitlements 的文件。

2)主 APP 写下数据

//Main App 通过TextField来向共享文件appGroup.txt中写入数据- (void)textFieldDidEndEditing:(UITextField *)textField {   //获取App Group的共享目录    NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.simon.app.test"];    NSURL *fileURL = [groupURL URLByAppendingPathComponent:@"appGroup.txt"];  //写入文件    [textField.text writeToURL:fileURL atomically:YES encoding:NSUTF8StringEncoding error:nil];}
复制代码

3)Widget 内读取 App Group 的共享目录

NSURL *groupURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"group.com.simon.app.test"];   NSURL *fileURL = [groupURL URLByAppendingPathComponent:@"appGroup.txt"];    //读取文件     NSString *str = [NSString stringWithContentsOfURL:fileURL encoding:NSUTF8StringEncoding error:nil];   self.shareLabel.text = str;
复制代码

方式二:KeyChain Sharing

KeyChain 可将用户信息加密存储在钥匙串中,保证用户信息的安全性;另外多个应用可通过 keyChain 共享用户信息。

1)同样我们需要配置 keychan 的 key, 保持和主 APP 一致,省事的是少了证书的配置

2)通过对钥匙串的读写来操作 (一般由 SAKeychain 库管理)比较方便,注意一点:实现共享的只是在钥匙串的缓存数据,如果一旦加载到内存中,它的修改不受主 APP 的影响了。

4 常见问题

问题 1 . 选择 Intent (widget 配置),会出现 configtion 查找不到

解决:

方法一: 若不使用 Inetent, 在生成 widget 时不勾选即可,避免这类问题;

方法二:见https://developer.apple.com/forums/thread/653910 (在官网提出后,目前没有官方人员回答,不过有其他答案,未验证)

问题 2 . Xcode 12 bate3 运行贝壳的 widget 未显示 preview

解决:Build systems: 选择新的编译方式

问题 3 . 由于 WidgetKit 使用一些 Swift 的新特性,所以版本需要修改成 Swift5.0

问题 4 . Swift 桥接 OC 组件库

解决:

1) 生成 LJShell-Bridging-Header.h 桥接文件,

2)在 Build setting 中找到 Objective-C Bridging Header 设置对应路径 $(SRCROOT)/LianJiaShell/LJShell-Bridging-Header.h

3)将 Build Settings 中的 Defines Module 选项设置为 YES

问题 5 . 引入 OC 组件库报查找不到

解决:在 podfile 中在 LJWidget 的 target 引入对应 pod xxx

问题 6 . 使用 Widget Preview 功能

bate 版本目前发现在 OC 工程下混编情况,prview 无法使用,可以尝试新建个 swift 工程来编写 swiftUI,使用 preview 功能

问题 7 . Error: Multiple commands produce

解决:

方法一:

不使用 New Build System,在 File > Project/Workspace Settings 中的 Share Project/Workspace Settings 里 build system 将 New Build System(Default)切换成 Legacy build system。

方法二:在 target -> Build phase > Copy Bundle Resource 中找到 info.plist,移除

问题 8 . dyld: Library not loaded:

dyld: Library not loaded: /System/Library/Frameworks/WidgetKit.framework/WidgetKit  Referenced from: /var/containers/Bundle/Application/EA42E025-6CFA-4C90-950E-50D28255B4DA/LJShell.app/LJShell  Reason: image not found
复制代码

解决:https://developer.apple.com/forums/thread/126506

5 参考文献

  • WidgetKit:

https://developer.apple.com/documentation/widgetkit

  • SwiftUI:

https://developer.apple.com/tutorials/swiftui/creating-and-combining-views

本文转载自公众号贝壳产品技术(ID:beikeTC)。

原文链接

iOS14新特性-WidgetKit开发与实践