Содержание урока · 10 разделов
От ручной синхронизации к источникам данных#
Восемнадцать уроков подряд ты доставал данные в интерфейс по личному распоряжению: SetText в обработчике кнопки, RefreshItem после удаления записи, обновление метки после каждого клика. Пока источник один и обработчиков два, схема живёт; но когда значений становится десятки, а менять их нужно из разных мест — из меню четырнадцатого урока, из диалогов тринадцатого, из фоновых задач — ручная синхронизация начинает пропускать обновления, и интерфейс врёт. Пакет fyne.io/fyne/v2/data/binding решает задачу архитектурно. Запрос «fyne binding» — это источники данных, которые сами сообщают подписчикам об изменениях: значение перестаёт быть безликой переменной и становится объектом — DataItem с методами AddListener и RemoveListener. Над DataItem построены типизированные интерфейсы: binding.String с парой Get() (string, error) и Set(string) error, а рядом binding.Float, binding.Bool и binding.Int. Источник создаётся фабрикой: binding.NewString() даёт пустую строку, NewFloat(), NewBool() и NewInt() — нули; начального значения в конструкторе нет, заполнение делается через Set. Заметь контракт ошибок: и Get, и Set возвращают error. У фабричного источника она всегда nil — таково правило игры; но у источников, привязанных к внешнему миру, ошибка настоящая, и код честно получает пару (v, err), а не падает на месте. Третья опора — уведомление: после каждого Set источник рассылает слушателям сигнал DataChanged, и каждый подписчик перечитывает значение через Get. Виджеты уже умеют быть подписчиками: метод Bind есть у Entry, Label, Check, Slider, ProgressBar и Select. Разберём первый пример.
Первый источник: Entry и Label на одном значении
GOpackage main
import (
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Знакомство")
note := binding.NewString()
note.Set("Первая привязка")
entry := widget.NewEntry()
entry.Bind(note)
mirror := widget.NewLabel("")
mirror.Bind(note)
window.SetContent(container.NewVBox(entry, mirror))
window.ShowAndRun()
}note — источник binding.String со значением, заданным до создания виджетов. Entry.Bind подключает поле двусторонне: печать пользователя вызывает Set, и источник уведомляет всех. Label.Bind подключает подпись в одну сторону: она перечитывает Get при каждом изменении, и текст зеркалит поле без единого обработчика OnChanged.
Пользователь печатает в Entry, привязанном через Bind к binding.String. Что происходит со значением источника?
Скаляры и виджеты: кто с чем дружит#
Семейство источников покрывает все базовые типы интерфейса, и у каждого виджета есть свой естественный напарник. Текстовые — Entry и MultiLineEntry, а также Label — работают с binding.String. widget.Check и widget.Select нуждаются в булевом и строковом значении: Check привязывается к binding.Bool, галочка включена или выключена; Select хранит выбранный вариант как binding.String. Числа — царство binding.Float: Slider и ProgressBar обоих направлений привязываются к нему, поэтому громкость из ползунка и её отражение в прогресс-баре — это один источник и два Bind. Для целых счётчиков есть binding.Int, хотя на практике Float часто удобнее из-за прогресс-баров. Метод Bind принимает источник подходящего типа, компилятор не даст привязать Bool к Slider — типы защищают на этапе сборки. Противоположность — Unbind(): отвязывает виджет от источника и сбрасывает подписку; он обязателен при переиспользовании шаблонных элементов, о чём будет отдельный разговор ближе к концу урока. В англоязычной документации весь этот подход зовут fyne data binding: данные — единственный источник истины, виджеты — только отображение. Отсюда практическое правило перехода: если в коде появилась связка «обработчик меняет переменную и вручную обновляет метку», это кандидат на источник плюс Bind. Пять лет назад такие связки были нормой — в этом курсе они жили до девятого урока включительно; сегодня замени их привязкой, и класс рассинхронизации исчезнет как вид. Осталось показать связку Check-Slider-ProgressBar в сборе.
Экран громкости: один Float на три виджета
GOpackage main
import (
"fmt"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Громкость")
volume := binding.NewFloat()
volume.Set(0.4)
muted := binding.NewBool()
slider := widget.NewSlider(0, 1)
slider.Bind(volume)
bar := widget.NewProgressBar()
bar.Bind(volume)
label := widget.NewLabel("")
label.Bind(binding.FloatToString(func(f float64) string {
return fmt.Sprintf("Громкость: %.0f%%", f*100)
}))
mute := widget.NewCheck("Без звука", nil)
mute.Bind(muted)
window.SetContent(container.NewVBox(slider, bar, label, mute))
window.ShowAndRun()
}Три виджета на одном источнике: пользователь двигает слайдер — Set уходит в volume, прогресс-бар и подпись обновляются подпиской. Подпись показывает преобразование: FloatToString оборачивает Float в String с функцией форматирования, и Label.Bind получает уже готовый текст. Check на отдельном источнике Bool — булево состояние не смешивается с числом.
Какой источник нужен widget.Check для привязки через Bind?
Два подписчика на одном источнике
Прочитайте код и выберите, что он выведет:
name := binding.NewString()
name.Set("Том")
entry := widget.NewEntry()
entry.Bind(name)
label := widget.NewLabel("")
label.Bind(name)
// пользователь дописывает в поле: «Мария»
// что покажет label?Слушатель подключается — и что происходит сразу
Прочитайте код и выберите, что он выведет:
score := binding.NewFloat()
score.Set(3.5)
listener := binding.NewDataListener(func() {
v, _ := score.Get()
fmt.Println("счёт:", v)
})
score.AddListener(listener)
score.Set(4.0)
// сколько строк напечатается к этому моменту?Слушатели: DataListener в чистом виде#
Механизм подписки доступен и без виджетов — иногда нужно реагировать на изменение значением, а не картинкой: писать лог, пересчитывать итоги, дёргать сетевой запрос. Для этого есть интерфейс DataListener с единственным методом DataChanged и удобный конструктор binding.NewDataListener(func()) — он заворачивает твой колбэк в готового слушателя. Подключение — AddListener на источнике, отключение — RemoveListener. Помни две детали. Первая: подписка немедленно синхронизируется — предсказание выше показывало, что колбэк срабатывает уже при AddListener, поэтому не полагайся в нём на то, что «изменения ещё не было». Вторая: снимай слушателя, когда он больше не нужен. Источник держит ссылку на каждого подписчика; слушатель, привязанный к источнику с долгой жизнью, переживёт свой экран и будет выполняться впустую — классическая утечка замыканий. Практическое правило: кто создал слушателя для экрана, тот и снимает его при закрытии; в простых приложениях с одним окном этим можно пренебречь, в многоэкранных — нет. Порядок доставки не гарантирован: источник обходит подписчиков в порядке подписки, но строить логику на строгой очередности — хрупкое решение; если один слушатель должен увидеть результат другого, объединяй их в один колбэк. И последнее: колбэк выполняется в той горутине, где случился Set. Об этом — жёлтая рамка ниже и двадцатый урок.
Слушатель как живой статус
GOpackage main
import (
"fmt"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/widget"
)
func main() {
myApp := app.New()
window := myApp.NewWindow("Слушатель")
temperature := binding.NewFloat()
temperature.Set(20)
status := widget.NewLabel("")
listener := binding.NewDataListener(func() {
v, err := temperature.Get()
if err != nil {
status.SetText("ошибка чтения")
return
}
status.SetText(fmt.Sprintf("Температура: %.1f°", v))
})
temperature.AddListener(listener)
up := widget.NewButton("+0.5", func() {
v, _ := temperature.Get()
temperature.Set(v + 0.5)
})
window.SetContent(container.NewVBox(status, up))
window.ShowAndRun()
}Слушатель читает значение через Get с честной проверкой err и пишет текст в метку — без Bind, чистой подпиской. Заметь: метка заполнится ещё до первого клика, потому что AddListener вызывает DataChanged сразу. Кнопка читает текущее значение и пишет обратно — источник остаётся единственным местом хранения.
Почему слушатель, ставший ненужным, стоит снимать через RemoveListener?
Внешние значения и преобразования#
Не всегда значение рождается внутри источника: иногда оно уже живёт в поле твоей структуры, и хочется привязать интерфейс к нему, не переписывая модель. Для этого есть семейство Bind: binding.BindString(&myField) возвращает ExternalString — источник, у которого Set пишет и в хранилище, и в твою переменную, а метод Reload() перечитывает внешнее значение и рассылает уведомления, если данные поменялись извне. Аналогично устроены BindFloat, BindBool и BindInt с указателями на float64, bool и int. Правило одно: переменная обязана жить достаточно долго — указатель на локальную переменную функции приведёт к висячей привязке. Второй инструмент — преобразования. binding.StringToString(func(s string) string) даёт источник-надстройку: он слушает исходный и выдаёт преобразованное значение; пример с громкостью показывал FloatToString для процентов. Если преобразование должно работать в обе стороны — из отображения в данные при правке поля — бери FloatToStringWithReverse и братьев: конструктор принимает две функции, прямую и обратную, и привязанный Entry пишет в данные уже разобранное значение. Классика — температура в обе стороны или форматирование дат. Третий инструмент — коллекции: binding.NewUntypedList() создаёт источник списка, Append и Remove меняют состав, а widget.List.Bind подключает виртуализированный список из десятого урока к данным — элементы списка получают свои мини-источники, и RefreshItem становится не нужен: изменение элемента обновляет только его строку. Для таблиц одиннадцатого урока есть табличные привязки — механика та же, детали — в документации пакета.
Каталог API привязки#
| API | Что делает | Когда звать |
|---|---|---|
| binding.NewString() / NewFloat() / NewBool() / NewInt() | создать пустой источник-скаляр | значение состояния рождается в приложении |
| item.Set(v) и v, err := item.Get() | записать и прочитать значение; оба возвращают error | в обработчиках и слушателях |
| widget.Bind(item) | привязать виджет к источнику; Entry — двусторонне | вместо ручных SetText и OnChanged |
| widget.Unbind() | отвязать виджет и снять подписку | переиспользование шаблона элемента |
| binding.BindString(&ext) | внешнее значение: Set пишет в переменную, Reload перечитывает | данные живут в своей структуре |
| binding.NewDataListener(func()) | слушатель из обычного колбэка | реакция на изменение без виджета |
| AddListener / RemoveListener | подписка и её снятие; подписка срабатывает сразу | не забывай снимать лишних |
| binding.StringToString(f) | надстройка-преобразование для отображения | формат вывода без правки данных |
| binding.FloatToStringWithReverse(f, g) | преобразование в обе стороны | поле правит данные через конвертер |
| binding.NewUntypedList() | источник динамического списка | List.Bind вместо ручного RefreshItem |
| binding.BindPreferenceString(key, prefs) | источник прямо из хранилища настроек | мост к девятнадцатому уроку |
Собери привязку со слушателем
Восстанови пять пропусков: создание источника, привязка поля, фабрика слушателя, чтение с ошибкой и подключение подписки.
Путь значения от Set до экрана
Расставь по порядку, что происходит после вызова Set у источника, на который смотрят три подписчика.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Привязкам нужен настоящий экран — в браузерной песочнице курса GUI не исполняется. Собери примеры по второму уроку и обязательно проверь два сценария: печать в привязанное поле меняет второй виджет, а слушатель печатает стартовое значение ещё до первого изменения.
Колбэк слушателя выполняется в той горутине, где случился Set. Если значение пишет фоновая задача, то и все виджеты-подписчики обновятся из неё — а трогать интерфейс вне главного потока канон запрещает с шестого урока. Мост к данным оставь, но запись обёртывай fyne.Do — полный разбор потоков и правило «одно окно, один поток» ждут в двадцатом уроке.
Архитектура: одна истина#
Главный подарок привязки — не экономия на SetText, а дисциплина состояния. Одна истина: значение живёт в источнике, и виджет — лишь окно в него; два места хранения одного значения гарантированно разъедутся, вопрос только когда. Практика: собирай источники в структуру состояния приложения — App struct с полями-привязками создаётся при старте и передаётся экранам; обработчики меняют источники, экраны подписаны, и новая фича не требует ревизии всех старых обработчиков. Не дублируй: если текст поля уже в binding.String, не храни копию в переменной «на всякий случай» — читай Get в момент нужды. Следи за переиспользованием: в шаблоне элемента List из десятого урока виджет один, а значений много — перед привязкой нового значения зови Unbind, иначе подписка от прошлой строки продолжит писать в свежие данные, и список начнёт путать строки под прокруткой. Это самый частый баг новичка в коллекциях с привязкой. Разумно делить: не превращай всё подряд в источники — локальная переменная внутри обработчика не заслуживает DataItem; привязка окупается там, где значение видно более чем в одном месте или меняется более чем из одного места. Помни про ошибки: Get возвращает пару, и источники, привязанные к внешнему миру, вправе ответить не-nil — обрабатывай хотя бы до уровня «показать ShowError из тринадцатого урока». Наконец, мости уровни: доменная модель пусть остаётся обычными структурами, а источники — тонким слоем между моделью и экраном; BindString-семейство как раз для этого.
Что дальше: валидация ввода#
Теперь данные текут: поле пишет в источник, подписчики обновляются сами, слушатели логируют каждое изменение. Но поток бесполезен, если в нём мусор — «abc» в поле возраста доедет до источника так же честно, как «42». Опора урока — девятый с Entry и его событиями, десятый с шаблонами списка и Unbind, второй с каркасом запуска. Следующая тема — валидация ввода: встроенные валидаторы Entry, свои правила func(string) error, красная рамка и блокировка Submit, пока данные не стали съедобными; привязка и валидация работают в паре — проверенное значение заслуживает права попасть в источник. Домашнее задание соберёт панель счётчика на одном Float с тремя виджетами и слушателем-логгером. Полный маршрут — на странице курса Fyne.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyne…
Экран собран: «binding»
Коротко: что запомнить
- Пакет fyne.io/fyne/v2/data/binding делает значение объектом-источником: DataItem с подпиской, типизированные String, Float, Bool, Int с парой Get/Set и честным error.
- widget.Bind подключает виджет к источнику: Entry и Slider пишут в данные сами, Label и ProgressBar только показывают; Unbind снимает привязку.
- После каждого Set источник рассылает DataChanged: сколько бы подписчиков ни висело, все перечитают значение — ручной SetText больше не нужен.
- AddListener срабатывает немедленно: слушатель и привязанный виджет видят текущее значение сразу; ненужных слушателей снимай RemoveListener.
- BindString подключает внешнюю переменную, StringToString и WithReverse-семейство преобразуют значения для отображения и обратно.
- NewUntypedList с List.Bind переводит коллекции на привязку — изменение элемента обновляет только его строку без RefreshItem.
- Правило одной истины: данные в источниках, виджеты — отображение; Set из чужой горутины — только через fyne.Do, детали в двадцатом уроке.
Домашнее задание
Собери панель счётчика объёма на одном источнике: binding.NewFloat, Slider и ProgressBar привязаны к нему, Label показывает проценты через FloatToString, кнопка «Сброс» пишет Set(0). Добавь ручного слушателя через NewDataListener, который печатает каждое значение в терминал, — и проверь предсказание урока: строка со стартовым значением должна появиться ещё до первого клика. Сделай второй экран на том же Float: переключение вкладкой из двенадцатого урока, и убедись, что оба экрана показывают одно значение без единого обработчика синхронизации. Смоделируй список: NewUntypedList, кнопка добавления Append, List.Bind с шаблоном из десятого урока — и проверь, что удаление через Remove обновляет только нужную строку. Ловушка для самопроверки: попробуй переиспользовать один Entry в шаблоне без Unbind и добейся путаницы строк; найди минимальный сценарий, где она проявляется. Если захочешь писать в источник из горутины — остановись и перечитай жёлтую рамку: правильный инструмент появится в двадцатом уроке.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем binding лучше привычных OnChanged и SetText?
OnChanged ловит только правки пользователя, а источнику без разницы, кто вызвал Set: код, слушатель или другой виджет. Привязка убирает целый класс рассинхронов: не нужно помнить, какие метки обновлять после каждого изменения, — подписчики перечитывают значение сами.
Можно ли вызывать Set и Get из горутин?
Сам источник потокобезопасен, но колбэки слушателей выполняются в горутине вызова, и обновление виджетов из чужого потока запрещено каноном GUI. Пиши в источники через fyne.Do из двадцатого урока — тогда уведомления разойдутся по правилам.
Как привязать целую структуру, а не отдельное поле?
Готовых структурных источников нет: держи модель обычными структурами и подключай поля по одному через BindString-семейство, либо сериализуй структуру в строку и привязывай её. Для коллекций — NewUntypedList и табличные привязки из документации пакета.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки