三国卡牌客户端基础资源仓库
hch
2026-01-06 516ecb1fafd963a6bff6a63526789c230452caf8
0312 因事件订阅未取消引发的问题,增加工具检测
2个文件已添加
505 ■■■■■ 已修改文件
Assets/Editor/UIComponent/EventSubscriptionAnalyzer.cs 494 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Assets/Editor/UIComponent/EventSubscriptionAnalyzer.cs.meta 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
Assets/Editor/UIComponent/EventSubscriptionAnalyzer.cs
New file
@@ -0,0 +1,494 @@
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; }
    }
}
Assets/Editor/UIComponent/EventSubscriptionAnalyzer.cs.meta
New file
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 358a907ee3630034f888dda6b6c9c788
MonoImporter:
  externalObjects: {}
  serializedVersion: 2
  defaultReferences: []
  executionOrder: 0
  icon: {instanceID: 0}
  userData:
  assetBundleName:
  assetBundleVariant: