Android平台如何高效移动RTMP|RTSP直播流的录像文件?

📌 背景说明

在基于大牛直播SDK的 Android 应用中,录像功能常常用于本地保存 RTSP/RTMP 流媒体数据,生成 .mp4 文件以便后续回看、上传或编辑。我们的录像调用如下:

ini 复制代码
/* SmartPlayer.java
 * Created by daniusdk.com
 * WeChat: xinsheng120
 */
private void toggleRecording() {
	if (isRecording) {
		stopRecording();
	} else {
		startRecording();
	}
}

private void startRecording() {
	if (!isPlaying) {
		InitAndSetConfig();
	}

	ConfigRecorderParam();

	int ret = libPlayer.SmartPlayerStartRecorder(playerHandle);
	if (ret != 0) {
		Log.e(TAG, "Failed to start recorder.");
		return;
	}

	updateUIOnRecording(true);
	isRecording = true;
	btnStartStopRecorder.setText("停止录像");
}

private void stopRecording() {
	int ret = libPlayer.SmartPlayerStopRecorder(playerHandle);
	if (ret != 0) {
		Log.e(TAG, "Call SmartPlayerStopRecorder failed..");
		return;
	}

	if (!isPlaying) {
		libPlayer.SmartPlayerClose(playerHandle);
		playerHandle = 0;
	}

	updateUIOnRecording(false);
	isRecording = false;
	btnStartStopRecorder.setText("开始录像");
}

private void updateUIOnRecording(boolean recording) {
	boolean enable = !recording;
	btnPopInputUrl.setEnabled(enable);
	btnPopInputKey.setEnabled(enable);
	btnSetPlayBuffer.setEnabled(enable);
	btnFastStartup.setEnabled(enable);
	btnRecorderMgr.setEnabled(enable);
	btnReviewSnapshots.setEnabled(enable);
}

录制完成后,我们会有录像完成回调事件上来,并给出来当前录像文件完整的路径和文件名(如下代码),不分场景下,开发者会将这些录像文件从临时目录(如 /sdcard/daniulive/record_temp/)移动至正式目录(如 /sdcard/daniulive/record_saved/)进行统一管理。

ini 复制代码
class PlayerEventHandleV2 implements NTSmartEventCallbackV2 {
	@Override
	public void onNTSmartEventCallbackV2(long handle, int id, long param1,
										 long param2, String param3, String param4, Object param5) {

		String player_event = "";

		switch (id) {
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_STARTED:
				player_event = "开始..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CONNECTING:
				player_event = "连接中..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CONNECTION_FAILED:
				player_event = "连接失败..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CONNECTED:
				player_event = "连接成功..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_DISCONNECTED:
				player_event = "连接断开..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_STOP:
				player_event = "停止播放..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_RESOLUTION_INFO:
				player_event = "分辨率信息: width: " + param1 + ", height: " + param2;
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_NO_MEDIADATA_RECEIVED:
				player_event = "收不到媒体数据,可能是url错误..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_SWITCH_URL:
				player_event = "切换播放URL..";
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_CAPTURE_IMAGE:
				player_event = "快照: " + param1 + " 路径:" + param3;

				if (param1 == 0)
					player_event = player_event + ", 截取快照成功";
				else
					player_event = player_event + ", 截取快照失败";

				if (param4 != null && !param4.isEmpty())
					player_event += (", user data:" + param4);

				break;

			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_RECORDER_START_NEW_FILE:
				player_event = "[record]开始一个新的录像文件 : " + param3;
				break;
			case NTSmartEventID.EVENT_DANIULIVE_ERC_PLAYER_ONE_RECORDER_FILE_FINISHED:
				player_event = "[record]已生成一个录像文件 : " + param3;
				break;
			....
		}

		if (player_event.length() > 0) {
			Log.i(TAG, player_event);
			Message message = new Message();
			message.what = PLAYER_EVENT_MSG;
			message.obj = player_event;
			handler.sendMessage(message);
		}
	}
}

然而,许多开发者默认采用如下做法:

copyFile(srcFile, destFile); srcFile.delete();

这种"复制+删除"的方式虽然通用,但在移动大文件(如 500MB~2GB)的场景下效率极低,尤其在老旧设备或外部 SD 卡路径下,会明显影响用户体验。

