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

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

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

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

Таймеры и реальное время: tea.Tick

Идиома tea tick в bubbletea выглядит как одна строка — tea.Tick с функцией, возвращающей сообщение, — но на ней стоят часы в шапке дашборда, таймауты операций и всякое реальное время TUI. Разберём самообновляющуюся цепочку тиков, сообщение, несущее момент времени, дедлайны с защитой от устаревших срабатываний и деградацию частоты, когда экран простаивает. В финале — честное сравнение с time.After и Ticker.

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

Часы в шапке дашборда показывают время старта программы и замирают. Модель ничего плохого не делала — она просто не знает, что время идёт: Bubble Tea перерисовывает экран только по сообщениям, а сообщений о времени ей никто не присылает. Механика команд из пятого урока умеет планировать будущее: команда — это func() tea.Msg, который фреймворк достанет в горутине через сколько угодно миллисекунд. Спиннер из шестого урока уже ездил на этом приёме, но там цепочку тиков прятал компонент. Здесь достанем её на поверхность: tea.Tick из пакета bubbletea — таймер tui в каноническом виде, и вокруг него собираются три задачи урока. Часы в шапке, которые тикают каждую секунду. Дедлайн операции, который честно объявляет ошибку, когда сервер молчит. И частота тиков, которая снижается, когда экран простаивает, — потому что каждый кадр это работа процессора и пакет по SSH. Тот же механизм вращает прогресс-бары из двенадцатого урока и любое автообновление — класс один, и разобрав его здесь, вы закрываете сразу весь список.

tea.Tick: команда по расписанию#

Сигнатура короткая: tea.Tick(d, fn) принимает длительность и функцию func(time.Time) tea.Msg, а возвращает обычную команду. Верните её из Init или Update — фреймворк запустит, подождёт d и вызовет fn с текущим моментом времени; что fn вернёт, то и приедет в Update сообщением. Ключевое слово — одноразовость: одна команда стреляет один раз. Цепочка держится на самозапланировании, которое вы уже видели у спиннера: ветка tickMsg в Update обязана вернуть новую команду тика, иначе через секунду цепочка оборвётся и часы замрут. Сообщение принято объявлять типом-обёрткой над временем — type tickMsg time.Time: так оно не спутается с сырым time.Time, а внутри модели момент достаётся приведением time.Time(msg). Время в сообщении — не украшение: именно оно позволяет View показать момент события, а логам — понять, когда тик опоздал. Опоздания, кстати, реальны: под нагрузкой терминал собирает кадры, и секунда между тиками иногда превращается в полторы.

Сообщение-время и фабрика тика

GO
type tickMsg time.Time

func tickCmd() tea.Cmd {
        return tea.Tick(time.Second, func(t time.Time) tea.Msg {
                return tickMsg(t)
        })
}

func (m dashModel) Init() tea.Cmd {
        return tea.Batch(tickCmd(), m.list.Tick)
}

tickMsg — свой тип над time.Time, чтобы путаницы с сырым временем не было. Фабрика tickCmd скрывает интервал: сменить частоту — правка одной строки. Init объединяет собственный тик с командой готового компонента через Batch из седьмого урока.

Часы в шапке#

Модель хранит последний момент — поле now time.Time, — ветка tickMsg его обновляет и возвращает следующий тик, View форматирует. Конструктор ставит now в time.Now(): без этого до первого тика шапка покажет нулевое время рандомной эпохи, а целую секунду — заметно долго. Формат по-русски привычен: m.now.Format("15:04:05") выдаёт часы, минуты и секунды; раскладка шапки — занятие урока про раскладку Lip Gloss, здесь достаточно строки. Заметьте разделение труда: модель знает момент, View решает, как его показывать, а команда не знает ничего о том, зачем её позвали. Из-за этого часы можно переставить в футер или выкинуть совсем — правка затрагивает View и не трогает таймер. Проверять ход часов в песочнице бессмысленно — время эмулирует только живой терминал, поэтому запускайте локально: time.Second уменьшите до пары сотен миллисекунд, и бегущие цифры покажут и частоту, и опоздания кадров наглядно. Одна деталь, которую стоит видеть своими глазами: при потере фокуса окна терминал может приостановить отрисовку, и после возврата цифры прыгнут — модель ни при чём, догонять кадры не нужно.

Ветка тика и строка шапки

GO
case tickMsg:
        m.now = time.Time(msg)
        m.beats++
        return m, tickCmd()

// во View:
header := fmt.Sprintf("Панель задач · %s",
        m.now.Format("15:04:05"))

Три строки ветки — обновить момент, посчитать тик, запланировать следующий. Забытый return tickCmd() — главная ошибка темы: компилятор молчит, программа жива, только часы стоят. Форматирование живёт во View: момент в модели, представление — отдельно.

Проверка: чем живёт цепочка

Часы тикали минуту и замерли на одном времени, программа продолжает работать. Что случилось?

Таймауты операций#

