Підготовка до співбесіди з iOS

Запитання для співбесіди з iOS

20 поширених запитань на співбесіді з iOS. iOS-розробка - затребуваний напрям, де створюють застосунки на Swift, SwiftUI і UIKit, вирішують задачі lifecycle, конкурентності, мережі, зберігання даних, продуктивності, тестування й публікації в App Store. Запитання охоплюють різні рівні, і ви можете потренуватися відповідати на них усно в нашому тренажері.

Почати AI-співбесіду з iOSКредитна картка не потрібна. Доступна 1 безкоштовна сесія.
Практика технічних співбесід англійськоюРежим для тих, для кого англійська не є рідною, щоб тренувати технічні співбесіди.

Запитання для початківців

1Які обов'язки має виконувати UIViewController у типовій архітектурі iOS MVC (Model-View-Controller)?

У класичному патерні iOS MVC (Model-View-Controller) `UIViewController` виступає в ролі контролера (Controller), який є посередником між компонентами представлення (View) та даними моделі (Model). Його основні обов'язки включають керування життєвим циклом представлення (наприклад, `viewDidLoad`, `viewWillAppear`), налаштування та оновлення елементів інтерфейсу користувача (UI), обробку прямої взаємодії з користувачем (такої як натискання кнопок, делегати та target-action) і координацію переходів або презентацій екранів. Обов'язки, не пов'язані з інтерфейсом користувача (наприклад, робота з мережею, збереження даних у сховище та складна бізнес-логіка), слід делегувати окремим сервісним об'єктам або об'єктам моделі, щоб запобігти перетворенню контролера на Massive View Controller.

class UserProfileViewController: UIViewController {
    private let userService: UserServiceProtocol
    private let profileView = UserProfileView()
    
    init(userService: UserServiceProtocol) {
        self.userService = userService
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    
    override func loadView() {
        view = profileView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        profileView.editButton.addTarget(self, action: #selector(didTapEdit), for: .touchUpInside)
        fetchProfile()
    }
    
    private func fetchProfile() {
        userService.fetchCurrentUser { [weak self] result in
            DispatchQueue.main.async {
                if case .success(let user) = result {
                    self?.profileView.configure(with: user)
                }
            }
        }
    }
    
    @objc private func didTapEdit() {
        // Handle user action or trigger navigation
    }
}
Відповісти на це запитання з ШІ-тренером

2Яка роль профілю забезпечення (provisioning profile) у релізі iOS-застосунку?

Під час релізу iOS-застосунку профіль забезпечення (provisioning profile) діє як криптографічно підписаний пакет, який узгоджує вимоги безпеки Apple із бінарним файлом застосунку та цільовими пристроями. Його основне завдання — повідомити операційній системі iOS та App Store Connect, що застосунок авторизовано для запуску відповідно до визначених правил розповсюдження. Профіль забезпечення об'єднує кілька важливих компонентів: App ID (ідентифікатор пакета — Bundle Identifier), авторизований сертифікат або сертифікати підпису, що підтверджують особу розробника, надані застосунку права та можливості (entitlements і capabilities, такі як Push-повідомлення, Sign in with Apple або iCloud), а також — для профілів не з App Store, таких як Development або Ad-Hoc — список дозволених ідентифікаторів пристроїв UDID (Unique Device Identifier). Для релізних профілів App Store індивідуальні UDID пристроїв опускаються, оскільки Apple дозволяє розповсюдження на будь-які споживчі пристрої через App Store.

Provisioning Profile (.mobileprovision)
├── App ID / Bundle Identifier (e.g., com.example.app)
├── Certificates (Public key / Distribution Certificate)
├── Entitlements (e.g., Push Notifications, Associated Domains)
└── Device UDIDs (Present for Development/Ad-Hoc; omitted for App Store distribution)
Відповісти на це запитання з ШІ-тренером

3Що означає виконання роботи у фоновому режимі для застосунку iOS, і які базові обмеження система накладає на таку роботу?

Виконання роботи у фоновому режимі в iOS означає виконання коду, коли застосунок не є активним на екрані або видимим для користувача. Коли користувач виходить із застосунку, той переходить з активного стану переднього плану у фоновий стан, а невдовзі після цього — у стан призупинення (suspended state), де його виконання зупиняється, хоча його пам'ять залишається в RAM. Щоб зберегти заряд акумулятора, швидкість відгуку системи та ресурси пристрою, iOS суворо контролює фонове виконання. Система обмежує тривалість виконання коду після переходу у фоновий режим (зазвичай це коротке обмежене вікно приблизно у 30 секунд, якщо не використовуються спеціальні фонові режими або заплановані завдання), обмежує пріоритет процесора (CPU) і мережі та може примусово завершити призупинені застосунки, якщо пристрій зазнає нестачі пам'яті.

import UIKit

NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification,
    object: nil,
    queue: .main
) { _ in
    print("App entered background. Code execution will pause soon unless extended.")
}
Відповісти на це запитання з ШІ-тренером

4Що таке видавець (publisher) у Combine і чим він відрізняється від передплатника (subscriber) у типовому потоці даних в iOS?

У фреймворку Combine видавець (Publisher) генерує потік значень із плином часу і може завершитися подією завершення (успішно або з помилкою). Він оголошує два пов'язані типи: `Output` (тип даних, які він генерує) і `Failure` (тип помилки, яку він може згенерувати). Передплатник (Subscriber) отримує значення та події життєвого циклу від видавця. У типовому потоці даних в iOS видавці виступають джерелом або виробником асинхронних подій (таких як мережеві відповіді, сповіщення або введення даних користувачем), тоді як передплатники є споживачами, які реагують на випущені значення, обробляють помилки та реагують на завершення (наприклад, оновлюючи стан інтерфейсу користувача або записуючи дані в базу даних). Передплатник приєднується до видавця, отримує токен підписки та надсилає запит на необхідну кількість елементів.

import Combine

// Publisher: emits a sequence of integers
let numberPublisher = [1, 2, 3].publisher

// Subscriber: consumes the emitted integers
let cancellable = numberPublisher.sink(
    receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("Finished")
        case .failure(let error):
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Received: \(value)")
    }
)
Відповісти на це запитання з ШІ-тренером

