Android
iOS

ガワネイティブアプリを作るときに必要な技術

動機・前提

今更ながら、とある案件でガワネイティブの要望が出てきそうだったので、事前調査としていくつか調べました。

"ガワネイティブ"という表記が一般的なのかは不明ですがこの記事では、 Webとスマホネイティブの間にある技術(ガワネイティブやハイブリッド、クロスプラットフォームなど) で記載したように「アプリ内WebViewで、サーバがレンダリングしたHTMLを表示する」とします。

サンプルとして作ったもの

WebView内に表示するHTML
https://github.com/noboru-i/sample-html/blob/master/webview.html

iOS側のサンプル(playground形式)
https://github.com/noboru-i/sample-hybrid-ios
WKWebViewを利用しています。

Android側のサンプル
https://github.com/noboru-i/sample-hybrid-android

下では、一部のコードを切り出した形で記載していますが、コード全体を参照したい場合は、リポジトリの方を確認してください。

必要な技術

User-Agentの書き換え

同一のURLに対して、ブラウザからも流入 / アプリでもアクセスさせる場合、表示・処理をサーバサイドで分岐させたいと思います。

例えば、下記のような分岐が考えられます。

  • アプリからのアクセスの場合、ヘッダ・フッタは表示しない。(ヘッダをネイティブで実装する、copyrightなどをアプリでは表示したくない、など)
  • 古いバージョンのアプリでは、特定のリンクを表示しない。(どこかのバージョンでアプリ側に実装した機能など)

iOSの場合

https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/1395665-applicationnameforuseragent
applicationNameForUserAgent を利用することで、WKWebView本来のUserAgentに任意の文字列を追加できます。

let configuration = WKWebViewConfiguration()
configuration.applicationNameForUserAgent = "Sample-iOS-App/v1.0.0"
webView = WKWebView(frame: .zero, configuration: configuration)

Androidの場合

https://developer.android.com/reference/android/webkit/WebSettings.html#setUserAgentString(java.lang.String)
setUserAgentString を利用することで、UserAgentを上書きできます。
iOSの applicationNameForUserAgent とは異なり、"追加"するインターフェースが無いので、既存のUAを取得後、文字列連結する必要があります。

WebView webView = findViewById(R.id.webview);
String userAgent = webView.getSettings().getUserAgentString();
webView.getSettings().setUserAgentString(userAgent + " Sample-Android-App/v1.0.0");

JavaScriptのインターフェースを追加

HTML上の要素をタップした際に、ネイティブの機能を実行させたいと思います。

例えば、下記のような状況が考えられます。

  • ボタンタップでネイティブのUIを操作する。(ネイティブで実装したヘッダを書き換えたり、Toastを出したり。)
  • ボタンタップでGPSなどのネイティブのセンサーなどを実行する。

iOSの場合

1つの文字列引数を取る sendMessage を定義してみます。
JavaScriptから、swiftの print を実行できるようにしてみます。

class MyViewController : UIViewController {
    override func loadView() {
        let configuration = WKWebViewConfiguration()

        let controller = WKUserContentController()
        controller.add(self, name: "sendMessage")
        configuration.userContentController = controller

        webView = WKWebView(frame: .zero, configuration: configuration)
        view = webView
    }
}
extension MyViewController : WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        switch message.name {
        case "sendMessage":
            guard let contentBody = message.body as? String else {
                return
            }
            print("message is received. message: " + contentBody)
        default:
            fatalError()
        }
    }
}

Androidの場合

1つの文字列引数を取る MyApp.sendMessage を定義してみます。
JavaScriptから、 Toast を表示できるようにしてみます。

public class MainActivity extends AppCompatActivity {

    private WebView webView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView = findViewById(R.id.webview);
        WebSettings webSettings = webView.getSettings();
        webSettings.setJavaScriptEnabled(true);

        webView.addJavascriptInterface(new WebAppInterface(this), "MyApp");
    }

    private static class WebAppInterface {
        private Context mContext;

        private WebAppInterface(Context c) {
            mContext = c;
        }

        @JavascriptInterface
        public void sendMessage(String toast) {
            Toast.makeText(mContext, toast, Toast.LENGTH_SHORT).show();
        }
    }
}

