21 May

Как происходит рендеринг экрана сообщений ВКонтакте

ВКонтакте corporate blogJUG Ru Group corporate blogDevelopment of mobile applicationsDevelopment for AndroidSocial networks and communities
Что делает ВКонтакте, чтобы уменьшить лаги отрисовки? Как отобразить очень большое сообщение и не убить UiThread? Как уменьшить задержки при скролле в RecyclerView?



Мой опыт основан на работе отрисовки экрана сообщений в Android-приложении VK, в котором необходимо показывать огромное количество информации с минимумом тормозов на UI.

Я программирую под Android уже почти десять лет, ранее занимался фрилансом для PHP/Node.js. Сейчас — старший Android-разработчик ВКонтакте.

Под катом — видео и расшифровка моего доклада с конференции Mobius 2019 Moscow.


В докладе раскрываются три темы



Посмотрите на экран:


Это сообщение где-то на пять экранов. И они вполне могут у нас быть (в случае пересылок сообщений). Стандартные средства уже не будут работать. Даже на топовом девайсе всё может лагать.
Ну и, помимо этого, сам UI довольно разнообразен:

  • даты и индикаторы подгрузки,
  • сервисные сообщения,
  • текст (emoji, link, email, hashtags),
  • клавиатура ботов,
  • ~40 способов отображения аттачей,
  • дерево пересланных сообщений.

Встаёт вопрос: как сделать так, чтобы количество лагов было как можно меньше? Как в случае простых сообщений, так и в случае объемных (edge-case из видео выше).


Стандартные решения


RecyclerView и его надстройки


Есть различные надстройки над RecyclerView.

  • setHasFixedSize (boolean)

Многие считают, что этот флаг нужен тогда, когда элементы списка имеют одинаковый размер. Но на самом деле, судя по документации, всё наоборот. Это когда размер RecyclerView постоянный и не зависит от элементов (грубо говоря, не wrap_content). Установка флага помогает немного повысить скорость у RecyclerView, чтобы он избежал лишних вычислений.

  • setNestedScrollingEnabled (boolean)

Незначительная оптимизация, отключающая поддержку NestedScroll. У нас на этом экране нет CollapsingToolbar или других фич, зависящих от NestedScroll, поэтому можем смело выставить этот флаг в false.

  • setItemViewCacheSize (cache_size)

Настройка внутреннего кэша RecyclerView.

Многие думают, что механика RecyclerView — это:

  • есть ViewHolder, отображаемый на экране;
  • есть RecycledViewPool, хранящий ViewHolder;
  • когда ViewHolder уходит с экрана — он помещается в RecycledViewPool.

На практике всё немного сложнее, ведь между этими двумя вещами есть промежуточный кеш. Он называется ItemViewCache. В чём его суть? Когда ViewHolder уходит с экрана, он помещается не в RecycledViewPool, а в промежуточный кеш (ItemViewCache). Все изменения в адаптере применяются как к видимым ViewHolder, так и к ViewHolder внутри ItemViewCache. А к ViewHolder внутри RecycledViewPool изменения не применяются.

Через setItemViewCacheSize мы можем задать размер этого промежуточного кеша.
Чем он больше, тем быстрее будет скролл на небольшие расстояния, но операции обновления будут выполняться дольше (из-за ViewHolder.onBind и т. д.).

Как реализован RecyclerView и как устроен его кеш — довольно большая и сложная тема. Можно прочитать большую статью, где детально рассказывают про всё.

Оптимизация OnCreate/OnBind


Ещё одно классическое решение — оптимизация onCreateViewHolder/onBindViewHolder:

  • лёгкая верстка (стараемся максимально использовать FrameLayout либо Custom ViewGroup),
  • тяжёлые операции (парсинг ссылок/emoji) делаются асинхронно на этапе загрузки сообщений,
  • StringBuilder для форматирования имени, даты, etc.,
  • и прочие решения, сокращающие время работы этих методов.

Отслеживание Adapter.onFailedToRecyclerView()




У вас есть список, в котором какие-то элементы (или их часть) анимируются с альфой. В тот момент, когда View, будучи в процессе анимации, уходит с экрана, то она не уходит в RecycledViewPool. Почему? RecycledViewPool видит, что View сейчас анимируется за счёт флага View.hasTransientState, и просто её игнорирует. Поэтому в следующий раз при скролле вверх-вниз картинка не будет браться из RecycledViewPool, а создастся заново.

Самое правильное решение — когда ViewHolder уходит с экрана, нужно отменять все анимации.



Если же нужен хотфикс как можно скорее или вы ленивый разработчик, то в методе onFailedToRecycle можно просто всегда возвращать true и всё будет работать, но я бы не советовал так делать.



Отслеживание Overdraw и Profiler


Классический способ обнаружения проблем — отслеживание overdraw и profiler.

Overdraw — количество перерисовок пикселя: чем меньше слоев и чем меньше перерисовывается пиксель, тем быстрее. Но по моим наблюдениям, в современных реалиях, это уже не так сильно влияет на performance.



Profiler — он же Android Monitor, который есть в Android Studio. В нём можно проанализировать все вызываемые методы. Например, открыть сообщения, прокрутить вверх-вниз и посмотреть, какие методы вызывались и сколько они заняли времени.



Всё, что находится в левой половине, — это системные вызовы Android, которые нужны, чтобы создать/отрисовать View/ViewHolder. На них мы либо не можем повлиять, либо нужно будет потратить много усилий.

Правая половина — наш код, который исполняется во ViewHolder.

Блок вызовов под №1 — это вызов регулярных выражений: где-то недосмотрели и забыли вынести операцию на фоновый поток, тем самым замедлив скролл на ~20%.

Блок вызовов под №2 — Fresco, библиотека для отображения картинок. Она местами не оптимальна.Пока непонятно, что делать с этим лагом, но если получится решить, то сэкономим ещё ~15%.

То есть, исправив эти проблемы, мы можем получить прирост ~35%, а это довольно круто.

DiffUtil


Многие из вас используют DiffUtil в стандартном виде: есть два списка — вызвали, сравнили и запушили изменения. Выполнять всё это на основном потоке немного затратно, потому что список может быть очень большим. Так что обычно вычисление DiffUtil запускается на фоновом потоке.

ListAdapter и AsyncListDiffer это делают за вас. ListAdapter расширяет обычный Adapter и запускает всё асинхронно — достаточно сделать submitList и весь расчёт изменений улетает на внутренний фоновый поток. ListAdapter умеет учитывать кейс частых обновлений: если его вызвать три раза подряд, он возьмёт только последний результат.

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

Анимации


Когда-то очень давно был фреймворк Animation — довольно скудный, но всё же уже что-то. Работали с ним так:

view.startAnimation(TranslateAnimation(fromX = 0, toX = 300))

Проблема в том, что параметр getTranslationX() до анимации и после будет возвращать одно и то же значение. Это потому, что Animation менял визуальное представление, но при этом не менял физические свойства.



В Android 3.0 появился фреймворк Animator, более корректный, потому что он менял конкретное физическое свойство объекта.



Позже появился ViewPropertyAnimator и все до сих пор не очень понимают его отличие от Animator.

Поясню. Допустим, вам нужно сделать translation по диагонали — сместить View по осям x,y. Скорее всего, вы бы написали типичный код:

val animX = ObjectAnimator.ofFloat(view, “translationX”, 100f)
val animY = ObjectAnimator.ofFloat(view, “translationY”, 200f)
AnimatorSet().apply {
    playTogether(animX, animY)
    start()
}

А можно сделать короче:

view.animate().translationX(100f).translationY(200f) 

Когда вы исполняете view.animate(), вы неявно запускаете ViewPropertyAnimator.

Зачем он нужен?

  1. Проще читать и поддерживать код.
  2. Batch операций анимации.

В нашем прошлом кейсе мы изменяли два свойства. Когда мы делаем это через аниматоры, то тики анимаций будут вызываться отдельно для каждого Animator. То есть setTranslationX и setTranslationY будут вызваны раздельно, и View будет производить операции обновления отдельно.

В случае ViewPropertyAnimator изменение происходит одновременно, поэтому получается экономия за счёт меньшего количества операций и само изменение свойств лучше оптимизировано.

Подобного можно достичь и с помощью Animator, но придётся писать больше кода. Помимо этого, используя ViewPropertyAnimator, можно быть уверенным, что анимации будут максимально оптимизированы. Почему? В Android есть RenderNode (DisplayList). Очень грубо говоря, они кешируют результат onDraw и используют его при перерисовке. ViewPropertyAnimator работает напрямую с RenderNode и применяет анимации к ней, избегая вызовы onDraw.

Многие свойства View тоже могут напрямую влиять на RenderNode, но не все. То есть при использовании ViewPropertyAnimator вы гарантированно задействуете максимально производительный способ. Если у вас вдруг есть какие-то анимации, которые не могут быть выполнены с помощью ViewPropertyAnimator, то, возможно, стоит задуматься и изменить их.

Анимации: TransitionManager


Обычно у людей возникает ассоциация, что этот фреймворк используется для перехода с одной Activity на другую. На самом деле, он может использоваться иначе и очень упрощать реализацию анимации изменения структуры. Допустим, у нас есть экран, на котором играет голосовое сообщение. Мы закрываем его крестиком, и плашка уходит наверх. Как это сделать? Анимация довольно сложная: плеер закрывается с альфой, при этом двигается не через translation, а меняет свою высоту. Одновременно с этим наш список поднимается наверх и тоже меняет высоту.



Если бы плеер был частью списка, то анимации было бы сделать довольно просто. Но у нас плеер это не элемент списка, а вполне самостоятельная View.

Возможно, мы бы начали писать какой-нибудь Animator, затем столкнулись бы с проблемами, крашами, начали бы пилить костыли и ещё в два раза увеличили код. И получили бы что-то, как на экране ниже.



С помощью TransitionManager всё можно сделать проще:

TransitionManager.beginDelayedTransition(
        viewGroup = <LinearLayoutManager>,
        transition = AutoTransition())
playerView.visibility = View.GONE

Вся анимация происходит автоматически под капотом. Это выглядит как магия, но если углубиться внутрь и посмотреть, как это работает, то выяснится, что TransitionManager просто подписывается на все View, ловит изменения их properties, высчитывает diff, создаёт нужные аниматоры или ViewPropertyAnimator, где нужно, и делает всё максимально производительно. TransitionManager позволяет нам делать анимации в разделе сообщений быстрыми и простыми в реализации.

Нестандартные решения




Это самая фундаментальная вещь, на которой основан performance и последующие за ним проблемы. Что делать, когда ваше сообщение находятся на 10 экранах? Если обратить внимание, то все наши элементы располагаются ровно друг под другом. Если мы примем, что ViewHolder это не одно сообщение, а десятки различных ViewHolder-ов, тогда всё становится сильно проще.

Для нас не проблема, что сообщение стало на 10 экранов, ведь теперь мы отображаем в конкретном примере всего лишь шесть ViewHolder-ов. Мы получили лёгкую верстку, код проще поддерживать, да и проблем особых нет, кроме одной — как это сделать?



Есть простые ViewHolder — это классические разделители даты, Load more и так далее. И BaseViewHolder — условно базовый ViewHolder для сообщения. У него есть базовая реализация и несколько конкретных — TextViewHolder, PhotoViewHolder, AudioViewHolder, ReplyViewHolder и так далее. Всего их около 70.

За что отвечает BaseViewHolder


BaseViewHolder отвечает только за то, чтобы отрисовать аватарку и нужный кусок bubble, а также линию для пересланных сообщений — голубую слева.



Конкретную реализацию контента осуществляют уже другие наследники BaseViewHolder: TextViewHolder отображает только текст, FwdSenderViewHolder — автора пересланного сообщения, AudioMsgViewHolder — голосовое сообщение и так далее.



Возникает проблема: что делать с шириной? Представьте, что сообщение хотя бы на два экрана. Не очень понятно, какую ширину выставить, потому что половина видна, половина не видна (и ещё даже не создалась). Измерить абсолютно всё нельзя, потому что это лагает. Приходится немного костылить, увы. Есть простые случаи, когда сообщение очень простое: чисто текст или голосовое — в общем, состоит из одного Item.



В этом случае используем классический wrap_content. Для сложного кейса, когда сообщение состоит из нескольких кусков, мы берём и форсируем каждому ViewHolder фиксированную ширину. Конкретно здесь — 220 dp.



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



Есть edge-кейсы. Если на какое-то сообщение отвечаем стикером, то мы можем указать ширину конкретно для такого кейса, чтобы выглядело симпатичнее.

Мы разбиваем на ViewHolder-ы на этапе загрузки сообщений: запускаем фоновую загрузку сообщения, преобразуем в item, они напрямую отображаются во ViewHolder-ы.



Глобальный RecycledViewPool


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

Это можно решить глобальным RecycledViewPool:

  • в рамках Application живёт RecycledViewPool как синглтон;
  • переиспользуется на экране сообщений, когда пользователь ходит между экранами;
  • устанавливается как RecyclerView.setRecycledViewPool(pool).

Есть и подводные камни, важно помнить две вещи:

  • вы зашли на экран, нажали back, вышли. Проблема в том, что те ViewHolder, что были на экране, выбрасываются, а не возвращаются в pool. Это исправляется так:
    LinearLayoutManager.recycleChildrenOnDetach = true
  • RecycledViewPool имеет ограничения: для каждого ViewType может храниться не больше пяти ViewHolder.

Если на экране отобразились 9 TextView, в RecycledViewPool вернутся только пять item-ов, а остальные будут выброшены. Размер RecycledViewPool можно поменять:
RecycledViewPool.setMaxRecycledViews(viewType, size)

Но так прописывать на каждый ViewType руками как-то грустно, потому можно написать свой RecycledViewPool, расширив стандартный, и сделать его NoLimit. По ссылке можно скачать готовую реализацию.

DiffUtil не всегда полезен


Вот классический кейс — аплоад, проигрывание аудиотрека и голосового сообщения. В этом случае происходит спам вызовов DiffUtil.



У нашего BaseViewHolder появляется абстрактный метод updateUploadProgress.

abstract class BaseViewHolder : ViewHolder {
    …
    fun updateUploadProgress(attachId: Int, progress: Float)
    …        
}

Чтобы прокинуть событие, нам необходимо обойти все видимые ViewHolder:

fun onUploadProgress(attachId: Int, progress: Float) {
    forEachActiveViewHolder {
        it.updateUploadProgress(attachId, progress)
    }
}

Это простая операция, вряд ли у нас на экране будет больше десяти ViewHolder. Такой подход не может лагать в принципе. Как найти видимые ViewHolder? Наивная реализация была бы примерно такой:

val firstVisiblePosition = <...>
val lastVisiblePosition = <...>
for (i in firstVisiblePosition.. lastVisiblePosition) {
    val viewHolder = recycler.View.findViewHolderForAdapterPosition(i)
    viewHolder.updateUploadProgress(..)
}

Но есть проблема. Промежуточный кеш, о котором я говорил ранее, ItemViewCache, содержит активные ViewHolder, которые просто не отображаются на экране. Код выше их не затронет. Напрямую мы тоже не можем к ним обратиться. И тогда нам на помощь приходят костыли. Создаем WeakSet, хранящий ссылки на ViewHolder. Далее нам достаточно просто обходить этот WeakSet.

class Adapter : RecyclerView.Adapter {
    val activeViewHolders = WeakSet<ViewHolder>()
        
    fun onBindViewHolder(holder: ViewHolder, position: Int) {
        activeViewHolders.add(holder)
    }

    fun onViewRecycled(holder: ViewHolder) {
        activeViewHolders.remove(holder)
    }
}

Наложение ViewHolder


Рассмотрим на примере историй. Раньше, если человек реагировал на историю стикером, мы отображали это так:



Выглядит довольно некрасиво. Хотелось сделать лучше, ведь истории — яркий контент, а у нас там маленький квадратик. Мы же хотели получить что-то такое:



Возникает проблема: у нас же сообщение разбито на ViewHolder, они располагаются строго друг под другом, а здесь они накладываются. Сходу непонятно, как это решить. Можно создать ещё один ViewType «история+стикер» или «история+голосовое сообщение». Итого, у нас вместо 70 ViewType стало бы 140… Нет, нужно придумать что-то более удобное.



