Apps extensions 应用扩展

在 iOS 开发的早期,构建应用程序很简单,构建的所有内容都构建在一个编译的二进制文件中,所有代码和功能包含在一个地方。如今,情况已不再如此,因为我们不仅发布一个简单的应用程序,还经常发布许多单独的组件——也许是 iMessage 应用程序,也许是自定义键盘,也许是连接到 Safari 的操作扩展,或者可能是连接到 Siri 的东西。

Apple 将小组件称为应用程序扩展(像是在扩展一个应用程序),但实际上小组件本身就是完全独立的应用程序。虽然用户不会这样看,用户只是认为在安装 MyAwesomeApp 时,应用恰好出现在几个地方,但在幕后,实际上是安装了几个协同工作的小应用程序。

所有这些都很重要,因为它使构建项目的方式变得复杂。在创建小组件时,实际上是在构建项目中一个完全独立的部分 target,这意味着:


配置单个小组件

1. 配置跨目标共享代码

创建 widget target

<aside> 💡 如果这是您第一次使用方案,请仔细阅读以下内容以避免混淆! 我们现在在多个独立的 targets 之间工作,不同的程序必须单独运行才能发挥作用。Xcode 会根据 schemes 决定应该运行哪一个,当前激活的方案就是下次按 Cmd+R 时,会运行的程序。这里 Xcode 询问是否激活新的小组件 scheme ,这很有帮助。这意味着我们可以继续构建小部件,并且之后每次按 Cmd+R 时,都会部署并运行新的小部件代码。因此,请立即按激活。 将来,如果希望返回到以前的方案,可以通过转到 “Product” 菜单并选择 “Scheme > UltimatePortfolio” 来执行此操作。

</aside>

指定需要编译的文件

PortfolioWidget 文件夹看起来像一个迷你应用程序:它有两个包含代码的 Swift 文件,一个资产目录,还有一个 Info.plist 文件。接下来我们要告诉 Xcode 哪些其他 Swift 文件应该编译到小部件中。小组件是单独的二进制文件,默认情况下它只有两个 Swift 文件的代码,是不够的。

当然,我们可以将所有的原始 Swift 文件编译到新的小部件目标中,但这不是一个好主意,因为:

因此,不能复制所有内容,而要有选择性地告诉 Xcode “原始目标中的哪些 Swift 文件应该同时存在于小部件 target 中” 。这可能会带来一些混乱,可以先从简单的部分开始。例如我们需要在小部件中读取 CoreData 数据,那么直观能想到:

将原有代码解藕

但这是最佳方法吗?不是。首先,你会从 SwiftLint 那里得到一些警告,因为 Apple 的示例代码有点邋遢;此外我们还将 Award.swift、Bundle-Decodable.swift… 这些实际上在小部件中都不需要的文件添加到了 widget bundle 中以编译代码。所以其实应该做得更好一点:

这是一个小小的改动,但现在我们的小部件可以干净地编译了。它拥有构建所需的所有代码,而不必引入其他外部文件。这种方法并不总是可行的,但只要你能做到这一点,你就应该这样做——它使你的代码更精简,减少依赖关系,并避免将来出现意外问题。


2. 配置跨目标共享数据

此时小组件代码已可以编译。不过遇到一个更大的问题:仅仅共享一些代码和核心数据模型,并不足以让主应用程序和小部件共享活跃数据 active data。该模型本身描述了数据结构,但不存储用户创建的实时数据(例如用户添加的所有标签和问题)。

使用 App Group

解决此问题需要几个步骤,其主要目标是确保应用程序使用的实时数据库,放置在主应用程序和小部件都可以访问的共享位置。

在完整应用程序包的不同部分之间共享数据,主要是使用 app groups 完成的。app groups 实际上是一个更大的系统,它允许同一公司的不同 App 之间共享数据。目前我们只利用它在主应用程序和小部件之间,共享 Core Data database。要跨目标共享数据请按照以下步骤操作:

将数据保存到共享数据区域

为了真正实现应用的不同部分都可以随时从相同的共享数据中读取和写入,我们需要告诉 Core Data 将它的实时数据保存到应用组的共享数据区域中,因此需要修改 DataController 类。

// 在 DataController 初始化方法中找到以下代码
if inMemory {
    container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
}

// 如果我们使用内存数据库,这段代码能够指定 Core Data 存储其信息的位置
// 现在如果我们要使用真实的数据库,我们会将核心数据重定向到 app group container
// container 是 Apple 对共享数据区域的名称,我们通过 FileManager 方法来找到它的确切路径,因此将上面的代码调整为:
if inMemory {
    container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
} else {
    let groupID = "group.com.hackingwithswift.upa"
    if let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) {
        container.persistentStoreDescriptions.first?.url = url.appending(path: "Main.sqlite")
    }
}

另外还有一处需要更改:现在我们指示 Core Data 在两个地方共享其信息,有必要将 “enabling history tracking 启用历史记录跟踪” 移至调用 loadPersistentStores() 之前,否则 Core Data 和小部件协同工作的方式可能会导致问题。

