☰
纯C#打通Unity客户端、服务端与热更新:一套方案省掉多少胶水代码
2026/10/22 14:24:42 网站建设 项目流程

简介:这是一套基于Unity引擎、采用纯C#实现的游戏开发整合方案,覆盖客户端、服务端与热更新三大模块,面向计算机相关专业的毕业设计、课程设计、大作业及学科竞赛参赛者,也适合希望系统练手Unity全栈开发的学习者。压缩包共约2000个文件,整体19.97MB,其中1634个cs源码文件构成核心逻辑,另有meta、asset、prefab、unity等Unity工程资源,csproj、sln、xml、config等工程与配置文件,以及dll、so、dylib、bytes等运行依赖,目录结构完整,便于按模块检索与复现。资源内代码均经过测试运行,功能正常,可参照实现复刻出相同项目,设计报告亦可借鉴。已有46人学习关注。对于具备一定C#与Unity基础的读者,可在此基础上扩展新功能,或将其作为项目立项、工程实训的参考模板,遇到使用问题也可与作者沟通获取解答。

1. 纯 C# 打通 Unity 客户端、服务端与热更新:一套方案到底能省掉多少胶水代码

很多 Unity 项目做到中期都会遇到同一个尴尬:客户端用 C# 写得挺顺,一到服务端就换成另一套语言和工具链,热更新再引入第三种脚本方案,结果三套代码三种写法,一个协议字段改一次要动三个仓库。这套「基于 Unity 的纯 C# 整合方案」想解决的就是这件事——客户端逻辑、服务端逻辑、热更新补丁全部用 C# 写,共享同一套数据结构和协议定义。它适合已经有一定 Unity 基础、正在被多语言协作拖慢节奏的团队,也适合想从零搭一套可维护网络游戏骨架的个人开发者。核心价值不在于某个库多强,而在于把「一份代码三处复用」这条链路真正跑通。

2. 为什么值得把服务端也交给 C#:选型逻辑与工程边界

2.1 三端同语言的真实收益与代价

先说收益。客户端和服务端共用 C#,最直接的好处是数据结构只定义一次。比如一个角色属性结构体,客户端用来渲染血条,服务端用来做伤害结算,热更新脚本用来做活动加成,三处引用同一个类,字段改名时编译器会直接报错,而不是等到联调时才发现服务端少解析了一个字段。这种编译期检查带来的安全感,是跨语言方案很难给的。

第二个收益是序列化统一。纯 C# 方案里,客户端和服务端可以用同一套序列化逻辑,协议结构体不需要为每种语言各写一份解析代码。常见做法是定义一个带明确字段顺序的结构,两端用同样的读写顺序处理字节流,减少因为字节序或字段对齐导致的玄学问题。

代价也要说清楚。C# 服务端在极端高并发场景下,内存占用和 GC 压力通常比专门优化的方案更明显。如果你的项目是万人同屏的实时战斗,纯 C# 服务端需要额外做对象池、减少装箱、控制大对象分配,否则 GC 卡顿会直接反映到玩家延迟上。所以这套方案更适合房间制、回合制、中小规模实时同步的游戏类型,而不是无脑套在所有项目上。

第三个代价是热更新边界。纯 C# 热更新通常依赖 IL 层面的方案,能热更的是逻辑代码,不能热更的是引擎底层和已经编译进包体的原生部分。把热更新当成「什么都能改」是最大的误解,实际能改的范围要在项目初期就划清楚。

2.2 目录结构与程序集划分

要让三端共享代码,第一步不是写逻辑,而是把程序集边界划对。我一般会这样分:

ProjectRoot/ Shared/ # 三端共享,纯 C# 类库,不引用 UnityEngine Shared.csproj Protocol/ # 协议结构体 Math/ # 定点数、向量等自定义数学 Client/ # Unity 客户端 Assets/ Scripts/ Runtime/ # 引用 Shared HotUpdate/ # 热更新逻辑,单独程序集 Server/ Server.csproj # 引用 Shared,控制台或服务宿主 Tools/ ProtocolGen/ # 协议代码生成工具

