KINTO Tech Blog
KMP (Kotlin Multiplatform)

Kotlin Multiplatform Hybrid Mode: Compose Multiplatform Meets SwiftUI and Jetpack Compose

Cover Image for Kotlin Multiplatform Hybrid Mode: Compose Multiplatform Meets SwiftUI and Jetpack Compose

Introduction

Hello! I'm Yao Xie from the Mobile Application Development Group at KINTO Technologies, where I develop the Android app of KINTO Kantan Moushikomi (KINTO Easy Application). In this article, I’ll be sharing some thoughts on how to implement a hybrid model that takes advantage of both Compose Multiplatform and native UI frameworks.

Developing mobile apps often means juggling limited budgets, varied team sizes, and tight timelines: all while ensuring long-term maintainability and a consistent user experience. A hybrid architecture built on Kotlin Multiplatform (KMP), which now includes Compose Multiplatform as a core UI framework, combined with native frameworks such as SwiftUI (iOS) and Jetpack Compose (Android), offers the flexibility to meet these challenges. Whether you’re launching an MVP on a shoestring budget or scaling up for a fully polished app, this approach adapts to your constraints and preserves code quality over time.

Kotlin Multiplatform Structure

A Flexible and Maintainable Approach for Diverse Constraints

Rapid Development with Consistency

For projects constrained by limited budget, small teams, or short deadlines, the benefits I perceive are the following:

  • Shared Business Logic: by writing your core functionality (networking, data models, business rules) once in a KMP module, will ensure consistent behavior across Android and iOS while reducing duplication and ease future maintenance.
  • Shared UI with Compose Multiplatform: Compose Multiplatform is part of the Kotlin Multiplatform ecosystem, which means it naturally builds on shared Kotlin code to build UI components. Develop most of your UI as shared composable components. Android benefits directly via Jetpack Compose, and iOS can embed these components using helpers like ComposeUIViewController. This uniform approach guarantees that both platforms adhere to the same design and behavior guidelines, simplifying long-term support.

This strategy minimizes development time while maintaining consistency—a key factor for high-quality user experiences and future-proof code.

Kotlin Multiplatform Hybrid Mode with Limited Resources

"The Android robot is reproduced or modified from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License."

Scaling Up with Native Enhancements

As your project secures more funding, personnel, or additional time:

  • Incremental Native Integration: With a solid shared foundation, you can gradually replace or enhance key screens using native UI elements. For example, you can enhance critical iOS screens with SwiftUI to better align with platform-specific design standards.
  • Expect/Actual Mechanism: Kotlin’s expect/actual pattern lets you define a generic shared component (like a platform-specific button) and then provide native implementations. This enables you to start with a shared UI and later invest in polished native refinements where they matter most.

Kotlin Multiplatform Hybrid Mode with Abundant Resources

Adapting to Different Team Sizes and Project Timelines

The hybrid approach is designed to flex as your project evolves:

  • For Lean Teams and Tight Schedules: Focus on maximum code reuse. A small team can build and maintain the shared business logic and UI, ensuring consistency while speeding up time-to-market.
  • For Larger Teams or Extended Deadlines: As your team grows or deadlines relax, allocate additional resources toward developing native components to enhance user experience. This phased strategy minimizes risk and ensures that the core logic remains stable and maintainable over time.

I find that by keeping the shared logic and UI consistent across platforms, you reduce complexity and technical debt, making projects easier to maintain and evolve.

Kotlin Multiplatform Hybrid Mode with Standard Resources

Code Snippets Illustrating the Kotlin Multiplatform Hybrid Mode

Shared Business Logic

This shared module guarantees that both Android and iOS apps use the same core logic, ensuring consistency and reducing maintenance overhead.

Api.kt
// Api
interface Api {
    suspend fun getUserProfile(): User
}

class ApiImpl(private val client: HttpClient) : Api {
    override suspend fun getUserProfile(): User {
        return client.get {
            url {
                protocol = URLProtocol.HTTPS
                host = "yourdomain.com"
                path("yourpath")
            }
        }.safeBody()
    }
}
Repository.kt
// Repository 
interface UserRepository {
    suspend fun getUserProfile(): Flow<User>
}

class UserRepositoryImpl(private val api: Api) : UserRepository {
    override suspend fun getUserProfile(): Flow<User> {
        return flowOf(api.getUserProfile())
    }
}

// ViewModel
class ProfileViewModel(
    private val repository: UserRepository,
) : ViewModel() {

    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()

    @OptIn(ExperimentalCoroutinesApi::class)
    fun onAction(action: ProfileScreenAction) {
        when (action) {
            is ProfileScreenAction.OnInit -> {
                viewModelScope.launch {
                    // Set loading state to true
                    _uiState.update { it.copy(isLoading = true) }
                    
                    // Retrieve authorization info and then fetch user profile data
                    repository.getAuthorizationInfo()
                        .flatMapLatest { authInfo ->
                            // If authInfo exists, call getUserProfile() to get user data; otherwise, emit null
                            if (authInfo != null) {
                                repository.getUserProfile()
                            } else {
                                flowOf(null)
                            }
                        }
                        // Catch any exceptions in the Flow chain
                        .catch { e ->
                            e.printStackTrace()
                        }
                        // Collect the user profile data and update the UI state
                        .collect { userProfile ->
                            _uiState.update { state ->
                                state.copy(
                                    isLoading = false,
                                    data = userProfile,
                                    errorMessage = if (userProfile == null) "User profile data is null" else null
                                )
                            }
                        }
                }
            }
        }
    }
}

