做录音录像类应用时,多数人只关注MediaRecorder本身的调用流程,却忽略了录制产生的元数据管理。用户录了二十条语音,列表页要展示每条的时长、大小、创建日期,如果每次都去遍历文件、用MediaPlayer重新探测时长,性能会非常差,而且一旦文件被用户用文件管理器手动删除,界面上就会出现打不开的僵尸条目。把SQLite引入进来,在录制的各个环节同步写入和更新元数据,才能真正做出一个稳定可用的录制应用。本文以一个录音模块为例,完整演示建库、录制中更新、录制完成落库、查询展示、删除同步的整个闭环。

一、设计元数据表结构:先想清楚要存什么
动手写代码之前,先把表结构定下来。很多教程一上来就建一个只有文件名的表,后面要展示时长、排序、搜索时就不得不反复改表。结合MediaRecorder的产出特点,一条录制记录通常需要以下字段:唯一ID、输出文件绝对路径、文件显示名、时长(毫秒)、文件大小(字节)、MIME类型、创建时间戳、是否被标记删除。其中时长是重灾区——MediaRecorder在录制过程中并不会主动告诉你已经录了多久,需要自己计算,后面会专门讲。
建表SQL可以这样写:
CREATE TABLE IF NOT EXISTS recording (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT,
created_at INTEGER NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recording_created ON recording(created_at DESC);这里有两个细节值得注意。第一,file_path加UNIQUE约束,防止同一路径插入两条记录;第二,给created_at建降序索引,因为录制列表几乎总是按时间倒序展示的,这个索引能让分页查询直接走索引,数据量到几千条时差距明显。
另外一个建议是不要在数据库里存「只读文件名」而不存完整路径。Android 10以后作用域存储的限制越来越严,文件可能位于getExternalFilesDir()下的不同子目录,存绝对路径配合FileProvider分享时处理起来最省事。
二、封装数据库操作:SQLiteOpenHelper与录制流程的衔接
先写一个继承SQLiteOpenHelper的数据库管理类,把增删改查都封装成方法,避免业务代码里到处拼SQL。下面的代码包含了插入、更新时长、更新大小、查询列表和软删除几个核心操作:
public class RecordingDbHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "recordings.db";
private static final int DB_VERSION = 1;
public RecordingDbHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL("CREATE TABLE IF NOT EXISTS recording (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT," +
"file_path TEXT NOT NULL UNIQUE," +
"display_name TEXT NOT NULL," +
"duration_ms INTEGER NOT NULL DEFAULT 0," +
"file_size INTEGER NOT NULL DEFAULT 0," +
"mime_type TEXT," +
"created_at INTEGER NOT NULL," +
"is_deleted INTEGER NOT NULL DEFAULT 0)");
db.execSQL("CREATE INDEX IF NOT EXISTS idx_recording_created " +
"ON recording(created_at DESC)");
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldV, int newV) {
// 升级时根据版本差异做迁移,这里简单重建
db.execSQL("DROP TABLE IF EXISTS recording");
onCreate(db);
}
// 录制开始时插入一条初始记录
public long insertRecording(String path, String name, String mime) {
ContentValues cv = new ContentValues();
cv.put("file_path", path);
cv.put("display_name", name);
cv.put("mime_type", mime);
cv.put("created_at", System.currentTimeMillis());
return getWritableDatabase()
.insertWithOnConflict("recording", null, cv,
SQLiteDatabase.CONFLICT_IGNORE);
}
// 录制中周期性更新时长
public void updateDuration(long id, long durationMs) {
ContentValues cv = new ContentValues();
cv.put("duration_ms", durationMs);
getWritableDatabase().update("recording", cv,
"id = ?", new String[]{String.valueOf(id)});
}
// 录制结束时补全文件大小
public void updateFileSize(long id, long size) {
ContentValues cv = new ContentValues();
cv.put("file_size", size);
getWritableDatabase().update("recording", cv,
"id = ?", new String[]{String.valueOf(id)});
}
// 查询未删除的录制记录
public Cursor queryAll() {
return getReadableDatabase().query("recording", null,
"is_deleted = 0", null, null, null,
"created_at DESC");
}
}接下来是录制流程的衔接。在调用MediaRecorder.prepare()之前插入数据库记录,拿到返回的行ID后记在成员变量里,供后续更新使用。整个启动流程如下:
public class AudioRecordManager {
private MediaRecorder recorder;
private RecordingDbHelper dbHelper;
private long currentRecordId = -1;
private long recordStartTime;
private Handler timerHandler = new Handler(Looper.getMainLooper());
public void startRecording(String dir) {
File file = new File(dir, "rec_" + System.currentTimeMillis() + ".m4a");
dbHelper = new RecordingDbHelper(context);
// 先落库,再启动录制,顺序不能反
currentRecordId = dbHelper.insertRecording(
file.getAbsolutePath(), file.getName(), "audio/mp4");
recordStartTime = System.currentTimeMillis();
recorder = new MediaRecorder();
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
recorder.setAudioEncodingBitRate(96000);
recorder.setAudioSamplingRate(44100);
recorder.setOutputFile(file.getAbsolutePath());
try {
recorder.prepare();
recorder.start();
startDurationTimer();
} catch (IOException e) {
// 启动失败时回滚数据库记录,避免产生僵尸数据
dbHelper.softDelete(currentRecordId);
currentRecordId = -1;
}
}
}这里有一个容易被忽视的容错点:prepare()和start()可能因为麦克风被占用、存储空间不足等原因抛异常。一旦失败,前面已经插入的那条数据库记录就成了脏数据,必须立刻回滚。上面的代码用软删除处理,也可以直接物理删除,视你的回收站需求而定。
三、录制中动态更新时长与结束时补全数据
前面提到过,MediaRecorder没有提供「已录制时长」的查询接口,需要自己算。最稳妥的方式是用Handler起一个定时器,每隔一秒根据System.currentTimeMillis() - recordStartTime计算真实时长并写库。有人会问:为什么不直接用定时器累加计数?因为一旦主线程卡顿或设备休眠,累加值和真实时长会产生偏差,用系统时钟做差值永远准确。
private Runnable durationTask = new Runnable() {
@Override
public void run() {
if (currentRecordId > 0) {
long elapsed = System.currentTimeMillis() - recordStartTime;
dbHelper.updateDuration(currentRecordId, elapsed);
timerHandler.postDelayed(this, 1000);
}
}
};
private void startDurationTimer() {
timerHandler.post(durationTask);
}
private void stopDurationTimer() {
timerHandler.removeCallbacks(durationTask);
}
public void stopRecording() {
if (recorder != null) {
try {
recorder.stop();
} catch (RuntimeException e) {
// 录制时间过短时stop会抛异常,此时文件无效
dbHelper.softDelete(currentRecordId);
} finally {
recorder.release();
recorder = null;
}
}
stopDurationTimer();
if (currentRecordId > 0) {
// 以stop时刻的实际时长为准做最后一次更新
long finalDuration = System.currentTimeMillis() - recordStartTime;
dbHelper.updateDuration(currentRecordId, finalDuration);
File f = new File(dbHelper.getFilePath(currentRecordId));
if (f.exists()) {
dbHelper.updateFileSize(currentRecordId, f.length());
}
currentRecordId = -1;
}
}注意stop()可能抛出RuntimeException——当录制时长小于一秒左右时,MPEG_4容器还没写入有效帧,系统会直接抛异常且文件不可用。这个场景下同样要软删除数据库记录并清理残留文件,否则用户会看到一条点了就报错的「幽灵录音」。
关于写库频率还有一点优化建议:每秒写一次数据库在录音场景下开销可以接受,但如果你的应用是长时间录像,可以把间隔放宽到5秒,或者只在内存里持有时长,等stop()时一次性落库。取舍在于:录制中途进程被杀时,定时写库能保住大部分元数据,只在内存里存则会全部丢失。对语音备忘录这类应用,建议保留定时写库。
四、列表展示与删除同步:闭环的最后一步
查询展示部分,用Cursor配合RecyclerView的CursorAdapter即可,也可以自己写一个简单的实体类映射。这里的关键是删除逻辑必须做到文件与数据库双向一致。正确的删除顺序是:先软删数据库,再删文件,最后确认文件删除成功后才物理删除数据库记录。顺序反了会出现文件删了但记录还在,或者记录删了但文件残留占用空间。
public void deleteRecording(long id) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
db.beginTransaction();
try {
// 第一步:软删除,界面立即感知
ContentValues cv = new ContentValues();
cv.put("is_deleted", 1);
db.update("recording", cv, "id = ?",
new String[]{String.valueOf(id)});
// 第二步:删除物理文件
String path = dbHelper.getFilePath(id);
File file = new File(path);
boolean fileDeleted = !file.exists() || file.delete();
// 第三步:文件删除成功才物理删除记录
if (fileDeleted) {
db.delete("recording", "id = ?",
new String[]{String.valueOf(id)});
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}把三步包在事务里有实际意义:如果第三步的物理删除因为某种原因失败,事务回滚后软删除标记也一并撤销,数据回到删除前的状态,不会出现两边对不上的中间态。
最后再提两个实战中的坑。一是用户可能通过系统文件管理器直接删掉录音文件,数据库里却不知情,所以查询后展示前最好做一次file.exists()校验,把失效记录标记或清理掉;二是所有数据库操作都不要放在主线程,Android 7.0以上主线程操作SQLite会直接抛异常,建议把RecordingDbHelper的调用统一封装到线程池或ViewModel的协程里。做到这两点,这套SQLite加MediaRecorder的元数据方案就足够健壮,可以直接落地到生产项目中了。
SQLiteMediaRecorderAndroid音视频录制修改时间:2026-09-05 05:56:50