Подготовка за интервю за Android

Въпроси за интервю за Android

20 често задавани въпроса за интервю за Android. Android разработката е търсено направление за мобилни приложения с Kotlin, Android SDK и Jetpack, както и за lifecycle, производителност, мрежа, съхранение, тестове и релийзи. Въпросите покриват различни нива и можете да упражнявате устни отговори в нашия тренажор за интервюта.

Започнете AI интервю за AndroidНе се изисква кредитна карта. Налична е 1 безплатна сесия.
Практика за техническо интервю на английски езикРежим, в който хора, за които английският не е роден език, могат да се упражняват за преминаване на технически интервюта.

Въпроси за начинаещи

1Преминете през основните функции за обратно извикване в жизнения цикъл на Activity от създаването до унищожаването му и обяснете разликите между onPause(), onStop() и onDestroy() по отношение на освобождаването на ресурси.

Даден Activity компонент преминава през шест основни функции за обратно извикване в жизнения цикъл: onCreate(), onStart(), onResume(), onPause(), onStop() и onDestroy(). onCreate() извършва еднократна инициализация, като например зареждане на изгледи (view inflation). onStart() прави активността видима, а onResume() я извежда на преден план, където тя получава интерактивен фокус. При навигация извън екрана onPause() показва, че активността е загубила фокус, onStop() указва, че тя вече не е видима на екрана, а onDestroy() сигнализира окончателното унищожаване на инстанцията на активността. По отношение на освобождаването на ресурси: - onPause(): Тук трябва да се спират на пауза само бързи операции, специфични за предния план (като спиране на анимации на потребителския интерфейс или леки прегледи от камерата), тъй като изпълнението на код в onPause() директно блокира стартирането на следващата активност. - onStop(): Това е основното място за освобождаване на по-тежки ресурси, свързани с видимостта на потребителския интерфейс (като слушатели за GPS/местоположение, сензорни данни, възпроизвеждане на медийно съдържание или мрежови заявки на интервали). Тъй като активността е напълно невидима, поддържането им активни хаби батерията, а системата може да прекрати фоновия процес без допълнително известие. - onDestroy(): Използва се за финално почистване на инстанцията на Activity (например изчистване на локални нишки или референции). Въпреки това критичните ресурси не трябва да чакат до onDestroy(), тъй като Android може директно да спре процеса на спряно приложение, за да освободи памет, без изобщо да извика 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()
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

2Кои са четирите основни компонента на едно Android приложение и какви са жизненият цикъл и моделът на нишките на техните стандартни извиквания (callbacks)?

Четирите основни компонента на Android приложение са Activity, Service, BroadcastReceiver и ContentProvider. Activity предоставя потребителски интерфейс за взаимодействия на екрана. Service извършва фонова обработка без специален потребителски интерфейс. BroadcastReceiver слуша и реагира на системни известия или съобщения на ниво приложение. ContentProvider управлява и предоставя структурирани данни на други приложения или вътрешни модули. По подразбиране основните извиквания за жизнения цикъл за Activity (като onCreate, onStart, onResume), Service (като onCreate, onStartCommand, onBind) и BroadcastReceiver (onReceive) се изпълняват синхронно в главната нишка на приложението (UI нишката). Методите на ContentProvider (като onCreate) също се инициализират в главната нишка, въпреки че операциите query/insert, извикани между процеси, се изпълняват в нишки от пула от нишки на Binder. Тъй като извикванията по подразбиране се изпълняват в главната нишка, извършването на тежки блокиращи операции като мрежови заявки или обемист дисков I/O за бази данни директно в тях блокира цикъла на потребителския интерфейс и предизвиква грешка 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
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

3В MVVM (Model-View-ViewModel) архитектурата на Android какви са основните отговорности на ViewModel в сравнение с UI контролерите като Activity и Fragment и защо подаването на Context към ViewModel не е препоръчително?

