Screen Time API ของ iOS ในปี 2026: สิ่งที่เอกสารของ Apple ไม่ได้บอกคุณ

FamilyControls, DeviceActivity และ shield extension จากการปล่อยแอปบล็อกแอปจริง โดย Thomas Willheim · 30 กันยายน 2026

ช่วงเดือนสิงหาคมกับกันยายน 2026 ผมใช้เวลาไปกับการปล่อยแอปบล็อกแอปบน iOS แอปนี้ใช้เฟรมเวิร์กของ Screen Time (FamilyControls, DeviceActivity, ManagedSettings และ ManagedSettingsUI) จาก React Native ผ่าน Expo SDK 57 และไลบรารี react-native-device-activity (0.6.1) เอกสารของ Apple บอกว่าแต่ละ type คืออะไร แต่แทบไม่บอกเลยว่ามันทำงานยังไงบนมือถือจริง ข้ามคืน บน iOS 26

นี่คือลิสต์ที่ผมอยากมีตั้งแต่แรก พฤติกรรมที่ผมเห็นบนเครื่องจริงแต่หาเอกสารยืนยันไม่ได้จะติดป้าย สังเกตเอง ซึ่งอาจไม่เป็นแบบนี้ทุกเวอร์ชันของ iOS ส่วนเรื่องที่เฉพาะกับไลบรารี React Native จะติดป้าย ไลบรารี ถ้าคุณเขียน Swift ตรงๆ ข้ามไปได้เลย

1. ชิ้นส่วนที่เกี่ยวข้อง

“Screen Time API” ที่จริงคือเฟรมเวิร์กสี่ตัว และในทางปฏิบัติก็คือ app extension อีกสามตัว:

หัวใจของเรื่องอยู่ที่ extension ตอนที่ผู้ใช้เวลาหมด แอปของผมมักถูก suspend หรือถูกปิดไปแล้ว monitor extension จึงเป็นตัวที่ขึ้น shield กลับมา ออกแบบไว้เลยว่า process ของแอปคุณจะไม่อยู่ตรงนั้นตอนที่อะไรๆ เกิดขึ้น

2. Entitlement: ขอหนึ่งครั้งต่อหนึ่ง bundle ID

Entitlement ของ Family Controls (Distribution) ได้รับอนุมัติแยกตาม bundle ID มี extension สามตัวก็เท่ากับต้องขอสี่ครั้ง ของผมคือ:

พออนุมัติแล้ว ให้เปิด capability Family Controls (Distribution) ใน App ID ทุกตัว ใน developer portal จากนั้นสร้าง App Group หนึ่งอันแล้วผูกกับทั้งสี่ตัว พลาดไปตัวเดียว target นั้นก็จะ provision ไม่ผ่าน การอนุมัติไม่ได้เกิดขึ้นทันที เพราะฉะนั้นยื่นขอทั้งสี่อันตั้งแต่วันแรก ก่อนจะเขียนโค้ดไปเยอะ

สุดท้ายไฟล์ entitlements ของทุก extension จะหน้าตาเหมือนกันหมด:

<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 บริการ build จะสร้าง provisioning profile ให้เฉพาะ extension ที่มันรู้จัก ผมเลยประกาศแต่ละ target พร้อม entitlements ไว้ใต้ extra.eas.build.experimental.ios.appExtensions ใน app.json:

"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 ของมันเป็นคนละ process และแทบไม่เคยรันพร้อมกันเลย สิ่งเดียวที่ใช้ร่วมกันได้คือ container ของ App Group ซึ่งปกติก็ใช้ผ่าน UserDefaults(suiteName:) ทุกอย่างที่ extension ต้องใช้ (selection ที่บันทึกไว้ ข้อความบน shield สิ่งที่ต้องทำเมื่อ threshold ทำงาน) ต้องเขียนเก็บไว้ตรงนั้นล่วงหน้า

react-native-device-activity ใช้แนวคิดนี้แบบสุดทาง แอปจะเขียนรายการ “action” ไว้ใต้ key อย่าง actions_for_{activity}_{callback}_{event} แล้ว monitor extension จะไปหามันตอนที่ callback ทำงาน ผลพลอยได้ที่มีประโยชน์คือ คุณเปลี่ยนสิ่งที่ monitor ซึ่งรันอยู่แล้วจะทำได้ โดยไม่ต้องรีสตาร์ทมัน นอกจากนี้ 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. Token เป็นแบบทึบ และเรื่องของตัวเลือกแอป

ตัวเลือกแอปส่ง ApplicationToken, ActivityCategoryToken และ WebDomainToken ให้แอปของคุณ และไม่มีอะไรอื่นนอกจากนั้น โดยการออกแบบ แอปแปลง token กลับเป็น “Instagram” ไม่ได้ ที่ทำได้คือนับจำนวน หน้ารายการบล็อกของผมเลยแสดงว่า “3 แอป, 1 หมวดหมู่” นี่แหละคือโมเดลความเป็นส่วนตัวทั้งหมด และมันดีด้วย แอปของผมไม่รู้จริงๆ ว่าคุณบล็อกอะไร

ตัวเลือกแอปของเวลาหน้าจอบน iOS: รายการหมวดหมู่แอป โดยเปิดหมวดสุขภาพและฟิตเนสให้เห็นแอปข้างใน
FamilyActivityPicker ของ Apple ผู้ใช้เห็นชื่อและไอคอนของแอป แต่แอปของคุณได้กลับมาแค่ token ทึบๆ

Shield extension ไม่เหมือนกัน ตรงนั้นระบบจะส่ง Application ของแอปที่กำลังถูกบล็อกมาให้ และผมใช้ localizedDisplayName ของมันในข้อความบน shield ส่วนตัวแอปเองเห็นแค่ token เท่านั้น

ข้อสังเกตเชิงปฏิบัติสองข้อ:

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

ไลบรารี อีกเรื่อง: blockSelection ของไลบรารีจะเพิ่ม token เข้าไปในรายการบล็อกที่บันทึกไว้ หลังผู้ใช้แก้ selection แอปที่เอาออกไปแล้วก็ยังโดน shield อยู่ ผมเลยเรียก resetBlocks() ก่อน แล้วค่อยบล็อก selection ปัจจุบันใหม่ทุกครั้ง

5. วัดการใช้งานด้วย threshold event

HonorFit ให้ยอดเวลาเป็นนาทีที่จะลดลงเฉพาะตอนที่แอปที่ถูกบล็อกอยู่บนหน้าจอ DeviceActivity ไม่มีคำสั่งแบบ “บอกหน่อยว่าเขาใช้ไปนานแค่ไหนแล้ว” มีแต่ threshold event ที่ทำงานเมื่อการใช้งานสะสมของ selection ถึงระยะเวลาที่กำหนด ผมเลยลงทะเบียน event หนึ่งอันต่อการใช้งานทุก 15 วินาที ตั้งชื่อเป็นหน่วยนาที: used-0.25, used-0.5, … ไปจนถึงยอดเวลาที่มี ค่าเศษหนึ่งส่วนสี่แทนได้แบบเป๊ะๆ ในเลขทศนิยมฐานสอง ชื่อเลยแปลงไปกลับผ่าน Number() ได้ไม่เพี้ยน ใต้ไลบรารีลงไป มันก็คือ API ปกติของ Apple:

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 (ค่าเริ่มต้น) หมายความว่าการใช้งานก่อนลงทะเบียน event จะไม่ถูกนับ เรื่องนี้สำคัญกับเทคนิคเรื่อง schedule ในหัวข้อถัดไป

เมื่อ event สุดท้ายทำงาน monitor extension จะขึ้น shield ใหม่และโพสต์การแจ้งเตือน “หมดเวลาแล้ว” ทุก event ที่ 15 วินาทีจะอัปเดตการแจ้งเตือนอันเดียวที่ใช้ identifier ตายตัว มันเลยแทนที่ของเดิมในตำแหน่งเดิม และทำหน้าที่เป็นตัวนับถอยหลังแบบคร่าวๆ ได้ ส่วน interruptionLevel = .passive จะส่งมันเข้าศูนย์การแจ้งเตือนแบบเงียบๆ ไม่มีแบนเนอร์

ไลบรารี อย่าใส่ขีดล่าง (underscore) ในชื่อ event หรือ activity ไลบรารีบันทึก event ที่ทำงานแล้วไว้ใต้ events_{activity}_{callback}_{eventName} แล้ว parse กลับด้วยการ split ที่ _ event ที่ชื่อ used_7 จะกลับมาเป็น used และไม่มีอะไร match เลย อาการที่เห็นคือยอดเวลาไม่ลดลงสักที

6. Schedule ไม่ได้เริ่มตอนที่คุณคิด

ถ้า threshold event ของคุณเงียบหายไม่เคยทำงาน น่าจะเป็นเพราะเรื่องนี้

“ตอนนี้” กลายเป็นอดีตไปแล้ว

สังเกตเอง DeviceActivitySchedule แบบไม่ทำซ้ำจะถูกจับคู่กับ date component แล้ว monitor จะรันช่วงถัดไปที่ตรงกัน ถ้าคุณส่งเวลาปัจจุบันเป็น intervalStart พอเฟรมเวิร์กประมวลผล เวลานั้นก็ผ่านไปแล้ว ช่วงที่ตรงกันถัดไปคือพรุ่งนี้ และวันนี้ไม่มีอะไรทำงานเลย: ไม่มี threshold ไม่มี callback ไม่มี error วิธีแก้คือถอยเวลาเริ่มย้อนหลัง ให้ “ตอนนี้” อยู่ในช่วงนั้น:

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 ด้วย: schedule ที่สั้นกว่า 15 นาทีจะถูกปฏิเสธ ซึ่งจะเล่นงานใครก็ตามที่เริ่ม session ก่อนเที่ยงคืนนิดเดียว

Schedule ที่ครอบ “ตอนนี้” จะเริ่มทันที

สังเกตเอง อีกด้านหนึ่ง: schedule 00:00–23:59:59 ครอบเวลาปัจจุบันอยู่ มันเลยเริ่มตอนนี้ ไม่ใช่เที่ยงคืนถัดไป ผมเจอสองวิธีที่จะจองไว้สำหรับพรุ่งนี้:

สังเกตเอง อย่าใช้ date component แบบเต็ม ปี/เดือน/วัน ใน schedule เพื่อเจาะจงวันที่ ตอนที่ผมลอง callback ของ extension เริ่มเชื่อถือไม่ได้ ซึ่งตรงกับที่มีคนรายงานใน Apple Developer Forums (เธรด 729841) ใช้แค่ ชั่วโมง/นาที/วินาที พอ

ข้อควรระวัง: พฤติกรรมช่วงเที่ยงคืนพวกนี้จะเห็นก็ต่อเมื่อทดสอบบนเครื่องจริงข้ามคืนเท่านั้น ผมทำตัวกันไว้แล้ว แต่ยังเก็บข้อมูลจากเที่ยงคืนจริงๆ ได้ไม่มากเท่าที่อยาก เพราะฉะนั้นให้มองหัวข้อนี้เป็น “สิ่งที่ผมเห็น” ไม่ใช่สเปก

7. stopMonitoring ทำให้ intervalDidEnd ทำงาน

สังเกตเอง การเรียก stopMonitoring หรือเรียก startMonitoring ด้วยชื่อของ monitor ที่รันอยู่แล้ว จะทำให้ iOS เรียก intervalDidEnd ใน extension ของคุณ เหมือนกับว่า schedule จบลงตามปกติ ถ้า intervalDidEnd ของคุณขึ้น shield ใหม่เป็นตาข่ายกันพลาด (ของผมทำแบบนั้น เพื่อไม่ให้ monitor หมดอายุแล้วปล่อยให้แอปไม่ถูกบล็อก) ทุกครั้งที่แอปหยุด monitor ตัว extension จะขึ้น shield ทับทุกอย่างอยู่แป๊บนึง

มีผู้ใช้รายงานอาการนี้มา: Spotify หยุดเล่นทุกครั้งที่เขาเปิด HonorFit การหยุด monitor ทำให้ extension ขึ้น shield ทับ selection อยู่ครู่หนึ่ง ซึ่งทำให้ Spotify ถูก suspend ก่อนที่แอปจะเอา shield ออกอีกที

วิธีแก้มีสองข้อ และต้องทำทั้งคู่ ข้อแรก ลบ action ของ extension ก่อนหยุด monitor จะได้ไม่มีอะไรให้มันรัน:

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

ข้อสอง ใส่ timestamp แบบ “ห้ามทำงานก่อนเวลานี้” ให้กับทุก action ของ intervalDidEnd และ intervalDidStart โดยตั้งไว้ที่ขอบจริงของ schedule ลบเผื่อไปหนึ่งนาที แล้ว 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. Threshold ผีบน iOS 26

