返回文章列表

被 Apple 连拒两次之后,我决定把 GPlay 的"伪装功能"彻底下线

2026年4月19日


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. 历史通过:这个伪装功能从 1.0.7 起就有,连续 9 个版本(1.0.7 → 1.0.15)审核通过
  2. 同类先例:"Calculator# Hide Photos Videos"、"Private Photo Vault" 等同类 App 合计 140 万评论,全部在架
  3. 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。

一个下午的手术

代码里"伪装"相关的东西分布很广:

  • 锁屏 UIcalculator/notepad/ 两个完整的页面目录
  • 核心服务DecoySpaceService(管理当前空间状态、伪装密码)
  • 数据层VideoFingerprintsAlbumsPlaylistsPlayHistory 四张表都有 spaceId 字段(0=真实,1=伪装)
  • 设置页:伪装模式开关、伪装外观选择页(一个 682 行的 PageView 卡片)、Decoy Space 开关和密码设置
  • 会员权益membership.benefit.decoy_spacemembership.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 掉。但这是最坏的选项:

  1. SQLite 不支持 ALTER TABLE DROP COLUMN,Drift 要做一次全表复制
  2. 老用户升级时数据库迁移失败,视频全没了——比被 Apple 拒一百次还惨
  3. 跨越版本的 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 加 11.0.16+11.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 调用点都清掉后会被一并删除。它作为一个提醒保留着:不是所有"用户有需求"的功能都值得做


查看所有文章