Stop Thinking in Rows: GListModel and the Modern List Mindset

Stop Thinking in Rows: GListModel and the Modern List Mindset

停止以“行”为单位思考:GListModel 与现代列表思维

This is the third entry in A Field Guide to GTK Widgets — a series about which widget to reach for, when, and what bites you when you do. We’re jumping straight to lists, ahead of layout and navigation, because it’s the material the reference documents worst. Each post stands on its own, and the complete, runnable code for this one lives in the companion repo. 这是《GTK 组件实战指南》系列的第三篇文章,该系列旨在探讨何时使用哪些组件,以及使用时会遇到哪些坑。我们直接跳到列表部分,排在布局和导航之前,因为这是参考文档中写得最糟糕的部分。每篇文章都是独立的,完整的可运行代码可以在配套仓库中找到。

The list you’d build without thinking

你会不假思索构建的列表

Say you’re showing a handful of tasks. The obvious way to do it doesn’t require reading any documentation at all: 假设你要展示几个任务。最直观的做法甚至不需要阅读任何文档:

let list_box = gtk::ListBox::new();
for task in &tasks {
    let label = gtk::Label::builder()
        .label(&task.title)
        .xalign(0.0)
        .build();
    list_box.append(&label);
}

Loop over your data, build a row, append it. It’s the same instinct you’d bring from almost any UI toolkit that isn’t fully declarative, and for a fixed handful of items it’s completely fine — we’ll come back to exactly when it’s fine later in this post. The trouble starts the moment the data stops holding still. 遍历数据,构建一行,然后追加。这几乎是任何非完全声明式 UI 工具包都会带来的直觉。对于固定数量的项目,这样做完全没问题——我们稍后会在文中讨论它具体在何时适用。但一旦数据开始变动,麻烦就来了。

Where that breaks

这种方式的问题所在

A task gets marked done somewhere else in the app. The list needs to re-sort so completed tasks drop to the bottom. A “show completed” toggle needs to filter rows in and out. With the loop-and-append approach, every one of those is a hand-rolled patch to a widget tree you built once and now have to keep in sync by hand: find the right row, remove it, rebuild it, reinsert it at the right index, and don’t get the index wrong or you’ll silently update the wrong task. 当应用的其他地方将一个任务标记为完成时,列表需要重新排序,以便已完成的任务沉到底部;或者“显示已完成”的开关需要过滤行。使用“循环并追加”的方法,每一个操作都需要手动修补你曾经构建过一次的组件树,并必须手动保持同步:找到正确的行、移除它、重建它、在正确的索引处重新插入。如果索引搞错了,你就会在不知不觉中更新了错误的记录。

None of this is exotic; it’s the normal shape of a list that does anything at all, and it’s exactly the kind of code that works fine in testing and comes apart three weeks later. This is the specific pain GTK4 redesigned around, and it’s worth naming what it replaced. Older GTK did this with GtkTreeView, GtkListStore, and cell renderers — a whole apparatus for exactly this problem, and it’s largely deprecated now. 这并不罕见;这是任何具有交互功能的列表的常态。这种代码在测试时运行良好,但三周后就会崩溃。这正是 GTK4 重新设计的核心痛点,值得一提的是它取代了什么。旧版 GTK 使用 GtkTreeViewGtkListStore 和单元格渲染器来处理这个问题——这是一整套专门针对此问题的机制,但现在已基本被弃用。

The series intro opens with the story of finding a six-year-old Stack Overflow answer built on exactly that apparatus, getting it working, and only later discovering the toolkit had moved on without telling you. This post is the other half of that story: what actually replaced it, and why it’s better once it clicks. 本系列开篇讲述了找到一个基于旧机制的六年前 Stack Overflow 答案,将其跑通后才发现工具包早已更新的故事。本文是故事的另一半:究竟是什么取代了它,以及为什么一旦理解了它,你会发现它更好用。

GListModel: data as the source of truth

GListModel:以数据为真理来源

