Открыть список
Как стать автором
Обновить
460,12
Рейтинг
Яндекс
Как мы делаем Яндекс

Готовим Большую Фичу™ на Kotlin Multiplatform. Доклад Яндекса

Блог компании ЯндексРазработка под iOSРазработка мобильных приложенийРазработка под AndroidKotlin
Предположим, вы решили внедрить Kotlin Multiplatform в свой проект, чтобы переиспользовать логику на iOS и Android. Рано или поздно вы захотите сделать Большую Фичу, которая будет включать в себя и сложную многопоточную логику, походы в сеть, кэширование. Каждый из этих этапов вы привыкли делать на своей платформе (ведь делали это тысячу раз). Но в мультиплатформе нет привычных библиотек и подходов, зато есть абсолютно новый стек и тысяча новых способов элегантно выстрелить себе в ногу. Яндекс.Карты и Дмитрий Яковлев yakdmt прошли тернистый путь реализации фичи в мультиплатформе.

— Для начала пару слов о себе. Меня зовут Дмитрий Яковлев. Я поработал в нескольких стартапах, в нескольких банках, а сейчас работаю в Яндексе над Android-приложением Карт. При этом еще немного пишу на Kotlin Multiplatform кроссплатформенную логику.



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

У вас, скорее всего, сложилось понимание, что среднестатистическая большая фича состоит примерно из одинаковых этапов. Обычно нужно сходить в сеть, распарсить результат, сконвертировать, обработать данные и так далее. Лучше это делать в фоне. Дальше мы кладем это в кэш и показываем на экране.

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

Для начала вернемся немного назад, в момент, когда мы думали над внедрением Kotlin Multiplatform. Посмотрим, как мы к этому пришли. Около года назад мы сделали небольшой хакатон, в котором разделились на команды и решили запилить несколько фич на Kotlin Multiplatform. Некоторые из этих вещей мы потом доделали и запустили в продакшен. Например, сейчас в приложении Яндекс.Карт вы можете увидеть рулетку — или, по-другому, линейку, — то есть фичу, с помощью которой можно измерить расстояние между точками. Как раз она написана на Kotlin Multiplatform.

С тех пор мы договорились, что будем большинство новой функциональности делать кроссплатформенно, то есть на Kotlin Multiplatform.



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

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

Еще одни важный момент: мы хотели переиспользовать код из Android. У нас достаточно большая кодовая база на Android, много реактивной и сложной логики. Мы хотели взять ее и переиспользовать на iOS с помощью переноса в common-часть. К тому же это новая технология, нам было интересно с ней разобраться.



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



Я не буду подробно рассказывать про кроссплатформенный UI, потому что исторически сложилось, что в Android- и iOS-части Карт у нас очень разная архитектура. Пока мы не пришли к единому решению, которое позволило бы нам перенести UI в кроссплатформу. Но зато мы поговорим о том, как готовить многопоточность, какие есть подходы и библиотеки, о сетевом взаимодействии в common-коде и взглянем на кэширование — как все это можно сделать в common-части.

Что, собственно, пилим?


Но для начала давайте посмотрим, что мы будем пилить, что за фичу мы сделали.

Возможно, вы видели — в Яндекс.Картах появляются пины с концертами, выставками, с короткоживущими событиями. Как раз этот сервис по показу событий на карте мы и хотели сделать в мультиплатформе — чтобы он под капотом сам ходил в сеть, кэшировал, решал, что ему делать. А мы бы говорили ему: «Рисуй», — и он бы делал всё в common-части.



Нужен был примерно такой интерфейс, EventsService, у которого всего два метода — «нарисуй на карте» и чтобы он возвращал тапы на события. То есть чтобы мы на iOS и Android отлавливали, когда пользователь тапает на событие. Причем, так как логика у нас и в iOS-, и в Android-приложении использует много реактивщины, RxSwift и RxJava, то хотелось как-то связать, чтобы это ложилось на реактивную логику, которая уже действует на платформах. Хотелось связь платформы сделать как Observable, а возможность отменять подписку — как нечто типа Disposable.



Здесь уже есть тонкость, потому что мы не можем использовать стандартный Observable из RxJava и Observable из RxSwift в common-коде. Мы должны сделать его аналог, который будет жить в common-части.



Мы решили это так: сделали expect-класс PlatformObservable, у которого разные реализации на Android и iOS. Например, на Android это typealias для RxJava Observable, на iOS — собственная обертка.

