Привет! Меня зовут Сергей, я Flutter-разработчик SimbirSoft. В этой статье хочу поделиться интересным платформоспецифичным кейсом для мобильных устройств и ТСД. Нам с командой удалось сократить затраты на разработку и ускорить процесс ввода данных в 2 раза.

Клиент располагает крупными товарными складами, на которых сотрудники используют сканеры 1-D/2-D кодов — это смартфоны на iOS, Android, а также терминалы сбора данных с установленным Flutter-приложением для сборки заказов. Нашей задачей стало обновить плагин сканера, не привлекая отдельные команды для разных платформ.

Очевидно, что данная функциональность сильно полагается на платформу, и Flutter из коробки не умеет работать с ТСД. Как мы решили эту задачу, расскажу по порядку, а в конце поделюсь результатами тестов и ссылкой на исходный код. Спойлер: по сравнению с ручным вводом штрихкодов скорость выросла в 13,4 раза, а с предыдущей версией сканера в 2 раза.

Подобный кейс применим везде, где требуется сканировать 1-D/2-D коды в большом количестве. Поэтому материал будет полезен разработчикам кроссплатформенных приложений для решения подобных задач, а также их заказчикам.

Вводные данные

Представим большой склад, с которого ежедневно отправляются сотни заказов. Чтобы вести учет в системе по каждому наименованию в собранном заказе, сотрудник склада сканирует штрихкоды товаров.

Для этого сотрудник использует специальное мобильное приложение на Android и iOS со сканером, написанным на Flutter. Сканер не работает в режиме потока, а вызывается по запросу, и каждое отдельное сканирование занимает 2 секунды (это в идеальном случае). Кажется, что немного, но за весь день приходится обрабатывать тысячи таких кодов.

Владелец склада планирует расширение бизнеса, он хотел бы отправлять больше заказов и тратить на распознавание штрихкодов меньше времени. Еще для части работников он решает приобрести ТСД (терминал сбора данных) под управлением Android. 

Мы оценили требования и пришли к выводу, что требуется написать новый плагин для всех используемых устройств, который будет считывать штрихкоды потоково, не требуя открытия нового экрана. За счет уменьшения количества инициализаций и закрытий сканера/камеры удастся сократить общее время, затрачиваемое на сканирование.

Наша задача — написать новый плагин со следующими фичами:

  1. Он должен работать на Android, iOS и выбранных моделях ТСД (Android).

  2. Возможность отсканировать несколько 1-D/2-D кодов с одного экрана.

  3. Для не ТСД сканирование должно осуществляться с помощью распознавания изображения с камеры устройства (Android/iOS). 

Становится понятно, что для этой задачи не подойдут стандартные средства Dart/Flutter или существующие pub-пакеты. Поэтому принимаем решение написать свой плагин, и нам на помощь приходит встроенный в Flutter механизм взаимодействия с кастомным платформоспецифичным кодом — Platform Channels!

Что такое Platform Channels и как его использовать

Platform Channels — механизм, позволяющий из dart-кода вызывать нативный код.

Flutter использует гибкую систему, которая позволяет вызывать специфичные для платформы API на языке, который работает непосредственно с этими API:

  • Kotlin или Java на Android

  • Swift или Objective-C на iOS

  • C++ на Windows

  • Objective-C на macOS

  • C на Linux

Сообщения передаются между клиентом (UI) и хостом (платформой) с использованием каналов платформы, как показано на этой схеме:

Схема передачи Flutter сообщений с Flutter‑приложения на хост‑платформу. Источник

Сообщения и ответы передаются асинхронно, чтобы обеспечить отзывчивость пользовательского интерфейса. На стороне клиента MethodChannel позволяет отправлять сообщения, соответствующие вызовам методов. Со стороны платформы MethodChannel на Android (MethodChannelAndroid) и FlutterMethodChannel на iOS (MethodChanneliOS) позволяют принимать вызовы метода и отправлять результат обратно. Эти классы позволяют разрабатывать плагин платформы с очень небольшим количеством бойлерплейта.

