一份停在 2017 年、引擎分支还是 UE4.17 时代的手游工程,要在 2026 年的 Xcode 26、clang 17 和新 iOS SDK 上重新出包。编译、签名、热更新都打通后,真机又出现剧情 CG 有声音有字幕、画面却纯白的怪问题。最后当然修好了,但真正值得记录的不是那几行补丁,而是怎样在旧引擎、新工具链、跨平台资源和真机渲染组成的黑盒里,一层层找到它。
这不是一个 Bug,而是一串年代断层
表面任务只是“把 iOS 包重新打出来”,实际同时跨了好几层年代:
| 层 | 老工程默认相信的世界 | 2026 年实际面对的世界 |
|---|---|---|
| C++ | 2016 年前后的 clang 会放过很多旧写法 | clang 17 会严格检查,甚至利用未定义行为做激进优化 |
| 构建系统 | Perforce、远程 Mac、厂内预编译库都在线 | 工作区不完整,旧二进制甚至已无法在现代 macOS 执行 |
| 引擎 | 老 iOS RHI、Slate、MoviePlayer 的时序和状态假设 | Metal、UIKit、签名和线程检查规则已经变化多轮 |
| 资源 | Windows cook、Mac 编译、CDN 热更各走一段 | 任意一段“看起来成功”都可能只是局部成功 |
| 验证 | 能编译、能启动就接近完成 | 真机、冷启动、热更后、过场播放、最终 IPA 都是不同状态 |
这种任务最危险的地方,是每条报错都像真因,每个旧经验也都有一点道理。如果跟着最新一行日志跑,几天时间很容易耗在互不相关的问题上。
第一步:把日志分级,不要见红就修
真机启动时能同时刷出几十屏信息:旧 LLDB 格式化脚本路径失效、UIScene 生命周期提醒、Game Center entitlement、主线程检查、废弃 UIKit API、商店沙盒报错、工程相对路径不存在……它们严重程度完全不同。
我最后把日志只分成四类:
| 类型 | 判断标准 | 处理方式 |
|---|---|---|
| 当前阻断 | 进程退出、Fatal、链接失败、资源读取明确失败 | 立即处理 |
| 功能缺口 | 某项 SDK、支付、Game Center 或推送不可用 | 按本次验收范围决定优先级 |
| 技术债 | 线程检查、废弃 API、未来系统会强制的生命周期要求 | 记录,后续单独治理 |
| 调试噪声 | LLDB 本机路径、旧开发机绝对路径、调试器辅助模块加载失败 | 不让它带偏当前故障 |
一个很实用的判断是:这条日志能不能解释眼前全部现象?
例如调试器找不到 2017 年开发机上的 Python 文件,解释不了“App 已经正常运行、声音和字幕正常、只有大画面是白色”;Game Center entitlement 也解释不了静态加载背景为什么一起白。它们要修,但不是这次白屏的主线。
编译阶段:先问新编译器揭露了什么
面对老工程,最省事的冲动是把 -Werror 关掉、警告全压掉。这样有时能多编几步,也可能把真正的运行期炸弹一起藏进去。
更稳的做法是把编译问题再分三类:
- 纯风格警告:旧 builtin 改名、隐式拷贝、枚举转换等,语义没有变化,可以沿着原厂工具链已有的方式精准关闭对应 warning;
- 语言规则错误:例如旧编译器曾放过的访问控制问题,只做最小语义修正,不借机重构大段代码;
- 未定义行为:这是最危险的一类。新编译器并不是“误报”,而是终于开始利用 C++ 标准允许它利用的假设。
这次最典型的是 UHT:二进制编译链接都成功,一运行却 SIGSEGV。真正有效的排查顺序是:
- 不带管道直接取退出码,避免拿到
tail的 0 当成程序成功; - 读 macOS crash report,先把范围缩到具体调用栈;
- 逐文件、逐行、逐操作加最小日志,找到数据第一次变得不可能的位置;
- 看到一个字符串长度突然变成
INT32_MAX后,沿整数运算往回查; - 再对照后续引擎分支,确认这段旧实现后来确实被改过。
中间还有一个很有迷惑性的插曲:把可疑函数摘出来写独立复现,怎么跑都正常。后来才发现真实函数被强制内联,编译器在调用现场掌握了更多常量信息;独立样例失去了那个优化上下文,所以它的“无法复现”并不能推翻假设。
独立复现失败,只能证明你的复现没有覆盖真实条件,不能自动证明原假设错误。 对优化器相关问题,内联、编译级别、常量传播和调用上下文都属于输入。
缺库时,让链接器告诉你到底缺什么
另一类典型坑是 Mac 宿主工具缺老静态库。看到目录为空,最直觉的做法是上网找同名的十年前二进制;这通常既不安全,也不一定真需要。
更有效的实验是放入最小占位 archive,让链接过程继续到底,再看最终未解析符号。文件名说“缺三个库”,链接器最后可能只真正需要几个符号,而且其中一些只属于宿主工具、根本不会进入 iOS App。
拿到精确符号后,再回到原厂代码里找:
- 有没有平台能力开关;
- 有没有系统库替代;
- 有没有厂商自己写好的 fallback;
- 这个依赖究竟属于 Mac 宿主程序,还是最终 iOS 运行时。
这比“把缺失文件补齐”更接近问题本质:我们要恢复的是必要能力,不是恢复 2017 年那台构建机的文件系统。
真机白屏:先做症状矩阵
包能启动、能完成原厂 PVRTC 热更新、也能进游戏后,最难缠的问题出现了:剧情 CG 有配音、有字幕、有跳过按钮,但主体画面纯白。
如果只看这一条,很容易把锅甩给 H.264、AVFoundation、音视频时钟或者视频文件。但把所有现象并排后,方向完全变了:
| 场景 | 主体画面 | 叠加 UI | 其它信号 |
|---|---|---|---|
| 剧情 CG | 纯白 | 字幕、跳过按钮正常 | 音频正常播放,结束后能进游戏 |
| 普通进图页 | 大背景纯白 | 转圈、文字、进度条正常 | 它根本不走视频解码 |
| 前端视频页 | 有时白、有时能显示 | 控件正常 | 同一安装、同一批资源 |
| 正常游戏场景 | 正常 | 正常 | PVRTC 纹理和热更新整体可用 |
| Android | 正常 | 正常 | 同一业务资源不构成充分证据,但可排除很多内容错误 |
这里最值钱的不是“视频白”,而是静态背景也以同一种方式白。一个不经过视频解码的入口,把 H.264、音轨和 AVFoundation 从“共同真因”位置上直接拿掉了。
两条看似无关的链路以相同方式失败,通常不是多了一个问题,而是它们在提醒你:去找更下游的共同层。
把“白屏”拆成七层可验证链路
“白屏”不是根因,只是最终像素。要定位它,必须不断追问:最后一个确定有颜色的地方在哪里?

