Skip to content

网络 / 缓存 / 存储 — 面试题篇

更新: 5/15/2026 字数: 0 字 时长: 0 分钟


Q1: OkHttp 的拦截器链是怎么工作的?应用拦截器和网络拦截器的区别?

考察点:OkHttp 架构

完整回答

OkHttp 使用责任链模式,请求依次经过:应用拦截器 → RetryAndFollowUpInterceptor → BridgeInterceptor → CacheInterceptor → ConnectInterceptor → 网络拦截器 → CallServerInterceptor。

每个拦截器调用 chain.proceed(request) 传递给下一个,可以在调用前修改 Request,调用后修改 Response。

应用拦截器 vs 网络拦截器:

  • 应用拦截器:最先执行,不关心重定向和重试,只调用一次。适合添加公共参数、日志
  • 网络拦截器:在 ConnectInterceptor 之后,能看到真实的网络请求(包括重定向后的)。适合网络层监控

追问:CacheInterceptor 的缓存策略?

遵循 HTTP 缓存规范:先检查本地缓存,根据 Cache-Control 判断是否过期。未过期直接返回(强缓存);过期则发条件请求(If-None-Match/If-Modified-Since),304 则用缓存,200 则更新缓存。

追问:连接池的作用?

ConnectionPool 复用 TCP 连接,避免每次请求都三次握手。默认最多 5 个空闲连接,空闲 5 分钟回收。HTTP/2 还能在同一连接上多路复用。


Q2: HTTPS 的原理?TLS 握手过程?

考察点:网络安全

完整回答

HTTPS = HTTP + TLS。TLS 握手过程:

  1. ClientHello:客户端发送支持的 TLS 版本、加密套件列表、随机数 A
  2. ServerHello:服务端选定加密套件、发送随机数 B 和数字证书
  3. 客户端验证证书(CA 证书链验证)
  4. 客户端生成预主密钥,用证书中的公钥加密发送给服务端
  5. 双方用随机数 A + 随机数 B + 预主密钥生成对称密钥
  6. 后续通信使用对称密钥加密

核心思想:非对称加密交换密钥(安全但慢),对称加密传输数据(快)。

追问:证书验证的过程?

  1. 检查证书是否过期
  2. 检查证书的颁发者(CA)是否在信任列表中
  3. 用 CA 的公钥验证证书签名
  4. 检查证书的域名是否匹配
  5. 如果是中间 CA,递归验证直到根 CA

追问:Android 中怎么做证书固定(Certificate Pinning)?

network_security_config.xml 中配置 pin,或在 OkHttp 中使用 CertificatePinner。将服务端证书的公钥 hash 固定在客户端,防止中间人攻击。


Q3: HTTP/1.1 和 HTTP/2 的区别?

考察点:HTTP 协议演进

完整回答

HTTP/1.1 的问题:

  • 队头阻塞:同一连接上请求必须排队,前一个响应完才能发下一个
  • 头部冗余:每次请求都携带完整头部(Cookie 等)
  • 只能客户端发起请求

HTTP/2 的改进:

  • 多路复用:一个 TCP 连接上并行多个 Stream,每个 Stream 是一个请求/响应对,互不阻塞
  • 头部压缩:HPACK 算法,维护静态表(61个常见头部)和动态表,只传输差异
  • 服务端推送:服务端可以主动推送资源(如 HTML 中引用的 CSS/JS)
  • 二进制分帧:数据分为 HEADERS 帧和 DATA 帧,解析更高效

OkHttp 默认支持 HTTP/2,通过 ALPN(Application-Layer Protocol Negotiation)在 TLS 握手时协商协议版本。


Q4: Retrofit 的原理?动态代理是怎么工作的?

考察点:Retrofit 架构

完整回答

Retrofit 的 create() 方法使用 Proxy.newProxyInstance 创建接口的动态代理对象。调用接口方法时,实际执行的是 InvocationHandler:

  1. 解析方法上的注解(@GET/@POST/@Path/@Query/@Body 等),构建 ServiceMethod
  2. ServiceMethod 缓存在 ConcurrentHashMap 中,避免重复解析
  3. 根据注解信息构建 OkHttp Request
  4. CallAdapter 将 OkHttp Call 适配为目标返回类型(suspend 函数、Flow、RxJava Observable)
  5. Converter 将 ResponseBody 转换为数据类(Gson/Moshi/kotlinx.serialization)

追问:suspend 函数是怎么支持的?

Retrofit 检测到方法最后一个参数是 Continuation(suspend 函数编译后的特征),会使用 SuspendForBody 适配器。内部将 OkHttp 的异步 enqueue 回调转换为协程的 suspendCancellableCoroutine。

