Pull to refresh

Kotlin Multiplatform. Работаем с многопоточностью на практике. Ч.2

Reading time7 min
Views6.9K
Доброго всем времени суток! С вами я, Анна Жаркова, ведущий мобильный разработчик компании «Usetech».

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



В прошлом примере нам помогла библиотека Ktor, которая взяла на себя всю основную работу по обеспечению асинхронности в сетевом клиенте. Это избавило нас от необходимости использовать DispatchQueue на iOS в том конкретном случае, но в других нам бы пришлось использовать задание очереди исполнения для вызова бизнес-логики и обработки ответа. На стороне Android мы использовали MainScope для вызова suspended функции.

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

Начнем с простого. Создадим нашего архитектурного посредника, который будет вызывать методы сервиса в своем scope, получаемом из контекста корутины:

class PresenterCoroutineScope(context: CoroutineContext) : CoroutineScope {
    private var onViewDetachJob = Job()
    override val coroutineContext: CoroutineContext = context + onViewDetachJob

    fun viewDetached() {
        onViewDetachJob.cancel()
    }
}

//Базовый класс для посредника
abstract class BasePresenter(private val coroutineContext: CoroutineContext) {
    protected var view: T? = null
    protected lateinit var scope: PresenterCoroutineScope

    fun attachView(view: T) {
        scope = PresenterCoroutineScope(coroutineContext)
        this.view = view
        onViewAttached(view)
    }
}

Вызываем сервис в методе посредника и передаем нашему UI:

class MoviesPresenter:BasePresenter(defaultDispatcher){
    var view: IMoviesListView? = null

    fun loadData() {
        //запускаем в скоупе
        scope.launch {
            service.getMoviesList{
                val result = it
                if (result.errorResponse == null) {
                    data = arrayListOf()
                    data.addAll(result.content?.articles ?: arrayListOf())
                    withContext(uiDispatcher){
                    view?.setupItems(data)
                   }
                }
            }
        }

//IMoviesListView - интерфейс/протокол, который будут реализовывать UIViewController и Activity. 
interface IMoviesListView  {
  fun setupItems(items: List<MovieItem>)
}
class MoviesVC: UIViewController, IMoviesListView {
private lazy var presenter: IMoviesPresenter? = {
       let presenter = MoviesPresenter()
        presenter.attachView(view: self)
        return presenter
    }()

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        presenter?.attachView(view: self)
        self.loadMovies()
    }

    func loadMovies() {
        self.presenter?.loadMovies()
    }

   func setupItems(items: List<MovieItem>){}
//....

class MainActivity : AppCompatActivity(), IMoviesListView {
    val presenter: IMoviesPresenter = MoviesPresenter()

    override fun onResume() {
        super.onResume()
        presenter.attachView(this)
        presenter.loadMovies()
    }

   fun  setupItems(items: List<MovieItem>){}
//...

Чтобы корректно создавать scope из контекста корутины, нам потребуется задать диспетчер корутины.

Это платформозависимая логика, поэтому используем кастомизацию с помощью expect/actual.

expect val defaultDispatcher: CoroutineContext

expect val uiDispatcher: CoroutineContext

uiDispatcher будет отвечать за работу в потоке UI. defaultDispatcher будем использовать для работы вне UI потока.

Проще всего создать в androidMain, т.к в Kotlin JVM есть готовые реализации для диспетчеров корутин. Для доступа к соответствующим потокам используем CoroutineDispatchers Main (UI поток) и Default (стандартный для Coroutine):

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

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


Диспетчер MainDispatcher выбирается для платформы под капотом CoroutineDispatcher с помощью фабрики диспетчеров MainDispatcherLoader:

internal object MainDispatcherLoader {

    private val FAST_SERVICE_LOADER_ENABLED = systemProp(FAST_SERVICE_LOADER_PROPERTY_NAME, true)

    @JvmField
    val dispatcher: MainCoroutineDispatcher = loadMainDispatcher()

    private fun loadMainDispatcher(): MainCoroutineDispatcher {
        return try {
            val factories = if (FAST_SERVICE_LOADER_ENABLED) {
                FastServiceLoader.loadMainDispatcherFactory()
            } else {
                // We are explicitly using the
                // `ServiceLoader.load(MyClass::class.java, MyClass::class.java.classLoader).iterator()`
                // form of the ServiceLoader call to enable R8 optimization when compiled on Android.
                ServiceLoader.load(
                        MainDispatcherFactory::class.java,
                        MainDispatcherFactory::class.java.classLoader
                ).iterator().asSequence().toList()
            }
            @Suppress("ConstantConditionIf")
            factories.maxBy { it.loadPriority }?.tryCreateDispatcher(factories)
                ?: createMissingDispatcher()
        } catch (e: Throwable) {
            // Service loader can throw an exception as well
            createMissingDispatcher(e)
        }
    }
}

Так же и с Default:

internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {
    val IO: CoroutineDispatcher = LimitingDispatcher(
        this,
        systemProp(IO_PARALLELISM_PROPERTY_NAME, 64.coerceAtLeast(AVAILABLE_PROCESSORS)),
        "Dispatchers.IO",
        TASK_PROBABLY_BLOCKING
    )

    override fun close() {
        throw UnsupportedOperationException("$DEFAULT_DISPATCHER_NAME cannot be closed")
    }

    override fun toString(): String = DEFAULT_DISPATCHER_NAME

    @InternalCoroutinesApi
    @Suppress("UNUSED")
    public fun toDebugString(): String = super.toString()
}

Однако, не для всех платформ есть реализации диспетчеров корутин. Например, для iOS, который работает с Kotlin/Native, а не с Kotlin/JVM.

Если мы попробуем использовать код, как в Android, то получим ошибку:



Давайте разберем, в чем же у нас дело.

Issue 470 c GitHub Kotlin Coroutines содержит информацию, что специальные диспетчеры еще не реализованы для iOS:



Issue 462, от которой зависит 470, то же еще в статусе Open:



Рекомендуемым решением является создание собственных диспетчеров для iOS:

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()
            }catch (err: Throwable) {
                throw err
            }
        }
    }
}

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()
            }catch (err: Throwable) {
                throw err
            }
        }
    }

