网络诊断体系设计:让每一次失败都可追溯、可解释

客户端网络问题排查时,最常遇到的困境是:用户反馈”加载失败""图片刷不出来”,但日志里只有一个异常类型和一个错误码,真实原因可能分布在 DNS 解析、TCP 连接、TLS 握手、HTTP 响应、业务状态码解释、缓存策略等多个层面。传统排查方式依赖客户端日志、服务端日志和人工复现,各有价值但缺点明显:日志字段不统一,用户现场很难复现,服务端看不到未到达的请求,人工抓包成本高且有隐私风险。

我们项目里的网络诊断不是孤立页面,而是嵌在”错误页、路由、调试工具、网络层”之间的基础设施。错误页不仅展示重试按钮,还通过隐藏多击入口进入网络诊断;网络层结合多域名能力、业务域名配置、Token/错误码/日志拦截器,让诊断覆盖 DNS、连接、TLS、接口响应、业务错误码、当前站点环境等信息。这样诊断结果才不是”ping 一下某个域名”,而是知道当前选择了哪个 API 域、图片域、请求是否被动态 BaseUrl 改写、错误是否来自 TLS 层还是限流策略。

统一事件模型:让不同入口的网络行为归一化

网络诊断的基础不是探测工具,而是事件模型。图片库有自己的加载机制,WebView 有自己的资源请求,下载模块可能又维护一套连接逻辑。如果每个组件都用自己的方式记录失败,诊断信息就会断裂。

一个统一的 NetworkEvent 可以记录 traceId、scene、resourceType、sanitizedHost、method、startTime、duration、networkType、cacheState、retryCount、phase、errorCategory、httpStatus。注意这里使用 sanitizedHost 而不是完整 URL,query 参数和 path 中可能含业务标识,默认裁剪或哈希:

data class NetworkEvent(
    val traceId: String,
    val scene: String,
    val resourceType: String,
    val hostAlias: String,
    val method: String,
    val startAt: Long,
    val durationMs: Long,
    val networkType: String,
    val cacheState: String,
    val phase: String,
    val errorCategory: String?,
    val httpStatus: Int?
)

事件模型的作用是把”接口请求失败""图片加载失败""文件下载失败”放到同一张图里观察。例如图片加载失败时,也可以记录 DNS、连接和响应状态;接口失败时,也可以记录是否走过缓存、是否命中过期数据。

按阶段拆分失败原因

诊断体系应该把失败原因拆成多个阶段,而不是只依赖异常类名:

  • 网络可用性阶段:检查系统是否有可用网络、是否处于飞行模式或受限网络
  • 解析阶段:DNS 是否成功、解析耗时是否异常、是否存在本地解析缓存
  • 连接阶段:TCP 连接是否建立、连接耗时、是否频繁失败
  • 安全阶段:TLS 握手、证书链、系统时间异常、协议版本兼容性
  • 响应阶段:HTTP 状态码、首包耗时、内容长度、读取中断
  • 业务解释阶段:响应能否解析、业务状态码是否代表失败、是否触发登录态刷新
  • 缓存阶段:是否命中缓存、缓存是否过期、是否因缓存策略导致用户看到旧数据

这样的分层让诊断报告更像一份检查单。即便无法百分百定位根因,也能缩小范围。

诊断任务编排器:按场景选择探测策略

诊断任务不应由页面直接调用一堆工具函数,而应由编排器根据场景和失败类型决定执行哪些任务。比如”无网络错误”只需要检查系统网络和本地代理;“DNS 失败”可以追加解析探测;“5xx 错误”不需要客户端重复做连接探测,而应优先输出 traceId 供服务端查询。

