在Android上,一个完整的UDP通信模块应该是怎样的?-阿里云开发者社区

开发者社区> 开发与运维> 正文
登录阅读全文

在Android上,一个完整的UDP通信模块应该是怎样的?

简介:

CP与UDP差异对比分析

在这篇文章中,在可靠性、数据发送、适用场景等多个方面分析了二者的区别。而本文的目的是想给大家介绍下在 Android 设备上,一个手机通过热点连接另一个手机。这种场景下,完整的 UDP 通信模块应该考虑哪些方面,又应该如何优化,如何避开一些坑呢?

在Android上,一个完整的UDP通信模块应该是怎样的?

UDP 在 Java 中的使用

我们都知道,开发一个 Android 应用程序,目前大多数还是使用的是 Java 语言。在 Java 语言中怎么去使用 UDP 协议呢?

上篇文章中我们没说 Socket,其实 Socket 可以理解为对 TCP、UDP 协议在程序使用层面的封装,提供出一些 api 来供程序员调用开发,这就是 Socket 最表层的含义。

在 Java 中,与 UDP 相关的类有 DatagramSocket、DatagramPacket 等,关于他们的使用,这里不着重介绍。

好了,假设大家对他们的使用都已大概了解,可以正式开始本文的内容了。

初始化一个 UDPSocket

首先创建一个叫 UDPSocket 的类。


  1. public UDPSocket(Context context) { 
  2.         this.mContext = context; 
  3.         int cpuNumbers = Runtime.getRuntime().availableProcessors(); 
  4.         // 根据CPU数目初始化线程池 
  5.         mThreadPool = Executors.newFixedThreadPool(cpuNumbers * POOL_SIZE); 
  6.         // 记录创建对象时的时间 
  7.         lastReceiveTime = System.currentTimeMillis(); 
  8.     } 

