Hacker News 中文摘要

RSS订阅

用 Haskell 开发 GTK 应用(一) -- Making a GTK application in Haskell, part 1

本系列将使用 Haskell、GTK 4 工具包(GTK 4)与 Adwaita 库(Adwaita)构建待办事项应用,Adwaita 提供诸多实用部件与样式。受众为有经验的中级 Haskell 开发者。Adwaita 是 GNOME 项目(GNOME)的 GTK 组件库,充当其设计语言,将 GNOME 在无障碍和样式上的人类接口指南(HIG)决策编码于 libadwaita(libadwaita)中。该库支持响应式设计,桌面切换明暗主题时实时重新着色。后续将用 haskell-gi 工具包(haskell-gi)自动生成 GTK 的 Haskell 绑定。

正文

在这个系列里,我们将用 Haskell、GTK 4 和 Adwaita 库构建一个待办事项应用。Adwaita 会提供许多有用的控件和样式。目标读者是中级 Haskell 开发者,有该语言的开发经验。

GTK 4、Adwaita

Adwaita 是一组 GTK 组件库,也是 GNOME 项目的设计语言。简单说,GNOME 在无障碍和风格上的所有决定(Human Interface Guidelines,HIG)都编码在 libadwaita 里。它提供很多功能,例如让应用采用响应式设计,并在桌面切换明暗主题时于运行时重新着色。

Haskell 与 GTK

整个系列使用 haskell-gi 工具包。它从 GTK 库自动生成 Haskell 绑定,让我们得到仍能与 C API 对应的 Haskell 接口。

为了让文章可读,代码不会完整;完整项目在 https://github.com/Floreal-Technologies/adwaita-todo。

你的第一个窗口

