MapBox proper loading and offline caching

Johan Attali bio photo By Johan Attali

Note: Please note that this article is referring to the old MapBox 1.x SDK and not MapBoxGL Native. The reason behind this is that at the time of this writing the MapBoxGL Native is still a little behind of the old SDK in terms of features.

Introduction:

MapBox is a great SDK that I think is extremely well written. It's very useful if you want to have custom maps rather than the Apple one.

But you if you want to use it properly you'll notice there's some tweaking that needs to get done. Also, if you want to add caching to MapBox you'll notice that the documentation doesn't go into high details. The purpose of this post is to dive into the details of this great SDK.

doesn't go into high details, but there's a class that's worth digging into: RMTileCache.

Loading the RMMapView

The most basic way to load a RMMapView is to go through a RMMapboxSource instance.

let onlineSource = RMMapboxSource(mapID: kMapBoxMapID)
let mapView = RMMapView(frame: self.view.bounds, andTilesource: onlineSource)

However you'll notice two things:

  1. The RMMapboxSource(mapID: kMapBoxMapID) is blocking the thread in which this call is made.
  2. If you're offline the RMMapboxSource(mapID: kMapBoxMapID) return nil and crashes the subsequent call

To overcome this we should load the RMMapboxSource asynchronously which can be easily achieved with GCD:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
  let onlineSource = RMMapboxSource(mapID: kMapBoxMapID)
  guard onlineSource != nil else { throw NSError("your.domain", code: 0)  }
  onlineSource.cacheable = true

  dispatch_async(dispatch_get_main_queue()) {
    let mapView = RMMapView(frame: self.view.bounds, andTilesource: onlineSource)
  }
}

Notice how we make sure the onlineSource is not nil through the guard statement. We also prepare the map to be cached through the cacheable property.

The caveat of this technique obviously is that if your app loads this code while offine, it'll simply fail to load the RMMapView instance.

Adding Offline Caching to MapBox

We can add some caching to overcome this effect. To do so, we can have a look at the code inside RMTileCache.

Inside this class, the MapBox team created a friendly RMTileCacheProtocol which you simply have to conform to create your own caching mechanism. Thankfully, you also don't have to write your own as the MapBox SDK already has a RMDatabaseCache. Now comes the tricky part setting this whole thing up.

Firt we'll create two extensions methods for the RMMapboxSource class. They will act as helper methods to get our caching setup properly.

extension RMMapboxSource {

  // returns the saved source from the cache directory if it exists
  class func cj_savedSource() -> RMMapboxSource? {
      if let pathStr = NSSearchPathForDirectoriesInDomains(.CachesDirectory, .UserDomainMask, true).first,
         let source = RMMapboxSource(referenceURL: NSURL(fileURLWithPath: "\(pathStr)/\(kMapBoxCacheName)")) {
            return source
      }
      return nil
  }

  // saves the current map cache into the cache directory.
  func cj_saveTiles() -> Bool {
    if let pathStr = NSSearchPathForDirectoriesInDomains(.CachesDirectory, .UserDomainMask, true).first,
      let json = self.tileJSON {
        do {
          try json.writeToFile("\(pathStr)/\(kMapBoxCacheName)", atomically: true, encoding: NSUTF8StringEncoding)
          return true
        }
        catch _ { return false }
    }
    return false
  }
}

Now we can have the full loading to happen.

func loadMapView() {
  let database = RMDatabaseCache(usingCacheDir: false)
  let offlineSource = RMMapboxSource.cj_savedSource()

  // loading closure for both offline and online sources
  let loadingBlock: ((mapView: RMMapView, source: RMMapboxSource) -> Void) = { mapView, source in
      self.mapView = mapView
      self.mapView.tileCache.insertCache(database, atIndex: 0)
      self.mapView.tileCache.beginBackgroundCacheForTileSource(source, southWest: sw, northEast: ne, minZoom: 0, maxZoom: maxZoom)

      // additional  setup goes here...
  }

  // offline source available
  if let offlineSource = offlineSource {
      let mapView = RMMapView(frame: self.view.bounds, andTilesource: offlineSource)
      loadingBlock(mapView: mapView, source: offlineSource)
  }

  // in any case, load the online source as well
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    let onlineSource = RMMapboxSource(mapID: kMapBoxMapID)
    guard onlineSource != nil else { throw NSError(domain: "", code: 0) }

    onlineSource.cacheable = true

    dispatch_async(dispatch_get_main_queue()) {
      // if no offline source was loaded, load the current view
      if offlineSource == nil {
          let mapView = RMMapView(frame: self.view.bounds, andTilesource: onlineSource)
          loadingBlock(mapView: mapView, source: onlineSource)
      }
      // if offline source was loaded insert the online source
      else {
          self.mapView.addTileSource(onlineSource)
      }
    }
  }
}

Now while this setup is pretty decent and solid, there's still one thing missing, saving the cache when the app goes in the background. This can easily be achieved with the help of our cj_saveTiles method:

// somewhere in the `viewDidLoad`
NSNotificationCenter.defaultCenter().addObserver(self, selector: "saveTiles:", name: UIApplicationWillResignActiveNotification, object: nil)

// saveTiles
func saveTiles(notification: NSNotification) {
  if let source = mapView.tileSources.last as? RMMapboxSource {
    source.cj_saveTiles()
  }
}

Conclusion

MapBox is a great API but it requires some special care if you want to load it properly and make good usage of the caching mechanics. I will probably make another post after I migrate our codebase to use the new MapBoxGL Native.

If you have any questions, feel free to send me tweet.