15、日志注解处理:使用注解自动生成日志标签、AOP风格的日志拦截
日志这件事,说实话,每个项目都在做。但做得好的,真不多。
我见过太多项目里,日志标签靠手写,类名一改就忘了同步。也见过AOP日志拦截写得极其复杂,最后没人敢动。今天这一章,我们就来聊聊怎么用注解+反射+APT,把这堆破事自动化。
15.1 日志标签的痛点
先问你一个问题:你项目里的日志标签是怎么写的?
我猜八成是这样的:
class UserManager {
private val TAG = "UserManager"
fun login() {
Log.d(TAG, "login: start")
}
}
看起来没问题对吧?但实际维护起来,全是坑。
- 类名改了,TAG忘了改——日志里还是老名字
- 复制粘贴代码,TAG没改——两个类用同一个TAG
- 团队里有人写"UserMgr",有人写"UserManager"——日志没法统一过滤
我在一个日活千万级的项目里,就吃过这个亏。线上排查问题,grep日志发现同一个类有七八种TAG写法,差点没把运维逼疯。
15.2 用注解自动生成日志标签
解决方案其实很简单:用注解标记类,编译期自动生成TAG。
15.2.1 定义注解
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class LogTag(
val prefix: String = "",
val useSimpleName: Boolean = true
)
这里我故意把Retention设成SOURCE。为什么?因为TAG只在编译期需要,运行时根本用不到这个注解。省点内存,也避免反射开销。
15.2.2 注解处理器实现
@AutoService(Processor::class)
@SupportedOptions(LogTagProcessor.KAPT_KOTLIN_GENERATED_OPTION_NAME)
@SupportedSourceVersion(SourceVersion.RELEASE_8)
class LogTagProcessor : AbstractProcessor() {
override fun getSupportedAnnotationTypes(): Set<String> {
return setOf(LogTag::class.java.canonicalName)
}
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(LogTag::class.java).forEach { element ->
val annotation = element.getAnnotation(LogTag::class.java)
val className = element.simpleName.toString()
val tag = buildTag(className, annotation)
generateTagFile(element, tag)
}
return true
}
private fun buildTag(className: String, annotation: LogTag): String {
return if (annotation.prefix.isNotEmpty()) {
"${annotation.prefix}_$className"
} else {
className
}
}
private fun generateTagFile(element: Element, tag: String) {
val packageName = processingEnv.elementUtils
.getPackageOf(element).qualifiedName.toString()
val className = element.simpleName.toString()
val fileBuilder = FileSpec.builder(packageName, "${className}_LogTag")
.addProperty(
PropertySpec.builder("TAG", String::class)
.initializer("%S", tag)
.addModifiers(KModifier.CONST)
.build()
)
.build()
fileBuilder.writeTo(processingEnv.filer)
}
}
这段代码的核心逻辑就三件事:
- 找到所有加了@LogTag的类
- 根据注解参数拼出TAG字符串
- 生成一个带TAG常量的文件
生成的文件长这样:
// 自动生成,请勿手动修改
package com.example.app
object UserManager_LogTag {
const val TAG = "UserManager"
}
使用的时候:
@LogTag
class UserManager {
fun login() {
Log.d(UserManager_LogTag.TAG, "login: start")
}
}
15.3 AOP风格的日志拦截
日志标签解决了「谁在打日志」的问题。但还有一个更头疼的问题:方法调用的日志怎么打?
你想想看,每个方法入口出口都写Log.d,代码里一半都是日志。而且万一要统一加个耗时统计,得改几百个方法。
15.3.1 定义拦截注解
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class LogMethod(
val level: LogLevel = LogLevel.DEBUG,
val logParams: Boolean = true,
val logResult: Boolean = true,
val logDuration: Boolean = true
)
enum class LogLevel {
DEBUG, INFO, WARN, ERROR
}
注意这里Retention是RUNTIME。因为AOP拦截需要在运行时通过反射拿到注解信息。嗯,这里要权衡一下性能,后面我会讲怎么优化。
15.3.2 基于动态代理的AOP实现
Android里做AOP,我推荐用动态代理。轻量,不引入额外框架,团队上手快。
class LogProxy<T>(private val target: T) : InvocationHandler {
@Suppress("UNCHECKED_CAST")
fun create(): T {
return Proxy.newProxyInstance(
target::class.java.classLoader,
target::class.java.interfaces,
this
) as T
}
override fun invoke(proxy: Any, method: Method, args: Array<Any?>?): Any? {
val annotation = method.getAnnotation(LogMethod::class.java) ?: return method.invoke(target, *(args ?: emptyArray()))
val tag = target::class.java.simpleName
val methodName = method.name
// 方法开始日志
if (annotation.logParams) {
logWithLevel(annotation.level, tag, "$methodName params: ${args?.contentToString() ?: "no params"}")
}
// 耗时统计
val startTime = if (annotation.logDuration) System.currentTimeMillis() else 0L
return try {
val result = method.invoke(target, *(args ?: emptyArray()))
// 方法结束日志
if (annotation.logDuration) {
val duration = System.currentTimeMillis() - startTime
logWithLevel(annotation.level, tag, "$methodName completed in ${duration}ms")
}
if (annotation.logResult) {
logWithLevel(annotation.level, tag, "$methodName result: $result")
}
result
} catch (e: Exception) {
logWithLevel(LogLevel.ERROR, tag, "$methodName error: ${e.message}")
throw e
}
}
private fun logWithLevel(level: LogLevel, tag: String, message: String) {
when (level) {
LogLevel.DEBUG -> Log.d(tag, message)
LogLevel.INFO -> Log.i(tag, message)
LogLevel.WARN -> Log.w(tag, message)
LogLevel.ERROR -> Log.e(tag, message)
}
}
}
使用方式:
interface IUserService {
@LogMethod(level = LogLevel.INFO, logDuration = true)
fun login(username: String, password: String): Boolean
}
class UserServiceImpl : IUserService {
override fun login(username: String, password: String): Boolean {
Thread.sleep(100) // 模拟网络请求
return true
}
}
// 使用
val service = LogProxy(UserServiceImpl()).create()
service.login("admin", "123456")
// 输出: I/UserServiceImpl: login params: [admin, 123456]
// 输出: I/UserServiceImpl: login completed in 102ms
// 输出: I/UserServiceImpl: login result: true
15.4 性能优化与避坑
反射和AOP,性能是绕不开的话题。我直接说结论:
| 方案 | 性能损耗 | 推荐场景 |
|---|---|---|
| APT生成TAG常量 | 零损耗(编译期完成) | 所有项目 |
| 动态代理AOP | 每次调用多0.1-0.5ms | 非高频调用方法 |
| ASM字节码插桩 | 零运行时损耗 | 高频调用、性能敏感 |
我的建议是:
- 日志标签生成,无脑用APT,零成本
- 日志拦截,低频方法用动态代理,高频方法用ASM
- 不要在循环里用反射调方法,那真的是灾难
核心思路总结:
日志注解处理,说白了就是把「写日志」这件事从人工变成自动。APT负责编译期生成TAG,AOP负责运行时拦截方法。两者配合,你只需要关注业务逻辑,日志的事情交给注解去操心。
避坑指南:我曾经在一个项目里,把@LogMethod加到了每个方法上,结果发现启动速度慢了300ms。后来排查发现,有个循环里调了上千次被代理的方法。解决方案很简单:高频方法用ASM插桩,低频方法用动态代理。不要一刀切。
好了,这一章的内容就到这里。日志注解处理的核心思路,说白了就是「把重复劳动交给编译器,把拦截逻辑交给代理」。你只要把注解一加,剩下的自动搞定。