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

发布于:2025-07-11 ⋅ 阅读:(64) ⋅ 点赞:(0)

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

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

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

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

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

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

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

在开始前,我们首先需要回答这个问题,我们为什么要对UniTask进行二次封装?回答是:UniTask 提供了底层的异步操作原语,就像搭乐高的基础砖块。然而,直接使用这些基础砖块在大型或复杂的项目中可能会遇到一些挑战:

  1. 提高易用性和开发效率:

    • 减少重复代码: 许多异步操作模式在项目中会反复出现,例如:等待一段时间、加载文件、网络请求等。如果每次都从头编写 UniTask.Delayawait UniTask.SwitchToThreadPool() 等,会产生大量重复且相似的代码。
    • 统一接口: UniTask 本身有多种等待方式(按时间、按帧、等待条件),通过封装可以提供一个更统一、更语义化的接口。
    • 隐藏底层细节: 封装可以将线程切换、取消令牌管理、异常处理等底层复杂性隐藏起来,让业务逻辑开发者能更专注于他们要实现的功能,而不是异步编程的细节。
  2. 增强代码可读性和可维护性:

    • 语义化命名: 通过封装,我们可以用更具业务含义的名称来命名异步操作,例如 DownloadAssetAsyncFadeOutUI,而不是泛泛的 DoSomethingAsync
    • 降低心智负担: 当你看到 await MyTaskUtils.WaitFor(2.5f) 时,立刻就知道它是在等待,而不需要去思考 UniTask.Delay 内部参数的含义。
    • 集中修改: 如果未来 UniTask 某个 API 的使用方式发生变化,或者需要统一修改某个异步行为(例如统一的日志输出),只需要修改封装层,而不需要修改所有使用到该 API 的地方。
  3. 提升代码健壮性和安全性:

    • 统一异常处理: 异步操作中的异常处理往往容易被忽略。封装层可以内置 try-catch 块,确保所有异步任务的异常都能被捕获和处理,避免程序崩溃。
    • 自动取消管理: CancellationTokenUniTask 的核心,但手动管理 CancellationTokenSource 的生命周期容易出错。封装可以自动绑定任务与 MonoBehaviour 的生命周期,防止内存泄漏和僵尸任务。
    • 线程安全: 封装可以确保敏感操作(如文件 IO)在正确的线程(后台线程)上执行,并在必要时安全地切回主线程,避免多线程访问 Unity API 的错误。

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

我们将通过四个常用场景来展示如何进行二次封装,涵盖了延迟等待、文件 IO、周期性任务和生命周期绑定。


1. 统一的延迟等待方法:MyTaskUtils.WaitFor

代码:

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

public static class MyTaskUtils
{
    /// <summary>
    /// 异步等待指定的时间(秒)或帧数。
    /// 默认在主线程更新。
    /// </summary>
    /// <param name="delayTime">等待时间(秒)。如果小于等于 0,则等待指定帧数。</param>
    /// <param name="delayFrames">如果 delayTime <= 0,则等待此帧数。</param>
    /// <param name="ignoreTimeScale">是否忽略 Time.timeScale 的影响。</param>
    /// <param name="cancellationToken">可选的取消令牌。</param>
    /// <returns>UniTask。</returns>
    public static async UniTask WaitFor(
        float delayTime,
        int delayFrames = 1,
        bool ignoreTimeScale = false,
        CancellationToken cancellationToken = default)
    {
        if (delayTime > 0)
        {
            await UniTask.Delay(TimeSpan.FromSeconds(delayTime), ignoreTimeScale, PlayerLoopTiming.Update, cancellationToken);
        }
        else
        {
            await UniTask.DelayFrame(delayFrames, PlayerLoopTiming.Update, cancellationToken);
        }
    }
}

设计意图和写法:

  • 单一职责: 这个方法旨在提供一个统一的“等待”功能。在 Unity 中,我们常常需要按时间等待(受 Time.timeScale 影响或不影响),或者按帧等待。UniTask 提供了 DelayDelayFrame 两个独立的方法来处理。
  • 参数合并与智能判断: 我将 delayTimedelayFrames 合并到一个方法签名中。通过判断 delayTime 是否大于 0,智能地选择调用 UniTask.DelayUniTask.DelayFrame,简化了调用方的心智负担。
  • 默认参数: delayFrames 默认为 1,ignoreTimeScale 默认为 falsecancellationToken 默认为 default。这使得最常见的“等待一小段时间”或“等待一帧”的调用变得非常简洁,例如 await WaitFor(2.5f);await WaitFor(0);
  • PlayerLoopTiming.Update 明确指定了在 Update 阶段进行等待,这是最常用的游戏逻辑更新时机。
  • 可取消性: 依然保留了 cancellationToken 参数,确保封装后的方法仍然支持任务取消,这是 UniTask 的核心优势之一。

2. 安全的文件 IO 操作:FileIOTaskUtils

代码:

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

public static class FileIOTaskUtils
{
    /// <summary>
    /// 异步读取文件所有文本内容。在后台线程执行。
    /// </summary>
    /// <param name="filePath">文件路径。</param>
    /// <param name="cancellationToken">可选的取消令牌。</param>
    /// <returns>文件的文本内容,如果失败则返回 null。</returns>
    public static async UniTask<string> ReadAllTextAsyncSafe(string filePath, CancellationToken cancellationToken = default)
    {
        string content = null;
        try
        {
            await UniTask.SwitchToThreadPool(); // 切换到后台线程
            cancellationToken.ThrowIfCancellationRequested(); // 检查取消

            if (!File.Exists(filePath))
            {
                Debug.LogWarning($"文件不存在: {filePath}");
                return null;
            }

            content = await File.ReadAllTextAsync(filePath, cancellationToken);
        }
        catch (OperationCanceledException)
        {
            Debug.LogWarning($"读取文件操作被取消: {filePath}");
            return null;
        }
        catch (Exception ex)
        {
            Debug.LogError($"读取文件失败: {filePath} - {ex.Message}");
            return null;
        }
        finally
        {
            await UniTask.SwitchToMainThread(); // 确保回到主线程
        }
        return content;
    }

    /// <summary>
    /// 异步写入文本内容到文件。在后台线程执行。
    /// </summary>
    /// <param name="filePath">文件路径。</param>
    /// <param name="content">要写入的文本内容。</param>
    /// <param name="cancellationToken">可选的取消令牌。</param>
    /// <returns>写入成功则返回 true,否则返回 false。</returns>
    public static async UniTask<bool> WriteAllTextAsyncSafe(string filePath, string content, CancellationToken cancellationToken = default)
    {
        bool success = false;
        try
        {
            await UniTask.SwitchToThreadPool();
            cancellationToken.ThrowIfCancellationRequested();

            string directory = Path.GetDirectoryName(filePath);
            if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
            {
                Directory.CreateDirectory(directory);
            }

            await File.WriteAllTextAsync(filePath, content, cancellationToken);
            success = true;
        }
        catch (OperationCanceledException)
        {
            Debug.LogWarning($"写入文件操作被取消: {filePath}");
            success = false;
        }
        catch (Exception ex)
            {
                Debug.LogError($"写入文件失败: {filePath} - {ex.Message}");
                success = false;
            }
        finally
        {
            await UniTask.SwitchToMainThread();
        }
        return success;
    }
}

设计意图和写法:

  • 线程安全: 文件 IO 是典型的 IO 密集型操作,将其放在后台线程执行可以避免阻塞主线程。因此,在 try 块的开始就 await UniTask.SwitchToThreadPool(),并在 finally 块中 await UniTask.SwitchToMainThread(),确保了整个 IO 过程的线程安全性以及操作完成后安全地回到主线程。
  • 健壮性:
    • 内置异常处理: 使用 try-catch 块捕获 OperationCanceledException(当任务被取消时抛出)和其他 Exception,并在控制台输出警告/错误信息,而不是让异常导致程序崩溃。
    • 空/无效路径检查: ReadAllTextAsyncSafe 中增加了 File.Exists(filePath) 检查,避免尝试读取不存在的文件。
    • 自动目录创建: WriteAllTextAsyncSafe 在写入前会检查并创建目标文件的父目录,增加了便利性。
  • 返回值设计: ReadAllTextAsyncSafe 返回 stringnull (表示失败),WriteAllTextAsyncSafe 返回 bool (表示成功或失败)。这种设计让调用方更容易通过返回值判断操作结果,而不是必须使用 try-catch
  • 可取消性: 每次操作前都 cancellationToken.ThrowIfCancellationRequested(),并将其传递给 File.ReadAllTextAsyncFile.WriteAllTextAsync,确保了 IO 操作在长时间运行时也能被及时取消。

3. 可控的定时器和循环:LoopTaskUtils.DoPeriodicTask

代码:

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

public static class LoopTaskUtils
{
    /// <summary>
    /// 异步执行一个周期性任务,直到满足停止条件或被取消。
    /// </summary>
    /// <param name="intervalSeconds">每次迭代之间的间隔时间(秒)。</param>
    /// <param name="action">每次迭代执行的动作。</param>
    /// <param name="stopCondition">停止循环的条件函数。返回 true 则停止。</param>
    /// <param name="cancellationToken">可选的取消令牌。</param>
    /// <param name="runOnMainThread">任务是否在主线程执行。如果为 false,action 会在后台线程执行。</param>
    /// <returns>UniTask。</returns>
    public static async UniTask DoPeriodicTask(
        float intervalSeconds,
        Action action,
        Func<bool> stopCondition = null,
        CancellationToken cancellationToken = default,
        bool runOnMainThread = true)
    {
        try
        {
            while (!cancellationToken.IsCancellationRequested && (stopCondition == null || !stopCondition()))
            {
                if (!runOnMainThread)
                {
                    await UniTask.SwitchToThreadPool();
                }
                
                action?.Invoke();

                if (!runOnMainThread)
                {
                    await UniTask.SwitchToMainThread();
                }

                if (!cancellationToken.IsCancellationRequested && (stopCondition == null || !stopCondition()))
                {
                    await UniTask.Delay(TimeSpan.FromSeconds(intervalSeconds), false, PlayerLoopTiming.Update, cancellationToken);
                }
            }
        }
        catch (OperationCanceledException)
        {
            Debug.Log("周期性任务被取消。");
        }
        catch (Exception ex)
        {
            Debug.LogError($"周期性任务发生错误: {ex.Message}");
        }
        finally
        {
            Debug.Log("周期性任务结束。");
        }
    }
}

设计意图和写法:

  • 灵活的循环控制:
    • intervalSeconds:控制每次迭代的时间间隔。
    • action:通过 Action 委托传入每次循环需要执行的具体逻辑,使循环体可定制。
    • stopCondition:引入一个 Func<bool> 委托作为停止条件,使得循环可以基于任意动态条件(如计数器达到上限、某个状态变为真)自动终止,比简单的 forwhile(true) 循环更加强大。
  • 可取消性: while 循环内部始终检查 cancellationToken.IsCancellationRequested,并且将 cancellationToken 传递给 UniTask.Delay,确保任务可以在外部被随时取消,避免无限循环或资源浪费。
  • 线程选择: runOnMainThread 参数是一个关键设计点。它允许你决定 action 中的逻辑是在主线程执行还是在后台线程执行。
    • 如果 runOnMainThreadfalse,则在执行 actionSwitchToThreadPool(),执行完毕后 SwitchToMainThread(),确保 action 即使在后台线程执行,其内部如果访问 Unity API,也能通过后续的 SwitchToMainThread 安全地回到主线程。请注意: 在后台线程执行 action 意味着 action 内部的所有代码都将在后台线程运行,如果 action 内部直接操作 Unity API 仍会导致错误,需要确保 action 内部仅进行计算或 IO,或自己再次 SwitchToMainThread。这里封装的意图是 action 内部的耗时计算部分可以在后台线程运行。
  • 健壮性: 内置 try-catch 块捕获 OperationCanceledException 和其他 Exception,为周期性任务提供统一的错误处理。
  • Forget() 友好: 这种周期性任务通常是“启动后即运行”,调用方不关心其返回值。因此,通常会结合 Forget() 使用,如 DoPeriodicTask(...).Forget();

4. 与 MonoBehaviour 生命周期绑定的任务启动器:TaskRunner

代码:

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

public static class TaskRunner
{
    /// <summary>
    /// 在指定 MonoBehaviour 的生命周期内启动一个 UniTask。
    /// 任务会在 MonoBehaviour 销毁时自动取消。
    /// </summary>
    /// <param name="monoBehaviour">绑定生命周期的 MonoBehaviour 实例。</param>
    /// <param name="taskFunc">要执行的 UniTask 函数。</param>
    public static UniTask RunOnDestroy(this MonoBehaviour monoBehaviour, Func<CancellationToken, UniTask> taskFunc)
    {
        return RunTaskWithLifecycle(monoBehaviour, taskFunc, monoBehaviour.GetCancellationTokenOnDestroy());
    }

    /// <summary>
    /// 在指定 MonoBehaviour 的生命周期内启动一个 UniTask。
    /// 任务会在 MonoBehaviour 禁用时自动取消。
    /// </summary>
    /// <param name="monoBehaviour">绑定生命周期的 MonoBehaviour 实例。</param>
    /// <param name="taskFunc">要执行的 UniTask 函数。</param>
    public static UniTask RunOnDisable(this MonoBehaviour monoBehaviour, Func<CancellationToken, UniTask> taskFunc)
    {
        return RunTaskWithLifecycle(monoBehaviour, taskFunc, monoBehaviour.GetCancellationTokenOnDisable());
    }

    private static async UniTask RunTaskWithLifecycle(MonoBehaviour monoBehaviour, Func<CancellationToken, UniTask> taskFunc, CancellationToken lifecycleToken)
    {
        if (monoBehaviour == null)
        {
            Debug.LogWarning("TaskRunner: MonoBehaviour 实例为空,任务无法启动。");
            return;
        }

        try
        {
            await taskFunc(lifecycleToken);
        }
        catch (OperationCanceledException)
        {
            if (monoBehaviour != null)
            {
                Debug.Log($"任务绑定到 {monoBehaviour.name} 生命周期,因取消令牌触发而结束。");
            }
        }
        catch (Exception ex)
        {
            if (monoBehaviour != null)
            {
                Debug.LogError($"任务绑定到 {monoBehaviour.name} 发生未处理的错误: {ex.Message}");
            }
            else
            {
                Debug.LogError($"一个绑定生命周期的任务在 MonoBehaviour 销毁后发生未处理的错误: {ex.Message}");
            }
        }
    }
}

设计意图和写法:

  • 扩展方法: RunOnDestroyRunOnDisable 被设计为 MonoBehaviour 的扩展方法 (this MonoBehaviour monoBehaviour)。这意味着你可以在任何 MonoBehaviour 实例上直接调用它们,如 this.RunOnDestroy(...),这大大提高了代码的简洁性和直观性。
  • 自动生命周期绑定: 这是核心功能。MonoBehaviour 扩展方法 GetCancellationTokenOnDestroy()GetCancellationTokenOnDisable()UniTask 提供的非常实用的功能,它们会自动创建一个 CancellationToken,并在对应的 MonoBehaviour 被销毁或禁用时自动触发取消。TaskRunner 利用这一点,将这个自动生成的 CancellationToken 传递给你的任务。
  • 统一任务启动入口: RunTaskWithLifecycle 是私有辅助方法,负责实际的任务启动和通用错误处理。它接收 MonoBehaviour 实例、要执行的任务 (taskFunc) 和生命周期 CancellationToken
  • 避免空引用: 在任务开始前检查 monoBehaviour == null,防止在 MonoBehaviour 已经被销毁的情况下尝试启动任务。
  • 统一异常处理: 内置 try-catch 块捕获 OperationCanceledException 和其他 Exception。特别地,它会检查 MonoBehaviour 是否还存在,以便在日志中提供更准确的上下文信息。
  • Func<CancellationToken, UniTask> 参数: 这种委托签名允许你传入一个 Lambda 表达式或方法,该方法会接收一个 CancellationToken 参数。这样,你的任务内部就可以方便地访问并响应取消请求。
  • 通常与 Forget() 结合: 绑定生命周期的任务通常是“启动后运行”的,你可能不关心它们的完成结果,因此常常会配合 Forget() 方法使用,以避免编译器的 await 警告,同时确保任务在后台运行。

总结

通过上述的二次封装示例,我们能清楚地看到,对 UniTask 进行封装不仅仅是代码上的简单复制粘贴,更是一种设计模式的体现。它让我们能够:

  1. 提升代码的抽象层级: 将底层异步细节抽象为更符合业务语义的高级接口。
  2. 强制最佳实践: 在封装内部嵌入线程安全、异常处理、取消机制等最佳实践,避免开发者遗漏。
  3. 提高团队协作效率: 统一的接口和规范让团队成员能够更快上手,并产出更高质量、更一致的代码。

掌握 UniTask 本身是基础,而学会如何根据项目需求对其进行高层次的二次封装,则是一个经验丰富的 Unity 开发者迈向卓越的关键一步。

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

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

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

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

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


网站公告

今日签到

点亮在社区的每一天
去签到