20 поширених запитань на співбесіді з Android. Android-розробка - затребуваний напрям, де створюють мобільні застосунки на Kotlin, працюють з Android SDK і Jetpack, вирішують задачі lifecycle, продуктивності, мережі, зберігання даних, тестування й релізів. Запитання охоплюють різні рівні, і ви можете потренуватися відповідати на них усно в нашому тренажері.
1Опишіть основні методи життєвого циклу (lifecycle callbacks) 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 і яка модель життєвого циклу та потоків виконання застосовується для їхніх стандартних функцій зворотного виклику?
Чотири основні компоненти додатків Android — це Activity, Service, BroadcastReceiver і ContentProvider. Activity надає інтерфейс користувача для взаємодії з екраном. Service виконує фонову обробку без виділеного інтерфейсу. BroadcastReceiver очікує на загальносистемні або внутрішньопрограмні широкомовні повідомлення та реагує на них. ContentProvider керує структурованими даними та надає до них доступ іншим додаткам або внутрішнім модулям. За замовчуванням основні функції зворотного виклику (callbacks) життєвого циклу для Activity (наприклад, onCreate, onStart, onResume), Service (наприклад, onCreate, onStartCommand, onBind) і BroadcastReceiver (onReceive) виконуються синхронно в головному потоці додатка (UI-потоці). Методи ContentProvider (наприклад, onCreate) також ініціалізуються в головному потоці, хоча операції query/insert, викликані з інших процесів, виконуються в пулі потоків Binder. Оскільки стандартні зворотні виклики виконуються в головному потоці, запуск важких блокувальних операцій (мережевих запитів або тривалого дискового I/O бази даних) безпосередньо в них блокує цикл подій (event loop) інтерфейсу та призводить до помилки 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 (User Interface), такими як Activity та Fragment, і чому не рекомендується передавати Context у ViewModel?
В архітектурі MVVM для Android компонент ViewModel відповідає за збереження та керування станом, пов'язаним із користувацьким інтерфейсом, узгодження логіки відображення та збереження стану під час змін конфігурації (наприклад, обертання екрана). На противагу цьому контролери інтерфейсу (Activity та Fragment) відповідають за відмальовування елементів інтерфейсу на екрані, спостереження за змінами стану, обробку безпосередніх дій користувача (натискання, жести) та керування подіями життєвого циклу Android. Передавання посилання на Context з Activity або на View у ViewModel наполегливо не рекомендується, оскільки час життя ViewModel зазвичай довший за життєвий цикл контролерів інтерфейсу під час змін конфігурації. Коли Activity знищується та створюється заново під час обертання, збереження його Context у ViewModel заважає збирачу сміття (Garbage Collector) звільнити пам'ять, що спричиняє витік пам'яті. Якщо Android 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Чим відрізняються звичайні (normal) та небезпечні (dangerous) дозволи в Android, і що відбувається, коли застосунок запитує небезпечний дозвіл під час виконання (runtime)?
Звичайні дозволи (normal permissions) становлять мінімальний ризик для конфіденційності користувача або роботи пристрою та автоматично надаються системою під час встановлення застосунку, якщо вони оголошені в `AndroidManifest.xml` (наприклад, `ACCESS_NETWORK_STATE`). Небезпечні дозволи (dangerous permissions, також відомі як дозволи часу виконання — runtime permissions) регулюють доступ до приватних даних користувача або обмежених можливостей пристрою, таких як камера, мікрофон, геолокація та контакти. Коли застосунок запитує небезпечний дозвіл під час виконання, система Android відображає системне діалогове вікно, пропонуючи користувачеві надати або відхилити доступ. Якщо користувач надає дозвіл, застосунок виконує відповідну функцію. Якщо користувач відхиляє дозвіл, застосунок отримує зворотний виклик (callback) про відмову та має коректно його обробити — вимкнути або обмежити функціональність залежної можливості без аварійного завершення роботи, а також за потреби показати роз'яснювальний інтерфейс користувача (UI rationale).
5У чому полягає різниця між debug- та release-збірками в Android, і як типи збірок (build types) та варіанти продукту (product flavors) об'єднуються у варіанти збірки (build variants)?
В Android-розробці типи збірок (build types) налаштовують параметри пакування та поведінки під час виконання для різних етапів розробки. Збірки для налагодження (debug builds) вмикають прапорці налагодження (`isDebuggable = true`), використовують типове налагоджувальне сховище ключів (keystore) і зазвичай вимикають мініфікацію та оптимізацію коду (R8/ProGuard) для швидких ітерацій збирання. Релізні збірки (release builds) вимикають налагодження, вмикають оптимізації та обфускацію і вимагають захищеного ключа підпису для розповсюдження. Варіанти продукту (product flavors) представляють різні версії чи цільові конфігурації застосунку на основі спільної кодової бази, наприклад, різні середовища (`staging` та `production`) або редакції продукту (`free` та `paid`). У Gradle варіанти збірки (build variants) утворюються як декартовий добуток варіантів продукту та типів збірок (Build Variant = Product Flavor × Build Type). Наприклад, поєднання варіантів `staging` і `production` із типами збірок `debug` і `release` створює чотири варіанти збірки: `stagingDebug`, `stagingRelease`, `productionDebug` та `productionRelease`.
6Що таке корутина в Kotlin, і чим призупинення (suspension) відрізняється від блокування потоку під час виконання в Android?
Корутина в Kotlin — це легковагова абстракція конкурентності, яка дозволяє писати асинхронний неблокувальний код у послідовному стилі. Кілька корутин можуть виконуватися конкурентно в одному потоці або через пули потоків без значних витрат пам'яті та накладних витрат на перемикання контексту, притаманних потокам рівня ОС (операційної системи).
Ключова відмінність між призупиненням і блокуванням полягає у використанні ресурсів потоку:
- Блокування (наприклад, `Thread.sleep()` або синхронне I/O (Input/Output) диска чи мережі) зупиняє базовий потік і робить його недоступним для виконання будь-якої іншої роботи. У головному потоці 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 керує оновленнями UI (User Interface) між рекомпозиціями?
У Jetpack Compose `remember` зберігає об'єкт у пам'яті під час рекомпозицій доти, доки composable-функція залишається в дереві композиції. Однак стан, збережений лише через `remember`, втрачається під час змін конфігурації (наприклад, повороту екрана) та завершення процесу системою (process death). На противагу цьому, `rememberSaveable` зберігає стан під час змін конфігурації та завершення процесу, автоматично зберігаючи та відновлюючи значення через Android Saved Instance State bundle (або за допомогою власних реалізацій `Saver` для непідтримуваних типів даних). `mutableStateOf` загортає значення в спостережуваний об'єкт `MutableState`, який підтримується системою знімків стану (Snapshot state system) у Compose. Коли composable-функція зчитує значення `MutableState` під час композиції, Compose фіксує цю операцію зчитування. Якщо згодом значення змінюється, Compose автоматично позначає всі composable-функції, які зчитували це значення, як недійсні та планує їхню рекомпозицію, забезпечуючи синхронізацію UI з оновленим станом.
@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 (nullable type), замість типу, що не допускає null (non-null type)?
Безпека роботи з null у Kotlin допомагає запобігти багатьом аварійним завершенням Android-застосунків завдяки тому, що можливість набувати значення null інтегрована в систему типів. Значення, оголошене як `String`, вважається таким, що не може бути null (non-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 Context безпосередньо прив'язаний до життєвого циклу конкретного екрана та ієрархії інтерфейсу, тоді як Application Context прив'язаний до життєвого циклу процесу всього застосунку. Коли робота Activity завершується або вона створюється заново (наприклад, під час зміни конфігурації через поворот екрана), її життєвий цикл закінчується, а зайнята пам'ять має бути звільнена збирачем сміття (Garbage Collector, GC). Якщо передати Activity Context довгоживучому об'єкту — наприклад, одинаку (singleton), статичному полю або довготривалому фоновому потоку — цей об'єкт збереже сильне посилання на Activity. Оскільки довгоживучі об'єкти виступають коренями збирання сміття (GC roots) або є досяжними з них, збирач сміття не може звільнити знищену Activity. Через це утримується не лише сам екземпляр Activity, а й уся прив'язана до нього ієрархія View, графічні ресурси та ресурси макета, спричиняючи значний витік пам'яті. Натомість передавання `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 ключ завантаження (upload key) і ключ підпису додатку (app signing key) виконують дві різні функції безпеки. Ключ завантаження зберігається у розробника для підпису артефакту збірки (AAB) перед його завантаженням у Google Play Console, підтверджуючи особу розробника для Google. Ключ підпису додатку безпечно зберігається у хмарній інфраструктурі Google і використовується Google Play для підпису згенерованих APK, які безпосередньо доставляються та встановлюються на пристрої кінцевих користувачів, формуючи постійну криптографічну ідентичність додатку в Android. Важливою практичною перевагою є відновлення ключа: якщо розробник втратить або скомпрометує свій ключ завантаження, служба підтримки Google Play може скинути його після верифікації особи розробника без порушення можливості оновлення додатку. За застарілою моделлю, коли розробники напряму володіли ключем підпису додатку, втрата цього ключа унеможливлювала подальші оновлення програми.
11Екран втрачає введені користувачем дані форми після повертання пристрою. Як би ви систематично діагностували, чи спричинена проблема відсутністю збереженого стану (saved state), некоректною областю видимості ViewModel чи повторним прив'язуванням подання (view rebinding)?
Щоб систематично з'ясувати причину втрати даних форми після повертання пристрою, виділіть три ключові напрямки:
1. **Область видимості та збереження ViewModel (ViewModel Scoping & Retention):** Переконайтеся, що екземпляр ViewModel дійсно зберігається під час зміни конфігурації, а не створюється наново. Логуйте хеш-код ViewModel (`viewModel.hashCode()`) під час поворотів екрана. Перевірте, чи отримується він через делеговані властивості на кшталт `by viewModels()` / `by activityViewModels()` або `ViewModelProvider(this)`, а не через прямий виклик конструктора (наприклад, `MyViewModel()`).
2. **Збережений стан і відновлення за ідентифікатором подань (View ID):** Перевірте, чи покладалася форма виключно на стандартний механізм відновлення ієрархії подань в Android. Елементи подання повинні мати атрибут `android:id`, визначений у розмітці, щоб брати участь в автоматичному збереженні та відновленні стану. Якщо використовуються кастомні подання або `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 (Application Programming Interface) замінює застарілий startActivityForResult і як він захищає від втрати зворотних викликів (callbacks) під час повторного створення 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. Коли запущена активність повертає керування і хост-активність створюється повторно, `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) керування станом підпорядковується суворому односпрямованому потоку даних (UDF, Unidirectional Data Flow), побудованому навколо трьох елементів: 1. Єдиний незмінний `UiState`, що представляє стан усього екрана. 2. Дискретні типи `UiIntent` (або Action), що представляють усі взаємодії користувача та системні події. 3. Чиста функція-редуктор стану (State Reducer): `(PreviousState, UiIntent) -> NewState`. MVI гарантує атомарні оновлення стану, оскільки всі події відправляються як окремі наміри (intents) і послідовно обробляються через редуктор стану. Редуктор бере незмінний знімок наявного стану й створює повністю новий знімок стану, в якому всі пов'язані властивості оновлюються одночасно. Оскільки оновлення стану централізовані, а переходи відбуваються послідовно, UI ніколи не отримує частковий, розсинхронізований або суперечливий знімок стану.
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Як би ви спроєктували процес запиту дозволів під час виконання (runtime permissions), що обробляє перший запит, UI (User Interface) з поясненням необхідності, відхилення, опцію «більше не запитувати» та перехід до налаштувань без примусу користувача?
Зручний та ненав'язливий процес запиту дозволів під час виконання (runtime permissions) базується на поступовому розкритті інформації (progressive disclosure), чіткому поясненні потреби та плавному зниженні функціональності (graceful degradation) за будь-яких сценаріїв відмови:
1. **Контекстний запит (перший раз)**: запитуйте дозволи лише в момент потреби, коли користувач взаємодіє з функцією (наприклад, натискання «Сканувати код» для доступу до камери), а не заздалегідь під час запуску програми.
2. **Інтерфейс із поясненням (Rationale 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Як би ви спроєктували конвеєр CI/CD (Continuous Integration/Continuous Deployment) для Android від перевірки pull request до створення підписаного реліз-кандидата, внутрішнього тестування, завантаження у трек Play Store та поетапного розгортання?
Виробничий конвеєр CI/CD для Android починається на етапі валідації pull request зі швидких автоматизованих перевірок: статичного аналізу (ktlint, detekt, Android Lint), модульних тестів і компіляції налагоджувальної збірки (debug build). Після злиття в основну або релізну гілку запускається релізний конвеєр для створення підписаного реліз-кандидата (Release Candidate, RC) у форматі Android App Bundle (`.aab`) з використанням ключів підпису зі сховища ключів (keystore), керованого в CI, оптимізації та мінімізації коду через Proguard/R8 і автоматичного підвищення версії. Підписаний AAB разом із файлами зіставлення для деобфускації (mapping files) завантажується в трек внутрішнього тестування (Internal Testing) Google Play (або Firebase App Distribution) за допомогою таких інструментів, як Gradle Play Publisher (GPP) або Fastlane, де запускаються автоматизовані димові (smoke) або інструментальні тести. Щойно внутрішня команда QA та стейкхолдери схвалюють збірку, той самий двійковий артефакт просувається далі за ланцюжком (Internal -> Closed Alpha/Beta -> Production Rollout) без повторної компіляції з вихідного коду. На завершення поетапне розгортання (наприклад, 5% -> 20% -> 100%) у поєднанні з автоматичним моніторингом показників збоїв (crash rate) та показників стабільності (Android Vitals) забезпечує безпечне розгортання.
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 порушує скасування корутин і як діагностувати проблеми зі скасуванням?
Kotlin Coroutines покладаються на кооперативне скасування, реалізоване за допомогою `CancellationException`. Коли `Job` корутини скасовується, функції призупинення (такі як `delay` або `yield`) викидають `CancellationException`, щоб розгорнути стек викликів і завершити корутину. Якщо код перехоплює загальні `Exception` або `Throwable` без повторного викидання `CancellationException`, сигнал скасування поглинається. Корутина не переривається і продовжує виконуватися, перетворюючись на «корутину-зомбі» (zombie coroutine), яка марно витрачає ресурси процесора та пам'ять, спричиняє витоки ресурсів і може викликати некоректні зміни стану або збої (crashes) при зверненні до знищених компонентів UI. Для діагностики проблем зі скасуванням: 1. Перевірте коректність обробки винятків, переконавшись, що `CancellationException` явно викидається повторно або що перехоплюються специфічні винятки предметної області замість загальних `Throwable`/`Exception`. 2. Перевіряйте стан корутин у debug-збірках за допомогою Kotlin Coroutines Debugger (`-Dkotlinx.coroutines.debug`) або Coroutines Inspector в Android Studio, щоб виявити запущені корутини, які мали завершитися. 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, і як визначити, де має зберігатися стан: у листовій composable-функції (leaf composable), батьківській composable-функції чи у ViewModel?
Підйом стану (state hoisting) — це патерн у Jetpack Compose, за якого стан переміщується вище за ієрархією композиції, щоб зробити composable-функцію безстановою (stateless), перетворивши її на чистий UI-компонент. Це забезпечує односпрямований потік даних (UDF, Unidirectional Data Flow), де стан передається зверху вниз (від батьківського компонента/ViewModel до дочірніх composable-функцій у вигляді аргументів), а події підіймаються знизу вгору (від дочірніх composable-функцій до батьківського компонента/ViewModel у вигляді зворотних викликів).
Вибір місця зберігання стану здійснюється за такими правилами:
1. **Листова composable-функція (локальний UI-стан):** Якщо стан є суто тимчасовим, візуальним і не потрібним батьківським або суміжним компонентам (наприклад, чи виконується внутрішня анімація згортання/розгортання або локальний ефект натискання ripple), зберігайте його локально в листовій composable-функції за допомогою `remember`.
2. **Батьківська composable-функція (піднятий UI-стан):** Якщо суміжним composable-функціям потрібно спільно використовувати стан чи реагувати на нього, або якщо батьківський компонент керує видимістю чи валідацією компонента, стан підіймається до безпосереднього спільного батьківського компонента. Листова функція стає безстановою (приймає `value` та `onValueChange`).
3. **ViewModel (стан екрана / бізнес-стан):** Якщо стан представляє бізнес-дані, має зберігатися під час змін конфігурації (configuration changes), керує навігацією або вимагає взаємодії з шарами домену/репозиторію, він належить до ViewModel і надається у вигляді спостережуваного стану (наприклад, `StateFlow`). ViewModel обробляє бізнес-логіку та відповідним чином оновлює UI-стан.
18Як би ви спроєктували телеметрію, діагностичні сліди падінь (crash breadcrumbs) та архітектурні обмеження (architectural guardrails) для виявлення, діагностики та запобігання втраті стану FragmentManager і станам гонитви під час навігації в робочому середовищі?
Втрата стану FragmentManager (`IllegalStateException: Can not perform this action after onSaveInstanceState`) та асинхронні стани гонитви під час навігації виникають, коли асинхронні операції — такі як мережеві зворотні виклики чи реактивні потоки — намагаються виконати транзакції інтерфейсу користувача (UI) після того, як життєвий цикл хоста перейшов за межі `onSaveInstanceState()` або `onStop()`.
Для виявлення та діагностики таких проблем у робочому середовищі ми впроваджуємо телеметрію життєвого циклу та діагностичні сліди (breadcrumbs) через `Application.ActivityLifecycleCallbacks` і `FragmentManager.FragmentLifecycleCallbacks`, фіксуючи переходи з часовими мітками, кількість операцій у черзі backstack і контекст виконання перед збоєм.
Для архітектурного запобігання втраті стану навігація та UI-транзакції мають керуватися виключно спостерігачами стану, що враховують життєвий цикл (lifecycle-aware state observers, такі як `repeatOnLifecycle(Lifecycle.State.RESUMED)` або `StateFlow`, зібраний із прив'язкою до життєвого циклу), а не необробленими асинхронними зворотними викликами. Події навігації слід моделювати як дискретні переходи стану в межах односпрямованого потоку даних (UDF, Unidirectional Data Flow) або як одноразові події (single-shot events), які обробляються лише тоді, коли стан життєвого циклу є щонайменше `STARTED` або `RESUMED`.
Крім того, архітектурні обмеження мають гарантувати безпеку: `commitStateLoss()` слід дозволяти лише в явно невідновлюваних тимчасових контекстах або, що краще, перейти на компонент Jetpack Navigation із суворими межами життєвого циклу. Статичний аналіз за допомогою власних правил Android Lint та перевірки під час виконання в налагоджувальних збірках (наприклад, 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 обмежує використання процесора, доступ до мережі та виконання фонових робіт у періоди бездіяльності, дозволяючи виконання лише під час періодичних вікон обслуговування (maintenance windows). Режим App Standby Buckets динамічно знижує частоту фонових завдань і доступ до мережі залежно від того, як давно використовувався застосунок (від Active до Restricted або Never). Крім того, агресивні кастомні менеджери живлення від OEM-виробників (наприклад, MIUI або OneUI) часто примусово завершують фонові процеси, ігнорують стандартні сигнали AlarmManager та скасовують дозволи на автозапуск усупереч стандартній поведінці AOSP. Для побудови стійкого рушія синхронізації стандартною основою є WorkManager, оскільки він абстрагує JobScheduler, AlarmManager і BroadcastReceiver, напряму інтегруючись з обмеженнями ОС. WorkManager дозволяє декларувати суворі попередні умови виконання (як-от `NetworkType.CONNECTED`, `requiresBatteryNotLow(true)`), автоматично відкладаючи виконання до вікон обслуговування Doze або до відновлення зв'язку. Щоб протистояти неочікуваному завершенню процесів, тимчасовим збоям мережі та примусовим зупинкам з боку OEM, рушій синхронізації повинен дотримуватися двох основних принципів: експоненційного відкладення повторних спроб (exponential backoff) та наскрізної ідемпотентності. WorkManager слід налаштувати з `BackoffPolicy.EXPONENTIAL`, щоб уникнути проблеми навалу запитів (thundering herd) на серверах бекенду під час виходу з режиму Doze. Крім того, воркери мають обробляти операції синхронізації як атомарні та ідемпотентні — використовуючи детерміновані ідентифікатори транзакцій, локальні прапорці стану та серверні ключі дедуплікації, щоб у разі раптового завершення воркера та повторної постановки в чергу спроба виконання не призводила до дублювання даних або пошкодження локальних баз даних.
20Як спроєктувати масштабовану багатокомандну архітектуру Android із використанням патерну розділення модулів «API (Application Programming Interface) — Реалізація», щоб оптимізувати час збирання у Gradle та забезпечити суворі межі контрактів?
У корпоративній кодовій базі Android із багатьма командами патерн API-Implementation (API-Impl) розділяє кожен модуль функціональності або домену на два окремі підпроєкти Gradle: полегшений модуль `:feature:api`, що містить публічні інтерфейси, моделі та контракти навігації, і модуль `:feature:impl`, що містить внутрішню бізнес-логіку, користувацький інтерфейс та реалізації репозиторіїв. Модулі-споживачі залежать виключно від `:feature:api` через `implementation project(':feature:api')`, тоді як кореневий модуль `:app` або спеціальні корені композиції зв'язують конкретні реалізації через впровадження залежностей (наприклад, Dagger/Hilt). Цей патерн істотно оптимізує продуктивність збирання Gradle завдяки стабільності бінарного інтерфейсу застосунку (Application Binary Interface, ABI) та ізоляції classpath компіляції. Коли інженери змінюють деталі реалізації в `:feature:impl`, публічний ABI модуля `:feature:api` залишається незмінним. Відповідно, Gradle пропускає повторну компіляцію всіх залежних модулів, які спираються лише на `:feature:api`, максимально підвищуючи ефективність віддаленого кешу збирання Gradle (Remote Build Cache) та кешу конфігурації. З точки зору організації та управління цей патерн встановлює чіткі межі володіння між командами за допомогою таких інструментів, як CODEOWNERS. Команди можуть безпечно розвивати внутрішні деталі реалізації без розкриття приватних класів, запобігаючи небажаному сильному зв'язуванню та циклічним залежностям у великих командах.