首页 > 解决方案 > UICollectionView 上的滞后滚动问题

问题描述

我正在尝试优化 UICollectionView 加载。我有一个数组,其中有 40 张图像,并且在滚动时会滞后。我进行了很多研究,但找不到本地图像异步的合适解决方案。我尝试了 DispatchQueue 全局异步,但效果不佳。我怎么解决这个问题?谢谢。

这是我的问题:

滚动滞后问题gif

这是我的 CollectionView 快速文件:

    extension ThemesVC: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout{
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return ThemeManager().themeBackgroundImages().count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ThemesCollectionViewCell", for: indexPath) as! ThemesCollectionViewCell
        
        cell.contentView.layer.cornerRadius = 20
        
        cell.themeCellImageView.image = ThemeManager().themeBackgroundImages()[indexPath.row]
        
        
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 20
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        return 20
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: view.frame.size.width/2.34, height: view.frame.size.height * 0.3)
    }
}

UICollectionViewCell Swift 文件:

import UIKit

class ThemesCollectionViewCell: UICollectionViewCell {
    @IBOutlet weak var themeCellImageView: UIImageView!
}

这是 ThemeManager().themeBackgroundImages() 代码:

func themeBackgroundImages() -> [UIImage]{
    var images = [UIImage]()
    images.reserveCapacity(40)

    images = [UIImage(named: "original-theme")!, UIImage(named: "blue-theme")!, UIImage(named: "purple-theme")!, UIImage(named: "pink-theme")!, UIImage(named: "red-theme")!, FlyTheme().backgroundImage, DreamTheme().backgroundImage, LoverTheme().backgroundImage, NatureTheme().backgroundImage, InspireTheme().backgroundImage, TimeMachineTheme().backgroundImage, NowhereTheme().backgroundImage, DarkThoughtsTheme().backgroundImage, FutureCityTheme().backgroundImage, LostTheme().backgroundImage, FireTheme().backgroundImage, PatternTheme().backgroundImage, HorizonTheme().backgroundImage, SpelTheme().backgroundImage, WonderlandTheme().backgroundImage, BreatheTheme().backgroundImage, PassionTheme().backgroundImage, TimelapseTheme().backgroundImage, FugitiveTheme().backgroundImage, InfiniteWayTheme().backgroundImage, LonelyTheme().backgroundImage, RelationshipTheme().backgroundImage, MoonlightTheme().backgroundImage, DreamlandTheme().backgroundImage, WayOutTheme().backgroundImage, MistyTheme().backgroundImage, UnpredictableTheme().backgroundImage, VolcanoTheme().backgroundImage, DiscoverTheme().backgroundImage, PurpleInspirationTheme().backgroundImage, ForestTheme().backgroundImage, RainbowTheme().backgroundImage, FlowersTheme().backgroundImage, TheMoonTheme().backgroundImage, LuridTheme().backgroundImage]
    
    return images
}

标签: iosswiftxcode

解决方案


您的代码中存在一些导致故障的问题:

  1. 每次集合视图需要一个单元格时,您的代码都会从磁盘加载 40 张大图像,从中挑选一张,然后丢弃其余的。
  2. 即使每个单元格只加载一张图像,它仍然会发生在主队列中。加载图像时,UI 冻结。
  3. 图像本身非常大,因此UIImageView每次需要显示图像时都必须调整它们的大小。

为了减轻这种情况,以下是我的建议:

  1. 不要ThemeManagercollectionView(:cellForItemAt:). 相反,让它成为视图控制器的属性。
  2. 与其ThemeManager一次返回所有图像,不如让它只获取一个具有特定索引的图像。
  3. 使图像加载异步,将工作移出主队列。
  4. 从磁盘加载图像后,适当调整它的大小,这样UIImageView就不必处理巨大的分辨率。
  5. 在内部实现调整大小图像的图像缓存,ThemeManager以便立即加载先前的图像。NSCache是一门可以做到这一点的好课。

这是一个示例,其中我标记了上述每个建议的实现:

import UIKit

final class ViewController: UIViewController {

    @IBOutlet private var collectionView: UICollectionView!
    
