11 月 3 日,定制侠 iOS 版通过审核上架了:
https://apps.apple.com/cn/app/id6757757917
从 10 月 23 日第一次跑通 CI 构建,到 10 月 31 日被 Guideline 2.1 打回,再到 11 月 3 日过审,踩的坑集中在三类:构建链路、原生能力、审核材料。
这篇不复述概念,只写「当时以为对了、其实错了」的地方,以及后来用来判断「这次真的对了」的取证方法。没有 Mac 的团队可以直接照抄。
没有 Mac,iOS 构建全部跑在 GitHub Actions 的 macOS runner 上。分工是这样的:
| 工作流 | 触发 | 干什么 | 会占用构建号吗 |
|---|---|---|---|
ios.yml |
push main / PR | 未签名验证构建,只证明「编得过」 | 不占,不给苹果发任何东西 |
ios-release.yml |
手动 dispatch 或 tag v* |
自动签名 + 打 ipa + 上传 TestFlight | 占,号即 github.run_number |
android.yml / android-release.yml |
push / tag v* |
安卓验证构建 / 上传 Play | 独立 |
坑 1:v* 会同时触发 iOS 和安卓两条发版流水线。
我本来只想发个 TestFlight 验证微信登录,结果一个 tag 下去,安卓包也被推到 Play 内部测试轨道了。只想发一端就用 workflow_dispatch 手动触发,别打 tag。
坑 2:构建号不要手写。
用 --build-number=${{ github.run_number }},号自然递增,永不会撞「这个构建号已被使用」。手写就等着报错。
记住一句话:验证构建和发版构建是两条路。前者用来快速试错(几分钟、不消耗构建号),后者才是给苹果的。我前期把「CI 绿了」当成「能上架了」,后面吃了亏(见第四节)。
一开始我以为新增 Associated Domains 这个 entitlement,必须先去 Apple Developer 后台给 App ID 勾上对应能力,否则签名会失败。
实际不用。 用的是自动签名 + -allowProvisioningUpdates,runner 会现场向苹果申请证书和描述文件,缺的能力会被自动补进 App ID。
但这里有个更值得记住的点——不要用「构建成功」判断签名对了。真正的证据在日志里:
ProcessProductPackaging .../Runner.entitlements -> Runner.app.xcent
Entitlements:
{
"com.apple.developer.applesignin" = ( Default );
"com.apple.developer.associated-domains" = ( "applinks:www.example.com" );
}
Runner.app.xcent 里出现什么,才是这个包真的带了什么能力。源码里写了 entitlements,签名时被丢掉的情况是真实存在的。
顺带一个早期坑:Flutter 在 buildSettings 里没有 DEVELOPMENT_TEAM 键时,即使 --no-codesign 且 Xcode 已经构建成功,flutter 也会强制报错退出。CI 里必须把 team 注入 pbxproj(幂等注入,重复跑不能叠加出重复键)。
nil.each第一次 pod install 崩得非常莫名。三个要点,抄官方模板即可,别自己改:
templates/cocoapods/Podfile-ios 原样。flutter_root 要 matches[1].strip,少个 strip 路径带空格就废。use_frameworks! / use_modular_headers! —— 在 CocoaPods 1.17 上会触发 nil.each 崩溃,报错完全看不出跟这两行有关。还有一条:'Profile' => :release 要写全,否则 Release 包会按 Debug 编译,体积和性能都不对。
这是整个项目里代价最大的一处。
Flutter 项目的 pubspec.yaml 里,有几个插件是指向本地 OHOS 分支的 path: 依赖。到了 CI 上这些路径不存在,于是我们写了个 transform_pubspec_ios.py:把 F:/、E:/ 的 path 依赖改成 pub.dev 版本;对 iOS 根本没原生实现的插件,换成仓库内的纯 Dart 桩。
逻辑没问题,但产生了一个非常隐蔽的后果:
编译过、构建绿、提审过、功能是假的。
典型症状:iOS 上点「微信账号登录」,弹一句「请先安装微信客户端」。桩里的 isWeChatInstalled() 恒返回 false,代码走的就是这一条分支。而 CI 从头到尾没有一行红。
更麻烦的是怎么发现。三个 TestFlight 包在手上,日志全是绿的,用户在真机上试了却说不清到底哪一步不对。后来是靠翻历史日志里这一行定的案:
transform_pubspec: converted_path_deps=9, stubbed_plugins=2, pinned_plugins=0
* fluwx 0.0.1 from path ios_ci_stub/fluwx (was 5.7.5 ...)
建议:让转换脚本把统计信息打出来。 stubbed_plugins=2 和 =1 的区别,一眼就能看出这一版被桩了几个插件。这行日志 10 秒钟就能加,能省掉一整天。
看构建日志不够,看「有没有报错」更不够。下载 CI 的 artifact,解包查产物:
| 查什么 | 怎么查 | 桩会有吗 |
|---|---|---|
| 插件自带资源 | 包里有没有 xxx_privacy.bundle/PrivacyInfo.xcprivacy |
没有,桩只有 Dart 代码 |
| 原生符号 | 对主二进制 strings 搜 SDK 类名 / 方法名,如 WXApi、registerApp:universalLink: |
搜不到 |
| 打包后的 Info.plist | CFBundleURLSchemes、LSApplicationQueriesSchemes、ATS 例外 |
不会有 |
这三条是**「真的编进去了」的硬证据**。此前所有判断失误,都发生在只看日志的阶段。
去掉 path 依赖后裸解析,CI 拉到的可能是最新大版本(我们遇到的是 6.x),Dart API 已经改了,编译直接挂。正确做法是钉一个已知兼容的版本,并在注释里写清楚为什么是这个版本。
还有一个反直觉的点:本地那份 OHOS 分支副本,ios/Classes 里一个 .m .h 都没有(它只服务鸿蒙),所以「用 path 依赖直接编 iOS」这条路从一开始就不通,必须走 pub.dev。
iOS 上让微信登录真的能用,要同时满足三层,缺任意一层都表现为「点了没反应」或「请先安装微信」:
| 层 | 要做什么 | 漏了会怎样 |
|---|---|---|
| CI 层 | 插件必须是真包,不能是桩 | 功能静默失效 |
| 工程层 | entitlements、URL scheme、queries schemes、插件配置 | 授权完回不来 |
| 平台层 | 微信开放平台单独登记 iOS 的 Bundle ID + Universal Link | 微信侧直接拒绝 |
工程层四处,少一处都不行:
Runner.entitlements 加 com.apple.developer.associated-domains = applinks:<你的域名>;Info.plist 的 CFBundleURLSchemes 加微信 AppID,LSApplicationQueriesSchemes 加 weixin、weixinULAPI;pubspec.yaml 顶层加插件配置,注意 universal_link 要写在 ios: 子级(插件的 podspec 读的是 cfg['fluwx']['ios']['universal_link'],写顶层等于没配);universalLink 要和平台登记的一字不差,必须是 /app/ 这种非根路径,根路径 https://domain.com/ 微信不接受。还有一个开关值得单独说:插件的自动配置脚本默认会把 NSAllowsArbitraryLoads 写成 true(全局关掉 ATS)。我们本来在 Info.plist 里按域名精确声明了 CDN 例外,被脚本一改就全放开了,审核也解释不清。把它关掉(插件一般有 ignore_security 之类的开关),自己手写需要的四项。
pod install 阶段崩微信插件的 podspec 顶部有一段 ruby,作用是在安装时反推工程根目录、执行脚本去改主工程的 Info.plist 和 entitlements。它靠 /.symlinks 在路径里切片推算根目录,CI 的 Pods 结构下算不出来,直接 abort,报的错跟微信毫无关系(Must provide a project directory and project name)。
解法不是不用它,而是:在 flutter pub get 之后、pod install 之前,用脚本把 podspec 顶部那段 preamble 整段替换成固定变量(保留 WechatOpenSDK-XCFramework 依赖,SDK 走 CocoaPods 正常下载)。那些脚本本来要写的四项,手工提交到仓库里即可。
写这个补丁脚本时注意三点:
.dart_tool/package_config.json 里找,别写死 pub-cache 路径(不同 runner 不一样);工程侧配好只是「你有资格申请这个域名」,域名侧还得认你。三处最容易错:
1)位置只有一个。 苹果只读 /.well-known/apple-app-site-association。根目录那份是 iOS 9 时代的遗留,现在没有系统读它,放了也无害,但不必放。
2)Content-Type 必须 application/json。 nginx 对无扩展名文件默认给 application/octet-stream,这个是最常见的失配。
3)不能有 301/302。 http→https 跳转、裸域→www 跳转,任何一次重定向都会让苹果判定失败。www 和裸域是两个域名,混用必挂。
验证不用等真机,直接问苹果 CDN:
https://app-site-association.cdn-apple.com/a/v1/<你的域名>
能吐出 JSON,说明苹果侧已经抓到并接受。这是最省时间的一步。
还有两件事容易忽略:
appIDs 里的 Team ID 必须和签名用的完全一致,差一个字符就是「点了没反应」,而且没有任何报错。location ^~ /app/ 直接 return 200 一个落地页。Flutter 3.4x 默认用 FlutterSceneDelegate,而很多插件(包括微信插件)只注册了 addApplicationDelegate:,没有注册 scene delegate。按常理推:scene:continueUserActivity: 到不了插件,微信授权后回不来。
实际不会。 在 Flutter.framework 二进制里能搜到这两个选择器:
application:continueUserActivity:restorationHandler:isFallbackForScene:
application:openURL:options:isFallbackForScene:
引擎为「只注册 app delegate 的旧插件」做了 scene 兜底转发。所以这类插件在 UIScene 生命周期下依然能收到回调。
这是一个很好的方法论示例:在源码里找不到答案时,去二进制里找选择器字符串。比猜快,也比等真机快。
10 月 31 日被拒,类型是 Guideline 2.1 — Information Needed,不是功能缺陷。要求六项:
App 一启动就是登录页,没有演示账号审核员根本进不去。这是 2.1 里最常见、也最容易自己发现的一条。提审前先问一句:我能不能用一个陌生账号在五分钟内走完主流程?
同理,演示账号里要预置一笔订单,否则「我的订单」是空页,审核员会怀疑流程没做完。
这点没有提示,但超了粘不进去。长篇英文写得再好,也得压。压缩的有效办法:
六项要求一个都不能丢,只是不再重复描述同一件事。
注销这件事上很容易翻车。苹果 5.1.1(v) 的期望是「能删账号」,但如果你实现的是匿名化账号、保留订单(我们的做法:用户名改成 deleted_<id>_<时间戳>、密码置随机值,让旧 token 立即失效,订单原样保留),回复里就只写 account deletion,千万别写「deletes all associated data」。写过头,审核员一问数据在哪删的,就露馅了。
| 问题 | 我们的实际情况 | 处置 |
|---|---|---|
| 强制登录 | 启动即登录页 | 提供演示账号 + 预置订单 |
| 应用内注销 | 前端早就调注销接口,后端压根没这个接口(404) | 补服务端 POST /api/user/deleteAccount,匿名化账号 |
| 类似 App Store 的第三方应用推广列表 | 有一个活动页会列出其他 App 并外跳 | iOS 隐藏入口(3.2.2(i) / 4.2 风险) |
| 社交 / UGC 问卷 | 无 feed、无评论点赞关注、上传的图只用于自己那单 | 社交媒体答「否」,不要为了宽松去答「是」 |
| 支付 | 卖实体手机壳,不是数字内容 | 走支付宝 / 微信,按 3.1.3(e),不接 IAP |
两个特别值得记的:
「客户端做完了」不等于「功能做完了」。 注销按钮在 App 里点了半年,直到真机点下去才报错,因为服务端接口从来没写。这类问题只有真机全流程走一遍才会暴露。
年龄分级里的「社交媒体 / 用户生成内容」要按定义答。 判定标准是「是否把用户内容分发给众多用户」。答「是」会连带要求「13 岁以下不可访问」「接入 Declared Age Range API」,没做就是虚假陈述,反而更容易被拒。没有内容分发就如实答否。
TARGETED_DEVICE_FAMILY 从 "1,2" 改成 "1"。不改,App Store Connect 会强制你补 iPad 截图,而很多定制类界面在 iPad 上并没有适配。run_number;同一个构建号不能重复上传,重跑流水线前先确认号变了。构建与签名
use_frameworks!DEVELOPMENT_TEAM 幂等注入插件
stubbed/pinned 统计平台能力
.well-known 单点、application/json、无 301、Team ID 一致审核材料
这一节是给自己定的规矩,顺便也提醒同行:
.p12、.mobileprovision、keystore、密码,一律只走 CI Secrets,永不入库。.p8、Key ID、Issuer ID):等同发布权限,只放 Secret。.gitignore,只在本地留存。写复盘文章的时候也一样:上面这些值一律用 <TEAM_ID>、<APP_ID>、<你的域名> 占位。技术细节该写多细写多细,标识符一个都别带。
回头看,这个项目真正难的从来不是「写 workflow」,而是判断力:
每一层都有自己的取证方式:产物解包、xcent、二进制选择器、苹果 CDN、真机录屏。能拿到证据的地方,就不要靠推测。
时间线:10-23 首次 CI 构建通过 → 10-25 发首个 TestFlight(v1.0.1)→ 10-31 被 2.1 打回 → 11-01 打通微信登录、补注销接口、完成录屏 → 11-03 过审上架。
还没有评论,登录后来说两句。