不用 AccessibilityService 打造 Android App 封鎖工具
我做了 HonorFit,一個靠做伏地挺身賺螢幕使用時間的 App 封鎖工具(誠實制,不用鏡頭)。第一個 Android 版本封鎖 App 的方式跟大多數 Android 封鎖工具一樣:用 AccessibilityService。8 月的時候我把它整個拔掉,換成 UsageStatsManager、一個前景服務和一個疊加視窗。封鎖變得稍微沒那麼硬,新手引導變得一點都不嚇人,Play 的審查也簡單多了。
下面是這個架構長什麼樣子,還有一路上踩到的坑。所有程式碼都是寫在一個本機 Expo module 裡的 Kotlin,但這裡講的東西沒有一樣是 React Native 專屬的。
為什麼要拿掉無障礙 API
無障礙服務是 Android 上最強的工具。每次視窗變化你都會立刻收到通知,而 performGlobalAction(GLOBAL_ACTION_HOME) 可以當場把使用者踢出被封鎖的 App。這就是為什麼這麼多封鎖工具都用它。代價是:
- 權限提示超嚇人。Android 會告訴使用者,這個 App 可以看到畫面上的所有東西,還能代替他們操作。對一個主打「資料都不會離開你的手機」的 App 來說,第一天就要求這個權限很難看,很多人當場就放棄了。
- Google Play 把它當成敏感 API。如果你不是無障礙工具,就需要在 App 裡放顯眼的揭露說明、在 Play Console 填聲明,還要審查人員同意你的用途沒問題。我連揭露說明的對話框都做好了,但我寧願根本不需要它。
- 它看到的東西遠超過封鎖工具需要的。封鎖工具只需要知道一件事:現在前景是哪個 App。
而這件事在別的地方也拿得到:數位健康(Digital Wellbeing)背後用的那些使用事件。
整體架構
三個權限,全部由使用者在「設定」裡授予:
| 權限 | 用途 |
|---|---|
PACKAGE_USAGE_STATS(「使用情況存取權」) | 讀取 UsageEvents,知道前景是哪個 App。這個權限不能在執行階段授予;你要把使用者帶到設定畫面,再檢查 app-op。 |
SYSTEM_ALERT_WINDOW(「顯示在其他應用程式上層」) | 在被封鎖的 App 上面畫出鎖定畫面。 |
FOREGROUND_SERVICE_SPECIAL_USE | 監控服務。封鎖 App 不屬於任何列出來的前景服務類型,所以用 specialUse,再加一個說明原因的子類型字串。 |
各個部分:
ForegroundMonitorService只有在有東西需要執行的時候才會跑(封鎖清單不是空的,而且已授予使用情況存取權)。它每秒問一次UsageStatsManager現在前景是什麼,而且只在螢幕亮著的時候。被封鎖但還有時間的 App 會扣掉存著的餘額;扣到零就顯示疊加視窗。- 它必須顯示的通知就是額度顯示(「還剩 12 分鐘」),所以 Android 硬塞給你的東西也做了正事。
- 所有狀態都存在
SharedPreferences,不在 JS 裡。React Native 的執行環境大部分時間都是死的,服務必須在這種情況下繼續運作。JS 端透過 module 存入時間、讀取餘額;執行封鎖則由服務負責。
服務在 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>
你還要在 Play Console 的前景服務聲明裡說明為什麼用 specialUse。兩邊講同一套說法會有幫助。
輪詢 UsageStats(還有它的寫入延遲)
核心迴圈很小。掃描上次輪詢之後的事件,留下最後一個 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)
跟無障礙事件比起來有個不錯的附帶好處:鍵盤和通知欄都不是 activity,所以它們永遠不會被誤判成「使用者離開了 App」。
地雷:事件會晚到
我的第一版讓時間窗嚴格往前推進:每次輪詢查詢 [lastEnd, now]。大部分時候都沒問題,結果在一支剛冷開機的手機上,第一次打開被封鎖的 App 完全沒被看到。UsageStats 把事件寫進儲存區會有延遲,所以一個時間戳是 10:00:00.900 的事件,可能在一次已經涵蓋到 10:00:01 的輪詢之後才出現。嚴格往前推的時間窗會永遠跳過它。
解法:每次輪詢都重新掃描最後一段重疊區間(我用 15 秒)。同一個事件看到兩次沒有壞處,因為不管怎樣,時間窗裡最後一次 resume 說了算。晚超過 15 秒才進來的事件還是會漏掉,但我還沒看過。
地雷:第一次輪詢要往回看很久
服務啟動時(重新啟動後,或螢幕亮起時)沒有「最後已知的 App」。如果使用者已經在 TikTok 裡待了 20 分鐘,而第一次輪詢只往回看一分鐘,在他切換 App 之前什麼都看不到。所以第一次輪詢會掃描最近一小時。這樣做是安全的,因為桌面啟動器也是一個 activity:最後一個 resume 的,就真的是現在在前景的。
地雷:存取權被撤銷時看起來就是一片安靜
如果使用者在服務執行時關掉使用情況存取權,queryEvents 不會丟例外,只會什麼都不回傳。你最後讀到的結果(「IG 在前景」)就過時了,餘額會一直扣下去。所以每次輪詢都會先檢查 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
}
螢幕關掉就停
螢幕關掉時,服務會把進行中的使用時段結算,然後完全停止輪詢。螢幕關著不會有人在用 App,而一個只在你看手機時才跑的 1 Hz 迴圈很省。螢幕重新亮起後的第一次輪詢也會處理換日(午夜重置、每日基本額度),所以它會在使用者打開被封鎖的 App 之前就完成,就算他們從來不打開 HonorFit 也一樣。
鎖定疊加視窗
沒有無障礙就沒有 GLOBAL_ACTION_HOME,所以你沒辦法把使用者踢出 App,只能把它蓋住:一個 TYPE_APPLICATION_OVERLAY 視窗,上面有「賺更多時間」按鈕和「回到主畫面」按鈕。
- 蓋住整個螢幕。預設情況下,疊加視窗會被系統列內縮,被封鎖的 App 會從邊緣露出來。
FLAG_LAYOUT_NO_LIMITS可以解決。FLAG_LAYOUT_IN_SCREEN在 API 30 已被淘汰,所以只在 R 以下加上它。 - 「回到主畫面」必須真的回到主畫面。只把疊加視窗隱藏,被封鎖的 App 又會露出來,下一次輪詢又會把鎖放回去。改成啟動桌面啟動器:
startActivity(Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_HOME) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) - 先啟動,再隱藏。「賺更多時間」按鈕會打開 HonorFit,這是從背景啟動 activity。Android 允許這麼做,是因為 App 有一個看得見的疊加視窗。所以要在疊加視窗還顯示的時候啟動 activity,然後再把它移除。順序反過來,啟動就會被默默擋掉。
- 沒有疊加權限?照樣計時。如果沒有授予「顯示在其他應用程式上層」,服務還是會誠實地扣餘額,只是在權限回來之前畫不出鎖定畫面。
讓服務活下去
這是最花時間才能弄對的部分,而且大多跟你的程式碼無關。
START_STICKY 不夠
Samsung 的「休眠中的應用程式」、MIUI、OnePlus 這些系統會殺掉背景服務,然後把 sticky 重啟吞掉。封鎖工具是最糟的情況:大家還有時間的時候不會打開它,所以監控不見了也沒有東西會發現。HonorFit 設了一個每 15 分鐘一次、不精確、不喚醒的重複鬧鐘,重新把服務拉起來。它從來不會喚醒手機,而螢幕關著時被延後的鬧鐘,會在螢幕亮起後不久觸發,那正是監控最重要的時候。
Android 12 以上不一定讓你啟動它
從背景(鬧鐘、sticky 重啟)啟動前景服務會丟出 ForegroundServiceStartNotAllowedException,除非你有豁免。這裡有兩個相關的:使用者已經關掉這個 App 的電池最佳化,或 App 持有 SYSTEM_ALERT_WINDOW(這個只在 Android 15 之前有用)。所以:
- 接住這個例外,安靜地退出。讓程序當掉對誰都沒好處,下次打開 App 時服務反正也會啟動。
- 請使用者把電池用量設成「無限制」。HonorFit 的「設定」裡有一張「背景執行穩定性」卡片,會帶他們到正確的畫面。在某些手機上,這個設定就是能不能用的差別。
重新開機和更新
一個接收 BOOT_COMPLETED 和 MY_PACKAGE_REPLACED 的 receiver 會重新啟動監控。第二個很容易忘記:Play 自動更新會殺掉你的程序,而且從來不會重啟它的服務,所以沒有它的話,每次更新都會默默把你的封鎖工具關掉,直到使用者打開 App 為止。這兩個廣播都不受背景啟動限制,而且 exported="false" 的 receiver 一樣收得到。
其他小事
- 有些 App 絕對不能被封鎖:設定、時鐘(鬧鐘!)和個人安全,還有各家廠商的版本,例如 Samsung 的時鐘套件。要在原生程式碼裡把它們從封鎖清單濾掉,不能只在選擇器裡處理。
- 服務在沒有 JS 的情況下執行,所以不能呼叫
t()。App 會把疊加視窗和通知的字串,用目前的語言推進SharedPreferences,在字串送到之前,服務會先用英文。 - 服務重啟期間一直開著的使用時段,在服務死掉的那段時間沒辦法扣時間。啟動時,把經過的時間結算(有上限),再開始一個新的時段,這樣就沒有人會因為服務不在而被扣到它沒看到的時間。
- Android 14 以上可以讓使用者滑掉前景服務的通知。服務會持續更新它(有做去重,所以不會每秒重新發一次),所以它會再回來。
取捨
這個做法比無障礙軟,這點我想先講清楚:
- 被封鎖的 App 在疊加視窗出現之前,最多會露出一個輪詢間隔(大約一秒)。
- 被封鎖的 App 會在疊加視窗底下繼續執行。如果原本在播影片,在你點「回到主畫面」之前還是聽得到聲音。
- 它要看手機廠商願不願意放過你的服務,這也是為什麼電池設定這麼重要。
對一個誠實制的封鎖工具來說,這是正確的取捨。HonorFit 並不想做到牢不可破。真的想作弊的人,直接記沒做的次數就好了。重點是讓你停一下、付出一點代價,而不是把你關起來。換來的是一個寫著「使用情況存取權」而不是「完全控制」的權限畫面、簡單得多的 Play 審查,以及一個只讀一樣東西的服務:前景 App 的套件名稱。
如果你也在做 iOS,那邊完全是另一個世界。我寫了Apple 的 Screen Time 文件沒告訴你的事。
HonorFit Blocker 是 iPhone 和 Android 上的免費 App 封鎖工具。用伏地挺身、深蹲或任何你選的運動來賺螢幕使用時間。誠實制、不用鏡頭、不用帳號,資料都不會離開你的手機。