บน iOS 26.x บางครั้ง eventDidReachThreshold ทำงานทั้งที่ไม่มีการใช้งานจริงเลย: ทันทีหลังตั้ง schedule หรือตอนหยิบมือถือที่ล็อกทิ้งไว้สักพักขึ้นมา หรือตอนเสียบชาร์จ Apple ยอมรับเรื่องนี้แล้วในเธรดบน developer forum (809410, 811305, 814559) มีรายงานว่าแก้แล้วใน 26.5 beta แต่หลังจากนั้นก็ยังมีคนรายงานเข้ามาเรื่อยๆ ในแอปของผม อาการคือ “หมดเวลา” หลังจากมือถืออยู่ในกระเป๋ากางเกงมาพักหนึ่ง เพราะ extension ขึ้น shield ใหม่และยอดเวลาถูกหักจนเหลือศูนย์

คุณห้าม event ไม่ให้ทำงานไม่ได้ แต่คุณเลือกที่จะไม่เชื่อมันได้ ข้อเท็จจริงสองข้อเกี่ยวกับ threshold ของจริงช่วยได้เกือบทั้งหมด:

  1. การใช้งานวิ่งแซงนาฬิกาไม่ได้ used-7 ไม่มีทางทำงานจริงได้ภายในเวลาไม่ถึง 7 นาทีหลังตั้ง monitor ทุก action ของ threshold เลยมี neverTriggerBefore = armedAt + N minutes − 5s ติดไปด้วย extension จะข้าม event ที่ทำงานเร็วเกินกว่าจะเป็นไปได้
  2. การใช้งานจริงค่อยๆ ทยอยมา ส่วน event ผีมาเป็นชุดพร้อมกัน ตอนที่แอปอ่าน event ที่ทำงานแล้วกลับมา ถ้า threshold ต่ำสุดกับสูงสุดทำงานห่างกันไม่เกินประมาณ 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
}

Guard 2 รันในแอป ไม่ได้รันใน extension ผีเลยยังขึ้น shield ได้อยู่ แต่ยอดเวลาไม่หาย และครั้งต่อไปที่เปิดแอป แอปจะเอา shield ออกให้ ถ้าจะตรวจจับแบบเป็นชุดใน extension ต้องเขียน Swift เพิ่มเอง ซึ่งผมยังไม่ได้เขียน

สังเกตเอง มีปัญหาที่เกี่ยวข้องในทางกลับกันด้วย: threshold ที่ควรทำงานแต่ไม่ทำ เวลาใช้งานแบบเข้าๆ ออกๆ บน iOS 26 ผมเห็น threshold 15 วินาทีแรกของ monitor ใหม่ทำงานตรงเวลา แต่ตัวถัดๆ ไปถูกรวบหรือหายไปเลย (used-0.25 ทำงาน แต่ used-0.5 ขึ้นไปไม่เคยทำงาน) วิธีอ้อมของผมคือถือว่า monitor ไหนที่วัดการใช้งานไปแล้วเป็นของเก่า แล้วตั้ง monitor ใหม่ด้วยยอดเวลาที่เหลือ ทุกครั้งที่แอปไปอยู่เบื้องหลัง วิธีนี้ยกเวลาให้ไม่เกิน 15 วินาทีต่อการเริ่มใหม่หนึ่งครั้ง ซึ่งผมรับได้

9. Shield เปิดแอปของคุณไม่ได้

ดีไซน์ที่ใครๆ ก็นึกถึงคือปุ่มบน shield ที่เขียนว่า “หาเวลาเพิ่ม” แล้วเปิดแอปของคุณ แต่ไม่มีวิธีที่รองรับอย่างเป็นทางการ Apple DTS บอกว่าไม่มี API ให้ shield extension เปิดแอปหลักของมัน ShieldActionDelegate ตอบการกดปุ่มด้วย ShieldActionResponse (.none, .close หรือ .defer) และไม่มีอันไหนเปิดอะไรได้เลย

สังเกตเอง วิธีอ้อมที่คุณจะเจอ (NSExtensionContext().open(url) จาก ShieldAction extension ซึ่งไลบรารีเปิดให้ใช้ในชื่อ openUrl) ไม่มีผลอะไรเลยบน iOS ปัจจุบัน ตอนที่ผมทดสอบบนเครื่องจริงในเดือนกันยายน 2026 ปุ่มแค่ปิด shield ไป การโพสต์การแจ้งเตือนตอนกดปุ่มก็เชื่อถือไม่ได้สำหรับผมเหมือนกัน

สิ่งที่ผมใช้ในแอปจริงแทน: shield มีปุ่ม “ปิด” ปุ่มเดียว และ extension ShieldConfiguration จะโพสต์การแจ้งเตือนในเครื่องทุกครั้งที่ shield แสดงขึ้นมา ผู้ใช้แตะแบนเนอร์แล้วก็เข้าแอป แอปเขียน payload ของการแจ้งเตือนไว้ใน App Group ล่วงหน้า และลบการแจ้งเตือนทิ้งเมื่อแอปขึ้นมาอยู่เบื้องหน้า iOS อาจขอ configuration ของ shield หลายครั้งต่อการแสดงหนึ่งครั้ง การโพสต์เลยต้อง 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
}

