网络能力下沉到网络层、何时发送交给系统调度器,业务层只看到挂起函数。
做登录态、做上传下载的工程,迟早会撞上同一对矛盾:一边是"网络请求到底该长什么样"——鉴权头怎么加、错误码怎么统一、上传进度怎么算;另一边是"这个请求该什么时候发出去"——凭证过期前要刷新、离线时别硬刷、进程被杀后还得能恢复。多数工程把这两边的事散在业务代码里:ViewModel 里 new Retrofit、Activity 里 Handler.postDelayed 刷 Token,框架细节和业务逻辑缠成一团,想换框架或改超时得翻半个工程。
这两件事看着不相关,其实共享同一个判断:它们都是"网络这件事的标准动作",不该由业务方自己管。本文把两篇内部笔记合并成一条线——网络层负责"请求长什么样"(分层、拦截器、响应映射、进度),后台调度负责"请求什么时候发"(WorkManager 接管约束与重试),统一收口到一句话:业务层只该看到"一个挂起函数 / 一个可观察的结果",不该看到拦截器、重试策略和约束条件。
一、网络层三层结构:接口、客户端、响应映射
如果把"怎么发请求"这件事交给每个业务方自己处理,最直接的后果就是框架类型泄露:Repository 返回 Response<T>、界面层直接拿 Call、甚至 OkHttpClient 被传来传去。一旦想换框架、想统一加缓存或鉴权头,就得把整个调用链翻一遍。拆开看,一套能隔离框架侵入的网络层大致是三层:
- 接口层:定义"这个业务能做什么",不出现任何网络框架类型;
- 客户端层:统一 Retrofit / OkHttp 单例,负责 baseUrl、超时、拦截器、日志;
- 仓储层(响应映射层):实现接口,内部完成"调接口 → 拆解响应 → 给业务"。
业务代码只依赖第 1 层的接口,这就是"依赖倒置"在数据访问层的落地。三层之间的依赖方向是单向的——业务层指向接口,接口的实现(仓储)才去碰客户端层,客户端层才去碰 OkHttp。换实现不影响上层,是分层最直接的红利。
flowchart TD
A["业务层 ViewModel 或界面"] -->|"只依赖接口抽象"| B["接口层 DeviceGateway"]
B -->|"调用抽象方法"| C["仓储层 Repository"]
C -->|"发起请求"| D["客户端层 Retrofit 单例"]
D -->|"经过拦截器链"| E["OkHttp Interceptor 链"]
E -->|"网络 IO"| F["远端服务"]
F -->|"原始 Response"| C
C -->|"拆包裹并映射"| G["ApiResult 业务语义"]
G -->|"回调或流"| 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["ViewModel"]
participant GW["DeviceGateway 接口"]
participant Repo["Repository 仓储"]
participant Cli["OkHttp 客户端"]
participant Int["AuthInterceptor"]
participant Net["远端服务"]
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,有几处调用点要跟着改?