Привет, Хабр! Меня зовут Саша Лепехин, я разрабатываю мобильное приложение СберИнвестор под Android. Во время разработки и тестирования мобильного приложения мне часто нужно просматривать логи внутри приложения, причем без подключения по adb.
В принципе, есть множество приложений, которые позволяют просматривать содержимое logcat на устройств. Но с ними часто возникают самые разные проблемы: такие приложения нуждаются в специальных разрешениях или наличии root доступа, запускают отдельные процессы или сервисы. Или же они могут просто не работать на некоторых устройствах (например, там, где выключен logd). В этой небольшой статье предложено решение большинства подобных проблем. Оно основано на трансформации байт-кода приложения. Поехали!
Постановка задачи
Добавить возможность просмотра логов в приложении.
-
Фича должна работать только в debug build type.
-
Где-то в приложении должен быть RecyclerView, содержащий последние N записей из лога.
-
Важно, чтобы можно было просматривать сообщения, залогированные как внутри нашего приложения (исходный код принадлежит нам и мы можем вносить в него изменения), так и во внешних библиотеках.
-
Нет необходимости в логах операционной системы и других приложений.
Разбираемся с проблемами
Runtime.exec()
Задача с первого взгляда простая и решение, которое сразу приходит в голову — выполнить команду logcat с помощью, допустим, Runtime.exec(). То есть — получить список сообщений, распарсить какой-нибудь регуляркой и вывести на экран. Но на самом деле это не очень хорошая реализация. Дело в том, что мы выполняем какую-то стороннюю команду, поведение которой нами не регулируется — может измениться формат вывод, из-за чего придется править регулярное выражение, да и логирование может быть выключено на уровне операционной системы. В итоге получим не очень стабильное решение.
Но есть же Timber!
К сожалению, Timber тут не поможет. Можно было бы сделать свою реализацию Timber.log.Tree, но это никак не решит проблему с логированием сообщений из внешних библиотек, к которым нет доступа.
Требование |
Runtime.exec(«logcat…») |
Timber |
Просмотр сообщений залогированных из внешних модулей |
+ |
— |
Стабильность. Работа независимо от настроек системы/устройства |
— |
+ |
Еще варианты?
Здорово, если бы существовала возможность перехватить вызов методов android.util.Log и сохранить сообщение в свой Storage. Почему здорово? В этом случае не нужно логировать другие приложения, поэтому достаточно в apk поменять вызовы android.util.Log на какой-то собственный логгер). И это вполне реализуемо.
Самое время проанализировать, из каких частей может состоять наше решение.
-
Storage — место где хранятся залогированные сообщения (например, какой-нибудь циклический буфер в памяти).
-
Appender — компонент, который доставляет сообщение в storage.
-
Viewer — читает сообщения из storage и выводит пользователю.
В системе логирования Android можно сказать, что storage — набор циклических буферов, управляемых демоном logd, Appender — android.util.Log и библиотека liblog, а Viewer — logcat (https://developer.android.com/studio/command-line/logcat#Overview, https://developer.android.com/reference/android/util/Log). В коде вызываем методы android.util.Log, которые вызывают метод из liblog, а он, в свою очередь, отправляет сообщение в соответствующий буфер.
android.util.Log.d() → my.app.util.Log.d()
Gradle и AGP позволяют преобразовывать байт-код. Можно написать свой AsmClassVisitorFactory и ClassVisitor, который и будет производить эту трансформацию. Такая возможность есть — подобные преобразования выполняют многие библиотеки и фреймворки.
Что для этого нужно:
-
Реализовать свой Appender, методы которого будут вызываться вместо android.util.Log.
-
Написать ClassVisitor, который будет преобразовывать вызовы методов android.util.Log в соответствующие методы Appender. Тут воспользуемся относительно простым решением: в инструкции INVOKE_STATIC можно просто подменить один класс (method owner) на другой, при условии, что их дескрипторы идентичны (подходит и MethodHandle, но это доступно только начиная с «Android O»).
-
Воспользоваться удобной возможностью по преобразованию байт-кода предоставляемой AGP.
-
Реализовать свой Storage, где будут храниться логи.
-
Реализовать Viewer, чтобы можно было бы удобно просматривать логи, искать нужные сообщения и шарить логи между приложениями (почта/мессенджер).
Ну что ж, приступим…
К делу!
Appender
В реализации Appender (назовем его LogProxy) нет ничего сложного, но нужно обратить внимание на следующие моменты:
-
В принципе, это не обязательно, но для простоты реализации трансформера байт-кода повторим бинарный интерфейс методов логирования (v(), i(), w(), d(), e()) из класса android.uril.Log.
-
Кроме добавления сообщения в Storage оставим нативное логирование через android.util.Log, чтобы просматривать логи с помощью logcat.
-
Метод логирования должен быть быстрым и не блокировать поток, или сводить такие блокировки к минимуму.
Вот пример реализации Appender — LogProxy. В каждом методе потребуется реализовать сохранение сообщения в какой-то абстрактный Storage.
/** * Класс копирующий структуру методов из [Log]. * Используется для подмены вызовов к [Log] на уровне байт-кода. */ object LogProxy { @JvmStatic fun i(tag: String, message: String): Int { // todo: Тут нужно сохранить message в наш Storage return Log.i(tag, message) } @JvmStatic fun i(tag: String, message: String, throwable: Throwable): Int { //... } @JvmStatic fun d(tag: String, message: String): Int { //... } @JvmStatic fun d(tag: String, message: String, throwable: Throwable): Int { //... } @JvmStatic fun v(tag: String, message: String): Int { //... } @JvmStatic fun v(tag: String, message: String, throwable: Throwable): Int { //... } @JvmStatic fun w(tag: String, message: String): Int { //... } @JvmStatic fun w(tag: String, message: String, throwable: Throwable): Int { //... } @JvmStatic fun w(tag: String, throwable: Throwable): Int { //... } @JvmStatic fun e(tag: String, message: String): Int { //... } @JvmStatic fun e(tag: String, message: String, throwable: Throwable): Int { //... } }
Storage
Сначала опишем наш Storage, пока на уровне интерфейса. Ну и заодно создадим сущность, представляющую единичную запись лога — LogEntry.
/** * Хранилище логов [LogEntry] * * Реализация должна учитывать: * 1) методы могут быть вызваны из разных потоков * 2) метод [append] не должен блокировать поток или сводить такие блокировки к минимуму */ interface Storage { /** * Добавить [logEntry] * * @param logEntry экземпляр [LogEntry], который необходимо сохранить */ fun append(logEntry: LogEntry) /** * Снимок состояния хранилища логов. * Возвращает [Iterator]<[LogEntry]> по всем записям, * которые были добавлены методом [append] до вызова [snapshot] * * @return [Iterator]<[LogEntry]> копии содержимого буфера на момент вызова метода */ fun snapshot(): Iterator<LogEntry> /** * Удалить все записи [LogEntry] */ fun clear() }
/** * Сущность представляющая элемент (строку) в системе журналирования - то, что логируется при вызове * `android.util.Log.println(...)` * * @property tag [String] строка для идентификации источника сообщения * @property message [String] логируемое сообщение. Может быть multiline * @property priority [Priority] приоритет. * @property throwable [Throwable] * @property timestamp [Long] временная метка, время создания записи */ data class LogEntry( val tag: String, val message: String, val priority: Priority, val throwable: Throwable? = null, val timestamp: Long = System.currentTimeMillis() ) /** * Приоритет с которым логируется сообщение */ enum class Priority { VERBOSE, DEBUG, INFO, WARN, ERROR, ASSERT }
С доменной моделью вроде бы все, давайте теперь вернемся к LogProxy. Мы там оставили todo.
На данный момент нет конкретной реализации Storage (мы ее еще не написали), да и хотелось бы, чтобы была возможность использовать разные реализации, чтобы прямо в run-time указать нужный Storage, а не хардкодить это на уровне LogProxy.
Для этого создадим StorageDispatcher, который будет делегировать все свои вызовы конкретной реализации Storage.
/** * [Storage], который делегирует методы в [storage] * * Используется для переопределения реализации [Storage] в run-time */ object StorageDispatcher : Storage { /** * [Storage] которому будут делегированы функции по добавлению [LogEntry], очистке и создания снимков. */ var storage: Storage? = null override fun append(logEntry: LogEntry) { storage?.append(logEntry) } override fun snapshot(): Iterator<LogEntry> = storage?.snapshot() ?: Collections.emptyIterator() override fun clear() { storage?.clear() } }
И теперь методы LogProxy принимают вот такой вид:
@JvmStatic fun i(tag: String, message: String): Int { StorageDispatcher.storage?.append( LogEntry( tag = tag, message = message, priority = Priority.INFO ) ) return Log.i(tag, message) }
Трансформация байт-кода
Теперь самое интересное — будем преобразовывать байт-код.
Начнем с реализации ClassVisitor. Напомню, что для этого потребуется в инструкции INVOKE_STATIC поменять method owner с andoid.util.log на наш LogProxy.
Если посмотреть на байт-код, то преобразование выглядит так:
До:
LINENUMBER 22 L0 LDC "Logging debug1" LDC "Debug message" INVOKESTATIC android.util.Log.d (Ljava/lang/String;Ljava/lang/String;)I POP
После:
LINENUMBER 22 L0 LDC "Logging debug1" LDC "Debug message" INVOKESTATIC ru/sberbank/android/log/transformation/core/LogProxy.d (Ljava/lang/String;Ljava/lang/String;)I POP
Т.е. поменять строку «android.util.Log» на ru/sberbank/android/log/transformation/core/LogProxy». В этом поможет прекрасный фреймворк ASM. Вот так, например:
class MethodOwnerTransformationClassVisitor( api: Int, classVisitor: ClassVisitor ) : ClassVisitor(api, classVisitor) { override fun visitMethod( access: Int, name: String?, descriptor: String?, signature: String?, exceptions: Array<out String>? ): MethodVisitor { return object : MethodVisitor(api, super.visitMethod(access, name, descriptor, signature, exceptions)) { override fun visitMethodInsn( opcode: Int, owner: String?, name: String?, descriptor: String?, isInterface: Boolean ) { if (owner == "android/util/Log" && name == "d" && opcode == Opcodes.INVOKESTATIC) { mv.visitMethodInsn(opcode, "ru/sberbank/android/log/transformation/core/LogProxy", name, descriptor, isInterface) } else { super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) } } } } }
Получается, что visitMethodInsn выполняется как только встречает в коде инструкцию, которая вызывает какой-то метод. Далее проверяем: если это статический метод android.util.Log.d(…), то вместо исходной инструкции записываем новую, точно такую же, но вызывающую метод LogProxy.d().
Для простоты изложения пока ограничимся только методом d() и оставим хардкод в виде строк дескрипторов (думаю, что написать полную реализацию несложно). А пока напишем AsmClassVisitorFactory и gradle плагин, который будет запускать MethodOwnerTransformationClassVisitor.
abstract class LogTransformationFactory : AsmClassVisitorFactory<LogTransformationParameters> { override fun createClassVisitor( classContext: ClassContext, nextClassVisitor: ClassVisitor ): ClassVisitor { return if (isInstrumentable(classContext.currentClassData)) { MethodOwnerTransformationClassVisitor( api = instrumentationContext.apiVersion.get(), classVisitor = nextClassVisitor ) } else { nextClassVisitor } }
// Так как в LogProxy мы оставили вызов методов android.util.Log, то исключим его из классов, которые подлежат трансформации override fun isInstrumentable(classData: ClassData): Boolean = classData.className != "ru.sberbank.android.log.transformation.core.LogProxy" }
// gradle plugin description = "Convention-plugin для преобразования вызовов к android.util.Log" project.plugins.withType(AndroidBasePlugin::class) { extensions.getByType(AndroidComponentsExtension::class).onVariants { if (it.buildType == "debug") { it.transformClassesWith( LogTransformationFactory::class.java, InstrumentationScope.ALL ) { params -> // todo } } } }
Осталось сбилдить плагин и подключить его к приложению…
После декомпиляции собранного приложения можно видеть, что там, где раньше вызывался Log.d(), теперь вызывается LogProxy.d().
Осталось реализовать свой Storage. Он может быть in-memory, а может сохранять LogEntry на диск. Конкретная реализация Storage, как и компонентов для отображения его содержимого на экране устройства, уже не относится к теме этой статьи. Главное — не забывайте, что методы Storage могут быть вызваны из разных потоков. И помните, что логирование не должно ухудшать производительность приложения, даже в debug build type.
На этом все — если вдруг у вас возникли вопросы или предложения по теме, традиционно отписывайтесь в комментариях. Надеюсь, что статья окажется полезной для многих разработчиков.
Немного полезных ссылок
ссылка на оригинал статьи https://habr.com/ru/company/sberbank/blog/685142/
Добавить комментарий