How to build an XCFramework on Kotlin Multiplatform
1441 words
7 minutes
When you start integrating Kotlin Multiplatform (I’ll call it KMP in the rest of the article) in an existing project you most likely don’t have a mono-repo structure (and making a refactor to achieve this kind of architecture will not be easy). An example of architecture is the following, with a repository for every platform.
KMP code will be served as a library: the compiler generates a .jar for the JVM, a .aar for Android, and a Framework for iOS.
For iOS, the Framework will be a FatFramework, because it is necessary to have in the same package the architecture for the simulator and the real device. In a past article, I’ve explained how to generate a FatFramework and how to distribute it in a CocoaPod repo. It is possible with some Gradle tasks or with the KMP FatFramework Cocoa Gradle plugin that I wrote.
However, FatFrameworks seems not to be the “current state of the art” solution to distribute multiple architectures at the same time. In fact, Apple during WWDC 2019 has introduced XCFramework, a binary that can contain multiple platform-specific variants (even for iOS and macOS at the same time).
Apple is pushing toward the use of XCFrameworks and you could encounter errors like the following one that happened to Sam Edwards.
Sam followed the same approach I’ve followed but I never encounter the error! And the reason could be the following:
for those who really can't use xcframework, usage of VALIDATE_WORKSPACE = NO, on Build Settings, can be leverage. just make sure to set to YES and then NO so that xcode persists the value onto pbxproj
Unfortunately, there isn’t native support for XCFrameworks on Kotlin Multiplatform yet (it should come hopefully with Kotlin 1.5.30) and to generate an XCFramework, you have to create manually an XCFramework starting from the different frameworks built by KMP.
vallibName=“LibraryName”register("buildDebugXCFramework",Exec::class.java){description="Create a Debug XCFramework"dependsOn("link${libName}DebugFrameworkIosArm64")dependsOn("link${libName}DebugFrameworkIosX64")valarm64FrameworkPath="$rootDir/build/bin/iosArm64/${libName}DebugFramework/${libName}.framework"valarm64DebugSymbolsPath="$rootDir/build/bin/iosArm64/${libName}DebugFramework/${libName}.framework.dSYM"valx64FrameworkPath="$rootDir/build/bin/iosX64/${libName}DebugFramework/${libName}.framework"valx64DebugSymbolsPath="$rootDir/build/bin/iosX64/${libName}DebugFramework/${libName}.framework.dSYM"valxcFrameworkDest=File("$rootDir/../kmp-xcframework-dest/$libName.xcframework")executable="xcodebuild"args(mutableListOf<String>().apply{add("-create-xcframework")add("-output")add(xcFrameworkDest.path)// Real Device
add("-framework")add(arm64FrameworkPath)add("-debug-symbols")add(arm64DebugSymbolsPath)// Simulator
add("-framework")add(x64FrameworkPath)add("-debug-symbols")add(x64DebugSymbolsPath)})doFirst{xcFrameworkDest.deleteRecursively()}}
The first thing to do is building the frameworks for the required architectures: arm64 for the “real” device and X64 for the simulator. The build process can be triggered with the link task, in this case for the Debug variant of the framework.
Before executing the task, it’s better to clear the files inside the XCFramework destination, because the xcodebuild command will not replace the old artifacts.
1
2
3
doFirst{xcFrameworkDest.deleteRecursively()}
And that’s it! Now there is a Debug XCFramework ready to be distributed.
The steps required to build a Release version of the framework are very similar.
First of all, it is necessary to build the frameworks for both the required architectures:
register("buildReleaseXCFramework",Exec::class.java){description="Create a Release XCFramework"dependsOn("link${libName}ReleaseFrameworkIosArm64")dependsOn("link${libName}ReleaseFrameworkIosX64")valarm64FrameworkPath="$rootDir/build/bin/iosArm64/${libName}ReleaseFramework/${libName}.framework"valarm64DebugSymbolsPath="$rootDir/build/bin/iosArm64/${libName}ReleaseFramework/${libName}.framework.dSYM"valx64FrameworkPath="$rootDir/build/bin/iosX64/${libName}ReleaseFramework/${libName}.framework"valx64DebugSymbolsPath="$rootDir/build/bin/iosX64/${libName}ReleaseFramework/${libName}.framework.dSYM"valxcFrameworkDest=File("$rootDir/../kmp-xcframework-dest/$libName.xcframework")executable="xcodebuild"args(mutableListOf<String>().apply{add("-create-xcframework")add("-output")add(xcFrameworkDest.path)// Real Device
add("-framework")add(arm64FrameworkPath)add("-debug-symbols")add(arm64DebugSymbolsPath)// Simulator
add("-framework")add(x64FrameworkPath)add("-debug-symbols")add(x64DebugSymbolsPath)})doFirst{xcFrameworkDest.deleteRecursively()}}
The newly built XCFramework can now be distributed. The distribution can be archived in different ways: for example in a CocoaPods repository, in the Swift Package Manager or with Carthage. Since I’m familiar with CocoaPods, that’s what I’m using.
To make the publishing process as streamlined as possible, I’ve written a bunch of Gradle tasks to automatically build and publish through git the Debug and Release version of the XCFramework. For the details and to understand how the task works, I suggest you give a look at this article that I wrote a few months ago.
register("publishDevFramework"){description="Publish Debug XCFramework to the Cocoa Repo"project.exec{workingDir=File("$rootDir/../kmp-xcframework-dest")commandLine("git","checkout","develop").standardOutput}dependsOn("buildDebugXCFramework")doLast{valdir=File("<framework-destination>/<your-library-name>.podspec")valtempFile=File("<framework-destination>/<your-library-name>.podspec.new")valreader=dir.bufferedReader()valwriter=tempFile.bufferedWriter()varcurrentLine:String?while(reader.readLine().also{currLine->currentLine=currLine}!=null){if(currentLine?.startsWith("s.version")==true){writer.write("s.version = \"${libVersionName}\""+System.lineSeparator())}else{writer.write(currentLine+System.lineSeparator())}}writer.close()reader.close()valsuccessful=tempFile.renameTo(dir)if(successful){valdateFormatter=SimpleDateFormat("dd/MM/yyyy - HH:mm",Locale.getDefault())project.exec{workingDir=File("<framework-destination>")commandLine("git","commit","-a","-m","\"New dev release: ${libVersionName}-${dateFormatter.format(Date())}\"").standardOutput}project.exec{workingDir=File("<framework-destination>")commandLine("git","push","origin","develop").standardOutput}}}}