Swift Package Manager ile 100+ modüllü iOS architecture

Yüz module'ü local Swift package'larla yönetmek mümkün, yeter ki manifest'i veri gibi değil kod gibi yazın. Tek package kararından linking tuzaklarına, macro faturasından yalnızca etkilenen testleri çalıştıran CI script'ine kadar uçtan uca kurulum.

Yüz module'lü bir iOS projesinin en çok okunan dosyası çoğu zaman Package.swift olur ve genellikle en kötü yazılmış dosyası da odur. Bin beş yüz satır .target(...), her biri bir öncekinden kopyalanmış, üçünde swiftSettings unutulmuş, birinde bir feature başka bir feature'a doğrudan bağlanmış ve bunu kimse fark etmemiş. Dosyanın tepesinde de bir yorum durur: "Lütfen alfabetik sırayı bozmayın."

Local Swift package'lar bu ölçeği taşır. Taşıyamadığı yerler de var, onları yazının sonunda açıkça söylüyoruz. Ama büyük projelerde gördüğümüz sorunların çoğu SwiftPM'in sınırlarından gelmiyor, manifest'in bir YAML dosyası gibi yazılmasından geliyor. Örnek olarak hayali bir seyahat rezervasyon uygulaması kullanacağız: otuz küsur feature, bir düzine core module, bir widget extension. Araç tarafında Xcode 27 ve Swift 6.4 ile konuşuyoruz; manifest swift-tools-version: 6.2 ile yazıldı.

Yüz module'ün sizin projeniz için doğru sayı olup olmadığı ayrı bir soru, onu modularization'ın gerçek maliyeti yazısında tartıştık. Burada kararı vermiş olduğunuzu varsayıyoruz.

İlk karar: tek package, çok target

İki yol var. Ya her module kendi Package.swift dosyasına sahip ayrı bir local package olur ya da tek bir Modules package'ı içinde yüz küsur target yaşar. Biz ikincisini seçiyoruz ve gerekçesi dört başlıkta toplanıyor.

Dependency çözümü: Yüz package, yüz manifest demek. Her manifest ayrı derlenir, ayrı değerlendirilir ve aralarındaki her bağımlılık .package(path:) satırı artı bir product tanımı ister. Search, HotelDetailInterface'i kullanacaksa önce HotelDetail package'ının bunu product olarak yayınlaması gerekir. Tek package'da target'lar birbirini adıyla görür, product yalnızca uygulamaya açılan kapıdır.

Access control: Swift 5.9 ile gelen package erişim seviyesi (SE-0386), bir sembolü aynı package içindeki diğer module'lere açar, dışarıya kapatır. Sınır package'dır. Yüz ayrı package kurarsanız bu seviye fiilen internal ile aynı işi görür ve module'ler arası her şey public olmak zorunda kalır. Tek package'da FoundationKit içindeki bir yardımcıyı package yapar, üçüncü parti gibi davranmasını istediğiniz API'leri public bırakırsınız.

Xcode davranışı: Xcode, local package'ların her library product'ı için otomatik bir scheme üretir, package başına da bir -Package scheme'i ekler. Örnek manifest'imizde xcodebuild -list üç scheme döndürüyor: AppModules, WidgetModules, Modules-Package. Yüz package'da bu liste yüzün üzerine çıkar.

Manifest'in kendisi: Tek dosya, tek yerde yazılmış kurallar. Aşağıda göreceğiniz helper'ları yüz ayrı manifest'e dağıtmanın temiz bir yolu yok, çünkü manifest'ler birbirini import edemez.

Tek package'ın bedeli şu: Package.swift herkesin dokunduğu bir dosya olur ve her değişiklik tüm graph'ın yeniden yüklenmesine yol açar. Bunu kabul ediyoruz. Çok package'a geçmeyi yalnızca bir grup module gerçekten başka bir repoya ya da başka bir uygulamaya taşınacaksa düşünüyoruz; o zaman da sayı yüz olmaz, üç ya da dört olur.

Kopyala-yapıştır manifest neye benziyor

Büyük projelerde sık gördüğümüz hâl şudur:

.target(
    name: "Search",
    dependencies: [
        "Networking", "DesignSystem", "Analytics", "FoundationKit",
        "HotelDetail",          // "geçici", ekran geçişi için
        .product(name: "Kingfisher", package: "Kingfisher"),
    ],
    path: "Sources/Search",
    resources: [.process("Resources")],
    swiftSettings: [.enableUpcomingFeature("ExistentialAny")]
),
.target(
    name: "HotelDetail",
    dependencies: ["Networking", "DesignSystem", "Booking", "FoundationKit"],
    path: "Sources/HotelDetail"
    // swiftSettings unutulmuş
),
// ... 118 target daha

Burada üç ayrı sorun var ve hiçbiri code review'da yakalanmaz. Search, HotelDetail'in implementation'ına bağlanmış; yani HotelDetail'deki her değişiklik Search'ü yeniden derletir ve bir gün HotelDetail de Search'e ihtiyaç duyduğunda döngü oluşur. Kingfisher doğrudan bir feature'a girmiş. Ayarlar target'tan target'a farklı. Dosya string'lerden oluştuğu için derleyici bunların hiçbirine itiraz etmez.

Manifest Swift'tir: kuralları tiplerle yazın

Package.swift çalıştırılan bir Swift programıdır. Enum, struct, fonksiyon yazabilirsiniz. Biz module'leri tipli bir tabloyla tarif ediyor, target'ları bu tablodan üretiyoruz. Kural basit: bir feature başka bir feature'ı yalnızca uses alanıyla tarif edebilir ve bu alan yalnızca Interface target'ına çevrilir. Feature'dan feature'a implementation bağımlılığı yazmanın söz dizimi yoktur.

// swift-tools-version: 6.2
import PackageDescription

// MARK: - Module tanımları

/// Feature'ların üzerine kurulduğu altyapı. Dışarıdan gelen her kütüphane
/// yalnızca buradaki bir module tarafından sarılır.
enum Core: String, CaseIterable {
    case foundationKit = "FoundationKit"
    case networking = "Networking"
    case persistence = "Persistence"
    case analytics = "Analytics"
    case imageLoading = "ImageLoading"
    case designSystem = "DesignSystem"

    var dependencies: [Core] {
        switch self {
        case .foundationKit: []
        case .networking, .persistence, .analytics: [.foundationKit]
        case .imageLoading: [.foundationKit, .networking]
        case .designSystem: [.foundationKit, .imageLoading]
        }
    }

    var external: [Target.Dependency] {
        switch self {
        case .imageLoading: [.product(name: "Kingfisher", package: "Kingfisher")]
        default: []
        }
    }

    var hasResources: Bool { self == .designSystem }
}

enum FeatureName: String {
    case search = "Search"
    case hotelDetail = "HotelDetail"
    case booking = "Booking"
    case payment = "Payment"
    case profile = "Profile"
    // ... 30 feature daha
}

struct Feature {
    let name: FeatureName
    /// Implementation'ın kullandığı core module'ler.
    var core: [Core] = [.foundationKit, .designSystem]
    /// Interface'in public API'sinde görünen core module'ler. Kısa tutun.
    var interfaceCore: [Core] = [.foundationKit]
    /// Başka feature'lar. Yalnızca Interface target'larına çevrilir.
    var uses: [FeatureName] = []
    var hasResources = false
}

let features: [Feature] = [
    Feature(name: .search, core: [.foundationKit, .designSystem, .networking, .analytics],
            uses: [.hotelDetail], hasResources: true),
    Feature(name: .hotelDetail, core: [.foundationKit, .designSystem, .networking, .imageLoading],
            uses: [.booking], hasResources: true),
    Feature(name: .booking, core: [.foundationKit, .designSystem, .networking, .persistence],
            uses: [.payment, .profile]),
    Feature(name: .payment, core: [.foundationKit, .designSystem, .networking, .analytics]),
    Feature(name: .profile, core: [.foundationKit, .designSystem, .networking, .persistence]),
]

// MARK: - Ortak ayarlar

let settings: [SwiftSetting] = [
    .enableUpcomingFeature("ExistentialAny"),
    .enableUpcomingFeature("InternalImportsByDefault"),
]

extension Target.Dependency {
    static func core(_ module: Core) -> Self { .target(name: module.rawValue) }
    static func interface(_ feature: FeatureName) -> Self { .target(name: feature.rawValue + "Interface") }
    static func testing(_ feature: FeatureName) -> Self { .target(name: feature.rawValue + "Testing") }
}

// MARK: - Target üreticileri

