Git

Vala: GTK4 и сигналы

Вторая страница раздела о памяти в Vala: что нужно знать про виджеты GTK4, контроллеры жестов, списки, привязки свойств и CSS. Основы замыканий разобраны на странице память и замыкания.

Подписки на сигналы#

Подписки отписывают явно, в dispose. Идентификатор подписки хранят в поле:

public class CustomDialog : Gtk.Window { private Gtk.Button action_button; private ulong clicked_handler_id = 0; public CustomDialog () { this.action_button = new Gtk.Button.with_label ("Click"); this.clicked_handler_id = this.action_button.clicked.connect (on_button_clicked); } private void on_button_clicked () { print ("Clicked!\n"); } public override void dispose () { if (this.clicked_handler_id != 0) { this.action_button.disconnect (this.clicked_handler_id); this.clicked_handler_id = 0; } base.dispose (); } }

Время жизни подписки определяет излучатель сигнала. Если он живёт дольше подписчика, а отписки нет, подписчик утечёт.

Контроллеры жестов#

widget.add_controller (controller) отдаёт виджету сильное владение контроллером. Если ещё и держать контроллер в поле, а в обработчике использовать this, получится цикл:

// Плохо: поле держит контроллер, контроллер держит this, объект никогда не удалится public class LeakyCardWidget : Gtk.Box { private Gtk.GestureClick click_gesture; public LeakyCardWidget () { Object (orientation: Gtk.Orientation.VERTICAL, spacing: 6); this.click_gesture = new Gtk.GestureClick (); this.click_gesture.pressed.connect ((n_press, x, y) => { this.on_card_clicked (n_press); }); this.add_controller (this.click_gesture); } private void on_card_clicked (int n_press) { message ("Clicked %d", n_press); } }

Вторая типичная ошибка: добавлять новый контроллер при каждом обновлении, не удаляя старый. Обработчики дублируются вдвое, втрое и так далее:

// Плохо: новый контроллер при каждом вызове public void update_data (string title) { var click = new Gtk.GestureClick (); click.pressed.connect (() => { message ("Click!"); }); this.add_controller (click); }

Исправление: контроллер это локальная переменная (владение забирает add_controller), а в замыкании unowned-ссылка на виджет:

public class SafeCardWidget : Gtk.Box { public SafeCardWidget () { Object (orientation: Gtk.Orientation.VERTICAL, spacing: 6); var click_gesture = new Gtk.GestureClick (); unowned SafeCardWidget self = this; click_gesture.pressed.connect ((n_press, x, y) => { self.on_card_clicked (n_press); }); this.add_controller (click_gesture); } private void on_card_clicked (int n_press) { message ("Clicked"); } }

Правило для обработчиков самого контроллера: внутри лямбды используйте только weak-копию контроллера, сильная переменная нужна лишь вне лямбды, для add_controller. Объект, упомянутый в собственном обработчике, создаёт цикл через общий блок замыкания, см. историю.

Важно. Если логический класс-обёртка держит виджет, время жизни обёртки должно совпадать со временем жизни виджета по архитектуре. Нельзя «лечить» утечку слабой ссылкой: обёртка исчезнет, виджет останется на экране, и клики молча перестанут работать.

Шаблоны: GtkChild#

Поля [GtkChild] должны быть строго unowned. Поле owned по умолчанию заставляет Vala освободить ссылку, которой у неё не было: при разрушении шаблона GTK уже отпустил ребёнка, и дальше срабатывает двойное освобождение или падение.

[GtkTemplate (ui = "/org/niti/ui/user_profile.ui")] public class UserProfileWidget : Gtk.Box { [GtkChild] private unowned Gtk.Label name_label; [GtkChild] private unowned Gtk.Image avatar_image; [GtkChild] private unowned Gtk.Button edit_button; private ulong edit_click_handler_id = 0; public UserProfileWidget () { this.init_template (); unowned UserProfileWidget self = this; this.edit_click_handler_id = this.edit_button.clicked.connect (() => { self.on_edit_clicked (); }); } private void on_edit_clicked () { message ("Edit clicked"); } public override void dispose () { if (this.edit_click_handler_id != 0 && this.edit_button != null) { this.edit_button.disconnect (this.edit_click_handler_id); this.edit_click_handler_id = 0; } this.dispose_template (typeof (UserProfileWidget)); base.dispose (); } }

Современные версии valac могут отклонить неверное поле уже при компиляции, но на это не стоит полагаться для старых версий.

