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

0 阅读6分钟

热更新-边玩边下载

篇章: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 会自动加入下载队列:

// 加载一个未下载的 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}");
}

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

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 内部维护一个全局下载队列,所有按需下载和热更下载的任务都会进入这个队列:

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 支持按优先级调度:

// 业务层可以指定优先级
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 加载提示

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

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 进度显示

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

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

下载失败的降级策略:

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 队列配置
public class DownloadConfig
{
    public int MaxConcurrent = 5;          // 最大并发下载数
    public int MaxQueueLength = 1000;       // 队列最大长度
    public int PerFileTimeout = 60;         // 单文件超时(秒)
    public int FailedRetryCount = 3;        // 失败重试次数
    public int SpeedSampleInterval = 5;     // 速度采样间隔(秒)
}

并发数选择:

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

3.2 下载队列的管理

3.2.1 入队
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 出队
public DownloadTask Dequeue()
{
    lock (_queueLock)
    {
        return _queue.Count > 0 ? _queue.Dequeue() : null;
    }
}
3.2.3 队列清空
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 连接建立的开销:

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 优先级动态调整
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 队列监控
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 加载中资源提升

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

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

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

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 饥饿避免

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

// 解决方案:每下载 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> 实现:

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 调度器
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 时间片控制
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:

// 内存监控与降级
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 网络竞争

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

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

5.3 场景切换的竞态

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

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. 带宽分配要区分热更与按需,避免互相抢占

下一篇:首包资源策略