20 usein kysyttyä Android-haastattelukysymystä. Android-kehitys on kysytty ala, jossa rakennetaan Kotlin-pohjaisia mobiilisovelluksia, käytetään Android SDK:ta ja Jetpackia sekä ratkaistaan lifecycle-, suorituskyky-, verkko-, tallennus-, testaus- ja julkaisutehtäviä. Kysymykset kattavat eri tasot, ja voit harjoitella niihin vastaamista ääneen haastatteluharjoittajassamme.
1Käy läpi Activityn elinkaaren keskeiset takaisinkutsut luonnista tuhoamiseen ja selitä funktioiden onPause(), onStop() ja onDestroy() erot resurssien vapauttamisessa.
Activity käy läpi kuusi keskeistä elinkaaren takaisinkutsua: `onCreate()`, `onStart()`, `onResume()`, `onPause()`, `onStop()` ja `onDestroy()`.
`onCreate()` suorittaa kertaluonteisen alustuksen, kuten näkymän luomisen (`view inflation`). `onStart()` tekee aktiviteetista näkyvän, ja `onResume()` tuo sen etualalle, jolloin sillä on interaktiivinen kohdistus (focus). Pois siirryttäessä `onPause()` ilmaisee, että aktiviteetti menetti kohdistuksen, `onStop()` ilmaisee, ettei se ole enää näkyvissä näytöllä, ja `onDestroy()` ilmaisee aktiviteetti-instanssin lopullista purkamista.
Resurssien vapauttamisen osalta erot ovat seuraavat:
- `onPause()`: Tässä tulee keskeyttää vain nopeat, suoraan etualaan liittyvät toiminnot (kuten käyttöliittymäanimaatiot tai kevyt kameran esikatselu), koska koodin suorittaminen `onPause()`-vaiheessa estää suoraan seuraavan saapuvan aktiviteetin käynnistymisen.
- `onStop()`: Tämä on ensisijainen paikka vapauttaa raskaammat resurssit, jotka liittyvät käyttöliittymän näkyvyyteen (kuten GPS- ja sijaintikuuntelijat, anturisyötteet, mediasoittimen toisto tai säännölliset verkkokyselyt). Koska aktiviteetti on täysin näkymätön, näiden pitäminen käynnissä kuluttaa akkua, ja järjestelmä saattaa sammuttaa taustaprosessin ilman erillistä ilmoitusta.
- `onDestroy()`: Käytetään Activity-instanssin lopulliseen siivoukseen (kuten paikallisten säikeiden tai viittausten tyhjentämiseen). Kriittisten resurssien vapauttamista ei kuitenkaan pidä jättää `onDestroy()`-vaiheeseen, sillä Android voi vapauttaa muistia lopettamalla pysäytetyn sovellusprosessin suoraan suorittamatta lainkaan `onDestroy()`-metodia.
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()
}
}
2Mitkä ovat Android-sovelluksen neljä pääkomponenttia, ja millainen on niiden oletustakaisinkutsujen elinkaari- ja säiemalli?
Android-sovelluksen neljä pääkomponenttia ovat Activity, Service, BroadcastReceiver ja ContentProvider. Activity tarjoaa käyttöliittymän näkymän vuorovaikutusta varten. Service suorittaa tausta-ajoa ilman erillistä käyttöliittymää. BroadcastReceiver kuuntelee ja reagoi järjestelmänlaajuisiin tai sovellustason yleislähetyksiin. ContentProvider hallitsee ja tarjoaa strukturoitua dataa muille sovelluksille tai sisäisille moduuleille. Oletusarvoisesti Activityn (kuten onCreate, onStart, onResume), Servicen (kuten onCreate, onStartCommand, onBind) ja BroadcastReceiverin (onReceive) tärkeimmät elinkaaritakaisinkutsut suoritetaan synkronisesti sovelluksen pääsäikeessä (UI-säikeessä). Myös ContentProviderin metodit (kuten onCreate) alustetaan pääsäikeessä, vaikka prosessien välillä kutsutut query- ja insert-operaatiot ajetaankin Binder-säiepoolin säikeissä. Koska oletustakaisinkutsut ajetaan pääsäikeessä, raskaiden estävien operaatioiden – kuten verkkopyyntöjen tai suurten tietokantalevyoperaatioiden – suorittaminen suoraan niissä lukitsee käyttöliittymäsilmukan ja aiheuttaa ANR-virheen (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
}
}
3Mitkä ovat ViewModelin päävastuut Androidin MVVM-arkkitehtuurissa (Model-View-ViewModel) verrattuna UI-ohjaimiin (User Interface), kuten Activity- ja Fragment-komponentteihin, ja miksi Context-olion välittämistä ViewModelille ei suositella?
Androidin MVVM-arkkitehtuurissa ViewModel vastaa käyttöliittymään liittyvän tilan säilyttämisestä ja hallinnasta, esityslogiikan koordinoinnista sekä konfiguraatiomuutoksista (kuten näytön kääntämisestä) selviytymisestä. Sen sijaan UI-ohjaimet (Activityt ja Fragmentit) vastaavat UI-elementtien piirtämisestä näytölle, tilamuutosten havainnoinnista, suorien käyttäjäinteraktioiden (kuten klikkausten ja eleiden) käsittelystä sekä Androidin elinkaaritapahtumien hallinnasta. Activity Context- tai View-viitteen välittämistä ViewModelille vältetään vahvasti siksi, että ViewModelin elinkaari kestää yleensä pidempään kuin UI-ohjaimien elinkaari konfiguraatiomuutosten yli. Kun Activity tuhotaan ja luodaan uudelleen näytön käännön yhteydessä, sen Context-viitteen säilyttävä ViewModel estää vanhan Activityn vapauttamisen roskienkeruussa (Garbage Collector), mikä aiheuttaa muistivuodon. Jos Androidin Context on ehdottoman välttämätön järjestelmätason toimintoja varten, tulisi käyttää sovelluslaajuista Contextia (kuten `AndroidViewModel`-luokan kautta tai injektoimalla 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))
}
}
}
4Miten Androidin tavanomaiset ja vaaralliset käyttöoikeudet eroavat toisistaan, ja mitä tapahtuu, kun sovellus pyytää vaarallista käyttöoikeutta suoritusaikana?
Tavanomaiset käyttöoikeudet (normal permissions) aiheuttavat minimaalisen riskin käyttäjän yksityisyydelle tai laitteen toiminnalle, ja järjestelmä myöntää ne automaattisesti sovelluksen asennuksen yhteydessä, kun ne on määritelty `AndroidManifest.xml`-tiedostossa (kuten `ACCESS_NETWORK_STATE`). Vaaralliset käyttöoikeudet (dangerous permissions, kutsutaan myös suoritusaikaisiksi käyttöoikeuksiksi) hallitsevat pääsyä käyttäjän yksityisiin tietoihin tai rajoitettuihin laiteominaisuuksiin, kuten kameraan, mikrofoniin, sijaintiin ja yhteystietoihin. Kun sovellus pyytää vaarallista käyttöoikeutta suoritusaikana, Android-järjestelmä näyttää järjestelmädialogin, jossa käyttäjää pyydetään sallimaan tai kieltämään pääsy. Jos käyttäjä myöntää käyttöoikeuden, sovellus suorittaa pyydetyn toiminnon. Jos käyttäjä kieltää sen, sovellus saa tiedon hylkäyksestä takaisinkutsun kautta ja sen on käsiteltävä tilanne hallitusti poistamalla kyseinen toiminto käytöstä tai rajoittamalla sitä kaatumatta sekä tarvittaessa näyttämällä käyttäjälle selittävä käyttöliittymäviesti.
5Mitä eroa on Androidin debug- ja release-koontiversioilla, ja miten koontityypit (build types) ja tuoteversiot (product flavors) yhdistyvät koontivarianteiksi?
Android-kehityksessä koontityypit (build types) määrittävät paketointi- ja suoritusaikaiset ominaisuudet kehityksen eri vaiheisiin. Debug-versiot ottavat käyttöön virheenkorjausliput (`isDebuggable = true`), käyttävät oletusarvoista debug-avainsäilöä ja yleensä poistavat koodin minimoinnin ja karsinnan (R8/ProGuard) käytöstä nopeiden koontisyklien takaamiseksi. Release-versiot poistavat virheenkorjausliput käytöstä, ottavat käyttöön optimoinnit ja koodin hämärtämisen sekä vaativat suojatun allekirjoitusavaimen jakelua varten. Tuoteversiot (product flavors) edustavat sovelluksen eri variaatioita tai kohteita, jotka jakavat saman ydinpohjan, kuten eri ympäristöjä (staging vs. production) tai tuotepaketteja (ilmainen vs. maksullinen). Gradlessa koontivariantit (build variants) muodostuvat tuoteversioiden ja koontityyppien karteesisena tulona (Build Variant = Product Flavor × Build Type). Esimerkiksi yhdistämällä tuoteversiot `staging` ja `production` koontityyppeihin `debug` ja `release` saadaan neljä koontivarianttia: `stagingDebug`, `stagingRelease`, `productionDebug` ja `productionRelease`.
6Mikä on korutiini Kotlinissa, ja miten suorituksen keskeyttäminen (suspension) eroaa säikeen estämisestä (blocking) ajonaikaisesti Androidissa?
Korutiini on Kotlinissa kevyt rinnakkaisuuden abstraktio, joka mahdollistaa asynkronisen, estämättömän koodin kirjoittamisen peräkkäisessä muodossa. Useita korutiineja voi suorittaa samanaikaisesti yhdellä säikeellä tai useiden säiepoolien yli ilman käyttöjärjestelmätason säikeiden tuomaa suurta muisti- ja kontekstinvaihtorasitetta. Keskeinen ero keskeyttämisen ja estämisen välillä liittyy säikeen hyödyntämiseen: - Estäminen (esim. Thread.sleep() tai synkroninen levyn/verkon I/O) pysäyttää taustalla olevan säikeen eikä se ole käytettävissä muihin tehtäviin. Androidin pääsäikeessä estäminen jumiuttaa käyttöliittymän ja aiheuttaa ANR (Application Not Responding) -virheitä. - Keskeyttäminen (suspend-funktioiden, kuten delay(), avulla) pysäyttää korutiinin pysäyttämättä alla olevaa säiettä. Korutiini tallentaa suoritustilansa Continuation-olioon ja vapauttaa säikeen takaisin ajonaikaiselle ympäristölle muiden tehtävien (kuten käyttöliittymätapahtumien) käsittelyä varten. Kun asynkroninen operaatio valmistuu, korutiinin suoritusta jatketaan.
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"
}
7Mitä eroa on Jetpack Composessa tilan säilyttämisellä funktiolla remember verrattuna funktioon rememberSaveable, ja miten mutableStateOf ohjaa käyttöliittymän päivityksiä uudelleenkoostamisessa (recomposition)?
Jetpack Composessa `remember` säilyttää olion muistissa uudelleenkoostamisten (recompositions) yli niin kauan kuin composable-funktio pysyy osana koostamispuuta (composition tree). Vain `remember`-funktiolla säilytetty tila kuitenkin menetetään konfiguraatiomuutoksissa (kuten näytön kääntämisessä) ja järjestelmän käynnistämässä prosessin lopettamisessa (process death). Sen sijaan `rememberSaveable` säilyttää tilan konfiguraatiomuutosten ja prosessin lopettamisen yli tallentamalla ja palauttamalla arvot automaattisesti Androidin Saved Instance State -paketin kautta (tai mukautettujen `Saver`-toteutusten avulla ei-tuetuille tietotyypeille).
`mutableStateOf` käärii arvon reaktiiviseen `MutableState`-olioon, joka pohjautuu Composen Snapshot-tilajärjestelmään. Kun composable lukee `MutableState`-olion arvon koostamisen aikana, Compose rekisteröi kyseisen lukutoiminnon. Kun arvo myöhemmin muuttuu, Compose merkitsee automaattisesti kaikki kyseistä arvoa lukeneet composable-funktiot vanhentuneiksi ja ajoittaa ne uudelleenkoostettaviksi, mikä varmistaa, että käyttöliittymä pysyy synkronoituna päivitetyn tilan kanssa.
@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")
}
}
}
8Miten Kotlinin null-turvallisuus auttaa estämään kaatumisia Android-sovelluksissa, ja milloin tulisi käyttää nullable-tyyppiä ei-null-tyypin sijaan?
Kotlinin null-turvallisuus auttaa estämään useita Android-sovellusten kaatumisia tekemällä tyhjän arvon sallimisesta (nullability) osan tyyppijärjestelmää. Tyyppinä `String` määritelty arvo käsitellään ei-nullina, jolloin kääntäjä sallii suoran käsittelyn kuten `name.length`. Tyyppinä `String?` määritelty arvo voi olla null, joten Kotlin pakottaa käsittelemään kyseisen tapauksen ennen arvon käyttämistä. Tämä vähentää tahattomia `NullPointerException`-poikkeuksia, joita syntyy valinnaisista Intent-lisätiedoista (extras), API-vastauksista, Bundle-argumenteista tai Java-/alustarajapinnoista.
Käytä ei-null-tyyppiä, kun arvo on pakollinen olion tai funktion toiminnan kannalta. Käytä nullable-tyyppiä, kun arvon puuttuminen on todellinen ja odotettu tila, kuten valinnainen profiilikuvan URL-osoite tai puuttuva palvelinvastauskenttä. Nullable-arvoja käsitellään tavallisesti turvakutsuilla kuten `user?.name`, Elvis-operaattorilla kuten `user?.name ?: "Guest"` tai eksplisiittisillä null-tarkistuksilla. `!!`-operaattori ohittaa tyyppiturvallisuuden ja heittää poikkeuksen arvon ollessa null, joten sitä tulisi käyttää harvoin ja vain silloin, kun kehittäjä voi varmuudella todistaa, ettei arvo voi olla 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
9Mitä teknistä eroa on Activity- ja Application-kontekstin välittämisellä pitkäikäisille olioille, ja miten väärän vaihtoehdon valitseminen johtaa muistivuotoon?
Activity-konteksti on sidottu suoraan tietyn näkymän ja käyttöliittymähierarkian elinkaareen, kun taas Application-konteksti on sidottu koko sovellusprosessin elinkaareen. Kun Activity sulkeutuu tai luodaan uudelleen (esimerkiksi konfiguraatiomuutoksen, kuten näytön kääntämisen, yhteydessä), sen elinkaari päättyy ja sen muistin on tarkoitus vapautua roskienkerääjän (GC, Garbage Collector) toimesta. Jos Activity-konteksti välitetään pitkäikäiselle oliolle – kuten singletonille, staattiselle kentälle tai pitkäkestoiselle taustasäikeelle – kyseinen pitkäikäinen olio säilyttää vahvan viittauksen Activityyn. Koska pitkäikäiset oliot toimivat roskienkeruun juurina (GC roots) tai ovat saavutettavissa niistä, roskienkerääjä ei voi vapauttaa tuhottua Activitya. Tällöin muistiin jää itse Activity-instanssin lisäksi koko siihen liitetty View-hierarkia, piirrettävät resurssit (drawables) ja muut resurssit, mikä aiheuttaa huomattavan muistivuodon. Sen sijaan `applicationContext`-olion välittäminen on turvallista pitkäikäisille komponenteille, koska sen elinkaaren odotetaan vastaavan prosessin elinkaarta.
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 }
}
}
}
}
10Mitä eroa on latausavaimella (upload key) ja sovelluksen allekirjoitusavaimella (app signing key), kun käytetään Google Play App Signing -palvelua?
Google Play App Signing -palvelussa latausavaimella (upload key) ja sovelluksen allekirjoitusavaimella (app signing key) on kaksi erillistä tietoturvaroolia. Kehittäjä säilyttää latausavaimen itsellään allekirjoittaakseen käännösartefaktin (AAB) ennen sen lataamista Google Play Consoleen, mikä todentaa kehittäjän henkilöllisyyden Googlelle. Sovelluksen allekirjoitusavain taas säilytetään turvallisesti Googlen pilvi-infrastruktuurissa, ja Google Play käyttää sitä loppukäyttäjien laitteille toimitettavien ja asennettavien APK-pakettien allekirjoittamiseen, mikä muodostaa sovelluksen pysyvän kryptografisen identiteetin Androidissa. Merkittävä toiminnallinen etu on avaimen palautus: jos kehittäjä kadottaa latausavaimensa tai se vaarantuu, Google Playn tuki voi nollata latausavaimen kehittäjän henkilöllisyyden vahvistamisen jälkeen rikkomatta sovelluksen päivitysketjua. Aiemmassa mallissa, jossa kehittäjät hallinnoivat suoraan sovelluksen allekirjoitusavainta, avaimen katoaminen tarkoitti, ettei sovellusta voitu enää koskaan päivittää.
11Näkymä kadottaa käyttäjän syöttämät lomaketiedot laitteen kääntämisen jälkeen. Miten diagnosoisit järjestelmällisesti, johtuuko ongelma tallennetun tilan puuttumisesta, virheellisestä ViewModel-elinkaaresta vai näkymän uudelleensidonnasta?
Jotta lomaketietojen häviämisen syy laitteen kääntämisen jälkeen voidaan diagnosoida järjestelmällisesti, tarkastelu kannattaa jakaa kolmeen pääalueeseen:
1. **ViewModelin elinkaari ja säilyminen:** Varmista, että ViewModel-ilmentymä säilyy konfiguraationmuutoksen yli eikä sitä luoda uudelleen. Kirjaa ViewModelin hajautuskoodi (`viewModel.hashCode()`) lokiin ennen ja jälkeen laitteen kääntämisen. Varmista, että ilmentymä haetaan delegoitujen ominaisuuksien kautta, kuten `by viewModels()` / `by activityViewModels()` tai `ViewModelProvider(this)`, eikä luomalla sitä suoraan konstruktorilla (esim. `MyViewModel()`).
2. **Tallennettu tila ja View ID:n palautus:** Tarkista, luottaako lomake pelkästään Androidin oletusarvoiseen näkymähierarkian palautukseen. Näkymillä täytyy olla layoutissa määritelty `android:id`-attribuutti, jotta ne osallistuvat automaattiseen tilan tallennukseen ja palautukseen. Jos käytössä on omia näkymiä (custom views) tai `SavedStateHandle`, tarkista, että `onSaveInstanceState` tai `SavedStateHandle` todella tallentaa ja palauttaa tarvittavat kentät.
3. **Näkymän uudelleensidonta ja havainnointilogiikka (observer logic):** Tarkista `onViewCreated`-metodi ja tilamuutosten tilaukset (`StateFlow`, `LiveData`). Varmista, että uudelleenluodut näkymät tilaavat tiedot oikein ja ettei näkymän alustuskoodi vahingossa ylikirjoita palautettua tekstiä tyhjillä oletusarvoilla. Tarkista lisäksi kaksisuuntainen sidonta ja mahdolliset `TextWatcher`-silmukat, joissa tyhjä palautettu näkymä lähettää välittömästi tyhjän päivityksen takaisin ViewModeliin.
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)
}
}
}
}
}
}
12Miten moderni Jetpack Activity Result API (Application Programming Interface) korvaa vanhentuneen startActivityForResult-metodin ja miten se estää takaisinkutsun katoamisen Activityn uudelleenluonnin yhteydessä?
Moderni Jetpack Activity Result API korvaa vanhentuneet `startActivityForResult()`- ja `onActivityResult()`-metodit tyyppiturvallisella, löyhästi kytketyllä ja sopimuspohjaisella mekanismilla. Satunnaisten pyyntökoodien (request codes) ja raskaiden `onActivityResult`-valintalauseiden hallinnan sijaan kutsuja määrittelee `ActivityResultContract<I, O>` -rajapinnan (joka määrittää syötteen intent-parametrit ja odotetun jäsennetyn tulosteen) ja rekisteröi tyypitetyn takaisinkutsun `registerForActivityResult()`-metodilla saadakseen `ActivityResultLauncher`-olion. Rajapinta suojaa takaisinkutsun katoamiselta Activityn uudelleenluonnin tai prosessin sulkemisen yhteydessä tiukan elinkaarirekisteröinnin avulla: 1. Kun ulkoinen Activity (kuten kamera tai tiedostonvalitsin) käynnistetään, käyttöjärjestelmä voi tuhota kutsuvan Activityn muistipulan tai konfiguraatiomuutosten vuoksi. 2. Vaatimalla, että `registerForActivityResult()` kutsutaan ehdottomasti ennen kuin Activity tai Fragment siirtyy `STARTED`-tilaan (tyypillisesti ominaisuuden alustuksessa tai `onCreate`-metodissa), tuloksen takaisinkutsu rekisteröidään `ActivityResultRegistry`-olioon ennen tilan palauttamista. 3. Kun käynnistetty activity palaa ja isäntäactivity luodaan uudelleen, `ActivityResultRegistry` yhdistää järjestelmästä saapuvan odottavan tuloksen juuri rekisteröityyn takaisinkutsuun ja välittää tuloksen turvallisesti perille.
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)
}
}
}
13Miten vertailisit MVVM (Model-View-ViewModel)- ja MVI (Model-View-Intent) -arkkitehtuureja monimutkaisella näytöllä: miten tilanmuodostajat (state reducers) ja aikomusten välitys (intent dispatching) takaavat atomiset tilapäivitykset MVI-mallissa verrattuna MVVM-malliin?
Perinteisessä MVVM-mallissa ViewModel tarjoaa usein useita erillisiä reaktiivisia tietovirtoja (kuten `isLoading`, `userData`, `errorMessage` omina `StateFlow`- tai `LiveData`-ominaisuuksinaan) tai useita julkisia päivitysmetodeja. Kun useat asynkroniset tehtävät valmistuvat samanaikaisesti, nämä tietovirrat voivat päivittyä toisistaan riippumatta, mikä voi aiheuttaa kilpailutilanteita, keskeneräisiä välitiloja tai käyttöliittymän välkkymistä.
MVI (Model-View-Intent) -arkkitehtuurissa tilanhallinta noudattaa tiukkaa yksisuuntaista tiedonkulkua (UDF, Unidirectional Data Flow), joka rakentuu kolmen osan ympärille:
1. Yksi muuttumaton `UiState`, joka edustaa koko näytön tilaa.
2. Selkeät `UiIntent`-tyypit (tai toiminnot), jotka edustavat kaikkia käyttäjän toimintoja ja järjestelmätapahtumia.
3. Puhdas tilanmuodostajafunktio (state reducer): `(PreviousState, UiIntent) -> NewState`.
MVI takaa atomiset tilapäivitykset, koska kaikki tapahtumat välitetään erillisinä aikomuksina (intent) ja käsitellään järjestyksessä tilanmuodostajan läpi. Tilanmuodostaja ottaa muuttumattoman tilannevedoksen nykytilasta ja tuottaa täysin uuden tilavedoksen, jossa kaikki toisiinsa liittyvät ominaisuudet päivittyvät samanaikaisesti. Koska tilapäivitykset ovat keskitettyjä ja tilasiirtymät tapahtuvat sarjallisesti, käyttöliittymä ei koskaan havaitse osittaista, epäsynkronoitua tai ristiriitaista tilannevedosta.
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)
}
14Miten suunnittelisit ajonaikaisten käyttöoikeuksien pyyntövuon, joka käsittelee ensimmäisen pyynnön, perustelunäkymän (rationale UI, User Interface), hylkäyksen, 'älä kysy uudelleen' -tilan sekä asetuksiin ohjaamisen pakottamatta käyttäjää?
Käyttäjää pakottamaton ja käyttäjäystävällinen ajonaikaisten oikeuksien pyyntövuo noudattaa asteittaista tiedon antamista (progressive disclosure), selkeitä perusteluita ja hallittua toiminnallisuuden heikentämistä (graceful degradation) kaikissa hylkäystiloissa: 1. **Pyyntö kontekstissa (ensimmäinen kerta)**: Pyydä oikeuksia vasta tarpeen ilmetessä, kun käyttäjä käyttää tiettyä toimintoa (esim. painaa 'Lue koodi' -painiketta kameran käyttöoikeudelle) sen sijaan, että niitä kysyttäisiin heti sovelluksen käynnistyessä. 2. **Perustelunäkymä (Rationale UI)**: Kun `shouldShowRequestPermissionRationale()` palauttaa arvon `true`, näytä selkeä sovelluksen sisäinen selitys (kuten pohjaikkuna/bottom sheet tai dialogi), jossa kerrotaan miksi oikeutta tarvitaan ja mitä hyötyä siitä on, ennen järjestelmän lupakyselyn avaamista. 3. **Hallittu toiminnan heikennys hylättäessä**: Jos käyttäjä kieltää oikeuden, kunnioita valintaa estämättä muiden, riippumattomien toimintojen käyttöä. Tarjoa mahdollisuuksien mukaan vaihtoehtoinen työnkulku (esim. manuaalinen tekstinsyöttö, jos kameran käyttö estettiin). 4. **Pysyvä hylkäys ('Älä kysy uudelleen') ja siirtyminen asetuksiin**: Jos oikeus on evätty ja `shouldShowRequestPermissionRationale()` palauttaa arvon `false` (käyttäjä on valinnut 'Älä kysy uudelleen' tai hylännyt pyynnön toistuvasti), kerro käyttäjälle miksi toiminto ei ole käytettävissä ja tarjoa valinnainen painike, joka ohjaa sovelluksen asetuksiin käyttäen arvoa `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Varmista, että käyttäjä voi helposti perua tai navigoida takaisin ilman, että hän jää jumiin.
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()
}
}
Tuotantotasoinen Androidin CI/CD-putki alkaa pull requestin validointivaiheesta nopeilla automatisoiduilla tarkistuksilla: staattinen analyysi (ktlint, detekt, Android Lint), yksikkötestit ja debug-version kääntäminen. Kun muutokset yhdistetään `main`- tai julkaisuhaaraan, julkaisuputki käynnistyy ja luo allekirjoitetun Release Candidate (RC) Android App Bundlen (`.aab`) hyödyntäen CI-ympäristön hallinnoimia avainsäilön tunnistetietoja (keystore credentials), ProGuard-/R8-koodinoptimointia ja versionumeron automaattista kasvatusta. Allekirjoitettu AAB ja sen koodinpurkamisen kartoitustiedostot (deobfuscation mapping files) ladataan Google Playn sisäiseen testikanavaan (tai Firebase App Distributioniin) esimerkiksi Gradle Play Publisher (GPP) - tai Fastlane-työkaluilla, joissa suoritetaan automatisoidut savutestit (smoke tests) tai instrumentaatiotestit. Kun sisäinen laadunvarmistus (QA) ja sidosryhmät hyväksyvät version, täsmälleen sama binaariartefakti edistetään eteenpäin (Internal -> Closed Alpha/Beta -> Production Rollout) ilman uudelleenkääntämistä lähdekoodista. Lopuksi vaiheittaiset julkaisut (esim. 5 % -> 20 % -> 100 %) yhdessä automaattisen kaatumisasteen ja suorituskykymetriikoiden (Android vitals) seurannan kanssa takaavat turvallisen käyttöönoton.
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
16Miksi yleisen `Exception`- tai `Throwable`-poikkeuksen nappaaminen ilman `CancellationException`-poikkeuksen uudelleenheittämistä rikkoo korutiinien peruutuksen, ja miten diagnosoit peruutukseen liittyviä ongelmia?
Kotlin Coroutines -korutiinit perustuvat yhteistyöhön perustuvaan peruutukseen (cooperative cancellation), joka on toteutettu `CancellationException`-poikkeuksen avulla. Kun korutiinin `Job` peruutetaan, keskeytettävät kutsut (kuten `delay` tai `yield`) heittävät `CancellationException`-poikkeuksen kutsupinon purkamiseksi ja korutiinin lopettamiseksi. Jos koodi nappaa yleisen `Exception`- tai `Throwable`-poikkeuksen heittämättä `CancellationException`-poikkeusta uudelleen, peruutussignaali nieleskellään huomaamattomasti. Korutiinin suoritus ei keskeydy vaan jatkuu, jolloin syntyy zombikorutiini, joka kuluttaa suoritinta ja muistia, vuotaa resursseja ja saattaa aiheuttaa virheellisiä tilasiirtymiä tai kaatumisia tuhottuja käyttöliittymäkomponentteja vasten. Peruutusongelmien diagnosoimiseksi: 1. Varmista poikkeustenkäsittelyn eheys varmistamalla, että `CancellationException` heitetään eksplisiittisesti uudelleen tai että yleisten `Throwable`/`Exception`-luokkien sijaan napataan vain sovelluskohtaisia täsmällisiä poikkeuksia. 2. Tarkasta korutiinien tilat debug-versioissa Kotlin Coroutines Debuggerilla (`-Dkotlinx.coroutines.debug`) tai Android Studion Coroutines Inspectorilla sellaisten käynnissä olevien korutiinien tunnistamiseksi, joiden olisi pitänyt päättyä. 3. Kirjaa korutiinien elinkaaritiloja lokiin (esim. `job.isActive`, `job.isCancelled`) tai käytä rakenteellista lokitusta peruutuksen valmistumiskäsittelijöissä (`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)
}
17Miten tilan nostaminen (state hoisting) ja UDF (Unidirectional Data Flow) toimivat Jetpack Composessa, ja miten päätät, kuuluuko tila lehtitason composableen, ylemmän tason composableen vai ViewModeliin?
Tilan nostaminen (state hoisting) on Jetpack Composen malli, jossa tila siirretään komponenttihierarkiassa ylemmäs, jotta composablesta saadaan tilaton ja puhdas käyttöliittymäkomponentti. Tämä mahdollistaa yksisuuntaisen tietovirran (UDF, Unidirectional Data Flow), jossa tila kulkee alaspäin (vanhemmalta tai ViewModelilta alikomponenteille argumentteina) ja tapahtumat kulkevat ylöspäin (alikomponteilta vanhemmalle tai ViewModelille takaisinkutsuina). Päätös tilan sijoittamisesta noudattaa seuraavia ohjeita:
1. Lehtitason composable (paikallinen käyttöliittymätila): Jos tila on puhtaasti väliaikainen, visuaalinen eikä mikään ylempi tai rinnakkainen komponentti tarvitse sitä (esim. sisäinen avaus-/sulkemisanimaatio tai paikallinen ripple-efekti), pidä se lehtikomponentin sisäisenä käyttämällä funktiota `remember`.
2. Ylemmän tason composable (nostettu käyttöliittymätila): Jos rinnakkaisten composable-komponenttien on jaettava tila tai reagoitava siihen, tai jos vanhempi hallitsee komponentin näkyvyyttä tai validointia, nosta tila välittömälle yhteiselle vanhemmalle. Lehtikomponentista tulee tilaton (se ottaa vastaan arvot `value` ja `onValueChange`).
3. ViewModel (näkymä- ja liiketoimintatila): Jos tila edustaa liiketoimintadataa, säilyy konfiguraation muutosten yli, ohjaa navigointia tai edellyttää vuorovaikutusta domain- tai repository-kerrosten kanssa, se kuuluu ViewModeliin havaittavana tilana (esim. `StateFlow`). ViewModel käsittelee liiketoimintalogiikan ja päivittää käyttöliittymän tilaa vastaavasti.
18Miten suunnittelisit telemetrian, kaatumisten murupolun (crash breadcrumbs) ja arkkitehtuuriset suojakaiteet FragmentManagerin tilanmenetysten ja navigoinnin kilpatilanteiden havaitsemiseen, diagnosointiin ja estämiseen tuotannossa?
FragmentManagerin tilanmenetys (`IllegalStateException: Can not perform this action after onSaveInstanceState`) ja asynkroniset navigoinnin kilpatilanteet syntyvät, kun asynkroniset toiminnot – kuten verkkokutsut tai reaktiiviset tietovirrat – yrittävät tehdä käyttöliittymätransaktioita sen jälkeen, kun isännän elinkaari on siirtynyt vaiheiden `onSaveInstanceState()` tai `onStop()` ohi. Niiden havaitsemiseksi ja diagnosoimiseksi tuotannossa toteutamme elinkaaren telemetrian ja murupolun käyttämällä rajapintoja `Application.ActivityLifecycleCallbacks` ja `FragmentManager.FragmentLifecycleCallbacks`, tallentaen aikaleimatut tilasiirtymät, odottavien takaisinpinon transaktioiden määrät ja suorituskontekstin ennen kaatumisia. Tilanmenetyksen estämiseksi arkkitehtuuritasolla navigoinnin ja käyttöliittymätransaktioiden tulee nojata pelkästään elinkaaritietoisiin tilankuuntelijoihin (kuten `repeatOnLifecycle(Lifecycle.State.RESUMED)` tai elinkaareen sidottu `StateFlow`) raakojen asynkronisten takaisinkutsujen sijaan. Navigointitapahtumat tulisi mallintaa erillisinä UDF-tilasiirtyminä (Unidirectional Data Flow) tai kertaluonteisina tapahtumina, jotka kulutetaan vain silloin, kun elinkaaren tila on vähintään `STARTED` tai `RESUMED`. Lisäksi arkkitehtuurin suojakaiteiden tulisi sallia `commitStateLoss()` vain nimenomaisissa tilapäisissä konteksteissa, joita ei tarvitse palauttaa, tai mieluiten siirtyä Jetpack Navigation -komponenttiin, jossa on tiukat elinkaarirajat. Staattinen analyysi Android Lint -säännöillä ja ajonaikainen valvonta kehitysversioissa (esim. Fragment StrictMode) auttavat havaitsemaan virheet ennen tuotantoa.
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)
}
}
}
19Miten järjestelmätason virranhallintaominaisuudet, kuten Doze-tila, App Standby Buckets ja laitevalmistajien (OEM, Original Equipment Manufacturer) prosessintappajat, vaikuttavat tausta-ajojen aikataulutukseen, ja miten suunnittelisit vikasietoisen synkronointimoottorin käyttäen WorkManageria?
Järjestelmätason akun optimoinnit rajoittavat tausta-ajoa voimakkaasti. Doze-tila rajoittaa suorittimen käyttöä, verkkoyhteyksiä ja taustatehtäviä käyttämättömyysjaksojen aikana sallien suorituksen vain ajoittaisissa ylläpitoikkunoissa. App Standby Buckets rajoittaa dynaamisesti taustatehtävien tiheyttä ja verkkopääsyä sovelluksen viimeisimmän käytön perusteella (Active-tasosta aina Restricted- tai Never-tasoille). Lisäksi laitevalmistajien aggressiiviset omat virranhallintasovellukset (kuten MIUI tai OneUI) tappavat taustaprosesseja, ohittavat standardit hälytykset ja poistavat automaattikäynnistysoikeudet riippumatta AOSP:n oletustoiminnasta. Vikasietoisen synkronointimoottorin rakentamisessa WorkManager on vakioperusta, koska se abstrahoi JobSchedulerin, AlarmManagerin ja BroadcastReceiverit integroituen suoraan käyttöjärjestelmän rajoitteisiin. WorkManager mahdollistaa tiukkojen suoritusehtojen määrittelyn (kuten NetworkType.CONNECTED, requiresBatteryNotLow(true)) ja lykkää suoritusta automaattisesti Dozen ylläpitoikkunoihin tai verkkoyhteyden palautumiseen asti. Odottamattoman prosessien sulkemisen, ohimenevien verkkokatkosten ja OEM-prosessintappojen kestämiseksi synkronointimoottorin on noudatettava kahta pääperiaatetta: eksponentiaalista perääntymistä (exponential backoff) ja päästä päähän -idempotenssia. WorkManager tulisi konfiguroida käyttämään BackoffPolicy.EXPONENTIAL -asetusta, jotta vältetään palvelimien ruuhkautuminen (thundering herd) Dozesta toivuttaessa. Lisäksi työntekijöiden (workers) on kohdeltava synkronointioperaatioita atomisina ja idempotentteina — käyttäen deterministisiä transaktio-tunnisteita, paikallisia tilalippuja ja palvelinpään kaksoiskappaleiden poistoavaimia —, jotta keskeytyneen työn uudelleensuoritus ei tuota kaksoiskappaleita tai korruptoi paikallista tietokantaa.
20Miten suunnittelet skaalautuvan usean tiimin Android-arkkitehtuurin hyödyntäen 'API (Application Programming Interface) -toteutus' -moduulijakomallia Gradle-käännösaikojen optimoimiseksi ja tiukkojen sopimusrajojen valvomiseksi?
Yritystason usean tiimin Android-koodikannassa API-toteutus-malli (API-Impl) jakaa kunkin ominaisuus- tai toimialuemoduulin kahteen erilliseen Gradle-aliprojektiin: kevyeen `:feature:api`-moduuliin, joka sisältää julkiset rajapinnat, mallit ja navigaatiosopimukset, sekä `:feature:impl`-moduuliin, joka sisältää sisäisen liiketoimintalogiikan, käyttöliittymän ja repositoriototeutukset. Ominaisuutta kuluttavat moduulit riippuvat ainoastaan moduulista `:feature:api` määrityksellä `implementation project(':feature:api')`, kun taas `:app`-juurimoduuli tai erilliset kompositiojuuret kytkevät konkreettiset toteutukset yhteen riippuvuuksien injektoinnin (kuten Dagger/Hilt) avulla. Tämä malli optimoi merkittävästi Gradle-käännössuorituskykyä ABI-vakauden (Application Binary Interface) ja käännösaikaisen luokkapolun eristämisen kautta. Kun kehittäjät muokkaavat toteutusyksityiskohtia moduulissa `:feature:impl`, moduulin `:feature:api` julkinen ABI pysyy muuttumattomana. Tämän seurauksena Gradle ohittaa kaikkien niiden jatkomoduulien uudelleenkääntämisen, jotka riippuvat vain `:feature:api`-moduulista, mikä maksimoi Gradlen etäkäännösvälimuistin (Remote Build Cache) ja konfiguraatiovälimuistin tehon. Organisaation ja hallinnoinnin näkökulmasta tämä malli luo selkeät tiimien väliset omistajuusrajat käyttäen työkaluja kuten CODEOWNERS. Tiimit voivat turvallisesti kehittää sisäisiä toteutuksiaan paljastamatta yksityisiä luokkia, mikä estää ei-toivotun tiukan kytkennän ja kehäriippuvuudet suurissa tiimeissä.