using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; using UnityEditor; using UnityEngine; namespace ProjSG.Editor.UI { public class AssetDuplicateCheck : EditorWindow { private Vector2 scrollPosition; private bool showSettings = true; private bool showResults = false; private bool showFullPath = false; private string[] searchFolders = new string[] { "Assets" }; private string[] searchExtensions = new string[] { ".png", ".jpg", ".jpeg", ".tga", ".psd", ".tif", ".tiff" }; private bool includeSubfolders = true; private bool compareByContent = true; private bool compareByName = false; private int minFileSize = 10; private Dictionary> duplicateGroups = new Dictionary>(); private int totalFilesScanned = 0; private int totalDuplicatesFound = 0; private long totalSpaceSaved = 0; private bool keepNewestFiles = true; private bool createBackup = true; private string backupFolder = ""; private Dictionary selectedForRemoval = new Dictionary(); private bool isProcessing = false; private float progress = 0f; private string progressInfo = ""; [MenuItem("工具/资源管理/资源查重工具")] public static void ShowWindow() { AssetDuplicateCheck window = GetWindow("资源查重工具"); window.minSize = new Vector2(600, 400); window.Show(); } private string GetAssetPath(string filePath) { string fullPath = Path.GetFullPath(filePath); string projectDataPath = Path.GetFullPath(Application.dataPath); if (fullPath.StartsWith(projectDataPath, StringComparison.OrdinalIgnoreCase)) { return "Assets" + fullPath.Substring(projectDataPath.Length).Replace('\\', '/'); } string projectRootPath = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); if (fullPath.StartsWith(projectRootPath, StringComparison.OrdinalIgnoreCase)) { string relativeToProjectRoot = fullPath.Substring(projectRootPath.Length); if (relativeToProjectRoot.StartsWith("/") || relativeToProjectRoot.StartsWith("\\")) { relativeToProjectRoot = relativeToProjectRoot.Substring(1); } return relativeToProjectRoot.Replace('\\', '/'); } Debug.LogWarning($"路径 '{filePath}' 无法可靠地转换为项目相对的资产路径。将返回原始路径(已规范化斜杠)。"); return filePath.Replace('\\', '/'); } private void OnGUI() { GUILayout.Label("资源查重工具", EditorStyles.boldLabel); EditorGUILayout.Space(); scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); showSettings = EditorGUILayout.Foldout(showSettings, "搜索设置", true); if (showSettings) { DrawSettings(); } EditorGUILayout.Space(); if (duplicateGroups.Count > 0) { showResults = EditorGUILayout.Foldout(showResults, $"搜索结果 (找到 {totalDuplicatesFound} 个重复资源,可节省 {totalSpaceSaved / 1024f:F2} MB)", true); if (showResults) { DrawResults(); } } EditorGUILayout.EndScrollView(); EditorGUILayout.Space(); if (isProcessing) { EditorGUI.ProgressBar(EditorGUILayout.GetControlRect(false, 20f), progress, progressInfo); if (GUILayout.Button("取消")) { isProcessing = false; } } else { EditorGUILayout.BeginHorizontal(); if (GUILayout.Button("扫描重复资源", GUILayout.Height(30))) { FindDuplicateAssets(); } GUI.enabled = duplicateGroups.Count > 0; if (GUILayout.Button("处理选中的重复资源", GUILayout.Height(30))) { ProcessDuplicateAssets(); } GUI.enabled = true; EditorGUILayout.EndHorizontal(); } } private void DrawSettings() { EditorGUI.indentLevel++; EditorGUILayout.LabelField("搜索文件夹:"); EditorGUILayout.BeginHorizontal(); string displaySearchFolder = searchFolders.Length > 0 ? searchFolders[0] : "Assets"; EditorGUILayout.SelectableLabel(displaySearchFolder, EditorStyles.textField, GUILayout.Height(EditorGUIUtility.singleLineHeight)); if (GUILayout.Button("选择文件夹", GUILayout.Width(100))) { string folderPath = EditorUtility.OpenFolderPanel("选择要搜索的文件夹", Application.dataPath, ""); if (!string.IsNullOrEmpty(folderPath)) { string relativePath = GetAssetPath(folderPath); if (!string.IsNullOrEmpty(relativePath) && (relativePath.StartsWith("Assets", StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith("Packages", StringComparison.OrdinalIgnoreCase) || Directory.Exists(Path.Combine(Path.GetFullPath(Path.Combine(Application.dataPath, "..")), relativePath)))) { searchFolders = new string[] { relativePath }; } else { Debug.LogWarning($"选择的文件夹 '{folderPath}' 不在项目 Assets 或 Packages 内,或无法转换为有效的项目相对路径。"); } } } EditorGUILayout.EndHorizontal(); EditorGUILayout.LabelField("文件类型:"); string extensionsString = string.Join(", ", searchExtensions); extensionsString = EditorGUILayout.TextField(extensionsString); searchExtensions = extensionsString.Split(new char[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries) .Select(e => e.Trim().ToLower()) .Select(e => e.StartsWith(".") ? e : "." + e) .Distinct() .ToArray(); EditorGUILayout.Space(); EditorGUILayout.LabelField("搜索选项:"); includeSubfolders = EditorGUILayout.Toggle("包含子文件夹", includeSubfolders); compareByContent = EditorGUILayout.Toggle("按内容比较", compareByContent); compareByName = EditorGUILayout.Toggle("按名称比较", compareByName); minFileSize = EditorGUILayout.IntField("最小文件大小 (KB)", minFileSize); EditorGUILayout.Space(); EditorGUILayout.LabelField("处理选项:"); keepNewestFiles = EditorGUILayout.Toggle("保留最新文件", keepNewestFiles); createBackup = EditorGUILayout.Toggle("创建备份", createBackup); if (createBackup) { EditorGUILayout.BeginHorizontal(); backupFolder = EditorGUILayout.TextField("备份文件夹:", backupFolder); if (GUILayout.Button("浏览", GUILayout.Width(60))) { string path = EditorUtility.OpenFolderPanel("选择备份文件夹", backupFolder, ""); if (!string.IsNullOrEmpty(path)) { backupFolder = path; } } EditorGUILayout.EndHorizontal(); } EditorGUI.indentLevel--; } private void DrawResults() { EditorGUI.indentLevel++; EditorGUILayout.BeginHorizontal(); showFullPath = EditorGUILayout.Toggle("显示完整路径", showFullPath); GUILayout.FlexibleSpace(); if (GUILayout.Button("全选", GUILayout.Width(60))) { SelectAll(true); } if (GUILayout.Button("全不选", GUILayout.Width(60))) { SelectAll(false); } if (GUILayout.Button("智能选择", GUILayout.Width(80))) { SmartSelect(); } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(); int groupIndex = 0; foreach (var group in duplicateGroups) { if (group.Value.Count <= 1) continue; groupIndex++; EditorGUILayout.BeginVertical(GUI.skin.box); string groupTitle = $"组 {groupIndex}: {group.Value.Count} 个文件"; if (group.Value.Count > 0) { string fileName = Path.GetFileName(group.Value[0]); groupTitle += $" - {fileName}"; } EditorGUILayout.LabelField(groupTitle, EditorStyles.boldLabel); foreach (string filePath in group.Value) { EditorGUILayout.BeginHorizontal(); bool isSelected = false; if (selectedForRemoval.ContainsKey(filePath)) { isSelected = selectedForRemoval[filePath]; } bool newSelected = EditorGUILayout.Toggle(isSelected, GUILayout.Width(20)); if (newSelected != isSelected) { selectedForRemoval[filePath] = newSelected; } string displayPath = showFullPath ? filePath : Path.GetFileName(filePath); FileInfo fileInfo = new FileInfo(Path.Combine(Application.dataPath, "..", filePath)); string fileDetails = $"{displayPath} - {fileInfo.Length / 1024} KB"; if (fileInfo.Exists) { fileDetails += $" - {fileInfo.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss")}"; } EditorGUILayout.LabelField(fileDetails); if (GUILayout.Button("选择", GUILayout.Width(60))) { Selection.activeObject = AssetDatabase.LoadAssetAtPath(filePath); } EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndVertical(); EditorGUILayout.Space(); } EditorGUI.indentLevel--; } private void SelectAll(bool select) { foreach (var group in duplicateGroups) { if (group.Value.Count <= 1) continue; foreach (string filePath in group.Value) { selectedForRemoval[filePath] = select; } } } private void SmartSelect() { foreach (var group in duplicateGroups) { if (group.Value.Count <= 1) continue; string fileToKeep = null; if (keepNewestFiles) { fileToKeep = group.Value .OrderByDescending(f => File.GetLastWriteTime(Path.Combine(Application.dataPath, "..", f))) .FirstOrDefault(); } else { fileToKeep = group.Value.FirstOrDefault(); } foreach (string filePath in group.Value) { selectedForRemoval[filePath] = (filePath != fileToKeep); } } } // 计算文件的MD5哈希值 private string GetFileHash(string filePath) { try { using (var md5 = MD5.Create()) using (var stream = File.OpenRead(filePath)) { byte[] hash = md5.ComputeHash(stream); return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant(); } } catch (Exception ex) { Debug.LogError($"计算文件哈希值时出错: {filePath}, {ex.Message}"); return string.Empty; } } private async void FindDuplicateAssets() { isProcessing = true; progress = 0f; progressInfo = "正在收集文件..."; // 清空之前的结果 duplicateGroups.Clear(); selectedForRemoval.Clear(); totalFilesScanned = 0; totalDuplicatesFound = 0; totalSpaceSaved = 0; try { // 收集所有文件 List allFiles = new List(); foreach (string folder in searchFolders) { if (string.IsNullOrEmpty(folder)) continue; string fullPath = Path.Combine(Application.dataPath, "..", folder); if (!Directory.Exists(fullPath)) continue; string[] files = Directory.GetFiles(fullPath, "*.*", includeSubfolders ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) .Where(f => searchExtensions.Contains(Path.GetExtension(f).ToLower())) .Select(f => GetAssetPath(f)) .ToArray(); allFiles.AddRange(files); } totalFilesScanned = allFiles.Count; progressInfo = $"找到 {totalFilesScanned} 个文件,正在分析..."; progress = 0.1f; Repaint(); // 按内容或名称分组 Dictionary> groups = new Dictionary>(); for (int i = 0; i < allFiles.Count; i++) { string filePath = allFiles[i]; string fullPath = Path.Combine(Application.dataPath, "..", filePath); // 检查文件大小 FileInfo fileInfo = new FileInfo(fullPath); if (!fileInfo.Exists || fileInfo.Length < minFileSize * 1024) continue; string key = ""; if (compareByContent) { // 计算文件哈希值 key = GetFileHash(fullPath); } else if (compareByName) { // 使用文件名作为键 key = Path.GetFileNameWithoutExtension(filePath); } if (string.IsNullOrEmpty(key)) continue; if (!groups.ContainsKey(key)) { groups[key] = new List(); } groups[key].Add(filePath); // 更新进度 if (i % 10 == 0) { progress = 0.1f + 0.8f * ((float)i / allFiles.Count); progressInfo = $"正在分析文件 {i+1}/{allFiles.Count}..."; Repaint(); await Task.Delay(1); } } // 过滤出重复组 foreach (var group in groups) { if (group.Value.Count > 1) { duplicateGroups[group.Key] = group.Value; totalDuplicatesFound += group.Value.Count - 1; // 计算可节省空间 string firstFile = group.Value[0]; string fullPath = Path.Combine(Application.dataPath, "..", firstFile); FileInfo fileInfo = new FileInfo(fullPath); if (fileInfo.Exists) { totalSpaceSaved += (group.Value.Count - 1) * fileInfo.Length / 1024; } } } progressInfo = $"分析完成,找到 {totalDuplicatesFound} 个重复资源"; progress = 1f; Repaint(); // 自动选择 if (duplicateGroups.Count > 0) { SmartSelect(); } } catch (Exception ex) { Debug.LogError($"查找重复资源时出错: {ex.Message}\n{ex.StackTrace}"); EditorUtility.DisplayDialog("错误", $"查找重复资源时出错: {ex.Message}", "确定"); } finally { isProcessing = false; Repaint(); } } private async void ProcessDuplicateAssets() { isProcessing = true; progress = 0f; progressInfo = "正在准备处理..."; Repaint(); // 统计要处理的文件数量 int totalToProcess = selectedForRemoval.Count(pair => pair.Value); if (totalToProcess == 0) { EditorUtility.DisplayDialog("提示", "没有选择任何文件进行处理", "确定"); isProcessing = false; return; } // 确认操作 bool confirm = EditorUtility.DisplayDialog("确认操作", $"将处理 {totalToProcess} 个重复资源。\n" + (createBackup ? $"备份将保存到: {backupFolder}" : "不创建备份") + "\n\n此操作不可撤销,是否继续?", "继续", "取消"); if (!confirm) { isProcessing = false; return; } try { // 处理每个重复组 foreach (var group in duplicateGroups) { if (group.Value.Count <= 1) continue; // 找出要保留的文件(通常是最新的文件) string keepFile = null; if (keepNewestFiles) { keepFile = group.Value .OrderByDescending(f => new FileInfo(Path.Combine(Application.dataPath, "..", f)).LastWriteTime) .FirstOrDefault(); } else { // 如果不是保留最新的,则保留第一个未被选中删除的文件 keepFile = group.Value.FirstOrDefault(f => !selectedForRemoval.ContainsKey(f) || !selectedForRemoval[f]); } // 如果没有找到要保留的文件,跳过这个组 if (string.IsNullOrEmpty(keepFile)) { throw new Exception("没有找到要保留的文件"); continue; } // 处理这个组中的其他文件 foreach (string filePath in group.Value) { if (filePath == keepFile) continue; // 检查是否选中了这个文件进行删除 if (!selectedForRemoval.ContainsKey(filePath) || !selectedForRemoval[filePath]) continue; // 更新引用关系,将引用从filePath重定向到keepFile UpdateReferences(filePath, keepFile); // ... existing code ... // 创建备份和删除文件的代码 } } // 刷新资源数据库 AssetDatabase.Refresh(); // 创建备份文件夹 if (createBackup && !string.IsNullOrEmpty(backupFolder)) { Directory.CreateDirectory(backupFolder); } // 收集要删除的文件和保留的文件 List filesToRemove = new List(); Dictionary fileReplaceMap = new Dictionary(); var tempGroups = new Dictionary>(duplicateGroups); var tempRemoval = new Dictionary(selectedForRemoval); // 清空结果数据 duplicateGroups.Clear(); selectedForRemoval.Clear(); totalFilesScanned = 0; totalDuplicatesFound = 0; totalSpaceSaved = 0; showResults = false; foreach (var group in tempGroups) { if (group.Value.Count <= 1) continue; // 找出要保留的文件 string fileToKeep = null; foreach (string filePath in group.Value) { if (!tempRemoval.ContainsKey(filePath) || !tempRemoval[filePath]) { fileToKeep = filePath; break; } } // 如果所有文件都被标记为删除,保留第一个 if (string.IsNullOrEmpty(fileToKeep) && group.Value.Count > 0) { if (keepNewestFiles) { // 按最后修改时间排序,保留最新的 fileToKeep = group.Value .OrderByDescending(f => File.GetLastWriteTime(Path.Combine(Application.dataPath, "..", f))) .FirstOrDefault(); } else { fileToKeep = group.Value[0]; } if (tempRemoval.ContainsKey(fileToKeep)) { tempRemoval[fileToKeep] = false; } } // 收集要删除的文件 foreach (string filePath in group.Value) { if (filePath != fileToKeep && tempRemoval.ContainsKey(filePath) && tempRemoval[filePath]) { filesToRemove.Add(filePath); fileReplaceMap[filePath] = fileToKeep; } } } // 处理文件 int processed = 0; foreach (string filePath in filesToRemove) { processed++; progress = (float)processed / totalToProcess; progressInfo = $"正在处理 {processed}/{totalToProcess}: {Path.GetFileName(filePath)}"; Repaint(); try { // 备份文件 if (createBackup && !string.IsNullOrEmpty(backupFolder)) { string backupPath = Path.Combine(backupFolder, Path.GetFileName(filePath)); File.Copy(Path.Combine(Application.dataPath, "..", filePath), backupPath, true); } // 更新引用 if (fileReplaceMap.ContainsKey(filePath)) { UpdateReferences(filePath, fileReplaceMap[filePath]); } // 删除文件 AssetDatabase.DeleteAsset(filePath); await Task.Delay(1); } catch (Exception ex) { Debug.LogError($"处理文件时出错: {filePath}, {ex.Message}"); } } // 刷新资源数据库 AssetDatabase.Refresh(); // 完成 progressInfo = $"处理完成,已处理 {processed} 个文件"; progress = 1f; EditorUtility.DisplayDialog("完成", $"已处理 {processed} 个重复资源", "确定"); } catch (Exception ex) { Debug.LogError($"处理重复资源时出错: {ex.Message}\n{ex.StackTrace}"); EditorUtility.DisplayDialog("错误", $"处理重复资源时出错: {ex.Message}", "确定"); } finally { isProcessing = false; Repaint(); } } // 更新引用关系 private void UpdateReferences(string oldPath, string newPath) { string[] guids = AssetDatabase.FindAssets("t:Object"); foreach (string guid in guids) { string assetPath = AssetDatabase.GUIDToAssetPath(guid); if (assetPath == oldPath || assetPath == newPath) continue; if (!assetPath.EndsWith(".asset") && !assetPath.EndsWith(".prefab") && !assetPath.EndsWith(".unity") && !assetPath.EndsWith(".mat")) continue; UnityEngine.Object obj = AssetDatabase.LoadAssetAtPath(assetPath); if (obj == null) continue; string[] dependencies = AssetDatabase.GetDependencies(assetPath, false); if (dependencies.Contains(oldPath)) { // 获取旧资源和新资源 UnityEngine.Object oldAsset = AssetDatabase.LoadAssetAtPath(oldPath); UnityEngine.Object newAsset = AssetDatabase.LoadAssetAtPath(newPath); if (oldAsset != null && newAsset != null) { // 使用SerializedObject更新引用 SerializedObject serializedObj = new SerializedObject(obj); bool modified = false; SerializedProperty iterator = serializedObj.GetIterator(); while (iterator.NextVisible(true)) { if (iterator.propertyType == SerializedPropertyType.ObjectReference && iterator.objectReferenceValue == oldAsset) { iterator.objectReferenceValue = newAsset; modified = true; } } if (modified) { serializedObj.ApplyModifiedProperties(); Debug.Log($"已更新资源 {assetPath} 中的引用: {oldPath} -> {newPath}"); } } EditorUtility.SetDirty(obj); } } AssetDatabase.SaveAssets(); } } }