追问:怎么添加公共参数?

通过 OkHttp 的应用拦截器:

kotlin
val client = OkHttpClient.Builder()
    .addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer $token")
            .build()
        chain.proceed(request)
    }
    .build()

Q5: SharedPreferences 有什么问题?MMKV 为什么快?

考察点:存储方案对比

完整回答

SP 的问题:

  1. 全量读写:每次 commit/apply 都序列化整个 Map 写入 XML
  2. ANR 风险:apply 是异步的,但 Activity onStop 时 QueuedWork.waitToFinish() 会同步等待所有 apply 完成,可能阻塞主线程
  3. 不支持多进程:MODE_MULTI_PROCESS 不可靠
  4. 首次加载慢:第一次 getSharedPreferences 会全量读取 XML 到内存

MMKV 快的原因:

  1. mmap 内存映射:写入内存页即完成,OS 负责异步刷盘。SP 需要写文件 + fsync
  2. 增量写入:protobuf 编码,新数据 append 到文件末尾,不需要全量序列化
  3. 多进程安全:文件锁 + CRC 校验
  4. 性能:写入比 SP 快约 100 倍

追问:DataStore 呢?

Jetpack DataStore 是 SP 的官方替代。基于 Flow 的异步 API,不会阻塞主线程。支持 Preferences(键值对)和 Proto(类型安全的序列化)两种模式。但性能不如 MMKV。


Q6: 如何设计一个多级缓存架构?

考察点:缓存设计能力

完整回答

三级缓存:内存 → 磁盘 → 网络

请求数据
  → 查内存缓存(LruCache,O(1))
    → 命中:直接返回
    → 未命中:查磁盘缓存(DiskLruCache)
      → 命中:返回 + 写入内存缓存
      → 未命中:网络请求
        → 成功:返回 + 写入磁盘缓存 + 写入内存缓存

设计要点:

  1. 内存缓存大小:通常用最大堆内存的 1/8
  2. 磁盘缓存大小:通常 50-200MB
  3. 缓存 key:URL 的 MD5 或 SHA256
  4. 过期策略:LRU(最近最少使用)+ TTL(过期时间)
  5. 线程安全:LruCache 内部 synchronized,DiskLruCache 内部有锁
  6. 缓存一致性:网络数据更新后同步更新缓存

以图片加载为例(Glide 的策略):

  • 活动资源缓存(WeakReference,正在使用的图片)
  • 内存缓存(LruCache)
  • 磁盘缓存(原始图片 + 变换后的图片)
  • 网络

加分点:提到缓存穿透(查询不存在的数据,可用布隆过滤器)、缓存雪崩(大量缓存同时过期,可用随机过期时间)。


Q7: Glide 的缓存机制?和 Picasso 有什么区别?

考察点:图片加载框架

完整回答

Glide 四级缓存:

  1. 活动资源(WeakReference,正在使用的图片,引用计数管理)
  2. 内存缓存(LruResourceCache)
  3. 磁盘缓存(DATA 原图 + RESOURCE 变换后的图)
  4. 网络/数据源

Glide vs Picasso:

维度GlidePicasso
缓存缓存变换后的图(按 ImageView 尺寸)缓存原图
内存更省(缓存适配尺寸的图)更费(缓存全尺寸原图)
GIF支持不支持
生命周期自动感知(注入 Fragment)不感知
磁盘缓存多种策略可选只缓存原图
包大小较大较小

追问:Glide 怎么做生命周期管理的?

Glide.with(activity) 会向 Activity 注入一个空的 SupportRequestManagerFragment。这个 Fragment 的生命周期回调(onStart/onStop/onDestroy)会通知 RequestManager 暂停/恢复/取消请求。

追问:Glide 的 BitmapPool 是什么?

BitmapPool 复用已回收的 Bitmap 内存。解码新图片时设置 inBitmap 为 Pool 中尺寸匹配的 Bitmap,避免频繁分配和回收内存,减少内存抖动和 GC。


Q8: TCP 和 UDP 的区别?

考察点:网络基础

完整回答

维度TCPUDP
连接面向连接(三次握手)无连接
可靠性可靠(确认、重传、排序)不可靠(可能丢包、乱序)
传输方式字节流数据报
速度较慢(握手+确认开销)
头部20 字节8 字节
拥塞控制有(慢启动、拥塞避免)
适用场景HTTP、文件传输视频直播、DNS、游戏

追问:TCP 怎么保证可靠传输?

  1. 序列号 + 确认号:每个字节编号,接收方确认
  2. 超时重传:未收到 ACK 则重发
  3. 滑动窗口:流量控制,接收方告知可接收的数据量
  4. 拥塞控制:慢启动 → 拥塞避免 → 快重传 → 快恢复

Q9: 网络请求中的 Token 过期怎么处理?

考察点:实际工程问题

完整回答

通过 OkHttp 拦截器实现自动刷新 Token:

kotlin
class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {
    override fun intercept(chain: Chain): Response {
        // 1. 添加 Token
        val request = chain.request().newBuilder()
            .header("Authorization", "Bearer ${tokenManager.accessToken}")
            .build()

        val response = chain.proceed(request)

        // 2. 401 则刷新 Token
        if (response.code == 401) {
            synchronized(this) {
                // 双重检查:可能其他线程已经刷新了
                val currentToken = tokenManager.accessToken
                if (currentToken == request.header("Authorization")?.removePrefix("Bearer ")) {
                    // Token 没变,说明还没刷新,执行刷新
                    val newToken = tokenManager.refreshTokenSync()
                        ?: return response // 刷新失败,跳转登录

                    // 用新 Token 重试
                    val newRequest = request.newBuilder()
                        .header("Authorization", "Bearer $newToken")
                        .build()
                    response.close()
                    return chain.proceed(newRequest)
                } else {
                    // Token 已被其他线程刷新,用新 Token 重试
                    val newRequest = request.newBuilder()
                        .header("Authorization", "Bearer ${tokenManager.accessToken}")
                        .build()
                    response.close()
                    return chain.proceed(newRequest)
                }
            }
        }
        return response
    }
}

关键点:synchronized 防止多个请求同时刷新 Token;双重检查避免重复刷新。


Q10: Room 数据库升级怎么做?

考察点:数据库迁移

完整回答

通过 Migration 定义升级脚本:

kotlin
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''")
    }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(db: SupportSQLiteDatabase) {
        // 创建新表
        db.execSQL("CREATE TABLE orders (id INTEGER PRIMARY KEY, userId INTEGER NOT NULL)")
    }
}

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addMigrations(MIGRATION_1_2, MIGRATION_2_3)
    .build()

如果没有提供 Migration,Room 默认会抛异常。可以用 fallbackToDestructiveMigration() 销毁重建(丢失数据,仅开发阶段使用)。

追问:怎么测试 Migration?

Room 提供了 MigrationTestHelper:

kotlin
@Test
fun migrate1To2() {
    helper.createDatabase(TEST_DB, 1).apply { close() }
    val db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)
    // 验证数据完整性
}

实习面试补充:网络与存储高频基础题

实习面试很喜欢从“项目怎么请求接口、怎么解析 JSON、怎么保存登录态”这些具体问题开始。

Q11: GET 和 POST 有什么区别?

考察点:HTTP 基础

完整回答

  • GET 通常用于获取资源,参数常放在 URL query 中。
  • POST 通常用于提交数据,参数常放在请求体中。
  • GET 请求更容易被缓存和记录,POST 更适合提交表单、登录、创建资源等场景。

需要注意:GET 和 POST 的语义由 HTTP 规范定义,安全性不能只靠方法区分。敏感数据应该使用 HTTPS,并避免把 token、密码放在 URL 中。

追问:GET 一定没有请求体吗?

规范没有绝对禁止,但实际开发中不推荐给 GET 放请求体,因为很多服务器、代理和客户端不会按预期处理。


Q12: 常见 HTTP 状态码有哪些?

考察点:接口调试能力

完整回答

  • 200:请求成功。
  • 201:创建成功。
  • 301/302:重定向。
  • 400:请求参数错误。
  • 401:未认证或登录失效。
  • 403:无权限。
  • 404:资源不存在。
  • 500:服务端内部错误。
  • 502/503:网关或服务暂不可用。

加分点:移动端排查接口问题时,要同时看状态码、业务 code、错误信息、请求参数、响应体和服务端日志。


Q13: SharedPreferences、DataStore 和 Room 分别适合存什么?

考察点:本地存储选型

完整回答

  • SharedPreferences:适合少量 key-value 配置,比如开关、简单标记。同步读取方便,但不适合复杂数据和频繁写入。
  • DataStore:适合替代 SharedPreferences,基于协程和 Flow,支持异步、类型安全更好的数据存储。
  • Room:适合结构化数据和复杂查询,比如用户表、消息列表、搜索历史。

追问:登录 token 存哪里?

可以用 DataStore 或加密后的 SharedPreferences。更关键的是避免明文暴露,退出登录时及时清理,并注意 token 过期刷新逻辑。