没有 Mac 的 Flutter 项目怎么上 App Store:定制侠 iOS 上架全链路复盘

xiezitai · 2026-11-03 16:10 · 9 次阅读
定制侠AppStore苹果iOS

11 月 3 日,定制侠 iOS 版通过审核上架了:

https://apps.apple.com/cn/app/id6757757917

从 10 月 23 日第一次跑通 CI 构建,到 10 月 31 日被 Guideline 2.1 打回,再到 11 月 3 日过审,踩的坑集中在三类:构建链路、原生能力、审核材料。

这篇不复述概念,只写「当时以为对了、其实错了」的地方,以及后来用来判断「这次真的对了」的取证方法。没有 Mac 的团队可以直接照抄。


一、整条链路:三个 workflow,别让它们互相牵连

没有 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(幂等注入,重复跑不能叠加出重复键)。


三、Podfile:CocoaPods 的一个 nil.each

第一次 pod install 崩得非常莫名。三个要点,抄官方模板即可,别自己改:

  1. 照 templates/cocoapods/Podfile-ios 原样。
  2. flutter_root 要 matches[1].strip,少个 strip 路径带空格就废。
  3. 不要加 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 秒钟就能加,能省掉一整天。

4.1 判断「插件是真装还是桩」,只有一种可靠办法

看构建日志不够,看「有没有报错」更不够。下载 CI 的 artifact,解包查产物:

查什么 怎么查 桩会有吗
插件自带资源 包里有没有 xxx_privacy.bundle/PrivacyInfo.xcprivacy 没有,桩只有 Dart 代码
原生符号 对主二进制 strings 搜 SDK 类名 / 方法名,如 WXApi、registerApp:universalLink: 搜不到
打包后的 Info.plist CFBundleURLSchemes、LSApplicationQueriesSchemes、ATS 例外 不会有

这三条是**「真的编进去了」的硬证据**。此前所有判断失误,都发生在只看日志的阶段。

4.2 版本要钉死,不要「让它自己解析」

去掉 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 微信侧直接拒绝

工程层四处,少一处都不行:

  1. Runner.entitlements 加 com.apple.developer.associated-domains = applinks:<你的域名>;
  2. Info.plist 的 CFBundleURLSchemes 加微信 AppID,LSApplicationQueriesSchemes 加 weixin、weixinULAPI;
  3. pubspec.yaml 顶层加插件配置,注意 universal_link 要写在 ios: 子级(插件的 podspec 读的是 cfg['fluwx']['ios']['universal_link'],写顶层等于没配);
  4. 代码里的 universalLink 要和平台登记的一字不差,必须是 /app/ 这种非根路径,根路径 https://domain.com/ 微信不接受。

还有一个开关值得单独说:插件的自动配置脚本默认会把 NSAllowsArbitraryLoads 写成 true(全局关掉 ATS)。我们本来在 Info.plist 里按域名精确声明了 CDN 例外,被脚本一改就全放开了,审核也解释不清。把它关掉(插件一般有 ignore_security 之类的开关),自己手写需要的四项。

5.1 插件自身会在 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 正常下载)。那些脚本本来要写的四项,手工提交到仓库里即可。

写这个补丁脚本时注意三点:


六、AASA 与 Universal Link:服务器侧最容易错

工程侧配好只是「你有资格申请这个域名」,域名侧还得认你。三处最容易错:

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,说明苹果侧已经抓到并接受。这是最省时间的一步。

还有两件事容易忽略:


七、UIScene:新 Flutter 与老插件的隐形不兼容

Flutter 3.4x 默认用 FlutterSceneDelegate,而很多插件(包括微信插件)只注册了 addApplicationDelegate:,没有注册 scene delegate。按常理推:scene:continueUserActivity: 到不了插件,微信授权后回不来。

实际不会。 在 Flutter.framework 二进制里能搜到这两个选择器:

application:continueUserActivity:restorationHandler:isFallbackForScene:
application:openURL:options:isFallbackForScene:

引擎为「只注册 app delegate 的旧插件」做了 scene 兜底转发。所以这类插件在 UIScene 生命周期下依然能收到回调。

这是一个很好的方法论示例:在源码里找不到答案时,去二进制里找选择器字符串。比猜快,也比等真机快。


八、被 2.1 打回:苹果要的其实是六份说明书

10 月 31 日被拒,类型是 Guideline 2.1 — Information Needed,不是功能缺陷。要求六项:

  1. 真机录屏,覆盖注册 / 登录 / 注销 / 付费;
  2. App 的用途与目标受众;
  3. 使用说明 + 可用的演示账号;
  4. 用到的外部服务清单;
  5. 区域差异说明;
  6. 是否属强监管行业 / 第三方素材授权。

8.1 我们的致命项:启动就是登录页

App 一启动就是登录页,没有演示账号审核员根本进不去。这是 2.1 里最常见、也最容易自己发现的一条。提审前先问一句:我能不能用一个陌生账号在五分钟内走完主流程?

同理,演示账号里要预置一笔订单,否则「我的订单」是空页,审核员会怀疑流程没做完。

8.2 回复框只有 4000 字符

这点没有提示,但超了粘不进去。长篇英文写得再好,也得压。压缩的有效办法:

六项要求一个都不能丢,只是不再重复描述同一件事。

8.3 口径要与实现一致,别写过头

注销这件事上很容易翻车。苹果 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」,没做就是虚假陈述,反而更容易被拒。没有内容分发就如实答否。


十、设备支持与版本号


十一、可复用清单

构建与签名

插件

平台能力

审核材料


十二、安全边界:哪些东西永远不进仓库、不进文章

这一节是给自己定的规矩,顺便也提醒同行:

写复盘文章的时候也一样:上面这些值一律用 <TEAM_ID>、<APP_ID>、<你的域名> 占位。技术细节该写多细写多细,标识符一个都别带。


结语

回头看,这个项目真正难的从来不是「写 workflow」,而是判断力:

每一层都有自己的取证方式:产物解包、xcent、二进制选择器、苹果 CDN、真机录屏。能拿到证据的地方,就不要靠推测。

时间线:10-23 首次 CI 构建通过 → 10-25 发首个 TestFlight(v1.0.1)→ 10-31 被 2.1 打回 → 11-01 打通微信登录、补注销接口、完成录屏 → 11-03 过审上架。

评论

还没有评论,登录后来说两句。