Unity 开发“踩坑图鉴”:从 ScriptableObject 到 DOTS

一、ScriptableObject:序列化不是银弹,滥用就是定时炸弹

 1.1 把配置当“小数据库”——为什么 20 MB 的 ScriptableObject 会让 Editor 打开即卡死

错误姿势: 

[CreateAssetMenu(menuName="Game/AllItems")]
public class AllItems : ScriptableObject
{
    [SerializeField] private List<ItemConfig> items; // 2 万条数据
}

 Unity 会为每个元素在 YAML 里写完整类型名,2 万条直接 20 MB+。一打开 Inspector 就全量反序列化,主线程卡死。

正确姿势:

[CreateAssetMenu(menuName="Game/ItemSheet")]
public class ItemSheet : ScriptableObject, ISerializationCallbackReceiver
{
    // Editor 下用字典方便查表
    public Dictionary<int, ItemConfig> id2Cfg = new();
    
    // 真正序列化的是这两个数组
    [SerializeField] private int[] _keys;
    [SerializeField] private ItemConfig[] _values;
    
    public void OnBeforeSerialize()
    {
        _keys = id2Cfg.Keys.ToArray();
        _values = id2Cfg.Values.ToArray();
    }
    public void OnAfterDeserialize()
    {
        id2Cfg.Clear();
        for (int i = 0; i < _keys.Length; i++)
            id2Cfg[_keys[i]] = _values[i];
    }
}

把字典拆成平行数组,YAML 体积瞬间降到 2 MB,Inspector 首次打开 < 200 ms。

1.2 ScriptableObject 的引用链陷阱

  • 不要在 ScriptableObject 里直接引用 Texture2DAudioClip 等重量级资源。

  • 建议用 AssetReference(Addressable)或 ResourcePath 字符串 + 延迟加载。

[System.Serializable]
public class IconRef
{
    [SerializeField] private string _address;
    private Sprite _cache;
    public Sprite Icon => _cache ??= Addressables.LoadAssetAsync<Sprite>(_address).WaitForCompletion();
}

二、AssetBundle 热更:依赖地狱与内存“双杀”

2.1 依赖收集不完整 → 运行时粉色材质

错误代码:

BuildPipeline.BuildAssetBundles(output,
    new AssetBundleBuild[]
    {
        new AssetBundleBuild
        {
            assetBundleName = "ui",
            assetNames = new[] { "Assets/Prefabs/UI/PanelA.prefab" }
        }
    },
    BuildAssetBundleOptions.ChunkBasedCompression,
    EditorUserBuildSettings.activeBuildTarget);

PanelA.prefab 引用了公共图集 SpriteAtlas,但 SpriteAtlas 没打进来,真机运行全粉。

修复:

[MenuItem("Build/CollectBundle")]
static void Collect()
{
    var map = new Dictionary<string, string>(); // assetPath -> bundleName
    Collect("Assets/Prefabs/UI", "ui", map);
    Collect("Assets/Atlas", "common", map);

    var builds = map.GroupBy(kv => kv.Value)
                    .Select(g => new AssetBundleBuild
                    {
                        assetBundleName = g.Key,
                        assetNames = g.Select(x=>x.Key).ToArray()
                    }).ToArray();
    BuildPipeline.BuildAssetBundles(output, builds,
        BuildAssetBundleOptions.ChunkBasedCompression,
        EditorUserBuildSettings.activeBuildTarget);
}

2.2 重复加载导致内存爆炸

永远用 AssetBundleRequest 的缓存封装:

public static class AssetBundleCache
{
    private static readonly Dictionary<string, AssetBundle> _cache = new();
    public static AssetBundle Load(string path)
    {
        if (_cache.TryGetValue(path, out var ab)) return ab;
        return _cache[path] = AssetBundle.LoadFromFile(path);
    }
    public static void Unload(string path, bool unloadAll = false)
    {
        if (!_cache.Remove(path, out var ab)) return;
        ab.Unload(unloadAll);
    }
}

三、UGUI:看似自动回收,实则“内存钉子户”

3.1 事件监听忘记移除

public class BagPanel : MonoBehaviour
{
    private void OnEnable()  => BagModel.OnItemChanged += Refresh;
    private void OnDisable() => BagModel.OnItemChanged -= Refresh;
}

很多开发者把面板做成 DontDestroyOnLoad 的“常驻节点”,切场景后 OnDisable 不触发,事件一直挂着。解决:

  • OnDestroy 里再解一次;

  • 或者使用弱事件:

