Пишем свой профайлер для анализа производительности приложения на Android

67cc33f1446feb3752f5727d78520e89.jpeg

По мере развития приложения стоит проводить её аудит для выявления неявных деградаций в производительности. Недавно я проводил аудит раздела комментариев iFunny и написал собственный профайлер. Он не заменит имеющиеся на рынке инструменты Android Profile из Android Studio, Battery Historian и Systrace, но обладает рядом плюсов:

  1. Негативное влияние профилировщика на производительность приложения сводится к минимуму

  2. Документация итераций оптимизации работы приложения.

  3. Гибкость в сборе метрик.

Далее расскажу про существующие инструменты подробнее и перейдём к анализу использования процессорного времени, памяти, использования сети и потребления батареи с помощью кастомного решения.

Общеизвестные инструменты

Их не так много, основные я уже перечислил, рассмотрим их подробнее.

1. Android Profiler. Встроен в Android Studio, он позволяет отслеживать потребление CPU, Memory, Network и Energy во время использования приложения и предоставляет много полезных инструментов. Но у него есть как плюсы, так и минусы.

Плюсы:

  • Наглядные графики.

  • Довольно обширный функционал.

  • Централизованность, всё в одном месте.

Минусы:

  • В работе приложения появляются тормоза из-за механизмов сбора метрик, это не позволяет контролировать основные параметры потребления ресурсов, приближённые в использовании к реальным условиям.

  • Приложение может спонтанно крашиться.

  • Отсутствие гибкости в сборке метрик. Например, чтобы не дампить постоянно heap память на критических показателях, хочется, чтобы Android Profiler делал это за тебя.

2. Systrace. Инструмент, позволяющий собирать и инспектировать время работы частей кода по всем процессам на девайсе.

3. Battery Historian. Нужен, чтобы получить дополнительную информацию по работе приложения, например, процент потребления батарейки и условия, влияющие на потребление.

Изначально основным инструментом для профилирования я выбрал Android Profiler, но его минусы показались критичными. Хочется показать наглядно и количественно с наименьшей погрешностью этапы оптимизации комментариев. Поэтому решил написать свой профилировщик и строить на его основе графики в любом удобном инструменте, например, Excel или Numbers.

В качестве движка по выполнению долгих рутинных повторяющихся действий можно использовать фреймворк автотестов Espresso.

CPU

Получить процент использования процессорного времени процессом приложения довольно сложная задача, но так как Android разработан на базе операционной системы Linux, то можно выполнить специальную команду, распарсить ответ и зафиксировать полученное значение.

Например, можно использовать команду /proc//stat, а PID получить из android.os.Process.myPid (). Но в Android O ограничили доступ к /proc, что заставило использовать другую команду top -n 1, где -n — количество итераций обновлений. Ниже представлен код, фиксирующий значение потребления CPU соответствующего процесса.

class CpuUsageExporter(context: Context) : AppMetricExporter {
  
  private companion object {
    const val CPU_USAGE_FILENAME = "cpu_usage.txt"
    const val PACKAGE_NAME = "com.example.app"
  }
  
  private val cpuPw = PrintWriter(FileOutputStream(File(context.filesDir, CPU_USAGE_FILENAME), true), true)
  
  override fun export() {
    try {
      recordCpu()
    } catch (th: Throwable) {
      Assert.fail(th)
    }
  }
	
  override fun close() {
    cpuPw.close()
  }
	
  private fun recordCpu() {
    val processLine = readSystemFile("top", "-n", "1").filter { it.contains(PACKAGE_NAME) }
      .flatMap { it.split(" ") }
      .map(String::trim)
      .filter(String::isNotEmpty)
    if (processLine.isNotEmpty()) {
      val index = processLine.indexOfFirst { it == "S" || it == "R" || it == "D" }
      check(index > -1) {
        "Not found process state of $PACKAGE_NAME"
      }
      
		cpuPw.println(processLine[index + 1].toFloat().toInt().toString())
    }
  }
	
  @Throws(java.lang.Exception::class)
  private fun readSystemFile(vararg pSystemFile: String): List {
    return Runtime.getRuntime()
      .exec(pSystemFile).inputStream.bufferedReader()
      .useLines {
        it.toList()
      }
  }
}

recordCpu () завернут в try-catch, так как на некоторых девайсах могут возникать ошибки. Все полученные значения записываются в cpu_usage.txt, откуда берутся данные для построения аналитической информации и построения графиков.

Memory

С памятью всё довольно просто, есть классы Debug и Runtime, которые предоставляют необходимый функционал. Если heap память заполняется на 90%, то запускается механизм дампинга памяти автоматически. Таким образом, разработчику не нужно следить за ходом выполнения тестов и он может спокойно пойти попить кофе. После выполненных тестов дамп можно открыть с помощью Android Profiler в Android Studio (Profiler → + → Load from file). Ниже представлен код, анализирующий потребление памяти приложением.

class MemoryUsageExporter(context: Context) : AppMetricExporter {
	
  private companion object {
    const val MEM_USAGE_FILENAME = "mem_usage.txt"
    const val CRITICAL_MEMORY_LOADING = 0.9
  }
	
  private val absolutePath = context.filesDir.absolutePath
  private val memPw = PrintWriter(FileOutputStream(File(context.filesDir, MEM_USAGE_FILENAME), true), true)
	
