EGregory-esristaff

I ❤️ <AGSLoadable>

Blog Post created by EGregory-esristaff Employee on Apr 25, 2019

I'm not going to hide it, I love the ArcGIS Runtime's loadable design pattern. We find the loadable pattern across the ArcGIS runtime; no doubt you interact with it often.

In short, the pattern allows you to work with an object that might take some time figuring stuff out before it’s ready to be used. For example, it might depend on multiple (possibly remote) resources, sometimes in sequence, before it knows enough about itself to be usable. Once these resources are retrieved, the loadable object executes a callback block signaling that it's ready. An AGSMap is a good concrete example as it might need to load multiple remote layers before it knows what extent and spatial reference to use.

Some other qualities of a good loadable object include:

  • calling the completion block on a suitable thread based off the thread the load was started from (see this blog post).
  • providing an observable loadStatus and loadError, though generally you just wait for the completion block to be called.
  • handling load failure elegantly with the option to retry later if needed (perhaps the network connection was interrupted).

As an added bonus, a full implementation of <AGSLoadable> can be found in AGSLoadableBase, a class designed to be subclassed (and which saves you reinventing a lot of wheels, doing a lot of the heavy lifting of load state and callback thread considerations).

"It's only a protocol," you might say. "You can't always subclass AGSLoadablebase," you might suggest. "Try implementing AGSLoadable yourself, do you still love it?" you might pronounce.

Hot take, but of course you'd be right. As an engineer, I'm tickled by problems like this and eagerly look for solutions.

Today I'd like to share with you a delightful maneuver that allows any class to adhere to <AGSLoadable> with neither the need for a custom async implementation (yikes) nor subclassing AGSLoadableBase.

I'd like to introduce you to LoadableSurrogate & <LoadableSurrogateProxy>, a member/protocol solution that offloads the heavy lifting of async loading onto a surrogate loader.

There are two actors in this maneuver, engaged in a parent/child delegate-like relationship:

  1. LoadableSurrogate is a concrete subclass of AGSLoadableBase that routes messages to a proxy object.
  2. A proxy object that adheres to <LoadableSurrogateProxy> we'd like to make loadable with the help of a loadable surrogate.

A class can leverage this tool by creating a LoadableSurrogate member, adhering to <LoadableSurrogateProxy> and specifying the LoadableSurrogate's proxy.

In this example, I've built a simple loader that downloads an image of (my favorite muppet) Kermit the Frog hanging out on the legendary Hollywood walk of fame.

class KermitLoader: NSObject, LoadableSurrogateProxy { }

I can use the kermit loader object like any other <AGSLoadable>:

let kermitLoader = KermitLoader()  kermitLoader.load { (error) in  
  
    if let error = error {        
        print("Error: \(error.localizedDescription)")    
    }

    imageView.image = kermitLoader.kermitImage
}

The kermit loader is initialized with a LoadableSurrogate member, assigning the surrogate's proxy to self.

class KermitLoader: NSObject, LoadableSurrogateProxy {

    private let surrogate = LoadableSurrogate()         

    override init() {
        super.init()        
        surrogate.proxy = self
    }    
    /* ...

For the kermit loader to conform to <LoadableSurrogateProxy> it must also conform to <AGSLoadable>. Conveniently, all <AGSLoadable> methods can be piped through the surrogate.

    ... */
    func load(completion: ((Error?) -> Void)? = nil) {
        surrogate.load(completion: completion)    
    }         

    func retryLoad(completion: ((Error?) -> Void)? = nil) {        
        surrogate.retryLoad(completion: completion)    
    }         

    func
cancelLoad() {        
        surrogate.cancelLoad()    
    }    
    /* ...

Following the same pattern outlined above, you might opt to compute the <AGSLoadable> properties loadStatus and loadError on the fly, getting those values from the surrogate.

Instead, I've opted to persist those properties and thus, expose them to KVO.

    ... */
    @objc var loadStatus: AGSLoadStatus = .unknown

    @objc var loadError: Error? = nil
    //
    // Proxy informs of changes to `loadStatus` and `loadError`.
    //

    func loadStatusDidChange(_ status: AGSLoadStatus) {        
        self.loadStatus = status    
    }         

    func loadErrorDidChange(_ error: Error?) {        
        self.loadError = error    
    }    
    /* ...

Everything we've seen up until this point is boilerplate and can be copied and pasted. Let's get to the good stuff.

First, in order to perform the loadable operation we'll need to set up some resources and properties. We need a URL to the image, a data task, and of course a reference to the loaded image.

    ... */
    private let kermitURL = URL(string: "https://c1.staticflickr.com/2/1033/1024297684_582bc1c05a_b.jpg")!