关键点是 Shared 目录绝对不能引用 UnityEngine。一旦引用了,服务端就编译不过,因为服务端没有 Unity 运行时。很多团队翻车就翻在这里:共享库里顺手用了 Vector3,结果服务端一编译就报找不到类型。解决办法是在 Shared 里定义自己的数学结构,客户端在边界处做一次转换。

程序集划分还要考虑热更新。热更新程序集应该只依赖 Shared 和少量稳定的客户端接口,不要直接依赖大量 Unity 组件。否则热更新代码和引擎耦合太深,每次引擎升级都可能让热更脚本失效。

2.3 协议定义与代码生成的最小实现

协议是客户端和服务端的契约。纯 C# 方案里,我倾向于用特性标注加代码生成,而不是手写序列化。下面是一个最小可用的协议定义和生成思路:

// Shared/Protocol/ProtocolAttributes.cs using System; namespace Shared.Protocol { // 标记一个类为协议消息,id 全局唯一 [AttributeUsage(AttributeTargets.Class)] public class MessageAttribute : Attribute { public ushort Id { get; } public MessageAttribute(ushort id) { Id = id; } } // 标记字段的序列化顺序 [AttributeUsage(AttributeTargets.Field)] public class FieldIndexAttribute : Attribute { public int Index { get; } public FieldIndexAttribute(int index) { Index = index; } } }
// Shared/Protocol/Messages.cs using Shared.Protocol; namespace Shared.Protocol.Messages { // 登录请求,id = 1001 [Message(1001)] public class LoginRequest { [FieldIndex(0)] public string Account; [FieldIndex(1)] public string Token; } // 登录响应,id = 1002 [Message(1002)] public class LoginResponse { [FieldIndex(0)] public int Code; [FieldIndex(1)] public long PlayerId; } }

逻辑说明:MessageAttribute 的 Id 用来在网络上区分消息类型,FieldIndex 决定字段写入字节流的顺序。生成工具扫描这些特性,为每个消息生成 Write 和 Read 方法。参数上,Id 用 ushort 是因为 65535 个消息类型对大多数项目足够,且两个字节开销小。FieldIndex 必须从 0 连续,否则生成器要报错,这是血泪经验——曾经有人跳号导致两端读写错位,排查了一整晚。

生成器本身可以用 Roslyn 或简单的反射加模板实现。反射方案在启动时扫描一次,生成委托缓存,避免每次序列化都反射。下面是一个简化的序列化入口:

// Shared/Protocol/Serializer.cs using System; using System.Collections.Generic; using System.IO; namespace Shared.Protocol { public static class Serializer { // 消息 id 到写入委托的映射,启动时构建 private static readonly Dictionary<ushort, Action<BinaryWriter, object>> Writers = new Dictionary<ushort, Action<BinaryWriter, object>>(); public static void Register<T>(ushort id, Action<BinaryWriter, T> writer) { Writers[id] = (w, obj) => writer(w, (T)obj); } public static void Write(BinaryWriter w, ushort id, object msg) { w.Write(id); if (Writers.TryGetValue(id, out var writer)) writer(w, msg); else throw new InvalidOperationException($"未注册的消息 id: {id}"); } } }

参数说明:BinaryWriter 默认小端序,两端一致即可。id 写在消息体前面,接收方先读 id 再分发。注册在启动时完成,运行期只做字典查找和委托调用,避免反射开销。如果消息量很大,可以把字典换成数组,id 直接做下标,查找变成 O(1)。

3. 服务端怎么跑起来:从控制台宿主到网络循环

3.1 选择 TCP 还是可靠 UDP

服务端第一步是选传输层。纯 C# 方案里,TCP 实现简单,但队头阻塞在实时游戏里很致命;可靠 UDP 自己实现重传和排序,复杂度高但延迟表现好。我的建议是:回合制、卡牌、MMO 类用 TCP 足够;实时动作、射击类再考虑可靠 UDP。不要一上来就追求 UDP,先把逻辑跑通,传输层可以后面替换。