Shared UI with Compose Multiplatform

Compose Multiplatform—being an integrated part of Kotlin Multiplatform—allows you to create UI components in shared Kotlin code:

ProfileScreen.kt
@Composable
fun ProfileScreen(
    viewModel: ProfileViewModel = koinViewModel()
) {
    val uiState = viewModel.uiState.collectAsState().value

    LaunchedEffect(Unit) { viewModel.onAction(ProfileScreenAction.OnInit) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(White)
            .padding(16.dp)
    ) {
        // Profile header with image and user details
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .background(White, shape = RoundedCornerShape(16.dp))
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            AsyncImage(
                model = uiState.data.profileImage,
                contentDescription = "Profile Image",
                modifier = Modifier
                    .size(64.dp)
                    .clip(CircleShape)
            )
            Spacer(modifier = Modifier.width(16.dp))
            Column {
                Text(
                    text = uiState.data.userName,
                    fontSize = 16.sp,
                    color = Color.Black
                )
                Text(
                    text = uiState.data.email,
                    fontSize = 14.sp,
                    color = Color.Blue.copy(alpha = 0.6f)
                )
            }
        }
        Spacer(modifier = Modifier.height(16.dp))
        MenuLabel(label = "Account Settings")
        ....
    }
}

Lean Approach: I find using this composable directly on both platforms convenient for a consistent look and behavior.

Scaled Approach: Enhance the UI with native components as needed without altering the underlying business logic.

Native UI via Expect/Actual

Define an expected component:

PlatformButton.kt
@Composable
expect fun PlatformButton(
    modifier: Modifier = Modifier,
    label: String,
    onClick: () -> Unit
)

Android Implementation (androidMain):

PlatformButton.android.kt
@Composable
actual fun PlatformButton(
    modifier: Modifier,
    label: String,
    onClick: () -> Unit
) {
    Button(
        onClick = onClick,
        modifier = modifier
    ) {
        Text(text = label)
    }
}

iOS Implementation (iosMain):

PlatformButton.ios.kt
@Composable
actual fun PlatformButton(
    modifier: Modifier,
    label: String,
    onClick: () -> Unit
) {
    UIKitView(
        modifier = modifier,
        factory = {
            // Create a native UIButton instance.
            val button = UIButton.buttonWithType(buttonType = 0)
            button.setTitle(label, forState = UIControlStateNormal)
            // Style the button
            ...
            
            // Create a target object to handle button clicks.
            val target = object : NSObject() {
                @ObjCAction
                fun onButtonClicked() {
                    onClick()
                }
            }
            // Add the target with an action selector.
            button.addTarget(
                target = target,
                action = NSSelectorFromString("onButtonClicked"),
                forControlEvents = UIControlEventTouchUpInside
            )
            button
        }
    )
}

Blended UI: A Key Advantage Over Flutter

Unlike Flutter, Compose Multiplatform supports blending shared UI and native components seamlessly on a single screen.

In many scenarios, you may not need a complete switch from shared UI to native UI—rather, you can blend them on a single screen. This approach allows you to tap into the strengths of both Compose Multiplatform and native frameworks without fully committing to one for an entire screen. For example, you might want to display a shared composable header alongside a native SwiftUI list on iOS.

Here’s how you can achieve a blended UI:

Compose Multiplatfor embedded with Native UI

Embedding Shared Composables in a Native Layout:

One of Compose Multiplatform’s strengths is the ability to seamlessly embed shared Compose UI components into native SwiftUI layouts. Developers can gradually adopt shared components without rewriting entire screens, a flexibility that's difficult to achieve with Flutter’s overlay approach.

For example, you can use SwiftUI as a container to host Compose Multiplatform’s shared UI wrapped in a UIViewController:

ContentView.swift
import SwiftUI
import shared  // Import the shared KMP framework

struct ComposeContainerView: UIViewControllerRepresentable {
    let user: User

    func makeUIViewController(context: Context) -> UIViewController {
        // Use the Compose wrapper to create the UIViewController
        return AppComposeUI().createProfileVC(user)
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
}

struct ContentView: View {
    let user: User
    
