Über unsMediaKontaktImpressum
Thomas Künneth 24. Januar 2023

Mit Compose Multiplatform den Desktop wiederbeleben

Deklarative UI-Frameworks scheinen auf dem besten Weg, ihre imperativen Vorgänger zu verdrängen. Das Web ist mit React seit über zehn Jahren Vorreiter, Googles Cross-Platform-Framework Flutter (die erste als stabil gekennzeichnete Version erschien Ende 2018) ist praktisch von Beginn an deklarativ, und Apple hatte SwiftUI auf der WWDC 2019 vorgestellt. Android ließ sich am längsten Zeit. Im Sommer 2020 wurden die ersten Alpha-Versionen von Jetpack Compose veröffentlicht.

Für macOS, Windows und Linux zeichnet sich ein heterogenes Bild. Nur Apple macht eindeutige Aussagen darüber, mit welchen Programmiersprachen, Frameworks und Tools Mac-Apps zu schreiben sind. Die beiden anderen Plattformen lassen Entwicklern die Wahl. Das führte und führt zu einem bunten Stilmix. Ob man dies schön oder nervig findet, ist Geschmackssache. Was zählt: Die meisten etablierten Desktop-Frameworks reichen viele Jahre zurück, zum Teil bis zu den Anfängen der Betriebssysteme, unter denen sie verwendet werden. Anders formuliert: Ihre Architektur ist mehrere Jahrzehnte alt. Das ist nicht automatisch schlecht – im Gegenteil: Es zeigt, dass die zugrunde liegenden Ideen und Konzepte flexibel und langlebig sind.

Warum dann trotzdem ein neuer Ansatz? Außerdem, lohnt es überhaupt noch, für den Desktop zu entwickeln? Er wurde schon oft für tot erklärt, denn mit dem Siegeszug des Webs wurde der Browser als Programm-Ablaufumgebung populär. Statt Programme mühsam für Windows, Linux und macOS zu entwickeln, konnte man sich auf eine Plattform (den Browser) konzentrieren. Natürlich gibt es mit Java gefühlt seit Ewigkeiten die Möglichkeit, grafische UIs zu erstellen. Aber das AWT war zu minimalistisch und Swing zu Beginn nicht flüssig genug. Dass man mit Swing sehr elegante plattformübergreifende Programme schreiben kann, haben wir irgendwann gelernt. Aber da war – seien wir ehrlich – der Web-Zug schon zu flott unterwegs. Konsequenterweise hatten auch Alternativen (Adobe Air, JavaFX) keine Chance. Deshalb noch einmal: Warum für den Desktop entwickeln? Manche Dinge möchte man einfach lokal erledigen, zum Beispiel aufgrund von Sicherheitserwägungen, Performance oder fehlendem Zugang zu Netzwerken. Außerdem: Das Korsett des Browsers ablegen zu können, macht die Programmierung oft viel einfacher (nur ein Stichwort: lokaler Dateizugriff). Und schließlich ist es einfach inspirierend, gewohnte Plattformen ganz neu zu entdecken.

Imperativ versus deklarativ

Die meisten etablierten Desktop-UI-Frameworks sind objektorientiert und komponentenzentriert. Die Benutzeroberfläche einer App besteht zur Laufzeit aus einem oder mehreren Objektbäumen. Jedes Objekt repräsentiert entweder ein Bedienelement oder einen Behälter, der wiederum Teile der Bedienoberfläche aufnimmt. Hierbei kann es sich beispielsweise um einen Bildschirm, ein Fenster, einen Dialog oder eine Menüleiste handeln. Praktisch alle Frameworks kennen auch spezialisierte Container, die sich um das Anordnen (Layout) ihrer Kinder kümmern. Um der UI Elemente hinzuzufügen, diese zu löschen oder ihr Aussehen oder Verhalten zu verändern, wird der Baum zur Laufzeit durch Hinzufügen oder Entfernen von Zweigen oder durch Ändern von Blattattributen manipuliert. Aus diesem Grund nennt man solche Frameworks "imperativ". Jede noch so kleine Änderung muss ausimplementiert werden.

Das Problem dabei: Je umfangreicher die Benutzeroberfläche wird, desto aufwändiger gestaltet sich das Durchführen der "richtigen" Änderungen. Das hat damit zu tun, dass die Bedienelemente der meisten imperativen UI-Frameworks ein Eigenleben führen. Eigentlich müsste ich jetzt in Erinnerung rufen, was man in den späten 1990ern unter dem Begriff "Komponente" verstanden hat. Aber das würde natürlich zu weit führen. Deshalb nur ein Beispiel: Ein Texteingabefeld überwacht nicht nur die Tastatur, sondern erlaubt auch das Markieren, Ausschneiden und Einfügen von Teiltexten. Vielleicht kann es auch Eingaben validieren und filtern. Es speichert seinen Zustand (mindestens den aktuellen Text und die Cursorposition) in Eigenschaften, auf die man im Quelltext zugreifen kann – und muss: Nur weil sich der Inhalt einer Programmvariablen geändert hat, wird das Textfeld noch lange nicht aktualisiert. Und das Eingeben und Entfernen eines Zeichens führt nicht "einfach so" zur Aktualisierung der korrespondierenden Variablen. Für die Synchronisierung müssen wir Entwickler sorgen. Jedes (Komponenten-)Framework kennt hierfür bevorzugte Vorgehensweisen (Callbacks, Binding, ...). Da das Auf-dem-aktuellen-Stand-halten schnell aufwändig und fehleranfällig werden kann, hat sich im Laufe der Jahre eine Reihe von Entwurfsmustern etabliert, die für eine bessere Struktur und Wartbarkeit des Quelltexts sorgen. Daran, dass Daten und Benutzeroberfläche (bzw. die Komponenten, die sie repräsentieren) in eigenen Welten leben, ändert das aber nichts.

