深入理解代替单纯记忆
一、背景
在开发某充值弹窗功能时,原先所有价格均展示服务端下发的固定美元字符串。需求调整为:由客户端通过 StoreKit 读取本地商品信息,展示用户所在大区的本地货币价格。
需求本身不复杂,但实现过程中冒出了一连串问题:
- 新增内购商品时,要在哪些地方、基于什么货币定价?
- 客户端拿到的
priceLocale里那串格式是什么意思? - 货币符号到底是
SAR、﷼还是ريال سعودي,由什么决定? - 为什么沙特账号拿到的 locale 显示的是墨西哥?
- TestFlight 能不能测真实货币?
这篇文章把调研和测试过程中的发现整理下来,作为后续开发 IAP 相关功能的参考。
二、价格是怎么来的------App Store Connect 定价机制
App Store Connect 提供两种定价方式:
- 设置一个 base 地区价格,Apple 自动换算其他地区开发者选定一个熟悉的国家/地区(如美国),设置价格后,Apple 据此自动生成全球 174 个国家、43 种货币的对应价格。Apple 会根据实时汇率动态调整各地区价格(base 地区价格不变),防止用户跨区低价购买。
- 手动管理各地区价格开发者可以选择手动管理部分或全部地区的价格,此时需自行关注汇率变化并定期调整。
调整商品价格后,需要等约 1 小时才能在沙盒环境中验证生效。
延伸:App Store Connect API 提供了创建、更新商品和价格的接口,可以自建系统通过 API 方式批量管理商品。
三、priceLocale 是怎么决定的
SKProduct.priceLocale 并非由单一因素决定,而是由两条独立的信息流合并而来:
ini
App Store Connect 商品定价配置
↓
Apple 分配 storefront(决定 currency 和地区部分)
↓ ← 语言部分由此注入
设备系统语言 / 时区配置
↓
SKProduct.priceLocale(如 en_SA@currency=SAR)
具体规则:
-
currency + 地区部分:由当前设备登录的 App Store 账号所属 storefront 决定
- App Store 正式包 → 取 App Store 账号地区
- 开发包 / TestFlight 包 → 取沙盒账号地区
-
语言部分:由设备系统语言/时区配置注入
有趣的现象 :同一个沙盒账号,把系统语言从英语切换为阿语(并退出重新登录),priceLocale 的语言部分会从 en 变为 ar(即 en_SA → ar_SA)。这直接验证了语言部分来自系统配置,而非账号的固定属性。
四、priceLocale 格式解析
实际拿到的 priceLocale 打印出来长这样:
less
en_SA@currency=SAR (fixed en_SA@currency=SAR)
拆开每一段:
less
en_SA @ currency=SAR (fixed en_SA@currency=SAR)
──┬── ──────┬────── ──────────────┬──────────
│ │ └── fixed:不随系统环境变化的固定 locale
│ │ 区别于 Locale.current(会跟随系统漂移)
│ └── ICU 扩展参数:强制绑定货币代码
│ 优先级高于地区的默认货币
└── 语言_地区
en = 英语(→ 决定货币符号的展示形态)
SA = 沙特阿拉伯(→ 决定 storefront 归属)
fixed 关键字表示这是一个不可变的 locale 实例(等同于用 Locale(identifier:) 直接构造),区别于 Locale.current(会随系统设置实时变化)。
五、三个维度共同决定最终展示
使用 NumberFormatter + priceLocale 格式化价格时,最终呈现由 priceLocale 的三个维度共同决定,缺一不可:
| 维度 | 示例 | 决定的内容 |
|---|---|---|
| currency | SAR |
用哪种货币(ICU 扩展强制绑定,优先级高于地区默认货币) |
| 地区 | SA |
商品在哪个 storefront 定价,影响数字格式惯例 |
| 语言 | en vs ar |
同一货币的展示形态:符号写法、数字形状、符号左右位置 |
同一货币(SAR),不同语言下的格式化结果对比:
| priceLocale | NumberFormatter 输出 | 说明 |
|---|---|---|
en_SA@currency=SAR |
SAR 9.99 |
英语:ISO 代码、拉丁数字、符号在左 |
ar_SA@currency=SAR |
٩٫٩٩ ريال سعودي |
阿语:阿语名称、阿印数字、符号在右 |
代码写法
基础用法:格式化本地价格
ini
func formatPrice(price: NSDecimalNumber, priceLocale: Locale) -> String? {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = priceLocale
return formatter.string(from: price)
}
// 使用
if let priceText = formatPrice(price: product.price, priceLocale: product.priceLocale) {
priceLabel.text = priceText // 如:"SAR 9.99"
}
注意事项
formatter.locale必须用priceLocale,不要用Locale.current
正确做法 :不要硬编码 $、SAR、¥ 等符号。整串使用 NumberFormatter 的输出结果,小数点分隔符(逗号 vs 点)、货币符号位置(左 vs 右)、数字形状(拉丁 vs 阿印)全部由 priceLocale 自动处理。
六、实测结论矩阵
以下为实际测试结果,明确了各因素的优先级:
| 包类型 | App Store 账号地区 | 沙盒账号地区 | 系统语言/时区 | 展示结果 |
|---|---|---|---|---|
| App Store 正式包 | 沙特 | 不关心 | 英语 / 北京 | SAR 4.99 |
| App Store 正式包 | 沙特 | 不关心 | 阿语 / 利雅得 | ٩٩ ريال سعودي |
| 开发包 / TestFlight | 不关心 | 沙特 | 英语 / 北京 | SAR 4.99 |
| 开发包 / TestFlight | 不关心 | 沙特 | 阿语 / 利雅得 | ٩٩ ريال سعودي |
修改系统语言或时区后,需要退出 Apple ID/沙盒账号 重新登录,并等待约 20 分钟以上才会生效。直接切换系统语言后立即测试,结果可能仍是旧 locale。
七、测试中遇到的现象记录
未登录沙盒账号时,返回的是 en_MX
在未登录任何 沙盒 账号 、系统语言为英语的情况下,SKProductsRequest 返回的 priceLocale 是:
less
en_MX@currency=MXN (fixed en_MX@currency=MXN)
这个结果看起来是符合预期的------未登录沙盒账号时 StoreKit 无法确定 storefront,会回落到某个默认区域。为什么是 MX(墨西哥)而不是其他地区,原因未明,可能是 Apple 内部的默认 storefront 配置。
实际意义:如果沙盒账号尚未登录就调用 SKProductsRequest,拿到的 locale 可能是与用户真实地区完全无关的默认值,需确保先登录在对应区域的沙盒账号后再发起请求。
TestFlight 永远走沙盒
TestFlight 下载的包在 IAP 环节始终使用沙盒环境,无法测试真实 storefront。真实货币验证只能用 App Store 正式包 + 对应区域账号。
沙盒账号语言无法在创建时指定
创建沙盒账号时没有语言选项,Apple 根据所选国家/地区自动映射默认语言(沙特区默认映射英语,即 en_SA)。要复现 ar_SA 格式,需要在测试设备上切换系统语言为阿语,而非另建账号。
系统语言切换不是即时生效
切换系统语言后即使重启 App,priceLocale 也可能仍是旧值。需要退出沙盒账号重新登录并等待一段时间才会生效。
八、多国家货币格式如何批量验证
App 在全球多个国家和地区售卖,每个地区的货币符号、小数位数、排列方式均不同。为每个地区单独建一个沙盒账号通常不现实。
更实用的方式是:将真实拿到的 SKProduct.price 与手动构造的目标 priceLocale 组合,在单测或 Playground 中直接验证各地区的格式化结果。
csharp
// 模拟不同地区的 priceLocale------无需对应地区沙盒账号
let locales: [(String, String)] = [
("en_US@currency=USD", "美国"),
("en_SA@currency=SAR", "沙特(英语)"),
("ar_SA@currency=SAR", "沙特(阿语)"),
("de_DE@currency=EUR", "德国"),
("ja_JP@currency=JPY", "日本"),
("en_TR@currency=TRY", "土耳其"),
("en_MX@currency=MXN", "墨西哥"),
]
let price: NSDecimalNumber = 9.99 // 用真实的 SKProduct.price 替换
for (identifier, region) in locales {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = Locale(identifier: identifier)
let result = formatter.string(from: price) ?? "nil"
print("[(region)] (identifier) => (result)")
}
输出示例:
ini
[美国] en_US@currency=USD => $9.99
[沙特(英语)] en_SA@currency=SAR => SAR 9.99
[沙特(阿语)] ar_SA@currency=SAR => ٩٫٩٩ ريال سعودي
[德国] de_DE@currency=EUR => 9,99 €
[日本] ja_JP@currency=JPY => ¥9
[土耳其] en_TR@currency=TRY => TRY 9.99
[墨西哥] en_MX@currency=MXN => MX$9.99
一次跑完就能看到所有地区的实际输出,尤其对 RTL 语言(阿语)的符号方向、数字形状等可以快速覆盖验证。
总结
本文所有讨论的核心只有一句话:
最终展示的价格(包含货币符号、数字形状、小数点格式、符号位置),完全由 SKProduct.priceLocale 决定。客户端无需、也不应当手动干预任何货币符号展示逻辑,把 priceLocale 交给 NumberFormatter 就对了。
其他因素(系统语言、App Store 账号地区、ASC 定价配置)的作用,最终都是通过影响 priceLocale 的三个维度(currency、地区、语言)来间接作用于最终展示。排查货币展示类问题时,先把 priceLocale 打印出来,就能明确定位问题所在。