iOS小组件开发全面总结

4,671 阅读22分钟

小组件介绍

小组件是App Extension的一种,前身为Today Extension,iOS8推出,只能添加到负一屏。
iOS14推出Widget Extension取代Today Extension。 相比Today ExtensionWidget Extension可以添加到桌面,并且支持了更多的尺寸,可以承载更多内容。
iOS17开始支持在小组件上点击交互,进一步丰富了小组件能力。

小组件开发依赖的主要官方框架是WidgetKit,界面开发需要使用SwiftUI,自定义配置、点击交互等和SiriKit的机制相同。
本文将先简单介绍SwiftUI,使读者对小组件的UI开发有一个简单了解;然后对小组件的核心技术进行展开;另外,小组件开发中总会看到Siri和快捷指令的影子,因此,最后也会顺带讲一点Siri和快捷指令相关知识。

了解SwiftUI

SwiftUI是苹果2019年推出的构建界面的声明式框架,相对UIKit具有更高的开发效率和更好的可读性等优点。 接下来以Xcode创建的工程模版为切入点,介绍一点SwiftUI开发知识。

创建工程时,Interface选择SwiftUI,Xcode生成的工程模版会包含两个Swift文件:
MyApp.swift

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

ContentView.swift

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
        }
        .padding()
    }
}

MyApp.swift中的@main代表程序入口,一个进程只能有一个@main标注,能被标注为@main的类型必须包含一个无参无返回值的静态 main函数,即static func main()
App代表应用程序,Scene代表应用程序的窗口,它们都是协议,WindowGroup 是其中一个Scene
View代表视图,也是一个协议,每个View都有一个body属性,类型为some View,用于自定义View

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {
    associatedtype Body : View
    @ViewBuilder @MainActor var body: Self.Body { get }
}

@MianActor作用是将body的获取派发到主线程。
@ViewBuilder用于构建一个或多个视图:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@resultBuilder public struct ViewBuilder {
    // ...

    public static func buildBlock<each Content>(_ content: repeat each Content) -> TupleView<(repeat each Content)> where repeat each Content : View
}

@ViewBuilderbuildBlock函数支持传入多个View,因此ContentViewbody中可以放多个View,例如:

struct ContentView: View {
    var body: some View {
        Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
        Text("Hello, world!")
    }
}

以上代码也等效于:

struct ContentView: View {
    var body: some View {
        ViewBuilder.buildBlock(
            Image(systemName: "globe")
                .imageScale(.large),
            Text("Hello, world!")
        )
    }

}

@resultBuilder的作用可以简单理解为,省去buildBlock函数调用,直接在body里声明View
SwiftUI中还有很多地方用到@ViewBuidler,例如示例中的VStack

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct VStack<Content> : View where Content : View {
    @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
    public typealias Body = Never
}

小组件里只能使用有限的SwiftUI控件,具体支持小组件的控件可以参考官方文档 SwiftUI views for widgets

创建小组件扩展

1、在 Xcode 中打开 App 项目,并选取"File > New > Target" ,选择 "Widget Extension"

2、点击"Next"

  • Include Live Activity : 灵动岛、实时活动相关
  • Include Configuration App Intent:iOS 17以上小组件配置相关

以上两个设置项暂时不用勾选,涉及到相关功能时再添加。

3、点击“Finish” image.png

Xcode会自动生成和小组件Target同名的文件夹,包含以下内容:

  • MyWidgetBundle: 小组件容器, body可以放多个小组件,用于一个Widget Extension展示多种小组件
    @main
    struct MyWidgetBundleWidgetBundle {
       @WidgetBundleBuilder
       var body: some Widget {
           MyWidget()
       }
    }
    
  • MyWidget: 小组件核心代码从这里开始
    struct MyWidgetWidget {
       let kind: String = "MyWidget"
    ​
       var body: some WidgetConfiguration {
           StaticConfiguration(kind: kind, provider: Provider()) { entry in
               if #available(iOS 17.0*) {
                   MyWidgetEntryView(entry: entry)
                       .containerBackground(.fill.tertiary, for: .widget)
               } else {
                   MyWidgetEntryView(entry: entry)
                       .padding()
                       .background()
               }
           }
           .configurationDisplayName("My Widget")
           .description("This is an example widget.")
       }
    }
    

