如何使用异步构建 SwiftUI 视图

huangapple go评论56阅读模式
英文:

How to construct a SwiftUI view using async

问题

我有一个小的SwiftUI程序,用于显示给定日期的照片,以每年的照片堆栈方式显示。以下是简化后的代码。我将照片读入resultPhotos中,它是一个字典,每个键表示年份,每个值是PHFetchResult。然后,我遍历字典中的每一年,并使用该年的照片创建myImageStack。

在测试中,使用大约100张照片时,这个过程非常慢。问题似乎是在构建myImageStack中的图像堆栈时出现的。

我想尝试异步构建myImageStack,以避免冻结UI。有关如何执行此操作的任何指导吗?我发现大多数async/await示例都围绕返回数据构建,但我试图返回一个将在ForEach循环中使用的视图。我觉得我在这里漏掉了一些基本的东西。

(我意识到很多图像在myImageStack中完全被覆盖,所以也许我应该简化该视图。但由于图像是随机堆叠的,您可以看到一些图像的一部分,我想保持这个特性。)

import SwiftUI
import Foundation
import Photos

struct PhotosOverviewView_simplified: View {
    
    var body: some View {
        let photos = PhotosModel()
        let _ = photos.getPhotoAuthorizationStatus()
        let resultPhotos = photos.getAllPhotosOnDay(monthAbbr: "Feb", day: 16)
        
        NavigationStack {
            ScrollView(.vertical) {
                VStack(alignment: .center) {
                    
                    // For each year, create a stack of photos
                    ForEach(Array(resultPhotos.keys).sorted(), id: \.self) { key in
                        let stack = myImageStack(resultsPhotos: resultPhotos[key]!)
                        
                        NavigationLink(destination: PhotosYearView(year: key, resultsPhotos: resultPhotos[key]!)) {
                            stack
                                .padding()
                        }
                    }
                }
                .frame(maxWidth: .infinity)
            }
        }
    }
}

struct myImageStack: View {
    let resultsPhotos: PHFetchResult<PHAsset>
    
    var body: some View {
        let collection = PHFetchResultCollection(fetchResult: resultsPhotos)
        
        ZStack {
            ForEach(collection, id: \.localIdentifier) { p in
                Image(uiImage: p.getAssetThumbnail())
                    .resizable()
                    .scaledToFit()
                    .border(Color.white, width: 10)
                    .shadow(color: Color.black.opacity(0.2), radius: 5, x: 5, y: 5)
                    .frame(width: 200, height: 200)
                    .rotationEffect(.degrees(Double.random(in: -25...25)))
            }
        }
    }
}
英文:

I've got a little swiftUI program that displays photos from a given day as stacks of photos for each year. Simplified code below. I read the photos into resultPhotos as a dictionary with each key the year, and each value a PHFetchResult. I then loop through each year in the dictionary and create myImageStack with the photos for that year.

Tested with around 100 photos, this is very slow. The culprit appears to be building the stack of images in myImageStack.

I'd like to try to build myImageStack asynchronously so as to not freeze the UI. Any guidance on how to do that? Most of the async/await examples I've found are built around returning data, but I'm trying to return a view that will be used in a ForEach loop. I feel like I'm missing something fundamental here.

