สร้างแอปบล็อกแอปบน Android โดยไม่ใช้ AccessibilityService

UsageStatsManager, foreground service และ overlay จากแอปที่ใช้งานจริง โดย Thomas Willheim · 9 พฤศจิกายน 2026

ผมทำ HonorFit แอปบล็อกแอปที่คุณต้องวิดพื้นเพื่อแลกเวลาหน้าจอ (ระบบความซื่อสัตย์ ไม่ใช้กล้อง) เวอร์ชัน Android แรกบล็อกแอปแบบเดียวกับแอปบล็อกบน Android ส่วนใหญ่: ใช้ AccessibilityService พอเดือนสิงหาคม ผมรื้อออกทั้งหมด แล้วเปลี่ยนไปใช้ UsageStatsManager, foreground service และหน้าต่าง overlay แทน การบล็อกนุ่มลงนิดหน่อย ขั้นตอนเริ่มต้นใช้งานน่ากลัวน้อยลงเยอะ และการรีวิวของ Play ก็ง่ายขึ้นมาก

นี่คือหน้าตาของสถาปัตยกรรมนั้น และเรื่องที่ทำผมเจ็บตัวระหว่างทาง โค้ดทั้งหมดเป็น Kotlin ใน Expo module แบบ local แต่ไม่มีอะไรในนี้ที่เฉพาะกับ React Native เลย

ทำไมถึงเลิกใช้ Accessibility API

accessibility service เป็นเครื่องมือที่ทรงพลังที่สุดที่คุณได้บน Android คุณรู้ทุกครั้งที่หน้าต่างเปลี่ยนทันทีที่มันเกิดขึ้น และ performGlobalAction(GLOBAL_ACTION_HOME) ก็เตะผู้ใช้ออกจากแอปที่ถูกบล็อกได้ทันที นั่นคือเหตุผลที่แอปบล็อกจำนวนมากใช้มัน ส่วนต้นทุนคือ:

และข้อมูลนั้นหาได้จากที่อื่น: usage event ที่อยู่เบื้องหลัง Digital Wellbeing

โครงสร้างโดยรวม

สิทธิ์สามอย่าง ผู้ใช้เป็นคนให้เองทั้งหมดในการตั้งค่า:

สิทธิ์ใช้ทำอะไร
PACKAGE_USAGE_STATS (“สิทธิ์เข้าถึงการใช้งาน”)อ่าน UsageEvents เพื่อรู้ว่าแอปไหนอยู่ข้างหน้า สิทธิ์นี้ขอตอน runtime ไม่ได้ ต้องส่งผู้ใช้ไปที่หน้าการตั้งค่าแล้วเช็ก app-op เอา
SYSTEM_ALERT_WINDOW (“แสดงทับแอปอื่น”)วาดหน้าจอล็อกทับแอปที่ถูกบล็อก
FOREGROUND_SERVICE_SPECIAL_USEservice ที่คอยตรวจจับ การบล็อกแอปไม่เข้ากับประเภท foreground service ที่มีให้เลือกเลย จึงต้องเป็น specialUse พร้อมสตริง subtype ที่อธิบายเหตุผล

ส่วนประกอบต่างๆ:

รายการของ service ใน manifest:

<service
  android:name=".ForegroundMonitorService"
  android:exported="false"
  android:foregroundServiceType="specialUse">
  <property
    android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
    android:value="User-configured app blocking for digital wellbeing: monitors
      which app is in the foreground (UsageStats) so apps the user chose to block
      can be locked when their earned screen time runs out." />
</service>

คุณยังต้องอธิบายเหตุผลของ specialUse ในการประกาศ foreground service ของ Play Console ด้วย เล่าเรื่องเดียวกันทั้งสองที่จะช่วยได้

การ poll UsageStats (และความล่าช้าในการเขียน)

ลูปหลักเล็กนิดเดียว สแกน event ตั้งแต่การ poll ครั้งก่อน แล้วเก็บ package ของ MOVE_TO_FOREGROUND ตัวสุดท้ายไว้ (มันเป็นค่าคงที่เดียวกับ ACTIVITY_RESUMED ที่เปลี่ยนชื่อใน API 29):

val end = System.currentTimeMillis()
val events = usm.queryEvents(windowStart, end)
val event = UsageEvents.Event()
while (events.hasNextEvent()) {
  events.getNextEvent(event)
  if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
    currentForeground = event.packageName
  }
}
// รอบหน้าสแกน 15 วินาทีล่าสุดซ้ำ: ดูด้านล่าง
windowStart = end - REQUERY_OVERLAP_MS
handleForeground(currentForeground)

