08-03-热更新-边玩边下载

热更新-边玩边下载

篇章 :08-核心技术-热更新系统 状态 :已完成 阅读时间:约 20 分钟


一、引言

1.1 本章在系列中的定位

"边玩边下载"(Play-While-Downloading)是 YooAsset 中提升玩家体验的关键能力。想象一个 MMO 玩家登录游戏后,可以立即进入主城开始任务,同时新地图的资源在后台静默下载------这就是"边玩边下载"要解决的核心问题。

它与传统的"全部下载完再玩"模式形成鲜明对比:后者要求玩家在 Loading 界面等待十几分钟甚至半小时,前者让玩家进入游戏零等待,对核心场景和新场景都能带来显著体验提升。

1.2 本章要解决的核心问题

  • 资源加载时如何按需触发下载?
  • 下载队列如何设计?并发数、优先级、超时如何控制?
  • 玩家进入未下载完的场景时如何优雅降级?
  • 边玩边下载与热更下载如何共存而不抢占带宽?

1.3 阅读前置要求

  • 了解 YooAsset 的资源加载 API(LoadAssetAsync、LoadSceneAsync)
  • 了解 YooAsset 的下载系统(参考 06-源码深度-文件系统与下载模块)
  • 了解 YooAsset 的引用计数(参考 05-源码深度-运行时核心模块)

二、运行时按需下载

2.1 按需下载的概念

按需下载 (On-Demand Download)是指在资源被实际请求时才触发下载,而不是预下载整个游戏的所有资源。它的核心思想是"按需"------只下载游戏运行所必需的资源。

按需下载的优势:

维度 全量下载 按需下载
启动时间 几十分钟 秒级
初始包体 GB 级 MB 级
网络流量 一次性大量 分批少量
玩家体验 差 好
服务器压力 集中爆发 平稳分布

2.2 按需下载的实现

2.2.1 下载触发

YooAsset 中,按需下载通过 LoadAssetAsync / LoadSceneAsync 触发。当资源本地不存在时,YooAsset 会自动加入下载队列:

csharp 复制代码
// 加载一个未下载的 Prefab
var op = YooAssets.LoadAssetAsync<GameObject>("Assets/Characters/Hero/Hero.prefab");
await op.Task;

if (op.Status == EOperationStatus.Succeed)
{
    Instantiate(op.Result);
}
else if (op.Status == EOperationStatus.Failed)
{
    // 可能是下载失败,可以重试或降级
    Debug.LogError($"加载失败: {op.Error}");
}

底层触发逻辑(伪代码):

csharp 复制代码
public class LoadAssetOperation
{
    protected override void OnStart()
    {
        // 1. 查询本地文件是否存在
        var localPath = GetLocalPath(_location);
        if (File.Exists(localPath))
        {
            LoadFromLocal();
            return;
        }
        
        // 2. 查询远端清单,确认资源存在
        var bundleInfo = GetBundleInfo(_location);
        if (bundleInfo == null)
        {
            _error = "资源不存在于清单";
            _status = EOperationStatus.Failed;
            return;
        }
        
        // 3. 检查 Bundle 是否已下载
        if (!IsBundleDownloaded(bundleInfo))
        {
            // 4. 加入下载队列(关键!)
            DownloadManager.Enqueue(bundleInfo, OnBundleDownloaded);
            return;
        }
        
        // 5. 从本地加载
        LoadFromLocal();
    }
}
2.2.2 下载队列

YooAsset 内部维护一个全局下载队列,所有按需下载和热更下载的任务都会进入这个队列:

csharp 复制代码
public class DownloadQueue
{
    private readonly PriorityQueue<DownloadTask> _queue = new();
    private readonly int _maxConcurrent;
    
    public void Enqueue(BundleInfo bundle, int priority = 0)
    {
        var task = new DownloadTask
        {
            BundleInfo = bundle,
            Priority = priority,
            EnqueueTime = DateTime.Now
        };
        _queue.Enqueue(task, priority);
    }
    