Я хотел бы подробнее рассказать о том, почему этот класс абстрактный. Дело в том, что, если вы хотите делать expect- и actual-классы, это имеет смысл тогда, когда вы на одной платформе можете сделать typealias, то есть когда ваш expect-класс в сигнатуре совпадает с одним из платформенных. Это даст наибольшую пользу.

Чтобы как раз сделать его совместимым с Observable из RxJava, пришлось сделать класс PlatformObservable абстрактным: и iOS-реализацию, и обертку.



Если этого не сделать, компилятор будет ругаться, скажет, что expect и actual не совпадают по модальности, поэтому код не скомпилируется.





Подробнее о том, зачем нужна собственная обертка, почему нельзя использовать сразу RxSwift. Дело в том, что типовые параметры (Generics), которые есть в Kotlin в iOSMain-части, не видны в iOS, потому что код транслируется в Objective-C, и из Objective-C этот параметр не видно. Для сохранения типа мы и сделали такую обертку, которая повторяет основные методы Observable. Эта обертка живет только в части iOSMain. В ней реализованы основные методы: subscribe(), onNext(), onError() и onComplete().



При этом уже в Swift-части iOS-приложения мы можем сконвертировать эту обертку в RxSwift.



Под капотом это выглядит следующим образом.



То есть мы создаем RxSwift Observable.



Далее на каждый вызов onNext в Kotlin Observable мы отправляем данные в RxSwift Observable.



Конечно, подписываемся.



Эту подписку заворачиваем в Disposable уже в RxSwift. Таким образом происходит маппинг каждого элемента и в RxSwift не теряется типовой параметр.



Для чего нужен Disposer? Это такой аналог Disposable из Rx для отмены подписок. Disposable мы тоже не можем использовать в common-части, мы должны сделать свое решение.



Это интерфейс одним методом dispose() служит для того, чтобы соединять в common-части два мира: common с его корутинами и так далее, а также платформенную часть, которая написана на RxJava/RxSwift. Это выглядит примерно так, то есть в common-части мы создаем Scope, запускаем там рендеринг событий, а в Disposer заворачиваем отмену отрисовки и этот Disposer отдаем на платформу.



Уже на платформе мы можем завернуть этот Disposer в стандартный Disposable из RxJava. Таким образом эти два мира — мир корутин и платформенный Rx — у нас связаны.

Многопоточность


Итак, мы поговорили о том, как выглядит фича и API для этой фичи. Но дальше встает вопрос: как сделать что-то в фоне, асинхронно и многопоточно?



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

Первая лямбда запускается на бэкграунде, что-то считает, возвращает результат, который приходит в коллбэк на главном потоке. В Kotlin это легче всего сделать на корутинах, запускать что-то на Dispatchers.Default и возвращать результат на Dispatchers.Main.

Но когда мы попробовали это сделать, то на Android это заработало, а на iOS — нет. Дело в том, что под капотом на iOS и на Android разные рантаймы для многопоточности, на iOS — это Kotlin/Native, на Android — стандартная JVM-многопоточность, которые, конечно, работают по-разному.



В Kotlin/Native есть некоторые особенности, основная из которых заключается в том, что только иммутабельные объекты можно передавать между потоками. То есть при переходе объекта между потоками он будет заморожен, сделан иммутабельным, и обратной дороги уже не будет, разморозить мы его не сможем. При этом всё, на что ссылается этот объект, также будет заморожено. Если попробовать мутировать этот объект, который перешел между разными потоками в Kotlin/Native, то вы получите InvalidImmutabilityException и приложение упадет.



Конечно, в Kotlin/Native есть механизмы для работы с такими ограничениями. Но все они достаточно сложные, поэтому стабильная версия корутин в Kotlin/Native работает только на главном потоке, вы можете запускать только на Dispatchers.Main. Да, у вас будет асинхронность, но при этом между потоками вы переключиться не сможете.

Конечно, это известная проблема, по поводу нее был создан issue: поддержать переключение потоков в корутинах на Kotlin/Native.



В этот момент, начиная с 1.3.8, 1.3.9, у корутин появился форк, то есть наряду со стабильной версией начала поддерживаться версия корутин, где эта многопоточность работает на iOS.

Этот форк будет поддерживаться, пока коллеги из JetBrains не перепишут сборщик мусора (Garbage Collector). После этого форк с native-mt-корутинами, скорее всего, вольется в мастер-ветку. До тех пор можно использовать либо стабильную версию без многопоточности, либо native-mt.