WidgetBundleWidget都可以标注@main作为程序入口,如果Widget标注@main,那么整个小组件扩展只有一种小组件。

4、Xcode中运行“MyApp”, 再手动启动一次App,长按桌面 -> 搜索小组件 -> 选择“MyApp”即可添加小组件:

Simulator Screenshot - iPhone 15 - 2024-08-24 at 20.38.03.png Simulator Screenshot - iPhone 15 - 2024-08-24 at 20.39.07.png Simulator Screenshot - iPhone 15 - 2024-08-24 at 20.40.04.png

用户必须在安装包含小组件的 App 后至少启动该 App 一次,才能找到小组件

小组件结构

小组件结构图.png

WidgetBundle

@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public protocol WidgetBundle {
    associatedtype Body : Widget
    @WidgetBundleBuilder var body: Self.Body { get }
}
// ...

WidgetBundlebody中返回遵守Widget协议的实例。
@WidgetBundleBuilder的作用和前面的@ViewBuidler作用相似。@WidgetBundleBuilderbuildBlock方法最多支持传入10个遵守Widget协议的参数,因此一个WidgetBundle最多可以支持放10个Widget

截屏2024-08-25 16.35.28.png

以下方式可以突破数量的限制:

@main
struct MyWidgetBundle: WidgetBundle {
    var body: some Widget {
        MyChildWidgetBundle().body
        // 最多可以放10个`MyChildWidgetBundle().body`
    }
}

struct MyChildWidgetBundle: WidgetBundle {
    var body: some Widget {
        MyGrandsonWidgetBundle().body
        // 最多可以放10个`MyGrandsonWidgetBundle().body`
    }
}

struct MyGrandsonWidgetBundle: WidgetBundle {
    var body: some Widget {
        MyWidget() 
        // 最多可以放10个`MyWidget()`
    }
}

Widget

@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public protocol Widget {
    associatedtype Body : WidgetConfiguration
    var body: Self.Body { get }
}

Widgetbody返回遵守WidgetConfiguration协议的实例。系统为小组件提供了三个遵守WidgetConfiguration协议的结构体:

  • StaticConfiguration:适用于没有用户配置(也就是没有编辑功能)的小组件
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public struct StaticConfiguration<Content> : WidgetConfiguration where Content : View {
    public var body: some WidgetConfiguration { get }
    public typealias Body = some WidgetConfiguration
}

@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
extension StaticConfiguration {

    public init<Provider>(
        kind: String, 
        provider: Provider, 
        @ViewBuilder content: @escaping (Provider.Entry) -> Content
    ) where Provider : TimelineProvider

}
  • IntentConfiguration: 适用于有用户自定义配置的小组件,需要创建SiriKit意图定义文件和Intents Extension
@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
public struct IntentConfiguration<Intent, Content> : WidgetConfiguration where Intent : INIntent, Content : View {
    public var body: some WidgetConfiguration { get }
    public typealias Body = some WidgetConfiguration
}

@available(iOS 14.0, macOS 11.0, watchOS 9.0, *)
@available(tvOS, unavailable)
extension IntentConfiguration {

    public init<Provider>(
        kind: String, 
        intent: Intent.Type, 
        provider: Provider, 
        @ViewBuilder content: @escaping (Provider.Entry) -> Content
    ) where Intent == Provider.Intent, Provider : IntentTimelineProvider

}
  • AppIntentConfiguration: iOS17推出,不需要添加意图定义文件和Intents Extension,可以纯代码实现用户配置
public struct AppIntentConfiguration<Intent, Content> : WidgetConfiguration where Intent : WidgetConfigurationIntent, Content : View {
    public var body: some WidgetConfiguration { get }
    public typealias Body = some WidgetConfiguration
}

@available(iOS 17.0, macOS 14.0, watchOS 10.0, *)
@available(tvOS, unavailable)
extension AppIntentConfiguration {

    public init<Provider>(
        kind: String, 
        intent: Intent.Type = Intent.self, 
        provider: Provider, 
        @ViewBuilder content: @escaping (Provider.Entry) -> Content
    ) where Intent == Provider.Intent, Provider : AppIntentTimelineProvider

}

以上三种WidgetConfiguration的初始化方法都包含以下参数:

  • kind: 用于标识小组件(主程序刷新小组件时可以根据kind刷新指定小组件)
  • Provider: 用于给小组件生命周期各阶段提供数据
  • content:内容闭包,类型为(Provider.Entry) -> Content), 需要在闭包中返回一个SwiftUI视图对象交给WidgetKit显示,视图对象可以根据Provider生成的TimelineEntry对象展示数据

