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)
    }
}

这段代码的核心逻辑就三件事:

  1. 找到所有加了@LogTag的类
  2. 根据注解参数拼出TAG字符串
  3. 生成一个带TAG常量的文件

生成的文件长这样:

// 自动生成,请勿手动修改
package com.example.app

object UserManager_LogTag {
    const val TAG = "UserManager"
}

使用的时候:

@LogTag
class UserManager {
    fun login() {
        Log.d(UserManager_LogTag.TAG, "login: start")
    }
}
我的习惯:我会在团队规范里要求,所有日志必须通过自动生成的TAG来写。谁手写TAG,code review直接打回。一开始大家觉得麻烦,后来发现改类名再也不用改日志了,真香。

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
我曾经踩过的坑:动态代理要求目标类必须实现接口。如果你的类没有接口,可以考虑用ASM或者AspectJ。但我个人建议,Android项目尽量用接口+动态代理,简单可控,出了问题也好排查。

15.4 性能优化与避坑

反射和AOP,性能是绕不开的话题。我直接说结论:

方案 性能损耗 推荐场景
APT生成TAG常量 零损耗(编译期完成) 所有项目
动态代理AOP 每次调用多0.1-0.5ms 非高频调用方法
ASM字节码插桩 零运行时损耗 高频调用、性能敏感

我的建议是:

  • 日志标签生成,无脑用APT,零成本
  • 日志拦截,低频方法用动态代理,高频方法用ASM
  • 不要在循环里用反射调方法,那真的是灾难

核心思路总结:

日志注解处理,说白了就是把「写日志」这件事从人工变成自动。APT负责编译期生成TAG,AOP负责运行时拦截方法。两者配合,你只需要关注业务逻辑,日志的事情交给注解去操心。

日志注解处理知识体系 日志注解处理 日志标签自动生成(APT) @LogTag 注解 APT处理器 生成 TAG 常量文件 AOP风格日志拦截 @LogMethod 注解 动态代理AOP 方法参数日志 耗时统计 异常捕获 编译期生成 + 运行时拦截 = 零侵入日志方案

避坑指南:我曾经在一个项目里,把@LogMethod加到了每个方法上,结果发现启动速度慢了300ms。后来排查发现,有个循环里调了上千次被代理的方法。解决方案很简单:高频方法用ASM插桩,低频方法用动态代理。不要一刀切。

好了,这一章的内容就到这里。日志注解处理的核心思路,说白了就是「把重复劳动交给编译器,把拦截逻辑交给代理」。你只要把注解一加,剩下的自动搞定。

公众号:蓝海资料掘金营,微信deep3321