Flutter插件开发之HmsScanKit实现示例详解

前沿

从事Flutter开发以来,一直都是使用已有的插件,没有自己开发过。最近同事推荐让我使用华为的扫码SDK(hms_scan_kit),正好借此机会来开发一个Flutter的原生插件。算是对最近的插件学习做一个简单的总结。

效果图

我们先看一下实现的扫码效果:点击LoadScanKit按钮调起插件的扫码功能,扫码成功后在界面显示扫码结果。

相关知识点

1. Flutter Packages

通过使用 package(的模式)可以创建易于共享的模块化代码。一个最基本的 package 由以下内容构成:

- pubspec.yaml 文件
用于定义 package 名称、版本号、作者等其他信息的元数据文件。

- lib 目录
包含共享代码的 lib 目录,其中至少包含一个 <package-name>.dart 文件。

2. Package类别

Package包分为二种:

  • 纯Dart库(Dart packages)
  • 只用Dart编写的传统package,比如 path。
  • 原生插件(Plugin packages)
  • 使用Dart编写的,按需使用Java或 Kotlin、Objective-C或Swift 分别在Android或iOS平台实现的package。

3. 原生插件开发步骤

  • 创建package
  • 想要创建原生插件 package,请使用带有 --template=plugin 标志的 flutter create 命令
flutter create --org com.example --template=plugin --platforms=android,ios -a kotlin hello
  • 实现package a. 定义 package API(.dart) b. 添加 Android/iOS 平台代码(.kt/.swift) C. 关联 API 和平台代码

  • 指定插件支持的平台,比如hms_scan插件就如下定义:

name: flutter_hms_scan
description: A new Flutter project.
version: 0.0.1
homepage:
environment:
 sdk: ">=2.15.1 <3.0.0"
 flutter: ">=2.5.0"
flutter:
 plugin:
 platforms:
 android:
 package: com.fitem.flutter_hms_scan
 pluginClass: HmsScanPlugin
 ios:
 pluginClass: HmsScanPlugin

备注:如果使用IDE(比如Android Studio)直接在创建Flutter项目处选择Plugin类型即可,IDE会创建插件模板并实现获取平台系统版本的example,无需上面的步骤

  • Dart对应原生类型:
DartkotlinSwift
nullnullnil
boolBooleanNSNumber(value: Bool)
intIntNSNumber(value: Int32)
intLongNSNumber(value: Int)
doubleDoubleNSNumber(value: Double)
StringStringString
Uint8ListByteArrayFlutterStandardTypedData(bytes: Data)
Int32ListIntArrayFlutterStandardTypedData(int32: Data)
Int64ListLongArrayFlutterStandardTypedData(int64: Data)
Float32ListFloatArrayFlutterStandardTypedData(float32: Data)
Float64ListDoubleArrayFlutterStandardTypedData(float64: Data)
ListListArray
MapHashMapDictionary
  • Flutter的plugin通信流程如下:

HmsScan插件的实现

前面说了这么多,终于进入正题,下面我们开始HmsScan插件的开发吧。

1. 定义 package API:

class FlutterHmsScan {
 // 创建插件
 static const MethodChannel _channel = MethodChannel('hms_scan');
 // 定义调用方法
 static Future<ScanBean> loadScanKit() async {
 return await _channel
 .invokeMethod("loadScanKit")
 .then((value) => scanBeanFromJson(json.encode(value)));
 }
}

2. Android代码实现:

a. 使用IDE打开Android目录,根据官方SDK导入库

// scankitSDK
 implementation 'com.huawei.hms:scanplus:2.4.0.301'
 // 需要在repositories中导入url
 maven {url 'https://developer.huawei.com/repo/'}

b. 继承FlutterPlugin类,接入Flutter管道。由于sdk用到权限请求和onActivityResult的回调,因此我们需要继承ActivityAware对Activity添加监听。其中registerWith()方法是为了适配老版本Flutter的兼容。

