
简介JavaFX桌面录屏录音软件完整项目源码适合正在学习Java桌面应用开发、多媒体采集与编码的开发者也适合需要参考录屏软件完整实现方案的初中级工程师。项目基于JavaFX与AWT桥接结合Robot屏幕捕获、javax.sound.sampled音频采集实现了录屏、录音、暂停/恢复、本地播放以及MP4封装保存。源码共48个文件压缩包约14.93MB主要包含Java源码、编译后的class文件、PNG界面切图及JAR依赖库其中javacv、ffmpeg等第三方库支撑视频编码与格式转换便于导入运行和二次开发。项目通过多线程Task/Service处理录制与播放并配有异常处理与事件响应机制清晰展示JavaFX与多媒体底层结合的关键技术。已有6258人学习下载适合希望通过实际项目掌握屏幕采集、音视频同步与JavaFX多媒体编程的读者。1. 从 Robot 到 MP4Java 桌面录屏录音要过的四道坎别指望用一条 API 就能把屏幕和麦克风同时收进 MP4。Java 里最容易想到的Robot.createScreenCapture只能拿到单帧图像TargetDataLine拿到的又是未压缩 PCM 字节要生成兼容播放器的 H.264AAC 的 MP4通常还得靠 FFmpeg 层面的封装。这中间有四道坎抓屏性能撑不起高帧率、音频采样和视频帧各自独立产生时间戳、编码器参数选错导致文件打不开、暂停恢复后音画不同步。每一道都能单独解决但串起来跑才叫软件。这篇内容不讨论虚构的一键成品只拆一个可运行的 Java 桌面录屏录音项目骨架把java、mp4 保存这两个关键词背后的工程细节一次说透。适合打算写培训录屏、自动化演示记录或者想在面试里把多线程和多媒体链路讲明白的人。2. 录屏Robot 连续抓帧与帧率控制2.1 Robot 抓屏为什么慢像素拷贝与缓冲策略java.awt.Robot的createScreenCapture(Rectangle)在 Windows 和 Linux 上最终会调用系统屏幕拷贝接口但它返回的是BufferedImage像素必须从显存拷贝进 JVM 堆。一次 1920x1080 的截屏通常要花 8~30 毫秒取决于系统负载和窗口内容。如果你想做到 25fps每帧预算就只有 40 毫秒把截图和编码串在同一个线程里几乎是必然导致掉帧的。所以常见做法是生产者-消费者录屏线程只负责截屏并放入有界队列编码线程从队列取帧转给 FFmpeg。BlockingQueueBufferedImage frameQueue new ArrayBlockingQueue(30); // 录屏线程 ScheduledExecutorService screenExecutor Executors.newSingleThreadScheduledExecutor(); screenExecutor.scheduleAtFixedRate(() - { BufferedImage frame robot.createScreenCapture(screenRect); if (!frameQueue.offer(frame)) { // 队列满说明编码跟不上丢弃这一帧避免卡死 droppedFrames.incrementAndGet(); } }, 0, 1000 / targetFps, TimeUnit.MILLISECONDS); // 编码线程 while (!stopFlag.get()) { BufferedImage image frameQueue.poll(100, TimeUnit.MILLISECONDS); if (image ! null) { // 编码器写入 } }这段代码里的ArrayBlockingQueue(30)表示最多缓冲 30 帧按 25fps 算大约是 1.2 秒的放映时长。队列太小会让录屏线程频繁阻塞丢帧率上升太大则会让暂停恢复时一次性吐出大量过期帧。scheduleAtFixedRate的第三个参数是触发周期1000 / targetFps把帧率换算成毫秒间隔。需要知道它并不保证每次执行间隔严格精确如果一次截屏耗时就超过了间隔下一次任务会被推迟而不是补跑因此实际帧率会略低于设定值。screenRect指定要捕获的屏幕区域全屏可以取自GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds()但录单个窗口时尤其要注意坐标缩放。Windows 下如果系统缩放比例不是 100%Component.getBounds()返回的可能是逻辑坐标而Robot按照物理像素截屏两者不匹配会让截出来的图像偏移或裁掉一部分。我一般会用Toolkit.getDefaultToolkit().getScreenResolution() / 96.0计算缩放系数再把窗口矩形乘上这个系数。2.2 用 BufferedImage ByteBuffer 把帧交给编码器JavaCV 的FFmpegFrameRecorder可以直接接收BufferedImage并自动转成 YUV420P但每次调用都会复制像素、创建临时对象录久了 GC 压力不小。一个更稳的写法是声明一个ByteBuffer复用区将 ARGB 数据拆成字节后直接送进编码器。import org.bytedeco.javacv.FFmpegFrameRecorder; import java.nio.ByteBuffer; int width screenRect.width; int height screenRect.height; byte[] pixels new byte[width * height * 4]; ByteBuffer pixelBuffer ByteBuffer.wrap(pixels); // 每次截屏后调用 public void pushFrame(BufferedImage image) throws Exception { int[] argb image.getRGB(0, 0, width, height, null, 0, width); for (int i 0; i argb.length; i) { int v argb[i]; pixels[i * 4] (byte) ((v 16) 0xFF); // R pixels[i * 4 1] (byte) ((v 8) 0xFF); // G pixels[i * 4 2] (byte) (v 0xFF); // B pixels[i * 4 3] (byte) ((v 24) 0xFF); // A } pixelBuffer.clear(); recorder.recordImage(width, height, 4, pixelBuffer); }注意getRGB返回的 int 按 A、R、G、B 顺序排列拆字节时必须用位运算取分量不能直接把 int 数组转成 byte 数组。recordImage的四个参数依次是宽、高、通道数、像素缓冲区写4表示 RGBA但部分编码器不接受 alpha 通道。常见的替代方案是把BufferedImage设为TYPE_3BYTE_BGR然后从DataBufferByte里直接拿底层字节数组每帧少一次颜色转换速度更快不过要注意此时通道顺序是 BGR。如果录制的应用本身是静态页面用getRGB完全够用如果要录游戏或动画建议走底层数组路线。2.3 帧率控制与丢帧策略录屏软件最容易犯的错误是不控制帧率让截屏线程while(true)死循环。屏幕静止时连续帧完全相同编码器却要逐帧编入文件体积白白翻倍屏幕高速变化时Robot跟不上画面撕裂。所以我会设定一个基准帧率比如25或30并且允许Robot在系统繁忙时主动丢帧而不是把延迟传导给编码线程。帧率参数要同时配置在三个位置。第一个是录屏线程的scheduleAtFixedRate它决定采集频率第二个是FFmpegFrameRecorder的setFrameRate(25)它告诉编码器时间戳按多少帧率生成第三个是播放器播放时按文件头里的帧率信息做节奏控制。后两者不一致时视频会出现快进或慢放所以必须匹配。丢帧策略常见有两种。第一种是队列塞满时直接丢弃新帧代码就是上面的offer失败后累加计数器第二种是按时间戳判断如果当前帧和上一帧之间的实际间隔小于1000 / targetFps * 0.8就跳过。第二种对 CPU 波动更宽容能让输出时间戳保持单调递增不会出现两帧时间戳完全一样的情况。提示不要把帧率设置成 0 或 -1。部分封装库会用默认值最后生成的 MP4 里 fps 为 0ffprobe会显示0 tbr播放器甚至无法计算时长。3. 录音TargetDataLine 拾取麦克风/声卡输出3.1 AudioFormat 参数选型采样率、位深、声道Java Sound API 通过AudioSystem.getTargetDataLine(AudioFormat)打开录音设备。AudioFormat是你和底层音频驱动的约定参数不对会直接抛LineUnavailableException。做录屏录音时我不会直接用系统默认格式而是显式指定采样率、位深、声道数。位深选 16 位最省心8 位底噪明显24 位在 Java 里要自己处理三字节对齐麻烦且收益有限。采样率选 44100 或 48000这两者都被主流编码器原生支持也兼容大多数声卡。声道数与录制场景有关。只录麦克风单声道足够如果想录系统声音和麦克风混音建议用两个声道分别承载一路输入稍后在编码器里合成。下面的代码演示了如何创建并打开一个输入源import javax.sound.sampled.*; AudioFormat desiredFormat new AudioFormat(48000f, 16, 2, true, false); DataLine.Info info new DataLine.Info(TargetDataLine.class, desiredFormat); if (!AudioSystem.isLineSupported(info)) { System.out.println(当前设备不支持 48k/16bit/双声道尝试降级到 44.1k); desiredFormat new AudioFormat(44100f, 16, 2, true, false); info new DataLine.Info(TargetDataLine.class, desiredFormat); } TargetDataLine microphone (TargetDataLine) AudioSystem.getLine(info); microphone.open(desiredFormat, 4096); microphone.start();AudioFormat构造函数的五个参数分别是采样率、位深、声道数、有符号/无符号、大端/小端。true, false表示有符号小端是 Java Sound 最常见的配置。缓冲区大小4096是每次从系统缓冲一次性读出的样本字节数太小会增加 CPU 占用太大会让开始录音时有一段明显延迟。常见问题里还有一个容易被忽略的点TargetDataLine打开后第一次read可能会返回设备缓冲中的垃圾字节导致文件开头有一段爆音。我的做法是在start()后立刻读取并丢弃前 200 毫秒的数据再开始正式录音。3.2 用 AudioInputStream 和 TargetDataLine 持续读取 PCMTargetDataLine暴露的是字节流通常我会在外层套一个AudioInputStream这样后续接编码器或存 WAV 都方便。但有一个原则不要在录音线程里把裸 PCM 全部累加到内存里等到结束再编码。录 30 分钟 16bit/48k/双声道的裸 PCM 大约 330 MB放在堆里会让 GC 不堪重负。正确做法是边读边交给编码器或先写到临时文件。byte[] buffer new byte[4096]; int bytesRead; while (recording.get()) { bytesRead microphone.read(buffer, 0, buffer.length); if (bytesRead 0) { // 注意 bytesRead 不总是 buffer.length最后一块可能不足 recorder.recordSamples(0, 2, buffer, 0, bytesRead); } }microphone.read是阻塞式的调用microphone.stop()后它不会立刻返回需要等系统底层缓冲耗尽。因此通常用volatile boolean recording控制循环而不是在read线程外强行中断。停止录音后还要调用microphone.drain()把残余数据从设备冲洗出来否则最后几百毫秒音频会丢失。recordSamples的第一个参数是声道偏移第二个参数是总声道数后面的buffer是交错的 PCM 字节。这里的0, 2表示把立体声数据按左右声道交错的方式提交给编码器。如果通道数写错比如把立体声数据当成单声道提交会出现左右声道混叠听感上像「水下收音」。注意Java 标准 API 不支持直接采集系统输出loopback常见的做法是安装虚拟声卡把系统声音重定向到虚拟输入设备然后 Java 代码打开这个设备的TargetDataLine。录屏软件通常需要这个能力但不同平台的实现差异较大项目中要把采样设备做成可配置项。3.3 录音与录屏的时钟对齐时间戳与缓冲区水位音画不同步是录屏软件最明显的问题。录屏线程由ScheduledExecutorService驱动录音线程由声卡驱动两个时钟源并不完全一致。声卡晶振可能有几十 ppm 的漂移录 10 分钟就会积累几十毫秒误差。直接比较两个线程的纳秒时间戳是不可靠的因为采样缓冲延迟和调度延迟会不断变化。我会采用一种简单的时间戳对齐方式以视频时间为准音频不记录绝对时间只记录从录音开始到当前样本的累计样本数。合成 MP4 时告诉编码器固定的视频帧率和固定的音频采样率FFmpeg 会根据这两个固定参数自动在复用阶段补偿漂移。即使视频丢了几帧音频也不会对不上。recorder new FFmpegFrameRecorder(outputFile, width, height, 2); recorder.setFrameRate(25); recorder.setSampleRate(48000); recorder.setAudioChannels(2); recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);setFrameRate和setSampleRate是编码器内部时间戳的基础。一旦设置后视频帧按1/25秒递增音频样本按1/48000秒递增。暂停功能必须同步冻结两条时间戳线路只停视频不停音频会让音画以秒级速度失配这就是下一章要解决的核心问题。4. 合成 MP4JavaCV/FFmpeg 封装与暂停/播放实现4.1 为什么选择 JavaCV 而不是 JCodec 或命令行把BufferedImage和 PCM 合成 MP4纯 Java 方案里有 JCodec外部进程方案里可以直接调ffmpeg命令行而 JavaCV 是封装 FFmpeg native 库的中间层。JCodec 足够轻量但它的 H.264 编码器是简化实现兼容性不如 FFmpeg音频支持也比较薄弱命令行方案最稳定却很难做出精细暂停因为无法在编码过程中挂起进程暂停后重新拼文件又等于重新编码。JavaCV 的优势是既保留 FFmpeg 的编码质量又能让我们从 JVM 里实时地推入帧和音频样本。dependency groupIdorg.bytedeco/groupId artifactIdjavacv/artifactId version1.5.9/version /dependency dependency groupIdorg.bytedeco/groupId artifactIdffmpeg-platform/artifactId version6.0-1.5.9/version /dependency这不是唯一可用的版本组合但如果版本号对不上运行时会出现NoClassDefFoundError或 native 库加载失败。我的习惯是先确定 JavaCV 版本再去 Maven 仓库找配套的ffmpeg-platform不要直接写最新数字。另外javacv-platform会把 OpenCV、FFmpeg 和一堆无关 native 库全部带入正式项目里尽量用两个更精细的依赖减少部署体积和崩溃面。4.2 视频编码器参数CRF、preset、音频混流FFmpegFrameRecorder暴露的参数与 FFmpeg 命令行基本对应。视频编码我固定走 H.264常用参数有preset、crf、gop。crf控制质量范围约 18~28数值越小质量越高文件越大桌面录屏推荐值在 23 附近。preset影响编码速度和压缩率实时录屏适合veryfast它比medium速度快很多文件体积只多 5%~10%。参数推荐值说明setFrameRate25 或 30必须与采集线程的目标 fps 一致setVideoCodecH.264兼容性最好的封装videoOption(preset)veryfast实时录屏优先保速度videoOption(crf)23屏幕内容质量控制点videoOption(gop)25每 25 帧一个关键帧便于拖动和暂停setAudioBitrate128k~192k说话声 128k含音乐可提到 192k代码配置如下recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P); recorder.setVideoOption(preset, veryfast); recorder.setVideoOption(crf, 23); recorder.setVideoOption(gop, 25); recorder.setAudioBitrate(128 * 1000); recorder.start();AV_PIX_FMT_YUV420P是 H.264 编码器最常用的输入格式色度采样 4:2:0。JavaCV 会把BufferedImage自动转为这个格式。这里有一个细节桌面截屏的颜色范围是 full range转换后如果没有额外指定FFmpeg 默认会按全取值范围处理通常不会发灰。如果你发现播放器里视频对比度异常再检查color_range这个选项。4.3 暂停录制暂停逻辑与合成时间戳修复暂停不是简单停掉一个线程。视频和音频都有各自的缓冲队列和时钟状态只停视频采集会让音频继续向前走恢复后画面比声音时间早了几十秒只停音频则会让视频继续编码麦克风里没有声音时画面仍然在动。暂停必须同时冻结两条数据通路并且清理掉暂停期间积压的采样数据和视频帧。private volatile boolean paused false; public void pauseRecording() { paused true; // 让视频采集线程停止往 frameQueue 放新帧 // 让音频线程只 read 不 record } public void resumeRecording() { paused false; // 丢弃音频设备缓冲中的旧数据 microphone.flush(); // 清空视频队列中残留的帧 frameQueue.clear(); }microphone.flush()的作用是清空系统声卡缓冲避免暂停期间的音频在恢复后继续进入编码器。暂停期间音频线程如果仍在read应该直接丢弃读到的字节直到恢复调用recordSamples。视频线程同理暂停时仍然可以截屏用于实时预览但不要再把截图放入编码队列。暂停恢复后最容易被忽略的是时间戳修复。FFmpegFrameRecorder内部维护一个视频时间戳恢复后如果直接继续写入它会认为新帧是紧接着旧帧来的时间差并没有把暂停时长算进去。修复方式是在resumeRecording()里记录暂停持续的纳秒数之后每一帧写入前把这一帧的时间戳手动加上暂停时长。JavaCV 的FFmpegFrameRecorder提供了setTimestamp接受微秒单位的时间戳但更简单的做法是把暂停时间换算成「跳过的帧数」恢复后先写几帧黑屏或直接调用recorder.record空跑实际项目中我倾向于直接暂停编码线程让编码器一直等待新帧然后恢复后第一帧的时间戳从当前系统时间重新计算。4.4 播放器用 JavaFX MediaPlayer 预览并验证同步录制完成后需要把视频呈现给用户。JavaFX 的MediaPlayer是桌面 Java 项目里最顺手的播放组件可以直接加载 MP4 文件支持暂停和进度条。最小可用代码Media media new Media(new File(mp4Path).toURI().toString()); MediaPlayer player new MediaPlayer(media); MediaView view new MediaView(player); player.volumeProperty().set(1.0); player.play();MediaPlayer首次play()会有几百毫秒初始化延迟这是正常的。如果做的是录屏软件建议先setAutoPlay(false)在setOnReady回调里再播放可以避免黑屏误判。验证音画同步时把进度跳到暂停时刻附近观察说话人的嘴型与声音是否一致几百毫秒的错位能直接听出来。如果需要量化检查用下一章的ffprobe命令看音视频流的时间差。5. 收官细节文件大小与音画同步的三个实战验证5.1 用 ffprobe 验证 MP4 的流信息和时长录完文件不能直接交付。我用 FFmpeg 自带的ffprobe检查输出文件的基本属性ffprobe -v error -show_entries formatduration,size -show_entries streamcodec_type,codec_name,width,height,sample_rate,channels -of json output.mp4这条命令会输出 JSON可以直接看到视频流编码是否为h264音频流是否为aac时长是否接近录制时的秒数。一个容易踩的坑是format.duration和实际录制时间相差过大比如录了 60 秒文件却说只有 52 秒这通常说明录制过程中丢帧严重。另一个情况是输出文件里没有音频流原因是音频数据在编码器start()后始终没有提交过FFmpeg 在写文件头时认为没有音频轨。解决方法是start()后立刻写入一小段静音 PCM把音频流创建出来。5.2 常见坑黑帧、爆音、暂停后时间戳跳变黑帧多半不是截屏截出来的黑而是编码器还没收到关键帧时播放器无法解出画面。H.264 关键帧间隔默认可能很长如果录制两三秒就停止播放器一直等不到 IDR 帧画面就是黑的。把gop设为 25每 25 帧一个关键帧能显著改善这个问题。爆音的来源很宽泛最隐蔽的是TargetDataLine第一次read时读到的设备缓冲垃圾数据。我的做法是start()后立刻读取并丢弃前 200 毫秒音频再开始正式录音。暂停恢复处的爆音则是另一回事那是旧缓冲和新缓冲在编码器里拼接造成的需要在恢复前调用microphone.flush()。暂停后时间戳跳变是三者中最难排查的。如果恢复录制时没有清空视频队列编码器会先消化完队列里的旧帧再写入新帧导致画面突然跳回暂停前的几秒。所以在resumeRecording()里除了flush音频还要frameQueue.clear()同时把内部时间戳重新校准。可以用一个AtomicLong记录下一帧应写入的时间戳恢复时把它加回当前时刻这样播放进度才连贯。5.3 把录制核心提取成可复用的 Recorder 接口到这一步录屏、录音、编码、暂停、播放的代码已经完整立起来了。最后我会把它们收进一个稳定的接口方便上层 UI 调用和将来扩展。public interface Recorder { void start() throws Exception; void pause(); void resume(); void stop() throws Exception; File getOutputFile(); RecorderState getState(); }视频采集线程、音频采集线程、编码器全部由这个接口的实现统一调度UI 层只需在按钮回调里调用pause()和resume()不需要知道内部清理了几个缓冲区。将来如果要做「屏幕 摄像头画中画」只需要提供另一个Recorder实现接口不变。这个设计在 Java 面试里也很有讲头队列缓冲、线程编排、时钟对齐三个点都能展开比直接说「我调了 JavaCV 库」要有价值得多。把Recorder接口挂一个测试类录 30 秒桌面并对比输出时长和音画同步情况这套代码就可以放心复用到个人工具或课程录制项目里。本文还有配套的精品资源点击获取