    // Recommendation #1: make ThemeManager a property
    // instead of recreating it each time
    private let themeManager = ThemeManager()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.setCollectionViewLayout(UICollectionViewFlowLayout(), animated: false)
        collectionView.dataSource = self
        collectionView.delegate = self
    }
}

extension ViewController: UICollectionViewDataSource {
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        1
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        themeManager.imageKeys.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ThemesCollectionViewCell", for: indexPath) as? ThemesCollectionViewCell else {
            fatalError("Unexpected cell class dequeued")
        }
        
        cell.currentIndexPath = indexPath
        
        let cellSize = self.collectionView(collectionView, layout: collectionView.collectionViewLayout, sizeForItemAt: indexPath)
        themeManager.fetchImage(atIndex: indexPath.item, resizedTo: cellSize) { [weak cell] image, itemIndex in
            guard let cell = cell, let image = image else { return }
            DispatchQueue.main.async {
                guard let cellIndexPath = cell.currentIndexPath, cellIndexPath.item == itemIndex else {
                    print("⚠️ Discarding fetched image for item \(itemIndex) because the cell is no longer being used for that index path")
                    return
                }
                print("Fetched image for \(indexPath) of size \(image.size) and scale \(image.scale)")
                cell.imageView.image = image
                cell.textLabel.text = "\(indexPath)"
            }
        }
        
        return cell
    }
}

// MARK: - UICollectionViewDelegate and UICollectionViewDelegateFlowLayout

extension ViewController: UICollectionViewDelegate {}

extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let sectionInsets = self.collectionView(collectionView, layout: collectionViewLayout, insetForSectionAt: indexPath.section)
        return CGSize(width: collectionView.frame.width - sectionInsets.left - sectionInsets.right, height: 128)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        10
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
        0
    }
}

// MARK: - ThemesCollectionViewCell

final class ThemesCollectionViewCell: UICollectionViewCell {
    @IBOutlet var textLabel: UILabel!
    @IBOutlet var imageView: UIImageView!
    var currentIndexPath: IndexPath?
    
    override func awakeFromNib() {
        super.awakeFromNib()
        layer.cornerRadius = 20
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
        currentIndexPath = nil
    }
}

// MARK: - ThemeManager

final class ThemeManager {
    // Recommendation #5: Implement an image cache of resized images inside `ThemeManager`
    private let imageCache = NSCache<NSString, UIImage>()
    
    private(set) var imageKeys: [NSString]
    
    init() {
        imageKeys = []
        for i in 1...40 {
            imageKeys.append(NSString(string: "image\(i)"))
        }
    }
    
    // Recommendation #2: make ThemeManager fetch just one image with a particular index.
    func fetchImage(atIndex index: Int, resizedTo size: CGSize, completion: @escaping (UIImage?, Int) -> Void) {
        guard 0 <= index, index < imageKeys.count else {
            assertionFailure("Image with invalid index requested")
            completion(nil, index)
            return
        }
        
        let imageKey = imageKeys[index]
         
        if let cachedImage = imageCache.object(forKey: imageKey) {
            completion(cachedImage, index)
            return
        }
        
        // Recommendation #3: Make image loading asynchronous, moving the work off the main queue.
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            guard let self = self else {
                completion(nil, index)
                return
            }
            guard let image = UIImage(named: String(imageKey)) else {
                assertionFailure("Image is missing from the asset catalog")
                completion(nil, index)
                return
            }
            
            // Recommendation #4: After loading an image from disk, resize it appropriately
            let resizedImage = image.resized(to: size)
            
            self.imageCache.setObject(resizedImage, forKey: imageKey)
            completion(resizedImage, index)
        }
    }
}

// MARK: - Image Resize Extension

extension UIImage {
    func resized(to targetSize: CGSize) -> UIImage {
        let widthRatio  = (targetSize.width  / size.width)
        let heightRatio = (targetSize.height / size.height)
        let effectiveRatio = max(widthRatio, heightRatio)
        let newSize = CGSize(width: size.width * effectiveRatio, height: size.height * effectiveRatio)
        let rect = CGRect(origin: .zero, size: newSize)
        
        UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
        self.draw(in: rect)
        let newImage = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        
        return newImage!
    }
}

我已将整个示例项目放在 Github 上,您可以随意克隆它,看看它是如何工作的。


推荐阅读