In this codelab you will create an iOS and Android application, by making use of Kotlin's code sharing features. For Android you'll be using Kotlin/JVM, while for iOS it will be Kotlin/Native.
This codelab will show you the ability to share code within Kotlin and the benefits it provides. While what we'll be looking at is a simplified application, what is shown here can be applied to real world applications, independent of their size or complexity.
You will learn how to:
You need Android Studio 3.4+ for the Android part of the tutorial.
The Kotlin plugin 1.3.41 or higher should be installed in the IDE. This can be verified via Language & Frameworks | Kotlin Updates section in the Settings (or Preferences) window.
For the iOS part of the tutorial, you need a macOS 10.14+ host with Xcode 10.3+ and the tools installed and configured.
Clone the workshop project repository :
git clone https://github.com/mlumeau/workshop-kmp.git
Checkout the branch step_one_setup
:
cd workshop-kmp
git checkout step_one_setup
This project contain an Android app, a library and an iOS project.
The project should sync and you should be able to compile and run the Android application on an emulator or a real device.
You should see a blank screen :
First you have to prepare the framework for iOS
./gradlew :kore:packForXCode
It creates the directory kore/build/xcode-frameworks
which contains a gradlew executable and the framework for Xcode.
by opening the workspace file : ../workshop-kmp/iosApp/kosmos/kosmos.xcworkspace
You can now compile and run the project on an iOS emulator or on a real device.
You should see a blank screen :
The goal of this step is to define a common method which creates a greetings text and adds specific implementations for iOS and Android in the Kotlin common code.
As a result, we will creates an Android module and an iOS framework both exposing the same method createApplicationScreenMessage
but having different implementation.
First add this code in the common directory of the kore library : kore/src/commonMain/kotlin/xyz/mlumeau/kosmos/kore/common.kt
⚠️ IMPORTANT : Don't forget to switch to the Project View !!!
package xyz.mlumeau.kosmos.kore
expect fun platformName(): String
fun createApplicationScreenMessage(): String {
return "Kotlin Rocks on ${platformName()}"
}
Now edit the android directory : workshop-kmp/kore/src/androidMain/kotlin/xyz/mlumeau/kosmos/kore/actual.kt
package xyz.mlumeau.kosmos.kore
actual fun platformName(): String {
return "Android"
}
In the Android main project "androidApp", update the MainActivity :
Add a TextView in res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.MainActivity">
<TextView
android:id="@+id/title_tv"
android:textStyle="bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true" />
</RelativeLayout>
Handle this textview in java/xyz.mlumeau.kosmos.views/MainActivity
(Kotlin file)
package xyz.mlumeau.kosmos.views
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.*
import xyz.mlumeau.kosmos.R
import xyz.mlumeau.kosmos.kore.createApplicationScreenMessage
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
title_tv.text = createApplicationScreenMessage()
}
}
Run it and you should see :
Now edit the iOS directory : workshop-kmp/kore/src/iosMain/kotlin/xyz/mlumeau/kosmos/kore/actual.kt
package xyz.mlumeau.kosmos.kore
import platform.UIKit.UIDevice
actual fun platformName(): String {
return UIDevice.currentDevice.systemName() +
" " +
UIDevice.currentDevice.systemVersion
}
Back to Xcode !
If you are familiar with Storyboard, add a "titleTV" TextView in the center of the MainView and set the reference to the MainViewController.titleTV @IBOutlet. If you prefer, download the storyboard from the next step.
And then update the MainViewController to handle the "titleTV" textView
import UIKit
import Nuke
import kore
class MainViewController: UIViewController {
@IBOutlet weak var titleTV: UITextView!
override func viewDidLoad() {
super.viewDidLoad()
self.titleTV.text = CommonKt.createApplicationScreenMessage()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return self.style
}
var style:UIStatusBarStyle = .default
}
You can now compile and run the project on an iOS emulator or on a real device.
First create a common Model : kore/src/commonMain/kotlin/xyz/mlumeau/kosmos/kore/model/APOD.kt
package xyz.mlumeau.kosmos.kore.model
import kotlinx.serialization.Serializable
@Serializable
data class APOD(
val explanation: String? = null,
val media_type: String? = null,
val title : String? = null,
val url: String? = null
)
Update the common file : .../kore/common.kt
(You can remove the plaftorm example...)
package xyz.mlumeau.kosmos.kore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlin.coroutines.CoroutineContext
internal expect val coroutineScope: CoroutineScope
internal abstract class Scope(
private val dispatcher: CoroutineDispatcher
) : CoroutineScope {
private val job = Job()
override val coroutineContext: CoroutineContext
get() = dispatcher + job
}
Now create the repository cache interface : .../kore/data/APODRepositoryCache.kt
package xyz.mlumeau.kosmos.kore.data
import xyz.mlumeau.kosmos.kore.model.APOD
interface APODRepositoryCache {
fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit)
}
This interface offers a way to retrieve APOD data and takes a completion and a failure callbacks as parameters to return the resutl.
And finally the repository cache implementation : .../kore/data/APODRepositoryCacheImpl.kt
which contains the APOD stub in its companion object.
package xyz.mlumeau.kosmos.kore.data
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import xyz.mlumeau.kosmos.kore.coroutineScope
import xyz.mlumeau.kosmos.kore.model.APOD
class APODRepositoryCacheImpl : APODRepositoryCache {
private suspend fun getAPOD(): APOD? = Json.nonstrict.parse(APOD.serializer(), APOD_STUB)
override fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit) {
coroutineScope.launch {
var apod: APOD? = null
try {
apod = getAPOD()
} catch (e: Exception) {
println(e.message)
}
if (apod != null) {
completion(apod)
} else {
failure()
}
}
}
companion object {
private const val APOD_STUB =
"{\"date\":\"2019-08-31\",\"explanation\":\"Few cosmic vistas excite the imagination like the Orion Nebula, an immense stellar nursery some 1,500 light-years away. Spanning about 40 light-years across the region, this infrared image from the Spitzer Space Telescope was constructed from data intended to monitor the brightness of the nebula's young stars, many still surrounded by dusty, planet-forming disks. Orion's young stars are only about 1 million years old, compared to the Sun's age of 4.6 billion years. The region's hottest stars are found in the Trapezium Cluster, the brightest cluster near picture center. Launched into orbit around the Sun on August 25, 2003 Spitzer's liquid helium coolant ran out in May 2009. The infrared space telescope continues to operate though, its mission scheduled to end on January 30, 2020. Recorded in 2010, this false color view is from two channels that still remain sensitive to infrared light at Spitzer's warmer operating temperatures.\",\"hdurl\":\"https://apod.nasa.gov/apod/image/1908/orion2010_spitzer.jpg\",\"media_type\":\"image\",\"service_version\":\"v1\",\"title\":\"Spitzer's Orion\",\"url\":\"https://apod.nasa.gov/apod/image/1908/orion2010_spitzerMedRC.jpg\"}"
}
}
You have complete the common part of the kore library !
Now edit the android part of the kore library : workshop-kmp/kore/src/androidMain/kotlin/xyz/mlumeau/kosmos/kore/actual.kt
package xyz.mlumeau.kosmos.kore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
internal actual val coroutineScope = IOScope() as CoroutineScope
internal class IOScope : Scope(Dispatchers.IO)
Now that we have a data model and a repository to provide it, we will create the user interface to display the data content.
Let's start with the Android application. In the Android main project "androidApp", update the MainActivity :
Remove the TextView and add an Image, a title, a text and a progressbar in res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".views.MainActivity">
<ScrollView android:layout_width="match_parent" android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="16dp">
<ImageView android:id="@+id/apod_iv"
android:layout_width="match_parent"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintTop_toTopOf="parent"
android:contentDescription="@string/astronomy_picture_of_the_day"/>
<TextView
android:id="@+id/title_tv"
android:textStyle="bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:layout_marginTop="32dp" app:layout_constraintTop_toBottomOf="@+id/apod_iv"
android:layout_marginLeft="16dp" android:layout_marginRight="16dp"/>
<TextView
android:id="@+id/desc_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintStart_toStartOf="parent" android:layout_marginStart="16dp"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="16dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@+id/title_tv"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<ProgressBar
android:id="@+id/progress"
android:indeterminate="true"
android:layout_width="wrap_content" android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent" android:layout_marginEnd="8dp"
android:layout_centerInParent="true"
/>
</RelativeLayout>
Handle these views in java/xyz.mlumeau.kosmos.views/MainActivity
(Kotlin file)
package xyz.mlumeau.kosmos.views
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import com.squareup.picasso.Picasso
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
import xyz.mlumeau.kosmos.R
import xyz.mlumeau.kosmos.kore.model.APOD
import xyz.mlumeau.kosmos.kore.data.APODRepositoryCache
import xyz.mlumeau.kosmos.kore.data.APODRepositoryCacheImpl
class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
private val apodRepository: APODRepositoryCache = APODRepositoryCacheImpl()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
getAPOD()
}
private fun updateAPODData(apod: APOD) {
runOnUiThread {
title_tv.text = apod.title
desc_tv.text = apod.explanation
if (apod.media_type == "image" && !apod.url.isNullOrEmpty()) {
Picasso.get().load(apod.url).fit().centerCrop().into(apod_iv)
} else {
apod_iv.visibility = View.GONE
}
progress.visibility = View.GONE
}
}
private fun getAPOD() {
apodRepository.getAPOD(
this::updateAPODData,
this::onAPODLoadingError
)
}
private fun onAPODLoadingError() {
// Handle the error
}
}
Run it and you should see :
Now edit the iOS part of the library : workshop-kmp/kore/src/iosMain/kotlin/xyz/mlumeau/kosmos/kore/actual.kt
package xyz.mlumeau.kosmos.kore
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Runnable
import platform.darwin.dispatch_async
import platform.darwin.dispatch_get_main_queue
import kotlin.coroutines.CoroutineContext
internal class MainDispatcher : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
dispatch_async(dispatch_get_main_queue()) {
block.run()
}
}
}
internal class MainScope : Scope(MainDispatcher())
internal actual val coroutineScope = MainScope() as CoroutineScope
Back to Xcode ! We will now create the user interface for the iOS application.
If you are familiar with Storyboard, add the progressbar, UIImageView and another textview for description in the MainView (see picture below) and add references to the MainViewController.
If you prefer, download the storyboard from the next step.
And then update the MainViewController
code...
import UIKit
import Nuke
import kore
class MainViewController: UIViewController {
@IBOutlet weak var apodIV: UIImageView!
@IBOutlet weak var titleTV: UITextView!
@IBOutlet weak var descTV: UITextView!
@IBOutlet weak var progress: UIActivityIndicatorView!
private let apodRepository: APODRepositoryCache = APODRepositoryCacheImpl()
override func viewDidLoad() {
super.viewDidLoad()
startLoadingData()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return self.style
}
var style:UIStatusBarStyle = .default
}
private extension MainViewController {
private func startLoadingData() {
apodRepository.getAPOD(completion: { apod in
self.updateAPODData(apod: apod)
}, failure: { () in
self.onLoadingError()
})
}
func updateAPODData(apod: APOD) {
self.progress.isHidden = true
titleTV.text = apod.title
descTV.text = apod.explanation
if(apod.media_type == "image") {
if let imageURL = apod.url, let url = URL(string: imageURL) {
Nuke.loadImage(with: url, into: self.apodIV)
}
} else {
apodIV.removeFromSuperview()
}
}
func onLoadingError() {}
}
You can now compile and run the project on an iOS emulator or on a real device.
First create the Nasa API service interface : .../kore/service/nasa/NasaApi.kt
package xyz.mlumeau.kosmos.kore.service.nasa
import xyz.mlumeau.kosmos.kore.model.APOD
internal interface NasaApi {
suspend fun getAPOD(): APOD?
}
And the implementation : .../kore/service/nasa/NasaApiRemote.kt
package xyz.mlumeau.kosmos.kore.service.nasa
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.readText
import kotlinx.serialization.json.Json
import xyz.mlumeau.kosmos.kore.model.APOD
internal class NasaAPIRemote(
private val client: HttpClient = HttpClient()
) : NasaApi {
private suspend fun request(urlString: String): String {
return client.get<HttpResponse>(urlString).readText()
}
private suspend fun requestAPOD() : APOD {
val result = request(APOD_URL)
return Json.nonstrict.parse(APOD.serializer(), result)
}
override suspend fun getAPOD(): APOD = requestAPOD()
companion object {
const val APOD_URL = "https://api.nasa.gov/planetary/apod?&api_key=DEMO_KEY"
}
}
Now create the remote repository interface : .../kore/data/APODRepositoryRemote.kt
package xyz.mlumeau.kosmos.kore.data
import xyz.mlumeau.kosmos.kore.model.APOD
interface APODRepositoryRemote {
fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit)
}
And the implementation : .../kore/data/APODRepositoryRemoteImpl.kt
package xyz.mlumeau.kosmos.kore.data
import kotlinx.coroutines.launch
import xyz.mlumeau.kosmos.kore.coroutineScope
import xyz.mlumeau.kosmos.kore.model.APOD
import xyz.mlumeau.kosmos.kore.service.nasa.NasaAPIRemote
import xyz.mlumeau.kosmos.kore.service.nasa.NasaApi
class APODRepositoryRemoteImpl : APODRepositoryRemote {
private val nasaAPI: NasaApi = NasaAPIRemote()
private suspend fun getAPOD() = nasaAPI.getAPOD()
override fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit) {
coroutineScope.launch {
var apod: APOD? = null
try {
apod = getAPOD()
} catch (e: Exception) {
println(e.message)
}
if (apod != null) {
completion(apod)
} else {
failure()
}
}
}
}
In the Android main project "androidApp", update the java/xyz.mlumeau.kosmos.views/MainActivity
(Kotlin file) by replacing the Cache repository with Remote Repository :
...
import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemote
import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemoteImpl
class MainActivity : AppCompatActivity() {
private val apodRepository: APODRepositoryRemote = APODRepositoryRemoteImpl()
...
Run it and you should see a new picture : the Astronomy picture of the day !
const val APOD_URL = "https://api.nasa.gov/planetary/apod?&api_key=DEMO_KEY&date=2019-10-20"
Take some time to celebrate 🎉!!!
Back to Xcode !
Update the MainViewController
code by replacing the Cache Repository by a Remote Repository :
...
class MainViewController: UIViewController {
...
private let apodRepository: APODRepositoryRemote = APODRepositoryRemoteImpl()
You can now compile and run the project on an iOS emulator or on a real device to see the new picture of the day ! iOS celebration time 🥳!!!
Nothing to do in the Kore library this time !
In the Android main project "androidApp", create a viewmodels directory.
Add a new class APOD view model : .../viewmodels/APODViewModel.kt
package xyz.mlumeau.kosmos.viewmodels
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemote
import xyz.mlumeau.kosmos.kore.model.APOD
class APODViewModel(
private val apodRepository: APODRepositoryRemote
) : ViewModel() {
private val _apod = MutableLiveData<APOD>()
val apod: LiveData<APOD>
get() = _apod
init {
startLoadingData()
}
private fun startLoadingData() {
apodRepository.getAPOD(
this::onAPODLoaded,
this::onAPODLoadingError
)
}
private fun onAPODLoaded(apod: APOD) {
_apod.postValue(apod)
}
private fun onAPODLoadingError() {
// Handle the error
}
}
Add a factory : .../viewmodels/APODViewModelFactory.kt
package xyz.mlumeau.kosmos.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemoteImpl
class APODViewModelFactory : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass == APODViewModel::class.java) { "Unknown ViewModel class" }
return APODViewModel(
APODRepositoryRemoteImpl()
) as T
}
}
update the java/xyz.mlumeau.kosmos.views/MainActivity
(Kotlin file) by replacing the getApod() function with a call to the ViewModel :
...
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import xyz.mlumeau.kosmos.viewmodels.APODViewModel
import xyz.mlumeau.kosmos.viewmodels.APODViewModelFactory
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// getApod()
val model = ViewModelProvider(this, APODViewModelFactory())[APODViewModel::class.java]
model.apod.observe(this, Observer { apod -> updateAPODData(apod) })
}
The function getApod() can be removed.
Run it to validate the new architecture.
Back to Xcode !
Create a ViewModels directory.
Add a new class : .../ViewModels/MainViewModel
import Foundation
import kore
final class MainViewModel {
private let apodRepository: APODRepositoryRemote = APODRepositoryRemoteImpl()
var apod: APOD? = nil
var onAPODLoaded: ((APOD) -> ())? = nil
var onLoadingError: (() -> ())? = nil
init() {
startLoadingData()
}
private func startLoadingData() {
apodRepository.getAPOD(completion: { apod in
self.apod = apod
self.onAPODLoaded?(apod)
}, failure: { () in
self.onLoadingError?()
})
}
}
Update the MainViewController
code by replacing the Repository by the View Model :
...
// private let apodRepository: APODRepositoryRemote = APODRepositoryRemoteImpl()
let viewModel = MainViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// startLoadingData()
configureUI()
configureBinding()
}
...
private extension MainViewController {
// private func startLoadingData() {
// apodRepository.getAPOD(completion: { apod in
// self.updateAPODData(apod: apod)
// return .init()
// }, failure: { () in
// self.onLoadingError()
// return .init()
// })
// }
func configureUI() {
}
func configureBinding() {
viewModel.onAPODLoaded = updateAPODData
viewModel.onLoadingError = onLoadingError
}
...
You can now compile and run the project to validate the architecture updates !
To follow up with on the previous step and follow the single responsability principle, we will create a use case to retrieve APOD data. The main goal is to abstract the logic behind this operation. No needs for the final app to know how the data will be retrieved.
First create a usecase GetAPOD : .../kore/usecases/GetAPOD.kt
package xyz.mlumeau.kosmos.kore.usecases
import xyz.mlumeau.kosmos.kore.model.APOD
interface GetAPOD {
fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit)
}
, a usecase GetConnectionState : .../kore/usecases/GetConnectionState.kt
package xyz.mlumeau.kosmos.kore.usecases
interface GetConnectionState {
fun isConnectedToNetwork(): Boolean
}
and the GetAPOD implementation : .../kore/usecases/implementations/GetAPODImpl.kt
package xyz.mlumeau.kosmos.kore.usecases.implementations
import kotlinx.coroutines.launch
import xyz.mlumeau.kosmos.kore.coroutineScope
import xyz.mlumeau.kosmos.kore.data.APODRepositoryCache
import xyz.mlumeau.kosmos.kore.data.APODRepositoryCacheImpl
import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemote
import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemoteImpl
import xyz.mlumeau.kosmos.kore.model.APOD
import xyz.mlumeau.kosmos.kore.usecases.GetAPOD
import xyz.mlumeau.kosmos.kore.usecases.GetConnectionState
class GetAPODImpl(private val getConnectionState: GetConnectionState) : GetAPOD {
private val apodRepositoryCache: APODRepositoryCache = APODRepositoryCacheImpl()
private val apodRepositoryRemote: APODRepositoryRemote = APODRepositoryRemoteImpl()
private suspend fun getAPOD() = if (getConnectionState.isConnectedToNetwork()) {
apodRepositoryRemote.getAPOD()
} else {
apodRepositoryCache.getAPOD()
}
override fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit) {
coroutineScope.launch {
var apod: APOD? = null
try {
apod = getAPOD()
} catch (e: Exception) {
println(e.message)
}
if (apod != null) {
completion(apod)
} else {
failure()
}
}
}
}
Now update the repository interfaces to replace the getAPOD with params function by a suspend function : kore/data/APODRepositoryCache.kt
and kore/data/APODRepositoryRemote.kt
// fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit)
suspend fun getAPOD(): APOD?
Update the cache implementation kore/data/APODRepositoryCacheImpl.kt
by deleting the getAPOD with param function and transform the suspend function from private to override :
override suspend fun getAPOD(): APOD? = Json.nonstrict.parse(APOD.serializer(), APOD_STUB)
// override fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit) {
// ...
Do the same in the remote implementation kore/data/APODRepositoryRemoteImpl.kt
:
override suspend fun getAPOD() = nasaAPI.getAPOD()
// override fun getAPOD(completion: (APOD) -> Unit, failure: () -> Unit) {
// ...
In the Android main project "androidApp",
Add a usecase : .../usecases/GetConnectionStateAndroid.kt
package xyz.mlumeau.kosmos.usecases
import android.net.ConnectivityManager
import android.net.NetworkInfo
import xyz.mlumeau.kosmos.kore.usecases.GetConnectionState
class GetConnectionStateAndroid(
private val connectivityManager: ConnectivityManager
) : GetConnectionState {
override fun isConnectedToNetwork(): Boolean {
val activeNetwork: NetworkInfo? = connectivityManager.activeNetworkInfo
return activeNetwork?.isConnected == true
}
}
Update the viewmodel : .../viewmodels/APODViewModel.kt
...
// import xyz.mlumeau.kosmos.kore.data.APODRepositoryRemote
import xyz.mlumeau.kosmos.kore.usecases.GetAPOD
class APODViewModel(
// private val apodRepository: APODRepositoryRemote
private val getApodUseCase: GetAPOD
) : ViewModel() {
...
private fun startLoadingData() {
getApodUseCase.getAPOD(
this::onAPODLoaded,
this::onAPODLoadingError
)
}
}
And the factory : .../viewmodels/APODViewModelFactory.kt
package xyz.mlumeau.kosmos.viewmodels
import android.content.Context
import android.net.ConnectivityManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import xyz.mlumeau.kosmos.kore.usecases.GetConnectionState
import xyz.mlumeau.kosmos.kore.usecases.implementations.GetAPODImpl
import xyz.mlumeau.kosmos.usecases.GetConnectionStateAndroid
class APODViewModelFactory(
private val context: Context
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass == APODViewModel::class.java) { "Unknown ViewModel class" }
val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val getConnectionState: GetConnectionState = GetConnectionStateAndroid(connectivityManager)
return APODViewModel(
GetAPODImpl(getConnectionState)
) as T
}
}
Update the java/xyz.mlumeau.kosmos.views/MainActivity
(Kotlin file) to add a context parameter in the factory constructor :
...
val model = ViewModelProvider(this, APODViewModelFactory(this))[APODViewModel::class.java]
...
Everything's fine ???
Back to Xcode !
Add a usecase : .../UseCases/GetConnectionStateIos
import UIKit
import SystemConfiguration
import kore
class GetConnectionStateIos: NSObject, GetConnectionState {
func isConnectedToNetwork() -> Bool {
var zeroAddress = sockaddr_in(sin_len: 0, sin_family: 0, sin_port: 0, sin_addr: in_addr(s_addr: 0), sin_zero: (0, 0, 0, 0, 0, 0, 0, 0))
zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress))
zeroAddress.sin_family = sa_family_t(AF_INET)
let defaultRouteReachability = withUnsafePointer(to: &zeroAddress) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {zeroSockAddress in
SCNetworkReachabilityCreateWithAddress(nil, zeroSockAddress)
}
}
var flags: SCNetworkReachabilityFlags = SCNetworkReachabilityFlags(rawValue: 0)
if SCNetworkReachabilityGetFlags(defaultRouteReachability!, &flags) == false {
return false
}
/* Only Working for WIFI
let isReachable = flags == .reachable
let needsConnection = flags == .connectionRequired
return isReachable && !needsConnection
*/
// Working for Cellular and WIFI
let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0
let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0
let ret = (isReachable && !needsConnection)
return ret
}
}
Update the MainViewModel
code by replacing the Repository by the use case :
...
final class MainViewModel {
// private let apodRepository: APODRepositoryRemote = APODRepositoryRemoteImpl()
private let getConnectionState: GetConnectionState = GetConnectionStateIos()
private let getApodUseCase: GetAPOD
...
init() {
getApodUseCase = GetAPODImpl(getConnectionState: getConnectionState)
startLoadingData()
}
private func startLoadingData() {
// apodRepository.getAPOD(completion: { apod in
getApodUseCase.getAPOD(completion: { apod in
self.apod = apod
self.onAPODLoaded?(apod)
}, failure: { () in
self.onLoadingError?()
})
}
}
You can now compile and run the project to validate the architecture updates !
Let's think about some improvements we can make to this app.
How about improving the GetAPOD
use case with a cache management? New APOD data is available every day. Knowing this there is no point making multiple requests to the NASA's API during the same day. We could then have a cache system which stores the APOD data with an associated time. The APODRepositoryCache
will then be able to serve this persisted data and provide information about its expiration. The GetAPOD
use case will then be able to decide whether it should use the APODRepositoryCache
if cached data is available and not expired or the APODRepositoryRemote
to get fresh data.
Let's modify the APODRepositoryCache
interface (xyz.mlumeau.kosmos.kore.data.APODRepositoryCache
) and add two methods:
package xyz.mlumeau.kosmos.kore.usecases
import xyz.mlumeau.kosmos.kore.model.APOD
interface APODRepositoryCache {
suspend fun getAPOD(): APOD?
fun setLastCacheTime(lastCache: Long)
fun isProjectsCacheExpired(): Boolean
}
It's now your turn to create your own Kotlin Multiplatform code.
You should first update the implementation of the APODRepositoryCache
interface (APODRepositoryCacheImpl
) to provide persistence capabilites. You can either choose a plateform specific solution or use some Koltin Multiplatform compatible solution such as SQLdelight.
Once you have your improved cache system working, you can then update the GetAPOD
use case implementation (GetAPODImpl
) to use those new capabilities.