Приветствую всех заглянувших трудяг! Помните, как вы готовились к первым собеседованиям на должность Android-разработчика? Жизненный цикл, пересоздание Activity, коллбеки ЖЦ: все эти понятия хорошо знакомы каждому молодому специалисту в нашей с вами области. Однако одна из прилетевших от аналитиков задач на отправку событий, связанных с длительностью нахождения пользователя в приложении, ввела меня в ступор, заставила провести небольшое исследование, проявить чудеса ведения переговоров и узнать много интересных нюансов. Об этом увлекательном путешествии сегодня расскажу вам я, Александр Лебедь, Android-разработчик Core команды приложения WB Partners.

Первый взгляд на вещи

Исходная задача выглядела примерно так: необходимо отправлять в сервис аналитики события для отслеживания длительности пользовательской сессии. Всего этих событий было четыре:

  1. start — старт процесса приложения

  2. return — возврат в приложение из фона

  3. background — сворачивание приложения

  4. end — завершение процесса приложения

Прочитав ТЗ, я подумал, что это неплохо ложится на голый жизненный цикл Activity (у нас еще и Single Activity подход, как удачно): start будем вызывать в onCreate, return в onStart, background в onStop, а с end надо бы придумать как быть, наверняка есть какой-то коллбек. Спойлер: два из четырех событий отправлять мы отказались. С каждым из них оказалось не все так просто.

Пробуем отправить end событие

Итак, попробуем разобраться с отправкой самого сложного события: завершение процесса приложения. Какие можно попробовать варианты?

onDestroy

override fun onDestroy() {  
    analytics.sendEvent("end")
    super.onDestroy()  
}

Сразу «нет» по многим причинам. Во-первых, нет абсолютно никаких гарантий, что он вообще вызовется при уничтожении процесса. Даже если процесс убивает пользователь, а не система, коллбек может не отработать. Во-вторых, onDestroy в активити, даже несмотря на то, что у нас она всего одна, некорректно привязывать к процессу, так как активити может быть убита отдельно от процесса. Такой ненадежный способ явно нам не подходит.

onTerminate

class App : Application() {
	...
	override fun onTerminate() {  
	    analytics.sendEvent("end")  
	    super.onTerminate()  
	}
}

При исследовании методов Application класса можно наткнуться на метод с многообещающим названием onTerminate. Он вызывается при смерти процесса и отлично подходит... для эмуляторов. На реальных устройствах этот метод никогда и ни за что не вызовется, о чем нам кричит первая же строчка документации:

This method is for use in emulated process environments. It will never be called on a production Android device

Обидно, но что поделать, этот вариант тоже нам не поможет.

ProcessLifecycleOwner

// Необходимо подключить зависимость: implementation("androidx.lifecycle:lifecycle-process:2.9.4")

class App : Application() {  
    override fun onCreate() {  
        super.onCreate()  
        ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())  
    }  
}

class AppLifecycleObserver : LifecycleEventObserver {  
    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {  
        when (event) {  
            Lifecycle.Event.ON_DESTROY -> {  
                analytics.sendEvent("end")  
            }  
            else -> {}  
        }  
    }  
}

Еще одно многообещающее название, которое нас разочарует. С помощью него можно отлавливать события жизненного цикла приложения. Эти события формируются на основе состояний всех активити внутри приложения (подробнее в официальной документации). В этой же документации черным по белому написано, что Lifecycle.Event.ON_DESTROY will never be dispatched. И мы снова остаемся у разбитого корыта.

UncaughtExceptionHandler

Thread.setDefaultUncaughtExceptionHandler() { thread, throwable ->
	if(throwable is SomeKillByProcessException) { // какое-то исключение о завершении процесса приложения
		analytics.sendEvent("end")
	}
}

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

Heartbeat

class MainActivity : ComponentActivity() {
	private val heartbeatScope = CoroutineScope(newSingleThreadContext("Heartbeat"))
	
