Android 网络层与后台调度:拦截器、进度与定时任务

1 阅读14分钟

网络能力下沉到网络层、何时发送交给系统调度器,业务层只看到挂起函数。

做登录态、做上传下载的工程,迟早会撞上同一对矛盾:一边是"网络请求到底该长什么样"——鉴权头怎么加、错误码怎么统一、上传进度怎么算;另一边是"这个请求该什么时候发出去"——凭证过期前要刷新、离线时别硬刷、进程被杀后还得能恢复。多数工程把这两边的事散在业务代码里:ViewModel 里 new Retrofit、Activity 里 Handler.postDelayed 刷 Token,框架细节和业务逻辑缠成一团,想换框架或改超时得翻半个工程。

这两件事看着不相关,其实共享同一个判断:它们都是"网络这件事的标准动作",不该由业务方自己管。本文把两篇内部笔记合并成一条线——网络层负责"请求长什么样"(分层、拦截器、响应映射、进度),后台调度负责"请求什么时候发"(WorkManager 接管约束与重试),统一收口到一句话:业务层只该看到"一个挂起函数 / 一个可观察的结果",不该看到拦截器、重试策略和约束条件。

一、网络层三层结构:接口、客户端、响应映射

如果把"怎么发请求"这件事交给每个业务方自己处理,最直接的后果就是框架类型泄露:Repository 返回 Response<T>、界面层直接拿 Call、甚至 OkHttpClient 被传来传去。一旦想换框架、想统一加缓存或鉴权头,就得把整个调用链翻一遍。拆开看,一套能隔离框架侵入的网络层大致是三层:

  1. 接口层:定义"这个业务能做什么",不出现任何网络框架类型;
  2. 客户端层:统一 Retrofit / OkHttp 单例,负责 baseUrl、超时、拦截器、日志;
  3. 仓储层(响应映射层):实现接口,内部完成"调接口 → 拆解响应 → 给业务"。

业务代码只依赖第 1 层的接口,这就是"依赖倒置"在数据访问层的落地。三层之间的依赖方向是单向的——业务层指向接口,接口的实现(仓储)才去碰客户端层,客户端层才去碰 OkHttp。换实现不影响上层,是分层最直接的红利。

flowchart TD
    A[&#34;业务层 ViewModel 或界面&#34;] -->|&#34;只依赖接口抽象&#34;| B[&#34;接口层 DeviceGateway&#34;]
    B -->|&#34;调用抽象方法&#34;| C[&#34;仓储层 Repository&#34;]
    C -->|&#34;发起请求&#34;| D[&#34;客户端层 Retrofit 单例&#34;]
    D -->|&#34;经过拦截器链&#34;| E[&#34;OkHttp Interceptor 链&#34;]
    E -->|&#34;网络 IO&#34;| F[&#34;远端服务&#34;]
    F -->|&#34;原始 Response&#34;| C
    C -->|&#34;拆包裹并映射&#34;| G[&#34;ApiResult 业务语义&#34;]
    G -->|&#34;回调或流&#34;| A

这一层结构的价值不在"多写几个类",而在把"网络框架的可变部分"关进了一个可替换的边界里。接口层的稳定性由业务语义决定("查询设备状态"不会变),客户端层的实现细节由框架演进决定(Retrofit 升不升级、换不换 Ktor 是内部事)。两者用接口这堵墙隔开,墙两边各改各的,互不影响。

反例最能说明这堵墙值不值:若业务层直接持有 Retrofit 实例,某天要"离线时返回缓存数据",就得去每个调用点补 try/catch 和缓存读取;而接口后面只改一个仓储实现,上层一行不动。"分层"的代价是多一层抽象和样板,但它把"网络框架迟早会变"这个预期显式写进了架构,而不是埋在几十个文件里——当初省下的那点直接调用,会在第一次换框架时连本带利还回去。

二、接口与客户端层:抽象隔离与单例拦截器

接口层只声明业务能力,返回值是干净的数据模型,不是 Call、不是 Response<T>。业务调用它时,完全不知道背后是 Retrofit 还是手写 Socket。

interface DeviceGateway {
    fun queryStatus(deviceId: String): Flow<DeviceStatus>
    fun uploadLog(log: ByteArray, onProgress: (Float) -> Unit)
}

界面和 ViewModel 拿到的是"能查状态""能传日志"的能力,至于怎么传、走什么协议,它不关心。换实现不影响上层——这是分层存在的理由。

客户端层把 Retrofit 单例统一收口:baseUrl、超时、日志、鉴权头,全部通过一组 OkHttp 拦截器挂上去。这样"统一加头、统一打日志"集中在单例里,而不是散落在每个调用点。

val okHttpClient = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .addInterceptor(AuthInterceptor())          // 统一加鉴权头
    .addInterceptor(HttpLoggingInterceptor())    // 统一打日志
    .build()

val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .client(okHttpClient)
    .build()

拦截器是 OkHttp 的精髓。它本质是一个"对每一个请求/响应都能插手"的钩子,签名是 fun intercept(chain: Interceptor.Chain): Response。应用拦截器(addInterceptor)在请求真正发出前、响应回到业务前各跑一次,适合做加头、打日志、统一改路径这类"与网络无关"的处理;网络拦截器(addNetworkInterceptor)在经历 DNS、连接之后才跑,能拿到重定向后的真实请求,适合做耗时统计、对失败重试的精细控制。顺序上,应用拦截器先入先出、网络拦截器后入,拦截器链按 chain.proceed(request) 一层层往下传递,漏写 proceed 请求就断了。

一个真实可用的鉴权拦截器长这样——它从本地凭证存储读 Token,注入到每个请求的 Authorization 头,业务方完全无感:

class AuthInterceptor(
    private val tokenStore: TokenStore,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()
        val token = tokenStore.current()              // 读当前凭证
        val withAuth = original.newBuilder()
            .header("Authorization", "Bearer $token")  // 统一注入
            .build()
        return chain.proceed(withAuth)
    }
}

