你有没有过这种经历:手机放在会议桌上,开了静音,客户的电话打进来,屏幕亮了又灭,等会议结束只能看着未接来电叹气。又或者手机在包里充电,快递员连打三次,你一次也没听见,最后包裹被扔进了驿站。问题在于,手机明明就在身边,你却错过了最不该错过的电话。
这篇文章要实现的,不是“如何提高接电话效率”这种软技能,而是一套能在 Android 手机上直接运行的“来电提醒助手”:实时监听来电状态、识别来电号码、触发铃声震动和高优先级通知,还可以把来电信息推送到电脑副屏或智能家居面板。用户不需要盯着手机屏幕,就能第一时间知道有电话打进来。
我的判断是:这个项目的技术门槛并不高,真正的难点集中在三处——Android 权限模型、各版本 API 差异、后台存活策略。把这三处理解透,你不仅能写出来电提醒,还能把同样思路迁移到通话记录统计、防骚扰号码识别、老人机大字提醒、车载来电联动等场景。
本文会从来电监听原理讲起,然后给出可在 Android Studio 中直接运行的完整代码,最后分享实际工程中最容易踩的坑和最佳实践。
1. 这个项目解决的到底是什么问题
1.1 先看三个真实场景
场景 A:会议静音。你把手机倒扣在会议桌上,客户电话进来,屏幕亮起但没有任何声音。等会议结束,对方已经找了别人。
场景 B:手机放包里。你在超市排队结账,手机在包里震动,隔着几层布料根本感觉不到。快递员打了三个电话,你一个都没接,最终包裹被放进了驿站。
场景 C:手机在另一个房间。在家做家务或临时走开,来电铃声响起时你已经来不及跑回去,等到了手机旁,电话已经挂断。
这三个场景的共同点是:提醒强度与事件重要性不匹配。系统默认的响铃、震动、弹窗,在特定环境下会被静音、被遮挡、被忽略。而用户真正需要的,是在来电发生的同一时刻,用铃声、震动、通知、甚至另一块屏幕上的大字提示,把所有注意力拉回到这通电话上。
1.2 手机自带方案为什么不够
很多人会问:手机不是自带“勿扰模式”和“专注模式”吗?确实,多数手机允许设置“收藏联系人呼入”“重复来电提醒”等规则。但问题在于,这些规则是系统级的、封闭的,你无法定制“仅在某个时间段强提醒”“把来电号码推送到电脑”“用自定义铃声循环震动”这类需求。
厂商自带的“拿起铃声增强”“翻转静音”等私有功能,则存在严重的碎片化问题。同一部手机换了品牌,规则就全变了。
自制方案的优势在于:规则统一、场景联动、提醒形式可自定义。你可以把来电事件变成一条可编程的信息流,铃声、震动、通知、网络推送都由自己控制。
1.3 谁适合读这篇文章
如果你属于以下任意一类,本文的内容会非常实用:
- Android 应用开发者,尤其是刚开始接触广播、权限、通知机制的初学者。
- 想做智能家居/桌面副屏联动,希望把手机来电事件推送到其他设备的开发者。
- 想为老人或儿童定制“大字提醒”“强提醒”手机体验的个人开发者。
- 对 Android 系统电话机制感兴趣,想弄清 TelephonyManager 和 PhoneStateListener 背后原理的人。
2. 来电监听的核心原理与版本差异
2.1 电话状态机
Android 系统把通话过程抽象为一个简单的状态机,由 TelephonyManager 统一管理:
- CALL_STATE_IDLE:空闲状态,没有通话。
- CALL_STATE_RINGING:来电响铃状态,电话尚未接听。
- CALL_STATE_OFFHOOK:摘机状态,电话已接通,或正在拨号。
应用需要做的,就是监听状态变化:当状态从 IDLE 变成 RINGING,说明有来电;当状态变成 OFFHOOK,说明用户已经接听;当状态变回 IDLE,说明通话结束。这是一个典型的事件驱动场景,非常适合用系统回调或广播来处理。
这里真正容易踩坑的地方是:很多初学者只处理了 RINGING 状态,却没有处理 OFFHOOK 和 IDLE。结果来电提醒响起后,用户已经接通电话,铃声还在响,体验非常糟糕。正确的做法是在三个状态之间完整切换,并在接通或挂断后立刻停止提醒。
2.2 获取来电状态的两种路径
Android 提供了两条获取来电状态的路径:
| 路径 | 使用方式 | 优点 | 缺点 |
|---|---|---|---|
| 广播 ACTION_PHONE_STATE_CHANGED | 通过 BroadcastReceiver 接收系统广播 | 代码简单,可静态注册,适合快速跑通 | 高版本系统对隐式广播有限制,部分 ROM 上不稳定 |
| PhoneStateListener / registerTelephonyCallback | 调用 TelephonyManager 注册监听回调 | 实时性高,回调携带完整状态,适合长期监听 | 需要动态注册,代码稍复杂 |
从工程角度看,更推荐使用 PhoneStateListener 或新版 registerTelephonyCallback。原因很简单:广播方案虽然代码少,但在 Android 8.0 之后,系统对隐式广播和后台执行的限制越来越多,静态注册的广播接收器在进程被杀后可能无法及时唤醒。而动态注册的监听回调,只要进程存活就能实时收到状态变化。
2.3 权限模型的变化
来电监听涉及几个关键权限,这些权限在不同 Android 版本上的要求不同:
- READ_PHONE_STATE:读取电话状态的必需权限。没有它,PhoneStateListener 和 PHONE_STATE 广播都收不到。
- READ_CALL_LOG:在高版本系统上,想要读取来电号码,通常需要这个更敏感的权限,或者成为系统默认电话应用。
- POST_NOTIFICATIONS:Android 13(API 33)开始,通知权限变为运行时权限,必须动态申请。
- USE_FULL_SCREEN_INTENT:如果希望像来电一样全屏弹出提醒,在 Android 14(API 34)上,这个权限对于非通话类应用默认关闭,申请门槛很高。
这意味着,在 2024 年及以后维护这个项目的正确姿势是:先申请危险权限,再动态注册监听器,最后通过高优先级通知渠道做提醒。不要指望一个静态广播就能通吃所有版本。
3. 环境准备与项目搭建
3.1 开发环境
本文的示例基于 Android Studio 开发环境。创建项目时,你可以参考以下建议:
- JDK 版本:以当前 Android Studio 默认配置为准,通常为 JDK 17。
- compileSdk / targetSdk:建议使用 Android Studio 当前推荐的稳定版本。本文示例按 targetSdk 34 编写。
- minSdk:建议设置为 26 或更高。这样可以使用 NotificationChannel 并简化兼容判断。
- 真机调试:来电监听涉及系统电话服务,模拟器虽然部分支持,但真机测试更可靠。
3.2 新建工程
在 Android Studio 中创建一个新的 Empty Views Activity 项目,语言选择 Java。项目名可以叫 IncomingCallReminder,包名使用 com.example.incomingcallreminder。
创建完成后,项目结构类似:
IncomingCallReminder/ ├── app/ │ ├── src/ │ │ ├── main/ │ │ │ ├── java/com/example/incomingcallreminder/ │ │ │ ├── res/ │ │ │ └── AndroidManifest.xml │ └── build.gradle └── build.gradle3.3 依赖配置
在 app/build.gradle 中,确保包含 AppCompat 和 AndroidX Core 依赖,用于权限申请和通知构建:
android { namespace 'com.example.incomingcallreminder' compileSdk 34 defaultConfig { applicationId "com.example.incomingcallreminder" minSdk 26 targetSdk 34 versionCode 1 versionName "1.0" } } dependencies { implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'androidx.core:core:1.13.1' }版本号只是示例,实际创建项目时,Android Studio 生成的模板会给出可用的版本组合。本文重点演示的是代码逻辑,而不是锁定某个具体版本。
4. 权限声明与动态申请
4.1 AndroidManifest.xml 权限声明
打开 app/src/main/AndroidManifest.xml,加入以下权限:
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_CALL_LOG" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:label="来电提醒助手" android:theme="@style/Theme.AppCompat.DayNight"> <activity android:name=".MainActivity" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <receiver android:name=".PhoneStateReceiver" android:exported="true"> <intent-filter> <action android:name="android.intent.action.PHONE_STATE" /> </intent-filter> </receiver> </application> </manifest>需要说明的是,READ_CALL_LOG 是一个敏感权限。如果你只需要“有电话进来”这个信息,不要求显示具体号码,可以不申请它。如果你确实需要号码,就要在申请时向用户解释清楚用途,否则应用商店审核和用户体验都会出问题。
4.2 动态申请权限
在 Android 6.0 之后,危险权限必须在运行时动态申请。以下代码放在 MainActivity 中:
package com.example.incomingcallreminder; import android.Manifest; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import java.util.ArrayList; import java.util.List; public class MainActivity extends AppCompatActivity { private final ActivityResultLauncher<String[]> permissionLauncher = registerForActivityResult( new ActivityResultContracts.RequestMultiplePermissions(), result -> { boolean phoneState = Boolean.TRUE.equals( result.get(Manifest.permission.READ_PHONE_STATE)); if (phoneState) { Toast.makeText(this, "来电监听权限已开启", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(this, "没有权限将无法监听来电", Toast.LENGTH_LONG).show(); } }); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); requestNeededPermissions(); } private void requestNeededPermissions() { List<String> permissions = new ArrayList<>(); if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.READ_PHONE_STATE); } if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.READ_CALL_LOG); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { permissions.add(Manifest.permission.POST_NOTIFICATIONS); } if (!permissions.isEmpty()) { permissionLauncher.launch(permissions.toArray(new String[0])); } } }这个动态申请逻辑覆盖了两个容易忽略的点:一是 READ_CALL_LOG 作为可选权限单独处理;二是 Android 13 及以上才需要动态申请通知权限,低版本不处理也不会崩溃。
5. 核心代码实现:从监听、强提醒到副屏推送
5.1 方案一:静态注册广播接收器
先给一个最快跑通的方案:静态广播接收器。这种方式代码最少,适合验证来电状态机和权限是否配置正确。
package com.example.incomingcallreminder; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.telephony.TelephonyManager; import android.util.Log; public class PhoneStateReceiver extends BroadcastReceiver { private static final String TAG = "CallReminderTag"; @Override public void onReceive(Context context, Intent intent) { if (!Intent.ACTION_PHONE_STATE_CHANGED.equals(intent.getAction())) { return; } String state = intent.getStringExtra(TelephonyManager.EXTRA_STATE); String incomingNumber = intent.getStringExtra(TelephonyManager.EXTRA_INCOMING_NUMBER); Log.d(TAG, "state=" + state + ", number=" + incomingNumber); if (TelephonyManager.EXTRA_STATE_RINGING.equals(state)) { CallReminderHelper.startRemind(context, incomingNumber); } else if (TelephonyManager.EXTRA_STATE_IDLE.equals(state) || TelephonyManager.EXTRA_STATE_OFFHOOK.equals(state)) { CallReminderHelper.stopRemind(context); } } }这段代码逻辑很直白:收到系统广播后,判断是响铃、空闲还是摘机。RINGING 时启动提醒,IDLE 或 OFFHOOK 时停止提醒。
如果只做学习验证,用这个方案就够了。但要注意,Android 高版本对静态广播的限制比较多,部分厂商 ROM 上可能收不到,或者进程被杀后无法唤醒。所以生产级项目更推荐方案二。
5.2 方案二:动态注册 PhoneStateListener
动态注册方式更可靠,也是生产环境更推荐的做法。新建 CallMonitor.java:
package com.example.incomingcallreminder; import android.Manifest; import android.content.Context; import android.content.pm.PackageManager; import android.os.Build; import android.telephony.PhoneStateListener; import android.telephony.TelephonyManager; import android.util.Log; import androidx.core.content.ContextCompat; import java.util.concurrent.Executor; public class CallMonitor { private static final String TAG = "CallReminderTag"; private TelephonyManager telephonyManager; private PhoneStateListener phoneStateListener; private Executor executor; public void start(Context context) { telephonyManager = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); if (telephonyManager == null) { Log.e(TAG, "TelephonyManager is null"); return; } boolean hasPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED; if (!hasPermission) { Log.e(TAG, "READ_PHONE_STATE permission not granted"); return; } executor = ContextCompat.getMainExecutor(context); phoneStateListener = new PhoneStateListener() { @Override public void onCallStateChanged(int state, String incomingNumber) { Log.d(TAG, "onCallStateChanged state=" + state + ", number=" + incomingNumber); switch (state) { case TelephonyManager.CALL_STATE_RINGING: CallReminderHelper.startRemind(context, incomingNumber); break; case TelephonyManager.CALL_STATE_IDLE: case TelephonyManager.CALL_STATE_OFFHOOK: CallReminderHelper.stopRemind(context); break; default: break; } } }; // Android 12(API 31)开始推荐 registerTelephonyCallback if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { telephonyManager.registerTelephonyCallback(executor, phoneStateListener); } else { telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE); } } public void stop() { if (telephonyManager == null || phoneStateListener == null) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { telephonyManager.unregisterTelephonyCallback(phoneStateListener); } else { telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE); } phoneStateListener = null; } }这段代码包含一个非常重要的版本适配点:Android 12(API 31)开始,系统推荐使用 registerTelephonyCallback 替代旧的 listen 方法。注销时,新接口用 unregisterTelephonyCallback,旧接口则必须传 LISTEN_NONE,两者不能混用。不少老项目升级 targetSdk 后回调失效,就是死在这个细节上。
5.3 强提醒:铃声、震动、通知
来电提醒的核心价值在于“强”字。这里用三重手段保证用户一定能感知到。
package com.example.incomingcallreminder; import android.content.Context; import android.media.MediaPlayer; import android.media.RingtoneManager; import android.net.Uri; import android.os.Build; import android.os.VibrationEffect; import android.os.Vibrator; import android.util.Log; public class CallReminderHelper { private static final String TAG = "CallReminderTag"; private static MediaPlayer mediaPlayer; private static Vibrator vibrator; public static void startRemind(Context context, String incomingNumber) { stopRemind(context); // 1. 播放铃声,循环播放,直到用户接听或挂断 Uri ringUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); try { mediaPlayer = MediaPlayer.create(context, ringUri); if (mediaPlayer != null) { mediaPlayer.setLooping(true); mediaPlayer.start(); } } catch (Exception e) { Log.e(TAG, "play ring failed", e); } // 2. 震动 vibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); if (vibrator != null && vibrator.hasVibrator()) { long[] pattern = {0, 600, 300, 600}; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { vibrator.vibrate(VibrationEffect.createWaveform(pattern, 0)); } else { vibrator.vibrate(pattern, 0); } } // 3. 高优先级通知 NotificationHelper.showCallNotification(context, incomingNumber); } public static void stopRemind(Context context) { if (mediaPlayer != null) { mediaPlayer.stop(); mediaPlayer.release(); mediaPlayer = null; } if (vibrator != null) { vibrator.cancel(); vibrator = null; } NotificationHelper.cancelCallNotification(context); } }这里真正容易踩坑的地方有三个:一是 MediaPlayer.create 可能返回 null,比如默认铃声 Uri 在某些定制 ROM 上不可用,所以必须判空;二是提醒开始前必须先 stopRemind,避免上一个来电的铃声和震动没有释放,导致声音叠加;三是震动在 Android 8.0 之后必须使用 VibrationEffect,否则走老接口。
5.4 通知渠道与高优先级通知
通知是锁屏状态下最有效的提醒方式。为了让通知能直接在锁屏亮屏展示,需要配置一个高优先级通知渠道。
package com.example.incomingcallreminder; import android.app.NotificationManager; import android.content.Context; import android.os.Build; import androidx.core.app.NotificationCompat; public class NotificationHelper { private static final String CHANNEL_ID = "incoming_call_reminder"; private static final int NOTIFICATION_ID = 1001; public static void showCallNotification(Context context, String phoneNumber) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (manager == null) { return; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel channel = new NotificationChannel( CHANNEL_ID, "来电提醒", NotificationManager.IMPORTANCE_HIGH ); channel.setDescription("收到来电时进行强提醒"); manager.createNotificationChannel(channel); } Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle("有电话打进来") .setContentText("号码:" + (phoneNumber == null ? "未知号码" : phoneNumber)) .setCategory(NotificationCompat.CATEGORY_CALL) .setPriority(NotificationCompat.PRIORITY_HIGH) .setOngoing(true) .build(); manager.notify(NOTIFICATION_ID, notification); } public static void cancelCallNotification(Context context) { NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (manager != null) { manager.cancel(NOTIFICATION_ID); } } }通知渠道的 IMPORTANCE_HIGH 决定通知会弹出横幅并发出提示音。setOngoing(true) 表示通知不可被用户手动滑动删除,必须等通话结束由代码取消,这样能避免用户误滑导致提醒消失。
5.5 高级玩法:来电信息推送到副屏
如果只是手机本身提醒,还远没有发挥这个项目的价值。来电信息完全可以推送到电脑副屏、树莓派显示器或智能家居面板,让你在另一个房间也能一眼看到谁在打电话。
推送端用 HttpURLConnection 就够了,不需要额外引入网络库:
package com.example.incomingcallreminder; import android.os.StandardCharsets; import android.util.Log; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; public class PushClient { private static final String TAG = "CallReminderTag"; public static void push(final String targetUrl, final String phoneNumber) { new Thread(() -> { HttpURLConnection conn = null; try { URL url = new URL(targetUrl); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); conn.setDoOutput(true); conn.setConnectTimeout(3000); conn.setReadTimeout(3000); String body = "{\"phone\":\"" + phoneNumber + "\"}"; OutputStream os = conn.getOutputStream(); os.write(body.getBytes(StandardCharsets.UTF_8)); os.flush(); os.close(); int code = conn.getResponseCode(); Log.d(TAG, "push result code=" + code); } catch (Exception e) { Log.e(TAG, "push failed", e); } finally { if (conn != null) { conn.disconnect(); } } }).start(); } }接收端可以是一个运行在电脑上的极简 Python 服务。安装 Flask 后,启动一个本地 HTTP 接口:
from flask import Flask, request, jsonify app = Flask(__name__) @app.route("/incoming_call", methods=["POST"]) def incoming_call(): data = request.get_json() phone = data.get("phone", "unknown") print("来电号码:", phone) return jsonify({"status": "ok"}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)在 CallReminderHelper.startRemind 中增加一行推送调用,就完成了手机到副屏的联动:
PushClient.push("http://你的电脑IP:5000/incoming_call", incomingNumber);需要注意,这个接口不要暴露到公网,仅限局域网使用,否则任何人都可以向你的面板推送假来电信息。
6. 运行结果与验证方法
6.1 模拟器验证
Android Studio 模拟器支持模拟来电。启动 app 后,在终端执行:
adb emu gsm call 13800138000模拟器会进入来电响铃状态,此时应用应当触发提醒。通话结束后执行:
adb emu gsm cancel 13800138000提醒应当停止,通知应当消失。不过模拟器对电话服务的支持并不完整,部分模拟器镜像可能收不到 PHONE_STATE 广播,所以如果模拟器验证失败,优先换真机。
6.2 真机验证
真机测试是最可靠的。注意两点:一是必须在系统设置中允许应用读取电话状态;二是有些国产 ROM 默认关闭应用的“后台弹出界面”“自启动”等选项,需要在应用信息页面手动打开。
测试路径建议:
- 安装应用,点击打开,授权电话状态、通知等权限。
- 回到桌面,使用另一台手机拨打测试号码。
- 观察是否弹出通知、响起铃声、震动。
- 接听或挂断后,确认提醒和通知自动消失。
6.3 通过日志判断
如果某个环节没有生效,第一时间查看日志:
adb logcat -s CallReminderTag预期输出类似:
D/CallReminderTag: state=RINGING, number=13800138000 D/CallReminderTag: onCallStateChanged state=1, number=13800138000 D/CallReminderTag: push result code=200 D/CallReminderTag: onCallStateChanged state=0, number=13800138000如果只看到 RINGING 没有看到后续状态,说明 OFFHOOK 或 IDLE 分支没有覆盖;如果 state 正常但 number 为空,则是号码读取权限的问题,下一章会专门讲。
7. 常见问题与排查思路
来电提醒项目看起来简单,实际运行中问题不少。下面这几个问题是我认为最有代表性的:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 完全收不到来电状态 | 没有授予 READ_PHONE_STATE 权限 | 设置 -> 应用 -> 权限 检查 | 动态申请权限并确认用户已授权 |
| 能收到状态但号码为空 | 高版本系统限制非默认电话应用读取号码 | 查看日志确认 incomingNumber | 申请 READ_CALL_LOG 或成为默认电话应用;号码为空时降级显示 |
| Android 13 上通知不显示 | 没有动态申请 POST_NOTIFICATIONS | 检查系统通知权限开关 | 在代码中动态申请通知权限 |
| 提醒响一下就被系统杀掉 | 后台进程被厂商 ROM 清理 | 查看设备是否有自启动限制 | 使用前台服务 + 引导用户加入电池白名单 |
| 铃声不循环或播放失败 | 默认铃声 Uri 在某些设备上不可用 | 检查 MediaPlayer.create 返回值 | 使用 res/raw 本地音频资源 |
| 接听/挂断后铃声仍在响 | OFFHOOK/IDLE 状态未正确分支 | 看日志确认状态回调是否触发 | 补充完整状态机分支,停止提醒 |
逐个展开说明。
7.1 收不到来电广播或回调
这是最常见的问题,原因通常是权限没给全。很多初学者只在 Manifest 里写了权限,却没有在运行时动态申请。Android 6.0 之后这种做法行不通。另一个原因是部分厂商 ROM 对后台应用限制严格,应用回到桌面后进程被冻结,这时需要在系统设置中允许应用后台运行。
7.2 来电号码为空
Android 高版本对号码的读取越来越保守。从 Android 9 开始,普通应用仅凭 READ_PHONE_STATE 可能无法获取来电号码,需要 READ_CALL_LOG 权限,或者让应用成为系统默认电话应用。这里更稳妥的判断是:在申请 READ_CALL_LOG 之前,先想清楚产品是否真的需要号码。如果只是做“来电强提醒”,完全可以把号码显示为“未知号码”,降低权限敏感度。如果确实需要号码,请在权限申请弹窗中明确告知用途。
7.3 通知在 Android 13 上不弹
Android 13 把 POST_NOTIFICATIONS 变成了运行时权限。如果你的应用 targetSdk 是 33 及以上,却没有在运行时申请这个权限,用户会在系统设置里看到通知权限处于关闭状态。解决方法已经写在 MainActivity 的动态申请逻辑里,关键是要在申请时判断 Build.VERSION.SDK_INT >= TIRAMISU。
7.4 应用退到后台后被清理
通知、铃声弹不出来,最大的嫌疑是进程被杀。国产 ROM 的后台管理策略非常激进,要从两个方向解决:一是把来电监听放到前台服务中,提高进程优先级;二是引导用户在系统设置中关闭电池优化和白名单限制。更进一步的方案,是在应用内主动检测并跳转到对应的系统设置页。
7.5 提醒无法自动停止
提醒开始后,如果用户接听电话,但铃声还在响,说明状态机只处理了 RINGING。必须确认 OFFHOOK 和 IDLE 分支都调用了 stopRemind。另外要注意,OFFHOOK 不仅代表接听,也可能代表正在拨号,所以在自己的拨号场景下也要能正确停止提醒。
8. 最佳实践与工程建议
8.1 权限最小化与隐私合规
来电提醒涉及电话状态这类敏感能力,使用时要格外克制。我的建议是:只申请必要权限,动态申请时向用户解释每一项的用途。如果能不申请 READ_CALL_LOG,就不要申请;如果只是做提醒,“未知号码”也能完成核心功能。更重要的是,普通第三方应用在 Android 系统上根本没有机会窃听通话内容,那些宣称“通话录音/通话监听”的应用要么是诱导用户授权,要么就是借助无障碍服务做越界操作,这类做法风险极高,不建议触碰。
8.2 不要破坏系统提醒链路
来电提醒应用很容易做得“用力过猛”:强制前台服务、隐藏通知渠道、申请系统悬浮窗、直接跳转系统设置。这些操作短期看提升了提醒成功率,长期看会带来两个问题:一是应用商店审核风险,二是用户信任度下降。合理的做法是建立清晰的提醒层级:高优先级通知作为基础,铃声震动作为补充,副屏推送作为扩展。用户能自己决定是否开启每一项。
8.3 版本适配清单
维护一个长期项目,版本适配是绕不开的工作。建议在项目 README 中维护一份检查清单:
- minSdk 26 以下时,需要同时兼容旧版 Notification.Builder 和 NotificationChannel。
- Android 12 及以上,优先使用 registerTelephonyCallback,否则 listen 方法可能被标记废弃。
- Android 13 及以上,动态申请 POST_NOTIFICATIONS 权限。
- Android 14 及以上,谨慎使用 USE_FULL_SCREEN_INTENT,该权限对普通应用默认关闭。
- 国产 ROM 需要适配自启动、电池优化、后台弹出界面等厂商设置。
8.4 自动接听的合规边界
有的读者可能会问:能不能做成“自动接听”,让来电没有铃声就直接接通?从技术上讲,早期方案确实有通过反射调用 ITelephony.answerRingingCall 的做法,但 Android 8.0 之后系统权限收紧,普通应用很难再静默自动接听。现在常见的实现方式是借助无障碍服务模拟点击接听按钮,但这类方案必须向用户完整展示无障碍服务的用途,而且应用商店审核会非常严格。
更稳妥的产品路径是“强提醒 + 一步接听”:来电瞬间让提醒到达用户,用户通过通知点击直接进入接听界面。这既保证了用户体验,又避开了系统权限的深水区。
8.5 耗电与资源释放
来电提醒本身是一个非常省电的场景,因为它是事件驱动,不是轮询。唯一的性能风险在于资源释放。例如 MediaPlayer 使用后没有 release 会导致文件句柄泄漏,震动没有 cancel 会导致后续震动冲突,通知没有 cancel 会导致通知栏残留。我建议把资源释放逻辑收敛到一个方法里,每次提醒开始前先调用一次停止逻辑,形成“释放再创建”的固定顺序。这样无论状态机走到哪个分支,都不会出现资源叠加。
9. 总结与后续扩展方向
“快点接电话啦”这个小项目,看起来只是一个简单工具,但它覆盖的知识面相当典型:广播与回调、权限模型、通知渠道、后台存活、版本差异、网络推送、隐私边界。把这一套逻辑跑通,你对 Android 系统如何管理“电话”这个核心能力会有很具体的认识。
建议你先从最小链路开始:申请权限、动态注册监听、铃声震动通知。跑通之后再加副屏推送,再做来电历史记录。之后可以考虑接入开源防骚扰库识别高频骚扰号码,或者把界面改成适合老人的大字版本。
实际上,来电提醒只是“系统事件识别与联动”的一个入口。同样的思路可以扩展到短信验证码提取、日历日程提醒、低电量告警推送等场景。把事件当作信号源,把通知和推送当作输出通道,你就能在 Android 上搭建属于自己的自动化通知系统。先解决“能不能收到”,再解决“好不好用”,这是整个项目最稳妥的推进顺序。