BookingFlowContext gibi global state object'leri neden tehlikelidir?

Çok adımlı bir akışta her ekranın okuyup yazdığı otuz optional'lı context çantası, ilk gün kolaylık, üçüncü yıl crash kaynağıdır. Neyi kırdığını, observable yapmanın neden yetmediğini ve yerine koyduğumuz tipli girdi, çıktı ve state machine modelini Swift ve Kotlin koduyla anlatıyoruz.

Çok adımlı akışı olan hemen her büyük uygulamada adı Context, Session, Manager ya da DataHolder ile biten bir sınıf vardır. Seyahat uygulamasında BookingFlowContext, perakendede CheckoutSession, bankacılıkta TransferData olur. İçinde otuz civarı optional property bulunur, akıştaki her ekran onu okur, çoğu da yazar. Kimse onu tasarlamamıştır, zamanla birikmiştir.

Bu yazıda hayali bir seyahat rezervasyon uygulaması üzerinden gidiyoruz: arama, uçuş seçimi, yolcu bilgileri, ek hizmetler, ödeme. Çantanın nasıl doğduğunu, tam olarak neyi kırdığını ve onu feature geliştirmeyi durdurmadan nasıl söktüğümüzü anlatacağız.

Çanta bir kolaylık olarak doğar

İlk sürümde arama ekranı sonuç ekranına üç parametre geçirir. Sonra ödeme ekranının yolcu sayısına ihtiyacı olur ve arada iki ekran vardır. Parametreyi iki ekran boyunca elden ele taşımak yerine biri "şunu ortak bir yere koyalım" der. O ortak yer bir kez açıldıktan sonra her yeni ihtiyaç aynı adrese gider, çünkü en ucuz yol odur. İki yıl sonra elinizde şu vardır:

final class BookingFlowContext: ObservableObject {
    static let shared = BookingFlowContext()

    @Published var origin: Airport?
    @Published var destination: Airport?
    var departureDate: Date?
    var returnDate: Date?
    var isRoundTrip = false
    var passengerCount: Int?
    var searchResults: [Offer]?
    @Published var selectedOffer: Offer?
    var selectedReturnOffer: Offer?
    var passengers: [Passenger]?
    var contactEmail: String?
    var selectedSeats: [String: String]?   // yolcu id -> koltuk
    var baggage: [BaggageOption]?
    var insuranceSelected: Bool?
    var promoCode: String?
    @Published var totalPrice: Decimal?
    var currency: String?
    var paymentToken: String?
    var isFromDeepLink = false
    var isEditingFromSummary = false
    var shouldSkipExtras: Bool?
    // ... on kadar alan daha

    func reset() { /* her alanı tek tek nil yapar; yeni eklenen alan genelde unutulur */ }
}

Android tarafında aynı sınıf çoğunlukla DI container'ına @Singleton olarak kaydedilmiştir, bu da onu daha saygın gösterir ama doğasını değiştirmez:

@Singleton
class BookingFlowContext @Inject constructor() {
    var origin: Airport? = null
    var destination: Airport? = null
    var departureDate: LocalDate? = null
    var passengerCount: Int? = null
    var selectedOffer: Offer? = null
    var passengers: List<Passenger>? = null
    var selectedSeats: MutableMap<String, String>? = null
    var promoCode: String? = null
    var totalPrice: BigDecimal? = null
    var paymentToken: String? = null
    var isFromDeepLink: Boolean = false
    var isEditingFromSummary: Boolean = false
    // ...

    fun clear() { /* aynı hikâye */ }
}

class PaymentViewModel @Inject constructor(
    private val context: BookingFlowContext,
    private val payments: PaymentRepository,
) : ViewModel() {
    fun pay() = viewModelScope.launch {
        payments.pay(
            offerId = context.selectedOffer!!.id,
            passengers = context.passengers!!,
            amount = context.totalPrice!!,
        )
    }
}

Bu, constructor injection kılığına girmiş bir global değişkendir. Singleton'lar üzerine yazdığımız her şey burada da geçerli, üstüne bir de akışa özgü sorunlar biniyor.

Tam olarak ne kırılır

Optional patlaması ve force unwrap

