Unity C\# 健壮性编程:告别崩溃!掌握异常处理与调试的 4 大核心技巧 (Day 22)

Langchain系列文章目录

01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘

PyTorch系列文章目录

Python系列文章目录

C#系列文章目录

01-C#与游戏开发的初次见面:从零开始的Unity之旅
02-C#入门:从变量与数据类型开始你的游戏开发之旅
03-C#运算符与表达式:从入门到游戏伤害计算实践
04-从零开始学C#:用if-else和switch打造智能游戏逻辑
05-掌握C#循环:for、while、break与continue详解及游戏案例
06-玩转C#函数:参数、返回值与游戏中的攻击逻辑封装
07-Unity游戏开发入门:用C#控制游戏对象移动
08-C#面向对象编程基础:类的定义、属性与字段详解
09-C#封装与访问修饰符:保护数据安全的利器
10-如何用C#继承提升游戏开发效率?Enemy与Boss案例解析
11-C#多态性入门:从零到游戏开发实战
12-C#接口王者之路:从入门到Unity游戏开发实战 (IAttackable案例详解)
13-C#静态成员揭秘:共享数据与方法的利器
14-Unity 面向对象实战:掌握组件化设计与脚本通信,构建玩家敌人交互
15-C#入门 Day15:彻底搞懂数组!从基础到游戏子弹管理实战
16-C# List 从入门到实战:掌握动态数组,轻松管理游戏敌人列表 (含代码示例)
17-C# 字典 (Dictionary) 完全指南:从入门到游戏属性表实战 (Day 17)
18-C#游戏开发【第18天】 | 深入理解队列(Queue)与栈(Stack):从基础到任务队列实战
19-【C# 进阶】深入理解枚举 Flags 属性:游戏开发中多状态组合的利器
20-C#结构体(Struct)深度解析:轻量数据容器与游戏开发应用 (Day 20)
21-Unity数据持久化进阶:告别硬编码,用ScriptableObject优雅管理游戏配置!(Day 21)
22-Unity C# 健壮性编程:告别崩溃!掌握异常处理与调试的 4 大核心技巧 (Day 22)


文章目录


前言

欢迎来到 “C# for Unity 学习之旅 (50天)” 系列的第 22 天!在前几周,我们已经掌握了 C# 的基础语法、面向对象编程以及常用的数据结构。今天,我们将进入一个至关重要的主题——健壮性编程。编写健壮的代码意味着你的程序不仅能在预期情况下正常工作,还能在遇到意外错误(如文件损坏、网络中断、无效输入)时,优雅地处理它们,而不是直接崩溃。这对于提供良好的用户体验和简化开发调试过程至关重要。

在本篇文章中,我们将聚焦于 Unity C# 开发中提升代码健壮性的两大核心技术:异常处理(Exception Handling)调试(Debugging)。我们将学习:

  • 如何使用 try-catch 语句捕获并处理运行时可能发生的错误。
  • 如何有效利用 Unity 提供的 Debug 类进行日志记录,帮助追踪代码执行和定位问题。
  • 如何在 Visual Studio 中设置断点,逐步执行代码,深入了解程序运行状态。
  • 何时以及如何创建自定义异常,以更精确地表达特定错误场景。

掌握这些技能,将使你能够编写出更稳定、更易于维护和调试的 Unity 项目代码。让我们开始吧!

一、为何需要健壮性编程?

在软件开发中,尤其是游戏开发,代码的健壮性(Robustness)是衡量项目质量的重要指标。一个健壮的程序能够在各种异常或错误条件下继续运行,或者至少能够以可控的方式失败,而不是突然崩溃。

1.1 提升用户体验

想象一下,玩家正在兴致勃勃地玩你的游戏,突然因为一个未处理的文件读取错误或者数据解析问题导致游戏崩溃,之前的进度全部丢失。这种体验无疑是灾难性的。健壮的代码能够预见并处理这些潜在问题,例如:

  • 当存档文件损坏时,提示用户文件错误并提供恢复选项,而不是直接闪退。
  • 当网络连接丢失时,给出明确提示并尝试重新连接,而不是让游戏卡死。

