三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Unity Addressables 静默资源策略

Unity Addressables 静默资源策略

在 Unity 项目中,静默资源通常用于将非首屏、非关键路径内容从首包中拆出,以降低初始安装和更新成本。

首包体积下降后,收益不只体现在下载耗时上。它会影响玩家从商店页点击安装到真正进入游戏之间的完整链路:安装等待更短,更新负担更低。对依赖投放获量的项目来说,这些指标最终都会反映到安装成功率、转化成本和新用户进入率上。

更重要的是,很多资源并不是所有玩家一开始都需要。中后期功能、活动变体、主题资源、赛季内容,玩家在新手阶段根本看不到。过早把它们放进首包,本质上是在让所有新用户为少数未来路径提前下载资源;这既增加包体,也没有改善新手第一段体验。

所以静默包的目标不是“让资源下载这件事变得隐形”,而是把资源交付从“所有人一开始都下载”改成“玩家接近需要时再下载”。这个目标成立以后,才轮到我们讨论下载队列、优先级和失败恢复。

但把资源拆成远程包以后,最容易出现的误解是:只要资源不进首包,进入游戏后后台慢慢下载就好了。

但静默加载真正难的地方不在“后台下载”这四个字,而在这些问题:

  • 玩家点到一个功能入口时,资源还没下载完怎么办?
  • 网络中断后,下载队列能不能自动恢复?
  • 一个后台资源失败了,会不会让后续操作永远等不到回调?
  • 所有静默资源都排队下载时,哪些应该先下?
  • 打包侧说它是静默包,运行时是否真的按静默规则处理?

如果这些问题没有统一答案,静默加载就会从“优化首包体积”变成“随机制造卡顿”。所以我更愿意把静默加载看成一套资源调度系统,而不是一个下载开关。

设计目标

一个可维护的静默加载系统至少要满足五个目标。

第一,打包侧和运行时使用同一份资源分层结果。

如果构建脚本认为某个 group 是静默资源,运行时也必须能用同样的规则判断它。否则就会出现资源明明被排除出首包,运行时却不知道该走静默下载;或者运行时认为它是静默资源,打包却把它塞进了首包。

第二,后台下载不能影响前台操作。

玩家正在打开窗口、切换场景或进入玩法时,后台下载应该可以暂停、让路和恢复。否则静默下载会抢占带宽、IO 或 Addressables 操作,让“静默”变成可感知卡顿。

第三,前台依赖必须可恢复。

如果某个功能主动加载资源,而资源还没下载,系统需要进入可重试流程。网络恢复、连接恢复或用户重试后,原本等待的操作必须继续执行,而不是让业务自己到处补回调。

第四,下载顺序必须可解释。

队列不能只按发现顺序随机下载。基础顺序、业务入口优先级、运行时可见性、当前交互,都应该能合成一个稳定的优先级模型。

第五,必须可观测。

静默加载出问题时,开发者需要知道当前正在下载什么、为什么没下载、哪个资源失败、失败后是否还在队列里、当前是否被前台加载打断。

总体架构

可以把系统拆成五层。

构建期资源规划

运行时资源索引

下载调度器

功能入口可见性

前台资源加载

依赖就绪门

Addressables 下载器

本地缓存

网络与连接状态

调试快照

构建期资源规划负责回答“哪些资源不进首包”。运行时资源索引负责回答“一个 address 或 group 是否属于静默资源”。下载调度器负责回答“现在该不该下载、下载谁、失败后怎么排队”。前台资源加载只关心“我的依赖是否已经可用”,不应该直接操作后台队列细节。

这几个职责分开以后,很多边界就会清楚很多。

构建期:先把静默资源变成契约

静默加载的第一步不是写下载器,而是在构建期定义清楚资源契约。

一个 group 进入静默资源集合,意味着:

  • 它不应该进入首包;
  • 它应该上传到远端资源服务;
  • 它在运行时可以通过静默队列下载;
  • 它如果被首包资源静态依赖,应该被构建检查拦截;
  • 它如果被前台功能主动加载,必须走依赖就绪检查。

这个契约最好由统一的规划器合成,而不是散落在多个手写列表里。

常见输入可以包括:

  • 首包必须覆盖的基础模块;
  • 新手期必须可用的功能集合;
  • 多主题、多赛季、多活动这类可以延后下载的变体资源;
  • 手工补充的特殊资源组;
  • 构建期自动扫描出的候选静默组。

最终运行时只消费一个稳定结果:哪些 group 是静默资源,以及它们的基础下载顺序。

Editor 工具链:静默包不是手工列表

静默资源如果只靠手工维护,很快会变成另一种配置债务。

