简介:这是一份面向安卓开发者的WebSocket实战案例资源,聚焦在App端同时实现客户端与服务端的完整方案,适合具备一定Android基础、希望掌握实时通信技术的开发者参考。案例覆盖连接建立、消息收发、断开重连等核心环节,并涉及线程处理、端口监听、安全配置与生命周期管理等要点,可应用于在线聊天、实时推送、游戏同步等场景。资源包共467个文件,约19.03MB,包含9个java源码、33个xml布局与配置、130个json数据、11个jar依赖及12个dex文件,另有gradle构建脚本、properties配置和apk成品,结构完整便于直接导入研究。目前已有569人学习下载。通过研读源码,读者可掌握org.java_websocket库的客户端用法、ServerSocket服务端搭建思路,以及连接状态回调、数据发送接收、后台关闭与自动重连等排错经验,快速将WebSocket能力落地到自己的安卓项目中。
1. 安卓 WebSocket 双端同进程:一个被低估的调试利器
很多做安卓 IM 或实时推送的开发者,第一次听到「客服端和服务端都写在 App 端」这个方案时,第一反应是「这不是脱裤子放屁吗」。我当初也这么想,直到有一次在离线环境里调一个消息时序 bug,后端同事不在、测试服务器又连不上,我才意识到把 WebSocket 服务端塞进 App 里有多香。安卓 WebSocket 双端同进程,本质是在同一个 App 里同时跑一个 WebSocket 服务端(监听本地端口)和一个 WebSocket 客户端(连自己),消息不经过任何外部网络就能完成一次完整的收发闭环。它解决的不是生产环境的通信问题,而是开发期的联调、演示、教学和单元测试问题——你不需要后端、不需要网络、不需要配置域名,装上 APK 就能看到一条消息从客户端发出、被服务端接收、再回推给客户端的全过程。适合谁?适合正在学 WebSocket 协议、需要做离线 Demo、或者想在没有后端配合时先把客户端逻辑跑通的安卓开发者。
2. 为什么要在 App 里同时跑服务端和客户端:选型与协议底账
2.1 同进程双端的真实使用场景
先把「为什么要这么干」说清楚,否则后面写代码会心虚。常见做法有三类场景会用到这个方案。
第一类是教学与协议验证。WebSocket 的握手过程涉及 HTTP Upgrade、Sec-WebSocket-Key 计算、帧格式解析,如果只写客户端,你永远只能看到「连上了」和「收到消息」,看不到服务端怎么解析帧、怎么回握手响应。把服务端也放进 App,你可以在同一个 Logcat 里看到两端日志交替打印,握手包、掩码、opcode 一目了然。
第二类是离线演示。给客户或导师演示一个聊天功能,现场没有可用后端,或者后端部署在内网连不上。App 内置服务端后,两台手机连同一个热点,一台跑服务端、一台跑客户端,或者干脆一台手机自己连自己,演示不依赖任何外部条件。
第三类是客户端逻辑的独立测试。消息重连、心跳保活、消息去重、离线队列这些逻辑,本质上只跟客户端有关。如果每次测试都要等后端环境就绪,效率极低。内置一个轻量服务端,你可以精确控制它什么时候断、什么时候延迟回复、什么时候发重复消息,把客户端的边界逻辑测透。
注意:这个方案只用于开发和演示,不要把它当成生产架构。生产环境的服务端必须独立部署,App 内跑服务端会带来端口占用、电量消耗、安全暴露等一系列问题。
2.2 WebSocket 握手与帧格式的最小必要知识
要在安卓上自己实现服务端,绕不开两件事:握手和帧解析。这里只讲实现必需的部分,不展开 RFC 全文。
握手阶段,客户端发一个标准 HTTP 请求,关键头是Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key: <base64随机值>、Sec-WebSocket-Version: 13。服务端收到后,把Sec-WebSocket-Key拼上一个固定的 GUID 字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11,做 SHA-1 哈希,再 Base64 编码,作为Sec-WebSocket-Accept返回。返回状态码是 101,表示协议切换。
帧格式方面,服务端发给客户端的帧不加掩码,客户端发给服务端的帧必须加掩码。一个帧的头部至少 2 字节:第一字节的高位是 FIN 和 opcode,第二字节的最高位是 MASK 标志,低 7 位是 payload 长度。长度 126 表示后面 2 字节是真实长度,127 表示后面 8 字节是真实长度。掩码是 4 字节,payload 每个字节与掩码循环异或。
这些规则听起来琐碎,但自己实现一遍之后,你对 WebSocket 的理解会从「一个库」变成「一套协议」。下面进入实操。
2.3 用 Java 在安卓端实现最小 WebSocket 服务端
我一般会用一个独立线程跑ServerSocket,收到连接后先读 HTTP 握手请求,解析出Sec-WebSocket-Key,回 101 响应,然后进入帧循环。下面是最小可用版本。
// WebSocketServer.java public class WebSocketServer { private ServerSocket serverSocket; private volatile boolean running = false; private OnMessageListener listener; public interface OnMessageListener { void onMessage(String text); void onClientConnected(); } public void setListener(OnMessageListener l) { this.listener = l; } public void start(int port) throws IOException { serverSocket = new ServerSocket(port); running = true; new Thread(() -> { while (running) { try { Socket socket = serverSocket.accept(); handleClient(socket); } catch (IOException e) { if (running) e.printStackTrace(); } } }).start(); } private void handleClient(Socket socket) throws IOException { InputStream in = socket.getInputStream(); OutputStream out = socket.getOutputStream(); // 1. 读握手请求,直到遇到空行 ByteArrayOutputStream headerBuf = new ByteArrayOutputStream(); int prev = -1, cur; while ((cur = in.read()) != -1) { headerBuf.write(cur); if (prev == '\r' && cur == '\n') { // 简化判断:连续两个 CRLF 结束 byte[] arr = headerBuf.toByteArray(); int len = arr.length; if (len >= 4 && arr[len-4]=='\r' && arr[len-3]=='\n' && arr[len-2]=='\r' && arr[len-1]=='\n') break; } prev = cur; } String header = headerBuf.toString("UTF-8"); String key = extractKey(header); // 2. 计算 Sec-WebSocket-Accept String accept = Base64.encodeToString( MessageDigest.getInstance("SHA-1") .digest((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") .getBytes("UTF-8")), Base64.NO_WRAP); String resp = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + accept + "\r\n\r\n"; out.write(resp.getBytes("UTF-8")); out.flush(); if (listener != null) listener.onClientConnected(); // 3. 帧循环 while (running && !socket.isClosed()) { String msg = readFrame(in); if (msg == null) break; if (listener != null) listener.onMessage(msg); } } private String extractKey(String header) { for (String line : header.split("\r\n")) { if (line.toLowerCase().startsWith("sec-websocket-key:")) { return line.substring(line.indexOf(':') + 1).trim(); } } return ""; } // readFrame / sendFrame 见下一节 }这段代码的逻辑分三步:接受连接、完成握手、进入帧循环。extractKey从 HTTP 头里取出客户端随机生成的 Key,这是握手能否成功的关键。Base64.NO_WRAP是安卓特有的参数,不加它会在 Base64 结果里插入换行,导致握手失败——这个坑我踩过,排查了半天。
参数方面,port建议选 8080 以上、65535 以下的端口,避免与系统端口冲突。ServerSocket的 backlog 默认是 50,单客户端场景够用。handleClient目前是阻塞式的,一次只能处理一个客户端,做演示足够;如果要支持多客户端,需要把handleClient丢进独立线程。
2.4 帧的读取与发送:掩码、长度和 opcode 的处理
帧处理是自实现 WebSocket 服务端最容易翻车的地方。下面给出读取和发送两个方法。
// 读取一个文本帧,返回 payload 字符串;连接关闭返回 null private String readFrame(InputStream in) throws IOException { int b0 = in.read(); if (b0 == -1) return null; int opcode = b0 & 0x0F; if (opcode == 0x8) return null; // close 帧 int b1 = in.read(); boolean masked = (b1 & 0x80) != 0; long payloadLen = b1 & 0x7F; if (payloadLen == 126) { payloadLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF); } else if (payloadLen == 127) { payloadLen = 0; for (int i = 0; i < 8; i++) payloadLen = (payloadLen << 8) | (in.read() & 0xFF); } byte[] mask = new byte[4]; if (masked) { in.read(mask); // 客户端发来的帧必须带掩码 } byte[] payload = new byte[(int) payloadLen]; int read = 0; while (read < payloadLen) { int r = in.read(payload, read, (int) payloadLen - read); if (r == -1) return null; read += r; } if (masked) { for (int i = 0; i < payload.length; i++) { payload[i] ^= mask[i % 4]; } } return new String(payload, "UTF-8"); } // 发送一个文本帧(服务端发出的帧不加掩码) public void sendFrame(OutputStream out, String text) throws IOException { byte[] data = text.getBytes("UTF-8"); ByteArrayOutputStream frame = new ByteArrayOutputStream(); frame.write(0x81); // FIN=1, opcode=1 (text) if (data.length <= 125) { frame.write(data.length); } else if (data.length <= 65535) { frame.write(126); frame.write((data.length >> 8) & 0xFF); frame.write(data.length & 0xFF); } else { frame.write(127); for (int i = 7; i >= 0; i--) { frame.write((int)(((long)data.length >> (8 * i)) & 0xFF)); } } frame.write(data); out.write(frame.toByteArray()); out.flush(); }readFrame里最关键的是掩码处理。客户端发来的帧MASK位一定是 1,服务端必须用那 4 字节掩码对 payload 做异或还原。如果忘了这一步,收到的中文会变成乱码,英文可能看起来正常——这是最迷惑人的一个 bug。sendFrame里服务端发出的帧不加掩码,MASK位保持 0,这是协议规定的,加了反而会被客户端拒绝。
长度处理上,126 和 127 两个分支分别对应 2 字节和 8 字节扩展长度。演示场景消息一般不超过 125 字节,但心跳包和长文本会触发扩展长度,建议一开始就写全。opcode 方面,0x1 是文本帧,0x8 是关闭帧,0x9 是 ping,0xA 是 pong。最小实现里只处理文本和关闭,ping/pong 可以后续补。
3. 客户端与服务端的对接:从连接到消息闭环
3.1 安卓端 WebSocket 客户端的选型与最小实现
客户端侧我一般用 OkHttp 的 WebSocket,稳定、API 清晰、社区资料多。如果你不想引入 OkHttp,也可以用 Java-WebSocket 库,但 OkHttp 在安卓上更常见。
// 使用 OkHttp 建立 WebSocket 连接 OkHttpClient client = new OkHttpClient.Builder() .pingInterval(20, TimeUnit.SECONDS) // 心跳间隔 .build(); Request request = new Request.Builder() .url("ws://127.0.0.1:8080") // 连本机服务端 .build(); WebSocket webSocket = client.newWebSocket(request, new WebSocketListener() { @Override public void onOpen(WebSocket ws, Response response) { Log.d("WS", "客户端已连接"); ws.send("hello from client"); } @Override public void onMessage(WebSocket ws, String text) { Log.d("WS", "客户端收到: " + text); } @Override public void onFailure(WebSocket ws, Throwable t, Response response) { Log.e("WS", "连接失败", t); } });pingInterval是 OkHttp 自动发 ping 帧的间隔,服务端如果实现了 pong 回复,连接会更稳定。url用127.0.0.1表示连本机,如果服务端跑在另一台设备上,换成那台设备的局域网 IP。onOpen里发第一条消息,onMessage里处理回推,这样就形成了一个最小闭环。
3.2 把两端串起来:一次完整消息收发的时序
把服务端和客户端放在同一个 Activity 里启动,顺序很重要。必须先启动服务端并确保ServerSocket已经绑定端口,再启动客户端去连,否则客户端会收到Connection refused。
// 在 Activity 中同时启动两端 private WebSocketServer server; private WebSocket client; private void startBoth() { server = new WebSocketServer(); server.setListener(new WebSocketServer.OnMessageListener() { @Override public void onMessage(String text) { Log.d("WS", "服务端收到: " + text); // 收到后回推一条 // 注意:sendFrame 需要持有对应 socket 的 OutputStream } @Override public void onClientConnected() { Log.d("WS", "有客户端接入"); } }); try { server.start(8080); // 先启动服务端 } catch (IOException e) { Log.e("WS", "服务端启动失败", e); return; } // 延迟一小段再连,确保端口已绑定 new Handler(Looper.getMainLooper()).postDelayed(() -> { OkHttpClient c = new OkHttpClient(); Request req = new Request.Builder().url("ws://127.0.0.1:8080").build(); client = c.newWebSocket(req, new WebSocketListener() { @Override public void onMessage(WebSocket ws, String text) { Log.d("WS", "客户端收到回推: " + text); } }); }, 200); }这里的 200ms 延迟是个经验值。ServerSocket构造完成后端口就绑定了,但线程调度可能还没进入accept,客户端立刻连有可能失败。200ms 在大多数设备上够用,如果追求更稳,可以在服务端start里加一个CountDownLatch,等accept线程真正跑起来再返回。
服务端回推消息时,需要把handleClient里的OutputStream保存下来,否则onMessage回调里拿不到发送通道。常见做法是在WebSocketServer里维护一个List<OutputStream>或者单个OutputStream成员变量,onClientConnected时赋值。
3.3 端口、线程与生命周期:三个必须处理的工程问题
第一是端口占用。如果 App 被杀掉后端口没释放,下次启动会抛BindException: Address already in use。解决办法是在onDestroy里调用serverSocket.close(),并把running置为 false。安卓的ServerSocket在进程退出后通常会被系统回收,但热重载或快速重启时容易撞上。
第二是线程模型。ServerSocket.accept()和readFrame都是阻塞的,绝对不能放在主线程,否则直接 ANR。我一般用new Thread或Executors.newSingleThreadExecutor,演示场景单线程足够。客户端 OkHttp 的回调本身在子线程,更新 UI 要切回主线程。
第三是生命周期。服务端和客户端应该跟 Activity 或 Service 绑定。如果放在 Activity 里,onDestroy要关闭两端;如果放在 Service 里,注意安卓 8.0 以后后台 Service 的限制,演示时保持前台即可。
提示:调试时把服务端和客户端的日志用不同 TAG 打出来,比如
WS-Server和WS-Client,在 Logcat 里过滤一个 TAG 就能看到完整时序,比混在一起猜要快得多。
4. 避坑与排查:自实现 WebSocket 服务端最容易翻车的五个点
4.1 握手返回 101 但客户端立刻断开
现象是客户端onOpen没触发,直接走onFailure,服务端日志显示握手响应已发出。原因通常是Sec-WebSocket-Accept计算错误。最常见的是 Base64 编码时带了换行,或者 SHA-1 输入里 GUID 拼错、大小写不对。解决方法是把key + GUID的字符串打出来核对,GUID 必须严格是258EAFA5-E914-47DA-95CA-C5AB0DC85B11,Base64 用NO_WRAP。
4.2 收到的中文变成乱码
现象是英文消息正常,中文消息乱码。原因是读取帧时没有处理掩码,或者掩码异或的起始位置不对。客户端发来的帧MASK位一定是 1,必须用 4 字节掩码对 payload 逐字节异或,payload[i] ^= mask[i % 4]。如果掩码读漏了,后续所有字节都会错位。解决方法是先打印原始 payload 的十六进制,再打印异或后的结果,对比就能定位。
4.3 大消息被截断或解析出错
现象是短消息正常,超过 125 字节的消息收不全或直接异常。原因是长度字段只处理了 7 位的情况,没有处理 126 和 127 扩展长度。解决方法是把readFrame里的长度分支补全,126 读 2 字节,127 读 8 字节,注意用long接收 127 的长度,避免溢出。
4.4 客户端连不上,报 Connection refused
现象是客户端启动后立刻失败,服务端没有任何日志。原因通常是服务端还没绑定端口客户端就发起了连接,或者端口被占用导致ServerSocket构造失败但异常被吞了。解决方法是在server.start里加日志确认绑定成功,客户端连接前加短暂延迟或重试机制。重试三次、每次间隔 300ms 是个稳妥的做法。
4.5 连接一段时间后自动断开
现象是连接建立后几十秒无消息就断了。原因是缺少心跳。OkHttp 的pingInterval会发 ping 帧,但服务端如果没回 pong,客户端会认为连接失效。解决方法是在服务端的帧循环里识别 opcode 0x9(ping),收到后回一个 opcode 0xA(pong)的空帧。如果暂时不想实现 pong,可以把pingInterval设长一些,或者客户端定时发文本消息保活。
5. 进阶:把双端方案变成可复用的调试工具
前面四章把最小闭环跑通了,但如果你真的想把这个方案用起来,还需要做几件事让它从「能跑」变成「好用」。
第一件事是给服务端加可编程的响应策略。演示和测试时,你往往需要服务端「故意不回」「延迟 3 秒再回」「回一条重复消息」。我一般会在WebSocketServer里加一个ResponseStrategy接口,默认实现是立即回显,测试时替换成延迟或丢弃策略。这样客户端的心跳超时、消息去重、离线队列逻辑都能被精确触发。
public interface ResponseStrategy { void onMessage(String text, OutputStream out) throws IOException; } // 延迟 3 秒回显 public class DelayEchoStrategy implements ResponseStrategy { @Override public void onMessage(String text, OutputStream out) throws IOException { new Handler(Looper.getMainLooper()).postDelayed(() -> { try { sendFrame(out, "delayed: " + text); } catch (IOException e) { e.printStackTrace(); } }, 3000); } }第二件事是把双端日志统一到一个可观测的界面。Logcat 在演示时不够直观,我会在 App 里加一个简单的RecyclerView,服务端和客户端的每条消息都往列表里插一条,带时间戳和方向标记(C→S 或 S→C)。演示时直接看界面,比翻日志快得多。这个列表的数据结构就是一个List<LogEntry>,LogEntry包含timestamp、direction、content三个字段。
第三件事是端口和地址的可配置化。硬编码 8080 和 127.0.0.1 在单机演示时没问题,但两台设备联调时就需要改。我会在界面上放两个EditText,一个填服务端监听端口,一个填客户端连接地址,启动按钮读取这两个值。这样同一份 APK 既能当服务端也能当客户端,两台设备装同一个包,一台点「启动服务端」,另一台点「连接」,就能完成跨设备演示。
第四件事是处理多客户端。最小实现里handleClient是阻塞的,只能服务一个客户端。如果要支持多个,把handleClient丢进线程池,并用一个CopyOnWriteArrayList<OutputStream>保存所有客户端的输出流,广播时遍历发送。注意并发写同一个OutputStream要加锁,否则帧会交错。
验证方法上,我习惯用三步确认方案是否真的跑通:第一步,单机自连,客户端发一条、服务端收到并回显、客户端再收到,Logcat 里三条日志齐全;第二步,用adb forward把端口映射出来,在电脑上用浏览器控制台new WebSocket('ws://127.0.0.1:8080')连一下,能连上说明握手和帧格式都符合标准;第三步,两台设备连同一热点,一台跑服务端一台跑客户端,发一条中文长消息,确认不丢不乱。
最后说个我自己的教训。我最初实现时为了省事,把readFrame里的掩码处理跳过了,因为「反正服务端发给客户端不加掩码,客户端发过来的我也假装没掩码」。结果英文消息看起来正常,中文全是乱码,我花了整整一个下午在编码格式上找原因,最后才发现是掩码没异或。从那以后,我写任何协议实现都先把「对端一定会按协议来」当成铁律,不抱侥幸。这个双端方案本身不复杂,但协议细节一个都不能省。希望帮到你。
本文还有配套的精品资源,点击获取