两台Android 设备同一个局域网下如何自由通信?

一、背景

笔者前段时间开发了一款非常有意思的项目,已知在同一个局域网下有两款App分别运行在不同的设备上,业务上两款App分别具有不同的功能,同时两款App需要支持互相通信。后面我们称两款设备上的两款App一个为Server,一个为Client。

基于此我们需要考虑以下几点

  • 如何发现对方
  • 两款App如何通信
  • 通信协议如何选择

二、发现对方

在局域网中查找识别我们可以基于DNS-SD协议。

1、DNS-SD协议介绍

DNS-SD(Domain Name System - Service Discovery)是一种用于在局域网(Local Area Network,LAN)中发现服务的协议。它使用DNS协议扩展了域名系统(Domain Name System,DNS)的功能,使得客户端能够在局域网中查找特定类型的服务,并获取有关该服务的信息。

在Android 中也有对应的Api可以使用:NsdManager。 NsdManager主要实现三个功能:

  • 注册
  • 发现
  • 解析

其中一台设备作为服务端(即上面的Server App)通过NsdManager注册服务,提供自己的IP地址与端口号,同时可以提供用于标识自己的信息,例如设备ID。当一个局域网中有多台服务时,客户端在发现服务后,可以基于标识信息确认是否为自己需要找到的服务端。

另一台设备作为客户端(即上面的Client App),通过NsdManager的发现接口去发现服务。发现服务之后,再调用解析接口,获取到对应的信息,如服务端的IP地址、端口号、设备标识信息等。

详细的官方介绍地址:developer.android.com/reference/a...

2、注册

作为服务端的Server App,通过NsdManager注册。

less 复制代码
NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
NsdServiceInfo serviceInfo = new NsdServiceInfo();
serviceInfo.setServiceName(ServiceName);
serviceInfo.setServiceType(ServiceType);
int localPort = getLocalPort();
serviceInfo.setPort(localPort);
serviceInfo.setAttribute(Attribute_UUID, UuidManager.INSTANCE.getUUID());
String ipAddress = LocalIpUtils.getIPAddress();
serviceInfo.setAttribute(Attribute_IP, ipAddress);
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, new NsdManager.RegistrationListener() {
    @Override
    public void onRegistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {
        //异常上报
    }

    @Override
    public void onUnregistrationFailed(NsdServiceInfo nsdServiceInfo, int i) {}

    @Override
    public void onServiceRegistered(NsdServiceInfo nsdServiceInfo) {
        //注册成功
    }

    @Override
    public void onServiceUnregistered(NsdServiceInfo nsdServiceInfo) {}
});

3、发现

Client App,调用NsdManager#discoverServices()接口去发现服务。

less 复制代码
NsdManager nsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE);
discoveryListener = new NsdManager.DiscoveryListener() {
    @Override
    public void onStartDiscoveryFailed(String s, int i) {}

    @Override
    public void onStopDiscoveryFailed(String s, int i) {}

    @Override
    public void onDiscoveryStarted(String s) {}

    @Override
    public void onDiscoveryStopped(String s) {}

    @Override
    public void onServiceFound(NsdServiceInfo nsdServiceInfo) {}

    @Override
    public void onServiceLost(NsdServiceInfo nsdServiceInfo) {}
};
nsdManager.discoverServices(NsdServer.ServiceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener);

4、解析

Client App 收到onServcieFound回调之后,就可以调用解析接口解析数据了

typescript 复制代码
nsdManager.resolveService(nsdServiceInfo, new NsdManager.ResolveListener() {
    @Override
    public void onServiceResolved(NsdServiceInfo nsdServiceInfo) {
        Map<String, byte[]> attributes = nsdServiceInfo.getAttributes();
    }

    @Override
    public void onResolveFailed(NsdServiceInfo nsdServiceInfo, int i) {}
});

二、TCP通信

发现设备之后,我们要考虑两台设备要如何通信。此时我们可以有两套方案,分别是HTTP以及TCP。

HTTP

使用HTTP的话,就是在Server App上启动一个HTTP服务,我们可以预定义一些接口用于和Client来通信。 优点是比较简单,缺点是Server App没有办法主动通知Client App。

TCP

直接使用TCP协议的话,我们可以考虑选择一个支持TCP通信的框架,自定义一个简易的通信协议。优点是客户端与服务端可以互相通信,满足我们的需求。缺点是相对复杂一些。

在我们的项目中,我们有两台设备交互通信的需求,所以选择了TCP协议。

1、TCP通信库的选择

在我们的项目中使用了Netty作为TCP通信框架。关于Netty其大名鼎鼎,网络上的分析文章一大把,这里就不详细介绍了。Netty在国内有一位步道的大神名为李林峰(在华为工作),有兴趣的同学可以去看看大神的书。

2、简易的TCP协议设计

由于业务比较简单,所以这里我们将TCP通信协议进行简化。整体协议大致如下:IntString。

其中Int用于指定后面String字符串长度,我们可以基于这个Int来处理拆包、粘包的问题,这个逻辑后面详细介绍。String可以是JSON结构,可以依据业务来定义JSON中字段。