public static class WeakEvent<T> where T : class
{
    private static readonly List<WeakReference<T>> _listeners = new();
    public static void Add(T t) => _listeners.Add(new WeakReference<T>(t));
    public static void Invoke(Action<T> action)
    {
        _listeners.RemoveAll(r => !r.TryGetTarget(out var t) || t == null);
        foreach (var r in _listeners) action(r.Target);
    }
}

3.2 图集常驻内存

SpriteAtlasManager.atlasRequested 动态加载的图集要在场景卸载时主动 Resources.UnloadAsset(atlas),否则图集会常驻显存。

四、DOTS/ECS:不是“换个 API”,而是“换个脑子”

4.1 把 MonoBehaviour 直接塞进 IComponentData

错误:

public struct Health : IComponentData
{
    public MonoBehaviour fx; // 不能放托管类型
}

正确:

public struct Health : IComponentData
{
    public Entity fxEntity; // 用 Entity 指向特效实体
}

4.2 System 中每帧 Entities.ForEach 查询写逻辑

partial struct DamageSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        var ecbSingleton = SystemAPI.GetSingleton<EndSimulationEntityCommandBufferSystem.Singleton>();
        var ecb = ecbSingleton.CreateCommandBuffer(state.WorldUnmanaged);

        new ProcessJob { ECB = ecb }.Schedule();
    }

    [BurstCompile]
    partial struct ProcessJob : IJobEntity
    {
        public EntityCommandBuffer ECB;
        void Execute(Entity e, ref Health hp, in Damage dmg)
        {
            hp.Value -= dmg.Value;
            if (hp.Value <= 0)
                ECB.DestroyEntity(e);
        }
    }
}
  • 用 ECB 解决并行写回问题;

  • [BurstCompile] + IJobEntity 保证 Burst 优化。

4.3 内存布局陷阱

public struct TransformData : IComponentData
{
    public float3 position; // 12 字节
    public int   layer;     // 4 字节
    // 这里对齐到 16 字节,浪费 0 字节;但如果把 layer 换成 bool,会填充 3 字节,导致 SIMD 不友好
}

最佳实践:把 float3/float4 放一起,int/uint 放一起,减少 padding。

五、IL2CPP 崩溃:如何读懂天书堆栈

5.1 符号缺失

  • Windows:Unity\Editor\Data\PlaybackEngines\AndroidPlayer\Variations\il2cpp\Release\Symbols

  • macOS:Unity.app/Contents/PlaybackEngines/AndroidPlayer/Variations/il2cpp/Release/Symbols
    libil2cpp.sym.so 上传到 CrashSight / Firebase,就能还原函数名。

5.2 常见崩溃 Top3

  • 空委托:C# 编译器优化后空委托在 IL2CPP 里直接 deref。解决:所有事件先 ?.Invoke()

  • 泛型递归 AOT 没生成:用 [Preserve]link.xml

  • 多线程访问托管对象:Unity 主线程外的 Job 线程不能访问任何 UnityEngine.Object

六、性能:Profiler 不是“看热闹”,而是“破案”

6.1 GC 峰值

void Update()
{
    // 每帧 1000 次字符串拼接
    string s = "";
    for (int i = 0; i < 1000; i++) s += i.ToString();
}

换成 ValueStringBuilder(Unity 2022.2+ 内置)或 ZString

6.2 CPU Stsssssssssssssssallssssss

在 Timeline 里看到 Gfx.WaitForPresent → 说明 GPU 繁忙。

  • 检查 Overdraw:Frame Debugger 看有没有透明全屏 UI;

  • 检查 SetPassCall:合批失败通常是材质球参数不一致,把颜色放到顶点色而非材质属性。'

6.3 GPU Instancing 失效

  • 材质勾了 Enable GPU Instancing 但 Shader 里没 #pragma multi_compile_instancing

  • 动态合批与 Instancing 互斥,关闭 Dynamic Batching

七、代码仓库级别的“约定优于配置”

7.1 强制代码格式化

Assets/csc.rsp 里加:

-nullable:enable
-warnaserror+

配合 .editorconfig,CI 里用 dotnet format --verify-no-changes

7.2 自动生成 link.xml

[InitializeOnLoad]
public static class LinkXmlGenerator
{
    static LinkXmlGenerator()
    {
        var sb = new StringBuilder();
        sb.AppendLine("<linker>");
        foreach (var type in TypeCache.GetTypesWithAttribute<PreserveAttribute>())
            sb.AppendLine($"  <type fullname=\"{type.FullName}\" preserve=\"all\"/>");
        sb.AppendLine("</linker>");
        File.WriteAllText("Assets/link.xml", sb.ToString());
    }
}

这样再也不用手写 500 行 link.xml。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值