SwiftData 如何在 Widgets 和 App 的界面之间同步数据变化?

概述

从 iOS 17(watchOS 11)开始,苹果推出了可交互小组件(Interactive Widgets),使用它我们终于能在 Widgets 中添加按钮或 Toggle 来直接驱动数据的变化了。

但是被 Widgets 修改的数据如何及时的同步到对应 App 的界面中呢?

在本篇博文中,您将学到如下内容:

  1. 打造 Widgets 和 App 中共享的 ModelContainer 容器
  2. 在 Widget 中改变 SwiftData 数据
  3. 将改变反映到 App 的界面中

相信学完本篇之后,小伙伴们对于 SwiftData 数据变化在 Widgets 和 App 界面间的同步处理一定会了然于胸!

那还等什么呢?Let's go!!!;)


1. 打造 Widgets 和 App 中共享的 ModelContainer 容器

首先,为了能在 Widgets 和 App 里共享同一个持久存储数据库,我们必须采用某种机制让它们"航行在同一片海域"。

对于 Apple 平台来说,最简单的方法是使用 CoreData 或 SwiftData 的组件共享机制。比如,App Groups 或 iCloud。这里,因为我们不需要跨设备同步数据,所以采用 SwiftData + App Groups 的搭档方式。

第一步,我们需要在 Xcode 中为 Target 增加 App Groups Capability,我们还要选择一个以 group. 开头的唯一 Groups ID 名称:

接着,创建一个新的 ModelContainer+ext.swift 源代码文件:

swift 复制代码
import SwiftData

enum Common {
    static let appGroupID = "group.YourAppName.YourName.com"
}

extension ModelContainer {
    // 所有需要加入 ModelContainer 容器中的托管数据类型
    private static let schema = Schema([
        AppModel.self,
        TemptingRecord.self,
        InternalStorageClock.self,
        Settings.self,
    ])
        
    static var used: ModelContainer = {
        ProcessInfo.processInfo.isRunningInPreviews ? .preview : .shared
    }()
    
    static var shared: ModelContainer = {
        
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, groupContainer: .identifier(Common.appGroupID))
    
        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    static var preview: ModelContainer = {
        
        let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)

        do {
            return try ModelContainer(for: schema, configurations: [modelConfiguration])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()
}

从上面代码中可以看到,我们使用相同的 App Groups ID 创建了模型容器对象(ModelContainer)。


注意,对于预览中调试使用的 ModelContainer.preview 容器不需要 App Groups 的共享支持,因为它永远只存在于内存里。


我们还需最后一步:将 ModelContainer+ext.swift 同时加入到 Watch Widgets 和 Watch App 的编译目标中去

因为我的 SwiftData 模型容器同时支持 watchOS 与 iOS 上 App 和 Widgets 的共享数据,所以上面除了 Watch App 和 Watch Widgets 目标以外,我还十分"银杏化"的将 ModelContainer+ext.swift 加入到 iOS App 和 iOS Widgets 中去了。

现在 ModelContainer 已准备就绪,接下来我们一起看看如何在 Widgets 中操作它的数据吧。

2. 在 Widget 中改变 SwiftData 数据

如上所述,从 iOS 17(watchOS 11)开始我们首次可以在 iPhone 和 Apple Watch 小组件中提供用户互动的支持了。


关于更多交互式 Widgets 和 Live Activities 的介绍,请小伙伴们移步如下链接观赏进一步内容:


因为小组件和 App 分别在不同进程空间中运行,所以实际是由"系统"而不是 我们的 App 支持着 Widgets 的运行。于是乎,要想将 Widgets 中的功能和数据让外部系统"心知肚明",我们需要使用 Intents。

App Intents 是一种把我们 App 中的内容暴露给外部系统组件(discoverable)的机制,比如:Spotlight, Widgets 等等。


因为篇幅有限,更多关于 App Intents 的介绍请大家移步苹果开发官网观赏:


首先,我们新建一个 Intent 对象用来负责 Widgets 中的按钮点击操作,它需要遵守 AppIntent 协议:

swift 复制代码
struct IncValueIntent: AppIntent {
    static var title: LocalizedStringResource = "Inc Value"
    static var description: IntentDescription? = "将值 +1"
    
    func perform() async throws -> some IntentResult {
        // 实际逻辑待实现...
        .result()
    }
}

然后到我们的 Widget 视图界面代码中去,为 IncValueIntent 添加一个对应的按钮:

swift 复制代码
struct IncValueWidgetEntryView : View {
    var entry: Provider.Entry
    
    var body: some View {
        HStack(spacing: 10) {
            VStack {
                Text("当前 Value")
                    .font(.caption2)
                    .foregroundStyle(.gray)
                Text("\(Settings.shared.testVal)")
                    .font(.headline.weight(.black))
            }
            
            if #available(watchOS 11.0, *) {
                Button(intent: IncValueIntent()) {
                    Label("Inc", systemImage: "plus.square")
                }
            }
        }
    }
}

现在 Widget 的界面如下图所示:

当用户点击 "Inc" 按钮之后,系统会调用我们 IncValueIntent 实例中的 perform 方法,小伙伴们可以在该方法中充分施展天马行空般的想象力,对持久数据库进行一切必要的修改。

回到之前 IncValueIntent 结构的代码,补全 perform 方法内容:

swift 复制代码
struct IncValueIntent: AppIntent {
    static var title: LocalizedStringResource = "Inc Value"
    static var description: IntentDescription? = "将值 +1"
    
    func perform() async throws -> some IntentResult {
    	// 取得托管数据库中 Setting 类型的共享实例
        let settings = await Settings.shared
        // 将其 testVal 属性的值加 1
        settings.testVal += 1
        // 显式将改变写回到数据库中去
        try! settings.modelContext?.save()
        
        return .result()
    }
}

到目前为止,我们已经可以在 Widget 直接更改数据库中 Settings 托管对象的 testVal 值了,但假若此时切换回 Watch App,SwiftUI 可以正确将修改后的值反映到界面上去么?

答案是:并不能!这是怎么回事呢?

3. 将改变反映到 App 的界面中

尽管现在我们的 Watch App 与 Widgets 共享同一个持久存储,但是默认情况下它们相互的改变并不会直接被彼此感知到。

这是因为:我们在 Widgets 中对底层持久存储的修改,Watch App 内存中的 ModelContext 对此是完全一无所知的!

为了能够让 Watch App 可以"察觉到" Widgets 对 Model Container 所做的修改,我们必须在合适的时机手动更新内存中 Model Context 对象,让它能够反映出底层变化后的新内容。

目前,我们 Watch App 中 SettingsView 视图对应的代码是这样子的:

swift 复制代码
struct SettingsView: View {
    @State var settings = Settings.shared
    @Environment(\.modelContext) var modelContext
    
    var body: some View {
        Form {
            LabeledContent("时钟创建后立即运行") {
                Toggle("", isOn: $settings.afterCreationClockRunImmediately)
            }
                        
            LabeledContent(content: {
                VStack {
                    Text("\(settings.testVal)")
                        .font(.title2.weight(.black))
                        .contentTransition(.numericText())
                }
            }, label: {
                Text("Settings Val")
            })
        }
        .navigationTitle("设置")
    }
}

按照之前的讨论,此时位于内存中的 Settings 托管对象是绝对不会感知到底层数据变化的。所以,我们必须在 Watch App 进入前台时创建一个新的 Settings 对象来"映射" SwiftData 持久存储中改变后的内容:

swift 复制代码
struct SettingsView: View {
    
    @State var settings = Settings.shared
    @Environment(\.modelContext) var modelContext
    @Environment(\.scenePhase) var scenePhase
    @State var testVal = 0
    
    var body: some View {
        Form {
            LabeledContent("时钟创建后立即运行") {
                Toggle("", isOn: $settings.afterCreationClockRunImmediately)
            }
                        
            LabeledContent(content: {
                VStack {
                    Text("\(testVal)")
                        .font(.title2.weight(.black))
                        .contentTransition(.numericText())
                }
            }, label: {
                Text("Settings Val")
            })
        }
        .navigationTitle("设置")
        .task {
            testVal = settings.testVal
        }
        .onChange(of: scenePhase) {_,new in
            if new == .active {
            	// 根据 Model Context 创建一个新的 Settings 共享实例
                let tmp = try! Settings.getShared(modelContext)
                withAnimation(.bouncy) {
                    testVal = tmp.testVal
                }
            }
        }
    }
}

上面的代码十分简洁明了:

  1. 我们在 SettingsView 处在活动(active)状态时,使用 Settings.getShared() 方法重新创建了一个 Settings 实例对象,并用其最新的 testVal 值更新了界面中对应的状态;
  2. 我们使用 contentTransition 视图修改器让 testVal 更改动画更加自然和灵动;

编译运行,可以看到我们已然得偿所愿了:

至此,无论 Widgets 中的数据如何变幻莫测,我们都可以让 App 的界面从容面对、泰然处之了!棒棒哒!💯

总结

在本篇博文中,我们讨论了如何用 SwiftUI 妥善处理 Widget 和 App 界面之间的 SwiftData 数据同步,我们还顺面介绍了 iOS 17 和 watchOS 11 中最新可交互小组件的实现机制。

感谢观赏,再会啦!8-)

相关推荐
HarderCoder8 小时前
ByAI:iOS 生命周期:AppDelegate 与 SceneDelegate 中的 `willEnterForeground` 方法解析
swift
YungFan8 小时前
SwiftUI-Preference
swiftui·swift
HarderCoder8 小时前
ByAI:使用DRY原则编写干净可复用的Swift代码
swift
season_zhu10 小时前
Swift:优雅又强大的语法糖——Then库
ios·架构·swift
东坡肘子10 小时前
Swift 新设计、新案例、新体验 | 肘子的 Swift 周报 #087
swiftui·swift·wwdc
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(五)
swiftui·swift·apple watch
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(四)
数据库·swiftui·apple watch
MaoJiu2 天前
Flutter造轮子系列:flutter_permission_kit
flutter·swiftui