Lektion 4.14 · Stufe 4 – Profi

Modularisierung mit Swift Packages

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du mit Swift Packages arbeiten: fremde Pakete einbinden und Versionen sinnvoll festlegen, eigene lokale Pakete anlegen, dein Projekt in Module wie Designsystem, Netzwerk und Features aufteilen, Sichtbarkeit mit public und package steuern und Ressourcen sowie Tests in Paketen unterbringen.

Das Problem: Alles in einem großen Topf

Am Anfang besteht eine App aus ein paar Dateien in einem Ordner. Nach ein paar Monaten sind es hundert. Alles kann alles sehen: Die Rezeptliste greift direkt auf Hilfsfunktionen der Netzwerkschicht zu, das Designsystem kennt plötzlich ein Modell aus der Datenbank, und eine kleine Änderung an einer Datei lässt Xcode die halbe App neu kompilieren.

Typische Folgen:

  • Unübersichtlich: Wo gehört eine neue Datei hin? Wovon hängt was ab?
  • Langsame Builds: Jede Änderung kann viel neu kompilieren.
  • Ungewollte Abhängigkeiten: Teile, die nichts voneinander wissen sollten, sind eng verflochten.
  • Kein Wiederverwenden: Dein schönes Designsystem aus Lektion 3.29 steckt in einer App und lässt sich nicht in die nächste mitnehmen.

Die Lösung ist Modularisierung: die App in unabhängige Bausteine aufteilen, die klare Grenzen haben. In der Swift-Welt sind diese Bausteine Swift Packages.

Was ist ein Swift Package?

Ein Swift Package ist ein Ordner mit Swift-Code und einer Beschreibungsdatei namens Package.swift. Diese Datei legt fest:

  • wie das Paket heißt,
  • für welche Plattformen es gedacht ist,
  • welche Module (Targets) es enthält,
  • was es nach außen anbietet (Products),
  • und wovon es selbst abhängt.

Pakete kannst du auf zwei Arten nutzen: fremde Pakete einbinden, die andere geschrieben haben, und eigene Pakete anlegen, um dein Projekt aufzuteilen.

Fremde Pakete einbinden

Viele nützliche Bibliotheken gibt es als Swift Package, oft auf GitHub. So bindest du eines ein:

  1. Datei → Add Package Dependencies …
  2. Gib oben rechts die Adresse des Pakets ein, etwa https://github.com/apple/swift-collections.
  3. Wähle eine Dependency Rule (dazu gleich mehr) und klick auf Add Package.
  4. Wähle die gewünschten Bibliotheken und das Target, also deine App, und bestätige.

Danach kannst du sie mit import verwenden:

Swift
import Collections

var recent = Deque<String>()
recent.prepend("Pfannkuchen")

Xcode lädt das Paket herunter und zeigt es im Projektnavigator unter Package Dependencies.

Versionen festlegen

Pakete haben Versionsnummern nach dem Schema Major.Minor.Patch, etwa 1.4.2:

  • Patch (1.4.2): Fehlerbehebungen. Nichts ändert sich an der Verwendung.
  • Minor (1.4.0): Neue Funktionen. Bestehender Code funktioniert weiter.
  • Major (2.0.0): Größere Änderungen. Bestehender Code muss eventuell angepasst werden.

Die Dependency Rule bestimmt, welche Versionen Xcode verwenden darf:

RegelBedeutung
Up to Next Major Versionab 1.4.0, aber unter 2.0.0. Der empfohlene Standard.
Up to Next Minor Versionab 1.4.0, aber unter 1.5.0. Vorsichtiger.
Exact Versiongenau 1.4.2. Nur in Ausnahmefällen.
Branch / Commitein bestimmter Stand. Für Experimente, nicht für veröffentlichte Apps.

Die tatsächlich verwendeten Versionen hält Xcode in einer Datei Package.resolved fest. Sie gehört ins Git-Repository, damit alle im Team und deine Build-Server dieselben Versionen verwenden. Aktualisieren kannst du mit Datei → Packages → Update to Latest Package Versions.

Fremde Pakete mit Bedacht wählen

Jedes Paket ist Code, den du nicht selbst geschrieben hast, der aber in deiner App läuft. Prüfe vorher: Wird es noch gepflegt? Wie viele Leute verwenden es? Was steht in der Lizenz? Brauchst du es wirklich, oder reichen ein paar Zeilen eigener Code? Viele Funktionen, für die man früher Bibliotheken brauchte, sind heute in SwiftUI und Foundation eingebaut.

