本記事は「Ionicアドベントカレンダー2022」の24日目の記事です。
早速ですが、以下の図を見て下さい。
この図はCordovaの公式HPにのってるアーキテクチャ図です。
Ionic1~2の時代に触り始めた人は、一回は見たことある図だと思います。
この図にあるように、初期のIonicはCordovaを利用してクロスプラットフォームを実現しており、WebView上のJavaScriptからNativeの機能を呼び出せるところを売りにしていました。
現在は、Cordovaの後継Capacitorに変わっていますが、基本的に呼び出し部分の仕組み自体は変わっていません。
今回はこの図のネイティブ機能呼び出し周りについての記事になります。
前段
Ionic関係のサービスを仕事でも趣味でも開発してます。
たいていの場合、Web版ならFirebase Hosting、アプリ版ならCapacitorを使うことが多いんですが、特殊な例として独自のiOSアプリ(Swift)上にWebViewでIonicのアプリをのせるパターンでの開発がありました。
アプリケーション自体は、buildした成果物をバンドルしたりすれば良いですが、困ったのはネイティブ機能の呼び出しです。
アプリとなると、Push通知やカメラ機能など、様々なネイティブ機能を使いたいところです。
Capacitorを使っていれば、公式プラグインを使えば良いのですが、独自iOSアプリとなると、どうやれば良いのかわかりませんでした。*ひょっとしたら部分的にCapacitorを追加できるかもしれませんが。
そこで、今回は、CapacitorのNative API呼び出し周りの実装を参考に、同じ機構を実装することとしました。
Native API呼び出しの処理
CapacitorのコードはGitHubで公開されています。
こちらのリポジトリからNative API呼び出し周りの実装を見つけて、その処理を移植できればと思います。
CapacitorはSwift(一部Objective-C)で記述されており、普段TypeScriptとRubyでの開発が多い私には苦戦するところもありましたが、それっぽいところを見つけました。
fileprivate(set) var contentController = WKUserContentController()
enum WebViewLoadingState {
case unloaded
case initialLoad(isOpaque: Bool)
case subsequentLoad
}
fileprivate(set) var webViewLoadingState = WebViewLoadingState.unloaded
private let handlerName = "bridge"
init(bridge: CapacitorBridge? = nil) {
super.init()
self.bridge = bridge
contentController.add(self, name: handlerName)
}
どうやら、CapacitorはWKUserContentControllerというWebView上のJavaScriptからメッセージを受け取れるようになる関数を使ってNative APIとのやり取りを実現しているようです。
こちらの設定していれば、JavaScriptから以下のコードでNative側の処理を呼び出せるようです。
capacitor/native-bridge.ts at 57f8b39d7f4c5ee0e5e5cb316913e9450a81d22b · ionic-team/capacitor
win.webkit.messageHandlers.bridge.postMessage(data);
また、実際にNative側でWebViewのJS側からの処理を受け取るコードは以下のようです。typeでmessageやcordovaなど別れており、その後のpluginIdやmethodNameなどを各プラグイン呼び出し時などに使っているようです。
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let bridge = bridge else {
return
}
let body = message.body
if let dict = body as? [String: Any] {
let type = dict["type"] as? String ?? ""
if type == "js.error" {
if let error = dict["error"] as? [String: Any] {
logJSError(error)
}
} else if type == "message" {
let pluginId = dict["pluginId"] as? String ?? ""
let method = dict["methodName"] as? String ?? ""
let callbackId = dict["callbackId"] as? String ?? ""
let options = dict["options"] as? [String: Any] ?? [:]
if pluginId != "Console" {
CAPLog.print("⚡️ To Native -> ", pluginId, method, callbackId)
}
bridge.handleJSCall(call: JSCall(options: options, pluginId: pluginId, method: method, callbackId: callbackId))
} else if type == "cordova" {
let pluginId = dict["service"] as? String ?? ""
let method = dict["action"] as? String ?? ""
let callbackId = dict["callbackId"] as? String ?? ""
let args = dict["actionArgs"] as? Array ?? []
let options = ["options": args]
CAPLog.print("To Native Cordova -> ", pluginId, method, callbackId, options)
bridge.handleCordovaJSCall(call: JSCall(options: options, pluginId: pluginId, method: method, callbackId: callbackId))
}
}
}
ここらへんの処理を移植すれば、WebView側からNativeの機能呼び出しを実現できそうです。ただ、これだけだと不完全で、Push通知利用時のtokenや、カメラ撮影後のバイナリ(画像データ)をWebView側に返す処理(コールバック)が必要になります。
そのため、引き続きコールバック処理がどのようになっているか追っていきます。
Native APIからWebViewへのコールバック
コールバックの実装を追うために、Native API呼び出し周りの処理のその先を追っていきます。見てみると、Native側で処理を受け取った後、以下のメソッドを実行している様子です。
bridge.handleJSCall(call: JSCall(options: options, pluginId: pluginId, method: method, callbackId: callbackId))
こちらのメソッドを追っていくと最終的に以下のコードにたどり着きました。
capacitor/CapacitorBridge.swift at 9f1d863c1222096334a0dd05f39ce7f984a2763a · ionic-team/capacitor
func toJs(result: JSResultProtocol, save: Bool) {
let resultJson = result.jsonPayload()
CAPLog.print("⚡️ TO JS", resultJson.prefix(256))
DispatchQueue.main.async {
self.webView?.evaluateJavaScript("""
window.Capacitor.fromNative({
callbackId: '\(result.callbackID)',
pluginId: '\(result.pluginID)',
methodName: '\(result.methodName)',
save: \(save),
success: true,
data: \(resultJson)
})
""") { (_, error) in
if let error = error {
CAPLog.print(error)
}
}
}
}
上記コードではevaluateJavaScriptなる記述があります。これは、WebView上に対して記述したJavaScriptを実行するメソッドです。実行しているJavaScript内のwindow.Capacitor.fromNativeは、Capacitorがwindowオブジェクトに生やしてるメンバのようです。この処理の中で、呼び出し元のJavaScriptにNativeからの値を返却しているようです。
capacitor/native-bridge.ts at 57f8b39d7f4c5ee0e5e5cb316913e9450a81d22b · ionic-team/capacitor
cap.fromNative = result => {
returnResult(result);
};
さて、ここまでざっくりNative APIの呼び出しと、その結果の返却方法がわかってきました。これを使えば、NativeとWebView間で相互に通信ができ、Push通知のtoken取得といったことが行えそうです。
ただ、コードを見ていて思いましたが、Capacitorの処理をそのまま移植するのはだいぶ大変そうです。なぜなら、Capacitorの実装はPluginを利用する前提で作られており、ほんの少しだけネイティブ機能を使いたい場合には、オーバースペック気味かと思います。
ということで、私は機能を削ぎ落とした状態で使っていたりします。以下は、Push通知許可設定の実装の一例です。
private func createWebView() -> WKWebView {
// 省略
let userController: WKUserContentController = WKUserContentController()
userController.add(self, name: "bridge")
config.userContentController = userController
// 省略
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let methodName = message.body as? String ?? ""
switch methodName{
case "callRegisterNotification":
let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
UNUserNotificationCenter.current().requestAuthorization(options: authOptions,completionHandler: {granted, _ in
let jscript = """
window.SampleCallback({granted: \(granted)})
"""
DispatchQueue.main.async {
self.getWebView().evaluateJavaScript(jscript, completionHandler: { (object, error) -> Void in})
}
})
break
default:
print("default")
}
}
JavaScript(TypeScript)側の呼び出しの一例は以下です。
callRegisterNotification(): Promise<{ granted: boolean }> {
return new Promise(resolve => {
if (window.webkit && window.webkit.messageHandlers) {
let bridge = new Promise(resolve => {
window.SampleCallback = resolve;
window.webkit.messageHandlers.bridge.postMessage(
"callOpenNotificationSettings"
);
});
bridge.then((data: { granted: boolean }) => {
resolve(data);
});
}
}
}
カメラやHapTicなど、その他のネイティブ機能も似た要領で実装していけば対応できます。
内容自体は、たいした実装ではないので慣れてる人なら1日もかからずいけるんじゃないかと思います。
まとめ
以下、本記事のまとめです。
- NativeとWebViewのやり取りは、WKUserContentController、executeJsを使って行っている。
- Capacitorのコード完全移植はPlugin的な実装が必要ないならやらなくても良いかも。
- 本記事の内容を使えばアプリの一部でIonic利用。しかもネイティブ機能呼び出しも可能という運用がやりやすくなるかも。
書いてて思いましたが、結構局所的な需要だったかもしれない。。。
そして、本記事の内容、最終的にはWebViewとNativeでデータやり取りするという点においては、他に優良な記事がいっぱいありそうではあります。
しかし、記事を書く過程でCapacitorのコードを読む機会が得れたのは自分的には良かったです。
それでは!