Радость и грусть разработки на Qt под Android (и не только)

На хабре то и дело мелькают статьи об успешном опыте использования Qt для разработки под Android, а также под iOS и WP. Статьи наполнены достаточно большим энтузиазмом — ведь это так здорово: пишешь и тестируешь UI на десктопе, а потом просто собираешь с помощью нехитрых команд под Android, iOS, WP, заливаешь в сторы и готово. В этой же статье я хочу поделиться опытом «собирания грабель» преимущественно при разработке под Android.



Qt я использую достаточно давно, начиная с версии 4.1. Не сказать, что я «профессионально» его использую, но опыт был разный — и работы с виджетами и эволюции до версии 5.6.

Некоторые примеры проектов:

  • Пульт управления караоке-центром (Android/iOS)
  • Русско-Татарский словарь с кастомной клавиатурой (Android/iOS/WP), тогда ещё даже API для iOS кастомных клавиатур не было
  • Cоциальная сеть с разными примбамбахами под Android (гео, блютуз, чаты, фотки, профили и т.д.)
  • Приложение для быстрого заказа цветов (Android/iOS/WP)

Кроме того, на Qt написано Android приложение 2gis, на котором вы и можете проверить большинство описанного здесь.

Заранее прошу меня поправить, если что-то из описанного здесь имеет ненайденные мною пути решения (буду благодарен, если вы их укажете). Всё нижесказанное относится по большей части к Android.

Проблема №1


Первое и самое главное на сегодняшний момент: если вам нужно много работать с текстом, вводимым пользователем — не выбирайте Qt/Qml!

Крайне не люблю восклицательные знаки, но тут этот знак на своём месте: вам будет крайне сложно реализовать привычное для пользователей целевой платформы работы с полями ввода, а именно:

  1. Выделение текста
  2. Copy & Paste

Суть проблемы: баг работы с элементом редактирования текста висит аж с 2014 года, отмечен как Important и зарегистрирован пользователем с Silver подпиской, но до сих пор не исправлен. В багтрекере описан обходной путь, но если вы хотите использовать не Quick.Controls а чистый Quick с TextEdit и TextInput — извините.

Возможно кто-то скажет, что я слишком многого хочу и TextEdit/TextInput это базовые компоненты, но, извините меня, отсутствие Copy & Paste в базовых компонентах и отсутствие его реализации в Controls не будут вам доставлять проблем до первого замечания Заказчика.

TextEdit не содержит сигналов работы с указателем, типичных для MouseArea, поэтому попытка реализовать показ контекстого меню через долгое нажатие (PressAndHold в терминах Qml) успехом не увенчается. Кроме того, попытка в лоб обернуть поле ввода в MouseArea подходит лишь для ограниченного числа сценариев, т.к. вам придётся долго и упорно реализовывать выставление курсора между буквами и словами.

Поэтому, остаётся либо лезть в исходники и кастомизировать поле ввода, либо смириться.

Проблема №2


Второе и самое любимое заказчиками приложений, содержащий социализацию: Emoji

Суть проблемы: отсутствует нативная обработка любимых всеми смайликов, что в полях ввода, что в тексте — развивайте фантазию и реализуйте сами.

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

  1. Использовать шрифт с поддержкой символов Emoji. Используйте FontForge для компиляции Roboto с Emoji!
  2. RichText с заменой символов Emoji на цветные png'шки
  3. Глубокая кастомизация поля ввода (можете посмотреть в исходниках телеграмма для десктопа)

Итого: либо оно выглядит некрасиво (вариант 1), либо глючит (вариант 2), либо требует отличных знаний внутренностей Qt (вариант 3 — а если они у вас есть, вам не стоит труда решить большинство проблем).

P.S. Забавная забавность — не выставляйте никаких inputMethodHints у поля ввода, иначе встроенная Android клавиатура с Emoji (iWinn IME) у вас просто не покажется.

Проблема №3


Третье и самое раздражающее: Мерцание и BlackScreen'ы — ваши лучшие друзья.

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

Проблема №4


Четвёртое и самое трудноловимое: Шрифты

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

Выход здесь только один — брать исходники Qt и патчить под конкретный GPU.

Проблема №5


