本文适合 iOS / macOS 独立开发者和小团队。如果你维护着多语言的 App Store 页面,每次发版都要登录 App Store Connect 后台,一种语言一种语言地粘贴标题、副标题、描述、关键词——这篇文章能帮你把这件事变成一条命令。
完整可运行的模板已开源:github.com/Pulset/fast…
一、痛点:15 种语言 × 6 个字段 = 每次发版 90 次粘贴
我的 App 支持 15 种语言,App Store 页面每种语言有标题、副标题、促销文本、描述、关键词、版本更新说明 6 个字段。每次发版,我要:
- 登录 App Store Connect
- 进入版本页面,选择语言
- 一个字段一个字段粘贴
- 换下一种语言,重复
- 手滑贴错了,再来一遍
算下来一次发版要操作 90 次。更糟的是:文案在本地 Markdown 里维护一份,后台又有一份,永远不知道哪份是最新的。
fastlane 的 deliver 就是干这个的——把本地元数据目录一键推送到 App Store Connect。但网上中文教程几乎都停留在"iOS 打包上传"这个场景,元数据同步、尤其是多语言元数据同步,连英文资料都不多。我把完整方案和踩过的 7 个坑都写在这里,你可以直接抄。
二、整体方案:Markdown 是唯一数据源
架构非常简单:
docs/AppStore/ ← 唯一维护入口,一种语言一个 .md 文件
├── English.md
├── Chinese.md
└── screenshots/ ← 各语言截图
├── en/
└── zh/
scripts/build-appstore-metadata.mjs ← 解析 Markdown,生成 deliver 格式
fastlane/
├── Fastfile ← 推送/拉取/截图三个 lane
├── Appfile ← bundle id 等应用信息
├── metadata/ ← 生成物(gitignore)
│ ├── en-US/name.txt
│ ├── en-US/subtitle.txt
│ ├── zh-Hans/description.txt
│ └── ...
└── screenshots/ ← 生成物(gitignore)
日常发版只做一件事:改 Markdown,然后跑一条命令。生成物目录全部 gitignore,不存在两份数据打架的问题。
三、安装:从一个崩溃开始的坑
macOS 上推荐用 Bundler 管理 fastlane 版本,项目根目录建 Gemfile:
source "https://rubygems.org"
gem "fastlane"
bundle install
坑 1:gem 镜像导致非交互环境崩溃。
国内开发者普遍把 gem 源换成了镜像。fastlane 每次启动会做版本更新检查,检测到你的 gem 源里没有 rubygems.org 时,会尝试交互式询问,在 CI 或任何非交互终端里直接崩溃,报错还特别误导:
RubyGems is not listed as your Gem source
Could not retrieve response as fastlane runs in non-interactive mode
解法:在 .env 里加两行,永久跳过更新检查:
FASTLANE_SKIP_UPDATE_CHECK=1
FASTLANE_HIDE_CHANGELOG=1
四、认证:App Store Connect API Key
别用 Apple ID + 密码的方式(会撞上双重认证的交互提示),直接用 API Key:
- 打开 App Store Connect → 用户和访问 → 集成
- 点 "+" 生成密钥,下载
.p8文件(只有一次下载机会) - 记下 Key ID 和 Issuer ID
坑 2:角色不够,读得了写不了。
我一开始用了一把 Developer 角色的 Key,拉取线上元数据一切正常,推送时却报:
This request is forbidden for security reasons - The API key in use does not allow this request
ASC API Key 的角色在创建时固定,不能修改。Developer 角色对元数据是只读的,要写元数据至少需要 App Manager。而且这个 403 报错信息完全不提"角色"二字,非常难排查。记住:读 OK 写 403 = 角色不够,直接去建一把新 Key。
环境变量这样配:
APPLE_API_KEY=你的KeyID
APPLE_API_ISSUER=你的IssuerID
APPLE_API_KEY_PATH=/绝对路径/AuthKey_XXXX.p8 # 建议放在项目外,避免误提交
五、Fastfile:能用的版本长这样
default_platform(:ios)
# 项目根目录。fastlane 加载 Fastfile 时会把工作目录切到 fastlane/,
# 所有相对路径都会因此解析错位,必须基于 __FILE__ 定位(见坑 4)
def project_root
File.expand_path("../..", __FILE__)
end
def asc_api_key
key_id = ENV["APPLE_API_KEY"]
issuer_id = ENV["APPLE_API_ISSUER"]
return nil if key_id.nil? || issuer_id.nil?
key_path = ENV["APPLE_API_KEY_PATH"]
if key_path && File.exist?(key_path)
# 注意:参数名是 filepath,不是 key_filepath(见坑 3)
{ key_id: key_id, issuer_id: issuer_id, filepath: key_path }
end
end
platform :ios do
desc "推送全部语言的元数据到 App Store Connect"
lane :sync_metadata do
Dir.chdir(project_root) { sh("node", "scripts/build-appstore-metadata.mjs") }
deliver(
api_key: asc_api_key,
platform: "ios", # macOS 应用用 "osx"
metadata_path: File.join(project_root, "fastlane", "metadata"),
skip_screenshots: true,
skip_binary_upload: true,
run_precheck_before_submit: false,
submit_for_review: false,
force: true # 跳过 HTML 预览确认,CI 友好
)
end
end
对应的 npm script:
{
"scripts": {
"appstore:metadata": "bundle exec fastlane sync_metadata"
}
}
跑 npm run appstore:metadata,15 种语言的文案就推上去了。
坑 3:一个参数名,静默崩溃。
fastlane 的 Spaceship::ConnectAPI::Token.create 签名里,密钥文件参数叫 filepath:。而官方文档里另一个 action(app_store_connect_api_key)的参数叫 key_filepath。如果你把 key_filepath: 传进 deliver 的 api_key 哈希,不匹配的键会被 Ruby 静默吞掉,然后:
no implicit conversion of nil into String (TypeError)
因为 File.binread(nil)。这个坑的阴间之处在于:如果你用的是"密钥内容"方式(key: 参数,名字恰好是对的),一切正常;哪天换成文件路径方式,就崩给你看。
坑 4:Fastfile 里的相对路径全部是错的。
fastlane 解析 Fastfile 时会执行 Dir.chdir(fastlane目录),lane 里所有相对路径的基准都是 fastlane/ 而不是项目根。我在 lane 里调用外部脚本时传了相对路径 scripts/xxx.mjs,脚本内部又写相对路径,结果文件被写到了 fastlane/fastlane/metadata/ 这种套娃目录里。
更坑的是,fastlane 自带的 FastlaneCore::Helper.fastlane_enabled_folder_path 在这个场景下返回的是 fastlane/ 目录本身而不是项目根。唯一可靠的是基于 __FILE__:
def project_root
File.expand_path("../..", __FILE__) # Fastfile 位于 <root>/fastlane/Fastfile
end
六、Markdown → deliver 的生成脚本
deliver 要求的元数据格式是 fastlane/metadata/<locale>/<field>.txt。语言目录名必须是 ASC 的 locale 代码(不是 ISO 语言码):
| 你的语言 | ASC locale | 你的语言 | ASC locale |
|---|---|---|---|
| 英文 | en-US | 韩文 | ko |
| 简体中文 | zh-Hans | 荷兰文 | nl-NL |
| 西班牙文 | es-ES | 波兰文 | pl |
| 法文 | fr-FR | 葡萄牙文 | pt-BR |
| 德文 | de-DE | 俄文 | ru |
| 日文 | ja | 瑞典文 | sv |
| 土耳其文 | tr |
生成脚本核心逻辑(Node.js,约百行,文末仓库有完整版):
const LOCALE_MAP = {
English: 'en-US', Chinese: 'zh-Hans', Spanish: 'es-ES',
French: 'fr-FR', German: 'de-DE', Japanese: 'ja', /* ... */
};
// 从 Markdown 提取字段,写入 metadata/<locale>/<field>.txt
// 并做字符限制校验:name/subtitle ≤ 30,keywords ≤ 100,promo ≤ 170
这个校验很有价值:标题 30 字符的限制,中文英文不一样容易超,脚本在本地就把超长拦下来,不用等 ASC 后台飘红。
坑 5:类别值必须是大写枚举。
如果你的元数据里有 primary_category.txt,内容必须写成 UTILITIES、PRODUCTIVITY 这种大写枚举,写成小写 utilities 会报:
The provided entity includes a relationship with an invalid value
fastlane 内部有个"显示名 → 枚举"的映射表,键是首字母大写的 Utilities,小写根本映射不上,原样发出去就被 API 拒了。类别是一次性设置,最省事的做法是根本不生成类别文件,让线上已有的设置保持不动。
七、反向同步:从线上拉元数据也全是坑
想看看线上现在是什么文案?deliver 的 action 只有上传模式,下载必须走 CLI 子命令:
bundle exec fastlane deliver download_metadata \
--metadata_path fastlane/metadata --platform ios --force true
坑 6:三个连环坑。 裸跑这个命令会依次撞上:
-
走错认证:CLI 子命令不会用 Fastfile 里的
api_key逻辑,会去走 Apple ID 登录,然后卡在双重认证的六位验证码上。解法:把 API Key 写成临时 JSON 文件,通过--api_key_path传入:{ "key_id": "xxx", "issuer_id": "xxx", "key": "<.p8 文件内容>" } -
静默不下载:本地 metadata 目录非空时,CLI 会问"要不要覆盖",非交互环境下它不报错、直接什么都不做退出,显示
finished successfully 🎉,极具迷惑性。必须加--force true。 -
工作目录:在 lane 里用
sh调这个 CLI 时,记得套Dir.chdir(project_root),原因见坑 4。
八、版本行为:不会自动建版本
一个高频问题:推送元数据需要先建版本吗?
- 线上已有可编辑版本(准备提交/审核中/被拒)→ 直接写入,什么都不用做
- 上个版本已发布、还没开新版本 → 报错
Cannot find edit app store version,不会自动创建
deliver 有自动建版本的能力(app_version 参数),但有个危险副作用:如果线上已有可编辑版本且版本号和你传的不一致,它会把那个版本改成你传的版本号。所以我把它做成显式 opt-in:
app_version: ENV["ASC_CREATE_VERSION"], # 不设置就完全不动版本
发版时用:
ASC_CREATE_VERSION=1.0.7 npm run appstore:metadata
九、坑 7:你的 .env 可能正在被 git 跟踪
把密钥放进 .env 之前,先检查它有没有被提交过:
git ls-files --error-unmatch .env && echo "被跟踪了!"
我第二个项目的 .env 是被 git 跟踪的(历史提交里就有),直接往里加 Key 就把密钥写进版本历史了。解法:用 fastlane 官方的多环境文件机制,建一个独立的 .env.fastlane(gitignore 掉),命令里带 --env fastlane 加载,和项目自己的 .env 完全隔离。
十、最后一点建议
- 截图也可以走 deliver:
overwrite_screenshots: true+screenshots_path,注意 iOS 截图要符合 deliver 支持的设备尺寸(6.7 寸是 1320×2868) - 版本更新说明(What's New) 是逐语言的
release_notes.txt,把它也放进 Markdown 工作流,发版时一起推 - 线上语言可能比你本地的多:ASC 会自动衍生 en-AU/en-GB/fr-CA 等 locale,推送只更新你本地有的语言,其余不会被碰,不用担心
- 密钥角色用 App Manager,别用 Admin(权限最小化),也别用 Developer(写不了)
从"90 次手动粘贴"到"一条命令",这套东西我用在两个上架 App 上(macOS + iOS 各一),15 种语言,已经稳定跑了多个版本。希望这篇能帮同样被 App Store Connect 后台折磨的你省下这些时间。
完整模板已开源
文中全部代码(生成脚本 + Fastfile + 示例)整理成了一个开箱即用的模板仓库,clone 下来填上自己的 Markdown 和密钥就能跑:
- 两种 Markdown 格式自动识别(列表式 / 编号式),可混用
- 内置字符限制校验,超长在本地就拦下
- 支持 iOS / macOS(
ASC_PLATFORM一键切换),20 种语言映射 - 本文提到的所有坑都已在模板里修好
觉得有用的话给个 ⭐,有问题欢迎提 issue,也欢迎在评论区交流你的多语言维护方案。