Repository navigation
Photon de
🌐 Diese Seite auf: English · Português · Español · Français · Deutsch
Photon ist eQuantic.UIs eigene GPU-Rendering-Engine für die nativen Ziele: ein von Grund auf geschriebener, von Impeller inspirierter Renderer, der den Komponentenbaum Pixel für Pixel auf der GPU zeichnet (Metal unter macOS und iOS, Vulkan unter Android), ohne Skia-Schicht und ohne WebView. Es ist das zweite Realisierungsziel der Write-once-Komponentenarchitektur: dieselben C#-Komponenten, die sich im Web auf DOM und CSS senken, rasterisiert Photon nativ.
1.0 verspricht die Web-Spur: eine App, die dagegen geschrieben ist, kompiliert weiter und antwortet
über die 1.x-Linie hinweg gleich. Die native Spur ist noch nicht versprochen (Seit
0.2.0-preview.64). Ihre Assemblys, jede eQuantic.UI.Native.*, tragen .NETs
[Experimental("EQX0001")], und ihre API darf sich in einer Nebenversion ändern
(#324).
Eine mit eQuantic.UI.Sdk.Native gebaute App schreibt dafür nichts. Das native SDK zu wählen heißt
die Spur zu wählen, also unterdrückt das SDK EQX0001, und eine Veröffentlichung sagt es einmal. Ein
Projekt, das keine Photon-App ist und eine native Assembly verwendet, bricht seinen Build mit EQX0001,
bis es in seinem eigenen Projekt sagt, dass es das will:
<NoWarn>$(NoWarn);EQX0001</NoWarn>Die Komponenten, die eine Photon-App platziert, sind die des Vertrags, dieselben, die das Web zeichnet. Was das Versprechen noch nicht abdeckt, ist, wie die native Spur sie zeichnet, und die eigene API der Spur.
Eine native Schicht zu besitzen heißt, den ganzen Rendering-Stapel zu besitzen: direkte Metal- und Vulkan-Backends mit vorkompilierten Shadern, keine Hülle über Skia. Das kauft:
- Vorhersagbare Leistung: festes Register von Pipeline-Zuständen, null Shader-Kompilierung zur Laufzeit (die Ruckelquelle, die Impeller töten sollte). Pipeline-Caches bleiben über Starts hinweg auf beiden Backends erhalten.
- Eine winzige Fläche: eine Benutzeroberfläche zu rendern braucht eine Handvoll Primitiven, keine allgemeine 2D-Bibliothek.
- Ein normatives Modell: dieselbe Mathematik, auf das Pixel prüfbar, auf der CPU-Referenz und auf jedem GPU-Backend.
C# components (write-once, eQuantic.UI.Primitives vocabulary)
│ Build(ComponentContext) // pure, token-based, mode-free
▼
eQuantic.UI.Native.Framework // C# flex layout engine (spec A2)
▼
eQuantic.UI.Native.Components // PhotonRealizer: abstract tree → display list
▼
eQuantic.UI.Native.Engine // geometry, color model, Sdf.cs (NORMATIVE), DisplayList
▼
IRenderBackend
├── eQuantic.UI.Native.Engine.Reference // scalar CPU rasterizer (golden ground truth, never shipped)
├── eQuantic.UI.Native.Engine.Metal // Apple GPUs (macOS, iOS)
└── eQuantic.UI.Native.Engine.Vulkan // Android
eQuantic.UI.Native.Shell.MacOS / .iOS / .Android / .Windows // the window/host per platform
-
Sdf.csist eine Spezifikation, keine Bibliothek: vorzeichenbehafteter Abstand eines abgerundeten Rechtecks je Ecke, zentrierter Strich (|d| − w/2), Deckungsrampe für ein Pixel Antialiasing. Jeder Rasterisierer, die CPU-Referenz und die Fragment-Shader der GPU gleichermaßen, setzt genau diese Formeln um. - Farbmodell: Autorenschaft in sRGB → lineares vormultipliziertes Mischen → Speicherung in sRGB, als Spiegel des Verhaltens der GPU-Hardware mit sRGB-Renderzielen.
-
Displaylisten: flache, haldenfreie
DrawCommand-Records, neun Arten: Clear, FillRRect, StrokeRRect, ShadowRRect (der analytische Schatten aus §05), BeginLayer/EndLayer (Gruppendeckkraft), Texture (A8-Deckung oder RGBA8-Bilder), BackdropBlur (Milchglas) und FillAnnularSector (das Ringstück, seit 0.2.0-preview.45); einfarbige, lineare Farben mit zwei Stopps und elliptisch radiale Farben; eingebackene 2D-Transformationen und Zuschnitte im Geräteraum.
Jede Szene rendert über ein Backend in eine Oberfläche, liest die sRGB-Pixel zurück und vergleicht sie mit committeten Golden-PNGs (abhängigkeitsfreier Codec, Regenerierungsfluss mit EQ_UPDATE_GOLDENS=1, Toleranz von ±2, Fehlerartefakte mit verstärkten Differenzen). Der Szenenkatalog ist gemeinsam: das Referenz-Backend nagelt die Golden fest, die GPU-Backends laufen denselben Katalog für die Parität, und die Parität GPU↔CPU hält über den ganzen gemeinsamen Katalog innerhalb der Golden-Toleranz, auf Metal und auf Vulkan.
Die GPU-Backends werden über schlanke, typisierte objc_msgSend- und P-Invoke-Bindungen angesteuert: kein Bindungsframework, kein C-Shim, null Pakete von Dritten.
Der native Realisierer zeichnet echtes Komponenten-Chrom (über Tokens aufgelöste Füllungen, Ränder innen, Radien je Ecke):
| Teilsystem | Stand |
|---|---|
| Formen, Ränder, Gradienten, Transformationen, Mischen, Zuschnitt | ✅ Vollständig, mit Golden geprüft |
| Layout (Flex + Wrap, Grid, Stapel, adaptiv, Abschneiden) | ✅ Engine in C# |
| Schatten (Elevation §05) | ✅ Analytische ShadowCoverage, beide Backends |
| Text | ✅ ECHT. Texture-Primitive (A8-Deckung × Tönung) + ITextRasterizer der Plattform; macOS = CoreText (nur Systemframeworks, kein HarfBuzz und kein FreeType, wo die Plattform eine Text-Engine hat). Eine Engine misst UND rasterisiert, also stimmen die Zeilenumbrüche durch Konstruktion überein. Zwei Schnitte: der proportionale des Systems und sein nichtproportionaler (TypeStyle.Mono). Keine Glyphe wird je abgeschnitten: die Bitmap wird aus dem TINTEN-Kasten der Zeile bemessen (CTLineGetImageBounds, nicht aus den deklarierten Metriken der Schrift, da die Unterlänge eines tiefen g darüber hinausgeht) und meldet, wie weit über den Zeilenkasten sie reichen musste, also hebt der Realisierer die Zeichnung um genau das und der Zeilenkasten landet dennoch dort, wo das Layout ihn hingelegt hat |
| Icons | ✅ ECHT. SVG-Pfadparser in reinem C# (vollständiger Befehlssatz, Bögen nach F.6.5) + CoreGraphics-Rasterisierer, dieselbe Texture-Primitive |
| Gruppendeckkraft / Transformationen | ✅ Schichten der Engine |
| Scroll-Compositor | ✅ Nach Pfad geschlüsselte Versätze, echtes Pinned-Anheften (vertikal) |
| Bewegung | ✅ Schleifenbewegung, Eintritt und Austritt von Presence mit Wiedergabe eines Befehls-Schnappschusses, und jeder Stilkanal gleitet, siehe unten |
| Eigenes Zeichnen | ✅ Canvas: die App malt die eigenen Formen der Engine in ihren Kasten, und der Zeiger kommt in ihren Koordinaten an |
| Gesten | ✅ Hover-Kette, Druck mit Abbruch durch Spiel, Ziehen zum Verwerfen (folgen + gleiten) |
| Verankerte Überlagerungen | ✅ Synthetische Überlagerungsschichten, positioniert aus absoluten Ankergrenzen |
| Bilder | ✅ ECHT. Von der Plattform dekodiert (CoreGraphicsImageLoader auf den Apple-Shells) in RGBA8-Texturen in direktem sRGB, über dieselbe Texture-Primitive gezeichnet wie Text und Icons |
Seit 0.2.0-preview.28
Ein reicher Absatz (Text.Spans) rasterisierte früher als EIN Block im Grundstil, also lösten sich
das fette Wort, der Inline-Code-Lauf, der kursive Begriff und der Link mitten im Satz alle in flache
Prosa auf, die größte verbliebene Write-once-Abweichung, da das Web seit dem Tag, an dem Läufe
existierten, je Lauf einen Span ausgibt. Die Einheit ist jetzt ein FRAGMENT, ein Lauf auf einer Zeile,
denn das ist das einzige Stück mit einem Rechteck und beide Hälften brauchen eines: jedes Fragment
rasterisiert in seinem eigenen Stil (Gewicht, Mono, Kursiv, eigene Größe), und das Rechteck eines
verlinkten Fragments ist eine Trefferregion, von der aus die Shell navigiert, also sind Inline-Links
auf Photon drückbar, womit der Roadmap-Punkt geschlossen ist, der sie benannte.
Text.Align wurde vom Web-Realisierer beachtet und von Photon IGNORIERT, aufgefallen an einem echten
Begrüßungsbildschirm: ein zweizeiliger zentrierter Absatz renderte linksbündig, unter einem Titel, der
nur richtig aussah, weil EINE Zeile sich durch ihren Container zentriert.
Es war kein Durchreichen, denn jeder Textdienst schneidet seinen Raster knapp an der längsten Zeile und es gibt keinen Kasten, in dem auszurichten wäre. Den fertigen Raster um (Kasten − Raster) / 2 zu versetzen zentriert den BLOCK, und für einen Absatz, dessen längste Zeile seinen Kasten schon füllt, ist das eine Verschiebung von etwa null, während die kurzen Zeilen links bleiben, genau der Fall, der wehtut. Die Ausrichtung gilt je ZEILE. Der Rasterisierer nimmt sie, die drei Dienste platzieren jede Zeile danach, und die kopflosen Platzhalterbalken platzieren ihre Rechtecke mit derselben Arithmetik, und das ist es, was es ohne einen Plattform-Textdienst in der Schleife prüfbar macht.
Zwei Folgen, die man kennen sollte, wenn man es verwendet:
-
Ein Photon-
Textnimmt die Breite, die sein Elternteil entscheidet, wie ein Block es im Web tut und wie Flutters es unter einer engen Beschränkung tut: eineBoxmit einer Breite, eineRowoder eineColumn, die ihre Kinder streckt, die Seite selbst. Seine Zeilen richten sich über diese Breite aus, die längste eingeschlossen. Wo nichts eine entscheidet (entlang einerRow, innerhalb eines Buttons), ist der Text die Breite seines Inhalts, also ist der Block, in dem eine Zeile zentriert, die längste Zeile des Absatzes, und der Absatz als Ganzes wird von seinem Container platziert. Den Block zu zentrieren und seine Zeilen zu zentrieren sind zwei verschiedene Aufgaben, und eine zweizeilige Beschriftung in einer Form (die Knoten vonMermaid, zum Beispiel) will beide. - Ein reicher Absatz richtet sich auch aus, und seine Stücke bewegen sich mit der Zeile statt mit dem Raster, also bleibt ein verlinkter Lauf drückbar, wo er gezeichnet ist. Das sind zwei getrennte Berechnungen im Code und sie gehen absichtlich durch eine Funktion: ein Link, der sich auf dem Bildschirm bewegt und nicht im Treffertest, ist ein Link, der aufhört zu funktionieren.
Die Interaktion ist verdrahtet: PhotonHost hält eine zurückbehaltene Wurzel, SetState entwertet, Berührungen werden an Trefferregionen verteilt: der unter dem Punkt gezeichnete Kasten gewinnt, und das Ziel, das ein Steuerelement über seinen Kasten hinaus behält (≥48dp unter einem Finger, mindestens 24dp unter einem Zeiger, siehe Dichte), antwortet nur dort, wo kein Kasten daneben gezeichnet ist, oder vor dem Kasten, in dem es steht, also gibt eine Liste kurzer Zeilen jeder Zeile ihre eigenen Drücke; ein deaktiviertes Steuerelement verschluckt sie. Ein transformierter Kasten nimmt den Zeiger dort, wo er gezeichnet ist, ein gedrehter in seiner Form, und ein Druck auf ein Feld, eine Code-Oberfläche, ein Blatt oder ein skaliert oder gedreht gezeichnetes Canvas landet auf dem Caret, der Zelle oder dem Punkt darunter; eine skaliert oder gedreht gezeichnete Scroll-Ansicht holt ein fokussiertes Steuerelement und ein Caret so weit hervor, wie sie es untransformiert täte. Ein Textfeld, eine Code-Oberfläche oder ein Blatt nimmt Drücke nur dort, wo es innerhalb der es zeigenden Scroll-Ansicht auf dem Bildschirm ist. Der native Counter läuft in den Tests Berührung → SetState → Neubau von Ende zu Ende. PhotonHost.FocusedPath meldet, was die Tastatur hält, einschließlich eines Feldes, das bearbeitet wird (das Caret ist der eigene Fokusanzeiger dieses Feldes), denn eine Plattform-Accessibility-Brücke braucht eine ehrliche Antwort, nicht die Hälfte des Rings.
Seit 0.2.0-preview.24
Zeigerereignisse tragen ein PointerKind, je EREIGNIS und nicht je Host, denn die Fähigkeit zu
schweben gehört dem Gerät in der Hand (ein iPad gewinnt ein Trackpad, ein Android gewinnt eine Maus).
Ein mit Touch beschriftetes PointerMove treibt Ziehen, Schwenken und Auswahlen genau wie eine Maus
und überspringt nur die Hover-Auflösung; ein Touch-PressUp/PointerCancel löscht jeden Hover, den
eine Sitzung mit gemischter Eingabe hinterlassen hat, während eine Maus ihren Hover über einen Klick
hinweg behält. Die macOS-Shell installiert eine NSTrackingArea und routet MouseExited zu
PointerLeave(), also löscht das Überqueren des Fensterrands den Hover, statt ihn einzurasten.
Die Identität des Hovers ist der Layout-PFAD, nicht die Knotenreferenz, und der Druck hat das zuerst
gelernt: ein Komponenten-Neubau ersetzt jede Instanz, und ein Hover, der nur die alte Referenz kannte,
malte genau ein Bild. Es ist außerdem eine KETTE jeder Region unter dem Zeiger (CSS' :hover passt auf
jeden Vorfahren, und der native Realisierer antwortet auf dieselbe Frage, die der Web-Zwilling
beantwortet), und das lässt ein per Hover geöffnetes Anchored offen bleiben, während man den Auslöser
darin überfährt. Gedrückt gewinnt weiterhin über Hover auf dem Kasten, der den Tausch verbraucht hat.
Seit 0.2.0-preview.24
Ein außerhalb eines Terminals gestarteter GUI-Prozess hat kein LANG und kein LC_*, also startet
.NET selbst auf einer pt-BR-Maschine invariant. Die Shells lösen nun die Locale-Wahrheit der Plattform
auf und wenden sie vor dem ersten Bild an: AppleLocale liest NSLocale, wo
preferredLanguages[0] die UI-Kultur (Ressourcen) und currentLocale die Formatkultur (Daten,
Trennzeichen) ist, von Natur aus dasselbe Paar, das .NET als CurrentUICulture/CurrentCulture
modelliert, und AndroidLocale versorgt beide Statics aus Locale.getDefault().
PhotonCultureController besitzt das Schreiben der Statics (Standard-Thread UND aktueller Thread,
damit jeder spätere Thread übereinstimmt) und malt bei Apply neu, die Gestalt des Theme-Controllers;
mit TryAdd registriert, also gewinnt eine App, die eine Kultur erzwingt. Zu beobachten, dass die
Locale des Betriebssystems sich zur Laufzeit ändert, ist eine Grenze je Shell.
Die native Engine wird am Browser gemessen, denn der Web-Zwilling rendert dort denselben Baum:
-
Fill erbt die Unbestimmtheit: auf einer Achse, auf der das Elternteil sich aus dem Inhalt bemisst, hat ein
Fill-Kind nichts zu füllen und gibt die Frage weiter; auf einer solchen Achse wird kein Rest verteilt. -
Blocksemantik auf der Breite, und eine ENTSCHIEDENE Höhe gibt sich weiter: ein Kasten, dessen Breite entschieden ist, streckt ein automatisch bemessenes CONTAINER-Kind über sie (das Kind-div eines div ist volle Breite), während ein Button, ein Link oder ein Input dort umschließt: sie sind inline-block, und nur ein FLEX-Strecken reicht durch sie hindurch (
StretchKind). Die Höhen-Hälfte steht unten.
Seit 0.2.0-preview.28
Ein Kasten, dessen Höhe ENTSCHIEDEN ist (fest, gefüllt oder begrenzt), gibt sie an sein einziges Kind
weiter, auf beiden Zielen. Das ist die Regel, die die wiederkehrende Familie der „am oberen Rand ihrer
Leiste fester Höhe klebenden Werkzeugleiste“ ausrangiert hat, bei der jede Row in einer 64dp-Box ein
manuelles Height = Fill brauchte, um den Boden ihrer eigenen Leiste zu erreichen.
- flex-shrink mit einem min-content-Boden: eine Zeile, die nicht passt, schrumpft ihre Elemente statt überzulaufen, und nie unter das min-content jedes Elements (das längste Wort eines Textes, die Summe der Kinder einer Zeile). Der Boden zählt doppelt, weil der Zuschnitt die Pixel UND den Zeiger bewacht: eine zugeschnittene Druckregion kann nicht gedrückt werden.
- Umfluss durch Min/Max: wenn eine Grenze die Ausdehnung eines Kastens ändert, vermisst sich sein Kind gegen die endgültige Größe neu.
macOS (eQuantic.UI.Native.Shell.MacOS): ein NSWindow, das eine CAMetalLayer beherbergt, null Pakete von Dritten, nur schlanke, typisierte AppKit-Bindungen über objc_msgSend. Das Metal-Backend kodiert jedes Bild direkt in das Drawable der Schicht (Pipeline-Cache je Pixelformat, BGRA8_sRGB für Fenster); PhotonHost.RenderScale rasterisiert mit backingScaleFactor, während Layout und Eingabe in dp bleiben (Retina). Maus-, Scroll- und Tastaturereignisse des Betriebssystems werden in die gewöhnliche Eingabekette geroutet, also verhalten sich Druckbilder, Hover-Diffs und verankerte Menüs genau wie in den Tests. Echter San-Francisco-Text und echte Pack-Icons rendern auf dem Bildschirm. Probiere es: dotnet run --project samples/PhotonDesktop. Zwei kopflose Modi brauchen überhaupt kein Fenster: --Photon:MaxFrames 120 präsentiert so viele Bilder und endet, und --Photon:ScreenshotPath out.png (mit --Photon:Mode Dark für die andere Palette) rendert EIN beruhigtes Bild über das Referenz-Backend mit denselben CoreText-Metriken. Das ist der Screenshot-Schritt der CI und der Weg, einen Treuevergleich gegen eine Designübergabe zu prüfen. Run() außerhalb des Hauptthreads aufzurufen sagt es in .NET-Begriffen, statt in AppKit zu sterben. Ändere die Fenstergröße frei: der Host übernimmt den Viewport mit allem Zustand unversehrt.
Keiner der beiden kopflosen Modi sagt dir, dass die App funktioniert hat, und das lohnt es zu verstehen, bevor man eine Prüfung darauf baut. Eine Komponente, die wirft, wird EINGEFASST: ihr Teilbaum wird durch eine kleine rote Oberfläche ersetzt und die App läuft weiter, was eine ausgelieferte App tun soll. Jedes automatische Signal stellt sich auf die Seite der Einfassung: die Bilder werden präsentiert, der Exit-Code ist null, und die Accessibility-Zahl STEIGT, weil die Einfassungsoberfläche eigenen Text hat. Ein Verbraucher hatte „Bilder präsentiert und Exit 0“ als die Prüfung notiert, die ein schwarzes Fenster erwischt, und entdeckte eine App, deren ganze Titelleiste in jedem Bild warf und all das bestand.
Also benennt die Bildzusammenfassung, was eingefasst wurde:
[photon] frames presented: 6 — CONTAINED: TitleBar
Namen, keine Zahl: eine Komponente, die wirft, wirft in jedem Bild, also meldet eine Zahl die
Bildrate und begräbt die eine Tatsache, die zählt. Sie wird in beiden kopflosen Modi gedruckt, und
--Photon:StrictRender true lässt den Prozess mit einem Code ungleich null enden, solange etwas
eingefasst ist: standardmäßig aus, weil die Einfassung in einer ausgelieferten App richtig ist und ein
Exit-Code nicht der Ort ist, an dem ein Nutzer davon hört, und an für einen Selbsttest oder eine
CI-Schranke, wo das Gegenteil gilt.
iOS (eQuantic.UI.Native.Shell.iOS): PhotonApp.Run ist der Einstiegspunkt; dieselbe Engine präsentiert über Metal.
Die App läuft unter dem Szenen-Lebenszyklus, ohne den eine mit dem iOS-27-SDK gebaute App nicht starten
kann, und auf Xcode 27 wird jede App mit diesem SDK gebaut, welches .NET-Pack auch auflöst. Das SDK
deklariert das Szenen-Manifest in der Info.plist der App und der PhotonSceneDelegate der Shell
besitzt das Fenster, also schreibt die App keinen Delegaten und keinen plist-Schlüssel. Der Bildschirm
lebt für den Prozess statt für die Szene: wenn das System eine Szene im Hintergrund trennt und eine
neue verbindet, kommt die App auf der Seite zurück, die sie verlassen hat, mit ihrem Zustand. Im
Hintergrund zeichnet die Shell nichts, und das erste Bild zurück ist ein frisches.
Android (eQuantic.UI.Native.Shell.Android): die App stellt CreateApp bereit (kein Main); die Präsentation geht über Vulkan (das Referenz-Backend ist der Rückfall).
Der Host lebt mit der AKTIVITÄT, nicht mit ihrer Oberfläche. Android zerstört die Oberfläche jedes Mal, wenn die App den Bildschirm verlässt (Home, eine andere App davor, der Bildschirm, der ausgeht), und die Shell gibt dann nur frei, was ihr präsentierte: die Swapchain, das Fenster, das Backend. Die App kommt auf dem Bildschirm zurück, auf dem sie war, mit ihrem Zustand. Eine Drehung, ein Wechsel in den Dunkelmodus, eine angeschlossene Tastatur oder eine Dichteänderung erzeugt die Aktivität ebenfalls nicht neu: der generierte Starter deklariert sie als die seinen, und der Host folgt der neuen Größe, Palette und Skalierung. Die Swapchain wird in der neuen Größe des Fensters neu gebaut und deklariert ihre Bilder aufrecht, also dreht der Compositor sie auf das Panel: ein gedrehtes Telefon mit der App davor zeigt denselben Bildschirm, richtig herum gezeichnet und das Fenster füllend.
Das Zurück der Plattform schließt, was offen ist. Jede Schicht, die sich verwerfen lässt, bindet schon
Escape (ein verwerfbarer Dialog, ein BottomSheet, ein Drawer, ein offenes Menü oder ein offener
Auswähler, das kompakte Detail eines ListDetail). PhotonHost.Back() führt diese Bindung aus, und
PhotonHost.CanGoBack sagt, ob es eine gibt. Ein Feld in einer Schicht geht mit ihr. Eine laufende
Bearbeitung nimmt kein Zurück, und ein Fokusring allein auch nicht, wie ein fokussiertes TextField in
Flutter kein Zurück hält: auf einem Telefon hat die Tastatur das erste Zurück schon damit verbraucht,
nach unten zu gehen und das Caret zu behalten, und ein Zurück, das dann nur das Caret oder einen Ring
wegnähme, würde sich wie ein verschluckter Druck lesen.
Eine App, die vorausschauendes Zurück deklariert, wird VOR dem Ende der Geste gefragt: das System
zeichnet den Startbildschirm unter dem Finger, wenn die App sich nicht für das Zurück registriert hat.
Also registriert sich die Shell nur, solange CanGoBack wahr ist, und liest es nach jedem Bild neu;
mit nichts Offenem gehört das Zurück dem System und verlässt die App, wie in jeder Android-App. Die
Aktivität des generierten Starters deklariert vorausschauendes Zurück, also schreibt die App dafür
nichts, und Android 14 und später lesen diese Deklaration. Android 12 und früher liefern stattdessen die
Zurück-TASTE, und Android 13 auch, das die Deklaration nur von der Anwendung liest, wo .NET für Android
kein Attribut hat, um sie zu schreiben. Die Shell routet die Taste zum selben Back(), also wird ein
Druck nie zweimal behandelt.
Ein TextEntry (ein TextInput, ein SearchField), eine Code-Oberfläche oder ein Blatt auf einem
Telefon anzutippen öffnet die Tastatur der Plattform, und das Ende der Bearbeitung schließt sie. Die App
schreibt dafür nichts: das Feld, das sie schon hat, ist, was die Tastatur bearbeitet.
Die Tastatur eines Telefons bearbeitet ein DOKUMENT, statt Tasten zu senden: sie liest den Text um das
Caret, ersetzt das Wort, das sie autokorrigiert hat, markiert das Wort, für das sie vorschlägt, nimmt
eine Wischeingabe oder ein Diktat. Also übergeben die Shells dem Host den ganzen neuen Zustand des
Feldes, ein TextEditingValue (den Text, die Auswahl und die Komposition, Flutters Namen), über
PhotonHost.UpdateEditingValue, und die App hört den neuen Text einmal, über OnChanged, wie beim
Tippen. Das Wort, das komponiert wird, ist IM Wert und wird unterstrichen gezeichnet, also filtert eine
Suche, während das Wort getippt wird. Eine App, die den Wert umschreibt (eine Maske, eine
Längenobergrenze), beendet die Komposition, und der Tastatur wird es gesagt. TextInputSession hält die
beiden im Schritt: nach jedem Bild sagt sie, ob die Tastatur öffnet, auf einem anderen Feld neu
beginnt, schließt oder einen Wert nimmt, den die App geändert hat, und sie schickt der Tastatur nie
eine Bearbeitung zurück, die die Tastatur gemacht hat. Eine Code-Oberfläche und ein Blatt behalten
ihren Text für sich und nehmen die Tasten und den bestätigten Text der Tastatur, wie auf einem Desktop,
also wird Code nie autokorrigiert.
Der Bereich der Tastatur ist PhotonHost.ViewInsets, Flutters MediaQuery.viewInsets. Der Baum
layoutet darüber, der sichere Bereich, den sie bedeckt, zählt einmal, und ein Bild, das kürzer
gelayoutet ist als das letzte (die Tastatur, die hochkommt, eine Drehung, ein geteilter Bildschirm),
scrollt das bearbeitete Feld in das, was bleibt. Wo nichts scrollt (ein Formular, das nicht in das
passt, was die Tastatur lässt, ein Telefon auf der Seite), wird der Bildschirm gehoben, bis das Feld
zu sehen ist, wie Android es für ein Fenster ohne etwas zu Scrollendes tut, und kommt mit der Tastatur
wieder herunter. Ein Feld in einem ScrollView ist Sache der Scroll-Ansicht, es in den Blick zu holen,
und eines, das die Person unter die Tastatur scrollt, bleibt dort, wie in Flutter. Setze ein Formular
in einen ScrollView, wenn sein oberer Rand erreichbar bleiben muss, während ein Feld bearbeitet wird.
Die Aktionstaste der Tastatur auf einer einzelnen Zeile (Fertig) sendet das Feld ab, wie Enter es tut, und räumt dann die Tastatur mit der Bearbeitung weg.
- Android: die Surface-View antwortet der Tastatur mit einer Eingabeverbindung über einen Spiegel des Feldes, für sie beschrieben: ein Feld, das verbirgt, was getippt wird, wird nicht gelernt und nicht vorgeschlagen, eine einzelne Zeile endet auf der Fertig-Taste der Tastatur, und eine Code-Oberfläche oder ein Blatt ist kein Dokument. Das erste Zurück verbirgt die Tastatur und das Feld behält sein Caret, und ein Antippen darauf holt die Tastatur zurück. Zurück mit heruntergefahrener Tastatur schließt eine offene Schicht oder verlässt die App, wie es das von einem nativen Feld aus tut (Das Zurück des Systems).
- iOS: eine unsichtbare Textansicht am Caret nimmt die Tastatur, also lässt UIKit Autokorrektur, Vorschläge, Diktat und Komposition dagegen laufen, wie es das für seine eigenen Felder tut, und die Kandidatenleiste erscheint am Feld.
- Eine an ein Telefon oder ein Tablet angeschlossene Hardware-Tastatur erreicht den Host wie auf einem
Desktop: Pfeile, Tab, Escape und die Kurzbefehle der App, und der Akzent einer Tottaste verbindet
sich mit dem nächsten Buchstaben (´ und dann a ergibt á), was Android dem Editor überlässt und die
Shell tut. Ihr Kopieren, Ausschneiden und Einfügen geht über die Zwischenablage des Systems, dasselbe
ITextClipboard, um das der eigene Kopieren-Knopf einer Seite bittet (Fähigkeiten).
Ein BoxStyle.Transition war früher ein nur im Web vorhandenes Gleiten: das Native SPRANG auf jeden
neuen Wert und sagte es in einer Grenze. Es tut es nicht mehr. Farben, Deckkraft, die Komponenten der
Transformation, der Elevationsschatten und eine feste Größe interpolieren alle unter der eigenen
Spezifikation des Kastens, seiner Dauer, seiner Verzögerung, seiner Kurve, und ein Kanal, den der Autor
aus Channels ausgelassen hat, springt, über denselben Aufruf.
new Box(new BoxStyle
{
Background = hovered ? theme.SurfaceHover : theme.Surface,
Elevation = hovered ? 3 : 1,
Transition = TransitionSpec.Of(StyleChannels.Colors | StyleChannels.Shadow, Motion.State),
})Zwei Folgen, die es zu nennen lohnt, weil keine offensichtlich ist:
-
Der Schatten wächst, statt getauscht zu werden. Eine Karte, die von Elevation 1 auf 3 steigt,
interpoliert Versatz, Weichzeichnung, Ausbreitung und Farbe; eine, die auf 0 fällt, BLENDET AUS, was
if (Elevation > 0)nie zeichnen könnte. Stufe 0 ist eine echte Spezifikation, null Geometrie, transparente Farbe, also gibt es keinen Sonderfall. -
Eine gleitende Größe wird im interpolierten Wert gelayoutet, also folgt alles, was hinter ihr
liegt: der Umbruch des Kindes, der Anteil der Geschwister an der Zeile. Das ist, was
transition: widthin einem Browser tut, und darum lebt der Size-Kanal im Layout und nicht im Malen.
Die Kurven sind jetzt auch echt. Curve waren vier Kontrollpunkte ohne Auswerter, also setzte der
native Speicher für jede von ihnen einen Smoothstep ein: Motion.Press erzeugte im Web CSS'
cubic-bezier(.2,0,0,1) und auf Photon etwas anderes, eine Autorenabsicht, zwei Bewegungen. Der Löser
tut, was Browser tun, also bedeutet eine benannte Kurve auf beiden Zielen dasselbe. Der Löser,
CurveEvaluator, ist der des Hosts: im Web ist eine Transition eine CSS-Zeitfunktion, also liefert die
Laufzeit keinen Zwilling davon, und eine Komponente, die ihn benennt, bricht den Build mit EQ2010
(seit 0.2.0-preview.61).
Seit 0.2.0-preview.46
Alles andere im Vokabular ist aus Knoten zusammengesetzt, was für eine Benutzeroberfläche richtig und für eine Visualisierung falsch ist: ein Sunburst über eine Million Dateien, eine physikgetriebene Menge von Blasen, ein Diagramm, dessen Geometrie aus Daten neu berechnet wird, die nicht aufhören. Das ist ARITHMETIK über einen Kasten, und sie als tausende zurückbehaltene Knoten auszudrücken bezahlt einen Abgleichdurchgang dafür, zu sagen, was eine Schleife direkt sagen könnte.
Canvas(painter =>
{
foreach (var slice in slices)
painter.FillAnnularSector(cx, cy, slice.Inner, slice.Outer, slice.Start, slice.End, slice.Ink);
},
onPointerMove: p => Highlight(SliceAt(p.AngleFrom(cx, cy), p.DistanceFrom(cx, cy))),
label: "Disk usage by folder")Der Zeiger kommt in den Koordinaten des CANVAS an, und das macht den Treffertest zu dem der App: polar für einen Sunburst, je Teilchen für eine Simulation. Die Engine übergibt den Punkt und gibt nicht vor zu wissen, was dort gezeichnet wurde. Ein Druck fängt ein, also gehört ein Ziehen, das den Kasten verlässt, weiterhin dem Canvas; ein Canvas ohne Handler registriert überhaupt keine Region, also verschluckt reine Zierde nie den Druck darunter.
Das Vokabular des Malers ist das der ENGINE, abgerundete Rechtecke, Kreise, Ringsektoren, Striche, und es gibt keine beliebigen Pfade, denn Photon ist ein SDF-Rasterisierer und eine Pfad-Engine ist eine Grenze für v2. Diese Beschränkung ist es, die ein zweites Ziel kostenlos kauft: dieselben Aufrufe werden im Web zu inline SVG, also wird eine Visualisierung einmal geschrieben. Entartete Eingabe zeichnet nach den eigenen Regeln der Engine nichts (ein nicht positiver Außenradius, ein Endwinkel am Anfang oder davor, ein Innenradius, der den Außenradius erreicht), und beide Ziele sind gegen diese Regeln festgenagelt statt gegeneinander.
Seit 0.2.0-preview.46
Seit 0.2.0-preview.45
Ein mit eQuantic.UI.Sdk.Native unter macOS gebauter Desktop-Kopf kommt als ANWENDUNG heraus, nicht
als Prozess mit einem Fenster: nach Build schreibt das SDK <ApplicationTitle>.app neben die
Build-Ausgabe, die Info.plist aus dem Projekt (ApplicationTitle, Version, die Bundle-id aus
EQuanticBundleId, standardmäßig com.equantic.<assemblyname>), das Icon aus demselben
Assets/AppIcon, das die Telefone versorgt, das aus der Assembly gelesene Fähigkeitsmanifest, und
signiert es ad hoc unter diesem Bezeichner. Steig mit
<EQuanticMacAppBundle>false</EQuanticMacAppBundle> aus.
Ad hoc ist für die Entwicklung richtig und ehrlich darüber, was es ist: es beweist nichts über die Herkunft. Ein VERÖFFENTLICHTER Build sagt, wer er ist, und in dem Moment, in dem er das tut, wendet macOS die gehärtete Laufzeit an, die Dinge standardmäßig verbietet und sie nicht höflich ablehnt. Code, der eines davon braucht, wird nicht abgewiesen, der Prozess wird getötet.
<EQuanticSigningIdentity>Developer ID Application: Your Org (TEAMID)</EQuanticSigningIdentity>Diese eine Eigenschaft ist der ganze Schalter: sie ersetzt die Ad-hoc-Signatur und schaltet die
gehärtete Laufzeit mit ihr ein (EQuanticHardenedRuntime überschreibt diese Vorgabe in beide
Richtungen).
Was dein eigener Code braucht, sagt er in C#, am Builder, neben allem anderen, das er deklariert:
builder.Entitlements.RequireNetworkClient();
builder.Entitlements.RequireUserSelectedFiles();Was die .NET-LAUFZEIT braucht, deklariert das SDK selbst, und das ist der Teil, der es lohnt,
zweimal gelesen zu werden, weil er gemessen und nicht angenommen ist: eine framework-abhängige
.NET-App unter der gehärteten Laufzeit kann überhaupt nicht starten. Die Bibliotheksvalidierung
weist Microsofts eigene libhostfxr.dylib ab, bevor eine Zeile deines Codes läuft, „mapping process
and mapped file (non-platform) have different Team IDs“, und die Laufzeit im Bundle mitzuliefern hilft
nicht, was die naheliegende Annahme und die falsche ist. Also fügt das SDK
disable-library-validation und allow-jit hinzu, wenn die Härtung an ist und die App nicht
vollständig AOT ist, protokolliert, was es hinzugefügt hat, und lässt dich nur deine eigenen
Bedürfnisse deklarieren.
PhotonEntitlements benennt die Schlüssel, die zählen, jeder sagt WANN du ihn brauchst, und
Require(key) nimmt jeden anderen: die Liste ist Apples und sie wächst. EQ3004 weist einen zur
Laufzeit gebauten Schlüssel ab, denn Berechtigungen werden beim Build einsigniert.
Wenn du wasmtime einbettest, ist der Schlüssel, den du deklarierst,
RequireUnsignedExecutableMemory(), nicht RequireJit(). Die Namen täuschen: allow-jit deckt
Seiten ab, die auf Apples Weise gemappt sind (MAP_JIT), was .NETs eigener JIT verwendet, und darum
legt das SDK ihn schon für dich in ein gehärtetes, nicht-AOT-Bundle. wasmtime schreibt stattdessen
einfachen ausführbaren Speicher, also ist dieser eine Schlüssel die ganze Anforderung, und
RequireJit() daneben zu deklarieren ist eine harmlose Nulloperation. Was entscheidet, ist, wie die
Engine ihre Seiten mappt, nicht dass sie WebAssembly ausführt: eine andere WASM-Laufzeit mag durchaus
MAP_JIT verwenden, und dann hat das SDK es schon abgedeckt.
Mit einem echten Zertifikat gemessen, alle drei Zeilen: JIT allein wird beim ersten Gebrauch mit
SIGKILL beendet, mit "namespace":"CODESIGNING" und "indicator":"Invalid Page" im Absturzbericht;
JIT plus unsigned besteht; und unsigned ALLEIN besteht auch, und das hat bewiesen, dass die
JIT-Deklaration nie die Variable war. Unter PublishAot deklariert das SDK nichts für dich, also
findet eine AOT-App ihr eigenes Rezept: diese hat niemand gemessen.
Zu wissen wert, weil nichts ohne ein Signierzertifikat die gehärtete Laufzeit überhaupt beansprucht, also übersteht dies eine vollständig grüne Suite und erscheint bei deiner ersten notarisierten Veröffentlichung.
Seit 0.2.0-preview.46
Drei Regeln halten die Signatur ehrlich, jede aus einer echten App gelernt:
-
Das Bundle ist die Nutzlast.
Contents/MacOSwird jedes Mal aus der Build-Ausgabe neu gebaut, nie angesammelt: eine Datei, die die Ausgabe nicht mehr erzeugt, bleibt nicht in der App zurück. -
runtimes/ist eine Positivliste (osx*,unix*). Nur RIDs, die ein macOS-Prozess laden kann, kommen hinein; ein Paket, das auch native Assets fürbrowser-wasmoderlinux-x64mitliefert (Microsoft.Data.Sqlite tut das), lässtcodesign --deepnicht mehr das ganze Bundle ablehnen. - Ein Signierfehler bricht den Build. Früher war es eine Warnung, und die App behielt die VORIGE Signatur: eine Identität, die zwischen Builds umschlägt, ist der Weg, auf dem eine Erlaubnis für vollen Festplattenzugriff still verdampft, denn TCC schlüsselt ihre Erlaubnisse auf die Signieridentität.
Bis 0.2.0-preview.47 tat sie das nicht. Das .app wurde aus der BUILD-Ausgabe zusammengesetzt und
publish/ enthielt lose Dateien ohne Bundle darin, also hielt jede Veröffentlichungsentscheidung an
der Tür des Bundles: Trimmen, eigenständig, Einzeldatei, AOT. Am eigenen Desktop-Sample dieses
Repositorys mit eingeschaltetem Trimmen gemessen: publish 23 MB, Bundle 172 MB. Die App, die du
ausgeliefert hättest, war siebenmal die App, um die du gebeten hast, und nichts vom Trimmen erreichte
sie.
Jetzt landet das Bundle IM Veröffentlichungsverzeichnis, neben den losen Dateien, dort, wo MAUI sein
eigenes hinlegt und daher dort, wo die Werkzeuge schon hinsehen. Nichts zu konfigurieren:
dotnet publish erzeugt es.
dotnet publish -c Release -r osx-arm64 --self-contained
# → bin/Release/net10.0/osx-arm64/publish/My App.appDas Trimmen funktioniert, und es musste dazu gebracht werden. Vier Defekte, jeder davon der Trimmer, der dieselbe Evidenz liest, auf die sich dieser Entwurf stützt, und zum entgegengesetzten Schluss kommt: eine App benennt ihre Shell nie im Code, also entfernte der Trimmer sie und die App starb beim Start mit der Aussage, keine Shell sei referenziert. Das SDK verwurzelt nun, was das SDK hinzugefügt hat, annotiert die Nahtstellen, durch die der Container reicht, und schaltet den Generator für die Konfigurationsbindung in der Assembly ein, die bindet: ohne dieses letzte öffnete eine App ihr Fenster und ignorierte jede Einstellung, im Stillen. Danach gemessen: 86 MB → 23 MB, null Trimm-Warnungen, dieselben 110 Accessibility-Elemente.
Seit 0.2.0-preview.47
Die Info.plist hatte einen festen Satz von Schlüsseln, also war alles andere unsagbar. Es ist jetzt C#, am selben Builder wie alles andere:
builder.Bundle
.Copyright("© 2026 Acme")
.Category(AppCategory.Utilities)
.MinimumSystemVersion("13.0")
.Agent() // eine Menüleisten-App: keine Dock-Kachel (Apple nennt es LSUIElement)
.UrlScheme("acme") // acme://… öffnet diese App
.Key("NSSomeNewAppleKey", "value"); // der Notausgang, weiterhin in C#Eine Deklaration GEWINNT über den eigenen Wert des Frameworks für denselben Schlüssel, und kein
Schlüssel wird zweimal geschrieben. Category ist ein Enum statt public.app-category.utilities, denn
diese Zeichenkette ist einen Tippfehler davon entfernt, still ignoriert zu werden, und niemand sollte
sie nachsehen müssen.
Eine Sache, die das SDK ohne Rückfrage richtigstellt: Apples Version besteht aus einer bis drei ganzen
Zahlen, und eine .NET-Vorabversion nicht. 0.2.0-preview.46 ging wörtlich in die plist und erzeugte
ein Bundle, das die Notarisierung ablehnt, in einem Vokabular, das nichts benennt, was du geschrieben
hast. Es wird 0.2.0, und der Build sagt es einmal.
Seit 0.2.0-preview.47
Apple scannt die signierte App und stellt ein Ticket aus, ohne das eine heruntergeladene App mit
„kann nicht geöffnet werden, weil der Entwickler nicht verifiziert werden kann“ abgelehnt wird. Es sind
immer dieselben drei Schritte, also macht sie das SDK: so zippen, wie notarytool es erwartet,
einreichen und WARTEN, dann das Ticket in das Bundle klammern, damit es ganz ohne Netz verifiziert.
<EQuanticSigningIdentity>Developer ID Application: Acme (TEAMID)</EQuanticSigningIdentity>
<EQuanticNotaryProfile>acme-notary</EQuanticNotaryProfile>
<EQuanticPackageFormat>Dmg</EQuanticPackageFormat>Zugangsdaten leben nie im Projekt: entweder ein notarytool-Schlüsselbundprofil oder
EQuanticNotaryAppleId + EQuanticNotaryTeamId + EQuanticNotaryPassword aus den Geheimnissen einer
CI: ein Laptop hat einen Schlüsselbund und ein Build-Agent nicht. Jedes davon erreicht notarytool
als eigenes Argument, ohne dass eine Shell es liest, und der Build gibt die Befehlszeile, die das
Passwort trägt, nicht wieder (Seit 0.2.0-preview.62). Eine Diagnose oder ein Binärprotokoll hält
weiterhin die Parameter jeder Aufgabe fest, also ist das einer notarisierten Veröffentlichung keine
Datei zum Teilen. Das Datenträgerabbild wird aus dem GEKLAMMERTEN Bundle gebaut, also reist das Ticket
darin mit und die App verifiziert offline, sobald sie herausgezogen wird.
Noch nicht von Ende zu Ende bewiesen. Die Schritte laufen in Reihenfolge und scheitern laut an Zugangsdaten, die nicht existieren, und kein Bundle ist hier tatsächlich von irgendwem notarisiert worden: niemand hat bisher ein Developer ID Application-Zertifikat, und das ist, was die letzte Zeile hiervon braucht, nicht mehr Code. Deutlich gesagt, weil eine Seite, die eine Funktion beschreibt, die niemand ausgeführt hat, eine Seite ist, die das sagen sollte.
Seit 0.2.0-preview.47
Eine Auswahl hat zwei Enden und nur eines davon bewegt sich. Welches, ist nichts, was ein Mensch aus dem Band ableiten kann, also wird das Caret MIT der Auswahl gezeichnet, nicht an ihrer Stelle: das Band sagt, welche Zeichen gehalten sind, das Caret sagt, welches ENDE du hältst, das, das ⇧-Pfeil bewegen wird, das, von dem aus das nächste Zeichen ersetzt.
Zwei Regeln gehören dazu, und beide drehen sich darum, sichtbar zu sein, wenn es zählt:
- Die Phase des Blinkens beginnt bei der letzten BEWEGUNG des Carets, nicht bei der Epoche. Bei einer frei laufenden Phase kann ein Caret, das eben irgendwo angekommen ist, eine halbe Sekunde in seiner AUS-Hälfte sein: du drückst ⇧→ und schaust auf nichts. Jedes Schreiben an das Caret startet den Zyklus neu, und darum routet der Host sie alle über eine Eigenschaft.
- Während ein Ziehen eine Auswahl zeichnet, blinkt das Caret überhaupt nicht. Das Ende, das deinem Zeiger folgt, ist das Einzige, was du ansiehst.
Im Web ist das Feld ein echtes <input> und der Browser besitzt sein Caret; was hier beschrieben wird,
ist, was Photon malt, und was die Editor-Oberfläche tut (die beide Ziele selbst malen).
Ein Caret ist 2-Hz-Bewegung, keine vsync-Bewegung, also PLANT ein Caret in Bearbeitung seine Bilder, statt die Renderschleife heiß zu halten:
-
RenderFramelässtNeedsRenderfalsch, wenn das Caret der einzige Grund für ein nächstes Bild ist, und notiert, wann der nächste ÜBERGANG des Blinkens fällig ist; - die Leerlauf-Ticks der Shells (der 120-Hz-Timer von macOS, die vsync-Callbacks der Telefone) fragen
host.IsFrameDue(nowMs)nebenNeedsRender: eine Präsentation je halber Periode, auf dem Übergang landend, ~2 pro Sekunde statt der Bildwiederholrate des Displays.
Echte Bewegung (ein Spinner, ein Gleiten, eine Transition) hält die Schleife weiterhin heiß, und der
Plan existiert nur für das Caret. Während ein Ziehen eine Auswahl zeichnet, wird überhaupt nichts
geplant: das Caret wird solide erzwungen und die Bewegung des Zeigers selbst treibt die Bilder. Zehn
simulierte Sekunden eines fokussierten Feldes: 20 Präsentationen, jede das Blinken umkehrend, in
CaretPacingTests behauptet, die Zahl eingeschlossen.
Das Web braucht nichts davon: der Browser besitzt das Caret des <input>, und das Caret-div der
Editor-Oberfläche kann auf dem Compositor blinken.
Lass eine beliebige Photon-App unter dotnet watch run laufen, ändere einen Build-Körper, speichere,
und das Fenster zeichnet sich mit dem neuen Code und ALLEM Zustand unversehrt neu, innerhalb eines
Ticks. Kein SDK-Befehl, keine Konfiguration: die Verrohrung wird in
eQuantic.UI.Native.Components ausgeliefert und jeder Host meldet sich bei seiner Konstruktion an.
Die Architektur hat durch Konstruktion die Gestalt von Hot Reload:
- der Baum wird in jedem Bild aus der zurückbehaltenen Wurzel NEU AUFGESPANNT (
Buildläuft während des Messens), also wirkt ein geflickter Methodenkörper einfach im nächsten Bild; - der Zustand der Komponente lebt in einem nach PFAD geschlüsselten Speicher, nicht in den Objekten, die eine Änderung ersetzt, also muss nichts migriert werden. Drei Klicks Zählerzustand überleben ein Neuladen, weil nichts sie angefasst hat.
PhotonHotReload (der [MetadataUpdateHandler]) liefert das fehlende Stück, das nächste Bild selbst,
denn ein untätiges Fenster präsentiert nichts und eine Metadaten-Aktualisierung kommt ohne angehängtes
Eingabeereignis an. Es tut genau zwei Dinge:
- es weckt jeden lebenden Host über einen bool, den die Renderschleifen schon abfragen (macOS innerhalb von 50 ms, die Telefone bei ihrer nächsten vsync), und
- es erhöht eine GENERATION, die der Host auf seinem eigenen Renderthread vergleicht, um die Inhalts-Caches einmal fallen zu lassen (Textraster, Icons, Bilder). Die Schlüssel sind der Inhalt, also würden die meisten Änderungen sie verfehlen, aber ein geflickter Rasterisierer oder ein geänderter Icon-Pfad ist genau die Änderung, die ein Mensch macht, während er Visuelles abstimmt. Nichts wird vom Thread des Handlers aus verändert.
Grobe Änderungen (neue Typen, geänderte Signaturen) sind Sache der Laufzeit, sie abzulehnen.
dotnet watch antwortet darauf mit einem Neustart, was richtig ist und vom Framework nichts braucht.
Mobil: dasselbe Register bedient die iOS- und Android-Hosts (ihre vsync-Ticks fragen denselben bool
ab), also funktioniert Hot Reload im SIMULATOR. Ein physisches Gerät braucht, dass dotnet watch den
Prozess erreicht, und das ist eine Frage des Auslieferungskanals, keine des Frameworks.
Nur die Desktop-Schleife ist heute ein Befehl (dotnet run, dotnet watch run); die Telefone sind ein
Handrezept, bis das W7 der Spur W es als dotnet build -t:RunIos / -t:RunAndroid in das SDK faltet.
Was jetzt funktioniert, und die Fallen, die je eine Sitzung gekostet haben:
-
Die Xcode-Versionsprüfung ist standardmäßig aus. .NET für iOS lehnt den Build ab (E0191), sofern
das
MAJOR.MINORdes installierten Xcode nicht dem gleicht, das sein SDK-Pack empfiehlt, ein Versionsvergleich, keine Fähigkeitsprüfung. Das „richtige“ Xcode zu installieren erledigt es auch nicht: WELCHES Pack auflöst, entscheidet das Workload-Manifest, nicht das Xcode, das du hast, also löst eine Maschine mit Xcode 27.0 und den Packs 26.0, 26.5 und 27.0 dennoch 26.5 auf und wird aufgefordert, Xcode 26.6 zu holen. Das SDK setztValidateXcodeVersion=falsefür das iOS-Zielframework, und nur dann, wenn du es nicht getan hast: setze<ValidateXcodeVersion>true</ValidateXcodeVersion>in deine csproj, um den Vergleich zurückzuholen. Eine Toolchain, die wirklich nicht bauen kann, scheitert dennoch, mit dem Fehler dessen, was gescheitert ist. -
iOS-Simulator:
xcrun simctl boot <udid>, danndotnet build -f net10.0-ios -r iossimulator-arm64 -c Release(ein Debug-Build installiert ein.appohne Assemblys und stürzt beim Start ab),xcrun simctl install booted <path>.app,xcrun simctl launch booted <bundle id>.Console.WriteLinegeht unter iOS NIRGENDWOHIN: lies das vereinheitlichte Protokoll (xcrun simctl spawn booted log show --last 45s --predicate 'processImagePath CONTAINS "<App>"'). Startargumente erreichenMainnie; die Konfiguration reist als Umgebung (SIMCTL_CHILD_Photon__MaxFrames=90 xcrun simctl launch …). Ein Absturz mit „AOT module … out of date“ nach dem Neubau einer Shell bedeutet veralteteobj/undbin/für das iOS-TFM: lösche sie. -
Android-Emulator: starte ihn mit
-gpu host: mitswiftshader_indirectgibt es kein Vulkan, Photon fällt auf das Software-Referenz-Backend zurück, der UI-Thread klebt bei 100 % und jede Accessibility-Abfrage läuft in eine Zeitüberschreitung. Baue-c Debug -p:EmbedAssembliesIntoApk=true(sonst trägt das APK keine Assemblys), dannadb install -rundadb shell am start. Die Konfiguration reist über eine@(AndroidEnvironment)-Datei (Photon__MaxFrames=90);setprop debug.mono.envnicht. Was die Bildschirmtastatur und die Shell einander sagen, wird nachadb shell setprop log.tag.PhotonInput DEBUGprotokolliert, ab der nächsten Änderung (adb logcat -s PhotonInput). Ein Feld, das verbirgt, was getippt wird, zeigt nur, wie lang sein Text ist.
Sdf.slang (eine Transliteration von Sdf.cs) kompiliert offline über die festgenagelte
slangc-Toolchain zu committetem MSL + SPIR-V: das SDF-Fragment plus das texturierte Fragment
(Texel-Load, per Definition nearest, weil Raster in Geräteskalierung vorliegen, exakte Parität mit
der Referenz). App-Entwickler führen die Shader-Toolchain nie aus; sie verbrauchen die committeten
Artefakte.
PerfHarnessTests misst, was die Definition von fertig verspricht („120 Hz, null Allokationen im
Dauerzustand, Budgets gehalten“), statt es anzunehmen. Auf einer Dashboard-förmigen Szene (24 Karten +
ein Streifen Schleifenbewegung), je Bild im Dauerzustand (M-Serie als Basis): 146 Zeichenbefehle,
realize p50 0,23 ms / p95 0,32 ms gegen das Budget von 8,33 ms, und Allokation im Dauerzustand
unter 74 KB je Bild mit eingeschaltetem Bildrecycling.
Was die Zahl niedrig hält:
- ein Cache für Pfadzeichenketten: ein Pfad ist Identität, also leiht der Host ein Wörterbuch, das Bilder überlebt, und der Dauerzustand hört auf, sie neu zu verketten;
- eine Tabelle von Schichtpfaden: der Wurzelpfad jeder Überlagerungsschicht wird einmal gebaut und über Bilder hinweg behalten, statt einer neuen Zeichenkette je Schicht je Bild;
- ein wiederverwendeter
DisplayListBuilder:Reset()behält die Pufferkapazität; die Shells betreiben einen Builder je Schleife; -
Bildrecycling (
PhotonHost.RecycleFrames, auf Wunsch, standardmäßig aus):RealizeResultträgt eine ausdrückliche Besitzgeschichte. Mit eingeschaltetem Recycling BESITZT der Host jedes Bild, das er ersetzt, der ersetzte Baum versorgt einenLayoutNodePool, und das nächste Bild wird daraus gebaut. Die Produktions-Shells (macOS, iOS, Android) schalten es ein, weil nichts ihre Bilder zurückbehält; Tests und Werkzeuge, dieRealizeResults halten, lassen es aus und behalten unveränderliche Bäume.
Die Obergrenzen sind als Regressions-SPERRKLINKEN eine Spanne über den gemessenen Zahlen festgenagelt (152 KB im Standardprofil / 74 KB mit Pool, 200 Befehle, Alarm bei 33 ms, die Zeit absichtlich lose, damit eine gemeinsame CI nie flackert). Die GPU-Hälfte des Bildes bleibt von Paritäts-Golden gedeckt, nicht von CI-Zeiten.
Ein Bildschirm mit offenen Schichten hat ein eigenes Maß. Die dichte Szene hat keine Überlagerung, also
sagt ihre Obergrenze mit Pool nichts über ein offenes Menü, ein offenes Blatt und einen offenen
Tooltip; das Maß ist, was EINE Schicht hinzufügt, der Unterschied zwischen einer Szene mit 24 Schichten
und einer mit 32, über die acht dazwischen. Der dichte Teil sind in beiden dieselben Bytes, und
zwischen 24 und 32 behalten die Listen des Realisierers je Bild eine Kapazität, also bleibt der Preis
einer Schicht und nicht der einer Liste. Die Obergrenze liegt bei 528 Byte je Schicht, was weniger
Luft lässt als das kleinste Objekt, das .NET alloziert: ein Objekt mehr je Schicht scheitert. Das
Gerüst läuft allein (PerfHarnessCollection, Parallelisierung aus), denn ein Maß von Bytes je Bild,
neben dem Rest der Suite gemessen, misst einen Teil davon mit.
Während ein Feld das Caret hält, übergibt die macOS-Shell Tastenereignisse an den Eingabekontext der
PLATTFORM (NSTextInputClient: die Ansicht entspricht ihm, zur Laufzeit registriert wie jede andere
Überschreibung):
- Eine Tottaste oder eine CJK-Methode komponiert über mehrere Anschläge als markierten Text: vom
Host gehalten (
SetMarkedText), INLINE am Caret mit einer Unterstreichung gerendert, während der WERT des Feldes unberührt bleibt. Die Bestätigung (CommitText) betritt das Feld durch dieselbe Tür, die jeder Anschlag nutzt; Abbrechen lässt das Feld genau so, wie es war. Über eine Auswahl zu komponieren ersetzt sie, dieselbe Regel, die das Tippen hat. Ein verdecktes Feld komponiert blind (die Komposition zu spiegeln würde das Geheimnis spiegeln). - Bearbeitungstasten kommen AUS dem Eingabekontext als Selektoren (
doCommandBySelector:) zurück und treten durch dieselbeOnKey-Tür wieder ein, auf DOM-Namen umgeschrieben: Backspace, Pfeile (mit Shift- und Alt-Varianten), Home/End, Escape. Command- und Control-Akkorde betreten den Kontext nie: ⌘A/⌘C/⌘Z gehören der App. -
firstRectForCharacterRangeverankert das Kandidatenfenster am CARET (CaretRect()des Hosts → y-Spiegelung → Ansicht → Fenster → Bildschirm), also erscheint der CJK-Auswähler unter der Komposition. - Beweis:
ImeCompositionTestsam Host (Tottasten-Folge, Abbruch, Komposition über Auswahl, dem Caret folgen, Bereiche, Fokusverlust lässt die Komposition fallen), und der Fenster-Selbsttest treibt die ECHTEN registrierten Selektoren (setMarkedText:→insertText:) gegen ein lebendes Feld. Tippeoption+e ein ein beliebiges Feld, umékomponiert zu sehen. - Umfang: Code-Oberflächen komponieren blind. Die Bestätigung funktioniert, aber das Gesicht des Editors rendert keinen inline markierten Lauf.
Auf einem Telefon wird die Komposition nicht getrennt gehalten: die Tastatur behält sie im Text des Feldes, und der Host markiert sie nur (Die Tastatur des Telefons).
Seit 0.2.0-preview.29
Photon zeichnet seine eigenen Pixel, also sieht das Betriebssystem eine opake Ansicht, und ohne einen Semantikbaum ist die App für einen Screenreader UNSICHTBAR. Der Baum ist ein Bildartefakt erster Klasse:
-
SemanticsTree.Collect(frame)(gemeinsam, zielneutral) leitet Semantik in Leserichtung aus dem realisierten Layout ab, die Seite UND die Überlagerungsschichten beide, also werden Dialoge gesehen. Rollen: StaticText, Button, Link, TextField, CodeField, Slider (ein Adjustable), Image (beschriftetes Icon), Checkbox, Switch, GridCell, ProgressIndicator, Group, eine für jede drückbare Rolle, und die Tab-Leiste, die Radiogruppe, die Combobox und die Dialoge (beide unten). Der innere Text eines Steuerelements ist sein zugänglicher Name (die<button>text</button>-Regel des Webs); ein beschriftetesIconmeldet sich, ein unbeschriftetes ist Dekoration;TextEntry.Labelbenennt ein Feld. Die Identität ist der Layout-PFAD, dieselbe Identität, die Drücke, Fokus und Scrollen verwenden, denn der Baum wird in jedem Bild neu gebaut und Referenzen veralten. -
Aus dem Blick gescrollter Inhalt ist eingeschlossen, genau wie bei
FocusStopund absichtlich anders als bei den Trefferregionen des Zeigers: die lineare Navigation reicht unter den Falz; der Zuschnitt bewacht den Zeiger, nicht den Leser. -
PhotonHost.Semantics()legt den Baum offen;PhotonHost.ActivatePath(path)führt ein Steuerelement so aus, wie eine Berührung es tut (aus den Regionen des aktuellen Bildes aufgelöst, deaktivierte abweisend); verstellbare Elemente antworten auf Erhöhungs- und Verringerungsaktionen. -
macOS-Brücke (
PhotonAccessibility): die Inhaltsansicht antwortet aufaccessibilityChildrenmitEQAXElements (einNSAccessibilityElement, das gedrückt werden kann): Rolle und Unterrolle, Beschriftung, Wert, aktiviert, y-gespiegeltesaccessibilityFrameInParentSpace, der Pfad alsaccessibilityIdentifier, undaccessibilityPerformPressüberActivatePathgeroutet. Die Elemente werden je Abfrage neu gebaut; der vorige Satz wird freigegeben. -
iOS-Brücke (
PhotonAccessibility): diePhotonViewist ein Accessibility-CONTAINER, dessen Elemente aus dem Baum in Leserichtung gebaut werden: die Rolle aufUIAccessibilityTraits abgebildet, die Rahmen in den Bildschirmraum umgerechnet, der Pfad alsaccessibilityIdentifier, und der Doppeltipp und die Verstellwischbewegung alsaccessibilityActivate/accessibilityIncrement/accessibilityDecrementexportiert, überActivatePathundAdjustPathgeroutet. Ein Häkchen ist ein Button, dessen WERT"1"/"0"ist, UIKits eigener Vertrag für einen Umschalter, und darum wird der Zustand in der Sprache des Nutzers gemeldet, ohne dass das Framework eines der beiden Wörter mitliefert. Das Ausklappen reitet auf iOS 17saccessibilityExpandedStatus. Die Elemente werden neu gebaut, wenn der Baum sich ÄNDERT, statt je Abfrage (VoiceOver fragt die Liste ab, während es sie abgeht), undLayoutChangedwird nur gesendet, während VoiceOver läuft. -
Android-Brücke (
PhotonAccessibility, einAccessibilityNodeProvider): diePhotonSurfaceViewantwortet für eine VIRTUELLE Hierarchie: ein Kind je Semantikknoten, die Rolle als Klassenname der Plattform (android.widget.Button,CheckBox,RadioButton,Switch,SeekBar,EditText,ImageView,TextView), Grenzen in Bildschirmpixeln,ACTION_CLICKüberActivatePath, Scrollen vor und zurück überAdjustPath, und das Erkunden durch Berührung ausdispatchHoverEventbeantwortet. Das Ausklappen ist dort eine AKTION statt eines Flags: Ausklappen anzubieten ist, wie ein Knoten sagt, dass er geschlossen ist. Die Ansicht muss sich alsIMPORTANT_FOR_ACCESSIBILITY_YESdeklarieren, da sie die einzige Ansicht im Fenster ist, und ohne das hat das Fenster überhaupt keine zugängliche Wurzel. -
Zwei Grenzen, eine auf jedem Telefon: keine Plattform hat einen dritten Häkchenzustand, also wird
ein
mixed-Häkchen ohne einen gemeldet, statt zu behaupten, es sei aus; und Android hat keine Link-Klasse, also wird ein Link als das gemeldet, was er tut. -
Beweis:
SemanticsTestsnageln Reihenfolge, Benennung, Sichtbarkeit von Dialogen, das Auftauchen Deaktivierter und die Aktivierungsaktion fest, dazu die PARITÄTSSCHRANKESemanticsMatchFocusStops, die den Semantikgang und den Eingabegang davon abhält, je auseinanderzulaufen. Der Selbsttest jeder Shell fragt dann die PLATTFORM, nicht den Baum: das macOS-Fenster druckt seine lebendenaccessibilityChildren, der iOS-Controller sendet die echten Objective-C-Selektoren, und unter Android liestuiautomator dumpdie App von außen über dieselbe Dienstkette, die TalkBack nutzt. Derselbe Bildschirm antwortet auf allen drei mit denselben 26 Elementen. - Die Web-Parität ist dem Web-Realisierer eingeboren (echte
<button>/aria-label/placeholder), also ist dort nichts geschuldet. Der Baum ist zielneutral und jede Shell hat nun ihre Brücke.
Seit 0.2.0-preview.60
Ein Radio, ein Tab, ein Menüpunkt, eine Option und ein Navigationsziel erreichen den Baum jeweils als eigene Rolle, mit ihrem Zustand dort, wo das Web ihn hinlegt, und jede Brücke sagt es in den eigenen Worten der Plattform. Bis zu dieser Version wurden alle fünf als Button gemeldet.
PressableRole |
macOS (AppKit) | Android-Klasse | iOS (UIKit) | Zustand |
|---|---|---|---|---|
Radio |
AXRadioButton |
RadioButton |
Button, ausgewählt wenn gewählt | checked, wie aria-checked
|
Tab |
AXRadioButton, Unterrolle AXTabButton
|
View |
Button | selected, wie aria-selected
|
MenuItem |
AXMenuItem |
MenuItem |
Button | keiner |
Option |
AXMenuItem |
View |
Button | selected, wie aria-selected
|
Destination |
AXButton |
Button |
Button | current, wie aria-current
|
Die Worte von AppKit und Android folgen dem Core-AAM des W3C für dieselbe ARIA-Rolle, und das ist, was
Safari und Chrome für die Web-Hälfte derselben Komponente offenlegen, mit zwei Abweichungen. Eine
Option ist AXMenuItem, das Wort für eine Wahl in macOS' eigenen Popups, wo Core-AAMs AXStaticText
sie als Absatz lesen würde. Ein Ziel behält auf allen drei die Worte eines Buttons, wie es das
<button aria-current="page"> des Webs tut, denn ein ListItem ist nur ein Ziel, solange es die
aktuelle Zeile ist. Android hat keine Klasse für einen einzelnen Tab, eine Option oder einen Menüpunkt,
also liest TalkBack die Worte des Elements und seinen Zustand, so wie Androids eigene Menüs klingen.
UIKit hat für keine der fünf eine Rolle und sagt ein gewähltes Radio so, wie seine segmentierten
Steuerelemente es tun, mit dem Selected-Merkmal, statt mit der „1“ oder der „0“ eines Umschalters.
Wo sie gehört werden: die Punkte eines Menüs, die Optionen eines Select und eines Zeitauswählers, die
Ziele einer Navigationsleiste und einer Schiene, die aktuelle Zeile einer Liste, und die Tabs und
Radios eines Tabs, eines RadioGroup und eines SegmentedControl, jedes für sich innerhalb seiner
Gruppe gelesen (unten). Kein Element sagt noch, wo es in seinem Satz sitzt
(#502).
Seit 0.2.0-preview.60
Ein Tab-Streifen, eine Radiogruppe, das Feld eines Select und ein offener Dialog erreichen den Baum als
das, was sie sind, mit den Worten, die das Web ihnen gibt: tablist, radiogroup, combobox,
dialog und alertdialog. Bis zu dieser Version waren ein Tabs und ein RadioGroup ein unbenannter
Slider, dessen Tabs und Radios nie gelesen wurden
(#500), das Feld eines Select war ein Button, der
sich ausklappt, und ein Dialog eine schlichte Gruppe mit einem Namen
(#501).
SemanticRole |
macOS (AppKit) | Android-Klasse | iOS (UIKit) | Wo |
|---|---|---|---|---|
TabBar |
AXTabGroup |
TabWidget |
eine Gruppe | ein Tabs-Streifen |
RadioGroup |
AXRadioGroup |
RadioGroup |
eine Gruppe | ein RadioGroup, ein SegmentedControl
|
ComboBox |
AXPopUpButton |
Spinner |
Button, ausgeklappt oder eingeklappt | das Feld eines Select, das eines TimePicker
|
Dialog |
AXGroup, Unterrolle AXApplicationDialog
|
Dialog |
eine Gruppe | ein offenes modales Overlay, der Kalender eines DatePicker
|
AlertDialog |
AXGroup, Unterrolle AXApplicationAlertDialog
|
AlertDialog |
eine Gruppe | ein Dialog, dessen Aktion zerstörend ist |
-
Eine Tab-Leiste und eine Radiogruppe sind Container. Ein Leser landet auf der Leiste oder auf der
Gruppe, hört ihren Namen, wenn der Autor einen gegeben hat (die Beschriftung eines
RadioGroup), und dann jeden Tab oder jedes Radio der Reihe nach, mit dem, ob es gewählt oder angehakt ist; eines zu aktivieren führt den Handler aus, den eine Berührung ausführt. Die Tastatur hat sich nicht geändert: die Leiste ist weiterhin ein Tab-Halt, und ihre Pfeile verschieben weiterhin die Wahl. Die Gruppe selbst nimmt auf keiner Plattform eine Verstellgeste, wie macOS' Tab-Gruppen und Androids Radiogruppen keine nehmen. -
Die Combobox ist das Feld, aus dem eine Liste öffnet: das Drückbare, an dem ein Listbox-Panel
hängt, wie im Web, und das sagt, ob seine Liste offen ist. Der Auslöser eines Menüs bleibt ein Button.
Jede Combobox, die die Bibliothek baut, ist nur zur Auswahl, also nimmt sie die eigenen Worte jeder
Plattform für eine Aufklappliste, NSPopUpButtons
AXPopUpButtonund AndroidsSpinner, den TalkBack eine „Dropdown-Liste“ nennt, statt die der editierbaren Combobox, die Core-AAM gibt. -
Ein Dialog ist eine offene modale Schicht, nach seiner Beschriftung benannt; eine zerstörende
Bestätigung ist ein Warndialog. AppKit selbst beschreibt die
AXGroupdes Dialogs als „group“, also ist die Unterrolle das, was ihn als Dialog markiert. Die Schicht, die ein offenes Menü, ein Select oder ein Tooltip auf Photon öffnet, ist kein Dialog und kein Halt: das Web hat dort keine Schicht. Der Kalender eines Datumsauswählers ist ein Dialog, wie er es im Web ist. - UIKit hat für keinen der vier Container ein Merkmal. Es sagt eine Tab-Leiste und eine modale Ansicht mit einer Eigenschaft eines CONTAINERS, der selbst kein Element ist, den die iOS-Brücke nicht baut, also hört es sie als Gruppe, die den Namen trägt.
Die native Suite nagelt das Verhalten der Engine mit 91 committeten Golden-Bildern fest
(Komponentenpaare hell+dunkel, Blattzustände mitten im Ziehen, Texturdeckung) neben den Unit- und
Host-Suiten. Das Fenster betreibt den Write-once-Showroom mit echtem Text, echten Icons, echten Menüs
und echten Gesten auf den macOS-, iOS- und Android-Shells. Aktuelle Umfangsgrenze: das Dekodieren und
Hochladen von Bildern ist nicht enthalten, also rendert Image seine Platzhalteroberfläche.
🏁 Hier starten
🏗️ Architektur
- Architekturüberblick
- Write-once-Komponenten
- Deklarative Oberfläche
- Paketarchitektur
- Komponenten
- Styling
- Lokalisierung
- Analytics und GTM
📱 Write-once
- Photon-Engine
- Design System
- Fähigkeiten
- Speicher
- Formulare
- Code-Editor
- Markdown
- Mermaid
- E-Mail-Rendering
⚙️ Kompilierung
⚡ Runtime
🔌 Server
🎨 Ökosystem
🚀 Entwicklung