Animazione iOS e ottimizzazione per l'efficienza
Pubblicato: 2022-03-11La creazione di un'app eccezionale non riguarda solo l'aspetto o la funzionalità, ma anche le sue prestazioni. Sebbene le specifiche hardware dei dispositivi mobili stiano migliorando rapidamente, le app che funzionano male, balbettano ad ogni transizione dello schermo o scorrono come una presentazione possono rovinare l'esperienza dell'utente e diventare motivo di frustrazione. In questo articolo vedremo come misurare le prestazioni di un'app iOS e ottimizzarla per l'efficienza. Ai fini di questo articolo, costruiremo una semplice app con un lungo elenco di immagini e testi.
Ai fini del test delle prestazioni, consiglierei l'uso di dispositivi reali. Se sei seriamente intenzionato a creare app e ottimizzarle per un'animazione iOS fluida, i simulatori semplicemente non lo tagliano. Le simulazioni a volte possono non essere al passo con la realtà. Ad esempio, il simulatore potrebbe essere in esecuzione sul tuo Mac, il che probabilmente significa che la CPU (Central Processing Unit) è molto più potente della CPU del tuo iPhone. Al contrario, la GPU (Graphics Processing Unit) è così diversa tra il tuo dispositivo e il tuo Mac che il tuo Mac emula effettivamente la GPU del dispositivo. Di conseguenza, le operazioni legate alla CPU tendono ad essere più veloci sul simulatore mentre le operazioni legate alla GPU tendono ad essere più lente.
Animazione a 60 FPS
Un aspetto chiave delle prestazioni percepite è assicurarsi che le animazioni funzionino a 60 FPS (fotogrammi al secondo), che è la frequenza di aggiornamento dello schermo. Ci sono alcune animazioni basate su timer, di cui non parleremo qui. In generale, se stai girando a qualcosa di superiore a 50 FPS, la tua app apparirà fluida e performante. Se le tue animazioni sono bloccate tra 20 e 40 FPS, ci sarà una notevole balbuzie e l'utente rileverà una "ruvidità" nelle transizioni. Qualsiasi valore inferiore a 20 FPS influirà gravemente sull'usabilità della tua app.
Prima di iniziare, probabilmente vale la pena discutere la differenza tra le operazioni legate alla CPU e quelle legate alla GPU. La GPU è un chip specializzato ottimizzato per disegnare grafica. Anche se la CPU può farlo, è molto più lenta. Questo è il motivo per cui vogliamo scaricare gran parte del nostro rendering grafico, il processo di generazione di un'immagine da un modello 2D o 3D, sulla GPU. Ma dobbiamo stare attenti, poiché quando la GPU esaurisce la potenza di elaborazione, le prestazioni relative alla grafica si degraderanno anche se la CPU è relativamente libera.
Core Animation è un potente framework che gestisce l'animazione sia all'interno dell'app che all'esterno. Suddivide il processo in 6 passaggi chiave:
Layout: dove disponi i tuoi livelli e ne imposti le proprietà, come il colore e la loro posizione relativa
Display: qui è dove le immagini di sfondo vengono disegnate su un contesto. Qualsiasi routine che hai scritto in
drawRect:
odrawLayer:inContext:
è accessibile qui.Preparazione: in questa fase Core Animation, mentre sta per inviare il contesto al renderer su cui attingere, esegue alcune attività necessarie come decomprimere le immagini.
Commit: qui Core Animation invia tutti questi dati al server di rendering.
Deserializzazione: i 4 passaggi precedenti erano tutti all'interno dell'app, ora l'animazione viene elaborata all'esterno dell'app, i livelli del pacchetto vengono deserializzati in un albero che il server di rendering può comprendere. Tutto viene convertito in geometria OpenGL.
Disegna: rende le forme (in realtà triangoli).
Potresti aver intuito che i processi 1-4 sono operazioni della CPU e 5-6 sono operazioni della GPU. In realtà hai solo il controllo sui primi 2 passaggi. Il più grande killer della GPU sono i livelli semitrasparenti in cui la GPU deve riempire lo stesso pixel più volte per fotogramma. Anche qualsiasi disegno fuori schermo (diversi effetti di livello come ombre, maschere, angoli arrotondati o rasterizzazione del livello forzeranno Core Animation a disegnare fuori schermo) influirà anche sulle prestazioni. Le immagini troppo grandi per essere elaborate dalla GPU verranno invece elaborate dalla CPU molto più lenta. Sebbene le ombre possano essere facilmente ottenute impostando due proprietà direttamente sul livello, possono facilmente compromettere le prestazioni se sullo schermo sono presenti molti oggetti con ombre. A volte vale la pena considerare di aggiungere queste ombre come immagini.
Misurare le prestazioni dell'animazione iOS
Inizieremo con una semplice app con 5 immagini PNG e una vista tabella. In questa app, caricheremo essenzialmente 5 immagini, ma le ripeteremo su 10.000 righe. Aggiungeremo ombre sia alle immagini che alle etichette accanto alle immagini:
-(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; }
Le immagini vengono semplicemente riciclate mentre le etichette sono sempre diverse. Il risultato è:
Quando scorri in verticale, è molto probabile che noterai una balbuzie mentre la vista scorre. A questo punto potresti pensare che il fatto che stiamo caricando le immagini nel thread principale sia il problema. Può essere se spostassimo questo nel thread in background, tutti i nostri problemi sarebbero risolti.
Invece di fare supposizioni cieche, proviamolo e misuriamo le prestazioni. È tempo di strumenti.
Per utilizzare gli strumenti, devi passare da "Esegui" a "Profilo". E dovresti anche essere connesso al tuo dispositivo reale, non tutti gli strumenti sono disponibili sul simulatore (un altro motivo per cui non dovresti ottimizzare le prestazioni sul simulatore!). Utilizzeremo principalmente i modelli "GPU Driver", "Core Animation" e "Time Profiler". Un fatto poco noto è che invece di fermarsi e correre su uno strumento diverso, puoi trascinare e rilasciare più strumenti ed eseguirne diversi contemporaneamente.
Ora che abbiamo impostato i nostri strumenti, misuriamo. Per prima cosa vediamo se abbiamo davvero un problema con il nostro FPS.
Yikes, penso che stiamo ottenendo 18 FPS qui. Il caricamento delle immagini dal bundle sul thread principale è davvero così costoso e costoso? Si noti che l'utilizzo del nostro renderer è quasi al massimo. Così è il nostro utilizzo del piastrellista. Entrambi sono superiori al 95%. E questo non ha nulla a che fare con il caricamento di un'immagine dal bundle sul thread principale, quindi non cerchiamo soluzioni qui.
Sintonizzazione per l'efficienza
C'è una proprietà chiamata shouldRasterize e le persone probabilmente ti consiglieranno di usarla qui. Cosa dovrebbe fare esattamente Rasterize? Memorizza nella cache il tuo livello come un'immagine appiattita. Tutti quei costosi disegni a strati devono essere eseguiti una volta sola. Nel caso in cui il tuo frame cambi frequentemente, non serve una cache, poiché dovrà comunque essere rigenerata ogni volta.
Apportando una rapida modifica al nostro codice, otteniamo:
-(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; }
E misuriamo ancora:
Con solo due linee, abbiamo migliorato il nostro FPS di 2 volte. Ora abbiamo una media superiore a 40 FPS. Ma sarebbe d'aiuto se avessimo spostato il caricamento dell'immagine su un thread in background?

