去年冬天帮朋友搭一套内部演示系统时,我顺手写了一个基于 Java 的 VNC 屏幕采集工具。不是为了炫技,纯粹是因为对方那台远程机器跑的是 Linux,没有现成的 Web 远控,而团队里最熟的又是 Java。折腾完以后发现,VNC 的协议其实比想象中“老实”得多,只要把 RFB 3.3 那套握手、认证、帧缓冲更新的流程理清楚,用原生 Socket + Swing 就能跑起来,连第三方库都不用引。
下面我把整个项目从环境准备、协议拆解、核心代码、稳定化改造到踩坑记录,完整拆给你看。你可以把它当成一份“边写边聊”的开发笔记,而不是教科书式的步骤清单。
先让电脑“能说话”:环境准备
别一上来就写代码。VNC 是 C/S 架构,你得先有一台愿意被采集的机器。
服务端(被采集的那台)
- Windows:自带“远程桌面”不是 VNC,得装
TigerVNC或RealVNC - macOS:系统设置 → 通用 → 共享 → 打开“屏幕共享”(底层就是 VNC,默认端口 5900)
- Linux:
sudo apt install x11vnc,然后x11vnc -storepasswd mypass ~/.vnc/passwd && x11vnc -rfbauth ~/.vnc/passwd -forever -shared
客户端(你写工具的那台)
- JDK 17 或 21(Swing 在 JDK 17 里已经稳定回归,不需要额外加模块参数)
- IDEA 或 VS Code + Java 插件
- Maven 3.8+(用来管项目骨架,虽然我们不依赖任何第三方库)
网络层面记住一件事:VNC 端口通常是 5900 + displayNumber。比如 :1 就是 5901,:0 就是 5900。防火墙、云服务器安全组、公司内网路由,哪怕只漏一个,连接阶段就会卡在 ConnectException: Connection refused 或 Operation timed out。调试时先在客户端跑 telnet 192.168.x.x 5900,能通再写代码。
项目骨架:干净,不拖泥带水
vnc-screen-capture/
├── pom.xml
└── src/main/java/com/example/vnc/
├── VncCaptureApp.java // 入口
├── client/RfbClient.java // 协议主逻辑
├── auth/VncPasswordAuth.java
└── ui/ScreenPanel.java // Swing 渲染层
pom.xml 我故意只保留最基础的配置,因为 VNC 协议完全靠 JDK 标准库就能搞定:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>vnc-screen-capture</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
RFB 协议到底在干什么?用小朋友能听懂的话说
VNC 的底层协议叫 RFB(Remote FrameBuffer),目前最常用的是 3.3 版本。它的工作方式特别朴素:
- 双方先对暗号:服务器发
RFB 003.003\n,客户端回一样的,表示“咱用同一套语言”。 - 谈安全:服务器列出一堆认证方式,客户端挑一个。VNC 老版本基本都用“VNC Password Authentication”。
- 告诉服务器我想看哪块:客户端说“我要整个屏幕,只要新变化”。
- 服务器不断贴“照片”:每张照片是一个矩形区域,里面按像素排列的颜色数据。
- 客户端把照片拼起来,显示在窗口里。
整个过程就像两个人玩“传话游戏”:一方不断描述屏幕上哪些格子换了颜色,另一方负责把颜色涂回去。
核心代码:从握手到画面渲染
1. 协议通信层 RfbClient.java
这部分是整个工具的骨架。我尽量把每一段协议报文都写成独立的步骤,方便你对照 RFB 3.3 规范看。
package com.example.vnc.client;
import com.example.vnc.auth.VncPasswordAuth;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.Socket;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
public class RfbClient implements Runnable {
private final String host;
private final int port;
private final String password;
private volatile boolean running = true;
private Socket socket;
private DataInputStream in;
private DataOutputStream out;
public RfbClient(String host, int port, String password) {
this.host = host;
this.port = port;
this.password = password;
}
@Override
public void run() {
try {
connectAndHandshake();
negotiateSecurity();
authenticate();
configurePixelFormat();
requestUpdates();
while (running) {
handleServerMessage();
}
} catch (IOException e) {
System.err.println("连接断开或协议错误: " + e.getMessage());
} finally {
close();
}
}
private void connectAndHandshake() throws IOException {
socket = new Socket(host, port);
socket.setKeepAlive(true);
socket.setSoTimeout(30_000); // 30秒没数据就抛异常,用于检测死连接
in = new DataInputStream(new BufferedInputStream(socket.getInputStream()));
out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream()));
byte[] banner = new byte[12];
in.readFully(banner);
String serverBanner = new String(banner).trim();
System.out.println("服务器版本: " + serverBanner);
out.writeBytes("RFB 003.003\n");
out.flush();
}
private void negotiateSecurity() throws IOException {
int numTypes = in.readUnsignedByte();
if (numTypes == 0) {
throw new IOException("服务器不支持认证,或已关闭安全模式");
}
byte[] types = new byte[numTypes];
in.readFully(types);
// VNC 传统密码认证类型编号为 2
boolean found = false;
for (byte t : types) {
if (t == 2) {
out.writeByte(2);
out.flush();
found = true;
break;
}
}
if (!found) throw new IOException("未找到 VNC Password 认证方式,当前类型: " + java.util.Arrays.toString(types));
}
private void authenticate() throws IOException {
byte[] challenge = new byte[16];
in.readFully(challenge);
byte[] response = VncPasswordAuth.encryptPassword(password, challenge);
out.write(response);
out.flush();
byte[] result = new byte[4];
in.readFully(result);
int status = ByteBuffer.wrap(result).order(ByteOrder.BIG_ENDIAN).getInt();
if (status != 1) {
if (status > 4) {
byte[] reason = new byte[status - 4];
in.readFully(reason);
throw new IOException("认证失败: " + new String(reason));
}
throw new IOException("VNC 密码认证失败,请检查密码是否超过8位或是否正确");
}
System.out.println("认证成功,开始订阅屏幕更新...");
}
private void configurePixelFormat() throws IOException {
// 32bpp, little-endian, RGB: R=16..23, G=8..15, B=0..7
out.writeByte(0); // SetPixelFormat
out.writeByte(0); // subtype
out.writeByte(0); out.writeByte(0); out.writeByte(0); // padding
out.writeInt(32); // depth
out.writeBoolean(false); // big_endian
out.writeBoolean(true); // true_color
out.writeInt(255); // red_max
out.writeInt(255); // green_max
out.writeInt(255); // blue_max
out.writeInt(16); // red_shift
out.writeInt(8); // green_shift
out.writeInt(0); // blue_shift
out.writeByte(0); out.writeByte(0); out.writeByte(0); // pad to 16 bytes
out.flush();
}
private void requestUpdates() throws IOException {
// SetEncodings: 只请求 Raw 编码,方便理解。生产环境可加 Hextile(-5)、Tight(-7)
out.writeByte(1); // SetEncodings
out.writeByte(0); // subtype
out.writeInt(1); // number of encodings
out.writeInt(-1); // Raw encoding
// FramebufferUpdateRequest
out.writeByte(2); // FramebufferUpdateRequest
out.writeByte(0); // subtype
out.writeShort(0); out.writeShort(0); // x, y
out.writeInt(0); // width placeholder, 首次会收到 ServerInit 的宽高
out.writeInt(0);
out.writeBoolean(true); // newUpdatesOnly
out.flush();
}
private void handleServerMessage() throws IOException {
int msgType = in.readUnsignedByte();
in.readByte(); // padding
switch (msgType) {
case 0 -> handleFramebufferUpdate();
case 2 -> {
// Bell: 服务器发出提示音,客户端忽略即可
}
case 3 -> handleCutText();
default -> System.out.println("收到未知消息类型: " + msgType);
}
}
private void handleFramebufferUpdate() throws IOException {
in.readByte(); // padding
int numRects = in.readUnsignedShort();
for (int i = 0; i < numRects; i++) {
short x = in.readShort();
short y = in.readShort();
short w = in.readShort();
short h = in.readShort();
int encoding = in.readInt();
if (encoding == -1) { // Raw
byte[] pixelData = new byte[w * h * 4];
in.readFully(pixelData);
BufferedImage img = decodeRawPixelData(pixelData, w, h);
// 这里通过回调交给 UI 线程刷新
ScreenUpdater.update(img, x, y, w, h);
} else {
// 遇到 Hextile/Tight 等编码,先跳过数据部分
skipEncodingData(encoding, w, h);
}
}
// 必须回复 FramebufferUpdateResponse,否则服务器停止推送
out.writeByte(3); // FramebufferUpdateResponse
out.writeByte(0); // subtype
out.writeInt(0); out.writeInt(0); // x, y
out.writeInt(0); out.writeInt(0); // w, h
out.flush();
}
private BufferedImage decodeRawPixelData(byte[] data, int w, int h) {
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
int[] pixels = new int[w * h];
for (int i = 0; i < pixels.length; i++) {
int offset = i * 4;
int b = data[offset] & 0xFF;
int g = data[offset + 1] & 0xFF;
int r = data[offset + 2] & 0xFF;
// VNC little-endian 第四个字节通常是 padding,直接补全透明通道
pixels[i] = (0xFF << 24) | (r << 16) | (g << 8) | b;
}
img.setRGB(0, 0, w, h, pixels, 0, w);
return img;
}
private void skipEncodingData(int encoding, int w, int h) throws IOException {
// 简化处理:Raw 以外的编码暂不解析,实际项目应实现 Hextile/Tight 解码器
if (encoding == -5) { // Hextile
// 跳过 hextile subrects,此处省略复杂状态机
throw new UnsupportedOperationException("当前版本仅支持 Raw 编码,遇到 Hextile 请升级解码器");
}
if (encoding == -7) { // Tight
throw new UnsupportedOperationException("当前版本仅支持 Raw 编码,遇到 Tight 请升级解码器");
}
}
private void handleCutText() throws IOException {
in.readInt(); // length
byte[] text = new byte[in.readInt()];
in.readFully(text);
// 有些 VNC 服务器会发心跳类文本,这里仅作日志
// System.out.println("CutText: " + new String(text));
}
public void stop() {
running = false;
close();
}
private void close() {
try { if (socket != null) socket.close(); } catch (IOException ignored) {}
}
}
2. VNC 密码认证:那个让人头大的 DES 变换
VNC 的密码认证不是直接传明文,也不是走 TLS。它用的是一个“变形版 DES”:
- 服务器发 16 字节随机挑战值(challenge)
- 客户端把密码截断/补零到 8 字节,作为 DES 密钥
- challenge 每 8 字节一组,先把组内字节顺序反转,再用 DES 加密,再把结果字节顺序反转
- 两组拼起来发回服务器
这段代码看起来绕,但跑通一次就永远记得住:
package com.example.vnc.auth;
import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Arrays;
public class VncPasswordAuth {
private static final int DES_KEY_SIZE = 8;
public static byte[] encryptPassword(String password, byte[] challenge) throws Exception {
byte[] key = Arrays.copyOf(password.getBytes("ISO-8859-1"), DES_KEY_SIZE);
SecretKeySpec desKey = new SecretKeySpec(key, "DES");
Cipher des = Cipher.getInstance("DES/ECB/NoPadding");
des.init(Cipher.ENCRYPT_MODE, desKey);
byte[] result = new byte[16];
for (int i = 0; i < 2; i++) {
byte[] block = Arrays.copyOfRange(challenge, i * 8, (i + 1) * 8);
reverseBytes(block);
block = des.doFinal(block);
reverseBytes(block);
System.arraycopy(block, 0, result, i * 8, 8);
}
return result;
}
private static void reverseBytes(byte[] arr) {
for (int i = 0; i < arr.length / 2; i++) {
byte tmp = arr[i];
arr[i] = arr[arr.length - 1 - i];
arr[arr.length - 1 - i] = tmp;
}
}
}
⚠️ 两个高频翻车点:
- VNC 密码最长 8 个字符,超过会被服务端静默拒绝或截断,认证必然失败。
- 密码编码必须按
ISO-8859-1取字节,不能用 UTF-8。中文密码在老版 VNC 里根本不支持。
3. 窗口渲染层 ScreenPanel.java
Swing 更新图片必须放在 EDT 线程上,否则会出现花屏、卡死、甚至 JVM 崩溃。我用了一个简单的双缓冲队列,把网络线程收到的图片排队交给 UI 线程:
package com.example.vnc.ui;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class ScreenPanel extends JPanel {
private final BlockingQueue<BufferedImage> queue = new LinkedBlockingQueue<>(10);
private volatile BufferedImage currentImage;
private final JLabel displayLabel = new JLabel();
public ScreenPanel() {
setLayout(new BorderLayout());
displayLabel.setHorizontalAlignment(SwingConstants.CENTER);
displayLabel.setVerticalAlignment(SwingConstants.CENTER);
add(displayLabel, BorderLayout.CENTER);
setBackground(Color.BLACK);
}
public static void update(BufferedImage img, int x, int y, int w, int h) {
// 实际项目中可以把 x,y,w,h 一起传进来做局部拼接
// 这里为了教学清晰,直接推全量图
try {
ScreenPanel.instance.queue.put(img);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 静态实例,供 RfbClient 回调使用
public static ScreenPanel instance;
public void startRenderer() {
instance = this;
new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
BufferedImage next = queue.take();
currentImage = next;
SwingUtilities.invokeLater(() -> {
if (currentImage != null) {
displayLabel.setIcon(new ImageIcon(currentImage));
displayLabel.repaint();
}
});
} catch (InterruptedException e) {
break;
}
}
}, "VNC-Renderer").start();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
if (currentImage != null) {
int scale = Math.min(getWidth() / (float) currentImage.getWidth(),
getHeight() / (float) currentImage.getHeight());
int w = (int)(currentImage.getWidth() * scale);
int h = (int)(currentImage.getHeight() * scale);
g.drawImage(currentImage, (getWidth() - w) / 2, (getHeight() - h) / 2, w, h, null);
}
}
}
4. 启动入口 VncCaptureApp.java
package com.example.vnc;
import com.example.vnc.client.RfbClient;
import com.example.vnc.ui.ScreenPanel;
import javax.swing.*;
import java.awt.*;
public class VncCaptureApp {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
} catch (Exception e) { /* 忽略外观加载失败 */ }
JFrame frame = new JFrame("VNC Screen Capture Tool");
ScreenPanel panel = new ScreenPanel();
frame.setContentPane(panel);
frame.setSize(1280, 900);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setLocationRelativeTo(null);
frame.setVisible(true);
panel.startRenderer();
// 替换成你的服务端 IP、端口和密码
String host = "192.168.31.100";
int port = 5900;
String password = "mypass";
RfbClient client = new RfbClient(host, port, password);
Thread worker = new Thread(client, "VNC-Worker");
worker.setDaemon(true);
worker.start();
Runtime.getRuntime().addShutdownHook(new Thread(client::stop));
});
}
}
让它“稳定跑”:断线、卡顿、内存泄漏怎么办
协议跑通只是第一步。真正要给别人用,得解决三个现实问题。
1. 断线重连循环
TCP 不像微信消息有送达回执。网线松一下、路由器重启、服务器休眠,连接都会无声断裂。我的做法是在 main 外面包一层重连壳:
while (true) {
try {
client.run();
break;
} catch (IOException e) {
System.err.println("连接中断: " + e.getMessage() + ",5秒后重连...");
try { Thread.sleep(5000); } catch (InterruptedException ie) { break; }
}
}
配合前面设置的 setSoTimeout(30_000) 和 setKeepAlive(true),基本能覆盖 90% 的“假死连接”。
2. 内存不要越跑越满
网络线程收到图片后如果 UI 线程来不及渲染,BlockingQueue 会积压。我在 ScreenPanel 里把队列容量限制为 10,并且渲染循环里只保留最新一帧。如果生产环境想进一步优化,可以改成“只推送最近 N 秒的画面”,旧帧直接丢弃。
另外,VNC 原始数据是 4 字节/像素。1920×1080 的屏幕一帧就是 8MB。如果服务器推送频率高、你只关心静态画面,可以在客户端主动发 FramebufferUpdateRequest 时设置 newUpdatesOnly=false 并降低请求频率,或者实现 Hextile 编码让服务器只传变化区域。
3. 编码升级:从 Raw 到 Hextile
Raw 编码简单,但浪费带宽。当你在代码里看到 encoding == -5 时,说明服务器觉得 Raw 太笨,主动切到了 Hextile。Hextile 把屏幕切成 16×16 的瓦片,每个瓦片记录“背景色 + 变化子块”。解码稍微复杂,但带宽能降 60% 以上。
如果你暂时不想碰 Hextile,可以在 SetEncodings 里把顺序调一下:
out.writeInt(-1); // Raw
out.writeInt(-5); // Hextile
out.writeInt(-7); // Tight
服务器会优先选它认为最省流量的。但前提是你要把对应的解码器补上。
实测时那些“只有踩过才记得”的细节
- 云服务器端口别忘开:阿里云/腾讯云的安全组默认拦 5900。开了之后建议改 VNC 端口到 59300 以上,减少扫描器骚扰。
- VNC 密码不要用特殊符号:老版本 VNC 客户端对
!@#$%^&*()支持参差不齐,纯字母数字最稳。 - Linux 桌面环境差异:无头服务器(headless)没有图形界面,VNC 需要配合
Xvfb或x11vnc -create才能看到东西。 - 截图保存功能:把
ScreenPanel里的currentImage定时ImageIO.write(img, "png", new File("capture_%tF_%tT.png"))就行。注意文件名里的时间格式符要用String.format预处理,别直接把%tF塞进路径。 - 权限问题:macOS 的“屏幕共享”如果被系统提示“允许录制/控制”,必须在 系统设置 → 隐私与安全性 → 屏幕录制 里给终端或 IDEA 打勾,否则只能抓到黑屏。
怎么跟小朋友讲明白这件事?
如果你需要把这个工具的原理讲给没写过代码的人听,可以这样比喻:
想象你坐在教室外面,想看看里面黑板上写了什么。老师(VNC 服务器)每隔几秒给你寄一张明信片,上面画着黑板的一个小方块,还标明了这个方块在黑板的左上角还是右下角。你(Java 程序)收到明信片后,按位置把小方块贴回一张大纸上。贴完一看,整块黑板的内容就还原了。密码认证就是老师先问你一句暗号,答对了才肯寄明信片。
这个比喻虽然简化了像素编码、TCP 流和 Swing 线程,但核心逻辑是对的:远端采集本质上就是“接收局部图像补丁 → 按坐标拼合 → 实时刷新”。
下一步可以往哪儿走
现在这个工具已经能稳定抓取远程画面、断线重连、在本地窗口实时显示。如果你想继续打磨,方向很明确:
- 加入 Hextile/Tight 解码器,把带宽吃紧的场景撑起来
- 加上键盘鼠标转发,从“只看”变成“真远控”
- 导出为 Web 页面,用 WebSocket 把画面推到浏览器,团队不用装 Java
- 录屏功能,把
BufferedImage按时间戳存成gif或mp4(配合ffmpeg命令行)
代码都已经摊开在这儿了。你只需要把 host、port、password 换成你自己的测试环境,mvn clean package 跑起来,看着远程桌面的画面一点点在窗口里刷新,那种“协议被我驯服了”的感觉还是挺爽的。遇到哪个环节卡住,把报错日志贴出来,咱们接着往下拆。