Web & development notesHistorical technical note

JavaScript and Native iOS Interaction with JavaScriptCore

Compares early UIWebView URL interception with JavaScriptCore. Using JSContext, it introduces executing JavaScript from native code, registering native methods, and passing data between the two environments.

Original byline:Sindri

Originally credited to Sindri; edited and archived by Bison.

Source listed in the original

English translation of the Chinese original, translated on . The article body uses automated translation; app descriptions, historical reading notes and video subtitles receive separate editorial checks. Original publication dates, attribution, code and version notes are retained.

1

Hybrid development is growing at an unprecedented rate, with cross-platform development and hot updates establishing its importance. Although web interfaces still lag behind native apps in interaction, animation, and other areas, more companies will undoubtedly adopt hybrid development. On iOS, this history divides into the difficult period before iOS7 and the golden era afterward, marked by the JavaScriptCore framework.

Editor: Bison
Contributor: Sindri’s Notes

The difficult era

I was fortunate to avoid pre-iOS7 versions, so I know those difficult years only through a senior developer's stories. The only way to interact with web interfaces then was UIWebView. Setting aside its own memory-leak problems, here is some code that developer wrote:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType
{
    NSString * address = request.URL.absoluteString;
    for (NSString * black in _blackList) {
        if ([address containsString: black]) {
            return NO;
        }
    }
    for (NSString * event in _eventList) {
        if ([address containsString: event]) {
            SEL callback = NSSelectorFromString(_callbacks[event]);
            [self performSelector: callback];
            return [event containsString: @"shouldOpen=1"];
        }
    }
    return YES;
}

At the time, developers made frontend events trigger URL navigation and used special characters in the URL to choose an action. Several data collections were needed to store handlers for those characters. Exchanging data with the frontend meant concatenating every parameter into the request URL—quite a mess. Another interaction method was

stringByEvaluatingJavaScriptFromString

method to execute js code.

JavaScriptCore

JavaScriptCore is a set of tools for parsing JS code and supplies its execution environment: open-source framework greatly simplifies interaction. Next, I will explain the corresponding methods for calls from the project to JS code, and calls in the other direction.