class HmsScanPlugin : FlutterPlugin, ActivityAware {
 /// The MethodChannel that will the communication between Flutter and native Android
 ///
 /// This local reference serves to register the plugin with the Flutter Engine and unregister it
 /// when the Flutter Engine is detached from the Activity
 private lateinit var mScanLauncher: ScanLauncher
 private lateinit var mHandler: MethodCallHandlerImpl
 /**
 * 老版本Flutter兼容
 */
 fun registerWith(registrar: Registrar) {
 mScanLauncher = ScanLauncher(registrar.context(), registrar.activity())
 mHandler = MethodCallHandlerImpl(mScanLauncher)
 mHandler.startService(registrar.messenger())
 registrar.addActivityResultListener(mHandler)
 registrar.addRequestPermissionsResultListener(mHandler)
 }
 override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
 mScanLauncher = ScanLauncher(flutterPluginBinding.applicationContext, null)
 mHandler = MethodCallHandlerImpl(mScanLauncher)
 mHandler.startService(flutterPluginBinding.binaryMessenger)
 }
 override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
 mHandler.stopService()
 }
 override fun onAttachedToActivity(binding: ActivityPluginBinding) {
 mScanLauncher.activity = binding.activity
 binding.addActivityResultListener(mHandler)
 binding.addRequestPermissionsResultListener(mHandler)
 }
 override fun onDetachedFromActivity() {
 mScanLauncher.activity = null
 }
 override fun onDetachedFromActivityForConfigChanges() {
 onDetachedFromActivity()
 }
 override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
 onAttachedToActivity(binding)
 }
}

c. 考虑到HmsScanPlugin职责过多,这里使用MethodCallHandlerImpl进行分离解耦,专门处理Flutter管道的通信。

/**
 * 插件方法监听
 * Created by Fitem on 2022/3/2.
 */
class MethodCallHandlerImpl(var scanLauncher: ScanLauncher) : MethodChannel.MethodCallHandler,
 MethodCallHandlerListener, PluginRegistry.ActivityResultListener,
 PluginRegistry.RequestPermissionsResultListener {
 private lateinit var channel: MethodChannel
 override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
 when (call.method) {
 "getPlatformVersion" -> {
 result.success("Android ${android.os.Build.VERSION.RELEASE}")
 }
 "loadScanKit" -> {
 scanLauncher.loadScanKit(call, result)
 }
 else -> {
 result.notImplemented()
 }
 }
 }
 override fun startService(binaryMessenger: BinaryMessenger) {
 channel = MethodChannel(binaryMessenger, "hms_scan")
 channel.setMethodCallHandler(this)
 }
 override fun stopService() {
 channel.setMethodCallHandler(null)
 }
 override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
 if (resultCode != Activity.RESULT_OK || data == null) {
 return false
 }
 return scanLauncher.onActivityResult(requestCode, resultCode, data)
 }
 override fun onRequestPermissionsResult(
 requestCode: Int,
 permissions: Array<out String>?,
 grantResults: IntArray?
 ): Boolean {
 if (permissions == null || grantResults == null) {
 return false
 }
 return scanLauncher.onRequestPermissionResult(requestCode, permissions, grantResults)
 }
}
// 管道通信生命周期的绑定
interface MethodCallHandlerListener {
 fun startService(binaryMessenger: BinaryMessenger)
 fun stopService()
}

d. 最后通过ScanLauncher来专门处理扫码功能的相关实现

