资讯详情

Android局域网文件互传:基于HTTP与UDP广播的实现方案

📅 2026/9/16 15:14:36 | 华诺云谱 👁 阅读
Android局域网文件互传:基于HTTP与UDP广播的实现方案
简介这是一份面向安卓课程设计与局域网文件传输开发者的完整项目资料内含设计报告文档与安卓工程源码解决两台手机在同一个无线热点或路由器下、无需外网即可互传任意文件的问题。相比直连模式用无线热点完成设备配对更简便。压缩包共四十九个文件主要包含Java程序源码、XML界面与配置文件、Gradle构建脚本、属性文件以及工程说明并附有设计报告与混淆规则整个包仅四百二十一千字节421KB结构清晰适合直接导入开发环境运行。已有1540人下载学习配套博文对实现思路有更详细讲解。压缩包内含可运行的传输演示、规范的课程设计报告文档与完整的工程目录帮助快速理解Socket通信、设备发现和文件收发等关键环节无论是课程设计、毕业设计还是日常工具开发都具备参考价值。1. 局域网内Android手机传输任意文件绕开外网与压缩的实用方案出差在酒店里想给同事传一个几百MB的视频微信会自动压缩QQ要先加好友蓝牙速率只有几MB/s。如果两台手机连着同一个路由器完全可以用Android原生API实现一对一直传文件类型不受限速度能达到Wi-Fi链路的上限。本文要做的这个工具思路很直接发送端启动一个轻量HTTP服务接收端用浏览器或客户端下载设备发现用UDP广播。整套逻辑在Android Studio里用Kotlin写不需要第三方服务器不依赖蜂窝网络和流量。适合Android开发人员、运维和任何需要在局域网内快速交换文件的IT从业者。代码量不大但涉及HTTP服务器、Socket、分区存储、权限适配和Stream传输正好值得逐层拆开。2. 传输方案选型HTTP、Socket、FTP与设备发现机制2.1 为什么优先选择HTTP而不是Socket或FTP先给结论局域网Android互传文件HTTP协议在开发效率、兼容性和Debug便利性上综合最优。Socket通用性强但你需要自己定义报文边界、包序号、校验和、断点续传这些HTTP协议早就帮你解决了。FTP有现成的Apache Commons Net但FTP服务器在Android上维护起来比较重而且面对NAT、Active/Passive模式切换容易出问题。方案Android原生依赖代码量断点续传浏览器直下任意文件类型多文件支持HTTP Server无可加NanoHTTPD中支持Range支持支持需自己打包ZIPSocket无大自实现不支持支持需自建会话FTP ServerApache Commons Net大支持依赖客户端支持支持目录WebDAV第三方库大支持部分支持支持目录HTTP唯一弱项是“多文件支持”但在局域网传输场景下把文件夹用ZipOutputStream打成ZIP包就能解决正好和标题里的zip对应。另一个关键点是Range请求发送端实现Range后接收端下载到一半断网也能续传比Socket方案省掉至少一个产品版本的工作量。2.2 用UDP广播自动发现同一局域网内的Android设备两台手机在同一局域网怎么知道对方的IP让用户手动输入IP虽然不麻烦但体验不够好。更常见的做法是UDP广播发送方向255.255.255.255发送一个JSON包包里包含设备名、HTTP服务端口同一网段内所有监听同一端口的设备都能收到并回复自己的IP和名称。注意一个坑Android 7.0之后系统限制后台应用发送UDP广播所以发送动作要放在前台Activity或前台Service里。class DiscoveryService { private val port 60000 private val broadcastAddress InetAddress.getByName(255.255.255.255) as InetAddress fun startListen(onFound: (DeviceInfo) - Unit) { Thread { val socket DatagramSocket(port) // 接收端口 socket.broadcast true val buffer ByteArray(1024) while (true) { val packet DatagramPacket(buffer, buffer.size) socket.receive(packet) val data String(packet.data, 0, packet.length) val json JSONObject(data) onFound(DeviceInfo(name json.getString(name), ip packet.address.hostAddress, port json.getInt(port))) } }.start() } fun sendDiscover() { Thread { val socket DatagramSocket() // 随机源端口 socket.broadcast true val json JSONObject().apply { put(name, Build.MODEL) put(port, 8080) }.toString() val packet DatagramPacket(json.toByteArray(), json.toByteArray().size, broadcastAddress, port) socket.send(packet) socket.close() }.start() } }这段代码里端口60000是自定义的发现端口广播地址固定为255.255.255.255数据包大小控制在1024字节以内防止超过MTU导致分片丢失。收到回复后通过DatagramPacket的address获得对方IP。sendDiscover用随机源端口因为两个设备如果同端口也可能互不冲突但随机更安全。这个方案不需要RECEIVE_BOOT_COMPLETED之类的权限只需在AndroidManifest里声明INTERNET和ACCESS_NETWORK_STATE即可。2.3 获取本机局域网IPWifiManager与NetworkInterface双保险发送端要启动HTTP服务需要告诉接收端IP和端口。获取本机IP有两种方式如果知道当前Wi-Fi连接可以用WifiManager.getConnectionInfo().getIpAddress()但这个方法在多网卡热点、USB网络共享时会取错。我一般会优先遍历NetworkInterface匹配IPv4且非loopback、非保留地址的网卡。fun getLocalIpAddress(): String? { var result: String? null val interfaces NetworkInterface.getNetworkInterfaces() while (interfaces.hasMoreElements()) { val networkInterface interfaces.nextElement() val addresses networkInterface.inetAddresses while (addresses.hasMoreElements()) { val inetAddress addresses.nextElement() if (!inetAddress.isLoopbackAddress inetAddress is Inet4Address) { if (inetAddress.hostAddress?.startsWith(192.168.) true || inetAddress.hostAddress?.startsWith(10.) true) { result inetAddress.hostAddress break } } } } return result }过滤条件只匹配A类和C类私网段避免把热点地址172.16.0.1或示例IP输出给用户。注意在Android 11及以上获取本机IP不需要位置权限但获取WIFI SSID需要所以优先使用NetworkInterface。如果设备同时开着热点和Wi-Fi可能有多于一个私网地址这里简单选取第一个可用的更严谨的做法是枚举后弹列表让用户选择。2.4 为什么不用Android的NSD服务发现框架Android自带的NSDNetwork Service Discovery基于mDNS能发现同网段的注册服务但API封装得比较C/S化要先注册ServiceInfo再在客户端用ResolveListener回调。用它做文件传输属于过度设计而且调试时找不到命令行工具。UDP广播代码量不到30行只要路由器没开AP隔离成功率几乎一样。如果遇到AP隔离访客网络常见只能扫IP段或让用户手动输入本文不展开。3. 发送端实现用NanoHTTPD在Android上启动文件服务3.1 在Android Studio里引入NanoHTTPD并初始化发送端核心是监听TCP端口解析HTTP请求返回文件字节。选NanoHTTPD是因为它只有一个Java文件不需要处理复杂的网络状态变化也不会引入过多依赖。在build.gradle中加一行依赖dependencies { implementation(org.nanohttpd:nanohttpd:2.3.1) }然后在Activity里启动class FileServer( private val contentResolver: ContentResolver, private val fileUri: Uri, private val fileName: String, private val port: Int 8080 ) : NanoHTTPD(port) { override fun serve(session: IHTTPSession): Response { val fileDescriptor contentResolver.openFileDescriptor(fileUri, r) ?: return newFixedLengthResponse(Response.Status.NOT_FOUND, text/plain, file not found) return try { fileDescriptor.use { desc - val fis FileInputStream(desc.fileDescriptor) newFixedLengthResponse( Response.Status.OK, MimeType.getFromName(fileName) ?: application/octet-stream, fis, desc.statSize ) } } catch (e: Exception) { newFixedLengthResponse(Response.Status.INTERNAL_ERROR, text/plain, e.message) } } }这个serve方法每次收到请求就创建FileInputStream通过FileDescriptor读取原始文件流。注意不要用RandomAccessFile.readBytes()读整个文件否则一个视频就会让手机OOM。newFixedLengthResponse的第三参数传入InputStreamNanoHTTPD会流式写到Socket这是大文件传输的关键。调用时在Activity onCreate里启动val server FileServer(contentResolver, uri, fileName) server.start(SocketServerSocket.TIMEOUT, false)其中start的第二个参数为false表示非daemon线程否则进程结束后服务会立即被回收。3.2 处理content:// URI与延迟加载Android 7及以上如果直接用file:// Uri会触发FileUriExposedException。从系统文件选择器拿到的URI通常是content://com.android.providers.media.documents/document/xxx也可能来自第三方应用的FileProvider临时授权比如某些应用导出的content:// URI。我们的程序要拿到这个URI后不要立刻读取而是把它保存下来等到HTTP请求到达时再通过ContentResolver解析这样既避免提前占用FileDescriptor又能保证发送的是用户选中时的原始文件。private var pendingUri: Uri? null override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode PICK_FILE resultCode RESULT_OK) { pendingUri data?.data val fileName pendingUri?.let { resolveDisplayName(it) } ?: unknown.bin server FileServer(contentResolver, pendingUri, fileName) server.start(SocketServerSocket.TIMEOUT, false) } }resolveDisplayName可以通过ContentResolver查询OpenableColumns.DISPLAY_NAME列拿到不要凭URI路径推断文件名。这样做能同时兼容Android 11后的分区存储不管URI指向的是下载目录、SD卡还是应用私有目录。3.3 设置响应头支持Range断点续传默认的NanoHTTPD不支持Range下载大文件到一半断开后只能从头开始。在serve方法里判断请求头“range-header”解析出start和end然后返回206状态码override fun serve(session: IHTTPSession): Response { // 省略fileDescriptor和fis初始化 val rangeHeader session.headers[range-header] ?: session.headers[range] var start 0L var end desc.statSize - 1 var status Response.Status.OK if (rangeHeader ! null) { val match Regex(bytes(\\d)-(\\d*)).find(rangeHeader) if (match ! null) { start match.groupValues[1].toLong() if (match.groupValues[2].isNotBlank()) { end match.groupValues[2].toLong() } if (start end || start desc.statSize) { return newFixedLengthResponse(Response.Status.RANGE_NOT_SATISFIABLE, text/plain, invalid range) } status Response.Status.PARTIAL_CONTENT fis.skip(start) } } val response newFixedLengthResponse(status, application/octet-stream, fis, end - start 1) response.addHeader(Content-Range, bytes $start-$end/${desc.statSize}) response.addHeader(Accept-Ranges, bytes) return response }这里用正则把Range头拆成start和end。end缺省时用文件总长度-1。返回的数据长度是end-start1避免多读。Content-Range头在断点续传和进度条计算里都必需。注意NanoHTTPD会把请求头统一小写所以要同时查“range-header”和“range”。4. 接收端实现把任意文件下载到本地存储4.1 用HttpURLConnection流式下载并显示进度接收端逻辑相对简单发送GET请求把InputStream写入目标文件。为了避免大文件占用内存绝不能一次性readAllBytes。用缓冲数组循环读写同时用文件总长度和每轮累加计算进度。fun download(url: String, outputFile: File, progress: (Float) - Unit) { val connection (URL(url).openConnection() as HttpURLConnection) connection.connectTimeout 10000 connection.readTimeout 20000 connection.instanceFollowRedirects true val total connection.contentLength.toLong() connection.inputStream.use { input - FileOutputStream(outputFile).use { fos - val buffer ByteArray(8 * 1024) var read: Int var downloaded 0L while (input.read(buffer).also { read it } ! -1) { fos.write(buffer, 0, read) downloaded read if (total 0) progress(downloaded.toFloat() / total) } } } }这段代码里connectTimeout和readTimeout的单位是毫秒。局域网内connectTimeout设10秒足够但设备从休眠状态唤醒后第一个包可能延迟较多readTimeout建议至少20秒。需要注意HttpURLConnection在服务器返回206时需要单独处理不能只依赖contentLength当Content-Length表示的是分块长度时total是206返回的块大小这个值对进度条已经够用。4.2 保存到公共Downloads目录适配Android 10以上分区存储在Android 9及以下可以直接写外部存储的公共目录需要声明WRITE_EXTERNAL_STORAGE权限。Android 10开始强制分区存储直接写非应用专属目录会被拦截。有两种常见做法一是把文件写到getExternalFilesDir()应用专属目录用户不好找二是通过MediaStore.Downloads插入到公共下载目录用户在用“文件管理器”时能看到。RequiresApi(Build.VERSION_CODES.Q) fun saveToDownloads(contentResolver: ContentResolver, inputUri: Uri, displayName: String, mimeType: String) { val values ContentValues().apply { put(MediaStore.Downloads.DISPLAY_NAME, displayName) put(MediaStore.Downloads.MIME_TYPE, mimeType) put(MediaStore.Downloads.IS_PENDING, 1) } val collection MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val itemUri contentResolver.insert(collection, values) ?: return contentResolver.openOutputStream(itemUri)?.use { output - contentResolver.openInputStream(inputUri)?.use { input - input.copyTo(output) } } val updateValues ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) } contentResolver.update(itemUri, updateValues, null, null) }IS_PENDING标记让系统知道这个文件正在写入避免其他应用读到半截文件。写入完成后置0系统才会正式“发布”文件。对于Android 9以下直接写Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS)即可。我的建议是在代码里按Build.VERSION.SDK_INT分支处理并动态申请WRITE_EXTERNAL_STORAGE权限。4.3 常见参数与坑超时、文件名、MIME类型参数项推荐值说明connectTimeout10000 ms局域网内设备发现和握手很少超过1秒10秒防止无响应readTimeout20000 ms下载过程中每条数据间隔超过20秒应视为连接异常缓冲大小8 KB ~ 64 KB太小会频繁系统调用太大会浪费内存Wi-Fi下8 KB常见Content-Dispositionattachment; filename*中文文件名必须用UTF-8编码否则浏览器下载乱码MIME类型application/octet-stream无法确定类型时用这个确保接收端按二进制处理一个容易踩的坑通过浏览器直接访问手机IP下载时如果发送端返回的Content-Length比实际写入Socket的字节数大浏览器会卡在99%不动。排查方法是用curl --verbose查看响应头确认Content-Length和返回实体大小一致。NanoHTTPD的newFixedLengthResponse如果传入的InputStream实际读取内容比声明长度少需要你在serve方法里自行保证该流的数据量和第三个参数一致。5. 传输完整性校验与多线程分块下载技巧5.1 用MD5确认两端文件一致传输完成不代表文件没有损坏尤其是Wi-Fi弱信号下TCP也可能发生静默错误。最简单的校验是MD5。发送端在启动服务前计算文件的MD5接收端下载完成后计算本机MD5并比较fun md5(file: File): String { val digest MessageDigest.getInstance(MD5) FileInputStream(file).use { input - val buffer ByteArray(8192) var read input.read(buffer) while (read 0) { digest.update(buffer, 0, read) read input.read(buffer) } } return digest.digest().joinToString() { %02x.format(it) } }要在传输过程不被IO阻塞可以放到单独的HandlerThread里执行。如果把MD5值追加到HTTP响应头比如X-Checksum-MD5接收端在拿到响应头时就能预先知道目标校验值省一次HTTP请求。5.2 用线程池分块拉取大幅提升速度单个HTTP连接在Wi-Fi下的吞吐率通常受限于TCP窗口多线程并发是提升大文件传输的有效办法。原理是先用HEAD或Range请求拿到文件总长度然后分成N块每块用一个线程发送Range请求下载最后按偏移量写入同一个RandomAccessFile。val executor Executors.newFixedThreadPool(4) val chunkSize 1024 * 1024 // 1MB val fileSize getContentLength(url) val randomFile RandomAccessFile(outputFile, rw) randomFile.setLength(fileSize) for (i in 0 until (fileSize chunkSize - 1) / chunkSize) { val start i * chunkSize val end min(start chunkSize - 1, fileSize - 1) executor.execute { val connection URL(url).openConnection() as HttpURLConnection connection.setRequestProperty(Range, bytes$start-$end) connection.inputStream.use { input - randomFile.seek(start) input.copyTo(randomFile, bufferSize 8192) } } }分块数并不是越多越好我一般控制在4到8块。太多线程会加重新路由器和AP的并发连接数反而降低吞吐率。注意RandomAccessFile不是线程安全的需要确保每个线程只在对应偏移区域seek和write不要用同一个FileChannel并发。5.3 用浏览器和curl做端到端验证在没有Android设备的情况下可以在PC终端用adb reverse或直接通过局域网测试。这里有个小技巧先把发送端跑起来观察日志里打印的本机IP和端口然后在接收端手机或PC浏览器输入curl -v http://192.168.1.23:8080/filename.zip -o /tmp/filename.zipcurl能完整看到HTTP响应状态码、Content-Length和Range支持情况。如果返回206或200正常再看MD5是否一致。这个命令比浏览器调试更直观能直接暴露响应头缺失、Content-Length不匹配等问题。最后一步把接收端下载完成后的文件后缀改成.apk或.jpg并尝试打开确认不是损坏的文件。这样一套流程下来任意文件在局域网内的互传就真正可用了。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。