Screen Time API ของ iOS ในปี 2026: สิ่งที่เอกสารของ Apple ไม่ได้บอกคุณ
ช่วงเดือนสิงหาคมกับกันยายน 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 ตรงๆ ข้ามไปได้เลย
- ชิ้นส่วนที่เกี่ยวข้อง
- Entitlement: หนึ่งอันต่อหนึ่ง bundle ID
- App Group คือช่องทางเดียว
- Token เป็นแบบทึบ และเรื่องของตัวเลือกแอป
- วัดการใช้งานด้วย threshold event
- Schedule ไม่ได้เริ่มตอนที่คุณคิด
- stopMonitoring ทำให้ intervalDidEnd ทำงาน
- Threshold ผีบน iOS 26
- Shield เปิดแอปของคุณไม่ได้
- เก็บ extension ที่แก้เองไว้ไม่ให้หายตอน prebuild
- Xcode 26, Simulator และการทดสอบ
- แล้วบน Android ล่ะ
- เช็กลิสต์
- ตอบสั้นๆ
- ทำไมผมต้องทำทั้งหมดนี้
1. ชิ้นส่วนที่เกี่ยวข้อง
“Screen Time API” ที่จริงคือเฟรมเวิร์กสี่ตัว และในทางปฏิบัติก็คือ app extension อีกสามตัว:
- FamilyControls: การขอสิทธิ์ (
AuthorizationCenterโดยใช้.individualสำหรับแอปคุมตัวเอง) และFamilyActivityPickerซึ่งคืนค่าเป็นFamilyActivitySelectionที่มี token ของแอป หมวดหมู่ และโดเมนเว็บ - ManagedSettings:
ManagedSettingsStoreที่คุณใส่ token พวกนั้นเข้าไปเพื่อขึ้น shield (หน้าจอบล็อกของระบบ) ทับแอป - DeviceActivity: schedule และ event เมื่อการใช้งานถึง threshold callback ต่างๆ (
intervalDidStart,intervalDidEnd,eventDidReachThresholdและเวอร์ชันแจ้งเตือนล่วงหน้า) ถูกส่งไปที่ extensionDeviceActivityMonitorไม่ใช่ที่แอปของคุณ - ManagedSettingsUI: extension
ShieldConfigurationDataSourceที่กำหนดหน้าตาของ shield และ extensionShieldActionDelegateที่จัดการปุ่มบน shield
หัวใจของเรื่องอยู่ที่ extension ตอนที่ผู้ใช้เวลาหมด แอปของผมมักถูก suspend หรือถูกปิดไปแล้ว monitor extension จึงเป็นตัวที่ขึ้น shield กลับมา ออกแบบไว้เลยว่า process ของแอปคุณจะไม่อยู่ตรงนั้นตอนที่อะไรๆ เกิดขึ้น
2. Entitlement: ขอหนึ่งครั้งต่อหนึ่ง bundle ID
Entitlement ของ Family Controls (Distribution) ได้รับอนุมัติแยกตาม bundle ID มี extension สามตัวก็เท่ากับต้องขอสี่ครั้ง ของผมคือ:
com.honorfit.blocker(ตัวแอป)com.honorfit.blocker.ShieldConfigurationcom.honorfit.blocker.ShieldActioncom.honorfit.blocker.ActivityMonitorExtension
พออนุมัติแล้ว ให้เปิด 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 หมวดหมู่” นี่แหละคือโมเดลความเป็นส่วนตัวทั้งหมด และมันดีด้วย แอปของผมไม่รู้จริงๆ ว่าคุณบล็อกอะไร
FamilyActivityPicker ของ Apple ผู้ใช้เห็นชื่อและไอคอนของแอป แต่แอปของคุณได้กลับมาแค่ token ทึบๆShield extension ไม่เหมือนกัน ตรงนั้นระบบจะส่ง Application ของแอปที่กำลังถูกบล็อกมาให้ และผมใช้ localizedDisplayName ของมันในข้อความบน shield ส่วนตัวแอปเองเห็นแค่ token เท่านั้น
ข้อสังเกตเชิงปฏิบัติสองข้อ:
- หมวดหมู่ก็ยังเป็นหมวดหมู่ ถ้าผู้ใช้เลือก “โซเชียล” คุณจะได้ token ของหมวดหมู่ ไม่ใช่ของแอปที่อยู่ข้างใน เว้นแต่ selection จะถูกสร้างโดยใช้
includeEntireCategoryผมตั้งค่านี้เป็นfalseไว้ ตัวเลขที่แสดงจะได้ตรงไปตรงมา (“1 หมวดหมู่” ไม่ใช่จำนวนแอปที่ผมบอกชื่อไม่ได้) - ไลบรารี picker แบบ persisted ของ react-native-device-activity จะโหลด selection ที่บันทึกไว้ล่วงหน้า (เครื่องหมายถูกตอนเปิดใหม่) ก็ต่อเมื่อคุณส่ง prop
includeEntireCategoryเข้าไป ด้วยค่าอะไรก็ได้ เพราะ setter ฝั่ง native ของมันคือตัวที่สั่งให้โหลด ถ้าไม่มี prop นี้ ตัวเลือกจะเปิดขึ้นมาว่างเปล่าทุกครั้ง ทั้งที่ selection ถูกบันทึกไว้แล้ว
<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 ครอบเวลาปัจจุบันอยู่ มันเลยเริ่มตอนนี้ ไม่ใช่เที่ยงคืนถัดไป ผมเจอสองวิธีที่จะจองไว้สำหรับพรุ่งนี้:
- Schedule ที่หมดอายุไปแล้ว
[00:00, T)โดย T คือหนึ่งนาทีก่อนเวลาปัจจุบันของวัน รอบของวันนี้จบไปแล้ว เฟรมเวิร์กเลยจองรอบของพรุ่งนี้ ผมใช้วิธีนี้เพื่อยกยอดเวลาที่ยังไม่ได้ใช้ข้ามเที่ยงคืนไป repeats: trueพร้อม action ที่มีเงื่อนไขกันไว้ รอบของวันนี้เริ่มทันทีแต่ไม่ทำอะไร (ทุก action มีเงื่อนไขกันไว้ ดูหัวข้อถัดไป) รอบของพรุ่งนี้คือตัวที่ทำงานจริง
สังเกตเอง อย่าใช้ 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 ของจริงช่วยได้เกือบทั้งหมด:
- การใช้งานวิ่งแซงนาฬิกาไม่ได้
used-7ไม่มีทางทำงานจริงได้ภายในเวลาไม่ถึง 7 นาทีหลังตั้ง monitor ทุก action ของ threshold เลยมีneverTriggerBefore = armedAt + N minutes − 5sติดไปด้วย extension จะข้าม event ที่ทำงานเร็วเกินกว่าจะเป็นไปได้ - การใช้งานจริงค่อยๆ ทยอยมา ส่วน 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 และการทดสอบ
- Build ด้วย iOS 26 SDK ตอนนี้ App Store Connect ปฏิเสธไฟล์ที่ build ด้วย SDK เก่ากว่า iOS 26 แล้ว (ITMS-90725) บน EAS ผมปักหมุด build image ไว้ที่
macos-tahoe-26.5-xcode-26.6 - Simulator พาไปได้แค่ครึ่งทาง บน simulator ของ iOS 26 หน้าขอสิทธิ์และตัวเลือกแอปแสดงขึ้นมาได้ แต่การขอสิทธิ์ไม่เคยสำเร็จ (“Allow with Passcode” (อนุญาตด้วยรหัส) ต้องใช้รหัสของเครื่อง ซึ่ง Simulator ตั้งไม่ได้) ตัวเลือกแอปไม่มีแอปให้แสดง และ monitor extension ไม่เคยทำงาน ทุกอย่างในหัวข้อ 5 ถึง 9 ต้องใช้เครื่องจริง
- เช็กสิทธิ์ก่อนเรียก ManagedSettings การแตะ store ตอนที่ยังไม่ได้รับสิทธิ์ทำให้หน้าขอรหัสของระบบเด้งขึ้นมาบน Simulator ซึ่งทำให้ UI test ของผมพัง ผมเลยเช็กสถานะสิทธิ์ก่อนเรียกอะไรที่เกี่ยวกับ shield ทุกครั้ง
- แสดง error เรื่องสิทธิ์ใน UI คนที่ทดสอบผ่าน TestFlight ไม่มี console ถ้า
requestAuthorizationล้มเหลวแบบเงียบๆ ปุ่มก็จะดูเหมือนพัง ผมเลยแสดง error และสถานะใน alert - ทดสอบข้ามคืน บั๊กที่หนักที่สุดของผม (ถูกบล็อกทั้งคืนทั้งที่ยังมีเวลาเหลือ เพลงหยุดเล่น) มองไม่เห็นตอนรีวิวโค้ด และเกิดซ้ำได้แค่บนเครื่องจริงหลังผ่านไปหลายชั่วโมง
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 เวอร์ชันใหม่กว่า ผมอยากฟังมากครับ