fatbobman / blogComments

1 stars 0 forks source link

SwiftData 实战:用现代方法构建 SwiftUI 应用 #226

Open fatbobman opened 5 months ago

fatbobman commented 5 months ago

SwiftData 实战:用现代方法构建 SwiftUI 应用

在之前的文章“SwiftData 中的并发编程”中,我们深入探讨了 SwiftData 提出的创新并发编程模式,包括它的原理、核心操作及相关的注意事项。这种优雅的编程解决方案赢得了不少赞誉。然而,随着更多开发者在实际的 SwiftUI 应用中尝试使用 SwiftData,他们遇到了一些挑战:尤其在启用 Swift 的严格并发检查后,发现 SwiftData 基于 Actor 的并发模型与传统的应用构建方法很难融合。本文将采用类似教程的方式阐述如何将 SwiftData 与现代编程理念相结合,顺畅地融入 SwiftUI 应用之中,同时提供策略来应对目前开发者面临的挑战。

Practical SwiftData: Building SwiftUI Applications with Modern Approaches

In the previous article Concurrent Programming in SwiftData, we delved into the innovative concurrent programming model proposed by SwiftData, including its principles, core operations, and related considerations. This elegant programming solution has earned considerable praise. However, as more developers attempt to use SwiftData in actual SwiftUI applications, they have encountered some challenges, especially after enabling Swift's strict concurrency checks. They found that SwiftData's actor-based concurrency model is difficult to integrate with traditional application construction methods. This article will explain, in a tutorial-like manner, how to integrate SwiftData with modern programming concepts smoothly into SwiftUI applications and provide strategies to address the current challenges faced by developers.

CarberryChai commented 5 months ago

很奇怪啊,跟着你的教程写代码,点击更新是直接删除,跟点删除效果一模一样

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.createDataHandler) private var createDataHandler
    @Query(sort: \Item.timestamp, animation: .snappy) private var items: [Item]
    var body: some View {
        NavigationStack {
            List {
                ForEach(items) { item in
                    VStack {
                        Text("\(item.timestamp.timeIntervalSince1970)")
                        HStack {
                            Button("update item") {
                                let createDataHandler = createDataHandler
                                let id = item.id
                                Task.detached {
                                    if let dataHandler = await createDataHandler() {
                                        try await dataHandler.updateItem(id: id, timestamp: .now)
                                    }
                                }
                            }
                            Spacer()
                            Button("delete item") {
                                let createDataHandler = createDataHandler
                                let id = item.id
                                Task.detached {
                                    if let dataHandler = await createDataHandler() {
                                        try await dataHandler.deleteItem(id: id)
                                    }
                                }
                            }
                        }
                        .padding()
                    }
                }
            }
            .toolbar(content: {
                Button(action: addNewItem, label: {
                    Image(systemName: "plus")
                        .imageScale(.large)
                })
            })
        }
    }

    @MainActor
    private func addNewItem() {
        let createDataHandler = createDataHandler

        Task.detached {
            if let dataHandler = await createDataHandler() {
                try await dataHandler.newItem(timestamp: .now)
            }
        }
    }
}
fatbobman commented 5 months ago

@CarberryChai 这部分没有问题,是不是 DataHander 里面写错了

CarberryChai commented 5 months ago

@fatbobman @CarberryChai 这部分没有问题,是不是 DataHander 里面写错了

应该没有写错,真奇怪。

@ModelActor
public final actor DataHandler {
    @discardableResult
    public func newItem(timestamp: Date) throws -> PersistentIdentifier {
        let newItem = Item(timestamp: timestamp)
        modelContext.insert(newItem)
        try modelContext.save()
        return newItem.persistentModelID
    }

    public func updateItem(id: PersistentIdentifier, timestamp: Date) throws {
        guard let item = self[id, as: Item.self] else { return }
        item.timestamp = timestamp
        try modelContext.save()
    }

    public func deleteItem(id: PersistentIdentifier) throws {
        guard let item = self[id, as: Item.self] else { return }
        modelContext.delete(item)
        try modelContext.save()
    }
}
upswing-vibrant commented 5 months ago

