两台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通信协议进行简化。整体协议大致如下:[Int][String]。

其中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中数组长度。不过在实践过程中,一般我们不会使用这个方案,因为扩展性太差了。
  • 使用特殊字符作为结尾。
  • 自定义协议。

前面我们有提到我们的通信协议为[Int][String]实际上就是一个非常简易的自定义协议(由于业务简单,所以这里没有设计的非常复杂)。我们使用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通信框架。

相关推荐
studyForMokey4 小时前
kotlin 函数类型接口lambda写法
android·开发语言·kotlin
梁同学与Android7 小时前
Android --- 新电脑安装Android Studio 使用 Android 内置模拟器电脑直接卡死,鼠标和键盘都操作不了
android·ide·android studio
听楷哥说跨境8 小时前
UDP协议:独特之处及其在网络通信中的应用
网络·网络协议·tcp/ip·ip
山雨楼9 小时前
ExoPlayer架构详解与源码分析(14)——ProgressiveMediaPeriod
android·架构·音视频·源码·exoplayer·media3
cdsn123jian10 小时前
linux 发送报文的几种方式
linux·服务器·网络·tcp/ip
IsaacBan11 小时前
XJBX-6-Android启动App进程
android
DoubleYellowIce11 小时前
Android Studio阅读frameworks源码的正确姿势
android·android studio
分享者花花11 小时前
最佳 iPhone 解锁软件工具,可免费下载用于电脑操作的
android·windows·macos·ios·pdf·word·iphone
从后端到QT12 小时前
QT TCP网络通信编程
开发语言·qt·tcp/ip
爱编程的小猴15 小时前
【面试题】Reactor模型
开发语言·网络·网络协议·tcp/ip