Aplikace v Expo SDK 57 na Xcode 27: terénní průvodce

Všechno, co se rozbilo, když se vývojový build Expo SDK 57 poprvé potkal s Xcode 27, simulátorem iOS 27 a emulátorem Androidu 14, vždy s příčinou a opravou. Napsané tak, abyste při vývoji další aplikace nemuseli nic z toho objevovat znovu.

Lukáš Oreška 11 min čtení

Než začnete

Průvodce vznikl při prvním ověřování produkční Expo aplikace (expo-router, expo-notifications, expo-location, expo-sqlite, react-native-maps) v simulátorech. Nic z toho se netýká jen té aplikace. Stejné prostředí způsobí stejné chyby u každého projektu na Expo SDK 57 sestavovaného na Macu, který přešel na Xcode 27.

Prostředí

macOS s Xcode 27.0 a nainstalovaným jen runtime simulátoru iOS 27, emulátor Androidu API 34 (Pixel 3a), Node 24, Expo SDK 57, React Native 0.86, Maestro 2.x.

Postup

Vývojové buildy (npx expo run:ios, npx expo run:android) řízené flow v Maestru, se snímky obrazovky a unifikovaným logem jako důkazem. Ne Expo Go.

1. Aplikace spadne při startu: „UIScene life cycle is required“

Příznak. Build projde, aplikace se nainstaluje a během několika set milisekund skončí. Simulátor ukáže plochu. Záznam o pádu končí na ___UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption_block_invoke a konzole hlásí:

Application failed to launch: UIScene life cycle is required for apps built with this SDK.
See "Transitioning to the UIKit scene-based life cycle" in the UIKit documentation.

Příčina. Aplikace kompilované proti SDK iOS 27 (Xcode 27) musí používat životní cyklus založený na scénách. Základní šablona Expo SDK 57 neobsahuje manifest scén. Expo přidalo volitelnou podporu scén v opravném vydání 57.0.23; projekt uzamčený na starší verzi (ten náš měl 57.0.13) ji zapnout nemůže.

Oprava. Aktualizujte na nejnovější opravné vydání SDK 57, sjednoťte ostatní balíčky Expo a podporu scén zapněte přes expo-build-properties:

npx expo install expo@~57.0.27
npx expo install --fix
npx expo install expo-build-properties
// app.json → expo.plugins
[
  "expo-build-properties",
  { "ios": { "enableSceneSupport": true } }
]

Potom znovu vygenerujte nativní projekt. Obyčejný rebuild nestačí, je třeba přepsat Info.plist:

npx expo prebuild -p ios --clean
grep -c EXExpoAppSceneDelegate ios/<AppName>/Info.plist   # must print 1
npx expo run:ios

2. Kolize portů Metra: 8081 patří někomu jinému

Příznak. npx expo start se ptá „Use port 8082 instead?“ a v neinteraktivním režimu vývojový server prostě přeskočí. Vývojový build spuštěný proti špatnému Metru buď ukáže červenou chybovou obrazovku, nebo načte balíček jiného projektu.

Příčina. Na vývojářském Macu bývá 8081 obsazený zapomenutým expo run:ios z jiného projektu nebo nesouvisející lokální službou. U nás naslouchalo Metro druhé aplikace staré 18 hodin i vývojový server PHP.

lsof -nP -iTCP:8081 -sTCP:LISTEN

Oprava. Vyberte volný port a nativní aplikaci pro něj sestavte. Port se při sestavení zapíše do binárního souboru, takže --port je nutné předat příkazu pro build, ne jen Metru:

# iOS: sets RCT_METRO_PORT for the Xcode build and starts Metro on the same port
npx expo run:ios --port 8082

# Android: passes -PreactNativeDevServerPort=8082 to Gradle
ANDROID_HOME=$HOME/Library/Android/sdk npx expo run:android --port 8082
adb reverse tcp:8082 tcp:8082

Pro Android bez Expo CLI je ekvivalentní volání Gradle:

cd android && ./gradlew app:installDebug -x lint -x test \
  -PreactNativeDevServerPort=8082 -PreactNativeArchitectures=arm64-v8a

3. Červená obrazovka: „No script URL provided“

Příznak. Po prebuildu se aplikace spustí, ale ukáže No script URL provided. Make sure the packager is running, přestože Metro zjevně běží. Unifikovaný log iOS ukazuje, že aplikace zkouší localhost:8081/status, ne port, pro který jste ji sestavili.

Příčina. Definice RCT_METRO_PORT z doby kompilace nepřežila čerstvý projekt Pods, takže RCTBundleURLProvider spadl na 8081, našel tam cizí server a nedokázal přeložit balíček. Deep link vývojového klienta Expo (…://expo-development-client/?url=…) projektu, který expo-dev-client neobsahuje, nepomůže.

Oprava. RCTBundleURLProvider čte umístění packageru z NSUserDefaults aplikace, což je přesně to, co zapisuje „Configure Bundler“ ve vývojářském menu aplikace. Nastavte to z hostitele:

SIM=<simulator UDID>
xcrun simctl terminate $SIM cz.example.app
xcrun simctl spawn $SIM defaults write cz.example.app RCT_jsLocation "localhost:8082"
xcrun simctl launch $SIM cz.example.app

4. Onboarding se nikdy nezobrazí: initialRouteName není to, co si myslíte

Příznak. Čerstvá instalace jde rovnou na hlavní záložky. Obrazovka onboardingu je dostupná přes URL, ale nikdy není první. Unit testy procházejí, protože samotná logika hlídání je v pořádku.

Příčina. V Expo Routeru má initialRouteName (dnes anchor) smysl jen při deep linku do vnořené trasy, aby existovalo tlačítko zpět. Běžný studený start vždy vyhodnotí URL /, což je index skupiny záložek. Dokumentace je jasná: „The anchor is only used when deep-linking to a route.“

Oprava. Trasy chraňte pomocí Stack.Protected. Chráněná počáteční trasa přinutí router propadnout na první dostupnou obrazovku a přepnutí ochran uživatele automaticky přesune a smaže historii:

// app/_layout.tsx
<Stack screenOptions={{ headerShown: false }}>
  <Stack.Protected guard={onboarded}>
    <Stack.Screen name="(tabs)" />
  </Stack.Protected>
  <Stack.Protected guard={!onboarded}>
    <Stack.Screen name="onboarding" options={{ gestureEnabled: false }} />
  </Stack.Protected>
</Stack>

Obrazovka onboardingu musí umět přepnout onboarded v kořenovém layoutu. Kontext držte ve vlastním modulu, aby soubor trasy nikdy neimportoval _layout.tsx:

// components/OnboardingContext.ts
export const OnboardingContext = createContext<{ finish: () => void }>({ finish: () => {} });
export const useOnboarding = () => useContext(OnboardingContext);

// app/onboarding.tsx – after persisting the flag
await setFlag('onboarded', true);
onboarding.finish();           // no router.replace; the guard does the navigation

Tím se mění také místo, kde se spustí žádost o oprávnění k notifikacím: pokud se hlavní obrazovky mohou připojit dřív než onboarding, cokoli, co spustí (třeba efekt při fokusu, který přeplánuje notifikace), se uživatele zeptá dřív, než mu onboarding vysvětlí proč.

5. Neviditelné okno s žádostí o oprávnění na iOS

Příznak. Na zdánlivě čisté instalaci se systémové okno s oprávněním k notifikacím objeví zhruba 60 ms po startu, na první slajdu onboardingu, ještě než se spustil jakýkoli JavaScript. Sledování všech volání requestPermissionsAsync a getPermissionsAsync nic neukáže. Breakpoint v lldb na requestAuthorizationWithOptions:completionHandler: se nikdy nezastaví. Unifikovaný log ukazuje, že SpringBoard vyvolává systemModalAlert pro daný bundle, zatímco UIKit ještě vytváří okno.

Příčina. Dřívější běh požádal o oprávnění a byl ukončen, když bylo okno otevřené (testovacím nástrojem, příkazem simctl terminate nebo pádem). iOS si neodpovězenou žádost drží a při dalším spuštění stejného bundle identifikátoru ji zobrazí znovu. Odinstalace aplikace ji nesmaže. Neptá se aplikace; systém dokončuje starý rozhovor.

Oprava. Na okno jednou odpovězte (kterýmkoli tlačítkem), pak aplikaci znovu nainstalujte a otestujte. V automatizaci musí každé flow, které může systémové okno vyvolat, také zavřít. Zapište to do poznámek k testování, ať s tím další člověk nestráví odpoledne.

# Maestro: always resolve the dialog you caused
- tapOn: "Povolit|Allow"            # or "Nepovolovat|Don.t Allow"

6. Android: sound: 'default' u kanálu notifikací

Příznak. Červený toast na Androidu a tento řádek v logu Metra:

ERROR expo-notifications: Custom sound 'default' not found in native app.
Make sure the sound file (e.g. 'custom_sound.wav') is included in the expo-notifications config plugin sounds array in app config.

Příčina. V expo-notifications 57.0.22+ je NotificationChannelInput.sound název vlastního zvukového souboru registrovaného přes pole sounds v config pluginu. Řetězec 'default' je jen možná výstupní hodnota u NotificationChannel. Kanál se přesto vytvoří a chyba se jen zaloguje, nevyhodí, takže plánování dál funguje a problém snadno uniká pozornosti.

Oprava. Vynechejte sound. Kanál bez něj použije výchozí zvuk systému.

await Notifications.setNotificationChannelAsync('deadlines', {
  name: 'Vehicle deadlines',
  importance: Notifications.AndroidImportance.DEFAULT,
  // no `sound` → system default
});

7. Úskalí Maestra na tomto stacku

Maestro je nejrychlejší způsob, jak z jednoho souboru YAML ovládat oba simulátory, ale pět jeho výchozích chování se střetává s Expo a iOS 27.

Úskalí Co se děje Co dělat místo toho
launchApp na Androidu uděluje všechna oprávnění Váš flow s oprávněním tiše projde, protože se okno nikdy neobjeví. launchApp: { clearState: true, permissions: { all: unset } }
clearState na iOS maže NSUserDefaults Přepsání RCT_jsLocation z oddílu 3 zmizí a aplikace spadne na 8081. Resetujte skriptem: ukončit, odinstalovat, nainstalovat .app, zapsat výchozí hodnotu a spustit bez clearState.
Popisky záložek na iOS 27 nejsou textové prvky tapOn: "Settings" selže, přestože je popisek na obrazovce. Klepněte podle pozice: tapOn: { point: "83%,94%" }. Obsah obrazovky hledejte regulárním výrazem, například ".*[Gg]arage.*".
Systémová okna jsou lokalizovaná Český simulátor ukáže „Povolit“, ne „Allow“. Používejte alternace: "Povolit|Allow", "Nepovolovat|Don.t Allow", "Povolit při používání aplikace|Allow While Using App|While using the app".
Ukončený běh nechá okno otevřené Viz oddíl 5. Každý flow, který okno otevře, ho také zavře.

Opakovaně použitelný reset skript pro iOS:

#!/bin/zsh
SIM=<UDID>; APP="$1"; ID=cz.example.app
xcrun simctl terminate $SIM $ID 2>/dev/null
xcrun simctl uninstall $SIM $ID 2>/dev/null
xcrun simctl install $SIM "$APP"
xcrun simctl spawn $SIM defaults write $ID RCT_jsLocation "localhost:8082"

Samotnému Maestru trvá při prvním použití 20 až 30 sekund, než se spustí, protože hledá aktualizace; nastavte MAESTRO_CLI_NO_ANALYTICS=1 a nepovažujte tu pauzu za zamrznutí.

8. Realita emulátoru: pomalý, hlučný a občas bez GPS

Na straně Androidu se za chyby aplikace vydávaly tři problémy prostředí.

8.1 „System UI isn't responding“ a „Digital Wellbeing isn't responding“

Emulátor, který běží mnoho hodin na stroji, který navíc kompiluje dva nativní buildy, vyhazuje dialogy ANR od systémových komponent. Překrývají aplikaci a rozbíjejí každé ověření. Proveďte studený restart emulátoru a odstraňte nejhoršího viníka:

adb emu kill
emulator -avd <name> -no-snapshot-load -no-boot-anim
adb shell pm disable-user --user 0 com.google.android.apps.wellbeing
adb shell settings put global hide_error_dialogs 1

8.2 Studený start vývojového buildu trvá minuty, ne vteřiny

Na vytíženém emulátoru dosáhl ReactHost stavu Running "main" tři minuty po startu procesu, s 6vteřinovými pauzami GC. Šedesátivteřinový extendedWaitUntil selže i u úplně zdravé aplikace. Na první obrazovku na Androidu dejte 180 až 240 sekund, nebo flow pusťte proti aplikaci, která už je v popředí.

8.3 adb emu geo fix se přijme, ale nikdy nedoručí

Náš emulátor odpověděl OK na každou polohu, a přesto hlásil last location=null pro poskytovatele GPS i fused:

adb emu geo fix 14.4378 50.0755
adb shell dumpsys location | grep "last location"

Aplikace správně ukázala „žádný signál“ a nic neuložila, což ověřilo cestu pro nedostupnou polohu, ne ale cestu, kdy vše funguje. Berte polohu na emulátorech Androidu jako nespolehlivý důkaz; ukládání ověřte na iOS (kde simctl location set funguje) a na fyzickém zařízení s Androidem.

Kontrolní seznam pro další aplikaci

  • Expo na nejnovějším opravném vydání SDK 57 (≥ 57.0.23) a expo-build-properties s ios.enableSceneSupport: true; po přidání prebuild --clean.
  • Volný port Metra vybraný před prvním buildem; --port u run:ios / run:android plus adb reverse.
  • Simulátor iOS: výchozí hodnota RCT_jsLocation zapsaná po každém resetu.
  • Směrování při prvním spuštění používá Stack.Protected, ne initialRouteName; onboarding hlásí dokončení přes kontext.
  • Každé systémové okno, které flow vyvolá, se ve flow také zavře.
  • Kanály notifikací na Androidu nenastavují sound: 'default'.
  • Maestro: permissions: { all: unset } na Androidu, klepání podle pozice na záložky na iOS, lokalizované regulární výrazy pro systémová okna.
  • Emulátor Androidu před testovací relací po studeném restartu; Digital Wellbeing vypnutý; velkorysé časové limity pro první obrazovku; poloha ověřená jinde.
  • Důkazy uchované jako snímky obrazovky po krocích a v předávacích poznámkách je uvedeno, co se ověřilo v simulátoru a co na zařízení.

Doprovodný materiál: flow pro Maestro a reset skript jsou v repozitáři aplikace v tools/maestro/.

PHPStan level 9 na velké starší kódové základně

Jak jsme CMS s více než šestnácti tisíci chyb PHPStan v baseline začali postupně odstraňovat, aniž bychom zastavili vývoj: postup s baseline, jejíž počet nesmí růst, a pro každou opakující se třídu chyb oprava, kterou dnes používáme reflexivně.
19 min čtení

Publikace Android aplikace v Google Play: od Play Console po interní testování

Všechno, na co se Play Console ptá, než aplikace dorazí k prvním testerům: prohlášení o obsahu aplikace, Data safety, stránka v obchodě, nové prohlášení o médiích vytvořených AI, veřejně dostupné zásady ochrany soukromí, interní testování, podepisování aplikace a co čekat u ceny a daní.
9 min čtení

Vydání Expo aplikace bez EAS: lokální podepsané buildy pro Android

Jak na vlastním Macu vyrobit z projektu Expo SDK 57 správně podepsaný Android App Bundle připravený pro Play: keystore pro nahrávání (upload keystore) mimo repozitář, config plugin, který přežije prebuild, jedna oprava paměti Gradle a skript, který odmítne skončit, pokud je podpis špatný.
9 min čtení

Řešíte něco podobného na svém webu?

Napište mi, odpovím do druhého dne.

Napsat zprávu