Zum Inhalt springen

HandySwift 4.0 – Das große Update

Wieder Zeit in Open Source investiert: Komplette Überarbeitung von HandySwift mit deutlich verbesserter Dokumentation und vielen praktischen Features aus meinen Apps. Lies weiter, um zu erfahren, welche Helfer ich am häufigsten nutze!

HandySwift 4.0 – Das große Update

Es ist eine Weile her, seit ich zuletzt an Open-Source-Projekten gearbeitet habe. Ich habe mich darauf konzentriert, neue Apps zu veröffentlichen, um sicherzustellen, dass ich meine Indie-Karriere langfristig fortführen kann. Nachdem ich 6 Apps innerhalb von 3 Monaten gelauncht hatte, dachte ich, es wäre an der Zeit, die am häufigsten wiederverwendeten Code-Teile zu teilen. Dafür habe ich bereits eine Open-Source-Bibliothek: HandySwift.

Aber es waren mehr als 2 Jahre seit dem letzten Release vergangen. Natürlich hatte ich über die Zeit hinweg einige Funktionalitäten zum main-Branch hinzugefügt, damit ich sie leicht in meinen Apps wiederverwenden konnte. Doch undokumentierte Features, die nicht Teil eines neuen Releases sind, kann man als “intern” betrachten, selbst wenn die APIs selbst als public markiert sind.

Also habe ich mir in den letzten Tagen die Zeit genommen, den gesamten Code aufzuräumen, alles an die neuesten Swift-Erweiterungen anzupassen, ungenutzten Code zu entfernen, @available-Attribute für Umbenennungen hinzuzufügen (damit Xcode Fix-its anbieten kann) und eine ganze Reihe neuer APIs zu dokumentieren. Ich habe sogar ein komplett neues Logo entworfen!

Zusätzlich habe ich mich entschieden, Swift-DocC zu nutzen, wodurch ich meine README-Datei auf das Minimum reduzieren und stattdessen meine Dokumentation auf der Swift Package Index-Seite hosten konnte. Mit etwas Hilfe von ChatGPT konnte ich sogar die bestehende Dokumentation ausbauen, was zur am besten dokumentierten Bibliothek führte, die ich je veröffentlicht habe!

Logo-Update

Ich werde einen eigenen Beitrag über die Details der Migration schreiben. Aber weil ich noch nie über HandySwift geschrieben habe, möchte ich einige der Annehmlichkeiten erklären, die du bei der Nutzung bekommst. Ich empfehle, es jedem Projekt hinzuzufügen. Es hat keine Abhängigkeiten, ist selbst leichtgewichtig, unterstützt alle Plattformen (einschließlich Linux und visionOS) und die Plattformunterstützung reicht bis iOS 12 zurück. Ein absoluter No-Brainer.


Extensions

Einige Highlights der über 100 Funktionen und Properties, die bestehenden Typen hinzugefügt werden – jeweils mit einem praktischen Anwendungsfall direkt aus einer meiner Apps:

Sicherer Index-Zugriff

Music Player

In FocusBeats greife ich über einen Index auf ein Array von Musiktiteln zu. Mit subscript(safe:) vermeide ich Out-of-Bounds-Crashes:

var nextEntry: ApplicationMusicPlayer.Queue.Entry? {
   guard let nextEntry = playerQueue.entries[safe: currentEntryIndex + 1] else { return nil }
   return nextEntry
}

Du kannst es bei jedem Typ verwenden, der Collection konform ist, einschließlich Array, Dictionary und String. Statt den Subscript array[index] aufzurufen, der ein Non-Optional zurückgibt, aber bei einem ungültigen Index abstürzt, nutze das sicherere array[safe: index], das in solchen Fällen stattdessen nil zurückgibt.

Leere Strings vs. Blank Strings

API Keys

Ein häufiges Problem bei Textfeldern, die nicht leer sein dürfen: Nutzer tippen versehentlich ein Leerzeichen oder Zeilenumbruch ein und bemerken es nicht. Wenn der Validierungscode nur .isEmpty prüft, bleibt das Problem unbemerkt. Deshalb stelle ich in TranslateKit bei der Eingabe eines API-Keys sicher, dass zuerst alle Zeilenumbrüche und Leerzeichen am Anfang und Ende des Strings entfernt werden, bevor die .isEmpty-Prüfung erfolgt. Und weil ich das sehr oft an vielen Stellen mache, habe ich einen Helfer geschrieben:

Image(systemName: self.deepLAuthKey.isBlank ? "xmark.circle" : "checkmark.circle")
   .foregroundStyle(self.deepLAuthKey.isBlank ? .red : .green)

