iOS14 Widget开发踩坑(一)修正版-初识与刷新

  • 前言
  • 开发须知
  • 准备工作
  • 部署环境
  • 创建项目
  • 引入Widget Extension
  • 开始编写
  • 认识代码
  • 预览视图-Previews
  • 数据提供-Provider
  • 数据模型-SimpleEntry
  • 界面-MainWidgetEntryView
  • 入口-MainWidget
  • 遇到的坑
  • 主程序刷新和第二个坑
  • 参考文献


前言

2020年12月23日订正版,修改了一些描述和错误

这里记录一些我在开发的过程中遇到的一些坑,希望对开发有用。本文涉及到的代码都只是示例代码,仅提供思路,并不能直接复制使用,需要有一些开发Today Widget的知识,方便进行对比。本文部分内容引用自网络,如有侵权请联系删除。

开发须知

  1. WidgetExtension 使用的是新的WidgetKit不同于Today Widget,它只能使用SwiftUI进行开发,所以需要SwiftUI和Swift基础。
  2. Widget只支持3种尺寸systemSmall (2x2)、 systemMedium (4x2)、 systemLarge(4x4)
  3. 默认点击Widget打开主应用程序
  4. Widget类似于TodayWidget是一个独立运行的程序,需要在项目中进行 App Groups 的设置才能使其与主程序互通数据,这个以后会讲。
  5. Apple官方已经弃用Today Extension,Xcode12已经不再提供Today Extension的添加,已经有Today Widget的应用则会显示到一个特定的区域进行展示。

准备工作

部署环境

Widget的开发需要安装Xcode 12以及iOS 14进行。Apple官方下载链接

创建项目

正常的创建项目流程,我使用的是Swift语言、界面Storyboard,可以设置成自己习惯的配置,
Create a new Xcode project -> 填写Product Name-> Next-> Create

引入Widget Extension

  1. File -> New -> target-> Widget Extension ->Next
  2. 由于是加入一个新的Target,所以Widget的名字不能与项目名相同,也不能起成“Widget”(因为Widget是一个已有的类名),删除时不能只是删除文件还要在项目的Targets中删除,起已经删除过一次的名字会报找不到文件的错误。
  3. 如果 Widget 支持用户配置属性(例如天气组件,用户可以选择城市),就需要勾选Include Configuration Intent这个选项,不支持的话不用勾选。建议勾选上,谁知道以后会不会要求支持呢。
  4. 创建后,会自动生成5个struct和自带的方法

开始编写

认识代码

预览视图-Previews

代码运行的预览视图是SwiftUI新特性,会将运行成果显示在右边的视图上且支持热更新,方便开发者调试SwiftUI视图使用,它不是Widget的必须部分,可以直接将其删除或注释。

struct MainWidget_Previews: PreviewProvider {
    static var previews: some View {
        MainWidgetEntryView(entry: SimpleEntry(date: Date()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

数据提供-Provider

Provider是Widget最重要的部分,它决定了小组件的placeholder/getSnapshot/getTimeline这三种数据的显示。在项目创建时勾选了Include Configuration Intent后的话,Provider继承自IntentTimelineProvider支持用户配置数据,没有勾选则继承自TimelineProvider不支持用户配置数据。这个以后会讲到。

struct Provider: TimelineProvider {
    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<Entry>) -> ()) {
        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)
    }
}

getSnapshot 方法是提供一个预览数据,可以让用户看到该组件的一个大致情况,是长什么样、显示什么数据的,可以写成固定数据,国外的文章里叫它
“fake information” ,就是这个界面显示的样子:(以iWidget为例子)

iOS widget数据 ioswidgetsforkwgt_数据

getTimeline 方法就是Widget在桌面显示时的刷新事件,返回的是一个Timeline实例,其中包含要显示的所有条目:预期显示的时间(条目的日期)以及时间轴“过期”的时间。
因为Widget程序无法像天气应用程序那样“预测”它的未来状态,因此只能用时间轴的形式告诉它什么时间显示什么数据。

数据模型-SimpleEntry

Widget的Model,其中的Date是TimelineEntry的属性,是保存的是显示数据的时间,不可删除,需要自定义属性在它下面添加即可:

struct SimpleEntry: TimelineEntry {
    let date: Date
    xxxxx
}

界面-MainWidgetEntryView

Widget显示的View,在这个View上编辑界面,显示数据,也可以自定义View之后在这里调用。而且,一个Widget是可以直接支持3个尺寸的界面的。

struct MainWidgetEntryView : View {
	@Environment(\.widgetFamily) var family
    var entry: Provider.Entry
    var body: some View {
		switch family {
        case .systemSmall: Text("小尺寸界面")
        case .systemMedium: Text("中尺寸界面")
        default: Text("大尺寸界面")
        }
	}
}

入口-MainWidget

Widget 的主入口函数,可以设置Widget的标题和说明,规定其显示的View、Provider、支持的尺寸等信息。

@main
struct MainWidget: Widget {
    let kind: String = "MainWidget"// 标识符,不能和其他Widget重复,最好就是使用当前Widgets的名字。

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            MainWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("My Widget")//Widget显示的名字
        .description("This is an example widget.")//Widget的描述
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
    }
}

