MeteorCat / Godot 的 Unity 分包平替

Created Sat, 11 Oct 2025 22:31:37 +0800 Modified Wed, 29 Oct 2025 23:24:59 +0800

Godot分包方案

主要针对的是在 Godot 实现 UnityAB包(Asset Bundle Package) 概念.

对于商业游戏来说都会把非核心资源切分成网络包, 通过CDN把资源包发布上加速资源下载速度; 也就是默认启动游戏应用都会生成空白游戏场景, 之后会把资源包从CDN更新到最新业务代码之中.

这种方式也算是一种热更新方式, 通过将非核心功能(场景|皮肤等)提取出来设置动态客户端的资源替换.

很有必要的流程, 可以直接做些临时的动态皮肤热更不用全面覆盖更新, 已上架的游戏不修改版本可以直接更新

这里需要说明的是, 以下示例都是采用 Godot C# 版本开发, 方便契合 Unity 转移到 Godot 开发; 并且 C# 能够尽可能复用 .net 相关的第三方社区扩展, 比如 Protobuf 这些相关的功能.

不过目前截止在 Godot 4.5 版本还无法做 Web 打包, H5 游戏支持需要后续版本解决才能出包

这里想构建项目, 并且追加两个场景分别是初始化入口场景和网络加载场景, 注意后续 C# 版本脚本文件都是尽可能采用 .net 规范(大驼峰):

init

注意: 一般推荐材质贴图单独封成材质包抽离出来, 做到 美术业务 分离

一般需要准备材质表来加载其中所有的材质路径, 比如:

  • user://Packages/UI.pck → https://cdn.localhost/Packages/UI.pck

这里具体流程如下:

  1. 请求远程接口确定目前最新版本的材质包文件 MD5 哈希码
  2. 获取本地 UI.pck MD5哈希码是否匹配, 不匹配需要删除本地的 pck
  3. 启动网络下载到本地 pck 资源, 写入到 user://xxx 最好和远程路径匹配方便映射
  4. 下载完成之后通过 ProjectSettings.LoadResourcePack 加载资源包
  5. 通过 GD.Load<PackedScene>("res://xxx.tscn").Instantiate() 挂载场景到游戏场景

另外还需要说明的是 母包 概念, 也就是之前说的只负责启动空场景只做业务热更新功能的载体

而以目前情况就需要以下数据:

  • 资产清单的远程地址: 也就是拉取版本更新的JSON远程地址, 建议下载到本地比对上个版本
  • 远程CDN的下载地址: 全局版本不匹配的话就需要直接全量更新, 部分包不匹配则只需要增量更新

这里推荐远程资产返回的 JSON 文件样式:

{
    // 全局版本, 如果发现这个版本号匹配不上直接设置全量更新
    "Version": "1.0.0",

    // 需要更新的包列表
    "Packages": [
        {
            // 初始文件包的文件名, 必须保证编写成唯一, 后续加载依赖该字符串做唯一标识
            "FileName": "/Packages/UI.pck",

            // 远程下载的包地址
            "DownloadUrl": "https://cdn.localhost.com/Pacakges/UI.pck",

            // 比对下载之后包的哈希值用于对比
            "Hash": "29214c5814afeca5cf227466215aa9ab",

            // 下载到本地路径, user:// 是 Godot 的本地特殊标识地址, Web 项目可能不支持
            "LocalPath": "user://Pacakges/UI.pck",

            // 资源的场景, 也就是加载完 pck 包之后默认挂载到根目录等待启动的场景
            "ResourcePath": "res://UI.tscn",

            // 默认的包版本, 如果和当前保存本地的版本不匹配就直接做增量更新
            "Version": "1.0.2",

            // 包的体积(字节), 用于提供给某些平台的预分配, 可以做进度条比例增加,可有可无
            "Size": 1000
        },
        {
            "FileName": "/Packages/Authority.pck",
            "DownloadUrl": "https://cdn.localhost.com/Pacakges/Authority.pck",
            "Hash": "73a6262705ae300883c4066a9d123a72",
            "LocalPath": "user://Pacakges/Authority.pck",
            "ResourcePath": "res://Authority.tscn",
            "Version": "1.0.2",
            "Size": 1000
        }
        // 其他场景分包......
    ]
}

这部分是必须要的, 如果大小游戏版本更新的时候先把对应的 pck 包提交到 CDN 站点上; 之后覆盖掉原来请求的 资产清单JSON 文件, 最后把所有玩家强制下线让其登录; 重新进入根节点场景检测版本更新, 版本不匹配就按照以上的来选择增量或者全量更新.

这里编写个 PackageInfo.csResourceInfo.cs 的映射实体 C# 类, 用于接收文件内容转化为类结构:

using System.Collections.Generic;


/// <summary>
/// 远程请求的包信息
/// 通过远程加载 Manifest.json 读取列表的 ResourceInfo 数组和版本
/// </summary>
public class PackageInfo
{
    /// <summary>
    /// 全局版本信息, 当不匹配的时候就代表着
    /// </summary>
    public string Version { get; set; }