先看一个自包含的 GTK 4 / Adwaita 应用结构。创建 Adwaita Application 来处理资源管理,包括 Adwaita 样式表,applicationId 为 tech.floreal.TodoApp。用 new X [#attribute := value] 语法创建带属性的 Gtk 对象,井号语法叫 OverloadedLabels。连接 activate 信号到 activate 处理函数,然后运行主应用循环。activate 里创建 Adw.HeaderBar、Adw.ToolbarView,把 header 加到顶部栏,再创建 Adw.ApplicationWindow,设置 application、title 为 Todos、defaultWidth 480、defaultHeight 640、content 为 toolbar,最后 Gtk.windowPresent window。

你会看到一个空窗口,标题是 Todos,尺寸 480×640。

空窗口

Model-View-Update:Elm 架构

Elm 架构(TEA)是设计交互程序的模式。核心有三个概念:

  • Model:应用状态。
  • View:把 Model 变成用户界面(HTML、GTK 等)。
  • Update:根据 Message 更新 Model。

此外还有:

  • Message:用户与应用所有可能交互的枚举。
  • Effects:Update 不只返回更新后的 Model,还返回一组需要在旁执行的动作,称为 Effects。

这种方法由 Elm 广泛推广,很适合驯服 GTK 工具包的命令式本性。

待办应用

现在表示应用状态和动作。按 Elm 架构的精神,一切都建模为数据结构,从而完全看清触发了哪些动作、它们带来什么。

模型

用 newtype TodoId = TodoId Word,Todo 有 id、title、done,Model 有 todos :: Map TodoId Todo 和 nextId :: TodoId。Effect 表示把待办列表存到磁盘:Save [Todo]。init 里 todos 为空 Map,nextId 为 TodoId 0。

消息

用户交互建模为 Message。目前有 Add Text 和 SetDoneStatus TodoId Bool。

更新模型

update :: Message -> Model -> (Model, [Effect])。Add raw 时,先 strip 文本;若为空则不改;否则插入新 Todo,done = False,nextId 加一。SetDoneStatus todoId value 时,调整对应 todo 的 done。withTodos 辅助函数检查 todos 是否变化:有变化就返回 Save (Map.elems todos),没变化就不返回 Effect。

在 GHCi 里试:update (Add "Buy leeks") init 会得到 Save [Todo {id = TodoId 0, title = "Buy leeks", done = False}]。把该任务设为完成:update (SetDoneStatus (TodoId 0) True) m1 得到 Save [Todo {id = TodoId 0, title = "Buy leeks", done = True}]。再次设为 True 不会触发 Save,effects 列表为空。

这就是领域逻辑:消息的和类型,以及改变状态的 update 函数。

现在设计界面。

视图

开始做界面之前,先写下期望总是好的。经验告诉我,设计不会直接从数据中产生,所以我越来越少根据数据形状来决定设计。

纸上画的 UI 草稿

这里很简单,但我喜欢画。

控件

会用到几个控件:

  • Box:把子控件排成一行或一列。
  • ListBox:可动态过滤和排序的行列表。之后可用于按状态过滤、按时间排序。
  • EntryRow:带标题、占位文本和可编辑图标的输入行,是 ListBox 的子类,因此放在 ListBox 里。
  • ActionRow:EntryRow 的更受限版本,不能就地编辑,但仍可有动作图标,放在 ListBox 里。
  • Clamp:把子控件限制在给定尺寸,用来强制边距,让背景在边缘可见。
  • ScrolledWindow:让子控件可滚动。
  • ToolbarView:包含页面以及顶部和底部栏的视图控件。

拼乐高

有了这些积木,写下控件如何连接。view 函数接收 dispatch(Message -> IO ())和 Model,返回 IO Gtk.Widget。dispatch 稍后在 Runtime 模块定义,把消息变成状态/模型变化。

在 view 里:创建 Adw.EntryRow,标题为 New task;连接 entryActivated,读取文本并 dispatch (Add text)。EntryRow 是 ListBox 子类,所以创建 entryBox,追加 inputRow。创建 todoList,遍历 model.todos,为每个 todo 创建 Adw.ActionRow,标题为 todo.title,useMarkup 为 False,追加到 todoList。创建 content 为 Gtk.Box,垂直方向,spacing 12,四边 margin 12;把 entryBox 和 todoList 追加进去。创建 Adw.Clamp,child 为 content;创建 Gtk.ScrolledWindow,child 为 clamp。页脚显示任务数量:Gtk.Label,label 为 Map.size model.todos;Gtk.Box 水平方向,四边 margin 6/12;把 count 追加到 footer。最后创建 Adw.HeaderBar,Adw.ToolbarView,content 为 scrolled,顶部栏 header,底部栏 footer,返回 Gtk.toWidget toolbar。辅助函数 newBoxedList 创建 selectionMode 为 None、cssClasses 为 ["boxed-list"] 的 ListBox。

现在还没有很多视觉反馈,所以只能相信它像之前的纸笔草稿。

运行时

与开头演示类似,这里把 Model-View-Update 三者接起来。定义 dispatch 和 step。dispatch 接收消息,处理 GTK 执行循环优先级,调用 step 执行模型更新,进而更新视图。step 读取模型,调用 Model.update 得到新模型,写入新模型,把新模型和 dispatch 传给 View。View 返回新的 Gtk 控件,我们把它设为窗口内容。

run 函数创建 Adw.ApplicationWindow,title 为 Todos,defaultWidth 480,defaultHeight 640。创建 IORef 保存 Model.init。dispatch 用 GLib.idleAdd GLib.PRIORITYDEFAULT,调用 step 后返回 GLib.SOURCEREMOVE。step 读取旧模型,调用 Model.update 得到新模型和 effects,写入 IORef,调用 View.view dispatch newModel,再用 Adw.applicationWindowSetContent 设置窗口内容。最后用初始模型生成内容,设置窗口内容并 Gtk.windowPresent。

Main 模块创建 Adw.Application,applicationId 为 tech.floreal.AdwaitaTodo,连接 activate 到 Runtime.run app,然后 Gio.applicationRun app Nothing。

结果如下:

带输入行和两个条目 'Touch grass' 与 'Buy leeks' 的应用窗口

相当不错。第 2 部分会继续为待办应用添加更多功能。

评论

讨论整体对“用 Haskell 写 GTK 应用”的实用价值偏怀疑:核心分歧在于 Elm 架构的清晰能否抵消 Haskell 包管理、GTK 破坏性变更和异步回调的复杂度;少数技术评论则回到具体性能与代码细节。

认为异步事件和回调管理才是 Haskell GUI 的真正难点

  • 有 GTK/Haskell 经验的人认为手动接 gtk-gi 信号很快会变乱,Elm 架构纸面干净,但关键在于是否把所有 GI 回调收进 channel 再喂给 update 循环。(少数人) > 想请教异步事件如何避免回调地狱,是不是把所有 GI 回调都塞进 channel 再进更新循环。(@birchcove)

认为 Haskell GUI 的代价大于收益

  • 有人以 Pandoc 为例,认为 Haskell 在 Linux 上造成巨大包混乱,因此建议直接用 C++;也有人用“两年写一个待办清单”的玩笑概括对 Haskell GUI 生产力的印象。(较多人) > 有些人像我一样避开 Haskell,就因为它在 Linux 上带来的庞大包混乱,Pandoc 就是典型;还是用 C++ 吧。(@drnick1)

认为 GTK 已变成 GNOME 专属,GTK4/5 破坏通用性

  • 老用户回顾 gtk2、gtk3、gtk4:GTK4 移除或改变大量旧行为,连窗口定位这类简单操作都要 workaround;GTK5 将只支持 Wayland,非 GNOME 用户更没理由继续投入,与 GNOME 开发者沟通也常被视为浪费时间;理想是更去中心化的工具包,但资金和 Web 冲击让 GUI 工具包式微。(少数人) > GTK4 是 GNOME 味工具包,GTK5 会更糟,因为只支持 Wayland;非 GNOME 的人为什么要给 GTK 续命。(@shevy-java)

认为性能优化可以优先于架构争论

  • 有人给出免费提升滚动性能的技巧:把 .backdrop::after 的 backdrop-filter 换成 .backdrop picture 上的 filter。(少数人) > 把 .backdrop::after 的 backdrop-filter 换到 .backdrop picture 上用 filter,能免费换来滚动性能提升。(@seba_dos1)

有价值的补充

  • 示例里的 GI.Awd 疑似拼写错误,按 Adwaita 绑定应为 GI.Adw,有人直接问这是不是拼写错误。
  • 有评论借教程例子反讽:这些例子再次证明 Haskell 是最好的命令式语言,并追问 widget 是不是 monad。