Eigene Pakete: Das Projekt aufteilen

Der zweite, mindestens so wichtige Einsatz: Du teilst dein eigenes Projekt in Pakete auf.

Wie aufteilen?

Eine bewährte Aufteilung für eine mittelgroße App:

Text
Rezepte (App-Target)
├── RecipeKit (lokales Paket)
│   ├── DesignSystem      Farben, Abstände, Komponenten
│   ├── Models            Datentypen wie Meal, Category
│   ├── MealAPI           Netzwerkschicht, hängt von Models ab
│   └── RecipeFeature     Bildschirme, hängt von allem oben ab

Die Regel: Abhängigkeiten zeigen nur in eine Richtung, von oben nach unten. DesignSystem weiß nichts von MealAPI. Models weiß nichts von Views. Das App-Target selbst wird ganz dünn: Es stellt nur noch alles zusammen (Lektion 4.13).

Ein lokales Paket anlegen

  1. Datei → Neu → Package …
  2. Wähle Library, nenne es RecipeKit und speichere es im Ordner deines Projekts.
  3. Wähle unten bei Add to dein Projekt und bei Group die oberste Ebene.

Xcode legt einen Ordner mit Package.swift, einem Ordner Sources und einem Ordner Tests an.

Package.swift

Ersetze den Inhalt von Package.swift durch:

Swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "RecipeKit",
    platforms: [.iOS(.v18)],
    products: [
        .library(name: "DesignSystem", targets: ["DesignSystem"]),
        .library(name: "Models", targets: ["Models"]),
        .library(name: "MealAPI", targets: ["MealAPI"])
    ],
    targets: [
        .target(name: "DesignSystem", resources: [.process("Resources")]),
        .target(name: "Models"),
        .target(name: "MealAPI", dependencies: ["Models"]),
        .testTarget(name: "MealAPITests", dependencies: ["MealAPI"])
    ]
)
  • swift-tools-version: 6.0 in der ersten Zeile ist kein Kommentar, sondern Pflicht: Sie legt fest, welche Version der Paket-Beschreibung gilt. Ab 6.0 kompilieren Pakete standardmäßig im Swift-6-Sprachmodus mit voller Concurrency-Prüfung (Lektion 3.8).
  • platforms: Mindestversion. Ohne Angabe verwendet Swift eine sehr alte Version, und moderne SwiftUI-APIs wären nicht verfügbar.
  • targets: die Module. Jedes Target ist ein Ordner unter Sources mit demselben Namen, etwa Sources/MealAPI/.
  • dependencies eines Targets: Wovon hängt es ab? MealAPI braucht Models.
  • products: was das Paket nach außen anbietet. Nur Products kann die App einbinden.

Das Paket in der App verwenden

  1. Klick im Projektnavigator auf das Projekt und wähle das App-Target.
  2. Im Reiter General unter Frameworks, Libraries, and Embedded Content auf + klicken.
  3. Die gewünschten Bibliotheken, etwa DesignSystem und MealAPI, auswählen.

Danach in der App:

Swift
import SwiftUI
import DesignSystem
import MealAPI

Sichtbarkeit: public, package, internal

Jetzt kommt die Stelle, an der fast jeder beim ersten Mal stolpert. Du verschiebst Card aus Lektion 3.29 in das Modul DesignSystem und versuchst es in der App zu verwenden:

Text
Cannot find 'Card' in scope

Der Grund: In Swift ist alles standardmäßig internal, also nur innerhalb des eigenen Moduls sichtbar. Solange alles in der App lag, war das egal. Jetzt ist das Designsystem ein eigenes Modul, und die App kann nur sehen, was ausdrücklich freigegeben ist.

StufeSichtbar …
privatenur im selben Typ bzw. Abschnitt
fileprivatenur in derselben Datei
internal (Standard)im selben Modul
packagein allen Modulen desselben Pakets
publicüberall, wo das Modul importiert wird

Damit die App Card verwenden kann:

Swift
public struct Card<Content: View>: View {
    let content: Content

    public init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    public var body: some View {
        VStack(alignment: .leading, spacing: Spacing.s) {
            content
        }
        .padding(Spacing.m)
        .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: Radius.medium))
    }
}

