Пробуем Flutter в Telegram Mini Apps: Насколько хорошее решение для разработки?

от автора

Популярость телеграм

Каким мессенджером сейчас вы пользуетесь чаще всего? Большинство ответит телеграм, а те кто скажут иначе, все равно скорее всего используют его где-то, пусть и реже. Ни для кого не секрет, что Telegram — один из самых популярных мессенджеров в мире. Как и положено быстро развивающемуся продукту, со временем в него начали добавлять уникальные фичи, одной из таких и стал собственный встроенный браузер(актуально для мобильных устройств). Но стоит отметить, что перед этим в Telegram появились Mini Apps.

Исходя из названия, можно догадаться, что это — приложения, но не совсем в привычном виде. WebApps, как они сначала назывались, — это частный случай приложений, они же веб-приложения. Как и полагается любому сайту создаются они с использованием стандартного стека веб-технологий: HTML, CSS и JavaScript, и выглядят соответственно.

В один из дней я подумал, ведь я являюсь кроссплатформенным разработчиком на Flutter, насколько хорошо он покажет себя в веб? А конкретно, как удобно будет создавать и пользоваться таким продуктом? Ведь для написания кода на этом фреймворке не нужно изобретать велосипед — пишешь тот же код и радуешься тому, что он работает. Но это я знал лишь в теории, а как это будет работать на практике, мне предстояло узнать…

Откуда появилась идея для создания веб-приложения?

Все события происходили летом 2024 года. Следовательно, Mini Apps — это относительно новая функция Telegram, и на рынке еще не было большого числа реализованных идей. Это значило: «кто успел, тот и съел». Времени на то, чтобы избирательно собрать команду, не было от слова совсем, поэтому я позвал хороших знакомых, и понеслось. Сразу же, не тратя ни минуты времени, на первом же созвоне мы экспромтом решали вопрос: что бы мы хотели создать? Идей было очень много, но нужно было учитывать множество факторов. Одним из них был органический рост. Для нас было критично, чтобы наш продукт развивался без нашего вмешательства, за исключением части разработки. Исходя из этого, нам нужно было придумать что-то виральное.

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

Для реализации этой идеи нужно было сразу определить функции и, исходя из них, выбрать те, которых хватит для MVP. Всего я выделил три функции, которые бы хотелось видеть в приложении:

  • Проходить заранее готовые тесты;

  • Делиться ссылками на пройденные тесты;

  • Создавать пользовательские тесты;

  • Монетизировать свои тесты;

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

Реализация

Для взаимодействия с нашим веб-приложением нам потребуется бот. Думаю, нет смысла объяснять, как его создать, поэтому сразу перейдем к Telegam Mini App. Для того чтобы его создать, нужно разобраться, что это такое. По большому счету, это WebView, которая открывается внутри оболочки Telegram по заданной ссылке. То есть для подключения этого всего нам нужен работающий сайт, поэтому пока повременим с ботом и оставим его напоследок.

Быстрый дизайн и макетная верстка в фигме

Как и любому приложению, нам нужен был дизайн, хоть какой-то. Поэтому мы быстренько набросали макет того, как это все будет выглядеть в Фигме, и приступили к верстке. Как уже было упомянуто, для этого фреймворка кодовая база не меняется, и если вы уже писали на Flutter, то проблем не возникнет. Так и получилось: на вебе все виджеты и другие детали выглядели точно так же, как и на мобилках. Следовательно, нельзя было забывать про адаптивность. Хоть наше приложение и не особо нуждалось в сильной адаптивности из-за и без того адаптивных виджетов, все равно пришлось немного пошаманить с MediaQuery.

Разработки логики а также работа с бекендом

Поскольку нам не хотелось особо заморачиваться с бэкендом, мы выбрали PocketBase в качестве SaaS. Он полностью удовлетворил наши требования — просто хранить тесты. Архитектура тестов была достаточно проста:

  1. Идентификатор теста

  2. Начальная страница (содержит картинку теста, описание, просмотры)

  3. Страницы тестов (содержат вопросы и картинку к тесту)

  4. Страницы результатов (содержат возможные страницы результатов, которые в свою очередь показывают наиболее часто выбранное число, картинку и описание)

Чтобы обыграть эту логику, пришлось немного подумать. Первостепенная цель заключалась в том, чтобы сохранить максимально приятный пользовательский опыт, поэтому прохождение тестов должно быть интуитивно понятным. Для этого мы использовали PreloadPageController, который при открытии теста сразу загружал все страницы, которые впоследствии будут использоваться. У нас как таковой сущности квиза не было, и все данные хранились в трех полях состояния:

final class QuizLoaded extends QuizState {   final StartEntity startPage;   final List<FinalEntity> finalPage;   final List<PageEntity> pages;   final Map<int, int> answers;    @override   List<Object> get props => [startPage, finalPage, pages, answers];    const QuizLoaded({     required this.startPage,     required this.finalPage,     required this.pages,     required this.answers,   });    QuizLoaded copyWith({     StartEntity? startPage,     List<FinalEntity>? finalPage,     List<PageEntity>? pages,     Map<int, int>? answers,   }) =>       QuizLoaded(         startPage: startPage ?? this.startPage,         finalPage: finalPage ?? this.finalPage,         pages: pages ?? this.pages,         answers: answers ?? this.answers,       ); }

Да, возможно, это не самый оптимальный вариант, но на тот момент он показался нам наиболее подходящим. Весь же код выглядел для тестов примерно так:

else if (state is QuizLoaded) {               final startEntity = state.props[0] as StartEntity;               final finalEntites =                   state.props[1] as List<FinalEntity>;               final pageEntities =                   state.props[2] as List<PageEntity>;               return PreloadPageView(                 controller: pageController,                 onPageChanged: (index) {                   if (index - 1 == pageEntities.length) {                     context.read<QuizBloc>().add(QuizCompletedEvent());                   }                 },                 children: _buildPages(                   context: context,                   startEntity: startEntity,                   pageEntities: pageEntities,                   pageController: pageController,                   finalEntities: finalEntites,                   answers: state.answers,                 ),               );             } else if (state is QuizCompleted) {               final finalPage = state.finalpage;               return FinalPageQuiz(                 quizId: widget.id,                 finalId: finalPage.id,                 image: finalPage.image,                 name: finalPage.name,                 description: finalPage.description,                 mostFrequentDigit: finalPage.mostFrequentDigit,               );             }             return Container();           },         ),       );     List<Widget> _buildPages({     required BuildContext context,     required StartEntity startEntity,     required List<PageEntity> pageEntities,     required PreloadPageController pageController,     required List<FinalEntity> finalEntities,     required Map<int, int> answers,   }) {     final pages = <Widget>[];      pages.add(       FirstPageQuiz(         id: startEntity.id,         description: startEntity.description,         image: startEntity.image,         name: startEntity.name,         pageController: pageController,       ),     );      for (var i = 0; i < pageEntities.length; i++) {       pages.add(         QuestionPage(           questionId: pageEntities[i].id,           currentQuestion: i,           sumQuestions: pageEntities.length,           question: pageEntities[i].question,           pathToImage: pageEntities[i].image,           answers: pageEntities[i].answers as Map<int, dynamic>,           pageController: pageController,           id: widget.id,         ),       );     }      return pages;   }

Сейчас все разложу по полочкам в этом большом куске кода. Основная суть — это билд всех страниц теста и формировании последней страницы (i-1). Пусть и функция, которая возвращает виджет считается моветоном, но тут она вписалась идеально.

После того как пользователь заходит в тест, он загружает все данные, триггеря событие, которое эмитит QuizLoaded, содержащий все необходимые поля. Далее начинается самое интересное: PreloadPageView вызывает функцию _buildPages, которая возвращает все экраны, доступные пользователю до вычисления результата, то есть страницу начала и страницы теста. После загрузки пользователь видит весь тест.

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

else if (state is QuizCompleted) {               final finalPage = state.finalpage;               return FinalPageQuiz(                 quizId: widget.id,                 finalId: finalPage.id,                 image: finalPage.image,                 name: finalPage.name,                 description: finalPage.description,                 mostFrequentDigit: finalPage.mostFrequentDigit,               );             }

А как оно это делает сейчас разберемся.

  Future<void> _generateFinalScreen(     QuizCompletedEvent event,     Emitter<QuizState> emit,   ) async {     if (state is QuizLoaded) {       final loadedState = state as QuizLoaded;       final answers = loadedState.answers;       final finalsEntities = loadedState.finalPage;        final finalPageEntity = _determineFinalPage(         finalEntities: finalsEntities,         answers: answers,       );       emit(         QuizCompleted(           finalpage: finalPageEntity,         ),       );     }   }

Как тут можно увидеть половину данных берется из стейта, оно и не мудрено, это ответы и сущности финальных страниц, но что за загадочное _determineFinalPage?

FinalEntity _determineFinalPage({     required List<FinalEntity> finalEntities,     required Map<int, int> answers,   }) {     final valueCounts = <int, int>{};      for (final value in answers.values) {       if (valueCounts.containsKey(value)) {         valueCounts[value] = valueCounts[value]! + 1;       } else {         valueCounts[value] = 1;       }     }     var mostFrequentValue = valueCounts.keys.first;     var maxCount = valueCounts[mostFrequentValue]!;      valueCounts.forEach((key, count) {       if (count > maxCount) {         mostFrequentValue = key;         maxCount = count;       }     });      final finalPage = finalEntities.firstWhere(       (finalpage) {         if (finalpage.mostFrequentDigit == mostFrequentValue - 1) {           return true;         }          return false;       },       orElse: () => finalEntities.first,     );     return finalPage;   }