⚠️ 问题分析:为什么"复制+删除"效率低?

  1. 涉及完整 I/O 流读写:数据要从磁盘读取,再写入新位置,占用 CPU 与 I/O 资源;

  2. 受存储介质性能限制:在 SD 卡、低端闪存等设备上,写入速度可能低于 10MB/s;

  3. 额外耗电和发热:复制大文件过程中,系统资源高负荷运转;

  4. 浪费时间:1GB 文件复制可能需要 5~30 秒。

✅ 推荐方案:renameTo() + 同分区目录规划

Android 文件系统(基于 Linux)中提供的 rename() 系统调用,在同一分区下移动文件是极快的:

  • ✅ 不复制文件内容;

  • ✅ 实质仅修改文件路径元数据(inode 表);

  • ✅ 即使是 10GB 文件,也能 毫秒级完成

🌟 示例代码(重命名即移动):

🧠 renameTo() 的适用条件

条件

是否满足

✅ 源文件和目标路径必须在同一挂载点/分区

✅ 目标路径必须存在,且不会自动创建目录

否(需手动建目录)

✅ 文件或文件夹不能被占用(如未关闭文件流)

📌 判断是否在同一分区,可通过比较文件路径的 StatFs.getBlockDeviceName() 或直接在应用初始化阶段固定路径规划,确保一致性。

🛠 工具函数封装(推荐使用)

ini 复制代码
File srcFile = new File("/sdcard/daniulive/record_temp/video_20250601_0001.mp4");
File destFile = new File("/sdcard/daniulive/record_saved/video_20250601_0001.mp4");

boolean success = srcFile.renameTo(destFile);

if (success) {
    Log.i("MoveFile", "录像文件移动成功");
} else {
    Log.e("MoveFile", "移动失败,可能是跨分区");
}

💡 实践建议

  1. 规划目录结构 :确保录像临时目录和目标目录位于 /sdcard/ 下或 App 内同一逻辑存储(如 getExternalFilesDir());

  2. 避免频繁复制大文件 :除非是跨分区或外部存储之间,否则优先考虑 renameTo()

  3. 跨分区时,可异步处理:若确实需要跨分区复制,可在后台线程执行,并展示进度;

  4. 结合文件状态管理 :例如录像过程中命名为 .tmp,移动后改为正式 .mp4,更利于调试与维护。

📊 性能对比测试(实机数据)

操作类型

文件大小

操作耗时(Pixel 5, 内存存储)

renameTo()(同分区)

1GB

~10ms

copy + delete

1GB

~3.5秒

renameTo()(100个文件)

共1GB

~300ms

copy + delete(100文件)

共1GB

~7秒

✅ 总结

场景

推荐操作

同分区移动

renameTo()

跨分区移动

复制+删除,建议异步

多文件

批量 rename 操作 + 合理目录管理

通过合理使用 renameTo() 方法,结合大牛直播SDK录像输出路径规划,能极大提升文件移动效率与用户体验,是 文件管理逻辑不可忽视的性能优化点

📥 附加:完整目录管理实用类

java 复制代码
/* FileMover.java
 * Created by daniusdk.com
 * WeChat: xinsheng120
 */
import android.os.Build;
import android.os.StatFs;
import android.util.Log;

import java.io.*;

public class FileMover {

    public interface MoveCallback {
        void onSuccess(File src, File dest);
        void onFailure(File src, File dest, String reason);
    }

