Pangu
Pangu

Reputation: 3819

Initial scroll lag in UITableView cells with resized large images?

I'm basically having the same issue as this question:

Slow scroll on UITableView images

My UITableView contains a UIImageView that is 87x123 size.

When my UITableViewController is loaded, it first calls a function that loops through an array of images. These images are high resolutions stored from the photo library. In each iteration, it retrieves the image and resize each image down to 87x123, then stores it back into the original image in the array.

When all the images has been resized and stored, it calls self.tableView.reloadData to populate the data in the array into the cells.

However, like the mentioned question, my UITablView is choppy and lags if I scroll fast before all the images has been resized and stored in the array.

Here's the problematic code:

extension UIImage
{
    func resizeImage(originalImage: UIImage, scaledTo size: CGSize) -> UIImage
    {
        // Avoid redundant drawing
        if originalImage.size.equalTo(size)
        {
            return originalImage
        }

        UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
        originalImage.draw(in: CGRect(x: CGFloat(0.0), y: CGFloat(0.0), width: CGFloat(size.width), height: CGFloat(size.height)))

        let image = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()

        return image
    }
}

func loadImages()
{   
    DispatchQueue.global(qos: .background).async {

        for index in 0..<self.myArray.count
        {
            if let image = self.myArray[index].image
            {
                self.myArray[index].image = image.resizeImage(originalImage: image, scaledTo: CGSize(width: 87, height: 123) )
            }

            if index == self.myArray.count - 1
            {
                print("FINISHED RESIZING ALL IMAGES")
            }
        }
    }

    tableView.reloadData()
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{
    ...

    // Size is 87x123
    let thumbnailImage = cell.viewWithTag(1) as! UIImageView

    DispatchQueue.main.async{

        thumbnailImage.image = self.myArray[indexPath.row]

    }

    thumbnailImage.contentMode = UIViewContentMode.scaleAspectFill

    thumbnailImage.layer.borderColor = UIColor.black.cgColor
    thumbnailImage.layer.borderWidth = 1.0
    thumbnailImage.clipsToBounds = true

    return cell
}

I know to do any Non-UI operations in the background thread, which is what I do. Then all I do in cellForRowAt is load the image into the cell using its indexPath.row.

The problem is, as previously mentioned, if I start to scroll the UITableView BEFORE FINISHED RESIZING ALL IMAGES is printed out, i.e. before all the images has been resized, there is noticeable lag and slowness.

However, if I wait UNTIL all the images has been resized and FINISHED RESIZING ALL IMAGES is called before scrolling the UITableView, the scrolling is smooth without any lags.

I can put a loading indicator and have the user wait until all images has been resized and loaded into the cells before having user interaction, but that would be an annoyance since it takes about 8 seconds to resize all the high-res images (18 images to resize).

Is there a better way I can fix this lag?

UPDATE: Following @iWheelBuy's second example, I've implemented the following:

final class ResizeOperation: Operation {

    private(set) var image: UIImage
    let index: Int
    let size: CGSize

    init(image: UIImage, index: Int, size: CGSize) {
        self.image = image
        self.index = index
        self.size = size
        super.init()
    }

    override func main() {
        image = image.resizeImage(originalImage: image, scaledTo: size)
    }
}

class MyTableViewController: UITableViewController
{
    ...

    lazy var resizeQueue: OperationQueue = self.getQueue()

    var myArray: [Information] = []

    internal struct Information
    {
        var title: String?
        var image: UIImage?

        init()
        {

        }
    }

    override func viewDidLoad()
    {
        ....

        loadImages()

        ...
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
    {
        ...

        // Size is 87x123
        let thumbnailImage = cell.viewWithTag(1) as! UIImageView

        DispatchQueue.main.async{

            thumbnailImage.image = self.myArray[indexPath.row].image

        }

        thumbnailImage.contentMode = UIViewContentMode.scaleAspectFill

        return cell
    }

    func loadImages()
    {
        let size = CGSize(width: 87, height: 123)

        for item in myArray
        {
            let operations = self.myArray.enumerated().map({ ResizeOperation(image: item.image!, index: $0.offset, size: size) })

            operations.forEach { [weak queue = resizeQueue, weak controller = self] (operation) in
                operation.completionBlock = { [operation] in
                    DispatchQueue.main.async { [image = operation.image, index = operation.index] in

                        self.update(image: image, index: index)
                    }
                }
                queue?.addOperation(operation)
            }
        }
    }

    func update(image: UIImage, index: Int)
    {
        myArray[index].image = image

        tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: UITableViewRowAnimation.fade)

    }    
}

However, upon calling tableView.reloadRows, I receive a crash with the error:

attempt to delete row 0 from section 0, but there are only 0 sections before the update

I'm a bit confused on what it means and how to resolve it.

Upvotes: 1

Views: 1274

Answers (1)

iWheelBuy
iWheelBuy

Reputation: 5679

It is hard to determine the reason why you have lags. But there are some thoughts that might help you to make you code more performant.

Try using some small images at start and see, if it is the size of original image that influences bad performance. Also try to hide these lines and see if anything changes:

//    thumbnailImage.contentMode = UIViewContentMode.scaleAspectFill
//    thumbnailImage.layer.borderColor = UIColor.black.cgColor
//    thumbnailImage.layer.borderWidth = 1.0
//    thumbnailImage.clipsToBounds = true

Your code is a little bit oldschool, for loop in loadImages can become more readable with just a few lines of code by applying map or forEach to your image array.

Also, about your array of images. You read it from main thread and modify it from background thread. And you do it simultaneously. I'd suggest to make only image resizing on background... unless you are sure there will be no bad consequences

Check the code example #1 below how you current code can look like.

On the other hand, you can go some other way. For example you can set some placeholder image at start and update cells when image for some specific cell is ready. Not all images at once! If you go with some serial queue, you will get image updates every 0.5 seconds and the UI updates will be fine.

Check the code example #2. It wasn't tested, just to show the way you can go.

Btw, have you tried changing QualityOfService from background to userInitiated? It might decrease resizing time... or not (:

DispatchQueue.global(qos: .userInitiated).async {
    // code
}

Example #1

extension UIImage {

    func resize(to size: CGSize) -> UIImage {
        guard self.size.equalTo(size) else { return self }
        UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
        draw(in: CGRect(x: 0.0, y: 0.0, width: size.width, height: size.height))
        let image = UIGraphicsGetImageFromCurrentImageContext()!
        UIGraphicsEndImageContext()
        return image
    }

    static func resize(images: [UIImage], size: CGSize, completion: @escaping ([UIImage]) -> Void) {
        DispatchQueue.global(qos: .background).async {
            let newArray = images.map({ $0.resize(to: size) })
            DispatchQueue.main.async {
                completion(newArray)
            }
        }
    }
}

final class YourController: UITableViewController {

    var myArray: [UIImage] = []

    override func viewDidLoad() {
        super.viewDidLoad()
        self.loadImages()
    }
}

fileprivate extension YourController {

    func loadImages() {
        UIImage.resize(images: myArray, size: CGSize(width: 87, height: 123)) { [weak controller = self] (newArray) in
            guard let controller = controller else { return }
            controller.myArray = newArray
            controller.tableView.reloadData()
        }
    }
}

Example #2

final class ResizeOperation: Operation {

    private(set) var image: UIImage
    let index: Int
    let size: CGSize

    init(image: UIImage, index: Int, size: CGSize) {
        self.image = image
        self.index = index
        self.size = size
        super.init()
    }

    override func main() {
        image = image.resize(to: size)
    }
}

final class YourController: UITableViewController {

    var myArray: [UIImage] = []
    lazy var resizeQueue: OperationQueue = self.getQueue()

    override func viewDidLoad() {
        super.viewDidLoad()
        self.loadImages()
    }

    private func getQueue() -> OperationQueue {
        let queue = OperationQueue()
        queue.qualityOfService = .background
        queue.maxConcurrentOperationCount = 1
        return queue
    }
}

fileprivate extension YourController {

    func loadImages() {
        let size = CGSize(width: 87, height: 123)
        let operations = myArray.enumerated().map({ ResizeOperation(image: $0.element, index: $0.offset, size: size) })
        operations.forEach { [weak queue = resizeQueue, weak controller = self] (operation) in
            operation.completionBlock = { [operation] in
                DispatchQueue.main.async { [image = operation.image, index = operation.index] in
                    controller?.update(image: image, index: index)
                }
            }
            queue?.addOperation(operation)
        }
    }

    func update(image: UIImage, index: Int) {
        myArray[index] = image
        tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: UITableViewRowAnimation.fade)
    }
}

Upvotes: 2

Related Questions