Wir haben bereits darüber geschrieben, wie wir die neue Architektur von React Native übernommen haben, um unsere Leistung zu steigern. Bevor wir darauf eingehen, wie wir Rückschritte erkennen, erklären wir zunächst, wie wir Leistung definieren.
Wichtige Leistungskennzahlen für mobile Anwendungen
In Browsern gibt es bereits eine Industriestandard-Metriken zur Messung der Leistung, bekannt als Core Web Vitals. Diese sind zwar nicht perfekt, konzentrieren sich aber auf die tatsächliche Auswirkung auf die Benutzererfahrung. Ähnliches wollten wir für Apps und haben daher App Render Complete und Navigation Total Blocking Time als unsere wichtigsten Kennzahlen übernommen.
- App Render Complete: Zeigt die Zeit an, die benötigt wird, um die App aus einem Kaltstart für einen authentifizierten Nutzer zu öffnen, bis sie vollständig geladen und interaktiv ist. Ähnlich dem Time To Interactive im Browser.
- Navigation Total Blocking Time: Die Zeit, in der die Anwendung während eines 2-Sekunden-Fensters nach einer Navigation blockiert ist. Es ist ein Proxy für die Gesamtreaktionsfähigkeit, ähnlich wie Interaction to Next Paint.
Wir erfassen noch viele andere Metriken – wie Renderzeiten, Bundle-Größen, Netzwerkanfragen, eingefrorene Frames, Speichernutzung etc. – aber diese zeigen eher, warum etwas schiefgelaufen ist, als wie unsere Nutzer unsere Apps wahrnehmen.
Ihr Vorteil gegenüber den umfassenderen ARC/NTBT-Kennzahlen liegt in ihrer Granularität und Bestimmtheit. Es ist zum Beispiel viel einfacher, zuverlässig zu erkennen, dass die Bundle-Größe gestiegen ist oder der Gesamtbandbreitenverbrauch gesunken ist. Das bedeutet aber nicht automatisch, dass unsere Nutzer einen spürbaren Unterschied bemerken.
Erfassen von Metriken
Am Ende ist uns wichtig, wie unsere Apps auf den tatsächlichen Geräten unserer Nutzer laufen, aber wir wollen auch wissen, wie eine App vor der Veröffentlichung funktioniert. Dafür nutzen wir die Performance API (über react-native-performance), die wir an Sentry für Real User Monitoring senden. In der Entwicklung wird dies von Rozenite unterstützt.
Wir wollten auch eine zuverlässige Möglichkeit, zwei verschiedene Builds zu vergleichen, um zu sehen, ob unsere Optimierungen wirken oder neue Funktionen die Leistung verschlechtern. Da Maestro bereits für unsere End-to-End-Testsuite verwendet wurde, erweiterten wir es einfach, um Leistungsbenchmarks in bestimmten Schlüsselbereichen zu sammeln.
Um Zufälligkeiten auszugleichen, führten wir denselben Flow auf verschiedenen Geräten in unserer CI mehrfach aus und berechneten die statistische Signifikanz für jede Metrik. Jetzt konnten wir jede Pull-Request mit unserem Hauptzweig vergleichen und deren Leistung bewerten. Leistungsverschlechterungen gehörten der Vergangenheit an.
Realitätscheck
In der Praxis erzielte dies aus mehreren Gründen nicht die gewünschten Ergebnisse. Zuerst stellten wir fest, dass die automatischen Benchmarks hauptsächlich genutzt wurden, wenn Entwickler eine Bestätigung wollten, dass ihre Optimierungen Wirkung hatten – was an sich wichtig und wertvoll ist – aber das geschah normalerweise, nachdem wir eine Verschlechterung im Real User Monitoring gesehen hatten, nicht davor.
Um dies zu beheben, begannen wir, Benchmarks zwischen den Release-Zweigen durchzuführen, um deren Leistung zu überprüfen. Das enttarnte zwar Verschlechterungen, war jedoch schwer zu beheben, da es eine Woche voller Änderungen zu durchforsten gab – etwas, was unsere Release-Manager nicht immer leisten konnten. Selbst wenn sie die Ursache fanden, war ein einfaches Rückgängigmachen oft keine Option.
Darüber hinaus war die App Render Complete-Metrik netzwerkabhängig und nicht deterministisch. Wenn die Server in dieser Stunde zusätzliche Last hatten oder ein Feature-Flag aktiviert wurde, beeinträchtigte das die Benchmarks, selbst wenn sich der Code nicht änderte, und machte die Berechnung der statistischen Signifikanz ungültig.
Präzision, Spezifizität und Varianz
Wir mussten unsere Strategie überdenken und waren mit drei großen Herausforderungen konfrontiert:
- Präzision: Selbst wenn wir eine Verschlechterung erkennen konnten, war unklar, welche Änderung sie verursacht hatte.
- Spezifizität: Wir wollten Verschlechterungen erkennen, die durch Änderungen an unserem mobilen Code verursacht wurden. Während Benutzerverschlechterungen in der Produktion aus welchen Gründen auch immer entscheidend sind, gilt im Vorfeld das Gegenteil, wo wir weitgehend isolieren möchten.
- Varianz: Aus den oben genannten Gründen waren unsere Benchmarks zwischen den Läufen nicht stabil genug, um sicher sagen zu können, dass ein Build schneller war als ein anderer.
Die Lösung für das Präzisionsproblem war einfach; wir mussten die Benchmarks für jeden Merge ausführen, damit wir auf einem Zeitreihendiagramm erkennen konnten, wann sich etwas änderte. Dies war hauptsächlich ein Infrastrukturproblem, aber dank optimierter Pipelines, Build-Prozess und Caching konnten wir die Gesamtzeit von Merge bis Benchmarks auf etwa 8 Minuten reduzieren.
In Bezug auf Spezifizität mussten wir so viele Störfaktoren wie möglich ausschließen, wobei das Backend der Hauptfaktor war. Dazu zeichnen wir zunächst den Netzwerkverkehr auf und spielen ihn während der Benchmarks ab, einschließlich API-Anfragen, Feature-Flags und Websocket-Daten. Zudem wurden die Läufe auf noch mehr Geräte verteilt.
Gemeinsam trugen diese Änderungen auch zur Lösung des Varianzproblems bei, teils durch dessen Reduzierung, teils durch die Erhöhung der Stichprobengröße. Genau wie in der Produktion sagt eine einzelne Probe nie die ganze Geschichte aus, aber durch Betrachtung aller im Laufe der Zeit war es leicht, Trendänderungen zu erkennen, die wir einer Reihe von 1-5 Commits zuordnen konnten.
Alarmierung
Wie oben erwähnt, reicht es nicht aus, nur die Metriken zu haben, da jede Verschlechterung schnell behandelt werden muss. Wir benötigten einen automatisierten Weg, um uns zu alarmieren. Gleichzeitig würde es ignoriert, wenn wir zu oft oder fälschlicherweise aufgrund inhärenter Varianz alarmierten.
Nach der Erprobung exotischerer Modelle wie Bayesian Online Changepoint entschieden wir uns für einen viel einfacheren gleitenden Durchschnitt. Wenn sich eine Metrik um mehr als 10 % für mindestens zwei aufeinanderfolgende Läufe verschlechtert, lösen wir einen Alarm aus.
Nächste Schritte
Obwohl es fantastisch ist, Verschlechterungen zu erkennen und zu beheben, bevor ein Release-Zweig geschnitten wird, ist der heilige Gral, sie von vornherein zu verhindern.
Was uns momentan daran hindert, ist zweifach: Einerseits erfordert das Ausführen bei jedem Commit in jedem Branch noch mehr Kapazität in unseren Pipelines, andererseits benötigen wir genug statistische Macht, um zu erkennen, ob ein Effekt vorliegt oder nicht.
Die beiden sind antagonistisch, was bedeutet, dass bei Aufteilung unseres Budgets auf mehr Benchmarks über weniger Geräte die statistische Macht abnehmen würde.
Die Lösung besteht darin, unsere Ressourcen intelligenter einzusetzen – da sich der Effekt unterscheiden kann, ebenso die Stichprobengröße. Im Wesentlichen können wir für Änderungen mit großem Effekt weniger Läufe durchführen und für Änderungen mit kleinem Effekt mehr Läufe.
Mobile Leistungsrückschritte beobachtbar und umsetzbar machen
Durch die Kombination von Maestro-basierten Benchmarks, engerer Kontrolle über die Varianz und pragmatischer Alarmgebung haben wir die Erkennung von Leistungsrückschritten von einer reaktiven Übung zu einem systematischen Signal in nahezu Echtzeit gemacht.
Obwohl noch Arbeit erforderlich ist, um Rückschritte zu verhindern, bevor sie gemerget werden, hat dieser Ansatz die Leistung bereits zu einer erstklassigen, kontinuierlich überwachten Angelegenheit gemacht – schneller versenden, ohne langsamer zu werden.
