{
  "version": "https://jsonfeed.org/version/1",
  "title": "Ian's Digital Garden",
  "home_page_url": "https://ianwwagner.com/",
  "feed_url": "https://ianwwagner.com//tag-ios.json",
  "description": "",
  "items": [
    {
      "id": "https://ianwwagner.com//reverse-engineering-better-mapkit-tile-overlays.html",
      "url": "https://ianwwagner.com//reverse-engineering-better-mapkit-tile-overlays.html",
      "title": "Reverse Engineering Better MapKit Tile Overlays",
      "content_html": "<p>Despite nearly 15 years of developing iOS apps on a daily basis,\nit’s occasionally jarring to go back to some of the older APIs.\nToday's blog is about <a href=\"https://developer.apple.com/documentation/mapkit/\">MapKit</a>,\nwhich isn't quite as old as CoreGraphics, but dates back to iOS 3.0!\nIt was a bit slimmer back then, but some of the APIs we'll be looking at today go back to iOS 7!</p>\n<p>To set the stage, <code>MKMapView</code>, the main &quot;view&quot; in MapKit,\nlets you add a sort of minimalist Apple Maps experience to your app in approximately one line of code.\nOr no code if you're using Storyboards / Interface Builder.\nThis is about 100x easier than it is on any other platform I can think of.</p>\n<p>But map-centric user experiences almost never stop with just a basemap.\nYou probably want to throw some data on top, like the outline of an area of interest,\nmaybe filled in with some color coding.\nOr maybe a route line, or a &quot;pin&quot; to show all the nearby hotels and how much they charge per night.\nThese are all <em>vector</em> data in industry jargon.</p>\n<p>One of my former clients was an agricultural tech startup that was later acquired\nby a company with a large business related to weather data.\nIt turns out weather is really important to farmers.\nAnd a lot of weather data, like radar and expected precipitation,\nare displayed as a <em>raster</em> overlay on the map.\nIn this case, usually transparently.</p>\n<p>A second use case for overlays is custom cartography.\nApple's maps are fine (superb, actually!) for many use cases,\nbut you can't customize very much about them.\nThis might be a dealbreaker if you need something outside the box\nlike hillshading or ocean depth,\nor if you want to swap out the satellite imagery for something more locally up to date.</p>\n<p>Overlays are the solution to this too; you can replace the entire base layer with your own!\n(Sadly MapKit can't, and probably won't ever support slick vector tile rendering from third-party sources.\nCheck out <a href=\"https://maplibre.org/\">MapLibre Native</a> instead for vector basemaps and a whole lot more.)</p>\n<p>Today's post is about overlays in MapKit,\nsome surprising behaviors that I found along the way,\nand maybe even a few bits of <a href=\"https://xkcd.com/979/\">ancient wisdom</a>\nto share on StackOverflow.</p>\n<h1><a href=\"#a-tale-of-two-mapkits\" aria-hidden=\"true\" class=\"anchor\" id=\"a-tale-of-two-mapkits\"></a>A Tale of two MapKits</h1>\n<p>MapKit has long had support for overlays.\nPer <a href=\"https://developer.apple.com/documentation/mapkit/mkoverlayrenderer\">Apple's docs</a>,\nit looks like user overlays were added in iOS 7.\nBut if you poke around closely,\nyou might notice that the MapKit docs are subtly split into two APIs:\n<em>MapKit for AppKit and UIKit</em>, and <em>MapKit for SwiftUI</em>.</p>\n<p>The SwiftUI docs aren't just about a nicer way to use <code>MKMapView</code> in your SwiftUI apps;\nit's about a completely different API, still under the MapKit umbrella.\nIn contrast to the old API where you have to implement a delegate just to add something to the map,\nthe new API is relatively modern.\nBut it's missing a few things.\nAnd the largest hole of them all is.... you guessed it! No overlays!</p>\n<p>To be fair to Apple, the new API is pretty new.\nAnd it probably seems like the use cases for raster overlays are fairly niche,\nbut I'm a bit confused why Apple dropped this functionality.\nSo if you're building a live weather radar app,\nyou need to use the AppKit and UIKit variant of MapKit.\nOr MapLibre.</p>\n<p>But let's say you actually <em>do</em> need to use MapKit for this purpose?\nThere are at least two good reasons you might want to:</p>\n<ul>\n<li><strong>One less dependency</strong>: if you absolutely can't afford a (very few) extra megabytes, MapKit makes sense since it's bundled with the OS.</li>\n<li><strong>Broad device support</strong>: it's probably possible to build maps with another framework on niche platforms like watchOS and visionOS, but MapKit just works ™.</li>\n</ul>\n<h1><a href=\"#diving-into-mktileoverlay\" aria-hidden=\"true\" class=\"anchor\" id=\"diving-into-mktileoverlay\"></a>Diving into <code>MKTileOverlay</code></h1>\n<p>Ok, let's take a look at the APIs here.\nThe first one we'll look at is <a href=\"https://developer.apple.com/documentation/mapkit/mktileoverlay\"><code>MKTileOverlay</code></a>.\nThis is a pretty straightforward class that describes a tile-based data source.\n(If you've used maps for a while, you may occasionally notice when the map fills in like a mosaic;\ninternally it's made up of these &quot;tiles&quot; that are stitched together client-side.)\nIt has properties describing the valid zoom range\nand a few ways of specifying where to get the tiles.</p>\n<p>The constructor takes a <code>urlTemplate</code> string argument.\nThe template looks like this: <code>https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png?api_key=YOUR-API-KEY</code>.\nThis is supposed to &quot;just work.&quot;\nBut if you need something a bit more advanced,\nyou can implement <code>url(forTilePath:)</code> instead to return a URL.</p>\n<p>This sounds like an &quot;oh, that's nice&quot; sort of API,\nbut it's surprisingly useful.\nFor example, they support a <code>{scale}</code> placeholder,\nbut there is no <code>maximumScale</code> parameter in the public interface.\nFor reasons that should be obvious, few if any tile servers\nare investing in rendering out PNG tiles at triple the normal resolution,\nso <code>@2x</code> is the max that most will support.</p>\n<p>If you implement <code>url(forTilePath:)</code>, the constructor parameter is ignored.\nA rather clunky method of encapsulating things,\nbut I'll give the Apple engineers some slack,\nas this dates back to the era when object-oriented programming reigned supreme\nand we hadn't rediscovered the joy of protocols yet.</p>\n<p>Finally, if you want even <em>more</em> control,\nyou have <code>loadTile(at:result:)</code>, which asynchronously loads the tile data.\nThis gives you ultimate freedom in how you make your network request,\nif you make a network request at all, how you cache tiles, etc.\nWe'll revisit this in a bit.</p>\n<h1><a href=\"#adding-an-overlay-to-the-map\" aria-hidden=\"true\" class=\"anchor\" id=\"adding-an-overlay-to-the-map\"></a>Adding an overlay to the map</h1>\n<p>Adding an overlay to the map is not quite as straightforward as <code>mapView.addOverlay(overlay)</code>.\nThe design of <code>MKMapView</code> is quite flexible... so much so that you <em>have</em> to implement\n<code>mapView(_:rendererFor:)</code> on your delegate, or else no overlays will render!\nEnter <a href=\"https://developer.apple.com/documentation/mapkit/mktileoverlayrenderer\"><code>MKTileOverlayRenderer</code></a>.\nThe map view itself doesn't know what to do with the overlay.\nIt <em>requires</em> an overlay renderer to do that, and <code>MKTileOverlayRenderer</code> is built for tile overlays like this.</p>\n<p>A simple and &quot;obvious&quot; way to bring everything together looks something like this:</p>\n<pre><code class=\"language-swift\">import UIKit\nimport MapKit\n\nlet stadiaApiKey = &quot;YOUR-API-KEY&quot;  // TODO: Get one at client.stadiamaps.com\nclass ViewController: UIViewController {\n\n    @IBOutlet var mapView: MKMapView!\n\n    override func viewDidLoad() {\n        super.viewDidLoad()\n\n        let overlay = MKTileOverlay(urlTemplate: &quot;https://tiles.stadiamaps.com/tiles/stamen_terrain/{z}/{x}/{y}.png?api_key=\\(stadiaApiKey)&quot;)\n        mapView.addOverlay(overlay)\n    }\n\n}\n\nextension ViewController: MKMapViewDelegate {\n    func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -&gt; MKOverlayRenderer {\n        if let tileOverlay = overlay as? MKTileOverlay {\n            return MKTileOverlayRenderer(overlay: tileOverlay)\n        } else {\n            return MKOverlayRenderer(overlay: overlay)\n        }\n    }\n}\n</code></pre>\n<p>Not too bad, aside from the large amount of boilerplate (also ignoring for the moment that the images aren't scale optimized).\nThere's just one problem... <a href=\"https://stackoverflow.com/questions/79286875/mapkit-flashing-screen-each-time-a-zoom-level-is-changed-with-custom-map-tiles-w\">the UX is <em>awful</em></a>!</p>\n<blockquote>\n<p>[!bug] <code>addOverlay</code> docs bug</p>\n<p>At the time of this writing, the <a href=\"https://developer.apple.com/documentation/mapkit/mkmapview/addoverlay(_:)\">docs for <code>addOverlay</code></a>\nstate that &quot;The map view adds the specified object to the group of overlay objects in the <code>MKOverlayLevel.aboveLabels</code> level.&quot;\nThis is not what actually happens.\nSo the above code is theoretically correct, but will be even more surprisingly broken in practice.\nTo fix this bug, write <code>mapView.addOverlay(overlay, level: .aboveLabels)</code>.</p>\n</blockquote>\n<h1><a href=\"#fixing-the-flash\" aria-hidden=\"true\" class=\"anchor\" id=\"fixing-the-flash\"></a>Fixing the Flash</h1>\n<p>I initially thought the flashing behavior was a result of a poor cache implementation.\nSo the first thing I did was to write my own <code>MKTileOverlay</code> subclass.\nI knew I wanted to provide my own <code>loadTile(at:result:)</code> implementation anyways (to load the max <em>available</em> image scale).</p>\n<p>After digging into the cache behavior though (and replacing it with my own instance of <code>URLCache</code> which I could inspect),\nI realized the problem was <em>not</em> the cache responsiveness.</p>\n<p><code>MKTileRenderer</code> was the next suspect.\nRegrettably, this is a completely closed source library,\nso there's no way to know for sure what's going on under the hood,\nbut I was able to reverse engineer a few things.</p>\n<p>First, the problem is <em>related to</em> the way <code>MKTileOverlayRenderer</code>\nimplements <code>canDraw(_:zoomScale:)</code> and <code>draw(_:zoomScale:in:)</code>.\nThe class seems to indicate that it can't draw anything when the data is not available at this exact zoom level.\nThis is rather annoying,\nsince the entire map will disappear and then rapidly fill in every time you cross over a zoom boundary!</p>\n<p>Which means we have to go even deeper.\nTime to implement our own overlay renderer!</p>\n<p>The overall approach I settled on for the first (and fortunately only!) pass\nwas to leave <code>canDraw(_:zoomScale:)</code> unimplemented, so it will always try to draw something.\nFor the draw method, my goal was to put cache hits directly in the hot path,\nand kicking off async requests in case something wasn't in the cache.</p>\n<p>Here's most of the code:</p>\n<pre><code class=\"language-swift\">/// A generic protocol for MapKit tile overlays which implement their own queryable cache.\n///\n/// This is useful for making overlays more responsive, and allowing for fallback tiles\n/// to be fetched by the renderer while waiting for the higher zoom tiles to load over the network.\n/// While technically not required, it's easiest to just subclass `MKTileOverlay`.\npublic protocol CachingTileOverlay: MKOverlay {\n    /// Fetches a tile from the cache, if present.\n    ///\n    /// This method should retorn as quickly as possible.\n    func cachedData(at path: MKTileOverlayPath) -&gt; Data?\n    func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, (any Error)?) -&gt; Void)\n\n    var tileSize: CGSize { get }\n}\n\npublic class CachingTileOverlayRenderer: MKOverlayRenderer {\n    private var loadingTiles = AtomicSet&lt;String&gt;()\n\n    public init(overlay: any CachingTileOverlay) {\n        super.init(overlay: overlay)\n    }\n\n    public override func draw(_ mapRect: MKMapRect, zoomScale: MKZoomScale, in context: CGContext) {\n        // Shift the type; our constructor ensures we can't get this wrong by accident though.\n        guard let tileOverlay = overlay as? CachingTileOverlay else {\n            fatalError(&quot;The overlay must implement MKCachingTileOverlay&quot;)\n        }\n\n        // (Snipped) Calculate the range of tiles the mapRect intersects\n\n        // Loop over the tiles that intersect mapRect...\n        for x in firstCol...lastCol {\n            for y in firstRow...lastRow {\n                // Create the tile overlay path\n                let tilePath = MKTileOverlayPath(x: x, y: y, z: currentZoom, contentScaleFactor: self.contentScaleFactor)\n\n                if let image = cachedTileImage(for: tilePath) {\n                    // (Snipped) Compute tile rect\n                    let drawRect = self.rect(for: tileRect)\n                    // If we have a cached image for this tile, just draw it!\n                    drawImage(image, in: drawRect, context: context)\n                } else {\n                    // Miss; load the tile\n                    loadTileIfNeeded(for: tilePath, in: tileRect)\n                }\n            }\n        }\n    }\n\n    func cachedTileImage(for path: MKTileOverlayPath) -&gt; ImageType? {\n        guard let overlay = self.overlay as? CachingTileOverlay else { return nil }\n        if let data = overlay.cachedData(at: path) {\n            return ImageType(data: data)\n        }\n        return nil\n    }\n\n    func loadTileIfNeeded(for path: MKTileOverlayPath, in tileMapRect: MKMapRect) {\n        guard let overlay = self.overlay as? CachingTileOverlay else { return }\n\n        // Create a unique key for the tile (MKTileOverlayPath is not hashable)\n        // and use this to avoid duplicate requests.\n        let tileKey = &quot;\\(path.z)/\\(path.x)/\\(path.y)@\\(path.contentScaleFactor)&quot;\n        guard !loadingTiles.contains(tileKey) else { return }\n\n        loadingTiles.insert(tileKey)\n\n        overlay.loadTile(at: path) { [weak self] data, error in\n            guard let self = self else { return }\n            self.loadingTiles.remove(tileKey)\n\n            // When the tile has loaded, schedule a redraw of the tile region.\n            DispatchQueue.main.async {\n                self.setNeedsDisplay(tileMapRect)\n            }\n        }\n    }\n}\n</code></pre>\n<p>It worked!\nWell, almost...</p>\n<p><figure><img src=\"media/mapkit-flipped-tiles.png\" alt=\"A map with elements upside down and badly stitched\" /></figure></p>\n<p>That was my first attempt at grabbing the <code>cgImage</code> property of a <code>UIImage</code>\nand slapping it onto the context.\nThe <code>drawImage</code> function ended up being rather annoying for both UIKit and AppKit.</p>\n<pre><code class=\"language-swift\">// At the top of your file\n#if canImport(UIKit)\ntypealias ImageType = UIImage\n#elseif canImport(AppKit)\ntypealias ImageType = NSImage\n#endif\n\n// Later, inside the overlay renderer...\n\nfunc drawImage(_ image: ImageType, in rect: CGRect, context: CGContext) {\n#if canImport(UIKit)\n    UIGraphicsPushContext(context)\n\n    image.draw(in: rect)\n\n    UIGraphicsPopContext()\n#elseif canImport(AppKit)\n    let graphicsContext = NSGraphicsContext(cgContext: context, flipped: true)\n\n    NSGraphicsContext.saveGraphicsState()\n    NSGraphicsContext.current = graphicsContext\n    image.draw(in: rect)\n    NSGraphicsContext.restoreGraphicsState()\n#endif\n}\n</code></pre>\n<p>Not very pretty (especially with the conditional compilation to support macOS), but it gets the job done.</p>\n<p>One more wart to acknowledge...this approach requires a bit of faith in <code>URLCache</code> being responsive.\nBut it got rid of the flicker, and I think that's the bigger win.</p>\n<blockquote>\n<p>[!question] What about the snipped code?!</p>\n<p>Yeah, I skipped over a bunch of math to calculate some rectangles.\nIt wasn't very interesting, and this is a LONG post.\nYou can find the <a href=\"https://github.com/stadiamaps/mapkit-caching-tile-overlay/blob/main/Sources/CachingMapKitTileOverlay/CachingTileOverlayRenderer.swift\">full code on GitHub</a>.</p>\n</blockquote>\n<h1><a href=\"#implementing-cachingtileoverlay\" aria-hidden=\"true\" class=\"anchor\" id=\"implementing-cachingtileoverlay\"></a>Implementing <code>CachingTileOverlay</code></h1>\n<p>After some minimal testing, it became clear that the default caching behavior\nwas not going to cut it.\nWe can't see the source code, but it looks like internally,\nApple either uses <code>URLSession.shared</code> or sets up a new session with caching behavior similar to <code>URLCache.shared</code>.\nThis, somewhat understandably, doesn't do much if any disk caching.\nBut map users expect data to be cached for snappy app relaunches!</p>\n<p>I ended up setting up a cache like this:</p>\n<pre><code class=\"language-swift\">let cache = URLCache(memoryCapacity: 25 * 1024 * 1024, diskCapacity: 100 * 1024 * 1024)\n</code></pre>\n<p>It's not entirely clear when exactly the memory contents are flushed to disk, but this is a big improvemnet already.\nDon't forget to configure your <code>URLSession</code> to use the new cache!\nI set up the session as in instance variable that's configured during <code>init</code>.</p>\n<pre><code class=\"language-swift\">self.urlSession = URLSession(configuration: configuration)\n</code></pre>\n<p>Next, we need to actually create the request and load it in <code>loadTile(at:result:)</code>.</p>\n<pre><code class=\"language-swift\">public override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, (any Error)?) -&gt; Void) {\n    let url = self.url(forTilePath: path)\n    let request = URLRequest(url: url, cachePolicy: cachePolicy)\n\n    if let response = cache.cachedResponse(for: request) {\n        result(response.data, nil)\n        return\n    }\n\n    urlSession.dataTask(with: request) { data, _, error in\n        result(data, error)\n    }.resume()\n}\n</code></pre>\n<p>Nothing super special here.\nBut it's worth noting I also made <code>cachePolicy</code> an instance variable for extra configuability.\nAnd that's pretty much all the interesting bits in the overlay.</p>\n<div class=\"markdown-alert markdown-alert-tip\">\n<p class=\"markdown-alert-title\">`canReplaceMapContent`</p>\n<p>If you're implementing a basemap layer with this approach, make sure you set <code>canReplaceMapContent</code>!\nThis lets <code>MapKit</code> skip drawing all layers underneath yours.\nDon't do this if you're just adding a transparent overlay on top.</p>\n</div>\n<p>For a full implementation of an overlay,\ncheck out <a href=\"https://github.com/stadiamaps/mapkit-layers\">this one for Stadia Maps raster layers</a>.</p>\n<h1><a href=\"#going-for-gold-overzooming\" aria-hidden=\"true\" class=\"anchor\" id=\"going-for-gold-overzooming\"></a>Going for Gold: Overzooming</h1>\n<p>With the zoom transition &quot;flicker&quot; solved for cases where the tile was already in the cache,\nI noticed there was another problem with <code>MKTileOverlayRenderer</code>.\nIt refuses to show tiles from anything but the current zoom level.\nThis causes a jarring effect when zooming in, as the map is erased and slowly redrawn\nas new (non-cached) tiles are loaded.</p>\n<p><code>MapKit</code> is the first framework I can recall seeing with this behavior.\nOther frameworks will just &quot;overzoom&quot; the existing tiles.\nI was able to overcome this, but admittedly it required quite a lot of hackery.\nThe first thing we need to change is our drawing method.\nIt needs a fallback case.</p>\n<pre><code class=\"language-swift\">if let image = cachedTileImage(for: tilePath) {\n    // If we have a cached image for this tile, just draw it!\n    drawImage(image, in: drawRect, context: context)\n} else if let fallbackImage = fallbackTileImage(for: tilePath) {\n    // If we have a fallback image, draw that instead to start.\n    drawImage(fallbackImage, in: drawRect, context: context)\n\n    // Then, load the tile from the cache (if necessary)\n    loadTileIfNeeded(for: tilePath, in: tileRect)\n} else {\n    // Total cache miss; load the tile\n    loadTileIfNeeded(for: tilePath, in: tileRect)\n}\n</code></pre>\n<p>Nothing too surprising here.\nWe try to load a fallback image, and THEN kick off the tile fetch.\nThe majority of the logic lives in <code>fallbackTileImage(for:)</code>:</p>\n<pre><code class=\"language-swift\">/// Attempts to get a fallback tile image from a lower zoom level.\n///\n/// The idea is to try successively lower zoom levels until we find a tile we have cached,\n/// then use it until the real tile loads.\nfunc fallbackTileImage(for path: MKTileOverlayPath) -&gt; ImageType? {\n    var fallbackPath = path\n    var d = 0\n    while fallbackPath.z &gt; 0 &amp;&amp; d &lt; 2 {  // We'll go up to 2 levels higher\n        d += 1\n        fallbackPath = fallbackPath.parent\n\n        if let image = cachedTileImage(for: fallbackPath) {\n            let srcRect = cropRect(d: d, originalPath: path, imageSize: image.size)\n\n            return image.cropped(to: srcRect)\n        }\n    }\n    return nil\n}\n</code></pre>\n<p>This code looks for cached tiles up to 2 levels &quot;higher up.&quot;\nIf it finds one, it returns that image to be temporarily rendered as a stand-in.\nThis method in turn relies on two more methods:\nan extension on <code>MKTileOverlayPath</code> to get the parent tile,\nand a <code>cropRect</code> function which returns a sub-rectangle of the fallback image\nwhich we want to display.</p>\n<p>In digital maps, the map is subdivided into tiles.\nAt zoom level 0, the whole world is a single tile.\nEvery time you zoom in a level, each tile is subdivided into 4.\nThis property lets us use a previously loaded image from a lower zoom level as a stand-in.</p>\n<p>This took <strong>way</strong> too much trial and error to get right.\nFirst, we need to do some math to calculate which section of the cached image we should crop to and &quot;overzoom.&quot;\nThen we need to actually crop the image, which is easier said than done.\nNeither <code>UIImage</code> nor <code>NSImage</code> provide a cropping API directly,\nso we need to drop down to <code>CoreGraphics</code>.</p>\n<p>To make matters worse, AppKit and UIKit somewhat infamously use different coordinate systems,\nwith the origins in different spots.\nSo our cropping functions OR our rect calculation need to be aware of the difference.</p>\n<p>This code is not particularly interesting to be honest,\nbut here are links to the files on GitHub:</p>\n<ul>\n<li><a href=\"https://github.com/stadiamaps/mapkit-caching-tile-overlay/blob/main/Sources/CachingMapKitTileOverlay/CachingTileOverlayRenderer.swift\">Cropping rectangle</a> (search for <code>cropRect</code>)</li>\n<li><a href=\"https://github.com/stadiamaps/mapkit-caching-tile-overlay/blob/main/Sources/CachingMapKitTileOverlay/Cropping.swift\">Image extension</a></li>\n</ul>\n<blockquote>\n<p>[!question] What about zooming out?</p>\n<p>I currently don't apply the same tricks when zooming out.\nAs you zeem out, MapKit still clears tiles rather than showing smaller versions of what it has already.\nThis is a trickier problem since, while each child has exactly one parent tile,\nwhen you go in reverse, the task is to load 4 tiles and stitch them together.\nPRs welcome if anyone wants to take a swing!</p>\n</blockquote>\n<h1><a href=\"#conclusion\" aria-hidden=\"true\" class=\"anchor\" id=\"conclusion\"></a>Conclusion</h1>\n<p>Mapkit is full of surprises.\nWhile it works pretty well out of the box with a vanilla map style from Apple on a fast network,\nsomething as simple as adding raster overlays can be devilishly complicated.\nHere's to hoping that Apple eventually publishes the source code for MapKit.\nI would happily sobmit some PRs to improve it,\nincluding adding support for overlays in the SwiftUI API!</p>\n<p>In the meantime, I've published a Swift package\nwith the caching overlay and renderer outlined in this post.\n<a href=\"https://github.com/stadiamaps/mapkit-caching-tile-overlay/tree/main\">Check it out on GitHub</a>.</p>\n",
      "summary": "",
      "date_published": "2025-02-11T00:00:00-00:00",
      "image": "media/mapkit-flipped-tiles.png",
      "authors": [
        {
          "name": "Ian Wagner",
          "url": "https://fosstodon.org/@ianthetechie",
          "avatar": "media/avi.jpeg"
        }
      ],
      "tags": [
        "ios",
        "swift",
        "apple",
        "maps"
      ],
      "language": "en"
    }
  ]
}