首页 / 资讯中心 / 文章详情

Android Socket 长连接:心跳检测、粘包与断线重连实战

Android Socket 长连接:心跳检测、粘包与断线重连实战 ★ FEATURED ARTICLE
简介面向Android网络编程学习者的一份Socket长连接与心跳检测实战资料适合已掌握Java基础、希望理解移动端持久连接维护与断线重连的开发者。内容围绕客户端Socket建连、Service后台保活、心跳包定时发送与超时判定以及服务端连接监听和心跳响应展开帮助读者厘清长连接从建立、维持到异常恢复的完整链路。压缩包共107个文件以class、xml、png、java、jar等类型为主整体约2.78MB内含可运行APK、Java源码、AIDL接口定义及资源文件便于对照客户端与服务端工程结构进行学习。目前已有303人学习。读者可从中获得心跳线程调度、输入输出流读写、超时重连、异常处理与网络性能优化等具体实现思路并借助示例工程验证长连接稳定性适合作为Android网络通信模块的参考案例。1. 一条会自己喊话的连接Android Socket 长连接到底要解决什么有个做设备控制的哥们儿第一版用的是 HTTP 轮询两秒一次。上线三天用户反馈手机发烫、电池一小时掉 20%服务端那边 QPS 也顶不住。改成 Socket 长连接之后新问题来了切到后台几分钟连接就静默死掉界面还显示在线下发的指令全丢。安卓上的 Socket 通信长连接难的不是建立连接而是让它活着并且知道自己什么时候已经死了。心跳检测就是给这条连接装的呼吸监测——定期喊一声对方应一声几次不应就判定断线、触发重连。这篇从一个可运行的骨架讲起连接层、心跳层、重连层怎么拆核心代码长什么样以及我在真机上踩过的那些坑。2. 骨架先立住连接层、心跳层、重连层怎么分工很多人写长连接习惯把连接、读写、心跳、重连全塞进一个 Service 里几百行代码滚成一坨。第一版能跑第二个月加个离线消息补拉就改不动了。我一般会先分层哪怕项目再小。分层的核心目的是让连接是否健康这件事变成可观测的状态而不是靠socket.isConnected这种一厢情愿的判断。isConnected只告诉你本地对象还持有引用对端早就把你踢了它也不知道。2.1 一次握手到稳定维持整体链路和线程模型一条长连接从建立到稳定维持走的是这么一条链路网络可用性检查ConnectivityManager拿到NET_CAPABILITY_INTERNET建 TCP 连接设置SO_TIMEOUT、TCP_NODELAY应用层握手带 token、协议版本、设备标识握手成功启动心跳调度器读线程持续阻塞读解帧后分发心跳超时或读异常停止心跳、关闭 socket、进入重连队列线程模型上我坚持三线程一个写线程或者干脆用单线程的发送队列、一个读线程、一个调度线程心跳 重连。写操作必须串行化因为心跳包和业务包如果并发写同一个OutputStream帧就会交错对端解出来是乱码。这是最容易被忽略的一条。常见做法是维护一个BlockingQueueFrame作为发送队列写线程take()出来编码、写、flush。心跳只是往队里丢一个 PING 帧它不需要知道业务包长什么样。提示别用AsyncTask或者无限制的new Thread()去做读写。读线程必须是唯一的一旦有两个线程同时read()数据会被随机瓜分这个 bug 排查起来非常痛苦。2.2 协议帧格式给心跳包留好位置心跳包和业务包走同一条 TCP 流就必须有明确的帧边界否则粘包时你根本分不清哪个字节是心跳。我用的帧头是 7 字节定长字段长度说明magic2 字节固定魔数用于快速识别脏数据type1 字节0x01 PING / 0x02 PONG / 0x03 业务 / 0x04 握手length4 字节payload 长度大端序payloadN 字节实际内容心跳包为空选了 4 字节长度而不是 2 字节是因为后面要传图片指令和批量状态同步2 字节的 64KB 上限早晚会撞墙。改成 4 字节只多占两个字节省得以后改协议。心跳包的 payload 是空的这是刻意的心跳只需要证明链路通不需要带任何业务语义。有些设计会把设备状态塞进心跳里顺便上报短期看省流量长期看会让心跳逻辑和业务逻辑耦合一旦上报失败你分不清是网络断了还是字段校验没过。我倾向于心跳纯心跳。2.3 目录结构和类的职责边界一个能撑住二次开发的结构大致是这样com.example.longsocket ├── conn │ ├── SocketConnection.kt // 连接生命周期open/close/isAlive │ ├── FrameReader.kt // 半包粘包处理对外吐 Frame │ └── FrameWriter.kt // 串行化写flush 保证 ├── protocol │ ├── Frame.kt // 帧数据类 │ └── FrameCodec.kt // 编解码 ├── heartbeat │ ├── HeartbeatManager.kt // 定时发 PING、统计 PONG │ └── HeartbeatConfig.kt // 间隔、超时、重试次数 ├── reconnect │ ├── ReconnectPolicy.kt // 指数退避 抖动 │ └── NetworkWatcher.kt // 网络变化回调 ├── service │ └── LongConnService.kt // 前台服务持有连接单例 └── callback └── ConnStateListener.kt // 状态回调CONNECTING/CONNECTED/LOSTSocketConnection只负责 TCP 层面的事它不知道心跳是什么HeartbeatManager只负责按时往队列里丢 PING、按时检查有没有 PONG它不碰 socket。两者通过ConnStateListener和发送队列通信。这个边界划清楚之后你要把心跳从 30 秒改成 90 秒只需要动HeartbeatConfig要把 TCP 换成 WebSocket只需要重写SocketConnection。LongConnService是个前台服务它的价值不是保活黑科技而是让系统知道这个进程在做用户可见的事。Android 8.0 之后后台服务会被限制12.0 之后后台启动前台服务也有限制这一块在第 4 章细说。3. 核心代码拆解心跳怎么发、超时怎么判、粘包怎么拆骨架讲完落到能贴进项目的代码。这一章的每一段都可以直接抄参数按自己业务调。3.1 编解码长度字段加累积缓冲先看编码重点是ByteBuffer的大端序和flush// FrameCodec.kt object FrameCodec { private const val MAGIC: Short 0x5A5A const val TYPE_PING 0x01 const val TYPE_PONG 0x02 const val TYPE_BIZ 0x03 const val TYPE_HANDSHAKE 0x04 private const val HEADER_SIZE 7 // 2 1 4 /** 按协议拼一个完整帧payload 允许为空心跳就是空 */ fun encode(type: Int, payload: ByteArray ByteArray(0)): ByteArray { val buf ByteBuffer.allocate(HEADER_SIZE payload.size) // 默认大端序 buf.putShort(MAGIC) buf.put(type.toByte()) buf.putInt(payload.size) // 长度只算 payload不含头 buf.put(payload) return buf.array() } /** 从累积缓冲里尝试切一帧出来数据不够返回 null */ fun decode(acc: ByteArray): Frame? { if (acc.size HEADER_SIZE) return null val buf ByteBuffer.wrap(acc) if (buf.short ! MAGIC) throw ProtocolException(magic mismatch) val type buf.get().toInt() and 0xFF val len buf.int if (len 0 || len 1 shl 20) throw ProtocolException(bad length $len) if (acc.size HEADER_SIZE len) return null // 半包等下次 val payload ByteArray(len) buf.get(payload) return Frame(type, payload, HEADER_SIZE len) // 带 consumed供调用方裁剪 } }decode返回的Frame里带一个consumed字段调用方拿它去裁剪累积缓冲区。这个设计是为了避免在解码器里做数组拷贝——一次copyOfRange在大包场景下就是一次 GC 压力。读线程的实现// FrameReader.kt —— 读线程体唯一持有 InputStream 的地方 class FrameReader(private val input: InputStream) { private var acc ByteArray(0) fun loop(onFrame: (Frame) - Unit) { val buf ByteArray(4096) while (!Thread.currentThread().isInterrupted) { val n input.read(buf) // 阻塞靠 close() 打断 if (n -1) throw IOException(peer closed) acc if (acc.isEmpty()) buf.copyOf(n) else acc buf.copyOf(n) while (true) { val frame FrameCodec.decode(acc) ?: break acc acc.copyOfRange(frame.consumed, acc.size) // 裁掉已消费部分 onFrame(frame) } if (acc.size 1 shl 20) throw IOException(frame too large) } } }参数说明buf用 4096 是经验值太小会导致频繁系统调用太大对心跳这种 7 字节帧是浪费。acc累积到超过 1MB 还没解出完整帧说明对端协议不对或者数据被污染直接抛异常断开别让内存无限涨。3.2 心跳调度ScheduledExecutorService 而不是 postDelayedHandler.postDelayed在亮屏时很准锁屏后 CPU 进入低功耗状态延迟会漂移实测能漂到十几秒。心跳这种必须准点的任务我用ScheduledExecutorService// HeartbeatManager.kt class HeartbeatManager( private val sendQueue: BlockingQueueByteArray, private val config: HeartbeatConfig HeartbeatConfig() ) { private val scheduler Executors.newSingleThreadScheduledExecutor { r - Thread(r, hb-scheduler).apply { isDaemon true } } Volatile private var lastPongAt SystemClock.elapsedRealtime() Volatile private var running false fun start() { if (running) return running true lastPongAt SystemClock.elapsedRealtime() // 固定间隔发 PING不关心业务 scheduler.scheduleAtFixedRate({ if (!running) returnscheduleAtFixedRate sendQueue.offer(FrameCodec.encode(FrameCodec.TYPE_PING)) }, 0, config.intervalMs, TimeUnit.MILLISECONDS) // 单独一个巡检任务判超时避免发送线程被业务阻塞 scheduler.scheduleAtFixedRate({ if (!running) returnscheduleAtFixedRate val silent SystemClock.elapsedRealtime() - lastPongAt if (silent config.timeoutMs) { onTimeout(silent) } }, config.intervalMs, config.intervalMs, TimeUnit.MILLISECONDS) } fun onPong() { lastPongAt SystemClock.elapsedRealtime() } fun stop() { running false scheduler.shutdownNow() } private fun onTimeout(silent: Long) { Log.w(Heartbeat, pong missing ${silent}ms, kill conn) stop() stateListener.onLost(LostReason.HEARTBEAT_TIMEOUT) } } data class HeartbeatConfig( val intervalMs: Long 30_000L, // 发 PING 间隔 val timeoutMs: Long 90_000L // 三个周期没 PONG 判死 )关键点用elapsedRealtime而不是System.currentTimeMillis。后者会被用户改系统时间、NTP 校时影响一改时间心跳就误判。elapsedRealtime是开机以来的毫秒数单调递增这是 Android 上做超时判定的标准姿势。timeoutMs设成intervalMs的 3 倍是为了容忍偶发的一两次丢包。TCP 本身不丢包但弱网下重传可能让一个 PING 迟到几秒设成 1 倍会频繁误杀设成 5 倍以上又会让真正断线后用户等太久。3.3 PONG 回包和业务数据的路由读线程拿到帧之后先看 typePONG 直接喂给心跳管理器业务帧丢给上层// SocketConnection.kt 中的分发逻辑 private fun dispatch(frame: Frame) { when (frame.type) { FrameCodec.TYPE_PONG - heartbeat.onPong() FrameCodec.TYPE_PING - sendQueue.offer(FrameCodec.encode(FrameCodec.TYPE_PONG)) FrameCodec.TYPE_BIZ - { // 业务帧在主线程回调避免调用方自己切线程出错 mainHandler.post { bizCallback.onMessage(frame.payload) } } else - Log.w(Conn, unknown type ${frame.type}) } }TYPE_PING那个分支是给双向心跳准备的客户端和服务端都可能主动探活谁收到 PING 谁回 PONG。如果服务端不主动发心跳这个分支可以去掉但保留着不亏成本是一行代码。写线程// FrameWriter.kt class FrameWriter(private val output: OutputStream) : Thread(frame-writer) { val queue LinkedBlockingQueueByteArray(64) override fun run() { try { while (!isInterrupted) { val bytes queue.take() // 队列空时阻塞不空转 output.write(bytes) output.flush() // 必须 flush否则数据躺在缓冲区 } } catch (e: InterruptedException) { // 正常退出 } catch (e: IOException) { stateListener.onLost(LostReason.IO_ERROR) } } }队列容量给 64满了就offer失败调用方用offer而不是put这样网络卡住时业务层会立刻收到发不出去的信号而不是无限堆积把内存撑爆。4. 断线重连与后台存活把掉线从玄学变成可观测心跳判定断线只是开始真正让用户感知到的是重连要多久和后台还能不能收到消息。4.1 网络回调别只靠心跳去发现断网心跳超时要等 90 秒用户从地铁隧道出来到恢复连接体验上就是卡了一分半。系统其实早就告诉你网络变了ConnectivityManager.NetworkCallback就是干这个的// NetworkWatcher.kt class NetworkWatcher( private val cm: ConnectivityManager, private val onAvailable: () - Unit, private val onLost: () - Unit ) { private val callback object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { // 新网络可用立刻触发一次快速重连不等心跳超时 onAvailable() } override fun onLost(network: Network) { onLost() } } fun start() { val req NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) .build() cm.registerNetworkCallback(req, callback) } fun stop() cm.unregisterNetworkCallback(callback) }注意onAvailable回调回来的时候DNS 可能还没就绪Wi-Fi 刚连上但还没拿到 IP 的情况也常见。我一般会在onAvailable里 delay 500ms 再发起重连或者干脆做一次轻量的可达性探测。这个 500ms 不是玄学是给系统网络栈留出配置时间。4.2 指数退避别让重连把服务端打成筛子网络抖动的时候几百台设备同时掉线、同时重连如果重连间隔是固定的 1 秒服务端瞬间就是几百 QPS 的连接请求握手阶段还要查库校验 token很容易被自己的客户端打挂。// ReconnectPolicy.kt class ReconnectPolicy( private val baseMs: Long 1_000L, private val maxMs: Long 60_000L ) { private var attempt 0 /** 第 n 次重连的等待时间1s,2s,4s,8s...封顶 60s再叠 0~1s 随机抖动 */ fun nextDelay(): Long { val exp baseMs shl attempt.coerceAtMost(6) // 最多左移 6 位 64s val capped exp.coerceAtMost(maxMs) attempt return capped Random.nextLong(0, 1_000L) // 抖动打散重连峰值 } fun reset() { attempt 0 } // 连接成功后必须重置 }reset()这一行是血泪教训。有一次忘了重置设备跑了一天之后每次重连都等 60 秒运维那边看日志以为是服务端的问题查了半天才发现是客户端自己的计数器没清零。4.3 前台服务与系统限制的边界Android 8.0 之后后台服务在应用进入后台约一分钟后会被停止12.0 之后从后台启动前台服务也受限。所以保活这件事正确的期待是在用户明确知道的前提下通知栏有常驻通知让连接尽可能久地活着而不是对抗系统。// LongConnService.kt 片段 override fun onCreate() { super.onCreate() val channel NotificationChannel( CHANNEL_ID, 长连接服务, NotificationManager.IMPORTANCE_LOW ).apply { setShowBadge(false) } getSystemService(NotificationManager::class.java).createNotificationChannel(channel) val notification NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(设备在线) .setSmallIcon(R.drawable.ic_link) .setOngoing(true) .build() // Android 10 需要显式声明类型14 起 connectedDevice 类型还要权限 if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { startForeground(NOTIFY_ID, notification, ForegroundServiceType.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE) } else { startForeground(NOTIFY_ID, notification) } }如果业务真的要求杀进程后还能收消息那应该走厂商推送通道而不是死磕 Socket 保活。这一点在需求评审时就要说清楚否则后期一定扯皮。4.4 心跳间隔与耗电的折中心跳间隔不是一个可以拍脑袋的数字。发得太密射频模块频繁唤醒耗电明显发得太疏运营商的 NAT 表项老化后连接被回收你以为还连着实际早就断了。运营商 NAT 的老化时间常见在 5 分钟左右不同地区、不同网络类型差异很大。所以心跳间隔控制在 3 分钟以内比较稳30 秒到 3 分钟之间按业务实时性挑业务类型建议间隔超时判定说明即时通讯30s90s实时性优先耗电可接受设备控制60s180s折中兼顾指令下发延迟低频状态同步180s540s省电优先配合推送唤醒另外可以打开 TCP 层的 keepalive 作为兜底socket.setKeepAlive(true)但 Android 上 keepalive 的探测间隔由内核决定默认 2 小时改起来要 root指望不上只能当补充。5. 排查与避坑五个把我按在地上摩擦的问题这一章的问题都是真机上的模拟器复现不出来。5.1 现象锁屏五分钟后心跳就停了日志里干干净净原因Handler.postDelayed在 Doze 模式下会被推迟而且没有异常没有日志就是一个安静的不执行。更隐蔽的是有些机型在息屏后会冻结整个后台进程的 CPU 时间片线程调度直接停摆。解决心跳调度统一用ScheduledExecutorService并且把连接放进前台服务。同时在onTaskRemoved和onTrimMemory里打日志确认进程是不是被系统回收了。如果想进一步确认用adb shell dumpsys deviceidle看当前是否处于 idle 状态。5.2 现象客户端日志显示 PING 已发送服务端抓包一个都没收到原因OutputStream写了没flush。尤其是中途有人把OutputStream包成了BufferedOutputStream图性能结果心跳包 7 个字节全躺在缓冲区里等到缓冲区攒满才发出去服务端自然收不到。解决写线程每次write后必须flush。心跳这种小包缓冲区带来的性能提升可以忽略反而制造了发了但没发的黑匣子。如果一定要用缓冲流就在心跳发送路径上显式flush。5.3 现象心跳包和业务包粘在一起解析出来的业务数据是乱码原因没有长度字段或者有长度字段但读的时候按一次 read 一个完整包来写。TCP 是字节流一次read可能返回半个包也可能返回三个包。解决必须用累积缓冲 循环解帧就是第 3.1 节那段while (true) { decode(acc) ?: break }。另外调试期一定要打印每次read返回的字节数看到n1400这种接近 MTU 的数字基本就能确认是被拆包了。5.4 现象弱网环境下重连风暴服务端连接数瞬间飙到上限原因重连没有退避或者退避了但没有随机抖动。所有设备在同一时刻掉线又在同一时刻以相同间隔重连形成脉冲。解决指数退避 Random.nextLong(0, 1000)抖动再叠加一个全局最多同时存在一个连接尝试的单例约束。连接管理器必须是单例否则页面切换时很容易创建出两条连接服务端看到的是同一个设备两个会话消息投递就乱了。5.5 现象心跳间隔调成 5 分钟省电结果消息延迟越来越大原因连接被中间网络设备回收了但客户端没有感知。因为 NAT 设备是静默丢包不给你发 RST客户端 socket 状态还是已连接写进去的数据全部石沉大海直到某次写缓冲区满才报错。解决心跳间隔不要超过 3 分钟并且心跳超时判定要独立于业务数据的发送。判断链路是否活着唯一可靠的信号是收到了对方的回应而不是我写成功了。注意线上排查这类问题最有效的手段是把每一次连接建立、心跳超时、重连触发、网络变化都打上时间戳落到本地环形日志里。用户反馈消息收不到时让他导一份日志比问十句你当时在干什么管用得多。6. 二次开发把这套骨架改成你自己的业务连接骨架拿到手改造成本最低的路径是从外往里改先动配置再动协议最后才动连接层。第一步把所有可调参数收进一个HeartbeatConfig和一个ReconnectPolicy通过构建器注入别散落在各个类里。我一般会在Application里根据BuildConfig.DEBUG给两套参数调试期 10 秒心跳方便看日志线上 60 秒。第二步扩协议。FrameCodec里的type字段现在只用了 4 个值剩余空间足够。要加消息确认就新增0x05 ACKpayload 里放 4 字节序号要做可靠投递就在业务层维护一个待确认队列收到 ACK 才移除超时重发。这块改动集中在dispatch()和业务回调之间不碰 socket。第三步加密。最快的方式是SSLSocketFactory包一层代码改动只有建连那几行// 用 TLS 包一层其余逻辑完全不变 val raw Socket() raw.connect(InetSocketAddress(host, port), 10_000) val ssl (SSLSocketFactory.getDefault() as SSLSocketFactory) .createSocket(raw, host, port, true) as SSLSocket ssl.startHandshake() // 之后 FrameReader/FrameWriter 拿到的就是 ssl.inputStream / outputStream如果服务端不方便上 TLS就退到应用层 AES在encode之后、decode之前加解密帧头保持明文以便解长度。改造过程中有几个验证点我每次都走一遍验证项操作期望结果心跳准确性锁屏 30 分钟日志里 PING 间隔误差 2 秒断线感知飞行模式开关一次10 秒内状态变为 LOST 并触发重连重连退避连续断网 10 次间隔递增无连续同间隔重连粘包处理服务端一次发 3 个帧客户端回调 3 次内容正确内存挂后台 8 小时累积缓冲不增长无 OOM有一套弱网模拟的办法很好用在路由器上限制带宽到 20kbps再随机丢 5% 的包然后跑一晚上。这比在办公室连着 Wi-Fi 点几下靠谱得多很多半包和超时的边界问题只有在这种环境下才暴露。踩过最贵的一次坑是重连成功后忘了重置退避计数器也没重置心跳的lastPongAt导致设备在网络恢复后仍然每隔一分钟判一次超时连接刚建好就被自己杀掉。排查了两天才定位到是状态没清干净。从那以后我每次写连接状态机都会在CONNECTED分支里强制走一遍计数器归零 心跳重启 缓冲清空三件套一个都不能省。这套骨架不复杂难的是把每个状态都当成可能出错的地方去对待。把它抄过去之后先别急着接业务跑一晚上弱网压测看看日志里有没有你没想到的分支。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站