Web側

iOS/AndroidでJSの利用方法が違っているので、UAで判定して処理を分岐させます。

実際の案件でやる場合は、ブリッジするメソッドが多くなると思うので、インターフェースについてもうちょっと考慮が必要です。

<html>
<head>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <script>
    var NativeBridge = {
      send: function(methodName, args) {
        if (this.isIOS()) {
            window.webkit.messageHandlers[methodName].postMessage(args);
        } else if (this.isAndroid()) {
            MyApp[methodName](args);
        } else {
            console.log('browser access.');
        }
      },
      isIOS: function() {
        return window.navigator.userAgent.indexOf('Sample-iOS-App') !== -1;
      },
      isAndroid: function() {
        return window.navigator.userAgent.indexOf('Sample-Android-App') !== -1;
      }
    };
  </script>
</head>
<body>
  <span onclick="NativeBridge.send('sendMessage', 'test message.')">UA判定 タップイベント通知</span>
</body>
</html>

Cookieの設定

WebViewの方にCookieを設定したい場合があります。

ネイティブのログイン機能を利用して取得したトークン情報を、WebViewの中に設定するなど。

iOSの場合

ログイン認証したあとに、WKWebViewでCookieを使ってセッションを保つ方法と失敗例 - Qiita に書いてあるものをベースにします。

最初にリクエストするURL(この場合は /sample-html/webview.html)には、HTTP headerとしてCookieを送信しています。(今回は requestKey
それ以降の画面遷移の中でも必要なので、JavaScriptでcookieを設定しています。 https://developer.apple.com/documentation/webkit/wkuserscript/1537750-init

ただ、このあたり、実際に案件で使っていくと、うまくいかない可能性がありそうです。
とりあえず今見えている問題点としては、cookieを設定できるのが、読み込んでいるドメインに対するものだけ、という制約があります。

class MyViewController : UIViewController {
    private lazy var sharedProcessPool: WKProcessPool = WKProcessPool()
    private var webView: WKWebView!

    override func loadView() {
        let configuration = WKWebViewConfiguration()

        configuration.processPool = sharedProcessPool

        // set cookie for javascript
        let cookieScript = WKUserScript(source: "document.cookie = 'jsCookie=sample1;path=/';",
                                        injectionTime: .atDocumentStart,
                                        forMainFrameOnly: true)
        controller.addUserScript(cookieScript)

        webView = WKWebView(frame: .zero, configuration: configuration)
        view = webView
    }

    override func viewDidLoad() {
        let url = URL(string: "https://noboru-i.github.io/sample-html/webview.html")
        var request = URLRequest(url: url!)
        request.cachePolicy = .reloadIgnoringCacheData
        request.httpShouldHandleCookies = false

        request.addValue("requestKey=sample", forHTTPHeaderField: "Cookie")

        webView.load(request)
    }
}

Androidの場合

https://developer.android.com/reference/android/webkit/CookieManager.html#setCookie(java.lang.String,%20java.lang.String) を利用します。

CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setCookie("https://noboru-i.github.io/", "foo=bar; max-age=3600");

URLの読み込みを書き換える

URLの遷移に応じて、ネイティブの処理を実行させたいことがあるかと思います。

例えば、下記のような状況が考えられます。

  • 外部ドメインへの遷移は、外部のブラウザアプリを起動する。
  • URL schemeを判断して、ネイティブの任意の機能を実行する。(前述のJavaScriptインターフェースでも実現可能)

iOSの場合

アプリ内で表示するドメイン(今回は noboru-i.github.io)以外の場合、Safariで対象のURLを開きます。
https://developer.apple.com/documentation/webkit/wknavigationdelegate/1455641-webview のメソッドの中で、 decisionHandler(.cancel) を実行することで、対象URLの読み込みをキャンセル出来ます。

class MyViewController : UIViewController {
    override func loadView() {
        // ...
        webView = WKWebView(frame: .zero, configuration: configuration)
        webView.navigationDelegate = self
        view = webView
    }
}

// ...
extension MyViewController : WKNavigationDelegate {
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        let url = navigationAction.request.url!
        if(url.host! == "noboru-i.github.io") {
            decisionHandler(.allow)
            return
        }

        decisionHandler(.cancel)
        UIApplication.shared.open(url)
    }
}

