イントロダクション#
現在、モバイル端末が主流の時代において、技術選定は基本的にハイブリッド開発(Hybrid)であり、ネイティブ Native と Web H5 の両方の利点を組み合わせて使用しています:
- ネイティブ技術は iOS(Objective C、Swift)、Android(Java)を指し、開発効率は低いですが、リリースはユーザーの更新に依存し、パフォーマンスが高く、機能のカバレッジも広いです。
- Web 技術は JavaScript を指し、開発効率は高いですが、リリース更新が柔軟である一方、パフォーマンスが低く、機能特性も制限されています。
このモデルでは、H5 はしばしばネイティブの機能を使用する必要があります。例えば、カメラを開く、ローカルアルバムを表示する、画像をアップロードする、ページを共有するなどです。また、ネイティブも Web 側に更新状態をプッシュする必要があります。
JavaScript は独立した JS コンテキスト(Webview コンテナ、JSCore など)で実行され、ネイティブの実行環境とは隔離されているため、ネイティブと Web 側の双方向通信を実現するメカニズムが必要です。このメカニズムが JSBridge です:
JavaScript
エンジンまたはWebview
コンテナを媒介として、協定プロトコルを通じて通信し、ネイティブと Web の双方向通信を実現するメカニズムです。
JSBridge を通じて、Web 側はネイティブ側の Java インターフェースを呼び出すことができ、ネイティブ側も Web 側の JS インターフェースを呼び出すことができ、双方向の呼び出しを実現します。
通信原理#
JS がネイティブを呼び出す#
JavaScript がネイティブを呼び出す方法は主に三つあります:API 注入、URL SCHEME のインターセプト、および prompt などの JS グローバルメソッドのオーバーライド。
API 注入#
主な原理は、Webview が提供するインターフェースを通じて、JS の実行環境(window)にオブジェクトやメソッドを注入し、JS が呼び出すときに直接対応するネイティブコードのロジックを実行することです。
iOS UIWebView 実装(主に JavaScriptCore
)
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
context[@"postBridgeMessage"] = ^(NSArray<NSArray *> *calls) {
// ネイティブロジック
};
フロントエンド呼び出し
window.postBridgeMessage(message)
iOS WKWebView 実装(主に WKScriptMessageHandler
)
@interface WKWebVIewVC ()<WKScriptMessageHandler>
@implementation WKWebVIewVC
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = [[WKUserContentController alloc] init];
WKUserContentController *userCC = configuration.userContentController;
// オブジェクトを注入し、フロントエンドがそのメソッドを呼び出すときにネイティブがキャッチできる
[userCC addScriptMessageHandler:self name:@"nativeBridge"];
WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"nativeBridge"]) {
NSLog(@"フロントエンドから渡されたデータ %@: ",message.body);
// ネイティブロジック
}
}
フロントエンド呼び出し
window.webkit.messageHandlers.nativeBridge.postMessage(message);
Android 実装
/**
4.2 以前、Android で JavaScript オブジェクトを注入するインターフェースは addJavascriptInterface でしたが、
このインターフェースには脆弱性があり、不正な者に利用され、ユーザーの安全を脅かす可能性があるため、
4.2 では新しいインターフェース @JavascriptInterface が導入され、このインターフェースを置き換え、安全問題を解決しました。
したがって、Android でオブジェクトを注入する方法には互換性の問題があります。
*/
public class JavaScriptInterfaceDemoActivity extends Activity {
private WebView Wv;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Wv = (WebView)findViewById(R.id.webView);
final JavaScriptInterface myJavaScriptInterface = new JavaScriptInterface(this);
Wv.getSettings().setJavaScriptEnabled(true);
Wv.addJavascriptInterface(myJavaScriptInterface, "nativeBridge");
}
public class JavaScriptInterface {
Context mContext;
JavaScriptInterface(Context c) {
mContext = c;
}
public void postMessage(String webMessage){
// ネイティブロジック
}
}
}
フロントエンド呼び出し
window.nativeBridge.postMessage(message);
URL Scheme のインターセプト#
URL Scheme
は URL に似たリンクで、アプリが直接相互に呼び出すために設計されています。形式は次の通りです:
<protocol>://<host>/<path>?<qeury>#fragment
カスタムの JSBridge 通信の URL Scheme:jsbridge://showToast?text=hello
主な原理は、ネイティブが Webview を読み込んだ後、Web が送信するすべてのリクエストが Webview コンポーネントを通過するため、ネイティブは対応するメソッドをオーバーライドしてリクエストをインターセプトし、カスタムの URL Scheme 形式に一致するかどうかを判断して解析処理を行い、ネイティブメソッドを呼び出すことができます。
Web が URL リクエストを送信する方法はいくつかあります。
a
タグ(欠点:ユーザー操作が必要)location.href
(欠点:ページ遷移による呼び出しの喪失の可能性)ajax
リクエスト(Android には対応するインターセプトメソッドがない)iframe src
を使用
上記の方法の中で iframe src
は初期から頻繁に使用されている手法で、互換性が非常に良いです:
- Android は
shouldOverrideUrlLoading
メソッドを提供してインターセプトします。 UIWebView
はshouldStartLoadWithRequest
を使用し、WKWebView
はdecidePolicyForNavigationAction
を使用します。
ただし、一定の欠点もあります:
- URL に基づいているため、長さに制限があります。
- リクエストの作成には一定の時間がかかり、API 注入の方法で同じ機能を呼び出すよりも時間がかかります。
prompt などの JS グローバルメソッドのオーバーライド#
Webview
には setWebChromeClient
というメソッドがあり、WebChromeClient
オブジェクトを設定できます。その中には onJsAlert
、onJsConfirm
、onJsPrompt
の三つのメソッドがあり、JS が window.alert
、window.confirm
、window.prompt
を呼び出すと、上記の対応するメソッドもトリガーされます。このメカニズムを利用していくつかの処理を行うこともできます。
上記のメソッドをインターセプトすることはパフォーマンスに一定の影響を与えるため、使用頻度が低いメソッドを選択する必要があります。Android では、他のメソッドに比べて prompt メソッドはほとんど使用されないため、prompt を占有するのが最適な方法です。
ネイティブが JS を呼び出す#
ネイティブ側が JS 側を呼び出すのは比較的簡単です。JavaScript は解釈型言語であり、最大の特性はいつでもどこでも解釈器を通じて JS コードの一部を実行できることです。したがって、結合された JavaScript コードの文字列を JS 解析器に渡して実行すればよいのです。ここでの JS 解析器は Webview です。
- iOS UIWebview は
stringByEvaluatingJavaScriptFromString
を使用します。
NSString *jsStr = @"実行するJSコード";
[webView stringByEvaluatingJavaScriptFromString:jsStr];
- iOS WKWebView は
evaluateJavaScript
を使用します。
[webView evaluateJavaScript:@"実行するJSコード" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
}];
- Android 4.4 以前は
loadUrl
を使用して実現し、コールバックを実行できず、呼び出すと WebView がリフレッシュされます。
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.loadUrl("javascript: " + jsCode);
- Android 4.4 以降は
evaluateJavascript
が提供され、JS コードを実行して戻り値を取得し、コールバックを実行できます。
String jsCode = String.format("window.showWebDialog('%s')", text);
webView.evaluateJavascript(jsCode, new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
}
});
コールバックを伴う呼び出し#
上記で述べたネイティブと Web の双方向通信のいくつかの方法は、一方の視点から見ると依然として単方向通信のプロセスです:Web 側がネイティブメソッドを呼び出し、ネイティブが直接関連操作を行いますが、結果を Web に返すことはできません。
では、どのようにして対側の操作後に結果を返すことができるのでしょうか?
以前の単方向通信に基づいて、一方が呼び出すときにパラメータに callbackId を追加して対応するコールバックをマークします。対側が呼び出しリクエストを受け取った後、実際の操作を行い、再度呼び出して結果と callbackId を返します。元の側は callbackId に基づいて対応するコールバックをマッチさせ、結果を渡して実行すればよいのです。実際には、両側が一度ずつ呼び出しを行います。
具体的な例:Web 側でボタンをクリックし、ネイティブ側の入力フィールドの値を取得し、その値を Web 側のポップアップで表示します。
// Web 側コード
<body>
<div>
<button id="showBtn">ネイティブの入力を取得し、Web ポップアップで表示</button>
</div>
</body>
<script>
let id = 1;
// id に基づいてコールバックを保存
const callbackMap = {};
// JSSDK を使用してネイティブ通信のイベントをラップし、グローバル環境の汚染を避ける
window.JSSDK = {
// ネイティブ側の入力フィールドの value を取得し、コールバックを伴う
getNativeEditTextValue(callback) {
const callbackId = id++;
callbackMap[callbackId] = callback;
// JSB メソッドを呼び出し、callbackId を渡す
window.NativeBridge.getNativeEditTextValue(callbackId);
},
// ネイティブ側から渡された callbackId を受け取る
receiveMessage(callbackId, value) {
if (callbackMap[callbackId]) {
// ID に基づいてコールバックをマッチさせ、実行する
callbackMap[callbackId](value);
}
}
};
const showBtn = document.querySelector('#showBtn');
// ボタンイベントをバインド
showBtn.addEventListener('click', e => {
// JSSDK を通じて呼び出し、コールバック関数を渡す
window.JSSDK.getNativeEditTextValue(value => window.alert('ネイティブ入力値:' + value));
});
</script>
// Android 側コード
webView.addJavascriptInterface(new NativeBridge(this), "NativeBridge");
class NativeBridge {
private Context ctx;
NativeBridge(Context ctx) {
this.ctx = ctx;
}
// ネイティブ側の入力値を取得
@JavascriptInterface
public void getNativeEditTextValue(int callbackId) {
MainActivity mainActivity = (MainActivity)ctx;
// ネイティブ側の入力フィールドの value を取得
String value = mainActivity.editText.getText().toString();
// Web で実行される JS コードを注入する必要があります
String jsCode = String.format("window.JSSDK.receiveMessage(%s, '%s')", callbackId, value);
// UI スレッドで実行
mainActivity.runOnUiThread(new Runnable() {
@Override
public void run() {
mainActivity.webView.evaluateJavascript(jsCode, null);
}
});
}
}
これにより、Web -> ネイティブのコールバックを伴う呼び出しが実現され、同様にネイティブ -> Web も同じロジックです(callbackId に対して bridgeName の概念があります)。
ここに完全な呼び出しシーケンス図も貼っておきます。全体のプロセスは JSONP のメカニズムに非常に似ており、両側が一つの呼び出しメカニズムに従う必要があります:
参照方法#
実際の JSBridge の参照方法には以下の二つがあり、それぞれ利点と欠点があります:
方法 | 利点 | 欠点 |
---|---|---|
ネイティブ側注入 | JSBridge バージョンがネイティブと容易に一致し、互換性を考慮する必要がない | 注入のタイミングが不確定で、失敗の再試行メカニズムが必要 同時に JS 側の呼び出し時にも注入されているかどうかを判断する必要があります |
JS 側参照 | タイミングが制御可能で、JS からの直接呼び出しが便利 | JSBridge とネイティブ間の多バージョンの互換性を考慮する必要があります |