20 поширених запитань на співбесіді для Mobile Developer. Мобільна розробка - затребуваний напрям, де створюють надійні iOS, Android і кросплатформні застосунки, вирішують задачі продукту, продуктивності, релізів, offline-режиму й інтеграції з пристроєм. Запитання охоплюють різні рівні, і ви можете потренуватися відповідати на них усно в нашому тренажері.
1У чому полягає різниця між збереженням токена автентифікації у стандартному сховищі ключ-значення (такому як SharedPreferences або UserDefaults) та в захищеному сховищі платформи (такому як Android Keystore або iOS Keychain)?
Стандартні механізми зберігання, такі як `SharedPreferences` в Android та `UserDefaults` в iOS, призначені для легких і неконфіденційних налаштувань. Вони зберігають дані у незашифрованих текстових файлах (XML або plist) усередині ізольованої директорії застосунку (sandbox). Будь-хто, хто має фізичний доступ до пристрою з root-доступом або jailbreak, до незашифрованої резервної копії пристрою або до файлової системи, може прочитати ці токени напряму. На противагу цьому, захищені сховища платформи — зокрема iOS Keychain та Android Keystore (який часто використовується через `EncryptedSharedPreferences`) — забезпечують шифрування збережених даних (data-at-rest encryption). iOS Keychain шифрує збережені елементи за допомогою ключів, прив'язаних до апаратного забезпечення пристрою (такого як Secure Enclave), і дозволяє налаштовувати деталізовані політики доступу. Android Keystore генерує та зберігає криптографічні ключі всередині апаратно ізольованих модулів безпеки (Trusted Execution Environment або StrongBox), гарантуючи, що криптографічні ключі ніколи не потраплять у пам'ять застосунку у відкритому вигляді і не зможуть бути вилучені з файлової системи.
// --- ANDROID ---
// INSECURE (Plain SharedPreferences):
val prefs = context.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("auth_token", token).apply() // Plaintext XML on disk
// SECURE (EncryptedSharedPreferences backed by Android Keystore):
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
securePrefs.edit().putString("auth_token", token).apply()
// --- IOS (Swift) ---
// INSECURE:
UserDefaults.standard.set(token, forKey: "auth_token") // Plain plist on disk
// SECURE (Keychain Services API):
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "user_auth_token",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
]
SecItemAdd(query as CFDictionary, nil)
2Яке основне призначення мобільного конвеєра CI/CD (Continuous Integration / Continuous Delivery) і які унікальні етапи та обмеження відрізняють його від конвеєрів розгортання для вебу або бекенду?
Основне призначення мобільного конвеєра CI/CD полягає в автоматизації збирання, тестування, підписування та розповсюдження мобільних клієнтських додатків для підтримання стабільної якості та повторюваності релізів. Неперервна інтеграція (CI) перевіряє зміни в коді за допомогою автоматизованих збірок, статичного аналізу та модульного тестування. Неперервна доставка/розгортання (CD) пакує, підписує та передає бінарні артефакти до тестових середовищ (таких як TestFlight або Firebase App Distribution) чи магазинів додатків. Мобільний CI/CD відрізняється від веб- та бекенд-конвеєрів кількома ключовими аспектами: 1. Тип артефакту: у процесі збирання створюються скомпільовані клієнтські бінарні файли (.ipa, .apk, .aab), а не програми, що запускаються безпосередньо на серверах або всередині образів контейнерів. 2. Апаратні обмеження засобів запуску (runners): компіляція під iOS потребує обладнання на базі macOS та інструментів командного рядка Xcode, тоді як бекенд-конвеєри зазвичай працюють у легких Linux-контейнерах. 3. Затримка публікації та узгодження: випуск додатків для кінцевих користувачів передбачає проходження циклів перевірки сторонніми магазинами (Apple App Store / Google Play Store), через що розгортання не можна миттєво відкотити простим перезапуском сервера. Виправлення потребують надсилання нового підписаного бінарного файлу або використання динамічного перемикання функціоналу (feature flags).
name: Mobile CI/CD Workflow
on:
pull_request:
branches: [main]
push:
tags:
- 'v*.*.*'
jobs:
ci_validation:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Linter
run: ./gradlew lint
- name: Run Unit Tests
run: ./gradlew testDebugUnitTest
cd_release_ios:
name: Build & Distribute iOS
needs: ci_validation
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Signing Certificates
run: fastlane match appstore --readonly
- name: Build & Upload to TestFlight
run: fastlane ios beta
3Яка мета розділення кросплатформового мобільного додатка на рівні представлення (presentation), домену (domain) та даних (data), і як така структура сприяє спільному використанню коду між iOS та Android?
Розділення додатка на рівні представлення, домену та даних формує чіткі межі відповідальності: 1. **Рівень представлення (Presentation Layer):** відповідає за відмальовування UI, введення користувача та стан на рівні екранів (наприклад, Views, Widgets, ViewModels або Presenters). 2. **Рівень домену (Domain Layer):** інкапсулює чисту бізнес-логіку, доменні моделі/сутності та сценарії використання (use cases). Він залишається незалежним від UI та платформових фреймворків. 3. **Рівень даних (Data Layer):** керує отриманням і збереженням даних із віддалених API, локальних баз даних або кешу пристрою через репозиторії та джерела даних. У кросплатформовій мобільній розробці така структура сприяє спільному використанню коду, оскільки рівні домену та даних є платформонезалежними. Ключові бізнес-правила, перетворення даних і мережеву взаємодію можна на 100% використовувати спільно між iOS та Android, що дозволяє командам або ділити весь стек (як у Flutter чи React Native), або спільно використовувати логіку бізнесу й даних, зберігаючи нативні рівні представлення для кожної платформи (як у Kotlin Multiplatform).
[Presentation Layer] (UI, Screens, ViewModels)
│
▼ calls
[Domain Layer] (Use Cases, Business Rules, Entities)
▲
│ implements interfaces
[Data Layer] (Repositories, API Clients, Local Storage)
4Які основні відмінності між імперативною та декларативною парадигмами розробки UI (User Interface) у мобільній інженерії, і як віджети Flutter та компоненти React Native втілюють декларативну модель?
В імперативній розробці користувацького інтерфейсу розробники пишуть явні покрокові інструкції для створення, зміни та видалення елементів інтерфейсу (наприклад, пошук представлення за ідентифікатором і безпосередній виклик методів на зразок `setText` або `setVisibility`). На противагу цьому декларативні моделі описують, як саме інтерфейс має виглядати для заданого стану (часто це виражається формулою UI = f(state)). Коли стан змінюється, фреймворк самостійно визначає, як ефективно оновити візуальне відображення. І Flutter, і React Native втілюють цю декларативну парадигму. У Flutter віджети є незмінними (immutable) описами конфігурації; коли динамічні дані змінюються, виклик `setState()` у `StatefulWidget` планує перебудову, під час якої метод `build()` повертає новий опис дерева віджетів, який Flutter узгоджує зі своїми деревами `Element` та `RenderObject`. У React Native компоненти є функціями або класами, які повертають JSX; оновлення стану (`state`) або властивостей (`props`) ініціює повторний рендеринг, під час якого React узгоджує віртуальне дерево елементів і застосовує мінімально необхідні нативні оновлення через міст (bridge) або нативне середовище виконання.
5У чому полягає операційна різниця між холодним (cold start), теплим (warm start) і гарячим (hot start) запуском у мобільних застосунках, і чому холодний запуск є найважливішою метрикою для бюджетів продуктивності (performance budgets)?
У життєвому циклі мобільних застосунків стани запуску різняться залежно від того, чи існують уже в ОС процес і стан пам'яті застосунку:
- Холодний запуск (Cold Start): застосунок запускається з нуля, оскільки ОС ще не створила його процес або процес раніше було завершено. ОС повинна виділити новий процес, завантажити бінарні файли/середовище виконання, ініціалізувати контекст застосунку/виконання та побудувати ієрархію першого представлення (inflate). Цей запуск має найвищу затримку.
- Теплий запуск (Warm Start): процес застосунку зазвичай уже перебуває в пам'яті, але ієрархія операцій/представлень (activity/view) була знищена або вивантажена (наприклад, через перестворення у фоновому режимі або навігацію назад). ОС перестворює інтерфейс користувача/активність без потреби запускати новий процес із нуля.
- Гарячий запуск (Hot Start): застосунок та стан його інтерфейсу все ще повністю перебувають у пам'яті (наприклад, користувач натиснув кнопку «Додому» і відразу повернувся). ОС просто переводить наявну ієрархію представлень на передній план із майже нульовими накладними витратами на ініціалізацію.
Холодний запуск є найкритичнішою метрикою для бюджетів продуктивності, оскільки він формує найповільніший досвід першого враження користувача. Тривалий холодний запуск безпосередньо корелює з миттєвим відтоком користувачів, нижчим рівнем утримання (retention) та погіршенням рейтингу в магазинах застосунків (наприклад, за пороговими значеннями Android Vitals).
6Що таке нативний міст (native bridge) у кросплатформному мобільному застосунку, і коли його варто використовувати замість реалізації функціональності виключно кодом Flutter або React Native?
Нативний міст — це комунікаційний шар, який дозволяє кросплатформному коду викликати платформозалежний код iOS або Android, а нативному коду — повертати результати або надсилати події у відповідь. У Flutter це зазвичай реалізується через платформові канали (platform channels) або плагіни. У React Native це зазвичай робиться за допомогою нативних модулів (native modules), TurboModules або нативних компонентів інтерфейсу.
Міст використовують тоді, коли функціональності потрібен доступ до того, що чистий код Flutter або React Native не може отримати належним чином: наприклад, API (Application Programming Interface) платформи, можливості пристрою, нативний SDK (Software Development Kit), критична до продуктивності нативна реалізація або нативний компонент інтерфейсу. Поширені приклади включають роботу з камерою, Bluetooth, push-сповіщення, платежі, захищене сховище, фонові служби, API для даних про здоров'я або SDK, які надають інтеграцію лише для Swift/Objective-C чи Kotlin/Java.
Якісний міст зазвичай надає невеликий і чіткий API кросплатформному шару (наприклад, `getBatteryLevel`, `startBluetoothScan` або `openNativePaymentSheet`), тоді як платформозалежна реалізація бере на себе всі низькорівневі деталі iOS та Android.
7Що означає архітектура offline-first у мобільній розробці та чим вона принципово відрізняється від стандартного кешування відповідей HTTP (Hypertext Transfer Protocol)?
У мобільній розробці архітектура offline-first розглядає локальне сховище як основне джерело істини (source of truth) для операцій як читання, так і запису. Замість очікування завершення мережевих запитів перед відображенням даних або дозволом дій користувача, застосунок взаємодіє безпосередньо з локальною базою даних або шаром сховища, тоді як фонові процеси синхронізації узгоджують локальні зміни з віддаленим сервером за наявності зв'язку. Це принципово відрізняється від стандартного кешування HTTP-відповідей за кількома ознаками: 1. **Модель даних**: HTTP-кешування зберігає необроблені відповіді мережі (наприклад, корисне навантаження JSON) за ключем, що складається з URL-адреси запиту та заголовків. Архітектура offline-first зберігає структуровані доменні сутності в локальній базі даних (такій як Room, SQLite або SwiftData/Core Data). 2. **Запис проти читання**: HTTP-кешування — це передусім механізм оптимізації читання, який природно не підтримує операції запису або мутації в автономному режимі. Архітектура offline-first дозволяє повноцінний локальний запис, ставлячи мутації в чергу для подальшої синхронізації із сервером. 3. **Можливість запитів і час життя**: Кешовані HTTP-відповіді підлягають автоматичним політикам витіснення з кешу, і до них не можна застосовувати довільні запити, фільтрацію чи об'єднання (joins). Збереження в offline-first забезпечує повноцінні запити, індексацію та детерміноване керування часом життя незалежно від стану мережі.
/* Network-First with HTTP Caching */
UI -> Network Request -> [Cache Hit ? Return Cached JSON : Fetch from API -> Save to HTTP Cache] -> UI
// Limitation: Read-only optimization; offline writes fail immediately.
/* Offline-First with Local Source of Truth */
UI <-> Observes Local DB (e.g., Room / Core Data / SQLite)
User Write -> Write to Local DB -> Queue Sync Task
Sync Worker (Background) -> Send queued mutations to Server -> Update Local DB with Server Response
8У чому полягає різниця між віддаленим push-сповіщенням і локальним сповіщенням у мобільному застосунку?
Основна різниця між локальними та віддаленими push-сповіщеннями полягає в джерелі їхнього походження та способі активації:
1. Локальні сповіщення створюються, плануються та ініціюються безпосередньо на пристрої самим застосунком за допомогою API операційної системи (таких як UserNotifications в iOS або AlarmManager/WorkManager/NotificationManager в Android). Вони спрацьовують на основі умов на боці пристрою, як-от певна дата й час, таймер зворотного відліку або географічні межі (geofencing). Оскільки вони виконуються локально системним демоном операційної системи, їм не потрібен сервер чи активне підключення до інтернету в момент показу.
2. Віддалені push-сповіщення надходять із зовнішнього сервера застосунку та передаються через інтернет за допомогою платформних push-шлюзів (APNs для пристроїв Apple, FCM для Android). Шлюз доставляє корисне навантаження системному демону пристрою на рівні ОС, що пробуджує застосунок або відображає банер. Віддалені сповіщення необхідні для подій реального часу, які виникають ззовні, наприклад для вхідних повідомлень у чаті, запитів у друзі або термінових новин.
let content = UNMutableNotificationContent()
content.title = "Workout Reminder"
content.body = "Time for your daily exercise routine."
content.sound = .default
// Triggers locally after 1 hour (3600 seconds) without any server interaction
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
let request = UNNotificationRequest(identifier: "dailyWorkout", content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule notification: \(error)")
}
}
9У чому полягає структурна та експлуатаційна різниця між Android App Bundle (.aab), APK (Android Package) та архівом iOS IPA (iOS App Store Package) під час розповсюдження додатків через магазини додатків?
APK (Android Package) — це формат виконуваного пакета, що містить скомпільований байт-код DEX, ресурси, асети та нативні бібліотеки, який можна безпосередньо встановити та запустити на пристрої Android. Універсальний APK містить ресурси для всіх роздільних здатностей екрана, архітектур процесора та мов, що призводить до більшого розміру завантаження. Android App Bundle (.aab) — це формат завантаження та публікації, обов'язковий у Google Play для розповсюдження через магазин. AAB не можна встановити безпосередньо на пристрій; натомість Google Play використовує цей бандл і Play App Signing для створення оптимізованих split APK, що динамічно адаптовані під архітектуру, роздільну здатність екрана та мову конкретного пристрою (Dynamic Delivery). iOS IPA (.ipa) — це файл архіву програми (zip-контейнер, що містить каталог Payload, бандл `.app`, ресурси підпису та метадані). Під час завантаження в App Store Connect компанія Apple виконує оптимізацію розміру (App Thinning, наприклад App Slicing), щоб доставляти лише ті ресурси та бінарні файли, які потрібні конкретному пристрою, що завантажує застосунок. З точки зору експлуатації, APK та IPA можна встановлювати безпосередньо на фізичні тестові пристрої (за умови належного підпису та профілів ініціалізації — provisioning), тоді як AAB перед локальним встановленням на пристрій необхідно спочатку конвертувати у split APK (наприклад, за допомогою `bundletool`).
# 1. Standalone APK installs directly via ADB
adb install myapp-universal.apk
# 2. AAB requires bundletool to generate device-tailored split APKs
bundletool build-apks --bundle=myapp.aab --output=myapp.apks --connected-device
# 3. Install the generated split APK set onto the connected device
bundletool install-apks --apks=myapp.apks
10Який захист забезпечує протокол Transport Layer Security (TLS) для мережевих викликів мобільного додатку та як сучасні мобільні операційні системи забезпечують безпеку передавання даних за замовчуванням?
Transport Layer Security (TLS) захищає мобільні мережеві виклики, надаючи три фундаментальні гарантії: конфіденційність (шифрування даних під час передавання, завдяки чому сторонні спостерігачі не можуть прочитати корисне навантаження або заголовки), цілісність даних (виявлення будь-яких спроб підробки чи зміни запитів і відповідей у процесі передавання) та автентифікація сервера (перевірка цифрового сертифіката сервера у довірених центрах сертифікації для запобігання атакам типу «людина посередині» — Man-in-the-Middle). Сучасні мобільні операційні системи за замовчуванням забезпечують безпечний транспорт, блокуючи незашифрований трафік у відкритому вигляді (cleartext HTTP): 1. iOS застосовує App Transport Security (ATS), вимагаючи, щоб мережеві з'єднання (зокрема через URLSession) використовували HTTPS з TLS 1.2+, якщо винятки для конкретних доменів не визначені явно в Info.plist. 2. Android (API 28+) за замовчуванням вимикає відкритий HTTP-трафік (`cleartextTrafficPermitted=false`). Дозвіл на використання незашифрованого HTTP потребує явного налаштування винятків через Network Security Configuration (`network_security_config.xml`) або атрибут маніфесту `android:usesCleartextTraffic`.
11Як реалізувати біометричну автентифікацію (Face ID / відбиток пальця) з безпечним сховищем платформи, щоб криптографічні ключі розблоковувалися лише після успішної біометричної перевірки?
Щоб надійно захистити криптографічні ключі за допомогою біометрії, застосунок не повинен покладатися лише на булевий зворотний виклик (callback) інтерфейсу користувача від біометричного діалогу. Натомість криптографічні ключі мають генеруватися та зберігатися в апаратно захищеному сховищі (Secure Enclave на iOS, Android Keystore / StrongBox на Android), налаштованому з політиками контролю доступу, які вимагають біометричної автентифікації перед використанням ключа.
На iOS ключі або елементи Keychain налаштовуються за допомогою `SecAccessControlCreateWithFlags` з прапорцями на кшталт `.biometryCurrentSet` (або `.userPresence`). Коли приватний ключ запитується для підпису або розшифрування, операційна система автоматично запитує у користувача Face ID / Touch ID.
На Android ключі генеруються за допомогою `KeyGenParameterSpec.Builder` із викликом `.setUserAuthenticationRequired(true)`. Криптографічна операція (така як `Cipher` або `Signature`) ініціалізується та передається як `BiometricPrompt.CryptoObject` у метод `BiometricPrompt.authenticate()`. Ключ розблоковується та стає доступним для використання лише всередині `onAuthenticationSucceeded` через цей автентифікований `CryptoObject`.
Використання `.biometryCurrentSet` (iOS) або `setInvalidatedByBiometricEnrollment(true)` (Android) гарантує, що у разі реєстрації нового відбитка пальця або іншої біометрії на пристрої наявні ключі стають остаточно недійсними, що запобігає несанкціонованому доступу у випадку компрометації пароля пристрою.
val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
"biometric_auth_key",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGen.init(spec)
keyGen.generateKey()
12Які стратегії кешування та оптимізації конвеєра збірки можна впровадити, щоб суттєво скоротити час збірки для pull request на кросплатформних агентах мобільного CI (Continuous Integration)?
Щоб суттєво скоротити час збірки для pull request на мобільних агентах CI, оптимізація має бути спрямована на розв'язання залежностей, кешування компіляції та вибіркове виконання завдань.
По-перше, налаштуйте ефективне кешування залежностей із ключами на основі хешів lock-файлів (наприклад, `package-lock.json`, `Podfile.lock`, `gradle/verification-metadata.xml` або файлів вирішення пакетів SPM). Це запобігає завантаженню чи повторному розв'язанню зовнішніх пакетів на чистих екземплярах агентів збірки.
По-друге, налаштуйте нативні кеші збірки. Для Android увімкніть Gradle Build Cache (`--build-cache`) із віддаленим HTTP-кешуванням або постійним кешуванням на рівні CI для директорій `~/.gradle/caches` та каталогів збірки. Для iOS ретельно кешуйте `DerivedData` в Xcode та артефакти компіляції Swift Package / CocoaPods або використовуйте сучасні інструменти віддаленого кешування, як-от Tuist чи Bazel. Для React Native/Flutter кешуйте згенерований JS bundle, директорію `node_modules` і кеш SDK рушія Flutter.
По-третє, застосовуйте вибіркове виконання завдань (відстеження змін та аналіз впливу тестів). Використовуйте фільтри шляхів у Git, щоб пропускати збірку iOS, коли змінено лише файли Android або бекенду; запускайте швидкі етапи лінтингу та модульних тестів перед ресурсомісткими UI-тестами чи кроками компіляції; уникайте створення повних релізних бінарних файлів (таких як AAB або універсальні IPA) для збірок у PR, де достатньо збірки для симулятора/debug або лише запуску модульних тестів.
name: PR Fast Check
on:
pull_request:
paths:
- 'android/**'
- 'shared/**'
jobs:
android-unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
# Gradle build caching for dependencies & task outputs
- uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: false
- name: Run Unit Tests & Lint Only
run: ./gradlew testDebugUnitTest lintDebug --build-cache --parallel
13Як би ви структурували спільну функціональність (shared feature), щоб рівень представлення (presentation), доменні прецеденти використання (domain use cases) та доступ до даних залишалися незалежно тестованими та придатними для повторного використання в iOS та Android?
Щоб структурувати спільну функціональність для незалежного тестування та повторного використання на iOS та Android, варто застосувати чисту шарувату архітектуру (Clean Architecture), розділену на рівні представлення (Presentation), домену (Domain) та даних (Data):
1. **Доменний рівень (чиста спільна логіка):** містить чисті бізнес-сутності, інтерфейси репозиторіїв (контракти) та прецеденти використання / інтерактори (наприклад, `GetCartUseCase`, `ApplyPromoCodeUseCase`). Цей рівень не має жодних залежностей від UI-фреймворків (UIKit, SwiftUI, Android Views, Jetpack Compose, Flutter widgets) або платформових API. Кожен прецедент використання інкапсулює одну бізнес-операцію, що робить його на 100% придатним для модульного тестування за допомогою простих макетів (mocks).
2. **Рівень даних (доступ до даних та інкапсуляція):** реалізує інтерфейси доменних репозиторіїв. Він взаємодіє з віддаленими джерелами даних (REST/GraphQL) та локальним сховищем (SQLite/Key-Value). Мережеві DTO (Data Transfer Objects) і сутності бази даних суворо мапляться в чисті доменні сутності перед тим, як покинути цей рівень. Це запобігає проникненню зовнішніх схем серіалізації або змін у БД до доменного чи UI-рівня.
3. **Рівень представлення (UI та керування станом):** складається з власників стану (ViewModels, BLoC або Presenters) та компонентів інтерфейсу (views/widgets). Власник стану викликає доменні прецеденти використання, керує станом UI (завантаження, успіх, помилка) і надає спостережуваний стан представленню. Представлення лише спостерігає за цим станом і відображає його.
Така структура дає змогу ізольовано тестувати прецеденти використання без UI, використовувати макети репозиторіїв для тестування моделей представлення (ViewModels) і замінювати реалізації інтерфейсу для різних платформ, повторно використовуючи всю доменну логіку та рівень даних.
[ UI View / Compose / SwiftUI ]
|
v (Observes State / Dispatches Events)
[ ViewModel / Presenter / BLoC ] (Presentation Layer)
|
v (Executes pure business operations)
[ Use Case / Interactor ] (Domain Layer - Pure Shared Logic)
|
v (Calls interface contract)
[ Repository Interface ] (Domain Layer Contract)
^
| (Implements)
[ Repository Implementation ] (Data Layer)
|---- Maps NetworkDTO -> DomainEntity
|---- Maps DatabaseEntity -> DomainEntity
[ API Client / Local DB / Storage ]
14Як визначити, що локального стану компонента вже недостатньо, і вибрати відповідний архітектурний підхід до керування станом для кросплатформної кодової бази з багатьма розробниками?
У кросплатформній мобільній розробці перехід від локального стану компонента (`useState`/`StatefulWidget`) до архітектурного рішення для керування станом зумовлений областю видимості стану, вимогами до життєвого циклу та тестованістю: 1. **Коли локального стану недостатньо**: - **Спільне використання між компонентами та прокидання пропсів (Prop Drilling)**: Коли доступ до стану або його зміна необхідні в різних маршрутах навігації чи віддалених гілках дерева віджетів/компонентів. - **Збереження між життєвими циклами**: Коли дані повинні зберігатися після знищення екрана, переходів між маршрутами або скидання навігації (наприклад, сесія користувача, вміст кошика, кешовані стрічки). - **Розділення обов'язків і тестованість**: Коли бізнес-логіка, валідація та побічні ефекти стають тісно переплетеними з UI-компонентами, що ускладнює автоматизоване модульне тестування без графічного інтерфейсу (headless unit testing). 2. **Вибір архітектури для кодових баз із багатьма розробниками**: - **Односпрямований потік даних / Передбачувані контейнери (наприклад, BLoC, Redux, Riverpod)**: Забезпечують суворе розділення, де інтерфейс відправляє явні події/дії та відображає незмінний стан, випромінюваний виділеними модулями бізнес-логіки. Це формує чіткі контракти, зменшує побічні ефекти та дозволяє незалежно тестувати бізнес-логіку модульними тестами. - **Атомарні / Обмежені за областю реактивні сховища (наприклад, Zustand, MobX, Provider)**: Потребують менше шаблонного коду та пропонують гнучкі підписки через селектори, що підходить для команд, які прагнуть легкого розділення залежностей і точкового повторного рендерингу компонентів. У командній розробці головна архітектурна мета полягає в ізоляції чистої бізнес-логіки від рівнів відображення інтерфейсу та використанні вибіркових підписок для запобігання зайвим повним повторним рендерингам дерева компонентів.
15Як би ви досліджували та усували посмикування (jank) на екрані кросплатформового мобільного додатка, що виникає під час прокручування довгого списку зображень і динамічного вмісту?
Спочатку я б відтворив затримки на реальних пристроях і профілював застосунок, оскільки посмикування під час прокручування (jank) можуть бути викликані різними факторами: перевищенням ліміту часу на кадр (frame budget), навантаженням від коду JS або Dart, роботою в головному/UI-потоці, операціями GPU/растеризації, розрахунком макета (layout), декодуванням зображень, нестачею пам'яті або завантаженням з мережі. За частоти 60 Гц додаток має близько 16,7 мс на кадр, а за 120 Гц — близько 8,3 мс, тому ресурсомісткий рендеринг, декодування, обчислення макета чи синхронні операції під час прокручування спричиняють пропуск кадрів. Для пошуку справжнього вузького місця я б використав такі інструменти, як Flutter DevTools, інструменти продуктивності React Native або Flipper, Android Studio Profiler, Xcode Instruments та графіки часової шкали кадрів. Для довгого списку я б переконався, що він лінивий або віртуалізований: наприклад, FlatList, FlashList або RecyclerListView у React Native, чи ListView.builder, SliverList або подібні будівники у Flutter. Я б використав стабільні ключі, уникав перебудови або повторного рендерингу кожного рядка при зміні стану батьківського компонента, застосовував мемоізацію компонентів рядків або селекторів, де це доречно, робив функцію build/renderItem максимально легкою та виніс сортування, фільтрацію, парсинг JSON, форматування чи обробку зображень за межі процесу прокручування. Якщо розміри елементів передбачувані, я б надав підказки щодо макета, як-от getItemLayout у React Native або фіксовані/прототипні розміри елементів у Flutter. Щодо зображень, я б використовував мініатюри відповідного розміру, кешував їх, уникав декодування повнорозмірних зображень для маленьких клітинок, застосовував плейсхолдери та відкладене завантаження, а також контролював надмірне виділення пам'яті через великі растрові зображення (bitmaps). Також я б спростив надмірно складні макети рядків, зменшив використання витратних тіней, обрізань (clipping), надлишкового перемальовування (overdraw) і напівпрозорості (opacity), де це доречно, пакетував або пагінував дані, а потім повторно провів би профілювання, щоб переконатися у зменшенні кількості пропущених кадрів і покращенні часу кадру.
1. Record a trace while scrolling on a real device.
2. Check frame timeline: are frames exceeding 16.7 ms / 8.3 ms?
3. Identify bottleneck: JS/Dart, main/UI thread, raster/GPU, image decode, memory, or network.
4. Fix the largest measured bottleneck: virtualization, image resizing/caching, row memoization, layout simplification, moving work off scroll path.
5. Re-test on low-end and target-refresh-rate devices.
16Як функціонують межі потоків під час викликів через нативний міст (native bridge) і як запобігти затримкам через перемикання потоків або підвисанням UI (User Interface), коли нативні модулі виконують ресурсомістку фонову роботу?
Кросплатформові фреймворки використовують специфічні конвенції щодо потоків для викликів через міст: наприклад, стандартні виклики `MethodChannel` у Flutter надходять у головний UI-потік платформи, тоді як традиційні виклики мосту в React Native виконуються у виділеному JavaScript-потоці й асинхронно диспетчеризуються у нативні потоки. Коли нативний метод мосту виконується в нативному UI/головному потоці, запуск ресурсомістких операцій процесора (CPU), тривалого синхронного дискового вводу-виводу (I/O) або блокувальних мережевих завдань блокує головний цикл виконання (main run loop). Це призводить до пропуску кадрів, помітних підвисань UI, а на Android — до помилок Application Not Responding (ANR) чи примусового завершення процесу сторожовим таймером (watchdog) в iOS. Щоб запобігти цьому, обробники нативного мосту мають виносити важку або блокувальну роботу у фонові пули виконання (наприклад, Kotlin Coroutines із `Dispatchers.IO`/`Dispatchers.Default`, `ThreadPoolExecutor` в Android або `Task.detached` у Swift чи `DispatchQueue.global()` у GCD). Після завершення роботи результат слід передати назад у потік мосту, який очікує фреймворк (наприклад, повернення результатів у UI-потік для стандартних MethodChannels або використання фонових черг завдань), гарантуючи, що кросплатформові оновлення стану не блокуватимуть цикл рендерингу UI.
class ImageProcessorPlugin : MethodChannel.MethodCallHandler {
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "applyFilter") {
val imageBytes = call.argument<ByteArray>("image") ?: return result.error("INVALID_ARG", "Image null", null)
// Dispatch heavy computation off the main UI thread
scope.launch {
try {
val processed = processBitmapBytes(imageBytes) // CPU heavy work
withContext(Dispatchers.Main) {
result.success(processed)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
result.error("PROCESSING_FAILED", e.localizedMessage, null)
}
}
}
} else {
result.notImplemented()
}
}
}
17Як би ви спроєктували стратегію кешування та інвалідації для екранів списків і новинних стрічок, використовуючи шаблон Stale-While-Revalidate та умовні заголовки HTTP (Hypertext Transfer Protocol)?
Надійна стратегія кешування та інвалідації для екранів стрічок і списків поєднує патерн Stale-While-Revalidate (SWR) з умовними заголовками валідації HTTP (такими як `ETag` / `If-None-Match` або `Last-Modified` / `If-Modified-Since`). Під час відкриття екрана дані негайно зчитуються та відображаються з локальної бази даних (єдине джерело правди — Single Source of Truth, наприклад, Room або Core Data/SQLite), забезпечуючи користувачеві миттєвий рендеринг інтерфейсу. Одночасно у фоновому режимі надсилається мережевий запит із кешованим заголовком `If-None-Match: <etag>`. Якщо сервер повертає `304 Not Modified`, корисне навантаження не передається, що підтверджує валідність локального кешу й заощаджує трафік та заряд акумулятора. Якщо сервер відповідає `200 OK`, нові дані та оновлений `ETag` записуються в локальну базу даних у транзакції, а реактивні спостерігачі бази даних автоматично транслюють оновлений список в UI. Для пагінації маркери сторінок (page tokens) або зсуви (offsets) зберігаються разом із кешованими записами. Інвалідація запускається після завершення часу життя запису (TTL), діями користувача (наприклад, pull-to-refresh в обхід умовних заголовків) або побічними ефектами локальних мутацій (наприклад, створення чи видалення елемента оптимістично оновлює базу даних і позначає кешовані курсори сторінок для повторної валідації).
fun getFeed(): Flow<List<FeedItem>> = flow {
val cached = feedDao.getFeedItems()
if (cached.isNotEmpty()) emit(cached)
val lastEtag = feedDao.getFeedEtag()
try {
val response = api.fetchFeed(ifNoneMatch = lastEtag)
if (response.code() == 200 && response.body() != null) {
feedDao.updateFeedTransaction(response.body()!!, response.headers()["ETag"])
emit(feedDao.getFeedItems())
}
} catch (e: Exception) {
if (cached.isEmpty()) throw e
}
}
18Фінансовий мобільний застосунок повинен безпечно зберігати токени оновлення (refresh tokens) на пристрої, підтримувати біометричне розблокування, зберігати працездатність після оновлення ОС (Operating System) та безпечно функціонувати під час тимчасових офлайн-сесій. Як би ви спроєктували архітектуру зберігання та доступу до таких токенів?
Надійна архітектура зберігання токенів у мобільному застосунку використовує апаратні модулі безпеки: iOS Keychain на базі Secure Enclave та Android Keystore на базі StrongBox або ізольованого середовища виконання TEE (Trusted Execution Environment). Токени оновлення повинні шифруватися у стані спокою (at rest) за допомогою ключів, захищених біометричними криптографічними обмеженнями (такими як `kSecAccessControlBiometryCurrentSet` чи `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` на iOS та `setUserAuthenticationRequired(true)` з `AUTH_BIOMETRIC_STRONG` на Android). Криптографічне обмеження гарантує, що криптографічний ключ розгортається апаратним модулем виключно після успішної біометричної автентифікації, усуваючи залежність від перевірки логічного значення в коді застосунку, яку можна обійти. Щоб зберігати працездатність після оновлень ОС і водночас запобігати несанкціонованому доступу, ключі прив'язуються до апаратного забезпечення пристрою із застосуванням політик зміни біометрії, які анулюють ключ або вимагають повторної автентифікації у разі реєстрації нових біометричних даних (наприклад, `setInvalidatedByBiometricEnrollment(true)` на Android). Для тимчасових офлайн-сесій короткоживучі зашифровані токени доступу (access tokens) та обмежені локальні стани офлайн-сесії можуть функціонувати в межах визначеного локального TTL і обмежених прав, відкладаючи привілейовані операції оновлення до відновлення підключення до мережі. Після відновлення зв'язку ротація токенів оновлення з виявленням повторного використання на бекенді валідує сесію; якщо виявлено повторне використання токена або відкликання облікового запису, бекенд повертає сигнал анулювання, що змушує клієнт видалити апаратно захищені ключі та очистити офлайн-сховище.
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
val spec = KeyGenParameterSpec.Builder(
"refreshTokenKeyAlias",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
.setInvalidatedByBiometricEnrollment(true)
.build()
keyGenerator.init(spec)
keyGenerator.generateKey()
19Як би ви спроєктували масштабовану інфраструктуру CI/CD (Continuous Integration/Continuous Deployment) для кросплатформового мобільного монорепозиторію, яка підтримує декілька команд і водночас оптимізує використання обмежених та високовартісних агентів збірки на базі macOS?
Для проєктування масштабованої інфраструктури CI/CD кросплатформового мобільного монорепозиторію за умов оптимізації дефіцитних і дорогих ресурсів macOS архітектура повинна спиратися на виявлення зачеплених збірок на основі графа залежностей, чіткий поділ робочого навантаження між гібридними пулами агентів збірки (runners), а також віртуалізацію й оркестрацію ефемерних середовищ для macOS. По-перше, впровадьте інструмент для побудови графа збірки монорепозиторію (такий як Bazel, Nx або Turborepo) з розподіленим віддаленим кешуванням. Для кожного pull request конвеєр CI обчислює різницю у прямому ациклічному графі (DAG) відносно цільової гілки, щоб збирати та тестувати виключно зачеплені пакети та залежні від них модулі нижчого рівня, повністю пропускаючи незмінені компоненти. По-друге, реалізуйте гібридну стратегію розподілу агентів: перенесіть усі завдання, які не вимагають безпосередньо Xcode (лінтування та компіляцію TypeScript/Dart, модульні тести, статичний аналіз коду, сканування безпеки та збірки Android через Gradle), на економічні й легко масштабовані агенти під керуванням Linux/Kubernetes. Агенти macOS слід виділяти суто для фінального складання під iOS, компіляції Swift/Objective-C, підписування коду та виконання тестів у симуляторі iOS. По-третє, керуйте агентами macOS за допомогою інфраструктури віртуалізації (наприклад, Tart, Anka або виділених серверів AWS/MacStadium, керованих через Nomad чи Kubernetes). Кожна збірка iOS виконується в чистому ефемерному віртуальному середовищі з попередньо прогрітими наборами інструментів (toolchains) та кешами похідних даних (derived-data), що запобігає дрейфу конфігурації агентів і забезпечує швидке автоскейлінг залежно від глибини черги конвеєра.
20Як би ви спроєктували кросплатформову мобільну архітектуру для продукту, де більша частина бізнес-логіки має бути спільною між iOS та Android, але водночас UI (User Interface), навігація та нативні інтеграції мають розвиватися незалежно?
Я би спроєктував застосунок навколо спільного ядра, яке відповідає за стабільну бізнес-поведінку: доменні моделі, валідацію, прецеденти використання (use cases), бізнес-правила та контракти доступу до даних. Специфічні для платформи UI, навігація, нативні SDK, дозволи, обробка життєвого циклу та завдання оболонки застосунку мають залишатися поза цим ядром. Спільний код не повинен імпортувати UIKit, SwiftUI, Jetpack, API фреймворку Android, навігацію React Native, навігацію Flutter чи деталі нативних SDK. Натомість він має залежати від вузьких інтерфейсів, таких як AuthRepository, SecureStorage, Analytics, CameraService або PaymentProvider, які реалізуються кожною платформою.
Я би структурував код за функціональними напрямами (feature-based), наскільки це можливо, замість створення одного великого спільного шару. Наприклад, оформлення замовлення, пошук, обліковий запис і обмін повідомленнями можуть мати власний спільний доменний код/код прецедентів використання, контракти станів та інтерфейси репозиторіїв. Оболонки iOS та Android відповідають за компонування екранів, нативні патерни інтерфейсу, стеки навігації, дозволи та підключення адаптерів. Логіка представлення може бути спільною, якщо вона справді не залежить від UI-фреймворку (наприклад, редуктори, скінченні автомати або контракти моделей представлення (view-model), які віддають прості стани й дії), але самі подання (views) та навігація мають належати відповідним платформам для їхнього незалежного розвитку.
Основний компроміс полягає в активному повторному використанні спільної стабільної бізнес-логіки з одночасним уникненням абстракцій, які приховують реальні відмінності платформ. Я би забезпечував дотримання меж за допомогою правил залежностей між модулями, впровадження залежностей, публічних API, контрактних тестів, архітектурних перевірок і чіткого розподілу зон відповідальності. Нативні інтеграції повинні використовувати патерн портів і адаптерів (ports/adapters), щоб спільний код функціональності бачив єдину можливість, тоді як кожна платформа обробляла поведінку SDK, життєвий цикл, дозволи, помилки та відмінності UX у власному шарі.