Пятое и самое спорное: продвинутые контролы Qml — Camera и иже с ними.

Суть проблемы: частые краши, нехватка функциональности и прочие несоответствия стандартного пользовательского опыта нативных приложений. Лечится это всё очень просто — не стесняйтесь добавлять нативные компоненты (Activity в случае Android) в своё приложение. Да, от этого его кроссплатформенность снизится, а количество кода увеличится, но оно того стоит.

Бонус №1


Первое и самое важное: реальная кроссплатформенность.

Суть: после того, как ребята из SQLite портировали своё детище под WP, а скромный автор сего произведения указал на это ребятам из Qt, в порт Qt для WP был добавлен LocalStorage. Это счастье для всех любителей Qml.

Вы реально можете создавать приложения из одних исходников, реально под кучу платформ, при этом кастомизировать их в нужных местах исходя из возможностей и необходимостей платформы. Декларативный UI и js затягивают настолько сильно и позволяют писать настолько лаконичный код, что возвращаться после него на многословную Java + xml, либо спорный Swift + Storyboard'ы нет никакого желания.

Любые анимации, поддержка кастомных шрифтов, svg — это ли не счастье для мобильного разработчика?

Бонус №2


Второе и самое любимое заказчиками: нестандартность.

Суть: фраза от заказчика, приводящая к желанию убивать — «Сделайте как на айфоне». Теперь это не проблема, везде будет выглядеть примерно одинаково. Да, это нарушение гайдлайнов, да, это нехорошо и никогда так не делайте, но заказчик хочет так и у вас три пути — убедить его, уступить ему или отказаться от него, выбирайте сами.

Кроме того, при достаточном желании можно подхачить исходники платформы, так, как нужно именно вам. Так, в нескольких приложениях мы просто насовсем отключали клавиатуру и писали свою на базе Qt Virtual Keyboard, тогда как встроенные приложения таких возможностей не имели.

Бонус №3


Третье и самое любимое мной: скорость разработки.

Суть: в любом состоянии вы можете спроектировать UI практически любой сложности (исключая особенности взаимодействия с ОС, такие как поля ввода, обработка устройства ввода и т.д.). Если вы сам себе заказчик — то перед вами все дороги открыты.

Резюме


Начиная новый проект стоит прежде всего правильно для себя оценить границы развития этого проекта. Если в нём мало работы с нативными возможностями платформы и много нестандарта — используйте Qt. Если же наоборот — подумайте, сможете ли вы его доработать так, как вам нужно.

Спасибо за внимание! Поделитесь своим опытом использования Qt в разработке мобильных приложений в комментариях.

Upd 31.03.2016 16:53


Спасибо пользователю zsilas за наводку на интересную библиотеку QtOffscreenViews от команды 2gis, которая решает проблемы с вводом текста и показом emoji в полях ввода.
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 57
  • 0
    Как разруливаете ситуацию с адоптацией UI под разные размеры экрана и разрешения?
    • +1
      Зависит от проекта. В основном, руководствуемся гайдом от Qt.
      Но были проекты, например, где разработка сначала идёт под десктоп, а потом уже переносится на iOS/Android/WP. Из-за наличия большой кодовой базы просто масштабировали основной экран приложения, сохраняя пропорции (это применимо для планшетов в ландшафтной ориентации).
      Ну и последнее, но не самое красивое решение — это при первоначальной загрузке высчитывать высоту базовой строки исходя из размеров экрана. А из этой базовой высоты уже строится высота всех остальных элементов. Тут нужно отметить, что если вы поддерживаете смарфтоны, то высоту лучше считать из портретной ориентации и не пересчитывать при повороте устройства, иначе в ландшафтной всё будет оч.мелко.
      • +1
        Так, в нескольких приложениях мы просто насовсем отключали клавиатуру и писали свою на базе Qt Virtual Keyboard

        Это очень сильно пугает. У меня установлена кастомная клавиатура (нестандартная), и что, она работать не будет, и мне будут показывать обычную QWERTY?
        • +2
          Тут всё зависит от задачи, если приложение представляет из себя татарско-русский словарь, пользователю было бы удобнее не искать клавиатуру в плеймаркете/аппсторе/винсторе (тем более, если её там нет), либо добавлять через настройки нужный язык (тем более, если его в настройках нет) а пользоваться приложением сразу с интегрированной клавиатурой.
          Мы на этот счёт проводили исследования на целевой группе, никто не смог найти и скачать кастомную клавиатуру из маркетов без подсказки. Жизнь приложения заканчивалась на фразе "блин, тут ещё чё-то ставить/настраивать надо" в случае с обучалкой, где рассказно, как поставить клавиатуру, либо "блин, а как тут вводить буквы-то" без обучалки.
          Естественнно, если приложение не узко-специализированно, то нужно использовать стандартную клавиатуру.
          • 0
            Фух, а то реально напугали. Если это для настолько специализированных программ, то тогда понятно.
        • 0
          Самая сладость начинается тогда, когда нужно использовать разные версии NDK с учётом различных API, которые там представлены. Пытался использовать функции из posix для привязки треда к физическому ядру процессора с учётом cpu affinity — испытал непередаваемое удовольствие.
          • 0
            А приходилось ли сталкиваться с ситуацией, когда в приложении под Android, написанном на Qt, есть и foreground сервисы, и активити?
            • +1
              Да, естественнно, а также Broadcast Receiver'ы. С Content Provider'ами (самописными) не приходилось.
            • +1
              не выбирайте Qt/Qml!
              Получается, вы используете QWidget и его потомков? Скажите, как он сейчас работает под android?
              Я когда-то давно, когда порт под андроид еще назывался Necessitas, пытался с ними работать (точнее, просто откомпиллил виндово-убунтовое приложение) и столкнулся с тем что этот путь был совершенно непригоден т.к. виджеты были не адаптированы под тач, например, QTableView и, кажется, скролл имели крайне мелкие размеры скроллбаров, а диалог открытия файлов не ресайзился под экран и был раза этак в 2 больше телефона размером, при этом штатный путь кастомизации через QStyle попросту не срабатывал. В итоге плюнул и постепенно переписал приложение на QML, благо это домашний проект и чего сверхсложного там не было и нет.
              Из сложностей — постоянно натыкаюсь на всяческие мелкие баги в контролах, то у ComboBox'а принципиально нельзя програмно выставить пустую строку (если присвоить comboBox1.currentIndex = индекс пустой строки текст попросту не меняется), то файловый диалог возвращает в винте пути в виде /C:/Users/myFile.txt которые едят не все компоненты qt работающие с путями (конкретно QFile), то еще что. В целом вроде и мелочи, но раздражает, хотя я бы все равно сказал что это классная библиотека.
              Кстати, еще интересный вопрос — есть ли какой-то способ использовать разные элементы для разных платформ из qml? Вариант с подгрузкой через js я знаю, но мне он кажется несколько костыльным
              • 0
                Я не использую активно виджеты, поэтому написал именно про QML. Возможно, с QWidget есть способы это решить и потом внедрить решение в QML сцену.
                По поводу второго вопроса не совсем понял, можно использовать Loader с указанием какие конкретно виджеты подгружать. В этом плане полезно смотреть на исходники Quick.Controls, как они там это решают.
                • 0
                  А что же вы тогда имели в виду под "не выбирайте Qt/Qml"? Я как-то считал что когда речь идет о GUI на Qt, то можно использовать либо QWidget'ы, либо qml, либо же их комбинацию.
                  По поводу второго вопроса, да, про loader'ы я забыл, надо будет попробовать. Хотя у меня такой финт может быть и не пройдет — мне надо было загрузить разные меню для андроида и десктопа и хотелось написать что-то вида:
                  ApplicationWindow {

                  menuBar: Qt.platform.os == "android"? AndroidMenu{}:DesktopMenu{}

                  }
                  • +1
                    Когда я говорил про Не выбирайте Qt/Qml я хотел сказать, что не выбирайте его в качестве технологии для построения UI вашего мобильного приложения на Android если вам нужно очень много работать с текстом привычными для Android-пользователя методами.
                    Есть классная игра — VoltAir, сделанная ребятами из Google на базе QtQuick, в ней нет много работы с текстом — вот для этих целей, с моей точки зрения, Qml идеален (с учётом кроссплатформенности). Ради справедливости стоит отметить, если я не ошибаюсь, что ребята из Google правда подхачили немного исходники.
                    • 0
                      Спасибо за разъяснение. Я почему-то подумал что речь о том что вы отказались от qml вообще
                • 0
                  В своем проекте использовал кастомные Qml компоненты, которые были реализованы в виде плагинов с общим интерфейсом. Правда проект был не нацелен на мобильные платформы, но думаю подобный подход использовать можно, назначив каждой платформе свой компонент.
                  • 0
                    То что можно понятно и я так и делаю, но самый простой способ который я смог найти был загрузка того или иного файла (не в виде плагина, а просто в виде отдельного qml которые отличаются для разных платформ) при помощи js через Qt.createComponent(), и я как раз хотел узнать есть ли более правильный способ.
                    • 0
                      Не знаю чем собираются Qt проекты под андроид но в CMake можно указать какую директорию в проект добавить в зависимости от ОС. По директориям распихать компоненты. CMake я правда взял исключительно ради возможности пользоваться CLion. И не уверен стоит ли такой подход затраченого времени в вашем случае.
                      • 0
                        qmake, и там тоже есть такая возможность. Другое дело, что придется подключать эти директории непосредственно в QML, а вот там препроцессора на импорты нет.
                        • 0
                          в QML нужно импортировать компонент, а не директорию. Если в соответствующих разным ОС директориях лежат компоненты с одинаковым названием, то в QML ничего не меняется. По крайней мере с плагинами это работает, не могу придумать причину почему не будет работать напрямую.
                          • 0
                            Даже если мы хотим импортировать компонент, а не папку целиком, нужно в импорте прописать путь до него, который будет отличаться.
                            • 0
                              Если правильно понял разговор, то в CMake добавляется что-то типа:
                              if(UNIX)
                                  install(FILES qml/unix/Foo.qml
                                          DESTINATION qml)
                              elseif(WIN32)
                                  install(FILES qml/win/Foo.qml
                                          DESTINATION qml)
                              endif()
                              • 0
                                Не лучший вариант на самом деле, если нужно поправить платформоспецифичный компонент, обратно его после тестирования придется руками копировать. Это ладно, один, а если 3-5...
                                • 0
                                  Все изменения должны фиксироваться в системе контроля версии, а для тестеров
                                  $ make
                                  $ make package

                                  на выходе rpm/deb/windows installer
                                  • 0
                                    Погодите, вот скопировался платформоспецифичный компонент в папку qml, я его поправил, как он обратно в qml/unix попадет?
                                    • 0
                                      я думаю править нужно исходный компонент, а не тот что скопировался
                                      • 0
                                        Я не могу править отдельно, мне нужно чтобы он подгружался в программу. Да, можно придумать костыли с явным указанием папки для разработки, но тогда смысл в правилах выше теряется.
                              • +1
                                Я имел в виду что-то типа такого: в каждой папке реализован компонент.
                                В основной программе регистрируем кастомный тип:
                                const QUrl MyClass("qrc:/qml/MyClass.qml");
                                qmlRegisterType<MyClass>("com.mycompany.mycomponents", 1, 0, "MyClass");

                                в QML используем:
                                import com.mycompany.mycomponents 1.0
                                
                                MyClass {}

                                файл ресурсов подключается в зависимости от ОС
                                • 0
                                  А в файле ресурсов прописываем алиасы, чтобы нивелировать различие путей, да. На самом деле хорошее решение, кода выйдет чуть меньше.
                                  Я говорил именно про подключение напрямую внутри .qml файлов, без регистрации типов в плюсах, там альтернатив Qt.createComponent() нет, но оно не сильно напрягает.
                                  • 0
                                    Посмотрите в сторону Loader http://doc.qt.io/qt-5/qml-qtquick-loader.html
                                    Думаю будет удобнее чем Qt.createComponent().
                                    Справедливости ради: Предложение zmeykas подменять файлы при сборке мне нравится больше.
                                    • 0
                                      И чем Loader будет удобнее?
                                      • 0
                                        Тем, что GUI будет описан декларативно.
                                        • 0
                                          Он и так описан декларативно, просто подгружаем разные файлы:

                                              Component.onCompleted: {
                                                  var menuComponent;
                                                  if (core.isIOS) {
                                                      menuComponent = Qt.createComponent("IosNavigationTabBar.qml");
                                                      menu = menuComponent.createObject(panelApplication);
                                                  }
                                                  else {
                                                      menuComponent = Qt.createComponent("AndroidNavigationTabBar.qml");
                                                      menu = menuComponent.createObject(mainActionBar);
                                                  }
                                              }
                                          • 0
                                            Аналог этого кода:
                                            Loader { source = (core.isIOS) ? "IosNavigationTabBar.qml" : "AndroidNavigationTabBar.qml" }
                                            • 0
                                              Вы можете заметить, что у компонентов разные родители, соответственно отображаются они в разных частях экрана.
                                              • 0
                                                да, этот момент я действительно просмотрел. Можно "засунуть" лоадеры в те места, которым принадлежат загружаемые компоненты, например:
                                                Loader {
                                                  source: "IosNavigationTabBar.qml"
                                                  active: core.isIOS
                                                }

                                                Просто я считаю использование Qt.createComponent моветоном — оно нарушает декларативность
                                                • 0
                                                  Можно, но зачем? Почему вы так считаете?
                                                  • +1
                                                    1. В иерархии QML-файла компоненты создаются там же, где они отображаются.
                                                    2. Код лаконичнее
                                                    3. Свойства объекта задаются прямо в лоадере. createObject же задает свойства создаваемого объекта а. списком, б. по значению

                                                    Например, вот здесь: http://doc.qt.io/qt-5/qml-qtquick-loader.html#sourceComponent-prop
                                                    достаточно красивый пример правильного, на мой взгляд (и взгляд разработчиков Qt), использования динамической загрузки компонентов
                                                    • 0
                                                      1. Да
                                                      2. В простейшем случае — да. В сложном же — работа со свойствами и сигналами компонента в лоадере усложняется.
                                                      3. В лоадере по сути тоже списком, просто он обычно представлен по одному выражению в строке ;)

                                                      Сложно представить, что на странице документации по Loader будет что-то другое. Вот страница по динамическому созданию от тех же разработчиков.

                                                      В целом я с вами согласен, что в простых случаях Loader предпочтительнее.
                    • +1
                      в Qt 5 нет возможности вставлять QWidget'ы в QtQuick сцену: stackoverflow.com/questions/13014415/qt5-embed-qwidget-object-in-qml
                      Qt уходят от классических виджетов, и не просто так. QML проще в разработке, быстрее в рендеринге, да и кастомизируются проще.
                    • 0
                      Только SVG поддерживается не полностью. Есть очень хорошие векторные иконки Oxygen Icons (https://www.archlinux.org/packages/extra/any/oxygen-icons/). Но использовать их нормально не получается…
                      Вот цитата из документации (http://doc.qt.io/qt-5/svgrendering.html):
                      Qt supports the static features of SVG 1.2 Tiny.
                      Так что простая SVG графика отображается без проблем, а что-то сложнее — коверкается.
                      • +1
                        Согласен, но мы использовали статичные, вместо картинок. Допустим, удобно для стилизованных флагов с градиентом, внедрённых в форму, придуманную дизайнером. Основным бонусом для нас — было избавление от "чудных мнгновений", потраченных на ресайз картинок (хотя imagemagick и скрипты в этом помогает, но потом всё равно вручную их надо отсматривать и находить и исправлять "поковерканные" при ресайзе).
                        К слову сказать, нынешние VectorDrawables для Android тоже не идеальны. В Qt с этим проще.
                      • +2
                        Спасибо за статью. Я в свое время столкнулся тоже с некоторыми проблемами, что привело к появлению собственных велосипедов. Может они будут полезны и Вам в чем-то:
                        • https://github.com/kafeg/qtrest — полноценный REST клиент, который умеет автоматически маппить JSON/XML в наследника QAbstractListModel, обрабатывать fetchMore и canFetchMore для пагинации, умеет передавать параметры сортировки и фильтрации и прочие нужные фишки. Имеет интерфейс из C++ и QML. Из коробки умеет работать с Yii2 REST API и Django REST Framework (ну как умеет, по их лекалам сделан =) ). Пока в полубете, много еще чего нужно сделать.
                        • https://github.com/kafeg/adctl — QtAdMob и Google Play Game Services для Qml.

                        Не радует конечно, что очень много приходится делать с нуля при разработке на Qt под мобильные платформы, но как правило весь новый код затем можно переиспользовать почти везде.
                        • 0
                          Неплохо, а почему не строить работу с REST через WorkerScript и обычный Ajax? Если там возвращается JSON — то это очень просто и удобно. Ну и есть ListModel sync (если не нужны всякие фишки с сортировкой, fetchMore и т.д., которыми можно нагрузить разработчика серверной части).
                          • 0
                            Хотелось один раз сделать все полноценно. Почти в каждом проекте работающем с API есть стандартная потребность в получении данных и их сортировке/фильтрации/подгрузке, так и в отправке POST/PUT запросов. Плюс мне хотелось иметь доступ к моделям как из QML так и из C++ и располагать всеми средствами для предобработки данных из C++.
                            На JS можно было бы реализовать такую логику, но мне кажется код получился бы гораздо сложнее.
                            Сейчас, если Вы обратили внимание в репозитории описан сложный метод использования библиотеки — через создание производного класса. На самом же деле я планирую еще создать какой-нибудь простой QML-компонент для модели, чтобы ему модно было лишь передать параметр и показать API-метод из которого стоит брать данные. В этом случае программисту нужно будет лишь унаследовать и реализовать класс работы со своим API.
                        • 0
                          А как вы решаете момент с черным экраном при запуске Qt Quick приложения на Android?
                          • 0
                            Ну какой-то момент будет черным в любом случае, потом можно показывать картинку splash screen, задается стандартным образом в манифесте.
                            • +1
                              А момент этот возникает из-за бага.
                              • 0
                                Но как то же тот же 2Гис решило проблему… У них при запуске программы черного экрана нет.
                                • 0
                                  У них Qt патченное.
                                  • +2
                                    Можно использовать QSplashScreen и закрывать его по сигналу из QML, например когда нужные части UI прогрузились
                                • +1
                                  Мы используем плавную анимацию opacity контейнера с контентом и фоновым цветом от 0 до 1. В итоге эффект следующий (он не совсем решает проблему, но немного улучшает поведение приложения): чёрный экран и плавное появление интерфейса вашего приложения.
                                • +2
                                  Самое важное не написали: модуль QtLocation очень скуден для реального использования. По факту, подходит для демо тулзы с тайловой картой osm/here/mapbox, и эта карта даже не поддерживает вращения (bearing). Можно написать геоплагин, предоставляющий тайлы, это в принципе несложно. Но если же вы хотите написать свою векторную карту на основе QML Map, будьте готовы, что от оригинального кода QtLocation у вас останутся лишь формулы пересчета пикселей сцены в координаты.
                                  • +1
                                    Спасибо за разъяснения. Опыта работы как такового под Android/iOS/WP с QtLocation не было. Когда только начинал интегрироваться с "железными фичами", такими как камера — понял, что оно далеко от идеала, в связи с чем работу с сенсорами, гео, блютуз, камерой и прочим строю исключительно на базе нативных компонентов и уже при необходимости передаю результат обратно в Qt.


                                    Поделитесь, пожалуйста, опытом сравнения с нативными аналогами.
                                  • +2
                                    По первой проблеме есть вполне себе готовое решение для андроида.
                                    Можно использовать обертки над нативными полями ввода.
                                    https://gitlab.com/2gisqtandroid/qtandroidextensions/tree/master/QtOffscreenViews
                                    • 0
                                      Попробовал, да, действительно, проблема с тестом и Emoji решается. Насколько большой опыт использования у вас этой компоненты? Есть ли какие-то камни и т.д.?

                                      И не знаете ли, почему они не используют это решение в своём Android приложении?
                                      • 0
                                        Посмотрите в профиль
                                        • +2
                                          Писалось под новое мобильное приложение в плэе идет под маркировкой "бэта", так что опыт от начала создания.
                                          В своих кейсах почти все камни отловили и поправили.
                                          Используем в новом мобильном приложении и в некоторых закрытых продуктах на базе текущей версии приложения.

                                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                      Самое читаемое