YooAsset案例教程—太空战机DEMO讲解

择一业,爱一生

目录

Editor

[1. MainAssetCollector(主资源收集器)](#1. MainAssetCollector(主资源收集器))

[2. DependAssetCollector(依赖资源收集器)](#2. DependAssetCollector(依赖资源收集器))

[3. StaticAssetCollector(静态资源收集器)](#3. StaticAssetCollector(静态资源收集器))

分层分工(行业标准最优分层)

分包策略选择规律

Runtime

[Boot ------ 一切的起点](#Boot —— 一切的起点)

[FSM 状态机 ------ Patch 的骨架](#FSM 状态机 —— Patch 的骨架)

[Event 系统 ------ 真正的"解耦灵魂"](#Event 系统 —— 真正的“解耦灵魂”)

[PatchWindow ------ 玩家眼中的"热更系统"](#PatchWindow —— 玩家眼中的“热更系统”)

[GameManager 对外唯一话事人](#GameManager 对外唯一话事人)

热更框架全流程:

[1. 底层:YooAsset 资源库(第三方)](#1. 底层:YooAsset 资源库(第三方))

[2. 核心调度层(热更流程中枢)](#2. 核心调度层(热更流程中枢))

[3. 全局业务管理层](#3. 全局业务管理层)

[4. 表现层 UI](#4. 表现层 UI)

[5. 程序入口](#5. 程序入口)

游戏启动全链路

[阶段 1:游戏初始化(Boot)](#阶段 1:游戏初始化(Boot))

[阶段 2:资源包初始化(FsmInitializePackage)](#阶段 2:资源包初始化(FsmInitializePackage))

[阶段 3:请求远端版本号(FsmRequestPackageVersion)](#阶段 3:请求远端版本号(FsmRequestPackageVersion))

[阶段 4:下载资源清单 Manifest(FsmUpdatePackageManifest)](#阶段 4:下载资源清单 Manifest(FsmUpdatePackageManifest))

[阶段 5:统计需要更新的资源(FsmCreateDownloader)](#阶段 5:统计需要更新的资源(FsmCreateDownloader))

[阶段 6:资源下载(FsmDownloadPackageFiles)](#阶段 6:资源下载(FsmDownloadPackageFiles))

[阶段 7:下载收尾 + 清理缓存](#阶段 7:下载收尾 + 清理缓存)

[阶段 8:进入游戏主流程(FsmStartGame)](#阶段 8:进入游戏主流程(FsmStartGame))

[阶段 9:场景切换(游戏内)](#阶段 9:场景切换(游戏内))

[1. 简述这套「UniEvent + FSM + YooAsset」热更新整体架构分层,每层职责是什么?](#1. 简述这套「UniEvent + FSM + YooAsset」热更新整体架构分层,每层职责是什么?)

[2. 为什么要用 UniEvent 事件总线解耦?直接调用方法不好吗?说出 3 个优势。](#2. 为什么要用 UniEvent 事件总线解耦?直接调用方法不好吗?说出 3 个优势。)

[3. FSM 有限状态机相比 if/while 顺序流程写热更,优势在哪?](#3. FSM 有限状态机相比 if/while 顺序流程写热更,优势在哪?)

[4. YooAsset 四种运行模式(EPlayMode)分别适用什么场景?初始化有什么核心区别?](#4. YooAsset 四种运行模式(EPlayMode)分别适用什么场景?初始化有什么核心区别?)

[5. 看代码:FsmDownloadPackageFiles 里给 downloader 绑定 DownloadError、DownloadProgressChanged 委托,会存在什么隐患?如何修复?](#5. 看代码:FsmDownloadPackageFiles 里给 downloader 绑定 DownloadError、DownloadProgressChanged 委托,会存在什么隐患?如何修复?)

[6. 框架中为什么所有临时数据(PackageName、Downloader、PackageVersion)存在 FSM 黑板,不用静态全局变量?](#6. 框架中为什么所有临时数据(PackageName、Downloader、PackageVersion)存在 FSM 黑板,不用静态全局变量?)

[7. 代码里 GameManager 统一托管所有协程,不把 Mono 传给 FSM,设计目的是什么?](#7. 代码里 GameManager 统一托管所有协程,不把 Mono 传给 FSM,设计目的是什么?)

[8. 区分两类事件 PatchXXXEvent / UserXXXEvent,设计上为什么要严格分开,不能混用?](#8. 区分两类事件 PatchXXXEvent / UserXXXEvent,设计上为什么要严格分开,不能混用?)

[9. PatchWindow 里封装 MessageBox 对象池,为什么不直接 Instantiate/Destroy 弹窗?](#9. PatchWindow 里封装 MessageBox 对象池,为什么不直接 Instantiate/Destroy 弹窗?)

[10. 游戏热更时卡在 "请求版本号" 无响应,从这套框架出发,分模块排查思路?](#10. 游戏热更时卡在 “请求版本号” 无响应,从这套框架出发,分模块排查思路?)

[11. 用户反馈重复下载资源,每次启动游戏都全量更新,结合框架和 YooAsset 分析 3 种可能原因?](#11. 用户反馈重复下载资源,每次启动游戏都全量更新,结合框架和 YooAsset 分析 3 种可能原因?)

[12. 弱网环境下,单个 Bundle 下载失败后弹窗重试,但是重试后依旧全部重新下载,是什么问题?如何改造?](#12. 弱网环境下,单个 Bundle 下载失败后弹窗重试,但是重试后依旧全部重新下载,是什么问题?如何改造?)

[13. 现有框架只支持单分包热更,如果要求实现多分包并行热更(主包 + DLC 分包),这套架构哪些地方需要改造?](#13. 现有框架只支持单分包热更,如果要求实现多分包并行热更(主包 + DLC 分包),这套架构哪些地方需要改造?)

[14. 现有框架缺少「下载暂停 / 继续 / 取消」功能,基于现有 FSM + 事件架构,如何最小改动扩展?](#14. 现有框架缺少「下载暂停 / 继续 / 取消」功能,基于现有 FSM + 事件架构,如何最小改动扩展?)

[游戏 / 资源热更场景常见 FSM 状态机设计模式?](#游戏 / 资源热更场景常见 FSM 状态机设计模式?)


我们先来解析看看官方给的案例如何资源管理的吧!

Editor

具体来看看官方都做了哪些操作吧,也当作复习上篇文章的知识点

彻底搞懂YooAsset!(资源管理!热更新框架!)-CSDN博客

  • Show Package

    是否展示资源包列表视图。

  • Show Editor Alias

    是否显示为中文模式。

  • Unique Bundle Name

    资源包名追加PackageName作为前缀。

它这里都是默认空着,确实项目中也只有一个packages

  • Enable Addressable(启用可寻址资源定位系统)

它开启了,意思是可以用短地址 / 别名加载(按你配置的寻址规则)

按你配置的寻址规则的意思就是按照如下 ,你配置的

  • Location To Lower资源定位地址大小写不敏感)

它没开

Include Asset GUID资源清单里包含资源 GUID 信息)

它没开:

  • 只靠路径 / 别名匹配,改名就找不到
  • Auto Collect Shaders(将所有着色器构建到独立的资源包内)

它开了

  • File Ignore Rule(文件全局忽略规则)

它选的是NormalIgnoreRule(适配常规资源构建管线)

一个包它分为了四个组,都启用了分组激活------

它决定了这个分组在打包时是否生效,控制整个 Group 里的资源要不要被收集、打包。

EnableGroup启用分组

  • 打包时,这个 Group 下的所有 Collector 都会被执行,资源会被收集并打包。

CollectorType收集器类型:

  • MainAssetCollector 主资源收集器
  • StaticAssetCollector 静态资源收集器
  • DependAssetCollector 依赖资源收集器

AddressRuleName可寻址规则(配合 Enable Addressable 开关):

  • AddressDisable 禁用可寻址,只能用完整资源路径加载。
  • AddressByFileName(新手首选) 用不带后缀的文件名当寻址名,例:Assets/UI/Icon.png → 寻址名 Icon 代码直接 LoadAsset<Sprite>("Icon"),写法最简。
  • AddressByGroupAndFileName 分组名 + 文件名,例:ui_Icon,适合多分组重名资源防冲突。
  • AddressByFolderAndFileName 文件夹名 + 文件名,层级区分更细。

PackRule 打包规则(决定文件如何合并成 AB 包)

  • PackSeparately 每个文件单独打成一个包。 适用:独立大资源、极少改动的资源。
  • PackDirectory 按子文件夹分包,一个子目录一个包。
  • PackTopDirectory(常用) 按顶级目录分包,收集路径下第一层文件夹各自成包。
  • PackCollector 当前收集器下所有文件合并成一个大包。 适用:整体更新的模块,不建议大目录使用(改动一个文件全包更新)。
  • PackGroup 整个 Group 下所有收集器的资源合并为一个包。
  • PackRawFile 原生文件打包(.json/.csv/.txt 等),搭配 RawFileIgnoreRule 使用。
  • PackShader / PackShaderVariants 专门打包着色器 / 着色器变种,配合全局 Auto Collect Shaders 使用。

FilterRule 过滤规则(筛选要收集的文件)

  • CollectAll :收集目录内所有资源(通用默认)
  • CollectScene :仅收集场景 .unity 文件
  • CollectPrefab :仅收集预制体 .prefab
  • CollectSprite:仅收集精灵图
  • CollectShader:仅收集着色器
  • CollectShaderVariants

我们来探索一下官方的配置吧:

特效和实体和UI面板:

MainAssetCollector 主资源收集器

AddressByFileName

PackSeparately 每个文件单独打成一个包

CollectPrefab :仅收集预制体 .prefab

音乐和图集:

MainAssetCollector 主资源收集器

AddressByFileName

PackSeparately 每个文件单独打成一个包

CollectAll :收集目录内所有资源

特效材质和实体材质和UI面板美术和UI面板字体:

DependAssetCollector 依赖资源收集器

AddressByFileName

PackDirectory 按子文件夹分包,一个子目录一个包。

CollectAll :收集目录内所有资源

着色器

StaticAssetCollector 静态资源收集器

AddressByFileName

PackShader

CollectShader:仅收集着色器

着色器变体

StaticAssetCollector 静态资源收集器

AddressByFileName

PackShaderVariants

CollectShaderVariants

场景

StaticAssetCollector 静态资源收集器

AddressByFileName

PackSeparately 每个文件单独打成一个包

CollectScene :仅收集场景 .unity 文件

场景美术

DependAssetCollector 依赖资源收集器

AddressByFileName

PackDirectory 按子文件夹分包,一个子目录一个包。

CollectAll :收集目录内所有资源

UI精灵图片

StaticAssetCollector 静态资源收集器

AddressByFileName

PackDirectory 按子文件夹分包,一个子目录一个包。

CollectSprite:仅收集精灵图

1. MainAssetCollector(主资源收集器)

资源写入资源清单 Manifest ,运行时可以用 LoadAssetAsync("短地址") 直接加载。 游戏里你业务代码主动加载的东西全部放这里:UI 面板、特效预制体、实体角色、音乐、图集、场景文件。

2. DependAssetCollector(依赖资源收集器)

不写入清单,不能直接代码加载,只会被主资源自动依赖引用打包。 专门放材质、贴图、场景美术素材这类「被 Prefab / 场景间接引用的资源」,避免主包重复冗余。 只有当某个 Main 资源引用它,它才会打进包;没任何主资源引用则自动剔除不打包。

3. StaticAssetCollector(静态资源收集器)

不写入清单,不能直接代码加载,强制参与打包,不会被自动剔除。 专门放 Shader、Shader 变体、UI 精灵图这类全局共用底层资源,不管有没有引用都强制打包。

PackRule 分包规则(怎么打成 AB 包)

  • PackSeparately:每个文件单独一个 AB 包
  • PackDirectory:按子文件夹分包,一个文件夹合并成一个 AB
  • PackShader:所有 Shader 统一打包到专用着色器包

接下来,我为你逐段解释你这份配置的设计思路

1. 特效、实体、UI 面板、音乐、图集 ------ MainAssetCollector + PackSeparately

  • 为什么用 Main :这些是业务代码主动加载的核心资源(UI 面板、角色预制体、特效、背景音乐、图集),必须写入清单,支持短地址加载。
  • 为什么 PackSeparately 单文件分包
    • UI、活动面板、角色、特效经常局部更新,玩家只需要下载改动的单个 AB,不用整目录大包;
    • 资源粒度细,卸载精准,不用加载整个文件夹一堆不用的资源,减少内存占用。
  • CollectPrefab:只抓预制体,过滤掉里面的贴图、材质(交给 Depend 收集器统一管理);音乐图集用 CollectAll,目录内音频、图集全部纳入主资源。

2. 特效材质、实体材质、UI 字体、场景美术 ------ DependAssetCollector + PackDirectory

  • 为什么 Depend :材质、字体、场景贴图不会在代码直接 Load,只会被 UI / 角色 / 场景预制体自动依赖;放到 Depend 里,YooAsset 自动分析依赖,自动打包,不会重复复制到每个主包造成冗余。
  • 为什么 PackDirectory 按文件夹合并分包: 同一模块材质高度复用(比如 UI 全部字体、怪物全部材质),合并成一个包,减少 AB 文件数量,降低 IO 开销; 且材质很少单独更新,整目录打包不影响热更体积。
  • 不会写入清单,代码不能 LoadAssetAsync("mat_skin") 加载材质,只能加载预制体自动带出材质。

3. 着色器、着色器变体 ------ StaticAssetCollector + PackShader

  • 为什么 Static :Shader 是全局底层资源,所有材质都依赖,强制打包,哪怕没任何资源引用也要打进包;不写入清单,不能代码直接加载。
  • 专用 PackShader 规则:Unity Shader 打包有特殊要求,统一合并到独立着色器大包,避免多包重复 Shader、渲染报错、变体丢失。
  • 全局常驻资源,游戏启动就加载,几乎不更新,单独打包方便整包缓存。

4. 场景文件 ------ StaticAssetCollector + PackSeparately

  • 场景用 Static 而非 Main:场景体积巨大,独立静态打包;也可以用 Main,这里是项目选型(场景不做短地址直接加载,用场景加载接口)。
  • PackSeparately 单场景分包:关卡独立更新,切换关卡只加载对应场景 AB,不加载其他关卡资源。

5. UI 精灵图片 ------ StaticAssetCollector + PackDirectory

  • 精灵图是图集 / UI 面板的底层依赖贴图,全局共用,强制打包,放 Static;
  • 按文件夹合并分包:同 UI 模块的精灵合并,减少包数量,贴图极少单独更新。

分层分工(行业标准最优分层)

  1. 业务可加载层(Main):玩家代码主动加载的预制体、UI、音乐、图集 → 单文件分包,方便热更、精准卸载,支持短地址寻址。
  2. 依赖素材层(Depend):材质、场景贴图、字体 → 自动跟随主资源打包,文件夹合并分包,去冗余。
  3. 底层基础层(Static):Shader、精灵贴图、场景 → 强制全局打包,按资源特性选择合并 / 单独分包,不暴露给代码直接加载。

分包策略选择规律

  1. 频繁单独更新、需要精准卸载 → PackSeparately(UI 面板、角色、特效、场景)
  2. 高度复用、极少单独更新 → PackDirectory(材质、贴图、字体)
  3. 特殊底层渲染资源 → PackShader(着色器专用打包规则)

避坑:

  • 改动任意收集器规则后,必须重新收集 + 构建资源包,新寻址、分包规则才会生效;
  • Main 收集器内不能存在同名文件(AddressByFileName 会地址冲突,打包报错)

接下来我们看他的代码怎么写的吧!

Runtime

我尽量就不给大家贴大篇幅代码了,说一下思路吧

Boot 点火 → PatchManager 指挥 → FSM 状态机推进 → YooAsset 干活 → Event 广播 → PatchWindow 翻译给玩家看

Boot ------ 一切的起点

Boot(程序启动入口 Mono,游戏第一个执行脚本)

执行顺序(Start 方法)

  1. 设置帧率、后台运行、永不销毁自身
  2. 初始化 UniEvent 事件框架、YooAsset 资源框架
  3. 加载并实例化 PatchWindow 热更 UI
  4. 创建 PatchManager 状态机、启动热更流程

帧更新

Update()每帧驱动PatchManager.Update(),让 FSM 持续运转

FSM 状态机 ------ Patch 的骨架

所有节点实现IStateNode,由PatchManager创建并管理状态切换,状态之间通过黑板 Blackboard 共享数据 流转顺序(标准热更流程):

FsmInitializePackage 异步初始化资源包,运行模式选择对应的文件系统策略


FsmRequestPackageVersion 本状态的唯一职责:问服务器:"当前最新的资源版本号是多少?"

FsmUpdatePackageManifest 从服务器下载并加载"资源清单(Manifest)

FsmCreateDownloader 计算需要下载的资源 这一步不做下载,只做"算账"
├─ 无更新 → FsmStartGame
└─ 有更新 → 等待 UI 确认
↓(用户点击)
FsmDownloadPackageFiles 这个状态负责"真正开始下载资源"

FsmDownloadPackageOver "资源包下载完成"时的一个状态节点

FsmClearCacheBundle 异步清理 YooAsset 中未被使用的缓存 Bundle

FsmStartGame 进入启动游戏状态

每个 FSM 职责与对外交互

  1. FsmInitializePackage
    • 职责:根据PlayMode区分编辑器 / 单机 / 热更 / 微信小游戏 / WebGL,初始化 YooAsset 资源包
    • 输出:初始化失败发送PatchInitializeFailedEvent;成功切下一状态
    • 依赖:黑板PackageNamePlayMode;调用 YooAsset 初始化 API
  2. FsmRequestPackageVersion
    • 职责:请求 CDN 上最新资源版本号,存入黑板
    • 输出:请求失败发送PatchPackageVersionRequestFailedEvent;成功切更新清单状态
  3. FsmUpdatePackageManifest
    • 职责:用远端版本号下载资源清单 Manifest
    • 输出:下载失败发送PatchPackageManifestUpdateFailedEvent;成功进入计算下载列表
    • 依赖:GameManager提供协程运行器执行异步下载
  4. FsmCreateDownloader
    • 职责:对比本地缓存和 Manifest,生成下载器,统计需下载文件数量大小
    • 输出:有更新则发送PatchFoundUpdateFilesEvent(UI 弹窗询问玩家);无更新直接跳清理缓存
    • 数据输出:下载器存入黑板供下载状态使用
  5. FsmDownloadPackageFiles
    • 职责:真正执行资源下载,绑定下载进度 / 错误回调
    • 回调绑定:
      • 下载进度 → PatchDownloadUpdatedEvent
      • 单文件下载失败 → PatchWebFileDownloadFailedEvent
    • 依赖:黑板的 Downloader;GameManager 协程运行下载逻辑
  6. FsmDownloadPackageOver
    • 职责:下载完成过渡节点,打印完成提示,直接跳转清理缓存
  7. FsmClearCacheBundle
    • 职责:异步清理本地无用旧 Bundle 缓存,释放存储空间
    • 完成后进入启动游戏状态
  8. FsmStartGame
    • 职责:热更全部流程结束,把资源包交给 GameManager,发送切主城场景事件

Event 系统 ------ 真正的"解耦灵魂"

两类 Event(一定要分清)

类型 命名特征 谁发 谁听
系统事件 PatchXXXEvent FSM / YooAsset PatchWindow
用户事件 UserTryXXXEvent UI / Retry PatchManager

事件流向示意

FSM

└─PatchFoundUpdateFilesEvent

PatchWindow

└─ 用户点 OK

UserBeginDownloadWebFilesEvent

PatchManager

FSM

FSM 永远不直接碰 UI

UI 永远不直接碰 FSM

看一下在代码中如何实现的吧!!!

PatchFoundUpdateFilesEvent

在这里监听着,只要监听到有东西发送了

具体执行的是以下逻辑:告诉弹窗,你需要给用户什么提示

UserBeginDownloadWebFilesEvent

监听事件

PatchWindow ------ 玩家眼中的"热更系统"

(UGI 热更 UI 窗口,Mono)

PatchWindow 的职责边界

只做三件事:

  1. 监听 PatchXXXEvent

  2. ****显示进度 / 弹窗

  3. 把玩家操作转成 UserTryXXXEvent

绝不:

  • 操作 YooAsset

  • 切换 FSM

  • 执行业务逻辑

单向通信逻辑

  1. 接收(FSM→UI)EventGroup监听所有 Patch 开头的流程反馈事件
    • 收到进度事件:更新 Slider 进度、文字提示
    • 收到各类失败 / 发现更新事件:弹出 MessageBox 弹窗,展示提示文字
  2. 发送(UI→PatchManager) :弹窗点击 OK 后,发送对应UserTryXXX事件,通知 PatchManager 切换 FSM 状态

内部封装

MessageBox弹窗对象池,复用弹窗物体,避免频繁实例化销毁;OnDestroy自动清空所有事件监听,防内存泄漏

我们来看一下我们的

GameManager 对外唯一话事人

两大核心能力

  1. 资源包统一管理:缓存主ResourcePackage,对外提供加载场景接口

  2. 协程托管:持有 MonoBehaviour 运行器,给 FSM 提供协程执行能力(YooAsset 异步操作需要协程)

事件监听

内部EventGroup监听SceneChangeToHomeEvent/SceneChangeToBattleEvent,收到事件后调用资源包加载对应场景

依赖注入

Boot理论上需要调用GameManager.Instance.SetBehaviour(this)注入协程载体(代码里注释了);FsmUpdatePackageManifest等状态直接调用GameManager.Instance.StartCoroutine执行异步逻辑

UI / 玩法系统
↓(只认识 GameManager)
GameManager

YooAsset / Coroutine / Event

Patch / 资源 / 场景

来解释以下四个疑问点,你就都懂了

SetGamePackage()------ 为什么不让 GameManager 自己创建?

表面作用

已经初始化完成的 Package​ 交到 GameManager 手里

深层设计原因(非常重要)

职责分离(SRP)

模块 职责
PatchManager 负责"怎么拿到 Package"
FSM / YooAsset 负责"初始化 Package"
GameManager 负责"以后用 Package 干什么"

Package 是"战略资源"

  • 创建一次

  • 全局唯一

  • 生命周期长

  • 不能乱建

所以:

谁创建,谁负责;GameManager 只负责"接盘"

SetBehaviour()------ 为什么 MonoBehaviour 要单独注入?

Unity 的铁律

只有 MonoBehaviour 才能 StartCoroutine

如果没有这个设计会怎样?

  • GameManager 自己不是 MonoBehaviour

  • 想开协程 → 编译不过

  • 强耦合一个 GameObject → 架构脏了

这是非常专业的解耦手法,很多项目都做不到这一点。

StartCoroutine()------ 为什么要包一层?

  • 调用方 不用关心协程怎么跑

  • 以后你可以换成:

    • UniTask

    • CoroutineUtility

    • 自己的 Task 系统

这一层是"未来可替换层"

没有这一层时,世界是什么样?

必须是 MonoBehaviour,否则编译不过

有了这一层后,世界变成什么样?

调用方脑子里只有一句话:

"我想异步执行一段逻辑,剩下的别让我管"

它不知道、也不需要知道:

  • 是不是 MonoBehaviour.StartCoroutine

  • 是不是 UniTask

  • 是不是自定义 Task 调度器

  • 是不是在主线程

为什么说「未来可以换」?

今天(Unity Coroutine)

明天(换成 UniTask)

复制代码
public UniTask StartCoroutine(IEnumerator enumerator)
{
    return _behaviour.StartCoroutineAsUniTask(enumerator);
}
复制代码
或者

public UniTask StartAsync(Func<CancellationToken, UniTask> task)

{

return task(default);

}

调用方一行都不用改

GameManager.Instance.StartCoroutine(LoadSomething());

GameManager.StartCoroutine 不是"为了方便调用",而是为了"让调用方永远不用知道协程是怎么跑的"。

这就是经典的 Adapter 模式

Adapter(适配器)模式 的本质

我不改原来的东西,但我包一层,让它"长得像我想要的那样"

原始问题(Unity 的限制)

// 只有 MonoBehaviour 能用

StartCoroutine(LoadSomething());

但你的 GameManager 不是 MonoBehaviour

写的 Adapter

public void StartCoroutine(IEnumerator enumerator)

{

_behaviour.StartCoroutine(enumerator);

}

它干了什么?

角色 是谁
被适配者(Adaptee) MonoBehaviour.StartCoroutine
目标接口(Target) GameManager.StartCoroutine
Adapter GameManager这一层

你没改 Unity

也没强迫 GameManager 继承 MonoBehaviour

你只是"包了一下"

这就是 100% 标准 Adapter 模式

什么叫「对外的话事人」?

一个模块如果是对外的话事人,必须满足这 4 件事:

标准 是否满足
对外暴露稳定 API 满足
屏蔽底层实现细节 满足
所有"业务系统"只认它 满足
底层改动不影响业务 满足

哪些事「必须」经过 GameManager?

业务需求 正确入口
加载主界面 SceneChangeToHomeEvent→ GameManager
进入战斗 SceneChangeToBattleEvent→ GameManager
加载 YooAsset 资源 GameManager.Instance.GamePackage
启动协程 GameManager.Instance.StartCoroutine()
获取当前 Package GameManager.Instance.GamePackage
预加载 / 卸载资源 GameManager 包装

那我们来总结一下

热更框架全流程:

四层架构总览(从底层资源→调度→UI 入口)

1. 底层:YooAsset 资源库(第三方)

所有资源初始化、版本请求、Manifest 下载、文件下载、缓存清理均由 YooAsset 提供 API,业务代码只做封装调度。

2. 核心调度层(热更流程中枢)

  • PatchManager:静态总控制器,FSM 状态机入口、事件分发中枢
  • 一批FsmXXX状态节点:有限状态机,拆分每一步热更逻辑(初始化包→请求版本→更新清单→计算下载→下载文件→清理缓存→进入游戏)
  • 全部XXXEvent事件类:解耦通信载体 ,分两类:
    1. 用户操作事件(UI→PatchManager):UserTryXXXEvent
    2. 流程反馈事件(FSM→UI 窗口):PatchXXXEvent、场景切换事件SceneChangeXXXEvent

3. 全局业务管理层

GameManager:游戏全局单例,持有主资源包、提供协程运行器、监听场景切换事件、负责场景加载

4. 表现层 UI

PatchWindow:热更进度 UI,只监听流程反馈事件,弹窗 / 进度条展示,点击按钮发送用户操作事件

5. 程序入口

Boot:启动脚本,游戏第一执行脚本,初始化 UniEvent/YooAsset、实例化 UI、创建并启动热更流程

游戏启动全链路

阶段 1:游戏初始化(Boot)

  1. 打开游戏 → 执行Boot.Awake → Boot.Start
  2. 初始化 UniEvent、YooAssets
  3. 生成 PatchWindow UI 窗口
  4. PatchManager.Create("DefaultPackage", PlayMode)
    • 创建状态机,注册全部 FSM 节点
    • 黑板存入包名、运行模式
    • 监听所有 User 操作事件
  5. PatchManager.Start() → FSM 进入FsmInitializePackage

阶段 2:资源包初始化(FsmInitializePackage)

  1. 根据 PlayMode 区分编辑器 / 单机 / 热更 / 小游戏,初始化 Package
  2. 初始化失败:发送PatchInitializeFailedEvent
    • PatchWindow 收到事件,弹窗提示初始化失败,按钮发送UserTryInitializePackageEvent
    • PatchManager 接收 User 事件,切回初始化状态重试
  3. 初始化成功 → 切换FsmRequestPackageVersion

阶段 3:请求远端版本号(FsmRequestPackageVersion)

  1. 调用 YooAsset 请求 CDN 版本文件
  2. 请求失败:发送PatchPackageVersionRequestFailedEvent,弹窗重试
  3. 请求成功:版本号存入黑板 → 切换FsmUpdatePackageManifest

阶段 4:下载资源清单 Manifest(FsmUpdatePackageManifest)

  1. 用 GameManager 协程执行异步下载 Manifest
  2. 下载失败:发送PatchPackageManifestUpdateFailedEvent,弹窗重试
  3. 成功 → 切换FsmCreateDownloader

阶段 5:统计需要更新的资源(FsmCreateDownloader)

  1. 创建 Downloader,对比本地与远端资源
  2. 无更新文件:直接跳转FsmClearCacheBundle
  3. 存在更新:发送PatchFoundUpdateFilesEvent
    • PatchWindow 弹窗展示文件数量、总大小
    • 玩家点击确认 → 发送UserBeginDownloadWebFilesEvent
    • PatchManager 接收事件,切换FsmDownloadPackageFiles开始下载

阶段 6:资源下载(FsmDownloadPackageFiles)

  1. 绑定下载进度、下载失败回调
  2. 每帧下载进度变化:发送PatchDownloadUpdatedEvent → UI 刷新进度条
  3. 单个文件下载失败:发送PatchWebFileDownloadFailedEvent → 弹窗,可发送UserTryDownloadWebFilesEvent重试下载
  4. 全部文件下载完成 → 切换FsmDownloadPackageOver

阶段 7:下载收尾 + 清理缓存

  1. FsmDownloadPackageOver:打印完成提示,直接切FsmClearCacheBundle
  2. FsmClearCacheBundle:异步删除本地废弃 Bundle,完成后切FsmStartGame

阶段 8:进入游戏主流程(FsmStartGame)

  1. 将资源包交给 GameManager 统一管理
  2. 发送SceneChangeToHomeEvent
  3. GameManager 监听场景事件,加载主城场景,热更流程全部结束

阶段 9:场景切换(游戏内)

玩家主城 / 战斗切换时,外部逻辑发送SceneChangeToBattleEvent,GameManager 接收后加载对应场景

为了大家加深印象,我想了几个问答题!大家可以自行读一读

1. 简述这套「UniEvent + FSM + YooAsset」热更新整体架构分层,每层职责是什么?

参考答案要点: 四层分层:启动层 Boot、调度层 PatchManager+FSM、资源管理层 GameManager、表现层 PatchWindow; 两套事件隔离:PatchXXXEvent(流程→UI)、UserXXXEvent(UI→流程); FSM 黑板 Blackboard 做状态间数据共享,全程单向依赖,UI 不碰底层资源逻辑。

2. 为什么要用 UniEvent 事件总线解耦?直接调用方法不好吗?说出 3 个优势。

参考答案:

  1. 完全解耦:UI 不需要持有 PatchManager、FSM 引用,底层流程不用操作 UI 控件;
  2. 一对多广播:一个流程进度事件可同时给 UI、日志、埋点多端消费;
  3. 生命周期统一管理:EventGroup 批量添加 / 移除监听,避免漏删监听造成重复回调、内存泄漏。

3. FSM 有限状态机相比 if/while 顺序流程写热更,优势在哪?

参考答案:

  1. 单一职责:每个热更步骤独立一个状态类,新增 / 删减流程只增删节点,不污染原有代码;
  2. 流程可回滚、可重试:出错时通过 User 事件切回对应前置状态,不用写大量重试分支;
  3. 数据隔离:黑板 Blackboard 存储流程临时数据,不污染全局静态变量,支持多分包并行热更;
  4. 结构清晰,流程链路标准化,便于维护、排查卡死问题。

4. YooAsset 四种运行模式(EPlayMode)分别适用什么场景?初始化有什么核心区别?

参考答案:

  1. EditorSimulateMode:编辑器开发调试,虚拟构建,不走真实 CDN 下载;
  2. OfflinePlayMode:单机离线包,无热更,只读取内置 AB;
  3. HostPlayMode:移动端 / PC 真机热更,内置清单 + 沙盒缓存双文件系统;
  4. WebPlayMode:WebGL / 微信小游戏,专用网络文件系统,微信需适配 WX 文件系统。

5. 看代码:FsmDownloadPackageFiles 里给 downloader 绑定 DownloadError、DownloadProgressChanged 委托,会存在什么隐患?如何修复?

考点:委托重复绑定、内存泄漏 参考答案: 隐患:多次进入该状态节点会重复绑定委托,下载一次触发多次弹窗、多次进度刷新;流程结束委托不会自动解绑,产生内存泄漏。 修复方案:

  1. 在 OnExit 回调解绑委托;
  2. 在下载完成 Completed 回调里即时解绑;
  3. 单次操作局部委托,不长期挂载。

6. 框架中为什么所有临时数据(PackageName、Downloader、PackageVersion)存在 FSM 黑板,不用静态全局变量?

考点:全局变量弊端、多包扩展设计 参考答案:

  1. 静态全局变量单例,无法同时支持多分包、多套独立热更流程;
  2. 流程重启、重试时全局脏数据残留,容易出现版本错乱、下载器失效;
  3. 黑板随状态机生命周期销毁,自动回收数据,无内存残留;
  4. 状态节点完全解耦,不需要互相持有引用传参。

7. 代码里 GameManager 统一托管所有协程,不把 Mono 传给 FSM,设计目的是什么?

参考答案:

  1. FSM 纯逻辑层,不依赖 MonoBehaviour 生命周期,代码复用性更强;
  2. 统一协程管理,方便全局暂停、清理所有异步下载协程;
  3. 资源加载、场景加载统一收口,方便后续加加载超时、埋点、异常拦截。

8. 区分两类事件 PatchXXXEvent / UserXXXEvent,设计上为什么要严格分开,不能混用?

考点:单向数据流设计思想 参考答案:

  1. PatchEvent:下行消息,FSM / 资源层推送流程状态、进度、报错,UI 只读展示;
  2. UserEvent:上行指令,玩家交互触发,驱动状态机切换流程;
  3. 职责隔离,阅读代码时一眼区分 "状态反馈" 和 "流程控制",不会出现 UI 直接下发下载指令、底层随意修改 UI 的反向耦合。

9. PatchWindow 里封装 MessageBox 对象池,为什么不直接 Instantiate/Destroy 弹窗?

参考答案:

  1. 频繁创建销毁 UGUI 造成 GC 卡顿,热更新界面高频弹窗场景卡顿明显;
  2. 对象池复用 GameObject、Button 绑定,减少 Transform 查找、组件获取开销;
  3. 统一管理弹窗生命周期,避免多弹窗层级错乱、重复弹出。

10. 游戏热更时卡在 "请求版本号" 无响应,从这套框架出发,分模块排查思路?

考点:排错逻辑、分层定位 参考答案:

  1. 查看日志,确认 FSM 是否正常进入 FsmRequestPackageVersion;
  2. 检查 CDN 地址 GetHostServerURL 平台适配是否正确;
  3. 网络拦截:是否无法访问.version 文件、http/https iOS ATS 配置;
  4. 事件监听:PatchPackageVersionRequestFailedEvent 是否正常广播,弹窗是否弹出;
  5. 状态机驱动:Boot.Update 是否每帧调用 PatchManager.Update,FSM 未轮询卡死。

11. 用户反馈重复下载资源,每次启动游戏都全量更新,结合框架和 YooAsset 分析 3 种可能原因?

参考答案:

  1. HostPlayMode 初始化未开启 CopyBuiltinPackageManifest,本地无内置清单无法对比版本;
  2. 清理缓存逻辑错误,使用 ClearAllBundleFiles 而非 ClearUnusedBundleFiles,每次启动删全部缓存;
  3. CDN 打包版本号固定,每次打包 version 未更新,本地和远端永远不一致。

12. 弱网环境下,单个 Bundle 下载失败后弹窗重试,但是重试后依旧全部重新下载,是什么问题?如何改造?

考点:Downloader 机制理解 参考答案: 原因:原有逻辑发送 UserTryDownloadWebFilesEvent 会重新 CreateDownloader,重新生成完整下载列表,不会断点续传失败文件。 优化:区分 "全部重新下载" 和 "仅重试失败文件",单独提供失败文件重试接口,复用原有下载器只重下失败资源。

13. 现有框架只支持单分包热更,如果要求实现多分包并行热更(主包 + DLC 分包),这套架构哪些地方需要改造?

参考答案:

  1. PatchManager 不要静态单例,改为实例化,每个分包独立 StateMachine、独立黑板;
  2. 事件增加 PackageName 标识,区分不同分包的进度、错误事件;
  3. GameManager 支持多 Package 缓存,对外提供指定分包加载接口;
  4. UI 层增加多分包进度展示面板,区分不同分包下载进度。

14. 现有框架缺少「下载暂停 / 继续 / 取消」功能,基于现有 FSM + 事件架构,如何最小改动扩展?

参考答案:

  1. 新增控制事件 UserPauseDownloadEvent、UserResumeDownloadEvent、UserCancelDownloadEvent;
  2. PatchManager 监听控制事件,传递给 FsmDownloadPackageFiles;
  3. 下载状态内持有 Downloader,调用 YooAsset Downloader 的 Pause/Resume 接口;
  4. 不改动原有流程节点,仅新增事件和少量分支判断,符合开闭原则。

加个彩蛋!

游戏 / 资源热更场景常见 FSM 状态机设计模式?

一、枚举 + If/Else 简陋状态机(最入门,新手 Demo)

结构

  1. Enum 定义所有状态

  2. 类内一个 CurrentState 变量

  3. Update 里大量 if else / switch 判断执行逻辑

cs 复制代码
enum PatchState
{
    InitPackage, RequestVersion, DownloadManifest, DownloadFiles
}
PatchState _curState;
void Update()
{
    switch(_curState)
    {
        case PatchState.InitPackage: DoInit(); break;
        case PatchState.RequestVersion: DoRequestVer(); break;
    }
}
void ChangeState(PatchState newState)
{
    // 手动执行退出逻辑、进入逻辑
}

优点

零依赖、写得快,小 Demo 一两步流程够用

缺点

  1. 状态越多 switch 越臃肿,难以维护

  2. 没有统一的 OnEnter/OnExit/OnCreate 生命周期

  3. 状态切换逻辑散落在各处,无法统一管理

  4. 不支持状态间传参、黑板共享数据

适用

极小流程、一次性脚本,不适合你这种热更工程

二、基类虚函数 FSM(面向对象标准状态模式,GoF 经典 State Pattern)

核心设计(你当前项目用的就是这套)

  1. 抽象接口 / 基类 IStateNode,统一生命周期: OnCreate / OnEnter / OnUpdate / OnExit

  2. 每个状态独立一个类(单一职责)

  3. 中央状态机 StateMachine 统一管理:

    • 持有当前状态、所有状态实例

    • 提供 ChangeState<T>() 切换接口

    • 内置黑板 Blackboard 跨状态共享数据

你的代码对应实现

FsmInitializePackage / FsmCreateDownloader 全部独立类,实现 IStateNode,由 PatchManagerStateMachine 调度。

优点

  1. 单一职责,新增流程只加状态类,不修改原有代码(开闭原则)

  2. 标准生命周期,进入 / 退出可做资源清理、委托解绑

  3. 支持黑板隔离全局数据,支持多套独立状态机

  4. 流程可回滚、可重试,热更、登录、战斗流程首选

缺点

类数量多,流程步骤多会产生大量状态文件

适用

中型 / 大型游戏流程:热更新、登录流程、战斗 AI、剧情流程

三、有限状态机 + 事件驱动(事件 FSM,你整套架构核心组合)

在标准面向对象 FSM 基础上,用事件总线驱动状态切换,而非硬编码调用 ChangeState

分层

  1. FSM 只负责执行流程逻辑,不接收玩家输入

  2. 外部输入(UI 点击、网络回调)通过事件发送

  3. 管理器(PatchManager)监听事件,收到后调用 ChangeState

对应你的代码

  • UI 弹窗点重试 → 发送 UserTryXXXEvent

  • PatchManager 全局监听事件,统一切换 FSM 状态

优点

  1. 状态机与输入源完全解耦(UI、网络、定时器随便发事件)

  2. 一套状态机可以被多种输入驱动

  3. 便于埋点、日志拦截所有状态跳转

适用

需要 UI 交互、可重试、多触发源的流程(热更新、登录、商城)

四、分层嵌套 FSM(Super FSM / 复合状态机)

设计

大状态内部嵌套子状态机,解决多级流程 例: 总流程:热更新 FSM

  • 子状态 1:资源初始化子 FSM(初始化→失败重试)

  • 子状态 2:下载子 FSM(开始下载→暂停→失败重试)

优点

复杂流程拆分层级,避免顶层状态爆炸

缺点

调试链路变长,层级深容易混乱

适用

复杂 AI、大型新手引导、多阶段活动流程;你的热更目前单层级足够,不需要

五、状态表驱动 FSM(数据驱动 FSM,策划可配置)

设计

不用硬编码写死跳转,用配置表(ScriptableObject/Json)定义: 当前状态 + 触发事件 = 目标状态 例:

当前状态 触发事件 跳转状态
CreateDownloader 用户确认下载 DownloadFiles
DownloadFiles 下载失败 CreateDownloader

优点

跳转规则配置化,策划可调整流程,不用改代码

缺点

简单流程过度设计,复杂逻辑(协程、异步回调)不好塞进表格

适用

新手引导、剧情分支、活动流程;资源热更一般不用

六、栈式 FSM(状态栈,支持弹出回退)

设计

状态机内部用栈存储状态,不只是单一当前状态

  • PushState():压入新状态,保留旧状态

  • PopState():退出当前,回到上一个

典型场景

弹窗系统、多层 UI、战斗暂停切设置界面

不适合

线性热更流程(你的热更是单向流水线,不需要回退上一步)

七、行为树混合 FSM(AI 专用)

把 FSM 作为行为树的节点,多用于角色 AI、怪物逻辑;资源热更完全不适用

八、协程 FSM(轻量流式 FSM,大量 YooAsset Demo 在用)

设计

不用拆分状态类,直接用多个 yield return 分段写流程

cs 复制代码
IEnumerator PatchFlow()
{
    yield return InitPackage();
    yield return RequestVersion();
    yield return DownloadManifest();
}

优点

代码集中,文件少,小型 Demo 快速实现

缺点

  1. 流程耦合严重,重试、分支逻辑会写大量嵌套

  2. 无统一生命周期,退出清理、委托解绑极难处理

  3. 无法拆分复用,不能多包并行热更

和你现有架构对比

你放弃了这种写法,选用类对象 FSM,就是为了工程可维护性。

精简总结

  1. 枚举 Switch 简陋 FSM:小型 Demo,耦合严重,不适合工程;

  2. 标准面向对象状态模式(IStateNode):项目主流,单状态单一职责,带完整生命周期(当前热更框架使用);

  3. 事件驱动 FSM:在标准 FSM 基础上用事件总线解耦输入与流程,适合带 UI 交互的热更、登录;

  4. 嵌套分层 FSM:多级复杂流程拆分;

  5. 数据表驱动 FSM:配置化流程,策划可控;

  6. 栈式 FSM:多层 UI、弹窗回退场景;

  7. 协程流式 FSM:简易 Demo 专用,不适合大型项目维护。