这次把链路拆成了七层:
- 分发层:版本文件、manifest、分块对象是否真的存在;
- 重组层:客户端下载后能否按 hash 还原出与原件相同的文件;
- 系统解码层:不用游戏引擎,直接让原生框架解出指定帧;
- CPU 像素层:解码帧的尺寸、stride、中心 BGRA 是否非白;
- GPU 纹理层:上传后的真实 RHI texture 读回是否仍有相同颜色;
- UI 合成层:Slate 提交了哪个资源、几何、裁剪、混合和绘制顺序;
- 最终缓冲层:送去显示前的 backbuffer 中心像素到底是什么。
每一层都要求正面证据,而不是“日志没报错”。
versions.json返回 200,只能证明入口文件存在,不能证明 manifest 和几千个 chunk 都存在;- 有声音,只能证明容器和音频链工作,不能证明视频帧到了 GPU;
- GPU texture 有颜色,也不能证明最后画到屏幕上的就是它;
- 源码里有修复标记,也不能证明 Xcode 重新编译、链接并安装了那个二进制。
用证据逐个推翻“最像答案的答案”
一开始最像的答案是编码兼容。Android 之前确实遇到过老 MPEG-4 视频黑屏,也确实通过转 H.264 解决过,所以这个经验非常诱人。但 iOS 这次的证据不支持它:
- 本地原件与客户端下载重组文件 hash 一致;
- manifest 中所有分块存在,CDN 的长度和校验一致;
- 原生 AVFoundation 能从同一文件解出多帧非白 BGRA;
- CPU 解码像素上传到 GPU 后,读回仍是同样的颜色;
- 影片对应的矩形在屏幕内,顶点、索引、透明度也正常;
- 但最终 backbuffer 的对应位置仍然是纯白。
做到这里,“视频坏了”已经被逐层证否。故障范围只剩GPU 纹理之后、最终缓冲之前。
接下来做的不是继续猜,而是设计能把剩余空间一刀切开的实验:在最终绑定点把影片纹理临时替换成引擎内置的纯黑纹理。
- 如果手机变黑,说明四边形、顺序和 shader 能工作,原纹理采样有问题;
- 如果手机仍白,说明这个绘制结果没有成为最终可见像素,应该查裁剪、覆盖或后续合成。
真机仍然白。这个“失败的修复”反而给了非常强的证据:不要再回头折腾视频和纹理上传,继续往最终合成追。
随后对裁剪状态、alpha shader、batch 顺序分别做单变量实验;每个不生效的实验都及时撤掉。直到最终缓冲读回明确显示影片绘制后仍被白色结果占据,再换用引擎中一条已知稳定的屏幕拷贝路径做验证,问题才被钉死在旧 Slate 的这条合成路径上。
具体补丁只是这个工程的答案;真正可复用的是这套问法:
上一层已被什么数据证明?下一层怎样正面观察?哪一个实验能让两个假设产生完全不同的画面?
一次只改一个变量,并给每个包身份
真机排查最怕“这个包顺手改了五处”。它好了,不知道哪处有效;它没好,也不知道五个假设是不是互相抵消。

