Seleziona una pagina
Jetpack Compose è pronto

Dopo qualche mese di utilizzo di Jetpack compose per la programmazione Android provo a trarre qualche conclusione per quella che è stata la mia esperienza di utilizzo in un progetto in produzione. Traccerò dunque i vantaggi principali e perché, a mio avviso, conviene migrare a Jetpack Compose il prima possibile.

E’ production ready

La risposta in breve è: si. Fino a qualche mese fa avrei detto “sni” ma ora direi che non c’è alcun motivo per cui attendere ancora a migrare.

I problemi inizialmente erano che non c’erano determinati componenti per fare ui “più avanzate”. Da questo punto di vista ora invece Jetpack Compose copre tutti gli usi più comuni e anche avanzati delle UI. Inclusi layout e posizionamento degli elementi.

Un punto dolente per un po’ è stato il testing, come ho detto, perché senza i Semantics era tutto un “hasTag” di continuo. Proprio perché non c’era un modo per definire id o cosa volesse dire un determinato nodo. Semantics invece permette di “descrivere” ogni nodo in modo che sia possibile anche testare che ciò che viene mostrato a schermo sia coerente.

le preview in jetpack compose funzionano bene

Anche il sistema di Preview è migliorato molto e per quanto meno “reattivo” rispetto agli XML, è sufficientemente fatto bene per essere usabile senza troppi rallentamenti. Inoltre permette di avere la Preview non solo di uno stato, ma di vari stati diversi e anche configurazioni diverse (es: tablet, telefoni piccoli o grandi).

preview multiple di Jetpack Compose

Inoltre è anche possibile interagire in real time con queste interfacce senza dover lanciare l’app. Un bel salto avanti rispetto al Preview statico degli XML.

Unidirectional Data Flow

Unidirectional State Flow spiegato con un diagramma

L’Unidirectional Data Flow è un concetto che arriva da Redux che permette di modellare la nostra applicazione come una serie di stati. Ne avevo accennato quando ho parlato della programmazione funzionale. Jetpack Compose dunque reagisce a questi cambiamenti di stato e ridisegna la UI a seconda dello stato presente.

In sostanza, passiamo da Loading a Successfull? Dal ViewModel alla UI viene propagato questo cambiamento di stato che si riflette quindi in un cambiamento della UI.

Il vantaggio grosso di questo approccio è che è possibile avere un approccio più funzionale e meno imperativo. Non dobbiamo gestire una selva di if a seconda che siamo in un caso o in un altro. Semplicemente forniamo una UI per uno stato e un’altra UI per un altro. E potendo riutilizzare le funzioni diventa semplice comporle in modo da avere solo quelle che ci servono.

Ma quindi stai parlando dell’MVI o è sempre Jetpack Compose con l’MVVM “standard” di Google?

La differenza è molto labile visto che l’idea è molto simile. Possiamo infatti avere serenamente Jetpack Compose sia con un’architettura MVI che un’architettura MVVM. In entrambi i casi avremmo uno stato dell’applicazione. Solo che con un’architettura MVI avremmo anche un Reducer e degli Intent da interpretare.

Qualcosa di questo genere:

data class AppState(val counter: Int)

sealed class CounterIntent(val delta: Int) {
    object Increment : CounterIntent(delta = 1)
    object Decrement : CounterIntent(delta = -1)
}

class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow(AppState(0))
    val state: StateFlow<AppState> = _state

    private fun reducer(state: AppState, intent: CounterIntent): AppState {
        return state.copy(counter = state.counter + intent.delta)
    }

    fun processIntent(intent: CounterIntent) {
        _state.value = reducer(_state.value, intent)
    }
}

In sostanza avremmo una funzione (o un oggetto) che si occupa di prendere uno stato e a seconda dell’intent che viene ricevuto, applicare delle modifiche a questo stato.

Volessimo avere un’archiettura MVVM avremmo invece del codice come il seguente:

data class AppState(val counter: Int)

class CounterViewModel : ViewModel() {
    private val _state = MutableStateFlow(AppState(0))
    val state: StateFlow<AppState> = _state

    fun updateCounter(delta: Int) = viewModelScope.launch {
        val newCounter = _state.value.counter + delta
        _state.emit(AppState(newCounter))
    }
}

E’ palese dunque che utilizzando questo modello di dati la differenza tra le due architetture si appiattisce molto. Semplicemente nell’MVI il calcolo del nuovo stato e l’emissione dello stesso sono separati. E quindi più facili da testare in modo unitario. Allo stesso tempo abbiamo un livello di indirezione maggiore che può rendere il codice semplice con più boilerplate e più complesso da mantenere.

In entrambi i casi comunque, non abbiamo azioni dirette sulla UI. La UI semplicemente reagisce ai cambiamenti di questo stato. E ci permette quindi di “fare azioni” sulla UI senza avere alcun puntatore alla stessa all’interno del viewModel. Come in questo caso:

@Composable
fun CounterScreen(viewModel: CounterViewModel) {
    val state by viewModel.state.collectAsState()

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text(text = "Counter: ${state.counter}", fontSize = 24.sp)
        Spacer(modifier = Modifier.height(16.dp))
        Row {
            Button(onClick = { viewModel.updateCounter(-1) }) {
                Text(text = "-")
            }
            Spacer(modifier = Modifier.width(16.dp))
            Button(onClick = { viewModel.updateCounter(+1) }) {
                Text(text = "+")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 400, heightDp = 200)
@Composable
fun CounterScreenPreview() {
    CounterScreen(viewModel = CounterViewModel())
}

Questa è la UI utilizzando l’architettura MVVM. Naturalmente con MVI sarebbe analoga. Il risultato è dunque il seguente:

preview di esempio del codice mostrato

Come vediamo è dunque la UI a chiamare il viewModel per fare dei cambiamenti sullo stato. Allo stesso tempo il viewModel non ha nemmeno idea di come sia fatta la UI. E non ha alcuna interazione con la stessa. Nemmeno il fragment o l’activity sono necessari per questa interazione. La view si ridisegna al cambio di stato automaticamente.

Questo naturalmente è un mero “accenno”. Una trattazione più esaustiva e completa sugli stati in Jetpack Compose e come affrontarli puoi trovarla in questo video molto ben fatto di Marco Cattaneo:

Riutilizzabilità delle funzioni

riutilizzabilità delle funzioni

Potresti aver già incontrato in giro dei bellissime <include> per includere dei “pezzi” di codice XML dentro il tuo layout passando dei parametri. Questo purtroppo è l’unico modo per condividere dei pezzi di vista tra un componente e l’altro usando XML.

Ti piacciono questi argomenti? Seguimi su Linkedin o su instagram per non perdere i prossimi articoli 🙂

In Jetpack Compose tuttavia è possibile spezzare le funzioni in più sottofunzioni. Questo permette molto facilmente di passare delle funzioni in input che magari sono già state scritte altrove.

Inoltre, figli dell’Unidirectional Data Flow, abbiamo il grosso vantaggio che non solo condivideremo facilmente la UI, bensì anche la logica di disegno che prima era sul frammento o sull’activity “ospite”.

Questo ci permette dunque di fare dei “pezzi di UI” intelligenti e molto semplici da condividere. Al posto di dover riempire i nostri file XML di codice dentro delle stringhe usando il data binding oppure dei BindingAdapter.

Esempio con XML

Ma vediamo un esempio pratico per vedere questa cosa in azione. Prendiamo questo XML di esempio di una UI fatta per Android:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="viewModel"
            type="com.example.myapp.MyViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/image"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@{viewModel.imageUrl}"
            app:imageUrl="@{viewModel.imageUrl}"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent" />

        <include
            android:id="@+id/buttons"
            layout="@layout/buttons_layout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            app:viewModel="@{viewModel}"
            app:layout_constraintTop_toBottomOf="@+id/image" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

A questo dovremmo dunque aggiungere il layout di buttons_layout:

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="viewModel"
            type="com.example.myapp.MyViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="16dp">

        <Button
            android:id="@+id/button1"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="@{() -> viewModel.doSomething()}"
            android:text="Do Something" />

        <View
            android:layout_width="16dp"
            android:layout_height="0dp" />

        <Button
            android:id="@+id/button2"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:onClick="@{() -> viewModel.doSomethingElse()}"
            android:text="Do Something Else" />
    </LinearLayout>
</layout>

In sostanza dunque abbiamo un’immagine e un paio di pulsanti. Uno che fa “qualcosa” e un altro che fa “qualcos’altro”. Utilizzando l’include riusciamo a “riutilizzare” il layout di buttons che utilizzando il viewModel in input chiama direttamente le funzioni di suo interesse.

Inoltre, l’immagine essendo gestita con glide abbiamo specificato un parametro imageUrl che abbiamo “recuperato” utilizzando un BindingAdapter. Così:

@BindingAdapter("imageUrl")
fun ImageView.setImageUrl(url: String?) {
    url?.let {
        Glide.with(context).load(url).into(this)
    }
}

Entriamo ora però nella sfida e vediamo come verrebbe fuori utilizzando Jetpack Compose.

Esempio con Jetpack Compose

Il corrispettivo del codice precedente in Jetpack Compose è il seguente:

@Composable
fun LoadImage(url: String) {
    val image = rememberImagePainter(data = url)

    Image(
        painter = image,
        contentDescription = null,
        modifier = Modifier.fillMaxWidth(),
        contentScale = ContentScale.Crop
    )
}

@Composable
fun Buttons(viewModel: MyViewModel) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Button(onClick = { viewModel.doSomething() }) {
            Text(text = "Do Something")
        }
        Spacer(modifier = Modifier.width(16.dp))
        Button(onClick = { viewModel.doSomethingElse() }) {
            Text(text = "Do Something Else")
        }
    }
}

