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<string, List<string>> duplicateGroups = new Dictionary<string, List<string>>();
|
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<string, bool> selectedForRemoval = new Dictionary<string, bool>();
|
|
private bool isProcessing = false;
|
private float progress = 0f;
|
private string progressInfo = "";
|
|
[MenuItem("工具/资源管理/资源查重工具")]
|
public static void ShowWindow()
|
{
|
AssetDuplicateCheck window = GetWindow<AssetDuplicateCheck>("资源查重工具");
|
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<UnityEngine.Object>(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<string> allFiles = new List<string>();
|
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<string, List<string>> groups = new Dictionary<string, List<string>>();
|
|
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<string>();
|
}
|
|
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<string> filesToRemove = new List<string>();
|
Dictionary<string, string> fileReplaceMap = new Dictionary<string, string>();
|
|
var tempGroups = new Dictionary<string, List<string>>(duplicateGroups);
|
var tempRemoval = new Dictionary<string, bool>(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<UnityEngine.Object>(assetPath);
|
if (obj == null) continue;
|
|
string[] dependencies = AssetDatabase.GetDependencies(assetPath, false);
|
if (dependencies.Contains(oldPath))
|
{
|
// 获取旧资源和新资源
|
UnityEngine.Object oldAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(oldPath);
|
UnityEngine.Object newAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(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();
|
}
|
}
|
}
|
|