Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~30 мин +50 XP

Итоговый проект: менеджер задач на fyne

Двадцать четыре урока собирались ради этого дня: запрос «fyne проект» больше не про примеры, а про приложение, которое открывается с иконки, помнит данные после перезапуска и не стыдно отдать пользователю. Менеджер задач на сто строк — классика, но здесь он соберёт канон всего курса: список на виртуализации, ввод с валидацией, хранение в preferences, привязки для состояния, тему на переключателе, диалоги подтверждения и каркас запуска, где всё происходит в правильном порядке. Русский запрос «gui приложение go» закрывается этим проектом целиком. Урок идёт не по виджетам, а по архитектуре: данные отдельно от экрана, мутация отдельно от отрисовки, и в конце — выпускной экзамен, где ты собираешь всё сам.

Содержание урока · 11 разделов

Финал: приложение, а не пример#

Каждый пример курса делал одну вещь: окно — про компоновку, список — про виртуализацию, настройки — про хранение. Приложение отличается тем, что решает все вопросы сразу и не разваливается, когда их становится десять. Менеджер задач идеален для финала: он выглядит примитивно, но внутри скрыт весь список тем, которые ты проходил — и только полная сборка показывает, что ни одна из них не была лишней. Что делает программа: показывает список задач, позволяет добавить новую через поле с проверкой, отметить выполненную галочкой, удалить с подтверждением, переключить светлую и тёмную тему и — главное — помнит всё это после перезапуска. Что не делает: синхронизацию с облаком, совместное редактирование и всё, что требует сервера; границы проекта — тоже навык. Запрос «fyne проект» обычно приводит к туториалам с готовым кодом, но этот урок ведёт иначе: каждый фрагмент опирается на урок, где ты его уже видел, поэтому после чтения ты не скопируешь проект, а сможешь воспроизвести его по памяти. Читай с редактором открытым: вводить код руками — часть метода. Начнём с самой важной строки архитектуры: данные живут отдельно от интерфейса.

Архитектура: данные живут отдельно#

Главная ошибка первого GUI-проекта — хранить состояние в виджетах: задачи в срезе, спрятанном внутри List, флаги в метках, истина размазана по экрану. Такой код работает, пока не нужен второй экран, тесты из двадцать четвёртого урока или сохранение. Правило проекта обратное: экран — функция от данных, и рисует он то, что вернёт хранилище. Слой данных — обычные структуры Go: тип Task с текстом и флагом выполнения и тип Store, который держит срез задач и умеет четыре вещи — добавить, переключить, удалить и сохранить. Никакого fyne в этом файле, кроме единственной зависимости от preferences для записи: Store тестируется чистыми тестами без интерфейса, а завтра его можно переездить в файлы из двадцать первого урока или в базу — экран не заметит. Слой интерфейса — функции, которые строят виджеты и подписываются на изменения: после каждой мутации хранилища вызывается один колбэк синхронизации, который обновляет список и счётчик. Одна точка правды, одна точка обновления — вот вся дисциплина. Почему это критично именно в GUI: событие пользователя приходит внезапно и в произвольный момент, и если каждая обработка сама чинит экран, рано или поздно два обработчика починят его вразнобой. Поэтому дальше все фрагменты кода делятся на два файла: store.go — модели и хранилище, main.go — интерфейс. Начнём с store.go.

Модель и хранилище

GO
package 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(), и он у нас будет ровно один — в функции синхронизации, о которой ниже. К списку примыкает панель добавления: поле с валидатором из восемнадцатого урока и кнопка из третьего.

Список, поле с проверкой и кнопка

GO
func 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; задачи — данные, они в том же хранилище, но под своим ключом, и при переезде в файлы уедут только задачи. Данные и настройки — разные жанры, даже если хранилище одно.

Единая синхронизация и переключатель темы

GO
func 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 в этом проекте намеренно оставлен за кадром выпускного экзамена для тех, кто захочет пойти дальше.

Удаление с подтверждением

GO
var 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 целиком

