2026年版 iOS Screen Time API:Appleのドキュメントに書かれていないこと

アプリブロッカーをリリースして分かった、FamilyControls・DeviceActivity・シールドExtensionの実際。Thomas Willheim · 2026年9月30日

2026年の8月から9月にかけて、iOS向けのアプリブロッカーをリリースしました。Screen Time系のフレームワーク(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」の実体は、4つのフレームワークと、実際には3つのApp Extensionです。

肝心なのはExtensionのほうです。ユーザーの時間が切れる時点では、たいていアプリはサスペンドされているか終了しているので、シールドを元に戻すのはモニターExtensionの役目になります。何かが起きるときにアプリのプロセスは絶対にいない、という前提で設計してください。

2. Entitlement:Bundle IDごとに1回申請

Family Controls (Distribution) EntitlementはBundle IDごとに承認されます。Extensionが3つなら、申請は4回です。僕の場合はこうでした。

承認されたら、DeveloperポータルでそれぞれのApp IDにFamily Controls (Distribution) のCapabilityを有効にし、App Groupを1つ作って4つすべてに紐づけます。1つでも漏れると、そのターゲットはプロビジョニングできません。承認はすぐには下りないので、まだコードをほとんど書いていない初日のうちに、4つとも申請しておきましょう。

どの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の分しかプロビジョニングプロファイルを作りません。僕はapp.jsonのextra.eas.build.experimental.ios.appExtensionsに、各ターゲットをEntitlementと一緒に宣言しています。

"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が唯一の通り道

アプリとExtensionは別々のプロセスで、同時に動いていることはほとんどありません。共有できるのはApp Groupのコンテナだけで、たいていはUserDefaults(suiteName:)として使います。Extensionが必要とするもの(保存した選択内容、シールドの文言、しきい値が発火したときの処理)は、すべて事前にそこへ書き込んでおく必要があります。

react-native-device-activityはこれを徹底しています。アプリはactions_for_{activity}_{callback}_{event}のようなキーに「アクション」のリストを書き込み、モニターExtensionはコールバックが発火した時点でそれを読みにいきます。便利な副産物として、すでに動いているモニターの挙動を、再起動せずに変えられます。また、Extensionは読み込みの前にCFPreferencesAppSynchronizeを呼ぶので、アプリの最新の書き込みが見えます。

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. トークンは中身が見えない、そしてピッカーのこと

ピッカーがアプリに渡すのはApplicationToken、ActivityCategoryToken、WebDomainTokenだけです。設計上、アプリはトークンを「Instagram」に戻すことができません。できるのは数を数えることなので、僕のブロックリスト画面には「3個のアプリ・1カテゴリ」と表示しています。これがプライバシーモデルのすべてで、良い設計だと思います。僕のアプリは、あなたが何をブロックしたのか文字通り知りません。

iOSのスクリーンタイムのアプリ選択画面:アプリのカテゴリが並び、Health & Fitnessを展開して中のアプリを表示している
AppleのFamilyActivityPicker。ユーザーにはアプリ名とアイコンが見えますが、アプリに返ってくるのは中身の見えないトークンだけです。

シールドExtensionは事情が違います。こちらはシステムがシールド対象アプリのApplicationを渡してくれるので、僕はそのlocalizedDisplayNameをシールドの文言に使っています。アプリ本体から見えるのは、あくまでトークンだけです。

実用上の注意が2つあります。

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

ライブラリ もう1つ。ライブラリのblockSelectionは、永続化されたブロックリストにトークンを追加します。ユーザーが選択内容を編集しても、外したアプリにはシールドがかかったままです。僕は毎回、先にresetBlocks()を呼んでから、現在の選択内容をブロックし直しています。

5. しきい値イベントで使用時間を計測する

HonorFitでは、分単位の残高があり、それはブロック中のアプリが画面に表示されているあいだだけ減ります。DeviceActivityには「どれだけ使ったか教えて」というAPIがありません。あるのは、選択したアプリの累計使用時間が指定した長さに達したときに発火するしきい値イベントです。そこで、使用15秒ごとにイベントを1つ登録し、名前は分単位にしています。used-0.25、used-0.5、…と、残高の分まで続きます。0.25刻みの値は2進浮動小数点で正確に表せるので、名前を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秒ごとのイベントでは固定IDの通知を1件だけ更新するので、通知がその場で置き換わり、おおまかなライブカウントダウンとして機能します。interruptionLevel = .passiveにすると、バナーを出さずに通知センターへ静かに届きます。

ライブラリ イベント名やアクティビティ名にアンダースコアを使わないこと。ライブラリは発火したイベントをevents_{activity}_{callback}_{eventName}というキーで保存し、_で分割してパースし直します。used_7というイベントはusedとして戻ってくるので、何もマッチしません。僕のところでは、残高がまったく減らないという症状で現れました。

6. スケジュールは思ったタイミングで始まらない

しきい値イベントが黙って一度も発火しないなら、たぶんこれが原因です。

「今」はもう過去になっている

観測 非繰り返しのDeviceActivityScheduleは日時コンポーネントで照合され、モニターは次に一致するインターバルで動きます。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分より短いスケジュールは拒否されるので、深夜0時の直前にセッションを始めるとここでつまずきます。

「今」を含むスケジュールはすぐに始まる

観測 逆に、00:00–23:59:59のスケジュールは現在時刻を含むので、次の0時ではなく今すぐ始まります。翌日の分を予約する方法として、僕が見つけたのは2つです。

観測 特定の日付を狙うために、スケジュールに年・月・日まで指定するのはやめましょう。僕が試したときはExtensionのコールバックが不安定になりましたし、Apple Developer Forumsでの報告(スレッド729841)とも一致しています。時・分・秒だけにしておくのが無難です。

注意点として、こうした0時まわりの挙動は、実機で夜をまたがないと現れません。対策のガードは入れてありますが、実際の0時をまたぐケースを思ったほど多くは計測できていないので、このセクションは仕様ではなく「僕が見たこと」として読んでください。

7. stopMonitoringでintervalDidEndが呼ばれる

観測 stopMonitoringを呼ぶか、すでに動いているモニターと同じ名前でstartMonitoringを呼ぶと、iOSはスケジュールが自然に終わったかのように、ExtensionのintervalDidEndを呼びます。intervalDidEndで安全策としてシールドをかけ直している場合(僕はそうしています。モニターが期限切れになって、アプリのブロックが外れたままになるのを防ぐためです)、アプリがモニターを止めるたびに、Extensionが一瞬すべてにシールドをかけることになります。

ユーザーから報告された症状は、HonorFitを開くたびにSpotifyが一時停止する、というものでした。モニターを止めたことでExtensionが一瞬だけ選択内容にシールドをかけ、それでSpotifyがサスペンドされ、そのあとアプリがシールドを外していたのです。

対策は2つで、両方とも必要です。まず、止める前にExtensionのアクションを削除して、実行するものがない状態にします。

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

次に、すべてのintervalDidEndとintervalDidStartのアクションに、スケジュールの本来の境界時刻から1分の余裕を引いた「この時刻より前は実行しない」タイムスタンプを持たせます。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のベータで修正されたと言われていますが、その後も報告は続いていました。僕のアプリでは、スマホをポケットに入れていただけなのに「時間切れ」になる、という症状でした。Extensionがシールドをかけ直し、残高がゼロまで減ってしまったためです。

イベントの発火そのものは止められませんが、それを信じないことはできます。本物のしきい値について言える次の2つの事実で、ほとんど対処できます。

  1. 使用時間が実時間を追い越すことはない。used-7が、モニターを開始してから7分未満で正当に発火することはありえません。すべてのしきい値アクションにneverTriggerBefore = armedAt + N minutes − 5sを持たせ、ありえないほど早い発火をExtensionがスキップするようにしています。
  2. 本物の使用は少しずつ届き、ファントムはまとめて届く。発火したイベントをアプリが読み返すとき、最小と最大のしきい値が約3秒以内に発火しているのに、使用時間としては1分以上離れていれば、ファントムとして扱います。
// 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
}