Но когда мы делали свою фичу, то native-mt-корутин еще не было, поэтому нам пришлось делать костыли, чтобы запускать код на iOS в другом потоке. На Android мы оставили ту же реализацию с корутинами, она работает.



На iOS actual-реализация получилась немного другой.



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



Далее запускаем в фоновом потоке лямбду, которая должна быть выполнена в фоне.



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



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



Таким образом мы сделали функцию, которая умеет честно ходить в фон на iOS. Но хотелось использовать ее в связке с другими корутинами. Ее получилось обернуть и тоже сделать из нее корутину.

Но мы столкнулись с тем, что если обернуть эту корутину в try-catch, то исключение не будет поймано, у вас случится падение в рантайме.



Решение здесь такое:



Нужно отлавливать исключение в фоновом потоке, то есть поставить try-catch в лямбде для фонового потока и прокидывать между потоками уже какой-то свой объект, BackgroundActionResult. Если есть исключение, вызывать на главном потоке метод Continuation.resumeWithException(). Таким образом мы можем обернуть наш метод coroutineOnBackground() в try-catch и корректно отловить исключение, не получая падения в рантайме.



Еще одна тонкость, с которой мы столкнулись, когда попытались запустить код на iOS: стандартный Dispatcher не работал.



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



Но при этом если с таким самописным диспатчером сделать нечто содержащее задержку — либо использовать оператор delay(), либо во flow сделать какой-нибудь debounce, — то все сломается.



Так что это не работает. При этом компилятор или система вам ничего не скажет о том, что у вас что-то не реализовано. Здесь нужно реализовать еще интерфейс Delay, в котором есть два метода. Реализовав их, вы сможете использовать задержку на iOS.



Но, к счастью, в ветке native-mt уже доступны нормальные диспатчеры, они работают так, как ожидается. Да, там есть некоторые пограничные ситуации, в которых могут происходить утечки памяти, но если вам не страшно, тогда можно использовать native-mt версию.



Вердикт: в целом с корутинами можно жить, но станет гораздо лучше, когда форк native-mt появится в мастере. По корутинам в сети есть куча информации, в основном от Android-разработчиков, потому что в Android корутины уже достаточно широко используются. По common-части мультиплатформы не так много информации, но она есть.

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

Еще одна конкретно наша боль: понадобилось время на освоение, потому что до этого в Android-части у нас не было корутин, они появились в common-части и нужно было прямо с нуля осваивать, так как это совершенно другой подход, отличный от RxJava.



Когда мы переносили логику из Android-части в common, нам хотелось перенести также и рисовалку на карте, то есть сущность, которая умеет рендерить объекты на карте. В Android она тоже написана с использованием RxJava, это очень обширный класс, очень много логики. Хотелось реактивности в common-части. Примерно так, как мы пишем на платформе.



Мы использовали flow. Что мы заметили? Что базовые операторы в основном поддержаны, они есть, хотя называются немного по-другому, но тем не менее работают.



Есть отличия и в логике работы. Например, flowOn() влияет не на downstream, как observeOn(), а на upstream. Или, например, collect(), в отличие от subscribe(), — это suspend-функция и иногда можно напороться на то, что при вызове двух подряд collect() второй метод не вызовется, потому что произойдет приостановка (suspend) в первом. В этом и были отличия от RxJava и RxSwift.

Также на iOS в нашем распоряжении только Dispatchers.Main, то есть мы не можем переключать диспатчеры во flow со стабильной версией корутин.



Поэтому сложные операции мы сделали с помощью нашей функции coroutineOnBackground(). Таким образом получилось считать диффы на другом потоке и возвращать результат обратно на главный.



Также столкнулись с тем, что во flow не было некоторых операторов, которые были у нас в Android-части. Например, нельзя было сделать replay(), share() и publish(). Приходилось идти обходными путями и писать свою логику с использованием Channel и flow, свои велосипеды.

Но, к счастью, все меняется, уже в версии 1.4 появился Shared flow, аналог горячего Observable в RxJava. Он позволяет использовать функционал replay(), share() и так далее.



Какой итог по flow? О нем тоже есть информация в сети, в основном от Android-сообщества, и тоже отличается поведение на iOS и Android. То есть здесь те же самые ограничения, что и у корутин на Kotlin/Native. Но все меняется в ветке native-mt. При этом нам также пришлось закладывать время на то, чтобы разбираться. Подход все-таки другой, это не Rx, и код из Android-части не получилось перенести без серьезного рефакторинга. Там, где у нас были сложные цепочки, нужно было аккуратно всё переписывать на flow, при этом помнить про особенности iOS, то есть про заморозку и так далее.