TimelineProvider

不同WidgetConfiguration对应的Provider遵循的协议也不同,但各协议的方法比较类似,这里以StaticConfiguration对应的TimelineProvider为例:

struct ProviderTimelineProvider {

   /// 小组件没有数据时占位,每种(kind)小组件只会调用一次
   func placeholder(in contextContext) -> SimpleEntry {
      SimpleEntry(date: Date(), emoji: "😀")
   }
​
   /// 小组件预览时调用,例如添加小组件的页面
   func getSnapshot(in contextContextcompletion@escaping (SimpleEntry) -> ()) {
       let entry = SimpleEntry(date: Date(), emoji: "🧐")
       completion(entry)
   }

   /// 小组件时间线刷新的时候调用
   /// 刷新时机:添加小组件、编辑小组件、主App调用WigetKit的刷新方法、定时刷新、操作系统的一些设置等
  func getTimeline(in contextContextcompletion@escaping (Timeline<Entry>) -> ()) {
       var entries: [SimpleEntry= []
       // ...
       let timeline = Timeline(entries: entries, policy: .atEnd)
       completion(timeline)
   }
}

Context

小组件上下文,包含小组件的所有环境变量,类型为TimelineProviderContext结构体

public struct TimelineProviderContext {
    /// 环境变量
    /// 例如:`environmentVariants.colorScheme`可以获取到系统当前是否是暗黑模式
    /// 环境变量也可以在`SwiftUI`通过以下方式获取:
    ///     @Environment(\.colorScheme) private var colorScheme
    ///
    ///     var body: some View {
    ///         Text(colorScheme == .dark ? "Dark" : "Light")
    ///     }
    public let environmentVariants: TimelineProviderContext.EnvironmentVariants
    /// 小组件型号
    public let family: WidgetFamily
    /// 是否在预览页面
    public let isPreview: Bool
    /// 小组件尺寸
    public let displaySize: CGSize
}

TimelineEntry

小组件数据模型,包含一个必须实现的date属性,告诉WidgetKit何时渲染小组件。relevance不是必须的,几乎用不上,可忽略。可以遵守TimelineEntry为小组件视图提供业务数据。

public protocol TimelineEntry {
   var date: Date { get }
   var relevance: TimelineEntryRelevance? { get }
}

Timeline

小组件时间线,包含policy和一组TimelineEntry

public struct Timeline<EntryTypewhere EntryType : TimelineEntry {
   public let entries: [EntryType]
   public let policy: TimelineReloadPolicy

   public init(entries: [EntryType], policyTimelineReloadPolicy)
}

policy表示小组件时间线的刷新策略,有以下三种:

  • atEnd:最后一个TimelineEntry用完之后立即刷新
  • after(Date):指定日期之后刷新小组件
  • never:永不刷新

小组件UML

小组件UML.png

小组件尺寸

系统支持的尺寸

iPhone上,小组件支持的尺寸包括:smallmediumlarge从 iOS 16 开始,锁屏界面支持 accessoryCircularaccessoryRectangularaccessoryInline 类型,这几种小尺寸也可以用在手表中。

小组件尺寸iPhoneiPadApple WatchMac
系统小尺寸主屏幕、“今天”视图和待机显示主屏幕、“今天”视图和锁定屏幕桌面和“通知中心”
系统中尺寸主屏幕和“今天”视图主屏幕和“今天”视图桌面和“通知中心”
系统大尺寸主屏幕和“今天”视图主屏幕和“今天”视图桌面和“通知中心”
系统特大尺寸主屏幕和“今天”视图桌面和“通知中心”
补充圆形锁定屏幕锁定屏幕手表复杂功能和在智能叠放中
补充圆角手表复杂功能
补充矩形锁定屏幕锁定屏幕手表复杂功能和在智能叠放中
补充内联锁定屏幕锁定屏幕手表复杂功能
实时活动锁定屏幕锁定屏幕

更多小组件尺寸相关参考: Widget Design

配置尺寸

通过为WidgetConfiguration设置supportedFamilies属性来为小组件设置支持哪些尺寸

struct MyWidgetWidget {
   let kind: String = "MyWidget"
​
   var body: some WidgetConfiguration {
       StaticConfiguration(kind: kind, provider: Provider()) { entry in
           MyWidgetEntryView(entry: entry)
       }
       .configurationDisplayName("My Widget")
       .description("This is an example widget.")
       .supportedFamilies([.small, .medium]) // 小、中
   }
}

获取尺寸

在小组件任何视图中,通过环境变量(@Environment)获取小组件的尺寸,例如:

struct TestViewView {
   @Environment(.widgetFamily) var family
   
   var body: some View {
       switch family {
       case .systemSmall:
           Text("small")
       default:
           Text("not small")
       }
   }
}

小组件刷新

活跃时间段

小组件活跃时间段.png

小组件的活跃时间段:

  • 时间线刷新过程
  • iOS17及以上AppIntent的点击交互过程

在这两段时间内需要准备好数据,以供小组件渲染;另外,在这两段时间内,网络请求、开线程、定时器、收发通知(NotificationCenter)等iOS具备的能力基本上可以正常使用。

刷新机制

小组件的页面刷新是基于时间线的更新。时间线由刷新策略和一组具有时间点的Entry(数据模型)组成。

官方建议entry间隔时间至少5分钟,实践过程中可以间隔秒级。但是小组件对内存有限制,最多30M,间隔时间太短需要创建很多entry,如果页面上元素也较多,会消耗大量内存,导致小组件整体页面显示不出来。

小组件的时间线刷新是串行的,下一次getTimeLine一定会在上一次completion之后,如果getTimeLinecompletion超时,当次刷新会被丢弃,等待下一次刷新。

经测试,每个活跃时间段不能超过28s,例如getTimeLinecompletion必须在28s以内完成,否则刷新超时。

刷新预算

为节省系统资源以及减少电耗,系统会根据用户浏览小组件的频率和次数、小组件上次刷新时间等因素,为小组件动态分配刷新预算。通常一个小组件24小时可以获得40-70次刷新,时间线刷新策略的时间设置大概是15-60分钟。如果添加了多个小组件,虽然这些小组件同属一个进程,但刷新预算是独立的。

以下场景触发刷新小组件时,不会计入预算:

  • 主App在前台
  • 主App有活跃的音频或导航会话
  • iOS17以上的点击交互
  • 系统语言区设置发生更改
  • 动态类型或辅助功能设置发生更改

主App通知小组件刷新

除了时间线刷新策略.after(_:)、一些系统设置触发时间线刷新,主App活跃时,可以通过WidgetKitWidgetCenter通知小组件刷新。

  • 刷新指定小组件:
WidgetCenter.shared.reloadTimelines(ofKind: "com.company.carwidget.middle")
  • 刷新所有小组件:
WidgetCenter.shared.reloadAllTimelines()
  • 根据小组件配置有条件地刷新小组件
WidgetCenter.shared.getCurrentConfigurations { result in
   guard case .success(let widgets) = result else { return }
   // Iterate over the WidgetInfo elements to find one that matches
   // the character from the push notification.
   if let widget = widgets.first(
       where: { widget in
           let intent = widget.configuration as? SelectCharacterIntent
           return intent?.character == characterThatReceivedHealingPotion
       }
   ) {
       WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
   }
}

经测试,如果主App在前台同时调用多次刷新,系统会合并,直到主App退到后台,小组件的时间线才会开始刷新,因此不用担心主App刷新调用次数过多。

小组件自定义配置

小组件支持自定义配置, 例如一些车控小组件的编辑车控功能等。

理想汽车编辑.gif

小组件自定义配置和Siri、快捷指令交互机制相同,通过自定义意图来定义配置。
iOS17以下,通过创建SiriKit意图定义文件和意图扩展程序,来实现小组件自定义配置,详细教程可以参考官方文档制作可配置小组件
iOS17及以上,还可以通过实现WidgetConfigurationIntent协议来完成小组件自定义配置,这种方式是纯代码实现,不需要添加意图定义文件,也不需要添加意图扩展,可以直接运行在小组件进程中。涉及到的数据类型可以参考前面的小组件UML

以下通过SiriKit意图定义文件意图扩展,来完成自定义配置小组件车控功能,主要包含以下步骤:

