从代码中提升自己-局域网通信前置条件(设备发现mDNS)

1,764 阅读8分钟

正文

话说,翻别人的代码,其实还是容易学到很多东西的。比如说,现在这个砖,早上客户说“手机上的设备发现功能没有了”,震惊我半天,毕竟搬砖很多东西都是黑箱操作的,我只需要了解黑箱的输入输出即可,不用关心黑箱的具体实现,我明明记得这玩意我复制过去了啊,断点也进去了啊,为啥没有啊?

我们先说这个功能,局域网内部的设备发现。不知道mDNS的我第一感觉是这么实现的:

  • 首先判断wifi连接状态。
  • 然后获取到当前设备的wifi状态下的ip地址。
  • 通常设备是发布到固定ip网段内部的,所以再判断一下ip的网段。
  • 最后基于最后的网段去ping当前网段内的非自己的ip,ping得通就是可用的。

什么?你说慢?开协程嘛,开线程嘛,ping的时候就ping一次就好嘛。

然而当我打开对应的代码进行调试的时候,emmmm? 并不是通过我的想法实现的。他是通过jmDNS去实现的,那么就来到了知识点盲区环节,那么什么是Multicast DNS呢?

什么是Multicast DNS

这个直接百度百科就可以看到:

mDNS协议发布为 RFC 6762使用IP多播用户数据报协议 (UDP)数据包,由Apple Bonjour和开源Avahi软件包实现。 Android包含mDNS实现。 mDNS也已在Windows 10中实现,最初仅限于发现网络打印机[3] ,后来也能够解析主机名。

mDNS可以与DNS服务发现 (DNS-SD)结合使用, DNS服务发现是RFC 6763中单独指定的配套零配置技术。

mdns 即多播dns(Multicast DNS),mDNS主要实现了在没有传统DNS服务器的情况下使局域网内的主机实现相互发现和通信,使用的端口为5353,遵从dns协议,使用现有的DNS信息结构、名语法和资源记录类型。并且没有指定新的操作代码或响应代码。在局域网中,设备和设备之前相互通信需要知道对方的ip地址的,大多数情况,设备的ip不是静态ip地址,而是通过dhcp协议动态分配的ip 地址,如何设备发现呢,就是要mdns大显身手,例如:现在物联网设备和app之间的通信,要么app通过广播,要么通过组播,发一些特定信息,感兴趣设备应答,实现局域网设备的发现,当然mdns 比这强大。

我更喜欢这种解释:

每一个进入局域网的主机开启了mNDS服务的话,都会向局域网内的所有主机发送消息,我是谁,我的IP地址是多少,然后其他主机就会响应,并告诉你,他是谁,他的IP地址是多少

基于这种逻辑,我们可以获取到需要的IP地址,这么就直接过滤掉了不需要的设备,同时也将自己也加入的需要的设备群里面,其他设备也很方便的与我们通信。

那么JAVA如何去实现呢

jmNDS javadoc org直接将maven 都提供了。通过上面的mDMS的逻辑,去发现设备大致分为2步:

  • 将自己添加为设备并广而告之
  • 收到其他设备的消息

那么直接开整。首先是导入maven:

implementation "org.jmdns:jmdns:3.5.8"

添加权限

 <uses-permission
        android:name="android.permission.NEARBY_WIFI_DEVICES"
        android:usesPermissionFlags="neverForLocation" />
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
    <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

当前wifi下的IP地址然后转化为jmdns需要的InetAddress对象。

创建jmDNS对象:

val address = getInetAddress()
val hostname = address?.hostName
var jmdns= JmDNS.create(address, hostname)

将自己发布成服务

这玩意贼重要,主要是单纯的局域网内部可能没有设备信息。那就只能两台手机,自己发布自己测试了。

val serviceType = "_http._tcp.local."
val serverName = "JmDNS Test"
val port = 1234
val serviceInfo = ServiceInfo.create(serviceType, serverName, port,"描述信息")
//注册服务
jmDNS?.registerService(serviceInfo)

