背景:
之前做游戏的时候和同组的同事聊过说日志过滤尽量不要限制大家怎么使用日志打印的接口,不要加额外的参数,比如多加一个标签string,或者使用特定的接口,枚举。最好就是日志大家还是用Debug.Log无感去用,然后通过勾选一些toggle去赛选你感兴趣的日志。
原理:
通过分析堆栈,判断当前日志所属哪一个模块,具体属于哪一个模块可以通过自定义模块路径配置来解决,比如LogFilter.json
如果你的日志来源于路径manager/net那么该日志属于网络模块,勾选网络则只打印网络日志,当然可以同时勾选多个日志,这取决于你上面配置了多少json数组。另外当你勾选了一些toggle之后如果希望保存,比如你只关心你的模块但是不想每次都去勾选一堆的toggle,那么点击Save tag可以保存你的修改 ,对应的配置路径在
其json格式为:
运行截图:
核心类:
#if DebugMod
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using HotFix.Manager;
using HotFix.UtilTool;
using Newtonsoft.Json;
using UnityEngine;
namespace HotFix.Helper.LogHelper
{
public class LogHelper : SingletonMono<LogHelper>
{
public class Log
{
public enum _LogType
{
Assert = LogType.Assert,
Error = LogType.Error,
Exception = LogType.Exception,
Log = LogType.Log,
Warning = LogType.Warning,
}
public _LogType logType;
public string condition;
public string stacktrace;
public string time;
public string moduleName;
public override string ToString()
{
return time + condition + "\n" + stacktrace;
}
public Log CreateCopy()
{
return (Log)this.MemberwiseClone();
}
public float GetMemoryUsage()
{
return (float)(sizeof(int) +
sizeof(_LogType) +
condition.Length * sizeof(char) +
stacktrace.Length * sizeof(char) +
sizeof(int));
}
}
public List<Log> logs = new();
public Dictionary<string, string> fileModule = new();
private Dictionary<string, HashSet<string>> customModuleMap;
public Dictionary<string, HashSet<string>> CustomModuleMap
{
get
{
#if UNITY_EDITOR
if(customModuleMap == null)
{
var sourceFile = proRoot + "/environment/LogFilter.json";
if (File.Exists(sourceFile))
{
if(File.Exists(persistentDataPath + "/LogFilter.json"))
{
File.Delete(persistentDataPath + "/LogFilter.json");
}
File.Copy(sourceFile, persistentDataPath + "/LogFilter.json");
}
}
#endif
if(customModuleMap == null && File.Exists(persistentDataPath + "/LogFilter.json"))
{
customModuleMap = JsonConvert.DeserializeObject<Dictionary<string, HashSet<string>>>(File.ReadAllText(persistentDataPath + "/LogFilter.json"));
}
return customModuleMap;
}
}
public Regex pattern = new Regex(@"\bat Assets/([^:]+)\.cs:\d+\b", RegexOptions.Compiled);
public string persistentDataPath = "";
public string proRoot;
public override void Init()
{
#if UNITY_EDITOR // debug模式不监听 因为需要正则处理堆栈信息
Application.logMessageReceivedThreaded -= CaptureLogThread;
Application.logMessageReceivedThreaded += CaptureLogThread;
#endif
persistentDataPath = Application.persistentDataPath;
proRoot = Environment.CurrentDirectory;
}
string GetModuleName(string path, int level = 2)
{
if(CustomModuleMap != null && CustomModuleMap.Count > 0)
{
string matchKey = customModuleMap.FirstOrDefault(pair => pair.Value.Any(value => path.StartsWith(value))).Key;
if(matchKey != null)
{
return matchKey;
}
}
return "other";
//int index = 0;
//int lastIndex = 0;
//int runLevel = 0;
//for (int i = path.Length - 1; i >= 0; --i)
//{
// if (path[i] == '/')
// {
// lastIndex = index;
// index = i;
// ++runLevel;
// if (runLevel == level)
// {
// break;
// }
// }
//}
//int length = Math.Abs(index - lastIndex) - 1;
//length = Math.Max(0, length);
//var moduleName = path.Substring(index + 1, length);
//return moduleName;
}
void CaptureLogThread(string condition, string stacktrace, LogType type)
{
var arr = stacktrace.Split("\n");
string dir = null;
string csName = "";
string moduleName = "";
//逐行分析 日志
for (int i = 0; i < arr.Length; i++)
{
//有些日志属于自定义日志 或者封装的日志接口 这种需要过滤堆栈
if (arr[i].StartsWith("HotFix.UtilTool.CommonUtils"))
{
continue;
}
if (pattern.IsMatch(arr[i]))
{
var group = pattern.Match(arr[i]);
dir = group.Groups[1].Value;
csName = dir.Substring(dir.LastIndexOf("/") + 1);
lock(fileModule)
{
if (!fileModule.ContainsKey(csName))
{
moduleName = GetModuleName(dir,2);
fileModule[csName] = moduleName;
}
else
{
moduleName = fileModule[csName];
}
}
Log log = new Log() { condition = condition, stacktrace = stacktrace, logType = (Log._LogType)type ,moduleName = moduleName,time = $"[{DateTime.Now.ToString("HH:mm:ss:fff")}]" };
lock (logs)
{
logs.Add(log);
}
break;
}
}
}
}
}
#endif
核心gui类:
部分接口请参考github实现。
#if DebugMod
using UnityEditor;
using UnityEngine;
using System.Collections.Generic;
using HotFix.Helper.LogHelper;
using Newtonsoft.Json;
using HotFix.UtilTool;
using System.IO;
using System.Text;
//作为loghelper的显示窗口 不打算做到包体内部
// 先在编辑器上测试
public class LogHelperWindow : EditorWindow
{
public class ShowControlInfo
{
public ShowControlInfo()
{
isShowLog = false;
Color = new Color(1, 1, 1, 1);
showDebugTrace = false;
}
public bool showDebugTrace; // 是否显示堆栈
public int color_r = 255;
public int color_g = 255;
public int color_b = 255;
[JsonIgnore]
public Color _color;
[JsonIgnore]
private bool isColorCtor = false;
[JsonIgnore]
public Color Color
{
set
{
_color = value;
color_r = UnityEngine.Mathf.CeilToInt( _color.r * 255);
color_g = UnityEngine.Mathf.CeilToInt(_color.g * 255);
color_b = UnityEngine.Mathf.CeilToInt(_color.b * 255);
}
get
{
if(!isColorCtor)
{
isColorCtor = true;
_color = new Color(color_r / 255f, color_g / 255f, color_b / 255f, 1f);
}
return _color;
}
}
public bool isShowLog;
}
// 滚动视图的位置
private Vector2 scrollPosition;
private bool isShow = false;
public Dictionary<string, ShowControlInfo> allTags = new Dictionary<string, ShowControlInfo>();
private bool isAllSelected = false; // 全选状态
private Vector2 tagScrollPosition; // 标签滚动视图位置
private bool isInited = false;
private GUIStyle customLogStyle;
// 绘制错误图标
private Texture2D errorIcon;
[InitializeOnLoadMethod]
static void Initialize()
{
if( EditorPrefs.GetBool("customlog"))
{
// 订阅播放模式状态改变事件
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
}
}
private static void OnPlayModeStateChanged(PlayModeStateChange state)
{
if (state == PlayModeStateChange.EnteredPlayMode)
{
// 当进入播放模式时,打开自定义控制台窗口
ShowWindow();
}
else if(state == PlayModeStateChange.ExitingPlayMode)
{
CloseWindow();
}
}
public static void ShowWindow()
{
// 获取或创建自定义控制台窗口实例
LogHelperWindow window = GetWindow<LogHelperWindow>(false,"Custom Console",true);
window.Show();
window.OnShow();
}
public static void CloseWindow()
{
LogHelperWindow window = GetWindow<LogHelperWindow>(false, "Custom Console", true);
window.OnRelease();
}
private void OnShow()
{
isShow = true;
}
private void OnRelease()
{
isShow = false;
isInited = false;
}
// 绘制窗口内容
private void OnGUI()
{
if (!Application.isPlaying || !isShow) return;
if(!isInited)
{
isAllSelected = false;
isInited = true;
errorIcon = EditorGUIUtility.Load("icons/d_console.erroricon.sml.png") as Texture2D;
if (errorIcon == null)
{
errorIcon = new Texture2D(16, 16);
Color[] pixels = new Color[16 * 16];
for (int i = 0; i < pixels.Length; i++)
{
pixels[i] = Color.red; // 红色感叹号
}
errorIcon.SetPixels(pixels);
errorIcon.Apply();
}
customLogStyle = new GUIStyle(EditorStyles.label);
// 设置选中时的背景颜色
Texture2D selectedBackground = new Texture2D(1, 1);
selectedBackground.SetPixel(0, 0, Color.blue); // 这里将选中背景颜色设为红色
selectedBackground.Apply();
//customLogStyle.normal.background = selectedBackground;
customLogStyle.onFocused.background = selectedBackground;
ReadTagConfig();
}
// --- 整体横向布局 ----
EditorGUILayout.BeginHorizontal(GUILayout.ExpandHeight(true));
// ----------- 左侧:日志滚动区(自动填充剩余空间) ------------
scrollPosition = EditorGUILayout.BeginScrollView(
scrollPosition,
GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)
);
{
var logs = LogHelper.Instance.logs;
foreach (var item in LogHelper.Instance.fileModule.Values)
{
if (!allTags.ContainsKey(item))
allTags.Add(item, new ShowControlInfo());
}
Color consoleColor = GUI.color;
foreach (var log in logs)
{
var tagInfo = allTags[log.moduleName];
if (!tagInfo.isShowLog) continue;
GUI.color = tagInfo.Color;
EditorGUILayout.BeginVertical("box");
string content = "";
if (tagInfo.showDebugTrace)
{
content = $"{log.time}{log.condition}\n{log.stacktrace}";
}
else
{
content = $"{log.time}{log.condition}";
}
if(log.logType == LogHelper.Log._LogType.Error)
{
EditorGUILayout.BeginHorizontal();
{
GUILayout.Label(new GUIContent(errorIcon), GUILayout.Width(16), GUILayout.Height(16)); // 显示图标
var height = Mathf.Max(20, GUI.skin.label.CalcHeight(new GUIContent(content), position.width - 240)); //留出右侧宽度
EditorGUILayout.SelectableLabel(content, customLogStyle, GUILayout.Height(height));
EditorGUILayout.EndHorizontal();
}
}
else
{
var height = Mathf.Max(20, GUI.skin.label.CalcHeight(new GUIContent(content), position.width - 240)); //留出右侧宽度
EditorGUILayout.SelectableLabel(content, customLogStyle, GUILayout.Height(height));
}
EditorGUILayout.EndVertical();
}
GUI.color = consoleColor;
EditorGUILayout.EndScrollView();
}
// ----------- 右侧:标签筛选区(固定宽度,竖直铺满) ------------
EditorGUILayout.BeginVertical("box", GUILayout.Width(400), GUILayout.ExpandHeight(true));
{
EditorGUILayout.LabelField("标签筛选", EditorStyles.boldLabel);
// 标签滚动部分-竖直铺满
tagScrollPosition = EditorGUILayout.BeginScrollView(tagScrollPosition, GUILayout.ExpandHeight(true));
{
foreach (var tag in allTags)
{
GUILayout.BeginHorizontal();
{
bool newValue = GUILayout.Toggle(tag.Value.isShowLog,tag.Key);
if (newValue != tag.Value.isShowLog)
allTags[tag.Key].isShowLog = newValue;
GUILayout.Space(5);
bool showTrace = GUILayout.Toggle(tag.Value.showDebugTrace,"堆栈" );
if (showTrace != tag.Value.showDebugTrace)
allTags[tag.Key].showDebugTrace = showTrace;
GUILayout.Space(5);
GUILayout.Label("颜色", GUILayout.Width(40));
Color newColor = EditorGUILayout.ColorField(tag.Value.Color, GUILayout.Width(60));
if (newColor != tag.Value.Color)
tag.Value.Color = newColor;
GUILayout.EndHorizontal();
}
}
}
EditorGUILayout.EndScrollView();
// 全选/取消全选
EditorGUILayout.Space();
if (GUILayout.Button(isAllSelected ? "取消全选" : "全选"))
{
isAllSelected = !isAllSelected;
var keys = new List<string>(allTags.Keys);
foreach (var key in keys)
allTags[key].isShowLog = isAllSelected;
}
}
EditorGUILayout.EndVertical();
// --- End横向大布局
EditorGUILayout.EndHorizontal();
// ------ 下方操作按钮区(单独一行) ------
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("save tag"))
SaveLogTag();
if (GUILayout.Button("copy log"))
CopyLog();
if (GUILayout.Button("Clear Logs"))
ClearLogs();
EditorGUILayout.EndHorizontal();
}
private void ReadTagConfig()
{
allTags?.Clear();
var path = tagPath;
path = CommonUtils.GetLinuxPath(path);
var content = File.ReadAllText(path);
allTags = JsonConvert.DeserializeObject<Dictionary<string, ShowControlInfo>>(content);
}
void CopyLog()
{
StringBuilder sb = new();
var logs = LogHelper.Instance.logs;
foreach(var log in logs)
{
if(allTags[log.moduleName].isShowLog)
{
sb.AppendLine(log.ToString());
}
}
GUIUtility.systemCopyBuffer = sb.ToString();
}
private string tagPath = System.Environment.CurrentDirectory + "/environment/log.json";
private void SaveLogTag()
{
var settings = new JsonSerializerSettings
{
Formatting = Formatting.Indented, // 格式化输出,使生成的 JSON 有缩进
};
var json = JsonConvert.SerializeObject(allTags, settings);
var path = tagPath;
path = CommonUtils.GetLinuxPath(path);
EasyUseEditorFuns.CreateDir(path);
File.WriteAllText(path, json);
this.ShowNotification(new GUIContent($"保存日志tag成功{path}"));
}
// 清空日志的方法
private void ClearLogs()
{
LogHelper.Instance.logs?.Clear();
}
}
#endif
提示:
不一定所有人都用得上这个功能,在接入的时候需要考虑开关,比如EditorPrefs 存储一个开关的key。例如参考以下截图: