USB 摄像头适配iPadOS17+ios端的配置

This commit is contained in:
2026-10-22 12:54:08 +08:00
parent 58a23ef218
commit 615d76a8ce
4 changed files with 401 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
//
// UsbCameraAndroidPlugin.swift
// usb_camera_android
//
// Flutter iOS 插件入口:注册 MethodChannel / EventChannel / PlatformView,
// 通道名与 Android 端完全一致,上层 Dart 无需区分平台。
//
import Flutter
import UIKit
public class UsbCameraAndroidPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private static let METHOD_CHANNEL = "com.maibu.usb_camera_android/methods"
private static let EVENT_CHANNEL = "com.maibu.usb_camera_android/events"
private static let VIEW_TYPE = "com.maibu.usb_camera_android/preview"
private let engine = UsbCameraEngine()
public static func register(with registrar: FlutterPluginRegistrar) {
let instance = UsbCameraAndroidPlugin()
let methodChannel = FlutterMethodChannel(
name: METHOD_CHANNEL, binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(instance, channel: methodChannel)
let eventChannel = FlutterEventChannel(
name: EVENT_CHANNEL, binaryMessenger: registrar.messenger())
eventChannel.setStreamHandler(instance)
registrar.register(UsbCameraViewFactory(engine: instance.engine),
withId: VIEW_TYPE)
instance.engine.startMonitoring()
}
// MARK: - FlutterStreamHandler(转发给 engine)
public func onListen(withArguments arguments: Any?,
eventSink events: @escaping FlutterEventSink) -> FlutterError? {
return engine.onListen(withArguments: arguments, eventSink: events)
}
public func onCancel(withArguments arguments: Any?) -> FlutterError? {
return engine.onCancel(withArguments: arguments)
}
// MARK: - MethodCallHandler
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "isSupported":
result(engine.isSupported())
case "getDeviceList":
result(engine.getDeviceList())
case "getSupportedResolutions":
result(engine.getSupportedResolutions())
case "getPreviewSize":
result(engine.getPreviewSize())
case "open":
let args = call.arguments as? [String: Any]
engine.open(deviceId: args?["deviceId"] as? String,
resolution: args?["resolution"] as? String,
result: result)
case "close":
engine.close(result: result)
case "setResolution":
let args = call.arguments as? [String: Any]
engine.setResolution(args?["resolution"] as? String, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
}

View File

@@ -0,0 +1,256 @@
//
// UsbCameraEngine.swift
// usb_camera_android
//
// iOS 端 UVC 外接摄像头引擎(iPadOS 17+)。
// 通过 AVCaptureDevice.DiscoverySession 枚举外接摄像头,
// 用 AVCaptureSession + AVCaptureVideoPreviewLayer 出画面。
// 协议与 Android 端完全对齐:deviceId 使用 device.uniqueID。
//
// 兼容性:整个 Runner / pod 的最低系统版本仍为 iOS 12,
// 因此对 iOS 17 才有的 API(.externalUnknown)一律用 #available 运行时保护,
// iOS 17 以下枚举返回空、isSupported 返回 false,走优雅降级。
//
import AVFoundation
import Flutter
import UIKit
final class UsbCameraEngine: NSObject, FlutterStreamHandler {
/// 预览会话,供 PlatformView 绑定 layer。
private(set) var captureSession: AVCaptureSession?
private(set) var currentDevice: AVCaptureDevice?
private var isRunning = false
private var eventSink: FlutterEventSink?
private let workQueue = DispatchQueue(label: "com.maibu.usb_camera.engine")
// MARK: - 能力探测
/// iPhone 无论系统版本都不支持外接摄像头输入;iPad 需 iPadOS 17+。
func isSupported() -> Bool {
if #available(iOS 17.0, *) {
return UIDevice.current.userInterfaceIdiom != .phone
}
return false
}
// MARK: - 枚举
private func discoverySession() -> AVCaptureDevice.DiscoverySession {
if #available(iOS 17.0, *) {
return AVCaptureDevice.DiscoverySession(
deviceTypes: [.externalUnknown],
mediaType: .video,
position: .unspecified
)
}
return AVCaptureDevice.DiscoverySession(
deviceTypes: [],
mediaType: .video,
position: .unspecified
)
}
/// 返回设备列表,字段与 Android 对齐:deviceId / vendorId / productId / productName。
func getDeviceList() -> [[String: Any]] {
return discoverySession().devices.map { device in
return [
"deviceId": device.uniqueID,
"vendorId": 0,
"productId": 0,
"productName": device.localizedName,
]
}
}
// MARK: - 打开 / 关闭
func open(deviceId: String?, resolution: String?, result: @escaping FlutterResult) {
guard let deviceId = deviceId else {
result(FlutterError(code: "INVALID_ARG", message: "deviceId 为空", details: nil))
return
}
let handle = { [weak self] in
guard let self = self else { return }
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
self.doOpen(deviceId: deviceId, resolution: resolution, result: result)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
if granted {
self.doOpen(deviceId: deviceId, resolution: resolution, result: result)
} else {
result(FlutterError(code: "PERMISSION_DENIED",
message: "用户拒绝了相机权限", details: nil))
}
}
}
default:
result(FlutterError(code: "PERMISSION_DENIED",
message: "相机权限未授权,请在系统设置中开启", details: nil))
}
}
if Thread.isMainThread { handle() } else { DispatchQueue.main.async(execute: handle) }
}
private func doOpen(deviceId: String, resolution: String?, result: @escaping FlutterResult) {
let candidates = discoverySession().devices
guard let device = candidates.first(where: { $0.uniqueID == deviceId }) else {
result(FlutterError(code: "DEVICE_NOT_FOUND",
message: "USB 设备已拔出或未检测到,请重新插入后重试", details: nil))
return
}
stopRunningIfNeeded()
let session = AVCaptureSession()
session.beginConfiguration()
if let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) {
session.addInput(input)
}
if let resolution = resolution {
applyResolution(resolution, to: device)
}
session.commitConfiguration()
self.captureSession = session
self.currentDevice = device
workQueue.async {
session.startRunning()
}
isRunning = true
postEvent(type: "opened", deviceId: deviceId)
result(nil)
}
func close(result: FlutterResult?) {
stopRunningIfNeeded()
captureSession = nil
currentDevice = nil
result?(nil)
}
private func stopRunningIfNeeded() {
if isRunning, let session = captureSession {
workQueue.async {
if session.isRunning { session.stopRunning() }
}
}
isRunning = false
}
// MARK: - 分辨率
func getSupportedResolutions() -> [String] {
guard let device = currentDevice else { return [] }
var seen = Set<String>()
var list: [String] = []
for fmt in device.formats {
let dims = CMFormatDescriptionGetDimensions(fmt.description)
let key = "\(dims.width)x\(dims.height)"
if seen.insert(key).inserted { list.append(key) }
}
return list
}
func getPreviewSize() -> [String: Any]? {
guard isRunning, let device = currentDevice else { return nil }
let dims = CMFormatDescriptionGetDimensions(device.activeFormat.description)
return ["width": Int(dims.width), "height": Int(dims.height)]
}
func setResolution(_ resolution: String?, result: FlutterResult) {
guard let resolution = resolution, let device = currentDevice else {
result(FlutterError(code: "NO_DEVICE", message: "摄像头未打开", details: nil))
return
}
if applyResolution(resolution, to: device) {
postEvent(type: "opened", deviceId: device.uniqueID)
result(nil)
} else {
result(FlutterError(code: "RESOLUTION_UNSUPPORTED",
message: "不支持的分辨率 \(resolution)", details: nil))
}
}
@discardableResult
private func applyResolution(_ resolution: String, to device: AVCaptureDevice) -> Bool {
let parts = resolution.lowercased().split(separator: "x")
guard parts.count == 2, let w = Int(parts[0]), let h = Int(parts[1]) else { return false }
do {
try device.lockForConfiguration()
for fmt in device.formats {
let dims = CMFormatDescriptionGetDimensions(fmt.description)
if Int(dims.width) == w && Int(dims.height) == h {
device.activeFormat = fmt
device.unlockForConfiguration()
return true
}
}
device.unlockForConfiguration()
} catch {
return false
}
return false
}
// MARK: - 插拔监听
func startMonitoring() {
let nc = NotificationCenter.default
nc.addObserver(self, selector: #selector(deviceConnected(_:)),
name: AVCaptureDevice.wasConnectedNotification, object: nil)
nc.addObserver(self, selector: #selector(deviceDisconnected(_:)),
name: AVCaptureDevice.wasDisconnectedNotification, object: nil)
}
@objc private func deviceConnected(_ note: Notification) {
guard let device = note.object as? AVCaptureDevice else { return }
postEvent(type: "attached", deviceId: device.uniqueID)
}
@objc private func deviceDisconnected(_ note: Notification) {
guard let device = note.object as? AVCaptureDevice else { return }
if device.uniqueID == currentDevice?.uniqueID {
stopRunningIfNeeded()
}
postEvent(type: "detached", deviceId: device.uniqueID)
}
// MARK: - FlutterStreamHandler
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
self.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
self.eventSink = nil
return nil
}
private func postEvent(type: String, deviceId: String?) {
let payload: [String: Any] = [
"type": type,
"data": deviceId == nil ? [:] : ["deviceId": deviceId!],
]
DispatchQueue.main.async { [weak self] in
self?.eventSink?(payload)
}
}
func dispose() {
NotificationCenter.default.removeObserver(self)
stopRunningIfNeeded()
captureSession = nil
currentDevice = nil
}
}

View File

@@ -0,0 +1,51 @@
//
// UsbCameraView.swift
// usb_camera_android
//
// iOS 原生预览 PlatformView:用 AVCaptureVideoPreviewLayer 直接渲染 session。
//
import AVFoundation
import Flutter
import UIKit
/// 以 AVCaptureVideoPreviewLayer 作为 backing layer 的 UIView。
final class UsbCameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
}
final class UsbCameraView: NSObject, FlutterPlatformView {
private let previewView: UsbCameraPreviewUIView
init(engine: UsbCameraEngine, frame: CGRect) {
self.previewView = UsbCameraPreviewUIView(frame: frame)
super.init()
previewView.previewLayer.session = engine.captureSession
previewView.previewLayer.videoGravity = .resizeAspect
}
func view() -> UIView {
return previewView
}
}
final class UsbCameraViewFactory: NSObject, FlutterPlatformViewFactory {
private let engine: UsbCameraEngine
init(engine: UsbCameraEngine) {
self.engine = engine
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
return FlutterStandardMessageCodec.sharedInstance()
}
func create(withFrame frame: CGRect, viewIdentifier viewId: Int64,
arguments args: Any?) -> FlutterPlatformView {
return UsbCameraView(engine: engine, frame: frame)
}
}

View File

@@ -0,0 +1,20 @@
Pod::Spec.new do |s|
s.name = 'usb_camera_android'
s.version = '0.1.0'
s.summary = 'USB UVC camera preview plugin (iOS: iPadOS 17+ external cameras).'
s.description = <<-DESC
Local plugin for USB UVC camera preview. iOS side uses AVFoundation external
camera APIs (iPadOS 17+, runtime-guarded). Android side uses UVCAndroid/libuvc.
DESC
s.homepage = 'http://1.95.137.212:59001/IOT/flutterApp'
s.license = { :text => 'Copyright 2026 maibu. All rights reserved.' }
s.author = { 'maibu' => 'dev@maibu.com' }
s.source = { :path => '.' }
s.source_files = 'Classes/**/*'
s.dependency 'Flutter'
s.platform = :ios, '12.0'
s.swift_version = '5.0'
s.static_framework = true
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
end