简介:面向Android开发者的电话拨号器源码包,提供了完整的电话应用实现,主要适用于移动开发或系统定制的中高级工程师,可用于分析拨号器架构、自定义拨号键盘或优化通话流程。资源共56个文件,以3个Java源码、20个XML布局/配置、11个class编译文件为核心,搭配PNG图片资源、android-support-v4.jar依赖库、proguard混淆配置及Eclipse工程文件,压缩后仅875KB,目录结构包含src、res、libs、assets等标准模块,便于直接导入分析。源码具体覆盖拨号键盘UI设计、DialerActivity拨号逻辑(按键监听、号码识别、调用系统API发起呼叫)、联系人通过ContentProvider与Cursor同步管理、权限动态申请、基于InCallService的来电去电状态交互,并给出异步任务处理耗时操作、内存管理优化、触摸动画与VoIP弱网拨号等场景下的示例代码。目前已有324人学习讨论,对于希望深入Android系统服务交互、性能调优或开发特色拨号应用的开发者,是一份高价值的参考材料。
1. 电话拨号器就是 Android 四大组件教学的最佳切片
拿到一个「Android的电话拨号器源码.zip」,九成是从课设、毕设或培训班存货里翻出来的老工程:一个MainActivity、一块EditText、两个Button,点一下就Intent.ACTION_CALL,再配一个CALL_PHONE权限。这套东西在 Android 4.x 时代能直接跑,放到 Android 14 上大概率一进页面就崩,或者点了没反应。原因很简单:拨号器虽然是个"小项目",但它把 Intent 显示跳转、危险权限运行时申请、版本兼容、厂商 ROM 差异全撞上了。这篇不聊这个 zip 里有什么——因为各地的 zip 内容差异很大,有的还带lib目录,有的连res/layout都缺——而是把"你自己重写一个能用的拨号器"所需要的完整路径讲清楚:原理、最小工程、号码处理、厂商适配和验证收尾。适合正被课设 deadline 追着的人,也适合想搞明白ACTION_DIAL与ACTION_CALL边界的中级开发。
2. 电话拨号器的地基:两个 Intent 的分工与权限边界
2.1 为什么会有ACTION_DIAL和ACTION_CALL两个入口
电话拨号器的核心不是 UI,而是用 Intent 把"拨号"这件事交给系统。Android 提供了两条路:Intent.ACTION_DIAL和Intent.ACTION_CALL。两者的差异并不是"能不能拨通",而是"谁说了算"。
ACTION_DIAL只做一件事:把号码交给系统默认的拨号界面,让用户决定要不要拨出去。它不需要任何权限,因为你的应用根本没有发起电话,只是打开了一个带号码的拨号面板。ACTION_CALL则是直接发起呼叫,跳过确认界面,因此必须持有android.permission.CALL_PHONE权限且通过运行时检查。
// 方案 A:打开系统拨号盘,用户手动点击拨号键 Uri telUri = Uri.parse("tel:" + number); Intent dialIntent = new Intent(Intent.ACTION_DIAL, telUri); startActivity(dialIntent);// 方案 B:直接拨号,需要 CALL_PHONE 权限 Uri telUri = Uri.parse("tel:" + number); Intent callIntent = new Intent(Intent.ACTION_CALL, telUri); startActivity(callIntent);两个方案在代码上只有一个单词之差,但行为差异明显。方案 A 适合"保存联系人后回拨"、"历史记录回听"这类低风险场景;方案 B 适合"紧急呼叫"、"车载模式"这类需要一步到位的场景。我一般会在项目里同时保留两个按钮,一个叫"拨打",一个叫"预览",既演示了权限边界,也避免被安全审查卡住。
2.2CALL_PHONE权限在 Android 6.0 之后的申请姿势
很多老源码里只有<uses-permission android:name="android.permission.CALL_PHONE" />,然后在AndroidManifest.xml里完事。这在 targetSdk 22 及以下没问题,一旦 targetSdk 升到 23 以上,CALL_PHONE属于危险权限组PHONE,必须在运行时动态申请。
申请流程的标准套路是:先检查ContextCompat.checkSelfPermission,未授权就调ActivityCompat.requestPermissions,然后在onRequestPermissionsResult回调里看用户的决定。注意不要在这里直接开始拨号——用户点击授权按钮后,回调时机是确定的,但在回调里做 UI 操作时要判断Activity.isFinishing(),否则用户快速退出页面会闪退。
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CALL_PHONE}, REQ_CALL_PHONE); } else { doCall(number); // 已授权,直接拨号 }REQ_CALL_PHONE是自己定义的请求码,只要不与页面里其他请求码冲突即可。注意"拒绝过一次"和"勾选了不再询问"是两种状态,标准的做法是在申请前用shouldShowRequestPermissionRationale判断——这个方法在用户拒绝过一次之后会返回 true,可以借机弹一个说明对话框解释"为什么要打电话权限",而不是直接再次弹系统弹窗。
2.3 拨号器在不同 Android 版本上的行为差异
这部分直接决定你的项目能活多久。Android 10(API 29)之前,ACTION_CALL在绝大多数设备上都能直接拉起通话界面。Android 10 开始,后台应用发起的ACTION_CALL会被系统拦截,除非你的应用是默认拨号器或者处于前台。Android 11(API 30)引入了包可见性限制,queryIntentActivities去探测"哪个应用能处理拨号"会返回空列表——这个问题经常出现在老代码里,表现在"点了拨号没反应但不报错"。
| 版本 | 关键变化 | 对拨号器的影响 |
|---|---|---|
| Android 6.0 | 运行时权限机制 | 必须动态申请CALL_PHONE |
| Android 10 | 后台拨号限制 | 从后台启动ACTION_CALL会被忽略 |
| Android 11 | 包可见性 | queryIntentActivities需要<queries>声明 |
常见做法是:能用ACTION_DIAL就绝不用ACTION_CALL。不要觉得"直接拨号"更高级,在 Android 10 以上,ACTION_DIAL依然是零权限、零限制;而ACTION_CALL一旦被用户拒绝过权限,体验反而更差。
3. 用 Android Studio 从零搭一个可用的电话拨号器
3.1 项目骨架与布局文件
如果手头的 zip 里是一个 Eclipse 工程(包结构是src/加gen/,没有 Gradle 脚本),别想着"兼容"它,直接把MainActivity.java和res/layout里的 XML 拷贝到新工程里重新组织就好。新版 Android Studio 里建一个Empty Views Activity,空模板自带activity_main.xml与MainActivity.java,语言选 Java 最接近老源码,改造阻力最小。
布局文件里要有三样东西:一个EditText接收号码,一个"拨号"按钮触发ACTION_CALL,一个"预览"按钮触发ACTION_DIAL。不要重复造轮子去做数字键盘——那是自寻烦恼,EditText设置android:inputType="phone"就能拿到拨号键盘。
<EditText android:id="@+id/et_number" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:hint="请输入电话号码" android:inputType="phone" /> <Button android:id="@+id/btn_call" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="直接拨打" /> <Button android:id="@+id/btn_dial" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="拨号面板" />android:inputType="phone"很关键:它会将软键盘切换为电话键盘,且EditText内部自动过滤部分非法字符——注意只是"部分",#、*、+仍然合法,这是后续号码处理要面对的坑。布局里的layout_weight让输入框占满剩余宽度,按钮固定在右侧,这是拨号器最常见的布局形态。
3.2 拨号逻辑与权限请求的完整实现
在MainActivity.java里,两个按钮的点击事件都指向同一个号码输入,但走不同的 Intent。直接拨号按钮要经过权限检查,权限通过后才真正发起ACTION_CALL。
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); EditText etNumber = findViewById(R.id.et_number); findViewById(R.id.btn_call).setOnClickListener(v -> { String number = etNumber.getText().toString().trim(); if (number.length() == 0) { Toast.makeText(this, "号码不能为空", Toast.LENGTH_SHORT).show(); return; } if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CALL_PHONE}, REQ_CALL); } else { call(number); } }); findViewById(R.id.btn_dial).setOnClickListener(v -> { String number = etNumber.getText().toString().trim(); if (number.length() == 0) { Toast.makeText(this, "号码不能为空", Toast.LENGTH_SHORT).show(); return; } dial(number); // ACTION_DIAL 不需要权限 }); } private void call(String number) { Intent intent = new Intent(Intent.ACTION_CALL, Uri.parse("tel:" + number)); startActivity(intent); } private void dial(String number) { Intent intent = new Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + number)); startActivity(intent); }这段代码把"权限未授予"和"已授予"两条路径分开了。注意trim()是必要的:用户在EditText里输入的号码可能存在末尾空格或换行,直接拼进tel:URI 里会导致无法解析。Uri.parse("tel:" + number)不会对非法字符做转义,所以#和*会原样进结果——这在拨打运营商服务号(比如*21*)时是对的,但如果你拿到的是用户粘贴的一段话里带着"转1"这样的文字,就会出问题。
3.3 清单文件里必须检查的三个点
AndroidManifest.xml在老源码里经常出错,尤其是从 Eclipse 时代迁移过来的项目。第一,<uses-permission>必须放在<application>标签之外,放在里面会被合并工具忽略;第二,CALL_PHONE的权限声明和READ_PHONE_STATE不要搞混,很多源码里抄了一堆用不上的权限;第三,Activity 声明里要有exported="true"且包含MAIN/LAUNCHER意图过滤器,否则应用装上后图标都看不到。
<uses-permission android:name="android.permission.CALL_PHONE" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name"> <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> </application>exported="true"是 Android 12(API 31)之后强制要求的,不写会在安装时直接报INSTALL_PARSE_FAILED_MANIFEST_MALFORMED。从老工程里拷贝的 Manifest 可能没有这个属性,编译报错时优先检查这里。
4. 把号码处理做成拨号器里最有信息量的部分
4.1 号码里的特殊字符:#、*、+与Uri.encode
拨号器与普通表单输入最大的不同是:号码不是纯数字。#在拨号 URI 里是"分机或操作符"的语义,*表示通配,+表示国际区号。直接拼接tel:URI 时,#在某些实现下会被当作 URI fragment 的起始符——这部分内容不会传给拨号应用,导致号码被截断。
常见的做法是先用Uri.encode对号码编码,再拼进tel:schema。要注意Uri.encode会把/变成%2F,但对#、*、+的处理各厂商不一致,甚至有PhoneNumberUtils这样的专门工具类可以调用。
String encodedNumber = Uri.encode(rawNumber, "+#*"); Uri uri = Uri.parse("tel:" + encodedNumber); Intent intent = new Intent(Intent.ACTION_CALL, uri);这段代码里的第二个参数"+#*"是"白名单":告诉Uri.encode这些字符不要转义。否则#会被编码成%23,部分老版本系统拨号器不识别的%23会把#当作普通字符处理,导致运营商服务码拨不出去。这个细节在测试中很容易被忽略——因为手动输入"10086"没问题,但一输入"2112345#"就静默失败。
4.2 双卡手机拨号:官方 API 的边界与厂商私有方案
多卡拨号是很多真机测试时才暴露的问题。Android 官方从 4.0 开始就没有提供"指定 SIM 卡拨号"的公开 API,ACTION_CALL只能弹出一个系统选择框,或者永远用默认卡拨出。SubscriptionManager能查到两张卡的信息,但setDefaultDataSubId这类方法在大多数 ROM 上被隐藏或需要系统权限,普通应用根本调用不到。
| 方案 | 可行性 | 风险 |
|---|---|---|
ACTION_CALL+ 默认卡 | 完全兼容 | 用户无法选卡 |
| 弹窗引导用户切换默认卡 | 部分 ROM 可用 | 步骤繁琐,用户体验差 |
反射调用ITelephony.call | 需系统签名 | 线上应用绝对不要用 |
| 华为/小米等厂商私有 API | 品牌限定 | 代码碎片化严重 |
所以常见做法是:做一个"选卡提示"的AlertDialog,告知用户"请将默认拨号卡切换为卡1",然后继续走ACTION_CALL。不做任何反射或隐藏 API 调用——那是 AOSP 源码里才该做的事,普通应用写了就是给自己埋雷。
4.3 用通话记录回拨:从 ContentResolver 到CallLog.Calls
拨号器源码里如果带了"最近通话"列表,那重点不在 UI,而在CallLog.Calls的查询。这个系统表可以读出通话类型(来电/去电/未接)、时间、号码和联系人姓名,不需要额外权限,但要注意 Android 10 之后查询呼入记录可能需要READ_CALL_LOG权限——这个权限也属于危险权限组,需要按运行时权限流程申请。
String[] projection = { CallLog.Calls._ID, CallLog.Calls.NUMBER, CallLog.Calls.CACHED_NAME, CallLog.Calls.TYPE, CallLog.Calls.DATE }; Cursor cursor = getContentResolver().query( CallLog.Calls.CONTENT_URI, projection, null, null, CallLog.Calls.DEFAULT_SORT_ORDER );CallLog.Calls.DEFAULT_SORT_ORDER默认按时间倒序排,所以第一个游标记录就是最近一条通话。这里有个参数容易写错:CallLog.Calls._ID与CallLog.Calls.NUMBER在部分厂商 ROM 上会返回空,原因是系统拨号器没有授予当前应用读取权限——遇到这种情况直接把NUMBER换成CallLog.Calls.PHONE_NUMBER(部分源码版本存在)再试。
5. 用 adb 把拨号流程做成自动化验证,再顺手加一个回拨快捷入口
很多项目做到能拨号就停了,但验收的时候老师或测试会用一台没连接 Android Studio 的手机装 APK,点两下说"没反应"。这时候最有效的验证手段反而不是 UI 点击,而是 adb 命令直接拉起拨号链路,把每一步的意图和数据打出来。
启动应用装好后先验证权限状态:
adb shell dumpsys package com.example.simple_dialer | grep -E "CALL_PHONE|granted"输出里如果看到CALL_PHONE: granted=false,说明权限没授权——直接跑拨号命令会被系统拒绝。接着用am start验证ACTION_CALL与ACTION_DIAL的恢复路径:
# 验证直接拨号(会弹系统权限框或因为未授权而失败) adb shell am start -a android.intent.action.CALL -d "tel:10086" # 验证无权限路径(打开系统拨号盘) adb shell am start -a android.intent.action.DIAL -d "tel:10086"如果ACTION_CALL启动后马上回退到桌面,大概率是权限问题或 Android 10 以上的后台限制;如果ACTION_DIAL也失败,检查 Manifest 里的exported属性和<queries>声明。在真机上跑这几条命令,比反复点击 UI 更高效,因为它能定位到"是权限阻塞还是 Intent 没被解析"。
验证收尾之后,可以给拨号器加一个小入口:一个"最近一次号码"的SharedPreferences存储 + 桌面长按快捷方式,免去每次打开应用输号码的步骤。这个改造只需要 30 行代码,却能把用户体验从"工具应用"变成"日常应用"。核心逻辑是:拨号成功后把号码写进偏好设置,桌面快捷方式读取并直接发起ACTION_DIAL。注意这里不要用ACTION_CALL,原因是快捷方式从桌面启动属于后台拉起拨号,Android 10 以上会被限制,而ACTION_DIAL永远安全。这样一个完整的拨号器才闭环——不仅会拨号,还知道什么时候该用手上最稳妥的入口。
本文还有配套的精品资源,点击获取