(I realize that many of the images are completely covered in myImageStack, so perhaps I should be simplifying that view as well. But as the images are stacked randomly, you can see pieces of some of the images in the stack and I'd like to maintain that.)

import SwiftUI
import Foundation
import Photos

struct PhotosOverviewView_simplified: View {
    
    var body: some View {
        let photos = PhotosModel()
        let _ = photos.getPhotoAuthorizationStatus()
        let resultPhotos = photos.getAllPhotosOnDay(monthAbbr:&quot;Feb&quot;, day:16)
        
        NavigationStack{
            ScrollView(.vertical){
                VStack(alignment: .center){
                    
                    //For each year, create a stack of photos
                    ForEach(Array(resultPhotos.keys).sorted(), id: \.self) { key in
                        let stack = myImageStack(resultsPhotos: resultPhotos[key]!)
                        
                        NavigationLink(destination: PhotosYearView(year: key, resultsPhotos: resultPhotos[key]!)){
                            stack
                                .padding()
                        }
                    }
                }
                .frame(maxWidth: .infinity)
            }
        }
    }
}


struct myImageStack: View {
    let resultsPhotos: PHFetchResult&lt;PHAsset&gt;
    
    var body: some View {
        let collection = PHFetchResultCollection(fetchResult: resultsPhotos)
        
        ZStack{
            ForEach(collection, id: \.localIdentifier){ p in
                    Image(uiImage: p.getAssetThumbnail())
                        .resizable()
                        .scaledToFit()
                        .border(Color.white, width:10)
                        .shadow(color:Color.black.opacity(0.2), radius:5, x:5, y:5)
                        .frame(width: 200, height: 200)
                        .rotationEffect(.degrees(Double.random(in:-25...25)))
            }
        }
    }
}


答案1

得分: 2

大部分的async/await示例都是围绕返回数据构建的,但我试图返回一个将在ForEach循环中使用的视图。我觉得我在这里漏掉了一些基本的东西。

是的,这正是你的问题所在。SwiftUI的要点在于它是声明性的。你不能进行异步工作来生成视图,因为视图描述布局。它们不像UIKit视图,代表屏幕上的实际内容。

你遇到问题是因为你没有考虑在加载数据时应该显示什么。你的视图需要考虑所有可能的状态。这些状态不出所料地放在@State属性中。当状态发生变化时,视图将被更新。另一种模式是@StateObject模式,当引用类型更新@Published属性时,视图将被更新。但关键教训是:视图始终显示其当前状态。当状态发生变化时,视图将重新渲染。

所以对于你的例子,你可能期望像这样的东西:

struct Photo: Identifiable {
    let id: String
}

@MainActor
class PhotosModel: ObservableObject {
    @Published var photos: [Photo] = []

    init() {}

    func load(month: String, day: Int) async throws {
        // 做一些事情来更新 `photos`
    }
}

struct PhotosOverviewView_simplified: View {
    // 每当 model 的 @Published 属性更新时,body 将重新渲染
    // 但 body 的*值*实际上并没有改变。它始终描述
    // 视图的所有可能状态。
    @StateObject var model = PhotosModel()

    var body: some View {
        NavigationStack{
            ScrollView(.vertical){
                VStack(alignment: .center){

                    // 你不应该将这个 ForEach 视为一个执行的循环。
                    // 它只是描述视图。它说“有一个 YourCustomPhotoView 对于 photos 中的每个元素。”
                    // 它实际上不会创建这些视图。渲染器
                    // 将在未来的某个时间做这件事。
                    ForEach(model.photos) { photo in
                        YourCustomPhotoView(photo)
                    }
                }
            }
        }
        .task { // 当显示此视图时,开始加载任务
            do {
                try await model.load(month: "Feb", day: 16)
            } catch {
                // 错误处理
            }
        }
    }
}

我在这里稍微概括了错误处理,以使其简单。通常情况下,你需要有一个@State变量来指示是否处于错误状态。在body中,你会有类似以下的东西:

if let error {
    CustomErrorView(error)
else {
    ...当前 body 的其余部分...
}

再次强调,关键是body描述了所有可能的状态。它不会变成一个ErrorView或类似的东西。这里的if只是描述不同的状态。它不应被视为在运行时执行的代码。

(并且不要忘记查看AsyncImage。它可能会自动完成你想要的许多功能。)

英文:

>Most of the async/await examples I've found are built around returning data, but I'm trying to return a view that will be used in a ForEach loop. I feel like I'm missing something fundamental here.

Yep, this is exactly your problem. The point of SwiftUI is that it is declarative. You cannot do async work to generate Views because Views describe the layout. They are not like UIKit views that represent the actual thing on the screen.

You're having trouble because you haven't considered what to display while the data is loading. Your View needs to consider all of its possible states. Those states, unsurprisingly, go in @State properties. When the state changes, the View will be updated. Another pattern is the @StateObject pattern, such that when a reference type updates a @Published property, the View will be updated. But the key lesson is: The View always displays its current state. When the state changes, the View is re-rendered.

So for your example, you might expect something like:

struct Photo: Identifiable {
    let id: String
}

@MainActor
class PhotosModel: ObservableObject {
    @Published var photos: [Photo] = []

    init() {}

    func load(month: String, day: Int) async throws {
        // Do things to update `photos`
    }
}

struct PhotosOverviewView_simplified: View {
    // Whenever a @Published property of model updates, body will re-render
    // But the *value* of body does not actually change. It always describes
    // the View in all possible states.
    @StateObject var model = PhotosModel()

    var body: some View {
        NavigationStack{
            ScrollView(.vertical){
                VStack(alignment: .center){

                    // You should not think of this ForEach as a loop that 
                    // executes. It just describes the View. It says &quot;there
                    // is a YourCustomPhotoView for each element of photos.&quot;
                    // It does not actually make those Views. The renderer
                    // will do that at some future time.
                    ForEach(model.photos) { photo in
                        YourCustomPhotoView(photo)
                    }
                }
            }
        }
        .task { // When this is displayed, start a loading task
            do {
                try await model.load(month:&quot;Feb&quot;, day: 16)
            } catch {
                // Error handling
            }
        }
    }
}

I've waved my hands a little here with Error handling to keep this simple. Typically what you'd need to do is have a @State variable to indicate if you're in an error state. Inside body, you'd then have something like:

if let error {
    CustomErrorView(error)
else {
    ... the rest of your current body ...
}

Again, the point is that body describes all possible states. It does not mutate into an ErrorView or anything like that. The if here just describes the different states. It should not be thought of as code that executes at runtime.

(And don't forget to check out AsyncImage. It may do a lot of what you want automatically.)

huangapple
  • 本文由 发表于 2023年2月26日 23:19:23
  • 转载请务必保留本文链接:https://go.coder-hub.com/75572961.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定