Steven
Steven5 мин. чтения

Как транслировать отчёт в реальном времени без мерцания

Когда встреча заканчивается, сводка GeekBye теперь заполняется вживую, а не заставляет вас смотреть на крутящийся индикатор. Чтобы потоковый интерфейс ощущался спокойным, а не дёрганым, пришлось решать мерцание дважды — один раз для структурных полей, один раз для markdown — и вторым решением стал рендерер, который у нас уже был.

Инженерия
Фронтенд
Стриминг
Релизы GeekBye
Как транслировать отчёт в реальном времени без мерцания

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

GeekBye v1.6.13–v1.6.15 превратил это в отчёт, который вливается вживую, поле за полем, по мере генерации. Интересна не сама потоковая передача — а всё, что нам пришлось сделать, чтобы стриминг не выглядел дёргано. Спокойный стриминг и топорный стриминг — это одна и та же функция с очень разным исполнением.

Мерцание, часть первая: не давай каждой порции трясти React

Сводка — это не один блок, а структурные поля (общая оценка, ключевые моменты, задачи и так далее), которые бэкенд выдаёт по одному через server-sent events. Наивный способ это отрендерить — обновлять состояние React на каждом событии по мере поступления.

Сделай так — и получишь мерцание. Каждое приходящее поле запускает собственный повторный рендер; компоненты, показывавшие «пусто», монтируются, потом размонтируются, потом монтируются снова как «заполнено»; вёрстка дёргается на каждом пакете. Отчёт собирает себя сам у вас на глазах наихудшим возможным образом — заметно, нервно.

Исправление — это двухчастная дисциплина. Во-первых, накапливай приходящие поля в ref — изменяемом держателе, который не запускает рендеры, — и публикуй в состояние React раз на порцию со свежей копией, чтобы дерево обновлялось намеренно, а не на каждое микрособытие. Во-вторых, рендери одно стабильное дерево компонентов всё время, при стриминге или после завершения, с плейсхолдерами на месте для полей, которые ещё не пришли:

// accumulate in a ref, publish once per chunk
partialRef.current = { ...partialRef.current, [field]: value }
setPartial({ ...partialRef.current })

// one tree, never swapped; missing fields show a placeholder in place
const report = isStreaming ? partial : saved
<Score>{report.overallScore ?? '…'}</Score>

Компонент, показывающий оценку, никогда не размонтируется — он просто показывает , пока число не приземлится, а затем число. Ничего не дёргается. А полностью собранный объект в конце всё равно кешируется в локальную базу данных, так что потоковый интерфейс и надёжное хранилище сосуществуют без борьбы.

Мерцание, часть вторая: рендерер, который у нас уже был

Второе мерцание тоньше и живёт в самом markdown. Отчёт рендерит богатый markdown — заголовки, жирный текст, списки, блоки кода. Но markdown, приходящий в середине потока, в любой момент недописан. Список с одним пунктом пока что. Блок кода, который открыли, но не закрыли. Маркер жирного без пары.

Обычный рендерер markdown повторно парсит всю строку на каждом токене, и это недописанное состояние каждый раз парсится во что-то иное — так что список мерцает, блок кода вспыхивает открытием и закрытием, вёрстка прыгает, пока токены дописываются. Это то же нервное собирание, что и раньше, слоем ниже.

Исправление было почти неловко доступным: мы уже использовали рендерер markdown с учётом стриминга для живого чата с ИИ. Рендерер, построенный терпеть неполные токены — рендерить недописанный markdown стабильно и устаканивать его лишь по мере дописывания токенов — вместо того чтобы каждый раз парсить с нуля. Отчёту нужно было просто использовать тот же самый. Мы решили ровно эту задачу для чата месяцами раньше; «исправлением» для отчёта было осознание, что инструмент у нас уже есть, и направление его на вторую поверхность. Повторное использование побило перестройку.

Бонус: форматированное копирование — это задача формата буфера обмена

