見出し画像

Flutter WebでのQRコード読み取りライブラリ検証と選定のポイント

こんにちは。株式会社フィノバレーでモバイルアプリチームのリーダーを担当している照井です。

今回の記事では、今年2024年の4月頃にFlutterで開発したQRコード読み取りWebアプリ開発にあたってブラウザ対応のQRコード読み取りライブラリを調査した時の話を書こうと思います。


背景

発端は「とある地域で商品券に印字されたQRコードを読み取るアプリを作りたい」という要望でした。話を聞いた当初はFlutterを使用しAndroid/iOSアプリとして提供する想定でした。

ただ、このアプリは利用者と利用期間、機能が非常に限定されていることから「可能な限り簡素な構成で使えるようにしたい」という要件がありました。
ネイティブアプリとして提供する場合、リリース時の審査や各ストアにアップする手間がありますが、さらに今回は利用者がそれぞれのストアからインストールして利用するのが難しいケースもあるとのことでした。

そこで「ネイティブではなくWebアプリとして提供できないか?Webアプリであれば利用者に"このURLを開いてください"と伝えれば良いしインストール手順も不要なので助かる」という話になりました。

短期スケジュールではあったもののFlutterなら使い慣れていたので可能だと思ったのですが、Flutter Webでカメラ機能を使った経験がなかったためカメラライブラリを検証することにしました。

ライブラリ候補の選定

今回は以下の3つのQRコードスキャナーライブラリについて検討することにしました。

  1. mobile_scanner

  2. ai_barcode_scanner

  3. flutter_web_qrcode_scanner

mobile_scannerの検証

  • pub.devのサイト: https://pub.dev/packages/mobile_scanner

  • 検証で使用したver: ver4.0.1  ver5.0.0-dev2

mobile_scannerは、MLKitをベースにしたFlutter用のユニバーサルスキャナーでAndroidではCameraX、iOSではAVFoundationを使用しているようです。

ver4.0.1での検証
検証当時の最新verであった4.0.1を使用しました。
このライブラリを使ってサンプルコードを書いて検証した結果、iPhoneでは問題なく動作したのですが、WebおよびAndroidで一度画面遷移して再びQRコード読み取り画面を開くと「真っ黒な画面のままでカメラが動作しない」という問題が発生しました。

どうやらAndroidライフサイクルとFlutterのバージョンが関係しているようで、2024年4月頃はGitHubのissue(以下のURL)で議論が続いており解決には至っていませんでした。
(2024年8月時点では最新のver5系がリリースされており、このissueの問題も解消されています)
https://github.com/juliansteenbakker/mobile_scanner/issues/976

ver5.0.0-dev2での検証
2024年4月の時点でver5.0.0の開発版が出ていたため、ver5.0.0-dev2で試しましたが残念ながらこの問題は解消されていませんでした。
さらにWebでデバッグ実行を行うと以下のエラーが発生し、カメラが起動しないという問題が発生しました。

MobileScannerException: code genericError, 
 message: TypeError: null: type 'Null' is not a subtype of type 'String'

このエラーはMediaTrackConstraintsDelegate()メソッドでMediaTrackSettingsをreturnする際にsettings.facingModeがnullになっていることが原因のようです。

デバッグ実行ではなく実際にHostingにデプロイして実行するとエラーは発生せず動作するのですが、それでもUncaught Bad state: No elementというエラーが出ていました。また、1回実行した後にcontroller.startを行うとフリーズしてしまう問題も発生しました。

htmlレンダラーとCanvasKitのどちらを試しても解決できなかったため、devがついている通りまだ改善の余地がありそうでした。

ver4.0.1での試行錯誤の結果
なんとかAndroidで発生する問題を回避できないか試行錯誤し、Android端末上でも動作するようにできました。

回避手段として実装したコードがかなりトリッキーな方法だったので記事に載せるか迷ったのですが、記事を書いている2024年9月時点での最新バージョン5.1.1で再検証したところ問題が解消されていました。
今後はver5系以降が使われ、私がとった回避方法はもう使われることはないと思うので、参考までにどのようなコードを書いたかも記事の後半に記載しました。

ai_barcode_scannerの検証

  • pub.devのサイト: https://pub.dev/packages/ai_barcode_scanner

  • 検証で使用したver: 3.4.3

ai_barcode_scannerは、mobile_scannerをラップしているライブラリであり、基本的にはmobile_scannerと同等の機能を持っています。