Вторая работа таймера — дедлайны. Запрос ушёл на сервер — запланируйте рядом команду-таймаут: tea.Tick на пять секунд возвращает timeoutMsg, и если ответ не успел, ветка переводит модель в состояние ошибки с честным текстом «сервер молчит дольше пяти секунд». Для HTTP правильнее контекст из девятнадцатого урока — клиент отменяет запрос физически; таймер в модели нужен для произвольных операций: долгая индексация, воркер без таймаута, ответ от внешней программы. Но у таймера есть подвох, которого у контекста нет: он стреляет всегда, даже когда уже не нужен. Пользователь нажал r — загрузка перезапущена — а старый таймер первой попытки всё ещё в полёте и через пять секунд влепит ошибку в успешно загруженные данные. Лечение — метка принадлежности: у модели счётчик попыток gen, каждая команда-таймер уносит номер своей попытки, и ветка сравнивает msg.gen с текущим. Чужой таймер молча сгорает: return m, nil. Три строки страховки против загадочного флота ошибок через полминуты после успешной загрузки.

Дедлайн с генерацией попытки

GO
func loadWithTimeout(url string, gen int) tea.Cmd {
        return tea.Batch(
                fetchItemsCmd(url),
                tea.Tick(5*time.Second, func(time.Time) tea.Msg {
                        return timeoutMsg{gen: gen}
                }),
        )
}

case timeoutMsg:
        if msg.gen != m.gen {
                return m, nil
        }
        m.state = stateError
        m.err = errors.New("сервер молчит дольше пяти секунд")
        return m, nil

Batch запускает запрос и дедлайн параллельно: кто первый — тот и владеет исходом. Ветка timeoutMsg верит только таймеру своей попытки; устаревший gen — сигнал, что дедлайн опоздал, и экран не трогаем. Ответ fetchItemsCmd сбрасывает состояние и гасит опасения симметрично.

Забытая генерация

Прочитайте код и выберите, что он выведет:

// Загрузка шла долго, пользователь нажал r:
// gen стал 2, вторая попытка успешно загрузила данные.
// Первый таймер (gen 1) стреляет только сейчас:

case timeoutMsg:
        // сравнения msg.gen с m.gen в ветке нет
        m.state = stateError
        m.err = errors.New("сервер молчит дольше пяти секунд")
        return m, nil

// Что покажет экран?

Деградация частоты#

Секундный тик стоит денег: кадр перерисовывается, процессор просыпается, по SSH уходит пакет. Когда панель в фокусе и на ней смотрят часы — это цена правильная. Когда экран простаивает или свёрнут — секунда превращается в расточительность, и частоту стоит деградировать: раз в тридцать секунд вместо ежесекундно. Реализация честнее всего живёт в фабрике: интервал вычисляется из состояния модели, ветка тика просто возвращает tickCmd(m.interval()). Панель в фокусе — секунда; ушёл фокус — тридцать секунд; идёт загрузка — четверть секунды, чтобы спиннер жил. Заметьте направление зависимости: Update не решает частоту сам, он спрашивает у модели — состояние в одном месте, и тестировать интервал можно отдельной функцией без терминала. Деградация — не только про экономию: медленный тик на простаивающем дашборде означает, что возврат из скринсейвера не принесёт очередь из тридцати пропущенных кадров. Пропущенные тики догонять не нужно — опоздавший таймер просто рисует текущий момент, догонять нечего.

Интервал из состояния

GO
func (m dashModel) interval() time.Duration {
        if m.loading {
                return 250 * time.Millisecond
        }
        if m.focused {
                return time.Second
        }
        return 30 * time.Second
}

// в ветке tickMsg:
return m, tickCmd(m.interval())

Три режима в одной функции: спиннер живёт на четверть секунды, активная панель — на секунде, фоновая — на полминуте. Фабрика принимает длительность параметром, поэтому смена режима — два if, а не новая ветка в Update.

Проверка: time.After на повтор

Команда вернула tickMsg, полученный через <-time.After(d). Сколько ещё сообщений принесёт этот же time.After?

time.After против tea.Tick#

Альтернатива без хелпера выглядит так: return func() tea.Msg { return tickMsg(<-time.After(d)) } — горутина фреймворка блокируется на канале таймера и через d доставляет сообщение. Поведение то же, и в проектах такой вариант встречается. Различия мягкие, но есть. tea.Tick передаёт момент времени в функцию — время уже внутри сообщения; с time.After момент приходится собирать отдельно вызовом time.Now() в замыкании, и между выстрелом таймера и снимком момента успевает пройти ещё пара микросекунд. tea.Tick — каноническая идиома, по которой проект на Bubble Tea узнаётся с первого взгляда: grep по tea.Tick находит все таймеры репозитория. time.After прячет Timer, который живёт до срабатывания: для секунды незаметно, для часовых дедлайнов — уже счёт. Мое правило — tea.Tick везде, кроме одного случая: тесты подменяют реальное время фиктивными сообщениями, и там таймер не нужен вовсе, о чём подробно расскажет следующий урок. Ticker из стандартного пакета не трогаем: повторяющийся источник с обязательным Stop чужд архитектуре, где частота задаётся возвратом команды.

