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