Unity Engine单例模式未按预期工作/初始化异常求助(自定义存档系统交互引发空引用错误)
Hey Vyacheslav, let's break down the issues causing your NullReferenceException and get your save/load system working properly:
Root Causes of the NullReferenceException
Your crash stems from a combination of serialization limitations, flawed singleton logic, and unsafe delegate handling. Let's go through each problem and fix them step by step:
1. JsonUtility Can't Serialize Your Generic/Dictionary Types
Unity's built-in JsonUtility only supports serializable Unity types, and it does not work with generic classes or Dictionary out of the box. Your GenericDataStorage<T> is a generic class without serialization attributes, so when you serialize SavableDataShell, all storage fields get completely ignored. When you deserialize later, shell.FloatData (and other storage fields) are null, which overwrites the valid storage instances you initialized in Database.Awake()—this is why accessing Database.Instance.FloatData throws a null reference.
2. Unreliable Singleton Implementation
Your singleton's Awake() uses FindObjectsOfType<T>() which can be unpredictable during scene initialization (e.g., it might miss instances that haven't finished Awake yet). A simpler, more reliable approach is to check for an existing instance first.
3. Unsafe Delegate Invocation
Your ContentUpdated delegate is invoked without checking if it's null. While you pass a handler in the Database, any other usage without handlers would crash, and it's a good practice to always guard against null delegates.
4. Invalid Value Initialization in TryLoadValue
value = (T)new object(); will throw errors for value types like float or int—you can't cast a raw object to a value type. Use default(T) instead to get the default value for the type.
5. GameObject Serialization is Impossible
You can't serialize a GameObject reference directly via JSON (it's a scene-specific object with runtime-only data). Instead, save a unique identifier like a prefab path or asset GUID, then load the prefab from Resources/Addressables later.
Fixed Code Implementations
1. Updated Singleton Base Class
using UnityEngine; public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour { public static T Instance { get; private set; } protected virtual void Awake() { if (Instance == null) { Instance = this as T; DontDestroyOnLoad(gameObject); } else { Destroy(gameObject); } } }
2. Fixed GenericDataStorage Class
using System.Collections.Generic; using UnityEngine; using System; [Serializable] // Add this for serialization support (if using Newtonsoft) public class GenericDataStorage<T> { private Dictionary<string, T> _data; private Action _contentUpdated; public GenericDataStorage(params Action[] contentUpdatedHandlers) { _data = new Dictionary<string, T>(); foreach (Action handler in contentUpdatedHandlers) _contentUpdated += handler; } public bool TryLoadValue(string key, out T value) { if (_data.ContainsKey(key)) { value = _data[key]; return true; } else { value = default(T); // Safe default value instead of invalid cast return false; } } public bool TryDeleteValue(string key) { if (_data.ContainsKey(key)) { _data.Remove(key); _contentUpdated?.Invoke(); // Safe null-check before invoking return true; } else return false; } public void SaveValue(string key, T value) { // Handle existing keys to avoid Add() exceptions if (_data.ContainsKey(key)) { _data[key] = value; } else { _data.Add(key, value); } _contentUpdated?.Invoke(); } }
3. Switch to Newtonsoft.Json for Serialization
Unity 2019.4 supports Newtonsoft.Json via the Package Manager (search for "Newtonsoft.Json" and install it). This library handles generics and dictionaries seamlessly. Here's the updated Database:
using UnityEngine; using System.IO; using System; using System.Text; using Newtonsoft.Json; public class Database : Singleton<Database> { private const string _FILE_NAME = "SavedGame.txt"; public GenericDataStorage<float> FloatData { get; private set; } public GenericDataStorage<int> IntData { get; private set; } public GenericDataStorage<bool> BooleanData { get; private set; } // Replace GameObject storage with string paths/IDs public GenericDataStorage<string> PrefabData { get; private set; } public GenericDataStorage<string> StringData { get; private set; } private void Save() { var shell = new SavableDataShell(FloatData, IntData, BooleanData, PrefabData, StringData); string databaseAsJson = JsonConvert.SerializeObject(shell, Formatting.Indented); byte[] bytesToEncode = Encoding.UTF8.GetBytes(databaseAsJson); string encodedText = Convert.ToBase64String(bytesToEncode); string filePath = Path.Combine(Application.persistentDataPath, _FILE_NAME); File.WriteAllText(filePath, encodedText); // Simpler than StreamWriter } private void Load() { string filePath = Path.Combine(Application.persistentDataPath, _FILE_NAME); if (File.Exists(filePath)) { string encodedText = File.ReadAllText(filePath); byte[] encodedBytes = Convert.FromBase64String(encodedText); string decodedText = Encoding.UTF8.GetString(encodedBytes); var shell = JsonConvert.DeserializeObject<SavableDataShell>(decodedText); // Fallback to fresh storage if deserialization fails FloatData = shell?.FloatData ?? new GenericDataStorage<float>(Save); IntData = shell?.IntData ?? new GenericDataStorage<int>(Save); BooleanData = shell?.BooleanData ?? new GenericDataStorage<bool>(Save); PrefabData = shell?.PrefabData ?? new GenericDataStorage<string>(Save); StringData = shell?.StringData ?? new GenericDataStorage<string>(Save); } } protected override void Awake() { base.Awake(); FloatData = new GenericDataStorage<float>(Save); IntData = new GenericDataStorage<int>(Save); BooleanData = new GenericDataStorage<bool>(Save); PrefabData = new GenericDataStorage<string>(Save); StringData = new GenericDataStorage<string>(Save); Load(); } private void OnEnable() { Application.wantsToQuit += ApplicationWantsToQuitEventHandler; } private void OnDisable() { Application.wantsToQuit -= ApplicationWantsToQuitEventHandler; } private bool ApplicationWantsToQuitEventHandler() { Save(); return true; } }
4. Updated LevelDifficultyDisplay (with Safety Checks)
using UnityEngine; using UnityEngine.UI; public class LevelDifficultyDisplay : MonoBehaviour { [Tooltip("A text type element to display current difficulty of the level.")] [SerializeField] private Text _difficultyDisplay = null; [Tooltip("Type an index number of a level to track its difficulty.")] [SerializeField] private int _level = 1; private void Start() { if (Database.Instance == null) { Debug.LogError("Database instance not found! Ensure exactly one Database exists in your scene."); return; } if (_difficultyDisplay == null) { Debug.LogError("Difficulty display Text is not assigned!"); return; } if (Database.Instance.FloatData.TryLoadValue($"level {_level} difficulty", out float d)) _difficultyDisplay.text = string.Format("{0:###.##}", d); else { Database.Instance.FloatData.SaveValue($"level {_level} difficulty", 1f); _difficultyDisplay.text = string.Format("{0:###.##}", 1f); } } }
Final Notes
- Make sure you have exactly one
DatabaseGameObject in your initial scene (the singleton will handle DontDestroyOnLoad). - For prefab references, save their path in
Resources(e.g.,"Prefabs/Player") and load them withResources.Load<GameObject>(path)instead of serializing the GameObject directly. - The Newtonsoft.Json package is essential here—Unity's built-in JsonUtility can't handle the generic/dictionary structures you're using.
内容的提问来源于stack exchange,提问作者Heavy Violence

