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

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

前言

转载:写程序的柠檬精 原文

2020年10月3日修正版
在对Widget进行开发了一个月后,解决了几个问题,对本文进行重新编辑以纠正以前的错误和适应最新版本。

2020年10月15日修正版
对刷新和视图有了新的理解,修改刷新部分。

这里记录一些我在开发的过程中遇到的一些坑,希望对开发有用。本文涉及到的代码都只是示例代码,仅提供思路,并不能直接复制使用,需要有一些开发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

作为一个ios开发者,遇到问题的时候,有一个学习的氛围跟一个交流圈子特别重要,对自身有极大帮助,众人拾柴火焰高 这是一个我的iOS交流群:711315161,进群密码iOS 分享BAT,阿里面试题、面试经验,讨论技术, 大家一起交流学习成长!希望帮助开发者少走弯路。

引入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新特性,会将运行成果显示在右边的视图上且支持热更新,但是会很卡,它不是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) -> ()) {
        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为例子)

snapshot显示的地方

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

数据模型-SimpleEntry

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

struct SimpleEntry: TimelineEntry {
    public let date: Date
    xxxxx
}

界面-MainWidgetEntryView

Widget显示的View,在这个View上编辑界面,显示数据,也可以自定义View之后在这里调用。

struct MainWidgetEntryView : View {
    var entry: Provider.Entry
    var body: some View {
        xxxxxx
    }
}

入口-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的描述
    }
}

遇到的坑

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

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)
    }

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

例子:实现一个按秒刷新的时钟,为了每一秒尽可能的准确刷新就应该向entries提供0-299这300秒的300个时间数据,View展示时转换成具体到秒的字符串展示即可,运行一个周期后再次获取5分钟的时间数据。这就导致秒钟显示会有一定的偏差1~3s,高频率的刷新也会导致耗电量的增加。

func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
    var currentDate = Date()
    // 每5分钟刷新一次
    let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
    var arr:[SimpleEntry] = []
    var tempDate = Date()
        for idx in 0...300 {
            tempDate = Calendar.current.date(byAdding: .second, value: idx, to: currentDate)!
            let tempEntry = SimpleEntry(date: tempDate)
            arr.append(tempEntry)
        }
        let timeline = Timeline(entries: arr, policy: .after(refreshDate))
        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》

你可能感兴趣的:(### iOS14 Widget开发踩坑(一)修正版-初识与刷新)