На ум приходит один из любимых костылей в Android. Например, мы что-то сверстали, а у нас не сходится Pixel Perfect. Чтобы это исправить, нужно всё удалить и написать с нуля, но лень. В итоге можно сделать margin=-2dp (отрицательным), и вот у нас всё встаёт на место. Но именно такой подход здесь использовать нельзя. Если задать отрицательный margin, то стикер сдвинется, но место, которое он занимал, останется пустым. Но у нас есть ItemDecoration, где itemOffset мы можем сделать отрицательным числом. И это работает! В результате у нас получится ожидаемое наложение и при этом останется парадигма, где каждый ViewHolder друг под дружкой.

Красивое решение в одну строчку.

class OffsetItemDecoration : RecyclerViewItemDecoration() {
    overrride fun getItemOffsets(offset: Rect, …) {
        offset.top = -100dp
    }
}

IdleHandler


Это кейс со звездочкой, он сложный и не так часто нужен на практике, но важно знать о наличии такого способа.

Для начала расскажу, как устроен главный поток UiThread. Общая схема: есть очередь событий tasks, в которую задачи ставятся через handler.post, и бесконечный цикл, который ходит по этой очереди. То есть UiThread — это просто while (true). Если есть задачи — исполняем их, если нет — ждём, пока появятся.



В привычных нам реалиях Handler отвечает за то, чтобы закинуть задачи в очередь, а Looper бесконечно обходит очередь. Есть задачи, которые для UI не очень важны. Например, пользователь прочитал сообщение — нам не так важно, когда мы его отобразим на UI, прямо сейчас или спустя 20 мс. Пользователь разницы не заметит. Тогда, возможно, стоит запускать эту задачу на главном потоке только тогда, когда он освободится? То есть нам хорошо бы узнать, когда вызывается строчка awaitNewTask. Для этого случая у Looper есть addIdleHandler, который срабатывает в тот момент, когда срабатывает код tasks.isEmpty.

Looper.myQueue().addIdleHandler()

И тогда простейшая реализация IdleHandler будет выглядеть так:

@AnyThread
class IdleHandler {
    private val handler = Handler(Looper.getMainLooper())

    fun post(task: Runnable) {
        handler.post {
            Looper.myQueue().addIdleHandler {
                task.run()
                return@addIdleHandler false
            }
        }
    }
}

Этим же способом можно измерить честный холодный старт приложения.

Emoji


Мы используем свои кастомные эмоджи вместо системных. Вот пример, как выглядели эмоджи на разных платформах в разные годы. Слева и справа эмоджи довольно симпатичные, а вот посередине…



Есть и вторая проблема:



Каждый ряд — это один и тот же emoji, а вот воспроизводимые ими эмоции разные. Мне больше всего нравится нижний правый, я до сих пор не понимаю, что он обозначает.

Есть байка из ВКонтакте. В ~2014 году мы немного поменяли один emoji. Может быть, кто-то помнит — «Зефирчик» был. После его смены начался мини-бунт. Он, конечно, не достиг уровня «верни стену», но реакция была довольно интересной. И это говорит нам о важности трактовки эмоджи.

Как сделаны эмоджи: у нас есть большой битмап, где все они собраны в одном большом «атласе». Их несколько — под разные DPI. И есть EmojiSpan, который содержит информацию: я рисую «такой-то» эмоджи, он находится в таком-то битмапе по такому-то location(x,y).
И есть ReplacementSpan, который позволяет отобразить что-то вместо текста под Span.
То есть вы находите в тексте эмоджи, оборачиваете его EmojiSpan, а система рисует нужный эмоджи вместо системного.



Альтернативы


Inflate


Кто-то может сказать, что раз inflate медленный, то почему бы просто не создать вёрстку руками, избегая inflate. И тем самым ускорить всё, избежав 100500 ViewHolder. Это заблуждение. Прежде чем что-то сделать, стоит это измерить.

В Android есть класс Debug, у него есть startMethodTracing и stopMethodTracing.

Debug.startMethodTracing(“trace»)
inflate(...)
Debug.stopMethodTracing()

Он позволит нам собрать информацию о времени выполнения конкретного участка кода.



