2026 年的 iOS Screen Time API:Apple 文件沒告訴你的事

從一個已上架的 App 封鎖工具來看 FamilyControls、DeviceActivity 和 shield extension。作者:Thomas Willheim · 2026 年 9 月 30 日

2026 年 8 月到 9 月,我都在忙著把一個 App 封鎖工具上架到 iOS。它用的是螢幕使用時間相關的框架(FamilyControls、DeviceActivity、ManagedSettings 和 ManagedSettingsUI),從 React Native 呼叫,透過 Expo SDK 57 和 react-native-device-activity 函式庫(0.6.1)。Apple 的文件會告訴你每個型別是什麼,但對於它們在真實手機上、跨過一整夜、在 iOS 26 上的實際行為,幾乎隻字未提。

這是我當初很希望有人給我的清單。我在裝置上看到、但找不到文件記載的行為,會標上 實測;這些不一定在每個 iOS 版本都成立。只跟 React Native 函式庫有關的項目,會標上 函式庫;如果你直接寫 Swift,可以跳過。

1. 有哪些元件

所謂的「Screen Time API」,其實是四個框架,實務上再加三個 app extension:

重點就在這些 extension。使用者時間用完的時候,我的 App 通常已經被暫停或被系統關掉了,所以是監控 extension 負責把封鎖畫面重新套上去。設計的時候,就當作事情發生時你的 App 程序永遠不在場。

2. Entitlement:每個 bundle ID 都要申請一次

Family Controls (Distribution) entitlement 是依 bundle ID 核准的。有三個 extension,就是四份申請。我的是:

核准之後,要在開發者網站上替每一個 App ID 開啟 Family Controls (Distribution) capability,然後建立一個 App Group,把四個都加進去。漏掉一個,那個 target 就沒辦法 provision。核准不是馬上就會下來,所以第一天、還沒寫多少程式之前,就把四份申請全部送出去。

最後每個 extension 的 entitlements 檔案都長得一樣:

<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
  <string>group.com.honorfit.blocker</string>
</array>

在 Expo/EAS 上,建置服務只會替它知道的 extension 建立 provisioning profile。我在 app.json 的 extra.eas.build.experimental.ios.appExtensions 底下宣告每個 target 和它的 entitlements:

"appExtensions": [
  {
    "bundleIdentifier": "com.honorfit.blocker.ShieldConfiguration",
    "targetName": "ShieldConfiguration",
    "entitlements": {
      "com.apple.developer.family-controls": true,
      "com.apple.security.application-groups": ["group.com.honorfit.blocker"]
    }
  }
  // ...same for ShieldAction and ActivityMonitorExtension
]

3. App Group 是唯一的溝通管道

你的 App 和它的 extension 是各自獨立的程序,幾乎從來不會同時在執行。它們唯一共用的東西是 App Group 的容器,通常是透過 UserDefaults(suiteName:)。extension 需要的所有東西(儲存的選擇、封鎖畫面上的文字、門檻觸發時要做什麼)都必須事先寫進去。

react-native-device-activity 把這個做法發揮到極致。App 會在像 actions_for_{activity}_{callback}_{event} 這樣的 key 底下寫入一串「動作」(actions),監控 extension 則是在回呼觸發的當下才去查。這帶來一個好處:你可以在不重新啟動的情況下,改變一個已經在跑的監控要做的事。extension 在讀取之前也會先呼叫 CFPreferencesAppSynchronize,所以能看到 App 最新寫入的內容:

let appGroup = Bundle.main.object(
  forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)

// in each callback:
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)
if let actions = userDefaults?.array(forKey: triggeredBy) { /* run them */ }

4. Token 是不透明的,還有選擇器

選擇器只會交給你的 App ApplicationToken、ActivityCategoryToken 和 WebDomainToken,其他什麼都沒有。這是刻意的設計,App 沒辦法把 token 轉回「Instagram」。它能做的是數有幾個,所以我的封鎖清單畫面會顯示「3 個 App、1 個類別」。這就是整個隱私模型,而且是個好模型:我的 App 真的不知道你封鎖了什麼。

iOS 的螢幕使用時間 App 選擇器:一串 App 類別,其中「健康與健身」展開顯示裡面的 App
Apple 的 FamilyActivityPicker。使用者看得到 App 名稱和圖示,你的 App 只會拿回不透明的 token。

