Допустим, вы решили реализовать Kotlin Multiplatform в своем проекте, чтобы повторно использовать логику на iOS и Android. Рано или поздно вам захочется создать большую функцию, которая будет включать в себя сложную многопоточную логику, сетевые отключения и кэширование.
Каждый из этих этапов вы привыкли делать на своей платформе (ведь вы делали это тысячу раз).
Но в мультиплатформе нет привычных библиотек и подходов, а есть совершенно новый стек и тысяча новых способов элегантно выстрелить себе в ногу.
Яндекс.
Карты и Дмитрий Яковлев якдмт мы прошли тернистый путь реализации функции в мультиплатформе.
— Сначала несколько слов о себе.
Меня зовут Дмитрий Яковлев.
Я работал в нескольких стартапах, в нескольких банках, а сейчас работаю в Яндексе над приложением «Карты для Android».
В то же время я все еще пишу кроссплатформенную логику на Kotlin Multiplatform.
Вспомните самую первую функцию, которую вы запустили в производство.
Даже если она была маленькой, вспомни, как ты гордился собой, как ты был счастлив.
С тех пор, вероятно, прошло много времени; вы запустили десятки функций и зафиксировали тысячи строк кода.
Вы, скорее всего, понимаете, что среднестатистическая большая фича состоит примерно из одних и тех же этапов.
Обычно вам нужно выйти в интернет, проанализировать результат, преобразовать, обработать данные и так далее.
Лучше это делать в фоновом режиме.
Затем помещаем его в кэш и отображаем на экране.
У меня также были лучшие практики, устоявшийся подход к созданию функций.
Но, к сожалению, от всего этого пришлось отказаться, когда пришло время делать мультиплатформенность на Kotlin Multiplatform — старые подходы перестали работать.
Для начала давайте вернемся немного назад, к тому моменту, когда мы задумались о реализации Kotlin Multiplatform. Давайте посмотрим, как мы дошли до этого момента.
Около года назад мы провели небольшой хакатон, на котором разделились на команды и решили написать несколько фич на Kotlin Multiplatform. Некоторые из этих вещей мы позже доделали и запустили в производство.
Например, теперь в приложении Яндекс.
Карты можно увидеть рулетку – или, другими словами, линейку – то есть функцию, с помощью которой можно измерить расстояние между точками.
Он был написан на мультиплатформенном языке Kotlin.
С тех пор мы договорились, что большую часть нового функционала будем делать кроссплатформенным, то есть на Kotlin Multiplatform.
Чего мы хотели? Изначально мы ставим перед собой некоторые цели; мы думали, что если бы кода было меньше, не две кодовые базы, а одна, то ошибок было бы меньше и нам пришлось бы тратить меньше времени на их исправление.
Второй момент: мы считали, что если один разработчик разрабатывает одну фичу, то логика не будет расходиться, человек всегда будет в контексте и отвечать сразу за две платформы.
Еще один важный момент: мы хотели повторно использовать код Android. У нас довольно большая база кода на Android с большим количеством реактивной и сложной логики.
Мы хотели взять его и переиспользовать на iOS, переместив в общую часть.
К тому же это новая технология, нам было интересно в ней разобраться.
Что мы понимаем о Kotlin Multiplatform? Реальный проект намного сложнее всех обучающих программ и статей, которые есть в Интернете.
Всегда есть неожиданные ловушки, неожиданные ошибки и так далее.
Именно об этом пути, о том, как мы построили этот функционал на Kotlin Multiplatform и запустили его в производство, я хотел сегодня поговорить.
Я не буду подробно рассказывать о кроссплатформенном пользовательском интерфейсе, потому что исторически у нас очень разные архитектуры в частях Карт для Android и iOS. Пока мы не пришли к единому решению, которое позволило бы перенести UI на кроссплатформенность.
Но мы поговорим о том, как подготовить многопоточность, какие подходы и библиотеки доступны, о сетевом взаимодействии в общем коде и посмотрим на кеширование — как все это можно сделать в общей части.
Что именно пилим?
Но сначала давайте посмотрим, что мы будем резать, какую фичу мы сделали.Возможно, вы видели, что на Яндекс.
Картах появляются метки с концертами, выставками и кратковременными мероприятиями.
Это именно тот сервис отображения событий на карте, который мы хотели сделать мультиплатформенным — чтобы он под капотом сам выходил в интернет, кешировал и решал, что делать.
А мы ему говорили: «Нарисуй», и он делал все в общей части.
Нам нужен был что-то вроде этого интерфейса EventsService, у которого есть всего два метода — «рисовать на карте» и чтобы он возвращал нажатия на события.
То есть, чтобы на iOS и Android мы могли уловить, когда пользователь нажимает на событие.
Более того, поскольку наша логика и в iOS, и в Android-приложениях использует много реактивной логики, RxSwift и RxJava, нам захотелось ее как-то связать, чтобы она вписывалась в уже работающую на платформах реактивную логику.
Я хотел сделать подключение к платформе Observable, а возможность отмены подписки — чем-то вроде Disposable.
Здесь уже есть тонкость, потому что мы не можем использовать стандартные Observable от RxJava и Observable от RxSwift в общем коде.
Нам нужно сделать его аналог, который будет жить в общей части.
Мы решили это так: сделали ожидаемый класс PlatformObservable, который имеет разные реализации на Android и iOS. Например, на Android это псевдоним RxJava Observable, на iOS — собственная оболочка.
Я хотел бы более подробно рассказать, почему этот класс является абстрактным.
Дело в том, что если вы хотите сделать ожидаемые и актуальные классы, имеет смысл, когда вы можете сделать типалиасы на одной платформе, то есть когда ваш ожидаемый класс в сигнатуре соответствует одному из платформенных.
Это принесет максимальную пользу.
Чтобы сделать его совместимым с Observable из RxJava, нам пришлось сделать класс PlatformObservable абстрактным: как реализацию iOS, так и обертку.
Если этого не сделать, компилятор пожалуется и скажет, что ожидаемое и фактическое не совпадают по модальности, поэтому код не скомпилируется.
Подробнее о том, зачем нужна своя обертка, почему нельзя сразу использовать RxSwift. Дело в том, что общие параметры (Generics), которые есть в Kotlin в iOSMain части, не видны в iOS, потому что код транслируется в Objective-C, а из Objective-C этот параметр не виден.
Чтобы сохранить тип, мы сделали обертку, повторяющую основные методы Observable. Эта оболочка живет только в основной части iOS. Он реализует основные методы: subscribe(), onNext(), onError() и onComplete().
Более того, уже в Swift-части iOS-приложения мы можем конвертировать эту обертку в RxSwift.
Под капотом это выглядит так.
То есть мы создаем RxSwift Observable.
Далее для каждого вызова onNext в Kotlin Observable мы отправляем данные в RxSwift Observable.
Конечно, мы подписываемся.
Эту подписку мы оборачиваем в Disposable уже в RxSwift. Таким образом, каждый элемент сопоставляется, и стандартный параметр не теряется в RxSwift.
Для чего используется диспоузер? Это аналог Disposable от Rx для отмены подписок.
Мы также не можем использовать Disposable в общей части; мы должны принять собственное решение.
Этот интерфейс с одним методом Dispose() служит для соединения двух миров в общей части: общей со своими сопрограммами и так далее, а также платформенной части, которая написана на RxJava/RxSwift. Выглядит это примерно так, то есть в общей части создаём Scope, начинаем там рендерить события, а в Disposer заворачиваем отмену рендеринга и отправляем этот Disposer на платформу.
Уже на платформе мы можем обернуть этот Disposer в стандартный Disposable от RxJava. Таким образом, эти два мира — мир сопрограмм и платформы Rx — для нас связаны.
Многопоточность
Итак, мы поговорили о том, как выглядит функция и API для этой функции.
Но тогда возникает вопрос: как сделать что-то в фоне, асинхронно и многопоточно?
Как перенести на задний план тяжелые операции, например вычисление различий? Наше первое решение: написать логику для обратных вызовов, аналогично AsyncTask. Разработчики Android наверняка помнят, что такой был.
Первая лямбда запускается в фоне, что-то вычисляет, возвращает результат, который приходит на колбек в основной поток.
В Котлине проще всего это сделать с помощью сопрограмм, запуская что-то в Dispatchers.Default и возвращая результат в Dispatchers.Main. Но когда мы попытались это сделать, на Android это сработало, а на iOS — нет. Дело в том, что под капотом у iOS и Android разные рантаймы для многопоточности, у iOS — Kotlin/Native, у Android — стандартная многопоточность JVM, которые, естественно, работают по-разному.
У Kotlin/Native есть некоторые особенности, главная из которых заключается в том, что между потоками можно передавать только неизменяемые объекты.
То есть, когда объект проходит между потоками, он будет заморожен, сделан неизменяемым, и пути назад уже не будет; мы не сможем его разморозить.
В этом случае все, на что ссылается этот объект, также будет заморожено.
Если вы попытаетесь изменить этот объект, который передавался между разными потоками в Kotlin/Native, вы получите исключение InvalidImmutabilityException и приложение выйдет из строя.
Конечно, в Kotlin/Native есть механизмы борьбы с такими ограничениями.
Но все они довольно сложны, поэтому стабильная версия сопрограмм в Kotlin/Native работает только в основном потоке; вы можете запустить их только в Dispatchers.Main. Да, у вас будет асинхронность, но переключаться между потоками вы не сможете.
Конечно, это известная проблема, по ней была создана проблема: поддержка переключения потоков в сопрограммах на Kotlin/Native.
В этот момент, начиная с 1.3.8, 1.3.9, появился форк сопрограмм, то есть наряду со стабильной версией стала поддерживаться версия сопрограмм, где эта многопоточность работает на iOS.
Этот форк будет поддерживаться до тех пор, пока коллеги из JetBrains не перезапишет сборщик мусора.
После этого форк с нативными-mt-корутинами, скорее всего, сольется с основной веткой.
До тех пор вы можете использовать либо стабильную версию без многопоточности, либо Native-MT.
Но когда мы делали нашу фичу, Native-mt-coroutines еще не было, поэтому пришлось делать костыли, чтобы запускать код на iOS в другом потоке.
На Android мы оставили ту же реализацию с сопрограммами, она работает.
На iOS реальная реализация оказалась немного другой.
Мы использовали библиотеку Stately для хранения обратного вызова, который будет выполнен в основном потоке с использованием ThreadLocalRef.
Далее мы запускаем лямбду в фоновом потоке, которая должна выполняться в фоновом режиме.
Получив результат, мы извлекаем наш колбек из ThreadLocalRef и запускаем его.
Таким образом, колбек, который вызывается на главной странице, здесь не зависнет.
Но в то же время блок, выполняющийся в фоновом потоке, все равно будет заморожен, поэтому туда невозможно попасть изменяемому объекту или чему-то, что не следует замораживать.
Таким образом, мы сделали функцию, которая может честно уходить в фон на iOS. Но я хотел использовать его в сочетании с другими сопрограммами.
Его можно было обернуть, а также сделать из него сопрограмму.
Но мы столкнулись с тем, что если обернуть эту сопрограмму в try-catch, то исключение не будет перехвачено и произойдет крах во время выполнения.
Решение здесь:
Вам нужно перехватить исключение в фоновом потоке, то есть поместить try-catch в лямбду фонового потока и передать между потоками собственный объект BackgroundActionResult. Если есть исключение, вызовите метод Continuation.resumeWithException() в основном потоке.
Таким образом, мы можем обернуть наш метод coroutineOnBackground() в try-catch и правильно перехватить исключение без сбоев во время выполнения.
Еще одна тонкость, с которой мы столкнулись при попытке запустить код на iOS: стандартный Диспетчер не работал.
При попытке его использования возникло исключение.
Мне пришлось написать собственный Dispatcher для запуска блоков кода в основном потоке.
Вот как мы запустили сопрограммы на iOS.
Но в то же время, если с таким самописным диспетчером, содержащим задержку, сделать что-то — либо использовать оператор задержки(), либо сделать какой-то дебоунс в потоке — то все сломается.
Так что это не работает. В этом случае компилятор или система ничего вам не скажет, что вы что-то не реализовали.
Здесь также необходимо реализовать интерфейс Delay, у которого есть два метода.
Как только вы их реализуете, вы сможете использовать задержку на iOS.
Но, к счастью, в ветке Native-MT уже есть нормальные диспетчеры, они работают как положено.
Да, есть некоторые пограничные ситуации, при которых могут возникнуть утечки памяти, но если вы не боитесь, то можете использовать версию Native-MT.
Вердикт: в целом с сопрограммами жить можно, но будет намного лучше, когда в мастере появится форк own-mt. В Интернете очень много информации о сопрограммах, в основном от Android-разработчиков, потому что сопрограммы уже достаточно широко используются в Android. По общей части мультиплатформы не так много информации, но она есть.
Сопрограммы поддерживаются разработчиком языка.
Это значит, что будут постоянные обновления, будут исправлены ошибки и так далее, это однозначно плюс.
При этом на iOS и Android логика разная, то есть всегда нужно думать о другой платформе.
Еще одна специфическая для нас боль: потребовалось время на освоение, так как до этого у нас не было сопрограмм в Android-части, они появлялись в общей части и их приходилось осваивать прямо с нуля, так как это совсем другой подход, другой из RxJava.
Когда мы переносили логику из Android-части в общую часть, нам также хотелось перенести рисовальщик карты, то есть сущность, которая может рендерить объекты на карте.
В Android тоже пишется с использованием RxJava, это очень обширный класс, много логики.
Мне хотелось реактивности в общей части.
Примерно так же, как мы пишем на платформе.
Мы использовали поток.
Что мы заметили? Что основные операторы в основном поддерживаются, они есть, хоть и называются немного по-другому, но все равно работают.
Есть различия в логике работы.
Например, flowOn() влияет не на нисходящий поток, как ObserveOn(), а на восходящий поток.
Или, например, Collect(), в отличие от subscribe(), является функцией приостановки, и иногда можно столкнуться с тем, что при вызове двух Collect() подряд второй метод не будет вызываться, потому что приостановка будет происходят в первую очередь.
В этом и заключалось отличие от RxJava и RxSwift. Также на iOS в нашем распоряжении есть только Dispatchers.Main, то есть мы не можем переключать диспетчеры в потоке со стабильной версией сопрограмм.
Поэтому мы выполняли сложные операции с помощью нашей функции coroutineOnBackground().
Таким образом можно было прочитать различия в другом потоке и вернуть результат обратно в основной.
Также мы столкнулись с тем, что в потоке не было некоторых операторов, которые были у нас в Android-части.
Например, невозможно было выполнить replay(), Share() иPublish().
Пришлось искать обходные пути и писать собственную логику, используя Channel и Flow, свои велосипеды.
Но, к счастью, все меняется; уже в версии 1.4 появился Shared Flow, аналог горячего Observable в RxJava. Это позволяет вам использовать функции replay(), Share() и т. д.
Каков общий расход? Информация об этом тоже есть в интернете, в основном от Android-сообщества, поведение на iOS и Android тоже разное.
То есть здесь те же ограничения, что и у сопрограмм на Kotlin/Native. Но все меняется в ветке Native-MT. В то же время нам также пришлось выделить время, чтобы во всем разобраться.
Подход все же другой, это не Rx, и код из Android-части не удалось перенести без серьезного рефакторинга.
Там, где у нас были сложные цепочки, приходилось аккуратно всё переписывать в потоке, учитывая при этом особенности iOS, то есть зависания и так далее.
Ребята из других команд спрашивали: «Почему вы не используете ReaktiveЭ» Это библиотека для многопоточности на мультиплатформе.
Нам стало очень интересно, и мы решили исследовать.
Прежде чем внедрять его в рабочий код, нужно было ответить на несколько вопросов.
Первый вопрос: насколько Reaktive API отличается от RxJava? На первый взгляд изменений не так много; почти все из них вы можете увидеть на слайде.
Поддерживаются базовые операторы, есть улучшения, можно передавать null, в отличие от RxJava. Но при этом, например, нет выбора стратегии разрешения противодавления, то есть нет текучести.
Но есть функцииPublish() и Connectable(), а это именно то, чего нам не хватало, когда мы переписывали логику для потока.
Портирование оказалось довольно простым; код перешёл из RxJava практически без изменений.
Пришлось поменять импорт и некоторые функции и на Андроиде все легко завелось, почти как есть.
При этом на iOS нам приходилось следить за заморозкой объектов, то есть любые коллбэки и методы doOnSomething() в Reaktive будут заморожены при переключении потоков, поэтому туда также не должны попадать никакие изменяемые объекты или ссылки на такие объекты, иначе они будут заморожены.
В Reaktive есть оператор threadLocal(), который позволяет обойти зависание.
Это можно использовать, например, совместно с ktor. Теперь с нативными-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. Если вы не боитесь пограничных случаев или утечек, вы можете использовать его.
Сопрограммы подходят для простых задач.
Если вам нужна более сложная логика или вы пишете с нуля, вы можете использовать поток.
Reaktive очень хорошо работает для переноса больших объемов кода с Android. Если вам нужно переписать кучу логики из RxJava, вы легко можете использовать ее в общей части.
Сеть
В Интернете нам нужно запросить объекты, чтобы отобразить их на карте.Когда мы разрабатывали эту функцию, мы рассматривали два подхода.
Первый подход заключается в создании ожидаемого класса, фактическая реализация которого будет разной на каждой платформе: под капотом на Android будет использоваться некоторый OkHttp, а на iOS будут использоваться некоторые стандартные инструменты.
Однако стало ясно, что логика на наших платформах может расходиться и использовать как можно больше кода явно не получится.
Итак, мы посмотрели на ktor. Это практически единственный HTTP-клиент, доступный в Kotlin Multiplatform.
Он запустился довольно быстро и легко, и мы скачали клиент OkHttp с платформы.
В результате получается клиент, который уже преднастроен в Android-части, к нему прикреплены мониторинг, перехватчики, куча логики, и его можно было без изменений отправить на мультиплатформу.
Но при этом мы заметили особенность: во время редиректов перехватчики срабатывали дважды.
Мне пришлось отключить перенаправления в HTTP-клиенте ktor и включить их в OkHttp. Таким образом, получилось, что перехватчики сработали один раз.
Что еще мы заметили, когда попробовали использовать ktor? Первое: в тот момент его невозможно было запустить на потоке, отличном от основного.
Сам запрос, конечно, происходил в другом потоке — в фоне, но при этом все обратные вызовы выполнялись в основном потоке.
Теги: #Разработка для iOS #Разработка для Android #Разработка мобильных приложений #Kotlin #Flow #многопоточность #kotlin multiplatform #kotlin multiplatform #coroutines
-
Как Ux-Писатель Помогает Улучшить Продукт
19 Oct, 24 -
Новый День — Новый Язык!
19 Oct, 24 -
Одно Из Лучших Расширений Для Chrome.
19 Oct, 24 -
Расширение Реальности
19 Oct, 24