Чтобы повышались надежность приложений и удовлетворенность пользователей, при разработке мобильных приложений необходимы отслеживание ошибок и понимание пользовательских взаимодействий. При возникновении проблемы важно знать ее причины, вызвана она внешним фактором или поведением пользователя.
Создадим библиотеку Krashlytics на Kotlin Multiplatform для обработки отчетов о сбоях и отслеживания навигационных цепочек. Такие цепочки — это логи пользовательских действий, по которым выявляются последовательности событий, приведших к сбою.
Мы продемонстрируем основные концепции этой системы логирования, поэтому воспользуемся упрощенным примером. В реальных библиотеках — Firebase Crashlytics, Sentry, решениях корпоративного уровня — применяются более сложные и надежные методы обработки отчетов о сбоях, хранения данных и передачи по сети. Но для целей обучения и небольших проектов этот пример — отличная отправная точка.
Для хранения навигационных цепочек и логов сбоев воспользуемся локальной базой данных Room, для сериализации данных — Kotlin Serialization, для генерирования сущностей Room для iOS — Kotlin Symbol Processing, то есть KSP. Итак, смоделируем хранилище данных для отчетов, подобных реальным.
Настройка проекта Kotlin Multiplatform
Если проекта нет, создадим его:
- Создаем проект Kotlin Multiplatform в Android Studio, IntelliJ IDEA или другой среде IDE с поддержкой KMP.
- Добавляем зависимости в файл
build.gradle.kts, а для наследуемых конфигураций — в файлbuild.gradle. - В
gradle/libs.versions.tomlдобавляем нужные библиотеки:
[versions]
# другие версии
kotlinxSerializationJson = "1.7.3"
roomVersion = "2.7.0-alpha10"
ksp = "2.0.21-1.0.25"
sqlite = "2.5.0-alpha10"
[libraries]
# другие библиотеки
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" }
sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
В composEapp/build.gradle.kts добавим зависимости для основного набора исходников:
sourceSets {
androidMain.dependencies {
}
commonMain.dependencies {
// другие зависимости
implementation(libs.room.runtime)
implementation(libs.kotlinx.serialization.json)
implementation(libs.sqlite.bundled)
}
iosMain {
kotlin.srcDir("build/generated/ksp/metadata")
}
}
kotlin.srcDir("build/generated/ksp/metadata")
Эта строчка выше нужна KSP для генерирования реализации базы данных Room.
plugins {
// другие плагины
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.room)
}
Добавляем настройки для KSP и для Kotlin Multiplatform:
dependencies {
"kspAndroid"(libs.room.compiler)
"kspCommonMainMetadata"(libs.room.compiler)
}
Теперь указываем каталог схем Room:
room {
schemaDirectory("$projectDir/schemas")
}
Наконец, нажимаем sync и синхронизируем новые конфигурации Gradle.
Добавление моделей предметной области
Эти модели — основа для функциональности будущей системы: навигационной цепочки и логирования сбоев. Здесь структурируются и сохраняются данные для отслеживания ошибок и отчетов о сбоях.
Изучим модели одну за другой, каково их назначение и как они вписываются в общую архитектуру приложения.
В composeApp/domai/model создаем класс данных Kotlin:
data class Breadcrumb(
val type: BreadCrumbType = BreadCrumbType.Log,
val message: String,
val timestamp: Long = DateTime.currentTimeMillis(),
val data: Map<String, String?>? = null
)
enum class BreadCrumbType {
User,
Error,
Log,
System,
}
Создаем другой класс — для сведений об устройстве:
data class DeviceInfo(
val deviceModel: String,
val manufacturer: String,
val osVersion: String,
val sdkVersion: String,
val screenResolution: String,
val locale: String,
val batteryLevel: Int,
val isCharging: Boolean,
val isConnectedToWifi: Boolean,
val storageAvailable: Long,
val totalStorage: Long,
val isRooted: Boolean
)
А теперь — класс для навигационных цепочек и сведений об устройстве:
data class DeviceBreadCrumb(
val id: String? = null,
val breadcrumb: Breadcrumb,
val deviceInfo: DeviceInfo,
)
Наконец, создаем класс для отчета о сбоях приложения:
data class UncaughtErrorModel(
val id: String? = null,
val cause: String? = null,
val errorMessage: String? = null,
val causedAtMillis: Long = DateTime.currentTimeMillis(),
val deviceInfo: DeviceInfo,
)
Для этого DateTime воспользуемся kotlinx-datetime, цель минималистичная — сделать собственную реализацию.
В composeApp/platform создаем файл для ожидаемого объявления объекта date time:
expect object DateTime {
fun currentTimeMillis(): Long
}
В androidMain/platform создаем саму реализацию:
actual object DateTime {
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
}
То же и в iosMain/platform:
actual object DateTime {
actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
}
Затем для считывания этой даты сделаем простой форматировщик.
В commonMain/platform/DateTimeUtils создаем ожидаемую функцию-расширение:
expect val Long.fromMillis: String
Теперь создадим эту actual для ios и Android.
Для Android:
actual val Long.fromMillis: String
get() {
val dateFormat = SimpleDateFormat("dd-MM-yyyy hh:mm a", Locale.getDefault())
return dateFormat.format(Date(this))
}
Для ios:
actual object DateTime {
actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
}
В iosMain/platform для всех ожидаемых объявлений обеих платформ реализуем логику поставщика сведений об устройстве:
expect class DeviceInfoProvider {
fun getDeviceInfo(
): DeviceInfo
}
Для Android:
actual class DeviceInfoProvider(
private val context: Context,
) {
actual fun getDeviceInfo(): DeviceInfo {
val (batteryLevel, isCharging) = getBatteryInfo()
val (storageAvailable, totalStorage) = getStorageInfo()
return DeviceInfo(
deviceModel = Build.MODEL,
manufacturer = Build.MANUFACTURER,
osVersion = Build.VERSION.RELEASE,
sdkVersion = "${Build.VERSION.SDK_INT}",
screenResolution = getScreenResolution(),
locale = Locale.getDefault().toString(),
batteryLevel = batteryLevel,
isCharging = isCharging,
isConnectedToWifi = isConnectedToWifi(),
storageAvailable = storageAvailable,
totalStorage = totalStorage,
isRooted = isDeviceRooted()
)
}
private fun getScreenResolution(): String {
val displayMetrics = context.resources.displayMetrics
return "${displayMetrics.widthPixels}x${displayMetrics.heightPixels}"
}
private fun getBatteryInfo(): Pair<Int, Boolean> {
val batteryManager =
context.getSystemService<BatteryManager>()
return batteryManager?.let { manager ->
val batteryLevel = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
val isCharging = manager.isCharging
Pair(batteryLevel, isCharging)
} ?: Pair(0, false)
}
private fun isConnectedToWifi(): Boolean {
val connectivityManager =
context.getSystemService<ConnectivityManager>()
return connectivityManager?.let { manager ->
val network = manager.activeNetwork
val capabilities = manager.getNetworkCapabilities(network)
return capabilities?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI)
?: false
} ?: false
}
private fun getStorageInfo(): Pair<Long, Long> {
val stat = android.os.StatFs(Environment.getDataDirectory().path)
val availableBytes = stat.availableBytes
val totalBytes = stat.totalBytes
return Pair(availableBytes, totalBytes)
}
private fun isDeviceRooted(): Boolean {
val paths = arrayOf(
"/system/app/Superuser.apk",
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/system/sd/xbin/su",
"/system/bin/failsafe/su",
"/data/local/su"
)
return paths.any { File(it).exists() }
}
}
isRooted()
Функцией isRooted проверяется, «рутовано» ли устройство Android — получены ли на нем права суперпользователя или администратора. На рутованном устройстве пользователи минуют ограничения операционной системы: вносят изменения в системные файлы, устанавливают приложения неизвестных разработчиков или выполняют другие нетривиальные операции.
- Рутованные устройства: рутовано ли устройство, на Android определяется проверками. Например, наличие бинарных файлов
suили конкретных путей к файлам — это признак того, что устройство рутовано. - Возвращаемое значение: функцией обычно проверяется наличие файлов или путей файловой системы, которые на рутованных устройствах, как правило, имеются — например,
/system/bin/su. Если таковые находятся, устройство считается рутованным и функцией возвращаетсяtrue. В противном случае возвращаетсяfalse, то есть устройство не рутовано.
Для ios:
actual class DeviceInfoProvider {
actual fun getDeviceInfo(): DeviceInfo {
val (storageAvailable, totalStorage) = getStorageInfo()
return DeviceInfo(
deviceModel = UIDevice.currentDevice.model,
manufacturer = "Apple",
osVersion = UIDevice.currentDevice.systemVersion,
sdkVersion = "16",
screenResolution = getScreenResolution(),
locale = NSLocale.currentLocale.languageCode,
batteryLevel = getBatteryLevel(),
isCharging = isDeviceCharging(),
isConnectedToWifi = true,
storageAvailable = storageAvailable,
totalStorage = totalStorage,
isRooted = false
)
}
@OptIn(ExperimentalForeignApi::class)
private fun getScreenResolution(): String {
val screen = UIScreen.mainScreen
return "${screen.bounds.size}x${screen.bounds.size}"
}
private fun getBatteryLevel(): Int {
return (UIDevice.currentDevice.batteryLevel * 100).toInt()
}
private fun isDeviceCharging(): Boolean {
return UIDevice.currentDevice.batteryState == UIDeviceBatteryState.UIDeviceBatteryStateCharging
}
@OptIn(ExperimentalForeignApi::class)
private fun getStorageInfo(): Pair<Long, Long> {
val fileManager = NSFileManager.defaultManager
val storagePath = fileManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null
) ?: return Pair(0, 0)
val attributes =
fileManager.attributesOfFileSystemForPath(storagePath.path ?: "", null)
?: return Pair(0, 0)
val availableStorage = attributes[NSFileSystemSize] as? Long ?: 0
val totalStorage = attributes[NSFileSystemSize] as? Long ?: 0
return Pair(availableStorage, totalStorage)
}
}
Предоставим источник данных для навигационных цепочек в commonMain/domain/source:
interface BreadcrumbDataSource {
suspend fun addBreadcrumb(
breadcrumb: DeviceBreadCrumb
)
fun getBreadcrumbs(): Flow<List<DeviceBreadCrumb>>
}
Репозиторий тоже, но сначала создадим область супервизора для вызова приостанавливающей функции, в случае сбоя навигационные цепочки продолжают логироваться с сохранением логов локально, так смоделируем библиотеку в реальных условиях:
interface AppSupervisorScope {
operator fun invoke(): CoroutineScope
}
class DefaultAppSupervisorScope() : AppSupervisorScope {
override fun invoke(): CoroutineScope = CoroutineScope(
Dispatchers.IO + SupervisorJob()
)
}
interface AppCrashSource {
suspend fun addAppCrash(error: UncaughtErrorModel)
fun getAppCrashReport(): Flow<List<UncaughtErrorModel>>
}
Теперь репозиторий:
interface KrashlyticsRepository {
fun log(breadcrumb: Breadcrumb)
fun logFatal(error: Throwable)
fun getAppReport(): Flow<List<DeviceBreadCrumb>>
fun getAppCrashReport(): Flow<List<UncaughtErrorModel>>
}
С предметной областью закончили, создадим слой данных и настроим Room.
В commonMain/data/database/entities создаем сущности Room:
@Entity(tableName = "breadcrumbs")
data class DeviceBreadCrumbEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@Embedded val breadcrumb: BreadcrumbEntity,
@Embedded val deviceInfo: DeviceInfoEntity,
)
data class BreadcrumbEntity(
val type: String,
val message: String,
val timestamp: Long,
val data: Map<String, String?>?
)
data class DeviceInfoEntity(
val deviceModel: String,
val manufacturer: String,
val osVersion: String,
val sdkVersion: String,
val screenResolution: String,
val locale: String,
val batteryLevel: Int,
val isCharging: Boolean,
val isConnectedToWifi: Boolean,
val storageAvailable: Long,
val totalStorage: Long,
val isRooted: Boolean
)
@Entity("uncaughtError")
data class UncaughtErrorEntity(
@PrimaryKey(autoGenerate = true)
val id: Long? = null,
val cause: String? = null,
val errorMessage: String? = null,
val causedAtMillis: Long = DateTime.currentTimeMillis(),
@Embedded
val deviceInfo: DeviceInfoEntity,
)
Еще нужны преобразователи для полей карты в классах, создадим класс:
class AppDatabaseCnvertors {
@TypeConverter
fun fromMap(value: Map<String, String?>?): String? {
return Json.encodeToString(value)
}
@TypeConverter
fun toMap(value: String?): Map<String, String?>? {
return value?.let { Json.decodeFromString(it) }
}
}
Используя KotlinX Serialization, кодируем и декодируем toString и fromString.
Затем, чтобы привязать сущности Room к моделям предметной области, создаем модули сопоставления:
fun DeviceInfoEntity.toDomain() = DeviceInfo(
deviceModel = deviceModel,
manufacturer = manufacturer,
osVersion = osVersion,
sdkVersion = sdkVersion,
screenResolution = screenResolution,
locale = locale,
batteryLevel = batteryLevel,
isCharging = isCharging,
isConnectedToWifi = isConnectedToWifi,
storageAvailable = storageAvailable,
totalStorage = totalStorage,
isRooted = isRooted
)
fun DeviceInfo.toEntity() = DeviceInfoEntity(
deviceModel = deviceModel,
manufacturer = manufacturer,
osVersion = osVersion,
sdkVersion = sdkVersion,
screenResolution = screenResolution,
locale = locale,
batteryLevel = batteryLevel,
isCharging = isCharging,
isConnectedToWifi = isConnectedToWifi,
storageAvailable = storageAvailable,
totalStorage = totalStorage,
isRooted = isRooted
)
fun DeviceBreadCrumb.toEntity(): DeviceBreadCrumbEntity {
return DeviceBreadCrumbEntity(
breadcrumb = BreadcrumbEntity(
type = this.breadcrumb.type.name,
message = this.breadcrumb.message,
timestamp = this.breadcrumb.timestamp,
data = this.breadcrumb.data
),
deviceInfo = deviceInfo.toEntity()
)
}
fun DeviceBreadCrumbEntity.toDomain(): DeviceBreadCrumb {
return DeviceBreadCrumb(
breadcrumb = Breadcrumb(
type = BreadCrumbType.valueOf(this.breadcrumb.type),
message = this.breadcrumb.message,
timestamp = this.breadcrumb.timestamp,
data = this.breadcrumb.data
),
deviceInfo = deviceInfo.toDomain()
)
}
fun UncaughtErrorModel.toEntity() = UncaughtErrorEntity(
errorMessage = errorMessage,
cause = cause,
causedAtMillis = causedAtMillis,
deviceInfo = deviceInfo.toEntity(),
)
fun UncaughtErrorEntity.tDomain() = UncaughtErrorModel(
id = "${id}",
errorMessage = errorMessage,
cause = cause,
causedAtMillis = causedAtMillis,
deviceInfo = deviceInfo.toDomain(),
)
Для работы Room и для источников создадим объекты доступа к данным:
@Dao
interface UnCaughtErrorDao {
@Upsert
suspend fun addUnCaughtError(error: UncaughtErrorEntity)
@Query("SELECT * FROM uncaughtError")
fun getAllUnCaughtErrors(): Flow<List<UncaughtErrorEntity>>
}
@Dao
interface BreadCrumbDao {
@Upsert
suspend fun addBreadCrumb(breadcrumb: DeviceBreadCrumbEntity)
@Query("SELECT * FROM breadcrumbs")
fun getAllDeviceBreadCrumbs(): Flow<List<DeviceBreadCrumbEntity>>
}
Чтобы объявить базу данных, создаем в ней .kt-файл AppDatabase.kt:
@Database(
exportSchema = false,
entities = [
DeviceBreadCrumbEntity::class,
UncaughtErrorEntity::class,
],
version = 1,
)
@TypeConverters(AppDatabaseCnvertors::class)
@ConstructedBy(AppDatabaseCreator::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun breadCrumbDao(): BreadCrumbDao
abstract fun unCaughtErrorDao(): UnCaughtErrorDao
}
expect object AppDatabaseCreator : RoomDatabaseConstructor<AppDatabase>
Благодаря KSP объект AppDatabaseCreator создастся для ios и Android автоматически: для ios в gradle заранее объявили, где и как при помощи KSP он создается, для Android дальнейшей работы не требуется.
Теперь ради простоты выполним внедрение зависимостей вручную, инверсию управления пока не применяем, хотя для внедрения всех создаваемых здесь зависимостей можно воспользоваться Koin.
Продолжим реализовывать слой данных, в качестве источника данных воспользуемся только что созданными объектами доступа к данным.
В data/source/ создаем RoomAppCrashSource:
class RoomAppCrashSource(
private val dao: UnCaughtErrorDao,
) : AppCrashSource {
override suspend fun addAppCrash(error: UncaughtErrorModel) {
try {
withContext(Dispatchers.IO) {
dao.addUnCaughtError(error.toEntity())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
}
}
override fun getAppCrashReport(): Flow<List<UncaughtErrorModel>> =
dao.getAllUnCaughtErrors().map { list -> list.map { it.tDomain() } }.flowOn(Dispatchers.IO)
}
Затем создаем RoomBreadcrumbDataSource:
class RoomBreadcrumbDataSource(
private val dao: BreadCrumbDao,
) : BreadcrumbDataSource {
override suspend fun addBreadcrumb(
breadcrumb: DeviceBreadCrumb
) {
try {
withContext(Dispatchers.IO) {
dao.addBreadCrumb(breadcrumb.toEntity())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
}
}
override fun getBreadcrumbs(): Flow<List<DeviceBreadCrumb>> =
dao.getAllDeviceBreadCrumbs().map { list -> list.map { it.toDomain() } }
.flowOn(Dispatchers.IO)
}
Реализуем репозиторий:
class DefaultKrashlyticsRepository(
private val breadcrumbDataSource: BreadcrumbDataSource,
private val appSupervisorScope: AppSupervisorScope,
private val deviceInfoProvider: DeviceInfoProvider,
private val appCrashSource: AppCrashSource
) : KrashlyticsRepository {
override fun log(breadcrumb: Breadcrumb) {
appSupervisorScope().launch {
val info = deviceInfoProvider.getDeviceInfo()
val deviceBreadcrumb = DeviceBreadCrumb(deviceInfo = info, breadcrumb = breadcrumb)
breadcrumbDataSource.addBreadcrumb(deviceBreadcrumb)
}
}
override fun logFatal(error: Throwable) {
appSupervisorScope().launch {
val info = deviceInfoProvider.getDeviceInfo()
appCrashSource.addAppCrash(
UncaughtErrorModel(
errorMessage = error.message ?: "Unknown Error",
cause = error.cause.toString(),
deviceInfo = info
)
)
}
}
override fun getAppReport(): Flow<List<DeviceBreadCrumb>> =
breadcrumbDataSource.getBreadcrumbs()
override fun getAppCrashReport(): Flow<List<UncaughtErrorModel>> =
appCrashSource.getAppCrashReport()
}
Чтобы использовать только что созданный экземпляр репозитория, для каждой платформы создадим локатор служб.
Для Android создаем в качестве контейнера пользовательский класс Application, в основном наборе Android создаем класс и расширяем приложение:
class KrashlyticsApp : Application() {
companion object {
lateinit var krashlyticsRepository: KrashlyticsRepository
lateinit var appDatabase: AppDatabase
}
private lateinit var breadcrumbDataSource: BreadcrumbDataSource
private lateinit var appCrashSource: AppCrashSource
override fun onCreate() {
super.onCreate()
appDatabase = Room.databaseBuilder(
this,
AppDatabase::class.java,
"krash.db"
).build()
val scope = DefaultAppSupervisorScope()
val deviceInfoProvider = DeviceInfoProvider(this)
breadcrumbDataSource = RoomBreadcrumbDataSource(appDatabase.breadCrumbDao())
appCrashSource = RoomAppCrashSource(appDatabase.unCaughtErrorDao())
krashlyticsRepository = DefaultKrashlyticsRepository(
breadcrumbDataSource = breadcrumbDataSource,
appSupervisorScope = scope,
deviceInfoProvider = deviceInfoProvider,
appCrashSource = appCrashSource
)
}
}
В AndroidManifest.xml не забываем упомянуть вот этот класс:
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:name=".KrashlyticsApp"
Теперь в качестве синглтона репозитория создадим ожидаемую функцию в commonMain/platform:
expect fun krashlytics(): KrashlyticsRepository
Для Android воспользуемся статическим экземпляром, который делается после создания приложения:
actual fun krashlytics(): KrashlyticsRepository = KrashlyticsApp.krashlyticsRepository
Для ios не нужен контекст, экземпляр создается легко:
actual fun krashlytics(): KrashlyticsRepository {
val database = createDatabaseBuilder()
val breadCrumbDataSource = RoomBreadcrumbDataSource(database.breadCrumbDao())
val appCrashDataSource = RoomAppCrashSource(database.unCaughtErrorDao())
val scope = DefaultAppSupervisorScope()
val deviceInfo = DeviceInfoProvider()
val krash = DefaultKrashlyticsRepository(
breadcrumbDataSource = breadCrumbDataSource,
appCrashSource = appCrashDataSource,
appSupervisorScope = scope,
deviceInfoProvider = deviceInfo
)
return krash
}
Теперь прослушаем все выброшенные неперехваченные сообщения для обеих платформ.
Для Android воспользуемся потоками Java, здесь это основной строительный блок, в нем имеется функция, которой отслеживается любое неперехваченное исключение в контексте текущего потока.
В androidMain/ расширяем UncaughtExceptionHandler и потом делаем с этим отслеживаемым неперехваченным исключением все что угодно:
class AndroidUnCaughtExceptionHandler(
private val krashlytics: KrashlyticsRepository,
) : UncaughtExceptionHandler {
private val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
override fun uncaughtException(thread: Thread, e: Throwable) {
krashlytics.logFatal(e)
defaultHandler?.uncaughtException(thread, e)
}
}
Возвращаемся к Android и в созданном пользовательском классе Application инстанцируем этот класс:
override fun onCreate() {
// предыдущая логика
Thread.setDefaultUncaughtExceptionHandler(
AndroidUnCaughtExceptionHandler(
krashlytics = krashlyticsRepository
)
)
}
Для ios то же самое, но отслеживается только NSException, который относится к нативному IOS. Неперехваченными в приложении окажутся в основном исключения Kotlin, поэтому обработаем то и другое.
В Kotlin Multiplatform добраться до нативного ios легко, поэтому начнем с обработки NSException.
В iosMain/MainViewController объявляем такой метод:
@OptIn(ExperimentalForeignApi::class)
private fun handleNSUncaughtException(
krashlytics: KrashlyticsRepository,
) {
val handler: CPointer<NSUncaughtExceptionHandler> = staticCFunction { nsException ->
val cause = Throwable(nsException?.reason)
val throwable = Throwable(message = nsException?.name, cause)
krashlytics.logFatal(throwable)
}
NSSetUncaughtExceptionHandler(handler)
}
В NSSetUncaughtExceptionHandler для отслеживания выброшенного NSException задается обработчик.
Итак, требовалось определить функцию с выброшенным и уже предоставленным системой NSException, затем для отправки в репозиторий преобразовать в Kotlin Throwable.
Сейчас в Kotlin Multiplatform имеется функция, которой задается хук неперехваченного исключения типа Kotlin:
setUnhandledExceptionHook { exception ->
}
Если оставить все как есть, то после выбрасывания неперехваченного исключения приложение никогда не завершится аварийно и никогда не закроется, но все равно зависнет. Поэтому предложили другую функцию, с которой приложение завершается после того, как поработать с этим неперехваченным исключением:
terminateWithUnhandledException(exception)
Соединяем все вместе:
setUnhandledExceptionHook { exception ->
krash.logFatal(exception)
terminateWithUnhandledException(exception)
}
В MainViewController вызываем оба метода и отправляем ошибку в репозиторий:
@OptIn(ExperimentalNativeApi::class)
fun MainViewController() = ComposeUIViewController(
) {
val krash = krashlytics()
setUnhandledExceptionHook { exception ->
krash.logFatal(exception)
terminateWithUnhandledException(exception)
}
handleNSUncaughtException(krash)
App()
}
Теперь для логирования экрана/навигации создадим специальный composable в commonMain/:
package com.mohaberabi.kmp.krashlytics.presentation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.mohaberabi.kmp.krashlytics.domain.model.BreadCrumbType
import com.mohaberabi.kmp.krashlytics.domain.model.Breadcrumb
import com.mohaberabi.kmp.krashlytics.platform.DateTime
import com.mohaberabi.kmp.krashlytics.platform.krashlytics
import kotlinx.coroutines.internal.ThreadSafeHeapNode
import kotlinx.coroutines.launch
@Composable
fun TrackedCompose(
composedTag: String,
disposedTag: String,
composedExtras: Map<String, String?>? = null,
disposedExtras: Map<String, String?>? = null,
content: @Composable () -> Unit
) {
val krash = remember {
krashlytics()
}
val composedBreadCrumb = remember {
Breadcrumb(
BreadCrumbType.Log,
composedTag,
DateTime.currentTimeMillis(),
composedExtras
)
}
val disposedBreadCrumb = remember {
Breadcrumb(
BreadCrumbType.Log,
disposedTag,
DateTime.currentTimeMillis(),
disposedExtras
)
}
LaunchedEffect(Unit) {
krash.log(
composedBreadCrumb
)
}
DisposableEffect(Unit) {
onDispose {
krash.log(
disposedBreadCrumb
)
}
}
content()
}
Логируем сведения, когда именно случилась композиция и когда composable ее «покидает», и отображаем нужный в контенте пользовательский интерфейс.
Теперь протестируем приложение и испытаем в деле эти composable пользовательского интерфейса, создадим их для сведений об устройстве:
@Composable
fun DeviceInfoCompose(deviceInfo: DeviceInfo) {
Column {
Text("Model: ${deviceInfo.deviceModel}", style = MaterialTheme.typography.bodyMedium)
Text(
"Manufacturer: ${deviceInfo.manufacturer}",
style = MaterialTheme.typography.bodyMedium
)
Text("OS Version: ${deviceInfo.osVersion}", style = MaterialTheme.typography.bodyMedium)
Text(
"Battery Level: ${deviceInfo.batteryLevel}%",
style = MaterialTheme.typography.bodyMedium
)
Text(
"Charging: ${if (deviceInfo.isCharging) "Yes" else "No"}",
style = MaterialTheme.typography.bodyMedium
)
Text(
"WiFi Connected: ${if (deviceInfo.isConnectedToWifi) "Yes" else "No"}",
style = MaterialTheme.typography.bodyMedium
)
}
}
И еще один для навигационных цепочек:
@Composable
fun DeviceBreadCrumbItem(deviceBreadCrumb: DeviceBreadCrumb) {
var expanded by remember { mutableStateOf(false) }
Card(
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { expanded = !expanded }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = deviceBreadCrumb.breadcrumb.type.name,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
Text(
text = "Message: ${deviceBreadCrumb.breadcrumb.message}",
)
Text(
text = "Timestamp: ${deviceBreadCrumb.breadcrumb.timestamp.fromMillis}",
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Device Info",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
modifier = Modifier.padding(vertical = 4.dp)
)
DeviceInfoCompose(deviceBreadCrumb.deviceInfo)
}
}
}
}
}
@Composable
fun UncaughtErrorItem(uncaughtErrorModel: UncaughtErrorModel) {
var expanded by remember { mutableStateOf(false) }
Card(
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { expanded = !expanded }
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Error at ${uncaughtErrorModel.causedAtMillis.fromMillis}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(modifier = Modifier.padding(top = 12.dp)) {
uncaughtErrorModel.cause?.let {
Text(
text = "Cause: $it",
color = Color.Red,
style = MaterialTheme.typography.bodyMedium
)
}
uncaughtErrorModel.errorMessage?.let {
Text(
text = "Error Message: $it",
color = Color.Red,
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(8.dp))
DeviceInfoCompose(uncaughtErrorModel.deviceInfo)
}
}
}
}
}
Создадим внутри отслеживаемого composable экран:
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
onClose: () -> Unit,
) {
Scaffold(
) { padding ->
Column(
modifier = modifier.padding(padding).fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
"Home Screen",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Button(
onClick = onClose,
) { Text("Close") }
}
}
}
Теперь сделаем главный экран точки входа в приложение:
@Composable
@Preview
fun App() {
val krashlytics = remember { krashlytics() }
val breadcrumbs by krashlytics.getAppReport().collectAsStateWithLifecycle(listOf())
val crashes by krashlytics.getAppCrashReport().collectAsStateWithLifecycle(listOf())
var showHomeScreen by remember {
mutableStateOf(false)
}
MaterialTheme {
Scaffold { padding ->
LazyColumn(
modifier = Modifier
.padding(padding)
.fillMaxSize()
) {
item {
Button(
modifier = Modifier.fillMaxWidth().padding(12.dp),
onClick = {
krashlytics.log(
Breadcrumb(BreadCrumbType.Log, "some message"),
)
},
) {
Text("Log Test")
}
Button(
modifier = Modifier.fillMaxWidth().padding(12.dp),
onClick = {
showHomeScreen = true
},
) {
Text("Open trackable screen")
}
}
item {
Button(
modifier = Modifier.fillMaxWidth().padding(12.dp),
onClick = {
throw RuntimeException("Thrown by me ")
},
) {
Text("Throw Test")
}
}
item {
Text("Your app breadcrumbs")
}
items(
breadcrumbs,
) { bread ->
DeviceBreadCrumbItem(bread)
}
item {
Text("Your app crash report")
}
items(
crashes,
) { crash ->
UncaughtErrorItem(crash)
}
}
}
if (showHomeScreen) {
TrackedCompose(
composedTag = "Home Screen opened ",
disposedTag = "Home Screen closed"
) {
HomeScreen(
onClose = { showHomeScreen = false },
)
}
}
}
}
Запустим приложение. Как только отобразится главный экран, он логируется при компоновке и при удалении, а кроме того, в реальном времени логируется протоколирование тестирования. Когда выбрасывается исключение, приложение аварийно завершается, но после перезапуска исключение появляется в отчете о сбоях — оно залогировано.
Заключение
Мы создали простую, но эффективную библиотеку Krashlytics для обработки навигационных цепочек и логов сбоев приложений в среде Kotlin Multiplatform, использовали Room для длительного сохранения логов, смоделировав таким образом условия реального сценария.
Хотя это всего лишь пример реализации подобных библиотек, задействованный здесь Room вполне переключается вместе с бэкендом на отправку в api.
Вот репозиторий Github.
Читайте также:
- Преобразуем проект в мультиплатформенный с Kotlin Multiplatform: зачем, когда и как
- Kotlin Multiplatform: как усовершенствовать процесс разработки iOS
- 6 рекомендаций по запуску современной кодовой базы Android с нуля
Читайте нас в Telegram, VK и Дзен
Перевод статьи Mohab erabi: Develop Your Own Crashlytics Library Using Kotlin Multiplatform