@Composable
fun MainScreen(viewModel: MyViewModel = viewModel()) {
    Column {
        LoadImage(url = viewModel.imageUrl)
        Buttons(viewModel = viewModel)
    }
}

Come possiamo vedere non è necessario usare il BindingAdapter in quanto Jetpack Compose ha una libreria di caricamento delle immagini integrata chiamata coil-compose. Questo ne permette direttamente il caricamento senza utilizzare ulteriori librerie.

C’è dunque la funzione dei pulsanti che, prendendo sempre il viewModel in input, chiamano le relative funzioni con i due pulsanti. E infine in MainScreen viene messo assieme il tutto.

Questo permette dunque di essere più concisi, chiari e riutilizzare le logiche senza dover utilizzare data binding complessi, BindingAdapter o dover ricopiare le logiche scritte in fragment e activity.

E’ semplice integrare compose dentro un XML

integrare Jetpack Compose dentro un XML è semplice

Il vantaggio davvero grande di Jetpack Compose è che è possibile anche utilizzare un XML già esistente e “innestarci” un pezzo in compose. Questo ci permette di migrare facilmente layout vetusti a Jetpack Compose in modo progressivo.

Come avevamo visto nei fattori critici di successo di un team mobile, il non fare big bang change è cruciale per evitare che le tue modifiche abbiano un impatto troppo grande nella code base. Questo ci permette dunque di isolare le componenti nuove senza dover riscrivere quelle vecchie. Se non una minor parte.

Ti piacciono questi argomenti? Seguimi su Linkedin o su instagram per non perdere i prossimi articoli 🙂

E poter dunque migrare un po’ alla volta le UI senza dover fare balzi in avanti troppo importanti. Causando bug, regressioni, ecc. oltre a dare noie ai colleghi in fase di review (se le fate).

In questo codice vediamo un esempio di come integrare Jetpack Compose dentro un XML.

Questa è una funzione composable che possiamo voler integrare in un XML:

@Composable
fun MyComposableMessage(message: String) {
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        BasicText(text = message)
    }
}

Questo è invece un XML di esempio in cui innestarla:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!-- Resto del tuo layout XML -->

</androidx.constraintlayout.widget.ConstraintLayout>

E questo è il codice dell’activity che integra i due componenti (si può fare similmente con un fragment):

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val composeView = findViewById<ComposeView>(R.id.compose_view)
        composeView.setContent {
            MyComposableMessage(message = "Ciao, Compose!")
        }
    }
}

La cosa è dunque molto lineare e incredibilmente semplice. Se hai dunque paura che per “certe cose serva ancora l’XML”… beh usa per quelle cose l’XML senza problemi 🙂

C’è Compose anche per app Desktop, iOS, ecc.

Jetpack Compose c'è anche per desktop, ios, web, ecc.

Con Compose Multiplatform si vuole arrivare ad avere una UI per conquistarli tutti. Essendo una UI dichiarativa che prescinde dalla piattaforma Android, si può facilmente riutilizzare per più ambiti. App Desktop, iOS e anche Web!

Mentre iOS e Web sono ancora lontani da essere production ready, ho provato a fare un’app molto semplice con Jetpack Compose per Desktop e funziona perfettamente. Naturalmente le logiche sono diverse rispetto a un’app Android. Tuttavia molto codice può essere riutilizzato.

Visto che il mercato delle app desktop, specialmente per macchinari, è un mercato molto interessante… è possibile per un programmatore Android fare app desktop serenamente e senza dover imparare molto di nuovo. Questo permette sicuramente da un lato di essere più flessibili, dall’altro il non obbligare il costruttore a montare un tablet su un macchinario o dover lanciare un web server fittizio.

esempio di app desktop

Questa spinta per riuscire sempre di più con una sola tecnologia a fare applicazioni su più mondi ne rende ancora più interessante il rapido apprendimento per potersi spendere con successo su più ambiti.

Conclusioni

Naturalmente ci sono altri motivi per cui passare a utilizzare Jetpack Compose. Per esempio, gli esempi di codice di questo articolo sono stati fatti utilizzando ChatGPT dandogli indicazioni precise. E’ dunque possibile generare facilmente codice di Jetpack Compose utilizzandolo o anche tradurre XML in Jetpack Compose. E altri vantaggi come in termini di performance, ecc.

In ogni caso, penso che già i vantaggi elencati in questo articolo siano sufficienti per motivarti a dare un’occhiata a questa tecnologia. E, per chi già la sapeva, spero di aver dato qualche indicazione utile per approfondire qualche argomento di Jetpack Compose.

Se non vuoi perderti i prossimi articoli in merito, non dimenticarti di seguirmi su LinkedIn, Instagram e mettere like al post 🙂