Flutter实现安卓/ios app中利用mdns服务实现搜索附近设备功能

997 阅读6分钟

功能描述

实现搜索附近设备功能

尝试了几款flutter的第三方插件,最后选用了bonsoir

界面

IMG_3294.PNG

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 ,值分别是AddRmv

Add:添加进入局域网 Rmv:已从局域网内移出