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 的设计哲学是低层和数据导向,这赋予了它极致的性能。但这种低层特性也带来了开发上的挑战:
- 大量的样板代码: 每次定义一个 Job,都需要实现
IJob
或IJobFor
接口,手动创建、填充、调度NativeContainer
,并管理JobHandle
的依赖和完成。这些重复性的代码不仅耗时,还容易出错。 - 陡峭的学习曲线: 对于不熟悉数据导向设计(Data-Oriented Design, DOD)和原生内存管理的开发者来说,
NativeArray
、JobHandle
、Dependency
、Allocator
等概念是全新的,理解并正确使用它们需要时间和经验。 - 数据流转的复杂性: Job System 强制我们使用
NativeContainer
进行数据传递,这意味着需要在主线程和 Job 之间进行数据拷贝和转换。手动管理NativeContainer
的生命周期(Dispose
)更是容易遗漏,导致内存泄漏。 - 缺乏高层语义: 原生的 Job API 专注于"如何并行计算",而不是"我们要计算什么"。这使得代码难以直接表达业务逻辑,可读性较低。
- 调试相对困难: Job 在后台线程执行,其内部无法直接使用
Debug.Log
,传统的调试器也难以跟踪。虽然 Unity 提供了 Job Profiler,但与单线程代码的调试体验仍有差距。
因此,二次封装的根本目的就是 在保留 Job System 强大性能优势的同时,大幅降低其使用门槛,提升开发效率和代码的可维护性。
对 Job System 进行二次封装的思路与设计意图
封装 Job System 的核心思路是:将通用的、重复的底层操作抽象出来,提供更简洁、更高层、更贴近业务逻辑的 API。 我们的设计意图主要体现在以下几个方面:
- 自动化资源管理: 核心目标是自动化
NativeContainer
的创建、填充和最关键的销毁 ,避免开发者手动调用Dispose
造成的内存泄漏风险。 - 简化 Job 调度: 提供更直接的 Job 调度方法,可能通过
async/await
与UniTask
集成,让 Job 能够无缝融入异步工作流。 - 抽象数据流转: 允许开发者使用更熟悉的 C# 类型(如
List<T>
、数组)作为输入和输出,封装内部负责这些类型与NativeContainer
之间的转换。 - 提高语义表达: 封装后的方法名称应该清晰地表达其业务目的,而不是底层的 Job 执行细节。
- 内置错误处理: 尽管 Job 内部不能直接
try-catch
,但封装层可以在 Job 完成后检查其状态或通过结果回调传递信息。
如何进行二次封装(代码示例与设计意图)
我们将通过三个层面的封装来展示:
- 基础 Job 执行器: 简化最常见的 Job 调度和等待。
- 数据绑定 Job 执行器: 自动化数据在 C# 类型和
NativeContainer
之间的转换。 - 业务逻辑 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;
}
}
设计意图和写法解析:
- 同步与异步分离: 提供了
RunJobSync
和ScheduleJobAsync
/ScheduleJobForAsync
。RunJobSync
适合那些计算量极小,或者你明确知道需要在当前帧立刻拿到结果的场景,但需要注意它会阻塞主线程。异步方法则利用UniTask
的ToUniTask()
扩展,将JobHandle
转换为可await
的UniTask
,从而实现非阻塞的等待。 - 隐藏
JobHandle.Complete()
:JobHandle.Complete()
是一个阻塞操作。在封装中,我们将它隐藏在RunJobSync
内部,或者通过await handle.ToUniTask()
交由UniTask
异步管理其完成,避免开发者忘记调用或错误调用。 - 参数语义化:
ScheduleJobForAsync
方法直接接收length
和innerloopBatchCount
,这是IJobFor
最重要的调度参数,清晰明了。 - 泛型设计:
JobRunner
接受任何实现IJob
或IJobFor
的struct
作为泛型参数,确保了其通用性。 - 日志: 增加了简单的日志输出,方便追踪 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
块结束时(无论正常结束还是抛出异常),NativeArray
的Dispose()
方法都会被自动调用,这极大地降低了内存泄漏的风险,也减少了手动管理资源的样板代码。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 逻辑,只负责其生命周期和数据流转。 - 泛型和类型安全: 继续使用泛型确保了代码的通用性和类型安全。
- 错误处理: 虽然
NativeArray
的Dispose
是自动的,但方法内部仍可以加入对空输入或其他异常的检查。
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
方法内部,对外部调用方完全透明。 - 数据转换: 自动处理
Vector3
到float3
(Job 中使用的数学库类型)的转换,以及NativeArray<float>
到List<float>
的结果转换。 - 异常捕获: 尽管 Job 内部不会抛出 C# 异常,但 Job 的调度和等待本身可能出现问题(例如依赖链断裂),或者更常见的是,Job 完成后主线程在处理结果时可能遇到逻辑错误。这里的
try-catch
主要用于捕获这些 Job 生命周期管理中可能出现的异常。 - 通用性: 尽管名为
SimplePathfinder
,其核心思路可以推广到任何复杂的并行计算模块,例如:ParticleSystemUpdater.UpdateParticlesAsync()
NavMeshGenerator.GenerateNavMeshAsync()
PhysicsSolver.RunSimulationStepAsync()
如何在你的项目中使用这些封装?
-
创建文件: 将上述代码分别放入对应的静态类文件(如
JobRunner.cs
,DataProcessingJobRunner.cs
)或业务逻辑类文件(如SimplePathfinder.cs
)。 -
导入命名空间: 在需要使用这些工具类的地方导入相应的命名空间(例如
using static JobRunner;
或using DataProcessingJobRunner;
)。 -
调用:
csharppublic 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博客