Когда отчёт стал хорошо выглядеть, люди захотели его вставлять — в документ, в письмо — и сохранять форматирование и скриншоты. Инстинкт — сериализовать отчёт в строку markdown и надеяться, что цель её отрендерит. Обычно нет.

Настоящий ответ в том, что буфер обмена держит несколько представлений сразу. Так что мы записываем два: HTML-версию с сохранённым форматированием и скриншотами, вложенными как inline-картинки, и текстовый запасной вариант для целей, которые не принимают HTML. Вставь в форматированный редактор — получишь отформатированный отчёт с картинками; вставь в простое текстовое поле — получишь чистый текст. «Форматированное копирование» никогда не было задачей сериализации — это была задача выбери-правильный-формат-буфера-обмена.

Тот же релиз ещё дал отчёту метки Я / Они в стиле чата с двумя говорящими, выровненными по противоположным сторонам, чтобы транскрипция читалась как разговор, которым и была, и перенёс ваши собственные скриншоты на их сторону, чтобы совпадало. (Один релиз в этом окне, v1.6.14, был чистой пересборкой — никакой истории тут нет, и честно так и сказать.)

Три вещи, которым нас научил потоковый интерфейс

  1. Буферизуй потоковые поля, публикуй раз на порцию. Дать каждому server-sent event гнать собственное обновление состояния — это и есть мерцание. Аккумулятор в ref плюс единая публикация на порцию, в дерево, которое никогда не размонтируется, превращает нервное собирание в спокойное заполнение.
  2. Всё, что стримится, нуждается в рендерере с учётом стриминга. Рендерер, повторно парсящий всю строку на каждом токене, будет мерцать на недописанном markdown. Используй построенный под неполный ввод — а если у тебя уже есть один для другой поверхности, переиспользуй его, прежде чем строить второй.
  3. Форматированное копирование про форматы буфера обмена, а не про сериализацию. Запиши text/html с вложенными картинками и запасной text/plain. Буфер обмена спроектирован нести оба; пользуйся этим.

Это пятая глава истории про надёжность и отделку, которая становится GeekBye v2. За предыдущей главой смотри баг с наушниками-вкладышами (v1.6.12); за тем, где та же идея server-sent events позже стала целым запасным транспортом, транскрипция вживую, когда файрвол блокирует WebSockets (v2.0.8); а за всей дугой — анатомию доведения софта до совершенства.

Похожие статьи

Разрыв соединения не должен ронять всё приложение — а наше ронял
Steven
Steven6 мин. чтения

Разрыв соединения не должен ронять всё приложение — а наше ронял

Когда наш бэкенд отвалился посреди встречи, он не просто приостановил транскрипцию — он уронил всё приложение. Причиной было единственное необработанное событие, а исправление уместилось в десять строк. Это кластер релизов, благодаря которому GeekBye остаётся залогиненным и на связи через то, что раньше его убивало.

Инженерия
Надёжность
Electron
Анатомия доведения ПО до совершенства: как код-ревью поймало то, что не смогли тесты
Steven
Steven6 мин. чтения

Анатомия доведения ПО до совершенства: как код-ревью поймало то, что не смогли тесты

На протяжении всей серии GeekBye v2 повторяется одно и то же: исправление проходит каждый тест на машине разработчика, а потом код-ревью доказывает, что оно бы не сработало почти ни у кого. Вот рабочий процесс, стоящий за девятью релизами — ворота ревью, отловы на этапе исправления и дисциплина «протестируй до отгрузки», которая превращает «у меня работает» в «работает».

Инженерия
Процесс
Код-ревью
Ваше Mac-приложение получает доступ к микрофону — и забывает его при каждом запуске
Steven
Steven5 мин. чтения

Ваше Mac-приложение получает доступ к микрофону — и забывает его при каждом запуске

GeekBye запросил доступ к микрофону, вы его выдали, и всё заработало. Следующий запуск: доступа нет. А в Системных настройках → Микрофон приложение вообще не появилось. Виновником оказалась функция безопасности macOS, тихо запускающая приложение из пути, который исчезает — вот диагноз и исправление в одно нажатие.

macOS
Разрешения
Инженерия