Unity入门教程之异步篇第六节:对Job System的高级封装

Unity入门教程之异步篇第一节:协程基础扫盲--非 Mono 类如何也能启动协程?-CSDN博客

Unity入门教程之异步篇第二节:协程 or UniTask?Unity 中异步流程到底怎么选-CSDN博客

Unity入门教程之异步篇第三节:多线程初探?理解并发与线程安全-CSDN博客

Unity入门教程之异步篇第四节:Unity 高性能计算?Job System 与 Burst Compiler !-CSDN博客

Unity入门教程之异步篇第五节:对UniTask的高级封装-CSDN博客

Unity入门教程之异步篇第六节:对Job System的高级封装

为什么要对 Job System 进行二次封装?

在上一篇文章中,我们对Unitask进行了高级封装,目的是提高代码可读性、简洁性、复用性等,同样的道理,我们当然也可以且应当对Job System进行高级封装。Job System 的设计哲学是低层和数据导向,这赋予了它极致的性能。但这种低层特性也带来了开发上的挑战:

  1. 大量的样板代码: 每次定义一个 Job,都需要实现 IJobIJobFor 接口,手动创建、填充、调度 NativeContainer,并管理 JobHandle 的依赖和完成。这些重复性的代码不仅耗时,还容易出错。
  2. 陡峭的学习曲线: 对于不熟悉数据导向设计(Data-Oriented Design, DOD)和原生内存管理的开发者来说,NativeArrayJobHandleDependencyAllocator 等概念是全新的,理解并正确使用它们需要时间和经验。
  3. 数据流转的复杂性: Job System 强制我们使用 NativeContainer 进行数据传递,这意味着需要在主线程和 Job 之间进行数据拷贝和转换。手动管理 NativeContainer 的生命周期(Dispose)更是容易遗漏,导致内存泄漏。
  4. 缺乏高层语义: 原生的 Job API 专注于"如何并行计算",而不是"我们要计算什么"。这使得代码难以直接表达业务逻辑,可读性较低。
  5. 调试相对困难: Job 在后台线程执行,其内部无法直接使用 Debug.Log,传统的调试器也难以跟踪。虽然 Unity 提供了 Job Profiler,但与单线程代码的调试体验仍有差距。

因此,二次封装的根本目的就是 在保留 Job System 强大性能优势的同时,大幅降低其使用门槛,提升开发效率和代码的可维护性。


对 Job System 进行二次封装的思路与设计意图

封装 Job System 的核心思路是:将通用的、重复的底层操作抽象出来,提供更简洁、更高层、更贴近业务逻辑的 API。 我们的设计意图主要体现在以下几个方面:

  1. 自动化资源管理: 核心目标是自动化 NativeContainer 的创建、填充和最关键的销毁 ,避免开发者手动调用 Dispose 造成的内存泄漏风险。
  2. 简化 Job 调度: 提供更直接的 Job 调度方法,可能通过 async/awaitUniTask 集成,让 Job 能够无缝融入异步工作流。
  3. 抽象数据流转: 允许开发者使用更熟悉的 C# 类型(如 List<T>、数组)作为输入和输出,封装内部负责这些类型与 NativeContainer 之间的转换。
  4. 提高语义表达: 封装后的方法名称应该清晰地表达其业务目的,而不是底层的 Job 执行细节。
  5. 内置错误处理: 尽管 Job 内部不能直接 try-catch,但封装层可以在 Job 完成后检查其状态或通过结果回调传递信息。

如何进行二次封装(代码示例与设计意图)

我们将通过三个层面的封装来展示:

  1. 基础 Job 执行器: 简化最常见的 Job 调度和等待。
  2. 数据绑定 Job 执行器: 自动化数据在 C# 类型和 NativeContainer 之间的转换。
  3. 业务逻辑 Job 封装: 提供高层、业务相关的并行计算接口。

1. 基础 Job 执行器:JobRunner

这个工具类旨在简化 Job 的创建、调度和完成。