Drei Dinge brauchen public:

  1. der Typ selbst,
  2. der Initializer: Den automatisch erzeugten Initializer eines Structs gibt es nur internal. Für andere Module musst du ihn selbst schreiben und public machen,
  3. body, weil View es verlangt.

Gute Grenzen ziehen

Das ist kein lästiger Zwang, sondern der eigentliche Gewinn: Du entscheidest bewusst, was die Schnittstelle eines Moduls ist. Alles andere bleibt Implementierungsdetail, das du jederzeit ändern kannst, ohne dass irgendetwas außerhalb kaputtgeht.

package ist der Mittelweg: Ein Typ, den MealAPI und Models gemeinsam brauchen, den die App aber nicht sehen soll, wird package.

Ressourcen in Paketen

Bilder, Farben, JSON-Dateien und Schriften legst du in einen Ordner im Target, etwa Sources/DesignSystem/Resources/, und meldest ihn in Package.swift mit resources: [.process("Resources")] an.

Im Code greifst du über Bundle.module darauf zu, nicht über Bundle.main:

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

let url = Bundle.module.url(forResource: "sample", withExtension: "json")

Bundle.module wird automatisch für jedes Target mit Ressourcen erzeugt und zeigt auf dessen Ressourcen.

Tests und Previews in Paketen

Jedes Modul kann eigene Tests haben: ein testTarget in Package.swift und ein passender Ordner unter Tests. Das ist einer der größten Vorteile: Die Tests für MealAPI laufen in Sekunden, ohne dass die ganze App gebaut werden muss.

Swift
import Testing
@testable import MealAPI

@Test func decodesMeal() throws { ... }

Auch Previews funktionieren in Paketen. Wähle dafür oben in Xcode das Schema des Pakets (etwa DesignSystem) statt der App. Weil nur das kleine Modul kompiliert wird, sind Previews dort oft deutlich schneller.

Typische Fehler

Cannot find 'X' in scope nach dem Verschieben

Typ, Initializer oder benötigte Eigenschaften sind nicht public. Oder die Bibliothek ist nicht unter Frameworks, Libraries, and Embedded Content eingetragen.

'X' is only available in iOS 17 or newer

platforms fehlt in Package.swift oder nennt eine zu alte Version.

Bild oder Farbe aus dem Paket wird nicht gefunden

Der Zugriff geht über Bundle.main statt Bundle.module, oder der Ressourcen-Ordner ist nicht in Package.swift angemeldet.

Kreisförmige Abhängigkeit

Models hängt von MealAPI ab und MealAPI von Models. Das erlaubt Swift nicht. Zieh das, was beide brauchen, in ein drittes Modul, oder dreh eine der Abhängigkeiten um, etwa mit einem Protokoll.

FAQ

Anfänger

Was bedeutet das überhaupt?

Ab wann lohnt sich Modularisierung? Ein erstes Paket für das Designsystem lohnt sich früh, weil du es in jeder weiteren App wiederverwenden kannst. Die ganze App aufzuteilen, lohnt sich ab mittlerer Größe oder sobald mehrere Leute daran arbeiten. Für kleine Übungsapps ist es nicht nötig.

Was ist mit CocoaPods und Carthage? Das sind ältere Paketverwaltungen, die du in bestehenden Projekten noch sehen wirst. Für neue Projekte ist der Swift Package Manager der Standard, direkt in Xcode eingebaut.

Fortgeschritten

Warum verhält es sich so?

Kann ich SwiftData-Modelle in ein Paket legen? Ja. Die @Model-Klassen und ihre Eigenschaften müssen dann public sein, inklusive Initializer. Die .modelContainer-Einrichtung bleibt meist in der App.

Wie teile ich ein Paket zwischen mehreren Apps? Leg es in ein eigenes Git-Repository und binde es in beiden Apps über Add Package Dependencies mit der Repository-Adresse ein. Für private Repositorys fragt Xcode nach deinen Zugangsdaten bzw. verwendet deinen GitHub-Account aus den Xcode-Einstellungen.

Profi

Was passiert unter der Haube?

Wie wirkt sich Modularisierung auf Build-Zeiten aus? Xcode kompiliert nur Module neu, deren Code sich geändert hat oder deren öffentliche Schnittstelle sich bei einer Abhängigkeit geändert hat. Änderungen an internal-Code eines Moduls zwingen abhängige Module nicht zum Neukompilieren. Je kleiner die öffentliche Schnittstelle, desto mehr profitierst du. Zusätzlich können unabhängige Module parallel kompiliert werden.

Was sind Paket-Plugins? Pakete können Plugins enthalten, die beim Bauen Code erzeugen oder prüfen, etwa einen Formatierer oder einen Generator für Typen aus einer Schnittstellenbeschreibung. Sie werden in Package.swift mit .plugin deklariert und laufen in einer abgeschirmten Umgebung.

Übungen

Anfänger

Übung 1: Ein fremdes Paket

Binde https://github.com/apple/swift-collections in ein Testprojekt ein, mit der Regel Up to Next Major Version. Verwende ein OrderedSet für eine Liste zuletzt angesehener Rezepte, in der kein Rezept doppelt vorkommt und die Reihenfolge erhalten bleibt.

Fortgeschritten

Übung 2: Das Designsystem auslagern

Leg ein lokales Paket AppDesign mit einem Target DesignSystem an. Verschiebe Spacing, Radius, Card und den PrimaryButtonStyle aus Projekt E hinein und mach sie public. Verwende sie wieder in der App.

Lösung Übung 2Erst selbst probieren!

Package.swift:

Swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "AppDesign",
    platforms: [.iOS(.v18)],
    products: [.library(name: "DesignSystem", targets: ["DesignSystem"])],
    targets: [.target(name: "DesignSystem")]
)

Sources/DesignSystem/Spacing.swift:

Swift
import CoreGraphics

public enum Spacing {
    public static let xs: CGFloat = 4
    public static let s: CGFloat = 8
    public static let m: CGFloat = 16
    public static let l: CGFloat = 24
}

Sources/DesignSystem/PrimaryButtonStyle.swift:

Swift
import SwiftUI

public struct PrimaryButtonStyle: ButtonStyle {
    public init() {}

    public func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.headline)
            .foregroundStyle(.white)
            .padding(.vertical, Spacing.m - 2)
            .frame(maxWidth: .infinity)
            .background(.tint, in: .rect(cornerRadius: 14))
            .scaleEffect(configuration.isPressed ? 0.97 : 1)
    }
}

public extension ButtonStyle where Self == PrimaryButtonStyle {
    static var primary: PrimaryButtonStyle { PrimaryButtonStyle() }
}

Statt der Asset-Farbe der App verwendet der Stil hier .tint, also die Akzentfarbe der App, in die er eingebunden wird. So bleibt das Paket unabhängig von einer bestimmten App.

Profi

Übung 3: Die Rezept-App modularisieren

Teile die Rezept-App aus Projekt D in ein Paket RecipeKit mit den Modulen Models, MealAPI und RecipeFeature auf. MealAPI darf nur von Models abhängen, RecipeFeature von beiden. Verschiebe die Tests für das Dekodieren in MealAPITests und führe sie im Paket-Schema aus. Wie viel schneller laufen sie als im App-Target?

Zusammenfassung

  • Swift Packages sind Bausteine mit einer Package.swift, die Targets, Products, Plattformen und Abhängigkeiten beschreibt.
  • Fremde Pakete über Add Package Dependencies, mit der Regel Up to Next Major Version. Package.resolved gehört ins Repository.
  • Eigene Pakete teilen die App in Module mit Abhängigkeiten in nur eine Richtung.
  • Standard ist internal: Was andere Module sehen sollen, muss public sein, inklusive Initializer. package für gemeinsame Teile innerhalb eines Pakets.
  • Ressourcen über Bundle.module, Tests und Previews direkt im Paket.
  • platforms nicht vergessen, ab swift-tools-version: 6.0 gilt der Swift-6-Modus.

Spickzettel

Swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "RecipeKit",
    platforms: [.iOS(.v18)],
    products: [.library(name: "MealAPI", targets: ["MealAPI"])],
    dependencies: [
        .package(url: "https://github.com/apple/swift-collections", from: "1.1.0")
    ],
    targets: [
        .target(name: "Models"),
        .target(name: "MealAPI", dependencies: [
            "Models",
            .product(name: "Collections", package: "swift-collections")
        ]),
        .testTarget(name: "MealAPITests", dependencies: ["MealAPI"])
    ]
)

public struct Card: View {
    public init() {}
    public var body: some View { ... }
}

Color("Brand", bundle: .module)

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.