Verwende einfach isBlank statt isEmpty für das gleiche Verhalten!

Lesbare Zeitintervalle

Premium-Plan läuft ab

Immer wenn ich eine API verwendet habe, die ein TimeInterval erwartet (was nur ein Typealias für Double ist), fehlte mir die Einheit, was zu weniger lesbarem Code führte, weil man sich aktiv daran erinnern muss, dass die Einheit “Sekunden” ist. Und wenn ich eine andere Einheit wie Minuten oder Stunden brauchte, musste ich die Berechnung manuell durchführen. Nicht so mit HandySwift!

Statt einen einfachen Double-Wert wie 60 * 5 zu übergeben, kannst du einfach .minutes(5) schreiben. Zum Beispiel nutze ich in TranslateKit für die Vorschau der Ansicht bei einem abgelaufenen Abo Folgendes:

#Preview("Expiring") {
   ContentView(
      hasPremiumAccess: true,
      premiumExpiresAt: Date.now.addingTimeInterval(.days(3))
   )
}

Du kannst sogar mehrere Einheiten mit einem +-Zeichen verketten, um eine Uhrzeit wie “09:41 Uhr” zu erstellen:

let startOfDay = Calendar.current.startOfDay(for: Date.now)
let iPhoneRevealedAt = startOfDay.addingTimeInterval(.hours(9) + .minutes(41))

Beachte, dass dieses API-Design im Einklang mit Duration und DispatchTimeInterval steht, die beide bereits Dinge wie .milliseconds(250) unterstützen. Aber sie hören bei der Sekunden-Ebene auf, sie gehen nicht höher. HandySwift fügt für diese Typen auch Minuten, Stunden, Tage und sogar Wochen hinzu. So kannst du zum Beispiel Folgendes schreiben:

try await Task.sleep(for: .minutes(5))

Achtung: Das Voranschreiten der Zeit durch Intervalle berücksichtigt keine Komplexitäten wie die Sommerzeit. Verwende dafür einen Calendar.

Durchschnitte berechnen

Kreuzworträtsel-Generierung

Im Kreuzworträtsel-Generierungsalgorithmus von CrossCraft habe ich eine Health-Funktion für jede Iteration, die die Gesamtqualität des Rätsels berechnet. Zwei verschiedene Aspekte werden berücksichtigt:

/// Ein Wert zwischen 0 und 1.
func calculateQuality() -> Double {
   let fieldCoverage = Double(solutionBoard.fields) / Double(maxFillableFields)
   let intersectionsCoverage = Double(solutionBoard.intersections) / Double(maxIntersections)
   return [fieldCoverage, intersectionsCoverage].average()
}

In früheren Versionen habe ich mit verschiedenen Gewichtungen experimentiert, zum Beispiel Kreuzungen doppelt so stark gewichtet wie die Feldabdeckung. Das ließe sich immer noch mit average() erreichen – einfach so in der letzten Zeile:

return [fieldCoverage, intersectionsCoverage, intersectionsCoverage].average()

Fließkommazahlen runden

Fortschrittsbalken

Beim Lösen eines Rätsels in CrossCraft siehst du deinen aktuellen Fortschritt oben auf dem Bildschirm. Ich nutze den eingebauten Prozent-Formatter (.formatted(.percent)) für numerische Werte, aber er erwartet ein Double mit einem Wert zwischen 0 und 1 (1 = 100%). Ein Int wie 12 zu übergeben, rendert unerwartet als 0%, also kann ich nicht einfach Folgendes machen:

Int(fractionCompleted * 100).formatted(.percent)  // => "0%" bis "100%"

Und einfach fractionCompleted.formatted(.percent) zu verwenden, ergibt manchmal sehr langen Text wie "0.1428571429".

Stattdessen nutze ich rounded(fractionDigits:rule:), um den Double-Wert auf 2 signifikante Stellen zu runden:

Text(fractionCompleted.rounded(fractionDigits: 2).formatted(.percent))

Es gibt auch eine mutierende round(fractionDigits:rule:)-Funktion, wenn du eine Variable direkt ändern möchtest.

Symmetrische Datenverschlüsselung

Rätsel teilen

Bevor ich ein Kreuzworträtsel in CrossCraft hochlade, stelle ich sicher, dass es verschlüsselt wird, damit technisch versierte Leute die Antworten nicht einfach aus dem JSON auslesen können:

func upload(puzzle: Puzzle) async throws {
   let key = SymmetricKey(base64Encoded: "<base-64 encoded secret>")!
   let plainData = try JSONEncoder().encode(puzzle)
   let encryptedData = try plainData.encrypted(key: key)

   // Upload-Logik
}