TCP 在 C# 里用 Socket 或 TcpListener 都行。下面是一个基于 Socket 的异步接受循环:

// Server/Network/TcpServer.cs using System; using System.Net; using System.Net.Sockets; using System.Threading.Tasks; namespace Server.Network { public class TcpServer { private readonly Socket _listener; private readonly Func<Socket, Task> _onClient; public TcpServer(int port, Func<Socket, Task> onClient) { _onClient = onClient; _listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _listener.Bind(new IPEndPoint(IPAddress.Any, port)); _listener.Listen(128); } public async Task RunAsync() { while (true) { // 接受新连接,交给上层处理 var client = await _listener.AcceptAsync(); // 不 await,让每个连接独立跑 _ = _onClient(client); } } } }

逻辑说明:AcceptAsync 返回新连接的 Socket,交给回调处理。这里用_ =丢弃任务是有意为之,表示不等待单个连接处理完成,否则一个慢连接会阻塞接受循环。参数上,Listen 的 backlog 设 128 是经验值,太小会导致高并发时连接被拒,太大在多数系统上没意义。每个连接的处理里要设置 NoDelay,否则小包会被 Nagle 算法合并,增加延迟。

3.2 连接会话与心跳管理

每个连接需要一个会话对象,保存玩家 id、最后心跳时间、发送队列等。心跳是排查掉线的关键,没有心跳你根本不知道客户端是卡了还是断了。

// Server/Network/Session.cs using System; using System.Net.Sockets; namespace Server.Network { public class Session { public Socket Socket { get; } public long PlayerId { get; set; } public DateTime LastHeartbeat { get; private set; } public bool IsClosed { get; private set; } public Session(Socket socket) { Socket = socket; Socket.NoDelay = true; LastHeartbeat = DateTime.UtcNow; } public void OnHeartbeat() { LastHeartbeat = DateTime.UtcNow; } // 由定时器调用,超时则关闭 public bool CheckTimeout(TimeSpan timeout) { if (IsClosed) return true; if (DateTime.UtcNow - LastHeartbeat > timeout) { Close(); return true; } return false; } public void Close() { if (IsClosed) return; IsClosed = true; try { Socket.Shutdown(SocketShutdown.Both); } catch { } Socket.Close(); } } }

参数说明:心跳超时一般设 10 到 30 秒,太短会误杀网络抖动的玩家,太长则掉线感知慢。LastHeartbeat 用 UTC 时间,避免时区问题。CheckTimeout 由单独的定时器每秒扫一次,不要在收包线程里做超时判断,否则逻辑会纠缠。

3.3 消息分发与主循环

服务端收到字节流后,先读消息 id,再查处理器。处理器注册用字典,和客户端序列化对称。

// Server/Network/MessageDispatcher.cs using System; using System.Collections.Generic; using System.IO; using Shared.Protocol; namespace Server.Network { public class MessageDispatcher { private readonly Dictionary<ushort, Action<Session, BinaryReader>> _handlers = new Dictionary<ushort, Action<Session, BinaryReader>>(); public void Register(ushort id, Action<Session, BinaryReader> handler) { _handlers[id] = handler; } public void Dispatch(Session session, BinaryReader reader) { var id = reader.ReadUInt16(); if (_handlers.TryGetValue(id, out var handler)) handler(session, reader); else Console.WriteLine($"未处理的消息 id: {id}"); } } }

逻辑说明:Dispatch 只负责查表和调用,不包含业务逻辑。业务逻辑放在各自的 Handler 里,方便测试。参数上,id 用 ReadUInt16 和写入端一致。如果消息体解析失败,要在 Handler 里捕获异常并关闭会话,不要让坏数据污染后续解析。

主循环可以用一个简单的定时器驱动,每帧处理网络收包、逻辑更新、心跳检查。纯 C# 服务端不需要 Unity 的 Update,用System.Threading.Timer或自己写一个循环加Thread.Sleep都行。关键是逻辑更新要单线程,避免多线程锁带来的复杂度。

