Привет, Хабр! Меня зовут Саша Лепехин, я разрабатываю мобильное приложение СберИнвестор под 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.
На этом все — если вдруг у вас возникли вопросы или предложения по теме, традиционно отписывайтесь в комментариях. Надеюсь, что статья окажется полезной для многих разработчиков.
Немного полезных ссылок
AlexWoodblock
Интересный подход! Буду держать в уме для непослушных библиотек, не позволяющих читать свои логи.