公開日 2026-07-29

Flutterで業務用バーコード読み取りアプリを作る(最小構成)

Flutterで業務用バーコード読み取りの最小アプリを作り、カメラ検出、履歴表示、重複スキャン抑止までを1画面で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 処理の流れを先に確認する
  6. 3. プロジェクト作成と依存追加
  7. 3-1. Flutter プロジェクトを作る
  8. 3-2. 実機を接続する(推奨)
  9. 3-3. mobile_scanner を追加する
  10. 3-4. Android のカメラ権限を確認する
  11. 4. lib/main.dart を作成する
  12. コードのポイント
  13. 5. 最小構成で押さえる運用観点
  14. 6. まとめ

Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う の次は、業務端末アプリに近い題材としてバーコード読み取りを作ってみる流れです。この記事では mobile_scanner を使い、カメラ読み取り、検出値表示、履歴表示、同一コードの短時間連続読み取り抑止までを最小構成で実装します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、Dart 基礎、画面遷移、REST API 通信の入口まで進んだ人
  • 次に業務端末向け機能へ進みたい人
  • まずは「1画面で動く読み取り機能」を作って感覚をつかみたい人

この記事で到達する状態

  • mobile_scanner でバーコード / QR コードを読み取れる
  • 読み取り結果を画面に表示し、履歴に蓄積できる
  • 同一コードの短時間連続読み取りを抑止できる
  • カメラの開始 / 停止を明示操作できる

非対象

  • MethodChannel によるネイティブ SDK 連携
  • API への登録処理
  • オフライン同期
  • Riverpod / Bloc などの状態管理ライブラリ

今回のゴールは、業務端末アプリの入口として成立する最小構成を作ることにあります。

2. 処理の流れを先に確認する

flowchart TD
  A[Camera Preview] --> B[onDetect]
  B --> C{rawValueあり?}
  C -->|No| X[無視]
  C -->|Yes| D{同一コードを2秒以内に再検出?}
  D -->|Yes| X
  D -->|No| E[現在値を更新]
  E --> F[履歴先頭へ追加]
  F --> G[最大30件に制限]
  E --> H[SnackBarで通知]

バーコード読み取りで最初に詰まりやすいのは、読み取りできないことより「同じコードが一瞬で何回も入る」ことです。先に流れを図で確認しておくと、重複抑止の置き場が分かりやすくなります。

  • rawValue が空の場合は何もしない。カメラがコードの途中を捉えたときなどに発生する
  • 同一コードかつ 2 秒以内の再検出はスキップする。この判定が重複防止の核心
  • 受け入れたコードは現在値の更新と履歴追加を同時に行い、SnackBar で読み取りを通知する
  • 履歴は最大 30 件に絞り、古い件から削除する

この流れを頭に入れておくと、後から API 送信を足す際にも、どの位置に処理を差し込むかが迷いにくくなります。

3. プロジェクト作成と依存追加

3-1. Flutter プロジェクトを作る

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

次のコマンドでプロジェクトを作成します。

flutter create my_barcode_app
cd my_barcode_app

3-2. 実機を接続する(推奨)

バーコード読み取りはカメラを使うため、動作確認は実機が最も確実です

端末側の設定

  1. 「設定 → デバイス情報 → ビルド番号」を7回タップする
  2. 「開発者向けオプション」が出現したらオンにする
  3. 「開発者向けオプション → USB デバッグ」をオンにする

PC と接続する

USB ケーブルで端末を接続すると「USB デバッグを許可しますか?」ダイアログが表示されます。「許可」を選択してください。

次のコマンドで端末が認識されているか確認します。

flutter devices

端末名が一覧に表示されれば接続完了です。

つまずきやすいポイント

  • ケーブルが充電専用だと認識されない。データ転送対応のケーブルを使ってください
  • 端末画面に「このコンピューターを信頼しますか?」が出ている場合は許可が必要です
  • Windows でドライバーが当たっていない場合は Google USB Driver をインストールしてください

エミュレーターで確認する場合

実機がない場合はエミュレーターでも起動できます。ただし仮想カメラにはバーコードが写らないため、読み取りの動作確認はできません。AVD Manager でバックカメラを「Webcam0」に設定し PC の Web カメラにバーコードを映す方法もありますが、実機を用意するのが確実です。

flutter emulators
flutter emulators --launch <emulator_id>

3-3. mobile_scanner を追加する

次のコマンドで依存を追加します。

flutter pub add mobile_scanner

3-4. Android のカメラ権限を確認する

mobile_scanner は Android のカメラ権限(CAMERA)をプラグイン側のマニフェストに含んでいるため、手動での追記は不要です。ただし初回起動時に OS のランタイム権限ダイアログが表示されるので、許可してください。

