4. 网络缓存实现:HTTP请求封装、OkHttp/URLConnection选用、异步下载与回调设计

网络缓存是图片三级缓存模型中的最后一级屏障。当内存缓存与磁盘缓存均未命中时,网络层负责从远端服务器拉取原始图片数据,并回填至下级缓存。本章聚焦网络层的工程化封装:HTTP请求统一抽象、底层引擎选型以及异步回调模型,确保下载过程可取消、可重试、可回抛至UI线程。

4.1 网络缓存层的职责边界

  • 职责单一:只处理HTTP请求的发起与响应解析,不参与缓存策略决策。
  • 异步非阻塞:所有网络操作必须脱离主线程,通过回调或监听器通知结果。
  • 可配置:超时、重试次数、User-Agent、缓存头等参数应支持外部注入。
  • 可取消:当图片被复用或页面销毁时,能够中断正在进行的网络请求。
架构提示:网络层只返回原始字节流(byte[]),Bitmap的转换与缩放由上层缓存层或展示层完成,避免网络层耦合UI逻辑。

4.2 HTTP请求封装设计

封装的核心目标是让调用方不关心底层是 OkHttp 还是 URLConnection,只通过统一的 ImageRequest 对象描述请求参数。

4.2.1 请求对象定义

public class ImageRequest {
    private final String url;
    private final Map<String, String> headers;
    private final int connectTimeoutMs;
    private final int readTimeoutMs;
    private final int retryCount;

    private ImageRequest(Builder builder) {
        this.url = builder.url;
        this.headers = Collections.unmodifiableMap(builder.headers);
        this.connectTimeoutMs = builder.connectTimeoutMs;
        this.readTimeoutMs = builder.readTimeoutMs;
        this.retryCount = builder.retryCount;
    }

    // Getter 省略

    public static class Builder {
        private String url;
        private Map<String, String> headers = new HashMap<>();
        private int connectTimeoutMs = 15_000;
        private int readTimeoutMs = 30_000;
        private int retryCount = 2;

        public Builder url(String url) {
            this.url = url;
            return this;
        }
        public Builder addHeader(String key, String value) {
            headers.put(key, value);
            return this;
        }
        public Builder connectTimeout(int ms) {
            this.connectTimeoutMs = ms;
            return this;
        }
        public Builder readTimeout(int ms) {
            this.readTimeoutMs = ms;
            return this;
        }
        public Builder retryCount(int count) {
            this.retryCount = count;
            return this;
        }
        public ImageRequest build() {
            if (url == null || url.isEmpty())
                throw new IllegalStateException("URL must not be null or empty");
            return new ImageRequest(this);
        }
    }
}

4.2.2 响应对象定义

public class ImageResponse {
    private final int statusCode;
    private final byte[] data;
    private final String contentType;
    private final long contentLength;
    private final Map<String, List<String>> headers;

    public ImageResponse(int statusCode, byte[] data, String contentType,
                         long contentLength, Map<String, List<String>> headers) {
        this.statusCode = statusCode;
        this.data = data;
        this.contentType = contentType;
        this.contentLength = contentLength;
        this.headers = headers;
    }

    public boolean isSuccessful() {
        return statusCode >= 200 && statusCode < 300;
    }
    // Getter 省略
}

4.3 OkHttp 与 URLConnection 选型对比

对比维度 OkHttp HttpURLConnection
连接池复用 内置连接池,支持 HTTP/2 多路复用 无连接池,每次请求新建连接
自动重定向 默认跟随,可配置 默认跟随,需手动设置
超时控制 connect / read / write 独立超时 仅支持 connect 与 read 超时
拦截器链 支持自定义 Interceptor(日志/重试/缓存) 无原生拦截器机制
取消请求 通过 Call.cancel() 优雅取消 通过 disconnect() 强制中断
低版本兼容 API 21+(Android) API 1+ 原生可用
包体积 约 0.5 MB(含依赖) 0(系统内置)
选用建议:新项目优先选用 OkHttp,其连接池、拦截器与取消机制能显著提升网络层稳定性;若项目对包体积极度敏感或需兼容 API 19 以下设备,可降级为 HttpURLConnection,但需自行封装重试与连接复用。

