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:
| Anbieter | Vorgabetakt | Neuladungen/Tag |
|---|---|---|
| ChatGPT/Codex | 30 s | 2880 |
| Claude | 60 s | 1440 |
| Kimi | 120 s | 720 |
| OpenRouter | 300 s | 288 |
| OpenAI | 600 s | 144 |
| Anthropic, Grok, Copilot | je 900 s | 288 |
| Summe, alle acht eingerichtet | 5760 |
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:
- Es gibt keinen Zähler zum Auslesen und keine Meldung, wenn gedrosselt wird.
- Die Dokumentation sagt nicht genau, wie eine Neuladung, die die App selbst
über
WidgetCenteranfordert, gegen die vom System geplanten Auffrischungen verrechnet wird — ob es haargenau derselbe Topf ist. - Ich habe kein gedrosseltes Gerät mit einem Messwerkzeug beobachtet, weil es dafür nichts zu beobachten gibt. Die eingefrorene Kachel ist die dokumentierte Folge einer Überschreitung und das, was der Vergleich vorhersagt. Eine eigene Messung ist sie nicht.
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
- Eine Struktur, die zugleich «was wird gezeigt» und «wann haben wir es geholt» ist, wird sich in jedem Takt selbst ungleich. Abrufzeitpunkte sind der häufigste Fall; Anfrage-Kennungen, Wiederholzähler, ETags und Ablaufmarken haben dieselbe Form. Die synthetisierte Gleichheitsprüfung ist eine Bequemlichkeit, keine Absichtserklärung. Sobald sie über etwas entscheidet, schreibt man den Vergleich besser selbst hin.
- Achte auf Grenzen, die mit Schweigen durchgesetzt werden. Eine Rate-Begrenzung, die mit einem Fehler antwortet, macht sich bemerkbar. Die von WidgetKit antwortet, indem sie nichts tut — und das Symptom taucht an einer Stelle auf, die einen zuerst die Abrufschicht verdächtigen lässt.
- Billig schreiben, teuer melden. Die beiden auseinanderzuhalten ist die richtige Bauform. Sie trägt nur, wenn die Bedingung an der teuren Hälfte ausdrücklich dasteht.
- Geteilter Code teilt seine Fehler. Dieselbe Struktur liegt fast buchstabengleich auf iOS — das iPhone-Widget hatte den Fehler also ebenfalls, und die Uhr-Komplikation dazu: Sie wurde bei jeder Übernahme über die WatchConnectivity-Brücke neu geladen. Eine Ursache, drei Orte, an denen sie zu beheben war.
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.