
Android 录音机
项目背景与设计目标
1. 核心定位
本项目基于 Android 原生 AudioRecord(录音)和 AudioTrack(播放)实现核心音频功能,同时 1:1 还原 iOS 系统录音机的视觉与交互特征,最终达成两个目标:
- 功能层:实现「录制→停止→播放→暂停」的完整录音机流程;
- 视觉层:还原 iOS 标志性的大圆角按钮、磨砂玻璃卡片、蓝色强调色、居中反馈等设计特征;
- 性能层:保证子线程处理音频、主线程安全更新 UI,无 ANR、无资源泄漏。
2. iOS 录音机核心设计特征拆解
要做好「仿」,首先要精准分析 iOS 设计的核心特征,避免只做「形似」不做「神似」:
| 设计维度 | iOS 录音机特征 | Android 实现核心思路 |
|---|---|---|
| 视觉风格 | 浅灰基底(#F5F5F7)+ 白色磨砂卡片 + 大圆角按钮 | 根布局背景色+CardView实现磨砂卡片+Drawable圆角Shape |
| 强调色 | iOS 标准蓝色(#007AFF),按压态(#0066CC) | Selector 状态Drawable+颜色值精准匹配 |
| 交互反馈 | 按钮按压浅度深色化、Toast 居中显示 | 自定义Selector+Toast位置/样式重写 |
| 布局规范 | 8/16/24dp 倍数间距、元素居中对齐 | ConstraintLayout 精准布局+间距统一配置 |
| 字体规范 | SF Pro 字体(17sp 按钮/18sp 状态文本) | sans-serif-medium 替代+字体大小严格匹配 |
样式:
技术栈与环境准备
1. 核心技术栈
- 开发语言:Java(Android 原生,无 Kotlin/跨平台框架);
- 音频核心:
AudioRecord(PCM 录音)、AudioTrack(PCM 播放); - UI 实现:
ConstraintLayout(布局)、CardView(磨砂卡片)、Drawable Selector(按钮状态); - 权限处理:Android 6.0+ 动态权限(RECORD_AUDIO);
- 线程管理:子线程+Handler+volatile 保证线程安全。
2. 环境配置
- Android Studio:2022.3.1+(兼容 Version Catalog 依赖管理);
- Gradle 版本:7.0+;
- SDK 配置:minSdk 23(Android 6.0)、targetSdk 33;
- 核心依赖(通过 Version Catalog 管理):
# gradle/libs.versions.toml [versions] cardview = "1.0.0" constraintlayout = "2.1.4" [libraries] cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }# app/build.gradle dependencies { implementation libs.cardview implementation libs.constraintlayout }
UI 层
UI 是「仿 iOS」的核心,我们从布局、样式、Drawable、交互四个层面拆解实现。
1. 布局层:ConstraintLayout 实现 iOS 居中布局
iOS 录音机的核心布局逻辑是「元素居中+分层卡片」,通过 ConstraintLayout 实现精准对齐:
<!-- activity_main.xml 核心结构 -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F5F7"> <!-- iOS 浅灰基底 -->
<!-- iOS 磨砂玻璃卡片:核心内容容器 -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:layout_marginTop="64dp"
app:cardBackgroundColor="#FFFFFF"
app:cardCornerRadius="16dp"
app:cardElevation="2dp"
app:cardUseCompatPadding="true">
<!-- 内部元素:状态文本+按钮组,全部居中对齐 -->
<TextView
android:id="@+id/tv_status"
style="@style/IOSStatusText"
android:text="未开始录制"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<!-- iOS 蓝色强调按钮(开始录制) -->
<Button
android:id="@+id/btn_record"
style="@style/IOSButton.Primary"
android:text="开始录制"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tv_status"/>
<!-- 其余按钮省略... -->
</androidx.cardview>
</androidx.constraintlayout.widget.ConstraintLayout>
关键细节:
- CardView 的
cardElevation设为 2dp,模拟 iOS 柔和的阴影效果,避免 Android 原生阴影过重; - 所有交互元素通过
layout_constraintStart/End_toParent实现居中,符合 iOS 对称布局特征; - 间距采用 8/16/24dp 倍数(iOS 设计系统标准),避免随意间距破坏风格统一。
2. 样式层:Style 统一管理 iOS 视觉规范
通过 styles.xml 定义全局样式,避免重复代码,同时精准匹配 iOS 字体、尺寸规范:
<!-- res/values/styles.xml -->
<resources>
<!-- iOS 全局主题 -->
<style name="Theme.IOSRecorder" parent="Theme.MaterialComponents.Light.NoActionBar">
<item name="android:windowBackground">#F5F5F7</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:fontFamily">sans-serif-medium</item> <!-- 替代 iOS SF Pro -->
</style>
<!-- iOS 按钮基础样式 -->
<style name="IOSButton">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">50dp</item>
<item name="android:layout_marginHorizontal">16dp</item>
<item name="android:layout_marginVertical">8dp</item>
<item name="android:background">@drawable/ios_btn_bg</item>
<item name="android:textColor">#1D1D1F</item> <!-- iOS 文本主色 -->
<item name="android:textSize">17sp</item> <!-- iOS 按钮标准字体大小 -->
<item name="android:elevation">1dp</item>
<item name="android:stateListAnimator">@null</item> <!-- 移除 Android 原生按压动效 -->
</style>
<!-- iOS 蓝色强调按钮 -->
<style name="IOSButton.Primary" parent="IOSButton">
<item name="android:background">@drawable/ios_btn_primary_bg</item>
<item name="android:textColor">#FFFFFF</item>
</style>
</resources>
关键细节:
- 移除 Android 原生按钮的
stateListAnimator,避免按压时的「抬升」动效(iOS 按钮无此动效); - 字体选择
sans-serif-medium替代 iOS SF Pro(Android 无原生 SF Pro,此字体视觉最接近); - 按钮高度 50dp + 圆角 25dp(高度的一半),实现 iOS 「全圆角按钮」特征。
3. Drawable 层:实现 iOS 按钮状态切换
iOS 按钮的核心交互是「按压深色化」,通过 Selector Drawable 实现状态切换:
(1)普通按钮背景(ios_btn_bg.xml)
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<corners android:radius="25dp"/>
<solid android:color="#E6E6E8"/> <!-- 按压深灰 -->
</shape>
</item>
<item>
<shape android:shape="rectangle">
<corners android:radius="25dp"/>
<solid android:color="#F1F1F3"/> <!-- 默认浅灰 -->
</shape>
</item>
</selector>
(2)蓝色强调按钮背景(ios_btn_primary_bg.xml)
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<corners android:radius="25dp"/>
<solid android:color="#0066CC"/> <!-- iOS 蓝色按压态 -->
</shape>
</item>
<item>
<shape android:shape="rectangle">
<corners android:radius="25dp"/>
<solid android:color="#007AFF"/> <!-- iOS 标准蓝色 -->
</shape>
</item>
</selector>
关键细节:
- 圆角半径 25dp 与按钮高度 50dp 匹配,保证按钮为「纯圆角」;
- 颜色值严格匹配 iOS 官方规范(#007AFF 为 iOS 系统蓝色);
- 按压态颜色仅做「浅度加深」,符合 iOS 轻量交互的特征。
核心功能层
UI 是「外衣」,音频核心是「内核」,基于 Android 原生 AudioRecord/AudioTrack 实现,需保证参数一致、线程安全。
1. 音频参数配置(核心:录制/播放参数必须一致)
// MainActivity 中定义全局音频参数
private static final int SAMPLE_RATE = 44100; // iOS 录音机标准采样率
private static final int CHANNEL_CONFIG_IN = AudioFormat.CHANNEL_IN_MONO; // 单声道录制
private static final int CHANNEL_CONFIG_OUT = AudioFormat.CHANNEL_OUT_MONO; // 单声道播放
private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT; // 16位 PCM 编码
关键细节:
- 采样率选择 44100Hz(iOS/Android 通用标准,兼容性最好);
- 单声道(MONO)比立体声(STEREO)更省资源,且符合录音机基础需求;
- 16 位 PCM 编码是平衡音质与体积的最优选择(8 位音质差,32 位无必要)。
2. AudioRecord 实现录音(子线程执行)
// 初始化 AudioRecord
private boolean initAudioRecord() {
try {
// 计算系统推荐的最小缓冲区大小(避免初始化失败)
int recordBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG_IN, AUDIO_FORMAT);
audioRecord = new AudioRecord(
AudioManager.STREAM_VOICE_CALL, // 音频源:麦克风
SAMPLE_RATE,
CHANNEL_CONFIG_IN,
AUDIO_FORMAT,
recordBufferSize
);
return audioRecord.getState() == AudioRecord.STATE_INITIALIZED;
} catch (Exception e) {
showIOStyleToast("录音初始化失败:" + e.getMessage());
return false;
}
}
// 开始录音(子线程)
private void startRecording() {
audioDataList.clear(); // 清空历史数据
isRecording = true;
if (!initAudioRecord()) return;
// 主线程更新 UI 状态
runOnUiThread(() -> {
tvStatus.setText("正在录制...");
btnRecord.setEnabled(false);
btnStopRecord.setEnabled(true);
});
// 子线程读取录音数据(避免阻塞主线程)
new Thread(() -> {
audioRecord.startRecording();
byte[] buffer = new byte[recordBufferSize];
while (isRecording) {
int readSize = audioRecord.read(buffer, 0, buffer.length);
if (readSize > 0) {
// 拷贝数据(避免原数组被覆盖)
byte[] data = new byte[readSize];
System.arraycopy(buffer, 0, data, 0, readSize);
audioDataList.add(data);
}
}
// 停止录音并释放资源
audioRecord.stop();
audioRecord.release();
audioRecord = null;
// 主线程更新 UI
runOnUiThread(() -> {
tvStatus.setText("录制完成(共" + audioDataList.size() + "帧数据)");
btnRecord.setEnabled(true);
btnStopRecord.setEnabled(false);
btnPlay.setEnabled(true);
});
}).start();
}
关键细节:
- 必须通过
AudioRecord.getMinBufferSize()计算缓冲区大小,不可硬编码(不同设备最小值不同); - 录音逻辑放在子线程,避免主线程 ANR;
- 数据拷贝使用
System.arraycopy,避免原缓冲区数组被覆盖导致数据错乱; - 用
volatile修饰isRecording(多线程可见性),保证停止录音时线程能及时退出循环。
3. AudioTrack 实现播放(子线程执行)
// 初始化 AudioTrack
private boolean initAudioTrack() {
try {
int playBufferSize = AudioTrack.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG_OUT, AUDIO_FORMAT);
audioTrack = new AudioTrack(
AudioManager.STREAM_MUSIC, // 播放流类型:音乐
SAMPLE_RATE,
CHANNEL_CONFIG_OUT,
AUDIO_FORMAT,
playBufferSize,
AudioTrack.MODE_STREAM // 流模式:实时播放
);
return audioTrack.getState() == AudioTrack.STATE_INITIALIZED;
} catch (Exception e) {
showIOStyleToast("播放初始化失败:" + e.getMessage());
return false;
}
}
// 开始播放(子线程)
private void startPlaying() {
if (audioDataList.isEmpty()) {
showIOStyleToast("暂无录音数据");
return;
}
isPlaying = true;
if (!initAudioTrack()) return;
// 主线程更新 UI
runOnUiThread(() -> {
tvStatus.setText("正在播放...");
btnRecord.setEnabled(false);
btnPlay.setEnabled(false);
btnStopPlay.setEnabled(true);
});
// 子线程播放
new Thread(() -> {
audioTrack.play();
for (byte[] data : audioDataList) {
if (!isPlaying) break; // 检测停止播放标记
audioTrack.write(data, 0, data.length); // 写入数据播放
}
// 停止播放并释放资源
audioTrack.stop();
audioTrack.release();
audioTrack = null;
// 主线程更新 UI
runOnUiThread(() -> {
tvStatus.setText(isPlaying ? "播放完成" : "播放已停止");
btnRecord.setEnabled(true);
btnPlay.setEnabled(true);
btnStopPlay.setEnabled(false);
});
isPlaying = false;
}).start();
}
关键细节:
- 播放模式选择
MODE_STREAM(流模式),适合实时播放录制的 PCM 数据; - 播放循环中检测
isPlaying标记,支持「停止播放」功能; - 播放完成后及时释放
AudioTrack,避免扬声器资源被占用。
4. 权限处理:Android 6.0+ 动态申请录音权限
// 权限申请逻辑
private static final int PERMISSION_REQUEST_CODE = 100;
private String[] permissions = {Manifest.permission.RECORD_AUDIO};
private void checkPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
boolean needRequest = false;
for (String permission : permissions) {
if (checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
needRequest = true;
break;
}
}
if (needRequest) {
requestPermissions(permissions, PERMISSION_REQUEST_CODE);
}
}
}
// 权限申请回调
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
boolean isGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
isGranted = false;
break;
}
}
if (!isGranted) {
showIOStyleToast("必须授予录音权限才能使用功能");
btnRecord.setEnabled(false);
}
}
}
关键细节:录音权限(RECORD_AUDIO)是「危险权限」,Android 6.0+ 必须动态申请,否则功能完全不可用。
核心难点与解决方案
1. 难点1:音频参数不一致导致播放失真/无声
问题:录制和播放的采样率、声道、编码格式不一致,会导致播放时杂音、无声甚至崩溃。
解决方案:
- 定义全局常量统一参数,录制/播放共用;
- 初始化
AudioRecord/AudioTrack后检查STATE_INITIALIZED状态,失败时给出明确提示。
2. 难点2:iOS 风格 UI 还原不精准
问题:Android 原生控件的默认样式(如阴影、动效)会破坏 iOS 风格的统一性。
解决方案:
- 移除
stateListAnimator禁用原生按压动效; - 降低 CardView 阴影高度(elevation=2dp);
- 严格匹配 iOS 颜色值和间距规范,避免「主观调整」。
3. 难点3:多线程安全问题
问题:录音/播放线程与主线程同时操作 UI 或修改状态标记,导致状态错乱。
解决方案:
- 用
volatile修饰isRecording/isPlaying,保证多线程可见性; - 所有 UI 更新操作通过
runOnUiThread()或 Handler 执行; - 页面销毁时强制停止线程、释放资源:
@Override protected void onDestroy() { super.onDestroy(); isRecording = false; isPlaying = false; if (audioRecord != null) { audioRecord.stop(); audioRecord.release(); } if (audioTrack != null) { audioTrack.stop(); audioTrack.release(); } }
扩展优化方向
本项目是基础版,可基于此扩展为完整的 iOS 风格录音机:
- 音频文件保存:将录制的 PCM 数据写入文件,或转码为 WAV/MP3(需引入 LAME 库);
- 音频波形图:基于 PCM 数据绘制 iOS 风格的音频波形(通过 Canvas 实现);
- 深色模式适配:添加
values-night目录,适配 iOS 深色模式的配色; - 录音列表:用 RecyclerView 实现 iOS 风格的录音列表,支持侧滑删除;
- 音质调节:支持切换采样率、声道数,满足不同音质需求。
总结
本文从「设计拆解→UI 还原→功能实现→难点解决」四个维度,完整讲解了 Android 实现 iOS 风格录音机的全过程。核心要点有三:
- 设计还原:精准拆解 iOS 视觉/交互特征,用 Android 原生控件(ConstraintLayout、CardView、Drawable)实现「神形兼备」;
- 音频核心:基于
AudioRecord/AudioTrack实现,保证参数一致、线程安全、资源及时释放; - 工程化:通过 Style、全局常量、自定义工具方法提升代码复用性,避免冗余。

浙公网安备 33010602011771号