  1. 创建和编辑SiriKit意图定义文件
  2. 添加并设置意图扩展
  3. 修改MyWidgetProvider,支持自定义配置

创建和编辑SiriKit意图定义文件

1、创建意图定义文件

image.png

201723973790_.pic.jpg

2、添加意图
截屏2024-08-18 17.46.38.png

  • 点击"+"选择"New Intent",添加后取名为"WidgetConfig"
  • 勾选"Intent is eligible for widgets",将意图应用在小组件

3、定义车控按钮数据类型

截屏2024-08-24 21.36.16.png
  • 点击"+"选择"New Type"定义外观模式的数据类型,这里取名为"CarControl"
  • "Dispaly Name" 取名为“Car Control”,用于在类型列表中展示
  • 配置默认包含两个属性:"identifier"和"displayString",也可以添加其他属性

4、为意图添加车控属性

截屏2024-08-25 19.02.51.png 截屏2024-08-25 19.06.21.png
  • Display Name:配置页面显示的名称
  • Supports multiple values:是否支持多个值,勾选上后,Fixed size才能勾选
  • Fixed size:各种尺寸的小组件配置页面显示几个元素
  • User can edit value in Shortcuts, widgets, and add to Siri:勾选上才会有「小组件编辑」入口
  • Options are provided dynamically:支持动态的配置选项
  • Intent handler provides search results are the user types:支持自定义匹配搜索关键字(一般不用勾选)

截屏2024-08-31 16.50.47.png

完成以上步骤后,Xcode会生成以下几个数据类型:

  • CarControl结构体
  • WidgetConfigIntent类,具有carControls属性
  • WidgetConfigIntentHandling协议

添加意图扩展

截屏2024-08-18 18.41.32.png

截屏2024-08-18 18.47.31.png 添加完成后,Xcode会自动和扩展同名的文件夹,文件夹中包含一个IntentHandler文件

代码中支持配置

1、 意图定义文件勾选上WidgetIntents对应的Target,使意图定义文件的数据类型在两个Target中能被访问到

截屏2024-08-25 19.12.07.png

2、实现WidgetConfigIntentHandling协议,提供车控功能选项

车控功能枚举定义

enum CarControlType: String, CaseIterable {
    case lock
    case findCar
    case trunk
    case window
    case airCondition
    case ventilate
    case skylight
}

extension CarControlType {
    var name: String {
        switch self {
        case .lock:
            return "车锁"
        case .findCar:
            return "寻车"
        case .trunk:
            return "后备箱"
        case .window:
            return "车窗"
        case .airCondition:
            return "空调"
        case .ventilate:
            return "透气"
        case .skylight:
            return "天窗"
        }
    }
}

IntentHander.swift

import Intents

class IntentHandler: INExtension {

    override func handler(for intent: INIntent) -> Any {
        return self
    }
}

extension IntentHandler: WidgetConfigIntentHandling {

    /// 动态提供可选的车控功能(每次时间线刷新都会执行)
    /// 勾选`Intent handler provides search results are the user types`后才会有searchTerm参数
    func provideCarControlsOptionsCollection(
        for intent: WidgetConfigIntent, 
        searchTerm: String?, 
        with completion: @escaping (INObjectCollection<CarControl>?, Error?) -> Void
    ) {
        var carControlTypes = CarControlType
            .allCases
            .filter { carControlType in
                !(intent.carControls ?? []).contains { $0.identifier == carControlType.rawValue }
            } // 过滤掉编辑页面已经存在的车控功能
         
        // 匹配搜索关键字
        if let searchTerm {
            carControlTypes = carControlTypes.filter { $0.name.contains(searchTerm) }
        }
     
        let carControls = carControlTypes.map { CarControl(identifier: $0.rawValue, display: $0.name) }
        
        completion(INObjectCollection(items: carControls), nil)
    }

    /// 提供默认4个车控功能(小组件生命周期内只会执行一次)
    func defaultCarControls(for intent: WidgetConfigIntent) -> [CarControl]? {
        CarControlType
            .allCases
            .prefix(4)
            .map { CarControl(identifier: $0.rawValue, display: $0.name) }
    }
}

3、Provider修改为遵守IntentTimelineProvider协议

struct Provider: IntentTimelineProvider {

    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date())
    }

    func getSnapshot(for configuration: WidgetConfigIntent, in context: Context, completion: @escaping (Entry) -> Void) {
        let entry = SimpleEntry(date: Date())
        completion(entry)
    }

    func getTimeline(for configuration: WidgetConfigIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
       let entry = SimpleEntry(date: Date(), carControls: configuration.carControls ?? [])
        let timeline = Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(20 * 60)))
        completion(timeline)
    }