5Що означає `async/await` у Swift, і як би ви використали цю конструкцію для виклику асинхронного API (Application Programming Interface) в застосунку для iOS?

`async/await` — це вбудований у Swift синтаксис для написання асинхронного коду в лінійному, легкому для читання послідовному стилі замість використання вкладених замикань (closures) та обробників завершення (completion handlers). Позначення функції як `async` вказує компілятору, що вона може призупиняти своє виконання під час очікування тривалих операцій (таких як мережеві запити чи введення-виведення файлів / file I/O). Ключове слово `await` позначає точку призупинення (suspension point): виконання поточної функції ставиться на паузу, звільняючи базовий потік для виконання іншої роботи, і відновлюється, щойно очікуваний результат або помилка будуть готові. Щоб викликати асинхронний API в iOS-застосунку, ви викликаєте метод `async` із ключовим словом `await` (або `try await`, якщо функція може викидати помилки) всередині асинхронного контексту, наприклад, усередині іншої функції `async` або блоку `Task`.

func fetchUserData(from url: URL) async throws -> User {
    let (data, response) = try await URLSession.shared.data(from: url)
    
    guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    
    return try JSONDecoder().decode(User.self, from: data)
}
Відповісти на це запитання з ШІ-тренером

6У яких випадках варто використовувати UserDefaults у застосунку для iOS, і які типи даних не слід там зберігати?

`UserDefaults` призначений для збереження невеликих, легковагових користувацьких налаштувань, параметрів і прапорців між запусками застосунку (наприклад, налаштування теми, рівень гучності або булевий прапорець `hasSeenOnboarding`). Він підтримує типи списків властивостей (property list), такі як `String`, `Int`, `Double`, `Bool`, `Date`, `Data`, `Array` та `Dictionary`. НЕ слід зберігати: 1. Конфіденційну інформацію (таку як паролі користувачів, приватні ключі або токени автентифікації), оскільки `UserDefaults` зберігає дані у нешифрованому вигляді у файлі plist у відкритому тексті. 2. Великі набори даних, документи або медіафайли (наприклад, зображення, аудіо або великі обсяги даних у форматі JSON), оскільки під час звернення весь plist-файл завантажується в оперативну пам'ять, що призводить до значних перевитрат пам'яті та сповільнює запуск застосунку.

// Saving simple preference values
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
UserDefaults.standard.set("dark", forKey: "preferredTheme")

// Reading values back
let hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")
let theme = UserDefaults.standard.string(forKey: "preferredTheme") ?? "system"
Відповісти на це запитання з ШІ-тренером

7Що таке ARC (Automatic Reference Counting) у Swift і як цей механізм керує часом життя екземплярів класів у застосунку для iOS?

ARC (Automatic Reference Counting) — це механізм керування пам'яттю на етапі компіляції у Swift для відстеження та керування часом життя екземплярів класів (посилальних типів). Щоразу, коли створюється новий екземпляр класу і присвоюється сильному посиланню (strong reference), ARC відстежує внутрішній лічильник посилань (refcount) для цього екземпляра. Доки існує хоча б одне сильне посилання на екземпляр, ARC утримує його в пам'яті. Коли всі сильні посилання видаляються і лічильник посилань стає рівним нулю, ARC негайно звільняє пам'ять екземпляра, автоматично викликаючи метод `deinit` безпосередньо перед звільненням. На відміну від збирача сміття під час виконання (runtime garbage collection) у середовищах на зразок Java або .NET, ARC не запускає періодичні цикли очищення; компілятор Swift вставляє відповідні виклики retain та release у бінарний код ще на етапі компіляції.

