Unity进阶--C#补充知识点--【C#各版本的新功能新语法】C#1~4与C#5

·来源于唐老狮的视频教学,仅作记录和感悟记录,方便日后复习或者查找


一.C#版本与Unity的关系

1.各Unity版本支持的C#版本

更多信息可以在Untiy官网说明查看
https://docs.unity3d.com/2020.3/Documentation/Manual/CSharpCompiler.html(这个好像要科学上网)

2.为什么不同版本Unity支持的C#版本不同

是因为不同版本的Unity使用的 C#编译器 和 脚本运行时版本不同

3.不同版本C#的意义

主要就是可以使用新功能,来节约代码量,让代码更简单直观简洁

4.Unity中调整.Net API 兼容级别

可以去工程中选择对应的兼容级别

目前新版本(这里是2022.3.62f1c1)分为了.Net Standard 2.1 和 .Net Framework

.Net Framework(特殊需求时):

.Net Standard 2.1(常规情况下):

  • 是一个.Net标准API集合,相对.Net Framework包含更少的内容,可以减小最终可执行文件大小
  • 它具有更好的跨平台支持
  • .Net Standard 2.1 配置文件大小是.Net Framework配置文件的一半

通常情况下,为了跨平台和更小的包体,我们都选择使用默认的.Net Standard 2.1


二.C#1~4

1.Unity最低支持的C#版本

2.C#1~4的功能和语法

这里只要提及一些Unity开发中常用的功能与特性

3.补充内容--命名与可选参数

可以让我们在调用函数的时候,指定传入的数据是传入到哪一个参数上的。例如如下:

cs 复制代码
public void Test(int i, float f, bool b)
{

}

public void Test2(int i , bool b = true, string s = "123")
{

}

//有了命名参数,我们将不用匹配参数在所调用方法中的顺序
//每个参数可以按照参数名字进行指定
Test(1, 1.2f, true);
Test(f: 3.3f, i: 5, b: false);
Test(b: false, f: 3.4f, i: 3);

//命名参数可以配合可选参数使用,让我们做到跳过其中的默认参数直接赋值后面的默认参数
Test2(1, true, "234");
Test2(1, s: "234");

这样的好处是:

①可以跳过一些默认参数去赋值后面的参数

②通过好处一可以让我们少写一些代码与重载函数

4.补充内容--动态类型

关键词:dynamic

作用:能够接收任意类型的不为NULL的变量,类似于Object,但是不是把他装箱为Object,而是直接动态地解析成该变量的类型,我们可以在代码后面直接使用该变量去赋值或者调用里面的成员变量与方法(不过不会有提示,需要我们自己保证拼写没有出错)。更详细的解释如下:

注意事项:

  • 使用dynamic功能 需要将Unity的.Net API兼容级别切换为.Net Framework
  • IL2CPP 不支持 C# dynamic 关键字。它需要 JIT 编译,而 IL2CPP 无法实现
  • 动态类型是无法自动补全方法的,我们在书写时一定要保证方法的拼写正确性
  • 该功能我们只做了解,不建议大家使用

使用例子:

cs 复制代码
using UnityEngine;

public class TestClass
{
    public string name = "Test Me";

    public void PrintName()
    {
        Debug.Log(name);
    }
}

public class Lesson3 : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        //不确定要赋值的类型的时候使用
        dynamic i = 1;
        print(i);

        //可以接受一个类类型
        dynamic t = new TestClass();

         //去调用其中的方法
        t.PrintName(); 
        print(t.GetType());    
    }
}

好处:

  • 可以让我不用自己去关心和处理类型转化相关的事项,节约代码。
  • 当不确定对象类型,但是确定对象成员时,可以使用动态类型通过反射处理某些功能时,也可以考虑使用动态类型来替换它

三.C#5

1.C#5的新功能和新语法

①调用方信息特性(C#进阶套课------特性)

cs 复制代码
using System.Runtime.CompilerServices;

public static void Log(
    string message,
    [CallerMemberName] string caller = null,
    [CallerFilePath] string file = null,
    [CallerLineNumber] int line = 0)
{
    Console.WriteLine($"【{message}】| 调用者:{caller} | 文件:{file} | 行号:{line}");
}

// 调用示例
public class Demo
{
    public void Test()
    {
        Log("发生错误"); // 编译器自动注入调用信息
    }
}

②异步方法async和await

2.回顾内容--线程

Unity中线程使用的注意事项:

  • Unity支持多线程
  • Unity中开启的多线程不能使用主线程中的对象(不能使用Unity的API)
  • Unity中开启多线程后一定记住关闭(一般在一个对象中创建线程后,在OnDestory中关闭线程)

线程的使用例子:

cs 复制代码
//创建一个新线程对象
t = new Thread(()=> {
    //线程中进行一个死循环
    while (true)
    {
        print("123");
        //每次运行到这里的时候让线程休眠1秒
        Thread.Sleep(1000);
    }
});
//启用线程
t.Start();

//下面是主线程的代码
print("主线程执行");

3.补充内容--线程池

3.1.原理与特性

命名空间: System.Threading

类名: ThreadPool(线程池)

目的:

  • 在多线程的应用程序开发中,频繁的创建删除线程会带来性能消耗,产生内存垃圾
  • 为了避免这种开销C#推出了 线程池ThreadPool类

大致原理:

  • ThreadPool中有若干数量的线程,如果有任务需要处理时,会从线程池中获取一个空闲的线程来执行任务
  • 任务执行完毕后线程不会销毁,而是被线程池回收以供后续任务使用
  • 当线程池中所有的线程都在忙碌时,又有新任务要处理时,线程池才会新建一个线程来处理该任务,
  • 如果线程数量达到设置的最大值,任务会排队,等待其他任务释放线程后再执行

优点:

  • 线程池能减少线程的创建,节省开销,可以减少GC垃圾回收的触发

缺点:

  • 不能控制线程池中线程的执行顺序,也不能获取线程池内线程取消/异常/完成的通知

3.2.内部的方法使用

cs 复制代码
//1.获取可用的工作线程数和I/O线程数
int num1;
int num2;
ThreadPool.GetAvailableThreads(out num1, out num2);
print(num1);
print(num2);

 //2.获取线程池中工作线程的最大数目和I/O线程的最大数目
 ThreadPool.GetMaxThreads(out num1, out num2);
 print(num1);
 print(num2);
cs 复制代码
//3.设置线程池中可以同时处于活动状态的 工作线程的最大数目和I/O线程的最大数目
//  大于次数的请求将保持排队状态,知直到线程池线程变为可用
//  更改成功返回true,失败返回false
if(ThreadPool.SetMaxThreads(20, 20))
{
    print("更改成功");
}
cs 复制代码
//5.设置 工作线程的最小数目和I/O线程的最小数目
if(ThreadPool.SetMinThreads(5, 5))
{
    print("设置成功");
}

//4.获取线程池中工作线程的最小数目和I/O线程的最小数目
ThreadPool.GetMinThreads(out num1, out num2);
print(num1);
print(num2);

最小线程数是:在初始的时候一直保持至少有这么多个线程是在运行的(即使它没有执行什么任务,但是一旦有任务可以马上用它来执行)

最大线程数是:如果当前线程数已经达到了最大线程数,当有新任务的时候,就会先排队等待线程资源被释放出来

cs 复制代码
//6.将方法排入队列以便执行,当线程池中线程变得可用时执行

//ThreadPool.QueueUserWorkItem((obj) =>
//{
//    print(obj);
//    print("开启了一个线程");
//}, "123452435345");

for (int i = 0; i < 10; i++)
{
    //第一个参数传入一个回调函数,第二个参数传入一个要在回调中使用的参数值(传给obj了)
    ThreadPool.QueueUserWorkItem((obj) =>
    {
        print("第" + obj + "个任务");
    }, i);
}

print("主线程执行");

4.补充内容----Task任务类

4.1.认识Task

命名空间:System.Threading.Tasks

类名:Task

说明:Task就是任务的意思,它继承了线程池的优点的同时改进了线程池的缺点,即创建的任务能够被精确地控制。它是基于线程池的优点的一个封装类,可以帮助我们更加高效地完成多线程相关的开发。

4.2.创建Task的方式

4.2.1.创建无返回值的Task的三种方式
cs 复制代码
        //1.通过new一个Task对象传入委托函数并启动
        Task t1 = new Task(() => {
            int i = 0;
            while (isRuning) {
                print("方式一:" + i);
                ++i;
                Thread.Sleep(1000);
            }
        });
        t1.Start();

        //2.通过Task中的Run静态方法传入委托函数
        Task t2 = Task.Run(() => {
            int i = 0;
            while (isRuning) {
                print("方式二:" + i);
                ++i;
                Thread.Sleep(1000);
            }
        });

        //3.通过Task.Factory中的StartNew静态方法传入委托函数
        Task t3 = Task.Factory.StartNew(() => {
            int i = 0;
            while (isRuning) {
                print("方式三:" + i);
                ++i;
                Thread.Sleep(1000);
            }
        });

①new一个Task对象,传入委托函数

②使用Task.Run静态方法传入委托函数

③使用Task.Factory.StartNew()传入委托函数

4.2.2.创建有返回值的Task的三种方式
cs 复制代码
        //1.通过new一个Task对象闯入委托函数并启动
        t1 = new Task<int>(() => {
            int i = 0;
            while (isRuning) {
                print("方式一:" + i);
                ++i;
                Thread.Sleep(1000);
            }
            return 1;
        });
        t1.Start();

        //2.通过Task中的Run静态方法传入委托函数
        t2 = Task.Run<string>(() => {
            int i = 0;
            while (isRuning) {
                print("方式二:" + i);
                ++i;
                Thread.Sleep(1000);
            }
            return "1231";
        });

        //3.通过Task.Factory中的StartNew静态方法传入委托函数
        t3 = Task.Factory.StartNew<float>(() => {
            int i = 0;
            while (isRuning) {
                print("方式三:" + i);
                ++i;
                Thread.Sleep(1000);
            }
            return 4.5f;
        });

①方式和无返回值的基本方法差不多:new,Task.Run(),Task.Factory.RunNew()

②不同的在于这些方法后面要多加一个用于告诉返回值是多少的泛型,并且传入的委托函数中要有相对应的返回值

cs 复制代码
//获取返回值
//注意:
//Resut获取结果时会阻塞线程
//即如果task没有执行完成
//会等待task执行完成获取到Result
//然后再执行后边的代码,也就是说 执行到这句代码时 由于我们的Task中是死循环 
//所以主线程就会被卡死
print(t1.Result);
print(t2.Result);
print(t3.Result);

print("主线程执行");

①通过task变量的值.Result获取里面返回的值

②要注意一旦使用这个方法的时候,如果该线程中还没有计算完成返回值的话就会一直阻塞主线程的执行

4.3.同步执行Task

默认情况下各个线程和主线程之间肯定是异步执行的

同步执行Task的意思大概可以粗暴地理解成把Task中的任务再放回主线程中执行。

cs 复制代码
//如果你希望Task能够同步执行
//只需要调用Task对象中的RunSynchronously方法
//注意:需要使用 new Task对象的方式,因为Run和StartNew在创建时就会启动

Task t = new Task(() => {
    Thread.Sleep(1000);
    print("哈哈哈");
});
//t.Start();
t.RunSynchronously();
print("主线程执行");
//不Start 而是 RunSynchronously

①在开始线程的时候用的不是Start()而是RunSynchronously()

②注意这个只能用在用new一个Task的时候使用,因为另外两种方法会直接默认Start()线程

4.4.Task中线程阻塞的方式(任务阻塞)

即让一个线程执行完了之后才能够继续执行后面的内容

当我们需要在某几个任务完成之后再去执行其他任务的时候可以使用这个方法

cs 复制代码
//1.Wait方法:等待任务执行完毕,再执行后面的内容
Task t1 = Task.Run(() =>
{
    for (int i = 0; i < 5; i++)
    {
        print("t1:" + i);
    }
});

Task t2 = Task.Run(() =>
{
    for (int i = 0; i < 20; i++)
    {
        print("t2:" + i);
    }
});
t2.Wait();

//2.WaitAny静态方法:传入任务中任意一个任务结束就继续执行
//Task.WaitAny(t1, t2);

//3.WaitAll静态方法:任务列表中所有任务执行结束就继续执行
//Task.WaitAll(t1, t2);

//print("主线程执行");

①可以直接让t1.wait()

②可以用Task中的静态方法.WaitAny(),传入可变参数个任务,等待其中任意一个任务完成之后就继续执行

③可以用Task中的静态方法.WaitAll(),传入可变参数个任务,等待所有这些任务完成之后就继续执行

4.5.Task中完成后续其他Task(任务延续)

即让Task按一定顺序接续执行

当我们需要让某几个任务之间按一定顺序完成的时候就要使用这个方法

cs 复制代码
//1.WhenAll静态方法 + ContinueWith方法:传入任务完毕后再执行某任务
Task.WhenAll(t1, t2).ContinueWith((t) => {
    print("一个新的任务开始了");
    int i = 0;
    while (isRuning) {
        print(i);
        ++i;
        Thread.Sleep(1000);
    }
});

Task.Factory.ContinueWhenAll(new Task[] { t1, t2 }, (t) => {
    print("一个新的任务开始了");
    int i = 0;
    while (isRuning) {
        print(i);
        ++i;
        Thread.Sleep(1000);
    }
});

//2.WhenAny静态方法 + ContinueWith方法:传入任务只要有一个执行完毕后再执行某任务
Task.WhenAny(t1, t2).ContinueWith((t) => {
    print("一个新的任务开始了");
    int i = 0;
    while (isRuning) {
        print(i);
        ++i;
        Thread.Sleep(1000);
    }
});

Task.Factory.ContinueWhenAny(new Task[] { t1, t2 }, (t) => {
    print("一个新的任务开始了");
    int i = 0;
    while (isRuning) {
        print(i);
        ++i;
        Thread.Sleep(1000);
    }
});

①可以通过Task的静态类的.WhenAll和.WhenAny中的.ContinueWith方法,或者Task.Factory中的静态方法.ContinueWhenAll()和.ContinueWhenAny()来指定所有任务完成后或者其中任意一个任务完成之后再去执行的任务

②委托函数中传入的参数t是一个Task类型的,它是里面包含了前置的那些任务的执行状态之类的变量。比如可以按如下方式使用:

cs 复制代码
.ContinueWith(t => {
    if (t.IsFaulted) {
        Debug.LogError($"任务失败: {t.Exception}");
        return;
    }
    // 安全执行后续逻辑...
});

4.6.取消Task执行

方法一:通过加入bool标识 控制线程内死循环的结束

方法二:通过CancellationTokenSource取消标识源类 来控制

CancellationTokenSource对象可以达到延迟取消、取消回调等功能

cs 复制代码
 //声明一个取消标识源类
 c = new CancellationTokenSource();
 //延迟取消
 c.CancelAfter(5000);
 //取消回调
 c.Token.Register(() =>
 {
     print("任务取消了");
 });
 Task.Run(() =>
 {
     int i = 0;
     //用里面的是否被撤销的属性来控制循环
     while (!c.IsCancellationRequested)
     {
         print("计时:" + i);
         ++i;
         Thread.Sleep(1000);
     }
 });

 //延迟取消

// Update is called once per frame
void Update()
{
    if(Input.GetKeyDown(KeyCode.Space))
    {
        //调用取消标识源中的取消方法来取消任务
        c.Cancel();
    }
}

①可以在一开始的时候设定一个控制取消的延迟时间

②可以在取消的时候触发一个取消回调事件

③可以用是否撤销的属性来控制线程是否被撤销

5.异步方法

5.1.什么是同步和异步

同步和异步主要用于修饰方法
同步方法:

当一个方法被调用时,调用者需要等待该方法执行完毕后返回才能继续执行
异步方法:

当一个方法被调用时立即返回,并获取一个线程执行该方法内部的逻辑,调用者不用等待该方法执行完毕

5.2.什么时候需要异步编程

当我们调用的方法计算量比较大且耗时的时候,我们不希望花太多时间在这上面阻塞主线程。这个时候据需要使用异步方法了。

比如:

  • 1.复杂逻辑计算时
  • 2.网络下载、网络通讯
  • 3.资源加载时

等等

5.3.异步方法async和await

async和await一般需要配合Task进行使用

**async:**负责修饰方法,告诉编译器这个是异步方法

await: 负责在异步方法内部返回一个线程中的执行逻辑,此时会回到函数外部的主线程中,当该线程中的这部分执行逻辑执行结束了之后才会继续执行函数后面的逻辑

  • 在一个async异步函数中可以有多个await等待关键字

使用await等待异步内容执行完毕(一般和Task配合使用)

遇到await关键字时发生的事情:

  • 1.异步方法将被挂起
  • 2.将控制权返回给调用者
  • 3.当await修饰内容异步执行结束后,继续通过调用者线程执行后面内容
cs 复制代码
//异步方法执行流程示意
public async void TestAsync()
{
    //1
    print("进入异步方法");
    //2.这步会在一个线程中执行,此时程序返回到函数外部的主线程中继续执行
    await Task.Run(() =>
    {
        Thread.Sleep(5000);
    });
    //3.当第二步中的线程执行完毕了之后,就会继续执行这之后的逻辑
    print("异步方法后面的逻辑");
}

使用async修饰异步方法的注意事项:

  • 1.在异步方法中使用await关键字(不使用编译器会给出警告但不报错),否则异步方法会以同步方式执行
  • 2.异步方法名称建议以Async结尾
  • 3.异步方法的返回值只能是void、Task、Task<>
  • 4.异步方法中不能声明使用ref或out关键字修饰的变量

5.4.使用例子

5.4.1.复杂逻辑计算

利用Task新开线程进行计算 计算完毕后再使用 比如复杂的寻路算法

CalcPathAsync(this.gameObject, Vector3.zero);

cs 复制代码
 public async void CalcPathAsync(GameObject obj, Vector3 endPos)
 {
     print("开始处理寻路逻辑");
     int value = 10;
     await Task.Run(() =>
     {
         //处理复杂逻辑计算 我这是通过 休眠来模拟 计算的复杂性
         Thread.Sleep(1000);
         value = 50;
         //是多线程 意味着我们不能在 多线程里 去访问 Unity主线程场景中的对象
         //这样写会报错
         //print(obj.transform.position);
     });

     print("寻路计算完毕 处理逻辑" + value);
     obj.transform.position = Vector3.zero;
 }
5.4.2.计时器

顾名思义,就是按时间计时了

Timer();

print("主线程逻辑执行");

cs 复制代码
 public async void Timer()
 {
     UnityWebRequest q = UnityWebRequest.Get("");
     source = new CancellationTokenSource();
     int i = 0;
     while (!source.IsCancellationRequested)
     {
         print(i);
         await Task.Delay(1000);
         ++i;
     }
 }

// Update is called once per frame
void Update()
{
    if (Input.GetKeyDown(KeyCode.Space))
        source.Cancel();
}
5.4.3.资源加载

Addressables的资源异步加载是可以使用async和await的

Resources.LoadAsync()这些早期的异步加载资源的方式在await这里用不了,只有使用协同程序进行使用。除非搭配一些第三方的插件https://github.com/svermeulen/Unity3dAsyncAwaitUtil(需要科学上网)

用到.Net库中的一些API的时候可以考虑使用异步方法

用第三方插件实现的Resources.LoadAsync()实例:

cs 复制代码
using UnityEngine;

/// <summary>
/// 用异步函数异步加载一个立方体到场景中
/// </summary>
public class Lesson6 : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        LoadCubeAsync("cube");
    }

    /// <summary>
    /// 异步加载一个资源并实例化到场景中
    /// </summary>
    private async void LoadCubeAsync(string resName)
    {
        print("开始加载一个资源");
        //获取异步加载请求
        ResourceRequest request = Resources.LoadAsync<GameObject>(resName);

        //等待资源异步加载完成
        await request;

        print("加载完毕,开始实例化");
        GameObject cube = request.asset as GameObject;
        cube = Instantiate(cube, Vector3.zero, Quaternion.identity);
    }
}

①用来这插件之后可以让await支持对ResourceRequest这个类的异步等待

②之后按异步方法的常规套路来处理等待加载完成之后的逻辑


四.总结

①Unity各个版本支持的C#版本是不同的,因为各个版本的Unity使用的脚本运行时和C#编译器都是更新的,这能够让我们使用最新的C#来在我们的代码中进行编程

②更新版本的C#一般会带来新的方法和特性,使用这些方法和特性往往能够让我们的代码更加简洁

③在C#4中,加入了dynamic动态类型,能够让我们绕过编译器检查去在运行的时候动态地生成相应类型的变量,这在我们无法一开始确定对象的类型的时候还是比较有用的。但是它不支持IL2CPP,且需要把.Net兼容版本设置为.Net Framework,这意味着更大的包体,更低的执行效率,以及放弃了跨平台的特性。因此我们一般不推荐使用这个

④在C#5中,加入了ThreadPool,Task,和异步方法Async与await关键字

⑤ThreadPool能够帮助我们更高效地利用线程资源,但是无法精确地获取每个线程的信息与控制

⑥Task是基于ThreadPool的优点的更高一层封装,它能够创建无返回值和有返回值的任务类型,并且能够让任务同步执行(用new方法创建的),让关键任务进行阻塞,让几个任务按一定顺序完成,同时可以用一个取消标记源类来控制任务的撤销以及触发相应的撤销回调

⑦Async和await关键字合作来完成对一个异步方法的修饰,Async修饰的方法只能够以void,Task,Task<>作为返回值,await是用于挂起该函数并等待一个线程逻辑执行完毕之后再执行后续的函数逻辑,一个函数中可以包含多个await。

⑧异步方法通常用于复杂计算,计时器,资源加载等场景。其中Unity内置的一些资源加载的异步方法需要加入一些第三方插件才能够支持运行。不过一般情况下用协同函数也能够完成这样的资源加载。