功能描述
实现搜索附近设备功能
尝试了几款flutter的第三方插件,最后选用了bonsoir
界面
mdns原理
mdns的工作原理是依赖于设备持续的广播自己。在mDNS协议中,设备(也成为服务端)需要定期发送广播信号,才能让其他设备(接收方)能够发现它并进行通信。这种广播是通过DNS请求发送到特定的多播地址(通常是224.0.0.251)和端口(5353)来实现的。
具体工作原理
1、设备注册服务
- 当一个设备(比如打印机、智能家居设备、电脑等)首次接入网络时,它会通过mDNS广播自己提供的服务(例如ariplay、printer、http等)。
- 设备将自己的服务信息注册到本地的mDNS解析器,并定期通过多播向局域网内的其他设备广播这些信息。
2、接收方查询
- 当一个接收方(比如手机、电脑、其他智能设备)希望发现可用的mDNS服务时,它会向内网发送一个多播查询请求,询问是否有相应的服务。
- 服务端在接受到查询请求后会回应,告诉接收方自己的信息,比如IP地址、端口号等,接收方就可以直接与服务端建立连接。
3、广播机制
- 为了保持设备对网络中的可见性,mDNS设备通常会定期广播自己的服务信息,确保其他设备能够持续发现它。即便没有新的查询请求,设备也会定时发送更新的广播,防止网络中其他设备忘记它的存在。
4、TTL(生存时间)和重新广播
- 每个mDNS记录都有一个TTL(Time To Live,生存时间),这表示记录的有效期。设备在TTL过期之前需要重新广播自己的服务信息。
- 如果设备长时间没有广播,其他设备可能会认为它已经下线或者不可用。
代码
定义设备类
// 定义设备信息类
class Device {
final String name;
final String ip;
final double angle; // 设备在雷达上的角度
final double distance; // 设备与雷达中心的距离
final int port;
final String details;
Device(
{required this.name,
required this.ip,
required this.angle,
required this.distance,
required this.port,
required this.details});
}
绘制扩展圆弧效果
// 绘制扩展的圆弧效果
class RadarArcPainter extends CustomPainter {
final double progress; // 动画的进度值
RadarArcPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = Colors.green.withOpacity(1 - progress) // 随着进度逐渐变透明
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
// 绘制多个同心扩张的完整圆
for (double radius = size.width / 4;
radius <= size.width / 2;
radius += size.width / 8) {
canvas.drawArc(
Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2),
radius: radius * progress),
0, // 起始角度为 0
2 * 3.14159, // 绘制完整的 360 度圆
false,
paint,
);
}
}
@override
bool shouldRepaint(covariant RadarArcPainter oldDelegate) {
return oldDelegate.progress != progress;
}
}
绘制雷达的背景和同心圆弧
// 绘制雷达的背景和同心圆弧
class RadarBackgroundPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final Paint backgroundPaint = Paint()
..shader = RadialGradient(
colors: [Colors.green.shade400, Colors.green.shade800],
).createShader(Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2),
radius: size.width / 2))
..style = PaintingStyle.fill;
// 绘制绿色的雷达背景
canvas.drawCircle(
Offset(size.width / 2, size.height / 2),
size.width / 2,
backgroundPaint,
);
final Paint linePaint = Paint()
..color = Colors.white.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
// 绘制同心的白色圆弧
for (double radius = size.width / 5;
radius < size.width / 2;
radius += size.width / 6) {
canvas.drawCircle(
Offset(size.width / 2, size.height / 2),
radius,
linePaint,
);
}
final Paint linePaint2 = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.sp;
canvas.drawCircle(
Offset(size.width / 2, size.height / 2),
size.width / 2,
linePaint2,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}
绘制旋转的扫描线
// 绘制旋转的扫描线
class RadarLinePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final Paint linePaint = Paint()
..color = Colors.white.withOpacity(0.8)
..style = PaintingStyle.fill
..strokeWidth = 2.0;
// 计算中心点
final Offset center = Offset(size.width / 2, size.height / 2);
// 绘制扫描线
canvas.drawLine(
center,
Offset(
center.dx + size.width / 2 * cos(0), // 计算终点的 x 坐标
center.dy + size.width / 2 * sin(0), // 计算终点的 y 坐标
),
linePaint,
);
// 绘制覆盖扫描区域的扇形
canvas.drawArc(
Rect.fromCircle(center: center, radius: size.width / 2),
-pi / 6, // 扫描起始角度(可以调整以覆盖更多区域)
pi / 6, // 扫描的角度范围
true,
linePaint..color = Colors.white.withOpacity(0.1),
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return true;
}
}
主页代码
class RadarPage extends StatefulWidget {
const RadarPage({super.key});
@override
State<RadarPage> createState() => _RadarPageState();
}
class _RadarPageState extends State<RadarPage> with TickerProviderStateMixin {
final ValueNotifier<List<Device>> _devicesNotifier = ValueNotifier([]);
String _ipResolveFailed = 'IP resolution failed';
AnimationController? _rotationController;
AnimationController? _expansionController;
bool _isScanning = false;
final _random = Random();
BonsoirDiscovery _discovery = BonsoirDiscovery(type: '_http._tcp');
@override
void initState() {
super.initState();
// 初始化旋转控制器
_rotationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat(); // 无限循环旋转
// 初始化扩展控制器
_expansionController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(); // 无限循环扩展
_toggleScanning();
}
Future<void> _toggleScanning() async {
setState(() {
_isScanning = !_isScanning;
if (_isScanning) {
// 开始扫描:恢复动画和设备
_rotationController?.repeat();
_expansionController?.repeat();
// _getLocalIpAddress();
// _discoverDevices();
_startDiscovery();
} else {
// 停止扫描:暂停动画并清空设备
_rotationController?.stop();
_expansionController?.stop();
_discovery.stop();
_devicesNotifier.value.clear();
}
});
}
void _showDevice(Device device) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.device_details),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${AppLocalizations.of(context)!.device_name}:${device.name}'),
Row(
children: [
Text(
'${AppLocalizations.of(context)!.device_ip}: ${device.ip}'),
Visibility(
visible: device.ip == _ipResolveFailed,
child: Icon(
Icons.warning,
color: Colors.red,
size: 20.sp,
),
),
],
),
Text(
'${AppLocalizations.of(context)!.device_port}:${device.port}'),
Text(
'${AppLocalizations.of(context)!.device_details}:${device.details}'),
],
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(AppLocalizations.of(context)!.cancel)),
Visibility(
visible: device.ip != _ipResolveFailed,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context, true);
},
child: Text(AppLocalizations.of(context)!.confirm)))
],
)).then((v) {
if (v == true) {
if (!mounted) return;
Navigator.pop(context, 'http://${device.ip}:${device.port}');
}
});
}
// ignore: unused_element
Future<void> _discoverDevices() async {
_devicesNotifier.value.clear();
final MDnsClient client = MDnsClient();
final List<Device> list = [];
await client.start();
await for (final PtrResourceRecord ptr in client.lookup<PtrResourceRecord>(
ResourceRecordQuery.serverPointer('_http._tcp'),
)) {
await for (final SrvResourceRecord srv
in client.lookup<SrvResourceRecord>(
ResourceRecordQuery.service(ptr.domainName),
)) {
await for (final IPAddressResourceRecord ip
in client.lookup<IPAddressResourceRecord>(
ResourceRecordQuery.addressIPv4(srv.target),
)) {
final double minDistance = 0.3.sw;
bool overlap = true;
while (overlap) {
final angle = _random.nextDouble() * 2 * pi;
final distance = 0.1.sw + _random.nextDouble() * 0.1.sw;
overlap = false;
final device = Device(
name: srv.target,
ip: ip.address.address,
angle: angle,
distance: distance,
port: srv.port,
details: srv.name);
for (Device existinDevice in list) {
final d = _calculateDistance(device, existinDevice);
if (d > minDistance) {
overlap = true;
list.add(device);
break;
}
}
}
}
}
}
if (list.isNotEmpty) {
final seenNames = <String>{};
final r = list.where((device) => seenNames.add(device.name)).toList();
_devicesNotifier.value = r;
}
client.stop();
}
// 计算两设备之间的距离
double _calculateDistance(Device device1, Device device2) {
//将极坐标转换为直角坐标
double x1 = device1.distance * cos(device1.angle);
double y1 = device1.distance * sin(device1.angle);
double x2 = device2.distance * cos(device2.angle);
double y2 = device2.distance * sin(device2.angle);
return sqrt(pow(x2 - x1, 2) + pow(y2 - y1, 2));
}
void _startDiscovery() {
_discovery = BonsoirDiscovery(type: '_http._tcp');
_discovery.ready.then((_) {
_discovery.eventStream?.listen((event) async {
if (event.type == BonsoirDiscoveryEventType.discoveryServiceFound) {
debugPrint('Service found : ${event.service?.toJson()}');
event.service!.resolve(_discovery
.serviceResolver); // Should be called when the user wants to connect to this service.
} else if (event.type ==
BonsoirDiscoveryEventType.discoveryServiceResolved) {
debugPrint('Service resolved : ${event.service?.toJson()}');
if (event.service != null) {
final service = event.service!;
// 尝试解析 IP 地址
final ip =
await resolveIpAddress(service as ResolvedBonsoirService);
const double minDistance = 50;
bool overlap = true;
Device? device;
// 找到一个不重叠的设备位置
while (overlap) {
final angle = _random.nextDouble() * 2 * pi;
final distance = 0.1.sw + _random.nextDouble() * 0.05.sw;
overlap = false;
device = Device(
name: service.name,
details: service.toString(),
port: service.port,
ip: ip ?? _ipResolveFailed,
angle: angle,
distance: distance,
);
if (_devicesNotifier.value.isEmpty) {
_devicesNotifier.value = [device];
debugPrint('length1: ${_devicesNotifier.value.length}');
break;
} else {
final list = _devicesNotifier.value;
for (Device existingDevice in list) {
if (_calculateDistance(device, existingDevice) <
minDistance) {
overlap = true;
break;
}
}
// 只有在没有重叠的情况下才添加设备
if (!overlap) {
_devicesNotifier.value = [..._devicesNotifier.value, device];
debugPrint('length2: ${_devicesNotifier.value.length}');
break;
}
}
}
}
} else if (event.type ==
BonsoirDiscoveryEventType.discoveryServiceLost) {
debugPrint('Service lost : ${event.service?.toJson()}');
if (event.service != null) {
final list = _devicesNotifier.value;
list.removeWhere((e) => e.name == event.service!.name);
_devicesNotifier.value = list;
}
}
});
_discovery.start();
});
}
// Future<String?> resolveIpAddress(ResolvedBonsoirService service) async {
// try {
// // 使用服务名称来解析 IP 地址(假设服务名是唯一的)
// List<InternetAddress> addresses = await InternetAddress.lookup(
// service.host!,
// type: InternetAddressType.IPv4);
// for (var address in addresses) {
// debugPrint("IP Address: ${address.address}");
// return address.address;
// }
// } catch (e) {
// debugPrint("Failed to resolve IP: $e");
// }
// return null;
// }
Future<String?> resolveIpAddress(ResolvedBonsoirService service) async {
try {
final attributesIp = service.attributes['CurrentIp'];
if (attributesIp != null && attributesIp != '127.0.0.1') {
return attributesIp;
}
/// 判断 host
if (service.host == null || service.host!.isEmpty) {
debugPrint("Resolved host is empty");
}
// 使用 DNS 查找
debugPrint("Resolving IP for host: ${service.host}");
List<InternetAddress> addresses = await InternetAddress.lookup(
service.host!,
type: InternetAddressType.IPv4);
if (addresses.isNotEmpty) {
debugPrint("Resolved IP Address: ${addresses.first.address}");
return addresses.first.address;
} else {
debugPrint("No IP addresses found for host: ${service.host}");
}
} catch (e, stackTrace) {
debugPrint("Failed to resolve IP: $e");
debugPrint("Stack trace: $stackTrace");
}
return null;
}
@override
void dispose() {
_rotationController?.dispose();
_expansionController?.dispose();
_discovery.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
_ipResolveFailed = AppLocalizations.of(context)!.ip_resolution_failed;
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.nearby_devices)),
body: Stack(
alignment: Alignment.center,
children: [
Container(
width: 1.sw,
// height: 1.sh,
color: Colors.grey.withOpacity(0.1),
),
// 外部扩张的圆弧效果
Visibility(
visible: _isScanning,
child: AnimatedBuilder(
animation: _expansionController!,
builder: (context, child) {
return CustomPaint(
painter: RadarArcPainter(_expansionController!.value),
size: Size(1.sw, 1.sw),
);
},
)),
// 固定的同心圆弧
CustomPaint(
painter: RadarBackgroundPainter(),
size: Size(50.sp, 50.sp),
isComplex: false,
willChange: false,
),
// 旋转的扫描线
Visibility(
visible: _isScanning,
child: RotationTransition(
turns: _rotationController!,
child: CustomPaint(
painter: RadarLinePainter(),
size: Size(50.sp, 50.sp),
),
)),
// 显示检测到的设备
ValueListenableBuilder(
valueListenable: _devicesNotifier,
builder: (ctx, v, _) {
debugPrint('${"-" * 100} v:${v.length}');
return Stack(
children:
v.map((device) => _buildDeviceWidget(device)).toList(),
);
}),
// 显示设备信息
Positioned(
bottom: 20,
child: Column(
children: [
ElevatedButton(
onPressed: _toggleScanning,
child: Text(_isScanning
? AppLocalizations.of(context)!.pause
: AppLocalizations.of(context)!.scan)),
const SizedBox(
height: 20,
),
Text(
AppLocalizations.of(context)!.searching_holomotion,
style: const TextStyle(color: Colors.grey),
),
],
),
),
],
),
);
}
// 构建每个设备的 Widget
Widget _buildDeviceWidget(Device device) {
// 计算设备的位置
final double x = 150 + device.distance * cos(device.angle);
final double y = 150 + device.distance * sin(device.angle);
// HoloMotion-on-HM20240620141200._http._tcp.local
bool isHMDevice = device.details.contains('HoloMotion');
return Positioned(
left: x,
top: y,
child: GestureDetector(
onTap: () => _showDevice(device),
child: Column(
children: [
CircleAvatar(
radius: 20,
backgroundColor:
isHMDevice ? Theme.of(context).primaryColor : Colors.grey,
child: const Icon(Icons.devices, color: Colors.white),
),
const SizedBox(height: 4),
Text(
device.name,
style: const TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.bold),
),
Text(
device.ip,
style: const TextStyle(color: Colors.black, fontSize: 10),
),
],
),
),
);
}
}
权限
安卓权限
在android>app>src>mian>AndroidManifest.xml添加
<!-- 附近设备搜索功能 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> -->
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<!-- <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> -->
ios权限
在ios>Runner>info.plist 添加
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_services._dns-sd._udp</string>
</array>
测试
因为应用场景的问题,需要搜索的设备网络连接偶尔不是很稳定引起的起mdns服务广播失败,导致有些时候界面上并没有显示搜索的设备。 为了排除app本身搜索的问题,可以在Mac电脑上输入以下命令进行监听:
dns-sd -B _http._tcp
输出当中有一列是 A/R ,值分别是Add、Rmv
Add:添加进入局域网 Rmv:已从局域网内移出