Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

動機・前提

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

"ガワネイティブ"という表記が一般的なのかは不明ですがこの記事では、 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/ は一通り読んでおいたほうが良さそうです。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away