SwiftUI 列表 - 远程图像加载 = 图像闪烁(重新加载)

Posted

技术标签:

【中文标题】SwiftUI 列表 - 远程图像加载 = 图像闪烁(重新加载)【英文标题】:SwiftUI List - Remote Image loading = images flickering (reloading) 【发布时间】:2020-02-21 20:46:35 【问题描述】:

我是 SwiftUI 和 Combine 的新手。我有一个简单的列表,我正在加载 iTunes 数据并构建列表。加载图像有效 - 但它们在闪烁,因为似乎我在主线程上的调度一直在触发。我不确定为什么。下面是图像加载的代码,然后是它的实现位置。

struct ImageView: View 
    @ObservedObject var imageLoader: ImageLoaderNew
    @State var image: UIImage = UIImage()

    init(withURL url: String) 
        imageLoader = ImageLoaderNew(urlString: url)
    

    func imageFromData(_ data: Data) -> UIImage 
        UIImage(data: data) ?? UIImage()
    

    var body: some View 
        VStack 
            Image(uiImage: imageLoader.dataIsValid ?
                imageFromData(imageLoader.data!) : UIImage())
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:60, height:60)
                .background(Color.gray)
        
    


class ImageLoaderNew: ObservableObject
    
    @Published var dataIsValid = false
    var data: Data?

    // The dispatch fires over and over again. No idea why yet
    // this causes flickering of the images in the List. 
    // I am only loading a total of 3 items. 

    init(urlString: String) 
        guard let url = URL(string: urlString) else  return 
        let task = URLSession.shared.dataTask(with: url)  data, response, error in
            guard let data = data else  return 
            DispatchQueue.main.async 
                self.dataIsValid = true
                self.data = data
                print(response?.url as Any) // prints over and over again.
            
        
        task.resume()
    

这里是加载JSON等后实现的。

List(results, id: \.trackId)  item in
    HStack 

        // All of these image end up flickering
        // every few seconds or so.
        ImageView(withURL: item.artworkUrl60)
            .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

        VStack(alignment: .leading) 
            Text(item.trackName)
                .foregroundColor(.gray)
                .font(.headline)
                .truncationMode(.middle)
                .lineLimit(2)

            Text(item.collectionName)
                .foregroundColor(.gray)
                .font(.caption)
                .truncationMode(.middle).lineLimit(1)
            
        
    
    .frame(height: 200.0)
    .onAppear(perform: loadData) // Only happens once

我不确定为什么图像会一遍又一遍地加载(至今)。我一定遗漏了一些简单的东西,但我绝对不知道组合的方式。任何见解或解决方案将不胜感激。

【问题讨论】:

【参考方案1】:

我将更改分配顺序如下(因为否则dataIsValid 强制刷新,但尚未设置data

DispatchQueue.main.async 
    self.data = data             // 1) set data
    self.dataIsValid = true.     // 2) notify that data is ready

其他一切似乎都不重要。但是考虑以下优化的可能性,因为UIImage 从数据构造可能也足够长(对于 UI 线程)

func imageFromData(_ data: Data) -> UIImage 
    UIImage(data: data) ?? UIImage()

所以我建议不仅将数据,而且将整个图像构造移动到后台线程中,并仅在最终图像准备好时刷新。

更新:我已使您的代码快照在本地工作,当然,由于并非所有部分最初都可用,因此进行了一些替换和简化,并建议进行上述修复。以下是一些实验结果:

如您所见,没有观察到闪烁,也没有重复登录控制台。由于我没有对您的代码逻辑进行任何重大更改,我认为导致报告闪烁的问题不在提供的代码中 - 可能其他一些部分导致重新创建 ImaveView 从而产生这种效果。

这是我的代码(完全,用 Xcode 11.2/3 和 ios 13.2/3 测试):

struct ImageView: View 
    @ObservedObject var imageLoader: ImageLoaderNew
    @State var image: UIImage = UIImage()

    init(withURL url: String) 
        imageLoader = ImageLoaderNew(urlString: url)
    

    func imageFromData(_ data: Data) -> UIImage 
        UIImage(data: data) ?? UIImage()
    

    var body: some View 
        VStack 
            Image(uiImage: imageLoader.dataIsValid ?
                imageFromData(imageLoader.data!) : UIImage())
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width:60, height:60)
                .background(Color.gray)
        
    


class ImageLoaderNew: ObservableObject

    @Published var dataIsValid = false
    var data: Data?

    // The dispatch fires over and over again. No idea why yet
    // this causes flickering of the images in the List.
    // I am only loading a total of 3 items.

    init(urlString: String) 
        guard let url = URL(string: urlString) else  return 
        let task = URLSession.shared.dataTask(with: url)  data, response, error in
            guard let data = data else  return 
            DispatchQueue.main.async 
                self.data = data
                self.dataIsValid = true
                print(response?.url as Any) // prints over and over again.
            
        
        task.resume()
    


struct TestImageFlickering: View 
    @State private var results: [String] = []
    var body: some View 
        NavigationView 
            List(results, id: \.self)  item in
                HStack 

                    // All of these image end up flickering
                    // every few seconds or so.
                    ImageView(withURL: item)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

                    VStack(alignment: .leading) 
                        Text("trackName")
                            .foregroundColor(.gray)
                            .font(.headline)
                            .truncationMode(.middle)
                            .lineLimit(2)

                        Text("collectionName")
                            .foregroundColor(.gray)
                            .font(.caption)
                            .truncationMode(.middle).lineLimit(1)
                    
                
            
            .frame(height: 200.0)
            .onAppear(perform: loadData)
         // Only happens once
    

    func loadData() 
        var urls = [String]()
        for i in 1...10 
            urls.append("https://placehold.it/120.png&text=image\(i)")
        
        self.results = urls
    


struct TestImageFlickering_Previews: PreviewProvider 
    static var previews: some View 
        TestImageFlickering()
    

【讨论】:

我切换了顺序 - 它仍然被一遍又一遍地调用(print(response?.url as Any)。其他事情正在发生。 @Asperi 更改顺序无济于事,但“我建议不仅将数据,而且将整个图像构造移动到后台线程并仅在最终图像准备好时刷新”可能会有所帮助,如果最终图像将被创建为固定大小【参考方案2】:
// The dispatch fires over and over again. No idea why yet
// this causes flickering of the images in the List. 
// I am only loading a total of 3 items. 

    init(urlString: String)  ....

表示init一遍遍地调用

只能从这部分调用

// All of these image end up flickering
// every few seconds or so.
    ImageView(withURL: item.artworkUrl60)
        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

在什么情况下 SwiftUI 会这样做?除了改变布局,我什么也没看到。看来,调整 ImageView 的大小是发现这种闪烁的地方。

我会尝试玩这部分

.resizable()
.aspectRatio(contentMode: .fit)

或申请

ImageView(withURL: item.artworkUrl60)
    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))
    .fixedSize()

Asperi 的建议(从后台下载的数据创建固定大小的图像)并删除

.resizable()
.aspectRatio(contentMode: .fit)

从你的布局来看,可能是最好的方法

或者只是从 ImageView 中移动 .frame(width:60, height:60) 并将其应用到您的列表中

ImageView(withURL: item.artworkUrl60)
    .frame(width:60, height:60)
    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

我还将尝试修复所有 List 组件的大小...,直到您找到哪个使闪烁。

【讨论】:

【参考方案3】:

我在使用的 SwiftUI 远程图像加载器中遇到了类似的问题。尝试使 ImageView 相等,以避免在 uiImage 设置为初始化的 nil 值以外的值或返回 nil 后重绘。

