Swift: Retain cycle with NSOperation

2019-06-26 17:44发布

问题:

In my app I use an image loader class to load images from the web for a collection view. The class keeps track of the download operations and cancels them when the cells for the images are no longer visible in the collection view. This implementation is based on the raywenderlich tutorial for NSOperation: http://www.raywenderlich.com/76341/use-nsoperation-nsoperationqueue-swift.

I use NSOperation for downloading an image from the web. I noticed with Instruments that none of the NSoperations is released. This creates an increase of the used memory for each image that is downloaded. In the completion block I references 'self'. So I figured out that I created a retain cycle.

I read a lot of examples on internet. I understand that I can use capture lists with 'weak self' or 'unowned self'. I tried this for the completion block, but still the operations are not released.

My code for the image loader class is as follows:

import Foundation
import UIKit

class ImageLoader {
    lazy var downloadsInProgress = [NSIndexPath:NSOperation]()  
    lazy var downloadQueue:NSOperationQueue = {
        var queue = NSOperationQueue()
        queue.name = "Image Download queue"
        return queue
    }()

    let cache = NSCache()       // contains NSData objects for images

    init() {
        // Max. cache size is 10% of available physical memory (in MB's)
        cache.totalCostLimit = 200 * 1024 * 1024    // TODO: change to 10%
    }

    /**
     * Download image based on url for given indexpath. 
     * The download is only started if the indexpath is still present in the downloadsInProgress array
     */

    func startDownloadForUrl(url: String, indexPath: NSIndexPath, completion: (imageData: NSData?) -> Void) {
        // check if download request is already present
        if downloadsInProgress[indexPath] != nil {
            return
        }

        // check cache
        if let imageData = self.cache.objectForKey(url) as? NSData {
            NSOperationQueue.mainQueue().addOperationWithBlock() {
                //remove indexpath from progress queue
                self.downloadsInProgress.removeValueForKey(indexPath)
                completion(imageData: imageData)
            }
            return
        }

        // prepare the download
        let downloader = ImageDownloader(url: url)

        downloader.completionBlock = {
            [unowned self] in

            if downloader.cancelled {
                return
            }

            // image is retrieved from web
            NSOperationQueue.mainQueue().addOperationWithBlock() {
                [unowned self] in

                //remove indexpath from progress queue
                self.downloadsInProgress.removeValueForKey(indexPath)

                // add image to cache
                if downloader.imageData != nil {
                    self.cache.setObject(downloader.imageData!, forKey: url, cost: downloader.imageData!.length)
                }
                completion(imageData: downloader.imageData)
            }
        }

        // add downloader to operations in progress and start the operation
    NSOperationQueue.mainQueue().addOperationWithBlock() {
            [unowned self] in

            self.downloadsInProgress[indexPath] = downloader
            self.downloadQueue.addOperation(downloader)
        }
    } 


    /**
     * Suspends queue for downloading images
     */

    func suspendAllOperations() {
        downloadQueue.suspended = true
    }


    /**
     * Resumes queue for downloading images
     */

    func resumeAllOperations() {
        downloadQueue.suspended = false
    }


    /**
     * Cancels downloads for NOT visible indexpaths. The parameter specifies an array of visible indexpaths!
     */

    func cancelDownloads(visibleIndexPaths: [NSIndexPath]) {
        let allPendingOperations = Set(downloadsInProgress.keys)
        let visiblePaths = Set(visibleIndexPaths)

        // cancel all pending operations for indexpaths that are not visible
        var toBeCancelled = allPendingOperations
        toBeCancelled.subtractInPlace(visiblePaths)

        for indexPath in toBeCancelled {
            if let pendingDownloadOperation = downloadsInProgress[indexPath] {
                pendingDownloadOperation.cancel()
            }

            downloadsInProgress.removeValueForKey(indexPath)
        }
    }
}


class ImageDownloader: NSOperation {
    var url: String
    var imageData: NSData?

    init(url: String) {
        self.url = url
    }

    override func main() {
        if self.cancelled {
            return
        }

        if let imageUrl = NSURL(string: url) {
            // retrieve data from web
            setNetworkActivityIndicatorVisible(true)
            imageData = NSData(contentsOfURL: imageUrl)
            setNetworkActivityIndicatorVisible(false)

            if self.cancelled {
                imageData = nil
                return
            }

            // scale image
            if imageData != nil {
                if let image = UIImage(data: imageData!) {
                    let imageData2 = UIImageJPEGRepresentation(image, 1.0)
                    let compressionRate = Float(imageData!.length) / Float(imageData2!.length)

                    let scaleWidth = 244 / image.size.width
                    let scaleHeight = 244 / image.size.height
                    let imageScale = min(scaleWidth, scaleHeight)

                    let rect = CGRectMake(0.0, 0.0, image.size.width * imageScale, image.size.height * imageScale)

                    UIGraphicsBeginImageContext(rect.size)
                    image.drawInRect(rect)
                    let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
                    let scaledImageData = UIImageJPEGRepresentation(scaledImage, CGFloat(compressionRate))
                    UIGraphicsEndImageContext()

                    imageData = scaledImageData
                }
            }
        }
    }

    private func setNetworkActivityIndicatorVisible(visible: Bool) {
        NSOperationQueue.mainQueue().addOperationWithBlock() {
            let appDelegate = UIApplication.sharedApplication().delegate as! AppDelegate
            appDelegate.setNetworkActivityIndicatorVisible(visible)
        }
    }
}

Where exactly do I create the retain cycle? And how do I solve this? When should I use 'unowned' and when should I use 'weak'?

I would appreciate it if someone can explain the solution, so I can learn from my mistake.

回答1:

I found the problem. The retain cycle is not caused by referencing self, but by referencing the NSOperation in the completion block of the NSOperation!

In the function startDownloadForUrl(...) I declare the variable downloader. Next I declare an completion block for this variable. In this completion block I reference the variable downloader. This causes the retain cycle.

I solved this by using [unowned downloader] within the completion block.

This created anaother problem. In the completion block I asynchronously call the main thread. In this call the variable downloader.imageData was used. Because of this asynchronous call, the NSOperation may be already ended and the variable downloader may not longer exists. To avoid crashes I declare a new variable for the imageData, so the data will still be available when used in the main thread.

The completion block now looks like:

downloader.completionBlock = {
    [unowned downloader] in
    if downloader.cancelled {
        return
    }

    let imageData = downloader.imageData    // retain the imageData. It will be used asynchrounous in the main thread. The downloader operation might already be finished and downloader will no longer exists.

    // image is retrieved from web
    NSOperationQueue.mainQueue().addOperationWithBlock() {
        //remove indexpath from progress queue
        self.downloadsInProgress.removeValueForKey(indexPath)

        // add image to cache
        if imageData != nil {
            self.cache.setObject(imageData!, forKey: url, cost: imageData!.length)
        }
        completion(imageData: imageData)
    }
}