为什么在这个 table 视图中滚动如此不稳定?

Why is the scrolling so choppy in this table view?

我的 tvOS 应用中的 table 视图出现一些奇怪的行为。我的应用程序只是显示一些电视 shows/movies 的图像列表以及系列名称和剧集标题。我创建了一个名为 BMContentCell 的自定义 table 视图单元格 class。这是整个文件:

import UIKit

class BMContentCell: UITableViewCell
{
    private var imageCache: NSCache<NSString, UIImage>? // key: string url pointing to the image on the internet, value: UIImage object
    private let kCellContentFormat = "VOD: %@ | DRM: %@ | Auth: %@"
    private let kPosterContent = "poster"

    private var contentImageView: UIImageView =
    {
        let imageView = UIImageView()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.contentMode = .scaleAspectFit
        imageView.layer.cornerRadius = 15
        imageView.clipsToBounds = true
        imageView.layer.borderColor = UIColor.clear.cgColor
        imageView.layer.borderWidth = 1
        return imageView
    }()
    private let contentTitle: UILabel =
    {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 32)
        label.textColor = UIColor.black
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let contentSubtitle: UILabel =
    {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 26)
        label.textColor = UIColor.black
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    private let contentAttributes: UILabel =
    {
        let label = UILabel()
        label.backgroundColor = .white
        label.font = UIFont.systemFont(ofSize: 20)
        label.textColor = UIColor.black
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    override func awakeFromNib()
    {
        super.awakeFromNib()
    }
    override func setSelected(_ selected: Bool, animated: Bool)
    {
        super.setSelected(selected, animated: animated)
    }

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?)
    {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        backgroundColor = .white
        addSubview(contentImageView)
        addSubview(contentTitle)
        addSubview(contentSubtitle)
        addSubview(contentAttributes)
        contentImageView.anchor(
            top: self.topAnchor,
            leading: self.leadingAnchor,
            bottom: self.bottomAnchor,
            trailing: contentTitle.leadingAnchor,
            padding: .init(top: 0, left: 0, bottom: 0, right: 0),
            size: .init(width: 180, height: 180)
        )
        contentTitle.anchor(
            top: self.topAnchor,
            leading: contentImageView.trailingAnchor,
            bottom: contentSubtitle.topAnchor,
            trailing: self.trailingAnchor,
            size: CGSize.init(width: 0, height: 60)
        )
        contentSubtitle.anchor(
            top: contentTitle.bottomAnchor,
            leading: contentImageView.trailingAnchor,
            bottom: contentAttributes.topAnchor,
            trailing: self.trailingAnchor,
            padding: .zero,
            size: CGSize.init(width: 0, height: 60)
        )
        contentAttributes.anchor(
            top: contentSubtitle.bottomAnchor,
            leading: contentImageView.trailingAnchor,
            bottom: self.bottomAnchor,
            trailing: self.trailingAnchor,
            padding: .zero,
            size: CGSize.init(width: 0, height: 60)
        )
    }

    required init?(coder aDecoder: NSCoder)
    {
        fatalError("init(coder:) has not been implemented")
    }

    internal func configureCell(content: BMContent)
    {
        self.contentTitle.text = content.media.name
        self.contentSubtitle.text = content.name
        self.contentAttributes.text = self.contentCellAttributes(
            String(!content.isLiveStream()),
            String(content.isDrm),
            String(content.authentication.required)
        )

        var posterImageContent = content.media.images.filter { [=11=].type == kPosterContent }
        DispatchQueue.global(qos: .userInitiated).async
        {
            if posterImageContent.count > 1
            {
                posterImageContent.sort { [=11=].url < .url }
                self.loadImageURLFromCache(imageURLStr: posterImageContent[0].url)
            }
            else if posterImageContent.count == 1
            {
                self.loadImageURLFromCache(imageURLStr: posterImageContent[0].url)
            }
        }
    }

    private func contentCellAttributes(_ isLiveStream: String, _ isDRM: String, _ isAuthRequired: String) -> String
    {
        return String(format: kCellContentFormat, isLiveStream, isDRM, isAuthRequired)
    }