Beachte, dass der obige Code zwei Extensions nutzt: Zuerst wird init(base64Encoded:) verwendet, um den Schlüssel zu initialisieren, dann verschlüsselt encrypted(key:) die Daten unter Verwendung sicherer CryptoKit-APIs unter der Haube, mit denen du dich nicht beschäftigen musst.

Wenn ein anderer Nutzer dasselbe Rätsel herunterlädt, entschlüssele ich es mit decrypted(key:):

func downloadPuzzle(from url: URL) async throws -> Puzzle {
   let encryptedData = // Download-Logik

   let key = SymmetricKey(base64Encoded: "<base-64 encoded secret>")!
   let plainData = try encryptedPuzzleData.decrypted(key: symmetricKey)
   return try JSONDecoder().decode(Puzzle.self, from: plainData)
}

HandySwift liefert außerdem praktischerweise encrypted(key:)- und decrypted(key:)-Funktionen für String, die eine Base-64-kodierte String-Repräsentation der verschlüsselten Daten zurückgeben. Verwende sie, wenn du mit String-APIs arbeitest.


Neue Typen

Neben der Erweiterung bestehender Typen führt HandySwift auch 7 neue Typen und 2 globale Funktionen ein. Hier sind die, die ich in nahezu jeder einzelnen App verwende:

Gregorian Day und Time

Du möchtest ein Date aus Jahr, Monat und Tag konstruieren? Ganz einfach:

GregorianDay(year: 1960, month: 11, day: 01).startOfDay() // => Date

Du hast ein Date und möchtest nur den Datumsteil ohne die Uhrzeit speichern? Verwende einfach GregorianDay in deinem Model:

struct User {
   let birthday: GregorianDay
}


let selectedDate = // aus dem DatePicker
let timCook = User(birthday: GregorianDay(date: selectedDate))
print(timCook.birthday.iso8601Formatted)  // => "1960-11-01"

Du möchtest einfach das heutige Datum ohne Uhrzeit?

GregorianDay.today

Funktioniert auch mit .yesterday und .tomorrow. Für mehr einfach aufrufen:

let todayNextWeek = GregorianDay.today.advanced(by: 7)

GregorianDay konformiert zu allen Protocols, die du erwarten würdest, wie Codable, Hashable und Comparable. Für Encoding/Decoding wird das ISO-Format wie “2014-07-13” verwendet.

GregorianTimeOfDay ist das Gegenstück:

let iPhoneAnnounceTime = GregorianTimeOfDay(hour: 09, minute: 41)
let anHourFromNow = GregorianTimeOfDay.now.advanced(by: .hours(1))


let date = iPhoneAnnounceTime.date(day: GregorianDay.today)  // => Date

Delay und Debounce

Wolltest du schon mal Code verzögert ausführen und fandest diese API umständlich zu merken und einzutippen?

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .milliseconds(250)) {
   // dein Code
}

HandySwift bietet eine kürzere Version, die leichter zu merken ist:

delay(by: .milliseconds(250)) {
   // dein Code
}

Es unterstützt auch verschiedene Quality-of-Service-Klassen wie DispatchQueue (Standard ist die Main Queue):

delay(by: .milliseconds(250), qosClass: .background) {
   // dein Code
}

Während Verzögerungen großartig für einmalige Aufgaben sind, gibt es manchmal schnelle Eingaben, die Performance- oder Skalierungsprobleme verursachen. Ein Nutzer könnte zum Beispiel schnell in ein Suchfeld tippen. Es ist gängige Praxis, die Aktualisierung der Suchergebnisse zu verzögern und zusätzlich ältere Eingaben zu verwerfen, sobald der Nutzer eine neue macht. Diese Praxis nennt sich “Debouncing”. Und mit HandySwift ist es ganz einfach:

@State private var searchText = ""
let debouncer = Debouncer()


var body: some View {
    List(filteredItems) { item in
        Text(item.title)
    }
    .searchable(text: self.$searchText)
    .onChange(of: self.searchText) { newValue in
        self.debouncer.delay(for: .milliseconds(500)) {
            // Suchvorgang mit dem aktualisierten Suchtext nach 500 Millisekunden Nutzer-Inaktivität ausführen
            self.performSearch(with: newValue)
        }
    }
    .onDisappear {
        debouncer.cancelAll()
    }
}

Beachte, dass der Debouncer in einer Property gespeichert wurde, damit cancelAll() beim Verschwinden für die Bereinigung aufgerufen werden kann. Aber delay(for:id:operation:) ist der Ort, an dem die Magie passiert – und du musst dich nicht um die Details kümmern!


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