Списки: ListView и фабрика#

Gtk.ListView переиспользует элементы: setup, затем bind при каждой прокрутке, unbind, teardown. Главная утечка: подписка на модель в bind без отписки в unbind. При каждой прокрутке к переиспользованному элементу добавляется ещё один обработчик, старые не снимаются:

// Плохо: обработчики накапливаются, unbind отсутствует this.factory.bind.connect ((factory, list_item_obj) => { var list_item = (Gtk.ListItem) list_item_obj; var label = (Gtk.Label) list_item.child; var data_model = (MyDataItem) list_item.item; label.label = data_model.title; data_model.notify["title"].connect (() => { label.label = data_model.title; }); });

Лучший способ в GTK4: декларативная привязка через выражение. GTK сам управляет подпиской и отпиской:

factory.setup.connect ((factory, list_item_obj) => { var list_item = (Gtk.ListItem) list_item_obj; var label = new Gtk.Label (""); var item_expr = new Gtk.PropertyExpression (typeof (Gtk.ListItem), null, "item"); var title_expr = new Gtk.PropertyExpression (typeof (MyDataItem), item_expr, "title"); title_expr.bind (label, "label", list_item); list_item.child = label; });

Если пользуетесь ручной подпиской, обязательно отписывайтесь в unbind.

Привязки свойств#

bind_property не отменяет связь при удалении виджета, если не сохранён объект Binding и не вызван unbind (). Binding держит ссылки на оба объекта, и долгоживущий источник удерживает виджет:

public class TaskRow : Gtk.ListBoxRow { private Gee.ArrayList<Binding> bindings = new Gee.ArrayList<Binding> (); public TaskRow (Task task) { this.bindings.add (task.bind_property ("title", this.title_label, "label", BindingFlags.SYNC_CREATE)); this.bindings.add (task.bind_property ("complete", this.check_button, "active", BindingFlags.SYNC_CREATE)); } public override void dispose () { foreach (var binding in this.bindings) binding.unbind (); this.bindings.clear (); base.dispose (); } }

CSS#

Gdk.Display хранит все добавленные провайдеры. Если при смене темы добавлять новый провайдер, не убрав старый, каждый вызов копит сотни килобайт и замедляет расчёт стилей:

public class SafeThemeManager : GLib.Object { private static Gtk.CssProvider? current_provider = null; public static void apply_css (string css_data) { var display = Gdk.Display.get_default (); if (display == null) return; if (current_provider != null) Gtk.StyleContext.remove_provider_for_display (display, current_provider); var provider = new Gtk.CssProvider (); provider.load_from_string (css_data); Gtk.StyleContext.add_provider_for_display (display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION); current_provider = provider; } }

Важно. Gtk.StyleContext объявлен устаревшим с GTK 4.10. Принцип не меняется: старый провайдер нужно снимать. Сверяйте актуальные имена методов в документации GTK.

Анимации с масштабом над текстом#

CSS-анимация с scale() над блоком текста приводит к тому, что каждый кадр глифы рендерятся в новом дробном масштабе, и кэш глифов GSK пополняется. В панели это давало рост около 150 КБ на каждое открытие окна поиска. Для появления и скрытия используйте только прозрачность и сдвиг (opacity, translate).

Окна Layer Shell#

Создавать новое Gtk.Window на каждый показ регулятора громкости или меню питания нельзя: на стороне композитора и библиотеки layer-shell остаются протокольные ресурсы. Создайте окно один раз и переиспользуйте его: показывайте и скрывайте.

Данные: GVariant, массивы, коллекции#

Несколько правил, которые вытекают из реальных багов:

  • Методы get_child_value (), iter.next_value () и call_finish () возвращают обычную ссылку. Присваивание такого результата переменной unowned приведёт к утечке на каждый вызов. Используйте обычную (owned) переменную.
  • Не сохраняйте unowned-срез массива и не изменяйте исходный массив (resize, +=), пока срез нужен: срез станет висячим указателем.
  • Для Gdk.Pixbuf закрывайте GdkPixbufLoader через close () в try/finally. Кэши миниатюр делайте ограниченного размера, а не неограниченным словарём.
  • У GLib.List и GLib.HashTable внимательно относитесь к владению элементами. append () возвращает новую голову списка, это важно, если список лежит в таблице.
  • Если замыкание захватило unowned-элемент списка и вызывается позже (по клику), элемент к тому времени может быть уже освобождён.