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

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

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

Способ 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 как решение для внутреннего, так и для внешнего тестирования, то внутри есть форма, заполнив которую, мы можем получить жалобы не только на крэши, но и на лаги, которые мешают взаимодействию с приложением.

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

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

  • когда хотим быть уверены, что часть нашего кода работает и будет работать корректно - пишем 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()
        }
    }
}

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

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

  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 находятся в нашей конфигурации.

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

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

Комментарии (2)


  1. OBIEESupport
    20.08.2024 20:57
    +2

    Несмотря на то, что речь идет о производительности, ни в сложносочиненных, ни в сложноподчиненных приложениях нет большинства знаков препинания. Поправьте, пожалуйста.

    И еще один дилетантский вопрос. iOS крутится на нехилом таком железе. А есть ли метрики, которые могут позволить оценить, насколько процентов задействовано именно железо?


    1. CrazyMiller Автор
      20.08.2024 20:57
      +1

      Привет, спасибо, что подсветил! Сейчас должно стать сильно лучше.

      Относительно железа для тестирования - все основные прогоны для тестов в основном идут на стороне CI на Mac mini или других машинках. Специфичных требований относительно обычных UI тестов - нет. Единственное - нужно помнить, что условия для адекватности замеров должны быть приближены к одинаковым. Например, отдельная группа машинок на ферме + для большей точности использование реальных девайсов, а не симуляторов.
      На начальных этапах симуляторы тоже будет подходить. Пока не столкнетесь с проблемами, этого решения будет более чем хватать.
      Метрик железа - можно смотреть на потребление CPU относительно симулятора. Он будет являться критерием того, сколько потребляет ваше приложение.