跳到正文
0:00 / 0:00
已成形

C# 语言细节与性能优化技巧

5 分钟

AI 摘要

梳理 C# 业务开发中易错的语言细节与性能陷阱,涵盖值类型相等性、位运算规范、闭包循环、字符串拼接及 GC 优化等核心实践。

日常写业务代码,真正让人栽跟头的往往不是架构,而是语言细节。这篇把值类型、相等性、位运算和 GC 相关的经验集中整理。

一、类型与运算符

  • Single 就是 System.Single 的别名,本质是 float(32 位浮点)。写 Single 和 float 完全等价。
  • & 与 && 不同:& 是算术 / 位运算(两边都会求值),&& 是逻辑运算(短路)。同理 | 与 || 也是这个区别。判断条件用 && / ||,操作位用 & / |。

二、常见的语法与语义陷阱

1. 迭代器不能有 ref / in / out 参数

// 编译错误
IEnumerator PlayerAnimation(out bool animDone) { ... }

// 改为返回值或回调传达结果

2. as 看的是运行时真实类型

如果当前对象的运行时类型是基类 A、并不是子类 B,那么 as B 只会得到 null。as 只在「实际类型兼容」时才成功。

3. i++ 与 ++i

int i = 0;
func(i++);   // 传入 0(先取值,后自增)
func(++i);   // 传入 1(先自增,后取值)
func(i + 1); // 显式表达同样意图

4. 字符长度与字节数不是一回事

一个汉字(全角)占 2 字节,一个英文字母(半角)占 1 字节;但在代码层面,无论 char 还是 string.Length,一个汉字都只算 1 个字符。做字节级限制(如网络协议)和字符级限制(如 UI 字数)时别搞混。

5. for 循环里的闭包

for (int i = 0; i < siftToggleList.Length; i++)
{
    int index = i;   // 关键:为每次迭代捕获独立副本
    siftToggleList[i].onValueChanged.AddListener((isOn) =>
    {
        if (isOn && _curToggleIndex != index)
        {
            _curToggleIndex = index;
            _siftToggleClickEvent?.Invoke(index);
        }
    });
}

如果不写 int index = i;,回调里捕获的是同一个 i,触发时的索引会全部变成循环结束后的值(表现为索引错位 / 越界)。循环内注册委托时,一定先拷贝循环变量。

三、相等性与比较:自定义类型必做

1. 结构体要实现 IEquatable<T>

默认的结构体相等是逐字段比较,自定义相等可以避免装箱、提升性能:

struct RefreshShopItemInfo : IEquatable<RefreshShopItemInfo>
{
    public ShopType shopType;
    public int shopItemId;
    public Action UpdateComplete;

    public bool Equals(RefreshShopItemInfo other)
        => shopType == other.shopType && shopItemId == other.shopItemId;

    public override bool Equals(object obj)
        => obj is RefreshShopItemInfo other && Equals(other);

    public override int GetHashCode()
        => ((int)shopType * 397) ^ shopItemId;
}

注意:UpdateComplete 这种委托字段不参与相等比较(否则永远不相等)。

2. 排序要实现 IComparable<T>

public class UIFlowTask : IComparable<UIFlowTask>
{
    private static int _sequenceSeed;

    public string TaskId { get; }
    public UIFlowPriority Priority { get; }
    public int Sequence { get; }

    public UIFlowTask(string taskId, UIFlowPriority priority)
    {
        TaskId = taskId;
        Priority = priority;
        Sequence = _sequenceSeed++;
    }

    public int CompareTo(UIFlowTask other)
    {
        if (other == null) return -1;

        int priorityCompare = other.Priority.CompareTo(Priority);
        if (priorityCompare != 0) return priorityCompare;

        return Sequence.CompareTo(other.Sequence);
    }
}

要点:优先级高的排在前面(用 other.Priority.CompareTo(Priority) 取反序),同优先级用自增的 Sequence 保证稳定顺序(先进先出)。

3. List.Sort 的比较语义

List.Sort 的比较函数返回大于 0 就交换(升序时 a.CompareTo(b) 为正表示 a 应排在 b 后面)。

四、位运算与位标记

性能排序:位运算 > 加减 > 乘法 > 除法。

关于「位宽」的两个澄清:

  • 一个 byte 永远是 8 位。即使写 byte flag = 0b11,内部也是 0000 0011。
  • 在 C# 中,byte flag = 000; 里的 000 是十进制整数 0,不是 3 位二进制,也不是八进制(C# 不支持用前导 0 表示八进制)。所以 000、0、00 都是同一个值 0。二进制字面量要写 0b000。

适合用位标记的场景:

  1. 判断某个 bit 是否为 1
  2. 设置 / 清除某一位
  3. 组合标志(Mask Flags)
  4. 权限系统、状态管理

这些场景位运算远优于加减法 —— 因为加减法根本不适合精确控制某一位。

const int FLAG_A = 1 << 0;
const int FLAG_B = 1 << 1;

flags |= FLAG_B;                    // 置位
flags &= ~FLAG_A;                   // 清位
bool hasB = (flags & FLAG_B) != 0;  // 判位

五、字符串拼接与 GC

字符串拼接频繁的场景(如时间显示),可以用一个全局静态的 StringBuilder 来减少 GC。

但要考虑线程安全:C# 中 static 变量默认线程共享。线程 A 正在写 "12:34",线程 B 进来 Clear() 后开始写 "00:01",线程 A 拿到的结果就会乱码甚至崩溃。

解法是加 [ThreadStatic] —— 它告诉编译器这个静态变量在每个线程中各有一份独立拷贝:

[ThreadStatic] private static StringBuilder _stringBuilder;

线程隔离后无需加锁(每个线程只用自己的那份),性能极高。

六、序列化:Newtonsoft.Json 的常用特性

JsonProperty(重命名 / 映射字段)、JsonIgnore(忽略字段)、自定义 JsonConverter(自定义转换)等,是处理「协议字段与本地模型不一致」时的常用手段,按需选用,避免手写解析。

七、分页查询

public List<ZoneInfo> GetZoneListByPage(int page, int pageSize)
{
    int startIndex = (page - 1) * pageSize;
    return _zoneInfos.Skip(startIndex).Take(pageSize).ToList();
}

八、基于权重的随机

用 UnityEngine.Random 实现加权随机的标准写法:

int totalWeight = 1000;
var currentValue = 0;
var randomValue = Random.Range(0, totalWeight);

for (int i = 0; i < randomList.Count; i++)
{
    currentValue += randomList[i].weight;
    if (randomValue < currentValue)
        return randomList[i].id;
}

关键是保证 totalWeight 等于(或大于等于)所有项权重之和,且权重分布与业务一致。

小结

  • 值类型相等 / 排序要显式实现 IEquatable / IComparable,并保证 GetHashCode 一致。
  • 循环里注册闭包必须拷贝循环变量;i++ 与 ++i 语义不同。
  • 位标记优于加减法;0b 才是二进制字面量。
  • 高频拼接用 StringBuilder,跨线程记得 [ThreadStatic]。

这是一条笔记,不是成稿文章。它可能不完整、可能有错,也可能过段时间就被我推翻。 想看成体系的文章,去 Blog。