    /**
     * 高效移动文件:同分区使用 renameTo,跨分区复制再删除
     *
     * @param srcFile       原文件
     * @param destDir       目标目录(必须为目录)
     * @param overwrite     是否覆盖同名文件
     * @param backupSource  是否在跨分区复制时保留源文件
     * @param callback      操作回调
     */
    public static void moveFile(File srcFile, File destDir, boolean overwrite, boolean backupSource, MoveCallback callback) {
        if (srcFile == null || !srcFile.exists() || !srcFile.isFile()) {
            if (callback != null) callback.onFailure(srcFile, null, "源文件无效");
            return;
        }

        if (destDir == null || (!destDir.exists() && !destDir.mkdirs())) {
            if (callback != null) callback.onFailure(srcFile, null, "目标目录创建失败");
            return;
        }

        File destFile = new File(destDir, srcFile.getName());

        // 同名处理
        if (destFile.exists()) {
            if (overwrite) {
                destFile.delete();
            } else {
                destFile = getUniqueFile(destDir, srcFile.getName());
            }
        }

        boolean sameVolume = isSameVolume(srcFile, destDir);

        try {
            boolean success;

            if (sameVolume) {
                success = srcFile.renameTo(destFile);
            } else {
                success = copyFile(srcFile, destFile);
                if (success && !backupSource) {
                    success = srcFile.delete();
                }
            }

            if (success) {
                if (callback != null) callback.onSuccess(srcFile, destFile);
            } else {
                if (callback != null) callback.onFailure(srcFile, destFile, "文件移动失败");
            }

        } catch (Exception e) {
            if (callback != null) callback.onFailure(srcFile, destFile, "异常: " + e.getMessage());
        }
    }

    private static File getUniqueFile(File dir, String fileName) {
        File newFile = new File(dir, fileName);
        int count = 1;
        String name = fileName;
        String baseName = name;
        String ext = "";

        int dotIndex = name.lastIndexOf(".");
        if (dotIndex > 0) {
            baseName = name.substring(0, dotIndex);
            ext = name.substring(dotIndex);
        }

        while (newFile.exists()) {
            newFile = new File(dir, baseName + "_" + count + ext);
            count++;
        }
        return newFile;
    }

    private static boolean isSameVolume(File file1, File file2) {
        try {
            StatFs stat1 = new StatFs(file1.getAbsolutePath());
            StatFs stat2 = new StatFs(file2.getAbsolutePath());

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
                return stat1.getBlockSizeLong() == stat2.getBlockSizeLong()
                        && stat1.getBlockCountLong() == stat2.getBlockCountLong();
            } else {
                return stat1.getBlockSize() == stat2.getBlockSize()
                        && stat1.getBlockCount() == stat2.getBlockCount();
            }
        } catch (Exception e) {
            return false;
        }
    }

    private static boolean copyFile(File src, File dest) {
        try (InputStream in = new FileInputStream(src);
             OutputStream out = new FileOutputStream(dest)) {

            byte[] buffer = new byte[8192];
            int length;

            while ((length = in.read(buffer)) > 0) {
                out.write(buffer, 0, length);
            }

            out.flush();
            return true;

        } catch (IOException e) {
            e.printStackTrace();
            return false;
        }
    }
}

调用示例如下:

arduino 复制代码
File src = new File("/sdcard/daniulive/record_temp/20250614_001.mp4");
File destDir = new File("/sdcard/daniulive/record_saved");

FileMover.moveFile(src, destDir, true, false, new FileMover.MoveCallback() {
    @Override
    public void onSuccess(File src, File dest) {
        Log.i("MoveFile", "移动成功: " + dest.getAbsolutePath());
    }

    @Override
    public void onFailure(File src, File dest, String reason) {
        Log.e("MoveFile", "移动失败: " + reason);
    }
});

相关推荐
aqi001 小时前
FFmpeg开发笔记(六十六)Windows给FFmpeg集成LC3音频的编码器liblc3
ffmpeg·音视频·直播·流媒体
aqi0021 小时前
FFmpeg开发笔记(六十五)Linux给FFmpeg集成LC3音频的编码器liblc3
ffmpeg·音视频·直播·流媒体
KeyFafa8884 天前
Android音视频学习(二) — FFmpeg常用的命令(查询命令)
音视频开发
aqi007 天前
FFmpeg开发笔记(六十四)使用国产的RedPlayer播放器观看网络视频
android·ffmpeg·音视频·直播·流媒体
aqi008 天前
FFmpeg开发笔记(六十三)FFmpeg使用vvenc把视频转为H.266编码
ffmpeg·音视频·直播·流媒体
哔哩哔哩技术9 天前
B站画质补完计划(4):SDR2HDR 让观感如临其境 Part.1
音视频开发
二号水泥工10 天前
深度解析阿里云 AUI Kits 互动直播 Web 端:从代码解剖到多端兼容实践
直播
nangonghen10 天前
实时通信RTC与传统直播的异同
实时音视频·直播·rtc
GetcharZp12 天前
Go语言实现屏幕截取+实时推流
后端·音视频开发