Ребята из других команд спрашивали: «Почему вы не используете Reaktive?» Это такая библиотека для многопоточности в мультиплатформе. Мы действительно заинтересовались и решили исследовать. Было несколько вопросов, на которые нужно было ответить перед тем, как внедрять ее себе в продакшен-код.



Первый вопрос: насколько отличается API Reaktive от RxJava? На первый взгляд, изменений не так много, практически все из них вы видите на слайде. Основные операторы поддержаны, есть улучшения, можно передавать null, в отличие от RxJava. Но при этом, например, нет выбора стратегии разрешения backpressure, то есть нет flowable. Зато есть publish() и connectable(), как раз то, чего нам не хватало, когда мы переписывали логику на flow.



Портировать получилось достаточно легко, код с RxJava переехал практически без изменений. Понадобилось поменять импорты и некоторые функции и все легко завелось на Android, практически как есть. При этом на iOS пришлось проследить за заморозкой объектов, то есть любые коллбэки и методы doOnSomething() в Reaktive будут заморожены при переключении потоков, поэтому туда также не должны попадать никакие изменяемые объекты, никакие ссылки на такие объекты, иначе они будут заморожены.

Есть оператор threadLocal() в Reaktive, который позволяет обойти заморозку. Это можно использовать, например, в связке с ktor. Сейчас с native-mt-корутинами стало получше, но все равно есть некоторые ограничения.

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

Смотреть видео



О производительности. Мы заинтересовались и увидели, что бенчмарки нельзя запустить на iOS. Там были бенчмарки для JVM, но на iOS нам пришлось самим сделать сэмпл и запустить на iOS логику, аналогичную той, что используется в JVM-бенчмарках. При этом поначалу не все было гладко.

Мы нашли проблему, написали о ней ребятам, то есть приложили и сказали: «У вас есть аномалии с производительностью». Ребята буквально за пару дней выкатили фикс, в котором уже эта проблема была решена. Таким образом мы тоже косвенно поучаствовали в улучшении Reaktive.



Каков итог? Нам показалось, что это очень простая интеграция, достаточно заменить импорты, и на Android это все заводится. Но для iOS надо аккуратно посмотреть, не захватываете ли где-то мутабельные объекты. При этом сейчас мы широко не используем Reaktive, поставили его интеграцию на паузу и сделали ставку на flow, потому что все библиотеки от JetBrains изначально идут с suspend-функциями либо с flow в API и получается, что нужна еще конвертация в Reaktive Observable, а потом еще конвертация в нечто подходящее для платформы (iOS/Android). Пока используем в основном flow, но ждем, когда поменяется модель памяти в Kotlin/Native и часть ограничений, с которыми мы столкнулись в Reaktive, уйдет.



Какие итоги по многопоточке? Асинхронное выполнение уже сейчас возможно, оно работает, а многопоточность на iOS — в native-mt-ветке. Если не боитесь граничных случаев или утечек, можно использовать. Для простых задач подходят корутины. Если хочется более сложной логики или вы пишете с нуля, то можно использовать flow. Для миграции больших объемов кода с Android очень хорошо заходит Reaktive. Если вам нужно переписать кучу логики с RxJava, можно легко использовать ее в common-части.

Сеть


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



Первый подход — сделать expect-класс, у которого actual-реализации будут разные на каждой из платформ: под капотом на Android будет использоваться какой-нибудь OkHttp, а на iOS — что-нибудь из стандартных средств. Однако стало понятно, что логика на платформах у нас может разъехаться и использовать максимум кода явно не получится.



Поэтому мы посмотрели на ktor. Это практически единственный HTTP-клиент, который доступен в Kotlin Multiplatform.



Завелся он достаточно быстро, легко, при этом мы прокинули OkHttp-клиент с платформы. Получился клиент, который уже преднастроен в Android-части, у него есть мониторинги, интерцепторы, на него навешана куча логики, его без изменений получилось отправить в мультиплатформу. Но при этом мы заметили особенность: при редиректах интерцепторы срабатывали дважды. Пришлось выключить редиректы в HTTP-клиенте ktor и включить их в OkHttp. Таким образом получилось, что интерцепторы срабатывали по одному разу.



Что еще мы заметили, когда попытались использовать ktor? Первое: в тот момент нельзя было его запустить на другом потоке, кроме главного. Сам запрос, конечно, происходил в другом потоке — в фоне, но при этом все коллбэки выполнялись на главном потоке. То есть десериализация JSON также происходила в главном потоке.