shield extension 就不一樣了。在那裡,系統會把正被封鎖的 App 以 Application 傳給你,我會把它的 localizedDisplayName 用在封鎖畫面的文字裡。App 本身永遠只看得到 token。

兩個實務上的提醒:

<DeviceActivitySelectionSheetViewPersisted
  familyActivitySelectionId="distracting-apps"
  includeEntireCategory={false}   // must be present, or nothing preloads
  onSelectionChange={(e) => setCounts(e.nativeEvent)}
  onDismissRequest={() => setPickerVisible(false)}
/>

函式庫 還有一個:函式庫的 blockSelection 是把 token 加進一個持久保存的封鎖清單。使用者編輯選擇之後,被移除的 App 還是會被封鎖。所以我每次都先呼叫 resetBlocks(),再重新封鎖目前的選擇。

5. 用門檻事件計算使用時間

HonorFit 給你一個分鐘數的餘額,只有在被封鎖的 App 顯示在畫面上時才會扣。DeviceActivity 沒有「告訴我他們用了多久」這種呼叫;它有的是門檻事件,在某個選擇的累積使用時間達到一段長度時觸發一次。所以我每 15 秒的使用時間就註冊一個事件,用分鐘來命名:used-0.25、used-0.5……一路到餘額為止。四分之一在二進位浮點數裡是精確的,所以這些名稱經過 Number() 來回轉換也不會跑掉。函式庫底下用的就是 Apple 原本的 API:

let schedule = DeviceActivitySchedule(
  intervalStart: DateComponents(hour: 9, minute: 45, second: 0),  // see section 6
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
  repeats: false
)
let event = DeviceActivityEvent(
  applications: selection.applicationTokens,
  categories: selection.categoryTokens,
  webDomains: selection.webDomainTokens,
  threshold: DateComponents(minute: 0, second: 15),
  includesPastActivity: false          // iOS 17.4+
)
try DeviceActivityCenter().startMonitoring(
  DeviceActivityName("honorfit-distracting-apps-1790000000000"),
  during: schedule,
  events: [DeviceActivityEvent.Name("used-0.25"): event]  // ...one per step
)

includesPastActivity: false(預設值)代表事件註冊之前的使用時間不算數。這點對下一節的排程技巧很重要。

最後一個事件觸發時,監控 extension 會重新套上封鎖畫面,並發出一則「時間到了」的通知。每個 15 秒的事件都會更新同一則有固定識別碼的通知,所以它會原地替換,當作一個粗略的即時倒數。interruptionLevel = .passive 會讓它安靜地送進通知中心,不會跳出橫幅。

函式庫 事件名稱和 activity 名稱裡不要用底線。函式庫會把觸發過的事件存在 events_{activity}_{callback}_{eventName} 底下,讀回來時再用 _ 切開。一個叫 used_7 的事件,讀回來會變成 used,然後永遠對不上。症狀是餘額一直都不會減少。

6. 排程不會在你以為的時間開始

如果你的門檻事件默默地從來不觸發,八成就是這個原因。

「現在」已經是過去了

實測 一個不重複的 DeviceActivitySchedule 是用日期元件(date components)來比對的,監控會執行下一個符合的區間。如果你把目前的時鐘時間當成 intervalStart 傳進去,等框架去評估它的時候,那一刻已經過了。下一個符合的時間是明天,今天什麼都不會觸發:沒有門檻、沒有回呼、也沒有錯誤。解法是把開始時間往前推,讓「現在」落在區間裡面:

const now = new Date();
const backdated = new Date(now.getTime() - 15 * 60_000);
const intervalStart =
  backdated.getDate() === now.getDate()
    ? { hour: backdated.getHours(), minute: backdated.getMinutes(), second: backdated.getSeconds() }
    : { hour: 0, minute: 0, second: 0 };   // don't wrap past midnight

await startMonitoring(activityName,
  { intervalStart, intervalEnd: { hour: 23, minute: 59, second: 59 }, repeats: false },
  events);

因為有 includesPastActivity: false,往前推開始時間並不會把使用者先前滑手機的時間算進去。它也讓區間維持在 Apple 的最低限制以上:短於 15 分鐘的排程會被拒絕,剛好在午夜前開始一段時段的人就會中招。

包含「現在」的排程會立刻開始

