Содержание урока · 11 разделов
- Финал: приложение, а не пример
- Архитектура: данные живут отдельно
- Список задач на виртуализации
- Состояние и привязки: счётчик и тема
- Удаление и подтверждения
- Каркас запуска по канону курса
- Карта проекта: какой урок поставил какой блок
- Чек-лист качества и горизонт
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Финал: приложение, а не пример#
Каждый пример курса делал одну вещь: окно — про компоновку, список — про виртуализацию, настройки — про хранение. Приложение отличается тем, что решает все вопросы сразу и не разваливается, когда их становится десять. Менеджер задач идеален для финала: он выглядит примитивно, но внутри скрыт весь список тем, которые ты проходил — и только полная сборка показывает, что ни одна из них не была лишней. Что делает программа: показывает список задач, позволяет добавить новую через поле с проверкой, отметить выполненную галочкой, удалить с подтверждением, переключить светлую и тёмную тему и — главное — помнит всё это после перезапуска. Что не делает: синхронизацию с облаком, совместное редактирование и всё, что требует сервера; границы проекта — тоже навык. Запрос «fyne проект» обычно приводит к туториалам с готовым кодом, но этот урок ведёт иначе: каждый фрагмент опирается на урок, где ты его уже видел, поэтому после чтения ты не скопируешь проект, а сможешь воспроизвести его по памяти. Читай с редактором открытым: вводить код руками — часть метода. Начнём с самой важной строки архитектуры: данные живут отдельно от интерфейса.
Архитектура: данные живут отдельно#
Главная ошибка первого GUI-проекта — хранить состояние в виджетах: задачи в срезе, спрятанном внутри List, флаги в метках, истина размазана по экрану. Такой код работает, пока не нужен второй экран, тесты из двадцать четвёртого урока или сохранение. Правило проекта обратное: экран — функция от данных, и рисует он то, что вернёт хранилище. Слой данных — обычные структуры Go: тип Task с текстом и флагом выполнения и тип Store, который держит срез задач и умеет четыре вещи — добавить, переключить, удалить и сохранить. Никакого fyne в этом файле, кроме единственной зависимости от preferences для записи: Store тестируется чистыми тестами без интерфейса, а завтра его можно переездить в файлы из двадцать первого урока или в базу — экран не заметит. Слой интерфейса — функции, которые строят виджеты и подписываются на изменения: после каждой мутации хранилища вызывается один колбэк синхронизации, который обновляет список и счётчик. Одна точка правды, одна точка обновления — вот вся дисциплина. Почему это критично именно в GUI: событие пользователя приходит внезапно и в произвольный момент, и если каждая обработка сама чинит экран, рано или поздно два обработчика починят его вразнобой. Поэтому дальше все фрагменты кода делятся на два файла: store.go — модели и хранилище, main.go — интерфейс. Начнём с store.go.
Модель и хранилище
GOpackage main
import (
"encoding/json"
"fyne.io/fyne/v2"
)
type Task struct {
Text string `json:"text"`
Done bool `json:"done"`
}
const taskKey = "tasks.json"
type Store struct {
prefs fyne.Preferences
tasks []Task
OnChange func() // единая точка обновления интерфейса
}
func NewStore(prefs fyne.Preferences) *Store {
s := &Store{prefs: prefs}
if raw := prefs.String(taskKey); raw != "" {
_ = json.Unmarshal([]byte(raw), &s.tasks)
}
return s
}
func (s *Store) save() {
raw, _ := json.Marshal(s.tasks)
s.prefs.SetString(taskKey, string(raw))
if s.OnChange != nil {
s.OnChange()
}
}
func (s *Store) Add(text string) { s.tasks = append(s.tasks, Task{Text: text}); s.save() }
func (s *Store) Toggle(i int) { s.tasks[i].Done = !s.tasks[i].Done; s.save() }
func (s *Store) Remove(i int) { s.tasks = append(s.tasks[:i], s.tasks[i+1:]...); s.save() }
func (s *Store) Len() int { return len(s.tasks) }
func (s *Store) DoneCount() int { n := 0; for _, t := range s.tasks { if t.Done { n++ } }; return n }Хранилище — обычные структуры Go с одной зависимостью от preferences из девятнадцатого урока. Список сериализуется в JSON-строку и живёт под одним ключом. Каждая мутация заканчивается вызовом save: запись на диск и колбэк OnChange для интерфейса — в одном месте, забыть нельзя.
Почему список задач хранится как JSON-строка под одним ключом preferences?
Список задач на виртуализации#
Экранное сердце проекта — widget.List из десятого урока, и сейчас станет понятно, почему курс учил именно три функции. Length() int говорит, сколько задач сейчас в хранилище — не в кеше списка, а прямо len(store.tasks); CreateTemplate() строит пустую строку один раз — горизонтальный контейнер с галочкой и меткой; UpdateItem(i, item) наполняет шаблон данными i-й задачи: текст в метку, флаг в галочку, зачёркивание через TextStyle для выполненных. Виртуализация означает, что для тысячи задач создаются лишь десятки видимых шаблонов и переиспользуются при прокрутке — поэтому UpdateItem обязан перезаписывать всё содержимое, а не полагаться на то, что там было. Коварная ловушка этого слоя — галочка: OnChanged срабатывает и когда пользователь кликнул, и когда ты сам присвоил Checked в UpdateItem, и без защиты клик по строке порождает цикл обновлений. Лечение — временная заглушка обработчика на время программной установки, либо установка флага напрямую без события. Вторая ловушка — устаревшие индексы: после удаления список смещается, и колбэки со старым i в замыкании удалят не ту задачу; поэтому в замыкание идёт актуальный индекс на момент отрисовки, а удаление идёт по выбору пользователя в момент клика. Отдельный вопрос — refresh: List не следит за срезом, после каждой мутации нужен list.Refresh(), и он у нас будет ровно один — в функции синхронизации, о которой ниже. К списку примыкает панель добавления: поле с валидатором из восемнадцатого урока и кнопка из третьего.
Список, поле с проверкой и кнопка
GOfunc buildUI(window fyne.Window, store *Store, counter binding.String) (*widget.List, fyne.CanvasObject) {
list := widget.NewList(
func() int { return store.Len() },
func() fyne.CanvasObject {
return container.NewHBox(widget.NewCheck("", nil), widget.NewLabel("шаблон"))
},
func(i widget.ListItemID, o fyne.CanvasObject) {
row := o.(*fyne.Container).Objects
chk, lbl := row[0].(*widget.Check), row[1].(*widget.Label)
task := store.tasks[i]
chk.OnChanged = nil
chk.SetChecked(task.Done)
chk.OnChanged = func(bool) { store.Toggle(i) }
lbl.SetText(task.Text)
lbl.TextStyle.Strikethrough = task.Done
lbl.Refresh()
},
)
entry := widget.NewEntry()
entry.SetPlaceHolder("Что нужно сделать?")
entry.Validator = validation.NewRegexp("\\S+", "Пустая задача не нужна")
add := widget.NewButtonWithIcon("Добавить",
theme.Icon(theme.IconNameContentAdd), func() {
if entry.Validate() != nil {
return // валидатор уже показал ошибку под полем
}
store.Add(entry.Text)
entry.SetText("")
})
entry.OnSubmitted = add.OnTapped
count := widget.NewLabelWithData(counter)
frame := container.NewBorder(nil, container.NewVBox(count, container.NewHBox(entry, add)), nil, nil, list)
return list, frame
}Три функции списка читают хранилище напрямую — кеша нет. Обработчик галочки глушится на время программной SetChecked, чтобы не зациклить обновления. Поле проверяется валидатором из восемнадцатого урока, Enter равен кнопке через OnSubmitted из девятого, счётчик снизу живёт на привязке, компоновка — Border из четвёртого.
Что покажет список после добавления
Прочитайте код и выберите, что он выведет:
store := NewStore(prefs)
list := widget.NewList(
func() int { return store.Len() },
createTemplate,
updateItem,
)
store.Add("Купить хлеб")
// list.Refresh() ещё не вызывали
// сколько задач покажет список и что нужно, чтобы показал?Состояние и привязки: счётчик и тема#
Теперь единственная точка синхронизации. Колбэк store.OnChange присваивается одной функции syncState, и она делает ровно три вещи: обновляет счётчик через источник привязки из семнадцатого урока, дергает list.Refresh() и сохраняет, если мутация ещё не сохранила. Счётчик — это binding.NewString и метка через NewLabelWithData: строка «Всего: 3, готово: 1» пишется через Set, а метка обновляется сама — маленькая демонстрация привязок там, где ручной SetText располз бы по трём обработчикам. Механика: источник Set уведомляет слушателей, метка перерисовывается, данные и экран снова не встретились напрямую. Если бы счётчиков и меток было много, этот слой вырос бы в полноценную модель представления — но дисциплина одна: экран отражает то, что сказал syncState, и больше ничего не пишет. Тема — вторая часть состояния: флаг из preferences девятнадцатого урока читается при старте с WithFallback, а переключатель в тулбаре из четырнадцатого урока вызывает myApp.Settings().SetTheme с тёмным или светлым вариантом из пятнадцатого урока и пишет флаг обратно. Порядок важен: тема применяется до построения интерфейса, чтобы первый кадр не мигал. Обрати внимание на границу: тема — настройка приложения, она живёт в preferences; задачи — данные, они в том же хранилище, но под своим ключом, и при переезде в файлы уедут только задачи. Данные и настройки — разные жанры, даже если хранилище одно.
Единая синхронизация и переключатель темы
GOfunc syncState(store *Store, list *widget.List, counter binding.String) {
counter.Set(fmt.Sprintf("Всего: %d, готово: %d", store.Len(), store.DoneCount()))
list.Refresh()
}
func buildToolbar(myApp fyne.App, prefs fyne.Preferences, list *widget.List, window fyne.Window) *widget.Toolbar {
applyTheme := func(dark bool) {
if dark {
myApp.Settings().SetTheme(theme.DarkTheme())
} else {
myApp.Settings().SetTheme(theme.LightTheme())
}
prefs.SetBool("theme.dark", dark)
}
return widget.NewToolbar(
widget.NewToolbarAction(theme.Icon(theme.IconNameColorPalette), "Тема", func() {
applyTheme(!prefs.BoolWithFallback("theme.dark", true))
}),
widget.NewToolbarAction(theme.Icon(theme.IconNameDelete), "Удалить", onDelete(list, window)),
)
}syncState — единственное место, которое трогает список и счётчик; обработчики хранилища вызывают его через OnChange. Счётчик идёт через источник binding из семнадцатого урока, тема — через SetTheme из пятнадцатого с флагом в preferences из девятнадцатого, тулбар — из четырнадцатого.
Где правильно обновлять счётчик задач при каждой мутации хранилища?
Удаление и подтверждения#
Удаление — первое по-настоящему разрушительное действие пользователя, и интерфейс обязан быть вежливым. Выбор строки даёт список через OnSelected — в проекте запоминается индекс выбранной задачи; кнопка удаления на тулбаре с иконкой из каталога встроенных значков активна, только когда выбор есть. Дальше диалог из тринадцатого урока: dialog.ShowConfirm показывает текст удаляемой задачи и ждёт ответа, мутация происходит только в ветке подтверждения. Порядок операций после подтверждения важен: сначала снять выбор — после удаления индексы сместились и выбранный указатель указывает на чужую задачу, — затем вызвать store.Remove, который через save дергает OnChange и синхронизирует экран. Вспомогательный сценарий — контекстное меню из четырнадцатого урока: правый клик по строке через TappedSecondary показывает ShowPopUpMenu с пунктами «Выполнить» и «Удалить»; это ускоряет работу опытных пользователей, не заменяя тулбар. И предупреждение, которое стоит держать в голове с двадцатого урока: весь этот код исполняется в обработчиках событий, то есть в главном потоке — если когда-нибудь добавишь импорт из файла через горутину, мутацию хранилища верни через fyne.Do, иначе состояние будут править два потока. Разрушительные действия прощают не всё: подтверждение — да, отмену — нет; undo в этом проекте намеренно оставлен за кадром выпускного экзамена для тех, кто захочет пойти дальше.
Удаление с подтверждением
GOvar selected = -1
list.OnSelected = func(i widget.ListItemID) { selected = i }
func onDelete(list *widget.List, window fyne.Window) func() {
return func() {
idx := selected
if idx < 0 || idx >= store.Len() {
return
}
text := store.tasks[idx].Text
dialog.ShowConfirm("Удалить задачу?", text, func(ok bool) {
if !ok {
return
}
list.UnselectAll()
selected = -1
store.Remove(idx)
}, window)
}
}Кнопка удаления с иконкой из шестнадцатого урока фиксирует индекс в момент клика и проверяет границы, диалог подтверждает намерение, и только после «Да» мутация доходит до хранилища. Сначала сбрасывается выбор, затем Remove: OnChange внутри save синхронизирует список и счётчик сам.
Что будет без подписки на OnChange
Прочитайте код и выберите, что он выведет:
store := NewStore(prefs)
store.OnChange = nil // синхронизацию забыли подключить
store.Add("Купить хлеб")
list.Refresh() // Refresh вызвали вручную
// что покажет список и счётчик, привязанный через binding?Каркас запуска по канону курса#
Проект собирается в main в строгом порядке, и каждый шаг — из конкретного урока. Первый шаг: app.NewWithID с постоянным идентификатором — без него preferences из девятнадцатого урока будут жить до закрытия окна, и это самая частая ошибка финальных работ. Второй: окно через NewWindow и роль мастер-окна из двадцать третьего урока — в десктопном приложении с одним окном закрытие и есть выход. Третий: чтение состояния — тема и флаги читаются из preferences до построения интерфейса, чтобы первый кадр уже был правильным. Четвёртый: хранилище NewStore загружает задачи — JSON-строка парсится в срез, и данные готовы к тому моменту, когда интерфейс начнёт их спрашивать. Пятый: строится интерфейс — buildUI создаёт список, поле, кнопки и подписывает store.OnChange на syncState; порядок именно такой, потому что подписка должна существовать до первой мутации. Шестой: SetContent, стартовый Resize, CenterOnScreen — геометрия из двадцать третьего урока — и ShowAndRun из второго урока, после которого управление уходит тулкиту. Седьмой, неявный: всё, что случается дальше, — события, мутации, синхронизации — исполняется в цикле приложения, и твой код в main уже закончился. Если будущее приложение вдруг начнёт грузить большие файлы или ходить в сеть, фоновая работа появится внутри этого каркаса через fyne.Do из двадцатого урока, но каркас не изменится. Вот ради такой устойчивости канон и собирался двадцать четыре урока.
main целиком
GOfunc main() {
myApp := app.NewWithID("io.gomaster.tasks") // ID решает: данные переживут перезапуск
window := myApp.NewWindow("Задачи")
window.SetMaster()
prefs := myApp.Preferences()
store := NewStore(prefs)
if dark := prefs.BoolWithFallback("theme.dark", true); !dark {
myApp.Settings().SetTheme(theme.LightTheme())
}
counter := binding.NewString()
list, frame := buildUI(window, store, counter) // поле, кнопки, счётчик
store.OnChange = func() { syncState(store, list, counter) }
syncState(store, list, counter) // первый кадр сразу с верными данными
toolbar := buildToolbar(myApp, prefs, list, window)
window.SetContent(container.NewBorder(toolbar, frame, nil, nil))
window.Resize(fyne.NewSize(480, 560))
window.CenterOnScreen()
window.ShowAndRun()
}Семь шагов каркаса в семи вызовах: идентификатор, окно, чтение настроек, загрузка данных, сборка интерфейса с подпиской, геометрия, запуск. Поменяй порядок чтения темы и сборки интерфейса — и увидишь мигание первого кадра; перенеси NewWithID в app.New — и потеряешь данные. Каждая строка каркаса проверяема.
Карта проекта: какой урок поставил какой блок#
| Блок проекта | Урок | Что именно взято |
|---|---|---|
| Каркас запуска и идентификатор | 02-pervoe-okno | app.NewWithID, NewWindow, SetContent, ShowAndRun |
| Компоновка экрана | 04-box | VBox, Border, HBox для панели добавления |
| Виджеты полей и кнопок | 03-vidzhety | Entry, Button, Check, Label в строках списка |
| Обработчики и Enter | 06-sobytiya | OnTapped, OnSubmitted, события галочки |
| Список задач | 10-spiski | widget.List: Length, Template, UpdateItem, виртуализация |
| Диалог подтверждения | 13-dialogi | dialog.ShowConfirm перед удалением |
| Тулбар и действия | 14-menju | ToolbarAction, контекстное меню, SetMainMenu |
| Светлая и тёмная тема | 15-temy | SetTheme, DarkTheme, LightTheme |
| Иконки действий | 16-resursy | theme.IconNameContentAdd, IconNameDelete |
| Счётчик и состояние | 17-binding | binding.NewString, NewLabelWithData |
| Проверка ввода | 18-validaciya | Validator на поле добавления |
| Сохранение задач и темы | 19-preferences | String, SetBool, BoolWithFallback |
| Потоки и фоновая работа | 20-gorutiny-gui | fyne.Do для мутаций из чужих потоков |
| Окно, геометрия, выход | 23-desktop | SetMaster, Resize, CenterOnScreen, перехват закрытия |
| Тесты проекта | 24-testirovanie | test.NewApp, Tap, Type, снимки разметки |
Собери каркас финального проекта
Восстанови пять пропусков каркаса main: идентификатор, мастер-окно, настройки, хранилище и запуск.
Путь задачи от клавиатуры до диска
Расставь по порядку, что происходит от нажатия Enter до сохранённой задачи.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Проекту нужен настоящий экран — в браузерной песочнице курса GUI не исполняется. Собери файлы store.go и main.go по каркасу второго урока, запусти go run . и прогонись по сценарию: добавить, отметить, удалить, переключить тему, закрыть, открыть — всё должно вернуться на место.
Соблазн финала — добавить всё сразу: даты, приоритеты, фильтры, undo. Каждое расширение тянет за собой валидацию, миграции хранилища и тесты, и проект из учебного становится бесконечным. Сначала добей канонический чек-лист до конца, потом расширяй по одному признаку за раз.
Чек-лист качества и горизонт#
Прежде чем сдавать проект, прогонись по списку, который собирает стандарты всего курса. Данные: перезапуск восстанавливает задачи и тему; идентификатор приложения постоянный; при пустом хранилище первый запуск выглядит прилично, без паник. Ввод: пустая строка не проходит валидатор, длинная строка не ломает раскладку, Enter работает как кнопка. Список: виртуализация не течёт — прокрути тысячу задач; удаление смещает индексы честно; галочка не зацикливает обновления. Окно: закрытие при наличии правок спрашивает подтверждение из двадцать третьего урока; геометрия запоминается; трей, если добавил, имеет пункт выхода. Качество: логика хранилища покрыта чистыми тестами, ключевые сценарии — UI-тестами из двадцать четвёртого, снимки разметки есть для пустого и заполненного состояний. И горизонт: этот же проект собирается под Android и iOS через fyne package из двадцать второго урока — интерактив на мобильном потребует пересмотра тулбара, но данные и логика поедут без изменений; файловый экспорт списка подставляется из двадцать первого урока; фоновая загрузка — из двадцатого. Приложение на сто строк выросло в каркас, который принимает новые функции, не разваливаясь, — это и есть главный навык курса: не выучить API, а уметь собрать из них целое. Полный маршрут всех двадцати пяти уроков — на странице курса Fyne.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Виртуальный экран
HTML-модель виджет-дерева fyneСписок задач:
Задач выполнено: 0
Экран собран: «Менеджер задач»
Коротко: что запомнить
- Архитектура проекта: данные в Store вне виджетов, экран — функция от данных, обновление идёт через один колбэк OnChange.
- Список задач на widget.List живёт тремя функциями: Length, Template и UpdateItem; после мутации нужен единственный list.Refresh.
- Хранилище сериализует задачи в JSON-строку под одним ключом preferences — и переезжает в файлы, когда задач станет много.
- Ввод защищён валидатором, Enter равен кнопке, галочка глушится на время программной установки против цикла обновлений.
- Счётчик идёт через binding.NewString и NewLabelWithData, тема — через SetTheme с флагом в preferences.
- Каркас main: NewWithID, мастер-окно, чтение состояния до первого кадра, сборка интерфейса, геометрия, ShowAndRun.
- Удаление всегда через подтверждение, сброс выбора до мутации; тесты хранилища и UI-сценариев — часть сдачи проекта.
Домашнее задание
ВЫПУСКНОЙ ЭКЗАМЕН. Собери менеджер задач целиком и сдай его по чек-листу. Обязательная часть: файл store.go с типами Task и Store, методами Add, Toggle, Remove, Len, DoneCount и сохранением JSON в preferences; файл main.go с каркасом из урока — app.NewWithID, мастер-окно, чтение темы до первого кадра, buildUI, syncState как единственная точка обновления. Интерфейс: список на widget.List с виртуализацией и зачёркиванием выполненных, поле добавления с валидатором на пустую строку, Enter равен кнопке, удаление только через dialog.ShowConfirm, счётчик «Всего/готово» на привязке, переключатель тёмной и светлой темы с сохранением флага. Поведение: перезапуск восстанавливает задачи, тему и геометрию окна; закрытие окна с несохранёнными правками спрашивает подтверждение; пустой список первого запуска выглядит прилично. Тесты: табличные тесты Store без интерфейса, минимум два UI-сценария через test.NewApp — добавление и удаление, — и markup-снимки пустого и заполненного состояний. Дополнительная часть по желанию: контекстное меню по правому клику, экспорт списка в файл через диалог из урока про файлы, трей с пунктом выхода. Критерий зачёта: приложение проходит все сценарии на твоей машине, go test зелёный, а в коде нет ни одного обращения к интерфейсу из горутины мимо fyne.Do. Удачи — ты дошёл до конца курса.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему список не обновляется после добавления задачи?
widget.List не следит за твоим срезом: после мутации хранилища нужен list.Refresh(). В проекте его вызывает единственная функция syncState через колбэк OnChange — поэтому забывать не приходится. Счётчик на привязке обновляет Set источника, а не Refresh.
Куда переехать, когда задач станет слишком много для preferences?
JSON-строка под одним ключом удобна до сотен задач. Дальше данные выносят в файл хранилища приложения из урока про файлы или во встроенную базу; интерфейс не меняется, потому что экран читает Store, а не хранилище настроек напрямую.
Как добавить приоритеты или даты к задачам?
Расширь структуру Task и убедись, что JSON мигрирует: старые записи без новых полей разворачиваются нулевыми значениями — это работает само. Затем обнови UpdateItem и валидацию ввода и добавь тесты на новые правила. Один признак за раз, с тестами.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Cobra: CLI-приложения на Go
Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.
- Создавать CLI-приложения с деревом команд на Cobra
- Работать с аргументами, флагами и их привязкой к конфигу
- Управлять конфигурацией через Viper: файлы, env, приоритеты
- Писать хуки PreRun/PostRun и корректно обрабатывать ошибки