ข้อดีที่ได้แถมมาเมื่อเทียบกับ accessibility event: คีย์บอร์ดกับแถบการแจ้งเตือนไม่ใช่ activity เลยไม่เคยโผล่มาเป็น “ผู้ใช้ออกจากแอปแล้ว” แบบหลอกๆ

จุดที่ต้องระวัง: event มาช้า

เวอร์ชันแรกของผมเลื่อนหน้าต่างเวลาไปข้างหน้าอย่างเดียว: poll แต่ละครั้ง query [lastEnd, now] ส่วนใหญ่ก็ใช้ได้ แต่บนมือถือที่เพิ่งเปิดเครื่องใหม่ การเปิดแอปที่ถูกบล็อกครั้งแรกสุดไม่ถูกเห็นเลย UsageStats เขียน event ลงที่เก็บข้อมูลแบบล่าช้า event ที่ประทับเวลา 10:00:00.900 เลยอาจโผล่มาหลังจาก poll ที่ครอบคลุมถึง 10:00:01 ไปแล้ว หน้าต่างที่เลื่อนไปข้างหน้าอย่างเดียวจะข้ามมันไปตลอดกาล

วิธีแก้: poll ทุกครั้งสแกนช่วงที่ซ้อนทับย้อนหลังซ้ำ (ผมใช้ 15 วินาที) การเห็น event เดิมสองครั้งไม่มีผลเสีย เพราะยังไง resume ครั้งสุดท้ายในหน้าต่างก็ชนะอยู่ดี event ที่มาช้ากว่า 15 วินาทียังหลุดได้ แต่ผมยังไม่เคยเจอ

จุดที่ต้องระวัง: poll แรกต้องมองย้อนไปไกล

ตอนที่ service เริ่มทำงาน (หลังรีสตาร์ต หรือตอนหน้าจอเปิด) ยังไม่มี “แอปล่าสุดที่รู้” ถ้าผู้ใช้นั่งอยู่ใน TikTok มา 20 นาทีแล้ว และ poll แรกมองย้อนแค่หนึ่งนาที มันจะไม่เห็นอะไรเลยจนกว่าเขาจะสลับแอป poll แรกจึงสแกนย้อนหลังหนึ่งชั่วโมง ซึ่งปลอดภัยเพราะ launcher ก็เป็น activity เหมือนกัน: อะไรที่ resume ล่าสุดก็คือสิ่งที่อยู่ข้างหน้าจริงๆ

จุดที่ต้องระวัง: สิทธิ์ที่ถูกถอนดูเหมือนความเงียบ

ถ้าผู้ใช้ปิดสิทธิ์เข้าถึงการใช้งานระหว่างที่ service ทำงานอยู่ queryEvents จะไม่ throw แต่จะคืนค่าว่างเปล่า ค่าที่อ่านได้ล่าสุด (“Instagram อยู่ข้างหน้า”) ก็จะค้างอยู่ และยอดคงเหลือก็ถูกหักไปเรื่อยๆ ไม่มีวันจบ poll ทุกครั้งเลยเช็ก app-op ก่อน และหยุดนับเวลาถ้าสิทธิ์หายไป:

