using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEngine; /// /// Unity Editor 工具:识别并清理项目中没有对应资源文件的孤立 .meta 文件。 /// /// 功能: /// - 扫描 Assets 目录下所有 .meta 文件 /// - 检查每个 .meta 文件对应的资源是否存在 /// - 列出所有孤立的 .meta 文件 /// - 支持一键删除所有孤立 .meta 文件 /// - 也可通过 Assets 右键菜单对选中文件夹进行扫描 /// /// 菜单位置: Tools/Ichni/Clean Unused Meta Files /// 右键菜单: Assets/Clean Unused Meta Files (in folder) /// public static class MetaFileCleaner { private const string MENU_ROOT = "Tools/Ichni/Clean Unused Meta Files"; private const string ASSETS_CONTEXT_MENU = "Assets/Clean Unused Meta Files"; /// /// 扫描整个 Assets 目录 /// [MenuItem(MENU_ROOT)] private static void CleanUnusedMetaFiles() { string assetsPath = Application.dataPath; ScanAndCleanMetaFiles(assetsPath, "Entire Assets"); } /// /// 在项目窗口(Project窗口)中选中文件夹时,右键菜单项 /// [MenuItem(ASSETS_CONTEXT_MENU)] private static void CleanUnusedMetaFilesFromSelection() { // 获取当前选中的资源路径(相对路径) string selectedPath = AssetDatabase.GetAssetPath(Selection.activeObject); if (string.IsNullOrEmpty(selectedPath)) { EditorUtility.DisplayDialog("No Selection", "Please select a folder in the Project window.", "OK"); return; } string fullPath = Path.GetFullPath(selectedPath); if (!Directory.Exists(fullPath)) { EditorUtility.DisplayDialog("Not a Folder", "Please select a folder, not a file.", "OK"); return; } ScanAndCleanMetaFiles(fullPath, selectedPath); } /// /// 验证右键菜单是否可用:仅当选中的是文件夹时启用 /// [MenuItem(ASSETS_CONTEXT_MENU, true)] private static bool ValidateCleanUnusedMetaFilesFromSelection() { string selectedPath = AssetDatabase.GetAssetPath(Selection.activeObject); if (string.IsNullOrEmpty(selectedPath)) return false; string fullPath = Path.GetFullPath(selectedPath); return Directory.Exists(fullPath); } /// /// 核心扫描与清理逻辑 /// private static void ScanAndCleanMetaFiles(string scanDirectory, string displayName) { // 收集所有 .meta 文件 string[] allMetaFiles = Directory.GetFiles(scanDirectory, "*.meta", SearchOption.AllDirectories); List orphanedMetaFiles = new List(); // 进度条 for (int i = 0; i < allMetaFiles.Length; i++) { string metaFile = allMetaFiles[i]; if (EditorUtility.DisplayCancelableProgressBar( "Scanning .meta files", $"Checking: {metaFile}", (float)i / allMetaFiles.Length)) { EditorUtility.ClearProgressBar(); Debug.LogWarning("[MetaFileCleaner] Scan cancelled by user."); return; } // .meta 文件对应的原始资源路径 // 例如: "Assets/Textures/foo.png.meta" → "Assets/Textures/foo.png" string assetPath = metaFile.Substring(0, metaFile.Length - ".meta".Length); // 检查原始资源是否存在(文件或目录) if (!File.Exists(assetPath) && !Directory.Exists(assetPath)) { orphanedMetaFiles.Add(metaFile); } } EditorUtility.ClearProgressBar(); // 显示结果 if (orphanedMetaFiles.Count == 0) { EditorUtility.DisplayDialog( "Clean Unused Meta Files", $"Scan completed: {displayName}\n\nNo orphaned .meta files found. ({allMetaFiles.Length} total .meta files checked)", "OK"); return; } // 有孤立文件,显示详细窗口 OrphanedMetaWindow.ShowWindow(orphanedMetaFiles, displayName); } #region Orphaned Meta Window private class OrphanedMetaWindow : EditorWindow { private List orphanedFiles; private Vector2 scrollPos; private string displayName; private bool[] selected; public static void ShowWindow(List orphanedFiles, string displayName) { OrphanedMetaWindow window = GetWindow(true, "Orphaned .meta Files", true); window.orphanedFiles = orphanedFiles; window.displayName = displayName; window.selected = new bool[orphanedFiles.Count]; for (int i = 0; i < window.selected.Length; i++) window.selected[i] = true; // 默认全选 window.minSize = new Vector2(600, 300); window.Show(); } private void OnGUI() { GUILayout.BeginVertical(EditorStyles.helpBox); GUILayout.Label($"Orphaned .meta files found in: {displayName}", EditorStyles.boldLabel); GUILayout.Label($"Total: {orphanedFiles.Count} orphaned .meta files (out of all files scanned)", EditorStyles.miniLabel); GUILayout.Space(4); // 统计选中数量 int selectedCount = selected.Count(s => s); using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Select All", GUILayout.Width(100))) { for (int i = 0; i < selected.Length; i++) selected[i] = true; } if (GUILayout.Button("Deselect All", GUILayout.Width(100))) { for (int i = 0; i < selected.Length; i++) selected[i] = false; } GUILayout.FlexibleSpace(); GUI.color = new Color(0.8f, 0.2f, 0.2f); if (GUILayout.Button($"Delete Selected ({selectedCount})", GUILayout.Width(180))) { ConfirmAndDelete(); } GUI.color = Color.white; if (GUILayout.Button("Cancel", GUILayout.Width(80))) { Close(); } } GUILayout.EndVertical(); GUILayout.Space(8); // 文件列表 scrollPos = EditorGUILayout.BeginScrollView(scrollPos); for (int i = 0; i < orphanedFiles.Count; i++) { string relativePath = GetRelativePath(orphanedFiles[i]); selected[i] = EditorGUILayout.ToggleLeft(relativePath, selected[i]); } EditorGUILayout.EndScrollView(); GUILayout.Space(4); GUILayout.Label("Tip: Hover over an item to see full path in tooltip", EditorStyles.miniLabel); } private void ConfirmAndDelete() { List toDelete = new List(); for (int i = 0; i < orphanedFiles.Count; i++) { if (selected[i]) toDelete.Add(orphanedFiles[i]); } if (toDelete.Count == 0) { EditorUtility.DisplayDialog("Nothing Selected", "Please select at least one file to delete.", "OK"); return; } // 计算可读的文件大小总计 long totalBytes = 0; foreach (string file in toDelete) { try { totalBytes += new FileInfo(file).Length; } catch { /* skip */ } } string sizeStr = FormatBytes(totalBytes); bool confirm = EditorUtility.DisplayDialog( "Confirm Delete", $"Are you sure you want to delete {toDelete.Count} orphaned .meta file(s)?\n\n" + $"Total size: {sizeStr}\n\n" + "This operation cannot be undone. It is recommended to have a backup.\n\n" + "Note: If these .meta files are related to assets you plan to restore, " + "deleting them may cause GUID conflicts. Only proceed if you are sure the " + "original assets are no longer needed.", "Delete", "Cancel"); if (!confirm) return; DeleteFiles(toDelete); } private void DeleteFiles(List files) { int deleted = 0; int failed = 0; long recoveredBytes = 0; for (int i = 0; i < files.Count; i++) { string file = files[i]; if (EditorUtility.DisplayCancelableProgressBar( "Deleting orphaned .meta files", $"Deleting: {GetRelativePath(file)} ({i + 1}/{files.Count})", (float)i / files.Count)) { EditorUtility.ClearProgressBar(); Debug.LogWarning($"[MetaFileCleaner] Deletion cancelled after {deleted} files deleted."); break; } try { FileInfo fi = new FileInfo(file); long size = fi.Length; File.Delete(file); // 同时删除 .meta 对应的资源文件(如果存在空的残留文件) string assetPath = file.Substring(0, file.Length - ".meta".Length); if (File.Exists(assetPath) && new FileInfo(assetPath).Length == 0) { File.Delete(assetPath); Debug.Log($"[MetaFileCleaner] Also deleted empty residual file: {assetPath}"); } deleted++; recoveredBytes += size; } catch (System.Exception ex) { Debug.LogError($"[MetaFileCleaner] Failed to delete: {file}\n{ex.Message}"); failed++; } } EditorUtility.ClearProgressBar(); AssetDatabase.Refresh(); string summary = $"Deleted: {deleted} file(s).\n" + $"Failed: {failed} file(s).\n" + $"Space recovered: {FormatBytes(recoveredBytes)}"; Debug.Log($"[MetaFileCleaner] Cleanup complete.\n{summary}"); EditorUtility.DisplayDialog("Cleanup Complete", summary, "OK"); Close(); } private string GetRelativePath(string fullPath) { string assetsIndex = "Assets"; int idx = fullPath.IndexOf(assetsIndex); if (idx >= 0) return fullPath.Substring(idx); return fullPath; } } #endregion #region Utilities /// /// 可读的文件大小格式化 /// private static string FormatBytes(long bytes) { if (bytes < 1024) return $"{bytes} B"; if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB"; return $"{bytes / (1024.0 * 1024.0):F2} MB"; } #endregion }