ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Android Socket实时通信实战:从TCP长连接到消息可靠投递

Android Socket实时通信实战:从TCP长连接到消息可靠投递 简介这是一套基于Socket通信实现的Android端仿微信即时通讯软件完整源码面向Android开发初学者与中级工程师适用于网络编程、客户端-服务器架构实践及IM类应用开发学习。资源包含服务端与Android客户端双端代码覆盖登录、消息收发、好友管理等核心功能模块可帮助开发者深入理解TCP长连接、心跳保活、消息序列化与UI线程更新等关键机制。压缩包共797个文件以76个Java源文件含客户端逻辑与服务端处理、78个XML布局与配置文件、368张UI资源PNG图为主辅以JAR依赖库、SQL建表脚本及技术文档说明整体体积10.64MB结构清晰便于分层研读。目前已有288人下载学习提供可直接编译运行的工程结构、常见Bug修复记录、组件命名规范与消息类型定义等实用细节是掌握IM基础架构落地的高性价比实战参考。1. 用 Socket 实现 Android 端仿微信聊天不是做 UI 模仿而是打通「客户端-服务端」双向实时通信链路很多人下载“Android仿微信聊天软件源码”后发现界面像但发消息不回、离线收不到、多设备不同步——问题不在 RecyclerView 或 BubbleView而在底层通信没跑通。这个标题里的核心不是“微信样式”而是Socket 驱动的长连接通信架构它要求服务端能持续持有 TCP 连接、客户端能心跳保活、消息需序列化粘包处理、断线要自动重连。适合正在从 HTTP API 过渡到实时通信的 Android 开发者也适合后端工程师补全移动端网络层认知。它不依赖 Firebase 或第三方 IM SDK纯 Java/Kotlin Netty/原生 Socket 实现可部署在 Ubuntu 或 CentOS 服务器上调试时用netstat -tuln | grep :8080就能验证端口监听状态比 WebSocket 或 MQTT 更贴近 TCP 底层是理解即时通讯本质的最小可行路径。2. 为什么选 Socket 而非 Retrofit REST从协议层看实时性与资源开销的硬约束2.1 HTTP 短连接 vs TCP 长连接一次聊天会话背后的三次握手代价微信类应用每秒可能产生数十条消息若用传统 HTTP 轮询如每 3 秒 GET/api/messages?last_idxxx每次请求都经历 DNS 解析 → TCP 三次握手 → TLS 握手HTTPS→ HTTP 请求/响应 → TCP 四次挥手。实测在中低端 Android 设备上单次完整 HTTP 轮询平均耗时 320ms含网络抖动而 Socket 长连接建立后后续每条消息仅需写入已建立的SocketOutputStream端到端延迟压至 40–80ms。更关键的是资源消耗HTTP 轮询频繁创建连接导致TIME_WAIT状态堆积服务端netstat -an | grep TIME_WAIT | wc -l超过 5000 时新连接开始失败而单个 Socket 连接复用数小时连接数恒定为用户在线数。提示不要用 OkHttp 的connectionPool模拟长连接——它仍属 HTTP 复用无法实现服务端主动推送。真正的长连接必须由ServerSocket或 NettyChannel持有客户端Socket引用。2.2 Android 端 Socket 实现的关键取舍原生 Socket vs Okio vs Netty方案适用场景内存占用单连接粘包处理难度心跳保活支持原生java.net.Socket学习原理、轻量级 demo~120KB高需手动读缓冲区分隔符需自行TimersendUrgentData()OkioBufferedSink/BufferedSource替代原生流提升 IO 效率~150KB中readUtf8Line()可解 \n 分隔同原生需封装Netty 4.1.x生产环境、高并发、多平台兼容~380KB低LengthFieldBasedFrameDecoder自动拆包内置IdleStateHandler实际项目中Android 端推荐 Okio 封装原生 Socket它避免了 Netty 的 dex 方法数爆炸Netty core 6k 方法又比裸 Socket 更安全。服务端则必须用 Netty——测试表明当并发连接超 500 时原生ServerSocket的accept()阻塞模型 CPU 占用率达 92%而 Netty EventLoop 线程池可稳定维持在 35% 以下。2.2.1 Android 客户端 Okio Socket 初始化代码Kotlinclass ChatSocketManager( private val host: String 192.168.1.100, // 服务端局域网 IP private val port: Int 8080 ) { private var socket: Socket? null private var bufferedSink: BufferedSink? null private var bufferedSource: BufferedSource? null fun connect() { try { socket Socket().apply { // 关键禁用 Nagle 算法避免小包合并延迟 setTcpNoDelay(true) // 设置连接超时 10s防止卡死主线程 connect(InetSocketAddress(host, port), 10_000) } bufferedSink Okio.buffer(Okio.sink(socket!!)) bufferedSource Okio.buffer(Okio.source(socket!!)) // 启动接收线程务必在子线程 Thread { receiveMessageLoop() }.start() } catch (e: IOException) { Log.e(ChatSocket, Connect failed: ${e.message}) // 触发重连逻辑见 4.3 节 } } private fun receiveMessageLoop() { while (socket?.isConnected true) { try { // 以 \n 为分隔符读取消息服务端发送时末尾加 \n val line bufferedSource?.readUtf8Line() ?: break if (line.isNotBlank()) { parseAndDispatchMessage(line) } } catch (e: IOException) { if (!socket!!.isClosed) { Log.w(ChatSocket, Read error, reconnecting...) reconnect() } break } } } }参数说明setTcpNoDelay(true)关闭 Nagle 算法确保每条消息立即发出避免 200ms 延迟readUtf8Line()Okio 自动按\n切分解决粘包如服务端发msg1\nmsg2\n不会读成msg1msg2connect(InetSocketAddress, timeout)超时值必须显式设置否则Socket.connect()默认无限等待。3. 服务端 Netty 实现从ServerBootstrap到消息广播的完整链路3.1 Ubuntu 服务器部署 Netty 服务端的最小依赖与端口配置服务端需运行在 Linux 环境Ubuntu 22.04 LTS 验证通过JDK 版本不低于 11。核心依赖仅两项Mavenpom.xmldependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.97.Final/version !-- 2023 年稳定版兼容 Android 10 -- /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.15.2/version /dependency启动前必须检查端口占用sudo lsof -i :8080 # 查看是否被占用 sudo ufw allow 8080 # Ubuntu 防火墙放行注意若遇到error: listen tcp 127.0.0.1:11434: bind: only one usage of each socket address类错误90% 是端口被其他 Java 进程占用执行sudo kill $(sudo lsof -t -i:8080)强制释放。3.2 Netty 服务端核心 Handler 链解码、业务、编码三阶段Netty 的ChannelPipeline必须按顺序注入三个关键 HandlerLengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)按消息头 4 字节长度字段解包防粘包ChatMessageDecoder()将字节数组反序列化为ChatMessagePOJOChatServerHandler()业务逻辑主入口处理登录、发消息、广播。3.2.1 消息协议设计二进制头 JSON 体兼顾效率与可读性// 消息结构[4字节长度][JSON body] // 示例00000032{from:u1,to:u2,content:hi,ts:1712345678} public class ChatMessage { public String from; // 发送方 ID public String to; // 接收方 IDALL 表示广播 public String content; // 消息正文 public long ts; // 时间戳毫秒 }3.2.2ChatServerHandler广播逻辑Javapublic class ChatServerHandler extends SimpleChannelInboundHandlerChatMessage { // 全局存储在线 Channel用 ConcurrentMap 避免锁竞争 private static final ConcurrentMapString, Channel ONLINE_USERS new ConcurrentHashMap(); Override protected void channelRead0(ChannelHandlerContext ctx, ChatMessage msg) throws Exception { if (LOGIN.equals(msg.content)) { // 登录绑定用户 ID 到 Channel ONLINE_USERS.put(msg.from, ctx.channel()); ctx.writeAndFlush(new ChatMessage().setFrom(SYSTEM).setContent(Login success)); return; } // 普通消息查收件人 Channel存在则单发否则广播 Channel target ONLINE_USERS.get(msg.to); if (target ! null target.isActive()) { target.writeAndFlush(msg); } else { // 广播给所有在线用户除自己 ONLINE_USERS.values().stream() .filter(c - !c.equals(ctx.channel())) .filter(Channel::isActive) .forEach(c - c.writeAndFlush(msg)); } } Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { // 断连清理从 ONLINE_USERS 移除该 Channel ONLINE_USERS.entrySet().removeIf(entry - entry.getValue().equals(ctx.channel())); super.channelInactive(ctx); } }关键点说明ConcurrentMap替代HashMap synchronized避免高并发下putIfAbsent竞态channelInactive()必须清理注册表否则内存泄漏每个 Channel 持有 1MB 缓冲区writeAndFlush()是异步非阻塞调用无需ExecutorService封装。4. 客户端-服务端联调用telnet和adb logcat定位三类高频故障4.1 网络层连通性验证绕过 App 直接测试 Socket 通路在 Android 设备同一局域网的 Ubuntu 机器上用telnet测试服务端端口是否可达# 从 Ubuntu 执行假设服务端 IP 192.168.1.100 telnet 192.168.1.100 8080 # 成功返回 # Trying 192.168.1.100... # Connected to 192.168.1.100. # Escape character is ^]. # 此时输入任意字符串 回车应看到服务端返回 Login success若已发送 LOGIN若提示Connection refused检查服务端进程是否运行ps aux | grep javaUbuntu 防火墙是否放行sudo ufw status服务端bind()是否指定0.0.0.0:8080而非127.0.0.1:8080后者只接受本机连接。4.2 Android 端日志抓取过滤 Socket 相关异常的精准命令Android Studio Logcat 过滤太杂用adb直接抓取# 抓取指定包名的所有日志实时输出 adb logcat -s ChatSocket:V System.out:V # 或导出最近 100 行到文件便于分析 adb logcat -t 100 -s ChatSocket socket_debug.log重点关注三类日志Connect failed: failed to connect to /192.168.1.100 (port 8080) from /:: (port 42124): connect failed: ECONNREFUSED (Connection refused)→ 服务端未启动或 IP 错Read error, reconnecting...→ 网络中断或服务端主动断连java.net.SocketException: Software caused connection abort→ 客户端强制关闭连接如 Activity 销毁未socket.close()。4.3 断线自动重连策略指数退避 最大重试次数控制原生 Socket 断连后不能立即重试避免雪崩需实现退避算法private var retryCount 0 private val maxRetry 5 private val baseDelayMs 1000L // 初始延迟 1s private fun reconnect() { if (retryCount maxRetry) { Log.e(ChatSocket, Max retry reached, stop reconnecting) return } val delay baseDelayMs * (1L shl retryCount) // 2^n 指数增长1s, 2s, 4s, 8s, 16s retryCount Handler(Looper.getMainLooper()).postDelayed({ Log.i(ChatSocket, Reconnect attempt $retryCount after ${delay}ms) connect() // 调用第 2.2.1 节的 connect() }, delay) }参数依据maxRetry 5避免无限重试耗尽电池baseDelayMs 1000首重试延迟 1 秒符合 RFC 5927 对 TCP 重传的建议1L shl retryCount位运算实现2^retryCount比Math.pow(2, n)更高效。5. 消息可靠性增强ACK 机制与本地消息去重的落地实现5.1 服务端 ACK 回执解决“消息已发但对方未收到”的确认盲区微信消息右下角的“✓✓”本质是服务端 ACK。在当前 Socket 架构中需扩展协议客户端发消息时带唯一msgId服务端处理完立即回传ACK:{msgId}。5.1.1 客户端发送逻辑增强Kotlinfun sendMessage(content: String) { val msgId UUID.randomUUID().toString().substring(0, 8) val message ChatMessage().apply { from u1 to u2 this.content content ts System.currentTimeMillis() this.msgId msgId // 新增字段 } // 发送前存入待确认队列内存 Map pendingAcks[msgId] System.currentTimeMillis() bufferedSink?.writeUtf8(Json.encodeToString(message) \n) bufferedSink?.flush() }5.1.2 服务端 ACK 回传Java// 在 ChatServerHandler.channelRead0() 处理完消息后追加 ctx.channel().writeAndFlush(new ChatMessage() .setFrom(SERVER) .setContent(ACK: msg.msgId) .setTs(System.currentTimeMillis()));5.1.3 客户端 ACK 校验与超时清理private val pendingAcks mutableMapOfString, Long() // msgId - sendTime private val ackTimeoutMs 5_000L // 5秒未收到ACK视为失败 // 在 receiveMessageLoop() 中解析到 ACK:xxx 时 if (line.startsWith(ACK:)) { val ackMsgId line.substring(4) pendingAcks.remove(ackMsgId) // 移除成功 updateUiMessageStatus(ackMsgId, DELIVERED) // 刷新 UI } // 启动定时任务检查超时 Handler(Looper.getMainLooper()).postDelayed({ val now System.currentTimeMillis() pendingAcks.entries.removeIf { (id, sendTime) - if (now - sendTime ackTimeoutMs) { updateUiMessageStatus(id, FAILED) // 标红重发按钮 true } else false } }, ackTimeoutMs 1000)5.2 本地消息去重防止因重连导致同一条消息重复插入数据库Android 端使用 Room 数据库存储聊天记录插入前必须校验msgId唯一性Entity(tableName chat_messages) data class DbMessage( PrimaryKey val msgId: String, val from: String, val to: String, val content: String, val ts: Long, val status: Int // 0send, 1delivered, 2failed ) Dao interface MessageDao { Insert(onConflict OnConflictStrategy.IGNORE) // 冲突时忽略不报错 suspend fun insert(message: DbMessage) }OnConflictStrategy.IGNORE确保即使服务端因网络抖动重复推送同一条消息Room 也不会抛SQLiteConstraintException而是静默丢弃——这是 IM 场景下最合理的去重策略。本文还有配套的精品资源点击获取
返回列表