iOS小组件开发总结

3,144 阅读11分钟

背景介绍

小组件是App Extension的一种,前身为Today Extension,是iOS8中推出,添加到负一屏的小组件.

在iOS14 中,推出Widget Extension,相较而言,Widget Extension可以添加到桌面,并且支持了更多的尺寸,可以显示更多形态的内容.而且从 iOS17开始, 支持在小组件上点击交互,进一步丰富了小组件能力.

小组件开发依赖的主要官方框架是WidgetKit,界面开发需要使用SwiftUI.

本文将先简单介绍SwiftUI,使读者对小组件的UI开发有一个简单了解. 之后再对小组件的核心功能进行展开介绍.此外,使用 SwiftUI 进行布局设计,可以参考下一篇基于SwiftUI 页面布局设计.

1. SwiftUI 介绍

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

1.1 SwiftUI 布局

在 SwiftUI 中进行界面的布局,先介绍几个基本的概念: Stack容器,Spacer(),alignment对齐,间隔spacing, padding

  1. Stack

    • VStack:垂直堆栈,将子视图垂直排列。
    • HStack:水平堆栈,将子视图水平排列。
    • ZStack:层叠视图,将子视图层叠在一起。
  2. Spacer()

    • Spacer() 是一个非常有用的布局工具,它允许在水平或垂直堆叠的视图(Stack)中自动填充可用空间。
  3. padding

    • padding():在视图周围添加内边距。
    • padding(.horizontal)padding(.vertical):分别在水平或垂直方向添加内边距。
    • padding([.top, .bottom]):在特定方向添加内边距。
  4. 容器视图中的对齐和间距

  • alignment:在Stack中对齐子视图。
  • spacing:相邻子视图之间的距离。

Pasted image 20241016171438.png 在默认情况下 Stack 视图的宽高由子视图的最大宽高决定。

此外,还有一些常用属性和修饰符来调整视图布局:

  1. frame

    • frame(width:height:):为视图设置固定的宽度和高度。
    • frame(maxWidth: maxHeight:):为视图设置最大宽度和高度。
    • frame(minWidth: minHeight:):为视图设置最小宽度和高度。
  2. offset

    • offset(x:y:):将视图在父视图内偏移指定的x和y坐标。
  3. cornerRadius

    • .cornerradius(_:):为视图设置圆角。
  4. border

    • .border():为视图添加边框。
  5. gesture

    • .gesture(_:):为视图添加手势识别。
  6. onTapGesture

    • .onTapGesture():为视图添加点击手势。

2 widget 介绍

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

2.1 新建小组件工程

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

Pasted image 20241008093135.png 2、点击"Next"

Pasted image 20241008093234.png

  • Include Live Activity : 灵动岛、实时活动相关
  • Include Configuration App Intent:iOS 17以上小组件配置相关 以上两个设置项暂时不用勾选,涉及到相关功能时再添加.

3、点击“Finish”

Pasted image 20241008093335.png

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

  • MyWidgetBundle: 小组件容器, body可以放多个小组件,用于一个Widget Extension展示多种小组件
  • MyWidget: 小组件核心代码从这里开始

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

IMG_0252.pngIMG_0251.pngIMG_0250.png

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

2.2 小组件框架介绍

2.2.1 WidgetBundle

WidgetBundlebody中返回遵守Widget协议的实例。 WidgetBundle最多可以支持放10个Widget,超过 10 个 widget 会编译报错.

Pasted image 20241008102236.png

@main
struct MyWidgetBundleWidgetBundle {
   @WidgetBundleBuilder
   var body: some Widget {
       MyWidget()
   }
}

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

2.2.2 Widget

Widget是小组件的核心入口,Widget中定义了两个变量 kind 和 body.


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

  • kind: 用于标识小组件(主程序刷新小组件时可以根据kind刷新指定小组件)
  • Body: Widgetbody中遵守WidgetConfiguration协议的实例。

系统为小组件body提供了三个遵守WidgetConfiguration协议的结构体:

  • StaticConfiguration:适用于没有用户配置(也就是没有编辑功能)的小组件
  • IntentConfiguration: 适用于可以用户自定义配置的小组件,需要创建SiriKit意图定义文件和Intents Extension
  • AppIntentConfiguration: iOS17推出,不需要添加意图定义文件和Intents Extension,可以纯代码实现用户自定义配置的小组件.

自定义配置的小组件: 允许用户编辑小组件。比如日历组件,当你长按小组件时,可以选择编辑小组件按钮.

IMG_0254.pngIMG_0255.png

此外,默认的小组件周围存在白色间距,也叫做安全区域.在布局时,可以配置.contentMarginsDisabled()来禁用该白色区域. Pasted image 20241009105051.png

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

2.3 小组件中用到的数据结构

介绍一下 TimeLine Provider中 用到的 struct

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

2.3.2 TimelineEntry

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

public protocol TimelineEntry {
   var date: Date { get } //  必须实现
   var relevance: TimelineEntryRelevance? { get } // 可选的
  
}

