Tüm yazılar Yazılım

Kotlin’i Java Projesine Sokmak

2020’de Java ile yazdığım Android projelerine Kotlin’i sonradan ekledim. Hepsini birden çevirmek yerine dosya dosya ilerledim. Bu yazıda platform tiplerinin neden null güvenliğini deldiğini, Firestore ile data class kullanırken çalışma anında patlayan yeri, extension function’ların Utils sınıflarının yerini nasıl aldığını ve AsyncTask’tan coroutine’e geçerken Firebase çağrılarını nasıl sardığımı anlatıyorum. Kırılan Java çağrıları ve bunları düzelten @Jvm* işaretleri de dahil.

DT
Demir Taşdemir Mobil Uygulama & Web Geliştirici
— dk okuma

2020’de Android tarafına Java ile girdim. O dönem yazdığım her şey Java’ydı: Activity’ler, adapter’lar, Firebase çağrılarını saran yardımcı sınıflar. Kotlin’i ciddi biçimde kullanmaya başladığımda ilk aklıma gelen fikir, projenin tamamını bir hafta sonunda çevirmekti. İyi ki yapmadım. Bu yazı, çalışan bir Java projesine Kotlin’i yavaş yavaş sokarken öğrendiklerim — özellikle de canımı yakan yerler.

Hepsini birden çevirmek neden kötü bir fikir

Android Studio’da “Convert Java File to Kotlin File” diye bir menü var. Bir Activity’yi seçip tıkladım, dosya Kotlin oldu, proje derlendi. Bir an için işin bittiğini sandım.

Sorun şu: dönüştürücü, Java kodunun null hakkında hiçbir garantisi olmadığını bildiği için her şeyi güvenli tarafa çekiyor. Ortaya çıkan dosya !! ve ?. ile dolu. Yani Kotlin yazıyorsun ama hâlâ Java’nın null davranışını taşıyorsun, üstüne bir de okunması zor bir dosyan oluyor. Ben bunu 700 satırlık bir ekranda yaşadım; dönüşümden sonra kod derleniyordu, çalışıyordu, ama tek bir satırı bile Kotlin gibi görünmüyordu.

İkinci sorun daha sinsi: büyük bir dönüşümde bir hata çıktığında, hatanın dönüşümden mi yoksa senin o gün yazdığın özellikten mi geldiğini ayırt edemiyorsun. Bu yüzden kuralı şuraya çektim: yeni yazılan her dosya Kotlin, eski dosyalar ancak zaten dokunmam gereken bir iş varken Kotlin.

Kurulum: iki dil aynı modülde

Kotlin ve Java aynı modülde, hatta aynı klasörde yaşayabiliyor. src/main/java altına .kt dosyası koymak yeterli; ayrı bir kaynak klasörü açmak zorunda değilsin.

plugins {
          id 'com.android.application'
          id 'org.jetbrains.kotlin.android'
      }

Derleme sırası şöyle işliyor: Kotlin derleyicisi önce çalışıyor ve Java kaynaklarını okuyabiliyor, sonra javac devreye girip Kotlin’in ürettiği sınıfları görüyor. Pratikte bunun anlamı, Java sınıfının Kotlin sınıfını, Kotlin sınıfının da Java sınıfını çağırabilmesi. Karşılıklı bağımlılık bile sorun çıkarmıyor.

Tek gerçek maliyet, Kotlin standart kütüphanesinin APK’ya eklenmesi. Sürüm derlemesinde R8 kullanılmayan kısmı budadığı için bu, geçişten vazgeçmeyi gerektirecek bir şey değil — ama debug APK’nın büyüdüğünü görüp şaşırmayın.

Java’dan gelen her şey “platform tipi”

Kotlin’in null güvenliği, tip sisteminde String ile String? ayrımı yapmasına dayanıyor. Ama Java tarafında böyle bir bilgi yok. Bir Java metodu String döndürdüğünde Kotlin bunu platform tipi olarak görüyor ve String! diye gösteriyor: “null olabilir de olmayabilir de, ben karar veremiyorum”.

Platform tiplerinde derleyici seni uyarmaz. Yani Kotlin’e geçmiş olmana rağmen klasik NullPointerException’ı yeniden yiyebilirsin.

// Java tarafı
      public class Kullanici {
          public String getEposta() { return eposta; }   // null dönebilir
      }

      // Kotlin tarafı
      val uzunluk = kullanici.eposta.length   // derlenir, çalışma anında patlayabilir