	override fun onCreate(savedInstanceState: Bundle?) {  
	    super.onCreate(savedInstanceState)  
	    startHeartbeat(60000) // раз в минуту
	}
	
	private fun startHeartbeat(periodMillis: Long) {  
	    heartbeatScope.launch {  
	        while(true) {  
	            delay(periodMillis) 
	            val currentTimeMillis = System.currentTimeMillis() // текущее время
	            val previousBeat = prefs.getLong("beat", System.currentTimeMillis()) // предыдущее сохраненное время
	            if(currentTimeMillis - previousBeat > periodMillis + 1000) {  // если разница во времени больше чем период + 1 секунда погрешности
	                analytics.sendEvent(event = "end", time = previousBeat)  // отправляем событие с предыдущим сохраненным временем, что событие завершалось
	            }  
	            prefs.edit {  
	                putLong("beat", System.currentTimeMillis())  // кладем в префы новое значение
	            }  
	        }  
	    }  
	}
}

Это уже не какой-то стандартный класс или метод, а особый способ: раз в n секунд необходимо писать в sharedPreferences / datastore текущее время. Если при попытке записать новое время мы понимаем, что времени прошло больше, чем n секунд, то значит, что процесс был уничтожен и создан заново. Звучит значительно лучше, но все еще не торт: ненадежно (а если не прошло n секунд, но все равно успели убить процесс и создать заново, то не считается?) и непроизводительно (каждые n секунд постоянно писать в префы).

Другой процесс для слежки за состоянием процесса приложения?

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

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

Что насчет start события?

Ладно, с end событием не выгорело. Попробуем тогда представить, как нам отправить событие start. Метод onCreate внутри Application отлично подходит, вызывается ровно один раз при старте процесса приложения, никаких проблем не имеет, кроме одной загвоздки: он вызывается не только при открытии приложения, но и при выполнении фоновых операций, показе уведомлений и взаимодействии с виджетом на рабочем столе. Это справедливо, ведь процесс приложения на то и процесс, чтобы подниматься с кровати и работать, когда необходимо выполнять какие-то операции в контексте приложения. Но, как оказалось, бизнесу это не нужно знать.

А что бизнесу нужно знать?

На этом этапе закрались разумные сомнения: а точно ли все это действительно нужно? Правда ли аналитикам интересно, когда именно система убила процесс приложения пользователя? Правда ли бизнесу так важно видеть, когда процесс приложения запускается, чтобы показать уведомление? Да и вообще, что понимается под «длительностью пользовательской сессии»?

Над этими вопросами стоило бы задуматься раньше, еще при получении задачи, но кажущаяся простота исполнения отвлекла мое внимание и увела в дебри жизненного цикла процесса. Порассуждав и обсудив с аналитиками все непонятки, мы пришли к тому, что пользовательская сессия это время от появления приложения на экране до его полного скрытия. То есть, из 4 вышеупомянутых в тз событий остается только два: return и background, которые мы для удобства назовем show и hide. Но и с ними не все так просто...

Реализация отправки событий

Что-то мы заговорились, пора переходить уже наконец к коду. Событие show означает появление приложения на экране, hide - скрытие приложения с экрана. Напоминает моменты вызова методов ЖЦ Activity onStart и onStop. Давайте поместим в них отправку событий:

class MainActivity : ComponentActivity() {  

	private val analytics: SomeAnalytics by inject() // допустим, у нас есть DI и реализация аналитики

    override fun onCreate(savedInstanceState: Bundle?) {  
        super.onCreate(savedInstanceState)  
        ...
    }  
      
    override fun onStart() {  
        super.onStart()  
        Log.i("Lifecycle", "onStart") // поставим логи, чтобы следить, когда методы вызываются 
        analytics.sendEvent("show")
    }  
  
    override fun onStop() {  
        super.onStop()  
        Log.i("Lifecycle", "onStop") // поставим логи, чтобы следить, когда методы вызываются 
        analytics.sendEvent("hide")
    }  
}

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

