Kotlin Multiplatform / Enable iosMain sourceSet in Kotlin Multiplatform Mobile projects

Enable iosMain sourceSet in Kotlin Multiplatform Mobile projects

When using Kotlin Multiplatform Mobile, you come across an unusual feature — the iOS code is considered by the compiler in several versions: iosArm64, iosX64, and also iosArm32 (to support devices released before the iPhone 5s). When developing for iOS in Swift, you don't think about these, because it is hidden in the headers of the system libraries by the preprocessor conditions.

For a developer, more often than not, it should not be necessary to take into account the architecture of the processor on which the application will be launched (especially if the architectures are of the same bitness as iosArm64 and iosX64). And the code for both architectures is completely the same, so the project is configured to use one source of the source code — iosMain. There are several options for combining ios code in one sourceSet, each with its own pros and cons.

Commonizer in Kotlin 1.4

Kotlin Multiplatform allows you to build a hierarchy from KotlinSourceSets. For example, make an intermediate sourceSet with all ios code, as in the diagram below.

unnamed (5).png

iosMain in hierarhical structure (source — kotlinlang docs).

With this setup, you can place all the ios-related code in the iosMain sourceSet. It will compile successfully, but before Kotlin 1.4 the IDE could not correctly parse this code, since it is not known for which platform the analysis should be done — Arm64 or X64. As a result, we received errors in the IDE (but everything was valid for the compiler):

unnamed (6).png

With Kotlin 1.4, the IDE support issue has been resolved with a new tool, the Commonizer. It automatically searches for commonality between iosArm64Main and iosX64Main and generates a special iosMain klib that contains all the common declarations, and the IDE analyzes your code using this klib. You can learn more about the Commonizer in the presentation of the Kotlin Multiplatform developer.

To set up your project for this option, you need to specify in build.gradle.kts:

plugins {
   kotlin("multiplatform")
}

kotlin {
   ios {
       binaries {
           framework {
               baseName = "shared"
           }
       }
   }
   sourceSets {
       val commonMain by getting
       val iosMain by getting
   }
}

And to enable the Commonizer, add to gradle.properties:

kotlin.mpp.enableGranularSourceSetsMetadata=true
kotlin.native.enableDependencyPropagation=false

As a result, we get one place with the iOS source code and working help from the IDE.

unnamed (7).png

But there are also limitations — not all iOS APIs are available in iosMain. For example, the UITextFieldDelegateProtocol is completely empty:

public expect interface UITextFieldDelegateProtocol : 
platform.darwin.NSObjectProtocol {
}

Although when working from iosX64Main/iosArm64Main we see the full interface:

public interface UITextFieldDelegateProtocol : platform.darwin.NSObjectProtocol {
   public open fun textField(textField: platform.UIKit.UITextField, shouldChangeCharactersInRange: kotlinx.cinterop.CValue<platform.Foundation.NSRange>, replacementString: kotlin.String): kotlin.Boolean

   public open fun textFieldDidBeginEditing(textField: platform.UIKit.UITextField): kotlin.Unit
   ...
}

And also when setting up cInterop (for example, when connecting cocoapods in Kotlin), all declarations are not available in iosMain when viewed through the IDE (although everything will work correctly for the compiler).

A configured example can be viewed on GitHub.

Pros:

  1. Intermediate sourceSet is fully supported by IDE.
  2. Separate gradle tasks for compiling both architectures.

Cons:

  1. CInterop not visible by IDE in intermediate sourceSet.
  2. Commonization works only at the 1st level of the hierarchy (if you make appleMain for iosMain and macosMain, commonization at this level will not work).
  3. Not all APIs are available in the intermediate sourceSet.
  4. External libraries must have their own published intermediate sourceSet (no matter what its name is — it matters what targets are combined in it).

One sourceSet for iOS

The following approach as stated in the Kotlin Multiplatform Mobile documentation

In this case, it is proposed at the gradle configuration stage to choose which target to use — iosX64 or iosArm64. This choice is made based on the SDK_NAME environment variable — it is automatically substituted by Xcode. Therefore, with this approach, we will be able to compile for the device only from Xcode.

The setup is done as follows:

import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins {
   kotlin("multiplatform")
}
kotlin {
   val iosTarget: (String, KotlinNativeTarget.() -> Unit) -> KotlinNativeTarget =
       if (System.getenv("SDK_NAME")?.startsWith("iphoneos") == true)
           ::iosArm64
       else
           ::iosX64
   iosTarget("ios") {
       binaries {
           framework {
               baseName = "shared"
           }
       }
   }
   sourceSets {
       val commonMain by getting
       val iosMain by getting
   }
}

As a result, we get iosMain fully working with both IDE and cInterop:

unnamed (8).png

A configured example can be viewed on GitHub.

Pros:

  1. iosMain contains all the code for both architectures
  2. cInterop works correctly

Cons:

  1. Configuration in gradle depends on environment variables
  2. Only one iOS compilation task is available in gradle, and which architecture will be built is decided by the environment variable
  3. To compile for a device, you need to build from Xcode

Arm64 sourceSet depends on X64

Dependencies between sourceSets can be used for more than just a hierarchy. For example, specify the dependence of iosArm64Main on iosX64Main.

To configure, you need to create separate targets and specify the dependency:

plugins {
   kotlin("multiplatform")
}

kotlin {
   val ios = listOf(iosX64(), iosArm64())
   configure(ios) {
       binaries {
           framework {
               baseName = "shared"
           }
       }
   }
   sourceSets {
       val commonMain by getting
       val iosX64Main by getting
       val iosArm64Main by getting {
           dependsOn(iosX64Main)
       }
   }
}

And all the code in this case is located in the iosX64Main directory:

unnamed (9).png

A configured example can be viewed on GitHub.

Pros:

  1. the code is not duplicated, located in one sourceSet
  2. all platform API available
  3. separate gradle tasks for compiling both architectures
  4. cInterop is correctly supported

Cons:

  1. before Kotlin 1.4 cInterop with such a configuration was not supported (there was an error about connecting an incorrect architecture to the linkage stage)

symlink Arm64 to X64

The last option, we use in IceRock, allows you not to duplicate code, use all APIs and cInterop, and also does not require complex settings. In order not to duplicate the code, we simply create a symlink for one of the ios sourceSets:

ln -s iosX64Main iosArm64Main

And in gradle, we set up a project with two ios targets:

plugins {
   kotlin("multiplatform")
}

kotlin {
   val ios = listOf(iosX64(), iosArm64())
   configure(ios) {
       binaries {
           framework {
               baseName = "shared"
           }
       }
   }
   sourceSets {
       val commonMain by getting
   }
}

As a result, we get the desired result:

unnamed (10).png

A configured example can be viewed on GitHub.