介绍

打包时需要使用两次 BuildPipeline.BuildPlayer 分别构建不同版本的包,只调用一次则打包时间很正常只有 5 分钟,但是打包两次时间则会膨胀到 30 分钟。

通过阅读分析 Unity 打包日志,发现在两次 BuildPipeline.BuildPlayer 中间有大量长时间耗时的操作呢?实际项目中出现了 1300+ 条类似下面的日志,而且耗时达22分钟:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
16:43:08.145856 Unloading 1 Unused Serialized files (Serialized files now loaded: 0)
16:43:09.158184 Unloading 17 unused Assets / (24.7 KB). Loaded Objects now: 38901.
16:43:09.158184 Memory consumption went from 0.72 GB to 0.72 GB.
16:43:09.158184 Total: 960.310500 ms (FindLiveObjects: 2.454500 ms CreateObjectMapping: 3.433300 ms MarkObjects: 954.311900 ms  DeleteObjects: 0.110200 ms)
16:43:09.158184 
16:43:09.158184 Unloading 1 Unused Serialized files (Serialized files now loaded: 0)
16:43:10.170468 Unloading 17 unused Assets / (4.4 KB). Loaded Objects now: 38901.
16:43:10.170468 Memory consumption went from 0.72 GB to 0.72 GB.
16:43:10.170468 Total: 956.463900 ms (FindLiveObjects: 2.442300 ms CreateObjectMapping: 3.146500 ms MarkObjects: 950.779100 ms  DeleteObjects: 0.095300 ms)

...

17:05:10.661445 Unloading 2 Unused Serialized files (Serialized files now loaded: 0)
17:05:11.675348 Unloading 13 unused Assets / (6.2 KB). Loaded Objects now: 40923.
17:05:11.675348 Memory consumption went from 0.71 GB to 0.71 GB.
17:05:11.675348 Total: 891.505800 ms (FindLiveObjects: 2.536600 ms CreateObjectMapping: 3.384700 ms MarkObjects: 885.480900 ms  DeleteObjects: 0.102900 ms)
17:05:11.675348 
17:05:11.675348 Unloading 4 Unused Serialized files (Serialized files now loaded: 0)
17:05:12.688781 Unloading 20 unused Assets / (12.8 KB). Loaded Objects now: 40923.
17:05:12.688781 Memory consumption went from 0.72 GB to 0.72 GB.
17:05:12.688781 Total: 890.204700 ms (FindLiveObjects: 2.589100 ms CreateObjectMapping: 3.347600 ms MarkObjects: 884.123500 ms  DeleteObjects: 0.143700 ms)

环境

  • Unity 2022.3.62f1
  • Windows 10 22H2

问题分析

上面的日志是卸载资源,Unity 编辑器下对应的 API 是 EditorUtility.UnloadUnusedAssetsImmediate()

问题出在 Spine 插件的构建预处理器 SpineBuildProcessor.cs。它作为 IPreprocessBuildWithReport(callbackOrder = -2000)在 BuildPipeline.BuildPlayer 之前执行:

SpineBuildProcessor.cs Lines 73-93

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
internal static void PreprocessSpinePrefabMeshes () {
    BuildUtilities.IsInSkeletonAssetBuildPreProcessing = true;
    try {
        AssetDatabase.StartAssetEditing();
        prefabsToRestore.Clear();
        var prefabAssets = AssetDatabase.FindAssets("t:Prefab");
        foreach (var asset in prefabAssets) {
            string assetPath = AssetDatabase.GUIDToAssetPath(asset);
            GameObject prefabGameObject = AssetDatabase.LoadAssetAtPath<GameObject>(assetPath);
            if (SpineEditorUtilities.CleanupSpinePrefabMesh(prefabGameObject)) {
                prefabsToRestore.Add(assetPath);
            }
            EditorUtility.UnloadUnusedAssetsImmediate();
        }
        AssetDatabase.StopAssetEditing();
        if (prefabAssets.Length > 0)
            AssetDatabase.SaveAssets();
    } finally {
        BuildUtilities.IsInSkeletonAssetBuildPreProcessing = false;
    }
}

同样的问题也出现在 PreprocessSpriteAtlases 中:

SpineBuildProcessor.cs Lines 111-131

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
internal static void PreprocessSpriteAtlases () {
    BuildUtilities.IsInSpriteAtlasBuildPreProcessing = true;
    try {
        AssetDatabase.StartAssetEditing();
        spriteAtlasTexturesToRestore.Clear();
        var spriteAtlasAssets = AssetDatabase.FindAssets("t:SpineSpriteAtlasAsset");
        foreach (var asset in spriteAtlasAssets) {
            string assetPath = AssetDatabase.GUIDToAssetPath(asset);
            SpineSpriteAtlasAsset atlasAsset = AssetDatabase.LoadAssetAtPath<SpineSpriteAtlasAsset>(assetPath);
            if (atlasAsset && atlasAsset.materials.Length > 0) {
                spriteAtlasTexturesToRestore[assetPath] = AssetDatabase.GetAssetPath(atlasAsset.materials[0].mainTexture);
                atlasAsset.materials[0].mainTexture = null;
            }
            EditorUtility.UnloadUnusedAssetsImmediate();
        }
        AssetDatabase.StopAssetEditing();
        if (spriteAtlasAssets.Length > 0)
            AssetDatabase.SaveAssets();
    } finally {
        BuildUtilities.IsInSpriteAtlasBuildPreProcessing = false;
    }
}

问题本质

这两个方法的致命问题是:在 foreach 循环的每次迭代内部调用了 EditorUtility.UnloadUnusedAssetsImmediate()

从日志中可以看到:

指标
每次 MarkObjects 耗时 ~950ms
已加载对象数 38,901 ~ 40,923
每次卸载的资源 仅 13~20 个(几 KB)
循环次数 1,300+ 次(= 项目中 Prefab 总数 + SpineSpriteAtlasAsset 数量)
总耗时 1,300 × ~1s ≈ 22 分钟

每次调用 UnloadUnusedAssetsImmediate() 都需要完整遍历内存中所有 38,901+ 个对象来执行 MarkObjects,但每次只卸载了十几个微小资源(几 KB),内存从 0.72GB 到 0.72GB 几乎没有变化——这完全是无效的重复劳动。

修复方案

修改文件: Assets/Plugins/Spine/Editor/spine-unity/Editor/Utility/SpineBuildProcessor.cs

两处改动: 将 EditorUtility.UnloadUnusedAssetsImmediate()foreach 循环内部移到循环结束后(StopAssetEditing 之后)只调用一次。

修改前 修改后
PreprocessSpinePrefabMeshes 循环内每个 Prefab 都调用一次 循环外只调用一次
PreprocessSpriteAtlases 循环内每个 Atlas 都调用一次 循环外只调用一次
预估耗时 1,300+ 次 × ~1s = 22 分钟 1 次 × ~1s = 1 秒

这个修复将这部分构建时间从 22 分钟压缩到约 1 秒。UnloadUnusedAssetsImmediate 的目的是防止循环中加载大量 Prefab 导致内存溢出,但每次循环只加载一个 Prefab 然后立即卸载是极其低效的做法——在循环结束后统一清理一次就足够了。

官方现状

截止到 2026-03-22 最新的 Spine 4.2 版本依然存在此问题