iOS-Animation und -Tuning für Effizienz
Veröffentlicht: 2022-03-11Beim Erstellen einer großartigen App geht es nicht nur um Aussehen oder Funktionalität, sondern auch darum, wie gut sie funktioniert. Obwohl sich die Hardwarespezifikationen mobiler Geräte in rasantem Tempo verbessern, können Apps, die schlecht funktionieren, bei jedem Bildschirmwechsel stottern oder wie eine Diashow scrollen, das Erlebnis des Benutzers ruinieren und zu Frustration führen. In diesem Artikel erfahren Sie, wie Sie die Leistung einer iOS-App messen und auf Effizienz optimieren. Für diesen Artikel erstellen wir eine einfache App mit einer langen Liste von Bildern und Texten.
Zum Testen der Leistung würde ich die Verwendung echter Geräte empfehlen. Wenn Sie ernsthaft Apps erstellen und sie für reibungslose iOS-Animationen optimieren möchten, reichen Simulatoren einfach nicht aus. Simulationen können manchmal von der Realität abweichen. Beispielsweise kann der Simulator auf Ihrem Mac ausgeführt werden, was wahrscheinlich bedeutet, dass die CPU (Central Processing Unit) weitaus leistungsfähiger ist als die CPU auf Ihrem iPhone. Umgekehrt ist die GPU (Graphics Processing Unit) zwischen Ihrem Gerät und Ihrem Mac so unterschiedlich, dass Ihr Mac tatsächlich die GPU des Geräts emuliert. Infolgedessen sind CPU-gebundene Vorgänge auf Ihrem Simulator tendenziell schneller, während GPU-gebundene Vorgänge tendenziell langsamer sind.
Animation mit 60 FPS
Ein wichtiger Aspekt der wahrgenommenen Leistung besteht darin, sicherzustellen, dass Ihre Animationen mit 60 FPS (Frames pro Sekunde) ausgeführt werden, was der Aktualisierungsrate Ihres Bildschirms entspricht. Es gibt einige zeitgesteuerte Animationen, die wir hier nicht besprechen werden. Im Allgemeinen sieht Ihre App flüssig und leistungsfähig aus, wenn Sie mit mehr als 50 FPS arbeiten. Wenn Ihre Animationen zwischen 20 und 40 FPS hängen bleiben, wird es ein merkliches Stottern geben und der Benutzer wird eine „Rauigkeit“ in den Übergängen feststellen. Alles unter 20 FPS beeinträchtigt die Benutzerfreundlichkeit Ihrer App erheblich.
Bevor wir beginnen, lohnt es sich wahrscheinlich, den Unterschied zwischen CPU-gebundenen und GPU-gebundenen Operationen zu diskutieren. Die GPU ist ein spezialisierter Chip, der für das Zeichnen von Grafiken optimiert ist. Während die CPU das auch kann, ist sie viel langsamer. Aus diesem Grund möchten wir möglichst viel von unserem Grafik-Rendering, dem Prozess der Bilderzeugung aus einem 2D- oder 3D-Modell, auf die GPU verlagern. Aber wir müssen vorsichtig sein, denn wenn der GPU die Rechenleistung ausgeht, verschlechtert sich die grafikbezogene Leistung, selbst wenn die CPU relativ frei ist.
Core Animation ist ein leistungsstarkes Framework, das Animationen sowohl innerhalb als auch außerhalb Ihrer App verarbeitet. Es unterteilt den Prozess in 6 Hauptschritte:
Layout: Hier ordnen Sie Ihre Ebenen an und legen ihre Eigenschaften wie Farbe und relative Position fest
Anzeige: Hier werden die Hintergrundbilder auf einen Kontext gezeichnet. Auf jede Routine, die Sie in
drawRect:
oderdrawLayer:inContext:
, wird hier zugegriffen.Vorbereiten: In dieser Phase führt Core Animation einige notwendige Aufgaben aus, wie z.
Commit: Hier sendet Core Animation all diese Daten an den Renderserver.
Deserialisierung: Die vorherigen 4 Schritte waren alle innerhalb Ihrer App, jetzt wird die Animation außerhalb Ihrer App verarbeitet, die gepackten Ebenen werden in einen Baum deserialisiert, den der Renderserver verstehen kann. Alles wird in OpenGL-Geometrie umgewandelt.
Zeichnen: Rendert die Formen (eigentlich Dreiecke).
Sie haben vielleicht erraten, dass die Prozesse 1-4 CPU-Operationen und 5-6 GPU-Operationen sind. In Wirklichkeit haben Sie nur die Kontrolle über die ersten 2 Schritte. Der größte Killer der GPU sind halbtransparente Ebenen, bei denen die GPU dasselbe Pixel mehrmals pro Frame füllen muss. Auch jede Zeichnung außerhalb des Bildschirms (mehrere Ebeneneffekte wie Schatten, Masken, abgerundete Ecken oder Ebenenrasterung zwingen Core Animation dazu, außerhalb des Bildschirms zu zeichnen) wirkt sich ebenfalls auf die Leistung aus. Bilder, die zu groß sind, um von der GPU verarbeitet zu werden, werden stattdessen von der viel langsameren CPU verarbeitet. Während Schatten leicht durch das Festlegen von zwei Eigenschaften direkt auf der Ebene erreicht werden können, können sie die Leistung leicht beeinträchtigen, wenn Sie viele Objekte mit Schatten auf dem Bildschirm haben. Manchmal lohnt es sich, diese Schatten als Bilder hinzuzufügen.
Messung der iOS-Animationsleistung
Wir beginnen mit einer einfachen App mit 5 PNG-Bildern und einer Tabellenansicht. In dieser App laden wir im Wesentlichen 5 Bilder, wiederholen dies jedoch über 10.000 Zeilen. Wir werden sowohl den Bildern als auch den Beschriftungen neben den Bildern Schatten hinzufügen:
-(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; }
Bilder werden einfach recycelt, Etiketten sind immer anders. Das Ergebnis ist:
Wenn Sie vertikal wischen, werden Sie sehr wahrscheinlich ein Stottern bemerken, wenn die Ansicht scrollt. An dieser Stelle denken Sie vielleicht, dass die Tatsache, dass wir Bilder in den Haupt-Thread laden, das Problem ist. Vielleicht würden alle unsere Probleme gelöst, wenn wir dies in den Hintergrundthread verschieben würden.
Anstatt blind zu raten, probieren wir es aus und messen die Leistung. Es ist Zeit für Instrumente.
Um Instruments zu verwenden, müssen Sie von „Run“ auf „Profile“ wechseln. Und Sie sollten auch mit Ihrem realen Gerät verbunden sein, nicht alle Instrumente sind auf dem Simulator verfügbar (ein weiterer Grund, warum Sie die Leistung nicht auf dem Simulator optimieren sollten!). Wir werden hauptsächlich die Vorlagen „GPU Driver“, „Core Animation“ und „Time Profiler“ verwenden. Eine wenig bekannte Tatsache ist, dass Sie, anstatt anzuhalten und auf einem anderen Instrument zu laufen, mehrere Instrumente ziehen und ablegen und mehrere gleichzeitig ausführen können.
Jetzt, da wir unsere Instrumente eingerichtet haben, lassen Sie uns messen. Lassen Sie uns zuerst sehen, ob wir wirklich ein Problem mit unseren FPS haben.
Huch, ich glaube, wir bekommen hier 18 FPS. Ist das Laden von Bildern aus dem Bundle im Hauptthread wirklich so teuer und kostspielig? Beachten Sie, dass unsere Renderer-Auslastung fast ausgeschöpft ist. So auch unsere Fliesenlegerauslastung. Beide liegen über 95%. Und das hat nichts mit dem Laden eines Bildes aus dem Bundle im Hauptthread zu tun, also suchen wir hier nicht nach Lösungen.
Tuning für Effizienz
Es gibt eine Eigenschaft namens shouldRasterize, und die Leute werden Ihnen wahrscheinlich empfehlen, sie hier zu verwenden. Was genau macht shouldRasterize? Es speichert Ihre Ebene als abgeflachtes Bild. All diese teuren Ebenenzeichnungen müssen einmal ausgeführt werden. Falls sich Ihr Frame häufig ändert, nützt ein Cache nichts, da er sowieso jedes Mal neu generiert werden muss.
Wenn wir unseren Code schnell ändern, erhalten wir:
-(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; }
Und wir messen noch einmal:

Mit nur zwei Zeilen haben wir unsere FPS um das Doppelte verbessert. Wir liegen jetzt im Durchschnitt bei über 40 FPS. Aber würde es helfen, wenn wir das Laden von Bildern in einen Hintergrund-Thread verschoben hätten?
-(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; }
Beim Messen sehen wir, dass die Leistung im Durchschnitt bei 18 FPS liegt:
Nichts, was sich zu feiern lohnt. Mit anderen Worten, es hat unsere Bildrate nicht verbessert. Denn obwohl das Verstopfen des Haupt-Threads falsch ist, war es nicht unser Engpass, sondern das Rendern.
Zurück zum besseren Beispiel, bei dem wir im Durchschnitt über 40 FPS lagen, ist die Leistung deutlich flüssiger. Aber wir können es tatsächlich besser machen.
Wenn wir im Core Animation Tool „Color Blended Layers“ überprüfen, sehen wir:
„Color Blended Layers“ zeigt auf dem Bildschirm an, wo Ihre GPU viel Rendering durchführt. Grün zeigt die geringste Rendering-Aktivität an, während Rot die größte anzeigt. Aber wir haben shouldRasterize auf YES
gesetzt. Es sei darauf hingewiesen, dass „Color Blended Layers“ nicht dasselbe ist wie „Color Hits Green and Misses Red“. Letzteres hebt gerasterte Ebenen grundsätzlich rot hervor, wenn der Cache neu generiert wird (ein gutes Tool, um festzustellen, ob Sie den Cache nicht richtig verwenden). Die Einstellung von shouldRasterize auf YES
hat keine Auswirkung auf das anfängliche Rendern von nicht deckenden Ebenen.
Dies ist ein wichtiger Punkt, und wir müssen einen Moment innehalten, um nachzudenken. Unabhängig davon, ob shouldRasterize auf YES
gesetzt ist oder nicht, muss das Framework zum Rendern alle Ansichten überprüfen und mischen (oder nicht), je nachdem, ob Unteransichten transparent oder undurchsichtig sind. Während es sinnvoll sein könnte, dass Ihr UILabel nicht undurchsichtig ist, ist es möglicherweise wertlos und beeinträchtigt Ihre Leistung. Zum Beispiel ist ein transparentes UILabel auf weißem Hintergrund wahrscheinlich wertlos. Machen wir es undurchsichtig:
Dies führt zu einer besseren Leistung, aber unser Erscheinungsbild der App hat sich geändert. Da unser Etikett und unsere Bilder undurchsichtig sind, hat sich der Schatten um unser Bild bewegt. Diese Änderung wird wahrscheinlich niemandem gefallen, und wenn wir das ursprüngliche Erscheinungsbild mit erstklassiger Leistung bewahren wollen, sind wir nicht verzweifelt.
Um einige zusätzliche FPS herauszupressen und gleichzeitig das ursprüngliche Aussehen zu bewahren, ist es wichtig, zwei unserer Kernanimationsphasen, die wir bisher vernachlässigt haben, noch einmal zu besuchen.
- Bereiten
- Begehen
Diese scheinen völlig aus unserer Hand zu liegen, aber das ist nicht ganz richtig. Wir wissen, dass ein Bild zum Laden dekomprimiert werden muss. Die Dekompressionszeit ändert sich je nach Bildformat. Bei PNGs ist die Dekomprimierung viel schneller als bei JPEGs (obwohl das Laden länger dauert und dies auch von der Bildgröße abhängt), also waren wir auf dem richtigen Weg, PNGs zu verwenden, aber wir tun nichts gegen den Dekomprimierungsprozess und diese Dekomprimierung findet am „Point of Drawing“ statt! Dies ist der denkbar schlechteste Ort, an dem wir die Zeit totschlagen können - im Hauptthread.
Es gibt eine Möglichkeit, die Dekompression zu erzwingen. Wir könnten es sofort auf die Bildeigenschaft eines UIImageView setzen. Aber das dekomprimiert immer noch das Bild im Hauptthread. Gibt es einen besseren Weg?
Da ist einer. Zeichnen Sie es in einen CGContext, wo das Bild dekomprimiert werden muss, bevor es gezeichnet werden kann. Wir können dies (unter Verwendung der CPU) in einem Hintergrundthread tun und ihm je nach Größe unserer Bildansicht Grenzen setzen. Dies optimiert unseren Bildzeichnungsprozess, indem es außerhalb des Hauptthreads ausgeführt wird, und erspart uns unnötige „Vorbereitungs“-Berechnungen im Hauptthread.
Wenn wir schon dabei sind, warum fügen Sie nicht die Schatten hinzu, während wir das Bild zeichnen? Wir können das Bild dann als ein statisches, undurchsichtiges Bild erfassen (und zwischenspeichern). Der Code lautet wie folgt:
- (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; }
Und schlussendlich:
-(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; }
Und die Ergebnisse sind:
Wir liegen jetzt im Durchschnitt bei über 55 FPS, und unsere Render- und Tiler-Nutzung sind fast halb so hoch wie ursprünglich.
Einpacken
Nur für den Fall, dass Sie sich gefragt haben, was wir sonst noch tun können, um ein paar Bilder pro Sekunde mehr herauszuholen, suchen Sie nicht weiter. UILabel verwendet WebKit-HTML zum Rendern von Text. Wir können direkt zu CATextLayer gehen und vielleicht auch dort mit den Schatten spielen.
Sie haben vielleicht bemerkt, dass wir in unserer obigen Implementierung das Bild nicht in einem Hintergrund-Thread geladen haben, sondern es zwischengespeichert haben. Da es nur 5 Bilder gab, funktionierte dies sehr schnell und schien die Gesamtleistung nicht zu beeinträchtigen (insbesondere, da alle 5 Bilder vor dem Scrollen auf den Bildschirm geladen wurden). Aber Sie können versuchen, diese Logik für zusätzliche Leistung in einen Hintergrundthread zu verschieben.
Die Optimierung der Effizienz macht den Unterschied zwischen einer Weltklasse-App und einer Amateur-App aus. Die Leistungsoptimierung, insbesondere wenn es um iOS-Animationen geht, kann eine entmutigende Aufgabe sein. Aber mit Hilfe von Instruments kann man die Engpässe in der Animationsleistung auf iOS leicht diagnostizieren.