    private var kermitSessionDataTask: URLSessionDataTask?

    var kermitImage: UIImage? = nil
    /* ...

What comes next is the custom loadable implementation. If you have ever subclassed AGSLoadableBase directly, this should feel familiar.

The proxy object is responsible for starting the load and completing with an error or nil, depending on the success of the operation. The proxy object is also responsible for canceling any async operations as well.

    ... */
    func doStartLoading(_ retrying: Bool, completion: @escaping (Error?) -> Void) {   

        if retrying {                         
            let previousDataTask = kermitSessionDataTask            
            kermitSessionDataTask = nil
            previousDataTask?.cancel()            
            kermitImage = nil
        }                 

        kermitSessionDataTask = URLSession.shared.dataTask(with: kermitURL) { [weak self] data, response, error in

            guard let self = self else { return }                         

            if let data = data, let image = UIImage(data: data) {                
                self.kermitImage = image            
            }                         

            if response == self.kermitSessionDataTask?.response {                
                completion(error)            
            }        
        }                 

        kermitSessionDataTask!.resume()    
    }    
    /* ...

The proxy object is also responsible for canceling running operations. If you want the surrogate to supply a generic CancelledError, you can return true. In this example the data task reliably provides its own cancel error in the task's callback and thus we return false.

    ... */
    func doCancelLoading() -> Bool {                 
        kermitSessionDataTask?.cancel()        
        kermitSessionDataTask = nil
        kermitImage = nil
        // Returns `false` because the URLSession returns a cancel error in the completion callback.
        // Return `true` if you want the surrogate to supply a generic cancel error.
        return false
     }
}

Cool! Now let's take a look under the hood of the LoadableSurrogate, powering much of the kermit loader.

To start, a LoadableSurrogate is a subclass of AGSLoadableBase.

class LoadableSurrogate: AGSLoadableBase { /* ...

A LoadableSurrogate passes messages to a proxy object. As you saw in the KermitLoader.init(), the kermit loader specifies itself as the proxy.

    ... */
    weak var proxy: LoadableSurrogateProxy? {        
        didSet {            
            proxy?.loadStatusDidChange(loadStatus)            
            proxy?.loadErrorDidChange(loadError)        
        }    
    }    
    /* ...

A LoadableSurrogate observes loadError and loadStatus so that it may immediately inform the proxy of changes to either of these properties.

... */
    // Cocoa requires we hold on to observers.
    private var kvo: Set<NSKeyValueObservation> = []         

    override init() {           

        super.init()     

        let loadStatusObservation = self.observe(\.loadStatus) { [weak self] (_, _) in

            guard let self = self else { return }                         

            self.proxy?.loadStatusDidChange(self.loadStatus)        
        }                 

        kvo.insert(loadStatusObservation)                 

        let loadErrorObservation = self.observe(\.loadError) { [weak self] (_, _) in

            guard let self = self else { return }                         

            self.proxy?.loadErrorDidChange(self.loadError)        
        }                 

        kvo.insert(loadErrorObservation)    
    }    
    /* ...

And finally a LoadableSurrogate handles piping the loadable method calls to and from the proxy.

    ... */
    private let UnknownError = NSError(domain: "LoadableSurrogate.UnknownError", code: 1, userInfo: [NSLocalizedDescriptionKey: "An unknown error occurred."])         

    override func doStartLoading(_ retrying: Bool) {                 

        // We want to unwrap the delegate, if we have one.
        if let proxy = proxy {               

            // Call start loading on the delegate.
            proxy.doStartLoading(retrying) { [weak self] (error) in

                guard let self = self else { return }                                 

                // Finish loading with the reponse from the delegate.
                self.loadDidFinishWithError(error)            
            }        
        }        
        else {            
            // No delegate, finish loading.
            loadDidFinishWithError(UnknownError)        
        }    
    }

    private let CancelledError = NSError(domain: "LoadableSurrogate.CancelledError", code: NSUserCancelledError, userInfo: [NSLocalizedDescriptionKey: "User did cancel."])     

    override func doCancelLoading() {  

        // Call cancel delegate method.
        if proxy?.doCancelLoading() == true {                         

            self.loadDidFinishWithError(CancelledError)        
        }    
    }
}

To see this maneuver in action, have a look at this playground.

Happy loading!

Outcomes