Hi all, Wondering if anyone has experimented aroun...
# multiplatform
m
Hi all, Wondering if anyone has experimented around what the size impact is if using KMM instead of native platform for doing the same work? I tried, but not sure if I'm measuring it right on iOS.. To measure it, I created simple Hello World apps using XCode and AndroidStudio. The native iOS app (created by default in XCode) seems to be ~450 KB while the one created for iOS using KMM in AndroidStudio seems to be ~2.5 MB. This difference seems HUGE! And I feel I might be doing something wrong in measuring the sizes as I'm not from iOS background. Here's what I'm doing - For the app created in XCode, I created an Archive of it, and then zipped the archive (followed one of the SO posts). For the one created through KMM in AndroidStudio, I'm looking at the size of file called 'iosApp' in build->ios->Debug-iphonesimulator->iosApp. I did similar test for Android, where sizes are comparable. Can someone share if I'm measuring the iOS size wrongly? Or what your observations of size impacts of using KMM have been?
👀 1
e
I’m also dealing with this size issue (see above). I found this bit by searching the workspace:

https://www.youtube.com/watch?v=hrRqX7NYg3Q&t=1892s

Apparently marking things as
internal
will help cut down on the iOS binary size. I’m interested if anyone else has other insights or optimizations!
👍 1
k
I was about to post this!
🎉 2
e
@kpgalligan it’s been a while since you put out this video, is marking as much of your common Kotlin as possible as
internal
still the best way to reduce the size of a resulting iOS binary as far as you know? Has there been any further R&D on this topic?
k
Was writing a longer reply, but in a meeting. Will respond in a bit...
👀 2
👍 2
I've half written this blog post like 10 times in slack. I should probably write it. Anyway...
Swift and Kotlin/Native are very similar, as are Java and Objc. S&KN have very static compilers. Swift does not allow for the crazy runtime reflection stuff that Objc does. KN and K-JVM are similarly different. Java, and Kotlin on the JVM, can do fully dynamic things like create proxy classes, redefine methods, etc.
Both Swift and KN cannot. One consequence of that is KN does not need to carry around much info on it's own class as runtime. That's probably not 100% accurate, but you get the idea.
However, to interface with Objc, which can do things like have methods called from string targets, etc, the compiled Kotlin that gets exposed to Objc needs to generate a public Objc interface that includes things like method names, etc.
I don't know exactly what goes into it, but that objc adapter adds considerable weight, at least to otherwise relatively empty classes.
So, if a class is "internal", there won't be an objc interface generated, and the binary doesn't get that weight.
That is one reason I kind of cringe when I see projects export a whole bunch of libraries to Objc. I would guess most of those exposed classes aren't needed.
So, size.
There is a beginning overhead you'll pay. In our tests, it's like 500k before you're adding any serious libraries. After that, growth of non-public binary is linear-ish.
By that I mean we created a simple class generator that you could give a number and it would generate that may Kotlin and Swift classes. Very basic, not real-world classes, but still.
I have to go back and look at the exact details, but this is the size test chart comparing app binary sizes between swift-only and Kotlin Native, with number of those auto gen classes on the bottom.
This is what it looks like if they're all public, and therefor get an objc interface.
s
One other consideration for the size difference that you have no control over is a pure Swift app doesn’t need to bundle the Swift runtime. K/N binaries must include the Kotlin runtime which will add some weight to the app.
k
Size will vary based on libraries you use, etc. The internal thing is just one part of the equation.
K/N binaries must include the Kotlin runtime which will add some weight to the app.
Yes, true, but the reason you can't have multiple dependent frameworks, and (I would guess) a reason why you can't do reflection, etc, is because it doesn't include all of the runtime. Just the part you need. Swift used to require the runtime be included, which added ~20m, but apple (and apple devs) knew that was only temporary.
Kotlin will never have the runtime auto-included by Apple, so it needs to be super trim. If you're just doing basic kotlin, with no libraries, etc, the whole thing is like 500k.
Now, going back to the OP, "And I feel I might be doing something wrong in measuring the sizes as I'm not from iOS background" If you're looking at binary on disk, yes, you're doing it wrong.
We were uploading to app store connect and letting apple generate real numbers. Now we also do the local size summary. I haven't done this in quite a while so I can't describe it from memory, but it involves some Xcode magic.
The summary, though. With the tests I've done, and the prod teams I've talked to, if you follow some best practices, binary size of KMM is reasonable. YMMV. It is important to measure that accurately, though.
I'd say there's also a question of how much more size would be acceptable? A kotlin library will likely be larger than a raw swift one, for a number of reasons, but not disproportionately larger, and more importantly, as you add more code, it shouldn't grow at a significantly faster rate.
y
I remember me measuring the increase of IPA file after we’ve published the first iOS app with a KMP module included, and it was like +10% for an app with about 6 years legacy. Not that much taking into account all advantages KMP brings.
👍 1
@kpgalligan thanks for the deep dive into how all this stuff works. You should certainly write a blog post about it K
p
my new ios app is less than 1mb in testflight, but it also has 4 swiftUI screens (0 external libraries). good result for me
m
Thanks for sharing these details @kpgalligan! This is super useful! I'll really appreciate if you could write a formal blog about this. Also wondering if the issue of extra objc adapters getting generated than really required can be handled at language level in upcoming versions - something like keep everything internal by default in KMM, and the developer explicitly makes something public if really required by using some keyword or annotation. If not through language, then perhaps by having a combination of annotation and compile time plugin - the dev would be required to explicitly add the annotation to classes required to be reached through native, and if annotation is missing and also class is not marked internal then that plugin causes build to fail.
k
I've thought about this quite a bit, actually.
handled at language level in upcoming versions
Well, I would expect the Kotlin team would say
internal
is already in the language. If it's your own code, we recommend doing this now, but obviously there's a limit to this.
combination of annotation and compile time plugin
We've explored this. We tried writing a compiler plugin that would change visibility to
internal
unless annotated. The first major issue is that currently you can't change the visibility of existing classes. We'd need to modify the plugin sdk somewhat. JB has suggested we could do that and submit the changes, but we haven't gone down that road yet. There are bigger issues. You'd want to make sure anything you make visible to objc also has anything it references visible. That includes parameter and return types, specifically referenced generics, etc. Not impossible, but there's certainly room for issues.
For today, what you can do is create a wrapper framework. This module has kotlin module dependencies into your code. The only code it has defines simple pass through methods, or simply references the types you want to include. The types referenced here will be the only ones for which objc interfaces are generated.
We haven't really done that yet, but it's possible. It could be a little ugly, but it would also let you craft a very specific interface to give to the iOS devs. One of the issues with exporting anything public, besides binary size, is the header file has a ton of stuff you don't need to call directly. Trying to navigate that visually in Xcode is frustrating.
We've run into issues with dependency libraries getting dragged into the objc interface. For example, I was testing binary size optimizations with a sample project. I forget exactly how much, but we cut a fair bit of binary by simply hiding things like kotlinx.coroutines. If you expose one class from a dependency, it can sometimes reference something else from the library, and suddenly you're dragging in a big class definition graph. It's useless to the consumer of the objc framework, and is extra binary.
Something closer to home, sqldelight generates it's interfaces and classes, which reference a fair bit of the sqldelight runtime. It's very likely that you only use your db classes internally, and there's generally no reason to expose any of the runtime or driver classes to objc, but at least with your db classes and runtime, there's no way to control it because that code is generated. Currently, the only way to deal with that is to wrap frameworks and expose a minimal sdk surface.
m
Thanks for sharing these details @kpgalligan, but I'm not sure if I've really understood how to wrap frameworks and expose the minimal sdk surface. Won't this wrapper hold the weight of the KMM shared module since that would be added as its dependency? More specifically, can you clarify how this wrapper approach would help in not creating the objc adapter classes even if internal was not declared on the classes in that shared module.
k
Well, to take an exaggerated example, imagine you had 1000 classes in your module and did not make them all internal. They're all public. Then imagine your consuming app only really needs a few functions and, say, 5 data classes that represent the results of what your module's computation does. Creating a framework from the module itself will result in a huge header and binary, because everything is exposed. Creating a "wrapper" module and framework, that only provides passthrough functions and returns those specific 5 class types, will result in a much smaller header (easier to read) and a lot less binary. The compiler tries to be very conservative, so it'll only add objc definitions for things you directly reference. It'll include binary for all the other internal stuff, but without the objc interface. It would be difficult to explain that better without an example, though.
👍 1