網(wǎng)站首頁(yè) 編程語(yǔ)言 正文
前沿
從事Flutter開(kāi)發(fā)以來(lái),一直都是使用已有的插件,沒(méi)有自己開(kāi)發(fā)過(guò)。最近同事推薦讓我使用華為的掃碼SDK(hms_scan_kit),正好借此機(jī)會(huì)來(lái)開(kāi)發(fā)一個(gè)Flutter的原生插件。算是對(duì)最近的插件學(xué)習(xí)做一個(gè)簡(jiǎn)單的總結(jié)。
效果圖
我們先看一下實(shí)現(xiàn)的掃碼效果:點(diǎn)擊LoadScanKit按鈕調(diào)起插件的掃碼功能,掃碼成功后在界面顯示掃碼結(jié)果。
相關(guān)知識(shí)點(diǎn)
1. Flutter Packages
通過(guò)使用 package(的模式)可以創(chuàng)建易于共享的模塊化代碼。一個(gè)最基本的 package 由以下內(nèi)容構(gòu)成:
- pubspec.yaml 文件
用于定義 package 名稱(chēng)、版本號(hào)、作者等其他信息的元數(shù)據(jù)文件。- lib 目錄
包含共享代碼的 lib 目錄,其中至少包含一個(gè) <package-name>.dart 文件。
2. Package類(lèi)別
Package包分為二種:
- 純Dart庫(kù)(Dart packages)
- 只用Dart編寫(xiě)的傳統(tǒng)package,比如 path。
- 原生插件(Plugin packages)
- 使用Dart編寫(xiě)的,按需使用Java或 Kotlin、Objective-C或Swift 分別在Android或iOS平臺(tái)實(shí)現(xiàn)的package。
3. 原生插件開(kāi)發(fā)步驟
- 創(chuàng)建package
- 想要?jiǎng)?chuàng)建原生插件 package,請(qǐng)使用帶有 --template=plugin 標(biāo)志的 flutter create 命令
flutter create --org com.example --template=plugin --platforms=android,ios -a kotlin hello
實(shí)現(xiàn)package a. 定義 package API(.dart) b. 添加 Android/iOS 平臺(tái)代碼(.kt/.swift) C. 關(guān)聯(lián) API 和平臺(tái)代碼
指定插件支持的平臺(tái),比如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)直接在創(chuàng)建Flutter項(xiàng)目處選擇Plugin類(lèi)型即可,IDE會(huì)創(chuàng)建插件模板并實(shí)現(xiàn)獲取平臺(tái)系統(tǒng)版本的example,無(wú)需上面的步驟
- Dart對(duì)應(yīng)原生類(lèi)型:
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插件的實(shí)現(xiàn)
前面說(shuō)了這么多,終于進(jìn)入正題,下面我們開(kāi)始HmsScan插件的開(kāi)發(fā)吧。
1. 定義 package API:
class FlutterHmsScan {
// 創(chuàng)建插件
static const MethodChannel _channel = MethodChannel('hms_scan');
// 定義調(diào)用方法
static Future<ScanBean> loadScanKit() async {
return await _channel
.invokeMethod("loadScanKit")
.then((value) => scanBeanFromJson(json.encode(value)));
}
}
2. Android代碼實(shí)現(xiàn):
a. 使用IDE打開(kāi)Android目錄,根據(jù)官方SDK導(dǎo)入庫(kù)
// scankitSDK
implementation 'com.huawei.hms:scanplus:2.4.0.301'
// 需要在repositories中導(dǎo)入url
maven {url 'https://developer.huawei.com/repo/'}
b. 繼承FlutterPlugin類(lèi),接入Flutter管道。由于sdk用到權(quán)限請(qǐng)求和onActivityResult的回調(diào),因此我們需要繼承ActivityAware對(duì)Activity添加監(jiān)聽(tīng)。其中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職責(zé)過(guò)多,這里使用MethodCallHandlerImpl進(jìn)行分離解耦,專(zhuān)門(mén)處理Flutter管道的通信。
/**
* 插件方法監(jiān)聽(tīng)
* 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. 最后通過(guò)ScanLauncher來(lái)專(zhuān)門(mén)處理掃碼功能的相關(guān)實(shí)現(xiàn)
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中添加需要的權(quán)限:
<!--相機(jī)權(quán)限--> <uses-permission android:name="android.permission.CAMERA" /> <!--文件讀取權(quán)限--> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
3. ios部分的實(shí)現(xiàn)
ios原本也是打算使用hms的,但是官方居然2年沒(méi)有更新了,并且不支持bitcode版本、不支持cocopod,demo也無(wú)法正常運(yùn)行。經(jīng)過(guò)一番嘗試后,決定放棄使用該庫(kù),換成了MTBBarcodeScanner庫(kù)。(ios新人一個(gè),如果有精通IOS的同學(xué)們歡迎指教!)
a. 通過(guò)SwiftHmsScanPlugin創(chuàng)建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實(shí)現(xiàn)掃碼功能
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文件中添加相機(jī)權(quán)限:
// example/ios/Runner/Info.plist <key>NSCameraUsageDescription</key> <string>Camera permission is required for barcode scanning.</string>
至此,一個(gè)簡(jiǎn)單的應(yīng)用于Android、iOS的plugin插件已完成。
4. 需要注意的點(diǎn)
- 使用Android Studio右鍵選擇Flutter即可通過(guò)Android Studio和Xcode打開(kāi)項(xiàng)目,如圖:
- Android目錄打開(kāi)后,若看不到插件module,可以選擇Project Files模式下查看,如圖:
- ios目錄打開(kāi)前,需要進(jìn)入example目錄輸入命令 flutter build ios,待編譯完成后再通過(guò)Xcode打開(kāi)。
總結(jié)
Plugin原生插件其實(shí)就是基于Flutter提供的管道進(jìn)行通信,和原生開(kāi)發(fā)的使用并無(wú)太大區(qū)別。但需要我們對(duì)原生代碼的調(diào)用有一個(gè)基本的了解,然后引入其他原生開(kāi)發(fā)庫(kù)進(jìn)行調(diào)用。最后附上項(xiàng)目地址:flutter_hms_scan
原文鏈接:https://juejin.cn/post/7084893279233245220
相關(guān)推薦
- 2023-04-27 Redisson分布式限流的實(shí)現(xiàn)原理解析_Redis
- 2022-06-26 React?Native?加載H5頁(yè)面的實(shí)現(xiàn)方法_React
- 2022-03-14 事務(wù)隔離級(jí)別 在spring實(shí)戰(zhàn)
- 2021-12-06 Flutter多項(xiàng)選擇彈窗實(shí)現(xiàn)詳解_Android
- 2022-03-09 GO使用socket和channel實(shí)現(xiàn)簡(jiǎn)單控制臺(tái)聊天室_Golang
- 2022-06-30 PyTorch詳解經(jīng)典網(wǎng)絡(luò)種含并行連結(jié)的網(wǎng)絡(luò)GoogLeNet實(shí)現(xiàn)流程_python
- 2022-07-26 繞過(guò)正則表達(dá)式例子
- 2022-09-29 ASP.NET?MVC實(shí)現(xiàn)多選下拉框保存并顯示_實(shí)用技巧
- 最近更新
-
- window11 系統(tǒng)安裝 yarn
- 超詳細(xì)win安裝深度學(xué)習(xí)環(huán)境2025年最新版(
- Linux 中運(yùn)行的top命令 怎么退出?
- MySQL 中decimal 的用法? 存儲(chǔ)小
- get 、set 、toString 方法的使
- @Resource和 @Autowired注解
- Java基礎(chǔ)操作-- 運(yùn)算符,流程控制 Flo
- 1. Int 和Integer 的區(qū)別,Jav
- spring @retryable不生效的一種
- Spring Security之認(rèn)證信息的處理
- Spring Security之認(rèn)證過(guò)濾器
- Spring Security概述快速入門(mén)
- Spring Security之配置體系
- 【SpringBoot】SpringCache
- Spring Security之基于方法配置權(quán)
- redisson分布式鎖中waittime的設(shè)
- maven:解決release錯(cuò)誤:Artif
- restTemplate使用總結(jié)
- Spring Security之安全異常處理
- MybatisPlus優(yōu)雅實(shí)現(xiàn)加密?
- Spring ioc容器與Bean的生命周期。
- 【探索SpringCloud】服務(wù)發(fā)現(xiàn)-Nac
- Spring Security之基于HttpR
- Redis 底層數(shù)據(jù)結(jié)構(gòu)-簡(jiǎn)單動(dòng)態(tài)字符串(SD
- arthas操作spring被代理目標(biāo)對(duì)象命令
- Spring中的單例模式應(yīng)用詳解
- 聊聊消息隊(duì)列,發(fā)送消息的4種方式
- bootspring第三方資源配置管理
- GIT同步修改后的遠(yuǎn)程分支