    /// <summary>
    /// 需要判断的包列表
    /// </summary>
    public List<ResourceInfo> Packages { get; set; }
}


namespace PackGame.Scene;


/// <summary>
/// 材质包信息
/// 判断 Manifest.json 内部的版本号是否和本地匹配包版本是否匹配
/// </summary>
// ReSharper disable once ClassNeverInstantiated.Global
public class ResourceInfo
{
    /// <summary>
    /// 文件名, 比如 /Packages/UI.pck
    /// </summary>
    public string FileName { get; set; }


    /// <summary>
    /// 远程下载地址, 比如 https://cdn.localhost.com/Pacakges/UI.pck
    /// </summary>
    public string DownloadUrl { get; set; }


    /// <summary>
    /// 文件的MD5哈希码
    /// </summary>
    public string Hash { get; set; }


    /// <summary>
    /// 本地下载地址, 比如 user://Pacakges/UI.pck
    /// </summary>
    public string LocalPath { get; set; }


    /// <summary>
    /// 资源场景, 比如 res://UI.tscn
    /// </summary>
    public string ResourcePath { get; set; }

    /// <summary>
    /// 包的版本号
    /// </summary>
    public string Version { get; set; }

    /// <summary>
    /// 包具体大小(单位: 字节), 这个字段可有可无, 最多是做预分配本地空间计算的情况
    /// </summary>
    public long Size { get; set; }
}

之后就是编写 GameApplication.cs 脚本, 用于下载和更新处理 pck 包到本地:

using System;
using System.Text.Json;
using Godot;

namespace PackGame.Scene;

/// <summary>
/// 游戏加载启动入口: AssetBundle
/// </summary>
public partial class GameApplication : Node
{
    /// <summary>
    /// 资产清单JSON地址
    /// </summary>
    [ExportGroup("Manifest")]
    [Export(hintString: "资产清单JSON地址, 正式环境推荐采用 https")]
    public string ManifestInfoAddress { get; set; } = "http://localhost/Manifest.json";

    /// <summary>
    /// 测试环境本地加载的资源清单包
    /// </summary>
    [ExportGroup("Manifest")]
    [Export(hint: PropertyHint.File, hintString: "测试加载的资源清单文件, 正式环境不需要打包文件到项目之中")]
    public string ManifestInfoFilename { get; set; } = "res://Manifest.json";
    
    
    /// <summary>
    /// 保存在本地的资产文件
    /// </summary>
    [ExportGroup("Manifest")]
    [Export(hint: PropertyHint.File, hintString: "本地加载的资源清单文件, 用来远程下载版本比对识别是否全量更新")]
    public string ManifestInfoLocalFilename { get; set; } = "user://Manifest.json";
    
    
    /// <summary>
    /// 资产统一的加密KEY
    /// </summary>
    [ExportGroup("Manifest")]
    [Export(hintString: "远程统一下载的资产解密KEY")]
    public string ManifestKey { get; set; } = "";


    /// <summary>
    /// 加载获取的包信息
    /// </summary>
    private PackageInfo PackageInfo { get; set; }


    /// <summary>
    /// 启动的时候进入节点回调
    /// </summary>
    public override void _EnterTree()
    {
#if DEBUG
        // 这里本地调试的情况不需要做网络请求, 直接拉取本地的 Manifest.json 即可
        GD.Print("Mode: DEBUG");

        // 测试是否存在本地资源清单
        if (!FileAccess.FileExists(ManifestInfoFilename))
        {
            // 抛出错误直接退出, 注意: Web 平台可能不支持退出, 所以需要做判断
            ThrowErrExit($"ERR: Not Found Manifest File: {ManifestInfoFilename}");
            return;
        }
        
        // 获取到字符串内容就直接抛给内部函数处理
        using var fp = FileAccess.Open(ManifestInfoFilename, FileAccess.ModeFlags.Read);
        PackageInfo = ConvertPackageInfo(fp.GetAsText());
#else
        GD.Print("Mode: RELEASE");
        // todo: 远程资源 JSON 文件下载
#endif
        
        
        // 包加载之后判断是否完成
        if (PackageInfo == null)
        {
            ThrowErrExit("ERR: Convert Manifest Struct");
            return;
        }
        
        
        // 需要加载本地的资产清单, 比对是全量更新还是增量更新
        if (!UpdateManifestFiles())
        {
            ThrowErrExit("ERR: Update Manifest Files");
        }
        
        
        // 其他识别
        
    }


    /// <summary>
    /// 抛出错误并且退出
    /// </summary>
    /// <param name="message">错误信息</param>
    /// <param name="code">退出代码</param>
    private void ThrowErrExit(string message, int code = 1)
    {
        GD.PrintErr(message);
#if !WEB
        GetTree().Quit(code);
#endif
    }


