在 iOS 开发的早期,构建应用程序很简单,构建的所有内容都构建在一个编译的二进制文件中,所有代码和功能包含在一个地方。如今,情况已不再如此,因为我们不仅发布一个简单的应用程序,还经常发布许多单独的组件——也许是 iMessage 应用程序,也许是自定义键盘,也许是连接到 Safari 的操作扩展,或者可能是连接到 Siri 的东西。
Apple 将小组件称为应用程序扩展(像是在扩展一个应用程序),但实际上小组件本身就是完全独立的应用程序。虽然用户不会这样看,用户只是认为在安装 MyAwesomeApp 时,应用恰好出现在几个地方,但在幕后,实际上是安装了几个协同工作的小应用程序。
所有这些都很重要,因为它使构建项目的方式变得复杂。在创建小组件时,实际上是在构建项目中一个完全独立的部分 target,这意味着:
host
File
菜单并选择 New > Target
,然后选择 Widget Extension
Embed in Application
”设置,这是将两个目标绑定到一个应用程序的关键Include Live Activity
” 和 “Include Configuration App
” 两项<aside> 💡 如果这是您第一次使用方案,请仔细阅读以下内容以避免混淆! 我们现在在多个独立的 targets 之间工作,不同的程序必须单独运行才能发挥作用。Xcode 会根据 schemes 决定应该运行哪一个,当前激活的方案就是下次按 Cmd+R 时,会运行的程序。这里 Xcode 询问是否激活新的小组件 scheme ,这很有帮助。这意味着我们可以继续构建小部件,并且之后每次按 Cmd+R 时,都会部署并运行新的小部件代码。因此,请立即按激活。 将来,如果希望返回到以前的方案,可以通过转到 “Product” 菜单并选择 “Scheme > UltimatePortfolio” 来执行此操作。
</aside>
PortfolioWidget 文件夹看起来像一个迷你应用程序:它有两个包含代码的 Swift 文件,一个资产目录,还有一个 Info.plist 文件。接下来我们要告诉 Xcode 哪些其他 Swift 文件应该编译到小部件中。小组件是单独的二进制文件,默认情况下它只有两个 Swift 文件的代码,是不够的。
当然,我们可以将所有的原始 Swift 文件编译到新的小部件目标中,但这不是一个好主意,因为:
import UIKit
的东西,共享文件可能会很棘手(因为这在 Mac 上不可用)因此,不能复制所有内容,而要有选择性地告诉 Xcode “原始目标中的哪些 Swift 文件应该同时存在于小部件 target 中” 。这可能会带来一些混乱,可以先从简单的部分开始。例如我们需要在小部件中读取 CoreData 数据,那么直观能想到:
DataController
类Target Membership
” 中的 “PortfolioWidgetExtension
” 框。这代表这些文件既是为原始应用程序目标构建的,现在也是为小组件构建的Filter
类型。这准确地揭示了 Xcode 内部的机制,即:它只编译 target 组中的文件,以及我们主动添加到 target 中的所有文件。而其他代码(如 Filter.swift 等)都会被忽略,这就是错误的原因,因为 DataController
中引用了小部件中不存在的代码Filter.swift
并将其添加到小部件目标中。但是当这样做时,会得到了另一个 Award
找不到的错误,因此 Award.swift
也需要添加到小部件目标中。这反过来又会触发第三个错误,告诉我们需要包含 Bundle-Decodable.swift
,然后 Xcode 又提示还需要包含 DataController-StoreKit.swift
,然后不断添加...... 最后项目可以构建了但这是最佳方法吗?不是。首先,你会从 SwiftLint 那里得到一些警告,因为 Apple 的示例代码有点邋遢;此外我们还将 Award.swift、Bundle-Decodable.swift… 这些实际上在小部件中都不需要的文件添加到了 widget bundle 中以编译代码。所以其实应该做得更好一点:
首先从小部件目标中删除 Award.swift 和 Bundle-Decodable.swift 文件,这将使项目报错,因为 DataController
引用了 Award
结构
为了解决这个问题,我们把 hasEarned(award:)
方法从主 DataController
类中剥离出来,把它放在一个单独的文件中的扩展中。这样,我们仍然保留了原始功能,但不再在主类中引用 Award
,以便该文件可以安全地被引入小部件包中。
// 因此,创建一个名为 DataController-Awards.swift 的新 Swift 文件,并为其提供以下代码:
extension DataController {
// 现在,从原始 DataController 类中剪切出整个 hasEarned(award:) 方法,并将其粘贴到扩展中
}
这是一个小小的改动,但现在我们的小部件可以干净地编译了。它拥有构建所需的所有代码,而不必引入其他外部文件。这种方法并不总是可行的,但只要你能做到这一点,你就应该这样做——它使你的代码更精简,减少依赖关系,并避免将来出现意外问题。
此时小组件代码已可以编译。不过遇到一个更大的问题:仅仅共享一些代码和核心数据模型,并不足以让主应用程序和小部件共享活跃数据 active data。该模型本身描述了数据结构,但不存储用户创建的实时数据(例如用户添加的所有标签和问题)。
解决此问题需要几个步骤,其主要目标是确保应用程序使用的实时数据库,放置在主应用程序和小部件都可以访问的共享位置。
在完整应用程序包的不同部分之间共享数据,主要是使用 app groups
完成的。app groups
实际上是一个更大的系统,它允许同一公司的不同 App 之间共享数据。目前我们只利用它在主应用程序和小部件之间,共享 Core Data database
。要跨目标共享数据请按照以下步骤操作:
App Groups
功能。可能会看到两个选项,一个用于 macOS,一个用于所有其他平台,但只要添加一个,它们都会被添加group.
”开头,后面您可以使用任何喜欢的反向域名标识符。我们只将此标识符用于一个应用程序,因此最好使用与您的 bundle ID 相同或相似的值,例如 “group.com.hackingwithswift.upa
”为了真正实现应用的不同部分都可以随时从相同的共享数据中读取和写入,我们需要告诉 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
苹果的 widget template(小部件模板)提供了很多代码,实际上这是一个完整小部件的最小内容。具体分以下几块:
第一个结构体叫做 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
,它符合 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 空白的任何类型
第三个结构体叫做 xxxxWidgetEntryView
,它符合 SwiftUI View
的协议。它主要决定小部件的数据的呈现方式。xxxxWidgetEntryView
结构体最终是要包装在下面这个符合 Widget
协议的结构体中 —— 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])
}
}
配置小组件的名称:这是通过 kind
属性设置的
// 该字符串代表小组件 Widget 的名称
let kind: String = "SimplePortfolioWidget"
配置标题和描述:这是通过 configurationDisplayName()
和 description()
修饰符设置的
// 这些是应用名称的补充,它们会显示在“添加小组件”的界面中,顶部以粗体黑色文本显示名称,下方以浅灰色文本显示描述
// 在“添加小组件”的界面中,用户可以看到应用名称和图标,因此不需要在这两个地方再重复应用名称,可以写一些生动的文案
.configurationDisplayName("Up next…")
.description("Your #1 top-priority item.")
// 更好的是,这两个修饰符都自动接受本地化的字符串键。因此可以向 Localizable.strings 文档里添加3个新值来本地化小部件
/* WIDGETS */
"Up next…" = "Up next…";
"Nothing!" = "Nothing!";
"Your #1 top-priority item." = "Your #1 top-priority item.";
// 并将这些添加到匈牙利语文件中:
/* WIDGETS */
"Up next…" = "Következő…";
"Nothing!" = "Semmi!";
"Your #1 top-priority item." = "Az #1 elsőbbségi tétel.";
<aside>
💡 现在正在为小部件使用本地化的字符串,因此请确保将 Localizable.strings
添加到 PortfolioWidgetExtension 目标中,iOS 将为我们处理剩下的工作。
</aside>
配置小组件支持尺寸:有三种支持尺寸:2x2 square
、4x2 rectangle
和 mammoth-sized 4x4 square
// 在 PortfolioWidget 结构体中,将此内容添加到现有修饰符后面:
.supportedFamilies([.systemSmall])
// 数组里只有一项,说明小部件限制为只支持一种尺寸
// 如果没有设置该修饰符,则说明小组件可以支持所有三种尺寸
// 如果需要支持多种尺寸,在数组中添加 .systemMedium 和 .systemLarge
#Preview
宏,它主要决定了如何在 Xcode 中预览我们的小部件。
// 预览时可以设置尺寸
#Preview(as: .systemMedium) {
RecentProjectsWidget()
} timeline: {
SimpleEntry(date: .now, recentNotes: [.example])
SimpleEntry(date: .now, recentNotes: [.example])
}