Zum Inhalt springen

2.000 Imports: Meine SwiftPM-Module organisieren

Wie du die Swift-Module deiner Apps für Klarheit und Komfort organisieren kannst – mithilfe eines versteckten (inoffiziellen) Swift-Features. Eine praktische Lösung für kleine bis mittelgroße Apps.

2.000 Imports: Meine SwiftPM-Module organisieren

Das Problem

Ich habe mich kürzlich entschieden, am größten Feature für RemafoX bisher zu arbeiten, und während ich überlegte, wo ich anfangen soll, fand ich mich in über 70 Targets wieder – bei einem weniger als ein Jahr alten Projekt. Ich modularisiere meine App für klare Code-Trennung und schnellere Build-Zeiten (= schnellere SwiftUI Previews, Tests und mehr) mit dem klassischen SwiftPM-basierten Ansatz, den Point-Free in dieser kostenlosen Episode vorgestellt hat. Da ich vorhabe, über Jahre an dieser App zu arbeiten (die 27 Features online sind nur die Spitze des Eisbergs, ich habe noch viel mehr Ideen), habe ich mich entschlossen, erst mal aufzuräumen. Denn eine Runde Refactoring zwischen Features hält die Codebasis sauber und macht den Entwickler glücklich! 😇

Ich erinnerte mich daran, dass ich das @_exported-Attribut entdeckt hatte, als ich Swift-Evolution-Threads durchlas, um eine der Ausgaben meines zugehörigen Newsletters vorzubereiten. Obwohl es nicht empfohlen wird, APIs mit Unterstrich zu verwenden, da sich ihr Verhalten ändern oder sie sogar komplett entfernt werden könnten, fehlten mir Alternativen für mein Ziel, die vielen unorganisierten Targets aufzuräumen. Genau deshalb habe ich mich davon überzeugt, dass die Chancen, dass dieses Attribut komplett entfernt wird, relativ gering sind. Wenn überhaupt, glaube ich, dass der zugehörige Pitch irgendwann aufgegriffen wird und seinen Weg ins offizielle Swift findet, sodass wir @_exported mit dem dann gewählten Namen ersetzen können. Außerdem habe ich herausgefunden, dass Point-Free in The Composable Architecture ebenfalls auf dieses Attribut setzt, einem Framework, von dem meine App bereits stark abhängt. Warum also nicht voll darauf setzen?

Kurz gesagt hilft das Attribut bei Folgendem: Stell dir vor, du hast 10 Feature-Module und 5 Helfer-Module. In jeder Datei der 10 Features importiere ich tendenziell alle (oder die meisten) dieser 5 Helfer-Module, was zu so etwas führt:

import Assets
import Analytics
import ComposableArchitecture
import Constants
import Defaults
import HandySwift
import HelpfulErrorUI
import ReusableUI
import SFSafeSymbols
import SwiftUI
import Utility

Und das wiederholt sich immer wieder. Ok, es stimmt, dass nicht alle in jeder einzelnen Datei eines Targets gebraucht werden, aber die Wahrheit ist auch, dass Xcode das gesamte Modul ohnehin linkt, sobald eine einzige Datei im Modul es importiert – sie also in allen Dateien zu importieren, würde die Build-Zeiten nicht beeinflussen (soweit ich weiß). Mit @_exported import können wir all diese Imports kombinieren, indem wir ein neues Target erstellen, es z.B. CoreDependencies nennen und eine Swift-Datei darin mit folgendem Inhalt anlegen:

@_exported import Assets
@_exported import Analytics
@_exported import ComposableArchitecture
@_exported import Constants
@_exported import Defaults
@_exported import HandySwift
@_exported import HelpfulErrorUI
@_exported import ReusableUI
@_exported import SFSafeSymbols
@_exported import SwiftUI
@_exported import Utility

Jetzt importiert import CoreDependencies automatisch auch alle anderen Module!

Aber ist es wirklich die richtige Lösung, alles in eine Gruppe namens CoreDependencies zu packen? Ein weiteres Problem neben den zu oft wiederholten Imports ist, dass ich aktuell all diese 70 Module alphabetisch sortiere, weil mir eine andere Art von Gruppierung oder Struktur fehlt. Dieses Fehlen einer Gruppierung macht es nicht nur schwieriger, das richtige Modul zu finden, wenn ich ungefähr weiß, was ich suche, aber den genauen Namen nicht mehr kenne. Es kann auch zu zirkulären Abhängigkeiten führen, wenn man an Features arbeitet und dabei möglichst viel Code wiederverwenden will. Es erfordert strategische Planung, was wohin gehört, um Code wiederzuverwenden und gleichzeitig zyklische Abhängigkeiten zu vermeiden, die zu Kompilierfehlern führen.