Нам очень хотелось это поменять, и мы сделали это с помощью подмены стандартной стратегии сериализации JsonFeature на свою реализацию — BackgroundJsonFeature.



Мы просто скопировали код из JsonFeature и подменили в нем одну строчку: сделали десериализацию в фоне, используя функцию coroutineOnBackground().



С какими еще проблемами мы столкнулись? Все-таки сериализация работает на платформах по-разному. Например, на Android всё окей, на iOS возникает ошибка. Эти ошибки чинятся и, надо признать, достаточно быстро, но нужно всегда проверять поведение на обеих платформах. Заметили, что не работает сериализация inline-классов. Inline-классы — экспериментальная штука, но тем не менее. Столкнулись с тем, что нельзя использовать вычисляемую переменную на iOS: если вы добавите val, который вычисляется, то на Android у вас все скомпилируется, а на iOS вы получите ошибку компиляции.



Резюме — решение ktor и kotlinx подойдет, наверное, для 99% кейсов, при этом оставшийся 1% — это те случаи, когда у вас очень кастомный, свой сетевой стек. Тогда вам подойдет подход с expect-классом и actual-реализацией на обеих платформах. При этом у ktor- и kotlinx-сериализации хорошая поддержка, и проблемы чинятся достаточно быстро. Не стоит забывать, что нужно потратить время, если вы переходите с другого стека. Если у вас на Android был один сетевой стек, для него была куча обвязок, интерцепторов и так далее, то сейчас для common-части нужно сделать все то же самое, только для ktor.

Кэширование




Для кэширования есть много библиотек, они предназначены для разных кейсов. Если SQL-база, SQL Delight. Можно сделать даже HTTP-кэширование в ktor, если вам не нужно, чтобы кэш переживал перезапуск.



Мы написали свой кэш, использовали запись и чтение из файлов. Как мы это сделали? Всю логику, отвечающую за сериализацию и десериализацию объектов, мы оставили в common-части в классе EventsCacheService. Там же оказалась и проверка на то, протух кэш или нет. EventsCacheService принимает в себя класс PersistentCache — это как раз реализация, которая приходит с платформы. Интерфейс содержит всего лишь три метода — запиши, прочитай и удали файлик. EventsCacheService из common-части использует эти методы, которые реализованы на платформах по-разному. То есть на iOS это стандартные запись-чтение файлов, а на Android использовали библиотеку OkIO под капотом.



Таким образом у нас завелся файловый кэш, но хотелось не ходить за файлами каждый раз, а чтобы последнее закэшированное значение уже было в памяти. Здесь не обошлось без тонкостей, которые связаны с Kotlin/Native, то есть последнее кэшированное значение — это AtomicReference, при этом за файликом мы тоже ходим в фоне с помощью coroutineOnBackground(). И перед тем, как обновить последнее кэшированное значение, его нужно заморозить, чтобы не возникло исключения на iOS.

Подытожим




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



Давайте вернемся к целям, которые мы себе ставили. Самая большая и амбициозная цель — перенести код из Android в common-часть. Здесь мы поняли, что здесь не обойдется без рефакторинга: либо серьезного, как в случае с flow, либо небольшого, как в случае с Reaktive.

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

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


Ссылка со слайда

Напоследок хочу сказать, что Kotlin Multiplatform очень быстро меняется. Буквально пару месяцев назад сборка и отладка iOS-приложения внутри Android Studio была невозможна, а сейчас уже можно ее сделать. Также можно применять многопоточные корутины в iOS, если вы используете native-mt-ветку вместо стабильной. Ребята работают над новой моделью памяти, она тоже снимет часть ограничений Kotlin/Native. По ссылке вы можете почитать планы JetBrains по развитию Kotlin Multiplatform.

На этом всё, спасибо вам за внимание. Используйте Kotlin Multiplatform — это отличная технология.
Теги:kotlin multiplatformмногопоточностькорутиныflow
Хабы: Блог компании Яндекс Разработка под iOS Разработка мобильных приложений Разработка под Android Kotlin
Всего голосов 16: ↑14 и ↓2 +12
Просмотры3.3K

Комментарии 2

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

Похожие публикации

Android-разработчик в Маркет
ЯндексМоскваМожно удаленно
iOS разработчик супераппа Яндекс
ЯндексМоскваМожно удаленно
iOS-разработчик в Маркет
ЯндексМоскваМожно удаленно
Android разработчик в Еду
ЯндексМожно удаленно

Лучшие публикации за сутки