Kotlin Multiplatform. We work with multithreading in practice. Part 2

Good day to all! With you I am, Anna Zharkova, the leading mobile developer at Usetech.

In the previous article, I talked about one of the ways to implement multithreading in a Kotlin Multiplatform application. Today we will consider an alternative situation when we implement an application with the most shared common code, transferring all work with threads into common logic.





In the previous example, we were helped by the Ktor library, which took over all the main work of providing asynchrony in the network client. This saved us from having to use DispatchQueue on iOS in that particular case, but in others we would have to use a run queue job to invoke the business logic and handle the response. On the Android side, we used MainScope to call a suspended function.



So, if we want to implement a uniform work with multithreading in a common project, then we need to correctly configure the scope and context of the coroutine in which it will be executed.

Let's start simple. Let's create our architectural mediator, which will call service methods on its scope, obtained from the coroutine context:

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)
    }
}

      
      





We call the service in the mediator's method and pass it to our 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>){}
//...

      
      







To correctly create a scope from a coroutine context, we need to set up a coroutine dispatcher.

This logic is platform dependent, so we use customization with expect / actual.

expect val defaultDispatcher: CoroutineContext

expect val uiDispatcher: CoroutineContext

      
      





uiDispatcher will be responsible for working on the UI thread. defaultDispatcher will be used to work outside the UI thread.

The easiest way to create it is in androidMain, because the Kotlin JVM has ready-made implementations for coroutine dispatchers. To access the corresponding streams, we use CoroutineDispatchers Main (UI stream) and Default (standard for Coroutine):

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

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







The MainDispatcher is selected for the platform under the hood of the CoroutineDispatcher using the MainDispatcherLoader dispatcher factory:



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)
        }
    }
}

      
      







It's the same with 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()
}

      
      







However, not all platforms have coroutine dispatcher implementations. For example, for iOS, which works with Kotlin / Native, not Kotlin / JVM.

If we try to use the code, as in Android, we will get an error:





Let's see what we are doing.



Issue 470 from GitHub Kotlin Coroutines contains information that special dispatchers are not yet implemented for iOS:





Issue 462 , on which 470 depends, the same is still in the Open status: The





recommended solution is to create your own dispatchers for 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
            }
        }
    }

      
      







We will get the same error on startup.

First, we cannot use dispatch_get_global_queue (DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong (), 0.toULong ())) because it is not tied to any thread in Kotlin / Native:



Second, Kotlin / Native unlike Kotlin / JVM can't fumble coroutines between threads. And also any mutable objects.

Therefore, we use MainDispatcher in both cases:

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
            }
        }
    }

      
      







In order for us to be able to transfer mutable blocks of code and objects between threads, we need to freeze them before transfer using the freeze () command:



However, if we try to freeze an already frozen object, for example, singletons, which are considered frozen by default, we will get FreezingException.

To prevent this from happening, we mark the singletons with the @ThreadLocal annotation, and the global variables @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

      
      







Using MainDispatcher in both cases is fine when working with Ktor. If we want our heavy requests to go in the background, then we can send them to the GlobalScope with the main dispatcher Dispatchers.Main / MainDispatcher as the context:

iOS

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

      
      







Android:

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

      
      





The call and context change will then be in our service:

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)
           }
        }
    }

      
      





And even if you call not only the Ktor functionality there, everything will work.



You can also implement on iOS a block call with a transfer to the background DispatchQueue like this:

//  ,  ,   
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     
    }
}

      
      







Of course, you will have to add actual fun callFreeze (...) on the Android side as well, but just with passing your response to the callback.



As a result, after making all the edits, we get an application that works the same on both platforms:





Example sources github.com/anioutkazharkova/movies_kmp

There is a similar example, but not under 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






All Articles