在构造方法里,我们进行下一些初始化操作,简单来说就是创建一个线程池,记录一下当前时间毫秒值,至于他们有什么用,再往下看:


  1. public void startUDPSocket() { 
  2.         if (client != nullreturn
  3.         try { 
  4.             // 表明这个 Socket 在设置的端口上监听数据。 
  5.             client = new DatagramSocket(CLIENT_PORT); 
  6.             if (receivePacket == null) { 
  7.                 // 创建接受数据的 packet 
  8.                 receivePacket = new DatagramPacket(receiveByte, BUFFER_LENGTH); 
  9.             } 
  10.             startSocketThread(); 
  11.         } catch (SocketException e) { 
  12.             e.printStackTrace(); 
  13.         } 
  14.     } 

这里我们首先创建了一个 DatagramSocket 作为“客户端”,其实 UDP 本身没有客户端和服务端的概念,只有发送方和接收方的概念,我们把发送方暂时当成是一个客户端吧。

创建 DatagramSocket 对象时,传入了一个端口号,这个端口号可以在一个范围内自己定义,表示这个 DatagramSocket 在此端口上监听数据。

然后又创建了一个 DatagramPacket 对象,作为数据的接收包。

最后调用 startSocketThread 启动发送和接收数据的线程。


  1. /** 
  2.      * 开启发送数据的线程 
  3.      */ 
  4.     private void startSocketThread() { 
  5.         clientThread = new Thread(new Runnable() { 
  6.             @Override 
  7.             public void run() { 
  8.                 Log.d(TAG, "clientThread is running..."); 
  9.                 receiveMessage(); 
  10.             } 
  11.         }); 
  12.         isThreadRunning = true
  13.         clientThread.start(); 
  14.         startHeartbeatTimer(); 
  15.     } 

首先 clientThread 线程的目的是调用 DatagramSocket receive 方法,因为 receive 方法是阻塞的,不能放在主线程,所以自然开启一个子线程了。receiveMessage 就是处理接受到的 UDP 数据报,我们先不看接受数据的这个方法,毕竟还没人发消息呢,自然就谈不上收了。

心跳包保持“长连接”

来到本文的第一个重点,我们都知道 UDP 本身没有连接的概念。在 Android 端应用 UDP 和 TCP 的场景是一个手机连接另一个手机的热点,二者处在同一局域网中。在二者并不知道对方的存在时,怎么才能发现彼此呢?

通过心跳包的方式,双方都每隔一段时间发一个 UDP 包,如果对方接收到了,那就能知道对方的 ip,建立起通信了。


  1. private static final long TIME_OUT = 120 * 1000; 
  2.     private static final long HEARTBEAT_MESSAGE_DURATION = 10 * 1000; 
  3.     /** 
  4.      * 启动心跳,timer 间隔十秒 
  5.      */ 
  6.     private void startHeartbeatTimer() { 
  7.         timer = new HeartbeatTimer(); 
  8.         timer.setOnScheduleListener(new HeartbeatTimer.OnScheduleListener() { 
  9.             @Override 
  10.             public void onSchedule() { 
  11.                 Log.d(TAG, "timer is onSchedule..."); 
  12.                 long duration = System.currentTimeMillis() - lastReceiveTime; 
  13.                 Log.d(TAG, "duration:" + duration); 
  14.                 if (duration > TIME_OUT) {//若超过两分钟都没收到我的心跳包,则认为对方不在线。 
  15.                     Log.d(TAG, "超时,对方已经下线"); 
  16.                     // 刷新时间,重新进入下一个心跳周期 
  17.                     lastReceiveTime = System.currentTimeMillis(); 
  18.                 } else if (duration > HEARTBEAT_MESSAGE_DURATION) {//若超过十秒他没收到我的心跳包,则重新发一个。 
  19.                     String string = "hello,this is a heartbeat message"
  20.                     sendMessage(string); 
  21.                 } 
  22.             } 
  23.         }); 
  24.         timer.startTimer(0, 1000 * 10); 
  25.     } 

这段心跳的目的就是每隔十秒通过 sendMessage 发送一个消息,看看对方能不能收到。若对方收到消息,则刷新下 lastReceiveTime 的时间。

这里我每隔十秒向对方发送了一个字符串。


  1. private static final String BROADCAST_IP = "192.168.43.255"
  2.     /** 
  3.      * 发送心跳包 
  4.      * 
  5.      * @param message 
  6.      */ 
  7.     public void sendMessage(final String message) { 
  8.         mThreadPool.execute(new Runnable() { 
  9.             @Override 
  10.             public void run() { 
  11.                 try { 
  12.                     InetAddress targetAddress = InetAddress.getByName(BROADCAST_IP); 
  13.                     DatagramPacket packet = new DatagramPacket(message.getBytes(), message.length(), targetAddress, CLIENT_PORT); 
  14.                     client.send(packet); 
  15.                     Log.d(TAG, "数据发送成功"); 
  16.                 } catch (UnknownHostException e) { 
  17.                     e.printStackTrace(); 
  18.                 } catch (IOException e) { 
  19.                     e.printStackTrace(); 
  20.                 } 
  21.             } 
  22.         }); 
  23.     } 

这里就是发送一个消息的代码。最初在填写 DatagramPacket 的参数之时,我有一个疑问,那个 targetAddress 其实是自己的 ip 地址。问题来了,我填写了自己的 ip 地址和对方的端口,怎么可能找得到对方呢?你可能有一个疑惑 "192.168.43.255" 这个自己的 ip 地址是怎么来的,为什么要这么定义?

首先 android 手机开启热点,可以理解成一个网关,有一个默认的 ip 地址:"192.168.43.1"

这个 ip 地址不是我瞎编的一个,在 Android 源码之中,就是这么定义的:

WifiStateMachine


  1. ifcg = mNwService.getInterfaceConfig(intf); 
  2.                         if (ifcg != null) { 
  3.                             /* IP/netmask: 192.168.43.1/255.255.255.0 */ 
  4.                             ifcg.setLinkAddress(new LinkAddress( 
  5.                                     NetworkUtils.numericToInetAddress("192.168.43.1"), 24)); 
  6.                             ifcg.setInterfaceUp(); 
  7.                             mNwService.setInterfaceConfig(intf, ifcg); 
  8.                         } 

所以我是知道所谓打开热点一方的 ip 地址,而 UDP 发送消息时还有一个特性,就是发出去的消息,处在整个网关的设备是都可以接收到的,所以我自己的 ip 地址就定为了 "192.168.43.255",所以这个 ip 地址和 "192.168.43.1" 在同一网关中,你发送的消息,它是可以收到的。

至于怎么判断两个 ip 地址是否处在同一网段中:

判断两个IP大小及是否在同一个网段中

来做一个阶段总结:

首先我们创建了一个发送端 DatagramSocket,启动了一个心跳程序,每间隔一段时间发送一个心跳包。

因为我知道热点方的 ip 地址是默认的 "192.168.43.1",并且 UDP 的特性就是发送的消息同一网段的设备都可以收到。所以发送方的 ip 地址定为了与热点一方处在同一网段的 "192.168.43.255"。

事件与数据

事件与数据这两个模块与业务就紧密相关了。

先来说数据,双方发送的数据格式你们可以随意定义,当然我觉得还是定义成常规的 Json 格式就好。其中可以包含一些关键的事件字段:比如广播心跳包、收到心跳包给对方上线的应答包、超时的下线包、以及各种业务相关的数据等等。

当然发送数据时是转换成二进制数组发送的。发送中文字符、图片等都没有问题,但是可能有一些细节需要注意,随时 google 一下就好了。

再来说下事件:

与业务无关的事件有哪些?

比如:

  • DatagramSocket.send 方法之后就是发送数据成功的事件;
  • DatagramSocket.receive 方法之后是数据接收成功的事件;
  • 在心跳包发送一段时间,仍没有接到回信时,是连接超时的事件;
  • 与业务相关的事件就和我们上文提到的数据类型有关了,设备上线,心跳包回应等等。

事件又如何发送出去,通知到各个页面呢?用 Listener、或者其他事件总线的三方库都没问题,看你自己选择了。

处理接收的消息


  1. /** 
  2.     * 处理接受到的消息 
  3.     */ 
  4.    private void receiveMessage() { 
  5.        while (isThreadRunning) { 
  6.            try { 
  7.                if (client != null) { 
  8.                    client.receive(receivePacket); 
  9.                } 
  10.                lastReceiveTime = System.currentTimeMillis(); 
  11.                Log.d(TAG, "receive packet success..."); 
  12.            } catch (IOException e) { 
  13.                Log.e(TAG, "UDP数据包接收失败!线程停止"); 
  14.                stopUDPSocket(); 
  15.                e.printStackTrace(); 
  16.                return
  17.            } 
  18.            if (receivePacket == null || receivePacket.getLength() == 0) { 
  19.                Log.e(TAG, "无法接收UDP数据或者接收到的UDP数据为空"); 
  20.                continue
  21.            } 
  22.            String strReceive = new String(receivePacket.getData(), 0, receivePacket.getLength()); 
  23.            Log.d(TAG, strReceive + " from " + receivePacket.getAddress().getHostAddress() + ":" + receivePacket.getPort()); 
  24.            //解析接收到的 json 信息 
  25.            // 每次接收完UDP数据后,重置长度。否则可能会导致下次收到数据包被截断。 
  26.            if (receivePacket != null) { 
  27.                receivePacket.setLength(BUFFER_LENGTH); 
  28.            } 
  29.        } 
  30.    } 

处理接收消息时,有几个值得注意的点:

  • receive 方法是阻塞的,没收到数据包时会一直阻塞,所以要放到子线程中;
  • 每次接收到消息之后,重新调用 receivePacket.setLength;
  • 收到消息刷新lastReceiveTime的值,暂停心跳包的发送;
  • 处理收到的数据具体在业务上就是刚才我们谈的发送数据的问题,视业务而定。

“用户”的概念

上文已经谈过了 UDP 的特性,假如一个手机已经开启了热点,若多个手机与他相连接,则多个手机发送的消息它都可以收到。如果发送方的端口与接收方的端口相同的话,甚至自己发的消息,自己都可以收到。这就很尴尬了,也就是说我们既要剔除自己发给自己的消息,也得区分不同手机发来的消息,这个时候就理应有一个“用户”的概念。

创建 User 对象,有哪些属性可以看自己的业务,本文的例子就有 ip、imei、以及 softversion。


  1. /** 
  2.     * 创建本地用户信息 
  3.     */ 
  4.    private void createUser() { 
  5.        if (localUser == null) { 
  6.            localUser = new Users(); 
  7.        } 
  8.        if (remoteUser == null) { 
  9.            remoteUser = new Users(); 
  10.        } 
  11.        localUser.setImei(DeviceUtil.getDeviceId(mContext)); 
  12.        localUser.setSoftVersion(DeviceUtil.getPackageVersionCode(mContext)); 
  13.        if (WifiUtil.getInstance(mContext).isWifiApEnabled()) {// 判断当前是否是开启热点方 
  14.            localUser.setIp("192.168.43.1"); 
  15.        } else {// 当前是开启 wifi 方 
  16.            localUser.setIp(WifiUtil.getInstance(mContext).getLocalIPAddress()); 
  17.            remoteUser.setIp(WifiUtil.getInstance(mContext).getServerIPAddress()); 
  18.        } 
  19.    } 
  20.    /** 
  21.     * <p><b>IMEI.</b></p> Returns the unique device ID, for example, the IMEI for GSM and the MEID 
  22.     * or ESN for CDMA phones. Return null if device ID is not available. 
  23.     * <p> 
  24.     * Requires Permission: READ_PHONE_STATE 
  25.     * 
  26.     * @param context 
  27.     * @return 
  28.     */ 
  29.    public synchronized static String getDeviceId(Context context) { 
  30.        if (context == null) { 
  31.            return ""
  32.        } 
  33.        String imei = ""
  34.        try { 
  35.            TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); 
  36.            if (tm == null || TextUtils.isEmpty(tm.getDeviceId())) { 
  37.                // 双卡双待需要通过phone1和phone2获取imei,默认取phone1的imei。 
  38.                tm = (TelephonyManager) context.getSystemService("phone1"); 
  39.            } 
  40.            if (tm != null) { 
  41.                imei = tm.getDeviceId(); 
  42.            } 
  43.        } catch (SecurityException e) { 
  44.            e.printStackTrace(); 
  45.        } 
  46.        return imei; 
  47.    }</p> 

这里就不将所有的代码展开来看了。如果有了手机的 imei 号,那很容易就可以来做身份的区分,你既可以区分不同的发送方,也可以剔除掉自己发给自己的消息。当然如果需要更多的信息,可以按照自己的业务区分,将这些信息作为发送的 messge,通过 Socket 发送。

写在后面:

到现在开始本文的大部分内容都已经介绍完成,有的同学可能会发问,你要用一个心跳来维持一个假的“长连接”,使用起来比较麻烦,而且还可能忍受 UDP 造成的丢包的痛苦,为什么不选择 TCP 呢?问得好,其实这个版本是当时做的第一个版本,之后就使用 TCP+UDP 的方式来完成这个模块了,下一篇文章再来看看加上 TCP 的改进版吧。     


原文发布时间为:2017-10-17

本文作者:佚名

本文来自云栖社区合作伙伴“51CTO”,了解相关信息可以关注。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章
最新文章
相关文章