  override fun export() {
    val runtime = Runtime.getRuntime()
    
    val maxHeapSizeInMB = InformationUnit.BYTE.toMB(runtime.maxMemory())
    val availHeapSizeInMB = InformationUnit.BYTE.toMB(runtime.freeMemory())
    val usedHeapSizeInMB = InformationUnit.BYTE.toMB((runtime.totalMemory() - runtime.freeMemory()))
		
    val totalNativeMemorySize = InformationUnit.BYTE.toMB(Debug.getNativeHeapSize())
    val availNativeMemoryFreeSize = InformationUnit.BYTE.toMB(Debug.getNativeHeapFreeSize())
    val usedNativeMemoryInMb = InformationUnit.BYTE.toMB(totalNativeMemorySize - availNativeMemoryFreeSize)
		
    if (usedHeapSizeInMB > maxHeapSizeInMB * CRITICAL_MEMORY_LOADING) {
      Debug.dumpHprofData("$absolutePath/dump_heap_memory_${System.currentTimeMillis()}.hprof")
    }
		
    val str =
    	"$usedHeapSizeInMB $availHeapSizeInMB $maxHeapSizeInMB $usedNativeMemoryInMb $availNativeMemoryFreeSize $totalNativeMemorySize"
    memPw.println(str)
  }
  
  override fun close() {
    memPw.close()
  }
}

Таким образом, в файле mem_usage.txt образовывается несколько колонок с необходимой информацией, которую можно анализировать по-разному с аналитической точки зрения.

Network

Для метрик использования сети доступен класс android.net.TrafficStats, при помощи которого можно получить принятый и переданный трафик. Ниже представлен код фиксирования трафика данных. Все данные записываются в файл network_usage.txt.

class NetworkUsageExporter(context: Context) : AppMetricExporter {
  
  private companion object {
    const val NETWORK_USAGE_FILENAME = "network_usage.txt"
  }
  
  private val networkPw = PrintWriter(FileOutputStream(File(context.filesDir, NETWORK_USAGE_FILENAME), true), true)
  
  private var transmittedBytes = 0L
  private var receivedBytes = 0L
  
  override fun export() {
    val tBytes = TrafficStats.getTotalTxBytes()
    val rBytes = TrafficStats.getTotalRxBytes()
    
    if (tBytes.toInt() == TrafficStats.UNSUPPORTED || rBytes.toInt() == TrafficStats.UNSUPPORTED) {
      throw RuntimeException("Device not support network monitoring")
    } else if (transmittedBytes > 0 && receivedBytes > 0) {
      networkPw.println("${tBytes - transmittedBytes} ${rBytes - receivedBytes}")
    }
    
    transmittedBytes = tBytes
    receivedBytes = rBytes
  }
  
  override fun close() {
    networkPw.close()
  }
}

Battery

Получение метрик потребления батарейки также довольно простая задача. BatteryManager хранит текущий статус заряда батареи в sticky Intent, который можно получить, если передать в метод registerReceiver IntentFilter c action Intent.ACTION_BATTERY_CHANGED без регистрации ресивера (его, конечно, можно передать, но в данной задачи этого не требуется).

Ниже код получения метрик использования батареи.

class BatteryUsageExporter(private val context: Context) : AppMetricExporter {
  
  private companion object {
    const val BATTERY_USAGE_FILENAME = "battery_usage.txt"
    const val INVALIDATE_VALUE = -1
  }
  
  private val batteryPw = PrintWriter(FileOutputStream(File(context.filesDir, BATTERY_USAGE_FILENAME), true), true)
  private val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
  
  override fun export() {
    val batteryStatus: Intent? = intentFilter.let { ifilter -> context.registerReceiver(null, ifilter) }
    val status: Int = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, INVALIDATE_VALUE) ?: INVALIDATE_VALUE
    if (status != INVALIDATE_VALUE) {
      batteryPw.println("$status")
    }
  }
  
  override fun close() {
    batteryPw.close()
  }
}

Все экспортёры метрик управляются AppMetricUsageManager. Раз в секунду происходит анализ потребления ресурсов и соответствующие значения заносятся по файлами, указанных в экспортёрах. Его код довольно прост:

class AppMetricUsageManager(context: Context) {
  
  private companion object {
    const val INTERVAL_TIME_IN_SEC = 1L
    const val INITIAL_DELAY = 0L
  }
  
  private val exporters = listOf(CpuUsageExporter(context),
                                 MemoryUsageExporter(context),
                                 BatteryUsageExporter(context),
                                 NetworkUsageExporter(context))
  
  private var disposable: Disposable? = null
  
  fun startCollect() {
    disposable = Observable.interval(INITIAL_DELAY, INTERVAL_TIME_IN_SEC, TimeUnit.SECONDS)
      .subscribe({ exporters.forEach { it.export() } }, { th -> Timber.e(th) })
  }
  
  fun stopCollect() {
    exporters.forEach { it.close() }
    disposable.safeDispose()
    disposable = null
  }
}

safeDispose () — экстеншен, который выполняет dispose, если Observable не null или не задиспоузен ранее.

После проведённых тестов можно получить красивые графики:  

b08f481f629d522eedc0df2fc3a5a58e.png

Возникает резонный вопрос. А зачем все это, если есть профайлер?

Как я и говорил в начале статьи, у кастомного решения есть ряд преимуществ по отношению к Android Profiler — это отсутствие крашей, замедлений приложения, гибкость в сборе метрик и составление документации для анализа хода выполнения оптимизаций. В тоже время данный подход не является заменой Android Profiler, Systrace или Battery Historian, а лишь дополняет их.

© Habrahabr.ru