fun hasUsageAccess(context: Context): Boolean {
  val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
  val mode = appOps.checkOpNoThrow(
    AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
  return mode == AppOpsManager.MODE_ALLOWED
}

หน้าจอดับคือหยุด

พอหน้าจอดับ service จะสรุปเวลาของ session ที่เปิดอยู่ แล้วหยุด poll ไปเลย ไม่มีใครใช้แอปตอนหน้าจอดับ และลูป 1 Hz ที่ทำงานเฉพาะตอนคุณมองมือถืออยู่ก็กินทรัพยากรน้อย poll แรกหลังหน้าจอเปิดยังจัดการเรื่องขึ้นวันใหม่ด้วย (รีเซ็ตตอนเที่ยงคืน, เวลาพื้นฐานรายวัน) มันเลยเกิดขึ้นก่อนที่ผู้ใช้จะไปถึงแอปที่ถูกบล็อก แม้ว่าเขาจะไม่เคยเปิด HonorFit เลยก็ตาม

overlay สำหรับล็อก

ถ้าไม่มี accessibility ก็ไม่มี GLOBAL_ACTION_HOME คุณจึงโยนผู้ใช้ออกจากแอปไม่ได้ ต้องปิดทับมันแทน: หน้าต่าง TYPE_APPLICATION_OVERLAY ที่มีปุ่ม “รับเวลาเพิ่ม” และปุ่ม “กลับหน้าหลัก”

ทำให้ service ไม่ตาย

นี่คือส่วนที่ใช้เวลานานที่สุดกว่าจะทำให้ถูก และส่วนใหญ่ไม่เกี่ยวกับโค้ดของคุณเลย

START_STICKY ไม่พอ

“แอปที่อยู่ในโหมดสลีป” ของ Samsung, MIUI, OnePlus และพวกพ้อง ฆ่า service ที่ทำงานเบื้องหลัง แล้วกลืนการรีสตาร์ตแบบ sticky ไปด้วย แอปบล็อกคือกรณีที่แย่ที่สุด: คนไม่เปิดมันตอนที่ยังมีเวลาเหลือ เลยไม่มีอะไรสังเกตว่าตัวตรวจจับหายไปแล้ว HonorFit ตั้ง alarm แบบซ้ำทุก 15 นาที ที่ไม่แม่นยำและไม่ปลุกเครื่อง เพื่อสั่งให้ service กลับมาทำงาน มันไม่เคยปลุกมือถือ และ alarm ที่ถูกเลื่อนไว้ตอนหน้าจอดับจะทำงานไม่นานหลังหน้าจอเปิด ซึ่งก็คือตอนที่ตัวตรวจจับสำคัญที่สุดพอดี

Android 12+ ไม่ได้ยอมให้คุณเริ่มมันเสมอ

การเริ่ม foreground service จากเบื้องหลัง (alarm, การรีสตาร์ตแบบ sticky) จะ throw ForegroundServiceStartNotAllowedException เว้นแต่คุณมีข้อยกเว้น มีสองข้อที่เกี่ยวกับกรณีนี้: ผู้ใช้ปิดการเพิ่มประสิทธิภาพแบตเตอรี่ให้แอปแล้ว หรือแอปถือสิทธิ์ SYSTEM_ALERT_WINDOW (ข้อนี้ช่วยได้แค่ก่อน Android 15) ดังนั้น:

รีบูตและอัปเดต

receiver สำหรับ BOOT_COMPLETED และ MY_PACKAGE_REPLACED จะเริ่มการตรวจจับใหม่ ตัวที่สองลืมง่าย: การอัปเดตอัตโนมัติของ Play จะฆ่า process ของคุณและไม่เคยรีสตาร์ต service ให้ ถ้าไม่มีมัน ทุกการอัปเดตจะปิดแอปบล็อกของคุณเงียบๆ จนกว่าผู้ใช้จะเปิดแอป broadcast ทั้งสองได้รับยกเว้นจากข้อจำกัดการเริ่มจากเบื้องหลัง และ receiver ที่ exported="false" ก็ยังได้รับอยู่

เรื่องเล็กๆ อื่นๆ

สิ่งที่ต้องแลก

วิธีนี้นุ่มกว่า accessibility และผมอยากพูดตรงๆ เรื่องนี้:

สำหรับแอปบล็อกที่ใช้ระบบความซื่อสัตย์ นี่คือการแลกที่ถูกต้อง HonorFit ไม่ได้พยายามจะเป็นสิ่งที่ทำลายไม่ได้ คนที่ตั้งใจจะโกงก็แค่บันทึกครั้งที่ไม่ได้ทำ จุดประสงค์คือการหยุดคิดสักนิดและมีราคาที่ต้องจ่าย ไม่ใช่คุก สิ่งที่ได้กลับมาคือหน้าขอสิทธิ์ที่เขียนว่า “สิทธิ์เข้าถึงการใช้งาน” แทนที่จะเป็น “ควบคุมได้ทั้งหมด” การรีวิวของ Play ที่ง่ายกว่าเยอะ และ service ที่อ่านแค่อย่างเดียว: ชื่อ package ของแอปที่อยู่ข้างหน้า

ถ้าคุณทำบน iOS ด้วย ฝั่งนั้นเป็นอีกโลกหนึ่งเลย ผมเขียนเรื่องสิ่งที่เอกสาร Screen Time ของ Apple ไม่ได้บอกคุณไว้แล้ว

HonorFit Blocker คือแอปบล็อกแอปที่ใช้ฟรีทั้ง iPhone และ Android แลกเวลาหน้าจอด้วยการวิดพื้น สควอท หรือท่าไหนก็ได้ที่คุณเลือก ใช้ระบบความซื่อสัตย์ ไม่ใช้กล้อง ไม่ต้องมีบัญชี ไม่มีข้อมูลออกจากมือถือคุณ

อ่านต่อ