4. 热更新怎么接:程序集加载与版本回退

4.1 热更新程序集的加载时机

热更新在纯 C# 方案里通常是在客户端启动时,从可写目录加载一个编译好的 dll,然后用反射调用入口。这个 dll 不参与打包,可以后续替换。加载时机一般在资源初始化之后、进入登录界面之前。

// Client/Assets/Scripts/HotUpdate/HotUpdateLoader.cs using System; using System.IO; using System.Reflection; using UnityEngine; public static class HotUpdateLoader { public static Assembly Load(string dllPath) { if (!File.Exists(dllPath)) { Debug.LogError($"热更新程序集不存在: {dllPath}"); return null; } // 读取字节后加载,避免文件被占用 var bytes = File.ReadAllBytes(dllPath); var assembly = Assembly.Load(bytes); Debug.Log($"热更新程序集加载成功: {assembly.FullName}"); return assembly; } public static void InvokeEntry(Assembly assembly, string typeName, string methodName) { var type = assembly.GetType(typeName); if (type == null) { Debug.LogError($"找不到入口类型: {typeName}"); return; } var method = type.GetMethod(methodName, BindingFlags.Public | BindingFlags.Static); method?.Invoke(null, null); } }

逻辑说明:用Assembly.Load(byte[])而不是LoadFrom,可以避免 dll 文件被锁定,方便后续替换。参数上,dllPath 指向持久化目录,通常是Application.persistentDataPath下的某个子目录。入口类型和方法的命名要固定,比如HotUpdate.Entry.Start,这样加载器不需要知道具体业务。

4.2 版本比对与回退策略

热更新最怕的是新版本有 bug 导致游戏起不来。所以必须有回退。常见做法是本地保存当前版本号和上一个可用版本,启动时如果新版本加载后初始化失败,就回退到旧版本。

// Client/Assets/Scripts/HotUpdate/VersionManager.cs using System; using System.IO; using UnityEngine; public static class VersionManager { private static string VersionFile => Path.Combine(Application.persistentDataPath, "hotupdate_version.txt"); public static int CurrentVersion { get => File.Exists(VersionFile) ? int.Parse(File.ReadAllText(VersionFile)) : 0; set => File.WriteAllText(VersionFile, value.ToString()); } // 加载指定版本,失败返回 false public static bool TryLoad(int version, out System.Reflection.Assembly assembly) { var path = Path.Combine(Application.persistentDataPath, $"hotupdate_{version}.dll"); assembly = HotUpdateLoader.Load(path); return assembly != null; } }

参数说明:版本号用整数递增,简单可靠。回退逻辑是:先尝试加载新版本,初始化失败则把 CurrentVersion 改回旧值,重新加载旧 dll。注意回退后要清理新版本产生的缓存数据,否则可能状态不一致。这个机制不能省,线上环境没有后悔药。

4.3 热更新与主工程的接口约定

热更新代码不能随便引用主工程的所有类型,否则主工程一改,热更脚本就编译不过。我一般会定义一个稳定的接口层,热更新只依赖这个接口。

// Client/Assets/Scripts/HotUpdate/IHotUpdateBridge.cs public interface IHotUpdateBridge { void Log(string msg); void ShowMessage(string msg); void RequestLogin(string account, string token); object GetConfig(string key); }

主工程实现这个接口,热更新通过接口调用。这样主工程内部重构不影响热更脚本,只要接口不变。参数上,接口方法要尽量少,每多一个方法就多一个耦合点。GetConfig 返回 object 是为了避免热更新依赖具体配置类型,用的时候再转换。

5. 避坑与排查:三端同语言最容易翻车的五个地方

5.1 共享库引用了 UnityEngine 导致服务端编译失败

现象:服务端编译时报找不到 UnityEngine 命名空间。原因:Shared 目录里某个文件用了 Vector3 或 Debug.Log。解决:在 Shared 里定义自己的数学类型和日志接口,客户端在边界处转换。检查方法是给 Shared 项目单独编译一次,不引用任何 Unity 程序集。

