Unity游戏有限统计数据的最优存储方案技术问询
游戏统计系统序列化与键方案优化建议
问题背景
我正在开发一款Unity游戏,需要展示由「本地化统计名称」+「多类型统计值(int、float、string)」组成的列表/子列表。当前采用枚举作为键的方案(代码如下),但当统计项达到50-70个时会极度冗余。核心需求是:统计数据可序列化到磁盘,且能通过可序列化键便捷访问。现有枚举方案存在明显问题:序列化UI依赖元素顺序,迭代开发中增减统计项极易引发错误。
现有实现代码
[Serializable] public enum GameStatisticsEnum { TimesGameOpened, TimePlayed, TimeBattling, VersusBattleCount, VersusRoundCount, SpellCastCount, DeathCount, } [Serializable] public class GameStatistics { [SerializeField] public int TimesGameOpened; [SerializeField] public float TimePlayed; [SerializeField] public float TimeBattling; [SerializeField] public int VersusBattleCount; [SerializeField] public int VersusRoundCount; [SerializeField] public int SpellCastCount; [SerializeField] public int DeathCount; [SerializeField] public ZoneStats[] ZonesStats; [SerializeField] public CharacterStats[] CharactersStats; public string GetStatLocalizationID(GameStatisticsEnum stat) { return "GameStat_"+stat.ToString(); } public float GetStatValue(GameStatisticsEnum stat) { switch (stat) { case GameStatisticsEnum.TimesGameOpened: return TimesGameOpened; case GameStatisticsEnum.TimePlayed: return TimePlayed; case GameStatisticsEnum.TimeBattling: return TimeBattling; case GameStatisticsEnum.VersusBattleCount: return VersusBattleCount; case GameStatisticsEnum.VersusRoundCount: return VersusRoundCount; case GameStatisticsEnum.SpellCastCount: return SpellCastCount; case GameStatisticsEnum.DeathCount: return DeathCount; default: throw new ArgumentOutOfRangeException(nameof(stat), stat, null); } } public void SetStat(GameStatisticsEnum stat, float value) { switch (stat) { case GameStatisticsEnum.TimesGameOpened: TimesGameOpened = (int)value; break; case GameStatisticsEnum.TimePlayed: TimePlayed = value; break; case GameStatisticsEnum.TimeBattling: TimeBattling = value; break; case GameStatisticsEnum.VersusBattleCount: VersusBattleCount = (int)value; break; case GameStatisticsEnum.VersusRoundCount: VersusRoundCount = (int)value; break; case GameStatisticsEnum.SpellCastCount: SpellCastCount = (int)value; break; case GameStatisticsEnum.DeathCount: DeathCount = (int)value; break; default: throw new ArgumentOutOfRangeException(nameof(stat), stat, null); } } }
备选方案分析
方案一:ScriptableObject作为统计键
实现思路
为每个统计项创建一个继承自StatKeySO的ScriptableObject子类,存储本地化ID、值类型等元数据;GameStatistics用可序列化的List<StatEntry>存储键值对,初始化时转成字典缓存以提升访问效率。
优缺点
- 优点:
- 类型安全:编译时即可检查值类型匹配错误,避免枚举方案的switch冗余
- 扩展性强:新增统计项仅需创建新SO,无需修改现有代码结构,彻底规避枚举顺序依赖问题
- 元数据集中:本地化ID、显示格式(如时间转时分秒)等配置可直接存在SO内,管理更清晰
- 编辑器友好:支持拖拽SO配置UI或统计逻辑,无需手动输入字符串/枚举值
- 缺点:
- 需维护大量SO资源文件,项目资源数量会增加
- Unity原生不支持字典序列化,需用列表模拟或结合第三方序列化库(如Newtonsoft.Json)
- 首次访问需遍历列表初始化缓存,但50-70个统计项的性能影响可忽略
方案二:带自定义属性抽屉的字符串键
实现思路
定义可序列化的StatKey结构体,包含字符串键值;编写自定义PropertyDrawer,在编辑器中提供统计键下拉选择,避免手动输入错误;统计数据用可序列化的键值对列表或字典(结合第三方序列化)存储,元数据(本地化ID、值类型)单独维护在配置类/SO中。
优缺点
- 优点:
- 轻量无额外资源:无需创建SO文件,仅用字符串存储键
- 序列化灵活:列表形式可被Unity原生序列化,字典可通过Newtonsoft.Json实现序列化
- 配置便捷:新增统计项仅需在配置中添加条目,无需修改代码结构
- 缺点:
- 类型不安全:字符串易出现拼写错误,虽有编辑器抽屉辅助,但运行时仍需额外错误处理
- 元数据分散:统计键与对应元数据需分开维护,易出现不一致问题
- 需编写自定义PropertyDrawer,增加编辑器代码的维护成本
推荐方案
若团队规模不大、统计项增长稳定,优先选择ScriptableObject方案——类型安全和编辑器友好性能大幅降低迭代中的错误概率,虽需维护SO资源,但管理成本远低于枚举方案的冗余代码。若追求轻量灵活,或项目已引入Newtonsoft.Json等序列化库,字符串键+自定义抽屉方案也是可行选择。
优化示例(ScriptableObject方案)
// 统计键基类 public abstract class StatKeySO : ScriptableObject { public string LocalizationID; public abstract Type ValueType { get; } } // 不同类型的统计键子类 public class IntStatKeySO : StatKeySO { public override Type ValueType => typeof(int); } public class FloatStatKeySO : StatKeySO { public override Type ValueType => typeof(float); } public class StringStatKeySO : StatKeySO { public override Type ValueType => typeof(string); } // 可序列化的统计条目 [Serializable] public class StatEntry { public StatKeySO Key; public int IntValue; public float FloatValue; public string StringValue; public object GetValue() { return Key switch { IntStatKeySO => IntValue, FloatStatKeySO => FloatValue, StringStatKeySO => StringValue, _ => null }; } public void SetValue(object value) { switch (Key) { case IntStatKeySO: IntValue = (int)value; break; case FloatStatKeySO: FloatValue = (float)value; break; case StringStatKeySO: StringValue = (string)value; break; } } } // 统计数据容器 [Serializable] public class GameStatistics { [SerializeField] private List<StatEntry> _stats = new(); private Dictionary<StatKeySO, StatEntry> _statCache; public void Initialize() { _statCache = _stats.ToDictionary(entry => entry.Key, entry => entry); } public T GetStatValue<T>(StatKeySO key) { if (_statCache.TryGetValue(key, out var entry)) { return (T)entry.GetValue(); } return default; } public void SetStatValue(StatKeySO key, object value) { if (_statCache.TryGetValue(key, out var entry)) { entry.SetValue(value); } else { var newEntry = new StatEntry { Key = key }; newEntry.SetValue(value); _stats.Add(newEntry); _statCache.Add(key, newEntry); } } }
内容的提问来源于stack exchange,提问作者Santiago14
相关产品推荐
相关产品推荐

