10 月 12 日,我把日记应用 Lanternly 的商店页面上传完了 39 种语言。按字段算一共 819 个:“App 信息”里的名称、副标题和隐私政策链接,iOS 版本的六个字段,macOS 版本的五个字段,再加上订阅的本地化内容(一个订阅组和三个方案)。我没有翻译团队,也没有 ASO 代理公司。
这篇讲的是我怎么做的,以及在哪些地方踩了坑。字数限制的数字已于 2026 年 10 月 13 日对照 App Store Connect 文档核实。
先决定哪些事不能说假话#
Lanternly 的界面只有英语和俄语。瑞典语或土耳其语的商店页面会让人以为应用本身也有瑞典语或土耳其语。用户下载后看到英文,就会打一星,或者申请退款。
所以除英语和俄语外,所有语言描述的第二行意思都相同:
Die App-Oberfläche ist auf Englisch und Russisch.
Appens gränssnitt är på engelska och ryska.它紧跟在第一段之后、功能列表之前。放在更下面,就没人会看到了。
第二个决定是篇幅。14 种语言用了 3000 到 4000 字符的完整描述,另外 25 种用了约 1300 字符的短描述:应用核心是什么、关于界面语言的那行如实说明、订阅说明区块和法律链接。11 种印度语言我干脆没有上传。我没办法检查印地语或泰米尔语的翻译质量,而商店页面里的糟糕翻译还不如英文原文。
用一个文件代替 819 个表单字段#
直接在 App Store Connect 的表单里改 39 种语言行不通:英文里改了一句订阅措辞,就得记住另外 38 个地方也有这句话。
于是唯一可信的源头搬进了一个 JSON:语言 → 字段。iOS 和 macOS 的描述只差最后一段(Mac 有自己的“还能在哪些设备上用”列表),所以存的是共用文本加上 Mac 的替换段。任何修改都先进文件,通过检查,然后才进商店。旁边还有一个 TSV,放名称、副标题和关键词:方便肉眼看,也方便脚本检查。
处处受限的字数上限#
| 字段 | 上限 | 在哪里设置 |
|---|---|---|
| Name | 30 个字符 | App 信息,iOS 和 macOS 共用 |
| Subtitle | 30 个字符 | 同上 |
| Keywords | 文档写的是 100 字节 | 版本,各平台分开 |
| Promotional Text | 170 个字符 | 版本,修改无需审核 |
| Description | 4000 个字符 | 版本 |
| What's New | 4000 个字符 | 版本 |
关于关键词,文档是这样写的:“You can provide up to 100 bytes of content”。是字节,不是字符。在 UTF-8 里,西里尔字母每个占两个字节,日文占三个。我的俄语关键词是 93 个字符,也就是 176 字节,App Store Connect 照样接受了。实际按字符计算,但我不会指望这一点永远不变。
最紧张的地方并不在我预想的位置。名称和副标题的 30 个字符在所有语言里都放下了:德语复合词虽然长,但数量少。真正卡住我的是别处:
- 德语推广文本正好 170/170。再多一个逗号都放不下。
- 荷兰语推广文本不得不删减:第一版超了。
- 法语描述超过了 4000。救场的是隐私政策和条款的链接出现了两次:一次在正文里,一次在底部必需的区块里。删掉重复后,变成了 3917。
检查表单看不到的问题的脚本#
App Store Connect 只检查长度。其余的它都默默接受,你大概只能从没有曝光量这一点上发现错误。所以每次上传前,我都会用一个小脚本过一遍 TSV。下面是它的简化可用版本:
#!/usr/bin/env python3
"""按语言检查 name/subtitle/keywords: python3 check_fields.py fields.tsv"""
import csv, re, sys
LIMITS = {"name": 30, "subtitle": 30, "keywords": 100}
BANNED = {"app", "free", "apple", "iphone"}
def words(s):
return re.findall(r"[\w’'-]+", s.lower())
bad = 0
for r in csv.DictReader(open(sys.argv[1], encoding="utf-8"), delimiter="\t"):
issues = [f"{f}: {len(r[f])}/{n}" for f, n in LIMITS.items() if len(r[f]) > n]
kw = r["keywords"]
if re.search(r",\s", kw):
issues.append("逗号后有空格")
kw_words = [w for t in kw.split(",") for w in words(t)]
dup = {w for w in kw_words if kw_words.count(w) > 1}
if dup:
issues.append(f"keywords 内重复: {sorted(dup)}")
title = set(words(r["name"] + " " + r["subtitle"]))
if set(kw_words) & title:
issues.append(f"与 name/subtitle 重复: {sorted(set(kw_words) & title)}")
if (set(kw_words) | title) & BANNED:
issues.append(f"多余的词: {sorted((set(kw_words) | title) & BANNED)}")
bad += bool(issues)
print(("FAIL " if issues else "OK ") + r["locale"], *issues, sep="\n - ")
sys.exit(1 if bad else 0)输入是包含 locale、name、subtitle、keywords 列的 TSV。它能抓出什么,以及为什么:
- 逗号后的空格。 这只是白白丢掉 100 个字符里的一个。Apple 用逗号分隔关键词,不需要空格。
- 关键词内部重复,或与名称、副标题重复的词。 名称和副标题里的词已经参与搜索,再写进关键词就等于占用两次空间。
- 不需要的词。
app和free不带来任何价值,而别家应用和公司的名称,文档明确禁止使用。我实际的列表更长:还包括那些我在产品里根本不用的词,比如医学标签。
描述和推广文本的长度由第二个脚本检查,结果我存成文件,放在 JSON 旁边。一个月后再修改时,一眼就能看出哪种语言已经没有余量。
我是怎么翻译的#
译文初稿是我借助语言模型完成的。没有母语者校对过。这没什么好隐瞒的:一个独立开发者能负担得起的就是这些。所以我给自己定了几条规则。
翻译意思,而不是句子。 英文推广文本 “Free to write as much as you like” 在德语里变成了 “Schreib, so viel du willst”:“免费”这个词放进去就会超出字数,但意思保留了下来。
关键词要按用户的输入方式来写。 俄语用户搜的是 “дневник эмоций”(情绪日记)和 “дневник с паролем”(带密码的日记),所以关键词里写的是 эмоций 和 паролем,分别是俄语的属格和工具格形式,而不是词典原形。德国人用复合词搜索,所以 dankbarkeitstagebuch 整个作为一个词。在波兰语里,dziennik 更常指学校的电子成绩册,所以名称里的主词用的是 Pamiętnik。
对没法检查的语言用短描述。 文本越短,可能藏着我注意不到的怪句子的地方就越少。
链接也要本地化#
链接同样需要本地化。每种语言都有自己的 Privacy Policy URL、Support URL 和 Marketing URL,而每份描述末尾还有一个法律链接区块。网站上的文档有五种语言,所以西班牙语各地区链接到 /es/,日语链接到 /ja/,中文链接到 /zh-CN/,其余链接到 /en/。支持页面只有英语和俄语。关于 EULA 区块本身,以及为什么订阅类应用必须有它,我另写了一篇。
上传:只用脚本#
手动填 819 个字段要花上一整天,而且肯定会在哪里打错字。我是在自己的 App Store Connect 会话里用脚本上传的。如果你经常做这件事,正确的方式是官方的 App Store Connect API:它有 appInfoLocalizations、appStoreVersionLocalizations、subscriptionGroupLocalizations 和 subscriptionLocalizations 这些资源,也就是上面列出的全部内容。
途中遇到的两件事:
- 第一个 Mac 版本没有 What's New 字段。 Lanternly 的 macOS 1.1 是第一个版本,这个字段根本不存在,脚本必须跳过它。
- 名称和副标题由 iOS 和 macOS 共用,而关键词和描述每个平台各一份。我原样复制,只改描述的最后一段。
还没做完的#
所有新语言的截图目前仍是英文:反正界面也只有英语或俄语,所以这样是如实的,但不算理想。37 种语言的文本没有母语者读过。如果有读者会说瑞典语、土耳其语或波兰语,并在 Lanternly 的商店页面里发现了错误,欢迎给我写信。
如果重新来过,我会在第一天、在第一次翻译之前,就建好一个源文件和检查脚本。这项工作里剩下的部分每次都会重复,可以交给脚本去做。



