简介:一套Android示例工程,面向需要实现类似QQ、微信、米聊“按住说话”交互效果的开发者。项目围绕按钮按下与抬起时的图片切换,展示了事件监听、录音、动态界面反馈、运行时权限申请与文件存储等核心实现,适合作为语音社交、即时通讯类应用的参考模板。资源包共26个文件,压缩包约264KB,包含java源码、xml布局与配置、png图片素材、编译生成的class与dex文件,以及可直接安装的apk文件,结构较完整,便于对照学习。目前已有316人浏览/学习。通过该工程,开发者可以快速理解录音状态机的管理方式,掌握在Android中如何用触摸监听实现按住录音、松开停止,并通过属性动画丰富交互反馈;还涵盖应用清单中的权限声明、录音参数配置等细节。对于正在学习Android音频录制或自定义录音按钮的开发者来说,是一份小巧实用的参考代码。
1. 按住说话不是监听点击:先想清楚交互状态机,再谈图片变化
把 QQ、微信、米聊那套按住说话拆开看,真正难的不是录音本身,而是两个并发动作要同时成立:手指按下的几十毫秒内,按钮图片要切到按下态、录音浮层要弹出来、MediaRecorder 要静默进入录制状态,任何一边慢半拍,体感就是“按了没反应”。这类 Android 源码包的核心交互基本都是同一套三层结构:状态机管触摸分支,MediaRecorder 管录音生命周期,PopupWindow 管图片和文案变化。下面按这条链路把每层实现和参数讲透,适合已经能写基础 Activity、准备自己做 IM 语音模块或录音按钮的开发者。读完你能复现按住说话、上滑取消、松开发送这条完整交互,也能解释快速点按为什么不会产生录音文件、列表滑动为什么不会误触发录音。
2. MotionEvent 与状态机:按下、抬手、取消如何驱动图片变化
2.1 为什么不能用 onClick 与 onLongClick 做按住说话
onClick 在 ACTION_UP 之后才回调,按下瞬间没有任何反馈,图片变化永远慢半拍;而且拿不到按下到抬手之间的中间事件,手指是否滑出按钮、滑了多远都感知不到。onLongClick 虽然能感知“按住”,但它只回调一次,抬手时没有对应通知,“松手取消发送”这种分支就永远补不上。结论很直接:按住说话必须自己接管 onTouchEvent 或 setOnTouchListener,在 ACTION_DOWN 到 ACTION_UP 或 ACTION_CANCEL 之间维护一个状态机。这个状态机也是图片变化的唯一依据——每切换一个状态,就换一次按钮图片、浮层图标和提示文案。用 Android Studio 打开这类源码包时,先别急着跑,找到处理 Touch 事件的类,交互逻辑都在里面,绕开状态机直接看录音代码会看晕。
2.2 四条触摸分支与录音动作的映射
触摸事件里与按住说话有关的其实是四个分支:ACTION_DOWN 按下、ACTION_MOVE 滑动、ACTION_UP 抬手、ACTION_CANCEL 触摸序列被中断。UP 和 CANCEL 都是“结束”,语义却完全不同:UP 是用户主动松手,要判断是发送还是取消发送;CANCEL 是触摸被父视图拦截或系统抢占(来电、手势导航、外层列表开始滚动),必须无条件丢弃录音数据。很多源码包只处理了 UP,在列表页里就经常出现“语音条没发出去但文件已经生成”的怪问题,本质就是漏了 CANCEL。
| 事件 | 时机 | 录音动作 | UI 动作 |
|---|---|---|---|
| ACTION_DOWN | 手指按下 | 延迟启动录音 | 按钮切按下态,弹出录音浮层 |
| ACTION_MOVE | 手指滑动 | 不碰录音数据 | 按坐标切换录音/取消态图片 |
| ACTION_UP | 手指抬起 | 停止并保存或丢弃 | 隐藏浮层,按钮恢复 |
| ACTION_CANCEL | 触摸被中断 | 停止并丢弃文件 | 隐藏浮层,状态复位 |
2.3 状态机最小实现:PRESSED 态与延迟启动
常见做法是按下时先只切图片、不启动录音,延迟 120 到 200ms 再 start(),过滤掉“手指恰好停了一下”的误触。延迟用 Handler.postDelayed 实现,抬手时 removeCallbacks。下面这个辅助类把状态和触摸分支收在一起,UI 层只通过 onStateChanged 回调换图片。
public class PressToTalkHelper { public static final int STATE_IDLE = 0; public static final int STATE_PRESSED = 1; public static final int STATE_RECORDING = 2; public static final int STATE_CANCEL_READY = 3; private final Handler handler = new Handler(Looper.getMainLooper()); private static final long START_DELAY_MS = 150; private int state = STATE_IDLE; private final Runnable startRecordRunnable = new Runnable() { @Override public void run() { if (state == STATE_PRESSED) { state = STATE_RECORDING; startRecording(); // 真正创建 MediaRecorder 并 start uiListener.onStateChanged(state); } } }; public boolean onTouch(View button, MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: state = STATE_PRESSED; uiListener.onStateChanged(state); // 切到按下图片 handler.postDelayed(startRecordRunnable, START_DELAY_MS); return true; case MotionEvent.ACTION_UP: handler.removeCallbacks(startRecordRunnable); if (state == STATE_RECORDING) stopAndSave(); if (state == STATE_CANCEL_READY) stopAndDiscard(); state = STATE_IDLE; uiListener.onStateChanged(state); return true; case MotionEvent.ACTION_CANCEL: handler.removeCallbacks(startRecordRunnable); stopAndDiscard(); // 系统中断,不保存 state = STATE_IDLE; uiListener.onStateChanged(state); return true; } return false; } }逻辑说明:DOWN 只把状态推到 PRESSED 并启动 150ms 倒计时;如果用户在 150ms 内抬手,UP 分支先 removeCallbacks,此时 state 还是 PRESSED,两个结束分支都不会触发录音保存,自然就没有文件产生。CANCEL 分支和 UP 分支长得像但语义完全不同——列表开始滑动时系统发来的是 CANCEL 而不是 UP,这里如果只是调用 stopAndSave 而不是 stopAndDiscard,就会把半截语音当作有效消息发出去。
参数说明:START_DELAY_MS 一般取 100 到 200ms。取值越小按下反馈越跟手、误触率越高;放在列表项里的按钮建议取 180ms 以上,独立页面里取 120ms 左右手感更好。延迟回调里判断的是 state 而不是事件类型,因为回调执行时可能已经经过了 UP,必须靠状态判断“当前还有没有资格启动录音”。
2.4 MOVE 分支:取消区进入与退出
MOVE 分支做两件事:判断触点是否进入取消区,以及进入或退出时切换状态和对应图片。取消区一般定义为“按钮顶部向上偏移一段距离”的水平线以上区域,滑过这条线就进入 CANCEL_READY。
case MotionEvent.ACTION_MOVE: if (state != STATE_RECORDING && state != STATE_CANCEL_READY) { return true; // 录音未启动,忽略 } boolean inCancelZone = event.getRawY() < cancelLineY; if (inCancelZone && state == STATE_RECORDING) { state = STATE_CANCEL_READY; // 滑入取消区 uiListener.onStateChanged(state); // 图标换成取消样式 } else if (!inCancelZone && state == STATE_CANCEL_READY) { state = STATE_RECORDING; // 滑回录音区 uiListener.onStateChanged(state); // 图标换回麦克风 } return true;cancelLineY 不能在 MOVE 里每次现算。常见做法是在 DOWN 时用 button.getLocationOnScreen(loc) 拿到按钮屏幕坐标,减去一个 dp 阈值换算出的像素值,缓存成成员变量,MOVE 里只做一次整型比较。这样既省去每帧布局计算,也不会因为父容器滚动导致阈值来回跳动。这里用 getRawY() 是因为它返回屏幕绝对坐标,和 getLocationOnScreen 的结果处于同一坐标系,可以直接比较;如果用 getY(),在 ScrollView 或 RecyclerView 里会叠加子视图的位移,阈值就失真了。
3. MediaRecorder 接入:按住说话录音的最小链路与权限时序
3.1 权限检查放在页面进入时,不放在按下时
RECORD_AUDIO 是危险权限,必须运行时申请。如果把申请动作放在 ACTION_DOWN,权限 Dialog 会打断触摸序列,ACTION_UP 收不到,界面卡死在按下态。常见做法是 onResume 或 onCreate 里检查权限,未授权就引导去授权,按下时才发现没权限则直接 return 不启动任何链路,比弹窗打断手势体面得多。另外注意 targetSdk 28 及以下在写录音文件时还需要 WRITE_EXTERNAL_STORAGE,Android 10 以后用 getExternalFilesDir 写应用专属目录,存储权限不再需要。老源码包在新模拟器上报权限错,多半是这里。
private boolean ensureRecordPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { return true; } ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.RECORD_AUDIO}, REQ_CODE_RECORD); return false; }逻辑说明:checkSelfPermission 返回 GRANTED 才返回 true,否则发起请求并返回 false,调用方根据返回值决定是否绑定 onTouchListener。注意 requestPermissions 的回调是异步的,不要在 onRequestPermissionsResult 里直接 startRecording,正确的做法是回调里刷新按钮可用状态,让用户再按一次。这样触摸时序最简单,也不容易出现权限刚弹完、手势序列已经结束的状态残留。
3.2 MediaRecorder 参数清单与不同 Android 版本的差异
MediaRecorder 是典型的状态机 API:new 之后必须按 set 参数、prepare、start、stop、release 的顺序调用,stop 之后这一实例就作废,再次录音必须 new 新对象,复用会抛 IllegalStateException。Android 12(API 31)起无参构造方法废弃,需要传 Context 的 new MediaRecorder(context),源码包如果 crash 在构造方法这一行,先查这里。
private MediaRecorder createRecorder(File file) throws IOException { MediaRecorder recorder = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? new MediaRecorder(context) // API 31+ 必须带 Context : new MediaRecorder(); recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); recorder.setAudioEncodingBitRate(32000); recorder.setAudioSamplingRate(16000); recorder.setOutputFile(file.getAbsolutePath()); recorder.prepare(); recorder.start(); return recorder; }参数选择可以参考微信语音的体感:语音场景 16kHz 采样率足够,文件比 44.1kHz 小一半还多;AAC + MPEG_4 生成的 m4a 在 Android 和 iOS 上都能直接播,不用二次转码。常见参数对照如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| AudioSource | MIC | 主麦克风;VOICE_RECOGNITION 带降噪但部分机型增益偏低 |
| OutputFormat | MPEG_4 | 通用性最好;想压缩到极致可换 AMR_NB(.amr) |
| AudioEncoder | AAC | 音质体积均衡;老嵌入式设备才需要 AMR |
| SamplingRate | 16000 | 人声频段足够,体积省 60% 以上 |
| EncodingBitRate | 32000 | 语音够清晰;环境嘈杂时提到 64000 |
提示:MediaRecorder 是单次对象。stop() 之后实例进入 Error 状态,不能再次 start,必须 new 新的实例。看到 IllegalStateException 先查是不是复用了旧实例。
3.3 stop 抛 RuntimeException 与 0 秒文件兜底
MediaRecorder 的 stop() 在录音时长过短时直接抛 RuntimeException,这是按住说话源码 Crash 率最高的点。快速点按虽然被 PRESSED 态过滤了,但用户按下 200ms 后开始录音、又只录了 300ms 就松手,stop 依然可能炸。处理方式就是 try-catch 包住 stop,catch 里删文件返回 null,上层拿到 null 就当作“这次没有有效录音”。
public File stopAndReturnFile() { try { recorder.stop(); // 时长过短会抛 RuntimeException } catch (RuntimeException e) { Log.w(TAG, "stop failed, drop file"); cleanup(); // 释放实例并删除当前文件 return null; } finally { if (recorder != null) { recorder.release(); recorder = null; } } File f = currentFile; currentFile = null; return f; }逻辑说明:catch 里必须做 cleanup 而不是只吞异常。cleanup 一般包含三件事:recorder.release() 释放底层 AudioRecord 句柄、当前临时文件 delete 掉、状态机复位到 IDLE。漏掉 release 会导致下一次 start 报 EACCES;漏掉 delete 会在缓存目录里堆满打不开的空文件。UP 分支里拿到 null 时 UI 直接回到初始图片,不弹发送面板,这就是 0 秒语音不会出现在聊天流里的原因。
3.4 录音文件的命名与缓存目录
文件建议放在 getExternalFilesDir("voice") 下,文件名带时间戳,例如 voice_20240101120000.m4a。发送之前文件在应用私有目录,不需要存储权限;发送成功后再挪到用户可见目录或者直接走上传接口。源码包里如果写死了 sdcard/xxx 这类路径,在新系统上一定会挂,改成 Context 提供的目录即可。如果后续要加音量可视化,MediaRecorder.getMaxAmplitude() 可以直接驱动音量条,这属于扩展项,不影响主链路先跑通。
4. 图片变化浮层:Selector、PopupWindow 与上滑取消的完整实现
4.1 图片变化的两种驱动方式:Selector 与代码切换
按钮的按压反馈可以用系统 Selector 兜底:normal 和 pressed 两张图交给系统处理,手指按下时自动换图、抬手自动恢复,这是按钮类控件最省事的做法。
<!-- res/drawable/btn_record_selector.xml --> <selector xmlns:android="http://schemas.android.com/apk/res/android"> <item android:state_pressed="true" android:drawable="@drawable/ic_record_pressed"/> <item android:drawable="@drawable/ic_record_normal"/> </selector>但按住说话只靠 state_pressed 不够,因为录制中和取消中是两种不同的按压态,系统不会为它们提供 pressed 分支。所以实用项目里,按钮背景用 Selector 做“按下即有反馈”的保底,浮层图标和提示文案则完全由代码驱动:onStateChanged 回调里根据 state 分发资源。这样才能实现“滑入取消区换红色图标、滑回录音区换回麦克风”这种跨状态切换。区分这两层是看懂源码包里图片逻辑的关键——一半图片变化在 XML 里,另一半在代码里。
4.2 PopupWindow 浮层:显示时机与触摸穿透
录音浮层用 PopupWindow 还是自定义 Dialog 都行,但要注意一个触摸事件的关键特性:触摸序列从 DOWN 开始就被绑定到按下时所在的窗口,后续 MOVE 和 UP 会持续投递给同一窗口,即使中途在它上面弹出了新窗口也不会被夺走。这就是“按住过程中弹浮层”能成立的前提。所以浮层只需要负责显示,不需要也不能拦截触摸。
private void showRecordPopup() { if (popupWindow != null && popupWindow.isShowing()) return; View content = LayoutInflater.from(this).inflate(R.layout.popup_record, null); ivMic = content.findViewById(R.id.iv_mic); tvHint = content.findViewById(R.id.tv_hint); popupWindow = new PopupWindow(content, dp2px(240), dp2px(200)); popupWindow.setFocusable(false); // 不抢焦点 popupWindow.setTouchable(false); // 触摸穿透 popupWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); popupWindow.showAtLocation(recordButton, Gravity.CENTER, 0, 0); }setFocusable(false) 和 setTouchable(false) 是两个必设项:前者保证弹窗不夺走输入焦点,否则聊天页的 EditText 会被顶掉焦点;后者保证弹窗不消费触摸事件,否则按钮收不到后续 MOVE 和 UP。背景必须设透明 Drawable,PopupWindow 默认没有背景时在部分 ROM 上会有黑色边框闪烁。showAtLocation 传 Gravity.CENTER 表示浮层相对屏幕居中,和微信那种“紧贴按钮上方”的定位不同,想贴近按钮可以用 showAsDropDown 加负偏移,视觉上更接近原版。
| 参数 | 取值 | 作用 |
|---|---|---|
| setFocusable | false | 防止弹窗抢焦点,顶掉输入框 |
| setTouchable | false | 触摸穿透,不拦截后续 MOVE/UP |
| setBackgroundDrawable | 透明 | 避免部分 ROM 黑边闪烁 |
| showAtLocation | Gravity.CENTER | 屏幕居中;贴按钮需换 showAsDropDown |
4.3 上滑取消:屏幕坐标换算与阈值缓存
上滑取消的判定在 2.4 已经给了 MOVE 分支,这里补上 cancelLineY 怎么算。阈值一般取按钮顶部向上 60 到 80dp——太近了随手一抖就取消,太远了拇指不敢往上滑。
// ACTION_DOWN 时缓存一次 int[] loc = new int[2]; recordButton.getLocationOnScreen(loc); cancelLineY = loc[1] - dp2px(60); // onStateChanged 里根据状态切图片和文案 private void onStateChanged(int state) { if (state == PressToTalkHelper.STATE_CANCEL_READY) { ivMic.setImageResource(R.drawable.ic_record_cancel); tvHint.setText("松开手指,取消发送"); tvHint.setBackgroundResource(R.drawable.bg_hint_cancel); } else if (state == PressToTalkHelper.STATE_RECORDING) { ivMic.setImageResource(R.drawable.ic_record_mic); tvHint.setText("松开发送,上滑取消"); tvHint.setBackgroundResource(R.drawable.bg_hint_normal); } // STATE_IDLE 和 STATE_PRESSED 分支省略:隐藏浮层 / 显示按住图 }dp2px(60) 必须在运行时用 density 换算,不能在布局里写死 px,否则 720p 和 2K 屏幕的取消区高度差出一倍。做列表页时还有一个细节:按钮在 RecyclerView 条目里,getLocationOnScreen 拿到的 Y 会随条目滚动变化,所以必须在每次 DOWN 时重新缓存,不能只在页面 onCreate 算一次。
4.4 按压动画与录音时长提示
图片变化不止是换资源。按下瞬间给浮层里的麦克风图标加一个 0.9 到 1.1 倍的缩放动画,手指能明显感觉到“沉下去了”,这个反馈比单纯换图更接近微信的手感。实现上用 ObjectAnimator 对 scaleX/scaleY 做 100ms 到 150ms 的插值,状态切回时反向播放。浮层底部还可以加一条横向进度条实时显示录音时长,用 Handler 每 100ms 发一次空消息驱动,语音超过 60 秒时强制停止并进入发送流程。这两个都属于“体验加分项”,源码包里有就复用,没有也完全不影响主交互跑通。
5. 按住说话的真机验证与三个必调参数
5.1 录音启动延迟的取舍
START_DELAY_MS 是第一个必调参数。模拟器上按着没反应,先看是不是延迟设到了 300ms 以上,手指已经抬了才刚进 RECORDING——这是“按了说话没声音”最常见的原因。独立按钮取 120ms,列表项取 180ms,如果测试反馈误触多再往上加到 200ms,别超过 250ms,否则延迟感会明显到能感知。
5.2 取消阈值与手势导航的冲突
第二个必调参数是取消阈值,默认 60dp。全面屏手势从屏幕底部上滑会触发系统导航,应用会收到 ACTION_CANCEL——这正好验证了 2.2 里的设计:CANCEL 分支丢弃文件、按钮复位。但如果把录音按钮放在屏幕底部,用户拇指自然活动范围就在底部,上滑系统手势和上滑取消会打架,这种情况建议调小阈值或直接关闭上滑取消。验证方法:按住按钮,拇指缓慢上滑越过阈值线,浮层图标应该从麦克风变成红色取消样式,文案同步变化;滑回原处,图标应还原。
5.3 onPause 状态复位与验收清单
第三个必调参数是 onPause 复位,最容易漏。录音过程中来电、按 Home 键、锁屏,系统会中断 MediaRecorder,但不一定会立刻传 ACTION_CANCEL 给应用。漏掉复位的表现是:回到页面按钮还停在“按住”的图片,再点任何地方都没反应。固定写法是在 onPause 里强制复位:
@Override protected void onPause() { super.onPause(); helper.cancelAndCleanup(); // 丢弃未发送的录音并复位状态机 helper.hidePopup(); // 隐藏录音浮层 }| 验证项 | 操作 | 预期结果 |
|---|---|---|
| 快速点按 | 手指快速点一下按钮 | 无录音文件,无浮层闪现 |
| 正常发送 | 按住 2 秒松手 | 浮层消失,生成 >1s 的有效录音 |
| 上滑取消 | 按住上滑超过阈值松手 | 文件被删除,按钮复位 |
| 列表干扰 | 在列表中按住再横滑 | 收到 CANCEL,不产生文件 |
| 后台中断 | 录音中按 Home 键 | 回到页面按钮初始图片 |
最后检查音频焦点:开始录音时 requestAudioFocus,结束录音时 abandonAudioFocus,否则播放器可能在同时放歌,录进去的背景音乐会让你误以为采样率配错了。按住说话这条链路到这就完整了,剩下的波形动画、消息气泡和列表加载是另外一套模块的事。
本文还有配套的精品资源,点击获取