Performance Testing для iOS

5pr05qdzbgphaantyj3vhosubno.jpeg

Все мы любим, когда приложение, которым пользуемся работает отзывчиво, быстро, а так же те операции, которые мы хотим совершить происходили максимально быстро: буть то банковское приложение, буть то приложением в коммерции и тд.

Но как мы можем отслеживать и мерить метрики скорости нашего приложения? Этим вопросом задаются многие разработчики и компании, которые получили негативную реакцию или думают наперед, когда кодовая база и сложность приложения будет расти. Существует два пути: либо мы изучаем на реальных пользователях нашего приложения, либо мы ищем какой-то другой способ, который позволяет нам статистически проверить гипотезу.

Решения на реальных пользователях

Способ 1: XCodeOrganizer

Xcode Organizer применяется для анализа показателей запуска приложения, отзывчивости интерфейса, потребления памяти и энергии, и других важных аспектов. Этот инструмент позволяет сортировать данные по моделям устройств, версиям приложения и группам пользователей. Xcode Organizer помогает определить, улучшилась ли производительность благодаря осуществленным оптимизациям.

Пример XcodeOrganizer

Пример XcodeOrganizer

Способ 2: MetricKit

MetricKit — фреймворк, который служит инструментом для мониторинга производительности приложений. Он служит для агрегирования информации в гистограммы, такие как: состояние сети, длительность инициализации и другие параметры, связанные с производительностью. Также у разработчиков есть возможность добавить свои собственные замеры на основе OSSignpost событий в приложении.

import MetricKit

class MySubscriber: NSObject, MXMetricManagerSubscriber {
    
    var metricManager: MXMetricManager?
    
    override init() {
        super.init()
        metricManager = MXMetricManager.shared
        metricManager?.add(self)
    }
    
    override deinit() {
        metricManager?.remove(self)
    }
    
    func didReceive(_ payload: [MXMetricPayload]) {
        for metricPayload in payload {
            // Do something with metricPayload.
        }
    }
    
}

Способ 3: TestFlight

Если вы используете TestFlight как решение для внутреннего так и для внешнего тестирования, то внутри есть форма, заполнив которую мы можем получить жалобы не только на крэши, но и на лаги, которые мешают взаимодействию с приложением.

caw8xk2iicl9su7yoveq-xvv9fq.jpeg

Немного синтетики

Но все вышеперечисленное работает только уже на реальных пользователях. Что если хотим проверить изменения до того, как это увидят конечные пользователи (например, миграция базы данных, или мажорное изменение корневой библиотеки).
Самый простой ответ будет: использование тестов. А потому что, если задуматься:

  • когда хотим быть уверены, что часть нашего кода работает и будет работать корректно — пишем unit-тесты. (XCTests).

  • когда хотим проверить, что наше приложение работает правильно и будет работать дальше — пишем интеграционные тесты. (XCUITests).

  • когда хотим проверить, что что верстка в нашем приложении выглядит и будет выглядеть так, как нам нужно — пишем snapshot тесты. (FBSnashopt как пример). Скорость приложение не исключение — мы пишем перформанс тесты. А что это такое?

Performance testing мобильных приложений — это вид тестирования программного обеспечения, который оценивает скорость, отзывчивость, стабильность и общую производительность мобильного приложения в различных условиях.
Основная цель тестирования производительности — убедиться в том, что приложение работает хорошо и обеспечивает положительный пользовательский опыт на различных устройствах, в сетях и при различных сценариях использования. Это помогает разработчикам выявлять и устранять узкие места в производительности, оптимизировать использование ресурсов и улучшать общее поведение мобильного приложения.

Примеры метрик

Существуют несколько метрик, которые в приложении имеют особенную ценность:

  1. Метрика запуска приложения — помимо того, что если эта метрика будет слишком большой, то система может остановить приложение в момент запуска, она важна тем, что в момент запуска пользователь не может совершить любое действие в приложении и может спокойно подумать, что приложение зависло и перейти в конкурирующее приложение. Пример: Startup Metrics или TTI (Time to interactive), которую можно заметить не только в мобильной разработке, но и в вебе.

  2. Метрика потребления ресурсов — у нас существуют большие ограничение внутри нашего приложения: энергоэффективность, потребление памяти, чтения из диска, CPU usage и тд. В небольших приложениях это может быть не так явно видно, но как только кодовая база, сложность, функциональность будет расти — каждый из аспектов будет иметь критическое значение.

  3. Метрика быстродействия приложения — характеризует то, как быстро работают определенные части нашего приложения. Например, Hitch Ratio — количество потери кадров на единицу времени (особенно актуально для устройств с 120 гц экранами). Также не стоит забывать и про сетевой слой, но не со стороны загрузки данных (очень относительно возможностями сети), а декодирование и преобразования данных.

Сбор метрик

1. Через measure

Самый простой способ для сборки метрик, которые мы можем собирать в наших тестах — через блок measure. Вызовите этот метод из тестового метода, чтобы измерить производительность блока кода. По умолчанию этот метод измеряет количество секунд, затрачиваемых блоком кода на выполнение.

final class LaunchPerfTests: XCTestCase {

    override func setUp() {
        super.setUp()

        let app = XCUIApplication()
        app.launch()

    }

    func test_launchPerformance() throws {
        measure(metrics: [XCTApplicationLaunchMetric()]) {
            XCUIApplication().launch()
        }
    }
}

Пример самого простого теста на запуск приложения.

Также, например, если мы хотим посмотреть сколько времени отрабатывают методы до того, как пользователь наконец сможем пользоваться нашим приложением (аналог Time to interactive метрики), можно использовать похожий тест как:

final class LaunchPerfTests: XCTestCase {

    override func setUp() {
        super.setUp()

        let app = XCUIApplication()
        app.launch()

    }

    func test_launchPerfUntilResponsive() throws {
        let app = XCUIApplication()
        
        measure(
	        metrics: [XCTApplicationLaunchMetric(waitUntilResponsive: true)]
        ) {
            app.launch()
            app.activate()
        }
    }
}

Пример теста по запуску изображения до интерактивности.

В приведенном выше тесте используется одна из базовых метрик, такая как метрика запуска. Она может быть особенно актуальна, когда, например, вы делаете изменения в линковках модулей или переносите какую-то часть запуска приложения позже и тд. Однако, не стоит забывать ставить настройки baseline-а для того, чтобы понимать, в каком отрезке наши результаты должны находиться.

ipl4khom8obuiocnjd1ftqgzg-8.png

Также существуют и другие метрики, которые можем записать по-дефолту:

  1. XCTCPUMetric для записи информации об активности процессора во время теста производительности.

  2. XCTClockMetric для записи времени, прошедшего во время теста производительности.

  3. XCTMemoryMetric для записи физической памяти, используемой при тестировании производительности.

  4. XCTOSSignpostMetric для записи времени, затрачиваемого тестом производительности на выполнение обозначенной области кода.

2. Через signpost-ы

Но что если хотим отслеживать какие-то другие метрики, например hitch ratio и тд? На помощь приходят к нам указатели. Они позволяют нам записывать полезную информацию об отладке и анализе и включайте динамический контент в свои сообщения.
Существуют два способа создать signpost-ы:

  1. Старое API через os_signpost.

  2. Новое API, через OSSignposter (его и будем рассматривать).

let signposter = OSSignposter()
let signpostID = signposter.makeSignpostID()

let data = fetchData(from: request) // Создание события, чтобы отметить определенную точку интереса.
signposter.emitEvent("Fetch complete.", id: signpostID) processData(data) // Завершение указанного интервала, используя сохраненное состояние интервала.
signposter.endInterval("processRequest", state)

Далее создаем экземпляр класса XCTOSSignpostMetric, который принимает в себя информацию по нашему созданном signpost-у выше:

func signpostMetric(for name: StaticString) -> XCTOSSignpostMetric {
	return XCTOSSignpostMetric(
		subsystem: subsystem, 
		category: category, 
		name: String(name)
	)
}

И после этого помещаем новую метрику в блок measure(metrics: ...), который разбирали чуть ранее.

3. Общее пространство

Мы успели рассмотреть примеры через signpost-ы и заранее реализованные способы сбора метрик, но что, например, если хотим собрать метрики, к которым нельзя применить правило с точкой старта и конца. Или нам требуется какое-то собственное правило подсчета метрики: своя собственная метрика hitch ratio и тд. Тут назревает вопрос: как собирать эту метрику? Ведь все тесты скорости используют XCUITests по-дефолту. И не можем напрямую передавать данные из Runner-а (приложение, которые содержит тесты) в Target (основное приложение) и наоборот.
Тут нам приходит прием, который используем при написании UI тестов и общения между двумя приложениями, например, для переходов по диплинкам.

import Foundation
import Network

/// Класс в target приложении, который отвечает за отправку событий
final class DataSender {
    private var host: NWEndpoint.Host?
    private var port: NWEndpoint.Port?
    private var connection: NWConnection?

    static let shared = DataSender()

    private init() {}

