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. 実機を接続する(推奨)
バーコード読み取りはカメラを使うため、動作確認は実機が最も確実です。
端末側の設定
- 「設定 → デバイス情報 → ビルド番号」を7回タップする
- 「開発者向けオプション」が出現したらオンにする
- 「開発者向けオプション → 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 設定から再許可してください。
次の QR コードをカメラに向けて動作を確認します。上から順に S-1001、S-1002、S-1003 です。
3 件スキャンすると、現在値と履歴が更新されます。
FutureBuilder を使わず、_currentCode、_records、_isStarted、_isTorchOn をフラットに持っているのは、1 ファイルで全状態を見通せるようにするためです。状態管理ライブラリを入れる前段では、このくらいの分離に留めたほうが追いやすくなります。
5. 最小構成で押さえる運用観点
今回のサンプルでは、業務現場でよく問題になる 3 点を最初から組み込んでいます。
| 観点 | この記事での対応 | 現場での意味 |
|---|---|---|
| 同一コード連続読み取り | 同一コードかつ 2 秒以内はスキップ | API と組み合わせたとき二重登録を防ぐ起点になる |
| カメラの明示停止 | 停止 / 開始ボタンを常時表示 | 読み取りミス時に一時停止して状況を確認できる |
| 1 ファイル完結 | 状態管理ライブラリなし・分割なし | 読み取り動作そのものを先に確認してから分割できる |
重複抑止の 2 秒という閾値はサンプル用の固定値です。実際の現場では、読み取り間隔の要件に合わせて調整してください。API 連携時も同じ判定で二重送信を減らせますが、API 側でも冪等性を担保しておくのが安全です。
6. まとめ
mobile_scanner を使って、業務端末アプリの入口となるバーコード読み取り最小構成を作りました。ここまでで「読み取る」「表示する」「履歴を持つ」「重複を抑止する」が揃います。
まずは今回のサンプルを手元で動かし、同一コードを短時間で連続スキャンしたときの動作と、カメラ停止 / 再開の挙動を確認しておくこと。その感覚がつかめると、次の API 連携や MethodChannel への差し替えでも詰まりにくくなります。
続けて取り組むなら、次の順が扱いやすいです。
- Flutterでスキャン入力を受けて処理する を読み、キーボード入力型スキャナの受け口を先に固める
- permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読み、拒否時導線と設定画面誘導を固める
- FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理) を組み合わせ、読み取り結果を API に送る
- MethodChannel を使い、端末 SDK に置き換える最小構成へ進む