using UnityEngine;
using UnityEditor;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
///
/// 事件订阅分析工具 - 检查整个项目中所有的事件订阅和取消订阅是否匹配
///
public class EventSubscriptionAnalyzer : EditorWindow
{
[MenuItem("Tools/事件订阅匹配检查")]
public static void ShowWindow()
{
GetWindow("Event Sub Analyzer");
}
private Vector2 scrollPosition;
private string analysisResult = "";
private List issues = new List();
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 ExtractMethodRanges(string[] lines)
{
var methodRanges = new List();
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 == '}');
}
///
/// 动态查找所有事件订阅操作 (+=)
///
private List FindAllEventSubscriptions(string[] lines, List methodRanges)
{
var subscriptions = new List();
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;
}
///
/// 动态查找所有事件取消订阅操作 (-=)
///
private List FindAllEventUnsubscriptions(string[] lines, List methodRanges)
{
var unsubscriptions = new List();
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;
}
///
/// 检查是否为关键字
///
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);
}
///
/// 检查订阅是否有对应的取消订阅
///
private bool HasMatchingUnsubscription(EventSubscription subscription,
List unsubscriptions, List 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; }
}
}