
简介一套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_STORAGEAndroid 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 是典型的状态机 APInew 之后必须按 set 参数、prepare、start、stop、release 的顺序调用stop 之后这一实例就作废再次录音必须 new 新对象复用会抛 IllegalStateException。Android 12API 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 上都能直接播不用二次转码。常见参数对照如下参数推荐值说明AudioSourceMIC主麦克风VOICE_RECOGNITION 带降噪但部分机型增益偏低OutputFormatMPEG_4通用性最好想压缩到极致可换 AMR_NB.amrAudioEncoderAAC音质体积均衡老嵌入式设备才需要 AMRSamplingRate16000人声频段足够体积省 60% 以上EncodingBitRate32000语音够清晰环境嘈杂时提到 64000提示MediaRecorder 是单次对象。stop() 之后实例进入 Error 状态不能再次 start必须 new 新的实例。看到 IllegalStateException 先查是不是复用了旧实例。3.3 stop 抛 RuntimeException 与 0 秒文件兜底MediaRecorder 的 stop() 在录音时长过短时直接抛 RuntimeException这是按住说话源码 Crash 率最高的点。快速点按虽然被 PRESSED 态过滤了但用户按下 200ms 后开始录音、又只录了 300ms 就松手stop 依然可能炸。处理方式就是 try-catch 包住 stopcatch 里删文件返回 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:androidhttp://schemas.android.com/apk/res/android item android:state_pressedtrue android:drawabledrawable/ic_record_pressed/ item android:drawabledrawable/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。背景必须设透明 DrawablePopupWindow 默认没有背景时在部分 ROM 上会有黑色边框闪烁。showAtLocation 传 Gravity.CENTER 表示浮层相对屏幕居中和微信那种“紧贴按钮上方”的定位不同想贴近按钮可以用 showAsDropDown 加负偏移视觉上更接近原版。参数取值作用setFocusablefalse防止弹窗抢焦点顶掉输入框setTouchablefalse触摸穿透不拦截后续 MOVE/UPsetBackgroundDrawable透明避免部分 ROM 黑边闪烁showAtLocationGravity.CENTER屏幕居中贴按钮需换 showAsDropDown4.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否则播放器可能在同时放歌录进去的背景音乐会让你误以为采样率配错了。按住说话这条链路到这就完整了剩下的波形动画、消息气泡和列表加载是另外一套模块的事。本文还有配套的精品资源点击获取