В MVVM архитектурата на Android класът ViewModel отговаря за съхранението и управлението на състоянието на потребителския интерфейс, оркестрацията на презентационната логика и запазването на данните при конфигурационни промени (като завъртане на екрана). За разлика от него, UI контролерите (Activities и Fragments) отговарят за визуализирането на UI елементите на екрана, наблюдаването на промени в състоянието, обработката на преките потребителски взаимодействия (кликове, жестове) и управлението на събитията от жизнения цикъл на Android. Подаването на Activity Context или препратка към View във ViewModel е силно непрепоръчително, тъй като ViewModel обикновено надживява жизнения цикъл на UI контролерите при промяна на конфигурацията. Когато дадено Activity се унищожи и създаде наново при завъртане на екрана, ViewModel, който държи неговия Context, пречи на старото Activity да бъде освободено от GC (Garbage Collector), което води до изтичане на памет (memory leak). Ако Android Context е строго необходим за операции на системно ниво, трябва да се използва Context с обхват на цялото приложение (например чрез `AndroidViewModel` или инжектиране на 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))
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

4Как се различават нормалните и опасните разрешения в Android и какво се случва, когато дадено приложение изиска опасно разрешение по време на изпълнение?

Нормалните разрешения представляват минимален риск за поверителността на потребителя или работата на устройството и се предоставят автоматично от системата при инсталиране на приложението, когато са декларирани в `AndroidManifest.xml` (като например `ACCESS_NETWORK_STATE`). Опасните разрешения (наричани още разрешения по време на изпълнение) управляват достъпа до поверителни потребителски данни или ограничени възможности на устройството, като камера, микрофон, местоположение и контакти. Когато дадено приложение изиска опасно разрешение по време на изпълнение, операционната система Android показва системен диалогов прозорец, приканващ потребителя да разреши или откаже достъпа. Ако потребителят предостави разрешение, приложението изпълнява заявената функционалност. Ако потребителят откаже разрешението, приложението получава известие за отказ чрез функция за обратно извикване и трябва да обработи ситуацията коректно, като деактивира или ограничи зависимата функционалност, без да се срива, и по избор покаже разясняващ потребителски интерфейс с обосновка.

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)
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

5Каква е разликата между debug и release компилациите в Android и как типовете компилация (build types) и продуктовите разновидности (product flavors) се комбинират в компилационни варианти (build variants)?

В разработката за Android типовете компилация (build types) конфигурират пакетирането и свойствата по време на изпълнение за различните етапи на разработка. Debug компилациите активират флагове за дебъгване (`isDebuggable = true`), използват стандартно хранилище за ключове за дебъгване (debug keystore) и обикновено изключват минимизирането и свиването на кода (R8/ProGuard) с цел бързи итерации при компилиране. Release компилациите деактивират флаговете за дебъгване, включват оптимизации и обфускация и изискват защитен ключ за подписване на release версията за дистрибуция. Продуктовите разновидности (product flavors) представляват различни версии или цели на приложението, споделящи една и съща основна кодова база, като например различни среди (staging срещу production) или продуктови издания (безплатно срещу платено). В Gradle компилационните варианти (build variants) се образуват от декартовото произведение на продуктовите разновидности и типовете компилация (Build Variant = Product Flavor × Build Type). Например комбинирането на разновидностите `staging` и `production` с типовете компилация `debug` и `release` създава четири компилационни варианта: `stagingDebug`, `stagingRelease`, `productionDebug` и `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")
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

6Какво представлява корутината в Kotlin и по какво спирането (suspension) се различава от блокирането на нишка по време на изпълнение в Android?

Корутината в Kotlin е лека абстракция за конкурентност, която позволява писането на асинхронен, неблокиращ код по последователен начин. Множество корутини могат да се изпълняват конкурентно на една нишка или в пулове от нишки без голямото натоварване върху паметта и превключването на контекста, характерно за системните нишки на операционната система. Основната разлика между спирането и блокирането е в използването на нишката: - Блокирането (напр. Thread.sleep() или синхронен вход/изход към диск или мрежа) спира изпълнението на нишката и я прави недостъпна за каквато и да е друга работа. В главната нишка на Android блокирането замразява потребителския интерфейс и предизвиква грешки от тип ANR (Application Not Responding). - Спирането (чрез suspend функции като delay()) поставя корутината на пауза, без да спира изпълнението на самата нишка. Корутината запазва състоянието на своето изпълнение в Continuation обект и връща нишката на средата за изпълнение, за да може тя да извършва друга работа (като обработка на взаимодействия с потребителския интерфейс). Когато асинхронната операция завърши, изпълнението на корутината се възобновява.

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"
}
Опитайте да отговорите на този въпрос с треньор с ИИ

7В Jetpack Compose каква е разликата между състояние, запазено с remember, и такова с rememberSaveable, и как mutableStateOf задвижва обновяването на потребителския интерфейс при прекомпозиране?

В Jetpack Compose `remember` съхранява обект в паметта между отделните прекомпозирания, докато компонентът остава част от дървото на композицията. Въпреки това състоянието, пазено единствено с `remember`, се губи при промени в конфигурацията (като завъртане на екрана) и при прекратяване на процеса от операционната система. За разлика от него, `rememberSaveable` запазва състоянието при промени в конфигурацията и прекратяване на процеса, като автоматично записва и възстановява стойности чрез Android Saved Instance State bundle (или чрез персонализирани имплементации на `Saver` за неподдържани типове данни). `mutableStateOf` обвива дадена стойност в наблюдаем обект `MutableState`, базиран на системата за състояния на снапшоти в Compose. Когато даден компонент чете стойността на `MutableState` по време на композиция, Compose записва тази операция по четене. Когато стойността по-късно се промени, Compose автоматично маркира като невалидни всички компоненти, които са чели тази стойност, и ги планира за прекомпозиране, гарантирайки синхронизацията на потребителския интерфейс с обновеното състояние.

@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")
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

8Как безопасността по отношение на null стойности (null safety) в Kotlin помага за предотвратяване на сривове в Android приложения и кога се използва тип, позволяващ null, спрямо тип, който не позволява null?

Безопасността по отношение на null стойности в Kotlin помага за предотвратяването на много сривове в Android, като превръща допустимостта на null в част от типовата система. Стойност, декларирана като `String`, се третира като непозволяваща null, така че компилаторът разрешава директен достъп като `name.length`. Стойност, декларирана като `String?`, може да бъде null, затова Kotlin изисква изричното обработване на този случай преди използване, което намалява случайните изключения `NullPointerException` от опционални Intent extras, API отговори, аргументи на Bundle или Java/платформени API. Използвайте тип, който не позволява null, когато стойността е задължителна, за да бъде даден обект или функция валидна. Използвайте тип, позволяващ null, когато липсата на стойност е напълно очаквано състояние – например опционален URL адрес на профилна снимка или липсващо поле от сървъра. Стойностите, позволяващи null, обикновено се обработват с безопасно извикване като `user?.name`, оператора Elvis като `user?.name ?: "Guest"` или изрични проверки за null. Операторът `!!` заобикаля защитата и хвърля изключение, ако стойността е null, затова трябва да се използва рядко и само когато разработчикът може категорично да докаже, че стойността не може да бъде 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
Опитайте да отговорите на този въпрос с треньор с ИИ

9Каква е разликата в механизма на работа при предаване на Activity Context спрямо Application Context на дългоживеещи обекти и как изборът на неподходящия води до изтичане на памет?

Контекстът на дадено Activity (`Activity Context`) е пряко обвързан с жизнения цикъл на конкретен екран и йерархията на потребителския интерфейс, докато контекстът на приложението (`Application Context`) е обвързан с жизнения цикъл на целия процес на приложението. Когато дадено Activity приключи работа или се пресъздаде (например при промяна на конфигурацията като завъртане на екрана), неговият жизнен цикъл завършва и паметта му трябва да бъде освободена от сметосъбирача GC (Garbage Collector). Ако подадете Activity Context на дългоживеещ обект — като сингълтон (singleton), статично поле или дълго изпълняваща се фонова нишка — този дългоживеещ обект запазва силна референция към съответното Activity. Тъй като дългоживеещите обекти служат като корени за сметосъбиране (GC roots) или са достижими от тях, GC не може да изчисти унищоженото Activity. Това задържа в паметта не само самата инстанция на Activity, но и цялата прилежаща йерархия от изгледи (View hierarchy), графични ресурси (drawables) и ресурси, което води до значително изтичане на памет. За разлика от това, подаването на `applicationContext` е безопасно за дългоживеещи компоненти, тъй като неговият жизнен цикъл съвпада с времетраенето на целия процес.

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 }
            }
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

10Каква е разликата между ключ за качване (upload key) и ключ за подписване на приложение (app signing key) при използване на Google Play App Signing?

При Google Play App Signing ключът за качване и ключът за подписване на приложението изпълняват две отделни роли за сигурност. Ключът за качване се съхранява от разработчика за подписване на артефакта на компилацията (AAB) преди качването му в Google Play Console, като по този начин удостоверява самоличността на разработчика пред Google. Ключът за подписване на приложението се съхранява сигурно в облачната инфраструктура на Google и се използва от Google Play за подписване на генерираните APK файлове, които реално се доставят и инсталират на устройствата на крайните потребители, установявайки постоянната криптографска идентичност на приложението в Android. Голямо оперативно предимство е възможността за възстановяване на ключа: ако разработчик загуби или компрометира своя ключ за качване, поддръжката на Google Play може да го нулира след проверка на самоличността, без това да наруши процеса на обновяване на приложението. При стария модел, в който разработчиците съхраняваха директно ключа за подписване, загубата на ключа означаваше, че приложението никога повече не може да бъде актуализирано.

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
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

Въпроси за напредващи

11Даден екран губи въведените от потребителя данни във форма след завъртане на устройството. Как систематично бихте диагностицирали дали проблемът се дължи на липсващо запазено състояние, неправилен обхват на ViewModel или повторно свързване на изгледа?

За да диагностицирате систематично защо данните във формата се губят след завъртане на устройството, изолирайте три основни области: 1. **Обхват и запазване на ViewModel:** Проверете дали инстанцията на ViewModel действително се запазва при промяната на конфигурацията, а не се създава наново. Логнете хеш кода на ViewModel (`viewModel.hashCode()`) при завъртанията. Уверете се, че се извлича чрез делегирани свойства като `by viewModels()` / `by activityViewModels()` или `ViewModelProvider(this)`, а не чрез директно инстанциране през конструктора (напр. `MyViewModel()`). 2. **Запазено състояние и възстановяване на идентификатори на изгледи:** Проверете дали формата разчита единствено на стандартното възстановяване на йерархията на изгледите в Android. Изгледите трябва да имат дефиниран атрибут `android:id` в layout файла, за да участват в автоматичното запазване и възстановяване на състоянието си. Ако използвате персонализирани изгледи или `SavedStateHandle`, проверете дали `onSaveInstanceState` / `SavedStateHandle` наистина запазва и възстановява съответните полета. 3. **Повторно свързване на изгледа и логика на наблюдателите:** Прегледайте `onViewCreated` или абонаментите на наблюдателите (`StateFlow`, `LiveData`). Проверете дали новосъздадените изгледи се абонират отново правилно или кодът за първоначална настройка на изгледа случайно презаписва възстановения текст с празни стойности по подразбиране. Освен това проверете за двупосочно свързване (two-way binding) или цикли в `TextWatcher`, при които празен възстановен изглед незабавно изпраща празна актуализация обратно към 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)
                    }
                }
            }
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

12Как съвременният Jetpack Activity Result API заменя остарелия `startActivityForResult` и как предпазва от загуба на обратно извикване (callback) при повторно създаване на Activity?

Съвременният Jetpack Activity Result API заменя остарелите `startActivityForResult()` и `onActivityResult()` с типобезопасен, разделен механизъм, базиран на договори. Вместо да управляват произволни целочислени кодове за заявки и големи `switch` конструкции в `onActivityResult`, извикващите дефинират `ActivityResultContract<I, O>` (който описва входните intent параметри и очаквания анализиран изход) и регистрират типизирано обратно извикване чрез `registerForActivityResult()`, за да получат `ActivityResultLauncher`. API предпазва от загуба на обратното извикване при повторно създаване на Activity или прекратяване на процеса от операционната система чрез стриктна регистрация, обвързана с жизнения цикъл: 1. Когато се стартира външно Activity (например камера или инструмент за избор на документи), извикващото Activity може да бъде унищожено от операционната система поради недостиг на памет или промени в конфигурацията. 2. Чрез изискването `registerForActivityResult()` да се извиква безусловно преди Activity или Fragment да достигне състояние `STARTED` (обикновено като инициализатор на свойство или в `onCreate`), обратното извикване се регистрира в `ActivityResultRegistry` преди възстановяването на състоянието. 3. Когато стартираното activity приключи и хост activity-то бъде създадено отново, `ActivityResultRegistry` свързва чакащия резултат от системата с новорегистрираното обратно извикване и подава резултата безопасно.

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)
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

13Сравнете архитектурите MVVM (Model-View-ViewModel) и MVI (Model-View-Intent) за сложен екран: как редукторите на състоянието и изпращането на намерения в MVI гарантират атомарни обновявания на състоянието в сравнение с MVVM?

В традиционния MVVM модел един ViewModel често предоставя множество независими реактивни потоци (като `isLoading`, `userData`, `errorMessage` като отделни `StateFlow` или `LiveData` свойства) или множество публични методи за обновяване. Когато няколко асинхронни задачи приключат едновременно, тези потоци могат да се обновят независимо, което води до състезания за ресурси (race conditions), междинни непълни състояния или визуално трептене на интерфейса. В MVI (Model-View-Intent) управлението на състоянието следва строг еднопосочен поток на данните (Unidirectional Data Flow, UDF), изграден около три елемента: 1. Единично неизменяемо състояние `UiState`, представящо целия екран. 2. Дискретни типове `UiIntent` (или Action), представящи всички потребителски взаимодействия и системни събития. 3. Чиста функция за редуциране на състоянието: `(PreviousState, UiIntent) -> NewState`. MVI гарантира атомарни обновявания на състоянието, тъй като всички събития се подават като отделни намерения и се обработват последователно през редуктора. Редукторът приема неизменяема моментна снимка на съществуващото състояние и генерира изцяло ново състояние с едновременно обновени свързани свойства. Тъй като обновяванията са централизирани, а преходите – последователни, потребителският интерфейс никога не получава частично, несинхронизирано или противоречиво състояние.

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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

14Как бихте проектирали процес за искане на разрешения по време на изпълнение, който обработва първоначална заявка, UI (User Interface) с обосновка, отказ, „не питай отново“ и пренасочване към настройките, без да притиска потребителя?

Непринуждаващият и удобен за потребителя процес за искане на разрешения по време на изпълнение следва принципите на прогресивно разкриване на информация, ясна обосновка и плавна деградация на функционалността при всички състояния на отказ: 1. **Искане в контекст (първи път)**: Искайте разрешения само в момента на необходимост, когато потребителят взаимодейства с дадена функционалност (например докосване на „Сканиране на код“ за разрешение за камерата), а не предварително при стартиране на приложението. 2. **UI с обосновка**: Когато `shouldShowRequestPermissionRationale()` върне `true`, покажете ясно обяснение в приложението (например bottom sheet или диалогов прозорец), което пояснява защо разрешението е необходимо и каква полза носи, преди да се задейства системният диалог. 3. **Плавна деградация при отказ**: Ако потребителят откаже заявката, уважете избора му, без да блокирате несвързани функционалности. Предоставете алтернативен работен процес, когато е възможно (например ръчно въвеждане на текст, ако достъпът до камерата е отказан). 4. **Постоянен отказ („Не питай отново“) и пренасочване към настройките**: Ако разрешението е отказано и `shouldShowRequestPermissionRationale()` върне `false` (потребителят е избрал „Не питай отново“ или е отказал неколкократно), информирайте потребителя защо функционалността е недостъпна и предложете опционален бутон, който го пренасочва към настройките на приложението чрез `Settings.ACTION_APPLICATION_DETAILS_SETTINGS`. Уверете се, че потребителят може лесно да се откаже или да се върне назад, без да се оказва в капан.

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()
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

15Как бихте проектирали Android CI/CD (Continuous Integration/Continuous Delivery) конвейер от валидация на pull request през създаване на подписан кандидат за рилийз, вътрешно тестване, качване в Play пътеки и поетапно пускане към потребителите?

Производственият Android CI/CD конвейер започва на етапа на валидиране на pull request с бързи автоматизирани проверки: статичен анализ (ktlint, detekt, Android Lint), модулни тестове и компилиране на debug билд. При сливане в основния клон или клона за рилийз се задейства конвейерът за издаване, който генерира подписан Android App Bundle (`.aab`) за RC (Release Candidate), използвайки удостоверителни данни за keystore за рилийз, управлявани от CI, свиване на кода с Proguard/R8 и автоматично увеличаване на версията. Подписаният AAB файл и файловете за съпоставяне при деобфускация се качват в пътеката за вътрешно тестване на Google Play (или Firebase App Distribution) с инструменти като Gradle Play Publisher (GPP) или Fastlane, където се изпълняват автоматизирани димни (smoke) или инструментални тестове. След като вътрешният екип по QA (Quality Assurance) и заинтересованите страни одобрят билда, абсолютно същият двоичен артефакт се придвижва по веригата (Вътрешно тестване -> Затворена алфа/бета -> Пускане в продакшън), без да се компилира наново от изходния код. Накрая, поетапното пускане (например 5% -> 20% -> 100%), комбинирано с автоматизирано наблюдение на процента на сривове и жизнените показатели на приложението, осигурява безопасно внедряване.

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
Опитайте да отговорите на този въпрос с треньор с ИИ

16Защо прихващането на общ Exception или Throwable без повторно хвърляне на CancellationException нарушава прекратяването (cancellation) на корутини и как диагностицирате проблеми с прекратяването?

Kotlin корутините разчитат на кооперативно прекратяване, реализирано чрез `CancellationException`. Когато даден `Job` на корутина бъде прекратен, прекъсваемите (suspending) извиквания (като `delay` или `yield`) хвърлят `CancellationException`, за да разгънат стека от извиквания и да прекратят корутината. Когато кодът прихване общ `Exception` или `Throwable` без повторно хвърляне на `CancellationException`, сигналът за прекратяване се поглъща. Корутината не успява да спре и продължава изпълнението си, превръщайки се в „зомби корутина“, която хаби процесорно време и памет, предизвиква изтичане на ресурси и може да доведе до невалидни преходи на състояния или сривове при взаимодействие с унищожени UI компоненти. За диагностика на проблеми с прекратяването: 1. Проверете коректността на обработката на изключения, като се уверите, че `CancellationException` се прехвърля експлицитно или се прихващат само специфични типове изключения вместо общия `Throwable`/`Exception`. 2. Инспектирайте състоянията на корутините в дебъг компилации чрез Kotlin Coroutines Debugger (`-Dkotlinx.coroutines.debug`) или Android Studio Coroutines Inspector, за да идентифицирате работещи корутини, които е трябвало да бъдат прекратени. 3. Логвайте състоянията от жизнения цикъл на корутините (напр. `job.isActive`, `job.isCancelled`) или използвайте структурирано логване в обработчиците за завършване при прекратяване (`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)
}
Опитайте да отговорите на този въпрос с треньор с ИИ