Many thanks for your useful article. I have noticed that the example code crashes with Xcode 15.3. You can try out yourself by starting the app in iOS simulator and adding a few items, changing timestamps and then put the app in the background by pressing the home button and bring it up into foreground again. I also noticed that the update timestamp does not work in ContentView in preview.

Interestingly, if you load the example with Xcode 15.2 the compiler complains in line 27 "Button(action: addItem) {" with "Converting function value of type '@MainActor () -> ()' to '() -> Void' loses global actor 'MainActor". this does not happen in 15.3

fatbobman commented 5 months ago

@upswing-vibrant Thank you for your feedback. SwiftData has seen some changes in every version starting from 17.0. I had planned to write this article earlier, but there have always been certain issues with creating ModelActor instances on non-main threads. For example, in the current demo, on versions 17.0 and 17.2, clicking the add button does not result in new data appearing. We still need to wait for SwiftData to become more stable. However, I believe the operational logic mentioned in this article should be feasible. To make the demo more stable, you can replace the delete and update operations with createDataHandlerWithMainContext (a DataHandler built with the main context), which should stabilize it.

upswing-vibrant commented 5 months ago

if I use createDataHandlerWithMainContext (in ContentView and ItemView) instead of createDataHandler it also works under 17.2.

I am not sure if I fully understood the reason for createDataHandlerWithMainContext vs. createDataHandler. Is it only because of the update bug?

fatbobman commented 5 months ago

@upswing-vibrant It's a bug, so I've added the following section to the article.

image
upswing-vibrant commented 5 months ago

Awesome! This makes it much clearer. I really do hope Apple will further improve SwiftData as there are currently a lot of glitches&bugs.

wisepmlin commented 4 months ago

@discardableResult func newProduct(organization: Organization, name: String, strategicObjectives: String, slogan: String, subtype: String, type: String, valueProposition: String, orderIndex: Int64, productIcon: Data, skCalendarModel: SKCalendarModel) throws -> PersistentIdentifier { let product = Product(name: name, slogan: slogan, strategicObjectives: strategicObjectives, subtype: subtype, type: type, valueProposition: valueProposition, orderIndex: orderIndex, productIcon: productIcon_) organization.products?.append(product) let allDatesCount = skCalendarModel.getAllDay(start_times: Date().startOfCurrentYear(), end_times: Date().endOfCurrentYear(returnEndTime: true) + 1.days) let todayDateAtStart = Date().startOfCurrentYear() for index in 0..<allDatesCount { let date = SKDate(date: todayDateAtStart + index.days, index: Int64(index)) product.skdates?.append(date) } createProductFolder(newProduct: product) try modelContext.save() return product.persistentModelID }我的代码使用了DataHandler后,创建 356 条数据巨卡

fatbobman commented 4 months ago

@discardableResult func newProduct(organization: Organization, name: String, strategicObjectives: String, slogan: String, subtype: String, type: String, valueProposition: String, orderIndex: Int64, productIcon: Data, skCalendarModel: SKCalendarModel) throws -> PersistentIdentifier { let product = Product(name: name, slogan: slogan, strategicObjectives: strategicObjectives, subtype: subtype, type: type, valueProposition: valueProposition, orderIndex: orderIndex, productIcon: productIcon_) organization.products?.append(product) let allDatesCount = skCalendarModel.getAllDay(start_times: Date().startOfCurrentYear(), end_times: Date().endOfCurrentYear(returnEndTime: true) + 1.days) let todayDateAtStart = Date().startOfCurrentYear() for index in 0..<allDatesCount { let date = SKDate(date: todayDateAtStart + index.days, index: Int64(index)) product.skdates?.append(date) } createProductFolder(newProduct: product) try modelContext.save() return product.persistentModelID }我的代码使用了DataHandler后,创建 356 条数据巨卡

https://fatbobman.com/zh/posts/relationships-in-swiftdata-changes-and-considerations/

看效率的部分

LivenBom commented 3 months ago

大佬你好,请教一下,关于文章中说的:建议避免在 DataProvider 中持有一个长期共享的 DataHandler 实例。相反,在每个业务逻辑场景中独立创建新的实例或许是更好的选择。这个是否会导致并发问题,比如不同的View从Environment中获取的DataHandler不是同一个对象,那么有可能出现多个ModelContext同时对同一个托管对象修改的问题。当然这种可能性不高,我只是想知道发生这种情况的时候,是否会有问题,谢谢

fatbobman commented 3 months ago

@LivenBom 在 SwiftData 或 Core Data 中,每个托管对象都会对应数据上的一条记录。在同一个上下文中,数据库上的一条数据只能对应一个托管对象。但是,在不同的上下文中,可以有多个不同的托管对象( 分散在不同上下文中)对应同一条数据库上的数据。 我们是将构建 DataHandler 实例的方法注入到环境中,因此,通过环境,每次都会创建一个新的 DataHandler 实例。每个实例,每次都会创建一个新的上下文( 除掉使用 MainActor 的特殊场景)。因此,并不存在多个上下文对应一个托管对象的问题。 另外,托管对象本身就是与上下文绑定的。也不可能跨上下文。这也是为什么需要是使用 PersistentIdentifier 在不同的上下文( DataHandler 实例方法)中进行传递的原因

jyrnan commented 3 months ago

@fatbobman @CarberryChai 这部分没有问题,是不是 DataHander 里面写错了

放置两个Button的HStack少了.buttonStyle(.bordered)

@LivenBom 大佬你好,请教一下,关于文章中说的:建议避免在 DataProvider 中持有一个长期共享的 DataHandler 实例。相反,在每个业务逻辑场景中独立创建新的实例或许是更好的选择。这个是否会导致并发问题,比如不同的View从Environment中获取的DataHandler不是同一个对象,那么有可能出现多个ModelContext同时对同一个托管对象修改的问题。当然这种可能性不高,我只是想知道发生这种情况的时候,是否会有问题,谢谢

SwiftData框架默认会开启保证各个Context之间托管对象的自动同步。

jyrnan commented 3 months ago

报告一个小bug: 通过文章后半部份介绍的创建基于MainContext的Datahandler进行Update的确可以保证当前视图的更改会及时反应到视图中。但是如果这个托管对象是通过iCloud同步到另一个设备后,在当前设备修改数据本地视图均可以反馈修改,但是同步设备只有通过TextView展示的数据可以自动更新,独立View的数据还是不能更新。

fatbobman commented 3 months ago

@jyrnan 报告一个小bug: 通过文章后半部份介绍的创建基于MainContext的Datahandler进行Update的确可以保证当前视图的更改会及时反应到视图中。但是如果这个托管对象是通过iCloud同步到另一个设备后,在当前设备修改数据本地视图均可以反馈修改,但是同步设备只有通过TextView展示的数据可以自动更新,独立View的数据还是不能更新。

Observation 中缺少了一种无需写入的触发机制,目前 SwiftData 应该用的一些 hack 方法来进行的提醒,因此会在不同的场景下有表现会不一样。 你可以试一下,本期周报中推荐的 @LiveModel 方式 https://fatbobman.com/zh/weekly/issue-032/

zhangle commented 2 months ago

肘子哥,用您的代码试着做了一个SwiftData+CloudKit 的macOS 应用,发现每次应用切换到前台时,都会主动进行一次 iCloud 同步,在网络不佳时,应用会停止响应。其实已经注册了 Remote Notifications,应该不用主动的调用 iCloud 同步,但好像SwiftData没有关闭的选项。

fatbobman commented 2 months ago

@zhangle SwiftData 和 Core Data 一样,只能硬关闭同步( 冷启动时决定是否开启同步 )。 暂时没有其他的人反应过你遇到的情况( 可能在 macOS app 上使用 SwiftData 的比较少),暂时还没有解决方法。不过,每次进入前台时主动同步确实比较奇怪,这个现象与 iOS app 在模拟器上的现象一样,但是在实机上不应该会有这种情况。

zhangle commented 2 months ago

@fatbobman @zhangle SwiftData 和 Core Data 一样,只能硬关闭同步( 冷启动时决定是否开启同步 )。 暂时没有其他的人反应过你遇到的情况( 可能在 macOS app 上使用 SwiftData 的比较少),暂时还没有解决方法。不过,每次进入前台时主动同步确实比较奇怪,这个现象与 iOS app 在模拟器上的现象一样,但是在实机上不应该会有这种情况。

感谢回复,我再试试看