Configuration Changes

Смена конфигурации влечет за собой пересоздание нашей Activity. Соответственно, все наши коллбеки отработают еще по разу за каждый переворот экрана, чего не хотелось бы, ведь по сути юзер не перестал пользоваться приложением. Чтобы узнать, когда Activity пересоздается в связи со сменой конфигурации, можно использовать флаг isChangingConfigurations. Выставим условие, что мы отправляем событие только когда флаг равен false.

class MainActivity : ComponentActivity() {  
	...
    override fun onStart() {  
        super.onStart()  
        if(!isChangingConfigurations) {
	        Log.i("Lifecycle", "onStart")
	        analytics.sendEvent("show")
        }
    }  
  
    override fun onStop() {  
        super.onStop()
        if(!isChangingConfigurations) {  
	        Log.i("Lifecycle", "onStop")
	        analytics.sendEvent("hide")
        }
    }  
}

Запустив наше приложение и повертев смартфоном в руках, мы увидим следующую картину в логах:

С onStop сработало, но почему onStart по-прежнему отрабатывает? Фокус в том, что флаг isConfigurationChanges установлен в true только при уничтожении Activity. Это значит, что мы можем удостовериться, что идет смена конфигурации, только в onPause, onStop и onDestroy методах, onStart в сделку не входил. Как тогда понять, что мы восстанавливаем состояние после смены конфигурации, а не заходим в приложение?

Один из способов проверить в onCreate, что savedInstaceState, в котором должны лежать данные для восстановления состояния активности, пришел не пустым. В таком случае мы уверены, что это не обычное открытие Activity. Создадим локальный флаг isRestored и будем опираться на него при отправке события в onStart.

class MainActivity : ComponentActivity() {  
	
	private var isRestored: Boolean = false
	
	override fun onCreate(savedInstanceState: Bundle?) {  
	    super.onCreate(savedInstanceState)  
	    isRestored = savedInstanceState != null // устанавливаем значение флага в зависимости от savedInstanceState
	    ...
    }
	
    override fun onStart() {  
        super.onStart()  
        if(!isRestored) { // проверяем флаг
	        Log.i("Lifecycle", "onStart")
	        analytics.sendEvent("show")
        } else {
	        isRestored = false // возвращаем флаг на место, чтобы при переоткрытии приложения событие отправлялось
        }
    }  
    ...
}

После запуска все начинает работать как нужно, но мы забыли учесть один момент: если пользователь свернет приложение и будет заниматься своими делами в других местах, то система может в этот момент убить Activity (не процесс!) для освобождения дополнительной памяти. По возврату в приложение Activity начнет восстановление своего состояния. А это значит, что savedInstanceState не будет равен null и событие в onStart не будет отправлено, хотя по сути мы просто зашли в приложение.

Такое поведение терпеть нельзя, поэтому мы поступим следующим образом: будем менять наш флаг в зависимости от isChangingConfigurations, а не savedInstanceState. Тогда у нас будет уверенность, что вызов коллбека onStart это следствие изменения конфигурации, когда пользователь не выходил из приложения. Однако хранить этот флаг в качестве локальной переменной в Activity не удастся: между вызовами onStop и onStart мы уничтожаем и создаем новый экземпляр Activity. Соответственно, значение, записанное в локальную переменную, не сохранится.

Здесь есть три варианта: использовать ViewModel, положить в savedInstanceState или сохранять это в отдельном классе-синглтоне. Каждый из них имеет право на существование, но мы для себя решили выбрать третий. Логика все меньше становилась похожа на простенькие вызовы методов в Activity, так что выделить под это отдельный ответственный класс стало хорошим решением.

internal class UserSessionAnalyticsManager(  
    private val analytics: SomeAnalytics  
) : LifecycleEventObserver {  
  
    private var isConfigChange = false
     
    override fun onStateChanged(  
        source: LifecycleOwner,  
        event: Lifecycle.Event  
    ) {  
        when(event) {  
            Lifecycle.Event.ON_START -> {  
                if(!isConfigChange) {  
                    Log.i("LifecycleEventLog", "onStart")  
                    analytics.sendEvent("show")  
                }  
                isConfigChange = false  
            }  
  
            Lifecycle.Event.ON_STOP -> {  
                if(!isConfigChange) {  
                    Log.i("LifecycleEventLog", "onStop")  
                    analytics.sendEvent("hide")  
                }  
            }  
  
            else -> {}  
        }  
    }  
  
    fun notifyConfigChange() {  
        isConfigChange = true  
    }  
}

class MainActivity : ComponentActivity() {
	private val sessionManager: UserSessionAnalyticsManager by inject() // указан в di как Singleton
	
	init {  
	    lifecycle.addObserver(sessionManager)  
	}
	
	...
	
	override fun onPause() {  
	    if (isChangingConfigurations) sessionManager.notifyConfigChange()  
	    super.onPause()  
	}
}

Мы создали класс UserSessionAnalyticsManager, который реализует интерфейс LifecycleEventObserver. Внутри реализуемого метода onStateChanged мы отправляем события аналитики в зависимости от isConfigChange. В активности мы добавляем экземпляр класса через lifecycle.addObserver(). Сам флаг мы выставляем из MainActivity с помощью метода notifyConfigChange в onPause. Почему onPause? Потому что он идет перед onStop, а этот флаг нам нужен актуальным уже там.

Вот и все, с изменением конфигурации мы справились. Но заканчивать на этом наше путешествие пока рано.

Exceptions

Наличие ошибок, к сожалению или к счастью, является уделом любого программного обеспечения. Мы стремимся полностью избавиться от них, сделать все максимально надежно и стрессоустойчиво, но все равно периодически вынуждены наблюдать, как наше приложение падает в проде с исключением. Такие выпады без объявления войны завершают работу программы и не дают классу UserSessionAnalyticsManager никакой возможности отправить нам событие hide, поскольку onStop просто не успеет вызваться. При подобной конъюнктуре следует использовать класс UncaughtExceptionHandler, который мы рассматривали ранее при отправке события end.

Thread.setDefaultUncaughtExceptionHandler() { thread, throwable ->
	analytics.sendEvent("hide")
}

Метод setDefaultUncaughtExceptionHandler устанавливает собственную обработку для выброшенных исключений, заменяя текущую. Заменять текущую мы не хотим, так как иначе приложение просто повиснет, а не крашнется. Плюс к тому, стоит убирать нашу обработку в onStop, чтобы краши в фоне не отправляли события hide. Дополним код нашего менеджера:

internal class UserSessionAnalyticsManager(  
    private val analytics: SomeAnalytics  
) : LifecycleEventObserver { 
	
	...

	private val defaultHandler by lazy { Thread.getDefaultUncaughtExceptionHandler() }
	
	override fun onStateChanged(  
        source: LifecycleOwner,  
        event: Lifecycle.Event  
    ) {  
        when(event) {  
            Lifecycle.Event.ON_START -> {
	            registerUncaughtExceptionHandler()
				... // остальной код 
            }  
  
            Lifecycle.Event.ON_STOP -> {  
                ... // остальной код
                unregisterUncaughtExceptionHandler()
            }  
  
            else -> {}  
        }  
    }
    
    ...
    
    private fun registerUncaughtExceptionHandler() {  
	    defaultHandler?.let { handler ->  
	        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->  
	            Log.i("LifecycleEventLog", "exceptionHandler")  
	            analytics.sendEvent("hide")  
	            handler.uncaughtException(thread, throwable)  
	        }  
	    }
    }  
  
	private fun unregisterUncaughtExceptionHandler() {  
	    defaultHandler?.let { Thread.setDefaultUncaughtExceptionHandler(it) }  
	}  
}

Мы создали локальную переменную defaultHandler, в которой будет храниться текущий обработчик исключений. Далее в методе registerUncaughtExceptionHandler() мы добавляем свою логику в setDefaultUncaughtExceptionHandler и в конце вызываем стандартную обработку (handler.uncaughtException(thread, throwable)), чтобы приложение не зависло и благополучно упало. В методе unregisterUncaughtExceptionHandler() возвращаем все на круги своя. Эти два метода мы будем вызывать в onStateChanged методе нашего менеджера при обработке ON_START и ON_STOP эвентов.

Хорошей практикой при реализации вашей аналитики станет сохранение событий в какое-нибудь локальное хранилище при вызове метода sendEvent(), чтобы затем в фоне пачкой отправить их на сервер. Это позволит ускорить выполнение кода метода, пользователи не будут замечать задержки на отправку, а uncaughtExceptionHandler отработает быстрее.

В целом, код уже кажется рабочим. Запускаем, тестим, пробуем выкидывать исключения, и все работает... пока мы выкидываем исключения на главном потоке. Как только мы перенесем это в другой поток, то сможем стать свидетелями следующей картины:

Событие отправляется как из exceptionHandler, так и из onStop. Так происходит, потому что exceptionHandler отрабатывает в другом потоке, не блокируя при этом возможность закрыть приложение и вызвать onStop с главного потока. А если исключения выбрасываются одновременно на нескольких потоках, то и exceptionHandler может отработать несколько раз:

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

internal class UserSessionAnalyticsManager(  
    private val analytics: SomeAnalytics  
) : LifecycleEventObserver { 
	...
	private var isHideSent: Boolean = true // флаг, показывающий, было ли отправлено событие hide
	
	private fun synchronizedWithHideCheck(block: () -> Unit) {  
	    if (!isHideSent) { // проверяем, что мы еще не отправляли событие hide  
	        synchronized(this) { // входим в блок синхронизации  
	            if (!isHideSent) { // еще раз уточняем, что мы ничего не отправляли  
	                isHideSent = true // устанавливаем флаг в true  
	                block() // вызываем блок кода  
	            }  
	        }  
	    }  
	}
	...
}

Обернем в функцию synchronizedWithHideCheck блоки кода, связанные с отправкой события hide. Также надо не забыть установить флаг в true после отправки show, чтобы при обычных скрытиях / открытиях приложения события не терялись.

internal class UserSessionAnalyticsManager(  
    private val analytics: SomeAnalytics  
) : LifecycleEventObserver { 
	
	...
	
	override fun onStateChanged(  
        source: LifecycleOwner,  
        event: Lifecycle.Event  
    ) {  
        when(event) {  
            Lifecycle.Event.ON_START -> {
	            registerUncaughtExceptionHandler()
				if (!isConfigChange) {  
					Log.i("LifecycleEventLog", "onStart")  
					analytics.sendEvent("show")  
					isHideSent = false // выставляем флаг в false, чтобы можно было отправить новое событие 
                }  
                isConfigChange = false
            }  
  
            Lifecycle.Event.ON_STOP -> {  
                if (!isConfigChange) {  
				    synchronizedWithHideCheck {  // добавляем синхронизацию
				        Log.i("LifecycleEventLog", "onStop")  
				        analytics.sendEvent("hide")  
				    }  
				}  
				unregisterUncaughtExceptionHandler()
            }  
  
            else -> {}  
        }  
    }
    
    ...
    
    private fun registerUncaughtExceptionHandler() {  
	    defaultHandler?.let { handler ->  
	        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->  
	            synchronizedWithHideCheck {  // добавляем синхронизацию
				    Log.i("LifecycleEventLog", "exceptionHandler")  
				    analytics.sendEvent("hide")  
				}
	            handler.uncaughtException(thread, throwable) // этот блок оставляем снаружи, условие не препятствует работе других хэндлеров 
	        }  
	    }
    }  
}