Çantadaki her alan optional'dır, çünkü akışın başında hiçbiri yoktur. Ödeme ekranı açısından ise selectedOffer kesinlikle vardır; yoksa o ekrana gelinemezdi. Tip sistemi bu bilgiyi taşımadığı için geliştirici onu ! ya da !! ile kendisi beyan eder. Yukarıdaki PaymentViewModel üç ayrı yerde "buraya ancak şu ekranlardan geçilerek gelinir" varsayımı yapıyor ve bu varsayım hiçbir yerde yazılı değil.

Savunmacı olan ekip guard let ile sessizce çıkar. Sonuç daha kötüdür: ödeme butonu hiçbir şey yapmaz, crash raporu da gelmez.

Zamansal bağımlılık

C ekranı B'nin çalışmış olduğunu varsayar. Bu varsayım yalnızca "mutlu yol" navigasyonunda doğrudur. Ürün ekibi "kayıtlı yolcusu olan kullanıcı yolcu ekranını atlasın" dediği gün, contactEmail alanını yolcu ekranının yazdığını ve ödeme ekranının okuduğunu production'da öğrenirsiniz. Derleyici bu sırayı bilmez, çünkü sıra tiplerde yoktur; yalnızca ekranların açılış sırasındadır.

Alanı kimin yazdığını bilememek

totalPrice için "Find Usages" yaptığınızda on dört yazma noktası çıkıyorsa, yanlış fiyat gösteren bir bug'ı ayıklamak on dört breakpoint demektir. Çantadaki bir alanın sahibi yoktur. Ek hizmetler ekranı fiyatı günceller, promosyon kodu servisi günceller, arka planda çalışan fiyat yenileme işi de günceller. En son yazan kazanır.

Geri navigasyon ve değişen seçim sonrası bayat veri

Kullanıcı ek hizmetler ekranında 14A koltuğunu seçti, geri gidip başka bir uçuş seçti. Çantada selectedOffer yeni uçuşu, selectedSeats eski uçağın koltuk haritasını gösteriyor. Bunu engellemenin tek yolu, her seçim değişikliğinde hangi alanların geçersizleştiğini elle bilmek ve temizlemektir. reset() metodunun içindeki "şunu da nil yapmayı unutmuşuz" commit'leri bu yüzden hiç bitmez.

"Ödemenizi tamamlayın" bildirimi kullanıcıyı doğrudan ödeme ekranına götürmek ister. Çanta boştur. Çözüm olarak genellikle deep link işleyicisi çantayı elle doldurur: önceki dört ekranın yan etkilerini taklit eden, o ekranlar değiştikçe sessizce bayatlayan bir fonksiyon. isFromDeepLink bayrağı da bu noktada çantaya girer ve ekranlar ona göre dallanmaya başlar.

İki yazar

Fiyat yenileme isteği arka planda dönerken kullanıcı bagaj ekler. İki yazma işlemi aynı alana farklı thread'lerden gelir. Swift 6 dil modunda, Sendable olmayan bir sınıfın static let shared örneği derleyiciye takılır ve ekipler genellikle @unchecked Sendable ya da nonisolated(unsafe) ile uyarıyı susturur; sorun yerinde durur. Kotlin'de düz var alanlara Dispatchers.IO üzerinden yazmak derlenir, çalışır ve arada bir yanlış toplam gösterir.

Android'de process death

Kullanıcı ödeme ekranındayken banka uygulamasına geçip SMS kodunu bekler. Sistem bellek için process'inizi öldürür. Kullanıcı döndüğünde Navigation back stack'i saved state'ten geri yükler ve ödeme ekranını açar. @Singleton çanta ise yeni process'te boş olarak yeniden yaratılmıştır. context.selectedOffer!! satırı NullPointerException fırlatır. Bunu kendi projenizde denemek bir dakika sürer: akışın ortasına gelin, uygulamayı arka plana alın ve şunu çalıştırın:

adb shell am kill com.example.travel

Sonra uygulamayı son uygulamalar listesinden açın. Çantaya dayanan akışların çoğu bu testi geçemez.

Testler bütün çantayı ister

Ödeme ekranını test etmek için gerçekte bir teklif ve bir tutar yeter. Çantayla çalışan bir view model için ise test, hangi on bir alanın okunduğunu kaynak koddan çıkarıp hepsini doldurmak zorundadır. Bir alan eksik kalırsa test crash eder ya da daha kötüsü, guard let yüzünden hiçbir şey doğrulamadan geçer.

