78 changed files with 5782 additions and 4276 deletions
Binary file not shown.
@ -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), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,164 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
class SecondLevelBottomSheet extends StatefulWidget { |
||||
|
final String title; |
||||
|
final String subtitle; |
||||
|
final Function(int index) onTap; |
||||
|
final List children; |
||||
|
|
||||
|
const SecondLevelBottomSheet( |
||||
|
this.title, this.subtitle, this.onTap, this.children, |
||||
|
{super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<SecondLevelBottomSheet> createState() => _SecondLevelBottomSheetState(); |
||||
|
} |
||||
|
|
||||
|
class _SecondLevelBottomSheetState extends State<SecondLevelBottomSheet> { |
||||
|
int _selectedIndex = 0; |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: EdgeInsets.symmetric(vertical: 10.w), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
widget.title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.black87, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
GestureDetector( |
||||
|
onTap: () => Get.back(), |
||||
|
child: Container( |
||||
|
width: 26.w, |
||||
|
height: 26.w, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(26.r), |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.close, |
||||
|
size: 18.w, |
||||
|
color: Colors.grey[400], |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
Divider( |
||||
|
height: 1, |
||||
|
color: Colors.grey[300], |
||||
|
), |
||||
|
Padding( |
||||
|
padding: EdgeInsetsGeometry.only( |
||||
|
top: 20.w, |
||||
|
bottom: 10.w, |
||||
|
left: 12.w, |
||||
|
), |
||||
|
child: Text( |
||||
|
widget.subtitle, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.grey[600], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
clipBehavior: Clip.hardEdge, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
child: Column( |
||||
|
children: List.generate(widget.children.length, (int index) { |
||||
|
Map item = widget.children[index]; |
||||
|
return InkWell( |
||||
|
onTap: () { |
||||
|
setState(() { |
||||
|
_selectedIndex = index; |
||||
|
}); |
||||
|
}, |
||||
|
child: Container( |
||||
|
padding: |
||||
|
EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.w), |
||||
|
decoration: BoxDecoration( |
||||
|
border: item['isBorder'] |
||||
|
? Border( |
||||
|
bottom: BorderSide( |
||||
|
width: 0.5, |
||||
|
color: Colors.grey[300]!, |
||||
|
), |
||||
|
) |
||||
|
: null, |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
item['icon'], |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
10.horizontalSpace, |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
item['title'], |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
if (_selectedIndex == index) |
||||
|
const Icon( |
||||
|
Icons.check, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
), |
||||
|
), |
||||
|
Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 20.w), |
||||
|
child: GestureDetector( |
||||
|
onTap: () { |
||||
|
Get.back(); |
||||
|
widget.onTap(_selectedIndex); |
||||
|
}, |
||||
|
child: Container( |
||||
|
height: 48.w, |
||||
|
alignment: Alignment.center, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.black, |
||||
|
borderRadius: BorderRadius.circular(12.r), |
||||
|
), |
||||
|
child: Text( |
||||
|
'导出', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16.sp, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -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 Foundation |
||||
import CoreBluetooth |
// import CoreBluetooth |
||||
import os.log |
// import os.log |
||||
import AVFoundation |
// import AVFoundation |
||||
import AccessorySetupKit |
// import AccessorySetupKit |
||||
import UIKit |
// import UIKit |
||||
|
|
||||
@available(iOS 18.0, *) |
// @available(iOS 18.0, *) |
||||
class BleAccessorySetup: NSObject { |
// class BleAccessorySetup: NSObject { |
||||
// MARK: - 属性 |
// // MARK: - 属性 |
||||
static let shared = BleAccessorySetup() |
// static let shared = BleAccessorySetup() |
||||
|
|
||||
private var bleService: BleService? |
// private var bleService: BleService? |
||||
private var session: ASAccessorySession? |
// private var session: ASAccessorySession? |
||||
private var isSessionActivated = false |
// private var isSessionActivated = false |
||||
private var isInitializing = false |
// private var isInitializing = false |
||||
private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
// private let logger = OSLog(subsystem: "com.yunqiinnovation.ble_service", category: "BleAccessorySetup") |
||||
|
|
||||
// 杰理蓝牙设备的公司标识符 |
// // 杰理蓝牙设备的公司标识符 |
||||
private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
// private let deviceCompanyIdentifier: UInt16 = 0xEFAB |
||||
private let deviceDisplayName = "语音助理耳机" |
// private let deviceDisplayName = "语音助理耳机" |
||||
|
|
||||
// 用于存储配对设备UUID的UserDefaults键 |
// // 用于存储配对设备UUID的UserDefaults键 |
||||
private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
// private let pairedDeviceUUIDKey = "com.yunqiinnovation.ble_service.paired_device_uuid" |
||||
|
|
||||
typealias DeviceDiscoveredCallback = (UUID) -> Void |
// typealias DeviceDiscoveredCallback = (UUID) -> Void |
||||
private var onDeviceDiscovered: DeviceDiscoveredCallback? |
// private var onDeviceDiscovered: DeviceDiscoveredCallback? |
||||
|
|
||||
// MARK: - 初始化 |
// // MARK: - 初始化 |
||||
private override init() { super.init() } |
// private override init() { super.init() } |
||||
|
|
||||
// MARK: - 公共方法 |
// // MARK: - 公共方法 |
||||
func setBleService(_ service: Any) { |
// func setBleService(_ service: Any) { |
||||
self.bleService = service as? BleService |
// self.bleService = service as? BleService |
||||
} |
// } |
||||
|
|
||||
func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
// func setDeviceDiscoveredCallback(_ callback: @escaping (UUID) -> Void) { |
||||
self.onDeviceDiscovered = callback |
// self.onDeviceDiscovered = callback |
||||
} |
// } |
||||
|
|
||||
// 处理发现的设备并连接 |
// // 处理发现的设备并连接 |
||||
func connectToAccessory(uuid: UUID) { |
// func connectToAccessory(uuid: UUID) { |
||||
guard let service = bleService else { |
// guard let service = bleService else { |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
service.connectToAccessory(uuid: uuid) |
// service.connectToAccessory(uuid: uuid) |
||||
} |
// } |
||||
|
|
||||
func addToReconnectQueue(uuid: UUID) { |
// func addToReconnectQueue(uuid: UUID) { |
||||
guard let service = bleService else { |
// guard let service = bleService else { |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
service.addToReconnectQueue(uuid: uuid) |
// service.addToReconnectQueue(uuid: uuid) |
||||
} |
// } |
||||
|
|
||||
// 从视图控制器显示设备选择器 - 主要入口点 |
// // 从视图控制器显示设备选择器 - 主要入口点 |
||||
func showDevicePickerFromViewController(_ viewController: UIViewController) { |
// func showDevicePickerFromViewController(_ viewController: UIViewController) { |
||||
initializeSession() |
// initializeSession() |
||||
} |
// } |
||||
|
|
||||
// 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
// // 清除蓝牙状态(仅用于测试或重置,不会清除系统授权) |
||||
func resetBluetoothState() { |
// func resetBluetoothState() { |
||||
if let service = bleService { |
// if let service = bleService { |
||||
service.disconnect() |
// service.disconnect() |
||||
} |
// } |
||||
} |
// } |
||||
|
|
||||
// MARK: - 会话管理 |
// // MARK: - 会话管理 |
||||
func initializeSession() { |
// func initializeSession() { |
||||
if isInitializing { |
// if isInitializing { |
||||
os_log("会话正在初始化中,跳过", log: logger, type: .info) |
// os_log("会话正在初始化中,跳过", log: logger, type: .info) |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
// 如果会话已激活,检查是否有配对设备 |
// // 如果会话已激活,检查是否有配对设备 |
||||
if session != nil && isSessionActivated { |
// if session != nil && isSessionActivated { |
||||
os_log("会话已激活,检查配对设备", log: logger, type: .info) |
// os_log("会话已激活,检查配对设备", log: logger, type: .info) |
||||
// 会话已激活,检查是否有配对设备 |
// // 会话已激活,检查是否有配对设备 |
||||
if let accessories = session?.accessories, !accessories.isEmpty { |
// if let accessories = session?.accessories, !accessories.isEmpty { |
||||
os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) |
// os_log("发现 %d 个已配对设备,尝试连接", log: logger, type: .info, accessories.count) |
||||
// 有配对设备,尝试连接 |
// // 有配对设备,尝试连接 |
||||
if let service = bleService { |
// if let service = bleService { |
||||
let _ = service.getCentralManager() |
// let _ = service.getCentralManager() |
||||
|
|
||||
for accessory in accessories { |
// for accessory in accessories { |
||||
if let uuid = accessory.bluetoothIdentifier { |
// if let uuid = accessory.bluetoothIdentifier { |
||||
onDeviceDiscovered?(uuid) |
// onDeviceDiscovered?(uuid) |
||||
connectToAccessory(uuid: uuid) |
// connectToAccessory(uuid: uuid) |
||||
} |
// } |
||||
} |
// } |
||||
} |
// } |
||||
} else { |
// } else { |
||||
os_log("没有已配对设备,显示配对框", log: logger, type: .info) |
// os_log("没有已配对设备,显示配对框", log: logger, type: .info) |
||||
// 没有配对设备,直接显示配对框 |
// // 没有配对设备,直接显示配对框 |
||||
showDevicePicker() |
// showDevicePicker() |
||||
} |
// } |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
os_log("开始初始化新会话", log: logger, type: .info) |
// os_log("开始初始化新会话", log: logger, type: .info) |
||||
isInitializing = true |
// isInitializing = true |
||||
|
|
||||
do { |
// do { |
||||
session = try ASAccessorySession() |
// session = try ASAccessorySession() |
||||
session?.activate(on: .main) { [weak self] event in |
// session?.activate(on: .main) { [weak self] event in |
||||
guard let self = self else { return } |
// guard let self = self else { return } |
||||
self.handleSessionEvent(event) |
// self.handleSessionEvent(event) |
||||
} |
// } |
||||
} catch { |
// } catch { |
||||
os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
// os_log("会话初始化失败: %{public}@", log: logger, type: .error, error.localizedDescription) |
||||
handleSessionInitError(error) |
// handleSessionInitError(error) |
||||
} |
// } |
||||
} |
// } |
||||
|
|
||||
func resetSession() { |
// func resetSession() { |
||||
session?.invalidate() |
// session?.invalidate() |
||||
session = nil |
// session = nil |
||||
isSessionActivated = false |
// isSessionActivated = false |
||||
isInitializing = false |
// isInitializing = false |
||||
} |
// } |
||||
|
|
||||
// MARK: - 设备选择器显示 |
// // MARK: - 设备选择器显示 |
||||
private func showDevicePicker() { |
// private func showDevicePicker() { |
||||
guard let session = session, isSessionActivated else { |
// guard let session = session, isSessionActivated else { |
||||
os_log("无法显示配对框:会话未激活", log: logger, type: .error) |
// os_log("无法显示配对框:会话未激活", log: logger, type: .error) |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
os_log("准备显示设备配对框", log: logger, type: .info) |
// os_log("准备显示设备配对框", log: logger, type: .info) |
||||
|
|
||||
let descriptor = ASDiscoveryDescriptor() |
// let descriptor = ASDiscoveryDescriptor() |
||||
descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) |
// descriptor.bluetoothCompanyIdentifier = ASBluetoothCompanyIdentifier(deviceCompanyIdentifier) |
||||
|
|
||||
let deviceImage = UIImage(systemName: "headphones") ?? UIImage() |
// let deviceImage = UIImage(systemName: "headphones") ?? UIImage() |
||||
let displayItem = ASPickerDisplayItem( |
// let displayItem = ASPickerDisplayItem( |
||||
name: deviceDisplayName, |
// name: deviceDisplayName, |
||||
productImage: deviceImage, |
// productImage: deviceImage, |
||||
descriptor: descriptor |
// descriptor: descriptor |
||||
) |
// ) |
||||
|
|
||||
DispatchQueue.main.async { |
// DispatchQueue.main.async { |
||||
os_log("正在显示设备配对框", log: self.logger, type: .info) |
// os_log("正在显示设备配对框", log: self.logger, type: .info) |
||||
session.showPicker(for: [displayItem]) { [weak self] error in |
// session.showPicker(for: [displayItem]) { [weak self] error in |
||||
guard let self = self else { return } |
// guard let self = self else { return } |
||||
|
|
||||
if let error = error { |
// if let error = error { |
||||
let nsError = error as NSError |
// let nsError = error as NSError |
||||
os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
// os_log("设备选择器错误: %{public}@", log: self.logger, type: .error, error.localizedDescription) |
||||
} else { |
// } else { |
||||
os_log("设备选择器完成", log: self.logger, type: .info) |
// os_log("设备选择器完成", log: self.logger, type: .info) |
||||
} |
// } |
||||
} |
// } |
||||
} |
// } |
||||
} |
// } |
||||
|
|
||||
// MARK: - 私有辅助方法 |
// // MARK: - 私有辅助方法 |
||||
private func handleSessionEvent(_ event: ASAccessoryEvent) { |
// private func handleSessionEvent(_ event: ASAccessoryEvent) { |
||||
switch event.eventType { |
// switch event.eventType { |
||||
case .activated: |
// case .activated: |
||||
os_log("会话已激活", log: logger, type: .info) |
// os_log("会话已激活", log: logger, type: .info) |
||||
handleSessionActivated() |
// handleSessionActivated() |
||||
|
|
||||
case .accessoryAdded: |
// case .accessoryAdded: |
||||
os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
// os_log("发现新设备: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
||||
handleAccessoryAdded(event.accessory) |
// handleAccessoryAdded(event.accessory) |
||||
|
|
||||
case .accessoryRemoved: |
// case .accessoryRemoved: |
||||
os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
// os_log("设备已移除: %{public}@", log: logger, type: .info, event.accessory?.bluetoothIdentifier?.uuidString ?? "Unknown") |
||||
handleAccessoryRemoved(event.accessory) |
// handleAccessoryRemoved(event.accessory) |
||||
|
|
||||
case .accessoryChanged: |
// case .accessoryChanged: |
||||
os_log("设备状态已改变", log: logger, type: .info) |
// os_log("设备状态已改变", log: logger, type: .info) |
||||
break |
// break |
||||
|
|
||||
case .pickerDidPresent: |
// case .pickerDidPresent: |
||||
os_log("配对框已显示", log: logger, type: .info) |
// os_log("配对框已显示", log: logger, type: .info) |
||||
break |
// break |
||||
|
|
||||
case .pickerDidDismiss: |
// case .pickerDidDismiss: |
||||
os_log("配对框已关闭", log: logger, type: .info) |
// os_log("配对框已关闭", log: logger, type: .info) |
||||
break |
// break |
||||
|
|
||||
case .pickerSetupFailed: |
// case .pickerSetupFailed: |
||||
os_log("选择器设置失败: %{public}@", log: logger, type: .error, |
// os_log("选择器设置失败: %{public}@", log: logger, type: .error, |
||||
event.error?.localizedDescription ?? "未知错误") |
// event.error?.localizedDescription ?? "未知错误") |
||||
|
|
||||
default: |
// default: |
||||
os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) |
// os_log("未处理的会话事件: %d", log: logger, type: .info, event.eventType.rawValue) |
||||
break |
// break |
||||
} |
// } |
||||
} |
// } |
||||
|
|
||||
private func handleSessionActivated() { |
// private func handleSessionActivated() { |
||||
isSessionActivated = true |
// isSessionActivated = true |
||||
isInitializing = false |
// isInitializing = false |
||||
|
|
||||
if let accessories = session?.accessories, !accessories.isEmpty { |
// if let accessories = session?.accessories, !accessories.isEmpty { |
||||
if let service = bleService { |
// if let service = bleService { |
||||
let _ = service.getCentralManager() |
// let _ = service.getCentralManager() |
||||
|
|
||||
for accessory in accessories { |
// for accessory in accessories { |
||||
if let uuid = accessory.bluetoothIdentifier { |
// if let uuid = accessory.bluetoothIdentifier { |
||||
onDeviceDiscovered?(uuid) |
// onDeviceDiscovered?(uuid) |
||||
connectToAccessory(uuid: uuid) |
// connectToAccessory(uuid: uuid) |
||||
} |
// } |
||||
} |
// } |
||||
} |
// } |
||||
} else { |
// } else { |
||||
showDevicePicker() |
// showDevicePicker() |
||||
} |
// } |
||||
} |
// } |
||||
|
|
||||
private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
// private func handleAccessoryAdded(_ accessory: ASAccessory?) { |
||||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
// 保存配对设备的UUID |
// // 保存配对设备的UUID |
||||
savePairedMacAddress(uuid) |
// savePairedMacAddress(uuid) |
||||
|
|
||||
onDeviceDiscovered?(uuid) |
// onDeviceDiscovered?(uuid) |
||||
connectToAccessory(uuid: uuid) |
// connectToAccessory(uuid: uuid) |
||||
} |
// } |
||||
|
|
||||
private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
// private func handleAccessoryRemoved(_ accessory: ASAccessory?) { |
||||
guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
// guard let accessory = accessory, let uuid = accessory.bluetoothIdentifier else { |
||||
return |
// return |
||||
} |
// } |
||||
|
|
||||
if let service = bleService { |
// if let service = bleService { |
||||
service.disconnect() |
// service.disconnect() |
||||
} |
// } |
||||
} |
// } |
||||
|
|
||||
private func handleSessionInitError(_ error: Error) { |
// private func handleSessionInitError(_ error: Error) { |
||||
isInitializing = false |
// isInitializing = false |
||||
session = nil |
// session = nil |
||||
} |
// } |
||||
|
|
||||
// MARK: - 蓝牙状态检查 |
// // MARK: - 蓝牙状态检查 |
||||
func isBluetoothAvailable() -> Bool { |
// func isBluetoothAvailable() -> Bool { |
||||
do { |
// do { |
||||
let audioSession = AVAudioSession.sharedInstance() |
// let audioSession = AVAudioSession.sharedInstance() |
||||
let outputs = audioSession.currentRoute.outputs |
// let outputs = audioSession.currentRoute.outputs |
||||
|
|
||||
for output in outputs { |
// for output in outputs { |
||||
if output.portType == .bluetoothA2DP || |
// if output.portType == .bluetoothA2DP || |
||||
output.portType == .bluetoothHFP || |
// output.portType == .bluetoothHFP || |
||||
output.portType == .bluetoothLE { |
// output.portType == .bluetoothLE { |
||||
return true |
// return true |
||||
} |
// } |
||||
} |
// } |
||||
} catch { |
// } catch { |
||||
os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) |
// os_log("检查蓝牙状态出错: %{public}@", log: logger, type: .error, error.localizedDescription) |
||||
} |
// } |
||||
|
|
||||
return true |
// return true |
||||
} |
// } |
||||
|
|
||||
// MARK: - 清除配对关系 |
// // MARK: - 清除配对关系 |
||||
func clearAssociations() { |
// func clearAssociations() { |
||||
resetBluetoothState() |
// resetBluetoothState() |
||||
|
|
||||
// 清除保存的配对设备UUID |
// // 清除保存的配对设备UUID |
||||
clearPairedMacAddress() |
// clearPairedMacAddress() |
||||
|
|
||||
#if canImport(AccessorySetupKit) && swift(>=5.9) |
// #if canImport(AccessorySetupKit) && swift(>=5.9) |
||||
if #available(iOS 18.0, *) { |
// if #available(iOS 18.0, *) { |
||||
resetSession() |
// resetSession() |
||||
initializeSession() |
// initializeSession() |
||||
} |
// } |
||||
#endif |
// #endif |
||||
} |
// } |
||||
|
|
||||
// MARK: - 配对设备UUID管理 |
// // MARK: - 配对设备UUID管理 |
||||
|
|
||||
/// 获取已配对设备的UUID字符串 |
// /// 获取已配对设备的UUID字符串 |
||||
/// 返回存储的配对设备UUID,如果没有则返回nil |
// /// 返回存储的配对设备UUID,如果没有则返回nil |
||||
func getPairedMacAddress() -> String? { |
// func getPairedMacAddress() -> String? { |
||||
let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) |
// let uuidString = UserDefaults.standard.string(forKey: pairedDeviceUUIDKey) |
||||
if let uuidString = uuidString, !uuidString.isEmpty { |
// if let uuidString = uuidString, !uuidString.isEmpty { |
||||
os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
// os_log("获取已配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
||||
return uuidString |
// return uuidString |
||||
} |
// } |
||||
os_log("未找到已配对设备UUID", log: logger, type: .info) |
// os_log("未找到已配对设备UUID", log: logger, type: .info) |
||||
return nil |
// return nil |
||||
} |
// } |
||||
|
|
||||
/// 保存配对设备的UUID |
// /// 保存配对设备的UUID |
||||
/// - Parameter uuid: 要保存的设备UUID |
// /// - Parameter uuid: 要保存的设备UUID |
||||
func savePairedMacAddress(_ uuid: UUID) { |
// func savePairedMacAddress(_ uuid: UUID) { |
||||
let uuidString = uuid.uuidString |
// let uuidString = uuid.uuidString |
||||
UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) |
// UserDefaults.standard.set(uuidString, forKey: pairedDeviceUUIDKey) |
||||
UserDefaults.standard.synchronize() |
// UserDefaults.standard.synchronize() |
||||
os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
// os_log("已保存配对设备UUID: %{public}@", log: logger, type: .info, uuidString) |
||||
} |
// } |
||||
|
|
||||
/// 清除已保存的配对设备UUID |
// /// 清除已保存的配对设备UUID |
||||
private func clearPairedMacAddress() { |
// private func clearPairedMacAddress() { |
||||
UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) |
// UserDefaults.standard.removeObject(forKey: pairedDeviceUUIDKey) |
||||
UserDefaults.standard.synchronize() |
// UserDefaults.standard.synchronize() |
||||
os_log("已清除配对设备UUID", log: logger, type: .info) |
// 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