678 lines
20 KiB
C#
678 lines
20 KiB
C#
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("\\", "/");
|
||
}
|
||
}
|