Find Non-Lambda Log Calls 是一个面向 Kotlin/Android 代码库的日志卫生审计技能,用于发现并修复三类相关的日志调用问题。
用途
该技能适用于以下场景:合并了新增日志的分支之后;对日志卫生进行周期性审计;将 android.util.Log 用法迁移到共享 Log 包装器之后。
主要能力
技能覆盖三类问题:
-
缺少 lambda 重载。使用字符串插值但未使用 lambda 重载的 Log.d/i/w/e 调用,在 release 构建中日志级别被过滤掉时仍会浪费字符串分配。
-
catch 块中丢弃异常。catch (e: ...) 块内的 Log.w/e 调用插值了 ${e.message} 却没有传入 e,会丢失堆栈跟踪,并且在 e.message 为 null(NPE、无消息的 IOException 等)时记录不到有用信息。
-
仍在使用 android.util.Log。导入平台日志器的文件绕过了 Log.minLevel 和 LogSink,且没有 lambda 重载,因此上述两项修复都无法应用,需先迁移。
使用方式
技能以分步搜索命令的形式给出操作流程:
Step 0:先查找仍在使用 android.util.Log 的文件。使用两个模式——仅用全限定名模式会产生漏报,因为几乎没人写 android.util.Log.w(...),通常是导入 android.util.Log 后写 Log.w(...),因此 import 才是可靠信号:
grep -rln --include='*.kt' '^import android.util.Log$' . | grep -v '/build/' | grep -v PlatformLog
grep -rnE --include='*.kt' 'android.util.Log.(d|i|w|e|v)(' . | grep -v '/build/' | grep -v PlatformLog
需排除 PlatformLog.android.kt,它是包装器实现,必须调用 android.util.Log。
Step 0b:模式是按行锚定的,需单独扫描多行调用。用行尾左括号形式捕获,再逐条阅读:
grep -rnE --include='*.kt' 'Log.[diwe]($' . | grep -v '/build/'
rg -U --multiline --type kotlin 'Log.[diwe](\n[^)]*${'
优先处理循环内的调用点,而非单行调用。
Step 1:查找插值的 Log.d/Log.i(最高优先级,release 中会被过滤):
pattern: Log.(d|i)("[^"]+",\s*"[^"]*$ type: kotlin
pattern: Log.(d|i)(\w+,\s*"[^"]*$ type: kotlin
Step 2:查找未传异常的插值 Log.w/Log.e,然后手动排除第三个参数传入了异常的行。注意 it 是最容易漏掉的名字,Result.onFailure { ... } 是常见形态,多数正确调用以 , it) 结尾而非 , e)。异常也不一定在行尾,因此排除规则不应锚定 $:
THROWABLE='(e|t|it|ex|err|error|throwable|cause|tr)'
grep -rnE --include='.kt' 'Log.(w|e)([^,]+,\s"[^"]$' . | grep -vE ",\s$THROWABLE)"
Step 3:查找 catch 块中丢弃异常的 Log.w/e。在 Step 2 命中项中,插值 ${e.message}(或 ${t.message}、${throwable.message})但未传入异常本身的调用是另一类 bug:
pattern: Log.(w|e)([^)]*${(e|t|it|ex|err|throwable|cause).message} type: kotlin
随后逐条打开文件确认该行位于 catch (e: ...) 块内且未将 e 作为第三个参数传入。误报包括接受 e: SomeError 参数并转发到别处的扩展函数/辅助函数。Step 2 与 Step 3 可能标记同一行,应先处理 Step 3,再对剩余项应用 Step 2。
修复写法
lambda 重载(Step 1 + Step 2):
// Before Log.d("Tag", "Processing event ${event.id} from ${relay.url}")
// After Log.d("Tag") { "Processing event ${event.id} from ${relay.url}" }
异常重载(Step 3):改用 (tag, msg, throwable),lambda 重载不接受异常,因此必须使用即时字符串形式,并从消息文本中去掉冗余的 ${e.message}:
// Before — 丢失堆栈,e.message 为 null 时打印 "...failed: null" try { groupManager.clearAllState() } catch (e: Exception) { Log.w("MarmotManager", "clearAllState failed: ${e.message}") }
// After — 记录完整堆栈 try { groupManager.clearAllState() } catch (e: Exception) { Log.w("MarmotManager", "clearAllState failed", e) }
权衡:即使 warn 被过滤,消息字符串也会被即时分配,但 warn 级别的 catch 日志属于罕见事件路径,相比丢失诊断细节,这一开销可忽略。
不要转换的情况
不要转为 lambda:传入了 Throwable 参数的调用——lambda 重载 (tag) { message } 没有异常参数。
不要转为 lambda:任何导入了 android.util.Log 的文件中的调用。平台 Log 没有 lambda 重载,转换会编译失败并报 None of the following candidates is applicable。应先迁移文件,或保持调用不变。
无 $ 插值的静态字符串调用——没有分配收益。
被注释掉的日志调用。
catch 块之外有意记录 e.message 的信息性日志(罕见,通常表示异常已被处理,只有消息有意义)。
从 android.util.Log 迁移文件
这是为 Step 0 发现的文件解锁 Step 1–3 的前提。这是行为变更,应检查而非假设。迁移前需确认:文件使用了哪些级别(grep -hoE 'Log.[a-zA-Z]+' <files> | sort | uniq -c;包装器只有 d/i/w/e,没有 v,也没有 getStackTraceString,Log.v 调用没有直接等价物,需要决策而非重命名);门控是否会丢弃它们(LogLevel { DEBUG, INFO, WARN, ERROR },门控为 minLevel <= <level>,Amethyst.DEFAULT_LOG_LEVEL 在 debug 下为 INFO、release 下为 WARN,包装器自身默认为 DEBUG,因此 Log.w 和 Log.e 在所有构建类型和进程中都会保留,包括 Amethyst.init 运行之前,这对 :napplet 很重要;Log.d/Log.i 在 release 中会静默,需要有意决策);输出是否变化(不会,Android 上的 PlatformLogSink 委托给 android.util.Log,行原样进入 logcat);模块能否看到 quartz(nappletHost 已有 implementation(project(":quartz")),需先检查)。
然后:将 import android.util.Log 换成 import com.vitorpamplona.quartz.utils.Log,运行 ./gradlew spotlessApply(导入顺序会变化),仅将插值且无异常的调用转换为 lambda 形式。已传入异常的调用保持即时三参数形式——包装器的 w(tag, msg, throwable) 完全匹配,只需移动导入。
验证异常是否保留,因为粗心的重写可能静默丢弃第三个参数:
grep -hoE 'Log.[diwe]([^)],\s(e|it))' <files> | wc -l # 比较前后
适用场景与限制
该技能面向 Kotlin/Android 代码库,尤其是使用共享 Log 包装器(如 Amethyst 项目中的 quartz Log)的工程。搜索前需过滤噪声,否则统计会误导:丢弃 /build/、/androidTest/ 和 /src/test/(release 过滤不适用于测试),并丢弃首个非空白字符为 // 或 * 的行(注释掉的调用和 KDoc 示例都会匹配这些模式),可用 grep -vE ':[0-9]+: *(//|*)' 处理后者。标签可以是字符串字面量("Tag")或变量(tag、LOG_TAG),每一步都要运行两种模式。技能中的具体数字(如 2026-08-28 的扫描结果)为资料中记录的历史观察,非通用保证。