Wprowadzenie

W tym artykule skupię się na temacie dość często pomijanym przez programistów React Native, a mianowicie – wydajności.

React Native jest dość wydajny i jego domyślne możliwości są wystarczające w większości scenariuszy, ale jeśli zamierzasz na przykład stworzyć w nim grę, prawdopodobnie powinieneś sięgnąć głębiej i zrobić wszystko, aby Twój produkt działał płynnie.

Architektura React Native

Przede wszystkim powinieneś znać podstawy działania RN. Posiada on trzy podstawowe wątki:

  • UI – główny wątek aplikacji RN. Jest odpowiedzialny za renderowanie widoków natywnych, odpowiednich dla platformy, dla której budujesz.
  • JS – tutaj działa cała Twoja logika biznesowa
  • Shadow – wątek odpowiedzialny za obliczanie layoutu na podstawie danych JSON podanych przez wątek JS. Następnie przetworzone dane są wysyłane do wątku UI, który używa ich do renderowania komponentów natywnych na ekranie. Należy wspomnieć, że komponenty takie nie używają stylów opartych na flexie, więc one również muszą zostać tutaj przekonwertowane. Dzieje się to za pomocą silnika Yoga od Facebooka.

Istnieją również moduły natywne, które w systemie iOS mają przypisane po jednym wątku do każdego modułu, w Androidzie natomiast współdzielą jeden wątek systemowy.

Komunikacja pomiędzy JavaScriptem a stroną natywną odbywa się przez asynchroniczny most. Rozważmy przykład – programista chce, aby przycisk przełączał kolor tła z czerwonego na niebieski po każdym jego naciśnięciu. Schemat komunikacji wyglądałby następująco:

  1. JS chce wyrenderować czerwony przycisk z funkcją handlePress przypisaną do parametru onPress
  2. JS serializuje dane i wysyła je poprzez most do wątku Shadow
  3. Wątek Shadow oblicza layout i wysyła te obliczone dane do wątku UI, który renderuje przycisk na ekranie
  4. Wątek UI przesyła zserializowane powiadomienie o udanym procesie renderowania do JS (oczywiście przez most!)
  5. Użytkownik klika w przycisk
  6. Wątek UI poprzez most uruchamia callback handlePress
  7. handlePress zmienia kolor przycisku na niebieski po stronie JS i uruchamia proces renderowania jaki opisano w punktach 1, 2, 3 i 4

Teraz wiesz, jak RN działa pod maską. Przejdźmy do tematu i omówmy kilka praktycznych luk w jego wydajności.

Animacje

Rozważ przykład – chcesz wykonać animację layoutu w swojej aplikacji, aby poprawić odczucia użytkownika, ale po implementacji zauważyłeś, że zamiast działać bez zarzutu, w niektórych miejscach aplikacji okazuje się ją spowalniać. Jest to prawdopodobnie spowodowane tym, że zapomniałeś ustawić useNativeDriver na true!

Prawdopodobnie zapytasz teraz „dlaczego?”. Wyjaśnijmy więc najpierw, jak to działa, gdy useNativeDriver zostanie ustawione na false. W tym przypadku wszystkie wymagane obliczenia wykonywane są po stronie JS, a następnie ich wyniki są przesyłane do wątku UI przez natywny most i tak w każdej klatce. Tak więc, gdy Twoja aplikacja musi wykonać jakąś kosztowną logikę równolegle z animacjami, może to skutkować klatkowaniem animacji z powodu zablokowanego wątku JS.

Natywny sterownik przenosi wszystkie obliczenia do wątku UI. JS jest odpowiedzialny tylko za przesłanie wszystkich wymaganych danych przed rozpoczęciem animacji, resztę pracy wykonuje strona natywna.

Bezużyteczne przeładowania

Wyobraźmy sobie sytuację, w której deweloper, na potrzeby tego przykładu mający na imię Jon, został poproszony o udział w projekcie mającym na celu stworzenie gry ekonomicznej podobnej do SimCity. Po podstawowym planowaniu, podczas dystrybucji zadań, Jon otrzymał kluczowego taska polegającego na wdrożeniu funkcji renderowania mapy użytkownika. Zaczął od stworzenia komponentu Level, potem dodał kilka metod odpowiedzialnych za obliczanie pozycji budynków, a na końcu, w instrukcji return zmapował tablicę z danymi budynków na obrazki widoczne na ekranie. Jakiś czas po tym, został przeniesiony do innego projektu. Po pewnym czasie inny programista – Bob został poproszony o dodanie funkcjonalności umieszczania nowych budynków na mapie. Postanowił zrobić nową tablicę z danymi placów budowy, a następnie zmapować ją w Levelu zaledwie linijkę poniżej miejsca, w którym zmapowano zwykłe budynki. (W tym momencie prawdopodobnie już wiesz, co Bob zrobił źle) Podczas testów okazało się, że po umieszczeniu nowego placu budowy przez użytkownika, cała aplikacja ścinała się na co najmniej sekundę (czas zależał od wielkości mapy). Spowodowane to było tym, że cały Level musiał się przerenderować po każdej zmianie w tablicy placów budowy. A zatem Bob musiał spojrzeć na to jeszcze raz. W końcu przeniósł każde mapowanie do osobnego komponentu z własnym połączeniem ze storem i to rozwiązało problem. Level nie musiał dokonywać ponownego przeładowania, ponieważ nie było już w nim propsów związanych z budynkami lub placami budowy. Komponent poziomu wiedział tylko, że renderuje dwa inne komponenty, ale nie był świadomy, co robią i dlaczego.