このライブラリにはWeb上でQRコードをスキャンする際の見た目を調整するためのオーバーレイコードが実装されています。
特に、スキャンウィンドウをWebで適用する際に視覚的な枠を提供する機能が追加されていますが、あくまで見た目の調整だけであり枠外でもQRコードは読み取れてしまいます。
そのため、実際に枠内でのみQRコードを撮影したい場合は、自力でコードを実装する必要があります。

動作確認中に「一度画面遷移して再びQRコード読み取り画面を開くとカメラが動作しない。ただし、コントローラのswitchCamera()を実行すると動作するようになる」という挙動が確認できました。

そこで画面表示時にcontroller.switchCamera()を裏で実行することで、常にカメラが動いているように見せられるのではないかと考え、試してみました。結果としてWebのChromeではこの手法が成功しましたが、Android14端末では同様には動作しませんでした。

さらに、initStateでcontroller.switchCamera()を実行すると、真っ暗な画面になってしまう問題も発生しました。

flutter_web_qrcode_scannerの検証

  • pub.devのサイト: https://pub.dev/packages/flutter_web_qrcode_scanner

  • 検証で使用したver: 1.1.1

flutter_web_qrcode_scannerは、jsQR.jsライブラリを使用してカメラにネイティブアクセスし、QRコードをスキャンするためのライブラリのようです。デバッグ実行時に以下のエラーが発生しました。

The platformViewRegistry getter is deprecated and will be removed in a future release

これは非推奨のAPIであるplatformViewRegistryを使用しているためです。
このエラーはすぐに修正される見込みがなさそうだったため今回は無視しました。このライブラリはシンプルで使いやすかったです。

選定ポイントと採用ライブラリ

私・・に限らずおそらく開発者の皆さんが自身のプロダクトにライブラリを採用する上で重視している点は以下のようなポイントだと思います。

  • 利用者とネット上での情報量が多いか

  • メンテナンスの頻度

  • GitHubのスター数やpub.devのLIKE数が多いか

  • issueが放置されていないか

これらのポイントに当てはめると、検証した3つのライブラリの中ではmobile_scannerを採用するのが一番良さそうだなと判断しました。
mobile_scanner ver4.0.1は問題はあるものの回避方法を見つけられましたし、その問題はissueでやり取りがされているので次のメジャーバージョンアップではおそらく修正されると考えられます。

今回は利用者と期間が限定されているという特殊条件のため多少このポイントからは外れても「今機能するならそれで良い」という選択もできます。
ただ、個人的なスキルアップも常に兼ねたいし万が一「やっぱ末長く使いたい」「他の環境でも同じようなことをしたい」となるケースもあるため、今回はmobile_scannerを採用することにしました。
ただ問題があるのはわかっていたので、万が一を想定してflutter_web_qrcode_scannerに切り替えられるよう準備もしていました。

mobile_scannerでの実装

mobile_scannerを使ってどう実装したのか解説したいと思います。QRコードを読み取る機能はStatefulWidgetで定義しました。
要点となる関数や処理を書いていきます。disposeなど実際のコードには記載していますがこの記事には載せていません。

Stateクラスのフィールド

カメラのコントロールはこちらで制御したいのでautoStartはfalseにしています。この引数はver5.0.0では廃止されたようです。
_availableCameraはver4.0.1でAndroid・Webで発生した問題の回避用に用意したフラグで、カメラの準備ができたらこのフラグをtrueにします。build関数内では、このフラグがtrueかどうかでQRコードを読み取るカメラUIを表示するか制御しています。

final _controller = MobileScannerController(autoStart: false);
// ver4.0.1でAndroid・Webで発生した問題の回避用に用意したフラグ
bool _availableCamera = false;

build関数

initState関数は記載内容が多いので先にbuild関数を載せます。MobileScannerControllerのstopとstartでカメラを制御し、読み取ったコードからデータが読み取れたら確認のダイアログを表示します。ここはほとんど公式のサンプルと変わらないと思います。

@override
Widget build(BuildContext context) {
  if (!_availableCamera) {
    return const Center(child: CircularProgressIndicator());
  }

  return Stack(
    children: [
      MobileScanner(
        controller: _controller,
        onDetect: (capture) async {
          _controller.stop();
          final barcode = capture.barcodes.first;
          if (barcode.rawValue == null) {
            _controller.start();
            return;
          }
          await ReadQrDialog.show(context, barcode.rawValue!);
          _controller.start();
        },
      ),
    ],
  );
}

initState関数

「Android・Webの場合、再度カメラを起動したときに真っ黒な画面になってしまう」という問題をここで無理やり回避しています。この問題はmobile_scannerのexampleにあるサンプルアプリでも発生しましたが、サンプルアプリはライト点灯や前面・背面カメラの切り替え機能などがあったのでそれを試した結果、カメラの切り替えをしたら真っ黒な画面からカメラが使える状態に戻ることがわかりました。

