热更新
热更新让游戏只发布一次,之后从 CDN 换掉它的资产——一张贴图、一段音效、一个预制体——运行中的客户端便能拉取新字节,无需重新下载安装包,也无需重新提交到商店。这正是已上架的手游或小游戏在过审后继续更新内容的方式。
Estella 的热更新是内容寻址、原子(要么全成、要么回滚)、完整性校验(被篡改的字节会被拒绝)且对游戏代码透明(内建重绑器会替你把新资产换进运行中的精灵)。同一套机制在 Web、桌面、微信小游戏与原生上一致运行。
每个 cook 后的资产都以 <contentHash>.<ext> 的形式发布——这是一个不可变、可永久缓存的 URL。改动一个字节,哈希就变,资产便成为一个新 URL;旧的 URL 从此不再被引用。任何文件都不会被覆盖,因此缓存永远不会失效。
于是一次热更新就是四步:
- 从 CDN 拉取远端清单(
asset-manifest.json)。 - 按内容哈希把它与游戏正在运行的清单做 diff。
- 只下载哈希变化的那些资产(并逐一校验)。
- 切换活动清单——引用随即解析到新 URL。
因为资产是内容寻址的,diff 就是一次纯粹的逐资产哈希比较,应用它绝不会破坏已缓存的文件。整条链路平台无关:同一份 asset-manifest.json 驱动所有目标平台。
哪些资产打进包内、哪些从 CDN 提供,是一个按文件夹的决策,称为资产的交付组。每个文件夹都归结为三种模式之一:
| 模式 | Bundle | 徽标 | 交付方式 | 适用于 |
|---|---|---|---|---|
| 本地 | eager | — | 打进包内,启动时加载 | 游戏的核心资产 |
| 分包 | lazy | 分包(紫) |
打进包内,按需加载 | 大体量可选内容、微信分包 |
| 远端 | remote | CDN(蓝) |
从 CDN 提供,可热更新 | 任何你想在发布后改动的内容 |
只有远端组会被热更新——它们是按环境根地址从你的 CDN 拉取的资产。本地与分包资产都被烘进构建里。(分包是包内的按需加载,见 资源 → 寻址组。)
把文件夹标为 CDN 交付
Section titled “把文件夹标为 CDN 交付”在内容浏览器中右键任意文件夹,选择 交付方式 → 远端(CDN / 热更)。这就是全部的配置动作——该文件夹下的每个资产(递归)从此改由 CDN 交付,而不再打进包内。

内容浏览器 → 右键文件夹 → 交付方式 → 本地 / 分包 / 远端(CDN / 热更)。选择会写入 .esengine/asset-groups.json。
同一个子菜单里还有总是打进构建——一个独立开关,即使文件夹里没有任何东西能从场景可达, 也让它留在构建里。见资源。
被指派到分包或远端组的文件夹会带一个角标徽标,让你一眼看清交付布局——CDN(蓝)表示远端,分包(紫)表示分包。

cdn 文件夹带着蓝色 CDN 徽标——它的资产以远端方式交付,可被热更新。
交付决策与文件夹名解耦——任意普通文件夹都能成为远端组。(旧约定中以 remote/<name>/ 或 subpackages/<name>/ 文件夹名隐含交付方式的做法仍然有效,作为项目没有 asset-groups.json 时的零配置回退。)
设置 CDN 根地址
Section titled “设置 CDN 根地址”远端组资产需要一个根 URL 来拉取——你的 CDN。它是一个构建 profile 设置,因此 dev 与 prod 可以指向不同的 CDN,导出时会把活动 profile 的根地址烘进发布的游戏里。在打包项目对话框(文件 → 构建…)的高级里设置:

CDN 地址 字段会把活动 profile 的 remoteRoot 写入 asset-groups.json。留空则远端资产从游戏同源加载(例如本地测试时)。
.esengine/asset-groups.json
Section titled “.esengine/asset-groups.json”以上两项设置都存在同一个授权文件里,它是 cook 与编辑器 Play 域通过同一个 resolver 读取的单一事实来源(因此两者绝不会对资产的交付去向产生分歧):
{ "version": "1.0", "groups": { "cdn": { "folder": "assets/cdn", "mode": "remote" } }, "activeProfile": "dev", "profiles": { "dev": { "remoteRoot": "" }, "prod": { "remoteRoot": "https://cdn.example.com/my-game" } }}| 字段 | 类型 | 说明 |
|---|---|---|
version |
string |
配置版本("1.0")。 |
groups |
Record<name, {folder, mode}> |
文件夹 → 交付方式的指派。 |
groups.<name>.folder |
string |
项目相对文件夹;其下(递归)每个资产都归入该组。文件夹嵌套时,最长匹配前缀胜出。 |
groups.<name>.mode |
'local' | 'subpackage' | 'remote' |
交付模式。 |
groups.<name>.alwaysInclude |
boolean |
即使构建里没有任何东西可达到它,也照样发布这个组——用于只有你的代码点名的资产(url、路径、富文本标记)。默认关闭。见资源。 |
activeProfile |
string |
构建使用哪个 profile(profiles 的键)。 |
profiles.<name>.remoteRoot |
string |
该 profile 的远端组资产的 CDN 根地址。留空 ⇒ 同源。 |
构建产出了什么
Section titled “构建产出了什么”每次 Web / 桌面导出都会在游戏旁写出一份 asset-manifest.json(可寻址清单)——那正是客户端做热更新 diff 的依据。当活动 profile 设有 CDN 根地址时,导出还会把一段 hotUpdate 烘进 game.config.json:
{ "hotUpdate": { "remoteRoot": "https://cdn.example.com/my-game", "persistUpdateKey": "esengine:hotupdate" }}发布的运行时会读取它,并在启动时自动接线:
- 应用
remoteRoot,于是远端组的@uuid引用解析到 CDN。 persistUpdateKey(只要设了根地址就默认为esengine:hotupdate)让运行时在启动时恢复此前已应用的更新——回访玩家直接从已更新的内容开始,即使离线。- 安装内建重绑器,于是一次已应用的更新会自动换进运行中的精灵,无需游戏代码。
你发布一次更新的方式,是把新 cook 出的资产连同新的 asset-manifest.json 上传到 CDN。因为 URL 是内容寻址的,上传纯粹是增量的——旧文件对尚未更新的客户端依然有效。
在运行时触发更新
Section titled “在运行时触发更新”运行时已自动配置好,但总得有什么来发问「有没有更新?」。那是游戏发起的一次调用——在启动时、藏在检查更新按钮后,或用定时器。
import { defineSystem, Res, Schedule, Assets } from 'esengine';
async function pullUpdate(assets: Assets): Promise<void> { // 1. 拉取 CDN 清单并与运行中的清单做 diff(不下载任何东西)。 const plan = await assets.checkForUpdate({ manifestUrl: 'https://cdn.example.com/my-game/asset-manifest.json', remoteRoot: 'https://cdn.example.com/my-game', }); if (!plan.hasUpdate) return; console.log(`有可用更新:${plan.changedAssets.length} 个文件,${(plan.totalBytes / 1024) | 0} KB`);
// 2. 下载并校验每个变更资产,然后切换清单——原子完成。 const result = await assets.applyUpdate((loaded, total) => { console.log(`下载中 ${loaded}/${total}`); }); if (result.ok) console.log(`已应用——更新了 ${result.updated} 个资产`); else console.warn('更新失败,已回滚:', result.failed);}
// 在启动时运行一次(加锁,让这次异步检查只触发一次)。let checked = false;const checkForUpdates = defineSystem([Res(Assets)], (assets) => { if (checked) return; checked = true; void pullUpdate(assets);});// app.addSystems(Schedule.Update, checkForUpdates)checkForUpdate 是「有没有更新、有多大?」的查询——它拉取候选清单并返回一份计划,但不下载任何资产,因此你可以在提交前弹出提示(N 个文件,K KB)。applyUpdate 才真正下载并切换。
当场景以 @uuid 引用某个资产、而该资产位于远端组时,applyUpdate 会全部搞定——它下载新字节、切换清单,内建重绑器随即把旧贴图换进屏幕上每一个运行中的 Sprite 与 Mesh2D。画面自行改变,场景作者什么都不用写。(hot-update-demo 示例的游戏代码字面上是空的。)
对于内建重绑器覆盖不到的东西——你手动绑定的贴图、一段音效、一个自定义系统——订阅 onInvalidate 自行重绑:
const unsub = assets.onInvalidate((ref) => { // `ref` 刚被一次更新失效——重新加载它,并在你用到它的地方重绑。 void assets.loadTexture(ref).then((tex) => { /* 赋值 tex.handle */ });});API 参考
Section titled “API 参考”热更新接口位于 Assets 资源上(Res(Assets)):
| 方法 | 返回 | 说明 |
|---|---|---|
checkForUpdate(options) |
Promise<UpdatePlan> |
拉取候选清单,与活动清单 diff,并暂存。不下载任何资产。 |
applyUpdate(onProgress?) |
Promise<ApplyUpdateResult> |
原子地应用暂存的更新:下载并校验每个变更资产,然后切换清单、重绑运行中的句柄、持久化。onProgress(loaded, total) 回报下载进度。 |
restorePersistedUpdate(key) |
boolean |
在启动时,恢复上一次 applyUpdate 以 key 持久化的清单。设了 persistUpdateKey 时运行时会替你调用。 |
setRemoteRoot(url?) |
void |
把远端组解析指向某个 CDN 根地址(undefined 清除 → 同源)。运行时会从构建的 remoteRoot 设置它。 |
remoteRoot |
string | undefined |
当前 CDN 根地址(getter)。 |
onInvalidate(listener) |
() => void |
订阅逐引用的失效(用于自定义重绑)。返回一个取消订阅的函数。 |
loadGroup(name, onProgress?) |
Promise<AssetBundle> |
按需加载整个组(DLC 模式——见下文)。 |
releaseGroup(name) |
void |
释放 loadGroup(name) 获取的一切。 |
CheckForUpdateOptions
| 字段 | 类型 | 说明 |
|---|---|---|
manifestUrl |
string |
候选(远端)清单 JSON 的 URL。 |
remoteRoot |
string(可选) |
候选清单的远端组资产从哪个 CDN 根地址提供。默认沿用当前根地址。 |
UpdatePlan(checkForUpdate 返回)
| 字段 | 类型 | 说明 |
|---|---|---|
hasUpdate |
boolean |
当且仅当有任何资产新增或内容变化时为真。 |
changedAssets |
AssetChange[] |
需要下载的新增 / 内容变化资产。 |
removedAssets |
AssetChange[] |
此前存在、现已消失的资产(仅供参考;从不下载)。 |
changedGroups |
string[] |
拥有 ≥1 个变更资产的不同组。 |
totalBytes |
number |
changedAssets 大小之和——进度 UI 的下载量估计。 |
fromRevision / toRevision |
string | null |
旧 / 新清单修订号。 |
ApplyUpdateResult(applyUpdate 返回)
| 字段 | 类型 | 说明 |
|---|---|---|
ok |
boolean |
仅当每个变更资产都下载 + 校验通过且清单已切换时为真。 |
updated |
number |
应用了多少个资产(失败时为 0)。 |
failed |
AssetDownloadFailure[] |
为何回滚——每项 { path, reason: 'fetch' | 'integrity' }。 |
按需组(DLC)
Section titled “按需组(DLC)”除了热更新,远端组也是一条可下载内容通道:游戏可以在玩家到达某内容支撑的区域时拉取整个组,离开时释放。
// 玩家进入「第二世界」——从 CDN 拉取它的远端组。const bundle = await assets.loadGroup('world2', (loaded, total) => { showProgress(loaded / total);});// ……稍后玩家离开:assets.releaseGroup('world2');loadGroup 通过与其它一切相同的类型化加载器暖起缓存,releaseGroup 按引用计数释放——被另一个场景仍持有的资产会存活。完整的组模型见 资源 → 寻址组。
完整性、原子性与回滚
Section titled “完整性、原子性与回滚”applyUpdate 是两相、要么全成要么全不的:
- 下载 + 校验。 每个变更资产都被拉取,其字节被哈希并与清单的
contentHash比对。下载失败、或字节不匹配(被破坏或篡改的 CDN 响应),都记为一次失败。此阶段不触碰活动清单。 - 提交。 只有当每个资产都成功时,更新才提交——切换清单与根地址、重绑运行中的句柄、持久化新清单。
若第 1 相出现任何失败,则什么都不应用:旧清单保持活动(干净回滚),applyUpdate 返回 { ok: false, failed }。半应用的更新不可能出现。
持久化与离线
Section titled “持久化与离线”当设了 persistUpdateKey 时(导出会自动设置),applyUpdate 会把已应用的清单存入平台存储。下次启动时运行时调用 restorePersistedUpdate(key),玩家便直接从已更新的内容开始——哪怕没有网络。在原生与小游戏目标上,校验过的字节还会写入一个内容寻址的落盘缓存,于是已更新的资产离线加载而无需再次访问 CDN。
| 目标 | 清单 + diff | CDN 拉取 | 持久化 | 落盘缓存 |
|---|---|---|---|---|
| Web | ✅ | ✅(受 CORS/CSP 约束) | localStorage |
浏览器 HTTP 缓存 |
| 桌面 | ✅ | ✅ | ✅ | ✅(内容寻址) |
| 微信小游戏 | ✅ | ✅ | ✅ | ✅ |
| 原生(iOS / Android) | ✅ | ✅ | ✅ | ✅ |
清单 diff 是纯粹且平台无关的;只有拉取、存储与落盘缓存原语按平台不同,运行时会挑选正确的那一个。
- 把可热更的内容放进独立文件夹并标为远端——核心游戏保持本地,让安装包无网也能启动。
- 给检查加锁,藏在启动时的一次性开关或「检查更新」按钮后;不要每帧调用
checkForUpdate。 - 展示体积。
checkForUpdate在你提交前就给出totalBytes与文件数——在移动数据网络下大下载前提示玩家。 - 处理
ok: false。 失败的更新会干净回滚;稍后重试,而不是假定成功。 - 善用 profile。 让
dev指向测试桶(或留空走同源)、prod指向真正的 CDN,这样构建就会自动发布正确的根地址。 - 用清单 URL 做版本。 灰度发布 / A-B 通道无非是不同的
manifestUrl——给不同客户端发同一 CDN 上不同的清单。