对比TimelineProvider协议,getSnapshotgetTimeline的方法多一个WidgetConfigIntent类型的参数,通过该参数可以获取当前选择的配置项。 WidgetConfigIntent是自动生成的类,在编辑意图定义文件时,我们为WidgetConfigIntent添加了一个carControls的属性,WidgetConfigIntent的声明如下:

@available(iOS 12.0, macOS 11.0, watchOS 5.0, *) @available(tvOS, unavailable)
@objc(WidgetConfigIntent)
public class WidgetConfigIntent: INIntent {
    @NSManaged public var carControls: [CarControl]?
}

4、MyWidget使用IntentConfiguration

struct MyWidget: Widget {
    let kind: String = "MyWidget"

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: Provider.Intent.self, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                MyWidgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                MyWidgetEntryView(entry: entry)
                    .padding()
            }
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

至此,自定义车控功能配置已全部完成,重新运行程序即可看到效果:

Simulator Screen Recording - iPhone 15 - 2024-08-25 at 19.56.36.gif

意图扩展也是独立进程,不能访问小组件的内存数据,数据通信需要使用AppGroup

  • 每个小组件的配置是独立的,用户设置了小组件的配置,不会影响其他小组件
  • 用户一旦设置了配置,代码里便不能更改

小组件和主App通信

小组件和主App是相互独立的进程,内存中的数据和方法不能直接访问。

App Group(共享数据)

两个进程间的数据共享依赖于AppGroup, AppGroup是iOS8增加的功能,关于AppGroup的配置可以参考配置AppGroup

可以通过UserDefaultsFileManager两种方式实现数据共享,小组件和主App都可以对共享数据进行读取

UserDefaults

UserDefaults(suiteName: "group.com.company.myApp")

FileManager

let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.company.myApp")
let fileURL = groupURL?.appendingPathComponent("myApp.txt")

主App向小组件通信

主App活跃时,可以通过WidgetKit提供的刷新时间线的方法通知小组件时间线刷新

// 刷新所有小组件
WidgetCenter.shared.reloadAllTimelines()
​
// 根据kind刷新小组件
WidgetCenter.shared.reloadTimelines(ofKind: "widgetKind")

小组件向主App通信

widgetURL和Link

widgetURLLink可以在小组件点击时打开主App,并且将数据通过URL传递给主App

CFNotificationCenter

CFNotificationCenter是进程间通信的一种方式,但必须使用CFNotificationCenterGetDarwinNotifyCenter

public struct NotifyCenter {
​
    /// 发通知
    static func post(name: String) {
        CFNotificationCenterPostNotification(
            CFNotificationCenterGetDarwinNotifyCenter(),             // center
            CFNotificationName(rawValue: "AppToWidget" as CFString), // name
            nil,                                                     // object
            nil,                                                     // userInfo
            true                                                     // deliverImmediately                   
        )
    }
    
    /// 监听通知
    static func observe(name: String, callback: @escaping () -> Void) {
        callbacks[name] = callback
        
        CFNotificationCenterAddObserver(
            CFNotificationCenterGetDarwinNotifyCenter(),            // center
            nil,                                                    // observer
            { _, _, notificationName, _, _ in                       // callback
                if let notificationName {
                    NotifyCenter.notifyFired(notificationName)
                }
            },
            name as CFString,                                       // name
            nil,                                                    // object
            .deliverImmediately                                     // suspensionBehavior
        )  
    }
    
    private static var callbacks: [String: () -> Void] = [:]
    private static func notifyFired(_ notifyName: CFNotificationName) {
        let name = notifyName.rawValue as String
        callbacks[name]?()
    }
}

经测试,CFNotificationCenter用于小组件和主App通信有以下特点:

  • 小组件向主App发通知时,App在前后台都可以收到
  • 小组件里也可以接收主App的通知,但是小组件必须在活跃时段
  • CFNotificationCenterGetDarwinNotifyCenter不能传递数据,传递数据只能通过AppGroup

小组件点击交互

iOS17以下,点击小组件,只能打开主App,在主App完成交互;iOS 17及以上,可以直接在小组件上完成点击交互。

iOS17以下跳转主App完成交互

