はじめに
この記事はElixirアドベントカレンダー2024のシリーズ2、8日目の記事です
WebViewで表示しているWebアプリからネイティブブリッジを通してiOS、Androidのアプリから別のアプリ(ブラウザ)を呼び出す機能を実装していきます
今回の作業ブランチを作成します
git checkout -b feature/naive_bridge
ネイティブブリッジとは
Native BridgeとはWebViewとネイティブアプリとの連携を表す言葉で、Webからネイティブアプリのメソッドを呼び出したり、ネイティブアプリから表示しているWebページのJavascriptのメソッドを呼び出すことができます。
by https://engineering.mercari.com/blog/entry/20210814-6694cc7502/
ElixirDesktopはPhoenixのWebアプリケーションと各OSのアプリでWebViewを起動するネイティブ部分に分かれており
Webアプリケーション側から特定のJavaScript関数を実行し、
ネイティブのWebView側で設定した特定の関数の実行をトリガーとして
外部のブラウザを開く、別のアプリを開く、各種センサーデータの取得、課金機能などが実行できます
Web app -> JS Handler -> OSネイティブ機能
各OSのブリッジ
各OSのブリッジについて解説します
iOS WKScriptMessageHandler
iOSではWKScriptMessageHandlerを使用します
WKScriptMessageHandlerを継承したクラス作成し、
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
のメソッドを実装します
そして、init
関数内でWKWebViewConfiguration
のuserContentController
を引数にしてWebViewを作成することで、WebViewで表示しているWebアプリのJavaScriptからwindow.webkit.messageHandlers.[メソッド名].postMessage(ペイロード)
で呼び出すことが出来ます
final class WebView: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var webview: WKWebView
override init() {
...
let configuration = WKWebViewConfiguration()
configuration.userContentController.add(self, name: [メソッド名])
webview = WKWebView(frame: CGRect.zero, configuration: configuration)
...
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case "[メソッド名]":
// ブラウザを開くなどネイティブ機能を実行する処理を追加
// callbackを行いたい場合は evaluateJavaScriptで行う
// ペイロード等は message.bodyで取得できる
default: // 設定していないメソッド名を実行された時の処理
assertionFailure("Received invalid message: \(message.name)")
}
}
}
Android
AndroidではWebAppInterfaceを使用します
MainActivityクラスの外にWebAppInterfaceクラスを作成し、@JavascriptInterface
アノテーションを付けた関数を作ることで、WebViewで表示しているWebアプリケーションからAndroid.method_name
で呼ぶことが出来ます
class WebAppInterface(private val mContext: Context) {
@JavascriptInterface
fun method_name (payload) {
// ネイティブ処理をここに書く
// コールバックが欲しい場合は
// wevbiew.loadUrl("javascript:実行するJSコード")
}
}
onCreate内でWebAppInterfaceインスタンスを作成し、WebViewにインターフェイスを追加して完了です
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val webView: WebView = findViewById(R.id.webview)
val webInterface = WebAppInterface(this@MainActivity)
webView.addJavascriptInterface(webAppInterface, "Android")
...
}
}
実装
iOS,Androidで実装方法がわかったので外部のURLをクリックした際にOSのデフォルトブラウザで開くようにします
iOS側
desktop_setupのテンプレートで作成した場合すでに実装済みですので該当箇所のハイライトを行います
super.init()
実行後に以下を追加します
configuration.userContentController.add(self, name: [関数名])
今回はopenSafari
となり、別の関数を追加したい場合は同様のコードを下に追加していきます
override init() {
// Enable javascript in WKWebView to interact with the web app
let preferences = WKPreferences()
let page = WKWebpagePreferences()
page.allowsContentJavaScript = true
let configuration = WKWebViewConfiguration()
configuration.limitsNavigationsToAppBoundDomains = true
configuration.preferences = preferences
configuration.defaultWebpagePreferences = page
webview = WKWebView(frame: CGRect.zero, configuration: configuration)
webview.allowsBackForwardNavigationGestures = true
webview.scrollView.isScrollEnabled = true
super.init()
+ configuration.userContentController.add(self, name: "openSafari")
....
}
userController関数にopenSafari
が呼ばれた時の処理を追加します
canOpenURL
で開くことができるURLかチェックを行い、問題なければopen
でデフォルトに指定しているブラウザでURLを開きます
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
+ case "openSafari":
+ let url = URL(string:message.body as! String)
+ if( UIApplication.shared.canOpenURL(url!) ) {
+ UIApplication.shared.open(url!)
+ }
case "error":
// You should actually handle the error :)
let error = (message.body as? [String: Any])?["message"] as? String ?? "unknown"
assertionFailure("JavaScript error: \(error)")
default:
assertionFailure("Received invalid message: \(message.name)")
}
}
Android
Android,デスクトップアプリの場合は特に手を加えること無く開けるので特にやることはありません
Phoenix
Webアプリケーション側の実装を行います
今回はLiveViewとJavaScript間で相互に関数の実行やデータのやり取りができる JS Hooksを使ってネイティブブリッジを作っていきます
最初にhooksフォルダをassets配下に作ります
mkdir assets/hooks
ネイティブブリッジにopen_safari
を追加します。ネイティブブリッジを使うのはiOSだけなので、iOSだけ正常に終了して、他のOSだと例外を握りつぶして何も起こらないようにします。
Android,iOSで分ける場合は、UAを見るかAndroidがundefかどうかで切り分けます
const NativeBridge = {
mounted() {
this.handleEvent("open_safari", ({ url }) => {
try {
window.webkit.messageHandlers.openSafari.postMessage(url);
} catch {}
});
},
};
export default NativeBridge;
ブリッジが出来たのでindex.jsでまとめます
import NativeBridge from "./NativeBridge";
export default Hooks = {
NativeBridge: NativeBridge,
};
JS Hookが出来たのでapp.jsで読み込みます
import "phoenix_html";
// Establish Phoenix Socket and LiveView configuration.
import { Socket } from "phoenix";
import { LiveSocket } from "phoenix_live_view";
import topbar from "../vendor/topbar";
+ import Hooks from "./hooks";
let csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
+ hooks: Hooks,
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
});
JS Hooksが出来たので実行する箇所で読み込み、イベントトリガーを追加します
JS Hooksを読み込む箇所はid属性をつける必要があります
phx-click="イベント名"
でhandle_event/3
関数で待ち受けているイベントを発火させます
phx-value-[パラメーター名]={パラメーター}
でhandle_event/3
関数の第2引数に値を渡せます
def render(assigns) do
~H"""
<div
class="flex justify-center w-screen h-screen bg-local bg-contain bg-center bg-no-repeat"
style={"background-image: url(#{@page_data.image}); background-color: #{@page_data.bg}"}
>
...
- <p id="freepik" class="absolute bottom-2 right-4 text-sm">
+ <p id="freepik" class="absolute bottom-2 right-4 text-sm" phx-hook="NativeBridge">
<a
href={@page_data.link}
class="underline text-blue-600"
target="_blank"
+ phx-click="open_safari"
+ phx-value-url={@page_data.link}
>
Image by storyset on Freepik
</a>
</p>
"""
end
push_event/3
関数でJS Hookのthis.handleEvent
で実装しているイベントを発火させます
第3引数は渡す値がなくても必須で%{}
(空のマップ)を渡します
def handle_event("finish", _params, socket) do
{:noreply, push_navigate(socket, to: ~p"/")}
end
+ def handle_event("open_safari", %{"url" => url}, socket) do
+ socket
+ |> push_event("open_safari", %{url: url})
+ |> then(&{:noreply, &1})
+ end
動作確認
iOSで問題なくデフォルトブラウザでURLを開くことが出来ました
またAndroid、デスクトップアプリでURLをブラウザで開けることが確認できました
今回はネイティブブリッジのテストはLiveviewの範囲外になるため、テストは書けませんがplaywright-elixir
を使えば書けるかもしれません
最後に
今回はネイティブブリッジの解説と実装JS Hookについて解説しました
次は行きたいスポットをグループ分けするフォルダのCRUD画面とリレーションに付いて解説します
本記事は以上になりますありがとうございました
参考サイト
https://developer.apple.com/documentation/webkit/wkscriptmessagehandler
https://qiita.com/the_haigo/items/aed1cc6b57d596dc0dc1
https://smile-jsp.hateblo.jp/entry/android/webview-js-to-native-code
https://engineering.mercari.com/blog/entry/20210814-6694cc7502/
https://qiita.com/atsuto/items/1acb87ce9f0512cf8641
https://github.com/mechanical-orchard/playwright-elixir
https://qiita.com/RyoWakabayashi/items/ca2924b652ec358008d7