Соберите фабрику тика

Восстановите идиому: хелпер Bubble Tea, тип функции и сообщение, несущее момент.

func tickCmd() tea.Cmd { return tea.(time.Second, func(t ) tea.Msg { return tickMsg(t) }) }

Соберите минутное обновление

Расставьте ветку тика с накопленным обновлением раз в минуту: сообщение, обновление момента, счётчик, условие, усиленный возврат, обычный возврат.

Строки

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

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

Что дальше#

Цепочка тиков — последний кирпич каркаса: клавиши, команды, композиция, сеть и теперь время. Следующая остановка — тестирование TUI: там фиктивный tickMsg с заданным моментом проверит часы без единой секунды ожидания, а дедлайны проверят подменой таймера моком. Повторить материал спиннера можно в шестом уроке — там та же цепочка спрятана в готовый компонент. Для сетевых запросов дедлайном остаётся контекст из урока про HTTP — таймер модели дополняет его, но не заменяет. А урок про сборку и дистрибуцию покажет, где деградация частоты окупается по-настоящему: медленные SSH-сессии, где каждый кадр — пакет через океан. Обзор курса целиком — на странице Bubble Tea. Проверять тики честно можно только локально: поставьте интервал в сто миллисекунд, запустите панель в настоящем терминале и посмотрите, как опоздания под нагрузкой съедают ровность хода.

Карточки: словарь времени

Термин

tea.Tick(d, fn)

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

Хелпер Bubble Tea: возвращает команду, которая ждёт d и доставляет fn(time.Time) как сообщение. Одноразовая — цепочку поддерживает возврат новой команды из Update.

1 / 5

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

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

Виртуальный терминал

модель Bubble Tea · Model → Update → View

Отредактируйте модель, Update или View — и нажмите «Собрать модель»: код разберётся и начнёт отвечать на клавиши, как настоящий tea.Program.

виртуальный терминал · bubbleteaalt-screen
00:30 осталось
██████████
▶ идёт отсчёт
тик — секунда, пробел — пауза, r — сброс, q — выход

Клавиатура — обработано в Update

Журнал программы

модель собрана: left int = 30 · paused bool = false · expired bool = false

программа работает — нажмите клавишу ниже

Коротко: что запомнить

  • tea.Tick(d, fn) — канонический таймер tui: команда ждёт d и доставляет сообщение с моментом времени.
  • Цепочка живёт самозапланированием: ветка tickMsg обязана вернуть tickCmd(), иначе часы замрут без ошибок.
  • Модель хранит момент, View форматирует; сообщение-обёртка type tickMsg time.Time не путается с сырым временем.
  • Дедлайн операции — tea.Tick рядом с запросом; метка gen защищает от таймеров прошлых попыток.
  • Частота деградирует по состоянию: интервал вычисляет метод модели, фабрика принимает его параметром.
  • time.After — равносильная самописная альтернатива; Ticker с Stop в архитектуре команд не нужен.

Домашнее задание

Соберите шапку с часами и деградацией: секундный тик в фокусе, тридцатисекундный в фоне, переключение по клавише f. Добавьте к загрузке из урока про HTTP таймаут на пять секунд с gen и проверьте сценарий: медленный ответ, повтор по r, успех — старый таймер обязан сгореть молча. Прогоните локально с интервалом сто миллисекунд и посмотрите на опоздания кадров.

Личные заметкиПусто
Хранятся только в вашем браузере (Bubble Tea · Таймеры и реальное время: tea.Tick)

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

Часы отстают под нагрузкой — тики опаздывают. Чинить?

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

Как остановить тики совсем, например на экране ошибки?

Перестаньте возвращать tickCmd() из ветки — цепочка умрёт сама, как у спиннера. Если часы должны вернуться, заведите флаг paused и в ветке возвращайте команду только при false. Жёсткой отмены у летящего таймера нет: он доставит ещё одно сообщение, и ветка должна это пережить без изменений состояния.

Можно ли заводить несколько независимых таймеров?

Да: разные фабрики с разными сообщениями — tickMsg для часов, refreshMsg для данных, timeoutMsg для дедлайнов. Различайте их типами, а не полями, и не планируйте два таймера одной фабрикой с разными интервалами — сообщения неразличимы, и частота перепутается. Своё имя типа на каждый источник — дешёвая страховка.

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

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

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

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

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

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

Zap: структурное логирование на Go

Интерактивный курс Zap — быстрого логгера для Go: уровни, поля, JSON, ротация, интеграции с Gin, GORM и gRPC. 25 уроков с примерами и итоговым проектом.

  • Писать структурные логи с полями вместо строк
  • Выбирать между Logger и SugaredLogger осознанно
  • Настраивать энкодеры, выводы и ротацию файлов
  • Разбираться в zapcore: Core, Encoder, WriteSyncer, LevelEnabler