做Unity客户端开发的时候,总会碰到一些“想调个接口但又不太想搞重型服务器”的尴尬时刻。比如要给游戏加一个随时可变的后台开关、想把客户端日志发回自己的工具台看一眼、想做版本热更前的资源检查,甚至想用一个网页给Unity应用发一条控制指令。真为了这点事去搭一个完整后端,从云服务器到数据库到域名备案,性价比实在太低。
我当时用了一个很朴素的方案:直接在C#里手写一个极简HTTP服务器,然后在Unity端用自带的网络请求组件去通信。跑通以后发现这套思路不仅轻量,而且几乎不依赖第三方库,在局域网联调、或是单机工具链场景里非常好使。这篇文章就把这个方案从选型、实现到联调踩坑完整拆开,给同样需要“给自己的Unity项目找一台轻量服务器”的朋友做参考。
1. 方案选型:为什么非要自己写HTTP服务器
1.1 在Unity开发里,什么时候会用到自建HTTP服务器
先明确一个使用场景,免得有人觉得我在小题大做。Unity项目里需要一台“随身服务器”的情况其实比想象中常见:
一是配置热更新。游戏里的活动开关、掉落概率、界面文案,不想每次都发一个客户端版本,那就把配置丢到服务器上,客户端启动时拉一次。这里的服务器不一定非要部署在公网,本地开发机上跑一个就够。
二是调试工具链。Unity跑起来以后,开发者想从外部观察它的内部状态——当前帧率、内存占用、某个节点的Transform数据、AI状态机的切换记录。如果Unity侧自己启动了一个HTTP服务器,那么开发者的浏览器就是一个现成的监控面板,输入地址就能看到状态,这比每次连Profiler要轻便得多。
三是跨端通信。比如手机上的Unity应用要跟电脑上的工具软件交换数据,两头都是自己写的程序,没必要引入MQTT或者WebSocket这类重型框架,HTTP这种一来一回的请求响应模式足够应付大部分情况。
在这些场景里,服务器要满足几个共性需求:足够轻量、能嵌入现有C#工程、跨平台、不需要安装额外运行时。自己手写HTTP服务器反而是最合适的选择。
1.2 为什么不用现成的Nginx、Kestrel或者ASP.NET Core
现成的Web服务器大把,Nginx性能极强,Kestrel是微软官方的高性能服务器,ASP.NET Core更是成熟得不能再成熟。但放到Unity开发场景里,它们各有各的别扭。
Nginx需要单独部署,它跟C#代码的交互要靠外部进程或协议转发,没法直接嵌入Unity工程。Kestrel和ASP.NET Core倒是C#生态,但它们面向的是复杂的Web应用框架,引入一个庞大的依赖体系来跑几个简单接口,在Unity的工程环境里会引入很多兼容性风险。Unity用的是Mono和IL2CPP运行时,对.NET类库的支持是裁剪过的,直接上ASP.NET Core很可能跑不起来。
更重要的一点是学习价值。手写一个HTTP服务器,能让你真正理解HTTP协议长什么样:请求报文怎么解析、响应状态码怎么填、Content-Length有多重要、连接是如何被复用的。这些底层的理解在实际联调中非常有用,哪怕是以后用现成的框架,排查问题的思路也清晰得多。
用生活化的说法:需要送一个快递,没必要为此买一架物流飞机。工具越小、越贴合场景,越好用。
1.3 核心架构:监听、解析、路由、响应的完整链路
这个轻量HTTP服务器的整体架构可以拆成四大块,它们也对应着服务器启动后的完整处理链路:
监听层负责在指定端口上接收客户端的TCP连接。C#里最底层的方式是TcpListener,它绑定一个端口后就等着客户端来连。这一步相当于快递站在门口安排了一个收件员。
解析层负责把一个“连接字节流”转成结构化数据。HTTP请求本质上是一段字节流,请求行、请求头、请求体之间用固定符号分隔。解析层要干的活就是从原始Buffer里把这些信息抠出来。
路由层负责根据请求的方法和路径,决定把这个请求交给哪个业务处理函数。比如收到GET /api/health,就返回一个“服务存活”的JSON;收到POST /api/config,就调用更新配置的逻辑。
响应层负责把业务处理的结果拼成HTTP响应报文,写回给客户端。这里最关键的点是必须写对Content-Length,否则客户端就会一直等下去。
明晰了这个链路,实现代码就只是按部就班,把每一层写清楚。
2. 服务器端核心实现:从监听端口到路由分发
2.1 监听模块:用TcpListener绑定端口
监听模块是整个服务器的心脏。我用的是System.Net.Sockets下面的TcpListener,它允许指定IP地址和端口,然后开始监听。这里有一个非常关键的决策点:绑定地址到底是127.0.0.1还是0.0.0.0。
127.0.0.1是回环地址,也就是说只有本机程序能访问这个服务器。如果你的Unity编辑器和服务器跑在同一台电脑上,用127.0.0.1当然可以。但如果Unity跑在手机上,手机要通过局域网去访问这台电脑上的服务器,就必须监听0.0.0.0——这个地址表示“本机所有网卡上的所有IP都可访问”,包括回环和局域网IP。
我实际测试的时候经常犯这个错:服务器在电脑上启动,Unity编辑器里访问127.0.0.1一切正常,打包到手机就死活连不上。最后排查下来,问题根本不在防火墙,而是服务器只监听了回环地址。这个坑一踩一个准,所以代码里直接用IPAddress.Any,也就是0.0.0.0。
using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Collections.Generic; public class SimpleHttpServer { private TcpListener _listener; private Dictionary<string, Func<HttpRequest, string>> _routes; private bool _isRunning; public SimpleHttpServer(int port = 8080) { _listener = new TcpListener(IPAddress.Any, port); _routes = new Dictionary<string, Func<HttpRequest, string>>(); } public void RegisterRoute(string path, Func<HttpRequest, string> handler) { _routes[path] = handler; } public void Start() { _listener.Start(); _isRunning = true; Console.WriteLine($"[HttpServer] listening on port {((IPEndPoint)_listener.LocalEndpoint).Port}"); // 单独开一个线程跑接受连接的主循环 Thread acceptThread = new Thread(AcceptLoop); acceptThread.IsBackground = true; acceptThread.Start(); } private void AcceptLoop() { while (_isRunning) { TcpClient client = _listener.AcceptTcpClient(); // 每个连接放线程池处理,不阻塞后续连接 ThreadPool.QueueUserWorkItem(HandleClient, client); } } }这里的ThreadPool.QueueUserWorkItem是个值得留意的设计。如果不把它丢到线程池,那么AcceptLoop在等待一个客户端处理完之前,不会去接收下一个客户端连接,这会导致明显的“第一个请求处理完、第二个请求才被受理”的排队现象。实际体验就是Unity那边发请求,偶尔会突然卡一下。线程池能保证每个连接都有独立的处理线程,互不阻塞。
2.2 请求解析:从字节流里抠出方法、路径、请求头和请求体
TCP是一个流式协议,它不保证一次发送的数据能被完整接收到。更麻烦的是,一次Read可能只读到半个请求,也可能一次读到了两个请求。所以解析模块必须先从缓冲区里把“一个完整的HTTP请求”裁出来。
HTTP请求报文长这样:
POST /api/login HTTP/1.1 Host: 127.0.0.1:8080 Content-Type: application/json Content-Length: 23 {"username":"admin"}第一行是请求行,包含方法、路径、HTTP版本三个字段,以空格分隔。接着是若干请求头,每行一个,格式是“键: 值”。请求头和请求体中间有一个空行,空行下面是请求体,请求体的长度以Content-Length请求头的值为准。
所以解析的完整流程是:先把接收到的字节积累到一个大的Buffer里,然后用一个循环去判断Buffer中是否已经存在一个完整报文。判断方法是找到“\r\n\r\n”这个分割符,然后读Content-Length的值,当Buffer长度大于等于“头长度+体长度”时,说明一个完整请求已经到齐。
public class HttpRequest { public string Method; public string Path; public string Version; public Dictionary<string, string> Headers = new Dictionary<string, string>(); public byte[] BodyBytes; public string Body => Encoding.UTF8.GetString(BodyBytes ?? new byte[0]); public static HttpRequest Parse(byte[] buffer, int count) { // 把字节转成字符串,只取头部分 string raw = Encoding.UTF8.GetString(buffer, 0, count); int splitIndex = raw.IndexOf("\r\n\r\n", StringComparison.Ordinal); if (splitIndex < 0) throw new Exception("不完整的HTTP请求,缺少头部分隔符"); string headPart = raw.Substring(0, splitIndex); int bodyLength = buffer.Length - (splitIndex + 4); // 解析请求行 string[] headLines = headPart.Split("\r\n", StringSplitOptions.None); string[] requestLine = headLines[0].Split(' '); HttpRequest req = new HttpRequest { Method = requestLine[0], Path = requestLine[1], Version = requestLine[2] }; // 解析请求头 for (int i = 1; i < headLines.Length; i++) { string line = headLines[i]; int colonIndex = line.IndexOf(':'); if (colonIndex <= 0) continue; string key = line.Substring(0, colonIndex).Trim(); string value = line.Substring(colonIndex + 1).Trim(); req.Headers[key] = value; } // 拷贝请求体 if (bodyLength > 0) { req.BodyBytes = new byte[bodyLength]; Array.Copy(buffer, splitIndex + 4, req.BodyBytes, 0, bodyLength); } return req; } }这里分享一个经验:解析的时候不要用String.Split来粗暴切割,因为请求体可能是二进制数据,转成字符串再前后裁剪会丢字节或者损坏内容。如果是纯文本JSON请求体问题不大,但一旦涉及图片上传或者压缩报文,就必须老老实实按字节数组来拷贝。我的做法是先将Buffer整体转成字符串只为了解析头部,但请求体永远用字节数组定位和拷贝,这样最稳。
2.3 路由分发与响应构造:写好状态行和响应头
完整解析出请求之后,就该交给路由分发逻辑了。路由表我用了Dictionary<string, Func<HttpRequest, string>>,键是请求路径,值是一个处理函数。这里func返回的是响应体字符串,为了让例子简单,我直接统一返回字符串,实际项目中你可以返回自定义对象再序列化为JSON。
值得一提的细节是:路由匹配不应该只匹配Path,最好把HTTP方法也作为匹配的一部分。比如GET /api/user和POST /api/user是两种操作,如果只靠路径分发,POST就会错误地落到GET的处理函数上。我的处理方法是把“方法+路径”拼成路由键,比如"GET /api/user"。
private void HandleClient(object state) { TcpClient client = state as TcpClient; using (NetworkStream stream = client.GetStream()) { try { byte[] buffer = new byte[65536]; // 这里简化了读取逻辑,实际要做拼接缓冲直到解析到完整报文 int readCount = stream.Read(buffer, 0, buffer.Length); HttpRequest request = HttpRequest.Parse(buffer, readCount); string responseBody = ""; string routeKey = request.Method + " " + request.Path; if (_routes.TryGetValue(routeKey, out var handler)) { responseBody = handler(request); } else { responseBody = "{\"error\":\"not found\"}"; // 返回404 WriteResponse(stream, 404, responseBody); return; } WriteResponse(stream, 200, responseBody); } catch (Exception ex) { Console.WriteLine($"[HttpServer] error: {ex.Message}"); } } client.Close(); } private void WriteResponse(NetworkStream stream, int statusCode, string body) { byte[] bodyBytes = Encoding.UTF8.GetBytes(body); string statusText = statusCode == 200 ? "OK" : "Not Found"; StringBuilder sb = new StringBuilder(); sb.Append($"HTTP/1.1 {statusCode} {statusText}\r\n"); sb.Append("Content-Type: application/json; charset=utf-8\r\n"); sb.Append($"Content-Length: {bodyBytes.Length}\r\n"); sb.Append("Connection: close\r\n"); sb.Append("\r\n"); byte[] headBytes = Encoding.UTF8.GetBytes(sb.ToString()); stream.Write(headBytes, 0, headBytes.Length); stream.Write(bodyBytes, 0, bodyBytes.Length); stream.Flush(); }响应报文有一个不能马虎的地方:Content-Length必须精确等于响应体的字节数。很多人用字符串拼接时直接写String.Length,但这个Length是字符数,不是字节数。一旦响应体里面有中文,UTF-8编码下中文字符是3字节,String.Length和字节数就会不一致,客户端收到含中文的JSON就可能出现无法解析的情况。这也是为什么我在WriteResponse里先把body转成byte[],再用数组的Length来填响应头。
2.4 完整可运行的服务器主体
把上面几块拼起来,就是一个能用的HTTP服务器了。为了演示方便,我在主函数里注册了两个接口,一个健康检查,一个返回模拟的配置数据。
public class Program { public static void Main(string[] args) { SimpleHttpServer server = new SimpleHttpServer(8080); server.RegisterRoute("GET /api/health", (req) => { return "{\"status\":\"ok\"}"; }); server.RegisterRoute("GET /api/config", (req) => { return "{\"enableAds\":false,\"serverTime\":1680000000}"; }); server.RegisterRoute("POST /api/echo", (req) => { return "{\"echo\":" + req.Body + "}"; }); server.Start(); Console.WriteLine("server started, press enter to exit."); Console.ReadLine(); } }跑起来之后,用浏览器或者命令行工具(比如curl)访问http://localhost:8080/api/health,就能看到服务器返回的JSON。这个最小可用的闭环一旦建立,后面想加什么接口都是在路由表里加一个函数的事,扩展成本极低。
提示:如果服务器进程跑在电脑A上,Unity客户端在电脑B或手机上,客户端访问的地址不能是localhost,必须是电脑A的局域网IP,比如192.168.1.100:8080,前提是两台设备在同一局域网内。先用浏览器验证服务器本身没问题,再去调Unity端,这个顺序能少走很多弯路。
3. Unity端通信调用:用UnityWebRequest发请求并处理回调
3.1 Unity侧发起HTTP请求的两种方式对比
Unity端有一个自带的网络组件UnityWebRequest,还有一个偏底层一点的HttpWebRequest(.NET自带的类)。实际项目中我更推荐UnityWebRequest,原因有几个。
UnityWebRequest是Unity官方为游戏场景封装的请求组件,它对Unity的协程体系有天然支持,可以直接yield return来等待请求完成,代码写起来像同步逻辑但其实没有阻塞。而且它内部已经处理了跨平台差异,比如Android和iOS上的底层实现各自不同,但上层API统一,开发者不用关心这些差异。
HttpWebRequest在Unity的Mono环境下也能用,但有两个痛点:如果在主线程同步调用它会卡住主线程,界面直接白屏;如果放到后台线程调用,等回调回来再切回主线程操作Unity对象,流程又绕又容易出错。
对比结论很明确:在Unity脚本中直接使用UnityWebRequest,配合协程,是做这种轻量HTTP调用的最佳姿势。
3.2 协程异步处理:收到响应后安全更新游戏对象
Unity对主线程有严格的限制:除了主线程,任何其他线程都不能调用UnityEngine下的API。所以如果我们用协程发请求,yield return等待期间,其实Unity主线程已经返回去渲染下一帧了,等请求完成再恢复以下代码,这部分逻辑依然在主线程上执行,天然安全。
下面这个例子演示了Unity里向自建服务器发GET请求,并把返回的JSON字符串打印出来:
using UnityEngine; using UnityEngine.Networking; using System.Collections; public class HttpTestClient : MonoBehaviour { void Start() { StartCoroutine(GetConfigFromServer()); } IEnumerator GetConfigFromServer() { string url = "http://127.0.0.1:8080/api/config"; using (UnityWebRequest request = UnityWebRequest.Get(url)) { request.timeout = 5; yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { string json = request.downloadHandler.text; Debug.Log("收到配置: " + json); // 在这里解析JSON,然后更新游戏里的物体状态 // 注意这里已经回到主线程了,可以安全操作Transform等组件 } else { Debug.LogError("请求失败: " + request.error); } } } }代码里有几个细节要额外说明。
request.timeout = 5是一个容易被人忽略但其实很重要的设置。UnityWebRequest默认超时时间是0,也就是永不超时。如果你的自建服务器挂了,或者IP地址写错了,客户端会卡在等待界面,没有任何提示。显式设置一个5秒或10秒的超时,能让问题尽快暴露出来,不至于让玩家盯着加载圈怀疑人生。
using关键字也是必须的。UnityWebRequest实现了IDisposable接口,请求用完以后需要释放底层的非托管资源。写using能保证即使请求失败,资源也能被正确回收。我见过有人为了省事不写using,长时间运行后Unity内存持续上涨,排查半天最后才发现是请求对象没有释放。
3.3 从纯工具视角看:数据序列化与反序列化的最佳实践
把HTTP服务器返回的JSON字符串转成游戏里的数据对象,如果用手动字符串拼接解析,既容易出错又难维护。一个小而实用的做法是配合Unity官方的JsonUtility来反序列化。
假设服务器返回的数据是:
{"enableAds": false, "serverTime": 1680000000}在Unity里定义对应的数据类,然后一行代码转成对象:
[System.Serializable] public class ServerConfig { public bool enableAds; public long serverTime; } // 反序列化 ServerConfig config = JsonUtility.FromJson<ServerConfig>(json); Debug.Log("是否显示广告: " + config.enableAds);需要注意的是,JsonUtility要求目标类型声明[System.Serializable]特性,如果字段名和JSON里的键名不一致,可以加[SerializeField]配合字段重命名的方式来映射。这个工具类的适用范围是“字段简单、结构固定”的配置同步场景,如果服务器返回的是那种键名带连字符或者包含数组嵌套很深的复杂结构,JsonUtility会有点力不从心,那时再考虑引入成熟的JSON解析库也不迟。
发送POST请求时,请求体需要用UTF-8的字节数组。UnityWebRequest.Post的第一个参数是URL,第二个参数是表单数据。如果想发送纯JSON字符串,不能直接用那个重载,要手动构造方式,设置请求头的Content-Type为application/json:
IEnumerator SendJson() { string json = "{\"playerName\":\"test\",\"score\":99}"; byte[] jsonBytes = System.Text.Encoding.UTF8.GetBytes(json); UnityWebRequest request = new UnityWebRequest("http://127.0.0.1:8080/api/score", "POST"); request.uploadHandler = new UploadHandlerRaw(jsonBytes); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader("Content-Type", "application/json"); yield return request.SendWebRequest(); if (request.result == UnityWebRequest.Result.Success) { Debug.Log("上报成功"); } }UploadHandlerRaw和DownloadHandlerBuffer的组合是一个略微繁琐但很标准的写法。UploadHandlerRaw表示上传原始字节,DownloadHandlerBuffer表示把响应保存在内存缓冲里。这个组合相当于给UnityWebRequest说明了“我要上传二进制内容、下载二进制内容”,非常通用。
4. 联调过程中踩过的坑
4.1 手机连不上电脑上的服务器:监听地址和防火墙排查
这个坑实际遇到太多次了。Unity编辑器在电脑上访问localhost一切正常,但打包到手机后访问电脑的局域网IP,始终请求失败。问题的根源往往是以下两条链路中的某一环节出了岔子。
第一条是服务器监听地址。如果TcpListener绑定的地址是127.0.0.1,那只有本机能访问,手机通过局域网IP是连不上的。解决办法就是前面代码里的写法:用IPAddress.Any监听“所有网卡”,或者明确绑定电脑的局域网IP。
第二条是系统防火墙。Windows防火墙默认会拦截外部的入站连接,尤其是一些开发工具的监听服务,弹窗提示是否允许访问的时候一旦手滑点了取消,整个局域网都连不进来。排查时可以直接在Windows的防火墙面板里检查有没有对应端口和程序的入站规则,或者临时关闭防火墙做测试以确认是不是这个原因。
我推荐的标准排查顺序是:先在目标设备上用浏览器访问服务器地址,如果能打开看到JSON说明网络通、服务器通,问题在Unity代码;如果浏览器都打不开,说明问题在网络层,要去检查监听地址和防火墙。把问题做二分定位,效率高很多。
4.2 请求收到一半就解析失败:TCP分包粘包的应对
TCP协议底层不做消息边界切分,它只保证字节流按序到达。HTTP服务器在读取字节流时,可能遇到一次读到了半个请求体,也可能一次读到了两个请求体。如果按“一次Read就是一个完整请求”的假设来写,服务器就会偶发性地解析失败,而且这种失败还很难复现。
解决方案是在服务器内部维护一个累积缓冲区,每次从Socket读到数据就追加到缓冲区,然后尝试从缓冲区头部解析一个完整的HTTP报文;若能解析,就截取报文长度并移除已消费部分,继续处理下一个报文;若不能解析,就继续等下一次Read。
这个“读Buffer、寻边界、消费数据”的循环是手写TCP服务器的基本功。如果觉得每连接一个线程再加缓存太复杂,一个临时措施是把服务器设计成“每次只读一次请求并处理一次”的方式,然后把响应的Connection头设为close,强制客户端在一个请求后断开连接,这样就把粘包发生的概率降得很低,适合学习Demo但不适合真实使用。
4.3 Android平台默认禁止明文HTTP流量
Android 9(API 28)及更高版本默认禁止应用使用明文HTTP流量,也就是说直接访问http://开头的地址会被系统拦截,只有https地址才被允许。Unity打包的Android应用也会受到这个策略的影响。
解决办法有两个。要么给Android工程配置网络安全策略,允许特定域名或所有域名的明文流量;要么服务器这边直接支持HTTPS。在自建轻量服务器场景下,让服务器支持HTTPS需要配置证书,成本有点高,大多数人是选择允许明文流量,毕竟这个服务器的使用范围基本限定在局域网开发调试。
在Unity里处理这个限制,需要修改AndroidManifest.xml,给application节点加上usesCleartextTraffic属性:
<application android:usesCleartextTraffic="true" ...>如果Unity工程里没有自定义的AndroidManifest,需要在Assets/Plugins/Android目录下创建,或者在Player Settings里勾选允许HTTP相关的选项。不同Unity版本的设置位置略有差异,但思路一致。
4.4 WebGL平台的特殊限制
除了Android,还有一个平台也要特别注意:WebGL。Unity导出WebGL之后,跑在浏览器里,而浏览器有严格的同源策略和网络安全限制。浏览器里的JavaScript代码不能随意向非同一来源的服务发起HTTP请求,除非服务器响应头里带了合适的CORS头。
这意味着如果你把刚才写的Unity项目导出成WebGL,然后访问一个局域网里的自建HTTP服务器,请求基本都会被浏览器拦截。解决方案是在服务器的响应报文里加上CORS相关的响应头:
Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type三个头分别表示允许任意来源访问、允许GET和POST方法、允许请求头里携带Content-Type。加上之后,浏览器预检和实际请求都能被放行。
另外WebGL运行在浏览器环境下,UnityWebRequest在WebGL上的底层实现是走浏览器的XMLHttpRequest或Fetch接口,所以服务器收到的请求可能来自浏览器的预检OPTIONS请求。如果服务器端只注册了GET和POST的路由,接到来路不明的OPTIONS请求时会返回404,导致正式请求无法发出。我在做WebGL版本联调时,通常会在路由分发前先拦截OPTIONS请求,直接返回200空响应体,省去很多麻烦。
提示:WebGL下UnityWebRequest没有真正的多线程IO,超时和请求行为也会受浏览器机制影响。如果要做WebGL版本的HTTP服务器联调,先在桌面版(编辑器或PC包)把功能调通,再来处理WebGL的特殊行为,会轻松很多。
4.5 快速自测技巧:不写UI也能验证通信链路
联调时经常遇到一个问题:Unity客户端里写了请求UI,但请求失败时只能看到日志,看不到网络层面的细节。一个很好用的自测办法:在电脑浏览器里直接访问服务器地址,用浏览器的开发者工具观察请求和响应。
比如你在Unity里点了一个“下载配置”按钮,同步在浏览器里访问同一个/api/config地址,如果浏览器能正常拿到JSON,而Unity端请求报错,那问题大概率在Unity脚本本身,比如URL拼错、协程没启动、权限配置缺失。反之如果浏览器也拿不到,问题就在服务器端。
对于POST请求的自测,可以用另一个工具:命令行里执行curl命令,或者用Postman/Apifox这类接口工具。不过一旦依赖外部工具,就失去了“极简”的初衷。我的经验是尽量保持服务器可以浏览器直接GET访问,这样测试成本最低。哪怕业务逻辑是需要POST数据的,服务器端的处理函数里也可以预留一个带默认参数的兼容路径,方便手动验证请求链路是否通畅。
5. 最后再分享一个小技巧
关于这个自建HTTP服务器,我在一次给某项目做存档自动备份功能的时候,有了一个很实用的扩展思路:不只是让Unity“调用”服务器,还可以让服务器“主动”给Unity发指令。方案是在HTTP服务器里加一个长轮询接口,Unity客户端每隔几秒访问一次,服务器如果检测到有新的待处理任务,就返回任务数据,否则返回空JSON。这一段实现了“服务器推送”的效果,虽然不实时,但配合几秒的轮询间隔,足够满足日常工具的需求。
另外一个小提醒:用TcpListener手写Socket解析HTTP,虽然代码可控性很高,但HttpListener类在Windows和Unity中对该类的支持情况不一致,实际项目里可能会在IL2CPP转换期间遇到兼容性问题。所以如果你不想自己解析HTTP报文,但又不想用Socket层,可以考虑用HttpListener做备选方案,不过需要提前核实它在目标平台的兼容性情况。
这个方案的完整链路,从C#的TcpListener到Unity的UnityWebRequest,其实只用了两个平台的“自带”能力,没有任何第三方依赖。只要理解了TCP流式协议、HTTP报文格式、协程异步这三个核心概念,搭建这套通信系统其实一次就能跑通。后续想扩展加密认证、JSON协议结构、多接口管理,底子已经在这里了。