Animacja i dostrajanie systemu iOS pod kątem wydajności
Opublikowany: 2022-03-11Tworzenie świetnej aplikacji to nie tylko wygląd i funkcjonalność, ale także to, jak dobrze działa. Chociaż specyfikacje sprzętowe urządzeń mobilnych poprawiają się w szybkim tempie, aplikacje, które działają słabo, zacinają się przy każdym przejściu ekranu lub przewijają się jak pokaz slajdów, mogą zrujnować wrażenia użytkownika i stać się przyczyną frustracji. W tym artykule zobaczymy, jak mierzyć wydajność aplikacji na iOS i dostrajać ją pod kątem wydajności. Na potrzeby tego artykułu zbudujemy prostą aplikację z długą listą obrazów i tekstów.
Na potrzeby testowania wydajności polecam korzystanie z prawdziwych urządzeń. Jeśli poważnie myślisz o tworzeniu aplikacji i optymalizowaniu ich pod kątem płynnej animacji na iOS, symulatory po prostu tego nie robią. Symulacje mogą czasem odbiegać od rzeczywistości. Na przykład symulator może działać na komputerze Mac, co prawdopodobnie oznacza, że procesor (centralna jednostka przetwarzania) jest znacznie mocniejszy niż procesor w iPhonie. I odwrotnie, procesor graficzny (Graphics Processing Unit) jest tak różny między twoim urządzeniem a komputerem Mac, że komputer Mac faktycznie emuluje procesor graficzny urządzenia. W rezultacie operacje związane z procesorem CPU są zwykle szybsze na symulatorze, podczas gdy operacje związane z procesorem GPU są zwykle wolniejsze.
Animacja przy 60 FPS
Jednym z kluczowych aspektów postrzeganej wydajności jest upewnienie się, że animacje działają z szybkością 60 klatek na sekundę (klatki na sekundę), co odpowiada częstotliwości odświeżania ekranu. Istnieje kilka animacji opartych na zegarze, których nie będziemy tutaj omawiać. Ogólnie rzecz biorąc, jeśli pracujesz z prędkością większą niż 50 FPS, Twoja aplikacja będzie wyglądać płynnie i wydajnie. Jeśli animacje utkną między 20 a 40 FPS, wystąpi zauważalne zacinanie się, a użytkownik wykryje „nierówność” w przejściach. Wszystko poniżej 20 FPS poważnie wpłynie na użyteczność Twojej aplikacji.
Zanim zaczniemy, prawdopodobnie warto omówić różnicę między operacjami związanymi z CPU i GPU. GPU to wyspecjalizowany układ zoptymalizowany do rysowania grafiki. Chociaż procesor też może, jest znacznie wolniejszy. Dlatego chcemy przenieść jak najwięcej naszego renderowania grafiki, procesu generowania obrazu z modelu 2D lub 3D, na GPU. Ale musimy być ostrożni, ponieważ gdy GPU wyczerpie się moc obliczeniowa, wydajność związana z grafiką spadnie, nawet jeśli procesor jest stosunkowo wolny.
Core Animation to potężna platforma, która obsługuje animacje zarówno w aplikacji, jak i poza nią. Dzieli proces na 6 kluczowych etapów:
Układ: miejsce, w którym rozmieszczasz warstwy i ustawiasz ich właściwości, takie jak kolor i ich względne położenie
Wyświetlacz: w tym miejscu obrazy tła są rysowane w kontekście. Każda procedura, którą napisałeś w
drawRect:
lubdrawLayer:inContext:
jest dostępna tutaj.Przygotowanie: na tym etapie Core Animation, który ma wysłać kontekst do renderera, aby mógł na nim rysować, wykonuje pewne niezbędne zadania, takie jak dekompresowanie obrazów.
Commit: Tutaj Core Animation wysyła wszystkie te dane do serwera renderowania.
Deserializacja: wszystkie poprzednie 4 kroki znajdowały się w Twojej aplikacji, teraz animacja jest przetwarzana poza Twoją aplikacją, spakowane warstwy są deserializowane w drzewo, które może zrozumieć serwer renderowania. Wszystko jest konwertowane na geometrię OpenGL.
Rysuj: renderuje kształty (właściwie trójkąty).
Mogłeś zgadnąć, że procesy 1-4 to operacje CPU, a 5-6 to operacje GPU. W rzeczywistości masz kontrolę tylko nad pierwszymi 2 krokami. Największym zabójcą GPU są półprzezroczyste warstwy, w których GPU musi wielokrotnie wypełniać ten sam piksel na klatkę. Również wszelkie rysowanie poza ekranem (kilka efektów warstw, takich jak cienie, maski, zaokrąglone rogi lub rasteryzacja warstw, zmusi Core Animation do rysowania poza ekranem) również wpłynie na wydajność. Obrazy, które są zbyt duże, aby mogły zostać przetworzone przez GPU, będą przetwarzane przez znacznie wolniejszy procesor. Chociaż cienie można łatwo uzyskać, ustawiając dwie właściwości bezpośrednio na warstwie, mogą one łatwo obniżyć wydajność, jeśli na ekranie znajduje się wiele obiektów z cieniami. Czasami warto rozważyć dodanie tych cieni jako obrazów.
Pomiar wydajności animacji na iOS
Zaczniemy od prostej aplikacji z 5 obrazami PNG i widokiem tabeli. W tej aplikacji zasadniczo załadujemy 5 obrazów, ale powtórzymy je w 10 000 wierszy. Dodamy cienie zarówno do obrazów, jak i do etykiet obok obrazów:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; return cell; }
Obrazy są po prostu poddawane recyklingowi, podczas gdy etykiety są zawsze inne. Wynik to:
Po przesunięciu w pionie najprawdopodobniej zauważysz zacinanie się podczas przewijania widoku. W tym momencie możesz pomyśleć, że problemem jest fakt, że ładujemy obrazy w głównym wątku. Być może, gdybyśmy przenieśli to na drugi wątek, wszystkie nasze problemy zostałyby rozwiązane.
Zamiast zgadywać na ślepo, wypróbujmy to i zmierzmy wydajność. Czas na instrumenty.
Aby korzystać z instrumentów, musisz zmienić „Uruchom” na „Profil”. Powinieneś także być podłączony do swojego prawdziwego urządzenia, nie wszystkie instrumenty są dostępne w symulatorze (kolejny powód, dla którego nie powinieneś optymalizować wydajności na symulatorze!). Będziemy używać przede wszystkim szablonów „GPU Driver”, „Core Animation” i „Time Profiler”. Mało znanym faktem jest to, że zamiast zatrzymywania się i uruchamiania na innym instrumencie, można przeciągać i upuszczać wiele instrumentów i uruchamiać kilka jednocześnie.
Teraz, gdy mamy już ustawione instrumenty, zmierzmy. Najpierw zobaczmy, czy naprawdę mamy problem z naszym FPS-em.
Hej, myślę, że mamy tu 18 FPS. Czy ładowanie obrazów z pakietu na głównym wątku jest naprawdę takie drogie i kosztowne? Zauważ, że nasze wykorzystanie renderera jest prawie maksymalne. Podobnie jak nasze wykorzystanie glazurników. Oba są powyżej 95%. A to nie ma nic wspólnego z ładowaniem obrazka z paczki na główny wątek, więc nie szukajmy tutaj rozwiązań.
Strojenie pod kątem wydajności
Istnieje właściwość o nazwie shouldRasterize i prawdopodobnie ludzie będą polecać jej użycie tutaj. Co dokładnie powinien robić powinien Rasterize? Buforuje twoją warstwę jako spłaszczony obraz. Wszystkie te drogie rysunki warstw muszą być wykonane raz. Jeśli twoja ramka często się zmienia, pamięć podręczna nie ma sensu, ponieważ i tak będzie musiała zostać zregenerowana za każdym razem.
Dokonując szybkiej poprawki do naszego kodu, otrzymujemy:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
I znowu mierzymy:

Za pomocą zaledwie dwóch linii poprawiliśmy nasz FPS o 2x. Teraz średnio powyżej 40 FPS. Ale czy pomogłoby, gdybyśmy przenieśli ładowanie obrazu do wątku w tle?
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; cell.tag = indexPath.row; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; dispatch_async(dispatch_get_main_queue(), ^{ if (indexPath.row == cell.tag) { cell.customCellImageView.image = image; } }); }); cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; // cell.layer.shouldRasterize = YES; // cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
Po zmierzeniu widzimy, że wydajność wynosi średnio około 18 FPS:
Nic wartego świętowania. Innymi słowy, nie poprawiło to naszej liczby klatek na sekundę. To dlatego, że chociaż zatykanie głównego wątku jest złe, to nie było to nasze wąskie gardło, renderowanie było.
Wracając do lepszego przykładu, w którym średnio przekraczaliśmy 40 klatek na sekundę, wydajność jest znacznie płynniejsza. Ale faktycznie możemy zrobić lepiej.
Zaznaczając „Warstwy mieszane z kolorami” w głównym narzędziu do animacji, widzimy:
„Warstwy mieszane kolorów” wyświetlają się na ekranie, gdzie twój GPU wykonuje dużo renderowania. Zielony oznacza najmniejszą aktywność renderowania, a czerwony oznacza największą. Ale ustawiliśmy opcję shouldRasterize na YES
. Warto zauważyć, że „Warstwy mieszane z kolorami” to nie to samo, co „Kolor trafia na zielono i chybia czerwony”. Później w zasadzie podświetla zrasteryzowane warstwy na czerwono, gdy pamięć podręczna jest regenerowana (dobre narzędzie, aby sprawdzić, czy nie używasz pamięci podręcznej prawidłowo). Ustawienie parametru shouldRasterize na YES
nie ma wpływu na początkowe renderowanie warstw nieprzezroczystych.
To ważna kwestia i musimy się na chwilę zatrzymać, aby pomyśleć. Niezależnie od tego, czy shouldRasterize jest ustawione na YES
, czy nie, aby renderować framework musi sprawdzić wszystkie widoki i mieszać (lub nie) na podstawie tego, czy podwidoki są przezroczyste czy nieprzezroczyste. Chociaż może mieć sens, aby Twoja etykieta UILabel była nieprzezroczysta, może być bezwartościowa i zabijać wydajność. Na przykład przezroczysty UILabel na białym tle jest prawdopodobnie bezwartościowy. Zróbmy to nieprzejrzyste:
Zapewnia to lepszą wydajność, ale zmienił się nasz wygląd i sposób działania aplikacji. Teraz, ponieważ nasza etykieta i obrazy są nieprzezroczyste, cień przesunął się wokół naszego obrazu. Prawdopodobnie nikt nie będzie zachwycony tą zmianą, a jeśli chcemy zachować oryginalny wygląd i zachowanie przy zachowaniu najwyższej klasy wydajności, nie tracimy nadziei.
Aby wycisnąć trochę więcej FPS, zachowując oryginalny wygląd, ważne jest, aby ponownie przejść do dwóch z naszych faz Core Animation, które do tej pory zaniedbaliśmy.
- Przygotowywać
- Popełniać
To może wydawać się całkowicie poza naszymi rękami, ale to nie do końca prawda. Wiemy, że aby załadować obraz, należy go zdekompresować. Czas dekompresji zmienia się w zależności od formatu obrazu. W przypadku plików PNG dekompresja jest znacznie szybsza niż w przypadku plików JPEG (chociaż ładowanie trwa dłużej i zależy to również od rozmiaru obrazu), więc byliśmy na dobrej drodze do korzystania z plików PNG, ale nie robimy nic z procesem dekompresji i tą dekompresją dzieje się w „punkcie rysowania”! To najgorsze możliwe miejsce, w którym możemy zabić czas - na głównym wątku.
Jest sposób na wymuszenie dekompresji. Możemy od razu ustawić go na właściwość obrazu UIImageView. Ale to wciąż dekompresuje obraz w głównym wątku. Czy jest lepszy sposób?
Jest jeden. Narysuj go do CGContext, w którym obraz musi zostać zdekompresowany, zanim będzie można go narysować. Możemy to zrobić (używając procesora) w wątku w tle i w razie potrzeby określić granice w oparciu o rozmiar naszego widoku obrazu. Zoptymalizuje to nasz proces rysowania obrazu, wykonując go poza głównym wątkiem, i oszczędzi nam niepotrzebnych „przygotowywania” obliczeń w głównym wątku.
Skoro już przy tym jesteśmy, dlaczego nie dodać cieni podczas rysowania obrazu? Możemy następnie przechwycić obraz (i zapisać go w pamięci podręcznej) jako jeden statyczny, nieprzezroczysty obraz. Kod wygląda następująco:
- (UIImage*)generateImageFromName:(NSString*)imageName { //define a boudns for drawing CGRect imgVwBounds = CGRectMake(0, 0, 48, 48); //get the image NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; //draw in the context UIGraphicsBeginImageContextWithOptions(imgVwBounds.size, NO, 0); { //get context CGContextRef context = UIGraphicsGetCurrentContext(); //shadow CGContextSetShadowWithColor(context, CGSizeMake(0, 3.0f), 3.0f, [UIColor blackColor].CGColor); CGContextBeginTransparencyLayer (context, NULL); [image drawInRect:imgVwBounds blendMode:kCGBlendModeNormal alpha:1.0f]; CGContextSetRGBStrokeColor(context, 1.0, 1.0, 1.0, 1.0); CGContextEndTransparencyLayer(context); } image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; }
I w końcu:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; // NSInteger index = (indexPath.row % [self.images count]); // NSString *imageName = [self.images objectAtIndex:index]; // // cell.tag = indexPath.row; // // dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; // UIImage *image = [UIImage imageWithContentsOfFile:filePath]; // // dispatch_async(dispatch_get_main_queue(), ^{ // if (indexPath.row == cell.tag) { // cell.customCellImageView.image = image; // } // }); // }); cell.customCellImageView.image = [self getImageByIndexPath:indexPath]; cell.customCellImageView.clipsToBounds = YES; // cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); // cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
A wyniki to:
Teraz średnio przekraczamy 55 klatek na sekundę, a nasze wykorzystanie renderowania i kafelków jest prawie o połowę mniejsze niż pierwotnie.
Zakończyć
Na wypadek, gdybyś zastanawiał się, co jeszcze możemy zrobić, aby zwiększyć liczbę klatek na sekundę, nie szukaj dalej. UILabel używa HTML WebKit do renderowania tekstu. Możemy przejść bezpośrednio do CATextLayer i być może również tam pobawić się cieniami.
Być może zauważyłeś w naszej powyższej implementacji, że nie ładowaliśmy obrazu w wątku w tle, a zamiast tego buforowaliśmy go. Ponieważ było tylko 5 obrazów, działało to bardzo szybko i nie wydawało się wpływać na ogólną wydajność (zwłaszcza, że wszystkie 5 obrazów zostało załadowanych na ekran przed przewijaniem). Ale możesz spróbować przenieść tę logikę do wątku w tle, aby uzyskać dodatkową wydajność.
Strojenie pod kątem wydajności to różnica między aplikacją światowej klasy a aplikacją amatorską. Optymalizacja wydajności, zwłaszcza jeśli chodzi o animację na iOS, może być trudnym zadaniem. Ale z pomocą Instruments można łatwo zdiagnozować wąskie gardła w wydajności animacji na iOS.