Tuesday, 28 November 2017

Parse JSON objects in Swift from WKWebView

Upto this point, I had only worked with the UIWebView in iOS, even though I had heard/read about the new WKWebView. I had only read good things about it but given the limited resources at my startup, working with WKWebView was a lower priority. A few weeks ago, the time was right for me to have a more in-depth look at the WKWebView and while learning more about it I realised something. I realised that while there are a ton of useful tutorials out there, almost none of them covered it with the depth or all the use cases that I was after. One example of missing information or a use case is, if we receive a json object from the webview, how do we parse it in Swift? In this post I will be talking about how to parse json data in Swift sent from web content hosted in a WKWebView of an iOS app.

Background


As seen from HTMLStarterAppWithSwift, my interest in the webview is not for showing content hosted in a website but more towards working with content hosted locally i.e. a native iOS app who's UI is powered by an HTML5. If you want to know more about what I mean by that, have a look at my this Github repo. I did hear about the WKWebView some time ago and how awesome it is with it's nitro JS engine, handles javascript asynchronously etc etc but I did not think about it till now, why? well the UIWebView used in My Day To-Do has been working perfectly and I simply could not justify allocating time to exploring an alternative to it while there are other higher priority problems to solve at My Day To-Do. The webview exploration could have only happen in two cases

  1. our users complain about the performance of MDT
  2. addition of a new feature that could benefit from the performance gain


In my case it was the second option now that I have started working on 'Feature 21' for My Day To-Do (more on that later), the time is right, as it could use the performance boost. Therefore I started looking at the WKWebView and one of the first things that I noticed was the awesome new 'messageHandler' for calling a Swift function from Javascript. This was all great but...

Problem

The new WKScriptHandler not only let's you call a Swift function but also let's you pass some data to it i.e. a Javascript (json) object to it. That all works great and you can pass an object to Swift but then how do you parse it? i.e. how do you extract the individual properties out of that object? While working on this, I found a lot of really useful tutorials for the WKWebView but I failed to find something with a solid enough example to illustrate this entire process. Hmm, know what? let's simplify the problem description even further but talking about in the context of a scenario.

Scenario

We have a website that shows house listings i.e. like a rental website and after years of success with the web product, we are making a companion iOS app for the website. One of the requirements for the web site is to be able to send the house price objects to the corresponding Swift class. Now the Swift backend code can get both a single house price object or a list of such objects from the front-end, it needs to be prepared to handle both cases.

Solution


I eventually found a solution to achieve something like the above and it was mostly by going through the documentation and more importantly examining the WKScriptMessageHandler.body property in the Xcode debugger

If you want to know more about the WKScriptMessageHandler and that entire process of loading content in a webview, I would recommend you have a read of these tutorials first

  1. A look at WebView framework in iOS 8
  2. Complete guide to implementing WKWebView
  3. Communicating between Javascript in WKWebView and Native Code



Now everything from this point on wards assumes you have some basic understanding of building a WKWebView based solution, including an understanding of WKScriptMessage, the WKScriptMessageDelegate etc.

The simple web app

Remember this is a simple web app that sends house pricing and house listing to the Swift backend so here it's a simple webpage that communicates with the Swift backend. Here's the html file

<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
<script> function housePrice() {
var houseStr = JSON.stringify(h1);
var h1 = {name:"Central Perk, NYC, NY", range: [2,3,4,5]};
function houseListing() {
window.webkit.messageHandlers.sendPrice.postMessage(houseStr); }
var houses = [h1,h2];
var h1 = {name:"13 Spooner St", range: [2,3,4,5]}; var h2 = {name:"742 Evergreen Tc", range: [7,8]};
</script>
var housesStr = JSON.stringify(houses); window.webkit.messageHandlers.sendListing.postMessage(housesStr); } </head>
<button onclick="houseListing()"> Send House listing </button>
<body ng-app="starter"> <div id="table"> <button onclick="housePrice()"> Send House Price </button> </div> </body>
</html>

That's all it is really, a webpage with two buttons, nothing more and the code about is pretty self-explanatory i.e. we create javascript objects, stringify them and send them to our Swift class. Our JS object has two properties,

  1. name of type String
  2. range which is an Integer array

So we send an object HousePrice -> {name:"13 Spooner St", range: [2,3,4,5]}  to our Swift based code, let's just call it backend code. Now let's look at our ViewControlller.swift class

import UIKit
import WebKit

class ViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler {

    var webView: WKWebView!
    var oldWebView: UIWebView!
    
