App extensions 应用扩展

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

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


配置单个小组件

1. 创建 Widget Target

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

</aside>

2. 配置跨目标共享代码

往小组件添加编译文件:

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

添加方法是选择需要的文件,打开右侧文件检查器面板,然后选中 “Target Membership” 中的 “PortfolioWidgetExtension” 框。这代表这些文件既是为原始应用程序目标构建的,现在也是为小组件构建的。

为什么不添加所有文件:

当然,我们可以将所有的原始 Swift 文件编译到新的小组件 Target 中,但这不是好主意,有以下一些原因

如何选择添加哪些文件:

既然要有选择性地告诉 Xcode “原始目标中哪些文件应该同时存在于小部件 target 中” ,那从哪里开始呢?主要思路如下:

将原有代码解藕:

但前面的方法是最佳的吗?不是。首先,你会从 SwiftLint 那里得到一些警告,因为 Apple 的示例代码有点邋遢;此外我们还将一些实际上在小部件中都不需要的文件都添加到了 widget bundle 中以编译代码。这时我们可以尝试原代码解藕:

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


3. 配置跨目标共享数据

<aside> 💡

做这步之前,先检查小组件 Target 和主 Target 的团队签名证书是否一致,如果不一致它也会报错。

</aside>

App Group 共享 Core Data

此时小组件已可以编译,不过遇到一个更大的问题:仅仅共享一些代码和 Core Data 模型,并不足以让主应用和小组件共享活跃数据 active data。因为 Core Data 数据模型本身只描述数据结构,但不存储用户创建的实时数据。解决此问题需要几个步骤,其主要目标是确保 “应用程序使用的实时数据库”,放置在主应用程序和小部件都可以访问的共享位置。

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

App Group 共享 UserDefaults

默认情况下,主应用和小组件运行在各自独立的沙盒(sandbox)中。这意味着:UserDefaults.standard 是隔离的。主应用的 UserDefaults.standard 和小组件的 UserDefaults.standard 是两个完全不同的实例,它们分别读写各自容器(container)内的偏好设置文件。因此,小组件无法直接通过 UserDefaults.standard 访问到主应用设置的值。

解决这个共享问题的方法也是通过 App Groups,首先还是确保为主应用 & 小组件 Target 添加完全相同的 App Group。

步骤 1:修改主应用的 UserDefaults 代码

现在不能再使用 UserDefaults.standard 来进行共享数据的读写了。你需要使用 UserDefaults(suiteName:)构造器,并传入你的 App Group 标识符。在主应用中写入偏好设置:

// 定义 App Group 标识符(建议统一定义,避免手误),替换成你实际的 App Group ID
let appGroupID = "group.com.yourcompany.yourapp.shareddata" 

// 获取共享的 UserDefaults 实例
if let sharedDefaults = UserDefaults(suiteName: appGroupID) {
    // 写入你的偏好设置
    sharedDefaults.set(true, forKey: "isPhilokidsPremium") // 使用你定义的 UserDefaultKeys
    // sharedDefaults.synchronize() // 在旧版 iOS 中有时需要,现代 iOS 中通常会自动保存,但关键时刻调用一下也无妨
    print("主应用:isPhilokidsPremium 已写入共享 UserDefaults")
} else {
    print("主应用:无法访问共享 UserDefaults!检查 App Group ID 是否正确以及 entitlements 是否配置。")
}

步骤 2:修改小组件的 UserDefaults 代码

例如在小组件的代码中读取偏好设置 (例如在 TimelineProvider 的方法中)

// 定义 App Group 标识符(与主应用中定义的完全一致)
let appGroupID = "group.com.yourcompany.yourapp.shareddata" // 替换成你实际的 App Group ID

struct YourWidgetTimelineProvider: TimelineProvider {

    // 读取共享偏好设置的辅助方法
    func readPremiumStatus() -> Bool {
        if let sharedDefaults = UserDefaults(suiteName: appGroupID) {
            let isPremium = sharedDefaults.bool(forKey: "isPhilokidsPremium") // 使用你定义的 UserDefaultKeys
            print("小组件:读取到 isPhilokidsPremium 状态为 \\(isPremium)")
            return isPremium
        } else {
            print("小组件:无法访问共享 UserDefaults!检查 App Group ID 是否正确以及 entitlements 是否配置。")
            return false // 或者返回一个合适的默认值
        }
    }
    
}

最后重要提示:

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

为了真正实现应用的不同部分都可以随时从相同的共享数据中读取和写入,我们需要告诉 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()