Соглашусь, функция, возможно, выглядит немного запутанной, но мы преследовали конкретную цель. Она принимает список финальных страниц и мапу ответов. И работает по следующей логике:

  1. Считает ответы: Сначала функция подсчитывает, сколько раз встречается каждое значение ответа в ответах (от 0 до 3).

  2. Самое частое значение: Затем функция определяет самое частое значение среди них.

  3. Поиск Финальной Страницы: И в конце ищет в финальных страницах первый элемент, у которого mostFrequentDigit равен mostFrequentValue - 1.

P.S. Значение mostFrequentDigit - 1 связано с фичой, а не багом, возникшей в ходе верстки. Все ответы были смещены на -1, то есть от 0 до 3. Поскольку логика уже была построена на таком варианте, было принято решение оставить её в этом виде.

Разработка Deep Links

С самого начала разработки тестов мы стремились к возможному органическому росту, поэтому необходимо было создать максимальное удобство для пользователей. Это включало возможность создания собственных тестов и навигацию к ним. Но как можно перенаправлять пользователей на нужный тест без идентификатора в ссылке? Ответ: Никак.

Первоначально мы планировали создать возможность адресации для собственных тестов. Однако, поскольку эту функцию мы временно отложили, было решено изменить цель диплинков на возможность делиться тестами с друзьями, которые уже были пройдены. Сама логика выглядела примерно так:

Блок-схема, как работала логика обработки навигации на конкретный тест

Блок-схема, как работала логика обработки навигации на конкретный тест

Для реализации взаимодействия с Telegram API мы использовали пакет telegram_web_app, который на момент разработки находился на версии 0.1.0. Сама же ссылка на тест выглядела следующим образом:

https://t.me/<бот>/base?startapp={***args***}

Где args — являлся бы тем идентификатором, который бы был нам нужен. Поскольку Telegram сразу открывал наше приложение, было принято решение редиректить любые роуты с возможностью проверки на стартовые параметры. Весь кусок кода выглядел так:

redirect: (BuildContext context, GoRouterState state) async {   final initData = TelegramWebApp.instance.initDataUnsafe;    if (initData?.startParam != null) {     final path = '/quiz/${initData!.startParam}';     return path;   }    if (state.matchedLocation.contains('/quiz')) {     return null;   }    return '/'; },

Дословно, когда человек открывает приложение, роутер проверяет, есть ли у пользователя стартовые параметры в startapp. Если параметры присутствуют, он перенаправляет пользователя на страницу с нужным тестом. Если стартовых параметров нет, роутер возвращает корневой путь со всеми тестами. И это работало! Человек переходил по ссылке и открывал нужный ему тест с подгрузкой данных из удаленной базы данных.

Проблемы с скроллом

Указание версии пакета было не случайным. Дело в том, что в той версии Telegram Mini Apps существовала распространённая ошибка: «A bug with collapsing when scrolling in Web App for Telegram Bot». Эта ошибка означала, что если пользователь пытался прокрутить тесты, приложение могло непроизвольно сворачиваться, что создавалo существенные пробелмы для функционирования наших тестов. В связи с этим нам пришлось редактировать нативный код HTML-документа и добавлять свой скрипт на JavaScript, который не позволял сворачивать окно при прокрутке вниз. Однако сейчас, начиная с версии API 7.7 появился очень удобный метод disableVerticalSwipes, который полностью устранял эту проблему.

Деплой сайта на хостинг и настройка бота

Итак, вернемся к первой части, когда мы только создали бота. После того как наше веб-приложение будет готово, его нужно разместить на сервере и подключить к какому-либо хостингу, чтобы оно было доступно в браузере по протоколу HTTPS. После всех этих манипуляций у вас появится ссылка на ваше приложение, которую вы копируете. Далее переходите к боту и заходите в настройки:

Нажимаете на Configure Mini App, после чего активируете Enable Mini App. Следующим шагом отправляете ссылку на сайт и идете проверять бота.

Если вы всё сделали по инструкции, то в описании бота вы увидите кнопку Open App, нажав на которую, запустится ваше приложение.

Итог

По итогу, даже на небольшом проекте Flutter для Telegram Mini Apps показал себя с лучшей стороны. Несмотря на то, что проект пришлось закрыть из-за недоработанной маркетинговой стратегии, мы достигли своей не менее важной цели — проверить, как Flutter справится с задачей для Telegram Mini Apps. Приложение работало плавно и быстро, а разработка оказалась проще, чем ожидалось. Если у вас есть вопросы или вы тоже пробовали делать мини-приложения — делитесь опытом в комментариях, буду рад пообщаться! А для тех, кто ищет больше такого материала и не против заглянуть за кулисы новых статей и моих личных инсайтов — подписывайтесь на телегу!


ссылка на оригинал статьи https://habr.com/ru/articles/854406/