При запуске мы получим ту же самую ошибку.

Во-первых, мы не можем использовать dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(),0.toULong())), потому что он не привязан ни к одному потоку в Kotlin/Native:



Во-вторых, Kotlin/Native в отличие от Kotlin/JVM не может шарить корутины между потоками. А также любые изменяемые объекты.

Поэтому мы используем MainDispatcher в обоих случаях:

actual val ioDispatcher: 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
            }
        }
    }

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



Однако, если мы попытаемся заморозить уже замороженный объект, например, синглтоны, которые считаются замороженными по умолчанию, то получим FreezingException.

Чтобы этого не произошло, помечаем синглтоны аннотацией @ThreadLocal, а глобальные переменные @SharedImmutable:

/**
 * Marks a top level property with a backing field or an object as thread local.
 * The object remains mutable and it is possible to change its state,
 * but every thread will have a distinct copy of this object,
 * so changes in one thread are not reflected in another.
 *
 * The annotation has effect only in Kotlin/Native platform.
 *
 * PLEASE NOTE THAT THIS ANNOTATION MAY GO AWAY IN UPCOMING RELEASES.
 */
@Target(AnnotationTarget.PROPERTY, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
public actual annotation class ThreadLocal

/**
 * Marks a top level property with a backing field as immutable.
 * It is possible to share the value of such property between multiple threads, but it becomes deeply frozen,
 * so no changes can be made to its state or the state of objects it refers to.
 *
 * The annotation has effect only in Kotlin/Native platform.
 *
 * PLEASE NOTE THAT THIS ANNOTATION MAY GO AWAY IN UPCOMING RELEASES.
 */
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.BINARY)
public actual annotation class SharedImmutable

Использовать в обоих случаях MainDispatcher подойдет при работе с Ktor. Если же мы хотим, чтобы у нас тяжелые запросы шли в фоне, то мы можем отправлять их в GlobalScope с главным диспетчером Dispatchers.Main/MainDispatcher в качестве контекста:

iOS

actual fun ktorScope(block: suspend () -> Unit) {
    GlobalScope.launch(MainDispatcher) { block() }
}

Android:

actual fun ktorScope(block: suspend () -> Unit) {
           GlobalScope.launch(Dispatchers.Main) { block() }
       }

Вызов и смена контекста тогда будет у нас уже в сервисе:

suspend fun loadMovies(callback:(MoviesList?)->Unit) {
       ktorScope {
            val url =
                "http://api.themoviedb.org/3/discover/movie?api_key=KEY&page=1&sort_by=popularity.desc"
            val result = networkService.loadData<MoviesList>(url)
            delay(1000)
           withContext(uiDispatcher) {
               callback(result)
           }
        }
    }

И даже если у вас там будет вызов не только функционала Ktor, все отработает.

Также можно еще реализовать на iOS вызов блока с передачей в background DispatchQueue таким образом:

//Тип ответа примерный, используйте тот, который вам нужен
actual fun callFreeze(callback: (Response)->Unit) {
    val block = {
      //Тут может быть тот блок кода, который вы будете передавать
        callback(Response("from ios").freeze())
    }
    block.freeze()
    dispatch_async {
        queue = dispath_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND.toLong, 
            0.toULong())
        block = block     
    }
}

Разумеется, придется добавить actual fun callFreeze(...) и на стороне Android, но просто с передачей вашего ответа в callback.

В итоге, внеся все правки, мы получаем работающее одинаково на обеих платформах приложение:



Исходники примера
Тут аналогичный пример, но не под Kotlin 1.4
github.com/anioutkazharkova/kmp_news_sample

tproger.ru/articles/creating-an-app-for-kotlin-multiplatform
github.com/JetBrains/kotlin-native
github.com/JetBrains/kotlin-native/blob/master/IMMUTABILITY.md
github.com/Kotlin/kotlinx.coroutines/issues/462
helw.net/2020/04/16/multithreading-in-kotlin-multiplatform-apps
Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments13

Articles