For study purposes, I'm creating a app to show a list of some star wars ships. It fetches my json (locally) for the ship objects (it has 4 ships for this example). It's using a custom cell for the table view.
The table populates without problems, if I already have the images downloaded (in user documents) or not. My starshipData array is populated by my DataManager class by delegate. I removed some code to make the class smaller, I can show everything if needed.
Ok, so the problem happens (very rarely) when I press the sorting button. The way I'm doing it is after recovering or downloading the image, I update the image field in starshipData array.
Here is my sorting method, pretty basic.
@objc private func sortByCost(sender: UIBarButtonItem) {
starshipData.sort { $0.costInCredits < $1.costInCredits }
starshipTableView.reloadData()
}
Here are the implementations of the tableView.
First I use the cellForRowAt method to populate the fast/light data.
// MARK: -> cellForRowAt
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "StarshipCell", for: indexPath) as! StarshipCell
let starship = starshipData[indexPath.row]
// update cell properties
cell.starshipNameLabel.text = starship.name
cell.starshipManufacturerLabel.text = starship.manufacturer
cell.starshipCostLabel.text = currencyFormatter(value: starship.costInCredits)
// only populate the image if the array has one (the first time the table is populated,
// the array doesn't have an image, it'll need to download or fetch it in user documents)
if starship.image != nil {
cell.starshipImgView.image = starship.image
}
// adds right arrow indicator on the cell
cell.accessoryType = .disclosureIndicator
return cell
}
Here I use the willDisplay method to download or fetch the images, basically the heavier data.
// MARK: -> willDisplay
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// update cell image
let cell = cell as! StarshipCell
let imageUrl = starshipData[indexPath.row].imageUrl
let starshipName = starshipData[indexPath.row].name
let index = indexPath.row
// if there isn't any image on the cell, proceed to manage the image
if cell.starshipImgView.image == nil {
// only instantiate spinner on imageView position if no images are set
let spinner = UIActivityIndicatorView(style: .medium)
startSpinner(spinner: spinner, cell: cell)
// manage the image
imageManager(starshipName: starshipName, imageUrl: imageUrl, spinner: spinner, cell: cell, index: index) { (image) in
self.addImageToCell(cell: cell, spinner: spinner, image: image)
}
}
}
Here is where I think the problem is as my knowledge in swift and background threads are still in development.
I found out with print logs that the times the cell doesn't show the correct image is because the array does not have the image for that index, so the cell shows the image from the last time the table was populated/loaded.
I wonder if it's because the background threads didn't have enough time to update the starshipArray with the fetched/downloaded image before the user pushing the sort button.
The thing is, if the table was populated correctly the first time, when the sort button is pushed, the starshipData array should already have all images, as you can see in the imageManager method, after the image is unwrappedFromDocuments, I call updateArrayImage to update the image.
Maybe it's the amount of dispatchesQueues being used? Are the completion handler and dispatchQueues used correctly?
private func imageManager(starshipName: String, imageUrl: URL?, spinner: UIActivityIndicatorView, cell: StarshipCell, index: Int, completion: @escaping (UIImage) -> Void) {
// if json has a string on image_url value
if let unwrappedImageUrl = imageUrl {
// open a background thread to prevent ui freeze
DispatchQueue.global().async {
// tries to retrieve the image from documents folder
let imageFromDocuments = self.retrieveImage(imageName: starshipName)
// if image was retrieved from folder, upload it
if let unwrappedImageFromDocuments = imageFromDocuments {
// TO FORCE THE PROBLEM DESCRIBED, PREVENT ONE SHIP TO HAVE IT'S IMAGE UPDATED
// if (starshipName != "Star Destroyer") {
self.updateArrayImage(index: index, image: unwrappedImageFromDocuments)
// }
completion(unwrappedImageFromDocuments)
}
// if image wasn't retrieved or doesn't exists, try to download from the internet
else {
var image: UIImage?
self.downloadManager(imageUrl: unwrappedImageUrl) { data in
// if download was successful
if let unwrappedData = data {
// convert image data to image
image = UIImage(data: unwrappedData)
if let unwrappedImage = image {
self.updateArrayImage(index: index, image: unwrappedImage)
// save images locally on user documents folder so it can be used whenever it's needed
self.storeImage(image: unwrappedImage, imageName: starshipName)
completion(unwrappedImage)
}
}
// if download was not successful
else {
self.addImageNotFound(spinner: spinner, cell: cell)
}
}
}
}
}
// if json has null on image_url value
else {
addImageNotFound(spinner: spinner, cell: cell)
}
}
Here are some of the helper methods I use on imageManager, if necessary.
// MARK: - Helper Methods
private func updateArrayImage(index: Int, image: UIImage) {
// save image in the array so it can be used when cells are sorted
self.starshipData[index].image = image
}
private func downloadManager(imageUrl: URL, completion: @escaping (Data?) -> Void) {
let session: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 5
return URLSession(configuration: configuration, delegate: nil, delegateQueue: nil)
}()
var dataTask: URLSessionDataTask?
dataTask?.cancel()
dataTask = session.dataTask(with: imageUrl) { [weak self] data, response, error in
defer {
dataTask = nil
}
if let error = error {
// use error if necessary
DispatchQueue.main.async {
completion(nil)
}
}
else if let response = response as? HTTPURLResponse,
response.statusCode != 200 {
DispatchQueue.main.async {
completion(nil)
}
}
else if let data = data,
let response = response as? HTTPURLResponse,
response.statusCode == 200 { // Ok response
DispatchQueue.main.async {
completion(data)
}
}
}
dataTask?.resume()
}
private func addImageNotFound(spinner: UIActivityIndicatorView, cell: StarshipCell) {
spinner.stopAnimating()
cell.starshipImgView.image = #imageLiteral(resourceName: "ImageNotFound")
}
private func addImageToCell(cell: StarshipCell, spinner: UIActivityIndicatorView, image: UIImage) {
DispatchQueue.main.async {
spinner.stopAnimating()
cell.starshipImgView.image = image
}
}
private func imagePath(imageName: String) -> URL? {
let fileManager = FileManager.default
// path to save the images on documents directory
guard let documentPath = fileManager.urls(for: .documentDirectory,
in: FileManager.SearchPathDomainMask.userDomainMask).first else { return nil }
let appendedDocumentPath = documentPath.appendingPathComponent(imageName)
return appendedDocumentPath
}
private func retrieveImage(imageName: String) -> UIImage? {
if let imagePath = self.imagePath(imageName: imageName),
let imageData = FileManager.default.contents(atPath: imagePath.path),
let image = UIImage(data: imageData) {
return image
}
return nil
}
private func storeImage(image: UIImage, imageName: String) {
if let jpgRepresentation = image.jpegData(compressionQuality: 1) {
if let imagePath = self.imagePath(imageName: imageName) {
do {
try jpgRepresentation.write(to: imagePath,
options: .atomic)
} catch let err {
}
}
}
}
private func startSpinner(spinner: UIActivityIndicatorView, cell: StarshipCell) {
spinner.center = cell.starshipImgView.center
cell.starshipContentView.addSubview(spinner)
spinner.startAnimating()
}
}
To sum all up, here is the unordered table, when you open the app: unordered
The expected result (happens majority of time), after pushing the sort button: ordered
The wrong result (rarely happens), after pushing the sort button: error
I'll gladly add more info if needed, ty!
First, consider move the cell configuration for the UITableViewCell class. something like this:
class StarshipCell {
private var starshipNameLabel = UILabel()
private var starshipImgView = UIImageView()
func configure(with model: Starship) {
starshipNameLabel.text = model.name
starshipImgView.downloadedFrom(link: model.imageUrl)
}
}
Call the configure(with: Starship)
method in tableView(_:cellForRowAt:).
The method downloadedFrom(link: )
called inside the configure(with: Starship)
is provide by following extension
extension UIImageView {
func downloadedFrom(url: URL, contentMode mode: UIView.ContentMode = .scaleAspectFit) {
contentMode = mode
URLSession.shared.dataTask(with: url) { data, response, error in
guard let httpURLResponse = response as? HTTPURLResponse, httpURLResponse.statusCode == 200,
let mimeType = response?.mimeType, mimeType.hasPrefix("image"),
let data = data, error == nil,
let image = UIImage(data: data)
else { return }
DispatchQueue.main.async() {
self.image = image
}
}.resume()
}
func downloadedFrom(link: String?, contentMode mode: UIView.ContentMode = .scaleAspectFit) {
if let link = link {
guard let url = URL(string: link) else { return }
downloadedFrom(url: url, contentMode: mode)
}
}
}