遇到的坑

getTimeline 就是第一个坑,iOS14 Widget是无法主动更新数据的!!!
Today小组件是可以主动获取最新的数据,由程序直接控制,但 iOS 14 的小组件却不是,系统只会向小组件询问一系列的数据,并根据当前的时间将获取到的数据展示出来。由于代码不是主动运行的,这使它更偏向于静态的信息展示,连动画和视频也都是被禁止的。
这就意味着,我们只能提前为小组件写好下一个时间该展示什么数据,并制作成时间线,让系统去读取展示。但是我们可以通过以闭包的方式进行正常的数据请求和填充来实现自动请求并刷新数据。这样的刷新方式与我平时开发时的思维相差较大,导致我犯了很多错误。

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = []
        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)
    }

官方的示例代码的意思是:显示从现在开始的5个小时的每个小时的时间,再显示完之后又重新运行一次getTimeline。理解了这个方法的意思后才可以写出自己想要的效果。
所以,我们只需要控制刷新时间的Calendar.ComponentValue与entries中元素的个数,并设置TimeLinepolicy 就可以控制Widget的刷新时间,次数和方法。但是经过我的测试,getTimeline最高的刷新频率是5分钟一次,高于这个频率是不起作用的。我们在填充entries时应该为其填充5分钟内需要显示的数据。

例子:实现一个按秒刷新的时钟,为了每一秒尽可能的准确刷新就应该向entries提供这300秒的300个时间数据,View展示时转换成具体到秒的字符串展示即可,运行一个周期后再次获取5分钟的时间数据。
这就导致秒钟显示会有一定的偏差1~3s,高频率的刷新也会导致耗电量的增加,而且偶尔会发生停止。目前(2020年12月23日)也没有找到合适的解决方法,如果有,请私聊我!!!!

func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
	var currentDate = Date()
	var arr:[SimpleEntry] = []
		for idx in 0...300 {
			let tempDate = Calendar.current.date(byAdding: .second, value: idx, to: currentDate)!
			let tempEntry = SimpleEntry(date: tempDate)
			arr.append(tempEntry)
		}
		let timeline = Timeline(entries: arr, policy: .atEnd)
		completion(timeline)
    }

主程序刷新和第二个坑

在主程序内,我们可以使用WidgetKit提供的WidgetCenter来管理小组件,其中的reloadTimelines来强制刷新一次我们指定的小组件,或者reloadAllTimelines来刷新所有的小组件。

WidgetCenter.shared.reloadTimelines(ofKind: "xxx")
WidgetCenter.shared.reloadAllTimelines()

如果你的主程序是Oojective-C编写的,那么你就需要使用OC调用Swift的方法来写,混编配置方式详见参考文档 《混编之oc调用swift》,因为WidgetKit没有写OC版本。

import WidgetKit
@objcMembers class WidgetTool: NSObject {
    @available(iOS 14, *)
    @objc func refreshWidget(sizeType: NSInteger) {
        #if arch(arm64) || arch(i386) || arch(x86_64)
        WidgetCenter.shared.reloadTimelines(ofKind: "xxx")
        #endif
    }
}

上面代码中的

#if arch(arm64) || arch(i386) || arch(x86_64)
        xxxx
 #endif

@available(iOS 14, *)

就是第二个坑。
其一,加这个判断是因为Widget只能在这三个条件其中的一个满足的下运行,没有加这一句在打包时会出现报错,这个解决方法是从Apple Developer的问题反馈中找到的。

其二,WidgetKitiOS 14才新出的,因为我们的项目要向下支持到iOS 10,所以要加上版本判断才能编译打包。

参考文献

本人新手,如果有写错的地方欢迎指正,期待和大家一起交流开发,建议先看完官方的说明文档再去找相关的网络资料。

《Creating a Widget Extension》《Keeping a Widget Up To Date》《从开发者的角度看 iOS 14 小组件》《iOS14WidgetKit开发实战1-4》《iOS14 Widget 开发相关及易报错地方处理》《How to create Widgets in iOS 14 in Swift》《SwiftUI-Text》《混编之oc调用swift》