實測 反過來說:一個 00:00–23:59:59 的排程包含了目前時間,所以它會立刻開始,而不是等到下一個午夜。我找到兩種把排程訂到明天的方法:

實測 不要在排程裡用完整的年/月/日元件來指定某個日期。我試的時候,extension 的回呼變得很不可靠,這也跟 Apple Developer Forums 上的回報一致(thread 729841)。只用時/分/秒就好。

要提醒一點:這些跟午夜有關的行為,只會在裝置上、過一整夜才看得到。我已經針對它們加了防護,但實際記錄過的午夜沒有我希望的那麼多,所以請把這一節當成「我看到的現象」,而不是規格。

7. stopMonitoring 會觸發 intervalDidEnd

實測 呼叫 stopMonitoring,或者用一個已經在跑的監控名稱呼叫 startMonitoring,都會讓 iOS 在你的 extension 上呼叫 intervalDidEnd,就好像排程自然結束了一樣。如果你的 intervalDidEnd 會重新封鎖當作安全網(我的就是,這樣監控才不會過期後讓 App 處於沒被封鎖的狀態),那每次 App 停掉一個監控,extension 都會短暫地把所有東西封鎖起來。

有位使用者回報了這個症狀:每次打開 HonorFit,Spotify 就會暫停。停止監控讓 extension 把選擇封鎖了一下,Spotify 因此被暫停,然後 App 才又把封鎖畫面解除。

兩個解法,兩個都要做。第一,在停止之前先刪掉 extension 的動作,讓它沒東西可以執行:

function stopActivities(names: string[]): void {
  for (const name of names) RNDA.cleanUpAfterActivity(name); // clear actions first
  RNDA.stopMonitoring(names);                                // then stop
}

第二,替每個 intervalDidEnd 和 intervalDidStart 動作加上一個「不早於」的時間戳記,設在排程真正的邊界,再減一分鐘的緩衝。extension 在執行任何東西之前都會先檢查它:

// TypeScript: register the safety net
RNDA.configureActions({
  activityName,
  callbackName: 'intervalDidEnd',
  actions: [{
    type: 'blockSelection',
    familyActivitySelectionId: 'distracting-apps',
    neverTriggerBefore: new Date(intervalEndMs - 60_000),
  }],
});

// Swift, in the DeviceActivityMonitor extension
if let neverTriggerBefore = neverTriggerBefore {
  let now = Date().timeIntervalSince1970 * 1000
  if now < neverTriggerBefore { return false }   // skip this action
}

如果是純 Swift:把真正的結束時間存在 App Group 裡,讓 intervalDidEnd 在太早被呼叫時直接返回。

8. iOS 26 的幽靈門檻

在 iOS 26.x 上,eventDidReachThreshold 有時會在完全沒有實際使用的情況下觸發:剛排好程的時候,或者一支鎖了一陣子的手機被拿起來、插上電的時候。Apple 已經在開發者論壇的討論串裡承認這個問題(809410、811305、814559)。據說在 26.5 beta 修好了,但之後還是一直有人回報。在我的 App 裡,症狀是手機放在口袋一陣子後就顯示「時間用完了」,因為 extension 重新套上封鎖畫面,餘額也被扣到零。

你沒辦法阻止事件觸發,但你可以拒絕相信它。真正的門檻有兩個特性,能處理掉大部分的情況:

  1. 使用時間不可能跑得比時鐘還快。used-7 不可能在監控啟動後不到 7 分鐘就正當地觸發。每個門檻動作都帶著 neverTriggerBefore = armedAt + N minutes − 5s,所以 extension 會跳過那些不可能這麼早的觸發。
  2. 真正的使用是一點一點累積的;幽靈事件是一次爆出來的。App 讀回已觸發的事件時,如果最低和最高的門檻在大約 3 秒內一起觸發,但兩者之間卻差了一分鐘以上的使用時間,就把它當成幽靈事件。
// Guard 1: drop thresholds that fired before they could have been reached
if (at < startedAt + minutes * 60_000 - 5_000) continue;

// Guard 2: lowest and highest fired in the same instant, a minute or more apart
if (highest.minutes - lowest.minutes >= 1 &&
    Math.abs(highest.at - lowest.at) <= 3_000) {
  return { usedMinutes: 0, partialMs: 0 };   // phantom: ignore the lot
}

