在某些场景下,用户需要连续扫描多个二维码,比如仓库盘点、活动签到等。如果每次扫描都要返回再重新进入,效率会很低。批量扫描功能让用户可以连续扫描多个二维码,扫描结果会累积显示在列表中,最后可以一次性保存或导出。
请添加图片描述

批量扫描的设计思路

批量扫描页面需要解决几个核心问题:

连续扫描:扫描成功后不跳转到结果页面,而是继续保持扫描状态,让用户可以立即扫描下一个。

结果累积:每次扫描的结果都添加到列表中,用户可以看到已经扫描了多少个,以及每个的内容。

单项管理:用户可以删除某一条扫描结果,比如扫描错误的时候。

批量操作:提供清空全部、保存全部、导出全部等批量操作功能。

BatchScanView 的整体结构

批量扫描页面使用 StatefulWidget,因为需要管理扫描结果列表的状态:

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../../data/models/qr_record.dart';

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

  
  State<BatchScanView> createState() => _BatchScanViewState();
}

class _BatchScanViewState extends State<BatchScanView> {
  final List<QrRecord> scannedItems = [];

这里使用 StatefulWidget 而不是 GetX 的响应式状态管理,是因为批量扫描的状态只在这个页面内使用,不需要跨页面共享。使用 StatefulWidget 更简单直接。

scannedItems 是一个 QrRecord 列表,存储所有扫描到的记录。每次扫描成功后,会往这个列表添加一条记录。

模拟扫描方法

为了测试,添加一个模拟扫描的方法:

  void _addDemoItem() {
    setState(() {
      scannedItems.add(QrRecord(
        content: 'https://example.com/item${scannedItems.length + 1}',
        type: QrType.url,
      ));
    });
  }

_addDemoItem 方法模拟一次扫描,创建一个 QrRecord 对象并添加到列表。内容使用序号区分,比如 “item1”、“item2” 等。

setState 是 StatefulWidget 更新状态的标准方法。调用 setState 后,Flutter 会重新调用 build 方法,UI 会更新显示新添加的记录。

在实际项目中,这个方法会被相机扫描的回调替代。当相机识别到二维码时,调用类似的逻辑添加记录。

AppBar 配置

批量扫描页面的应用栏:

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('批量扫描'),
        actions: [
          if (scannedItems.isNotEmpty)
            TextButton(
              onPressed: () {
                Get.snackbar('成功', '已保存${scannedItems.length}条记录',
                    snackPosition: SnackPosition.BOTTOM);
              },
              child: const Text('保存全部'),
            ),
        ],
      ),

actions 中有一个"保存全部"按钮,只有当列表不为空时才显示。使用 if (scannedItems.isNotEmpty) 进行条件渲染。

TextButton 比 IconButton 更适合这种文字操作,用户一眼就能知道这个按钮的功能。点击后会把所有记录保存到历史记录服务中。

Get.snackbar 显示操作结果,告诉用户保存了多少条记录。${scannedItems.length} 是字符串插值,会显示实际的数量。

扫描区域

页面上半部分是扫描区域:

      body: Column(
        children: [
          Container(
            height: 200.h,
            color: Colors.black87,
            child: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.qr_code_scanner, size: 60.sp, color: Colors.white54),
                  SizedBox(height: 12.h),
                  Text(
                    '批量扫描模式',
                    style: TextStyle(color: Colors.white70, fontSize: 14.sp),
                  ),
                  SizedBox(height: 16.h),
                  ElevatedButton(
                    onPressed: _addDemoItem,
                    child: const Text('模拟扫描一个'),
                  ),
                ],
              ),
            ),
          ),

扫描区域使用 Container 包裹,高度固定为 200.h。背景色 Colors.black87 是深灰色,模拟相机预览的效果。内部使用 Column 垂直排列图标、文字和按钮。

Icon 使用 qr_code_scanner 图标,大小 60.sp,颜色是半透明白色。下方的文字"批量扫描模式"提示用户当前处于批量扫描状态。

ElevatedButton 是模拟扫描的按钮,点击调用 _addDemoItem 方法。在实际项目中,这个区域会被相机预览组件替代,扫描成功后自动调用添加记录的方法。

已扫描列表区域

扫描区域下方是已扫描的记录列表:

          Expanded(
            child: scannedItems.isEmpty
                ? Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Icon(Icons.inbox, size: 60.sp, color: Colors.grey),
                        SizedBox(height: 12.h),
                        Text(
                          '暂无扫描记录',
                          style: TextStyle(color: Colors.grey, fontSize: 14.sp),
                        ),
                      ],
                    ),
                  )

Expanded 让列表区域占据剩余的所有空间。使用三元表达式判断列表是否为空,为空时显示空状态提示,不为空时显示列表。

空状态使用 Center 居中显示,包含一个收件箱图标和"暂无扫描记录"的文字。这种空状态设计比直接显示空白更友好,让用户知道这里会显示什么内容。

扫描记录列表

当有扫描记录时,使用 ListView.builder 显示:

                : ListView.builder(
                    padding: EdgeInsets.all(16.w),
                    itemCount: scannedItems.length,
                    itemBuilder: (context, index) {
                      final item = scannedItems[index];
                      return Card(
                        child: ListTile(
                          leading: CircleAvatar(
                            child: Text('${index + 1}'),
                          ),
                          title: Text(
                            item.content,
                            maxLines: 1,
                            overflow: TextOverflow.ellipsis,
                          ),
                          subtitle: Text(item.typeLabel),
                          trailing: IconButton(
                            icon: const Icon(Icons.delete_outline),
                            onPressed: () {
                              setState(() => scannedItems.removeAt(index));
                            },
                          ),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),

ListView.builder 是懒加载列表,只渲染可见的项目,适合长列表。padding 设置四周 16.w 的内边距。

每个列表项使用 Card 包裹,提供卡片效果。ListTile 是 Material Design 的标准列表项组件,包含 leading、title、subtitle 和 trailing 四个位置。

leading 使用 CircleAvatar 显示序号,让用户知道这是第几个扫描的。title 显示二维码内容,限制一行并用省略号截断。subtitle 显示二维码类型。trailing 是删除按钮,点击后从列表中移除该项。

底部操作栏

页面底部是清空和导出两个按钮:

      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: EdgeInsets.all(16.w),
          child: Row(
            children: [
              Expanded(
                child: OutlinedButton(
                  onPressed: () => setState(() => scannedItems.clear()),
                  child: const Text('清空'),
                ),
              ),
              SizedBox(width: 16.w),
              Expanded(
                child: ElevatedButton(
                  onPressed: scannedItems.isEmpty ? null : () {
                    Get.snackbar('成功', '已导出${scannedItems.length}条记录',
                        snackPosition: SnackPosition.BOTTOM);
                  },
                  child: const Text('导出'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

bottomNavigationBar 是 Scaffold 的底部导航栏位置,这里用来放置操作按钮。SafeArea 确保按钮不会被系统导航栏遮挡。

Row 让两个按钮水平排列,Expanded 让它们平分宽度。清空按钮使用 OutlinedButton 描边样式,表示这是次要操作。导出按钮使用 ElevatedButton 凸起样式,表示这是主要操作。

导出按钮的 onPressed 使用三元表达式,列表为空时设为 null 禁用按钮。这是一个很好的用户体验细节,避免用户点击无效的按钮。

批量扫描的实际应用

在实际项目中,批量扫描功能需要集成相机扫描库。以 mobile_scanner 为例:

import 'package:mobile_scanner/mobile_scanner.dart';

class _BatchScanViewState extends State<BatchScanView> {
  final MobileScannerController _scannerController = MobileScannerController(
    detectionSpeed: DetectionSpeed.normal,
    facing: CameraFacing.back,
  );
  
  final List<QrRecord> scannedItems = [];
  final Set<String> _scannedContents = {};

  void _onDetect(BarcodeCapture capture) {
    for (final barcode in capture.barcodes) {
      final content = barcode.rawValue;
      if (content != null && !_scannedContents.contains(content)) {
        _scannedContents.add(content);
        setState(() {
          scannedItems.add(QrRecord(
            content: content,
            type: _detectType(content),
          ));
        });
        _playBeep();
      }
    }
  }
}

MobileScannerController 控制相机的行为,detectionSpeed 设置检测速度,facing 设置使用后置摄像头。

_scannedContents 是一个 Set,用于存储已扫描的内容,避免重复添加相同的二维码。这在批量扫描时很重要,因为相机可能会多次识别同一个二维码。

扫描区域的相机预览

将模拟的扫描区域替换为真实的相机预览:

Container(
  height: 200.h,
  child: ClipRRect(
    borderRadius: BorderRadius.circular(0),
    child: MobileScanner(
      controller: _scannerController,
      onDetect: _onDetect,
      overlay: Center(
        child: Container(
          width: 150.w,
          height: 150.w,
          decoration: BoxDecoration(
            border: Border.all(color: Colors.white, width: 2),
            borderRadius: BorderRadius.circular(12.r),
          ),
        ),
      ),
    ),
  ),
),

MobileScanner 是相机预览组件,controller 传入控制器,onDetect 是识别回调。overlay 可以在预览上叠加自定义 UI,这里添加了一个扫描框。

扫描提示音

扫描成功后播放提示音,给用户反馈:

import 'package:audioplayers/audioplayers.dart';

class _BatchScanViewState extends State<BatchScanView> {
  final AudioPlayer _audioPlayer = AudioPlayer();

  void _playBeep() async {
    await _audioPlayer.play(AssetSource('sounds/beep.mp3'));
  }

  
  void dispose() {
    _audioPlayer.dispose();
    super.dispose();
  }
}

使用 audioplayers 插件播放音频。AssetSource 从 assets 目录加载音频文件。记得在 dispose 中释放资源。

震动反馈

除了声音,还可以添加震动反馈:

import 'package:vibration/vibration.dart';

void _onScanSuccess() async {
  if (await Vibration.hasVibrator() ?? false) {
    Vibration.vibrate(duration: 100);
  }
}

Vibration 插件提供震动功能。先检查设备是否支持震动,然后调用 vibrate 方法,duration 设置震动时长(毫秒)。

导出功能的实现

导出功能可以将扫描结果保存为文件或分享:

import 'package:share_plus/share_plus.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';

Future<void> _exportRecords() async {
  if (scannedItems.isEmpty) return;
  
  final buffer = StringBuffer();
  buffer.writeln('批量扫描结果');
  buffer.writeln('扫描时间: ${DateTime.now()}');
  buffer.writeln('总数: ${scannedItems.length}');
  buffer.writeln('---');
  
  for (var i = 0; i < scannedItems.length; i++) {
    final item = scannedItems[i];
    buffer.writeln('${i + 1}. [${item.typeLabel}] ${item.content}');
  }
  
  final directory = await getTemporaryDirectory();
  final file = File('${directory.path}/scan_result.txt');
  await file.writeAsString(buffer.toString());
  
  await Share.shareXFiles([XFile(file.path)], text: '批量扫描结果');
}

StringBuffer 用于高效拼接字符串。getTemporaryDirectory 获取临时目录路径。Share.shareXFiles 调用系统分享功能。

保存到历史记录

将批量扫描的结果保存到历史记录服务:

void _saveAllRecords() {
  final historyService = Get.find<QrHistoryService>();
  
  for (final item in scannedItems) {
    historyService.addScanRecord(item.copyWith(
      scanTime: DateTime.now(),
      source: 'batch',
    ));
  }
  
  Get.snackbar(
    '保存成功',
    '已保存 ${scannedItems.length} 条记录到历史',
    snackPosition: SnackPosition.BOTTOM,
  );
  
  setState(() {
    scannedItems.clear();
    _scannedContents.clear();
  });
}

遍历所有记录,调用 historyService.addScanRecord 保存。保存后清空当前列表,准备下一轮扫描。

扫描统计信息

在页面顶部显示扫描统计:

Widget _buildStats() {
  final typeCount = <QrType, int>{};
  for (final item in scannedItems) {
    typeCount[item.type] = (typeCount[item.type] ?? 0) + 1;
  }
  
  return Container(
    padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h),
    color: Theme.of(context).primaryColor.withOpacity(0.1),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceAround,
      children: [
        _StatItem('总数', '${scannedItems.length}'),
        ...typeCount.entries.take(3).map((e) => 
          _StatItem(e.key.label, '${e.value}'),
        ),
      ],
    ),
  );
}

统计各类型的数量,显示在页面顶部。用户可以一眼看到扫描了多少个,各类型分别有多少。

列表项的滑动删除

给列表项添加滑动删除功能:

Dismissible(
  key: Key(item.content + index.toString()),
  direction: DismissDirection.endToStart,
  background: Container(
    color: Colors.red,
    alignment: Alignment.centerRight,
    padding: EdgeInsets.only(right: 16.w),
    child: const Icon(Icons.delete, color: Colors.white),
  ),
  onDismissed: (direction) {
    setState(() {
      scannedItems.removeAt(index);
      _scannedContents.remove(item.content);
    });
  },
  child: Card(
    child: ListTile(
      // 列表项内容
    ),
  ),
),

Dismissible 组件提供滑动删除功能。direction 设置只能从右向左滑动。background 是滑动时显示的背景。onDismissed 是删除后的回调。

暂停和继续扫描

添加暂停扫描的功能,方便用户查看已扫描的内容:

bool _isPaused = false;

void _togglePause() {
  setState(() {
    _isPaused = !_isPaused;
    if (_isPaused) {
      _scannerController.stop();
    } else {
      _scannerController.start();
    }
  });
}

暂停时停止相机扫描,继续时重新开始。按钮图标根据状态切换。

二维码类型自动识别

根据内容自动识别二维码类型:

QrType _detectType(String content) {
  if (content.startsWith('http://') || content.startsWith('https://')) {
    return QrType.url;
  } else if (content.startsWith('WIFI:')) {
    return QrType.wifi;
  } else if (content.startsWith('tel:')) {
    return QrType.phone;
  } else if (content.startsWith('mailto:')) {
    return QrType.email;
  } else if (content.startsWith('smsto:')) {
    return QrType.sms;
  } else if (content.startsWith('BEGIN:VCARD')) {
    return QrType.contact;
  } else if (content.startsWith('BEGIN:VEVENT')) {
    return QrType.event;
  } else if (content.startsWith('geo:')) {
    return QrType.location;
  }
  return QrType.text;
}

根据内容的前缀判断类型。网址以 http 或 https 开头,WiFi 以 WIFI: 开头,电话以 tel: 开头,等等。

批量扫描的应用场景

批量扫描功能在很多场景下都很实用:

仓库盘点:连续扫描商品条码,记录库存数量。

活动签到:扫描参会者的二维码门票,快速完成签到。

资产管理:扫描设备标签,建立资产清单。

物流分拣:扫描包裹条码,记录分拣信息。

性能优化建议

批量扫描可能会产生大量数据,需要注意性能:

列表优化:使用 ListView.builder 而不是 ListView,避免一次性创建所有列表项。

去重处理:使用 Set 存储已扫描的内容,O(1) 时间复杂度判断是否重复。

内存管理:如果扫描数量很大,考虑分批保存到数据库,而不是全部保存在内存中。

UI 更新:避免频繁调用 setState,可以使用节流或防抖。

小结

批量扫描功能让用户可以连续扫描多个二维码,提高效率。实现时需要考虑去重、提示反馈、列表管理、导出保存等多个方面。

页面布局分为扫描区域和列表区域两部分,底部是操作按钮。使用 StatefulWidget 管理扫描结果列表的状态。实际项目中需要集成相机扫描库,添加声音和震动反馈,提供导出和保存功能。

批量扫描在仓库盘点、活动签到、资产管理等场景下非常实用,是二维码应用的重要功能之一。


欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