Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

From Android to Multiplatform and beyond | DevF...

From Android to Multiplatform and beyond | DevFest Berlin

With Kotlin Multiplatform getting increasingly established, many Android libraries became multiplatform.

But how to make an existing Android library multiplatform?

In this talk, we will cover the common challenges faced while migrating Android libraries to Kotlin Multiplatform, like handling platform-specific dependencies, re-organizing the project structure without losing the contributor's history, testing on multiple platforms, and publishing the library.

Marco Gomiero

November 23, 2024
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. @marcoGomier Marco Gomiero Senior Android Developer @ Airalo Google Developer

    Expert for Kotlin From Android to Multiplatform and beyond
  2. @marcoGomier • Move the new source sets inside the existing

    library project • Rename the old source set • Duplicate and keep the old source set in the repo (for reference) • Move the existing code to the androidMain source set • Make the Android part work as before without sharing The Recipe
  3. @marcoGomier • Move the new source sets inside the existing

    library project • Rename the old source set • Duplicate and keep the old source set in the repo (for reference) • Move the existing code to the androidMain source set • Make the Android part work as before without sharing The Recipe
  4. @marcoGomier • Move the new source sets inside the existing

    library project • Rename the old source set • Duplicate and keep the old source set in the repo (for reference) • Move the existing code to the androidMain source set • Make the Android part work as before without sharing The Recipe
  5. @marcoGomier • Move the new source sets inside the existing

    library project • Rename the old source set • Duplicate and keep the old source set in the repo (for reference) • Move the existing code to the androidMain source set • Make the Android part work as before without sharing The Recipe
  6. @marcoGomier RSSParser pre-multiplatform class Parser( private var callFactory: Call.Factory, private

    val charset: Charset? = null, ) { suspend fun getChannel(url: String): Channel = withContext(coroutineContext) { // If the charset is null, then "null" is saved in the database. // It's easier for retrieving data afterwards val charsetString = charset.toString() val cachedFeed = cacheManager?.getCachedFeed(url, charsetString) if (cachedFeed != null) { Log.d(TAG, "Returning object from cache") return@withContext cachedFeed } else { Log.d(TAG, "Returning data from network") val xml = CoroutineEngine.fetchXML(url, callFactory) val channel = CoroutineEngine.parseXML(xml, charset) cacheManager?.cacheFeed( url = url, channel = channel, charset = charsetString, ) return@withContext channel } } }
  7. @marcoGomier suspend fun getChannel(url: String): Channel = withContext(coroutineContext) { //

    If the charset is null, then "null" is saved in the database. // It's easier for retrieving data afterwards val charsetString = charset.toString() val cachedFeed = cacheManager?.getCachedFeed(url, charsetString) if (cachedFeed != null) { Log.d(TAG, "Returning object from cache") return@withContext cachedFeed } else { Log.d(TAG, "Returning data from network") val xml = CoroutineEngine.fetchXML(url, callFactory) val channel = CoroutineEngine.parseXML(xml, charset) cacheManager?.cacheFeed( url = url, channel = channel, charset = charsetString, ) return@withContext channel } } }
  8. @marcoGomier suspend fun getChannel(url: String): Channel = withContext(coroutineContext) { //

    If the charset is null, then "null" is saved in the database. // It's easier for retrieving data afterwards val charsetString = charset.toString() val cachedFeed = cacheManager?.getCachedFeed(url, charsetString) if (cachedFeed != null) { Log.d(TAG, "Returning object from cache") return@withContext cachedFeed } else { Log.d(TAG, "Returning data from network") val xml = CoroutineEngine.fetchXML(url, callFactory) val channel = CoroutineEngine.parseXML(xml, charset) cacheManager?.cacheFeed( url = url, channel = channel, charset = charsetString, ) return@withContext channel } } }
  9. @marcoGomier class Parser private constructor( private var callFactory: Call.Factory, private

    val charset: Charset? = null, ) { suspend fun getChannel(url: String): Channel = withContext(coroutineContext) { // If the charset is null, then "null" is saved in the database. // It's easier for retrieving data afterwards val charsetString = charset.toString() val cachedFeed = cacheManager?.getCachedFeed(url, charsetString) if (cachedFeed != null) { Log.d(TAG, "Returning object from cache") return@withContext cachedFeed } else { Log.d(TAG, "Returning data from network") val xml = CoroutineEngine.fetchXML(url, callFactory) cacheManager?.cacheFeed( url = url, channel = channel, charset = charsetString, ) return@withContext channel } } } import okhttp3.Call import okhttp3.Callback import okhttp3.Request import okhttp3.Response val channel = CoroutineEngine.parseXML(xml, charset) cacheManager?.cacheFeed( url = url, channel = channel, import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserFactory
  10. @marcoGomier internal interface XmlFetcher { suspend fun fetchXml(url: String): ParserInput

    } internal class JvmXmlFetcher( private val callFactory: Call.Factory, ): XmlFetcher { override suspend fun fetchXml(url: String): ParserInput { val request = createRequest(url) return ParserInput( inputStream = callFactory.newCall(request).await() ) } } internal class IosXmlFetcher( private val nsUrlSession: NSURLSession, ): XmlFetcher { override suspend fun fetchXml(url: String): ParserInput = suspendCancellableCoroutine { continuation -> ... } }
  11. @marcoGomier internal class AndroidXmlParser( private val dispatcher: CoroutineDispatcher, ) :

    XmlParser { override suspend fun parseXML(input: ParserInput): RssChannel = withContext(dispatcher) { val factory = XmlPullParserFactory.newInstance() ... } } internal interface XmlParser { suspend fun parseXML(input: ParserInput): RssChannel } internal class IosXmlParser( private val dispatcher: CoroutineDispatcher, ) : XmlParser { override suspend fun parseXML(input: ParserInput): RssChannel = withContext(dispatcher) { suspendCancellableCoroutine { continuation -> ... } } } internal class JvmXmlParser( private val dispatcher: CoroutineDispatcher, ) : XmlParser { override suspend fun parseXML(input: ParserInput): RssChannel = withContext(dispatcher) { val parser = SAXParserFactory.newInstance().newSAXParser() ... } }
  12. @marcoGomier class RssParser internal constructor( private val xmlFetcher: XmlFetcher, private

    val xmlParser: XmlParser, ) { suspend fun getRssChannel(url: String): RssChannel = withContext(coroutineContext) { val parserInput = xmlFetcher.fetchXml(url) return@withContext xmlParser.parseXML(parserInput) } } @marcoGomier
  13. @marcoGomier Prefer interfaces, if possible class RssParser internal constructor( private

    val xmlFetcher: XmlFetcher, private val xmlParser: XmlParser, ) { suspend fun getRssChannel(url: String): RssChannel = withContext(coroutineContext) { val parserInput = xmlFetcher.fetchXml(url) return@withContext xmlParser.parseXML(parserInput) } }
  14. @marcoGomier Expect/Actual internal expect class ParserInput internal actual data class

    ParserInput( val inputStream: InputStream ) internal actual data class ParserInput( val data: NSData )
  15. @marcoGomier • Keep using OKhttp on Android and JVM •

    Use NSURLSession on iOS • Backward compatibility and popularity internal class JvmXmlFetcher( private val callFactory: Call.Factory, ): XmlFetcher { override suspend fun fetchXml(url: String): ParserInput { val request = createRequest(url) return ParserInput( inputStream = callFactory.newCall(request).await() ) } } internal class IosXmlFetcher( private val nsUrlSession: NSURLSession, ): XmlFetcher { override suspend fun fetchXml(url: String): ParserInput = suspendCancellableCoroutine { continuation -> ... } }
  16. @marcoGomier Creating an RssParser Instance • Create an instance with

    platform-specific dependencies (OkHttp, NSURLSession) • Create an instance with default values • Create an instance from a KMP, Android, or JVM project
  17. @marcoGomier Create an instance with platform-specific dependencies class RssParser internal

    constructor( private val xmlFetcher: XmlFetcher, private val xmlParser: XmlParser, ) { internal interface Builder { fun build(): RssParser } }
  18. @marcoGomier class RssParserBuilder( private val callFactory: Call.Factory = OkHttpClient(), private

    val charset: Charset? = null, ): RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = JvmXmlFetcher( callFactory = callFactory, ), xmlParser = AndroidXmlParser( charset = charset, dispatcher = Dispatchers.IO, ), ) } }
  19. @marcoGomier class RssParserBuilder( private val callFactory: Call.Factory = OkHttpClient(), private

    val charset: Charset? = null, ): RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = JvmXmlFetcher( callFactory = callFactory, ), xmlParser = AndroidXmlParser( charset = charset, dispatcher = Dispatchers.IO, ), ) } }
  20. @marcoGomier class RssParserBuilder( private val callFactory: Call.Factory = OkHttpClient(), private

    val charset: Charset? = null, ): RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = JvmXmlFetcher( callFactory = callFactory, ), xmlParser = AndroidXmlParser( charset = charset, dispatcher = Dispatchers.IO, ), ) } }
  21. @marcoGomier class RssParserBuilder( private val callFactory: Call.Factory = OkHttpClient(), private

    val charset: Charset? = null, ): RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = JvmXmlFetcher( callFactory = callFactory, ), xmlParser = JvmXmlParser( charset = charset, dispatcher = Dispatchers.IO, ), ) } }
  22. @marcoGomier class RssParserBuilder( private val nsUrlSession: NSURLSession = NSURLSession.sharedSession, ):

    RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = IosXmlFetcher( nsUrlSession = nsUrlSession, ), xmlParser = IosXmlParser( Dispatchers.IO ), ) } }
  23. @marcoGomier class RssParserBuilder( private val nsUrlSession: NSURLSession = NSURLSession.sharedSession, ):

    RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = IosXmlFetcher( nsUrlSession = nsUrlSession, ), xmlParser = IosXmlParser( Dispatchers.IO ), ) } }
  24. @marcoGomier class RssParserBuilder( private val nsUrlSession: NSURLSession = NSURLSession.sharedSession, ):

    RssParser.Builder { override fun build(): RssParser { return RssParser( xmlFetcher = IosXmlFetcher( nsUrlSession = nsUrlSession, ), xmlParser = IosXmlParser( Dispatchers.IO ), ) } }
  25. @marcoGomier Create an instance with default values internal expect object

    XmlParserFactory { fun createXmlParser(): XmlParser }
  26. @marcoGomier internal expect object XmlParserFactory { fun createXmlParser(): XmlParser }

    internal actual object XmlParserFactory { actual fun createXmlParser(): XmlParser = JvmXmlParser(dispatcher = UnconfinedTestDispatcher()) } internal actual object XmlParserFactory { actual fun createXmlParser(): XmlParser = AndroidXmlParser(dispatcher = UnconfinedTestDispatcher()) } internal actual object XmlParserFactory { actual fun createXmlParser(): XmlParser = IosXmlParser(dispatcher = UnconfinedTestDispatcher()) }
  27. @marcoGomier Test the Parser class XmlParserTest { @Test fun channelTitle_isCorrect()

    = runTest { val parser = XmlParserFactory.createXmlParser() val input = readFileFromResources("test-feed.xml") val channel = parser.parseXML(input) assertEquals("channel-title", channel.title) } }
  28. @marcoGomier Test the Parser class XmlParserTest { @Test fun channelTitle_isCorrect()

    = runTest { val parser = XmlParserFactory.createXmlParser() val input = readFileFromResources("test-feed.xml") val channel = parser.parseXML(input) assertEquals("channel-title", channel.title) } }
  29. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  30. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  31. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  32. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  33. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  34. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  35. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on a specific platform
  36. @marcoGomier Test the Parser class XmlParserTest { @Test fun channelTitle_isCorrect()

    = runTest { val parser = XmlParserFactory.createXmlParser() val input = readFileFromResources("test-feed.xml") val channel = parser.parseXML(input) assertEquals("channel-title", channel.title) } }
  37. @marcoGomier Test the Parser class XmlParserTest { @Test fun channelTitle_isCorrect()

    = runTest { val parser = XmlParserFactory.createXmlParser() val input = readFileFromResources("test-feed.xml") val channel = parser.parseXML(input) assertEquals("channel-title", channel.title) } }
  38. @marcoGomier Gradle sets the test process's working directory to the

    module's directory Read files in tests but ... • No java.io.File on Kotlin/Native • On iOS, the working directory is unrelated to the project directory
  39. @marcoGomier val rootDir = "${rootProject.rootDir.path}/rssparser/src/commonTest/resources" tasks.withType<Test>().configureEach { environment("TEST_RESOURCES_ROOT", rootDir) }

    tasks.withType<KotlinNativeTest>().configureEach { environment("TEST_RESOURCES_ROOT", rootDir) environment("SIMCTL_CHILD_TEST_RESOURCES_ROOT", rootDir) } https://stackover fl ow.com/a/53604237/5264056
  40. @marcoGomier internal actual fun readFileFromResources( resourceName: String, ): ParserInput {

    val path = System.getenv("TEST_RESOURCES_ROOT") val file = File("$path/$resourceName") return ParserInput( inputStream = FileInputStream(file) ) } internal expect fun readFileFromResources( resourceName: String ): ParserInput
  41. @marcoGomier internal expect fun readBinaryResource( resourceName: String, ): ParserInput internal

    actual fun readFileFromResources( resourceName: String ): ParserInput { val s = getenv("TEST_RESOURCES_ROOT")?.toKString() val path = "$s/${resourceName}" val data = NSData.dataWithContentsOfFile(path) return ParserInput(requireNotNull(data)) } internal actual fun readFileFromResources( resourceName: String, ): ParserInput { val path = System.getenv("TEST_RESOURCES_ROOT") val file = File("$path/$resourceName") return ParserInput( inputStream = FileInputStream(file) ) }
  42. @marcoGomier Conclusions • Adapting to different platforms requires time and

    thinking • Code organisation can be challenging, especially for maintaining git history • Prefer Interfaces over expect/actual, where possible
  43. @marcoGomier Thank you! Marco Gomiero Senior Android Developer @ Airalo

    Google Developer Expert for Kotlin > Twitter: @marcoGomier > Github: prof18 > Website: marcogomiero.com > Mastodon: androiddev.social/@marcogom > BlueSky: @marcogomiero.com
  44. Bibliography / Useful Links • https: // kotlinlang.org/docs/multiplatform-mobile-understand-project- structure.html#source-sets •

    https: // kotlinlang.org/docs/multiplatform-connect-to-apis.html • https: // square.github.io/okhttp/ • https: // ktor.io/ • https: // developer.apple.com/documentation/foundation/nsurlsession • https: // kotlinlang.org/docs/multiplatform-run-tests.html • https: // publicobject.com/2023/04/16/read-a-project-file-in-a-kotlin-multiplatform-test/ • https: // github.com/vanniktech/gradle-maven-publish-plugin • https: // github.com/prof18/RSS-Parser • https: // thebakery.dev/52/ • https: // www.feedflow.dev/