// Copyright (c) 2025 EFramework Innovation. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. using System; using System.Collections.Generic; using System.IO; using System.Linq; using EFramework.Unity.Editor; using EFramework.Unity.Utility; using Microsoft.Extensions.FileSystemGlobbing; using UnityEditor; using UnityEditor.Build.Reporting; using UnityEngine; namespace EFramework.Unity.Asset.Editor { public partial class XAsset { /// /// XAsset.Builder 提供了资源的构建工作流,支持资源的依赖分析及打包功能。 /// /// /// /// 功能特性 /// - 首选项配置:提供首选项配置以自定义构建流程 /// - 自动化流程:提供资源包构建任务的自动化执行 /// /// 使用手册 /// 1. 首选项配置 /// /// 配置项说明: /// - 输出路径:`Asset/Builder/Output@Editor`,默认值为 `Builds/Patch/${Environment.Platform}/Assets` /// - 包含路径:`Asset/Builder/Include@Editor`,默认值为 `["Assets/Resources/Bundle", "Assets/Scenes/**/*.unity"]` /// - 排除路径:`Asset/Builder/Exclude@Editor`,默认值为 `[]` /// - 暂存路径:`Asset/Builder/Stash@Editor`,默认值为 `["Assets/Resources/Bundle"]` /// - 合并材质:`Asset/Builder/Merge/Material@Editor`,默认值为 `true` /// - 合并单包:`Asset/Builder/Merge/Single@Editor`,默认值为 `false` /// - 拷贝资源:`Asset/Builder/Streaming/Assets@Editor`,默认值为 `true` /// /// 关联配置项:`Asset/OffsetFactor`、`Asset/AssetUri`、`Asset/LocalUri` /// /// 以上配置项均可在 `Tools/EFramework/Preferences/Asset/Builder` 首选项编辑器中进行可视化配置。 /// /// 2. 自动化流程 /// /// 2.1 构建流程 /// - 分析依赖 --> 打包资源 --> 生成清单 /// /// 2.2 构建准备 /// /// 依赖分析 /// - 依赖分析系统将资源类型分为可加载资源和原生依赖资源 /// - 可加载资源资源一般位于 Resources 和 Scenes 目录中,通过 `Asset/Builder/Include@Editor` 选项进行设置,以单文件形式进行打包 /// - 原生依赖资源一般位于 RawAssets 目录中,不可以加载,以文件夹形式进行打包 /// - 可以通过设置 `Asset/Builder/Include@Editor` 选项排除 `Asset/Builder/Include@Editor` 中包含的文件/目录,支持通配符 /// /// 资源合并 /// - 材质合并:可选择是否将材质合并到场景包中以完整收集 `Shader` 变体,注意:若某材质的依赖数为1,则默认进行材质合并 /// - 单包合并:可选择是否将单一资源合并到主包中 /// - 自定义合并:支持通过 AssetImporter 设置自定义打包规则 /// /// 2.3 构建产物 /// /// 在 `Asset/Builder/Output@Editor` 目录下会生成以下文件: /// - `*.bundle`:资源包文件,格式为 `file_md5.bundle` /// - `Manifest.db`:资源包清单,格式为 `名称|MD5|大小` /// /// 构建产物会在内置构建事件 `XEditor.Event.Internal.OnPreprocessBuild` 触发时内置于安装包的资源目录下: /// /// - 移动平台 (Android/iOS/..) /// ``` /// / /// └── # 资源包压缩为 ZIP /// ``` /// /// - 桌面平台 (Windows/macOS/..) /// ``` /// <输出目录>_Data/ /// └── Local/ /// └── # 资源包直接部署 /// ``` /// /// 更多信息请参考模块文档。 /// [XEditor.Tasks.Worker(name: "Build Assets", group: "XAsset", priority: 101)] public class Builder : XEditor.Tasks.Worker, XEditor.Tasks.Panel.IOnGUI, XEditor.Event.Internal.OnPreprocessBuild, XEditor.Event.Internal.OnPostprocessBuild { /// /// Preferences 是构建的配置管理器,提供资源打包相关的配置项和界面化设置功能。 /// 包含输出路径、资源包含/排除规则、资源暂存设置以及合并选项等配置。 /// public class Preferences : Asset.XAsset.Preferences { /// /// Output 是输出路径配置键。 /// public const string Output = "XAsset/Builder/Output@Editor"; /// /// OutputDefault 是输出路径默认值。 /// public const string OutputDefault = "Builds/Patch/${Environment.Platform}/Assets"; /// /// Include 是资源路径配置键。 /// public const string Include = "XAsset/Builder/Include@Editor"; /// /// IncludeDefault 是资源路径默认值。 /// public static readonly string[] IncludeDefault = new string[] { "Assets/Resources/Bundle", "Assets/Scenes/**/*.unity" }; /// /// Exclude 是资源排除配置键。 /// public const string Exclude = "XAsset/Builder/Exclude@Editor"; /// /// Stash 是资源暂存配置键。 /// public const string Stash = "XAsset/Builder/Stash@Editor"; /// /// StashDefault 是资源暂存默认值。 /// public static readonly string[] StashDefault = new string[] { "Assets/Resources/Bundle" }; /// /// MergeMaterial 是材质合并配置键。 /// public const string MergeMaterial = "XAsset/Builder/Merge/Material@Editor"; /// /// MergeMaterialDefault 是材质合并默认值。 /// public const bool MergeMaterialDefault = true; /// /// MergeSingle 是资源合并配置键。 /// public const string MergeSingle = "XAsset/Builder/Merge/Single@Editor"; /// /// MergeSingleDefault 是资源合并默认值。 /// public const bool MergeSingleDefault = false; /// /// StreamingAssets 是资源拷贝配置键。 /// public const string StreamingAssets = "XAsset/Builder/Streaming/Assets@Editor"; /// /// StreamingAssets 是资源拷贝默认值。 /// public const bool StreamingAssetsDefault = true; public override string Section => "XAsset"; public override int Priority => 101; [SerializeField] internal string[] include; [SerializeField] internal string[] exclude; [SerializeField] internal string[] stash; [NonSerialized] SerializedObject serialized; public Preferences() { foldout = false; } public override void OnVisualize(string searchContext, XPrefs.IBase context) { var taskPanel = searchContext == "Task Runner"; serialized ??= new SerializedObject(this); serialized.Update(); var ocolor = GUI.color; var bundleMode = context.GetBool(BundleMode, BundleModeDefault); if (!taskPanel) { if (!bundleMode) GUI.color = Color.gray; EditorGUILayout.BeginVertical(EditorStyles.helpBox); foldout = EditorGUILayout.Foldout(foldout, new GUIContent("Builder", "XAsset Builder Options.")); } else foldout = true; if (foldout && bundleMode) { EditorGUILayout.BeginVertical(EditorStyles.helpBox); EditorGUILayout.BeginHorizontal(); GUILayout.Label(new GUIContent("Output", "Output Path of AssetBundle."), GUILayout.Width(60)); context.Set(Output, EditorGUILayout.TextField("", context.GetString(Output, OutputDefault))); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); GUILayout.Label(new GUIContent("Options"), GUILayout.Width(60)); GUILayout.Label(new GUIContent("Merge Material", "Merge Material into Bundle for Collecting Shader Variants."), GUILayout.Width(90)); context.Set(MergeMaterial, EditorGUILayout.Toggle(context.GetBool(MergeMaterial, MergeMaterialDefault))); GUILayout.Label(new GUIContent("Merge Single", "Merge Single Raw Bundle into Main Bundle."), GUILayout.Width(80)); context.Set(MergeSingle, EditorGUILayout.Toggle(context.GetBool(MergeSingle, MergeSingleDefault))); GUILayout.Label(new GUIContent("Streaming Assets", "Copy Assets Patch into Streaming Assets."), GUILayout.Width(105)); context.Set(StreamingAssets, EditorGUILayout.Toggle(context.GetBool(StreamingAssets, StreamingAssetsDefault))); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); include = context.GetStrings(Include, IncludeDefault); EditorGUILayout.PropertyField(serialized.FindProperty("include"), new GUIContent("Include")); if (GUILayout.Button(new GUIContent("?", "Learn more about File Globbing"), GUILayout.Width(20))) Application.OpenURL("https://learn.microsoft.com/zh-cn/dotnet/core/extensions/file-globbing"); if (serialized.ApplyModifiedProperties()) context.Set(Include, include); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); exclude = context.GetStrings(Exclude, Array.Empty()); EditorGUILayout.PropertyField(serialized.FindProperty("exclude"), new GUIContent("Exclude")); if (GUILayout.Button(new GUIContent("?", "Learn more about File Globbing"), GUILayout.Width(20))) Application.OpenURL("https://learn.microsoft.com/zh-cn/dotnet/core/extensions/file-globbing"); if (serialized.ApplyModifiedProperties()) context.Set(Exclude, exclude); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); stash = context.GetStrings(Stash, StashDefault); EditorGUILayout.PropertyField(serialized.FindProperty("stash"), new GUIContent("Stash")); if (serialized.ApplyModifiedProperties()) context.Set(Stash, stash); EditorGUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } else if (foldout && !bundleMode) EditorGUILayout.HelpBox("Bundle Mode is Disabled.", MessageType.None); GUI.color = ocolor; if (!taskPanel) EditorGUILayout.EndVertical(); } } internal static string stashFile { get => XFile.PathJoin(XEnv.ProjectPath, "Library", "AssetStash.db"); } internal static string dependencyFile { get => XFile.PathJoin(XEnv.ProjectPath, "Library", "AssetDependency.db"); } internal string buildDir; internal Preferences preferencesPanel; void XEditor.Tasks.Panel.IOnGUI.OnGUI() { preferencesPanel = preferencesPanel != null ? preferencesPanel : ScriptableObject.CreateInstance(); preferencesPanel.OnVisualize("Task Runner", XPrefs.Asset); } public override void Preprocess(XEditor.Tasks.Report report) { buildDir = XFile.NormalizePath(XPrefs.GetString(Preferences.Output, Preferences.OutputDefault).Eval(XEnv.Instance)); if (string.IsNullOrEmpty(buildDir)) throw new ArgumentNullException("Preferences.Builder.Output is empty."); if (!XFile.HasDirectory(buildDir)) XFile.CreateDirectory(buildDir); var maniFile = XFile.PathJoin(buildDir, XMani.Default); var tmpManiFile = maniFile + ".tmp"; if (XFile.HasFile(maniFile)) XFile.CopyFile(maniFile, tmpManiFile); } public override void Process(XEditor.Tasks.Report report) { var bundles = GenDependency(); var builds = new List(); foreach (var kvp in bundles) { var build = new AssetBundleBuild { assetBundleName = kvp.Key, assetNames = kvp.Value.ToArray() }; builds.Add(build); } try { if (BuildPipeline.BuildAssetBundles(buildDir, builds.ToArray(), BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.AssetBundleStripUnityVersion, EditorUserBuildSettings.activeBuildTarget) == null) { report.Error = "BuildPipeline.BuildAssetBundles returns nil."; } } catch (Exception e) { XLog.Panic(e); report.Error = e.Message; } } public override void Postprocess(XEditor.Tasks.Report report) { GenManifest(report); GenSummary(report); } /// /// GenDependency 分析项目资源并生成依赖关系图,支持自定义打包规则和资源合并策略。 /// 会处理场景文件、材质球等特殊资源,确保正确的打包顺序和依赖关系。 /// /// 资源依赖关系字典,键为Bundle名称,值为资源路径列表 public static Dictionary> GenDependency() { var buildBundles = new Dictionary>(); var buildTime = XTime.GetTimestamp(); try { var visited = new List(); var fileBundles = new Dictionary>(); var dirBundles = new Dictionary>(); var customBundles = new Dictionary>(); var refCountMap = new Dictionary(); var sourceAssets = new List(); // 处理 Include 规则 var includes = XPrefs.GetStrings(Preferences.Include, Preferences.IncludeDefault); if (includes != null && includes.Length > 0) { foreach (var temp in includes) { if (temp.IndexOfAny(new char[] { '*', '?', '[' }) >= 0) { var tempAssets = new List(); var rootDir = temp.Split('*', '?', '[')[0].TrimEnd('/', '\\'); var partten = temp[rootDir.Length..].TrimStart('/', '\\'); if (string.IsNullOrEmpty(rootDir)) rootDir = "Assets"; XEditor.Utility.CollectAssets(rootDir, tempAssets, ".cs", ".js", ".meta", ".tpsheet", ".DS_Store", ".gitkeep", ".variant", ".hlsl", ".cginc", ".shadersubgraph"); var matcher = new Matcher(); matcher.AddInclude(partten); foreach (var asset in tempAssets) { var relativeAsset = XFile.NormalizePath(Path.GetRelativePath(rootDir, asset)); if (matcher.Match(relativeAsset).HasMatches) sourceAssets.Add(asset); } } else if (XFile.HasFile(temp)) sourceAssets.Add(temp); else if (XFile.HasDirectory(temp)) { XEditor.Utility.CollectAssets(temp, sourceAssets, ".cs", ".js", ".meta", ".tpsheet", ".DS_Store", ".gitkeep", ".variant", ".hlsl", ".cginc", ".shadersubgraph"); } } } // 处理 Exclude 规则 var excludes = XPrefs.GetStrings(Preferences.Exclude); if (excludes != null && excludes.Length > 0) { for (var i = 0; i < sourceAssets.Count;) { var asset = sourceAssets[i]; var ignore = false; var ignorePartten = string.Empty; foreach (var exclude in excludes) { if (exclude.IndexOfAny(new char[] { '*', '?', '[' }) >= 0) { var rootDir = exclude.Split('*', '?', '[')[0]; if (asset.StartsWith(rootDir)) // 判断文件是否匹配根目录 { var partten = exclude[rootDir.Length..]; var matcher = new Matcher(); matcher.AddInclude(partten); var relativeAsset = XFile.NormalizePath(Path.GetRelativePath(rootDir, asset)); if (matcher.Match(relativeAsset).HasMatches) { ignore = true; ignorePartten = partten; break; } } } else if (exclude == asset) // 文件路径完全匹配 { ignore = true; ignorePartten = exclude; break; } } if (ignore) { sourceAssets.RemoveAt(i); XLog.Warn("XAsset.Builder.GenDependency: source asset {0} has been ignored by matcher: {1}.", asset, ignorePartten); } else i++; } } var dependAssets = XEditor.Utility.CollectDependency(sourceAssets); for (int i = 0; i < sourceAssets.Count; i++) { var asset = sourceAssets[i]; visited.Add(asset); var assetImporter = AssetImporter.GetAtPath(asset); if (assetImporter) { var bundleName = Asset.XAsset.Constants.GetName(asset); if (!fileBundles.TryGetValue(bundleName, out var dependencies)) { dependencies = new List(); fileBundles.Add(bundleName, dependencies); } if (!dependencies.Contains(asset)) dependencies.Add(asset); } } var keys = dependAssets.Keys.ToList(); // 优先处理场景以剔除材质依赖 keys.Sort((a1, a2) => { var b1 = a1.EndsWith(".unity") ? 0 : 1; var b2 = a2.EndsWith(".unity") ? 0 : 1; if (b1 > b2) return 1; else if (b1 == b2) return 0; else return -1; }); foreach (var key in keys) { var assets = dependAssets[key]; for (int j = 0; j < assets.Count; j++) { var asset = assets[j]; if (!visited.Contains(asset)) { visited.Add(asset); if (asset.Contains("Editor/")) { XLog.Warn("XAsset.Builder.GenDependency: ignore editor asset dependency: {0}.", asset); continue; } else if (asset.EndsWith(".hlsl") || asset.EndsWith(".cginc") || asset.EndsWith(".shadersubgraph")) { XLog.Debug("XAsset.Builder.GenDependency: ignore shader asset dependency: {0}.", asset); continue; } else { if (excludes != null && excludes.Length > 0) { var ignore = false; var ignorePartten = string.Empty; foreach (var exclude in excludes) { if (exclude.IndexOfAny(new char[] { '*', '?', '[' }) >= 0) { var rootDir = exclude.Split('*', '?', '[')[0]; if (asset.StartsWith(rootDir)) // 判断文件是否匹配根目录 { var partten = exclude[rootDir.Length..]; var matcher = new Matcher(); matcher.AddInclude(partten); var relativeAsset = XFile.NormalizePath(Path.GetRelativePath(rootDir, asset)); if (matcher.Match(relativeAsset).HasMatches) { ignore = true; ignorePartten = partten; break; } } } else if (exclude == asset) // 文件路径完全匹配 { ignore = true; ignorePartten = exclude; break; } } if (ignore) { XLog.Warn("XAsset.Builder.GenDependency: dependency asset {0} has been ignored by matcher: {1}.", asset, ignorePartten); continue; } } var temp = asset[..asset.LastIndexOf("/")]; var assetImporter = AssetImporter.GetAtPath(asset); if (assetImporter) { string bundleName; List dependencies; if (!string.IsNullOrEmpty(assetImporter.assetBundleName)) { bundleName = assetImporter.assetBundleName; XLog.Debug("XAsset.Builder.GenDependency: using custom bundle name: {0} for asset: {1}.", bundleName, asset); if (!customBundles.TryGetValue(bundleName, out dependencies)) { dependencies = new List(); customBundles.Add(bundleName, dependencies); } if (!dependencies.Contains(asset)) dependencies.Add(asset); } else { var skip = assetImporter is ShaderImporter || asset.EndsWith(".shadergraph"); // 强制合并材质,避免着色器变体丢失 if (!skip && XPrefs.GetBool(Preferences.MergeMaterial, Preferences.MergeMaterialDefault)) skip = asset.EndsWith(".mat") && key.EndsWith(".unity"); if (skip) continue; else { var dir = XFile.NormalizePath(Path.GetDirectoryName(asset)); bundleName = Asset.XAsset.Constants.GetName(dir.StartsWith("Assets/") ? dir["Assets/".Length..] : dir); if (!dirBundles.TryGetValue(bundleName, out dependencies)) { dependencies = new List(); dirBundles.Add(bundleName, dependencies); } if (!dependencies.Contains(asset)) dependencies.Add(asset); } } } } } if (!refCountMap.TryGetValue(asset, out var count)) refCountMap.Add(asset, count); else refCountMap[asset] = count + 1; } } if (XPrefs.GetBool(Preferences.MergeSingle, Preferences.MergeSingleDefault)) // 若业务层依赖depth为1(间接引用)的包,则会引起异常(如:fairygui.uipanel的bundle) { var deletes = new List(); foreach (var kvp in dirBundles) // 若引用的多个资源都只出现在该包中,也会合并 { var sig = true; foreach (var dependency in kvp.Value) { if (refCountMap[dependency] > 0) { sig = false; break; } } if (sig) deletes.Add(kvp.Key); } foreach (var k in deletes) { dirBundles.Remove(k); XLog.Debug("XAsset.Builder.GenDependency: merged raw single asset '{0}'.", k); } } else XLog.Debug("XAsset.Builder.GenDependency: ignore to merge raw single bundles."); foreach (var kvp in fileBundles) buildBundles.Add(kvp.Key, kvp.Value); foreach (var kvp in dirBundles) buildBundles.Add(kvp.Key, kvp.Value); foreach (var kvp in customBundles) buildBundles.Add(kvp.Key, kvp.Value); if (XFile.HasFile(dependencyFile)) XFile.DeleteFile(dependencyFile); using var fs = File.Open(dependencyFile, FileMode.Create); using var sw = new StreamWriter(fs); foreach (var kvp in buildBundles) { sw.WriteLine($"bundle: {kvp.Key}"); var assets = kvp.Value; for (var j = 0; j < assets.Count; j++) { var asset = assets[j]; if (asset.Contains("Editor/") == false && asset != kvp.Key) sw.WriteLine($" asset: {asset}"); } } sw.Flush(); fs.Flush(); } catch (Exception e) { if (XFile.HasFile(dependencyFile)) XFile.DeleteFile(dependencyFile); XLog.Panic(e); } XLog.Debug("XAsset.Builder.GenDependency: generate {1} done, elapsed {2}s.", Path.GetFullPath(dependencyFile), Path.GetRelativePath(XEnv.ProjectPath, dependencyFile), XTime.GetTimestamp() - buildTime); return buildBundles; } /// /// GenManifest 根据构建结果生成资源清单文件,记录每个资源包的信息(如MD5、大小等)。 /// 这个清单文件将用于运行时的资源加载和版本检查。 /// /// 构建报告对象 private void GenManifest(XEditor.Tasks.Report report) { if (report.Result != XEditor.Tasks.Result.Succeeded) return; var abManifestFilePath = XFile.PathJoin(buildDir, Asset.XAsset.Constants.Manifest); var manifestFilePath = abManifestFilePath + ".manifest"; var assetManifestFilePath = XFile.PathJoin(buildDir, XMani.Default); if (XFile.HasFile(assetManifestFilePath)) XFile.DeleteFile(assetManifestFilePath); if (XFile.HasFile(abManifestFilePath) == false) { report.Error = "No asset bundle manifest file."; XLog.Error("XAsset.Builder.GenManifest: no asset bundle manifest file."); return; } var bundle = AssetBundle.LoadFromFile(abManifestFilePath); var manifest = bundle.LoadAsset("AssetBundleManifest"); if (manifest == null) { report.Error = "Null asset bundle manifest."; XLog.Error("XAsset.Builder.GenManifest: null asset bundle manifest."); return; } var fs = new FileStream(assetManifestFilePath, FileMode.OpenOrCreate); var sw = new StreamWriter(fs); // write ab manifest file; var manifestMD5 = XFile.FileMD5(abManifestFilePath); var manifestSize = XFile.FileSize(abManifestFilePath); sw.WriteLine(Asset.XAsset.Constants.GetName(Asset.XAsset.Constants.Manifest) + "|" + manifestMD5 + "|" + manifestSize); var lines = File.ReadAllLines(manifestFilePath); var abs = new List(); for (var i = 0; i < lines.Length; i++) { var line = lines[i]; if (line.StartsWith(" Name: ")) { line = line.Replace(" Name: ", ""); line = line.Trim(); abs.Add(line); } } var abs2 = new List(); var count = 0; while (abs.Count > 0) { for (var i = 0; i < abs.Count;) { var ab = abs[i]; var dependencies = manifest.GetAllDependencies(ab); if (dependencies.Length == count) { abs.RemoveAt(i); abs2.Add(ab); } else i++; } count++; } for (var i = 0; i < abs2.Count; i++) { var ab = abs2[i]; var filePath = XFile.PathJoin(buildDir, ab); var size = XFile.FileSize(filePath); var md5 = XFile.FileMD5(filePath); sw.WriteLine(ab + "|" + md5 + "|" + size); } sw.Close(); fs.Close(); bundle.Unload(true); // 标准化 Bundle 文件命名 var dstMani = XFile.PathJoin(buildDir, Asset.XAsset.Constants.GetName(Asset.XAsset.Constants.Manifest)); if (XFile.HasFile(dstMani)) XFile.DeleteFile(dstMani); File.Move(abManifestFilePath, dstMani); dstMani += ".manifest"; if (XFile.HasFile(dstMani)) XFile.DeleteFile(dstMani); File.Move(manifestFilePath, dstMani); } /// /// GenSummary 生成构建报告,记录资源变更情况并清理无效的资源文件。 /// 通过对比新旧清单,可以了解此次构建的具体改动。 /// /// 构建报告对象,用于记录构建过程中的信息 private void GenSummary(XEditor.Tasks.Report report) { var tmpFile = XFile.PathJoin(buildDir, XMani.Default + ".tmp"); var tmpMani = new XMani.Manifest(tmpFile); if (XFile.HasFile(tmpFile)) { tmpMani.Read(); XFile.DeleteFile(XFile.PathJoin(buildDir, XMani.Default + ".tmp")); } if (report.Result != XEditor.Tasks.Result.Succeeded) return; var maniFile = XFile.PathJoin(buildDir, XMani.Default); var mani = new XMani.Manifest(maniFile); mani.Read(); var diff = tmpMani.Compare(mani); for (var i = 0; i < diff.Modified.Count; i++) { var fi = diff.Modified[i]; XLog.Debug("XAsset.Builder.GenSummary: {0} has been modified.", fi.Name); } for (var i = 0; i < diff.Added.Count; i++) { var fi = diff.Added[i]; XLog.Debug("XAsset.Builder.GenSummary: {0} has been added.", fi.Name); } for (var i = 0; i < diff.Deleted.Count; i++) { var fi = diff.Deleted[i]; var file = XFile.PathJoin(buildDir, fi.Name); var mfile = file + ".manifest"; XFile.DeleteFile(file); XFile.DeleteFile(mfile); XLog.Debug("XAsset.Builder.GenSummary: {0} has been deleted.", fi.Name); } try { var files = Directory.GetFiles(buildDir); for (var i = 0; i < files.Length; i++) { var f = files[i]; var n = Path.GetFileName(f); var e = Path.GetExtension(f); if (n == XMani.Default || e == ".manifest") continue; if (mani.Files.Find((e) => { return e.Name == n; }) == null) { XFile.DeleteFile(f); XFile.DeleteFile(f + ".manifest"); XLog.Warn("XAsset.Builder.GenSummary: invalid {0} has been deleted.", n); } } } catch (Exception e) { XLog.Panic(e); } var dirty = false; foreach (var fi in mani.Files) { if (GenOffset(fi)) dirty = true; } if (dirty) XFile.SaveText(maniFile, mani.ToString()); XLog.Debug("XAsset.Builder.GenSummary: {0} asset(s) has been modified, {1} asset(s) has been added, {2} asset(s) has been deleted.", diff.Modified.Count, diff.Added.Count, diff.Deleted.Count); } /// /// GenOffset 用于处理 Bundle 文件的偏移。 /// /// Bundle 文件信息 private bool GenOffset(XMani.FileInfo fi) { var offsetFactor = XPrefs.GetInt(Asset.XAsset.Preferences.OffsetFactor, Asset.XAsset.Preferences.OffsetFactorDefault); if (offsetFactor <= 0) return false; var src = XFile.PathJoin(buildDir, fi.Name); var dst = src + ".tmp"; if (XFile.HasFile(dst)) XFile.DeleteFile(dst); var prefix = new byte[28]; // 根据文件名长度计算 offsetCount,且至少偏移一个单元 var offsetCount = fi.Name.Length % offsetFactor + 1; using (var fs = new FileStream(src, FileMode.Open, FileAccess.Read)) { // 先读 prefix var readPrefix = new byte[28]; int readLen = fs.Read(readPrefix, 0, readPrefix.Length); if (readLen != prefix.Length) return false; // 文件太小,不处理 Buffer.BlockCopy(readPrefix, 0, prefix, 0, prefix.Length); // 再读 offsetCount * prefix.Length 用于比对 var totalCheckSize = prefix.Length * offsetCount; var checkData = new byte[totalCheckSize]; readLen = fs.Read(checkData, 0, totalCheckSize); var alreadyOffset = true; if (readLen < totalCheckSize) alreadyOffset = false; else { for (int i = 0; i < offsetCount; i++) { for (int j = 0; j < prefix.Length; j++) { if (checkData[i * prefix.Length + j] != prefix[j]) { alreadyOffset = false; break; } } if (!alreadyOffset) break; } } if (alreadyOffset) return false; } // 如果未偏移则执行偏移写入 var buffer = new byte[1024]; using (var fs = new FileStream(src, FileMode.Open, FileAccess.Read)) using (var sw = new FileStream(dst, FileMode.Create, FileAccess.Write)) { // 读取原始的 prefix fs.Read(prefix, 0, prefix.Length); // 写入原始的 prefix sw.Write(prefix, 0, prefix.Length); // 写入偏移的 prefix for (var i = 0; i < offsetCount; i++) sw.Write(prefix, 0, prefix.Length); int bytesRead; while ((bytesRead = fs.Read(buffer, 0, buffer.Length)) > 0) sw.Write(buffer, 0, bytesRead); sw.Flush(); } XFile.DeleteFile(src); File.Move(dst, src); XFile.DeleteFile(dst); fi.Size = XFile.FileSize(src); fi.MD5 = XFile.FileMD5(src); XLog.Debug("XAsset.Builder.GenOffset: add {0} prefix offset into {1}.", offsetCount, fi.Name); return true; } /// /// XEditor.Event.Internal.OnPreprocessBuild.Process 是构建开始前的预处理,主要处理平台相关的资源复制工作。 /// 对于移动平台,会将资源打包成 zip 文件以便于分发。 /// /// 构建参数数组 void XEditor.Event.Internal.OnPreprocessBuild.Process(params object[] args) { if (!XPrefs.GetBool(Asset.XAsset.Preferences.BundleMode, Asset.XAsset.Preferences.BundleModeDefault)) { XLog.Debug("XAsset.Builder.OnPreprocessBuild: ignore to preprocess in non-bundle mode."); return; } Stash(); if (XPrefs.GetBool(Preferences.StreamingAssets, Preferences.StreamingAssetsDefault)) { var srcDir = XFile.NormalizePath(XPrefs.GetString(Preferences.Output, Preferences.OutputDefault).Eval(XEnv.Instance)); if (!XFile.HasDirectory(srcDir)) { XLog.Warn("XAsset.Builder.OnPreprocessBuild: ignore to streaming asset(s) because of non-exists dir: {0}.", srcDir); } else { if (XEnv.Platform == XEnv.PlatformType.Android || XEnv.Platform == XEnv.PlatformType.iOS) { var dstDir = XFile.PathJoin(XEnv.ProjectPath, "Temp", XPrefs.GetString(Asset.XAsset.Preferences.LocalUri, Asset.XAsset.Preferences.LocalUriDefault)); var srcZip = XFile.PathJoin(XEnv.ProjectPath, "Temp", XPrefs.GetString(Asset.XAsset.Preferences.AssetUri, Asset.XAsset.Preferences.AssetUriDefault)); var dstZip = XFile.PathJoin(XEnv.AssetPath, XPrefs.GetString(Asset.XAsset.Preferences.AssetUri, Asset.XAsset.Preferences.AssetUriDefault)); if (XFile.HasDirectory(dstDir)) XFile.DeleteDirectory(dstDir); XFile.CopyDirectory(srcDir, dstDir, ".manifest"); XEditor.Utility.ZipDirectory(dstDir, srcZip); XFile.CopyFile(srcZip, dstZip); AssetDatabase.Refresh(); } else { XEditor.Event.Decode(out var report, args); var outputDir = Path.GetDirectoryName(report.summary.outputPath); var outputName = Path.GetFileNameWithoutExtension(report.summary.outputPath); var dstDir = XFile.PathJoin(outputDir, outputName + "_Data", "Local", XPrefs.GetString(Asset.XAsset.Preferences.LocalUri, Asset.XAsset.Preferences.LocalUriDefault)); XFile.CopyDirectory(srcDir, dstDir, ".manifest"); } XLog.Debug("XAsset.Builder.OnPreprocessBuild: streaming asset(s) from {1} succeeded.", Path.GetFullPath(srcDir), Path.GetRelativePath(XEnv.ProjectPath, srcDir)); } } } /// /// XEditor.Event.Internal.OnPostprocessBuild.Process 是构建完成后的后处理,负责恢复暂存的资源并清理临时文件。 /// 确保构建过程不会影响项目的正常开发。 /// /// 构建参数数组 void XEditor.Event.Internal.OnPostprocessBuild.Process(params object[] args) { if (!XPrefs.GetBool(Asset.XAsset.Preferences.BundleMode, Asset.XAsset.Preferences.BundleModeDefault)) { XLog.Debug("XAsset.Builder.OnPostprocessBuild: ignore to postprocess in non-bundle mode."); return; } Restore(); if (XPrefs.GetBool(Preferences.StreamingAssets, Preferences.StreamingAssetsDefault)) { if (XEnv.Platform == XEnv.PlatformType.Android) { var dstZip = XFile.PathJoin(XEnv.AssetPath, "Patch@Assets.zip"); if (XFile.HasFile(dstZip)) { XFile.DeleteFile(dstZip); AssetDatabase.Refresh(); } } } } /// /// Stash 将指定资源暂时移动到临时位置,用于构建过程中的资源管理。 /// 会同时处理资源文件及其对应的 meta 文件,并记录暂存信息。 /// public static void Stash() { try { if (XFile.HasFile(stashFile)) XFile.DeleteFile(stashFile); using var fs = File.Open(stashFile, FileMode.Create); using var sw = new StreamWriter(fs); var stashes = XPrefs.GetStrings(Preferences.Stash, Preferences.StashDefault).OrderByDescending(asset => asset.Length).ToList(); foreach (var stash in stashes) { var src = XFile.NormalizePath(Path.GetRelativePath(XEnv.ProjectPath, stash)); var dst = XFile.HasFile(src) ? XFile.PathJoin(Path.GetDirectoryName(src), "." + Path.GetFileName(src)) : $"{src}~"; if (XFile.HasDirectory(src) || XFile.HasFile(src)) { if (XFile.HasDirectory(dst)) XFile.DeleteDirectory(dst); if (XFile.HasFile(dst)) XFile.DeleteFile(dst); FileUtil.MoveFileOrDirectory(src, dst); var srcm = XFile.PathJoin(Path.GetDirectoryName(src), Path.GetFileName(src) + ".meta"); var dstm = XFile.PathJoin(Path.GetDirectoryName(src), "." + Path.GetFileName(src) + ".meta"); if (XFile.HasFile(srcm)) { if (XFile.HasFile(dstm)) XFile.DeleteFile(dstm); FileUtil.MoveFileOrDirectory(srcm, dstm); } AssetDatabase.Refresh(); sw.WriteLine(stash); XLog.Debug("XAsset.Builder.Stash: stashed asset {0} to {1}.", src, dst); } else XLog.Warn("XAsset.Builder.Stash: stashed asset {0} not found.", src); } sw.Flush(); fs.Flush(); } catch (Exception e) { XLog.Panic(e); } } /// /// Restore 将暂存的资源恢复到原始位置。 /// 为了避免资源丢失,不会主动删除目标位置的文件,如果恢复过程中出现问题,会提示用户手动处理。 /// [InitializeOnLoadMethod] public static void Restore() { if (XFile.HasFile(stashFile)) { try { var stashes = File.ReadAllLines(stashFile); foreach (var stash in stashes) { var src = XFile.NormalizePath(Path.GetRelativePath(XEnv.ProjectPath, stash)); var dst = XFile.PathJoin(Path.GetDirectoryName(src), "." + Path.GetFileName(src)); if (!XFile.HasFile(dst)) dst = $"{src}~"; if (XFile.HasDirectory(dst) || XFile.HasFile(dst)) { // 不主动删除,避免资源丢失,抛异常用户自行处理 // if (XFile.HasDirectory(dst)) XFile.DeleteDirectory(dst); // if (XFile.HasFile(dst)) XFile.DeleteFile(dst); FileUtil.MoveFileOrDirectory(dst, src); var srcm = XFile.PathJoin(Path.GetDirectoryName(src), Path.GetFileName(src) + ".meta"); var dstm = XFile.PathJoin(Path.GetDirectoryName(src), "." + Path.GetFileName(src) + ".meta"); if (XFile.HasFile(dstm)) { // if (XFile.HasFile(srcm)) XFile.DeleteFile(srcm); // 同上 FileUtil.MoveFileOrDirectory(dstm, srcm); } AssetDatabase.Refresh(); XLog.Debug("XAsset.Builder.Restore: popuped asset {0} from {1}.", dst, src); } } } catch (Exception e) { XLog.Panic(e); } finally { XFile.DeleteFile(stashFile); } } } } } }