Разработка ПО

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

25 июля 2026 г.
Как мы построили финтех-приложение реального времени на 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-фичи, которые многим командам даются с трудом.

Ещё от Дима