"Observable yaparız" neden çözmez

Çantayla ilgili ilk şikâyetler geldiğinde önerilen ilk yama onu reaktif yapmaktır: @Published, @Observable ya da StateFlow<BookingContext>. Böylece ekranlar değişiklikten haberdar olur. Haberdar olmaları sorunların hiçbirini çözmez. Alanlar hâlâ optional'dır, hangi alanın hangi adımda var olması gerektiği hâlâ yazılı değildir, yazan hâlâ belirsizdir ve process death hâlâ her şeyi siler.

Üstüne yeni sorunlar eklenir. Birlikte değişmesi gereken iki alan (selectedOffer ve totalPrice) ayrı ayrı yayınlandığında gözlemciler ara durumu görür: yeni uçuş, eski fiyat. Bir ekran çantayı gözlemleyip tepki olarak çantaya yazmaya başladığında geri besleme döngüleri oluşur ve "fiyat neden iki kez hesaplanıyor" sorusu sprint'in yarısını alır. Reaktiflik, paylaşılan mutable state'in üzerine bir bildirim mekanizması koyar; sahipliği ve geçerliliği tanımlamaz.

Yerine ne koyuyoruz

İki kuralımız var.

Her adım, ihtiyacı olan veriyi immutable bir girdi olarak alır ve ürettiği şeyi açık bir çıktı olarak döndürür.

Birikmiş state'in tek bir sahibi vardır ve o state, her noktada var olması gereken veriyi tipinde taşır.

Akışın sahibi iOS'ta bir coordinator ya da flow modeli, Android'de navigation host ya da bir reducer olabilir. Ekranlar birbirini bilmez, sahibi de ekranların içini bilmez. Ödeme ekranının girdisi bir Quote ise, o ekranın var olabilmesi için birinin bir Quote üretmiş olması gerekir. "B çalıştı mı" sorusu derleme zamanında cevaplanır.

Bu modelde akışın state'i bir state machine'dir ve her durum yalnızca o noktada kesin var olan veriyi taşır. Swift'te associated value'lu enum, Kotlin'de sealed interface bunun doğal karşılığıdır. Optional kalmaz, çünkü "henüz yok" durumu ayrı bir case'tir.

iOS: path, state machine'in kendisidir

NavigationStack, Hashable değerlerden oluşan bir koleksiyona bağlanabilir ve stack'i o koleksiyon yönetir. Tek tip route kullandığımız için type-erased NavigationPath yerine düz bir dizi tercih ediyoruz; switch exhaustive kalır.

struct SearchCriteria: Hashable, Codable {
    let origin: String          // IATA kodu
    let destination: String
    let date: Date
    let passengerCount: Int
}

enum BookingStep: Hashable {
    case results(SearchCriteria)
    case passengers(Offer)            // seçilmiş teklif
    case extras(BookingDraft)         // sunucudaki taslak: teklif + yolcular
    case payment(Quote)               // fiyatı kilitlenmiş, süreli teklif
    case confirmation(BookingReference)
}

Her case bir öncekinin çıktısını taşır. .payment içinde yolcu listesi yok, çünkü ödeme ekranının ona ihtiyacı yok; Quote yeterli. Akışın sahibi şöyle:

@MainActor @Observable
final class BookingFlow {
    var path: [BookingStep] = []

    func searchSubmitted(_ criteria: SearchCriteria) {
        path = [.results(criteria)]
    }

    func offerSelected(_ offer: Offer, from origin: BookingStep) {
        advance(from: origin, to: .passengers(offer))
    }

    func passengersSaved(_ draft: BookingDraft, from origin: BookingStep) {
        advance(from: origin, to: .extras(draft))
    }

    func extrasPriced(_ quote: Quote, from origin: BookingStep) {
        advance(from: origin, to: .payment(quote))
    }

    func paid(_ reference: BookingReference) {
        // Geri tuşu ödeme ekranına dönmemeli: stack'i değiştiriyoruz
        path = [.confirmation(reference)]
    }

    private func advance(from origin: BookingStep, to next: BookingStep) {
        // Çıktıyı üreten adım artık stack'te değilse (kullanıcı geri gitmiş,
        // istek geç dönmüş) sonucu yok sayıyoruz
        guard let index = path.lastIndex(of: origin) else { return }
        path.removeSubrange((index + 1)...)
        path.append(next)
    }
}

