简介:一款基于JavaFX的桌面录屏录音软件完整源码,面向熟悉Java语法、希望进阶桌面应用与多媒体开发的读者,用来解决录屏、录音、暂停、播放和MP4导出的完整流程实现。项目通过Robot类定时抓取屏幕图像,使用Java声音API采集麦克风音频,并借助javacv、ffmpeg等第三方库完成H.264视频与AAC音频编码,最终封装为MP4文件,是理解JavaFX界面与AWT底层屏幕捕获协同工作的典型示例。压缩包共48个文件,主要包含Java源码、编译后的class文件、运行所需的依赖jar包、界面预览png图片及工程配置文件,压缩后约14.93MB,目录划分清楚,方便按模块查找。源码中除了核心的录制、音频采集、播放和编码模块,还涉及多线程任务、JavaFX事件驱动和异常处理,读者可以从主入口逐步看懂屏幕帧获取、音频同步和暂停恢复的实现细节。目前已有6258人浏览学习,对于想自己动手开发桌面录屏工具,或希望掌握Java多媒体处理与第三方编码库集成的开发者,是一份参考价值很高的实践案例。
1. 从捡起一个录屏需求说起
接到"做一个桌面录屏录音工具"这个需求时,如果你第一反应是去JavaFX的API里找屏幕捕获能力,多半会扑个空。JavaFX把精力花在UI渲染和事件模型上,压根没有提供抓屏的接口——真正能拿到屏幕像素的,是AWT里那个出道二十多年的Robot类。所以这个项目最反直觉的一点在于:一套UI是现代JavaFX,底层采集却是"老古董"AWT,然后再用JavaCV把帧序列编码成MP4。这恰是Java桌面多媒体开发的典型组合,适合想搞清"录屏到底怎么把帧、音频、编码器串起来"的人。下文按选型拆解、抓帧录音、编码封装、播放排错的顺序,完整过一遍能落地的实现。
2. 技术选型:为什么是 AWT + JavaCV,而不是纯 JavaFX 方案
2.1 录制链路里四个模块的职责边界
录屏录音软件拆开看是四条独立管线:界面控制、帧采集、音频采集、编码封装。界面这层用JavaFX没有争议,Application基类、Stage窗口、Scene场景树,事件驱动天然适合开始/暂停/停止这类操作。关键是后三层:帧采集在Java标准库内只有java.awt.Robot一个选择,它能把屏幕指定区域截成BufferedImage;音频采集标准库提供javax.sound.sampled,通过TargetDataLine从麦克风读PCM数据;编码封装则绕不开第三方库,MP4容器和H.264压缩标准库一个都不支持,项目里引入的JavaCV正是干这个的。
音频采集这条线容易被人忽略,但javax.sound.sampled在暂停恢复的场景下有个天然优势:TargetDataLine的read()方法按字节流读取,暂停时只要停止调用read(),缓冲区的数据不会丢也不会跳秒,恢复时继续读就能接上。如果用第三方音频库,反而要额外处理缓冲区和时间戳的拼接逻辑。
2.2 JavaCV 封装了什么,四个 jar 各自的作用
项目libs目录下那几个jar,javacv.jar是JavaCV的核心API,ffmpeg.jar是FFmpeg的Java绑定,ffmpeg-platform.jar带一整套跨平台native库,ffmpeg-windows-x86_64.jar则把Windows 64位系统对应的FFmpeg动态库单独抽出来。说直白点,JavaCV就是FFmpeg的Java门面,你不需要用JNI去写C代码调用FFmpeg,而是直接操作FFmpegFrameRecorder和FFmpegFrameGrabber这两个类。
javacpp.jar这层容易被忽略,它是JavaCPP的底层支撑,负责在Java和C++之间搬数据。没有它,JavaCV调不动FFmpeg的native库。理解这个依赖链对排查环境问题有用——哪天程序启动报UnsatisfiedLinkError,九成是native库没加载上,而不是你的业务代码出问题。
2.3 为什么不用纯FFmpeg命令行,也不用JCodec
外部调用FFmpeg进程(ProcessBuilder拼命令行)是另一种做法,但"进程间通信+文件中转"意味着每一帧都要写临时文件,而且暂停函数要做进程挂起而不是简单的线程挂起。JCodec知名度不如JavaCV,H.264编码支持也不完整,遇到"暂停后继续录制"这种操作基本没有现成方案。JavaCV的方案把编码器内嵌到进程内,帧数据从BufferedImage直接转Frame,内存操作,暂停逻辑只在业务层做,可控性强得多。
3. 帧采集与音频录制的并发实现
3.1 Robot 抓帧 + 定时器,帧率怎么定
Robot的createScreenCapture方法拿到的是BufferedImage,但JavaCV的编码器要的是Frame,中间需要一次类型转换。标准做法是Java2DFrameConverter的convert()方法,把BufferedImage变成FFmpeg认识的Frame。帧率直接决定文件大小和流畅度,录屏软件一般取15到30 FPS,PPT讲解类内容15 FPS足够,操作演示类建议30 FPS。
// 定时任务里执行抓帧和编码 private void captureFrame() { BufferedImage image = robot.createScreenCapture(screenRect); Frame frame = converter.convert(image); try { recorder.record(frame); } catch (FFmpegFrameRecorder.Exception e) { e.printStackTrace(); } } // 主线程启动定时任务,2秒延迟后开始 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(this::captureFrame, 2000, 1000 / FPS, TimeUnit.MILLISECONDS);createScreenCapture需要传入一个Rectangle对象,表示捕获区域。如果要全屏录制,用GraphicsEnvironment.getLocalGraphicsEnvironment().getMaximumWindowBounds()拿整个虚拟屏幕的边界,这样能覆盖多显示器场景。scheduleAtFixedRate的第三个参数1000 / FPS就是帧间隔,用固定速率调度比scheduleWithFixedDelay更合适——后者是"上一帧执行完再等一个间隔",帧率会越来越低。定时器线程要单独建,不要占UI线程,否则界面一卡,抓帧和编码全部停摆。
3.2 TargetDataLine 录音通道的参数校对
javax.sound.sampled的音频采集比想象中简单,核心就是AudioFormat和TargetDataLine。格式参数必须和编码器预期匹配:采样率44100 Hz是CD音质,48 kHz是视频常用标准(推荐后者),16位量化精度,单声道录人声,双声道保留环境声。JavaCV里设置音频参数时,这几个值必须和AudioFormat一致,不然后期record会报采样率不匹配的错。
AudioFormat format = new AudioFormat(48000, 16, 2, true, true); DataLine.Info info = new DataLine.Info(TargetDataLine.class, format); TargetDataLine microphone = (TargetDataLine) AudioSystem.getLine(info); microphone.open(format); microphone.start(); byte[] audioBuffer = new byte[8192]; while (isRecording) { int bytesRead = microphone.read(audioBuffer, 0, audioBuffer.length); recorder.recordSamples(48000, 2, 16, audioBuffer, bytesRead); }recordSamples是FFmpegFrameRecorder提供的方法,直接喂PCM裸数据,不用手动封装成Frame。bytesRead是实际读到的字节数,音频设备IO不稳定时,返回值小于audioBuffer.length是正常现象,必须用返回值而不是数组长度。八个参数的含义分别是:采样率、声道数、位深、样本数据、有效长度。
注意:
record和recordSamples不能混用在一个录制周期里,否则编码器内部的状态机可能错乱。视频走record(Frame),音频固定走recordSamples,各走各的通道。
3.3 暂停与恢复的正确打开方式
暂停是整个项目里最容易写崩的地方。刚接触录制功能的人很容易想到"暂停就停线程,恢复就重启线程",但这样写,recorder内部的时间戳就断了——编码器认为你新建了一条视频流,而不是在原来那条上续录。FFmpegFrameRecorder的record方法会在内部累计PTS(显示时间戳),线程停下再恢复,PTS断档,播放时画面会卡住几秒甚至跳帧。
我一般用标志位加等待策略,而不是销毁线程:
private volatile boolean isPaused = false; private final Object pauseLock = new Object(); private void captureFrame() { synchronized (pauseLock) { while (isPaused) { try { pauseLock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } // 继续执行抓帧和编码 } public void pause() { isPaused = true; } public void resume() { synchronized (pauseLock) { isPaused = false; pauseLock.notifyAll(); } }wait/notify在scheduleAtFixedRate里有个好处:暂停时线程还在,只是阻塞在wait()上,恢复后直接从下一帧开始,recorder的时间轴是连续的。录制时长如果要用当前时间减开始时间,暂停时要把暂停的累计时间减掉,否则恢复后文件时长比实际多一段。
4. JavaCV 编码管线:帧数据怎么变成 mp4 文件
4.1 FFmpegFrameRecorder 关键参数与设备兼容性
编码器初始化参数直接决定输出MP4的可用性,这段配置踩过的坑最多。
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder( outputFile, screenWidth, // 视频宽 screenHeight, // 视频高 2 // 声道数,纯录屏可传 0 ); recorder.setFormat("mp4"); recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264); recorder.setVideoBitrate(3000 * 1000); // 3 Mbps recorder.setFrameRate(FPS); recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC); recorder.setSampleRate(48000); recorder.setAudioBitrate(192 * 1000); // 192 kbps recorder.start();视频编码器选了AV_CODEC_ID_H264,这是目前兼容性最好的选择,Windows Media Player和手机相册都能播。setVideoBitrate的值按分辨率估算:1080P屏幕建议4000到8000 kbps,720P给2000到4000够用。码率给太高文件膨胀,给太低文字边缘全是水波纹。音频编码器用AAC,比MP3在低码率下保留的人声更干净。
注意:JavaCV的H.264编码器默认是
libx264软件编码,CPU占用会明显偏高。如果目标机器性能差,可以把视频编码器换成AV_CODEC_ID_MPEG4,兼容性稍差但编码速度快不少。
4.2 音视频交错时的 PTS 对齐逻辑
MP4容器的音视频交错(interleave)靠PTS完成,JavaCV的FFmpegFrameRecorder内部会处理大部分对齐工作,但前提是两路时间戳的基准一致。视频帧按1000/FPS毫秒递增,音频按采样率递增,编码器会自动换算成统一的时间基。需要注意的是start()之后不要立刻record,最好先睡200到300毫秒,让编码器完成内部缓冲区的初始化,否则前几帧容易丢。
这个细节在暂停恢复后重新编码时再踩一次:恢复后的第一帧,PTS如果从零开始,编码器会认为这是新视频段,输出文件在播放器里表现为"进度条不走但画面在动"。JavaCV的record方法会自动延续之前的PTS计数,前提是recorder对象没有被重新start()。
4.3 退出时资源释放的正确顺序
录制结束的资源释放顺序有讲究:先停音频流,再停定时任务,最后调recorder.close()。
public void stop() { isPaused = false; isRecording = false; scheduler.shutdownNow(); microphone.stop(); microphone.close(); try { recorder.close(); } catch (FFmpegFrameRecorder.Exception e) { e.printStackTrace(); } }microphone.stop()和microphone.close()之间的顺序不能颠倒,stop()是停止数据采集,close()是释放设备句柄。如果先close()再stop(),部分Windows声卡驱动会抛IllegalStateException。recorder.close()会flush编码器缓冲区并写入MP4的moov box,这个操作可能耗时几百毫秒到几秒,取决于录制的时长和码率,所以stop()必须在后台线程调用,不能在JavaFX的Platform.runLater里同步执行,否则界面会卡住。
5. JavaFX 界面与录制状态机的对接
5.1 Platform.runLater 与后台录制线程的协作
JavaFX的UI线程不能阻塞,录制和编码必须跑在后台线程。但界面的按钮状态(开始/暂停/停止切换)要随录制状态变化,这就涉及线程间通信。Platform.runLater是把界面更新操作扔回UI线程的经典手段,注意别在录制循环里频繁调用它——每帧都调runLater去刷新状态Label,UI线程会被回调淹没,录出来的视频反而因为UI卡顿而掉帧。
状态机建议只维护四个状态:IDLE、RECORDING、PAUSED、STOPPED,界面根据状态切换按钮可点性。开始按钮在IDLE时可用,暂停按钮只在RECORDING时可用,恢复按钮只在PAUSED时可用,停止按钮只要不是IDLE就能点。
public void startRecording() { if (recorderState != State.IDLE) return; recorderState = State.RECORDING; startTime = System.currentTimeMillis(); recordingTask = new RecordingTask(); Thread thread = new Thread(recordingTask, "screen-recorder-thread"); thread.setDaemon(true); thread.start(); Platform.runLater(this::updateButtonState); }RecordingTask是一个Runnable,内部执行的是JavaCV编码循环。捕获区域和音频格式都在这之前就配置好,这样任务线程启动后能立即进入captureFrame循环。setDaemon(true)保证应用关窗口时录制线程不会阻止JVM退出,但这对录制类应用是把双刃剑——窗口关了线程直接死掉,MP4文件没写完。所以窗口关闭事件里必须显示调用stopRecording(),不能依赖守护线程的自动清理。
5.2 MediaPlayer 播放验证:录制结果能不能播
录制完的MP4能否在应用内直接播放,取决于JavaFX的MediaPlayer支持情况。JavaFX的MediaPlayer仅支持H.264/AAC编码的MP4,以及部分MP3、WAV格式。如果你往里放一个MPEG4 Part 2编码的MP4,播放器只会报MediaException: MEDIA_UNSUPPORTED,不会给你任何"编码格式不对"的提示——这是踩坑最容易懵的地方。
播放的核心代码很短:
Media media = new Media(Paths.get(outputFilePath).toUri().toString()); mediaPlayer = new MediaPlayer(media); mediaView.setMediaPlayer(mediaPlayer); mediaPlayer.setAutoPlay(true);代码就这三行,但MediaView要想正确显示画面,必须添加到布局容器里,而且要设置合适的fitWidth和fitHeight,否则JavaFX按视频原始尺寸渲染,录个1080P的MP4,MediaView默认就占满整个窗口。MediaPlayer是重量级组件,一次只能存在一个实例,切换播放文件前必须调用dispose()释放旧实例,否则第二次播放会一直黑屏。
6. 多线程排错与帧率自适应的编码技巧
6.1 录制中途切换分辨率或缩放比例怎么办
录屏时如果目标应用从全屏切到窗口模式,屏幕分辨率不变但捕获区域内容大变,Robot仍然能继续抓,但编码器的分辨率参数是在start()时定死的,不能中途改。我一般捕获区域固定为"虚拟屏幕的完整尺寸",然后把需要录制的窗口位置和大小换算到屏幕坐标系里,通过setCaptureRect动态调整。
但说实话,"动态改分辨率"在录制场景里很难处理干净——画面会拉伸或出现黑边。更稳妥的方案是启动录制前让用户固定选好区域,录制过程中不提供改分辨率的功能,只支持"预览区域"的缩放。这个约束在交互设计上就告诉用户:录屏的分辨率在开始时确定,中途改动不在能力范围内。
6.2 帧率掉不下来:动态降帧策略
录制高动态画面(比如拖动窗口、播放视频)时,scheduleAtFixedRate固定30 FPS可能让CPU和内存都吃紧。我一般会在帧采集后检查时间戳,如果前100帧的平均编码耗时超过50 ms,就意味着当前机器跑不满30 FPS,这时手动跳过部分帧。跳过帧也会导致PTS不连续,播放端表现为"快进感"。所以最优解是降低目标帧率而不是跳帧。
private void captureFrame() { long startNanos = System.nanoTime(); BufferedImage image = robot.createScreenCapture(screenRect); Frame frame = converter.convert(image); recorder.record(frame); long elapsed = (System.nanoTime() - startNanos) / 1_000_000; if (elapsed > frameIntervalMs) { int newFps = (int) (FPS * 0.9); // 降帧率 10% adjustFrameRate(newFps); } } private void adjustFrameRate(int newFps) { if (newFps >= minFps) { recorder.setFrameRate(newFps); FPS = newFps; } }adjustFrameRate里调recorder.setFrameRate()是JavaCV支持的做法,它会在编码器内部调整时间基,不产生PTS中断。注意加一个下限保护(minFps),否则极端情况下帧率会一路降到个位数,编码器会抛异常。
6.3 时间换算:录制时长还是暂停时长
暂停功能如果做了,录制总时长的展示就有两个口径:自然时间(从开始到停止的墙钟时间)和有效时长(去掉暂停段)。JavaFX界面上显示"录制时长 02:13"时,用户期望的是有效时长——暂停的那段时间不该算进去。实现上维护一个pausedDuration累加器:
public void pause() { if (isRecording && !isPaused) { pauseStart = System.currentTimeMillis(); isPaused = true; // 暂停录制任务,但不停止 recorder } } public void resume() { if (isPaused) { pausedDuration += System.currentTimeMillis() - pauseStart; isPaused = false; pauseStart = 0; } } public long getEffectiveDuration() { return isRecording ? System.currentTimeMillis() - startTime - pausedDuration : 0; }getEffectiveDuration同时考虑录制中状态,一旦停止就不再更新。这段逻辑与第3.3节中暂停的wait/notify配套,暂停点击时更新pausedDuration,恢复时重置pauseStart。时间精度到毫秒足够,别去用System.nanoTime(),转换成本高且没有意义。最终MP4的duration由编码器的PTS决定,所以暂停逻辑写了多久,文件的时间轴就是多久,与UI显示的有效时长完全对上。
本文还有配套的精品资源,点击获取