Jan 3

6 min read

Flin: Flutter + Kotlin Multiplatform

This article will assume that you know what is Flutter and Kotlin Multiplatform (KMP). And no, this is not another review about Flutter vs KMP. On the contrary, I want to talk about how to make Flutter and KMP work together and make something I called Flin. Wait, how? But more importantly, why?

Let’s talk about ‘How’ first:

1. Init Flutter (2.8.0)

> flutter create myapp

2. Update Gradle

update android/build.gradle:

  buildscript { 
- ext.kotlin_version = '1.3.50'
+ ext.kotlin_version = '1.6.10'
...
dependencies {
- classpath 'com.android.tools.build:gradle:4.1.0'
+ classpath 'com.android.tools.build:gradle:7.0.4'
...
}
}

update android/gradle/wrapper/gradle-wrapper.properties:

-  distributionUrl=.../gradle-6.7-all.zip
+ distributionUrl=.../gradle-7.3.3-all.zip

Type in terminal:

\android > ./gradlew --refresh-dependencies

3. Init KMM

create shared/build.gradle:

plugins {
id "org.jetbrains.kotlin.multiplatform"
}
kotlin {
targets {
final def iOSTarget =
System.getenv("SDK_NAME")?.startsWith("iphoneos")
? presets.iosArm64
: presets.iosX64
fromPreset(iOSTarget, "iOS") {
binaries {
framework {
baseName = "shared"
}
}
}
fromPreset(presets.jvm, "android")
}
sourceSets {
commonMain.dependencies {}
androidMain.dependencies {}
iosMain.dependencies {}
}
}
repositories {
google()
maven { url "https://kotlin.bintray.com/kotlinx" }
maven { url "https://kotlin.bintray.com/kotlin/kotlinx" }
maven { url "https://kotlin.bintray.com/ktor" }
}
dependencies {}task packForXCode(type: Sync) {
final File frameworkDir = new File(buildDir, "xcode-frameworks")
final String mode =
project.findProperty("XCODE_CONFIGURATION")
?.toUpperCase()
?: 'DEBUG'
final def binary =kotlin.targets.iOS.binaries.getFramework(mode)
inputs.property "mode", mode
dependsOn binary.linkTask
from { binary.outputFile.parentFile }
into frameworkDir
doLast {
new File(frameworkDir, 'gradlew').with {
text =
"#!/bin/bash\n
export 'JAVA_HOME=${System.getProperty("java.home")}'\n
cd '${rootProject.rootDir}'\n./gradlew \$@\n"
setExecutable(true)
}
}
}
tasks.build.dependsOn packForXCode

create shared/src/commonMain/kotlin/Sdk.kt:

package com.example.myapp.kmmexpect object Sdk {
val platformName: String
}
fun processMethodChannel(
method: String,
params: Any,
success: (Any) -> Unit,
error: (String?, String?, String?) -> Unit
) {
when (method) {
"platformName" -> success(platformName())
else -> error("err method", "err method", "err method")
}
}
fun platformName() = Sdk.platformName

create shared/src/androidMain/kotlin/Sdk.kt:

package com.example.myapp.kmm
actual object Sdk {
actual val platformName: String = "Android"
}

create shared/src/iosMain/kotlin/Sdk.kt:

package com.example.myapp.kmm
import platform.UIKit.UIDevice
import platform.darwin.*
actual object Sdk {
actual val platformName: String =
UIDevice.currentDevice.systemName() +
" " +
UIDevice.currentDevice.systemVersion
}

4. Update Android

update android/app/src/main/AndroidManifest.xml:

  <application
...
- android:name="${applicationName}"
...
>
...
</application>

update android/app/build.gradle:

  dependencies {
...
+ implementation project(":shared")
...
}

update android/settings.gradle:

   ...
+ include ':shared'
+ project(":shared").projectDir = new File("../shared")

update android/app/src/main/kotlin/com/example/myapp/MainActivity.kt:

   package com.example.myapp

import io.flutter.embedding.android.FlutterActivity
+ import io.flutter.embedding.engine.FlutterEngine
+ import io.flutter.plugin.common.MethodChannel
+ import io.flutter.plugins.GeneratedPluginRegistrant
+ import com.example.myapp.kmm.processMethodChannel
+++
class MainActivity : FlutterActivity() {

override fun configureFlutterEngine(
flutterEngine: FlutterEngine
) {
GeneratedPluginRegistrant.registerWith(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, "/sdk"
).apply {
setMethodCallHandler { call, result ->
processMethodChannel(
call.method ?: "",
call.arguments ?: emptyList<String>(),
{ s -> result.success(s) },
{ e, e1, e2 -> result.error(e, e1, e2) }
)
}
}
}
}
+++

Type in terminal:

\android > ./gradlew build

5. Update iOS

update ios/Runner.xcodeproj/project.pbxproj:

/* Begin PBXBuildFile section */
...
+ A34F615D223B36C9007121E0 /* shared.framework in Frameworks */ =
+ {
+ isa = PBXBuildFile;
+ fileRef = A34F615C223B36C8007121E0 /* shared.framework */;
+ };
+ A34F615E223B36DB007121E0 /* shared.framework in Frameworks */ =
+ {
+ isa = PBXBuildFile;
+ fileRef = A34F615C223B36C8007121E0 /* shared.framework */;
+ };

+ A34F615F223B36DB007121E0 /*shared.framework in Embed Frameworks*/=
+ {
+ isa = PBXBuildFile;
+ fileRef = A34F615C223B36C8007121E0 /* shared.framework */;
+ settings = {
+ ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, );
+ };
+ };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
...
files = (
+ A34F615F223B36DB007121E0
+ /* shared.framework in Embed Frameworks */,
);
...
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
...
+ A34F615C223B36C8007121E0 /* shared.framework */ = {
+ isa = PBXFileReference;
+ lastKnownFileType = wrapper.framework;
+ name = shared.framework;
+ path = "../build/shared/xcode-frameworks/shared.framework";
+ sourceTree = "<group>";
+ };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
...
files = (
+ A34F615E223B36DB007121E0 /* shared.framework in Frameworks */,
+ A34F615D223B36C9007121E0 /* shared.framework in Frameworks */,
);
...
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
...
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
+ A34F615C223B36C8007121E0 /* shared.framework */,
...
);
...
};
...
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
97C146ED1CF9000F007C117D /* Runner */ = {
...
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
+ 5006EFAF274CA707000120A6 /* Run Script */,
...
);
...
/* End PBXNativeTarget section */

/* Begin PBXShellScriptBuildPhase section */
...
+ 5006EFAF274CA707000120A6 /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ iles = ();
+ inputFileListPaths = ();
+ inputPaths = ();
+ name = "Run Script";
+ outputFileListPaths = ();
+ outputPaths = ();
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript =
+ "cd $SRCROOT/build/shared/xcode-frameworks\n
+ cd ..\n
+ cd android\n
+ gradle :shared:packForXCode
+ -PXCODE_CONFIGURATION=${CONFIGURATION}\n";
+ };
/* End PBXShellScriptBuildPhase section */
/* Begin XCBuildConfiguration section */
249021D4217E4FDB00AE95B9 /* Profile */ = {
...
buildSettings = {
...
ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = "../build/shared/xcode-frameworks";
...
}
...
}
97C147061CF9000F007C117D /* Debug */ = {
...
buildSettings = {
...
ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = "../build/shared/xcode-frameworks";
...
}
...
}
97C147071CF9000F007C117D /* Release */ = {
...
buildSettings = {
...
ENABLE_BITCODE = NO;
+ FRAMEWORK_SEARCH_PATHS = "../build/shared/xcode-frameworks";
...
}
...
}
/* End XCBuildConfiguration section */

update ios/Runner/AppDelegate.swift:

    import UIKit
import Flutter
+ import shared
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
override func application(...) -> Bool {
+++
let controller : FlutterViewController =
window?.rootViewController as! FlutterViewController

let channel = FlutterMethodChannel.init(name: "/sdk",
binaryMessenger: controller.binaryMessenger)

channel.setMethodCallHandler({
(
call: FlutterMethodCall,
result: @escaping FlutterResult
) -> Void in SdkKt.processMethodChannel(
method: call.method ,
params: call.arguments ?? [:],
success: {
s in result(s)
},
error: {
_, _, _ in result(FlutterMethodNotImplemented)
})
});
+++
...
}
}

6. Update Flutter

update lib/main.dart:

+  import 'package:flutter/services.dart';
class _MyHomePageState extends State<MyHomePage> {
...
+ static const _channel = MethodChannel('/sdk');
+ var _platform = "?";
+ void _incrementCounter() async {
+ var platform = await _channel.invokeMethod('platformName');
+ setState(() { _counter++; _platform = platform; });
+ }
}
...
- const Text('You have pushed the button this many times:',),
+ Text('You have pushed the button this many times on $_platform'),
...

7. Run the app!

App comparisons: Android and iOS

How Flin Works

Flutter <=> OS MethodChannel <=> Kotlin

Flutter is using asynchronous MethodChannel ‘/sdk’ to get its data from OS, which the OS get its data from Kotlin. Flin has some advantages over using full Flutter:

  • If you are an Android Native Developer or more comfortable coding in Kotlin than Dart then you can reduce a lot of Dart code in your project. You only use Dart for the UI code and put all your business logic in Kotlin,
  • Using KMP means you are already 50% to become native as your business logic is already done and all that’s left is the native UI code,
  • Technically, you can code in Java then convert it automatically to Kotlin using Android Studio (or Intellij IDEA).

Why do I do this?

I come from Kotlin/Android background. When I found that you can make a desktop and web app using KMP, I found fewer reasons for learning Dart. The only thing that doesn't make KMP perfect is Compose doesn't support iOS, so I need to learn SwiftUI for this. Between learning SwiftUI and Dart, I feel that Dart is a better investment as I can only use SwiftUI inside the Apple ecosystem.

I’m not saying that one is better than the other. I just want to provide an alternative that I feel more comfortable doing. If you are already fluent in Dart, then go on! But, if you already have a plan to make your cross-platform app become a native, you might consider coding your business logic in Kotlin as I do.

Thanks for reading and happy coding.

References