背景
在开发一款面向老年群体的健康关怀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方法的逻辑:
- 设置
environment、serverUrl、tenantId - 保存
cachedApiKey和cachedApiSecret - 如果 apiKey 和 apiSecret 不为空,调用
fetchOAuthToken获取token -
fetchOAuthToken内部用OkHttp发送grantType=client_credentials请求,解析返回的accessToken,保存到this.token
queryReport方法的逻辑(通过executeWithRetry):
- 检查
token是否为空,为空直接返回tokenNotConfigured错误 - 把token加到
Authorizationheader 发送请求 - 如果服务端返回401,检查重试次数是否 <
MAX_RETRY_COUNT - 如果是,调用
refreshOAuthToken(用cachedApiKey/cachedApiSecret重新 fetchOAuthToken) - 如果refresh成功,重试请求;如果失败,返回401响应
关键发现:SDK的token没有记录过期时间,不会主动刷新! 只有在服务端返回401时才被动触发refresh。如果refresh也失败了(网络问题或凭证问题),就直接透传401错误。
第四步:确认根因
结合以上分析,完整的根因链路清晰了:
- App启动后,心电检测流程可能已经调用过
initAnalysisIfNeeded,hasInitializedAnalysis = true,token获取成功 - 过了一段时间(几小时甚至更久),token过期
- 用户进入健康档案页面,
reqRecentEcg调用queryEcgReport -
queryEcgReport内部调用initAnalysisIfNeeded(force = false),因hasInitializedAnalysis = true直接返回,不重新获取token - 用过期token请求服务端 → 返回401
- SDK的
executeWithRetry尝试refreshOAuthToken,但如果refresh也失败,透传401 - 最终错误信息
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时,找到一个"能正常工作的对照组"比什么都重要。