Архитектура слоя исполнения асинхронных задач

в 10:34, , рубрики: appsconf, kotlin, rxjava, асинхронность, Блог компании Конференции Олега Бунина (Онтико), мобильные приложения, разработка мобильных приложений, Разработка под android, разработка под iOS

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

Архитектура слоя исполнения асинхронных задач - 1

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

О спикере: Степан Гончаров (stepango) работает в компании Grab — это как Uber, но в юго-восточной Азии. Больше 9 лет занимается Android-разработкой. Интересуется Kotlin с 2014 году, а с 2016 — использует его в проде. Организует Kotlin User Group в Сингапуре. Это одна из причин, почему все примеры кода будут на Kotlin, а не потому, что это модно.

Мы рассмотрим один из подходов к проектированию компонентов вашего приложения. Это руководство к действию для тех, кто хочет добавить новые компоненты в приложение, удобно их спроектировать, и потом расширять. iOS-разработчики могут использовать подход на iOS. Также подход применим и к другим платформам. Я интересуюсь Kotlin с 2014 года, поэтому все примеры будут на этом языке. Но не беспокойтесь — вы можете то же самое написать на Swift, Objective-C и других языках.

Начнём с проблем и недостатков Reactive Extensions. Проблемы типичны и для остальных асинхронных примитивов, поэтому говорим RX — держим в уме future и promise, и все будет работать аналогично.

Проблемы RX

Высокий порог входа. RX довольно сложный и большой — в нем 270 операторов, и непросто научить всю команду правильно их использовать. Эту проблему не будем обсуждать — она за рамками доклада.

В RX вы должны вручную управлять подписками, а также следить за жизненным циклом приложения. Если вы уже подписались на Single или Observable, то не можете сравнить его с другим SIngle, потому что всегда будете получать новый объект и для runtime всегда будут разные подписки. В RX нет возможности для сравнения подписок и стримов.

Некоторые из этих проблем мы попробуем решить. Каждую проблему будем решать один раз, а потом переиспользовать результат.

Проблема № 1: выполнение одной задачи больше одного раза

Частая проблема в разработке — лишняя работа и повторение одинаковых задач больше одного раза. Представим, что у нас есть форма для ввода данных и кнопка сохранения. При нажатии отправляется запрос, но если нажать несколько раз, пока сохраняется форма, то отправятся несколько одинаковых запросов. Мы отдали кнопку на тестирование QA, они нажали 40 раз за одну секунду — получили 40 запросов, потому что, например, анимация не успела отработать.

Как решить проблему? У каждого разработчика есть свой любимый подход для решения: один воткнет debounce, другой заблокирует кнопку на всякий случай через clickable = false. Общего подхода нет, поэтому эти баги будут то появляться, то исчезать из нашего приложения. Мы решаем проблему только, когда QA нам говорит: «Ой, я тут нажал, и у меня сломалось!»

Масштабируемое решение?

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

Назовем нашу обертку «Act» — другие имена уже заняты. Для этого создаем небольшой typealias и простой interface, в котором всего одно поле:

typealias Id = String
interface Act { 
    val id: Id
}

Это удобно и немного сокращает количество кода. Уже потом, если не понравится String, заменим его на что-то другое. В этом маленьком кусочке кода наблюдаем забавный факт.

Интерфейсы могут содержать property.

Для программистов, которые приходят из Java, это неожиданно. Обычно они добавляют внутрь интерфейса методы getId(), но это неверное решение, с точки зрения Kotlin.

Как будем проектировать?

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

Почему недостаточно ID?

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

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

Вводим новые абстракции: MapDisposable

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

Создадим небольшой интерфейс MapDisposable, который содержит информацию о текущих задачах и вызывает dispose() при удалении. Реализацию приводить не буду, вы можете посмотреть все исходники у меня на GitHub.

Мы называем MapDisposable так, потому что компонент будет работать как Map, но при этом имеет свойства CompositeDiposable.

Вводим новые абстракции: ActExecutor

Следующий абстрактный компонент — ActExecutor. Он запускает или не запускает новые задачи, зависит от MapDisposable и делегирует обработку ошибок. Как выбирать имя — cмотрите в документации.

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

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