func coreTargets(_ module: Core) -> [Target] {
    let name = module.rawValue
    return [
        .target(
            name: name,
            dependencies: module.dependencies.map { .core($0) } + module.external,
            path: "Core/\(name)/Sources",
            resources: module.hasResources ? [.process("Resources")] : nil,
            swiftSettings: settings
        ),
        .testTarget(
            name: name + "Tests",
            dependencies: [.core(module)],
            path: "Core/\(name)/Tests",
            swiftSettings: settings
        ),
    ]
}

func featureTargets(_ feature: Feature) -> [Target] {
    let name = feature.name.rawValue
    let root = "Features/\(name)"
    return [
        // Sözleşme: protokoller, modeller, route'lar. Başka feature'ı tanımaz.
        .target(
            name: name + "Interface",
            dependencies: feature.interfaceCore.map { .core($0) },
            path: "\(root)/Interface",
            swiftSettings: settings
        ),
        // Implementation: kendi Interface'i, komşuların Interface'i ve core.
        .target(
            name: name,
            dependencies: [.interface(feature.name)]
                + feature.uses.map { .interface($0) }
                + feature.core.map { .core($0) },
            path: "\(root)/Sources",
            resources: feature.hasResources ? [.process("Resources")] : nil,
            swiftSettings: settings
        ),
        // Fake ve fixture'lar. Hiçbir uygulama product'ına girmez.
        .target(
            name: name + "Testing",
            dependencies: [.interface(feature.name)],
            path: "\(root)/Testing",
            swiftSettings: settings
        ),
        .testTarget(
            name: name + "Tests",
            dependencies: [.target(name: name), .testing(feature.name)]
                + feature.uses.map { .testing($0) },
            path: "\(root)/Tests",
            swiftSettings: settings
        ),
    ]
}

// MARK: - Package

