Unity Job System 总结

介绍非托管集合和C# 作业系统的核心概念和使用方法。

C#作业系统

Unity Job System 主要用于多线程处理作业任务,缩短运行时间。不同的作业类型,工作线程池内的线程数量不同,线程越多,cpu开销越大,需要根据具体的任务需求进行选择。并行处理时,线程上限由电脑的处理器配置决定,我的是8核,所以并行处理的线程最多为8个。

定义作业类型

需声明一个实现IJob或其他作业接口(如IJobParallelFor, IJobEntity, IJobChunk.等)的结构体(struct)

示例1:FindNearestJob
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

/// <summary>
/// burst 编译器进行编译 的结构体,单线程处理
/// </summary>
[BurstCompile]
public struct FindNearestJob : IJob
{
    /// <summary>
    /// 用 Unity.Mathematics.float3 替代 Vector3
    /// </summary>
    [ReadOnly]
    public NativeArray<float3> TargetPositions;
    [ReadOnly]
    public NativeArray<float3> SeekertPositions;
    public NativeArray<float3> NearestTargetPositions;

    public void Execute()
    {
        for (int i = 0; i < SeekertPositions.Length; i++)
        {
            float3 seekerPosition = SeekertPositions[i];
            float nearestDistSq = float.MaxValue;
            for (int j = 0; j < TargetPositions.Length; j++)
            {
                float3 targetPosition = TargetPositions[j];
                //用 Unity.Mathematics.math.distancesq替代 Vector3.sqrMagnitude.
                float distSq = math.distancesq(seekerPosition, targetPosition);
                if(distSq< nearestDistSq)
                {
                    nearestDistSq = distSq;
                    NearestTargetPositions[i] = targetPosition;
                }
            }

        }
    }
}

备注:Unity 的 Job System 是为高性能多线程处理设计的,但由于其运行在 Burst 编译器和安全系统下,对托管类型的使用有严格限制。

扩展:可安全使用的托管类型

Job System 主要设计用于非托管代码,但以下托管类型可以在特定情况下使用:

1. 基本值类型(完全支持)

  • intfloatdoubleboolchar 等基本数值类型

  • Unity.Mathematics 中的类型(如 float3quaternionint4 等)

2. 结构体(有限支持)

  • 简单的 struct 类型(仅包含值类型字段)

  • 必须不包含:

    • 引用类型字段

    • 指针

    • 托管数组

    • 字符串

    • 类实例

3. 特殊容器(通过 NativeContainer)

  • NativeArray<T>

  • NativeList<T>

  • NativeHashMap<TKey, TValue>

  • NativeQueue<T>

  • 其他 Unity.Collections 命名空间下的容器

4. 受限使用的托管类型

  • string(只读访问,不能修改)

  • 枚举类型(完全支持)

不可使用的托管类型

  • 任何类(class)类型

  • 托管数组(T[]

  • 委托(delegate

  • 接口(interface

  • 动态分配的复杂对象

作业调度

调用扩展方法 Schedule()将作业实例加入作业队列,等待工作线程执行(调用Execute()方法)。

示例2:FindNearestJob实例化及调度处理
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
/// <summary>
/// 查找最近的目标画线
/// </summary>
public class FindNearest : MonoBehaviour
{

    NativeArray<float3> TargetPositions;
    NativeArray<float3> SeekerPositions;
    NativeArray<float3> NearestTargetPositions;
    private void Start()
    {
        Spawner spawner = Object.FindAnyObjectByType<Spawner>();
        //永久分配器,因为在程序运行期间一直使用,手动回收
        TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
        SeekerPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
        NearestTargetPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
    }
    // We are responsible for disposing of our allocations
    // when we no longer need them.
    //手动释放分配器
    private void OnDestroy()
    {
        TargetPositions.Dispose();
        SeekerPositions.Dispose();
        NearestTargetPositions.Dispose();
    }
    // Update is called once per frame
    void Update()
    {
        //copy every target transform to a NativeArray
        //将目标的Transform信息复制到原生数组
        for (int i = 0; i < TargetPositions.Length; i++)
        {
            // Vector3 is implicitly converted to float3
            //Vector3隐式转换为float3
            TargetPositions[i]= Spawner.TargetTransforms[i].localPosition;

        }
        //copy every seeker transform to a NativeArray
        //复制seeker位置信息到SeekerPositions
        for (int i = 0; i < SeekerPositions.Length; i++)
        {
            SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;

        }
        // To schedule a job, we first need to create an instance and
        // populate its fields.
        //实例化 FindNearestJob
        FindNearestJob findJob = new FindNearestJob
        {
            TargetPositions = TargetPositions,
            SeekertPositions = SeekerPositions,
            NearestTargetPositions = NearestTargetPositions,
        };
        //调度:将finfjob加入作业队列中
        JobHandle findHandle = findJob.Schedule();
        //等待作业完成
        findHandle.Complete();

        for (int i = 0; i < SeekerPositions.Length; i++)
        {
            // float3 隐式转换 Vector3
            Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
        }
    }
}

注意:作业仅可从主线程调度,不可从其他作业内部调度。IJob为单线程作业,需等待当前作业执行完成后,才会处理下一个等待中的作业。

作业依赖系统

JobHandle 

通过 JobHandle 管理作业间的依赖关系:确保数据访问顺序正确,避免竞态条件的达成

    备注:  工作线程只有在作业的所有依赖项都执行完成后,才会取出执行完成的作业。

示例 3:根据二分查找获取最近目标
using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
public struct FindNearestByBinarySearchParallelJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float3> TargetPositions;
    [ReadOnly]
    public NativeArray<float3> SeekerPositions;
    
    public NativeArray<float3> NearestTargetPositions;
    public void Execute(int index)
    {
        float3 seekerPos = SeekerPositions[index];
        //对已排序的TargetPositions 进行二分查找 ,
        //不存在返回负值
        //存在返回下标
        //AxisXComparer 自定义的比较器
        int startIdx = TargetPositions.BinarySearch(seekerPos,new AxisXComparer { } );
        //取反获得插入位置
        //在二分查找(Binary Search)中,取反运算(~)用于处理查找失败时返回的负值,将其转换为正确的插入位置
        if (startIdx<0)startIdx=~startIdx;
        if (startIdx >= TargetPositions.Length) startIdx = TargetPositions.Length - 1;
        //将target的x值最接近seeker的x值的tartget设为最近的目标
        float3 nearestTargetPos = TargetPositions[startIdx];
        //math.distancesq() 计算两个点之间的平方距离(distance squared),而不是实际距离.
        //平方距离公式:(x2-x1)² + (y2-y1)² + (z2-z1)²
        float nearestDistSq = math.distancesq(seekerPos, nearestTargetPos);
        //向上向下查找
        Search(seekerPos,startIdx+1,TargetPositions.Length,+1, ref nearestTargetPos,ref nearestDistSq);
        Search(seekerPos, startIdx - 1, -1, -1, ref nearestTargetPos, ref nearestDistSq);
        //取得最近的目标
        NearestTargetPositions[index] = nearestTargetPos;
    }
/// <summary>
/// 向上向下查找,ref 双向传递,不可为空 
/// </summary>
/// <param name="seekerPos"></param>
/// <param name="startIdx"></param>
/// <param name="endIdx"></param>
/// <param name="step"></param>
/// <param name="nearestTargetPos"></param>
/// <param name="nearestDistSq"></param>
    void Search(float3 seekerPos, int startIdx, int endIdx, int step,
              ref float3 nearestTargetPos, ref float nearestDistSq)
    {
        for (int i = startIdx; i != endIdx; i += step)
        {
            float3 targetPos = TargetPositions[i];
            float xdiff = seekerPos.x - targetPos.x;

            // If the square of the x distance is greater than the current nearest, we can stop searching.
            //若xdiff平方大于最近的距离
            //=>(xt-xs)²> (xn-xs)² + (yn-ys)² + (zn-zs)²
            //=> (xt-xs)² + (yt-ys)² + (zt-zs)²>(xn-xs)² + (yn-ys)² + (zn-zs)² 
            //=>对于已经排序的集合, xdiff*xdiff呈现增长趋势,所以当前条件达成时,停止查找 
            if ((xdiff * xdiff) > nearestDistSq) break;

            float distSq = math.distancesq(targetPos, seekerPos);

            if (distSq < nearestDistSq)
            {
                nearestDistSq = distSq;
                nearestTargetPos = targetPos;
            }
        }
    }
}
public struct AxisXComparer : IComparer<float3>
{
    public int Compare(float3 x, float3 y)
    {
        return x.x.CompareTo(y.x);// 按X轴坐标比较
    }
}
示例4:针对排序和查找创建依赖关系
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
/// <summary>
/// 对目标数组进行排序,通过二分查找进行算法优化
/// </summary>
public class FindNearestBySort : MonoBehaviour
{
    NativeArray<float3> TargetPostions;
    NativeArray<float3> SeekerPositions;
    NativeArray<float3> NearestTargetPositions;
    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        Spawner spawner = Object.FindAnyObjectByType<Spawner>();
        ///对其进行永久分配
        TargetPostions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
        SeekerPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
        NearestTargetPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
    }
    // Update is called once per frame
    void Update()
    {
        //刷新位置信息
        for (int i = 0; i < TargetPostions.Length; i++)
        {
            TargetPostions[i] = Spawner.TargetTransforms[i].localPosition;//隐式转换
        }
        for (int i = 0; i < SeekerPositions.Length; i++)
        {
            SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;//隐式转换
        }
        //unity.collection 包里封装的SortJob, 根据自定义比较器进行排序
        SortJob<float3, AxisXComparer> sortJob = TargetPostions.SortJob(new AxisXComparer { });
        JobHandle sortHandle = sortJob.Schedule();

        //执行任务,实例化job
        FindNearestByBinarySearchParallelJob findNearestParallelJob = new FindNearestByBinarySearchParallelJob
        {
            TargetPositions = TargetPostions,
            SeekerPositions = SeekerPositions,
            NearestTargetPositions = NearestTargetPositions
        };
        ///每个线程分配100个任务,等待排序任务完成后,执行
        JobHandle jobHandle = findNearestParallelJob.Schedule(SeekerPositions.Length, 100, sortHandle);
        jobHandle.Complete();
    }
}

JobHandle.CombineDependencies()

使用 JobHandle.CombineDependencies()将多个 JobHandle 合并为一个,表示这些任务全部完成后才会触发后续任务。

示例5:JobHandle.CombineDependencies()
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class CombineDependenciesExample : MonoBehaviour
{
    private void Start()
    {
        // 创建三个独立的 NativeArray
        NativeArray<float> arrayA = new NativeArray<float>(100, Allocator.TempJob);
        NativeArray<float> arrayB = new NativeArray<float>(100, Allocator.TempJob);
        NativeArray<float> arrayC = new NativeArray<float>(100, Allocator.TempJob);

        // 定义三个独立的 Job
        var jobA = new InitJob { Data = arrayA, Value = 1f };
        var jobB = new InitJob { Data = arrayB, Value = 2f };
        var jobC = new InitJob { Data = arrayC, Value = 3f };

        // 并行调度三个 Job(无依赖关系)
        JobHandle handleA = jobA.Schedule();
        JobHandle handleB = jobB.Schedule();
        JobHandle handleC = jobC.Schedule();

        // 合并三个 Job 的依赖关系
        JobHandle combinedHandle = JobHandle.CombineDependencies(handleA, handleB, handleC);

        // 定义一个依赖 combinedHandle 的后续 Job
        var sumJob = new SumJob
        {
            ArrayA = arrayA,
            ArrayB = arrayB,
            ArrayC = arrayC,
            Result = new NativeArray<float>(1, Allocator.TempJob)
        };
        JobHandle finalHandle = sumJob.Schedule(combinedHandle);

        // 等待所有任务完成
        finalHandle.Complete();

        // 打印结果
        Debug.Log($"Total Sum: {sumJob.Result[0]}");

        // 释放内存
        arrayA.Dispose();
        arrayB.Dispose();
        arrayC.Dispose();
        sumJob.Result.Dispose();
    }
}

[BurstCompile]
public struct InitJob : IJob
{
    public NativeArray<float> Data;
    public float Value;

    public void Execute()
    {
        for (int i = 0; i < Data.Length; i++)
        {
            Data[i] = Value; // 初始化数组为指定值
        }
    }
}

[BurstCompile]
public struct SumJob : IJob
{
    public NativeArray<float> ArrayA;
    public NativeArray<float> ArrayB;
    public NativeArray<float> ArrayC;
    public NativeArray<float> Result;

    public void Execute()
    {
        float sum = 0f;
        for (int i = 0; i < ArrayA.Length; i++)
        {
            sum += ArrayA[i] + ArrayB[i] + ArrayC[i]; // 对三个数组求和
        }
        Result[0] = sum; // 存储结果
    }
}

作业完成

作业执行完成的流程:

1.递归完成该作业的所有依赖项(包括依赖项的依赖项,递归执行)

2.等待该作业执行完成(若该作业尚未执行完成)

3.从作业队列中移除该作业的所有剩余引用
备注:

  • 调用已完成作业的JobHandle的Complete()方法不会执行任何东西也不会报错。
  • 仅可在主线程完成作业,在作业内部调用Complete()属于非法操作。
  • 虽然作业可以在调度后立即调用Complete(),最好延迟到工作真正需要结果的最后一刻调用。通常来说,在作业的调度和完成之间的间隔越长,主线程和工作线程非必要空转(等待状态)将花费时间的可能性就越低。(延迟同步策略)

在作业中的数据存取

通用规则:

  • 作业不应该执行I/O操作
  • 作业不应该访问托管对象
  • 作业仅应访问只读静态字段

调度作业时会创建一个私有副本,该副本仅对正在执行的作业可见。所以对作业结构体字段的任何修改操作仅在作业内部有效。但作业的字段包含指针或者引用类型,则作业对这个外部数据的修改可在作业外部可见。(作业数据隔离机制)

非托管集合(Unmanaged collections)

Unity.Collections package 的非托管集合类型相较于常规的C#托管集合的优势:

  • 1. 非托管对象可在Burst编译代码中使用。
  • 2.非托管对象可用于作业中(而托管对象在作业中的使用通常是非线程安全的)。
  • 3.Native-集合类型内置作业安全检测,强制保障线程安全。
  • 4 .非托管对象不受垃圾回收机制管理,因此不会产生GC开销。

注意:需要手动调用Dispose()方法释放不再使用的非托管集合。若未及时释放会导致内存泄露。虽然,Native-集合通过内置的安全检测可捕捉一些(非全部)此类泄露并抛出错误。

分配器(Allocators

实例化一个非托管集合时,必须指定分配器进行内存分配。不同的分配器以不同的方式组织和追踪内存。最常见的三种分配器是:

Allocator.Persistent:

  •  最慢的分配器。
  • 长期存在(手动释放)
  • 用于生命周期不确定的内存分配。
  • 分配的内存必须手动释放。
  • 分配的集合能传递到作业中。
   TargetPostions = new NativeArray<float3>(spawner.NumTargets,Allocator.Persistent);
   SeekerPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
   NearestTargetPositions = new NativeArray<float3>(spawner.NumSeeks,Allocator.Persistent);
 //手动释放分配器
    private void OnDestroy()
    {
        TargetPositions.Dispose();
        SeekerPositions.Dispose();
        NearestTargetPositions.Dispose();
    }

Allocator.Temp:

  • 最快的分配器。
  • 最短生命期(1帧)
  • 不需要手动释放Temp分配器,会在作业结束时自动释放。
  • 分配的集合不能传递到作业中。
  • 在作业内使用Allocator.Temp是线程安全的。

Allocator.TempJob:

  • 速度介于中间层的分配器
  • 作业生命期(4帧)
  • 分配的内存必须手动释放。若启用释放安全检查,分配的内存若在分配后 4 帧内未释放,系统将抛出异常。
  • 分配的集合能传递到作业中。

作业安全检测(Job Safety Checks)

任何两个访问相同数据的作业,需要避免其执行过程出现重叠或者执行顺序不确定的情况。否则当任意作业修改数组时,可能会对另一作业产生干扰:结果取决于一个作业是否在另一作业之前运行、两者执行是否重叠等偶然因素,最终可能导致一个或两个作业产生错误结果。

因此,若两个作业之间存在此类数据冲突,应选择以下一种方式:

  • 先完整调度并执行完一个作业,再调度另一个
  • 或将一个作业设为另一个的依赖项后再调度

调用Schedule()时,若启用作业安全检查(默认启用),系统在检测到潜在竞态条件时会抛出异常。

一些特殊的情况:

  • 当两个作业仅读取同一份数据时,访问是线程安全的。由于没有作业会修改数据,它们彼此不会产生干扰。通过在结构体字段上添加 [ReadOnly] 属性,可声明某个原生数组或集合在作业中仅用于读取。若两个作业共享的所有原生数组/集合均被标记为 [ReadOnly],作业安全检查将判定它们不存在冲突。
  • 若需完全禁用作业安全检查机制对某个原生数组/集合的约束,可使用 [NativeDisableContainerSafetyRestriction] 属性标记该字段。但需自行确保不会因此引发竞态条件!
  • 当原生容器(Native Collection)被任何已调度的作业使用时,若主线程尝试读取或修改该容器,安全检查系统将抛出异常。作为特殊情况,若该容器在作业中被标记为 [ReadOnly],则允许主线程读取其内容。

并行作业(Parallel Jobs)

通过多线程并行处理数组或列表,可通过实现IJobParallelFor 接口来定义作业。

当作业执行时,Execute() 方法会被调用count次,从0到count-1的整数值传递给index参数。

作业的索引会被按照批次大小分割成多个批次,然后工作线程可分别从队列中取出这些批次。从运行效果来看,不同的批次也许被不同的线程并行处理,但同批次内的所有索引将在单个线程中集中处理。

示例6:定义并行作业
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
/// <summary>
/// 实现IJobParallelFor并行任务处理
/// </summary>
[BurstCompile]
public struct FindNearestParallelJob : IJobParallelFor
{
    /// <summary>
    /// Execute处理时不可修改,初始化结构体时进行赋值
    /// </summary>
    [ReadOnly]
    public NativeArray<float3> TargetPositions;
    [ReadOnly]
    public NativeArray<float3> SeekerPositions;

    public NativeArray<float3> NearestTargetPositions;
    public void Execute(int index)
    {
        //对数据进行业务处理
        float3 seekerPos = SeekerPositions[index];
        float nearestDisSq = float.MaxValue;
        for (int i = 0; i < TargetPositions.Length; i++)
        {
            float3 targetPos = TargetPositions[i];
            float distSq = math.distancesq(seekerPos, targetPos);//x^2+y^2+Z^2
            if(nearestDisSq< distSq)
            {
                nearestDisSq = distSq;
                NearestTargetPositions[index] = targetPos;
            }
        }
    }

   
}
示例7:并行作业实例化
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

public class FindNearestParallel : MonoBehaviour
{
    NativeArray<float3> TargetPostions;
    NativeArray<float3> SeekerPositions;
    NativeArray<float3> NearestTargetPositions;

    // Start is called once before the first execution of Update after the MonoBehaviour is created
    void Start()
    {
        Spawner spawner = Object.FindAnyObjectByType<Spawner>();
        ///对其进行永久分配
        TargetPostions = new NativeArray<float3>(spawner.NumTargets,Allocator.Persistent);
        SeekerPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
        NearestTargetPositions = new NativeArray<float3>(spawner.NumSeeks, Allocator.Persistent);
    }

    // Update is called once per frame
    void Update()
    {
        //刷新位置信息
        for (int i = 0; i < TargetPostions.Length; i++)
        {
            TargetPostions[i] = Spawner.TargetTransforms[i].localPosition;//隐式转换
        }
        for (int i = 0; i < SeekerPositions.Length; i++)
        {
            SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;//隐式转换
        }

        //执行任务
        FindNearestParallelJob findNearestParallelJob = new FindNearestParallelJob
        {
            TargetPositions = TargetPostions,
            SeekerPositions=SeekerPositions,
            NearestTargetPositions= NearestTargetPositions
        };
        ///每个线程分配100个任务
        JobHandle jobHandle = findNearestParallelJob.Schedule(SeekerPositions.Length, 100);
        jobHandle.Complete();
    }
}

备注:过多的微批次也许会导致作业系统显著的开销。

当处理一个批次时,该批次应当仅访问其指定范围内的数组或列表索引。若访问的索引值超出当前批次的索引参数范围,安全检测机制会在访问数组或列表时抛出异常。

标记了[ReadOnly]属性的数组和列表字段,安全检测机制将不会抛出异常。对于需要在作业中执行写入操作的数组或列表,可以通过为字段添加[NativeDisableParallelForRestriction]属性来屏蔽安全检测机制。但需注意:屏蔽安全检测机制可能导致竞态条件,请谨慎操作!

unity6.0 下载链接:NoUnityCN | Unity国际版下载站 - 让游戏开发更加简单

参考链接:

Get started with Unity's job system - Unity Learn

https://github.com/Unity-Technologies/EntityComponentSystemSamples

unity package manual :

About Burst | Burst | 1.8.7

Unity - Manual: Job system

Collections package | Collections | 2.5.0-exp.1

Unity Mathematics | Mathematics | 1.3.1

### 关于 Unity Job System 的教程与常见问题解答 #### 什么是 Unity Job SystemUnity Job System 是一种用于多线程编程的功能模块,旨在帮助开发者更高效地利用现代 CPU 架构中的多个核心。通过将任务分配到不同的线程上运行,Job System 能显著提升性能并减少主线程的压力[^6]。 #### 如何创建和调度一个简单的 Job? 以下是创建和调度一个简单 Job 的基本流程: 1. 定义一个继承自 `IJob` 接口的结构体。 2. 实现该接口所需的 `Execute()` 方法。 3. 使用 `Schedule()` 和 `Complete()` 来安排和完成 Job 执行。 下面是一个示例代码片段展示如何实现上述逻辑: ```csharp using UnityEngine; using Unity.Jobs; public struct SimpleJob : IJob { public float deltaTime; public void Execute() { Debug.Log($"Executing job with delta time {deltaTime}"); } } public class ExampleScript : MonoBehaviour { private void Start() { var jobData = new SimpleJob { deltaTime = Time.deltaTime }; // Schedule the job. JobHandle handle = jobData.Schedule(); // Complete ensures all jobs finish before continuing on main thread. handle.Complete(); } } ``` 此代码定义了一个简单的 Job 并将其安排执行[^7]。 #### 常见错误及其解决方案 当使用 Unity Job System 时可能会遇到一些常见的陷阱或者错误情况。例如尝试访问未标记为 `[ReadOnly]` 或者 `[WriteOnly]` 属性的数据可能导致同步冲突等问题。确保遵循最佳实践来规避这些问题是非常重要的。 对于复杂场景下的调试建议启用详细的日志记录以便追踪潜在的问题根源所在[^8]。 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Merrina

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值