// 找到这行代码,并将其移动到调用 NotificationCenter.default.addObserver() 之前
container.persistentStoreDescriptions.first?.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

自动更新小组件数据

最后一步是告诉 DataController 它应该在数据更改时自动刷新小部件。这将确保主应用程序和小部件在用户数据发生变化时保持同步。

// 首先,在 DataController.swift 的顶部添加一个新的导入
import WidgetKit

// 然后将以下代码添加到 save() 方法中,在代码 container.viewContext.save() 的后面
WidgetCenter.shared.reloadAllTimelines()

完成强制自动刷新所有小组件,所有设置也就完成了。如果在测试过程中小组件无法读取数据,注意检查数据控制器是否用的是 preview 测试单例。如果是,要改回用正式的单例,数据才不会储存到内存,改为储存到持久化目录,这样小组件才能正常读取数据。

@StateObject var dataController = DataController.shared
// @StateObject var dataController = DataController.preview

3. 配置小组件模版

苹果的 widget template(小部件模板)提供了很多代码,实际上这是一个完整小部件的最小内容。具体分以下几块:

Provider 结构体

第一个结构体叫做 Provider ,它符合 TimelineProvider 协议。它主要决定了如何获取小部件的数据。

// 在 Provider 结构体代码中,实现了这样的方法:
func placeholder(in context: Context) -> SimpleEntry {
	...
}

// 这代表 Swift 知道该 placeholder() 方法将返回任何 Entry ,我们明确告诉它返回的是 SimpleEntry 
// 因此 Swift 可以将这两条信息放在一起,以了解我们的 provider 的 Entry 类型实际上是 . SimpleEntry
// 因此,当它作为后面 PortfolioWidgetEntryView 属性时:
var entry: Provider.Entry

// 它实际上与以下代码的含义相同
var entry: SimpleEntry

// 优点是,如果想让视图使用不同类型的数据,如果新旧数据类型足够相似,也许只更改内部 Provider 的方法即可,而不改变 SwiftUI 视图

SimpleEntry 结构体

第二个结构体叫做 SimpleEntry ,它符合 TimelineEntry 协议。它主要决定了如何存储小部件的数据。

事实上 SimpleEntry  和  PortfolioWidgetEntryView  是相互关联的,尽管不是很明显。

// 您可以看到 PortfolioWidgetEntryView 具有以下属性:
var entry: Provider.Entry

// 这意味着我们的 SwiftUI 视图需要得到一个要显示的条目,该条目应包含显示自身所需的所有信息
// 该条目应该具有 Provider.Entry 类型,起初有点令人困惑,但实际上它与 SimpleEntry 是相同的类型

// 如果您好奇这是如何实现的,请右键单击 TimelineProvider 协议,然后选择 Jump to Definition。在里面,你会看到以下代码:
associatedtype Entry : TimelineEntry

// 这意味着需要为协议提供某种符合 TimelineEntry 协议的数据类型。我们不知道会是哪种类型,所以协议给它一个占位符名称 Entry 。
// 该协议希望我们决定该 Entry 类型应该是什么。如果再往下滚动一点 TimelineProvider ,你会看到已经添加到模板代码中的方法,包括这个:
func placeholder(in context: Self.Context) -> Self.Entry

// 也就是说,该 placeholder() 方法将返回一个 Self.Entry – 该方法将返回用于填充 Entry 空白的任何类型

WidgetEntryView 结构体

第三个结构体叫做 xxxxWidgetEntryView ,它符合 SwiftUI View 的协议。它主要决定小部件的数据的呈现方式。xxxxWidgetEntryView 结构体最终是要包装在下面这个符合 Widget 协议的结构体中 —— XxxxWidget

XxxxWidget 结构体

第四个结构体叫做 PortfolioWidget ,它符合 Widget 协议。它主要决定了小部件应该如何配置。基本代码如下:

// 结构体的名字也应该是小组件的名称
struct RecentNotesWidget: Widget {

    // 1.配置小组件名称
    let kind: String = "RecentNotesWidget"

    var body: some WidgetConfiguration {
		    // 这里关联上名称、Provider、EntryView...
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                RecentNotesWidgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                RecentNotesWidgetEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
        
        // 2.配置小组件的标题 & 展示的描述
        .configurationDisplayName("Rencent Ideas")
        .description("Show your rencent ideas")
        
        // 3.配置小组件支持的尺寸
        .supportedFamilies([.systemSmall, .systemMedium])
        
    }

}

#Preview 宏

#Preview 宏,它主要决定了如何在 Xcode 中预览我们的小部件。

// 预览时可以设置尺寸
#Preview(as: .systemMedium) {
    RecentProjectsWidget()
} timeline: {
    SimpleEntry(date: .now, recentNotes: [.example])
    SimpleEntry(date: .now, recentNotes: [.example])
}