PAYATHON 2026

Flutter mobile_scanner 扫码后黑屏,如何正确处理相机生命周期?

数据小许

结论

扫码成功后,建议保留一次性结果锁,移除返回前的 stop() 和固定延迟,让页面先开始退出,再由页面统一释放 controller。当前代码在扫码页面仍可见时停止预览,随后等待 150 ms,使页面在这段时间内没有相机画面。

这可以解释退出前的黑屏。至于偶发卡死和再次进入时无法启动,现有代码还不足以确认它们是否有相同原因,也无法认定这是 mobile_scanner 某个版本的已知缺陷。

为什么当前流程容易出现问题?

扫码成功后,当前代码按以下顺序执行:

识别条形码
→ 设置 _isProcessing
→ 等待 controller.stop()
→ 等待 150 ms
→ Navigator.pop(value)
→ 页面销毁时再次调用 stop() 和 dispose()

这个流程中有几处需要分别处理:

  • 调用 stop() 时,扫码页面还在屏幕上。预览停止后,页面可能显示黑色背景或停止更新;后面的延迟会延长这段可见时间。
  • Navigator.pop() 要等 stop() 完成后才执行。如果停止操作迟迟不返回,页面退出也会被阻塞。try/catch 可以处理抛出的异常,但不能让尚未完成的 Future 自动结束。
  • dispose() 中连续调用 stop() 和 dispose() 时,没有等待前一个异步操作结束。两行代码的先后顺序不代表相机已经依次完成停止和释放。
  • 重试按钮还提供了一条 stop()、延迟、start() 路径。用户重复点击,或页面正在退出时,这些操作可能重叠。

DetectionSpeed.noDuplicates 不能替代业务层的一次性返回锁。不同条形码、手动输入和返回按钮仍需要协调。

先修改扫码结果的返回逻辑

对于每次打开一个新的扫码页面、获取一个结果后退出的流程,可以先做下面的最小调整。示例沿用现有 controller 配置,替换相关方法即可。

bool _isProcessing = false;
bool _manualEntryOpen = false;

void _finish(String? value) {
  if (!mounted || _isProcessing) return;

  // 必须在导航前设置,阻止后续回调再次返回。
  _isProcessing = true;
  Navigator.of(context).pop<String>(value);
}

void _onDetect(BarcodeCapture capture) {
  if (!mounted || _isProcessing || _manualEntryOpen) return;
  if (capture.barcodes.isEmpty) return;

  final value = capture.barcodes.first.rawValue;
  if (value == null || value.isEmpty) return;

  _finish(value);
}

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

这段代码移除了 _onDetect() 中的 await _controller.stop() 和 150 ms 延迟,也移除了 dispose() 中额外发起的 stop()。controller 仍由创建它的页面负责释放。

Flutter 的 State.dispose() 应保持同步。如果需要追踪 controller 异步释放期间的异常,应按实际依赖版本的返回类型记录错误。不能将 State.dispose() 改成 async,并指望导航系统等待它完成。

这个修改减少了退出路径中的相机操作,但不能保证所有设备的返回动画都没有黑色帧。如果黑屏只出现在退出动画期间,还需要观察预览资源释放与页面转场的时序。

手动输入和返回按钮也要共用退出入口

原代码打开手动输入框后,扫码回调仍可能执行。此时调用 Navigator.pop(),可能关闭当前 Navigator 顶部的对话框,打乱预期的返回流程。

可以在手动输入期间忽略识别结果,再将输入结果交给同一个 _finish():

Future<void> _openManualEntry() async {
  if (!mounted || _isProcessing || _manualEntryOpen) return;

  _manualEntryOpen = true;
  String? result;

  try {
    result = await showDialog<String>(
      context: context,
      builder: (_) => _ManualEntryDialog(),
    );
  } finally {
    _manualEntryOpen = false;
  }

  if (!mounted || _isProcessing) return;
  if (result != null && result.isNotEmpty) {
    _finish(result);
  }
}

手动输入按钮使用 onTap: _openManualEntry,页面顶部的返回按钮使用:

onTap: () => _finish(null),

这段代码只在对话框打开期间屏蔽扫码结果,相机仍可能继续运行。如果还需要暂停相机,应将暂停和恢复纳入统一的生命周期管理,避免增加独立的启动、停止逻辑。

另外,_ManualEntryDialog 创建了 TextEditingController,却没有释放。可以将它改为 StatefulWidget,在对应 State 的 dispose() 中调用 _ctrl.dispose()。这是资源管理问题,现有信息不足以认定它是相机黑屏的原因。

再次打开时,先确认是否创建了新页面

下面的调用方式每次都会创建新的扫码页面,页面 State 中的 controller 和结果锁也会重新创建:

final barcode = await Navigator.of(context).push<String>(
  MaterialPageRoute<String>(
    builder: (_) => const BarcodeScannerScreen(),
  ),
);

if (!context.mounted) return;
if (barcode == null || barcode.isEmpty) return;

// 在这里处理 barcode。

不要把已经释放的 controller 缓存在全局对象中,供下一次扫码使用。

如果扫码页面位于 IndexedStack、保活 Tab 或其他保留 State 的结构中,处理方式就不同。页面隐藏不一定触发 dispose(),之前停止的相机和 _isProcessing = true 也可能一直保留。此时需要在页面重新可见时恢复扫描,并在新一轮扫描开始前重置业务状态。

应用进入后台和路由被覆盖是不同的事件。前者需要应用生命周期处理,后者可能需要路由可见性处理;仅检查 mounted 无法判断页面是否正在显示。

autoStart 开启后还能手动调用 start() 吗?

避免同时使用自动启动和无条件手动启动。对于当前新建页面后立即扫码的流程,可以先保留 autoStart: true,删除额外的初始 start() 调用。

开启 autoStart 后仍可能需要调用 start()。如果代码明确停止了相机,而页面还需要继续扫描,就需要安排恢复操作。恢复时应确认页面仍然有效、仍然可见,且没有其他启动、停止或释放操作正在进行。

如果选择 autoStart: false,则需要自行安排初次启动、应用恢复、页面重新可见和退出时的处理。具体接入方式应以实际安装版本的 API 为准,单独切换这个参数不能完成生命周期管理。

如果修改后仍然黑屏,如何定位?

先暂时移除遮罩、扫描线动画、手动输入和重试按钮,只保留 Scaffold、MobileScanner 和单次结果返回逻辑。然后分别测试首次打开、扫码返回、再次打开,以及切到后台后恢复。

建议记录以下事件的时间和错误信息:

  • 页面创建、扫码回调、开始返回和页面销毁。
  • 每次 start()、stop() 的调用、完成和异常。
  • MobileScannerException.errorCode 以及完整异常内容。

如果日志显示 stop() 已调用却没有完成,应继续检查相机停止路径。如果页面已经正常销毁,但新页面仍无法启动,应检查新旧 controller 的交接和原生相机日志。

依赖声明 mobile_scanner: ^7.2.0 不能证明实际运行的就是 7.2.0,应以 pubspec.lock 为准。判断是否属于已知缺陷,还需要实际解析版本、Flutter 版本、设备型号、系统版本及最小复现。现有材料缺少这些证据,不能得出“某个版本已修复”或“升级即可解决”的结论。

备注:内容仅供参考。