简介:这份压缩包是一份JavaFX制作的简易音乐播放器完整源码示例,主要面向正在学习Java桌面应用开发的初学者,也适合需要快速搭建播放器原型或复习JavaFX组件的开发者参考。包内共35个文件,包括13个编译好的class文件、8个java源文件、2个fxml界面布局文件,以及项目配置、prefs、构建脚本和备份文件等;整体体积只有30KB,结构紧凑,没有冗余资源,便于逐文件阅读和修改。资源发布以来已有1371人学习下载,具备一定的参考热度。通过这份源码可以清晰看到JavaFX项目如何划分UI层与逻辑层:FXML文件负责界面结构,Java代码处理播放、暂停、切歌等交互,同时还能参考其Maven或Eclipse工程配置方式,了解从源码编译到运行的完整组织方法。对想要动手实践JavaFX媒体播放功能的读者来说,是一份轻量便捷的练习素材。
1. 简易音乐播放器:比想象中简单,也比想象中容易翻车
用 JavaFX 做一个"简易音乐播放器",听起来像是某高校课程设计的老题目,但真动手做过的开发者都知道:把界面搭出来只要半天,让播放器在切歌、拖进度、换音频格式时不崩、不卡、不出玄学问题,才是真正耗时间的地方。这个项目本质上是"JavaFX 桌面应用骨架 + MediaPlayer 状态机 + 本地文件 IO"三件事的组合,解决的是你手里正好有一批本地音频、想在桌面端用一个轻量工具按列表播放,又不想被重型播放器绑定的场景。适合刚学完 Java 语法、准备第一个图形界面项目的学生,也适合需要内部工具快速播放音频文件的一线开发者。我是按"先能出声,再像播放器,最后能交付"的顺序来做,这条路最稳。
2. 选型与骨架:为什么是 JavaFX 的 MediaPlayer,而不是 Swing 或命令行
2.1 技术选型:JavaFX 正好有 Media 与 MediaPlayer,但底层是平台编解码器
做音乐播放器,第一步不是写 UI,而是选对能播放音频的 API。Swing 本身没有成熟的音频播放组件,JLayer 这类第三方库只解 MP3,WAV 又得自己读采样数据再送音频线,代码量会失控;命令行调用外部播放器则完全拿不到播放进度和状态回调。JavaFX 自带javafx.scene.media包,里面有Media、MediaPlayer、MediaView三个核心类,其中MediaView是给视频用的,做纯音频播放器不需要它,但前面的课程设计例子经常把它一起拉进来,这是新手常见的第一个多余动作。
Media负责描述"放什么",它接收一个 URL 字符串;MediaPlayer负责"怎么放",包括播放、暂停、停止、跳转、音量、循环等操作。要特别明确一点:JavaFX 的MediaPlayer并不是自己解码音频的播放引擎,它通过 JavaFX Media 框架调用当前操作系统的底层多媒体编解码能力。这意味着"能不能放这个文件"取决于你的操作系统和 JavaFX 运行时带了多少原生编解码器,而不是你写的 Java 代码。MP3、WAV、AAC 在主流平台上通常没问题,FLAC 这类无损格式在部分平台的标准 JavaFX 构建里会直接抛MediaException,这个前置认知能帮你省掉后面一整夜的排查时间。
2.2 先搭一个能出声的最小工程:模块描述、目录结构与启动类
我一般建议先用 Maven 构架一个最小 JavaFX 工程,目录固定为标准的src/main/java结构,不接 FXML,不接 Scene Builder,先把Application子类跑起来再说。JavaFX 从 11 开始不再是 JDK 自带的一部分,所以必须在pom.xml里显式声明依赖,这里不写死版本号,用你本机安装的 JavaFX 对应发行版即可。
工程里第一个要写的是module-info.java,JavaFX 11 之后推荐用模块描述文件声明依赖:
module music.player { requires javafx.controls; requires javafx.media; exports musicplayer; }逻辑说明:requires javafx.controls是因为要用Button、ListView、Slider这些控件;requires javafx.media是本次项目的核心依赖,提供Media和MediaPlayer;exports musicplayer把主包导出给外部模块。如果没有这一步,运行时大概率报ModuleNotFoundException或IllegalAccessError,而且不一定第一眼看得懂。
参数说明:如果你的工程里后续要读取 FXML 并通过反射注入控制器,还需要opens musicplayer to javafx.fxml。这个模块描述是 JavaFX 项目里最容易踩的隐性地雷,少写requires javafx.media时项目能编译,一运行就崩。
2.3 加载本地音频并播放:Media、MediaPlayer 与线程模型的关系
最小可播放代码,是理解这三个类关系的最短路径。下面这段代码不需要 UI,直接在start方法里播放一首音频文件:
package musicplayer; import javafx.application.Application; import javafx.scene.media.Media; import javafx.scene.media.MediaPlayer; import javafx.stage.Stage; import java.nio.file.Path; public class Main extends Application { @Override public void start(Stage primaryStage) { Media media = new Media(Path.of("song.mp3").toUri().toString()); MediaPlayer mediaPlayer = new MediaPlayer(media); mediaPlayer.setOnReady(() -> { System.out.println("时长: " + mediaPlayer.getTotalDuration().toSeconds() + " 秒"); mediaPlayer.play(); }); primaryStage.setTitle("最小播放器"); primaryStage.show(); } public static void main(String[] args) { launch(args); } }逻辑说明:Media的构造参数必须是 URL 格式。很多人会直接传"song.mp3"或"file:///song.mp3",前者会被当作相对 URL 解析失败,后者在带空格或中文路径时大概率翻车。用Path.toUri().toString()是通用做法,它会自动完成编码和绝对路径转换。setOnReady是播放器状态变为 ready 时的回调,只有进入 ready 状态后getTotalDuration()才有效,这时才能安全调用play()。
参数说明:MediaPlayer有setOnPlaying、setOnPaused、setOnStopped、setOnEndOfMedia、setOnError这一组状态回调,后续的播放列表逻辑全靠它们驱动。在还没有进入 ready 状态之前调用play()不会报异常,但音频设备可能没有正确初始化;在错误状态下调用stop()则可能触发MediaException。这个最小工程能出声,说明环境没问题,接下来才值得搭 UI。
3. UI 与状态联动:从「能出声」到「像一个播放器」
3.1 用 BorderPane 搭主界面:中置播放列表、底置控制栏
播放器的界面布局是有固定套路的:左侧或中间放列表,底部放控制栏,顶部可以放标题和搜索框。JavaFX 的BorderPane正好适合这种结构,它把窗口分成上、下、左、右、中五个区域。我一般把ListView放在中心区,把控制栏放在bottom区,这样窗口尺寸变化时列表能自动撑开,控制栏保持固定高度。
import javafx.application.Application; import javafx.geometry.Insets; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.ListView; import javafx.scene.control.Slider; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.stage.Stage; import java.io.File; public class PlayerApp extends Application { private ListView<File> playlistView = new ListView<>(); private Label timeLabel = new Label("00:00 / 00:00"); private Slider progressSlider = new Slider(0, 100, 0); @Override public void start(Stage primaryStage) { BorderPane root = new BorderPane(); Button prevButton = new Button("上一首"); Button playButton = new Button("播放"); Button nextButton = new Button("下一首"); HBox controlBar = new HBox(10, prevButton, playButton, nextButton, progressSlider, timeLabel); controlBar.setPadding(new Insets(10)); root.setCenter(playlistView); root.setBottom(controlBar); primaryStage.setScene(new Scene(root, 720, 480)); primaryStage.setTitle("JavaFX 简易音乐播放器"); primaryStage.show(); } public static void main(String[] args) { launch(args); } }逻辑说明:ListView<File>直接以文件对象作为列表项,比存字符串更合理,因为后续播放时需要的是文件的路径和 URI,界面展示时再通过toString()取文件名。控制栏用HBox排列,Slider放在按钮和标签之间,保持"上一首/播放/下一首/进度/时间"的常见播放器顺序,符合用户习惯。
参数说明:Slider(0, 100, 0)的三个参数是最小值、最大值、当前值。这里暂时用 0 到 100 的百分比值,等播放器就绪后再根据音频总时长动态调整范围。HBox(10, children)的第一个参数是子节点间距,单位是像素,10 像素是比较紧凑又不拥挤的间距。
3.2 播放列表 ListView 与当前歌曲高亮:选择、双击播放与索引同步
有了静态界面,下一步给列表装行为。需要支持两种触发方式:单击选中但不播放、双击播放、播放结束后自动播放下一条。当前播放的歌曲要能在列表中高亮出来,方便用户知道耳朵里听到的是哪一首。
playlistView.setCellFactory(param -> new ListCell<>() { @Override protected void updateItem(File file, boolean empty) { super.updateItem(file, empty); if (empty || file == null) { setText(null); } else { setText(file.getName()); } } }); playlistView.getSelectionModel().selectedIndexProperty().addListener((obs, oldIndex, newIndex) -> { if (newIndex.intValue() >= 0 && playlistView.getItems().size() > newIndex.intValue()) { currentIndex = newIndex.intValue(); } }); playlistView.setOnMouseClicked(event -> { if (event.getClickCount() == 2) { int index = playlistView.getSelectionModel().getSelectedIndex(); if (index >= 0) { playAt(index); } } });逻辑说明:setCellFactory自定义了列表项的展示方式,默认File.toString()会显示完整路径,改成file.getName()后界面只显示文件名。selectedIndexProperty的监听器用来维护当前选中索引,这样即使用户只单击不双击,播放逻辑也能知道自己应该从哪个位置继续。双击判定用getClickCount() == 2,这是 JavaFX 里判断双击的标准写法,注意双击事件会先触发一次单击事件,所以不要在单击监听里做播放动作。
参数说明:高亮实际上不用手动设置颜色,ListView默认选中项就有蓝色背景,前提是样式表没有覆盖默认选中状态。如果后面引入自定义 CSS,要小心把.list-cell:selected的样式改了之后看不到选中项,这是换皮肤时最常见的隐形问题。
3.3 进度条与时间显示:currentTimeProperty 绑定与 seek 的一个注意点
进度条是播放器里牵一发而动全身的地方。MediaPlayer有两个属性直接驱动进度:currentTimeProperty()持续发出当前播放到的时间点,totalDurationProperty()在就绪后发出总时长。把这两个属性绑到进度条上,UI 就能自动跟着播放走。
mediaPlayer.currentTimeProperty().addListener((obs, oldTime, newTime) -> { Duration total = mediaPlayer.getTotalDuration(); if (total != null && total.toMillis() > 0) { double percentage = newTime.toMillis() / total.toMillis() * 100.0; progressSlider.setValue(percentage); } timeLabel.setText(formatTime(newTime) + " / " + formatTime(total)); }); progressSlider.valueProperty().addListener((obs, oldVal, newVal) -> { if (progressSlider.isValueChanging()) { Duration total = mediaPlayer.getTotalDuration(); if (total != null && total.toMillis() > 0) { mediaPlayer.seek(total.multiply(newVal.doubleValue() / 100.0)); } } });逻辑说明:第一段监听器的职责是"播放器动,进度条跟着动";第二段监听器的职责是"用户拖进度条,播放器跳到对应位置"。这两个方向如果不做区分会互相触发:进度条值一变,播放器 seek,seek 又触发 currentTime 变化,又回头设置进度条,形成一个肉眼可见的循环抖动。isValueChanging()的作用就是识别当前值变化是由用户拖动触发的,而不是由程序赋值触发的,这是进度条联动里最关键的一行。
参数说明:seek接收的是绝对时间点Duration,不是相对偏移量。把百分比换算成Duration时用total.multiply(ratio)而不是Duration.seconds(total.toSeconds() * ratio),后者在精度上会有微小误差,播放时间久了会明显对不上。formatTime建议写成mm:ss格式,超过一小时的音频再用hh:mm:ss,这个函数自己随手写就行。
4. 播放控制与异常分支:切歌、暂停、循环与格式兜底
4.1 播放状态机:idle/ready/playing/paused/ended 之间的转换
播放器做到这个阶段,靠"播放按钮里直接调 play()"已经不够用了。用户会在歌曲播放中时点击下一首、在暂停状态点击上一首、在刚开始加载时疯狂点击按钮,这些操作交错出现会让参数状态互相打架。我一般会在项目里维护一个显式的播放状态枚举,让每个按钮动作都先检查状态再做响应。
enum PlayState { IDLE, READY, PLAYING, PAUSED, STOPPED, ENDED } private PlayState playState = PlayState.IDLE; private void bindMediaPlayer(MediaPlayer player) { player.setOnPlaying(() -> playState = PlayState.PLAYING); player.setOnPaused(() -> playState = PlayState.PAUSED); player.setOnStopped(() -> playState = PlayState.STOPPED); player.setOnEndOfMedia(() -> playState = PlayState.ENDED); player.setOnReady(() -> playState = PlayState.READY); } private void playPause() { switch (playState) { case PLAYING -> mediaPlayer.pause(); case PAUSED, READY, ENDED -> mediaPlayer.play(); case STOPPED, IDLE -> { /* 先加载再播放 */ } } }逻辑说明:MediaPlayer内部本身有状态,但它的状态转换会异步发生,比如play()调用之后状态不会立刻变成 playing,中间可能经过若干帧的缓冲。自己维护一份PlayState是为了让业务逻辑不直接依赖底层状态,按钮点击时通过 switch 决定"当前处境下应该做什么动作"。setOnPlaying这些回调是状态机更新的唯一入口,不要在图方便时自己直接赋值,否则会出现界面显示播放中、实际声音已经停了的脱节现象。
参数说明:JavaFX 的MediaPlayer没有公开的setOnReady之外的"状态查询"方法,getStatus()返回的也只是底层状态,所以在业务层自己维护一份状态是常见做法。setOnEndOfMedia里记得先更新状态再处理切歌逻辑,否则自动切歌会漏掉一次状态刷新。
4.2 自动切歌与循环模式:setOnEndOfMedia 的三种常见实现
自动切歌是播放器区别于"单曲播放工具"的关键功能。三种常见做法里,最简单的是顺序播放到尾停止,其次是顺序循环,最后是单曲循环。只需要一个枚举加一个switch就能完整覆盖。
enum RepeatMode { NONE, ALL, ONE } private List<File> playlist = new ArrayList<>(); private int currentIndex = 0; private RepeatMode repeatMode = RepeatMode.ALL; private void playAt(int index) { if (index < 0 || index >= playlist.size()) return; currentIndex = index; releaseMediaPlayer(); File file = playlist.get(index); Media media = new Media(file.toURI().toString()); mediaPlayer = new MediaPlayer(media); bindMediaPlayer(mediaPlayer); mediaPlayer.setOnReady(() -> { progressSlider.setMax(100); mediaPlayer.play(); }); mediaPlayer.setOnEndOfMedia(this::handleEndOfMedia); } private void handleEndOfMedia() { switch (repeatMode) { case ONE -> mediaPlayer.seek(Duration.ZERO); case ALL -> playAt((currentIndex + 1) % playlist.size()); case NONE -> { /* 停在 ENDED 状态,等待用户操作 */ } } }逻辑说明:handleEndOfMedia是整个自动切歌的核心。单曲循环用seek(Duration.ZERO)回到开头再继续播放,效率比重新创建MediaPlayer高很多,而且不会短暂黑屏无声。列表循环用取模运算绕回开头,边界处(currentIndex + 1) % playlist.size()在播最后一首时会正确回到 0。NONE模式其实什么都不用做,只更新状态,让播放器静默停在 ended。
参数说明:releaseMediaPlayer()是必须写的释放函数,里面要做mediaPlayer.stop()、mediaPlayer.dispose(),然后置空引用。切歌时如果直接 new 一个新的MediaPlayer而不释放旧的,音频设备会被占满,播到第三四首就开始卡顿。JavaFX 里dispose()会释放原生资源,这个动作没有后悔药,释放后不能再对旧实例调用任何方法。
4.3 对不支持的本地音频做兜底:读 MediaPlayer.error 而不是凭扩展名判断
项目做到能播放 MP3,马上就会遇到"为什么这首歌播放不了"的报告。最常见的两个原因:文件扩展名是.mp3但实际编码是别的格式;或者 FLAC 文件在目标机器上缺少解码头。别依赖扩展名判断能不能播,正确姿势是统一尝试创建MediaPlayer,然后监听 error 事件。
mediaPlayer.setOnError(() -> { MediaException error = mediaPlayer.getError(); if (error != null) { switch (error.getType()) { case MEDIA_UNAVAILABLE -> showAlert("媒体不可用:文件可能被移动或删除"); case MEDIA_INCORRECT_CODE -> showAlert("编码格式不支持:缺少对应编解码器"); default -> showAlert("播放失败:" + error.getMessage()); } } playNextAfterError(); });逻辑说明:MediaPlayer.getError()返回MediaException,里面带一个MediaException.Type枚举,常见的有MEDIA_UNAVAILABLE(文件不存在或 URL 错误)、MEDIA_INCORRECT_CODE(解码器不支持)、MEDIA_UNSUPPORTED(格式整体不支持)。根据不同类型给用户不同的提示文案,比一概显示"播放失败"体验好得多。playNextAfterError()是业务层回退策略,我一般会让它自动跳到下一首,但只在repeatMode == ALL时这样做,否则用户会困惑于"为什么老是自己跳歌"。
参数说明:setOnError也同样适用于Media对象本身,Media的错误可以通过Media.getError()读取。两条链路要分别处理,有些错误在构建Media时就已经发生,根本等不到MediaPlayer创建。一个隐藏点是:MediaPlayer.getError()在成功播放过一次之后不会自动清空,所以每次切换歌曲前先检查并广播一次错误状态是不少项目的默认做法。
5. JavaFX 音乐播放器的避坑指南:5 个让初学者原地翻车的细节
5.1 现象:双击歌曲没声音,控制台也干干净净
双击列表项,界面没有异常,没有报错堆栈,音量也确认开启,但就是不出声。
原因:最常见的是Media构造参数传了本地路径字符串"D:/music/song.flac"而不是 URL。Media要求的参数是符合 URL 规范的字符串,本地文件必须用file:协议前缀并正确编码。更隐蔽的一种是:文件路径里包含中文或空格时,直接拼字符串生成的 URL 是非法字符,解码失败静默发生,监听器里如果没有挂setOnError就完全看不到原因。
解决:统一用Path.of(file).toUri().toString()或者file.toURI().toString(),把编码交给标准库,不要自己拼接。同时所有MediaPlayer创建后立刻挂setOnError,哪怕只是打印一行日志,也能避免排查时像无头苍蝇一样乱试。
5.2 现象:切歌时偶发 IllegalStateException
快速连续点击"下一首",偶然会抛IllegalStateException,崩溃点指向mediaPlayer.play()或mediaPlayer.stop()。
原因:MediaPlayer没有准备好的时候调用 seek 或 play,或者旧MediaPlayer已经dispose()过了,业务代码还在持有旧引用调用方法。由于切歌逻辑里创建新实例和释放旧实例是分开写的,并发点击会让两条路径交错执行,出现"上一秒还在播的歌,下一秒已经被释放"的状态。
解决:把"释放旧实例、创建新实例、绑定事件、调用 play"放在同一个方法里顺序执行,加一个loading标志位,在setOnReady回调之前拒绝所有播放控制输入。我一般在 UI 层用一个boolean isSwitching状态,切歌过程中按钮全部置灰,从物理上消除竞态窗口。这个做法有点笨,但胜在稳定,播放器这类小工具不值得为并发体验引入复杂锁。
5.3 现象:拖动进度条后,播放位置跳回开头
用户把播放进度拖到 2 分 30 秒,松手后播放器先跳过去,然后立刻回到开头重新播。
原因:Slider的valueProperty监听器和currentTimeProperty监听器互相触发。用户拖动进度条 → 进度条值变化 →seek()→ 播放器 currentTime 变化 →setValue()重置进度条 → 又触发一次进度条监听 → 再次seek()到错误位置。因为没有对"程序赋值变化"和"用户拖动变化"做区分,两次 seek 在播放器尚未稳定时直接打架。
解决:只在progressSlider.isValueChanging()为 true 时才执行 seek,程序赋值时isValueChanging()为 false,就不会触发跳转。另一个补充手段是记录一个isSeeking布尔值,在 seek 前后手动加锁。注意isValueChanging()在 JavaFX 里是"用户正在拖动滑块的滚动过程中"的标记,松手后它会变回 false,所以逻辑上刚好贴合需求。
5.4 现象:界面卡顿,进度条像幻灯片一样一跳一跳
播放正常,但拖动窗口时界面反应迟钝,进度条刷新不平滑,CPU 占用莫名飙升。
原因:currentTimeProperty的刷新频率由播放器驱动,本身不高,但如果在监听器里做了重量级操作,比如文件 IO、字符串拼接大日志、或者调用了布局刷新,每个 tick 都会放大开销。另一个常见来源是用了Timeline每 10 毫秒取一次播放位置来更新 UI,这完全没必要;再一个就是同时在非 JavaFX 线程里修改 UI 属性,导致内部死锁或重排风暴。
解决:把currentTimeProperty的监听器尽量变瘦,只做两件事:更新进度条值、更新文本标签。文本标签用String.format而不是多次+拼接。不要在监听器里读取目录、扫描文件列表。如果发现MediaPlayer时间回调不规律,可以换用Timeline每 200 毫秒采样一次进度值,这种降频方案能大幅减少布局失效次数,已经够肉眼平滑了。
5.5 现象:开发环境一切正常,打成安装包后双击闪退
代码在 IDE 里运行没有问题,但用 jpackage 或者 fat jar 方式交付后,安装完打开就闪退,毫无提示。
原因:JavaFX 的 media 模块是原生代码模块,打包时必须把对应的原生库文件一起带出去。纯 jar 打包模式不会包含.dll/.so/.dylib文件,jlink 时漏掉javafx.media模块则会在加载播放器时直接抛UnsatisfiedLinkError或NoClassDefFoundError。这类问题在开发环境不会出现,因为 IDE 通常是使用完整的 JavaFX SDK 模块在运行。
解决:交付阶段统一用jlink生成自定义运行时,把javafx.controls、javafx.media都放进add-modules列表,再交给jpackage打包当前平台安装包。另外在入口方法最前面加一段启动日志输出到文件中,这样用户双击闪退时,能把日志文件发回来定位到底崩在哪一步。这个"文件日志兜底"的做法是很多桌面工具开发者交付前必加的一层保险。
6. 再往前走一步:CSS 皮肤、快捷键与用 jpackage 交付
6.1 用 CSS 让默认控件改头换面:JavaFX CSS 与网页 CSS 的区别
JavaFX 控件支持 CSS 美化,但它不是网页 CSS。选择器用.class和#id,属性是-fx-background-color这种带前缀的写法。给控制栏加背景、给播放按钮突出配色,只需要一份style.css,然后在Scene上挂样式表:
#controlBar { -fx-background-color: #2b2b2b; -fx-padding: 12 16 12 16; -fx-spacing: 12; } #playButton { -fx-background-color: #4a90d9; -fx-text-fill: white; -fx-font-size: 14px; -fx-background-radius: 6; } .list-cell:filled:selected { -fx-background-color: #4a90d9; -fx-text-fill: white; }Scene scene = new Scene(root, 720, 480); scene.getStylesheets().add(getClass().getResource("/style.css").toExternalForm()); primaryStage.setScene(scene);逻辑说明:-fx-spacing对应 HBox 构造器里的间距参数,CSS 里设置后构造器里的值会被覆盖。.list-cell:filled:selected这个伪类选择器必须这样写才能覆盖默认蓝色,只写.list-cell拦不住默认样式。CSS 文件放在src/main/resources下,通过getResource加载,不要拼文件绝对路径,否则打包后一定找不到样式。
6.2 给播放器加全局快捷键:KeyCombination 与 setOnKeyPressed
鼠标点按钮不是播放器的完整形态,桌面工具加上快捷键才有实用价值。JavaFX 里用KeyCombination描述组合键,然后在场景上挂按键事件:
KeyCombination playPauseKey = new KeyCodeCombination(KeyCode.SPACE); KeyCombination nextKey = new KeyCodeCombination(KeyCode.RIGHT, KeyCombination.CONTROL_DOWN); scene.setOnKeyPressed(event -> { if (playPauseKey.match(event)) { playPause(); } else if (nextKey.match(event)) { playAt((currentIndex + 1) % playlist.size()); } });逻辑说明:KeyCodeCombination构造器的第一个参数是主键,后面的可变参数是修饰键。空格键单独做播放/暂停,右方向键加 Ctrl 做下一首,这两个组合是播放器标配。要特别注意:如果焦点在Button上,空格键会被按钮消费掉,场景层的按键事件收不到。常见做法是根布局调用setFocusTraversable(true)并把焦点打到根节点,或者干脆给控制栏设setFocusTraversable(false),让焦点永远停在舞台根上。
6.3 用 jpackage 产出当前平台的安装包:模块化与非模块化两种打包方式
开发完成后交付是最后的关卡。现在 JavaFX 官方推荐的链路是jlink加jpackage,前者产出精简运行时,后者把运行时和应用一起封装成当前平台的安装包。两个命令分别执行:
mvn clean package jlink \ --module-path target/classes \ --add-modules javafx.controls,javafx.media \ --output runtime jpackage \ --input target \ --main-class musicplayer.Main \ --main-jar musicplayer.jar \ --module-path runtime \ --add-modules javafx.controls,javafx.media \ --name MusicPlayer \ --type dmg逻辑说明:第一条mvn clean package产出应用 jar;第二条jlink把占用的 JavaFX 模块单独抽出来形成一个精简运行时,输出到runtime目录;第三条jpackage读取这个运行时和应用 jar,打包成当前系统的安装包。--type参数在 macOS 上是dmg,在 Windows 上是msi或exe,在 Linux 上是deb或rpm,不同平台用各自对应的值。
参数说明:--main-class填的是Application子类的全限定名,这个必须和代码里的一致;--main-jar填的是mvn package产出的 jar 文件名。如果项目没有做模块化(没有module-info.java),jlink和jpackage的--module-path要指向 JavaFX 的 jmods 目录,而不是target/classes,两种方式的路径语义完全不同,照着命令抄之前先确认自己的项目是不是模块化结构。
这个播放器我从最早只有播放按钮的原型,一路做到带列表、切歌、循环、CSS 皮肤、打包交付的完整桌面工具,最大的教训是:JavaFX 写界面真的不难,难的是把异步状态、底层解码器边界和打包依赖这三维的暗坑同时填平。每次看到别人在群里问"为什么我双击不出声",都能猜到八成卡在了Media的 URL 上——这坑我也踩过,所以值得写出来。做完这个项目,你对 JavaFX 的事件回调、属性绑定和模块化打包的理解会直接上一个大台阶,后续做任何桌面工具都有了可复用的骨架。希望帮到你。
本文还有配套的精品资源,点击获取