Создадим наш plugin, запустив следующую команду в терминале:

flutter create --org com.example --template=plugin --platforms=android -a kotlin example_barcode_scanner

Это создает проект плагина в папке «example_barcode_scanner» со следующим содержимым:

  • Dart API для плагина
    lib/example_barcode_scanner.dart

  • Реализация API плагина в Kotlin для конкретной платформы Android
    android/src/main/java/com/example/hello/ExampleBarcodeScannerPlugin.kt

  • Приложение Flutter, которое зависит от плагина и иллюстрирует, как его использовать
    example/

Вынос платформоспецифичного кода в отдельный плагин позволит нам повторно использовать наш сканер в других приложениях. Подробнее о создании плагинов можно прочесть в документации.

Реализация плагина на Dart 

Приступим же к реализации нашего плагина! 

  1. В качестве первого шага нам потребуется определить PlatformChannel API нашего плагина. Для этого создайте .dart файл вне lib проекта:

/// описание API, генерируемого pigeon для платформы
@HostApi()
abstract class ScanHostApi {
 /// запуск сканера
 @async
 StartScanResult startScan();

 /// остановка сканера
 @async
 void stopScan();
}

/// описание API, геренрируемого pigeon для Flutter-приложения
@FlutterApi()
abstract class ScanFlutterApi {
 /// метод, вызываемый платформой для передачи результата сканирования
 void onScan(String data);
}
  1. Опишем модели, используемые для передачи данных по PlatformChannel:

Тип реализации сканера, это потребуется в дальнейшем для выбора реализации UI под соответствующий тип сканера:

/// тип реализации сканера
enum ScannerType {
 /// ТСД
 tsd,

 /// камера
 camera
}

Модель свойств камеры для корректного отображения preview с камеры:

/// модель свойств камеры
class CameraProperties {
 const CameraProperties(
   this.textureId,
   this.aspectRatio,
   this.width,
   this.height,
 );

 /// id текстуры для передачи изображения
 final int textureId;

 /// соотношение сторон
 final double aspectRatio;
 final int width;
 final int height;
}

Модель результата метода startScan:

/// модель результата запуска сканера
class StartScanResult {
 const StartScanResult(
   this.scannerType,
   this.cameraProperties,
 );

 /// тип сканера
 final ScannerType scannerType;

 /// свойства камеры
 /// согласно контракту, если тип сканера [ScannerType.tsd], то данное поле буде null.
 final CameraProperties? cameraProperties;
}
  1. Далее запустим pigeon для генерации Dart и платформенного кода API для типобезопасного взаимодействия по PlatformChannel:

flutter pub run pigeon \

 --input pigeons/scan_api.dart \

 --dart_out lib/scan_api.dart \

 --java_out android/src/main/kotlin/com/example/example_barcode_scanner/Pigeon.java  \

 --java_package "com.example.example_barcode_scanner"

  1. Следующим шагом создадим интерфейс сервиса для взаимодействия со сканером:

abstract class ExampleBarcodeScannerService extends ScanFlutterApi {
 static ExampleBarcodeScannerService? _instance;

 static ExampleBarcodeScannerService get instance {
   if (_instance != null) {
     return _instance!;
   }
   _instance = ExampleBarcodeScannerServiceImpl();
   return _instance!;
 }

 /// поток результатов сканирования
 Stream<String> get scanResultStream;

 /// метод запуска сервиса сканирования
 /// в [onScan] передается результат успешного сканирования
 ///
 /// возвращает [StartScanResult] с информацией о сканере
 Future<StartScanResult> startScan();

 /// метод остановки сервиса сканирования
 Future<void> stopScan();
}

Можно заметить что ExampleBarcodeScannerService наследует ScanFlutterApi. Это сделано для того чтобы экземпляр реализации ExampleBarcodeScannerService мог принимать события от платформы. 

ExampleBarcodeScannerServiceImpl() {
 // важно вызвать чтобы зарегистрировать экземпляр
 // плагина для получения сообщений по PlatformChannel
 ScanFlutterApi.setup(this);
}