    var body: some View {
        VStack {
            Text("Native SwiftUI Header")
                .font(.headline)
                .padding()
            // Embed the Compose UI inside SwiftUI.
            ComposeContainerView(user: user)
                .frame(height: 250)
            List {
                Text("Native SwiftUI Item 1")
                Text("Native SwiftUI Item 2")
            }
        }
    }
}

Embedding Native iOS UI into Compose Multiplatform Screens

Compose Multiplatform also enables embedding native iOS views (e.g., SwiftUI or UIKit) within shared Compose screens—another capability not easily achieved by Flutter.

Define a SwiftUI view to embed:

MySwiftUIView.swift
import SwiftUI
struct MySwiftUIView: View {
    let user: User
    var body: some View {
        VStack {
            Text("Native SwiftUI Content")
                .font(.body)
            Text("Welcome, \(user.firstName)")
        }
        .padding()
        .background(Color.gray.opacity(0.2))
        .cornerRadius(8)
    }
}

Then integrate this native view into your shared Compose UI using UIKitView:

ComposeWithNativeUI.kt
@Composable
fun ComposeWithNativeUI(user: User) {
    Column {
        // Shared Compose UI header.
        ProfileHeader(user = user)
        Spacer(modifier = Modifier.height(16.dp))
        // Embed a native iOS view using UIKitView.
        UIKitView(factory = {
            // Create a UIHostingController with a SwiftUI view.
            val hostingController = UIHostingController(rootView = MySwiftUIView(user = user))
            hostingController.view
        }, modifier = Modifier.fillMaxWidth().height(100.dp))
    }
}

This bidirectional inline integration sets Compose Multiplatform apart from Flutter, enabling teams to incrementally adopt shared UI components while maintaining native look and feel. It offers developers greater flexibility and reduces the risk of large-scale rewrites, combining the efficiency of shared code with the richness and quality of native UI.

Compose Multiplatform provides a significant advantage over Flutter by enabling more flexible, fine-grained integration between shared UI and native platform UI. Unlike Flutter, which typically renders shared UI components through an overlay mechanism, Compose Multiplatform uses an inline rendering approach, allowing shared and native UI elements to blend naturally together.

Compose Multiplatfor embedded in Native UI vs Flutter embedded in Native UI

Pros

Best Practices for Flexibility, Maintainability, and Scalability

Embrace Rapid Prototyping:

I’ve learned the hard way that spending weeks perfecting pixel details before users even touch the app is risky. These days our team spins up a shared‑UI prototype in a week or two, pushes it into stakeholders’ hands, and soaks up feedback early. When we’re confident the idea sticks, we layer on native flourishes—animations, platform‑specific gestures—without throwing away what we built.

Plan Incremental Development:

Our rule of thumb: launch small, refactor later. We start with a KMP module for core logic and a single shared Compose screen. Once that slice proves itself, we peel off the next screen—or replace a shared widget with a SwiftUI or Jetpack Compose version—one commit at a time. The gradual approach keeps velocity up and rewrites down.

Balance Resources, Quality, and Scalability:

When it was just two of us, 90 percent of the UI lived in shared code because that’s what we could afford. As head‑count and design ambitions grew, we shifted the critical flows to native, leaving the rest untouched. That staged migration kept the backlog realistic and let us dial quality up without derailing releases.

Maintain Consistency:

Centralizing business logic in one Kotlin module has saved us countless merge headaches. A single bug fix lands once and rolls out everywhere—no more “iOS fixed, Android still broken” stand‑ups. It’s not flashy, but this shared core is what lets the team sleep at night when deadlines loom.

Ensure Elasticity:

In my experience leading mixed‑platform projects, the teams that thrive are the ones that treat architecture like a living organism, not a static blueprint. We design every layer so it can flex when budgets tighten, timelines shift, or a surprise hire brings new skills. Because Compose Multiplatform lets us blend shared and native UI on demand, we can pivot from “MVP mode” to “native‑polish mode” in days, not months. That elasticity has repeatedly saved us when product strategy—or the market itself—changed overnight.

Conclusion

This elastic hybrid architecture, built on Kotlin Multiplatform and Compose Multiplatform alongside native UI frameworks, offers unparalleled flexibility. It allows you to adjust your development strategy based on budget, team size, and timeline constraints while maintaining consistency and long-term maintainability. For lean projects, maximize code reuse to launch quickly; as resources expand, incrementally enhance critical screens with native UI elements without rewriting your core logic.

Embrace this flexible, incremental model to reduce duplication, streamline maintenance, and deliver a consistent, high-quality user experience that scales as your project evolves.

Facebook

関連記事 | Related Posts

We are hiring!

【プロダクト開発バックエンドエンジニア(リーダークラス)】共通サービス開発G/東京・大阪

共通サービス開発グループについてWebサービスやモバイルアプリの開発において、必要となる共通機能=会員プラットフォームや決済プラットフォームなどの企画・開発を手がけるグループです。KINTOの名前が付くサービスやKINTOに関わりのあるサービスを同一のユーザーアカウントに対して提供し、より良いユーザー体験を実現できるよう、様々な共通機能や顧客基盤を構築していくことを目的としています。

【UI/UXデザイナー】クリエイティブ室/東京・大阪

クリエイティブ室についてKINTOやトヨタが抱えている課題やサービスの状況に応じて、色々なプロジェクトが発生しそれにクリエイティブ力で応えるグループです。所属しているメンバーはそれぞれ異なる技術や経験を持っているので、クリエイティブの側面からサービスの改善案を出し、周りを巻き込みながらプロジェクトを進めています。

イベント情報

Cloud Security Night #2
製造業でも生成AI活用したい!名古屋LLM MeetUp#6
Mobility Night #3 - マップビジュアライゼーション -