Wprowadzenie do Jetpack Compose
Mateusz Teteruk twórca mailingu Nowoczesny Android przedstawi Ci, czym jest Jetpack Compose.
Jeśli zajmujesz się pisaniem natywnych aplikacji mobilnych na telefony z systemem Android, to podejrzewam, że słyszałeś o czymś takim jak Jetpack Compose. Co to jest?
Zaraz poznasz odpowiedź na to pytanie i na kilka innych!
Czytając ten artykuł:
- Dowiesz się, czym jest Jetpack Compose i deklaratywny UI.
- Poznasz filozofię działania Compose.
- Będziesz znał podstawowe komponenty w Compose.
- Zapoznasz się z przykładem wykorzystania Compose w demonstracyjnej aplikacji.
Mateusz Teteruk
Zajmuję się zawodowo Androidem od ponad 5 lat. Pracowałem w projektach tworzonych od zera i w projektach istniejących już od kilku lat. Aplikacje, które rozwijałem miały zarówno kilkaset tysięcy, jak i kilkuset aktywnych użytkowników dziennie.
Kładę duży nacisk na jak najefektywniejsze wykorzystanie dostępnych narzędzi w codziennej pracy. Jestem fanem idiomatycznego Kotlina, a w rezultacie zrozumiałego i eleganckiego kodu. Moje oczy widzą każdą niedoróbkę UI/UX.
Twórca newslettera NowoczesnyAndroid.pl i współtwórca społeczności KlubMobile.pl.
Możesz do mnie napisać na mateusz@nowoczesnyandroid.pl, znaleźć w social mediach: mateuszteteruk (Twitter, Instagram, Linkedin, YouTube) lub nowoczesnyandroid (Instagram, TikTok).
Jetpack Compose
Jetpack Compose to nowy sposób pisania interfejsów użytkownika w natywnych aplikacjach Androidowych. Piszemy deklaratywny UI w Kotlinie! Logika biznesowa i interfejs użytkownika w jednym języku programowania.
Deklaratywny UI
Dla osób, które miały jakąkolwiek styczność z Flutterem, czy React Native to koncepcja powinna być znana. W deklaratywnym podejściu skupiamy się na tym, CO trzeba zrobić, a nie JAK coś zrobić.
W przypadku korzystania z widoków zdefiniowanych w plikach XML musieliśmy ręcznie przeszukiwać drzewo widoków poprzez jakąś formę findViewById
i ręcznie modyfikować stan komponentów.
W Compose tego nie robimy.
Widok jest tworzony w całości, a wszelkie zmiany są wykonywane jak najrzadziej. Przerysowywane jest tylko to, co konieczne, na podstawie przekazanego modelu.
Potencjalnym problemem może być koszt (mocy obliczeniowej, baterii) przerysowania na nowo całego ekranu. Compose inteligentnie wybiera komponenty, które muszą zostać przerysowane, zachowując bez zmian to, co się da.
Jak działa Compose?
Interfejs użytkownika tworzymy w Compose za pomocą zbioru funkcji oznaczonych annotacją @Composable
, które przyjmują model danych i na jego podstawie renderują elementy widoku.
Compose wykorzystuje Unidirectional Date Flow (UDF). Oznacza to, że kierunek przepływu danych jest w jedną stronę (od źródeł danych do UI). Kierunek przepływu akcji jest w stronę przeciwną (od UI do źródła danych, gdzie są przygotowane do użycia). Przykładem akcji jest naciśnięcie przycisku przez użytkownika.
W Compose wyróżniamy trzy fazy:
- Composition – jaki UI trzeba pokazać. Compose uruchamia funkcje Composable i tworzy opis UI.
- Layout – gdzie umieścić UI. Ta faza składa się z dwóch kroków: pomiaru i umieszczenia. Elementy układu mierzą i umieszczają siebie oraz wszelkie elementy podrzędne we współrzędnych 2D, dla każdego węzła w drzewie.
- Draw – jak narysować UI. Elementy UI rysują się na Canvas.
Ważnym procesem w cyklu życia funkcji Composable jest rekompozycja. To proces wywołania ponownie funkcji @Composable
, gdy zmienią się jej parametry. Cała magia inteligentnego i optymalnego przerysowywania widoków znajduje się właśnie tutaj. Compose wykrywa, które parametry się zmieniły i uruchamia po raz kolejny tylko te funkcje, które korzystają ze zmienionych parametrów, pomijając te, które się nie zmieniły.
Stan w Jetpack Compose
Kilka ważnych uwag dotyczących funkcji Composable:
- Mogą wykonywać się w dowolnej kolejności.
- Mogą wykonywać się równolegle.
- Rekompozycja pomija najwięcej jak to możliwe.
- Rekompozycja jest optymistyczna i może zostać anulowana.
- Funkcje mogą być uruchamiane bardzo często (nawet dla każdej klatki animacji).
Ważnym aspektem związanym z procesem rekompozycji jest stan, czyli wartość, która zmienia się w czasie. Parametry funkcji Composable są reprezentacją stanu. Rekompozycja następuje w momencie ich zmiany. Żeby Composable został przerysowany, trzeba mu to powiedzieć, wprost przekazując nowe parametry.
Stan możemy zapamiętać w funkcji Composable np. za pomocą funkcji remember
. Stan w Compose jest reprezentowany przez interfejs State
, który jest immutable. Mamy też mutowalny MutableState
.
Compose wspiera również inne popularne obserwowalne typy danych, np. LiveData, Flow, RxJava2.
Ostatni aspekt, o którym chcę wspomnieć to funkcje Composable, którą zapamiętują w sobie stan (funkcje stateful) oraz funkcje, które tego nie robią (funkcje stateless) i po prostu renderują UI na podstawie przekazanych parametrów.
W jaki sposób otrzymać funkcję stateless, która nie trzyma w sobie stanu? Najprościej poprzez wyniesienie tego stanu poziom wyżej — do funkcji, która ją wywołuje. Funkcja stateless ma wtedy parametr związany z przekazaniem stanu oraz lambdę do jej zmiany.
Wiem, że jest tego dużo. Może to się wydać przytłaczające, ale bądź spokojny! Bardzo łatwo zacząć z Compose! Dopiero po jakimś czasie będziesz w stanie zagłębiać się w szczegóły techniczne i aspekty optymalizacji.
Podstawowe komponenty
Scaffold
To gotowy komponent, który ma przygotowane sloty na typowe elementy interfejsu:
- TopAppBar
- BottomAppBar
- FloatingActionButton.
TopAppBar/BottomAppBar
Górny/dolny pasek nawigacyjny.
Dokumentacja: TopAppBar i BottomAppBar.
Box
Można go porównać do FrameLayout
znanego z XML. To kontener na inne elementy.
Dokumentacja
Surface
Można porównać do CardView
znanego z XML.
Dokumentacja
Column/Row (LazyColumn/LazyRow)
Column
i Row
to komponenty pozwalające na pozycjonowanie elementów widoku wertykalnie i horyzontalnie. Odpowiedniki z przedrostkiem Lazy
pozwalają na tworzenie list z wieloma elementami, które nie są jednocześnie widoczne na ekranie. Można je porównać do RecyclerView.
Dokumentacja: Column, Row, LazyColumn, LazyRow.
Button
Komponent do przycisków. Jest kilka różnych gotowych wersji, np. OutlinedButton
, TextButton
.
Dokumentacja
Text
Za jego pomocą wyświetlimy tekst.
Dokumentacja
Modifier
Moim zdaniem, to jeden z najciekawszych elementów Compose. W dokumentacji jest nawet cała oddzielna strona poświęcona Modifierom! Modifiery pozwalają modyfikować wygląd i zachowanie Composabli.
Modifier to po prostu interfejs z przygotowanymi funkcjami umożliwiającymi łączenie kilku Modifierów w łańcuch wywołań za pomocą kropki. Mamy do wyboru wiele wbudowanych modyfikatorów, ale można też pisać je samemu.
Musisz zapamiętać jedno — kolejność Modifierów ma ogromne znaczenie!
Dokumentacja
Podstawowe Modifiery
fillMaxSize / fillMaxWidth / fillMaxHeight
Za ich pomocą definiujemy wielkość danego komponentu.
Dokumentacja.
padding
Padding
umożliwia utworzenie pustych przestrzeni wokół komponentu. Co ważne, w łańcuchu wywołań może być wykorzystany kilkukrotnie. Każdy padding
będzie się wtedy odnosić do innych elementów widoku. Pamiętaj, kolejność Modifierów jest bardzo ważna.
Dokumentacja
clickable
Composable będzie klikalny i automatycznie uzyskamy ripple effect.
Dokumentacja
background
Ustawisz dla Composable background
. Jest kilka opcji: Color
/Brush
, Shape
, alpha
.
Dokumentacja
border
Composable będzie miał obwódkę z podanym BorderStroke
oraz Shape
.
Dokumentacja
Demonstracyjna aplikacja
Przeanalizujmy fragment kodu typowej aplikacji.
W naszym przykładzie będzie to prosta aplikacja z jednym ekranem — listą piosenek. Piosenkę można polubić oraz na nią kliknąć. Tak wygląda docelowa aplikacja:
Struktura projektu w Android Studio
Podczas tworzenia projektu wybrałem opcję Empty Compose Activity. Tak wygląda MainActivity.kt
:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
IntroToComposeTheme {
// A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
Greeting("Android")
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
IntroToComposeTheme {
Greeting("Android")
}
}
Android Studio wygenerowało strukturę projektu:
W Color.kt
mamy definicję domyślnych kolorów.
import androidx.compose.ui.graphics.Color
val Purple200 = Color(0xFFBB86FC)
val Purple500 = Color(0xFF6200EE)
val Purple700 = Color(0xFF3700B3)
val Teal200 = Color(0xFF03DAC5)
W Shape.kt
znajdziemy domyślnie zdefiniowane wartości kształtów.
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)
W Type.kt
domyślna typografia.
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
)
W Theme.kt
zobaczymy automatycznie wygenerowane wsparcie dla trybu jasnego/ciemnego.
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
private val DarkColorPalette = darkColors(
primary = Purple200,
primaryVariant = Purple700,
secondary = Teal200
)
private val LightColorPalette = lightColors(
primary = Purple500,
primaryVariant = Purple700,
secondary = Teal200
)
@Composable
fun IntroToComposeTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
Zaczynamy pisać kodzik
Zacznijmy od najbardziej zewnętrznych komponentów widoku. W tym wypadku skorzystamy ze Scaffold
, który posiada dodatkowe, interesujące nas, sloty. Skonfigurujemy górny pasek nawigacyjny z nazwą aplikacji oraz obsługę snackbar’ów.
TopAppBar
to po prostu Text
. Możliwość pokazywania snackbar’ów osiągniemy poprzez przekazanie snackbarHostState
do scaffoldState
. Kod poniżej piszę w oddzielnym Composable nazwanym SongsScreen
, który wywołuję w MainActivity#onCreate
.
Scaffold(
modifier = Modifier.fillMaxSize(),
scaffoldState = rememberScaffoldState(snackbarHostState = snackbarHostState),
topBar = { TopAppBar(title = { Text(text = "Songs") }) },
) {
// content ...
}
W środku Scaffold
chcemy umieścić docelową zawartość ekranu.
W tym wypadku jest to po prostu lista piosenek. Osiągniemy to za pomocą LazyColumn
i funkcji items
, do której przekażemy listę oraz identyfikator każdego elementu. Lambda w środku funkcji items
służy zdefiniowaniu, co ma się wydarzyć dla każdego elementu listy songs.
LazyColumn {
items(songs, key = { it.id }) { song ->
SongItem(
song = song,
onSongClick = onSongClick,
onFavouriteClick = onFavouriteClick,
)
}
}
Lista piosenek songs jest uzyskana z ViewModelu i przekonwertowana do stanu, który rozumie Compose.
val songs by songsViewModel.songs.collectAsStateWithLifecycle()
W ramach LazyColumn
korzystamy też z funkcji SongItem
, która reprezentuje jeden element listy. Najbardziej zewnętrzny Row
(elementy ułożone horyzontalnie), w środku Image
, kolumna z dwoma napisami oraz animowany przycisk polubienia piosenki.
SongItem
jest funkcją typustateless
, czyli nie posiada w sobie żadnego stanu. Wszystko jest renderowane na podstawie przekazanych do niej parametrów, w tym dane piosenki oraz lambdy zmieniające stan wybranego elementu.
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun SongItem(
song: Song,
onSongClick: (Song) -> Unit,
onFavouriteClick: (Song) -> Unit,
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSongClick(song) }
.padding(all = 10.dp)
) {
Image(
modifier = Modifier
.align(CenterVertically)
.padding(horizontal = 10.dp),
painter = painterResource(id = R.drawable.ic_android),
contentDescription = "cover",
)
Column(
modifier = Modifier.weight(1F),
) {
Text(
text = song.trackName,
fontSize = 15.sp,
)
Text(
text = song.artist,
fontSize = 12.sp,
)
}
AnimatedContent(targetState = song.isFavourite) {
IconButton(
modifier = Modifier,
onClick = { onFavouriteClick(song) },
content = {
val icon = if (it) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder
Icon(icon, contentDescription = "")
},
)
}
}
}
Ostatnim elementem jest pokazanie snackbaru w momencie kliknięcia elementu listy. Jest to troszkę podchwytliwe, ponieważ cała obsługa snackbarów w Compose jest zrobiona za pomocą korutyn i funkcja showSnackbar
jest suspendowalna. Musimy stworzyć nasz własny coroutineScope
, który zostanie wykorzystany do pokazywania snackbara po kliknięciu elementu.
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
// ...
onSongClick = {
coroutineScope.launch {
snackbarHostState.showSnackbar(
"You selected: ${it.trackName} by ${it.artist}"
)
}
}
I to tyle!
Mam nadzieję, że wprowadziłem Cię w świat Jetpack Compose.
Znasz już podstawowe założenia tego narzędzia. Zapoznałeś się z podstawowymi elementami, za pomocą których możesz tworzyć bardziej złożone widoki. Jesteś w stanie napisać mały projekt aplikacji.
Cały kod, który wykorzystałem w demonstracji, znajdziesz na Githubie: