webview_flutterでJavaScriptからFlutter処理を呼び出す

この記事はFlutter Advent Calendar 2021(カレンダー2)の9日目の記事です。

こんにちは。株式会社ビデオマーケットの仙台オフィスでモバイルアプリ全般を担当しているasmzです。

弊社サービス「videomarket」ではスマートフォン(iOS/Android)向けアプリにFlutterを採用しています。その開発の中で、アプリ内WebViewで表示したWebサイトからFlutter側へのデータ受け渡し方法を調査したので、今回はその調査結果をまとめました。

やりたいこと

例えば、以下に挙げたような用途のページの場合、アプリで実装するのではなくアプリ内でWebサイトを表示させ、そのサイト内で行った操作の結果をアプリ側で受け取りたい、と思う機会は割と多いかなと思います。

  • ある期間を過ぎたら不要になるようなキャンペーンページ
  • マーケティング観点でいろいろデザイン変更を試したいランディングページ
  • 既存Webサービス側で実装済みの機能を、アプリ側でも利用したい

こういった要件を実現する方法の一つとして、今回は以下の仕組みを実装します。

  • アプリ内にWebViewを用意し、Webサイトを表示
  • Webサイト側でなんらか操作を行った結果のデータを、Flutterアプリ内ロジックで利用

f:id:asmz0:20211203191158p:plain:w500

このようなアプリのWebViewとWebサイト(JavaScript)との連携の仕組みは、ネイティブでのアプリ開発では以前から存在していた(AndroidのaddJavascriptInterfaceなど)のですが、Flutterでの実現方法は知らなかったため今回調査しました。

なお、この記事では深く触れませんが、この方式はWebサイト側からアプリのコードを実行できるという点でセキュリティリスクも存在するため、信頼できるコンテンツであることをチェックするなど、実際に利用する際はセキュリティ面の考慮が必要です。

webview_flutterの導入

まずアプリ内でWebView表示を行う必要がありますが、今回はflutter.dev公式の「webview_flutter」を利用します。

pub.dev

アプリへの導入は上記サイトのドキュメントをご確認いただければと思いますが、以下のような形で簡単に導入できます。

[pubspec.yaml]

dependencies:
  webview_flutter: ^2.3.1

[main.dart]

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('WebView Test'),
        ),
        body: const MyWebView(),
      ),
    );
  }
}

[my_web_view.dart]

class MyWebView extends StatefulWidget {
  const MyWebView({Key? key}) : super(key: key);

  @override
  MyWebViewState createState() => MyWebViewState();
}

class MyWebViewState extends State<MyWebView> {
  @override
  void initState() {
    super.initState();
    // Enable hybrid composition.
    if (Platform.isAndroid) WebView.platform = SurfaceAndroidWebView();
  }

  @override
  Widget build(BuildContext context) {
    return WebView(
      initialUrl: 'https://flutter.dev',
    );
  }
}

[実行結果]

f:id:asmz0:20211202191437p:plain:w300

なお、AndroidのWebView埋め込みはPlatform Viewsに依存しており、導入の際にはHybrid compositionモードかVirtual displaysモード(デフォルトはこっち)かを選択する必要があります。詳しくは公式ドキュメントをご参照ください。

Flutter側:JSからデータを受け取るJavascriptChannel用意

上記で用意したWebViewはただWebサイトを表示しているだけですが、独自に用意した何らかのWebサイトからデータを受け取るために今回は「JavascriptChannel」を利用します。

pub.dev

JavascriptChannelはWebView内で実行されるJavaScriptとデータのやり取りを行う名前付きチャンネルです。

以下の通り、任意の名前を付けたチャンネルを定義し、そのチャンネルを使って届いたデータをハンドリングできます。

[my_web_view.dart]

class MyWebView extends StatefulWidget {
  ...
}

class MyWebViewState extends State<MyWebView> {
  ...

  @override
  Widget build(BuildContext context) {
    return WebView(
      initialUrl: 'https://path/to/something/url',
      javascriptMode: JavascriptMode.unrestricted,
      javascriptChannels: {
        JavascriptChannel(
          name: 'flutterApp', // 任意の文字列(JS側の呼び出し名になる)
          onMessageReceived: (result) async {
            // result.message でJSからのデータを取得可能
            print(result.message);
          },
        )
      },
    );
  }
}

なお、その名の通りデータやり取りにはJavaScriptを用いますが、webview_flutterはデフォルトでJavaScript無効となっているため、有効にしておく必要があります。(javascriptMode: JavascriptMode.unrestricted

Web(JS)側:Flutterへのデータ送信

Flutter側は先に定義したJavascriptChannelのnameに指定した名前のObjectをウォッチしていますので、Web側からデータを送信したい場合はJavaScriptでそのnameと同名のObject(今回の例だとflutterApp)に対し、postMessage()を実行することでデータを送信できます。

flutterApp.postMessage("Post message from JavaScript!!!");

ローカルHTMLでお試し送信

動作確認のため、Flutter内ローカルにHTMLを置いて挙動を確認してみます。

[pubspec.yaml]

flutter:
  assets:
    - test.html

[assets/test.html]

<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <script>
        function sendFlutterApp() {
            // Flutterへデータ送信
            flutterApp.postMessage("Post message from JavaScript!!!");
        }
    </script>
</head>
<body>
    <button onclick="sendFlutterApp()">Send Message</button>
</body>
</html>

[my_web_view.dart]

class MyWebView extends StatefulWidget {
  ...
}

class MyWebViewState extends State<MyWebView> {
  ...

  @override
  Widget build(BuildContext context) {
    return WebView(
      onWebViewCreated: (controller) async {
        // ローカルHTML読み込み
        final html = await rootBundle.loadString('assets/test.html');
        final uri = Uri.dataFromString(
          html,
          mimeType: 'text/html',
          encoding: Encoding.getByName('utf-8'),
        );
        controller.loadUrl(uri.toString());
      },
      javascriptMode: JavascriptMode.unrestricted,
      javascriptChannels: {
        JavascriptChannel(
          name: 'flutterApp', // 任意の文字列(JS側の呼び出し名になる)
          onMessageReceived: (result) async {
            // result.message でJSからのデータを取得可能
            print(result.message);
          },
        )
      },
    );
  }
}

[実行結果]

f:id:asmz0:20211203161537p:plain:w300

「Send Message」ボタンタップ後のデバッグコンソール表示

I/flutter (27447): Post message from JavaScript!!!

このように、元々はJavaScript側に記載していた文字列がFlutter側に受け渡されていることが確認できます。

まとめ

以上のような仕組みで、アプリ内WebViewで表示したWebサイトからFlutter側へのデータを受け渡すことができました。

今回挙げた例では、受け取った文字列データをただprintしているだけなのであんまり意味は無いですが、実際にはここでJSON形式などを用いてより複雑なデータを受け渡すことによって、様々なユースケースに対応できるかと思います。