这次后半程强制执行了几条纪律:
- 每个实验包只回答一个问题,例如“GPU 中有没有颜色”“换成黑纹理后屏幕会不会黑”;
- 每包递增 build number,日志带唯一 marker,截图必须同时记录包版本;
- 写实验前先写出两种结果各自意味着什么,避免看到结果后临时编故事;
- 被证否的实验代码下一包就撤掉,不让临时路径越叠越厚;
- 同一场景、同一素材、同一安装策略复测,减少无关变量。
为了缩短回路,还要把昂贵复现变便宜:难到达的剧情 CG 可以临时挂到一分钟能进入的前端入口;调试阶段尽量覆盖安装以保留几 GB 热更新缓存,只有验证冷启动、首次安装或签名信任时才卸载重装。否则每个小实验都先等一次全量热更,人的耐心会比 Bug 更早耗尽。
诊断包能用,不等于发布包完成
找到真因时,诊断版里通常塞满同步 GPU readback、backbuffer 抽样、batch dump 和一次性日志。这些代码很适合问问题,却不适合留在 Shipping 包里。
最后必须再走一轮独立收口:
- 保留已被真机证明必要的行为;
- 删除同步读回和所有探针;
- 用 Shipping 配置重新编译,确认相关模块真的被重编并链接;
- 安装这份干净 App,重走同一热更新和同一白屏场景;
- 从这份已经测过的 App封装 IPA,而不是从另一个 DerivedData 目录随手捞产物;
- 核对签名、entitlements、bundle version、Mach-O UUID;
- 记录 App 二进制、IPA 的 SHA-256;上传后再从 CDN 完整下载一次,复算 hash 并检查 ZIP CRC。
这条身份链很重要:
修复源码 → 实际重编的目标文件 → 链接后的 Mach-O → 真机验证过的签名 App → Payload 封装的 IPA → CDN 完整下载后的同一份 IPA少任何一环,都可能出现“现场测好的包”和“最终交付的包”不是同一个东西。
还有一个容易混淆的运维边界:如果只改原生 C++ 和 IPA,热更新的 PAK、manifest、chunk、MP4、PVRTC 对象都没动,不要为了安心乱清 CDN;反过来,只要改了热更新对象,就必须明确刷新对应 CDN 缓存。把原生包和内容热更当成两条发布线,才能避免旧缓存把代码问题搅成资源问题。
这次最容易走错的几条路
- 追着最新一行红字跑:日志发生得近,不代表因果也近;
- 把 Android 的旧答案直接搬到 iOS:H.264 曾经救过 Android,不代表每次白屏都是编码;
- 看到入口 200 就宣布热更新正常:manifest、分块、重组 hash 必须逐层验证;
- 为了通过 clang 把警告全关:风格警告、真错误和 UB 必须分开;
- 连续叠加猜测补丁:没有单变量,就没有可解释结果;
- 相信“源码改了”:构建缓存、旧 UBT、Archive restore phase 都可能把旧二进制装回去;
- 每包都卸载重热更:除非测试冷状态,否则只是把复现回路人为拉长;
- 修好后直接拿诊断包交付:同步 GPU 读回在调试时是眼睛,在发布包里可能就是性能炸弹。
一套可以复用的排障顺序
以后再碰到“老客户端在新平台上能跑但表现诡异”,我会直接按这个顺序:
- 冻结基线:记录源码、工具链、配置、包版本和可稳定复现步骤;
- 做症状矩阵:找正常与异常场景的共同点和差异,而不是只描述“白了”;
- 日志分级:阻断、功能缺口、技术债、调试噪声分开处理;
- 画分层链路:文件、解码、CPU、GPU、合成、backbuffer、显示逐层列出;
- 要求正面证据:每层至少有 hash、像素、指针、几何或读回数据之一;
- 设计证伪实验:优先选择能让两个假设产生相反结果的探针;
- 单变量出包:一个 build 回答一个问题,被否定就撤;
- 缩短复现回路:把入口搬近、保留热更缓存、自动化构建和日志抓取;
- 诊断与发布分离:修好后清探针,再用干净 Shipping 包重测;
- 闭合产物身份链:从源码一直核对到 CDN 下载文件。
结论
老工程真正怕的不是新 Xcode,而是不可观测。当系统横跨十年、两台构建机、多个平台和一套热更新链时,凭经验猜一个“最常见原因”几乎必然反复返工。
这次最后能从“视频白屏”走到真正的渲染合成问题,不是因为突然想到了神奇补丁,而是因为每一步都在回答一个更小的问题:文件在不在、系统能不能解、CPU 有没有颜色、GPU 有没有颜色、最后一帧在哪里变白。
不要急着问白屏该改哪行代码。先问:最后一个被证明确实正确的层是哪一层。 下一层,就是你真正该下探针的地方。
部分信息可能已经过时