GO
func 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-oknoapp.NewWithID, NewWindow, SetContent, ShowAndRun
Компоновка экрана04-boxVBox, Border, HBox для панели добавления
Виджеты полей и кнопок03-vidzhetyEntry, Button, Check, Label в строках списка
Обработчики и Enter06-sobytiyaOnTapped, OnSubmitted, события галочки
Список задач10-spiskiwidget.List: Length, Template, UpdateItem, виртуализация
Диалог подтверждения13-dialogidialog.ShowConfirm перед удалением
Тулбар и действия14-menjuToolbarAction, контекстное меню, SetMainMenu
Светлая и тёмная тема15-temySetTheme, DarkTheme, LightTheme
Иконки действий16-resursytheme.IconNameContentAdd, IconNameDelete
Счётчик и состояние17-bindingbinding.NewString, NewLabelWithData
Проверка ввода18-validaciyaValidator на поле добавления
Сохранение задач и темы19-preferencesString, SetBool, BoolWithFallback
Потоки и фоновая работа20-gorutiny-guifyne.Do для мутаций из чужих потоков
Окно, геометрия, выход23-desktopSetMaster, Resize, CenterOnScreen, перехват закрытия
Тесты проекта24-testirovanietest.NewApp, Tap, Type, снимки разметки

Собери каркас финального проекта

Восстанови пять пропусков каркаса main: идентификатор, мастер-окно, настройки, хранилище и запуск.

func main() { myApp := app.("io.gomaster.tasks") window := myApp.NewWindow("Задачи") window.() // закрытие этого окна завершает приложение prefs := myApp.() store := NewStore(prefs) window.SetContent(buildUI(window, store)) window.Show() }

Путь задачи от клавиатуры до диска

Расставь по порядку, что происходит от нажатия Enter до сохранённой задачи.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Чек-лист качества и горизонт#

Прежде чем сдавать проект, прогонись по списку, который собирает стандарты всего курса. Данные: перезапуск восстанавливает задачи и тему; идентификатор приложения постоянный; при пустом хранилище первый запуск выглядит прилично, без паник. Ввод: пустая строка не проходит валидатор, длинная строка не ломает раскладку, Enter работает как кнопка. Список: виртуализация не течёт — прокрути тысячу задач; удаление смещает индексы честно; галочка не зацикливает обновления. Окно: закрытие при наличии правок спрашивает подтверждение из двадцать третьего урока; геометрия запоминается; трей, если добавил, имеет пункт выхода. Качество: логика хранилища покрыта чистыми тестами, ключевые сценарии — UI-тестами из двадцать четвёртого, снимки разметки есть для пустого и заполненного состояний. И горизонт: этот же проект собирается под Android и iOS через fyne package из двадцать второго урока — интерактив на мобильном потребует пересмотра тулбара, но данные и логика поедут без изменений; файловый экспорт списка подставляется из двадцать первого урока; фоновая загрузка — из двадцатого. Приложение на сто строк выросло в каркас, который принимает новые функции, не разваливаясь, — это и есть главный навык курса: не выучить API, а уметь собрать из них целое. Полный маршрут всех двадцати пяти уроков — на странице курса Fyne.

Термины урока

Термин

Store и OnChange

нажмите, чтобы перевернуть
Определение

Слой данных вне виджетов: срез задач, JSON в preferences и единый колбэк OnChange, который вызывает syncState после каждой мутации.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Виртуальный экран

HTML-модель виджет-дерева fyne
Менеджер задач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. Удачи — ты дошёл до конца курса.

Личные заметкиПусто
Хранятся только в вашем браузере (Fyne · Итоговый проект: менеджер задач на fyne)

Вопросы и ответы по уроку

Почему список не обновляется после добавления задачи?

widget.List не следит за твоим срезом: после мутации хранилища нужен list.Refresh(). В проекте его вызывает единственная функция syncState через колбэк OnChange — поэтому забывать не приходится. Счётчик на привязке обновляет Set источника, а не Refresh.

Куда переехать, когда задач станет слишком много для preferences?

JSON-строка под одним ключом удобна до сотен задач. Дальше данные выносят в файл хранилища приложения из урока про файлы или во встроенную базу; интерфейс не меняется, потому что экран читает Store, а не хранилище настроек напрямую.

Как добавить приоритеты или даты к задачам?

Расширь структуру Task и убедись, что JSON мигрирует: старые записи без новых полей разворачиваются нулевыми значениями — это работает само. Затем обнови UpdateItem и валидацию ввода и добавь тесты на новые правила. Один признак за раз, с тестами.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Cobra25 уроков~10 чпрактика25 уроков уже доступны

Cobra: CLI-приложения на Go

Интерактивный курс Cobra — фреймворка для CLI на Go: команды, флаги, конфиги Viper, автодополнение. 25 уроков с примерами кода, тестами и итоговым приложением.

  • Создавать CLI-приложения с деревом команд на Cobra
  • Работать с аргументами, флагами и их привязкой к конфигу
  • Управлять конфигурацией через Viper: файлы, env, приоритеты
  • Писать хуки PreRun/PostRun и корректно обрабатывать ошибки