(※この記事は 別媒体に投稿した記事 のバックアップです。 canonical も設定しています)
2022-03-17
こんにちは! フロントエンドエンジニアの もりや です。
今回は、ママリアプリ内で iOS/Android と WebView 間でデータを連携する仕組みを作った事例を紹介します。
2021年6月頃に実装してリリースし、現在(2022年3月)も問題なく使えています。
ママリの場合、例えば以下のような場面で使っています。
それまではその場その場で対応していましたが、これらを共通で便利に扱うための仕組みをそろそろ作りたいね、という話がでてきたので実装をしました。
この場合は window.mamariq.state
という名前空間を用意して、iOS/Android からそこを参照してもらう形にしました。
window.mamariq = {
state: {
PAGE: { // ページごとに名前空間を作る
KEY: VALUE, // 参照してもらいたい値を入れる
}
}
}
状態が変わったら、その値を更新していきます。React
の場合は、以下のように useEffect
で更新するようにする実装が多いです。
useEffect(() => {
window.mamariq.PAGE.KEY = value
}, [value])
あとは iOS/Android から必要な時にセットされている値を見にいけばOKです。
(iOS/Android での実装方法は「iOS/Android からの呼び出し方」の章を参照してください)
上記の方法は、状態を参照して処理するタイミングが iOS/Android 側で決めるものなので、すぐに iOS/Android へ状態を伝えたい場合には使えません。
そういった用途は(ページ遷移を伴わない)専用のディープリンクを作って対応しています。
ちなみに iOS でプッシュする場合あれば、以下のやり方が一般的だそうです(yanamura からお聞きしました)
【Swift】WKWebViewでJavaScriptのコールバックを受けつける(WKUserContentControllerの使い方)
ただ、このやり方だと iOS と Android で方式を変えないといけないので、共通で使えるやり方を考えました。
この場合は window.mamariq.action
という関数を用意しておき、iOS/Android から呼び出してもらう形にしました。
また、UI側では必要なシーンでリスナーを登録しておき、イベントの内容に応じて処理を登録する、という形にしています。
ざっくりと以下のような構造になっています。
+-----------------------+
| iOS/Android |
+-----------------------+
|
| イベント発行
v
+-----------------------+
| window.mamariq.action |
+-----------------------+
|
| イベント発行
v
+-----------------------+
| listeners |
+-----------------------+
^ |
登録/削除 | | イベント発行
| v
+-----------------------+
| UI (React) |
+-----------------------+
リスナーは以下のような型定義になっています。type
というイベントを識別するキーと、必要な場合は(iOS/Android 側で)payload
に情報を詰めて呼び出してくれます。
// リスナーに渡されるイベントの型定義
export interface MamariqBridgeEvent {
type: string
payload: ObjectType
}
// リスナーの型定義
type MamariqBridgeEventListener = (event: MamariqBridgeEvent) => void
リスナーを管理する配列と、それに追加・削除をするための関数を用意しておきます。
// リスナーを管理する配列
let listeners: MamariqBridgeEventListener[] = []
// リスナーを削除する関数
export const removeMamariqEventListener = (listener: MamariqBridgeEventListener): void => {
listeners = listeners.filter((_listene) => _listener !== listener)
}
// リスナーを追加する関数
export const addMamariqEventListener = (listener: MamariqBridgeEventListener): void => {
removeMamariqEventListener(listener) // 重複登録を避けるため、念の為一度削除する(通常は何も起こらない)
listeners.push(listener)
}
iOS/Android からイベントを受け取るための関数を定義しておきます。
window.mamariq = {
action: (event: unknown): void => {
const type = `${checkObject(event).type ?? '(unknown)'}`
const payload = checkObject(checkObject(event).payload) ?? {}
listeners.forEach((listener) => listener({ type, payload }))
}
}
checkObject
はオブジェクト型であるかを確認して、違う型であっても必ずオブジェクト型で返してくれる関数です。
type ObjectType = { [key: string]: unknown }
const checkObject = (target: unknown): ObjectType => {
if (typeof target === 'object' && target != null && !Array.isArray(target)) {
return target as ObjectType
}
return {}
}
実際に使用する側では、useEffect
を使ってこういう感じで実装してます。
useEffect(() => {
const receiveMamariqBridgeEvent = (event: MamariqBridgeEvent): void => {
switch (event.type) {
case 'EVENT_TYPE':
// do something
break
}
}
addMamariqEventListener(receiveMamariqBridgeEvent)
return () => removeMamariqEventListener(receiveMamariqBridgeEvent)
}, [])
そのコンテキストで必要なイベントのみハンドリングするようにしておくことで、リスナーを複数登録したり新しいイベントを追加した場合でも問題が起きにくくしています。
iOS/Android で同じ形でできることが一つのメリットかな、と思います。
また DevTools のコンソールを使って、実機を繋がなくてもブラウザ単体で動作確認ができるのも一つのメリットだと思います。
(他の方法はあまり知りませんので、推測です)
// 現状のステートを確認できる
console.log(window.mamariq.state.PAGE.KEY)
// iOS/Android からイベントが来た場合の動作を確認できる
window.mamariq.action({ event: "EVENT_TYPE"})
iOS の場合、boolean値 (true
, false
) が何故か 0
, 1
の数値として取得できてしまうとのことでした。
その原因はわかりません・・・。
(知っている方いましたらコメントいただけると嬉しいです)
今回は、それぞれ文字列 ("true"
, "false"
) とすることで対応しました。
yanamura と tommykw にご協力頂き、それぞれの OS での実装箇所を抜粋しました。
window.mamariq.xxx
のデータを以下のように取得します。
webView
.evaluateJavaScript(
"window.mamariq.xxx"
) { [weak self] result, _ in
if let result = result as? String, result == "true" {
// do something
} else {
// do something
}
}
window.mamariq.action()
で以下のようにイベントを発行します。
let params: [String: Any] = [
"type": "xxx",
"payload": [
"yyy": "zzz"
],
]
do {
let data = try JSONSerialization.data(withJSONObject: params, options: [])
guard let stringValue = String(data: data, encoding: .utf8) else {
assertionFailure()
return
}
contentViewController.webView.evaluateJavaScript(
"window.mamariq.action(\(stringValue))"
) { _, _ in }
} catch {
// do something
}
window.mamariq.xxx
のデータを以下のように取得します。
// データバインディングを利用
binding.webView.evaluateJavascript("window.mamariq.xxx") { result ->
if (result == "true") {
// do something
} else {
// do something
}
}
window.mamariq.action()
で以下のようにイベントを発行します。
// データバインディングを利用
binding.webView.evaluateJavascript("window.mamariq.action({type:'xxx'})") {}
既に実装して8ヶ月以上が経ち、本番環境でも使っていますが、現状では特に問題なく使えています。
やっていることがシンプルなので、あまり不具合が起きにくいというのもあるかもしれません。
この仕組みを作っておいたおかげで、iOS/Android と WebView でデータを連携する時に実装方法に迷うことがなくなり、実装に集中できるようになりました。
最初が少し面倒ですが、早めにやっておくとコスト的にペイできたな〜、と思っています。
コネヒトではエンジニアを募集しています!