20 często zadawanych pytań rekrutacyjnych dla Mobile Developer. Rozwój mobilny to poszukiwana dziedzina skupiona na niezawodnych aplikacjach iOS, Android i cross-platform, a także na zadaniach produktowych, wydajności, release'ach, trybie offline i integracji z urządzeniami. Pytania obejmują różne poziomy, a odpowiedzi możesz ćwiczyć na głos w naszym trenerze rozmów.
1Jaka jest różnica między przechowywaniem tokena uwierzytelniającego w standardowym magazynie klucz-wartość, takim jak SharedPreferences lub UserDefaults, a bezpiecznym magazynem platformowym, takim jak Android Keystore lub iOS Keychain?
Standardowe mechanizmy przechowywania danych, takie jak SharedPreferences w systemie Android i UserDefaults w systemie iOS, zostały zaprojektowane dla lekkich, niewrażliwych preferencji. Przechowują one dane w niezaszyfrowanych plikach tekstowych (XML lub plist) w katalogu piaskownicy aplikacji (sandbox). Każdy, kto uzyska fizyczny dostęp do zrootowanego lub odblokowanego (jailbroken) urządzenia, niezaszyfrowanej kopii zapasowej lub systemu plików, może bezpośrednio odczytać te tokeny. Z kolei bezpieczne magazyny platformowe — w szczególności iOS Keychain i Android Keystore (często wykorzystywany przez EncryptedSharedPreferences) — zapewniają szyfrowanie danych w spoczynku (data-at-rest). Magazyn iOS Keychain szyfruje zapisane elementy za pomocą kluczy powiązanych ze sprzętem urządzenia (takich jak Secure Enclave) i umożliwia precyzyjne definiowanie polityk dostępu. Android Keystore generuje i przechowuje klucze kryptograficzne w sprzętowo izolowanych modułach bezpieczeństwa (Trusted Execution Environment lub StrongBox), gwarantując, że klucze kryptograficzne nigdy nie są ujawniane w pamięci aplikacji i nie można ich wyodrębnić z systemu plików.
// --- 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)
2Jaki jest główny cel potoku CI/CD (Continuous Integration/Continuous Deployment) w aplikacjach mobilnych i jakie unikalne etapy oraz ograniczenia odróżniają go od potoków wdrożeniowych dla aplikacji webowych lub backendowych?
Głównym celem mobilnego potoku CI/CD jest automatyzacja budowania, testowania, podpisywania i dystrybucji mobilnych aplikacji klienckich w celu utrzymania spójnej jakości i powtarzalności wydań. Ciągła integracja (Continuous Integration, CI) weryfikuje zmiany w kodzie poprzez automatyczne kompilacje, analizę statyczną i testy jednostkowe. Ciągłe dostarczanie/wdrażanie (Continuous Delivery/Deployment, CD) pakuje, podpisuje i dystrybuuje pliki binarne do kanałów testowych (takich jak TestFlight lub Firebase App Distribution) lub sklepów z aplikacjami. Mobilne CI/CD różni się od potoków webowych i backendowych pod kilkoma kluczowymi względami: 1. Typ artefaktu: Kompilacje generują skompilowane pliki binarne klienta (.ipa, .apk, .aab), zamiast uruchamiać kod bezpośrednio na serwerach lub wewnątrz obrazów kontenerowych. 2. Wymagania sprzętowe środowiska wykonawczego (runnera): Kompilacja pod iOS wymaga sprzętu z systemem macOS i narzędzi wiersza poleceń Xcode, podczas gdy potoki backendowe zazwyczaj działają na lekkich kontenerach Linux. 3. Opóźnienie wydania i proces zatwierdzania: Publikacja dla użytkowników końcowych wiąże się z procesem weryfikacji przez zewnętrzne sklepy z aplikacjami (Apple App Store / Google Play Store), co oznacza, że wdrożenia nie można natychmiast wycofać (rollback) poprzez ponowne wdrożenie na serwerze. Poprawki wymagają przesłania nowej, podpisanej binarki lub użycia flag funkcji (feature flags) w czasie działania aplikacji.
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
3Jaki jest cel podziału aplikacji wieloplatformowej (cross-platform) na warstwę prezentacji, domeny i danych oraz w jaki sposób taka struktura ułatwia współdzielenie kodu między systemami iOS i Android?
Podział aplikacji na warstwy prezentacji, domeny i danych wyznacza wyraźne granice odpowiedzialności: 1. **Warstwa prezentacji (Presentation Layer):** Odpowiada za renderowanie interfejsu użytkownika (UI), obsługę danych wejściowych od użytkownika oraz stan na poziomie ekranu (np. widoki – Views, widżety – Widgets, ViewModels lub Presenters). 2. **Warstwa domeny (Domain Layer):** Enkapsuluje czystą logikę biznesową, modele domenowe/encji oraz przypadki użycia (use cases). Pozostaje niezależna od interfejsu użytkownika i frameworków platformowych. 3. **Warstwa danych (Data Layer):** Zarządza pobieraniem i utrwalaniem danych ze zdalnych interfejsów API (Application Programming Interface), lokalnych baz danych lub pamięci podręcznej urządzenia za pośrednictwem repozytoriów i źródeł danych. W wieloplatformowym programowaniu mobilnym taka struktura ułatwia współdzielenie kodu, ponieważ warstwy domeny i danych są niezależne od platformy. Podstawowe reguły biznesowe, transformacje danych i obsługa sieci mogą być w 100% współdzielone między iOS i Androidem, co pozwala zespołom na współdzielenie całego stosu technologicznego (jak w Flutterze czy React Native) lub współdzielenie logiki biznesowej i danych przy zachowaniu natywnych, specyficznych dla platformy warstw prezentacji (jak w Kotlin Multiplatform).
[Presentation Layer] (UI, Screens, ViewModels)
│
▼ calls
[Domain Layer] (Use Cases, Business Rules, Entities)
▲
│ implements interfaces
[Data Layer] (Repositories, API Clients, Local Storage)
4Jakie są podstawowe różnice między imperatywnym a deklaratywnym paradygmatem tworzenia interfejsu użytkownika UI (User Interface) w inżynierii aplikacji mobilnych i w jaki sposób widżety Fluttera oraz komponenty React Native realizują model deklaratywny?
W imperatywnym podejściu do tworzenia UI programiści piszą jawne instrukcje krok po kroku w celu tworzenia, modyfikacji i usuwania elementów interfejsu (np. wyszukiwanie widoku po ID i bezpośrednie wywoływanie metod takich jak setText czy setVisibility). Z kolei deklaratywne modele UI opisują, jak interfejs powinien wyglądać dla danego stanu (często wyrażane jako UI = f(state)). Gdy stan ulega zmianie, framework sam decyduje, jak efektywnie zaktualizować reprezentację wizualną. Zarówno Flutter, jak i React Native korzystają z tego deklaratywnego paradygmatu. We Flutterze widżety są niezmiennymi opisami konfiguracji; gdy dane dynamiczne się zmieniają, wywołanie setState() w StatefulWidget planuje przebudowę, w której metoda build() zwraca nowy opis drzewa widżetów, a Flutter porównuje go i uzgadnia z drzewami Element i RenderObject. W React Native komponenty są funkcjami lub klasami zwracającymi JSX; aktualizacja stanu lub właściwości (props) wyzwala ponowne renderowanie, w trakcie którego React uzgadnia wirtualne drzewo elementów i aplikuje minimalne niezbędne natywne aktualizacje przez most (bridge) lub natywne środowisko wykonawcze.
5Jaka jest różnica operacyjna między zimnym startem (cold start), ciepłym startem (warm start) a gorącym startem (hot start) w aplikacjach mobilnych i dlaczego zimny start jest najważniejszą metryką w budżetach wydajnościowych?
W cyklu życia aplikacji mobilnych stany uruchamiania różnią się w zależności od tego, czy proces aplikacji i jej stan w pamięci już istnieją w systemie operacyjnym (OS, Operating System). - Zimny start (Cold Start): Aplikacja uruchamia się od zera, ponieważ system nie utworzył jeszcze jej procesu lub proces został wcześniej zabity. System musi przydzielić nowy proces, załadować pliki binarne/środowisko uruchomieniowe, zainicjalizować kontekst aplikacji oraz wygenerować (inflate) pierwszy widok. Charakteryzuje się największym opóźnieniem. - Ciepły start (Warm Start): Proces aplikacji zazwyczaj znajduje się już w pamięci, ale hierarchia widoków/aktywność została zniszczona lub usunięta (np. z powodu odtwarzania w tle lub nawigacji wstecz). System odtwarza interfejs użytkownika/aktywność bez konieczności tworzenia nowego procesu od zera. - Gorący start (Hot Start): Aplikacja i stan jej interfejsu nadal w pełni rezydują w pamięci (np. użytkownik wcisnął przycisk Home i natychmiast wrócił do aplikacji). System jedynie przenosi istniejącą hierarchię widoków na pierwszy plan przy niemal zerowym narzucie inicjalizacji. Zimny start jest najbardziej krytyczną metryką w budżetach wydajnościowych, ponieważ stanowi najwolniejsze, pierwsze doświadczenie użytkownika. Długi czas zimnego startu bezpośrednio koreluje z natychmiastowym porzucaniem aplikacji przez użytkowników, niższą retencją oraz gorszymi pozycjami w sklepach z aplikacjami (np. w progach Android Vitals).
6Czym jest most natywny (native bridge) w wieloplatformowej aplikacji mobilnej i kiedy należy go użyć zamiast pisać daną funkcjonalność w całości w kodzie Flutter lub React Native?
Most natywny (native bridge) to warstwa komunikacji, która umożliwia kodowi wieloplatformowemu wywoływanie kodu specyficznego dla platformy iOS lub Android oraz pozwala kodowi natywnemu zwracać wyniki lub przesyłać zdarzenia z powrotem. We Flutterze realizuje się to powszechnie za pomocą kanałów platformy (platform channels) lub wtyczek (plugins). W React Native wykonuje się to zazwyczaj przy użyciu modułów natywnych (native modules), TurboModules lub natywnych komponentów UI (User Interface). Mostu używa się wtedy, gdy funkcjonalność wymaga czegoś, do czego czysty kod Fluttera lub React Native nie ma wystarczającego dostępu, na przykład API (Application Programming Interface) platformy, możliwości urządzenia, natywnego SDK (Software Development Kit), implementacji natywnej krytycznej pod względem wydajności lub natywnego komponentu interfejsu użytkownika. Typowe przykłady obejmują obsługę aparatu, Bluetooth, powiadomienia push, płatności, bezpieczny magazyn danych, usługi działające w tle, API zdrowotne lub pakiety SDK oferujące integrację wyłącznie w językach Swift/Objective-C lub Kotlin/Java. Dobry most zwykle udostępnia małe, przejrzyste API dla warstwy wieloplatformowej, takie jak getBatteryLevel, startBluetoothScan lub openNativePaymentSheet, podczas gdy implementacja specyficzna dla platformy zajmuje się właściwymi szczegółami iOS i Androida.
7Co oznacza architektura offline-first w programowaniu aplikacji mobilnych i czym zasadniczo różni się od standardowego buforowania odpowiedzi HTTP (Hypertext Transfer Protocol)?
W programowaniu aplikacji mobilnych architektura offline-first traktuje lokalną pamięć masową jako główne źródło prawdy (source of truth) zarówno dla operacji odczytu, jak i zapisu. Zamiast czekać na zakończenie żądań sieciowych przed wyrenderowaniem widoku lub umożliwieniem interakcji użytkownikowi, aplikacja komunikuje się bezpośrednio z lokalną bazą danych lub warstwą pamięci masowej, podczas gdy procesy synchronizacji w tle zajmują się uzgadnianiem zmian lokalnych ze zdalnym serwerem, gdy połączenie jest dostępne. Różni się to zasadniczo od standardowego buforowania odpowiedzi HTTP pod kilkoma względami: 1. **Model danych**: Buforowanie HTTP przechowuje surowe odpowiedzi sieciowe (np. ładunki JSON) indeksowane za pomocą adresów URL żądań i nagłówków. Architektura offline-first przechowuje ustrukturyzowane encje domenowe w lokalnej bazie danych (takiej jak Room, SQLite lub SwiftData/Core Data). 2. **Zapisy a odczyty**: Buforowanie HTTP służy głównie do optymalizacji odczytu i nie obsługuje natywnie transakcji zapisu ani modyfikacji danych w trybie offline. Architektura offline-first umożliwia pełne zapisy lokalne, umieszczając operacje modyfikacji w kolejce do późniejszej synchronizacji z serwerem. 3. **Możliwość wykonywania zapytań i cykl życia**: Buforowane odpowiedzi HTTP podlegają automatycznym politykom unieważniania pamięci podręcznej i nie można na nich wykonywać dowolnych zapytań, filtrowania ani złączeń. Trwałość w modelu offline-first zapewnia pełne możliwości odpytywania, indeksowania oraz deterministyczne zarządzanie cyklem życia danych niezależnie od stanu sieci.
/* 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
8Jaka jest różnica między zdalnym powiadomieniem push a powiadomieniem lokalnym w aplikacji mobilnej?
Główna różnica między powiadomieniami lokalnymi a zdalnymi powiadomieniami push polega na miejscu ich powstawania oraz sposobie wyzwalania:
1. Powiadomienia lokalne (local notifications) są tworzone, planowane i wyzwalane w całości na urządzeniu przez aplikację przy użyciu interfejsów API systemu operacyjnego (takich jak UserNotifications w systemie iOS lub AlarmManager/WorkManager/NotificationManager w systemie Android). Wyzwalają się one na podstawie warunków po stronie urządzenia, takich jak określona data/godzina, odliczanie czasomierza lub przekroczenie granicy geograficznej (geofencing). Ponieważ działają lokalnie w procesie demona systemu operacyjnego, w momencie doręczenia nie wymagają serwera backendowego ani aktywnego połączenia z internetem.
2. Zdalne powiadomienia push (remote push notifications) pochodzą z zewnętrznego serwera aplikacji i są przesyłane przez internet za pośrednictwem platformowych bramek push (APNs dla urządzeń Apple, FCM dla systemu Android). Bramka dostarcza ładunek danych (payload) do demona na poziomie systemu operacyjnego urządzenia, wybudzając aplikację lub wyświetlając baner. Powiadomienia zdalne są niezbędne do obsługi zdarzeń zewnętrznych w czasie rzeczywistym, takich jak przychodzące wiadomości na czacie, zaproszenia do znajomych czy pilne wiadomości.
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)")
}
}
9Jaka jest różnica strukturalna i operacyjna między formatami Android App Bundle (.aab), APK (Android Package) a archiwum iOS IPA (iOS App Store Package) podczas dystrybucji aplikacji przez sklepy z aplikacjami?
APK (Android Package) to wykonywalny format pakietu zawierający skompilowany kod bajtowy DEX, zasoby (resources), assety oraz biblioteki natywne, który można bezpośrednio zainstalować i uruchomić na urządzeniu z systemem Android. Uniwersalny plik APK łączy zasoby dla wszystkich gęstości ekranu, architektur procesora i języków, co prowadzi do większego rozmiaru pobieranych danych. Android App Bundle (.aab) to format publikacji/przesyłania wymagany przez Google Play do dystrybucji w sklepie. Pakiet AAB nie może być zainstalowany bezpośrednio na urządzeniu; zamiast tego Google Play wykorzystuje ten pakiet oraz usługę Play App Signing do generowania zoptymalizowanych, dzielonych pakietów APK (split APKs), dynamicznie dopasowanych do architektury, gęstości ekranu i języka konkretnego urządzenia (Dynamic Delivery). Plik iOS IPA (.ipa) to archiwum aplikacji (kontener zip zawierający katalog Payload, pakiet `.app`, zasoby podpisujące i metadane). Po przesłaniu do App Store Connect firma Apple stosuje mechanizm App Thinning (taki jak App Slicing), aby dostarczyć tylko te zasoby i pliki binarne, które są wymagane dla pobierającego urządzenia. Pod względem operacyjnym pliki APK i IPA mogą być instalowane bezpośrednio na fizycznych urządzeniach testowych (pod warunkiem odpowiedniego podpisania i profilu provisioning), podczas gdy AAB musi zostać najpierw przekonwertowany na pakiety split APK (np. za pomocą narzędzia `bundletool`) przed instalacją na urządzeniu lokalnym.
# 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
10Jaką ochronę zapewnia protokół TLS (Transport Layer Security) dla wywołań sieciowych aplikacji mobilnej i w jaki sposób nowoczesne mobilne systemy operacyjne wymuszają domyślne bezpieczne połączenia sieciowe?
Protokół TLS (Transport Layer Security) chroni mobilne wywołania sieciowe, zapewniając trzy kluczowe gwarancje: poufność (szyfrowanie przesyłanych danych, dzięki czemu osoby podsłuchujące nie mogą odczytać ładunku ani nagłówków), integralność danych (wykrywanie wszelkich modyfikacji lub manipulacji żądaniami i odpowiedziami podczas transmisji) oraz uwierzytelnianie serwera (weryfikacja certyfikatu cyfrowego serwera względem zaufanych urzędów certyfikacji w celu zapobiegania atakom typu Man-in-the-Middle). Nowoczesne mobilne systemy operacyjne domyślnie wymuszają bezpieczną transmisję poprzez blokowanie nieszyfrowanego ruchu HTTP (cleartext): 1. System iOS wymusza mechanizm App Transport Security (ATS), wymagając, aby połączenia sieciowe (np. za pośrednictwem URLSession) korzystały z HTTPS z protokołem TLS 1.2+ chyba że wyjątki dla poszczególnych domen zostały jawnie zdefiniowane w pliku Info.plist. 2. System Android (od wersji API 28) domyślnie blokuje nieszyfrowany ruch HTTP (`cleartextTrafficPermitted=false`). Zezwolenie na nieszyfrowany ruch HTTP wymaga jawnych wyjątków w konfiguracji bezpieczeństwa sieci (`network_security_config.xml`) lub atrybutu manifestu `android:usesCleartextTraffic`.
11Jak zaimplementować uwierzytelnianie biometryczne (Face ID / czytnik linii papilarnych) z wykorzystaniem bezpiecznego magazynu platformy, aby klucze kryptograficzne były odblokowywane wyłącznie po udanej weryfikacji biometrycznej?
Aby bezpiecznie zabezpieczyć klucze kryptograficzne biometrią, aplikacja nie może opierać się wyłącznie na logicznym (boolean) wywołaniu zwrotnym z monitu biometrycznego w interfejsie. Klucze kryptograficzne muszą być generowane i przechowywane w magazynie sprzętowym (Secure Enclave w iOS, Android Keystore / StrongBox w Androidzie) skonfigurowanym z zasadami kontroli dostępu, które wymuszają uwierzytelnienie biometryczne przed użyciem klucza. W systemie iOS klucze lub elementy pęku kluczy (Keychain) konfiguruje się za pomocą `SecAccessControlCreateWithFlags` z flagami takimi jak `.biometryCurrentSet` (lub `.userPresence`). Gdy klucz prywatny jest wymagany do podpisu lub deszyfrowania, system operacyjny automatycznie wyświetla użytkownikowi monit o Face ID / Touch ID. W systemie Android klucze generuje się za pomocą `KeyGenParameterSpec.Builder` z ustawieniem `.setUserAuthenticationRequired(true)`. Operacja kryptograficzna (taka jak `Cipher` lub `Signature`) jest inicjalizowana i przekazywana jako `BiometricPrompt.CryptoObject` do metody `BiometricPrompt.authenticate()`. Klucz zostaje odblokowany i jest dostępny do użycia wyłącznie wewnątrz metody `onAuthenticationSucceeded` za pośrednictwem tego uwierzytelnionego obiektu `CryptoObject`. Zastosowanie `.biometryCurrentSet` (iOS) lub `setInvalidatedByBiometricEnrollment(true)` (Android) gwarantuje, że w przypadku dodania nowego odcisku palca lub danych biometrycznych w urządzeniu dotychczasowe klucze stają się trwale nieważne, co uniemożliwia nieautoryzowany dostęp nawet w przypadku przejęcia kodu blokady ekranu.
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()
12Jakie strategie buforowania i optymalizacje potoku zadań można wdrożyć, aby znacząco skrócić czas budowania pull requestów w wieloplatformowych środowiskach wykonawczych CI (Continuous Integration) dla aplikacji mobilnych?
Aby drastycznie skrócić czas budowania pull requestów w środowiskach wykonawczych mobilnego CI, optymalizacje muszą koncentrować się na rozwiązywaniu zależności, buforowaniu kompilacji oraz selektywnym wykonywaniu zadań. Po pierwsze, należy wdrożyć efektywne buforowanie zależności oparte na skrótach plików blokad (np. `package-lock.json`, `Podfile.lock`, `gradle/verification-metadata.xml` lub plikach rozwiązywania pakietów SPM). Pozwala to uniknąć pobierania lub ponownego rozwiązywania pakietów zewnętrznych na czystych instancjach środowisk wykonawczych. Po drugie, należy skonfigurować natywne pamięci podręczne kompilacji. W przypadku Androida należy włączyć pamięć podręczną kompilacji Gradle (`--build-cache`) ze zdalnym buforowaniem HTTP lub trwałym buforowaniem natywnym dla CI dla katalogów `~/.gradle/caches` oraz katalogów kompilacji. W przypadku iOS należy ostrożnie buforować katalog `DerivedData` środowiska Xcode oraz artefakty kompilacji Swift Package / CocoaPods, bądź skorzystać z nowoczesnych narzędzi do zdalnego buforowania, takich jak Tuist czy Bazel. Dla React Native/Flutter należy buforować wynik pakietu JS (bundle), `node_modules` oraz pamięć podręczną SDK silnika Flutter. Po trzecie, należy zastosować selektywne wykonywanie zadań (detekcję zmian i analizę wpływu testów). Użyj filtrów ścieżek Gita, aby pomijać kompilacje iOS, gdy zmieniono tylko pliki Androida lub backendu, uruchamiaj szybkie etapy linterów i testów jednostkowych przed kosztownymi testami UI lub krokami kompilacji oraz unikaj generowania pełnych plików binarnych wydania (takich jak pliki AAB lub uniwersalne IPA) w ramach pull requestów, gdzie wystarczająca jest kompilacja debugowa pod symulator lub samo uruchomienie testów jednostkowych.
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
13W jaki sposób zaprojektowałbyś strukturę współdzielonej funkcjonalności, aby warstwa prezentacji, przypadki użycia domeny oraz dostęp do danych pozostały niezależnie testowalne i możliwe do ponownego wykorzystania między platformami iOS i Android?
Aby zaprojektować współdzieloną funkcjonalność pod kątem niezależnej testowalności i ponownego wykorzystania na platformach iOS i Android, należy zastosować czystą architekturę warstwową (Clean Architecture) podzieloną na warstwy prezentacji, domeny i danych: 1. **Warstwa domeny (czysta współdzielona logika biznesowa):** Zawiera czyste encje biznesowe, interfejsy repozytoriów (kontrakty) oraz przypadki użycia/interaktory (np. `GetCartUseCase`, `ApplyPromoCodeUseCase`). Warstwa ta nie ma żadnych zależności od frameworków UI (UIKit, SwiftUI, Android Views, Jetpack Compose, widgety Fluttera) ani specyficznych API platformy. Każdy przypadek użycia hermetyzuje pojedynczą operację biznesową, dzięki czemu jest w 100% testowalny jednostkowo za pomocą zwykłych obiektów makiet (mocks). 2. **Warstwa danych (dostęp do danych i enkapsulacja):** Implementuje interfejsy repozytoriów domenowych. Komunikuje się ze zdalnymi źródłami danych (REST/GraphQL) oraz lokalnym magazynem danych (SQLite/Key-Value). Sieciowe obiekty transferu danych DTO (Data Transfer Object) oraz encje bazodanowe są ściśle mapowane na czyste encje domenowe przed opuszczeniem tej warstwy, co zapobiega przenikaniu zewnętrznych schematów serializacji lub zmian w bazie danych do warstwy domeny lub prezentacji. 3. **Warstwa prezentacji (UI i zarządzanie stanem):** Składa się z obiektów zarządzających stanem (ViewModels, Blocs lub Presenters) oraz widoków/komponentów UI. Obiekt zarządzający stanem wywołuje przypadki użycia domeny, zarządza stanem interfejsu użytkownika (ładowanie, sukces, błąd) i udostępnia obserwowalny stan dla widoku. Widok po prostu obserwuje ten stan i go renderuje. Taka struktura pozwala na testowanie jednostkowe przypadków użycia w izolacji od UI, mockowanie repozytoriów do testowania modeli widoku oraz wymianę implementacji prezentacji między platformami przy jednoczesnym ponownym wykorzystaniu całej logiki domeny i danych.
[ 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 ]
14Jak ocenić, kiedy lokalny stan komponentu przestaje wystarczać, i wybrać odpowiednie podejście architektoniczne do zarządzania stanem w wieloosobowym projekcie wieloplatformowym?
W wieloplatformowym tworzeniu aplikacji mobilnych przejście od lokalnego stanu komponentu (`useState`/`StatefulWidget`) do architektonicznego rozwiązania do zarządzania stanem wynika z zakresu stanu, wymagań dotyczących cyklu życia oraz testowalności: 1. **Kiedy stan lokalny jest niewystarczający**: - **Współdzielenie między komponentami i przekazywanie właściwości w głąb drzewa (prop drilling)**: Gdy stan musi być dostępny lub modyfikowany w różnych trasach nawigacji bądź odległych gałęziach drzewa widżetów/komponentów. - **Trwałość cyklu życia**: Gdy dane muszą przetrwać niszczenie ekranów, przejścia między widokami lub resetowanie nawigacji (np. sesja użytkownika, zawartość koszyka, buforowane dane). - **Rozdzielenie odpowiedzialności i testowalność**: Gdy logika biznesowa, walidacja i efekty uboczne stają się ściśle powiązane z komponentami interfejsu użytkownika, utrudniając automatyczne testy jednostkowe bez renderowania interfejsu (headless). 2. **Wybór architektury dla zespołów programistycznych**: - **Jednokierunkowy przepływ danych / przewidywalne kontenery stanu (np. BLoC, Redux, Riverpod)**: Wymusza ścisłe rozdzielenie, w którym interfejs użytkownika wysyła jawne zdarzenia/akcje i renderuje niemutowalny stan emitowany przez dedykowane moduły logiki biznesowej. Tworzy to czytelne kontrakty, ogranicza efekty uboczne i umożliwia niezależne testowanie jednostkowe logiki biznesowej. - **Atomowe / ograniczone zakresem magazyny reaktywne (np. Zustand, MobX, Provider)**: Zapewniają mniej kodu powtarzalnego (boilerplate) i elastyczne subskrypcje oparte na selektorach, co sprawdza się, gdy zespoły oczekują lekkiego odsprzężenia i precyzyjnego ponownego renderowania tylko wybranych komponentów. W pracy zespołowej głównym celem architektonicznym jest odizolowanie czystej logiki biznesowej od warstw renderowania interfejsu użytkownika oraz stosowanie selektywnych subskrypcji w celu zapobiegania niepotrzebnemu ponownemu renderowaniu całego drzewa widżetów.
15W jaki sposób zbadałbyś i zredukował zacinanie animacji (jank) na ekranie wieloplatformowej aplikacji mobilnej, który przycina się podczas przewijania długiej listy obrazów i dynamicznej zawartości?
W pierwszej kolejności odtworzyłbym przycinanie na rzeczywistych urządzeniach i przeprowadził profilowanie, ponieważ zacinanie podczas przewijania (jank) może wynikać z wielu przyczyn: przekroczenia budżetu czasu ramki (frame budget), operacji w silniku JS lub Dart, obciążenia wątku głównego/UI, pracy GPU/rasteryzacji, przeliczania układu (layout), dekodowania obrazów, presji pamięciowej lub ładowania danych z sieci. Przy 60 Hz aplikacja ma około 16,7 ms na klatkę, a przy 120 Hz około 8,3 ms, więc kosztowne renderowanie, dekodowanie, wyliczanie układu czy synchroniczne obliczenia podczas przewijania mogą powodować gubienie klatek (dropped frames). Użyłbym narzędzi takich jak Flutter DevTools, narzędzia wydajnościowe React Native lub Flipper, Android Studio Profiler, Xcode Instruments oraz widoków osi czasu klatek (frame timeline), aby zidentyfikować rzeczywiste wąskie gardło. W przypadku długiej listy upewniłbym się, że stosowane jest leniwe ładowanie lub wirtualizacja: na przykład FlatList, FlashList lub RecyclerListView w React Native, albo ListView.builder, SliverList lub podobne mechanizmy w Flutterze. Zastosowałbym stabilne klucze, unikał przebudowywania lub ponownego renderowania każdego wiersza przy zmianie stanu rodzica, memoizował komponenty wierszy lub selektory tam, gdzie to zasadne, dbał o niski koszt metod build/renderItem oraz przeniósł sortowanie, filtrowanie, parsowanie JSON, formatowanie czy przetwarzanie obrazów poza ścieżkę przewijania. Jeśli rozmiary elementów są przewidywalne, dostarczyłbym wskazówki dotyczące układu, takie jak getItemLayout w React Native lub stałe wymiary elementów (prototype/fixed item extents) w Flutterze. W przypadku obrazów serwowałbym miniatury o odpowiednim rozmiarze, buforował je w pamięci podręcznej, unikał dekodowania obrazów w pełnej rozdzielczości dla małych komórek, stosował symbole zastępcze (placeholders) i leniwe ładowanie oraz kontrolował rotację pamięci (memory churn) wywołaną zbyt dużą liczbą dużych bitmap. Uprościłbym również zbyt złożone układy wierszy, ograniczył kosztowne cienie, przycinanie (clipping), nadmierne przerysowywanie (overdraw) i przezroczystość tam, gdzie to istotne, zastosował przetwarzanie wsadowe lub paginację danych, a następnie ponownie przeprowadził profilowanie, aby potwierdzić spadek liczby gubionych klatek i poprawę czasów renderowania.
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.
16Jak działają granice wątków podczas wywołań przez most natywny (native bridge) i jak zapobiegać przestojom wynikającym z przełączania wątków oraz zacięciom interfejsu użytkownika (UI – User Interface), gdy moduły natywne wykonują obciążające operacje w tle?
Wieloplatformowe frameworki stosują specyficzne konwencje wielowątkowości dla wywołań mostu: przykładowo, standardowe wywołania `MethodChannel` we Flutterze trafiają do głównego wątku UI platformy, podczas gdy tradycyjne wywołania mostu w React Native działają na dedykowanym wątku JavaScriptu i są asynchronicznie przekazywane do wątków natywnych. Gdy natywna metoda mostu wykonuje się na natywnym wątku UI/głównym, uruchamianie operacji silnie obciążających procesor, długich synchronicznych operacji wejścia/wyjścia na dysku czy blokujących zadań sieciowych blokuje główną pętlę zdarzeń (run loop). Prowadzi to do gubienia klatek, widocznych zacięć interfejsu (UI jank), a na platformie Android do błędów ANR (Application Not Responding) lub zatrzymania aplikacji przez mechanizm watchdog w systemie iOS. Aby temu zapobiec, procedury obsługi mostu natywnego powinny oddelegowywać ciężkie lub blokujące zadania do pul wykonawczych działających w tle (np. Kotlin Coroutines z `Dispatchers.IO`/`Dispatchers.Default`, androidowy `ThreadPoolExecutor` lub `Task.detached` w Swift / `DispatchQueue.global()` w GCD). Po zakończeniu pracy wynik musi zostać przekazany z powrotem na wątek mostu oczekiwany przez framework (np. zwrócenie wyniku na wątku UI dla standardowych MethodChannels lub użycie kolejek zadań w tle), co zapewnia, że aktualizacje stanu wieloplatformowego nie blokują cyklu renderowania 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()
}
}
}
17Jak zaprojektować strategię buforowania i unieważniania danych dla ekranów list i kanałów informacyjnych przy użyciu wzorca Stale-While-Revalidate oraz warunkowych nagłówków HTTP (Hypertext Transfer Protocol)?
Odporna strategia buforowania i unieważniania danych dla ekranów list i kanałów informacyjnych (feed) łączy wzorzec Stale-While-Revalidate (SWR) z warunkowymi nagłówkami walidacji HTTP (takimi jak `ETag` / `If-None-Match` lub `Last-Modified` / `If-Modified-Since`). Po otwarciu ekranu aplikacja natychmiast odczytuje i renderuje zbuforowane dane z lokalnej bazy danych (będącej jedynym źródłem prawdy – Single Source of Truth, np. Room lub Core Data/SQLite), zapewniając użytkownikowi natychmiastowe wyświetlenie UI. Jednocześnie w tle wysyłane jest żądanie sieciowe ze zbuforowanym nagłówkiem `If-None-Match: <etag>`. Jeśli serwer odpowie kodem `304 Not Modified`, treść odpowiedzi nie jest przesyłana, co waliduje lokalną pamięć podręczną, oszczędzając transfer i baterię. Jeśli serwer odpowie kodem `200 OK`, nowe dane oraz zaktualizowany `ETag` są zapisywane w lokalnej bazie danych w ramach transakcji, a reaktywne obserwatory bazy automatycznie emitują zaktualizowaną listę do UI. W przypadku paginacji tokeny stron lub przesunięcia (offsets) są przechowywane razem ze zbuforowanymi rekordami. Unieważnienie danych następuje po upływie TTL (Time To Live), w wyniku akcji użytkownika (np. pull-to-refresh z pominięciem nagłówków warunkowych) lub efektów ubocznych lokalnych mutacji (np. utworzenie lub usunięcie elementu lokalnie optymistycznie aktualizuje bazę danych i oznacza zbuforowane kursory stron do ponownej walidacji).
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
}
}
18Aplikacja mobilna dla sektora finansowego musi bezpiecznie przechowywać tokeny odświeżające (refresh tokens) na urządzeniu, obsługiwać odblokowywanie biometryczne, zachowywać sprawność po aktualizacjach systemu operacyjnego OS (Operating System) oraz działać bezpiecznie podczas tymczasowych sesji offline. Jak zaprojektowałbyś architekturę przechowywania tokenów i dostępu do nich?
Solidna architektura przechowywania tokenów mobilnych wykorzystuje sprzętowe moduły bezpieczeństwa: iOS Keychain wspierany przez Secure Enclave oraz Android Keystore oparty na StrongBox lub bezpiecznym środowisku wykonawczym TEE (Trusted Execution Environment). Tokeny odświeżające muszą być szyfrowane w spoczynku (at rest) przy użyciu kluczy chronionych kryptograficzną bramką biometryczną (np. `kSecAccessControlBiometryCurrentSet` lub `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` na iOS oraz `setUserAuthenticationRequired(true)` z flagą `AUTH_BIOMETRIC_STRONG` na Androidzie). Bramkowanie kryptograficzne gwarantuje, że klucz szyfrujący jest odszyfrowywany przez sprzęt wyłącznie po udanym uwierzytelnieniu biometrycznym, zamiast polegać na podatnym na obejście sprawdzeniu flagi logicznej w kodzie aplikacji. Aby zapewnić ciągłość działania po aktualizacjach systemu operacyjnego przy jednoczesnym blokowaniu nieautoryzowanego dostępu, klucze są powiązane ze sprzętem urządzenia i wymuszają reguły unieważniania w przypadku zmiany zarejestrowanych danych biometrycznych (np. `setInvalidatedByBiometricEnrollment(true)` na Androidzie). W przypadku tymczasowych sesji offline krótkotrwałe, zaszyfrowane tokeny dostępu oraz ograniczony lokalny stan sesji offline mogą działać w ramach zdefiniowanego czasu życia (TTL) i ograniczonych uprawnień, odraczając uprzywilejowane odświeżanie do momentu przywrócenia łączności sieciowej. Po ponownym połączeniu rotacja tokenów odświeżających z wykrywaniem powtórzeń (replay detection) na backendzie weryfikuje sesję; w przypadku wykrycia ponownego użycia tokena lub unieważnienia konta backend zwraca sygnał wymuszający usunięcie kluczy sprzętowych i wyczyszczenie pamięci podręcznej offline.
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()
19Jak zaprojektowałbyś skalowalną infrastrukturę CI/CD (Continuous Integration/Continuous Deployment) dla wieloplatformowego monorepozytorium aplikacji mobilnych obsługującego wiele zespołów, zarządzając jednocześnie ograniczoną i kosztowną pulą maszyn wykonawczych z systemem macOS?
Aby zaprojektować skalowalną infrastrukturę CI/CD dla wieloplatformowego monorepozytorium mobilnego przy jednoczesnej optymalizacji deficytowych i drogich zasobów macOS, architektura musi opierać się na wykrywaniu zmienionych pakietów na podstawie grafu zależności, ścisłym podziale zadań pomiędzy hybrydowe floty maszyn wykonawczych (runners) oraz wirtualizacji i efemerycznej orkiestracji środowisk macOS. Po pierwsze, należy wdrożyć narzędzie do budowania oparte na grafie monorepozytorium (np. Bazel, Nx lub Turborepo) ze zdalnym buforowaniem rozproszonym (remote caching). Przy każdym pull requeście potok CI oblicza różnicę na skierowanym grafie acyklicznym (DAG) względem gałęzi docelowej, aby budować i testować wyłącznie pakiety, których dotyczą zmiany, oraz ich bezpośrednie zależności, całkowicie pomijając niezmienione moduły. Po drugie, należy zastosować hybrydową strategię alokacji maszyn: przenieść wszystkie zadania niewymagające bezpośrednio środowiska Xcode (np. linting i kompilację TypeScript/Dart, testy jednostkowe, statyczną analizę kodu, skanowanie bezpieczeństwa i kompilację Gradle dla Androida) na skalowalne i tanie maszyny z systemem Linux w klastrze Kubernetes. Maszyny z macOS należy zarezerwować wyłącznie dla końcowego budowania aplikacji iOS, kompilacji kodu Swift/Objective-C, podpisywania kodu (code signing) oraz wykonywania testów na symulatorze iOS Simulator. Po trzecie, zarządzanie maszynami macOS powinno odbywać się za pomocą infrastruktury wirtualizacji (np. Tart, Anka lub węzłów bare-metal AWS/MacStadium orkiestrowanych przez Nomad lub Kubernetes). Każde zadanie budowania iOS uruchamia się w czystej, efemerycznej maszynie wirtualnej ze wstępnie przygotowanym zestawem narzędzi i pamięcią podręczną derived data, co eliminuje dryf środowisk wykonawczych i umożliwia szybkie automatyczne skalowanie na podstawie długości kolejki w potoku.
20Jak zaprojektować wieloplatformową (cross-platform) architekturę aplikacji mobilnej dla produktu, który powinien współdzielić większość logiki biznesowej między systemami iOS i Android, jednocześnie pozwalając na niezależny rozwój interfejsu użytkownika (UI, User Interface), nawigacji oraz integracji natywnych specyficznych dla danej platformy?
Zaprojektowałbym aplikację wokół współdzielonego rdzenia (shared core), który zarządza stabilnym zachowaniem biznesowym: modelami domenowymi, walidacją, przypadkami użycia (use cases), regułami biznesowymi i kontraktami dostępu do danych. Kwestie specyficzne dla platformy, takie jak UI, nawigacja, natywne pakiety SDK, uprawnienia, obsługa cyklu życia i powłoka aplikacji (app-shell), powinny pozostać poza tym rdzeniem. Współdzielony kod nie powinien importować UIKit, SwiftUI, Jetpack, frameworków platformy Android, nawigacji React Native, nawigacji Flutter ani szczegółów natywnych SDK. Zamiast tego powinien zależeć od wąskich interfejsów, takich jak AuthRepository, SecureStorage, Analytics, CameraService czy PaymentProvider, implementowanych przez poszczególne platformy.
Kod zorganizowałbym w miarę możliwości według funkcji (by feature), zamiast tworzyć jedną wielką współdzieloną warstwę. Na przykład moduły checkout, wyszukiwania, konta użytkownika i wiadomości mogą zawierać własny współdzielony kod domenowy/przypadków użycia, kontrakty stanu i interfejsy repozytoriów. Powłoki iOS i Android odpowiadają wówczas za kompozycję ekranów, natywne wzorce UI, stosy nawigacji, uprawnienia oraz łączenie adapterów. Logika prezentacji może być współdzielona, jeśli jest w pełni niezależna od frameworka UI – na przykład w postaci reducerów, maszyn stanów lub kontraktów widoków-modeli (view-model contracts) emitujących prosty stan i akcje – jednak właściwe widoki i nawigacja powinny pozostać domeną poszczególnych platform, co pozwoli na ich niezależny rozwój.
Głównym kompromisem jest zdecydowane współdzielenie wspólnej, stabilnej logiki biznesowej przy jednoczesnym unikaniu abstrakcji, które maskują realne różnice między platformami. Granice te egzekwowałbym za pomocą reguł zależności modułów, wstrzykiwania zależności, publicznych API, testów kontraktowych, reguł architektury oraz jasnej własności kodu. Integracje natywne powinny wykorzystywać porty i adaptery (ports/adapters), dzięki czemu współdzielony kod funkcji korzysta ze wspólnej abstrakcji, podczas gdy każda z platform obsługuje zachowanie SDK, cykl życia, uprawnienia, błędy oraz różnice w doświadczeniu użytkownika (UX) we własnej warstwie.