Skip to content

Photon de

Edgar Mesquita edited this page Oct 14, 2026 · 1 revision

Photon: die native GPU-Engine

🌐 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.

Die native Spur ist in 1.0 experimentell

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.

Warum eigen, warum kein Skia

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.

Die Architektur auf einen Blick

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

Der normative Kern

  • Sdf.cs ist 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.

Das Golden-Image-Gerüst

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.

Komponenten-Rendering

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

Ein Satz zeichnet sich Lauf für Lauf

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.

Ein Absatz richtet seine Zeilen aus

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-Text nimmt die Breite, die sein Elternteil entscheidet, wie ein Block es im Web tut und wie Flutters es unter einer engen Beschränkung tut: eine Box mit einer Breite, eine Row oder eine Column, die ihre Kinder streckt, die Seite selbst. Seine Zeilen richten sich über diese Breite aus, die längste eingeschlossen. Wo nichts eine entscheidet (entlang einer Row, 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 von Mermaid, 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.

Finger schweben nie, und der Hover überlebt den Neubau

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.

Das Fenster lernt die Sprache der Maschine

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.

Layout: die CSS-Regeln, die die Engine dir schuldet

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.

Die Shells: Photon in einem echten Fenster

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 des Systems

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.

Die Tastatur des Telefons

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).

Bewegung, die meint, was sie sagt

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: width in 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

Eigene Pixel zeichnen: Canvas

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

Eine macOS-App ausliefern: das .app-Bundle

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.

Eine App signieren, die du wirklich ausliefern kannst

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/MacOS wird 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ür browser-wasm oder linux-x64 mitliefert (Microsoft.Data.Sqlite tut das), lässt codesign --deep nicht 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.

Die App, die du ausliefern kannst, kommt aus dotnet publish

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.app

Das 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

Sagen, was das System über deine App liest

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

Notarisierung und ein Datenträgerabbild

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

Das Caret, und was eine Auswahl zeigen muss

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).

Das Blinken ist GETAKTET, nicht angenagelt

Ein Caret ist 2-Hz-Bewegung, keine vsync-Bewegung, also PLANT ein Caret in Bearbeitung seine Bilder, statt die Renderschleife heiß zu halten:

  • RenderFrame lässt NeedsRender falsch, 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) neben NeedsRender: 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.

Hot Reload: dotnet watch run, und die App folgt

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 (Build lä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:

  1. 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
  2. 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.

Heute auf einem Simulator oder Emulator laufen

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.MINOR des 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 setzt ValidateXcodeVersion=false fü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>, dann dotnet build -f net10.0-ios -r iossimulator-arm64 -c Release (ein Debug-Build installiert ein .app ohne Assemblys und stürzt beim Start ab), xcrun simctl install booted <path>.app, xcrun simctl launch booted <bundle id>. Console.WriteLine geht unter iOS NIRGENDWOHIN: lies das vereinheitlichte Protokoll (xcrun simctl spawn booted log show --last 45s --predicate 'processImagePath CONTAINS "<App>"'). Startargumente erreichen Main nie; 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 veraltete obj/ und bin/ für das iOS-TFM: lösche sie.
  • Android-Emulator: starte ihn mit -gpu host: mit swiftshader_indirect gibt 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), dann adb install -r und adb shell am start. Die Konfiguration reist über eine @(AndroidEnvironment)-Datei (Photon__MaxFrames=90); setprop debug.mono.env nicht. Was die Bildschirmtastatur und die Shell einander sagen, wird nach adb shell setprop log.tag.PhotonInput DEBUG protokolliert, ab der nächsten Änderung (adb logcat -s PhotonInput). Ein Feld, das verbirgt, was getippt wird, zeigt nur, wie lang sein Text ist.

Shader: EINE normative Slang-Quelle

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.

Leistung: die Versprechen bekommen Zahlen

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): RealizeResult trägt eine ausdrückliche Besitzgeschichte. Mit eingeschaltetem Recycling BESITZT der Host jedes Bild, das er ersetzt, der ersetzte Baum versorgt einen LayoutNodePool, 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, die RealizeResults 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.

IME: die Komposition ist der Text der Plattform

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 dieselbe OnKey-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.
  • firstRectForCharacterRange verankert das Kandidatenfenster am CARET (CaretRect() des Hosts → y-Spiegelung → Ansicht → Fenster → Bildschirm), also erscheint der CJK-Auswähler unter der Komposition.
  • Beweis: ImeCompositionTests am 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. Tippe option+e e in 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).

Accessibility: der Semantikbaum

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 beschriftetes Icon meldet sich, ein unbeschriftetes ist Dekoration; TextEntry.Label benennt 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 FocusStop und 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 auf accessibilityChildren mit EQAXElements (ein NSAccessibilityElement, das gedrückt werden kann): Rolle und Unterrolle, Beschriftung, Wert, aktiviert, y-gespiegeltes accessibilityFrameInParentSpace, der Pfad als accessibilityIdentifier, und accessibilityPerformPress über ActivatePath geroutet. Die Elemente werden je Abfrage neu gebaut; der vorige Satz wird freigegeben.
  • iOS-Brücke (PhotonAccessibility): die PhotonView ist ein Accessibility-CONTAINER, dessen Elemente aus dem Baum in Leserichtung gebaut werden: die Rolle auf UIAccessibilityTraits abgebildet, die Rahmen in den Bildschirmraum umgerechnet, der Pfad als accessibilityIdentifier, und der Doppeltipp und die Verstellwischbewegung als accessibilityActivate / accessibilityIncrement / accessibilityDecrement exportiert, über ActivatePath und AdjustPath geroutet. 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 17s accessibilityExpandedStatus. Die Elemente werden neu gebaut, wenn der Baum sich ÄNDERT, statt je Abfrage (VoiceOver fragt die Liste ab, während es sie abgeht), und LayoutChanged wird nur gesendet, während VoiceOver läuft.
  • Android-Brücke (PhotonAccessibility, ein AccessibilityNodeProvider): die PhotonSurfaceView antwortet 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 über ActivatePath, Scrollen vor und zurück über AdjustPath, und das Erkunden durch Berührung aus dispatchHoverEvent beantwortet. Das Ausklappen ist dort eine AKTION statt eines Flags: Ausklappen anzubieten ist, wie ein Knoten sagt, dass er geschlossen ist. Die Ansicht muss sich als IMPORTANT_FOR_ACCESSIBILITY_YES deklarieren, 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: SemanticsTests nageln Reihenfolge, Benennung, Sichtbarkeit von Dialogen, das Auftauchen Deaktivierter und die Aktivierungsaktion fest, dazu die PARITÄTSSCHRANKE SemanticsMatchFocusStops, 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 lebenden accessibilityChildren, der iOS-Controller sendet die echten Objective-C-Selektoren, und unter Android liest uiautomator dump die 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.

Jede drückbare Rolle, in den Worten jeder Plattform

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).

Tab-Leisten, Radiogruppen, Comboboxen und Dialoge

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 AXPopUpButton und Androids Spinner, 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 AXGroup des 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.

Stand

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.

Clone this wiki locally