มันเป็นวิธีแก้ขัด และต้องพึ่งให้ผู้ใช้อนุญาตการแจ้งเตือน แต่มันคือทางเดียวที่พาเข้าแอปได้อย่างเชื่อถือได้สำหรับผม

10. เก็บ extension ที่แก้เองไว้ไม่ให้หายตอน prebuild

ไลบรารี ถ้าคุณใช้ Expo คำสั่ง expo prebuild จะรัน config plugin ของ react-native-device-activity ซึ่งโดยค่าเริ่มต้นจะคัดลอกซอร์สของ extension ตัวมาตรฐานของไลบรารีไปทับใน ./targets ทุกครั้งที่รัน ShieldConfiguration ที่ผมแก้ไว้เลยถูกแทนที่แบบเงียบๆ วิธีแก้คือออปชันของ plugin แค่ตัวเดียว:

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

ราคาที่ต้องจ่าย: เวลาอัปเกรดไลบรารี ต้องเทียบ diff ของ targets/ และโค้ด Swift ที่ใช้ร่วมกันของมันกับของคุณเองด้วยมือ แล้วใส่การแก้ไขของคุณกลับเข้าไป

11. Xcode 26, Simulator และการทดสอบ

12. แล้วบน Android ล่ะ

ลองเทียบกันดู Android ไม่มีอะไรที่เทียบเท่าอย่างเป็นทางการ คุณเลยต้องสร้างเอง ตัวบล็อกของ HonorFit บน Android เป็น foreground service (ประเภท specialUse เพราะการบล็อกแอปไม่เข้ากับประเภทไหนในรายการเลย) ที่ poll UsageStatsManager ทุกวินาทีระหว่างที่หน้าจอเปิด เพื่อดูว่าแอปไหนอยู่ข้างหน้า เมื่อแอปที่ถูกบล็อกขึ้นมาและยอดเวลาเป็นศูนย์ มันจะวาดหน้าล็อก TYPE_APPLICATION_OVERLAY ทับไว้ (“แสดงทับแอปอื่น”) boot receiver กับ watchdog alarm จะรีสตาร์ท service หลังรีบูตเครื่อง หลังอัปเดตแอป และหลังโดนตัวจัดการแบตเตอรี่ที่โหดๆ ของผู้ผลิตมือถือปิดไป

ข้อแลกเปลี่ยนไปในทางตรงข้ามกับ iOS คุณเห็นชื่อ package จริง ความเป็นส่วนตัวเลยเป็นสัญญาที่คุณต้องรักษาเอง ไม่ใช่สิ่งที่ระบบบังคับให้ แอปที่ถูกบล็อกอาจโผล่ให้เห็นได้นานสูงสุดหนึ่งรอบของการ poll และยังรันต่อไปใต้ overlay แล้วคุณก็ต้องเขียนคำชี้แจงใน Play Console สำหรับประเภทของ foreground service, สิทธิ์ overlay และ package visibility ผมตั้งใจเลี่ยง AccessibilityService เพราะมันโดนตรวจเข้มที่สุดในบรรดาทั้งหมด

13. เช็กลิสต์

อาการสาเหตุที่น่าจะเป็นวิธีแก้
Provisioning ไม่ผ่านสำหรับ target ตัวเดียวbundle ID นั้นขาด entitlement หรือ App Groupขอ Family Controls (Distribution) และผูก App Group ให้ทุก bundle ID
ไม่มี threshold event เลยสักครั้งintervalStart = ตอนนี้ monitor เลยถูกจองไว้พรุ่งนี้ถอยเวลาเริ่มย้อนหลัง และให้ช่วงเวลา ≥ 15 นาที
Monitor ที่ตั้งไว้สำหรับพรุ่งนี้รันวันนี้Schedule ครอบเวลาปัจจุบันSchedule ที่หมดอายุแล้วของวันนี้ [00:00, T) หรือ repeats: true พร้อม action ที่มีเงื่อนไขกันไว้
แอปโดน shield แวบหนึ่งตอนเปิดแอปของคุณstopMonitoring ทำให้ intervalDidEnd ทำงานลบ action ก่อนหยุด และกันไว้ด้วยเวลา “ห้ามทำงานก่อน”
“หมดเวลา” หลังวางมือถือทิ้งไว้Threshold ผีบน iOS 26เช็กกับเวลานาฬิกา บวกตัวกรองแบบเป็นชุด
Threshold ตัวหลังๆ ไม่เคยทำงานถูกทิ้ง/รวบเมื่อใช้งานแบบเข้าๆ ออกๆตั้ง monitor ใหม่ทุกครั้งที่แอปไปอยู่เบื้องหลัง
ปุ่มบน shield เปิดแอปไม่ได้ไม่มี API ที่รองรับโพสต์การแจ้งเตือนจาก ShieldConfiguration ตอนที่ shield แสดง
Event ที่ทำงานแล้ว match ไม่เจอ (RN)มีขีดล่างในชื่อ eventใช้ used-7 ไม่ใช่ used_7
ตัวเลือกแอปเปิดมาว่างเปล่า (RN)ไม่มี prop includeEntireCategoryส่ง prop นี้ไป ด้วยค่าอะไรก็ได้

