Die Kachel fror ein, weil sie zu oft neu geladen wurde

2. September 2026 · ipstyle · English

WidgetKit gibt einem Widget einen knappen Tagesvorrat an Neuladungen. Ist er aufgebraucht, beachtet das System weitere Anfragen nicht mehr. Es gibt dazu keinen Fehler, keine Protokollzeile, keinen Rückruf und keine Schnittstelle, die den Reststand verriete. Die Kachel steht einfach still, und an der App wirkt alles in Ordnung.

Am schnellsten ist der Vorrat mit einer Struktur weg, die völlig harmlos aussieht.

Der Aufbau

Die App fragt acht Anbieter ab und legt das Ergebnis in einen gemeinsamen Container — eine App Group. Das Widget liest diesen Stand und zeichnet ihn. Es holt nichts selbst: Auf dem Mac läuft die App ohnehin, und ein zweiter Abrufweg wäre nur ein weiterer Ort, an dem ein Token liegt.

Zwei Vorgänge, sehr verschieden teuer. Das Hinschreiben geht in eine lokale Datei und kostet praktisch nichts; das Anstossen des Widgets kostet etwas. Die schreibende Stelle war deshalb von Anfang an so gebaut: immer schreiben, nur manchmal anstossen.

@discardableResult
public func schreib(nach vorgaben: UserDefaults? = AppGruppe.vorgaben) -> Bool {
    guard let vorgaben, let daten = try? Self.kodierer.encode(self) else { return false }
    let vorher = Self.lies(aus: vorgaben)
    vorgaben.set(daten, forKey: Self.schluessel)

    guard vorher?.fenster != fenster || vorher?.quellen != quellen else { return false }
    WidgetCenter.shared.reloadAllTimelines()
    return true
}

Die Fassung, die in 6.4 steckte. Dieses guard ist die ganze Sparlogik.

Es hat nie gegriffen.

Das Feld, an dem jeder Stand sich selbst ungleich wurde

Eine Zeile der Kachel ist eine Quelle — eine Anbieterkarte, eingedampft auf fertige Anzeigewerte:

public struct Quelle: Codable, Sendable, Equatable {
    public let name: String        // «Claude», «ChatGPT/Codex»
    public let anbieter: String
    public let fenster: [Fenster]
    public let wert: String        // «5 h: 64 % · 7 d: 57 %»
    public let kurz: String
    public let prozent: Double?
    public let warnung: Bool
    /// Wann **diese** Zahlen erhoben wurden — je Quelle, nicht global.
    public let stand: Date
}

stand ist selbst ein Anzeigewert: Die Kachel schreibt «vor 4 min» daraus. Er gehört also ins Modell und muss durch die JSON-Kodierung kommen. Er ist zugleich eine gespeicherte Eigenschaft — und damit vergleicht ihn das ==, das Swift synthetisiert. Gesetzt wird er überall aus dem Abrufzeitpunkt:

stand: limits.fetchedAt              // Claude
stand: snapshot.costs.fetchedAt      // OpenAI
stand: xaiHealth.updated ?? Date()   // Grok

Lieferte ein Dienst also im nächsten Takt exakt dieselben Zahlen, war vorher?.quellen != quellen trotzdem wahr. Ein Date war weitergewandert. Das guard liess durch. Neuladen.

Die Rechnung

Jeder erfolgreiche Abruf schreibt den Stand — jeder erfolgreiche Abruf war damit eine Neuladung. Das sind die ausgelieferten Vorgabetakte:

AnbieterVorgabetaktNeuladungen/Tag
ChatGPT/Codex30 s2880
Claude60 s1440
Kimi120 s720
OpenRouter300 s288
OpenAI600 s144
Anthropic, Grok, Copilotje 900 s288
Summe, alle acht eingerichtet5760

Diese Zahl ist gerechnet, nicht gemessen. Weniger Karten, ein schlafendes Notebook, ein Dienst, der nicht antwortet — alles drückt sie. Kürzere Takte, die die Einstellungen erlauben, treiben sie hoch. So oder so liegt sie drei Grössenordnungen daneben.

Wie gross der Vorrat wirklich ist

Apples WidgetKit-Dokumentation nennt als üblichen Rahmen 40 bis 70 Neuladungen am Tag. Diese Zahl steht auch in den Kommentaren im Code, und sie ist die einzige, die ich habe.

Was nicht dokumentiert ist — und was ich deshalb nicht behaupte:

Das Argument hält alle drei Einschränkungen aus: Tausende Anfragen am Tag gegen einen Vorrat im zweistelligen Bereich sind keine Frage der Feinabstimmung.

Wie der Fehler gefunden wurde

Nicht durch eine Meldung und nicht im Feld. Die Mac-Widgets waren in Fassung 6.4 neu, und 6.4 wurde vor der Freigabe wieder aus der Prüfung genommen. Ausserhalb dieses Rechners hat niemand die kaputte Fassung laufen lassen.

Gefunden hat ihn stumpfe Arbeit: nach dem Schnitt der Fassung alles noch einmal lesen, was diese Fassung überhaupt erst gebracht hat, Datei für Datei, und bei jedem Vergleich fragen, was er genau vergleicht. Ein != zwischen zwei Strukturen ist die Sorte Zeile, über die das Auge hinwegrutscht. Es lohnt sich, dort anzuhalten — Swift synthetisiert bereitwillig einen Vergleich über Felder, die man nie vergleichen wollte.

Die Stelle, bei der es sich zu verweilen lohnt, ist der Test. Es gab ihn längst, und er war grün:

func testUnveraenderteWerteStossenNichtAn() {
    let original = beispiel()
    XCTAssertTrue(original.schreib(nach: vorgaben))
    let gleicheWerte = beispiel(erhoben: Date(timeIntervalSince1970: 1_788_000_000))
    XCTAssertFalse(gleicheWerte.schreib(nach: vorgaben))
}

Der Baustein beispiel(erhoben:) setzt aus diesem einen Argument sowohl den Erhebungszeitpunkt als auch jeden stand der Quellen. Zweimal derselbe Wert ergab zweimal dieselbe Struktur — und der Test prüfte danach, dass gleiche Eingaben gleich sind. Das ist eine Aussage über Equatable, nicht über diesen Code.

Der Fall, auf den es ankam — gleiche Zahlen, neuerer Zeitstempel, also praktisch jeder echte Takt —, war genau der, den der Baustein gar nicht ausdrücken konnte. Ein grüner Test an der richtigen Funktion ist keine Abdeckung des richtigen Falls.

Die Lösung — und die, die verworfen wurde

Drei Wege standen zur Wahl.

stand streichen. Nein: Die Kachel zeigt ihn an, und das Alter ist der Unterschied zwischen einer Zahl, der man trauen kann, und einer, der man nicht trauen kann.

Ein eigenes == auf Quelle, das stand übergeht. Verlockend — eine Zeile, kein neuer Typ. Es nimmt aber stillschweigend einem Test die Schärfe, den man behalten will. Der Stand reist als JSON durch die App Group, und die Rundreiseprüfung fängt ein unterwegs verlorenes Feld dadurch, dass sie das Dekodierte mit dem Original vergleicht. Übergeht == den stand, kommt ein stand, der beim Kodieren verlorengeht, durch diese Prüfung — und niemand erfährt je davon.

Eine ausdrückliche Projektion dessen, was der Nutzer sieht. Das ist die Fassung, die ausgeliefert wurde:

/// Alles, was auf der Kachel steht — **ohne** `stand`.
struct Anzeige: Equatable {
    let name: String
    let anbieter: String
    let fenster: [Fenster]
    let wert: String
    let kurz: String
    let prozent: Double?
    let warnung: Bool
}

var anzeige: Anzeige {
    Anzeige(name: name, anbieter: anbieter, fenster: fenster, wert: wert,
            kurz: kurz, prozent: prozent, warnung: warnung)
}
guard vorher?.fenster != fenster
        || vorher?.quellen.map(\.anzeige) != quellen.map(\.anzeige)
else { return false }
WidgetCenter.shared.reloadAllTimelines()

Equatable auf Quelle bleibt vollständig. Zwei verschiedene Fragen bekommen jetzt zwei verschiedene Vergleiche: ist das derselbe Wert (Serialisierung) und sieht das auf dem Bildschirm anders aus (Neuladen).

Der Preis ist echt, aber klein: Ein neues Feld in Quelle zwingt jetzt zur Entscheidung, ob es in Anzeige gehört. Das ist dieselbe Entscheidung, die man dem Leser des Codes ohnehin schuldet — Anzeigewert oder Buchhaltung —, nur muss die Antwort jetzt hingeschrieben werden.

Die Tests, die den grünen ersetzt haben

func testNeuererZeitstempelAlleinStoesstNichtAn() {
    XCTAssertTrue(beispiel().schreib(nach: vorgaben))
    let spaeter = Date(timeIntervalSince1970: 1_788_003_600)
    XCTAssertFalse(beispiel(erhoben: spaeter).schreib(nach: vorgaben),
                   "Nur der Abrufzeitpunkt ist neuer — die Kachel zeigt dasselbe.")
    XCTAssertEqual(WidgetZustand.lies(aus: vorgaben)?.erhoben, spaeter,
                   "Geschrieben wird trotzdem, sonst altert «vor x» nicht mit.")
    XCTAssertEqual(WidgetZustand.lies(aus: vorgaben)?.quellen.first?.stand, spaeter)
}

Zwei Behauptungen in einem Test, mit Absicht: Das Neuladen darf nicht stattfinden, das Schreiben muss. Ein guard, das auch das Schreiben überspränge, tauschte die eingefrorene Kachel gegen eine, die über ihr eigenes Alter lügt.

Und die Gegenprobe, denn ein Vergleich, der gar nichts mehr durchlässt, ist ebenso wenig ein Fortschritt:

func testGeaenderterWertStoesstAn() {
    XCTAssertTrue(beispiel().schreib(nach: vorgaben))
    let spaeter = Date(timeIntervalSince1970: 1_788_003_600)
    XCTAssertTrue(beispiel(erhoben: spaeter, prozent: 47).schreib(nach: vorgaben),
                  "42 % sind zu 47 % geworden — das gehört auf die Kachel.")
}

Was sich verallgemeinern lässt

Der Code oben stammt aus AI-Cockpit — einer Menüleisten-App für den Mac und einer App für iPhone, iPad und Apple Watch, die zeigen, wie viel von deinen Claude-, ChatGPT- und API-Kontingenten noch übrig ist. Beide sind gratis.