Kotlin Native. Работаем с потоками с корутинами и без. Ч1

от автора

Всем доброго дня! С вами Анна Жаркова, ведущий мобильный разработчик компании Usetech. Совсем недавно компания JetBrains выпустила preview новой модели управления памятью. Это отличный повод сделать подробный ее разбор на практике, а также сравнить с моделью, используемой в KMM в текущих версиях. Но для начала неплохо было бы поговорить о тех возможностях работы в Kotlin Native, которые мы на практике не используем.

Если вы уже знакомы с тем, как работать с корутинами в Kotlin/Native и/или читали предыдущие статьи 1 и 2 автора, то пролистайте немного вниз. Материал может немного повторяться.

Когда мы работаем с Kotlin и Kotlin Multiplatform, то самым и простым удобным способом для настройки работы с многопоточностью в приложении являются Kotlin Coroutines. Наша задача сводится к настройке скоупов CoroutineScope для запуска корутин и suspend функций в основном потоке и фоновом. Т.к. в разных платформенных версиях языка Kotlin этот механизм реализуется по-разному, то необходимо кастомизировать получение контекста корутин с помощью expect/actual:

expect val defaultDispatcher: CoroutineContext  expect val uiDispatcher: CoroutineContext

В случае Kotlin JVM и Android у нас проблем нет (у Kotlin JVM вообще проблем нет, поэтому мы его и не рассматриваем) :

actual val uiDispatcher: CoroutineContext     get() = Dispatchers.Main  actual val defaultDispatcher: CoroutineContext     get() = Dispatchers.Default

Мы просто используем доступные диспетчеры корутин Main и Default (можно взять IO).

Но в случае iOS и Kotlin Native не все так просто. Мы, конечно, можем использовать те же самые диспетчеры Main и Default, но при переключении контекста запроса мы получим исключение:

Дело в том, что для того, чтобы мы могли передавать изменяемые блоки кода и объекты между потоками, нам нужно их замораживать перед передачей с помощью команды freeze() . Делать это, конечно, удобнее при скрытом переключении контекста. Поэтому мы создадим свои диспетчеры, которые будут обращаться к нужным DispatchQueue, и будем использовать их как контексты корутин:

actual val defaultDispatcher: CoroutineContext get() = IODispatcher  actual val uiDispatcher: CoroutineContext get() = MainDispatcher  private object MainDispatcher: CoroutineDispatcher(){     override fun dispatch(context: CoroutineContext, block: Runnable) {         dispatch_async(dispatch_get_main_queue()) {             try {                 block.run().freeze()             }catch (err: Throwable) {                 throw err             }         }     } } //Background private object IODispatcher: CoroutineDispatcher(){     override fun dispatch(context: CoroutineContext, block: Runnable) {         dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0.toULong())) {             try {                 block.run().freeze()             }catch (err: Throwable) {                 throw err             }         }     }

Но если для основного потока использование такого MainDispatcher корректно, для для фонового потока мы не сможем работать с dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0.toULong()), потому что эта очередь не привязана ни к одному потоку. Поэтому нам стоит поменять вызов на MainDispatcher для обоих контестов.

actual val defaultDispatcher: CoroutineContext get() = MainDispatcher  actual val uiDispatcher: CoroutineContext get() = MainDispatcher   @ThreadLocal private object MainDispatcher: CoroutineDispatcher(){     override fun dispatch(context: CoroutineContext, block: Runnable) {         dispatch_async(dispatch_get_main_queue()) {             try {                 block.run().freeze()             }catch (err: Throwable) {                 throw err             }         }     }

Аннотация ThreadLocal используется для корректного шаринга неизменяемого объекта (синглтона) между разными потоками.

Кого-то может смутить корректность получившегося решения. Но так советуют разработчики технологии:

В качестве примера корректности такого поведения разбирается асинхронная работа с сетевыми запросами через библиотеку Ktor. Действительно большинство случаев, когда нам в мобильном приложении нужно работать с разными потоками, сводятся к сетевым запросам. И при разработке с помощью Kotlin Multiplatform в основном все мы используем готовую библиотеку Ktor, которая уже реализует всю асинхронную работу под капотом.

А что же нам делать, если наша асинхронная работа не связана с сетевыми запросами, и/или мы не хотим использовать Ktor? Да, многопоточные корутины под Kotlin/Native не поддерживаются (вернее, поддерживаются, но не в основных ветках, а, например, реализациях «1.5.0-native-mt»), но нам же надо что-то делать.

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

//Не рекомендовано for i in 0..10 { GlobalScope.launch {    work(i) } }  //Workaround  val jobs = mutableListOf<Job>() for i in 0..10 {   jobs += GlobalScope.launch {     work(i)   } } jobs.forEach{ it.join() }

Что же нам делать в таком случае? Обратимся к опыту предшественников, т.е Ktor, а также документации по многопоточности в Kotlin Native.

Все, чего нам не хватает при работе с корутинами и скоупами корутин в Kotlin Native, делается некоторыми другими средствами, доступными в самом Kotlin Native из-под коробки. Давайте же разберем их, а заодно и посмотрим, как написать свой сетевой клиент под iOS именно средствами Kotlin Native.

В данном случае будем делать разные реализации классов сетевых клиентов под iOS и Android не с помощью expect/actual, а путем создания классов с общим интерфейсом, через который мы и будем к нашим клиентам обращаться:

interface IHttpClient {     fun request(request: Request, completion: (Response)->Unit) }  //Android with OKHttp class HttpClientAndroid: IHttpClient { /**...*/ }  //iOS with NSUrlSession class HttpClientIOS: IHttpClient {  /**...*/  }  

Для iOS будем использовать тот же механизм NSURLSession, что и при нативной разработке, т.к весь Network фреймворк нам доступен в нативном модуле IOS общей части. Единственное, что из-за смены контекстов мы можем работать с сессией только через делегат:

class HttpEngine : ResponseListener {     private var completion: ((Response) -> Unit)? = null      fun request(request: Request, completion: (Response) -> Unit) {         this.completion = completion          val urlSession =             NSURLSession.sessionWithConfiguration(...)          val urlRequest =             NSMutableURLRequest(NSURL.URLWithString(request.url)!!)         // background             val task = urlSession.share().dataTaskWithRequest(               urlRequest)             task?.resume()     }     override fun receiveData(data: NSData) {       /**       Main block       */     }

В качестве решения для выполнения кода в фоновом потоке мы рассмотрим Worker. Worker является механизмом Kotlin Native для работы с многопоточностью из-под коробки. Каждый worker представляет собой некую очередь работы, с помощью которой можно выполнять некоторые задачи в отдельных потоках. И на каждый worker создается свой отдельный поток:

internal fun background(block: () -> (Any?)) {     val future = worker.execute(TransferMode.SAFE, { block.share() }) {         it()     }     collectFutures.add(future) } private val worker = Worker.start() private val collectFutures = mutableListOf<Future<*>>()

Future создается при запуске некоторого функционального блока, который мы передаем в worker.execute для выполнения в producer. Мы также можем отследить выполнение future с помощью сохранения в коллекцию ссылки на него (главное, не забыть потом очистить). Чтобы наш блок выполнялся потокобезопасно, используем параметр TransferMode.SAFE и используем расширение для заморозки нашего блока:

internal fun <T> T.share(): T {     return this.freeze() }

Также мы можем получить из нашего future результат нашей работы с помощью consume и отправить его в некоторый callback-блок:

internal fun background(block: () -> (Any?), callback: (Any?)->Unit) {     val future = worker.execute(TransferMode.SAFE, { block.share() }) {         it()     }     future.consume {         main {             callback(it)         }     }     collectFutures.add(future) }

Мы можем вызвать выполнение нашего callback и в главном потоке. Для этого создаем обертку, где мы DispatchQueue.Main будем вызывать наш замороженный блок:

internal fun main(block:()->Unit) {     block.share().apply {         val freezedBlock = this         dispatch_async(dispatch_get_main_queue()) {             freezedBlock()         }     } }

Еще один момент, прежде, чем мы применим наш worker к коду. Чтобы мы не столкнулись с InvalidMutabilityException: mutation attempt of frozen, нам потребуется заморозить практически все параметры, которые мы используем для нашего сетевого запроса:

fun request(request: Request, completion: (Response) -> Unit) {   /**....*/    val urlSession =             NSURLSession.sessionWithConfiguration(                 NSURLSessionConfiguration.defaultSessionConfiguration,                responseReader.share(),                 delegateQueue = NSOperationQueue.currentQueue()             )  /**....*/         background {             val task = urlSession.share().dataTaskWithRequest(urlRequest)             task?.resume()         }     }

И теперь нам надо как-то собирать ответ, который может прийти не весь сразу, а порциями, в массив байтов:

 private var chunks = ByteArray(0)     override fun receiveData(data: NSData) {         updateChunks(data)     }      private fun updateChunks(data: NSData) {         chunks += data.toByteArray() //!CRASH!!!!     }   

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

На помощь нам приходит API AtomicReference. Это не костыль, а полноценное, рекомендуемое и рабочее решение. AtomicReference держит ссылку на замороженный объект, который можно изменять. Для этого добавим расширение, превращающее наши объекты в атомарные, и изменим код:

internal fun <T> T.atomic(): AtomicReference<T>{     return AtomicReference(this.share()) }  //iOS Client private val chunks = ByteArray(0).atomic()  private fun updateChunks(data: NSData) {         var newValue = ByteArray(0)         newValue += chunks.value         newValue += data.toByteArray()         chunks.value = newValue.share()   }

Также добавим очистку AtomicReference после окончания работы, чтобы предотвратить захламление памяти и утечки:

 private fun clear() {         clearChunks()         completion = null     }      private fun clearChunks() {        chunks.value = ByteArray(0).share()     }

В принципе готово. Мы сможем обращаться к нашему клиенту таким образом:

actual class HttpClient : IHttpClient {     val httpEngine = HttpEngine()      /**     * @param request наш запрос с внутренними параметрами     */     actual override fun request(request: Request, completion: (Response) -> Unit) {          httpEngine.request(request) {             completion(it)         }     }

В итоге мы обошлись без скоупов корутин при запросе. И реализовали собственный асинхронный клиент iOS. Но это еще не все, что мы можем сделать в следующей части мы рассмотрим, как работать с DetachedObjectGraph и использовать Kotlin Flow для удобства.

https://github.com/anioutkazharkova/kotlin_native_network_client


ссылка на оригинал статьи https://habr.com/ru/articles/577464/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *