如何在编译完成后自动刷新ScriptableSingleton数据
解读
国内 Unity 项目普遍把 ScriptableObject 单例(ScriptableSingleton<T>)当作“全局配置容器”或“运行时数据中心”。
编译完成后数据不刷新会带来两大痛点:
- 热重载(Hot Reload)后,旧数据残留在内存,导致策划在 Inspector 里改完数值,运行游戏却看不到效果;
- 打包机做 CI 时,如果 ScriptableSingleton 里缓存了“上一次构建的哈希/版本号”,下一次构建就会误判增量,造成资源冗余或版本错乱。
因此,“编译完成” 这一时间点必须被精准捕获,并触发 ScriptableSingleton 的强制卸载+重建,才能保证数据与磁盘 ScriptableObject 资产完全一致。
知识点
- ScriptableSingleton<T> 本质是一个泛型静态类,内部持有 T(继承 ScriptableObject)的静态引用;首次访问时通过 AssetDatabase.LoadAssetAtPath 或 Resources.Load 把磁盘资产加载到内存,之后不再释放。
- Unity 编译管线
- 域重载(Domain Reload)阶段会重置所有静态字段,但 ScriptableSingleton 通常自己做“延迟初始化”或“缓存不为空判断”,导致旧引用被重新赋回,出现“伪刷新”。
- 资产导入后(AssetPostprocessor)与编译后(IPreprocessBuildWithReport / IPostprocessBuildWithReport)是两个独立事件;后者才是“代码编译完成”的准确时机。
- 刷新策略
- 暴力卸载:EditorUtility.UnloadUnusedAssetsImmediate + AssetDatabase.Refresh 可清掉内存中所有未引用的 ScriptableObject;
- 定向重建:在 ScriptableSingleton 内部暴露 static void Reload(),主动调用 Resources.UnloadAsset(instance) 并重新 LoadAsset;
- 事件订阅:使用 [InitializeOnLoadMethod] 在编辑器启动时注册回调,监听 AssemblyReloadEvents.afterAssemblyReload 或 EditorApplication.delayCall + EditorApplication.update 组合,确保在编译后第一帧执行 Reload。
- 性能与线程安全
- 卸载操作必须在主线程完成,不可放子线程;
- 若 ScriptableSingleton 被其他静态构造引用,需先置空静态字段再卸载,否则 Unity 会阻止卸载并输出“Asset is being used”警告。
答案
- 在 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();
}
}
- 在 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();
}
}
- 结果
代码编译完成后,域重载触发 afterAssemblyReload → 下一帧统一调用 Reload → 内存中的 ScriptableSingleton 与磁盘资产完全一致,策划改数值、CI 做版本号都能实时生效,无需手动重启 Unity。
拓展思考
- 增量打包场景
如果 ScriptableSingleton 里缓存了“上次构建的 AssetBundle 哈希”,可在 IPostprocessBuildWithReport 里把哈希写回磁盘,再触发 Reload,保证下一次 CI 能正确比对增量。 - 大型项目性能
反射扫描全部类型会带来 50~80 ms 的 Editor 顿卡,可改为手动注册表:在基类里维护 static List<System.Action> Reloaders,子类在静态构造中把自身 Reload 委托添加进去,避免全量反射。 - 运行时热更
在 Addressables 或 HybridCLR 热更后,客户端同样需要“刷新”配置单例。可封装一套运行时 Reload 接口,通过 Addressables.Release 再重新 LoadAssetAsync,实现不停服更新数值。 - 团队协作规范
国内很多策划会把 ScriptableObject 当 Excel 用,直接在 Inspector 里改数值。务必加公司级强制规则:所有 ScriptableSingleton 资产必须勾选“Editor Only”或打入专属 AssetBundle,防止被打包进客户端,造成数据泄露或包体膨胀。