问题不是少占一点内存
iOS 的内存警告最容易被做成一个简单回调:收到系统通知后清缓存、释放几张贴图、把日志打出来,然后祈祷系统不要杀进程。这个做法在工具 Demo 里看起来合理,在真实游戏里却经常不够。玩家不关心客户端有没有执行过 clear_cache(),他只会感知到当前关卡是否突然黑屏、角色皮肤是否变成默认材质、战斗结算是否丢失,或者切回桌面再回来时游戏是否重新启动。
Godot 项目里,资源常常通过 preload、load、ResourceLoader.load_threaded_request、场景实例、材质引用、音频流和自定义资源包共同存在。内存警告到来时,真正难的是判断哪些资源可以释放,哪些资源只是看起来闲置,哪些资源虽然当前不显示却关系到下一秒的状态恢复。比如 Boss 二阶段还没触发的技能特效,如果被当成冷资源释放,过几秒就可能在战斗高潮处卡顿;主角当前武器的高模贴图如果被释放,重载期间会出现明显闪烁;剧情对白的语音包如果直接卸掉,跳过、回放和字幕同步都会受到影响。
所以 iOS 内存警告不应该只被视为性能优化问题,而是一个运行时资源一致性问题。目标不是尽可能多释放,而是用最小可见损伤换取进程继续存活。最稳的策略是先定义资源重要度,再定义释放阶段,最后让每次降级都有记录、有恢复、有 QA 场景。
先把资源分成能解释的层级
内存压力下,最危险的代码是“遍历缓存表,把不在屏幕上的资源全删掉”。屏幕可见不是资源重要度。一个资源是否可释放,至少要看 owner、使用阶段、恢复成本、可替代资源和玩家是否会立即感知。
建议把资源分为四层。第一层是 critical,包括当前场景必需脚本、当前角色、战斗判定相关资源、正在播放的剧情节点、结算状态和不能丢的存档中间态。这一层在内存警告下也不能释放,只能减少后续加载。第二层是 warm,包括当前关卡即将用到的技能特效、附近区域的音频、下一波怪物、短时间内可能打开的 UI 资源。它们可以降质或延迟恢复,但不能随意删除。第三层是 cold,包括远处区域、已关闭页面、近期不用的角色预览、活动入口缩略图。第四层是 rebuildable,包括截图缓存、缩略图、临时合成材质、可重新生成的导航辅助数据。
这几个层级最好不要只写在文档里。每个被 ResourceCache 管理的条目都应该带上 memory_tier、owner、scene_scope、last_used_at、reload_cost_ms 和 fallback_id。如果一个资源无法说明 owner,通常意味着它未来也无法被安全释放。
Godot 里的接入点
Godot 本身不替你决定 iOS 内存警告策略。实际项目通常需要通过平台桥接把系统警告转成 Godot 信号,再交给全局服务处理。GDScript 层不一定直接拿到底层通知,但应该能收到归一化事件:memory_warning(level, free_hint, platform)。这里的 level 不要只做布尔值,至少区分 notice、warning、critical。第一次警告可以清冷缓存,第二次警告要暂停预加载,critical 则进入可见降级。
在 Godot 项目结构里,可以把 MemoryPressureService 做成 autoload。它不直接释放节点,而是向 ResourceBudgetStore 查询当前资源清单,再把释放计划交给各 owner 执行。这样做的原因很简单:资源的拥有者最清楚它能不能被释放。角色系统知道当前皮肤能否回退到低清材质,音频系统知道哪些 stream 可以停止,UI 系统知道哪个页面已经关闭,下载系统知道哪些资源还没有校验完成。
不要让平台回调直接操作资源。iOS 通知来的时机可能很糟:加载中、切场景中、进入后台前、恢复前台后、视频播放中。直接释放容易撞上 Godot 的节点生命周期。安全的做法是把压力事件排队,在主线程的稳定检查点里执行计划,必要时分帧释放。
降级状态机
内存降级最怕“做过一次就忘了”。客户端需要知道当前处在正常、轻度压力、严重压力还是恢复观察期。恢复观察期很重要,因为系统不再报警不代表马上可以把资源全部加载回来。很多项目在释放之后立刻预加载下一批资源,等于刚喘口气又把内存顶回去。
stateDiagram-v2
[*] --> Normal
Normal --> SoftPressure: "first warning"
SoftPressure --> HardPressure: "warning repeats or free memory low"
HardPressure --> SurvivalMode: "critical scene risk"
SoftPressure --> Cooldown: "stable for N seconds"
HardPressure --> Cooldown: "budget recovered"
SurvivalMode --> Cooldown: "scene reaches safe point"
Cooldown --> Normal: "slow refill complete"
Cooldown --> HardPressure: "warning returns"
SoftPressure 可以停止非关键预加载、释放缩略图和冷资源。HardPressure 可以降低贴图 LOD、卸载远处区域、关闭角色预览缓存。SurvivalMode 则要做更明确的取舍:暂停后台下载、关闭拍照模式二次渲染、禁止打开高成本页面,必要时把玩家引导到安全结算点。状态机的关键不是复杂,而是每个状态都有进入条件、退出条件和用户可感知行为。
释放顺序要能被 QA 复现
资源释放顺序不能靠“谁先注册谁先释放”。建议按收益和风险排序:先释放 rebuildable,再释放 cold,再降级 warm,最后才考虑可替代的 critical 附属资源。每个释放动作都要输出记录:资源 id、owner、tier、释放前估算大小、释放结果、失败原因、是否需要恢复。
一个可操作的释放计划可以像这样:
func build_memory_relief_plan(level: String) -> Array[MemoryAction]:
var actions: Array[MemoryAction] = []
actions.append_array(cache.collect_rebuildable_actions())
actions.append_array(cache.collect_cold_release_actions())
if level in ["warning", "critical"]:
actions.append_array(cache.collect_warm_degrade_actions())
if level == "critical":
actions.append_array(scene_guard.collect_survival_actions())
return action_policy.sort_by_risk(actions)
真实项目里,MemoryAction 不应该只是一个回调。它要包含预计释放量、执行成本、是否可分帧、失败时是否继续、恢复方式和调试说明。这样线上出现“收到内存警告但仍被系统杀掉”时,团队能知道是释放量不够、执行太慢、某个 owner 拒绝释放,还是资源统计不准。
当前关卡优先级最高
移动端内存治理经常在一个原则上失败:为了保住全局体验,破坏了当前关卡体验。玩家正在进行 Boss 战时,客户端最重要的任务是让这场战斗完整结束。即使画质降低、远处资源不再预加载、结算页延迟显示,也比当前战斗中断要好。
所以资源降级要接入场景保护。SceneProtectionProfile 可以描述当前场景的最低生存资源:玩家角色、敌人、判定资源、关键特效、关键音频、UI 必需字体、存档缓冲区。内存警告到来时,释放计划必须先询问这个 profile。它不需要保住所有视觉表现,但必须保住玩法正确性。
举个例子,开放世界场景里有大量远景模型和环境音。内存压力下可以先降低远景加载半径、暂停远处 NPC 预取、把非交互装饰切到低清材质。但当前任务目标、战斗区域、交互提示和角色装备不能被同样处理。Godot 的场景树很容易让开发者通过节点分组批量处理资源,但批量处理前必须先有 owner 和 scope,否则看起来省事,实际是在碰运气。
恢复比释放更容易被忽略
释放资源只是半个系统。玩家继续游玩时,客户端要知道什么时候恢复、恢复到什么质量、恢复失败怎么办。最糟糕的情况是内存压力解除后,一帧里把所有资源重新加载回来,造成明显卡顿,甚至再次触发内存警告。
恢复策略应当慢。进入 Cooldown 后,先恢复当前场景必需的降级资源,再恢复玩家可能马上看到的 UI 和音频,最后恢复预加载队列。恢复过程仍然要看帧预算、网络状态、电量和温度。移动端上,内存压力经常和发热、低电量、后台切换同时出现,单独盯内存并不可靠。
恢复还要处理版本变化。假设内存警告期间资源包更新、玩家切换账号或活动结束,旧的恢复计划就不能继续执行。每个恢复任务都要带 resource_revision 和 scene_revision。如果 revision 不匹配,任务应该丢弃,而不是把过期资源塞回缓存。
和资源包下载的关系
iOS 内存警告期间,下载系统也要参与。很多项目只在渲染资源上做释放,却让后台下载继续解压、校验、写缓存。解压峰值可能比下载体积更危险。一个 200MB 的资源包,在解压、哈希校验和导入缓存时可能制造更高瞬时压力。
建议在内存压力状态下把下载队列分成三类:当前关卡必需、玩家明确等待、后台机会型。第一类可以继续,但要限制并发和解压峰值;第二类要提示玩家当前设备压力较高,可能需要稍后继续;第三类直接暂停。暂停不是失败,UI 文案也不能写成“下载失败”。它应该显示为“设备内存紧张,已暂停后台下载,稍后自动继续”。
资源包系统还要提供解压前检查。不要只看磁盘空间,还要估算解压过程的内存峰值和临时文件大小。移动端资源事故里,很多问题不是包下载不下来,而是下载完的一瞬间把系统推到危险区域。
日志和调试面板
没有观测的内存降级,很难长期维护。开发包里建议加一个 Memory Pressure 面板,显示当前状态、最近一次警告时间、资源 tier 统计、释放计划、实际释放结果、被 owner 拒绝的资源、恢复队列和场景保护 profile。这个面板不需要漂亮,但必须能让 QA 在真机上截图反馈。
日志字段至少包括:device_model、os_version、build_channel、scene_id、memory_state、warning_level、plan_id、released_bytes_estimated、actions_count、denied_count、survival_mode_entered。如果线上崩溃系统能上传前置事件,还要把最近 30 秒的内存压力事件附带上。
不要记录用户隐私,不要记录完整文件路径里的账号信息。资源 id、包版本、场景 id 和 owner 已经足够排查大多数问题。日志要服务于判断,不是把设备状态一股脑上传。
QA 场景
QA 不应该只在空场景里模拟内存警告。至少要覆盖这些场景:主城停留 10 分钟后收到警告、战斗中连续两次警告、打开背包和角色预览时警告、后台下载解压时警告、切后台再回来后警告、拍照模式高分辨率截图后警告、低端设备连续切场景后警告。
每个场景都要验证三件事。第一,当前玩法是否继续正确,不能因为释放资源导致判定、UI 或音频状态错乱。第二,降级是否可见但可接受,例如远景变糊、预览延迟、非关键动画减少。第三,恢复是否平滑,不出现一次性卡顿和重复报警。
自动化可以覆盖一部分,例如通过调试命令注入 memory_warning,然后检查状态机和缓存统计。但真机测试仍然必要,因为系统内存压力和 Godot 内部统计不是一回事。尤其 iOS 不同设备的杀进程阈值不同,同样的缓存策略在高端机上看不出问题,在低端机上可能很快暴露。
落地顺序
第一阶段只做观测和分层,不急着释放。把现有资源清单整理出来,统计 owner、tier、估算大小和引用来源。很多团队做到这一步就会发现大量资源没有 owner,或者一些 UI 关闭后仍然持有场景引用。
第二阶段接入轻度释放:缩略图、截图缓存、临时材质、关闭页面资源、远处区域预加载。此时不要动当前关卡关键资源,先验证释放动作不会破坏状态。
第三阶段才做可见降级:贴图 LOD、粒子密度、角色预览质量、远景半径、后台下载暂停。可见降级要和 UI、日志、QA 标准一起上线,不能只靠代码悄悄处理。
第四阶段补恢复策略和线上指标。记录每次内存警告后是否继续存活、是否进入 SurvivalMode、是否发生二次警告、恢复耗时和玩家流失点。只有这些指标稳定,内存警告处理才算从“应急代码”变成“客户端系统”。
小结
iOS 内存警告不是一句“清缓存”能解决的问题。Godot 客户端要先知道资源归属和重要度,再按状态机释放和恢复。最重要的原则是保住当前关卡,降低非关键体验,而不是为了释放更多内存把玩家正在做的事情打断。
如果项目现在还没有这套系统,建议从 ResourceCache 加 owner 和 tier 开始。先让资源能被解释,再让释放能被复现,最后再追求更细的自动化策略。内存压力下,清晰的边界比激进的释放更可靠。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。