创建jmDNS的监听对象:ServiceListener并设置操作:

// 添加服务监听。
jmDNS?.addServiceTypeListener(object : ServiceTypeListener{
    override fun serviceTypeAdded(event: ServiceEvent) {
        LogUtils.e(event.info)
    }
​
    override fun subTypeForServiceTypeAdded(event: ServiceEvent) {
        LogUtils.e(event.info)
    }
})
// 这个是通过type
jmDNS?.addServiceListener("_http._tcp.local.", object : ServiceListener {
    override fun serviceAdded(event: ServiceEvent) {
        // 请求服务信息。
        jmDNS?.requestServiceInfo(event.type, event.name, 1000)
        LogUtils.e(event.info)
    }
​
    override fun serviceRemoved(event: ServiceEvent) {
        // 服务移除
        LogUtils.e(event.info)
    }
​
    override fun serviceResolved(event: ServiceEvent) {
        // 在这里是检索到了服务,但是为啥是这个命名。然后再这里就获取信息就自己处理逻辑,比较服务很多。
        LogUtils.e(event.info)
    }
})

这里面有一个懵逼的点,那就是 _http._tcp.local. 这个应该可以有很多类型,但是有哪些类型不知道,需要后续检索一下。

为什么需要获取到wifiManger中的MulticastLock然后调用acquire()

先上MulticastLock文档地址 我们可以从文档中直接翻到这句话:

允许应用程序接收 Wifi 多播数据包。通常,Wifi 堆栈会过滤掉未明确寻址到该设备的数据包。获取多播锁将导致堆栈接收寻址到多播地址的数据包。处理这些额外的数据包可能会导致明显的电池消耗,在不需要时应将其禁用。

通过上面的描述我们知道wifi 会过滤掉数据包,所以说,这和我们mDNS的接收其他设备的数据相违背,所以说,我们需要关闭其过滤功能。 获取到 MulticastLock 对象后,是执行了3个函数的。

  • setReferenceCounted(false) :控制这是引用计数还是非引用计数多播锁。
  • acquire():锁定 Wifi 多播,直至release()被呼叫。
  • release():解锁 Wifi 多播,恢复非专门发送至该设备的数据包过滤器并节省电量。

Android系统支持的网络服务发现

还是先上官网文档Use network service discovery。权限相关的还是和上面的类似。基于wifiManger的特性。所以我们还是手动屏蔽一下,至于NSDManager是否会自己屏蔽,还没仔细看代码,感觉不用屏蔽。

nsdManger=  application.getSystemService<NsdManager>()

获取到当前局域网的ip 并且注册服务

获取ip 信息并且进行转换。

    wifiManager?.connectionInfo?.let {
        address = InetAddress.getByName(
            String.format(
                Locale.ENGLISH,
                "%d.%d.%d.%d",
                it.ipAddress and 0xff,
                it.ipAddress shr 8 and 0xff,
                it.ipAddress shr 16 and 0xff,
                it.ipAddress shr 24 and 0xff
            )
        )
    }
})

注册服务

jobRegistr= lifecycleScope.launch {
    var nsdServiceInfo= NsdServiceInfo()
    nsdServiceInfo.serviceName="my_nsd_s"
    nsdServiceInfo.port=500
    nsdServiceInfo.host=address
    nsdServiceInfo.serviceType="_http._tcp"
    nsdManger?.registerService(nsdServiceInfo,NsdManager.PROTOCOL_DNS_SD,registrationListener)
}

服务的监听:

val registrationListener= object :NsdManager.RegistrationListener{
    override fun onRegistrationFailed(p0: NsdServiceInfo?, p1: Int) {
        LogUtils.e(p0)
    }
​
    override fun onUnregistrationFailed(p0: NsdServiceInfo?, p1: Int) {
        LogUtils.e(p0)
    }
​
    override fun onServiceRegistered(p0: NsdServiceInfo?) {
        LogUtils.e(p0)
    }
​
    override fun onServiceUnregistered(p0: NsdServiceInfo?) {
        LogUtils.e(p0)
    }
}