Çözüm, Java sınıflarını dokunduğun ölçüde işaretlemek. @Nullable ve @NonNull ek açıklamalarını gören Kotlin derleyicisi, tipi artık String? veya String olarak ele alıyor ve kontrolü zorunlu kılıyor. Ben bunu bir seferde değil, hangi Java sınıfını Kotlin’den çağırıyorsam o sınıfı işaretleyerek yaptım.

!! bir çözüm değil, alarmdır

Dönüştürücünün bıraktığı her !!, aslında “burada null gelirse ne olacağına henüz karar vermedim” demek. Ben bunları tek tek gezip ya erken dönüşe ya varsayılan değere ya da lateinit var’a çevirdim.

data class ve Firestore: derlemede değil, çalışma anında patlıyor

Model sınıfları, Kotlin’e geçmek için en cazip yer. Java’da 60 satır olan bir model, data class ile beş satıra iniyor; equals, hashCode, toString ve copy bedava geliyor. Arka ucum her zaman Firebase olduğu için modellerimi Firestore ile toObject() üzerinden dolduruyorum ve tam burada tökezledim.

Firestore’un varsayılan dönüştürücüsü, nesneyi oluşturabilmek için parametresiz bir kurucuya ve alanları yazabilmek için setter’lara ihtiyaç duyuyor. Kotlin’de zorunlu parametreli bir data class yazarsan bu kurucu üretilmiyor; kod derleniyor, test cihazında ilk veri çekişinde patlıyor. Çözüm, bütün alanlara varsayılan değer vermek ve var kullanmak:

data class Ilan(
          var id: String = "",
          var baslik: String = "",
          @get:PropertyName("satis_fiyati")
          @set:PropertyName("satis_fiyati")
          var satisFiyati: Long = 0,
          var kapali: Boolean = false
      )

İkinci ayrıntı da @PropertyName. Java’da alanın üstüne yazıp geçiyordun; Kotlin’de bir özellik hem alan hem getter hem setter üretiyor, dolayısıyla ek açıklamanın kime gittiğini @get: ve @set: ile söylemen gerekiyor. Sadece @PropertyName yazarsan okuma çalışır, yazma yanlış alana gider. Bunu açık artırma akışı olan bir projede fark ettim: kayıt gidiyordu ama geri okunduğunda alan boştu.

Bir uyarı daha: copy() ve varsayılan parametreler Java tarafından rahat kullanılamıyor. Modelin hâlâ Java kodundan oluşturuluyorsa, dönüşümü modelle değil, modeli kullanan ekranla başlatmak daha az acı veriyor.

Extension function: Utils sınıflarının sonu

Her Java projemde bir Utils sınıfı vardı: tarih biçimleme, metin kısaltma, para birimi yazdırma. Kotlin’de bunlar extension function olarak tipin kendisine yapışıyor.

@file:JvmName("MetinYardimcilari")

      package com.ornek.util

      fun String.kisalt(sinir: Int): String =
          if (length <= sinir) this else take(sinir).trimEnd() + "…"

Bunlar aslında derlendiğinde statik metot oluyor, o yüzden Java tarafı da kullanabiliyor: MetinYardimcilari.kisalt(baslik, 40). Dosya adı MetinYardimcilariKt gibi çirkin bir sınıf adına dönüşmesin diye @file:JvmName koyuyorum.

Akılda tutulması gereken şey, extension function’ların statik olarak çözümlenmesi. Yani çağrı, değişkenin çalışma anındaki gerçek tipine değil, derleme anındaki bildirilen tipine bakılarak seçiliyor. Sanal metot gibi davranmıyor; bunu bilmeden alt sınıfa özel davranış beklersen sonuç şaşırtıyor.

Kotlin’i Java’dan çağırmak: kırılan yerler

Geçişin en can sıkıcı kısmı buydu. Kotlin dosyası gayet güzel derleniyor, ama onu çağıran eski Java kodu derlenmiyor. Sebep, Kotlin’in bazı özelliklerinin JVM’de doğrudan karşılığı olmaması.

Java’dan yapmak istediğinKotlin tarafında gereken
Sinif.metot() biçiminde statik çağrıcompanion object içinde @JvmStatic
Varsayılan parametreleri atlamak@JvmOverloads
try/catch ile yakalamak@Throws(IOException::class)
Alanı getter olmadan okumak@JvmField
Üretilen sınıf adını sabitlemek@file:JvmName("...")

@JvmStatic koymazsan Java tarafı Sinif.Companion.metot() yazmak zorunda kalıyor. Kotlin’de object olarak yazdığın tekil nesneye ise Java Sinif.INSTANCE üzerinden ulaşıyor. Checked exception meselesi de gerçek: Kotlin’de checked exception yok, o yüzden @Throws yazmadığında Java derleyicisi “bu metot zaten hata fırlatmıyor” diyerek catch bloğunu reddediyor.

AsyncTask’tan coroutine’e

AsyncTask API 30 ile kullanımdan kaldırıldı, ama asıl derdim o değildi. Derdim, iç içe geçmiş listener’lardı: Firestore’dan belgeyi al, gelince Storage’dan görseli al, o da gelince arayüzü güncelle. Üç seviye indent, her seviyede ayrı hata bloğu.

Coroutine’e geçerken tek seferde her şeyi değiştirmedim. Önce Firebase çağrılarını suspend fonksiyonlarla sardım:

suspend fun ilanGetir(id: String): Ilan =
          suspendCancellableCoroutine { devam ->
              db.collection("ilanlar").document(id).get()
                  .addOnSuccessListener { snap ->
                      val ilan = snap.toObject(Ilan::class.java)
                      if (ilan == null) devam.resumeWithException(NoSuchElementException(id))
                      else devam.resume(ilan)
                  }
                  .addOnFailureListener { hata -> devam.resumeWithException(hata) }
          }

Ekran tarafında da yaşam döngüsüne bağlı bir kapsam tutuyorum ve onDestroy içinde iptal ediyorum:

private val kapsam = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

      override fun onDestroy() {
          kapsam.cancel()
          super.onDestroy()
      }

Bundan sonra ardışık iki çağrı, iki satır oluyor ve tek bir try/catch ikisini birden kapsıyor. Ağır işi withContext(Dispatchers.IO) içine alıp arayüz güncellemesini ana thread’de bırakmak da bu yapıda çok daha okunur.

İptal, isteği durdurmaz

Kapsamı iptal etmek coroutine’i durdurur ama arka planda başlamış ağ isteğini geri almaz. Yani ekran kapandığında sonuç artık kullanılmaz, fakat istek yine de tamamlanır. Bunu bilerek kullanmak, “iptal ettim ama sayaç hâlâ dönüyor” şaşkınlığından kurtarıyor.

Kademeli geçişte kendime koyduğum kurallar

  • Yeni dosya her zaman Kotlin. Eski dosya ancak zaten değiştirmem gereken bir iş varsa Kotlin.
  • Dönüştürücüyü kullanıyorsam, çıktısını olduğu gibi bırakmıyorum; her !! için bir karar veriyorum.
  • Bir dosyayı çevirdiğim commit’te başka hiçbir şey değiştirmiyorum. Böylece bir hata çıkarsa nereye bakacağım belli oluyor.
  • Firestore ile konuşan model sınıflarını en sona bırakıyorum, çünkü hataları derlemede değil çalışma anında görünüyor.
  • Kotlin’den çağrılacak Java sınıflarını nullability ek açıklamalarıyla işaretliyorum; bunu yapmadan “Kotlin sayesinde null sorunu bitti” demek kendini kandırmak oluyor.
Nereden başlamalı

En iyi ilk aday, dışa bağımlılığı az ve girdi–çıktısı net olan yardımcı sınıflar. Bunları Kotlin’e çevirmek Java tarafını kırmıyor, sonucu test etmesi kolay ve dilin nasıl davrandığını risk almadan görüyorsun. Activity’lerle başlamak ise en zor yoldan girmek demek.

Bugün Android tarafında yeni ne yazsam Kotlin ile yazıyorum, ama eski projelerin içinde hâlâ Java dosyaları var ve bu beni rahatsız etmiyor. İkisi aynı modülde sorunsuz çalışıyor. Geçişin değeri, dosya sayısının azalmasında değil; dokunduğun her yerde kodun biraz daha okunur hâle gelmesinde.

  • Kotlin
  • Java
  • Android
  • Firebase
  • Coroutine
  • Interop
Paylaş: LinkedIn X WhatsApp
DT

Demir Taşdemir

Mobil Uygulama & Web Geliştirici

2018'den beri yazılım geliştiriyorum. App Store ve Google Play'de 11 uygulama yayınladım; şu an 6 mobil uygulama, 1 e-ticaret platformu ve 1 masaüstü oyun üzerinde çalışıyorum.

Mevcut projenize Kotlin eklemek ister misiniz?

Java ile yazılmış bir Android uygulamanız var ve yeni özellikleri Kotlin ile yazmak istiyorsanız, kademeli geçiş planını birlikte çıkarabiliriz. Benimle iletişime geçin.