Bloga Dön
Kotlin MultiplatformCompose MultiplatformAndroid

Native Android Uygulamasını Kotlin Multiplatform'a Taşımak: ValeCep Deneyimi

30 Ağustos 202615 dk okuma
Native Android Uygulamasını Kotlin Multiplatform'a Taşımak: ValeCep Deneyimi

Play Store'da aktif olarak yayında olan native Android uygulamamı Kotlin Multiplatform'a (KMP) taşıdım. Şu an aynı kod tabanı hem Android hem iOS üzerinde sorunsuz çalışıyor; üstelik ekranların ve iş mantığının (business logic) %93'ü tamamen ortak. Bu yazıda refaktör sürecinin tamamını, mimari kararları ve kod örnekleriyle birlikte paylaşacağım.

ValeCep Nedir ve Bu Yolculuk Nasıl Başladı?

ValeCep, restoran ve otellerde vale operasyonlarını dijitalleştiren, aynı zamanda site ve apartmanlarda otopark yönetimi (park yeri, plaka takibi, komşu iletişimi) sunan bir SaaS platformu. İki farklı iş modelini tek bir mobil uygulamada birleştiriyor.

Projeyi en başında tamamen native Android olarak kurgulamıştım: Kotlin, Jetpack Compose, Hilt, Room ve Firestore. Klasik Android mimarisine sahip, Play Store'da canlıda ve aktif kullanıcıları olan bir projeydi. Ancak uygulamanın büyümesiyle birlikte iOS tarafında da bulunma ihtiyacı kaçınılmaz hale geldi.

Tek geliştirici olarak iki ayrı native kod tabanını (Android + Swift) paralel yönetmek giderek zorlaşıyordu. Her yeni özelliği iki defa yazmak, her hatayı iki ayrı kod tabanında düzeltmek ve iki ayrı yayın döngüsünü yürütmek operasyonel bir yüktü. Mevcut Kotlin kod tabanını koruyarak iOS'a genişleyebilecek bir mimari gerekiyordu. Çözüm: Kotlin Multiplatform + Compose Multiplatform.

Bugün ValeCep hem Play Store hem App Store'da canlıda. Tek kod tabanı, %93 ortak kod ve 40+ paylaşılan ekran ile yayında. Bu yazıda söz konusu dönüşümü nasıl gerçekleştirdiğimi, kütüphane seçimlerimi, karşılaştığım teknik engelleri ve production ortamında öğrendiğim dersleri aktaracağım.

Alternatifler ve Neden KMP?

Native Android uygulamamı App Store'da da yayınlamak için önümde üç seçenek vardı:

  1. React Native / Flutter'a Geçiş: Mevcut tüm Android kod tabanını çöpe atıp sıfırdan yazmak demekti. En az 2-3 aylık bir yeniden geliştirme süresi ve mevcut stabil yapının terk edilmesi anlamına geliyordu.
  2. Native iOS (Swift) Geliştirme: İki ayrı kod tabanını paralel yaşatmak ve her özelliği iki kez geliştirmek demekti. Tek kişilik bir mühendislik kapasitesi için sürdürmesi oldukça zor bir yaklaşımdı.
  3. Kotlin Multiplatform (KMP): Mevcut Kotlin altyapısının büyük kısmını koruyup, iOS tarafında yalnızca platforma özel katmanları yazmak.

KMP seçeneği başlangıçta radikal görünse de iki temel etken kararı kesinleştirdi:

  • Compose Multiplatform Stabilitesi: Yalnızca iş mantığını değil, UI katmanını da platformlar arasında ortaklaştırmak artık mümkündü.
  • KMP Stable Statüsü: JetBrains'in KMP'yi resmi olarak production kullanımına uygun (Stable) ilan etmiş olması.

Bu değerlendirmelerin sonucunda KMP + Compose Multiplatform mimarisinde karar kıldım.

İlk Adım: shared Modülünün Ayrıştırılması

Native Android projesinde tüm mimari app/ dizini altında toplanmıştı. İlk adım, kod tabanını sorumluluklarına göre iki ana yapıya ayırmak oldu:

  • app/: Yalnızca Android'e özel giriş noktaları (MainActivity, Application sınıfı, Google Play entegrasyonları)
  • shared/: Her iki platformda da ortak çalışan kodlar (domain, data, presentation)

