$30 off During Our Annual Pro Sale. View Details »

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

    View Slide

  2. @marcoGomier
    • Started in 2016 as an Android library
    RSSParser
    • Time to make it Multiplatform!

    View Slide

  3. @marcoGomier
    Project Structure

    View Slide

  4. @marcoGomier
    Android Library Source Set

    View Slide

  5. @marcoGomier
    Kotlin Multiplatform Source Sets
    Android Library Source Set

    View Slide

  6. @marcoGomier
    How to migrate to
    Multiplaform source sets?

    View Slide

  7. @marcoGomier
    * without losing git and contributors history
    How to migrate to
    Multiplaform source sets? *

    View Slide

  8. @marcoGomier
    Create a new library project

    View Slide

  9. @marcoGomier
    Create a new library project

    View Slide

  10. @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

    View Slide

  11. @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

    View Slide

  12. @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

    View Slide

  13. @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

    View Slide

  14. @marcoGomier
    Platform-specific APIs

    View Slide

  15. @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


    }


    }


    }

    View Slide

  16. @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


    }


    }


    }

    View Slide

  17. @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


    }


    }


    }

    View Slide

  18. @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

    View Slide

  19. @marcoGomier
    🤔

    View Slide

  20. @marcoGomier
    Expect/Actual
    https://kotlinlang.org/docs/multiplatform-connect-to-apis.html

    View Slide

  21. @marcoGomier
    internal interface XmlFetcher {


    suspend fun fetchXml(url: String): ParserInput


    }
    Interface

    View Slide

  22. @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 ->


    ...


    }


    }

    View Slide

  23. @marcoGomier
    internal interface XmlParser {


    suspend fun parseXML(input: ParserInput): RssChannel


    }
    Interface

    View Slide

  24. @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()


    ...


    }


    }

    View Slide

  25. @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

    View Slide

  26. @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)


    }


    }

    View Slide

  27. @marcoGomier
    Expect/Actual
    internal expect class ParserInput
    internal actual data class ParserInput(


    val inputStream: InputStream


    )
    internal actual data class ParserInput(


    val data: NSData


    )

    View Slide

  28. @marcoGomier
    Networking

    View Slide

  29. @marcoGomier
    Ktor? OkHttp?


    Platform-specific APIs?

    View Slide

  30. @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 ->


    ...


    }


    }

    View Slide

  31. @marcoGomier
    Constructors

    View Slide

  32. @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

    View Slide

  33. @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


    }


    }

    View Slide

  34. @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,


    ),


    )


    }


    }

    View Slide

  35. @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,


    ),


    )


    }


    }

    View Slide

  36. @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,


    ),


    )


    }


    }

    View Slide

  37. @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,


    ),


    )


    }


    }

    View Slide

  38. @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


    ),


    )


    }


    }

    View Slide

  39. @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


    ),


    )


    }


    }

    View Slide

  40. @marcoGomier
    Create an instance with default values
    expect fun RssParser(): RssParser

    View Slide

  41. @marcoGomier
    expect fun RssParser(): RssParser
    actual fun RssParser(): RssParser = RssParserBuilder().build()

    View Slide

  42. @marcoGomier
    Testing

    View Slide

  43. @marcoGomier
    How to write one test

    for multiple platforms?

    View Slide

  44. @marcoGomier
    Create an instance with default values
    internal expect object XmlParserFactory {


    fun createXmlParser(): XmlParser


    }

    View Slide

  45. @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())


    }


    View Slide

  46. @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)


    }


    }

    View Slide

  47. @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)


    }


    }

    View Slide

  48. @marcoGomier
    Test the Parser

    View Slide

  49. @marcoGomier
    Test the Parser
    • Run tests on all the platforms


    • Run tests on iOS


    • Run test on JVM


    • Run test on Android

    View Slide

  50. @marcoGomier
    Test the Parser
    • Run tests on all the platforms


    • Run tests on iOS


    • Run test on JVM


    • Run test on Android

    View Slide

  51. @marcoGomier
    Test the Parser
    • Run tests on all the platforms


    • Run tests on iOS


    • Run test on JVM


    • Run test on Android

    View Slide

  52. @marcoGomier
    Test the Parser
    • Run tests on all the platforms


    • Run tests on iOS


    • Run test on JVM


    • Run test on Android

    View Slide

  53. @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)


    }


    }

    View Slide

  54. @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)


    }


    }

    View Slide

  55. @marcoGomier
    Read files in tests
    val file = File("./src/commonTest/resources/$resourceName")


    val inputStream = FileInputStream(file)

    View Slide

  56. @marcoGomier
    Read files in tests
    internal expect fun readBinaryResource(


    resourceName: String,


    ): ParserInput

    View Slide

  57. @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)


    )


    }

    View Slide

  58. @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!!)


    }


    View Slide

  59. @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!!)


    }


    View Slide

  60. @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!!)


    }


    View Slide

  61. @marcoGomier
    Copy files


    to iOS binary
    tasks.register("copyIosTestResourcesArm64") {


    from("src/commonTest/resources")


    into("build/bin/iosSimulatorArm64/debugTest/resources")


    }


    tasks.register("copyIosTestResourcesX64") {


    from("src/commonTest/resources")


    into("build/bin/iosX64/debugTest/resources")


    }


    tasks.findByName("iosX64Test")?.dependsOn("copyIosTestResourcesX64")


    tasks.findByName("iosSimulatorArm64Test")?.dependsOn("copyIosTestResourcesArm64")


    View Slide

  62. @marcoGomier https://publicobject.com/2023/04/16/read-a-project-
    fi
    le-in-a-kotlin-multiplatform-test/

    View Slide

  63. @marcoGomier
    Publishing

    View Slide

  64. @marcoGomier
    • Nothing to do! \o/
    Publishing
    https://github.com/vanniktech/gradle-maven-publish-plugin

    View Slide

  65. @marcoGomier
    Conclusions

    View Slide

  66. @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

    View Slide

  67. @marcoGomier https://github.com/prof18/RSS-Parser

    View Slide

  68. @marcoGomier https://thebakery.dev/52/

    View Slide

  69. @marcoGomier https://www.feed
    fl
    ow.dev/

    View Slide

  70. 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/

    View Slide

  71. @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

    View Slide