没有 adb 怎么排查接口问题?给 OkHttp 加一个"永不炸"的文件日志拦截器

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

三个容易踩的坑:

  1. buffer.clone() 是灵魂 。直接 buffer.readString() 会把响应体掏空,上层 Gson 解析直接拿到空串;
  2. 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)
    }
}
  1. 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 接收实时定位数据。

相关推荐
邪修king2 小时前
Re:Linux 系统篇(三十四):进程间通信开篇Chapter1 —— 匿名管道 pipe 深度拆解与代码实战
android·linux·运维·开发语言
AFinalStone2 小时前
Android7 SystemUI 源码解析(一)全景概览与架构设计
android·systemui
2501_916008892 小时前
如何实现iOS App代码混淆:SwiftShield使用指南
android·ios·小程序·https·uni-app·iphone·webview
恋猫de小郭3 小时前
AndroidX 新增 Security State ,支持可编程的系统安全状态查询
android·前端·flutter
AFinalStone3 小时前
Android7 SystemUI 源码解析(六)QuickSettings快捷设置面板
android·systemui
Kapaseker3 小时前
你有搞明白 Volatile 什么意思吗?
android·kotlin
美狐美颜SDK开放平台4 小时前
直播APP源码与视频美颜sdk如何配合?一套完整开发思路
android·人工智能·计算机视觉·音视频·直播美颜sdk
心平气和量大福大4 小时前
android-权限
android·java
2501_916008894 小时前
怎么用 Godot 导出 iOS 应用并上架 App Store?签名字段该填什么?
android·ios·小程序·https·uni-app·iphone·webview