做Unity项目做到中后期,资源管理一定是绕不开的坎。我接手过几个半路项目,最直观的感受就是:场景加载越来越慢、内存占用忽高忽低、某些美术资源明明删了却还在打包产物里躺着。这些问题的根源,十有八九都出在“资源类型和基本使用”这个地基上没打好。Unity里的资源管理,说到底就是回答三个问题:这个资源属于什么类型、用什么方式加载它、什么时候把它卸载掉。这三个点搞清楚了,后面再去理解AssetBundle、Addressables这些进阶方案会顺畅很多,排查内存问题也不会像无头苍蝇一样到处乱试。
这篇文章不打算讲太玄乎的架构,就把我在实际项目里反复用到、反复踩坑的Unity资源管理基础完整过一遍。适合刚入门Unity、对Resources和AssetBundle概念还比较模糊的同学,也适合做过几个Demo但从未系统梳理过资源体系的开发者。文里所有的操作建议和排查经验,都是基于我在自己项目里的真实测试,直接照着做就能少走弯路。
1. 资源在Unity里的本质:文件、内存与实例的关系
1.1 资源类型的底层逻辑
Unity里“资源”这个词,其实涵盖了两层不同的东西。
第一层是工程目录下的源文件,比如FBX、PNG、WAV、材质、预制体、场景,这些是磁盘上的资产,有文件后缀和导入设置。第二层是运行时真正存在于内存里的对象,比如Texture2D、AudioClip、GameObject实例。这两者之间不是一对一的关系:同一个源文件可以同时被多个系统引用,也可以被重复加载出多份内存对象。
理解这一点特别重要,因为很多内存问题都源于“我以为这个资源只占一份,实际上它被加载了好几份”。Unity引擎在导入每个文件时,都会生成一个.meta文件记录导入参数,引擎根据文件后缀选择对应的导入管线,把源文件序列化成特定的Asset类型。我们平时讨论的“资源类型”,本质上就是这些Asset类型的分类。
常见的资源类型按大类划分,大致是这些:
- 图像类:Texture2D、Sprite、Cubemap、Texture2DArray
- 模型类:Mesh、骨骼数据(Avatar)、蒙皮相关数据
- 材质相关:Material、Shader
- 组合载体:Prefab、SceneAsset
- 音视频:AudioClip、VideoClip
- 动画:AnimationClip、AnimatorController
- 数据配置:ScriptableObject、TextAsset
每一种类型的导入管线都不一样,内存结构差异也很大。比如纹理在GPU上有一份显存数据,如果勾选了Read/Write Enabled还会在CPU侧额外保留一份原始像素数据;Mesh则有顶点缓冲和索引缓冲的区分,还有一套CPU侧的简化数据用于碰撞和边界计算。这些特性直接决定了你该用什么策略去管理它们。
1.2 资源(Asset)与实例(Instance)的区别
新手最容易混淆的就是Asset和Instance。简单来说,Asset是工程里的“图纸”,Instance是运行时按照图纸造出来的“实物”。
以预制体为例。工程里有一个名为Enemy.prefab的Asset,它本身不是场景里能动的对象,只是一份序列化数据。当你Instantiate这个Prefab时,Unity会根据这份数据在场景里创建出真正的GameObject实例,这个实例有独立的Transform、组件状态,还可以在A图中被修改而不影响B图。同理,Material Asset是磁盘上的一个材质定义,但当你在运行时修改某个引用该材质的渲染器的sharedMaterial副本时,其实是在改实例。
这种区分直接关系到内存管理。Asset可以被多个GameObject共享引用,它只占一份内存;而你每次Instantiate产生的实例,则各自占用一份内存。很多项目出现“一跑起来内存就飙到几个G”的情况,就是因为实例创建了没有销毁,或者同一个Asset被反复加载导致缓存了多份。
另一个典型的例子是Material。运行时通过renderer.material访问会立即为每个渲染器生成一个材质实例,方便你单独修改颜色而互不影响;但如果项目里几百个物体都统一用同一个贴图和材质,你却挨个用renderer.material去读取,就会莫名其妙多出几百份材质实例。正确的做法是,能共享的就用sharedMaterial,需要独立实例的才用material。这个细节在粒子系统、UI元素多的界面里尤其明显,内存差异可以达到一个量级。
1.3 资源生命周期:加载、引用、卸载
Unity资源的生命周期可以拆成三个节点:加载进入内存、被引用、卸载释放。
加载这一步发生在很多时机:场景反序列化时自动加载场景里引用的资源、Resources.Load被调用时同步加载、AssetBundle.LoadFromFile加载Bundle后从中取资源、Addressables异步加载等。加载完成后资源就驻留在内存中,等待被系统创建成实例或直接使用。
引用的含义比较特殊。Unity引擎没有像C#那样严格的托管引用计数系统,资源对象是否“存活”取决于两条链:一是被场景、Prefab、组件等对象通过序列化引用链持有;二是被C#侧的强引用持有(比如静态变量、未销毁的脚本里保存的引用)。只要还存在于这两条链上,资源就不会被卸载。
卸载则依赖于Resources.UnloadUnusedAssets、AssetBundle.Unload或Addressables的Release。很多人以为调用Resources.UnloadUnusedAssets就能把内存降下来,但它其实只处理“没有被任何东西引用”的资源,只要有一条引用链挂着,哪怕你从视觉上再也看不见它,它依然占着内存。所以我一直跟团队强调:资源管理是加载和释放两头都要管,只在释放侧使劲是没用的。
2. 主流资源类型逐个拆解:各自的管理重点
2.1 预制体:项目里最核心的组合型资源
预制体是Unity里使用频率最高的资源类型,它的本质是一组GameObject组件的序列化快照,用来在场景中反复生成角色、道具、UI、特效等组合体。管理预制体有几个关键点必须形成肌肉记忆。
一个是预制体的层级和引用关系。预制体内部可以引用工程里任意资源,比如模型、材质、动画;同时它也可以被其他预制体嵌套引用。从Unity 2018开始支持嵌套预制体之后,项目里很容易出现“预制体套预制体”的深层结构,这是好事,方便复用;但我见过很多项目因为嵌套层级过深,导致场景打开、序列化、实例化都变慢。建议是:不要把整个UI界面塞进一个巨型预制体里,按功能拆成若干个子预制体,再在根节点组装。
另一个关键是预制体的Variant(变体)。当你需要“同一个敌人,普通版和精英版只有属性和贴图不同”时,不需要复制一份完整预制体,新建一个Prefab Variant,在子预制体上覆盖差异字段即可。这样美术改原始Prefab,所有变体会一并更新;反之,某个变体单独覆盖的部分也不会被原始改掉。这个机制用好了能省无数重复维护的功夫。
预制体的卸载通常不需要手动处理。场景里Instantiate出来的实例,用Object.Destroy销毁即可;Prefab Asset本身如果被场景引用,会随场景加载,如果没有引用链则需要通过Resources或AssetBundle管理的机制来卸载。很多人忽略的一点是:预制体内部引用的Mesh、Texture等资源,会跟着预制体Asset的加载而加载,即使场景里一个实例都没创建。所以不要在Prefab上挂一堆用不到的组件和资源引用,这会让内存白白上涨。
2.2 ScriptableObject:被大多数新手低估的配置资源
ScriptableObject是Unity里最灵活也最被低估的资源类型。它是一个继承自Object的数据容器,不绑定到GameObject上,可以独立保存为.asset文件。最大的价值在于:多个脚本、多个场景可以共享同一个数据实例,修改它一次,所有引用方立即生效。
举个例子。角色升级表、技能配置表、关卡掉落表,这些如果用Json或Excel管理,运行时需要加载、解析、转成类对象;直接用ScriptableObject做成资产,可以在编辑器里填好数据,运行时通过字段直接引用,没有字符串、没有解析开销,还能在Inspector里可视化编辑。我习惯把“一次配置、多处复用”的纯数据都做成ScriptableObject,比如武器属性表、AI行为树参数、音效映射表,甚至场景名和资源路径的映射表。
管理ScriptableObject要注意一个坑:它虽然有默认Asset的形式,但你可以在运行时通过ScriptableObject.CreateInstance动态创建,也可以用Resources/Addressables加载Asset文件。如果是动态创建的,要记得在不再使用时跟随场景逻辑销毁;如果是asset文件,和普通资源一样受引用链控制。
还有一个容易翻车的地方:ScriptableObject里如果存了List或者Dictionary,在多个场景之间共享时,运行时如果有代码修改了它的内容,影响是全局的。我遇到过某个技能系统运行时改了配置对象的字段值,导致测试模式下数据被污染、退回主菜单再进游戏数值全乱的情况。建议所有运行时可变数据都不要直接存在ScriptableObject上,要么做成浅拷贝实例,要么用只读约定并让代码严格遵守。
2.3 纹理与材质:内存占用的大头
项目里纹理通常是内存吃满的第一元凶,百分之八十的“内存爆炸”都和贴图有关。纹理的管理核心在导入设置,而不是代码。
导入设置里最该盯的是几个参数:压缩格式、Max Size、Mipmap、Read/Write Enabled。手机上ASTC或ETC2是主流压缩格式,如果某个贴图是给UI用的,可以关掉Mipmap并降低Max Size;如果是3D模型用的法线贴图,压缩格式又不一样;如果不需要在CPU侧读取像素,一定要关掉Read/Write Enabled,否则内存里会常年多一份不可见的原始数据。
纹理尺寸对内存的影响是指数级的,一张2048x2048的RGBA32纹理,即使经过压缩,在相同参数下也比1024x1024的版本最多多出4倍显存占用。很多美术资源原画画时是4K,实际显示在屏幕上可能撑死也就占几百像素。项目里应该定一套纹理规范,比如UI贴图上限1024、角色贴图上限2048、场景大地面最多4096并开Mipmap,然后通过纹理导入预设(Texture Importer的Preset)统一约束,防止美术提交时忘改设置。
材质的管理则要区分“材质Asset”和“材质实例”。材质Asset持有Shader和一组贴图引用,Shader变体数量会影响打包体量;材质实例则是运行时复制出来的独立修改对象。我之前提过,千万别用renderer.material去批量读取共享材质,一来产生大量实例,二来每次访问还可能触发重进Shader属性闪存,性能和内存双输。对于需要动态修改颜色、替换贴图的场合,用MaterialPropertyBlock是更轻量的方案,它不改材质,只修改渲染时的属性块,既省内存又避免破裂GPU合批。
2.4 音频与动画:流式加载与特殊处理
音频和动画在资源管理上容易被忽视,因为它们在普通玩法下不显山露水,一旦成规模就会拖垮加载时间和内存。
AudioClip有三种加载方式:Decompress On Load(加载即解压成原始PCM)、Compressed In Memory(压缩驻留内存,播放时解压)、Streaming(流式从磁盘读取,不把完整音频放进内存)。短音效(UI点击、开枪)适合Compressed In Memory;长音乐、环境音必须用Streaming,否则一段3分钟的音乐直接解压进内存非常浪费。另外,音频的采样率、单双声道设置也要按内容定制,语音用单声道22050Hz完全够,没必要都上44100立体声。
AnimationClip的管理有个常见误区:动画文件很大程度取决于曲线数量、关键帧数量和压缩设置。Unity里AnimationClip的导入设置提供动画压缩(压缩动画曲线、减少浮点精度),开启后能在不明显降低质量的前提下大幅缩小内存。对于人形角色的动作,尽量用Humanoid动画类型,引擎会做骨骼重定向和更高效的数据存储;非人形动画用Generic或Legacy前要确认是否需要做轨迹修改,不需要就开压缩。
还要注意动画事件和曲线里的Object引用。动画面板里如果绑了音频或物体会导致clip依赖外部资源,这些依赖必须跟动画一起加载。很多项目把动画直接扔进Resources或Addressables时容易漏掉这些依赖引用,运行时动画倒是能放,但声音和事件回调全部丢失,排查起来特别隐蔽。
3. 资源加载的三种主流方式:从直接引用到Addressables
3.1 直接引用:最简单也最容易被绑死的方式
直接引用是指在Inspector里把资源拖到脚本字段、把它们作为预制体或场景的一部分保存。这种方式的优点是零代码、零异步、编译器就能验证,缺点则是耦合和总包体失控。
场景里引用一个Prefab,只要场景加载,这个Prefab以及它引用的所有资源都会一并加载进内存。当场景里直接引用了几百个美术资源,加载场景的时间就会被这些资源的反序列化时间拉满。更麻烦的是,被场景引用的资源一定会被打进包体,哪怕你只在某个隐藏界面用一次,它也会跟着主场景常驻内存。
直接引用适合的场景是:预加载列表很短、资源被主玩法高频使用、或者该资源必然会出现在当前场景里。比如玩家的默认角色、主UI的背景图、常驻音频挂在场景里就很合理。但凡某个资源是“特定关卡才出现的”“需要远程更新的”“可以延迟加载的”,都不应该用直接引用把它焊死在场景上。
还有一种直接引用容易踩坑:脚本的序列化字段引用了某个Asset。只要这个脚本的实例挂在场景里,或者挂在一个被加载的Prefab上,它引用的Asset就会被连带加载。我见过一个项目把几十张活动海报图全部挂在同一个管理脚本的数组字段里,结果游戏一启动,这些跟当前活动八竿子打不着的图片全部进了内存。排查这种问题最简单的方法是检查Build Report和Profiler里的加载来源,凡是启动时莫名其妙出现的资源,八成就是被这类字段拽进去的。
3.2 Resources文件夹:随拿随用,但别把它当仓库
Resources是Unity早期提供的标准加载方案。放在若干Resources目录下的资源会被打进同一个特殊Bundle里,运行时用Resources.Load("路径")直接同步加载。
同步加载写起来非常舒服,不用回调也不用异步流程,因此很多小团队整个项目所有资源都往Resources里塞。这样做的代价是:Resources下的所有资源,不管用不用,都会全部打成一个整体包;首包体积膨胀、启动加载变长;Unity官方很早之前就提示过,Resources目录应该只放必须通过它加载的小体积配置和关键路径资源,而不是整个游戏的资源库。
另一个潜规则是,Resources.Load对路径敏感,资源改名或移动后,旧代码里的字符串路径会在运行时默默失败。我见过太多因为重构移动了Resources目录里某个贴图,导致某个界面打开时贴图缺失且无报错的问题。我的建议是:Resources里只放数量可控、路径尽量稳定的资源,比如全局通用的默认图、常用字体、基础材质,并且在加载处统一封装路径常量,不要散落在各段代码里手写字符串。
从Unity 2023开始,Resources目录的推荐替代方案是Addressables,官方文档里也把Resources描述成“很小规模的场景下可以用”。如果你正在开新项目,我强烈建议直接从Addressables开始学,而不是先上车Resources再花好几周迁移。
3.3 AssetBundle与Addressables:正式项目的进阶选择
AssetBundle是Unity最底层的自定义资源打包方案,你可以把任意资源分组打进Bundle,运行时通过AssetBundle.LoadFromFile或UnityWebRequest加载,再从中LoadAsset取资源。AssetBundle的难题在于依赖管理:一个Prefab用到的材质可能放在另一个Bundle里,加载时必须先把所有依赖Bundle也加载了,否则资源就是坏的,没打出来的引用全会丢。
依赖管理是AssetBundle项目里最大的时间黑洞。打包时需要通过BuildPipeline计算每个资源的依赖,构建Manifest;运行时也得解析依赖关系,按顺序加载Bundle。一旦某个依赖没加载或者加载顺序不对,现象往往是“某个模型的贴图是粉色的”“Prefab默认是等边三角体”。排查这些问题既繁琐又容易漏掉冷门资源,所以社区里才催生了各种高级打包工具。
Addressables则是官方在AssetBundle之上封装的现代方案。它把“直接用路径加载”升级成Addressable Address,底层自动处理Bundle的划分、依赖加载、引用计数以及资源释放。你只需要给资源打上Addressable标记,分配Group,然后用Addressables.LoadAssetAsync异步加载,用Addressables.Release释放。
Addressables的引用计数是它最核心的价值。同一个资源被多个异步加载请求同时引用,底层不会真的加载两次;释放时如果还有别处在引用,也不会提前卸载。这套东西把AssetBundle时代需要手写的依赖管理、引用统计、分批加载全部标准化了,是我现在做新项目的默认选择。当然它也有学习成本,比如资源组划分、加载失败处理、远程内容更新,但这套成本是一次性的,换来的是长期可维护的资源体系。
4. 资源生命周期与内存管理:卸载时机决定成败
4.1 引用计数与独立资源卸载
资源卸载有两个层次:Object级别的销毁和Asset级别的卸载。
Object.Destroy是销毁场景里的实例,比如Instantiate出来的GameObject、动态创建出来的材质变量、运行时创建的Mesh。Destroy之后,这个对象的托管包装、组件、Transform都会被清理,但如果它引用了某个Asset(比如挂着的模型Mesh),那Mesh Asset并不会因此卸载,因为Mesh Asset可能还被其他对象引用着。
Asset级别的卸载,在Resources体系下通过Resources.UnloadUnusedAssets完成。这个API会扫描当前所有被引用的资源,把没有引用链的资源从内存里释放。它的特点是:必须没有任何引用链,且调用是异步的。而且它只作用于Resources体系加载的资源,对于AssetBundle加载出来的资源,必须通过AssetBundle.Unload指定是否卸载其资源对象。很多人调用完UnloadUnusedAssets发现内存没降,多半就是忘了处理仍被C#变量挂着的引用,或者资源根本走的是AssetBundle通道。
用AssetBundle.Unload(false)只卸载Bundle外壳保留资源对象,true则会强制卸载所有从该Bundle加载出来的资源。false模式适合你把资源对象转交给其他管理机制继续持有;true模式则要求你确信Bundle里所有资源都不再需要,否则接下来访问这些资源会出现Missing预设和报错。
Addressables体系里则统一了这套逻辑。Addressables.Release一个AsyncOperationHandle时,底层引用计数减一,减到零才真正执行卸载,整个过程不用关心底层是Resources还是AssetBundle。对大型项目来说,这套“加载一次、多处共享、计数归零才释放”的模型是内存安全的基石。
4.2 内存泄漏的典型场景
我说几个项目里反复出现的内存泄漏场景,每一个都是真实踩过坑的,列出来给大家对照检查。
第一个是静态字段和单例持有对象引用。单例模式在Unity项目里极其常见,动不动就有个GameManager挂着List 之类的东西。场景切换时旧的GameObject没被清出这个List,导致整个场景的引用链被单例一直拽着,卸载永远不能触发。这种泄露在Profiler里很容易看到“场景切了十次,同名的Mesh和Texture一份都没少”。
第二个是事件委托忘记移除。脚本在OnEnable里注册了事件,在OnDisable里没取消注册。场景卸载后,已经销毁的脚本仍然被事件源引用,这不止是内存问题,还会造成回调抛MissingReferenceException,表现非常随机,时好时坏。解决办法是养成对称注册习惯:OnEnable/OnDisable、OnDestroy成对出现,事件源自身释放时也要把订阅者列表清空。
第三个是协程和异步回调的悬挂引用。协程在等待某个资源时,如果宿主对象已经被销毁,协程本身可能还在跑;异步加载的回调如果捕获了宿主对象,宿主也没法被GC正常回收。Addressables的Handle没有Release、Resources.Load出来的资源没有配套卸载、AssetBundle加载完没缓存引用就交给GC,这些都属于同类问题:要么该释放的没释放,要么该持有的没持有导致重复加载。
第四个是动态创建的Mesh和Texture没有清理。运行时生成模型、程序化生成贴图、截图生成Texture2D,这类动态资源不属于任何Asset,系统不会自动为你销毁。每次截图功能如果不去做纹理内存的及时回收,多截几次屏内存就上去了。我现在处理这类动态资源的统一做法是:在创建时就建立一个运行时资源清单,统一挂在场景管理器的卸载逻辑里,场景切换时挨个销毁,最省心。
4.3 内存分析工具的使用思路
排查内存问题,我用的最多的工具一直是Unity Profiler和Memory Profiler。
Unity Profiler的Memory模块能看Unity对象的内存分布,按类型、按Asset排序,快速找到谁占了大头。排查“场景切完内存不降”的步骤:先在切换场景前打一个内存快照,切换后再打一个,对比新增了哪些资源类型;如果新增的和切换操作能对上,说明是业务加载;如果对不上,比如出现了上一个场景的Mesh,说明有引用链泄漏。
Memory Profiler提供的是更底层的托管内存和原生内存映射,可以看到每个UnityEngine.Object对应的托管引用以及GC内存块。排查C#侧泄漏,它比Profiler直观得多——你直接在对象列表里搜Mesh,能看到它被哪个C#对象引用,顺着引用链就能揪出单例或静态字段。这一步是很多人在用的排查思路里缺失的,因为只看Profiler的类型柱状图永远查不出引用源。
还有一个被低估的工具是Unity的Scene Management和Object.FindObjectsOfType,不是在运行时用,而是在编辑器里写检查脚本:遍历所有场景和Prefab,找出引用了不在白名单内资源的节点,或者找出Resources目录里超过指定大小的贴图。这种静态检查搭配构建报告,比等到运行时崩溃再查内存效率高多了。
5. 常见问题与排查技巧实录
5.1 加载卡顿与场景变慢
场景加载卡顿最普遍的原因是同步反序列化了太多资源。场景文件里直接引用的Prefab、贴图、Mesh,全部要在主线程完成加载和解析,资源多自然卡。
最直接的解决手段是减少场景资源量,把非必须资源拆出去走Addressables异步加载;其次是把场景切分成多个子场景用Additive方式加载,配合Loading界面过渡。Unity的异步场景加载(LoadSceneAsync)并不是万能的,它只是在多帧间分配工作,如果单帧工作总量太大,异步也会卡,所以真正的解法永远是让单帧加载量变小。
另外记得检查Streaming Mipmap(纹理流送)的开关。开了之后Unity可以只加载Mip等级最低的那几层,玩家接近时再补高分辨率层级,对大地图场景加载时间改善非常明显。它是从Unity 2018开始引入的功能,移动端在低端机上默认会有更好的表现,但要注意对材质透明和UI贴图的支持情况,个别Shader变体需要配合设置。
5.2 资源重复打包导致包体膨胀
包体体积过大经常不是美术资源本身太大,而是同一个资源被重复打进了多个Bundle。AssetBundle打包时如果资源同时被A和B两个Bundle引用,没有做依赖拆分,Unity默认会把它复制进这两个Bundle,体积直接翻倍。Addressables的Group如果不能正确识别依赖,也会出现类似问题。
检查方式优先看构建后的Bundle清单和Build Report。如果只是确定某个资源出现在多个Bundle里,最稳妥的办法是建立“通用依赖组”——把引擎公共库、通用Shader、通用UI图集这类被广泛引用的资源放进同一个Group或Bundle,其他Bundle不得设置额外包含它们的复制策略。Addressables里可以开启“将资源放到与其依赖相同的组”之类的选项,但更可靠的是在架构上就分层设计:核心依赖层、内容层、远程层。
Shader变体是另一个隐形包体杀手。一个材质Shader往往内置了几百个变体,实际用到可能只有几十个,默认打包会把所有变体全部打进包体。解决方案是开启Shader Variant Collection,在需要时通过ShaderVariantCollection保存实际变体列表并打入包体,再加上Strip Unused Shader Variants,可以有效控制这部分体积。
5.3 纹理内存过高的快速定位
碰到贴图内存暴涨,我一般按这个顺序排查:打开Profiler的Memory模块,按Total Allocated排序,找到体型最大的Texture2D列表。这时候列表会显示每个纹理的Memory大小和实例ID,但看不到调用处是谁加载的,所以我会用Memory Profiler里对象的引用图,选中一个贴图,查看它被哪些Material引用,再找到Material被哪些Renderer或UI组件引用,一步到位。
定位到具体贴图后,先看它的导入设置:是不是出现了“一张1024的贴图被放大了32倍处理”这种比例失衡;如果某个贴图在很多UI界面里都很小却占了超大内存,多半是Max Size设置得过高或者开了Read/Write。对于不需要实时修改像素的贴图,关闭Read/Write后内存能立刻降一半;对于大图,用Crunch压缩配合Streaming Mipmap,能在画质和内存之间找到一个很舒服的平衡点。
落地到团队协作上,光在编辑器里看还不够。我会在构建管线上加一个检查节点,扫描所有贴图的导入配置,凡是超过项目规范阈值的直接报错,阻止进包。这样能在资源入库的时候就拦住问题,而不是等到某次真机测试被内存压死之后再去编辑器里翻几十个贴图的导入设置。
回到资源管理这件事本身,我个人的体会是:它不像写玩法逻辑那样能立刻看到成果,但所有隐蔽的性能问题和诡异的加载异常,最后都能追溯到资源的使用方式上。只要把资源类型分清、加载方式选对、释放节奏管住,项目再大,内存和加载表现也不会差到哪里去。如果你正准备梳理项目里的资源体系,先别急着抄那些复杂的框架,把本文的基础闭环跑通,再根据实际体量往上加方案,这个顺序最不容易翻车。