ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Android来电提醒与漏接记录工具开发实战:从0到1实现

Android来电提醒与漏接记录工具开发实战:从0到1实现 你是否有过这样的经历手机静音放在桌上等拿起来一看好几个未接来电其中恰恰有那个最不该错过的电话或者在开会、做饭、午休时手机明明在响却因为距离远、震动弱、铃声被自动调低而没听到等回拨过去对方已经不方便接听了。“快点接电话啦”听起来像一句催促但放在开发场景里它其实是一个非常典型的移动端实战需求做一个来电感知与提醒工具。它能检测呼入电话、识别号码、弹出全屏提醒、甚至在用户漏接后生成一条带时间戳和号码的记录避免重要电话被遗忘。本文就以这个场景为例从 0 到 1 实现一个轻量级 Android 来电提醒与漏接记录工具。内容覆盖系统电话状态监听、权限适配、前台服务保活、来电弹窗、Room 本地存储等核心模块。新手可以把它当作 Android 广播与服务机制的入门项目有经验的开发者也可以直接复用部分代码作为来电类功能的底层实现。1. 来电提醒工具的核心思路1.1 这个工具到底做什么先明确需求边界。我们要实现的不是电话应用本身而是围绕“来电”这一事件做增强提醒的小工具。具体功能拆分为四块监听电话状态变化检测到电话状态变为RINGING响铃时说明有来电。获取来电号码从系统广播的 extras 中读取incoming_number。弹窗提醒在屏幕上弹出自定义的来电提醒浮窗显示号码并配合震动或铃声提醒。记录漏接电话电话状态从RINGING变为IDLE且未接听则保存一条漏接记录。整个流程可以用一句话概括通过系统电话状态广播获取来电信息在用户无感知时主动做提醒和信息留存。1.2 为什么选用 BroadcastReceiver Foreground ServiceAndroid 系统对电话状态的感知需要有合适的入口。电话状态变化是由系统发出广播的应用侧只需要监听TelephonyManager.ACTION_PHONE_STATE_CHANGED即可。但是只靠广播接收器还不够。原因有两点广播接收器的生命周期非常短onReceive()执行完就结束了来电弹窗、震动提醒、漏接记录是多个动作的组合需要在一个更稳定的宿主中执行。所以合理的架构是TelephonyManager 广播 ↓ BroadcastReceiver接收事件解析号码 ↓ 启动/通知 Foreground Service执行弹窗、震动、存储 ↓ Room 数据库保存漏接记录这里使用前台服务一是为了在 Android 8.0 之后能稳定运行二是为了在来电时保证有一定优先级降低被系统回收的概率。前台服务需要附带一条常驻通知这也是 Android 系统对长时间后台运行的基本要求。1.3 需要注意的权限限制在 Android 平台上来电相关权限属于敏感权限尤其是 Android 10API 29之后普通应用读取电话号码受到了更严格的限制。用到的权限主要是READ_PHONE_STATE读取电话状态这是监听来电广播的前提SYSTEM_ALERT_WINDOW显示悬浮窗VIBRATE来电震动提醒FOREGROUND_SERVICE前台服务运行POST_NOTIFICATIONSAndroid 13发送通知。需要特别说明的是从 Android 10 开始READ_PHONE_STATE权限被划分为敏感权限部分设备上即使已授权也只能拿到READ_CALL_LOG连接列表中已有联系人对应的来电号码陌生号码可能显示为空。这一点在后面的适配部分会单独讲。2. 环境准备与项目结构2.1 开发环境说明本文示例使用以下环境读者不必完全一致操作系统Windows / macOS 均可 开发工具Android Studio最新稳定版即可 构建工具Gradle 8.x 语言Kotlin 最低 SDKAPI 24Android 7.0 目标 SDKAPI 33Android 13 数据库Room 2.6.x如果你的项目已经存在可以不新建工程直接把模块代码移植进去。核心代码与 Android 版本无关的部分占多数重点是权限申请和通知适配需要按 targetSdk 处理。2.2 示例项目包结构为了代码清晰我们按分层分包com.example.quickpickup ├── receiver │ └── PhoneStateReceiver.kt ├── service │ └── CallAlertService.kt ├── db │ ├── MissedCall.kt │ ├── MissedCallDao.kt │ └── AppDatabase.kt ├── ui │ └── CallAlertDialog.kt └── util └── PermissionUtils.kt按照这个结构读者可以很清楚地看到“接收广播—处理业务—存储数据”的完整链路。接下来按步骤拆解。3. 核心原理拆解3.1 电话状态广播机制TelephonyManager定义了三种电话状态状态含义CALL_STATE_IDLE空闲无任何来电或通话CALL_STATE_RINGING有来电正在响铃CALL_STATE_OFFHOOK通话中包括接听或正在拨打系统在状态切换时会发送广播Action 为TelephonyManager.ACTION_PHONE_STATE_CHANGED。广播中携带两个关键 ExtraTelephonyManager.EXTRA_STATE字符串形式的电话状态TelephonyManager.EXTRA_INCOMING_NUMBER来电号码。监听方式可以选静态注册或动态注册。这里推荐在BroadcastReceiver中动态注册因为静态注册在 Android 8.0 之后对隐式广播有诸多限制电话状态广播虽然属于例外但为了更可控动态注册更安全。3.2 判断“漏接”逻辑来电流程通常是这样IDLE - RINGING - OFFHOOK接听 - IDLE IDLE - RINGING - IDLE未接听漏接所以判断漏接有一个简单策略当状态从RINGING变为IDLE并且期间没有经过OFFHOOK就认为是漏接。为了记录状态我们需要在全局保存一个“呼叫状态追踪变量”。示例伪代码如下var isRinging false var isOffhook false fun onStateChanged(state: Int) { when (state) { TelephonyManager.CALL_STATE_RINGING - { isRinging true isOffhook false currentNumber getNumber() } TelephonyManager.CALL_STATE_OFFHOOK - { isOffhook true } TelephonyManager.CALL_STATE_IDLE - { if (isRinging !isOffhook) { // 漏接 } isRinging false isOffhook false } } }这里的要点是必须保存上一次的状态而不是只看当前状态。只靠一次IDLE事件无法判断之前是响铃还是通话所以状态流转逻辑是核心。3.3 前台服务的必要性Android 应用后台执行受限是很多开发者踩过的大坑。尤其在 8.0 之后如果应用处于后台系统会限制其启动服务。如果应用在收到来电广播时处于后台状态直接startService()很可能会触发IllegalStateException。解决办法是使用startForegroundService()并保证在服务启动后 5 秒内调用startForeground()。这意味着来电弹窗和存储逻辑不能直接放在BroadcastReceiver里执行而应该由后台服务统一调度。3.4 悬浮窗实现方式来电提醒需要一种“高优先级展示”的形式。常用的方案有两种方案优点缺点通知栏通知实现简单、系统兼容性最好视觉提醒强度不足SYSTEM_ALERT_WINDOW 悬浮窗展示效果突出可以全屏需要额外授权国产 ROM 限制多结合“快点接电话”的场景我们优先使用悬浮窗提醒同时降级使用通知栏提醒。这样既保证提醒强度又能兼容未授予悬浮窗权限的设备。4. 完整实战开发4.1 创建项目并添加依赖首先在app/build.gradle.kts中添加依赖dependencies { // Room 数据库 implementation(androidx.room:room-runtime:2.6.1) ksp(androidx.room:room-compiler:2.6.1) // 协程 implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3) // 生命周期 implementation(androidx.lifecycle:lifecycle-service:2.7.0) // 通知相关 implementation(androidx.core:core-ktx:1.12.0) }如果使用的是kapt而不是ksp注意build.gradle.kts中需要相应配置。4.2 AndroidManifest 权限与组件声明AndroidManifest.xml中声明权限与组件manifest xmlns:androidhttp://schemas.android.com/apk/res/android uses-permission android:nameandroid.permission.READ_PHONE_STATE / uses-permission android:nameandroid.permission.SYSTEM_ALERT_WINDOW / uses-permission android:nameandroid.permission.VIBRATE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS / uses-permission android:nameandroid.permission.WAKE_LOCK / application android:labelstring/app_name receiver android:name.receiver.PhoneStateReceiver android:exportedfalse / service android:name.service.CallAlertService android:exportedfalse android:foregroundServiceTypespecialUse / /application /manifest在 Android 14API 34上部分前台服务类型有额外要求如果 targetSdk 达到 34需要在foregroundServiceType上添加说明或者直接放在清单中声明FOREGROUND_SERVICE_SPECIAL_USE权限。本文示例以兼容性优先读者按实际 targetSdk 调整。4.3 动态注册电话状态监听推荐在MainActivity或一个全局Application类中动态注册广播。这里给出在 Application 中注册的示例// 文件路径app/src/main/java/com/example/quickpickup/App.kt package com.example.quickpickup import android.app.Application import android.content.IntentFilter import android.telephony.TelephonyManager import com.example.quickpickup.receiver.PhoneStateReceiver class App : Application() { private val phoneStateReceiver PhoneStateReceiver() override fun onCreate() { super.onCreate() registerPhoneStateReceiver() } private fun registerPhoneStateReceiver() { val filter IntentFilter().apply { addAction(TelephonyManager.ACTION_PHONE_STATE_CHANGED) } // Android 13 及以上需要动态注册 if (android.os.Build.VERSION.SDK_INT android.os.Build.VERSION_CODES.TIRAMISU) { registerReceiver(phoneStateReceiver, filter, RECEIVER_EXPORTED) } else { registerReceiver(phoneStateReceiver, filter) } } }注意RECEIVER_EXPORTED是 Android 13 引入的 flag 要求。如果是监听系统广播需要设置为RECEIVER_EXPORTED如果只接收应用内广播可以设为RECEIVER_NOT_EXPORTED。4.4 广播接收器解析来电事件PhoneStateReceiver负责接收广播解析电话状态和号码然后启动前台服务处理后续业务。// 文件路径app/src/main/java/com/example/quickpickup/receiver/PhoneStateReceiver.kt package com.example.quickpickup.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.telephony.TelephonyManager import com.example.quickpickup.service.CallAlertService class PhoneStateReceiver : BroadcastReceiver() { private var lastState TelephonyManager.CALL_STATE_IDLE private var isRinging false private var isOffhook false private var incomingNumber override fun onReceive(context: Context, intent: Intent) { if (intent.action ! TelephonyManager.ACTION_PHONE_STATE_CHANGED) { return } val state intent.getStringExtra(TelephonyManager.EXTRA_STATE) val number intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER) ?: val stateCode when (state) { TelephonyManager.EXTRA_STATE_RINGING - TelephonyManager.CALL_STATE_RINGING TelephonyManager.EXTRA_STATE_OFFHOOK - TelephonyManager.CALL_STATE_OFFHOOK else - TelephonyManager.CALL_STATE_IDLE } when (stateCode) { TelephonyManager.CALL_STATE_RINGING - { isRinging true isOffhook false incomingNumber number // 启动前台服务处理提醒 CallAlertService.start(context, incomingNumber) } TelephonyManager.CALL_STATE_OFFHOOK - { isOffhook true } TelephonyManager.CALL_STATE_IDLE - { if (isRinging !isOffhook) { // 漏接保存记录 CallAlertService.saveMissedCall(context, incomingNumber) } isRinging false isOffhook false incomingNumber } } lastState stateCode } }这里有几个容易出错的地方如果手机在响铃后立即被系统挂断状态可能是RINGING - IDLE此时能正确捕获漏接如果用户拒绝来电或者来电被拦截某些 ROM 可能不会广播IDLE而是直接不发广播这就可能导致漏接记录缺失后文会给出兜底策略incomingNumber在部分 Android 10 设备上可能为空因为权限收紧。4.5 前台服务弹窗与震动CallAlertService是整个业务的核心宿主。它在响铃时启动负责展示悬浮窗和震动在漏接时保存记录。// 文件路径app/src/main/java/com/example/quickpickup/service/CallAlertService.kt package com.example.quickpickup.service import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.os.Build import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import androidx.core.app.NotificationCompat import com.example.quickpickup.MainActivity import com.example.quickpickup.R import com.example.quickpickup.db.AppDatabase import com.example.quickpickup.db.MissedCall import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch class CallAlertService : Service() { private val serviceScope CoroutineScope(SupervisorJob() Dispatchers.IO) override fun onCreate() { super.onCreate() createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val number intent?.getStringExtra(EXTRA_NUMBER) ?: 未知号码 startForeground(ID, buildNotification(number)) showCallAlert(number) vibrate() return START_NOT_STICKY } override fun onBind(intent: Intent?): Nothing? null private fun createNotificationChannel() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( CHANNEL_ID, 来电提醒, NotificationManager.IMPORTANCE_HIGH ).apply { description 来电提醒与漏接记录 } val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) } } private fun buildNotification(number: String): Notification { val contentIntent PendingIntent.getActivity( this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(有来电) .setContentText(号码$number) .setSmallIcon(R.drawable.ic_call) .setContentIntent(contentIntent) .setOngoing(true) .build() } private fun showCallAlert(number: String) { // 实际展示悬浮窗详见下文的 CallAlertDialog } private fun vibrate() { val vibrator if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { val manager getSystemService(VibratorManager::class.java) manager.defaultVibrator } else { Suppress(DEPRECATION) getSystemService(Vibrator::class.java) } if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { vibrator.vibrate( VibrationEffect.createWaveform( longArrayOf(0, 1000, 500, 1000), -1 ) ) } else { Suppress(DEPRECATION) vibrator.vibrate(longArrayOf(0, 1000, 500, 1000), -1) } } companion object { private const val CHANNEL_ID call_alert_channel private const val ID 1001 private const val EXTRA_NUMBER extra_number fun start(context: Context, number: String) { val intent Intent(context, CallAlertService::class.java).apply { putExtra(EXTRA_NUMBER, number) } if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { context.startForegroundService(intent) } else { context.startService(intent) } } fun saveMissedCall(context: Context, number: String) { val db AppDatabase.get(context) val missedCall MissedCall( number number.ifBlank { 未知号码 }, timestamp System.currentTimeMillis() ) CoroutineScope(Dispatchers.IO).launch { db.missedCallDao().insert(missedCall) } } } }注意几个细节startForegroundService()后必须在 5 秒内调用startForeground()否则系统会报RemoteServiceException通知渠道的IMPORTANCE_HIGH是为了在响铃时通知栏也能有明显提示震动使用VibrationEffect.createWaveform实现“震-停-震”效果。4.6 数据库层Room 漏接记录先定义实体和 DAO// 文件路径app/src/main/java/com/example/quickpickup/db/MissedCall.kt package com.example.quickpickup.db import androidx.room.Entity import androidx.room.PrimaryKey Entity(tableName missed_calls) data class MissedCall( PrimaryKey(autoGenerate true) val id: Long 0, val number: String, val timestamp: Long )// 文件路径app/src/main/java/com/example/quickpickup/db/MissedCallDao.kt package com.example.quickpickup.db import androidx.room.Dao import androidx.room.Insert import androidx.room.Query Dao interface MissedCallDao { Insert suspend fun insert(missedCall: MissedCall) Query(SELECT * FROM missed_calls ORDER BY timestamp DESC) suspend fun getAll(): ListMissedCall Query(DELETE FROM missed_calls WHERE id :id) suspend fun deleteById(id: Long) }// 文件路径app/src/main/java/com/example/quickpickup/db/AppDatabase.kt package com.example.quickpickup.db import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase Database(entities [MissedCall::class], version 1, exportSchema false) abstract class AppDatabase : RoomDatabase() { abstract fun missedCallDao(): MissedCallDao companion object { Volatile private var INSTANCE: AppDatabase? null fun get(context: Context): AppDatabase { return INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, quick_pickup.db ).build().also { INSTANCE it } } } } }Room 数据库的使用能保证漏接记录的可靠性。即使应用进程被系统杀掉数据仍然持久化在本地重启应用后依然能看到历史漏接记录。4.7 悬浮窗提醒界面悬浮窗本质上是一个系统级 View使用WindowManager.addView()添加。// 文件路径app/src/main/java/com/example/quickpickup/ui/CallAlertDialog.kt package com.example.quickpickup.ui import android.content.Context import android.graphics.PixelFormat import android.os.Build import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.WindowManager import android.widget.TextView import com.example.quickpickup.R class CallAlertDialog( private val context: Context, private val number: String ) { private val windowManager context.getSystemService(Context.WINDOW_SERVICE) as WindowManager private var view: View? null fun show() { val inflater LayoutInflater.from(context) val dialogView inflater.inflate(R.layout.dialog_call_alert, null) val numberText dialogView.findViewByIdTextView(R.id.tv_number) numberText.text 来电号码$number val layoutParams WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT, if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY } else { Suppress(DEPRECATION) WindowManager.LayoutParams.TYPE_PHONE }, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, PixelFormat.TRANSLUCENT ).apply { gravity Gravity.TOP } view dialogView windowManager.addView(dialogView, layoutParams) dialogView.findViewByIdView(R.id.btn_close).setOnClickListener { dismiss() } } fun dismiss() { view?.let { windowManager.removeView(it) } view null } }对应的布局文件res/layout/dialog_call_alert.xml?xml version1.0 encodingutf-8? LinearLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:orientationvertical android:layout_widthmatch_parent android:layout_heightwrap_content android:background#E64A4A android:padding16dp TextView android:idid/tv_number android:layout_widthwrap_content android:layout_heightwrap_content android:text来电号码 android:textColor#FFFFFF android:textSize18sp android:textStylebold / Button android:idid/btn_close android:layout_widthwrap_content android:layout_heightwrap_content android:layout_marginTop8dp android:text知道了 / /LinearLayout悬浮窗显示时需要先检查Settings.canDrawOverlays()权限。如果未授权应跳转设置页引导用户开启。4.8 MainActivity权限申请在MainActivity中申请所有必要权限。示例代码如下// 文件路径app/src/main/java/com/example/quickpickup/MainActivity.kt package com.example.quickpickup import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings import android.widget.Button import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat class MainActivity : AppCompatActivity() { private val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { result - val allGranted result.values.all { it } if (allGranted) { Toast.makeText(this, 权限已授予, Toast.LENGTH_SHORT).show() } else { Toast.makeText(this, 部分权限未授予功能可能受限, Toast.LENGTH_SHORT).show() } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewByIdButton(R.id.btn_request_permission).setOnClickListener { requestNeededPermissions() } } private fun requestNeededPermissions() { val permissions mutableListOf( Manifest.permission.READ_PHONE_STATE, Manifest.permission.VIBRATE ) if (Build.VERSION.SDK_INT Build.VERSION_CODES.TIRAMISU) { permissions.add(Manifest.permission.POST_NOTIFICATIONS) } val requestList permissions.filter { ContextCompat.checkSelfPermission(this, it) ! PackageManager.PERMISSION_GRANTED } if (requestList.isEmpty()) { Toast.makeText(this, 权限都已授予, Toast.LENGTH_SHORT).show() } else { permissionLauncher.launch(requestList.toTypedArray()) } // 单独检查悬浮窗权限 if (!Settings.canDrawOverlays(this)) { val intent Intent( Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse(package:$packageName) ) startActivity(intent) } } }布局文件就放一个按钮避免内容过长占用篇幅读者按需补充即可。5. 运行验证5.1 准备测试方式因为没有真机双卡拨号条件时可以用以下方式测试另一台手机拨打电话最直接的测试方式。模拟器测试Android Studio 模拟器支持通过adb模拟电话状态。ADB 广播模拟手动发送电话状态广播适合调试广播接收逻辑。推荐使用 ADB 模拟因为速度和可控性都很好。模拟来电命令adb shell am broadcast -a android.intent.action.PHONE_STATE --es state RINGING --es incoming_number 13800138000模拟挂断命令adb shell am broadcast -a android.intent.action.PHONE_STATE --es state IDLE模拟接听命令adb shell am broadcast -a android.intent.action.PHONE_STATE --es state OFFHOOK但要注意adb shell am broadcast发送的是普通广播和真正的TelephonyManager系统广播还是存在差异。正式验证仍然建议使用真机互拨。5.2 验证流程步骤 1安装应用打开 MainActivity点击授权按钮授予全部权限并允许悬浮窗。步骤 2用另一台手机拨打测试手机号码。步骤 3观察以下结果测试手机顶部出现“有来电”通知屏幕上方出现红色悬浮窗显示“来电号码xxx”手机开始震动不接听等待对方挂断。步骤 4打开应用首页看到新增一条漏接记录包含号码和时间。6. 常见问题与适配6.1 READ_PHONE_STATE 权限回调但号码为空这个问题在 Android 10 及以上比较常见。问题现象常见原因解决思路能触发广播但incoming_number为空Android 10 对电话号码读取严格设备厂商修改权限策略提示用户授予“电话”权限结合READ_CALL_LOG提高获取概率对未知号码存为空字符串startForegroundService崩溃服务启动后未及时调用startForeground()在onStartCommand中第一时间调用悬浮窗不显示未授予SYSTEM_ALERT_WINDOW权限部分国产 ROM 自动清理跳转设置页手动授权引导用户加入电池白名单手机休眠后收不到广播系统将应用进程冻结使用前台服务并显示常驻通知建议用户开启自启动权限6.2 检查清单如果你的来电提醒没有生效按以下顺序排查是否授予READ_PHONE_STATE权限广播接收器是否成功注册可通过日志确认服务是否成功启动通知栏是否有常驻通知是否为 Android 10号码为空是否影响核心功能悬浮窗权限是否开启应用是否被系统厂商加入“省电优化”或“后台冻结”名单6.3 国产 ROM 适配备注部分国产系统对后台启动服务、悬浮窗、自启动都有额外限制。如果面向国内用户发布需要在应用内提供引导页帮助用户完成开机自启动、电池优化白名单、悬浮窗授权等设置。7. 最佳实践与工程建议7.1 状态跟踪务必放在 Receiver 外部前文代码中状态逻辑放在PhoneStateReceiver内部。但广播接收器在onReceive()后并不会被立刻销毁只是不能保证长时间存活。如果系统内存紧张进程被杀状态跟踪变量就会丢失导致后续漏接判断不准确。更稳妥的做法是把状态保存到SharedPreferences或单例对象中或者利用onReceive()的goAsync()延长执行时间。对于不追求百分百准确率的应用来说内存变量已经足够但如果要做得严谨建议持久化状态。7.2 服务启动要区分前后台startForegroundService()和startService()在 8.0 以上区别明显。在不明确应用是否处于前台时统一使用startForegroundService()并尽快startForeground()是相对稳妥的方案。7.3 记录去重同一个来电可能会产生多条漏接记录。比如用户拒接、对方再拨或者系统状态广播重复发送都会导致重复记录。建议在插入数据库前做一次去重判断使用number timestamp窗口来判断。7.4 通知渠道要分级不要把所有通知都放在同一个渠道。来电提示使用高重要性渠道漏接记录汇总使用默认渠道这样用户可以在系统设置中分别控制。7.5 权限引导要足够清晰权限说明文案要明确告知用户为什么要这个权限。例如为什么需要READ_PHONE_STATE为了感知来电状态为什么需要悬浮窗为了将来电提醒显示在屏幕最上层为什么需要通知权限为了保证服务常驻和提示可见。模糊的文案会导致用户拒绝授权影响整体体验。7.6 注意 Android 14 与后续版本适配从 Android 14 开始前台服务类型变得更加严格。如果你把 targetSdk 升级到 34必须为specialUse类型在清单中附带property声明用途说明。如果不使用前台服务可以考虑使用WorkManager或NotificationManager的替代方案但实时性会有所下降。8. 总结本文以一个非常贴近日常的场景“快点接电话啦”为切入点实现了完整的来电提醒与漏接记录 Android 工具。核心内容包括通过TelephonyManager.ACTION_PHONE_STATE_CHANGED动态监听电话状态使用广播接收器解析来电号码与状态流转通过前台服务执行悬浮窗弹窗、震动与通知提醒使用 Room 保存漏接记录确保数据不丢失完成权限申请与 Android 版本适配。读者在实现类似功能时要特别关注权限限制、广播接收器的生命周期、服务启动方式和国内 ROM 的后台限制。建议先在模拟器上用 ADB 模拟电话状态跑通逻辑再用真机互拨验证实际体验。下一步你可以在这个基础上扩展加入联系人匹配显示姓名添加勿扰时间段夜间自动静默支持导出漏接记录为 CSV或者接入语音播报把来电号码读出来。这样“快点接电话”就不只是一句提醒而是一个能够真正帮助用户不错过重要通话的贴心工具。
返回列表