App Store 五次退件實錄:條款判讀與回覆紀律
這篇逐輪對照一款 iOS App 在同一個版號上被退件五次的過程:Apple 信裡寫的理由,與真正該改的東西。讀者需要對審查條款編號(2.1、4、5.1.1、5.1.2、2.5.1)、StoreKit、TestFlight 有基本認識,讀完能拿到一套「回覆訊息、備註欄、二進位驗證」的操作紀律。
這是 追追推推 追星族遠征紀錄 App 複盤 的延伸閱讀,完整的條款對照放在這裡。
信裡寫的理由與真正該改的東西經常不同層次
審查裝置從頭到尾都是同一款:iPad Air 11-inch(M3),流程也是同一個循環:
flowchart TD A[送出審查] --> B{收到結果} B -->|退件| C[逐字比對信件內容與後台條款碼] C --> D[本地驗證找出真正原因] D --> E[回覆訊息說明成因與修法] E --> F[備註欄記錄本輪差異] F --> G[重新提交] G --> B B -->|通過| H[結束]
後台標的條款碼,可能跟信件內容完全脫鉤
第一次退件,Apple 的自動化訊息判定 binary 含 VPN 功能,要求說明收了什麼資料、用途、是否分享第三方;後台標的案由卻是 2.1.0 App Completeness,跟信件內容對不上。
App 裡一行 VPN 程式碼都沒有。來源是靜態連結進 binary 的 Google Mobile Ads SDK,內含網路介面名字串(tap、tun、ppp、ipsec、utun)與 HTTPProxy、HTTPSProxy 鍵,觸發了 Apple 的自動二進位掃描。判斷分支要看信件內容,後台條款編號的字面定義套不上去。
修法不改程式、不重打 build,只對送審那顆 .ipa 做二進位檢查,證明 NEVPNManager 等符號皆為 0。自動掃描比對的是 binary 裡的字串,跟 App 實際有沒有這個功能無關。
技術細節:二進位檢查的定型句與正對照組
二進位 grep 的定型句是
grep -a -o <字串> <檔> | wc -l,-a不可省,-c會忽略-o而只數行數。單獨證明「沒有」某個符號說服力不夠,要搭配已知存在的符號(例如GoogleMobileAds、Reachability)當正對照組,證明檢查方法本身能抓到東西。 要驗「送審那顆」必須解.ipa,不能用工作樹,因為工作樹在送審後往往已經被平行修改過。
模板信的受詞可能是錯的,但審查員相信有東西存在
第二次退件引用 Guideline 5.1.2,指控 App 上傳了使用者通訊錄,開頭句還帶了 still,暗示是上一輪沒解決的問題。這個 App 連 NSContactsUsageDescription 都沒有,收到的是一封模板信,同樣的文字在 2017 年就有完全沒碰受保護資料的案例收到過。
推定的觸發點是上一輪回覆信裡自己寫的一句話:“our own Apple WeatherKit proxy server”,正好構成 5.1.2「上傳/同意」分支的標準形狀:開發者自承有自家伺服器,App 內卻看不到上傳同意畫面。回覆信裡多寫的每一句,都是下一輪的攻擊面。
修法是五語 purpose string 補上「不會上傳」的句子,並附上權限彈窗截圖逐條對答。
技術細節:5.1.2 的兩個分支靠開頭句分辨
5.1.2底下至少有兩個獨立分支。追蹤分支的開頭句是「indicates you collect data in order to track the user」,解法是改隱私標籤或實作 ATT;上傳/同意分支的開頭句是「accesses user data from the device but does not have the required precautions」,解法是改 purpose string 或加同意畫面。 上傳/同意這個模板早於隱私標籤上線三年,解方不可能是改隱私標籤。
隱藏式降級路徑,在審查員眼中就是功能不存在
第三次退件同時踩到兩條:Guideline 4 說畫面被切掉、按鈕碰不到,信中沒有截圖也沒點名頁面;2.5.1 說用了 HealthKit 卻沒在介面上清楚標示。
Guideline 4 的成因是月曆元件的 cellW = (pageWidth - spacing.xs * 6) / 7,7 格加 6 個間距在代數上剛好等於容器寬,零容錯。iPad 相容模式的縮放比例讓版面往上捨入一個 sub-pixel,星期六那一欄整個消失。修法是 cellW 改用 Math.floor。
2.5.1 的標示其實存在,但全 App 三處「Apple 健康」字樣都掛在 if (!isHealthModuleAvailable()) return null; 下游。HKHealthStore.isHealthDataAvailable() 在 iPad 上回傳 false,審查員看到的介面裡一個「健康」字都沒有。修法是把標示釘進設定頁常駐節,不掛任何顯示條件。
Apple 給的是症狀,信裡寫的又是複數 screens,找到一個實證不代表找齊了。
同一句話被退第二次代表上一輪解錯層次
第四次退件也是兩條並發。5.1.1(iv) 說 GDPR 表單出現在使用者已經選了「Ask App Not to Track」之後,Apple 這次罕見地直接給出口句,指明只要把表單順序排在 ATT 之前就不必改文案。成因是 initAds() 把順序寫死成 ATT、UMP、initialize,兩個彈窗都必要、都沒錯,錯的只有先後順序。修法是兩段對調,不加任何條件。
Guideline 4 這次跟上一輪逐字相同,只有 iPadOS 版本從 26.5.2 變成 26.6。上一輪把最外層 View 換成 ScrollView,只讓主要行動「捲得到」,沒讓它「第一屏就完整可見」。判準因此改成「審查員在任何狀態下第一眼看到的畫面是正常的」,捲得到不算解決。回覆信裡逐字寫過 “that container scrolls” 又被同一句退,再寫一次等於告訴審查員沒改。
這一輪還踩到一個不可逆的操作事故:在 ASC 誤將舊提交關閉,整串 Resolution Center 對話全部歸屬舊提交,新審查員點不進去,後續只能把重點濃縮進備註欄。
修法必須先假設審查員帶著已購買狀態進來
第五次退件說「選了購買,購買流程沒觸發,App 直接跳到內容,沒完成 IAP 流程」,Apple 這次的描述逐字正確。
成因是 StoreKit 2 對「已擁有的非消耗型商品」再次購買時會直接回成功,不顯示任何 UI,而 App 把這個事件無條件當成「剛完成一次新購買」,開通權益並彈出感謝訊息。IAP 相關的四支 JS 檔自驗收起零改動,套件版本也從未變過,變的是帳號狀態:TestFlight 的購買預設記在裝置的真實 Apple ID,關鍵在於要先登出 Media & Purchases,登出後用全新沙盒帳號,付款畫面正常出現。
ASC 的 Clear Purchase History 只作用於開發者自建的測試帳號,審查員的購買紀錄清不掉,所以修法必須對「審查員帶著已購買狀態進來」這個前提免疫。
回覆信先承認 Apple 的描述每個字都對,再解釋 App 把「帳號已擁有」誤判成「剛完成新購買」,並刻意保留一段預告:審查帳號已擁有此商品時,點購買列仍然不會出現付款表單,那是 StoreKit 的行為。
技術細節:三個結構性缺陷與被否決的修法
本地實查揪出三個結構性缺陷:唯一的 IAP listener 掛在設定頁且只在「未購買」時 render,畫面外沒有任何地方收 StoreKit 事件也沒有地方 finish 交易;先開通權益再 finish,兩者 race 且失敗被空 catch 靜默吞掉;收到相符 productId 就開通,不分辨事件來源。
曾考慮「finish 成功才開通權益」,但敗方代價是 finish 失敗的人付了錢當場拿不到東西;最後選擇的方案是使用者按了購買且 StoreKit 回成功即刻給權益,未 finish 的交易在下次進設定頁自我修復。
也考慮過靠
transactionDate分辨新購買與已擁有,但型別無單位註解、同檔案有毫秒與秒兩種前例、真正的序列化在遠端不可讀,最後改用「按下購買前的已擁有商品快照」當決定性判準。
條款編號的射程各不相同,判斷靠信件開頭句
條款編號只標明射程,同一個編號底下可能掛著好幾種完全不同的成因。
技術細節:本案踩過的五個條款編號各自管什麼
2.1/2.1(b)Performance - App Completeness:Apple 把「需要補充資訊」的自動訊息也歸在這裡,後台碼可能跟信件脫鉤;2.1(b)專指 IAP 有 bug,原文明文允許商品若無法在 App 內被找到或審查,在備註裡解釋,這是條款允許的做法。5.1.2Legal - Privacy - Data Use and Sharing:至少兩個分支,判斷看開頭句,見上文第二次退件的說明。5.1.1(iv)Legal - Privacy - Data Collection and Storage:管的是同意流程的次序,以及是否尊重使用者已表達的選擇。2.5.1Performance - Software Requirements:用了 HealthKit 就必須在介面上清楚標示,Core Motion 不算 HealthKit。4.0Design Preamble:版面在審查裝置上擁擠、被切掉、碰不到。
不上架 iPad 不能繞開 iPad 的審查
Apple DTS 的逐字說法是:UIDeviceFamily: 1 只告訴 App Store 這是為 iPhone 設計的,iPad 使用者照樣能下載並以相容模式執行。想用 UIRequiredDeviceCapabilities 加 telephony 或 healthkit 擋 iPad,等於拿一個新退件理由換掉舊的。
Apple 沒有任何一句規定主要行動必須在第一屏、或不得依賴捲動,HIG 的立場其實相反,允許捲動,前提是看得出來能捲。「主要行動常駐」屬於自選解法,同一句話被退第二次就代表要改解法,跟審查員辯沒有用。
技術細節:官方說法出處與相容視窗的四個坑
App Review 官方文件有一條 Don’t:不要假設在 Xcode 移除 supported destination 就能阻止該裝置類型的散佈。同一位 DTS 也明講,App 若不真的用電話功能,
UIRequiredDeviceCapabilities加telephony會被 App Store Review 直接拒絕。 相容視窗有四種在 iPhone 上永遠測不到的坑:Platform.isPad在相容模式回 true 但視窗是 iPhone 尺寸;insets.bottom === 0會讓insets.bottom || 34這類寫法走錯分支;鍵盤 frame 是螢幕座標而非 view 座標;iPad 還有浮動鍵盤與分割鍵盤要處理。
IAP 審查是在沙盒環境進行的,商品不需事先核准
App Review 就是在 sandbox 審 IAP,商品不需事先核准即可運作,而審查員的 sandbox 購買紀錄是持久的,開發者清不掉。
StoreKit 2 對已擁有的非消耗型商品會靜默回成功、不出任何 UI,這點 Apple 官方無明文,是第一手實測結果。任何「收到相符 productId 事件就發權益」的實作,在這個情境下都會在沒有付款畫面的情況下開通,那正是 2.1(b) 的定義。
技術細節:StoreKit 與 TestFlight 的其餘幾條實測結論
- 舊技術文件說重複購買會跳系統對話框,那是 StoreKit 1 的描述,射程不符的官方文件也會失效。
- 交易沒 finish,StoreKit 每次都會把它當 restored 重新派送。
- 官方立場是「都要」:啟動時用
currentEntitlements主動更新購買鈕狀態,同時保留一顆常駐的「恢復購買」鈕,二選一是錯的。AppStore.sync()不論登入狀態一律跳系統認證提示,只准在使用者明確按鈕時呼叫。- TestFlight 的 IAP 預設記在 Media & Purchases 的真實 Apple ID,要讓沙盒帳號生效,必須先登出 Media & Purchases,再於開發者設定登入沙盒帳號。
備註欄與回覆訊息是兩個機制,功能不能互相取代
拒審信把「回覆訊息」與「重新提交」並列要求,這是兩個動作。讓審查續行靠回覆訊息;build 一個位元組沒變就重新提交,同一套自動掃描很可能再觸發一次。射程也不同:訊息串是一次性對話,備註留在版本上,接手的審查員、下次改版、自動掃描都讀得到。
技術細節:ASC 兩個欄位的實測機制
ASC 備註欄上限 4000 字元;審查資訊的附件欄只能上傳一個檔案,Resolution Center 可以逐張附,多張圖要合併成單一 PDF;兩處都要上傳,因為 Resolution Center 是回應上一位審查員的主管道,審查資訊附件則跟著新提交走。 ASC Submission ID 一個提交可歷經多次退件與重送、ID 不變,換 build 也不變,EAS submission ID 則是每上傳一次就產生新的。在 ASC 關閉提交是不可逆的,會把整串 Resolution Center 歷史一起帶走。 備註不要寫死 build 號,會被整段沿用;第二版起,審查員第一個要知道的是與已通過的那版差在哪,並要主動排除新增權限、framework、資料流這類追問。
回覆信只寫已經驗證過的事
沒測過的就不寫成驗證句,也不用模糊措辭包裝。禁用全稱句,例如「所有畫面都驗過」「所有資料不離開裝置」:本案的「資料不離開裝置」字面上是假的,音樂搜尋會把使用者輸入的字串送出去,精確句是「送到伺服器的只有行政區代碼與使用者主動輸入的搜尋字串」。
也不寫與 ASC 後台矛盾的話:本案隱私標籤六格中有五格標「用於追蹤=是」,此時寫 “we do not track” 是審查員一鍵可查的假聲明。引官方文件論證所以不用管 iPad,容易被讀成挑釁。推論的語氣停在 “the likely cause”,不寫成斷言。
隱私申報寧可過度,不要不足
「連結身分」與「用於追蹤」是兩條獨立的軸,不要綁在一起改。過度申報不會被罰,申報不足才會導致退件或下架,有疑慮就往「是」填。第三方 SDK 收集的算開發者收集,官方原文明講開發者對第三方函式庫存取隱私敏感資料負全責。
技術細節:隱私標籤的判定邊界與取得 xcprivacy 的免費路徑
第三方 SDK 的
PrivacyInfo.xcprivacy可以直接從 CocoaPods 下載取得,不需要ios/目錄或本機 archive,這繞過了 Expo managed workflow 的限制。 有NSUserTrackingUsageDescription就必須至少一項 tracking 標「是」,ASC 介面會強制。粗略位置的定義是依解析度,經緯度低於小數點後三位算粗略,與有沒有位置權限無關;IP 位址也要依用途申報。Apple 對「收集」的定義是傳出裝置,步數不出裝置就是未收集,不需申報 Health & Fitness。
能用免費手段查完的,就不要送 build
build 只驗得到程式碼,設定層的錯誤用免費手段就查得完。字串在二進位裡的鄰近性只能證歸屬,不能證觸發。成因沒證實時,選一個不論假設成不成立都該修的修法,讓修法對成因假設不敏感。
退件信只存在 ASC 後台,Apple 的截圖放在下載資料夾會被洗掉,收到當天、動手分析前要先做完信件全文逐字、附件原檔、時間軸、送出紀錄這四步留存,通過信同樣要存。
技術細節:build 台帳與 OTA 驗收陷阱
打了沒送的 build 也要登記,否則事後查不到它存在過。 拿裝了 OTA 的裝置驗收,等於從沒驗過內嵌包,審查員拿到的是全新安裝,第一次啟動跑的是內嵌包,OTA 要第二次啟動才套用。
想繞開 iPad 的幾條路都在動手前被否決
技術細節:評估過但沒走的方案與否決理由
方案 否決理由 移除 iPad 的 supported destination 不阻止 iPad 安裝,仍以相容模式執行,官方文件明文列為 Don’t UIRequiredDeviceCapabilities加 telephony 或 healthkit拿新退件理由換舊的,該鍵只能用於真正的硬體依賴,且上架後只能放寬不能加嚴 開啟 supportsTablet: true把已知的窄視窗換成全新未驗證的大版面,且會動到原生層,讓「前後兩顆 build 原生層逐位元組相同」這條驗收基準一起消失 UMP 表單加「ATT 狀態不等於 denied」的保險條件 前提是錯的, .restricted與.denied被壓成同一個值,會讓法規要求的同意表單一次都不出現App Review Board 申訴、Webex 諮詢 留作最後手段,判準是留到第三次收到同一封信再用,全程未動用
通過不代表任何一條修法被證實有效
通過信沒有 Review Environment 段,不會說審查員走過哪些流程,只知道這次過了,不知道是哪一項讓它過的。信末的制式段也不是審查員的觀察:最後一封信末提到的協議問題,實查後確認協議生效中,是罐頭文字。
「iPad 上 isHealthDataAvailable() 回 false」至今仍是外部資訊,未經第一手驗證;Apple 是否跨審查回合沿用同一個 sandbox 帳號,也沒有公開說法。
相關連結
- 追追推推 追星族遠征紀錄 App 複盤:這個 App 的整體複盤
- NailNote 本地美甲筆記 App 複盤:純本地、零資料收集、無 IAP、無廣告的上架經驗,對照之下看得出廣告與 IAP 讓合規面積擴大了多少
- AI 協作開發:非技術 PM 的驗收閘門:不自報完成、要有可觀察證據,具體形式就是解
.ipa做二進位檢查