-(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; }
Dopo la misurazione, vediamo che le prestazioni sono in media di circa 18 FPS:
Niente che valga la pena festeggiare. In altre parole, non ha apportato alcun miglioramento al nostro frame rate. Questo perché anche se intasare il thread principale è sbagliato, non era il nostro collo di bottiglia, il rendering lo era.
Tornando all'esempio migliore, in cui avevamo una media superiore a 40 FPS, le prestazioni sono notevolmente più fluide. Ma possiamo effettivamente fare di meglio.
Controllando "Livelli miscelati colore" sullo strumento di animazione principale, vediamo:
"Color Blended Layers" mostra sullo schermo dove la tua GPU sta eseguendo molto rendering. Il verde indica la quantità minima di attività di rendering mentre il rosso indica la maggior parte. Ma abbiamo impostato shouldRasterize su YES
. Vale la pena sottolineare che "Color Blended Layers" non è lo stesso di "Color Hits Green and Misses Red". Il successivo evidenzia fondamentalmente i livelli rasterizzati in rosso mentre la cache viene rigenerata (un buon strumento per vedere se non stai usando la cache correttamente). L'impostazione di shouldRasterize su YES
non ha effetto sul rendering iniziale dei livelli non opachi.
Questo è un punto importante e dobbiamo fermarci un momento a pensare. Indipendentemente dal fatto che shouldRasterize sia impostato su YES
o meno, per eseguire il rendering del framework è necessario controllare tutte le viste e unirle (o meno) in base al fatto che le viste secondarie siano trasparenti o opache. Anche se potrebbe avere senso che la tua UILabel non sia opaca, potrebbe essere inutile e uccidere le tue prestazioni. Ad esempio, una UILabel trasparente su sfondo bianco è probabilmente inutile. Rendiamolo opaco:
Ciò produce prestazioni migliori, ma il nostro aspetto e la sensazione dell'app sono cambiati. Ora, poiché la nostra etichetta e le immagini sono opache, l'ombra si è spostata attorno alla nostra immagine. Nessuno probabilmente apprezzerà questo cambiamento, e se vogliamo preservare l'aspetto originale con prestazioni di prim'ordine non siamo persi di speranza.
Per spremere alcuni FPS extra preservando l'aspetto originale, è importante rivisitare due delle nostre fasi di animazione principale che abbiamo trascurato finora.
- Preparare
- Commettere
Questi possono sembrare completamente fuori dalle nostre mani, ma non è del tutto vero. Sappiamo che per caricare un'immagine deve essere decompressa. Il tempo di decompressione cambia a seconda del formato dell'immagine. Per i PNG la decompressione è molto più veloce dei JPEG (sebbene il caricamento sia più lungo e questo dipende anche dalle dimensioni dell'immagine), quindi eravamo sulla buona strada per usare i PNG, ma non stiamo facendo nulla per il processo di decompressione e questa decompressione sta accadendo al "punto di disegno"! Questo è il peggior posto possibile in cui possiamo ammazzare il tempo - sul thread principale.
C'è un modo per forzare la decompressione. Potremmo impostarlo subito sulla proprietà dell'immagine di un UIImageView. Ma ciò decomprime comunque l'immagine sul thread principale. C'è un modo migliore?
C'è uno. Disegnalo in un CGContext, in cui l'immagine deve essere decompressa prima di poter essere disegnata. Possiamo farlo (usando la CPU) in un thread in background e dargli i limiti necessari in base alle dimensioni della nostra visualizzazione dell'immagine. Ciò ottimizzerà il nostro processo di disegno delle immagini facendolo fuori dal thread principale e ci salverà da calcoli di "preparazione" non necessari sul thread principale.
Già che ci siamo, perché non aggiungere le ombre mentre disegniamo l'immagine? Possiamo quindi catturare l'immagine (e memorizzarla nella cache) come un'immagine statica e opaca. Il codice è il seguente:
- (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; }
E infine:
-(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; }
E i risultati sono:
Ora abbiamo una media di oltre 55 FPS e il nostro utilizzo del rendering e dell'utilizzo del tiler sono quasi la metà di quello che erano originariamente.
Incartare
Nel caso ti stessi chiedendo cos'altro possiamo fare per ottenere qualche fotogramma in più al secondo, non cercare oltre. UILabel utilizza WebKit HTML per eseguire il rendering del testo. Possiamo andare direttamente su CATextLayer e magari giocare con le ombre anche lì.
Potresti aver notato nella nostra implementazione di cui sopra, non stavamo eseguendo il caricamento dell'immagine in un thread in background e invece lo stavamo memorizzando nella cache. Dato che c'erano solo 5 immagini, questo ha funzionato molto velocemente e non sembrava influenzare le prestazioni complessive (soprattutto perché tutte e 5 le immagini sono state caricate sullo schermo prima dello scorrimento). Ma potresti provare a spostare questa logica in un thread in background per prestazioni extra.
L'ottimizzazione dell'efficienza è la differenza tra un'app di livello mondiale e una amatoriale. L'ottimizzazione delle prestazioni, soprattutto quando si tratta di animazioni iOS, può essere un compito arduo. Ma con l'aiuto di Instruments, è possibile diagnosticare facilmente i colli di bottiglia nelle prestazioni di animazione su iOS.