反编译第三方SDK定位心电报告查询401未登录问题

背景

在开发一款面向老年群体的健康关怀Android应用时,我们集成了康源(Kangyuan)的心电分析SDK(kyperipheral-sdk-1.0.1-release.aar),用于便携式心电设备的数据采集和报告分析。某天测试反馈了一个诡异的问题:用户进入健康档案页面查看历史心电报告时,日志报出 Server returned error: 401 账号未登录,页面上心电波形图不显示。

这个问题的棘手之处在于:SDK是闭源的aar包,没有源码,错误信息只有一个模糊的"401账号未登录",而且同样的查询接口在另一个页面(心电检测页面)却工作正常。本文记录了从发现问题到反编译SDK定位根因的完整过程。

问题复现

用户进入健康档案页面,HealthPersonModel 会自动调用 reqRecentEcg() 查询最近一次心电报告:

private fun reqRecentEcg(memberFileId: Long) {
    viewModelScope.launch {
        postFlow(showDialogLoading = false, isToastError = false) {
            getRecentEcgRecord(memberFileId)
        }.catch {
            LogUtil.printLog(tag, "reqRecentEcg failed: ${it.message}")
        }.next {
            val orderNum = (this as HealthEcgRecentResponse).record?.reportId.orEmpty()
            if (orderNum.isBlank()) {
                ecgWaveFile.postValue(null)
                return@next
            }
            KyPortableEcgDeviceManager.getInstance()
                .queryEcgReport(orderNum, downloadEcgData = true)
                .onSuccess { report ->
                    ecgWaveFile.postValue(report.localEcgFilePath)
                }
                .onFailure {
                    LogUtil.printLog(tag, "query recent ecg report failed: orderNum=$orderNum, ${it.message}")
                }
        }
    }
}

日志输出:

[HealthPersonModel-main] query recent ecg report failed: orderNum=2068898657934839809, Server returned error: 401 账号未登录

排查思路

第一步:对比正常场景

同样的 queryEcgReport 方法,在心电检测页面(HealthMultiCheckingViewModel)中调用却是正常的。对比两处代码的区别:

// 心电检测页面 - 正常工作
if (!manager.initAnalysisIfNeeded(force = true)) {
    LogUtil.printLog(tag, "portable ecg analysis skipped: init failed")
    return
}
// ... 执行心电分析 ...
manager.queryEcgReport(orderNum)  // ✅ 正常

// 健康档案页面 - 报401
KyPortableEcgDeviceManager.getInstance()
    .queryEcgReport(orderNum, downloadEcgData = true)  // ❌ 401

关键差异浮出水面:心电检测页面在查询前调用了 initAnalysisIfNeeded(force = true),而健康档案页面直接调用了 queryEcgReport。

第二步:分析 initAnalysisIfNeeded 的实现

queryEcgReport 内部其实已经调用了 initAnalysisIfNeeded(),但默认参数是 force = false:

suspend fun queryEcgReport(orderNum: String, downloadEcgData: Boolean = false): Result<KyEcgReport> {
    return withContext(Dispatchers.IO) {
        runCatching {
            check(initAnalysisIfNeeded()) { "ECG analysis initialization failed" }
            val detail = peripheral.analysis.queryReport(orderNum).getOrThrow()
            // ...
        }
    }
}

initAnalysisIfNeeded 的实现中有一个关键标志位 hasInitializedAnalysis:

suspend fun initAnalysisIfNeeded(force: Boolean = false): Boolean {
    if (hasInitializedAnalysis && !force) {
        return true  // ← 如果之前初始化过,直接返回true,跳过init!
    }
    // ...
    hasInitializedAnalysis = false
    return runCatching {
        peripheral.analysis.init(
            environment = environment,
            apiKey = ANALYSIS_API_KEY,
            apiSecret = ANALYSIS_API_SECRET,
            tenantId = ANALYSIS_TENANT_ID
        )
        hasInitializedAnalysis = true
        true
    }.getOrElse { false }
}

问题初步定位:force = false 时,如果 hasInitializedAnalysis 已经为 true(之前初始化过),就直接返回 true 跳过 init。但这还不足以解释 401——如果之前 init 成功了,token 应该是有效的。

第三步:反编译SDK,深挖token生命周期

SDK是闭源的aar,但已经被Gradle解压到了 app/build/tmp/ky-sdk-inspect/classes.jar。我用 javap 反编译了 AnalysisManager 类:

# 解压jar
jar xf classes.jar

# 查看AnalysisManager的方法签名
javap -p com.kangyuan.peripheral.sdk.analysis.AnalysisManager

输出中发现了几个关键信息:

private java.lang.String token;           // OAuth token字段
private java.lang.String cachedApiKey;    // 缓存的apiKey
private java.lang.String cachedApiSecret; // 缓存的apiSecret
private final int MAX_RETRY_COUNT;        // 最大重试次数

public java.lang.Object init(...);                    // 初始化,内部调fetchOAuthToken
private java.lang.Object fetchOAuthToken(...);        // 获取token
private java.lang.Object refreshOAuthToken(...);      // 刷新token
private java.lang.Object executeWithRetry(...);       // 带重试的请求执行
public java.lang.Object queryReport(...);             // 查询报告

原来SDK内部使用OAuth2的 client_credentials 模式获取token。接下来用 javap -c 查看字节码,理清了完整的调用链:

init方法的逻辑:

  1. 设置 environment、serverUrl、tenantId
  2. 保存 cachedApiKey 和 cachedApiSecret
  3. 如果 apiKey 和 apiSecret 不为空,调用 fetchOAuthToken 获取token
  4. fetchOAuthToken 内部用OkHttp发送 grantType=client_credentials 请求,解析返回的 accessToken,保存到 this.token

queryReport方法的逻辑(通过executeWithRetry):

  1. 检查 token 是否为空,为空直接返回 tokenNotConfigured 错误
  2. 把token加到 Authorization header 发送请求
  3. 如果服务端返回401,检查重试次数是否 < MAX_RETRY_COUNT
  4. 如果是,调用 refreshOAuthToken(用 cachedApiKey/cachedApiSecret 重新 fetchOAuthToken)
  5. 如果refresh成功,重试请求;如果失败,返回401响应

关键发现:SDK的token没有记录过期时间,不会主动刷新! 只有在服务端返回401时才被动触发refresh。如果refresh也失败了(网络问题或凭证问题),就直接透传401错误。

第四步:确认根因

结合以上分析,完整的根因链路清晰了:

  1. App启动后,心电检测流程可能已经调用过 initAnalysisIfNeeded,hasInitializedAnalysis = true,token获取成功
  2. 过了一段时间(几小时甚至更久),token过期
  3. 用户进入健康档案页面,reqRecentEcg 调用 queryEcgReport
  4. queryEcgReport 内部调用 initAnalysisIfNeeded(force = false),因 hasInitializedAnalysis = true 直接返回,不重新获取token
  5. 用过期token请求服务端 → 返回401
  6. SDK的 executeWithRetry 尝试 refreshOAuthToken,但如果refresh也失败,透传401
  7. 最终错误信息 Server returned error: 401 账号未登录 传到上层

而心电检测页面用 force = true,每次都重新init获取新token,所以不会出现这个问题。

解决方案

在 reqRecentEcg 调用 queryEcgReport 之前,强制初始化以获取最新token:

if (!KyPortableEcgDeviceManager.getInstance().initAnalysisIfNeeded(force = true)) {
    ecgWaveFile.postValue(null)
    LogUtil.printLog(tag, "reqRecentEcg skipped: ecg analysis init failed, orderNum=$orderNum")
    return@next
}
KyPortableEcgDeviceManager.getInstance()
    .queryEcgReport(orderNum, downloadEcgData = true)
    .onSuccess { report ->
        ecgWaveFile.postValue(report.localEcgFilePath)
    }
    .onFailure {
        LogUtil.printLog(tag, "query recent ecg report failed: orderNum=$orderNum, ${it.message}")
    }

收获与思考

1. 闭源SDK不是黑盒

当SDK报出模糊错误时,不要止步于"SDK的问题,等厂商修复"。aar本质是个zip,里面的classes.jar可以用 javap -c -p 反编译字节码。虽然不如源码直观,但足以理解关键逻辑:方法调用链、字段读写、条件分支、异常处理都能从字节码中还原出来。

2. 状态标志位的陷阱

hasInitializedAnalysis 这种"初始化过就跳过"的模式很常见,但隐藏着一个假设:初始化获得的状态在整个生命周期内有效。对于token这种有时效性的凭证,这个假设不成立。正确的做法应该是:

  • 记录token的过期时间,在过期前主动刷新
  • 或者每次使用前检查有效性

SDK没做到这点,我们在封装层用 force = true 来弥补。

3. 对比排查法的重要性

这个问题能快速定位,关键在于发现了"同一段代码在A页面正常、B页面报错"。对比两个调用点的差异(force = true vs force = false),直接锁定了问题方向。在排查疑难bug时,找到一个"能正常工作的对照组"比什么都重要。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容