App Store 五次退件實錄:條款判讀與回覆紀律
這篇記錄一個 iOS App 被 Apple 退件五次才通過上架的完整過程:退件信寫的理由,跟工程上真正需要修改的地方,中間常常有落差,這篇把每一輪的落差攤開來看。適合正在自己送審 App、尤其是用 Expo/React Native 開發、且 App 裡有廣告 SDK、App 內購買(以下簡稱 IAP)或需要處理隱私問卷的人讀。讀完可以掌握退件信怎麼判讀、回覆信該遵守哪些紀律,以及 IAP 在審查環境裡幾個容易被忽略的坑。
這篇是 追追推推 追星族遠征紀錄 App 複盤 的延伸閱讀,那篇只留摘要,完整的條款對照與教訓收在這裡。
總帳
同一個 1.0.0 版本,前後送審六次,被退五次,第六次才通過:
| 項目 | 數字 |
|---|---|
| 提交次數(1.0.0) | 6 次 |
| 退件次數 | 5 次 |
| 通過日期 | 2026-08-28 |
| 第一次送審到通過 | 21 天 |
| build 累計編號 | 到 (13),通過的是 (12) |
| 打了但從未送審的 build | 2 顆 |
| 同一顆 build 被退次數 | (6) 被退 2 次,中間零程式碼變更 |
| 審查裝置 | 第 2 到 5 次每次都是 iPad Air 11-inch(M3) |
最後一輪只花約兩天,前五輪各花 1 到 6 天不等。真正燒掉時間的不是等待,是前四輪裡有三輪其實解錯了層次,或把因果歸錯了對象。
五次退件,逐一拆解
每一輪都分成 Apple 信裡寫了什麼,跟工程上實際發生了什麼,因為這兩件事常常對不齊。
第一次:被判定含 VPN 功能
Apple 自動判定 binary 含 VPN 功能,要求說明收了什麼資料;但後台,也就是 App Store Connect(以下簡稱 ASC),標的案由是 2.1.0 App Completeness,跟信件內容完全脫鉤。
真正的原因不是程式碼,是靜態連結進 binary 的 Google Mobile Ads SDK,內含網路介面名稱字串,觸發了 Apple 的自動二進位掃描。修法不改程式,直接對送審那顆 .ipa 做二進位檢查證明沒有 VPN 相關符號,當天回覆重新提交。
二進位檢查怎麼做的
九項檢查裡,
NEVPNManager等六個 VPN 相關符號在 binary 裡出現次數皆為 0,並附上GoogleMobileAds命中 222 次、Reachability命中 26 次當正對照組,證明檢查方法本身有效,不是查不到東西。
這輪學到:後台的條款編號可能跟信件內容脫鉤,判讀要以信件文字為準;自動掃描看的是字串不是行為,沒寫的功能,依賴的 SDK 可能替你「寫」進了 binary。
第二次:被判定上傳了通訊錄
案由是 Guideline 5.1.2,內容是 App 存取裝置資料卻沒有事前告知與同意,信件開頭帶了一個 still 字,暗示這是上一輪沒解決的舊問題。
但這個 App 連通訊錄的用途說明字串都沒有,這封信其實是模板信。最可能的觸發點,是我方在上一輪回覆信裡自己寫了一句「有自家伺服器」,正好構成 5.1.2「上傳並需要同意」這條分支的標準形狀:開發者自承有伺服器,App 裡卻看不到對應的同意畫面。
修法是重打 build,五種語言的用途說明都補上「不會上傳」的句子,回覆信逐條對答,並第一次附上六張權限彈窗截圖佐證。
這輪學到:模板信的具體受詞可能完全錯,但審查員從選單挑出那個分支,代表他相信有東西被上傳,要找的是他為什麼這樣相信,不是反駁受詞本身。另外,回覆信裡多寫的每一句話都是下一輪的攻擊面,這輪的成因很可能是自己在上一輪招來的。
第三次:畫面被切掉、健康資料標示不清
這輪同時中了兩條。Guideline 4 說部分畫面被切掉、按鈕碰不到,信裡沒有截圖也沒點名頁面;Guideline 2.5.1 說用了 HealthKit 卻沒有在介面上清楚標示。
兩個原因分別是什麼
畫面被切的原因,是月曆元件的欄寬計算式在代數上剛好等於容器寬,零容錯。iPad 相容模式的縮放比例會讓版面引擎往上捨入一個 sub-pixel,第七格被擠到下一行,結果整個星期六欄消失,修法是欄寬計算改用無條件捨去。健康標示的問題更隱蔽:標示文字確實存在,但全 App 三處寫著健康字樣的地方,全都掛在一個「裝置不支援健康資料就不顯示」的判斷式下游,而這個判斷在 iPad 上恰好回傳不支援,審查員在 iPad 上打開這頁時一個健康字都看不到,修法是把標示釘進設定頁的常駐區塊,不掛任何顯示條件。
這輪學到:裝置不支援就靜默隱藏功能,是好的工程直覺,但送審時會連合規標示一起藏起來;Apple 給的是症狀不是定位,找到一個確定的實證不代表找齊了,Apple 信裡寫的是複數 screens。
第四次:同一句話被退第二次
5.1.1(iv) 這條說 App 顯示追蹤同意彈窗的時機,晚於使用者已經在系統層選擇不要被追蹤。這輪 Apple 難得直接給了出口句,指明只要把 GDPR 表單排在 App Tracking Transparency(以下簡稱 ATT,蘋果系統層的追蹤同意彈窗)前面就行。原因是廣告初始化函式裡,ATT、Google 的 GDPR 同意彈窗、廣告初始化三步驟順序寫死了,修法就是把兩段對調。
同一輪還有 Guideline 4 逐字重複上一次的內容,只差 iPadOS 版本號不同。上一輪的修法把最外層容器換成可捲動視窗,只讓主要按鈕捲得到,沒讓它在第一屏就完整可見,審查員第一眼看到的仍是被切掉九成的按鈕。
這輪學到最重的一課:同一句話被退第二次,代表上一輪解錯了層次,不是解得不夠,判準要改寫成「審查員在任何狀態下第一眼看到的畫面是正常的」,捲得到不算解決。上一輪回覆信裡逐字寫過的句子,這輪不能再原樣寫一次,等於告訴審查員我沒改。
這輪還踩到一次不可逆的操作事故
在 ASC 誤將舊提交關閉,導致前四次退件與回覆的整串 Resolution Center(審核後台跟審查員來回溝通的訊息中心)對話全部歸屬舊提交,新審查員完全點不進去,後續只能把重點濃縮進備註欄。這件事後來變成一條操作紀律:ASC 的提交一旦關閉無法復原,會把整串對話歷史一起帶走。
第五次:購買流程沒觸發
案由是 2.1(b),Apple 描述選了購買選項但付款流程沒有出現,App 直接導向內容,等於沒完成購買就開通了權益。這段描述逐字正確。
成因是 StoreKit 2(Apple 的購買框架)對已經擁有的非消耗型商品再次購買時,會直接回傳成功、不顯示任何付款畫面,而 App 把這個事件無條件當成剛完成一次新購買,開通權益並跳出感謝訊息。
真正的落差不在 Apple,在我方一開始的歸因錯誤:本地重現成功後,一度判定是審查帳號本來就已擁有這個商品,換了全新沙盒帳號重測,症狀完全相同,才推翻這個假設。回去翻本地檔案才挖出真正的根因:TestFlight 的購買紀錄預設記在裝置的真實 Apple ID 上,不是沙盒測試帳號,兩次換沙盒帳號的實驗都忘了先登出 Media & Purchases(裝置設定裡登入購買身分的那組帳號),改到的都是無效變數。正確登出後用全新沙盒帳號測試,付款畫面才正常出現。
這輪學到:重現症狀不等於證實成因,換一個變數症狀照舊,才知道原本歸因的那個變數不是原因;修法必須對「審查員帶著已購買狀態進來」這個前提免疫,因為 ASC 的清除購買紀錄功能只作用於開發者自建的測試帳號,審查員的購買紀錄清不掉。
第五次的回覆信寫法
先承認 Apple 的描述每個字都對,再解釋 App 把帳號已擁有誤判成剛完成新購買,並刻意保留一段預告:若審查帳號已擁有此商品,點購買列仍然不會出現付款表單,那是 StoreKit 對已擁有非消耗型商品的正常行為,不是 bug。
以下這張圖是這五輪反覆磨出來的共通判讀流程:
flowchart TD A[收到退件信] --> B[逐字讀信件內文找開頭句] B --> C[對照ASC後台條款碼是否脫鉤] C --> D[本機重現或做二進位檢查] D --> E{能否證實成因} E -- 能 --> F[針對真正成因修法] E -- 不能 --> G[選對成因假設不敏感的修法] F --> H[回覆信用可驗證的語氣描述] G --> H H --> I[重新提交]
條款速查
五輪下來,踩到的條款集中在這幾條:
2.1/2.1(b)Performance - App Completeness:Apple 常把需要補充資訊的自動訊息也歸在這裡,後台條款碼常跟信件內容脫鉤,2.1(b)專指 IAP 有 bug。5.1.2Legal - Privacy - Data Use and Sharing:至少有兩個獨立分支,一個管追蹤,一個管上傳與同意,要看信件開頭句判斷是哪一支。5.1.1(iv)Legal - Privacy - Data Collection and Storage:管的是同意流程的次序,以及是否尊重使用者已表達過的選擇。2.5.1Performance - Software Requirements:用了 HealthKit 就必須在介面上清楚標示。4.0Design Preamble:版面在審查裝置上被擠壓、切掉、按不到。
5.1.2 兩個分支怎麼分辨
追蹤分支的開頭句是關於為了追蹤使用者而收集資料,解法是改隱私標籤或實作 ATT。上傳/同意分支的開頭句是關於存取裝置資料卻缺乏必要防護,解法是改用途說明或加同意畫面。這個模板信件的版本從 2017 年就存在,比隱私標籤機制早三年,所以它的解方不可能是改隱私標籤,這條時間線可以當判斷分支的旁證。
iPad 相容模式:最貴的一課
不上架 iPad 不是選項。Apple 開發技術支援的說法是:宣告只支援 iPhone 只是告訴 App Store 這是為 iPhone 設計的,iPad 使用者照樣能下載、以相容模式執行;官方文件也明講不要以為在專案裡移除支援目標裝置就能阻止該類型下載。
想用裝置能力宣告去擋 iPad,等於拿一個新退件理由換掉舊的,同一份技術支援文件也明講:App 若不是真的用到那項硬體能力,一樣會被 App Review 退件。
iPad 相容視窗特有、在 iPhone 上測不到的坑
- 判斷裝置類型的 API 在相容模式下回傳 iPad,但視窗其實是 iPhone 尺寸
- 安全區域下緣為 0,那個視窗底下真的沒有 home indicator,容易讓「取不到值就用預設值」這類寫法走錯分支
- 鍵盤 frame 是螢幕座標,不是視圖座標
- iPad 特有的浮動鍵盤與分割鍵盤
還有一件事值得記:Apple 沒有任何一句規定主要行動必須在第一屏、或不得依賴捲動,人機介面指南的立場其實相反,允許捲動,前提是要讓使用者看得出來這裡能捲。「主要行動常駐第一屏」是自選的解法,不是規定,這個區分要誠實帶進回覆信,但同一句話被退第二次,代表該做的是換解法,不是去跟審查員辯規定寫在哪裡。
IAP 送審的硬事實
幾個第一手撞出來、平常不會寫在文件裡的事實:
- App Review 就是在沙盒環境審查 IAP,商品不需事先核准即可運作。
- 審查員的沙盒購買紀錄是持久的,開發者清不掉。
- StoreKit 2 對已擁有的非消耗型商品,會靜默回傳成功、不顯示任何付款畫面,這是第一手實測,Apple 官方文件對此沒有明文。任何「收到相符商品 ID 事件就發權益」的實作,在這個情境下都會在沒有付款畫面的狀況下開通權益,那正是
2.1(b)條款定義的問題。 - 舊版技術文件說重複購買會跳系統對話框,那描述的是 StoreKit 1 的行為,官方文件也會因為射程對不上版本而失效。
IAP 其餘操作細節
- 交易沒有結清,StoreKit 每次啟動都會把它當成 restored 重新派送。
- 官方立場是兩件事都要做:啟動時主動更新購買鈕狀態,同時保留一顆常駐的恢復購買鈕,二選一是錯的。
- 恢復購買不論登入狀態一律跳出系統認證提示,因此只能在使用者明確按下按鈕時呼叫。
- TestFlight 的 IAP 預設記在 Media & Purchases 的真實 Apple ID 上,不是沙盒測試帳號。要讓沙盒帳號生效,必須先在裝置設定登出 Media & Purchases,再到開發者設定登入沙盒帳號,不做這一步,換沙盒帳號這個測試變數根本沒生效。
回覆信紀律
五輪換來的幾條硬規矩:
- 沒測過的東西不寫成已驗證的句子,作法是不寫,不是用模糊措辭包裝。
- 禁用全稱句,例如所有畫面都驗過、所有資料不離開裝置。這次的資料不離開裝置字面上就是假的,音樂搜尋功能會把使用者輸入的字串送出去,改成精確句:送到我方伺服器的只有行政區代碼與使用者主動輸入的搜尋字串。
- 不寫跟 ASC 後台矛盾的話,隱私標籤裡有五格標了用於追蹤是,這時候寫我方不追蹤,審查員一鍵就能查出是假的。
- 不引用官方文件去論證所以不用管 iPad,容易被讀成挑釁。
- 推論寫進回覆信時,語氣要停在「這可能是原因」,不要寫成斷言。
Resolution Center 與備註欄的機制
ASC 後台幾個容易踩雷的操作細節
- 回覆訊息與重新提交是兩個獨立動作,不能互相取代。拒審信會把兩者並列要求,讓審查續行靠回覆訊息;build 一個位元組都沒變就重新提交,同一套自動掃描很可能再觸發一次。
- 訊息串是一次性對話,備註留在版本上,接手的審查員、下次改版、自動掃描都讀得到,兩者射程不同。
- ASC 備註欄有字數上限,本案曾超出一點點,刪掉四句不損證據的話才壓回去。
- 審查資訊的附件欄實測只能上傳一個檔案,Resolution Center 則可以逐張附,多張圖要合併成單一檔案。
- 附件要兩處都上傳:Resolution Center 是回應上一位審查員的主管道,審查資訊附件則跟著新提交走,接手的審查員會先看版本頁。
- ASC 的提交編號與打包服務的提交編號是兩回事,前者一個提交可能歷經多次退件與重送、編號不變,換 build 也不變;後者每上傳一次就產生新的編號。
- 在 ASC 關閉提交是不可逆的操作,會把整串 Resolution Center 歷史一起帶走。
- 備註不要寫死 build 號,會被整段沿用,寫死等於埋一顆定時器;指向已關閉舊提交的連結是死指標。
- 第二版起,審查員第一個要知道的是跟已經通過的那版差在哪,備註裡要主動排除新增權限、新增函式庫、新增資料流這類疑慮。
隱私問卷與資料收集聲明
隱私標籤填寫的幾條判準
- 連結身分與用於追蹤是兩條獨立的軸,不要綁在一起改。
- 過度申報不會被罰,申報不足才會,有疑慮就往是填。
- 第三方 SDK 收集的資料算開發者自己收集,Apple 明文要求開發者為所有第三方函式庫存取隱私敏感資料的行為負責。
- 第三方 SDK 的隱私宣告檔可以直接從套件管理工具下載取得,不需要本機建置環境,這繞過了 Expo managed workflow 的限制。
- 有追蹤用途說明字串,就必須至少一項追蹤標是,ASC 介面會強制檢查。
- 粗略位置的定義是依解析度,經緯度精度低於小數點後三位,跟有沒有位置權限無關;IP 位址也要依用途申報。
- Apple 對收集的定義是資料傳出裝置,步數不出裝置就是未收集,不需要申報健康與健身類別。
流程與驗證方法
幾條驗收紀律
- 能用免費手段查完的,不要送 build,build 是驗程式碼的,不是驗設定的。
- 要驗送審那顆必須解開
.ipa,不能用工作樹,因為工作樹在送審後已經被平行線改過了。- 二進位字串檢查要留正對照組,字串在二進位裡的鄰近性只能證明歸屬,不能證明觸發。
- 成因沒證實時,選一個不論假設成不成立都該修的修法,讓修法對成因假設不敏感。
- 退件信只存在 ASC 後台,Apple 附的截圖放在下載資料夾會被系統清掉。收到當天、動手分析前先做完四步留存:信件全文逐字、附件原檔、時間軸、送出後回填送出紀錄,通過信同樣要存。
- 打了沒送的 build 也要登記,否則事後查不到它存在過,本案有兩顆就是這樣消失的。
- 拿裝了 OTA 更新的裝置驗收,等於從沒驗過內嵌包,審查員拿到的是全新安裝,第一次啟動跑的是內嵌包,OTA 要第二次啟動才套用。
被否決的替代方案
留著這張表是為了記住為什麼沒走那些路:
| 方案 | 否決理由 |
|---|---|
| 不上架 iPad(移除支援目標裝置) | 不阻止 iPad 安裝,仍以相容模式執行,官方文件明列為不可行做法 |
| 用裝置能力宣告擋 iPad | 拿新退件理由換舊的,該宣告只能用於真正的硬體依賴,且上架後只能放寬不能加嚴 |
| 開啟平板全支援模式 | 把已知的窄視窗換成全新未驗證的大版面,四次退件後不該開新戰線,且會動到原生層,讓前後兩顆 build 原生層逐位元組相同這條乾淨的驗收基準一起消失 |
| GDPR 表單加一條保險條件 | 前提是錯的,系統層把受限與拒絕壓成同一個值,使用者關掉系統追蹤詢問時,法規要求的同意表單會一次都不出現,等於用不必要的條件壓掉法規揭露 |
| 交易結清成功才開通權益 | 敗方代價是結清失敗的人付了錢當場拿不到東西;勝方理由是使用者按下購買、StoreKit 回傳成功,權益即刻到手,未結清的交易會在下次進設定頁被補結清,可以自我修復 |
| 靠交易時間戳分辨新購買與已擁有 | 型別沒有單位註解,同一份程式碼裡出現過毫秒與秒兩種前例,真正的序列化在遠端也不可讀,最後改用按下購買前的已擁有商品快照當決定性判準 |
| 申請正式申訴管道或線上諮詢 | 列為可用而未用的武器,判準是留到第三次收到同一封信再動用,全程沒有真的用到 |
誠實的但書
通過不代表任何一條修法被證實有效。通過信裡沒有審查環境的段落,也沒說審查員實際走過哪些流程,只知道這次過了,不知道是哪一項修法起了作用。信末的制式段也不是審查員的觀察,第五次信末提到的協議問題,實查後確認協議本來就生效中,是罐頭文字。
另外兩點誠實標註:iPad 上健康資料 API 回傳不支援這件事,在這個案例裡至今仍是外部資訊,沒有經過第一手驗證;Apple 是否會跨審查回合沿用同一個沙盒帳號,目前也沒有公開說法。
相關連結
- 追追推推 追星族遠征紀錄 App 複盤 —— 這個 App 的整體複盤
- NailNote 本地美甲筆記 App 複盤 —— 同一位開發者三個月前的上架經驗,那次三天就通過,純本地、零資料收集、無 IAP、無廣告,對照之下能看出引入廣告與 IAP 讓合規面積擴大了多少
- AI 協作開發:非技術 PM 的驗收閘門 —— 不自報完成、要有可觀察證據,在這裡的具體形式就是解
.ipa做二進位檢查