没有 adb 怎么排查接口问题?给 OkHttp 加一个"永不炸"的文件日志拦截器
场景:App 部署在几百台野外的工业终端上,出了接口问题没有 adb、没有抓包环境。我们需要一个设备本地可查的接口日志系统------写一个 OkHttp 拦截器,把每次请求/响应落盘,App 内置一个日志查看页面。
一、需求与设计原则
- 永不影响主流程:日志模块自己崩了也不能让请求失败;
- 响应体只能读一次 :OkHttp 的
ResponseBody是流,读了就没了,要安全地"偷看"; - 文件不能无限膨胀:按时间(24h)和大小(10MB)双阈值自动清理;
- 展示不能 OOM:10MB 文本直接塞进 TextView 必崩,需要分页/截断策略。
整体结构:
bash
OkHttpClient
└── FileLogInterceptor ← 拦截每次请求,解析参数与响应
└── ApiLogManager ← 负责落盘、清理、读取、截断
└── context.filesDir/api_log.txt
二、拦截器:安全地读取响应体
关键知识点:读响应体要克隆 Buffer,而不是消费原始流:
kotlin
class FileLogInterceptor(private val context: Context) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val requestTime = System.currentTimeMillis()
val requestParams = try {
parseParams(request)
} catch (e: Exception) {
"[解析参数失败: ${e.message}]"
}
try {
val response = chain.proceed(request)
val duration = System.currentTimeMillis() - requestTime
val responseBodyString = try {
val body = response.body()
if (body != null && isParseable(body.contentType())) {
printResult(response) // 见下方
} else {
"[非文本响应或文件下载]" // APK 下载这类二进制流不能读
}
} catch (e: Exception) {
"[解析响应失败: ${e.message}]"
}
safeWriteLog(/* 写入日志,内部全 catch */)
return response
} catch (e: Exception) {
// 请求本身失败也要记日志,但异常必须继续抛出
safeWriteLog(isSuccess = false, errorMsg = e.message)
throw e
}
}
private fun printResult(response: Response): String {
val responseBody = response.body() ?: return "[空响应体]"
val source = responseBody.source()
source.request(Long.MAX_VALUE) // 把全部数据读进 buffer
val buffer = source.buffer()
val encoding = response.headers()["Content-Encoding"]
val clone = buffer.clone() // ★ 克隆!原 buffer 留给业务方
return parseContent(responseBody, encoding, clone)
}
}
三个容易踩的坑:
buffer.clone()是灵魂 。直接buffer.readString()会把响应体掏空,上层 Gson 解析直接拿到空串;- gzip 要手动解压 。如果你像某些教程那样在拦截器里禁用
Accept-Encoding头会改变线上行为,正确做法是克隆后判断Content-Encoding自己解压:
kotlin
private fun parseContent(body: ResponseBody, encoding: String?, clone: Buffer): String {
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
return when {
"gzip".equals(encoding, true) -> decompressForGzip(clone.readByteArray(), charset)
"zlib".equals(encoding, true) -> decompressToStringForZlib(clone.readByteArray(), charset)
else -> clone.readString(charset)
}
}
- Content-Type 白名单。APK、图片等二进制响应直接跳过,只记文本类(json / xml / html / form / plain):
kotlin
fun isParseable(mediaType: MediaType?): Boolean {
if (mediaType?.type() == null) return false
return isText(mediaType) || isPlain(mediaType) || isJson(mediaType)
|| isForm(mediaType) || isHtml(mediaType) || isXml(mediaType)
}
三、日志管理:双阈值自动清理
写入逻辑本身很简单(追加写文本),重点在写入前先检查是否需要清理:
kotlin
object ApiLogManager {
private const val LOG_MAX_AGE_HOURS = 24L // 超过24小时删
private const val LOG_MAX_SIZE_BYTES = 10 * 1024 * 1024L // 超过10MB删
private val logFileLock = Any()
fun writeLog(context: Context, url: String, method: String, ...) {
synchronized(logFileLock) { // 多线程写同一文件必须加锁
try {
checkAndCleanLogFile(context)
val logFile = File(context.filesDir, "api_log.txt")
// 拼接收割格式的日志块,追加写入
FileWriter(logFile, true).use { ... }
} catch (e: Exception) {
// 日志写入异常绝不外抛
e.printStackTrace()
}
}
}
private fun checkAndCleanLogFile(context: Context) {
val logFile = File(context.filesDir, "api_log.txt")
if (!logFile.exists()) return
// 大小优先:避免短时间内疯狂写爆磁盘
if (logFile.length() > LOG_MAX_SIZE_BYTES) {
logFile.delete()
return
}
val diffHours =
(System.currentTimeMillis() - logFile.lastModified()) / 3600_000
if (diffHours >= LOG_MAX_AGE_HOURS) {
logFile.delete()
}
}
}
为什么放在 context.filesDir 而不是外部存储?免存储权限、卸载自动清理、其他应用读不到(日志里可能有敏感信息)。
四、读取展示:大文本防 OOM 三板斧
日志查看页就是一个 TextView,10MB 的文本直接 setText() 会卡死甚至 OOM。我们用了三层防护:
kotlin
private const val LOG_DISPLAY_MAX_RECORDS = 120 // 最多显示120条
private const val LOG_DISPLAY_MAX_CHARS = 256 * 1024 // 总量上限 256KB
private const val LOG_SINGLE_RECORD_MAX_CHARS = 64 * 1024 // 单条超64KB截断
private const val LOG_SINGLE_RECORD_HEAD_CHARS = 48 * 1024 // 保留头48KB
private const val LOG_SINGLE_RECORD_TAIL_CHARS = 16 * 1024 // 保留尾16KB
suspend fun readLogContent(context: Context): String = withContext(Dispatchers.IO) {
val records = ArrayDeque<String>()
var keptChars = 0
fun addRecord(record: String) {
records.addLast(record)
keptChars += record.length
// 超出条数或总字符数:从头淘汰最旧的(滑动窗口)
while (records.size > LOG_DISPLAY_MAX_RECORDS
|| keptChars > LOG_DISPLAY_MAX_CHARS) {
val removed = records.removeFirst()
keptChars -= removed.length
}
}
// ... 按分隔符切分日志块,逐条 addRecord
// 最后倒序拼接:最新日志在最前
}
// 单条超长日志:保留头尾,砍掉中间
private fun normalizeLogRecord(record: String): String {
if (record.length <= LOG_SINGLE_RECORD_MAX_CHARS) return record
val omitted = record.length - LOG_SINGLE_RECORD_HEAD_CHARS - LOG_SINGLE_RECORD_TAIL_CHARS
return record.take(LOG_SINGLE_RECORD_HEAD_CHARS) +
"\n\n[单条日志过长,已省略中间 $omitted 个字符]\n\n" +
record.takeLast(LOG_SINGLE_RECORD_TAIL_CHARS)
}
设计取舍:排查问题时,响应体的开头(状态码、错误信息)和结尾(堆栈尾部)最有价值 ,中间的大段数据可以牺牲------头尾截断比简单 take(64K) 实用得多。
另外读取挂 Dispatchers.IO,并 catch OutOfMemoryError------是的,极端情况下读文件也能 OOM,要兜住:
kotlin
} catch (e: OutOfMemoryError) {
return@withContext "日志文件过大,请清空后重试"
}
五、日志长什么样
css
========================================
[时间] 2026-04-22 14:03:11.245
[地址] https://example.com/api/osc/recorder/api/status
[方式] POST
[耗时] 182ms
[参数]
{
"deviceNumber": "FN2590343",
"deviceBattery": 87,
"versionName": "1.1"
}
[结果]
{"code":0,"data":{...},"msg":"success"}
========================================
设备现场出问题,运维人员打开 App 里的日志页面就能看到最近的接口调用记录,不需要任何开发工具。
六、总结
| 设计点 | 做法 |
|---|---|
| 安全读响应体 | buffer.clone(),gzip 手动解压,Content-Type 白名单 |
| 主流程防护 | 每个环节独立 try-catch,日志失败不外抛 |
| 文件控制 | 24h / 10MB 双阈值,写入前检查 |
| 展示防 OOM | 条数 + 总字符滑动窗口,单条头尾截断,catch OOM |
| 存储位置 | filesDir,免权限、卸载自清 |
这套方案的全部代码都在本系列的示例项目里,下一篇讲一个更冷门的场景:在 Android 设备上直接跑 Kafka Consumer 接收实时定位数据。