桌面应用在工具类软件里从未退场:开发者工具、数据查看器、内部管理后台,往往一个本地窗口比部署一套 Web 更划算。Clojure 做桌面 UI 的最佳搭档是 cljfx——它把 JavaFX 封装成声明式(Declarative)的 Clojure 数据结构,UI 是状态的纯函数。
本文从 cljfx 的心智模型讲起,覆盖布局、事件、样式、状态管理与打包发布,给出一条从零到可分发的桌面应用路径。
1. 为什么是 cljfx
1.1 三条可选路线
| 方案 | 底层 | 声明式 | 生态 |
|---|---|---|---|
| cljfx | JavaFX | 是 | 活跃 |
| seesaw | Swing | 否 | 老旧 |
| 直接 JavaFX 互操作 | JavaFX | 否 | 全 |
cljfx 的核心价值:用纯数据描述 UI,与 Clojure 的不可变数据哲学一致,并且天然支持 REPL 热重载。
1.2 心智模型
状态(atom) --cljfx/on-change--> 描述(map/vector) --> JavaFX 场景图
^ |
+---------------- 事件(:on-action 等)---------------------+
UI 是状态的函数:状态变了,cljfx 对比新旧描述,只更新差异部分(虚拟 DOM 式的 diff)。
2. 第一个应用
2.1 依赖与入口
;; deps.edn
{:deps {cljfx/cljfx {:mvn/version "1.9.0"}}
:aliases
{:run {:main-opts ["-m" "myapp.core"]}}}
(ns myapp.core
(:require [cljfx.api :as fx])
(:import [javafx.application Platform]))
(defn -main [& _]
(Platform/setImplicitExit true)
(fx/on-fx-thread
(fx/run
(fn [state]
(fx/mount-renderer
state
(fx/create-renderer
:middleware (fx/wrap-map-desc
(fn [state]
{:fx/type :stage
:showing true
:title "Hello cljfx"
:scene {:fx/type :scene
:root {:fx/type :label
:text "你好,Clojure"}}})))))))
fx/mount-renderer 把 renderer 挂到 FX 线程;wrap-map-desc 把状态映射为 UI 描述。
2.2 描述即数据
UI 描述就是普通 map 与 vector:
{:fx/type :v-box
:spacing 8
:padding 16
:children [{:fx/type :label :text "用户名"}
{:fx/type :text-field :text "alice"}
{:fx/type :button :text "登录"}]}
:fx/type 指定组件类型(对应 JavaFX 的类),其余键是属性。嵌套的 map 就是子组件。
3. 状态与响应式订阅
3.1 单一状态源
cljfx 推荐把整个应用状态放在一个 atom:
(def *state
(atom {:user {:name "alice" :email "a@example.com"}
:items [{:id 1 :title "任务一" :done false}
{:id 2 :title "任务二" :done true}]
:filter :all}))
3.2 fx/sub-val 细粒度订阅
不要每次都重绘整棵树。fx/sub-val 让组件只依赖它关心的那部分状态:
(defn item-list [{:keys [filter] :as state}]
{:fx/type :list-view
:items (->> (:items state)
(filter (case filter
:all (constantly true)
:active (complement :done)
:done :done))
(mapv :title))})
(defn root [{:as state}]
{:fx/type :v-box
:children [(item-list state)
{:fx/type :button
:text "只显示未完成"
:on-action {:event/type ::set-filter :filter :active}}]})
用 fx/sub-val 形式声明订阅,cljfx 会记住依赖、只在相关部分变化时重算:
(defn done-count [*state]
(fx/sub-val *state #(count (filter :done (:items %)))))
;; 在描述里
{:fx/type :label
:text (str "已完成 " (done-count *state) " 项")}
3.3 派生状态与缓存
订阅函数是纯函数,可以用 fx/sub 组合;cljfx 内部对订阅结果做了缓存,相同输入不会重复计算:
(defn visible-items [*state]
(fx/sub-val *state
(fn [{:keys [items filter]}]
(case filter
:all items
:active (remove :done items)
:done (filter :done items)))))
4. 事件与副作用
4.1 事件描述符
cljfx 的事件处理不是直接写回调,而是发出一个事件描述符(map),由统一的事件处理器处理:
{:fx/type :button
:text "添加"
:on-action {:event/type ::add-item}}
处理器用 multimethod 分发:
(defmulti handle-event (fn [_state event] (:event/type event)))
(defmethod handle-event ::add-item [state _]
(update state :items conj {:id (System/currentTimeMillis)
:title "新任务" :done false}))
(defmethod handle-event ::toggle-item [state {:keys [id]}]
(update state :items
(fn [items]
(mapv (fn [it] (if (= (:id it) id) (update it :done not) it)) items))))
(defmethod handle-event ::set-filter [state {:keys [filter]}]
(assoc state :filter filter))
4.2 接入处理器
(fx/create-renderer
:middleware (fx/wrap-map-desc root)
:event-handler (fn [_renderer event]
(swap! *state handle-event event))
:opts {:fx.opt/map-event-handler (fn [event] (swap! *state handle-event event))})
所有副作用(写文件、发请求)都集中在事件处理器里,UI 层保持纯函数。
4.3 异步副作用
耗时操作不要阻塞 FX 线程:
(defmethod handle-event ::load-data [state _]
(future
(let [data (http/get-data)] ;; 后台线程
(fx/on-fx-thread ;; 回 FX 线程更新状态
(swap! *state assoc :items data))))
state)
fx/on-fx-thread 保证状态变更发生在 JavaFX 应用线程上——所有 UI 更新必须在 FX 线程,否则抛 IllegalStateException。
5. 布局与控件
5.1 常用布局
cljfx :fx/type | JavaFX 类 | 用途 |
|---|---|---|
:v-box | VBox | 垂直排列 |
:h-box | HBox | 水平排列 |
:border-pane | BorderPane | 上/下/左/右/中 |
:grid-pane | GridPane | 网格 |
:stack-pane | StackPane | 层叠 |
:scroll-pane | ScrollPane | 可滚动容器 |
{:fx/type :border-pane
:top {:fx/type :tool-bar
:items [{:fx/type :button :text "新建"}
{:fx/type :button :text "打开"}]}
:center {:fx/type :scroll-pane
:fit-to-width true
:content (item-list state)}
:bottom {:fx/type :label :text "就绪"}}
5.2 表格视图
数据表格用 :table-view + 列描述:
{:fx/type :table-view
:items (fx/sub-val *state :items)
:columns [{:fx/type :table-column
:text "标题"
:cell-value-factory :title}
{:fx/type :table-column
:text "状态"
:cell-value-factory #(if (:done %) "完成" "未完成")}
{:fx/type :table-column
:text "操作"
:cell-factory
{:fx/cell-type :table-cell
:describe (fn [item]
{:text "切换"
:on-mouse-clicked {:event/type ::toggle-item
:id (:id item)}})}}]}
:cell-value-factory 决定单元格取值,:cell-factory 允许自定义渲染与交互。
5.3 输入与校验
文本输入事件通过 :fx/event 携带新值,校验逻辑放在处理器里,非法输入回写错误消息到状态,UI 自动显示:
{:fx/type :text-field
:text (fx/sub-val *state :user :name)
:prompt-text "请输入用户名"
:on-text-changed {:event/type ::set-name}}
(defmethod handle-event ::set-name [state {:keys [fx/event]}]
(assoc-in state [:user :name] event))
6. 样式与主题
6.1 CSS
JavaFX 支持 CSS。把样式表挂在场景上:
{:fx/type :scene
:stylesheets ["/css/app.css"]
:root ...}
/* resources/css/app.css */
.root {
-fx-font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
-fx-background-color: #f7f7f9;
}
.card {
-fx-background-color: white;
-fx-background-radius: 8;
-fx-padding: 12;
-fx-effect: dropshadow(gaussian, rgba(0,0,0,0.08), 8, 0, 0, 2);
}
.button-primary {
-fx-background-color: #2f6feb;
-fx-text-fill: white;
}
给节点加类名:
{:fx/type :v-box :style-class ["card"] :children [...]}
6.2 内联样式与优先级
{:fx/type :label
:text "警告"
:style "-fx-text-fill: #d33; -fx-font-weight: bold;"}
内联样式优先级最高,适合随状态变化的动态值;静态外观统一放 CSS,避免样式散落。
7. 与 JavaFX/Java 互操作
cljfx 未覆盖的控件或 API,直接走 Java 互操作(Java 互操作的通用技巧见 Clojure 与 Java 互操作
)。cljfx 提供 :fx/type :custom 或 fx/instance 桥接:
(import '[javafx.scene.chart PieChart PieChart$Data])
{:fx/type :custom
:ctor (fn [] (PieChart.))
:props {:data [{:name "A" :value 30}
{:name "B" :value 70}]}
:update (fn [chart props]
(.setData chart
(into-array PieChart$Data
(map (fn [{:keys [name value]}]
(PieChart$Data. name (double value)))
(:props props)))))}
:ctor 建实例,:update 把 Clojure 数据同步到控件。这样既有 cljfx 的声明式外壳,又能用任意 JavaFX API。
7.1 平台线程规则
JavaFX 的线程规则很严格:
| 操作 | 允许的线程 |
|---|---|
| 修改场景图/控件 | FX 应用线程 |
| 读取状态 atom | 任意 |
| 网络/文件 IO | 后台线程(future) |
跨线程更新一律用 fx/on-fx-thread。
8. 打包与分发
8.1 jpackage
JDK 14+ 自带 jpackage,可生成原生安装包(.dmg / .msi / .deb):
# 先构建 uberjar
clojure -T:build uber
# 生成 macOS 应用包
jpackage \
--type dmg \
--name "MyApp" \
--input target \
--main-jar myapp-1.0.0.jar \
--main-class myapp.core \
--icon resources/icon.icns \
--java-options "-Xmx512m" \
--mac-package-identifier com.example.myapp
产物是带内嵌 JRE 的安装包,用户无需自装 Java。
8.2 模块与 JavaFX
JavaFX 在 JDK 11 之后不再随 JDK 分发,需要显式加依赖:
;; deps.edn — 按平台加 classifier
{:deps {org.openjfx/javafx-controls {:mvn/version "21.0.2"}
org.openjfx/javafx-base {:mvn/version "21.0.2"}
org.openjfx/javafx-graphics {:mvn/version "21.0.2"}
cljfx/cljfx {:mvn/version "1.9.0"}}}
打包时 jpackage 需要把 JavaFX 的 native 库一并带上,用 --module-path 或把 classifier 指定的平台 jar 解压进 --input 目录。
8.3 GraalVM native image 可行性
理论上可把桌面应用编成 native image,启动快、体积小。实践中有两个坎:JavaFX 反射需要大量 reflect-config.json(社区有模板但维护成本高),AOT 与 FX 初始化要求部分逻辑延到运行时。结论:常驻桌面应用不必追求 native image,jpackage + JRE 已足够(安装包 60~90MB);只有对启动速度或体积极敏感的 CLI 型工具才值得投入,相关取舍见 GraalVM 与性能优化
。
9. 开发体验:REPL 驱动
cljfx 最爽的地方是 REPL 里改 UI 立即生效:启动 renderer 后,直接在 REPL 里 (swap! app/*state assoc :filter :done) 或重定义订阅函数,窗口实时更新,无需重启。这种「改代码 → 看窗口」的循环,与 REPL 驱动开发
里讲的 jack-in 工作流完全一致,是 Clojure 桌面开发相对 Electron 的核心优势之一。
10. 小结
cljfx 把 JavaFX 变成「状态 → 数据描述 → 场景图」的单向数据流:
- 状态集中:一个 atom 存全部应用状态;
- UI 是纯函数:描述 map,
:fx/type决定组件,嵌套即子节点; - 事件描述符 + 集中处理:副作用只在
handle-event里发生; - 订阅细化:
fx/sub-val让组件只依赖需要的状态; - 线程守规矩:UI 更新只在 FX 线程,IO 放后台再回主线程;
- 打包用 jpackage:内嵌 JRE 的原生安装包,无需 native image。
相比 Web 前端方案(见 Clojure 现代 Web 全栈 ),桌面方案的启动开销更低、无需服务器、本地文件与系统集成更直接。当你的工具需要一个真正的窗口时,cljfx 是 Clojure 生态里最顺手的答案。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。