Из бесплатных доступных библиотек для работы с qr кодами в Android самой лучшей (на мой личный взгляд) является zxing-android-embedded. Часто, UI который предоставляет эта библиотека не достаточно или нужен какой-то иной. В этой статье пойдет речь о том, как «кастомизировать» UI библиотеки zxing-android-embedded для распознавания QR кодов при использовать её Flutter проекте.

Представленная статья и код вместе с ней, всего лишь минимальный достаточный пример для демонстрации возможностей «кастомизации» zxing-android для работы с ней во flutter. Статья затрагивает только Android реализацию не касаясь IOS.

Мы будем использовать три основных компонента для взаимодействия с этой библиотекой из flutter окружения. Для этого нам потребуется:

  • PlatformViewLink

  • MethodChannel

  • EventChannel

PlatformViewLink:

Даёт возможность «прокинуть» нативный android экран(View) в ваше fluttter приложение. Это удобно в тех случаях когда есть готовое, проверенное решение под нативную платформу, а времени переделывать под flutter не хватает и легче показать android activity напрямую в вашем flutter приложении. По такому принципу работают google maps во flutter приложениях. В нашем случае через PlatformViewLink мы будем показывать нативный экран со стримом фотокамеры.

MethodChannel:

Даёт возможность вызывать нативные методы платформы(android или ios и т.д.) из flutter среды в и получать результат . Надо заметить что все вызовы методов асинхронны. В данном проекте MethodChannel будет использоваться чтобы включать и выключать подсветку фотокамеры.

EventChannel:

Почти то же самое что и MethodChannel, с тем лишь отличием, что мы можем подписаться на поток событий, генерируемых в нативной среде. Самый часты кейс это например «слушать» gps координаты от нативной платформы. В данном примере EventChannel будет использоваться для отправки распознанного QR кода из android окружения в наше flutter приложение. Конечно, для получения результата мы могли бы использовать MethodChannel, например самостоятельно запрашивая данные скажем каждые 10 секунд. Но такой подход выглядит не очень правильным в условиях, когда у нас есть возможность получить результат именно тогда когда он готов.

Создадим пустой flutter проект. В консоли терминала вышей любимой ОС выполним команду:

Откроем main.dart. Добавим в качестве home элемента MaterialApp виджета, QrCodePage - виджет, который обернёт основной экран в Scaffold и добавит AppBar для него:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: QrCodePage(),
    );
  }
}

class QrCodePage extends StatelessWidget {
  QrCodePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("QR code App"),
      ),
      body: PlatformView(),
    );
  }
}

PlatformView - основной StatefulWidge виджет. Он будет обёрткой над PlatformViewLink и будет непосредственно транслировать видео с камеры:

class _PlatformViewState extends State<PlatformView> {
  final MethodChannel platformMethodChannel = MethodChannel('flashlight');
  bool isFlashOn = false;
  bool permissionIsGranted = false;
  String result = '';

  void _handleQRcodeResult() {
    const EventChannel _stream = EventChannel('qrcodeResultStream');
    _stream.receiveBroadcastStream().listen((onData) {
      print('EventChannel onData = $onData');
      result = onData;
      setState(() {});
    });
  }

  Future<void> _onFlash() async {
    try {
      dynamic result = await platformMethodChannel.invokeMethod('onFlash');
      setState(() {
        isFlashOn = true;
      });
    } on PlatformException catch (e) {
      debugPrint('PlatformException ${e.message}');
    }
  }

  Future<void> _offFlash() async {
    try {
      dynamic result = await platformMethodChannel.invokeMethod('offFlash');
      setState(() {
        isFlashOn = false;
      });
    } on PlatformException catch (e) {
      debugPrint('PlatformException ${e.message}');
    }
  }

  @override
  void initState() {
    super.initState();
    _handleQRcodeResult();
    _checkPermissions();
  }

  _requestAppPermissions() {
    showDialog(
        context: context,
        builder: (BuildContext context) => AlertDialog(
              title: const Text('Permission required'),
              content: const Text('Allow camera permissions'),
              actions: <Widget>[
                TextButton(
                  onPressed: () {
                    _checkPermissions();
                    Navigator.pop(context, 'OK');
                  },
                  child: const Text('OK'),
                ),
              ],
            ));
  }

  _checkPermissions() async {
    var status = await Permission.camera.status;
    if (!status.isGranted) {
      final PermissionStatus permissionStatus = await Permission.camera.request();
      if (!permissionStatus.isGranted) {
        _requestAppPermissions();
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final String viewType = '<platform-view-type>';
    final Map<String, dynamic> creationParams = <String, dynamic>{};
    return result.isEmpty
        ? Stack(
            alignment: Alignment.center,
            children: [
              PlatformViewLink(
                viewType: viewType,
                surfaceFactory: (BuildContext context, PlatformViewController controller) {
                  return Container(
                    child: AndroidViewSurface(
                      controller: controller as AndroidViewController,
                      gestureRecognizers: const <Factory<OneSequenceGestureRecognizer>>{},
                      hitTestBehavior: PlatformViewHitTestBehavior.opaque,
                    ),
                  );
                },
                onCreatePlatformView: (PlatformViewCreationParams params) {
                  return PlatformViewsService.initSurfaceAndroidView(
                    id: params.id,
                    viewType: viewType,
                    layoutDirection: TextDirection.ltr,
                    creationParams: creationParams,
                    creationParamsCodec: StandardMessageCodec(),
                  )
                    ..addOnPlatformViewCreatedListener(params.onPlatformViewCreated)
                    ..create();
                },
              ),
              Align(
                  alignment: Alignment.topCenter,
                  child: ElevatedButton(
                      onPressed: () {
                        if (!isFlashOn) {
                          _onFlash();
                        } else {
                          _offFlash();
                        }
                      },
                      child: isFlashOn ? Text('off flashlight') : Text('on flashlight'))),
              Align(
                alignment: Alignment.center,
                child: Container(
                  height: 200,
                  width: 200,
                  decoration: BoxDecoration(
                      color: Colors.transparent,
                      border: Border.all(
                        color: Colors.blueAccent,
                        width: 5,
                      )),
                ),
              )
            ],
          )
        : Container(
            child: Center(child: Text('QR code result:\n$result')),
          );
  }
}

Пару комментариев к коду ваше:

  • Запрашиваем разрешения на работу с камерой:

  • В поле класса, создаём platformMethodChannel - через этот экземпляр будем вызывать нативные методы (которые мы создадим чуть позже) в android окружении. Аргумент в конструкторе ‘flashlight’ это своего рода уникальный ID, который должен быть идентичный во flutter и нативной среде:

  • Метод _handleQRcodeResult() - будет получать результат отсканированного qr кода:

  • Методы _onFlash() и _offFlash() вызывают соответствующий метод на стороне Android фреймворка.

  • В некоторых случаях необходимо передать параметры в нативную среду. Для этого удобно использовать creationParams. Но в нашем примере параметров для передачи у нас не будет:

  • В качестве ViewGroup используем Stack для того чтоб расположить дополнительные UI элементы. В моём примере это рамка в центр экрана(Container с прозрачным фоном и BoxDecoration) и ElevatedButton над ней для включения подсветки.

Взглянем на Android реализацию:

В build.gradle модуля app (android/app/build.gradle) подключим библиотеку. В раздел dependencies добавим:

В MainActivity, в методе configureFlutterEngine, EventChannel

class MainActivity : FlutterFragmentActivity(), LifecycleOwner, ResultCallback {
    var myEvents: EventChannel.EventSink? = null

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        EventChannel(flutterEngine.dartExecutor.binaryMessenger, "qrcodeResultStream")
                .setStreamHandler(object : EventChannel.StreamHandler {
                    override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
                        myEvents = events
                    }

                    override fun onCancel(arguments: Any?) {
                        myEvents = null
                    }
                })

        flutterEngine
                .platformViewsController
                .registry
                .registerViewFactory("<platform-view-type>", NativeViewFactory())
    }

    override fun result(result: String) {
        myEvents?.success(result)
    }

    override fun getMyFlutterEngine(): FlutterEngine? = flutterEngine
}

EventChannel.StreamHandler возвращает нам объект EventChannel.EventSink вызывая на котором .success(result) — мы передаём событие во flutter фреймворк. В нашем случае это будет строка с QR кодом.

В методе выше мы регистрируем фабрику которая может возвращать разные View в зависимости от переданных аргументов, но мы не будем усложнять пример и возвращаем наш единственное NativeView:

Взглянем на интерфейс ResultCallback, который имплементирует MainActivity:

Метод result(result: String) нужен для передачи результата (распознанного qr кода) в MainActivity.

метод getMyFlutterEngine() - вернёт нам FlutterEngine в нашем NativeView.

Основной код будет в NativeView:

class NativeView(context: Context, id: Int, creationParams: Map<String?, Any?>?) : PlatformView {
    private val textView: TextView
    private val CHANNEL = "flashlight"

    private val rootView: View
    private var barcodeView: DecoratedBarcodeView? = null
    override fun getView(): View {
        return rootView
    }

    override fun dispose() {}

    init {
        (context as LifecycleOwner).lifecycle.addObserver(object : LifecycleObserver {
            @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
            fun connectListener() {
                barcodeView?.resume()
            }

            @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
            fun disconnectListener() {
                barcodeView?.pause()
            }
        })

        rootView = LayoutInflater.from(context.applicationContext).inflate(R.layout.layout, null)

        barcodeView = rootView.findViewById<DecoratedBarcodeView>(R.id.barcode_scanner)
        val formats: Collection<BarcodeFormat> = Arrays.asList(BarcodeFormat.QR_CODE, BarcodeFormat.CODE_39)
        barcodeView?.barcodeView?.decoderFactory = DefaultDecoderFactory(formats)
        barcodeView?.setStatusText("")
        barcodeView?.viewFinder?.visibility = View.INVISIBLE

        barcodeView?.initializeFromIntent(Intent())
        barcodeView?.decodeContinuous(object : BarcodeCallback {
            override fun possibleResultPoints(resultPoints: MutableList<ResultPoint>?) {
                super.possibleResultPoints(resultPoints)
            }

            override fun barcodeResult(result: BarcodeResult?) {
                (context as ResultCallback).result(result?.result?.text ?: "no result")
                barcodeView?.setStatusText(result?.text)
            }
        })

        barcodeView?.resume()
        textView = TextView(context)
        textView.textSize = 36f
        textView.setBackgroundColor(Color.rgb(255, 255, 255))
        textView.text = "Rendered on a native Android view (id: $id) ${creationParams?.entries}"

        val flutterEngine = (context as ResultCallback).getMyFlutterEngine()
        MethodChannel(flutterEngine?.dartExecutor?.binaryMessenger, CHANNEL)
                .setMethodCallHandler { call, result ->
                    when (call.method) {
                        "onFlash" -> {
                            barcodeView?.setTorchOn()
                            result.success("setTorchOn")
                        }

                        "offFlash" -> {
                            barcodeView?.setTorchOff()
                            result.success("setTorchOff")
                        }
                        else -> {
                            result.notImplemented()
                        }
                    }
                }
    }
}

В init блоке подписываемся на жизненный цикл activiti и в соответствующих методах вызываем resume / pause у barcodeView. Важно: что без реализации этих методов вы вместо видеопотока с камеры будет увидите черный экран:

NativeView наследуется от интерфейса PlatformView это обязывает нас реализовать два метода:

В getView мы должны вернуть View которая является главным экраном. Нужно создать layout.xml с следующего содержания:

Из него с помощью LayoutInflater мы создаём view и возвращаем ссылку на него в методе getView():

Поскольку наш layout содержит DecoratedBarcodeView мы можем найти его(получить ссылку на него) с помощью findViewById и настроить как на нужно:

Тут мы устанавливаем поддерживаемый формат qr кодов, дефолтную строку результата «сеттим» как пустую, убираем стандартную рамку в центре экрана. Отдельно стоит остановиться на этом куске кода:

Когда библиотека распознаёт qr код, результат этого она передаёт в callback - barcodeResult(result: BarcodeResult?). В нем имея ссылку на MainActivity через общий контекст, вызываем метод result нашего ResultCallback и через него передаём строку с результатом. И уже в самом MainActivity используя EventChannel передаём дальше — во Flutter окружение.

Код выше является обработчиком событий отправляемых из flutter среды. У MethodChannel принимает MethodCallHandler используя который мы узнаём какой метод сейчас вызывается и реагируем на него. В данном коде мы включаем или выключаем подсветку камеры.

Короткое видео с примером этого приложения:

Исходный код приложения

zxing-android-embedded

Комментарии (2)


  1. sargon5000
    29.11.2021 04:37

    Спасибо за статью, Сергей. Может, информация пригодится. А как насчёт Datamatrix?


    1. postflow Автор
      29.11.2021 09:25

      Если речь о классическом Datamatrix - zxing его распознает (настройка Collection<BarcodeFormat> ). Но если речь о datamatrix dpm, то этот формат не подаётся распознаванию (https://github.com/journeyapps/zxing-android-embedded/issues/543#issuecomment-627529828)