Androidの場合

アプリ内で表示するドメイン(今回は noboru-i.github.io)以外の場合、ブラウザアプリで対象のURLを開きます。
https://developer.android.com/reference/android/webkit/WebViewClient.html#shouldOverrideUrlLoading(android.webkit.WebView,%20java.lang.String) でtrueを返すことで、読み込みを停止させることが出来ます。

ただ、上記のStringを引数に取るメソッドはDeprecatedになっているため、 https://stackoverflow.com/a/38484061 を参考にして下記のように、両方のメソッドで単一のメソッドを呼ぶようにしました。

public class MainActivity extends AppCompatActivity {
    private WebView webView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView = findViewById(R.id.webview);

        webView.setWebViewClient(new MyWebViewClient());
        webView.loadUrl("https://noboru-i.github.io/sample-html/webview.html");
    }

    private class MyWebViewClient extends WebViewClient {
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            return handleLoading(url);
        }

        @TargetApi(Build.VERSION_CODES.N)
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
            String url = request.getUrl().toString();
            return handleLoading(url);
        }

        private boolean handleLoading(String url) {
            if (Uri.parse(url).getHost().equals("noboru-i.github.io")) {
                return false;
            }

            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
            startActivity(intent);
            return true;
        }
    }
}

自己証明書(オレオレ証明書)でも読み込む

自己証明書を利用したサイトを、WebViewで読み込みたい場合があります。

例えば、下記のような状況が考えられます。

  • 開発中のため、まだドメイン・証明書を取得できておらず、自己証明書によって https にしている。
  • Charlesなどのプロキシを利用して、レスポンスを改ざんしてテストしたい。

重要:ただ、これらの処置は無用なセキュリティリスクを含んでしまうため、本番では処理を削除しておく必要があります。

iOSの場合

https://developer.apple.com/documentation/webkit/wknavigationdelegate/1455638-webview をオーバーライドして、無条件で信頼するようにします。

class MyViewController : UIViewController {
    private var webView: WKWebView!

    override func loadView() {
        webView = WKWebView(frame: .zero, configuration: configuration)
        webView.navigationDelegate = self
        view = webView
    }
}

extension MyViewController : WKNavigationDelegate {
    func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        let credential = URLCredential(trust: challenge.protectionSpace.serverTrust!)
        completionHandler(.useCredential, credential)
    }
}

Androidの場合

https://developer.android.com/reference/android/webkit/WebViewClient.html#onReceivedSslError(android.webkit.WebView,%20android.webkit.SslErrorHandler,%20android.net.http.SslError) にて、無条件で処理を進めます。

public class MainActivity extends AppCompatActivity {
    private WebView webView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        webView = findViewById(R.id.webview);

        webView.setWebViewClient(new MyWebViewClient());

        webView.loadUrl("https://noboru-i.github.io/sample-html/webview.html");
    }

    private class MyWebViewClient extends WebViewClient {
        @Override
        public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
            handler.proceed();
        }
    }
}

その他

Androidのライフサイクルに対する対応

WebViewには https://developer.android.com/reference/android/webkit/WebView.html#onPause() などのライフサイクルメソッドがあり、利用する際にはそれを実行してやる必要があります。

http://nein37.hatenablog.com/entry/2014/09/29/160018 の下記の部分です。

WebViewのライフサイクルを考慮していない

ただ、 https://developer.android.com/reference/android/webkit/WebViewFragment を見ても「onPauseonResume を手動で呼び出せ」としか書いてないため、destroyの実行が本当に必要なのか?は判断できていません。。

@Override
protected void onResume() {
    super.onResume();
    webView.onResume();
}

@Override
protected void onPause() {
    super.onPause();
    webView.onPause();
}

また、上記以外にもやっておいたほうが良いことなど、WebViewを使うにあたってのTipsなどが載っているので、 https://developer.android.com/guide/webapps/ は一通り読んでおいたほうが良さそうです。