Hier setzen "deklarative" UI-Frameworks an. Statt Komponentenbäume nach der Änderung von Daten mühsam zu aktualisieren, wird im Quelltext "beschrieben", wie die Bedienoberfläche auf Grundlage der aktuellen Daten aussieht. Praktisch alle deklarativen UI-Frameworks kennen in diesem Zusammenhang das Konzept des "Zustands". Zustandsänderungen führen automatisch zu einer Aktualisierung der UI. Ob etwas umgebaut oder neu gezeichnet werden muss, entscheidet das Framework. Im Quellcode ist davon nichts zu sehen. Stattdessen rücken Daten in den Mittelpunkt der Programmierung.

In Jetpack Compose, Googles deklarativen UI-Framework für Android, sieht das so aus:

@Composable
@Preview
fun CounterDemo() {
  var counter by remember { mutableStateOf(0) }
  Column(
    horizontalAlignment = CenterHorizontally,
    modifier = Modifier.padding(16.dp)
  ) {
    Box(
      contentAlignment = Center,
      modifier = Modifier.height(200.dp)
    ) {
      if (counter == 0) {
        Text(
          text = "Noch nicht geklickt",
          softWrap = true,
          textAlign = TextAlign.Center,
          style = MaterialTheme.typography.h3
        )
      } else {
        Text(
          text = "$counter",
          textAlign = TextAlign.Center,
          style = MaterialTheme.typography.h1
        )
      }
    }
    Button(
      onClick = { counter += 1 }
    ) {
      Text(text = "Klick")
    }
  }
}

Bedienelemente erscheinen im Quelltext als mit @Composable annotierte Kotlin-Funktionen. @Preview sorgt dafür, dass man sich ein Composable in einer Vorschau ansehen kann.

Die Oberfläche einer App entsteht, indem selbstgeschriebene oder bereits vorhandene composable functions ineinander geschachtelt werden. CounterDemo() ordnet eine Box() und einen Button in einer Spalte an. Die Box() enthält immer ein Kindelement, welches durch if (counter == 0) festgelegt wird. Ist die Bedingung wahr, erscheint der Text "Noch nicht geklickt". Bei false wird stattdessen der Wert der Variablen counter ausgegeben. counter ist ein Zustand. In Jetpack Compose wird "state" oft mit mutableStateOf erzeugt und mit remember erinnert. Ändert sich der Wert (counter += 1), sorgt das Framework dafür, dass alle Composables, die den Zustand verwenden, aktualisiert werden. Ist Ihnen aufgefallen, dass es im Listing keine Referenzen oder Zeiger auf UI-Elemente gibt? Anders als bei den imperativen Frameworks müssen keine Objekte oder Baumstrukturen manipuliert werden. Mit den nicht mehr benötigten Referenzen auf Äste und Blätter des Komponentenbaums entfallen auch die vielen korrespondierenden, schwer find- und analysierbaren Abstürze aufgrund falsch gesetzter Referenzen. Sollte eine Zustandsänderung Umbauten an internen Strukturen erfordern, kümmert sich das Framework darum.

Namen von composable functions beginnen untypischerweise mit einem Großbuchstaben und erinnern somit an Klassen oder Datenstrukturen. Dieser Stilbruch ist gewollt. Composables repräsentieren schließlich UI-Komponenten. "Deklarativ" bedeutet also nicht, dass es keine Komponenten mehr gibt. Allerdings sind Komponenten nun weniger schwergewichtig. Es gibt viele kleine Bausteine, die nach Belieben kombiniert werden. Man nennt dies "Composition over inheritance". Ein letzter Punkt: "Deklarativ" heißt nicht zwangsläufig "funktional". Flutter beispielsweise setzt sehr wohl auf Klassen und Vererbung. Wie bereits angesprochen, werden Komponenten allerdings anders geschnitten, als man es beispielsweise von Java Swing oder JavaFX kennt. Je nach Framework könnte es beispielsweise Align, Size oder Padding geben. In der imperativen Welt wären dies eher Eigenschaften.