Guard 2はExtensionではなくアプリ側で動くので、ファントムでもシールドはかかってしまいます。ただし残高は守られ、次にアプリを開いたときにシールドが外れます。このまとめて発火の検出をExtensionの中でやるにはカスタムのSwiftが必要で、まだ書けていません。

観測 逆方向の関連問題もあります。発火するはずのしきい値が発火しないケースです。iOS 26でアプリを開いたり閉じたりしながら使っていると、新しいモニターの最初の15秒のしきい値は時間どおりに発火するのに、それ以降はまとめられたり、完全に落ちたりしました(used-0.25は発火し、used-0.5以降は一度も発火しない)。僕の回避策は、すでに使用時間を計測したモニターを古いものとみなし、アプリがバックグラウンドに移るたびに、残りの残高分で新しいモニターを登録し直すことです。登録し直すたびに最大15秒分の使用を見逃すことになりますが、そこは許容しています。

9. シールドからアプリは開けない

すぐに思いつく設計は、シールドに「もっと時間を稼ぐ」ボタンを置いて、アプリを開くことです。でも、サポートされた方法はありません。Apple DTSによれば、シールドExtensionから親アプリを起動するAPIは存在しません。ShieldActionDelegateはボタンが押されるとShieldActionResponse(.none、.close、.defer)を返しますが、どれも何かを開くものではありません。

