Search code examples
iosswiftwatchkituser-experienceapple-watch

iOS WatchKit / Swift - Lazy Loading images in WKInterfaceTable rows


In my Apple Watch app, I have a table with about 25 rows, and each one has a few bits of text and an image that needs to be loaded from the internet. Similar to an Instagram-style feed, but these are profile images of about 8k each.

When I build the table based on incoming JSON data, I'm doing everything correctly, utilizing WatchKit's built in image caching, to reduce unnecessary network traffic.

The app "works" and the images display correctly. But the problem is it takes 10-20 seconds for the view to be ready for interaction from the user. Before those 10-20 seconds are up, scrolling is sluggish, and buttons don't do anything until all the images have loaded.

What I would like is some way to lazy load the images as the user scrolls down.

I've already tried implementing a paging solution, but that has its drawbacks as well, and doesn't solve my original problem entirely.

If I only populate the text bits (no images), the table displays instantly and is ready for interaction.


***UPDATE: Mike Swanson's answer below was extremely thorough and did solve most of my problem. The sluggishness and blocking have gone away.

I wanted to post the final code for anybody else who comes along and has a similar issue. Specifically, I wanted to show how I implemented the background dispatch.

I'm now also temporarily setting the image to the placeholder during the main thread to show activity. It gets replaced as soon as the actual image loads and transmits to the watch. I also improved the image cache key to miss if a new image has been uploaded.

NEW CODE:

let qualityOfServiceClass = QOS_CLASS_BACKGROUND
let backgroundQueue = dispatch_get_global_queue(qualityOfServiceClass, 0)

i = 0
for p in self.profiles {
    if let profileId = p["id"] as? NSNumber {
        var profileIdStr = "\(profileId)"
        let row = self.profilesTable.rowControllerAtIndex(i) as! WatchProfileRow

        if let hasImage = p["has_image"] as? NSNumber {
            let profileImageCacheKey = "\(profileIdStr)-\(hasImage)"

            // if the image is already in cache, show it
            if self.device.cachedImages[profileImageCacheKey] != nil {
                row.profileImageThumbnail.setImageNamed(profileImageCacheKey)

                // otherwise, get the image from the cdn and cache it
            } else {
                if let imageHost = self.imageHost as String! {
                    var imageURL = NSURL(string: NSString(format: "%@%@-thumbnail?version=%@", imageHost, profileId, hasImage) as String)

                    var imageRequest = NSURLRequest(URL: imageURL!)

                    NSURLConnection.sendAsynchronousRequest(imageRequest, queue: NSOperationQueue.mainQueue()) {
                        (response, data, error) -> Void in
                        if error == nil {
                            row.profileImageThumbnail.setImageNamed("profile-placeholder")

                            dispatch_async(backgroundQueue, {
                                self.device.addCachedImageWithData(data, name: profileImageCacheKey)

                                dispatch_async(dispatch_get_main_queue(), { () -> Void in
                                    row.profileImageThumbnail.setImageNamed(profileImageCacheKey)
                                })
                            })
                        }
                    }
                } else {
                    row.profileImageThumbnail.setImageNamed("profile-placeholder")
                }
            }
        } else {
            row.profileImageThumbnail.setImageNamed("profile-placeholder")
        }

    }

    i++
}

ORIGINAL CODE:

i = 0
for p in self.profiles {
    if let profileId = p["id"] as? NSNumber {
        var profileIdStr = "\(profileId)"
        let row = self.profilesTable.rowControllerAtIndex(i) as! WatchProfileRow

        if let hasImage = p["has_image"] as? NSNumber {

            // if the image is already in cache, show it
            if self.device.cachedImages[profileIdStr] != nil {
                row.profileImageThumbnail.setImageNamed(profileIdStr)

            // otherwise, get the image from the cdn and cache it
            } else {
                if let imageHost = self.imageHost as String! {
                    var imageURL = NSURL(string: NSString(format: "%@%@-thumbnail?version=%@", imageHost, profileId, hasImage) as String)

                    var imageRequest = NSURLRequest(URL: imageURL!)

                    NSURLConnection.sendAsynchronousRequest(imageRequest, queue: NSOperationQueue.mainQueue()) {
                        (response, data, error) -> Void in
                        if error == nil {
                            var image = UIImage(data: data)
                            row.profileImageThumbnail.setImage(image)

                            self.device.addCachedImage(image!, name: profileIdStr)
                        }
                    }
                } else {
                    row.profileImageThumbnail.setImageNamed("profile-placeholder")
                }
            }
        } else {
            row.profileImageThumbnail.setImageNamed("profile-placeholder")
        }   
    }        
    i++
}

Solution

  • First, there is no method in WatchKit to determine the current position in a WKInterfaceTable. That means that any logic you use will have to work around that fact.

    Second, it looks like you're sending an asynchronous network request then using setImage when you have the data. setImage encodes the image as a PNG before transmitting it to the device, and in your existing code, this is happening on the main thread. If you don't need a PNG image, I'd consider using setImageData to send JPEG-encoded data to further reduce the number of bytes that need to be transmitted to the Watch.

    Finally, it appears that you're double-transmitting your images. Once with setImage and twice with addCachedImage. After you receive your image, I'd suggest dispatching to a background thread, then using WKInterfaceDevice's addCachedImageWithData (which can safely be used on a background thread) to send the data across. Then, dispatch back to the main thread before setting the now-cached image with setImageNamed.