let package = Package(
    name: "Modules",
    defaultLocalization: "tr",
    platforms: [.iOS(.v17)],
    products: [
        // Uygulama target'ının bağlandığı tek product.
        .library(
            name: "AppModules",
            targets: features.map(\.name.rawValue) + Core.allCases.map(\.rawValue)
        ),
        // Widget extension yalnızca ihtiyacı olanı alır.
        .library(
            name: "WidgetModules",
            targets: [FeatureName.booking.rawValue + "Interface", Core.designSystem.rawValue]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/onevcat/Kingfisher.git", exact: "8.13.0"),
    ],
    targets: Core.allCases.flatMap(coreTargets) + features.flatMap(featureTargets)
)

Bu manifest'i beş feature ve altı core module ile swift package describe ve xcodebuild üzerinden çalıştırdık: 32 target üretiyor. Otuz beş feature'da aynı dosya 150 target'ı geçer ve yalnızca features tablosu uzar.

Kazandıklarımız somut. Yeni bir feature eklemek tek satırdır. Feature'dan feature'a doğrudan bağımlılık yazılamaz, çünkü uses bir FeatureName alır ve tek çıktısı Interface'tir. Üçüncü parti kütüphane yalnızca Core.external üzerinden girer; Feature tipinde böyle bir alan yoktur. InternalImportsByDefault (SE-0409) bunu derleyici seviyesinde tamamlar: ImageLoading içindeki import Kingfisher varsayılan olarak internal kalır, dolayısıyla Kingfisher tipleri module'ün public API'sine sızamaz. Aynı ayar feature'ların Interface'lerini de ince tutar, çünkü public imzada görünen her module için bilerek public import yazmanız gerekir.

Tablonun yakalayamadığı kurallar da var (örneğin bir Interface'in başka bir Interface'e bağlanıp bağlanamayacağı). Onlar için graph'ı ayrıca denetleyen bir adım gerekir; yöntemleri architecture kurallarını zorunlu kılmak yazısında anlattık.

İsimler, klasörler ve ince uygulama target'ı

Her feature dört target'tır ve isimleri pazarlık konusu olmaz: Search, SearchInterface, SearchTesting, SearchTests. SwiftPM'in varsayılan Sources/<Target> düzeni bu dörtlüyü dört ayrı köke dağıtır. path: parametresiyle hepsini feature'ın kendi klasöründe topluyoruz, çünkü bir feature'ı silen ya da taşıyan kişi tek klasörle uğraşmalı.

TravelApp/
  TravelApp.xcodeproj
  App/                      uygulama target'ı: @main, composition root, Info.plist
  Widget/
  Modules/
    Package.swift
    Core/
      DesignSystem/
        Sources/            (Resources/ burada)
        Tests/
      Networking/ ...
    Features/
      Search/
        Interface/          SearchInterface
        Sources/            Search (ekranlar, view model'ler, preview'lar)
        Testing/            SearchTesting (fake'ler, fixture'lar)
        Tests/              SearchTests
      HotelDetail/ ...
    Scripts/
      affected-tests.swift

Preview'lar, önizledikleri view ile aynı dosyada, implementation target'ında durur. Bir preview başka bir feature'ın fake'ine ihtiyaç duyuyorsa bu genellikle view'ın fazla şey bildiğini gösterir; view'ı düz değerlerle beslenir hâle getirmek, implementation'ı Testing target'larına bağlamaktan daha iyi bir çözümdür. Testing target'ları uygulama product'ına girmemeli, bunu da manifest garanti ediyor.

Uygulama target'ı yalnızca üç şey içerir: giriş noktası, konfigürasyon ve composition root. Interface'lerin implementation'larla eşleştiği tek yer burasıdır.

import Booking
import BookingInterface
import HotelDetail
import HotelDetailInterface
import Networking
import Search
import SwiftUI

@MainActor
final class CompositionRoot {
    private let client = HTTPClient(baseURL: AppConfig.apiBaseURL)

    // Search yalnızca HotelDetailRouting protokolünü tanır; somut tipi burada alır.
    func makeSearch() -> some View {
        SearchScreen(client: client, hotelDetail: hotelDetailRouter)
    }

    private var hotelDetailRouter: any HotelDetailRouting {
        HotelDetailRouter(client: client, booking: BookingRouter(client: client))
    }
}

Bu dosya büyür, bu normaldir. Dört yüz satıra ulaştığında feature grubu başına bir dosyaya bölersiniz; module'lere geri dağıtmazsınız.

Resource'lar, lokalizasyon ve Bundle.module

Resource içeren her target için build sırasında ayrı bir resource bundle ve o module'e özel, internal bir Bundle.module uzantısı üretilir. Asset catalog, .lproj klasörleri, storyboard ve Core Data modelleri otomatik tanınır; font gibi diğer dosyaları resources: ile bildirmeniz gerekir. Manifest'teki .process("Resources") bunu klasör düzeyinde yapıyor.

En sık görülen hata belirtisi tanıdıktır: ekranda search.title yazar. Sebep, bundle parametresi verilmeyen bir çağrının string'i uygulamanın ana bundle'ında aramasıdır. Package içindeki her lookup bundle'ı açıkça söylemek zorunda:

// Search/Sources/SearchScreen.swift
Text("search.title", bundle: .module)
let hint = String(localized: "search.empty.hint", bundle: .module)

defaultLocalization verdiğiniz anda Xcode package'da lokalize resource bekler ve eksik çevirileri build sırasında uyarı olarak gösterir. String'leri feature'ın kendi target'ında tutuyoruz; "tüm string'ler tek module'de" yaklaşımı her metin değişikliğinde bütün feature'ları yeniden derletir.

Renkler, ikonlar ve fontlar DesignSystem'de yaşar. Bundle.module internal olduğu için diğer module'ler bu bundle'a doğrudan ulaşamaz; DesignSystem onları public API olarak sunar. Fontlar ayrı bir dikkat ister: Info.plist'teki UIAppFonts anahtarı uygulamanın kendi bundle'ına bakar, package'ın resource bundle'ındaki dosyaları görmez. Fontları açılışta kendiniz kaydedersiniz.

// DesignSystem/Sources/Fonts.swift
import CoreText
import Foundation
public import SwiftUI

public enum Fonts {
    /// Uygulama açılışında bir kez çağrılır.
    public static func register() {
        let urls = Bundle.module.urls(forResourcesWithExtension: "ttf", subdirectory: nil) ?? []
        for url in urls {
            let registered = CTFontManagerRegisterFontsForURL(url as CFURL, .process, nil)
            assert(registered, "Font kaydedilemedi: \(url.lastPathComponent)")
        }
    }
}

public extension Color {
    static let brandPrimary = Color("BrandPrimary", bundle: .module)
}

public extension Image {
    static let emptyState = Image("EmptyState", bundle: .module)
}

Xcode 15'ten beri package'lardaki asset catalog'lar için de ImageResource ve ColorResource sembolleri üretiliyor. Module içinde bunları kullanın, dışarıya ise yukarıdaki gibi kendi public isimlerinizi verin.

Linking: varsayılan static, tuzak diamond

.library product'ında type vermezseniz seçimi araca bırakırsınız; Apple'ın dokümantasyonu da bunu öneriyor. Pratikteki sonuç şudur: target'lar static olarak derlenir ve product'ı bağlayan binary'nin içine girer. AppModules'ü bağlayan uygulamanız yüz module'ü tek bir executable içinde taşır. Launch süresi açısından bu iyi bir varsayılandır, çünkü dyld'nin yükleyeceği yüz framework yoktur.

Uygulama ile extension'ın aynı module'ü kullanması sorun çıkarmaz ama bedavaya da gelmez. İkisi ayrı process'tir, dolayısıyla sembol çakışması olmaz; DesignSystem kodunun birer kopyası hem uygulamaya hem .appex'e girer. Resource bundle için de aynı şeyi bekleyin ve archive'ınızda .appex içeriğine bakarak doğrulayın. Asset'leri ağır bir design system'i widget'a bağlamadan önce boyutu ölçün; gerekirse widget için küçük bir WidgetKitUI module'ü ayırın.

Asıl tuzak, aynı module'ün aynı process içinde iki kez bulunmasıdır. Uygulama Networking'i static alır, uygulamaya gömülü bir dynamic framework de aynı Networking'i static alırsa bellekte iki kopya olur. Belirtiler tanıdıktır: konsolda Class X is implemented in both ... uyarıları, iki kez yaratılan "singleton"lar, aynı tip olduğu hâlde başarısız olan as? dönüşümleri. Xcode 12.5'ten beri Xcode bu durumu fark ettiğinde package product'ını kendiliğinden dynamic framework olarak derliyor. Bu mantığa güvenip tasarım yapmayın: Xcode 27 beta döneminde tam bu diamond çözümlemesi bozuldu, static kopyalar çoğaldı ve düzeltme swift-build tarafında ayrı bir PR ile geldi. Kullandığınız Xcode sürümünde log'larda bu uyarıyı aratmak iki dakikalık bir kontroldür.

Aynı package içinde bilerek aynı tuzağı kurmak da mümkün: ortak target'ları paylaşan iki .dynamic product tanımlarsanız, package içi target'lar her product'a static olarak girer ve ortak kod iki framework'te de bulunur. Kuralımız kısa:

Büyük package'da type: yazmayın; process başına bir product tanımlayın.

Mergeable library'ler (Xcode 15 ve sonrası) farklı bir sorunu çözer. MERGED_BINARY_TYPE = automatic ile debug build'lerde dynamic framework'ler normal şekilde bağlanır, release build'lerde ise ana binary'ye birleştirilir; yani geliştirirken hızlı link, yayında static'e yakın launch. Dokümantasyona göre otomatik birleştirme, projenizdeki framework ve dylib target'larını ve mergeable metadata taşıyan XCFramework'leri kapsar. Varsayılan hâliyle static bağlanan package target'larında birleştirilecek bir şey yoktur. Mergeable library'ler, module'lerinizi Xcode framework target'larına taşıdığınız gün anlam kazanır; .dynamic package product'larıyla nasıl davrandığını biz doğrulamadık, kendi toolchain'inizde denemeniz gerekir.

Xcode bu ölçekte nerede zorlanır

Package çözümleme süresi, projeyi her açtığınızda ve her branch değişiminde ödediğiniz bedeldir. Tek manifest burada zaten en büyük kazanımdır. İkinci kazanım uzak dependency sayısını düşük tutmak ve hepsini exact: ile sabitlemektir; çözümleyicinin seçecek bir şeyi kalmaz.

Scheme enflasyonu product sayısıyla orantılıdır. İki product, üç scheme. Tek bir feature üzerinde çalışmak isteyen ekip için Modules/.swiftpm/xcode/xcshareddata/xcschemes altında elle oluşturulmuş, paylaşılan birkaç scheme tutuyoruz (örneğin Search ve SearchTests'i derleyen bir Search scheme'i). Her feature için product açmak aynı işi görür ama scheme listesini ve linking yüzeyini büyütür.

SwiftUI preview'ları Xcode 16'dan beri build-and-run ile aynı build ürünlerini paylaşıyor ve seçili scheme'in derlediği target'ları derliyor. Sonucu pratikte hissedersiniz: uygulama scheme'i seçiliyken Search içindeki bir preview, uygulamanın tamamının güncel olmasını bekler. Dar scheme seçin, preview yalnızca o module'ün bağımlılık kapanışını derlesin. Interface target'larının değeri burada da görünür: Search'ün kapanışında HotelDetail'in ekranları yoktur, yalnızca birkaç protokolü vardır.

Indexing için elimizde sihirli bir ayar yok. Index DerivedData'da tutulur; "bir şey bozuldu, DerivedData'yı silelim" alışkanlığı yüz module'lü projede her seferinde tam bir yeniden index'leme demektir. Daha etkili olan, Package.swift'e gereksiz yere dokunmamaktır: manifest değişince Xcode graph'ı baştan yükler.

Build tarafında Xcode 26 ile gelen compilation caching (COMPILATION_CACHE_ENABLE_CACHING, varsayılan olarak kapalı) branch değiştirme ve temiz build senaryolarını hedefliyor. Açıp kendi projenizde xcodebuild -showBuildTimingSummary ile önce ve sonra ölçün; rakam vermiyoruz çünkü sonuç module yapınıza bağlı.

Üçüncü parti: sabitleyin, sarın, macro faturasını bilin

Sürümleri manifest'te exact: ile yazıyor ve Package.resolved dosyasını commit ediyoruz. Uygulama projesi için bu dosya TravelApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/ altındadır. CI'da -disableAutomaticPackageResolution ile build, dosyadaki sürümlerden sapmaz.

Sarmalama kuralını manifest zaten uyguluyor: her dış kütüphane tek bir core module'ün arkasındadır. Bunun faydası değiştirilebilirlikten önce derleme yüzeyidir. Kingfisher'ı otuz feature import ederse sürüm yükseltmesi otuz module'ü ilgilendirir; yalnızca ImageLoading import ederse bir module'ü.

Binary target'lar (.binaryTarget(name:path:) ya da url ve checksum ile) kapalı kaynak SDK'lar için kaçınılmazdır. Onları da sarın ve XCFramework'ün içinde ne olduğuna bakın: içine yaygın bir açık kaynak kütüphaneyi gömmüş dynamic bir SDK, yukarıdaki diamond sorununun en zor ayıklanan hâlidir.

// Core enum'una yeni bir case ve manifest'e tek bir target:
.binaryTarget(name: "PaymentSDK", path: "Binaries/PaymentSDK.xcframework"),
// Feature'lar PaymentSDK'yı görmez; yalnızca "PaymentGateway" core module'ü ona bağlanır.

Macro'lar ayrı bir kalemdir. Bir macro package'ı graph'a swift-syntax'i getirir ve onu kaynaktan derlemek temiz bir build'in en uzun adımlarından biri olabilir. Xcode 26'dan beri Xcode, macro target'ları için swift.org'dan önceden derlenmiş bir swift-syntax indiriyor ve bu varsayılan olarak açık. Swift 6.4 komut satırında da --enable-experimental-prebuilts varsayılan durumda, adı hâlâ "experimental" olsa da. Sınırlarını bilin: prebuilt yalnızca macro'lar için kullanılır, çözümlenen swift-syntax sürümü ve derleyici için yayınlanmış bir prebuilt varsa devreye girer, yoksa SwiftPM sessizce kaynaktan derlemeye döner. Hangisinin olduğunu build log'unda SwiftSyntax module'lerinin derlenip derlenmediğine bakarak görürsünüz. Graph'taki bütün macro package'ları tek bir swift-syntax sürümünde buluşmak zorunda olduğu için, her yeni macro dependency'si bir sürüm kısıtı daha demektir. CI'da macro'lara güven onayı için -skipMacroValidation gerekir; bu bayrağı yalnızca sürümleri sabitlenmiş dependency'lerle kullanın.

Bizim ölçütümüz: bir macro package'ı, sildiği boilerplate proje genelinde gerçekten yaygınsa girer. Üç yerde kullanılan bir macro için bu maliyeti ödemeyiz.

Testler: yalnızca etkilenen module'ler

Her module'ün kendi test target'ı var ve hepsi uygulama projesindeki tek bir test plan'e (Modules.xctestplan) ekli. Module'ler UIKit ve SwiftUI kullandığı için testleri swift test ile macOS üzerinde koşturamazsınız, xcodebuild ve bir simulator gerekir.

Pull request'te yüz elli test target'ının hepsini çalıştırmak gereksizdir. Yaklaşım basit: değişen dosyalardan target'ları bul, package graph'ında ters yönde yürü, yolun üzerindeki test target'larını seç. swift package describe --type json her target'ın path, type ve target_dependencies alanlarını verir; gerisi kırk satır Swift.

// Kullanım: git diff --name-only origin/main...HEAD | swift Modules/Scripts/affected-tests.swift Modules
import Foundation

struct Description: Decodable {
    struct Target: Decodable {
        let name: String
        let path: String
        let type: String
        let target_dependencies: [String]?
    }
    let targets: [Target]
}

let packageDir = CommandLine.arguments.dropFirst().first ?? "Modules"
var changed: [String] = []
while let line = readLine() { changed.append(line) }

// `swift package describe --type json` çıktısını oku.
let process = Process()
let pipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["swift", "package", "--package-path", packageDir, "describe", "--type", "json"]
process.standardOutput = pipe
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let targets = try JSONDecoder().decode(Description.self, from: data).targets
let tests = targets.filter { $0.type == "test" }.map(\.name).sorted()

// Manifest, lockfile ya da package dışındaki bir dosya değiştiyse her şeyi çalıştır.
let prefix = packageDir + "/"
let global = changed.contains { file in
    !file.hasPrefix(prefix) || file.hasSuffix("Package.swift") || file.hasSuffix("Package.resolved")
}

var affected = Set<String>()
if !global {
    // 1. Değişen dosyaların ait olduğu target'lar.
    for target in targets {
        let root = prefix + target.path + "/"
        if changed.contains(where: { $0.hasPrefix(root) }) { affected.insert(target.name) }
    }
    // 2. Ters bağımlılıklar: etkilenen bir target'a bağlı olan herkes etkilenir.
    var grew = true
    while grew {
        grew = false
        for target in targets where !affected.contains(target.name) {
            if target.target_dependencies?.contains(where: affected.contains) == true {
                affected.insert(target.name)
                grew = true
            }
        }
    }
}

let selected = global ? tests : tests.filter(affected.contains)
print(selected.map { "-only-testing:\($0)" }.joined(separator: " "))

Örnek package'da Features/Payment/Sources altındaki bir değişiklik yalnızca PaymentTests'i seçiyor. Features/Payment/Interface değişince BookingTests de listeye giriyor, çünkü Booking o sözleşmeyi kullanıyor. Interface'leri küçük ve durağan tutmanın CI'daki karşılığı budur. Bu script bilerek kabadır: emin olmadığı her durumda (manifest, lockfile, uygulama kodu) bütün testleri seçer.

CI: neyi cache'liyoruz, neyi baştan derliyoruz

set -euo pipefail

TESTS=$(git diff --name-only origin/main...HEAD | swift Modules/Scripts/affected-tests.swift Modules)
if [ -z "$TESTS" ]; then
  echo "Etkilenen test target'ı yok."   # boş argümanla xcodebuild her şeyi koşturur
  exit 0
fi

# $TESTS bilerek tırnaksız: her -only-testing ayrı argüman olmalı.
xcodebuild test \
  -project TravelApp.xcodeproj -scheme TravelApp -testPlan Modules \
  -destination 'platform=iOS Simulator,name=iPhone 17' \
  -clonedSourcePackagesDirPath .build/source-packages \
  -disableAutomaticPackageResolution \
  -skipMacroValidation \
  $TESTS

Cache'lediğimiz ilk şey -clonedSourcePackagesDirPath ile verdiğimiz klasördür; anahtarı Package.resolved dosyasının hash'idir. Ucuzdur, güvenlidir ve her job'da dependency'leri yeniden klonlamayı önler.

DerivedData'yı tek kullanımlık runner'lar arasında taşımak konusunda temkinliyiz. Taze bir checkout'ta dosya zaman damgaları değişir ve incremental build'in ne kadarının geçerli kalacağı belirsizdir; büyük bir arşivi indirip açmak, kazandırdığından fazlasına mal olabilir. Bunu varsayım olarak bırakmayın: bir hafta boyunca cache'li ve cache'siz job sürelerini yan yana kaydedin. Kalıcı runner'larınız varsa DerivedData'yı yerinde bırakmak çoğu zaman en basit kazançtır. İçerik adresli çalışan compilation caching, bu sorun için tasarlanmış araçtır ve değerlendirmeye ilk onu alırız.

Strateji olarak iki hat yürütüyoruz. Pull request'lerde incremental ve seçici: etkilenen testler, cache'li dependency'ler. main üzerinde gecelik olarak temiz build ve bütün testler. Gecelik hat, seçici hattın kaçırdığını ve cache'in gizlediğini yakalar; ikisi birbirinin sigortasıdır.

Mevcut projeyi adım adım taşımak

  1. Boş bir Modules package'ı oluşturun, yalnızca FoundationKit'i taşıyın ve uygulamayı AppModules product'ına bağlayın. Bu adım linking ve resource varsayımlarınızı tek module üzerinde sınar.
  2. Onuncu target'tan önce helper'ları yazın. Kopyala-yapıştır yüz target'ı sonradan tabloya çevirmek mümkün ama sıkıcıdır.
  3. Core module'leri taşıyın, sonra en çok bağımlılık alan feature'dan başlayarak Interface çıkarın. Her çıkarılan Interface, bir feature'dan feature'a bağımlılığı siler.
  4. uses dışında bağımlılık yazılamadığı gün eski dependencies: ["HotelDetail"] satırları derlenmez hâle gelir. Bu, işin bittiğini gösteren işarettir.
  5. Ölçün: aynı makinede temiz build süresi, tek bir feature dosyasını değiştirdikten sonraki incremental build süresi ve pull request başına koşan test target sayısı. Üçünü taşımadan önce de kaydedin.

Düz SPM'in yetmediği yer

SwiftPM'in bilmediği şeyler var. Build konfigürasyonu olarak yalnızca debug ve release tanır; Staging gibi özel konfigürasyonlara göre koşul yazamazsınız. Uygulama, extension ve feature başına demo uygulama gibi target'lar package'da tarif edilemez; onlar .xcodeproj içinde yaşar ve sayıları arttıkça proje dosyası merge conflict kaynağına dönüşür. Linking üzerinde ince kontrol (hangi module dynamic, hangisi mergeable) product düzeyiyle sınırlıdır. Module'leri önceden derlenmiş binary olarak cache'lemenin de yerleşik bir yolu yoktur.

Bu sınırlardan ikisine birden dayandığınızda proje üretimine geçiyoruz: module tablosu aynı kalır, target'lar Tuist ya da XcodeGen ile framework target'ı olarak üretilir, üçüncü parti dependency'ler SwiftPM'de kalır. Tablonuz zaten tipli veri olduğu için geçiş mekaniktir. Seçenekleri Tuist, SPM ve xcodeproj karşılaştırmasında ayrıntılı ele aldık. Sinyaller şunlardır: feature ekipleri demo uygulama istiyor, temiz build süresi binary cache olmadan kabul edilemez hâle geldi ya da debug link süresi incremental build'in büyük kısmını oluşturuyor. Bunlardan hiçbiri yoksa düz SPM ile kalın; en az hareketli parçası olan kurulum odur.

Kontrol listesi

  • Tek bir local package var; module'ler target, uygulamaya açılan kapı process başına bir product.
  • Package.swift target'ları tipli bir tablodan üretiyor; feature'dan feature'a implementation bağımlılığı yazılamıyor.
  • Her feature dört target: X, XInterface, XTesting, XTests; hepsi tek klasörde.
  • Testing target'ları hiçbir uygulama product'ında yok.
  • Ortak swiftSettings tek yerde; InternalImportsByDefault açık.
  • Package içindeki her string, renk ve görsel lookup'ı bundle: .module kullanıyor; fontlar açılışta kaydediliyor.
  • Hiçbir product'ta type: yok; log'larda implemented in both uyarısı aranıp temiz bulundu.
  • Extension'a bağlanan module'lerin .appex boyutuna etkisi ölçüldü.
  • Üçüncü parti sürümler exact: ile sabit, Package.resolved commit'li, her biri tek bir core module'ün arkasında.
  • Macro kullanan dependency'ler için swift-syntax'in prebuilt mi kaynaktan mı geldiği build log'unda kontrol edildi.
  • Pull request'ler yalnızca etkilenen test target'larını, gecelik hat temiz build ile hepsini çalıştırıyor.
  • Temiz ve incremental build süreleri kayıtlı; proje üretimine geçiş kararı bu rakamlara bakılarak verilecek.
Seviye 15 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: SSL pinning: ne zaman gerekli, nasıl doğru yapılır, nasıl kırılır?

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.