4.4 异步下载与回调设计

异步下载是网络缓存层的核心模式。统一采用 回调接口 将结果从工作线程抛回调用方,避免调用方感知线程切换细节。

4.4.1 回调接口定义

public interface DownloadCallback {
    void onSuccess(ImageResponse response);
    void onFailed(IOException e, int retryRemaining);
    void onProgress(int bytesRead, int totalBytes);
}

4.4.2 异步下载流程

下图展示了从请求封装到回调通知的完整生命周期:

网络缓存异步下载与回调流程 网络缓存异步下载与回调流程 封装HTTP请求 (URL / Header / 超时) 引擎选择 OkHttp / URLConnection 异步线程池执行 (工作线程) 响应解析 (状态码 / 数据流) 回调分发 成功 / 失败 成功 失败 写入缓存 + 返回Bitmap (内存 + 磁盘) 重试 / 降级 (返回占位图) UI线程回调更新 回调始终抛回主线程
设计要点:回调分发器内部使用 Handler (Android) 或 Executor 将 onSuccess/onFailed 切换到主线程;onProgress 应限制回调频率(如每 200ms 回调一次),避免高频刷新导致界面卡顿。

4.5 核心代码实现

4.5.1 OkHttp 下载器实现

public class OkHttpDownloader {
    private final OkHttpClient client;
    private final ExecutorService executor;

    public OkHttpDownloader(OkHttpClient client, ExecutorService executor) {
        this.client = client;
        this.executor = executor;
    }

    public void download(ImageRequest request, DownloadCallback callback) {
        executor.submit(() -> {
            int retry = request.getRetryCount();
            do {
                try {
                    Request.Builder builder = new Request.Builder()
                            .url(request.getUrl())
                            .get();
                    for (Map.Entry<String, String> h : request.getHeaders().entrySet()) {
                        builder.addHeader(h.getKey(), h.getValue());
                    }
                    Call call = client.newCall(builder.build());
                    Response response = call.execute();

                    if (!response.isSuccessful()) {
                        throw new IOException("Unexpected code " + response.code());
                    }

                    byte[] data = response.body().bytes();
                    ImageResponse imgResp = new ImageResponse(
                            response.code(),
                            data,
                            response.header("Content-Type"),
                            response.body().contentLength(),
                            response.headers().toMultimap()
                    );
                    callback.onSuccess(imgResp);
                    return;
                } catch (IOException e) {
                    retry--;
                    if (retry < 0) {
                        callback.onFailed(e, 0);
                        return;
                    }
                    callback.onFailed(e, retry);
                }
            } while (retry >= 0);
        });
    }
}

4.5.2 URLConnection 下载器实现

public class UrlConnectionDownloader {
    private final ExecutorService executor;

    public UrlConnectionDownloader(ExecutorService executor) {
        this.executor = executor;
    }

    public void download(ImageRequest request, DownloadCallback callback) {
        executor.submit(() -> {
            int retry = request.getRetryCount();
            do {
                HttpURLConnection conn = null;
                try {
                    URL url = new URL(request.getUrl());
                    conn = (HttpURLConnection) url.openConnection();
                    conn.setConnectTimeout(request.getConnectTimeoutMs());
                    conn.setReadTimeout(request.getReadTimeoutMs());
                    conn.setInstanceFollowRedirects(true);
                    for (Map.Entry<String, String> h : request.getHeaders().entrySet()) {
                        conn.setRequestProperty(h.getKey(), h.getValue());
                    }
                    conn.connect();

                    int code = conn.getResponseCode();
                    if (code < 200 || code >= 300) {
                        throw new IOException("HTTP " + code);
                    }
                    byte[] data = readFully(conn.getInputStream());
                    ImageResponse imgResp = new ImageResponse(
                            code,
                            data,
                            conn.getContentType(),
                            conn.getContentLengthLong(),
                            conn.getHeaderFields()
                    );
                    callback.onSuccess(imgResp);
                    return;
                } catch (IOException e) {
                    retry--;
                    if (retry < 0) {
                        callback.onFailed(e, 0);
                        return;
                    }
                    callback.onFailed(e, retry);
                } finally {
                    if (conn != null) {
                        conn.disconnect();
                    }
                }
            } while (retry >= 0);
        });
    }