第二道防護是在 App 裡跑,不是在 extension 裡,所以幽靈事件還是可能把封鎖畫面套上去。不過餘額會保住,下次打開 App 時,它會再把封鎖畫面解除。要在 extension 裡做爆量偵測,得寫自訂的 Swift,這我還沒寫。

實測 還有一個相關的問題剛好相反:該觸發的門檻沒有觸發。在 iOS 26 上進進出出地使用時,我看過一個新監控的第一個 15 秒門檻準時觸發,但後面的被合併或整個被丟掉(used-0.25 觸發了,used-0.5 之後的都沒有)。我的變通方法是:只要一個監控已經計算過使用時間,就把它當成過期的,每次 App 進入背景時,都用剩下的餘額重新啟動一個新的監控。這樣每次重啟最多會放過 15 秒的使用時間,我可以接受。

9. 封鎖畫面打不開你的 App

最直覺的設計,是在封鎖畫面放一個寫著「賺更多時間」的按鈕,點了就打開你的 App。但沒有官方支援的方法做得到;Apple DTS 說,沒有任何 API 能讓 shield extension 啟動它的主 App。ShieldActionDelegate 用 ShieldActionResponse(.none、.close 或 .defer)來回應按鈕,而這些沒有一個會打開任何東西。

實測 你會找到的變通方法(從 ShieldAction extension 呼叫 NSExtensionContext().open(url),函式庫把它包成 openUrl),我在 2026 年 9 月用實機測試時,在目前的 iOS 上完全沒反應。按鈕只會把封鎖畫面關掉。從按鈕點擊發出通知,對我來說也不可靠。

我實際上架的做法是:封鎖畫面上只有一個「關閉」按鈕,而 ShieldConfiguration extension 會在封鎖畫面顯示時發出一則本機通知。使用者點一下橫幅,就會進到 App。App 會事先把通知內容寫進 App Group,並在回到前景時把通知清掉。iOS 每次顯示封鎖畫面時可能會要好幾次設定,所以發通知的動作有做 debounce:

func postShieldShownNotification(placeholders: [String: String?]) {
  guard let config = userDefaults?.dictionary(forKey: "honorfitShieldShownNotification"),
        let payload = config["payload"] as? [String: Any] else { return }

  let minInterval = config["minIntervalSeconds"] as? Double ?? 30
  let now = Date().timeIntervalSince1970
  let lastSentAt = userDefaults?.double(forKey: "honorfitShieldShownNotificationLastSentAt") ?? 0
  if now - lastSentAt < minInterval { return }
  userDefaults?.set(now, forKey: "honorfitShieldShownNotificationLastSentAt")

  // builds a UNMutableNotificationContent and adds a UNNotificationRequest
  // with a fixed identifier, so repeats replace rather than stack
  sendNotification(contents: payload, placeholders: placeholders)
}

class ShieldConfigurationExtension: ShieldConfigurationDataSource {
  override func configuration(shielding application: Application) -> ShieldConfiguration {
    postShieldShownNotification(placeholders: [
      "applicationOrDomainDisplayName": application.localizedDisplayName
    ])
    return buildShield(/* title, subtitle, colors from the App Group */)
  }
  // ...same hook in the category and web-domain overloads
}

這是個 hack,而且要使用者允許通知才行,但這是我唯一試過能穩定進到 App 的路。

10. 讓自訂的 extension 撐過 prebuild

函式庫 如果你用 Expo,expo prebuild 會執行 react-native-device-activity 的 config plugin,而它預設每次都會把函式庫原本的 extension 原始碼複製到 ./targets。我客製過的 ShieldConfiguration 就這樣被默默蓋掉了。解法是一個 plugin 選項:

["react-native-device-activity", {
  "appleTeamId": "XXXXXXXXXX",
  "appGroup": "group.com.honorfit.blocker",
  "copyToTargetFolder": false
}]

代價是:升級函式庫的時候,你得手動把它的 targets/ 和共用的 Swift 跟你的版本做 diff,再把你的修改重新套上去。

11. Xcode 26、模擬器和測試

12. 那 Android 呢

