在 M1 的 Mac 上把 WebRTC 源码编成 iOS 能直接用的 WebRTC.framework,再进一步合成一份同时包含真机与模拟器切片的 WebRTC.xcframework,这件事看起来只是两三条命令,真动手的人多半会在第一步就卡上半天。我自己第一次在 M1 上做这件事的时候,卡住的地方不是编译本身,而是“为什么真机编出来的东西模拟器就是链接不上”这个最朴素的问题。这篇内容讲的就是把这条链路彻底打通:从拉源码、配 args.gn、跑 ninja,到补 module map、拼 xcframework、接进工程、排查各类报错。适合已经会一点 iOS 工程配置、但没系统做过 WebRTC 自编译的开发者,也适合之前只在 Intel Mac 上编过、换到 M1 之后发现流程全变了的人。整篇以实操为主线,每个参数我都会说清楚为什么这么填,踩过的坑也会原样写出来。
1. 先把产物理清楚:framework 和 xcframework 到底差在哪
1.1 一个 framework 只能装一种“平台 + 架构”组合
很多人第一次做这件事的误区,是以为编一次就能通用。实际上一份 WebRTC.framework 里面的二进制是一个 Mach-O 文件,它带着明确的平台标记(platform)和 CPU 架构(architecture)。真机要的是 platform 为 iOS、架构为 arm64 的切片;M1 上的模拟器要的是 platform 为 iOS Simulator、架构为 arm64 的切片;Intel Mac 上的模拟器要的又是 iOS Simulator + x86_64。这三者在链接器眼里是完全不同的东西,你把真机切片丢给模拟器工程去链接,就会得到那句经典报错:“building for iOS Simulator, but linking in dylib built for iOS”。
所以正确的思路不是“想办法编一个万能的”,而是“分别编,最后装进一个容器里”。这个容器就是 xcframework。理解了这一点,后面所有的目录结构、脚本写法、参数区分,都是围绕这个前提在转。
1.2 xcframework 本质上是“分包”,并不是“合并”
xcframework 这个名字容易让人误以为是像 fat binary 那样把多个架构塞进同一个文件。其实不是。它就是一个目录,里面按“平台 + 架构”分了若干个子目录,每个子目录里放一份完整的 framework,外加一个顶层的 Info.plist 记录清单。Xcode 在链接的时候读这份清单,根据当前的编译目标自动挑对应的那份。
这个设计带来的直接好处是:你可以在同一个 xcframework 里同时放真机切片和模拟器切片,工程配置一次就够,不用来回切。代价是目录会变大,因为每份 framework 都是完整的一份,头文件、module map、Info.plist 全都重复。理解了“它是分包、不是合并”,你就不会去纠结“为什么我用 lipo 合完之后 xcodebuild 还是报错”——lipo 合的是同一个平台下的不同架构,跨平台合并它做不了,也不该做。
1.3 为什么 M1 让这件事变复杂了
在 Intel 时代,模拟器只有 x86_64 一种架构,流程非常线性:编一个真机 arm64,编一个模拟器 x86_64,两份丢进 xcframework,收工。换成 M1 之后,模拟器多了一个 arm64 变体,而且这个变体跟真机的 arm64 长得一模一样,只有平台标记不同。人眼分不出来,脚本也容易分不出来,于是就有了大量“明明架构对得上,为什么链接失败”的困惑。
更麻烦的是生态兼容期。一些老的构建脚本、老的 Python 依赖、老的 gn 二进制,最初都不是为 arm64 准备的,运行时会走 Rosetta 转译,表现就是“命令能跑,但特别慢”或者“跑到某一步莫名其妙挂了”。我的建议很直接:如果分支不太老,尽量用原生的 arm64 工具链跑完整个流程,别为了省事一直挂在 Rosetta 下,转译带来的问题排查成本远高于一次性把环境理顺。
从影响范围看,自编译产物会直接决定三件事:一是最终 App 的二进制体积,真机切片通常十几到二十几 MB,裁剪参数配不好很容易翻倍;二是启动阶段的动态库加载开销,动态 framework 比静态链接多一次加载;三是后续升级维护成本,你自己编的包意味着版本要自己跟,不能指望一键升级。这三条决定了“要不要自编译”本身就是一个需要想清楚的问题——如果你只是想让 iOS 端跑通一个 1 对 1 通话,官方预编译包完全够用;只有当你需要改源码、需要特定版本、需要裁掉某些编解码器、需要开 H.264 硬编硬解、或者需要保留调试符号时,才值得走这条路。
2. 环境准备:工具链、磁盘和版本对齐
2.1 三个必须先装好的东西
第一个是 Xcode 本体,注意是完整安装,不是只装命令行工具。WebRTC 的 iOS 编译链路里会用到 Xcode 自带的 SDK、clang、还有打包 framework 需要的工具,缺一样都会在中途炸。装完之后记得执行一次切换,让xcode-select -p指向正确的路径,而不是指向 CommandLineTools 目录——很多人卡在“找不到 iOS SDK”,根源就在这。
第二个是 depot_tools。它不是一个普通的工具包,而是一组脚本的集合,负责拉代码、管依赖、生成构建文件。安装方式就是从上游仓库克隆一份到本地固定目录,然后把这个目录加到 PATH 的最前面。这里有个细节:一定要放在 PATH 最前面。因为 depot_tools 里自带了一些同名工具(比如 gn、ninja 的引导脚本),如果系统里已经装了别的版本,PATH 顺序不对就会用错。
第三个是 Python 3。现在的构建脚本基本都迁到 Python 3 了,但个别老分支还在用 Python 2 的写法。判断方法很简单:跑一次同步命令,如果报语法错误或者模块找不到,八成就是分支太老,这时候要么换分支,要么老老实实准备一个 Python 2 环境。
2.2 depot_tools 的 PATH 处理与自动更新
depot_tools 默认每次执行都会尝试自我更新。这在网络顺畅的时候是好事,能保证你拿到最新的脚本;但在网络不稳或者你正在复现一个特定版本的构建时,自动更新就是个麻烦,因为它可能在你两次编译之间悄悄换掉脚本版本,导致结果不一致。
我的做法是:环境搭好、确认能正常拉代码之后,把自动更新关掉,用环境变量控制。这样整个项目周期内工具链是静止的,出问题好回溯。等要升级的时候,手动拉一次,重新验证一遍再继续用。这个习惯看起来有点保守,但在做过几次“昨天还能编,今天突然编不过”的排查之后,你会觉得这点保守很值。
另外提醒一下磁盘。一次完整的 iOS 源码检出加上构建产物,轻松吃掉几十 GB。如果你的机器是 256G 的,最好提前清一下,或者把构建目录放到外置盘上——不过外置盘要注意文件系统大小写敏感性和性能,机械盘上跑 ninja 会让人怀疑人生。
2.3 Xcode 版本要跟分支“配年份”
这是我在 M1 上踩得最深的一个坑。WebRTC 的源码和 Xcode 的 SDK 之间有隐性的版本耦合:新分支配新 Xcode 基本没问题,老分支配新 Xcode 就很容易在编译系统头文件的时候报错,典型表现是某个系统头文件里的声明和源码里的用法对不上,或者某个早已废弃的 API 被当成错误。
所以选版本的正确姿势是倒过来的:先确定你要哪个 WebRTC 版本(通常取决于你的业务需要什么特性、修没修过某个 bug),再去看那个版本发布的时间点,选一个同期的 Xcode。别反过来“我先升到最新 Xcode,再去挑一个最老的 WebRTC 分支”,那样大概率是自找麻烦。
如果真的必须在老分支上用新 Xcode,也不是完全没办法,可以在编译参数里适当放过一些警告、或者针对性打几个小补丁,但这是下策,维护成本高。我一般的态度是:能用匹配版本解决的事,绝不去改源码。
3. 拉源码:fetch、gclient 和 M1 上的省空间做法
3.1 fetch 与 gclient 的分工
这两个命令经常被混着说,其实分工很清楚。fetch是“第一次拉代码”的入口,它做两件事:创建目录结构、写一份基础的依赖配置文件,然后调用gclient sync把依赖拉全。gclient sync则是“按配置文件同步依赖”的执行者,后续任何一次更新都靠它。
针对 iOS,拉代码时可以直接用带目标的写法,让配置里默认带上 iOS 相关依赖,省去后面手工改配置。目录建好之后,你会看到一个src子目录,所有源码都在里面,src同级有一个依赖配置文件,后面要改的东西就在那份文件里。
一个实用技巧:如果你只是要编译、不打算提交代码、也不关心提交历史,可以在同步的时候加参数跳过历史记录。这样拉下来的仓库体积会小一大截,代价是git log之类操作不好用。对我这种只做编译的场景来说,这个取舍很划算。
3.2 依赖配置文件里值得改的字段
依赖配置文件里最值得关注的是自定义变量那一段。这里可以声明目标平台,让同步过程只拉 iOS 需要的那部分依赖,避免把 Android、Linux 的一堆东西也拖下来。写进去之后,后面所有同步都会按这个来。
另外可以考虑的是并发数。同步过程本质上是一堆 git 操作在排队,适当提高并发能明显缩短时间,但太高会打满网络或者触发对端限流,反而更慢。我的经验是在网络条件好的时候设成中等的值,比默认值快不少,也不至于翻车。
改完配置之后不要忘了重新跑一次同步,让改动生效。很多人改完配置直接就去编译,结果发现依赖缺一半,报错信息还特别隐晦,最后绕一大圈才回过头发现是配置没生效。
3.3 锁版本:checkout 到 branch-heads
同步下来的默认是主干的某个位置。如果你需要可复现的构建,一定要切到一个明确的点。最稳的做法是切到某个 branch-heads 上的具体版本号,而不是切到某个分支名——分支名是会往前走的,今天切和下周切拿到的东西不一样。
切完之后要再做一次同步,并且带上让依赖跟随主仓库版本对齐的参数,否则可能出现主仓库回退了、依赖还停在新版本的情况,编译时各种找不到符号。这一步做完,把当前版本号记到项目文档里,以后任何人要复现,照着这个号切就行。
我还养成了一个习惯:在src目录下用git status确认一下工作区是干净的,然后再开始编译。这样万一后面出了问题,可以直接git stash或者重置,不用在一堆混杂的改动里找原因。
4. args.gn:每个参数都要知道为什么
4.1 决定产物形态的四个核心参数
编译参数决定了你最终拿到什么形态的东西。最核心的是目标系统、目标架构、目标环境这三项,加上是否使用组件化构建。
目标系统填 iOS,这个没得商量。目标架构对真机来说是 arm64。目标环境用来区分真机和模拟器,这是 M1 时代新增的、也是最容易被忽略的一项——很多老教程里没有这个参数,因为它们写于只有 x86_64 模拟器的年代,那时候靠架构就能区分,现在靠架构区分不了了。
组件化构建必须关掉。开着它会产出一堆散装的动态库,而不是一个完整的 framework,打包脚本会直接失效。这个参数在桌面平台上有它的价值(编译快、链接快),但在 iOS 的 framework 场景下只会添乱。
4.2 剪裁参数:把编译时间和体积都压下来
WebRTC 的默认构建目标非常全,测试、示例、工具链全都编,一次全量编译在小机器上能耗掉一两个小时。对我们只要 framework 的场景来说,这些统统是浪费。
关掉测试和示例是最直接的两刀,能砍掉大量无关目标的编译。关掉工具类的构建也能省不少。再配合发布模式构建、降低符号级别,整体时间能压到原来的三分之一左右。这里有个取舍要说清楚:符号级别降到零,出问题时的堆栈就是一堆地址,排查体验极差。我的建议是先在发布模式下把符号级别设成一,保留基本的符号信息,等真要极限压体积的时候再降到零,并且单独留一份带完整符号的构建产物用于崩溃分析。
还有一个参数叫是否启用完整优化构建,开启后会用更激进的优化选项,产物更小更快,但编译更慢、也更难调试。正式对外发版时值得开,日常开发调试时关掉更舒服。
4.3 编解码相关:H.264 不是默认就有的
这一点是很多人事后才发现的:默认编译出来的 WebRTC 里,H.264 的软件编解码是不带的,因为它涉及专利许可,上游默认关闭。如果你只是跑 VP8/VP9,那没问题;但 iOS 生态里 H.264 硬编硬解几乎是刚需,没有它你会发现在某些设备组合上完全谈不起来。
要开启,需要同时打开专有编解码选项和 H.264 开关,并且把 ffmpeg 的品牌配置切成包含专有编解码的那一档。三个参数是一组的,少一个都不生效——这是我在一个项目上折腾了整整一个下午才确认的事,只开 H.264 开关不开专有编解码,编译能过,运行时就是协商不出 H.264。
顺带说一句,开启硬编硬解以后,iOS 端的采集和编码路径会走系统提供的视频工具箱接口,功耗和发热表现比纯软编好一个档次,这对移动端来说不是优化项,是必需品。
4.4 一份可以直接抄的编译参数
下面是真机切片的参数示例,模拟器切片只需改目标环境与架构两项。不同分支的参数名有细微差异,遇到不认识的参数时用列参数的命令确认一下就知道了。
target_os = "ios" target_cpu = "arm64" target_environment = "device" is_debug = false is_component_build = false is_official_build = true rtc_build_examples = false rtc_build_tools = false rtc_include_tests = false use_xcode_clang = true ios_enable_code_signing = false ios_deployment_target = "13.0" enable_ios_bitcode = false rtc_use_h264 = true proprietary_codecs = true ffmpeg_branding = "Chrome" symbol_level = 1 enable_dsyms = true几个参数单独说明。关掉代码签名是为了避免构建过程要求你配好开发者证书,真机包在 Xcode 里嵌入时会重新签名,所以构建阶段不需要签。关掉 bitcode 是因为新版 Xcode 已经不再支持它,开着只会报错。部署目标别设太低,太低会触发新旧 SDK 之间的一些兼容问题,设成比你实际支持的最低版本略高一点比较稳。
5. 跑编译:gn gen 到 ninja 的实操记录
5.1 生成构建目录并确认目标名
参数写进构建目录之后,第一步是生成构建文件。这一步会校验参数合法性,参数写错会直接报错,是好事情,比编到一半才炸强。生成成功后会得到一个装满构建脚本的目录。
接下来要确认目标名。不同分支里,产出 framework 的目标名不完全一样,有的叫带模块前缀的写法,有的直接是短名。用列目标的命令筛一下带 framework 关键字的,就能看到你这个分支里到底叫什么。这一步千万别跳过,照抄别人教程里的目标名是最常见的失败原因之一。
同时建议看一下产出路径。framework 的落点一般在构建目录的根下,名字就是 WebRTC.framework。如果编译成功但你找不到产物,多半是它在别的子目录里。
5.2 ninja 并发数与内存的平衡
ninja 默认按 CPU 核心数开并发。M1 的八核机器上,全开能跑到很快,但同时会吃掉大量内存,编译中途如果系统开始频繁换页,速度反而会掉下来,严重的时候编译器会被系统杀掉,报一个看起来很莫名的错误。
我的经验值是并发数设成核心数减去一到两个,留出余量给系统和编辑器。这样总时间几乎不变,但稳定性好很多。如果你是 16G 内存的机器,可以再保守一点。
还有一个细节:第一次编译一定要留足时间,不要在旁边开着浏览器几十个标签页。我第一次跑真机切片全量编译,八核机器上花了接近半小时,中间因为内存压力失败过一次,重跑才过。后面增量编译就快多了,改点参数重新打包通常几分钟。
5.3 产出物定位与快速验证
编译完成后,先用 lipo 看一眼架构信息,确认是真机 arm64 而不是别的。再用查看构建版本的工具确认平台标记,这一步能提前发现“参数没生效”这类问题。我见过有人参数写在旧目录里、编译却跑在新目录里,结果编出来的是模拟器切片,接进工程后死活链不上,排查了半天。
同时确认一下 framework 里的头文件目录是不是齐的,特别是 Objective-C 的公开头文件。如果头文件缺失,后面打包脚本会做出一个“能链接但没有接口声明”的空壳,编译时一堆找不到类型的错误,非常容易误判成链接问题。
6. 打包 WebRTC.framework:补上 module map 和 umbrella header
6.1 framework 的目录结构长什么样
一个标准的动态 framework 目录下,二进制文件和资源文件平铺,头文件放在 Headers 子目录里,模块描述文件放在 Modules 子目录里。上游构建出来的产物,头文件通常没问题,但模块描述文件和总头文件不一定齐——这取决于你切的分支和当时的构建脚本。
如果没有 Modules 目录或者里面是空的,用@import WebRTC;这种模块化引入就会失败,报模块找不到。这时候只能手动补。很多人以为是 framework 做坏了,其实是缺了这两样东西。
6.2 手写 module.modulemap 与 WebRTC.h
总头文件的职责是把所有公开头文件聚起来,模块描述文件则告诉编译器“这是一个叫 WebRTC 的框架模块,从这里找头文件”。
// Headers/WebRTC.h #import <WebRTC/RTCCameraVideoCapturer.h> #import <WebRTC/RTCPeerConnection.h> #import <WebRTC/RTCPeerConnectionFactory.h> #import <WebRTC/RTCSessionDescription.h> #import <WebRTC/RTCIceCandidate.h> #import <WebRTC/RTCDataChannel.h> #import <WebRTC/RTCMediaStream.h> #import <WebRTC/RTCVideoTrack.h> #import <WebRTC/RTCAudioTrack.h> #import <WebRTC/RTCVideoRenderer.h> // 其余公开头文件按实际 Headers 目录内的文件名补全// Modules/module.modulemap framework module WebRTC { umbrella header "WebRTC.h" export * module * { export * } }两个注意点。第一,总头文件里#import的路径前缀必须跟模块名一致,写成别的名字会找不到。第二,模块描述文件里framework module后面的名字决定了你在代码里@import什么,别写错。补完之后,最好用一个空工程引一次,确认能编译通过再往下走。
6.3 一份可复用的打包脚本
手动复制粘贴容易漏文件,写成脚本更稳。下面这段脚本做三件事:把构建目录里的 framework 拷到统一输出位置、补上缺失的模块文件、打印出二进制信息用于校验。
#!/bin/bash set -euo pipefail BUILD_DIR="${1:?用法: ./package.sh out/ios_arm64}" OUT_ROOT="dist" NAME="WebRTC" SRC_FW="${BUILD_DIR}/${NAME}.framework" DST_FW="${OUT_ROOT}/${BUILD_DIR##*/}/${NAME}.framework" if [ ! -d "${SRC_FW}" ]; then echo "找不到产物: ${SRC_FW}" exit 1 fi rm -rf "${DST_FW}" mkdir -p "$(dirname "${DST_FW}")" cp -R "${SRC_FW}" "${DST_FW}" # 补模块描述文件 if [ ! -f "${DST_FW}/Modules/module.modulemap" ]; then mkdir -p "${DST_FW}/Modules" cat > "${DST_FW}/Modules/module.modulemap" <<'EOF' framework module WebRTC { umbrella header "WebRTC.h" export * module * { export * } } EOF fi # 补总头文件(已存在则跳过) if [ ! -f "${DST_FW}/Headers/WebRTC.h" ]; then { echo "// 自动生成的总头文件" for h in "${DST_FW}"/Headers/*.h; do base=$(basename "$h") [ "$base" = "WebRTC.h" ] && continue echo "#import <WebRTC/${base}>" done } > "${DST_FW}/Headers/WebRTC.h" fi echo "== 架构信息 ==" lipo -info "${DST_FW}/${NAME}" echo "== 平台信息 ==" xcrun vtool -show-build "${DST_FW}/${NAME}" | head -20这段脚本里我特意做了“已存在则跳过”的判断,因为有些分支自己就带了模块描述文件,你硬覆盖反而可能覆盖掉更完整的版本。自动生成总头文件那段也是同样的考虑,只在缺失时才生成,并且以实际头文件列表为准,不会漏。
7. 合成 WebRTC.xcframework
7.1 模拟器切片:arm64 与 x86_64 怎么取舍
M1 上的模拟器跑的是 arm64,但你的团队里可能还有人在 Intel Mac 上开发。这时候就有两个选择:只编 arm64 模拟器切片(体积小、编译快,但 Intel 同事用不了),或者两个都编再合成胖切片(兼容性好,但体积和编译时间都翻倍)。
我的判断标准是看团队构成。纯 M1 团队就只留 arm64,省下的体积很实在;混合团队就两个都编。至于“以后可能有 Intel 同事”,别为假设的场景付出确定的成本,等真有人提需求再补一个切片,流程是现成的。
另外提醒一句,两种模拟器切片合成胖切片的时候,只合二进制,不要整个目录合。做法是复制一份完整的 framework,然后用 lipo 把两个二进制合出来替换掉里面那一个。这样头文件和模块描述文件只保留一份,不会冲突。
7.2 用 xcodebuild 合成 xcframework
合成命令本身很简单,把每个切片的 framework 路径按顺序传进去,指定输出路径即可。关键是顺序和平台别搞混:真机切片必须在,模拟器切片按需加。
xcodebuild -create-xcframework \ -framework dist/ios_arm64/WebRTC.framework \ -framework dist/ios_sim_arm64/WebRTC.framework \ -framework dist/ios_sim_x64/WebRTC.framework \ -output dist/WebRTC.xcframework如果只做两个切片,就把第三行去掉。执行完之后,输出目录里会按平台分出子目录,顶层有一份清单文件。
这里有个容易忽略的点:如果模拟器的两个切片你已经在外面用 lipo 合成了一个胖 framework,那就不要再把两个单独的也传进去,否则会报架构冲突。要么全用分开的,要么用合成好的一个,二选一。
7.3 结果校验与常见异常
合成完先看目录结构,再读顶层清单确认平台列表对不对。清单里每一项会写明支持的平台和架构变体,这一份就是 Xcode 挑切片时的依据。
ls dist/WebRTC.xcframework plutil -p dist/WebRTC.xcframework/Info.plist lipo -info dist/WebRTC.xcframework/ios-arm64/WebRTC.framework/WebRTC常见的异常有三种。一是清单里只有一个平台,说明你传进去的切片被识别成同一个平台了,多半是模拟器切片其实编成了真机切片,回头查参数。二是报某个 framework 缺少 Info.plist,那是拷贝过程漏了文件。三是报架构重复,就是上面说的胖切片和单切片混传。这三种我都遇到过,处理方式都很直接,不需要改源码。
8. 接进工程:链接配置与运行时权限
8.1 头文件、模块与链接标志
把 xcframework 拖进工程之后,先确认它出现在“框架、库与嵌入内容”列表里,嵌入方式选“嵌入并签名”。这一步不做,运行时会崩在动态库加载阶段,报找不到镜像。
引入方式有两种:模块化引入或者按路径引入头文件。前者更干净,但依赖模块描述文件正确;后者兼容性好,但需要配头文件搜索路径。我一般先用模块化引入试,不行再退回路径引入。
链接标志里有一个必须加:-ObjC。原因是 Objective-C 的类别实现不会被自动链接进来,不加这个标志,某些分类方法在运行时会报“unrecognized selector”。这个坑非常隐蔽,编译期一切正常,跑起来才崩,而且崩的位置往往跟 WebRTC 看起来没关系。
8.2 需要链接的系统框架清单
WebRTC 依赖的系统框架不少,少一个就是一堆未定义符号。下面这份清单覆盖了绝大多数场景,缺什么补什么,看链接报错里提示的符号前缀就能判断。
| 框架/库 | 作用 | 是否必需 |
|---|---|---|
| AVFoundation | 音视频采集与播放 | 必需 |
| AudioToolbox | 音频格式转换 | 必需 |
| CoreAudio | 音频会话与单元 | 必需 |
| CoreMedia | 时间戳与媒体样本 | 必需 |
| CoreVideo | 像素缓冲管理 | 必需 |
| VideoToolbox | 硬件编解码 | 开硬编时必需 |
| UIKit / Foundation | 基础运行时 | 必需 |
| Security | 加密与证书 | 必需 |
| Network | 网络状态与连接 | 多数分支需要 |
| Metal / MetalKit | 渲染路径 | 用 Metal 渲染时需要 |
如果用的是更新一些的分支,可能还会牵扯到屏幕采集、音频处理相关的额外框架,遇到未定义符号就去搜对应的符号名,一般能定位到具体框架。别一上来就把能想到的框架全加上,那样会拖慢链接、也可能引入不必要的依赖。
8.3 Info.plist 与真机权限
跑真机之前必须配两个权限描述:相机和麦克风。这两项缺失的表现是应用直接闪退,控制台会打印一条明确的崩溃原因,但因为闪退太快,很多人来不及看。我的习惯是在接入第一天就把这两项配上,别等联调的时候才发现。
音量相关的会话配置也要注意。默认的音频会话类别不一定适合通话场景,需要在建立连接前后主动设置音频会话类别并激活,否则会出现“能听到对方但对方听不到你”或者“声音从听筒出来特别小”这类问题。这些不属于编译范畴,但既然走到了自编译这一步,通常意味着你要深度定制,顺手一起处理掉更省事。
9. 问题排查速查表
把我在 M1 上遇到过的问题按阶段整理成表,遇到相似现象可以直接对号入座。
9.1 编译期问题
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 生成构建文件时报未知参数 | 分支较老,不支持目标环境参数 | 改用该分支支持的写法,或换分支 |
| 系统头文件报错 | Xcode 版本与分支年份不匹配 | 换成同期版本 Xcode |
| 编译中途进程被杀 | 并发过高导致内存不足 | 降低并发数,关闭占内存的应用 |
| 找不到 framework 目标 | 目标名与教程不同 | 列出目标,筛选关键字确认 |
| 编译成功但产物是模拟器切片 | 参数写在了另一个构建目录 | 检查构建目录与参数文件是否对应 |
关于“系统头文件报错”这一条,我再补充一点判断方法:如果报错集中在一两个系统头文件上,而且是“某个声明不存在”或者“类型不完整”这类,基本可以确定是版本不匹配。这时候不要试图改源码绕过,改一处会引出十处,换 Xcode 是最省时间的做法。
9.2 链接期问题
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| building for iOS Simulator, but linking in dylib built for iOS | 切片平台不匹配 | 用 xcframework,别手工指定单个 framework |
| 大量 undefined symbols | 系统框架没链 | 按符号前缀补链接项 |
| 找不到模块 WebRTC | 模块描述文件缺失 | 补 module.modulemap 与总头文件 |
| 找不到头文件 | 搜索路径没配 | 配 framework 搜索路径或改用模块引入 |
| 架构重复冲突 | 胖切片与单切片混用 | 只保留一种形式 |
“大量 undefined symbols”这个现象特别值得说一句。如果未定义符号里出现的是系统 API(比如音频、视频相关的函数),那是框架没链;如果出现的是_OBJC_CLASS_$_RTC...这类,那是 framework 本身没链进去或者没嵌入。这两类的排查方向完全相反,看符号名就能分清,能省很多时间。
9.3 运行期问题
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 启动即崩,提示找不到镜像 | 没设嵌入并签名 | 改成嵌入并签名 |
| 相机或麦克风权限闪退 | Info.plist 缺权限描述 | 补两项描述 |
| unrecognized selector | 缺-ObjC链接标志 | 加上该标志 |
| 通话无声 | 音频会话未配置 | 设置类别并激活 |
| 模拟器能跑真机崩 | 用了模拟器切片或反之 | 检查 xcframework 清单 |
关于“模拟器能跑真机崩”这一类,还有一种隐蔽情况:xcframework 里两个切片都存在,但真机那份是用调试模式构建的,带了不该带的依赖。这种问题不会报链接错误,只在真机上表现为启动慢或者某些能力不可用。解决办法是发版构建严格用发布模式重新编一份,别把调试产物直接拿去发。
10. 几条踩过坑之后才明白的经验
第一条,构建目录一次只放一种目标。别想着“我在同一个目录里改参数再编一次模拟器”,那样增量构建的缓存会混在一起,出问题时你根本分不清是参数错了还是缓存脏了。我现在是每种切片一个独立目录,命名上带上平台和架构,一眼就能看出来是哪个,清理的时候也方便。
第二条,产物一定要打标记。我习惯在打包完成后,把版本号、提交点、构建时间写到一个文本文件里,跟 xcframework 放在一起。原因是这个东西一旦接进工程,过几个月回头看,没人记得这份包是从哪个点上编出来的。等线上出了崩溃、需要对符号还原的时候,没有这个记录就只能重新编一份去猜,非常痛苦。
第三条,别轻易追新分支。WebRTC 的迭代速度很快,新分支可能带新特性,也可能带新的构建问题。除非你确实需要某个新能力或者某个关键修复,否则用一个验证过的版本长期维护,比每次跟着升要省心得多。我吃过一次亏:为了一个小优化升了分支,结果整个构建链路重配了两天,最后发现那个优化对我的场景根本用不上。
第四条,符号文件单独留档。带符号的那份构建产物体积大,不适合放进代码仓库,但一定要单独存好。崩溃分析的时候,没有对应的符号文件,堆栈里全是地址,定位成本会高到让人放弃。我现在的做法是把符号文件和版本记录放在一起,按版本打标签存到内部存储上。
第五条,如果团队里有多个人要做这件事,把整个流程脚本化。从拉代码、切版本、生成参数、编译、打包、合成 xcframework,全部写成一个可以一条命令跑完的脚本,并且把每一步的关键输出打印出来。这样别人接手的时候不用问你,看日志就知道走到哪一步、卡在哪一步。我现在维护的那套脚本,最初就是为了让别人不用来问我,结果最受益的其实是我自己——半年后回头重跑,全程零思考。