advance iki sorunu birden kapatıyor. Kullanıcı geri gidip başka bir uçuş seçtiğinde eski .extras adımı stack'ten çıkar ve içindeki koltuk seçimi onunla birlikte yok olur; temizlenecek bir alan kalmaz. Geç dönen bir async sonuç da, onu başlatan adım stack'te olmadığı için akışı ileri itemez. Çift dokunuşta aynı ekranın iki kez push edilmesi de aynı mekanizmayla önlenir.

Görünüm tarafı her adımı girdisiyle kurar ve çıktısını flow'a bağlar:

struct BookingFlowView: View {
    @State private var flow = BookingFlow()
    let services: BookingServices

    var body: some View {
        NavigationStack(path: $flow.path) {
            SearchView(onSubmit: flow.searchSubmitted)
                .navigationDestination(for: BookingStep.self) { step in
                    switch step {
                    case .results(let criteria):
                        ResultsView(criteria: criteria, offers: services.offers) { offer in
                            flow.offerSelected(offer, from: step)
                        }
                    case .passengers(let offer):
                        PassengersView(offer: offer, drafts: services.drafts) { draft in
                            flow.passengersSaved(draft, from: step)
                        }
                    case .extras(let draft):
                        ExtrasView(draft: draft, pricing: services.pricing) { quote in
                            flow.extrasPriced(quote, from: step)
                        }
                    case .payment(let quote):
                        PaymentView(quote: quote, payments: services.payments, onPaid: flow.paid)
                    case .confirmation(let reference):
                        ConfirmationView(reference: reference)
                    }
                }
        }
    }
}

Ödeme ekranının view model'i artık yalnızca kendi girdisini bilir ve testi de o kadar küçüktür:

@MainActor @Observable
final class PaymentViewModel {
    let quote: Quote
    private(set) var isPaying = false
    private(set) var failure: PaymentFailure?
    private let payments: PaymentService
    private let onPaid: (BookingReference) -> Void

    init(quote: Quote, payments: PaymentService, onPaid: @escaping (BookingReference) -> Void) {
        self.quote = quote
        self.payments = payments
        self.onPaid = onPaid
    }

    func pay(with method: PaymentMethod) async {
        isPaying = true
        defer { isPaying = false }
        do {
            onPaid(try await payments.pay(quoteID: quote.id, method: method))
        } catch {
            failure = PaymentFailure(error)
        }
    }
}

@Test @MainActor
func successfulPaymentReturnsReference() async {
    let quote = Quote(id: "q-1", draftID: "d-1", total: Money(amount: 1250, currency: "TRY"), expiresAt: .distantFuture)
    var received: BookingReference?
    let viewModel = PaymentViewModel(
        quote: quote,
        payments: PaymentServiceStub(result: BookingReference("PNR123")),
        onPaid: { received = $0 }
    )

    await viewModel.pay(with: .savedCard(id: "c-1"))

    #expect(received == BookingReference("PNR123"))
}

Force unwrap yok, çanta yok, testte doldurulacak on bir alan yok. Kullanıcı uçuşu değiştirdiğinde girdiği yolcu bilgilerinin korunmasını istiyorsanız bu artık kazara olan bir davranış olmaz: PassengersView girdisine açık bir prefill parametresi eklersiniz ve kararın nerede verildiği kodda görünür.

Android: tipli route'lar ve SavedStateHandle

Navigation Compose, 2.8.0 sürümünden beri route'ları @Serializable tiplerle tanımlamayı destekliyor: composable<T>, navigation<T>, toRoute() ve tipli popUpTo. Route'ları bir sealed interface altında topluyoruz; her biri o adımın var olabilmesi için gereken veriyi taşıyor.

@Serializable data object BookingGraph

@Serializable
sealed interface BookingRoute {
    @Serializable data object Search : BookingRoute

    @Serializable data class Results(
        val origin: String,
        val destination: String,
        val date: String,           // ISO-8601
        val passengerCount: Int,
    ) : BookingRoute

    @Serializable data class Passengers(val offerId: String) : BookingRoute
    @Serializable data class Extras(val draftId: String) : BookingRoute
    @Serializable data class Payment(val quoteId: String) : BookingRoute
    @Serializable data class Confirmation(val reference: String) : BookingRoute
}