5. Для вызовов методов платформы потребуется создать экземпляр ScanHostApi:

final ScanHostApi _scanHostApi = ScanHostApi();

Таким образом, при вызове startScan/stopScan мы будем просто проксировать вызовы платформе:

@override
Future<StartScanResult> startScan() {
 // вызываем метод платформы для запуска сканера
 return _scanHostApi.startScan();
}
@override
Future<void> stopScan() {
 // вызываем метод платформы для остановки сканера
 return _scanHostApi.stopScan();
}

6. А платформа, в свою очередь, будет проактивно вызывать метод onScan при распознании штрихкода:

@override
void onScan(String data) {
 _scanResultStreamController.add(data);
}

Реализация плагина на Kotlin 

  1. От реализации интерфейса на Dart перейдем к реализации плагина на Android:

/** Класс плагина сканера
* реализует [Pigeon.ScanHostApi] для получения событий по PlatformChannel
* @property [flutterApi] - экземпляр [Pigeon.ScanFlutterApi] для вызова api Flutter приложения
* */
class ExampleBarcodeScannerPlugin : FlutterPlugin, ActivityAware, Pigeon.ScanHostApi {
  1. ExampleBarcodeScannerPlugin помимо FlutterPlugin будет также реализовывать ActivityAware, чтобы наш плагин получал уведомления о состоянии activity через данные методы:

override fun onAttachedToActivity(
    activityBinding: ActivityPluginBinding
) {...}

override fun onDetachedFromActivityForConfigChanges() {...}

override fun onReattachedToActivityForConfigChanges(
   activityBindingScanner: ActivityPluginBinding
) {...}

override fun onDetachedFromActivity() {...}

Но стоит отдельно отметить, что onAttachedToActivity вызывается строго после onAttachedToEngine из FlutterPlugin.

А интерфейс Pigeon.ScanHostApi был сгенерирован pigeon, и содержит методы, вызываемые Flutter через PlatformChannel. 

  1. Чтобы экземпляр плагина мог получать события по PlatformChannel, важно вызвать setup из Pigeon.ScanHostApi:

override fun onAttachedToEngine(
   @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding
) {
   // важно вызвать, чтобы зарегистрировать экземпляр
   // плагина для получения сообщений по PlatformChannel
   Pigeon.ScanHostApi.setup(flutterPluginBinding.binaryMessenger, this)
  1. Далее определим интерфейс сканера, общий для камеры и ТСД:

/**
* Интерфейс сканера
* */
interface Scanner {
   fun onActivityAttach(activity: Activity)

   fun onActivityDetach(activity: Activity)
   /**
    * метод запуска сканера
    * @param onData - callback вызываемый при распозновании штрихкода
    * @param onComplete - callback вызываемый при завершении запуска зканера
    * */

   fun startScan(
       onData: (String) -> Unit,
       onComplete: (Pigeon.StartScanResult) -> Unit,
   )

   /**
    * Метод остановки сканера
    * */
   fun stopScan()
}

onActivityAttach и onActivityDetach нужны для того чтобы мы могли иметь доступ к activity внутри сканера. 

Реализация сканера

От интерфейса перейдем к реализации в виде сканера, использующего заднюю камеру устройства для распознания штрихкодов. 

  1. В startScan получаем processCameraProvider и создаем preview:

val cameraProviderFuture =
ProcessCameraProvider.getInstance(activity)
cameraProviderFuture.addListener(
   {
       val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
       processCameraProvider = cameraProvider
       val preview = Preview.Builder().build()
  1. Далее биндим userCase preview к cameraProvider:

try {
   cameraProvider.unbindAll()
   camera = cameraProvider.bindToLifecycle(
       activity as LifecycleOwner,
       CameraSelector.DEFAULT_BACK_CAMERA,
       preview,
       imageAnalysis
   )
  1. Связываем текстуру с preview:

preview.setSurfaceProvider { request ->
   val reqRes = request.resolution
   // связываем текстуру и превью
   val surfaceTexture = textureEntry.surfaceTexture()
   surfaceTexture.setDefaultBufferSize(reqRes.width, reqRes.height)
   request.provideSurface(Surface(surfaceTexture), mainThreadExecutor) {}
  1. Формируем результат со свойствами камеры и отправляем его через onComplete в Flutter:

// формируем результат
val cameraProperties = Pigeon.CameraProperties.Builder()
   .setAspectRatio(reqRes.height.toDouble() / reqRes.width.toDouble())
.setHeight(reqRes.height.toLong()).setWidth(reqRes.width.toLong())
   .setTextureId(textureEntry.id()).build()
val startScanResult = Pigeon.StartScanResult.Builder()
   .setScannerType(Pigeon.ScannerType.CAMERA)
   .setCameraProperties(cameraProperties).build()
// отправляем результат через вызов onComplete
onComplete(startScanResult)
  1. Далее в ExampleBarcodeScannerPlugin создадим экземпляр CameraScanner как дефолтный сканер, так как большинство девайсов обладают камерой:

override fun onAttachedToActivity(activityBinding: ActivityPluginBinding) {
   activity = activityBinding.activity
   val deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} ${Build.DEVICE}"
   Log.i("ExampleBarcodeScanner", deviceInfo)
   scanner = when {
       // TODO здесь можно добавить создание объекта реализации [Scanner] под конкретный ТСД
       else -> textureRegistry?.let { textureRegistry ->
           return@let CameraScanner(textureRegistry)
       }
   }
  1. А метод startScan ExampleBarcodeScannerPlugin примет следующий вид:

override fun startScan(result: Pigeon.Result<Pigeon.StartScanResult>?) {
   val scanner = this.scanner
   if (scanner == null) {
       result?.error(Exception("Scanner not running"))
       return
   }

   scanner.startScan(onData = { data ->
       ContextCompat.getMainExecutor(activity).execute {
           Log.i("ExampleBarcodeScanner", "data: $data")
           flutterApi?.onScan(data) {}
       }
   }, onComplete = {
       result?.success(it)
   })
}

Важно вызывать методы FlutterApi только из главного потока, так как они обращаются к PlatformChannel. 

  1. Но данный сканер еще не умеет распознавать изображения. Чтобы исправить досадный недостаток, напишем свой класс, реализующий ImageAnalysis.Analyzer:

/**
* класс анализатора изображения для распознавания EAN-13 и EAN-8 штрихкодов
*/
class MlKitCodeAnalyzer(
   private val barcodeListener: SuccessListener,
) : ImageAnalysis.Analyzer {

   private val scanner = BarcodeScanning.getClient(
       defaultOptions()
   )

   private fun defaultOptions() = BarcodeScannerOptions.Builder().setBarcodeFormats(
       Barcode.FORMAT_EAN_13,
       Barcode.FORMAT_EAN_8,
   ).build()

   @SuppressLint("UnsafeExperimentalUsageError")
   override fun analyze(image: ImageProxy) {...}
}
  1. Данный анализатор использует mlKit для распознания штрихкодов:

@SuppressLint("UnsafeExperimentalUsageError")
override fun analyze(image: ImageProxy) {
   val mediaImage = image.image ?: return
   val mlImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)
   val currentTimestamp = System.currentTimeMillis()
   scanner.process(mlImage).addOnSuccessListener { barcodes ->
       barcodes.firstOrNull()?.let {
           it.rawValue?.let(barcodeListener)
       }
   }.addOnCompleteListener {
       // Позволяет производить сканирование раз в секунду
       CoroutineScope(Dispatchers.IO).launch {
           delay(1000 - (System.currentTimeMillis() - currentTimestamp))
           image.close()
       }
   }
}
  1. Подключим наш анализатор к камере. Для этого нам надо создать ImageAnalysis и указать в качестве Analyzer экземпляр нашего анализатора. 

val imageAnalysis = ImageAnalysis.Builder().build()
val analyzer: ImageAnalysis.Analyzer = MlKitCodeAnalyzer(
   barcodeListener = onData,
)
  1. Также важно выставить backpressure-стратегию. STRATEGY_KEEP_ONLY_LATEST в нашем случае подходит идеально, так как модель распознания кодов из mlKit работает весьма шустро:

val imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build()
  1. Затем биндим наш imageAnalysis к cameraProvider:

cameraProvider.unbindAll()
camera = cameraProvider.bindToLifecycle(
   activity as LifecycleOwner,
   CameraSelector.DEFAULT_BACK_CAMERA,
   preview,
   imageAnalysis
)

Результат и выводы

Пройдя все этапы работы, запускаем небольшое демоприложение с использованием нашего плагина и наслаждаемся результатом. Нативная часть успешно распознает EAN-9 и EAN-13 штрихкоды, передает изображение и результат сканирования во Flutter-приложение.

Какой профит мы предоставили клиенту и его бизнесу?

  1. Экономия времени = денег. За счет обновления сканера мы смогли уменьшить время, затрачиваемое пользователем на ввод 1-D/2-D кода. Чтобы убедиться в этом, проведем синтетический тест:

    При работе старой версии сканера на чтение 20 штрихкодов ушло 39 секунд 475 миллисекунд.

    После обновления приложения при потоковом чтении процесс занял 20 секунд 086 миллисекунд.

    Скорость увеличилась в 2 раза.

    Еще один тест мы провели, чтобы сравнить работу обновленного сканера с ручным вводом.

    На ручной ввод 20 штрихкодов ушло 4 минуты, 30 секунд и 11 миллисекунд.

    Скорость увеличилась в 13,4 раза.

    Кажется, разница небольшая. Но если у вас тысячи товаров на складе, а время сотрудника стоит денег, это заметный рост скорости работы.

  2. Универсальность. Решение делает возможным использование и поддержку специфичных устройств, таких как ТСД. Для этого надо создать свою реализацию интерфейса Scanner и подставить ее в момент определения устройства:

    override fun onAttachedToActivity(activityBinding: ActivityPluginBinding) {
       activity = activityBinding.activity
       val deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} ${Build.DEVICE}"
       Log.i("ExampleBarcodeScanner", deviceInfo)
       scanner = when {
           // TODO здесь можно добавить создание объекта реализации [Scanner] под конкретный ТСД
           else -> textureRegistry?.let { textureRegistry ->
               return@let CameraScanner(textureRegistry)
           }
       }
       try {
           activity?.let { activity ->
               scanner?.onActivityAttach(activity)
           }
       } catch (e: Throwable) {
           Log.e("ExampleBarcodeScanner", deviceInfo, e)
       }
    }
  1. Снижение влияния человеческого фактора. Мы нивелируем риск ошибки, когда сотрудник склада может ввести неверный штрихкод.

    Но важно отметить, что желательно оставить пользователю опцию ввести данные вручную, так как код может быть поврежден и его нельзя считать устройством.

  1. Типобезопасность. Использование Pigeon устраняет необходимость сопоставления строк между хостом и клиентом для имен и типов данных сообщений. Сгенерированный код читается и гарантирует отсутствие конфликтов между несколькими клиентами разных версий. Поддерживаемыми языками являются Objective-C, Java, Kotlin и Swift (с взаимодействием Objective-C).

  2. Поддержка крупных 2-D кодов. При небольшой доработке возможна обработка 2-D кодов, содержащих большой объем информации, невозможной для ввода человеком (QR-код, DataMatrix и т.д.).

Отмечу, что для написания такого плагина нужны компетенции не только в Flutter, но и в iOS/Аndroid. Если таковых нет, то попросите помощи у своих коллег из соответствующих направлений. Они сделают свою платформенную магию, а вам лишь останется «примотать» их код к Flutter-приложению. Это требует несравнимо меньше затрат, чем работа отдельных команд для каждой платформы.

Тем, кто хочет изучить исходный код подробнее, добро пожаловать на GitHub.

Спасибо за внимание!

Полезные материалы о разработке мы также публикуем в наших соцсетях – ВКонтакте и Telegram.