17Как работят изнасянето на състоянието (state hoisting) и еднопосочният поток на данни (UDF – Unidirectional Data Flow) в Jetpack Compose и как решавате дали състоянието принадлежи на краен компонент (leaf composable), родителски компонент или ViewModel?

Изнасянето на състоянието (state hoisting) е шаблон в Jetpack Compose, при който състоянието се премества нагоре в йерархията на композицията, за да се направи даден компонент независим от състояние (stateless), превръщайки го в чист потребителски интерфейс компонент. Това позволява еднопосочен поток на данни (UDF – Unidirectional Data Flow), при който състоянието се спуска надолу (от родителя/ViewModel към дъщерните компоненти като аргументи), а събитията се предават нагоре (от дъщерните компоненти към родителя/ViewModel като функции за обратно извикване). Решението къде принадлежи дадено състояние следва следните насоки: 1. **Краен компонент (Локално състояние на потребителския интерфейс):** Ако състоянието е изцяло временно, визуално и ненужно за родителски или съседни компоненти (например дали се изпълнява вътрешна анимация за разгъване/свиване или локален визуален ефект на пулсиране), запазете го локално в крайния компонент чрез `remember`. 2. **Родителски компонент (Изнесено състояние на потребителския интерфейс):** Ако съседни компоненти трябва да споделят или да реагират на състоянието, или ако родителят управлява видимостта/валидацията на компонента, изнесете състоянието в прекия общ родител. Крайният компонент остава без вътрешно състояние (приема `value` и `onValueChange`). 3. **ViewModel (Екранно / Бизнес състояние):** Ако състоянието представлява бизнес данни, запазва се при промени в конфигурацията (configuration changes), управлява навигацията или изисква взаимодействие със слоевете на домейна или хранилището (repository), то принадлежи на ViewModel и се предоставя като наблюдаемо състояние (например чрез `StateFlow`). ViewModel обработва бизнес логиката и обновява състоянието на потребителския интерфейс съответно.

