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