更稳的方式是把静默包管理放到 Editor 工具链里:开发者在工具窗口里看到的是“首包”和“静默包”的结果,而不是直接修改运行时列表。真正的运行时列表由规则、远程配置和手工补充项一起生成。

这类工具通常要解决四件事。

第一,规则生成。

有些资源天然适合用规则管理。例如多主题资源里,前 N 个LobbyTheme可以默认留在首包,后续主题进入静默包;连续章节、赛季变体、活动皮肤这类资源,也可以根据当前配置自动判断哪些是新手期必须可用,哪些可以延后下载。

这类规则不应该散落在打包脚本里。它们应该收敛到一个规划器里,由规划器生成最终的 silent group 集合和基础下载顺序。

第二,配置驱动。

很多“前 N 个”不是写死的工程常量,而是要跟随配置变化。Editor 工具可以在生成前刷新配置,把最新的首包范围、活动映射和主题范围合并进规划结果。

如果配置刷新失败,工具应该中止生成,而不是拿旧缓存覆盖运行时文件。静默资源规划宁愿失败得早一点,也不要生成一个看起来可用、实际已经过期的列表。

第三,预览与快速校验。

工具窗口需要能直接预览哪些 group 会进入首包,哪些 group 会进入静默包。这样资源调整不是靠翻代码确认,而是在生成前就能看到结果。

快速校验也很重要。开发者应该能在 Editor 里主动跑一次静默依赖检查,确认首包资源没有静态依赖静默资源。正式构建时还要再基于 Addressables 的构建报告做一次硬校验:只要非静默 group 依赖了静默 group,就直接让构建失败。

第四,报告与统计。

静默包策略最终会影响包体和下载体感,所以工具还需要能输出资源报告:首包体积、静默包体积、各类资源占比、异常大的 group、以及资源从首包移入静默包后的体积变化。

有了这些报告,团队讨论的就不再是“这个资源感觉应该静默”,而是“这个规则会让首包减少多少、会把多少下载成本移动到运行时、对应功能是否有可接受的等待策略”。

构建期规划可以抽象成下面这段伪代码:

function generateSilentPlan(config, addressableGroups): firstPack = resolveFirstPackGroups(config) candidates = scanDelayableGroups(addressableGroups) silent = candidates - firstPack silent = silent + config.manualSilentGroups silent = silent - config.forceLocalGroups order = keepStableOrder(previousOrder, silent) preview(firstPack, silent, order) writeRuntimeIndex(silent, order)

正式构建前后还需要做两类校验。第一类校验规划结果是否过期;第二类基于构建报告检查真实 bundle 依赖:

function validateBuild(layout, silentGroups): for each bundle in layout.bundles: sourceGroup = resolveGroup(bundle) if sourceGroup in silentGroups: continue for each dependency in bundle.dependencies: targetGroup = resolveGroup(dependency) if targetGroup in silentGroups: failBuild(sourceGroup, targetGroup)

这段校验表达的规则很简单:首包和非静默资源不能静态依赖静默资源。否则资源看起来被规划成“进入游戏后再下载”,实际却会被首包链路提前拉起。

运行时:后台队列只是默认路径

进入游戏后,下载调度器会拿到静默资源队列。它不会立刻无条件下载,而是先判断当前是否适合下载。

典型条件包括:

  • 自动静默下载开关是否开启;
  • 当前是否有网络;
  • 是否正在切场景或加载关键界面;
  • 当前场景是否适合执行后台下载;
  • 是否已经有前台资源加载在等待依赖。

这套判断的意义是:后台下载只在“不打扰玩家”的窗口期运行。

下载前通常会先用Addressables.GetDownloadSizeAsync判断这个 key 是否还有远端依赖。如果返回 0,说明依赖已经在包内、缓存中,或者本身不需要下载;如果大于 0,再调用Addressables.DownloadDependenciesAsync把依赖提前写入 Addressables 缓存。

一个简化流程可以写成这样:

while pendingQueue is not empty: if not canRunBackgroundDownload(): wait(nextCheckInterval) continue key = pickNextKey() result = downloadDependencies(key) if result.succeeded: markReady(key) removeFromQueue(key) else: markFailed(key) moveToQueueTail(key) wait(backoff.nextDelay())

这里有两个细节很关键。

第一,失败资源不要直接丢弃。它应该留下失败状态,并回到队列后面。这样网络短暂抖动不会永久损坏资源状态。

第二,等待间隔不应该永远固定。失败越频繁,重试间隔应该逐步变长;当网络或连接状态恢复时,再把退避状态复位,让资源尽快恢复下载。

下载进度建议从AsyncOperationHandle.GetDownloadStatus读取。PercentComplete更像子操作完成比例,而GetDownloadStatus更适合展示真实字节下载进度。下载或查询结束后,记得用Addressables.Release释放对应 handle。

优先级:稳定顺序 + 运行时提升

静默资源的优先级通常来自两类信息。

第一类是静态优先级。

它来自资源规划阶段:越可能被早期访问、越接近核心路径、越常作为入口资源出现的 group,排序越靠前。这个顺序应该稳定,方便团队理解和调整。

第二类是运行时提升。

当某个功能入口已经展示给玩家,说明玩家下一秒就可能点击它。此时对应资源即使在静态顺序里靠后,也应该被提升到队列前面。

这类提升不一定要等到点击发生。入口可见本身就是一个信号。

onFeatureEntryVisible(featureKey): groupKey = resourceIndex.resolveGroup(featureKey) if groupKey is silent and not ready: scheduler.promote(groupKey)

如果玩家已经点击入口,那优先级还要再上一个等级:从“后台优先下载”变成“前台依赖下载”。

前台加载:必须打断后台队列

静默加载最容易出问题的地方,是业务代码主动加载一个还没下载完的资源。

如果系统只是让 Addressables 自己去下载依赖,业务侧很可能看到几种不稳定表现:

  • 打开窗口一直等,没有统一失败反馈;
  • 下载失败后回调没有按预期执行;
  • 后台队列还在下载别的资源,当前点击资源反而排不上;
  • 网络恢复后资源下载成功了,但原来的打开动作已经丢了。

更稳的做法是给前台加载加一道“依赖就绪门”。这道门通常不应该藏在资源加载函数最深处,而是放在界面入口处:玩家点击某个入口时,入口先检查资源依赖是否就绪;如果没就绪,就弹出下载提示窗,把这次操作转成可见、可重试的前台下载。

这样做有两个好处。第一,玩家不会误以为功能卡死了,他看到的是“当前内容需要下载”的明确反馈,下载完成后还能继续进入。第二,入口层天然知道“下载完成后应该继续打开哪个界面”,不需要把业务回调散落在各个资源加载失败分支里。

这道门的实现仍然可以基于Addressables.GetDownloadSizeAsyncAddressables.DownloadDependenciesAsync:先确认当前资源是否还有远端依赖,缺失时暂停后台队列,把这次请求转成前台依赖下载;下载成功后再继续LoadAssetAsync或实例化流程。

function onFeatureEntryClicked(featureKey): address = resolveEntryAsset(featureKey) if dependenciesReady(address): openFeature(featureKey) return scheduler.pauseBackground() showDownloadPrompt(address) downloadDependenciesWithRetry(address, result => { scheduler.resumeBackground() if result.succeeded: openFeature(featureKey) else: showRetryPrompt(featureKey) })

这段伪代码表达的是职责,而不是具体实现。

入口发现依赖缺失时,应当暂停或打断后台下载,把当前依赖提升到最高优先级。下载成功后继续原本的打开流程。下载失败时,也要把“如何重试”和“重试后继续哪个动作”统一保存下来。

业务模块不应该自己到处写“如果资源为空就延迟一会再试”。那会把恢复逻辑扩散到每个窗口和入口里。

如果项目采用 remote catalog + 首包预置 bundle 的模式,还可以用Addressables.InternalIdTransformFunc做本地优先路径:当 bundle 已经随首包落在本地时,加载路径重定向到本地;没有命中时,再按 catalog 中的远端地址下载。

失败处理:等待恢复,而不是只失败一次

静默资源失败通常不是资源真的不存在,而是网络或连接状态暂时不可用。

因此失败处理要区分三层。

场景后台静默下载前台依赖下载
无网络暂停队列,等待网络恢复保留当前操作,等待恢复或展示可重试状态
短暂下载失败记录失败,退避后重试优先重试当前依赖,必要时提示玩家
资源确实不可用标记失败,进入观测列表明确失败反馈,并允许用户重新触发
前台加载打断后台后台任务释放当前下载,稍后恢复当前依赖获得最高优先级

恢复时机也不应该只有一个。

网络从不可用变为可用,是一个恢复时机。长连接重连成功,也应该是一个恢复时机。应用从后台回到前台,或者资源系统完成初始化,也可以触发一次恢复检查。

恢复时要注意一个细节:退避计数应该被重置。否则玩家从无网环境回到正常网络后,系统可能还在等待一个很长的失败间隔,体感上就像没有恢复。

UI 策略:静默失败不打扰,前台失败要可操作

静默下载失败时,不应该主动弹窗。

玩家没有明确请求这个资源,系统就不应该因为后台任务失败打断当前操作。最多记录状态、延后重试,并在调试面板或日志中可见。

但前台加载失败不同。

玩家已经点击了入口,当前操作依赖这个资源。此时可以有三种策略:

  1. 有基础 fallback:先打开基础界面,缺失部分使用默认皮肤或占位。
  2. 资源必须完整:显示可重试加载状态,让玩家能重新触发。
  3. 资源可延后:先进入功能,把非关键视觉资源继续挂在后台补齐。

这三个策略应该由功能体验决定,而不是由下载器决定。下载器只负责提供“资源是否就绪、下载是否成功、是否可重试”的能力。

不要让静默资源卡死流程

一个常见失败模式是:某个业务流程写成“资源加载完成后执行 action”。当资源缺失触发下载,而下载失败时,这个 action 就再也不会执行。

解决这个问题的重点不是给每个业务 action 单独补超时,而是把资源依赖下载变成可恢复事务。

一个前台资源事务至少要保存:

  • 当前等待的资源 key;
  • 下载状态;
  • 失败次数和下次重试时间;
  • 成功后要继续执行的回调;
  • 用户是否仍然处于同一个业务上下文;
  • 失败后是否允许 fallback。

当网络恢复或连接恢复时,调度器重新唤醒这个事务;下载成功后,它继续调用原本的成功路径。这样业务流程才不会因为一次下载失败永久悬挂。

调试能力:要能回答“为什么没下载”

静默加载的问题往往不是“下载失败”这么简单,而是“为什么它现在没有下载”。

调试快照至少应该能回答:

  • 自动下载是否开启;
  • 当前是否正在下载;
  • 当前下载 key 和进度;
  • 队列里还有多少资源;
  • 每个资源是等待、下载中、失败还是完成;
  • 当前等待原因是什么:无网络、场景加载中、被前台打断、队列为空,还是策略不允许。

这些信息最好能在开发环境里以面板或命令形式查看。只靠日志排查,很容易错过当前状态。

几个容易踩的坑

把静默列表写成手工常量

早期手工维护很快,但模块变多后会失控。更好的方式是让构建期规划器从配置、资源命名规则和 Addressables group 中合成最终结果,再由运行时消费一个稳定产物。

只按 group 名下载,不考虑入口可见性

静态顺序只能表达默认预期,不能表达玩家当前行为。入口已经出现时,对应资源应该提升优先级,否则玩家看到入口却点不开,会比晚一点显示入口更糟。

后台下载不让路

静默下载如果不能被前台加载打断,就会和玩家操作抢资源。只要用户明确点击了某个功能,当前依赖就应该成为最高优先级。

失败后只记录错误

记录错误不是恢复。失败资源要么进入退避重试,要么转为可操作的前台重试状态。否则问题只会从下载器转移到业务流程里。

fallback 没有边界

fallback 适合视觉变体、非关键装饰和可延后内容;不适合核心逻辑资源。否则玩家会进入一个看似可用、实际缺能力的半残状态。

和依赖治理的关系

静默加载依赖前一层资源治理。

如果首包资源静态依赖了静默资源,运行时调度再完善也救不了,因为首包边界已经被破坏。

如果一个静默资源跨模块依赖了一串不相关资源,下载优先级也会变得难以解释,因为用户点击的是一个入口,实际下载的却是一整条陌生链。

所以静默加载解决的是“资源什么时候下载、失败后怎么恢复、如何不打扰玩家”。它不负责修正错误依赖。错误依赖应该在 Editor、构建分析和 CI 阶段被发现。

结果与取舍

这套设计带来的收益很直接:

  • 首包可以更稳定地控制体积;
  • 后台下载不会轻易影响前台操作;
  • 玩家点击功能时,资源依赖有统一的等待和恢复路径;
  • 网络抖动后,系统能继续推进,而不是把恢复逻辑交给每个业务模块;
  • 调试时可以看到队列、状态和等待原因。

代价也存在。

第一,资源规划必须更严谨。哪些进首包、哪些静默、哪些作为 fallback,需要产品、技术和资源制作流程共同维护。

第二,下载器不再是简单循环,而是一个有状态调度器。它要处理暂停、恢复、提升、失败、退避和前台事务。

第三,业务入口需要给调度器提供信号。入口可见、入口点击、窗口打开失败,这些状态都应该被系统感知。

总结

静默加载不是“偷偷把资源下完”,而是把资源交付从一次性首包,拆成可规划、可调度、可恢复的运行时过程。

我认为比较稳的原则是:

  1. 构建期先定义清楚静默资源契约。
  2. 运行时后台下载只作为默认路径。
  3. 玩家当前操作依赖的资源永远最高优先级。
  4. 失败必须进入可恢复状态,而不是只打日志。
  5. 下载顺序要能解释,等待原因要能观察。

当这些原则成立后,静默加载才真正从“省包体的技巧”变成“可运营项目里的资源交付能力”。

← 返回列表