9.UniTask取消操作

9.取消操作


9.1 知识点

原生 Task 取消机制详解

测试脚本

using System;
using System.Threading;
using System.Threading.Tasks;

namespace UniTask_Console
{
    internal class Program
    {
        static async Task Main(string[] args)
        {
            // 创建取消令牌源,用于控制异步操作的取消逻辑
            CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
            // 从令牌源获取取消令牌,用于在异步操作中检测取消请求
            CancellationToken cancellationToken = cancellationTokenSource.Token;

            // 启动一个异步任务,使用匿名方法定义任务逻辑
            Task task = Task.Run(async () =>
            {
                for (int i = 0; i < 10; i++)
                {
                    // 检查是否收到取消请求,若收到则抛出OperationCanceledException异常
                    cancellationToken.ThrowIfCancellationRequested();
                    Console.WriteLine($"打印当前迭代值:{i + 1}");
                    // 模拟异步操作延迟1秒
                    await Task.Delay(1000);
                }
            });

            Console.WriteLine("按下任意按钮取消 Task");
            // 阻塞程序,等待用户按下任意键
            Console.ReadKey();

            // 触发取消请求,通知所有关联的CancellationToken取消操作
            cancellationTokenSource.Cancel();
            try
            {
                // 等待任务完成,若任务被取消会在此处抛出异常
                await task;
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("按下了任意按钮,Task 取消了,抛出OperationCanceledException异常");
            }
            finally
            {
                // 释放CancellationTokenSource占用的非托管资源
                cancellationTokenSource.Dispose();
            }

            Console.WriteLine("程序结束");
        }
    }
}

取消机制原理

原生 Task 的取消通过协作式模式实现,核心组件为:

  1. CancellationTokenSource:取消指令发射器
  2. CancellationToken:取消状态接收器
  3. ThrowIfCancellationRequested():取消检测方法

当调用Cancel()时,所有关联的CancellationToken会进入取消状态,但需要任务内部主动检查才能响应取消请求。

测试脚本解析

// 创建取消令牌源,用于控制异步操作的取消逻辑
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
// 从令牌源获取取消令牌,用于在异步操作中检测取消请求
CancellationToken cancellationToken = cancellationTokenSource.Token;

// 启动一个异步任务,使用匿名方法定义任务逻辑
Task task = Task.Run(async () =>
{
    for (int i = 0; i < 10; i++)
    {
        // 检查是否收到取消请求,若收到则抛出OperationCanceledException异常
        cancellationToken.ThrowIfCancellationRequested();
        Console.WriteLine($"打印当前迭代值:{i + 1}");
        // 模拟异步操作延迟1秒
        await Task.Delay(1000);
    }
});

取消执行流程

  1. 用户按下任意键触发cts.Cancel()
  2. 令牌状态变为IsCancellationRequested = true
  3. 循环中ThrowIfCancellationRequested()检测到取消状态
  4. 立即抛出OperationCanceledException中断任务

异常处理与资源释放

Console.WriteLine("按下任意按钮取消 Task");
// 阻塞程序,等待用户按下任意键
Console.ReadKey();

// 触发取消请求,通知所有关联的CancellationToken取消操作
cancellationTokenSource.Cancel();
try
{
    // 等待任务完成,若任务被取消会在此处抛出异常
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine("按下了任意按钮,Task 取消了,抛出OperationCanceledException异常");
}
finally
{
    // 释放CancellationTokenSource占用的非托管资源
    cancellationTokenSource.Dispose();
}

Console.WriteLine("程序结束");

运行结果

按下任意按钮取消 Task
打印当前迭代值:1
打印当前迭代值:2
打印当前迭代值:3
打印当前迭代值:4
打印当前迭代值:5
按下了任意按钮,Task 取消了,抛出OperationCanceledException异常
程序结束

进程已结束,退出代码为 0。

UniTask 取消机制详解

在之前的博文中,我们探讨了原生 Task 的取消机制。本文将重点介绍 UniTask 如何实现任务取消,并结合官方文档和示例代码进行说明。

取消操作原理

与原生 Task 相似,UniTask 也采用协作式取消机制。核心组件包括:

  1. CancellationTokenSource:用于发出取消指令。
  2. CancellationToken:用于接收取消状态。
  3. **ThrowIfCancellationRequested()**:用于检测取消请求。

当调用 CancellationTokenSource.Cancel() 时,所有关联的 CancellationToken 会进入取消状态。任务内部需要主动检查取消状态,以响应取消请求。

测试脚本

using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;

// 取消操作,大致原理同 Task 中的取消
// 通过 CancellationTokenSource 创建对应令牌 CancellationToken
// 通过 CancellationToken 与要取消的任务对应,通过 CancellationTokenSource 的 Cancel 方法设置 CancellationToken 标志位,任务取消,CancellationToken 抛出异常
// 捕获异常,任务停止
// async UniTaskVoid 与 async Void 比较
// async Void 是 C# 提供的,比较重
// 最好不要在 UniTask 中使用
public class Lesson09_取消操作 : MonoBehaviour
{
    public GameObject ball;
    public Button btnMove;
    public Button btnCancel;
    private CancellationTokenSource _cancellationTokenSource;
    private CancellationToken _cancellationToken;

    void Start()
    {
        // 创建一个新的 CancellationTokenSource 实例
        _cancellationTokenSource = new CancellationTokenSource();

        // 获取 CancellationTokenSource 对应的取消令牌
        _cancellationToken = _cancellationTokenSource.Token;


        // 为按钮添加点击事件监听器,使用 UniTask.UnityAction 避免使用 async void
        // btnMove.onClick.AddListener(OnClickBtnMove);
        // btnMove.onClick.AddListener(UniTask.UnityAction(async () => await OnClickBtnMove()));
        btnMove.onClick.AddListener(UniTask.UnityAction(OnClickBtnMove));
        btnCancel.onClick.AddListener(OnClickBtnCancel);
    }

    // private async void OnClickBtnMove()
    // private async UniTask OnClickBtnMove()
    private async UniTaskVoid OnClickBtnMove()
    {
        try
        {
            // 调用 Move 方法,传入球的 Transform 和取消令牌,等待移动任务完成
            await Move(ball.transform, _cancellationToken);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("捕获到OperationCanceledException异常");
        }
    }

    private void OnClickBtnCancel()
    {
        // 取消并更新 Token
        _cancellationTokenSource.Cancel();
        _cancellationTokenSource.Dispose();
        _cancellationTokenSource = new CancellationTokenSource();
        _cancellationToken = _cancellationTokenSource.Token;
    }

    // 定义一个异步方法 Move,用于移动指定的 Transform 对象
    // 接收一个 Transform 类型的参数 tf 表示要移动的对象,以及一个 CancellationToken 类型的参数 CancellationToken 用于取消操作
    // 方法返回一个异步任务,任务完成后返回一个 int 类型的值
    private async UniTask<int> Move(Transform ballTransform, CancellationToken token)
    {
        // 定义总移动时间为 5 秒
        float totalTime = 5;
        // 定义当前已经移动的时间,初始值为 0
        float currentTime = 0;

        // 当当前移动时间小于总移动时间时,持续执行循环
        while (currentTime < totalTime)
        {
            // 累加当前移动时间,使用 Time.deltaTime 确保在不同帧率下移动速度一致
            currentTime += Time.deltaTime;
            // 移动 Transform 对象的位置,在 X 轴正方向上移动,移动速度与时间相关
            ballTransform.position += new Vector3(1, 0, 0) * Time.deltaTime;

            // 等待下一帧,并传入取消令牌,若取消则会抛出异常
            await UniTask.NextFrame(token);
        }

        // 移动完成后返回 0
        return 0;
    }
}

关键点说明

  • 按钮事件绑定:使用 UniTask.UnityAction 避免使用 async void,确保异步方法的正确执行和异常处理。

    btnMove.onClick.AddListener(UniTask.UnityAction(OnClickBtnMove));
    
  • 任务取消:在 OnClickBtnCancel 方法中调用 CancellationTokenSource.Cancel() 发出取消指令,并重新创建 CancellationTokenSourceCancellationToken,以便后续使用。

    _cancellationTokenSource.Cancel();
    _cancellationTokenSource.Dispose();
    _cancellationTokenSource = new CancellationTokenSource();
    _cancellationToken = _cancellationTokenSource.Token;
    
  • 任务执行与取消检测:在 Move 方法中,使用 await UniTask.NextFrame(token) 等待下一帧,并传入取消令牌。如果在此期间收到取消请求,将抛出 OperationCanceledException,任务可在 catch 块中捕获并处理。

    await UniTask.NextFrame(token);
    

async void 与 async UniTaskVoid 对比

async void是一个原生的 C# 任务系统,因此它不在 UniTask 系统上运行。也最好不要使用它,因为有可能导致无法捕获异常,难以进行任务管理等问题。async UniTaskVoidasync UniTask的轻量级版本,因为它没有等待完成并立即向UniTaskScheduler.UnobservedTaskException报告错误。如果您不需要等待(即发即弃),那么使用UniTaskVoid会更好。不幸的是,要解除警告,您需要在尾部添加Forget()

public async UniTaskVoid FireAndForgetMethod()
{
    // do anything...
    await UniTask.Yield();
}

public void Caller()
{
    FireAndForgetMethod().Forget();
}

UniTask 也有Forget方法,与UniTaskVoid类似且效果相同。如果您完全不需要使用await,那么使用UniTask会更高效。

public async UniTask DoAsync()
{
    // do anything...
    await UniTask.Yield();
}

public void Caller()
{
    DoAsync().Forget();
}

要使用注册到事件的异步 lambda,请不要使用async void。您可以使用UniTask.ActionUniTask.UnityAction来代替,这两者都通过async UniTaskVoid lambda 来创建委托。

Action actEvent;
UnityAction unityEvent; // UGUI 特供

// 这样是不好的: async void
actEvent += async () => { };
unityEvent += async () => { };

// 这样是可以的: 通过 lamada 创建 Action
actEvent += UniTask.Action(async () => { await UniTask.Yield(); });
unityEvent += UniTask.UnityAction(async () => { await UniTask.Yield(); });

UniTaskVoid也可以用在 MonoBehaviour 的Start方法中。

class Sample : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        // 异步初始化代码。
    }
}

返回值UniTask和UniTaskVoid

适合返回 UniTask 的场景

  • 需要返回值:当异步操作需要返回一个结果时,应该使用 UniTask<T>T 是返回值的类型)。例如,异步从网络获取数据、从文件读取内容等,这些操作完成后通常会有一个结果需要传递给调用者。
public async UniTask<string> GetDataFromNetwork()
{
    // 模拟网络请求
    await UniTask.Delay(1000); 
    return "Some data from network";
}

public async void Caller()
{
    string data = await GetDataFromNetwork();
    Debug.Log(data);
}

在上述示例中,GetDataFromNetwork 方法异步获取数据并返回一个 string 类型的结果,调用者可以等待该任务完成并获取结果。

  • 需要等待任务完成:如果调用者需要等待异步任务完成后再继续执行后续代码,那么使用 UniTask 是合适的。例如,在执行一系列依赖的异步操作时,需要确保前一个操作完成后再进行下一个操作。
public async UniTask DoStep1()
{
    await UniTask.Delay(1000);
    Debug.Log("Step 1 completed");
}

public async UniTask DoStep2()
{
    await UniTask.Delay(1000);
    Debug.Log("Step 2 completed");
}

public async void Caller()
{
    await DoStep1();
    await DoStep2();
    Debug.Log("All steps completed");
}

在这个例子中,Caller 方法会依次等待 DoStep1DoStep2 完成后再继续执行后续代码。

适合返回 UniTaskVoid 的场景

  • 即发即弃的操作:当你发起一个异步操作,但不需要等待它完成,也不需要获取其返回值时,可以使用 UniTaskVoid。例如,在游戏中播放一个音效或者显示一个短暂的提示信息,这些操作不需要调用者等待完成。
public async UniTaskVoid PlaySoundEffect()
{
    await UniTask.Delay(1000);
    Debug.Log("Sound effect played");
}

public void Caller()
{
    PlaySoundEffect().Forget();
    Debug.Log("Continuing other tasks...");
}

在这个示例中,Caller 方法调用 PlaySoundEffect 后不会等待音效播放完成,而是直接继续执行后续代码。

  • 事件处理方法:在事件处理方法中,如果需要执行异步操作,使用 UniTaskVoid 可以避免使用 async void 带来的问题。例如,在 Unity 中处理按钮点击事件时,可以使用 UniTaskVoid 来执行异步逻辑。
using UnityEngine;
using UnityEngine.UI;
using Cysharp.Threading.Tasks;

public class ButtonHandler : MonoBehaviour
{
    public Button myButton;

    void Start()
    {
        myButton.onClick.AddListener(UniTask.UnityAction(OnButtonClick));
    }

    private async UniTaskVoid OnButtonClick()
    {
        await UniTask.Delay(1000);
        Debug.Log("Button clicked and async operation completed");
    }
}

在这个例子中,OnButtonClick 方法是一个按钮点击事件的处理方法,使用 UniTaskVoid 可以在其中执行异步操作,并且避免了 async void 的潜在问题。

CancelAfter延迟取消

using System.Threading;
using UnityEngine;
using Cysharp.Threading.Tasks;
using System;

// CancellationTokenSource 的 CancelAfter 方法
// ***时间后取消任务,可用于做超时处理 
public class Lesson09_取消操作_延时取消 : MonoBehaviour
{
    // 定义取消令牌源,用于控制异步任务的取消
    private CancellationTokenSource _cancellationTokenSource;

    // 定义取消令牌,用于在异步任务中检测取消请求
    private CancellationToken _cancellationToken;

    void Start()
    {
        // 创建一个新的取消令牌源实例
        _cancellationTokenSource = new CancellationTokenSource();

        // 启动一个异步任务,该任务将执行具体的测试逻辑
        UniTaskVoid taskVoid = BeginTestCancelAfter();
        // 设置在指定时间(这里是 1 秒)后触发取消请求,实现超时处理
        _cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(1));
    }

    // 定义一个异步方法,用于测试延时取消功能
    // 接收一个取消令牌作为参数,用于在任务执行过程中检测取消请求
    // 方法返回一个异步任务,任务完成后返回一个 int 类型的值
    async UniTask<int> TestCancelAfter(CancellationToken token)
    {
        // 循环 5 次,模拟一个持续的工作任务
        for (int i = 0; i < 5; i++)
        {
            Debug.Log($"打印当前迭代值:{i + 1}");
            // 等待 1 秒,并传入取消令牌,若取消则会抛出异常
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
        }

        // 任务正常完成后返回 0
        return 0;
    }

    // 定义一个异步方法,用于开始测试取消功能
    // 该方法返回一个 UniTaskVoid 类型,表示即发即弃的异步任务
    async UniTaskVoid BeginTestCancelAfter()
    {
        try
        {
            // 调用 TestCancelAfter 方法,并传入取消令牌源的令牌
            // 等待该异步任务完成
            await TestCancelAfter(_cancellationTokenSource.Token);
        }
        catch (OperationCanceledException)
        {
            // 捕获到取消异常,打印信息
            Debug.Log("捕获到取消异常OperationCanceledException");
        }
    }
}

  • 注意:官方文档是这样说的
    • CancellationTokenSouce.CancelAfter是一个原生的api。但是在 Unity 中你不应该使用它,因为它依赖于线程计时器。CancelAfterSlim是 UniTask 的扩展方法,它使用 PlayerLoop 代替。
    • 如果您想将超时与其他cancellation一起使用,请使用CancellationTokenSource.CreateLinkedTokenSource.
  • 结论:最好使用CancelAfterSlim

CreateLinkedTokenSource 多条件取消实现

var cancelToken = new CancellationTokenSource();
cancelButton.onClick.AddListener(()=>
{
    cancelToken.Cancel(); // 点击按钮后取消。
});

