Kompromisy w React Native na dużą skalę: stan, listy, urządzenia, tokeny, aktualizacje
Pięć problemów z React Native, które ujawniają umiejętności inżynierskie: stan serwera w porównaniu ze stanem klienta, wolne FlatLists, urządzenia Android o niskich specyfikacjach, przechowywanie tokenów oraz aktualizacje stopniowe.
Budowanie interfejsu w React Native jest proste. Trudności pojawiają się, gdy aplikacja rośnie: stan rozprzestrzenia się wszędzie, listy działają nierównomiernie, tanie telefony Android się zawieszają, tokeny są przechowywane w postaci tekstowej, a framework pozostaje lata w tyle. Poniżej przedstawiono pięć takich sytuacji, często używanych podczas rozmów kwalifikacyjnych z doświadczonymi inżynierami, wraz z wyjaśnieniem, jakie elementy powinno zawierać solidne rozwiązanie, abyś mógł zastosować je we własnym kodzie.
Rozdzielenie stanu serwera od stanu klienta
Gdy lokalny stan, odpowiedzi API, cache oraz wspólne flagi interfejsu łączą się w jeden bałagan, pierwsze pytanie brzmi nie „Redux czy Context?”, lecz „kto jest właścicielem tych danych?”.
Dane pochodzące z API, takie jak profile, strumienie treści czy listy produktów, stanowią stan serwera. Z czasem stają się przestarzałe, więc należy je ponownie pobrać, zapisać w buforze i unieważnić. Umieszczanie ich w globalnym magazynie oznacza konieczność ręcznego ponownego zaimplementowania całego tego procesu, wraz z obsługą flag ładowania i błędów. Poniższy fragment pokazuje ten antypatern.
// ❌ Server data forced into a global store —
// now YOU own caching, staleness, invalidation, loading states…
dispatch(setProducts(await fetchProducts()));
TanStack Query i RTK Query zostały stworzone właśnie po to, by zarządzać tą warstwą. Funkcja useQuery grupuje dane według kategorii i utrzymuje je aktualne przez 60 sekund dzięki parametrowi staleTime. Druga część bloku (połączona z pierwszą w oryginale) pokazuje, co pozostaje dla globalnego magazynu: mały obiekt sesji.
// ✅ Server state managed by a tool built for it
const { data, refetch } = useQuery({
queryKey: ['products', category],
queryFn: () => fetchProducts(category),
staleTime: 60_000,
});// ✅ Truly shared client state — small and intentional
const useSession = create<SessionStore>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
Pozostający problem jest znacznie mniejszy. Pola formularza, przełączniki oraz wartości animacji pozostają w komponencie, gdzie są mało kosztowne, izolowane i znikają wraz z ekranem. Globalny magazyn powinien przechowywać tylko te dane, których potrzebuje kilka ekranów i które należą do samego klienta – na przykład zalogowanego użytkownika, temat, flagi funkcjonalne lub stan interfejsu obejmujący kilka ekranów.
Co się psuje, gdy wszystko jest globalne
Aktualizacje ponownie renderują niespowiązane ekrany, każda funkcjonalność jest powiązana z strukturą magazynu, testy muszą symulować otoczenie, a refaktoryzacja przypomina wykopy archeologiczne. Przerostły magazyn wygląda jak architektura, ale zachowuje się jak dług.
Diagnozowanie wolnego działania FlatList bez domysłów
FlatList wydaje się wolny, mimo że API jest szybkie. Używanie React.memo wszędzie to domysły; właściwym podejściem jest najpierw pomiary.
Profil z React DevTools (lub biblioteką why-did-you-render) podczas przewijania. Gdy wszystkie wiersze są ponownie renderowane przy każdym ruchu przewijania, przyczyną jest tożsamość referencyjna: funkcje strzałkowe umieszczone bezpośrednio w renderItem, obiekty stylów budowane na nowo przy każdym renderowaniu lub brak keyExtractor, co zmusza do ponownego montowania elementów. W tym przypadku każde renderowanie tworzy nowy zamykacz onPress oraz obiekt style, więc żaden wiersz nie może pominąć procesu renderowania.
// ❌ New function + new object on EVERY render → every row re-renders
<FlatList
data={items}
renderItem={({ item }) => (
<Row item={item} onPress={() => open(item.id)} style={{ padding: 12 }} />
)}
/>
Rozwiązanie zapewnia React stabilne referencje: zmemorizowany element Row, funkcja renderItem otoczona useCallback, stabilny klucz oraz funkcja getItemLayout, dzięki czemu lista nigdy nie mierzy wysokości wierszy. Typowane właściwości sprawiają, że kod jest w formacie TSX.
// ✅ Stable identities + memoized rows
const Row = React.memo(({ item, onPress }: RowProps) => { /* … */ });const renderItem = useCallback(
({ item }: ListRenderItemInfo<Item>) => <Row item={item} onPress={handlePress} />,
[handlePress],
);<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
getItemLayout={(_, index) => ({
length: ROW_HEIGHT,
offset: ROW_HEIGHT * index,
index,
})}
/>
getItemLayout nadaje się tylko do wierszy o stałej wysokości; błędne wartości dla wierszy o zmiennych wysokościach powodują skoki i puste przerwy.
Czytanie monitora wydajności
Jeśli wyświetlanie wygląda dobrze, ale klatki nadal znikają, porównaj dwa wskaźniki z Perf Monitor:
- Niskie wartości JS FPS oznaczają przeciążenie wątku JavaScript, zwykle spowodowane intensywnym wykorzystaniem funkcji
renderItemlub brakiem ograniczeń w wywoływaniu funkcjionScroll. - Niskie wartości UI FPS wskazują na koszty związane z obsługą interfejsu, najczęściej obrazy. Dekodowanie zdjęć w pełnej rozdzielczości na miniatury o rozmiarze 80 punktów marnuje pamięć i klatki; lepiej zmieniać rozmiar obrazów po stronie serwera lub używać bibliotek takich jak
expo-imagelubFastImagez odpowiednimi wymiarami.
Dopiero wtedy dostosuj ustawienia za pomocą parametrów windowSize, removeClippedSubviews lub przenieś się na framework FlashList. Dostosowywanie właściwości listy przed analizą danych traktuje jedynie objawy problemu.
Zatrzymywanie awarii na tanich urządzeniach Android
Aplikacja, która działa płynnie na najnowszych telefonach flagowych, może zawiesić się na taniach telefonach Android, które posiadają większość użytkowników. Zacznij od analizy danych: Play Console lub narzędzia analityczne pokazują rzeczywisty skład urządzeń, który rzadko odpowiada telefonom używanym przez twoją zespół.
Zrób z urządzeń o niskich parametrach sprzętowych nieodłączną część codziennej pracy: trzymaj w zasięgu ręki fizyczne urządzenie z 2 do 3 GB pamięci RAM lub użyj Firebase Test Lab do testowania modeli zgodnie z danymi analitycznymi, tak jak to robi ten narzędzie.
gcloud firebase test android run \
--app app-release.apk \
--device model=a10,version=29 \
--device model=redmi9,version=30 # the phones in your analytics, not yours
Zawsze testuj wersje wydane. Wersje do debugowania ukrywają rzeczywistą wydajność, a Hermes w wersji wydanej zachowuje się inaczej niż podczas debugowania. Typowe problemy na słabym sprzęcie to presja pamięci spowodowana dużymi obrazami lub zbyt długimi listami, przeciążony wątek główny oraz awarie z powodu braku pamięci, których nigdy nie ma na telefonach high-end.
Eleganckie spowolnienie działania zamiast awarii
Możesz dostosować to doświadczenie do poszczególnych urządzeń. react-native-device-info podaje łączną ilość pamięci.
import DeviceInfo from 'react-native-device-info';
Dzięki niemu możesz oznaczyć urządzenia z mniej niż 3 GB jako niskopoziomowe, serwować miniatury zamiast pełnych obrazów oraz pominąć efekt rozmycia, paralaksę i intensywne animacje. Ponieważ getTotalMemory jest asynchroniczny, oblicz ten wskaźnik raz podczas uruchamiania aplikacji, zamiast czekać na to podczas renderowania.
const totalMemory = await DeviceInfo.getTotalMemory();
const isLowEnd = totalMemory < 3 * 1024 ** 3; // < 3 GB RAM<Image
source={{ uri: isLowEnd ? item.thumbUrl : item.fullResUrl }}
// skip blur, parallax and heavy animations on low-end devices
/>
Następnie działaj w sposób ostrożny: dziel raporty z Sentry lub Crashlytics według poziomu urządzeń, używaj stopniowych wdrożeń w Play Store (5%, 20%, 100%) i zatrzymaj się, zanim zła wersja aplikacji dotrze do wszystkich użytkowników. Celem nie jest wyeliminowanie całkowicie awarii, ale ich szybkie i tanie wykrywanie.
Bезpieczne przechowywanie tokenów autoryzacyjnych
AsyncStorage zapisuje dane nieszyfrowane na dysku: plik SQLite w systemie Android, zwykłe pliki sandbox w systemie iOS. Urządzenie z rootem lub jailbroken, złośliwy backup lub dostęp do systemu plików ujawniają tokeny bezpośrednio. Został stworzony do przechowywania ustawień, a nie tajemnic.
Tokeny powinny znajdować się w pamięci wspieranej sprzętowo, takiej jak Keychain w iOS i Keystore w Android, które są obsługiwane przez react-native-keychain.
import * as Keychain from 'react-native-keychain';
To wywołanie przechowuje zserializowane tokeny przy użyciu parametru WHEN_UNLOCKED_THIS_DEVICE_ONLY: są one dostępne tylko wtedy, gdy urządzenie jest odblokowane, i nigdy nie są przenoszone na inne urządzenie.
await Keychain.setGenericPassword('auth', JSON.stringify(tokens), {
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
});
Proces odświeżania, który radzi sobie z jednoczesnymi błędami 401
Ustaw okres ważności tokenów dostępu na kilka minut, a nie dni, wspieraj je tokenem odświeżania, który jest zastępowany za każdym razem po wymianie, i skoncentruj to w jednej warstwie autoryzacji, która odświeża się dokładnie raz, niezależnie od liczby nieudanych żądań. Umożliwia to wspólna obietnica.
let refreshing: Promise<string> | null = null;
Interceptor Axios ponownie rzuca błąd dla wszystkiego oprócz kodu 401. W przypadku 401, ??= uruchamia odświeżenie tylko wtedy, gdy żadne nie jest w trakcie wykonywania, więc równoczesne błędy czekają na tę samą obietnicę; finally ją resetuje, a oryginalny żądanie jest ponownie wysyłane z nowym tokenem. W oryginalnym kodzie kilka instrukcji umieszczono na jednej linii. Aby dowiedzieć się więcej, zobacz dlaczego równoczesne błędy 401 wylogowują użytkowników i jak to naprawić za pomocą pojedynczego odświeżenia.
api.interceptors.response.use(undefined, async (error) => {
if (error.response?.status !== 401) throw error; // Concurrent 401s all await the SAME refresh — no refresh storm
refreshing ??= refreshTokens().finally(() => (refreshing = null));
const newToken = await refreshing; error.config.headers.Authorization = `Bearer ${newToken}`;
return api.request(error.config); // retry the original request
});
Dwa dodatkowe szczegóły: nie przechowuj tokenów w dostępnym z poziomu JS globalnym stanie dłużej niż to konieczne, włącz pinning certyfikatów dla szczególnie wrażliwych API oraz nigdy nie loguj tokenów, ponieważ narzędzia do raportowania awarii z łatwością przechwytują nagłówki żądania. „Use SecureStore” to nazwa narzędzia; solidna odpowiedź powinna obejmować kwestie czasu trwania, odnawiania oraz sposobów radzenia sobie z błędami.
Zaktualizowanie dwuletniej aplikacji React Native
Aplikacja jest dwa lata w tyle, przerwy w działaniu nie są do przyjęcia, a nie ma budżetu na jej przepisanie.
Nigdy nie przechodź bezpośrednio na najnowszą wersję. Narzędzie React Native Upgrade Helper pokazuje dokładne różnice pomiędzy wersjami; postępuj jedną lub dwiema małymi wersjami na krok, zachowując możliwość kompilacji i dystrybucji aplikacji przez cały czas. Każdy taki krok stanowi zwykłą wersję wydania, co pozwala uniknąć przerw w działaniu.
Najpierw przeprowadź audyt zależności
Ulepszenia nie udają się z powodu starych, nierozwijanych bibliotek natywnych, a nie samego React Native. Przed pierwszym krokiem zidentyfikuj zależności, które blokują nową architekturę lub nowsze wymagania dotyczące Gradle i Xcode, oraz zastąp lub przenieś te porzucone rozwiązania.
Zautomatyzuj weryfikację każdego kroku
Zainstaluj testy end-to-end dla kluczowych procesów przed rozpoczęciem pracy, aby każdy krok mógł zostać sprawdzony w ciągu kilku minut, zamiast ręcznie przez dział QA. Ten proces Maestro loguje się za pomocą adresu e-mail z zmiennej środowiskowej i sprawdza, czy strony domowa, koszyk oraz proces płatności są dostępne.
# smoke-test.yaml — run with Maestro on every upgrade hop
appId: com.myapp
---
- launchApp
- tapOn: "Log in"
- inputText: ${EMAIL}
- tapOn: "Continue"
- assertVisible: "Home"
- tapOn: "Cart"
- assertVisible: "Checkout"
Prezentuj to przedsiębiorstwu jako sposób na zmniejszenie ryzyka, a nie refaktoryzację: każda opóźniona wersja zwiększa koszt kolejnego przymusowego aktualizowania spowodowanego regułami sklepu, deprecjacjami systemu operacyjnego oraz poprawkami bezpieczeństwa. Małe kroki przekształcają straszny projekt w serię nudnych wydań, a nudność jest tu celem.
Główne wnioski
- Decyduj, kto jest właścicielem każdego elementu danych; niech biblioteka zapytań zarządza stanem serwera i utrzymuje małą ilość danych globalnych.
- Profiluj przed optymalizacją: problemy z tożsamością, praca w wątkach JS oraz dekodowanie obrazów wymagają różnych rozwiązań.
- Testuj wersje produkcyjne na rzeczywistych urządzeniach użytkowników i wdrażaj je stopniowo.
- Traktuj tokeny jak system: bezpieczne przechowywanie, krótki czas trwania, rotacja oraz odświeżanie po jednym użyciu.
- Wdrażaj aktualizacje małymi, gotowymi do wysyłki krokami, wspieranymi przez automatyczne testy wstępne.