// 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) }
    )
}
Опитайте да отговорите на този въпрос с треньор с ИИ

Въпроси за опитни

18Как бихте проектирали телеметрия, проследяващи следи при срив (crash breadcrumbs) и архитектурни защитни механизми (guardrails) за откриване, диагностициране и предотвратяване на загуба на състояние във FragmentManager и състезателни състояния при навигация в производствена среда?

Загубата на състояние във FragmentManager (`IllegalStateException: Can not perform this action after onSaveInstanceState`) и асинхронните състезателни състояния при навигация възникват, когато асинхронни операции – като мрежови отговори или реактивни потоци – се опитват да извършат трансакции на потребителския интерфейс, след като жизненият цикъл на хоста е преминал отвъд `onSaveInstanceState()` или `onStop()`. За откриването и диагностицирането им в реална среда се имплементират телеметрия на жизнения цикъл и проследяващи следи (breadcrumbs) чрез `Application.ActivityLifecycleCallbacks` и `FragmentManager.FragmentLifecycleCallbacks`, които записват преходи с времеви маркери, броя на чакащите елементи в стека (backstack) и контекста на изпълнение преди възникването на срив. За архитектурно предотвратяване на загубата на състояние трансакциите по навигация и интерфейс трябва да се задвижват единствено от наблюдатели на състоянието, съобразени с жизнения цикъл (lifecycle-aware), като например `repeatOnLifecycle(Lifecycle.State.RESUMED)` или `StateFlow`, събиран с обвързване към жизнения цикъл, вместо директно от необработени асинхронни функции за обратно извикване. Навигационните събития трябва да се моделират като отделни преходи на състоянието в еднопосочен поток от данни (UDF – Unidirectional Data Flow) или еднократни събития, консумирани само докато състоянието на жизнения цикъл е поне `STARTED` или `RESUMED`. Освен това архитектурните защитни механизми трябва да ограничат използването на `commitStateLoss()` само до изрични, неподлежащи на възстановяване временни контексти или за предпочитане да се мигрира към Jetpack Navigation с неговите стриктни граници за жизнен цикъл. Статичният анализ чрез персонализирани Android Lint правила и проверките по време на изпълнение в тестови (debug) компилации (например Fragment StrictMode) позволяват улавянето на подобни нарушения още преди достигане до производствена среда.

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)
        }
    }
}
Опитайте да отговорите на този въпрос с треньор с ИИ