struct ImageView: View, Equatable 

    static func == (lhs: ImageView, rhs: ImageView) -> Bool 
        let lhsImage = lhs.image
        let rhsImage = rhs.image
        if (lhsImage == nil && rhsImage != nil) || (lhsImage != nil && rhsImage == nil) 
            return false
         else 
            return true
        
    

    @ObservedObject var imageLoader: ImageLoaderNew
    @State var image: UIImage?

// ... rest the same

【讨论】:

【参考方案4】:

我在处理远程图像时遇到了类似的问题。

我使用像你这样的 ObservableObject 作为图像加载器,发现这就是问题所在。 SwiftUI 会多次创建 ImageView 结构,如果您实例化一个新的 ImageLoader 并在每次最终收到数百个 URLSession 请求时加载数据。即使网络请求被缓存,你也必须将 Data 转换为 UIImage 数百次。

我通过在单独的类中实现缓存机制来解决闪烁问题。 我仍然使用 ImageLoader,所以我可以为我的 SwiftUI 视图创建一个 ObservableObject,但我没有在那里创建 URLSession 数据任务,我从不同的对象获取 UIImage 并且 UIImage 本身被缓存,而不是来自网络请求的数据.

class ImageLoader: ObservableObject 
    @Published var image = UIImage()
    
    func load(url:URL) 
        loadImage(fromURL: url)
    
    
    func load(urlString:String) 
        guard let url = URL(string: urlString) else  return 
        loadImage(fromURL: url)
    
    
    // MARK: - Private
    private var imageCache = ImageCache.shared
    
    private func loadImage(fromURL url:URL) 
        imageCache.imageForURL(url)  image in
            if let image = image 
                DispatchQueue.main.async 
                    self.image = image
                
            
        
    

而实际的图片加载是在另一个类中完成的

typealias ImageCacheCompletion = (UIImage?) -> Void

class ImageCache 
    static let shared = ImageCache()
    
    func imageForURL(_ url:URL, completion: @escaping ImageCacheCompletion) 
        if let cachedImage = cache.first(where:  record in
            record.urlString == url.absoluteString
        ) 
            completion(cachedImage.image)
        
        else 
            loadImage(url: url, completion: completion)
        
    
    
    // MARK: - Private
    private var cache:[ImageCacheRecord] = []
    private let cacheSize = 10
    
    private func imageFromData(_ data:Data) -> UIImage? 
        UIImage(data: data)
    
    
    private func loadImage(url:URL, completion: @escaping ImageCacheCompletion) 
        RESTClient.loadData(atURL: url)  result in
            switch result 
            case .success(let data):
                if let image = self.imageFromData(data) 
                    completion(image)
                    self.setImage(image, forUrl: url.absoluteString)
                
                else 
                    completion(nil)
                
            case .failure(_ ):
                completion(nil)
            
        
    
    
    private func setImage(_ image:UIImage, forUrl url:String) 
        if cache.count >= cacheSize 
            cache.remove(at: 0)
            
        
        cache.append(ImageCacheRecord(image: image, urlString: url))
    
    
    // MARK: - ImageCacheRecord
    
    struct ImageCacheRecord 
        var image:UIImage
        var urlString:String
    

这个实现的一个例子是GitHub

您可能需要调整一些参数,例如缓存大小以满足您的需求,正如您所见,我使用 FIFO 方法,因此缓存类有改进的空间,但这是一个很好的起点。

【讨论】:

以上是关于SwiftUI 列表 - 远程图像加载 = 图像闪烁(重新加载)的主要内容,如果未能解决你的问题,请参考以下文章

远程图像使用 List 正确加载,但在使用带有嵌入式 VStack 的 ScrollView 和 SwiftUI 时不加载

为啥从 Realm DB 加载图像时 SwiftUI List 会崩溃? RAM 达到极限

如何将 UIImage 异步加载到 SwiftUI Image 中?

SwiftUI:使用 .fileImporter 加载图像

在 SwiftUI 列表中重新加载单个行属性

SwiftUI:使用 ForEach 动态加载图像