Preparazione al colloquio Android

Domande di colloquio su Android

20 domande frequenti di colloquio su Android. Lo sviluppo Android è un ambito molto richiesto incentrato su app mobili in Kotlin, Android SDK e Jetpack, con problemi di lifecycle, performance, rete, storage, test e release. Le domande coprono livelli diversi e puoi esercitarti a rispondere a voce nel nostro simulatore di colloquio.

Inizia un colloquio AI su AndroidNessuna carta di credito richiesta. 1 sessione gratuita disponibile.
Pratica per colloqui tecnici in ingleseUna modalità in cui chi non è madrelingua può esercitarsi a superare i colloqui tecnici.

Domande per principianti

1Descrivi i callback principali del ciclo di vita di un'Activity dalla creazione alla distruzione e spiega le differenze tra onPause(), onStop() e onDestroy() per quanto riguarda il rilascio delle risorse.

Un'Activity attraversa sei callback principali del ciclo di vita: `onCreate()`, `onStart()`, `onResume()`, `onPause()`, `onStop()` e `onDestroy()`. `onCreate()` esegue le inizializzazioni una tantum, come l'inflation del layout della view. `onStart()` rende l'activity visibile e `onResume()` la porta in primo piano conferendole il focus per l'interazione. Quando ci si allontana dalla schermata, `onPause()` indica che l'activity ha perso il focus, `onStop()` indica che non è più visibile a schermo e `onDestroy()` indica la distruzione finale dell'istanza dell'activity. Per quanto riguarda il rilascio delle risorse: - `onPause()`: Deve sospendere solo operazioni rapide e specifiche del primo piano (come la pausa delle animazioni della UI o preview leggere della fotocamera), poiché l'esecuzione di codice in `onPause()` blocca direttamente l'avvio della successiva activity in arrivo. - `onStop()`: È il punto principale in cui rilasciare risorse più pesanti legate alla visibilità della UI (come listener GPS/posizione, flussi di sensori, riproduzione di media player o polling di rete). Poiché l'activity è del tutto invisibile, mantenere attive queste operazioni spreca batteria e il sistema potrebbe terminare il processo in background senza ulteriore preavviso. - `onDestroy()`: Viene utilizzato per la pulizia finale dell'istanza dell'Activity (come la rimozione di riferimenti o thread locali). Tuttavia, le risorse critiche non dovrebbero attendere `onDestroy()`, poiché Android può terminare direttamente un processo arrestato per recuperare memoria senza mai eseguire `onDestroy()`.

class LocationActivity : AppCompatActivity() {
    private var locationClient: LocationClient? = null

    override fun onStart() {
        super.onStart()
        // Start listening when visible to the user
        locationClient?.startLocationUpdates()
    }

    override fun onStop() {
        super.onStop()
        // Release when invisible to save battery and prevent leaks on process kill
        locationClient?.stopLocationUpdates()
    }
}
Prova a rispondere a questa domanda con un coach IA

2Quali sono i quattro componenti principali di un'applicazione Android e quali sono il ciclo di vita e il modello di threading dei rispettivi callback predefiniti?

I quattro componenti principali di un'applicazione Android sono Activity, Service, BroadcastReceiver e ContentProvider. Un'Activity fornisce un'interfaccia utente per le interazioni sullo schermo. Un Service esegue elaborazioni in background senza una UI dedicata. Un BroadcastReceiver rimane in ascolto e risponde ad annunci broadcast a livello di sistema o di applicazione. Un ContentProvider gestisce ed espone dati strutturati ad altre applicazioni o moduli interni. Per impostazione predefinita, i callback principali del ciclo di vita per Activity (come onCreate, onStart, onResume), Service (come onCreate, onStartCommand, onBind) e BroadcastReceiver (onReceive) vengono eseguiti in modo sincrono sul thread principale (main/UI thread) dell'applicazione. Anche i metodi di ContentProvider (come onCreate) vengono inizializzati sul thread principale, sebbene le operazioni di query/insert invocate tra processi diversi vengano eseguite sui thread del pool di thread di Binder. Poiché i callback predefiniti vengono eseguiti sul thread principale, l'esecuzione diretta al loro interno di operazioni bloccanti pesanti, come richieste di rete o I/O su disco per database di grandi dimensioni, blocca il loop della UI e genera un errore ANR (Application Not Responding).

class DataSyncService : Service() {
    private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // onStartCommand runs on the Main Thread; heavy work must be offloaded
        serviceScope.launch {
            performDiskAndNetworkSync()
            stopSelf(startId)
        }
        return START_NOT_STICKY
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel()
    }

    private fun performDiskAndNetworkSync() {
        // Blocking I/O safely executed on Dispatchers.IO
    }
}
Prova a rispondere a questa domanda con un coach IA

3Nell'architettura MVVM (Model-View-ViewModel) di Android, quali sono le responsabilità principali di un ViewModel rispetto ai controller UI (User Interface) come Activity e Fragment, e perché passare un Context a un ViewModel è sconsigliato?

Nell'architettura MVVM di Android, il ViewModel è responsabile della conservazione e della gestione dello stato relativo all'interfaccia utente (UI), dell'orchestrazione della logica di presentazione e del mantenimento dello stato attraverso le modifiche di configurazione (come le rotazioni dello schermo). Al contrario, i controller UI (Activity e Fragment) sono responsabili del rendering degli elementi dell'interfaccia sullo schermo, dell'osservazione dei cambi di stato, della gestione delle interazioni dirette dell'utente (click, gesture) e della gestione degli eventi del ciclo di vita di Android. Passare il Context di un'Activity o un riferimento a una View a un ViewModel è fortemente sconsigliato poiché i ViewModel generalmente sopravvivono al ciclo di vita dei controller UI durante i cambi di configurazione. Quando un'Activity viene distrutta e ricreata durante una rotazione, un ViewModel che ne mantiene il Context impedisce alla vecchia Activity di essere recuperata dal Garbage Collector, provocando un memory leak. Se un Context di Android è strettamente necessario per operazioni a livello di sistema, è opportuno utilizzare un Context con ambito applicazione (ad esempio tramite `AndroidViewModel` o iniettando ApplicationContext).

// Bad: Storing Activity context or View reference causes leaks
class LeakyViewModel(private val activityContext: Context) : ViewModel() {
    fun showToast() { Toast.makeText(activityContext, "Hello", Toast.LENGTH_SHORT).show() }
}