Теперь событие hide отправляется только один раз. Все здорово, только мы не учли один маленький и редкий, но возможный момент: если приложение упадет в другом потоке до установки нашего exceptionHandler, то есть вероятность, что событие старта приложения успеет записаться. А вот событие закрытия уже не отправится. Такую проблему можно исправить с помощью еще одного, более раннего UncaughtExceptionHandler.

internal class UserSessionAnalyticsManager(  
    private val analytics: SomeAnalytics  
) : LifecycleEventObserver {

	...
	 
	private var isCrash: AtomicBoolean = AtomicBoolean(false) // флаг наличия креша
	
	init {  
	    defaultHandler?.let { handler ->  
	        Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->  
	            isCrash.set(true)  // устанавливаем флаг наличия креша в true
	            handler.uncaughtException(thread, throwable)  
	        }  
	    }
	}
	
	override fun onStateChanged(  
        source: LifecycleOwner,  
        event: Lifecycle.Event  
    ) {  
        when(event) {  
            Lifecycle.Event.ON_START -> {
	            registerUncaughtExceptionHandler()
				if (!isConfigChange && !isCrash.get()) {  // добавляем здесь проверку на наличие креша
					Log.i("LifecycleEventLog", "onStart")  
					analytics.sendEvent("show")  
					isHideSent = false
                }  
                isConfigChange = false
            }  
            ...
		}
	}
}

И вот теперь, наконец-то, полная победа над исключениями. Мы грамотно их обрабатываем, отправляем события, аналитики рады, бизнес рад. Система полностью готова и работает безотказно.

Бонус: Recent Windows

Задумывались ли вы когда-то о Recent Windows? В каком состоянии жц будет находиться Activity, если я нажму на квадратик снизу и окажусь в состоянии, как на скрине?

Правильный ответ: не PAUSED. На разных версиях Android и разных вендорах по-разному.

На более старых устройствах активность будет находиться в STOPPED состоянии. Казалось бы, Activity видна на экране, просто с ней нельзя повзаимодействовать, значит должен быть PAUSED? На самом деле, видим мы здесь не Activity, а Task. И даже еще точнее: скриншот, сделанный Android системой, который относится к этой Task. При сворачивании приложения (а именно это и происходит, даже если вы сразу попали в Recent Windows) и после перехода Activity в STOPPED состояние WindowManager помещает скриншот ушедшей в бэкграунд таски в GraphicBuffer. Сохраненное изображение будет использоваться на Recents экране и при возврате в приложение в качестве стартового кадра, который после отрисовки первого кадра активности плавно перетечет в него. Подробнее о концепции Task snapshots можно почитать в документации.

На современных же устройствах состояние чаще всего будет RESUMED. Анимации продолжают работать, активность продолжает функционировать, с ней можно, в теории, повзаимодействовать (но на практике при первом же тапе на нее вы просто уйдете с Recent Windows и вернетесь в полноэкранное приложение). Только после переключения в другое приложение или переходе на рабочий стол она переходит в STOPPED состояние.

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

Итоги

Настало время резюмировать, чего мы достигли в рамках этой статьи:

  • создали рабочее решение для подсчета времени, которое провел пользователь в приложении;

  • попутно рассмотрели кучу неочевидных нюансов и преодолели несколько подводных камней, расширив тем самым собственное понимание происходящего в Android системе и SDK (в частности, процессах приложений, жизненном цикле, изменении конфигурации, восстановлении состояния, Recent Windows, отлове исключений);

  • еще раз убедились, что к оценке и пониманию задачи стоит подходить со всей серьезностью, не лишним также будет понимание, зачем это бизнесу.

Всем спасибо за уделенное этой статье время! Итоговую версию кода вы сможете посмотреть на GitHub, также советую обратить внимание на другие наши статьи по Android тематике:

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

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