動機・前提
今更ながら、とある案件でガワネイティブの要望が出てきそうだったので、事前調査としていくつか調べました。
"ガワネイティブ"という表記が一般的なのかは不明ですがこの記事では、 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の場合
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の場合
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 を見ても「onPause
と onResume
を手動で呼び出せ」としか書いてないため、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/ は一通り読んでおいたほうが良さそうです。