20 często zadawanych pytań rekrutacyjnych z Androida. Android development to poszukiwana dziedzina skupiona na aplikacjach mobilnych w Kotlinie, Android SDK i Jetpacku oraz na lifecycle, wydajności, sieci, przechowywaniu danych, testach i release'ach. Pytania obejmują różne poziomy, a odpowiedzi możesz ćwiczyć na głos w naszym trenerze rozmów.
1Omów podstawowe metody wywołań zwrotnych cyklu życia komponentu Activity od utworzenia do zniszczenia i wyjaśnij różnice między onPause(), onStop() a onDestroy() w kontekście zwalniania zasobów.
Komponent Activity przechodzi przez sześć podstawowych wywołań zwrotnych cyklu życia: `onCreate()`, `onStart()`, `onResume()`, `onPause()`, `onStop()` oraz `onDestroy()`. Metoda `onCreate()` wykonuje jednorazową inicjalizację, taką jak pompowanie widoku (view inflation). `onStart()` sprawia, że aktywność staje się widoczna, a `onResume()` przenosi ją na pierwszy plan, gdzie uzyskuje fokus interakcji. Podczas opuszczania ekranu `onPause()` wskazuje na utratę fokusu, `onStop()` oznacza, że aktywność nie jest już widoczna na ekranie, a `onDestroy()` oznacza ostateczną likwidację instancji aktywności. W kontekście zwalniania zasobów: - `onPause()`: Należy tu wstrzymywać wyłącznie szybkie operacje specyficzne dla pierwszego planu (takie jak pauzowanie animacji UI czy lekkiego podglądu z aparatu), ponieważ wykonanie kodu w `onPause()` bezpośrednio blokuje uruchomienie kolejnej aktywności. - `onStop()`: Jest to główne miejsce do zwalniania cięższych zasobów powiązanych z widocznością interfejsu (takich jak nasłuchiwacze lokalizacji GPS, strumienie czujników, odtwarzacze multimediów czy odpytywanie sieci). Ponieważ aktywność jest całkowicie niewidoczna, pozostawienie ich aktywnych zużywa baterię, a system może bez uprzedzenia zakończyć proces w tle. - `onDestroy()`: Służy do ostatecznego czyszczenia instancji Activity (np. zwalniania lokalnych wątków lub referencji). Jednak kluczowe zasoby nie powinny czekać na `onDestroy()`, ponieważ Android może bezpośrednio zabić zatrzymany proces aplikacji w celu odzyskania pamięci bez wywołania metody `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()
}
}
2Jakie są cztery podstawowe komponenty aplikacji Android i jaki jest cykl życia oraz model wątkowości ich domyślnych funkcji zwrotnych (callbacks)?
Cztery podstawowe komponenty aplikacji w systemie Android to Activity, Service, BroadcastReceiver oraz ContentProvider. Komponent Activity udostępnia interfejs użytkownika do interakcji na ekranie. Service wykonuje zadania w tle bez dedykowanego interfejsu użytkownika (UI). BroadcastReceiver nasłuchuje i reaguje na ogólnosystemowe lub aplikacyjne komunikaty rozgłoszeniowe. ContentProvider zarządza ustrukturyzowanymi danymi i udostępnia je innym aplikacjom lub wewnętrznym modułom. Domyślnie główne wywołania zwrotne cyklu życia dla Activity (takie jak onCreate, onStart, onResume), Service (takie jak onCreate, onStartCommand, onBind) oraz BroadcastReceiver (onReceive) wykonują się synchronicznie w głównym wątku aplikacji (wątku interfejsu użytkownika – UI thread). Metody komponentu ContentProvider (takie jak onCreate) również inicjalizują się w głównym wątku, chociaż operacje typu query/insert wywoływane między procesami działają w puli wątków mechanizmu Binder. Ponieważ domyślne wywołania zwrotne działają w głównym wątku, wykonywanie w nich bezpośrednio ciężkich, blokujących operacji (takich jak żądania sieciowe czy operacje wejścia/wyjścia na dysku przy dużych bazach danych) blokuje pętlę zdarzeń interfejsu i wywołuje błąd 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
}
}
3W architekturze MVVM (Model-View-ViewModel) w systemie Android, jakie są główne obowiązki klasy ViewModel w porównaniu z kontrolerami interfejsu użytkownika UI (User Interface), takimi jak komponenty Activity i Fragment, oraz dlaczego przekazywanie obiektu Context do ViewModel jest odradzane?
W architekturze MVVM w systemie Android klasa ViewModel odpowiada za przechowywanie i zarządzanie stanem powiązanym z interfejsem użytkownika, koordynację logiki prezentacji oraz przetrwanie zmian konfiguracji (takich jak obrót ekranu). Kontrolery UI (Activity i Fragment) odpowiadają natomiast za renderowanie elementów interfejsu na ekranie, obserwowanie zmian stanu, obsługę bezpośrednich interakcji użytkownika (kliknięcia, gesty) oraz zarządzanie cyklem życia systemu Android. Przekazywanie referencji do Activity Context lub instancji View do ViewModel jest stanowczo odradzane, ponieważ czas życia instancji ViewModel zazwyczaj przewyższa cykl życia kontrolerów UI podczas zmian konfiguracji. Gdy Activity jest niszczona i tworzona na nowo podczas obrotu ekranu, ViewModel przechowujący jej Context uniemożliwia zwolnienie starej Activity przez odśmiecacz pamięci (garbage collector), co prowadzi do wycieku pamięci. Jeśli Context Androida jest bezwzględnie wymagany do operacji na poziomie systemu, należy zamiast tego użyć kontekstu o zasięgu całej aplikacji (na przykład za pośrednictwem `AndroidViewModel` lub wstrzykując 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))
}
}
}
4Czym różnią się zwykłe (normal) i niebezpieczne (dangerous) uprawnienia w systemie Android oraz co się dzieje, gdy aplikacja żąda niebezpiecznego uprawnienia w trakcie działania (runtime)?
Zwykłe uprawnienia (normal permissions) stanowią minimalne ryzyko dla prywatności użytkownika lub działania urządzenia i są przyznawane automatycznie przez system podczas instalacji aplikacji, jeśli zadeklarowano je w pliku `AndroidManifest.xml` (np. `ACCESS_NETWORK_STATE`). Uprawnienia niebezpieczne (dangerous permissions, zwane również uprawnieniami czasu wykonania – runtime permissions) kontrolują dostęp do prywatnych danych użytkownika lub ograniczonych funkcji urządzenia, takich jak aparat, mikrofon, lokalizacja czy kontakty. Gdy aplikacja żąda niebezpiecznego uprawnienia w czasie wykonywania, system Android wyświetla systemowe okno dialogowe z prośbą o zezwolenie lub odmowę dostępu. Jeśli użytkownik wyrazi zgodę, aplikacja wykonuje żądaną funkcję. Jeśli użytkownik odmówi, aplikacja otrzymuje wywołanie zwrotne (callback) informujące o odmowie i musi obsłużyć tę sytuację w sposób łagodny – wyłączając lub ograniczając zależną funkcjonalność bez awarii aplikacji, opcjonalnie wyświetlając wyjaśnienie w interfejsie użytkownika (UI rationale).
5Jaka jest różnica między wersjami debug i release w systemie Android oraz jak typy kompilacji (build types) i warianty produktu (product flavors) łączą się w warianty kompilacji (build variants)?
W programowaniu na platformę Android typy kompilacji (`build types`) konfigurują właściwości pakowania i środowiska wykonawczego dla różnych etapów wytwarzania oprogramowania. Wersje debugowe włączają flagi debugowania (`isDebuggable = true`), używają domyślnego magazynu kluczy debugowania (`debug keystore`) i zazwyczaj wyłączają minifikację oraz optymalizację rozmiaru kodu (R8/ProGuard) w celu szybkiej iteracji kompilacji. Wersje produkcyjne (`release`) wyłączają flagi debugowania, włączają optymalizacje i zaciemnianie kodu (obfuscation) oraz wymagają bezpiecznego klucza podpisywania do dystrybucji. Warianty produktu (`product flavors`) reprezentują różne wersje lub cele aplikacji współdzielące tę samą bazę kodu, takie jak różne środowiska (staging vs produkcja) lub edycje produktu (darmowa vs płatna). W narzędziu Gradle warianty kompilacji (`build variants`) powstają jako iloczyn kartezjański wariantów produktu i typów kompilacji (Build Variant = Product Flavor × Build Type). Na przykład połączenie wersji `staging` i `production` z typami kompilacji `debug` i `release` tworzy cztery warianty kompilacji: `stagingDebug`, `stagingRelease`, `productionDebug` oraz `productionRelease`.
6Czym jest korutyna w języku Kotlin i czym różni się zawieszenie (suspension) od blokowania wątku w środowisku uruchomieniowym w systemie Android?
Korutyna w języku Kotlin to lekka abstrakcja współbieżności umożliwiająca pisanie asynchronicznego, nieblokującego kodu w sposób sekwencyjny. Wiele korutyn może działać współbieżnie na jednym wątku lub w pulach wątków bez dużego narzutu pamięciowego i kosztu przełączania kontekstu typowego dla wątków na poziomie systemu operacyjnego. Zasadnicza różnica między zawieszeniem a blokowaniem polega na wykorzystaniu wątku: - Blokowanie (np. Thread.sleep() lub synchroniczne operacje wejścia/wyjścia na dysku lub w sieci) zatrzymuje powiązany wątek i sprawia, że staje się on niedostępny do jakiejkolwiek innej pracy. Na głównym wątku Androida blokowanie zamraża interfejs użytkownika (UI – User Interface) i wywołuje błędy ANR (Application Not Responding). - Zawieszenie (za pomocą funkcji suspend, takich jak delay()) wstrzymuje korutynę bez zatrzymywania powiązanego wątku. Korutyna zapisuje swój stan wykonania w obiekcie Continuation i oddaje wątek środowisku uruchomieniowemu, aby mogło ono wykonywać inną pracę (np. obsługiwać interakcje z interfejsem użytkownika). Po zakończeniu operacji asynchronicznej korutyna zostaje wznowiona.
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"
}
7Czym w Jetpack Compose różni się stan zachowywany za pomocą `remember` od stanu zachowywanego przy użyciu `rememberSaveable` oraz w jaki sposób `mutableStateOf` steruje aktualizacjami UI (User Interface) podczas rekompozycji?
W Jetpack Compose funkcja `remember` przechowuje obiekt w pamięci pomiędzy kolejnymi rekompozycjami tak długo, jak dany komponent (@Composable) pozostaje w drzewie kompozycji. Stan przechowywany wyłącznie za pomocą `remember` jest jednak tracony podczas zmian konfiguracji (takich jak obrót ekranu) oraz w przypadku zatrzymania procesu przez system. Z kolei `rememberSaveable` zachowuje stan podczas zmian konfiguracji i zatrzymania procesu, automatycznie zapisując i przywracając wartości za pośrednictwem mechanizmu Saved Instance State w systemie Android (lub poprzez niestandardowe implementacje `Saver` dla nieobsługiwanych typów danych). Funkcja `mutableStateOf` opakowuje wartość w obserwowalny obiekt `MutableState`, który opiera się na systemie stanów Snapshot w Compose. Kiedy funkcja komponowalna odczytuje wartość `MutableState` podczas kompozycji, Compose rejestruje tę operację odczytu. Gdy wartość ulegnie później zmianie, Compose automatycznie oznacza wszystkie funkcje komponowalne, które ją odczytały, jako nieaktualne i planuje ich rekompozycję, gwarantując synchronizację interfejsu użytkownika ze zaktualizowanym stanem.
@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")
}
}
}
8W jaki sposób mechanizm bezpieczeństwa typów dopuszczających wartości null (null safety) w języku Kotlin pomaga zapobiegać awariom w aplikacjach na system Android i kiedy należy stosować typ dopuszczający wartość null (nullable), a kiedy niedopuszczający wartości null (non-null)?
Mechanizm null safety w języku Kotlin pomaga zapobiegać wielu awariom aplikacji na system Android, czyniąc obsługę wartości null integralną częścią systemu typów. Wartość zadeklarowana jako `String` jest traktowana jako nienullowalna (non-null), dzięki czemu kompilator pozwala na bezpośredni dostęp do właściwości, np. `name.length`. Wartość zadeklarowana jako `String?` może przyjąć wartość null, dlatego Kotlin wymusza jej jawną obsługę przed użyciem, co zmniejsza ryzyko przypadkowego wystąpienia wyjątku `NullPointerException` pochodzącego z opcjonalnych dodatków Intent (extras), odpowiedzi API, argumentów Bundle lub kodu napisanego w języku Java czy API platformy. Typu nienullowalnego należy używać, gdy wartość jest wymagana do prawidłowego działania obiektu lub funkcji. Typ dopuszczający null należy stosować wtedy, gdy brak wartości jest spodziewanym stanem, na przykład w przypadku opcjonalnego adresu URL zdjęcia profilowego lub brakującego pola z serwera. Wartości dopuszczające null są zwykle obsługiwane za pomocą bezpiecznych wywołań, takich jak `user?.name`, operatora Elvis, np. `user?.name ?: "Guest"`, lub jawnych sprawdzeń warunku null. Operator `!!` wyłącza mechanizmy bezpieczeństwa i rzuca wyjątek, jeśli wartość wynosi null, dlatego powinien być używany rzadko i wyłącznie wtedy, gdy programista może jednoznacznie dowieść, że wartość nie może być nullem.
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
9Jaka jest mechaniczna różnica między przekazywaniem kontekstu Activity a kontekstu Application do obiektów długożyjących i w jaki sposób wybór niewłaściwego prowadzi do wycieku pamięci?
Kontekst `Activity` jest powiązany bezpośrednio z cyklem życia konkretnego ekranu i hierarchii interfejsu użytkownika (UI), podczas gdy kontekst `Application` jest powiązany z cyklem życia całego procesu aplikacji. Gdy `Activity` kończy działanie lub jest niszczona i tworzona ponownie (np. podczas zmiany konfiguracji, takiej jak obrót ekranu), jej cykl życia się kończy, a zajmowana pamięć powinna zostać odzyskana przez moduł odśmiecania pamięci (Garbage Collector, GC). Jeśli przekażesz kontekst `Activity` do obiektu długożyjącego — takiego jak singleton, pole statyczne lub długo działający wątek w tle — ten długożyjący obiekt zachowa silną referencję (strong reference) do `Activity`. Ponieważ obiekty długożyjące działają jako korzenie GC (GC roots) lub są z nich osiągalne, odśmiecacz pamięci nie może zwolnić zniszczonej `Activity`. Zatrzymuje to w pamięci nie tylko samą instancję `Activity`, ale również całą powiązaną hierarchię widoków (`View`), zasoby graficzne (`drawables`) oraz inne powiązane zasoby, tworząc poważny wyciek pamięci. Natomiast przekazanie `applicationContext` jest bezpieczne dla komponentów długożyjących, ponieważ jego czas życia w naturalny sposób pokrywa się z czasem życia procesu.
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 }
}
}
}
}
10Jaka jest różnica między kluczem przesyłania (upload key) a kluczem podpisywania aplikacji (app signing key) podczas korzystania z usługi Google Play App Signing?
W usłudze Google Play App Signing klucz przesyłania (upload key) oraz klucz podpisywania aplikacji (app signing key) pełnią dwie odrębne role w zakresie bezpieczeństwa. Klucz przesyłania jest przechowywany przez programistę w celu podpisania artefaktu kompilacji (AAB) przed przesłaniem go do konsoli Google Play Console, co weryfikuje tożsamość programisty wobec Google. Klucz podpisywania aplikacji jest bezpiecznie przechowywany w infrastrukturze chmurowej Google i jest używany przez Google Play do podpisywania wygenerowanych plików APK faktycznie dostarczanych i instalowanych na urządzeniach użytkowników końcowych, ustanawiając trwałą tożsamość kryptograficzną aplikacji w systemie Android. Główną korzyścią operacyjną jest możliwość odzyskania klucza: jeśli programista zgubi lub naruszy bezpieczeństwo swojego klucza przesyłania, wsparcie Google Play może go zresetować po zweryfikowaniu tożsamości programisty, nie przerywając ścieżki aktualizacji aplikacji. W starszym modelu, w którym programiści bezpośrednio przechowywali klucz podpisywania aplikacji, utrata klucza oznaczała brak możliwości ponownego zaktualizowania aplikacji.
11Ekran traci dane wprowadzone przez użytkownika do formularza po obróceniu urządzenia. W jaki sposób systematycznie zdiagnozujesz, czy problem wynika z braku zapisanego stanu, nieprawidłowego zakresu cyklu życia komponentu ViewModel, czy też ponownego wiązania widoku?
Aby systematycznie zdiagnozować przyczynę utraty danych formularza po obróceniu urządzenia, należy wyizolować trzy kluczowe obszary: 1. **Zakres i zachowanie ViewModel (ViewModel Scoping & Retention):** Upewnij się, że instancja ViewModel jest rzeczywiście zachowywana między zmianami konfiguracji, a nie tworzona na nowo. Rejestruj w logach kod skrótu ViewModelu (`viewModel.hashCode()`) przed i po obróceniu ekranu. Sprawdź, czy jest on pobierany za pomocą delegowanych właściwości, takich jak `by viewModels()` / `by activityViewModels()` lub `ViewModelProvider(this)`, a nie przez bezpośrednie wywołanie konstruktora (np. `MyViewModel()`). 2. **Zapisany stan i przywracanie po identyfikatorze widoku:** Sprawdź, czy formularz polega wyłącznie na domyślnym mechanizmie przywracania hierarchii widoków w systemie Android. Widoki muszą mieć zdefiniowany atrybut `android:id` w układzie, aby brać udział w automatycznym zapisywaniu i przywracaniu stanu. W przypadku korzystania z widoków niestandardowych lub `SavedStateHandle` zweryfikuj, czy `onSaveInstanceState` / `SavedStateHandle` faktycznie zapisuje i odtwarza odpowiednie pola. 3. **Ponowne wiązanie widoku i logika obserwatorów:** Przeanalizuj metodę `onViewCreated` lub subskrypcje obserwatorów (`StateFlow`, `LiveData`). Sprawdź, czy nowo utworzone widoki prawidłowo ponawiają subskrypcję oraz czy kod inicjalizacyjny widoku przypadkowo nie nadpisuje przywróconego tekstu domyślnymi pustymi wartościami. Dodatkowo skontroluj dwukierunkowe wiązanie danych (two-way binding) lub pętle w `TextWatcher`, w których pusty przywrócony widok natychmiast wysyła puste uaktualnienie z powrotem do ViewModelu.
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)
}
}
}
}
}
}
12W jaki sposób nowoczesne Jetpack Activity Result API (Application Programming Interface) zastępuje przestarzałe startActivityForResult i jak zabezpiecza przed utratą wywołania zwrotnego podczas ponownego tworzenia Activity?
Nowoczesne Jetpack Activity Result API zastępuje przestarzałe metody `startActivityForResult()` oraz `onActivityResult()` bezpiecznym pod kątem typów, rozproszonym mechanizmem opartym na kontraktach. Zamiast zarządzać dowolnymi całkowitoliczbowymi kodami żądań (request codes) i rozbudowanymi instrukcjami `switch` w `onActivityResult`, wywołujący definiuje obiekt `ActivityResultContract<I, O>` (który określa wejściowe parametry intentu oraz oczekiwany sparsowany wynik wyjściowy) i rejestruje typowane wywołanie zwrotne za pomocą `registerForActivityResult()`, uzyskując instancję `ActivityResultLauncher`. API zabezpiecza przed utratą wywołania zwrotnego podczas ponownego tworzenia Activity lub przerwania procesu przez ścisłą rejestrację w cyklu życia: 1. Gdy uruchamiana jest zewnętrzna Activity (np. aparat lub selektor dokumentów), wywołująca Activity może zostać zniszczona przez system operacyjny z powodu braku pamięci lub zmian konfiguracyjnych. 2. Wymóg bezwarunkowego wywołania `registerForActivityResult()` przed osiągnięciem przez Activity lub Fragment stanu `STARTED` (zazwyczaj jako inicjalizator właściwości lub wewnątrz `onCreate`) sprawia, że wywołanie zwrotne wyniku jest rejestrowane w `ActivityResultRegistry` przed przywróceniem stanu. 3. Gdy uruchomiona aktywność zakończy działanie, a aktywność nadrzędna zostanie odtworzona, `ActivityResultRegistry` dopasowuje oczekujący wynik z systemu do nowo zarejestrowanego wywołania zwrotnego i bezpiecznie przekazuje ten wynik.
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)
}
}
}
13Porównaj architektury MVVM (Model-View-ViewModel) i MVI (Model-View-Intent) w kontekście złożonego ekranu: w jaki sposób reduktory stanu i wysyłanie intencji w MVI gwarantują atomowe aktualizacje stanu w porównaniu z MVVM?
W tradycyjnym wzorcu MVVM model widoku (ViewModel) często udostępnia wiele niezależnych strumieni reaktywnych (takich jak `isLoading`, `userData`, `errorMessage` jako osobne właściwości `StateFlow` lub `LiveData`) albo wiele publicznych metod aktualizujących. Gdy wiele zadań asynchronicznych kończy się współbieżnie, strumienie te mogą aktualizować się niezależnie, co prowadzi do wyścigów (race conditions), pośrednich niekompletnych stanów lub migotania interfejsu (visual flickering). W MVI (Model-View-Intent) zarządzanie stanem opiera się na ścisłym jednokierunkowym przepływie danych (UDF – Unidirectional Data Flow) zbudowanym wokół trzech elementów: 1. Pojedynczego, niemutowalnego obiektu `UiState` reprezentującego stan całego ekranu. 2. Dyskretnych typów `UiIntent` (lub akcji) reprezentujących wszystkie interakcje użytkownika oraz zdarzenia systemowe. 3. Czystej funkcji reduktora stanu (State Reducer): `(PreviousState, UiIntent) -> NewState`. MVI gwarantuje atomowe aktualizacje stanu, ponieważ wszystkie zdarzenia są wysyłane jako odrębne intencje i sekwencyjnie przetwarzane przez reduktor stanu. Reduktor pobiera niemutowalną migawkę istniejącego stanu i generuje całkowicie nową migawkę stanu ze wszystkimi powiązanymi właściwościami zaktualizowanymi jednocześnie. Ponieważ aktualizacje stanu są scentralizowane, a przejścia sekwencyjne, interfejs użytkownika nigdy nie otrzymuje częściowej, niespójnej ani sprzecznej migawki stanu.
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)
}
14Jak zaprojektowałbyś przepływ obsługi uprawnień w czasie wykonywania (runtime permissions), który obsługuje pierwsze żądanie, UI (User Interface) z uzasadnieniem, odmowę, opcję „nie pytaj ponownie” oraz przejście do ustawień bez wywierania presji na użytkowniku?
Przyjazny dla użytkownika i pozbawiony presji przepływ uprawnień w czasie wykonywania (runtime permissions) opiera się na stopniowym ujawnianiu informacji (progressive disclosure), jasnym uzasadnieniu oraz łagodnej degradacji funkcjonalności (graceful degradation) we wszystkich stanach odmowy: 1. **Żądanie w odpowiednim kontekście (pierwszy raz)**: Żądaj uprawnień tylko w momencie zapotrzebowania, gdy użytkownik wchodzi w interakcję z daną funkcją (np. dotknięcie „Skanuj kod” dla uprawnienia do aparatu), a nie z góry przy uruchomieniu aplikacji. 2. **UI z uzasadnieniem**: Gdy `shouldShowRequestPermissionRationale()` zwraca `true`, wyświetl w aplikacji czytelne wyjaśnienie (np. arkusz dolny / bottom sheet lub okno dialogowe), tłumaczące, dlaczego uprawnienie jest wymagane i jakie korzyści przynosi, zanim zostanie wywołany monit systemowy. 3. **Łagodna degradacja przy odmowie**: Jeśli użytkownik odrzuci prośbę, uszanuj ten wybór bez blokowania niepowiązanych funkcji. W miarę możliwości zapewnij alternatywny przepływ (np. ręczne wprowadzanie tekstu, jeśli odmówiono dostępu do aparatu). 4. **Trwała odmowa („Nie pytaj ponownie”) i przejście do ustawień**: Jeśli uprawnienie zostanie odrzucone, a `shouldShowRequestPermissionRationale()` zwraca `false` (użytkownik wybrał opcję „Nie pytaj ponownie” lub wielokrotnie odmówił), poinformuj użytkownika, dlaczego funkcja jest niedostępna, i zaoferuj opcjonalny przycisk przekierowujący do ustawień aplikacji za pośrednictwem `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Upewnij się, że użytkownik może łatwo anulować operację lub wrócić, nie będąc zablokowanym.
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()
}
}
15Jak zaprojektowałbyś potok CI/CD (Continuous Integration / Continuous Deployment) dla systemu Android, od walidacji pull requestów, przez tworzenie podpisanego kandydata do wydania (release candidate), testy wewnętrzne, wysyłanie na ścieżkę w Google Play, aż po stopniowe wdrażanie na produkcję?
Produkcyjny potok CI/CD dla Androida rozpoczyna się na etapie walidacji pull requestów za pomocą szybkich testów automatycznych: analizy statycznej (ktlint, detekt, Android Lint), testów jednostkowych oraz kompilacji wersji debug. Po scaleniu (merge) ze ścieżką główną (main) lub gałęzią wydania (release branch) uruchamiany jest potok wydawniczy generujący podpisany pakiet Android App Bundle (`.aab`) kandydata do wydania (Release Candidate, RC) przy użyciu poświadczeń magazynu kluczy (keystore) zarządzanych przez CI, optymalizacji i minifikacji Proguard/R8 oraz automatycznego podbijania wersji. Podpisany plik AAB wraz z plikami mapowania do deobfuskacji są przesyłane do wewnętrznej ścieżki testowej Google Play (Internal Testing track) lub Firebase App Distribution za pomocą narzędzi takich jak Gradle Play Publisher (GPP) czy Fastlane, gdzie uruchamiane są automatyczne testy dymne (smoke tests) lub testy instrumentowane. Gdy wewnętrzny zespół QA i interesariusze zatwierdzą kompilację, dokładnie ten sam artefakt binarny jest promowany do kolejnych etapów (Internal -> Zamknięta Alpha/Beta -> Wdrożenie produkcyjne) bez konieczności ponownego budowania z kodu źródłowego. Na koniec stopniowe wdrażanie (np. 5% -> 20% -> 100%) w połączeniu ze zautomatyzowanym monitorowaniem wskaźnika awarii (crash rate) oraz metryk Android Vitals zapewnia bezpieczne wdrożenie.
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
16Dlaczego przechwytywanie ogólnego Exception lub Throwable bez ponownego rzucania CancellationException zaburza anulowanie korutyn i jak diagnozować problemy z anulowaniem?
Kotlin Coroutines opierają się na kooperacyjnym anulowaniu zaimplementowanym za pomocą `CancellationException`. Gdy obiekt `Job` korutyny zostaje anulowany, wywołania funkcji zawieszających (takie jak `delay` lub `yield`) rzucają `CancellationException`, aby zwinąć stos wywołań i zakończyć działanie korutyny. Gdy kod przechwytuje ogólny wyjątek `Exception` lub `Throwable` bez ponownego rzucenia `CancellationException`, sygnał anulowania zostaje stłumiony. Korutyna nie przerywa pracy i nadal działa, stając się „korutyną zombie”, która marnuje zasoby procesora/pamięci, powoduje wycieki zasobów oraz może wywołać nieprawidłowe przejścia stanów lub awarie przy odwołaniach do zniszczonych komponentów UI. Aby diagnozować problemy z anulowaniem: 1. Zweryfikuj poprawność obsługi wyjątków, upewniając się, że `CancellationException` jest jawnie rzucany dalej lub że przechwytywane są wyłącznie wyjątki domenowe zamiast ogólnych `Throwable`/`Exception`. 2. Sprawdź stany korutyn w kompilacjach debugowych za pomocą debugera Kotlin Coroutines (`-Dkotlinx.coroutines.debug`) lub narzędzia Coroutines Inspector w Android Studio, aby zidentyfikować działające korutyny, które powinny być zakończone. 3. Rejestruj stany cyklu życia korutyny w logach (np. `job.isActive`, `job.isCancelled`) lub korzystaj ze strukturyzowanego logowania w procedurach obsługi zakończenia anulowania (`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)
}
17Jak działają wynoszenie stanu (state hoisting) oraz jednokierunkowy przepływ danych (UDF – Unidirectional Data Flow) w Jetpack Compose i jak decydujesz, czy stan powinien należeć do liściastej funkcji komponowalnej (leaf composable), nadrzędnej funkcji komponowalnej (parent composable), czy do ViewModel?
Wynoszenie stanu (state hoisting) to wzorzec w Jetpack Compose polegający na przesuwaniu stanu w górę hierarchii kompozycji, aby uczynić komponent bezstanowym (stateless) i przekształcić go w czysty element UI. Umożliwia to jednokierunkowy przepływ danych (UDF – Unidirectional Data Flow), w którym stan przepływa w dół (od rodzica/ViewModelu do funkcji potomnych jako argumenty), a zdarzenia przepływają w górę (od funkcji potomnych do rodzica/ViewModelu jako wywołania zwrotne).
Decyzja o umiejscowieniu stanu opiera się na następujących zasadach:
1. **Liściasta funkcja komponowalna (lokalny stan UI):** Jeśli stan jest czysto tymczasowy, wizualny i niepotrzebny żadnemu komponentowi nadrzędnemu ani rodzeństwu (np. czy trwa lokalna animacja rozwijania/zwijania lub lokalny efekt kliknięcia ripple), zachowaj go lokalnie w komponencie liściastym za pomocą `remember`.
2. **Nadrzędna funkcja komponowalna (wyniesiony stan UI):** Jeśli komponenty na tym samym poziomie muszą współdzielić stan lub na niego reagować, albo jeśli rodzic kontroluje widoczność/walidację komponentu, wynieś stan do najbliższego wspólnego rodzica. Komponent potomny staje się bezstanowy (przyjmuje `value` oraz `onValueChange`).
3. **ViewModel (stan ekranu / stan biznesowy):** Jeśli stan reprezentuje dane biznesowe, musi przetrwać zmiany konfiguracji (configuration changes), steruje nawigacją lub wymaga interakcji z warstwą domeny/repozytorium, jego miejsce jest w ViewModelu udostępnionym jako obserwowalny stan (np. `StateFlow`). ViewModel obsługuje logikę biznesową i odpowiednio aktualizuje stan interfejsu.
18Jak zaprojektowałbyś telemetrię, ścieżki zdarzeń przed awarią (crash breadcrumbs) i zabezpieczenia architektoniczne, aby wykrywać, diagnozować i zapobiegać utracie stanu FragmentManager oraz wyścigom w nawigacji w środowisku produkcyjnym?
Utrata stanu FragmentManager (`IllegalStateException: Can not perform this action after onSaveInstanceState`) oraz asynchroniczne wyścigi nawigacyjne występują, gdy operacje asynchroniczne — takie jak sieciowe wywołania zwrotne czy strumienie reaktywne — próbują wykonać transakcje UI po tym, jak cykl życia nadrzędnego komponentu przeszedł poza `onSaveInstanceState()` lub `onStop()`.
Aby wykrywać i diagnozować te problemy na produkcji, implementujemy telemetrię cyklu życia oraz ścieżki zdarzeń (breadcrumbs) przy użyciu `Application.ActivityLifecycleCallbacks` i `FragmentManager.FragmentLifecycleCallbacks`, rejestrując przejścia stanów ze znacznikami czasu, liczbę oczekujących wpisów na stosie cofania (backstack) oraz kontekst wykonania przed wystąpieniem awarii.
Aby zapobiegać utracie stanu na poziomie architektury, transakcje nawigacyjne i UI muszą być sterowane wyłącznie przez obserwatory stanu uwzględniające cykl życia (takie jak `repeatOnLifecycle(Lifecycle.State.RESUMED)` lub kolekcje `StateFlow` powiązane z cyklem życia), a nie przez surowe asynchroniczne wywołania zwrotne. Zdarzenia nawigacyjne powinny być modelowane jako dyskretne przejścia stanu w ramach jednokierunkowego przepływu danych (UDF – Unidirectional Data Flow) lub jednorazowe zdarzenia konsumowane tylko wtedy, gdy stan cyklu życia wynosi co najmniej `STARTED` lub `RESUMED`.
Ponadto zabezpieczenia architektoniczne powinny wymuszać bezpieczeństwo poprzez stosowanie `commitStateLoss()` wyłącznie w jawnych, nieodnawialnych kontekstach przejściowych, a najlepiej poprzez migrację do komponentu Jetpack Navigation z rygorystycznymi ograniczeniami cyklu życia. Analiza statyczna za pomocą własnych reguł Android Lint oraz weryfikacja w czasie wykonywania w kompilacjach debugowych (np. Fragment StrictMode) pozwalają wychwycić naruszenia przed trafieniem kodu na produkcję.
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)
}
}
}
19Jak funkcje zarządzania energią na poziomie systemu, takie jak tryb uśpienia (Doze Mode), zasobniki uśpienia aplikacji (App Standby Buckets) i agresywne mechanizmy zamykania procesów producentów OEM (Original Equipment Manufacturer), wpływają na planowanie zadań w tle i jak zaprojektowałbyś odporny silnik synchronizacji przy użyciu biblioteki WorkManager?
Optymalizacje zużycia baterii na poziomie systemu drastycznie ograniczają wykonywanie kodu w tle. Doze Mode ogranicza czas procesora, dostęp do sieci i zadania w tle podczas bezczynności urządzenia, zezwalając na ich wykonanie jedynie podczas okresowych okien konserwacyjnych (maintenance windows). App Standby Buckets dynamicznie ograniczają częstotliwość zadań w tle i dostęp do sieci na podstawie czasu od ostatniego użycia aplikacji (od stanu Active do Restricted lub Never). Ponadto agresywne, niestandardowe menedżery zasilania producentów OEM (np. MIUI czy OneUI) często bez ostrzeżenia zabijają procesy w tle, ignorują standardowe alarmy i odbierają uprawnienia do autostartu, niezależnie od zachowania czystego systemu AOSP. Aby zbudować odporny silnik synchronizacji, WorkManager stanowi standardowy fundament, ponieważ abstrahuje mechanizmy JobScheduler, AlarmManager i BroadcastReceiver, integrując się bezpośrednio z ograniczeniami systemu operacyjnego. WorkManager pozwala zadeklarować ścisłe warunki wstępne wykonania (np. `NetworkType.CONNECTED`, `requiresBatteryNotLow(true)`), automatycznie odraczając wykonanie do okna konserwacyjnego Doze lub przywrócenia łączności. Aby wytrzymać niespodziewane zakończenie procesu, przejściowe utraty połączenia sieciowego i interwencje menedżerów OEM, silnik synchronizacji musi opierać się na dwóch kluczowych zasadach: wycofaniu wykładniczym (exponential backoff) oraz pełnej idempotentności. WorkManager powinien być skonfigurowany z `BackoffPolicy.EXPONENTIAL`, aby zapobiec problemowi thundering herd na serwerach backendowych po wyjściu z trybu Doze. Dodatkowo zadania robocze (workers) muszą traktować operacje synchronizacji jako atomowe i idempotentne — wykorzystując deterministyczne identyfikatory transakcji, lokalne flagi stanu oraz klucze deduplikacji po stronie serwera — dzięki czemu przerwanie zadania i jego ponowne zakolejkowanie nie spowoduje duplikacji danych ani uszkodzenia lokalnej bazy danych.
20Jak zaprojektować skalowalną architekturę aplikacji na platformę Android dla wielu zespołów z wykorzystaniem wzorca separacji modułów „API (Application Programming Interface)-Implementacja”, aby zoptymalizować czas budowania w Gradle i wymusić ścisłe granice kontraktów?
W korporacyjnej bazie kodu na platformę Android rozwijanej przez wiele zespołów wzorzec API-Implementacja (API-Impl) dzieli każdy moduł funkcjonalny lub domenowy na dwa odrębne podprojekty Gradle: lekki moduł `:feature:api`, zawierający publiczne interfejsy, modele i kontrakty nawigacji, oraz moduł `:feature:impl`, zawierający wewnętrzną logikę biznesową, interfejs użytkownika (UI) i implementacje repozytoriów. Moduły konsumujące zależą wyłącznie od `:feature:api` za pomocą `implementation project(':feature:api')`, podczas gdy moduł główny `:app` lub dedykowane korzenie kompozycji (composition roots) łączą konkretne implementacje za pomocą wstrzykiwania zależności (Dependency Injection, np. Dagger/Hilt).
Wzorzec ten znacząco optymalizuje wydajność budowania w Gradle dzięki stabilności interfejsu binarnego aplikacji (Application Binary Interface, ABI) oraz izolacji ścieżki kompilacji (compilation classpath). Gdy inżynierowie modyfikują szczegóły implementacyjne w `:feature:impl`, publiczne ABI modułu `:feature:api` pozostaje niezmienione. W konsekwencji Gradle pomija rekompilację wszystkich modułów zależnych wyłącznie od `:feature:api`, maksymalizując efektywność zdalnej pamięci podręcznej budowania Gradle (Gradle Remote Build Cache) oraz pamięci podręcznej konfiguracji (configuration cache). Z perspektywy organizacyjnej i ładu architektonicznego wzorzec ten ustanawia jasne granice własności kodu między zespołami za pomocą narzędzi takich jak CODEOWNERS. Zespoły mogą bezpiecznie rozwijać wewnętrzne szczegóły implementacji bez ujawniania prywatnych klas, co zapobiega niepożądanemu ścisłemu powiązaniu (tight coupling) i zależnościom cyklicznym w dużych strukturach zespołowych.