Die Lösung

✨ UPDATE: Für neue/kleinere Apps verwende ich eine vereinfachte Version dessen, was ich unten im Detail beschreibe. Siehe mein FlineDevKit-Repository für mehr.

Der beste Weg, eine praktische Lösung für ein Problem zu finden, ist, sich ein reales Beispiel anzuschauen. Hier also eine Auswahl von Modulen, die ich tatsächlich in RemafoX verwende:

Analytics
Assets
BetterCodable
CommandLineSetup
ComposableArchitecture
Constants
FilesSearch
Foundation
HandySwift
HelpfulErrorUI
MachineTranslation
Paywall
ProjectsBrowser
ReusableUI
SFSafeSymbols
Settings
SwiftUI
Utility

Ja, ich habe auch Foundation und SwiftUI in der Liste oben aufgeführt. Warum? Weil sie am Ende des Tages eben auch Dependencies sind, die importiert werden müssen, genau wie jede andere Dependency, die wir importieren, ob extern oder intern. Ich betrachte sie als eingebaute externe Dependencies. Du bist es vielleicht gewohnt, mindestens import Foundation in jeder Swift-Datei zu haben, aber tatsächlich kannst du Swift-Code auch ohne Foundation schreiben – du hast dann eben nur die grundlegenden Swift-Features inklusive allem, was in der Swift Standard Library enthalten ist. Das funktioniert!

Und diese beiden Imports, die wir alle so oft machen, repräsentieren tatsächlich eine Apple-interne Gruppierung von Features/Helfern: Apple bündelt eine ganze Menge Funktionalität hinter Foundation, und das Gleiche macht Apple mit SwiftUI oder UIKit/AppKit. Der entscheidende Faktor scheint zu sein, dass alles, was eine Art UI darstellt oder direkt mit UI zusammenhängt, in eine Gruppe gehört, und alles, was keine UI darstellt oder nicht direkt mit UI zusammenhängt, in eine andere. Das Naheliegendste wäre also, ihrem Beispiel zu folgen – wir könnten sogar ihre Benennung übernehmen, indem wir Foundation für die Nicht-UI-Gruppe und UI (das sowohl in SwiftUI als auch in UIKit vorkommt) für die UI-Gruppe verwenden. Da unsere Gruppen spezifisch für eine App-Domäne sind, wären die resultierenden Namen für unsere Gruppen: AppFoundation und AppUI.

Wenden wir das mal auf die obige Modulliste an:

// AppFoundation
Analytics
BetterCodable
CommandLineSetup
Constants
FilesSearch
Foundation
HandySwift
MachineTranslation
Utility

// AppUI
Assets
ComposableArchitecture
HelpfulErrorUI
Paywall
ProjectsBrowser
SFSafeSymbols
ReusableUI
Settings
SwiftUI

Das sieht schon besser aus. Aber es gibt noch etwas, das wir von Apples Framework-Struktur lernen können: Apple linkt nicht jedes Nicht-UI-Feature als Teil von Foundation, und sie liefern auch nicht allen SwiftUI-bezogenen Code als Teil von SwiftUI aus. Combine und Charts sind zwei Frameworks, die wir separat importieren müssen. Warum werden sie nicht als Teil von Foundation und SwiftUI ausgeliefert? Weil sie nur in bestimmten Domänen nützlich sind und möglicherweise nicht in einem globaleren Kontext gebraucht werden.

Wenn du dich an das ursprüngliche Problem erinnerst: Ich hatte einen Satz von Modulen, die ich immer und immer wieder an vielen Stellen importiert habe, weil sie global nützliche Helfer waren, und nicht nur in bestimmten Domänen. Es ergibt also Sinn, sie unter einem einheitlichen Gruppennamen zu importieren. Aber was genau ist ein Helfer? Was unterscheidet ihn von einem domänenspezifischeren Feature?

Ich persönlich nenne ein Feature dann einen “Helfer” oder ein “Utility”-Feature, wenn seine globale Verfügbarkeit viel nützlicher ist, als sie dem Entwicklungsprozess schadet. Das ist natürlich etwas subjektiv, aber als Faustregel mache ich es so: Wenn ich das Feature bereits in mehreren verschiedenen Teilen meiner App verwende und mir zusätzlich 2 oder 3 potenzielle neue Features vorstelle, die ich irgendwann in Zukunft hinzufügen könnte, und mindestens eines davon es ebenfalls nutzen könnte – dann ist es wahrscheinlich global sehr nützlich.