19Как системните функции за управление на захранването като Doze Mode, App Standby Buckets и инструментите за принудително спиране на процеси от OEM (Original Equipment Manufacturer) влияят върху фоновото планиране на задачи и как бихте проектирали устойчив механизъм за синхронизация с помощта на WorkManager?

Оптимизациите на батерията на системно ниво силно ограничават фоновото изпълнение. Doze Mode ограничава процесора, мрежовия достъп и фоновите задачи по време на периоди на неактивност, като разрешава изпълнение само по време на периодични прозорци за поддръжка. App Standby Buckets динамично ограничават честотата на фоновите задачи и мрежовия достъп въз основа на това колко наскоро е било използвано приложението (от Active до Restricted или Never). Освен това агресивните персонализирани системи за управление на захранването от OEM производители (като MIUI или OneUI) често прекратяват фонови процеси, игнорират стандартни аларми и отнемат разрешенията за автоматично стартиране независимо от стандартното поведение на AOSP. За изграждането на устойчив механизъм за синхронизация WorkManager е стандартната основа, тъй като капсулира JobScheduler, AlarmManager и BroadcastReceivers, като същевременно се интегрира директно със системните ограничения. WorkManager позволява дефиниране на строги предварителни условия за изпълнение (като NetworkType.CONNECTED, requiresBatteryNotLow(true)), като автоматично отлага изпълнението до прозорците за поддръжка на Doze или до възстановяване на свързаността. За да издържи на неочаквано прекратяване на процеса, временни прекъсвания на мрежата и спиране от страна на OEM системи, механизмът за синхронизация трябва да следва два основни принципа: експоненциално отстъпление и цялостна идемпотентност. WorkManager трябва да бъде конфигуриран с BackoffPolicy.EXPONENTIAL, за да се избегнат проблеми от тип „thundering herd“ върху бекенд сървърите при излизане от Doze режим. В допълнение работните компоненти (workers) трябва да третират операциите по синхронизация като атомарни и идемпотентни — използвайки детерминистични идентификатори на трансакции, локални флагове за състояние и сървърни ключове за дедупликация, така че ако даден worker бъде внезапно прекратен по средата и нареден отново на опашката, повторният опит да не дублира данни и да не повреди локалните бази данни.

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
)
Опитайте да отговорите на този въпрос с треньор с ИИ

