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; } } }