Büyük projelerde sık gördüğümüz tablo şöyle: ekip bir yıl uğraşıp uygulamayı 60 module'e bölmüş, sunumda "modüler mimari" slaytı var, ama Core içindeki bir enum'a case eklediğinizde Xcode yine kahve molası veriyor. Module sayısı arttı, incremental build süresi yerinde saydı. Süreyi kutular arasındaki okların nereye baktığı belirliyor ve o oklar bölme işinden sonra da aynı yere bakıyor.
Bu yazının konusu o oklar: bir Swift projesinde compile-time graph'in ne olduğu, Swift'in bir değişiklikten sonra neyi yeniden derlemeye karar verdiği ve graph'i, en sık dokunduğunuz kodun en az şeyi tetikleyeceği biçime nasıl getireceğiniz. Android tarafını Gradle için ayrı bir yazıda anlattık; fikir aynı, mekanizma farklı.
Süreyi graph'in şekli belirler
Compile-time graph'in düğümleri target'lar, kenarları import satırlarıdır. Package.swift içindeki dependencies: listesi ya da Xcode'daki target dependency'leri neyin import edilebileceğini söyler; gerçek kenarı kaynak dosyadaki import çizer. Graph'in üç özelliği incremental build süresini doğrudan belirler.
Birincisi derinlik. App → Booking → Search → Payments → Networking → Core gibi bir zincirde her halka bir öncekinin module'ünün üretilmesini bekler. Apple'ın kendi dokümantasyonu da bunu söylüyor: bir target'ın çok dependency'si varsa ya da büyük, monolitik module'lere bağlıysa Xcode daha fazla işi seri yapmak zorunda kalır. Sekiz çekirdeğiniz olabilir, zincir tek sıra yürür.
İkincisi fan-in, yani bir module'ü kaç module'ün import ettiği. Hayali bir seyahat rezervasyon uygulaması düşünün; Core module'ünde Money, tarih formatlayıcıları, network istemcisi, analytics event'leri, feature flag'ler ve renk paleti duruyor. Her feature import Core yazıyor.
App
┌─────────┼──────────┬──────────┐
Booking Search Payments Profile ... (40 feature)
└─────────┴────┬─────┴──────────┘
Core ← fan-in: 41
┌──────────┬───┴──────┬─────────────┐
Alamofire Kingfisher FirebaseAnalytics ...
Üçüncüsü, değişimin graph'in neresinde olduğu. Fan-in'i 41 olan bir module'e günde on kez dokunuluyorsa, o module'ün üstündeki her şey günde on kez geçersiz kalır. Core'a yeni bir analytics event adı eklemek ödeme ekranını yeniden derletiyorsa sorun module sayısında aranmaz; okların hepsi aynı kutuya bakıyordur.
Bir module'ün maliyeti, değişme sıklığı ile onu import edenlerin sayısının çarpımıdır.
Swift neyi yeniden derleyeceğine nasıl karar veriyor
İki ayrı seviye var ve ikisini karıştırmak yanlış beklentiye yol açar.
Module'ün içinde Swift dosya bazında çalışır. Derleyicinin kendi dokümanı bunu "provides / depends" sistemi olarak anlatır: her dosya sağladığı ve kullandığı isimleri kaydeder, bir dosya değiştiğinde yalnızca onun sağladığına bağlı dosyalar yeniden derlenir. Altın kural temkinli olmaktır: emin değilse derler.
Module'ler arasında tablo daha kabadır. Aynı doküman şunu söyler: import edilen Swift module dosyaları "external dependency" olarak izlenir ve bir module'deki her dosya import listesinin tamamını paylaştığı için, external dependency değiştiğinde module'ün tamamı yeniden derlenir. Yani Core'un derlenmiş module'ü değiştiyse, Core'u import eden her module baştan derlenmeye adaydır. Booking'in Core'daki o yeni fonksiyonu hiç kullanmaması bir şey değiştirmez.
O zaman asıl soru şu: derlenmiş module'ü ne değiştirir? Bunu tahmin etmek yerine denedik. Beş target'lı küçük bir test paketinde (Swift 6.4 toolchain, debug build, swift build -v) Core'a farklı türde değişiklikler yapıp neyin derlendiğine baktık:
Core'da yapılan değişiklik |
Yeniden derlenen |
|---|---|
| Bir fonksiyonun gövdesi | Yalnızca Core'daki o dosya |
| Yorum satırı | Yalnızca Core'daki o dosya |
Yeni bir public fonksiyon |
Core ve onu import eden module'lerdeki dosyalar |
Mevcut bir public imzanın değişmesi |
Aynısı |
Yeni bir internal fonksiyon |
Aynısı |
Yeni bir private fonksiyon |
Aynısı |
Son iki satır çoğu kişiyi şaşırtır. Debug build'de derlenmiş .swiftmodule yalnızca public API'nin özeti gibi davranmıyor; private bir declaration eklemek bile üst module'leri tetikledi. Gövde değişiklikleri ise module sınırında kaldı. Bu tablo tek bir denemenin sonucudur ve dilin verdiği bir garanti sayılmaz; toolchain sürümüne, optimizasyon ayarına ve library evolution'ın açık olup olmamasına göre değişebilir. Aşağıdaki ölçüm bölümündeki yöntemle kendi projenizde beş dakikada tekrarlayabilirsiniz ve tekrarlamalısınız.
Pratik sonucu şu: "public API'ye dokunmadım, kimse etkilenmez" varsayımı güvenli değil. Çok import edilen bir module'de yeni bir declaration, erişim seviyesi ne olursa olsun, yukarıdaki her şeyi yeniden derletebilir. Güvenli olan tek şey gövde değişikliğidir.
Explicitly built modules bunu nasıl etkiliyor
Xcode 16 ile build sistemi, module dependency'lerinin derlenmesini build log'unda ayrı görevler olarak göstermeye başladı; Xcode 26'nın release notlarına göre explicit modules artık bütün Swift target'ları için varsayılan mod. İstisnalar da aynı notlarda yazıyor: Swift 5 öncesi dil sürümü kullanan ve Swift/C++ interop kullanan target'larda varsayılan olarak açık gelmiyor. Ayar SWIFT_ENABLE_EXPLICIT_MODULES.
Bu modda derleme üç aşamaya ayrılır: önce dependency scanning (derleyici kaynakların hangi module'lere bağlı olduğunu tarar), sonra module'lerin dependency sırasıyla ve birbirinden bağımsız olanların paralel derlenmesi, en son kaynak dosyaların derlenmesi. Graph yönetimi açısından iki sonucu var.
İlki görünürlük. Eskiden derleyicinin içinde örtük olarak yapılan iş artık build log'unda ve build timeline'da satır satır duruyor; graph'iniz neyse orada onu görüyorsunuz.
İkincisi module varyantları. Apple'ın dokümanı açık: module'ler, kendilerini kullananların derleyici seçeneklerine duyarlıdır. İki target aynı dependency'leri farklı Preprocessor Macros değerleriyle derliyorsa derlenmiş module'ü paylaşamaz, aynı module iki kez derlenir. Build With Timing Summary raporu hangi module'ün kaç varyantla derlendiğini ve seçenek farklarını listeler. Target'larınızın build ayarlarını tek bir .xcconfig'ten ya da tek bir SwiftPM swiftSettings tanımından beslemek burada doğrudan zaman kazandırır.
Explicit modules yukarıdaki geçersiz kılma kuralını değiştirmez. Çok import edilen module değiştiğinde üstündekiler yine derlenir; yalnızca bunu artık daha iyi planlanmış ve daha okunabilir biçimde yapar.
Dependency'leri public interface'inizin dışında tutun
Graph'te bir kenar, import ettiğiniz module'ün tiplerini kendi public API'nize koyduğunuz anda kalıcılaşır. Networking module'ünüzün public bir fonksiyonu Alamofire.DataRequest döndürüyorsa, Networking'i kullanan her feature fiilen Alamofire'a da bağlıdır ve o kütüphaneyi değiştirmek artık bir refactor projesidir.
import üzerinde erişim seviyesi (SE-0409)
SE-0409, import satırına erişim seviyesi yazmayı getirdi ve Swift 6.0 ile dile girdi. internal import Alamofire yazdığınızda derleyici, Alamofire tiplerinin sizin public imzalarınızda görünmesini hata olarak reddeder.
// Networking/HTTPClient.swift
internal import Alamofire // implementasyon detayı
public import Models // public imzalarda Models tipleri var
public struct HTTPClient {
private let session: Session // Alamofire.Session, dışarıdan görünmez
public init() { self.session = Session() }
public func offers(for query: SearchQuery) async throws -> [FlightOffer] {
try await session
.request("https://api.example.com/offers", parameters: query.parameters)
.serializingDecodable([FlightOffer].self)
.value
}
// public func raw() -> DataRequest { ... }
// hata: public olarak tanımlanamaz, sonuç tipi internal import edilmiş bir module'den geliyor
}
Varsayılan konusunda yanlış bilgi çok dolaşıyor, o yüzden net yazalım. Proposal'a göre Swift 6 dahil mevcut dil modlarında çıplak import hâlâ public kabul edilir; bu, kaynak uyumluluğu için bilinçli bir tercih. internal varsayılanı gelecekteki bir dil moduna bırakıldı ve bugün InternalImportsByDefault upcoming feature bayrağıyla açılıyor. Swift 6 moduna geçmiş olmanız bunu size otomatik vermez.
// Package.swift: bütün target'larda aynı ayarı kullanın, varyant üretmesin
let strict: [SwiftSetting] = [
.enableUpcomingFeature("InternalImportsByDefault"),
]
.target(name: "Networking", dependencies: ["Models", "Alamofire"], swiftSettings: strict)
Xcode target'larında karşılığı SWIFT_UPCOMING_FEATURE_INTERNAL_IMPORTS_BY_DEFAULT build ayarı. Bayrağı açtıktan sonra derleyici, public imzada kullandığınız her module için sizden public import ister. O public import listesi, module'ünüzün dışarıya gerçekten neyi taşıdığının dökümüdür ve code review'da okunacak kadar kısa olmalıdır.
Dürüst olmak gereken bir nokta var. internal import'un build süresini kendi başına düşürdüğünü söyleyen yazılar görürsünüz. Proposal bunu yalnızca library evolution açık (resilient) module'ler için vaat ediyor: orada gizlenen dependency'yi dolaylı client'ların yüklemesi gerekmez. Uygulama içi module'lerin neredeyse tamamı resilient değildir ve proposal'ın ifadesiyle bu durumda değişiklik yalnızca tip kontrolündedir; dolaylı client'lar o module'ü yine yükler. Bizim test paketimizde de import ile internal import arasında yeniden derleme kapsamı açısından fark çıkmadı. Bu özelliği derleme hızı için açmıyoruz. Açıyoruz, çünkü üçüncü parti bir tipin public API'ye sızmasını derleme hatasına çeviriyor ve sarmaladığınız kütüphanenin gerçekten sarmalanmış kalmasını sağlıyor.
package erişim seviyesi (SE-0386)
Swift 5.9 ile gelen package, internal ile public arasındaki boşluğu doldurur: aynı paketteki module'ler görür, paket dışı görmez. Hangi module'lerin aynı pakette olduğunu derleyiciye -package-name bayrağı söyler; SwiftPM bunu kendisi geçirir, Xcode target'larında SWIFT_PACKAGE_NAME build ayarıyla verirsiniz.
// Models/FlightOffer.swift
public struct FlightOffer: Sendable, Decodable {
public let id: String
public let price: Money
// Aynı paketteki feature'lar ve test-support target'ı kullanır; paket dışına çıkmaz
package init(id: String, price: Money) {
self.id = id
self.price = price
}
}
package'ın graph'e katkısı dolaylı ama gerçek: iki module arasında paylaşmak istediğiniz yardımcı kod için "o zaman public yapalım" refleksini keser. public yüzey küçük kaldıkça module'ün gerçekte neye söz verdiği belli olur ve bir sonraki adımdaki interface module'ler küçük tutulabilir.
Interface module'ler bir build aracıdır
Interface module'ü çoğu ekip "temiz mimari" gerekçesiyle kurar. Biz build gerekçesiyle kuruyoruz. Yukarıdaki tabloyu hatırlayın: bir module'e eklenen private bir fonksiyon bile onu import edenleri yeniden derletebiliyor. Search feature'ı üzerinde çalışan biri gün boyu tam da bunu yapar: yeni view'lar, yeni yardımcı tipler, yeni private metotlar. Booking doğrudan Search'ü import ediyorsa, bu değişikliklerin her biri Booking'i de tetikler.
Çözüm, Booking'in bağlandığı şeyi seyrek değişen küçük bir module'e indirmek:
// SearchInterface/SearchRouting.swift (bütün module iki dosya)
public import Models
public struct SearchRequest: Sendable {
public let origin: String
public let destination: String
public init(origin: String, destination: String) {
self.origin = origin
self.destination = destination
}
}
public protocol SearchRouting: Sendable {
func cheapestOffer(for request: SearchRequest) async throws -> FlightOffer?
}
// Package.swift (ilgili kısım)
.target(name: "SearchInterface", dependencies: ["Models"], swiftSettings: strict),
.target(name: "Search",
dependencies: ["SearchInterface", "Models", "Networking", "DesignSystem"],
swiftSettings: strict),
.target(name: "Booking",
dependencies: ["SearchInterface", "Models", "DesignSystem"], // Search yok
swiftSettings: strict),
Search implementasyonu ile SearchRouting'i birbirine bağlayan tek yer app target'ındaki composition root olur. Artık Search içinde ne değişirse değişsin Booking yeniden derlenmez; yeniden derlenen Search ve onu bilen tek yer olan app target'ıdır.
İki uyarı. Interface module'ün içine "nasılsa lazım olur" diye model, extension ve yardımcı fonksiyon koymaya başlarsanız, kısa sürede sık değişen ikinci bir Core elde edersiniz. İçinde protocol'ler ve onların imzasında geçen birkaç değer tipi olmalı, başka bir şey olmamalı. İkincisi, her feature'a refleks olarak interface module açmayın. Başka bir feature tarafından kullanılmayan bir feature'ın interface'e ihtiyacı yoktur; target sayısının da bir maliyeti var, onu modülerleşmenin maliyeti yazısında ayrıca hesapladık.
Uyguladığımız hijyen kuralları
Feature'lar feature import etmez. Bir feature'ın başka bir feature'a ihtiyacı varsa onun interface module'üne bağlanır. Bu kural hem döngüleri hem de derin zincirleri kaynağında keser. Kuralın wiki sayfasında durması yetmez; CI'da kontrol edilmesi gerekir, bunun yollarını mimari kurallarını zorlamak yazısında anlattık.
Graph sığ kalır. App target'ından en alttaki module'e en uzun yol bizim projelerimizde dört katmanı geçmez: app, feature, interface ya da servis, temel module'ler. Beşinci katmana ihtiyaç duyduğunuzda genellikle bir servis module'ü başka bir servis module'ünün implementasyonuna bağlanmıştır.
Altta duran module'ler seyrek değişen module'lerdir. Fan-in'i yüksek bir module'e koyacağınız her şey için tek soru sorun: bu dosya ayda kaç kez değişiyor? Money ve FlightOffer gibi değer tipleri altta durabilir. Analytics event listesi, feature flag anahtarları, string sabitleri gibi her sprint değişen şeyler duramaz; onlar ait oldukları feature'a ya da en fazla kendi küçük module'lerine gider.
Sınırsız büyüyen bir Common olmaz. Common, Core, Shared, Utils: isim değişir, kader aynıdır. "Nereye koyacağımı bilemedim" kodunun toplandığı module, tanımı gereği hem en çok import edilen hem en sık değişen module olur. Yeni bir temel module'ün adı ne yaptığını söylemeli (Models, Networking, DesignSystem) ve o işin dışındaki kod oraya giremiyor olmalı.
Test-support kodu kendi target'ında durur. Mock'lar, fixture'lar ve test builder'ları production module'ünün içinde #if DEBUG arkasında yaşıyorsa, bir mock'a alan eklemek production module'ünü değiştirir ve yukarıdaki her şeyi derletir. SearchInterfaceTestSupport gibi ayrı bir target yalnızca test target'ları tarafından import edilir ve app'in graph'ine hiç girmez.
Graph'i görmek
Kağıda çizdiğiniz graph ile derleyicinin gördüğü graph nadiren aynıdır. Bakılacak üç yer var.
SwiftPM tarafında swift package show-dependencies --format dot çözülmüş graph'i Graphviz formatında verir (diğer formatlar text, json ve flatlist). Ama dikkat: bu komut paketler arası graph'i gösterir. Bütün module'leriniz tek bir pakette target olarak duruyorsa çıktı boştur. Target seviyesindeki graph için swift package describe --type json çıktısını kullanıyoruz; her target'ın target_dependencies alanı var ve onu dot'a çevirmek yirmi satır:
#!/usr/bin/env python3
# kullanım: swift package describe --type json | ./graph.py > targets.dot
import json, sys
package = json.load(sys.stdin)
fan_in = {}
print("digraph targets {")
print(" rankdir=BT;")
for target in package["targets"]:
if target["type"] == "test":
continue
for dep in target.get("target_dependencies", []):
print(f' "{target["name"]}" -> "{dep}";')
fan_in[dep] = fan_in.get(dep, 0) + 1
print("}")
# fan-in sıralaması stderr'e: en üstteki, en pahalı module'ünüz
for name, count in sorted(fan_in.items(), key=lambda item: -item[1]):
print(f"{count:3d} {name}", file=sys.stderr)
dot -Tsvg targets.dot -o targets.svg ile resmi alırsınız, ama asıl işe yarayan stderr'e düşen fan-in listesidir. Listenin ilk üç satırı, bu yazının geri kalanında uğraşacağınız module'lerdir. Kullandığımız toolchain'de describe ayrıca --type mermaid kabul ediyor; kendi sürümünüzde swift package describe --help ile bakın. Bu script yalnızca manifest'te beyan edilen kenarları görür; beyan edilip hiç import edilmeyen dependency'ler de graph'te durur ve build sırasını etkiler, onları ayıklamak ayrı bir temizlik işidir.
Xcode tarafında Product > Perform Action > Build With Timing Summary, build'in sonunda görev türlerine göre süre dökümü verir; komut satırındaki karşılığı xcodebuild -showBuildTimingSummary. Explicit modules açıkken aynı rapor module varyantlarını da listeler. Build log'undan açılan build timeline ise graph'in zamana yayılmış halidir: aynı anda çalışan görevler alt alta satırlarda durur. Timeline'ın tek satıra indiği yerler, graph'inizdeki derin zinciri ya da herkesin beklediği tek module'ü ele verir; o anda derlenen module darboğazınızdır.
Üçüncü parti graph araçlarından bilinçli olarak bahsetmiyoruz: bu yazıyı yazarken güncel dokümantasyonlarını doğrulayamadıklarımızı önermek istemedik. Yukarıdaki script ile Xcode'un kendi raporları, karar vermek için yeterli veriyi veriyor.
Ölçmek: yaprak ile çekirdek arasındaki fark
"Build yavaş" cümlesiyle refactor planlanmaz. İhtiyacınız olan sayı şu: belirli bir dosyaya gerçekçi bir değişiklik yapıldıktan sonra incremental build kaç saniye sürüyor. Bunu yaprak bir feature dosyası ve çekirdek bir module dosyası için ayrı ayrı ölçtüğünüzde aradaki fark, graph'inizin size günlük maliyetidir.
Dosyaya touch atmak yetmez, çünkü yukarıda gördüğümüz gibi içerik değişmeden derlenen module değişmez ve dalga yukarı yayılmaz. Script bu yüzden gerçek bir declaration ekliyor:
#!/bin/bash
# kullanım: ./probe.sh Modules/Core/Sources/Core/Money.swift
set -euo pipefail
FILE="$1"
BUILD=(xcodebuild build -workspace Seyahat.xcworkspace -scheme Seyahat
-destination 'generic/platform=iOS Simulator' -quiet)
"${BUILD[@]}" # ısınma: her şey güncel olsun
echo "public func _probe_$(date +%s)() {}" >> "$FILE"
START=$(date +%s)
"${BUILD[@]}" # ölçülen incremental build
END=$(date +%s)
git checkout -- "$FILE" # değişikliği geri al
echo "$FILE: $((END - START)) sn"
Ölçümün tekrarlanabilir olması için birkaç kural: aynı makine, Xcode kapalı (indeksleme ölçüme karışmasın), her dosya için en az üç tur ve ortanca değer. Dosyanın git'te temiz olduğundan emin olun, script sonunda git checkout yapıyor. Hangi görevlerin çalıştığını da görmek isterseniz -quiet yerine -showBuildTimingSummary verin.
Bunu fan-in listesinin ilk beş module'ünden birer dosya ve iki üç yaprak feature dosyası için çalıştırın, sonucu bir tabloya yazın. Çoğu projede tablo aynı hikâyeyi anlatır: yaprak dosyalar saniyelerle, bir iki çekirdek module clean build'e yakın sürelerle biter. Refactor'a o bir iki module'den başlanır, en çirkin görünen module'den başlanmaz.
Aynı ölçümü ikinci bir değişiklik türüyle tekrarlamak öğreticidir: probe satırını public func yerine mevcut bir fonksiyonun gövdesine eklenen bir satır yapın. İkisi arasındaki fark, "gövde değişikliği ucuz, declaration değişikliği pahalı" kuralının sizin toolchain'inizde ne kadar geçerli olduğunu gösterir.
Üçüncü parti dependency'ler graph'in neresinde durmalı
Üçüncü parti bir kütüphane graph'te tek bir module tarafından import edilmeli. Alamofire'ı Networking, Kingfisher'ı DesignSystem ya da küçük bir ImageLoading, analytics SDK'sını Analytics sarar; feature'lar yalnızca sizin module'ünüzü görür. InternalImportsByDefault açıkken bu sarmalamanın delinmediğini derleyici denetler:
// Analytics/Analytics.swift
internal import FirebaseAnalytics
public struct AnalyticsEvent: Sendable {
public let name: String
public let parameters: [String: String]
public init(name: String, parameters: [String: String] = [:]) {
self.name = name
self.parameters = parameters
}
}
public struct AnalyticsTracker: Sendable {
public init() {}
public func track(_ event: AnalyticsEvent) {
// FirebaseAnalytics'e dokunan tek yer
FirebaseAnalytics.Analytics.logEvent(event.name, parameters: event.parameters)
}
}
Dikkat edin, event tanımları (searchStarted, bookingCompleted) bu module'de durmuyor. Onlar feature'lara ait; Analytics yalnızca taşıma mekanizması. Event listesini buraya koyarsanız fan-in'i yüksek bir module'ü her sprint değiştirmiş olursunuz, ki tam kaçındığımız şey buydu.
Sarmalamanın build tarafındaki kazancı, kütüphane sürümünü yükselttiğinizde ortaya çıkar: değişen API'yi tek module karşılar. Sarmalama aynı zamanda varyant sorununu da küçültür, çünkü kütüphaneyi farklı ayarlarla derleyen target sayısı bire iner.
Macro'lar ve swift-syntax
Macro tanımlayan ya da macro kullanan bir pakete bağlandığınızda graph'e swift-syntax da girer ve bu, kaynak koddan derlendiğinde clean build'e hissedilir bir süre ekleyen büyük bir pakettir. Güncel durum şöyle: Xcode 26'nın release notlarına göre macro target'ları için swift.org'dan önceden derlenmiş bir swift-syntax kütüphanesi indirilip build'e dahil ediliyor ve bu özellik otomatik açık. Swift forumlarındaki duyuruya göre SwiftPM, çözülen swift-syntax sürümü, host platform ve derleyici sürümü için hazır binary varsa onu kullanıyor; yoksa eskisi gibi kaynaktan derliyor.
Buradan çıkan pratik kurallar: Package.resolved içindeki swift-syntax sürümünüz için prebuilt'in gerçekten kullanılıp kullanılmadığını build log'undan kontrol edin (log'da swift-syntax module'lerinin derlendiğini görüyorsanız kullanılmıyordur). CI makinelerinizin bu binary'yi indirebildiğinden emin olun. Ve bir macro paketini eklemeden önce onu kaç module'ün import edeceğine bakın: Models gibi en alttaki module'e eklenen bir macro dependency'si, graph'in tamamının önüne bir adım koyar. Tek bir @Something için bunu yapmaya değip değmeyeceğini probe script'i söyler.
Örnek bir refactor: kötü graph'ten üç adımda çıkmak
Seyahat uygulamamızın başlangıç hali, sahada sık gördüğümüz hali:
App
├─ Booking ──► Search ──► Payments ──► Core
├─ Search ──► Payments
├─ Payments ──► Core
├─ Profile ──► Booking ──► ...
└─ (36 feature daha) ──► Core
Core: Money, FlightOffer, HTTPClient, AnalyticsEvents, FeatureFlags,
Colors, Date+Format, MockHTTPClient (#if DEBUG)
Core ──► Alamofire, Kingfisher, FirebaseAnalytics (tipleri public API'de)
Bu graph'te feature'lar birbirine zincirlenmiş (Profile → Booking → Search → Payments), Core hem en çok import edilen hem en sık değişen module, üçüncü parti tipleri her yere sızmış ve mock'lar production module'ünün içinde. Probe script'i Core/AnalyticsEvents.swift için neredeyse clean build süresi verir.
Sorunun çoğunu üç değişiklik çözer.
Birincisi, Core'u değişme sıklığına göre bölmek. Money ve FlightOffer gibi seyrek değişen değer tipleri Models'a gider ve altta kalır. HTTPClient Networking'e, renkler DesignSystem'a, analytics mekanizması Analytics'e taşınır. Event listesi ve feature flag anahtarları gibi sık değişenler ilgili feature'ların içine dağılır. Ortada Core diye bir module kalmaz. Feature'lar yalnızca kullandıklarını import eder; Profile'ın Networking'e ihtiyacı yoksa Networking değiştiğinde derlenmez.
İkincisi, feature'dan feature'a giden kenarları interface module'lerle değiştirmek. Booking → Search kenarı Booking → SearchInterface olur, Search → Payments kenarı Search → PaymentsInterface olur. Zincir kırılır, graph dört katmana iner ve feature'lar birbirinden bağımsız, paralel derlenir.
Üçüncüsü, üçüncü partileri ve test-support kodunu kapatmak. Bütün target'larda InternalImportsByDefault açılır, derleyicinin şikâyet ettiği her yerde ya public import bilinçli olarak yazılır ya da sızan tip sarmalanır. MockHTTPClient NetworkingTestSupport target'ına taşınır.
App (composition root)
├─ Booking ──► SearchInterface, PaymentsInterface, Models, DesignSystem
├─ Search ──► SearchInterface, PaymentsInterface, Models, Networking, DesignSystem
├─ Payments ──► PaymentsInterface, Models, Networking, Analytics
└─ Profile ──► BookingInterface, Models, DesignSystem
Networking ──► Models, (internal) Alamofire
Analytics ──► (internal) FirebaseAnalytics
DesignSystem ──► (internal) Kingfisher
NetworkingTestSupport ──► Networking (yalnızca test target'ları import eder)
Sırası önemli: önce birinci adım, çünkü en büyük kazanç orada ve diğer ikisini kolaylaştırıyor. Her adım ayrı bir PR dizisi olmalı; üçünü tek branch'te yapmaya kalkarsanız o branch asla merge edilmez.
Kazancı doğrulamak için başlamadan önce probe script'inin çıktısını kaydedin, her adımdan sonra aynı dosyalar (taşındılarsa yeni yerlerindeki karşılıkları) için tekrar çalıştırın. Bakacağınız üç şey: eski çekirdek dosyalar için incremental süre, Search içinde bir declaration değişikliğinden sonra Booking'in timing summary'de hiç görünmemesi ve fan-in listesinin tepesinde artık yalnızca seyrek değişen module'lerin durması. Üçüncüsü tutmuyorsa, yani listenin tepesindeki module'ün git geçmişi hâlâ kalabalıksa, Core'u bölmemiş, yalnızca yeniden adlandırmışsınızdır.
Bu işin durduğu yer de var. On beş module'lü, clean build'i birkaç dakika süren bir projede interface module'ler ve bölünmüş Core size ölçülebilir bir şey kazandırmayabilir; probe tablonuzda yaprak ile çekirdek arasındaki fark küçükse graph'iniz zaten iyi durumdadır ve zamanınızı başka yere harcamalısınız.
Kontrol listesi
- Target seviyesinde graph çıkarıldı; fan-in'e göre ilk beş module biliniyor.
- Bu beş module'ün her biri için son üç aydaki commit sayısına bakıldı; hem çok import edilen hem sık değişen module'ler işaretlendi.
- Probe script'iyle yaprak ve çekirdek dosyalar için incremental build süresi ölçüldü ve kaydedildi.
- Hiçbir feature başka bir feature'ın implementasyonunu import etmiyor; bu kural CI'da kontrol ediliyor.
- App'ten en alt module'e en uzun yol dört katmanı geçmiyor.
Core/Common/Sharedtürü module'ler değişme sıklığına göre bölündü; yeni module'lerin adı ne yaptığını söylüyor.- Bütün target'larda
InternalImportsByDefaultaçık;public importsatırları bilinçli ve az. - Her üçüncü parti kütüphane tek bir module tarafından import ediliyor ve tipleri public API'de görünmüyor.
- Paket içi paylaşım için
publicyerinepackagekullanılıyor. - Mock ve fixture'lar ayrı test-support target'larında; production module'lerinde
#if DEBUGmock'u yok. - Target'ların derleyici ayarları tek kaynaktan geliyor; timing summary'de gereksiz module varyantı görünmüyor.
- Macro dependency'leri için swift-syntax'ın prebuilt olarak geldiği build log'undan doğrulandı.
- Refactor sonrası aynı ölçümler tekrarlandı ve öncesiyle karşılaştırıldı.
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.
Kaynaklar
- Apple · Building your project with explicit module dependencies
- Apple · Improving the speed of incremental builds
- Apple · Xcode 26 Release Notes
- Apple · Build settings reference
- Swift Evolution · SE-0409 Access-level modifiers on import declarations
- Swift Evolution · SE-0386 New access modifier: package
- Swift · Dependency Analysis
- Swift Package Manager · swift package show-dependencies
- Swift Forums · Swift-Syntax Prebuilts for Macros
- Apple · WWDC24, Demystify explicitly built modules
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.