C# 语言细节与性能优化技巧
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。
适合用位标记的场景:
- 判断某个 bit 是否为 1
- 设置 / 清除某一位
- 组合标志(Mask Flags)
- 权限系统、状态管理
这些场景位运算远优于加减法 —— 因为加减法根本不适合精确控制某一位。
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。