14. ตอบสั้นๆ

ต้องขอ entitlement ของ Family Controls ให้ทุก extension เลยไหม?

ใช่ครับ อนุมัติแยกตาม bundle ID ตัวแอปบวก extension DeviceActivityMonitor, ShieldConfiguration และ ShieldAction ก็คือต้องได้รับอนุมัติสี่ครั้ง และทุก App ID ต้องผูกกับ App Group ที่ใช้ร่วมกันด้วย

ทำไม eventDidReachThreshold ของ DeviceActivity ไม่ทำงาน?

สำหรับผม ส่วนใหญ่เป็นเพราะ schedule แบบไม่ทำซ้ำที่เริ่ม “ตอนนี้” ซึ่งจะถูกจองไปเป็นพรุ่งนี้ ให้ถอย intervalStart ย้อนหลัง บน iOS 26 เวลาใช้งานแบบเข้าๆ ออกๆ threshold ระดับต่ำกว่าหนึ่งนาทีที่ตามมาทีหลังก็หายไปด้วย การตั้ง monitor ใหม่ช่วยได้

extension ShieldAction หรือ ShieldConfiguration เปิดแอปหลักได้ไหม?

ไม่ได้ด้วย API ที่รองรับ และวิธีอ้อมด้วย NSExtensionContext ก็ไม่มีผลอะไรบน iOS ปัจจุบันตอนที่ผมทดสอบ สิ่งที่ผมใช้ในแอปจริงคือการแจ้งเตือนในเครื่องที่โพสต์จาก ShieldConfiguration ตอนที่ shield แสดงขึ้นมา

ทดสอบ Screen Time API ใน iOS Simulator ได้ไหม?

ได้บางส่วน หน้าขอสิทธิ์และตัวเลือกแอปแสดงขึ้นมาบน simulator ของ iOS 26 แต่การขอสิทธิ์ไม่สำเร็จ ตัวเลือกแอปไม่มีอะไรให้แสดง และ monitor extension ไม่เคยทำงาน ใช้เครื่องจริงเถอะครับ

15. ทำไมผมต้องทำทั้งหมดนี้

ผมทำ HonorFit แอปบล็อกแอปที่คุณต้องวิดพื้นเพื่อแลกเวลาหน้าจอ มันใช้ระบบความซื่อสัตย์: คุณกรอกจำนวนครั้งเอง ไม่มีกล้อง ไม่มี AI และไม่มีการเรียกเครือข่าย ทุกอย่างข้างบนคือสิ่งที่ต้องทำเพื่อให้ “นาทีที่คุณหามาได้จะลดลงเฉพาะตอนที่แอปที่ถูกบล็อกอยู่บนหน้าจอ” ใช้งานได้บน iPhone ถ้าอยากเห็นมันทำงานจริง มีบน App Store และ Google Play ถ้าคุณมาที่นี่เพราะอยากได้แอปแบบนี้มากกว่าอยากสร้างเอง ผมเขียนเรื่องแอปบล็อกแอปแบบวิดพื้นทำงานยังไง และรีวิวแบบตรงไปตรงมาของแอปที่มีอยู่ในตลาด รวมถึงคู่แข่งด้วย ถ้าคุณเจอพฤติกรรมของ Screen Time ที่ไม่ได้อยู่ในนี้ หรือพบว่ามีอะไรข้างบนที่ผิดบน iOS เวอร์ชันใหม่กว่า ผมอยากฟังมากครับ

อ่านต่อ