class User {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) initialized")
    }
    deinit {
        print("\(name) deallocated")
    }
}

var ref1: User? = User(name: "Alice") // count = 1
var ref2: User? = ref1               // count = 2

ref1 = nil                           // count = 1
ref2 = nil                           // count = 0 -> deinit called
Відповісти на це запитання з ШІ-тренером

8Як виконати простий GET-запит за протоколом HTTP (Hypertext Transfer Protocol) за допомогою URLSession у застосунку для iOS?

Щоб виконати простий GET-запит за протоколом HTTP у застосунку для iOS, створюють об'єкт URL і передають його в екземпляр URLSession, наприклад `URLSession.shared`. Виконати запит можна за допомогою обробника завершення завдання передавання даних (`URLSession.shared.dataTask(with: url) { data, response, error in ... }`) або за допомогою сучасного механізму конкурентності у Swift (`let (data, response) = try await URLSession.shared.data(from: url)`). У разі використання традиційного підходу з `dataTask` необхідно викликати `.resume()` на завданні для його запуску, обробити можливі транспортні помилки, перевірити код стану HTTPURLResponse і переконатися, що оновлення інтерфейсу користувача передаються в головний потік.

guard let url = URL(string: "https://api.example.com/items") else { return }

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        print("Network error: \(error.localizedDescription)")
        return
    }
    
    guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
        print("Server error or invalid status code")
        return
    }
    
    if let data = data {
        DispatchQueue.main.async {
            // Update UI on main thread
        }
    }
}
task.resume()
Відповісти на це запитання з ШІ-тренером

9Що таке Instruments у Xcode і як використовувати цей інструмент для дослідження простої проблеми з продуктивністю в застосунку для iOS?