2.3.3 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:永不刷新

3 widget尺寸

3.1 系统支持的尺寸

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

Pasted image 20241008104700.png 更多小组件尺寸相关参考: Widget Design

3.2 配置尺寸

通过为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]) // 小、中
   }
}

3.3 获取尺寸

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

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

4 widget 数据刷新机制

小组件的数据刷新分为两种: 小组件内的刷新机制 , 与App交互的刷新机制

小组件内的刷新机制 在小组件内的刷新中,widgetKit 框架提供了 TimelineProvider ,基于时间线触发刷新。简单来说是系统提供了一个定时器的触发机制.


    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        // 静态时间线,不会定期更新
        let entry = SimpleEntry(date: Date())
        let timeline = Timeline(entries: [entry], policy: .never)  // 不会自动更新
        completion(timeline)
    }

但是,系统调用下一次 getTimeLine 事件时间是不确定的,并不是严格按照我们写的时间.此外官方建议entry间隔时间至少5分钟,间隔时间太短需要创建很多entry,如果页面上元素也较多,会消耗大量内存,导致小组件整体页面显示不出来.

此外,在时间线getTimeline内,网络请求、开线程、定时器、收发通知(NotificationCenter)等iOS具备的能力基本上可以正常使用。 TimeLine 刷新策略:

TimelineReloadPolicy
atEnd当前Timeline里面的[entry]事件处理完成以后回再次执行 getTimeline方法
never永远不主动请求, 当前entry执行完毕以后 就结束了
after设定一个刷新时间,到点请求,可以设置定时触发

基于时间线触发刷新的限制:

  • 动态分配刷新限制次数:
    • 为节省系统资源以及减少电耗,系统会根据用户浏览小组件的频率和次数、小组件上次刷新时间等因素,为小组件动态分配刷新限制次数
  • 数量限制:
    • 一个小组件24小时可以获得40-70次刷新,时间线刷新策略的时间设置大概是15-60分钟。
  • 刷新时间限制:
    • getTimeLinecompletion必须在28s以内完成,否则刷新超时.小组件的时间线刷新是串行的,下一次getTimeLine一定会在上一次completion之后,如果getTimeLinecompletion超时,当次刷新会被丢弃,等待下一次刷新
  • 多个组件的刷新限制次数是独立的:
    • 如果添加了多个小组件,虽然这些小组件同属一个进程,但刷新限制次数是独立的。
  • 不会记入刷新次数的情况:
    • 主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 }
   if let widget = widgets.first(
       where: { widget in
           let intent = widget.configuration as? SelectCharacterIntent
           return intent?.character == characterThatReceivedHealingPotion
       }
   ) {
       WidgetCenter.shared.reloadTimelines(ofKind: widget.kind)
   }
}

5 小组件与 App 通讯

小组件和主App是相互独立的进程,内存中的数据和方法不能直接访问。两个进程间的数据依赖于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")

5.1 小组件向主App通信

widgetURL和Link

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

  • widgetUrl 是针对整个小组件 点击小组件响应(如果有Link 就响应Link)
  • LinK 给元素添加点击事件, Link 对 systemSmall样式的组件不生效(systemSmall 样式的小组件只响应widgetUrl)

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

  • SwiftUI:onOpenURL(perform:)
  • SceneDelegate:scene(_:, openURLContexts:)
  • AppDelegate: application(_:open:options:)

CFNotificationCenter

CFNotificationCenter是进程间通信的一种方式,但必须使用CFNotificationCenterGetDarwinNotifyCenterCFNotificationCenterGetDarwinNotifyCenter不能传递数据,传递数据只能通过AppGroup.

5.2 主 App 向小组件通信

可参考 4 中的与App 交互的刷新机制,使用 AppGroupWidgetCenter.shared.reloadTimelines,来实现主 App 向小组件通信.

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

iOS17以下,点击小组件,只能打开主App,在主App完成交互.

iOS 17及以上,可以直接在小组件上完成点击交互。使用AppIntents框架,可以为SwiftUI的ButtonToggle 这两种控件扩展初始化方法,通过点击控件触发AppIntent的执行实现小组件上直接交互。

需要注意,可交互的组件仅支持 Button 和 Toggle 组件,其他控件都不起作用。

⚠️注意

现象: 如果删除小组件的代码, 更新完 app 时,会观察到老的widget 不会删除,而是变成白色背景.

1. 是否可以 programmable 移除小组件

总结:

  1. 修改widget UI 时,更新完 APP 后,widget 会更新成新的 UI
  2. 删除 target 时,老的widget 不会删除,而是变成白色背景.
  3. .supportedFamilies([])时,apple 会保留原来的 widget,但点击没有反应

2.supportedFamilies 支持的小组件变化(非空)

  1.  .supportedFamilies([.systemMedium,.systemSmall]) -->  .supportedFamilies([.systemSmall]) 系统会自动删除不支持的小组件