widgetURL和Link

默认情况下,点击小组件系统会打开主App。

对于小型组件,iOS17以下,可以为视图设置 widgetURL(_:) ,当点击小组件打开主App时,为主App传递URL

对于中、大型组件, 以及iOS17及以上的小型组件,除了widgetURL(_:),还可以添加Link控件,点击Link控件,同样会打开主App时,并为主App传递URL

点击widgetURLLink打开主App时,会触发主App的回调方法:

  • SwiftUI:onOpenURL(perform:)
  • SceneDelegate:scene(_:, openURLContexts:)
  • AppDelegate: application(_:open:options:)
struct MyWidgetEntryView : View {
    var entry: Provider.Entry
​
    var body: some View {
        VStack {
            Link(destination: URL(string: "myWidget://control?type=lock), label: {
                Text("车锁")
            })
            Link(destination: URL(string: "myWidget://control?type=honkFlast), label: {
                Text("寻车")
            })
        }
        .widgetURL(URL(string: "geelycarwidget://"))
    }
}

应该只在小组件的视图层次结构中添加一次 widgetURL,如果添加多个,只有最后添加的才会生效

iOS17及以上在小组件内直接交互

iOS17及以上,AppIntents框架为SwiftUI的ButtonToggle 这两种控件扩展了初始化方法,支持传入AppIntent(意图)实例,通过点击控件触发AppIntent的执行实现小组件上直接交互。

Button

/// SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct Button<Label> : View where Label : View {
  /// ...
  public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
  /// ...
}
​
/// AppIntents
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension Button {
    public init<I>(intent: I, @ViewBuilder label: () -> Label) where I : AppIntent
}

Toggle

/// SwiftUI
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public struct Toggle<Label> : View where Label : View {
  /// ...
  public init(isOn: Binding<Bool>, @ViewBuilder label: () -> Label)
  /// ...
}
​
/// AppIntents
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension Toggle {
    public init<I>(isOn: Bool, intent: I, @ViewBuilder label: () -> Label) where I : AppIntent
}
ToggleStyle

SwiftUI中对View扩展了toggleStyle(_:),支持定制Toggle样式:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension View {
  public func toggleStyle<S>(_ style: S) -> some View where S : ToggleStyle
}
​
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol ToggleStyle {
    associatedtype Body : View
  
    @ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
​
    typealias Configuration = ToggleStyleConfiguration
}

View调用toggleStyle(_:)可以为子视图内多个Toggle设置为相同的样式,Toggle也可以通过toggleStyle(_:)将自身设置为指定样式。

官方提供的样式

iOS系统上,官方提供了三个遵守ToggleStyle的结构体:DefaultToggleStyleSwitchToggleStyleButtonToggleStyle,可以分别通过

toggleStyle(_:)传入.automatic.switch.button

.automatic在不同平台会有不同表现:

PlatformDefault style
iOS, iPadOSswitch
macOScheckbox
tvOSA tvOS-specific button style (see below)
watchOSswitch
自定义样式

如果官方提供的样式不满足需求,可以通过实现ToggleStyle协议来自定义样式。

对于车控业务中的远控指令执行、车辆状态刷新这类需要一定时间的场景,交互效果上控件本身的UI需要切换为中间态来表示执行中。由于小组件页面更新只能来自于时间线刷新,从点击到时间线刷新这段时间内,可点击控件需要显示为中间态,只能选用Toggle,并且需要自定义ToggleStyle来实现。

车控指令交互实践
/// 车控按钮样式
struct ControlToggleStyle: ToggleStyle {
  func makeBody(configuration: Configuration) -> some View {
      // 根据Toggle状态true/false定制UI
      if configuration.on {
        Text("指令发送中")
      } else {
        Text("车锁")
      }
  }
}
​
/// 记录指令发送状态
var isCommandSending = false/// 指令意图
struct CommandAppIntent: AppIntent {
  
    static var title: LocalizedStringResource = "车控"
    
    /// 默认在子线程执行,可以标注`@MainActor`派发到主线程执行
    @MainActor 
    func perform() async throws -> some IntentResult {
        if isCommandSending {
            return .result()
        }
      
        isCommandSending = true
        await executeCommand()
        isCommandSending = false
      
        return .result()
    }
}
​
extension CommandAppIntent {
  @MainActor 
  func executeCommand() async {
        // 模拟指令发送
        await withCheckedContinuation { continuation in
            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
                continuation.resume()
            }
        }
    }
}
​
// 小组件页面中创建Toggle控件
Toggle(isOn: isCommandSending, intent: CommandAppIntent()) {
  // 这里不需要创建任何视图
}.toggleStyle(ControlToggleStyle())

