← 返回首页

ARTICLE

服务器 vs 客户端:为什么 .NET 9 & CS 12 新特性不能用于 Unity

很多开发者问:为什么服务器代码可以使用 C# 12 的新特性(如主构造器、可空引用类型),但 Unity 客户端就不能用?这不只是版本号的问题,背后涉及运行时环境、编译方式、生态成熟度的本质差异。 第一部分:三个关键的 C# 12 新特性 1. 主构造器(Primary Constructor) C

很多开发者问:为什么服务器代码可以使用 C# 12 的新特性(如主构造器、可空引用类型),但 Unity 客户端就不能用?这不只是版本号的问题,背后涉及运行时环境、编译方式、生态成熟度的本质差异。

第一部分:三个关键的 C# 12 新特性

1. 主构造器(Primary Constructor)

C# 12 中的新写法:

// 服务器端:可以直接用主构造器
public class UserService(IDatabase database, ILogger logger)
{
    public async Task<User> GetUser(string userId)
    {
        return await database.LoadUser(userId);
    }
}

以前的写法(仍然有效):

public class UserService
{
    private IDatabase _database;
    private ILogger _logger;

    public UserService(IDatabase database, ILogger logger)
    {
        _database = database;
        _logger = logger;
    }

    public async Task<User> GetUser(string userId)
    {
        return await _database.LoadUser(userId);
    }
}

好处:

  • 代码更简洁,减少重复的构造函数样板代码
  • 自动为参数创建私有字段,开发者无需手动声明
  • 依赖注入变得更直观

2. 可空引用类型(Nullable Reference Types)

C# 12 中启用可空性分析:

#nullable enable

public class UserProfile
{
    public string Name { get; set; }              // 不能为 null
    public string? Description { get; set; }     // 可以为 null
    public string Email { get; set; }            // 不能为 null

    public void UpdateProfile(string? newName)
    {
        if (newName != null)
        {
            Name = newName;  // ✅ 编译器认为这是安全的
        }
    }

    // ❌ 编译错误:Parameter 不能为 null,但方法可能接收 null
    public void SetEmail(string email)
    {
        Email = email;
    }
}

好处:

  • 编译时检测空引用风险,而不是运行时抛出异常
  • 减少 NullReferenceException
  • 代码的可靠性大幅提升

3. Record 类型(记录类型)

C# 12 中的 Record:

// 简洁的不可变数据载体
public record UserData(string UserId, string Name, int Level);

// 使用
var user1 = new UserData("user_001", "Alice", 10);
var user2 = new UserData("user_001", "Alice", 10);

Console.WriteLine(user1 == user2);  // ✅ true(值比较,不是引用比较)

等价的传统写法:

public class UserData : IEquatable<UserData>
{
    public string UserId { get; }
    public string Name { get; }
    public int Level { get; }

    public UserData(string userId, string name, int level)
    {
        UserId = userId;
        Name = name;
        Level = level;
    }

    public override bool Equals(object? obj)
    {
        return Equals(obj as UserData);
    }

    public bool Equals(UserData? other)
    {
        return other != null &&
               UserId == other.UserId &&
               Name == other.Name &&
               Level == other.Level;
    }

    public override int GetHashCode()
    {
        return HashCode.Combine(UserId, Name, Level);
    }
}

好处:

  • 用 5 行代码替代 30+ 行的样板代码
  • 自动实现值相等性比较
  • 完全不可变,线程安全
  • 特别适合传输数据和 API 响应

第二部分:为什么 Unity 不能用这些特性

问题根源:运行时环境的差异

.NET 服务器

C# 12 代码
    ↓
编译成 IL(中间语言)
    ↓
.NET 9 运行时(JIT 或 AOT)
    ↓
本机代码执行
    ↓
✅ 所有新特性都支持

Unity 客户端

C# 代码
    ↓
编译成 IL
    ↓
IL2CPP 转换(Unity 自己的工具)
    ↓
转换成 C++ 代码
    ↓
编译成本机代码
    ↓
❌ 某些 C# 新特性可能不支持

核心问题

1. 主构造器不支持

Unity 的 IL2CPP 编译器对主构造器的支持不完整。主构造器涉及隐式字段生成,在 IL2CPP 的转换过程中可能出现问题。

// ❌ Unity 中可能不工作
public class GameController(IGameLogic logic)
{
    // ...
}

// ✅ 需要写成传统方式
public class GameController
{
    private IGameLogic _logic;

    public GameController(IGameLogic logic)
    {
        _logic = logic;
    }
}

2. 可空引用类型有限制

虽然 C# 本身支持可空引用类型,但 Unity 的许多内置类型(如 Transform、GameObject)没有正确标注可空性,导致可空性分析不完整。

// ❌ Unity 内置类不支持可空性
public Transform? target;  // GameObject 可能是 null
// 但 Unity 很多方法不会告诉你返回值可能是 null

// ✅ 实际上你还是要手动检查
if (target != null)
{
    target.position = Vector3.zero;
}

3. Record 类型支持不稳定

Record 类型在 IL2CPP 转换中存在已知问题,特别是涉及序列化时。

// ❌ Record 在 IL2CPP 中可能序列化失败
public record PlayerData(string Name, int Level);

// ✅ 需要用传统类
public class PlayerData
{
    public string Name { get; set; }
    public int Level { get; set; }
}

第三部分:两个环境的对比

1. 成熟度和支持

方面 .NET 9 服务器 Unity 客户端
C# 版本 C# 12(完全支持) C# 7.3 - 9(部分支持)
主构造器 ✅ 完全支持 ❌ 不支持
可空引用类型 ✅ 完全支持 ⚠️ 部分支持
Record ✅ 完全支持 ⚠️ 不稳定
文件域范围 ✅ 支持 ❌ 不支持
必需成员 ✅ 支持 ❌ 不支持

