导读:本期聚焦于何守业创作的《如何用SQLite保存MediaRecorder录制的音视频元数据?完整实战教程》,敬请观看详情。录了一段视频,关闭应用后却发现找不到文件存在哪、录了多久、是什么格式?这类问题往往源于录制过程中没有同步保存元数据。本教程把SQLite与MediaRecorder结合起来,在Android上实现一个完整的录制元数据管理系统:先设计recording表结构,把文件路径、时长、大小、格式、创建时间这些字段规划清楚;再通过文件监听器、定时器、回调三种方式在录制过程中动态更新时长数据;最后用ContentProvider式的查询方式做列表展示,并补上文件删除时的数据库同步、事务写入、异步优化等容易踩坑的细节。全程附完整代码,可直接套用到自己的录音录像项目中。

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

如何用SQLite保存MediaRecorder录制的音视频元数据?完整实战教程

一、设计元数据表结构:先想清楚要存什么

动手写代码之前,先把表结构定下来。很多教程一上来就建一个只有文件名的表,后面要展示时长、排序、搜索时就不得不反复改表。结合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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260905/50715.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。