    override func loadView() {
        let webConfiguration = WKWebViewConfiguration()
        let controller = WKUserContentController()
        controller.add(self, name: "sendPrice")
        controller.add(self, name: "sendListing")
        webConfiguration.userContentController = controller
        webConfiguration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
        webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.uiDelegate = self
        view = webView
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        let pathString = Bundle.main.path(forResource: "test", ofType: "html", inDirectory:"www")
        let url = URL(fileURLWithPath: pathString!)
        let req = URLRequest(url: url)
        webView.load(req)
    }
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        extractJsonObjFromScriptMsg(message: message)
    }

    /* object sent
     name: String
     range: Int array ([Int])
     */
    func extractJsonObjFromScriptMsg(message: WKScriptMessage) {
        //get the object first
        var housePrices = [HousePrice]()
        //step 1: check if the obj is a string
        if let objStr = message.body as? String {
            //step 2: convert the string to Data
            let data: Data = objStr.data(using: .utf8)!
            do {
                let jsObj = try JSONSerialization.jsonObject(with: data, options: .init(rawValue: 0))
                if let jsonObjDict = jsObj as? Dictionary<String, Any> {
                    let housePrice = HousePrice(dict: jsonObjDict)
                    housePrices.append(housePrice)                    
                } else if let jsonArr = jsObj as? [Dictionary<String, Any>] {
                    for jsonObj in jsonArr {
                        let hPrice = HousePrice(dict: jsonObj)
                        housePrices.append(hPrice)
                    }
                }
            } catch _ {
                print("having trouble converting it to a dictionary")
            }
        }

    }
}

Ok now let's look at the part of the above ViewController logic that we are really interested in i.e. the didReceive message method from the WKScriptMessageHandler. All we do in that method above is pass the WkScriptMessage to...

ExtractJsonObjFromScriptMsg method

Our goal in this method is to determine whether we received a JSONObject or an array of JSONObjects, how do we do that? The WKScriptMessage has two properties that are of interest to us, body and name, or in this case just the name property

Step 1: Determine if it's a json object

Remember we stringify the object in javascript and pass it Swift side

var housesStr = JSON.stringify(houses);

Ok on the Swift side of things we receive the WKScriptMessage, as mentioned earlier we are interested in the body property of the message so,

if let objStr = message.body as? String {
            //step 2: convert the string to Data
            let data: Data = objStr.data(using: .utf8)!
            do {
                let jsObj = try JSONSerialization.jsonObject(with: data, options: .init(rawValue: 0))
                if let jsonObjDict = jsObj as? Dictionary<String, Any> {
                    let housePrice = HousePrice(dict: jsonObjDict)
                    housePrices.append(housePrice)                    
                } else if let jsonArr = jsObj as? [Dictionary<String, Any>] {
                    for jsonObj in jsonArr {
                        let hPrice = HousePrice(dict: jsonObj)
                        housePrices.append(hPrice)
                    }
                }
            } catch _ {
                print("having trouble converting it to a dictionary")
            }
        }

  1. First we check whether the message.body is indeed a string or not? I know we sent a string for sure, but it's always safe to deal with Optionals in Swift this way.
  2. Next we try to determine is it's a JSONObject via JSONSerialization
  3. Then we check if we received a Dictionary object, remember the HousePrices json object has a string property called name and an integer array called range. The json  object returned by the JSONSerialization class is a dictionary of type String and Any
  4. The next step is to send the dictionary object to a HousePrice class in Swift which brings us to another piece of code not shared above...

class HousePrice {
        var name  = ""
        var range = [Int]()
        struct Keys {
            static var NAME = "name"
            static var RANGE = "range"
        }
        convenience init(dict: Dictionary<String,Any>) {
            self.init()
            if let name = dict[Keys.NAME] as? String {
                self.name = name
            }
            if let range = dict[Keys.RANGE] as? [Int] {
                self.range = range
            }
        }
}
This is a Swift representation of the HousePrice class which has a convenience init (overriden constructor) which accepts a dictionary class and initialises it's properties by extracting the values out of the dictionary. The code above is fairly self-explanatory. You can read more about Swift initialisers here.

p.s. coming from a Java background and loving POJOs, I couldn't help but see HousePrice as a sort of a Plain old Swift Object (POSO)

Lastly and for the sake of clarity, we are keeping track of how many housePrice objects we find by storing them in an array of HousePrice objects called housePrices.

Summary

I think all that above sufficiently covers how to pass a json object from Javascript to Swift in an iOS app and then extract the object properties i.e. parse the Javascript class in Swift. Actually wait there's one more thing, but first let's look at all the code 

test.html

<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width">
        <script src="js/scripts.js"></script>        
    </head>
    <body ng-app="starter">
        <div id="table">
            <button onclick="housePrice()"> Send House Price </button>
            <button onclick="houseListing()"> Send House listing </button>
        </div>
    </body>
</html>

scripts.js

function housePrice() {
    var h1 = {name:"Central Perk, NYC, NY", range: [2,3,4,5]};
    var houseStr = JSON.stringify(h1);
    window.webkit.messageHandlers.sendPrice.postMessage(houseStr);
}

function houseListing() {
    var h1 = {name:"13 Spooner St", range: [2,3,4,5]};
    var h2 = {name:"742 Evergreen Tc", range: [7,8]};
    var houses = [h1,h2];
    var housesStr = JSON.stringify(houses);
    console.log(housesStr);
    window.webkit.messageHandlers.sendListing.postMessage(housesStr);
}

Notice, that unlike the code posted earlier, this time and realistically in almost every instance you wouldn't really have the Javascript functions in the html file. So we are getting our Javascript functions from a scripts.js file from the js folder. Now, when using the UIWebView, it worked just as expected, however when I first tried to use this in WKWebView, I got the dreaded CORS error. Ahh that was so annoying but luckily there's an easy fix for it via a small setting in the WKWebViewConfiguration's preferences. Le'ts take a look at our ViewController class.


import UIKit
import WebKit


class ViewController: UIViewController, WKUIDelegate, WKScriptMessageHandler {

    var webView: WKWebView!
    var oldWebView: UIWebView!
    var eventFunctions = Dictionary<String, (String) -> Void>()

    override func loadView() {
        let webConfiguration = WKWebViewConfiguration()
        let controller = WKUserContentController()
        controller.add(self, name: "sendPrice")
        controller.add(self, name: "sendListing")
        webConfiguration.userContentController = controller
        webConfiguration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
        webView = WKWebView(frame: .zero, configuration: webConfiguration)
        webView.uiDelegate = self
        view = webView
    }
    override func viewDidLoad() {
        super.viewDidLoad()
        let pathString = Bundle.main.path(forResource: "test", ofType: "html", inDirectory:"www")
        let url = URL(fileURLWithPath: pathString!)
        let req = URLRequest(url: url)
        webView.load(req)
    }
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        extractJsonObjFromScriptMsg(message: message)
    }
    func extractJsonObjFromScriptMsg(message: WKScriptMessage) {
        //get the object first
        var housePrices = [HousePrice]()
        //step 1: check if the obj is a string
        if let objStr = message.body as? String {
            //step 2: convert the string to Data
            let data: Data = objStr.data(using: .utf8)!
            do {
                let jsObj = try JSONSerialization.jsonObject(with: data, options: .init(rawValue: 0))
                if let jsonObjDict = jsObj as? Dictionary<String, Any> {
                    let housePrice = HousePrice(dict: jsonObjDict)
                    housePrices.append(housePrice)                    
                } else if let jsonArr = jsObj as? [Dictionary<String, Any>] {
                    for jsonObj in jsonArr {
                        let hPrice = HousePrice(dict: jsonObj)
                        housePrices.append(hPrice)
                    }
                }
            } catch _ {
                print("having trouble converting it to a dictionary")
            }
        }

    }
}

Now in the class above, this bit in override func loadView 

webConfiguration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs")
let's our WKWebView instance know that it should allow files to be loaded locally. In our iOS app, we have our iOS app and a folder called www which has out test.html file and subfolder called js which has a file scripts.js that has the Javascript functions we use in our web page.

Conclusion

There you go, once again I have blogged about solving something relatively simple but it did take me a little longer than it should have to figure it out simply because things it wasn't immediately obvious. I hope this blog post paints a much clearer picture for those willing to make use of the new WKWebView and ensure a smooth passage of data between your web and backend code.

Now, I have a ton of work to do but I am home sick with a cold so I am just using this time to finish off this blogpost, hoping it can help someone else stuck on this.

Lastly, I am working on my startup full-time right now, so if you find my blogposts useful and want to see me share more useful stuff in the future, please do support me. You can support me by purchasing the pro version My Day To-Do or maybe download the Lite version, use it and try one of it's IAPs. Whichever version you try, please leave an app store review for it.

No comments:

Post a Comment