interface ActExecutor {
    fun execute(
        act: Act,
        e: (Throwable) -> Unit = ::logError)
}

interface MapDisposable {
    fun contains(id: Id): Boolean
    fun add(id: Id, disposable: () -> T)
    fun remove(id: Id)
}

MapDisposable тоже ограничим: возьмем интерфейс Map и скопируем из него методы contains, add и remove. Метод add отличается от Map: вторым аргументом передаем лямбду для красоты и удобства. Удобство в том, что мы можем синхронизировать лямбду, чтобы предотвратить неожиданныe race conditions. Но об этом не будем говорить, продолжим про архитектуру.

Реализация интерфейсов

Мы задекларировали все интерфейсы и попробуем реализовать что-нибудь простое. Берем CompletableAct и SingleAct.

class CompletableAct (
    override val id: Id,
    override val completable: Completable
) : Act

class SingleAct<T : Any>(
    override val id: Id,
    override val single: Single<T>
) : Act 

CompletableAct — это обертка над Completable. В нашем случае она просто содержит ID — то, что нам и нужно. SingleAct — почти то же самое. Мы можем реализовать так же Maybe и Flowable, но остановимся на первых двух реализациях.

Для Single мы указали Generic тип <T : Any>. Как Kotlin-разработчик я предпочитаю использовать именно такой подход.

Старайтесь использовать Non-Null дженерики.

Теперь, когда у нас есть набор интерфейсов, реализуем немного логики, чтобы предотвратить выполнение одинаковых запросов.

class ActExecutorImpl (
    val map: MapDisposable
): ActExecutor {

    fun execute(
        act: Act,
        e: (Throwable) -> Unit
    ) = when {
        map.contains(act.id) -> {
            log("${act.id} - in progress")
        }
        else
            startExecution(act, e)
            log("${act.id} - Started")
        }
    }

Берем Map и проверяем — есть ли в ней запрос. Если нет — начинаем выполнять запрос и как раз во время выполнения добавим его в Map. После выполнения с любым результатом: ошибка или успех, удаляем запрос из Map.

Для очень внимательных — здесь нет синхронизации, но синхронизация есть в исходниках на GitHub.