Konkreter gesagt würde ich die obige Modulliste so aufteilen:

// (global nützliche) Helfer
Analytics
Assets
BetterCodable
ComposableArchitecture
Constants
Foundation
HandySwift
HelpfulErrorUI
ReusableUI
SFSafeSymbols
SwiftUI
Utility

// (domänenspezifische) Features
CommandLineSetup
FilesSearch
MachineTranslation
Paywall
ProjectsBrowser
Settings

Wenn wir nun die beiden Trennungsdimensionen kombinieren, erhalten wir etwas wie den folgenden Graphen mit 4 Quadranten und Abhängigkeiten dazwischen:

11 Imports wurden dank des @_exported-Attributs auf nur 2 reduziert.

Hier ein paar wichtige Dinge zu beachten:

  1. Die ⬆️ obere “Feature”-Hälfte baut auf der unteren “Helfer”-Hälfte auf, deshalb:

  2. Die ↖️ grünen “Nicht-UI-Feature”-Module können import AppFoundation verwenden.

  3. Die ↗️ roten “UI-Feature”-Module können beides importieren, AppFoundation und AppUI.

  4. Innerhalb einer Gruppe (eines Quadranten) können Module voneinander abhängen (Zyklen vermeiden!).

  5. ➡️ “UI”-Module können von ⬅️ “Nicht-UI”-Modulen oder von AppFoundation abhängen.

  6. Die ⬇️ unteren “Helfer” dürfen niemals von den “Features” oben importieren!

  7. Externe Module können auch “Features” sein (siehe ↖️, aktuell habe ich keine in ↗️)

Um diese Struktur umzusetzen, habe ich einfach ein neues Modul namens AppFoundation erstellt sowie eine neue Swift-Datei darin namens AppFoundation.swift mit folgendem Inhalt:

// System
@_exported import Foundation

// Internal
@_exported import Analytics
@_exported import Constants
@_exported import Utility

// External
@_exported import BetterCodable
@_exported import HandySwift

Ich habe auch ein Modul AppUI mit folgendem Inhalt für AppUI.swift darin erstellt:

// System
@_exported import SwiftUI

// Internal
@_exported import Assets
@_exported import HelpfulErrorUI
@_exported import ReusableUI

// External
@_exported import ComposableArchitecture
@_exported import SFSafeSymbols

Jetzt kann ich die 11 Imports aus dem Anfangsbeispiel dieses Artikels, die ich aus einer Datei innerhalb eines UI-Feature-Moduls entnommen habe, durch nur diese 2 Zeilen ersetzen:

import AppFoundation
import AppUI

11 Imports wurden dank des @_exported-Attributs auf nur 2 reduziert.

Beachte, dass ich nicht mal Foundation oder SwiftUI importieren musste. Und für jedes Nicht-UI-Feature brauche ich sogar nur eine einzige Zeile mit import AppFoundation!

Natürlich bedeutet das nicht, dass ich nie wieder etwas anderes importieren werde. Ich werde weiterhin Imports auf der vertikalen Achse haben, wo ein bestimmtes Modul ein anderes bestimmtes Modul innerhalb einer Gruppe importiert, z.B. ein ConfigFile-UI-Feature, das Kindkomponenten wie ConfigFileLinter und ConfigFileNormalizer importiert. Aber das sind domänenspezifische Imports, die nicht zu vielen sich wiederholenden Imports führen.

Als Letztes habe ich meine Products, Dependencies und Targets in meiner Package.swift-Datei nach diesen 4 Quadranten gruppiert. Dafür habe ich Pragma-Marks wie // MARK: - Non-UI Features in allen Abschnitten hinzugefügt und die zugehörigen Anweisungen alphabetisch sortiert hineingesetzt. Mein resultierendes Manifest sieht jetzt ungefähr so aus:

import PackageDescription

