安卓互联手写实现:解决版本升级API失效痛点 安卓互联手写实现:解决版本升级API失效痛点 版本升级后 API 全变了,旧代码直接报错,这种崩溃感谁懂?别再找那些过时的教程了,直接上手手写实现一套稳定的安卓互联方案,才能从根子上解决问题。 项目目标 咱们做技术博客,最怕的就是“水土不服”。很多读者反馈,照着网上 2021 年的代码跑,到 2024 年 Android 14 上直接崩,原因就是系统权限收紧、API 废弃。我们的目标很明确:不依赖第三方臃肿 SDK,从零手写一个轻量级、跨版本兼容的安卓互联模块。 这个模块主要解决三个场景: 设备发现:局域网内快速发现其他安卓设备。 数据同步:大文件(如视频、文档)的高效传输。 状态心跳:保持连接活跃,自动重连,避免“假死”。 为什么非要手写?因为第三方库往往封装得太深,出了 bug 你只能干瞪眼。手写实现,每一行代码都在你手里,哪里断了你知道,怎么修你也清楚。这就是安卓互联实战的核心价值——可控、可维护、可复现。 目录结构 为了保持工程化整洁,我们采用标准的 Gradle 模块结构。不要把所有逻辑堆在一个 Activity 里,那是新手才会犯的错。 com.example.androidconnect/ ├── app/ │ ├── java/ │ │ └── com/example/androidconnect/ │ │ ├── MainActivity.kt // 入口,UI 展示 │ │ ├── model/ │ │ │ ├── DeviceInfo.kt // 设备数据模型 │ │ │ └── TransferState.kt // 传输状态枚举 │ │ ├── service/ │ │ │ ├── DiscoveryService.kt // 核心:设备发现服务 | | ├── TransferManager.kt // 核心:文件传输管理 │ │ └── util/ │ │ ├── SocketUtils.kt // 底层 Socket 封装 │ │ └── PermissionHelper.kt // 权限申请工具 │ ├── res/ │ └── AndroidManifest.xml // 权限声明重点 └── build.gradle.kts 注意 service 包下的两个核心类,这是整个安卓互联逻辑的大脑。DiscoveryService 负责“找人”,TransferManager 负责“传东西”。这种职责分离的设计,是为了方便后续单元测试和模块化扩展。 核心代码实现 这里是重头戏。我们不贴满屏代码,只讲最关键的手写实现逻辑,并逐行拆解为什么这么写。 1. 设备发现:UDP 广播的坑与解法 很多教程教你用 Wi-Fi Direct,但配置复杂且兼容性差。在局域网内,UDP 广播是最稳的。但 Android 7.0+ 对后台网络访问限制极严,直接 sendto 会静默失败。 // DiscoveryService.kt 片段 class DiscoveryService : Service() { private var socket: DatagramSocket? = null private val handler = Handler(Looper.getMainLooper()) private val discoveredDevices = mutableListOfDeviceInfo() override fun onCreate() { super.onCreate() // 关键:必须获取运行时权限,否则广播包发不出去 if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_NETWORK_STATE) != PackageManager.PERMISSION_GRANTED) { // 启动 Activity 请求权限 return } initSocket() } private fun initSocket() { try { socket = DatagramSocket(0) // 系统自动分配端口 socket!!.setReuseAddress(true) // 允许多实例绑定 socket!!.setSoTimeout(5000) // 设置超时,避免无限阻塞 // 启动监听线程,接收其他设备的广播包 Thread { val buffer = ByteArray(1024) while (isRunning) { try { val packet = DatagramPacket(buffer, buffer.size) socket!!.receive(packet) val data = String(packet.data).trim() // 解析 JSON 格式的广播包,包含 IP, 端口, 设备名 val device = parseDeviceInfo(data) if (device.ip != getLocalIp()) { // 排除自己 addOrUpdateDevice(device) } } catch (e: SocketTimeoutException) { // 超时正常,继续循环 } catch (e: Exception) { e.printStackTrace() } } }.start() // 定时发送广播,告诉其他设备“我在这” handler.postDelayed(sendBroadcastRunnable, 3000) } catch (e: IOException) { e.printStackTrace() } } private val sendBroadcastRunnable = Runnable { try { val localIp = getLocalIp() // 构造广播包:IP:Port:DeviceName val msg = $localIp:$port:$deviceName val bytes = msg.toByteArray() val broadcastIp = InetAddress.getByName(255.255.255.255) val packet = DatagramPacket(bytes, bytes.size, broadcastIp, BROADCAST_PORT) socket!!.send(packet) } catch (e: Exception) { e.printStackTrace() } // 3秒后再次发送,保持活跃 handler.postDelayed(this, 3000) } } 逐行讲解重点: DatagramSocket(0):传 0 表示让系统分配随机端口,避免端口冲突。 setSoTimeout(5000):这是很多新手忽略的。不设超时,线程会永久阻塞在 receive(),导致无法退出或响应停止指令。 广播地址:使用 255.255.255.255 是最通用的局域网广播地址,比计算子网掩码更简单可靠,适合 C 类网络(大多数家庭/办公网)。 2. 文件传输:TCP 粘包处理 UDP 适合发现,TCP 适合传输。但 TCP 是流式协议,存在“粘包”问题。如果不处理,接收端可能一次收到多个文件头,或者把一个文件拆成多段。 // TransferManager.kt 片段 fun sendFile(file: File, socket: Socket) { val outputStream = socket.getOutputStream() val randomAccessFile = RandomAccessFile(file, r) // 1. 先发送文件元数据:长度(8字节) + 文件名长度(4字节) + 文件名 outputStream.write(Long.valueOf(randomAccessFile.length()).toByteArray()) val fileNameBytes = file.name.toByteArray() outputStream.write(Int.toByte(fileNameBytes.size)) outputStream.write(fileNameBytes) // 2. 分块发送文件内容,每块 1024 字节 val buffer = ByteArray(1024) var bytesRead: Int while ((bytesRead = randomAccessFile.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead) } outputStream.flush() randomAccessFile.close() } fun receiveFile(socket: Socket, saveDir: File) { val inputStream = socket.getInputStream() // 1. 读取元数据 val lenBytes = ByteArray(8) inputStream.read(lenBytes) val fileSize = ByteBuffer.wrap(lenBytes).long val nameLenByte = inputStream.read() val nameBytes = ByteArray(nameLenByte) inputStream.read(nameBytes) val fileName = String(nameBytes) val destFile = File(saveDir, fileName) val fileOutputStream = FileOutputStream(destFile) // 2. 按文件大小读取内容 val buffer = ByteArray(1024) var totalRead = 0L while (totalRead fileSize) { val bytesRead = inputStream.read(buffer) if (bytesRead == -1) break fileOutputStream.write(buffer, 0, bytesRead) totalRead += bytesRead } fileOutputStream.close() } 避坑指南: 字节序问题:Long.toByte() 和 ByteBuffer 默认是大端序(Big-Endian)。如果发送端和接收端架构不同(虽然 Android 都是小端,但 Java 网络流通常用大端),务必显式指定 order(ByteOrder.BIG_ENDIAN),否则文件大小会解析成天文数字。 缓冲区分块:不要一次性 read() 整个文件,内存会爆。1024 或 4096 字节是经验值,兼顾性能与内存占用。 运行与测试 代码写完了,怎么验证?别光看 Logcat 没报错就以为成功了。 模拟多设备环境: 由于测试手机可能只有一台,建议在 Android Studio 中启动两个 Emulator(虚拟机),分别配置不同的 Wi-Fi 网络(同一网段)。或者使用两台真机,连接同一个 Wi-Fi。 抓包验证: 使用 Wireshark 或 Android Studio 自带的 Network Profiler。观察 UDP 广播包是否真的发出去了,以及 TCP 连接建立后的数据流是否符合预期。重点看是否有 RST 包,如果有,说明连接被重置,检查防火墙设置。 弱网测试: 在 Network Profiler 中设置 Slow 3G 或 Offline。观察 DiscoveryService 是否能自动重连,TransferManager 是否能断点续传(当前代码未实现断点续传,这是进阶方向,但在弱网下必须考虑)。 常见报错排查: java.net.SocketException: Permission denied:检查 AndroidManifest.xml 是否声明了 INTERNET 和 ACCESS_NETWORK_STATE 权限,且运行时权限已获取。 Connection refused:目标设备的端口未开放,或目标设备 TransferManager 服务未启动。确保在接收端先启动监听线程。 优化扩展 基础功能跑通后,如何让它更“工程化”? 引入 OkHttp 替代原生 Socket? 不建议。OkHttp 是 HTTP 协议,不适合这种自定义二进制协议。手写实现的优势就在于协议层的绝对控制。但可以用 AsyncTask 或 Coroutine 替代原生 Thread,避免阻塞主线程,提升代码可读性。 加密传输: 公网环境下,明文传输是危险的。可以在 TCP 握手阶段增加 RSA 密钥交换,后续数据用 AES 加密。参考 OpenSSL 官方文档中的 TLS 握手流程,理解非对称加密与对称加密的结合使用。 状态管理: 使用 ViewModel + LiveData 或 StateFlow 管理设备列表和传输进度。避免在 Activity 销毁时丢失传输状态。将 TransferManager 封装为单例,通过 EventBus 或 Flow 向 UI 层推送进度更新。 断点续传: 在文件元数据中增加 Last-Modified 时间戳和 File-ID。接收端如果检测到相同 File-ID 且已存在部分文件,则从上次中断位置继续发送。这需要修改协议头,增加偏移量字段。 小结 通过手写实现这套安卓互联方案,我们绕过了第三方 SDK 的黑盒,彻底解决了版本升级后 API 失效的痛点。从 UDP 广播发现设备,到 TCP 流式传输文件,每一步都基于底层原理,而非堆砌 API。 技术不是背出来的,是踩坑踩出来的。你在这个项目中遇到过什么奇奇怪怪的 Bug?或者在权限申请上有什么独门技巧? 还有什么不懂的?评论区留言挨个回