GPlay 是我做的一款 iOS 私密视频播放器。上架以来一直有一个"伪装功能":锁屏界面可以伪装成计算器或备忘录,输入不同密码进入真实空间或伪装空间。从 1.0.7 版本引入,连续 9 个版本通过了 App Store 审核。
直到 1.0.16。
被拒两次
第一次拒绝是 2026 年 4 月 14 日,审核备注一句话:
The app uses a decoy functionality to hide a user's photos, which does not comply with guideline 2.5.1.
我看到这封信的第一反应是:不对啊,我用的是标准 PHPickerViewController 啊。
2.5.1 是 "Performance - Software Requirements",约束的是"非文档化的 API 使用"。我盯着 Photos API 的调用代码看了半天——PHPickerViewController 是苹果自己主推的、标准得不能再标准的相册选择器。没有任何 private API。
于是我写了一封长回复,论据有三:
- 历史通过:这个伪装功能从 1.0.7 起就有,连续 9 个版本(1.0.7 → 1.0.15)审核通过
- 同类先例:"Calculator# Hide Photos Videos"、"Private Photo Vault" 等同类 App 合计 140 万评论,全部在架
- API 用得对:PHPickerViewController 是标准文档化用法
三天后,回复来了。一句话:
The issues we previously identified still need your attention.
连续两次,同一个理由,几乎是同一句话。
我读错了拒绝理由
等我冷静下来再读 Apple 的原文,才发现自己误读了重点。
拒信里说的是:
The app uses public APIs in an unapproved manner. Specifically, we found that the app uses a decoy functionality to hide a user's photos, which is not an appropriate use of the Photos API.
我一直把重点放在"public API"和"unapproved manner"上。Apple 真正说的是后半句:你用 Photos API 的产品形态是 decoy,这本身就不是 Photos API 被授权的用途。
换句话说,Apple 不关心你怎么调用 API,关心的是你用这些 API 构成了一个什么样的产品。
伪装类 App(Calculator disguise / Decoy Space)是一种 产品属性判断,不是技术判断。Apple 用 2.5.1 作为政策工具来收紧这类 App。
那些"同类 App 在架"的论据为什么没用?因为 Apple 的审核标准在演变。140 万评论那些老 App,大多数是多年前通过的;今天再提这样的产品,大概率过不了。
"连续 9 个版本通过"也是同样的逻辑:以前漏网不代表合规,审核员在某一版突然较真是完全正常的。
三个选项
第二次被拒那天下午,我摆了三条路:
A. 最小妥协:去掉计算器/备忘录外观(改成标准 PIN 锁屏),但保留 Decoy Space 双密码假空间。
B. 彻底去伪装:计算器/备忘录外观 + Decoy Space 双密码,全部下线。只留"密码 + Face ID + 入侵者侦测 + 紧急手势"这套标准安全。
C. 再申诉:写更长的回复继续掰扯。
选 C 的话,大概率再被拒,而且 Resolution Center 来回一次要 2–5 天。每次被拒影响心态,审核员也会越来越不耐烦。
选 A 的话,Decoy Space 本身仍然是 "decoy functionality"——Apple 原话用的是 "a decoy functionality",范围覆盖所有伪装形式。A 方案还有二次被拒的风险。
我选了 B。
一个下午的手术
代码里"伪装"相关的东西分布很广:
- 锁屏 UI:
calculator/和notepad/两个完整的页面目录 - 核心服务:
DecoySpaceService(管理当前空间状态、伪装密码) - 数据层:
VideoFingerprints、Albums、Playlists、PlayHistory四张表都有spaceId字段(0=真实,1=伪装) - 设置页:伪装模式开关、伪装外观选择页(一个 682 行的 PageView 卡片)、Decoy Space 开关和密码设置
- 会员权益:
membership.benefit.decoy_space、membership.benefit.app_disguise - i18n:18 种语言,每个文件几十条相关翻译
- 文档:App Store 副标题、应用描述、Review Notes 都围绕"伪装"定位
- 测试:3 个直接测试这些功能的测试
全部清出去,约 40+ 文件改动。
1. 锁屏 UI 和 DecoySpaceService 不能同时删
这是我学到的第一个教训。一上手想把 DecoySpaceService 整个删掉,结果 17 个调用点同时编译失败,其中好几个还深入到数据查询层(spaceId 参数)。一次性改完意味着一个下午都在看 analyzer 报错。
正确做法:把 DecoySpaceService 降级成 no-op stub:
class DecoySpaceService {
static final DecoySpaceService instance = DecoySpaceService._();
int get currentSpaceId => 0; // 永远真实空间
bool get isDecoyMode => false;
bool get isDecoyEnabled => false;
bool isDecoyPasscode(String input) => false;
void enterRealSpace() {} // 全部 no-op
void enterDecoySpace() {}
Future<void> enableDecoySpace(String p) async {}
// ...
}保留 API 表面,所有调用点原样工作,但行为语义变成"永远处于真实空间"。然后逐文件清掉调用,不用一次性改完。
2. 数据迁移:不要 DROP 列,要 UPDATE
数据库 schema 上,spaceId 是一个真实的 INT 列,从 schema v2 开始就在那了。最直接的做法是出一个 schema v5 把列 DROP 掉。但这是最坏的选项:
- SQLite 不支持
ALTER TABLE DROP COLUMN,Drift 要做一次全表复制 - 老用户升级时数据库迁移失败,视频全没了——比被 Apple 拒一百次还惨
- 跨越版本的 schema 升级测试很难写
我选了应用层迁移 + 不动 schema:
abstract final class DecoyDataMigrationService {
static const String _flag = 'migration.decoy_data.v1.done';
static Future<void> migrate() async {
final sp = SpService.instance;
if (sp.getBool(_flag, defValue: false)) return;
final db = ClassificationDatabase.instance;
await db.customStatement(
'UPDATE video_fingerprints SET space_id = 0 WHERE space_id != 0',
);
// ...同样处理 albums / playlists / play_history
await sp.setBool(_flag, true);
}
}把 spaceId=1 的记录全部改成 0,幂等,启动时执行一次。用户的视频一条不丢,只是"空间概念"消失了——原来藏在伪装空间的视频直接合并回主空间。
这种"零丢失迁移"比"整洁的 schema"重要得多。schema 清理可以下一版本再做,用户信任丢了就回不来。
3. 名字里藏着的语义
AppSettingsService.isDisguiseModeEnabled 这个 flag 看起来是"伪装模式开关",实际上还绑着**"切后台自动锁屏"**这个行为:
if (!AppSettingsService.instance.isDisguiseModeEnabled) return;
// 这里是切后台自动锁屏的守卫如果直接把它删了,自动锁屏功能会彻底失效。用户会收到一个"更新后自动锁屏消失"的 regression。
我把它重命名为 isAutoLockEnabled,把 SpService 的 key 也从 settings.security.disguiseMode 迁到 settings.security.autoLock,老用户的开关状态一次性继承过来:
Future<void> _migrateLegacyAutoLockKey() async {
final sp = SpService.instance;
if (sp.getBool(_keyAutoLockMigrationDone, defValue: false)) return;
if (sp.exists(_keyLegacyDisguiseMode)) {
final legacy = sp.getBool(_keyLegacyDisguiseMode, defValue: false);
await sp.setBool(_keyAutoLock, legacy);
await sp.remove(_keyLegacyDisguiseMode);
}
await sp.setBool(_keyAutoLockMigrationDone, true);
}功能语义保留、字眼中立化,是我认为最合适的处理方式。
4. 18 种 i18n 不可能手工改
18 个语言文件,每个里有几十条 decoy.* / disguise.* / calculator.* / notepad.* 相关 key。手工改 18 遍不现实,sed 对 Dart map 多行 value 又不安全。
我写了两个 Python 脚本:
- clean_i18n.py:逐行读,遇到
DELETE_PREFIXES开头的 key,如果 value 跨多行就连着多行一起删 - patch_i18n_values.py:把所有保留 key 的 value 换成英文 fallback(保底可读)
一共删掉 1070 条废弃 entry,覆盖 192 条 key value 为英文。中文和英文的核心用户体验用人工翻译精修(这是 Apple 审核和核心市场最敏感的两种语言);其他 16 种语言用英文 fallback,下一版本再人工翻译。
5. 是新版本,还是同版本新 build?
第一版本计划里我写的是"发 1.0.17"。写完让它晾一会儿,回过头一看——这是个典型的错误。
被拒的 1.0.16 还在 App Store Connect 里是 "Rejected" 状态,Resolution Center 对话是 open 的。此时正确的做法是保持 version 不变,build number 加 1(1.0.16+1 → 1.0.16+2),直接在原 version 页面上传新 build,在 Resolution Center 回复说明已修订。
跳到 1.0.17 反而会:
- 迫使我在 ASC 新建 version + 写新的 What's New
- 绕过 Resolution Center 的对话上下文
- 让审核员从 fresh 角度重审(可能触发其他无关问题)
这是 App Store 的标准修订流程,但不做过第一次之前,很容易本能地想"新改动 = 新版本号"。
给 Apple 的回复,只写四段
第一次申诉我写了 700+ 字,据理力争,列了一堆同类 App。结果没用。
这次 Resolution Center 回复只写四段:
Dear App Review Team,
Thank you for the additional feedback on build 1. We understand and
accept the guidance. We have completely removed the decoy functionality
from GPlay:
- The calculator / notepad-style lock screen appearances have been removed.
- The dual-passcode decoy space feature has been removed.
GPlay is now a standard private video player with a conventional
passcode lock screen. Users import their own videos into an in-app
private library protected by a passcode, biometric authentication, and
an intruder-detection failsafe — all standard security features.
There is no disguise, decoy, or mimicry of any system app.
A new build has been submitted for version 1.0.16 with these changes
and updated App Review Notes. We would be grateful for your re-review.
Best regards,
Gunther Lau
不再辩论。不再列同类 App。不再讲历史通过。
审核员每天看几百个 case,长篇辩论只会让他们想"这人又在找借口"。简短、承认、说清楚改了什么、请求重审——这是更成熟的姿态。
下一步学到的
做独立 App 三年多,这是我第一次被 Apple 用 2.5.1 拒。复盘下来几条:
1. Apple 的政策边界在动。 同样的功能今年能过,明年可能过不了。不要把"历史通过"当做合规证据。
2. Review Notes 不是防线,UI 才是。 伪装类 App 被拒后,常规反应是"我再写一封更清楚的 Notes 解释给审核员"。没用。审核员是看运行时的 App 行为判断的,他们甚至不会仔细读 Notes。
3. 申诉是一张可以打但别指望赢的牌。 首次被拒可以申诉一次(有时候确实是审核员误判)。第二次被同一理由拒意味着对方的判断已经定型,再申诉等于浪费周期。要立刻进入方案执行。
4. 冗余的架构有它的代价。 Decoy Space 的 spaceId 字段侵入了 4 张数据库表、17 个业务文件。一个"以防万一多塞一个维度"的决定,在要拔掉时成本翻倍。
5. 合规改动要保住用户数据。 永远优先选"保守迁移(合并 / 标记废弃)"而不是"激进清理(删除 / DROP)"。数据层的洁癖可以下一个版本再满足,用户的视频不能丢一秒。
代码已经改完,1.0.16 build 2 上传了。现在等 Apple 审核,1-3 天出结果。
这篇文章本来可以等审核通过再发,但我决定先写。不是因为乐观,而是因为决策质量与审核结果无关。就算 Apple 再找别的理由拒,下线伪装功能仍然是对的——因为审核倾向摆在那里,现在不拔以后也要拔。
如果你的 App 里也有类似的"灰色地带"功能:伪装锁屏、双空间、隐藏 App 图标一类的。别等到被拒再动手。现在是 Apple 对 2.5.1 的解读最严的时候,主动合规比被动妥协好看得多。
我的那个 DecoySpaceService 现在是一个 40 行的 no-op stub,等所有 currentSpaceId 调用点都清掉后会被一并删除。它作为一个提醒保留着:不是所有"用户有需求"的功能都值得做。