var timeoutToken = new CancellationTokenSource();
timeoutToken.CancelAfterSlim(TimeSpan.FromSeconds(5)); // 设置5s超时。

try
{
    // 链接 token
    var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancelToken.Token, timeoutToken.Token);

    await UnityWebRequest.Get("http://foo").SendWebRequest().WithCancellation(linkedTokenSource.Token);
}
catch (OperationCanceledException ex)
{
    if (timeoutToken.IsCancellationRequested)
    {
        UnityEngine.Debug.Log("Timeout.");
    }
    else if (cancelToken.IsCancellationRequested)
    {
        UnityEngine.Debug.Log("Cancel clicked.");
    }
}

GetCancellationTokenOnDestroy绑定GameObject取消

CancellationToken 可以由CancellationTokenSource或 MonoBehaviour 的GetCancellationTokenOnDestroy扩展方法创建。

// 这个CancellationTokenSource和this GameObject生命周期相同,当this GameObject Destroy的时候,就会执行Cancel
await UniTask.DelayFrame(1000, cancellationToken: this.GetCancellationTokenOnDestroy());

9.2 知识点代码

Program.cs

using System;
using System.Threading;
using System.Threading.Tasks;

namespace UniTask_Console
{
    internal class Program
    {
        static async Task Main(string[] args)
        {
            // 创建取消令牌源,用于控制异步操作的取消逻辑
            CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
            // 从令牌源获取取消令牌,用于在异步操作中检测取消请求
            CancellationToken cancellationToken = cancellationTokenSource.Token;

            // 启动一个异步任务,使用匿名方法定义任务逻辑
            Task task = Task.Run(async () =>
            {
                for (int i = 0; i < 10; i++)
                {
                    // 检查是否收到取消请求,若收到则抛出OperationCanceledException异常
                    cancellationToken.ThrowIfCancellationRequested();
                    Console.WriteLine($"打印当前迭代值:{i + 1}");
                    // 模拟异步操作延迟1秒
                    await Task.Delay(1000);
                }
            });

            Console.WriteLine("按下任意按钮取消 Task");
            // 阻塞程序,等待用户按下任意键
            Console.ReadKey();

            // 触发取消请求,通知所有关联的CancellationToken取消操作
            cancellationTokenSource.Cancel();
            try
            {
                // 等待任务完成,若任务被取消会在此处抛出异常
                await task;
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("按下了任意按钮,Task 取消了,抛出OperationCanceledException异常");
            }
            finally
            {
                // 释放CancellationTokenSource占用的非托管资源
                cancellationTokenSource.Dispose();
            }

            Console.WriteLine("程序结束");
        }
    }
}

Lesson09_取消操作.cs

using Cysharp.Threading.Tasks;
using System;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;

// 取消操作,大致原理同 Task 中的取消
// 通过 CancellationTokenSource 创建对应令牌 CancellationToken
// 通过 CancellationToken 与要取消的任务对应,通过 CancellationTokenSource 的 Cancel 方法设置 CancellationToken 标志位,任务取消,CancellationToken 抛出异常
// 捕获异常,任务停止
// async UniTaskVoid 与 async Void 比较
// async Void 是 C# 提供的,比较重
// 最好不要在 UniTask 中使用
public class Lesson09_取消操作 : MonoBehaviour
{
    public GameObject ball;
    public Button btnMove;
    public Button btnCancel;
    private CancellationTokenSource _cancellationTokenSource;
    private CancellationToken _cancellationToken;

    void Start()
    {
        // 创建一个新的 CancellationTokenSource 实例
        _cancellationTokenSource = new CancellationTokenSource();

        // 获取 CancellationTokenSource 对应的取消令牌
        _cancellationToken = _cancellationTokenSource.Token;


        // 为按钮添加点击事件监听器,使用 UniTask.UnityAction 避免使用 async void
        // btnMove.onClick.AddListener(OnClickBtnMove);
        // btnMove.onClick.AddListener(UniTask.UnityAction(async () => await OnClickBtnMove()));
        btnMove.onClick.AddListener(UniTask.UnityAction(OnClickBtnMove));
        btnCancel.onClick.AddListener(OnClickBtnCancel);
    }

    // private async void OnClickBtnMove()
    // private async UniTask OnClickBtnMove()
    private async UniTaskVoid OnClickBtnMove()
    {
        try
        {
            // 调用 Move 方法,传入球的 Transform 和取消令牌,等待移动任务完成
            await Move(ball.transform, _cancellationToken);
        }
        catch (OperationCanceledException)
        {
            Debug.Log("捕获到OperationCanceledException异常");
        }
    }

    private void OnClickBtnCancel()
    {
        // 取消并更新 Token
        _cancellationTokenSource.Cancel();
        _cancellationTokenSource.Dispose();
        _cancellationTokenSource = new CancellationTokenSource();
        _cancellationToken = _cancellationTokenSource.Token;
    }

    // 定义一个异步方法 Move,用于移动指定的 Transform 对象
    // 接收一个 Transform 类型的参数 tf 表示要移动的对象,以及一个 CancellationToken 类型的参数 CancellationToken 用于取消操作
    // 方法返回一个异步任务,任务完成后返回一个 int 类型的值
    private async UniTask<int> Move(Transform ballTransform, CancellationToken token)
    {
        // 定义总移动时间为 5 秒
        float totalTime = 5;
        // 定义当前已经移动的时间,初始值为 0
        float currentTime = 0;

        // 当当前移动时间小于总移动时间时,持续执行循环
        while (currentTime < totalTime)
        {
            // 累加当前移动时间,使用 Time.deltaTime 确保在不同帧率下移动速度一致
            currentTime += Time.deltaTime;
            // 移动 Transform 对象的位置,在 X 轴正方向上移动,移动速度与时间相关
            ballTransform.position += new Vector3(1, 0, 0) * Time.deltaTime;

            // 等待下一帧,并传入取消令牌,若取消则会抛出异常
            await UniTask.NextFrame(token);
        }

        // 移动完成后返回 0
        return 0;
    }
}

Lesson09_取消操作_延时取消.cs

using System.Threading;
using UnityEngine;
using Cysharp.Threading.Tasks;
using System;

// CancellationTokenSource 的 CancelAfter 方法
// ***时间后取消任务,可用于做超时处理 
public class Lesson09_取消操作_延时取消 : MonoBehaviour
{
    // 定义取消令牌源,用于控制异步任务的取消
    private CancellationTokenSource _cancellationTokenSource;

    // 定义取消令牌,用于在异步任务中检测取消请求
    private CancellationToken _cancellationToken;

    void Start()
    {
        // 创建一个新的取消令牌源实例
        _cancellationTokenSource = new CancellationTokenSource();

        // 启动一个异步任务,该任务将执行具体的测试逻辑
        UniTaskVoid taskVoid = BeginTestCancelAfter();
        // 设置在指定时间(这里是 1 秒)后触发取消请求,实现超时处理
        _cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(1));
    }

    // 定义一个异步方法,用于测试延时取消功能
    // 接收一个取消令牌作为参数,用于在任务执行过程中检测取消请求
    // 方法返回一个异步任务,任务完成后返回一个 int 类型的值
    async UniTask<int> TestCancelAfter(CancellationToken token)
    {
        // 循环 5 次,模拟一个持续的工作任务
        for (int i = 0; i < 5; i++)
        {
            Debug.Log($"打印当前迭代值:{i + 1}");
            // 等待 1 秒,并传入取消令牌,若取消则会抛出异常
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
        }

        // 任务正常完成后返回 0
        return 0;
    }

    // 定义一个异步方法,用于开始测试取消功能
    // 该方法返回一个 UniTaskVoid 类型,表示即发即弃的异步任务
    async UniTaskVoid BeginTestCancelAfter()
    {
        try
        {
            // 调用 TestCancelAfter 方法,并传入取消令牌源的令牌
            // 等待该异步任务完成
            await TestCancelAfter(_cancellationTokenSource.Token);
        }
        catch (OperationCanceledException)
        {
            // 捕获到取消异常,打印信息
            Debug.Log("捕获到取消异常OperationCanceledException");
        }
    }
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