    func configure(host: String = "localhost", port: String) {
        if self.host != nil {
            stop()
        }
        self.host = NWEndpoint.Host(host)
        self.port = NWEndpoint.Port(port)
    }

    func start() {
        guard let host, let port else {
            NSLog("Host and Port must not be empty")
            return
        }
        connection = NWConnection(host: host, port: port, using: .tcp)

        connection?.stateUpdateHandler = { newState in
            switch newState {
            case .ready:
                NSLog("Client connection ready")
            case .failed(let error):
                NSLog("Client connection failed: \(error)")
                self.connection?.cancel()
            case .cancelled:
                NSLog("Client connection cancelled")
            default:
                break
            }
        }
        connection?.start(queue: .global())
    }
    
    private func sendData(_ message: String) {
        guard let connection = connection else { return }
        let data = message.data(using: .utf8) ?? Data()
        connection.send(content: data, completion: .contentProcessed { error in
            guard let error else {
                NSLog("Data sent successfully")
                return
            }
            connection.cancel()
        })
    }

    func stop() {
        connection?.cancel()
    }
}


Далее необходимо добавить создание соединение, когда наше приложение находится в режиме запуска UITest-ов или PerfTest-ов. Например: можно привязать старт на то, какие Active Compilation Conditions находятся в нашем конфиге.

yulke7dwyo3gzuhu4ydi2kga1kw.pngdh8ncpitff8zu9dblnrpyn6ndqe.png

import UIKit

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        #if PERFTESTS
	        DataSender.shared.configure(port: "8080")
            DataSender.shared.start()
        #endif
        return true
    }
}

После того как настроили основной target, время приступить к коду внутри наших перф тестов (можно назвать еще Runner-ом). Далее мы поднимаем Tcp сервер, который будет отслеживать сообщения, которые мы получаем из основного приложения.

import Network

/// Сервер внутри нашего Runner-а
final class TcpServer {
    private var listener: NWListener?
    private let queue = DispatchQueue(label: "TcpServerQueue")

    var onReceive: ((String) -> Void)?

    func start() {
        do {
            listener = try NWListener(using: .tcp, on: 8080)
            listener?.stateUpdateHandler = { newState in
                NSLog("Server state: \(newState)")
            }

            listener?.newConnectionHandler = { newConnection in
                newConnection.stateUpdateHandler = { state in
                    switch state {
                    case .ready:
                        self.receiveData(connection: newConnection)
                    case .failed(let error):
                        NSLog("Connection failed: \(error)")
                    default:
                        break
                    }
                }
                newConnection.start(queue: self.queue)
            }

            listener?.start(queue: queue)
        } catch {
            NSLog("Failed to start listener: \(error)")
        }
    }

    private func receiveData(connection: NWConnection) {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { [weak self] data, _, isComplete, error in
            guard let self = self else { return }
            if let data = data, !data.isEmpty, isComplete, let receivedString = String(data: data, encoding: .utf8) {
                onReceive?(receivedString)
            }
            if error == nil && !isComplete {
                self.receiveData(connection: connection)
            } else {
                connection.cancel()
            }
        }
    }

    func stop() {
        listener?.cancel()
        listener = nil
    }
}

Запись данных

После того, как собрали все данные в нашем тесте, необходимо сохранить все эти данные. Самый просто способ, использовать XCResult. Также у нас может быть желание записать какие-то доп данные как attachment. И нам на помощь приходит XCAttachment, который позволяет добавить всю информацию, которая соответствует Data или, например XML.

let savedData = prepareData(from: result)
let attachment = XCTAttachment(data: savedData)

add(attachment)

Последующий анализ

После того, как мы собрали наши результаты и сохранили для последующего анализа мы можем построить гистограмму по нашим замерам, которые мы создавали и четко на числах определить тренд восходящий или нисходящий. Например, если проверяем гипотезу о деградации скорости мы делаем два запуска перф тестов — до внесения изменений. Второй — после применения изменений с различным анализом. Далее мы делаем сравнение двух величин используя, например, diff и определяем линию тренда.
Естественно, можно написать довольно простые скрипты для сравнения величин и от этого определения линии тренда.

Заключение

Perf-тесты занимают особенное место в общей пирамиде тестирование и если вы только начинаете разработку вашего приложения, они могут быть излишними. Однако если вы уже прошли начальную стадию развития приложения и готовы потратить ресурсы в производительность, защитив себя от неосознанных изменений и фидбека от реальных пользователей, то такой тип тестов может быть полезным. Каждая команда должна принимать решение о целесообразности такого решения. Но оно того стоит.

Полезные ссылки

© Habrahabr.ru