简介:一套以Java语言编写的安卓端UDP报文发送小程序,面向初学网络编程的开发者,可用于局域网内的UDP包发送、连通性测试以及WiFi定位相关实验。项目核心包含发送客户端与主界面逻辑,使用DatagramSocket和DatagramPacket完成创建套接字、封装目标IP与端口、调用send发送、关闭套接字等关键步骤,代码量小,便于逐行阅读和二次修改。压缩包共40个文件,整体仅有97KB,其中以17个XML配置与界面布局文件、4个Java源文件、3个Gradle构建脚本为主,另含PNG图标、README说明、ProGuard规则和Gradle Wrapper,导入Android Studio即可查看运行。截至目前已有505人学习下载,通过完整源码可以查看网络权限声明、发送逻辑、界面布局以及广播接收等设计,结合UDP无连接、不可靠的特性,能帮助理解实时视频、在线游戏等场景下为何选用UDP。也可以继续扩展回包解析、超时重发和日志记录,快速搭建自己的局域网网络调试工具。 前段时间在现场调一台嵌入式设备,手头只有手机没有电脑,想验证设备的 UDP 服务端口是不是活着、能不能正确解析我发的报文,翻了半天应用商店,要么是广告满天飞的「网络工具箱」,要么是功能大杂烩但关键按钮藏得极深。后来实在受不了,在回程高铁上花了半小时写了个安卓小程序,取名 UDPSend,只做一件事:往指定 IP 和端口发送 UDP 数据包。
这个小工具我到现在还留着。做嵌入式、工控、机器人通信的人应该都有同感——不是什么时候手边都有一台笔记本,但手机几乎不离身。这篇内容就把 UDPSend 从需求到实现的关键细节拆开讲一遍,包括 UDP 协议里和安卓开发强相关的几个点、工具的设计取舍、核心代码怎么写、真机上最容易踩的坑,以及怎么验证工具本身是不是可靠。适合正在做安卓网络开发、或者是被 UDP 调试折腾过的朋友参考。
1. 为什么我会动手写一个安卓端 UDP 发送工具
1.1 现场调试设备时,我突然意识到手机比电脑更顺手
那次调试的对象是一个带 WiFi 模块的传感器盒子,业务逻辑是把采集到的数据通过 UDP 主动上报到指定端口。设备端日志只显示「发送失败」,但失败在哪一段完全看不出来。当时我最需要的是一个能往任意 IP:端口 发 UDP 包的工具,用来区分是设备端问题、网络问题还是服务端问题。
这种场景其实很常见:
- 开发板上跑着 UDP 服务,你想快速验证它能不能收到数据
- 现场没有串口线也没带电脑,只能靠手机 WiFi 连设备
- 要和 WSL2 里的 Ubuntu 子系统做 UDP 通讯测试,手边只有手机
- 用 LabVIEW 或 C# 做上位机 UDP 通信时,需要一个独立的发包端做对照实验
用手机发 UDP 包,比在电脑上敲命令行更直观。触摸屏点几下就能发,而且手机就在口袋里。至于为什么要自己写而不是随便找个工具,下面说。
1.2 为什么不想用现成的「网络调试助手」
应用商店里叫「网络调试助手」的应用一抓一大把,但真正好用的不多。我归纳了几类痛点:
- 包体臃肿。为了一个 UDP 发包功能,附带了一堆根本不用的「TCP 服务端」「WebSocket 测试」「DNS 查询」功能,安装包几十上百 MB。
- 广告和权限问题。很多工具类应用内置广告 SDK,申请一堆和网络调试毫无关系的权限,在一个现场调试工具里,这些都属于不可控因素。
- 年久失修。不少老牌工具还是几年前发布的,targetSdk 版本低,在 Android 13、14 上可能闪退,或者受到后台限制导致发包异常。
- 最关键的一点:别人写的工具,你没法确定它底层怎么处理数据。比如十六进制模式下要不要过滤空格、字符串模式下用的是什么字符集,这些细节对方不写文档你根本猜不到,出了问题反而更难排查。
所以我的结论很直接:这种二十几 KB 就能搞定的小工具,自己写一个比找一个完全信任的第三方应用成本更低。行为完全可控,逻辑简单,代码量少,出了问题自己就能查。
2. 动手前必须吃透的 UDP 关键点
2.1 「发送成功」不等于对方收到了
这是 UDP 调试里最容易被忽略的一点。TCP 是面向连接的,发送前要三次握手,对端确认收到了才算完;UDP 则是无连接的,你调用send()只是把数据报交给了本机操作系统的协议栈,协议栈会尽力把它发出去,但中途会不会丢、对端有没有收到、对端进程有没有绑定端口在监听,全部没有反馈。
这就带来一个很重要的调试思维转变:用 UDP 工具发包,如果对端没反应,不要第一步就怀疑工具坏了。send()成功只是说明「本机发送成功」,而不是「对端接收成功」。完整的验证链路要配合对端日志或者回显服务来判断,这个后面会详细展开。
2.2 一次 send 对应一次 recv,别和 TCP 搞混
UDP 是数据报协议,发送端每调用一次send(),接收端的recvfrom()就会读到一条完整的数据报。它不像 TCP 那样是字节流,不存在粘包问题,但存在「包边界」概念。
这个特性对工具设计是有影响的:UDP 发送工具不需要像 TCP 工具那样提供「分包发送」「流式发送」之类的功能,一次点击发送一个数据报,简单直接。但要注意 MTU 的限制。以太网标准 MTU 是 1500 字节,减去 IP 头和 UDP 头各 20 字节和 8 字节,单包数据超过 1472 字节后就会触发 IP 分片。分片报文在跨路由器时容易被丢弃,实际调试中建议单包不超过 1400 字节。UDP 理论最大载荷是 65507 字节,但我在代码里加了长度校验,超过 65507 直接报错,避免构造出无法发送的非法包。
2.3 广播地址和组播地址,是 UDP 的独门招牌
TCP 是点对点的,而 UDP 天然支持广播和组播。局域网里很多设备发现协议就是靠 UDP 广播实现的,比如打印机发现、SSDP、部分工控设备的自动发现机制。
所以我在设计 UDPSend 时特意保留了广播地址功能:输入框里填255.255.255.255,就能向局域网内所有设备广播一条消息;填224.0.0.1之类的组播地址,也能往组播组里发数据。这个能力在调试设备自动发现逻辑时特别有用。
注意:Android 设备发送 UDP 广播前,确认 WiFi 路由器没有开启 AP 隔离。这个坑后面会专门说。
另外补充一个容易被坑的知识点:UDP 协议本身没有规定字节序,网络上统一的规矩是网络字节序大端(Big-Endian)。嵌入式设备协议里经常有「帧头 + 长度 + 数据」之类的结构,如果对方用的是小端模式,工具最好能提供字节序切换选项。UDPSend 第一版没做这个,后面扩展时再加。
3. 只做「发送」一件事:UDPSend 的设计取舍
3.1 只做发送,不做接收
最开始我也纠结过要不要把接收功能一起做进去——毕竟「能发能收」听起来才完整。但仔细想了想,接收功能会引入一堆额外问题:
- 接收需要绑定本地端口,Android 上端口绑定和生命周期管理比发送复杂得多
- 接收要常驻监听线程,涉及后台运行限制、通知栏服务、省电策略等一系列问题
- 工具被迫申请更多权限,违背了「最小权限」原则
- 主界面要加接收日志区,交互复杂度翻倍
更重要的是,我做这个工具的初衷是「验证对端能不能收到」,判断依据是对端日志,不需要自己接收。如果要做完整的 UDP 调试助手,那是另一个量级的项目。第一版只做发送,代码量控制在几十行,逻辑清晰,出问题也容易排查。
3.2 选型:普通 Socket 加协程就够
技术选型上有几个常见选项,我的判断是:
- Java 传统
DatagramSocket:这是最直接的方案,几十行就能完成 UDP 发送,没有任何学习成本。它的阻塞式模型对「用户点击一次发一个包」这种低频操作完全够用。 - Kotlin 协程 +
withContext(Dispatchers.IO):用协程把网络操作放到后台线程,避免在主线程做网络操作导致 ANR,代码也比Thread写法简洁。 - NIO 的
DatagramChannel:适合高并发或者需要非阻塞收发的场景,对一个小工具来说是过度设计,还让代码更难读懂。 - 第三方网络库(Netty 等):功能强大,但引入依赖后包体积、崩溃风险、学习成本都会上来,不值当。
最终选型是DatagramSocket+ Kotlin 协程,核心发送类不超过 40 行。搭建界面用原生 XML 布局,不引入 Jetpack Compose,因为这个小项目完全没必要为一个按钮和两个输入框增加复杂度。
3.3 交互流程:一组输入框加一个按钮
界面设计遵循「单一操作路径」原则:打开应用,看到的就是目标 IP、目标端口、数据内容、发送按钮,外加一个十六进制模式的开关和简单的发送日志。
这背后是有讲究的。调试工具在真正的现场使用中有个很核心的需求——操作路径短。打开应用到发出第一个包,最好不超过三秒。如果界面有一堆 Tab、设置项、二级菜单,虽然在功能上更强大,但关键的发送按钮可能被埋得很深。这个取舍在工具类应用里非常重要。
4. 核心实现:从 DatagramSocket 到十六进制解析
4.1 一个 30 行的 UdpSender 核心类
先看最核心的发送类:
import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress class UdpSender { private var socket: DatagramSocket? = null @Synchronized fun send(ip: String, port: Int, data: ByteArray) { val address = InetAddress.getByName(ip) val packet = DatagramPacket(data, data.size, address, port) if (socket == null) { socket = DatagramSocket() } socket!!.send(packet) } @Synchronized fun close() { socket?.close() socket = null } }几个容易被忽略的细节:
DatagramSocket()无参构造器会由系统随机分配一个本地端口,发送端不需要关心本地端口是多少。- UDP 发送前不需要
connect(),DatagramPacket构造时把目标地址和端口传进去就行。虽然DatagramSocket.connect()也能调用,但那只是一种「过滤模式」,不是 TCP 那种真正的连接。 send()方法是阻塞的,不要在主线程调用。我在调用处用withContext(Dispatchers.IO)包了一层。- 用
@Synchronized保证并发场景下 socket 初始化和发送不冲突。如果用户快速连点多次发送,没有同步会导致创建多个 socket 实例,浪费资源还可能出现异常。
4.2 十六进制输入解析的隐藏坑
做网络调试工具,十六进制模式基本是标配。嵌入式设备的私有协议经常需要用十六进制字节序列来拼报文,比如设备 MAC 地址过滤、寄存器地址读写之类。但十六进制字符串转字节数组这个功能,看似简单,坑不少。
我踩过的坑包括:用户输入0x01 0x02 0x1F这种带前缀的格式、输入01:02:1F这种带冒号的格式、输入01021F这种连在一起的格式,还有0x01,0x02这种带逗号的格式。如果只处理一种格式,用户很容易被搞懵。
最终解析逻辑是这样处理的:
private fun parseHexString(input: String): ByteArray? { val cleaned = input.trim() .replace(Regex("0[xX]"), "") .replace(Regex("[\\s:,,、]"), "") if (cleaned.isEmpty() || cleaned.length % 2 != 0) { return null } return ByteArray(cleaned.length / 2) { i -> cleaned.substring(i * 2, i * 2 + 2).toInt(16).toByte() } }先统一去掉0x前缀、空格、冒号、逗号、顿号等分隔符,再判断剩余字符串长度是不是偶数,最后两两一组转成字节。这样0x01 0x02、01:02、01,02、0102四种输入都能正确解析。
这里有个新手容易踩的雷:如果用户输入了奇数长度的十六进制字符串,比如0x123,必须直接报错而不是猜测补零。很多工具会自作主张在末尾补零,这在网络协议里是致命错误,因为报文内容完全变了。
4.3 校验、异常与线程调度
发送逻辑再简单,也必须在界面上看到反馈。我对输入做了这些校验:
- IP 地址格式:用
InetAddress.getByName()解析,捕获UnknownHostException,提示「IP 地址格式不正确」 - 端口范围:0 到 65535,但 0 到 1024 之间是系统保留端口,通常不会用,校验时提醒「端口范围建议在 1024 到 65535 之间」
- 空数据拦截:发送内容为空时直接提示「请输入要发送的数据」
- 数据长度上限:超过 65507 字节直接拦截,提示「数据过长,UDP 单包最大 65507 字节」
- 异常捕获:
SocketException、SecurityException都捕获后转换成用户能看懂的错误消息,而不是让应用闪退
线程调度上,点击发送按钮后代码逻辑是这样的:
sendButton.setOnClickListener { val ip = ipEdit.text.toString() val port = portEdit.text.toString().toIntOrNull() val data = if (hexMode) parseHexString(dataEdit.text.toString()) else dataEdit.text.toString().toByteArray(Charsets.UTF_8) if (port == null || data == null) { showError("输入有误"); return@setOnClickListener } lifecycleScope.launch { val result = withContext(Dispatchers.IO) { try { sender.send(ip, port, data) "发送成功:${data.size} 字节" } catch (e: Exception) { "发送失败:${e.message}" } } logTextView.append("\n${Date().time} $result") } }这里有个界面体验的细节:发送结果要用时间戳加上日志的形式展示出来,而不是弹一个 Toast。因为调试时要连续发送多包,一个带时间戳的日志列表能让你看到每次发送的间隔,这对判断网络抖动、报文频率等非常有帮助。
4.4 中文编码不能想当然
字符串模式的编码也很关键。UDP 报文本质上是字节数组,字符串转字节时用的是什么字符集,对端就必须用什么字符集解析。我的实现里默认用 UTF-8,因为现代设备和系统基本都是 UTF-8,但要注意:一些老旧的嵌入式设备可能用的是 GBK 或者 ASCII,如果对显示乱码,需要手动切编码。这是工具类应用要不要做编码选项的经典取舍。第一版我先固定 UTF-8,扩展方向里加上编码切换。
5. 真机和模拟器上最容易踩的三个坑
5.1 模拟器的 NAT 网络:发到宿主机要用 10.0.2.2
如果你用的是 Android Studio 模拟器调试,有一个最大的网络坑:模拟器默认走 NAT 模式,模拟器里的「本机」不是宿主机。模拟器访问宿主机要使用特殊地址10.0.2.2,而不是127.0.0.1或者localhost。
也就是说,如果宿主机上开了一个 Python UDP 服务监听127.0.0.1:12345,你想从模拟器发 UDP 包过去,目标 IP 必须要填10.0.2.2,端口填12345。我第一次调试时就栽在这里,填了半天127.0.0.1,服务端一点反应都没有。
另外模拟器 NAT 模式下访问局域网其他设备也是受限的,不能直接拿模拟器去测局域网里的嵌入式设备。真机调试时完全没有这个问题,这也算是一个「模拟器能跑通不代表真机没问题」的典型例子。
5.2 路由器的 AP 隔离:WiFi 下「发送成功」也会失败
这个坑在真机调试时特别隐蔽。手机连上一个 WiFi 路由器,开发板也连同一个 WiFi,手机往开发板的 IP 发 UDP 包,send()返回成功,但开发板就是收不到。
排查到最后发现路由器开了 AP 隔离(也叫「客户端隔离」或「无线隔离」)。这个功能把连接到同一个 WiFi 的设备互相隔离,禁止它们之间直接通信,通常是为了防止局域网内部攻击,但对调试来说就是灾难。
遇到这种情况,解决办法有三个:关掉路由器的 AP 隔离;或者把需要通信的设备用网线接到路由器 LAN 口,让其中一个走有线上网;或者把手机和开发板用自建热点连到一起,避开路由器的隔离策略。这个问题的关键在于:UDP 发送成功没有任何反馈,你只能靠对端日志排除问题。
5.3 对方收不到的排查顺序
把常见问题归纳成排查顺序,按以下顺序走基本能定位九成的问题:
| 排查步骤 | 检查内容 | 常见原因 |
|---|---|---|
| 1 | 目标 IP 是否正确 | 看错了设备 IP,或者设备 DHCP 地址变了 |
| 2 | 目标端口是否正确 | 对端监听的是 12345,你发到了 1234 |
| 3 | 对端进程是否绑定端口在监听 | 对端程序没起来或者绑定失败 |
| 4 | 防火墙是否放行 UDP 端口 | Windows 默认防火墙会挡 UDP 入站 |
| 5 | 路由器是否开了 AP 隔离或组播过滤 | 无线设备间通信被隔离 |
| 6 | 是否需要广播/组播特殊处理 | 对端不在同一广播域 |
排查到第 4 步时有个经验:Windows 宿主机上跑 UDP 服务,通常要手动在防火墙里放行对应的 UDP 入站端口。用命令行netsh advfirewall firewall add rule name="UDP12345" dir=in action=allow protocol=UDP localport=12345就能放行,省事。这点在做 WSL2 和 Windows 的 UDP 通讯测试时同样适用。
6. 用回显脚本验证工具,并聊聊还能扩展什么
6.1 用 Python 回显脚本做一次端到端验证
工具写完不能只测「发送成功」,还要验证「对端真的收到了内容」。最方便的做法是写一个 UDP 回显服务——收到什么就原样发回去。
import socket s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.bind(("0.0.0.0", 12345)) print("listening on 12345") while True: data, addr = s.recvfrom(4096) print(f"from {addr}: {data.hex()}") s.sendto(data, addr)验证过程是这样的:
- 电脑上运行这个脚本,监听 12345 端口
- 手机和电脑连同一个局域网,查清楚电脑的 IP 地址
- 手机上打开 UDPSend,IP 填电脑 IP,端口填 12345,数据填
hello - 点击发送,Python 控制台打印出
hex编码后的数据,说明手机到电脑的链路通了 - 如果 UDPSend 做了接收功能(目前没做),回显的内容还能再收回来验证双向链路
这种端到端验证非常必要。它能同时验证手机端发送逻辑和电脑端接收监听,是排查「谁的问题」最靠谱的方法。
6.2 如果要继续做,我会加哪些功能
当前版本满足 90% 的使用场景就够了,但如果要往「更专业的调试工具」方向扩展,我的排序是这样的:
- 周期发送:设置发送间隔,自动连续发包。这对验证对端服务稳定性、长时间跑链路很有用,相当于一个轻量级打流工具。
- 报文递增字段:在数据里嵌入一个自增序号,对端根据序号能判断有没有丢包。这是打流测试的进阶需求。
- 广播地址快捷选择:把
255.255.255.255和常见组播地址做成快捷按钮,省得每次手敲。 - 历史记录:保存最近发送的 100 条报文,方便重复调试同一条指令。
- 字节序切换:十六进制模式下提供大端/小端转换开关,适配更多嵌入式私有协议。
- 接收模式开关:如果用户确实需要「收发一体」,做一个独立的监听页面,但这时候就要处理后台服务、通知、前台服务类型声明等一系列新问题,得单独开一个版本来做。
我的看法是,工具类应用的核心价值是「聚焦一件事并把它做到极致」。UDPSend 当前版本的定位就是发送 U DP 包,它不会去抢 TCP 调试工具的饭碗,也不会尝试做成一个完整的网络协议分析器。把你最常做的那件事做好,比什么都强。
最后分享一个小技巧:我在 UDPSend 里加了发送计数显示——累计发出去多少包、成功多少包、失败多少包。这个看似不起眼的功能,在实际现场调试时帮了大忙。比如你以为自己在连续发包测试丢包率,结果手机上显示已经发了 500 包,对端只收到 480 包,那问题就定位到链路层而不是应用层了。这类「最基本的数据统计」往往比花哨的功能更实用。
本文还有配套的精品资源,点击获取