为什么在 ScrollView 上的可刷新修饰符中取消异步任务(iOS 16)

Job*_*e J 8 scrollview ios swiftui

我正在尝试在面向 iOS 16 的应用程序中的 Scrollview 上使用可刷新修饰符。但是,异步任务在拉动刷新手势期间被取消。

这是一些代码和附加的视频,演示了问题以及带有打印错误的图像:

探索ViemModel.swift

class ExploreViewModel: ObservableObject {
    
    @Published var randomQuotes: [Quote] = []
    
    init() {
        Task {
            await loadQuotes()
        }
    }
     
    @MainActor
    func loadQuotes() async {
         
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                let quotes =  try JSONDecoder().decode([Quote].self, from: data)
                randomQuotes.append(contentsOf: quotes)
            }
        } catch {
            debugPrint(error)
            debugPrint(error.localizedDescription)
        }
    }
    
    func clearQuotes() {
        randomQuotes.removeAll()
    }
}
Run Code Online (Sandbox Code Playgroud)

内容视图.swift

import SwiftUI

struct ContentView: View {
    
    @StateObject private var exploreVM = ExploreViewModel()
    
    var body: some View {
        
        NavigationStack {
            ExploreView()
                .environmentObject(exploreVM)
                .refreshable {
                    exploreVM.clearQuotes()
                    await exploreVM.loadQuotes()
                }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

探索.swift

import SwiftUI

struct ExploreView: View {
    
    @EnvironmentObject var exploreVM: ExploreViewModel
 
    var body: some View {
        ScrollView {
            VStack {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 140.0), spacing: 24.0)], spacing: 24.0) {
                    ForEach(exploreVM.randomQuotes) { quote in
                        VStack(alignment: .leading) {
                            Text("\(quote.text ?? "No Text")")
                                .font(.headline)
                            Text("\(quote.author ?? "No Author")")
                                .font(.caption)
                        }
                        .frame(minWidth: 0, maxWidth: .infinity)
                        .frame(height: 144.0)
                        .border(Color.red, width: 2.0)
                        
                    }

                }
            }
            .padding()
            .navigationTitle("Explore")
        }
 
    }
}
Run Code Online (Sandbox Code Playgroud)

取消的任务

打印错误

lor*_*sum 11

当您调用时,exploreVM.clearQuotes()您会body在数组被清除时重新绘制。

.refreshable也会被重绘,因此之前正在使用的“任务”被取消。

这就是 SwiftUI 的本质。

有几种方法可以克服这个问题,最简单的方法是使用id.

选项1

struct ExploreParentView: View {
    @StateObject private var exploreVM = ExploreViewModel()
    //@State can survive reloads on the `View`
    @State private var taskId: UUID = .init()
    var body: some View {
        NavigationStack {
            ExploreView()
                .refreshable {
                    print("refreshable")
                    //Cause .task to re-run by changing the id.
                    taskId = .init()
                }
            //Runs when the view is first loaded and when the id changes.
            //Task is perserved while the id is preserved.
                .task(id: taskId) {
                    print("task \(taskId)")
                    exploreVM.clearQuotes()
                    await exploreVM.loadQuotes()
                }
        }.environmentObject(exploreVM)
    }
}
Run Code Online (Sandbox Code Playgroud)

如果您使用上述方法,您应该Task删除.initExploreViewModel

选项2

另一种方法是在 url 调用返回之前防止重新绘制。

class ExploreViewModel: ObservableObject {
    //Remove @Published
    var randomQuotes: [Quote] = []
    
    init() {
        //Floading Task that isn't needed for option 1
        Task {
            await loadQuotes()
        }
    }
     
    @MainActor
    func loadQuotes() async {
        
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                let quotes =  try JSONDecoder().decode([Quote].self, from: data)
                randomQuotes.append(contentsOf: quotes)
                print("updated")
            }
        } catch {
            debugPrint(error)
            debugPrint(error.localizedDescription)
        }
        print("done")
        //Tell the View to redraw
        objectWillChange.send()
    }
    
    func clearQuotes() {
        randomQuotes.removeAll()
    }
}
Run Code Online (Sandbox Code Playgroud)

选项3

就是等到有响应才去改变数组。

class ExploreViewModel: ObservableObject {
    @Published var randomQuotes: [Quote] = []
    
    init() {
        Task {
            await loadQuotes()
        }
    }
     
    @MainActor
    func loadQuotes() async {
        
        let quotesURL = URL(string: "https://type.fit/api/quotes")!
        
        do {
            let (data, urlResponse) = try await URLSession.shared.data(from: quotesURL)
            guard let response = urlResponse as? HTTPURLResponse else { print("no response"); return}
            
            if response.statusCode == 200 {
                let quotes =  try JSONDecoder().decode([Quote].self, from: data)
                //Replace array
                randomQuotes = quotes
                print("updated")
            }
        } catch {
            //Clear array
            clearQuotes()
            debugPrint(error)
            debugPrint(error.localizedDescription)
        }
        print("done")
    }
    
    func clearQuotes() {
        randomQuotes.removeAll()
    }
}
Run Code Online (Sandbox Code Playgroud)

选项 1 更能抵抗取消,对于短线呼叫来说是可以的。它不会等待调用返回来关闭 ProgressView。

选项 2 在 ViewModel 内提供更多控制,但视图仍然可以由其他人重绘。

选项 3 很可能是 Apple 设想的流程,但也容易受到其他重绘的影响。


stu*_*stu 6

我刚刚也遇到了这个问题,我不确定为什么会.refreshable这样。我的解决方案是将逻辑包装在.refreshable {}一个新任务中,然后等待结果。

.refreshable {
    await Task {
        exploreVM.clearQuotes()
        await exploreVM.loadQuotes()
    }.value
}
Run Code Online (Sandbox Code Playgroud)

这样,即使可刷新任务被取消,也无法取消新任务,因此新任务将运行直到完成。

这样做的一个好处是刷新旋转器应该显示直到新任务完成。