The modern answer inverts the relationship. Instead of a widget tree you mutate by hand, you keep your data in a list model — something implementing the GListModel interface — and the view watches it. GListModel isn’t a widget; it’s an interface any GObject can implement to say “I’m an ordered, indexable collection, and I’ll tell you when I change.” The concrete type you’ll reach for constantly is GListStore: a plain, mutable, in-memory list model you own and push items into. 现代的解决方案颠倒了这种关系。你不再手动修改组件树,而是将数据保存在列表模型中(即实现 GListModel 接口的对象),由视图来观察它。GListModel 不是一个组件;它是一个任何 GObject 都可以实现的接口,用来声明:“我是一个有序的、可索引的集合,当发生变化时我会通知你。”你最常使用的具体类型是 GListStore:一个简单的、可变的、内存中的列表模型,你可以拥有它并向其中添加项目。

Once data and view are genuinely separate concerns, the files should say so too. This example is small enough to fit in one, but I’ve split it the way I would in a real app instead — the way the companion repo actually has it: task.rs for the data, task_row.rs for turning one task into one row, window.rs for wiring the two into a window. The boundary is easier to see with it actually drawn. 一旦数据和视图真正分离,代码文件也应该体现这一点。这个例子虽然很小,可以放在一个文件中,但我还是按照真实应用的方式进行了拆分——这也是配套仓库中的做法:task.rs 负责数据,task_row.rs 负责将任务转换为行,window.rs 负责将两者连接到窗口中。这样划分边界会更清晰。

(Code snippets omitted for brevity) (代码片段略)

Two things stand out here. First, GListStore only holds GObjects, not arbitrary Rust structs — a plain Task can’t go in directly. The usual fix is a real GObject subclass with proper properties, which is the right call for anything long-lived, but that’s its own topic and I’ve already covered the subclassing mechanics in the GNOME/Rust series rather than repeat it here. 这里有两点值得注意。首先,GListStore 只持有 GObject,而不是任意的 Rust 结构体——普通的 Task 不能直接放入。通常的解决方法是创建一个带有适当属性的 GObject 子类,这对长期存在的对象来说是正确的做法,但这本身就是一个大话题,我已经在 GNOME/Rust 系列中介绍过子类化机制,这里不再赘述。

For a case like this one, where the items are plain data and nothing needs to bind to their properties, glib::BoxedAnyObject is the honest shortcut: it wraps any Rust value as a GObject with no subclassing at all, at the cost of borrow()/borrow_mut() runtime checks instead of compile-time property access. Reach for it when you just need data in a list model; reach for a real subclass when something needs to observe a property changing. 对于像本例这样项目只是纯数据、且不需要绑定属性的情况,glib::BoxedAnyObject 是一个诚实的捷径:它将任何 Rust 值包装为 GObject,无需任何子类化,代价是使用 borrow()/borrow_mut() 运行时检查,而不是编译时的属性访问。当你只需要在列表模型中存放数据时使用它;当需要观察属性变化时,请使用真正的子类。

Second, and this is the point of the whole post: you never touch a widget here. task.rs doesn’t import gtk at all — nothing above creates a label or a row. The store is just data. Getting it on screen needs two more pieces: a selection model and a factory. Then the mutation story pays off. 第二点,也是整篇文章的重点:你在这里根本不需要触碰组件。task.rs 完全没有导入 gtk——上面的代码没有创建任何标签或行。Store 仅仅是数据。要将其显示在屏幕上,还需要两个部分:选择模型(Selection Model)和工厂(Factory)。到那时,这种修改数据的模式才会显现出它的价值。

The other half, briefly

简述另一半

GtkListView won’t take a bare list model; it needs a selection model wrapped around it, even if you don’t care about selection at all. NoSelection is the model for exactly that case — a pass-through that reports nothing selectable. And it needs a factory: a recipe for turning one item into one row. That recipe is the entire job of task_row.rs. GtkListView 不接受原始的列表模型;它需要一个包装好的选择模型,即使你根本不在乎选择功能。NoSelection 正是为此而生的模型——一个不报告任何可选项的透传模型。它还需要一个工厂:一个将一个项目转换为一行的配方。这个配方就是 task_row.rs 的全部工作。

I’m deliberately not unpacking this fully. setup builds a row’s widgets once; bind fills them in with a… 我特意没有完全展开讲解。setup 只构建一次行的组件;bind 则用……填充它们。