20Как проектирате мащабируема архитектура за Android за множество екипи, използвайки модела за разделяне на модули „API-Implementation“, за да оптимизирате времето за компилация с Gradle и да наложите стриктни граници на договорите?

В корпоративна кодова база за Android с множество екипи шаблонът API-Implementation (API-Impl) разделя всеки модул за функционалност или домейн на два отделни Gradle подпроекта: лек `:feature:api` модул, съдържащ публични интерфейси, модели и навигационни договори, и `:feature:impl` модул, съдържащ вътрешна бизнес логика, потребителски интерфейс и имплементации на хранилища (repositories). Консумиращите модули зависят единствено от `:feature:api`, използвайки `implementation project(':feature:api')`, докато главният `:app` модул или специализирани корени за композиция (composition roots) свързват конкретните имплементации чрез инжектиране на зависимости (Dependency Injection, напр. Dagger/Hilt). Този шаблон драстично оптимизира производителността при компилация с Gradle чрез стабилност на ABI (Application Binary Interface) и изолация на компилационния classpath. Когато инженерите променят детайли по имплементацията в `:feature:impl`, публичният ABI на `:feature:api` остава непроменен. В резултат на това Gradle пропуска прекомпилацията на всички надолу по веригата модули, които зависят само от `:feature:api`, увеличавайки максимално ефективността на Gradle Remote Build Cache и кеша на конфигурацията. От организационна гледна точка и управление на процесите, този шаблон установява ясни граници на собственост между екипите с помощта на инструменти като CODEOWNERS. Екипите могат безопасно да развиват своите вътрешни детайли по имплементацията, без да разкриват частни класове, което предотвратява нежелано силно обвързване и циклични зависимости в големи екипи.

// :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
}
Опитайте да отговорите на този въпрос с треньор с ИИ