借助Jolt库,用800行Clojure代码封装GTK4

Lobsters Hottest 工具

摘要

本文介绍使用Jolt库在Clojure中封装GTK4的响应式UI模型,旨在与原生工具包和Electron相比提升开发者体验。

<p><a href="https://lobste.rs/s/svga6q/wrapping_gtk4_800_lines_clojure_with_jolt">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/08/29 21:53

# 使用 Jolt 用 800 行 Clojure 封装 GTK4 来源:https://yogthos.net/posts/2026-08-29-glimmer-ui.html 原生工具包的人体工学体验不佳,在许多方面使用起来比 Web 开发痛苦得多。用它们构建 UI 是一种命令式操作:你需要逐一调用创建控件,将它们装入容器,然后手动将每个事件绑定到其处理器。更糟的是,界面的结构通常游离于你的编程语言之外,迫使你使用像 GtkBuilder XML 或 Xcode Storyboards 这样的工具,而你惯用的代码组合与重构工具在这里无能为力。布局由盒子装箱规则和约束系统控制,这些规则描述起来容易但预测起来困难。一个常见的任务,比如将一组数据项转换为与数据保持同步的控件列表,结果却需要你编写大量重复的样板代码。 使用原生工具包的痛苦催生了像 Electron 这样的技术,它们只是简单地将浏览器引擎打包作为应用的前端。虽然这在技术上可行,但它是一种笨拙且低效的权宜之计。每个应用最终都必须附带自己的浏览器副本以及 JavaScript 运行时,而且结果看起来永远不太像原生应用。 然而,即使这样做,仍然无法解决一个最大的挫折:会打断开发流程的编译周期。这在构建 UI 时尤其成问题,因为你无法轻松地进行自动化测试。你必须启动应用,点击它的菜单,让它达到特定状态,然后才能直观地检查改动是否按预期工作以及用户体验是否良好。 在 ClojureScript 中使用 Reagent 和其他 Clojure UI 工具包是一种愉快的体验,正是因为你能够逐步构建 UI,并在添加更多组件的同时保持运行状态。你做出更改,观察应用,立即看到结果,然后继续迭代。 我一直非常喜欢 Reagent 的响应式模型,我觉得它非常直观。你将 UI 状态视为一个数据结构,让 UI 组件订阅其中的路径。每当特定路径上的元素发生变化,与之关联的 UI 组件就会更新。实际上就是这么简单。虽然 Reagent 是建立在 React 之上,但在这个模型中,React 实际上并非必需。React 使用一个 VDOM,对其进行差异比较,然后渲染到真实的 DOM。它之所以需要 VDOM,是因为 React 对于什么触发组件更改是不感知的。这就是为什么你需要整个 React 生命周期,包括检查 `componentDidMount`、`componentWillUnmount` 等。在 Reagent 中,一个组件简化为单个渲染函数,而响应式跟踪决定哪些组件需要重新执行,因此生命周期源于数据的状态。并且由于响应式模型已经确切知道什么发生了变化,这使得我们可以用它直接驱动 DOM,而无需 React 作为中介,就像 `mr-clean` (https://bitbucket.org/sonwh98/mr-clean/) 这个库所展示的那样。 既然这个模型对浏览器 DOM 效果良好,那为什么不把它应用到原生工具包呢?只需要创建包装器,通过响应式原子传递值来渲染原生控件,并提供一个回调供控件在用户输入时触发即可。我们不需要 VDOM 的等价物,因为所有更改都由响应式原子的状态驱动,并且可以直接渲染到 UI 上。甚至可以添加一个批处理层来控制 UI 更新的速率,如果需要的话。这正是 glimmer (https://github.com/jolt-lang/glimmer) 的工作原理,它提供了一个可以连接到特定 UI 工具包的响应式核心。然后,还有 glimmer-gtk (https://github.com/jolt-lang/glimmer-gtk/)、glimmer-uikit (https://github.com/jolt-lang/glimmer-uikit/) 和 glimmer-tui (https://github.com/jolt-lang/glimmer-tui/) 来为不同类型的 UI 控件提供具体绑定。 使用 glimmer-gtk 的经典计数器看起来和它的 Reagent 对应物几乎一模一样: ```clojure (ns counter (:require [glimmer.ratom :as r :refer [atom]] [glimmer.core :as ui] [glimmer-gtk.core])) ; 安装 GTK4 后端 (defn counter [] (let [count (atom 0)] (fn [] [:vbox {:spacing 12} [:label {:label (str "Count: " @count)}] [:hbox {:spacing 8} [:button {:label "- 1" :on-click #(swap! count dec)}] [:button {:label "+ 1" :on-click #(swap! count inc)}] [:button {:label "reset" :on-click #(reset! count 0)}]]]))) (defn -main [& _] (ui/run counter :title "counter" :width 320 :height 160)) ``` 外部函数运行一次以创建局部状态,而内部函数会在 `@count` 变化时重新运行,就地修补实时控件,而不是重建整个树。如果你以前用过 Reagent,这一切应该看起来非常熟悉,唯一的区别是元素是 GTK4 控件而不是 DOM 节点。 ### 封装一个控件需要什么 事实证明,将一个 C 工具包连接到响应式 Clojure 核心所需的代码量出奇地少。整个 glimmer-gtk 后端代码量不到 800 行 Clojure,分布在四个命名空间中,而且没有任何涉及高深技术的内容。 Jolt 的 FFI 允许你通过命名指向 C 函数的符号以及它的参数和返回类型,将其提升到 Clojure 层。我们可以从下面看到 GTK 后端绑定的几个示例。 ```clojure (ns glimmer-gtk.ffi (:require [jolt.ffi :as ffi])) (ffi/defcfn gtk-button-new-with-label "gtk_button_new_with_label" [:string] :pointer) (ffi/defcfn gtk-button-set-label "gtk_button_set_label" [:pointer :string] :void) (ffi/defcfn gtk-box-new "gtk_box_new" [:int :int] :pointer) (ffi/defcfn gtk-box-append "gtk_box_append" [:pointer :pointer] :void) ``` 指针就是用数字表示的普通机器地址,字符串会自动编组为和来自 C 字符串,GTK 的布尔值是由一个单行 `->bool` 辅助函数处理的整数。不需要编译 C 存根或运行绑定生成器,也不需要学习接口 DSL。共享库在 `deps.edn` 中的 `:jolt/native` 下声明,Jolt 在命名空间被 require 之前就加载它们。 有一点需要注意,主循环绑定需要特殊处理。 ```clojure (ffi/defcfn g-application-run "g_application_run" [:pointer :int :pointer] :int :blocking) ``` `:blocking` 标志告诉运行时,这个调用会在应用的整个生命周期内挂起线程,因此当 GTK 拥有主循环时,它不应该固定垃圾回收器。 GTK 的 API 也充满了像 `GTK_ALIGN_START` 和 `GTK_ORIENTATION_VERTICAL` 这样的枚举,一个常见的方法是维护一个反映 C 头文件的常量表。但 glimmer-gtk 不需要这样的表,因为每个 GObject 枚举都将其成员注册为小写的 nick(别名),这些别名映射到 Clojure 关键字。因此,只需要三个额外的绑定,就可以在运行时通过 GObject 类型注册表将别名解析为其整数值。 ```clojure (ffi/defcfn g-type-from-name "g_type_from_name" [:string] :size_t) (ffi/defcfn g-type-class-ref "g_type_class_ref" [:size_t] :pointer) (ffi/defcfn g-enum-get-value-by-nick "g_enum_get_value_by_nick" [:pointer :string] :pointer) ``` 当你写 `[:label {:halign :start}]` 时,后端会查找 `GtkAlign` 类型以获取其类结构体,要求其提供具有 `start` 别名的成员,并从返回的结构体中读取整数。成功的查找随后会被记忆化,并且原始整数仍然可以作为一种逃生舱使用。这个技巧是这个库中不需要任何单个 `GTK_*` 常量的原因。 这就是暴露所需 GTK 组件所需的全部样板代码。有了这些,每个 hiccup 标签都可以映射到注册表中的一个控件规范。这些规范以小地图的形式表示,描述如何构建控件、应用属性以及它是什么类型的容器。下面是声明按钮的代码。 ```clojure (defn- ->bool [x] (if x 1 0)) (defn- button-spec [] {:ctor (fn [p] (if (:label p) (g/gtk-button-new-with-label (:label p)) (g/gtk-button-new))) :apply (fn [w p] (when (contains? p :label) (g/gtk-button-set-label w (:label p))) (when (:tooltip p) (g/gtk-widget-set-tooltip-text w (:tooltip p))) (when (contains? p :sensitive) (g/gtk-widget-set-sensitive w (->bool (:sensitive p))))) :container :none}) ``` 协调器(reconciler)通过固定的生命周期驱动这些规范:`create!` 运行构造函数,应用属性,并设置事件处理器,而 `apply-props!` 在每次重新渲染时对现有控件重新运行属性应用,根据需要就地修补。处理器在控件挂载时连接一次,并且它们期望捕获响应式单元(reactive cells),因此在第一次渲染时捕获的闭包在控件的整个生命周期内保持正确,就像在 Reagent 中一样。 事件属性是 `:on-*` 键,在信号表中查找。 ```clojure (def signals (atom {:on-click "clicked" :on-change "changed" :on-activate "activate" :on-toggled "toggled"})) ``` 每个处理器都被包装在一个 `foreign-callable` 中,并使用 `g_signal_connect_data` 连接。 ```clojure (doseq [[event handler] props] (when-let [signal (@signals event)] (let [cb (ffi/foreign-callable (fn [widget _data] (handler)) [:pointer :pointer] :void :collect-safe)] (retain-callable! cb) (g/g-signal-connect-data widget signal cb ffi/null ffi/null g/CONNECT-DEFAULT)))) ``` `:collect-safe` 标志在这里很重要,因为 GTK 在阻塞的主循环内部调用处理器,并且可调用对象还必须在 Clojure 端被保留,因为 C 以一个对垃圾回收器不透明的原始指针形式持有它。 另一个值得注意的细节是,GTK 从自己的设置器同步发出信号,因此当一个重新渲染的程序设置条目的文本时,GTK 会立即触发 `changed`,从而触发处理器。由于处理器导致原子重置,你最终会陷入渲染循环。解决方法是在编程设置器前后用一个抑制集括起来,这样它们发出的信号就会被忽略。 ```clojure (defn- set-entry-text! [widget text] (when (and (some? text) (not= text (g/gtk-editable-get-text widget))) (swap! suppressing conj widget) (g/gtk-editable-set-text widget text) (swap! suppressing disj widget))) ``` 控件和信号注册表都是开放的,因此添加库不知道的控件只需要编写其规范并注册它即可。 最后,glimmer 本身不需要看到这些,因为后端只需在注册时向协调器提供一个包含八个函数的映射即可。 ```clojure (def backend {:name :gtk4 :create! w/create! :apply-props! w/apply-props! :append-child! w/append-child! :remove-child! w/remove-child! :replace-child! w/replace-child! :reorder-child! w/reorder-child! :schedule post-to-gui :run run!}) ``` 这就是响应式核心与平台之间的全部契约,这也是为什么为不同的工具包编写后端是一个有界任务,而不是一次重写。 ### 引入你自己的工具包 另一个值得注意的方法见于 glitter (https://glitter-uikit.b12n.app/) 及其基于 Replicant (https://github.com/cjohansen/replicant) 的 AppKit 渲染器 glitter-uikit。虽然 glimmer 是由响应式原子驱动的,你在其中构建一个有状态组件的树,但 Replicant 完全拒绝拥有局部状态,而是将整个用户界面建模为一个纯函数,该函数将你的应用数据转换为 hiccup。Reagent 将你的渲染逻辑与一个响应式状态图耦合,以在后台优化更新,而 Replicant 则作为一个极其严格的单向渲染器,数据进入,hiccup 输出。 在 glitter-uikit 中,同一个计数器看起来是这样的: ```clojure (require '[glitter-uikit.app :as app] '[glitter-uikit.appkit :as appkit] '[glitter.core :as core]) (defonce state (atom {:count 0})) (defn view [{:keys [count]}] [:vbox {:spacing 12} [:label {:label (str "Count: " count)}] [:hbox {:spacing 8} [:button {:label "+ 1" :on {:click [[:action/inc]]}}]]]) (defn execute-actions [_event actions] (doseq [[kind] actions] (case kind :action/inc (swap! state update :count inc) nil))) (core/set-dispatch! execute-actions) (defn -main [& _] (app/run (fn [window] (appkit/mount! window view state)))) ``` 这里的叶子节点是 AppKit 视图而不是 GTK 控件,并且注意按钮不再捕获原子。它只是将其想要完成的操作声明为数据,然后一个分发函数根据应用状态解释这些动作。hiccup 保持不变,唯一的区别是状态存在于哪里以及谁负责更新它。 ### 结论 当然,将 Web 风格的开发带到原生控件并非新想法。React Native 推广了这种方法,允许你编写 React 组件,并渲染到平台的原生控件。但问题在于,应用仍然运行在 JavaScript 运行时内部,通过桥接与平台通信,而开发循环围绕着打包 JavaScript 并将其热重载到运行中的应用展开。Flutter 通过自带渲染引擎并自己绘制每个控件来绕过桥接,这意味着你不再使用原生控件。另一种方法是 Tauri 采用的方式,保持 Web 前端由操作系统 webview 支持。这种方法比 Electron 更轻量,但仍然渲染 HTML 而不是原生控件,并且受限于每个平台上 webview 实现的各种怪癖。与此同时,原生世界正从另一端汇聚到相同的想法上,SwiftUI 和 Jetpack Compose 提供了声明式 UI,但这些又把你带回到了有编译周期且没有 REPL 的编译语言中。这些方法最终都是一种妥协,要么拥有一个重量级运行时,要么放弃人体工学体验。 有了 Jolt,我们终于可以兼得两者:使用原生控件而无需打包整个浏览器引擎来渲染 UI,拥有一个简洁的基于 Hiccup 的 API,让你可以像使用 DOM 中的 HTML 元素一样排列组件,并且拥有一个交互式开发环境,让你可以看到应用随着你做出更改而演进。由于 Jolt 是一种编译为原生代码的 Clojure 方言,没有 JVM 或 JavaScript 运行时阻碍,你也不需要在二进制文件中捆绑浏览器引擎。这就是那种让 Web 开发变得愉快的反馈循环,只不过它是在原生应用中驱动真实的平台控件。

相似文章

Jolt:在Chez Scheme上运行Clojure

Lobsters Hottest

Jolt是一个新的Clojure实现,目标平台为Chez Scheme,旨在提供即插即用的替代方案,具有快速启动和低内存占用,利用Chez的JIT和GC来避免JVM的开销。

Bonsai:Janestreet 的 UI 库

Hacker News Top

Bonsai 是 Jane Street 的开源 UI 库,用于在 OCaml 中构建响应式 Web 应用程序,具有可组合状态、增量渲染和模板语言等特点。

混合视觉与文本代码

Hacker News Top

本文介绍了Hybrid ClojureScript,一种允许开发者混合视觉与文本语法用于领域特定表达式的编程语言,保持了可组合性和静态推理能力。