Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Beyond one platform: migrating an Android library to Kotlin Multiplatform | Berlindroid August 2023

Beyond one platform: migrating an Android library to Kotlin Multiplatform | Berlindroid August 2023

Back in 2016, out of my raising passion for Android development, I started working on RSSParser, a library for parsing RSS feeds on Android. Nowadays, out of my passion for Kotlin Multiplatform, I decided to make RSSParser Multiplatform... How hard can it be?

In this talk, I will share the journey of expanding an Android library into the iOS and JVM world. We will cover the challenges faced during the process, including how to handle platform-specific dependencies, code organization, and testing strategies.

By the end of this talk, you'll have a better understanding of the benefits and challenges of creating a Kotlin Multiplatform library, and you'll be equipped with the knowledge and tools you need to conquer the Multiplatform world!

Marco Gomiero

August 31, 2023
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. 👨💻 Senior Android Engineer @ TIER 
 Google Developer Expert

    for Kotlin Marco Gomiero @marcoGomier Beyond one platform: migrating an Android library to Kotlin Multiplatform
  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 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) 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? = null, private

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

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

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

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

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

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

    XmlParserFactory { fun createXmlParser(): XmlParser }
  25. @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()) }
  26. @marcoGomier Test the Parser class XmlParserTest { private lateinit var

    parser: XmlParser @BeforeTest fun setUp() { parser = XmlParserFactory.createXmlParser() } @Test fun channelTitle_isCorrect() = runTest { val input = readBinaryResource(feedPath) val channel = parser.parseXML(input) assertEquals("channelTitle", channel.title) } }
  27. @marcoGomier Test the Parser class XmlParserTest { private lateinit var

    parser: XmlParser @BeforeTest fun setUp() { parser = XmlParserFactory.createXmlParser() } @Test fun channelTitle_isCorrect() = runTest { val input = readBinaryResource(feedPath) val channel = parser.parseXML(input) assertEquals("channelTitle", channel.title) } }
  28. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on iOS • Run test on JVM • Run test on Android
  29. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on iOS • Run test on JVM • Run test on Android
  30. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on iOS • Run test on JVM • Run test on Android
  31. @marcoGomier Test the Parser • Run tests on all the

    platforms • Run tests on iOS • Run test on JVM • Run test on Android
  32. @marcoGomier Test the Parser class XmlParserTest { private lateinit var

    parser: XmlParser @BeforeTest fun setUp() { parser = XmlParserFactory.createXmlParser() } @Test fun channelTitle_isCorrect() = runTest { val input = readBinaryResource(feedPath) val channel = parser.parseXML(input) assertEquals("channelTitle", channel.title) } }
  33. @marcoGomier Test the Parser class XmlParserTest { private lateinit var

    parser: XmlParser @BeforeTest fun setUp() { parser = XmlParserFactory.createXmlParser() } @Test fun channelTitle_isCorrect() = runTest { val input = readBinaryResource(feedPath) val channel = parser.parseXML(input) assertEquals("channelTitle", channel.title) } }
  34. @marcoGomier internal expect fun readBinaryResource( resourceName: String, ): ParserInput internal

    actual fun readBinaryResource( resourceName: String, ): ParserInput { val file = File( "./src/commonTest/resources/$resourceName", ) return ParserInput( inputStream = FileInputStream(file) ) }
  35. @marcoGomier internal expect fun readBinaryResource( resourceName: String, ): ParserInput internal

    actual fun readBinaryResource( resourceName: String, ): ParserInput { val file = File( "./src/commonTest/resources/$resourceName", ) return ParserInput( inputStream = FileInputStream(file) ) } internal actual fun readBinaryResource( resourceName: String ): ParserInput { val pathParts = resourceName.split("[.|/]".toRegex()) val path = NSBundle.mainBundle .pathForResource("resources/${pathParts[0]}", pathParts[1]) val data = NSData.dataWithContentsOfFile(path!!) return ParserInput(data!!) }
  36. @marcoGomier internal expect fun readBinaryResource( resourceName: String, ): ParserInput internal

    actual fun readBinaryResource( resourceName: String, ): ParserInput { val file = File( "./src/commonTest/resources/$resourceName", ) return ParserInput( inputStream = FileInputStream(file) ) } internal actual fun readBinaryResource( resourceName: String ): ParserInput { val pathParts = resourceName.split("[.|/]".toRegex()) val path = NSBundle.mainBundle .pathForResource("resources/${pathParts[0]}", pathParts[1]) val data = NSData.dataWithContentsOfFile(path!!) return ParserInput(data!!) }
  37. @marcoGomier internal expect fun readBinaryResource( resourceName: String, ): ParserInput internal

    actual fun readBinaryResource( resourceName: String, ): ParserInput { val file = File( "./src/commonTest/resources/$resourceName", ) return ParserInput( inputStream = FileInputStream(file) ) } internal actual fun readBinaryResource( resourceName: String ): ParserInput { val pathParts = resourceName.split("[.|/]".toRegex()) val path = NSBundle.mainBundle .pathForResource("resources/${pathParts[0]}", pathParts[1]) val data = NSData.dataWithContentsOfFile(path!!) return ParserInput(data!!) }
  38. @marcoGomier Copy files to iOS binary tasks.register<Copy>("copyIosTestResourcesArm64") { from("src/commonTest/resources") into("build/bin/iosSimulatorArm64/debugTest/resources")

    } tasks.register<Copy>("copyIosTestResourcesX64") { from("src/commonTest/resources") into("build/bin/iosX64/debugTest/resources") } tasks.findByName("iosX64Test")?.dependsOn("copyIosTestResourcesX64") tasks.findByName("iosSimulatorArm64Test")?.dependsOn("copyIosTestResourcesArm64")
  39. @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
  40. 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/
  41. @marcoGomier Thank you! > Twitter: @marcoGomier 
 > Github: prof18

    
 > Website: marcogomiero.com 
 > Mastodon: androiddev.social/@marcogom 👨💻 Senior Android Engineer @ TIER 
 Google Developer Expert for Kotlin Marco Gomiero