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-элемент списка и вызывается позже (по клику), элемент к тому времени может быть уже освобождён.