Как мы построили финтех-приложение реального времени на Flutter и Go

Мы построили ExtraETF — инвестиционное приложение реального времени для ETF и акций — на Flutter с бэкендом на Go для слоя рыночных данных. Оно вышло в 2022 году для Isarvest GmbH и до сих пор доступно в App Store и Google Play. Эта статья — тот инженерный разбор, который не помещается на странице портфолио: как на самом деле работает слой стриминга, как мы строим финансовые графики во Flutter и — раз уж честность здесь главное — где стек сопротивлялся нам и что мы изменили бы в 2026 году.
Сразу оговорка про цифры. Мы не публикуем бизнес-метрики клиентов — число пользователей, выручку, удержание — и уже писали о том, почему расплывчатые заявления о росте ничего не стоят. Показатели здесь — про систему: переиспользование кода, размеры буферов, частота обновлений, — а не про рынок клиента.
Коротко о главном
ExtraETF — это насыщенное графиками инвестиционное приложение, которое стримит живые цены множеству клиентов одновременно. Сборку определяли две задачи. Первая — веерная рассылка рыночных данных в реальном времени: доставить один поток цен каждому клиенту, подписанному на конкретный инструмент, с ограниченной частотой обновлений, не падая, когда соединения обрываются. Вторая — рендеринг настоящих финансовых графиков во Flutter — оси, сетка, несколько серий, перекрестие-скраб — достаточно эффективно, чтобы укладываться в бюджет кадра на телефоне.
Архитектура — три слоя: источник рыночных данных сверху, WebSocket-сервис на Go, который управляет соединениями и веерно рассылает обновления с обратным давлением, и клиенты на Flutter, которые потребляют поток и рисуют интерфейс. Go берёт на себя конкурентность; Flutter — единую кодовую базу для iOS и Android с полным контролем над рендерингом. Вот история в трёх предложениях; остальное — про то, как каждый слой оправдывает своё место.
Что на самом деле требует инвестиционное приложение реального времени
Уберите брендинг — и живое инвестиционное приложение окажется сложной системной задачей в финансовом костюме. Планку задают четыре требования:
- Живые цены, которые проталкиваются, а не опрашиваются. Котировки меняются в течение торгового дня. Опрашивать REST-эндпоинт каждые несколько секунд — и слишком медленно для пользователя, и слишком дорого в масштабе. Нужен push-канал.
- Много клиентов, один источник. Поток сверху читается один раз; цена инструмента должна дойти до каждого клиента, который сейчас за ним следит. Это задача веерной рассылки (fan-out), и именно на ней наивные реализации плавятся.
- Плотные интерактивные графики. Линия цены, заливка области, несколько серий и оверлеи сравнения, перекрестие-скраб — всё на 6-дюймовом экране, всё в бюджете кадра.
- Планка надёжности финтеха. Пользователь простит соцсети упавший кадр. Но не простит денежному приложению устаревшую цену, молча оборванное соединение или рывок при чтении графика. Переподключение, переподписка и корректность — не полировка, а сам продукт.
Всё, что ниже, вытекает из этих четырёх пунктов.
Почему Flutter и Go
Flutter для клиента. Нам нужна была одна команда, выпускающая одну и ту же функциональность на iOS и Android в сжатые сроки, и нам нужна была возможность владеть пикселями — настоящий финансовый график это кастомный рендеринг, а не стандартный виджет. Flutter даёт и то, и другое: единую кодовую базу на Dart и холст, на котором можно рисовать напрямую. Переиспользование кода между iOS и Android составило около 99% — кодовая база на Dart переиспользуется целиком, и лишь несколько сотен строк платформенного нативного «клея» приходятся на биллинг, OAuth и пуши. Развёрнутый разбор этого компромисса — в статье Flutter против нативной разработки в 2026.
Go для слоя WebSocket. Сервис стриминга — это в первую очередь задача конкурентности. Модель Go — пара лёгких горутин на соединение плюс единый цикл рассылки — подходит под эту форму почти идеально: заблокированная горутина стоит килобайты, а не поток ОС, а каналы делают логику рассылки читаемой, а не лабиринтом коллбэков. Цена одного соединения — одна-две припаркованные горутины и буфер, так что честный потолок — файловые дескрипторы, а не CPU.
Честные компромиссы. Обработка ошибок в Go многословна, а WebSocket-сервис — сплошные краевые случаи: полуоткрытые соединения, медленные потребители, частичные записи — так что if err != nil там много. Flutter в 2022-м означал борьбу с фреймворком на нескольких деталях рендеринга и принятие того, что у части нативных SDK (особенно платежей) поддержка плагинов была грубее, чем у нативного приложения. Ни то, ни другое не стало стоп-фактором. Но и то, и другое было реальным.
Архитектура
Система — это прямой конвейер, в середине которого один сервис делает всю сложную работу. У него три слоя, слева направо:
- Источник рыночных данных сверху — сырой поток цен, читается один раз.
- WebSocket-сервис на Go — слой веерной рассылки в середине и единственный компонент, который общается с источником сверху. Каждый клиент подключается сюда, а не к источнику напрямую.
- Клиенты на Flutter на iOS и Android — каждый держит одно WebSocket-соединение, превращает входящий поток в состояние интерфейса и рисует его в живых ячейках цен и слое графиков.
Цены текут слева направо — от источника в сервис, из сервиса к клиентам, — а подписки идут в обратную сторону: каждый клиент сообщает сервису, какие символы сейчас на экране, и обратно приходят обновления только по этим символам.
Три поведения делают систему устойчивой, и живут они почти целиком в сервисе посередине:
- Маршрутизация подписок. Клиенты не получают весь поток. Каждый клиент подписывается на символы на экране, сервис хранит набор подписчиков по каждому символу, и обновление доставляется только тем клиентам, кто запросил этот символ. При подписке сервис сразу повторяет последнюю известную котировку, так что свежий экран никогда не пуст.
- Переподключение и переподписка. Мобильные соединения обрываются постоянно — туннели, уход в фон, переключение сетей. Клиент считает оборванный сокет нормой, переподключается после короткой паузы и переигрывает свои текущие подписки, чтобы сервер заново построил состояние маршрутизации для этого клиента.
- Не перестраивать мир на каждый тик. Ячейка цены обновляется перестройкой лишь одного виджета; график вообще не завязан на сырой поток тиков. Подробнее — ниже про оба случая.
Сложная задача №1 — реальное время в масштабе
В основе — классический хаб: одна горутина владеет рассылкой. У каждого соединения своя горутина чтения и своя горутина записи — писатель вычерпывает буферизированный канал и пишет в сокет, — а хаб хранит по каждому инструменту набор подписанных на него клиентов. Наивная альтернатива — синхронно писать каждому подписчику прямо внутри рассылки — заклинивает в тот момент, когда сокет одного клиента становится медленным, потому что все остальные ждут за ним. Это первое, что ломается.
Решение — стандартное, но которое легко сделать неправильно: дать каждому клиенту буферизированный канал отправки и сделать рассылку неблокирующей отправкой. Если буфер клиента полон — он не успевает, и для живых цен правильный ход — не блокировать рассылку.
// Рассылаем обновление всем, кто подписан на символ.
// Медленный клиент не блокирует остальных: если его буфер полон,
// мы отключаем всё соединение, а не тормозим рассылку.
func (h *Hub) broadcast(symbol string, update Update) {
for _, c := range h.subscribers[symbol] {
select {
case c.send <- update: // буферизированный канал, неблокирующе
default:
// Буфер полон → клиент не успевает. Отключаем его;
// он переподключится и переподпишется с чистым буфером.
h.drop(c)
}
}
}
Два решения превратили это из «работает в демо» в «работает на открытии рынка»:
- Обратное давление — состояние соединения, а не отдельного сообщения. Буфер отправки намеренно глубокий — застопорившийся клиент может отстать на десятки тысяч сообщений, прежде чем что-то поддастся, — так что короткая заминка поглощается, а не карается. Но клиент, который постоянно переполняет буфер, отключается, а не тянется на устаревших данных. Он переподключается чисто. Буфер меняет память на терпимость; это регулятор, и мы его выкрутили.
- Схлопывание происходит выше по потоку, до слоя сокетов. Это честная часть, которую большинство статей про «реальное время» пропускают. Обновления схлопываются до рассылки — примерно одно обновление на инструмент в секунду, побеждает последнее значение, — так что активный символ никого не затапливает. Значит, это не сабсотнемиллисекундный тик-за-тиком стриминг; это ровная ~1-секундная частота. Ровно то, что нужно человеку, следящему за ценой, и то, что бережёт CPU и трафик клиента.
Отдача от модели с горутинами видна именно здесь: цена простаивающего подписчика — припаркованная горутина и буфер, так что один узел держит куда больше простаивающих соединений, чем сервер с потоком на соединение. Настоящее ограничение всегда было в лимитах сокетов и файловых дескрипторов, а не в рантайме Go — под это вы и закладываете потолок дескрипторов. В продакшене один узел несёт тысячи одновременных соединений, оставляя запас на порядок.
Сложная задача №2 — рендеринг финансовых графиков во Flutter
ExtraETF насыщен графиками, и рендеринг настоящего финансового графика во Flutter — отдельная дисциплина. Вот как мы их строим.
Для всего сложнее лёгкого спарклайна мы строим график как кастомный RenderObject, а не CustomPainter. RenderBox с собственным Element позволяет обращаться с подписями осей, легендой и маркерами серий как с настоящими дочерними render-объектами со своей раскладкой — гораздо чище, чем вручную раскладывать всё на одном плоском холсте, и даёт точный контроль над тем, что происходит на этапе layout, а что на этапе paint. Именно это разделение — вся история производительности.
Приёмы, которые держат его дешёвым:
- Геометрию — один раз, на layout. Объекты
PathиPaintдля каждой серии строятся вlayout()и кэшируются;paint()лишь рисует кэшированные пути. Дорогая работа — обход серии, проекция точек — происходит при изменении данных или размеров, а не на каждом кадре. - Мемоизировать масштаб. Отображение из пространства данных в пиксели (и прямоугольник графика) кэшируется и пересчитывается только при изменении входных данных, за сеттерами с проверкой равенства.
- Гейтить перерисовку у источника. Вместо эвристики
shouldRepaintсеттеры render-объекта сначала сравнивают —listEqualsпо списку серий — и вызываютmarkNeedsLayout/markNeedsPaintтолько когда реально изменилось что-то видимое.
set series(List<Series> value) {
if (listEquals(value, _series)) return; // ничего видимого не изменилось
_series = value;
markNeedsLayout(); // пути, масштаб и подписи пересобираются в layout(), затем одна перерисовка
}
- Тяжёлую подготовку данных — вне UI-потока. Фильтрация длинной серии-бенчмарка до выбранного диапазона выполняется в изоляте через
compute(), так что главный поток не стопорится, готовя то, что график нарисует.
Единственный жест, который важен на финансовом графике, — перекрестие-скраб. Мы вешаем его на HorizontalDragGestureRecognizer — выбранный намеренно, чтобы график выигрывал арену жестов у свайпа родительского PageView/TabBar, оставляя вертикальную прокрутку работать. Скраб двигает маркер и перестраивает лишь небольшой оверлей легенды/индикатора, с тактильным откликом; он никогда не зовёт setState на дереве.
Где Flutter сопротивлялся: путь скраба — самая интересная цена. Движение перекрестия инвалидирует и paint, и layout, так что активный скраб заново прогоняет layout() и перестраивает те кэшированные пути — кэш, который щадит обычные кадры, не щадит скраб. Это то, что находишь только с открытым бюджетом кадра перед глазами, и туда идёт следующий раунд оптимизации.
Одна намеренная «нефича»: нет ни пинч-зума, ни панорамирования окна. Смена временного диапазона перезапрашивает данные и порождает новую серию; это не преобразование внутри графика. Так график остаётся без состояния относительно вьюпорта, а слой данных — единственным источником правды. Это реальный компромисс, сделанный осознанно.
Ничто из этого — не про погоню за бенчмарком. Бюджет кадра — 16,6 мс, а полный финансовый график — оси, сетка, несколько серий, легенда, маркеры — это много для него. Именно поэтому работа с геометрией живёт в layout(), а paint() остаётся дешёвым.
Отдельно: живые числа цен на экране обновляются на каждый тик — перестройкой лишь одного виджета, value-listenable вокруг ячейки цены, так что движущаяся цена никогда не перестраивает график или страницу.
Напоследок одно финтех-специфичное правило, потому что рано или поздно оно кусает всех: никогда не считайте деньги в плавающей точке. Цены, проценты и суммы портфеля — целочисленные минорные единицы или десятичный тип, а не double. Мы написали полное объяснение, почему 0.1 + 0.2 ≠ 0.3 важно для денег — в финансовом приложении это не абстракция.
Слой платформенной интеграции
Сервис стриминга и графики — это интересная инженерия. А слой платформы — то, куда на самом деле уходит расписание, потому что каждый пункт здесь — нативный SDK со своими краевыми случаями, которые кросс-платформенный фреймворк не может абстрагировать:
- Вход через Apple и OAuth от Google. Счастливый путь быстр; краевые случаи — нет. Apple отдаёт имя и email пользователя только при самой первой авторизации — пропустите этот момент, и его больше нет — так что вы захватываете и сохраняете их тогда, или никогда. Связывание двух провайдеров с одним человеком — отдельная небольшая задача проектирования.
- Пуш-уведомления для ценовых оповещений. Доставить пуш «сработало ваше оповещение» — значит управлять жизненным циклом токена, показывать запрос разрешения в момент, когда пользователь действительно согласится, и проектировать полезную нагрузку так, чтобы она оставалась осмысленной, когда приложение в фоне или убито.
- Встроенные покупки и подписочные права. Именно это отнимает больше всего времени в любой финтех-сборке. Уровни, пробные периоды, восстановление покупок между устройствами пользователя и сверка между чеком стора и вашим собственным состоянием прав. Правила Apple и Google различаются, и ни один не прощает ошибок. Flutter не делает это сложнее, чем нативно — но и заметно легче тоже не делает.
- Диплинки на конкретные бумаги. Ссылка должна открывать приложение сразу на нужном инструменте, в том числе когда на момент нажатия приложение не было установлено. Отложенный диплинкинг — по-настоящему хитрый угол, который стал сложнее после закрытия Firebase Dynamic Links; мы разобрали, как делаем это теперь через App Clips и Play Install Referrer.
Приложение состоит из почти 70 экранов — онбординг, живые котировки, новости, списки отслеживания, графики с несколькими таймфреймами и управление подписками — и всё это из одной кодовой базы Flutter.
Что мы сделали бы иначе в 2026
Сборка 2022 года, пересмотренная в 2026-м, — полезная проверка на честность. Что хорошо состарилось: форма. Go для слоя рассылки и Flutter для клиента — ровно то, что мы выбрали бы и сегодня; у модели с горутинами и единого клиента с кастомным рендерингом аргументов только прибавилось.
Что мы изменили бы:
- Перевести графики полностью на нативный рендеринг. Строить график как кастомный
RenderObject— а не опираться на более тяжёлые готовые решения — это то, к чему мы пришли, и то направление, в котором развиваем наши графики. Impeller усиливает эту ставку: кастомно отрисованные графики теперь куда предсказуемее, чем на старом пути Skia, с меньшим заиканием от компиляции шейдеров при первой отрисовке. Приложение с обилием графиков, начатое сегодня, опирается на это с первого дня. - Сначала взять проверенный realtime-слой, а не писать свой. Наш сервис на Go несложен, но управление соединениями, переподключение и рассылка — решённые задачи. Для новой сборки мы всерьёз взвесили бы управляемый или обкатанный realtime-слой и потратили сэкономленное время на доменную логику — опускаясь до кастомного сервиса на Go только если этого потребовала бы экономика или требования по задержке.
- Типизировать контракт клиент-сервер сквозным образом. Сообщения потока сериализовались вручную. Сегодня мы описали бы схему один раз и сгенерировали бы типы и для Go, и для Dart, чтобы переименование поля не могло молча рассинхронизировать два конца.
Ничто из этого — не упрёк исходной сборке. Она вышла, она до сих пор живёт, и ключевые решения выдержали проверку. Это просто то, что покупают четыре года прогресса экосистемы.
FAQ
Да. Flutter хорошо справляется с потоком рыночных данных, потому что работа с сетью и декодирование идут вне UI-потока, и обновиться нужно только виджетам, привязанным к изменившемуся значению. Ограничение почти никогда не в самом Flutter, а в том, как вы доставляете обновления. Схлопывайте тики выше по потоку до ограниченной частоты, чтобы не затопить клиента, и обновляйте минимально возможное поддерево виджетов на каждое изменение, чтобы интерфейс укладывался в бюджет кадра.
Строите что-то реального времени или финтех?
Это ровно та работа, которой мы занимаемся. Если вы стримите живые данные мобильным клиентам, рендерите что-то тяжелее списка или подключаете подписки и OAuth без острых углов — мы это выпускали. Смотрите страницу проекта ExtraETF со скриншотами и деталями, загляните в наши open-source пакеты для Flutter или почитайте про услугу разработки приложений на Flutter.
Есть проект на уме? Напишите нам — расскажите, в чём сложность, а мы честно скажем, подходят ли под неё Flutter и Go.

Дима
Ведущий Flutter-разработчик
Дима работает с Flutter с 2021 года и специализируется на state management и архитектуре приложений. У него есть практический опыт с протоколами реального времени — WebRTC для аудио и видео и XMPP для обмена сообщениями, — поэтому ему близки сетевые stateful-фичи, которые многим командам даются с трудом.
Ещё от Дима