Unity工具---Lua脚本事件检查工具
1、目的
因为日常业务逻辑中有时候需要等一段时间执行某个操作的需求,所以项目中有通用的倒计时方法setTimer。
但是,使用setTimer时一定要记得在退出当前逻辑时调用removeTimer移除这个倒计时,否则容易引起不必要的报错。
因此,需要实现一个工具来检测代码中哪些Lua脚本注册了事件但没有在退出时移除事件。
2、思考
2.1简单判断可以吗?
如果只是简单的判断一个脚本中是否存在setTimer和removeTimer是不行的,因为一个脚本中可以设置多个timer,并且执行的回调方法各不相同,如果只是这样简单判断,就会漏掉这种情况。
2.2成组对应可以吗?
有多少个setTimer就对应多少个removeTimer,这样可以吗?
这样也不行。因为脚本中可以多次设置相同的方法,只在退出时移除一次就行,这样写是对的。
2.3记录设置的回调方法,去重,并逐个判断是否移除可以吗?
也不行,因为有可能退出时执行的不是removeTimer方法,而是一个自定义函数,例如执行removeAllTimer,在这个方法中再移除timer,这样写也是对的。
甚至,还有一种情况,一个Lua脚本继承另一个脚本,只重写其中一个方法,方法中存在setTimer方法,但是removeTimer方法在父类的退出函数中调用,这样写也是对的。
那么,有没有办法能完美检测出来上述情况呢?
有的,使用AST抽象语法树 。
本质上就是构建一个Lua脚本的方法定义映射以及调用映射,还要构造该脚本全部父类的方法映射和调用映射,并去重记录setTimer设置的回调方法,然后再通过抽象语法树,找到退出函数,检查退出函数中是否移除了对应回调方法的timer;如果没有移除,则检测在退出函数中执行的函数,若当前脚本存在该函数,检查该函数是否移除了对应回调方法的timer事件,如果还是没有移除,则按上述规则检测父类的情况,如果最终还是没有找到,则认为没有移除timer事件。
3、实现
csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
public class LuaEventChecker
{
#region ConfigAndConstants
private static readonly Dictionary<string, string> LuaScriptDirDict = new Dictionary<string, string>
{
{"Logic", GetProjectPath() + "Assets/Scripts/Lua/logic/"},
};
// Methods treated as "destroy functions". removetimer in this scope/call-chain is valid.
public static readonly HashSet<string> DestroyFunctions = new HashSet<string>
{
"onExit", "destroy", "dispose", "onDispose", "onReset", "onExitScene", "stop",
"onStop", "onExitFinished", "destroyUI", "OnDestroy", "_dispose", "_destroy"
};
private const int MaxRecursiveDepth = 10;
#endregion
#region RegexCache
private static readonly Regex RegexClass = new Regex(@"class\s*\(.*?,\s*([\w\s,]+)\s*\)", RegexOptions.Compiled);
private static readonly Regex RegexSetTimer = new Regex(@"settimer\(", RegexOptions.Compiled);
private static readonly Regex RegexRemoveTimer = new Regex(@"removetimer\(", RegexOptions.Compiled);
private static readonly Regex RegexFuncDef = new Regex(@"function\s+\w+:(\w+)\s*\(", RegexOptions.Compiled);
private static readonly Regex RegexIfStart = new Regex(@"^\s*if\b.*\bthen\s*(--.*)?$", RegexOptions.Compiled);
private static readonly Regex RegexForStart = new Regex(@"^\s*for\b.*\bdo\s*(--.*)?$", RegexOptions.Compiled);
private static readonly Regex RegexWhileStart = new Regex(@"^\s*while\b.*\bdo\s*(--.*)?$", RegexOptions.Compiled);
private static readonly Regex RegexDoStart = new Regex(@"^\s*do\s*(--.*)?$", RegexOptions.Compiled);
private static readonly Regex RegexRepeatStart = new Regex(@"^\s*repeat\b.*$", RegexOptions.Compiled);
private static readonly Regex RegexFunctionStart = new Regex(@"^\s*function\b.*$", RegexOptions.Compiled);
private static readonly Regex RegexEndOnly = new Regex(@"^\s*end\b", RegexOptions.Compiled);
private static readonly Regex RegexUntilOnly = new Regex(@"^\s*until\b", RegexOptions.Compiled);
#endregion
#region RuntimeCache
private static readonly Dictionary<string, string> _classNameToPath = new Dictionary<string, string>();
private static readonly Dictionary<string, LuaAstInfo> _pathToAst = new Dictionary<string, LuaAstInfo>();
private static readonly Stopwatch _stopwatch = new Stopwatch();
private static readonly List<string> _unremovedLoopTimerReportLines = new List<string>();
private static readonly List<string> _unremovedNamedTimerReportLines = new List<string>();
private static readonly List<string> _unremovedAnonymousTimerReportLines = new List<string>();
private static bool _cancelCheck;
#endregion
[MenuItem("资源检查/Lua脚本工具/检查Lua脚本是否移除了settimer事件", false, 1000)]
public static void LuaEventCheck()
{
_stopwatch.Restart();
_cancelCheck = false;
_classNameToPath.Clear();
_pathToAst.Clear();
_unremovedLoopTimerReportLines.Clear();
_unremovedNamedTimerReportLines.Clear();
_unremovedAnonymousTimerReportLines.Clear();
EditorUtility.ClearProgressBar();
Dictionary<string, List<string>> fileDict;
int totalCount;
ComputeAllFileAndCount(out fileDict, out totalCount);
foreach (var kv in fileDict)
{
CheckFileGroup(kv.Value, totalCount);
}
ExportUnremovedTimerReport();
EditorUtility.ClearProgressBar();
_stopwatch.Stop();
UnityEngine.Debug.Log($"LuaEventChecker 扫描完成,总耗时 {_stopwatch.ElapsedMilliseconds}ms");
}
#region MainFlow
private static void ComputeAllFileAndCount(out Dictionary<string, List<string>> filesDic, out int totalFileCount)
{
totalFileCount = 0;
filesDic = new Dictionary<string, List<string>>();
foreach (var kv in LuaScriptDirDict)
{
if (!Directory.Exists(kv.Value))
{
UnityEngine.Debug.LogWarning($"目录不存在:{kv.Value}");
continue;
}
var files = Directory.GetFiles(kv.Value, "*.lua", SearchOption.AllDirectories)
.Select(p => p.Replace('\\', '/'))
.ToList();
filesDic.Add(kv.Key, files);
totalFileCount += files.Count;
CacheClassNames(files);
}
}
private static void CheckFileGroup(List<string> files, int totalCount)
{
int checkedCount = 0;
foreach (var path in files)
{
if (_cancelCheck) break;
float progress = (float)checkedCount / totalCount;
bool cancel = EditorUtility.DisplayCancelableProgressBar($"\u68C0\u67E5\u4E2D {checkedCount}/{totalCount}", path.Replace(GetProjectPath(), ""), progress);
if (cancel) { _cancelCheck = true; break; }
CheckSingleLuaFile(path);
checkedCount++;
}
}
public static void CheckSingleLuaFile(string path, int depth = 0)
{
if (_pathToAst.ContainsKey(path) || depth > MaxRecursiveDepth) return;
var ast = new LuaAstInfo(path);
_pathToAst.Add(path, ast);
using var sr = new StreamReader(path, Encoding.UTF8);
string line;
int lineNum = 0;
while ((line = sr.ReadLine()) != null)
{
lineNum++;
if (line.TrimStart().StartsWith("--")) continue;
if (lineNum <= 8) ParseParentClass(line, ast, path, depth);
BuildAST(ast, line, lineNum);
}
// Output check results to both console and txt.
var checkResult = ast.CheckUnRemovedTimer(_pathToAst);
BuildResultMessages(path, checkResult);
}
#endregion
#region AstBuild
private static void ParseParentClass(string line, LuaAstInfo ast, string currentPath, int depth)
{
var match = RegexClass.Match(line);
if (!match.Success) return;
var parents = match.Groups[1].Value.Split(',').Select(s => s.Trim()).Where(s => !string.IsNullOrEmpty(s)).ToList();
foreach (var cls in parents)
{
if (_classNameToPath.TryGetValue(cls, out string parentPath))
{
ast.ParentPaths.Add(parentPath);
CheckSingleLuaFile(parentPath, depth + 1);
}
}
}
private static void BuildAST(LuaAstInfo ast, string line, int lineNum)
{
// Parse function definition and detect destroy scope.
var funcMatch = RegexFuncDef.Match(line);
if (funcMatch.Success)
{
ast.CurrentFunc = funcMatch.Groups[1].Value;
ast.IsInDestroyFunc = DestroyFunctions.Contains(ast.CurrentFunc);
ast.CurrentFuncBlockDepth = 1;
return;
}
// Skip if we are not inside a function body.
if (string.IsNullOrEmpty(ast.CurrentFunc)) return;
// 1) Record settimer callbacks in current function.
if (RegexSetTimer.IsMatch(line))
{
var callbackInfo = ExtractSetTimerCallback(line);
if (callbackInfo != null)
{
ast.AddSetTimerCallback(callbackInfo, lineNum);
}
}
// 2) Record removetimer callbacks in current function.
if (RegexRemoveTimer.IsMatch(line))
{
string cb = ExtractRemoveTimerCallback(line);
if (!string.IsNullOrEmpty(cb)) ast.AddRemovedCallbackInCurrentFunc(cb);
}
// 3) Build function call graph in destroy scope.
if (ast.IsInDestroyFunc)
{
var funcCalls = ExtractAllFuncCalls(line);
foreach (var func in funcCalls)
{
ast.AddDestroyChildFunc(func);
}
}
UpdateFunctionBlockDepth(ast, line);
}
private static void UpdateFunctionBlockDepth(LuaAstInfo ast, string line)
{
if (string.IsNullOrEmpty(ast.CurrentFunc)) return;
int delta = 0;
if (RegexIfStart.IsMatch(line)) delta++;
if (RegexForStart.IsMatch(line)) delta++;
if (RegexWhileStart.IsMatch(line)) delta++;
if (RegexDoStart.IsMatch(line)) delta++;
if (RegexRepeatStart.IsMatch(line)) delta++;
if (RegexFunctionStart.IsMatch(line)) delta++;
if (RegexEndOnly.IsMatch(line)) delta--;
if (RegexUntilOnly.IsMatch(line)) delta--;
ast.CurrentFuncBlockDepth += delta;
if (ast.CurrentFuncBlockDepth <= 0)
{
ast.CurrentFunc = string.Empty;
ast.IsInDestroyFunc = false;
ast.CurrentFuncBlockDepth = 0;
}
}
// Extract callback argument and loop flag from settimer.
private static TimerCallbackInfo ExtractSetTimerCallback(string line)
{
try
{
var argsMatch = Regex.Match(line, @"settimer\s*\((?<args>.*)\)\s*$", RegexOptions.IgnoreCase);
if (!argsMatch.Success) return null;
string argsRaw = argsMatch.Groups["args"].Value;
var args = SplitLuaArgs(argsRaw);
if (args.Count < 2) return null;
string callbackArg = args[1].Trim();
bool isLoop = ParseIsLoopTimer(args);
if (callbackArg.StartsWith("function", StringComparison.OrdinalIgnoreCase))
{
return new TimerCallbackInfo("__anonymous__", true, isLoop);
}
string callbackName = callbackArg.TrimEnd(')').Split('.', ':').Last();
return string.IsNullOrEmpty(callbackName) ? null : new TimerCallbackInfo(callbackName, false, isLoop);
}
catch { return null; }
}
private static bool ParseIsLoopTimer(List<string> args)
{
// Convention: settimer(interval, callback, self) is treated as loop;
// explicit 4th arg true is also loop.
if (args.Count == 3) return true;
if (args.Count >= 4)
{
return string.Equals(args[3].Trim(), "true", StringComparison.OrdinalIgnoreCase);
}
return false;
}
private static List<string> SplitLuaArgs(string input)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(input)) return result;
var sb = new StringBuilder();
int round = 0;
int square = 0;
int curly = 0;
bool inSingleQuote = false;
bool inDoubleQuote = false;
for (int i = 0; i < input.Length; i++)
{
char c = input[i];
char prev = i > 0 ? input[i - 1] : '\0';
if (c == '\'' && !inDoubleQuote && prev != '\\') inSingleQuote = !inSingleQuote;
else if (c == '"' && !inSingleQuote && prev != '\\') inDoubleQuote = !inDoubleQuote;
if (!inSingleQuote && !inDoubleQuote)
{
if (c == '(') round++;
else if (c == ')') round--;
else if (c == '[') square++;
else if (c == ']') square--;
else if (c == '{') curly++;
else if (c == '}') curly--;
if (c == ',' && round == 0 && square == 0 && curly == 0)
{
result.Add(sb.ToString().Trim());
sb.Clear();
continue;
}
}
sb.Append(c);
}
if (sb.Length > 0) result.Add(sb.ToString().Trim());
return result;
}
// Extract callback argument from removetimer.
private static string ExtractRemoveTimerCallback(string line)
{
try
{
var match = Regex.Match(line, @"removetimer\s*\(\s*(?<callback>[^,\)]+)", RegexOptions.IgnoreCase);
if (!match.Success) return null;
return match.Groups["callback"].Value.Trim().Split('.', ':').Last();
}
catch { return null; }
}
private static void ExportUnremovedTimerReport()
{
string exportPath = Directory.GetCurrentDirectory() + "\\EditorExport\\setTimer未移除回调列表.txt";
var reportLines = new List<string>();
AppendReportSection(reportLines, "【第一种】未移除的循环方法,以及循环匿名方法", _unremovedLoopTimerReportLines);
AppendReportSection(reportLines, "【第二种】未移除的方法", _unremovedNamedTimerReportLines);
AppendReportSection(reportLines, "【第三种】未移除的匿名方法", _unremovedAnonymousTimerReportLines);
if (reportLines.Count == 0)
{
File.WriteAllText(exportPath, "未检测到 settimer 未移除回调。", Encoding.UTF8);
}
else
{
File.WriteAllLines(exportPath, reportLines, Encoding.UTF8);
}
UnityEngine.Debug.Log($"settimer 未移除回调报告已导出:{exportPath}");
}
// Extract function calls from one line for call graph construction.
private static List<string> ExtractAllFuncCalls(string line)
{
List<string> calls = new List<string>();
int idx = 0;
while ((idx = line.IndexOf('(', idx)) != -1)
{
int start = idx - 1;
while (start >= 0 && (char.IsLetterOrDigit(line[start]) || line[start] == '_')) start--;
if (start + 1 < idx) calls.Add(line.Substring(start + 1, idx - start - 1));
idx++;
}
return calls;
}
private static string GetProjectPath() => Application.dataPath.Replace("Assets", "");
private static void CacheClassNames(List<string> files)
{
foreach (var p in files)
{
string name = Path.GetFileNameWithoutExtension(p).Trim();
if (!_classNameToPath.ContainsKey(name)) _classNameToPath.Add(name, p);
}
}
private static void AppendReportSection(List<string> reportLines, string title, List<string> sectionLines)
{
if (!sectionLines.Any()) return;
reportLines.Add(title);
reportLines.AddRange(sectionLines);
reportLines.Add(string.Empty);
}
private static void BuildResultMessages(string path, TimerCheckResult checkResult)
{
var relativePath = path.Replace(GetProjectPath(), "");
if (checkResult.UnremovedLoopNamedCallbacks.Any())
{
string line = $"[{relativePath}] 未移除的循环方法:{string.Join(", ", checkResult.UnremovedLoopNamedCallbacks)}";
UnityEngine.Debug.LogError(line);
_unremovedLoopTimerReportLines.Add(line);
}
if (checkResult.UnremovedLoopAnonymousTimerRecords.Any())
{
string line = $"[{relativePath}] 未移除的循环匿名方法位置:{string.Join("; ", checkResult.UnremovedLoopAnonymousTimerRecords)}";
UnityEngine.Debug.LogError(line);
_unremovedLoopTimerReportLines.Add(line);
}
if (checkResult.UnremovedNamedCallbacks.Any())
{
string line = $"[{relativePath}] 未移除的命名回调:{string.Join(", ", checkResult.UnremovedNamedCallbacks)}";
UnityEngine.Debug.LogError(line);
_unremovedNamedTimerReportLines.Add(line);
}
if (checkResult.AnonymousTimerRecords.Any())
{
// Anonymous callbacks must be persisted to txt as well for manual review.
string line = $"[{relativePath}] 匿名方法(未注销)位置:{string.Join("; ", checkResult.AnonymousTimerRecords)}";
UnityEngine.Debug.LogWarning(line);
_unremovedAnonymousTimerReportLines.Add(line);
}
}
#endregion
#region AstData
public class LuaAstInfo
{
public string Path { get; }
public List<string> ParentPaths { get; } = new List<string>();
private readonly Dictionary<string, List<string>> _namedSetTimerCallbackRecords = new Dictionary<string, List<string>>();
private readonly Dictionary<string, List<string>> _loopNamedSetTimerCallbackRecords = new Dictionary<string, List<string>>();
private readonly List<string> _anonymousTimerRecords = new List<string>();
private readonly List<string> _loopAnonymousTimerRecords = new List<string>();
// removetimer callbacks by function + function call graph in destroy scope.
private readonly Dictionary<string, HashSet<string>> _funcRemovedCallbacks = new Dictionary<string, HashSet<string>>();
private readonly Dictionary<string, HashSet<string>> _funcCallTree = new Dictionary<string, HashSet<string>>();
public string CurrentFunc { get; set; } = string.Empty;
public bool IsInDestroyFunc { get; set; } = false;
public int CurrentFuncBlockDepth { get; set; } = 0;
public LuaAstInfo(string path) => Path = path;
public void AddSetTimerCallback(TimerCallbackInfo callbackInfo, int lineNum)
{
if (callbackInfo.IsAnonymous)
{
if (callbackInfo.IsLoop)
{
_loopAnonymousTimerRecords.Add($"{CurrentFunc}:L{lineNum}");
}
else
{
_anonymousTimerRecords.Add($"{CurrentFunc}:L{lineNum}");
}
return;
}
string record = $"{CurrentFunc}:L{lineNum}";
if (callbackInfo.IsLoop)
{
AddNamedCallbackRecord(_loopNamedSetTimerCallbackRecords, callbackInfo.Name, record);
}
else
{
AddNamedCallbackRecord(_namedSetTimerCallbackRecords, callbackInfo.Name, record);
}
}
private static void AddNamedCallbackRecord(Dictionary<string, List<string>> records, string callbackName, string locationRecord)
{
if (!records.TryGetValue(callbackName, out var list))
{
list = new List<string>();
records[callbackName] = list;
}
list.Add(locationRecord);
}
// Record removetimer callback in current function.
public void AddRemovedCallbackInCurrentFunc(string cb)
{
if (string.IsNullOrEmpty(CurrentFunc)) return;
if (!_funcRemovedCallbacks.TryGetValue(CurrentFunc, out var removedSet))
{
removedSet = new HashSet<string>();
_funcRemovedCallbacks[CurrentFunc] = removedSet;
}
removedSet.Add(cb);
}
// Record function call relation in destroy scope.
public void AddDestroyChildFunc(string childFunc)
{
if (!_funcCallTree.ContainsKey(CurrentFunc)) _funcCallTree[CurrentFunc] = new HashSet<string>();
_funcCallTree[CurrentFunc].Add(childFunc);
}
// Collect all callbacks considered as removed in current class.
private HashSet<string> GetAllValidRemoved()
{
HashSet<string> result = new HashSet<string>();
HashSet<string> visited = new HashSet<string>();
foreach (var root in LuaEventChecker.DestroyFunctions)
{
RecursiveCollect(root, result, visited, 0);
}
return result;
}
private void RecursiveCollect(string func, HashSet<string> result, HashSet<string> visited, int depth)
{
if (depth > MaxRecursiveDepth || visited.Contains(func)) return;
visited.Add(func);
if (_funcRemovedCallbacks.TryGetValue(func, out var removedSet))
{
foreach (var cb in removedSet) result.Add(cb);
}
// DFS through destroy-function call graph.
if (_funcCallTree.TryGetValue(func, out var children))
{
foreach (var child in children) RecursiveCollect(child, result, visited, depth + 1);
}
}
// Check unremoved settimer callbacks, including inherited destroy chains.
public TimerCheckResult CheckUnRemovedTimer(Dictionary<string, LuaAstInfo> astDict)
{
var allRemoved = GetAllValidRemoved();
// Include removed callbacks from parent classes.
foreach (var parentPath in ParentPaths)
{
if (astDict.TryGetValue(parentPath, out var parent))
{
foreach (var cb in parent.GetAllValidRemoved()) allRemoved.Add(cb);
}
}
var unremovedLoopNamedRecords = _loopNamedSetTimerCallbackRecords
.Where(kv => !allRemoved.Contains(kv.Key))
.Select(kv => $"{kv.Key}({string.Join("/", kv.Value.Distinct())})")
.ToList();
var unremovedNamedRecords = _namedSetTimerCallbackRecords
.Where(kv => !allRemoved.Contains(kv.Key))
.Select(kv => $"{kv.Key}({string.Join("/", kv.Value.Distinct())})")
.ToList();
return new TimerCheckResult(
unremovedLoopNamedRecords,
new List<string>(_loopAnonymousTimerRecords),
unremovedNamedRecords,
new List<string>(_anonymousTimerRecords)
);
}
}
public class TimerCallbackInfo
{
public string Name { get; }
public bool IsAnonymous { get; }
public bool IsLoop { get; }
public TimerCallbackInfo(string name, bool isAnonymous, bool isLoop)
{
Name = name;
IsAnonymous = isAnonymous;
IsLoop = isLoop;
}
}
public class TimerCheckResult
{
public List<string> UnremovedLoopNamedCallbacks { get; }
public List<string> UnremovedLoopAnonymousTimerRecords { get; }
public List<string> UnremovedNamedCallbacks { get; }
public List<string> AnonymousTimerRecords { get; }
public TimerCheckResult(List<string> unremovedLoopNamedCallbacks, List<string> unremovedLoopAnonymousTimerRecords, List<string> unremovedNamedCallbacks, List<string> anonymousTimerRecords)
{
UnremovedLoopNamedCallbacks = unremovedLoopNamedCallbacks ?? new List<string>();
UnremovedLoopAnonymousTimerRecords = unremovedLoopAnonymousTimerRecords ?? new List<string>();
UnremovedNamedCallbacks = unremovedNamedCallbacks ?? new List<string>();
AnonymousTimerRecords = anonymousTimerRecords ?? new List<string>();
}
}
#endregion
}
核心逻辑很简单,就是逐Lua文件构造AST,剩下的就是通过正则表达式来检测一行代码是否匹配了。
对于一个Lua文件:
前八行会尝试识别继承的父类,并把父类文件也递归解析AST。
BuildAST识别函数定义、settimer、removetime、退出函数内调用关系。
文件结束后执行CheckUnRemovedTimer产出结果。
工具还有区分是否匿名函数,一般来说,匿名函数是不会移除的,这部分大概率会有问题。
工具还会区分是否是循环触发的回调,并且会在输出的txt文件中放到最前,这种情况最有可能出问题。
4、优缺点
这个工具能检测出上述的所有情况,但部分特殊情况还是无法检测。例如,定义了一个类,其退出回调用的是自定义的方法,例如方法名叫quit,然后在使用到这个类的类中的onExit方法中调用这个类的quit方法,这种情况就无法检测,当然,可以继续扩展DestroyFunctions中的方法名,使其适配更多的退出函数名。
另外,工具待补充白名单逻辑,部分特殊写法的Lua脚本可能会被检测到,但实际上是没有问题的,这种情况应该加一个白名单。