Önce (Native Android Mimarisi):

VehicleApp/
└── app/src/main/
    ├── java/com/enons/vehicleapp/
    │   ├── data/              ← Repository, Room DAO, Firestore
    │   ├── domain/            ← Use case, model
    │   ├── presentation/      ← Compose ekranlar, ViewModel
    │   ├── di/                ← Hilt modülleri
    │   └── util/              ← Helper fonksiyonlar
    └── res/                   ← XML resource (strings, drawables)

Tüm kod tek modülde toplanmıştı; Hilt, Room ve Android Context bağımlılıkları tüm katmanlara sızmış durumdaydı.

Sonra (KMP Mimarisi):

VehicleApp/
├── app/                       ← sadece Android giriş noktası (5 dosya)
│   └── src/main/
│       └── MainActivity.kt, Application.kt, DI init
├── iosApp/                    ← sadece iOS giriş noktası (3 Swift dosyası)
│   └── iOSApp.swift, ContentView.swift, IosBillingBridgeImpl.swift
└── shared/src/
    ├── commonMain/            ← her platform için ortak (173 dosya)
    │   ├── kotlin/
    │   │   ├── data/          ← SQLDelight, Repository impl
    │   │   ├── domain/        ← Use case, model, interface
    │   │   └── presentation/  ← Compose MP ekranlar, ViewModel
    │   ├── composeResources/  ← string/drawable/font (i18n)
    │   └── sqldelight/        ← veritabanı şemaları
    ├── androidMain/           ← Android-özel (16 dosya)
    │   └── kotlin/            ← OSMDroid, ContentResolver, vb.
    └── iosMain/               ← iOS-özel (21 dosya)
        └── kotlin/            ← MapKit, PHPicker, billing bridge

Sonuç net: Eskiden Android'e bağlı ~200 dosya varken, dönüşüm sonrası 173 dosya tamamen platform bağımsız commonMain katmanına taşındı. Platforma özel kod miktarı ise toplamda 37 dosyaya (16 Android + 21 iOS) geriledi.

Mimarinin Katmanlaşması

shared/src/commonMain/kotlin/com/enons/vehicleapp/
├── data/
│   ├── local/           ← SQLDelight
│   ├── repository/      ← Repository implementations
│   └── session/         ← Session holder
├── domain/
│   ├── billing/         ← BillingRepository contract
│   ├── model/           ← Vehicles, ParkingSpot, Plan, Organization
│   ├── repository/      ← Repository interfaces
│   ├── usecase/         ← Business logic
│   └── util/            ← Formatting, DateTime, Fee calculation
└── presentation/
    ├── navigation/      ← Compose Navigation graphs
    ├── screens/         ← 40+ ekran (auth, home, vehicle, site, premium, ...)
    ├── ui/              ← Design system (colors, tokens, components)
    └── viewmodel/       ← 22 ViewModel

Clean Architecture prensiplerine uygun olarak kurgulanan bu katmanlar tamamen commonMain içerisinde konumlanıyor.

Kritik Kütüphane Dönüşümleri

Native Android'de kullanılan kütüphanelerin KMP ekosistemindeki karşılıkları şu şekilde belirlendi:

Native AndroidKMP KarşılığıAçıklama
HiltKoin (4.1.0)KMP uyumlu bağımlılık enjeksiyonu
RoomSQLDelight (2.0.2)Native ve tip güvenli veritabanı
Firebase Native SDKGitLive Firebase SDKKMP için topluluk tarafından geliştirilen Firebase wrapper'ı
SharedPreferencesmultiplatform-settingsKey-value depolama çözümü
Jetpack ComposeCompose MultiplatformJetBrains tarafından geliştirilen UI motoru
Android NavigationNavigation Compose MP (2.9.0-beta)Ortak ekran yönlendirme mimarisi
Android ViewModelandroidx.lifecycle:lifecycle-viewmodelOfficial KMP ViewModel desteği

shared/build.gradle.kts bağımlılık yapılandırması:

kotlin
kotlin {
    androidTarget { ... }
    val xcf = XCFramework("shared")
    listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "shared"
            isStatic = true
            xcf.add(this)
        }
    }
    sourceSets {
        commonMain.dependencies {
            implementation("dev.gitlive:firebase-auth:2.1.0")
            implementation("dev.gitlive:firebase-firestore:2.1.0")
            implementation("dev.gitlive:firebase-storage:2.1.0")
            implementation("dev.gitlive:firebase-functions:2.1.0")
            implementation("io.insert-koin:koin-core:4.1.0")
            implementation("app.cash.sqldelight:coroutines-extensions:2.0.2")
            implementation("com.russhwolf:multiplatform-settings:1.2.0")
            implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel:2.9.0")
            implementation(compose.runtime)
            implementation(compose.material3)
            implementation(compose.components.resources)
            implementation("org.jetbrains.androidx.navigation:navigation-compose:2.9.0-beta03")
            implementation("io.insert-koin:koin-compose-viewmodel:4.1.0")
        }
        androidMain.dependencies {
            implementation("app.cash.sqldelight:android-driver:2.0.2")
            implementation("org.osmdroid:osmdroid-android:6.1.18")
        }
        iosMain.dependencies {
            implementation("app.cash.sqldelight:native-driver:2.0.2")
        }
    }
}

Not: ValeCep dönüşümünü gerçekleştirdiğim dönemde veritabanı tarafında SQLDelight, Firebase tarafında ise dev.gitlive kütüphanesi en kararlı seçeneklerdi. Bugün geldiğimiz noktada Google; Room 2.7+ ile resmi KMP desteğini duyurdu, ayrıca resmi Firebase KMP SDK'larını da kademeli olarak yayınlamaya başladı. Projenizin ihtiyaçlarına göre resmi kütüphaneler ile topluluk çözümleri arasındaki güncel kararlılık (production-ready) durumlarını araştırıp seçim yapmanızı öneririm. Benim senaryomda SQLDelight ve dev.gitlive ikilisi canlı ortamda son derece sorunsuz çalıştı.

Compose Multiplatform ile UI Katmanının Paylaşılması

Sürecin en verimli kısmı UI katmanının ortaklaştırılmasıydı. Native Compose ile yazılmış aşağıdaki gibi bir bileşeni KMP'ye taşırken kod üzerinde hiçbir değişiklik yapılması gerekmedi:

kotlin
@Composable
fun VehicleCard(vehicle: Vehicles, onClick: () -> Unit) {
    Card(onClick = onClick) {
        Column(Modifier.padding(16.dp)) {
            Text(vehicle.vehicleName, style = MaterialTheme.typography.titleMedium)
            Text(vehicle.customerName, style = MaterialTheme.typography.bodyMedium)
        }
    }
}

Compose Multiplatform'a taşımak için bu kodda değişmesi gereken bir şey yoktu. Aynı Composable fonksiyon hem Android hem iOS'ta sorunsuz çalıştı.

Projedeki 40'tan fazla ekranın tamamı Compose Multiplatform ile oluşturuldu. Uygulamada tek bir XML layout veya SwiftUI View yok — tüm UI tek kaynaktan besleniyor.

Ortak uygulamanın giriş noktası (App.kt):

kotlin
@Composable
fun App() {
    val authRepo: FirebaseAuthRepository = koinInject()
    val sessionState by authRepo.sessionState.collectAsState()
    ValeTheme {
        when (val s = sessionState) {
            is SessionState.Loading -> SplashPlaceholder()
            is SessionState.SignedOut -> AuthNavGraph()
            is SessionState.NeedsVerification -> VerifyEmailScreen(email = s.email)
            is SessionState.SignedIn -> {
                if (s.session.isSite) SiteNavGraph(session = s.session)
                else MainNavGraph(session = s.session)
            }
        }
    }
}

Bu fonksiyon Android'de ComposeView ile, iOS'ta ise UIViewController içinde çalışıyor. İçinde tek satır platforma özel kod bulunmuyor — tüm navigasyon ve durum yönetimi ortaklaştırılmış durumda.

Platforma Özel Katmanlar: expect / actual Pattern'i

Her uygulama bir noktada platforma özgü API'lere dokunmak zorundadır. KMP bu ihtiyacı expect/actual mekanizmasıyla ele alıyor:

commonMain'de contract:

kotlin
expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

androidMain'de:

kotlin
actual class DatabaseDriverFactory(private val context: Context) {
    actual fun createDriver(): SqlDriver =
        AndroidSqliteDriver(VehicleAppDatabase.Schema, context, "vehicleapp.db")
}

iosMain'de:

kotlin
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver =
        NativeSqliteDriver(VehicleAppDatabase.Schema, "vehicleapp.db")
}

Aynı contract, iki farklı implementation. Business logic bu contract'a bağlı ve hangi platformda çalıştığını bilmesine gerek yok.

Bu pattern'in yoğun biçimde kullanıldığı alanlar:

  • Harita Entegrasyonu: Android tarafında OSMDroid, iOS tarafında MapKit
  • Fotoğraf & Medya Seçimi: Android'de ContentResolver, iOS'ta PHPickerViewController
  • Push Notification Token'ları: Android'de FirebaseMessaging.getToken, iOS'ta APNs ➔ FCM eşleşmesi
  • Dil ve Yerelleştirme (Localization): Android'de Configuration, iOS'ta NSLocale
  • Yerel Depolama (Preferences): multiplatform-settings kütüphanesinin yetersiz kaldığı özel durumlar için custom expect/actual çözümleri

Toplamda 16 androidMain + 21 iosMain dosya bulunuyor. Geri kalan tüm kod ortaklaştırılmış durumda.

En Kritik Nokta: iOS'ta Swift ↔ Kotlin Birlikte Çalışabilirliği (Interop)

KMP'nin doğrudan çözemediği bir senaryo var: iOS tarafında yalnızca Swift ile erişilebilen bir kütüphaneyi kullanmak (bu projede RevenueCat billing SDK).

Temel sorun şu: Kotlin/Native'in ürettiği shared framework, Swift'te tanımlanmış sınıfları doğrudan göremez. Bu nedenle bağımlılığın yönünü tersine çevirmek gerekiyor. Kotlin bir arayüz (interface) tanımlar, Swift ise bu arayüzü implement eder.

Çözüm: Dependency Inversion (Bağımlılık Tersine Çevirme)

Kotlin tarafında iosMain/:

kotlin
interface IosBillingBridge {
    fun configure(apiKey: String)
    fun logIn(uid: String, callback: (success: Boolean, message: String?) -> Unit)
    fun purchase(vertical: String, planId: String, callback: (result: Int, error: String?) -> Unit)
    fun restore(callback: (result: Int, error: String?) -> Unit)
}

internal var iosBillingBridgeInstance: IosBillingBridge? = null

fun installIosBillingBridge(bridge: IosBillingBridge) {
    iosBillingBridgeInstance = bridge
}

Kotlin tarafında repository implementation, bu bridge'i kullanıyor:

kotlin
class RevenueCatBillingRepositoryIos : BillingRepository {
    override suspend fun purchase(...): PurchaseOutcome =
        suspendCancellableCoroutine { cont ->
            iosBillingBridgeInstance?.purchase(vertical, planId) { code, err ->
                cont.resume(mapResult(code, err))
            }
        }
}

Swift tarafında iosApp/:

swift
import RevenueCat
import shared

final class IosBillingBridgeImpl: NSObject, IosBillingBridge {
    func configure(apiKey: String) {
        Purchases.configure(withAPIKey: apiKey)
    }
    func purchase(vertical: String, planId: String,
                  callback: @escaping (KotlinInt, String?) -> Void) {
        Purchases.shared.purchase(package: pkg) { _, _, error, userCancelled in
            if userCancelled { callback(KotlinInt(int: 1), nil); return }
            if let error = error { callback(KotlinInt(int: 2), error.localizedDescription); return }
            callback(KotlinInt(int: 0), nil)
        }
    }
}

Startup'ta iOS tarafında Swift kodu, bridge instance'ını Kotlin'e enjekte ediyor:

swift
@main
struct iOSApp: App {
    init() {
        KoinInitKt.doInitKoin()
        let billingBridge = IosBillingBridgeImpl()
        IosBillingBridgeKt.installIosBillingBridge(bridge: billingBridge)
        billingBridge.configure(apiKey: "appl_XXXXXXXX")
    }
}

Kritik İpuçları:

  1. Kotlin/Native primitive'leri boxed olarak derlenir (generate). Boolean bir protocol callback'inde KotlinBoolean, Int ise KotlinInt olarak gelir. Swift tarafında Bool/Int32 yazılırsa protocol conformance hatası alınır.
  2. Swift dosyalarının Xcode target'a manuel eklenmesi gerekir. Dosyayı dosya sistemine koymak tek başına yeterli değildir. Bu adım xcodeproj Ruby gem'i ile otomatikleştirilebilir.

Bu pattern bir kez kavrandığında, Swift ↔ Kotlin köprüleme (bridging) işlemleri rutin bir mühendislik adımına dönüşüyor.

Metriklerle Dönüşümün Sonucu

  • ~223 commit — Tüm geçiş sürecinin toplam commit sayısı
  • 173 Kotlin dosyası commonMain'de — İş mantığı, UI ve veri katmanı dahil tüm ortak kod
  • 16 dosya androidMain, 21 dosya iosMain — Yalnızca platforma özgü implementasyonlar
  • 5 dosya app/ — Android giriş noktası (MainActivity, Application, DI init)
  • 3 Swift dosyası iosApp/ — ComposeUI wrapper, RevenueCat bridge ve uygulama başlatma
  • ~%93 kod paylaşımı — Business logic ve UI dahil
  • 40+ ekran Compose Multiplatform ile — Projede tek bir XML layout veya Storyboard yok

Yeni bir özellik eklendiğinde artık "Android'de yaptım, şimdi iOS'ta da yapayım" döngüsü ortadan kalktı. Yazılan kod doğrudan iki mağazada da yayına hazır.

Backend Altyapısı: Firebase Cloud Functions

Backend katmanında Firebase Cloud Functions (Node.js) kullanılıyor. Bu katman platforma bağımlı olmadığından hem Android hem iOS için tek merkezden hizmet veriyor:

  • RevenueCat webhook'ları — Abonelik yaşam döngüsü (INITIAL_PURCHASE, RENEWAL, EXPIRATION, ...)
  • Anonim komşu iletişimi — Plaka → sahip eşlemesi sunucu tarafında çözümleniyor (mahremiyet koruması için)
  • Otomatik araç sayaçları — Güvenlik gereği sayım işlemleri istemci tarafında yapılmıyor

Backend'de yapılan herhangi bir değişiklik her iki platforma da anında yansıyor.

Ne Zaman KMP Tercih Edilmeli?

KMP her projeye uygun olmayabilir. Bu tercihi yaparken değerlendirdiğim kriterleri aşağıda paylaşıyorum:

KMP'nin güçlü olduğu senaryolar:

  • Kotlin ekosisteminde güçlü bir altyapı varsa
  • İki platform arasında UI/UX tutarlılığı önemliyse
  • İş mantığı (business logic) ve durum yönetimi (state management) karmaşıksa
  • Native performans kritikse (React Native/Flutter'ın JS bridge katmanı bir kısıt olabilir)
  • Tek kişilik veya küçük bir ekiple çalışılıyorsa

Dikkatli değerlendirilmesi gereken durumlar:

  • iOS-native görünüm ve his (look & feel) kritik bir gereksinimse (Compose Multiplatform iOS'ta kendi render engine'ini kullanır)
  • Ekipteki iOS geliştiricileri Kotlin/Native bilmiyorsa
  • Platform-özel bağımlılıklar (StoreKit gibi) yoğunsa — her biri için Swift bridge yazmak gerekiyor
  • Compose Multiplatform iOS tarafında bazı animasyon detayları henüz tam olgunluğa ulaşmadı

Bu projede cevap netti: KMP, tek geliştirici olarak iki platforma çıkmanın en pragmatik ve sürdürülebilir yoluydu.

Sonuç

Native Android olarak başlayan ValeCep, bugün iki platformda aynı kod tabanıyla productionda çalışıyor. Yeni bir özellik eklendiğinde tek seferde yazılıyor ve her iki mağazaya da çıkıyor. Bir yıl önce oldukça iddialı görünen bu hedef, KMP ve Compose Multiplatform ekosisteminin olgunlaşmasıyla günlük mühendislik rutininin doğal bir parçası haline geldi.

Uygulamayı denemek için:

Sorularınız ya da önerileriniz için benimle iletişime geçebilirsiniz.