Xamarin.Forms の WebView で JavaScript 連携を行う(with iOS/Android共通化)

やりたい事

Xamarin.Forms 製アプリの WebView に表示した Webページから、ネイティブ(C#)で時間のかかる処理を行い、結果を JavaScript に通知したい。 JavaScript のコードは Android/iOS で共通にしたい。

具体的には、次のような JavaScript コードの heavyAdd(num) を実行した時に、ネイティブ側で処理を行い、結果を onResult(res) で受信したい。

function addAsync() {  
  MyCalc.onResult = function (res) {
    var label = document.getElementById("result");
    label.innerHTML = 'MyCalc.onResult - ' + res;
  };
  MyCalc.heavyAdd(98);
}

できた!

  • 前提 - Xamarin.Forms 3.4.x が必要

共通

sample.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <script>
    function addAsync() {
      
      MyCalc.onResult = function (res) {
        var label = document.getElementById("result");
        label.innerHTML = 'MyCalc.onResult - ' + res;
      };

      MyCalc.heavyAdd(98);
    }
  </script>
</head>
<body>
  <h1>WebView−JavaScript連携サンプル</h1>
  <p><button onclick="addAsync();">計算実行</button></p>
  <label id="result"></label>
</body>
</html>

ローカルPC にある sample.html は、 Webサーバー(npm serve とか)を立てて、 ngrok を使って外部公開するのが便利ですね。

MainPage.xaml

<?xml version="1.0" encoding="utf-8"?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" 
    xmlns:local="clr-namespace:WebViewSample" x:Class="WebViewSample.MainPage">
    <StackLayout Orientation="Vertical">
        <WebView x:Name="webView" 
                 Source="https://xxxx.ngrok.io/sample.html"
                 VerticalOptions="FillAndExpand"/>
    </StackLayout>
</ContentPage>

Android側

Android の CustomWebViewRenderer.cs

using System;
using System.Threading.Tasks;
using Android.Content;
using Java.Interop;
using Xamarin.Forms;
using Xamarin.Forms.Platform.Android;

[assembly: ExportRenderer(typeof(WebView), typeof(WebViewSample.Droid.CustomWebViewRenderer))]

namespace WebViewSample.Droid
{
    public class CustomWebViewRenderer : Xamarin.Forms.Platform.Android.WebViewRenderer
    {
        public CustomWebViewRenderer(Context context) : base(context) { }

        protected override void OnElementChanged(ElementChangedEventArgs<WebView> e)
        {
            base.OnElementChanged(e);
            Control.AddJavascriptInterface(new JavaScriptHandler(Control), "MyCalc");
        }
    }

    class JavaScriptHandler : Java.Lang.Object
    {
        private readonly Android.Webkit.WebView webView;

        public JavaScriptHandler(Android.Webkit.WebView webView)
        {
            this.webView = webView;
        }

        [Export]
        [Android.Webkit.JavascriptInterface]
        async public void heavyAdd(int num)
        {
            await Task.Delay(1000);
            var result = num * 2;

            // メインスレッドから呼ばないとエラー
            this.webView.Post(() => 
            {
                this.webView.LoadUrl($"javascript:MyCalc.onResult({result});");
            });
        }
    }
}

を参考に、ネイティブのやり方をカスタムレンダラーで。 Android の方はまだ単純で AddJavascriptInterface() の第2引数がクラス名に、JavascriptInterface 属性を付けたメソッドが JavaScript のメソッド名になる。 結果の通知は this.webView.LoadUrl($"javascript:MyCalc.onResult(xx); で。

iOS側

iOS の CustomWebViewRenderer.cs

using System;
using System.Threading.Tasks;
using Foundation;
using WebKit;
using Xamarin.Forms;
using Xamarin.Forms.Platform.iOS;

[assembly: ExportRenderer(typeof(WebView), typeof(WebViewSample.iOS.CustomWebViewRenderer))]

namespace WebViewSample.iOS
{
    public class CustomWebViewRenderer : Xamarin.Forms.Platform.iOS.WkWebViewRenderer, IWKScriptMessageHandler
    {
        protected override void OnElementChanged(VisualElementChangedEventArgs e)
        {
            base.OnElementChanged(e);

            var webView = this.NativeView as WKWebView;

            // JavaScript から呼び出すハンドラを追加。
            webView.Configuration.UserContentController.AddScriptMessageHandler(this, "MyHeavyAdd");

            // JavaScript 側で MyCalc.heavyAdd(n) が呼ばれた時に window.webkit.messageHandlers.xxx を呼ぶようにする。
            var script =
                "MyCalc = {};" +
                "MyCalc.heavyAdd = function (num) { window.webkit.messageHandlers.MyHeavyAdd.postMessage(num); };";
            webView.Configuration.UserContentController.AddUserScript(new WKUserScript(
                new NSString(script), WKUserScriptInjectionTime.AtDocumentStart, true));
        }

        async void IWKScriptMessageHandler.DidReceiveScriptMessage(WKUserContentController userContentController, WKScriptMessage message)
        {
            if (message.Name == "MyHeavyAdd") 
            {
                // 時間のかかる処理
                await Task.Delay(1000);
                var result = (message.Body as NSNumber).Int32Value * 2;

                // 結果を通知
                var webView = this.NativeView as WKWebView;
                webView.EvaluateJavaScript($"MyCalc.onResult({result});", null);
            }
        }
    }
}

を参考にカスタムレンダラーで実装。

ポイント1。Xamarin.Forms 3.4から? WebView の実装が WKWebView になった模様。それまでは(少なくとも Xamarin.Forms 3.1 では) UIWebView だった。 Xamarin.Forms 3.4 でないと Xamarin.Forms.Platform.iOS.WkWebViewRenderer が存在しないため使えない。

ポイント2。iOS で JavaScript からネイティブの処理を呼ぶには window.webkit.messageHandlers.xxxx.postMessage() を使わなければならないが、これでは Android 側と共通化できないので、AddUserScriptwindow.webkit.〜MyCalc.heavyAdd にマップしている。

ポイント3。JavaScript からの呼び出しに反応するのは IWKScriptMessageHandler インターフェース。

こんな感じ

image.png

端的に言うと、Android と異なる iOS の JavaScript→ネイティブ呼び出しを、AddUserScript で同じAPIにラップしたよーというお話でした。