Unity C#编程避坑指南:别再乱用public和private了,聊聊封装与访问修饰符的正确姿势

张开发
2026/4/20 11:05:41 15 分钟阅读

分享文章

Unity C#编程避坑指南:别再乱用public和private了,聊聊封装与访问修饰符的正确姿势
Unity C#编程避坑指南别再乱用public和private了聊聊封装与访问修饰符的正确姿势在Unity开发中我们经常看到这样的场景一个脚本里充斥着public字段Inspector面板被各种可调节参数挤满或者相反所有成员都标记为private导致其他脚本无法获取必要数据。这两种极端都会带来维护噩梦——前者让代码像蜘蛛网一样纠缠不清后者则迫使开发者频繁修改访问权限来临时解决问题。1. 为什么你的Unity代码变成了意大利面条上周review团队项目时我发现一个典型案例某个玩家控制脚本里声明了12个public变量其中8个其实只被类内部方法使用。问起原因作者坦言刚开始不确定哪些变量需要暴露给其他脚本索性全部public反正Unity能显示在Inspector里很方便...1.1 public滥用的三大恶果耦合度爆炸当5个不同脚本直接修改同一个PlayerController的public变量时任何改动都可能引发连锁崩溃调试地狱无法追踪是谁在什么时候修改了public字段的值架构腐蚀随着项目迭代这种代码会像雪球一样越来越难以重构// 反面教材 - 典型的public滥用 public class Player : MonoBehaviour { public float health; public int ammo; public GameObject weaponModel; public Animator animator; // ...还有8个类似的public字段 }1.2 private过度封装的陷阱另一个极端是过度保护public class Inventory : MonoBehaviour { private ListItem items new ListItem(); // 外部想获取物品数量只能添加这样的方法 public int GetItemCount() { return items.Count; } }这种写法虽然符合封装原则但在Unity中可能导致需要为每个简单查询编写样板代码序列化困难private字段默认不显示在Inspector子类无法扩展功能2. Unity中的访问修饰符实战策略2.1 黄金法则最小可见性原则在Unity中应用封装时我遵循这样的决策流程是否需要Inspector可见 → 是 → 使用[SerializeField] private ↓ 否 ↓ 是否会被其他脚本调用 → 是 → 使用internal或protected ↓ 否 ↓ 是否会被子类继承 → 是 → 使用protected ↓ 否 ↓ 使用private2.2 被低估的internal修饰符在Unity项目中internal是最被低估的访问修饰符。它允许同一Assembly-CSharp.dll内的所有脚本访问完美适用于模块内部协作避免public的全局暴露单元测试友好// 在Player模块内部 internal class PlayerState { internal bool IsGrounded { get; private set; } } // 只能在同程序集的MovementController中访问 public class MovementController : MonoBehaviour { void Update() { var state GetComponentPlayerState(); if (state.IsGrounded) { // 处理地面移动 } } }2.3 [SerializeField]的妙用Unity特有的SerializeField属性打破了private字段不显示在Inspector的规则public class Enemy : MonoBehaviour { [SerializeField] private float _attackRange 2f; [SerializeField] private LayerMask _playerLayer; private void Update() { // 使用_attackRange进行检测 } }这样做的好处保持字段的private访问权限允许设计人员调整参数避免意外被其他脚本修改3. 高级封装技巧属性与扩展方法3.1 属性包装器的威力C#属性是封装字段的最佳实践public class HealthSystem : MonoBehaviour { private float _currentHealth; public float CurrentHealth { get _currentHealth; private set { _currentHealth Mathf.Clamp(value, 0, MaxHealth); OnHealthChanged?.Invoke(_currentHealth); } } public float MaxHealth { get; private set; } 100f; public event Actionfloat OnHealthChanged; }这种模式实现了对赋值的控制自动钳制到0-MaxHealth变更通知通过事件只读外部访问MaxHealth3.2 扩展方法的封装优势当需要为现有类型添加功能但无法修改源码时扩展方法是不二之选public static class TransformExtensions { public static void ResetLocal(this Transform t) { t.localPosition Vector3.zero; t.localRotation Quaternion.identity; t.localScale Vector3.one; } } // 使用方式 transform.ResetLocal();相比继承或工具类扩展方法保持调用语法自然不会引入新的派生类型可以像实例方法一样被调用4. 实战重构一个真实的Unity脚本让我们看一个常见的玩家移动脚本并逐步优化其封装4.1 原始版本问题重重public class PlayerMovement : MonoBehaviour { public float speed 5f; public float jumpForce 10f; public Rigidbody rb; public bool isGrounded; void Update() { float move Input.GetAxis(Horizontal); rb.velocity new Vector3(move * speed, rb.velocity.y, 0); if (Input.GetKeyDown(KeyCode.Space) isGrounded) { rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); } } void OnCollisionEnter(Collision col) { if (col.gameObject.CompareTag(Ground)) { isGrounded true; } } void OnCollisionExit(Collision col) { if (col.gameObject.CompareTag(Ground)) { isGrounded false; } } }4.2 重构后的版本[RequireComponent(typeof(Rigidbody))] public class PlayerMovement : MonoBehaviour { [SerializeField] private float _moveSpeed 5f; [SerializeField] private float _jumpForce 10f; [SerializeField] private LayerMask _groundLayer; private Rigidbody _rb; private bool _isGrounded; public float CurrentSpeed _rb.velocity.magnitude; private void Awake() { _rb GetComponentRigidbody(); } private void Update() { HandleMovement(); HandleJump(); } private void HandleMovement() { float moveInput Input.GetAxis(Horizontal); _rb.velocity new Vector3(moveInput * _moveSpeed, _rb.velocity.y, 0); } private void HandleJump() { if (Input.GetKeyDown(KeyCode.Space) _isGrounded) { _rb.AddForce(Vector3.up * _jumpForce, ForceMode.Impulse); } } private void OnCollisionStay(Collision col) { _isGrounded col.gameObject.IsInLayerMask(_groundLayer); } private void OnCollisionExit(Collision col) { if (col.gameObject.IsInLayerMask(_groundLayer)) { _isGrounded false; } } } public static class GameObjectExtensions { public static bool IsInLayerMask(this GameObject go, LayerMask mask) { return mask (mask | (1 go.layer)); } }重构亮点所有字段改为[SerializeField] private添加了只读属性CurrentSpeed将逻辑拆分为独立方法使用扩展方法简化层检测移除不必要的public暴露5. 特殊场景下的修饰符选择5.1 MonoBehaviour继承链中的protected当创建可继承的基类时protected是最佳选择public abstract class BaseEnemy : MonoBehaviour { protected float _currentHealth; protected virtual void TakeDamage(float amount) { _currentHealth - amount; if (_currentHealth 0) { Die(); } } protected abstract void Die(); } public class ZombieEnemy : BaseEnemy { protected override void Die() { // 僵尸特定的死亡逻辑 } }5.2 ScriptableObject中的封装对于ScriptableObject数据容器推荐模式[CreateAssetMenu] public class WeaponConfig : ScriptableObject { [SerializeField] private float _damage 10f; [SerializeField] private float _cooldown 0.5f; public float Damage _damage; public float Cooldown _cooldown; // 允许有限修改 public void UpgradeDamage(float multiplier) { _damage * multiplier; } }这种设计保护核心数据不被随意修改提供受控的修改接口保持Inspector的可配置性6. 性能与封装的平衡6.1 属性访问的开销在性能敏感的代码中如每帧执行的Update需要注意// 低效写法 public float Health { get { Debug.Log(Health accessed); // 调试代码 return _health; } } // 优化建议 public float Health _health; // 表达式体属性提示在移动设备或VR项目中应避免在属性getter中执行复杂逻辑6.2 缓存组件引用Unity的GetComponent调用相对昂贵好的封装应该兼顾性能public class OptimizedEnemy : MonoBehaviour { private Animator _animator; private Collider _collider; private void Awake() { _animator GetComponentAnimator(); _collider GetComponentCollider(); } public void PlayDeathAnimation() { _animator.SetTrigger(Die); } public void DisableCollider() { _collider.enabled false; } }这种模式在初始化时获取引用对外提供简洁的方法避免重复调用GetComponent

更多文章