在Unity NativeArray与Span之间零拷贝互转的两种方法

解读

国内一线厂面试时,这道题不是考“会不会写”,而是考“敢不敢写、写得安不安全”。
NativeArray 与 Span 都是 unmanaged memory 的视图,二者互转必须满足:

  1. 绝对零拷贝(不 new、不 ToArray、不 Marshal);
  2. 线程与 Burst 安全;
  3. 在 Unity 2021 LTS 以后版本可编译通过;
  4. 对 IL2CPP 与 AOT 友好。
    如果答“用 ToArray 再 AsSpan”直接挂;答“用 unsafe 指针但说不清生命周期”也挂。
    必须给出 两种官方或半官方零拷贝路线,并指出各自的 生命周期管理细节平台陷阱

知识点

  1. NativeArray<T>.GetUnsafePtr() / GetUnsafeReadOnlyPtr()
    返回 void*,生命周期与 NativeArray 绑定,Burst 兼容。
  2. Span<T> 的构造函数
    public Span(void* pointer, int length) 仅在 unsafe 上下文生效,需要 Unity 2021.2+ 才带此 API。
  3. UnsafeUtility.AsRef<T>UnsafeUtility.AsSpanRef<T>
    Unity 2022.1 新增,内部直接 reinterpret_cast,不分配 GC。
  4. IL2CPP 陷阱
    Span<T> 带指针构造必须加 [IL2CPP]UnsafeUtility.SkipInitUnity.Burst.CompilerServices.SkipLocalsInit,否则 AOT 会插零初始化,产生拷贝。
  5. Jobs/Burst 约束
    在 IJobParallelForBurst 里只能用 readonlywriteonly 的 NativeArray,因此 Span 必须声明为 readonly refref readonly,否则编译失败。
  6. Mono 与 Android ARM32 对齐
    若 T 含 bool/decimal,需保证 sizeof(T)%4==0,否则 ARM32 下 Span 索引器会崩溃。

答案

方法一:unsafe 指针构造(Unity 2021.2+ 官方路线)

public static Span<T> AsSpan<T>(this NativeArray<T> na) where T : unmanaged
{
    unsafe
    {
        return new Span<T>(na.GetUnsafePtr(), na.Length);
    }
}

要点

  1. 调用端必须加 unsafe 关键字,Assembly 勾选 Allow Unsafe
  2. 返回的 Span 生命周期 不能超过 NativeArray;一旦 NativeArray.Dispose,Span 立刻悬空。
  3. 在 IL2CPP Release 打包时,需在 Assets/link.xml 里保留 System.Span<T> 的完整泛型实例,防止 AOT 裁剪。

方法二:UnsafeUtility.AsSpanRef(Unity 2022.1+ 零拷贝封装)

public static Span<T> AsSpan<T>(ref this NativeArray<T> na) where T : unmanaged
{
    unsafe
    {
        ref T head = ref UnsafeUtility.AsRef<T>(na.GetUnsafePtr());
        return MemoryMarshal.CreateSpan(ref head, na.Length);
    }
}

要点

  1. ref NativeArray 保证调用者不能传入临时副本,杜绝 Dispose 后悬针
  2. MemoryMarshal.CreateSpan 是 .NET Standard 2.1 正式 API,IL2CPP 已完整支持,无需额外 link.xml。
  3. Burst 1.8+ 可内联此函数,性能与指针方案相同,但 无需 unsafe 块,仅需 Unity.Burst.CompilerServices.SkipLocalsInit 特性即可。

两种方法对比

  • 方法一在 Unity 2021 LTS 即可上线,国内存量项目最多;
  • 方法二在 Unity 2022.3 LTS 后成为 官方推荐,代码更短且对 Roslyn Analyzers 友好,无需 unsafe 关键字,通过 ref + MemoryMarshal 完成零拷贝。

拓展思考

  1. 双向转换:Span→NativeArray 只能走 NativeArrayUnsafeUtility.ConvertExistingDataToNativeArray,但需手动 AtomicSafetyHandle 绑定,否则 Main-Thread 与 Job-System 并发检查会抛 InvalidOperationException
  2. 只读优化:若 Span 仅做读取,可返回 ReadOnlySpan<T>,避免 WriteBack 带来的 Cache Miss;此时 NativeArray 需用 GetUnsafeReadOnlyPtr,并在 Job 中标记 [ReadOnly]
  3. 大数组分页:当 Length > 2048*1024 时,Android 32-bit 进程 虚拟地址连续 可能失败,建议拆分为 NativeSlice<T> 再转 Span,防止 mmap ENOMEM
  4. 版本升级坑:Unity 2023.2 起默认开启 STRICT_MEMORY_SAFETY,若 Span 生命周期超过 NativeArray.Dispose 会触发 SIGSEGV,国内很多项目升级后 闪退,需加 DisposeSentinelUse-After-Free 检查。