3、TCP服务端

使用Netty实现TCP服务端大致代码

scss 复制代码
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();
//构建引导程序
mBootstrap = new ServerBootstrap();
//设置EventGroup
mBootstrap.group(bossGroup, workerGroup);
//设置Channel
mBootstrap.channel(NioServerSocketChannel.class);
mBootstrap.option(ChannelOption.SO_BACKLOG, 128);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.childHandler(new CustomChannelInitializer());
channelFuture = mBootstrap.bind(inetPort).sync();

以上TCP服务就启动了。

4、TCP客户端

使用Netty实现TCP客户端大致代码:

初始化

scss 复制代码
//构建线程池
mGroup = new NioEventLoopGroup();
//构建引导程序
mBootstrap = new Bootstrap();
//设置EventGroup
mBootstrap.group(mGroup);
//设置Channel
mBootstrap.channel(NioSocketChannel.class);
//设置的好处是禁用Nagle算法。表示不延迟立即发送
//这个算法试图减少TCP包的数量和结构性开销,将多个较小的包组合较大的包进行发送。
//这个算法收TCP延迟确认影响,会导致相继两次向链接发送请求包。
mBootstrap.option(ChannelOption.TCP_NODELAY, false);
mBootstrap.option(ChannelOption.SO_KEEPALIVE, true);
mBootstrap.remoteAddress(ip.getIp(), ip.getPort());
mBootstrap.handler(new CustomChannelInitializer());

建立连接

java 复制代码
ChannelFuture channelFuture = mBootstrap.connect();
channelFuture.addListener(new FutureListener() {
    @Override
    public void operationComplete(Future future) {
        final boolean isSuccess = future.isSuccess();
        writeLog("operation complete future.isSuccess: " + isSuccess);
    }
});

断开连接

php 复制代码
public void disConnect(boolean onPurpose) {
    try {
        if (mGroup != null) {
            mGroup.shutdownGracefully();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    try {
        if (mChannel != null) {
            mChannel.close();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    try {
        if (mChannelHandlerContext != null) {
            mChannelHandlerContext.close();
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

我们可以在operationComplete()方法中,确认建立连接成功。

ChannelInitializer

为了让更多协议和其他各种方式处理数据,Netty有了Handler组件。Handler就是为了处理Netty里面的置顶事件或一组事件。 ChannelInitializer 的作用就是将Handler 添加到ChannelPipeline中。当你发送或收到消息的时候,这些Handler就决定怎么处理你的数据。

java 复制代码
public class CustomChannelInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel socketChannel) {
        ChannelPipeline pipeline = socketChannel.pipeline();
        mCustomDecoder = new CustomDecoder();
        mCustomEncoder = new CustomEncoder();
        mCustomHandler = new CustomHandler();
        
        pipeline.addLast(mCustomDecoder);
        pipeline.addLast(mCustomEncoder);
        pipeline.addLast(mCustomHandler);
    }

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
        super.handlerAdded(ctx);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
        super.exceptionCaught(ctx, cause);
    }
}

ChannelPipeline 是一个管道,Handler就是里面一层一层要对数据进行处理的事件。所有的Handler都一个顶层接口ChannelHandler。ChannelHandler有两个子接口:

  • ChannelInboundHandler
  • ChannelOutboundHandler

Netty中数据流有2个方向:

  • 数据进(应用收到消息)的时候与ChannelInboundHandler有关。
  • 数据出(应用发出数据)的时候与ChannelOutboundHandler有关。

为了将数据从一端发送到另一端,一般都会有一个或多个ChannelHandler用各种方式对数据进行操作。 决定这些Handler以一种特定的顺序处理数据的是ChannelPipeline。

ChannelInboundHandler与ChannelOutboundHandler可以混在同一个ChannelPipeline里面。当应用收到数据时,首先从ChannelPipeline的头部进入到一个ChannelInboundHandler 。第一个ChannelInboundHandler处理后传给下一个ChannelInboundHandler。 然后ChannelPipeline中没有其他的ChannelInboundHandler 了数据就会到达ChannelPipeline的尾部,也就是应用对数据的处理已经完成了。

数据流出的过程是返过来的,首先从ChannelPipeline的尾部开始进入到最后一个ChannelOutboundHandler,最后一个ChannelOutboundHandler处理后,传给前面一个ChannelOutboundHandler。 和进不同的,进是从前到后,而出是从后到前。没有多余的ChannelOutboundHandler的时候,数据进入实际网络中传输,触发一些IO操作。

一旦一个ChannelHandler被添加到ChannelPipeline中,它就会获得一个ChannelHandlerContext。一般情况下,获得这个对象并持有它是安全的, 不过在数据包协议的时候不一样安全,例如UDP协议。 在Netty中有两种发送数据的方式。你可以写到Channel中或者使用ChannelhandlerContext对象。他们的主要区别是,直接写到Channel,则数据会从ChannelPipeline头部传到尾部。 每一个ChannelHandler都会处理数据,而使用ChannelHandlerContext则是将数据传送到下一个ChannelHandler。

一个 Channel 包含了一个 ChannelPipeline, 而 ChannelPipeline 中又维护了一个由 ChannelHandlerContext 组成的双向链表, 并且每个 ChannelHandlerContext 中又关联着一个 ChannelHandler。 入站事件和出站事件在一个双向链表中,入站事件会从链表head往后传递到最后一个入站的handler,出站事件会从链表tail往前传递到最前一个出站的handler,两种类型的handler互不干扰。

我们在 CustomChannelInitializer#initChannel()方法中添加编码、解码器。其中 CustomHandler 继承自SimpleChannelInboundHandler用来接收消息。

三、编解码

当你用Netty接收或发送消息,必须将其从一种格式转成另一种格式。比如收消息,你需要从字节转为Java对象。发消息就是将Java对象转成字节发出去。

Netty中有各种各样的编码器和解码器基类。

  • ByteToMessageDecoder
  • MessageToByteEncoder
  • ProtobufEncoder
  • ProtobufDecoder
  • StringDecoder
  • StringEncode

这里,编码器都是继承自ChannelInboundHandlerAdapter或者实现了ChannelInboundHandler。当读到数据时,会调用ChannelRead方法。重写此方法,然后就会调用decode 方法进行解码。并且会执行ChannelHandlerContext,fireChannelRead方法,将解码后的消息传给下一个Channelhandler。 当发送消息的时候,也执行类似的过程,编码器将消息转为字节,然后传给下一个ChannelOutboundHandler。

在我们的项目中,由于自定义了协议所以使用了ByteToMessageDecoder、MessageToByteEncoder。

1、编码

编码器比较简单,我们可以自定义类继承 MessageToByteEncoder 来实现解码。 需要注意的是,按照我们定义的协议顺序向 ByteBuf 中写入数据就好了。示例代码:

scala 复制代码
public class CustomEncoder extends MessageToByteEncoder<CustomMessage> {

    @Override
    protected void encode(ChannelHandlerContext channelHandlerContext, CustomMessage message, ByteBuf byteBuf) {
        //byteBuf.writeInt();
        //byteBuf.writeByte();
    }

}

2、解码

解码我们要解决TCP拆包、粘包的问题。

一般TCP中应对拆包、粘包基本有以下几种方案:

  • 消息定长,这但比较好理解,由于定长了,我们可以直接判断ByteBuffer中数组长度。不过在实践过程中,一般我们不会使用这个方案,因为扩展性太差了。
  • 使用特殊字符作为结尾。
  • 自定义协议。

前面我们有提到我们的通信协议为IntString实际上就是一个非常简易的自定义协议(由于业务简单,所以这里没有设计的非常复杂)。我们使用Int来标记后面的String长度,这样两端收到消息后,按照这个格式解析实际上就知道消息的长度了。

在Netty中,我们可以自定义类继承自 ByteToMessageDecoder,来处理解码。Netty中 ByteBuf 来处理数据。具体的步骤是:

  • 先标记已读位置:byteBuf.markReaderIndex();
  • 判断 ByteBuf 可读是否达到4个字节长度,如果不足直接返回,重置已读位置。
  • 读取前4个字节,转为Int。
  • 如果字符长度 > 0,那么接下来继续读 length 长度的字符,转为String。
  • 这样整个协议就解码完成了。

这里需要注意的是:客户端要与服务端约定好是大端字节序还是小端字节序。

以上在局域网中,两款App进行TCP通信就已经搭建好了,我们有了协议上层业务就可以基于此来封装业务需要的逻辑了。实际上很多IM 通信SDK,基本上都是自定义通信协议,只是协议会比本篇中举例的协议要复杂的多。

四、优化点

项目上线一段时间后,用户反馈偶现存在两台设备无法通信的问题。后面经过调研发现是服务端进程一直存在(即Server App 并没有挂),只是TCP服务挂了。

我们在客户端有处理断线重连逻辑,但是在服务端没有做任何监控重启逻辑。

如何优化

经过调研,我们在服务端Server App中,另外启动一个客户端来与TCP服务端建立连接(相当于是我监控我自己了),如果建立失败,就重启TCP服务。

  • 建立一个TCP客户端,启动轮询逻辑
  • 如果该客户端没有与服务端建立连接,那么尝试建立连接。
  • 如果连续三次都无法建立成功连接,那么认为此时TCP服务存在异常,重启TCP服务。
  • 如果可以正常与TCP服务建立连接,那么开始向TCP服务发送心跳包
  • 如果连续三次TCP服务没有回复心跳回包,那么也认为TCP服务存在异常,重启TCP服务。 以上,就是我们针对TCP服务端的优化,上面逻辑上线后,无法建立连接的反馈就没有了。

五、总结

本篇主要是介绍如何在局域网中,两个APP使用Dns-SD协议来发现对方。自定义协议进行TCP通信,使用Netty作为TCP通信框架。

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
CHENKONG_CK3 天前
破解制鞋打磨痛点:RFID赋能去毛刺工序自动化升级
网络·单片机·嵌入式硬件·网络协议·tcp/ip
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas