别再手动改 App Store 文案了:Fastlane 多语言元数据一键同步实战(附 7 个深坑)

15 阅读9分钟

本文适合 iOS / macOS 独立开发者和小团队。如果你维护着多语言的 App Store 页面,每次发版都要登录 App Store Connect 后台,一种语言一种语言地粘贴标题、副标题、描述、关键词——这篇文章能帮你把这件事变成一条命令。

完整可运行的模板已开源:github.com/Pulset/fast…

一、痛点:15 种语言 × 6 个字段 = 每次发版 90 次粘贴

我的 App 支持 15 种语言,App Store 页面每种语言有标题、副标题、促销文本、描述、关键词、版本更新说明 6 个字段。每次发版,我要:

  1. 登录 App Store Connect
  2. 进入版本页面,选择语言
  3. 一个字段一个字段粘贴
  4. 换下一种语言,重复
  5. 手滑贴错了,再来一遍

算下来一次发版要操作 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:

  1. 打开 App Store Connect → 用户和访问 → 集成
  2. 点 "+" 生成密钥,下载 .p8 文件(只有一次下载机会)
  3. 记下 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:三个连环坑。 裸跑这个命令会依次撞上:

  1. 走错认证:CLI 子命令不会用 Fastfile 里的 api_key 逻辑,会去走 Apple ID 登录,然后卡在双重认证的六位验证码上。解法:把 API Key 写成临时 JSON 文件,通过 --api_key_path 传入:

    { "key_id": "xxx", "issuer_id": "xxx", "key": "<.p8 文件内容>" }
    
  2. 静默不下载:本地 metadata 目录非空时,CLI 会问"要不要覆盖",非交互环境下它不报错、直接什么都不做退出,显示 finished successfully 🎉,极具迷惑性。必须加 --force true。

  3. 工作目录:在 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 和密钥就能跑:

👉 github.com/Pulset/fast…

  • 两种 Markdown 格式自动识别(列表式 / 编号式),可混用
  • 内置字符限制校验,超长在本地就拦下
  • 支持 iOS / macOS(ASC_PLATFORM 一键切换),20 种语言映射
  • 本文提到的所有坑都已在模板里修好

觉得有用的话给个 ⭐,有问题欢迎提 issue,也欢迎在评论区交流你的多语言维护方案。