class ScanLauncher(var applicationContext: Context, var activity: Activity?) {
 companion object {
 const val CAMERA_REQ_CODE = 111
 const val DEFINED_CODE = 222
 const val BITMAP_CODE = 333
 const val MULTIPROCESSOR_SYN_CODE = 444
 const val MULTIPROCESSOR_ASYN_CODE = 555
 const val GENERATE_CODE = 666
 const val DECODE = 1
 const val GENERATE = 2
 const val REQUEST_CODE_SCAN_ONE = 0X01
 const val REQUEST_CODE_DEFINE = 0X0111
 const val REQUEST_CODE_SCAN_MULTI = 0X011
 const val DECODE_MODE = "decode_mode"
 const val RESULT = "SCAN_RESULT"
 const val SCAN_STATUS = "scanStatus"
 const val CODE_FORMAT = "codeFormat"
 const val RESULT_TYPE = "resultType"
 const val CODE_RESULT = "codeResult"
 }
 private var result: MethodChannel.Result? = null
 /**
 * 扫码
 */
 fun loadScanKit(call: MethodCall, result: MethodChannel.Result) {
 this.result = result
 requestPermission(CAMERA_REQ_CODE, DECODE)
 }
 /**
 * Apply for permissions.
 */
 private fun requestPermission(requestCode: Int, mode: Int) {
 if (activity == null) {
 result?.success(mapOf(SCAN_STATUS to false))
 return
 }
 if (mode == DECODE) {
 decodePermission(requestCode)
 } else if (mode == GENERATE) {
 generatePermission(requestCode)
 }
 }
 /**
 * Apply for permissions.
 */
 private fun decodePermission(requestCode: Int) {
 ActivityCompat.requestPermissions(
 activity!!,
 arrayOf(Manifest.permission.CAMERA, Manifest.permission.READ_EXTERNAL_STORAGE),
 requestCode
 )
 }
 /**
 * Apply for permissions.
 */
 private fun generatePermission(requestCode: Int) {
 ActivityCompat.requestPermissions(
 activity!!, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
 requestCode
 )
 }
 fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent): Boolean {
 //Default View
 if (requestCode == REQUEST_CODE_SCAN_ONE) {
 val obj: HmsScan? = data.getParcelableExtra(ScanUtil.RESULT)
 if (obj != null) {
 result?.success(
 mapOf(
 SCAN_STATUS to true,
 CODE_FORMAT to getCodeFormat(obj.scanType),
 RESULT_TYPE to getResultType(obj),
 CODE_RESULT to obj.originalValue
 )
 )
 return true
 }
 //MultiProcessor & Bitmap
 }
 return false
 }
 fun onRequestPermissionResult(
 requestCode: Int,
 permissions: Array<out String>,
 grantResults: IntArray
 ): Boolean {
 if (grantResults.size < 2 || grantResults[0] != PackageManager.PERMISSION_GRANTED || grantResults[1] != PackageManager.PERMISSION_GRANTED) {
 return false
 }
 //Default View Mode
 if (requestCode == CAMERA_REQ_CODE) {
 ScanUtil.startScan(
 activity,
 REQUEST_CODE_SCAN_ONE,
 HmsScanAnalyzerOptions.Creator().create()
 )
 return true
 }
 return false
 }
 /**
 * 获取CodeFormat
 */
 private fun getCodeFormat(codeFormat: Int): String {
 return when (codeFormat) {
 HmsScan.QRCODE_SCAN_TYPE -> "QR code"
 HmsScan.AZTEC_SCAN_TYPE -> "AZTEC code"
 HmsScan.DATAMATRIX_SCAN_TYPE -> "DATAMATRIX code"
 HmsScan.PDF417_SCAN_TYPE -> "PDF417 code"
 HmsScan.CODE93_SCAN_TYPE -> "CODE93"
 HmsScan.CODE39_SCAN_TYPE -> "CODE39"
 HmsScan.CODE128_SCAN_TYPE -> "CODE128"
 HmsScan.EAN13_SCAN_TYPE -> "EAN13 code"
 HmsScan.EAN8_SCAN_TYPE -> "EAN8 code"
 HmsScan.ITF14_SCAN_TYPE -> "ITF14 code"
 HmsScan.UPCCODE_A_SCAN_TYPE -> "UPCCODE_A"
 HmsScan.UPCCODE_E_SCAN_TYPE -> "UPCCODE_E"
 HmsScan.CODABAR_SCAN_TYPE -> "CODABAR"
 else -> "OTHER"
 }
 }
 /**
 * 获取ResultType
 */
 private fun getResultType(hmsScan: HmsScan): String {
 return when (hmsScan.scanType) {
 HmsScan.QRCODE_SCAN_TYPE -> when (hmsScan.scanTypeForm) {
 HmsScan.QRCODE_SCAN_TYPE -> "Text"
 HmsScan.EVENT_INFO_FORM -> "Event"
 HmsScan.CONTACT_DETAIL_FORM -> "Contact"
 HmsScan.DRIVER_INFO_FORM -> "License"
 HmsScan.EMAIL_CONTENT_FORM -> "Email"
 HmsScan.LOCATION_COORDINATE_FORM -> "Location"
 HmsScan.TEL_PHONE_NUMBER_FORM -> "Tel"
 HmsScan.SMS_FORM -> "SMS"
 HmsScan.WIFI_CONNECT_INFO_FORM -> "Wi-Fi"
 HmsScan.URL_FORM -> "WebSite"
 HmsScan.URL_FORM -> "WebSite"
 else -> "Text"
 }
 HmsScan.EAN13_SCAN_TYPE -> when (hmsScan.scanTypeForm) {
 HmsScan.ISBN_NUMBER_FORM -> "ISBN"
 HmsScan.ARTICLE_NUMBER_FORM -> "Product"
 else -> "Text"
 }
 HmsScan.EAN8_SCAN_TYPE,
 HmsScan.UPCCODE_A_SCAN_TYPE,
 HmsScan.UPCCODE_E_SCAN_TYPE -> when (hmsScan.scanTypeForm) {
 HmsScan.ARTICLE_NUMBER_FORM -> "Product"
 else -> "Text"
 }
 else -> "Text"
 }
 }
}

最后在AndroidManifest.xml中添加需要的权限:

<!--相机权限-->
 <uses-permission android:name="android.permission.CAMERA" />
 <!--文件读取权限-->
 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

3. ios部分的实现

