Article

C# 实战课 02:配置与版本:用 ScriptableObject + JsonUtility 做可回滚配置

这章解决一个非常实际的问题:你迟早要改数值、改参数、加字段、删字段。

如果你没有配置体系,最终会变成:

  • 全靠硬编码:改一次要重新打包。
  • 全靠散落的 ScriptableObject:字段一变就一堆数据坏掉。
  • 全靠 JSON:默认值、迁移、回滚全靠手工,越改越乱。

我们要的是一套“能活很久”的方案:

  • 默认配置:放在工程里,版本可控。
  • 本地覆盖:放在 persistentDataPath,可热更新(至少不必重打包)。
  • 版本迁移:字段变化时能自动升级。
  • 回滚:升级失败时能恢复到旧版本。

本章交付物

以“技能系统配置”为例,交付:

  1. SkillConfig.asset(默认配置,随包发布)
  2. skill-config.json(本地覆盖,运行时加载)
  3. ConfigLoader(加载、迁移、回滚)

约束(Unity 老版本)

  • 使用 JsonUtility(不依赖 Newtonsoft)。
  • 配置结构保持“可序列化、可向前兼容”。
  • 不做“任意字典”作为核心结构(JsonUtility 不支持 Dictionary)。

配置结构设计:先定好版本字段

最关键的是加一个 schemaVersion

using System;
using UnityEngine;

[Serializable]
public class SkillEntry
{
    public string id;
    public int cooldownMs;
    public int damage;
}

[Serializable]
public class SkillConfigData
{
    public int schemaVersion;
    public SkillEntry[] skills;
}

SkillConfigData 是“运行时数据结构”,它要稳定、容易迁移。

默认配置:ScriptableObject 只负责承载 TextAsset

Unity 的 ScriptableObject 很适合做“默认值容器”,但不建议把它当作长期可迁移的数据结构(字段变化时资源容易乱)。

更稳的方法:SO 里只放一份 JSON 文本作为默认配置

using UnityEngine;

public sealed class SkillConfigAsset : ScriptableObject
{
    public TextAsset defaultJson;
}

你在 Editor 里创建一个 SkillConfigAsset,把默认 JSON 拖进去即可。

默认 JSON 示例:

{
  "schemaVersion": 1,
  "skills": [
    {"id": "fireball", "cooldownMs": 1200, "damage": 25},
    {"id": "dash", "cooldownMs": 800, "damage": 0}
  ]
}

本地覆盖:persistentDataPath

本地覆盖文件路径固定:

using System.IO;
using UnityEngine;

public static class ConfigPaths
{
    public static string SkillOverridePath
    {
        get { return Path.Combine(Application.persistentDataPath, "skill-config.json"); }
    }
}

加载器:默认 → 覆盖 → 校验 → 迁移 → 回滚

加载器的逻辑必须“可预测”。你要明确每一步发生了什么。

using System;
using System.IO;
using UnityEngine;

public static class SkillConfigLoader
{
    public static SkillConfigData Load(SkillConfigAsset asset)
    {
        Assert.NotNull(asset, "config", "SkillConfigAsset is null");
        Assert.NotNull(asset.defaultJson, "config", "defaultJson is null");

        var defaultText = asset.defaultJson.text;
        var defaultData = JsonUtility.FromJson<SkillConfigData>(defaultText);
        Validate(defaultData, "default");

        var path = ConfigPaths.SkillOverridePath;
        if (!File.Exists(path))
        {
            return defaultData;
        }

        var overrideText = File.ReadAllText(path);
        var overrideData = JsonUtility.FromJson<SkillConfigData>(overrideText);
        if (overrideData == null)
        {
            Log.Warn("config", "override parse failed, fallback to default");
            return defaultData;
        }

        try
        {
            var migrated = MigrateIfNeeded(overrideData);
            Validate(migrated, "override");
            return migrated;
        }
        catch (Exception ex)
        {
            Log.Error("config", "override invalid, rollback. ex=" + ex.Message);
            BackupBadOverride(path, overrideText);
            return defaultData;
        }
    }

    private static void Validate(SkillConfigData data, string source)
    {
        Assert.NotNull(data, "config", source + " data is null");
        Assert.IsTrue(data.schemaVersion >= 1, "config", source + " schemaVersion invalid");
        Assert.NotNull(data.skills, "config", source + " skills is null");

        for (int i = 0; i < data.skills.Length; i++)
        {
            var s = data.skills[i];
            Assert.NotNull(s, "config", source + " skill entry null at " + i);
            Assert.IsTrue(!string.IsNullOrEmpty(s.id), "config", source + " skill id empty");
        }
    }

    private static SkillConfigData MigrateIfNeeded(SkillConfigData data)
    {
        // 这里演示从 v1 -> v2 的迁移框架
        if (data.schemaVersion == 1)
        {
            // 举例:以后你可能新增字段,比如 "cost"。
            // v1 没有 cost,这里可以给默认值。
            data.schemaVersion = 2;
        }

        return data;
    }

    private static void BackupBadOverride(string path, string text)
    {
        try
        {
            var dir = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);

            var backup = path + ".bad." + DateTime.UtcNow.ToString("yyyyMMddHHmmss");
            File.WriteAllText(backup, text);
            File.Delete(path);
        }
        catch (Exception ex)
        {
            Log.Warn("config", "backup failed: " + ex.Message);
        }
    }
}

为什么不用 Dictionary

JsonUtility 不支持字典,这会逼你把数据做成数组,然后做一个运行时索引。

建议在加载后构建索引:

using System.Collections.Generic;

public sealed class SkillConfigRuntime
{
    private readonly Dictionary<string, SkillEntry> _map = new Dictionary<string, SkillEntry>();

    public SkillConfigRuntime(SkillConfigData data)
    {
        for (int i = 0; i < data.skills.Length; i++)
        {
            var s = data.skills[i];
            _map[s.id] = s;
        }
    }

    public SkillEntry Get(string id)
    {
        SkillEntry value;
        return _map.TryGetValue(id, out value) ? value : null;
    }
}

这能保证 JSON 保持简单,而运行时访问保持高效。

本章验收

  1. 删除本地覆盖文件时,游戏可用默认配置启动。
  2. 把本地覆盖 JSON 改坏(比如少字段/写错 JSON),会自动回滚。
  3. schemaVersion 变化时,迁移逻辑会生效并保持可验证。

下一章预告

下一章做“事件与解耦”:

  • 不用 C# event 也能跑
  • 控制订阅/退订
  • 避免 GC 分配

最终目标是让 UI、玩法、数据层能解耦演进。