    private func loadImageURLFromCache(imageURLStr: String)
    {
        // check if the url (key) exists in the cache
        if let imageFromCache = imageCache?.object(forKey: imageURLStr as NSString)
        {
            // the image exists in cache, load the image and return
            DispatchQueue.main.async {self.contentImageView.image = imageFromCache as UIImage}
            return
        }
        guard let url = URL(string: imageURLStr) else
        {
            // if the url is not valid, load a blank UIImage
            self.imageCache?.setObject(UIImage(), forKey: imageURLStr as NSString)
            DispatchQueue.main.async {self.contentImageView.image = UIImage()}
            return
        }
        let session = URLSession.shared     
        let task = session.dataTask(with: url)
        {
            data, response, error in
                if let data = data
                {
                    guard let imageFromURL = UIImage(data: data) else
                    {
                        // if we cannot create an image from the data for some reason, load a blank UIImage
                        let placeholderImage = UIImage()
                        self.imageCache?.setObject(placeholderImage, forKey: imageURLStr as NSString)
                        DispatchQueue.main.async {self.contentImageView.image = placeholderImage}
                        return
                    }
                    // load the image and set the key and value in the cache
                    DispatchQueue.main.async {self.contentImageView.image = imageFromURL}
                    self.imageCache?.setObject(imageFromURL, forKey: imageURLStr as NSString)
                }
        }
        task.resume()
    }
}

如果您查看 configureCell 方法,它正在寻找图像 url(这是此 class 中 NSCache object 的键)并且它会检查缓存是否url 已经存在。如果是,它会简单地加载 UIImage,否则它会从互联网上获取它。

table视图所在的视图控制器叫做BMContentViewController。 class里面的cellForRowAt方法如下:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
    {
        let cell = tableView.dequeueReusableCell(withIdentifier: kReuseIdentifier, for: indexPath) as! BMContentCell
        if self.viewModel.currentContent.count > 0
        {
                cell.configureCell(content: self.viewModel.currentContent[indexPath.row])
        }
        return cell
    }

每当我在 tvOS 应用程序的 table 视图中向上或向下滚动时,它都非常不稳定。而且对于任何给定的电视,图像最初似乎都是错误的 show/movie 并且在几毫秒后更新为正确的图像。

基本上,我的问题是在我的应用程序内的 table 视图中解决缓存和检索图像问题的更好方法是什么?

谢谢!

你绝对是在正确的轨道上。您的图像显示半秒不正确的原因是滚动时单元格被重复使用,因此它们保留了它们 "prepared" 所在的最后状态。这通常不会有问题,但在您的configureCell 方法,将 loadImageURLFromCache 包装在不在主队列上的线程中,然后每个 UI 更新都(正确地)在主队列上进行。这是 async 行为,也是图像需要一小部分更新的原因。在我看来,你根本不需要 DispatchQueue.global(qos: .userInitiated).async。由于您的缓存对象未使用异步查找,因此您可以安全地将图像设置为当前线程上的 imageView,而无需包装在主线程异步调用中。此外,如果您要滚动浏览大量图像,您真的应该考虑使用写入磁盘并具有清理方法的缓存库,因为您的内存使用量只会继续增长。我还建议不要在 loadImageURLFromCache 方法中间使用 "dangling return"。我在下面稍微调整了你的方法。我还没有验证它是否被正确输入或编译,所以它可能不是一个粘贴和构建的解决方案。

private func loadImageURLFromCache(imageURLStr: String)
{
    // check if the url (key) exists in the cache
    if let imageFromCache = imageCache?.object(forKey: imageURLStr) as? UIImage
    {
        // the image exists in cache, load the image and return
        self.contentImageView.image = imageFromCache
        return
    } else if let url = URL(string: imageURLStr) {
        loadImageFromURL(url)
    } else {
        loadBlankImage()
    }
}

private func loadImageFromURL(_ url: URL) {
    let session = URLSession.shared     
        let task = session.dataTask(with: url)
        {
            [weak self] (data, response, error) in
                if let data = data
                {
                    guard let imageFromURL = UIImage(data: data) else
                    {
                        // if we cannot create an image from the data for some reason, load a blank UIImage
                        self?.loadBlankImage()
                        return
                    }
                    // load the image and set the key and value in the cache
                    DispatchQueue.main.async {self?.contentImageView.image = imageFromURL}
                    self?.imageCache?.setObject(imageFromURL, forKey: imageURLStr as NSString)
                }
        }
        task.resume()
}

private func loadBlankImage() {
    contentImageView.image = UIImage()
}