Ana içeriğe geç
  1. 100 Günde SwiftUI Notları/

40.Gün - SwiftUI: Generics ve Codable

Bugünkü yazımızda Codable’ı daha detaylı inceleyeceğiz. Ayrıca yüksek oranda yeniden kullanılabilir kod oluşturmamızı sağlayan Generics’leri inceleyeceğiz.

Belli bir Tür Codable Data’nın Yüklenmesi #

Bu uygulamada Swift struct’lara iki farklı türde JSON yükleyeceğiz. Biri astronotlar için, diğeri de görevler için. Bunu bakımı kolay ve kodumuzu karmaşık hale getirmeyecek bir şekilde gerçekleştirmek biraz düşünmeyi gerektiriyor, ancak adım adım ilerleyeceğiz.

Öncelikli olarak burada bulunan astronauts.json ve missions.json dosyalarını project navigator’a sürükleyin. Ayrıca aynı linkte bulunan Images klasöründeki görselleri de Xcode projenizin asset klasörüne sürükleyin.

astronauts.json dosyasına bakarsanız, her astronotun üç alanla tanımlandığını görürsüz: id,name ve description

Şimdi bu astronot verilerini bir Swift struct’a dönüştürelim. Yeni bir dosya oluşturmak için Cmd+N tuşlarına basın, Swift files’ı seçin ve Astronaut.swift adını verin. Yeni oluşturduğunuz dosyaya ekleyin;

struct Astronaut: Codable, Identifiable {
    let id: String
    let name: String
    let description: String
}

Gördüğünüz gibi, bu struct’ın instance’lerini doğrudan JSON’dan oluşturabilmemiz için bunu Codable’a uygun hale getirdik, aynı zamanda ForEach ve daha fazlası için astronot array’lerini kullanabilmemiz için Identifiable’a da uygun hale getirdik.

Daha sonra astronauts.json dosyasını Astronaut instance’larından oluşan bir dictionary’ye dönüştürmek istiyoruz. Bu da dosyanın yolunu bulmak, bunu bir Data instance’a yüklemek ve bir JSONDecoder’dan geçirmek için Bundle kullanmamız gerektiği anlamına geliyor. Daha önce bunu ContentView üzerinde bir methoda koymuştuk, ancak burada size daha iyi bir yol göstermek istiyorum: hepsini tek bir merkezi yerde yapmak için Bundle üzerinde bir extension yazacağız.

Bu sefer Bundle-Decodable.swift adında yeni bir Swift dosyası daha oluşturun. Bu çoğunlukla daha önce gördüğünüz kodu kullanacak, ancak küçük bir fark var: daha önce bir string’e yüklemek için String(contentOf:) kullandık, ancak Codable Data kullandığı için bunun yerine Data(contentsOf:) kullanacağız. String(contentsOf:) ile aynı şekilde çalışır: yüklemek için bir dosya URL’si verin ve ya içeriğini döndürür ya da bir hata fırlatır.

extension Bundle {
    func decode(_ file: String) -> [String: Astronaut] {
        guard let url = self.url(forResource: file, withExtension: nil) else {
            fatalError("Failed to locate \(file) in bundle.")
        }

        guard let data = try? Data(contentsOf: url) else {
            fatalError("Failed to load \(file) from bundle.")
        }

        let decoder = JSONDecoder()

        guard let loaded = try? decoder.decode([String: Astronaut].self, from: data) else {
            fatalError("Failed to decode \(file) from bundle.")
        }

        return loaded
    }
}

Buna birazdan geri döneceğiz, ancak gördüğünüz gibi fatalError() methodunu bolca kullanıyoruz: dosya bulunamaz, yüklenemez veya decode edilemezse uygulama çökecektir.

Bu property’yi ContentView struct’a ekleyelim;

let astronauts = Bundle.main.decode("astronauts.json")

Evet, hepsi bu kadar. Elbette tek yaptığımız kodu ContentView’den çıkarıp bir extension’a taşımak oldu, bu sayede view’larımız küçük ve odaklanmış kalıyor.

JSON’umuzun doğru yüklendiğini iki kez kontrol etmek istiyorsanız, varsayılan body property’sini bu şekilde değiştirin;

Text(String(astronauts.count))

Ekranda 32 yazısını görüyor olmalısınız.

Bitirmeden önce extension’a geri dönmek ve biraz daha yakından bakmak istiyorum. Elimizdeki kod bu uygulama için gayet iyi, ancak ileride kullanmak istersek sorunları teşhis etmemize yardımcı olacak bazı ekstra kodlar ekleylim;

let decoder = JSONDecoder()

do {
    return try decoder.decode([String: Astronaut].self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
    fatalError("Failed to decode \(file) from bundle due to missing key '\(key.stringValue)' – \(context.debugDescription)")
} catch DecodingError.typeMismatch(_, let context) {
    fatalError("Failed to decode \(file) from bundle due to type mismatch – \(context.debugDescription)")
} catch DecodingError.valueNotFound(let type, let context) {
    fatalError("Failed to decode \(file) from bundle due to missing \(type) value – \(context.debugDescription)")
} catch DecodingError.dataCorrupted(_) {
    fatalError("Failed to decode \(file) from bundle because it appears to be invalid JSON.")
} catch {
    fatalError("Failed to decode \(file) from bundle: \(error.localizedDescription)")
}

Bu büyük bir değişiklik değil, ancak methodun artık decode etmede neyin yanlış gittiğini size söyleceği anlamına geliyor.

Her Tür Codable Data için Generics Kullanımı #

Uygulama Bundle’ından belirli bir JSON veri türünü yüklemek için bir Bundle extension ekledik, ancak şimdi ikinci bir türümüz var: missions.json Bu biraz daha karmaşık JSON içerir:

  • Her görevin bir ID numarası vardır, bu da Identifiable’ı kolayca kullanabileceğimiz anlamına gelir.
  • Her görevin Wikipedia’dan string düz metin bir açıklaması (description) vardır.
  • Her görevin, mürettebat(crew) üyelerinin adı(name) ve rolü(role) olan bir array’i vardır.
  • Görevlerin biri hariç hepsinin fırlatma tarihi bellidir. Ne yazık ki Apollo 1 hiçbir zaman fırlatılamadı çünkü fırlatma provasında kabinde yangın çıktı ve tüm mürettebat vefat etti.

Bunu koda dönüştürmeye başlayalım. Mürettebat rollerinin, name array ve role array depolayan kendi struct’ları olarak temsil edilmesi gerekir. Bu yüzden, Mission.swift adında yeni bir Swift dosyası oluşturun ve bu kodu yazın.

struct CrewRole: Codable {
    let name: String
    let role: String
}

missions, bir Int ID, bir CrewRole array ve bir description array olacaktır. Peki ya launch date? değer olabilir de olmayabilir de?

Peki, bir düşünün: Swift bu “belki, belki değil”’i başka bir yerde nasıl temsil eder? “Bir string olabilir, hiçbir şey olmayabilir” ifadesini nasıl saklayacağız? Cevap açıktır optional’leri kullanırız. Aslında bir property’yi optional olarak işaretlersek Codable, JSON girdimizde değer eksikse otomatik olarak üzerinden atlayacaktır.

Şimdi bu ikinci struct’ı Mission.swift’e ekleyelim;

struct Mission: Codable, Identifiable {
    let id: Int
    let launchDate: String?
    let crew: [CrewRole]
    let description: String
}

JSON’u buna nasıl yükleyeceğimize bakmadan önce bir şeyi daha göstermek istiyorum: CrewRole struct’ı özellikle mission ilgili verileri tutmak için yapılmıştır ve sonuç olarak CrewRole struct’ını Mission struct içine şu şekilde yerleştirebiliriz;

struct Mission: Codable, Identifiable {
    struct CrewRole: Codable {
        let name: String
        let role: String
    }

    let id: Int
    let launchDate: String?
    let crew: [CrewRole]
    let description: String
}

Buna nested (iç içe) struct denir ve basitçe bir struct’ın diğerinin içine yerleştirilmesidir. Bu, projedeki kodu etkilemeyecektir, ancak başka yerlerde kodumuzu düzenli tutmamıza yardımcı olmak için kullanışlıdır. CrewRole demek yerine Mission.CrewRole yazabiliriz.

Şimdi missions.json dosyasını bir Mission struct array’e nasıl yükleyebileceğimizi düşünelim. Bir JSON dosyasını Astronaut struct dictionary’ye yükleyen bir Bundle extension zaten yazmıştık, bu yüzden bunu kolayca kopyalayıp yapıştırabilir, ardından astronotlar yerine görevleri yükleyecek şekilde değiştirebiliriz. Ancak daha iyi bir çözüm var: Swift’in Generics sistemini kullanabiliriz.

Generics, farklı türlerle çalışabilen kod yazmamızı sağlar. Bu projede, Bundle extension’ı Astronaut dictionary’lerle çalışmak için yazdık, ancak aslında Astronaut dictionary’lerini, Mission array’leri veya potansiyel olarak birçok başka şeyi işleyebilmek istiyoruz.

Bir methodu Generics hale getirmek için, ona belirli türler için bir placeholder (yer tutucu) veririz. Placeholder, method adından sonra, parametrelerden önce açılı parantezler ( < ve > ) arasına yazılır.

func decode<T>(_ file: String) -> [String: Astronaut] {

Bu placeholder için herhangi bir şey kullanabiliriz. “Type,” “TypeOfAnything” veya “Fish” yazabilirdik, fark etmez. “T” “Type” ı sembolize etmek için geleneksel bir kullanımı vardır.

Methodun içinde artık [String: Astronaut] kullanacağımız her yerde “T” kullanabiliriz. Bu kelimenin tam anlamıyla çalışmak istediğimiz tür için bir placeholder’dır. Yani [String: Astronaout]döndürmek yerine bunu kullanacağız;

func decode<T>(_ file: String) -> T {

Dikkatli Olun: T ile [T] arasında büyük bir fark vardır. Unutmayın, T istediğimiz tür için bir placeholder’dır, bu nedenle “Astronaut dictionary’nin kodunu çöz” dersek, T [String: Astronaut] olur. decode() methodundan [T] döndürmeye çalışırsak, aslında [[String: Astronaut]] döndürmüş oluruz, ki bu da yanlış olur.

decode() methodunun ortalarına doğru [String: Astronaut] ‘un kullanıldığı başka bir yer daha vardır;

return try decoder.decode([String: Astronaut].self, from: data)

Bunu T olarak değiştirin, bunun gibi;

return try decoder.decode(T.self, from: data)

Yani, decode() methodunun [String: Astronaut] gibi bir türle kullanılacağını ve yüklediği dosyanın bu türde olduğunu çözmeye çalışması gerektiğini söylemiştik.

Bu kodu derlemeye çalışırsanız, Xcode’da bir hata görürsünüz: “Instance method ‘decode(_:from:)’ requires that ‘T’ conform to ‘Decodable’” Bunun anlamı, T’nin herhangi bir şey olabileceğidir: Astronaut dictionary veya tamamen başka bir şeyin dictionary’si olabilir. Sorun şu ki Swift, üzerinde çalıştığımız türün Codable protokolüne uygun olduğundan emin olamıyor, bu yüzden risk almak yerine kodumuzu oluşturmayı reddediyor.

Neyse ki bunu çözebiliriz; Swift’e T’nin Codable’a uygun olduğu sürece istediğimiz her şey olabileceğini söyleyebiliriz. Bu şekilde Swift bunun kullanımının güvenli olduğunu bilir ve methodu Codable’a uymayan bir türle kullanmaya çalışmadığımızdan emin olur.

Methodun imzasını şu şekilde değiştirelim;

func decode<T: Codable>(_ file: String) -> T {

Tekrar derlemeyi denerseniz, işlerin hala çalışmadığını göreceksiniz, ancak şimdi farklı bir nedenden dolayı. ContentView’in astronauts property’sinde “Generic parameter ‘T’ could not be inferred” hatasını göreceksiniz. Bu satır daha önce iyi çalışıyordu, ancak şimdi önemli bir değişiklik oldu: decode() her zaman bir astronaut dictionary döndürürdü, ancak şimdi Codable’a uygun olduğu sürece istediğimiz her şeyi döndürüyor.

Temel veri değişmediği için hala astronaut dictionary döndüreceğini biliyoruz, ancak Swift bunu bilmiyor. Sorunumuz decode() methodunun Codable’a uygun herhangi bir tip döndürebilmesi, ancak Swift’in daha fazla bilgiye ihtiyacı olması- tam olarak hangi tip olacağını bilmek istiyor.

Bu nedenle, bunu düzletmek için type annotation kullanmamız gerekir, böylece Swift astronauts’un tam olarak ne olduğunu bilir.

let astronauts: [String: Astronaut] = Bundle.main.decode("astronauts.json")

Sonunda artık mission.json’ı ContentView ’daki başka bir property’ye yükleyebiliriz. Aşağıdaki kodu astronauts’ın altına ekleyin;

let missions: [Mission] = Bundle.main.decode("missions.json")

İşte Generics’in gücü burada. Bundle’ımızdaki herhangi bir JSON’u Codable’a uyan herhangi bir Swift türüne yüklemek için aynı decode() methodunu kullanabiliriz. Yani aynı methodun yarım düzine varyantına ihtiyacımız yok.

Daha önce “Instance method ‘decode(_:from:)’ requires that ‘T’ conform to ‘Decodable’” mesajını gördünüz ve Decodable’ın ne olduğunu merak etmiş olabilirsiniz, sonuçta her yerde Codable kullanıyoruz. Perde arkasında, Codable sadece iki ayrı protokol için bir takma addır: Encodable ve Decodable. İsterseniz Codable’ı kullanabilirsiniz ya da spesifik olmayı tercih ediyorsanız Encodable ve Decodable’ı kullanabilirsiniz, bu size kalmış.

Mission View’ı Biçimlendirme #

Artık tüm verilerimiz hazır olduğuna göre, ilk ekranımızın tasarımına bakabiliriz; mission rozetlerinin yanında tüm mission’ların bir grid’i

Daha önce eklediğimiz assetler [email protected] ve benzeri adlara sahip resimler içeriyor, bu da asset katoloğunda “apollo1” , “apollo12” vb. adlarla erişilebilir oldukları anlamına geliyor. Mission struct’ta sayı kısmını sağyalan bir id integer vardır, bu nedenle resim adımızı almak için “apollo\(mission.id)” ve görevin biçimlendirilmiş, görünen adını almak için “Apollo \(mission.id)” gibi string interpolation kullanabiliriz.

Ancak burada farklı bir yaklaşım izleyeceğiz: Aynı verileri geri göndermek için Mission struct’a bazı computed property’ler ekleyeceğiz. Sonuç aynı olacak; “apollo1” ve “Apollo 1”

Lütfen bu iki property’yi Mission struct’a ekleyin;

var displayName: String {
    "Apollo \(id)"
}

var image: String {
    "apollo\(id)"
}

Artık ContentView’ı doldurmak için ilk adımı atabiliriz: başlıklı bir NavigationStack, missions array’i girdi olarak kullanan bir LazyVGrid ve içindeki her satırda mission’ın resmini, adını ve fırlatma tarihini içeren bir NavigationLink olacaktır. Buradaki tek küçük karmaşıklık, fırlatma tarihimizin optional bir string olmasıdır, bu nedenle text view’ın görüntüleyeceği bir değer olduğundan emin olmak için nil coalescing kullanmamız gerekir.

İlk olarak, adaptive bir sütun layout tanımlamak için bu property’yi ContentView’e ekleyelim;

let columns = [
    GridItem(.adaptive(minimum: 150))
]

Mevcut body property’sini bununla değiştirin;

NavigationStack {
    ScrollView {
        LazyVGrid(columns: columns) {
            ForEach(missions) { mission in
                NavigationLink {
                    Text("Detail view")
                } label: {
                    VStack {
                        Image(mission.image)
                            .resizable()
                            .scaledToFit()
                            .frame(width: 100, height: 100)

                        VStack {
                            Text(mission.displayName)
                                .font(.headline)
                            Text(mission.launchDate ?? "N/A")
                                .font(.caption)
                        }
                        .frame(maxWidth: .infinity)
                    }
                }
            }
        }
    }
    .navigationTitle("Moonshot")
}

SwiftUI Moonshot ContentView

Oldukça çirkin gözüküyor, ama birazdan düzelteceğiz. İlk olarak, şu ana kadar sahip olduklarımıza odaklanalım; resizable() , scaledToFit() ve frame() kullanarak görüntünün 100x100’lük bir alanı kaplamısını sağlayan ve aynı zamanda orijinal en boy oranını koruyan scroll edilebilen dikey bir grid.

Uygulamayı çalıştırdığınızda tarihlerin çok iyi biçimlendirilmediğini fark edebilirsiniz. Bundan daha iyisini yapabiliriz.

Swift’in JSONDecoder türünün dateDecodingStrategy adında, tarihleri nasıl decode edeceğini belirleyen bir property’si vardır. Bunu, tarihlerimizin nasıl biçimlendirileceğini açıklayan bir DateFormatter instance ile sağlayabiliriz. Bu örnekte tarihlerimiz yıl-ay-gün olarak yazılır ve DateFormat ’a göre “y-MM-dd” olarak yazılır.

Uyarı : Tarih biçimleri büyük/küçük harf duyarlıdır;

  • mm sıfır dolgulu dakika
  • MM sıfır dolgulu ay anlamına gelir.

Bundle-Decodable.swift dosyasını açın ve let decoder = JSONDecoder() ifadesinden hemen sonra bu kodu ekleyin:

let formatter = DateFormatter()
formatter.dateFormat = "y-MM-dd"
decoder.dateDecodingStrategy = .formatted(formatter)

İpucu: Tarihlerle çalışırken genellikle saat diliminiz hakkında spesifik olmak iyi fikirdir, aksi takdirde tarih ve saat ayrıştırılırken kullanıcının kendi saat dilimi kullanılır. Ancak, biz de tarihi kullanıcının saat diliminde görüntüleyeceğiz, bu nedenle burada bir sorun yok.

Kodu şimdi çalıştırırsanız her şey tamamen aynı görünecektir. Evet, hiçbir şey değişmedi, ama sorun değil. Swift launchDate’in bir tarih olduğunu fark etmediği için hiçbir şey değişmedi. Sonuçta onu bu şekilde bildirdik;

let launchDate: String?

Artık decode kodumuz tarihlerimizin nasıl biçimlendirildiğini anladığına göre, bu özelliği optional bir Date olarak değiştirebiliriz.

let launchDate: Date?

Kodumuz derlenmiyor çünkü problem ContentView.swift’teki bu kod satırı;

Text(mission.launchDate ?? "N/A")

Bu, bir text view içinde optional bir Date kullanmaya veya tarih boşsa “N/A” ile değiştirmeye çalışır. Burada computed property kullanabiliriz. Mission’un kendisinden, optional bir tarihi düzgün bir şekilde biçimlendirilmiş bir string’e dönüştüren veya eksik tarihler için “N/A” geri gönderen biçimlendirilmiş bir tarih sağlamasını isteyebiliriz.

Bu daha önce kullandığımız formatted() methodunu kullanır, bu nedenle bu sizin için biraz tanıdık olmalıdır. Bu computed property’yi Mission’a ekleyin;

var formattedLaunchDate: String {
    launchDate?.formatted(date: .abbreviated, time: .omitted) ?? "N/A"
}

Ve şimdi ContentView’deki bozuk text view’ı bununla değiştirin;

Text(mission.formattedLaunchDate)

Bu değişiklikle birlikte tarihlerimiz çok daha doğal bir şekilde işlenecek ve daha da iyisi, kullanıcının bölgeye uygun olacak şekilde işlenecek.

Şimdi daha büyük bir soruna odaklanalım: layout oldukça sıkıcı.

İki kullanışlı özelliği tanıtalım; özel uygulama renkleri ve uygulamayı dark mode temasına zorlama.

İlk olarak renkler. Bunu yapmanın iki yolu vardır ve her ikisi de kullanışlıdır. Renkleri asset kataloğuna belirli adlarla ekleyebilir veya Swift extension olarak ekleyebiliriz. Her ikisininde avantajları vardır. Asset kataloğunu kullanmak görsel olarak çalışmamızı sağlar, ancak kod olarak kullanmak Github gibi bir şey kullanarak değişiklikleri izlemeyi kolaylaştırır.

Ben kod yaklaşımını tercih ediyorum, çünkü ekip halinde çalışırken değişiklikleri takip etmeyi kolaylaştırıyor, bu yüzden renk adlarımızı Swift’e extension olarak ekleyeceğiz.

Bu extension’ları Color üzerinde yaparsak SwiftUI’de birkaç yerde kullanabiliriz, ancak Swift bize sadece biraz daha fazla kodla iyi bir seçenek sunar. Color, ShapeStyle adı verilen ve renkleri, gradinet’i material’i ve daha fazlasını aynı şeymiş gibi kullanmamızı sağlayan daha büyük bir protokole uyuyor.

ShapeStyle protokolü background() modifier’ının kullandığı şeydir, bu yüzden gerçekten yapmak istediğimiz şey Color’ı genişletmek ama bunu ShapeStyle kullanan tüm SwiftUI modifier’larının da çalışacağı şekilde yapmaktır.

Bunu denemek için Color-Theme.swift adında yeni bir Swift dosyası oluşturun ve bu kodu yazın;

import SwiftUI

extension ShapeStyle where Self == Color {
    static var darkBackground: Color {
        Color(red: 0.1, green: 0.1, blue: 0.2)
    }

    static var lightBackground: Color {
        Color(red: 0.2, green: 0.2, blue: 0.3)
    }
}

Bu, darkBackground ve lightBackground adlı iki yeni renk ekler. Ancak daha da önemlisi, bunları SwiftUI’nin bir ShapeStyle beklediği her yerde bu renkleri kullanmamızı sağlayan çok özel bir extension’ın içine yerleştiriyoruz.

Bu yeni renkleri uygulamada kullanalım. İlk olarak, mission adını ve fırlatma tarihini içeren VStack’i bulun ve modifier sırasını şu şekilde değiştirin.

.padding(.vertical)
.frame(maxWidth: .infinity)
.background(.lightBackground)

Daha sonra, NavigationLink’teki VStack’in grid’de daha çok bir kutu gibi görünmesini istiyoruz, bu etrafına bir çizgi çizmek ve şekli biraz kırpmak anlamına geliyor. Bunu yapmak için bu modifier’ları sona ekleyelim;

.clipShape(.rect(cornerRadius: 10))
.overlay(
    RoundedRectangle(cornerRadius: 10)
        .stroke(.lightBackground)
)

Üçüncü olarak, resimleri kenarlardan biraz uzaklaştırmak için padding eklememiz gerekiyor. Mission resimlerinin 100x100 çerçevelerinden hemen sonra padding ekleyerek bunu halledebiliriz.

.padding()

Ardından grid’e biraz yatay ve alt padding ekleyelim;

.padding([.horizontal, .bottom])

Önemli : Bu, ScrollView’e değil LazyVGrid’e eklemelidir. ScrollView’ e padding eklersek, kaydırma çubuklarına da eklemiş oluruz ve bu da garip görünür.

Şimdi beyaz arka planı daha önce oluşturduğumuz özel arka plan rengiyle değiştirebiliriz, bu modifier’ı navigationTitle() modifier’dan sonra ScrollView’e ekleyelim;

.background(.darkBackground)

Bu noktada custom layout neredeyse tamamlandı. Fakat bazı renkleri daha değiştirmeliyiz. Örneğin; mission adları mavi renk ve “Moonshot” başlığı siyah, bunlar oldukça uyumsuz gözüküyor.

Mission adları ve tarihlerindeki problemi aşağıdaki şekilde çözebiliriz;

VStack {
    Text(mission.displayName)
        .font(.headline)
        .foregroundStyle(.white)
    Text(mission.formattedLaunchDate)
        .font(.caption)
        .foregroundStyle(.white.opacity(0.5))
}

“Moonshot” başlığına gelince, bu NavigationStack’a aittir ve kullanıcının light mode veya dark mode’da mı olduğuna bağlı olarak siyah ya da beyaz görünecektir. Bunu düzeltmek için SwiftUI’ye view’ın her zaman karanlık modda olmayı tercih ettiğini söyleyebiliriz.

Bu view’ın tasarımını tamamamak için son modifier’ı ScrollView’a background() ‘dan sonraya ekleyelim;

.preferredColorScheme(.dark)

Uygulamayı çalıştırdığınızda, çok çeşitli cihaz boyutlarında sorunsuz bir şekilde uyum sağlayacak güzel bir şekilde kaydırma işlemi yapabildiğimiz bir grid’e sahip olacağız.

Final Design for iPhone
Final Design for iPad


Bu yazıyı İngilizce olarak da okuyabilirsiniz.
You can also read this article in English.

Bu yazı, SwiftUI Day 40 adresinde bulunan yazılardan kendim için aldığım notları içermektedir. Orjinal dersi takip etmek için lütfen bağlantıya tıklayın.