    /// <summary>
    /// 将资产清单转化为结构体
    /// </summary>
    /// <param name="manifest">资产JSON</param>
    /// <returns>资产结构体</returns>
    private static PackageInfo ConvertPackageInfo(string manifest)
    {
        if (string.IsNullOrEmpty(manifest))
        {
            return null;
        }
        
        try
        {
            return JsonSerializer.Deserialize<PackageInfo>(manifest);
        }
        catch (Exception e)
        {
            GD.PrintErr(e.Message);
            return null;
        }
    }


    /// <summary>
    /// 更新所有资产文件
    /// </summary>
    /// <returns></returns>
    private bool UpdateManifestFiles()
    {
        // 识别目前需要更新的包列表
        var packages = PackageInfo.Packages;
        
        
        // 确认本地用户文件目录已经存在, 不存在代表首次更新启动, 不需要做版本判断
        if (FileAccess.FileExists(ManifestInfoLocalFilename) && packages.Count > 0)
        {
            
            using var reader = FileAccess.Open(ManifestInfoLocalFilename, FileAccess.ModeFlags.Read);
            var oldPackageInfo = ConvertPackageInfo(reader.GetAsText());
            
            // 找到本地资产文件并且版本不匹配时候代表需要做全量更新
            if (oldPackageInfo != null && !PackageInfo.Version.Equals(oldPackageInfo.Version))
            {
                
            }
        }
        
        
        // 开始下载包到本地之中
        foreach (var package in packages)
        {
            GD.Print($"Package Name: {package.FileName}");
            GD.Print($"Download Package: {package.DownloadUrl}");
            GD.Print($"Local Path: {package.LocalPath}");
            
            // 这里开始就要下载远程包并且写入到本地路径
            DownloadPackage(package);
        }
        
        
        // 下载完成覆盖更新掉本地资产文件, 这里打印本地的真实路径方便查看是否正确
        using var writer = FileAccess.Open(ManifestInfoLocalFilename, FileAccess.ModeFlags.Write);
        var result = writer.StoreString(JsonSerializer.Serialize(PackageInfo));
        GD.Print($"Save Manifest File: {ProjectSettings.GlobalizePath(ManifestInfoLocalFilename)}");
        
        
        // 判断是否成功
        if (!result)
        {
            GD.PrintErr(writer.GetError());
        }

        return result;
    }


    /// <summary>
    /// 下载包到本地
    /// </summary>
    /// <param name="package">包信息</param>
    private void DownloadPackage(ResourceInfo package)
    {
        GD.Print($"Download Package Hash: {package.Hash}");
        // todo: 准备写入并且把场景放在根节点
    }
}

这里就是先简略生成在启动的时候必须要做的事, 因为现在还没生成场景包, 现在生成个测试包方便测试:

export

打出来个 UI.pck 包(可能是 zip 后缀, 不过更加推荐生成原生 pck 加载); 因为本地没有网络环境且没有 CDN 处理, 所以先放置在 user://Packages/UI.pck 之中.

默认 user:// 是特殊映射符, 可以通过 ProjectSettings.GlobalizePath("user://") 获取路径

放入之后编写测试代码来加载进场景之中:

/// <summary>
/// 游戏加载启动入口: AssetBundle
/// </summary>
public partial class GameApplication : Node
{
    // 其他略

    /// <summary>
    /// 下载包到本地
    /// </summary>
    /// <param name="package"></param>
    private void DownloadPackage(ResourceInfo package)
    {
        GD.Print($"Download Package Hash: {package.Hash}");
        // todo: 准备写入并且把场景放在根节点

        // 测试的数据包
        var filename = "/Packages/UI.pck";
        var packFilename = $"user://{filename}";
        
        // 加载的场景, 也就是打包进去配置的路径
        // 一般来说就是你打包的时候以项目根目录就是 res:// , 而我们打出来的资源包就要跟随起始节点加载场景
        var packScene = "res://Asset/UI/GameUI.tscn"; 

        // 简单加载到项目
        if (!ProjectSettings.LoadResourcePack(packFilename))
        {
            GD.Print("PCK Load Failed");
            return;
        }

        try
        {
            // 实例化附加到根目录节点
            var packedScene = GD.Load<PackedScene>(packScene);
            if (packedScene == null)
            {
                GD.PrintErr($"Failed to load {packScene}");
                return;
            }

            // 实例化场景
            var packInstance = packedScene.Instantiate<Node>();
            if (packInstance == null)
            {
                GD.PrintErr($"Failed to instantiate {packScene}");
                return;
            }

            // 获取根节点并且挂载
            Node rootNode = GetTree().Root;
            rootNode.CallDeferred("add_child",packInstance); // 延迟加载
            GD.Print($"Node load : {packedScene}");
        }
        catch (Exception e)
        {
            GD.PrintErr(e.Message);
        }
    }
}

需要注意的是加载进来的包按照官方说法目前没有卸载的方法, 因为本身按照官方说法就是包引入必然是要使用而不能被动态卸载清除.

这里就是比较浅显模拟出 Unity 的分包功能, 后面还有的扩展功能可能要后续有时间再做处理.