简介:这是一份面向安卓课程设计与局域网文件传输开发者的完整项目资料,内含设计报告文档与安卓工程源码,解决两台手机在同一个无线热点或路由器下、无需外网即可互传任意文件的问题。相比直连模式,用无线热点完成设备配对更简便。压缩包共四十九个文件,主要包含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 | 支持 | 支持 | 需自己打包ZIP |
| Socket | 无 | 大 | 自实现 | 不支持 | 支持 | 需自建会话 |
| FTP Server | Apache 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 获取本机局域网IP:WifiManager与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自带的NSD(Network 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的第三参数传入InputStream,NanoHTTPD会流式写到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-start+1,避免多读。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类型
| 参数项 | 推荐值 | 说明 |
|---|---|---|
| connectTimeout | 10000 ms | 局域网内设备发现和握手很少超过1秒,10秒防止无响应 |
| readTimeout | 20000 ms | 下载过程中每条数据间隔超过20秒应视为连接异常 |
| 缓冲大小 | 8 KB ~ 64 KB | 太小会频繁系统调用,太大会浪费内存,Wi-Fi下8 KB常见 |
| Content-Disposition | attachment; 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并尝试打开,确认不是损坏的文件。这样一套流程下来,任意文件在局域网内的互传就真正可用了。
本文还有配套的精品资源,点击获取