つまりMobileScannerControllerクラスのswitchCamera()を実行すれば再びカメラが使える状態になることがわかったので、カメラを切り替える処理を最初に入れることにしました。
最初のinitState関数で実装したコードは以下通りです。

await _controller.switchCamera();
// 前面カメラなら背面カメラに戻す
final state = _controller.cameraFacingState.value;
if (state == CameraFacing.front) {
  await _controller.switchCamera();
}
setState(() => _availableCamera = true);

switchCamera関数は前面と背面のカメラ切り替えを行う関数で、今は背面に複数のカメラが搭載されている端末がありますがそれを順番に切り替える・・という関数ではなさそうなので2回切り替えすれば前面→背面→前面と戻せると考えました。

ここで問題が発生しました。まずこのinitState関数のタイミングではswitchCamera関数が動作しませんでした。画面起動時はinitState関数の裏でカメラ起動処理が走っているため、その状態でswitchCamera関数を実行してもうまくいきません。
そこでWidgetsBinding.instance.addPostFrameCallbackで画面描画後にswitchCamera関数を実行しようとしましたが、それもうまくいきませんでした。カメラの準備が全て完了した後にswitchCamera関数を実行したいので本当はそのようなコールバック関数がコントローラに用意されていれば良かったのですがなさそうでした。
仕方ないので今回はゴリ押しのDelayを入れる方法で対応し、最初のswitchCamera関数は動作するようになりました。

次に2つ目のswitchCamera関数で問題が発生しました。端末にカメラが1つしかない場合・・たとえば私が使っているMBPであればブラウザ上では問題なく動作するのですが、Android端末は大体前面と背面両方にカメラが付いており前面から背面にカメラをスイッチさせる処理がうまくいきませんでした。
おそらく連続でswitchCamera関数を実行しているのが問題だと思われますのでここでもdelayを入れることで動作するようになりました。
最終的にinitState関数は以下のようにしました。

@override
void initState() {
  super.initState();
  _controller.start();

  // iOSの場合は問題が発生しないので即カメラを利用可能にする
  final userAgent = html.window.navigator.userAgent;  
  final isIOS = userAgent.contains('iphone') || userAgent.contains('ipad');
  if (isIOS) {
    setState(() => _availableCamera = true);
    return;
  }

  // Android・Webの場合、再度カメラを起動したときに正常動作しないので以下を実行する
  WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
    // 即時switchCamera()を実行するとAndroid端末で動かないのでdelayを入れる。手持ちの端末では1秒で全て動いたので念の為もう1秒加算して2秒としている。問い合わせがあったら要調整
    Future<void>.delayed(const Duration(seconds: 2)).then((_) async {
      await _controller.switchCamera();
      final state = _controller.cameraFacingState.value;
      if (state == CameraFacing.front) {
        // 前面→背面のカメラ切り替えも連続だとうまくいかなかったので少しdeplayを入れている
        await Future<void>.delayed(const Duration(milliseconds: 500));
        await _controller.switchCamera();
      }
      setState(() => _availableCamera = true);
    });
  });
}

この実装はAndroid端末利用者はバーコードを読み取る際に必ず2.5秒以上待つ必要がありますのでかなりUXが悪くなります。幸いにも今回のWebアプリは利用用途が限定的であったこととシビアなリアルタイム性が求められるもの(数万人規模のイベント会場でチケットに印字されたQRコードを読み取るなどの用途ではない)ということで一旦この実装で様子を見ることにしました。

リリース後、特に問い合わせもなく安定稼働しているようでしたので一安心でした。
2024年8月現在の最新版ver5.1.1ではこの問題が解消されていましたので、次回改修やバージョンアップを依頼された場合はこのコードは消してしまう予定です。

まとめ

今回のアプリで、簡易ではありますがFlutter Webを使用しAndroid/iPhone端末でQRコードを読み取る機能を検証することができました。
せっかくならQRコードを検知した時にラインで囲ったり他のQR読み取りするアプリのようにアニメーションをつけたかったのですが、スケジュール的に難しかったことと余計な機能をつけてカメラ機能が重くなったり不具合を作り込むのは避けたかったため今回はシンプルに対応しました。

弊社はFlutterを今回のような用途限定だったり一時利用するアプリ、または社内で使用するツールなどメインのプロダクト以外の部分で採用していますが、マルチプラットフォームの強みを活かせますし素早く提供できるため大変便利だと感じています。

※ QRコードは株式会社デンソーウェーブの登録商標です。

いいなと思ったら応援しよう!