csharp 复制代码
using Unity.Jobs;
using Unity.Collections;
using System;
using UnityEngine;
using Cysharp.Threading.Tasks; // 引入 UniTask

/// <summary>
/// 简化 Job 执行的通用工具类。
/// </summary>
public static class JobRunner
{
    /// <summary>
    /// 同步执行一个 Job 并等待其完成。适用于少量数据或需要立即结果的场景。
    /// 此方法会阻塞主线程直到 Job 完成。
    /// </summary>
    /// <typeparam name="TJob">实现 IJob 接口的 Job 类型。</typeparam>
    /// <param name="job">要执行的 Job 实例。</param>
    public static void RunJobSync<TJob>(TJob job) where TJob : struct, IJob
    {
        JobHandle handle = job.Schedule();
        handle.Complete(); // 强制等待 Job 完成,会阻塞当前线程
        Debug.Log($"Job {typeof(TJob).Name} 同步完成.");
    }

    /// <summary>
    /// 异步调度一个 IJob,并返回一个 UniTask 以便 await 其完成。
    /// </summary>
    /// <typeparam name="TJob">实现 IJob 接口的 Job 类型。</typeparam>
    /// <param name="job">要执行的 Job 实例。</param>
    /// <param name="inputDependencies">可选的 JobHandle 依赖项。</param>
    /// <returns>一个 UniTask,表示 Job 的异步完成。</returns>
    public static async UniTask ScheduleJobAsync<TJob>(TJob job, JobHandle inputDependencies = default) where TJob : struct, IJob
    {
        JobHandle handle = job.Schedule(inputDependencies);
        await handle.ToUniTask(); // UniTask 提供了 JobHandle 的扩展方法,使其可 await
        Debug.Log($"Job {typeof(TJob).Name} 异步完成.");
    }

    /// <summary>
    /// 异步调度一个 IJobFor,并返回一个 UniTask 以便 await 其完成。
    /// </summary>
    /// <typeparam name="TJob">实现 IJobFor 接口的 Job 类型。</typeparam>
    /// <param name="job">要执行的 Job 实例。</param>
    /// <param name="length">Job 迭代的总次数(通常是数据数组的长度)。</param>
    /// <param name="innerloopBatchCount">内循环批处理数量,通常为 1-128。</param>
    /// <param name="inputDependencies">可选的 JobHandle 依赖项。</param>
    /// <returns>一个 UniTask,表示 Job 的异步完成。</returns>
    public static async UniTask ScheduleJobForAsync<TJob>(TJob job, int length, int innerloopBatchCount = 64, JobHandle inputDependencies = default) where TJob : struct, IJobFor
    {
        JobHandle handle = job.Schedule(length, innerloopBatchCount, inputDependencies);
        await handle.ToUniTask();
        Debug.Log($"JobFor {typeof(TJob).Name} 异步完成,处理 {length} 个元素.");
    }
}

// --- 示例 Job 定义 (用于测试 JobRunner) ---
public struct MySimpleCalculationJob : IJob
{
    public NativeArray<int> Result; // Job 的结果通常通过 NativeContainer 传递

    public void Execute()
    {
        // 模拟一个简单的计算
        Result[0] = 100 + 23;
        // Debug.Log("Job内部不能直接使用 Debug.Log!");
    }
}

public struct MyArrayDoublerJob : IJobFor
{
    [ReadOnly] public NativeArray<float> Input;
    [WriteOnly] public NativeArray<float> Output;

    public void Execute(int index)
    {
        Output[index] = Input[index] * 2.0f;
    }
}

设计意图和写法解析:

  • 同步与异步分离: 提供了 RunJobSyncScheduleJobAsync/ScheduleJobForAsyncRunJobSync 适合那些计算量极小,或者你明确知道需要在当前帧立刻拿到结果的场景,但需要注意它会阻塞主线程。异步方法则利用 UniTaskToUniTask() 扩展,将 JobHandle 转换为可 awaitUniTask,从而实现非阻塞的等待。
  • 隐藏 JobHandle.Complete() JobHandle.Complete() 是一个阻塞操作。在封装中,我们将它隐藏在 RunJobSync 内部,或者通过 await handle.ToUniTask() 交由 UniTask 异步管理其完成,避免开发者忘记调用或错误调用。
  • 参数语义化: ScheduleJobForAsync 方法直接接收 lengthinnerloopBatchCount,这是 IJobFor 最重要的调度参数,清晰明了。
  • 泛型设计: JobRunner 接受任何实现 IJobIJobForstruct 作为泛型参数,确保了其通用性。
  • 日志: 增加了简单的日志输出,方便追踪 Job 的执行状态。