ios原本也是打算使用hms的,但是官方居然2年没有更新了,并且不支持bitcode版本、不支持cocopod,demo也无法正常运行。经过一番尝试后,决定放弃使用该库,换成了MTBBarcodeScanner库。(ios新人一个,如果有精通IOS的同学们欢迎指教!)

a. 通过SwiftHmsScanPlugin创建Flutter管道

public class SwiftHmsScanPlugin: NSObject, FlutterPlugin, BarcodeScannerViewControllerDelegate {
 private var result: FlutterResult?
 private var hostViewController: UIViewController?
 public static func register(with registrar: FlutterPluginRegistrar) {
 let channel = FlutterMethodChannel(name: "hms_scan", binaryMessenger: registrar.messenger())
 let instance = SwiftHmsScanPlugin()
 registrar.addMethodCallDelegate(instance, channel: channel)
 }
 public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
 self.result = result
 if ("loadScanKit" == call.method) {
 loadScanKit()
 } else {
 result("iOS " + UIDevice.current.systemVersion)
 }
 }
 public func loadScanKit() {
 if let rootVC = UIApplication.shared.keyWindow?.rootViewController {
 hostViewController = topViewController(base:rootVC)
 } else if let window = UIApplication.shared.delegate?.window,let rootVC = window?.rootViewController {
 hostViewController = topViewController(base:rootVC)
 }
 let scannerViewController = BarcodeScannerViewController()
 let navigationController = UINavigationController(rootViewController: scannerViewController)
 if #available(iOS 13.0, *) {
 navigationController.modalPresentationStyle = .fullScreen
 }
 scannerViewController.delegate = self
 hostViewController?.present(navigationController, animated: false)
 }
 private func topViewController(base: UIViewController?) -> UIViewController? {
 if let nav = base as? UINavigationController {
 return topViewController(base: nav.visibleViewController)
 } else if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
 return topViewController(base: selected)
 } else if let presented = base?.presentedViewController {
 return topViewController(base: presented)
 }
 return base
 }
 func didScanBarcodeWithResult(_ controller: BarcodeScannerViewController?, scanResult: ScanResult) {
 result?(["codeResult":scanResult.rawContent, "scanStatus" : String(true), "resultType": String(scanResult.format.rawValue)])
 }
 func didFailWithErrorCode(_ controller: BarcodeScannerViewController?, errorCode: String) {
 result?(["scanStatus" : String(false)])
 }
}

b. BarcodeScannerViewController实现扫码功能