let package = Package(
   name: "RemafoX",
   platforms: [.macOS(.v12)],

   // MARK: - Products
   products: [
      // MARK: - Grouping Products
      .library(name: "AppFoundation", targets: ["AppFoundation"]),
      .library(name: "AppUI", targets: ["AppUI"]),
      .library(name: "AppTest", targets: ["AppTest"]),

      // MARK: - Non-UI Helper Products (AppFoundation)
      .library(name: "Analytics", targets: ["Analytics"]),
      .library(name: "Constants", targets: ["Constants"]),
      .library(name: "Utility", targets: ["Utility"]),

      // MARK: - UI Helper Products (AppUI)
      .library(name: "Assets", targets: ["Assets"]),
      .library(name: "HelpfulErrorUI", targets: ["HelpfulErrorUI"]),
      .library(name: "ReusableUI", targets: ["ReusableUI"]),

      // MARK: - Test Helper Products (AppTest)
      .library(name: "TestResources", targets: ["TestResources"]),

      // MARK: - Non-UI Feature Products
      .library(name: "CommandLineSetup", targets: ["CommandLineSetup"]),
      .library(name: "FilesSearch", targets: ["FilesSearch"]),
      .library(name: "MachineTranslation", targets: ["MachineTranslation"]),

      // MARK: - UI Feature Products
      .library(name: "Paywall", targets: ["Paywall"]),
      .library(name: "ProjectsBrowser", targets: ["ProjectsBrowser"]),
      .library(name: "Settings", targets: ["Settings"]),
   ],

   // MARK: - Dependencies
   dependencies: [
      // MARK: - Non-UI Helper Dependencies (AppFoundation)
      .package(url: "https://github.com/marksands/BetterCodable.git", from: "0.4.0"),
      .package(url: "https://github.com/sindresorhus/Defaults", from: "6.3.0"),
      .package(url: "https://github.com/FlineDev/HandySwift", branch: "main"),

      // MARK: - UI Helper Dependencies (AppUI)
      .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "3.3.0"),
      .package(url: "https://github.com/pointfreeco/swift-composable-architecture", from: "0.40.2"),

      // MARK: - Test Helper Dependencies (AppTest)
      .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "0.3.0"),

      // MARK: - Non-UI Feature Dependencies
      .package(url: "https://github.com/FlineDev/Microya", branch: "main"),
      .package(url: "https://github.com/JohnSundell/Splash.git", from: "0.16.0"),
      .package(url: "https://github.com/jakeheis/SwiftCLI.git", from: "6.0.3"),
      .package(url: "https://github.com/TelemetryDeck/SwiftClient", branch: "main"),

      // MARK: - UI Feature Dependencies
   ],

   // MARK: - Targets
   targets: [
      // MARK: - Grouping Targets
      .target(
         name: "AppFoundation",
         dependencies: [
            // Internal
            "Analytics",
            "Constants",
            "Utility",

            // External
            .product(name: "BetterCodable", package: "BetterCodable"),
            .product(name: "HandySwift", package: "HandySwift"),
         ]
      ),
      .target(
         name: "AppUI",
         dependencies: [
            // Internal
            "Assets",
            "HelpfulErrorUI",
            "ReusableUI",

            // External
            .product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
            .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
         ]
      ),
      .target(
         name: "AppTest",
         dependencies: [
            // Internal
            "TestResources",

            // External
            .product(name: "CustomDump", package: "swift-custom-dump"),
         ]
      ),

      // MARK: - Non-UI Helper Targets (AppFoundation)
      .target(
         name: "Analytics",
         dependencies: [
            // Internal
            "Constants",

            // External
            .product(name: "HandySwift", package: "HandySwift"),
            .product(name: "TelemetryClient", package: "SwiftClient"),
         ]
      ),
      .testTarget(name: "AnalyticsTests", dependencies: ["AppTest", "Analytics"]),
      .target(
         name: "Constants",
         dependencies: [
            .product(name: "Defaults", package: "Defaults"),
            .product(name: "HandySwift", package: "HandySwift"),
         ]
      ),
      .target(
         name: "Utility",
         dependencies: [
            // Internal
            "Analytics",
            "Constants",

            // External
            .product(name: "HandySwift", package: "HandySwift"),
            .product(name: "Defaults", package: "Defaults"),
         ]
      ),
      .testTarget(name: "UtilityTests", dependencies: ["AppTest", "Utility"]),

      // MARK: - UI Helper Targets (AppUI)
      .target(
         name: "Assets",
         dependencies: [.product(name: "Defaults", package: "Defaults")],
         resources: [
            .process("Colors.xcassets"),
            .process("Images.xcassets"),
            .copy("Sounds"),
         ]
      ),
      .target(
         name: "HelpfulErrorUI",
         dependencies: [
            // Internal
            "AppFoundation",
            "Assets",
            "ReusableUI",
         ]
      ),
      .target(
         name: "ReusableUI",
         dependencies: [
            // Internal
            "AppFoundation",
            "Assets",

            // External
            .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
            .product(name: "Splash", package: "Splash"),
            .product(name: "SFSafeSymbols", package: "SFSafeSymbols"),
         ]
      ),

      // MARK: - Test Helper Targets (AppTest)
      .target(
         name: "TestResources",
         dependencies: [],
         path: "TestResources",
         exclude: ["Package.swift"],
         sources: ["TestResources.swift"],
         resources: [
            .copy("CustomSample"),
            .copy("EmptyFileStructureSamples"),
            .copy("GitHubSampleProjects"),
         ]
      ),

      // MARK: - Non-UI Feature Targets
      .target(name: "CommandLineSetup", dependencies: ["AppFoundation"]),
      .target(
         name: "MachineTranslation",
         dependencies: [
            // Internal
            "AppFoundation",

            // External
            .product(name: "Microya", package: "Microya"),
         ]
      ),
      .testTarget(
         name: "MachineTranslationTests",
         dependencies: ["AppFoundation", "AppTest", "MachineTranslation"],
         exclude: ["Resources/secrets.json.sample"],
         resources: [.copy("Resources/secrets.json")]
      ),

      // MARK: - UI Feature Targets
      .target(name: "Paywall", dependencies: ["AppFoundation", "AppUI"]),
      .target(
         name: "ProjectSetup",
         dependencies: [
            "AppFoundation",
            "AppUI",
            "ProjectDragAndDrop",
            "ProjectAnalyzer",
         ]
      ),
      .target(
         name: "Settings",
         dependencies: [
            "AppFoundation",
            "AppUI",
            "SettingsTabCurrentPlan",
            "SettingsTabGeneral",
            "SettingsTabMachineTranslation",
         ]
      ),
      // ... many more features related to Project, Settings etc.
   ]
)

☑️ Ähnlich wie AppFoundation und AppUI habe ich auch ein AppTest-Gruppierungs-Target in meiner App eingeführt, das ich verwende, um Imports von XCTest und Dependencies/Helfern wie CustomDump (sehr empfehlenswert!) zu vereinheitlichen.

Um alle relevanten Imports durch AppFoundation/AppUI zu ersetzen, habe ich diesen Trick angewandt:

  1. Zuerst habe ich Xcodes Suchen & Ersetzen für jede @_exported-Library verwendet und alle Imports durch AppFoundation ersetzt, sodass ich am Ende viele Dateien mit mehrfachen Imports von AppFoundation hatte.

  2. Dann habe ich die SwiftLint-Regel duplicate_imports verwendet, die Autokorrektur unterstützt. Installiere es über brew install swiftlint und führe dann diese 3 Zeilen aus:

echo "only_rules: [duplicate_imports]" > temp_swiftlint.yml
swiftlint lint --config temp_swiftlint.yml --path Sources --autocorrect
rm temp_swiftlint.yml

Passe den Parameter bei –path an, falls deiner anders als Sources ist.

  1. Zum Schluss habe ich die Änderungen für Dateien, die in Modulen liegen, die selbst Teil von AppFoundation sind, mit Git zurückgesetzt, ebenso die AppFoundation.swift selbst.

  2. Dann habe ich die obigen Schritte für AppUI wiederholt. Das Ganze hat weniger als 10 Minuten gedauert!

Das war’s! Wie du siehst, hatte ich vor dem Aufräumen ca. 2.000 Imports in meinem Projekt:

Die Lösung

Nach dem Aufräumen habe ich jetzt nur noch 1.200 Imports, also rund 40 % weniger als vorher!

Die Lösung 2

Außerdem ist meine Package.swift-Manifestdatei viel kürzer geworden, von 827 Zeilen auf 575 Zeilen – das ist ungefähr ein Drittel weniger. Und alles ist so viel strukturierter, ich bin begeistert! 😍

Fazit

Dank @_exported import und der Aufteilung von Modulen in vier Gruppen – durch die Fragen, ob sie (A) “UI-bezogen” oder “Nicht-UI-bezogen” und (B) eher “global nützlich” oder eher “domänenspezifisch” sind – kann ich jetzt eine beliebige Anzahl von “Helfer”-Modulen mit nur ein oder zwei import-Zeilen in meine “Feature”-Module importieren! Nicht nur das – diese Gruppen mit ihren Import-Regeln dienen auch als Leitfaden, um meinen Code im richtigen Modul zu platzieren und zirkuläre Abhängigkeiten zu vermeiden.

Das Ergebnis: Weniger Code schreiben, weniger Chancen für Build-Fehler – eine Win-win-Situation!

Hat dir dieser Beitrag gefallen? Folge mir auf Bluesky und Mastodon für mehr Swift-Tipps und Indie-Dev-Updates.