通过优雅地处理错误,可以显著提升玩家的满意度和游戏的口碑。

1.2 简化开发与维护

健壮的代码往往更容易调试和维护。

  • 清晰的错误信息: 通过合理的异常处理和日志记录,开发者可以快速定位问题的根源,而不是面对一个神秘的崩溃束手无策。
  • 减少连锁反应: 一个模块的错误如果能被妥善处理,就不会轻易扩散到其他模块,导致整个系统瘫痪。这使得代码的模块化更有效,修改和扩展也更安全。
  • 更早发现问题: 在开发阶段就引入健壮性措施,可以在早期发现并修复潜在的逻辑漏洞和边界情况。

1.3 应对不可预见的情况

现实世界充满了不确定性。用户的设备环境各异(不同的操作系统、硬件配置),文件系统可能出现问题,网络连接可能不稳定,第三方服务可能暂时不可用。健壮性编程的目标就是让你的程序有能力应对这些不可预见的外部因素,尽可能保证核心功能的稳定运行。

二、异常处理:C# 的 try-catch 语句

异常(Exception)是在程序执行期间发生的错误或意外情况。C# 提供了一套强大的异常处理机制,其核心就是 try-catch 语句,允许你编写代码来“尝试”执行可能出错的操作,并“捕获”和处理发生的异常。

2.1 try-catch 的基本语法与工作原理

try-catch 语句的基本结构如下:

try
{
    
    
    // 尝试执行的代码块,这里可能抛出异常
    // 例如:文件读写、网络请求、数据转换等
}
catch (SpecificExceptionType ex)
{
    
    
    // 如果 try 块中抛出了 SpecificExceptionType 类型的异常,
    // 或者其子类型的异常,这里的代码将被执行。
    // ex 变量包含了关于异常的详细信息。
    Debug.LogError($"捕获到特定异常: {
      
      ex.Message}\n{
      
      ex.StackTrace}");
    // 进行相应的错误处理,例如:记录日志、给用户提示、使用默认值等
}
catch (AnotherExceptionType ex)
{
    
    
    // 可以有多个 catch 块来处理不同类型的异常
    Debug.LogWarning($"捕获到另一种异常: {
      
      ex.Message}");
}
catch (Exception ex) // 捕获所有其他类型的异常(通常放在最后)
{
    
    
    // 这是一个通用的异常捕获器,应该谨慎使用,
    // 最好能明确捕获预期可能发生的特定异常类型。
    Debug.LogError($"捕获到未知异常: {
      
      ex.Message}\n{
      
      ex.StackTrace}");
}
finally // 可选的 finally 块
{
    
    
    // 无论 try 块中是否发生异常,也无论异常是否被 catch 块捕获,
    // finally 块中的代码 *总是* 会被执行。
    // 通常用于释放资源,如关闭文件流、断开网络连接等。
    Debug.Log("无论如何都会执行清理操作。");
}

2.1.1 try 块:保护可能出错的代码

将你预期可能引发异常的代码放入 try 关键字后的大括号 {} 中。例如,文件读取、数据解析、网络通信等操作都适合放在 try 块内。

2.1.2 catch 块:捕获并处理特定异常

如果在 try 块执行期间发生了异常,程序会立即停止 try 块中剩余代码的执行,并查找匹配该异常类型的 catch 块。

  • 类型匹配: catch 块通过指定异常类型(如 FileNotFoundException, FormatException, IOException 或通用的 Exception)来捕获异常。如果抛出的异常类型与 catch 块指定的类型相同,或者是其子类,则该 catch 块会被执行。
  • 顺序重要: 程序会按照 catch 块的书写顺序进行匹配。因此,应该将更具体的异常类型(子类)放在前面,更通用的异常类型(父类,如 Exception)放在后面。
  • ex 变量: catch 块可以声明一个异常对象变量(通常命名为 ex),通过它可以访问异常的详细信息,如错误消息 (ex.Message) 和导致异常的代码调用堆栈 (ex.StackTrace)。

2.1.3 finally 块(可选):无论如何都执行的代码

finally 块是可选的。无论 try 块是成功执行完毕,还是中途抛出了异常(无论是否被捕获),finally 块中的代码保证会被执行。这使得它非常适合执行资源清理操作,确保即使发生错误,重要的资源(如文件句柄、网络连接、数据库连接)也能被正确释放。

2.2 常见的异常类型

在 Unity 和 C# 开发中,你会遇到各种内置的异常类型。了解常见的异常有助于你编写更精确的 catch 块:

2.2.1 FileNotFoundException:文件未找到

当尝试访问一个不存在的文件时抛出。通常在 System.IO 文件操作中遇到。

2.2.2 IOException:通用 I/O 错误

这是一个更通用的输入/输出异常基类。可能在文件读写权限不足、磁盘已满等情况下抛出。FileNotFoundExceptionIOException 的子类。

2.2.3 FormatException:数据格式错误

当尝试将字符串转换为特定类型(如数字、日期)但字符串格式不符合要求时抛出。例如,int.Parse("abc") 就会抛出此异常。在使用 JsonUtility 或其他库解析格式错误的 JSON/XML 时也可能遇到类似问题(可能是不同的具体异常类型,但概念相似)。

2.2.4 NullReferenceException:空引用(Unity 常见陷阱)

这是 Unity 开发中最常见的错误之一!当你尝试访问一个值为 null 的对象的成员(方法、属性、字段)时抛出。例如,GetComponent<Rigidbody>() 如果找不到 Rigidbody 组件会返回 null,后续若直接调用 rb.velocity 就会触发此异常。虽然 try-catch 可以捕获它,但更好的做法通常是通过空检查 (if (myObject != null)) 来预防。

2.3 实践:为加载游戏存档添加 try-catch

假设我们需要从一个 JSON 文件加载游戏存档数据。这个过程可能因为文件不存在、文件损坏(JSON 格式错误)或读取权限问题而失败。

2.3.1 场景设定:读取 JSON 配置文件

我们定义一个简单的数据结构 PlayerData,并尝试从 Application.persistentDataPath 路径下的 savegame.json 文件读取数据。

using UnityEngine;
using System.IO; // 需要引入 System.IO 命名空间进行文件操作

[System.Serializable] // 确保 PlayerData 可以被 JsonUtility 序列化
public class PlayerData
{
    
    
    public string playerName;
    public int score;
    public float health;
}

public class SaveLoadManager : MonoBehaviour
{
    
    
    private string saveFilePath;

    void Awake()
    {
    
    
        // 构建存档文件的完整路径
        saveFilePath = Path.Combine(Application.persistentDataPath, "savegame.json");
        Debug.Log($"存档路径: {
      
      saveFilePath}");
    }

    public PlayerData LoadGame()
    {
    
    
        PlayerData loadedData = null; // 初始化为 null

        try
        {
    
    
            // --- 可能抛出异常的代码块 ---
            // 1. 检查文件是否存在 (虽然 ReadAllText 内部会检查,但显式检查更清晰)
            if (!File.Exists(saveFilePath))
            {
    
    
                // 如果文件不存在,我们可以选择抛出一个更具体的 FileNotFoundException,
                // 或者直接在这里处理(比如返回默认数据或创建新文件)。
                // 这里我们选择记录警告并返回 null 或默认数据。
                Debug.LogWarning($"存档文件未找到: {
      
      saveFilePath}. 将返回默认数据或 null。");
                // return new PlayerData(); // 可以返回一个默认的 PlayerData
                return null; // 或者直接返回 null,让调用者处理
            }

            // 2. 读取文件内容 (可能抛出 IOException 等)
            string json = File.ReadAllText(saveFilePath);

            // 3. 解析 JSON 数据 (如果 json 格式错误,可能抛出 ArgumentException 或其他解析相关的异常)
            loadedData = JsonUtility.FromJson<PlayerData>(json);

            if (loadedData == null)
            {
    
    
                 // JsonUtility 在解析失败时通常返回 null,而不是抛出异常
                 // 但某些极端情况或不同JSON库可能抛异常,所以 catch 仍然有意义
                 Debug.LogError("JSON 解析失败,文件可能已损坏。");
                 // 这里可以考虑抛出一个自定义异常,或者返回 null
                 throw new FormatException("无法将 JSON 解析为 PlayerData 对象。");
            }

            Debug.Log("游戏存档加载成功!");
            // --- 可能抛出异常的代码块结束 ---
        }
        catch (FileNotFoundException fnfEx)
        {
    
    
            // 这个 catch 块在本例中可能不会被直接触发,因为我们先用了 File.Exists
            // 但如果直接调用 ReadAllText 而不检查,这里就能捕获到。
            Debug.LogError($"捕获到文件未找到异常: {
      
      fnfEx.Message}");
            // 处理逻辑:可能是第一次启动游戏,可以创建默认存档
            loadedData = new PlayerData {
    
     playerName = "New Player", score = 0, health = 100f };
        }
        catch (IOException ioEx)
        {
    
    
            // 捕获其他可能的 IO 错误,如权限问题
            Debug.LogError($"捕获到 IO 异常: {
      
      ioEx.Message}\n尝试从路径 '{
      
      saveFilePath}' 读取时出错。请检查文件权限或磁盘空间。");
            // 处理逻辑:提示用户检查权限,或尝试备用路径等
            loadedData = null; // 或返回默认数据
        }
        catch (System.ArgumentException argEx) // JsonUtility.FromJson 在传入 null 或空字符串时可能抛出
        {
    
    
             Debug.LogError($"捕获到参数异常(可能是 JSON 解析问题): {
      
      argEx.Message}");
             loadedData = null; // 文件内容无效
        }
        catch (FormatException formatEx) // 捕获我们自己或库可能抛出的格式异常
        {
    
    
            Debug.LogError($"捕获到格式异常(JSON 解析失败): {
      
      formatEx.Message}");
            loadedData = null; // 文件内容无效
        }
        catch (System.Exception ex) // 捕获所有其他未预料到的异常
        {
    
    
            // 最后的防线,捕获所有其他未明确处理的异常
            Debug.LogError($"捕获到未处理的异常: {
      
      ex.GetType().Name} - {
      
      ex.Message}\n{
      
      ex.StackTrace}");
            // 处理逻辑:记录详细错误,可能需要向用户显示通用错误消息
            loadedData = null; // 发生严重错误,无法加载
        }
        finally
        {
    
    
            // 可以在这里执行一些清理操作,比如关闭读取器(如果使用的是 StreamReader)
            // 在这个例子中,ReadAllText 会自动处理文件关闭,所以 finally 不是必须的,
            // 但可以用来打印一条无论成功失败都会显示的消息。
            Debug.Log("加载存档操作尝试完毕。");
        }

        return loadedData;
    }

    // (可以在 Start 或其他地方调用 LoadGame)
    void Start()
    {
    
    
        PlayerData data = LoadGame();
        if (data != null)
        {
    
    
            Debug.Log($"玩家: {
      
      data.playerName}, 分数: {
      
      data.score}, 生命: {
      
      data.health}");
        }
        else
        {
    
    
            Debug.LogWarning("未能成功加载玩家数据。");
            // 可能需要初始化新游戏或显示错误信息给玩家
        }
    }
}

2.3.2 代码示例与解析

上面的 LoadGame 方法演示了:

  1. try 块包裹核心逻辑: 文件存在性检查、读取文件内容、JSON 解析都放在 try 块中,因为这些步骤都可能失败。
  2. 多个 catch 块处理特定异常: 我们分别捕获了 FileNotFoundException (虽然可能被 File.Exists 避免)、IOException(处理权限等问题)、ArgumentException/FormatException(处理 JSON 解析问题)以及通用的 Exception 作为最后保障。
  3. 日志记录:catch 块中,使用 Debug.LogErrorDebug.LogWarning 记录详细的错误信息,包括异常消息和类型,有时也包括堆栈跟踪 (ex.StackTrace),这对于调试非常有帮助。
  4. 错误处理策略: 根据不同的异常类型,采取不同的处理方式(返回 null、返回默认数据、记录警告或错误)。
  5. finally 块(可选): 用于确保某些操作(如资源释放或状态清理)总能执行。

通过这种方式,即使加载过程中出现问题,游戏也不会直接崩溃,而是能够根据捕获到的异常类型做出相应的反应。

三、有效的日志记录:Debug 类的妙用

在 Unity 中,UnityEngine.Debug 类是你调试代码、追踪执行流程、诊断问题的得力助手。它提供了多种方法向 Unity 控制台输出信息。

3.1 日志的不同级别与选择

Debug 类提供了三个主要的日志记录方法,对应不同的严重级别:

3.1.1 Debug.Log:常规信息与流程跟踪

用于输出一般性的信息,比如:

  • 确认代码块是否被执行。
  • 输出变量的当前值。
  • 标记重要的程序事件(如 “玩家开始游戏”, “关卡加载完成”)。
int score = 100;
Debug.Log($"玩家当前得分: {
      
      score}"); // 输出白色文本

3.1.2 Debug.LogWarning:潜在问题或非致命错误

用于指示可能存在的问题,或者发生了错误但程序仍能继续运行的情况。例如:

  • 某个资源未找到,但系统使用了备用资源。
  • 某个数值超出了预期范围,但被钳制在有效区间内。
  • 某个可选的配置文件加载失败。
if (optionalConfigFile == null)
{
    
    
    Debug.LogWarning("可选配置文件加载失败,将使用默认设置。"); // 输出黄色文本,带警告图标
}

3.1.3 Debug.LogError:严重错误,可能导致功能异常

用于报告严重的错误,这些错误很可能导致部分或全部功能无法正常工作,甚至可能在后续引发更严重的问题。例如:

  • 必要资源加载失败。
  • 发生未处理的异常(在 catch 块中常用)。
  • 关键逻辑执行失败。
catch (System.Exception ex)
{
    
    
    Debug.LogError($"处理玩家输入时发生严重错误: {
      
      ex.Message}\n{
      
      ex.StackTrace}"); // 输出红色文本,带错误图标
    // 点击控制台中的 Error 日志,通常会暂停游戏执行(如果开启了 Error Pause)
}

选择合适的级别非常重要,因为它直接影响到 Unity 控制台的显示(颜色、图标)以及过滤功能。你可以只显示 Error 级别的日志来快速定位严重问题。

3.2 日志的最佳实践

有效使用 Debug 类需要遵循一些最佳实践:

3.2.1 提供上下文信息

仅仅打印一个数字或简单的 “Error” 是不够的。你的日志应该包含足够的信息来理解它是在哪里、什么情况下输出的。

// 不好的例子
Debug.Log(playerHealth);
Debug.LogError("Error!");

// 好的例子
string objectName = gameObject.name; // 获取当前脚本所在游戏对象的名称
Debug.Log($"[{
      
      objectName}] 当前生命值: {
      
      playerHealth}");
Debug.LogError($"[{
      
      objectName}] 在执行 Attack() 方法时发生错误: 敌人目标未设置!");

包含对象名、方法名、关键变量值等上下文信息,能让你更快地定位问题源头。

3.2.2 避免在 Update 中过度打印

Update 方法每帧都会执行。如果在 Update 或类似频繁调用的方法中无条件地使用 Debug.Log,会产生大量的日志信息,淹没掉真正有用的信息,并且可能对性能产生轻微影响。

// 不推荐:每帧都打印
void Update()
{
    
    
    Debug.Log($"当前位置: {
      
      transform.position}");
}

// 推荐:只在需要时打印(例如,按下某个键、状态改变时)
void Update()
{
    
    
    if (Input.GetKeyDown(KeyCode.P))
    {
    
    
        Debug.Log($"玩家按下 P 键,当前位置: {
      
      transform.position}");
    }
}

// 或者使用一个布尔标记来控制一次性打印
private bool hasLoggedPosition = false;
void Update()
{
    
    
    if (!hasLoggedPosition && transform.position.z > 10f)
    {
    
    
        Debug.Log($"玩家 Z 坐标首次超过 10: {
      
      transform.position}");
        hasLoggedPosition = true;
    }
}

3.2.3 使用条件编译 (#if UNITY_EDITORDEBUG)

有时,某些详细的调试日志只在开发阶段需要,不希望它们出现在最终发布的游戏版本中(即使 Debug.Log 在 Release Build 中通常会被优化掉一部分,但过度使用仍可能残留影响)。可以使用 C# 的预处理指令来控制:

#if UNITY_EDITOR || DEVELOPMENT_BUILD // 只在编辑器模式或开发构建版本中编译这段代码
    // 打印非常详细的调试信息
    Debug.Log("非常详细的内部状态信息...");
#endif

public void SomeCriticalFunction()
{
    
    
    // 这个 LogError 比较重要,可能需要在所有版本中都保留(或至少在 Release build 中保留)
    // 注意:Release build 默认会定义 'DEBUG' 符号,除非在 Build Settings 中显式移除。
    // 但 Debug.LogError/LogWarning 通常建议保留,以便问题追踪。
    Debug.LogError("关键功能发生错误!");
}

UNITY_EDITOR 仅在 Unity 编辑器中为 true。DEVELOPMENT_BUILD 在勾选了 “Development Build” 选项的构建中为 true。DEBUG 符号默认在 Editor 和 Development Build 中定义。你可以根据需要选择合适的条件。

四、断点调试:深入代码内部

Debug.Log 不足以让你理解代码的执行流程或变量变化时,断点调试(Breakpoint Debugging) 就成了你的终极武器。它允许你在代码的特定行暂停执行,然后检查变量的值、单步执行代码、查看函数调用关系等。

4.1 设置与使用断点

调试通常在你的代码编辑器(如 Visual Studio)中进行。

4.1.1 在 Visual Studio 中设置断点

  • 打开你想要调试的 C# 脚本文件。
  • 在代码行的左侧边栏(通常是灰色区域)单击,即可在该行设置一个断点。一个红色的圆点会出现,表示断点已设置。
  • 再次单击红点可以取消断点。右键单击红点可以设置条件断点(仅当满足特定条件时才暂停)。

4.1.2 附加到 Unity 进程

要使 Visual Studio 能够控制 Unity 的执行并响应断点,你需要将调试器附加到 Unity 编辑器进程:

  1. 确保你的 Unity 项目是打开的。
  2. 在 Visual Studio 中,点击顶部菜单栏的 “调试” (Debug)。
  3. 选择 “附加到 Unity 调试程序” (Attach to Unity Debugger) 或类似的选项(有时可能显示为 “附加到进程 (Attach to Process…)”,然后选择 Unity.exe)。通常会有一个快捷按钮,图标类似于一个播放按钮旁边带有 Unity Logo。
  4. 选择正在运行的 Unity 编辑器实例。
  5. 点击 “附加” (Attach)。

附加成功后,当 Unity 运行到设置了断点的代码行时,执行会暂停,Visual Studio 窗口将自动激活,并高亮显示当前暂停的代码行。

4.2 调试控制

当程序在断点处暂停时,Visual Studio 提供了多个工具来帮助你检查和控制执行流程:

4.2.1 单步执行(Step Over, Step Into, Step Out)

这些是调试工具栏上最常用的按钮(通常是 F10, F11, Shift+F11):

  • 逐过程 (Step Over / F10): 执行当前行。如果当前行包含方法调用,它会执行整个方法,然后停在下一行。(常用)
  • 逐语句 (Step Into / F11): 执行当前行。如果当前行包含方法调用,它会进入该方法内部的第一行暂停。(用于深入了解函数内部)
  • 跳出 (Step Out / Shift+F11): 继续执行当前方法的剩余部分,然后在调用该方法的地方的下一行暂停。(当你不小心 Step Into 了一个不想看的函数时用)

4.2.2 监视变量与表达式

当执行暂停时,你可以检查变量的当前值:

  • 鼠标悬停: 将鼠标悬停在代码中的变量名上,通常会弹出一个小窗口显示其值。
  • 局部变量窗口 (Locals Window): 显示当前作用域内所有局部变量及其值。
  • 监视窗口 (Watch Window): 你可以手动添加特定的变量或表达式到监视窗口,持续跟踪它们的值的变化。
  • 自动窗口 (Autos Window): 自动显示当前行和上一行使用到的变量。

4.2.3 调用堆栈(Call Stack)

调用堆栈窗口显示了导致程序执行到当前断点的函数调用序列。最顶部的函数是当前正在执行的,下面是调用它的函数,以此类推。这对于理解代码是如何运行到这里的,以及问题的根源可能在哪个调用层级非常有帮助。

4.3 条件断点与日志点

  • 条件断点 (Conditional Breakpoint): 右键单击断点红点,选择 “条件…” (Conditions…),可以设置一个表达式。只有当该表达式为 true 时,断点才会触发暂停。这对于调试循环或只有在特定情况下才出现问题的场景非常有用(例如 i == 500 时暂停)。
  • 日志点 (Logpoint / Tracepoint): 在设置条件的同时,你可以选择 “操作…” (Actions…),勾选 “记录消息” (Log a message to Output Window)。这样,当断点被命中时,程序不会暂停,而是在 Visual Studio 的输出窗口打印你指定的消息(可以包含变量值,如 变量 x 的值是 {x})。这是一种无需修改代码即可添加临时日志的方法。

五、自定义异常:更精确的错误表达

虽然 .NET Framework 提供了丰富的内置异常类型,但在某些特定于你的应用程序逻辑的错误场景下,使用自定义异常可以提供更清晰、更具体的错误信息。

5.1 何时需要自定义异常?

你可能需要自定义异常,当:

5.1.1 标准异常不足以描述问题时

内置异常(如 ArgumentException, InvalidOperationException)虽然通用,但可能无法精确表达你的业务逻辑中的特定错误。例如,你可能想区分“玩家金币不足”和“购买的物品不存在”这两种不同的购买失败原因。

5.1.2 需要携带特定错误信息时

自定义异常类可以包含额外的属性,用于携带与错误相关的特定上下文数据。例如,一个 InsufficientFundsException 可以包含 RequiredAmountCurrentBalance 属性。

5.2 如何定义和抛出自定义异常

5.2.1 创建继承自 Exception 的类

创建一个新的 C# 类,让它继承自 System.Exception(或某个更具体的内置异常类型,如 InvalidOperationException)。按照惯例,自定义异常类名应以 “Exception” 结尾。

using System;

// 自定义异常:表示游戏配置数据无效
public class InvalidGameConfigurationException : Exception
{
    
    
    // 可以添加特定于此异常的属性
    public string ConfigurationKey {
    
     get; }

    // 提供标准的构造函数
    public InvalidGameConfigurationException() : base("无效的游戏配置数据。") {
    
     }

    public InvalidGameConfigurationException(string message) : base(message) {
    
     }

    public InvalidGameConfigurationException(string message, Exception innerException)
        : base(message, innerException) {
    
     }

    // 提供一个可以携带额外信息的构造函数
    public InvalidGameConfigurationException(string configurationKey, string message)
        : base(message)
    {
    
    
        ConfigurationKey = configurationKey;
    }

    public InvalidGameConfigurationException(string configurationKey, string message, Exception innerException)
        : base(message, innerException)
    {
    
    
        ConfigurationKey = configurationKey;
    }
}

5.2.2 使用 throw 关键字

在你检测到特定错误条件的地方,使用 throw 关键字创建并抛出你的自定义异常实例。

public class GameConfigLoader
{
    
    
    public int GetMaxPlayerLevel(GameSettings settings)
    {
    
    
        if (settings.maxLevel <= 0)
        {
    
    
            // 检测到配置错误,抛出自定义异常
            throw new InvalidGameConfigurationException("maxLevel", $"配置项 'maxLevel' 必须为正数,但获取到的值为 {
      
      settings.maxLevel}。");
        }
        if (settings.maxLevel > 999)
        {
    
    
             throw new InvalidGameConfigurationException("maxLevel", $"'maxLevel' ({
      
      settings.maxLevel}) 超出允许的最大值 999。");
        }

        return settings.maxLevel;
    }
}

// 在调用代码中,可以捕获这个特定的异常
public class GameManager : MonoBehaviour
{
    
    
    public GameSettings currentSettings;
    private GameConfigLoader configLoader = new GameConfigLoader();

    void Start()
    {
    
    
        try
        {
    
    
            int maxLevel = configLoader.GetMaxPlayerLevel(currentSettings);
            Debug.Log($"最大玩家等级配置为: {
      
      maxLevel}");
        }
        catch (InvalidGameConfigurationException configEx)
        {
    
    
            // 捕获我们自定义的异常
            Debug.LogError($"游戏配置错误 (键: {
      
      configEx.ConfigurationKey}): {
      
      configEx.Message}");
            // 进行处理,比如使用默认值或提示管理员检查配置
            // Use default level or notify admin
        }
        catch (Exception ex)
        {
    
    
            Debug.LogError($"加载配置时发生未知错误: {
      
      ex.Message}");
        }
    }
}

// 假设的 GameSettings 类
[System.Serializable]
public class GameSettings
{
    
    
    public int maxLevel;
    // ... 其他设置
}

5.2.3 代码示例

上面的示例演示了:

  1. 定义 InvalidGameConfigurationException 类,继承自 Exception,并添加了 ConfigurationKey 属性。
  2. GetMaxPlayerLevel 方法中,当检测到 maxLevel 配置不合法时,使用 throw new InvalidGameConfigurationException(...) 抛出异常,并传入相关的键名和错误消息。
  3. GameManagerStart 方法中,使用 try-catch 包裹对 GetMaxPlayerLevel 的调用。
  4. catch 块专门捕获 InvalidGameConfigurationException,并可以访问其特有的 ConfigurationKey 属性,从而进行更精确的错误处理。

通过自定义异常,代码的意图更清晰,错误处理逻辑也更具针对性。

六、总结

编写健壮的 Unity C# 代码是提升游戏质量和开发效率的关键。今天我们学习了几种核心技术:

  1. 异常处理 (try-catch-finally):

    • 使用 try 保护可能出错的代码。
    • 使用 catch 捕获特定类型的异常并进行处理(记录日志、提供备选方案、用户提示)。优先捕获具体异常,将通用 Exception 放在最后。
    • 使用 finally 确保资源释放等清理操作总是执行。
    • 了解常见异常类型(FileNotFoundException, IOException, FormatException, NullReferenceException 等)有助于编写针对性的 catch 块。
  2. 有效日志记录 (Debug 类):

    • 区分 Debug.Log (常规信息), Debug.LogWarning (潜在问题), Debug.LogError (严重错误) 并恰当使用。
    • 日志信息应包含足够的上下文(对象名、方法名、变量值)。
    • 避免在 Update 等频繁调用的方法中无条件打印大量日志。
    • 使用条件编译 (#if UNITY_EDITOR 等) 控制开发日志的输出。
  3. 断点调试:

    • 在 Visual Studio 中设置断点,并将调试器附加到 Unity 进程。
    • 使用单步执行(Step Over, Step Into, Step Out)控制代码流程。
    • 通过监视窗口、局部变量窗口、鼠标悬停检查变量值。
    • 利用调用堆栈理解函数调用关系。
    • 掌握条件断点和日志点提高调试效率。
  4. 自定义异常:

    • 当内置异常不足以描述特定业务逻辑错误或需要携带额外信息时,创建继承自 Exception 的自定义异常类。
    • 使用 throw 关键字在检测到错误条件时抛出自定义异常。
    • 在调用代码中 catch 特定的自定义异常,实现更精确的错误处理。

将这些技术结合起来,你就能构建出更稳定、更易于调试和维护的 Unity 应用程序。在接下来的学习中,我们将继续探索 C# 的高级特性及其在 Unity 中的应用。敬请期待!