注意用 header(覆盖)而不是 addHeader(追加),避免同一个头出现多个值。当凭证类型不止一种(比如同时有用户 Token 和设备授权),可以把"该加哪个头"抽成策略,拦截器只负责统一执行,不负责决策——决策仍在仓储层。

日志拦截器同理,生产环境要关掉明文 Body 打印,避免把鉴权头和用户数据打进日志。一个稳妥的做法是按 BuildConfig 开关切换级别:

val logging = HttpLoggingInterceptor().apply {
    level = if (BuildConfig.DEBUG) {
        HttpLoggingInterceptor.Level.BODY      // 调试期看全量
    } else {
        HttpLoggingInterceptor.Level.NONE      // 生产期静默
    }
}

拦截器链的最大好处是"可叠加、可排序、可替换":想加个统一埋点、想加个统一签名,都是往 Builder 里再 addInterceptor 一行,业务层零改动。这也是为什么"统一网络行为"值得有一层单例去收——它把横切关注点从 N 个调用点收敛成 1 个配置点。

三、响应映射与文件进度:解耦业务语义与字节流

网络响应的包裹结构(成功码、错误码、数据体)通常和业务字段混在一起。响应映射层负责"把包裹层拆掉、把业务数据吐出来",用一个统一的 ApiResult 收敛成功/失败判定,业务拿到的是干净的 ApiResult<T>,要么有数据、要么有错误码。

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
}

// 仓储内部:Call 的响应 -> 统一的 ApiResult,再给上层
suspend fun <T> safeCall(block: suspend () -> Response<T>): ApiResult<T> {
    return try {
        val resp = block()
        if (resp.isSuccessful) {
            ApiResult.Success(resp.body()!!)
        } else {
            ApiResult.Error(resp.code(), resp.message())
        }
    } catch (e: IOException) {
        ApiResult.Error(-1, e.message ?: "network error")
    }
}

这样成功/失败的判定完全统一,不用每个调用方各自 if (resp.isSuccessful)。ApiResult 用密封类,业务侧 when 必须穷尽分支,漏处理编译期就报错,比返回"可能为 null 的 data + 一个全局错误码"稳得多。

进一步地,服务端约定的错误码和业务语义也要解耦:同一份 code=401 在"登录态失效"和"设备未授权"两种业务里含义不同。映射层应该把"服务端码"翻译成"业务能懂的消息/动作",而不是把裸 code 丢给界面:

fun mapError(code: Int, raw: String): ApiResult.Error {
    val message = when (code) {
        401 -> "登录已失效,请重新登录"     // 服务端约定 -> 业务语义
        403 -> "无权限访问该资源"
        500 -> "服务异常,请稍后再试"
        else -> raw
    }
    return ApiResult.Error(code, message)
}

文件上传/下载的进度,也在这个单例里统一处理。核心思路是包装请求体/响应体,把字节读写切成长度回调。上传侧包装 RequestBody,用 ForwardingSink 把每次写入的字节累加,除以 contentLength() 就是进度:

class ProgressRequestBody(
    private val delegate: RequestBody,
    private val onProgress: (Float) -> Unit,
) : RequestBody() {
    override fun contentType() = delegate.contentType()
    override fun contentLength() = delegate.contentLength()

    override fun writeTo(sink: BufferedSink) {
        val counting = object : ForwardingSink(sink) {
            private var total = 0L
            override fun write(source: Buffer, byteCount: Long) {
                super.write(source, byteCount)
                total += byteCount
                onProgress(total.toFloat() / contentLength())
            }
        }
        val buffered = sink.buffer()  // 拿到底层缓冲再包一层计数
        delegate.writeTo(buffered)
        buffered.emit()               // 确保字节落盘到真实 sink
    }
}

下载侧对称地包装 ResponseBody,用 ForwardingSource 在 read 时累加:

class ProgressResponseBody(
    private val delegate: ResponseBody,
    private val onProgress: (Float) -> Unit,
) : ResponseBody() {
    override fun contentType() = delegate.contentType()
    override fun contentLength() = delegate.contentLength()

    override fun source(): BufferedSource {
        val source = delegate.source()
        return object : ForwardingSource(source) {
            private var total = 0L
            override fun read(sink: Buffer, byteCount: Long): Long {
                val read = super.read(sink, byteCount)
                if (read != -1L) {
                    total += read
                    onProgress(total.toFloat() / contentLength())
                }
                return read
            }
        }.buffer()
    }
}

进度回调通过包装器注入,对上层透明——业务调一个 uploadLog,进度自己冒出来,不用业务方碰 OkHttp 的 Sink。这里有个容易踩的边界:进度回调发生在网络线程,回抛给界面必须切主线程(用 Callback 或 Flow 的 flowOn + 收集到主线程),否则在回调里直接更新 UI 会崩。另一处边界是 contentLength() 可能为 -1(服务端没返回 Content-Length),这时进度算不出绝对值,要降级成"未知进度"而非除零崩溃。

一次完整请求从接口到进度的走向,用时序图看清谁在哪一刻插手:

sequenceDiagram
    participant VM[&#34;ViewModel&#34;]
    participant GW[&#34;DeviceGateway 接口&#34;]
    participant Repo[&#34;Repository 仓储&#34;]
    participant Cli[&#34;OkHttp 客户端&#34;]
    participant Int[&#34;AuthInterceptor&#34;]
    participant Net[&#34;远端服务&#34;]
    VM->>GW: uploadLog(bytes, onProgress)
    GW->>Repo: 实现方法 包装进度体
    Repo->>Cli: 发起带 ProgressRequestBody 的请求
    Cli->>Int: 注入鉴权头
    Int->>Net: 带鉴权头的请求
    Net-->>Cli: 响应字节流
    Cli-->>Repo: 字节逐段回写
    Repo->>Repo: 累加 total 计算进度
    Repo-->>VM: onProgress(0.0~1.0)
    Repo->>Repo: safeCall 拆 Response
    Repo-->>VM: ApiResult.Success 或 Error

四、定时任务为什么不能自己写:Doze 与后台限制下的失效

"请求什么时候发"这一侧,最典型的场景是凭证刷新:Token 会过期、VIP 授权会过期,但你又不想为了刷新它们常驻一个后台进程。第一反应往往是"起个线程,sleep 到点就刷",或者 Handler.postDelayed、或者 Timer。这些朴素写法在桌面环境也许能跑,在 Android 上会撞上系统的省电铁律。

Android 从 6.0 引入 Doze(打盹):设备灭屏静止一段时间后,网络访问、同步、Alarm 全被推迟到维护窗口才批量执行。从 8.0 起,后台应用启动服务受限,且大多数广播(含 BOOT_COMPLETED 的部分用法)收不到。从 9.0 起 Doze 在灭屏即生效,不要求静止。这些限制不是 bug,是系统为了续航强行收走了"后台自由执行"的能力。于是朴素定时写法逐个暴露问题:

现象根因解法
Handler.postDelayed 定时刷新,进程被杀就没了定时器跑在进程内的线程,进程终止即被回收,无外部调度交给 WorkManager,调度在系统进程外、被杀后能恢复
常驻线程在后台几分钟被掐后台不省电,系统后台限制 / Doze 直接延迟或停止其执行周期任务交给系统,电量与网络约束由系统判断
离线时刷新必然失败还要自己重试朴素写法不感知网络状态,无重试退避WorkManager 设 REQUIRED 网络约束 + Result.retry() 退避
多次启动 App 重复创建多个周期任务每次启动都 enqueue 且无唯一名,任务成倍叠加enqueueUniquePeriodicWork 唯一名 + KEEP 去重
BOOT_COMPLETED 里拉起刷新收不到8.0+ 后台启动限制,隐式广播被砍WorkManager 自身在重启后由系统重新调度,无需手动监听开机

反例很清楚:"定时刷新"本质是"需要系统在后台帮我盯着的周期性工作",而不是"我自己开个线程盯着"。把这类任务留在业务进程里,等于和系统省电机制正面对抗,必输。

五、WorkManager 接管:约束、唯一任务与退避重试

WorkManager 是官方给"周期性后台任务"的标准答案。它能把任务交给系统调度,系统在最合适(满足约束且省电)的时机执行,进程被杀后自动恢复,甚至重启后也能重新排程。核心是一个"工作请求":先定义一个 Worker(干活的人),再定义一个请求(多久干一次、什么条件下能干)。

// 干活的:真正去刷新凭证
class TokenRefreshWorker(context: Context, params: WorkerParameters) :
    CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        return try {
            tokenRepository.refreshToken()   // 网络刷新
            Result.success()
        } catch (e: IOException) {
            Result.retry()                    // 失败按退避策略重试
        }
    }
}

// 排程:每 6 小时周期跑一次
val request = PeriodicWorkRequestBuilder<TokenRefreshWorker>(6, TimeUnit.HOURS)
    .build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "token_refresh",
    ExistingPeriodicWorkPolicy.KEEP,
    request
)

几个关键点必须吃透:

  • enqueueUniquePeriodicWork 用唯一名字保证"同一份凭证刷新只排一队",不会重复创建多个定时任务;
  • ExistingPeriodicWorkPolicy.KEEP 表示"已存在就保留旧任务",避免重复注册覆盖掉之前的计划(与之相对 REPLACE 会替换,仅在你想强制改周期时用);
  • Result.retry() 让网络失败按退避策略自动重试,不用自己写重试循环;
  • CoroutineWorker 让 doWork 里能直接挂起写网络调用,不用手动切线程。

约束(Constraints)是 WorkManager 比裸 Alarm 聪明的地方:它声明"什么条件下才执行",系统据此挑省电窗口。凭证刷新至少要联网,否则刷了也白刷:

val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)   // 需要联网才能刷
    .setRequiresBatteryNotLow(true)                   // 低电量时不跑
    .setRequiresCharging(false)                        // 充电非必须
    .build()

val request = PeriodicWorkRequestBuilder<TokenRefreshWorker>(6, TimeUnit.HOURS)
    .setConstraints(constraints)
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,   // 指数退避:越失败间隔越长
        30, TimeUnit.SECONDS         // 初始退避间隔
    )
    .build()

唯一任务从入队到最终态的流转,用状态图看清系统怎么接管:

stateDiagram-v2
    [*] --> Enqueued
    Enqueued : 已入队 等待约束满足
    Enqueued --> Blocked : 约束未满足
    Blocked : 网络或电量未就绪
    Blocked --> Running : 约束满足 系统调度
    Running --> Succeeded : doWork 返回 success
    Running --> Retrying : doWork 返回 retry
    Retrying : 按退避重新执行
    Retrying --> Running
    Retrying --> Failed : 超过最大重试次数
    Failed --> [*]
    Succeeded --> [*]

注意 PeriodicWorkRequest 的"周期"是最小间隔,实际执行会被系统延后到满足约束的窗口,不能指望它精确到分钟。另外系统对周期任务有强制下限(早期版本 15 分钟),小于它的间隔会被拉到下限——这也是它不适合"精确到秒"任务的根本原因。

六、多凭证刷新编排与适用边界

实际工程里不止 Token 要刷新,VIP 授权信息也常要定期同步(会员到期时间、剩余次数)。两类凭证若各写一个 Worker、各排一个周期任务,调度上没问题,但有三条编排纪律:

  • 它们往往依赖同一个底层接口(刷新 Token、拉取授权),底层要共用一套仓储,避免重复建连接、重复读凭证;
  • 周期粒度可以不同——Token 可能 6 小时一刷,授权可能一天一同步,用各自的周期请求,灵活但不冲突;
  • 失败重试策略按业务区分:Token 刷新失败该早点重试(否则功能不可用),授权同步失败可以放宽。

"一个 Worker 干一件事 + 唯一周期任务 + 业务化重试"可推广到任何"后台周期性保鲜"。当两类凭证存在先后依赖时(必须先有 Token 才能拉授权),用一次性链把顺序钉死,而不是赌两个周期任务的执行先后:

// 登录后一次性链:先刷 Token 成功 再拉授权
val refreshToken = OneTimeWorkRequestBuilder<TokenRefreshWorker>().build()
val fetchGrant = OneTimeWorkRequestBuilder<VipGrantWorker>().build()

WorkManager.getInstance(ctx)
    .beginWith(refreshToken)
    .then(fetchGrant)              // 链式依赖,前驱失败后续不跑
    .enqueue()

四种定时方案的取舍,落到一张对比表上就不会选错:

方案进程被杀后省电 / Doze精确度适用场景
Handler / Timer立即失效不省电,后台被掐毫秒级但不可靠同生命周期内的一次性延时(如防抖、倒计时)
AlarmManager保留(setExactAndAllowWhileIdle 可唤醒)精确唤醒但费电秒级精确闹钟、日历提醒等必须到点
前台服务存活(前台常驻)持续占用资源实时长期高优先级、需用户感知的任务
WorkManager自动恢复、重启后重排系统选最优窗口弹性(分钟级)可延迟的周期任务、凭证刷新、离线同步

WorkManager 有自己的适用边界,不是万能钥匙:

需求适合 WorkManager 吗说明
周期性刷新凭证 / 同步状态适合系统调度、能恢复、能重试
立即执行的网络请求不适合用前台协程 / 即时任务
精确到秒的定时(闹钟)不适合用 AlarmManager 精确唤醒
需长期高优先级执行不适合要前台服务

判断口诀:"能否延迟?能否容忍系统决定时机?是否需要进程被杀后仍执行?" 三个都是"是",就放心交给 WorkManager;只要有一个"否",就换更精确或更高优先级的机制。

七、小结

  • 网络能力下沉到网络层:接口抽象 + 单例拦截器 + 统一响应映射,业务层不 import 任何 Retrofit / OkHttp 类型。
  • 拦截器(应用 / 网络)把加头、日志、签名等横切关注点从 N 个调用点收敛成 1 个配置点,可叠加可排序。
  • 上传/下载进度通过包装请求体/响应体回调,对上层透明;contentLength() 为 -1 时要降级为"未知进度"。
  • "请求什么时候发"交给 WorkManager:唯一任务名防重复、约束挑省电窗口、Result.retry() 退避重试。
  • 多凭证各自唯一周期任务、底层共用仓储;有先后依赖时用一次性链 beginWith...then 钉死顺序。
  • 统一收口的那句话:网络层管"请求长什么样"、调度器管"请求什么时候发",业务层只看到挂起函数 / 可观察结果,不该看到拦截器、重试策略和约束条件。

你现在的网络层里,业务代码是不是还在直接拿着 Retrofit 的 Response 写 if (resp.isSuccessful)?如果要今天把 OkHttp 换成 Ktor,有几处调用点要跟着改?