Und wie zeigt man CounterDemo() an? Unter Android sieht das so aus:

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      MaterialTheme {
        Scaffold(
          topBar = {
            TopAppBar(
              title = {
                Text(text = stringResource(id = R.string.app_name))
              }
            )
          }
        ) {
          Box(
            modifier = Modifier
              .fillMaxSize()
              .padding(it),
            contentAlignment = Center
          ) {
            CounterDemo()
          }
        }
      }
    }
  }
}

CounterDemo() wird in ein paar weitere composable functions (MaterialTheme(), Scaffold() und TopAppBar()) eingewickelt und schließlich mit setContent { } einer "Activity" (ein Grundbaustein von Android-Apps) zugeordnet.

Der Desktop wird deklarativ

Haben Sie Appetit bekommen, einmal deklarativ eine Oberfläche zu erstellen, möchten dies aber nicht auf einer mobilen Plattform tun? Das ist schon seit geraumer Zeit möglich. Beispielsweise können Sie eine React-App schreiben und diese mit Hilfe von Electron zu einer Desktop-Anwendung machen. Hierzu ist aber Web-Know-how (JavaScript und eben React) nötig. Als Alternative bietet sich Flutter an. Das Cross-Platform-Framework ist unter Android und iOS bekannt geworden und mittlerweile äußerst beliebt. Und es bietet Unterstützung für den Desktop sowie für das Web. Allerdings müssen Sie sich hierfür in Dart einarbeiten. Noch vergleichsweise jung ist Compose Multiplatform. Erfinder JetBrains bewirbt es als schnelles, reaktives Kotlin-Framework für Desktop- und Web-UIs. Ziel ist die Vereinfachung und Beschleunigung der UI-Entwicklung für Desktop- und Webanwendungen.

Konzeptionell besteht Compose Multiplatform aus "Compose for Desktop", "Compose for Web", sowie "Kotlin Multiplatform". Letzteres ist Ihnen vielleicht aus der mobilen Entwicklung ein Begriff. Die Idee ist, Geschäftslogik in Kotlin zu schreiben und mit nativen Bedienoberflächen zu verbinden. Google und JetBrains haben nun Jetpack Compose auf den Desktop portiert. Das ist möglich, weil Compose nicht besonders eng mit Android verzahnt ist. Für das Rendern der Bedienelemente ist die Open-Source-2D-Grafikbibliothek Skia zuständig, die übrigens auch in Chrome, ChromeOS und Flutter Verwendung findet. Auf dem Desktop werden Compose-Oberflächen in Java-Swing-Fenster gepackt. Damit stehen nicht nur alle Java- und Kotlin-Bibliotheken zur Verfügung, sondern auch die Tools zum Bauen von Java Native Images. Anders formuliert: Compose for Desktop-Apps laufen in der JVM. Um das Composable CounterDemo() anzuzeigen, ist nur wenig Code nötig:

fun main() = application {
  Window(
    title = TITLE,
    onCloseRequest = ::exitApplication,
  ) {
    MaterialTheme {
      Scaffold(
        topBar = {
          TopAppBar(
            title = {
              Text(text = TITLE)
            }
          )
        }
      ) {
        Box(
          modifier = Modifier.fillMaxSize(),
          contentAlignment = Center
        ) {
          CounterDemo()
        }
      }
    }
  }
}

Ist Ihnen aufgefallen, dass dieses Codefragment der Android-Version äußerst ähnlich ist? Man könnte weite Teile in eine gemeinsame Funktion auslagern. Nur das Zugreifen auf Zeichenketten (unter Android verwendet man hierfür stringResource()) müssten Sie anderweitig realisieren.

Zusammenfassung und Ausblick

Sie erinnern sich sicher, dass ich dem Desktop einen Stilmix unterstellt habe. Compose Multiplatform fügt eine weitere Facette hinzu: Material Design. Wenn Sie sich mit Compose for Desktop beschäftigen möchten, gehört die Dokumentation zu Googles Designsystem zur Pflichtlektüre. Das erleichtert den Einstieg in die Compose-API ungemein. Entwickelt werden die Apps am Besten mit IntelliJ. Als Build-System kommt Gradle zum Einsatz. Jetpack Compose funktioniert nur mit Kotlin, alle anderen Programmteile können in Java oder Kotlin realisiert werden.

Noch entwickelt sich Compose Multiplatform schnell weiter. Vieles funktioniert schon stabil und zuverlässig, anderes wirkt noch unfertig. Insbesondere die Integration in die jeweiligen Plattformen muss noch umfassender werden. Als Beispiele für Dinge, die nur über Umwege möglich sind, seien Dateiverknüpfungen und Drag-and-drop genannt. Es bleibt abzuwarten, was JetBrains im Laufe der Entwicklung noch nachreicht. Spaß macht die deklarative UI-Programmierung auf dem Desktop auf jeden Fall.

Weitere Informationen

Autor

Thomas Künneth

Thomas Künneth ist Google Developer Expert für Android und schreibt seit 2009 Mobile Apps. Er arbeitet als Senior Android Developer bei Snapp Mobile.
>> Weiterlesen
Das könnte Sie auch interessieren
Kommentare (0)

Neuen Kommentar schreiben