iOS'tan farkı görmüşsünüzdür: burada nesnenin kendisi yerine kimliği taşınıyor. Sebebi platformdur. Navigation, back stack'i argümanlarıyla birlikte saved state'e yazar ve Android dokümantasyonu saved state'in yalnızca küçük, basit veriler için olduğunu, karmaşık nesneler yerine kimliklerinin saklanmasını açıkça söyler. Primitive alanlarla kalmak ayrıca özel NavType yazma ihtiyacını da ortadan kaldırır.

Navigation host akışın sahibidir; geçişlerin tamamı tek dosyada okunur:

@Composable
fun BookingNavHost(navController: NavHostController = rememberNavController()) {
    NavHost(navController, startDestination = BookingGraph) {
        navigation<BookingGraph>(startDestination = BookingRoute.Search) {
            composable<BookingRoute.Search> {
                SearchScreen(onSubmit = { c ->
                    navController.navigate(
                        BookingRoute.Results(c.origin, c.destination, c.date.toString(), c.passengerCount)
                    )
                })
            }
            composable<BookingRoute.Results> {
                ResultsScreen(onOfferSelected = { offerId ->
                    navController.navigate(BookingRoute.Passengers(offerId))
                })
            }
            composable<BookingRoute.Passengers> {
                PassengersScreen(onDraftSaved = { draftId ->
                    navController.navigate(BookingRoute.Extras(draftId))
                })
            }
            composable<BookingRoute.Extras> {
                ExtrasScreen(onPriced = { quoteId ->
                    navController.navigate(BookingRoute.Payment(quoteId))
                })
            }
            composable<BookingRoute.Payment>(
                deepLinks = listOf(
                    navDeepLink<BookingRoute.Payment>(basePath = "https://travel.example.com/pay")
                )
            ) {
                PaymentScreen(onPaid = { reference ->
                    navController.navigate(BookingRoute.Confirmation(reference)) {
                        // Geri tuşu ödeme ekranına dönmemeli
                        popUpTo<BookingRoute.Search> { inclusive = true }
                    }
                })
            }
            composable<BookingRoute.Confirmation> { entry ->
                ConfirmationScreen(reference = entry.toRoute<BookingRoute.Confirmation>().reference)
            }
        }
    }
}

Her ekranın view model'i girdisini SavedStateHandle üzerinden okur. Navigation, route argümanlarını handle'a kendisi yerleştirir:

sealed interface PaymentUiState {
    data object Loading : PaymentUiState
    data class Ready(val quote: Quote, val paying: Boolean = false) : PaymentUiState
    data object QuoteExpired : PaymentUiState
    data class Paid(val reference: String) : PaymentUiState
}

@HiltViewModel
class PaymentViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val quotes: QuoteRepository,
    private val payments: PaymentRepository,
) : ViewModel() {

    private val route = savedStateHandle.toRoute<BookingRoute.Payment>()

    private val _state = MutableStateFlow<PaymentUiState>(PaymentUiState.Loading)
    val state: StateFlow<PaymentUiState> = _state.asStateFlow()

    init {
        viewModelScope.launch {
            val quote = quotes.find(route.quoteId)
            _state.value = if (quote == null || quote.isExpired()) {
                PaymentUiState.QuoteExpired
            } else {
                PaymentUiState.Ready(quote)
            }
        }
    }

    fun pay(method: PaymentMethod) {
        val ready = _state.value as? PaymentUiState.Ready ?: return
        if (ready.paying) return
        _state.value = ready.copy(paying = true)
        viewModelScope.launch {
            _state.value = try {
                PaymentUiState.Paid(payments.pay(ready.quote.id, method).reference)
            } catch (e: PaymentException) {
                ready.copy(paying = false)
            }
        }
    }
}

!! yok. Teklifin süresinin dolmuş olması bir crash yerine ekranın gösterebildiği bir durum. Ödeme ekranı deep link'ten, process death sonrası geri yüklemeden ya da normal akıştan açılmış olsun, aynı kod çalışır, çünkü tek girdisi quoteId. isFromDeepLink bayrağına gerek kalmaz.

