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

发布时间:2026/10/24 7:21:58
Android Socket 长连接:心跳检测、粘包与断线重连实战

简介:面向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 一次握手到稳定维持:整体链路和线程模型

一条长连接从建立到稳定维持,走的是这么一条链路:

  1. 网络可用性检查(ConnectivityManager拿到NET_CAPABILITY_INTERNET)
  2. 建 TCP 连接,设置SO_TIMEOUT、TCP_NODELAY
  3. 应用层握手(带 token、协议版本、设备标识)
  4. 握手成功,启动心跳调度器
  5. 读线程持续阻塞读,解帧后分发
  6. 心跳超时或读异常,停止心跳、关闭 socket、进入重连队列

线程模型上,我坚持"三线程":一个写线程(或者干脆用单线程的发送队列)、一个读线程、一个调度线程(心跳 + 重连)。写操作必须串行化,因为心跳包和业务包如果并发写同一个OutputStream,帧就会交错,对端解出来是乱码。这是最容易被忽略的一条。

常见做法是维护一个BlockingQueue<Frame>作为发送队列,写线程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/LOST

SocketConnection只负责 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 而不是 postDelayed

Handler.postDelayed在亮屏时很准,锁屏后 CPU 进入低功耗状态,延迟会漂移,实测能漂到十几秒。心跳这种"必须准点"的任务,我用ScheduledExecutorService:

// HeartbeatManager.kt class HeartbeatManager( private val sendQueue: BlockingQueue<ByteArray>, 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) return@scheduleAtFixedRate sendQueue.offer(FrameCodec.encode(FrameCodec.TYPE_PING)) }, 0, config.intervalMs, TimeUnit.MILLISECONDS) // 单独一个巡检任务判超时,避免发送线程被业务阻塞 scheduler.scheduleAtFixedRate({ if (!running) return@scheduleAtFixedRate 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 回包和业务数据的路由

读线程拿到帧之后,先看 type,PONG 直接喂给心跳管理器,业务帧丢给上层:

// 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 = LinkedBlockingQueue<ByteArray>(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返回的字节数,看到n=1400这种接近 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 ACK,payload 里放 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分支里强制走一遍"计数器归零 + 心跳重启 + 缓冲清空"三件套,一个都不能省。

这套骨架不复杂,难的是把每个状态都当成可能出错的地方去对待。把它抄过去之后,先别急着接业务,跑一晚上弱网压测,看看日志里有没有你没想到的分支。希望帮到你。

本文还有配套的精品资源,点击获取