热更新-边玩边下载
篇章 :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 实践建议
- 并发数不要硬编码,根据网络类型动态调整
- 优先级要动态调整,必要时提升等待过久的任务
- 进度显示要做细,避免玩家以为卡死
- 降级策略必须做,否则崩溃率会显著上升
- 带宽分配要区分热更与按需,避免互相抢占
下一篇 :首包资源策略