using UnityEngine;
|
using UnityEditor;
|
using System;
|
using System.Collections.Generic;
|
using System.IO;
|
using System.Linq;
|
using System.Text.RegularExpressions;
|
|
/// <summary>
|
/// 事件订阅分析工具 - 检查整个项目中所有的事件订阅和取消订阅是否匹配
|
/// </summary>
|
public class EventSubscriptionAnalyzer : EditorWindow
|
{
|
[MenuItem("Tools/事件订阅匹配检查")]
|
public static void ShowWindow()
|
{
|
GetWindow<EventSubscriptionAnalyzer>("Event Sub Analyzer");
|
}
|
|
private Vector2 scrollPosition;
|
private string analysisResult = "";
|
private List<SubscriptionIssue> issues = new List<SubscriptionIssue>();
|
|
private class SubscriptionIssue
|
{
|
public string FilePath;
|
public string FileName;
|
public int LineNumber;
|
public string EventType;
|
public string ClassName;
|
public string MethodName;
|
public IssueType Type;
|
public string Description;
|
|
public enum IssueType
|
{
|
MissingUnsubscribe, // 缺少取消订阅
|
MultipleSubscribe, // 重复订阅
|
SubscribeBeforeUnsubscribe, // 订阅在取消之前(正常情况)
|
PotentialMemoryLeak, // 潜在内存泄漏
|
Warning // 警告
|
}
|
|
public override string ToString()
|
{
|
return $"[{Type}] {ClassName}.{MethodName} in {Path.GetFileName(FilePath)}:{LineNumber} - {Description}";
|
}
|
}
|
|
void OnGUI()
|
{
|
GUILayout.Label("Event Subscription Analyzer", EditorStyles.boldLabel);
|
|
if (GUILayout.Button("Analyze Project"))
|
{
|
AnalyzeProject();
|
}
|
|
GUILayout.Space(10);
|
|
if (issues.Count > 0)
|
{
|
GUILayout.Label($"Found {issues.Count} Issues:", EditorStyles.boldLabel);
|
|
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
|
|
foreach (var issue in issues)
|
{
|
Color color = GetIssueColor(issue.Type);
|
GUI.backgroundColor = color;
|
|
GUILayout.BeginVertical(EditorStyles.helpBox);
|
|
string icon = GetIssueIcon(issue.Type);
|
GUILayout.Label($"{icon} {issue.Type}", EditorStyles.boldLabel);
|
GUILayout.Label($"File: {issue.FileName}:{issue.LineNumber}");
|
GUILayout.Label($"Class: {issue.ClassName}");
|
GUILayout.Label($"Event: {issue.EventType}");
|
GUILayout.Label($"Method: {issue.MethodName}");
|
GUILayout.Label($"Description: {issue.Description}");
|
|
if (GUILayout.Button("Open File"))
|
{
|
UnityEditorInternal.InternalEditorUtility.OpenFileAtLineExternal(
|
issue.FilePath, issue.LineNumber);
|
}
|
|
GUILayout.EndVertical();
|
|
GUI.backgroundColor = Color.white;
|
}
|
|
EditorGUILayout.EndScrollView();
|
}
|
else if (!string.IsNullOrEmpty(analysisResult))
|
{
|
GUILayout.Label(analysisResult);
|
}
|
}
|
|
private Color GetIssueColor(SubscriptionIssue.IssueType type)
|
{
|
switch (type)
|
{
|
case SubscriptionIssue.IssueType.MissingUnsubscribe:
|
return new Color(1f, 0.6f, 0.6f); // 红色
|
case SubscriptionIssue.IssueType.PotentialMemoryLeak:
|
return new Color(1f, 0.8f, 0.6f); // 橙色
|
case SubscriptionIssue.IssueType.Warning:
|
return new Color(1f, 1f, 0.6f); // 黄色
|
default:
|
return new Color(0.8f, 1f, 0.8f); // 绿色
|
}
|
}
|
|
private string GetIssueIcon(SubscriptionIssue.IssueType type)
|
{
|
switch (type)
|
{
|
case SubscriptionIssue.IssueType.MissingUnsubscribe:
|
return "[ERROR]";
|
case SubscriptionIssue.IssueType.PotentialMemoryLeak:
|
return "[WARN]";
|
case SubscriptionIssue.IssueType.Warning:
|
return "[WARN]";
|
default:
|
return "[OK]";
|
}
|
}
|
|
private void AnalyzeProject()
|
{
|
issues.Clear();
|
|
// 只获取 Assets/Scripts 下的 C# 文件
|
string scriptsPath = Path.Combine(Application.dataPath, "Scripts");
|
if (!Directory.Exists(scriptsPath))
|
{
|
Debug.LogWarning($"Scripts folder not found at: {scriptsPath}");
|
return;
|
}
|
|
string[] csFiles = Directory.GetFiles(
|
scriptsPath,
|
"*.cs",
|
SearchOption.AllDirectories);
|
|
Debug.Log($"Found {csFiles.Length} C# files to analyze in Assets/Scripts");
|
|
foreach (string filePath in csFiles)
|
{
|
// 跳过临时文件和生成的文件
|
if (filePath.Contains(".meta") ||
|
filePath.Contains("/Temp/") ||
|
filePath.Contains("/Library/"))
|
{
|
continue;
|
}
|
|
string relativePath = "Assets" + filePath.Replace(Application.dataPath, "");
|
relativePath = relativePath.Replace("\\", "/");
|
|
AnalyzeFile(filePath, relativePath);
|
}
|
|
Debug.Log($"Analysis complete. Found {issues.Count} issues.");
|
Repaint();
|
}
|
|
private void AnalyzeFile(string filePath, string relativePath)
|
{
|
string[] lines = File.ReadAllLines(filePath);
|
string className = ExtractClassName(filePath);
|
string fileContent = File.ReadAllText(filePath);
|
|
// 找到所有方法定义
|
var methodRanges = ExtractMethodRanges(lines);
|
|
// 动态查找所有事件订阅和取消订阅
|
var subscriptions = FindAllEventSubscriptions(lines, methodRanges);
|
var unsubscriptions = FindAllEventUnsubscriptions(lines, methodRanges);
|
|
// 检查每个订阅是否有对应的取消
|
foreach (var sub in subscriptions)
|
{
|
bool hasUnsubscribe = HasMatchingUnsubscription(sub, unsubscriptions, methodRanges);
|
|
if (!hasUnsubscribe)
|
{
|
// 检查是否有 OnDestroy 方法
|
bool hasOnDestroy = methodRanges.Any(m => m.Name == "OnDestroy");
|
|
var issue = new SubscriptionIssue
|
{
|
FilePath = filePath,
|
FileName = Path.GetFileName(filePath),
|
LineNumber = sub.LineNumber,
|
EventType = sub.EventName,
|
ClassName = className,
|
MethodName = sub.HandlerMethod,
|
Type = hasOnDestroy ?
|
SubscriptionIssue.IssueType.Warning :
|
SubscriptionIssue.IssueType.MissingUnsubscribe,
|
Description = hasOnDestroy ?
|
$"事件 '{sub.EventName}' 已订阅但取消订阅不在 OnDestroy 中,可能导致内存泄漏" :
|
$"事件 '{sub.EventName}' 已订阅但没有找到对应的取消订阅,可能导致内存泄漏"
|
};
|
|
issues.Add(issue);
|
}
|
}
|
}
|
|
private string ExtractClassName(string filePath)
|
{
|
string content = File.ReadAllText(filePath);
|
Match match = Regex.Match(content, @"class\s+(\w+)\s*:");
|
return match.Success ? match.Groups[1].Value : Path.GetFileNameWithoutExtension(filePath);
|
}
|
|
private List<MethodRange> ExtractMethodRanges(string[] lines)
|
{
|
var methodRanges = new List<MethodRange>();
|
int depth = 0;
|
MethodRange currentMethod = null;
|
bool inMethodBody = false;
|
|
for (int i = 0; i < lines.Length; i++)
|
{
|
string line = lines[i];
|
string trimmedLine = line.Trim();
|
|
// 检测方法开始
|
if (Regex.IsMatch(trimmedLine, @"^\s*(?:[a-zA-Z_]\w*\s+)+(?:[a-zA-Z_]\w*\s+)*(\w+)\s*\(.*\)") ||
|
Regex.IsMatch(trimmedLine, @"^\s*(void|int|string|bool|float|double|long|short|byte|char|decimal|var|Task|IEnumerator)\s+\w+\s*\(.*\)"))
|
{
|
if (currentMethod == null && depth == 0)
|
{
|
Match nameMatch = Regex.Match(trimmedLine, @"(\w+)\s*\(");
|
if (nameMatch.Success)
|
{
|
string methodName = nameMatch.Groups[1].Value;
|
string[] keywords = { "void", "int", "string", "bool", "float", "double", "long", "short", "byte", "char", "decimal", "var", "Task", "IEnumerator", "public", "private", "protected", "internal", "static", "virtual", "override", "async", "unsafe" };
|
if (!keywords.Contains(methodName))
|
{
|
currentMethod = new MethodRange
|
{
|
Name = methodName,
|
StartLine = i,
|
EndLine = i
|
};
|
depth = CountBraces(line);
|
|
if (depth > 0)
|
{
|
inMethodBody = true;
|
}
|
}
|
}
|
}
|
}
|
|
if (currentMethod != null)
|
{
|
int braceChange = CountBraces(line);
|
depth += braceChange;
|
|
if (inMethodBody && depth <= 0)
|
{
|
currentMethod.EndLine = i;
|
methodRanges.Add(currentMethod);
|
currentMethod = null;
|
depth = 0;
|
inMethodBody = false;
|
}
|
else if (!inMethodBody && depth > 0)
|
{
|
inMethodBody = true;
|
}
|
}
|
}
|
|
return methodRanges;
|
}
|
|
private int CountBraces(string line)
|
{
|
return line.Count(c => c == '{') - line.Count(c => c == '}');
|
}
|
|
/// <summary>
|
/// 动态查找所有事件订阅操作 (+=)
|
/// </summary>
|
private List<EventSubscription> FindAllEventSubscriptions(string[] lines, List<MethodRange> methodRanges)
|
{
|
var subscriptions = new List<EventSubscription>();
|
|
for (int i = 0; i < lines.Length; i++)
|
{
|
string line = lines[i];
|
string trimmedLine = line.Trim();
|
|
// 跳过注释
|
if (trimmedLine.StartsWith("//") || trimmedLine.StartsWith("/*") || trimmedLine.StartsWith("*"))
|
continue;
|
|
// 跳过不包含 += 的行
|
if (!line.Contains("+="))
|
continue;
|
|
// 严格匹配:eventPath += methodName;
|
// 要求:+= 左侧必须包含点号(.),右侧必须是标识符,后面紧跟 ; 或 // 或 /*,不能有任何运算符
|
var pattern = @"(\w+(?:\.\w+)+)\s*\+=\s*(\w+)\s*([;]|//|/\*)";
|
|
Match match = Regex.Match(trimmedLine, pattern);
|
if (!match.Success)
|
{
|
// 不再尝试简单名称匹配(不包含点号的情况),直接跳过
|
continue;
|
}
|
|
if (match.Success)
|
{
|
string fullEventPath = match.Groups[1].Value;
|
string handlerMethod = match.Groups[2].Value;
|
|
// 过滤关键字
|
if (IsKeyword(handlerMethod))
|
continue;
|
|
// 过滤数字开头
|
if (Regex.IsMatch(handlerMethod, @"^\d+"))
|
continue;
|
|
// 提取事件名
|
string eventName = fullEventPath.Contains(".")
|
? fullEventPath.Substring(fullEventPath.LastIndexOf('.') + 1)
|
: fullEventPath;
|
|
MethodRange containingMethod = methodRanges.FirstOrDefault(
|
m => i >= m.StartLine && i <= m.EndLine);
|
|
subscriptions.Add(new EventSubscription
|
{
|
LineNumber = i + 1,
|
EventName = eventName,
|
FullEventPath = fullEventPath,
|
HandlerMethod = handlerMethod,
|
MethodRange = containingMethod
|
});
|
}
|
}
|
|
return subscriptions;
|
}
|
|
/// <summary>
|
/// 动态查找所有事件取消订阅操作 (-=)
|
/// </summary>
|
private List<EventUnsubscription> FindAllEventUnsubscriptions(string[] lines, List<MethodRange> methodRanges)
|
{
|
var unsubscriptions = new List<EventUnsubscription>();
|
|
for (int i = 0; i < lines.Length; i++)
|
{
|
string line = lines[i];
|
string trimmedLine = line.Trim();
|
|
if (trimmedLine.StartsWith("//") || trimmedLine.StartsWith("/*") || trimmedLine.StartsWith("*"))
|
continue;
|
|
if (!line.Contains("-="))
|
continue;
|
|
// 严格匹配:eventPath -= methodName;
|
// 要求:-= 左侧必须包含点号(.),右侧必须是标识符,后面紧跟 ; 或 // 或 /*
|
var pattern = @"(\w+(?:\.\w+)+)\s*-=\s*(\w+)\s*([;]|//|/\*)";
|
|
Match match = Regex.Match(trimmedLine, pattern);
|
if (!match.Success)
|
{
|
// 不再尝试简单名称匹配(不包含点号的情况),直接跳过
|
continue;
|
}
|
|
if (match.Success)
|
{
|
string fullEventPath = match.Groups[1].Value;
|
string handlerMethod = match.Groups[2].Value;
|
|
if (IsKeyword(handlerMethod))
|
continue;
|
|
if (Regex.IsMatch(handlerMethod, @"^\d+"))
|
continue;
|
|
string eventName = fullEventPath.Contains(".")
|
? fullEventPath.Substring(fullEventPath.LastIndexOf('.') + 1)
|
: fullEventPath;
|
|
MethodRange containingMethod = methodRanges.FirstOrDefault(
|
m => i >= m.StartLine && i <= m.EndLine);
|
|
unsubscriptions.Add(new EventUnsubscription
|
{
|
LineNumber = i + 1,
|
EventName = eventName,
|
FullEventPath = fullEventPath,
|
HandlerMethod = handlerMethod,
|
MethodRange = containingMethod
|
});
|
}
|
}
|
|
return unsubscriptions;
|
}
|
|
/// <summary>
|
/// 检查是否为关键字
|
/// </summary>
|
private bool IsKeyword(string word)
|
{
|
string[] keywords = {
|
"null", "true", "false", "default", "new", "typeof", "sizeof",
|
"this", "base", "return", "throw", "if", "else", "for", "foreach",
|
"while", "do", "switch", "case", "break", "continue", "goto",
|
"try", "catch", "finally", "using", "lock", "checked", "unchecked",
|
"void", "int", "string", "bool", "float", "double", "long", "short",
|
"byte", "char", "decimal", "var", "object", "dynamic"
|
};
|
|
return keywords.Contains(word);
|
}
|
|
/// <summary>
|
/// 检查订阅是否有对应的取消订阅
|
/// </summary>
|
private bool HasMatchingUnsubscription(EventSubscription subscription,
|
List<EventUnsubscription> unsubscriptions, List<MethodRange> methodRanges)
|
{
|
// 使用完整的事件路径进行匹配(不仅仅是事件名)
|
var matchingUnsubs = unsubscriptions.Where(u =>
|
u.FullEventPath == subscription.FullEventPath &&
|
u.HandlerMethod == subscription.HandlerMethod).ToList();
|
|
// 在同一个方法中查找 -=(必须在订阅之后)
|
if (subscription.MethodRange != null)
|
{
|
var sameMethodUnsubs = matchingUnsubs.Where(u =>
|
u.MethodRange != null &&
|
u.MethodRange.Name == subscription.MethodRange.Name &&
|
u.LineNumber > subscription.LineNumber).ToList();
|
|
if (sameMethodUnsubs.Any())
|
{
|
return true;
|
}
|
}
|
|
// 在其他任意方法中查找匹配的取消订阅
|
// 只要有取消订阅存在,就认为已正确处理
|
if (matchingUnsubs.Any(u => u.MethodRange != null))
|
{
|
return true;
|
}
|
|
return false;
|
}
|
|
private class MethodRange
|
{
|
public string Name { get; set; }
|
public int StartLine { get; set; }
|
public int EndLine { get; set; }
|
}
|
|
private class EventSubscription
|
{
|
public int LineNumber { get; set; }
|
public string EventName { get; set; }
|
public string FullEventPath { get; set; }
|
public string HandlerMethod { get; set; }
|
public MethodRange MethodRange { get; set; }
|
}
|
|
private class EventUnsubscription
|
{
|
public int LineNumber { get; set; }
|
public string EventName { get; set; }
|
public string FullEventPath { get; set; }
|
public string HandlerMethod { get; set; }
|
public MethodRange MethodRange { get; set; }
|
}
|
}
|