5.2 协议字段顺序两端不一致导致解析错位

现象:客户端发的登录请求,服务端解析出来的账号是乱码。原因:一端按字段声明顺序写,另一端按 FieldIndex 读,但 FieldIndex 有跳号。解决:生成器强制校验 FieldIndex 从 0 连续,不连续直接报错。另外协议变更后两端必须同时重新生成,不能只更新一端。

5.3 热更新 dll 加载后旧代码仍在内存

现象:替换 dll 后行为没变。原因:Assembly.Load 加载的程序集无法卸载,旧版本还在。解决:热更新只在启动时加载一次,运行期不替换。如果需要运行期替换,要用可卸载的 AssemblyLoadContext,但复杂度高,多数项目不值得。我的习惯是热更新只在新启动生效,不做运行期热替换。

5.4 心跳超时设置过短导致误杀

现象:玩家在网络波动时频繁掉线。原因:心跳超时设了 5 秒,而移动网络切换基站可能超过 5 秒。解决:超时设 15 到 30 秒,并且客户端在切后台时主动发一次心跳。服务端超时检查用滑动窗口,不要用固定时间点。

5.5 服务端 GC 压力导致延迟尖刺

现象:玩家偶尔感觉操作延迟半秒。原因:服务端每帧分配大量临时对象,GC 触发时暂停。解决:用对象池复用消息对象,序列化时用可复用的 BinaryWriter,避免频繁 new byte[]。监控 GC 频率,如果每秒多次,就要查分配热点。

6. 进阶技巧:用条件编译和程序集隔离把三端代码管住

三端同语言最大的诱惑是到处写#if UNITY_EDITOR,最后代码变成迷宫。我的习惯是尽量不用条件编译,而是用程序集隔离。Shared 里只放纯逻辑,客户端特有的放 Client 程序集,服务端特有的放 Server 程序集。这样编译器帮你检查边界,比人肉记#if可靠得多。

如果确实需要条件编译,比如日志在客户端走 Unity 的 Debug,在服务端走 Console,那就定义一个接口,两端各自实现,而不是在共享代码里写#if。下面是一个日志接口的例子:

// Shared/ILogger.cs namespace Shared { public interface ILogger { void Info(string msg); void Error(string msg); } }
// Client/UnityLogger.cs using Shared; using UnityEngine; public class UnityLogger : ILogger { public void Info(string msg) => Debug.Log(msg); public void Error(string msg) => Debug.LogError(msg); }
// Server/ConsoleLogger.cs using System; using Shared; public class ConsoleLogger : ILogger { public void Info(string msg) => Console.WriteLine($"[INFO] {msg}"); public void Error(string msg) => Console.WriteLine($"[ERROR] {msg}"); }

这样共享代码只依赖 ILogger,两端注入不同实现。参数上,接口方法保持最少,需要分级日志再加。注入可以在启动时完成,服务端在 Main 里赋值,客户端在 GameManager 初始化时赋值。

另一个技巧是协议版本号。客户端和服务端各自维护一个协议版本,连接握手时比对,不一致直接拒绝,避免新旧协议混跑导致诡异 bug。版本号可以放在登录请求的第一个字段,服务端校验后决定是否继续。

最后说一个验证方法:写一个纯命令行的模拟客户端,不启动 Unity,直接引用 Shared 和服务端协议,跑一遍登录、心跳、收发消息。如果能跑通,说明协议和序列化没问题,剩下的才是 Unity 表现层的事。这个模拟客户端我每次搭新项目都会先写,省下大量在编辑器里点来点去的时间。

我踩过最深的坑是早期把热更新代码和主工程放在同一个程序集,结果每次热更都要重新打包整个客户端,热更新的意义完全没了。后来强制把热更新拆成独立程序集,只依赖接口层,才真正做到了只替换一个 dll。这个边界越早划越好,后期再拆成本极高。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询