在这里插入图片描述

Android 录音机

项目背景与设计目标

1. 核心定位

gittee源码链接

本项目基于 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 风格录音机:

  1. 音频文件保存:将录制的 PCM 数据写入文件,或转码为 WAV/MP3(需引入 LAME 库);
  2. 音频波形图:基于 PCM 数据绘制 iOS 风格的音频波形(通过 Canvas 实现);
  3. 深色模式适配:添加 values-night 目录,适配 iOS 深色模式的配色;
  4. 录音列表:用 RecyclerView 实现 iOS 风格的录音列表,支持侧滑删除;
  5. 音质调节:支持切换采样率、声道数,满足不同音质需求。

总结

本文从「设计拆解→UI 还原→功能实现→难点解决」四个维度,完整讲解了 Android 实现 iOS 风格录音机的全过程。核心要点有三:

  1. 设计还原:精准拆解 iOS 视觉/交互特征,用 Android 原生控件(ConstraintLayout、CardView、Drawable)实现「神形兼备」;
  2. 音频核心:基于 AudioRecord/AudioTrack 实现,保证参数一致、线程安全、资源及时释放;
  3. 工程化:通过 Style、全局常量、自定义工具方法提升代码复用性,避免冗余。
    在这里插入图片描述