Share
Embed
The Hitchhikers Guide Through Kotlin Multiplatform DCGreece
Since the early days of mobile that we keep seeing new frameworks being designed to overcome one of the biggest challenges:
- How can I develop for both Android and iOS?
Although it’s initial promises, when we talk about performance, maintainability or even customization we keep discarding these solutions and we always choose native.
Fast forward to the present, and now we have two new languages: Android is Kotlin first and iOS, Swift. And if you put them side by side you can see a lot of similarities between both what will ease switching between one to the other if you have to develop for both platforms.
But what I told that you could just develop in Kotlin and run it seamlessly on all devices? Here comes Kotlin Multiplatform!
cmota
September 24, 2019
Transcript
111 slides
-
The Hitchhikers Guide Through The Hitchhikers Guide Through Kotlin Multiplatform
Kotlin Multiplatform @cafonsomota droidcon Greece: revised and updated edition droidcon Greece: revised and updated edition -
a brief history of time “In the beginning the Universe
was created. This had made many people angry and has been widely regarded as a bad move.” -
Photo by Fabian Grohs on Unsplash a wild application idea
appears! develop it for Android and iOS. -
-hire a team of specialised developers -define requirements -design mockups
for both platforms -plan features development -start development/ write unit tests -write t-specs for SQA validation -cross-checking validation between both platforms (or jacks) Steps -
~2xthe team the cost the status meetings required the time
spent on development the time spent on testing native the time spent on bug fixing -
Photo by Fabian Grohs on Unsplash a wild application idea
appears! develop it for Android and iOS and web. -
~3xthe team the cost the status meetings required the time
spent on development the time spent on testing the time spent on bug fixing -
2008 2012 2018 2013 2011 2017 2006 2010 2009 2015
* Kotlin Multiplatform * -
- smaller team - typically half of the cost needed
on native - small learning curve for web developers (advantages) Cross-platform -
- chained to the framework implementation of UI - new
updates from the OS will take time to adopt - performance is not the same - some native code might need to be written - OS/ device features are dependent on the fw support - dart (flutter) is not widely used language (for now) - committed to one framework/ language (disadvantages) Cross-platform -
- developed by JetBrains - open source - concise, safe,
interoperable, tool-friendly - supported/ used by Google for Android Development - it’s more than “just for Android” Kotlin Server-side Android Kotlin JS Native -
Mutability etc. Null safety Default values Type inference String interpolation
when Collections Easy to learn -
Easy to learn - small learning curve involved - easily
to go from JavaScript/ Swift into Kotlin and back (from a developers’ point of view) -
var variable = 42 variable = 1 let value =
42 var variable = 42 variable = 1 val value = 42 *adapted from http://nilhcem.com/swift-is-like-kotlin/ (variables and constants) Kotlin vs Swift Kotlin Swift -
fun greet(name: String, day: String): String { return "Hello $name,
today is $day." } greet(“Greece”, “Tuesday”) *updated from http://nilhcem.com/swift-is-like-kotlin/ func greet(_ name: String,_ day: String) -> String { return "Hello \(name), today is \(day)." } greet(“Greece”, “Tuesday”) Kotlin Swift (functions) Kotlin vs Swift -
class Shape { var numberOfSides = 0 func simpleDescription() ->
String { return "A shape with \(numberOfSides) sides." } } var shape = Shape() shape.numberOfSides = 7 var shapeDescription = shape.simpleDescription() class Shape { var numberOfSides = 0 fun simpleDescription() = "A shape with $numberOfSides sides." } var shape = Shape() shape.numberOfSides = 7 var shapeDescription = shape.simpleDescription() (variables and constants) Kotlin vs Swift Kotlin Swift -
kotlin multiplatform “‘Resistance is useless!’ How can anyone maintain a
positive mental attitude if you’re saying things like that?” -
K otlin multiplatform using kotlin in projects that target more
than one platform -
view view view view model parser network presentation presentation presentation
presentation network network network parser parser parser model model model presentation presentation presentation desktop web iOS android -
business logic business logic business logic model parser network presentation
model parser network presentation model parser network presentation model parser network presentation business logic view view view view desktop web iOS android -
android iOS web desktop model parser network presentation common view
view view view java/kotlin objective-c/ swift (kotlin) JS supported in jvm -
(advantages) - language features - kotlin first! - low risk
- you decide what’s worth to share across projects - interoperability - consistency across platforms - strong community support Kotlin Multiplatform -
declared at common module expect declared at android module actual
declared at iOS module declared at … -
commonMain src/commonMain/sample/Platform.kt - define actual on android - define actual
on iOS targets: android and iOS expect object Platform { val name: String } -
src/iOSMain/sample/Platform.kt actual object Platform { actual val name: String =
"Android" } actual object Platform { actual val name: String = "iOS" } src/androidMain/sample/Platform.kt platform-dependent code -
*.kt common expect JVM actual *.kt, *.java, *.jar Native actual
*.kt, *C, Swift, Framework JS actual *.kt, *.js, NPM -
demo "For a moment, nothing happened. Then, after a second
or so, nothing continued to happen.” -
src/ commonMain/ commonTest/ app/ project structure <your code shared across
all targets goes here> -
androidMain androidTest project structure <your code shared across all targets
goes here> src/ commonMain/ commonTest/ app/ -
project structure <your android code here> <your code shared across
all targets goes here> src/ commonMain/ commonTest/ app/ androidMain androidTest -
project structure <your android code here> <your code shared across
all targets goes here> src/ commonMain/ commonTest/ app/ androidMain androidTest iosMain/ iosTest/ jsMain/ jsTest/ <your JS code goes here> <your platform iOS code here> -
- user interface - RecyclerView and more (Android) - UITableViewController
and more (iOS) - multiple network request - parse response objects - store on local database - notify the UI - that there’s new content available to be draw - reload list project structure -
android iOS Room CoreData Retrofit Alamofire GSON/ Moshi JSONSerialization MVP,
MVVM, MVI MVVM, ELM RxJava RxSwift Tests Tests Activity UIViewController RecyclerView UITableView -
Room CoreData Retrofit Alamofire GSON/ Moshi JSONSerialization MVP, MVVM, MVI
MVVM, ELM RxJava RxSwift Tests Tests Activity UIViewController RecyclerView UITableView android iOS -
Room CoreData Retrofit Alamofire GSON/ Moshi JSONSerialization MVP, MVVM, MVI
MVVM, ELM RxJava RxSwift Tests Tests Activity UIViewController RecyclerView UITableView android iOS Activity UIViewController RecyclerView UITableView unnecessary duplication -
multiplatform httpClient ktor SQLDelight android kotlinx.serialization SQLDriver dispatcher view presenter
presenter model GetSchedule GetSpeakers -
multiplatform httpClient ktor SQLDelight iOS model kotlinx.serialization GetSchedule GetSpeakers SQLDriver
dispatcher view presenter presenter -
class SessionizeAPI(engine: HttpClientEngine) { private val client = HttpClient(engine) {
install(JsonFeature) { serializer = KotlinxSerializer() } } suspend fun fetchSpeakers(): List<SpeakerEntity> { val response = client.get<HttpResponse>{ url ("$URL")} val json = response.readText() return Json.parse(SpeakerEntity.serializer().list, json) } } network src/commonMain/data/SessionizeAPI.kt ktor multiplatform network database android parser view platform specific presenter presenter domain model common (shared) code -
kotlinx.serialization network src/commonMain/data/SessionizeAPI.kt class SessionizeAPI(engine: HttpClientEngine) { private val client
= HttpClient(engine) { install(JsonFeature) { serializer = KotlinxSerializer() } } suspend fun fetchSpeakers(): List<SpeakerEntity> { val response = client.get<HttpResponse>{ url ("$URL")} val json = response.readText() return Json.parse(SpeakerEntity.serializer().list, json) } } multiplatform network database android parser view platform specific presenter presenter domain model -
json response [{ "id": "2bc1e95f-1243-4ad0-8a11-75541fee10e0", "firstName": "Carlos", "lastName": "Mota", "fullName":
"Carlos Mota", "bio": "… GDG Coimbra organizer and Kotlin evangelist, he also has a huge passion for travel, photography, space and the occasional run.", "tagLine": "Lead Software Engineer at WIT Software", "profilePicture": “https://sessionize.com/imag...7ef8707ae.jpg”, "sessions": [{ "id": 131513, "name": "The Hitchhikers Guide Through Kotlin Multiplatform" }], … },… https://sessionize.com/api/v2/3hvwlgcc/view/speakers -
json response [{ "id": "2bc1e95f-1243-4ad0-8a11-75541fee10e0", "firstName": "Carlos", "lastName": "Mota", "fullName":
"Carlos Mota", "bio": "… GDG Coimbra organizer and Kotlin evangelist, he also has a huge passion for travel, photography, space and the occasional run.", "tagLine": "Lead Software Engineer at WIT Software", "profilePicture": “https://sessionize.com/imag...7ef8707ae.jpg”, "sessions": [{ "id": 131513, "name": "The Hitchhikers Guide Through Kotlin Multiplatform" }], … },… https://sessionize.com/api/v2/3hvwlgcc/view/speakers -
kotlinx.serialization src/commonMain/data/entities/SpeakerEntity.kt multiplatform network database android parser view platform specific
presenter presenter domain model parser @Serializable data class SpeakerEntity ( val id: String, val firstName: String, val lastName: String, val fullName: String, val bio: String, val tagLine: String, val profilePicture: String, val sessions: List<SessionShortEntity>, val isTopSpeaker: Boolean, val links: List<String>, val questionAnswers: List<String>, val categories: List<String>) -
kotlinx.serialization src/commonMain/data/entities/SpeakerEntity.kt multiplatform network database android parser view platform specific
presenter presenter domain model parser @Serializable data class SpeakerEntity ( val id: String, val firstName: String, val lastName: String, val fullName: String, val bio: String, @SerialName("tagLine") val jobTitle: String, val profilePicture: String, val sessions: List<SessionShortEntity>, val isTopSpeaker: Boolean, val links: List<String>, val questionAnswers: List<String>, val categories: List<String>) -
database CREATE TABLE SpeakerModel ( id TEXT NOT NULL PRIMARY
KEY, speaker TEXT as Speaker NOT NULL ); insertOrReplaceSpeaker: INSERT OR REPLACE INTO SpeakerModel(id, speaker) VALUES (?, ?); selectAllSpeakers: SELECT * FROM SpeakerModel; SQLDelight src/commonMain/sqldelight/data/model/SpeakerModel.sq multiplatform network database android parser view platform specific presenter presenter domain model -
database SQLite SQLDelight multiplatform network database android parser view platform
specific presenter presenter domain model -
database SQLite Compiler Generated code SQLDelight multiplatform network database android
parser view platform specific presenter presenter domain model -
database Generated code SQLDelight multiplatform network database android parser view
platform specific presenter presenter domain model -
app/build/sqldelight/ScheduleDb/data/app/ScheduleDbImpl.kt database class SpeakerModelQueriesImpl(private val db: ScheduleDbImpl, private val driver:
SqlDriver) : TransacterImpl(driver), SpeakerModelQueries { … override fun insertOrReplaceSpeaker(id: String, speaker: Speaker) { driver.execute(2113668020, """INSERT OR REPLACE INTO SpeakerModel(id, speaker) |VALUES (?1, ?2)""", 2) { bindString(1, id) bindString(2, db.SpeakerModelAdapter.speakerAdapter.encode(speaker)) } notifyQueries(2113668020, {db.speakerModelQueries.selectAllSpeakers}) } } generated class SQLDelight multiplatform network database android parser view platform specific presenter presenter domain model -
model class SpeakerDao(database: ScheduleDb) { private val db = database.speakerModelQueries
internal fun insertOrReplace(speaker: Speaker) { db.insertOrReplaceSpeaker( id = speaker.id, speaker = speaker) } internal fun getAllSpeakers(): List<Speaker> { val data = db.selectAllSpeakers().executeAsList() … src/commonMain/domain/dao/SpeakerDao.kt multiplatform network database android parser view platform specific presenter presenter domain model -
model @Serializable data class Speaker ( val id: String, val
fullName: String, val bio: String, val tagLine: String, val profilePicture: String, val sessions: List<Info>, val categories: List<String>) fun SpeakerEntity.toSpeaker() = Speaker( id = id, fullName = fullName, bio = bio, …) src/commonMain/domain/model/Speaker.kt multiplatform network database android parser view platform specific presenter presenter domain model -
domain class GetSpeakers(val api: SessionizeAPI, val dao: SpeakerDao) { suspend
operator fun invoke(onSuccess: (List<Speaker>) -> Unit, onFailure: (Exception) -> Unit) { try { val result = api.fetchSpeakers() val speakers = Speaker.toSpeaker(result) dao.insertOrReplace(speakers) coroutineScope { launch(uiDispatcher) { onSuccess(speakers) } } } catch (e: Exception) { onFailure(e) } src/commonMain/domain/GetSpeakers.kt multiplatform network database android parser view platform specific presenter presenter domain model -
class SpeakersListPresenter(val speakers: GetSpeakers, val coroutineContext: CoroutineContext) { lateinit var
view: ISpeakersData fun attachView(currView: ISpeakersData) { view = currView fetchSpeakersList() } … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/presentation/SpeakersListPresenter.kt -
class SpeakersListPresenter(val speakers: GetSpeakers, val coroutineContext: CoroutineContext) { lateinit var
view: ISpeakersData fun attachView(currView: ISpeakersData) { view = currView fetchSpeakersList() } … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/presentation/SpeakersListPresenter.kt onCreate()/ viewDidLoad() -
class SpeakersListPresenter(val speakers: GetSpeakers, val coroutineContext: CoroutineContext) { lateinit var
view: ISpeakersData fun attachView(currView: ISpeakersData) { view = currView fetchSpeakersList() } … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/presentation/SpeakersListPresenter.kt -
presenter multiplatform network database android parser view platform specific presenter
presenter domain model src/commonMain/presentation/cb/ISpeakersData.kt interface ISpeakersData { fun onSpeakersDataFetched(speakers: List<Speaker>) fun onSpeakersDataFailed(e: Exception) } -
class SpeakersListPresenter(val speakers: GetSpeakers, val coroutineContext: CoroutineContext) { lateinit var
view: ISpeakersData fun attachView(currView: ISpeakersData) { view = currView fetchSpeakersList() } private fun fetchSpeakersList() { PresenterCoroutineScope(coroutineContext).launch { speakers( onSuccess = { view?.onSpeakersDataFetched(it) }, onFailure = { view?.onSpeakersDataFailed(it) }) … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/presentation/SpeakersListPresenter.kt -
class SpeakersListPresenter(val speakers: GetSpeakers, val coroutineContext: CoroutineContext) { lateinit var
view: ISpeakersData fun attachView(currView: ISpeakersData) { view = currView fetchSpeakersList() } private fun fetchSpeakersList() { PresenterCoroutineScope(coroutineContext).launch { speakers( onSuccess = { view?.onSpeakersDataFetched(it) }, onFailure = { view?.onSpeakersDataFailed(it) }) … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/presentation/SpeakersListPresenter.kt -
object ServiceLocator { private val sessionizeAPI by lazy { SessionizeAPI(PlatformServiceLocator.httpClientEngine)
} private val speakerDao by lazy { SpeakerDao(PlatformServiceLocator.databaseEngine) } private val getSpeakers: GetSpeakers get() = GetSpeakers(sessionizeAPI, speakerDao) val getSpeakersPresenter: SpeakersListPresenter get() = SpeakersPresenter(getSpeakers) … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/ServiceLocator.kt -
object ServiceLocator { private val sessionizeAPI by lazy { SessionizeAPI(PlatformServiceLocator.httpClientEngine)
} private val speakerDao by lazy { SpeakerDao(PlatformServiceLocator.databaseEngine) } private val getSpeakers: GetSpeakers get() = GetSpeakers(sessionizeAPI, speakerDao) val getSpeakersPresenter: SpeakersListPresenter get() = SpeakersPresenter(getSpeakers) … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/ServiceLocator.kt -
object ServiceLocator { private val sessionizeAPI by lazy { SessionizeAPI(PlatformServiceLocator.httpClientEngine)
} private val speakerDao by lazy { SpeakerDao(PlatformServiceLocator.databaseEngine) } private val getSpeakers: GetSpeakers get() = GetSpeakers(sessionizeAPI, speakerDao) val getSpeakersPresenter: SpeakersListPresenter get() = SpeakersPresenter(getSpeakers) … presenter multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/ServiceLocator.kt -
platform specific expect object PlatformServiceLocator { val httpClientEngine: HttpClientEngine val
databaseEngine: ScheduleDb } multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/PlatformServiceLocator.kt -
platform specific expect object PlatformServiceLocator { val httpClientEngine: HttpClientEngine val
databaseEngine: ScheduleDb } multiplatform network database android parser view platform specific presenter presenter domain model src/commonMain/PlatformServiceLocator.kt -
platform specific expect object PlatformServiceLocator { val httpClientEngine: HttpClientEngine val
databaseEngine: ScheduleDb } commonMain -
platform specific expect object PlatformServiceLocator { val httpClientEngine: HttpClientEngine val
databaseEngine: ScheduleDb } commonMain actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine by lazy { OkHttp.create() } actual val databaseEngine: SqlDriver by lazy { AndroidSqliteDriver(ScheduleDb.Schema, ctx, “app.db") … androidMain -
platform specific expect object PlatformServiceLocator { val httpClientEngine: HttpClientEngine val
databaseEngine: ScheduleDb } commonMain actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine by lazy { OkHttp.create() } actual val databaseEngine: SqlDriver by lazy { AndroidSqliteDriver(ScheduleDb.Schema, ctx, “app.db") … androidMain actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine by lazy { Ios.create() } actual val databaseEngine: SqlDriver by lazy { NativeSqliteDriver(ScheduleDb.Schema, “app.db") … iOSMain -
platform specific expect object PlatformServiceLocator { val httpClientEngine: HttpClientEngine val
databaseEngine: ScheduleDb } commonMain actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine by lazy { OkHttp.create() } actual val databaseEngine: SqlDriver by lazy { AndroidSqliteDriver(ScheduleDb.Schema, ctx, “app.db") … androidMain actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine by lazy { Ios.create() } actual val databaseEngine: SqlDriver by lazy { NativeSqliteDriver(ScheduleDb.Schema, “app.db") … iOSMain -
platform specific actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine
by lazy { OkHttp.create() } actual val databaseEngine: SqlDriver by lazy { AndroidSqliteDriver(ScheduleDb.Schema, ctx, “app.db") … androidMain actual object PlatformServiceLocator { actual val httpClientEngine: HttpClientEngine by lazy { Ios.create() } actual val databaseEngine: SqlDriver by lazy { NativeSqliteDriver(ScheduleDb.Schema, “app.db") … iOSMain multiplatform network database android parser view platform specific presenter presenter domain model -
src/commonAndroid/presenter/activities/MainActivity.kt android class MainActivity : AppCompatActivity(), ISpeakersData { val presenter
by lazy { ServiceLocator.getSpeakersPresenter } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) presenter.attachView(this) } override fun onSpeakersDataFetched(list: List<Speaker>){ setup(list) … multiplatform network database android parser view platform specific presenter presenter domain model -
src/commonAndroid/presenter/activities/MainActivity.kt android class MainActivity : AppCompatActivity(), ISpeakersData { val presenter
by lazy { ServiceLocator.getSpeakersPresenter } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) presenter.attachView(this) } override fun onSpeakersDataFetched(list: List<Speaker>){ setup(list) … multiplatform network database android parser view platform specific presenter presenter domain model -
src/commonAndroid/presenter/adapters/SpeakersListAdapter.kt android multiplatform network database android parser view platform specific
presenter presenter domain model override fun onBindViewHolder(viewHolder: SpeakerViewHolder, position: Int) { val speaker = speakers[position] Glide.with(viewHolder.speakerPhoto) .load(speaker.profilePicture) .apply(RequestOptions.circleCropTransform()) .into(viewHolder.speakerPhoto) viewHolder.speakerName.text = speaker.fullName viewHolder.talkTitle.text = speaker.talkTitle viewHolder.container.setOnClickListener { action.onUserClickAction(speaker, it) } } -
src/commonAndroid/presenter/adapters/SpeakersListAdapter.kt android override fun onBindViewHolder(viewHolder: SpeakerViewHolder, position: Int) { val
speaker = speakers[position] Glide.with(viewHolder.speakerPhoto) .load(speaker.profilePicture) .apply(RequestOptions.circleCropTransform()) .into(viewHolder.speakerPhoto) viewHolder.speakerName.text = speaker.fullName viewHolder.talkTitle.text = speaker.talkTitle viewHolder.container.setOnClickListener { action.onUserClickAction(speaker, it) } } you can keep using your android libraries multiplatform network database android parser view platform specific presenter presenter domain model -
iosApp/iosApp/SpeakerTableViewController.swift iOS multiplatform network database iOS parser view platform specific
presenter presenter domain model class SpeakerTableViewController: UITableViewController, ISpeakersData lazy var presenter = ServiceLocator.init().getSpeakerPresenter override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) presenter.attachView(view: self) } func onSpeakersDataFetched(speakers: [Speaker]) { setup(speakers) … -
iosApp/iosApp/SpeakerTableViewController.swift iOS multiplatform network database iOS parser view platform specific
presenter presenter domain model class SpeakerTableViewController: UITableViewController, ISpeakersData { lazy var presenter = ServiceLocator.init().getSpeakersPresenter override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) presenter.attachView(view: self) } func onSpeakersDataFetched(speakers: [Speaker]) { setup(speakers) … -
iosApp/iosApp/SpeakerTableViewController.swift iOS multiplatform network database iOS parser view platform specific
presenter presenter domain model override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cellIdentifier = "SpeakerTableViewCell" let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as SpeakerTableViewCell let speaker = speakers[indexPath.row] cell.talkName.text = speaker.talkTitle cell.speakerName.text = speaker.fullName cell.speakerImage.image = speaker.profilePicture return cell } -
how much sharing? what makes sense for your app kotlin
multiplatform -
(impressions) Kotlin Multiplatform I could just focus on doing what
I know best - UI. I have no idea how things are being done in the backend, yet I know that when I ask for data I receive it. - iOS Developer -
- it’s in experimental state - although there are projects
in production - you can spend some time resolving compilation issues - specially if you try to target all platforms - not possible to debug kotlin from Xcode - there’s a plugin for Xcode from TouchLab - lookout for plugins/ libraries updates (in progress) Kotlin Multiplatform -
- strong community - a lot of people are using
kotlin nowadays - Google and JetBrains are pushing Kotlin in - you can ask questions directly on https://kotlinlang.slack.com - 2x faster to develop your features business logic - 2x faster writing unit tests - one tech stack - consistency across platforms (conclusions) Kotlin Multiplatform -
- be extremely careful with all updates - IDEA, jdk,
kotlin, libraries - everything. - start small, don’t try to reach 100% of shared logic - keep versioning in mind - remember that’s still on experimental state (suggestions) Kotlin Multiplatform -
- Kotlin Slack - Kotlin Official - Kotlin Training -
Kotlin by: https://jakewharton.com/presentations/ http://antonioleiva.com/kotlin - droidcon Greece Transforming a Java Desktop application to Multiplatform | Jossi Wolf | 24, 17:15 Select * from SQLDelight JOIN Multiplatform | Andrii Rakhimov | 25, 13:15 (useful links) more information -
@cafonsomota so long and thanks for all the fish medium.com/@cafonsomota
github.com/cmota/droidconGC