拒否した場合はカメラが起動しません。再許可は「設定 → アプリ → 対象アプリ → 権限 → カメラ」から行います。

4. lib/main.dart を作成する

この lib/main.dart は、読み取り値の取得、重複抑止、操作ボタン、履歴表示までを 1 画面で確認するサンプルです。カメラ制御と UI 更新の境目を意識して読むと流れを追いやすくなります。

lib/main.dart を次の内容で作成します。

import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Business Barcode Scanner',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      home: const BarcodeScannerPage(),
    );
  }
}

class ScanRecord {
  const ScanRecord({required this.code, required this.scannedAt});

  final String code;
  final DateTime scannedAt;
}

class BarcodeScannerPage extends StatefulWidget {
  const BarcodeScannerPage({super.key});

  @override
  State<BarcodeScannerPage> createState() => _BarcodeScannerPageState();
}

class _BarcodeScannerPageState extends State<BarcodeScannerPage> {
  final MobileScannerController _controller = MobileScannerController(
    formats: const [
      BarcodeFormat.code128,
      BarcodeFormat.qrCode,
    ],
  );

  final List<ScanRecord> _records = <ScanRecord>[];

  String _currentCode = '-';
  String _lastAcceptedCode = '';
  DateTime _lastAcceptedAt = DateTime.fromMillisecondsSinceEpoch(0);
  bool _isTorchOn = false;
  bool _isStarted = true;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _onDetect(BarcodeCapture capture) {
    final String? value = capture.barcodes.firstOrNull?.rawValue;
    if (value == null || value.isEmpty) {
      return;
    }

    final DateTime now = DateTime.now();
    final bool isDuplicate =
        value == _lastAcceptedCode &&
        now.difference(_lastAcceptedAt).inMilliseconds < 2000;

    if (isDuplicate) {
      return;
    }

    _lastAcceptedCode = value;
    _lastAcceptedAt = now;

    setState(() {
      _currentCode = value;
      _records.insert(0, ScanRecord(code: value, scannedAt: now));
      if (_records.length > 30) {
        _records.removeLast();
      }
    });

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('読み取り: $value'),
        duration: const Duration(milliseconds: 700),
      ),
    );
  }

  Future<void> _toggleTorch() async {
    await _controller.toggleTorch();
    if (!mounted) {
      return;
    }

    setState(() {
      _isTorchOn = !_isTorchOn;
    });
  }

  Future<void> _toggleCameraStart() async {
    if (_isStarted) {
      await _controller.stop();
    } else {
      await _controller.start();
    }

    if (!mounted) {
      return;
    }

    setState(() {
      _isStarted = !_isStarted;
    });
  }

  String _formatTime(DateTime value) {
    final String hh = value.hour.toString().padLeft(2, '0');
    final String mm = value.minute.toString().padLeft(2, '0');
    final String ss = value.second.toString().padLeft(2, '0');
    return '$hh:$mm:$ss';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('業務用バーコード読み取り'),
      ),
      body: Column(
        children: <Widget>[
          Expanded(
            flex: 5,
            child: MobileScanner(
              controller: _controller,
              onDetect: _onDetect,
            ),
          ),
          Expanded(
            flex: 4,
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  const Text(
                    '現在の読み取り値',
                    style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  SelectableText(
                    _currentCode,
                    style: const TextStyle(fontSize: 18),
                  ),
                  const SizedBox(height: 12),
                  Wrap(
                    spacing: 8,
                    runSpacing: 8,
                    children: <Widget>[
                      FilledButton.icon(
                        onPressed: _toggleCameraStart,
                        icon: Icon(_isStarted ? Icons.pause : Icons.play_arrow),
                        label: Text(_isStarted ? 'カメラ停止' : 'カメラ開始'),
                      ),
                      OutlinedButton.icon(
                        onPressed: _toggleTorch,
                        icon: Icon(
                          _isTorchOn ? Icons.flashlight_off : Icons.flashlight_on,
                        ),
                        label: Text(_isTorchOn ? 'ライトOFF' : 'ライトON'),
                      ),
                      OutlinedButton.icon(
                        onPressed: () {
                          setState(() {
                            _records.clear();
                            _currentCode = '-';
                          });
                        },
                        icon: const Icon(Icons.delete_outline),
                        label: const Text('履歴クリア'),
                      ),
                    ],
                  ),
                  const SizedBox(height: 12),
                  const Text(
                    '読み取り履歴(最新30件)',
                    style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
                  ),
                  const SizedBox(height: 8),
                  Expanded(
                    child: _records.isEmpty
                        ? const Center(
                            child: Text('まだ読み取りがありません'),
                          )
                        : ListView.separated(
                            itemCount: _records.length,
                            separatorBuilder: (_, __) => const Divider(height: 1),
                            itemBuilder: (BuildContext context, int index) {
                              final ScanRecord item = _records[index];
                              return ListTile(
                                dense: true,
                                title: Text(item.code),
                                trailing: Text(_formatTime(item.scannedAt)),
                              );
                            },
                          ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

コードのポイント

① 読み取り結果の入口は _onDetect に集約する

void _onDetect(BarcodeCapture capture) {
  final String? value = capture.barcodes.firstOrNull?.rawValue;
  if (value == null || value.isEmpty) {
    return;
  }

  final bool isDuplicate =
      value == _lastAcceptedCode &&
      now.difference(_lastAcceptedAt).inMilliseconds < 2000;

  setState(() {
    _currentCode = value;
    _records.insert(0, ScanRecord(code: value, scannedAt: now));
  });
}

読み取りイベントが来たときの判定と state 更新を _onDetect に寄せているため、UI 側は検出処理の詳細を持たずに済みます。空文字の除外、2 秒以内の重複抑止、履歴追加がこの順で並んでいる点も追いやすい構成です。

② カメラ制御は MobileScannerController を通して操作する

final MobileScannerController _controller = MobileScannerController(
  formats: const [
    BarcodeFormat.code128,
    BarcodeFormat.qrCode,
  ],
);

await _controller.toggleTorch();

スキャナー本体の開始、停止、ライト切り替えを _controller に集約しているため、画面側は押下イベントからメソッドを呼ぶだけです。扱うバーコード形式もここで固定されるので、カメラ設定の入口が分かりやすくなります。

③ 画面はプレビュー領域と操作領域を Expanded で分ける

body: Column(
  children: <Widget>[
    Expanded(
      flex: 5,
      child: MobileScanner(
        controller: _controller,
        onDetect: _onDetect,
      ),
    ),
    Expanded(
      flex: 4,
      child: Padding(

上段はカメラ、下段は現在値と履歴という役割分担が明確です。flex を分けておくと、実機で見たときもどこが撮影領域でどこが操作領域かを崩しにくくなります。

コードを貼り付けたら実機が接続されている状態で、次のコマンドを実行します。

flutter run

初回起動時にカメラ権限を求められたら許可します。拒否した場合は OS 設定から再許可してください。

起動直後の画面:カメラプレビューと操作UIが表示されている

次の QR コードをカメラに向けて動作を確認します。上から順に S-1001、S-1002、S-1003 です。

テスト用 QR コード S-1001 テスト用 QR コード S-1002 テスト用 QR コード S-1003

3 件スキャンすると、現在値と履歴が更新されます。

3件スキャン後の画面:現在値と履歴一覧が更新されている

FutureBuilder を使わず、_currentCode_records_isStarted_isTorchOn をフラットに持っているのは、1 ファイルで全状態を見通せるようにするためです。状態管理ライブラリを入れる前段では、このくらいの分離に留めたほうが追いやすくなります。

5. 最小構成で押さえる運用観点

今回のサンプルでは、業務現場でよく問題になる 3 点を最初から組み込んでいます。

観点この記事での対応現場での意味
同一コード連続読み取り同一コードかつ 2 秒以内はスキップAPI と組み合わせたとき二重登録を防ぐ起点になる
カメラの明示停止停止 / 開始ボタンを常時表示読み取りミス時に一時停止して状況を確認できる
1 ファイル完結状態管理ライブラリなし・分割なし読み取り動作そのものを先に確認してから分割できる

重複抑止の 2 秒という閾値はサンプル用の固定値です。実際の現場では、読み取り間隔の要件に合わせて調整してください。API 連携時も同じ判定で二重送信を減らせますが、API 側でも冪等性を担保しておくのが安全です。

6. まとめ

mobile_scanner を使って、業務端末アプリの入口となるバーコード読み取り最小構成を作りました。ここまでで「読み取る」「表示する」「履歴を持つ」「重複を抑止する」が揃います。

まずは今回のサンプルを手元で動かし、同一コードを短時間で連続スキャンしたときの動作と、カメラ停止 / 再開の挙動を確認しておくこと。その感覚がつかめると、次の API 連携や MethodChannel への差し替えでも詰まりにくくなります。

続けて取り組むなら、次の順が扱いやすいです。

  1. Flutterでスキャン入力を受けて処理する を読み、キーボード入力型スキャナの受け口を先に固める
  2. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読み、拒否時導線と設定画面誘導を固める
  3. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) を組み合わせ、読み取り結果を API に送る
  4. MethodChannel を使い、端末 SDK に置き換える最小構成へ進む

シリーズ 26/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成) 現在の記事
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)