對照一下,Android 沒有官方認可的對應功能,所以你得自己做。HonorFit 的 Android 封鎖功能是一個前景服務(類型是 specialUse,因為 App 封鎖不屬於清單上任何一種類型),在螢幕亮著的時候每秒輪詢一次 UsageStatsManager,來得知目前在前面的是哪個 App。當被封鎖的 App 在畫面上、而餘額是零的時候,它會在上面畫一個 TYPE_APPLICATION_OVERLAY 鎖定畫面(「顯示在其他應用程式上層」)。一個開機 receiver 和一個 watchdog 鬧鐘,會在重新開機、App 更新,以及遇到手機廠商很兇的省電管理之後,把服務重新啟動。

取捨的方向跟 iOS 剛好相反。你看得到真正的套件名稱,所以隱私是你自己遵守的承諾,而不是作業系統強制的。被封鎖的 App 最多可能會顯示一個輪詢間隔那麼久,而且它在覆蓋畫面底下還是會繼續執行。另外,你還得在 Play Console 為前景服務類型、覆蓋權限和套件可見性寫聲明。我刻意避開了 AccessibilityService,它的審查是所有項目裡最嚴格的。

13. 檢查清單

症狀可能的原因解法
某個 target 的 provisioning 失敗那個 bundle ID 少了 entitlement 或 App Group每個 bundle ID 都要申請 Family Controls (Distribution),並加入 App Group
門檻事件從來沒觸發過intervalStart = 現在,所以監控被排到明天把開始時間往前推;區間維持 ≥ 15 分鐘
本來要明天跑的監控今天就跑了排程包含目前時間用今天已過期的 [00:00, T) 排程,或 repeats: true 加上有防護的動作
打開你的 App 時,其他 App 被閃一下封鎖stopMonitoring 觸發了 intervalDidEnd停止前先清掉動作;用「不早於」時間做防護
手機放著沒用,卻顯示「時間用完了」iOS 26 幽靈門檻時鐘時間防護加上爆量過濾
後面的門檻從來沒觸發斷斷續續使用時被丟掉/合併每次進入背景都重新啟動一個新的監控
封鎖畫面上的按鈕打不開 App沒有官方支援的 API封鎖畫面顯示時,由 ShieldConfiguration 發出通知
觸發的事件對不上(RN)事件名稱裡有底線用 used-7,不要用 used_7
選擇器打開是空的(RN)少了 includeEntireCategory prop傳入它,任何值都可以

14. 快速問答

每個 extension 都需要 Family Controls entitlement 嗎?

是的。它是依 bundle ID 核發的。App 加上 DeviceActivityMonitor、ShieldConfiguration 和 ShieldAction 三個 extension,就是四次核准,而且每個 App ID 也都要加入共用的 App Group。

為什麼 DeviceActivity 的 eventDidReachThreshold 不會觸發?

我遇到的通常是一個從「現在」開始的不重複排程,結果被排到明天。把 intervalStart 往前推。在 iOS 26 上進進出出地使用時,後面那些不到一分鐘的門檻也會被丟掉;重新啟動一個新的監控有幫助。

ShieldAction 或 ShieldConfiguration extension 能打開主 App 嗎?

沒有官方支援的 API 做得到,而在我的測試中,NSExtensionContext 這個變通方法在目前的 iOS 上完全沒反應。我實際上架的做法,是在封鎖畫面顯示時從 ShieldConfiguration 發出一則本機通知。

可以在 iOS 模擬器上測試 Screen Time API 嗎?

可以測一部分。iOS 26 模擬器上會出現提示和選擇器,但授權沒辦法完成,選擇器沒有東西可以列,監控 extension 也從來沒執行過。請用實體裝置。

15. 我為什麼需要搞懂這些

我做了 HonorFit,一個靠做伏地挺身賺螢幕使用時間的 App 封鎖工具。它採用誠實制:你自己輸入做了幾下,沒有鏡頭、沒有 AI,也沒有任何網路連線。上面寫的這些,就是為了讓「你賺到的分鐘數只有在被封鎖的 App 顯示在畫面上時才會扣」在 iPhone 上成立所付出的代價。如果你想看看它實際運作的樣子,App Store 和 Google Play 上都有。如果你來這裡是想找一個這樣的 App,而不是想自己做一個,我寫了伏地挺身 App 封鎖工具是怎麼運作的(英文),還有一篇誠實的市面上 App 整理(英文),連競爭對手都包含在內。如果你遇到這裡沒寫到的螢幕使用時間行為,或發現上面哪些內容在較新的 iOS 上已經不對了,我很想聽聽看。

延伸閱讀