Search code examples
iosswiftuitableviewnsurlsession

Why is the scrolling so choppy in this table view?


I have some strange behaviour in a table view inside my tvOS app. My app is just displaying a list of images for some tv shows/movies along with the series name and the episode title. I have a custom table view cell class created called BMContentCell. Here is the entire file:

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 { $0.type == kPosterContent }
        DispatchQueue.global(qos: .userInitiated).async
        {
            if posterImageContent.count > 1
            {
                posterImageContent.sort { $0.url < $1.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()
    }
}

If you look at the configureCell method, it's looking for the image url (which is the key for the NSCache object in this class) and it checks the cache if that url already exists. If it does, it simply loads the UIImage otherwise it goes and fetches it from the internet.

The view controller where the table view is located is called BMContentViewController. The cellForRowAt method inside that class is as follows:

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
    }

Anytime I scroll up or down in my table view in the tvOS app, it's very choppy. And it also seems like the images initially appear to be wrong for any given tv show/movie and after a few milliseconds update to the correct image.

Basically, my question is what is a better way to approach the issue of caching and retrieving images in a table view inside my app?

Thank you!


Solution

  • You're definitely on the right track. The reason that your images appear incorrectly for a half a second is that cells are reused when you scroll, and thus they retain the last state that they are "prepared" in. This normally wouldn't be a problem, but in your configureCell method, you wrap the loadImageURLFromCache in a thread that is not on the main queue, and then each UI update is (correctly) made on the main queue. This is async behavior, and is the reason why the image takes a fraction to update. In my opinion, you do not need DispatchQueue.global(qos: .userInitiated).async at all. Since your cache object is not using asynchronous lookup, you can safely set the image to the imageView on the current thread without wrapping in a main thread async call. Also, if you're scrolling through a lot of images, you should really consider using a cache library that writes to disk and has cleanup methods, as your memory usage is just going to continue to grow and grow. I would also recommend not having a "dangling return" in the middle of your loadImageURLFromCache method. I have tweaked your method a little bit below. I have not verified that it is typed or compiles correctly, so it might not be a paste-and-build solution.

    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()
    }