Platform Channels are Dead! Objective-C/Swift Interop is Here!
In Dart 2.18 the Dart Team introduced Objective-C/Swift Interop. It allows us to directly call native code on iOS from our Dart codebase by using Dart FFI and C as an interoperability layer. Does that mean we don’t have to rely on platform-channels to call native functions on iOS anymore? That’s what we’re going to explore in this article.
The Problem
One of the caveats of Flutter is that we need to learn all the platforms where we are trying to run our app. To be a successful Flutter developer it’s not enough to find your way around Flutter and Dart, from time to time you also have to touch the underlying native platforms iOS and Android. Those platforms are not only different in how they work but also use different programming languages Swift/Objective-C for iOS and Kotlin/Java for Android. To implement something that heavily depends on the device internals like battery, Bluetooth, etc. we always need to work on the native platform.
Most of the time there are already some packages that try to solve that for us, but the issue is they get easily outdated and you have to rely on someone to maintain them. Also, new versions of iOS or Android might introduce new functionality which means packages or your code gets outdated and you have to wait until someone finally implements the new functionality into their package.
Platform-Channels ✉️
Currently, we are relying on using platform channels to communicate with native platforms, which requires us to handle native (iOS, Android) implementation on our own. Managing resources and error handling makes this even more complicated. Also, building platform-channels creates a lot of repeating boilerplate code. Pigeon already tries to solve the boilerplate issue by relying on code generation, but we are still facing the issues mentioned above.
Luckily Google introduced a new way of calling native code on iOS: Objective-C/Swift Interop. There is also an early build for the same mechanism on Android called JNI. It’s still in an early alpha stage but we will take a look at that as well and will take a deeper look in an upcoming article. So feel free to subscribe to our newsletter to get informed when the new article is live!
What is Objective-C/Swift Interop? ⛓
Swift Interop allows us to call Swift/Object-C code directly from Dart code. Now you might think this is impossible since those are completely different languages!? Swift Interop heavily relies on Dart FFI (Foreign-Function-Interfaces) which was introduced to call native C-Code directly from Dart. Swift Interop uses the same mechanism. Since both languages have an interface to communicate directly with C-Code Dart can leverage that. Objective-C/Swift Interop will create C-Bindings from Swift/Objective-C code which then can be called using Dart FFI directly from Dart-Code.
Example 🛠️
Let’s start with an example to easily show how everything works. We want to load the current battery state from the device.
Swift Interop also relies on code generation based on the Swift/Objective-C code. For that, we need to set up some things on our project.
Prerequisites
There are some dependencies that we need. First, we need to add ffigen
and ffi
to our project dart pub add --dev ffigen
+ dart pub add ffi
.
Since ffigen
parses Objective-C header files you also need to install LLVM on your system. You can find the instructions for your system here. LLVM is a set of compilers to abstract away the complexity of different compilation frontends.
Next, we need a configuration file that tells ffigen
for which file it needs to generate the bindings. Since we want to load the battery-state we exactly need that file/library.
Where do we get the battery state from?
If you have some familiarity with iOS you probably know it comes from a class named UIDevice. An easy way to find out which file/class is relevant for the functionality you want to implement is to look up the documentation. The good thing is you don’t need to understand the code, just where it is located. If you develop apps for iOS there is already an iOS version installed on your system.
How to call an iOS function?
Now everything is set up? Based on which code do we now generate the bindings? As an example, we decided to load the battery state of the phone. It’s an easy function and is dependent on the device (it cannot be loaded directly from Flutter). Since we already know the function comes from a class named UIDevice we need to locate it on our development device. If you search for it you will find out it’s located in a file named UIDevice.h. We need the Objective-C header of the code to generate the bindings using FFI. We will later also look at an example of how we get there from Swift-Code.
If you are working on a Mac you can find the file here (The location might differ a little bit on your device): /Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/iOSSupport/System/Library/Frameworks/UIKit.framework/Headers/UIDevice.h
. It is located in the directory where the iOS SDK is located. You can either copy the file into the project or just reference it directly.
To generate the Dart-FFI-Code we need to set up the configuration for FFI. You can either do this in the pubspec.yaml or in a separate file.
ffigen: name: UIDevice description: Bindings for UIDevice. language: objc output: './lib/src/uidevice_bindings.dart' exclude-all-by-default: true objc-interfaces: include: - 'UIDevice' headers: entry-points: - '/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/iOSSupport/System/Library/Frameworks/UIKit.framework/Headers/UIDevice.h'
If you want to understand every available parameter you can take a look at ffigen. ffigen will also generate the bindings for all necessary dependencies.
Let’s generate some code!
To generate the code, we only have to run ffigen with the following command.
dart run ffigen
If you declared your configuration in a separate file you can link to a custom config file by adding --config config.yaml
. If you used the configuration from the above there should be a uidevice_bindings.dart file in lib/src folder now. You might have noticed that the generated file is quite big. In my case over 700k lines of code. What FFI did was to generate bindings to every method, and class inside the UIDevice.h + dependencies. But isn’t FFI there to generate/call C-Code? Yes and no. What ffigen did in the background was to generate C-Header files from the C-Objective-Header files. From the C-Header files the Dart bindings were generated, ffigen just hid the intermediate step from us.
700k lines of code where do we start? Let’s head back to the iOS documentation to find out what we need. There is a property called batteryLevel which returns the current level of the battery. That’s what we need! If you search in the generated file you should find a getter that returns the battery level. The code in the filex3e4 looks complicated but we don’t need to worry about that.
How to use the bindings in Flutter
So this is the last complicated step, that we need to do. We need to load the dylib file (similar to DLL’s on Windows) into the memory, so we can access the battery state function. Normally you would do it like that DynamicLibrary.open('path-to-lib')
but since we are developing for iOS the lib is already bundled with our app so we can just callDynamicLibrary.process()
and it will automatically pick the correct dylib.
After that, we just have to instantiate the UIDevice class and can then access all the methods that UIDevice provides.
final lib = UIDevice(DynamicLibrary.process()); final device = UIDevice.new1(lib)
Then we can just call device.batteryLevel
and get back the current battery state of the device. But if you run this code you might notice it does not work and the batteryLevel is always -1. Let’s jump back to the documentation:
Battery level ranges from 0.0 (fully discharged) to 1.0 (100% charged). Before accessing this property, ensure that battery monitoring is enabled.
We need to enable the battery monitoring first. So before we call the batteryLevel we have to call the setter device.batteryMonitoringEnabled = true
and then load the battery level. If you running this on a simulator it should return 1.0 (If you did not change the settings.
Here is the full code:
final lib = UIDevice(DynamicLibrary.process()); final device = UIDevice.new1(lib). device.batteryMonitoringEnabled = true; if (device.batteryMonitoringEnabled) { final batteryLevel = device.batteryLevel; log('Battery level: $batteryLevel'); }
How to generate Code from Swift
You might have noticed we only generated the code from Objective-C headers files, but modern iOS development uses Swift. How do we generate the same thing from Swift code? Sadly at the time of writing this, it only works with a small workaround. A @Objc
annotation needs to be added to the Swift code. For some of the iOS Swift libraries, this is already done. For others, you can write a quick wrapper in Swift and annotate those classes yourself. Also, you have to make every method you want to use public and they need to extend from NSObject. After that, you are ready to generate the Objective-C header files by running this:
swiftc -c swift_api.swift -module-name swift_module -emit-objc-header-path swift_api.h -emit-library -o libswiftapi.dylib
This will generate the Objective-C header files + the dylib file. With that, you can return to the previous steps and generate the Dart bindings. For more complex use cases you can also have a look at the Swift documentation.
Closed source-code
Those techniques don’t work with code that is not publicly available. This is especially the case for new SwiftUI components. The Dart team is working to find a proper solution and you can track the progress here.
Limitations
You already saw one big disadvantage with the closed source code or with missing Objective-C header files. But I think with the current progress we can expect a good solution here soon!
Other than that there are also some issues with multithreading which can be important for some of the platform tasks. These limitations are due to the relationship between Dart isolates and OS threads, and the way Apple’s APIs handle multithreading:
- Dart isolates are not the same thing as threads. Isolates run on threads but aren’t guaranteed to run on any particular thread, and the VM might change which thread an isolate is running on without warning. There is an open feature request to allow isolates to be pinned to specific threads.
- While ffigen supports converting Dart functions to Objective-C blocks, most Apple APIs don’t make any guarantees about on which thread a callback will run.
The callback created in one isolate might be invoked on a thread running a different isolate, or no isolate. This will cause your app to crash. You can work around this limitation by writing some Objective-C code that intercepts your callback and forwards it over a Dart Port to the correct isolate.
- Most APIs involving UI interaction can only be called on the main thread, the platform thread in Flutter.
Directly calling some Apple APIs using the generated Dart bindings might be thread-unsafe. This could crash your app, or cause other unpredictable behavior. You can work around this limitation by writing some Objective-C code that dispatches your call to the main thread. For more information, see the Objective-C dispatch documentation.
You can safely interact with Objective-C code, as long as you keep these limitations in mind.
JNI is coming
Objective-C/Swift Interop obviously only works for iOS. To replace platform-channels we also need a solution for Android (and other platforms). For Android, there is already an alpha implementation of JNI available which uses the the same C-Interop mechanism as Swift Interop. At the time of this article, it’s still in an alpha stage. We will release an article on how to use it (especially in combination with Swift Interop) when it reaches the beta state.
The future looks bright ✨
At the moment it is still some effort to access native methods. We have to check the native documentation, find the dylib, and generate code. That the generated code looks super complicated and enlarges our lines of code is also a negative side effect. We think that in the future the Flutter/Dart team might deliver those generated functions with the Flutter framework… at least that’s what we are hoping for. By that, they would diminish all the effort that we have to put in right now and only need to check the documentation and find the right methods.
Conclusion
Swift Interop is a new feature that makes it possible to call native functions directly from Dart. It’s still under heavy development, but it has the potential to revolutionize the way we develop Flutter apps and how we access native functions. There are still things to keep in mind:
- Swift Interop is still in beta: This means that there may be bugs, and the API might change in future versions of Flutter
- Swift Interop can be tricky to use correctly: If not used correctly, it can lead to memory leaks and other performance problems.
- Hard-to-debug issues: If you encounter issues inside the generated Dart code it’s not easy to find out where the issue comes from, since the code is generated and uses FFI code.
For now, our advice is to use Swift Interop mostly for small native functionalities that you like to implement in your app. We expect some breaking changes before Swift Interop reaches a stable release. So always keep in mind that with the next update, you might have to adapt your code again.
Thanks for reading!