简介:这份Android与PC的Socket通信项目(C#版)安卓源码包,面向需要在移动端与桌面端之间建立局域网或互联网通信的开发者,解决两端Socket连接、消息收发与跨语言调用等问题。包体共78个文件、大小约1.72MB,源码以C#的cs文件与Java的java文件为主,并包含17个class编译文件、11个xml配置与布局、8个png界面图标、4个exe可执行程序、2个可直接安装的apk及配套jar、pdb、sln等工程文件,构成一套完整的开发与运行环境。项目内置编译好的exe与apk,读者可先直接运行观察效果,再深入MySocketServer、SocketTest2等核心模块,理清从Socket服务端绑定监听、接收数据到Android客户端发起连接、发送消息的完整链路,并参考resx资源、manifest清单等文件掌握打包与权限配置细节。目前已有118人学习下载,适合有一定Java与C#基础、希望快速搭建跨平台Socket通信Demo或进行二次开发的初、中级开发者。
1. 从一份源码压缩包说起:安卓与PC的Socket通信C#版到底在解决什么问题
拿到一份「Android应用源码 安卓与PC的Socket通信项目 C#版.zip」,最常见的使用场景是:手机需要把数据传给电脑,或者电脑需要控制手机,两端都在同一个局域网里,不想搭服务器、不想走云中转,直接用TCP/UDP把数据从A端搬到B端。这个标题里藏着三个关键点:Android端用Java/Kotlin写Socket客户端,PC端用C#写服务端,中间的连接协议由开发者自己定义。很多刚从Android Studio入门的人,卡住的地方不在Socket API本身,而在「两端如何约定数据格式」「C#端如何同时接收多个设备」「为什么手机连不上电脑」。这篇博文按「协议设计 → Android端实现 → C#端实现 → 联调排错 → 进阶验证」的顺序,把一套能在局域网里跑通的Socket通信方案完整拆开讲。适用人群是Android开发、C#上位机开发,以及所有需要在手机和PC之间做实时数据交换的工程师。
2. 通信模型与协议设计:两端各写各的,但必须先谈好这四件事
Socket通信的本质是「两个进程通过IP和端口交换字节流」。Android端和C#端各自的语言、运行环境完全不同,能达成一致的就只有IP、端口、字节序和消息格式。第2章先把通信模型定下来,再定义应用层协议,最后落到帧结构上。很多源码包里的通信代码跑不通,问题往往出在这一层:两端对「一条消息从哪开始、到哪结束」的理解不一致。
2.1 TCP与UDP选型:局域网实时控制选TCP,高频采样数据再看UDP
Socket通信的第一步是选传输层协议。C#端做上位机、Android端做采集或控制端时,绝大多数场景应该选TCP,理由很直接:TCP面向连接、有确认重传机制,能保证字节流的顺序和完整性。Socket网络编程里最常见的两类故障——「收到半包数据」「PC端指令丢了导致Android端状态不同步」——TCP能从传输层杜绝一半。
UDP的优势是延迟低、无连接开销,适合音视频流、传感器高频上报这种「丢一帧无所谓」的场景。但UDP在局域网里丢包率并不为零,Wi-Fi环境下的突发丢包会让C#端解析出残缺的数据帧,需要自己在应用层做序号校验和重传逻辑。做「安卓与PC的Socket通信」这个标题下的项目,除非明确知道对端会持续产生大量数据且能容忍丢失,否则默认选TCP。
| 对比项 | TCP | UDP |
|---|---|---|
| 连接状态 | 需要connect/accept建立连接 | 无连接,直接sendto/recvfrom |
| 数据可靠性 | 可靠传输,按序到达 | 可能丢失、乱序、重复 |
| 数据边界 | 字节流,需要应用层自定义帧 | 保留消息边界 |
| 适用场景 | 指令控制、文件传输、状态同步 | 音视频、高频传感器、广播发现 |
2.2 应用层协议设计:魔数、长度、类型、负载四段式帧结构
传输层只负责把字节流从A送到B,不负责「哪些字节是一条完整消息」。C#端接收到的TCP数据是连续的字节流,如果两端没约定消息边界,就会出现粘包和半包问题。我一般会定义一个四段式帧结构:魔数、数据长度、消息类型、负载数据。
魔数(2字节) | 数据长度(4字节) | 消息类型(2字节) | 负载数据(N字节)魔数固定为0xAA 0x55,用于接收端快速校验「这确实是一条我们约定的消息」;数据长度是负载数据的字节数,不含头部本身;消息类型用来区分心跳、文本消息、文件块、控制指令;负载数据是业务内容。这个帧结构同时被Android端和C#端采用,两端各自的缓冲区里做同样解析。
// Android端帧编码示例,使用ByteBuffer保证字节序一致 public static byte[] encodeFrame(short msgType, byte[] payload) { // ByteBuffer默认大端序,C#端BinaryWriter默认小端序,这里必须两端统一 ByteBuffer buffer = ByteBuffer.allocate(2 + 4 + 2 + payload.length); buffer.putShort((short) 0xAA55); // 魔数,short占2字节 buffer.putInt(payload.length); // 数据长度,int占4字节 buffer.putShort(msgType); // 消息类型,short占2字节 buffer.put(payload); // 负载数据 return buffer.array(); }这段代码里最容易踩的坑是字节序。ByteBuffer默认使用大端序(Big Endian),而C#的BinaryWriter默认使用小端序(Little Endian)。如果两端各写各的没有统一字节序,C#端读出来的整数会变成乱码。解决办法是两端要么都用大端序(Java端保持默认,C#端用BinaryWriter写的时候反转字节),要么都用小端序(C#端保持默认,Java端调buffer.order(ByteOrder.LITTLE_ENDIAN))。建议统一使用小端序,因为C#端写起来更顺手。
2.3 通信模型确定:C#做服务端监听,Android做客户端主动连接
确定「谁是服务端、谁是客户端」是整个项目里决策痕迹最明显的一步。常见做法是让C#端做服务端,Android端做客户端,理由是:PC通常有固定局域网IP,C#进程启动后直接TcpListener监听端口等待连接;Android手机在Wi-Fi环境下IP是DHCP动态分配的,而且Android App在前台/后台切换时网络状态不稳定,作为客户端主动连接、断线重连更自然。
如果反过来让Android做服务端,会引入两个额外问题:一是Android后台进程容易被系统回收导致监听端口消失;二是手机的Wi-Fi网络在锁屏后可能休眠,连接会被系统断开。C#作为服务端,Android作为客户端,服务端只需保持监听,客户端负责连接的管理和重连,这是Socket编程里最好维护的方案。同一Wi-Fi下,PC的防火墙需要放行对应端口,这个坑放在第5章联调部分详细说。
3. Android端Socket实现:从权限配置到断线重连的完整客户端
第3章写Android端。代码基于Java Socket实现,用Android Studio直接编译运行。Android端的职责是:连接C#服务端、按帧结构封装和发送数据、接收服务端下发的指令并解帧。要处理的边界问题包括网络权限、主线程限制、连接超时、断线自动重连。
3.1 网络权限与明文流量配置
Android端申请网络权限是第一步。在AndroidManifest.xml里添加INTERNET权限。另一个容易被忽略的配置是:Android 9(API 28)开始默认禁止明文流量,而Socket通信的IP和端口是明文传输的,需要在application节点配置android:usesCleartextTraffic="true",否则连接会被系统拦截。
<uses-permission android:name="android.permission.INTERNET" /> <application android:usesCleartextTraffic="true" ... />usesCleartextTraffic这个属性只影响明文传输,不影响HTTPS。在实际项目中,INTERNET权限负责允许App创建Socket连接,usesCleartextTraffic负责允许连接使用非加密的TCP通道。两个都缺一不可,只加权限不加明文流量配置,在Android 9以上机型会报Cleartext HTTP traffic not permitted异常。
3.2 连接与消息发送:子线程里创建Socket,主线程只做UI刷新
Android的主线程不能执行网络操作,创建Socket、发送数据、接收数据都必须放在子线程。最简单的做法是new Thread一个工作线程,在线程里完成连接和收发。串口和上位机通信里常见「循环数据采集和UI刷新卡顿」的问题,在这个项目里对应的解法是:Socket收发全部放在子线程,UI刷新通过Handler或runOnUiThread切回主线程。
// Android端Socket连接与发送,工作线程中执行 public class TcpClient implements Runnable { private Socket socket; private DataOutputStream outputStream; private String serverIp; private int serverPort; @Override public void run() { try { // 连接是需要耗时的操作,必须放在子线程 socket = new Socket(); // 设置连接超时3秒,避免IP不可达时长时间阻塞 socket.connect(new InetSocketAddress(serverIp, serverPort), 3000); socket.setSoTimeout(5000); // 读超时5秒,防止收不到数据时永久阻塞 outputStream = new DataOutputStream(socket.getOutputStream()); // 发送一条类型为1的文本消息 byte[] payload = "hello from android".getBytes("UTF-8"); byte[] frame = encodeFrame((short) 1, payload); outputStream.write(frame); outputStream.flush(); } catch (IOException e) { // 连接失败或发送失败,这里记录日志并触发重连 } } }new Socket()然后connect分开写,是为了能设置连接超时时间。直接用new Socket(ip, port)的方式无法设置超时,路由不可达时可能卡几分钟。setSoTimeout(5000)设置的是socket读取超时,C#端如果一直不发数据,Android端read会在5秒后抛出SocketTimeoutException,配合心跳机制可以判断连接是否还活着。
3.3 接收线程与消息解帧:边接收、边累积、边解析
发送只需要write一帧数据,接收麻烦得多。TCP是字节流,一次read可能只读到半帧,也可能一次读到几帧。接收线程要维护一个累积缓冲区,每次读到的数据先追加进去,再循环检查缓冲区里是否有完整帧。
// Android端接收线程,处理粘包和半包 private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private void receiveLoop(Socket socket) throws IOException { InputStream inputStream = socket.getInputStream(); byte[] temp = new byte[4096]; int len; while ((len = inputStream.read(temp)) != -1) { buffer.write(temp, 0, len); // 循环解析缓冲区中的完整帧,直到剩余数据不足一个帧头 while (buffer.size() >= 8) { // 2字节魔数 + 4字节长度 + 2字节类型 byte[] data = buffer.toByteArray(); ByteBuffer bb = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); short magic = bb.getShort(); if (magic != (short) 0xAA55) { // 魔数不匹配,丢掉第一个字节重新找边界 buffer.reset(); buffer.write(data, 1, data.length - 1); continue; } int bodyLen = bb.getInt(); if (buffer.size() < 8 + bodyLen) { // 数据还不够一帧,等待下次read再解析 break; } short msgType = bb.getShort(); byte[] body = new byte[bodyLen]; bb.get(body); // 完整的一帧,交给Handler处理 handleFrame(msgType, body); // 从累积缓冲区中移除已消费的帧 byte[] remain = new byte[buffer.size() - (8 + bodyLen)]; System.arraycopy(data, 8 + bodyLen, remain, 0, remain.length); buffer.reset(); buffer.write(remain); } } }这个接收循环是Socket网络编程里结构最典型的「累积缓冲区拆帧」写法。temp数组是一次read的原始数据,buffer是跨多次read的累积数据。外层while判断buffer.size() >= 8,是因为帧头固定8字节(2+4+2),不足8字节不可能构成完整帧。内层while每次成功消费一帧后,把剩余字节搬回缓冲区头。如果魔数不匹配,说明数据流错位,丢掉第一个字节逐字节对齐——这种情况一般不会发生,但加上可以让代码在异常情况下自愈。
3.4 心跳与断线重连:解决「手机锁屏后连接就断了」的问题
局域网内Android与PC的Socket连接,最常见的断线原因是Wi-Fi休眠和NAT超时。Android锁屏后,Wi-Fi可能进入省电模式,TCP连接长时间无数据会被路由器或PC的TCP栈判定为死链。解决办法是心跳机制:客户端每隔10秒发送一个心跳帧(消息类型定义为0),服务端收到后不回复。如果服务端超过30秒没收到任何数据帧,判定连接失效并关闭socket。
// 心跳线程,每10秒发送一次,连接断开时触发重连 public void startHeartBeat() { new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { if (socket != null && socket.isConnected()) { byte[] frame = encodeFrame((short) 0, new byte[0]); outputStream.write(frame); outputStream.flush(); } else { reconnect(); } Thread.sleep(10000); // Android的Handler.postDelayed也可以实现定时任务 } catch (Exception e) { reconnect(); // 发送失败说明连接已断开,停止心跳线程由重连线程接管 break; } } }).start(); }心跳间隔10秒是一个经验值。间隔太短会增加Wi-Fi功耗和局域网流量,间隔太长则无法及时发现死链。Thread.sleep(10000)里的数值可以调整:如果C#端服务端有30秒无数据超时逻辑,Android端心跳间隔必须小于30秒,否则服务端会先于客户端感知到超时。reconnect()方法里要处理socket关闭和重新connect的完整流程,重连失败时用指数退避(1秒、2秒、4秒...)避免无限快速重连刷爆日志。
4. C#端Socket实现:TcpListener监听、多客户端接入与粘包处理
第4章写C#端。对应的应用场景是C#上位机:PC上打开程序,启动监听,等待Android手机接入,接收手机上报的数据,也能主动下发指令。C#的System.Net.Sockets命名空间下,TcpListener封装了服务端监听的能力,真正的工作量在「多客户端并发接入」和「UI不卡顿」这两件事上。
4.1 TcpListener启动监听与Accept客户端
C#服务端的启动逻辑很直接:指定监听IP和端口,启动TcpListener,进入Accept循环。监听IP用IPAddress.Any表示监听本机所有网卡的端口,这样手机通过PC的有线IP或Wi-Fi IP都能连上。
// C#端启动TcpListener监听 TcpListener listener = new TcpListener(IPAddress.Any, 9000); listener.Start(); // 每接受一个客户端,启动一个线程处理 while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); // 每个客户端连接单独开一个Task,避免一个客户端异常影响其他连接 _ = Task.Run(() => HandleClient(client)); }IPAddress.Any监听0.0.0.0,意味着PC的所有网络接口都会监听9000端口。如果PC同时连着Wi-Fi和有线网,手机会通过其中一条网络路径到达PC,IPAddress.Any能保证不管手机走哪个IP都能连上。AcceptTcpClientAsync是异步接受连接,不会阻塞UI线程。实际项目中常见问题是:直接listener.AcceptTcpClient()阻塞在UI线程上,程序启动后窗口就卡住了。用async/await配合Task.Run是C#端最稳妥的多客户端并发模型。
4.2 客户端会话管理:ConcurrentDictionary维护在线设备列表
多个Android手机同时连接时,服务端需要维护每个连接的客户端会话。我一般用ConcurrentDictionary以客户端ID或IP为key保存TcpClient对象,方便后续单独给某个设备下发数据。
// 客户端连接管理,ConcurrentDictionary线程安全 private static readonly ConcurrentDictionary<string, TcpClient> _clients = new(); private static async Task HandleClient(TcpClient client) { // 用连接的远程端点作为客户端标识 string clientId = client.Client.RemoteEndPoint.ToString(); _clients[clientId] = client; // 每个客户端持有一个NetworkStream using NetworkStream stream = client.GetStream(); byte[] buffer = new byte[4096]; int readCount; try { // 持续读取,客户端断开时Read返回0 while ((readCount = await stream.ReadAsync(buffer)) > 0) { // 这里将原始数据交给帧解析器处理,见4.4节 await ProcessReceivedDataAsync(clientId, buffer, readCount); } } catch (Exception ex) { // 客户端强制断开、网络异常都会走到这里 Console.WriteLine($"客户端 {clientId} 断开: {ex.Message}"); } finally { _clients.TryRemove(clientId, out _); client.Close(); } }RemoteEndPoint.ToString()返回格式是192.168.1.5:56789,其中冒号后的端口是客户端临时端口,每次连接可能不同,所以用IP+端口联合做标识比单独用IP更可靠。ReadAsync返回0意味着对端正常关闭了连接,C#端应该回收资源和清理字典。_clients字典在多个Task中并发读写,用ConcurrentDictionary是为了避免普通Dictionary在并发写入时抛出异常。
4.3 UI刷新不卡顿:Control.BeginInvoke跨线程更新界面
C#上位机里「循环数据采集和UI刷新卡顿」是高频搜索问题,根因是接收线程直接操作UI控件。WinForm的控件只能由创建它的线程(UI线程)更新,其他线程直接修改textBox.Text会抛InvalidOperationException。正确做法是使用Control.BeginInvoke把UI更新操作封送到UI线程执行。
// 使用BeginInvoke将UI更新封送到UI线程 private void UpdateStatus(string message) { // textBoxStatus是窗体上的控件,BeginInvoke是异步的,不会阻塞接收线程 if (textBoxStatus.InvokeRequired) { textBoxStatus.BeginInvoke(new Action(() => { textBoxStatus.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n"); })); } else { textBoxStatus.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n"); } }InvokeRequired判断当前线程是否是UI线程,如果是接收子线程就返回true。BeginInvoke是异步封送,调用后立即返回,接收线程不会被UI操作阻塞。不用Invoke而用BeginInvoke的原因在于:Invoke会等待UI线程执行完毕,如果UI线程正在处理其他重操作,接收线程会被拖慢,导致后续数据堆积在缓冲区里。
4.4 C#端的粘包半包解析:MemoryStream滑动窗口拆帧
C#端的帧解析逻辑与Android端对齐,但C#的流式操作和字节处理语法与Java不同。核心用MemoryStream累积历史数据,循环检查帧头和数据长度,产出完整帧后消费掉。
// C#端帧解析器,处理TCP粘包和半包 private void PacketParser(byte[] data, int length) { // _buffer是类的私有MemoryStream,跨多次Read累积数据 _buffer.Write(data, 0, length); while (true) { // 帧头不足8字节,等待下一次数据到达 if (_buffer.Length < 8) { break; } _buffer.Position = 0; byte[] header = new byte[8]; _buffer.Read(header, 0, 8); // 前2字节是魔数,4-6字节是负载长度,按小端序解析 ushort magic = BitConverter.ToUInt16(header, 0); if (magic != 0xAA55) { // 魔数不匹配,丢弃一个字节 byte[] remain = new byte[_buffer.Length - 1]; _buffer.Position = 1; _buffer.Read(remain, 0, remain.Length); _buffer.Dispose(); _buffer = new MemoryStream(); _buffer.Write(remain, 0, remain.Length); continue; } int bodyLength = BitConverter.ToInt32(header, 2); // 数据不够一帧,等待更多数据 if (_buffer.Length < 8 + bodyLength) { break; } // 读取完整的一帧 byte[] body = new byte[bodyLength]; _buffer.Position = 8; _buffer.Read(body, 0, bodyLength); // 处理消息类型:0=心跳,1=文本,2=指令 ushort msgType = BitConverter.ToUInt16(header, 6); ProcessFrame(msgType, body); // 消费掉当前帧,保留剩余字节 byte[] left = new byte[_buffer.Length - (8 + bodyLength)]; _buffer.Position = 8 + bodyLength; _buffer.Read(left, 0, left.Length); _buffer.Dispose(); _buffer = new MemoryStream(); if (left.Length > 0) { _buffer.Write(left, 0, left.Length); } } }BitConverter.ToUInt16和BitConverter.ToInt32默认按C#所在平台的字节序解析,Windows x86/x64平台是小端序,所以Android端使用ByteOrder.LITTLE_ENDIAN对齐。这段解析代码的关键细节是:每次重新new一个MemoryStream替代清空和重建,为了规避Position移动带来的索引复杂度。_buffer.Position = 1再读取全部剩余字节,实现「丢弃一个字节」的滑动窗口效果。
5. 联调、排错与验证:跑通后如何确认通信链路是健康的
第5章是整篇文章的收口部分,把「源码能跑但不明白为什么跑通」的情况拆开,落到三个具体问题上:防火墙、端口占用、中文乱码。最后给一个抓包验证的习惯,养成后能减少大量盲调时间。
5.1 三个必查项:同网段、防火墙、端口监听状态
Android与PC的Socket通信,出现「连不上」时按顺序排查。第一步确认两端在同一局域网且网段一致——手机连的Wi-Fi和PC连的路由器如果是不同网段,IP路由不通,TCP连接必然失败。第二步检查PC防火墙,TcpListener正常监听后外部设备仍无法连接,多半是Windows防火墙拦截了入站连接。第三步查看监听端口是否真的在听,cmd里执行netstat -ano | findstr 9000,看到LISTENING状态说明监听成功,同时能看到监听进程的PID。
netstat -ano | findstr 9000输出示例:TCP 0.0.0.0:9000 0.0.0.0:0 LISTENING 12345,PID为12345的进程正在监听9000端口。如果这里没有输出,先确认C#端有没有抛SocketException,端口被占用时TcpListener.Start会直接异常退出。防火墙放行操作是:控制面板 → Windows Defender防火墙 → 高级设置 → 入站规则 → 新建规则 → 端口 → 填9000 → 允许连接。手机连不上时把PC端临时关掉防火墙验证,确认是防火墙问题后再按端口放行,不要长期关闭防火墙。
5.2 bind: only one usage of each socket address 错误的完整解读
这个报错常见于C#端服务端重复启动,或者Android端重复创建Socket绑定到同一个IP端口。TcpListener或Socket在Bind一个端口时,如果该端口已被占用,Windows会抛出上述异常。Linux下对应的是Address already in use。解决办法是找到占用进程并结束,或者改用其他端口。
netstat -ano | findstr 9000 taskkill /f /pid 12345taskkill /f /pid 12345强制结束PID为12345的进程。这里值得说明的是:为什么程序退出后端口还占着?因为C#进程被强制关闭时不一定执行了listener.Stop(),socket没有正常关闭,TCP连接进入TIME_WAIT状态,端口在短时间内不可复用。调试阶段的惯用做法是在服务端代码里设置listener.ExclusiveAddressUse = false并调用listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true),允许端口复用,避免频繁改端口调试。
5.3 中文乱码的根因与修复:两端必须显式指定同一字符集
Android端发送字符串给C#端,接收后显示乱码,通常是因为两端编码不一致。Java默认的字符串编码是UTF-16,但getBytes()不带参数时使用平台默认编码(Android上是UTF-8)。C#的Encoding.Default在不同系统配置下可能返回GB2312或UTF-8。如果Android端用UTF-8编码发送,C#端用GB2312解析,中文就会变成乱码。
// C#端读取帧内容时,显式指定UTF-8编码 string message = Encoding.UTF8.GetString(body);// Android端发送时,显式指定UTF-8编码 byte[] payload = message.getBytes(StandardCharsets.UTF_8);两端统一使用UTF-8是唯一建议的做法。Encoding.UTF8.GetString(body)和getBytes(StandardCharsets.UTF_8)都显式指定了字符集,不依赖运行环境的默认编码。实际项目中还有一种规范做法:在帧协议的负载字段里增加一个「编码类型」字节,默认值0表示UTF-8,扩展时再增加GBK等选项,这样即使未来接入其他语言编写的设备也不会乱码。
5.4 用Wireshark验证通信链路的两个关键过滤条件
代码能跑通只是第一步,验证「数据真的按约定帧格式在网络上传输」需要抓包查看。Wireshark按ip.addr == 192.168.1.100 && tcp.port == 9000过滤,能看到Android端与PC之间的TCP握手和数据传输过程。最关键的是追踪TCP流:右键任意一条TCP数据包 → 追踪流 → TCP流,Wireshark会把两端交互的原始字节显示出来,能直观看到0xAA55魔数和正文内容。
抓包验证能发现代码层面看不到的问题:Android端的write和flush是否真的把数据推到了网络栈,C#端的ReadAsync在一次读取中拿到了多少字节,两端协商的帧格式是否严格一致。另一个高价值验证是心跳包是否按预期周期发送——在Wireshark里能看到每隔10秒出现一条长度极短的TCP包,那就是Android端的心跳帧。如果没有心跳包,说明心跳线程可能在断线重连过程中被终止。
本文还有配套的精品资源,点击获取