    private byte[] readFully(InputStream in) throws IOException {
        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        byte[] tmp = new byte[4096];
        int n;
        while ((n = in.read(tmp)) != -1) {
            buffer.write(tmp, 0, n);
        }
        return buffer.toByteArray();
    }
}

4.5.3 取消请求的封装

通过 Future 或 Call 的取消能力实现请求中断:

public class DownloadTask {
    private Future<?> future;
    private Call okHttpCall;

    public void cancel() {
        if (okHttpCall != null) {
            okHttpCall.cancel();          // OkHttp 优雅取消
        }
        if (future != null) {
            future.cancel(true);           // 强制中断线程
        }
    }

    public void bind(Future<?> future, Call call) {
        this.future = future;
        this.okHttpCall = call;
    }
}

4.6 调试与验证

网络缓存层调试主要验证 超时生效、重试次数、回调时序 三个维度。

测试场景 预期行为 验证方法
服务器 200 返回图片 onSuccess 被调用,data 非空 Log.d 输出 data.length
服务器 404 onFailed 被调用,retryRemaining 递减 Log.e 输出 status code
连接超时 onFailed 收到 SocketTimeoutException 设置 1ms 超时触发
请求被取消 回调不被执行,Future.isCancelled() == true 在 cancel() 后断言
重试耗尽 最终 onFailed 收到异常,retryRemaining == 0 拦截器日志计数
注意:测试网络层时务必使用 MockWebServer (OkHttp) 或 WireMock 模拟服务端,避免依赖真实网络导致测试不稳定。单元测试中所有回调需使用 CountDownLatch 同步等待结果。

4.7 常见错误与避坑

  • 回调未切回主线程:直接在工作线程更新 UI 导致崩溃。务必通过 Handler / LiveData / RxJava 切换到主线程。
  • 流未关闭导致内存泄漏:HttpURLConnection 的 InputStream 必须在 finally 块中关闭;OkHttp 的 Response.body() 使用 try-with-resources 自动关闭。
  • 重试风暴:重试逻辑未加指数退避,服务器异常时客户端疯狂重试。建议用 Thread.sleep(1000 * retryCount) 或 OkHttp 的拦截器实现退避。
  • 取消后仍收到回调:Future.cancel(true) 中断线程后,IO 操作可能抛出 InterruptedException,需在回调入口检查线程中断状态。
  • OkHttp 连接池未释放:全局只创建一个 OkHttpClient 实例,避免反复创建连接池耗尽 Socket。

4.8 性能与稳定性优化

  1. 连接池调优:OkHttp 默认最大空闲连接 5 个,对图片密集场景可提升至 10~15。
  2. 超时差异化:WiFi 环境下 connectTimeout 可缩短至 8s,移动网络保留 20s。
  3. 缓存头利用:解析 Cache-Control 与 ETag,配合磁盘缓存实现条件请求(304 Not Modified)。
  4. 并发控制:使用 Semaphore 限制最大并发下载数(如 4~6),避免线程池被打满。
  5. 字节流直接写入磁盘:对于大图(> 2MB),从网络流直接写入临时文件,避免全部加载到堆内存。
// 示例:OkHttp 连接池与超时配置
OkHttpClient client = new OkHttpClient.Builder()
    .connectTimeout(15, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .connectionPool(new ConnectionPool(12, 5, TimeUnit.MINUTES))
    .addInterceptor(new RetryInterceptor(2, 500))  // 重试2次,间隔500ms
    .build();
工程交付清单:本章完成后应当产出 ImageRequest、ImageResponse、DownloadCallback、OkHttpDownloader、UrlConnectionDownloader、DownloadTask 六个核心类,并通过 MockWebServer 单元测试覆盖成功/超时/重试/取消四条路径。