Skip to content
← All posts

KMP 102 · Part 4

KMP-102 - Optimizing Kotlin for Obj-C/Swift

By 6 min read Updated

In the last post, we learned how to use Kotlin code in Swift. We covered a few techniques to improve the code exported to Swift, and how annotations like @HiddenFromObjC and @HidesFromObjC control the visibility of code in Swift.

In this post, we’ll dig deeper into how this export works and the impact on our generated code.

Recapping code export

When you compile a .framework with Kotlin/Native, the compiler generates a series of files:

  • Headers/KotlinShared.h: The interface generated by KMP that exposes the Kotlin functions and classes to Objective-C/Swift.
  • KotlinShared.c (or without an extension): The compiled binary file that contains the native implementations of the Kotlin code, translated into LLVM IR.
  • Other components (like .plist and bundles): Additional information the framework needs to run on iOS.

💡 In short

  • KotlinShared.h: what is visible for use in Obj-C/Swift
  • KotlinShared.c: the internal compilation, which is not exposed.

How does Kotlin/Native resolve Kotlin types into Objective-C?

When compiling code with Kotlin/Native, the compiler goes through a series of steps to translate Kotlin types and structures into something Objective-C (and, consequently, Swift) can understand. The result of this translation is the KotlinShared.h file, which maps Kotlin types to their native equivalents.

For example, a String in Kotlin is turned into an NSString, while collections like List and Map are translated into NSArray and NSDictionary. On top of that, the compiler preserves important information such as nullability, making sure nullable and non-nullable values are represented correctly in Objective-C.

Here, the Kotlin Person class was mapped directly into an Objective-C class, with properties like name translated to NSString and parents to NSArray<Person *>.

class Person(
    val name: String,
    val age: Int,
    val parents: List<Person>
)
#import <Foundation/Foundation.h>

NS_SWIFT_NAME(Person)
@interface Person : NSObject

@property (readonly) NSString * _Nonnull name;
@property (readonly) NSInteger age;
@property (readonly) NSArray<Person *> * _Nonnull parents;

- (instancetype _Nonnull)initWithName:(NSString * _Nonnull)name
                                  age:(NSInteger)age
                              parents:(NSArray<Person *> * _Nonnull)parents;

@end

Controlling what gets exported to the Headers

This concept is crucial, especially if you’re looking to scale KMP in your project.

By default, everything that is public in Kotlin is exported to Objective-C, which isn’t ideal in large projects. As the code grows, the KotlinShared.h file can become huge, hurting compilation performance and making maintenance harder.

🤔 But why should I care about this?

As your project grows, you’ll have more and more Kotlin code being processed and exported to the Headers.

This can (and will) result in a gigantic KotlinShared.h file, with hundreds of lines of code.

With a large KotlinShared.h, compiling your XCFramework gets slower, because the compiler needs to process all of the Kotlin declarations to generate the Headers.

On top of that, a large KotlinShared.h can result in more compilation errors in Xcode, since the Swift compiler needs to process all of the Kotlin declarations to generate the final binary.

Finally, the development experience deteriorates, because every time you need to check KotlinShared.h in Xcode, you’ll be dealing with a huge, hard-to-navigate file, plus a longer wait to even open the file in Xcode.

💡 In short

  • If your team wants to scale KMP, it’s important to control what gets exported to Objective-C.
  • This keeps KotlinShared.h lean and easy to navigate, speeding up XCFramework compilation and improving the development experience (we’ll dig into this in a future post).
  • It’s highly recommended that your team spread the culture of controlling what gets exported to Objective-C from the start, to avoid scalability problems down the road.
  • Hiding Kotlin code from Objective-C is considered good practice. Establishing this convention early costs nothing and saves a lot of pain later 😅.

There’s a lot we can learn from open source libraries here. When you consume an open source library, it’s common to have access only to a well-defined interface, with few implementation details.

This helps us (the library consumers) understand what the library does, without needing to understand how it does it. This is what we call encapsulation. On top of that, the IDE experience is elevated, since auto-complete and navigation between files is faster and more accurate.

With that in mind, the recommendation is to hide as much of the Kotlin code from Objective-C as possible. That means you should only export what’s necessary for Swift to consume, and hide the rest.

The mindset is the following:

✅ Hide by default.

⚠️ Expose only what’s necessary.

Ways to hide Kotlin code from Objective-C

There are 3 ways to hide Kotlin code from Objective-C:

1. Using the internal modifier

This approach is the most recommended one, because it has a positive impact on your Kotlin code consumed in other source sets (Android, Desktop, Common, etc).

By default, the internal modifier makes a declaration visible only within the module where it was declared. This means Kotlin code marked as internal won’t be exported to Objective-C.

internal data class Person(
    val name: String,
    val age: Int,
    val parents: List<Person>
)

2. Using the @HiddenFromObjC and @HidesFromObjC annotations

The @HiddenFromObjC and @HidesFromObjC annotations are specific to Kotlin/Native and are meant to control the visibility of methods, properties, or classes in the interop with Objective-C/Swift. They influence how Kotlin elements are exposed to the framework generated by Kotlin/Native for use in iOS projects.

2.1 @HiddenFromObjC

This annotation is used to completely hide a Kotlin element from the API exposed to Objective-C/Swift. Any method, property, or class annotated with @HiddenFromObjC won’t be generated in the resulting framework and, therefore, won’t be visible in Swift/Objective-C projects.

@HiddenFromObjC
fun internalUtilityFunction() {
    // This function won't be exposed to Objective-C/Swift
}
@HiddenFromObjC
class InternalHelper {
    fun doSomething() {
        // This entire class will be invisible in the generated framework
    }
}
2.2 @HidesFromObjC

It’s a meta-annotation, meaning it’s used to mark other annotations that will be applied to Kotlin code elements.

When an annotation is marked with @HidesFromObjC, any element annotated with that annotation will be automatically removed from the generated public Objective-C API.

@HidesFromObjC allows greater flexibility, since you can create your own annotations with this functionality.

Examples of use include creating custom annotations that hide parts of the code from the Objective-C API, while still keeping the element available in Kotlin.

Here, the custom @InternalUseOnly annotation uses @HidesFromObjC, which automatically removes any function or class annotated with it from the Objective-C API.

@HidesFromObjC
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class InternalUseOnly

@InternalUseOnly
fun internalFunction() {
    println("This function won't be exposed to Objective-C")
}

Impact of using internal, @HiddenFromObjC, and @HidesFromObjC on the codebase

By controlling what gets exported: • You reduce the public API surface, avoiding confusion and errors. • The size of the generated framework decreases, improving build performance. • Security increases, since internal classes or methods don’t end up accessible on iOS. • Maintenance becomes simpler, with a cleaner, more focused API.

Conclusion

Controlling what gets exported to Objective-C is an essential practice for keeping the quality and scalability of your KMP project.

By hiding Kotlin code from Objective-C, you make sure only what’s necessary is exposed to Swift, keeping the API lean and easy to navigate.

On top of that, you avoid performance, security, and maintenance problems, making sure your KMP project is scalable and easy to maintain.

👍 It’s extremely important that you and your team adopt this practice from the very start of the project, to avoid scalability problems down the road.

With this concept well in place, we can move on to the next post, where we’ll explore a strategy that will unlock KMP scaling in your project (spoiler: using .klibs).

See you in the next one ✌️