Lekcja, którą możesz z tego wyciągnąć –  ​​zawsze powinieneś próbować rozdrobnić strukturę projektu do najmniejszych logicznie powiązanych części. Postaraj się, aby Twój kod był jak najbardziej ortogonalny. I oczywiście dokładnie przetestuj swój kod, zanim go wypchniesz :P.

Jeśli nie wiesz, co oznacza kod ortogonalny, poczytaj trochę o paradygmacie programowania aspektowego.

Naturą Reacta jest to, że kiedy zaktualizujesz komponent nadrzędny, komponenty potomne również są ponownie renderowane, ale możesz zmienić to zachowanie. Użyj React.PureComponent dla komponentów opartych na klasach lub React.memo dla komponentów funkcyjnych. PureComponent posiada własną implementację shouldComponentUpdate, która po płytkim porównaniu poprzednich i przyszłych propsów decyduje, czy zaktualizować komponent, czy nie. W przypadku memo nie jest to takie proste, ponieważ nie ma tutaj wbudowanej metody porównawczej, którą można użyć. Zamiast tego należy napisać ją samodzielnie. Ale jest na to rozwiązanie. Funkcja connect z reduxa ma domyślnie włączoną własną implementację shouldComponentUpdate. Więc jeśli używasz połączenia ze storem w swoim komponencie, nie musisz się tego obawiać.

Nawet jeśli użyto dowolnej implementacji shouldComponentUpdate, Twój kod może być narażony na niepotrzebne ponowne renderowanie. Jeśli przekażesz funkcję anonimową jako propsa do komponentu z zaimplementowaną funkcją shouldComponentUpdate, za każdym razem, gdy rodzic się aktualizuje, dziecko też to robi. Dzieje się tak, ponieważ przy każdym renderowaniu tworzona jest nowa funkcja. Płytkie porównanie w typie nieprymitywnym odbywa się przez referencję, więc (() => {}) === (() => {}) zawsze zwróci false i wywoła renderowanie.

W przypadku komponentów opartych na klasach rozwiązanie to wyglądałoby następująco:

W komponentach funkcjonalnych jest to trochę trudniejsze, ponieważ każda zagnieżdżona funkcja jest tworzona na nowo po każdym rerenderze rodzica. Są na to dwa rozwiązania:

  • po prostu przenieś funkcję poza komponent
  • przekaż swoją funkcję jako parametr do hooka useCallback

Oto przykład możliwości optymalizacji wydajności RN z naszego ostatniego projektu – Enter the game. Taka różnica polega na zmianie struktury projektu na bardziej ortogonalną i nieużywaniu funkcji anonimowych jako propsów.

 

Więcej o grze możesz przeczytać tutaj.

Redux wielokrotnie przeładowywujący selektory

Wyobraź sobie taki ekran:

Zakładam, że znasz podstawy przepływu danych i konfiguracji reduxa.

Mamy więc prosty ekran, połączony z reduxowym storem. Po naciśnięciu dowolnego przycisku wartość licznika odpowiednio się zmieni. Zauważ, że funkcja odpowiedzialna za inkrementację zawiera dwukrotne wywołanie funkcji dispatch.

Gdy sprawdzisz ten kod na przykład za pomocą debugera RN wbudowanego w Webstorm i dodasz breakpointy w wierszach zawierających return zarówno CounterScreen, jak i mapStateToProps, zobaczysz, że po naciśnięciu przycisku zatytułowanego “Increment” , mapStateToProps wykona się dwukrotnie (raz dla każdej wysłanej akcji), ale CounterScreen jest ponownie renderowany tylko raz. Od redux@7.0.1 dispatche są łączone w partie, aby zapobiec nadmiarowym renderowaniom, ale niestety po wykonaniu pojedynczej akcji nadal powiadamiany jest każdy wyrenderowany selektor w aplikacji (funkcja connect).

Aby temu zapobiec, możesz użyć zewnętrznej biblioteki rozszerzającej możliwości store, takiej jak redux-batched-subscribe

Pamiętaj, aby dodać batchedSubscribe jako ostatni argument funkcji compose, ponieważ wykonuje ona przekazane jej funkcje od prawej do lewej.

Po ponownym debugowaniu aplikacji zobaczysz, że po naciśnięciu “Increment”  mapStateToProps, wykona się tylko raz.

Hubert Bogdański

W Expansio od roku pracuje jako React Native developer. Mimo młodego wieku zaskakuje dużą wiedzą i zaangażowaniem w pracę. Interesuje się koszykówką, psychologią i nowymi technologiami.

Zostaw komentarz