Calling JS code from the project

  • JSContext

    A JSContext object is the global execution environment for JavaScript, providing code execution and method-registration interfaces. The following creates a JSContext and adds some JS code to that environment.

    let context = JSContext()
    context.evaluateScript(" var age = 22 ")
    context.evaluateScript(" var name = 'SindriLin' ")
    context.evaluateScript(" var birth = 1993-01-01 ")
    context.evaluateScript(" var createPerson = 
        function(age, name, birth) 
        { 
            return {'age': age, 'name': name, 'birth': birth}
        } ")
    context.evaluateScript(" var codeDescription = 'The code create three value and a function to create a dictionary stored person information' ")
    

    Errors such as syntax errors may occur while JS runs. The following code handles them:

    context?.exceptionHandler = { context, exception in 
          print("Java Script Run Error: \(exception)")
    }
    
  • JSValue

    JSValue is the value returned by JSContext operations, wrapping almost every data type, including errors and IMP pointers. The JSValue class provides several toXXXX methods for conversion to iOS types, along with call methods for invoking functions. The following retrieves existing variables from a JSContext environment and creates a dictionary holding person information.

      let age = context?.objectForKeyedSubscript("age")
      let name = context?.objectForKeyedSubscript("name")
      let birth = context?.objectForKeyedSubscript("birth")
      let createFunction = context?.objectForKeyedSubscript("createPerson")
      let codeDescription = context?.objectForKeyedSubscript("codeDescription")
      let person = createFunction.call(withArguments: [age.toInt32(), name.toString(), birth.toString()])
        
      let personInfo = "name: \(person["name"]) age: \(person["age"] and birth: \(person["birth"])"
      print("The javaScript code description: \(codeDescription.toString())")
      print("The created person \(personInfo) ")
    

    The example shows that once we know the function to call in the JS code, JSContext + JSValue lets us invoke web-interface methods easily from the project, without constructing long parameter strings and passing them through URL links.

    Calling project code from JS

    JavaScript can access objects and methods in our code in two ways: Blocks and JSExport.

    • Blocks

      Custom block code can be exposed through JSContext as a callable function pointer in JS. A pitfall is that Swift closures cannot undergo that conversion directly. In Swift, the process is therefore Closure → block → function pointer. Converting a closure into a block requires the important annotation @convention.

      let stringConvert: @convention(block) (String)->String = {
          let pinyin = NSMutableString(string: $0) as CFMutableString
          CFStringTransform(pinyin, nil, kCFStringTransformToLatin, false)
          CFStringTransform(pinyin, nil, kCFStringTransformStripCombiningMarks, false)
          return pinyin as String
      }      
          
      let convertObjc = unsafeBitCast(stringConvert, to: AnyObject.self)
      context?.setObject(convertObjc, forKeyedSubscript: "convertFunc")
      let convertFunc = context?.objectForKeyedSubscript("convertFunc")
      print("林欣达的拼音是\(convertFunc.call(withArguments: ["林欣达"]).toString())")
      

      Now, when the web interface's JS button handler calls convertFunc(), the code in this closure executes. Be careful: closure capture can retain the JSContext and prevent its release. Use JSContext.current() to obtain the current context and address the reference issue.

    • JSExport

      In JS, calls to iOS methods use methods of a protocol derived from JSExport. All methods in the derived protocol are automatically exposed to JavaScript, as demonstrated below.

    Putting it into practice

    I wrote some JS for this article's demo. Here are the code and its result. Note that the button's onclik specifies the click-response event:

    <!DOCTYPE html>
    <html>
        <head>
            <meta charset="UTF-8">
        </head>
        <body>
            <div style="margin-top: 20px">
                <h2 align="center" style="color:#ff0000">JS与iOS交互</h2>
                <input type="button" value="点击后切换控制器的背景颜色" onclick="sindrilin.call()">
            </div>
            <div style="color:#7BBDE5">
                <br />
                <br />
                账户:
                <input id="account" type="text">
                <br />
                密码:
                <input id="password" type="password">
            </div>
            <div>
                <input type="button" value="登录" onclick="login()">
            </div>
          
            <script>
              
                var login = function()
                {
                    account = document.getElementById("account")
                    password = document.getElementById("password")
                    var accountInfo = JSON.stringify({"account": account.value, "password": password.value});
                    sindrilin.login(accountInfo);
                }
          
                var alertFromIOS = function(message)
                {
                        alert(message)
                }
          
            </script>
        </body>
    </html>
    

1

First load this HTML file and obtain the global execution environment. For nearly all HTML files, the keyPath used to obtain that environment is the same:

  let jsPath = Bundle.main().pathForResource("interaction", ofType: "html")
  webView.loadRequest(URLRequest(url: URL(fileURLWithPath: jsPath!)))
  interactionContext = webView.value(forKeyPath: "documentView.webView.mainFrame.javaScriptContext") as? JSContext
  interactionContext?.exceptionHandler = {
      print("Interaction Error: \($1?.toString())")
  }

In the HTML, clicking the top button calls sindrilin.call(), which our view controller must ultimately handle. We can split this string into two parts like Target-Action: sindrilin identifies the responder, and call() the action. Set the Target as follows:

  interactionContext?.setObject(self, forKeyedSubscript: "sindrilin")   响应者已经有了,那么响应事件也要我们实现代码,这里就需要用到`JSExport`协议了。所有这种类似`Target-Action`的事件触发都会通过这个协议获取方法实现,因此我们需要自定义响应协议以及响应事件。对于有参数的方法我们需要用`@objc(name)`的方式给方法起`OC`式的方法名,才能保证能被正确调用响应:

  @objc protocol LXDInteractionExport: JSExport {
      func call()                                    ///响应sindrilin.call()
      @objc(login:) func login(accountInfo: String)  ///响应sindrilin.login(accountInfo)
  }
  
  extension ViewController: LXDInteractionExport {
      func call() {
          print("call from html button clicked")
          view.backgroundColor = UIColor(red: CGFloat(arc4random() % 256) / 255, green: CGFloat(arc4random() % 256) / 255, blue: CGFloat(arc4random() % 256) / 255, alpha: 1)
      }
  
      func login(accountInfo: String) {
          do {
              if let JSON: [String: String] = try JSONSerialization.jsonObject(with: accountInfo.data(using: String.Encoding.utf8)!, options: JSONSerialization.ReadingOptions()) as? [String: String] {
                  print("JSON: \(JSON)")
                  let alert = interactionContext?.objectForKeyedSubscript("alertFromIOS")
                  let message = "The alert from javascript call\naccount: \(JSON["account"]) and password: \(JSON["password"])"
                  _ = alert?.call(withArguments: [message])
              }
          } catch {
              print("Error: \(error)")
          }      
      }
  }

After entering a username and password in the frontend interface, clicking Log In calls login(accountInfo: String) method and assembles the username and password into a JSON string. In the response method, I parse the relevant user-information fields, assemble a new string, and call JS's dialog function to display a response Demo download

1

Follow iOS development collection for more articles


Recommended reading: Adding a launch advertisement to an iOS app