И мы видим, что здесь inflate как таковой даже незаметен. Четверть времени ушло на загрузку drawable, четверть на загрузку цветов. И только где-то в части etc — наш inflate.

Я пробовал перевести XML-вёрстку в код и сэкономил где-то 0.5 мс. Прирост, на самом деле, не самый впечатляющий. А код стал сильно сложнее. То есть переписывать особого смысла нет.

Тем более что на практике многие вообще не столкнутся с этой проблемой, потому что долгий inflate обычно возникает, только когда приложение становится очень большим. У нас в приложении ВКонтакте, например, примерно 200-300 различных экранов, и подгрузка всех ресурсов подлагивает. Что с этим делать — пока непонятно. Скорее всего, придется писать свой ресурс-менеджер.

Anko


Anko недавно стала deprecated. Да и вообще, Anko это не магия, а простой синтаксический сахар. Он точно так же всё переводит в условный new View(). Поэтому пользы от Anko никакой.

Litho/Flutter


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

Непонятно, даст ли нам это прирост производительности. И не получим ли мы новых проблем, ведь нашим приложением ежеминутно пользуются миллионы человек с абсолютно разными девайсами (о четверти из них вы даже, наверное, и не слышали). Более того, сообщения — это очень большая база кода. Моментально всё переписать нельзя. А делать это из-за хайпа технологии — глупо. Тем более когда где-то вдалеке маячит Jetpack Compose.

Jetpack Compose


Google всё обещает нам манну небесную в виде данной библиотеки, но она всё ещё в альфе. А когда будет в релизе — непонятно. Сможем ли мы его завести в текущием виде — тоже непонятно. Сейчас экспериментировать рано. Пусть выйдет в stable, пусть закроются основные баги. И только тогда мы будем смотреть в его сторону.

Одна большая Custom View


Есть ещё один подход, о котором говорят те, кто пользуются различными мессенджерами: «возьмите и напишите одну большую Custom View, никакой сложной иерархии».

В чём минусы?

  • Сложно поддерживать.
  • Не имеет смысла в текущих реалиях.

С Android 4.3 прокачалась система внутреннего кеширования внутри View. Например, не вызывается onMeasure, если View не изменилась. И используются результаты прошлого измерения.

С Android 4.3-4.4 появился RenderNode (DisplayList), кеширующий отрисовку. Давайте рассмотрим пример. Допустим, есть ячейка списка диалогов: аватарка, title, subtitle, статус прочитанности, время, ещё одна аватарка. Условно — 10 элементов. И мы написали Custom View. В таком случае при изменении одного свойства мы будем заново измерять все элементы. То есть просто потратим лишние ресурсы. В случае же ViewGroup, где каждый элемент — это отдельная View, при изменении одной View мы будет инвалидировать только её одну (за исключением случаев, когда эта View влияет на размеры других).

Итоги


Итак, вы узнали, что мы используем классический RecyclerView со стандартными оптимизациями. Есть часть нестандартных, где самая главная и фундаментальная — это разбиение сообщения на ViewHolder. Вы, конечно, можете сказать, что это узкоприменимо, но ведь этот подход можно проецировать и на другие вещи, например, на большой текст в 10 тысяч символов. Его можно разбить по абзацам, где каждый абзац — отдельный ViewHolder.

Также стоит максимально всё выносить на @WorkerThread: парсинг ссылок, DiffUtils — тем самым максимально разгрузив @UiThead.

Глобальный RecycledViewPool позволяет ходить между экранами сообщений и каждый раз не создавать ViewHolder.

Но есть и другие важные вещи, которые мы пока не решили, например, долгий inflate, а точнее — загрузку данных из ресурсов.

Если вам интересна тема сообщений, то на Mobius 2019 Piter я рассказывал, как работает кеш под капотом. Довольно сложный и хардкорный доклад, где я описал хранение кешей, оптимизацию SQLite, всякие хаки и многое другое. А в этом году Mobius 2020 Piter пройдет онлайн уже в июне.
Tags:androidмобильная разработка
Hubs: ВКонтакте corporate blog JUG Ru Group corporate blog Development of mobile applications Development for Android Social networks and communities
+44
11.5k 85
Comments 16