Birikmiş veri (yolcular, ek hizmetler) sunucudaki ya da yerel veritabanındaki taslakta durur. Bu, çantayı veritabanına taşımakla aynı şey sayılmaz. Taslağı yolcu adımı yaratır ve draftId döndürür; ek hizmetler adımı o kimlik olmadan var olamaz. Sıra yine tiplerde.

Yeni bir projeye başlıyorsanız Navigation 3'e de bakın. Orada back stack sizin sahip olduğunuz bir key listesidir ve model, yukarıdaki iOS koduna çok yaklaşır. Mevcut bir Navigation Compose projesinde ise bu yazıdaki her şey tipli route'larla yapılabilir; çantayı sökmek için navigation kütüphanesini değiştirmeniz gerekmez.

Akışı doğru saklamak ve geri yüklemek

Kural iki platformda aynıdır: saved state'e akıştaki konumu ve kimlikleri yazarız, fiyatı ve müsaitliği ise her zaman yeniden çekeriz. Bayat bir fiyatla ödeme ekranı açmak, akışı baştan başlatmaktan daha pahalı bir hatadır.

Android'de bunun büyük kısmı kendiliğinden olur. rememberNavController back stack'i route argümanlarıyla birlikte saklar; process death sonrası PaymentViewModel aynı quoteId ile yeniden yaratılır ve teklifi repository'den çeker. Kullanıcının yazmakta olduğu ama henüz göndermediği form girdisi için rememberSaveable ya da SavedStateHandle kullanırız; Lifecycle 2.9 ile gelen saved delegate'i @Serializable bir form taslağını doğrudan saklayabilir. Saved state'in yalnızca Activity durdurulduğunda yazıldığını ve kullanıcı uygulamayı son uygulamalardan kaydırıp attığında silindiğini unutmayın; günler sonra devam edilmesi gereken bir rezervasyon için kalıcı depolama gerekir.

iOS'ta sistem bunu sizin yerinize yapmaz. BookingStep içinde tam nesneler taşıdığımız için path'i olduğu gibi diske yazmak istemiyoruz. Onun yerine küçük bir snapshot üretiyoruz:

struct BookingSnapshot: Codable {
    enum Position: Codable {
        case results
        case passengers(offerID: String)
        case extras(draftID: String)
        case payment(quoteID: String)
    }
    let criteria: SearchCriteria
    let position: Position
}

extension BookingFlow {
    func restore(_ snapshot: BookingSnapshot, using services: BookingServices) async {
        let results = BookingStep.results(snapshot.criteria)
        do {
            switch snapshot.position {
            case .results:
                path = [results]
            case .passengers(let offerID):
                let offer = try await services.offers.offer(id: offerID)
                path = [results, .passengers(offer)]
            case .extras(let draftID):
                let draft = try await services.drafts.draft(id: draftID)
                path = [results, .passengers(draft.offer), .extras(draft)]
            case .payment(let quoteID):
                let quote = try await services.pricing.quote(id: quoteID)
                let draft = try await services.drafts.draft(id: quote.draftID)
                path = quote.expiresAt > .now
                    ? [results, .passengers(draft.offer), .extras(draft), .payment(quote)]
                    : [results, .passengers(draft.offer), .extras(draft)]
            }
        } catch {
            // Teklif ya da taslak artık yok: geçerli olan en ileri noktaya dönüyoruz
            path = [results]
        }
    }
}

Snapshot'ı path her değiştiğinde @SceneStorage içine Data olarak yazıp sahne açılırken restore çağırmak yeterli. Apple, SceneStorage verisinin hafif olmasını ve hassas veri içermemesini istiyor; snapshot'ta yalnızca arama kriteri ve kimlikler olması bu yüzden. Yolcu adları ve pasaport numaraları sunucudaki taslakta kalır. Aynı restore fonksiyonu deep link'i de karşılar: "ödemenizi tamamlayın" bildirimi bir .payment(quoteID:) snapshot'ından ibarettir.

Çantayı alan alan sökmek