这么写便于取消注册。当执行到onServiceRegistered 便表示注册成功了,如果没address我写Demo的时候是无法注册成功的。当type=http. tcp.local.会报错,但是为http. tcp 就可以检索到局域网中的mac。

扫描服务

jobDiscovery= lifecycleScope.launch {
    nsdManger?.let {
        discoveryListener=MyDiscoveryListener(it)
        it.discoverServices("_http._tcp",NsdManager.PROTOCOL_DNS_SD,discoveryListener)
    }
}

因为discoveryListener写成匿名内部类会泄漏,所以:

class MyDiscoveryListener(val nsdManger:NsdManager):NsdManager.DiscoveryListener{
    override fun onStartDiscoveryFailed(p0: String?, p1: Int) {
        LogUtils.e(p0)
    }
​
    override fun onStopDiscoveryFailed(p0: String?, p1: Int) {
        LogUtils.e(p0)
    }
​
    override fun onDiscoveryStarted(p0: String?) {
        LogUtils.e(p0)
    }
​
    override fun onDiscoveryStopped(p0: String?) {
        LogUtils.e(p0)
    }
​
    override fun onServiceFound(p0: NsdServiceInfo) {
        LogUtils.e(p0)
        nsdManger.resolveService(p0,object :NsdManager.ResolveListener{
            override fun onResolveFailed(p0: NsdServiceInfo?, p1: Int) {
                LogUtils.e(p0)
            }
​
            override fun onServiceResolved(p0: NsdServiceInfo?) {
                LogUtils.e(p0)
                // 处理扫描到的设备。
            }
        })
    }
    override fun onServiceLost(p0: NsdServiceInfo) {
        LogUtils.e(p0)
    }
}

onServiceFound 执行的时候,说明扫描到设备了,然后就需要调用:nsdManger.resolveService 去解析参数。在onServiceResolved 中处理相关的逻辑。

添加和扫描到时候,type的值都比较重要。

取消释放资源

private fun cancel() {
    lock?.release()
    wifiManager=null
    nsdManger?.apply {
        stopServiceDiscovery(discoveryListener)
        unregisterService(registrationListener)
    }
    jobRegistr?.cancel()
    jobDiscovery?.cancel()
    jobRegistr=null
    jobDiscovery=null
    nsdManger=null
}

感觉job 不用暂停。

总结

果然纸上得来终觉浅,绝知此事要躬行 当初写笔记的时候,决定划划水啊,简单的整的很快就决定简单嘛。

当接触到自己未知的领域的时候,就发现到处都难。而且还有很多没有整清楚的地方,这只是一个简单的发现服务而已。比如说type 的定义就是一个默认的板块。然后包下面提供了很多其他功能,也没有看。

当然了,写这个也不是没有收获,比如说,内存泄露相关的以后就可以避免了

内存泄漏

对String扩展toast竟然提示内存泄漏?

只要想法足够骚,就能写出足够震惊的泄露的代码。代码如下。

fun String.showToast(context: Context){
    Toast.makeText(context,this,Toast.LENGTH_LONG).show()
}

emmm?还没有看具体的字节码指令,暂时不知道为啥会泄漏。反正不要这么写就行了呗。

系统服务使用activity的context也会泄漏?

这么写提示就会泄漏

wifiManager =  getSystemService<WifiManager>()

下面的写法就不会。参考地址 具体为啥,俺不知道,俺也不敢问。

wifiManager =  application.getSystemService<WifiManager>()

包含线程调度的代码,回调写成匿名内部类会泄漏

典型代表就是:

var discoveryListener=object:NsdManager.DiscoveryListener{}

不泄露的写法就是,将NsdManager.DiscoveryListener 不写成匿名内部类。这个建议参考:匿名内部类/Lambda Java和Kotlin谁会导致内存泄漏?

参考资料

当前笔记完整代码地址