美文网首页iOS精选iOSWidget学习
iOS14 Widgets开发(从0到1最详细攻略)

iOS14 Widgets开发(从0到1最详细攻略)

作者: 铁头娃_e245 | 来源:发表于2021-01-27 23:16 被阅读0次

    关于widgets的一些特性描述以及应用场景可以参考上篇文章 iOS14 Widgets新特性

    开发思路浅谈

    思维导图
    我们将通过如下模块解析小组件开发
    • App Extension
    • App Groups 数据通信
    • 文件共享及pods共享
    • OC和Swift混编
    • Widget 核心代码解析
    • Timeline刷新机制
    • 获取数据
    • SwiftUI 构建组件
    • 跳转至App
    • 动态配置小组件
    开发须知:
    • iOS14系统以上才支持
    • 它只能使用SwiftUI进行开发,所以需要SwiftUI和Swift基础。(如果主项目为OC语言需要做语言桥接)
    • Widget只支持3种尺寸systemSmall (2x2)、 systemMedium (4x2)、 systemLarge(4x4)
    • 不支持动画(包括视频),仅支持静态页面展示。
    • 更新频率由系统通过机器学习来动态分配。
    • 不支持拖拽、滚动等复杂的交互,不支持 Switch 等控件。
    • 用户点击 Widget 一定会跳转到 app
    • 无法主动更新数据 (重点,让所有交互型的逻辑都无法实现)
    • 每一个 Widget都有其对应的独立 TimeLine,相互独立,互不干扰。
    • 其他特性通过代码和下面的实际开发中描述。

    App Extension

    如果你已经有了 App Extension 的开发经验,可以略过这个章节。

    首先需要明确的是小组件是一个独立于 App 环境(即 App Extension),小组件的生命周期/存储空间/运行进程都和 App 不同。所以我们需要引入这个环境下的一些基础设施,比如网络通信框架,图片缓存框架,数据持久化框架等。

    按照苹果的说法:App Extension 可以将自定义功能和内容扩展到应用程序之外,并在用户与其他应用程序或系统交互时向用户提供。例如,您的应用可以在主屏幕上显示为小部件。也就是说小组件是一种 App Extension,小组件的开发工作,基本都在 App Extension 的环境中。

    App 和 App Extension有什么关系?

    本质上是两个独立的程序,你的主程序既不可以访问 App Extension 的代码,也不可以访问其存储空间,这完完全全就是两个进程、两个程序。App Extension 依赖你的 App 本体作为载体,如果将 App 卸载,那么 App Extension 也不会存在于系统中了。而且 App Extension 的生命周期大多数都作用于特定的领域,根据用户触发的事件由系统控制来管理。

    创建 App Extension 和配置文件

    下面我们开始进行widget开发

    首先按照下图步骤在demo里添加一个widget


    添加widget

    弹出如下窗口后设置名称后创建


    创建后生成如下小组件的Extension,他与主App在不同的target(所以内存和应用内的数据不共享,数据共享方法下面介绍)


    生成小组件的Extension后,可以在运行栏选择小组件运行(也可以通过运行主app后在屏幕上长按后左上角添加小组件)


    运行会自动生成一个小组件


    默认小组件
    断点调试

    不同target断点调试需要调整到对应的模块,比如运行了widgetExtension,想要断点需要设置Debug -> Attach to Process 或 Attach to Process by PID or Name里的widgetExtension,就能顺利断点调试啦

    App Groups 数据通信

    因为 App 和 App Extension 是不能直接通讯的,所以需要共享信息时,需要使用 App Groups 来进行通讯。App Groups 有两种共享数据的方式,NSUserDefaultsNSFileManager

    创建完Extension后我们要去创建该Extension对应的证书(每个Extension都需要独立的证书),需要前往苹果开发者中心,手动创建 App ExtensionApp ID 和配置文件。如果需要数据共享,需要在创建证书时勾选App Groups。

    在 Widget Extension 的 Target 中添加 App Groups,并保持和主程序相同的 App Group ID 。如果主程序中还没有App Groups,则需要这个时候同时增加主 App 的 App Groups,并定义好 Group ID。


    添加App Group

    添加完App Groups后会出现对应环境的entitlements文件,可以在Code Signing Entitlements设置对应的路径(如果文件有移动也需要修改对应的路径设置)


    之后再entitlements文件里设置Group ID。
    NSUserDefaults 共享数据

    使用 NSUserDefaults 的 initWithSuiteName: 初始化实例。 suitename传入之前定义好的 App GroupID。

    - (instancetype)initWithSuiteName:(NSString *)suitename;
    

    之后即可使用NSUserDefaults的实例的存取方法来储存和获取共享数据了。比如我们需要和小组件共享当前的用户信息,则可以如下操作。

    //使用 Groups ID 初始化一个供 App Groups 使用的 NSUserDefaults 对象
    NSUserDefaults *userDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"widgetDemo.AppGroupID"];
    
    //写入数据
    [userDefaults setValue:@"123456789" forKey:@"userID"];
    
    //读取数据
    NSString *userIDStr = [userDefaults valueForKey:@"userID"];
    
    NSFileManager 共享数据

    使用 NSFileManager 的 containerURLForSecurityApplicationGroupIdentifier: 获取 App Group 共享的储存空间地址,即可进行文件的存取操作。

    - (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier;
    

    存储NSData数据

    //将图片存在AppGroup里
    //获取App Group的共享目录
    NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"widgetDemo.AppGroupID"];
    //拼接详细路径
    containerURL = [containerURL URLByAppendingPathComponent:[NSString stringWithFormat:@"%@",@"lol1"]];
    //获取到数据
    NSData *imageData = [NSData dataWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"lol1" ofType:@"jpg"]];
    //写入文件
    [imageData writeToURL:containerURL atomically:YES];
    

    获取存储的NSData数据

    NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:@"widgetDemo.AppGroupID"];
    //拼接详细路径
    containerURL = [containerURL URLByAppendingPathComponent:[NSString stringWithFormat:@"%@",deviceUUID]];
    //获取到数据
    NSData *resultData = [NSData dataWithContentsOfFile:containerURL.path];
    

    文件共享及pods共享

    • 文件共享
      在需要跨targets的文件勾选对应的target就可以完成共享,但要注意是否有不兼容的情况,比如 swiftUI不支持UIKit 的组件,勾选了就会报错。

    • pods共享
      正常使用下widget中无法使用pods导入的第三方SDK如Masonry等,会造成布局等极其不便,因此需要共享pods,在Podfile中需要另设置并重新install。(如果是手动导入的framework,需要在widget的target下的buid settings里手动添加对应需要的库和framework索引)

    source 'https://github.com/CocoaPods/Specs.git'
    
    platform :ios, '9.0'
    inhibit_all_warnings!
    use_frameworks!
    
    #共享HandyJSON
    def share_pods
        pod 'HandyJSON'
    end
    
    target "targetName" do
        pod 'Alamofire'
        share_pods
    end
    
    target "widgetTargetName" do
        share_pods
    end
    

    完成后即可使用pods中的第三方SDK了

    OC和Swift混编

    Swift调用OC代码

    不需要在Swift中import OC的类,统一在桥接文件Swift_OC_Header.h(自定义名称)中导入需要暴露给Swift的OC类即可在Swift中访问。

    因为工程是OC项目,并且需要使用主APP里的一些接口,这里要创建Swift调用OC的桥接文件,Calculator文件作为主APP的接口类,因为是OC工程,不会自动弹出桥接类系统提示,需要手动创建一个Header File设置为桥接文件。(如果swift功能会自动弹出桥接文件创建提示,点击创建就好了)

    创建桥接文件
    手动建完后,使用#import来引用oc库,如下图。(这里注意不会有代码提示,要完全手打出来,最好把类名复制粘贴)

    之后在小组件的targets里设置objective-C Bridging Header为桥接文件的路径,如下图

    这样就设置完毕了,可以直接在swift里调用OC的接口类方法啦。
    let vc = Calculator()
    
    OC调用Swift代码

    ps:项目里在widget刷新时候需要用到oc调用swift里的WidgetCenter.shared.reloadAllTimelines()

    首先在OC工程新建一个swift文件,会出现提示,点击蓝色创建bridging按钮(如果已经有了桥接可以不创建,在上面说的Bridging Header里设置好对应路径即可)


    之后要在setting里设置Defines Module值为Yes

    之后在使用swift文件的地方,导入头文件工程名-Swift.h,以我的项目为例
    #import "widgetDemo-Swift.h"
    

    之后就可以正常调用啦!
    工程名如下图,也可以自由修改


    工程名

    如果出现了如下报错


    SWIFT_VERSION
    在setting里修改Swift版本如下图即可
    修改Swift版本

    Widget 核心代码解析

    先分析自动生成的代码,代码较多,后续的解析通过注释解读代码

    首先我们看到在widget.swift中有一个@main函数,底下的struct widget方法代码就是我们生成widget后首先运行的代码

    //主入口,运行小组件会首先进入该方法,只会运行一次,初始化完成所有小组件内容
    @main
    struct widget: Widget {
        let kind: String = "widget"
    
        var body: some WidgetConfiguration {
            //可编辑内容为IntentConfiguration,不可编辑为StaticConfiguration
            //注册了Provider的block回调,当数据刷新后block回来带着数据传递给widgetEntryView
            IntentConfiguration(kind: kind, intent: ConfigurationIntent.self, provider: Provider()) { entry in
                widgetEntryView(entry: entry)
            }
            .configurationDisplayName("My Widget")   //添加小组件时界面显示的黑色文案(字符串可国际化)
            .description("This is an example widget.")  //添加小组件时显示的灰色文案
        }
    }
    

    数据模型,是每次刷新后让view显示的数据内容,Provider得到数据后转化为该模型block返回

    // 渲染 Widget 所需的数据模型,需要遵守TimelineEntry协议
    struct SimpleEntry: TimelineEntry {
        let date: Date
        let configuration: ConfigurationIntent
    }
    

    要显示的view,方法里设置你view的样式

    // 屏幕上 Widget 显示的内容,可以针对不同尺寸的 Widget 设置不同的 View。
    struct widgetEntryView : View {
        var entry: Provider.Entry
    
        var body: some View {
            Text(entry.date, style: .time)
        }
    }
    

    控件刷新及初始化等方法(小组件核心方法

    //为小组件展示提供一切必要信息的结构体,遵守TimelineProvider协议,产生一个时间线,告诉 WidgetKit 何时渲染与刷新 Widget,时间线包含一个你定义的自定义TimelineEntry类型。时间线条目标识了你希望WidgetKit更新Widget内容的日期。在自定义类型中包含你的Widget的视图需要渲染的属性。
    struct Provider: IntentTimelineProvider {
        // 占位视图,例如网络请求失败、发生未知错误、第一次展示小组件都会展示这个view
        func placeholder(in context: Context) -> SimpleEntry {
            SimpleEntry(date: Date(), configuration: ConfigurationIntent())
        }
    
        // 编辑屏幕在左上角选择添加Widget、第一次展示时会调用该方法(并不是每一次调用都会触发该方法,只有第一次展示或者到了固定的时间周期才会刷新,期间系统会缓存你上一次展示的内容展示出来)
        func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
            let entry = SimpleEntry(date: Date(), configuration: configuration)
            completion(entry)
        }
    
        // 小组件刷新时候走的方法,第一次加载也会触发该方法。
        // 被动刷新触发,由系统(可以设置时间)控制刷新频率,到了设定时间会刷新小组件。也可以使用手动刷新reload触发(需要主app在运行时候调用)
        // 进行数据的预处理,转化成Entry,最后completion返回。
        func getTimeline(for configuration: ConfigurationIntent, 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 {
                //.hour 是小时,hourOffset默认是5,结合起来就是5小时刷新一次,这个时间可以修改,目前测试结果最快为5分钟刷新一次,设置更短的时间也是失效的。
                let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
                let entry = SimpleEntry(date: entryDate, configuration: configuration)
                entries.append(entry)
            }
    
            let timeline = Timeline(entries: entries, policy: .atEnd)
            completion(timeline)
        }
    }
    

    以上为系统自动生成的小组件代码,只能加载一种小组件。

    如果需要不同类型的小组件,需要修改@main函数的内容,将继承改为WidgetBundleWidgetConfiguration改为Widget,block里面的改为多个Widget,即可实现多个组件功能,但这里注意最多只能设置5种小组件,每个小组件有大中小三种尺寸(共享同一套数据),就是最多有15个小组件。

    struct UserWidget: WidgetBundle {
        let kind: String = "Widget"
        
        @WidgetBundleBuilder
        var body: some Widget {
            FourToolWidget()
            NightFourToolWidget()
            OneClickRunWidget()
        }
    }
    

    下面介绍一下设置placeholder、getSnapshot、getTimeline后控制的视图内容

    • placeholder


    • getSnapshot


      添加页面显示
    • getTimeline


      主屏显示

    Timeline刷新机制

    首先,Widget 的刷新完全由 WidgetCenter 控制。开发者无法通过任何 API 去主动刷新 Widget 的页面,只能告知 WidgetCenter,Timeline 需要刷新了。(也就是最多只能整体刷新,不能单独刷新小组件里某一个地方)

    系统提供了两种方式来驱动 Timeline 的 Reload。System Reloads 和 App-Driven Reloads。这两种方式的触发其实就是运行了getTimeline方法。

    1. 被动刷新

    System Reloads: 这个行为由系统主动发起的 Timeline 刷新,会调用一次 Reload Timeline 向 Widget 请求下一阶段刷新的数据。系统除了会按时发起 System Reloads 之外,还会借助端智能的能力,动态决策每个不同的 TimeLine 的 System Reloads 的频次。超过频次的刷新请求将不会生效。高频使用的小组件可以获得更多的刷新频次。(目前自测最多5分钟刷新一次,如果设置小于5分钟的时间也不会生效)

    TimelineReloadPolicy

    TimeLine里的刷新规则由TimelineReloadPolicy设定的时间刷新,下面看他的api介绍


    TimelineReloadPolicy

    有三种形式:

    • atEnd
      是指 Timeline 执行到最后一个时间片的时候再刷新。


      atEnd
    • after(date)
      date 是指定的下次刷新的时间,系统会在这个时间对 Timeline 进行刷新。


      after(date)
    • never
      TimelineReloadPolicy 永远不会刷新 Timeline,最后一个 entry 也展示完毕之后 小组件就会一直保持那个 entry 的显示内容


      never

    一般常用的是atEnd,就是隔一段时间刷新一次,比如下图设置的每隔5分钟刷新一次

    var entries: [FourToolWidgetSimpleEntry] = []
    // Generate a timeline consisting of five entries an hour apart, starting from the current date.
    // 从当前日期开始,每小时产生一个包含十二个条目的时间轴。 
    let currentDate = Date()
    // 设置5分钟刷新一次(实际要看苹果的算法,可能有偏差)
    for hourOffset in 0 ..<  12 {
        let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
        let entry = FourToolWidgetSimpleEntry(date: entryDate, contact: contact, contact2: contact2, contact3: contact3, contact4: contact4)
        entries.append(entry)
    }
    
    let timeline = Timeline(entries: entries, policy: .atEnd)
    completion(timeline)
    
    2. 手动刷新

    App-Driven Reloads:指的是 App 请求 Widget 下一阶段刷新的数据。这里也要分两种场景,应用在前台运行和应用在后台运行。当应用在前台运行的时候,App 可以直接请求 WidgetCenter 的 API 来触发 Reload Timeline;而当应用处于后台时,后台推送(Background Notification)也可以触发 Reload Timeline。

    通过swift调用widgetKit里的api实现刷新widget。

    [WidgetKitHelper reloadAllWidgets];
    

    reloadWidget.swift文件

    import Foundation
    import WidgetKit
    
    //声明 14以上的系统才可用此 api
    @available(iOS 14.0, *)
    //定义 oc 方法
    @objcMembers final class WidgetKitHelper : NSObject {
        class func reloadAllWidgets() {
           // 刷新所有widget
           // arm64架构真机以及模拟器可以使用
            #if arch(arm64) || arch(i386) || arch(x86_64)
                WidgetCenter.shared.reloadAllTimelines()
            #endif
        }
        
        // 刷新某一个widget.  xxxx 是该widget的 identifier
        class func reloadWidgetForKind(kindStr: NSString) {
            WidgetCenter.shared.reloadTimelines(ofKind: "xxxx")
        }
    }
    

    获取数据

    网络请求
    小组件中可以使用 URLSession,所以网络请求和 App 中基本一致,在此就不赘述了。

    需要注意的点:

    1. 使用第三方框架需要引入小组件所在的 Target。
    2. 在刷新 Timeline 时调用网络请求。
    3. 如果需要和 App 共享信息,则需要通过 App Group 的方式存取。
    4. 异步的网络请求要用block实现。

    如果调用异步的网络请求,需要在getTimeline里使用block回调 (试过在网络请求里用gcd或其他线程同步方法去模拟block效果,但是不生效,只有直接使用block才可以)

    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<FourToolWidgetSimpleEntry>) -> ()) {
            mediumProdLoader.fetch(editArr: editArr as! NSArray){
                 //里面执行XXXXX
                 completion(timeline)  //最后返回completion
            }
    }
    

    block的内部实现可以去加载你的网络请求,我项目里要调用OC的接口,所以这里调用了OC代码

    // 获取所有设备数据
    struct mediumProdLoader {
        /// 获取设备列表数据
        static func fetch(editArr: NSArray,completion: @escaping (Result<NSArray,Error>) -> Void) {
            
            let vc = Calculator()
            let Arr = editArr
            
            //传参
            vc.getDeviceList(Arr as! [NSString?]) { (data) -> Void in
                let arr = data ?? NSArray() as! [Any]
                if arr.count > 0 {
                    completion(.success(arr as NSArray))
                }else{
                    completion(.success(NSArray()))
                }
            }
        }
    }
    

    SwiftUI 构建组件

    我们先来看下小组件的设计稿

    设计图样式
    因需要左右分割布局首先使用HStack分成2部分,这里需要注意swiftUI里控件与控件的默认间隔距离8,连接处如果需要2个控件紧贴需要设置padding对应的距离为-8。GeometryReader可以获取控件内部的宽高,且起点是和oc一样在控件的左上角(内部的视图还是在中心点),swiftUI默认情况下起点在中心位置。其他api可以查阅资料了解,下面展示UI对应代码
    struct MediumProductView: View {
        var entry: FourToolWidgetProvider.Entry
        
        var body: some View
        {
            let contact : Contact = entry.contact
            let contact2 : Contact = entry.contact2
            
            HStack{
                Link(destination: URL(string: "widget_deviceUUID:" + contact.deviceUUID + ":Device:" + contact.status)!, label: {
                    VStack
                    {
                        ProductView(imageUrl: contact.imageUrl)
    //                        .cornerRadius(14)
                            .padding(EdgeInsets(top:10, leading: 10, bottom: -8, trailing:0))
                        
                        
                        GeometryReader { geo in
                            if !contact.deviceUUID.isEmpty {
                                GeometryReader { geo in
                                    Text(loadStatusText(status: contact.status) ?? String())
                                        .font(.system(size: 16))
                                        .foregroundColor(Color("testColor"))
                                }
                                .frame(height: 18, alignment: .center)
                                .padding(EdgeInsets(top: 4, leading: 15, bottom: 28, trailing: 5))
                                
                                GeometryReader { geo in
                                    Text(entry.contact.name)
                                        .font(.system(size: 13))
                                        .foregroundColor(Color("testColor2"))
                                }
                                .frame(height: 12, alignment: .center)
                                .padding(EdgeInsets(top: 25, leading: 15, bottom: 13, trailing: 5))
                            }
                        }
                        .frame(minWidth: 100, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 18, maxHeight: 49.5, alignment: .center)
                    }
                    .padding(EdgeInsets(top:0, leading: 0, bottom: 0, trailing:1))
                })
                
                Link(destination: URL(string: "widget_deviceUUID:" + contact2.deviceUUID + ":Device:" + contact2.status)!, label: {
                    VStack
                    {
                        Image(uiImage: loadNetworkImage(imgUrlString: "https://wimg.ruan8.com/uploadimg/image/20190502/20190502165644_70280.jpg")!)
                            .resizable()   //自适应大小 * 图片没有完全显示全,我们可以用Image的resizable()来让图片自动适应
                            .scaledToFill()
                            .frame(minWidth: 0, maxWidth: .infinity)
                            .clipped()
    //                    ProductView(imageUrl: contact2.imageUrl)
    //                        .cornerRadius(14)
                            .padding(EdgeInsets(top:10, leading: 0, bottom: -8, trailing:10))
                        
                        GeometryReader { geo in
                            if !contact2.deviceUUID.isEmpty {
                                GeometryReader { geo in
                                    Text(loadStatusText(status: contact2.status) ?? String())
                                        .font(.system(size: 16))
                                        .foregroundColor(Color("testColor"))
                                }
                                .frame(height: 18, alignment: .center)
                                .padding(EdgeInsets(top: 4, leading: 5, bottom: 28, trailing: 15))
                                
                                GeometryReader { geo in
                                    Text(contact2.name)
                                        .font(.system(size: 13))
                                        .foregroundColor(Color("testColor2"))
                                }
                                .frame(height: 12, alignment: .center)
                                .padding(EdgeInsets(top: 25, leading: 5, bottom: 13, trailing: 15))
                            }
                        }
                        .frame(minWidth: 100, idealWidth: 100, maxWidth: .infinity, minHeight: 0, idealHeight: 18, maxHeight: 49.5, alignment: .center)
                    }
                    .padding(EdgeInsets(top:0, leading: 1, bottom: 0, trailing:0))
                })
            }
        }
    }
    

    通过上述简单的例子可以发现,在常规的流式布局中,使用 VStack 和 HStack 即可达到布局效果。而如果想要实现例子中 logo 图标的效果的话,就需要使用 position/offset 来改变定位坐标来达成目标了。

    跳转至App

    不可交互,只可点击

    Widget 的 UI 是无状态的,不支持滚动,也不支持像 Switch 一样的互动元素。唯一开放的能力只有通过点击和DeepLink 来唤起主 App。

    苹果提供了两种 API 给到开发者,对于 systemSmall 类型来说,只支持 widgetURL 的方式, systemMediumsystemLarge 只支持使用。

    SwiftUI widgetURL API,代码如下所示:


    而 widgetURL 的可点击区域如下:

    SwiftUI Link API,代码如下所示:


    而 Link 的可点击区域如下:
    同时,为了性能和耗电量的考虑。Widget 不能展示视频和动态图像。所以期待通过动效吸引用户眼球的方式可以暂时息熄火了~

    设置完widgetURL或Link后,添加对应的URL Types


    之后再AppDelegate里的- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options方法写对应的逻辑处理,如果有SceneDelegate文件,是在SceneDelegate 文件的- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts方法里实现。

    动态配置小组件

    Widget 需要一定的定制能力,比如当我添加一个天气 Widget,我只需要关心杭州的天气怎么样。为了实现这个能力,苹果给 Widget 提供了 Configuration 的能力。顾名思义,就是可配置。一共有两种配置类型:


    • StaticConfiguration,也就是用户无需配置,展示的内容只和用户信息有关系。
    • IntentConfiguration,支持用户配置及用户意图的推测功能。

    小组件支持用户在不打开应用的情况下配置自定义数据,使用 Intents 框架,可以定义用户在编辑小组件时看到的配置页面。 这里用的词的定义而不是绘制,是因为只能通过 Intents 来生成配置数据,系统会根据生成的数据来构建配置页面。

    编辑功能需要创建独立的Extension,他的类型为com.apple.intents-service,和siriKit的Intent类型一致,无法像widget那样独立运行,同widget创建逻辑,创建Intents Extension


    语言可以选择oc或swift,这里我选择了swift,并且Staring Point选择None

    创建后会生成对应的文件,默认只有一个handler方法,这里的方法需要你修改widget.intentdefinition文件后手动继承代理后实现对应的代理方法

    如果这里也要桥接OC代码可以引用之前的桥接文件,在Build Setting里做同样的路径配置

    编辑功能的具体内容需要在intentdefinition文件中设置,如果没有先创建一个intentdefinition文件(默认会自带一个),之后添加新的Intent如下图

    新建Intent后需要选择一种Type,有已经设定好的一些内容和自定义的内容,如果想展示通过网络请求这种自定义的数据需要新建一个Type类型。

    根据不同的需求配置完成后(因为我要根据不同类型展示编辑个数不同且需要动态编辑数据,所以最终配置如下图)
    编辑功能配置
    修改好配置后,Xcode 会自动帮你生成对应的代码和类型,需要command+b刷新一下配置,因为这里的配置会动态生成对应的编辑功能swift文件。(ps:有时候更新的会比较慢,很坑,可以多刷新或者重启下XCode试试)

    这里要注意你的intent的命名是后面代理方法和类的名称都要用到的


    之后修改IntentHandler里的代码如下

    //这里的代理名称是你设置的intent的命名后面加上IntentHandling
    class IntentHandler: INExtension, ConfigurationIntentHandling , RunIntentHandling  {
        
        override func handler(for intent: INIntent) -> Any {
            // This is the default implementation.  If you want different objects to handle different intents,
            // you can override this and return the handler you want for that particular intent.
            
            return self
        }
        
    }
    

    之后会出现个报错,提示没有实现代理方法,直接点击fix即可


    点击后生成必须实现的方法,也就是编辑功能对应的回调方法

    需要在方法里返回items数组,列表里是编辑的数据内容!数组里元素默认是2个值identifierdisplay
    func provideParameterOptionsCollection(for intent: ConfigurationIntent, with completion: @escaping (INObjectCollection<Type>?, Error?) -> Void) {
            mediumCacheProdLoader.fetch{ result in
                let arr: NSArray
                if case .success(let data) = result {
                    arr = data
                    
                    //判空处理
                    if arr.count == 0 {
                        completion(nil, nil)
                    }
                    
                    let animals: [Type] = arr.map { dict in
                        let dic = dict as? Dictionary<NSString, Any>
                        return Type(identifier: dic?["deviceUUID"] as? String, display: dic?["name"] as! String)
                    }
                    //最后通过回调返回编辑列表
                    completion(INObjectCollection(items: animals), nil)
                }
            }
        }
    

    之后要修改 Widget 的相关参数支持 ConfigurationIntent
    旧:

    struct MyWidget: Widget {
        let kind: String = "MyWidget"
        var body: some WidgetConfiguration {
            StaticConfiguration(kind: kind, provider: Provider()) { entry in
                MyWidgetEntryView(entry: entry)
            }
        }
    }
    

    新:

    @main
    struct MyWidget: Widget {
        let kind: String = "MyWidget"
        var body: some WidgetConfiguration {
            IntentConfiguration(kind: kind, intent: WidgetConfiguratIntent.self, provider: Provider()) { entry in
                MyWidgetEntryView(entry: entry)
            }
        }
    }
    

    修改 IntentTimelineProvider 的继承
    旧:

    struct Provider: TimelineProvider {
        ...
    }
    

    新:

    struct Provider: IntentTimelineProvider {
        typealias Intent = WidgetConfiguratIntent
        ...
    }
    

    如果要用到编辑的列表内容,可以给getSnapshotgetTimeline增加configuration属性

    func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<WidgetSimpleEntry>) -> ()) {
        ...
    }
    

    大致逻辑就是这样,具体的动态配置过程可以参照官方视频里的步骤操作https://developer.apple.com/videos/play/wwdc2020/10194/

    修改后长按小组件就会弹出编辑弹窗

    长按编辑弹窗
    点击编辑小组件
    编辑界面
    点击选取后会弹出可自定义的编辑列表
    自定义编辑列表
    每次选择完编辑内容后会再次触发getTimeline方法,在参数configuration.parameter中存放编辑的数据。

    总结

    小组件是一个优缺点都非常明显的事物,在桌面即点即用确实方便,可以是App的快捷入口,展示信息的窗口,还可以提升主操作页面的美观及多样性,可以说是一次很重要的体验更新。但是交互方式的匮乏以及不能实时更新数据又是非常大的缺陷。正如苹果所说:"Widgets are not mini-apps",不要用开发 App 的思维来做小组件,小组件只是由一连串数据驱动的静态视图。

    • 优势:
    1. 常驻桌面,大大增加了对产品的曝光。
    2. 利用网络接口和数据共享,可以展示与用户相关的个性化内容。
    3. 缩短了功能的访问路径。一次点击即可让用户触达所需功能。
    4. 可以多次重复添加,搭配自定义和推荐算法,添加多个小组件样式和数据都可以不同。
    5. 自定义配置简单。
    6. 多种尺寸,大尺寸可以承载复杂度高的内容展示。
    • 缺点:
    1. 不能实时更新数据。
    2. 只能点击交互。
    3. 小组件的背景不能设置透明效果。
    4. 不能展示动态图像(视频/动图)。
    5. UI样式固定,如编辑页面不能自由调整。
    6. 功能单一,对开发者的限制较大。(目前来看小组件更多的适合信息展示类的内容加载)

    总结来说,个人认为iOS14小组件功能还是利大于弊的,毕竟这种主桌面的增项功能可以提升App的粘性,使用得当可以极大提升用户体验。虽然目前对小组件的功能使用限制较多,但如果未来苹果愿意开通更多的权限给到开发者,小组件也必将是App开发者们的必争之地!

    参考资料

    网易云音乐 iOS 14 小组件实战手册
    Widget 到底是什么?和 App 的区别在哪儿呢?
    iOS14 Widget小组件开发(Widget Extension)
    iOS14 Widget 开发
    Add configuration and intelligence to your widgets
    iOS:OC与Swift互调
    SwiftUI 的一些初步探索 (一)
    SWiftUI之Layout基础篇

    Demo下载

    包含上述描述文章的所有功能,已上传github(mac 打不开github解决办法
    widgetDemo

    相关文章

      网友评论

        本文标题:iOS14 Widgets开发(从0到1最详细攻略)

        本文链接:https://www.haomeiwen.com/subject/mpeazktx.html