invalidatableContent

官方描述:

Use this modifier to annotate views that display values that are derived from the current state of your data and might be invalidated in response of, for example, user interaction.

The view will change its appearance when RedactionReasons.invalidated is present in the environment.

In an interactive widget a view is invalidated from the moment the user interacts with a control on the widget to the moment when a new timeline update has been presented.

由于小组件不支持任何动图(gif、lottie)等, 车控按钮的中间态只能是静态UI。如果希望在点击交互执行过程中有一个动态的效果,可以让某个视图调用invalidatableContent()来达到持续闪烁的效果,但这种方式有以下缺陷:

  • 不能实现点击哪个控件就让哪个控件闪烁

    时间线刷新时,某个控件一旦调用了invalidatableContent(), 无论哪个控件点击,该控件都会闪烁。因此,对于小组件上有多个车控按钮,不能做到点击哪个按钮,就只让哪个按钮闪烁。

  • 点击交互过程中再次点击,闪烁效果会停止

AppIntent

buttonToggle的点击交互,都是触发AppIntentperform(),并且AppIntent支持接收参数。

AppIntent除了可以应用在小组件上,也可以应用在Siri快捷指令中,默认情况下,定义一个AppIntent, 就可以在快捷指令App中添加操作时被找到,添加后,便可以通过快捷指令和Siri执行。

AppIntent定义:

@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public protocol AppIntent : PersistentlyIdentifiable, _SupportsAppDependencies, Sendable {
  
    associatedtype PerformResult : IntentResult
  
    /// 标题,不能为空字符串,小组件里用不上
    static var title: LocalizedStringResource { get }
​
    /// 意图执行时,是否自动将应用拉起到前台,默认为`false`。只有意图定义在主App时,设置为`true`才会生效,默认会将应用在后台拉起,如果我们需要应用程序进入前台,需要设置openAppWhenRun为true
    static var openAppWhenRun: Bool { get }
​
    /// 意图的执行权限,例如是否允许锁屏执行等
    static var authenticationPolicy: IntentAuthenticationPolicy { get }
​
    /// 是否能在快捷指令App和Spotlight中找到,默认为`true`,如果只应用在小组件,需要设置为`false`
    @available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *)
    static var isDiscoverable: Bool { get }

    associatedtype SummaryContent : ParameterSummary
​
    /// 参数描述,需要给意图传参时才用到
    static var parameterSummary: Self.SummaryContent { get }
​
    /// 描述,小组件里用不上
    static var description: IntentDescription? { get }
​
    /// 意图执行的方法体,返回遵守`IntentResult`协议的实例,小组件里`return .result()`即可,方法返回后,会触发时间线刷新
    func perform() async throws -> Self.PerformResult

    /// 创建AppIntent
    init()
}

Siri和快捷指令

如果意图的isDiscoverable没有设置为false,那么意图一旦定义,在快捷指令App中添加操作时可以找到该意图。添加之后,可以在快捷指令App中点击执行,也可以通过Siri执行。

Simulator Screenshot - iPhone 15 - 2024-08-31 at 19.17.24.png Simulator Screenshot - iPhone 15 - 2024-08-31 at 19.19.50.png

iOS17之后,可以实现AppShortcutsProvider协议,向快捷指令App添加一些AppShortcut(AppShortcut),用户无需手动添加就可以在快捷指令App里看到:

struct ShortcutsManager: AppShortcutsProvider {

    static var appShortcuts: [AppShortcut] {
        AppShortcut(
            intent: CarControlAppIntent(),
            phrases: [
                "\(.applicationName)车锁"
            ],
            shortTitle: "车锁"
        )
        AppShortcut(
            intent: CarControlAppIntent(),
            phrases: [
                "\(.applicationName)寻车"
            ],
            shortTitle: "寻车"
        )
    }
}
Simulator Screenshot - iPhone 15 - 2024-08-31 at 19.32.30.png

每个进程里AppShortcutsProviderappShortcuts里最多放10个AppShortcut,目前还没找到支持10个以上的方法。