2. 数据绑定 Job 执行器:DataProcessingJobRunner

这个封装旨在自动化数据在 C# 集合类型和 NativeContainer 之间的转换,并自动管理 NativeContainer 的生命周期。

csharp 复制代码
using Unity.Jobs;
using Unity.Collections;
using System;
using UnityEngine;
using Cysharp.Threading.Tasks;
using System.Collections.Generic;

/// <summary>
/// 封装 Job 执行和数据传递,自动化 NativeArray 的管理。
/// </summary>
public static class DataProcessingJobRunner
{
    /// <summary>
    /// 异步执行一个处理浮点数组的 Job,并返回处理后的结果。
    /// 自动处理输入数据的 NativeArray 转换和输出 NativeArray 的生命周期。
    /// </summary>
    /// <typeparam name="TJob">实现 IJobFor 接口的 Job 类型。</typeparam>
    /// <param name="inputData">要处理的输入数据列表。</param>
    /// <param name="jobFactory">一个函数,传入输入 NativeArray 和输出 NativeArray,返回配置好的 Job 实例。</param>
    /// <param name="batchCount">IJobFor 的内循环批处理大小。</param>
    /// <returns>包含处理后结果的 UniTask,返回类型为 float[]。</returns>
    public static async UniTask<float[]> ProcessFloatArrayAsync<TJob>(
        IList<float> inputData, // 使用 IList 增加兼容性
        Func<NativeArray<float>, NativeArray<float>, TJob> jobFactory, // 提供工厂函数来创建 Job
        int batchCount = 64)
        where TJob : struct, IJobFor
    {
        if (inputData == null || inputData.Count == 0)
        {
            return Array.Empty<float>();
        }

        // --- 核心设计:使用 using 语句块确保 NativeArray 被自动 Dispose ---
        using var inputNativeArray = new NativeArray<float>(inputData.Count, Allocator.TempJob);
        for(int i = 0; i < inputData.Count; i++)
        {
            inputNativeArray[i] = inputData[i]; // 填充数据
        }
        
        using var outputNativeArray = new NativeArray<float>(inputData.Count, Allocator.TempJob);

        TJob job = jobFactory(inputNativeArray, outputNativeArray);

        JobHandle handle = job.Schedule(inputData.Count, batchCount);
        await handle.ToUniTask(); // 等待 Job 完成

        // Job 完成后,结果在 outputNativeArray 中,将其拷贝回常规 C# 数组
        float[] results = outputNativeArray.ToArray();
        Debug.Log($"JobFor {typeof(TJob).Name} 处理 {inputData.Count} 个浮点数据完成.");
        return results;
    }

    // 可以为其他数据类型(int, Vector3, struct等)添加类似的重载方法
    // 例如:
    /*
    public static async UniTask<int[]> ProcessIntArrayAsync<TJob>(
        IList<int> inputData,
        Func<NativeArray<int>, NativeArray<int>, TJob> jobFactory,
        int batchCount = 64)
        where TJob : struct, IJobFor
    {
        // 类似上面的实现
    }
    */
}

设计意图和写法解析:

  • 自动化 NativeContainer 生命周期: 最核心的设计是使用 using var 语句 来声明 NativeArray。当 using 块结束时(无论正常结束还是抛出异常),NativeArrayDispose() 方法都会被自动调用,这极大地降低了内存泄漏的风险,也减少了手动管理资源的样板代码。Allocator.TempJob 是一个很好的默认选择,因为它允许 Job 在调度完成后自行释放内存。
  • C# 类型到 NativeContainer 的转换: 方法签名接受 IList<float> 作为输入,这是标准的 C# 集合类型。封装内部负责将 IList 的数据拷贝到 NativeArray 中,再传递给 Job。同样,Job 完成后,将 NativeArray 的结果拷贝回 float[] 返回给调用方。
  • Job 工厂模式: Func<NativeArray<float>, NativeArray<float>, TJob> jobFactory 允许调用方传入一个 lambda 表达式来创建和配置 Job 实例。这样,DataProcessingJobRunner 就不需要知道具体的 Job 逻辑,只负责其生命周期和数据流转。
  • 泛型和类型安全: 继续使用泛型确保了代码的通用性和类型安全。
  • 错误处理: 虽然 NativeArrayDispose 是自动的,但方法内部仍可以加入对空输入或其他异常的检查。

3. 业务逻辑 Job 封装:SimplePathfinder

这个封装层提供的是高层、业务相关的 API,完全隐藏了 Job System 的底层细节。

csharp 复制代码
using Unity.Jobs;
using Unity.Collections;
using UnityEngine;
using System.Collections.Generic;
using Unity.Mathematics; // 用于 float3
using Cysharp.Threading.Tasks;
using System;

/// <summary>
/// 封装一个简化的寻路 Job 系统,对外提供高层 API。
/// </summary>
public class SimplePathfinder
{
    // --- 内部 Job 定义 ---
    // 这个 Job 只是一个示例,实际寻路 Job 会复杂得多,可能需要图数据等
    public struct PathDistanceJob : IJobFor
    {
        [ReadOnly] public NativeArray<float3> Waypoints;
        [WriteOnly] public NativeArray<float> SegmentDistances; // 存储每段路径的距离

        public void Execute(int index)
        {
            if (index == 0)
            {
                SegmentDistances[index] = 0.0f; // 起点到起点的距离为0
            }
            else
            {
                // 计算当前点到前一个点的距离
                SegmentDistances[index] = math.distance(Waypoints[index - 1], Waypoints[index]);
            }
        }
    }

    /// <summary>
    /// 异步计算给定路径中各分段的距离。
    /// 隐藏 Job System 的所有底层细节。
    /// </summary>
    /// <param name="waypoints">路径点的列表 (Vector3)。</param>
    /// <returns>包含每段距离的 UniTask。</returns>
    public async UniTask<List<float>> CalculateWaypointDistancesAsync(List<Vector3> waypoints)
    {
        if (waypoints == null || waypoints.Count < 2)
        {
            Debug.LogWarning("路径点不足,无法计算距离。");
            return new List<float>();
        }

        // --- 内部处理 NativeArray 的创建、填充、调度和释放 ---
        using var float3Waypoints = new NativeArray<float3>(waypoints.Count, Allocator.TempJob);
        for (int i = 0; i < waypoints.Count; i++)
        {
            float3Waypoints[i] = waypoints[i]; // Vector3 到 float3 的隐式转换
        }

        using var segmentDistances = new NativeArray<float>(waypoints.Count, Allocator.TempJob);

        var job = new PathDistanceJob
        {
            Waypoints = float3Waypoints,
            SegmentDistances = segmentDistances
        };

        try
        {
            // 调度 Job 并等待其完成
            await job.Schedule(waypoints.Count, 64).ToUniTask();

            // 将 NativeArray 的结果拷贝回 List<float>
            List<float> results = new List<float>(segmentDistances.ToArray());
            Debug.Log("路径距离计算 Job 完成.");
            return results;
        }
        catch (Exception ex)
        {
            Debug.LogError($"计算路径距离 Job 失败: {ex.Message}");
            return new List<float>();
        }
    }
}

设计意图和写法解析:

  • 高层业务 API: CalculateWaypointDistancesAsync 方法的签名非常简洁,只接收业务相关的 List<Vector3> 参数,并返回 List<float>,完全没有 Job System 的痕迹。
  • 隐藏所有 Job 细节: PathDistanceJob 的定义、NativeArray 的创建和 Dispose、Job 的调度和等待,所有这些底层操作都封装在 CalculateWaypointDistancesAsync 方法内部,对外部调用方完全透明。
  • 数据转换: 自动处理 Vector3float3(Job 中使用的数学库类型)的转换,以及 NativeArray<float>List<float> 的结果转换。
  • 异常捕获: 尽管 Job 内部不会抛出 C# 异常,但 Job 的调度和等待本身可能出现问题(例如依赖链断裂),或者更常见的是,Job 完成后主线程在处理结果时可能遇到逻辑错误。这里的 try-catch 主要用于捕获这些 Job 生命周期管理中可能出现的异常。
  • 通用性: 尽管名为 SimplePathfinder,其核心思路可以推广到任何复杂的并行计算模块,例如:
    • ParticleSystemUpdater.UpdateParticlesAsync()
    • NavMeshGenerator.GenerateNavMeshAsync()
    • PhysicsSolver.RunSimulationStepAsync()

如何在你的项目中使用这些封装?

  1. 创建文件: 将上述代码分别放入对应的静态类文件(如 JobRunner.cs, DataProcessingJobRunner.cs)或业务逻辑类文件(如 SimplePathfinder.cs)。

  2. 导入命名空间: 在需要使用这些工具类的地方导入相应的命名空间(例如 using static JobRunner;using DataProcessingJobRunner;)。

  3. 调用:

    csharp 复制代码
    public class MyGameManager : MonoBehaviour
    {
        private async void Start()
        {
            // --- 使用 JobRunner ---
            using var simpleResult = new NativeArray<int>(1, Allocator.TempJob);
            await JobRunner.ScheduleJobAsync(new MySimpleCalculationJob { Result = simpleResult });
            Debug.Log($"简单 Job 结果: {simpleResult[0]}");
            simpleResult.Dispose(); // 记得手动释放
    
            // --- 使用 DataProcessingJobRunner ---
            List<float> initialData = new List<float> { 1.1f, 2.2f, 3.3f, 4.4f };
            float[] doubledData = await DataProcessingJobRunner.ProcessFloatArrayAsync(
                initialData,
                (input, output) => new MyArrayDoublerJob { Input = input, Output = output }
            );
            Debug.Log($"数据处理 Job 结果: {string.Join(", ", doubledData)}");
    
            // --- 使用 SimplePathfinder ---
            SimplePathfinder pathfinder = new SimplePathfinder();
            List<Vector3> pathPoints = new List<Vector3> {
                new Vector3(0,0,0), new Vector3(5,0,0), new Vector3(5,5,0)
            };
            List<float> distances = await pathfinder.CalculateWaypointDistancesAsync(pathPoints);
            Debug.Log($"路径距离 Job 结果: {string.Join(", ", distances)}");
        }
    }

总结

对 Job System 进行二次封装,是提升 Unity 项目开发效率和代码质量的关键一步。它让我们能够:

  • 提升开发效率: 通过减少样板代码和简化 API,让开发者能够更快地实现并行计算逻辑。
  • 增强代码可读性与可维护性: 提供更高层的语义化接口,使代码更易于理解和未来扩展。
  • 确保健壮性与安全性: 自动化 NativeContainer 的生命周期管理,并内置异常处理,降低内存泄漏和运行时错误的风险。
  • 更好地融合异步工作流: 结合 UniTask,将 Job 的执行无缝集成到 async/await 的异步编程模型中。

Unity入门教程之异步篇第一节:协程基础扫盲--非 Mono 类如何也能启动协程?-CSDN博客

Unity入门教程之异步篇第二节:协程 or UniTask?Unity 中异步流程到底怎么选-CSDN博客

Unity入门教程之异步篇第三节:多线程初探?理解并发与线程安全-CSDN博客

Unity入门教程之异步篇第四节:Unity 高性能计算?Job System 与 Burst Compiler !-CSDN博客

Unity入门教程之异步篇第五节:对UniTask的高级封装-CSDN博客

Unity入门教程之异步篇第六节:对Job System的高级封装