Compare commits
2 Commits
04079285e5
...
0fb27c41f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fb27c41f6 | |||
| d93d6d0e14 |
859
MD/代码优化标准.md
859
MD/代码优化标准.md
@ -1,859 +0,0 @@
|
||||
# 🔧 TH1 代码优化标准
|
||||
|
||||
> **适用范围**: TH1 回合制4X策略游戏全模块
|
||||
> **核心目标**: 减少GC分配 · 加快计算速度 · 保证输入输出不变
|
||||
> **约束条件**: 不改变整体逻辑 · 兼容 MemoryPack 本地/网络序列化 · 保持多人同步正确性
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [第一章 总则与核心原则](#第一章-总则与核心原则)
|
||||
- [第二章 序列化安全规范](#第二章-序列化安全规范)
|
||||
- [第三章 GC优化标准](#第三章-gc优化标准)
|
||||
- [第四章 计算优化标准](#第四章-计算优化标准)
|
||||
- [第五章 模块专项优化指南](#第五章-模块专项优化指南)
|
||||
- [第六章 优化验证清单](#第六章-优化验证清单)
|
||||
- [附录A 已知GC热点清单](#附录a-已知gc热点清单)
|
||||
- [附录B 禁止与允许操作速查表](#附录b-禁止与允许操作速查表)
|
||||
|
||||
---
|
||||
|
||||
## 第一章 总则与核心原则
|
||||
|
||||
### 1.1 优化铁律
|
||||
|
||||
| 编号 | 规则 | 说明 |
|
||||
|------|------|------|
|
||||
| **R-01** | **输入输出不变** | 任何优化不得改变方法的参数签名、返回值语义和副作用。对外行为必须与优化前完全一致 |
|
||||
| **R-02** | **逻辑等价** | 优化只能改变"怎么做",不能改变"做什么"。所有分支路径、计算结果、状态变更必须等价 |
|
||||
| **R-03** | **序列化安全** | 任何新增缓存/查找表字段必须加 `[MemoryPackIgnore]`,不得影响存档文件和网络消息的二进制格式 |
|
||||
| **R-04** | **确定性一致** | 多人联网场景下,优化后的计算必须保持确定性(相同输入产生相同输出),不得引入浮点精度差异 |
|
||||
| **R-05** | **渐进式优化** | 每次只优化一个点,优化后必须验证,确认无误后再优化下一个 |
|
||||
|
||||
### 1.2 优化优先级
|
||||
|
||||
```
|
||||
优先级从高到低:
|
||||
P0 - 消除热路径中的 GC 分配(每帧/每回合执行的代码)
|
||||
P1 - 缓存重复计算结果(空间换时间,需考虑序列化)
|
||||
P2 - 算法复杂度优化(O(n²) → O(n) 或 O(n log n))
|
||||
P3 - 减少非热路径的 GC 分配(初始化/低频调用)
|
||||
P4 - 微优化(方法内联、分支预测等)
|
||||
```
|
||||
|
||||
### 1.3 不优化原则
|
||||
|
||||
以下情况**不应**进行优化:
|
||||
|
||||
- 仅在游戏初始化时执行一次的代码(如 `SkillFactory` 首次反射扫描)
|
||||
- 仅在加载/存档时执行的代码(除非造成明显卡顿)
|
||||
- UI 层的低频更新代码
|
||||
- 已经足够高效且无 GC 问题的代码
|
||||
|
||||
---
|
||||
|
||||
## 第二章 序列化安全规范
|
||||
|
||||
### 2.1 TH1 序列化架构概览
|
||||
|
||||
TH1 存在三种序列化场景,任何"空间换时间"优化必须同时考虑:
|
||||
|
||||
| 场景 | 序列化方式 | 频率 | 敏感度 |
|
||||
|------|-----------|------|--------|
|
||||
| **本地存档** | MemoryPack → `.dat` 文件 | 每回合一次 | 向后兼容(旧存档能读取) |
|
||||
| **网络同步** | MemoryPack → Steam P2P | 每个Action、每2秒心跳 | 双向兼容(多客户端版本) |
|
||||
| **训练数据** | MemoryPack / JSON → `.jsonl` | `#if ENABLE_TRAIN` 时每步一次 | 格式稳定 |
|
||||
|
||||
### 2.2 核心 MemoryPackable 类清单
|
||||
|
||||
以下类直接参与序列化,新增字段时**必须**遵守规范:
|
||||
|
||||
```
|
||||
MapData ─ 游戏世界根节点(包含以下所有子结构)
|
||||
├── MapConfig ─ 对局配置
|
||||
├── GridMapData ─ 所有格子
|
||||
│ └── GridData ─ 单个格子(附带 Skills)
|
||||
├── PlayerMapData─ 所有玩家
|
||||
│ └── PlayerData─ 单个玩家(附带 Skills, DiplomacyData, TechTreeData)
|
||||
├── CityMapData ─ 所有城市
|
||||
│ └── CityData ─ 单个城市(附带 TerritoryData, Skills)
|
||||
├── UnitMapData ─ 所有部队
|
||||
│ └── UnitData ─ 单个部队(附带 Skills, ActionPoint)
|
||||
└── NetData ─ 网络状态(含 Actions 历史, 随机种子)
|
||||
|
||||
CommonActionParams ─ Action参数(网络传输)
|
||||
BaseMessage (16种) ─ 网络消息(含 GameStartMessage 携带完整 MapData)
|
||||
SkillBase (242种子类)─ 技能多态序列化
|
||||
```
|
||||
|
||||
### 2.3 新增缓存字段的标准做法
|
||||
|
||||
#### ✅ 正确做法:`[MemoryPackIgnore]` + 重建逻辑
|
||||
|
||||
```csharp
|
||||
[MemoryPackable]
|
||||
public partial class UnitData : IdentifierBase
|
||||
{
|
||||
// 已有的序列化字段
|
||||
public List<SkillBase> Skills;
|
||||
|
||||
// ===== 新增的缓存字段 =====
|
||||
[MemoryPackIgnore]
|
||||
private Dictionary<SkillType, SkillBase> _skillLookup;
|
||||
|
||||
/// <summary>
|
||||
/// O(1) 技能查找,替代 Skills 列表的 O(n) 线性搜索。
|
||||
/// 缓存在反序列化后自动重建。
|
||||
/// </summary>
|
||||
public SkillBase GetSkillFast(SkillType type)
|
||||
{
|
||||
if (_skillLookup == null) RebuildSkillLookup();
|
||||
return _skillLookup.TryGetValue(type, out var skill) ? skill : null;
|
||||
}
|
||||
|
||||
// 当 Skills 列表发生变更时调用
|
||||
public void InvalidateSkillLookup()
|
||||
{
|
||||
_skillLookup = null; // 延迟重建,下次查找时自动构建
|
||||
}
|
||||
|
||||
private void RebuildSkillLookup()
|
||||
{
|
||||
_skillLookup ??= new Dictionary<SkillType, SkillBase>(Skills.Count);
|
||||
_skillLookup.Clear();
|
||||
foreach (var skill in Skills)
|
||||
_skillLookup[skill.GetSkillType()] = skill;
|
||||
}
|
||||
|
||||
[MemoryPackOnDeserialized]
|
||||
public void OnAfterDeserialize()
|
||||
{
|
||||
_skillLookup = null; // 反序列化后标记为需要重建
|
||||
// 不在此处立即重建,延迟到首次使用
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ❌ 错误做法
|
||||
|
||||
```csharp
|
||||
// 错误1:缓存字段没有加 [MemoryPackIgnore]
|
||||
[MemoryPackable]
|
||||
public partial class UnitData
|
||||
{
|
||||
public Dictionary<SkillType, SkillBase> _skillLookup; // ❌ 会被序列化,增大存档/网络包体积
|
||||
}
|
||||
|
||||
// 错误2:在 [MemoryPackOnDeserialized] 中执行重量级初始化
|
||||
[MemoryPackOnDeserialized]
|
||||
public void OnAfterDeserialize()
|
||||
{
|
||||
RebuildSkillLookup(); // ❌ 网络同步的每次反序列化都会触发
|
||||
RecalcAllTerritoryBonuses(); // ❌ 高频反序列化时造成卡顿
|
||||
}
|
||||
|
||||
// 错误3:修改序列化字段的类型或移除字段
|
||||
public uint LegionId; // ❌ 如果改名或移除,旧存档无法反序列化
|
||||
```
|
||||
|
||||
### 2.4 序列化兼容性检查清单
|
||||
|
||||
每次涉及 `[MemoryPackable]` 类的修改,必须检查:
|
||||
|
||||
- [ ] 新增字段是否加了 `[MemoryPackIgnore]`?
|
||||
- [ ] 是否修改/删除/重排了已有的序列化字段?(**禁止**)
|
||||
- [ ] 缓存重建逻辑是否在 `[MemoryPackOnDeserialized]` 中以**延迟方式**处理?
|
||||
- [ ] 旧存档能否正常加载?(MemoryPack 对新增 Ignore 字段透明)
|
||||
- [ ] 多人游戏中不同客户端版本能否互通?
|
||||
|
||||
### 2.5 网络消息的特殊约束
|
||||
|
||||
对于 `CommonActionParams` 和 `BaseMessage` 子类:
|
||||
|
||||
- **绝不**新增序列化字段(增加每次 Action 的网络开销)
|
||||
- 如需传递额外数据,使用已有字段编码(如 `Value1`-`Value4` 通用数值字段)
|
||||
- 对于高频消息(心跳、Action确认),任何字节增长都会累积
|
||||
|
||||
### 2.6 反序列化重建的性能规范
|
||||
|
||||
```
|
||||
[MemoryPackOnDeserialized] 回调中:
|
||||
├── ✅ 可以做:设置标志位、置空缓存引用
|
||||
├── ✅ 可以做:O(1) 的简单赋值
|
||||
├── ⚠️ 谨慎做:O(n) 的字典/集合重建(仅在必要时)
|
||||
└── ❌ 禁止做:O(n²) 或更复杂的计算、涉及其他对象的查询
|
||||
```
|
||||
|
||||
理由:网络同步时,`ForceUpdateMessage` 和 `GameStartMessage` 会反序列化完整 `MapData`,触发所有子对象的 `[MemoryPackOnDeserialized]` 回调。如果每个回调都做重量级初始化,会造成瞬间卡顿。
|
||||
|
||||
---
|
||||
|
||||
## 第三章 GC优化标准
|
||||
|
||||
### 3.1 集合分配优化
|
||||
|
||||
#### 3.1.1 复用集合替代重复创建
|
||||
|
||||
**问题模式**:在频繁调用的方法中 `new List<T>()` / `new HashSet<T>()` / `new Dictionary<K,V>()`
|
||||
|
||||
```csharp
|
||||
// ❌ 每次调用都分配新集合
|
||||
public List<GridData> GetAroundGridData(GridData center, int radius)
|
||||
{
|
||||
List<GridData> result = new List<GridData>(); // GC 分配
|
||||
// ... 填充 result
|
||||
return result;
|
||||
}
|
||||
|
||||
// ✅ 方案A:调用方传入缓冲区
|
||||
public void GetAroundGridData(GridData center, int radius, List<GridData> buffer)
|
||||
{
|
||||
buffer.Clear();
|
||||
// ... 填充 buffer
|
||||
}
|
||||
|
||||
// ✅ 方案B:类级别复用集合(非线程安全,单线程游戏逻辑可用)
|
||||
private static readonly List<GridData> _sharedGridBuffer = new List<GridData>(64);
|
||||
|
||||
public List<GridData> GetAroundGridData(GridData center, int radius)
|
||||
{
|
||||
_sharedGridBuffer.Clear();
|
||||
// ... 填充 _sharedGridBuffer
|
||||
return _sharedGridBuffer; // 注意:调用方不应持有引用或修改
|
||||
}
|
||||
```
|
||||
|
||||
**适用范围**:
|
||||
|
||||
| 方法 | 当前问题 | 建议方案 |
|
||||
|------|---------|---------|
|
||||
| `GridMapData.GetAroundGridData()` (4个重载) | 每次 new List/HashSet | 传入缓冲区 |
|
||||
| `GridMapData.GetAroundGridIdList()` | 每次 new List\<uint\> | 传入缓冲区 |
|
||||
| `MapData.GetPlayerTerritoryGridIdSet()` | 每次 new HashSet + new List | 玩家级缓存 + 脏标记 |
|
||||
| `MapData.GetUnitDataListByPlayerId()` | null 参数时 new List | 必须传入缓冲区 |
|
||||
| `MapData.GetUnitDataListByCityId()` | null 参数时 new List | 必须传入缓冲区 |
|
||||
| `MapData.GetCityDataListByPlayerId()` | null 参数时 new List | 必须传入缓冲区 |
|
||||
|
||||
#### 3.1.2 集合预分配容量
|
||||
|
||||
```csharp
|
||||
// ❌ 默认容量,后续频繁扩容
|
||||
var list = new List<UnitData>();
|
||||
|
||||
// ✅ 预分配合理容量
|
||||
var list = new List<UnitData>(expectedCount);
|
||||
|
||||
// ✅ 字典也需要预分配
|
||||
var dict = new Dictionary<uint, CityData>(CityList.Count);
|
||||
```
|
||||
|
||||
**预分配容量参考**:
|
||||
|
||||
| 集合用途 | 建议初始容量 |
|
||||
|---------|-------------|
|
||||
| 格子周围列表(radius=1) | 9 |
|
||||
| 格子周围列表(radius=2) | 25 |
|
||||
| 玩家城市列表 | 8 |
|
||||
| 玩家部队列表 | 32 |
|
||||
| 城市领土格子集合 | 64 |
|
||||
| 技能列表 | 8 |
|
||||
|
||||
#### 3.1.3 消除默认参数创建集合
|
||||
|
||||
```csharp
|
||||
// ❌ 可选参数导致隐式分配
|
||||
public void GetUnitDataListByPlayerId(uint pid, List<UnitData> list = null)
|
||||
{
|
||||
if (list == null) list = new List<UnitData>(); // 隐式 GC 分配
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 强制调用方提供缓冲区(消除隐式分配)
|
||||
public void GetUnitDataListByPlayerId(uint pid, List<UnitData> buffer)
|
||||
{
|
||||
buffer.Clear();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 字符串优化
|
||||
|
||||
#### 3.2.1 避免热路径中的字符串操作
|
||||
|
||||
```csharp
|
||||
// ❌ 循环中拼接字符串
|
||||
foreach (var unit in units)
|
||||
{
|
||||
string key = "Unit_" + unit.Id.ToString(); // 每次循环产生 2 次分配
|
||||
Debug.Log($"Processing {key}"); // 插值字符串再分配
|
||||
}
|
||||
|
||||
// ✅ 调试日志使用条件编译
|
||||
#if UNITY_EDITOR
|
||||
foreach (var unit in units)
|
||||
{
|
||||
Debug.Log($"Processing Unit_{unit.Id}");
|
||||
}
|
||||
#endif
|
||||
|
||||
// ✅ 需要运行时字符串时使用 StringBuilder 复用
|
||||
private static readonly StringBuilder _sb = new StringBuilder(256);
|
||||
|
||||
public string GetDebugInfo()
|
||||
{
|
||||
_sb.Clear();
|
||||
_sb.Append("Unit_").Append(unit.Id);
|
||||
return _sb.ToString(); // 仅产生一次分配
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 枚举转字符串缓存
|
||||
|
||||
```csharp
|
||||
// ❌ 每次调用都装箱 + 反射
|
||||
string name = skillType.ToString(); // 装箱 + 内部反射查找
|
||||
|
||||
// ✅ 预建枚举字符串映射表
|
||||
private static readonly Dictionary<SkillType, string> _skillTypeNames;
|
||||
|
||||
static SkillHelper()
|
||||
{
|
||||
var values = (SkillType[])Enum.GetValues(typeof(SkillType));
|
||||
_skillTypeNames = new Dictionary<SkillType, string>(values.Length);
|
||||
foreach (var v in values) _skillTypeNames[v] = v.ToString();
|
||||
}
|
||||
|
||||
public static string GetSkillTypeName(SkillType type) => _skillTypeNames[type];
|
||||
```
|
||||
|
||||
### 3.3 对象创建优化
|
||||
|
||||
#### 3.3.1 技能工厂优化(SkillFactory)
|
||||
|
||||
```csharp
|
||||
// ❌ 当前:每次创建技能都用 Activator.CreateInstance(反射开销大)
|
||||
public static SkillBase GetSkillBySkillType(SkillType type)
|
||||
{
|
||||
return Activator.CreateInstance(_skillDict[type]) as SkillBase; // 反射 + 装箱
|
||||
}
|
||||
|
||||
// ✅ 使用编译时委托替代反射
|
||||
private static Dictionary<SkillType, Func<SkillBase>> _skillCreators;
|
||||
|
||||
static void InitCreators()
|
||||
{
|
||||
_skillCreators = new Dictionary<SkillType, Func<SkillBase>>();
|
||||
foreach (var kv in _skillDict)
|
||||
{
|
||||
var type = kv.Value;
|
||||
// 编译一次,后续调用零反射
|
||||
var constructor = type.GetConstructor(Type.EmptyTypes);
|
||||
var newExp = Expression.New(constructor);
|
||||
var lambda = Expression.Lambda<Func<SkillBase>>(newExp).Compile();
|
||||
_skillCreators[kv.Key] = lambda;
|
||||
}
|
||||
}
|
||||
|
||||
public static SkillBase GetSkillBySkillType(SkillType type)
|
||||
{
|
||||
return _skillCreators[type](); // 无反射,接近 new 的性能
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.2 避免闭包和委托分配
|
||||
|
||||
```csharp
|
||||
// ❌ Lambda 捕获外部变量产生闭包对象
|
||||
int threshold = 5;
|
||||
var filtered = units.Where(u => u.Level > threshold).ToList(); // 闭包 + List 分配
|
||||
|
||||
// ✅ 手动循环,零分配
|
||||
private static readonly List<UnitData> _tempFilteredUnits = new List<UnitData>(32);
|
||||
|
||||
public List<UnitData> GetUnitsAboveLevel(List<UnitData> units, int threshold)
|
||||
{
|
||||
_tempFilteredUnits.Clear();
|
||||
for (int i = 0; i < units.Count; i++)
|
||||
{
|
||||
if (units[i].Level > threshold)
|
||||
_tempFilteredUnits.Add(units[i]);
|
||||
}
|
||||
return _tempFilteredUnits;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 LINQ 禁用规范
|
||||
|
||||
**在热路径中完全禁止使用 LINQ**,包括但不限于:
|
||||
|
||||
| 禁止操作 | 原因 | 替代方案 |
|
||||
|---------|------|---------|
|
||||
| `.Where().ToList()` | 分配迭代器 + 新 List | 手动 for 循环 + 预分配 List |
|
||||
| `.Select().ToArray()` | 分配迭代器 + 新 Array | 手动 for 循环 + 预分配 Array |
|
||||
| `.Any(predicate)` | 分配闭包对象 | 手动 for 循环 + bool 标志 |
|
||||
| `.Count(predicate)` | 分配闭包对象 | 手动 for 循环 + int 计数 |
|
||||
| `.First() / .FirstOrDefault()` | 分配迭代器 | 手动 for 循环 + break |
|
||||
| `.OrderBy()` | 分配排序缓冲区 | 原地排序或插入排序 |
|
||||
| `.ToDictionary()` | 分配新 Dictionary | 预分配 + 手动填充 |
|
||||
| `.Sum() / .Max() / .Min()` | 分配迭代器 | 手动累加 |
|
||||
|
||||
**热路径定义**:每帧、每回合、每个 Action、AI 决策循环中执行的代码。
|
||||
|
||||
**非热路径**(允许使用 LINQ):初始化、UI 事件响应(低频点击)、加载/存档。
|
||||
|
||||
### 3.5 装箱避免
|
||||
|
||||
```csharp
|
||||
// ❌ 值类型传给 object 参数
|
||||
void Log(object value) { } // 调用 Log(42) 会装箱 int
|
||||
|
||||
// ❌ 枚举作为字典键(如果没有实现 IEqualityComparer)
|
||||
Dictionary<SkillType, SkillBase> dict; // SkillType 是 enum,默认比较器会装箱
|
||||
|
||||
// ✅ 为枚举字典提供专用比较器
|
||||
public struct SkillTypeComparer : IEqualityComparer<SkillType>
|
||||
{
|
||||
public bool Equals(SkillType x, SkillType y) => x == y;
|
||||
public int GetHashCode(SkillType obj) => (int)obj;
|
||||
}
|
||||
|
||||
var dict = new Dictionary<SkillType, SkillBase>(new SkillTypeComparer());
|
||||
```
|
||||
|
||||
### 3.6 struct 使用规范
|
||||
|
||||
```csharp
|
||||
// ✅ 短期使用、小数据量、值语义的数据适合 struct
|
||||
public struct AttackResult // 战斗结果传递,无需 GC
|
||||
{
|
||||
public int Damage;
|
||||
public bool IsKill;
|
||||
public bool IsCritical;
|
||||
}
|
||||
|
||||
// ✅ 事件系统已经使用 struct(UIEvents),保持不变
|
||||
public struct ShowUINotifyMoment // 零 GC 事件分发
|
||||
{
|
||||
public MomentType MomentType;
|
||||
public CityData CityData; // 引用类型成员不影响 struct 本身
|
||||
}
|
||||
```
|
||||
|
||||
**struct 使用条件**:
|
||||
- 大小不超过 64 字节
|
||||
- 不需要继承
|
||||
- 创建后不会被修改(或修改成本可接受)
|
||||
- 不会被装箱(不存入 object 或接口引用)
|
||||
|
||||
---
|
||||
|
||||
## 第四章 计算优化标准
|
||||
|
||||
### 4.1 缓存 + 脏标记模式
|
||||
|
||||
适用于结果稳定、查询频繁但修改不频繁的计算。
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// 玩家领土格子集合缓存。
|
||||
/// 仅在领土变更时置脏,查询时按需重建。
|
||||
/// </summary>
|
||||
[MemoryPackable]
|
||||
public partial class PlayerData
|
||||
{
|
||||
// ===== 已有序列化字段(不动)=====
|
||||
|
||||
// ===== 缓存字段 =====
|
||||
[MemoryPackIgnore] private HashSet<uint> _territoryCache;
|
||||
[MemoryPackIgnore] private bool _territoryDirty = true;
|
||||
|
||||
public HashSet<uint> GetTerritoryGridIdSet(MapData map)
|
||||
{
|
||||
if (_territoryDirty || _territoryCache == null)
|
||||
{
|
||||
_territoryCache ??= new HashSet<uint>();
|
||||
_territoryCache.Clear();
|
||||
// 从城市列表中收集领土
|
||||
foreach (var city in map.CityMap.CityList)
|
||||
{
|
||||
if (map.CityToPlayerDict.TryGetValue(city.Id, out var pid) && pid == Id)
|
||||
city.Territory.GetAllTerritoryArea(_territoryCache);
|
||||
}
|
||||
_territoryDirty = false;
|
||||
}
|
||||
return _territoryCache;
|
||||
}
|
||||
|
||||
/// <summary>领土变更时调用(城市升级、征服、丢失城市)</summary>
|
||||
public void InvalidateTerritoryCache() => _territoryDirty = true;
|
||||
}
|
||||
```
|
||||
|
||||
**适用场景**:
|
||||
|
||||
| 缓存对象 | 失效条件 | 查询频率 |
|
||||
|---------|---------|---------|
|
||||
| 玩家领土集合 | 城市升级 / 征服 / 丢失 | 每回合多次(AI评分、视野、建筑升级) |
|
||||
| 玩家部队列表 | 部队创建 / 阵亡 / 城市易主 | 每回合多次(AI决策) |
|
||||
| 技能查找字典 | 技能添加 / 移除 | 战斗计算、回合结算 |
|
||||
| 城市驻军列表 | 部队移动 / 阵亡 | 每次攻击判定 |
|
||||
|
||||
### 4.2 查找表优化
|
||||
|
||||
#### 4.2.1 O(n) 线性搜索 → O(1) 字典查找
|
||||
|
||||
```csharp
|
||||
// ❌ 当前:遍历所有城市查找领土归属
|
||||
public bool GetCityDataByTerritoryGridId(uint gid, out CityData cityData)
|
||||
{
|
||||
foreach (var city in CityList) // O(城市数)
|
||||
{
|
||||
if (city.CheckIsInTerritory(gid)) // O(领土格子数)
|
||||
{
|
||||
cityData = city;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 维护 格子ID → 城市 的反向映射
|
||||
[MemoryPackIgnore]
|
||||
private Dictionary<uint, CityData> _gridToCityLookup;
|
||||
|
||||
public bool GetCityDataByTerritoryGridId_Fast(uint gid, out CityData cityData)
|
||||
{
|
||||
if (_gridToCityLookup == null) RebuildGridToCityLookup();
|
||||
return _gridToCityLookup.TryGetValue(gid, out cityData); // O(1)
|
||||
}
|
||||
|
||||
// 城市领土变更时调用
|
||||
public void InvalidateGridToCityLookup() => _gridToCityLookup = null;
|
||||
```
|
||||
|
||||
#### 4.2.2 MapData 中已有的映射表利用
|
||||
|
||||
MapData 中已维护以下映射关系,优化时**优先使用已有映射**,避免重复建表:
|
||||
|
||||
```
|
||||
CityToPlayerDict 城市ID → 玩家ID
|
||||
UnitToCityDict 部队ID → 城市ID
|
||||
UnitToGridDict 部队ID → 格子ID
|
||||
CityToGridDict 城市ID → 格子ID
|
||||
GridToCityDict 格子ID → 城市ID(中心格子)
|
||||
```
|
||||
|
||||
### 4.3 算法复杂度优化
|
||||
|
||||
#### 4.3.1 BFS/邻居搜索优化
|
||||
|
||||
`GetAroundGridData` 是最高频的空间查询方法,当前实现为三重嵌套循环:
|
||||
|
||||
```csharp
|
||||
// ❌ 当前:triple nested loop + 距离判断冗余
|
||||
for (int r = 0; r <= radius; r++)
|
||||
for (int x = center.X - offset; x <= center.X + offset; x++)
|
||||
for (int y = center.Y - offset; y <= center.Y + offset; y++)
|
||||
if (Mathf.Max(Mathf.Abs(x-cx), Mathf.Abs(y-cy)) != r) continue;
|
||||
|
||||
// ✅ 优化:直接按距离环遍历,消除冗余条件判断
|
||||
// 对于小 radius(1-3),使用预计算偏移表
|
||||
private static readonly Vector2Int[][] _neighborOffsets = PrecomputeOffsets(maxRadius: 5);
|
||||
|
||||
public void GetAroundGridData(GridData center, int radius, List<GridData> buffer)
|
||||
{
|
||||
buffer.Clear();
|
||||
buffer.Add(center); // 中心格子
|
||||
for (int r = 1; r <= radius; r++)
|
||||
{
|
||||
var offsets = _neighborOffsets[r];
|
||||
for (int i = 0; i < offsets.Length; i++)
|
||||
{
|
||||
int x = center.Pos.x + offsets[i].x;
|
||||
int y = center.Pos.y + offsets[i].y;
|
||||
if (GetGridDataByPos(x, y, out var grid))
|
||||
buffer.Add(grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.2 O(n²) 外交计算优化
|
||||
|
||||
```csharp
|
||||
// ❌ 当前:O(玩家数²) 的外交关系计算
|
||||
foreach (var p1 in players)
|
||||
foreach (var p2 in players)
|
||||
foreach (var strategy in p1.GetFeelingStrategies(p2))
|
||||
Calculate(strategy);
|
||||
|
||||
// ✅ 优化:增量计算 + 仅在外交事件触发时更新
|
||||
// 方案:事件驱动的增量更新
|
||||
public void OnDiplomacyEvent(uint player1Id, uint player2Id)
|
||||
{
|
||||
// 只重算涉及的两个玩家的外交关系
|
||||
RecalcFeelingBetween(player1Id, player2Id);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.3 视野计算优化
|
||||
|
||||
```csharp
|
||||
// ❌ 当前:每次移动都完整重算所有部队+城市的视野集合
|
||||
UpdateSight_LogicView(player); // 遍历所有部队、所有城市领土
|
||||
|
||||
// ✅ 增量视野更新
|
||||
public void UpdateSightIncremental(MapData map, PlayerData player,
|
||||
UnitData movedUnit, Vector2Int oldPos, Vector2Int newPos)
|
||||
{
|
||||
// 1. 移除旧位置视野贡献
|
||||
RemoveSightContribution(movedUnit, oldPos);
|
||||
// 2. 添加新位置视野贡献
|
||||
AddSightContribution(movedUnit, newPos);
|
||||
// 比完整重算快 O(部队数 × 视野范围²) → O(视野范围²)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 循环优化
|
||||
|
||||
#### 4.4.1 for 替代 foreach(仅对 List\<T\>)
|
||||
|
||||
```csharp
|
||||
// ✅ List<T> 使用 for 循环(避免 enumerator 分配,虽然 List 的 enumerator 是 struct)
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
var item = list[i];
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ Dictionary 的 foreach 是安全的(struct enumerator)
|
||||
foreach (var kv in dict) { } // OK
|
||||
|
||||
// ✅ HashSet 的 foreach 也是安全的(struct enumerator)
|
||||
foreach (var item in set) { } // OK
|
||||
```
|
||||
|
||||
#### 4.4.2 提前退出优化
|
||||
|
||||
```csharp
|
||||
// ❌ 遍历完整列表仅为找到一个元素
|
||||
foreach (var unit in allUnits)
|
||||
{
|
||||
if (unit.IsAlive() && unit.OwnerId == playerId)
|
||||
{
|
||||
result = unit;
|
||||
// 没有 break,继续遍历...
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 找到即退出
|
||||
foreach (var unit in allUnits)
|
||||
{
|
||||
if (unit.IsAlive() && unit.OwnerId == playerId)
|
||||
{
|
||||
result = unit;
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.4.3 条件外提
|
||||
|
||||
```csharp
|
||||
// ❌ 循环内重复计算不变量
|
||||
for (int i = 0; i < units.Count; i++)
|
||||
{
|
||||
var grid = map.GridMap.GetGridDataByGid(units[i].GridId);
|
||||
var player = map.PlayerMap.GetPlayerById(playerId); // 每次循环都查找同一个 player
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ 循环不变量外提
|
||||
var player = map.PlayerMap.GetPlayerById(playerId);
|
||||
for (int i = 0; i < units.Count; i++)
|
||||
{
|
||||
var grid = map.GridMap.GetGridDataByGid(units[i].GridId);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 数学计算优化
|
||||
|
||||
```csharp
|
||||
// ❌ 使用 Mathf(浮点 + Unity 函数调用开销)
|
||||
int dist = Mathf.Max(Mathf.Abs(x1 - x2), Mathf.Abs(y1 - y2));
|
||||
|
||||
// ✅ 使用纯整数数学
|
||||
int dist = Math.Max(Math.Abs(x1 - x2), Math.Abs(y1 - y2));
|
||||
|
||||
// ❌ 重复计算距离
|
||||
for (int i = 0; i < targets.Count; i++)
|
||||
{
|
||||
float dist = Vector2.Distance(pos, targets[i].Pos); // sqrt 开销
|
||||
if (dist < minDist) { ... }
|
||||
}
|
||||
|
||||
// ✅ 使用距离平方比较(避免 sqrt)
|
||||
for (int i = 0; i < targets.Count; i++)
|
||||
{
|
||||
float distSq = (pos - targets[i].Pos).sqrMagnitude;
|
||||
if (distSq < minDistSq) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第五章 模块专项优化指南
|
||||
|
||||
### 5.1 数据层(TH1_Data)
|
||||
|
||||
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|
||||
|-------|---------|---------|-----------|--------|
|
||||
| `GetPlayerTerritoryGridIdSet` | 每次调用 new HashSet + new List | 玩家级缓存 + 脏标记 | `[MemoryPackIgnore]` 无影响 | P0 |
|
||||
| `GetCityDataByTerritoryGridId` | O(城市数×领土数) 线性搜索,代码中标注 TODO | 维护格子→城市反向映射 | `[MemoryPackIgnore]` 无影响 | P0 |
|
||||
| `GetAroundGridData` (4个重载) | 每次调用 new List/HashSet | 传入缓冲区模式 | 无 | P0 |
|
||||
| `GetAroundGridIdList` | 每次调用 new List | 传入缓冲区模式 | 无 | P0 |
|
||||
| `GetUnitDataListByPlayerId` | null 参数时隐式分配 | 强制传入缓冲区 | 无 | P1 |
|
||||
| `GetCityDataListByPlayerId` | null 参数时隐式分配 | 强制传入缓冲区 | 无 | P1 |
|
||||
| OnAfterMemoryPackDeserialize | 每次反序列化重建全部字典 | 延迟初始化(首次访问时重建) | 无 | P1 |
|
||||
|
||||
### 5.2 技能系统(TH1_Logic/Skill)
|
||||
|
||||
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|
||||
|-------|---------|---------|-----------|--------|
|
||||
| SkillFactory | 每次创建技能用 Activator.CreateInstance | 编译时委托缓存 | 无 | P1 |
|
||||
| 技能查找 | Skills 列表 O(n) 线性搜索 | `[MemoryPackIgnore]` 字典缓存 + 脏标记 | 无影响 | P1 |
|
||||
| GetAttackAdditionParam | 遍历所有技能聚合加成值 | 缓存加成总值 + 技能变更时置脏 | `[MemoryPackIgnore]` 无影响 | P2 |
|
||||
| OnTurnStart/End 技能回调 | 逐个遍历调用虚方法 | 仅遍历注册了该回调的技能(分类桶) | `[MemoryPackIgnore]` 无影响 | P2 |
|
||||
|
||||
### 5.3 AI系统(TH1_Logic/AI)
|
||||
|
||||
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|
||||
|-------|---------|---------|-----------|--------|
|
||||
| AIActionGenerator.Init | 复制领土到新 List,冗余遍历 | 直接引用已有缓存,减少拷贝 | 无 | P0 |
|
||||
| AILogic 主循环 | 循环内 new List\<uint\>() | 预分配 NodeRecords 池 | 无 | P1 |
|
||||
| 评分计算器 | 7个集合每次 Init 重建 | 复用集合 + Clear | 无 | P1 |
|
||||
| AI 合法 Action 生成 | 生成大量临时 CommonActionParams | 对象池复用 | 无 | P2 |
|
||||
| `validActions.Select().ToArray()` | LINQ 链式分配 | 预分配数组 + 手动填充 | 无 | P1 |
|
||||
|
||||
### 5.4 逻辑模块(City/Unit/Player Logic)
|
||||
|
||||
| 优化项 | 当前问题 | 优化方案 | 序列化影响 | 优先级 |
|
||||
|-------|---------|---------|-----------|--------|
|
||||
| 视野完整重算 | 每次移动重算全部视野 | 增量视野更新 | 无 | P1 |
|
||||
| 外交好感度 O(n²) | 每帧/每回合全量重算 | 事件驱动增量更新 | 无 | P2 |
|
||||
| 领土建筑升级遍历 | 遍历领土两遍(普通+学院/市场) | 单次遍历 + 分类处理 | 无 | P2 |
|
||||
| BFS 城市连通性 | 每回合完整 BFS | 仅在道路/领土变化时重算 | 无 | P2 |
|
||||
| 每回合资源计算 | 遍历所有领土格子 | 缓存产出总值 + 建筑变更时增量更新 | `[MemoryPackIgnore]` 无影响 | P3 |
|
||||
|
||||
### 5.5 渲染与动画(TH1_Renderer / TH1_Anim)
|
||||
|
||||
| 优化项 | 当前问题 | 优化方案 | 优先级 |
|
||||
|-------|---------|---------|--------|
|
||||
| Fragment 创建 | FragmentFactory 每次创建新对象 | 对象池复用 Fragment 实例 | P2 |
|
||||
| UnitAtomAnim | 每次创建新动画对象 | 对象池复用 | P2 |
|
||||
| 投射物 | ProjectileRenderer 每次实例化 | 已有 PrefabPool,确保使用 | P3 |
|
||||
|
||||
### 5.6 事件系统(TH1_Core/Events)
|
||||
|
||||
事件系统已使用 struct 事件,零 GC 分配,**保持现有设计,不需优化**。
|
||||
|
||||
---
|
||||
|
||||
## 第六章 优化验证清单
|
||||
|
||||
### 6.1 每次优化必须验证
|
||||
|
||||
- [ ] **功能等价**:优化前后方法的输入参数和返回结果完全一致
|
||||
- [ ] **单人游戏**:完整对局流程无报错、无异常行为
|
||||
- [ ] **多人游戏**:两个客户端对局,Action 同步正确、MapConfirm 校验通过
|
||||
- [ ] **存档兼容**:用优化前版本创建的存档能被优化后版本正常加载
|
||||
- [ ] **AI 行为**:AI 对局结果分布无异常偏移(相同种子应产生相同决策路径)
|
||||
|
||||
### 6.2 性能验证方法
|
||||
|
||||
```
|
||||
1. Unity Profiler → 对比优化前后:
|
||||
- GC Alloc (bytes/frame)
|
||||
- 目标方法的 CPU 时间 (ms)
|
||||
|
||||
2. Deep Profile 模式 → 确认热路径 GC 分配降为 0
|
||||
|
||||
3. 多人对局 → 确认 MapConfirm 哈希始终一致
|
||||
|
||||
4. 存档测试 → 加载旧存档 → 进行 10 回合 → 再次存档 → 加载验证
|
||||
```
|
||||
|
||||
### 6.3 回归测试范围
|
||||
|
||||
| 优化区域 | 最小测试范围 |
|
||||
|---------|------------|
|
||||
| 数据层(MapData/GridData) | 单人完整对局 + 多人2人对局 |
|
||||
| 技能系统 | 包含全部8文明的对局 + 英雄技能触发 |
|
||||
| AI系统 | 8人AI全自动对局(200回合) |
|
||||
| 逻辑模块 | 征服他国 + 联盟 + 奇迹建造 |
|
||||
| 视野/领土 | 大地图 + 地形多样 |
|
||||
|
||||
---
|
||||
|
||||
## 附录A 已知GC热点清单
|
||||
|
||||
### 按严重程度排序
|
||||
|
||||
| # | 热点 | 位置 | 调用频率 | 分配类型 | 严重度 |
|
||||
|---|------|------|---------|---------|--------|
|
||||
| 1 | `GetAroundGridData` (4个重载) | GridData.cs | 每回合 100+ 次 | new List / new HashSet | 🔴 严重 |
|
||||
| 2 | `GetPlayerTerritoryGridIdSet` | MapData.cs | 每回合 3-10 次 | new HashSet + new List | 🔴 严重 |
|
||||
| 3 | `OnAfterMemoryPackDeserialize` (4处) | CityData/GridData/UnitData | 每次网络同步 | Dictionary 重建 | 🔴 严重 |
|
||||
| 4 | `AIActionGenerator.Init` | AIActionGenerator.cs | 每个AI回合 | 列表拷贝 + HashSet | 🟡 中等 |
|
||||
| 5 | `SkillFactory.GetSkillBySkillType` | SkillFactory.cs | 技能创建时 | Activator.CreateInstance | 🟡 中等 |
|
||||
| 6 | `GetUnitDataListByPlayerId/ByCityId` | MapData.cs | 每回合多次 | null 参数时 new List | 🟡 中等 |
|
||||
| 7 | AI主循环 NodeRecords | AILogic.cs | 每AI步 | new List\<uint\>() | 🟡 中等 |
|
||||
| 8 | `validActions.Select().ToArray()` | AILogic.cs | AI 推理时 | LINQ 链分配 | 🟡 中等 |
|
||||
| 9 | `UpdateTerritoryAllBuildingLevel` | PlayerLogic.cs | 每回合 | 领土集合分配 x2 | 🟢 轻度 |
|
||||
| 10 | `GetCityDataByTerritoryGridId` | MapData.cs | 按需调用 | 仅 CPU 开销(O(n²)) | 🟢 轻度 |
|
||||
|
||||
---
|
||||
|
||||
## 附录B 禁止与允许操作速查表
|
||||
|
||||
### 🟢 允许的优化操作
|
||||
|
||||
| 操作 | 条件 |
|
||||
|------|------|
|
||||
| 新增 `[MemoryPackIgnore]` 字段作为缓存 | 配合延迟初始化和脏标记 |
|
||||
| 将 `new List<T>()` 改为传入缓冲区模式 | 调用方和被调用方同步修改 |
|
||||
| 用 for 循环替代 LINQ | 保证结果等价 |
|
||||
| 新增 static readonly 共享缓冲区 | 仅限单线程游戏逻辑 |
|
||||
| 预计算偏移表/查找表 | 只读数据,初始化时构建 |
|
||||
| 对象池复用 | 正确的获取/归还生命周期 |
|
||||
| 增量计算替代全量计算 | 脏标记在所有修改路径上正确设置 |
|
||||
|
||||
### 🔴 禁止的操作
|
||||
|
||||
| 操作 | 原因 |
|
||||
|------|------|
|
||||
| 向 `[MemoryPackable]` 类新增序列化字段 | 破坏存档兼容性和网络协议 |
|
||||
| 删除或重命名已有的序列化字段 | 旧存档无法加载 |
|
||||
| 修改方法的返回值类型或语义 | 违反 R-01 规则 |
|
||||
| 在缓存中引入浮点累积误差 | 多人同步会出现不一致 |
|
||||
| 使用多线程/异步优化游戏逻辑计算 | 破坏确定性 |
|
||||
| 跳过 null 检查以"加速" | 引入崩溃风险 |
|
||||
| 改变遍历顺序导致不同的执行结果 | 影响确定性(AI/战斗/随机数消费) |
|
||||
|
||||
### 🟡 需审慎评估的操作
|
||||
|
||||
| 操作 | 评估点 |
|
||||
|------|--------|
|
||||
| 修改 `[MemoryPackOnDeserialized]` 逻辑 | 确保所有缓存在反序列化后正确重建 |
|
||||
| static 共享缓冲区 | 确保单线程下无递归/重入问题 |
|
||||
| 缓存失效策略 | 确保所有修改路径都调用 Invalidate |
|
||||
| 移除冗余集合 | 确认没有外部引用依赖该集合 |
|
||||
| 改变内部数据结构(如 List → Array) | 确认所有增删操作都适配 |
|
||||
|
||||
---
|
||||
|
||||
> **文档版本**: 1.0
|
||||
> **最后更新**: 2026-04-16
|
||||
> **适用项目**: TH1 回合制4X策略游戏
|
||||
677
Unity/Assets/Editor/ProfilerAutoInstrumentWindow.cs
Normal file
677
Unity/Assets/Editor/ProfilerAutoInstrumentWindow.cs
Normal file
@ -0,0 +1,677 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
public class ProfilerAutoInstrumentWindow : EditorWindow
|
||||
{
|
||||
private const string DefaultFolder = "Assets/Scripts/TH1_Logic";
|
||||
private const string MarkerBegin = "// TH1_AUTO_PROFILE_BEGIN";
|
||||
private const string MarkerEnd = "// TH1_AUTO_PROFILE_END";
|
||||
private const string IndentUnit = " ";
|
||||
|
||||
private static readonly HashSet<string> NonMethodKeywords = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"if", "for", "foreach", "while", "switch", "catch", "lock", "using", "nameof", "typeof", "sizeof", "default"
|
||||
};
|
||||
|
||||
private string _targetFolder = DefaultFolder;
|
||||
private Vector2 _scrollPos;
|
||||
private string _lastReport = string.Empty;
|
||||
|
||||
private enum BlockKind
|
||||
{
|
||||
Other,
|
||||
Type,
|
||||
Method
|
||||
}
|
||||
|
||||
private struct BlockInfo
|
||||
{
|
||||
public BlockKind Kind;
|
||||
public int MethodIndex;
|
||||
}
|
||||
|
||||
private struct MethodBlock
|
||||
{
|
||||
public int OpenBraceIndex;
|
||||
public int CloseBraceIndex;
|
||||
public string MethodName;
|
||||
}
|
||||
|
||||
private enum InstrumentResult
|
||||
{
|
||||
Changed,
|
||||
AlreadyInstrumented,
|
||||
NoMethod,
|
||||
Failed
|
||||
}
|
||||
|
||||
[MenuItem("Tools/TH1/Profiler Auto Instrument")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ProfilerAutoInstrumentWindow>("Profiler Auto Instrument");
|
||||
window.minSize = new Vector2(720, 420);
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField("批量自动打点(Profiler Begin/End)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"会在目录下所有 .cs 文件的方法体内自动插入 Profiler.BeginSample/EndSample。\n" +
|
||||
"默认目录:Assets/Scripts/TH1_Logic\n" +
|
||||
$"已插入的代码会带标记:{MarkerBegin}",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
_targetFolder = EditorGUILayout.TextField("目标目录", _targetFolder);
|
||||
if (GUILayout.Button("选择...", GUILayout.Width(80)))
|
||||
{
|
||||
SelectFolder();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
EditorGUILayout.Space(8);
|
||||
if (GUILayout.Button("一键自动打点", GUILayout.Height(34)))
|
||||
{
|
||||
RunAutoInstrument();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
EditorGUILayout.LabelField("执行结果", EditorStyles.boldLabel);
|
||||
_scrollPos = EditorGUILayout.BeginScrollView(_scrollPos);
|
||||
EditorGUILayout.TextArea(string.IsNullOrEmpty(_lastReport) ? "尚未执行。" : _lastReport, GUILayout.ExpandHeight(true));
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void SelectFolder()
|
||||
{
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var selected = EditorUtility.OpenFolderPanel("选择要自动打点的目录", projectRoot, string.Empty);
|
||||
if (string.IsNullOrEmpty(selected))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
selected = Path.GetFullPath(selected);
|
||||
if (!selected.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
EditorUtility.DisplayDialog("目录无效", "请选择项目目录内的文件夹。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var relative = selected.Substring(projectRoot.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
_targetFolder = relative.Replace("\\", "/");
|
||||
}
|
||||
|
||||
private void RunAutoInstrument()
|
||||
{
|
||||
var folder = NormalizeFolderPath(_targetFolder);
|
||||
if (!AssetDatabase.IsValidFolder(folder))
|
||||
{
|
||||
EditorUtility.DisplayDialog("目录不存在", $"找不到目录:{folder}", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
var folderAbsolute = AssetPathToAbsolutePath(folder);
|
||||
var files = Directory.GetFiles(folderAbsolute, "*.cs", SearchOption.AllDirectories);
|
||||
|
||||
int changed = 0;
|
||||
int already = 0;
|
||||
int noMethod = 0;
|
||||
int failed = 0;
|
||||
var details = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < files.Length; i++)
|
||||
{
|
||||
var fullPath = files[i];
|
||||
var assetPath = AbsolutePathToAssetPath(fullPath);
|
||||
var result = InstrumentSingleFile(fullPath, assetPath, out var detail);
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case InstrumentResult.Changed:
|
||||
changed++;
|
||||
details.AppendLine($"[Changed] {assetPath}");
|
||||
break;
|
||||
case InstrumentResult.AlreadyInstrumented:
|
||||
already++;
|
||||
break;
|
||||
case InstrumentResult.NoMethod:
|
||||
noMethod++;
|
||||
break;
|
||||
case InstrumentResult.Failed:
|
||||
failed++;
|
||||
details.AppendLine($"[Failed] {assetPath} -> {detail}");
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
}
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
_lastReport =
|
||||
$"目录:{folder}\n" +
|
||||
$"扫描文件:{files.Length}\n" +
|
||||
$"已打点:{changed}\n" +
|
||||
$"已存在打点:{already}\n" +
|
||||
$"无可打点方法:{noMethod}\n" +
|
||||
$"失败:{failed}\n\n" +
|
||||
details;
|
||||
|
||||
Debug.Log($"[ProfilerAutoInstrument] 完成。Changed={changed}, Already={already}, NoMethod={noMethod}, Failed={failed}");
|
||||
}
|
||||
|
||||
private static InstrumentResult InstrumentSingleFile(string fullPath, string assetPath, out string detail)
|
||||
{
|
||||
detail = string.Empty;
|
||||
try
|
||||
{
|
||||
var rawBytes = File.ReadAllBytes(fullPath);
|
||||
bool hasUtf8Bom = rawBytes.Length >= 3 &&
|
||||
rawBytes[0] == 0xEF &&
|
||||
rawBytes[1] == 0xBB &&
|
||||
rawBytes[2] == 0xBF;
|
||||
|
||||
var original = File.ReadAllText(fullPath);
|
||||
if (original.Contains(MarkerBegin))
|
||||
{
|
||||
return InstrumentResult.AlreadyInstrumented;
|
||||
}
|
||||
|
||||
var methods = FindMethodBlocks(original);
|
||||
if (methods.Count == 0)
|
||||
{
|
||||
return InstrumentResult.NoMethod;
|
||||
}
|
||||
|
||||
var instrumented = BuildInstrumentedContent(original, methods, assetPath);
|
||||
if (instrumented == original)
|
||||
{
|
||||
return InstrumentResult.NoMethod;
|
||||
}
|
||||
|
||||
File.WriteAllText(fullPath, instrumented, new UTF8Encoding(hasUtf8Bom));
|
||||
return InstrumentResult.Changed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
detail = ex.Message;
|
||||
return InstrumentResult.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<MethodBlock> FindMethodBlocks(string content)
|
||||
{
|
||||
var codeOnly = BuildCodeOnlyBuffer(content);
|
||||
var methods = new List<MethodBlock>();
|
||||
var stack = new Stack<BlockInfo>();
|
||||
int typeDepth = 0;
|
||||
int methodDepth = 0;
|
||||
|
||||
for (int i = 0; i < codeOnly.Length; i++)
|
||||
{
|
||||
char c = codeOnly[i];
|
||||
if (c == '{')
|
||||
{
|
||||
var block = new BlockInfo { Kind = BlockKind.Other, MethodIndex = -1 };
|
||||
|
||||
if (LooksLikeTypeBlock(codeOnly, i))
|
||||
{
|
||||
block.Kind = BlockKind.Type;
|
||||
typeDepth++;
|
||||
}
|
||||
else if (typeDepth > 0 && methodDepth == 0 && TryResolveMethodName(codeOnly, i, out var methodName))
|
||||
{
|
||||
var method = new MethodBlock
|
||||
{
|
||||
OpenBraceIndex = i,
|
||||
CloseBraceIndex = -1,
|
||||
MethodName = methodName
|
||||
};
|
||||
methods.Add(method);
|
||||
block.Kind = BlockKind.Method;
|
||||
block.MethodIndex = methods.Count - 1;
|
||||
methodDepth++;
|
||||
}
|
||||
|
||||
stack.Push(block);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c != '}' || stack.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var closedBlock = stack.Pop();
|
||||
if (closedBlock.Kind == BlockKind.Type)
|
||||
{
|
||||
typeDepth = Math.Max(0, typeDepth - 1);
|
||||
}
|
||||
else if (closedBlock.Kind == BlockKind.Method)
|
||||
{
|
||||
methodDepth = Math.Max(0, methodDepth - 1);
|
||||
if (closedBlock.MethodIndex >= 0 && closedBlock.MethodIndex < methods.Count)
|
||||
{
|
||||
var method = methods[closedBlock.MethodIndex];
|
||||
method.CloseBraceIndex = i;
|
||||
methods[closedBlock.MethodIndex] = method;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
methods.RemoveAll(m => m.CloseBraceIndex <= m.OpenBraceIndex);
|
||||
return methods;
|
||||
}
|
||||
|
||||
private static string BuildInstrumentedContent(string content, List<MethodBlock> methods, string assetPath)
|
||||
{
|
||||
string newline = DetectNewLine(content);
|
||||
var output = new StringBuilder(content.Length + methods.Count * 220);
|
||||
int cursor = 0;
|
||||
|
||||
for (int i = 0; i < methods.Count; i++)
|
||||
{
|
||||
var method = methods[i];
|
||||
if (method.OpenBraceIndex < cursor || method.CloseBraceIndex <= method.OpenBraceIndex)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
output.Append(content, cursor, method.OpenBraceIndex + 1 - cursor);
|
||||
|
||||
string braceIndent = GetLineIndent(content, method.OpenBraceIndex);
|
||||
string innerIndent = braceIndent + IndentUnit;
|
||||
string sampleName = BuildSampleName(assetPath, method.MethodName);
|
||||
|
||||
output.Append(newline);
|
||||
output.Append(innerIndent).Append(MarkerBegin).Append(newline);
|
||||
output.Append(innerIndent).Append("UnityEngine.Profiling.Profiler.BeginSample(\"").Append(sampleName).Append("\");").Append(newline);
|
||||
output.Append(innerIndent).Append("try").Append(newline);
|
||||
output.Append(innerIndent).Append("{").Append(newline);
|
||||
|
||||
int bodyStart = method.OpenBraceIndex + 1;
|
||||
output.Append(content, bodyStart, method.CloseBraceIndex - bodyStart);
|
||||
|
||||
output.Append(newline);
|
||||
output.Append(innerIndent).Append("}").Append(newline);
|
||||
output.Append(innerIndent).Append("finally").Append(newline);
|
||||
output.Append(innerIndent).Append("{").Append(newline);
|
||||
output.Append(innerIndent).Append(IndentUnit).Append("UnityEngine.Profiling.Profiler.EndSample();").Append(newline);
|
||||
output.Append(innerIndent).Append("}").Append(newline);
|
||||
output.Append(innerIndent).Append(MarkerEnd).Append(newline);
|
||||
output.Append(braceIndent);
|
||||
|
||||
cursor = method.CloseBraceIndex;
|
||||
}
|
||||
|
||||
if (cursor < content.Length)
|
||||
{
|
||||
output.Append(content, cursor, content.Length - cursor);
|
||||
}
|
||||
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private static string BuildSampleName(string assetPath, string methodName)
|
||||
{
|
||||
var normalizedPath = assetPath.Replace("\\", "/");
|
||||
var sample = $"TH1Auto/{normalizedPath}::{methodName}";
|
||||
return sample.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private static bool LooksLikeTypeBlock(string codeOnly, int braceIndex)
|
||||
{
|
||||
int headerStart = FindHeaderStart(codeOnly, braceIndex);
|
||||
int length = braceIndex - headerStart;
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string header = codeOnly.Substring(headerStart, length);
|
||||
if (Regex.IsMatch(header, @"\bnamespace\b"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return Regex.IsMatch(header, @"\b(class|struct|interface|enum|record)\b");
|
||||
}
|
||||
|
||||
private static bool TryResolveMethodName(string codeOnly, int braceIndex, out string methodName)
|
||||
{
|
||||
methodName = null;
|
||||
|
||||
int segmentStart = FindHeaderStart(codeOnly, braceIndex);
|
||||
int closeParen = FindPreviousChar(codeOnly, ')', braceIndex - 1);
|
||||
while (closeParen >= 0 && closeParen >= segmentStart)
|
||||
{
|
||||
int openParen = FindMatchingOpenParen(codeOnly, closeParen);
|
||||
if (openParen < 0 || openParen < segmentStart)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int nameEnd = PreviousNonWhitespace(codeOnly, openParen - 1);
|
||||
if (nameEnd < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int nameStart = nameEnd;
|
||||
while (nameStart >= 0 && IsIdentifierChar(codeOnly[nameStart]))
|
||||
{
|
||||
nameStart--;
|
||||
}
|
||||
|
||||
nameStart++;
|
||||
if (nameStart > nameEnd)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
methodName = codeOnly.Substring(nameStart, nameEnd - nameStart + 1);
|
||||
if (string.IsNullOrEmpty(methodName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (methodName[0] == '@')
|
||||
{
|
||||
methodName = methodName.Substring(1);
|
||||
}
|
||||
|
||||
if (methodName == "base" || methodName == "this")
|
||||
{
|
||||
closeParen = FindPreviousChar(codeOnly, ')', openParen - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NonMethodKeywords.Contains(methodName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int checkLength = braceIndex - segmentStart;
|
||||
if (checkLength <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string header = codeOnly.Substring(segmentStart, checkLength);
|
||||
if (header.Contains("=>") || header.Contains(";") || header.Contains("="))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(header, @"\b(return|throw)\b"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildCodeOnlyBuffer(string content)
|
||||
{
|
||||
var chars = content.ToCharArray();
|
||||
bool inSingleLineComment = false;
|
||||
bool inMultiLineComment = false;
|
||||
bool inString = false;
|
||||
bool inChar = false;
|
||||
bool verbatimString = false;
|
||||
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
{
|
||||
char c = chars[i];
|
||||
char next = i + 1 < chars.Length ? chars[i + 1] : '\0';
|
||||
|
||||
if (inSingleLineComment)
|
||||
{
|
||||
if (c != '\r' && c != '\n')
|
||||
{
|
||||
chars[i] = ' ';
|
||||
}
|
||||
else
|
||||
{
|
||||
inSingleLineComment = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inMultiLineComment)
|
||||
{
|
||||
chars[i] = ' ';
|
||||
if (c == '*' && next == '/')
|
||||
{
|
||||
chars[i + 1] = ' ';
|
||||
i++;
|
||||
inMultiLineComment = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString)
|
||||
{
|
||||
chars[i] = ' ';
|
||||
if (verbatimString)
|
||||
{
|
||||
if (c == '"' && next == '"')
|
||||
{
|
||||
chars[i + 1] = ' ';
|
||||
i++;
|
||||
}
|
||||
else if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
verbatimString = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c == '\\' && i + 1 < chars.Length)
|
||||
{
|
||||
chars[i + 1] = ' ';
|
||||
i++;
|
||||
}
|
||||
else if (c == '"')
|
||||
{
|
||||
inString = false;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inChar)
|
||||
{
|
||||
chars[i] = ' ';
|
||||
if (c == '\\' && i + 1 < chars.Length)
|
||||
{
|
||||
chars[i + 1] = ' ';
|
||||
i++;
|
||||
}
|
||||
else if (c == '\'')
|
||||
{
|
||||
inChar = false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '/')
|
||||
{
|
||||
chars[i] = ' ';
|
||||
chars[i + 1] = ' ';
|
||||
i++;
|
||||
inSingleLineComment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '/' && next == '*')
|
||||
{
|
||||
chars[i] = ' ';
|
||||
chars[i + 1] = ' ';
|
||||
i++;
|
||||
inMultiLineComment = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
{
|
||||
chars[i] = ' ';
|
||||
inString = true;
|
||||
verbatimString =
|
||||
(i > 0 && chars[i - 1] == '@') ||
|
||||
(i > 1 && chars[i - 1] == '$' && chars[i - 2] == '@') ||
|
||||
(i > 1 && chars[i - 1] == '@' && chars[i - 2] == '$');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\'')
|
||||
{
|
||||
chars[i] = ' ';
|
||||
inChar = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
private static int FindMatchingOpenParen(string codeOnly, int closeParenIndex)
|
||||
{
|
||||
int depth = 0;
|
||||
for (int i = closeParenIndex; i >= 0; i--)
|
||||
{
|
||||
char c = codeOnly[i];
|
||||
if (c == ')')
|
||||
{
|
||||
depth++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c != '(')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FindHeaderStart(string codeOnly, int index)
|
||||
{
|
||||
for (int i = index - 1; i >= 0; i--)
|
||||
{
|
||||
char c = codeOnly[i];
|
||||
if (c == ';' || c == '{' || c == '}')
|
||||
{
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static int PreviousNonWhitespace(string text, int index)
|
||||
{
|
||||
for (int i = index; i >= 0; i--)
|
||||
{
|
||||
if (!char.IsWhiteSpace(text[i]))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int FindPreviousChar(string text, char target, int startIndex)
|
||||
{
|
||||
for (int i = startIndex; i >= 0; i--)
|
||||
{
|
||||
char c = text[i];
|
||||
if (c == target)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
if (c == ';' || c == '{' || c == '}')
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool IsIdentifierChar(char c)
|
||||
{
|
||||
return char.IsLetterOrDigit(c) || c == '_' || c == '@';
|
||||
}
|
||||
|
||||
private static string DetectNewLine(string content)
|
||||
{
|
||||
return content.Contains("\r\n") ? "\r\n" : "\n";
|
||||
}
|
||||
|
||||
private static string GetLineIndent(string content, int index)
|
||||
{
|
||||
int lineStart = content.LastIndexOf('\n', Mathf.Clamp(index, 0, Math.Max(0, content.Length - 1)));
|
||||
lineStart = lineStart < 0 ? 0 : lineStart + 1;
|
||||
int i = lineStart;
|
||||
while (i < content.Length && (content[i] == ' ' || content[i] == '\t'))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
return content.Substring(lineStart, i - lineStart);
|
||||
}
|
||||
|
||||
private static string NormalizeFolderPath(string folder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
return DefaultFolder;
|
||||
}
|
||||
|
||||
var normalized = folder.Replace("\\", "/").Trim();
|
||||
if (!normalized.StartsWith("Assets", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = "Assets/" + normalized.TrimStart('/');
|
||||
}
|
||||
|
||||
return normalized.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string AssetPathToAbsolutePath(string assetPath)
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
|
||||
}
|
||||
|
||||
private static string AbsolutePathToAssetPath(string absolutePath)
|
||||
{
|
||||
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
var full = Path.GetFullPath(absolutePath);
|
||||
var relative = full.Substring(projectRoot.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return relative.Replace("\\", "/");
|
||||
}
|
||||
}
|
||||
11
Unity/Assets/Editor/ProfilerAutoInstrumentWindow.cs.meta
Normal file
11
Unity/Assets/Editor/ProfilerAutoInstrumentWindow.cs.meta
Normal file
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f2d8ec8a8d147f5abdf1f4b7f940a11
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
x
Reference in New Issue
Block a user