Appearance
单例模式有什么优缺点?
一句话理解: 单例模式就是:某个类在全局只允许有一个对象,并且大家都通过同一个入口访问它。它的优点是方便统一管理,缺点是容易变成“全局变量仓库”,让代码耦合变高。
单例模式是什么
单例模式,英文叫 Singleton Pattern。
它解决的问题是:
这个类的对象,全局只应该有一个。比如游戏里常见:
音频管理器 AudioManager
配置管理器 ConfigManager
存档管理器 SaveManager
网络管理器 NetworkManager
事件中心 EventManager
SDK 管理器 SDKManager这些系统通常不希望到处 new 出很多份。
普通写法的问题
假设你有一个音频管理器:
c
AudioManager audio1 = new AudioManager();
AudioManager audio2 = new AudioManager();
AudioManager audio3 = new AudioManager();如果出现多个音频管理器,就可能出问题:
一个在播放 BGM
一个在控制音量
一个在加载音效
状态混乱单例就是为了避免这种情况。
C# 普通单例写法
c
public class AudioManager
{
// 保存唯一实例
private static AudioManager instance;
// 对外提供访问入口
public static AudioManager Instance
{
get
{
// 第一次访问时才创建
if (instance == null)
{
instance = new AudioManager();
}
return instance;
}
}
// 构造函数私有,外部不能随便 new
private AudioManager()
{
}
public void PlaySound(string soundName)
{
// 播放音效逻辑
}
}使用:
c
AudioManager.Instance.PlaySound("Click");这样外部不能 new AudioManager(),只能通过 Instance 拿到同一个对象。
更推荐的线程安全写法
普通单例在多线程环境下可能有线程安全问题。 C# 里可以用 Lazy<T>:
c
using System;
public class ConfigManager
{
// Lazy<T> 会保证延迟初始化,并且默认是线程安全的
private static readonly Lazy<ConfigManager> lazyInstance =
new Lazy<ConfigManager>(() => new ConfigManager());
public static ConfigManager Instance
{
get
{
return lazyInstance.Value;
}
}
private ConfigManager()
{
// 加载配置
}
public string GetConfig(string key)
{
// 根据 key 返回配置
return "value";
}
}这个写法面试里很加分,因为它说明你知道:
延迟创建
线程安全
构造函数私有
全局唯一入口Unity 里的 MonoBehaviour 单例
Unity 里很多管理器需要挂在 GameObject 上,所以不能直接 new MonoBehaviour。
常见写法:
c
using UnityEngine;
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
private void Awake()
{
// 如果还没有实例,就把自己设为唯一实例
if (Instance == null)
{
Instance = this;
// 切换场景时不销毁这个对象
DontDestroyOnLoad(gameObject);
}
else
{
// 如果已经有一个实例,说明这是重复创建的对象
Destroy(gameObject);
}
}
public void PlaySound(string soundName)
{
// 播放音效
}
}这个逻辑很重要:
第一个 AudioManager 保留
后面重复出现的 AudioManager 销毁否则切场景时可能产生多个 Manager。
优点
单例模式的优点:
1. 保证全局唯一
2. 访问方便
3. 集中管理共享资源
4. 避免重复创建和重复初始化
5. 适合全局服务类对象比如音频管理器:
全局只有一个音量设置
全局只有一个 BGM 播放状态
全局只有一个音频资源缓存这时候单例就比较合理。
缺点
单例的缺点也很明显:
1. 隐藏依赖
2. 代码耦合高
3. 单元测试困难
4. 生命周期不好控制
5. 多线程下要考虑安全
6. 滥用后会变成全局变量仓库比如这段代码:
c
public class Player
{
public void Die()
{
AudioManager.Instance.PlaySound("Die");
UIManager.Instance.ShowGameOver();
SaveManager.Instance.Save();
AnalyticsManager.Instance.Report("PlayerDie");
}
}表面看很方便,但 Player 实际依赖了很多全局系统。 以后想测试 Player,或者替换音频系统、UI 系统,就会比较麻烦。
Unity 里单例常见坑
1. 场景切换后重复生成
2. DontDestroyOnLoad 让旧对象一直残留
3. 初始化顺序不稳定
4. A 单例 Awake 里访问 B 单例,但 B 还没初始化
5. 静态变量退出 Play Mode 后可能残留
6. 单例里引用场景对象,切场景后引用失效尤其是这个坑很常见:
c
private void Awake()
{
UIManager.Instance.OpenPanel("Main");
}如果 UIManager 还没初始化,就会空引用。
所以 Unity 项目里更稳的做法是:
核心 Manager 在启动场景统一创建
初始化顺序由 GameRoot 或 Launcher 控制
不要在 Awake 里乱访问其他单例
场景对象不要随便被全局单例长期持有什么时候适合用单例
适合:
全局确实只需要一个实例
它更像服务,不像普通业务对象
生命周期接近整个游戏
状态需要统一管理
创建成本比较高比如:
c
AudioManager
SaveManager
ConfigManager
NetworkManager
SDKManager
LocalizationManager什么时候不适合用单例
不适合:
角色
怪物
子弹
普通 UI Item
普通业务数据
需要多个实例的系统
需要方便替换和测试的模块如果一个类未来可能有多个实例,就不要硬做单例。
单例和静态类有什么区别
静态类:
只有静态成员,不能继承,不能实现接口,不适合需要生命周期的对象。
单例:
本质还是对象,可以有构造过程,可以实现接口,可以继承,可以被延迟创建。简单说:
工具函数适合 static class。
全局服务对象适合 Singleton。面试高分回答
NOTE
单例模式用于保证一个类在全局只有一个实例,并提供统一访问入口。
它的优点是可以统一管理全局资源,避免重复创建,访问也比较方便,比如音频管理器、配置管理器、存档管理器、网络管理器。
缺点是它容易造成隐藏依赖和高耦合,代码里到处直接访问 Instance,会让模块之间关系不清晰,也会让单元测试和替换实现变困难。
在 Unity 里还要特别注意生命周期问题,比如 DontDestroyOnLoad 导致重复实例、场景切换后引用失效、Awake 初始化顺序不稳定。
所以我不会为了访问方便就滥用单例。只有当这个对象业务上确实应该全局唯一,并且更像一个全局服务时,我才会考虑用单例。
最短记忆版
单例 = 全局唯一实例 + 统一访问入口。
优点:唯一、方便、集中管理。
缺点:耦合高、测试难、生命周期容易出坑。
Unity 中要防重复实例、防初始化顺序问题。事件系统怎么设计?
一句话理解: 事件系统就是“发布订阅”:一个系统发生了事情,不直接调用别的系统,而是把事件发出去;谁关心这个事件,谁自己订阅并响应。
为什么需要事件系统
比如玩家受伤后,可能有很多系统要响应:
血条 UI 要刷新
音效系统要播放受伤音效
任务系统要统计受伤次数
成就系统要检查条件
战斗日志要记录信息如果玩家类直接调用这些系统:
ui.RefreshHp();
audio.PlayHit();
quest.Update();
achievement.Check();
log.Write();代码会越来越耦合。 玩家类知道太多系统,后面很难维护。
事件系统的做法是:
玩家只负责发出 PlayerDamagedEvent
其他系统自己监听这个事件发布者不关心谁接收,接收者也不需要被发布者直接引用。
基础结构
一个事件系统通常包含四个部分:
Event:
事件数据,比如玩家受伤事件、金币变化事件。
Publisher:
发布者,负责发事件,比如 Player、Inventory、BattleSystem。
EventBus:
事件中心,负责保存订阅关系和分发事件。
Subscriber:
订阅者,负责监听和处理事件,比如 UI、Audio、Quest。一个类型安全的事件系统示例
比起字符串事件名,我更推荐泛型事件。 字符串容易写错,编译器发现不了。
using System;
using System.Collections.Generic;
public static class EventBus
{
// 按事件类型保存监听函数
private static readonly Dictionary<Type, Delegate> eventTable =
new Dictionary<Type, Delegate>();
public static void Subscribe<T>(Action<T> handler)
{
Type eventType = typeof(T);
if (eventTable.TryGetValue(eventType, out Delegate existingHandler))
{
// 已经有监听者时,把新的监听函数追加进去
eventTable[eventType] = Delegate.Combine(existingHandler, handler);
}
else
{
// 第一次订阅这个事件类型
eventTable[eventType] = handler;
}
}
public static void Unsubscribe<T>(Action<T> handler)
{
Type eventType = typeof(T);
if (!eventTable.TryGetValue(eventType, out Delegate existingHandler))
{
return;
}
// 从委托链里移除指定监听函数
Delegate newHandler = Delegate.Remove(existingHandler, handler);
if (newHandler == null)
{
// 没有监听者了,就移除这个事件类型
eventTable.Remove(eventType);
}
else
{
eventTable[eventType] = newHandler;
}
}
public static void Publish<T>(T eventData)
{
Type eventType = typeof(T);
if (eventTable.TryGetValue(eventType, out Delegate handler))
{
// 转回对应事件类型的 Action<T> 再调用
Action<T> callback = handler as Action<T>;
callback?.Invoke(eventData);
}
}
public static void Clear()
{
// 切场景或退出战斗时,可以清理临时事件
eventTable.Clear();
}
}定义事件数据
事件数据建议单独定义清楚,不要直接传一堆散乱参数。
public struct PlayerDamagedEvent
{
public int PlayerId;
public int Damage;
public int CurrentHp;
public int MaxHp;
public PlayerDamagedEvent(int playerId, int damage, int currentHp, int maxHp)
{
PlayerId = playerId;
Damage = damage;
CurrentHp = currentHp;
MaxHp = maxHp;
}
}这里用 struct 可以减少小事件对象的堆分配。 但如果事件数据很复杂,或者需要引用语义,也可以用 class。
发布事件
玩家受伤时,只发事件,不直接控制 UI、音效、任务系统。
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
public int playerId = 1;
public int maxHp = 100;
public int currentHp = 100;
public void TakeDamage(int damage)
{
currentHp -= damage;
currentHp = Mathf.Max(currentHp, 0);
// 只发布事件,不直接调用 UI、音效、任务系统
EventBus.Publish(new PlayerDamagedEvent(
playerId,
damage,
currentHp,
maxHp
));
}
}这样 PlayerHealth 就很干净。 它只关心自己的血量变化,不关心谁要响应。
订阅事件
UI 系统监听这个事件:
using UnityEngine;
using UnityEngine.UI;
public class HpView : MonoBehaviour
{
public Slider hpSlider;
private void OnEnable()
{
// UI 启用时订阅事件
EventBus.Subscribe<PlayerDamagedEvent>(OnPlayerDamaged);
}
private void OnDisable()
{
// UI 禁用时取消订阅,避免内存泄漏和重复响应
EventBus.Unsubscribe<PlayerDamagedEvent>(OnPlayerDamaged);
}
private void OnPlayerDamaged(PlayerDamagedEvent eventData)
{
float hpPercent = (float)eventData.CurrentHp / eventData.MaxHp;
hpSlider.value = hpPercent;
}
}音效系统也可以监听同一个事件:
using UnityEngine;
public class BattleAudio : MonoBehaviour
{
private void OnEnable()
{
EventBus.Subscribe<PlayerDamagedEvent>(OnPlayerDamaged);
}
private void OnDisable()
{
EventBus.Unsubscribe<PlayerDamagedEvent>(OnPlayerDamaged);
}
private void OnPlayerDamaged(PlayerDamagedEvent eventData)
{
// 玩家受伤时播放音效
Debug.Log($"播放受伤音效,伤害值:{eventData.Damage}");
}
}这就是事件系统的好处: 玩家发一个事件,UI、音效、任务、成就都能各自响应,但它们互相不强依赖。
设计时要注意生命周期
Unity 里最常见的事件坑就是忘记取消订阅。
推荐:
OnEnable 订阅
OnDisable 取消订阅不要只订阅不取消,否则可能出现:
对象销毁后还被事件调用
切场景后旧对象残留
同一个 UI 重复订阅多次
一个事件触发多次响应
内存泄漏尤其是静态事件中心,因为它活得很久,更容易持有已经销毁的对象引用。
事件系统要不要支持异常隔离
如果一个监听者报错,不应该影响后面的监听者。 更稳的事件分发可以逐个调用并捕获异常。
public static void PublishSafe<T>(T eventData)
{
Type eventType = typeof(T);
if (!eventTable.TryGetValue(eventType, out Delegate handler))
{
return;
}
Delegate[] callbacks = handler.GetInvocationList();
foreach (Delegate callback in callbacks)
{
try
{
Action<T> action = callback as Action<T>;
action?.Invoke(eventData);
}
catch (Exception exception)
{
// 一个监听者出错,不影响后面的监听者
UnityEngine.Debug.LogError(
$"事件处理异常:{eventType.Name}, {exception}"
);
}
}
}面试里提到这点很加分: 事件系统属于基础设施,不能因为一个监听函数异常,就让整个事件分发中断。
事件系统不要滥用
事件系统适合:
跨模块通知
一对多广播
发布者不需要知道接收者
例如血量变化、金币变化、任务完成、语言切换不适合:
明确的一对一流程调用
需要返回值的调用
强顺序业务流程
非常高频的每帧通信比如移动逻辑里每帧发:
EventBus.Publish(new PlayerMoveEvent(...));如果频率很高,监听者很多,就可能变成性能问题。 高频逻辑更适合直接引用、缓存组件、或者专门的数据同步结构。
常见设计增强
项目复杂后,可以加这些能力:
事件日志:记录谁发了什么事件
订阅数量统计:排查重复订阅
一次性事件:触发一次后自动取消
事件优先级:控制监听者执行顺序
延迟分发:下一帧再处理,避免递归触发
场景事件组:切场景时批量清理
弱引用事件:降低忘记取消订阅的泄漏风险但初学者要记住: 不要一开始就设计得特别复杂,先保证清晰、类型安全、能取消订阅、能调试。
面试高分回答
可以这样答:
我会把事件系统设计成发布订阅模型。
事件数据用明确的类型表示,比如 PlayerDamagedEvent,而不是字符串事件名加 object 参数,这样更类型安全,编译期就能发现错误。
EventBus 内部用 Dictionary<Type, Delegate> 或 Dictionary<Type, List<Delegate>> 保存事件类型和监听函数的关系,提供 Subscribe、Unsubscribe、Publish 三个核心接口。
在 Unity 中,我会要求监听者在 OnEnable 订阅,在 OnDisable 取消订阅,避免对象销毁后还被静态事件中心持有,造成内存泄漏或重复响应。
发布者只负责发布事件,不直接依赖 UI、音效、任务等系统,这样可以降低模块耦合。
另外我会给事件系统加一些调试能力,比如记录事件名、订阅数量、异常隔离,防止某个监听者异常导致后续监听者不执行。
但事件系统不能滥用。明确的一对一调用、需要返回值的流程、高频每帧逻辑,不一定适合走事件总线。最短记忆版
事件系统 = 发布者 Publish + 事件中心 EventBus + 订阅者 Subscribe。
优点是解耦和一对多通知。
重点是类型安全、取消订阅、防泄漏、异常隔离、可调试。状态机怎么设计?
一句话理解: 状态机就是把一个对象的行为拆成多个“状态”,同一时间只处于一个状态。比如角色可以是 Idle、Move、Attack、Dead,每个状态负责自己的逻辑,状态机负责切换。
为什么需要状态机
如果不用状态机,角色逻辑很容易写成这样:
void Update()
{
if (isDead)
{
// 死亡逻辑
}
else if (isAttacking)
{
// 攻击逻辑
}
else if (isMoving)
{
// 移动逻辑
}
else
{
// 待机逻辑
}
}一开始还行,后面加上:
跳跃
受击
眩晕
冲刺
释放技能
击退
死亡复活
动画打断代码就会变成一大堆 if-else,很难维护。
状态机的目的就是: 把不同状态拆开,让每个状态只管自己。
状态机一般包含什么
一个基础状态机通常有四个部分:
State:
状态,比如 IdleState、MoveState、AttackState。
StateMachine:
状态机,保存当前状态,负责切换。
Enter:
进入状态时执行一次。
Tick / Update:
处于这个状态时持续执行。
Exit:
离开状态时执行一次。比如:
进入攻击状态:播放攻击动画
攻击状态中:检测攻击判定
退出攻击状态:关闭攻击碰撞盒先定义状态接口
public interface IState
{
// 进入状态时调用一次
void Enter();
// 当前状态每帧更新
void Tick();
// 离开状态时调用一次
void Exit();
}这个接口很重要。 所有状态都遵守同一套规则,状态机就能统一管理它们。
实现状态机
public class StateMachine
{
// 当前正在运行的状态
private IState currentState;
public void ChangeState(IState newState)
{
// 如果已经有旧状态,先退出旧状态
if (currentState != null)
{
currentState.Exit();
}
// 切换到新状态
currentState = newState;
// 进入新状态
if (currentState != null)
{
currentState.Enter();
}
}
public void Tick()
{
// 每帧只更新当前状态
if (currentState != null)
{
currentState.Tick();
}
}
}这就是状态机的核心流程:
旧状态 Exit
新状态 Enter
之后每帧 Tick 当前状态角色控制器使用状态机
using UnityEngine;
public class PlayerController : MonoBehaviour
{
public Animator animator;
private StateMachine stateMachine;
public IdleState IdleState { get; private set; }
public MoveState MoveState { get; private set; }
public AttackState AttackState { get; private set; }
private void Awake()
{
stateMachine = new StateMachine();
// 创建各个状态,并把角色自己传进去
IdleState = new IdleState(this);
MoveState = new MoveState(this);
AttackState = new AttackState(this);
}
private void Start()
{
// 默认进入待机状态
stateMachine.ChangeState(IdleState);
}
private void Update()
{
// 每帧更新当前状态
stateMachine.Tick();
}
public void ChangeState(IState state)
{
stateMachine.ChangeState(state);
}
}待机状态
using UnityEngine;
public class IdleState : IState
{
private readonly PlayerController player;
public IdleState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
// 进入待机状态时播放待机动画
player.animator.Play("Idle");
}
public void Tick()
{
// 有移动输入时,切换到移动状态
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
if (horizontal != 0 || vertical != 0)
{
player.ChangeState(player.MoveState);
return;
}
// 按下攻击键时,切换到攻击状态
if (Input.GetMouseButtonDown(0))
{
player.ChangeState(player.AttackState);
}
}
public void Exit()
{
// 离开待机状态时,通常不需要特殊清理
}
}移动状态
using UnityEngine;
public class MoveState : IState
{
private readonly PlayerController player;
public MoveState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
// 进入移动状态时播放移动动画
player.animator.Play("Move");
}
public void Tick()
{
float horizontal = Input.GetAxisRaw("Horizontal");
float vertical = Input.GetAxisRaw("Vertical");
Vector3 input = new Vector3(horizontal, 0, vertical);
// 没有输入时,回到待机状态
if (input.sqrMagnitude <= 0.01f)
{
player.ChangeState(player.IdleState);
return;
}
// 有攻击输入时,切换到攻击状态
if (Input.GetMouseButtonDown(0))
{
player.ChangeState(player.AttackState);
return;
}
// 移动角色
player.transform.position += input.normalized * 5f * Time.deltaTime;
}
public void Exit()
{
// 离开移动状态时,可以停止移动音效等
}
}攻击状态
using UnityEngine;
public class AttackState : IState
{
private readonly PlayerController player;
private float attackTimer;
private const float AttackDuration = 0.6f;
public AttackState(PlayerController player)
{
this.player = player;
}
public void Enter()
{
// 进入攻击状态时重置计时
attackTimer = 0f;
// 播放攻击动画
player.animator.Play("Attack");
// 这里可以打开武器碰撞盒,或者等待动画事件打开
Debug.Log("进入攻击状态");
}
public void Tick()
{
attackTimer += Time.deltaTime;
// 攻击时间结束后,回到待机状态
if (attackTimer >= AttackDuration)
{
player.ChangeState(player.IdleState);
}
}
public void Exit()
{
// 离开攻击状态时关闭攻击判定
Debug.Log("退出攻击状态,关闭攻击判定");
}
}状态切换条件放哪里
有两种常见做法:
1. 状态自己判断什么时候切走
2. 状态机或上层控制器统一判断切换条件简单角色可以让状态自己判断,比如 IdleState 判断是否移动。 复杂项目里,可能会把输入、AI、动画事件、战斗规则拆出去,避免状态类太胖。
状态机适合哪些场景
适合:
角色状态:待机、移动、攻击、受击、死亡
怪物 AI:巡逻、追击、攻击、逃跑、死亡
UI 流程:打开、显示、关闭、过渡
游戏流程:登录、加载、主城、战斗、结算
技能流程:前摇、命中、后摇、冷却只要一个对象在同一时刻只能处于某种行为模式,就很适合状态机。
状态机的优点
1. 消灭大量 if-else
2. 每个状态职责清晰
3. 进入、更新、退出逻辑分明
4. 方便扩展新状态
5. 方便控制状态切换
6. 适合和 Animator、AI、技能系统配合比如新增一个 StunState 眩晕状态,只需要加一个状态类,而不是把所有逻辑塞进一个巨大 Update。
状态机的缺点
1. 状态太多时会状态爆炸
2. 状态之间切换关系复杂时不好维护
3. 简单逻辑用状态机可能显得过度设计
4. 状态类如果互相乱引用,也会变得很乱
5. 复杂 AI 可能需要行为树、GOAP、Utility AI 配合所以状态机不是万能的。 简单逻辑用 if 就够,复杂行为再上状态机。
Unity 里常见坑
1. 切换状态时忘记调用 Exit
2. 攻击状态退出时忘记关闭攻击碰撞盒
3. 状态里直接 new 大量对象,产生 GC
4. 在状态里到处 Find / GetComponent
5. 状态切换条件写得太分散,难排查
6. Animator 状态和代码状态不同步
7. 状态切换递归触发,导致逻辑混乱尤其注意动画同步。 如果攻击真正命中的时机由动画决定,可以用动画事件通知状态,而不是简单用固定时间猜。
面试高分回答
可以这样答:
我设计状态机会先抽象出 IState 接口,包含 Enter、Tick、Exit 三个方法。
每个具体状态只负责自己的逻辑,比如 IdleState 负责待机,MoveState 负责移动,AttackState 负责攻击。
StateMachine 只保存当前状态,并提供 ChangeState 方法。切换时先调用旧状态 Exit,再设置新状态,然后调用新状态 Enter。Update 时只 Tick 当前状态。
这样可以避免大量 if-else,让状态职责更清晰,也方便扩展新状态。
在 Unity 里,我会注意状态切换和 Animator 的同步,比如攻击判定可以通过动画事件打开和关闭;切换状态时也要清理碰撞盒、特效、音效等临时逻辑。
如果 AI 很复杂,我不会只靠普通状态机硬撑,而是可能用分层状态机,或者状态机配合行为树。最短记忆版
状态机 = 当前状态 + 状态切换 + Enter/Tick/Exit。
优点是逻辑清晰,减少 if-else。
缺点是状态太多会复杂。
Unity 中要注意动画同步、Exit 清理和状态切换条件。配置表系统怎么设计?
一句话理解: 配置表系统不是“游戏里读 Excel”,而是:策划在 Excel / Google Sheet 里填数据,编辑器或 CI 工具导出成客户端可读格式,先校验,再生成代码,运行时只读加载,通过 ConfigManager 统一查询。
零基础理解
配置表就是游戏里的“数据说明书”。
比如:
道具表:道具 id、名字、品质、价格
怪物表:怪物 id、血量、攻击力、掉落
技能表:技能 id、伤害、冷却、范围
关卡表:关卡 id、怪物组、奖励代码负责规则,配置表负责数值。 这样策划想把攻击力从 100 改成 120,不需要程序重新写代码。
完整流程应该是这样
策划编辑 Excel / Google Sheet
↓
导出工具读取表格
↓
校验 id、类型、引用、范围
↓
生成 Json / 二进制 / ScriptableObject
↓
生成 C# 配置类
↓
打进首包或热更包
↓
客户端加载
↓
ConfigManager 统一查询重点: 运行时不要直接读 Excel。
因为 Excel 文件大,解析慢,依赖复杂,而且容易把编辑期格式带到运行时。
一个道具配置表例子
表格可以长这样:
id name quality price
1001 小血瓶 1 50
1002 大血瓶 2 200
2001 铁剑 2 500导出后可以变成 Json:
{
"items": [
{
"id": 1001,
"name": "小血瓶",
"quality": 1,
"price": 50
},
{
"id": 1002,
"name": "大血瓶",
"quality": 2,
"price": 200
}
]
}配置数据类
实际项目里这些类通常由工具自动生成。 这里先手写一个,方便你理解:
using System;
using System.Collections.Generic;
[Serializable]
public class ItemConfig
{
// 配置唯一 id,通常作为主键
public int id;
// 道具名字
public string name;
// 品质,例如 1 白、2 绿、3 蓝、4 紫
public int quality;
// 出售价格
public int price;
}
[Serializable]
public class ItemConfigList
{
// Unity JsonUtility 不支持直接解析顶层数组,所以外面包一层
public List<ItemConfig> items;
}运行时加载后转成 Dictionary
配置表运行时最常见的查询方式是按 id 查。 所以加载后不要每次遍历 List,而是转成字典。
using System.Collections.Generic;
using UnityEngine;
public class ItemConfigTable
{
// 用字典保存 id 到配置的映射,查询速度快
private readonly Dictionary<int, ItemConfig> itemDict =
new Dictionary<int, ItemConfig>();
public void LoadFromJson(string json)
{
// 把 Json 字符串解析成配置列表
ItemConfigList configList =
JsonUtility.FromJson<ItemConfigList>(json);
itemDict.Clear();
foreach (ItemConfig config in configList.items)
{
// 防止 id 重复,重复 id 是配置表的大问题
if (itemDict.ContainsKey(config.id))
{
Debug.LogError($"道具配置 id 重复: {config.id}");
continue;
}
itemDict.Add(config.id, config);
}
}
public ItemConfig Get(int id)
{
if (itemDict.TryGetValue(id, out ItemConfig config))
{
return config;
}
Debug.LogError($"找不到道具配置 id: {id}");
return null;
}
}统一 ConfigManager
不要让业务代码到处自己读文件。 应该统一从 ConfigManager 拿配置。
using UnityEngine;
public class ConfigManager
{
public static ConfigManager Instance { get; } = new ConfigManager();
// 道具配置表
public ItemConfigTable ItemTable { get; private set; }
private ConfigManager()
{
}
public void LoadAll()
{
ItemTable = new ItemConfigTable();
// 示例:从 Resources 加载 Json
// 真实项目里更常见的是 Addressables 或 AssetBundle
TextAsset itemJson = Resources.Load<TextAsset>("Configs/item_config");
if (itemJson == null)
{
Debug.LogError("找不到道具配置表");
return;
}
ItemTable.LoadFromJson(itemJson.text);
}
public ItemConfig GetItem(int id)
{
return ItemTable.Get(id);
}
}使用:
public class ItemView
{
public void ShowItem(int itemId)
{
// 业务层只关心查询,不关心配置文件怎么加载
ItemConfig config = ConfigManager.Instance.GetItem(itemId);
if (config == null)
{
return;
}
UnityEngine.Debug.Log(
$"道具名: {config.name}, 价格: {config.price}"
);
}
}为什么要做校验
配置表最怕“数据错了,线上才发现”。
必须校验:
id 是否唯一
字段类型是否正确
必填字段是否为空
枚举值是否合法
数值范围是否合理
外键引用是否存在
客户端表和服务器表是否一致
多语言 key 是否存在
奖励物品 id 是否存在
技能引用的特效 id 是否存在比如怪物掉落了 itemId = 99999,但道具表没有这个 id。 如果导出时不校验,玩家打怪掉落时才报错,就很危险。
配置对象最好只读
运行时配置不要被业务随便改。
错误思路:
ItemConfig config = ConfigManager.Instance.GetItem(1001);
// 业务代码偷偷改了配置,后面所有地方都受影响
config.price = 999999;更好的做法:
配置数据加载后当作只读数据
角色运行时属性单独存
不要把配置值当运行时状态改比如:
配置表:角色基础血量 1000
运行时数据:当前血量 650这两个不要混在一起。
Json、二进制、ScriptableObject 怎么选
Json:
可读性好,调试方便,适合开发期和中小配置。
二进制:
体积小,加载快,不方便人工查看,适合正式包和大量配置。
ScriptableObject:
Unity 生态友好,可以 Inspector 查看,适合部分强 Unity 资源关联配置。
Excel:
适合编辑期,不适合运行时直接读取。面试里可以说: 开发期可以用 Json 方便排查,正式环境可以导出二进制提高加载速度和减小体积。
热更新怎么配合配置表
配置表经常需要热更。
设计时要考虑:
配置版本号
配置 Hash
差异更新
下载失败回滚
客户端和服务器版本匹配
旧版本客户端兼容新配置比如热更流程:
客户端请求版本文件
比较本地配置版本
发现 item_config 变了
下载新配置
校验 Hash
加载新配置
失败则回滚旧配置千万不要下载完就直接覆盖。 必须先校验成功,再替换本地旧配置。
常见坑
1. 运行时直接读 Excel
2. 配置表没有 id 唯一校验
3. 外键引用不校验
4. 业务代码直接修改配置对象
5. 到处散落读取配置文件的代码
6. 表字段改名后代码不报错,运行时才炸
7. 配置没有版本管理,热更后客户端服务器不一致
8. 大表全部启动加载,导致进入游戏很慢
9. 没有错误提示到具体表名、行号、列名面试高分回答
可以这样答:
我会把配置表系统分成编辑期、导出期和运行期。
编辑期由策划维护 Excel 或 Google Sheet。导出工具负责读取表格,做 id 唯一、类型、范围、外键引用等校验,并生成客户端需要的 Json、二进制或 ScriptableObject,同时生成对应的 C# 配置类,减少字符串和 object 带来的风险。
运行时不直接读 Excel,而是由 ConfigManager 统一加载配置。加载后把 List 转成 Dictionary,以 id 为 key 快速查询。业务代码只通过 GetItem、GetSkill 这类接口访问配置,不直接关心文件路径和解析细节。
配置数据加载后应该当作只读,不能被业务随便修改。运行时状态要单独存,比如当前血量、当前等级,不要写回配置对象。
如果项目支持热更,还要给配置表做版本号、Hash 校验、差异下载和失败回滚,保证客户端和服务器配置一致。
总结就是:配置表系统不是简单读表,而是一整套数据生产、校验、发布、加载、查询和热更新流程。最短记忆版
配置表系统 = Excel 编辑 + 导出工具 + 数据校验 + 代码生成 + 运行时只读加载 + ConfigManager 查询 + 热更版本管理。