fun startExecution(act: Act, e: (Throwable) -> Unit) {
    val removeFromMap = { mapDisposable.remove(act.id) }
    mapDisposable.add(act.id) {
    when (act) {
        is CompletableAct -> act.completable
            .doFinally(removeFromMap)
            .subscribe({}, e)
        is SingleAct<*> -> act.single
            .doFinally(removeFromMap)
            .subscribe({}, e)
        else -> throw IllegalArgumentException()
    }
}

Ставьте лямбды последним аргументом, чтобы повысить читаемость кода. Это красиво и ваши коллеги вас поблагодарят.

Используем еще немного Kotlin фишек и добавим extension-функции для Completable и Single. С ними нам не придется искать фабричный метод, чтобы создать CompletableAct и SingleAct — будем их создавать через extension-функции.

fun Completable.toAct(id: Id): Act =
    CompletableAct(id, this)

fun <T: Any> Single<T>.toAct(id: Id): Act =
    SingleAct(id, this)

Extension-функции могут быть добавлены к любому классу.

Результат

Мы реализовали несколько компонентов и очень простую логику. Теперь главное правило, которое мы должны соблюдать — не вызывать подписку руками. Когда мы что-то хотим выполнить — даем это через Executor. Также как и с thread — никто их не запускает сам.

fun act() = Completable.timer(2, SECONDS).toAct("Hello")

executor.apply {
    execute(act())
    execute(act())
    execute(act())
}

        Hello - Act Started
        Hello - Act Duplicate
        Hello - Act Duplicate
        Hello - Act Finished

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

Первую проблему решили. Теперь давайте расширим решение, чтобы придать ему гибкости.

Проблема № 2: какую задачу отменить?

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

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

Добавляем компоненты

Назовем поведение запросов стратегиями и создадим для них 2 интерфейса: StrategyHolder и Strategy. Также создадим 2 объекта, которые отвечают за то, какую стратегию применить.

interface StrategyHolder {
    val strategy: Strategy
}

sealed class Strategy

object	
KillMe : Strategy()
object	
SaveMe : Strategy()

Я не использую enum — мне больше нравятся sealed class. Они легче, потребляют меньше памяти, и их проще и удобнее расширять.

Sealed class легче расширять и короче писать.

Обновляем существующие компоненты

На этом этапе все просто. У нас был простой интерфейс, теперь он будет наследником StrategyHolder. Так как это интерфейсы — нет никаких проблем с наследованием. В реализацию CompletableAct мы вставим еще один override и добавим туда дефолтное значение для уверенности, что изменения останутся совместимы с существующим кодом.

interface Act : StrategyHolder {
    val id: String
}

class CompletableAct(
    override val id: String,
    override val completable: Completable,
    override val strategy: Strategy = SaveMe
) : Act

Стратегии

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

Мы немного поработали над нашей реализацией. У нас был метод execute, и теперь мы добавили туда проверку стратегии.

  • Если стратегия SaveMe — то же самое, что мы делали до этого, то ничего не изменилось.
  • Если стратегия KillMe — убиваем предыдущий запрос и запускаем новый.

override fun execute(act: Act, e: (Throwable) -> Unit) = when {
    map.contains(act.id) -> when (act.strategy) {
        KillMe -> {
            map.remove(act.id)
            startExecution(act, e)
        }
        SaveMe -> log("${act.id} - Act duplicate")
    }
    else -> startExecution(act, e)
}

Результат

Мы получили возможность легко управлять стратегиями, написав минимум кода. При этом коллеги довольны, и мы можем сделать что-то вроде этого.

executor.apply {
    execute(Completable.timer(2, SECONDS)
        .toAct("Hello", KillMe))
    execute(Completable.timer(2, SECONDS)
        .toAct("Hello", KillMe))
    execute(Completable.timer(2, SECONDS)
        .toAct("Hello«, KillMe))
}
        Hello - Act Started
        Hello - Act Canceled
        Hello - Act Started
        Hello - Act Canceled
        Hello - Act Started
        Hello - Act Finished

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

Проблема № 3: стратегий недостаточно

Перейдём к одной интересной проблеме, с которой я сталкивался на паре проектов. Расширим наше решение, чтобы справляться с кейсами посложнее. Один из таких кейсов, особенно актуальный для социальных сетей — это «like/dislike». Есть пост и мы хотим его лайкнуть, но как разработчики не хотим блокировать весь UI, и показывать диалог на весь экран с загрузкой, пока запрос не выполнится. Да и пользователь будет недоволен. Мы хотим пользователя обмануть: он нажимает на кнопку и, как будто, лайк уже произошел — началась красивая анимация. Но на самом деле лайка не было — мы ждем пока обман не станет правдой. Чтобы обман не раскрыли мы должны прозрачно для пользователя обрабатывать также dislike.

Было бы хорошо обработать это правильно, чтобы пользователь получил желаемый результат. Но нам, как разработчикам, сложно каждый раз справляться с разными, взаимоисключающими запросами.

Возникает слишком много вопросов. Как понять, что запросы связаны? Как хранить эти связи? Как обрабатывать сложные сценарии и не копипастить? Как назвать новые компоненты? Задачи сложные, и то, что мы уже реализовали, не подходит для решения.

Группы и стратегии для групп

Создаем простой интерфейс, который назовем GroupStrategyHolder. Он немного посложнее — два поля вместо одного.

interface GroupStrategyHolder {
    val groupStrategy: GroupStrategy
    val groupKey: String
}
sealed class GroupStrategy

object Default : GroupStrategy()
object KillGroup : GroupStrategy()

Помимо стратегии для конкретного запроса вводим новую сущность — группу запросов. У этой группы тоже будут стратегии. Мы рассмотрим только самый простой вариант с двумя стратегиями: Default — стратегия по умолчанию, когда мы ничего не делаем с запросами, и KillGroup — убивает все существующие запросы из группы и запускает новый.

interface Act : StrategyHolder, GroupStrategyHolder {
    val id: String
}

class CompletableAct(
    override val id: String,
    override val completable: Completable,
    override val strategy: Strategy = SaveMe,
    override val groupStrategy: GroupStrategy = Default
    override val groupKey: String = ""
) : Act

Повторяем шаги, о которых говорил раньше: берем интерфейс, расширяем и добавляем два дополнительных поля в CompletableAct и SingleAct.

Обновляем реализацию

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

MapDisposable -> GroupDisposable

...

override fun execute(act: Act, e: (Throwable) -> Unit) {
    if (act.groupStrategy == KillGroup)
        groupDisposable.removeGroup(act.groupKey)
    return when {
        groupDisposable.contains(act.groupKey, act.id) ->
            when (act.strategy) {
                KillMe -> {
                    stop(act.groupKey, act.id)
                    startExecution(act, e)
                }
                SaveMe -> log("${act.id} - Act duplicate")
            }
        else -> startExecution(act, e)
    }
}

Проблема сложная, но у нас уже есть достаточно адекватная инфраструктура — мы можем ее расширить и решить задачу. Если посмотреть наш результат, что теперь нам нужно сделать?

Результат

fun act(id: String)= Completable.timer(2, SECONDS).toAct(
    id = id,
    groupStrategy = KillGroup,
    groupKey = "Like-Dislike-PostId-1234"
)

executor.apply {
    execute(act(“Like”))
    execute(act(“Dislike”))
    execute(act(“Like”))
}

        Like - Act Started
        Like - Act Canceled
        Dislike - Act Started
        Dislike - Act Canceled
        Like - Act Started
        Like - Act Finished</sorce>
Если у нас есть потребность в таких сложных запросах, мы добавляем два поля: groupStrategy и group ID. group ID — специфичный параметр, потому что для поддержки множества параллельных like/dislike запросов нужно создавать группу для каждой пары запросов, которые относятся к одному объекту. В данном случае можно назвать группу Like-Dislike-PostId и добавить туда ID поста. Каждый раз, когда будем лайкать соседние посты, мы будем уверены, что все работает правильно и для предыдущего поста, и для следующего.

В нашем синтетическом примере мы пытаемся выполнить последовательность like-dislike-like. Когда мы выполняем первое действие, а потом второе — предыдущий отменяется и следующий like отменяет предыдущий dislike. Это то, что я и хотел.

В последнем примере для создания Act’ов мы использовали именованные параметры. Это классно помогает читаемости кода, особенно когда параметров много.

<blockquote>Чтобы легче читалось — используйте именованные параметры.</blockquote>
<h2>Архитектура</h2>
Посмотрим, как это решение может повлиять на нашу архитектуру. На проектах я часто вижу, что View Model или Presenter берут на себя много ответственности, например, хаки, чтобы как-то обработать ситуацию с like/dislike. Обычно вся эта логика во View Model: много дубликатов кода с блокированием кнопок, LifeCycle обработчики, подписки.

<img src="https://lh4.googleusercontent.com/ZLY1RWKgjUBrkg6-lqyZ9f40izl9wo-tUBC7uWqUa_y1UgcmqMUe6ipZebt_V38YNvfSVj1LXm6WydOvRfSqefUdtSfkV2JecuIA-wHQr3JMv1aT5WE_MlASw8HKvjfU7WTCR64i">

Все то, что сейчас делает наш Executor, когда-то было либо в Presenter, либо во View Model. Если архитектура взрослая, разработчики могли выносить эту логику в какие-то интеракторы или use-cases, но логика дублировалась в нескольких местах.

После того, как мы взяли на вооружение Executor, View Model становятся проще и вся логика скрыта от них. Если вы когда-то это выносили в Presenter и интерактор, то знаете, что интерактор и Presenter становятся проще. В целом я остался доволен.

<img src="https://lh6.googleusercontent.com/74L7c4x3mzhvg51Y_OmA1H9OLyfq8lgV7gMGv6o_wUD9udkTyMpHpYBMQ94Dt01F8iCP1vF39CJ3Sr2Tz_FWY7hUYewZ4Xx7pS5I6QM_134q2k4E-yK-KPDrChd1rKJg9VknoVMn">

<h2>Что еще добавить?</h2>
Еще один плюс текущего решения в том, что оно расширяемое. Что бы нам еще хотелось добавить, как разработчикам, которые работают над мобильным приложением, и каждый день борются с ошибками и множеством параллельных запросов?

<h3>Возможности</h3>
За кадром осталась <b>реализация жизненного цикла</b>, но как мобильные разработчики мы все всегда об этом думаем и беспокоимся, чтобы ничего не утекло. Хотелось бы <b>сохранять и восстанавливать </b>запросы перезапуска приложения.

<b>Цепочки вызовов. </b>За счет оборачивания RX-цепочек появляется возможность их сериализации, потому что по дефолту RX никак не сериализуется.

Немногие знают, сколько параллельных запросов выполняется в конкретный момент времени в их приложениях. Я бы не сказал, что это большая проблема для маленьких и средних приложений. Но для большого приложения, которое выполняет много работы в бэкграунде, неплохо понимать причины сбоев и жалоб пользователей. Без дополнительной инфраструктуры у разработчиков просто нет информации, чтобы понять причину: может причина в UI, а может в огромном количестве постоянных запросов в бэкграунде. Мы можем расширить наше решение и добавить какие-то <b>метрики</b>.

Рассмотрим возможности подробнее.

<h3>Обработка жизненного цикла</h3>
<source lang="kotlin">class ActExecutorImpl(
    lifecycle: Lifecycle
) : ActExecutor {

    inir {
    lifecycle.doOnDestroy { cancelAll() }
    }
...

Это пример реализации жизненного цикла. В самом простом варианте — при Destroy фрагментов или отмене при Activity, — мы передаем lifecycle-handler в наш Executor, и при наступлении onDestroy события удаляем все запросы. Это простое решение, которое позволяет не копипастить похожий код во View Models. Примерно то же самое делает LifeData.

Сохранение/Восстановление

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

При этом мы получим возможность оффлайн работы а запросы которые выполнились с ошибками мы перезапустим при появлении интернета. При отсутствии интернета или при ошибках запроса мы сохраняем их в базу, а потом восстанавливаем и опять выполняем. Если вы можете это сделать с обычным RX без дополнительных оберток, пожалуйста, напишите в комментариях, было бы интересно.

Цепочки вызовов

Мы также можем связывать наши Act’ы. Еще один из вариантов расширения — выполнять цепочки запросов. Например, у вас есть одна сущность, которую нужно создать на сервере, а другая сущность, которая зависит от первой, должна быть создана ровно в тот момент, когда мы уверены, что первый запрос выполнился успешно. Это тоже можно сделать. Конечно, это не так тривиально, но, имея класс, который контролирует запуск всех асинхронных задач — возможно. Используя голый RX это сложнее сделать.

Метрики

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

Когда мы можем запускать и останавливать запросы в одном месте, можно добавить, например, трекинг среднего времени выполнения и посмотреть, что после какого-то релиза у нас почему-то в среднем на 10% запросы выполняются дольше. Это тоже неплохая информация, особенно для больших проектов.

Заключение

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

Когда вы разрабатываете решения для мобильных приложений, старайтесь делать маленькие и простые итерации, которые можно завалидировать. Обычная проблема — начинаете рефакторить что-то большое, сделали, проверили — ничего не работает. Почему не работает — непонятно. Изменения большие, вы будете пытаться откатываться назад и все равно итерировать маленькими кусочками. Так что лучше сразу идти маленькими шагами. Мелкие и простые итерации гораздо легче контролировать.

Язык программирования может помочь в дизайне. Используйте крутые фишки Kotlin, которые помогут вам и команде лучше разобраться в языке и эффективнее использовать ваш инструмент. Например, расширять удачные решения в дизайне.

Пока в блоге выходят доклады с AppsConf 2018, Программный комитет принимает в программу конференции AppsConf 2019 новые. В списке принятых докладов уже 38 позиций: архитектура, технологии Android, UX, масштабирование, бизнес-процессы и, конечно, Kotlin.

Следите за анонсами, подписывайтесь на youtube-канал и на рассылку и ждем вас 22–23 апреля на конференции мобильных разработчиков.

Автор: Егор

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js