网络 / 缓存 / 存储 — 面试题篇
更新: 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 握手过程:
- ClientHello:客户端发送支持的 TLS 版本、加密套件列表、随机数 A
- ServerHello:服务端选定加密套件、发送随机数 B 和数字证书
- 客户端验证证书(CA 证书链验证)
- 客户端生成预主密钥,用证书中的公钥加密发送给服务端
- 双方用随机数 A + 随机数 B + 预主密钥生成对称密钥
- 后续通信使用对称密钥加密
核心思想:非对称加密交换密钥(安全但慢),对称加密传输数据(快)。
追问:证书验证的过程?
- 检查证书是否过期
- 检查证书的颁发者(CA)是否在信任列表中
- 用 CA 的公钥验证证书签名
- 检查证书的域名是否匹配
- 如果是中间 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:
- 解析方法上的注解(@GET/@POST/@Path/@Query/@Body 等),构建 ServiceMethod
- ServiceMethod 缓存在 ConcurrentHashMap 中,避免重复解析
- 根据注解信息构建 OkHttp Request
- CallAdapter 将 OkHttp Call 适配为目标返回类型(suspend 函数、Flow、RxJava Observable)
- Converter 将 ResponseBody 转换为数据类(Gson/Moshi/kotlinx.serialization)
追问:suspend 函数是怎么支持的?
Retrofit 检测到方法最后一个参数是 Continuation(suspend 函数编译后的特征),会使用 SuspendForBody 适配器。内部将 OkHttp 的异步 enqueue 回调转换为协程的 suspendCancellableCoroutine。
追问:怎么添加公共参数?
通过 OkHttp 的应用拦截器:
val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
.build()Q5: SharedPreferences 有什么问题?MMKV 为什么快?
考察点:存储方案对比
完整回答:
SP 的问题:
- 全量读写:每次 commit/apply 都序列化整个 Map 写入 XML
- ANR 风险:apply 是异步的,但 Activity onStop 时 QueuedWork.waitToFinish() 会同步等待所有 apply 完成,可能阻塞主线程
- 不支持多进程:MODE_MULTI_PROCESS 不可靠
- 首次加载慢:第一次 getSharedPreferences 会全量读取 XML 到内存
MMKV 快的原因:
- mmap 内存映射:写入内存页即完成,OS 负责异步刷盘。SP 需要写文件 + fsync
- 增量写入:protobuf 编码,新数据 append 到文件末尾,不需要全量序列化
- 多进程安全:文件锁 + CRC 校验
- 性能:写入比 SP 快约 100 倍
追问:DataStore 呢?
Jetpack DataStore 是 SP 的官方替代。基于 Flow 的异步 API,不会阻塞主线程。支持 Preferences(键值对)和 Proto(类型安全的序列化)两种模式。但性能不如 MMKV。
Q6: 如何设计一个多级缓存架构?
考察点:缓存设计能力
完整回答:
三级缓存:内存 → 磁盘 → 网络
请求数据
→ 查内存缓存(LruCache,O(1))
→ 命中:直接返回
→ 未命中:查磁盘缓存(DiskLruCache)
→ 命中:返回 + 写入内存缓存
→ 未命中:网络请求
→ 成功:返回 + 写入磁盘缓存 + 写入内存缓存设计要点:
- 内存缓存大小:通常用最大堆内存的 1/8
- 磁盘缓存大小:通常 50-200MB
- 缓存 key:URL 的 MD5 或 SHA256
- 过期策略:LRU(最近最少使用)+ TTL(过期时间)
- 线程安全:LruCache 内部 synchronized,DiskLruCache 内部有锁
- 缓存一致性:网络数据更新后同步更新缓存
以图片加载为例(Glide 的策略):
- 活动资源缓存(WeakReference,正在使用的图片)
- 内存缓存(LruCache)
- 磁盘缓存(原始图片 + 变换后的图片)
- 网络
加分点:提到缓存穿透(查询不存在的数据,可用布隆过滤器)、缓存雪崩(大量缓存同时过期,可用随机过期时间)。
Q7: Glide 的缓存机制?和 Picasso 有什么区别?
考察点:图片加载框架
完整回答:
Glide 四级缓存:
- 活动资源(WeakReference,正在使用的图片,引用计数管理)
- 内存缓存(LruResourceCache)
- 磁盘缓存(DATA 原图 + RESOURCE 变换后的图)
- 网络/数据源
Glide vs Picasso:
| 维度 | Glide | Picasso |
|---|---|---|
| 缓存 | 缓存变换后的图(按 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 的区别?
考察点:网络基础
完整回答:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认、重传、排序) | 不可靠(可能丢包、乱序) |
| 传输方式 | 字节流 | 数据报 |
| 速度 | 较慢(握手+确认开销) | 快 |
| 头部 | 20 字节 | 8 字节 |
| 拥塞控制 | 有(慢启动、拥塞避免) | 无 |
| 适用场景 | HTTP、文件传输 | 视频直播、DNS、游戏 |
追问:TCP 怎么保证可靠传输?
- 序列号 + 确认号:每个字节编号,接收方确认
- 超时重传:未收到 ACK 则重发
- 滑动窗口:流量控制,接收方告知可接收的数据量
- 拥塞控制:慢启动 → 拥塞避免 → 快重传 → 快恢复
Q9: 网络请求中的 Token 过期怎么处理?
考察点:实际工程问题
完整回答:
通过 OkHttp 拦截器实现自动刷新 Token:
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 定义升级脚本:
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:
@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 过期刷新逻辑。
