iosswiftuitableviewgrand-central-dispatchmpmedialibrary

Processing image collage for UITableViewCells – Method that would scale for hundreds of cells?


Problem: I am using art assets from music accessed via the apple APIs (all local) to create a 4 image collage as the image in the cell for a playlist in a table view. I have tried a few methods which I will go over to get performance to an acceptable level, but there is always some kind of hiccup.

enter image description here

Method 1) Ran the processing code to return a collaged image for each cell. This was always fine for the few playlists I had on my device, but I got reports from heavy users that they couldn't scroll.

Method 2) In viewdidload(), iterated over all the playlists and saved the collaged image and one of the UUIDs in a mutable array then fetched the image for the cells using the UUID. This seems to work ok with a delay on loading the view - but for some reason subsequent loads are taking a longer time.

So before I try to troubleshoot method 2 more, I am wondering if I am straight up going about this the completely wrong way? I have looked in to GCD and NSCache, and as far as GCD is concerned I don't know enough to make a proper design pattern to exploit it, if it is even possible since things like UI updates and storage access might be whats getting in the way.

import UIKit
import MediaPlayer


class playlists: UITableViewController, UISearchBarDelegate, UISearchControllerDelegate {
...
    var compositedCellImages:[(UIImage, UInt64)] = []
...
    override func viewDidLoad() {
        super.viewDidLoad()

        let cloudFilter:MPMediaPropertyPredicate = MPMediaPropertyPredicate(value: false, forProperty: MPMediaItemPropertyIsCloudItem, comparisonType: MPMediaPredicateComparison.equalTo)
        playlistsQuery.addFilterPredicate(cloudFilter)

        playlistQueryCollections = playlistsQuery.collections?.filter{$0.value(forProperty: MPMediaPlaylistPropertyName) as? String != "Purchased"} as NSArray?

        var tmpArray:[MPMediaPlaylist] = []
        playlists = playlistQueryCollections as! [MPMediaPlaylist]

        for playlist in playlists {
            if playlist.value(forProperty: "parentPersistentID") as! NSNumber! == playlistFolderID {
                tmpArray.append(playlist)
                compositedCellImages.append(playlistListImage(inputPlaylistID: playlist.persistentID))
            }
        }
        playlists = tmpArray
...
}
    // MARK: - Table view data source
...
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = self.tableView.dequeueReusableCell(withIdentifier: "playlistCell", for: indexPath) as! playlistCell
...
            let currentItem = playlists[indexPath.row]
...
             cell.playlistCellImage.image = compositedCellImages[indexPath.row].0
            }
        }
        return cell
    }
...
func playlistListImage(inputPlaylistID:MPMediaEntityPersistentID) -> (UIImage,UInt64) {
    var playlistData:[MPMediaItem] = []
    var pickedArtwork:[UIImage] = []
    var shuffledIndexes:[Int] = []
    let playlistDetailImage:collageImageView = collageImageView()
    playlistDetailImage.frame = CGRect(x: 0, y: 0, width: 128, height: 128)

    let playlistDataPredicate = MPMediaPropertyPredicate(value: NSNumber(value: inputPlaylistID as UInt64), forProperty: MPMediaPlaylistPropertyPersistentID, comparisonType:MPMediaPredicateComparison.equalTo)
    let playlistDataQuery = MPMediaQuery.playlists()

    let cloudFilter:MPMediaPropertyPredicate = MPMediaPropertyPredicate(value: false, forProperty: MPMediaItemPropertyIsCloudItem, comparisonType: MPMediaPredicateComparison.equalTo)
    playlistDataQuery.addFilterPredicate(cloudFilter)

    playlistDataQuery.addFilterPredicate(playlistDataPredicate)
    playlistData = playlistDataQuery.items!
    playlistData = playlistData.filter{$0.mediaType == MPMediaType.music}

    for (index,_) in playlistData.enumerated() {
        shuffledIndexes.append(index)
    }

   shuffledIndexes.shuffleInPlace()

    for (_,element) in shuffledIndexes.enumerated() {
        if playlistData[element].artwork != nil {
            pickedArtwork.append(playlistData[element].artwork!.image(at: CGSize(width: 64, height: 64))!)
        }
        if pickedArtwork.count == 4 { break }
    }

    while pickedArtwork.count < 4 {
        if pickedArtwork.count == 0 {
            pickedArtwork.append(UIImage(named: "missing")!)
        } else {
            pickedArtwork.shuffleInPlace()
        pickedArtwork.append(pickedArtwork[0])
        }
    }

pickedArtwork.shuffleInPlace()

playlistDetailImage.drawInContext(pickedArtwork, matrixSize: 2)

return ((playlistDetailImage.image)!,inputPlaylistID)
}
...
}
...
class collageImageView: UIImageView {
    var inputImages:[UIImage] = []
    var rows:Int = 1
    var cols:Int = 1

    func drawInContext(_ imageSet: [UIImage], matrixSize: Int) {
        let frameLeg:Int = Int(self.frame.width/CGFloat(matrixSize))
        var increment:Int = 0

        UIGraphicsBeginImageContextWithOptions(self.frame.size, false, UIScreen.main.scale)
        self.image?.draw(in: self.frame)
        for col in 1...matrixSize {
            for row in 1...matrixSize {
                imageSet[increment].draw(in: CGRect(x: (row - 1) * frameLeg, y: (col-1) * frameLeg, width: frameLeg, height: frameLeg))
                increment += 1
            }
        }
        self.image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
    }
}

Solution

  • One approach is to modify your original Method 1 but add lazy loading and caching.

    For an example if how to do lazy loading see https://developer.apple.com/library/content/samplecode/LazyTableImages/Introduction/Intro.html

    The basic idea is that you only attempt to compute the image when scrolling has completed (in the above example they are loading the image from a URL, but you would replace that with a calculation).

    In addition, you can cache the results of the calculation for each row, so that as the user scrolls back and forth, you can check for cached values first. Also clear the cache in didReceiveMemoryWarning.

    So in tableView(_: UITableView, cellForRowAt: IndexPath)

    if <cache contains image for row> {
      cell.playlistCellImage.image = <cached image>
    } else if tableView.isDragging && !tableView.isDecelerating {
       let image = <calculate image for row>
       <add image to cache>
       cell.playlistCellImage.image = image
    } else {
       cell.playlistCellImage.image = <placeholder image>
    }
    

    Then override the delegate methods for scroll view

    override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
        loadImagesForOnScreenRows()
    }
    
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        loadImagesForOnScreenRows()
    }
    

    And implement loadImagesForOnScreenRows()

    for path in tableView.indexPathsForVisibleRows {
        let image = <calculate image for row>
        <add image to cache>
        if let cell = tableView.cellForRow(at: path) {
            cell.playlistCellImage.image = image
        }
    }
    

    One last optimisation would be to push the actual calculation onto a background thread but you should find the lazy loading above is probably enough.

    UPDATE - To compute on a background thread.

    The general idea is to run the calculation using DispatchQueue on a background queue, then when the results are ready, update the display on the UI thread.

    Something like the following (untested) code

    func loadImageInBackground(inputPlaylistID:MPMediaEntityPersistentID, completion:@escaping (UIImage, UInt64)) 
    {
        let backgroundQueue = DispatchQueue.global(dos: DispatchQoS.QoSClass.background)
    backgroundQueue.async {
           let (image,n) = playlistListImage(inputPlaylistID:inputPlaylistID)
           DispatchQueue.main.async {
               completion(image,n)
           }
        }
    
    }
    

    And in tableView(_: UITableView, cellForRowAt: IndexPath) instead of computing the image directly, call the background method:

    if <cache contains image for row> {
      cell.playlistCellImage.image = <cached image>
    } else if tableView.isDragging && !tableView.isDecelerating {
       cell.playlistCellImage.image = <placeholder image>
       loadImageInBackground(...) {
          (image, n) in 
              if let cell = tableView.cellForRow(at:indexPath) {        
                 cell.playlistCellImage.image = image
              }
           <add image to cache>
       }
    } else {
       cell.playlistCellImage.image = <placeholder image>
    }
    

    and similar update in loadImagesForOnScreenRows().

    Notice the extra code to retrieve the cell again within the callback handler. Because this update can occur asynchronously, it is quite possible for the original cell to have been reused, so you need to make sure you are updating the correct cell