class DiagnosisOrchestrator(
    private val tasks: List<DiagnosisTask>
) {
    fun diagnose(event: NetworkEvent): DiagnosisReport {
        val context = DiagnosisContext.from(event)
        val selectedTasks = chooseTasks(event)
        val findings = selectedTasks.map { task ->
            runWithTimeout(task.timeoutMs) { task.run(context) }
        }
        return DiagnosisReportBuilder.build(event, findings)
    }

    private fun chooseTasks(event: NetworkEvent): List<DiagnosisTask> {
        return when (event.errorCategory) {
            "NO_NETWORK" -> tasks.filterByName("NetworkState")
            "DNS_ERROR" -> tasks.filterByName("NetworkState", "DnsProbe")
            "TIMEOUT" -> tasks.filterByName("NetworkState", "RouteProbe", "RecentEvents")
            else -> tasks.filterByName("RecentEvents")
        }
    }
}

面向不同角色的报告输出

同一份诊断数据可以有不同展示方式。面向用户,只需要给出明确建议,比如”当前网络不可用,请切换网络后重试”。面向支持,需要看到设备环境、App 版本、诊断时间、错误分类和简短建议。面向研发,需要看到 traceId、阶段耗时、请求场景、缓存状态、采样日志和脱敏后的错误栈。

报告结构建议固定为:结论、影响范围、关键证据、建议动作、原始诊断摘要。固定结构有利于沉淀知识库,也方便后续接入自动化归因。

隐私保护前置

不要在等到上传前才脱敏,最好在事件生成时就只保留安全字段。请求头、请求体、响应体默认不进入诊断事件。诊断报告不能包含完整 token、Cookie、用户手机号、内部域名、业务记录号等敏感信息。即便是技术日志,也必须默认脱敏,并且只采集定位问题所需的最小集合。

另一个关键点:诊断不能无限重试。网络已经异常时,继续发起大量探测可能让用户更卡。每个诊断任务都要有独立超时,总编排也要有总超时,并且要支持取消。区分失败和取消也很重要——用户主动离开页面、请求被新请求覆盖、生命周期结束导致的取消,不应该被当成网络故障。


网络诊断体系的核心,不是做更多探测,而是让网络失败变得可描述、可关联、可解释。从工程落地看,最重要的三件事是:统一事件模型、分层归因、低侵入接入。统一事件模型解决信息口径问题;分层归因解决”失败但不知道失败在哪里”的问题;低侵入接入保证能力能覆盖更多网络入口。对大型客户端来说,这类基础设施越早建立,后续面对复杂网络环境和多模块协作时越从容。

Native/H5 路由灰度切换:用 RedirectRouterInterceptor 实现零风险页面迁移

同一入口存在 Native 和 H5 两种实现时,如何在路由层安全地灰度切换?本文介绍 RedirectRouterInterceptor 的通用设计,通过远程配置控制落点,配合稳定散列、参数映射、兜底策略和结构化监控,让 Native 新页面平滑上线,异常时快速回滚。

启动框架分阶段初始化:background/activity 两类 StartType 的设计与实践

App 启动阶段承载大量初始化逻辑,如果全部堆在 Application 中,冷启动耗时不可控。本文介绍一种分阶段初始化框架,将任务按 background 和 activity 两类 StartType 拆分,配合依赖声明、线程调度、异常降级和耗时监控,让初始化在正确时间完成必要工作。

图片加载稳定性实战:自定义 SSL 确认与 DoH DNS 双管齐下

移动端图片加载看似简单,但用户看到的头像不显示、内容图片灰块、首屏瀑布流空白,背后往往是证书链校验失败、DNS 被污染、运营商局部解析异常等问题。本文介绍图片网络层的两项稳定性增强:自定义 SSL 确认和 DoH DNS 解析。

图片加载统一门面:用 ImageUrlProcessor 动态裁剪与门面模式告别混乱的图片代码

图片加载是移动端体验的基础能力,但如果每个页面都直接调用底层库,URL 拼接规则、尺寸参数、预加载逻辑就会散落全项目。本文介绍一种图片加载统一门面设计,通过 ImageUrlProcessor 集中处理动态裁剪,配合门面接口、预加载调度和监控,让业务只关心展示意图。