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

Lessons learned after building apps with Kotlin...

Lessons learned after building apps with Kotlin Multiplatform

With Kotlin Multiplatform and Compose Multiplatform, you can share code and UI across multiple platforms, from Android and iOS to macOS, Windows, and Linux. But building apps on all these platforms brings unexpected challenges you don’t anticipate at the beginning.

In this talk, I’ll share what I’ve learned (often the hard way) while developing apps with Kotlin Multiplatform. You’ll hear why sharing less code can sometimes save more time, how dealing with the iOS Keychain from background services turned into a debugging odyssey, and why placing your database in the wrong Windows folder can lead to silent data loss during app updates. We’ll also cover sandboxing on macOS, the trade-offs between using interfaces and `expect/actual`, and the complexities of distributing apps across platforms.

Whether you’re just getting started with Kotlin Multiplatform or already deep into it, this talk might just save you from a few future debugging headaches.

Avatar for Marco Gomiero

Marco Gomiero

November 20, 2025
Tweet

More Decks by Marco Gomiero

Other Decks in Programming

Transcript

  1. Android Common iOS internal class AndroidXmlFetcher( private val callFactory: Call.Factory,

    ) : XmlFetcher { override suspend fun fetchXml(url: String): ParserInput { val request = createRequest(url) return ParserInput( inputStream = callFactory.newCall(request).await() ) } }
  2. Android Common iOS internal class IosXmlFetcher( private val nsUrlSession: NSURLSession,

    ) : XmlFetcher { override suspend fun fetchXml(url: String): ParserInput = suspendCancellableCoroutine { continuation -> ... continuation.resume(ParserInput(nsData = data)) } }
  3. Common iOS Android Test Android class FakeAndroidXmlFetcher: XmlFetcher { override

    suspend fun fetchXml(url: String): ParserInput { val file = File("test.xml") return ParserInput( inputStream = FileInputStream(file), ) } }
  4. iOS App Android class IosHtmlParser: HtmlParser { func getTextFromHTML(html: String)

    -> String? { let doc: Document? = try? SwiftSoup.parse(html) return try? doc?.text() } } Common
  5. Android Common iOS App internal class AndroidHtmlParser : HtmlParser {

    override fun getTextFromHTML(html: String): String? { val doc = Jsoup.parse(html) return doc.text() } }
  6. Android Common iOS App class IosHtmlParser: HtmlParser { func getTextFromHTML(html:

    String) -> String? { let doc: Document? = try? SwiftSoup.parse(html) return try? doc?.text() } }
  7. Code that centralizes the source of truth What to share?

    It depends 🤷 Start from “boring” code
  8. Code that can be gradually extracted What to share? It

    depends 🤷 Start from “boring” code Code that centralizes the source of truth
  9. KMP module XCFramework Android App iOS App .aar Maven Repo

    Swift Package KMP library repository Android App Repository iOS App Repository
  10. KMP module XCFramework iOS App Swift Package Swift Package GitHub

    Releases Custom artifacts storage (S3, etc)
  11. // swift-tools-version:5.3 import PackageDescription let package = Package( name: "MyLibrary",

    platforms: [ .macOS(.v10_14), .iOS(.v13), .tvOS(.v13) ], products: [ .library( name: "MyLibrary", targets: ["SomeRemoteBinaryPackage"]) ], dependencies: [ // Dependencies declare other packages that this package depends on. ], targets: [ .binaryTarget( name: "SomeRemoteBinaryPackage", url: "https://url/to/some/remote/xcframework.zip", checksum: "The checksum of the ZIP archive that contains the XCFramework." ) ] ) https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages
  12. https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages // swift-tools-version:5.3 import PackageDescription let package = Package( name:

    "MyLibrary", platforms: [ .macOS(.v10_14), .iOS(.v13), .tvOS(.v13) ], products: [ .library( name: "MyLibrary", targets: ["SomeRemoteBinaryPackage"]) ], dependencies: [ // Dependencies declare other packages that this package depends on. ], targets: [ .binaryTarget( name: "SomeRemoteBinaryPackage", url: "https://url/to/some/remote/xcframework.zip", checksum: "The checksum of the ZIP archive that contains the XCFramework." ) ] )
  13. Starting in an existing project Easier to go with multiple

    Repos and Library model When scaling up to full feature dev, maybe switch to sharing sources
  14. Understand how other platforms work Signing and provisioning pro fi

    les on Apple Sandboxing on macOS New fi le format for the Windows Store
  15. internal fun iosDirPath(folder:String):String{ val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true) val

    documentsDirectory = paths[0] as String val databaseDirectory = "$documentsDirectory/$folder" val fileManager = NSFileManager.defaultManager() if (!fileManager.fileExistsAtPath(databaseDirectory)) fileManager.createDirectoryAtPath(databaseDirectory, true, null, null); //Create folder return databaseDirectory } github.com/touchlab/SQLiter/
  16. internal fun iosDirPath(folder:String):String{ val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true) val

    documentsDirectory = paths[0] as String val databaseDirectory = "$documentsDirectory/$folder" val fileManager = NSFileManager.defaultManager() if (!fileManager.fileExistsAtPath(databaseDirectory)) fileManager.createDirectoryAtPath(databaseDirectory, true, null, null); //Create folder return databaseDirectory } github.com/touchlab/SQLiter/
  17. internal fun iosDirPath(folder:String):String{ val paths = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true) val

    documentsDirectory = paths[0] as String val databaseDirectory = "$documentsDirectory/$folder" val fileManager = NSFileManager.defaultManager() if (!fileManager.fileExistsAtPath(databaseDirectory)) fileManager.createDirectoryAtPath(databaseDirectory, true, null, null); //Create folder return databaseDirectory } github.com/touchlab/SQLiter/ folder == “database”
  18. fun getAppGroupDatabasePath(): String { val fileManager = NSFileManager.defaultManager() val containerURL

    = fileManager.containerURLForSecurityApplicationGroupIdentifier("group.com.mycompany.myapp") check(containerURL != null) { "Could not access App Group container" } val containerPath = containerURL.path val directoryPath = "$containerPath/databases" if (!fileManager.fileExistsAtPath(directoryPath)) { fileManager.createDirectoryAtPath( directoryPath, withIntermediateDirectories = true, attributes = null, error = null, ) } return directoryPath }
  19. fun getAppGroupDatabasePath(): String { val fileManager = NSFileManager.defaultManager() val containerURL

    = fileManager.containerURLForSecurityApplicationGroupIdentifier("group.com.mycompany.myapp") check(containerURL != null) { "Could not access App Group container" } val containerPath = containerURL.path val directoryPath = "$containerPath/databases" if (!fileManager.fileExistsAtPath(directoryPath)) { fileManager.createDirectoryAtPath( directoryPath, withIntermediateDirectories = true, attributes = null, error = null, ) } return directoryPath }
  20. fun getAppGroupDatabasePath(): String { val fileManager = NSFileManager.defaultManager() val containerURL

    = fileManager.containerURLForSecurityApplicationGroupIdentifier("group.com.mycompany.myapp") check(containerURL != null) { "Could not access App Group container" } val containerPath = containerURL.path val directoryPath = "$containerPath/databases" if (!fileManager.fileExistsAtPath(directoryPath)) { fileManager.createDirectoryAtPath( directoryPath, withIntermediateDirectories = true, attributes = null, error = null, ) } return directoryPath } Now it’s hardcoded :)
  21. fun createDatabaseDriver(): SqlDriver = NativeSqliteDriver( schema = MyDatabase.Schema, onConfiguration =

    { conf -> conf.copy( extendedConfig = conf.extendedConfig.copy( basePath = getAppGroupDatabasePath(), ), ) }, name = "MyDatabase.db", ) sqldelight.github.io/sqldelight/2.0.2/native_sqlite/
  22. fun createDatabaseDriver(): SqlDriver = NativeSqliteDriver( schema = MyDatabase.Schema, onConfiguration =

    { conf -> conf.copy( extendedConfig = conf.extendedConfig.copy( basePath = getAppGroupDatabasePath(), ), ) }, name = "MyDatabase.db", ) sqldelight.github.io/sqldelight/2.0.2/native_sqlite/
  23. public class KeychainSettings : Settings { private val cleaner: Cleaner?

    public constructor(service: String) { val cfService = CFBridgingRetain(service) defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, kSecAttrService to cfService) cleaner = createCleaner(cfService) { CFBridgingRelease(it) } } } github.com/russhwolf/multiplatform-settings/
  24. public class KeychainSettings : Settings { private val cleaner: Cleaner?

    public constructor(service: String) { val cfService = CFBridgingRetain(service) defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, kSecAttrService to cfService) cleaner = createCleaner(cfService) { CFBridgingRelease(it) } } } github.com/russhwolf/multiplatform-settings/
  25. public class KeychainSettings : Settings { private val cleaner: Cleaner?

    public constructor(service: String) { val cfService = CFBridgingRetain(service) defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, kSecAttrService to cfService) cleaner = createCleaner(cfService) { CFBridgingRelease(it) } } public constructor(vararg defaultProperties: Pair<CFStringRef?, CFTypeRef?>) { this.defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, *defaultProperties) cleaner = null } } github.com/russhwolf/multiplatform-settings/
  26. public class KeychainSettings : Settings { private val cleaner: Cleaner?

    public constructor(service: String) { val cfService = CFBridgingRetain(service) defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, kSecAttrService to cfService) cleaner = createCleaner(cfService) { CFBridgingRelease(it) } } public constructor(vararg defaultProperties: Pair<CFStringRef?, CFTypeRef?>) { this.defaultProperties = mapOf(kSecClass to kSecClassGenericPassword, *defaultProperties) cleaner = null } } github.com/russhwolf/multiplatform-settings/
  27. object KeychainSettingsWrapper { private val cfService = CFBridgingRetain("FeedFlow2") val settings

    = KeychainSettings( kSecAttrService to cfService, kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlock, ) private val cleaner = createCleaner(cfService) { CFBridgingRelease(it) } }
  28. object KeychainSettingsWrapper { private val cfService = CFBridgingRetain("FeedFlow2") val settings

    = KeychainSettings( kSecAttrService to cfService, kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlock, ) private val cleaner = createCleaner(cfService) { CFBridgingRelease(it) } }
  29. Conclusions Introduce changes incrementally Don’t force yourself to share everything

    Establish infrastructure and strategy It’s a team effort
  30. Conclusions Introduce changes incrementally Don’t force yourself to share everything

    Establish infrastructure and strategy It’s a team effort Understand how other platforms work