class BarcodeScannerViewController: UIViewController {
 private var previewView: UIView?
 private var scanRect: ScannerOverlay?
 private var scanner: MTBBarcodeScanner?
 private let formatMap = [
 BarcodeFormat.aztec : AVMetadataObject.ObjectType.aztec,
 BarcodeFormat.code39 : AVMetadataObject.ObjectType.code39,
 BarcodeFormat.code93 : AVMetadataObject.ObjectType.code93,
 BarcodeFormat.code128 : AVMetadataObject.ObjectType.code128,
 BarcodeFormat.dataMatrix : AVMetadataObject.ObjectType.dataMatrix,
 BarcodeFormat.ean8 : AVMetadataObject.ObjectType.ean8,
 BarcodeFormat.ean13 : AVMetadataObject.ObjectType.ean13,
 BarcodeFormat.interleaved2Of5 : AVMetadataObject.ObjectType.interleaved2of5,
 BarcodeFormat.pdf417 : AVMetadataObject.ObjectType.pdf417,
 BarcodeFormat.qr : AVMetadataObject.ObjectType.qr,
 BarcodeFormat.upce : AVMetadataObject.ObjectType.upce,
 ]
 var delegate: BarcodeScannerViewControllerDelegate?
 private var device: AVCaptureDevice? {
 return AVCaptureDevice.default(for: .video)
 }
 private var isFlashOn: Bool {
 return device != nil && (device?.flashMode == AVCaptureDevice.FlashMode.on || device?.torchMode == .on)
 }
 private var hasTorch: Bool {
 return device?.hasTorch ?? false
 }
 override func viewDidLoad() {
 super.viewDidLoad()
 UIDevice.current.endGeneratingDeviceOrientationNotifications()
 #if targetEnvironment(simulator)
 view.backgroundColor = .lightGray
 #endif
 previewView = UIView(frame: view.bounds)
 if let previewView = previewView {
 previewView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
 view.addSubview(previewView)
 }
 setupScanRect(view.bounds)
 scanner = MTBBarcodeScanner(previewView: previewView)
 navigationItem.leftBarButtonItem = UIBarButtonItem(title: "cancel",
 style: .plain,
 target: self,
 action: #selector(cancel)
 )
 updateToggleFlashButton()
 }
 override func viewDidAppear(_ animated: Bool) {
 super.viewDidAppear(animated)
 if scanner!.isScanning() {
 scanner!.stopScanning()
 }
 UIDevice.current.endGeneratingDeviceOrientationNotifications()
 scanRect?.startAnimating()
 MTBBarcodeScanner.requestCameraPermission(success: { success in
 if success {
 self.startScan()
 } else {
 #if !targetEnvironment(simulator)
 self.errorResult(errorCode: "PERMISSION_NOT_GRANTED")
 #endif
 }
 })
 }
 override func viewWillDisappear(_ animated: Bool) {
 scanner?.stopScanning()
 scanRect?.stopAnimating()
 UIDevice.current.beginGeneratingDeviceOrientationNotifications()
 if isFlashOn {
 setFlashState(false)
 }
 super.viewWillDisappear(animated)
 }
 override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
 super.viewWillTransition(to: size, with: coordinator)
 setupScanRect(CGRect(origin: CGPoint(x: 0, y:0),
 size: size
 ))
 }
 private func setupScanRect(_ bounds: CGRect) {
 if scanRect != nil {
 scanRect?.stopAnimating()
 scanRect?.removeFromSuperview()
 }
 scanRect = ScannerOverlay(frame: bounds)
 if let scanRect = scanRect {
 scanRect.translatesAutoresizingMaskIntoConstraints = false
 scanRect.backgroundColor = UIColor.clear
 view.addSubview(scanRect)
 scanRect.startAnimating()
 }
 }
 private func startScan() {
 do {
 try scanner!.startScanning(with: cameraFromConfig, resultBlock: { codes in
 if let code = codes?.first {
 let codeType = self.formatMap.first(where: { $0.value == code.type });
 let scanResult = ScanResult.with {
 $0.type = .barcode
 $0.rawContent = code.stringValue ?? ""
 $0.format = codeType?.key ?? .unknown
 $0.formatNote = codeType == nil ? code.type.rawValue : ""
 }
 self.scanner!.stopScanning()
 self.scanResult(scanResult)
 }
 })
 } catch {
 self.scanResult(ScanResult.with {
 $0.type = .error
 $0.rawContent = "\(error)"
 $0.format = .unknown
 })
 }
 }
 @objc private func cancel() {
 scanResult( ScanResult.with {
 $0.type = .cancelled
 $0.format = .unknown
 });
 }
 @objc private func onToggleFlash() {
 setFlashState(!isFlashOn)
 }
 private func updateToggleFlashButton() {
 if !hasTorch {
 return
 }
 let buttonText = isFlashOn ? "flash_off" : "flash_on"
 navigationItem.rightBarButtonItem = UIBarButtonItem(title: buttonText,
 style: .plain,
 target: self,
 action: #selector(onToggleFlash)
 )
 }
 private func setFlashState(_ on: Bool) {
 if let device = device {
 guard device.hasFlash && device.hasTorch else {
 return
 }
 do {
 try device.lockForConfiguration()
 } catch {
 return
 }
 device.flashMode = on ? .on : .off
 device.torchMode = on ? .on : .off
 device.unlockForConfiguration()
 updateToggleFlashButton()
 }
 }
 private func errorResult(errorCode: String){
 delegate?.didFailWithErrorCode(self, errorCode: errorCode)
 dismiss(animated: false)
 }
 private func scanResult(_ scanResult: ScanResult){
 self.delegate?.didScanBarcodeWithResult(self, scanResult: scanResult)
 dismiss(animated: false)
 }
 private var cameraFromConfig: MTBCamera {
 return .back
 }
}

c. 最后需要在example的ios目录Info.plist文件中添加相机权限:

// example/ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>Camera permission is required for barcode scanning.</string>

至此,一个简单的应用于Android、iOS的plugin插件已完成。

4. 需要注意的点

  • 使用Android Studio右键选择Flutter即可通过Android Studio和Xcode打开项目,如图:

  • Android目录打开后,若看不到插件module,可以选择Project Files模式下查看,如图:

  • ios目录打开前,需要进入example目录输入命令 flutter build ios,待编译完成后再通过Xcode打开。

总结

Plugin原生插件其实就是基于Flutter提供的管道进行通信,和原生开发的使用并无太大区别。但需要我们对原生代码的调用有一个基本的了解,然后引入其他原生开发库进行调用。最后附上项目地址:flutter_hms_scan

作者:Fitem

%s 个评论

要回复文章请先登录注册