2. 编译流程对比

服务器:直接 → 高效

.NET 源代码 → C# 编译器 → IL → .NET 运行时 → 机器码
              (标准流程,成熟可靠)

Unity:多步转换 → 更多风险

Unity 脚本 → C# 编译器 → IL → IL2CPP → C++ → 机器码
                              (额外转换层,可能出问题)

3. 依赖管理的差异

服务器:灵活的依赖注入

// .NET 内置依赖注入容器
services.AddScoped<UserService>();
services.AddSingleton<IDatabase>(new DatabaseService());

// 可以使用最新特性简化代码
public class UserService(IDatabase db, ILogger logger)
{
    // ...
}

Unity:有限的依赖注入

// Unity 没有内置的依赖注入
// 需要手动管理或用第三方库

public class UserService
{
    private IDatabase _db;
    private ILogger _logger;

    public UserService(IDatabase db, ILogger logger)
    {
        _db = db;
        _logger = logger;
    }
    // 还是要写样板代码
}

第四部分:实战对比

场景:设计一个数据传输对象(DTO)

服务器端(.NET 9)的做法:

// 使用 Record,代码简洁
public record ApiResponse<T>(
    int Code,
    string Message,
    T? Data
);

public record UserDto(
    string UserId,
    string Name,
    int Level
);

// 在 API 中使用
[HttpGet("users/{id}")]
public async Task<ApiResponse<UserDto>> GetUser(string id)
{
    var user = await _service.GetUser(id);
    return new ApiResponse<UserDto>(
        Code: 0,
        Message: "Success",
        Data: new UserDto(user.Id, user.Name, user.Level)
    );
}

Unity 客户端的做法:

// 必须用传统的 class + [System.Serializable]
[System.Serializable]
public class ApiResponse<T>
{
    public int Code;
    public string Message;
    public T Data;
}

[System.Serializable]
public class UserDto
{
    public string UserId;
    public string Name;
    public int Level;
}

// 在客户端中使用
public class GameManager : MonoBehaviour
{
    public async void LoadUser(string id)
    {
        var json = await _httpClient.GetStringAsync($"/users/{id}");
        var response = JsonUtility.FromJson<ApiResponse<UserDto>>(json);

        if (response.Code == 0 && response.Data != null)
        {
            DisplayUser(response.Data);
        }
    }
}

对比:

  • 服务器:5 行,自动值相等,完全不可变,序列化自动处理
  • 客户端:18 行,手动相等,可变对象,需要配置序列化属性

场景:依赖注入的简化

服务器端(.NET 9):

// 主构造器自动生成私有字段
public class UserProcessor(
    IDatabase database,
    ICache cache,
    ILogger logger,
    IEventBus eventBus)
{
    public async Task ProcessUser(string userId)
    {
        var user = await database.LoadUser(userId);
        // 直接使用,编译器知道这些字段存在
    }
}

Unity 客户端:

// 必须手动声明
public class UserProcessor : MonoBehaviour
{
    private IDatabase _database;
    private ICache _cache;
    private ILogger _logger;
    private IEventBus _eventBus;

    public UserProcessor(
        IDatabase database,
        ICache cache,
        ILogger logger,
        IEventBus eventBus)
    {
        _database = database;
        _cache = cache;
        _logger = logger;
        _eventBus = eventBus;
    }

    public async Task ProcessUser(string userId)
    {
        var user = await _database.LoadUser(userId);
        // ...
    }
}

第五部分:为什么要理解这个差异

对开发的影响

1. 代码风格要统一

如果项目同时有服务器和客户端代码,不能让它们差异太大。即使服务器用了 C# 12 新特性,和客户端通信的部分也要用兼容的写法。

// ❌ 不要在 DTO 中使用 Record
public record ApiResponse<T>(int Code, T? Data);

// ✅ 用传统方式,保证兼容
public class ApiResponse<T>
{
    public int Code { get; set; }
    public T Data { get; set; }
}

2. 团队沟通时别搞混

当说"这个功能用新特性可以简化"时,要明确说:

"在服务器端,我们可以用 Record 简化 DTO"
(不能默认客户端也能用)

"在客户端,我们还是要用传统 class 和序列化属性"
(即使费时间)

3. 迁移策略

如果要升级 Unity 版本,了解这些限制很重要:

Unity 2020 LTS
  ↓ 升级
Unity 2022 LTS(C# 9 支持更好)
  ↓ 升级
Unity 6.x(可能更好地支持 C# 12)
  ↓
最终目标:达到与服务器一致的开发体验

总结

核心要点

  1. C# 12 的新特性很强大

    • 主构造器简化依赖注入
    • 可空引用类型减少空指针异常
    • Record 类型简化数据传输对象
  2. 但 Unity 不能完全使用

    • IL2CPP 编译器支持不完整
    • 某些特性会导致序列化问题
    • Unity 内置库没有可空性标注
  3. 这不是 Unity 的"落后"

    • 而是技术选型的权衡
    • IL2CPP 在性能上有优势(特别是移动平台)
    • 稳定性和兼容性更重要
  4. 实战建议

    • 服务器:大胆使用 C# 12 新特性,简化代码
    • 客户端:用兼容的写法,保证稳定
    • 共享部分:用两个环境都支持的特性

理解这个差异,既能让你在服务器端写出现代、简洁的代码,也能避免在客户端踩坑。这是一个合格的全栈游戏开发者必须知道的知识。