Clojure 桌面 UI:cljfx 与 JavaFX 实战

用 Clojure 构建桌面应用:cljfx 的声明式 UI 与状态驱动模型、JavaFX 控件与布局、fx/sub-val 响应式订阅、事件处理与副作用、CSS 样式、打包为原生安装包与 GraalVM native image 的可行性分析。

桌面应用在工具类软件里从未退场:开发者工具、数据查看器、内部管理后台,往往一个本地窗口比部署一套 Web 更划算。Clojure 做桌面 UI 的最佳搭档是 cljfx——它把 JavaFX 封装成声明式(Declarative)的 Clojure 数据结构,UI 是状态的纯函数。

本文从 cljfx 的心智模型讲起,覆盖布局、事件、样式、状态管理与打包发布,给出一条从零到可分发的桌面应用路径。

1. 为什么是 cljfx

1.1 三条可选路线

方案底层声明式生态
cljfxJavaFX是活跃
seesawSwing否老旧
直接 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/typeJavaFX 类用途
:v-boxVBox垂直排列
:h-boxHBox水平排列
:border-paneBorderPane上/下/左/右/中
:grid-paneGridPane网格
:stack-paneStackPane层叠
:scroll-paneScrollPane可滚动容器
{: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 变成「状态 → 数据描述 → 场景图」的单向数据流:

  1. 状态集中:一个 atom 存全部应用状态;
  2. UI 是纯函数:描述 map,:fx/type 决定组件,嵌套即子节点;
  3. 事件描述符 + 集中处理:副作用只在 handle-event 里发生;
  4. 订阅细化:fx/sub-val 让组件只依赖需要的状态;
  5. 线程守规矩:UI 更新只在 FX 线程,IO 放后台再回主线程;
  6. 打包用 jpackage:内嵌 JRE 的原生安装包,无需 native image。

相比 Web 前端方案(见 Clojure 现代 Web 全栈 ),桌面方案的启动开销更低、无需服务器、本地文件与系统集成更直接。当你的工具需要一个真正的窗口时,cljfx 是 Clojure 生态里最顺手的答案。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. JSON/EDN 序列化与数据格式互操作
  2. JVM 调优与容器化部署:GC、JFR 与 Docker
  3. 解析与 DSL:Instaparse 与解析器组合子