排查 Resources.UnloadUnusedAssets 导致栈上对象被释放的问题

问题

游戏在房间内重连时,有空对象的报错,代码的基本逻辑如下:

void WarmUp()
{
    var svc = new ShaderVariantCollection();
    svc.name = "CustomSVC";
    
    foreach (var variantData in currentVariantDatas)
    {
        var shader = LoadShader(variantData.ShaderName);
        var varaint = new ShaderVariantCollection.ShaderVariant(shader, variantData.PassType, variantData.Keywords);
        ProcessVariant(svc, variant);
    }
    
    svc.WarmUp();
}

void ProcessVariant(ShaderVariantCollection svc, ShaderVariantCollection.ShaderVariant variant)
{
    svc.Add(variant);
}

通过增加日志,确定了在执行ProcessVariant时,svc为空,所以执行svc.Add时,就导致了空对象的异常。

排查

分析代码

看代码,从svc的创建到使用都是同步执行的,svc会变成空对象是比较奇怪的。分析后认为其中比较可疑的就只有LoadShader(使用Addressables加载Shader)了,除此之外的代码都是纯粹的C#代码,不太可能有问题。

在结合对LoadShader的怀疑,考虑什么情况下svc会变成的空的时候,想到会不会是因为svc是栈上的对象,被Resources.UnloadUnusedAssets认为没被使用,所以销毁了。

按照项目的代码逻辑,在房间中只有少数情况下,才会在重连时调用Resources.UnloadUnusedAssets,刚好发生报错的时候,就属于这些情况之一。测试了不会调用该函数的重连情况,发现不会出现这个报错。因此,认为有比较大的可能和Resources.UnloadUnusedAssets有关。

复现

在一个空工程中模拟实际游戏中的逻辑,发现确实能复现该问题(2020.3.30和2020.3.46都能复现):

private void Run()
{
    Resources.UnloadUnusedAssets();

    var svc = new ShaderVariantCollection();

    var bundleReq = AssetBundle.LoadFromFileAsync(Application.streamingAssetsPath + "/prefabs.bundle");

    // 访问assetBundle属性时,如果还没完成加载,会同步等待完成
    if (bundleReq.assetBundle == null)
        throw new Exception("Failed to load asset bundle");

    var prefabReq = bundleReq.assetBundle.LoadAssetAsync<GameObject>("Assets/Cube.prefab");

    // 访问asset属性时,如果还没完成加载,会同步等待完成
    if (prefabReq.asset == null)
        throw new Exception("Failed to load prefab");

    bundleReq.assetBundle.Unload(true);

    Debug.Log("svc: " + svc.name); // svc为空,抛异常
}

在Windows上测试打包版本,不管是用Mono,还是IL2CPP,都会报错。另外,还测试了使用同步接口(AssetBundle.LoadFromFileAssetBundle.LoadAsset)进行加载的情况,发现并不会报错。

查看Unity源码,发现Resources.UnloadUnusedAssets是创建了一个操作对象,放入PreloadManager的队列中异步执行,而异步加载AssetBundle,也是创建一个操作对象,放入同一个队列中执行。由于Resources.UnloadUnusedAssets的操作对象在前面,所以Unity会先执行它,再执行异步加载AssetBundle。

通过Profiler确认下执行顺序:

可以看到PreloadManager线程,在加载AssetBundle前,花了很长的时间在Application.Wait for Integration,这是在等待主线程。而此时主线程在执行GarbageCollectAssetsProfile,这项就是Resources.UnloadUnusedAssets的操作对象执行的逻辑。

到此为止,确定了在svc变成空对象之前,确实会执行Resources.UnloadUnusedAssets。但是还需要确定它是不是真的不会考虑栈上的对象。

查看关于Resources.UnloadUnusedAssets文档,发现Unity已经写明了这点:

The script execution stack, however, is not examined so an asset referenced only from within the script stack will be unloaded. All assets other than ScriptableObjects are loaded back in the next time one of its properties or methods is used. This requires extra care for assets which have been modified in memory. Make sure to call EditorUtility.SetDirty before an asset garbage collection is triggered.

修复

回到项目中的代码,虽然LoadShader会调用Addressables加载资源,但我们定制过资源加载的逻辑,这里应该会调用AssetBundle的同步接口进行加载,所以不应该有这个问题。翻了相关代码的改动记录,发现是之前的一次改动错误地导致这里变成使用异步接口加载了。把那次改动的问题修复后,就不会报错了。

链接