Unity C#编程:封装、访问修饰符与工程化实践指南

📅 2026/7/8 18:05:00
Unity C#编程:封装、访问修饰符与工程化实践指南
1. 项目概述从“能用”到“好用”的代码规范跃迁在Unity开发社区里无论是新手还是有一定经验的开发者我经常看到一种现象脚本里的变量要么清一色全是public要么就是毫无章法地混用public和private。这背后反映的其实是一个从“功能实现”到“工程化思维”的认知鸿沟。很多人觉得代码能跑起来不就行了public多方便在Inspector里拖拖拽拽就能赋值其他脚本想用的时候直接点出来就能访问。但当你参与一个稍具规模的团队项目或者一个需要长期维护迭代的个人项目时这种“方便”很快就会变成一场噩梦。变量被意外修改导致诡异的Bug、Inspector面板被一堆无关紧要的字段挤得杂乱无章、想重构一个功能却因为牵一发而动全身而束手无策……这些问题根源往往就在于对访问修饰符和封装概念的轻视。这篇文章我想和你深入聊聊Unity C#编程中public和private的正确使用姿势以及“封装”这个面向对象编程OOP核心思想在游戏开发中的实战意义。这不仅仅是语法规则更是一种关乎代码健壮性、可维护性和团队协作效率的工程实践。我们会从最基础的“为什么需要封装”开始逐步拆解public、private、protected、internal等修饰符的适用场景并结合Unity编辑器的特性探讨如何利用[SerializeField]、属性Property等工具在保持封装性的同时又不失编辑器配置的灵活性。我的目标是让你看完后不仅能写出“正确”的代码更能理解其背后的设计逻辑从而写出“优雅”且“坚固”的代码。2. 核心概念拆解封装、访问修饰符与Unity的独特生态2.1 封装的本质不是隐藏而是管理一提到封装很多教程会简单地说“把数据藏起来”。这个说法容易引起误解让人觉得封装是为了制造障碍。实际上封装的核心理念是管理复杂度和建立契约。想象一下你家的电路总闸。你不会把电线直接裸露在外面让人随便碰public而是用一个带开关和保险丝的电闸箱封装保护起来。你需要用电时通过开关公共方法来控制保险丝数据验证会在电流异常时熔断以保护整个电路。这个电闸箱并没有“隐藏”电它只是提供了一个安全、可控的访问和管理方式。在代码中一个类就是这样一个“电闸箱”。类的内部数据字段就像是墙里的电线状态可能很复杂。如果外部代码可以直接修改这些字段public字段就相当于让人直接去摆弄电线很容易导致系统处于不一致或无效的状态比如把玩家的血量health直接设为-100。封装通过将字段设为private然后提供公共方法或属性来访问和修改它们就像提供了那个安全的开关。在这些方法里你可以加入必要的检查、验证和逻辑。在Unity游戏开发中的具体价值防止非法状态确保游戏对象的状态始终有效。例如通过属性设置血量时可以将其限制在0到最大血量之间。降低耦合度外部代码只依赖于类的公共接口方法、属性而不依赖于其内部实现细节。当内部实现需要改变时比如优化算法、改变数据结构只要公共接口不变就不会影响其他代码。提升可调试性所有对数据的修改都通过有限的几个方法进行当数据出现问题时你只需要在这些方法中设置断点或添加日志就能快速定位问题源头而不是在海量代码中寻找是谁直接修改了那个public字段。便于Inspector管理合理使用[SerializeField]可以让必要的配置项在编辑器里可见同时保持代码的封装性避免Inspector被大量public字段污染。2.2 C#访问修饰符全景图与Unity语境下的再理解C#提供了多个访问修饰符但在Unity脚本中最常用、也最易混淆的是public和private。我们需要在Unity这个特定环境下理解它们。public含义完全公开。该成员可以被任何其他类访问无论是否在同一个程序集中。Unity特性标记为public的字段默认会在Unity编辑器的Inspector面板中显示为一个可编辑的输入框。这是Unity为快速原型开发提供的便利但也正是滥用之源。典型误用把一个仅在类内部使用的临时计算变量、或者不应该由设计者随意修改的核心逻辑变量声明为public仅仅因为“方便在Inspector里看值”。private含义私有。该成员只能在声明它的类或结构体内部访问。Unity特性标记为private或protected的字段默认不会在Inspector中显示。典型误用过度使用导致一些本应在编辑器中进行配置的参数如敌人的移动速度、子弹的伤害值无法方便地设置只能硬编码在代码里降低了项目的可配置性和设计迭代速度。其他重要修饰符protected受保护的。成员在类内部和派生类子类中可访问。这在构建游戏对象的继承体系时非常有用比如一个Enemy基类的某些字段需要被BossEnemy、MiniEnemy等子类使用但又不应对其他无关类公开。internal程序集内可访问。在Unity中一个程序集通常对应一个项目或一个插件。当你开发一个供团队内部使用的工具库时可以用internal来暴露一些功能给同一程序集的其他脚本而对程序集外隐藏。protected internal/private protected组合修饰符用于更精细的访问控制在大型或模块化程度高的Unity项目中可能会用到。注意一个常见的误区是认为public字段就是“坏的”private字段就是“好的”。这是一种非黑即白的错误观念。正确的思路是根据成员的职责和需要暴露的范围选择最合适的访问级别。该公开的公开该隐藏的隐藏。2.3 Unity Inspector与代码封装的冲突与调和Unity编辑器的工作流拖拽赋值、实时调整参数与传统的代码封装理念存在天然的张力。很多开发者为了利用编辑器的便利性牺牲了代码的封装性。其实两者完全可以兼得。冲突点设计师或策划希望能在Inspector中灵活调整一个Weapon的damage伤害值。如果damage是private他们调不了如果做成public又破坏了封装任何脚本都能随意修改它。解决方案[SerializeField]属性这是Unity提供的解决方案。你可以将一个private字段标记为[SerializeField]这样它就会在Inspector中显示但在代码层面它仍然是private的外部脚本无法直接访问。这完美地调和了编辑器配置需求和代码封装需求。public class Weapon : MonoBehaviour { [SerializeField] // 在Inspector中显示 private int damage 10; // 代码层面仍是私有的 // ... 其他代码 }属性PropertyC#的属性提供了更强大、更灵活的控制。你可以创建一个公共的getter读取器和一个私有的setter设置器或者通过get; private set;的语法来实现“只读”暴露。你甚至可以在setter中加入逻辑。public class Player : MonoBehaviour { [SerializeField] private int maxHealth 100; // 公共属性外部可以读取当前血量但不能直接设置 public int CurrentHealth { get; private set; } // 通过方法来修改血量可以加入逻辑如伤害计算、死亡判断 public void TakeDamage(int amount) { CurrentHealth - amount; if (CurrentHealth 0) Die(); // 可以在这里触发受伤动画、音效、UI更新等 } }自定义编辑器Editor Scripting对于更复杂的配置需求你可以为你的组件编写自定义的Inspector界面以更友好、更安全的方式暴露和编辑私有数据。这属于进阶内容但它是Unity强大编辑器扩展能力的体现。3. 实战场景分析与最佳实践理解了理论我们来看几个Unity开发中高频出现的场景分析如何正确应用封装和访问修饰符。3.1 场景一游戏角色Player/Enemy状态管理这是最经典的场景。角色的属性生命值、魔法值、攻击力、速度等是游戏逻辑的核心。反面教材全Public流public class BadPlayer : MonoBehaviour { public int health 100; public int mana 50; public float speed 5.0f; // ... 无数个public字段 }问题任何脚本包括一个无关的UI脚本或环境脚本都可以直接写player.health -999;游戏瞬间崩溃。Inspector里也杂乱无章。正确姿势封装属性/方法public class GoodPlayer : MonoBehaviour { // 1. 基础属性设计时配置用[SerializeField]保持私有 [SerializeField] private int maxHealth 100; [SerializeField] private int maxMana 50; [SerializeField] private float baseSpeed 5.0f; // 2. 运行时状态通过属性暴露控制修改权限 public int CurrentHealth { get; private set; } public int CurrentMana { get; private set; } public float CurrentSpeed { get; private set; } // 可能受减速效果影响 // 3. 初始化 private void Start() { CurrentHealth maxHealth; CurrentMana maxMana; CurrentSpeed baseSpeed; } // 4. 通过定义良好的方法改变状态 public void TakeDamage(int damage) { if (IsInvulnerable) return; // 状态检查 damage Mathf.Max(1, damage - Defense); // 伤害计算 CurrentHealth - damage; CurrentHealth Mathf.Clamp(CurrentHealth, 0, maxHealth); // 边界保护 OnHealthChanged?.Invoke(CurrentHealth, maxHealth); // 事件通知如更新UI if (CurrentHealth 0) { Die(); } } public void UseMana(int amount) { if (CurrentMana amount) { CurrentMana - amount; OnManaChanged?.Invoke(CurrentMana, maxMana); } else { // 触发法力不足的反馈 } } // 5. 私有或受保护的方法处理内部逻辑 private void Die() { // 播放死亡动画、音效触发游戏结束逻辑等 // 而不是让外部直接调用 } }设计思路maxHealth等是设计参数应可在Inspector中配置但代码中不应被随意修改故用[SerializeField] private。CurrentHealth是运行时状态对外提供只读访问修改必须通过TakeDamage、Heal等方法在这些方法里集中了所有相关逻辑计算、验证、事件触发。3.2 场景二管理器Manager与全局服务比如游戏管理器GameManager、音频管理器AudioManager。它们通常是单例Singleton需要被广泛访问但内部状态也需要保护。反面教材静态Public字段满天飞public class GameManager : MonoBehaviour { public static int score; public static bool isGameOver; // ... 其他静态字段 }问题同样是全局可写难以追踪修改来源。isGameOver可能在奇怪的地方被设为true。正确姿势单例封装 属性/事件public class GameManager : MonoBehaviour { // 单例实例私有静态通过公共属性访问 private static GameManager _instance; public static GameManager Instance { get { if (_instance null) { _instance FindObjectOfTypeGameManager(); // 也可以实现按需创建 } return _instance; } } // 内部状态 private int _score; private bool _isGameOver; // 通过属性暴露提供可控的访问 public int Score { get _score; private set { _score value; OnScoreChanged?.Invoke(_score); // 分数变化时通知UI } } public bool IsGameOver { get _isGameOver; private set { if (_isGameOver ! value) { _isGameOver value; OnGameStateChanged?.Invoke(_isGameOver); // 游戏状态变化通知 } } } // 定义事件用于解耦通信 public event Actionint OnScoreChanged; public event Actionbool OnGameStateChanged; // 修改状态的方法 public void AddScore(int points) { if (!IsGameOver) // 游戏结束时不能加分 { Score points; } } public void EndGame() { IsGameOver true; // 保存数据、显示结算界面等 } // 防止外部实例化 private void Awake() { if (_instance ! null _instance ! this) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); } }使用示例// 其他脚本中 // 读取是安全的 int currentScore GameManager.Instance.Score; bool gameOver GameManager.Instance.IsGameOver; // 写入必须通过定义好的方法 GameManager.Instance.AddScore(100); // GameManager.Instance.Score 999; // 错误因为set是private的 // GameManager.Instance.IsGameOver true; // 错误设计思路单例实例本身通过属性暴露确保唯一性。核心数据通过带有private set的属性暴露外部只能读不能写。所有修改必须通过AddScore、EndGame等公共方法进行这些方法包含了业务规则如游戏结束后不能加分。同时使用事件event机制通知状态变化而不是让其他脚本不断地轮询Update里判断这更高效、更解耦。3.3 场景三组件间通信与数据传递Unity中GetComponent、FindObjectOfType是常见的获取其他组件引用的方式。如何传递数据既安全又高效反面教材直接暴露字段引用public class Shooter : MonoBehaviour { public Target target; // 直接public引用 public void Shoot() { if (target ! null) { target.health - 10; // 直接修改 } } }问题Shooter直接修改了Target的内部状态两者紧密耦合。如果Target的伤害计算逻辑变了比如要加入护甲所有直接修改health的地方都要改。正确姿势定义接口或发送消息// Target.cs public interface IDamageable { void TakeDamage(int damage, GameObject damageSource); } public class Target : MonoBehaviour, IDamageable { [SerializeField] private int health 100; public void TakeDamage(int damage, GameObject damageSource) { // 在这里集中处理所有伤害逻辑减血、播放受击特效、判断死亡等 health - damage; // ... 其他逻辑 } } // Shooter.cs public class Shooter : MonoBehaviour { // 可以通过多种方式获取目标不一定是public字段 // 例如射线检测、触发器、在Inspector中拖拽[SerializeField] private字段等 [SerializeField] private GameObject currentTarget; public void Shoot() { if (currentTarget ! null) { IDamageable damageable currentTarget.GetComponentIDamageable(); if (damageable ! null) { damageable.TakeDamage(10, this.gameObject); // 通过接口调用 } else { // 目标不可被伤害处理逻辑如播放无效音效 } } } }设计思路定义IDamageable接口任何可以受到伤害的对象都实现它。Shooter只关心目标是否“可伤害”并通过接口调用TakeDamage方法而不关心Target内部如何实现伤害计算。这遵循了“依赖接口而非实现”的原则极大降低了耦合度。即使未来有新的BossTarget、ShieldedTarget只要实现了IDamageableShooter的代码都无需修改。4. 进阶技巧与性能考量4.1 属性Property的妙用与陷阱属性是C#实现封装的核心语法糖但在Unity中需要特别注意。自动属性Auto-Property的序列化问题public int Health { get; set; } // Unity默认无法序列化自动属性Unity的序列化系统用于在Inspector显示和保存场景/预制体默认不支持自动属性。这意味着Health不会出现在Inspector中其值也不会被保存。解决方案使用支持序列化的后备字段这是最常用、最兼容的方法。[SerializeField] private int _health 100; public int Health { get _health; private set _health Mathf.Clamp(value, 0, MaxHealth); }使用[SerializeField]标记属性Unity 2020.3新版本Unity允许对自动属性的支持字段进行序列化。[field: SerializeField] public int Health { get; private set; } 100;这种写法更简洁但需要注意团队使用的Unity版本是否支持。属性的性能开销属性本质上是一对方法getter/setter。在性能极度敏感的代码路径中如每帧在Update中调用成千上万次的循环直接访问字段会比通过属性访问略快。但对于绝大多数游戏逻辑这点开销可以忽略不计。可读性、安全性和封装性带来的好处远大于微小的性能损失。切勿因小失大过早优化。4.2readonly、const与static的配合使用readonly用于字段表示该字段只能在声明时或构造函数中被赋值之后即为只读。适用于在对象生命周期内不变但又需要在运行时计算或通过参数传入的常量。例如一个根据难度生成的敌人的初始血量。public class Enemy { private readonly int _initialHealth; public Enemy(int baseHealth) { _initialHealth baseHealth * 2; } // _initialHealth 之后不能再被修改 }const编译时常量必须在声明时赋值且值必须在编译时确定基本类型、字符串。适用于真正的数学常量如Mathf.PI。private const float Gravity 9.81f;static属于类型本身而非类型的实例。与readonly组合可以创建类级别的常量。public static class GameConstants { public static readonly Vector3 SpawnPoint new Vector3(0, 10, 0); public const int MaxPlayerCount 4; }4.3 何时可以打破封装——实用主义考量封装是原则不是教条。在以下特定情况下出于性能或便利性考虑可以适当放宽极简数据结构Data Container如果一个类仅用于存储一组相关的数据没有任何行为方法且数据字段间没有复杂的约束关系例如public struct PlayerSaveData { public string PlayerName; public int Level; public Vector3 LastPosition; // 没有方法只有数据 }这种情况下使用公共字段是可以接受的。在Unity中Vector3、Color等内置结构体就是例子。Unity事件回调UnityEventUnityEvent在Inspector中需要公开方法或字段来添加监听器。通常用于回调的方法本身可以是public的即使它只在内部被调用。这是为了配合编辑器的工作流。public UnityEvent OnPlayerDeath; public void PublicMethodForEvent() { /* 内部逻辑 */ }编辑器扩展脚本专门用于Editor文件夹下的工具脚本其公开字段主要是为了配置工具参数不参与运行时游戏逻辑可以更自由地使用public。核心准则每一次决定使用public字段时都要问自己“未来这个字段被任意代码在任何地方修改会不会导致问题我是否愿意在项目后期花几个小时去追踪一个因它而起的诡异Bug”如果答案是否定的那就请使用属性或方法进行封装。5. 常见问题与排查技巧实录在实际开发中即使理解了原则还是会遇到各种具体问题。下面是我总结的一些高频“坑点”和解决思路。5.1 Inspector中字段不显示或显示异常问题现象可能原因解决方案public字段不显示字段类型不被Unity序列化系统支持如某些自定义类未标记[System.Serializable]。1. 为自定义类添加[System.Serializable]属性。2. 如果不需要在Inspector编辑可忽略。[SerializeField] private字段不显示1. 字段是静态的static。2. 字段名以特殊前缀开头旧版本Unity有bug。3. 脚本编译错误。1. 静态字段属于类不属于实例本就不应序列化到每个对象。考虑改用ScriptableObject存储配置。2. 避免使用m_、k_等前缀虽然现在问题不大。3. 检查Console窗口修复所有编译错误。字段显示为灰色不可编辑字段被标记为readonly或者其setter是私有的对于属性。这是预期行为。如果需要在Inspector中初始化readonly字段可以在Awake()或Start()中通过[SerializeField]的另一个字段来赋值。数组/列表在Inspector中展开后内容丢失常见的Unity序列化Bug尤其在修改脚本或撤销操作时。1.勤备份场景和预制体。2. 避免在运行时通过代码修改[SerializeField]的列表/数组的结构如Add/Remove这容易破坏序列化。如需动态集合考虑使用ListT但不序列化在Awake中初始化。3. 使用[NonSerialized]或System.NonSerialized属性标记不需要序列化的字段。5.2 空引用异常NullReferenceException与封装空引用异常是Unity开发者的“头号公敌”。良好的封装能有效减少和定位这类问题。问题场景一个public的GameObject引用在Inspector中忘记拖拽赋值运行时其他脚本访问它就会抛出空引用异常。封装改进方案使用[SerializeField]并添加初始值[SerializeField] private GameObject projectilePrefab null;至少能在代码层面看到它可能为null。在访问前进行检查防御性编程public void Fire() { if (projectilePrefab null) { Debug.LogError(${gameObject.name}: Projectile prefab is not assigned!, this); return; // 或使用一个默认预制体 } Instantiate(projectilePrefab, transform.position, transform.rotation); }使用RequiredComponent属性对于当前GameObject上的组件[RequireComponent(typeof(Rigidbody))] public class MovingObject : MonoBehaviour { private Rigidbody _rb; private void Awake() { _rb GetComponentRigidbody(); // 确保能获取到 } }惰性初始化Lazy Initialization对于非必须的组件或引用在第一次使用时再尝试获取。private AudioSource _audioSource; private AudioSource MyAudioSource { get { if (_audioSource null) { _audioSource GetComponentAudioSource(); if (_audioSource null) { _audioSource gameObject.AddComponentAudioSource(); } } return _audioSource; } } // 使用时直接调用 MyAudioSource.Play();5.3 循环依赖与设计模式当两个类互相持有对方的public引用并通过这些引用频繁调用对方的方法时就形成了紧耦合的循环依赖。这会使代码难以理解、测试和修改。症状类A调用类B的方法类B的方法内部又调用了类A的方法如此循环。修改其中一个类常常需要同时修改另一个。解耦策略引入接口如上文IDamageable的例子让双方依赖于抽象接口而非具体实现。使用事件Event或消息系统类A完成某事后发布一个事件。类B订阅这个事件并在事件触发时执行自己的逻辑。两者不需要直接引用对方。Unity自带的UnityEvent或C#的event Action都是简单易用的工具。对于大型项目可以考虑消息总线Message Bus或观察者模式Observer Pattern的框架。依赖注入Dependency Injection通过构造函数、属性或方法参数将依赖项从外部“注入”到类中而不是在类内部直接new或FindObjectOfType。这提升了代码的可测试性和灵活性。在Unity中可以手动管理或使用像Zenject现称Extenject、StrangeIoC这样的DI框架。一个简单的事件解耦示例// 事件发布者 public class PlayerHealth : MonoBehaviour { public event Action OnPlayerDied; private void Die() { // ... 死亡逻辑 OnPlayerDied?.Invoke(); // 通知所有订阅者 } } // 事件订阅者 public class GameOverUI : MonoBehaviour { [SerializeField] private PlayerHealth playerHealth; private void OnEnable() { if (playerHealth ! null) playerHealth.OnPlayerDied ShowGameOverScreen; } private void OnDisable() { if (playerHealth ! null) playerHealth.OnPlayerDied - ShowGameOverScreen; } private void ShowGameOverScreen() { /* 显示UI */ } }这样PlayerHealth完全不知道GameOverUI的存在它只负责发布事件。任何关心玩家死亡的对象如音效管理器、成就系统都可以独立订阅这个事件彼此不再直接依赖。6. 代码重构实战将一个“Public混乱”的脚本改造成型让我们看一个真实的简单例子并一步步重构它。假设我们有一个最初由新手编写的Enemy脚本原始版本问题重重public class BadEnemy : MonoBehaviour { public int health 50; public float speed 3.0f; public GameObject player; public AudioClip deathSound; private AudioSource audioSource; void Start() { audioSource GetComponentAudioSource(); if (player null) // 试图补救空引用 player GameObject.FindGameObjectWithTag(Player); } void Update() { // 直接修改transform没有封装 transform.position Vector3.MoveTowards(transform.position, player.transform.position, speed * Time.deltaTime); if (health 0) { if (deathSound ! null) audioSource.PlayOneShot(deathSound); Destroy(gameObject, 0.5f); // 直接销毁没有通知 } } // 被攻击时其他脚本直接修改 health // void OnCollisionEnter(Collision other) { ... } }重构步骤与思考第一步识别并私有化内部字段health、speed、deathSound都是敌人的内部属性不应该被外部直接修改。player引用是为了寻路也是内部逻辑所需。public class BetterEnemy : MonoBehaviour { [SerializeField] private int maxHealth 50; [SerializeField] private float moveSpeed 3.0f; [SerializeField] private AudioClip deathSound; private int currentHealth; private Transform playerTransform; private AudioSource audioSource;第二步使用属性控制访问并提供初始化方法public int CurrentHealth currentHealth; // 只读属性 public bool IsDead currentHealth 0; private void Awake() { audioSource GetComponentAudioSource(); currentHealth maxHealth; // 初始化 } private void Start() { GameObject playerObj GameObject.FindGameObjectWithTag(Player); if (playerObj ! null) playerTransform playerObj.transform; else Debug.LogWarning(Player not found for enemy targeting., this); }第三步将核心逻辑封装成方法将移动和死亡逻辑从Update中抽离使其更清晰并便于控制和扩展。private void Update() { if (!IsDead playerTransform ! null) { MoveTowardsPlayer(); } } private void MoveTowardsPlayer() { Vector3 direction (playerTransform.position - transform.position).normalized; // 可以考虑加入NavMeshAgent等更复杂的移动逻辑 transform.Translate(direction * moveSpeed * Time.deltaTime, Space.World); } public void TakeDamage(int damageAmount) { if (IsDead) return; // 已死亡不再受到伤害 currentHealth - damageAmount; currentHealth Mathf.Max(currentHealth, 0); // 确保不小于0 // 可以在这里触发受伤动画、音效、UI飘字等 Debug.Log(${gameObject.name} took {damageAmount} damage. Health: {currentHealth}); if (IsDead) { Die(); } } private void Die() { // 集中处理所有死亡相关逻辑 if (deathSound ! null audioSource ! null) audioSource.PlayOneShot(deathSound); // 通知其他系统如分数管理器、生成器 // GameManager.Instance?.OnEnemyDied(this); // 禁用碰撞体、渲染器等而不是立即销毁以便死亡动画播放完 GetComponentCollider().enabled false; // GetComponentRenderer().enabled false; // 或播放死亡动画 Destroy(gameObject, 2f); // 延迟销毁 }第四步考虑使用事件进行解耦如果死亡需要通知多个系统分数、任务、UI可以定义事件。// 在类内部定义 public event ActionBetterEnemy OnEnemyDeath; private void Die() { OnEnemyDeath?.Invoke(this); // ... 其他死亡逻辑 }最终重构版本的优势状态安全外部只能通过TakeDamage方法改变血量确保了伤害计算的唯一入口。Inspector整洁只有maxHealth、moveSpeed、deathSound这几个设计参数可见且可配置。逻辑清晰移动、受伤、死亡逻辑被封装在独立的方法中Update函数简洁明了。易于扩展要添加“受伤无敌时间”、“移动速度随血量变化”等功能只需在对应方法内修改不会影响外部调用。便于测试可以单独测试TakeDamage和Die方法的行为。这个重构过程体现了从“面向过程编程”到“面向对象设计”的思维转变。一开始的代码只关心“怎么把功能做出来”而重构后的代码更关心“如何组织代码使其更健壮、更易维护”。这正是一个Unity开发者从新手走向资深必须跨越的门槛。记住写出能运行的代码只是第一步写出能经得起时间考验的代码才是我们的目标。