Instruments — це інструмент профілювання та аналізу продуктивності від Apple, інтегрований у Xcode. Він дає змогу розробникам відстежувати поведінку під час виконання, використання CPU, виділення пам'яті, витоки пам'яті та швидкість відтворення інтерфейсу користувача. Щоб дослідити просту проблему з продуктивністю (наприклад, підвисання інтерфейсу або надмірне навантаження на CPU), ви запускаєте Instruments із Xcode (через меню Product -> Profile), обираєте відповідний шаблон (наприклад, Time Profiler для виявлення затримок у CPU або Allocations/Leaks для проблем із пам'яттю) та записуєте трасування під час відтворення проблемного сценарію на пристрої. Після запису ви аналізуєте дерево викликів і найважчі трасування стека, щоб точно визначити методи, які спричиняють затримки чи надмірне споживання ресурсів, що дозволяє локалізувати вузьке місце до внесення змін у код.

1. In Xcode, select Product -> Profile (Builds with Release optimizations).
2. Select the 'Time Profiler' template.
3. Click Record and perform the sluggish action in the app.
4. Stop recording.
5. Inspect the Call Tree tab:
   - Enable 'Separate by Thread' and 'Hide System Libraries'.
   - Trace the heaviest call stack on the Main Thread to identify the blocking function.
Відповісти на це запитання з ШІ-тренером

10Що таке APNs (Apple Push Notification service) і яку роль вона відіграє в доставці push-сповіщень до додатків на iOS?

APNs (Apple Push Notification service) — це хмарний сервіс-посередник від Apple, який безпечно маршрутизує push-сповіщення від ваших бекенд-серверів (серверів-провайдерів) до пристроїв Apple. Оскільки мобільні пристрої не можуть підтримувати постійні відкриті сокетні з'єднання з бекендом кожного окремого додатку без швидкого розряджання акумулятора та надмірного споживання мережевого трафіку, Apple підтримує одне постійне енергоефективне з'єднання між кожним пристроєм та APNs. Коли ваш додаток реєструється для отримання віддалених сповіщень, APNs генерує унікальний непрозорий токен пристрою (device token), що ідентифікує конкретне встановлення додатку на конкретному пристрої. Додаток надсилає цей токен на ваш бекенд-сервер. Коли провайдер має надіслати сповіщення користувачеві, він формує корисне навантаження (payload) і передає його разом із токеном пристрою до APNs через протокол HTTP/2. Потім APNs знаходить активне з'єднання для цього пристрою та доставляє корисне навантаження push-сповіщення безпосередньо в iOS, яка відображає сповіщення або пробуджує додаток відповідно до конфігурації.

[Provider Server] ---> (HTTP/2 Request + Device Token + Payload) ---> [APNs]
                                                                           |
                                                               (Persistent APNs Connection)
                                                                           v
                                                                    [iOS Device / App]
Відповісти на це запитання з ШІ-тренером

Запитання для тих, хто продовжує

11Як би ви провели рефакторинг великого UIViewController (User Interface View Controller), який відповідає за оновлення UI (User Interface), валідацію, роботу з мережею та навігацію?

Рефакторинг масивного контролера подання (Massive View Controller, MVC) слід виконувати поетапно, щоб мінімізувати ризики та розділити різні обов'язки між окремими рівнями: 1. Відокремлення роботи з мережею та даними: Винесіть виклики API (Application Programming Interface), збереження даних та парсинг JSON за межі контролера подання у виділені класи служб (Service) або репозиторіїв (Repository) з протокольними абстракціями. 2. Відокремлення стану представлення та валідації: Впровадьте ViewModel (або Presenter). Перенесіть валідацію вхідних даних, форматування рядків та керування станом інтерфейсу у ViewModel, зробивши цю логіку придатною для ізольованого модульного тестування (unit testing). 3. Відокремлення навігації: Застосуйте патерн Coordinator (або Router / Flow Controller), щоб видалити виклики push/present і логіку переходів між екранами з контролера подання. 4. Збереження легкості контролера подання: Залиште в ньому лише розмітку інтерфейсу, конфігурацію дочірніх подань (subviews), хуки життєвого циклу та зв'язування елементів керування UI з ViewModel. 5. Інкрементальне виконання з тестами: Додавайте модульні тести для щойно виділених служб і ViewModel під час рефакторингу, щоб гарантувати збереження наявної поведінки.

// 1. Extracted Navigation
protocol LoginCoordinatorProtocol: AnyObject {
    func showHomeScreen()
}

// 2. Extracted Business Logic & State
final class LoginViewModel {
    private let authService: AuthServiceProtocol
    private weak var coordinator: LoginCoordinatorProtocol?
    
    init(authService: AuthServiceProtocol, coordinator: LoginCoordinatorProtocol) {
        self.authService = authService
        self.coordinator = coordinator
    }
    
    func login(email: String, password: String) {
        guard email.contains("@") else { return }
        authService.login(email: email, password: password) { [weak self] result in
            if case .success = result { self?.coordinator?.showHomeScreen() }
        }
    }
}

// 3. Lean UIViewController: Only handles UI and bindings
final class LoginViewController: UIViewController {
    private let viewModel: LoginViewModel
    init(viewModel: LoginViewModel) { self.viewModel = viewModel; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
}
Відповісти на це запитання з ШІ-тренером

12Як би ви діагностували та усували помилку вивантаження архіву iOS, спричинену проблемами з підписуванням коду (signing) або профілями забезпечення (provisioning)?

Діагностика помилки завантаження архіву iOS через проблеми з підписуванням або профілями забезпечення охоплює перевірку трьох основних компонентів: сертифікатів дистрибуції, профілів забезпечення (provisioning profiles) та дозволів цільового проєкту (target entitlements). 1. Аналіз діагностичних журналів: Перевірте журнали дистрибуції Xcode Organizer, детальні помилки валідації під час завантаження або консольні журнали CI (Continuous Integration) (`altool`/`notarytool`), щоб з'ясувати точну причину відхилення. 2. Перевірка сертифіката та цифрового посвідчення (Identity): Переконайтеся, що дійсний сертифікат Apple Distribution присутній у Keychain разом із відповідним приватним ключем, і що термін його дії не закінчився, він не був відкликаний та має встановлений проміжний сертифікат Apple WWDR (Worldwide Developer Relations). 3. Відповідність профілю забезпечення: Переконайтеся, що для експорту використовується профіль App Store Distribution, який точно відповідає Bundle Identifier. Якщо застосовуються такі можливості (capabilities), як Push Notifications або Associated Domains, перевірте наявність явно вказаного App ID замість несумісного символу підстановки (wildcard). 4. Узгодження прав доступу (Entitlements): Перевірте відсутність розбіжностей між файлом `.entitlements` вашої цілі збірки (target) та можливостями, активованими для App ID на порталі Apple Developer. Якщо локальні entitlements запитують дозволи, не зареєстровані у профілі на порталі, вивантаження зазнає невдачі. 5. Конфігурація підписування: Якщо використовується автоматичне підписування, перевірте вибрану команду (Team) та облікові дані Apple ID у налаштуваннях Xcode. Якщо використовується ручне підписування (або параметри експорту в CI), переконайтеся, що файл `ExportOptions.plist` зіставляє кожен bundle ID з правильним сертифікатом дистрибуції та профілем.

# Decode the provisioning profile embedded in the archive
security cms -D -i MyApp.xcarchive/Products/Applications/MyApp.app/embedded.mobileprovision > profile.plist

# Read entitlements embedded in the profile
/usr/libexec/PlistBuddy -c "Print :Entitlements" profile.plist

# Inspect signature and entitlements on the built binary
codesign -d --entitlements :- MyApp.xcarchive/Products/Applications/MyApp.app
Відповісти на це запитання з ШІ-тренером

13Як би ви обирали між BGAppRefreshTask, BGProcessingTask та фоновим URLSession (Uniform Resource Locator Session) для реалізації фонової синхронізації у продакшені?

Вибір правильного фонового API залежить від тривалості завдання, необхідності наявності живлення/мережі та характеру мережевого корисного навантаження: 1. **BGAppRefreshTask**: Найкраще підходить для коротких і легких оновлень контенту (таких як оновлення стрічок новин, соціальних мереж або панелей моніторингу користувача), які тривають приблизно 15–30 секунд. Система планує їх на основі патернів використання застосунку користувачем, щоб свіжий контент був готовий безпосередньо перед тим, як користувач зазвичай відкриває застосунок. 2. **BGProcessingTask**: Створено для тривалих, нетермінових, ресурсомістких операцій, таких як індексація даних, очищення/міграція баз даних, навчання ML-моделей або синхронізація великих обсягів даних. Воно може виконуватися кілька хвилин і вимагати підключення пристрою до заряджання (`requiresExternalPower = true`) та з'єднання з мережею/Wi-Fi (`requiresNetworkConnectivity = true`), зазвичай запускаючись уночі. 3. **Фоновий URLSession (`URLSessionConfiguration.background`)**: Правильний вибір під час передавання великих файлів (вивантаження фото/відео або завантаження великих наборів ресурсів), яке має продовжуватися поза межами процесу застосунку, навіть якщо застосунок призупинено або завершено операційною системою. Він делегує передавання даних системному демону `nsurlsessiond`, не утримуючи код застосунку активним у пам'яті.

import BackgroundTasks

func scheduleDatabaseMaintenance() {
    let request = BGProcessingTaskRequest(identifier: "com.app.db_cleanup")
    request.requiresNetworkConnectivity = false
    request.requiresExternalPower = true // Run overnight on charger
    request.earliestBeginDate = Date(timeIntervalSinceNow: 6 * 3600)
    
    try? BGTaskScheduler.shared.submit(request)
}

func scheduleTimelineRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.feed_refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
    
    try? BGTaskScheduler.shared.submit(request)
}
Відповісти на це запитання з ШІ-тренером

14Як би ви побудували конвеєр Combine для текстового поля пошуку, який усуває брязкіт (debounce), ігнорує дублікати, виконує мережевий запит і безпечно оновлює UI (User Interface)?

Щоб побудувати безпечний конвеєр пошуку в Combine: 1. **Усунення брязкоту та дедуплікація (Debounce & Deduplicate):** Візьміть видавця пошукового запиту (наприклад, `$queryText`), застосуйте `.debounce(for: .milliseconds(300), scheduler: RunLoop.main)`, щоб дочекатися паузи у введенні тексту, та `.removeDuplicates()`, щоб ігнорувати незмінені рядки запиту. 2. **Мережевий запит та скасування:** Відобразіть кожен запит у видавця мережевого запиту та розгорніть за допомогою `.switchToLatest()`. Це автоматично скасовує поточні активні запити, якщо надходить новий пошуковий запит, запобігаючи стану гонитви та застарілим результатам. 3. **Ізоляція помилок:** Перехоплюйте мережеві помилки всередині внутрішнього видавця (наприклад, `.catch { _ in Just([]) }`), щоб збої мережі не переривали зовнішній потік тексту пошуку. 4. **Доставка в головний потік:** Застосуйте `.receive(on: DispatchQueue.main)` перед оновленням стану або призначенням значень властивостям UI.

import Combine
import Foundation

class SearchViewModel: ObservableObject {
    @Published var query: String = ""
    @Published var searchResults: [String] = []
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .map { query -> AnyPublisher<[String], Never> in
                guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
                    return Just([]).eraseToAnyPublisher()
                }
                return self.performSearch(query)
                    .catch { _ in Just([]) }
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .receive(on: DispatchQueue.main)
            .assign(to: &$searchResults)
    }
    
    private func performSearch(_ term: String) -> AnyPublisher<[String], Error> {
        let url = URL(string: "https://api.example.com/search?q=\(term)")!
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: [String].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}
Відповісти на це запитання з ШІ-тренером

15Як би ви використали Swift async/await та скасування Task для реалізації екрана, який завантажує дані з мережі та запобігає оновленню UI (User Interface) після того, як користувач залишив екран?

Я б реалізував мережеве завантаження як функцію `async` у сервісі або моделі представлення (view model) і запускав її через `Task`, час життя якого прив'язаний до екрана. В UIKit це зазвичай означає збереження чогось на зразок `var loadTask: Task<Void, Never>?` у контролері представлення (view controller) або view model, запуск завдання тоді, коли екран має завантажитися, і його скасування у `viewWillDisappear`, `deinit` або перед початком новішого запиту залежно від бажаного часу життя. У SwiftUI я б надав перевагу `.task` або `.task(id:)`, коли це можливо, оскільки SwiftUI автоматично скасовує завдання, коли представлення зникає або змінюється `id`. Усередині завдання я б викликав асинхронний мережевий API за допомогою `try await`, обробляв скасування окремо від звичайних помилок і перевіряв статус скасування перед застосуванням результатів, якщо є кілька очікувань (awaits) або кроків обробки. Зміни стану UI мають відбуватися на головному акторі: або через позначення view model атрибутом `@MainActor`, або за допомогою `await MainActor.run`. Щоб уникнути застарілого UI, я б скасовував попередні завдання, уникав відокремлених/глобальних завдань (detached/global tasks) для дій, прив'язаних до екрана, і за потреби порівнював ідентифікатор запиту або поточного елемента перед застосуванням результату.

@MainActor
final class UsersViewController: UIViewController {
    private var loadTask: Task<Void, Never>?
    private let service: UserService

    override func viewDidLoad() {
        super.viewDidLoad()
        loadUsers()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        loadTask?.cancel()
    }

    private func loadUsers() {
        loadTask?.cancel()
        loadingView.isHidden = false

        loadTask = Task { [weak self] in
            do {
                let users = try await self?.service.fetchUsers() ?? []
                try Task.checkCancellation()

                guard let self else { return }
                self.loadingView.isHidden = true
                self.tableModel = users
                self.tableView.reloadData()
            } catch is CancellationError {
                // User navigated away or a new load replaced this one; do not update UI.
            } catch {
                guard let self else { return }
                self.loadingView.isHidden = true
                self.showError(error)
            }
        }
    }
}
Відповісти на це запитання з ШІ-тренером

16Як би ви обирали між UserDefaults, Keychain, файлами, SQLite та Core Data для різних типів даних програми?

Вибір відповідного механізму зберігання даних в iOS залежить від чутливості даних, їхнього розміру, реляційної складності, вимог до запитів і життєвого циклу резервного копіювання: 1. **Keychain**: для конфіденційних даних (токени автентифікації, паролі, ключі шифрування, біометричні секрети). Забезпечує апаратне шифрування, зберігається між перевстановленнями програми та залишається захищеним навіть у скомпрометованих середовищах пісочниці (sandbox). 2. **UserDefaults**: для легких, нечутливих налаштувань і прапорців (наприклад, `hasCompletedOnboarding`, обрана тема). Оскільки дані повністю завантажуються в пам'ять як список властивостей (property list), цей механізм не слід використовувати для великих наборів даних, медіафайлів або конфіденційних токенів. 3. **Файлова система (Documents / Caches / Application Support)**: для окремих файлів і великих бінарних об'єктів (завантажені зображення, аудіо, PDF). Каталог `Documents` орієнтований на користувача та копіюється в iCloud; `Caches` призначений для контенту, який можна очистити й завантажити повторно; `Application Support` — для постійних файлів, невидимих користувачеві. 4. **SQLite**: для структурованих табличних даних, які потребують швидких індексованих запитів, масових операцій або кросплатформового спільного використання бази даних без накладних витрат графа об'єктів. 5. **Core Data / SwiftData**: для складних графів об'єктів зі зв'язками, відстеженням змін, відкладеним завантаженням (faulting), керуванням скасуванням дій (undo) та прямою інтеграцією з UIKit (`NSFetchedResultsController`) або SwiftUI (`@Query`).

- Auth tokens / API keys          -> Keychain (Hardware-backed encrypted store)
- App settings / UI flags         -> UserDefaults (Lightweight key-value, non-sensitive)
- Downloaded videos / PDFs        -> File System (Documents / Caches directory)
- 50k items with complex queries   -> SQLite / Core Data / SwiftData (Indexed, relationship-aware)
Відповісти на це запитання з ШІ-тренером

17На робочому екрані в UIKit зі зворотними викликами (callbacks) від моделі представлення (view model), як ви вирішуєте, чи захоплювати `self` як `weak`, `unowned` або `strong` в обробниках замикань?

В архітектурі UIKit, де контролер представлення (view controller) взаємодіє з моделлю представлення (view model) через зворотні виклики (callbacks), стратегія захоплення визначається володінням та часом життя: 1. `[weak self]`: Стандартний і найбезпечніший підхід для збережених замикань або замикань, що виходять за межі контексту виконання (escaping closures), таких як зворотні виклики подій view model або асинхронні обробники завершення мережевих/даних операцій. Оскільки view controller володіє view model, сильне захоплення `self` у зворотному виклику, який зберігається у view model, створює цикл сильних посилань (`VC -> VM -> closure -> VC`). `[weak self]` перетворює `self` на опціональний тип (`UIViewController?`), дозволяючи `self` коректно звільнятися з пам'яті та запобігаючи витокам пам'яті. 2. `[unowned self]`: Передбачає, що `self` ніколи не набуде значення `nil` під час виконання замикання. Цей варіант слід використовувати з крайньою обережністю на екранах інтерфейсу. Якщо асинхронна задача або зворотний виклик завершується після того, як view controller було закрито та видалено з пам'яті, звернення до `unowned self` призведе до аварійного завершення програми (fatal runtime crash). З цієї причини в робочому коді на UIKit для UI-викликів зазвичай надають перевагу `[weak self]`, а не `unowned`. 3. **Сильне захоплення (strong capture за замовчуванням, без списку захоплення):** Доцільне тоді, коли замикання не виходить за межі контексту виконання (non-escaping, наприклад, стандартні операції над колекціями, як-от `map`/`filter`), або коли замикання є короткоживучим і не створює циклу володіння, що замикається на `self`.

final class ProfileViewController: UIViewController {
    private let viewModel: ProfileViewModel

    init(viewModel: ProfileViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // VM stores the callback -> capture weak self to break retain cycle
        viewModel.onDataUpdated = { [weak self] state in
            guard let self else { return }
            self.updateUI(with: state)
        }
    }

    private func updateUI(with state: ProfileState) { /* Update views */ }
}
Відповісти на це запитання з ШІ-тренером

Запитання для досвідчених

18Ви долучаєтеся до зрілого iOS-застосунку із сотнями екранів, великою кількістю надмірно перевантажених контролерів (massive view controllers) і повільними циклами релізів. Як би ви спланували стратегію поступового поліпшення архітектури без зупинки розробки нової функціональності?

Ефективна стратегія інкрементальної міграції уникає повного переписування коду з нуля та поєднує рефакторинг безпосередньо з поточною розробкою функціональності (за патерном «Душитель» / Strangler Fig). Роботу розпочинають зі створення узгодженого цільового архітектурного шаблону (наприклад, MVVM або VIPER із впровадженням залежностей і модульними координаторами) та покриття застарілого коду базовими тестами (характеризаційні, знімкові та модульні тести) перед тим, як вносити зміни. Пріоритетність має визначатися частотою змін (feature churn) та рівнем ризику: рефакторити слід ті екрани, які команди активно модифікують, або ділянки з високою частотою виникнення дефектів, а не стабільний успадкований код. При рефакторингу Massive View Controllers необхідно виносити бізнес-логіку та логіку представлення у виділені ViewModels/Presenters і впроваджувати координатори або адаптери на основі протоколів для ізоляції застарілого UI від нового коду. Насамкінець темп розробки та якість підтримуються за допомогою архітектурного контролю: надання еталонних реалізацій (golden sample), контролю меж через лінтери в CI або правила для pull request, а також виділення постійного резерву часу на технічний борг (наприклад, 15–20% часу або суміщення рефакторингу з пов'язаними задачами) з відстеженням метрик, таких як час збірки, показники падінь застосунку та покриття тестами.

protocol ProfileDisplaying: AnyObject {
    func updateProfile(name: String, avatarUrl: URL?)
}

extension LegacyProfileViewController: ProfileDisplaying {
    func updateProfile(name: String, avatarUrl: URL?) {
        self.nameLabel.text = name
    }
}

final class ProfilePresenter {
    private weak var view: ProfileDisplaying?
    private let userUseCase: FetchUserUseCaseProtocol
    
    init(view: ProfileDisplaying, userUseCase: FetchUserUseCaseProtocol) {
        self.view = view
        self.userUseCase = userUseCase
    }
    
    func onViewLoaded() async {
        guard let user = try? await userUseCase.execute() else { return }
        await MainActor.run {
            view?.updateProfile(name: user.name, avatarUrl: user.avatarURL)
        }
    }
}
Відповісти на це запитання з ШІ-тренером

19Ви випускаєте високонавантажений застосунок для iOS із міграцією бази даних та зміною бекенд-API (Application Programming Interface); як би ви спроєктували план розгортання, щоб мінімізувати вплив на користувачів?

Проєктування плану розгортання для високонавантаженого iOS-застосунку з міграціями бази даних і змінами бекенд-API вимагає розділення процесу розгортання та активації функціональності через обмеження клієнтів iOS (асинхронне оновлення користувачами та неможливість примусового відкату бінарного файлу на стороні клієнта). 1. Сумісність бекенду: застосуйте стратегію розширення та звуження (expand-and-contract). Розгорніть API v2 паралельно з API v1, щоб як застарілі, так і оновлені версії клієнта працювали одночасно без порушення сумісності. 2. Стійка міграція бази даних: переконайтеся, що локальні міграції схеми (наприклад, SQLite, Core Data, SwiftData) є ідемпотентними, неруйнівними та безпечно обробляють переходи через декілька версій (наприклад, оновлення з N-3 до N) без блокування головного потоку і без виникнення циклічних аварійних завершень під час запуску. 3. Динамічне керування доступом: випускайте новий функціонал клієнта у прихованому режимі за керованими сервером прапорцями функцій (feature flags) або віддаленою конфігурацією. Залишайте прапорець функції вимкненим під час початкового поширення. 4. Поетапне розгортання та моніторинг: розповсюджуйте застосунок за допомогою поетапного випуску в App Store (App Store Phased Release — 7-денне поетапне розгортання). Безперервно відстежуйте телеметрію, частоту збоїв, метрики успішності міграції бази даних і рівень помилок API. У разі виникнення аномалій призупиніть поетапне розгортання в App Store Connect та вимкніть прапорці функцій без необхідності екстреного відкату бінарного файлу.

Phase 1 (Backend Expand): Deploy API v2 supporting both new and legacy payloads.
Phase 2 (Client Distribution & Migration): Release client v2.0 via App Store Phased Release (1% -> 100%). DB migrates safely on first launch. Feature flag remains OFF.
Phase 3 (Validation & Feature Enablement): Monitor crash rates & migration success. Incrementally ramp Remote Config flag (10% -> 50% -> 100%).
Phase 4 (Backend Contract): Once legacy app adoption drops below deprecation threshold, sunset API v1.
Відповісти на це запитання з ШІ-тренером

20Ви проєктуєте синхронізацію за принципом offline-first для застосунку нотаток, де зміни мають згодом синхронізуватися між пристроями, але iOS може відкласти або скасувати фонову роботу. Яку архітектуру виконання у фоновому режимі ви оберете?

Архітектура синхронізації offline-first розглядає локальну базу даних (наприклад, SQLite, Core Data або SwiftData) як безпосереднє єдине джерело істини для стану користувацького інтерфейсу, тоді як зміни записуються в персистентну чергу вихідних повідомлень (outbox) на диску, щоб невідправлені редагування зберігалися в разі завершення процесу. Для керування опортуністичним і недетермінованим виконанням у фоновому режимі в iOS фонова робота структурується на кількох рівнях: 1. Очищення черги вихідних повідомлень під час переходу у фоновий режим: ініціюється через `UIApplication.shared.beginBackgroundTask(expirationHandler:)` для завершення активних або швидких очікуваних змін. 2. Запланована періодична синхронізація: реєструється в `BGTaskScheduler` з використанням `BGAppRefreshTask` для синхронізації легких метаданих або дельт і `BGProcessingTask` для важчих операцій синхронізації. 3. Синхронізація великих ресурсів: обробляється поза процесом за допомогою фонової конфігурації `URLSessionConfiguration` через завантаження та вивантаження файлів. 4. Синхронізація, ініційована сервером: опортуністичні пробудження за допомогою безшумних push-сповіщень (`content-available: 1`). Оскільки фонові завдання можуть бути перервані або перенесені в будь-який момент, операції мають використовувати згенеровані клієнтом ключі ідемпотентності (наприклад, UUID), що дозволяє безпечно повторювати спроби без дублювання даних. Якщо викликається `expirationHandler` завдання, поточна робота має бути негайно скасована, незафіксовані операції повернені до статусу очікування, а також викликано `setTaskCompleted(success: false)`. Узгодженість у кінцевому підсумку (eventual consistency) підтримується механізмами розв'язання конфліктів, такими як CRDT, вектори версій або стратегія останнього запису (last-write-wins) з маркерами видалення (tombstones), що гарантує, що віддалені дельти не перезапишуть незафіксовані локальні зміни з черги вихідних повідомлень.

import BackgroundTasks
import Foundation

final class NoteSyncCoordinator {
    static let shared = NoteSyncCoordinator()
    private let syncTaskID = "com.notesapp.sync.refresh"

    func registerSyncTask() {
        BGTaskScheduler.shared.register(forTaskWithIdentifier: syncTaskID, using: nil) { task in
            guard let refreshTask = task as? BGAppRefreshTask else { return }
            self.handleAppRefresh(task: refreshTask)
        }
    }

    func scheduleNextSync() {
        let request = BGAppRefreshTaskRequest(identifier: syncTaskID)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
        try? BGTaskScheduler.shared.submit(request)
    }

    private func handleAppRefresh(task: BGAppRefreshTask) {
        scheduleNextSync()
        let syncTask = Task {
            do {
                try await SyncEngine.shared.flushPersistentOutboxAndFetchDeltas()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }

        task.expirationHandler = {
            syncTask.cancel()
        }
    }
}
Відповісти на це запитання з ШІ-тренером