    public async Task StartDownloading()
    {
        var semaphore = new SemaphoreSlim(_maxConcurrent);
        while (_queue.Count > 0)
        {
            await semaphore.WaitAsync();
            var task = _queue.Dequeue();
            
            _ = Task.Run(async () => {
                try
                {
                    await DownloadBundle(task);
                }
                finally
                {
                    semaphore.Release();
                }
            });
        }
    }
}
2.2.3 下载优先级

不同的资源有不同的紧迫性。YooAsset 支持按优先级调度:

csharp 复制代码
// 业务层可以指定优先级
public class LoadAssetWithPriority
{
    public async Task<T> LoadCritical<T>(string location)
    {
        // 最高优先级:当前场景需要的资源
        return await YooAssets.LoadAssetAsync<T>(location, priority: 100);
    }
    
    public async Task<T> LoadPreload<T>(string location)
    {
        // 中等优先级:下一场景预加载
        return await YooAssets.LoadAssetAsync<T>(location, priority: 50);
    }
    
    public async Task<T> LoadBackground<T>(string location)
    {
        // 最低优先级:后台资源
        return await YooAssets.LoadAssetAsync<T>(location, priority: 0);
    }
}

2.3 按需下载的体验优化

2.3.1 加载提示

在玩家进入未下载完成的场景时,必须给出明确的提示:

csharp 复制代码
public class LoadingHint : MonoBehaviour
{
    public Slider progressBar;
    public TMP_Text progressText;
    public TMP_Text speedText;
    
    public async Task ShowProgress(AsyncOperationBase op)
    {
        gameObject.SetActive(true);
        try
        {
            while (!op.IsDone)
            {
                if (op is DownloadUpdateOperation downloadOp)
                {
                    progressBar.value = downloadOp.Progress;
                    progressText.text = $"{downloadOp.CurrentDownloadBytes / 1024 / 1024}MB / {downloadOp.TotalDownloadBytes / 1024 / 1024}MB";
                    speedText.text = $"{downloadOp.CurrentSpeed / 1024:F1} KB/s";
                }
                await Task.Yield();
            }
        }
        finally
        {
            gameObject.SetActive(false);
        }
    }
}
2.3.2 进度显示

进度显示需要细粒度,否则玩家会以为游戏卡死:

erlang 复制代码
[Loading 界面]
├── 总进度条:0% ──────── 100%
├── 当前文件:battle_map_01.bundle
├── 已下载:125 MB / 380 MB
├── 当前速度:3.2 MB/s
├── 预计剩余:1 分 20 秒
└── 提示文案:正在下载战斗资源,请稍候...
2.3.3 失败处理

下载失败的降级策略:

csharp 复制代码
public class DownloadFallback
{
    public async Task<T> LoadWithFallback<T>(string location) where T : Object
    {
        try
        {
            // 尝试加载
            var op = YooAssets.LoadAssetAsync<T>(location);
            await op.Task;
            return op.Result as T;
        }
        catch (Exception ex)
        {
            // 降级 1:使用默认资源
            Debug.LogWarning($"加载 {location} 失败,使用默认资源");
            return LoadDefaultResource<T>();
        }
    }
    
    T LoadDefaultResource<T>()
    {
        // 加载通用占位资源
        if (typeof(T) == typeof(GameObject))
            return Resources.Load<GameObject>("DefaultPlaceholder") as T;
        return default;
    }
}

三、下载队列

3.1 下载队列的设计

下载队列是 YooAsset 下载系统的"调度中心"。它需要平衡下载速度 、内存占用 、网络负载三个维度。

3.1.1 队列策略对比
策略 优点 缺点 适用场景
FIFO(先进先出) 简单公平 不分轻重缓急 小型游戏
优先级队列 关键资源优先 可能饿死低优先级 大多数商业游戏(推荐)
加权轮询 平衡性好 实现复杂 极少使用
时间片 防止长任务霸占 切片开销大 不推荐

YooAsset 内部使用优先级队列。

