13.
Concurrency
Written by Carlos Mota
As an app gets more complex, concurrency becomes a fundamental topic you’ll need to address. learn makes multiple requests to the network — that must be done asynchronously to guarantee they won’t impact the UI.
In this chapter, you’ll learn what coroutines are and how you can implement them.
Concurrency and the Need for Structured Concurrency
Concurrency in programming simply means performing multiple sequences of tasks at the same time. Structured concurrency allows doing multiple computations outside the UI thread to keep the app as responsive as possible. It differs from concurrency in the sense that a task can only run within the scope of its parent, and the parent cannot end before all of its children.
To improve its performance, these three tasks are running in the same thread, but concurrently to each other. They were divided into smaller segments that run independently.
Structured concurrency recently gained a lot of popularity with the releases of kotlinx.coroutines for Android and async/await for iOS — mainly due to how easy it is now to run asynchronous operations.
Different Concurrency Solutions
There are multiple Kotlin Multiplatform libraries that support concurrency:
- kotlinx.coroutines: The most popular one. It’s lightweight, it allows running multiple coroutines in a single thread and it supports exception handling and cancellation.
- Reaktive: An implementation of Reactive Extensions using the Observable pattern.
- CoroutineWorker: Supports multithreaded coroutines.
In this chapter, you’ll learn how to use kotlinx.coroutines. Spoiler alert: You’ve already worked with coroutines before. :]
If you’re already familiar with coroutines, you can skip the next few sections and go to “Structured concurrency in iOS”, or directly to “Working with kotlinx.coroutines”, for the next developments in learn.
Understanding kotlinx.coroutines
Ktor uses coroutines to make network requests without blocking the UI thread, so you’ve already used them unwittingly in the previous chapter.
Open the FeedPresenter.kt file from shared/commonMain/presentation and search for fetchAllFeeds and fetchFeed. In the first function, you’ve got:
for (feed in content) {
fetchFeed(feed.platform, feed.image, feed.url, cb)
}
If you weren’t using coroutines on fetchFeed, these instructions would run sequentially. In other words, the app would only iterate to the next item after fetchFeed returned, which would delay the app startup.
Suspend Functions
Suspend functions are at the core of coroutines. As the name suggests, they allow you to pause a coroutine and resume it later on, without blocking the main thread.
Network requests are one of the use cases for suspend functions. Open the FeedAPI.kt file in shared/commonMain/data and look at the function declarations:
public suspend fun fetchKodecoEntry(feedUrl: String): HttpResponse = client.get(feedUrl)
public suspend fun fetchMyGravatar(hash: String): GravatarProfile =
client.get("$GRAVATAR_URL$hash$GRAVATAR_RESPONSE_FORMAT") {
header(X_APP_NAME, APP_NAME)
}.body()
They’re both suspend functions. Since a response may take some time, the app cannot block and wait for any of these functions to return.
This image defines the flow that triggers fetchKodecoEntry to be called.
The entry point, for all platforms, is the fetchAllFeeds function from shared/commonMain/presentation/FeedPresenter.kt. Once invoked, it iterates over all the RSS feeds, calling fetchFeed for each one of its URLs:
- This is a heavy operation that might block the UI. To avoid this, you’ll do it asynchronously. Create a coroutine by calling
launch. - Once launched, it calls
invokeFetchKodecoEntryfrom shared/commonMain/domain/GetFeedData.kt. A suspend function calls the FeedAPI to make the request. - This function suspends after making the request, and it waits until there’s a response or the connection times out.
- This is done in a separate thread, so the UI doesn’t get blocked.
- Once there’s a response,
fetchKodecoEntryresumes and returns toinvokeFetchKodecoEntry, which can now deserialize the information received. - When this process finishes, the
onSuccessor theonFailurefunctions execute — depending on the result — and the UI receives an update. Since you’re usingMainScopetolaunchthe coroutine, it will run on the UI thread. You’ll see this in detail in the next section.
As a key point, you can only call a suspend function from another one or within a coroutine.
Coroutine Scope and Context
Return to FeedPresenter.kt from shared/commonMain/presentation and search for the fetchFeed function:
private fun fetchFeed(platform: PLATFORM, imageUrl: String, feedUrl: String, cb: FeedData) {
MainScope().launch {
// Call to invokeFetchKodecoEntry
}
}
You already know that launch creates a new coroutine, but what’s MainScope? A coroutine scope is where a coroutine will run — in this case, it will be the main thread.
If you open the source code of MainScope:
public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)
You can see that ContextScope is built using:
-
SupervisorJob: When you create a coroutine, it returns aJobthat corresponds to its instance. This allows you to cancel the coroutine or to know more about its current state:-
isActive: If it’s currently running. -
isCompleted: If all of its work, as well as that of its children, has ended. Moreover, when the current Job gets canceled — or fails — this value will be true. -
isCancelled: When the current job gets canceled or fails.
-
In a SupervisorJob, the children behave independently — if one fails, the others won’t be affected — whereas in case of Job, if a parent fails, all of its children will be canceled.
-
Dispatchers: Defines in which thread a coroutine should run:-
Default: Uses a shared pool of threads. -
Main: Has different behaviors depending on the platform it’s running on. On JVM and Android,Maincorresponds to the UI thread, and should only be used for operations that update the UI. On Native, it depends on the target itself. If it’s Darwin-based, the dispatcher is backed by Darwin’s main queue. For the other targets, it’s the same as theDefaultdispatcher. -
Unconfined: Doesn’t have any associated threading policy and doesn’t switch to any specific thread. -
IO: Should be used for long-running and heavy tasks because it’s one shared pool of threads, optimized for these types of operations.
-
When you create a coroutine, you have to define the Dispatcher where it should run, but you can always switch the context later on during execution by calling withContext with the prepended Dispatcher as an argument.
In the fetchMyGravatar from FeedPresenter.kt, you’re running the coroutine on the main thread, although the only parts that are necessary to run on the main thread are the onSuccess and onFailure calls. Update the existing function to use the IO thread for the network requests and when the data is available, switch to the Main thread so the UI can be updated:
public fun fetchMyGravatar(cb: FeedData) {
//1
CoroutineScope(Dispatchers.IO).launch {
//2
val profile = feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
//3
withContext(Dispatchers.Main) {
//4
cb.onMyGravatarData(profile)
}
}
}
Here’s what you’re doing:
-
You create a new coroutine in a thread from the IO thread pool and start it.
-
invokeGetMyGravataris a suspend function. When there’s a request, it suspends until there’s a server response. Once this happens, the coroutine resumes. -
The UI can only be updated from the UI thread, so it’s necessary to switch from the
IOdispatcher to theMainone. This can only be done from within a coroutine. -
onMyGravatarDatais now called from the UI thread, so the user can see this newly received data.
You’ll also need to update the invokeGetMyGravatar function to return the result instead. Open the GetFeedData.kt file from commonMain/domain and change invokeGetMyGravatar to:
public suspend fun invokeGetMyGravatar(
hash: String,
): GravatarEntry {
return try {
val result = FeedAPI.fetchMyGravatar(hash)
Logger.d(TAG, "invokeGetMyGravatar | result=$result")
if (result.entry.isEmpty()) {
GravatarEntry()
} else {
result.entry[0]
}
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch my gravatar. Error: $e")
GravatarEntry()
}
}
In addition to MainScope, you also have GlobalScope. Typically, it’s used in scenarios where the coroutine must live throughout the app execution.
You have to be extra careful when using this function. If the coroutine is unable to finish, it will keep using resources, potentially until the user closes the app.
If you have to update the UI, and you’re using GlobalScope, you must switch to the UI thread first. Otherwise, when running your iOS app, you’ll get the following exception:
kotlin.native.IncorrectDereferenceException: illegal attempt to access non-shared (…) from other thread
You also have the coroutineScope function that allows you to create a coroutine, but it uses the parent scope as context. It has some particularities, namely:
- If the parent gets canceled, it will cancel all of its children.
- Only after all the children end can the parent also terminate.
Coroutine Builders, Scope and Context
You’ve seen how to start a coroutine by calling launch. This function is part of the coroutine builders:
-
runBlocking: blocks the current thread until the coroutine that it creates ends.
Note: It shouldn’t be used inside an existing coroutine, since it will stop its execution.
-
launch: Creates a coroutine without blocking the current thread. You can define the CoroutineScope from where it should run. This scope guarantees structured concurrency — in other words, a coroutine only ends after all of its children have completed their operations. -
async: Similar tolaunchin the way it’s constructed and how it runs. It differs on its return type in that in this case it’s not a Job, but a Deferred<T> object that will contain the future result of this function.
Return to the fetchMyGravatar function and add this function below it:
private suspend fun fetchMyGravatar(): GravatarEntry {
return CoroutineScope(Dispatchers.IO).async {
feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}.await()
}
This fetchMyGravatar is a suspend function. With this approach, you don’t need the onSuccess and onFailure callbacks to update the UI, since you’ll return a GravatarEntry. You need to call await at the end to return its final value instead of a Deferred<GravatarEntry>.
It’s worth mentioning that this function is similar to using withContext:
private suspend fun fetchMyGravatar(): GravatarEntry {
return withContext(CoroutineScope(Dispatchers.IO).coroutineContext) {
feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}
}
The main difference is that CoroutineScope doesn’t use the same scope as its caller.
Following this approach means that you’ll also have to make a few more updates. To use the same logic to notify the UI via callbacks, you’ll need to change fetchMyGravatar(cb: FeedData) to:
public fun fetchMyGravatar(cb: FeedData) {
Logger.d(TAG, "fetchMyGravatar")
CoroutineScope(Dispatchers.IO).launch {
cb.onMyGravatarData(fetchMyGravatar())
}
}
Otherwise, you can return the GravatarEntry directly to the UI. You’ll see how to implement this second approach in the “Creating a Coroutine With Async” section.
Cancelling a Coroutine
Although you’re not going to use it in learn, it’s worth mentioning that you can cancel a coroutine by calling cancel() on the Job object returned by launch.
In case you’re using async, you’ll have to implement a solution similar to this one:
val deferred = CoroutineScope(Dispatchers.IO).async {
feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}
//If you want to cancel
deferred.cancel()
When you cancel a coroutine, a CancellationException is thrown silently. You can catch it to implement a specific behavior your app might need, or to clean up resources.
Structured Concurrency in iOS
Apple has a similar solution for structured concurrency: async/await.
Note: async/await is only available if you’re running your app on iOS 13 or newer versions.
With async/await, you no longer need to use completion handlers. Instead, you can use the async keyword after the function declaration. If you want to wait for it to return, add await before calling the suspend function:
private func fetchMyGravatar() async -> GravatarEntry {
return await feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}
Which in Kotlin is similar to:
private suspend fun fetchMyGravatar(): GravatarEntry {
return withContext(Dispatchers.IO) {
feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}
}
Following the same logic as suspend functions, you can only call an async function from another one or from an asynchronous task. In Kotlin, this corresponds to calling the function from a coroutine.
Swift uses Task. Using Task, the previous example can be translated to:
private func fetchMyGravatar() {
Task {
let profile = await feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
await profile
}
}
With kotlinx.coroutines, it’s:
private suspend fun fetchMyGravatar() = {
CoroutineScope(Dispatchers.IO).launch {
async { feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}.await()
}
}
Using kotlinx.coroutines
It’s time to update learn. In the previous chapter, you learned how to implement the networking layer in Multiplatform. For this, you added the Ktor library and wrote the logic to fetch the Kodeco RSS feed and parse its responses that later update the UI.
However, there’s a little detail that was left for this section: Ktor is built using kotlinx.coroutines. This is why the MainScope, launch and suspend functions seemed familiar in the “Understanding kotlinx.coroutines” section.
Adding kotlinx.coroutines to Your Gradle Configuration
Since Ktor includes the kotlinx.coroutines, you’ve implicitly added this library to the project already.
Otherwise, if you want to include kotlinx.coroutines in your projects, you’ll need to add:
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
Troubleshooting kotlinx.coroutines in iOS
As you continue your journey with Multiplatform outside this book, you’ll probably find this error:
Uncaught Kotlin exception: kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen
This InvalidMutabilityException means you’re accessing an object that belongs to another thread, which is currently not possible. Confirm if you’re using the latest version of Kotlin and that you’re using the Dispatchers.Main to access that object.
If you’re still running into problems:
- Delete the build folder in the root directory of the project.
- Delete the build folder in the shared directory in the root directory of the project.
Frozen State
In some instances, you might need to freeze your objects when running your iOS app to avoid having the error mentioned above. Once freeze()is called over an object, it becomes immutable. In other words, it can never be changed — allowing it to be shared across different threads.
Another advantage of using the kotlinx.coroutines library is that this logic is already built into the latest version of the library, so you shouldn’t need to do anything from your side.
Working With kotlinx.coroutines
In the app, go to the latest screen. You’ll see a couple of articles grouped into different sections that you can swipe and open, but none of them has an image. It’s time to change this!
Creating a Suspend Function
Start by opening the FeedAPI.kt file from commonMain/data in the shared module.
After the fetchKodecoEntry, add:
public suspend fun fetchImageUrlFromLink(link: String): HttpResponse = client.get(link) {
header(HttpHeaders.Accept, ContentType.Text.Html)
}
When prompted, import:
import io.ktor.http.HttpHeaders
import io.ktor.http.ContentType
fetchImageUrlFromLink receives the link from an article and returns the page source code as the HttpResponse. It’s set as a suspend function, so the current thread won’t block while it’s waiting for the server response.
Note: You need to set the
Acceptheader in this request otherwise the server will return a 406, not acceptable.
Next, open the GetFeedData.kt file from shared/commonMain/domain and add the following method inside the class:
//1
public suspend fun invokeFetchImageUrlFromLink(
link: String,
//2
onSuccess: (String) -> Unit,
onFailure: (Exception) -> Unit
) {
try {
//3
val result = FeedAPI.fetchImageUrlFromLink(link)
//4
val url = parsePage(link, result.bodyAsText())
//5
coroutineScope {
onSuccess(url)
}
} catch (e: Exception) {
coroutineScope {
onFailure(e)
}
}
}
Here’s a step-by-step breakdown of this logic:
-
invokeFetchImageUrlFromLinkis set assuspendsince it will call theFeedAPIto retrieve the page source code. - The
onSuccessandonFailurefunctions define how this function should behave, depending on whether it was possible to retrieve an image for the article or not. - The
FeedAPIuses the Ktor HttpClient to make a network request. - Since there’s no API to get the URL for the image, you’re going to parse the HTML code and look for a specific image tag. Along with the network request, this will be a heavy task. So, this logic needs to be called from a coroutine.
- The
coroutineScopecreates a new coroutine, using its parent scope to run the functions ofonSuccessoronFailuredepending on whether the operation succeeded or not.
In the next sections, you’ll see different approaches to creating and starting a coroutine. Although both of them are valid, the APIs that they expose to the UI are different.
Note: When multiple teams will use a shared module, it’s best for representatives from each team to discuss and agree on conventions they all feel comfortable following. This is especially important for iOS programmers who are new to Kotlin and can feel overwhelmed having to adapt to a new language. Interacting with your shared module should be similar to any other library that exists for iOS.
Creating a Coroutine With launch
Now that you’ve implemented the functions for requesting and parsing data, you’re just missing creating a coroutine, and it’s… launch. :]
Open the FeedPresenter.kt file inside commonMain/presentation. In the shared module and before the fetchMyGravatar(cb: FeedData) function, add:
public fun fetchLinkImage(platform: PLATFORM, id: String, link: String, cb: FeedData) {
CoroutineScope(Dispatchers.IO).launch {
feed.invokeFetchImageUrlFromLink(
link,
onSuccess = { cb.onNewImageUrlAvailable(id, it, platform, null) },
onFailure = { cb.onNewImageUrlAvailable(id, "", platform, it) }
)
}
}
In this approach, you’re using a FeedData listener that’s defined at the UI level. Once the invokeFetchImageUrlFrom finishes, it will either call the onSuccess or onFailure functions that in their turn will call the onNewImageUrlAvailable callback at the UI with the new data received or with an exception in case there was an error.
Now, connect your app’s UI to this new function.
In androidApp and desktopApp, the changes are similar. In both projects, go to ui/home, open the FeedViewModel.kt file, and update the onNewImageUrlAvailable callback with:
override fun onNewImageUrlAvailable(id: String, url: String, platform: PLATFORM, exception: Exception?) {
Logger.d(TAG, "onNewImageUrlAvailable | platform=$platform | id=$id | url=$url")
viewModelScope.launch {
val item = _items[platform]?.firstOrNull { it.id == id } ?: return@launch
val list = _items[platform]?.toMutableList() ?: return@launch
val index = list.indexOf(item)
list[index] = item.copy(imageUrl = url)
_items[platform] = list
}
}
When this method receives a new url, the item to which it corresponds is updated. Updating the _items map automatically updates the UI.
Note:
viewModelScoperuns on the UI-thread.
Inside the viewModelScope.launch of onNewDataAvailable, replace the existing code with:
_items[platform] = if (items.size > FETCH_N_IMAGES) {
items.subList(0, FETCH_N_IMAGES)
} else{
items
}
for (item in _items[platform]!!) {
fetchLinkImage(platform, item.id, item.link)
}
Now, when the app receives new articles, it will automatically request its images.
Create the fetchLinkImage function:
private fun fetchLinkImage(platform: PLATFORM, id: String, link: String) {
Logger.d(TAG, "fetchLinkImage | link=$link")
presenter.fetchLinkImage(platform, id, link, this)
}
fetchLinkImage calls the fetchLinkImage from the FeedPresenter.kt file that you created earlier.
In the iosApp, open the FeedClient.swift file that’s inside the extensions directory and search for fetchLinkImage. To also call the fetchLinkImage from the FeedPresenter.kt class, update this function to:
public func fetchLinkImage(_ platform: PLATFORM, _ id: String, _ link: String, completion: @escaping FeedHandlerImage) {
feedPresenter.fetchLinkImage(platform: platform, id: id, link: link, cb: self)
handlerImage = completion
}
Now, navigate to KodecoEntryViewModel.swift and, similar to what you’ve done on the other platforms, create the fetchLinkImage:
func fetchLinkImage() {
for platform in self.items.keys {
guard let items = self.items[platform] else { continue }
let subsetItems = Array(items[0 ..< Swift.min(self.fetchNImages, items.count)])
for item in subsetItems {
FeedClient.shared.fetchLinkImage(item.platform, item.id, item.link) { id, url, platform in
guard let item = self.items[platform.description]?.first(where: { $0.id == id }) else {
return
}
guard var list = self.items[platform.description] else {
return
}
guard let index = list.firstIndex(of: item) else {
return
}
list[index] = item.doCopy(
id: item.id,
link: item.link,
title: item.title,
summary: item.summary,
updated: item.updated,
platform: item.platform,
imageUrl: url,
bookmarked: item.bookmarked
)
Logger().d(tag: TAG, message: "\(list[index].title)Updated to:\(list[index].imageUrl)")
self.items[platform.description] = list
}
}
}
}
Finally, call it from the existing the fetchFeeds function as follows:
func fetchFeeds() {
FeedClient.shared.fetchFeeds { platform, items in
Logger().d(tag: TAG, message: "fetchFeeds: \(items.count) items | platform: \(platform)")
DispatchQueue.main.async {
self.items[platform] = items
self.fetchLinkImage()
}
}
}
Compile and run the apps for the three platforms and navigate to the latest screen.
Creating a Coroutine With Async
As an alternative to the previous approach where you’re using callbacks to notify the UI when new data is available, you can suspend the fetchLinkImage function until there’s a final result. For that, you’ll need to use async instead of launch.
Return to the FeedPresenter.kt file in commonMain/presentation in the shared module, and update the function fetchLinkImage:
public suspend fun fetchLinkImage(link: String): String {
return CoroutineScope(Dispatchers.IO).async {
feed.invokeFetchImageUrlFromLink(
link
)
}.await()
}
As you can see, it’s no longer necessary to have the platform and id parameters, since you’re going to return the image URL in case it exists. The async function allows returning an object while await waits for the response to be ready. Instead of returning a Deferred<T> — in this case it would be a Deferred<String?>.
Depending on the Android Studio version you’re using, it’s probable that it would suggest you replace the previous implementation with:
public suspend fun fetchLinkImage(link: String): String {
return withContext(CoroutineScope(Dispatchers.IO).coroutineContext) {
feed.invokeFetchImageUrlFromLink(
link
)
}
}
Both approaches produce similar results, but they’re quite different under the hood.
You can remove the onNewImageUrlAvailable from the FeedData.kt interface, located in the domain/cb directory.
Open GetFeedData.kt and update invokeFetchImageUrlFromLink to the following:
public suspend fun invokeFetchImageUrlFromLink(
link: String
): String {
return try {
val result = FeedAPI.fetchImageUrlFromLink(link)
parsePage(link, result.bodyAsText())
} catch (e: Exception) {
""
}
}
Now it’s time to update the UI! You’ll need to change how you’re calling the fetchLinkImage function:
- On both androidApp and desktopApp, go to the FeedViewModel.kt file inside ui/home, and replace the existing
fetchLinkImagefunction with:
private fun fetchLinkImage(platform: PLATFORM, id: String, link: String) {
Logger.d(TAG, "fetchLinkImage | link=$link")
viewModelScope.launch {
val url = presenter.fetchLinkImage(link)
val item = _items[platform]?.firstOrNull { it.id == id } ?: return@launch
val list = _items[platform]?.toMutableList() ?: return@launch
val index = list.indexOf(item)
list[index] = item.copy(imageUrl = url)
_items[platform] = list
}
}
This is the code that was in onNewImageUrlAvailable, along with the call to presenter.fetchLinkImage. Since you no longer use that callback, you can remove it.
- For iOSApp, you also need to update the FeedClient.swift file, which is inside the extensions folder. Start by updating the
FeedHandlerImagethat no longer has to receive all of its parameters:
public typealias FeedHandlerImage = (_ url: String) -> Void
Update the fetchLinkImage to:
@MainActor
public func fetchLinkImage(_ link: String, completion: @escaping FeedHandlerImage) {
Task {
do {
let result = try await feedPresenter.fetchLinkImage(link: link)
completion(result)
} catch {
Logger().e(tag: TAG, message: "Unable to fetch article image link")
}
}
}
Since you’re now accessing a suspend function from Swift, you’ll have to use await to wait for the result to be available. The @MainActor annotation guarantees the Task runs on the UI thread. Otherwise, you might have a InvalidMutabilityException.
Now, remove the onNewImageUrlAvailable from the FeedClient extension at the bottom of the file since this callback no longer exists.
Because this function needs to be declared as @MainActor and the id, platform and cb are no longer necessary, you have to update the fetchLinkImage method from KodecoEntryViewModel.swift :
@MainActor
func fetchLinkImage() {
for platform in self.items.keys {
guard let items = self.items[platform] else { continue }
let subsetItems = Array(items[0 ..< Swift.min(self.fetchNImages, items.count)])
for item in subsetItems {
FeedClient.shared.fetchLinkImage(item.link) { url in
guard var list = self.items[platform.description] else {
return
}
guard let index = list.firstIndex(of: item) else {
return
}
list[index] = item.doCopy(
id: item.id,
link: item.link,
title: item.title,
summary: item.summary,
updated: item.updated,
platform: item.platform,
imageUrl: url,
bookmarked: item.bookmarked
)
self.items[platform.description] = list
}
}
}
}
Compile and run your app, and browse through the outstanding artwork of the Kodeco articles. :]
Improving Coroutines Usage for Native Targets
A key point when showing the benefits of using Kotlin Multiplatform on multiple targets is to keep the developer experience as close to using the platform language and tools as possible. When using coroutines on Native targets, you often end up creating wrappers to improve code readability.
The following libraries were developed by the community to improve coroutines integration with Swift:
-
KMP-NativeCoroutines: allows canceling an existing coroutine from Swift and adds support to Flow without losing any property. It’s recommended by JetBrains and it gets continuous updates.
-
Koru: supports generating wrappers for suspend functions and Flow.
-
SKIE: has the primary goal of making it seamless to use Kotlin from Swift by providing support for suspend functions, Flow and default arguments. It also makes it easier to use Kotlin enums, sealed classes and interfaces.
Configuring KMP NativeCoroutines
To use this library, you need to add it first to the shared module and then to the iOSApp via the Swift Package Manager. Let’s start by opening the libs.versions.toml file located inside the gradle folder. In the [versions] section define the library versions that you’re going to use:
ksp = "1.9.10-1.0.13"
nativeCoroutines = "1.0.0-ALPHA-18"
Then scroll down to [plugins] and add:
google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kmp-NativeCoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativeCoroutines" }
KSP (Kotlin Symbol Processing API) is required by KMP NativeCoroutines.
Now, open the build.gradle.kts file in the root folder and update the plugins sections with these new ones:
alias(libs.plugins.google.ksp) apply false
alias(libs.plugins.kmp.nativeCoroutines) apply false
shared will use this library, so you also need to set the plugin on its build.gradle.kts file. In the plugins block, add:
alias(libs.plugins.google.ksp)
alias(libs.plugins.kmp.nativeCoroutines)
To generate the wrappers for Native, you’ll need to opt in to use the annotation ObjCName, otherwise, you’ll keep seeing a warning when compiling your project. Scroll down to the end of the file and after the existing kotlin.RequiresOptIn add a second one:
languageSettings.optIn("kotlin.experimental.ExperimentalObjCName")
Synchronize the project.
Open Xcode and go to File ▸ Add Package Dependencies… and in the top-right corner where it says, “Search or Enter Package URL” enter:
https://github.com/rickclephas/KMP-NativeCoroutines.git
Click Add Package to add it to your app.
That’s it! All libraries are set.
Using KMP NativeCoroutines With a Suspend Function
Now that all libraries are set, it’s time to update your code. Return to Android Studio and open the FeedPresenter.kt file located in the shared module.
KMP NativeCoroutines, has two annotations that you can use:
-
@NativeCoroutineScope: it defines the scope to use, and allows you to have more control over it. -
@NativeCoroutines: functions that use this are not generated for Objective-C, but instead, an extension is created that can more easily be accessed from Swift. This is generated via the KSP plugin that you just added.
You can open the SharedKit.framework directly from Xcode by clicking on import SharedKit or any function from it in any Swift file that uses it, or alternatively, you can go to shared/build/bin/ios*Arm64/debugFramework/Headers/SharedKit.h.
Look for the fetchMyGravatar declaration. Here you’re going to find two of them, one that receives a completion handler and another one that receives a callback.
- (void)fetchMyGravatarCb:(id<SharedKitFeedData>)cb __attribute__((swift_name("fetchMyGravatar(cb:)")));
- (void)fetchMyGravatarWithCompletionHandler:(void (^)(SharedKitGravatarEntry * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("fetchMyGravatar(completionHandler:)")));
To use these functions from Swift, you need to create a handler and an extension for retrieving data, as you can see in FeedClient.swift. With the KMP NativeCoroutines library, the generated code can be improved, making it easier and more friendly to access. For that, return to the FeedPresenter.kt file and update fetchMyGravatar(): GravatarEntry:
@NativeCoroutines
public suspend fun fetchMyGravatar(): GravatarEntry {
return CoroutineScope(Dispatchers.IO).async {
feed.invokeGetMyGravatar(
hash = GRAVATAR_EMAIL.toByteArray().md5().toString()
)
}.await()
}
It’s now set as public and has the @NativeCoroutines annotation set.
Compile the project and open the SharedKit.framework again. You’ll see that there’s no longer a fetchMyGravatarWithCompletionHandler function, but instead, you’ve got an interface with fetchMyGravatar declared, that you can easily call from Swift:
@interface SharedKitFeedPresenter (Extensions)
- (SharedKitKotlinUnit *(^(^)(SharedKitKotlinUnit *(^)(SharedKitGravatarEntry *, SharedKitKotlinUnit *), SharedKitKotlinUnit *(^)(NSError *, SharedKitKotlinUnit *), SharedKitKotlinUnit *(^)(NSError *, SharedKitKotlinUnit *)))(void))fetchMyGravatar __attribute__((swift_name("fetchMyGravatar()")));
@end
Now open the FeedClient.swift file and import the KMPNativeCoroutinesAsync library:
import KMPNativeCoroutinesAsync
Afterward, update the fetchProfile function to:
//1
public func fetchProfile() async -> GravatarEntry? {
//2
let result = await asyncResult(for: feedPresenter.fetchMyGravatar())
switch result {
//3
case .success(let value):
return value
case .failure(let value):
Logger().e(tag: TAG, message: "Unable to fetch profile. Reason:\(value)")
return nil
}
}
Here’s a step-by-step breakdown of this logic:
- You’re going to access a function that accesses the internet to retrieve the user’s Gravatar information. To accomplish this,
fetchMyGravataris set as a suspend function and returns aGravatarEntry. This is an asynchronous operation sofetchMyProfiledeclaration needs to reflect that, this is why it’s now set asasync. -
asyncResultis a wrapper fromKMPNativeCoroutinesAsyncthat returns the result of a function along with the operation state:.successor.failure. - If the call is successful and new data is available, its content is returned. Otherwise, a log message is printed, and the result is
nil.
With this change, you can now safely remove the ProfileHandler declaration and all of its usage.
Since the behavior of this function changed, you also need to update its caller; otherwise, the project won’t compile. Open the KodecoEntryViewModel.kt file and look for fetchProfile, update it to reflect these changes:
func fetchProfile() {
//1
Task {
//2
guard let profile = await FeedClient.shared.fetchProfile() else { return }
DispatchQueue.main.async {
self.profile = profile
}
}
}
Let’s break this code snippet into parts:
- Since
fetchProfileis asynchronous, to avoid blocking the main thread, this call will be executed inside aTask. - If the result is
.success, the main thread is resumed and theself.profileupdated. Otherwise, theTaskends.
All done! Compile and run the project.
Using KMP NativeCoroutines With Flow
None of the functions in FeedPresenter.kt uses Flow, so the first step is to update the fetchAllFeeds function:
//1
@NativeCoroutines
//2
public fun fetchAllFeeds(): Flow<List<KodecoEntry>> {
Logger.d(TAG, "fetchAllFeeds")
//3
return flow {
for (feed in content) {
//4
emit(
fetchFeed(feed.platform, feed.image, feed.url)
)
}
//5
}.flowOn(Dispatchers.IO)
}
There are a couple of changes to this function, so let’s go over them step-by-step:
- Similar to before, the use of the
@NativeCoroutinesannotation allows generating an interface with thefetchAllFeedsfunction which is easier and more friendly to call from Swift:
@interface SharedKitFeedPresenter (Extensions)
- (SharedKitKotlinUnit *(^(^)(SharedKitKotlinUnit *(^)(SharedKitGravatarEntry *, SharedKitKotlinUnit *), SharedKitKotlinUnit *(^)(NSError *, SharedKitKotlinUnit *), SharedKitKotlinUnit *(^)(NSError *, SharedKitKotlinUnit *)))(void))fetchMyGravatar __attribute__((swift_name("fetchMyGravatar()")));
@end
- Previously,
fetchAllFeedswould receive a callback, which on Native would be translated to a completion handler. The first step is to remove this argument, and instead return a Flow with the list of all theKodecoEntry. Ideally, it would return a Map<PLATFORM, List<KodecoEntry>>, but this is currently not possible with the current version of KMP NativeCoroutines. - The Flow that your function will return.
- For each Kodeco topic: All, Android, iOS, Flutter, Server, GameTech, and Growth, you’re going to retrieve its RSS feed. Once this data is available, it will be
emitted automatically, to whoever is listening to it. In this case, it will be FeedViewModel.kt on Android and Desktop and KodecoEntryViewModel.swift on iOS. - Finally, this will run on the
IOdispatcher.
With the removal of the FeedData callback from fetchAllFeeds you need to do the same thing on the fetchFeed function called in step 4. Update it to:
private suspend fun fetchFeed(
platform: PLATFORM,
imageUrl: String,
feedUrl: String,
): List<KodecoEntry> {
return CoroutineScope(Dispatchers.IO).async {
feed.invokeFetchKodecoEntry(
platform = platform,
imageUrl = imageUrl,
feedUrl = feedUrl
)
}.await()
}
Now, instead of receiving cb it returns the list of KodecoEntry that corresponds to the RSS feed for a specific topic. This change, also requires that invokeFetchKodecoEntry be modified. Open the GetFeedData.kt file and update this function to:
public suspend fun invokeFetchKodecoEntry(
platform: PLATFORM,
imageUrl: String,
feedUrl: String
): List<KodecoEntry> {
return try {
val result = FeedAPI.fetchKodecoEntry(feedUrl)
Logger.d(TAG, "invokeFetchKodecoEntry | feedUrl=$feedUrl")
val xml = Xml.parse(result.bodyAsText())
val feed = mutableListOf<KodecoEntry>()
for (node in xml.allNodeChildren) {
val parsed = parseNode(platform, imageUrl, node)
if (parsed != null) {
feed += parsed
}
}
feed
} catch (e: Exception) {
Logger.e(TAG, "Unable to fetch feed:$feedUrl. Error: $e")
emptyList()
}
}
Similarly, as before, instead of calling the cb with the result of the network requests, it will return a list of RSS feeds in case it successfully received and parsed them or an empty list in case any of these operations fail.
Since you’re now returning the data directly, you can remove the onNewDataAvailable function from FeedData.kt.
With the shared logic updated, it’s now time to update the apps. Starting with Android, open the FeedViewModel.kt file and replace the existing fetchAllFeeds function to:
fun fetchAllFeeds() {
Logger.d(TAG, "fetchAllFeeds")
viewModelScope.launch {
presenter.fetchAllFeeds().collect {
val platform = it.first().platform
_items[platform] = it
for (item in _items[platform]!!) {
fetchLinkImage(platform, item.id, item.link)
}
}
}
}
And remove the onNewDataAvailable function. Now that you’ve got the Android app ready, repeat both steps for Desktop.
Build and run to see the result of the refactoring that you just did.
Return to Xcode and open the FeedClient.swift file. Similarly, to what you’ve changed in the previous section, you need to update the fetchFeeds function to return a dictionary with the platform name and the list of KodecoEntry:
public func fetchFeeds() async -> [String: [KodecoEntry]] {
var items: [String: [KodecoEntry]] = [:]
do {
let result = asyncSequence(for: feedPresenter.fetchAllFeeds())
for try await data in result {
guard let item = data.first else { continue }
items[item.platform.name] = data
}
} catch {
Logger().e(tag: TAG, message: "Unable to fetch all feeds")
}
return items
}
-
KMPNativeCoroutinesAsynchas different functions depending on the type of data that you’re going to access. For Flow you need to use theasyncSequencethat allows you to collect the values from it. - Since it’s an asynchronous operation, you need to wait for it to return, or in other words, to emit (from
fetchAllFeedson FeedPresenter.kt) the data that you’re expecting. - If
datais valid, you’re going to update the currentitemslist, otherwise it’s discarded.
Remove the onNewDataAvailable extension function and the FeedHandler declaration and usage, which are no longer necessary.
Finally, open the KodecoEntryViewModel.swift and update the fetchFeeds function:
func fetchFeeds() {
Task {
let test = await FeedClient.shared.fetchFeeds()
DispatchQueue.main.async {
self.items = test
self.fetchLinkImage()
}
}
}
It creates a task to avoid blocking the main thread. Once new data is available it returns to it and updates self.items which in turn notifies the app that there’s new content to update. After the RSS feed is received, the app fetches its corresponding images.
Compile and run the app.
Challenge
Here’s a challenge for you to practice what you’ve learned in this chapter. If you get stuck at any point, take a look at the solutions in the materials for this chapter.
Challenge: Fetch the Article Images From the Shared Module
Instead of requesting the article images from the UI, move this logic to the shared module.
Remember that you don’t need to run this logic sequentially — you can launch multiple coroutines to fetch and parse the response, making this operation faster.
The requests should run in parallel.
Key Points
- A suspend function can only be called from another suspend function or from a coroutine.
- You can use
launchorasyncto create and start a coroutine. - A coroutine can start a thread from Main, IO or Default thread pools.
- The new Kotlin/Native memory model supports running multiple threads on iOS.
Where to Go From Here?
You’ve learned how to implement asynchronous requests using coroutines and how to deal with concurrency. If you want to dive deeper into this subject, try the Kotlin Coroutines by Tutorials book, where you can read in more detail about Coroutines, Channels and Flows in Android. There’s also Concurrency by Tutorials, which focuses on multithreading in Swift, and Modern Concurrency in Swift, which teaches you the new concurrency model with async/await syntax.
In the next chapter, you’ll learn how to migrate a feature to support Kotlin Multiplatform and release your libraries so that you can later reuse them in your projects.