目录
- 前沿
- 效果图
- 相关知识点
- 1. Flutter Packages
- 2. Package类别
- 3. 原生插件开发步骤
- HmsScan插件的实现
- 1. 定义 package API:
- 2. Android代码实现:
- 3. ios部分的实现
- 4. 需要注意的点
- 总结
前沿
从事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对应原生类型:
Dart | kotlin | Swift |
null | null | nil |
bool | Boolean | NSNumber(value: Bool) |
int | Int | NSNumber(value: Int32) |
int | Long | NSNumber(value: Int) |
double | Double | NSNumber(value: Double) |
String | String | String |
Uint8List | ByteArray | FlutterStandardTypedData(bytes: Data) |
Int32List | IntArray | FlutterStandardTypedData(int32: Data) |
Int64List | LongArray | FlutterStandardTypedData(int64: Data) |
Float32List | FloatArray | FlutterStandardTypedData(float32: Data) |
Float64List | DoubleArray | FlutterStandardTypedData(float64: Data) |
List | List | Array |
Map | HashMap | Dictionary |
- 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(FlutterPlugin.FlutterPluginBinding) flutterPluginBinding: { | |
mScanLauncher = ScanLauncher(flutterPluginBinding.applicationContext, null) | |
mHandler = MethodCallHandlerImpl(mScanLauncher) | |
mHandler.startService(flutterPluginBinding.binaryMessenger) | |
} | |
override fun onDetachedFromEngine(FlutterPlugin.FlutterPluginBinding) binding: { | |
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