// Good: ViewModel manages state; Activity observes and handles context-specific tasks
class SafeViewModel(private val repository: UserRepository) : ViewModel() {
    private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    fun loadUser(id: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Success(repository.getUser(id))
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

4In che cosa differiscono i permessi normali e pericolosi in Android e cosa succede quando un'app richiede un permesso pericoloso a runtime?

I permessi normali comportano un rischio minimo per la privacy dell'utente o per il funzionamento del dispositivo e vengono concessi automaticamente dal sistema al momento dell'installazione dell'app quando dichiarati nel file AndroidManifest.xml (come ACCESS_NETWORK_STATE). I permessi pericolosi (detti anche permessi a runtime) regolano l'accesso a dati privati dell'utente o a funzionalità sensibili del dispositivo, come fotocamera, microfono, posizione e contatti. Quando un'app richiede un permesso pericoloso a runtime, il sistema Android mostra una finestra di dialogo di sistema che invita l'utente a consentire o negare l'accesso. Se l'utente concede il permesso, l'app esegue la funzionalità richiesta. Se l'utente nega il permesso, l'app riceve una callback di rifiuto e deve gestire la situazione in modo appropriato disabilitando o degradando la funzionalità dipendente senza arrestarsi in modo anomalo (crash), visualizzando eventualmente un'interfaccia utente esplicativa con la motivazione della richiesta.

val requestPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
    if (isGranted) {
        accessCameraFeature()
    } else {
        showPermissionDeniedExplanation()
    }
}

fun checkAndRequestPermission() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            accessCameraFeature()
        }
        shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
        }
        else -> {
            requestPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

5Qual è la differenza tra le build Android di tipo debug e release, e in che modo build type e product flavor si combinano nei build variant?

Nello sviluppo Android, i build type configurano le proprietà di pacchettizzazione e di runtime per le diverse fasi di sviluppo. Le build di debug abilitano i flag di debug (`isDebuggable = true`), utilizzano un keystore di debug predefinito e in genere disabilitano la minificazione e l'ottimizzazione del codice (R8/ProGuard) per garantire iterazioni di compilazione veloci. Le build di release disabilitano i flag di debug, abilitano le ottimizzazioni e l'offuscamento, e richiedono una chiave di firma di release sicura per la distribuzione. I product flavor rappresentano diverse versioni o destinazioni dell'app che condividono la stessa base di codice principale, come ambienti diversi (staging rispetto a produzione) o edizioni del prodotto (gratuita rispetto a a pagamento). In Gradle, i build variant sono formati dal prodotto cartesiano dei product flavor e dei build type (Build Variant = Product Flavor × Build Type). Ad esempio, combinando i flavor `staging` e `production` con i build type `debug` e `release` si ottengono quattro build variant: `stagingDebug`, `stagingRelease`, `productionDebug` e `productionRelease`.

android {
    flavorDimensions += "environment"
    productFlavors {
        create("staging") {
            dimension = "environment"
            applicationIdSuffix = ".staging"
        }
        create("production") {
            dimension = "environment"
        }
    }
    buildTypes {
        getByName("debug") {
            isDebuggable = true
        }
        getByName("release") {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

6Che cos'è una coroutine in Kotlin e in che modo la sospensione differisce dal blocco di un thread a runtime in Android?

Una coroutine in Kotlin è un'astrazione di concorrenza leggera che consente di scrivere codice asincrono e non bloccante in modo sequenziale. Più coroutine possono essere eseguite contemporaneamente su un singolo thread o su pool di thread senza il pesante sovraccarico di memoria e cambio di contesto dei thread a livello di sistema operativo. La differenza fondamentale tra sospensione e blocco risiede nell'utilizzo del thread: - Il blocco (ad esempio con Thread.sleep() o I/O sincrono su disco/rete) ferma il thread sottostante rendendolo non disponibile per qualsiasi altra operazione. Sul thread principale di Android, il blocco causa il blocco dell'interfaccia utente (UI) e genera errori di tipo ANR (Application Not Responding). - La sospensione (tramite funzioni suspend come delay()) mette in pausa la coroutine senza bloccare il thread sottostante. La coroutine acquisisce il proprio stato di esecuzione in una Continuation e cede il thread al runtime in modo che possa eseguire altre attività (come gestire le interazioni con l'interfaccia utente). Al termine dell'operazione asincrona, la coroutine viene ripresa.

suspend fun fetchUserData(): String {
    delay(1000) // Suspends execution and yields the thread
    return "User Data"
}

fun fetchUserDataBlocking(): String {
    Thread.sleep(1000) // Blocks and locks the underlying thread
    return "User Data"
}
Prova a rispondere a questa domanda con un coach IA

7In Jetpack Compose, qual è la differenza tra lo stato preservato con `remember` rispetto a `rememberSaveable`, e in che modo `mutableStateOf` guida gli aggiornamenti della UI (User Interface) attraverso le ricomposizioni?

In Jetpack Compose, `remember` memorizza un oggetto in memoria durante le ricomposizioni per tutto il tempo in cui il composable rimane nell'albero della composizione. Tuttavia, lo stato mantenuto solo con `remember` va perso durante le modifiche di configurazione (come le rotazioni dello schermo) e la terminazione del processo avviata dal sistema. Al contrario, `rememberSaveable` preserva lo stato attraverso le modifiche di configurazione e la chiusura del processo salvando e ripristinando automaticamente i valori tramite il bundle Saved Instance State di Android (oppure tramite implementazioni personalizzate di `Saver` per tipi di dati non supportati). `mutableStateOf` incapsula un valore in un oggetto osservabile `MutableState`, supportato dal sistema di stato Snapshot di Compose. Quando un composable legge il valore di un `MutableState` durante la composizione, Compose registra tale operazione di lettura. Quando il valore cambia successivamente, Compose contrassegna automaticamente tutti i composable che hanno letto quel valore come non validi e ne pianifica la ricomposizione, garantendo che l'interfaccia utente rimanga sincronizzata con lo stato aggiornato.

@Composable
fun CounterExample() {
    // Resets to 0 on screen rotation
    var localCount by remember { mutableStateOf(0) }

    // Persists across screen rotation and process death
    var persistentCount by rememberSaveable { mutableStateOf(0) }

    Column {
        Button(onClick = { localCount++; persistentCount++ }) {
            Text("Local: $localCount | Saved: $persistentCount")
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

8In che modo la null safety di Kotlin aiuta a prevenire i crash nelle app Android e quando è opportuno utilizzare un tipo nullable rispetto a un tipo non nullo?

La null safety di Kotlin aiuta a prevenire molti crash in Android integrando la gestibilità dei null direttamente nel type system. Un valore dichiarato come `String` è considerato non nullo, quindi il compilatore consente l'accesso diretto come `name.length`. Un valore dichiarato come `String?` può essere null, perciò Kotlin obbliga a gestire tale eventualità prima di utilizzarlo, riducendo le eccezioni accidentali di tipo `NullPointerException` derivanti da Intent extra opzionali, risposte di API (Application Programming Interface), argomenti di Bundle o API Java/di piattaforma. È opportuno utilizzare un tipo non nullo quando il valore è indispensabile affinché l'oggetto o la funzione siano validi. Si utilizza invece un tipo nullable quando l'assenza del valore rappresenta uno stato realmente previsto, come l'URL opzionale dell'immagine del profilo o un campo assente restituito dal server. I valori nullable vengono solitamente gestiti con chiamate sicure (safe call) come `user?.name`, con l'operatore Elvis come `user?.name ?: "Guest"`, o tramite controlli espliciti di nullità. L'operatore `!!` esclude i controlli di sicurezza e genera un'eccezione se il valore è null; per questo motivo il suo uso dovrebbe essere raro e limitato ai casi in cui lo sviluppatore può effettivamente dimostrare che il valore non può essere null.

val displayName: String? = intent.getStringExtra("display_name")

val greeting = "Hello, ${displayName ?: "Guest"}"
val lengthText = displayName?.length?.toString() ?: "No name provided"

// Risky: crashes if displayName is null
val riskyLength = displayName!!.length
Prova a rispondere a questa domanda con un coach IA

9Qual è la differenza a livello di funzionamento tra il passaggio di un Context di un'Activity e un Context dell'Application a oggetti a vita prolungata, e in che modo la scelta errata porta a un memory leak?

Il Context di un'Activity è legato direttamente al ciclo di vita di una specifica schermata e della relativa gerarchia di UI, mentre il Context dell'Application è legato al ciclo di vita dell'intero processo dell'applicazione. Quando un'Activity termina o viene ricreata (ad esempio durante una modifica di configurazione come la rotazione dello schermo), il suo ciclo di vita finisce e la sua memoria dovrebbe essere rilasciata dal Garbage Collector (GC). Se si passa il Context di un'Activity a un oggetto a vita prolungata — come un singleton, un campo statico o un thread in background di lunga durata — tale oggetto mantiene un riferimento forte (strong reference) all'Activity. Poiché gli oggetti a vita prolungata fungono da GC root o sono raggiungibili da essi, il garbage collector non può deallocare l'Activity distrutta. In questo modo non viene trattenuta solo l'istanza dell'Activity, ma anche l'intera gerarchia di View a essa collegata, i drawable e le risorse, generando un consistente memory leak. Al contrario, passare applicationContext è sicuro per componenti a vita prolungata perché il suo ciclo di vita coincide con quello del processo.

class LocationRepository private constructor(private val context: Context) {
    companion object {
        @Volatile
        private var INSTANCE: LocationRepository? = null

        fun getInstance(context: Context): LocationRepository {
            return INSTANCE ?: synchronized(this) {
                // SAFE: Using context.applicationContext prevents retaining an Activity reference
                INSTANCE ?: LocationRepository(context.applicationContext).also { INSTANCE = it }
            }
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

10Qual è la differenza tra una chiave di upload e una chiave di firma dell'app quando si utilizza Google Play App Signing?

Con Google Play App Signing, la chiave di upload e la chiave di firma dell'app svolgono due ruoli di sicurezza distinti. La chiave di upload viene conservata dallo sviluppatore per firmare l'artefatto di build (AAB) prima di caricarlo su Google Play Console, verificando l'identità dello sviluppatore verso Google. La chiave di firma dell'app viene archiviata in modo sicuro all'interno dell'infrastruttura cloud di Google e viene utilizzata da Google Play per firmare gli APK generati che vengono effettivamente distribuiti e installati sui dispositivi degli utenti finali, stabilendo l'identità crittografica permanente dell'app su Android. Un grande vantaggio operativo è il ripristino delle chiavi: se uno sviluppatore perde o compromette la propria chiave di upload, il supporto di Google Play può reimpostarla dopo aver verificato l'identità dello sviluppatore senza interrompere il percorso di aggiornamento dell'app. Nel modello precedente, in cui gli sviluppatori gestivano direttamente la chiave di firma dell'app, la perdita della chiave significava non poter mai più aggiornare l'applicazione.

android {
    signingConfigs {
        create("release") {
            storeFile file("my-upload-key.jks")
            storePassword System.getenv("KEYSTORE_PASSWORD")
            keyAlias "upload-alias"
            keyPassword System.getenv("KEY_PASSWORD")
        }
    }
    buildTypes {
        release {
            signingConfig signingConfigs.getByName("release")
            minifyEnabled true
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

Domande intermedie

11Una schermata perde i dati del modulo inseriti dall'utente dopo la rotazione del dispositivo. In che modo diagnosticheresti sistematicamente se il problema è dovuto alla mancata persistenza dello stato (saved state), a uno scoping errato del ViewModel o al rebinding della vista?

Per diagnosticare sistematicamente il motivo per cui i dati del modulo vanno persi dopo la rotazione del dispositivo, è opportuno isolare tre aree principali: 1. **Scoping e conservazione del ViewModel:** Verificare che l'istanza del ViewModel venga effettivamente mantenuta durante la modifica della configurazione anziché essere ricreata. Registrare nei log l'hash code del ViewModel (`viewModel.hashCode()`) tra le rotazioni. Assicurarsi che venga ottenuto tramite proprietà delegate come `by viewModels()` / `by activityViewModels()` o `ViewModelProvider(this)`, e non tramite l'istanziazione diretta del costruttore (ad es. `MyViewModel()`). 2. **Stato salvato e ripristino dell'ID della vista:** Verificare se il modulo si basava esclusivamente sul ripristino predefinito della gerarchia delle viste di Android. Le viste devono avere un attributo `android:id` definito nel layout per partecipare al salvataggio/ripristino automatico dello stato della vista. Se si utilizzano viste personalizzate o `SavedStateHandle`, verificare che `onSaveInstanceState` / `SavedStateHandle` salvi e ripristini effettivamente i campi pertinenti. 3. **Logica di rebinding della vista e osservatori:** Ispezionare `onViewCreated` o le sottoscrizioni agli osservatori (`StateFlow`, `LiveData`). Verificare che le viste appena create si sottoscrivano nuovamente in modo corretto o se il codice di inizializzazione della vista sovrascrive accidentalmente il testo ripristinato con valori vuoti predefiniti. Inoltre, verificare la presenza di binding bidirezionali o cicli di `TextWatcher` in cui una vista ripristinata vuota emette immediatamente un aggiornamento vuoto verso il ViewModel.

class FormFragment : Fragment(R.layout.fragment_form) {
    // 1. Ensure correct lifecycle scoping
    private val viewModel: FormViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        Log.d("FormDiag", "VM instance hash: ${viewModel.hashCode()}")

        // 2. Observe state without clobbering input during view recreation
        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.formText.collect { text ->
                    if (binding.inputField.text.toString() != text) {
                        binding.inputField.setText(text)
                    }
                }
            }
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

12In che modo la moderna Activity Result API (Application Programming Interface) di Jetpack sostituisce la modalità legacy startActivityForResult e come protegge dalla perdita di callback durante la ricreazione dell'Activity?

La moderna Activity Result API di Jetpack sostituisce le funzioni legacy `startActivityForResult()` e `onActivityResult()` con un meccanismo tipizzato e disaccoppiato basato su contratti. Invece di gestire codici di richiesta interi arbitrari e complessi blocchi switch in `onActivityResult`, i chiamanti definiscono un `ActivityResultContract<I, O>` (che specifica i parametri Intent di input e l'output analizzato previsto) e registrano una callback tipizzata usando `registerForActivityResult()` per ottenere un `ActivityResultLauncher`. L'API protegge dalla perdita di callback durante la ricreazione dell'Activity o la terminazione del processo attraverso una registrazione rigorosa del ciclo di vita: 1. Quando viene avviata un'Activity esterna (come una fotocamera o un selettore di documenti), l'Activity chiamante può essere distrutta dal sistema operativo a causa della pressione sulla memoria o di modifiche di configurazione. 2. Richiedendo che `registerForActivityResult()` sia chiamata incondizionatamente prima che l'Activity o il Fragment raggiunga lo stato `STARTED` (in genere come inizializzatore di proprietà o all'interno di `onCreate`), la callback del risultato viene registrata in `ActivityResultRegistry` prima che avvenga il ripristino dello stato. 3. Quando l'activity avviata restituisce il controllo e l'activity host viene ricreata, `ActivityResultRegistry` abbina il risultato in sospeso del sistema con la callback appena registrata e invia il risultato in sicurezza.

class ProfileActivity : AppCompatActivity() {
    // Registered unconditionally during initialization / before onStart()
    private val takePictureLauncher = registerForActivityResult(
        ActivityResultContracts.TakePicturePreview()
    ) { bitmap: Bitmap? ->
        bitmap?.let {
            findViewById<ImageView>(R.id.avatarImage).setImageBitmap(it)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_profile)

        findViewById<Button>(R.id.btnCapture).setOnClickListener {
            takePictureLauncher.launch(null)
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

13Metti a confronto le architetture MVVM (Model-View-ViewModel) e MVI (Model-View-Intent) per una schermata complessa: in che modo i reducer di stato e il dispatching degli intent in MVI garantiscono aggiornamenti di stato atomici rispetto a MVVM?

Nel tradizionale MVVM, un ViewModel espone spesso molteplici stream reattivi indipendenti (come `isLoading`, `userData`, `errorMessage` sotto forma di proprietà distinte `StateFlow` o `LiveData`) oppure molteplici metodi pubblici di aggiornamento. Quando diverse operazioni asincrone terminano in modo concorrente, questi stream possono aggiornarsi indipendentemente, causando race condition, stati intermedi incompleti o sfarfallio visivo. In MVI (Model-View-Intent), la gestione dello stato segue un rigoroso flusso di dati unidirezionale (UDF, Unidirectional Data Flow) basato su tre elementi: 1. Un singolo `UiState` immutabile che rappresenta l'intero stato della schermata. 2. Tipi discreti di `UiIntent` (o Action) che rappresentano tutte le interazioni dell'utente e gli eventi di sistema. 3. Una funzione pura di State Reducer: `(PreviousState, UiIntent) -> NewState`. MVI garantisce aggiornamenti atomici dello stato perché tutti gli eventi vengono inviati come intent distinti e instradati in modo sequenziale attraverso il reducer di stato. Il reducer riceve uno snapshot immutabile dello stato esistente e genera uno snapshot di stato completamente nuovo con tutte le proprietà correlate aggiornate simultaneamente. Poiché gli aggiornamenti di stato sono centralizzati e le transizioni sono sequenziali, l'interfaccia utente non osserva mai uno snapshot di stato parziale, non sincronizzato o contraddittorio.

data class ScreenState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

sealed interface ScreenIntent {
    data object Refresh : ScreenIntent
    data class DataLoaded(val items: List<Item>) : ScreenIntent
    data class LoadFailed(val message: String) : ScreenIntent
}

fun reduce(state: ScreenState, intent: ScreenIntent): ScreenState = when (intent) {
    is ScreenIntent.Refresh -> state.copy(isLoading = true, error = null)
    is ScreenIntent.DataLoaded -> state.copy(isLoading = false, items = intent.items, error = null)
    is ScreenIntent.LoadFailed -> state.copy(isLoading = false, error = intent.message)
}
Prova a rispondere a questa domanda con un coach IA

14Come progetteresti un flusso per i permessi a runtime che gestisca la prima richiesta, la UI (User Interface) con la motivazione, il rifiuto, l'opzione 'non chiedere più' e un fallback alle impostazioni senza forzare l'utente?

Un flusso di gestione dei permessi a runtime incentrato sull'utente e non coercitivo adotta la divulgazione progressiva, spiegazioni chiare e un degrado controllato (graceful degradation) per tutti gli stati di rifiuto: 1. **Richiesta nel contesto (prima volta)**: richiedere i permessi solo nel momento del bisogno, quando l'utente interagisce con una funzionalità specifica (ad esempio toccando 'Scansiona codice' per il permesso della fotocamera), anziché all'avvio dell'app. 2. **UI di spiegazione (rationale)**: quando `shouldShowRequestPermissionRationale()` restituisce `true`, mostrare una spiegazione chiara all'interno dell'app (come un bottom sheet o una finestra di dialogo) spiegando perché il permesso è necessario e quale beneficio offre prima di avviare il prompt di sistema. 3. **Degrado controllato in caso di rifiuto**: se l'utente rifiuta la richiesta, rispettare la scelta senza bloccare le funzionalità non correlate. Fornire un flusso alternativo ove possibile (ad esempio l'inserimento manuale del testo se l'accesso alla fotocamera viene negato). 4. **Rifiuto permanente ('Non chiedere più') e fallback alle impostazioni**: se il permesso viene negato e `shouldShowRequestPermissionRationale()` restituisce `false` (l'utente ha selezionato 'Non chiedere più' o ha rifiutato ripetutamente), spiegare all'utente perché la funzionalità non è disponibile e mostrare un pulsante opzionale che lo reindirizzi alle impostazioni dell'app tramite `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Assicurarsi che l'utente possa facilmente annullare o tornare indietro senza rimanere bloccato.

fun onScanButtonClicked() {
    when {
        ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED -> {
            openScanner()
        }
        activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) -> {
            showRationaleDialog(onConfirm = { cameraPermissionLauncher.launch(Manifest.permission.CAMERA) })
        }
        else -> {
            cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
        }
    }
}

fun handlePermissionResult(isGranted: Boolean) {
    if (isGranted) {
        openScanner()
    } else if (!activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        showSettingsRedirectDialog()
    } else {
        showManualEntryFallback()
    }
}
Prova a rispondere a questa domanda con un coach IA

15Come progetteresti una pipeline CI/CD (Continuous Integration/Continuous Delivery) per Android, dalla validazione della pull request alla creazione di una release candidate firmata, ai test interni, al caricamento sulle tracce di Play e alla promozione del rollout?

Una pipeline CI/CD per Android pronta per la produzione inizia nella fase di validazione della pull request con verifiche automatiche rapide: analisi statica (ktlint, detekt, Android Lint), test unitari e compilazione di una build di debug. Al momento del merge nel branch principale o di release, si attiva la pipeline di rilascio per generare un Android App Bundle (`.aab`) Release Candidate (RC) firmato, utilizzando le credenziali del release keystore gestite nel sistema di CI, l'ottimizzazione e riduzione del codice con ProGuard/R8 e l'avanzamento automatico di versione. L'AAB firmato e i relativi file di mapping per la deoffuscazione vengono caricati sulla traccia di test interno di Google Play (o su Firebase App Distribution) tramite strumenti come Gradle Play Publisher (GPP) o Fastlane, dove vengono eseguiti smoke test o test di strumentazione automatizzati. Una volta approvata la build dal team di QA interno e dagli stakeholder, l'esatto artefatto binario viene promosso verso i canali successivi (Internal -> Alpha/Beta chiusa -> Rollout in produzione) senza ricompilare dal codice sorgente. Infine, rollout graduali (es. 5% -> 20% -> 100%) affiancati dal monitoraggio automatico del crash rate e dei vitali dell'app garantiscono un rilascio sicuro.

lane :promote_internal_to_beta do
  upload_to_play_store(
    track: 'internal',
    track_promote_to: 'beta',
    skip_upload_aab: true, # Promotes existing binary without rebuilding
    skip_upload_metadata: false,
    skip_upload_changelogs: false
  )
end
Prova a rispondere a questa domanda con un coach IA

16Perché intercettare `Exception` o `Throwable` generici senza rilanciare `CancellationException` interrompe la cancellazione delle coroutine e come si diagnosticano i problemi legati alla cancellazione?

Le coroutine di Kotlin si basano su una cancellazione cooperativa implementata tramite `CancellationException`. Quando il `Job` di una coroutine viene annullato, le funzioni di sospensione (come `delay` o `yield`) lanciano una `CancellationException` per risalire lo stack di chiamate e terminare la coroutine. Se il codice intercetta `Exception` o `Throwable` generici senza rilanciare `CancellationException`, il segnale di cancellazione viene ignorato. La coroutine non riesce a terminare e continua l'esecuzione, creando una "coroutine zombie" che consuma CPU/memoria, perde risorse e può innescare transizioni di stato non valide o crash interagendo con componenti UI già distrutti. Per diagnosticare i problemi di cancellazione: 1. Verificare la corretta gestione delle eccezioni assicurandosi che `CancellationException` venga esplicitamente rilanciata o che vengano catturate solo eccezioni specifiche di dominio invece di `Throwable`/`Exception` generici. 2. Ispezionare lo stato delle coroutine nelle build di debug usando il Kotlin Coroutines Debugger (`-Dkotlinx.coroutines.debug`) o il Coroutines Inspector di Android Studio per identificare le coroutine in esecuzione che avrebbero dovuto essere terminate. 3. Registrare gli stati del ciclo di vita della coroutine (ad esempio, `job.isActive`, `job.isCancelled`) o utilizzare log strutturati nei callback di completamento della cancellazione (`job.invokeOnCompletion`).

import kotlinx.coroutines.CancellationException

// ANTI-PATTERN: Swallows cancellation, creating a zombie coroutine
try {
    doSuspendingWork()
} catch (e: Exception) {
    logError(e)
}

// CORRECT: Preserves cancellation propagation
try {
    doSuspendingWork()
} catch (e: Exception) {
    if (e is CancellationException) throw e
    logError(e)
}
Prova a rispondere a questa domanda con un coach IA

17Come funzionano lo state hoisting e l'UDF (Unidirectional Data Flow) in Jetpack Compose, e come si decide se lo stato appartiene a un composable foglia, a un composable genitore o a un ViewModel?

Lo state hoisting è un pattern in Jetpack Compose in cui lo stato viene spostato verso l'alto nella gerarchia di composizione per rendere un composable stateless, trasformandolo in un componente UI puro. Questo abilita l'UDF (Unidirectional Data Flow), in cui lo stato fluisce verso il basso (dal genitore/ViewModel ai composable figli sotto forma di argomenti) e gli eventi fluiscono verso l'alto (dai composable figli al genitore/ViewModel come callback). La decisione su dove collocare lo stato segue queste linee guida: 1. **Composable foglia (Stato UI locale):** Se lo stato è puramente temporaneo, visivo e non necessario ad alcun genitore o elemento fratello (ad esempio l'esecuzione di un'animazione interna di espansione/compressione o un effetto ripple locale), va mantenuto locale nel composable foglia tramite `remember`. 2. **Composable genitore (Stato UI hoisted):** Se composable di pari livello devono condividere lo stato o reagire a esso, oppure se il genitore controlla la visibilità o la validazione del componente, si esegue l'hoisting dello stato verso il genitore comune immediato. Il composable foglia diventa stateless (accetta `value` e `onValueChange`). 3. **ViewModel (Stato della schermata / di business):** Se lo stato rappresenta dati di business, sopravvive ai cambi di configurazione, guida la navigazione o richiede interazioni con i livelli domain/repository, appartiene a un ViewModel ed è esposto come stato osservabile (ad esempio `StateFlow`). Il ViewModel gestisce la business logic e aggiorna lo stato della UI di conseguenza.

// Stateless Leaf Composable
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
    TextField(value = query, onValueChange = onQueryChange)
}

// Stateful Screen / ViewModel Owner
@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    SearchBar(
        query = uiState.query,
        onQueryChange = { newQuery -> viewModel.onQueryChanged(newQuery) }
    )
}
Prova a rispondere a questa domanda con un coach IA

Domande per esperti

18Come progetteresti telemetria, breadcrumb di crash e vincoli architetturali per rilevare, diagnosticare e prevenire la perdita di stato di FragmentManager e le race condition di navigazione in produzione?

La perdita di stato di FragmentManager (`IllegalStateException: Can not perform this action after onSaveInstanceState`) e le race condition nella navigazione asincrona si verificano quando operazioni asincrone — come callback di rete o stream reattivi — tentano transazioni UI dopo che il ciclo di vita dell'host ha superato `onSaveInstanceState()` o `onStop()`. Per rilevarle e diagnosticarle in produzione, si implementano telemetria del ciclo di vita e breadcrumb tramite `Application.ActivityLifecycleCallbacks` e `FragmentManager.FragmentLifecycleCallbacks`, registrando transizioni con timestamp, conteggio del backstack in sospeso e contesto di esecuzione prima dei crash. Per prevenire la perdita di stato a livello architetturale, le transazioni UI e di navigazione devono essere guidate esclusivamente da osservatori di stato consapevoli del ciclo di vita (lifecycle-aware, come `repeatOnLifecycle(Lifecycle.State.RESUMED)` o `StateFlow` raccolti con binding al ciclo di vita) anziché da callback asincrone grezze. Gli eventi di navigazione dovrebbero essere modellati come transizioni di stato discrete UDF (Unidirectional Data Flow) o eventi a consumo singolo (single-shot) elaborati solo quando lo stato del ciclo di vita è almeno `STARTED` o `RESUMED`. Inoltre, i vincoli architetturali dovrebbero consentire `commitStateLoss()` solo in contesti transitori espliciti non ripristinabili, oppure, preferibilmente, guidare la migrazione verso il componente Jetpack Navigation con vincoli di ciclo di vita rigorosi. L'analisi statica tramite regole personalizzate di Android Lint e il controllo a runtime nelle build di debug (ad esempio Fragment StrictMode) consentono di intercettare le violazioni prima che raggiungano la produzione.

class NavigationDispatcher @Inject constructor() {
    private val _events = Channel<NavigationCommand>(Channel.BUFFERED)
    val events: Flow<NavigationCommand> = _events.receiveAsFlow()

    fun navigate(command: NavigationCommand) {
        _events.trySend(command)
    }
}

// In Fragment / Activity
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
        navigationDispatcher.events.collect { command ->
            CrashReporting.leaveBreadcrumb("Navigating to ${command.destination} at state ${lifecycle.currentState}")
            command.execute(parentFragmentManager)
        }
    }
}
Prova a rispondere a questa domanda con un coach IA

19In che modo le funzionalità di gestione energetica a livello di sistema come Doze Mode, App Standby Buckets e i task killer degli OEM (Original Equipment Manufacturer) influiscono sulla pianificazione in background, e come progetteresti un motore di sincronizzazione resiliente utilizzando WorkManager?

Le ottimizzazioni della batteria a livello di sistema limitano fortemente l'esecuzione in background. Doze Mode riduce l'accesso alla CPU, alla rete e ai job in background durante i periodi di inattività, consentendo l'esecuzione solo durante finestre di manutenzione periodiche. Gli App Standby Buckets limitano dinamicamente la frequenza dei job in background e l'accesso alla rete in base a quanto recentemente è stata usata l'app (da Active fino a Restricted o Never). Inoltre, i power manager personalizzati e aggressivi di alcuni OEM (come MIUI o OneUI) arrestano frequentemente i processi in background, ignorano gli allarmi standard e revocano i permessi di avvio automatico indipendentemente dal comportamento predefinito di AOSP. Per realizzare un motore di sincronizzazione resiliente, WorkManager rappresenta la scelta standard poiché astrae JobScheduler, AlarmManager e BroadcastReceiver, integrandosi direttamente con i vincoli del sistema operativo. WorkManager consente di dichiarare precondizioni rigide di esecuzione (come `NetworkType.CONNECTED`, `requiresBatteryNotLow(true)`), rimandando automaticamente l'esecuzione alle finestre di manutenzione di Doze o al ripristino della connettività. Per resistere a terminazioni impreviste del processo, perdite temporanee di rete e arresti forzati da parte degli OEM, il motore di sincronizzazione deve basarsi su due principi fondamentali: backoff esponenziale e idempotenza end-to-end. WorkManager deve essere configurato con `BackoffPolicy.EXPONENTIAL` per evitare problemi di thundering herd sui server di backend al risveglio da Doze. Inoltre, i worker devono trattare le operazioni di sincronizzazione come atomiche e idempotenti — utilizzando ID di transazione deterministici, flag di stato locali e chiavi di deduplicazione lato server — in modo che, se un worker viene interrotto bruscamente e reinserito in coda, il nuovo tentativo non duplichi dati né corrompa i database locali.

val syncConstraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .build()

val syncWorkRequest = OneTimeWorkRequestBuilder<SyncWorker>()
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        WorkRequest.MIN_BACKOFF_MILLIS,
        TimeUnit.MILLISECONDS
    )
    .addTag("data_sync_tag")
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "unique_data_sync",
    ExistingWorkPolicy.KEEP,
    syncWorkRequest
)
Prova a rispondere a questa domanda con un coach IA

20Come progetteresti un'architettura Android scalabile e multi-team utilizzando il pattern di separazione dei moduli 'API (Application Programming Interface)-Implementation' per ottimizzare i tempi di build con Gradle e garantire confini contrattuali rigorosi?

In una codebase Android enterprise multi-team, il pattern API-Implementation (API-Impl) suddivide ciascuna feature o modulo di dominio in due sottoprogetti Gradle distinti: un modulo leggero `:feature:api` contenente interfacce pubbliche, modelli e contratti di navigazione, e un modulo `:feature:impl` contenente la logica di business interna, la UI e le implementazioni dei repository. I moduli consumatori dipendono esclusivamente da `:feature:api` tramite `implementation project(':feature:api')`, mentre il modulo root `:app` o composition root dedicate collegano le implementazioni concrete tramite Dependency Injection (ad es. Dagger/Hilt). Questo pattern ottimizza drasticamente le prestazioni di build di Gradle grazie alla stabilità dell'ABI (Application Binary Interface) e all'isolamento del classpath di compilazione. Quando gli ingegneri modificano i dettagli implementativi in `:feature:impl`, l'ABI pubblica di `:feature:api` rimane invariata. Di conseguenza, Gradle evita di ricompilare tutti i moduli a valle che dipendono solo da `:feature:api`, massimizzando l'efficacia della Gradle Remote Build Cache e della cache di configurazione. Dal punto di vista organizzativo e di governance, questo pattern stabilisce confini chiari di proprietà tra i team mediante strumenti come CODEOWNERS. I team possono far evolvere in sicurezza i propri dettagli implementativi interni senza esporre classi private, prevenendo accoppiamenti indesiderati e dipendenze circolari in organizzazioni ampie.

// :feature:checkout:api/src/main/java/com/example/checkout/api/CheckoutApi.kt
package com.example.checkout.api

interface CheckoutLauncher {
    fun launchCheckoutFlow(cartId: String)
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/CheckoutLauncherImpl.kt
package com.example.checkout.impl

import com.example.checkout.api.CheckoutLauncher
import javax.inject.Inject

internal class CheckoutLauncherImpl @Inject constructor(
    private val paymentGateway: PaymentGateway
) : CheckoutLauncher {
    override fun launchCheckoutFlow(cartId: String) {
        // Concrete checkout implementation
    }
}

// :feature:checkout:impl/src/main/java/com/example/checkout/impl/di/CheckoutModule.kt
package com.example.checkout.impl.di

import com.example.checkout.api.CheckoutLauncher
import com.example.checkout.impl.CheckoutLauncherImpl
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
internal abstract class CheckoutBindingModule {
    @Binds
    abstract fun bindCheckoutLauncher(impl: CheckoutLauncherImpl): CheckoutLauncher
}
Prova a rispondere a questa domanda con un coach IA