75 changed files with 5556 additions and 4274 deletions
@ -0,0 +1,9 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/ble_controller.dart'; |
|||
|
|||
class BleBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.put<BleController>(BleController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,281 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; |
|||
import 'package:permission_handler/permission_handler.dart'; |
|||
import 'package:app_settings/app_settings.dart'; |
|||
|
|||
class BleController extends GetxController { |
|||
// 扫描状态 |
|||
final RxBool isScanning = false.obs; |
|||
final RxBool isBluetoothOn = false.obs; |
|||
final RxBool hasPermissions = false.obs; |
|||
|
|||
// 发现的设备列表 |
|||
final RxList<fbp.ScanResult> scanResults = <fbp.ScanResult>[].obs; |
|||
|
|||
// 连接的设备 |
|||
final Rx<fbp.BluetoothDevice?> connectedDevice = |
|||
Rx<fbp.BluetoothDevice?>(null); |
|||
|
|||
// 错误信息 |
|||
final RxString errorMessage = ''.obs; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initBluetooth(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
stopScan(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
// 初始化蓝牙 |
|||
Future<void> _initBluetooth() async { |
|||
try { |
|||
// 先检查权限 |
|||
await _checkPermissions(); |
|||
|
|||
// 检查蓝牙适配器状态 |
|||
fbp.FlutterBluePlus.adapterState.listen((state) { |
|||
isBluetoothOn.value = state == fbp.BluetoothAdapterState.on; |
|||
if (state == fbp.BluetoothAdapterState.on) { |
|||
errorMessage.value = ''; |
|||
} else { |
|||
errorMessage.value = '请开启蓝牙'; |
|||
stopScan(); |
|||
} |
|||
}); |
|||
|
|||
// 监听扫描结果 |
|||
fbp.FlutterBluePlus.scanResults.listen((results) { |
|||
scanResults.value = results; |
|||
}); |
|||
|
|||
// 监听扫描状态 |
|||
fbp.FlutterBluePlus.isScanning.listen((scanning) { |
|||
isScanning.value = scanning; |
|||
}); |
|||
} catch (e) { |
|||
errorMessage.value = '蓝牙初始化失败: $e'; |
|||
} |
|||
} |
|||
|
|||
// 检查权限(不请求) |
|||
Future<void> _checkPermissions() async { |
|||
try { |
|||
Map<Permission, PermissionStatus> statuses = await [ |
|||
Permission.bluetoothScan, |
|||
Permission.bluetoothConnect, |
|||
Permission.location, |
|||
].request(); |
|||
|
|||
hasPermissions.value = |
|||
statuses.values.every((status) => status == PermissionStatus.granted); |
|||
|
|||
if (!hasPermissions.value) { |
|||
errorMessage.value = '需要蓝牙和位置权限才能扫描设备'; |
|||
} |
|||
} catch (e) { |
|||
print('检查权限失败: $e'); |
|||
hasPermissions.value = false; |
|||
} |
|||
} |
|||
|
|||
// 请求权限 |
|||
Future<bool> _requestPermissions() async { |
|||
try { |
|||
Map<Permission, PermissionStatus> statuses = await [ |
|||
Permission.bluetoothScan, |
|||
Permission.bluetoothConnect, |
|||
Permission.location, |
|||
].request(); |
|||
|
|||
bool allGranted = |
|||
statuses.values.every((status) => status == PermissionStatus.granted); |
|||
hasPermissions.value = allGranted; |
|||
|
|||
if (!allGranted) { |
|||
// 检查是否有权限被永久拒绝 |
|||
bool hasPermanentlyDenied = statuses.values |
|||
.any((status) => status == PermissionStatus.permanentlyDenied); |
|||
|
|||
if (hasPermanentlyDenied) { |
|||
_showPermissionDialog(); |
|||
} else { |
|||
errorMessage.value = '需要蓝牙和位置权限才能扫描设备'; |
|||
} |
|||
} |
|||
|
|||
return allGranted; |
|||
} catch (e) { |
|||
print('请求权限失败: $e'); |
|||
errorMessage.value = '权限请求失败: $e'; |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
// 显示权限对话框 |
|||
void _showPermissionDialog() { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
title: Text('需要权限'), |
|||
content: Text('应用需要蓝牙和位置权限才能扫描设备。请在设置中手动开启权限。'), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text('取消'), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
Get.back(); |
|||
AppSettings.openAppSettings(); |
|||
}, |
|||
child: Text('去设置'), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 显示蓝牙开启对话框 |
|||
void _showBluetoothDialog() { |
|||
Get.dialog( |
|||
AlertDialog( |
|||
title: Text('需要开启蓝牙'), |
|||
content: Text('请开启蓝牙以扫描和连接设备。'), |
|||
actions: [ |
|||
TextButton( |
|||
onPressed: () => Get.back(), |
|||
child: Text('取消'), |
|||
), |
|||
TextButton( |
|||
onPressed: () { |
|||
Get.back(); |
|||
// 尝试开启蓝牙 |
|||
fbp.FlutterBluePlus.turnOn(); |
|||
}, |
|||
child: Text('开启蓝牙'), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
// 开始扫描 |
|||
Future<void> startScan() async { |
|||
try { |
|||
errorMessage.value = ''; |
|||
|
|||
// 检查权限 |
|||
if (!hasPermissions.value) { |
|||
if (!await _requestPermissions()) { |
|||
return; |
|||
} |
|||
} |
|||
|
|||
// 检查蓝牙状态 |
|||
if (!isBluetoothOn.value) { |
|||
_showBluetoothDialog(); |
|||
return; |
|||
} |
|||
|
|||
// 清空之前的结果 |
|||
scanResults.clear(); |
|||
|
|||
// 开始扫描 |
|||
await fbp.FlutterBluePlus.startScan( |
|||
timeout: const Duration(seconds: 30), |
|||
androidUsesFineLocation: true, |
|||
); |
|||
} catch (e) { |
|||
errorMessage.value = '扫描失败: $e'; |
|||
} |
|||
} |
|||
|
|||
// 停止扫描 |
|||
Future<void> stopScan() async { |
|||
try { |
|||
await fbp.FlutterBluePlus.stopScan(); |
|||
} catch (e) { |
|||
print('停止扫描失败: $e'); |
|||
} |
|||
} |
|||
|
|||
// 连接设备 |
|||
Future<void> connectToDevice(fbp.BluetoothDevice device) async { |
|||
try { |
|||
errorMessage.value = ''; |
|||
|
|||
// 停止扫描 |
|||
await stopScan(); |
|||
|
|||
// 连接设备 |
|||
await device.connect(timeout: const Duration(seconds: 15)); |
|||
connectedDevice.value = device; |
|||
|
|||
Get.snackbar( |
|||
'连接成功', |
|||
'已连接到 ${device.platformName.isNotEmpty ? device.platformName : '未知设备'}', |
|||
backgroundColor: Colors.green, |
|||
colorText: Colors.white, |
|||
); |
|||
} catch (e) { |
|||
errorMessage.value = '连接失败: $e'; |
|||
Get.snackbar( |
|||
'连接失败', |
|||
e.toString(), |
|||
backgroundColor: Colors.red, |
|||
colorText: Colors.white, |
|||
); |
|||
} |
|||
} |
|||
|
|||
// 断开连接 |
|||
Future<void> disconnectDevice() async { |
|||
try { |
|||
if (connectedDevice.value != null) { |
|||
await connectedDevice.value!.disconnect(); |
|||
connectedDevice.value = null; |
|||
|
|||
Get.snackbar( |
|||
'已断开连接', |
|||
'设备已断开连接', |
|||
backgroundColor: Colors.orange, |
|||
colorText: Colors.white, |
|||
); |
|||
} |
|||
} catch (e) { |
|||
errorMessage.value = '断开连接失败: $e'; |
|||
} |
|||
} |
|||
|
|||
// 获取设备名称 |
|||
String getDeviceName(fbp.ScanResult result) { |
|||
if (result.device.platformName.isNotEmpty) { |
|||
return result.device.platformName; |
|||
} |
|||
if (result.advertisementData.localName.isNotEmpty) { |
|||
return result.advertisementData.localName; |
|||
} |
|||
return '未知设备'; |
|||
} |
|||
|
|||
// 获取信号强度描述 |
|||
String getSignalStrengthText(int rssi) { |
|||
if (rssi >= -50) return '信号强'; |
|||
if (rssi >= -70) return '信号中等'; |
|||
if (rssi >= -90) return '信号弱'; |
|||
return '信号很弱'; |
|||
} |
|||
|
|||
// 获取信号强度图标 |
|||
IconData getSignalStrengthIcon(int rssi) { |
|||
if (rssi >= -50) return Icons.signal_wifi_4_bar; |
|||
if (rssi >= -70) return Icons.signal_wifi_4_bar; |
|||
if (rssi >= -90) return Icons.signal_wifi_4_bar; |
|||
return Icons.signal_wifi_4_bar; |
|||
} |
|||
} |
|||
@ -0,0 +1,355 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import 'package:flutter_blue_plus/flutter_blue_plus.dart' as fbp; |
|||
import '../controllers/ble_controller.dart'; |
|||
|
|||
class BleView extends GetView<BleController> { |
|||
const BleView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
appBar: AppBar( |
|||
title: Text( |
|||
'BLE设备扫描', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 18.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
elevation: 0, |
|||
iconTheme: IconThemeData( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
actions: [ |
|||
Obx(() => IconButton( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: controller.startScan, |
|||
icon: Icon( |
|||
controller.isScanning.value ? Icons.stop : Icons.refresh, |
|||
color: controller.isScanning.value |
|||
? Colors.red |
|||
: (isDarkMode ? Colors.white : Colors.black), |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
body: SafeArea( |
|||
child: Column( |
|||
children: [ |
|||
// 状态栏 |
|||
_buildStatusBar(isDarkMode), |
|||
|
|||
// 连接的设备信息 |
|||
_buildConnectedDevice(isDarkMode), |
|||
|
|||
// 设备列表 |
|||
Expanded( |
|||
child: _buildDeviceList(isDarkMode), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
floatingActionButton: Obx(() => FloatingActionButton( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: controller.startScan, |
|||
backgroundColor: |
|||
controller.isScanning.value ? Colors.red : Colors.blue, |
|||
child: Icon( |
|||
controller.isScanning.value |
|||
? Icons.stop |
|||
: Icons.bluetooth_searching, |
|||
color: Colors.white, |
|||
), |
|||
)), |
|||
); |
|||
} |
|||
|
|||
// 状态栏 |
|||
Widget _buildStatusBar(bool isDarkMode) { |
|||
return Obx(() => Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.all(16.w), |
|||
margin: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
controller.isBluetoothOn.value |
|||
? Icons.bluetooth |
|||
: Icons.bluetooth_disabled, |
|||
color: controller.isBluetoothOn.value |
|||
? Colors.blue |
|||
: Colors.red, |
|||
size: 24.w, |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Text( |
|||
controller.isBluetoothOn.value ? '蓝牙已开启' : '蓝牙已关闭', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
Spacer(), |
|||
if (controller.isScanning.value) |
|||
SizedBox( |
|||
width: 20.w, |
|||
height: 20.w, |
|||
child: CircularProgressIndicator( |
|||
strokeWidth: 2, |
|||
valueColor: AlwaysStoppedAnimation<Color>(Colors.blue), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
if (controller.errorMessage.value.isNotEmpty) ...[ |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
controller.errorMessage.value, |
|||
style: TextStyle( |
|||
color: Colors.red, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
if (controller.isScanning.value) ...[ |
|||
SizedBox(height: 8.h), |
|||
Text( |
|||
'正在扫描设备...', |
|||
style: TextStyle( |
|||
color: Colors.blue, |
|||
fontSize: 14.sp, |
|||
), |
|||
), |
|||
], |
|||
], |
|||
), |
|||
)); |
|||
} |
|||
|
|||
// 已连接设备信息 |
|||
Widget _buildConnectedDevice(bool isDarkMode) { |
|||
return Obx(() { |
|||
if (controller.connectedDevice.value == null) { |
|||
return SizedBox.shrink(); |
|||
} |
|||
|
|||
final device = controller.connectedDevice.value!; |
|||
return Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.all(16.w), |
|||
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
decoration: BoxDecoration( |
|||
color: Colors.green.withOpacity(0.1), |
|||
border: Border.all(color: Colors.green, width: 1), |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
Icon( |
|||
Icons.bluetooth_connected, |
|||
color: Colors.green, |
|||
size: 24.w, |
|||
), |
|||
SizedBox(width: 12.w), |
|||
Expanded( |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'已连接设备', |
|||
style: TextStyle( |
|||
color: Colors.green, |
|||
fontSize: 12.sp, |
|||
fontWeight: FontWeight.w500, |
|||
), |
|||
), |
|||
Text( |
|||
device.platformName.isNotEmpty |
|||
? device.platformName |
|||
: '未知设备', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
Text( |
|||
device.remoteId.toString(), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
IconButton( |
|||
onPressed: controller.disconnectDevice, |
|||
icon: Icon( |
|||
Icons.close, |
|||
color: Colors.red, |
|||
size: 20.w, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
}); |
|||
} |
|||
|
|||
// 设备列表 |
|||
Widget _buildDeviceList(bool isDarkMode) { |
|||
return Obx(() { |
|||
if (controller.scanResults.isEmpty) { |
|||
return Center( |
|||
child: Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Icon( |
|||
Icons.bluetooth_searching, |
|||
size: 64.w, |
|||
color: isDarkMode ? Colors.white54 : Colors.black54, |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Text( |
|||
controller.isScanning.value ? '正在搜索设备...' : '点击扫描按钮开始搜索BLE设备', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white54 : Colors.black54, |
|||
fontSize: 16.sp, |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
return ListView.builder( |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w), |
|||
itemCount: controller.scanResults.length, |
|||
itemBuilder: (context, index) { |
|||
final result = controller.scanResults[index]; |
|||
return _buildDeviceItem(result, isDarkMode); |
|||
}, |
|||
); |
|||
}); |
|||
} |
|||
|
|||
// 设备项 |
|||
Widget _buildDeviceItem(fbp.ScanResult result, bool isDarkMode) { |
|||
final deviceName = controller.getDeviceName(result); |
|||
final isConnected = |
|||
controller.connectedDevice.value?.remoteId == result.device.remoteId; |
|||
|
|||
return Container( |
|||
margin: EdgeInsets.only(bottom: 12.h), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[800] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
border: Border.all( |
|||
color: isConnected |
|||
? Colors.green |
|||
: (isDarkMode ? Colors.grey[700]! : Colors.grey[300]!), |
|||
width: isConnected ? 2 : 1, |
|||
), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 8, |
|||
offset: Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: ListTile( |
|||
contentPadding: EdgeInsets.all(16.w), |
|||
leading: Container( |
|||
width: 48.w, |
|||
height: 48.w, |
|||
decoration: BoxDecoration( |
|||
color: isConnected |
|||
? Colors.green.withOpacity(0.1) |
|||
: Colors.blue.withOpacity(0.1), |
|||
borderRadius: BorderRadius.circular(24.r), |
|||
), |
|||
child: Icon( |
|||
isConnected ? Icons.bluetooth_connected : Icons.bluetooth, |
|||
color: isConnected ? Colors.green : Colors.blue, |
|||
size: 24.w, |
|||
), |
|||
), |
|||
title: Text( |
|||
deviceName, |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
subtitle: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
result.device.remoteId.toString(), |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Row( |
|||
children: [ |
|||
Icon( |
|||
controller.getSignalStrengthIcon(result.rssi), |
|||
size: 16.w, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
SizedBox(width: 4.w), |
|||
Text( |
|||
'${result.rssi} dBm (${controller.getSignalStrengthText(result.rssi)})', |
|||
style: TextStyle( |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
fontSize: 12.sp, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
], |
|||
), |
|||
trailing: isConnected |
|||
? Icon( |
|||
Icons.check_circle, |
|||
color: Colors.green, |
|||
size: 24.w, |
|||
) |
|||
: IconButton( |
|||
onPressed: () => controller.connectToDevice(result.device), |
|||
icon: Icon( |
|||
Icons.connect_without_contact, |
|||
color: Colors.blue, |
|||
size: 24.w, |
|||
), |
|||
), |
|||
onTap: isConnected |
|||
? null |
|||
: () => controller.connectToDevice(result.device), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -1,618 +0,0 @@ |
|||
import Foundation |
|||
import AVFoundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
// 自定义语音处理组件,提供音频流处理等功能 |
|||
import speech |
|||
import os.log |
|||
|
|||
|
|||
/** |
|||
* Azure ASR Helper |
|||
* |
|||
* 基于微软Azure语音服务的ASR实现 |
|||
* 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-recognize-speech |
|||
*/ |
|||
public class AzureAsrHelper: NSObject { |
|||
private let tag = "AzureAsrHelper" |
|||
// 日志对象 |
|||
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureAsrHelper") |
|||
|
|||
// 核心组件 |
|||
private var speechConfig: SPXSpeechConfiguration? |
|||
private var recognizer: SPXSpeechRecognizer? |
|||
private var audioConfig: SPXAudioConfiguration? |
|||
|
|||
// 状态管理 |
|||
private var _isContinuousRecognitionActive = false |
|||
|
|||
// 配置参数 |
|||
private var currentLanguage = "zh-CN" |
|||
private var supportedLanguages = ["zh-CN"] |
|||
private var isAutoDetectLanguage = false |
|||
private var subscriptionKey = "" |
|||
private var region = "" |
|||
|
|||
// 音频源配置 |
|||
public enum AudioSourceType { |
|||
/** 使用设备麦克风 */ |
|||
case microphone |
|||
|
|||
/** 使用外部提供的音频数据 */ |
|||
case external |
|||
} |
|||
|
|||
private var audioSourceType = AudioSourceType.microphone |
|||
|
|||
// 音频处理 |
|||
private var externalAudioStream: ExternalAudioPullStream? |
|||
|
|||
/** |
|||
* 初始化Azure语音服务 |
|||
* |
|||
* @param subscriptionKey Azure 订阅密钥 |
|||
* @param region Azure 区域 |
|||
* @param supportedLanguages 支持的语言数组 |
|||
* @param audioSourceType 音频源类型 |
|||
* @return 初始化是否成功 |
|||
*/ |
|||
public func initialize( |
|||
subscriptionKey: String, |
|||
region: String, |
|||
supportedLanguages: [String] = ["zh-CN"], |
|||
audioSourceType: AudioSourceType = .microphone |
|||
) -> Bool { |
|||
do { |
|||
// 检查配置是否为空 |
|||
if subscriptionKey.isEmpty || region.isEmpty { |
|||
os_log("Azure 配置信息不完整", log: log, type: .error) |
|||
return false |
|||
} |
|||
|
|||
// 释放之前的资源 |
|||
dispose() |
|||
|
|||
// 保存配置 |
|||
self.subscriptionKey = subscriptionKey |
|||
self.region = region |
|||
self.audioSourceType = audioSourceType |
|||
|
|||
// 设置语言 |
|||
if !supportedLanguages.isEmpty { |
|||
self.supportedLanguages = supportedLanguages |
|||
} |
|||
|
|||
// 根据支持的语言数量决定是否启用自动语言检测 |
|||
self.isAutoDetectLanguage = supportedLanguages.count >= 2 |
|||
|
|||
// 如果只有一种语言,设置为当前语言 |
|||
if !isAutoDetectLanguage && !supportedLanguages.isEmpty { |
|||
self.currentLanguage = supportedLanguages[0] |
|||
} |
|||
|
|||
// 创建语音配置 |
|||
speechConfig = try SPXSpeechConfiguration(subscription: subscriptionKey, region: region) |
|||
|
|||
if isAutoDetectLanguage { |
|||
// 直接启用语言检测模式 |
|||
speechConfig?.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") |
|||
os_log("启用语言检测模式", log: log, type: .info) |
|||
} else { |
|||
// 设置指定的识别语言 |
|||
speechConfig?.speechRecognitionLanguage = currentLanguage |
|||
} |
|||
|
|||
// 创建识别器 |
|||
return setupRecognizer() |
|||
} catch { |
|||
os_log("初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 向音频流写入音频数据 |
|||
* 仅当音频源设置为external时有效 |
|||
* |
|||
* @param data 音频数据字节数组 |
|||
*/ |
|||
public func pushAudioData(data: Data) { |
|||
if audioSourceType != .external { |
|||
return |
|||
} |
|||
|
|||
// 使用拉流模式,将数据推入队列 |
|||
externalAudioStream?.pushAudio(data) |
|||
} |
|||
|
|||
/** |
|||
* 开始连续语音识别 |
|||
* |
|||
* @param callback 连续识别结果回调 |
|||
* @param audioSourceType 音频源类型 |
|||
* @return 是否成功开始识别 |
|||
*/ |
|||
public func startContinuousRecognition( |
|||
callback: ContinuousRecognizeCallback, |
|||
audioSourceType: AudioSourceType = .microphone |
|||
) -> Bool { |
|||
guard speechConfig != nil else { |
|||
callback.onError("语音服务未初始化") |
|||
return false |
|||
} |
|||
|
|||
if _isContinuousRecognitionActive { |
|||
return true |
|||
} |
|||
|
|||
self.audioSourceType = audioSourceType |
|||
|
|||
// 重置识别器 |
|||
if !setupRecognizer() { |
|||
callback.onError("重置识别器失败") |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
// 设置各种事件监听 |
|||
setupEventListeners(callback: callback) |
|||
|
|||
// 启动音频处理 |
|||
startAudioProcessing() |
|||
|
|||
// 开始连续识别 |
|||
try recognizer?.startContinuousRecognition() |
|||
_isContinuousRecognitionActive = true |
|||
|
|||
os_log("连续识别已启动,音频源: %{public}@", log: log, type: .info, audioSourceType == .microphone ? "麦克风" : "外部") |
|||
|
|||
return true |
|||
} catch { |
|||
stopAudioProcessing() |
|||
_isContinuousRecognitionActive = false |
|||
callback.onError("启动连续识别失败: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止连续语音识别 |
|||
* |
|||
* @return 是否成功停止 |
|||
*/ |
|||
public func stopContinuousRecognition() -> Bool { |
|||
guard speechConfig != nil else { |
|||
os_log("语音服务未初始化", log: log, type: .error) |
|||
return false |
|||
} |
|||
|
|||
if !_isContinuousRecognitionActive { |
|||
return true |
|||
} |
|||
|
|||
do { |
|||
guard let recognizer = recognizer else { |
|||
os_log("识别器为空,重置状态", log: log, type: .info) |
|||
_isContinuousRecognitionActive = false |
|||
return true |
|||
} |
|||
|
|||
if audioSourceType == .external { |
|||
pushAudioData(data: Data()) |
|||
} |
|||
|
|||
// 停止连续识别 |
|||
try recognizer.stopContinuousRecognition() |
|||
|
|||
// 停止音频处理 |
|||
stopAudioProcessing() |
|||
|
|||
// 会话结束事件会设置_isContinuousRecognitionActive = false |
|||
return true |
|||
} catch { |
|||
// 强制重置状态 |
|||
_isContinuousRecognitionActive = false |
|||
os_log("停止连续识别失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
|
|||
// 停止音频处理 |
|||
stopAudioProcessing() |
|||
|
|||
// 尝试强制关闭识别器 |
|||
recognizer = nil |
|||
|
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查连续识别是否活跃 |
|||
*/ |
|||
public func isContinuousRecognitionActive() -> Bool { |
|||
return self._isContinuousRecognitionActive |
|||
} |
|||
|
|||
/** |
|||
* 释放所有资源 |
|||
*/ |
|||
public func dispose() { |
|||
do { |
|||
// 如果正在进行连续识别,先停止 |
|||
if _isContinuousRecognitionActive { |
|||
// 直接停止,不等待结果 |
|||
try? recognizer?.stopContinuousRecognition() |
|||
_isContinuousRecognitionActive = false |
|||
} |
|||
|
|||
// 停止音频处理 |
|||
stopAudioProcessing() |
|||
|
|||
// 释放资源 |
|||
recognizer = nil |
|||
speechConfig = nil |
|||
audioConfig = nil |
|||
|
|||
// 确保状态被重置 |
|||
_isContinuousRecognitionActive = false |
|||
externalAudioStream = nil |
|||
} catch { |
|||
// 确保状态被重置 |
|||
_isContinuousRecognitionActive = false |
|||
externalAudioStream = nil |
|||
audioConfig = nil |
|||
recognizer = nil |
|||
speechConfig = nil |
|||
} |
|||
} |
|||
|
|||
// MARK: - 私有方法 |
|||
|
|||
/** |
|||
* 设置识别器 |
|||
*/ |
|||
private func setupRecognizer() -> Bool { |
|||
do { |
|||
// 清理旧的识别器 |
|||
recognizer = nil |
|||
|
|||
// 设置音频配置 |
|||
switch audioSourceType { |
|||
case .microphone: |
|||
// 使用默认麦克风输入配置 |
|||
audioConfig = try SPXAudioConfiguration() |
|||
case .external: |
|||
// 使用拉流方式处理外部音频 |
|||
setupExternalAudioStream() |
|||
} |
|||
|
|||
// 创建识别器 |
|||
if isAutoDetectLanguage { |
|||
let autoDetectConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) |
|||
recognizer = try SPXSpeechRecognizer( |
|||
speechConfiguration: speechConfig!, |
|||
autoDetectSourceLanguageConfiguration: autoDetectConfig, |
|||
audioConfiguration: audioConfig! |
|||
) |
|||
} else { |
|||
recognizer = try SPXSpeechRecognizer( |
|||
speechConfiguration: speechConfig!, |
|||
audioConfiguration: audioConfig! |
|||
) |
|||
} |
|||
|
|||
return true |
|||
} catch { |
|||
os_log("创建识别器失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
stopAudioProcessing() |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置外部音频流 - 使用拉流方式 |
|||
*/ |
|||
private func setupExternalAudioStream() { |
|||
do { |
|||
// 创建外部音频拉流对象 |
|||
externalAudioStream = ExternalAudioPullStream() |
|||
|
|||
// 创建音频配置 |
|||
audioConfig = SPXAudioConfiguration(streamInput: externalAudioStream!.pullStream) |
|||
} catch { |
|||
os_log("设置外部音频流失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
externalAudioStream = nil |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置事件监听器 |
|||
*/ |
|||
private func setupEventListeners(callback: ContinuousRecognizeCallback) { |
|||
guard let recognizer = recognizer else { return } |
|||
|
|||
// 识别中事件 |
|||
recognizer.addRecognizingEventHandler { [weak self] (sender, event) in |
|||
guard let self = self else { return } |
|||
|
|||
let result = event.result |
|||
let detectedLanguage = self.getDetectedLanguage(from: result) |
|||
// os_log("识别中: %{public}@", log: self.log, type: .info, result.text ?? "") |
|||
// 直接在当前线程调用回调 |
|||
callback.onRecognizing(result.text ?? "", detectedLanguage) |
|||
} |
|||
|
|||
// 识别完成事件 |
|||
recognizer.addRecognizedEventHandler { [weak self] (sender, event) in |
|||
guard let self = self else { return } |
|||
|
|||
let result = event.result |
|||
if result.reason == SPXResultReason.recognizedSpeech { |
|||
let detectedLanguage = self.getDetectedLanguage(from: result) |
|||
// 直接在当前线程调用回调 |
|||
callback.onResult(result.text ?? "", detectedLanguage) |
|||
} |
|||
} |
|||
|
|||
// 会话开始事件 |
|||
recognizer.addSessionStartedEventHandler { (sender, event) in |
|||
// 直接在当前线程调用回调 |
|||
callback.onSessionStarted() |
|||
} |
|||
|
|||
// 会话结束事件 |
|||
recognizer.addSessionStoppedEventHandler { [weak self] (sender, event) in |
|||
guard let self = self else { return } |
|||
|
|||
// 直接在当前线程调用回调 |
|||
callback.onSessionStopped() |
|||
self._isContinuousRecognitionActive = false |
|||
self.stopAudioProcessing() |
|||
} |
|||
|
|||
// 取消事件 |
|||
recognizer.addCanceledEventHandler { [weak self] (sender, event) in |
|||
guard let self = self else { return } |
|||
|
|||
let errorDetails = event.errorDetails ?? "未知错误" |
|||
let reason = String(describing: event.reason.rawValue) |
|||
|
|||
os_log("识别取消: %{public}@", log: self.log, type: .error, errorDetails) |
|||
|
|||
callback.onCanceled(reason, errorDetails) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 获取检测到的语言 |
|||
*/ |
|||
private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { |
|||
if !isAutoDetectLanguage { |
|||
return "" |
|||
} |
|||
|
|||
// 尝试从属性中获取语言 |
|||
if let properties = result.properties, |
|||
let language = properties.getPropertyByName("SpeechServiceResponse_RecognitionLanguage") { |
|||
return language |
|||
} |
|||
|
|||
// 尝试另一种方式获取语言 |
|||
do { |
|||
let langResult = try SPXAutoDetectSourceLanguageResult(result) |
|||
return langResult.language ?? "" |
|||
} catch { |
|||
return "" |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 启动音频处理 |
|||
*/ |
|||
private func startAudioProcessing() { |
|||
switch audioSourceType { |
|||
case .microphone: |
|||
// 拉流模式不需要额外启动,SDK会自动拉取数据 |
|||
break |
|||
case .external: |
|||
// 外部音频数据模式下不需要启动处理,等待外部调用pushAudioData |
|||
break |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止音频处理 |
|||
*/ |
|||
private func stopAudioProcessing() { |
|||
if let stream = externalAudioStream { |
|||
stream.close() |
|||
externalAudioStream = nil |
|||
// os_log("外部音频流已关闭", log: log, type: .info) |
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 外部音频拉流 |
|||
* 实现PullAudioInputStreamCallback,将外部推送的音频数据转换为SDK可拉取的形式 |
|||
*/ |
|||
private class ExternalAudioPullStream: NSObject { |
|||
private(set) var pullStream: SPXPullAudioInputStream! |
|||
private let queue = LinkedBlockingQueue<Data>() |
|||
private var closed = false |
|||
|
|||
override init() { |
|||
super.init() |
|||
|
|||
pullStream = SPXPullAudioInputStream( |
|||
readHandler: { [weak self] (data: NSMutableData, size: UInt) -> Int in |
|||
guard let self = self else { return 0 } |
|||
return self.read(buffer: data, size: Int(size)) |
|||
}, |
|||
closeHandler: { [weak self] in |
|||
self?.close() |
|||
} |
|||
) |
|||
} |
|||
|
|||
/** |
|||
* 外部调用:推送音频数据到队列 |
|||
* @param data 音频数据 |
|||
*/ |
|||
func pushAudio(_ data: Data) { |
|||
if !closed { |
|||
queue.put(data) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* SDK调用:从队列中拉取数据 |
|||
* @param buffer SDK提供的缓冲区 |
|||
* @param size 缓冲区大小 |
|||
* @return 读取的字节数,0表示流结束 |
|||
*/ |
|||
private func read(buffer: NSMutableData, size: Int) -> Int { |
|||
// 阻塞等待下一块数据 |
|||
guard let chunk = queue.take() else { |
|||
return 0 // 队列已关闭 |
|||
} |
|||
|
|||
// 如果是空数据,表示流结束 |
|||
if chunk.isEmpty { |
|||
return 0 |
|||
} |
|||
|
|||
let toCopy = min(chunk.count, size) |
|||
buffer.append(chunk.prefix(toCopy)) |
|||
return toCopy |
|||
} |
|||
|
|||
/** |
|||
* SDK调用:关闭流 |
|||
*/ |
|||
func close() { |
|||
closed = true |
|||
queue.close() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* iOS版LinkedBlockingQueue实现 |
|||
* 与Android LinkedBlockingQueue保持一致的API和行为 |
|||
*/ |
|||
private class LinkedBlockingQueue<T> { |
|||
private var queue: [T] = [] |
|||
private var isClosed = false |
|||
|
|||
// 使用DispatchSemaphore实现阻塞行为 |
|||
private let availableItems: DispatchSemaphore |
|||
private let queueLock = NSLock() |
|||
|
|||
init() { |
|||
self.availableItems = DispatchSemaphore(value: 0) |
|||
} |
|||
|
|||
/** |
|||
* 阻塞式获取元素(等价于Android的take()) |
|||
* @return 队列中的元素,如果队列已关闭则返回nil |
|||
*/ |
|||
func take() -> T? { |
|||
// 等待可用元素(阻塞直到有元素或队列关闭) |
|||
availableItems.wait() |
|||
|
|||
queueLock.lock() |
|||
defer { queueLock.unlock() } |
|||
|
|||
// 检查队列是否已关闭 |
|||
if isClosed && queue.isEmpty { |
|||
return nil |
|||
} |
|||
|
|||
// 获取第一个元素 |
|||
guard !queue.isEmpty else { |
|||
return nil |
|||
} |
|||
|
|||
return queue.removeFirst() |
|||
} |
|||
|
|||
/** |
|||
* 添加元素(等价于Android的put()) |
|||
* @param item 要添加的元素 |
|||
*/ |
|||
func put(_ item: T) { |
|||
queueLock.lock() |
|||
defer { queueLock.unlock() } |
|||
|
|||
// 检查队列是否已关闭 |
|||
if isClosed { |
|||
return |
|||
} |
|||
|
|||
// 添加元素(无容量限制,与Android一致) |
|||
queue.append(item) |
|||
|
|||
// 通知有新元素可用 |
|||
availableItems.signal() |
|||
} |
|||
|
|||
/** |
|||
* 关闭队列 |
|||
* 关闭后不能再添加新元素,但可以继续取出已有元素 |
|||
*/ |
|||
func close() { |
|||
queueLock.lock() |
|||
defer { queueLock.unlock() } |
|||
|
|||
isClosed = true |
|||
|
|||
// 唤醒所有等待的take()操作 |
|||
for _ in 0..<100 { // 假设最多100个等待者 |
|||
availableItems.signal() |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 连续识别回调接口 |
|||
*/ |
|||
public protocol ContinuousRecognizeCallback { |
|||
/** |
|||
* 返回识别结果 |
|||
* |
|||
* @param text 识别的文本 |
|||
* @param detectedLanguage 检测到的语言 |
|||
*/ |
|||
func onResult(_ text: String, _ detectedLanguage: String) |
|||
|
|||
/** |
|||
* 识别进行中调用 |
|||
* |
|||
* @param recognizing 正在识别的文本 |
|||
* @param detectedLanguage 检测到的语言 |
|||
*/ |
|||
func onRecognizing(_ recognizing: String, _ detectedLanguage: String) |
|||
|
|||
/** |
|||
* 会话开始时调用 |
|||
*/ |
|||
func onSessionStarted() |
|||
|
|||
/** |
|||
* 会话结束时调用 |
|||
*/ |
|||
func onSessionStopped() |
|||
|
|||
/** |
|||
* 识别取消时调用 |
|||
* |
|||
* @param reason 取消原因 |
|||
* @param errorDetails 错误详情 |
|||
*/ |
|||
func onCanceled(_ reason: String, _ errorDetails: String) |
|||
|
|||
/** |
|||
* 识别出错时调用 |
|||
* |
|||
* @param error 错误信息 |
|||
*/ |
|||
func onError(_ error: String) |
|||
} |
|||
} |
|||
@ -1,17 +0,0 @@ |
|||
//
|
|||
// AzureSpeechPlugin.h
|
|||
// azure_speech
|
|||
//
|
|||
// Created for azure_speech plugin compatibility.
|
|||
//
|
|||
|
|||
#import <Flutter/Flutter.h> |
|||
|
|||
/**
|
|||
* Azure Speech Plugin 头文件 |
|||
* |
|||
* 注:实际实现使用 Swift,此头文件仅用于 Flutter 框架兼容 |
|||
*/ |
|||
@interface AzureSpeechPlugin : NSObject <FlutterPlugin> |
|||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar; |
|||
@end |
|||
@ -1,422 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import AVFoundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
// 自定义语音处理组件,提供音频流处理等功能 |
|||
import speech |
|||
import os.log |
|||
|
|||
/** |
|||
* Azure Speech Plugin |
|||
* |
|||
* 基于微软Azure语音服务的Flutter插件 |
|||
* 提供语音识别(ASR)和语音合成(TTS)功能 |
|||
*/ |
|||
@objc public class AzureSpeechPlugin: NSObject, FlutterPlugin { |
|||
// 日志标签 |
|||
private let tag = "AzureSpeechPlugin" |
|||
// 日志对象 |
|||
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureSpeechPlugin") |
|||
|
|||
// ASR相关 |
|||
private var asrChannel: FlutterMethodChannel? |
|||
private var asrEventChannel: FlutterEventChannel? |
|||
private var asrEventSink: FlutterEventSink? |
|||
private let azureAsrHelper = AzureAsrHelper() |
|||
|
|||
// TTS相关 |
|||
private var ttsChannel: FlutterMethodChannel? |
|||
private var ttsEventChannel: FlutterEventChannel? |
|||
private var ttsEventSink: FlutterEventSink? |
|||
private let azureTtsHelper = AzureTtsHelper() |
|||
|
|||
// 是否已添加TTS事件监听器 |
|||
private var isTtsListenerAdded = false |
|||
|
|||
// 当前的连续识别回调 |
|||
private var currentAsrCallback: AsrCallbackWrapper? |
|||
|
|||
// 插件注册 |
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let instance = AzureSpeechPlugin() |
|||
|
|||
// 初始化ASR通道 |
|||
let asrChannel = FlutterMethodChannel(name: "azure_speech/asr", binaryMessenger: registrar.messenger()) |
|||
registrar.addMethodCallDelegate(instance, channel: asrChannel) |
|||
instance.asrChannel = asrChannel |
|||
|
|||
// 初始化TTS通道 |
|||
let ttsChannel = FlutterMethodChannel(name: "azure_speech/tts", binaryMessenger: registrar.messenger()) |
|||
registrar.addMethodCallDelegate(instance, channel: ttsChannel) |
|||
instance.ttsChannel = ttsChannel |
|||
|
|||
// 初始化ASR事件通道 |
|||
let asrEventChannel = FlutterEventChannel(name: "azure_speech/asr_events", binaryMessenger: registrar.messenger()) |
|||
asrEventChannel.setStreamHandler(instance) |
|||
instance.asrEventChannel = asrEventChannel |
|||
|
|||
// 初始化TTS事件通道 |
|||
let ttsEventChannel = FlutterEventChannel(name: "azure_speech/tts_events", binaryMessenger: registrar.messenger()) |
|||
ttsEventChannel.setStreamHandler(instance) |
|||
instance.ttsEventChannel = ttsEventChannel |
|||
} |
|||
|
|||
// 发送ASR事件方法 |
|||
internal func sendAsrEvent(_ event: [String: Any]) { |
|||
if asrEventSink == nil { |
|||
os_log("无法发送ASR事件:事件通道未准备好", log: log, type: .error) |
|||
return |
|||
} |
|||
|
|||
DispatchQueue.main.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.asrEventSink?(event) |
|||
} |
|||
} |
|||
|
|||
// 发送TTS事件方法 |
|||
private func sendTtsEvent(_ event: [String: Any]) { |
|||
if ttsEventSink == nil { |
|||
os_log("无法发送TTS事件:事件通道未准备好", log: log, type: .error) |
|||
return |
|||
} |
|||
|
|||
DispatchQueue.main.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.ttsEventSink?(event) |
|||
} |
|||
} |
|||
|
|||
// 设置TTS事件监听器 |
|||
private func setupTtsEventListener() { |
|||
if !isTtsListenerAdded { |
|||
azureTtsHelper.addListener(self) |
|||
isTtsListenerAdded = true |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
// 处理Flutter方法调用 |
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
if call.method.hasPrefix("tts_") { |
|||
handleTtsMethodCall(call, result) |
|||
} else { |
|||
handleAsrMethodCall(call, result) |
|||
} |
|||
} |
|||
|
|||
// MARK: - ASR 方法处理 |
|||
|
|||
private func handleAsrMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "initialize": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let subscriptionKey = args["subscriptionKey"] as? String, |
|||
let region = args["region"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let supportedLanguages = args["supportedLanguages"] as? [String] ?? ["zh-CN"] |
|||
|
|||
// 初始化ASR引擎 |
|||
let success = azureAsrHelper.initialize( |
|||
subscriptionKey: subscriptionKey, |
|||
region: region, |
|||
supportedLanguages: supportedLanguages, |
|||
audioSourceType: .microphone |
|||
) |
|||
|
|||
result(success) |
|||
|
|||
case "recognizeOnce": |
|||
// iOS版本不支持recognizeOnce |
|||
result(FlutterError(code: "NOT_SUPPORTED", message: "iOS版本不支持recognizeOnce", details: nil)) |
|||
|
|||
case "startContinuousRecognition": |
|||
// 确保事件通道已准备好 |
|||
guard asrEventSink != nil else { |
|||
result(FlutterError(code: "EVENT_CHANNEL_NOT_READY", message: "事件通道未准备好,无法开始连续识别", details: nil)) |
|||
return |
|||
} |
|||
|
|||
// 解析音频源类型 |
|||
let audioSourceType: AzureAsrHelper.AudioSourceType |
|||
if let args = call.arguments as? [String: Any], |
|||
let audioSourceString = args["audioSourceType"] as? String { |
|||
switch audioSourceString.lowercased() { |
|||
case "external": |
|||
audioSourceType = .external |
|||
default: |
|||
audioSourceType = .microphone |
|||
} |
|||
} else { |
|||
audioSourceType = .microphone |
|||
} |
|||
|
|||
// 创建回调包装器 |
|||
currentAsrCallback = AsrCallbackWrapper(plugin: self) |
|||
|
|||
let success = azureAsrHelper.startContinuousRecognition( |
|||
callback: currentAsrCallback!, |
|||
audioSourceType: audioSourceType |
|||
) |
|||
result(success) |
|||
|
|||
case "stopContinuousRecognition": |
|||
let success = azureAsrHelper.stopContinuousRecognition() |
|||
currentAsrCallback = nil |
|||
result(success) |
|||
|
|||
case "isContinuousRecognitionActive": |
|||
result(azureAsrHelper.isContinuousRecognitionActive()) |
|||
|
|||
case "dispose": |
|||
azureAsrHelper.dispose() |
|||
currentAsrCallback = nil |
|||
result(true) |
|||
|
|||
case "pushAudioData": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let audioBytes = args["data"] as? FlutterStandardTypedData else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "音频数据不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
azureAsrHelper.pushAudioData(data: audioBytes.data) |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
// MARK: - TTS 方法处理 |
|||
|
|||
private func handleTtsMethodCall(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "tts_initialize": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let subscriptionKey = args["subscriptionKey"] as? String, |
|||
let region = args["region"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "必要的参数不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
// 初始化TTS引擎 |
|||
let language = args["language"] as? String ?? "zh-CN" |
|||
let success = azureTtsHelper.initialize(ttsAppId: "", ttsAppToken: subscriptionKey, ttsResource: region, language: language) |
|||
|
|||
// 设置TTS事件监听器 |
|||
setupTtsEventListener() |
|||
|
|||
result(success) |
|||
|
|||
case "tts_set_voice": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let voiceName = args["voiceName"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "语音名称不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = azureTtsHelper.setVoice(voiceName) |
|||
result(success) |
|||
|
|||
case "tts_speak_once": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = azureTtsHelper.speakOnce(text) |
|||
result(success) |
|||
|
|||
case "tts_speak_stream": |
|||
guard let args = call.arguments as? [String: Any], |
|||
let text = args["text"] as? String else { |
|||
result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) |
|||
return |
|||
} |
|||
|
|||
let success = azureTtsHelper.speakStream(text) |
|||
result(success) |
|||
|
|||
case "tts_flush_stream": |
|||
let success = azureTtsHelper.flushStream() |
|||
result(success) |
|||
|
|||
case "tts_stop": |
|||
let success = azureTtsHelper.stop() |
|||
result(success) |
|||
|
|||
case "tts_isSpeaking": |
|||
result(azureTtsHelper.isSpeaking()) |
|||
|
|||
case "tts_release": |
|||
azureTtsHelper.dispose() |
|||
result(true) |
|||
|
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// MARK: - ASR回调包装器 |
|||
private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { |
|||
private weak var plugin: AzureSpeechPlugin? |
|||
|
|||
init(plugin: AzureSpeechPlugin) { |
|||
self.plugin = plugin |
|||
} |
|||
|
|||
func onResult(_ text: String, _ detectedLanguage: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "result", |
|||
"text": text, |
|||
"language": detectedLanguage |
|||
]) |
|||
} |
|||
|
|||
func onRecognizing(_ recognizing: String, _ detectedLanguage: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "recognizing", |
|||
"text": recognizing, |
|||
"language": detectedLanguage |
|||
]) |
|||
} |
|||
|
|||
func onSessionStarted() { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "sessionStarted" |
|||
]) |
|||
} |
|||
|
|||
func onSessionStopped() { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "sessionStopped" |
|||
]) |
|||
} |
|||
|
|||
func onCanceled(_ reason: String, _ errorDetails: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "canceled", |
|||
"reason": reason, |
|||
"errorDetails": errorDetails |
|||
]) |
|||
} |
|||
|
|||
func onError(_ error: String) { |
|||
plugin?.sendAsrEvent([ |
|||
"type": "error", |
|||
"error": error |
|||
]) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 事件处理 |
|||
extension AzureSpeechPlugin: FlutterStreamHandler { |
|||
public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { |
|||
// 根据通道类型设置事件接收器 |
|||
if let args = arguments as? [String: Any], |
|||
let channel = args["channel"] as? String { |
|||
|
|||
if channel == "asr" { |
|||
asrEventSink = events |
|||
} else if channel == "tts" { |
|||
ttsEventSink = events |
|||
setupTtsEventListener() |
|||
} |
|||
} else if arguments == nil { |
|||
// 如果没有指定通道,尝试弄清楚是哪个通道在监听 |
|||
if asrEventSink == nil && ttsEventSink != nil { |
|||
asrEventSink = events |
|||
} else if asrEventSink != nil && ttsEventSink == nil { |
|||
ttsEventSink = events |
|||
setupTtsEventListener() |
|||
} else { |
|||
// 无法确定哪个通道,默认设置为ASR |
|||
asrEventSink = events |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
public func onCancel(withArguments arguments: Any?) -> FlutterError? { |
|||
// 根据通道类型清除事件接收器 |
|||
if let args = arguments as? [String: Any], |
|||
let channel = args["channel"] as? String { |
|||
|
|||
if channel == "asr" { |
|||
asrEventSink = nil |
|||
} else if channel == "tts" { |
|||
ttsEventSink = nil |
|||
} |
|||
} else { |
|||
// 如果没有指定通道,清除所有通道 |
|||
asrEventSink = nil |
|||
ttsEventSink = nil |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
} |
|||
|
|||
|
|||
|
|||
// MARK: - TTS 事件监听实现 |
|||
extension AzureSpeechPlugin: TtsEventListener { |
|||
// 使用@objc特性为方法提供一个不同的Objective-C选择器名称 |
|||
@objc(onTtsEvent:) |
|||
public func onEvent(_ event: TtsEvent) { |
|||
var eventMap: [String: Any] = [:] |
|||
|
|||
// 根据事件类型转换 |
|||
switch event.type { |
|||
case .synthesisStarted: |
|||
eventMap["type"] = "synthesis_started" |
|||
case .synthesisCompleted: |
|||
eventMap["type"] = "synthesis_completed" |
|||
case .synthesisCanceled: |
|||
eventMap["type"] = "synthesis_canceled" |
|||
if let reason = event.params["reason"] { |
|||
eventMap["reason"] = reason |
|||
} |
|||
if let errorDetails = event.params["errorDetails"] { |
|||
eventMap["errorDetails"] = errorDetails |
|||
} |
|||
case .error: |
|||
eventMap["type"] = "error" |
|||
if let errorCode = event.params["errorCode"] { |
|||
eventMap["errorCode"] = errorCode |
|||
} |
|||
if let errorMessage = event.params["errorMessage"] { |
|||
eventMap["errorMessage"] = errorMessage |
|||
} |
|||
@unknown default: |
|||
eventMap["type"] = "unknown" |
|||
for (key, value) in event.params { |
|||
eventMap[key] = value |
|||
} |
|||
} |
|||
|
|||
sendTtsEvent(eventMap) |
|||
} |
|||
} |
|||
|
|||
// MARK: - 音频数据监听实现 |
|||
extension AzureSpeechPlugin: AudioDataListener { |
|||
public func onAudioData(_ data: Data) { |
|||
if ttsEventSink == nil { return } |
|||
|
|||
let eventMap: [String: Any] = [ |
|||
"type": "audio_data", |
|||
"data": FlutterStandardTypedData(bytes: data) |
|||
] |
|||
|
|||
sendTtsEvent(eventMap) |
|||
} |
|||
} |
|||
@ -1,604 +0,0 @@ |
|||
import Foundation |
|||
import AVFoundation |
|||
import MicrosoftCognitiveServicesSpeech |
|||
// 自定义语音处理组件,提供音频流处理等功能 |
|||
import speech |
|||
import os.log |
|||
|
|||
/** |
|||
* Azure TTS Helper |
|||
* |
|||
* 基于微软Azure语音服务的TTS实现 |
|||
* 参考文档: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/how-to-speech-synthesis |
|||
* |
|||
* 特性: |
|||
* - 对外接口保持同步,内部异步处理 |
|||
* - 使用专用队列保证合成顺序 |
|||
* - 避免阻塞主线程和其他后台线程 |
|||
* - 事件回调由调用方处理线程切换 |
|||
* |
|||
* 使用示例: |
|||
* let success = ttsHelper.speakOnce("Hello World") // 立即返回,后台异步处理 |
|||
*/ |
|||
public class AzureTtsHelper: NSObject, ITtsService { |
|||
private let tag = "AzureTtsHelper" |
|||
// 日志对象 |
|||
private let log = OSLog(subsystem: "com.azure.speech", category: "AzureTtsHelper") |
|||
|
|||
private static let DEFAULT_LANGUAGE = "zh-CN" |
|||
private static let DEFAULT_VOICE = "zh-CN-XiaoxiaoNeural" |
|||
|
|||
// Azure语音服务配置 |
|||
private var speechConfig: SPXSpeechConfiguration? |
|||
private var synthesizer: SPXSpeechSynthesizer? |
|||
private var isInitialized = false |
|||
private var speaking = false |
|||
|
|||
// 当前配置 |
|||
private var currentVoice = DEFAULT_VOICE |
|||
private var currentLanguage = DEFAULT_LANGUAGE |
|||
private var currentRate = "+0%" |
|||
private var currentPitch = "+0%" |
|||
private var currentVolume = "100%" |
|||
|
|||
// 事件监听器列表 |
|||
private var eventListeners = NSHashTable<AnyObject>.weakObjects() |
|||
|
|||
// 流式文本处理的缓冲区 |
|||
private var streamBuffer = "" |
|||
private var lastSpeakTime: TimeInterval = 0 |
|||
|
|||
// 内部异步处理队列,保证顺序执行 |
|||
private let synthesisQueue = DispatchQueue(label: "com.azure.tts.synthesis", qos: .userInitiated) |
|||
private let synthesisGroup = DispatchGroup() |
|||
private var pendingTasks: [() -> Void] = [] |
|||
private let taskLock = NSLock() |
|||
|
|||
/** |
|||
* 初始化语音合成服务 |
|||
* |
|||
* @param appId 服务应用ID |
|||
* @param token 服务访问令牌/订阅密钥 |
|||
* @param resource 服务资源ID/区域(可选) |
|||
* @param language 语言代码,如"zh-CN" |
|||
* @return 是否初始化成功 |
|||
*/ |
|||
public func initialize(ttsAppId: String, ttsAppToken: String, ttsResource: String, language: String) -> Bool { |
|||
do { |
|||
// 创建语音配置 |
|||
if ttsResource.isEmpty { |
|||
speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: "eastasia") |
|||
} else { |
|||
speechConfig = try SPXSpeechConfiguration(subscription: ttsAppToken, region: ttsResource) |
|||
} |
|||
|
|||
// 设置语言 |
|||
speechConfig?.speechSynthesisLanguage = language |
|||
currentLanguage = language |
|||
|
|||
// 设置音频输出格式 - 使用16k、16位的PCM格式 |
|||
speechConfig?.setPropertyTo("Audio16Khz16BitMonoPcm", |
|||
byName: "SpeechServiceConnection_SynthOutputFormat") |
|||
|
|||
// 创建合成器,使用默认音频输出(扬声器) |
|||
synthesizer = try SPXSpeechSynthesizer(speechConfig!) |
|||
|
|||
// 设置事件监听 |
|||
setupEventListeners() |
|||
|
|||
// 设置默认音色 - 中文默认使用晓晓,英文默认使用Jenny |
|||
if language.lowercased().starts(with: "zh") { |
|||
_ = setVoice("zh-CN-XiaoxiaoNeural") |
|||
} else { |
|||
_ = setVoice("en-US-JennyNeural") |
|||
} |
|||
|
|||
// 设置初始化完成 |
|||
isInitialized = true |
|||
|
|||
return true |
|||
} catch { |
|||
os_log("语音合成服务初始化失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置语音角色 |
|||
* |
|||
* @param voiceName 语音角色名称(不同服务的语音角色命名可能不同) |
|||
* @return 是否设置成功 |
|||
*/ |
|||
public func setVoice(_ voiceName: String) -> Bool { |
|||
if !isInitialized { return false } |
|||
|
|||
do { |
|||
currentVoice = voiceName |
|||
|
|||
// 更新语音名称 |
|||
if let config = speechConfig { |
|||
config.speechSynthesisVoiceName = voiceName |
|||
} |
|||
|
|||
// 重新创建合成器 |
|||
recreateSynthesizer() |
|||
return true |
|||
} catch { |
|||
os_log("设置语音失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "VOICE_SET_FAILED", |
|||
"errorMessage": "设置语音失败: \(error.localizedDescription)" |
|||
]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 单次合成并播放 |
|||
* |
|||
* @param text 要合成的文本 |
|||
* @return 是否成功开始合成 |
|||
*/ |
|||
public func speakOnce(_ text: String) -> Bool { |
|||
if !isInitialized { |
|||
os_log("语音合成未初始化", log: log, type: .error) |
|||
return false |
|||
} |
|||
|
|||
// 立即返回成功,内部异步处理 |
|||
enqueueSynthesisTask { |
|||
self.performSynthesis(text: text) |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 将合成任务加入队列,保证顺序执行 |
|||
*/ |
|||
private func enqueueSynthesisTask(_ task: @escaping () -> Void) { |
|||
taskLock.lock() |
|||
defer { taskLock.unlock() } |
|||
|
|||
pendingTasks.append(task) |
|||
|
|||
// 如果当前没有任务在执行,开始处理队列 |
|||
if pendingTasks.count == 1 { |
|||
processNextTask() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理队列中的下一个任务 |
|||
*/ |
|||
private func processNextTask() { |
|||
synthesisQueue.async { |
|||
self.synthesisGroup.enter() |
|||
|
|||
self.taskLock.lock() |
|||
guard !self.pendingTasks.isEmpty else { |
|||
self.taskLock.unlock() |
|||
self.synthesisGroup.leave() |
|||
return |
|||
} |
|||
let task = self.pendingTasks.removeFirst() |
|||
self.taskLock.unlock() |
|||
|
|||
// 执行任务 |
|||
task() |
|||
|
|||
self.synthesisGroup.leave() |
|||
|
|||
// 处理下一个任务 |
|||
self.taskLock.lock() |
|||
if !self.pendingTasks.isEmpty { |
|||
self.taskLock.unlock() |
|||
self.processNextTask() |
|||
} else { |
|||
self.taskLock.unlock() |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 实际执行合成的方法 |
|||
*/ |
|||
private func performSynthesis(text: String) { |
|||
// 重置状态 |
|||
speaking = true |
|||
|
|||
// 生成SSML |
|||
let ssml = generateSsml(text) |
|||
|
|||
do { |
|||
os_log("开始合成: %{public}@", log: log, type: .debug, ssml) |
|||
// 使用同步方法,在后台队列中执行 |
|||
let result = try synthesizer?.startSpeakingSsml(ssml) |
|||
|
|||
// 检查结果 |
|||
if let result = result { |
|||
os_log("合成完成,结果: %{public}@", log: log, type: .debug, String(describing: result.reason)) |
|||
} |
|||
} catch { |
|||
os_log("语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "SYNTHESIS_FAILED", |
|||
"errorMessage": error.localizedDescription |
|||
]) |
|||
speaking = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 流式合成文本 |
|||
* |
|||
* @param text 要合成的文本片段 |
|||
* @return 是否成功处理 |
|||
*/ |
|||
public func speakStream(_ text: String) -> Bool { |
|||
if !isInitialized || text.isEmpty { |
|||
if !isInitialized { |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "NOT_INITIALIZED", |
|||
"errorMessage": "TTS引擎未初始化" |
|||
]) |
|||
} |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
// 添加新文本到缓冲区 |
|||
streamBuffer.append(text) |
|||
|
|||
// 增加500ms防抖逻辑 |
|||
let currentTime = Date().timeIntervalSince1970 |
|||
if currentTime - lastSpeakTime < 0.6 { |
|||
return true |
|||
} |
|||
lastSpeakTime = currentTime |
|||
|
|||
let currentText = streamBuffer |
|||
|
|||
// 定义标点符号列表 |
|||
let punctuationMarks: [Character] = [".", "。", "!", "!", "?", "?", ";", ";", ",", ",", ":", ":", "\n"] |
|||
|
|||
// 查找最后一个标点符号的位置 |
|||
var lastPunctuationIndex = -1 |
|||
for (i, char) in currentText.enumerated().reversed() { |
|||
if punctuationMarks.contains(char) { |
|||
lastPunctuationIndex = i |
|||
break |
|||
} |
|||
} |
|||
|
|||
// 如果找到标点符号,则播放到该标点符号 |
|||
if lastPunctuationIndex >= 0 { |
|||
// 提取要播放的文本(包含标点符号) |
|||
let textToSpeak = String(currentText.prefix(lastPunctuationIndex + 1)).trimmingCharacters(in: .whitespacesAndNewlines) |
|||
|
|||
// 剩余的文本保存在缓冲区中 |
|||
let startIndex = currentText.index(currentText.startIndex, offsetBy: lastPunctuationIndex + 1) |
|||
streamBuffer = String(currentText[startIndex...]) |
|||
|
|||
// 只有非空文本才播放 |
|||
if !textToSpeak.isEmpty { |
|||
return speakOnce(textToSpeak) |
|||
} |
|||
} |
|||
|
|||
// 如果没有找到标点符号,则等待更多文本 |
|||
return true |
|||
} catch { |
|||
os_log("流式语音合成失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "STREAM_FAILED", |
|||
"errorMessage": "流式语音合成失败: \(error.localizedDescription)" |
|||
]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 刷新并播放流式文本缓冲区中的剩余内容 |
|||
* |
|||
* @return 是否成功处理 |
|||
*/ |
|||
public func flushStream() -> Bool { |
|||
if !isInitialized { |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "NOT_INITIALIZED", |
|||
"errorMessage": "TTS引擎未初始化" |
|||
]) |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
// 获取缓冲区中剩余的文本 |
|||
let remainingText = streamBuffer.trimmingCharacters(in: .whitespacesAndNewlines) |
|||
|
|||
// 清空缓冲区 |
|||
streamBuffer = "" |
|||
|
|||
// 如果缓冲区为空,直接返回成功 |
|||
if remainingText.isEmpty { |
|||
return true |
|||
} |
|||
|
|||
// 播放剩余文本 |
|||
return speakOnce(remainingText) |
|||
} catch { |
|||
os_log("刷新流式文本失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "FLUSH_FAILED", |
|||
"errorMessage": "刷新流式文本失败: \(error.localizedDescription)" |
|||
]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 停止语音合成和播放 |
|||
* |
|||
* @return 是否成功停止 |
|||
*/ |
|||
public func stop() -> Bool { |
|||
// 立即更新状态 |
|||
speaking = false |
|||
|
|||
// 清除流式缓冲区中的待播放内容 |
|||
streamBuffer = "" |
|||
|
|||
// 清空待处理的任务队列 |
|||
taskLock.lock() |
|||
pendingTasks.removeAll() |
|||
taskLock.unlock() |
|||
|
|||
// 在后台队列停止合成器,避免阻塞主线程 |
|||
synthesisQueue.async { |
|||
if let synthesizer = self.synthesizer { |
|||
do { |
|||
try synthesizer.stopSpeaking() |
|||
os_log("语音合成已停止", log: self.log, type: .info) |
|||
|
|||
// 直接通知停止完成 |
|||
self.notifyEvent(eventType: .synthesisCanceled) |
|||
} catch { |
|||
os_log("停止语音合成失败: %{public}@", log: self.log, type: .error, error.localizedDescription) |
|||
self.notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "STOP_FAILED", |
|||
"errorMessage": error.localizedDescription |
|||
]) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
* 在不再需要服务时调用,释放底层资源 |
|||
*/ |
|||
public func dispose() { |
|||
// 停止播放 |
|||
_ = stop() |
|||
|
|||
// 等待所有任务完成 |
|||
synthesisGroup.wait() |
|||
|
|||
// 清理资源 |
|||
synthesisQueue.async { |
|||
// 释放合成器 |
|||
self.synthesizer = nil |
|||
|
|||
// 释放配置 |
|||
self.speechConfig = nil |
|||
|
|||
// 清空流缓冲区 |
|||
self.streamBuffer = "" |
|||
|
|||
// 重置状态 |
|||
self.isInitialized = false |
|||
self.speaking = false |
|||
|
|||
// 清空任务队列 |
|||
self.taskLock.lock() |
|||
self.pendingTasks.removeAll() |
|||
self.taskLock.unlock() |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 添加TTS事件监听器 |
|||
* |
|||
* @param listener 事件监听器 |
|||
*/ |
|||
public func addListener(_ listener: TtsEventListener) { |
|||
eventListeners.add(listener as AnyObject) |
|||
} |
|||
|
|||
/** |
|||
* 移除TTS事件监听器 |
|||
* |
|||
* @param listener 要移除的事件监听器 |
|||
*/ |
|||
public func removeListener(_ listener: TtsEventListener) { |
|||
eventListeners.remove(listener as AnyObject) |
|||
} |
|||
|
|||
/** |
|||
* 添加音频数据监听器 |
|||
* 由于不再支持自定义音频流,此方法实际上不再有效 |
|||
* |
|||
* @param listener 音频数据监听器 |
|||
*/ |
|||
public func addAudioDataListener(_ listener: AudioDataListener) { |
|||
os_log("警告:不支持音频数据监听器功能", log: log, type: .info) |
|||
} |
|||
|
|||
/** |
|||
* 移除音频数据监听器 |
|||
* 由于不再支持自定义音频流,此方法实际上不再有效 |
|||
* |
|||
* @param listener 要移除的音频数据监听器 |
|||
*/ |
|||
public func removeAudioDataListener(_ listener: AudioDataListener) { |
|||
// 不做任何操作 |
|||
} |
|||
|
|||
/** |
|||
* 当前是否正在播放/合成 |
|||
*/ |
|||
public func isSpeaking() -> Bool { |
|||
return speaking |
|||
} |
|||
|
|||
// MARK: - 辅助方法 |
|||
|
|||
/** |
|||
* 设置事件监听器 |
|||
*/ |
|||
private func setupEventListeners() { |
|||
guard let synthesizer = synthesizer else { return } |
|||
|
|||
// 添加合成开始事件处理器 |
|||
synthesizer.addSynthesisStartedEventHandler { [weak self] _, _ in |
|||
guard let self = self else { return } |
|||
self.notifyEvent(eventType: .synthesisStarted) |
|||
} |
|||
|
|||
// 添加合成中事件处理器 |
|||
synthesizer.addSynthesizingEventHandler { [weak self] _, _ in |
|||
// 可以在这里处理合成中的事件,目前没有特别操作 |
|||
} |
|||
|
|||
// 添加合成完成事件处理器 |
|||
synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in |
|||
guard let self = self else { return } |
|||
self.speaking = false |
|||
self.notifyEvent(eventType: .synthesisCompleted) |
|||
} |
|||
|
|||
// 添加合成取消事件处理器 |
|||
synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in |
|||
guard let self = self else { return } |
|||
|
|||
self.speaking = false |
|||
|
|||
var params: [String: Any] = [:] |
|||
do { |
|||
let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: e.result) |
|||
if cancellationDetails.reason == SPXCancellationReason.error { |
|||
params["reason"] = String(describing: cancellationDetails.reason.rawValue) |
|||
params["errorDetails"] = cancellationDetails.errorDetails ?? "未知错误" |
|||
} |
|||
} catch { |
|||
params["errorDetails"] = "获取取消详情失败: \(error.localizedDescription)" |
|||
} |
|||
os_log("语音合成取消, %{public}@", log: self.log, type: .info, String(describing: params["errorDetails"] ?? "未知错误")) |
|||
|
|||
self.notifyEvent(eventType: .synthesisCanceled, params: params) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 触发事件通知 |
|||
*/ |
|||
private func notifyEvent(eventType: TtsEventType, params: [String: Any] = [:]) { |
|||
let event = TtsEvent(type: eventType, params: params) |
|||
|
|||
// 直接通知事件,由调用方处理线程切换 |
|||
for case let listener as TtsEventListener in self.eventListeners.allObjects { |
|||
listener.onEvent(event) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 重新创建合成器 |
|||
*/ |
|||
private func recreateSynthesizer() { |
|||
do { |
|||
// 使用默认音频输出配置创建合成器 |
|||
synthesizer = try SPXSpeechSynthesizer(speechConfig!) |
|||
|
|||
// 设置事件监听 |
|||
setupEventListeners() |
|||
} catch { |
|||
os_log("重新创建合成器失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "RECREATE_FAILED", |
|||
"errorMessage": "重新创建合成器失败: \(error.localizedDescription)" |
|||
]) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置语音参数 |
|||
*/ |
|||
private func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { |
|||
if !isInitialized { return false } |
|||
|
|||
do { |
|||
currentRate = formatPercentage(rate) |
|||
currentPitch = formatPercentage(pitch) |
|||
currentVolume = "\(min(max(volume, 0), 100))%" |
|||
return true |
|||
} catch { |
|||
os_log("设置语音参数失败: %{public}@", log: log, type: .error, error.localizedDescription) |
|||
notifyEvent(eventType: .error, params: [ |
|||
"errorCode": "PARAMS_SET_FAILED", |
|||
"errorMessage": "设置语音参数失败: \(error.localizedDescription)" |
|||
]) |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 格式化百分比值 |
|||
*/ |
|||
private func formatPercentage(_ value: Int) -> String { |
|||
return value >= 0 ? "+\(value)%" : "\(value)%" |
|||
} |
|||
|
|||
/** |
|||
* 生成SSML |
|||
*/ |
|||
private func generateSsml(_ rawText: String) -> String { |
|||
// 1. 定义要静音的符号和表情符号列表 |
|||
let symbolsToMute = [ |
|||
"#", "*", |
|||
"😀", "😂", "😊", "😍", "😢", "😎", "😉", "👍", "🙌", "🎉" |
|||
] |
|||
|
|||
// 2. 转义 XML 保留字符 |
|||
var escapedText = rawText |
|||
.replacingOccurrences(of: "&", with: "&") |
|||
.replacingOccurrences(of: "<", with: "<") |
|||
.replacingOccurrences(of: ">", with: ">") |
|||
|
|||
// 3. 静音处理特殊符号和表情符号 |
|||
// 使用空白替换法,直接将符号替换为空字符串 |
|||
var processedText = escapedText |
|||
for symbol in symbolsToMute { |
|||
processedText = processedText.replacingOccurrences(of: symbol, with: "") |
|||
} |
|||
|
|||
// 4. 构造简化的SSML文档,减少嵌套层级 |
|||
let ssml = """ |
|||
<speak version="1.0" |
|||
xmlns="http://www.w3.org/2001/10/synthesis" |
|||
xmlns:mstts="https://www.w3.org/2001/mstts" |
|||
xml:lang="zh-CN"> |
|||
<voice name="\(currentVoice)"> |
|||
<mstts:express-as style="cheerful"> |
|||
<prosody rate="\(currentRate)" pitch="\(currentPitch)" volume="\(currentVolume)"> |
|||
<say-as interpret-as="text">\(processedText)</say-as> |
|||
</prosody> |
|||
</mstts:express-as> |
|||
</voice> |
|||
</speak> |
|||
""" |
|||
|
|||
return ssml |
|||
} |
|||
} |
|||
@ -0,0 +1,233 @@ |
|||
import Foundation |
|||
|
|||
public class RecordFile { |
|||
static let shared = RecordFile() |
|||
|
|||
private var currentAudioFile: URL? |
|||
private var fileHandle: FileHandle? |
|||
private let writeQueue = DispatchQueue(label: "audio.write.queue") |
|||
private let writeGroup = DispatchGroup() |
|||
private var isWriting = false |
|||
private var dataBuffer = Data() |
|||
private var totalBytesWritten = 0 |
|||
private let sampleRate: UInt32 = 16000 |
|||
private var lastSavedFile: URL? |
|||
public var isPause = false |
|||
var fileName = "" |
|||
public init() {} |
|||
// 创建音频文件并初始化 WAV 头 |
|||
public func creatingFiles(atPath path: String) { |
|||
guard fileHandle == nil else { return } |
|||
|
|||
let fileURL = URL(fileURLWithPath: path) |
|||
let directoryURL = fileURL.deletingLastPathComponent() |
|||
print("创建音频文件path: \(path)") |
|||
// 创建目录 |
|||
do { |
|||
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) |
|||
} catch { |
|||
print("创建目录失败: \(error.localizedDescription)") |
|||
return |
|||
} |
|||
|
|||
// 创建文件 |
|||
FileManager.default.createFile(atPath: path, contents: nil) |
|||
|
|||
do { |
|||
fileHandle = try FileHandle(forWritingTo: fileURL) |
|||
currentAudioFile = fileURL |
|||
|
|||
// 写入初始 WAV 头 |
|||
let header = generateWavHeader(dataLength: 0) |
|||
try fileHandle?.write(contentsOf: header) |
|||
totalBytesWritten = 0 |
|||
} catch { |
|||
print("文件初始化失败: \(error.localizedDescription)") |
|||
fileHandle = nil |
|||
currentAudioFile = nil |
|||
} |
|||
} |
|||
|
|||
// 异步保存音频数据 |
|||
public func saveAudioDataToWav(_ data: Data) { |
|||
guard let _ = fileHandle, let _ = currentAudioFile else { return } |
|||
|
|||
writeQueue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
self.dataBuffer.append(data) |
|||
self.totalBytesWritten += data.count |
|||
|
|||
if !self.isWriting { |
|||
self.isWriting = true |
|||
self.writeGroup.enter() |
|||
|
|||
defer { |
|||
self.isWriting = false |
|||
self.writeGroup.leave() |
|||
} |
|||
|
|||
while !self.dataBuffer.isEmpty { |
|||
let chunk: Data |
|||
if self.dataBuffer.count > 8192 { |
|||
chunk = self.dataBuffer.prefix(8192) |
|||
self.dataBuffer.removeFirst(8192) |
|||
} else { |
|||
chunk = self.dataBuffer |
|||
self.dataBuffer.removeAll() |
|||
} |
|||
|
|||
do { |
|||
try self.fileHandle?.seekToEnd() |
|||
self.fileHandle?.write(chunk) |
|||
} catch { |
|||
print("写入数据失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 生成 WAV 文件头 |
|||
private func generateWavHeader(dataLength: Int) -> Data { |
|||
let totalLength = 36 + dataLength |
|||
let byteRate = sampleRate * 2 |
|||
|
|||
var header = Data() |
|||
header.append("RIFF".data(using: .ascii)!) |
|||
header.append(Data(bytes: [ |
|||
UInt8(totalLength & 0xFF), |
|||
UInt8((totalLength >> 8) & 0xFF), |
|||
UInt8((totalLength >> 16) & 0xFF), |
|||
UInt8((totalLength >> 24) & 0xFF) |
|||
])) |
|||
header.append("WAVE".data(using: .ascii)!) |
|||
header.append("fmt ".data(using: .ascii)!) |
|||
header.append(Data(bytes: [16, 0, 0, 0])) // PCM 头长度 |
|||
header.append(Data(bytes: [1, 0])) // PCM 格式 |
|||
header.append(Data(bytes: [1, 0])) // 单声道 |
|||
header.append(Data(bytes: [ |
|||
UInt8(sampleRate & 0xFF), |
|||
UInt8((sampleRate >> 8) & 0xFF), |
|||
UInt8((sampleRate >> 16) & 0xFF), |
|||
UInt8((sampleRate >> 24) & 0xFF) |
|||
])) |
|||
header.append(Data(bytes: [ |
|||
UInt8(byteRate & 0xFF), |
|||
UInt8((byteRate >> 8) & 0xFF), |
|||
UInt8((byteRate >> 16) & 0xFF), |
|||
UInt8((byteRate >> 24) & 0xFF) |
|||
])) |
|||
header.append(Data(bytes: [2, 0])) // 块对齐 |
|||
header.append(Data(bytes: [16, 0])) // 样本位数 |
|||
header.append("data".data(using: .ascii)!) |
|||
header.append(Data(bytes: [ |
|||
UInt8(dataLength & 0xFF), |
|||
UInt8((dataLength >> 8) & 0xFF), |
|||
UInt8((dataLength >> 16) & 0xFF), |
|||
UInt8((dataLength >> 24) & 0xFF) |
|||
])) |
|||
|
|||
return header |
|||
} |
|||
|
|||
// 重命名文件 |
|||
public func renameFile(at sourcePath: String, to newName: String) -> Bool { |
|||
let sourceURL = URL(fileURLWithPath: sourcePath) |
|||
|
|||
guard FileManager.default.fileExists(atPath: sourcePath) else { |
|||
print("重命名失败: 文件不存在 \(sourcePath)") |
|||
return false |
|||
} |
|||
|
|||
if let current = currentAudioFile, current == sourceURL { |
|||
print("重命名失败: 不能重命名正在录制的文件") |
|||
return false |
|||
} |
|||
|
|||
let newFileURL = sourceURL |
|||
.deletingLastPathComponent() |
|||
.appendingPathComponent(newName) |
|||
.appendingPathExtension("wav") |
|||
|
|||
do { |
|||
try FileManager.default.moveItem(at: sourceURL, to: newFileURL) |
|||
if lastSavedFile == sourceURL { |
|||
lastSavedFile = newFileURL |
|||
} |
|||
return true |
|||
} catch { |
|||
print("重命名异常: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 移动文件 |
|||
public func moveFile(from sourcePath: String, to destPath: String) -> Bool { |
|||
let sourceURL = URL(fileURLWithPath: sourcePath) |
|||
let destURL = URL(fileURLWithPath: destPath) |
|||
|
|||
guard FileManager.default.fileExists(atPath: sourcePath) else { |
|||
print("移动失败: 源文件不存在 \(sourcePath)") |
|||
return false |
|||
} |
|||
|
|||
let destDir = destURL.deletingLastPathComponent() |
|||
do { |
|||
try FileManager.default.createDirectory(at: destDir, withIntermediateDirectories: true) |
|||
} catch { |
|||
print("创建目录失败: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
|
|||
do { |
|||
try FileManager.default.moveItem(at: sourceURL, to: destURL) |
|||
if lastSavedFile == sourceURL { |
|||
lastSavedFile = destURL |
|||
} |
|||
return true |
|||
} catch { |
|||
print("移动文件异常: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
// 关闭文件 |
|||
public func closeFile(isSave: Bool) -> Bool { |
|||
defer { |
|||
fileHandle = nil |
|||
currentAudioFile = nil |
|||
dataBuffer.removeAll() |
|||
totalBytesWritten = 0 |
|||
} |
|||
|
|||
guard let fileURL = currentAudioFile, let handle = fileHandle else { |
|||
return false |
|||
} |
|||
|
|||
// 等待写入完成 |
|||
writeGroup.wait() |
|||
|
|||
do { |
|||
try handle.close() |
|||
|
|||
if !isSave || totalBytesWritten == 0 { |
|||
try FileManager.default.removeItem(at: fileURL) |
|||
return false |
|||
} |
|||
|
|||
// 更新 WAV 头 |
|||
let header = generateWavHeader(dataLength: totalBytesWritten) |
|||
let updateHandle = try FileHandle(forUpdating: fileURL) |
|||
try updateHandle.seek(toOffset: 0) |
|||
updateHandle.write(header) |
|||
try updateHandle.close() |
|||
|
|||
lastSavedFile = fileURL |
|||
print("关闭文件path: \(fileURL)") |
|||
return true |
|||
} catch { |
|||
print("关闭文件失败: \(error.localizedDescription)") |
|||
return false |
|||
} |
|||
} |
|||
} |
|||
@ -1,306 +1,306 @@ |
|||
import Foundation |
|||
import CoreBluetooth |
|||
import os.log |
|||
import AVFoundation |
|||
import AccessorySetupKit |
|||
import UIKit |
|||
// import Foundation |
|||
// import CoreBluetooth |
|||
// import os.log |
|||
// import AVFoundation |
|||
// import AccessorySetupKit |
|||
// import UIKit |
|||
|
|||
@available(iOS 18.0, *) |
|||
class BleAccessorySetup: NSObject { |
|||
// MARK: - 属性 |
|||
static let shared = BleAccessorySetup() |
|||
// @available(iOS 18.0, *) |
|||
// class BleAccessorySetup: NSObject { |
|||
// // MARK: - 属性 |
|||
// static let shared = BleAccessorySetup() |
|||
|
|||
private var bleService: BleService? |
|||
private var session: ASAccessorySession? |
|||
private var isSessionActivated = false |
|||
private var isInitializing = false |
|||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
|||
// private var bleService: BleService? |
|||
// private var session: ASAccessorySession? |
|||
// private var isSessionActivated = false |
|||
// private var isInitializing = false |
|||
// private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
|||
|
|||
// 杰理蓝牙设备的公司标识符 |
|||
private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
|||
private let deviceDisplayName = "语音助理耳机" |
|||
// // 杰理蓝牙设备的公司标识符 |
|||
// private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
|||
// private let deviceDisplayName = "语音助理耳机" |
|||
|
|||
// 用于存储配对设备UUID的UserDefaults键 |
|||
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
|||
// // 用于存储配对设备UUID的UserDefaults键 |
|||
// private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
|||
|
|||
typealias DeviceDiscoveredCallback = (UUID) -> Void |
|||
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
|||
// typealias DeviceDiscoveredCallback = (UUID) -> Void |
|||
// private var onDeviceDiscovered: DeviceDiscoveredCallback? |
|||
|
|||
// MARK: - 初始化 |
|||
private override init() { super.init() } |
|||
// // MARK: - 初始化 |
|||
// private override init() { super.init() } |
|||
|
|||
// MARK: - 公共方法 |
|||
func setBleService(_ service: Any) { |
|||
self.bleService = service as? BleService |
|||
} |
|||
// // MARK: - 公共方法 |
|||
// func setBleService(_ service: Any) { |
|||
// self.bleService = service as? BleService |
|||
// } |
|||
|
|||
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
|||
self.onDeviceDiscovered = callback |
|||
} |
|||
// func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
|||
// self.onDeviceDiscovered = callback |
|||
// } |
|||
|
|||
// 处理发现的设备并连接 |
|||
func connectToAccessory(uuid: UUID) { |
|||
guard let service = bleService else { |
|||
return |
|||
} |
|||
// // 处理发现的设备并连接 |
|||
// func connectToAccessory(uuid: UUID) { |
|||
// guard let service = bleService else { |
|||
// return |
|||
// } |
|||
|
|||
service.connectToAccessory(uuid: uuid) |
|||
} |
|||
// service.connectToAccessory(uuid: uuid) |
|||
// } |
|||
|
|||
func addToReconnectQueue(uuid: UUID) { |
|||
guard let service = bleService else { |
|||
return |
|||
} |
|||
// func addToReconnectQueue(uuid: UUID) { |
|||
// guard let service = bleService else { |
|||
// return |
|||
// } |
|||
|
|||
service.addToReconnectQueue(uuid: uuid) |
|||
} |
|||
// service.addToReconnectQueue(uuid: uuid) |
|||
// } |
|||
|
|||
// 从视图控制器显示设备选择器 - 主要入口点 |
|||
func showDevicePickerFromViewController(_ viewController: UIViewController) { |
|||
initializeSession() |
|||
} |
|||
// // 从视图控制器显示设备选择器 - 主要入口点 |
|||
// func showDevicePickerFromViewController(_ viewController: UIViewController) { |
|||
// initializeSession() |
|||
// } |
|||
|
|||
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
|||
func resetBluetoothState() { |
|||
if let service = bleService { |
|||
service.disconnect() |
|||
} |
|||
} |
|||
// // 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
|||
// func resetBluetoothState() { |
|||
// if let service = bleService { |
|||
// service.disconnect() |
|||
// } |
|||
// } |
|||
|
|||
// MARK: - 会话管理 |
|||
func initializeSession() { |
|||
if isInitializing { |
|||
os_log("会话正在初始化中,跳过", log: logger, type: .info) |
|||
return |
|||
} |
|||
// // MARK: - 会话管理 |
|||
// func initializeSession() { |
|||
// if isInitializing { |
|||
// os_log("会话正在初始化中,跳过", log: logger, type: .info) |
|||
// return |
|||
// } |
|||
|
|||
// 如果会话已激活,检查是否有配对设备 |
|||
if session != nil && isSessionActivated { |
|||
os_log("会话已激活,检查配对设备", log: logger, type: .info) |
|||
// 会话已激活,检查是否有配对设备 |
|||
if let accessories = session?.accessories, !accessories.isEmpty { |
|||
os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) |
|||
// 有配对设备,尝试连接 |
|||
if let service = bleService { |
|||
let _ = service.getCentralManager() |
|||
// // 如果会话已激活,检查是否有配对设备 |
|||
// if session != nil && isSessionActivated { |
|||
// os_log("会话已激活,检查配对设备", log: logger, type: .info) |
|||
// // 会话已激活,检查是否有配对设备 |
|||
// if let accessories = session?.accessories, !accessories.isEmpty { |
|||
// os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) |
|||
// // 有配对设备,尝试连接 |
|||
// if let service = bleService { |
|||
// let _ = service.getCentralManager() |
|||
|
|||
for accessory in accessories { |
|||
if let uuid = accessory.bluetoothIdentifier { |
|||
onDeviceDiscovered?(uuid) |
|||
connectToAccessory(uuid: uuid) |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
os_log("没有已配对设备,显示配对框", log: logger, type: .info) |
|||
// 没有配对设备,直接显示配对框 |
|||
showDevicePicker() |
|||
} |
|||
return |
|||
} |
|||
// for accessory in accessories { |
|||
// if let uuid = accessory.bluetoothIdentifier { |
|||
// onDeviceDiscovered?(uuid) |
|||
// connectToAccessory(uuid: uuid) |
|||
// } |
|||
// } |
|||
// } |
|||
// } else { |
|||
// os_log("没有已配对设备,显示配对框", log: logger, type: .info) |
|||
// // 没有配对设备,直接显示配对框 |
|||
// showDevicePicker() |
|||
// } |
|||
// return |
|||
// } |
|||
|
|||
os_log("开始初始化新会话", log: logger, type: .info) |
|||
isInitializing = true |
|||
// os_log("开始初始化新会话", log: logger, type: .info) |
|||
// isInitializing = true |
|||
|
|||
do { |
|||
session = try ASAccessorySession() |
|||
session?.activate(on: .main) { [weak self] event in |
|||
guard let self = self else { return } |
|||
self.handleSessionEvent(event) |
|||
} |
|||
} catch { |
|||
os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
handleSessionInitError(error) |
|||
} |
|||
} |
|||
// do { |
|||
// session = try ASAccessorySession() |
|||
// session?.activate(on: .main) { [weak self] event in |
|||
// guard let self = self else { return } |
|||
// self.handleSessionEvent(event) |
|||
// } |
|||
// } catch { |
|||
// os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
// handleSessionInitError(error) |
|||
// } |
|||
// } |
|||
|
|||
func resetSession() { |
|||
session?.invalidate() |
|||
session = nil |
|||
isSessionActivated = false |
|||
isInitializing = false |
|||
} |
|||
// func resetSession() { |
|||
// session?.invalidate() |
|||
// session = nil |
|||
// isSessionActivated = false |
|||
// isInitializing = false |
|||
// } |
|||
|
|||
// MARK: - 设备选择器显示 |
|||
private func showDevicePicker() { |
|||
guard let session = session, isSessionActivated else { |
|||
os_log("无法显示配对框:会话未激活", log: logger, type: .error) |
|||
return |
|||
} |
|||
// // MARK: - 设备选择器显示 |
|||
// private func showDevicePicker() { |
|||
// guard let session = session, isSessionActivated else { |
|||
// os_log("无法显示配对框:会话未激活", log: logger, type: .error) |
|||
// return |
|||
// } |
|||
|
|||
os_log("准备显示设备配对框", log: logger, type: .info) |
|||
// os_log("准备显示设备配对框", log: logger, type: .info) |
|||
|
|||
let descriptor = ASDiscoveryDescriptor() |
|||
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) |
|||
// let descriptor = ASDiscoveryDescriptor() |
|||
// descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) |
|||
|
|||
let deviceImage = UIImage(systemName: "headphones") ?? UIImage() |
|||
let displayItem = ASPickerDisplayItem( |
|||
name: deviceDisplayName, |
|||
productImage: deviceImage, |
|||
descriptor: descriptor |
|||
) |
|||
// let deviceImage = UIImage(systemName: "headphones") ?? UIImage() |
|||
// let displayItem = ASPickerDisplayItem( |
|||
// name: deviceDisplayName, |
|||
// productImage: deviceImage, |
|||
// descriptor: descriptor |
|||
// ) |
|||
|
|||
DispatchQueue.main.async { |
|||
os_log("正在显示设备配对框", log: self.logger, type: .info) |
|||
session.showPicker(for: [displayItem]) { [weak self] error in |
|||
guard let self = self else { return } |
|||
// DispatchQueue.main.async { |
|||
// os_log("正在显示设备配对框", log: self.logger, type: .info) |
|||
// session.showPicker(for: [displayItem]) { [weak self] error in |
|||
// guard let self = self else { return } |
|||
|
|||
if let error = error { |
|||
let nsError = error as NSError |
|||
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
} else { |
|||
os_log("设备选择器完成", log: self.logger, type: .info) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
// if let error = error { |
|||
// let nsError = error as NSError |
|||
// os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
|||
// } else { |
|||
// os_log("设备选择器完成", log: self.logger, type: .info) |
|||
// } |
|||
// } |
|||
// } |
|||
// } |
|||
|
|||
// MARK: - 私有辅助方法 |
|||
private func handleSessionEvent(_ event: ASAccessoryEvent) { |
|||
switch event.eventType { |
|||
case .activated: |
|||
os_log("会话已激活", log: logger, type: .info) |
|||
handleSessionActivated() |
|||
// // MARK: - 私有辅助方法 |
|||
// private func handleSessionEvent(_ event: ASAccessoryEvent) { |
|||
// switch event.eventType { |
|||
// case .activated: |
|||
// os_log("会话已激活", log: logger, type: .info) |
|||
// handleSessionActivated() |
|||
|
|||
case .accessoryAdded: |
|||
os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|||
handleAccessoryAdded(event.accessory) |
|||
// case .accessoryAdded: |
|||
// os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|||
// handleAccessoryAdded(event.accessory) |
|||
|
|||
case .accessoryRemoved: |
|||
os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|||
handleAccessoryRemoved(event.accessory) |
|||
// case .accessoryRemoved: |
|||
// os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
|||
// handleAccessoryRemoved(event.accessory) |
|||
|
|||
case .accessoryChanged: |
|||
os_log("设备状态已改变", log: logger, type: .info) |
|||
break |
|||
// case .accessoryChanged: |
|||
// os_log("设备状态已改变", log: logger, type: .info) |
|||
// break |
|||
|
|||
case .pickerDidPresent: |
|||
os_log("配对框已显示", log: logger, type: .info) |
|||
break |
|||
// case .pickerDidPresent: |
|||
// os_log("配对框已显示", log: logger, type: .info) |
|||
// break |
|||
|
|||
case .pickerDidDismiss: |
|||
os_log("配对框已关闭", log: logger, type: .info) |
|||
break |
|||
// case .pickerDidDismiss: |
|||
// os_log("配对框已关闭", log: logger, type: .info) |
|||
// break |
|||
|
|||
case .pickerSetupFailed: |
|||
os_log("选择器设置失败: %{public}@", log: logger, type: .error, |
|||
event.error?.localizedDescription ?? "未知错误") |
|||
// case .pickerSetupFailed: |
|||
// os_log("选择器设置失败: %{public}@", log: logger, type: .error, |
|||
// event.error?.localizedDescription ?? "未知错误") |
|||
|
|||
default: |
|||
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) |
|||
break |
|||
} |
|||
} |
|||
// default: |
|||
// os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) |
|||
// break |
|||
// } |
|||
// } |
|||
|
|||
private func handleSessionActivated() { |
|||
isSessionActivated = true |
|||
isInitializing = false |
|||
// private func handleSessionActivated() { |
|||
// isSessionActivated = true |
|||
// isInitializing = false |
|||
|
|||
if let accessories = session?.accessories, !accessories.isEmpty { |
|||
if let service = bleService { |
|||
let _ = service.getCentralManager() |
|||
// if let accessories = session?.accessories, !accessories.isEmpty { |
|||
// if let service = bleService { |
|||
// let _ = service.getCentralManager() |
|||
|
|||
for accessory in accessories { |
|||
if let uuid = accessory.bluetoothIdentifier { |
|||
onDeviceDiscovered?(uuid) |
|||
connectToAccessory(uuid: uuid) |
|||
} |
|||
} |
|||
} |
|||
} else { |
|||
showDevicePicker() |
|||
} |
|||
} |
|||
// for accessory in accessories { |
|||
// if let uuid = accessory.bluetoothIdentifier { |
|||
// onDeviceDiscovered?(uuid) |
|||
// connectToAccessory(uuid: uuid) |
|||
// } |
|||
// } |
|||
// } |
|||
// } else { |
|||
// showDevicePicker() |
|||
// } |
|||
// } |
|||
|
|||
private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
|||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|||
return |
|||
} |
|||
// private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
|||
// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|||
// return |
|||
// } |
|||
|
|||
// 保存配对设备的UUID |
|||
savePairedMacAddress(uuid) |
|||
// // 保存配对设备的UUID |
|||
// savePairedMacAddress(uuid) |
|||
|
|||
onDeviceDiscovered?(uuid) |
|||
connectToAccessory(uuid: uuid) |
|||
} |
|||
// onDeviceDiscovered?(uuid) |
|||
// connectToAccessory(uuid: uuid) |
|||
// } |
|||
|
|||
private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
|||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|||
return |
|||
} |
|||
// private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
|||
// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
|||
// return |
|||
// } |
|||
|
|||
if let service = bleService { |
|||
service.disconnect() |
|||
} |
|||
} |
|||
// if let service = bleService { |
|||
// service.disconnect() |
|||
// } |
|||
// } |
|||
|
|||
private func handleSessionInitError(_ error: Error) { |
|||
isInitializing = false |
|||
session = nil |
|||
} |
|||
// private func handleSessionInitError(_ error: Error) { |
|||
// isInitializing = false |
|||
// session = nil |
|||
// } |
|||
|
|||
// MARK: - 蓝牙状态检查 |
|||
func isBluetoothAvailable() -> Bool { |
|||
do { |
|||
let audioSession = AVAudioSession.sharedInstance() |
|||
let outputs = audioSession.currentRoute.outputs |
|||
// // MARK: - 蓝牙状态检查 |
|||
// func isBluetoothAvailable() -> Bool { |
|||
// do { |
|||
// let audioSession = AVAudioSession.sharedInstance() |
|||
// let outputs = audioSession.currentRoute.outputs |
|||
|
|||
for output in outputs { |
|||
if output.portType == .bluetoothA2DP || |
|||
output.portType == .bluetoothHFP || |
|||
output.portType == .bluetoothLE { |
|||
return true |
|||
} |
|||
} |
|||
} catch { |
|||
os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
} |
|||
// for output in outputs { |
|||
// if output.portType == .bluetoothA2DP || |
|||
// output.portType == .bluetoothHFP || |
|||
// output.portType == .bluetoothLE { |
|||
// return true |
|||
// } |
|||
// } |
|||
// } catch { |
|||
// os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) |
|||
// } |
|||
|
|||
return true |
|||
} |
|||
// return true |
|||
// } |
|||
|
|||
// MARK: - 清除配对关系 |
|||
func clearAssociations() { |
|||
resetBluetoothState() |
|||
// // MARK: - 清除配对关系 |
|||
// func clearAssociations() { |
|||
// resetBluetoothState() |
|||
|
|||
// 清除保存的配对设备UUID |
|||
clearPairedMacAddress() |
|||
// // 清除保存的配对设备UUID |
|||
// clearPairedMacAddress() |
|||
|
|||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
if #available(iOS 18.0, *) { |
|||
resetSession() |
|||
initializeSession() |
|||
} |
|||
#endif |
|||
} |
|||
// #if canImport(AccessorySetupKit) && swift(>=5.9) |
|||
// if #available(iOS 18.0, *) { |
|||
// resetSession() |
|||
// initializeSession() |
|||
// } |
|||
// #endif |
|||
// } |
|||
|
|||
// MARK: - 配对设备UUID管理 |
|||
// // MARK: - 配对设备UUID管理 |
|||
|
|||
/// 获取已配对设备的UUID字符串 |
|||
/// 返回存储的配对设备UUID,如果没有则返回nil |
|||
func getPairedMacAddress() -> String? { |
|||
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) |
|||
if let uuidString = uuidString, !uuidString.isEmpty { |
|||
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|||
return uuidString |
|||
} |
|||
os_log("未找到已配对设备UUID", log: logger, type: .info) |
|||
return nil |
|||
} |
|||
// /// 获取已配对设备的UUID字符串 |
|||
// /// 返回存储的配对设备UUID,如果没有则返回nil |
|||
// func getPairedMacAddress() -> String? { |
|||
// let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) |
|||
// if let uuidString = uuidString, !uuidString.isEmpty { |
|||
// os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|||
// return uuidString |
|||
// } |
|||
// os_log("未找到已配对设备UUID", log: logger, type: .info) |
|||
// return nil |
|||
// } |
|||
|
|||
/// 保存配对设备的UUID |
|||
/// - Parameter uuid: 要保存的设备UUID |
|||
func savePairedMacAddress(_ uuid: UUID) { |
|||
let uuidString = uuid.uuidString |
|||
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) |
|||
UserDefaults.standard.synchronize() |
|||
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|||
} |
|||
// /// 保存配对设备的UUID |
|||
// /// - Parameter uuid: 要保存的设备UUID |
|||
// func savePairedMacAddress(_ uuid: UUID) { |
|||
// let uuidString = uuid.uuidString |
|||
// UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) |
|||
// UserDefaults.standard.synchronize() |
|||
// os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
|||
// } |
|||
|
|||
/// 清除已保存的配对设备UUID |
|||
private func clearPairedMacAddress() { |
|||
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) |
|||
UserDefaults.standard.synchronize() |
|||
os_log("已清除配对设备UUID", log: logger, type: .info) |
|||
} |
|||
} |
|||
// /// 清除已保存的配对设备UUID |
|||
// private func clearPairedMacAddress() { |
|||
// UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) |
|||
// UserDefaults.standard.synchronize() |
|||
// os_log("已清除配对设备UUID", log: logger, type: .info) |
|||
// } |
|||
// } |
|||
@ -0,0 +1,586 @@ |
|||
// import Foundation |
|||
// import CoreBluetooth |
|||
// import UIKit // 添加UIKit导入 |
|||
// import os.log |
|||
|
|||
// /// 简化的 BLE 扫描管理器 |
|||
// /// 只提供扫描和获取设备列表功能 |
|||
// @available(iOS 13.0, *) |
|||
// public class BleScanManager: NSObject { |
|||
|
|||
// // MARK: - 单例 |
|||
// public static let shared = BleScanManager() |
|||
|
|||
// // MARK: - 数据模型 |
|||
// public struct BleDeviceInfo { |
|||
// public let identifier: UUID |
|||
// public let name: String? |
|||
// public let rssi: NSNumber |
|||
// public let advertisementData: [String: Any] |
|||
// public let discoveryMethod: DiscoveryMethod |
|||
// public let lastSeen: Date |
|||
|
|||
// public enum DiscoveryMethod { |
|||
// case traditional |
|||
// case accessorySetupKit |
|||
// } |
|||
// } |
|||
|
|||
// // MARK: - 属性 |
|||
// private var centralManager: CBCentralManager! |
|||
// private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleScanManager") |
|||
|
|||
// // 扫描状态 |
|||
// private var isScanning = false |
|||
// private var scanTimeout: Timer? |
|||
|
|||
// // 设备列表 |
|||
// private var discoveredDevices: [UUID: BleDeviceInfo] = [:] |
|||
|
|||
|
|||
// // MARK: - 初始化 |
|||
// private override init() { |
|||
// super.init() |
|||
// setupCentralManager() |
|||
// } |
|||
|
|||
// // 在setupCentralManager后添加权限检查 |
|||
// private func setupCentralManager() { |
|||
// let options: [String: Any] = [ |
|||
// CBCentralManagerOptionShowPowerAlertKey: true, |
|||
// CBCentralManagerOptionRestoreIdentifierKey: "BleScanManager" |
|||
// ] |
|||
|
|||
// centralManager = CBCentralManager(delegate: self, queue: nil, options: options) |
|||
|
|||
// // 添加权限检查和触发逻辑 |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in |
|||
// self?.checkAndTriggerPermission() |
|||
// } |
|||
// } |
|||
|
|||
// private func checkAndTriggerPermission() { |
|||
// // 检查蓝牙权限状态,兼容不同iOS版本 |
|||
// let authorization: CBManagerAuthorization |
|||
// if #available(iOS 13.1, *) { |
|||
// authorization = CBManager.authorization |
|||
// } else if #available(iOS 13.0, *) { |
|||
// // iOS 13.0 使用实例方法,需要确保centralManager已初始化 |
|||
// authorization = centralManager?.authorization ?? .notDetermined |
|||
// } else { |
|||
// // iOS 13.0以下版本没有权限检查API,默认为未确定 |
|||
// authorization = .notDetermined |
|||
// } |
|||
|
|||
// os_log("📊 蓝牙权限状态: %d", log: logger, type: .info, authorization.rawValue) |
|||
// os_log("📊 CBCentralManager状态: %d", log: logger, type: .info, centralManager?.state.rawValue ?? -1) |
|||
|
|||
// // 只在权限未确定且蓝牙状态为关闭时触发扫描 |
|||
// if authorization == .notDetermined && centralManager?.state == .poweredOff { |
|||
// os_log("📊 主动触发一次扫描来申请权限", log: logger, type: .info) |
|||
// // 主动触发一次扫描来申请权限 |
|||
// centralManager?.scanForPeripherals(withServices: nil, options: nil) |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in |
|||
// self?.centralManager?.stopScan() |
|||
// } |
|||
// } |
|||
// } |
|||
|
|||
|
|||
|
|||
// // MARK: - 公共接口 |
|||
|
|||
// /// 开始扫描 BLE 设备 |
|||
// /// - Parameter timeout: 扫描超时时间,默认 10 秒 |
|||
// /// - Returns: 是否成功开始扫描 |
|||
// public func startScan(timeout: TimeInterval = 30.0) -> Bool { |
|||
// os_log("🔍 开始 BLE 扫描,超时时间: %.1f 秒", log: logger, type: .info, timeout) |
|||
|
|||
// guard !isScanning else { |
|||
// os_log("⚠️ 扫描已在进行中", log: logger, type: .info) |
|||
// return false |
|||
// } |
|||
|
|||
// // 清空之前的设备列表 |
|||
// discoveredDevices.removeAll() |
|||
|
|||
// // iOS 18 兼容性检查 |
|||
// if #available(iOS 18.0, *) { |
|||
// // iOS 18 使用新的权限检查方式 |
|||
// return startScanForIOS18(timeout: timeout) |
|||
// } else { |
|||
// // iOS 17 及以下使用传统方式 |
|||
// return startScanLegacy(timeout: timeout) |
|||
// } |
|||
// } |
|||
|
|||
// @available(iOS 18.0, *) |
|||
// private func startScanForIOS18(timeout: TimeInterval) -> Bool { |
|||
// // 对于 iOS 18,直接检查 CBCentralManager 状态 |
|||
// guard let centralManager = centralManager else { |
|||
// os_log("❌ CBCentralManager 未初始化", log: logger, type: .error) |
|||
// return false |
|||
// } |
|||
|
|||
// let currentState = centralManager.state |
|||
// os_log("📊 iOS 18 蓝牙状态: %d (%{public}@)", log: logger, type: .info, currentState.rawValue, getBluetoothStateString(currentState)) |
|||
|
|||
// switch currentState { |
|||
// case .poweredOn: |
|||
// return performScanWithPermission(timeout: timeout) |
|||
// case .unauthorized: |
|||
// os_log("❌ 蓝牙权限被拒绝", log: logger, type: .error) |
|||
// showPermissionAlert() |
|||
// return false |
|||
// case .unknown: |
|||
// os_log("⚠️ 蓝牙状态未知,等待状态更新", log: logger, type: .info) |
|||
// // 设置监听器等待状态更新 |
|||
// waitForBluetoothState(timeout: timeout) |
|||
// return true |
|||
// default: |
|||
// os_log("❌ 蓝牙不可用: %{public}@", log: logger, type: .error, getBluetoothStateString(currentState)) |
|||
// return false |
|||
// } |
|||
// } |
|||
|
|||
// private func startScanLegacy(timeout: TimeInterval) -> Bool { |
|||
// // 检查并申请蓝牙权限 |
|||
// let authorization: CBManagerAuthorization = CBManager.authorization |
|||
// os_log("📊 当前蓝牙权限状态: %d", log: logger, type: .info, authorization.rawValue) |
|||
|
|||
// switch authorization { |
|||
// case .allowedAlways: |
|||
// return performScanWithPermission(timeout: timeout) |
|||
// case .denied, .restricted: |
|||
// os_log("❌ 蓝牙权限被拒绝或受限", log: logger, type: .error) |
|||
// showPermissionAlert() |
|||
// return false |
|||
// case .notDetermined: |
|||
// // 权限未确定,需要主动触发蓝牙操作来显示权限弹窗 |
|||
// if centralManager == nil { |
|||
// setupCentralManager() |
|||
// } |
|||
|
|||
// // 等待CBCentralManager状态更新后,主动开始扫描来触发权限弹窗 |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in |
|||
// guard let self = self else { return } |
|||
// if self.centralManager.state == .poweredOn { |
|||
// // 开始一个短暂的扫描来触发权限申请 |
|||
// self.centralManager.scanForPeripherals(withServices: nil, options: nil) |
|||
// // 立即停止扫描 |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { |
|||
// self.centralManager.stopScan() |
|||
// } |
|||
// } else if self.centralManager.state == .unauthorized { |
|||
// // 权限被拒绝,显示设置提示 |
|||
// self.showPermissionAlert() |
|||
// } |
|||
// } |
|||
// return true |
|||
// @unknown default: |
|||
// os_log("⚠️ 未知的蓝牙权限状态", log: logger, type: .info) |
|||
// return false |
|||
// } |
|||
// } |
|||
|
|||
// private func waitForBluetoothState(timeout: TimeInterval) { |
|||
// var checkCount = 0 |
|||
// let maxChecks = 20 |
|||
|
|||
// let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in |
|||
// guard let self = self else { |
|||
// timer.invalidate() |
|||
// return |
|||
// } |
|||
|
|||
// checkCount += 1 |
|||
// let currentState = self.centralManager?.state ?? .unknown |
|||
|
|||
// os_log("🔍 检查蓝牙状态 (%d/%d): %{public}@", log: self.logger, type: .info, checkCount, maxChecks, self.getBluetoothStateString(currentState)) |
|||
|
|||
// switch currentState { |
|||
// case .poweredOn: |
|||
// timer.invalidate() |
|||
// _ = self.performScanWithPermission(timeout: timeout) |
|||
// case .unauthorized: |
|||
// timer.invalidate() |
|||
// self.showPermissionAlert() |
|||
// case .poweredOff, .unsupported: |
|||
// timer.invalidate() |
|||
// os_log("❌ 蓝牙不可用", log: self.logger, type: .error) |
|||
// default: |
|||
// if checkCount >= maxChecks { |
|||
// timer.invalidate() |
|||
// os_log("⏰ 等待蓝牙状态超时", log: self.logger, type: .error) |
|||
// } |
|||
// } |
|||
// } |
|||
// } |
|||
|
|||
// private func forceTriggerPermissionRequest(timeout: TimeInterval) { |
|||
// // 完全清理旧的 centralManager |
|||
// centralManager?.delegate = nil |
|||
// centralManager = nil |
|||
|
|||
// // 强制重新初始化,确保触发权限申请 |
|||
// let options: [String: Any] = [ |
|||
// CBCentralManagerOptionShowPowerAlertKey: true, |
|||
// CBCentralManagerOptionRestoreIdentifierKey: "BleScanManager" |
|||
// ] |
|||
|
|||
// centralManager = CBCentralManager(delegate: self, queue: nil, options: options) |
|||
|
|||
// // 设置一个监听器来等待权限状态更新 |
|||
// waitForPermissionResponse(timeout: timeout) |
|||
// } |
|||
|
|||
// private func waitForPermissionResponse(timeout: TimeInterval) { |
|||
// // 设置一个定时器来检查权限状态变化 |
|||
// var checkCount = 0 |
|||
// let maxChecks = 20 // 最多检查20次,每次间隔0.5秒,总共10秒 |
|||
|
|||
// let timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in |
|||
// guard let self = self else { |
|||
// timer.invalidate() |
|||
// return |
|||
// } |
|||
|
|||
// checkCount += 1 |
|||
// let currentAuth = CBManager.authorization |
|||
|
|||
// os_log("🔍 检查权限状态 (%d/%d): %d", log: self.logger, type: .info, checkCount, maxChecks, currentAuth.rawValue) |
|||
|
|||
// switch currentAuth { |
|||
// case .allowedAlways: |
|||
// timer.invalidate() |
|||
// os_log("✅ 蓝牙权限已获得,开始扫描", log: self.logger, type: .info) |
|||
// _ = self.performScanWithPermission(timeout: timeout) |
|||
// case .denied, .restricted: |
|||
// timer.invalidate() |
|||
// os_log("❌ 蓝牙权限被拒绝", log: self.logger, type: .error) |
|||
// self.showPermissionAlert() |
|||
// case .notDetermined: |
|||
// if checkCount >= maxChecks { |
|||
// timer.invalidate() |
|||
// os_log("⏰ 等待权限响应超时", log: self.logger, type: .error) |
|||
// self.showPermissionAlert() |
|||
// } |
|||
// // 继续等待 |
|||
// @unknown default: |
|||
// timer.invalidate() |
|||
// os_log("⚠️ 未知权限状态", log: self.logger, type: .error) |
|||
// } |
|||
// } |
|||
// } |
|||
|
|||
// private func reinitializeCentralManager() { |
|||
// // 清理旧的 centralManager |
|||
// centralManager?.delegate = nil |
|||
// centralManager = nil |
|||
|
|||
// // 重新初始化 |
|||
// setupCentralManager() |
|||
// } |
|||
|
|||
// /// 检查蓝牙权限状态 |
|||
// private func checkBluetoothPermissionStatus() -> (hasPermission: Bool, needsRequest: Bool) { |
|||
// // 检查权限状态 |
|||
// let authorization: CBManagerAuthorization |
|||
// if #available(iOS 13.1, *) { |
|||
// authorization = CBManager.authorization |
|||
// } else if #available(iOS 13.0, *) { |
|||
// if centralManager == nil { |
|||
// setupCentralManager() |
|||
// } |
|||
// authorization = centralManager?.authorization ?? .notDetermined |
|||
// } else { |
|||
// // iOS 13以下没有权限概念 |
|||
// return (true, false) |
|||
// } |
|||
|
|||
// os_log("📊 蓝牙权限状态: %d, CBCentralManager状态: %d", log: logger, type: .info, authorization.rawValue, centralManager?.state.rawValue ?? -1) |
|||
|
|||
// switch authorization { |
|||
// case .allowedAlways: |
|||
// return (true, false) |
|||
// case .denied, .restricted: |
|||
// return (false, false) |
|||
// case .notDetermined: |
|||
// return (false, true) |
|||
// @unknown default: |
|||
// return (false, true) |
|||
// } |
|||
// } |
|||
|
|||
// private func performScanWithPermission(timeout: TimeInterval) -> Bool { |
|||
// // 首先检查权限状态 |
|||
// let permissionStatus = checkBluetoothPermissionStatus() |
|||
|
|||
// if !permissionStatus.hasPermission { |
|||
// if permissionStatus.needsRequest { |
|||
// os_log("🔐 需要申请蓝牙权限", log: logger, type: .info) |
|||
// _ = requestBluetoothPermission() |
|||
// return false |
|||
// } else { |
|||
// os_log("❌ 蓝牙权限被拒绝", log: logger, type: .error) |
|||
// showPermissionAlert() |
|||
// return false |
|||
// } |
|||
// } |
|||
|
|||
// // 权限已获得,检查蓝牙硬件状态 |
|||
// let currentState = centralManager.state |
|||
// os_log("📊 蓝牙硬件状态: %d (%{public}@)", log: logger, type: .info, currentState.rawValue, getBluetoothStateString(currentState)) |
|||
|
|||
// switch currentState { |
|||
// case .poweredOn: |
|||
// // 蓝牙已开启,可以扫描 |
|||
// break |
|||
// case .unknown: |
|||
// os_log("⚠️ 蓝牙状态未知,等待状态更新...", log: logger, type: .info) |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in |
|||
// _ = self?.performScanWithPermission(timeout: timeout) |
|||
// } |
|||
// return false |
|||
// case .poweredOff: |
|||
// // 区分是权限问题还是硬件关闭 |
|||
// if permissionStatus.hasPermission { |
|||
// os_log("❌ 蓝牙硬件已关闭,请在设备设置中开启蓝牙", log: logger, type: .error) |
|||
// } else { |
|||
// os_log("❌ 可能是权限问题导致的蓝牙状态异常", log: logger, type: .error) |
|||
// _ = requestBluetoothPermission() |
|||
// } |
|||
// return false |
|||
// case .unauthorized: |
|||
// os_log("❌ 应用未授权使用蓝牙", log: logger, type: .error) |
|||
// showPermissionAlert() |
|||
// return false |
|||
// case .unsupported: |
|||
// os_log("❌ 设备不支持蓝牙", log: logger, type: .error) |
|||
// return false |
|||
// default: |
|||
// os_log("⚠️ 其他蓝牙状态: %d", log: logger, type: .error, currentState.rawValue) |
|||
// return false |
|||
// } |
|||
|
|||
// // 开始BLE扫描 |
|||
// centralManager.scanForPeripherals(withServices: nil, options: [ |
|||
// CBCentralManagerScanOptionAllowDuplicatesKey: true |
|||
// ]) |
|||
|
|||
// isScanning = true |
|||
|
|||
// // 设置扫描超时 |
|||
// scanTimeout = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in |
|||
// self?.stopScan() |
|||
// } |
|||
|
|||
// os_log("✅ 开始BLE扫描,超时时间: %.1f 秒", log: logger, type: .info, timeout) |
|||
// return true |
|||
// } |
|||
|
|||
// private func showPermissionAlert() { |
|||
// guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, |
|||
// let window = windowScene.windows.first else { |
|||
// os_log("❌ 无法获取当前窗口", log: logger, type: .error) |
|||
// return |
|||
// } |
|||
|
|||
// let alert = UIAlertController( |
|||
// title: "需要蓝牙权限", |
|||
// message: "请在设置中开启蓝牙权限以使用设备扫描功能", |
|||
// preferredStyle: .alert |
|||
// ) |
|||
|
|||
// alert.addAction(UIAlertAction(title: "去设置", style: .default) { _ in |
|||
// if let settingsUrl = URL(string: UIApplication.openSettingsURLString) { |
|||
// UIApplication.shared.open(settingsUrl) |
|||
// } |
|||
// }) |
|||
|
|||
// alert.addAction(UIAlertAction(title: "取消", style: .cancel)) |
|||
|
|||
// window.rootViewController?.present(alert, animated: true) |
|||
// } |
|||
|
|||
// /// 停止扫描 |
|||
// public func stopScan() { |
|||
// guard isScanning else { return } |
|||
|
|||
// os_log("🛑 停止 BLE 扫描", log: logger, type: .info) |
|||
|
|||
// centralManager.stopScan() // 直接使用,不需要可选绑定 |
|||
// isScanning = false |
|||
|
|||
// scanTimeout?.invalidate() |
|||
// scanTimeout = nil |
|||
// } |
|||
|
|||
// /// 获取已发现的设备列表 |
|||
// /// - Returns: 按最后发现时间排序的设备列表 |
|||
// public func getDiscoveredDevices() -> [BleDeviceInfo] { |
|||
// return Array(discoveredDevices.values).sorted { $0.lastSeen > $1.lastSeen } |
|||
// } |
|||
|
|||
// /// 清除设备列表 |
|||
// public func clearDeviceList() { |
|||
// discoveredDevices.removeAll() |
|||
// os_log("🗑️ 已清除设备列表", log: logger, type: .info) |
|||
// } |
|||
|
|||
// /// 检查是否正在扫描 |
|||
// public func isScanningActive() -> Bool { |
|||
// return isScanning |
|||
// } |
|||
|
|||
// /// 获取蓝牙状态 |
|||
// public func getBluetoothState() -> CBManagerState { |
|||
// return centralManager.state // 直接返回,不需要 ?? .unknown |
|||
// } |
|||
|
|||
// // MARK: - 私有方法 |
|||
|
|||
|
|||
|
|||
// private func addOrUpdateDevice(_ deviceInfo: BleDeviceInfo) { |
|||
// let identifier = deviceInfo.identifier |
|||
// discoveredDevices[identifier] = deviceInfo |
|||
|
|||
// os_log("📱 发现设备: %{public}@", log: logger, type: .debug, deviceInfo.name ?? "Unknown") |
|||
// } |
|||
|
|||
// /// 强制触发蓝牙权限申请 |
|||
// public func requestBluetoothPermission() -> Bool { |
|||
// os_log("🔐 强制触发蓝牙权限申请", log: logger, type: .info) |
|||
|
|||
// // 检查当前权限状态 |
|||
// let authorization: CBManagerAuthorization |
|||
// if #available(iOS 13.1, *) { |
|||
// authorization = CBManager.authorization |
|||
// } else if #available(iOS 13.0, *) { |
|||
// // 确保centralManager已初始化 |
|||
// if centralManager == nil { |
|||
// setupCentralManager() |
|||
// } |
|||
// authorization = centralManager.authorization |
|||
// } else { |
|||
// // iOS 13以下没有权限概念,直接返回true |
|||
// return true |
|||
// } |
|||
|
|||
// os_log("📊 当前权限状态: %d", log: logger, type: .info, authorization.rawValue) |
|||
|
|||
// switch authorization { |
|||
// case .notDetermined: |
|||
// // 权限未确定,需要主动触发蓝牙操作来显示权限弹窗 |
|||
// if centralManager == nil { |
|||
// os_log("📊 初始化蓝牙管理对象", log: logger, type: .info, "") |
|||
// setupCentralManager() |
|||
// } |
|||
|
|||
// // 等待CBCentralManager状态更新后,主动开始扫描来触发权限弹窗 |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in |
|||
// guard let self = self else { return } |
|||
|
|||
// if self.centralManager.state == .poweredOn { |
|||
// // 开始一个短暂的扫描来触发权限申请 |
|||
// self.centralManager.scanForPeripherals(withServices: nil, options: nil) |
|||
// // 立即停止扫描 |
|||
// DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { |
|||
// self.centralManager.stopScan() |
|||
// } |
|||
// } else if self.centralManager.state == .unauthorized { |
|||
// // 权限被拒绝,显示设置提示 |
|||
// self.showPermissionAlert() |
|||
// } |
|||
// } |
|||
// return true |
|||
// case .allowedAlways: |
|||
// os_log("✅ 权限已授予", log: logger, type: .info) |
|||
// return true |
|||
// case .denied, .restricted: |
|||
// os_log("❌ 权限被拒绝,显示设置提示", log: logger, type: .error) |
|||
// showPermissionAlert() |
|||
// return false |
|||
// @unknown default: |
|||
// os_log("⚠️ 未知权限状态", log: logger, type: .info) |
|||
// return false |
|||
// } |
|||
// } |
|||
// } |
|||
|
|||
// // MARK: - CBCentralManagerDelegate |
|||
// @available(iOS 13.0, *) |
|||
// extension BleScanManager: CBCentralManagerDelegate { |
|||
|
|||
// public func centralManagerDidUpdateState(_ central: CBCentralManager) { |
|||
// let stateString = getBluetoothStateString(central.state) |
|||
// os_log("🔧 CBCentralManager 状态更新: %{public}@ (原始值: %d)", log: logger, type: .info, stateString, central.state.rawValue) |
|||
|
|||
// switch central.state { |
|||
// case .poweredOn: |
|||
// os_log("✅ 蓝牙已开启,可以开始扫描", log: logger, type: .info) |
|||
// // 如果有待处理的扫描请求,现在可以执行 |
|||
// case .poweredOff: |
|||
// os_log("❌ 蓝牙已关闭", log: logger, type: .error) |
|||
// if isScanning { |
|||
// stopScan() |
|||
// } |
|||
// case .unauthorized: |
|||
// os_log("❌ 蓝牙权限被拒绝", log: logger, type: .error) |
|||
// if isScanning { |
|||
// stopScan() |
|||
// } |
|||
// case .unsupported: |
|||
// os_log("❌ 设备不支持蓝牙", log: logger, type: .error) |
|||
// if isScanning { |
|||
// stopScan() |
|||
// } |
|||
// case .unknown: |
|||
// os_log("⚠️ 蓝牙状态未知,等待进一步更新", log: logger, type: .error) |
|||
// case .resetting: |
|||
// os_log("⚠️ 蓝牙正在重置", log: logger, type: .error) |
|||
// if isScanning { |
|||
// stopScan() |
|||
// } |
|||
// @unknown default: |
|||
// os_log("⚠️ 未知蓝牙状态: %d", log: logger, type: .error, central.state.rawValue) |
|||
// } |
|||
// } |
|||
|
|||
// public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, |
|||
// advertisementData: [String : Any], rssi RSSI: NSNumber) { |
|||
|
|||
// let deviceInfo = BleDeviceInfo( |
|||
// identifier: peripheral.identifier, |
|||
// name: peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String, |
|||
// rssi: RSSI, |
|||
// advertisementData: advertisementData, |
|||
// discoveryMethod: .traditional, |
|||
// lastSeen: Date() |
|||
// ) |
|||
|
|||
// addOrUpdateDevice(deviceInfo) |
|||
// } |
|||
|
|||
// public func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) { |
|||
// os_log("🔄 恢复 CBCentralManager 状态", log: logger, type: .info) |
|||
|
|||
// // 恢复扫描状态 |
|||
// if dict[CBCentralManagerRestoredStateScanServicesKey] != nil { |
|||
// isScanning = true |
|||
// } |
|||
// } |
|||
|
|||
// private func getBluetoothStateString(_ state: CBManagerState) -> String { |
|||
// switch state { |
|||
// case .unknown: return "未知状态" |
|||
// case .resetting: return "重置中" |
|||
// case .unsupported: return "设备不支持蓝牙" |
|||
// case .unauthorized: return "应用未授权使用蓝牙" |
|||
// case .poweredOff: return "蓝牙已关闭" |
|||
// case .poweredOn: return "蓝牙已开启" |
|||
// @unknown default: return "未知状态" |
|||
// } |
|||
// } |
|||
// } |
|||
@ -1,38 +0,0 @@ |
|||
.idea/ |
|||
.vagrant/ |
|||
.sconsign.dblite |
|||
.svn/ |
|||
|
|||
.DS_Store |
|||
*.swp |
|||
profile |
|||
|
|||
DerivedData/ |
|||
build/ |
|||
GeneratedPluginRegistrant.h |
|||
GeneratedPluginRegistrant.m |
|||
|
|||
.generated/ |
|||
|
|||
*.pbxuser |
|||
*.mode1v3 |
|||
*.mode2v3 |
|||
*.perspectivev3 |
|||
|
|||
!default.pbxuser |
|||
!default.mode1v3 |
|||
!default.mode2v3 |
|||
!default.perspectivev3 |
|||
|
|||
xcuserdata |
|||
|
|||
*.moved-aside |
|||
|
|||
*.pyc |
|||
*sync/ |
|||
Icon? |
|||
.tags* |
|||
|
|||
/Flutter/Generated.xcconfig |
|||
/Flutter/ephemeral/ |
|||
/Flutter/flutter_export_environment.sh |
|||
@ -1,131 +0,0 @@ |
|||
import Foundation |
|||
import AMapLocationKit |
|||
|
|||
extension Notification.Name { |
|||
static let locationstartEvent = Notification.Name("locationstartEvent") //启动定位 |
|||
static let updatelocationEvent = Notification.Name("updatelocationEvent") //更新位置事件 |
|||
} |
|||
class LocationService: NSObject { |
|||
static let shared = LocationService() |
|||
private let TAG = "LocationService" |
|||
|
|||
private var locationManager: AMapLocationManager! |
|||
private var currentLocation: CLLocation? |
|||
private var currentReGeocode: AMapLocationReGeocode? |
|||
private var initialized = false |
|||
|
|||
private override init() { |
|||
super.init() |
|||
} |
|||
|
|||
// 初始化定位服务 |
|||
func initialize() { |
|||
if initialized { return } |
|||
print("\(TAG)--------- 初始化高德定位服务") |
|||
|
|||
locationManager = AMapLocationManager() |
|||
locationManager.delegate = self |
|||
|
|||
locationManager.desiredAccuracy = kCLLocationAccuracyBest // 定位精度 |
|||
locationManager.distanceFilter = 10.0 // 移动距离阈值(单位:米) |
|||
locationManager.locatingWithReGeocode = true // 是否返回逆地理编码信息 |
|||
locationManager.allowsBackgroundLocationUpdates = true // 是否允许后台定位 |
|||
locationManager.locationTimeout = 10 // 定位超时时间(秒) |
|||
locationManager.reGeocodeTimeout = 5 // 逆地理编码超时时间(秒) |
|||
|
|||
locationManager.startUpdatingLocation() |
|||
|
|||
initialized = true |
|||
} |
|||
|
|||
// 获取缓存的位置信息 |
|||
func getCachedLocation() -> [String: String]? { |
|||
guard let location = currentLocation else { return nil } |
|||
|
|||
var locationInfo: [String: String] = [ |
|||
"user_lat": "\(location.coordinate.latitude)", |
|||
"user_lng": "\(location.coordinate.longitude)" |
|||
] |
|||
|
|||
if let regeocode = currentReGeocode { |
|||
let address = [ |
|||
regeocode.province, // 省 |
|||
regeocode.city, // 市 |
|||
regeocode.district, // 区 |
|||
regeocode.street, // 街道 |
|||
regeocode.number // 门牌号 |
|||
].compactMap { $0 }.joined(separator: "") |
|||
|
|||
locationInfo["user_location"] = address |
|||
} |
|||
|
|||
return locationInfo |
|||
} |
|||
|
|||
// 获取当前位置(带回调) |
|||
func getCurrentLocation(completion: @escaping ([String: String]?, Error?) -> Void) { |
|||
if let cached = getCachedLocation() { |
|||
completion(cached, nil) |
|||
} else { |
|||
let error = NSError( |
|||
domain: "LocationService", |
|||
code: -1, |
|||
userInfo: [NSLocalizedDescriptionKey: "当前位置还未获取"] |
|||
) |
|||
completion(nil, error) |
|||
} |
|||
} |
|||
|
|||
// 停止定位服务 |
|||
func stop() { |
|||
locationManager.stopUpdatingLocation() |
|||
locationManager = nil |
|||
currentLocation = nil |
|||
currentReGeocode = nil |
|||
initialized = false |
|||
} |
|||
} |
|||
|
|||
// MARK: - AMapLocationManagerDelegate |
|||
extension LocationService: AMapLocationManagerDelegate { |
|||
// 定位更新回调 |
|||
func amapLocationManager(_ manager: AMapLocationManager!, didUpdate location: CLLocation!, reGeocode: AMapLocationReGeocode!) { |
|||
currentLocation = location |
|||
currentReGeocode = reGeocode |
|||
|
|||
print("\(TAG)--------- 定位更新: \(location.coordinate.latitude), \(location.coordinate.longitude)") |
|||
|
|||
var locationInfo: [String: String] = [ |
|||
"user_lat": "\(location.coordinate.latitude)", |
|||
"user_lng": "\(location.coordinate.longitude)" |
|||
] |
|||
if let regeocode = currentReGeocode { |
|||
let address = [ |
|||
reGeocode.province, // 省 |
|||
reGeocode.city, // 市 |
|||
reGeocode.district, // 区 |
|||
reGeocode.street, // 街道 |
|||
reGeocode.number // 门牌号 |
|||
].compactMap { $0 }.joined(separator: "") |
|||
print("\(TAG)--------- 定位地址: \(address)") |
|||
locationInfo["user_location"] = address |
|||
} |
|||
|
|||
|
|||
NotificationCenter.default.post( |
|||
name: .updatelocationEvent, |
|||
object: nil, |
|||
userInfo: locationInfo |
|||
) |
|||
} |
|||
|
|||
// 定位失败回调 |
|||
func amapLocationManager(_ manager: AMapLocationManager!, didFailWithError error: Error!) { |
|||
print("\(TAG)--------- 定位失败: \(error.localizedDescription)") |
|||
} |
|||
|
|||
// 权限变更回调 |
|||
func amapLocationManager(_ manager: AMapLocationManager!, doRequireLocationAuth locationManager: CLLocationManager!) { |
|||
locationManager.requestWhenInUseAuthorization() |
|||
} |
|||
} |
|||
@ -1,44 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
import AMapLocationKit |
|||
|
|||
public class LocationServicePlugin: NSObject, FlutterPlugin { |
|||
private static var channel: FlutterMethodChannel? |
|||
|
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.location_service", binaryMessenger: registrar.messenger()) |
|||
let instance = LocationServicePlugin() |
|||
// 必须先设置隐私合规 |
|||
DispatchQueue.main.async { |
|||
// 2. API Key |
|||
AMapServices.shared()?.apiKey = "aa60a03be9f6531bcc64029e60f95d03" |
|||
AMapServices.shared().enableHTTPS = true |
|||
// 3. 打印验证(调试用) |
|||
print("高德SDK初始化完成") |
|||
} |
|||
registrar.addMethodCallDelegate(instance, channel: channel) |
|||
self.channel = channel |
|||
instance.addstartlocationmonitor() |
|||
} |
|||
|
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "location": |
|||
result(nil) |
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
private func addstartlocationmonitor(){ |
|||
// 通常放在启动后或模块初始化时 |
|||
NotificationCenter.default.addObserver( |
|||
forName: .locationstartEvent, |
|||
object: nil, |
|||
queue: .main |
|||
) { notification in |
|||
print("📥 收到 启动定位服务事件") |
|||
LocationService.shared.initialize() |
|||
} |
|||
} |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint location_service.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'location_service' |
|||
s.version = '0.0.1' |
|||
s.summary = 'A new Flutter project.' |
|||
s.description = <<-DESC |
|||
A new Flutter project. |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'Classes/**/*' |
|||
s.dependency 'Flutter' |
|||
s.dependency 'AMapLocation', '~> 2.10.0' |
|||
|
|||
s.static_framework = true |
|||
s.pod_target_xcconfig = { |
|||
'DEFINES_MODULE' => 'YES', |
|||
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' |
|||
} |
|||
s.platform = :ios, '12.0' |
|||
s.swift_version = '5.0' |
|||
end |
|||
@ -0,0 +1,21 @@ |
|||
// swift-tools-version: 5.9 |
|||
import PackageDescription |
|||
|
|||
let package = Package( |
|||
name: "location_service", |
|||
platforms: [ |
|||
.iOS(.v16), |
|||
.macOS(.v13) |
|||
], |
|||
products: [ |
|||
.library(name: "location-service", targets: ["location_service"]) |
|||
], |
|||
dependencies: [ ], |
|||
targets: [ |
|||
.target( |
|||
name: "location_service", |
|||
dependencies: [], |
|||
path: "Sources/location_service" |
|||
) |
|||
] |
|||
) |
|||
@ -0,0 +1,7 @@ |
|||
import Foundation |
|||
|
|||
/// 定位回调协议 |
|||
public protocol LocationCallback: AnyObject { |
|||
func onLocationSuccess(latitude: Double, longitude: Double, address: String) |
|||
func onLocationError(errorCode: Int, errorMessage: String) |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
import Foundation |
|||
|
|||
/// 位置数据结构 |
|||
public struct LocationData: Codable { |
|||
public let latitude: Double |
|||
public let longitude: Double |
|||
public let address: String |
|||
public let timestamp: TimeInterval |
|||
|
|||
public init(latitude: Double, longitude: Double, address: String, timestamp: TimeInterval) { |
|||
self.latitude = latitude |
|||
self.longitude = longitude |
|||
self.address = address |
|||
self.timestamp = timestamp |
|||
} |
|||
} |
|||
@ -0,0 +1,357 @@ |
|||
import Foundation |
|||
import CoreLocation |
|||
import UIKit |
|||
|
|||
public extension Notification.Name { |
|||
static let locationStartEvent = Notification.Name("locationStartEvent") |
|||
static let updateLocationEvent = Notification.Name("updateLocationEvent") |
|||
} |
|||
|
|||
public class LocationService: NSObject { |
|||
private static let TAG = "LocationService" |
|||
private static let CACHE_PREFS_NAME = "location_cache" |
|||
private static let PERMISSION_REQUEST_CODE = 1001 |
|||
|
|||
public static let shared = LocationService() |
|||
|
|||
private var locationManager: CLLocationManager? |
|||
private var initialized = false |
|||
private var permissionRetryTimer: Timer? |
|||
|
|||
// 存储当前的定位请求回调(Flutter用) |
|||
private var currentLocationCallback: ((Result<[String: Any], Error>) -> Void)? |
|||
// 存储权限请求回调(Flutter用) |
|||
private var permissionRequestCallback: ((Result<[String: Bool], Error>) -> Void)? |
|||
|
|||
// 存储服务间调用的回调 |
|||
private var serviceCallbacks: [LocationCallback] = [] |
|||
|
|||
// 缓存的位置数据 |
|||
private var cachedLocationData: LocationData? |
|||
|
|||
private override init() { |
|||
super.init() |
|||
} |
|||
|
|||
/// 检查应用是否在前台运行 |
|||
public func isAppInForeground() -> Bool { |
|||
return UIApplication.shared.applicationState == .active |
|||
} |
|||
|
|||
/// 初始化定位服务 |
|||
public func initialize() { |
|||
if initialized { return } |
|||
|
|||
if !checkPermissions() { |
|||
print("\(LocationService.TAG): 没有定位权限,等待用户授权中...") |
|||
startPermissionMonitor() |
|||
return |
|||
} |
|||
|
|||
setupLocationManager() |
|||
initialized = true |
|||
print("\(LocationService.TAG): 定位服务初始化完成") |
|||
} |
|||
|
|||
private func setupLocationManager() { |
|||
locationManager = CLLocationManager() |
|||
locationManager?.delegate = self |
|||
locationManager?.desiredAccuracy = kCLLocationAccuracyBest |
|||
locationManager?.distanceFilter = 10 |
|||
} |
|||
|
|||
private func startPermissionMonitor() { |
|||
permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in |
|||
guard let self = self else { return } |
|||
let hasPermission = self.checkPermissions() |
|||
if hasPermission && !self.initialized { |
|||
print("\(LocationService.TAG): 权限已授予,重新初始化定位服务") |
|||
self.initialize() |
|||
self.stopPermissionMonitor() |
|||
} |
|||
} |
|||
} |
|||
|
|||
private func stopPermissionMonitor() { |
|||
permissionRetryTimer?.invalidate() |
|||
permissionRetryTimer = nil |
|||
} |
|||
|
|||
/// 检查定位权限 |
|||
private func checkPermissions() -> Bool { |
|||
return hasForegroundLocationPermission() |
|||
} |
|||
|
|||
private func hasForegroundLocationPermission() -> Bool { |
|||
let status = CLLocationManager.authorizationStatus() |
|||
return status == .authorizedWhenInUse || status == .authorizedAlways |
|||
} |
|||
|
|||
private func hasBackgroundLocationPermission() -> Bool { |
|||
return CLLocationManager.authorizationStatus() == .authorizedAlways |
|||
} |
|||
|
|||
/// 检查定位权限状态(Flutter用) |
|||
public func checkLocationPermission(completion: @escaping (Result<[String: Bool], Error>) -> Void) { |
|||
let hasPermission = checkPermissions() |
|||
let result: [String: Bool] = [ |
|||
"hasPermission": hasPermission, |
|||
"hasForegroundPermission": hasForegroundLocationPermission(), |
|||
"hasBackgroundPermission": hasBackgroundLocationPermission() |
|||
] |
|||
completion(.success(result)) |
|||
} |
|||
|
|||
/// 请求定位权限(Flutter用) |
|||
public func requestLocationPermission(completion: @escaping (Result<[String: Bool], Error>) -> Void) { |
|||
if checkPermissions() { |
|||
completion(.success(["granted": true])) |
|||
return |
|||
} |
|||
|
|||
permissionRequestCallback = completion |
|||
locationManager?.requestWhenInUseAuthorization() |
|||
} |
|||
|
|||
/// 按需获取当前位置(Flutter用) |
|||
public func getCurrentLocation(completion: @escaping (Result<[String: Any], Error>) -> Void) { |
|||
if !checkPermissions() { |
|||
completion(.failure(NSError(domain: "LocationService", code: -2, userInfo: [NSLocalizedDescriptionKey: "需要定位权限"]))) |
|||
return |
|||
} |
|||
|
|||
if !initialized { |
|||
completion(.failure(NSError(domain: "LocationService", code: -3, userInfo: [NSLocalizedDescriptionKey: "定位服务未初始化"]))) |
|||
return |
|||
} |
|||
|
|||
if currentLocationCallback != nil { |
|||
completion(.failure(NSError(domain: "LocationService", code: -4, userInfo: [NSLocalizedDescriptionKey: "正在定位中,请稍后再试"]))) |
|||
return |
|||
} |
|||
|
|||
// 保存回调,开始定位 |
|||
currentLocationCallback = completion |
|||
locationManager?.requestLocation() |
|||
} |
|||
|
|||
// MARK: - 供其他iOS服务使用的接口 |
|||
|
|||
/// 检查是否有定位权限(供其他服务使用) |
|||
public func hasLocationPermission() -> Bool { |
|||
return checkPermissions() |
|||
} |
|||
|
|||
/// 异步获取当前位置(供其他iOS服务使用) |
|||
public func getCurrentLocation() async throws -> LocationData { |
|||
return try await withCheckedThrowingContinuation { continuation in |
|||
getCurrentLocation { result in |
|||
switch result { |
|||
case .success(let locationInfo): |
|||
// 从返回的字典中提取位置信息 |
|||
if let latStr = locationInfo["user_lat"] as? String, |
|||
let lngStr = locationInfo["user_lng"] as? String, |
|||
let latitude = Double(latStr), |
|||
let longitude = Double(lngStr), |
|||
let address = locationInfo["user_location"] as? String { |
|||
|
|||
let locationData = LocationData( |
|||
latitude: latitude, |
|||
longitude: longitude, |
|||
address: address, |
|||
timestamp: Date().timeIntervalSince1970 * 1000 |
|||
) |
|||
continuation.resume(returning: locationData) |
|||
} else { |
|||
continuation.resume(throwing: NSError(domain: "LocationService", code: -5, userInfo: [NSLocalizedDescriptionKey: "位置数据格式错误"])) |
|||
} |
|||
case .failure(let error): |
|||
continuation.resume(throwing: error) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 获取当前位置(供其他iOS服务使用) |
|||
public func requestLocation(callback: LocationCallback) { |
|||
if !checkPermissions() { |
|||
callback.onLocationError(errorCode: -2, errorMessage: "没有定位权限") |
|||
return |
|||
} |
|||
|
|||
if !initialized { |
|||
callback.onLocationError(errorCode: -3, errorMessage: "定位服务未初始化") |
|||
return |
|||
} |
|||
|
|||
// 添加到回调列表 |
|||
serviceCallbacks.append(callback) |
|||
|
|||
// 如果没有正在进行的定位请求,开始定位 |
|||
if currentLocationCallback == nil && serviceCallbacks.count == 1 { |
|||
locationManager?.requestLocation() |
|||
} |
|||
} |
|||
|
|||
/// 同步获取缓存的位置信息(供其他服务使用) |
|||
/// 注意:这个方法返回的可能是过期的位置信息 |
|||
public func getCachedLocationData() -> LocationData? { |
|||
// 优先返回内存中的缓存 |
|||
if let cached = cachedLocationData { |
|||
return cached |
|||
} |
|||
|
|||
// 从UserDefaults中读取缓存 |
|||
return loadCachedLocationFromUserDefaults() |
|||
} |
|||
|
|||
private func saveCachedLocationToUserDefaults(_ locationData: LocationData) { |
|||
do { |
|||
let encoder = JSONEncoder() |
|||
let data = try encoder.encode(locationData) |
|||
UserDefaults.standard.set(data, forKey: "latitude") |
|||
UserDefaults.standard.set(String(locationData.latitude), forKey: "latitude") |
|||
UserDefaults.standard.set(String(locationData.longitude), forKey: "longitude") |
|||
UserDefaults.standard.set(locationData.address, forKey: "address") |
|||
UserDefaults.standard.set(Double(locationData.timestamp), forKey: "timestamp") |
|||
UserDefaults.standard.synchronize() |
|||
} catch { |
|||
print("\(LocationService.TAG): 保存位置缓存失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
|
|||
private func loadCachedLocationFromUserDefaults() -> LocationData? { |
|||
do { |
|||
guard let latitudeStr = UserDefaults.standard.string(forKey: "latitude"), |
|||
let longitudeStr = UserDefaults.standard.string(forKey: "longitude"), |
|||
let latitude = Double(latitudeStr), |
|||
let longitude = Double(longitudeStr) else { |
|||
return nil |
|||
} |
|||
|
|||
let address = UserDefaults.standard.string(forKey: "address") ?? "未知地址" |
|||
let timestampDouble = UserDefaults.standard.double(forKey: "timestamp") |
|||
|
|||
if timestampDouble == 0 { return nil } |
|||
|
|||
return LocationData( |
|||
latitude: latitude, |
|||
longitude: longitude, |
|||
address: address, |
|||
timestamp: TimeInterval(timestampDouble) |
|||
) |
|||
} catch { |
|||
print("\(LocationService.TAG): 读取位置缓存失败: \(error.localizedDescription)") |
|||
return nil |
|||
} |
|||
} |
|||
|
|||
/// 停止定位服务 |
|||
public func stop() { |
|||
locationManager?.stopUpdatingLocation() |
|||
locationManager = nil |
|||
currentLocationCallback = nil |
|||
permissionRequestCallback = nil |
|||
serviceCallbacks.removeAll() |
|||
initialized = false |
|||
stopPermissionMonitor() |
|||
} |
|||
|
|||
private func handleLocationResult(_ location: CLLocation?, error: Error?) { |
|||
// 处理Flutter回调 |
|||
let flutterCallback = currentLocationCallback |
|||
currentLocationCallback = nil |
|||
|
|||
if let location = location, error == nil { |
|||
print("\(LocationService.TAG): 定位成功: \(location.coordinate.latitude), \(location.coordinate.longitude)") |
|||
|
|||
// 进行逆地理编码获取地址 |
|||
let geocoder = CLGeocoder() |
|||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, geocodeError in |
|||
guard let self = self else { return } |
|||
|
|||
let address = placemarks?.first?.name ?? "未知地址" |
|||
|
|||
// 缓存位置数据 |
|||
let locationData = LocationData( |
|||
latitude: location.coordinate.latitude, |
|||
longitude: location.coordinate.longitude, |
|||
address: address, |
|||
timestamp: TimeInterval(Date().timeIntervalSince1970) * 1000 |
|||
) |
|||
self.cachedLocationData = locationData |
|||
self.saveCachedLocationToUserDefaults(locationData) |
|||
|
|||
// 通知Flutter |
|||
if let callback = flutterCallback { |
|||
let result: [String: Any] = [ |
|||
"user_location": address, |
|||
"user_lat": String(location.coordinate.latitude), |
|||
"user_lng": String(location.coordinate.longitude) |
|||
] |
|||
callback(.success(result)) |
|||
} |
|||
|
|||
// 通知其他iOS服务 |
|||
self.serviceCallbacks.forEach { callback in |
|||
callback.onLocationSuccess( |
|||
latitude: location.coordinate.latitude, |
|||
longitude: location.coordinate.longitude, |
|||
address: address |
|||
) |
|||
} |
|||
|
|||
// 清空服务回调列表(单次定位) |
|||
self.serviceCallbacks.removeAll() |
|||
} |
|||
} else { |
|||
let errorMessage = error?.localizedDescription ?? "未知错误" |
|||
print("\(LocationService.TAG): 定位失败: \(errorMessage)") |
|||
|
|||
// 通知Flutter |
|||
if let callback = flutterCallback { |
|||
callback(.failure(NSError(domain: "LocationService", code: -1, userInfo: [NSLocalizedDescriptionKey: "定位失败: \(errorMessage)"]))) |
|||
} |
|||
|
|||
// 通知其他iOS服务 |
|||
serviceCallbacks.forEach { callback in |
|||
callback.onLocationError(errorCode: -1, errorMessage: errorMessage) |
|||
} |
|||
|
|||
// 清空服务回调列表 |
|||
serviceCallbacks.removeAll() |
|||
} |
|||
} |
|||
} |
|||
|
|||
// MARK: - CLLocationManagerDelegate |
|||
extension LocationService: CLLocationManagerDelegate { |
|||
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { |
|||
guard let location = locations.last else { return } |
|||
handleLocationResult(location, error: nil) |
|||
} |
|||
|
|||
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { |
|||
handleLocationResult(nil, error: error) |
|||
} |
|||
|
|||
public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { |
|||
print("\(LocationService.TAG): 定位权限状态变更: \(status.rawValue)") |
|||
|
|||
switch status { |
|||
case .authorizedWhenInUse, .authorizedAlways: |
|||
if !initialized { |
|||
initialize() |
|||
} |
|||
permissionRequestCallback?(.success(["granted": true])) |
|||
permissionRequestCallback = nil |
|||
case .denied, .restricted: |
|||
permissionRequestCallback?(.success(["granted": false])) |
|||
permissionRequestCallback = nil |
|||
case .notDetermined: |
|||
break |
|||
@unknown default: |
|||
break |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,72 @@ |
|||
import Foundation |
|||
|
|||
#if canImport(Flutter) |
|||
import Flutter |
|||
import UIKit |
|||
|
|||
public class LocationServicePlugin: NSObject, FlutterPlugin { |
|||
private static var channel: FlutterMethodChannel? |
|||
|
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let channel = FlutterMethodChannel(name: "com.yunqiinnovation.location_service", binaryMessenger: registrar.messenger()) |
|||
let instance = LocationServicePlugin() |
|||
|
|||
registrar.addMethodCallDelegate(instance, channel: channel) |
|||
self.channel = channel |
|||
instance.addStartLocationMonitor() |
|||
} |
|||
|
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "location": |
|||
result(nil) |
|||
case "initialize": |
|||
LocationService.shared.initialize() |
|||
result(nil) |
|||
case "getCurrentLocation": |
|||
LocationService.shared.getCurrentLocation { locationResult in |
|||
switch locationResult { |
|||
case .success(let locationInfo): |
|||
result(locationInfo) |
|||
case .failure(let error): |
|||
result(FlutterError(code: "LOCATION_ERROR", message: error.localizedDescription, details: nil)) |
|||
} |
|||
} |
|||
case "checkPermission": |
|||
LocationService.shared.checkLocationPermission { permissionResult in |
|||
switch permissionResult { |
|||
case .success(let permissions): |
|||
result(permissions) |
|||
case .failure(let error): |
|||
result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) |
|||
} |
|||
} |
|||
case "requestPermission": |
|||
LocationService.shared.requestLocationPermission { permissionResult in |
|||
switch permissionResult { |
|||
case .success(let permissions): |
|||
result(permissions) |
|||
case .failure(let error): |
|||
result(FlutterError(code: "PERMISSION_ERROR", message: error.localizedDescription, details: nil)) |
|||
} |
|||
} |
|||
case "stop": |
|||
LocationService.shared.stop() |
|||
result(nil) |
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
private func addStartLocationMonitor() { |
|||
NotificationCenter.default.addObserver( |
|||
forName: .locationStartEvent, |
|||
object: nil, |
|||
queue: .main |
|||
) { notification in |
|||
print("📥 收到启动定位服务事件") |
|||
LocationService.shared.initialize() |
|||
} |
|||
} |
|||
} |
|||
#endif |
|||
@ -1,29 +0,0 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# Run `pod lib lint user_portrait.podspec` to validate before publishing. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'user_portrait' |
|||
s.version = '0.0.1' |
|||
s.summary = '用户画像' |
|||
s.description = <<-DESC |
|||
用户画像 |
|||
DESC |
|||
s.homepage = 'http://example.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'Your Company' => 'email@example.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'user_portrait/Sources/user_portrait/**/*' |
|||
s.dependency 'Flutter' |
|||
s.platform = :ios, '12.0' |
|||
|
|||
# Flutter.framework does not contain a i386 slice. |
|||
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } |
|||
s.swift_version = '5.0' |
|||
|
|||
# If your plugin requires a privacy manifest, for example if it uses any |
|||
# required reason APIs, update the PrivacyInfo.xcprivacy file to describe your |
|||
# plugin's privacy impact, and then uncomment this line. For more information, |
|||
# see https://developer.apple.com/documentation/bundleresources/privacy_manifest_files |
|||
# s.resource_bundles = {'user_portrait_privacy' => ['user_portrait/Sources/user_portrait/PrivacyInfo.xcprivacy']} |
|||
end |
|||
@ -1,14 +0,0 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|||
<plist version="1.0"> |
|||
<dict> |
|||
<key>NSPrivacyTrackingDomains</key> |
|||
<array/> |
|||
<key>NSPrivacyAccessedAPITypes</key> |
|||
<array/> |
|||
<key>NSPrivacyCollectedDataTypes</key> |
|||
<array/> |
|||
<key>NSPrivacyTracking</key> |
|||
<false/> |
|||
</dict> |
|||
</plist> |
|||
Loading…
Reference in new issue