Співбесіда iOS Senior 2026: Питання щодо Архітектури та Шаблонів Проєктування
Підготовка до співбесід iOS senior із ключовими питаннями про MVVM, VIPER, Clean Architecture та шаблони проєктування. Повний посібник із прикладами Swift.

Співбесіди iOS senior приділяють велику увагу архітектурі та шаблонам проєктування. Окрім синтаксису Swift, інтерв'юери оцінюють здатність проєктувати застосунки, які легко підтримувати, тестувати та масштабувати.
Цей посібник охоплює найчастіші питання про MVVM, VIPER, Clean Architecture й основні шаблони з детальними відповідями та готовими до співбесіди прикладами коду.
На senior-співбесідах технічна відповідь важить менше, ніж міркування. Завжди слід пояснювати, чому та чи інша архітектура підходить до конкретного контексту, а не лише як її реалізувати.
Розуміння iOS-архітектур: загальний огляд
Перш ніж переходити до конкретних питань, важливо зрозуміти ландшафт iOS-архітектур. Кожен шаблон вирішує різні задачі та підходить до різних контекстів.
MVC: історичний шаблон Apple
MVC (Model-View-Controller) залишається стандартним шаблоном Apple, проте у складних застосунках страждає від проблеми "Massive View Controllers".
// Typical MVC example showing its limitations
class UserViewController: UIViewController {
// The ViewController accumulates too many responsibilities
private var users: [User] = []
override func viewDidLoad() {
super.viewDidLoad()
// View logic
setupUI()
// Business logic
fetchUsers()
// Navigation logic
setupNavigationBar()
}
private func fetchUsers() {
// Networking in the VC: anti-pattern
URLSession.shared.dataTask(with: URL(string: "api/users")!) { data, _, _ in
// JSON parsing here too...
}.resume()
}
}Цей шаблон стає проблематичним, коли ViewController перевищує 500 рядків, що робить юніт-тести майже неможливими.
Питання 1: Поясніть MVVM та його реалізацію в Swift
MVVM (Model-View-ViewModel) виділяє логіку представлення у ViewModel, спрощуючи тестування та зменшуючи розмір ViewController.
MVVM сяє у SwiftUI завдяки @Observable та нативному data binding. У UIKit потрібен механізм binding (Combine, closures).
Реалізація MVVM з Combine
// ViewModel separated from any UIKit dependency
import Combine
@MainActor
final class UserViewModel: ObservableObject {
// Published states for binding
@Published private(set) var users: [User] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
// Injected dependency for testability
private let userRepository: UserRepositoryProtocol
private var cancellables = Set<AnyCancellable>()
init(userRepository: UserRepositoryProtocol = UserRepository()) {
self.userRepository = userRepository
}
func loadUsers() {
isLoading = true
errorMessage = nil
userRepository.fetchUsers()
.receive(on: DispatchQueue.main)
.sink { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
} receiveValue: { [weak self] users in
self?.users = users
}
.store(in: &cancellables)
}
}View з Combine binding
// SwiftUI View consuming the ViewModel
import SwiftUI
struct UserListView: View {
// StateObject for lifecycle management
@StateObject private var viewModel = UserViewModel()
var body: some View {
Group {
if viewModel.isLoading {
ProgressView("Loading...")
} else if let error = viewModel.errorMessage {
ErrorView(message: error, retry: viewModel.loadUsers)
} else {
List(viewModel.users) { user in
UserRowView(user: user)
}
}
}
.onAppear { viewModel.loadUsers() }
}
}ViewModel не знає ані UIKit, ані SwiftUI, тож повністю придатний до юніт-тестування.
Питання 2: Коли обирати VIPER замість MVVM?
VIPER (View-Interactor-Presenter-Entity-Router) підходить для складних застосунків, які потребують суворого розділення відповідальностей і складної навігації.
Повна структура VIPER
// Contract definitions between VIPER components
protocol UserListViewProtocol: AnyObject {
var presenter: UserListPresenterProtocol? { get set }
func showUsers(_ users: [UserViewModel])
func showError(_ message: String)
func showLoading()
}
protocol UserListPresenterProtocol: AnyObject {
var view: UserListViewProtocol? { get set }
var interactor: UserListInteractorInputProtocol? { get set }
var router: UserListRouterProtocol? { get set }
func viewDidLoad()
func didSelectUser(_ user: UserViewModel)
}
protocol UserListInteractorInputProtocol: AnyObject {
var presenter: UserListInteractorOutputProtocol? { get set }
func fetchUsers()
}
protocol UserListInteractorOutputProtocol: AnyObject {
func didFetchUsers(_ users: [User])
func didFailWithError(_ error: Error)
}
protocol UserListRouterProtocol: AnyObject {
func navigateToUserDetail(with userId: String)
}Presenter оркеструє логіку
// The Presenter bridges View and Interactor
final class UserListPresenter: UserListPresenterProtocol {
weak var view: UserListViewProtocol?
var interactor: UserListInteractorInputProtocol?
var router: UserListRouterProtocol?
func viewDidLoad() {
view?.showLoading()
interactor?.fetchUsers()
}
func didSelectUser(_ user: UserViewModel) {
router?.navigateToUserDetail(with: user.id)
}
}
// Extension for Interactor callbacks
extension UserListPresenter: UserListInteractorOutputProtocol {
func didFetchUsers(_ users: [User]) {
// Model -> ViewModel transformation
let viewModels = users.map { UserViewModel(from: $0) }
view?.showUsers(viewModels)
}
func didFailWithError(_ error: Error) {
view?.showError(error.localizedDescription)
}
}VIPER додає чималий boilerplate. Його застосування слід обґрунтовувати розміром команди (декілька розробників на один модуль) або складністю бізнес-домену.
Питання 3: Як реалізувати Clean Architecture в iOS?
Clean Architecture впорядковує код у концентричні кола, де бізнес-правила розташовані в центрі та незалежні від фреймворків.
Структура шарів
// Pure business entity, no framework dependencies
struct User: Identifiable, Equatable {
let id: String
let email: String
let fullName: String
let subscriptionLevel: SubscriptionLevel
enum SubscriptionLevel: String {
case free, premium, enterprise
}
}
// Domain/UseCases/GetUsersUseCase.swift
// Use Case encapsulating a business rule
protocol GetUsersUseCaseProtocol {
func execute() async throws -> [User]
}
final class GetUsersUseCase: GetUsersUseCaseProtocol {
// Dependency on abstraction, not implementation
private let repository: UserRepositoryProtocol
init(repository: UserRepositoryProtocol) {
self.repository = repository
}
func execute() async throws -> [User] {
let users = try await repository.fetchAll()
// Business rule: sort by subscription level
return users.sorted { $0.subscriptionLevel.rawValue > $1.subscriptionLevel.rawValue }
}
}Шар даних із шаблоном Repository
// Concrete repository implementation
final class UserRepository: UserRepositoryProtocol {
private let remoteDataSource: UserRemoteDataSourceProtocol
private let localDataSource: UserLocalDataSourceProtocol
init(
remoteDataSource: UserRemoteDataSourceProtocol = UserRemoteDataSource(),
localDataSource: UserLocalDataSourceProtocol = UserLocalDataSource()
) {
self.remoteDataSource = remoteDataSource
self.localDataSource = localDataSource
}
func fetchAll() async throws -> [User] {
do {
// Cache-first strategy with fallback
let remoteUsers = try await remoteDataSource.fetchUsers()
await localDataSource.save(remoteUsers)
return remoteUsers
} catch {
// Fallback to local cache
return try await localDataSource.fetchUsers()
}
}
}Такий розподіл дозволяє тестувати кожен шар незалежно й змінювати реалізації (наприклад, мігрувати з CoreData на SwiftData), не торкаючись домену.
Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Питання 4: Які шаблони проєктування ви використовуєте щодня?
Інтерв'юери очікують практичного володіння шаблонами, а не теоретичного декламування. Ось найпоширеніші в iOS.
Dependency Injection із Property Wrappers
// Simple and effective injection container
final class DIContainer {
static let shared = DIContainer()
private var factories: [String: () -> Any] = [:]
func register<T>(_ type: T.Type, factory: @escaping () -> T) {
let key = String(describing: type)
factories[key] = factory
}
func resolve<T>(_ type: T.Type) -> T {
let key = String(describing: type)
guard let factory = factories[key], let instance = factory() as? T else {
fatalError("No registration for \(key)")
}
return instance
}
}
// Property Wrapper for elegant injection
@propertyWrapper
struct Injected<T> {
private var value: T
init() {
self.value = DIContainer.shared.resolve(T.self)
}
var wrappedValue: T {
get { value }
mutating set { value = newValue }
}
}
// Usage in a ViewModel
final class PaymentViewModel {
@Injected private var paymentService: PaymentServiceProtocol
@Injected private var analyticsService: AnalyticsServiceProtocol
func processPayment(_ amount: Decimal) async throws {
analyticsService.track(.paymentInitiated(amount: amount))
try await paymentService.charge(amount)
}
}Шаблон Coordinator для навігації
// Coordinator managing navigation flow
protocol Coordinator: AnyObject {
var childCoordinators: [Coordinator] { get set }
var navigationController: UINavigationController { get }
func start()
}
final class AppCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
let navigationController: UINavigationController
private let window: UIWindow
init(window: UIWindow) {
self.window = window
self.navigationController = UINavigationController()
}
func start() {
window.rootViewController = navigationController
window.makeKeyAndVisible()
// Flow decision based on state
if AuthManager.shared.isAuthenticated {
showMainFlow()
} else {
showAuthFlow()
}
}
private func showAuthFlow() {
let authCoordinator = AuthCoordinator(navigationController: navigationController)
authCoordinator.delegate = self
childCoordinators.append(authCoordinator)
authCoordinator.start()
}
private func showMainFlow() {
let mainCoordinator = MainCoordinator(navigationController: navigationController)
childCoordinators.append(mainCoordinator)
mainCoordinator.start()
}
}Цей шаблон виносить логіку навігації за межі ViewController, роблячи їх легшими та більш повторно використовуваними.
Питання 5: Як організувати комунікацію між модулями?
Комунікація між модулями має критичне значення у великих застосунках. Існує кілька підходів залежно від бажаного зв'язування.
Комунікація на основі протоколів
// Public contracts exposed by each module
protocol PaymentModuleProtocol {
func startPaymentFlow(for productId: String, completion: @escaping (Result<Receipt, PaymentError>) -> Void)
}
protocol UserModuleProtocol {
func getCurrentUser() -> User?
func updateProfile(_ profile: ProfileUpdate) async throws
}
// Modules/Payment/PaymentModule.swift
// Internal module implementation
final class PaymentModule: PaymentModuleProtocol {
static let shared: PaymentModuleProtocol = PaymentModule()
private let paymentService: PaymentService
private init() {
self.paymentService = PaymentService()
}
func startPaymentFlow(for productId: String, completion: @escaping (Result<Receipt, PaymentError>) -> Void) {
// Module-internal logic
paymentService.process(productId: productId, completion: completion)
}
}Подієва комунікація з Combine
// Decoupled event bus for async communication
enum AppEvent {
case userDidLogin(User)
case userDidLogout
case purchaseCompleted(Receipt)
case subscriptionChanged(SubscriptionLevel)
}
final class AppEventBus {
static let shared = AppEventBus()
// Private Subject, public Publisher
private let eventSubject = PassthroughSubject<AppEvent, Never>()
var events: AnyPublisher<AppEvent, Never> {
eventSubject.eraseToAnyPublisher()
}
func send(_ event: AppEvent) {
eventSubject.send(event)
}
}
// Listening in any module
final class AnalyticsModule {
private var cancellables = Set<AnyCancellable>()
init() {
AppEventBus.shared.events
.sink { [weak self] event in
self?.handleEvent(event)
}
.store(in: &cancellables)
}
private func handleEvent(_ event: AppEvent) {
switch event {
case .purchaseCompleted(let receipt):
trackPurchase(receipt)
case .userDidLogin(let user):
identifyUser(user)
default:
break
}
}
}Варто згадати, що вибір між тісним зв'язуванням (протоколи) та слабким зв'язуванням (події) залежить від контексту: події підходять для глобальних сповіщень, протоколи — для прямих взаємодій.
Питання 6: Як структурувати тести в модульній архітектурі?
Придатність до тестування — важливий критерій для senior-позицій. Гарна архітектура полегшує тести на всіх рівнях.
Юніт-тест ViewModel
// Unit tests with injected mocks
import XCTest
@testable import MyApp
final class UserViewModelTests: XCTestCase {
private var sut: UserViewModel!
private var mockRepository: MockUserRepository!
override func setUp() {
super.setUp()
mockRepository = MockUserRepository()
sut = UserViewModel(userRepository: mockRepository)
}
override func tearDown() {
sut = nil
mockRepository = nil
super.tearDown()
}
func test_loadUsers_success_updatesUsersArray() async {
// Given
let expectedUsers = [User.mock(), User.mock()]
mockRepository.stubbedUsers = expectedUsers
// When
await sut.loadUsers()
// Then
XCTAssertEqual(sut.users.count, 2)
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.errorMessage)
}
func test_loadUsers_failure_setsErrorMessage() async {
// Given
mockRepository.stubbedError = NetworkError.noConnection
// When
await sut.loadUsers()
// Then
XCTAssertTrue(sut.users.isEmpty)
XCTAssertNotNil(sut.errorMessage)
}
}
// Mocks/MockUserRepository.swift
final class MockUserRepository: UserRepositoryProtocol {
var stubbedUsers: [User] = []
var stubbedError: Error?
var fetchUsersCalled = false
func fetchUsers() -> AnyPublisher<[User], Error> {
fetchUsersCalled = true
if let error = stubbedError {
return Fail(error: error).eraseToAnyPublisher()
}
return Just(stubbedUsers)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}Інтеграційний тест Use Case
// Integration test verifying business logic
final class GetUsersUseCaseTests: XCTestCase {
func test_execute_sortsUsersBySubscriptionLevel() async throws {
// Given
let freeUser = User(id: "1", email: "free@test.com", fullName: "Free", subscriptionLevel: .free)
let premiumUser = User(id: "2", email: "premium@test.com", fullName: "Premium", subscriptionLevel: .premium)
let mockRepo = MockUserRepository()
mockRepo.stubbedUsers = [freeUser, premiumUser]
let sut = GetUsersUseCase(repository: mockRepo)
// When
let result = try await sut.execute()
// Then - Premium should be first
XCTAssertEqual(result.first?.subscriptionLevel, .premium)
XCTAssertEqual(result.last?.subscriptionLevel, .free)
}
}Питання 7: Як обробляти складні стани UI?
Керування станами критичне в senior-застосунках. Структурований підхід усуває баги та спрощує налагодження.
Машина станів з Enum
// State machine for payment flow
enum CheckoutState: Equatable {
case idle
case loadingCart
case cartLoaded(CartSummary)
case processingPayment
case paymentSucceeded(Receipt)
case paymentFailed(PaymentError)
var isLoading: Bool {
switch self {
case .loadingCart, .processingPayment: return true
default: return false
}
}
}
@MainActor
final class CheckoutViewModel: ObservableObject {
@Published private(set) var state: CheckoutState = .idle
private let cartService: CartServiceProtocol
private let paymentService: PaymentServiceProtocol
init(cartService: CartServiceProtocol, paymentService: PaymentServiceProtocol) {
self.cartService = cartService
self.paymentService = paymentService
}
func loadCart() async {
state = .loadingCart
do {
let summary = try await cartService.getSummary()
state = .cartLoaded(summary)
} catch {
state = .paymentFailed(.cartLoadFailed)
}
}
func confirmPayment() async {
guard case .cartLoaded(let summary) = state else { return }
state = .processingPayment
do {
let receipt = try await paymentService.charge(summary.total)
state = .paymentSucceeded(receipt)
} catch let error as PaymentError {
state = .paymentFailed(error)
} catch {
state = .paymentFailed(.unknown)
}
}
}Такий підхід унеможливлює суперечливі стани (наприклад isLoading = true із показаною помилкою).
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Висновок
Співбесіди iOS senior оцінюють здатність обрати й обґрунтувати архітектуру, що відповідає контексту. Ключові висновки:
Чекліст архітектури iOS Senior:
✅ MVVM для застосунків середнього розміру з SwiftUI або UIKit + Combine ✅ VIPER для великих команд і складних бізнес-доменів ✅ Clean Architecture для незалежності від фреймворку ✅ Систематичне Dependency Injection для тестованості ✅ Шаблон Coordinator для роз'єднання навігації ✅ Машини станів для складних потоків ✅ Тести на кожному рівні: юніт, інтеграційні, UI
На співбесідах необхідно показати:
- Розуміння компромісів (простий MVVM проти структурованого VIPER)
- Практичний досвід із прикладами реальних проєктів
- Здатність адаптувати архітектуру до контексту (розмір команди, складність)
- Володіння тестуванням як критерієм архітектурної якості
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

Співбесіда StoreKit 2: Управління Підписками та Валідація Чеків
Опануйте питання співбесіди iOS щодо StoreKit 2, управління підписками, валідації чеків та реалізації покупок у застосунку з практичними прикладами коду на Swift.

Swift Testing Framework Співбесіда 2026: Макроси #expect та #require проти XCTest
Опанування нового Swift Testing Framework для iOS-співбесід: макроси #expect і #require, міграція з XCTest, складні шаблони та поширені помилки.

Співбесіда iOS Push Notifications 2026: APNs, токени та troubleshooting
Повний посібник для підготовки до співбесід iOS з тем Push Notifications, APNs, керування токенами та troubleshooting. Поширені запитання з докладними відповідями.