観測 よく見かける回避策(ShieldAction ExtensionからNSExtensionContext().open(url)を呼ぶもので、ライブラリではopenUrlとして公開されています)は、2026年9月に実機で試したところ、現行のiOSでは何も起きませんでした。ボタンを押しても、シールドが閉じるだけです。ボタンのタップで通知を送る方法も、僕の環境では不安定でした。

代わりに僕がリリースしている方法はこうです。シールドのボタンは「閉じる」1つだけにして、シールドが表示されるたびにShieldConfiguration Extensionからローカル通知を送ります。ユーザーがバナーをタップすると、アプリが開きます。通知のペイロードはアプリが事前にApp Groupへ書き込んでおき、アプリがフォアグラウンドに来たら通知を消します。iOSは1回の表示でシールドの設定を何度か要求することがあるので、通知の送信はデバウンスしています。

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
}

ハックですし、ユーザーが通知を許可していることが前提ですが、僕の環境で確実にアプリへ戻ってこられた方法はこれだけでした。

10. prebuildでカスタムExtensionを消さない

ライブラリ Expoを使っている場合、expo prebuildはreact-native-device-activityのconfig pluginを実行します。このプラグインはデフォルトで、実行のたびにライブラリ標準のExtensionのソースを./targetsにコピーします。僕がカスタマイズしたShieldConfigurationは、気づかないうちに上書きされていました。解決策はプラグインのオプション1つです。

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

その代わり、ライブラリをアップグレードしたときは、ライブラリのtargets/と共有のSwiftコードを自分のものと手作業で突き合わせて、変更を当て直す必要があります。

11. Xcode 26、シミュレータ、テスト

12. Androidの場合

比較のために書いておくと、Androidには公式の同等機能がないので、自分で作ることになります。HonorFitのAndroid版ブロッカーはフォアグラウンドサービス(タイプはspecialUse。アプリのブロックは、用意されているどのタイプにも当てはまらないため)で、画面がオンのあいだ1秒ごとにUsageStatsManagerをポーリングして、前面にあるアプリを調べています。ブロック対象のアプリが表示されていて残高がゼロなら、その上にTYPE_APPLICATION_OVERLAYでロック画面を描画します(「他のアプリの上に重ねて表示」)。端末の再起動やアプリのアップデート、メーカー独自の強力なバッテリー管理でサービスが止まっても、起動時のレシーバーとウォッチドッグ用のアラームで再起動します。

トレードオフはiOSと正反対です。実際のパッケージ名が見えるので、プライバシーはOSが強制してくれるものではなく、自分で守る約束になります。ブロック対象のアプリが最大でポーリング1回分の時間は見えてしまいますし、オーバーレイの下でアプリは動き続けます。それに、フォアグラウンドサービスのタイプ、オーバーレイの権限、パッケージの公開設定について、Play Consoleで申告を書くことになります。審査がいちばん厳しいAccessibilityServiceは、意図的に避けました。

13. チェックリスト

症状考えられる原因対処
特定のターゲットだけプロビジョニングに失敗するそのBundle IDにEntitlementかApp GroupがないすべてのBundle IDでFamily Controls (Distribution)を申請し、App Groupを紐づける
しきい値イベントが一度も来ないintervalStartが現在時刻で、モニターが翌日に予約されている開始時刻をさかのぼらせる。インターバルは15分以上に保つ
翌日用のモニターが今日動くスケジュールが現在時刻を含んでいる今日すでに終わった[00:00, T)のスケジュールか、repeats: trueとガード付きアクション
アプリを開くと一瞬シールドがかかるstopMonitoringでintervalDidEndが呼ばれる止める前にアクションを消す。「この時刻より前は実行しない」でガードする
放置していただけなのに「時間切れ」になるiOS 26のファントムしきい値実時間ガードと、まとめて発火のフィルタ
後半のしきい値が発火しない断続的な使用で落ちる/まとめられるバックグラウンドに移るたびに新しいモニターを登録し直す
シールドのボタンでアプリが開かないサポートされたAPIがない表示時にShieldConfigurationから通知を送る
発火したイベントがマッチしない(RN)イベント名にアンダースコアがあるused_7ではなくused-7を使う
ピッカーが空の状態で開く(RN)includeEntireCategory propがない値は何でもいいので渡す

14. よくある質問

すべてのExtensionにFamily ControlsのEntitlementが必要ですか?

はい。Bundle IDごとに付与されます。アプリ本体に加えてDeviceActivityMonitor、ShieldConfiguration、ShieldActionの各Extensionで4つの承認が必要で、各App IDには共有のApp Groupも必要です。

DeviceActivityのeventDidReachThresholdが発火しないのはなぜですか?

僕の場合、たいていは「今」から始まる非繰り返しスケジュールが原因で、翌日に予約されていました。intervalStartをさかのぼらせてください。iOS 26でアプリを開いたり閉じたりしながら使っていると、後半の1分未満のしきい値も落ちました。新しいモニターを登録し直すと改善しました。

ShieldActionやShieldConfigurationのExtensionから親アプリを開けますか?

サポートされたAPIではできませんし、NSExtensionContextを使う回避策も、僕が試した限り現行のiOSでは何も起きませんでした。僕がリリースしているのは、シールドが表示されたときにShieldConfigurationからローカル通知を送る方法です。

iOSシミュレータでScreen Time APIをテストできますか?

一部だけです。iOS 26のシミュレータでも認可ダイアログとピッカーは表示されますが、認可は完了せず、ピッカーには何も表示されず、モニターExtensionも動きませんでした。実機を使ってください。

15. なぜここまでやったのか

僕はHonorFitというアプリブロッカーを作っています。腕立て伏せをするとスクリーンタイムを稼げるアプリです。自己申告制で、回数は自分で入力します。カメラもAIも使わず、ネットワーク通信もしません。ここまでに書いたことはすべて、「稼いだ時間は、ブロック中のアプリが画面に表示されているあいだだけ減る」をiPhoneで実現するために必要だったことです。実際に動くところを見たい人は、App StoreとGoogle Playにあります。作る側ではなく、そういうアプリを使いたくてここにたどり着いた人向けに、腕立て伏せアプリブロッカーのしくみと、競合も含めた正直なまとめも書いています(どちらも英語です)。ここに載っていないScreen Timeの挙動に出会った人や、新しいiOSで上の内容が違っていると気づいた人は、ぜひ教えてください。

あわせて読みたい