3.1.2 队列配置
csharp 复制代码
public class DownloadConfig
{
    public int MaxConcurrent = 5;          // 最大并发下载数
    public int MaxQueueLength = 1000;       // 队列最大长度
    public int PerFileTimeout = 60;         // 单文件超时(秒)
    public int FailedRetryCount = 3;        // 失败重试次数
    public int SpeedSampleInterval = 5;     // 速度采样间隔(秒)
}

并发数选择:

网络类型 推荐并发数 理由
2G/3G 1 带宽太低,并发无意义
4G 2-3 平衡速度与稳定性
5G 3-5 带宽充足
WiFi 5-8 稳定且带宽大
局域网 10+ 几乎无限制

3.2 下载队列的管理

3.2.1 入队
csharp 复制代码
public void Enqueue(BundleInfo bundle, int priority)
{
    lock (_queueLock)
    {
        if (_queue.Count >= _maxQueueLength)
        {
            Debug.LogWarning($"下载队列已满,丢弃 {bundle.BundleName}");
            return;
        }
        
        _queue.Enqueue(new DownloadTask
        {
            Bundle = bundle,
            Priority = priority,
            EnqueueTime = DateTime.UtcNow
        });
    }
    
    // 通知调度器
    _schedulerEvent.Set();
}
3.2.2 出队
csharp 复制代码
public DownloadTask Dequeue()
{
    lock (_queueLock)
    {
        return _queue.Count > 0 ? _queue.Dequeue() : null;
    }
}
3.2.3 队列清空
csharp 复制代码
public void Clear(bool cancelRunning = false)
{
    lock (_queueLock)
    {
        _queue.Clear();
    }
    
    if (cancelRunning)
    {
        // 取消正在运行的任务
        foreach (var task in _runningTasks)
        {
            task.Cancel();
        }
        _runningTasks.Clear();
    }
}

3.3 下载队列的优化

3.3.1 批量下载

批量下载可以减少 HTTP 连接建立的开销:

csharp 复制代码
public class BatchDownloader
{
    public async Task DownloadBatch(List<BundleInfo> bundles)
    {
        // 1. 按 CDN 域名分组
        var groups = bundles.GroupBy(b => GetCDNDomain(b));
        
        // 2. 每个域名使用独立连接
        var tasks = groups.Select(async group =>
        {
            var connection = new HttpConnection(group.Key, maxPerHost: 4);
            foreach (var bundle in group)
            {
                await connection.SendAsync(bundle.Url);
            }
        });
        
        await Task.WhenAll(tasks);
    }
}
3.3.2 优先级动态调整
csharp 复制代码
public class DynamicPriority
{
    public int AdjustPriority(DownloadTask task)
    {
        var waited = DateTime.UtcNow - task.EnqueueTime;
        
        // 等待超过 30 秒,提升优先级
        if (waited.TotalSeconds > 30)
            return Math.Min(task.Priority + 10, 100);
        
        return task.Priority;
    }
}
3.3.3 队列监控
csharp 复制代码
public class QueueMonitor
{
    public QueueStats GetStats()
    {
        return new QueueStats
        {
            QueueLength = _queue.Count,
            RunningCount = _runningTasks.Count,
            TotalSize = _queue.Sum(t => t.Bundle.FileSize),
            AverageWaitTime = _queue.Any() 
                ? (DateTime.UtcNow - _queue.Min(t => t.EnqueueTime)).TotalSeconds 
                : 0
        };
    }
}

四、优先级调度

4.1 优先级的定义

YooAsset 中,资源优先级由业务层根据使用场景决定。常见的优先级分配:

优先级 数值 适用场景 示例
最高 100 当前场景立刻需要 主城场景、UI 框架
高 80 当前关卡即将进入 关卡内的模型、特效
中 50 下一场景预加载 主菜单后立即进入的新手关
低 20 后台预下载 远期活动资源
最低 0 闲置下载 远期 DLC、皮肤

4.2 优先级的动态调整

4.2.1 加载中资源提升

玩家主动请求的资源应该被提升到最高优先级:

csharp 复制代码
// 玩家点击"进入副本"按钮时
public void OnEnterDungeonButtonClicked()
{
    // 提升战斗资源优先级
    var bundles = GetDungeonBundles("dungeon_01");
    foreach (var bundle in bundles)
    {
        DownloadManager.BoostPriority(bundle, 100);
    }
    
    // 进入副本
    LoadScene("dungeon_01");
}
4.2.2 超时资源提升

如果某个资源等待时间过长,应提升其优先级避免饥饿:

csharp 复制代码
public class StarvationPrevention
{
    public void CheckStarvation()
    {
        var now = DateTime.UtcNow;
        var lowPriorityTasks = _queue.Where(t => t.Priority < 20).ToList();
        
        foreach (var task in lowPriorityTasks)
        {
            var waited = now - task.EnqueueTime;
            if (waited.TotalSeconds > 60)  // 等待超过 1 分钟
            {
                task.Priority = 50;  // 提升到中等
                Debug.Log($"任务 {task.Bundle.BundleName} 等待超时,提升优先级");
            }
        }
    }
}
4.2.3 饥饿避免

完全的低优先级任务可能永远得不到下载,需要平衡:

csharp 复制代码
// 解决方案:每下载 5 个高优先级任务后,必定下载 1 个低优先级
public class FairScheduler
{
    private int _highPriorityCount = 0;
    
    public DownloadTask SelectNextTask()
    {
        if (_highPriorityCount >= 5)
        {
            // 强制调度一个低优先级任务
            _highPriorityCount = 0;
            return _queue.FirstOrDefault(t => t.Priority < 20) 
                ?? _queue.Dequeue();
        }
        
        var task = _queue.Dequeue();
        if (task.Priority >= 50) _highPriorityCount++;
        return task;
    }
}

4.3 优先级调度的实现

4.3.1 优先级队列

YooAsset 使用 PriorityQueue<T> 实现:

csharp 复制代码
public class PriorityQueue<T> where T : IComparable<T>
{
    private readonly List<T> _heap = new();
    
    public void Enqueue(T item)
    {
        _heap.Add(item);
        HeapifyUp(_heap.Count - 1);
    }
    
    public T Dequeue()
    {
        var top = _heap[0];
        _heap[0] = _heap[^1];
        _heap.RemoveAt(_heap.Count - 1);
        HeapifyDown(0);
        return top;
    }
}
4.3.2 调度器
csharp 复制代码
public class DownloadScheduler
{
    private readonly PriorityQueue<DownloadTask> _queue = new();
    private readonly SemaphoreSlim _concurrency;
    private CancellationTokenSource _cts;
    
    public void Start()
    {
        _cts = new CancellationTokenSource();
        Task.Run(() => ScheduleLoop(_cts.Token));
    }
    
    private async Task ScheduleLoop(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (_queue.Count == 0)
            {
                await Task.Delay(100, ct);
                continue;
            }
            
            await _concurrency.WaitAsync(ct);
            var task = _queue.Dequeue();
            
            _ = Task.Run(async () =>
            {
                try
                {
                    await ExecuteDownload(task);
                }
                finally
                {
                    _concurrency.Release();
                }
            }, ct);
        }
    }
}
4.3.3 时间片控制
csharp 复制代码
public class TimeSliceController
{
    private const int FRAME_TIME_BUDGET_MS = 5;  // 每帧最多 5ms 用于下载管理
    
    public void Tick()
    {
        var sw = Stopwatch.StartNew();
        
        // 处理回调
        ProcessCallbacks();
        
        // 更新进度
        UpdateProgress();
        
        // 检查超时
        CheckTimeouts();
        
        // 如果超出时间预算,跳过剩余工作
        if (sw.ElapsedMilliseconds > FRAME_TIME_BUDGET_MS)
        {
            Debug.LogWarning("下载管理超出时间预算");
        }
    }
}

五、边玩边下载的陷阱与应对

5.1 内存压力

边玩边下载会在内存中保留下载的数据,叠加游戏本身的内存占用,可能触发 OOM:

csharp 复制代码
// 内存监控与降级
public class MemoryAwareDownloader
{
    public bool CanStartNewDownload()
    {
        long usedMemory = Profiler.GetTotalAllocatedMemoryLong();
        long totalMemory = SystemInfo.systemMemorySize * 1024L * 1024L;
        float usage = (float)usedMemory / totalMemory;
        
        if (usage > 0.8f)
        {
            Debug.LogWarning($"内存使用 {usage:P},暂停新下载");
            return false;
        }
        return true;
    }
}

5.2 网络竞争

边玩边下载与热更下载会竞争网络带宽,需要合理分配:

csharp 复制代码
public class BandwidthAllocation
{
    public void AllocateBandwidth()
    {
        // 热更下载:50% 带宽
        // 边玩边下载:30% 带宽
        // 业务网络(登录、支付等):20% 带宽
        ConfigureBandwidthLimit("hotUpdate", 0.5f);
        ConfigureBandwidthLimit("onDemand", 0.3f);
        ConfigureBandwidthLimit("business", 0.2f);
    }
}

5.3 场景切换的竞态

玩家可能在下载未完成时切场景,导致下载任务需要取消:

csharp 复制代码
public class SceneTransitionDownload
{
    public async Task OnSceneUnloading(string fromScene, string toScene)
    {
        // 1. 取消与即将离开场景相关的下载
        var fromBundles = GetSceneBundles(fromScene);
        DownloadManager.CancelBundles(fromBundles);
        
        // 2. 提升新场景相关下载的优先级
        var toBundles = GetSceneBundles(toScene);
        foreach (var bundle in toBundles)
        {
            DownloadManager.BoostPriority(bundle, 100);
        }
        
        // 3. 等待关键资源下载完成(或超时)
        await DownloadManager.WaitForCritical(toBundles, timeoutSec: 10);
        
        // 4. 真正切场景
        LoadScene(toScene);
    }
}

六、总结

6.1 本章要点回顾

  • 按需下载:在资源被请求时自动触发,零启动等待
  • 下载队列:使用优先级队列管理,最大并发数根据网络环境调整
  • 优先级调度:高优先级任务优先下载,但需避免低优先级任务饥饿
  • 体验优化:进度显示、加载提示、失败降级三者缺一不可

6.2 与前后章节的关联

  • 前章:08-02 差量更新解决"批量下载时的优化"
  • 本章:解决"运行时零散请求的调度"
  • 后章:08-04 首包资源策略解决"什么应该预下载"

6.3 实践建议

  1. 并发数不要硬编码,根据网络类型动态调整
  2. 优先级要动态调整,必要时提升等待过久的任务
  3. 进度显示要做细,避免玩家以为卡死
  4. 降级策略必须做,否则崩溃率会显著上升
  5. 带宽分配要区分热更与按需,避免互相抢占

下一篇 :首包资源策略

相关推荐
西风逍遥游13 小时前
从 EveScript 到 B站 Toy:一个 WebGPU 游戏发布插件怎样设计
游戏开发
西风逍遥游18 小时前
500 单位 RTS 为什么还没到 60 FPS:一次 WebGPU 游戏性能诊断
游戏开发
甲维斯1 天前
Codex,Zcode,KimiCode电脑操作能力对比!
人工智能·游戏开发
SmalBox1 天前
08-02-热更新-差量更新与断点续传
unity3d·游戏开发
UWA1 天前
多人战斗中Lua持续高耗时,是瞬时峰值还是高频调用在作祟
性能优化·lua·游戏开发
ZzT2 天前
vibe coding 做小游戏·问题排查篇:解决包体积、引擎适配与 iPhone 机型适配问题
ai编程·游戏开发
SmalBox2 天前
08-01-热更新-版本管理机制
unity3d·游戏开发
Aman19842 天前
Claude Code做Unity项目移植(二): 自动执行只读操作,告别频繁的
unity·游戏开发·vibe coding·claude code
SmalBox3 天前
07-03-打包系统-资源变体与多平台适配
游戏开发·图形学