如何在编译完成后自动刷新ScriptableSingleton数据

解读

国内 Unity 项目普遍把 ScriptableObject 单例(ScriptableSingleton<T>)当作“全局配置容器”或“运行时数据中心”。
编译完成后数据不刷新会带来两大痛点:

  1. 热重载(Hot Reload)后,旧数据残留在内存,导致策划在 Inspector 里改完数值,运行游戏却看不到效果;
  2. 打包机做 CI 时,如果 ScriptableSingleton 里缓存了“上一次构建的哈希/版本号”,下一次构建就会误判增量,造成资源冗余或版本错乱。

因此,“编译完成” 这一时间点必须被精准捕获,并触发 ScriptableSingleton 的强制卸载+重建,才能保证数据与磁盘 ScriptableObject 资产完全一致。

知识点

  1. ScriptableSingleton<T> 本质是一个泛型静态类,内部持有 T(继承 ScriptableObject)的静态引用;首次访问时通过 AssetDatabase.LoadAssetAtPath 或 Resources.Load 把磁盘资产加载到内存,之后不再释放。
  2. Unity 编译管线
    • 域重载(Domain Reload)阶段会重置所有静态字段,但 ScriptableSingleton 通常自己做“延迟初始化”或“缓存不为空判断”,导致旧引用被重新赋回,出现“伪刷新”。
    • 资产导入后(AssetPostprocessor)与编译后(IPreprocessBuildWithReport / IPostprocessBuildWithReport)是两个独立事件;后者才是“代码编译完成”的准确时机。
  3. 刷新策略
    • 暴力卸载:EditorUtility.UnloadUnusedAssetsImmediate + AssetDatabase.Refresh 可清掉内存中所有未引用的 ScriptableObject;
    • 定向重建:在 ScriptableSingleton 内部暴露 static void Reload(),主动调用 Resources.UnloadAsset(instance) 并重新 LoadAsset;
    • 事件订阅:使用 [InitializeOnLoadMethod] 在编辑器启动时注册回调,监听 AssemblyReloadEvents.afterAssemblyReload 或 EditorApplication.delayCall + EditorApplication.update 组合,确保在编译后第一帧执行 Reload。
  4. 性能与线程安全
    • 卸载操作必须在主线程完成,不可放子线程;
    • 若 ScriptableSingleton 被其他静态构造引用,需先置空静态字段再卸载,否则 Unity 会阻止卸载并输出“Asset is being used”警告。

答案

  1. 在 Editor 文件夹下新建 ScriptableSingletonRefresher.cs:
using UnityEditor;
using UnityEngine;

[InitializeOnLoad]
public static class ScriptableSingletonRefresher
{
    // 确保只执行一次
    private static bool _pending;

    static ScriptableSingletonRefresher()
    {
        // 1. 域重载后回调
        AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
        // 2. 作为兜底,监听下一次 Editor 更新
        EditorApplication.update += OnEditorUpdate;
    }

    private static void OnAfterAssemblyReload()
    {
        _pending = true;
    }

    private static void OnEditorUpdate()
    {
        if (!_pending) return;
        _pending = false;

        // 强制刷新所有自定义 ScriptableSingleton
        var baseType = typeof(ScriptableSingleton<>);
        var assemblies = System.AppDomain.CurrentDomain.GetAssemblies();
        foreach (var ass in assemblies)
        {
            foreach (var t in ass.GetTypes())
            {
                if (!t.IsAbstract && t.BaseType != null && t.BaseType.IsGenericType &&
                    t.BaseType.GetGenericTypeDefinition() == baseType)
                {
                    // 通过反射调用泛型类的 static Reload 方法
                    var reload = t.GetMethod("Reload", System.Reflection.BindingFlags.Static |
                                                        System.Reflection.BindingFlags.Public |
                                                        System.Reflection.BindingFlags.NonPublic);
                    reload?.Invoke(null, null);
                }
            }
        }

        // 清掉未使用资产
        EditorUtility.UnloadUnusedAssetsImmediate();
        AssetDatabase.Refresh();
    }
}
  1. 在 ScriptableSingleton<T> 基类里提供 Reload:
public abstract class ScriptableSingleton<T> : ScriptableObject where T : ScriptableObject
{
    private static T _instance;

    public static T Instance
    {
        get
        {
            if (_instance == null) Load();
            return _instance;
        }
    }

    private static void Load()
    {
        var guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}");
        if (guids.Length == 0)
        {
            Debug.LogError($"找不到 {typeof(T).Name} 资产");
            return;
        }
        var path = AssetDatabase.GUIDToAssetPath(guids[0]);
        _instance = AssetDatabase.LoadAssetAtPath<T>(path);
    }

    public static void Reload()
    {
        if (_instance != null)
        {
            Resources.UnloadAsset(_instance);
            _instance = null;
        }
        Load();
    }
}
  1. 结果
    代码编译完成后,域重载触发 afterAssemblyReload → 下一帧统一调用 Reload → 内存中的 ScriptableSingleton 与磁盘资产完全一致,策划改数值、CI 做版本号都能实时生效,无需手动重启 Unity。

拓展思考

  1. 增量打包场景
    如果 ScriptableSingleton 里缓存了“上次构建的 AssetBundle 哈希”,可在 IPostprocessBuildWithReport 里把哈希写回磁盘,再触发 Reload,保证下一次 CI 能正确比对增量。
  2. 大型项目性能
    反射扫描全部类型会带来 50~80 ms 的 Editor 顿卡,可改为手动注册表:在基类里维护 static List<System.Action> Reloaders,子类在静态构造中把自身 Reload 委托添加进去,避免全量反射。
  3. 运行时热更
    在 Addressables 或 HybridCLR 热更后,客户端同样需要“刷新”配置单例。可封装一套运行时 Reload 接口,通过 Addressables.Release 再重新 LoadAssetAsync,实现不停服更新数值
  4. 团队协作规范
    国内很多策划会把 ScriptableObject 当 Excel 用,直接在 Inspector 里改数值。务必加公司级强制规则:所有 ScriptableSingleton 资产必须勾选“Editor Only”或打入专属 AssetBundle,防止被打包进客户端,造成数据泄露包体膨胀