Çantayı tek bir pull request'te kaldırmayı denemeyin. Üç haftalık bir branch açılır, feature ekipleri o sırada çantaya dört alan daha ekler ve merge günü herkes pişman olur. Bizim izlediğimiz sıra şu:

  1. Önce çantayı dondurun: yeni alan eklemek yasak. Bunu bir toplantı kararı olarak bırakmayın; CI'da dosyadaki property sayısını sayan basit bir kontrol yeter. Aynı anda başlangıç ölçünüzü alın: grep -rn "BookingFlowContext" --include="*.swift" | wc -l ve akış kodundaki ! / !! sayısı.
  2. Yazmaları görünür yapın. Alanları private(set) yapın ve her yazmayı isimli bir metoda taşıyın (applyPromo(_:), selectOffer(_:)). Davranış değişmez ama kimin neyi yazdığının listesi elinize geçer. Bu adım genellikle iki üç bug'ı kendiliğinden ortaya çıkarır.
  3. Akışın sonundan başlayın. Onay ve ödeme ekranları en çok alanı okur ve onlara bağımlı ekran en azdır. Ekrana açık bir girdi tipi verin ve bu girdiyi akışın sahibinde, navigasyon anında, çantadan üretin. Ekran artık çantayı import etmez. Force unwrap'ler tek bir adapter fonksiyonunda toplanır ve orada gerçek bir hata yoluna dönüşür.
  4. Çıktıları çevirin. Ekran sonucunu closure ya da callback ile döndürsün, çantaya yalnızca akışın sahibi yazsın. Bu adımdan sonra çantanın tek bir yazarı olur.
  5. Akışın başına doğru ilerleyin. Her ekran taşındıkça çantadaki bazı alanların son okuyucusu ortadan kalkar; o alanı aynı PR'da silin. Çanta her hafta biraz küçülür ve bu görünür bir ilerlemedir.
  6. Son olarak sahibin içindeki çantayı state machine ile değiştirin. Artık çantaya yalnızca bir sınıf dokunduğu için bu, lokal bir refactoring'dir.

Göç sürerken yazılan her yeni adım ilk günden girdi ve çıktıyla yazılır; girdisini sahibi şimdilik çantadan üretse bile. Böylece feature işi durmaz ve borç büyümez.

Bittiğini şunlardan anlarsınız: tip silinmiştir ve reset() metodu onunla birlikte gitmiştir. Akış kodunda akış verisi için force unwrap kalmamıştır. Herhangi bir ekranın testi yalnızca o ekranın girdisini kurar. Soğuk açılışta ödeme deep link'i çalışır. Android'de am kill testi akışın her adımında geçer.

Nerede buna değmez

İki ekranlı bir akış için state machine kurmayın; ikinci ekrana bir parametre geçirin. Adımları gerçekte tek bir formun sayfaları olan bir wizard için tek sahipli, tek bir form state struct'ı gayet iyidir, çünkü bu yazıdaki sorunların kaynağı paylaşım ve sahipsizliktir. Oturum, dil ya da feature flag gibi gerçekten uygulama genelindeki şeyler de akış state'i sayılmaz ve bu yazının konusu dışındadır. Hangi verinin hangi modele ait olduğu sorusu için paylaşılan ve domain modelleri ayırma yazımıza bakabilirsiniz.

Kontrol listesi

  • Akışta her ekranın okuyup yazdığı, optional alanlardan oluşan paylaşılan bir nesne yok.
  • Her adımın girdisi immutable bir tip; çıktısı closure, callback ya da dönüş değeri olarak açıkça tanımlı.
  • Birikmiş state'in tek bir sahibi var ve her durum yalnızca o noktada var olan veriyi taşıyor (enum / sealed interface).
  • Geri gidip seçimi değiştirmek, sonraki adımların verisini kendiliğinden düşürüyor; elle temizlenen alan yok.
  • Geç dönen async sonuçlar, kendilerini başlatan adım stack'te değilse akışı ilerletmiyor.
  • Akış kodunda akış verisi için ! ya da !! yok; eksik veri, ekranın gösterebildiği bir durum.
  • Saved state'te yalnızca konum ve kimlikler var; fiyat ve müsaitlik geri yüklemede yeniden çekiliyor.
  • Android'de akışın her adımında adb shell am kill sonrası geri dönüş çalışıyor.
  • Ödeme adımına soğuk açılışta deep link ile girilebiliyor ve isFromDeepLink benzeri bir bayrak gerekmiyor.
  • Her ekranın testi yalnızca o ekranın girdisini kuruyor.
Seviye 4 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: Feature-based mimari vs layer-based mimari

Bu konuda desteğe mi ihtiyacınız var?

Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.

Yorumlar ve sorular

Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.