Unifikacja języka UI w aplikacjach mobilnych na przykładzie Jetpack Compose & Flutter
Częścią pracy każdego programisty jest wybór technologii, w których chcielibyśmy się rozwijać. Technologie mobilne, jak i same urządzenia – telefony i tablety, mimo upływu dekady od powstania pierwszego iPhone’a, ciągle rozwijają się w szalonym wręcz tempie. To samo dotyczy języków, których używa się do pisania na nie oprogramowania. Czym powinniśmy kierować się przy wyborze ścieżki naszej kariery jako programiści mobilni? Statystykami, wieszczeniem wróżki Vanessy czy własnym przeczuciem?
Skłaniałbym się ku rzeczowej analizie rynkowych trendów. Jednym z takich trendów jest zmiana języków definicji UI, rozpoczęta wśród programistów iOS, kontynuowana w React Native i Fluterze i dopełniona w Androidzie pod postacią Jetpack Compose.
Spis treści
Początki powrotu do code-first approach
W czasach objective-C interfejsy aplikacji iOS definiowaliśmy w plikach *.xib/*.storyboard pod postacią długich, małoczytelnych dokumentów xml. Sytuacja wyglądała tutaj dużo gorzej niż na konkurencyjnym Androidzie, który mimo użycia równie rozwlekłego formatu, kontrolki zapisywał w logicznym porządku, gdzie id nie były generowane losowo, a nazywane przez programistę. W dłuższej perspektywie okazało się to zbawienne dla utrzymania spójności i unikania konfliktów podczas mergowania kodu inżynierów pracujących nad tym samym modułem.
<?xml version="1.0" encoding="UTF-8"?> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <device id="retina4_7" orientation="portrait"> <adaptation id="fullscreen"/> </device> <dependencies> <deployment identifier="iOS"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> </dependencies> <objects> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LoginView" customModule="Xib_Demo" customModuleProvider="target"> <connections> <outlet property="accountMessageLabel" destination="RHZ-BM-iU1" id="Egt-mr-kFX"/> <outlet property="contentView" destination="iN0-l3-epB" id="m3I-eR-AZ3"/> <outlet property="emailTextField" destination="N2q-2h-lNG" id="C6A-5b-j3O"/> <outlet property="loginButton" destination="jtN-gs-dts" id="WQ1-T6-GRL"/> <outlet property="passwordTextField" destination="coH-nt-ZpH" id="Np3-GJ-RDk"/> </connections> </placeholder> <placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/> <view contentMode="scaleToFill" id="iN0-l3-epB"> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <subviews> <textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="N2q-2h-lNG"> <rect key="frame" x="16" y="192.5" width="343" height="50"/> <color key="backgroundColor" red="0.35367466469999997" green="0.71446218770000003" blue="1" alpha="1" colorSpace="calibratedRGB"/> <constraints> <constraint firstAttribute="height" constant="50" id="KbO-t6-m5M"/> </constraints> <nil key="textColor"/> <fontDescription key="fontDescription" type="system" pointSize="14"/> <textInputTraits key="textInputTraits"/> </textField> <textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" textAlignment="natural" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="coH-nt-ZpH"> <rect key="frame" x="16" y="250.5" width="343" height="50"/> <color key="backgroundColor" red="0.35367466469999997" green="0.71446218770000003" blue="1" alpha="1" colorSpace="calibratedRGB"/> <constraints> <constraint firstAttribute="height" constant="50" id="1es-ji-Ika"/> </constraints> <nil key="textColor"/> <fontDescription key="fontDescription" type="system" pointSize="14"/> <textInputTraits key="textInputTraits"/> </textField> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="jtN-gs-dts"> <rect key="frame" x="16" y="308.5" width="343" height="50"/> <color key="backgroundColor" red="1" green="0.33229502220000001" blue="0.06197259519" alpha="1" colorSpace="calibratedRGB"/> <constraints> <constraint firstAttribute="height" constant="50" id="bN2-Y6-1gu"/> </constraints> <fontDescription key="fontDescription" type="boldSystem" pointSize="20"/> <state key="normal" title="Button"> <color key="titleColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> </state> </button> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="New User? Create an account" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="RHZ-BM-iU1"> <rect key="frame" x="73.5" y="366.5" width="228" height="21"/> <fontDescription key="fontDescription" type="system" pointSize="17"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <nil key="highlightedColor"/> </label> </subviews> <color key="backgroundColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <constraints> <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="N2q-2h-lNG" secondAttribute="trailing" constant="16" id="0kh-Dm-mef"/> <constraint firstItem="jtN-gs-dts" firstAttribute="top" secondItem="coH-nt-ZpH" secondAttribute="bottom" constant="8" id="6kK-A1-7eu"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="coH-nt-ZpH" secondAttribute="trailing" constant="16" id="9sW-o7-1MF"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="jtN-gs-dts" secondAttribute="trailing" constant="16" id="ECy-bW-nIk"/> <constraint firstItem="RHZ-BM-iU1" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="Hmj-v7-nfh"/> <constraint firstItem="jtN-gs-dts" firstAttribute="centerX" secondItem="iN0-l3-epB" secondAttribute="centerX" id="RiZ-B1-Mmg"/> <constraint firstItem="coH-nt-ZpH" firstAttribute="top" secondItem="N2q-2h-lNG" secondAttribute="bottom" constant="8" id="Wfv-1H-3yP"/> <constraint firstItem="jtN-gs-dts" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="awJ-Cd-r2w"/> <constraint firstItem="RHZ-BM-iU1" firstAttribute="top" secondItem="jtN-gs-dts" secondAttribute="bottom" constant="8" id="c34-gr-HI8"/> <constraint firstItem="jtN-gs-dts" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="f7f-ew-nTT"/> <constraint firstItem="coH-nt-ZpH" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="jgR-1Z-ydA"/> <constraint firstItem="N2q-2h-lNG" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="s9y-nv-M4J"/> </constraints> <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <point key="canvasLocation" x="138.40000000000001" y="154.27286356821591"/> </view> </objects> </document>
Programiści iOS skłonili się więc ku SwiftUI, który oferował ucieczkę od problematycznego formatu *.xib, a jednocześnie pozwalał na:
- definicję UI bezpośrednio w kodzie – tym samym na nawigację do kodu kontrolek i łatwego czytania dokumentacji, która się tam znajduje,
- łatwe użycie funkcji zdefiniowanych w innych miejscach kodu (np. do formatowania, czy walidacji), a nawet zawarcie tam prostej logiki (UWAGA: mieszanie kodu UI i logiki biznesowej jest anty-patternem i nigdy tego nie rób jeżeli nie masz konkretnego powodu).
Oczywiście definicja UI z poziomu kodu ma też swoje wady, o czym później.
Ciekawym aspektem jest fakt, że SwiftUI, czy szerzej definiowanie interfejsu użytkownika z poziomu kodu aplikacji jest tak naprawdę powrotem do korzeni informatyki. Dużym osiągnięciem było rozdzielenie zapisu kształtu UI do osobnych plików – najpierw poprzez pliki *.xml, a następnie poprzez wprowadzenia bindingów i dwustronnych data-bindingów. Ułatwiało to początkującym programistom utrzymanie porządku w architekturze aplikacji i klarowne rozdzielenie logiki biznesowej – zarówno w architekturze MVP, jak i MVVM.
Flutter – mocny gracz wkracza do akcji już na starcie rozgrywając React-Native
Flutter odbił się niemałym echem w świecie mobilnego multiplatform. Jest on zupełnie nowym podejściem – między innymi po Xamarinie, którego największą bolączką było wrapowanie androidowych kontrolek, zmuszające twórców do pogoni za każdą kolejną wersją platformy i z góry skazanym na porażkę. Co ciekawe Flutter już przy swoim starcie rozwiązał także bolączkę React-native – niewydajnego drzewa DOM, nawigacji na Androidzie i opóźnień w odświeżeniu UI przy dużym obciążeniu. Interfejs zbudowany na silniku Chrome – Blink – pozwala na stałe utrzymywanie 60 klatek/s, aplikacje nie dławią się, a my mamy dostęp do całej palety przepięknych animacji.
Omawiany framework, podejście code-first w UI ma wbudowane od samego startu, a interfejsy definiowane w Darcie, wyglądają następująco:
// Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.blue, ), home: const MyHomePage(title: 'Flutter Demo Home Page'), ); } } class MyHomePage extends StatefulWidget { final String title; const MyHomePage({ Key? key, required this.title, }) : super(key: key); @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { int _counter = 0; void _incrementCounter() { setState(() { _counter++; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text( 'You have pushed the button this many times:', ), Text( '$_counter', style: Theme.of(context).textTheme.headline4, ), ], ), ), floatingActionButton: FloatingActionButton( onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), ), ); } }
Musicie przyznać, że mamy tu analogię do wspomnianego wcześniej SwiftUI.
Ostatni gracz na rynku – Jetpack compose
Jetpack compose, od niedawna w wersji stabilnej, pojawił się jako ostatni. Zamyka on cykl przejścia do nowego sposobu definiowania UI z poziomu kodu we wszystkich wiodących platformach mobilnych i jasno pokazuje czego możemy spodziewać się w najbliższy dwóch latach.
Kotlin jest zdecydowanie najpotężniejszym z wymienionych tutaj języków – w związku z czym sam Jetpack Compose ma także najwięcej możliwości – nie tylko rozszerzania, ale swobodnej ingerencji w zachowanie UI.
W przeciwności do SwiftUI – Jetpack Compose pretenduje też do bycia multiplatformowym frameworkiem definiowania interfejsów… a co z tego wyjdzie – zobaczymy. Statystyki użycia jasno pokazują, że na razie rynkowo nie udało mu się wyjść poza świat Androida.
Jetpack-compose vs. Flutter
Spójrzmy na prosty ekran logowania napisany w Jetpack Compose i Flutter. Od razu rzucają się nam w oczy ewidentne podobieństwa:
- w obu rozwiązaniach definiujemy widgety, które odświeżają stan,
- w obu rozwiązaniach mamy wiersze/kolumny zamiast typowych LinearLayout, czy ConstraintLayout,
- w obu rozwiązaniach możemy stosować flex znany ze świata webowego,
- oczywiście oba rozwiązania korzystają z natywnego języka Kotlin/Dart, a więc możemy korzystać z funkcji/zaawansowanych rozwiązań bezpośrednio w kodzie UI.
Jetpack Compose:
Flutter:
Fluttera i Jetpack Compose pisały zupełnie inne zespoły programistów – widać jednak próby unifikacji podejść. Kod UI nie różni się znacząco od SwiftUI. Jak na razie nie jest możliwe bezpośrednie kopiowanie fragmentów UI pomiędzy Flutterem, a Jetpack Compose, ale może w przyszłości będzie?
Pomyślmy jak korzystna z punktu widzenia biznesu byłaba możliwość swobodnego wyboru pomiędzy Jetpack Compose, a Flutterem, czy więcej – zamiana implementacji UI według aktualnych potrzeb – skupienia się na funkcjonalności natywnej [Kotlin] lub wybór oszczędności wynikającej z cross-platformowego podejścia [Flutter].
Co więcej, prototyp UI napisany we Flutterze można by swobodnie wykorzystać do natywnej aplikacji kotlinowej z Composem i na odwrót.
Osadzanie modułów flutterowych wewnątrz aplikacji natywnych również stałoby się łatwiejsze.
Czy ja już tego gdzieś nie widziałem?
Dla starych wyjadaczy informatyki, definiowanie UI z poziomu kodu nie jest niczym nowym. W zasadzie to powrót do lat 2000, czy nawet początku 90-tych. Dlaczego wtedy od niego odchodziliśmy, a teraz, po 20 latach nagle wracamy?
Nadmienię tylko, że mieszanie definicji UI z kodem biznesowym jest niebezpieczną praktyką, a prowadzenie projektu z Jetpack Compose wymaga od programistów dużo samodyscypliny, jak również wnikliwych PR review – w przeciwnym razie nasza aplikacja skończy na śmietniku historii jako spaghetti.
Podsumowanie
W tym artykule przyjrzeliśmy się obecnie panującym trendom w dziedzinie definicji UI w świecie mobilnym. Przechodząc przez 3 platformy – Android, iOS, Flutter – widzimy na wszystkich z nich tę samą tendencję odejścia od xmli i przejścia ku definicji UI z poziomu kodu. Mamy więc pewność, że trend ten utrzyma się w najbliższych miesiącach, czy nawet latach.
Ciekawym smaczkiem jest fakt, że całkiem możliwa jest jeszcze większa unifikacja definicji UI i wisząca w powietrzu nadzieja na łatwe współdzielenie kodu UI pomiędzy platformami. Odpowiadając na pytanie zawarte w pierwszym akapicie: tak, jak najbardziej powinniście uczyć się SwiftUI, Jetpack Compose i Fluttera, ponieważ zostaną one z nami na dłużej.
Zdjęcie główne artykułu pochodzi z unsplash.com.