面试题汇总
更新: 5/20/2026 字数: 0 字 时长: 0 分钟
Java / 基础
1. HashMap 的 put 流程是怎样的?为什么用红黑树?
考察点:HashMap 底层数据结构、hash 计算、扩容机制
完整回答:
HashMap 在 JDK 1.8 中底层是数组 + 链表 + 红黑树。put 流程如下:
- 对 key 调用
hash()方法:(h = key.hashCode()) ^ (h >>> 16),高 16 位异或低 16 位做扰动,减少碰撞 - 用
(n-1) & hash定位桶索引 - 如果桶为空,直接创建新节点放入
- 如果桶不为空,判断第一个节点的 key 是否相同(hash 相等且 equals 为 true),相同则覆盖 value
- 如果是红黑树节点,调用
putTreeVal插入 - 否则遍历链表,尾插法插入新节点。如果链表长度 ≥ 8 且数组长度 ≥ 64,将链表转为红黑树;数组长度 < 64 时优先扩容
- 插入后如果
size > threshold(容量 × 负载因子 0.75),触发 resize 扩容
用红黑树是因为链表过长时查找退化为 O(n),红黑树保证 O(log n)。阈值 8 是基于泊松分布计算的——正常 hash 分布下链表长度达到 8 的概率极低(约千万分之六)。
追问1:resize 扩容时节点怎么重新分配?
容量翻倍后,用 hash & oldCap 判断节点位置:
- 结果为 0 → 留在原索引
- 结果不为 0 → 移到原索引 + oldCap
这是因为扩容后 (newCap-1) & hash 比 (oldCap-1) & hash 多了一个高位 bit,这个 bit 正好是 oldCap 的位置。
追问2:HashMap 为什么线程不安全?
JDK 1.7 头插法并发扩容会导致链表成环(死循环)。JDK 1.8 改为尾插法解决了成环问题,但并发 put 仍可能丢数据——两个线程同时判断桶为空,都执行插入,后一个覆盖前一个。
加分点:提到 HashMap 允许 null key(hash 为 0),而 ConcurrentHashMap 不允许 null key/value。
2. ConcurrentHashMap 如何保证线程安全?JDK 1.7 和 1.8 有什么区别?
考察点:并发容器实现原理
完整回答:
JDK 1.7:分段锁(Segment)。将数据分成 16 个 Segment,每个 Segment 继承 ReentrantLock,是一个小 HashMap。不同 Segment 的操作互不影响,并发度 = Segment 数量。
JDK 1.8:抛弃 Segment,改用 CAS + synchronized,锁粒度细化到桶级别:
- 桶为空时,CAS 插入新节点(无锁)
- 桶非空时,synchronized 锁住桶的头节点,然后在链表/红黑树中操作
- 扩容时多线程协助 transfer,每个线程负责一段桶的迁移
追问1:size() 怎么计算的?
JDK 1.8 使用 baseCount + CounterCell[] 数组。put 成功后先 CAS 更新 baseCount,失败则更新随机一个 CounterCell。size() 时将 baseCount 和所有 CounterCell 的值求和。这个设计借鉴了 LongAdder 的思想,减少 CAS 竞争。
追问2:为什么不允许 null key/value?
多线程环境下,get(key) 返回 null 无法区分是"key 不存在"还是"value 就是 null"。单线程的 HashMap 可以用 containsKey 再确认,但并发环境下两次调用之间状态可能变化。
加分点:提到 JDK 1.8 的 transfer 方法支持多线程并发扩容,通过 transferIndex 分配任务段。
3. ArrayList 和 LinkedList 的区别?什么场景用哪个?
考察点:集合选型
完整回答:
ArrayList 底层是 Object 数组,LinkedList 底层是双向链表。
核心区别:
- 随机访问:ArrayList O(1),LinkedList O(n)
- 头部插入/删除:ArrayList O(n) 需要移动元素,LinkedList O(1)
- 尾部插入:ArrayList 均摊 O(1)(偶尔扩容),LinkedList O(1)
- 内存:ArrayList 紧凑连续,CPU 缓存友好;LinkedList 每个节点额外两个指针(prev/next),内存开销大且不连续
结论:绝大多数场景用 ArrayList。LinkedList 理论上头部插入快,但实际因为缓存不友好,在大多数基准测试中都不如 ArrayList。只有在需要频繁头部插入删除且不需要随机访问的场景(如实现队列)才考虑 LinkedList,但即便如此 ArrayDeque 通常更好。
追问:ArrayList 的 modCount 是什么?
modCount 记录结构性修改次数,用于快速失败(fail-fast)。迭代器创建时记录 expectedModCount,每次 next() 前检查两者是否一致,不一致则抛 ConcurrentModificationException。这不是线程安全机制,只是尽早发现并发修改的 bug。
4. Java 泛型的类型擦除是什么?有什么影响?
考察点:泛型原理
完整回答:
Java 泛型是编译期特性,编译后泛型信息被擦除——List<String> 和 List<Integer> 在运行时都是 List,类型参数被替换为上界(无界则为 Object)。
影响:
- 运行时无法获取泛型类型:
list instanceof List<String>编译报错 - 不能创建泛型数组:
new T[]不合法 - 不能用基本类型作为类型参数:
List<int>不行,必须List<Integer> - 可以通过反射绕过泛型检查
Gson 解析时需要 TypeToken 来保留泛型信息:new TypeToken<List<User>>(){}.getType()。原理是匿名内部类的父类泛型签名保存在字节码的 Signature 属性中,不会被擦除。
追问:PECS 原则是什么?
Producer Extends, Consumer Super:
- 只读取数据(生产者)用
<? extends T>:可以安全地读出 T 类型 - 只写入数据(消费者)用
<? super T>:可以安全地写入 T 类型 - 既读又写不用通配符
典型例子:Collections.copy(List<? super T> dest, List<? extends T> src)
加分点:Kotlin 用 out(协变,对应 extends)和 in(逆变,对应 super)在声明处指定型变,比 Java 的使用处型变更简洁。reified 内联泛型可以在运行时获取类型信息。
5. JVM 内存区域有哪些?各自存什么?哪些会 OOM?
考察点:JVM 内存模型
完整回答:
JVM 运行时数据区分为线程私有和线程共享两类:
线程私有:
- 程序计数器:当前线程执行的字节码行号,唯一不会 OOM 的区域
- 虚拟机栈:每个方法调用创建一个栈帧,包含局部变量表、操作数栈、动态链接、返回地址。递归过深会 StackOverflowError
- 本地方法栈:Native 方法调用,同样可能 StackOverflowError
线程共享:
- 堆:存放对象实例和数组,GC 的主要区域。分为新生代(Eden + S0 + S1)和老年代。对象分配不了就 OOM
- 方法区(JDK 8+ 用元空间实现,使用本地内存):存储类信息、常量池、静态变量。加载类过多会 OOM
追问:对象创建的过程?
- 类加载检查 → 2. 分配内存(指针碰撞或空闲列表,TLAB 保证线程安全)→ 3. 初始化零值 → 4. 设置对象头(Mark Word + 类型指针)→ 5. 执行构造方法
追问:四种引用类型?
- 强引用:
new Object(),不回收,宁可 OOM - 软引用:
SoftReference,内存不足时回收,适合缓存 - 弱引用:
WeakReference,下次 GC 就回收,Handler 防泄漏常用 - 虚引用:
PhantomReference,无法获取对象,仅用于回收通知
6. GC 算法有哪些?Android 的 GC 有什么特点?
考察点:垃圾回收
完整回答:
三种基础算法:
- 标记-清除:标记存活对象,清除其余。缺点是内存碎片
- 标记-复制:将存活对象复制到另一块区域。新生代用这个,Eden:S0:S1 = 8:1:1
- 标记-整理:标记后将存活对象向一端移动。老年代用这个,无碎片但移动开销大
常见收集器:
- CMS:以最短停顿为目标,四阶段(初始标记→并发标记→重新标记→并发清除),但有碎片问题
- G1:将堆划分为多个 Region,可预测停顿时间,Mixed GC 同时回收新生代和部分老年代
Android ART 的 CC(Concurrent Copying)收集器:
- 并发复制算法,几乎不需要暂停应用线程
- 使用读屏障(Read Barrier)而非写屏障
- 堆压缩在后台并发进行,减少碎片
- 针对移动设备优化,减少内存占用和停顿时间
追问:如何判断对象是否存活?
可达性分析:从 GC Roots 出发,沿引用链遍历,不可达的对象即为垃圾。GC Roots 包括:虚拟机栈中引用的对象、静态变量引用的对象、JNI 引用的对象等。
加分点:提到 Android 的 GC 日志分析,以及 ART 相比 Dalvik 的改进(AOT 编译、改进的 GC)。
7. String 为什么是不可变的?String、StringBuilder、StringBuffer 的区别?
考察点:String 原理
完整回答:
String 不可变的原因:
private final char[] value(JDK 9+ 改为byte[]),final 修饰且没有提供修改方法- String 类本身是 final 的,不能被继承
不可变的好处:
- 线程安全:多线程共享无需同步
- 哈希缓存:hashCode 只需计算一次,HashMap 的 key 常用 String
- 字符串常量池:相同内容的字符串共享同一对象,节省内存
- 安全性:网络连接、文件路径等用 String 不会被篡改
三者区别:
| 特性 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 是(不可变) | 否 | 是(synchronized) |
| 性能 | 拼接慢(每次创建新对象) | 最快 | 较快 |
| 场景 | 少量字符串操作 | 单线程大量拼接 | 多线程大量拼接 |
追问:String s = new String("abc") 创建了几个对象?
最多 2 个:
- 如果常量池中没有 "abc",先在常量池创建一个
- 在堆中创建一个 String 对象,内部引用常量池的 char[]
String s = "abc" 只有 1 个(直接引用常量池)。
8. == 和 equals 的区别?为什么重写 equals 必须重写 hashCode?
考察点:Object 基础方法
完整回答:
==:基本类型比较值,引用类型比较内存地址equals:Object 默认实现就是==,String/Integer 等重写为比较内容
为什么重写 equals 必须重写 hashCode:
HashMap 查找时先用 hashCode 定位桶,再用 equals 比较 key。如果两个对象 equals 相等但 hashCode 不同,HashMap 会认为它们在不同的桶中,导致同一个 key 存了两份数据。
规约:
- equals 相等 → hashCode 必须相等
- hashCode 相等 → equals 不一定相等(哈希碰撞)
// ❌ 只重写 equals 不重写 hashCode
class User {
String name;
@Override
public boolean equals(Object o) {
return name.equals(((User) o).name);
}
// 没重写 hashCode!
}
Map<User, String> map = new HashMap<>();
map.put(new User("张三"), "value");
map.get(new User("张三")); // null!因为两个 User 对象 hashCode 不同9. Java 的 final、finally、finalize 区别?
考察点:Java 基础
完整回答:
- final:修饰类(不可继承)、方法(不可重写)、变量(不可重新赋值,引用类型内容仍可变)
- finally:try-catch-finally 中的清理代码块,无论是否异常都会执行(除非
System.exit()或线程被杀) - finalize:Object 的方法,GC 回收对象前调用。已废弃(Java 9+),不可靠(不保证执行时机),用
Cleaner或 try-with-resources 替代
10. 接口和抽象类的区别?Java 8 接口有什么变化?
考察点:面向对象
完整回答:
| 特性 | 接口 | 抽象类 |
|---|---|---|
| 实例化 | 不能 | 不能 |
| 多继承 | 可以实现多个接口 | 只能继承一个类 |
| 构造方法 | 没有 | 有 |
| 成员变量 | 只有 public static final 常量 | 可以有各种成员变量 |
| 方法 | Java 8 前只有抽象方法 | 可以有具体方法 |
Java 8 接口新增:
default方法:有默认实现,实现类可以不重写static方法:接口的静态工具方法
选择原则:
- 定义行为规范(能力)→ 接口(Comparable、Serializable)
- 定义共同基类(is-a 关系)→ 抽象类(Activity、Fragment)
11. SparseArray 和 ArrayMap 是什么?为什么 Android 推荐用它们替代 HashMap?
考察点:Android 特有集合
完整回答:
HashMap 在数据量小时(<1000)内存浪费严重:
- 每个 Entry 对象 32 字节开销
- 自动装箱(int → Integer)
- 默认容量 16,负载因子 0.75,空间利用率低
SparseArray:
- key 是 int(避免装箱),value 是 Object
- 两个数组:int[] keys(有序)+ Object[] values
- 二分查找 O(log n),但数据量小时比 HashMap 快(缓存友好)
- 删除时标记为 DELETED,延迟压缩
ArrayMap:
- key/value 都是 Object
- 两个数组:int[] hashes(有序)+ Object[] array(key-value 交替存储)
- 二分查找 hash 定位,然后线性查找 key
- 内存比 HashMap 省 2-3 倍
选择:
- key 是 int → SparseArray
- key 是 Object 且数据量 < 1000 → ArrayMap
- 数据量大或频繁增删 → HashMap
实习面试更常从语言基础开始追问,不一定要求源码级深度,但要求概念准确、能结合代码说明。
12. == 和 equals() 的区别?
考察点:对象比较、字符串常量池
完整回答:
==比较的是两个变量的值。如果是基本类型,比较具体数值;如果是引用类型,比较对象地址。equals()是Object的方法,默认也是比较地址,但很多类会重写它,比如String会比较字符串内容。
String a = new String("abc");
String b = new String("abc");
System.out.println(a == b); // false,不是同一个对象
System.out.println(a.equals(b)); // true,内容相同追问:String 为什么有时 == 也是 true?
字符串字面量会进入字符串常量池:
String a = "abc";
String b = "abc";
System.out.println(a == b); // true,指向常量池中的同一个对象加分点:实际开发中比较字符串内容用 equals();为了避免空指针,可以写成 "abc".equals(str)。
13. Java 异常分为哪几类?开发中怎么处理?
考察点:异常体系、编码规范
完整回答:
Java 异常体系中,Throwable 下主要有两类:
Error:严重错误,通常程序无法处理,比如OutOfMemoryError、StackOverflowError。Exception:程序可以处理的异常。
Exception 又分为:
- 受检异常:编译期要求处理,比如
IOException。 - 运行时异常:继承自
RuntimeException,比如NullPointerException、IndexOutOfBoundsException、ClassCastException。
开发中不要用空的 catch 吞掉异常,至少要记录日志或转换成业务可理解的错误。
追问:finally 一定会执行吗?
大多数情况下会执行,但如果执行了 System.exit()、进程被杀、虚拟机崩溃,finally 不保证执行。
14. ArrayList 遍历时删除元素为什么容易出问题?
考察点:集合遍历、快速失败机制
完整回答:
使用增强 for 遍历 ArrayList 时,本质上使用的是 Iterator。如果遍历过程中直接调用 list.remove() 修改集合,会导致 modCount 和 expectedModCount 不一致,可能抛出 ConcurrentModificationException。
正确做法是使用迭代器的 remove():
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String item = iterator.next();
if (item.isEmpty()) {
iterator.remove();
}
}加分点:如果只是过滤生成新列表,可以优先使用新集合承接结果,逻辑更清晰。
Java / 并发
1. synchronized 和 ReentrantLock 的区别?
考察点:锁机制对比
完整回答:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置,monitorenter/monitorexit 字节码 | Java API,基于 AQS |
| 锁释放 | 自动释放(退出同步块/异常) | 必须手动 unlock(),通常在 finally 中 |
| 可中断 | 不可中断,线程会一直等待 | lockInterruptibly() 支持中断等待 |
| 公平性 | 只有非公平 | 构造时可选公平/非公平 |
| 条件变量 | 只有一个等待队列(wait/notify) | 可创建多个 Condition |
| 锁升级 | 偏向锁→轻量级锁→重量级锁 | 无锁升级机制 |
| 性能 | JDK 6 后大幅优化,差距不大 | 差距不大 |
选择建议:简单同步用 synchronized(代码简洁、不会忘记释放锁);需要可中断、公平锁、多条件变量等高级功能时用 ReentrantLock。
追问:synchronized 的锁升级过程?
- 无锁 → 偏向锁:第一个线程进入,在 Mark Word 中记录线程 ID,后续该线程进入只需比较 ID
- 偏向锁 → 轻量级锁:第二个线程尝试获取锁,撤销偏向,两个线程 CAS 竞争
- 轻量级锁 → 重量级锁:CAS 自旋一定次数仍失败,膨胀为重量级锁,未获取锁的线程阻塞
锁升级是单向的,不可降级。
追问:什么是锁消除和锁粗化?
锁消除:JIT 编译器通过逃逸分析发现锁对象不会逃逸到其他线程,自动去除锁。比如方法内的 StringBuffer。
锁粗化:连续对同一对象多次加锁解锁,合并为一次大范围的锁。
2. volatile 的原理?能保证线程安全吗?
考察点:JMM、volatile 语义
完整回答:
volatile 提供两个保证:
可见性:volatile 变量的写会立即刷新到主内存,读会从主内存加载。其他线程能立即看到最新值。
有序性:通过内存屏障禁止指令重排。volatile 写前插入 StoreStore 屏障,写后插入 StoreLoad 屏障;volatile 读后插入 LoadLoad 和 LoadStore 屏障。
但 volatile 不保证原子性。i++ 是读-改-写三步操作,两个线程可能同时读到相同值,各自加 1 后写回,丢失一次自增。
所以 volatile 不能完全保证线程安全,只适用于:
- 状态标志位(boolean flag)
- DCL 单例中防止指令重排
- 一写多读的场景
需要原子性时用 AtomicInteger(CAS)或 synchronized。
追问:DCL 单例为什么需要 volatile?
new Singleton() 分三步:1.分配内存 2.初始化 3.赋值引用。没有 volatile,步骤 2 和 3 可能重排序,另一个线程看到非 null 的引用但对象还没初始化完成。volatile 禁止这个重排序。
3. 线程池的核心参数?execute 的执行流程?
考察点:ThreadPoolExecutor 原理
完整回答:
7 个核心参数:
corePoolSize:核心线程数,即使空闲也不回收maximumPoolSize:最大线程数keepAliveTime+unit:非核心线程空闲存活时间workQueue:任务队列(LinkedBlockingQueue/ArrayBlockingQueue/SynchronousQueue)threadFactory:线程工厂,可自定义线程名handler:拒绝策略
execute 流程:
- 当前线程数 < corePoolSize → 创建核心线程执行任务
- 核心线程满 → 任务放入 workQueue
- 队列满且线程数 < maximumPoolSize → 创建非核心线程
- 队列满且线程数 = maximumPoolSize → 执行拒绝策略
四种拒绝策略:
- AbortPolicy(默认):抛 RejectedExecutionException
- CallerRunsPolicy:调用者线程执行任务(降级)
- DiscardPolicy:静默丢弃
- DiscardOldestPolicy:丢弃队列头部最老的任务
追问:常见线程池的问题?
Executors.newFixedThreadPool:LinkedBlockingQueue 无界队列,任务堆积可能 OOMExecutors.newCachedThreadPool:maximumPoolSize = Integer.MAX_VALUE,可能创建大量线程- 阿里规范建议手动创建 ThreadPoolExecutor,明确指定参数
追问:核心线程能回收吗?
可以,调用 allowCoreThreadTimeOut(true) 后,核心线程空闲超过 keepAliveTime 也会被回收。
加分点:提到 Worker 继承了 AQS 实现不可重入锁,用于判断线程是否空闲(shutdown 时只中断空闲线程)。
4. ThreadLocal 的原理?为什么会内存泄漏?
考察点:ThreadLocal 实现机制
完整回答:
每个 Thread 内部持有一个 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是存储的值。
Thread
└── ThreadLocalMap
├── Entry(WeakReference<ThreadLocal>, value1)
├── Entry(WeakReference<ThreadLocal>, value2)
└── ...get() 流程:获取当前线程的 ThreadLocalMap → 以 this(ThreadLocal 对象)为 key 查找 Entry → 返回 value。
内存泄漏原因:Entry 的 key 是弱引用,当 ThreadLocal 对象没有外部强引用时,GC 会回收 key,但 value 是强引用仍然存在。如果线程是线程池中的长期存活线程,这些 value 就永远不会被回收。
解决方案:使用完毕后调用 threadLocal.remove()。ThreadLocalMap 在 get/set 时也会清理 key 为 null 的 Entry(expungeStaleEntry),但不能完全依赖这个机制。
追问:InheritableThreadLocal 是什么?
普通 ThreadLocal 父子线程不共享数据。InheritableThreadLocal 在创建子线程时会将父线程的值拷贝到子线程的 inheritableThreadLocals 中。但注意线程池中线程是复用的,不会每次都拷贝,需要用阿里的 TransmittableThreadLocal 解决。
5. AQS 的原理?ReentrantLock 怎么实现的?
考察点:AQS 框架
完整回答:
AQS(AbstractQueuedSynchronizer)是 Java 并发包的基础框架,核心是:
volatile int state:同步状态(ReentrantLock 中 0=未锁,>0=重入次数)- CLH 双向队列:存放等待获取锁的线程节点
加锁流程(acquire):
tryAcquire:CAS 尝试将 state 从 0 改为 1,成功则获取锁- 如果当前线程已持有锁(重入),state++
- 失败则
addWaiter创建节点加入 CLH 队列尾部 acquireQueued:在队列中自旋,如果前驱是 head 则再次 tryAcquire- 否则
LockSupport.park()阻塞
解锁流程(release):
tryRelease:state--,state == 0 时真正释放LockSupport.unpark()唤醒队列中下一个等待线程
公平 vs 非公平:
- 非公平(默认):新线程直接 CAS 抢锁,不管队列
- 公平:tryAcquire 先检查
hasQueuedPredecessors(),队列中有等待线程则不抢
追问:基于 AQS 的其他实现?
- Semaphore:state 表示许可数量
- CountDownLatch:state 表示计数,countDown 减 1,await 等待 state 变为 0
- ReentrantReadWriteLock:state 高 16 位读锁计数,低 16 位写锁计数
6. CAS 是什么?ABA 问题怎么解决?
考察点:无锁编程
完整回答:
CAS(Compare And Swap)是一种无锁原子操作:比较内存值与预期值,相等则更新为新值,否则不操作。底层依赖 CPU 的 cmpxchg 指令。
Java 中通过 Unsafe.compareAndSwapInt 实现,AtomicInteger 等原子类都基于 CAS。
ABA 问题:线程1 读到值 A,线程2 将 A 改为 B 再改回 A,线程1 CAS 检查发现还是 A 就成功了,但实际上值已经被修改过。
解决方案:
AtomicStampedReference:加版本号(stamp),每次修改版本号+1,CAS 同时比较值和版本号AtomicMarkableReference:加布尔标记
大多数业务场景 ABA 问题不会造成实际影响,只有在涉及链表等数据结构的无锁算法中才需要关注。
加分点:CAS 的缺点——自旋开销(竞争激烈时 CPU 空转)、只能保证单个变量的原子性。LongAdder 通过分散热点(Cell 数组)减少 CAS 竞争,比 AtomicLong 在高并发下性能更好。
7. CountDownLatch 和 CyclicBarrier 的区别?
考察点:并发工具类
完整回答:
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 等待方式 | 一个线程等待其他 N 个线程 | N 个线程互相等待 |
| 可重用 | 一次性,计数到 0 不可重置 | 可重用(reset) |
| 底层 | AQS 的共享模式 | ReentrantLock + Condition |
| 回调 | 无 | 到达屏障时可执行 barrierAction |
使用场景:
- CountDownLatch:主线程等待多个子任务完成(如启动优化中等待多个 SDK 初始化完成)
- CyclicBarrier:多个线程分阶段执行,每阶段结束时同步(如并行计算后合并结果)
追问:Semaphore 呢?
Semaphore 控制并发访问数量。acquire() 获取许可(-1),release() 释放许可(+1)。许可为 0 时阻塞。适合限流场景(如数据库连接池最多 10 个连接)。
8. wait/notify 和 Condition 的区别?wait 为什么必须在 synchronized 中?
考察点:线程通信
完整回答:
wait/notify 是 Object 的方法,配合 synchronized 使用。Condition 是 Lock 的条件变量,配合 ReentrantLock 使用。
| 维度 | wait/notify | Condition |
|---|---|---|
| 配合 | synchronized | ReentrantLock |
| 条件队列 | 只有一个 | 可以有多个(精确唤醒) |
| 唤醒 | notify 随机唤醒一个 | signal 唤醒指定条件的线程 |
wait 必须在 synchronized 中的原因:wait 会释放锁并进入等待队列。如果不在 synchronized 中,没有锁可以释放,会抛 IllegalMonitorStateException。更重要的是,如果不加锁,check-then-wait 之间可能被其他线程插入 notify,导致信号丢失(lost wake-up)。
// 经典的生产者-消费者
synchronized (lock) {
while (queue.isEmpty()) { // 用 while 不用 if,防止虚假唤醒
lock.wait();
}
item = queue.poll();
}9. 死锁的条件?怎么排查和避免?
考察点:并发问题排查
完整回答:
死锁四个必要条件:
- 互斥:资源同一时刻只能被一个线程持有
- 持有并等待:持有一个资源的同时等待另一个资源
- 不可剥夺:已获取的资源不能被强制释放
- 循环等待:线程 A 等 B 的锁,B 等 A 的锁
排查方法:
jstack <pid>:打印线程堆栈,查找BLOCKED状态和waiting to lock信息- Android Studio Profiler → CPU → Thread dump
- ANR traces.txt 中查找死锁信息
避免方法:
- 固定加锁顺序(所有线程按相同顺序获取锁)
- 使用
tryLock(timeout)替代lock(),超时则放弃 - 减少锁的粒度和持有时间
- 使用无锁数据结构(ConcurrentHashMap、AtomicInteger)
10. Java 线程有哪些状态?状态之间怎么转换?
考察点:线程生命周期
完整回答:
6 种状态(Thread.State 枚举):
NEW → RUNNABLE → BLOCKED / WAITING / TIMED_WAITING → TERMINATED- NEW:创建但未 start
- RUNNABLE:调用 start 后,包括就绪和运行中(Java 不区分)
- BLOCKED:等待获取 synchronized 锁
- WAITING:调用 wait()、join()、LockSupport.park(),无限等待
- TIMED_WAITING:调用 sleep(ms)、wait(ms)、join(ms),有超时的等待
- TERMINATED:执行完毕或异常退出
追问:sleep 和 wait 的区别?
| 维度 | sleep | wait |
|---|---|---|
| 所属 | Thread 静态方法 | Object 实例方法 |
| 锁 | 不释放锁 | 释放锁 |
| 唤醒 | 超时自动唤醒 | 需要 notify/notifyAll |
| 使用条件 | 任何地方 | 必须在 synchronized 中 |
实习面试通常先确认你是否理解“主线程负责 UI,耗时任务放子线程”,再逐步追问 Handler、线程安全和协程。
11. Android 为什么不能在子线程直接更新 UI?
考察点:主线程模型、线程安全
完整回答:
Android 的 View 体系不是线程安全的。UI 的测量、布局、绘制和事件分发都在主线程中按消息队列顺序执行,如果多个线程同时修改 View 状态,会产生竞态条件,导致显示错乱甚至崩溃。
因此耗时任务应该放到子线程执行,拿到结果后切回主线程更新 UI。常见方式:
Activity.runOnUiThreadView.postHandler(Looper.getMainLooper()).post- Kotlin 协程中的
withContext(Dispatchers.Main)
追问:子线程一定不能创建 Handler 吗?
可以,但这个线程必须先调用 Looper.prepare() 创建 Looper,再调用 Looper.loop() 开启消息循环。普通子线程默认没有 Looper。
12. 线程和进程有什么区别?
考察点:操作系统基础
完整回答:
- 进程是系统分配资源的基本单位,有独立的内存空间。
- 线程是 CPU 调度的基本单位,同一个进程内的线程共享进程资源。
Android 中每个 App 通常运行在独立进程中,主线程也叫 UI 线程。网络请求、数据库读写、图片解码等耗时任务如果放在主线程,可能造成卡顿或 ANR。
加分点:多个线程共享内存,所以要注意线程安全;多个进程之间内存隔离,通信通常需要 IPC,比如 Binder。
13. 什么情况下会出现线程安全问题?
考察点:共享变量、并发修改
完整回答:
线程安全问题通常出现在多个线程同时读写同一份可变数据时。例如多个线程同时执行 count++,它不是原子操作,实际包含读取、加一、写回三个步骤,可能导致结果丢失。
解决方式:
- 使用
synchronized或Lock保护临界区。 - 使用原子类,比如
AtomicInteger。 - 尽量使用不可变对象,减少共享可变状态。
- 在 Android 中把 UI 状态收敛到主线程更新。
追问:volatile 能保证 count++ 线程安全吗?
不能。volatile 主要保证可见性和禁止部分重排序,不保证复合操作的原子性。
Kotlin / Kotlin 核心与协程
1. Kotlin 协程的挂起原理是什么?
考察点:协程底层实现
完整回答:
Kotlin 协程的挂起本质是 CPS(Continuation Passing Style)变换 + 状态机。
编译器将 suspend 函数转换为带 Continuation 参数的普通函数,函数体被改写为一个 when(label) 状态机。每个挂起点对应一个 label 状态。
执行到挂起点时:
- 保存当前局部变量到 Continuation 对象
- 设置下一个 label
- 调用挂起函数,如果返回
COROUTINE_SUSPENDED则函数返回(挂起) - 异步操作完成后,调用
continuation.resumeWith(result)恢复执行 - 恢复时从上次的 label 继续执行,从 Continuation 中恢复局部变量
关键点:协程挂起不会阻塞线程,线程可以去执行其他协程。恢复时由调度器决定在哪个线程继续执行。
追问:suspend 关键字的作用?
suspend 只是一个标记,告诉编译器这个函数可能挂起。编译器会为它添加 Continuation 参数并生成状态机代码。suspend 函数只能在协程或其他 suspend 函数中调用。
追问:协程比线程轻量在哪?
- 协程是用户态调度,不需要内核态切换(线程切换需要系统调用,开销约 1-10μs)
- 协程挂起时只保存少量状态(Continuation 对象),线程需要保存完整的栈帧(默认 1MB 栈空间)
- 一个线程上可以运行成千上万个协程
2. launch 和 async 的区别?异常处理有什么不同?
考察点:协程构建器与异常传播
完整回答:
launch 返回 Job,用于不需要返回值的场景。async 返回 Deferred(继承 Job),可以通过 await() 获取返回值。
异常处理的关键区别:
launch:异常立即向上传播到父协程,如果没有 CoroutineExceptionHandler,会导致整个协程作用域取消async:异常被封装在 Deferred 中,调用await()时才抛出
// launch 异常立即传播
val scope = CoroutineScope(Job())
scope.launch {
throw RuntimeException("boom") // 立即传播,scope 被取消
}
// async 异常延迟到 await
val deferred = scope.async {
throw RuntimeException("boom") // 暂时不传播
}
deferred.await() // 这里才抛出异常追问:CoroutineExceptionHandler 在哪里生效?
只在根协程(顶层 launch)中生效,子协程的异常会向上传播到父协程,不会被子协程的 handler 捕获。
val handler = CoroutineExceptionHandler { _, e -> println("Caught: $e") }
// ✅ 生效:handler 在根协程
CoroutineScope(Job() + handler).launch {
throw RuntimeException()
}
// ❌ 不生效:handler 在子协程
CoroutineScope(Job()).launch {
launch(handler) { // handler 在子协程,不生效
throw RuntimeException()
}
}追问:supervisorScope 和 coroutineScope 的区别?
coroutineScope:任何子协程失败,所有子协程都被取消supervisorScope:子协程失败不影响兄弟协程
SupervisorJob 的原理是重写了 childCancelled() 返回 false,不将子协程的失败传播给父协程。
3. Flow 和 LiveData 的区别?什么时候用哪个?
考察点:响应式数据流
完整回答:
| 维度 | Flow | LiveData |
|---|---|---|
| 所属 | Kotlin 协程库 | Android Jetpack |
| 生命周期感知 | 需要配合 lifecycleScope.collect | 自动感知,只在 STARTED 以上分发 |
| 操作符 | 丰富(map/filter/flatMap/combine/zip 等) | 只有 map/switchMap |
| 背压 | 支持(buffer/conflate/collectLatest) | 不支持 |
| 线程切换 | flowOn 指定上游线程 | 只在主线程观察 |
| 冷/热 | Flow 冷流,SharedFlow/StateFlow 热流 | 类似热流 |
选择建议:
- 纯 UI 层观察简单状态:StateFlow 或 LiveData 都可以
- 需要复杂数据变换、组合多个数据源:Flow
- Repository/数据层:Flow(不依赖 Android 框架)
- 新项目推荐全面使用 Flow + StateFlow 替代 LiveData
追问:StateFlow 和 SharedFlow 的区别?
- StateFlow:必须有初始值,replay = 1,自动 distinctUntilChanged(相同值不重复发射)
- SharedFlow:无需初始值,可配置 replay 和 buffer,不去重
StateFlow 适合表示状态(UI State),SharedFlow 适合表示事件(一次性事件如 Toast、导航)。
追问:LiveData 的粘性事件问题?
LiveData 内部有版本号机制,新观察者注册时如果版本号落后于最新值,会立即收到最后一次数据。这在事件场景下会导致重复消费(如 Toast 重复弹出)。解决方案:用 SharedFlow(replay=0) 替代,或用 Event 包装类。
4. Kotlin 的内联函数原理?什么时候用?
考察点:inline 机制
完整回答:
inline 函数在编译时将函数体和 lambda 参数直接内联到调用处,不会创建 Function 对象和额外的方法调用。
主要用途:
- 消除 lambda 开销:普通高阶函数每次调用会创建一个 Function 对象(匿名内部类),inline 消除这个开销
- reified 泛型:只有 inline 函数才能用 reified,在运行时获取泛型类型
- 非局部返回:inline lambda 中可以 return 直接从外层函数返回
inline fun <reified T> isType(value: Any): Boolean = value is T
// 调用处编译后直接展开为:value is String
isType<String>("hello")noinline:标记不需要内联的 lambda(需要将 lambda 存储或传递给非内联函数时)。 crossinline:禁止 lambda 中的非局部返回(lambda 会在其他执行上下文中调用时)。
追问:什么时候不该用 inline?
- 函数体很大时:内联会导致调用处代码膨胀
- 没有 lambda 参数时:没有 Function 对象开销需要消除,inline 没有意义
- 递归函数:无法内联
5. Kotlin data class 需要注意什么?
考察点:Kotlin 语法细节
完整回答:
data class 编译器自动生成 equals/hashCode/toString/copy/componentN。
注意事项:
- 只基于主构造函数参数:body 中声明的属性不参与 equals/hashCode
data class User(val name: String) {
var age: Int = 0 // 不参与 equals/hashCode!
}
User("张三").apply { age = 20 } == User("张三").apply { age = 30 } // true- copy 是浅拷贝:引用类型属性共享同一对象
- 必须有至少一个主构造函数参数
- 不能是 abstract/open/sealed/inner
- 解构声明按顺序:
val (name, age) = user依赖 component1/component2 的顺序,如果属性顺序变了会出 bug
追问:data class 和普通 class 在 HashMap 中的表现?
data class 自动生成基于属性值的 equals/hashCode,所以两个属性相同的 data class 实例在 HashMap 中被视为同一个 key。普通 class 默认用 Object 的 equals(引用比较),两个属性相同但不同实例的对象是不同的 key。
6. Kotlin 的扩展函数是怎么实现的?有什么限制?
考察点:扩展函数原理
完整回答:
扩展函数编译后是一个静态方法,接收者对象作为第一个参数:
fun String.addStar() = "*$this*"
// 编译为:
public static String addStar(String $this) { return "*" + $this + "*"; }限制:
- 静态分发:根据声明类型而非运行时类型调用,不支持多态
- 成员函数优先:如果类有同名同参数的成员函数,成员函数优先
- 不能访问 private/protected 成员:扩展函数本质是外部静态方法
- 可以被遮蔽:子类和父类定义同名扩展函数时,调用哪个取决于变量的声明类型
加分点:扩展函数非常适合给第三方库的类添加工具方法,比如给 Context 添加 toast 扩展、给 View 添加 visible/gone 扩展,代码更简洁且不需要继承。
7. 协程的结构化并发是什么?为什么重要?
考察点:协程生命周期管理
完整回答:
结构化并发是指协程的生命周期被限定在一个作用域(CoroutineScope)内,形成父子层级关系:
- 父协程取消时,所有子协程自动取消
- 父协程会等待所有子协程完成后才完成
- 子协程的异常会传播到父协程
// viewModelScope 绑定 ViewModel 生命周期
// ViewModel 销毁时,所有协程自动取消,不会泄漏
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
val user = fetchUser() // ViewModel 销毁时自动取消
val posts = fetchPosts() // 不会泄漏
}
}
}为什么重要:
- 防止协程泄漏(类似内存泄漏)
- 不需要手动管理每个协程的取消
- 异常传播有明确的层级关系
追问:GlobalScope 为什么不推荐?
GlobalScope 的生命周期是整个应用进程,启动的协程不会自动取消,容易泄漏。而且 GlobalScope 没有父 Job,异常不会传播,难以管理。应该使用与组件生命周期绑定的 scope(viewModelScope、lifecycleScope)。
加分点:提到 coroutineScope {} 和 withContext() 都是结构化并发的体现——它们创建子作用域,等待内部所有协程完成后才返回。
8. Kotlin 的 object 关键字有哪些用法?
考察点:Kotlin 语法
完整回答:
三种用法:
- 对象声明(单例):
object AppConfig {
val baseUrl = "https://api.example.com"
}
// 编译为:static final INSTANCE + static 初始化块(线程安全的饿汉式单例)- 伴生对象(companion object):
class User {
companion object {
fun create(): User = User() // 类似 Java 的静态方法
}
}
User.create()- 匿名对象(对象表达式):
val listener = object : View.OnClickListener {
override fun onClick(v: View) { /* ... */ }
}
// 类似 Java 的匿名内部类,但可以实现多个接口追问:companion object 和 Java static 的区别?
companion object 本质是一个单例对象,不是真正的 static。Java 调用时需要 User.Companion.create()。加 @JvmStatic 注解才会生成真正的静态方法。
9. Kotlin 的协程取消是怎么工作的?
考察点:协程取消机制
完整回答:
协程取消是协作式的,不是强制中断。调用 job.cancel() 后:
- Job 的状态变为 Cancelling
- 在下一个挂起点(suspend 函数调用处)检查取消状态
- 如果已取消,抛出 CancellationException
- CancellationException 不会被当作异常传播(正常取消流程)
关键点:如果协程中没有挂起点(纯 CPU 计算),取消不会生效:
val job = launch {
var i = 0
while (i < 1000000) { // 没有挂起点,cancel 无效!
i++
}
}
job.cancel() // 不会立即取消
// ✅ 正确:检查 isActive
val job = launch {
var i = 0
while (isActive && i < 1000000) { // 检查取消状态
i++
}
}
// ✅ 或使用 ensureActive()
while (i < 1000000) {
ensureActive() // 已取消则抛 CancellationException
i++
}
// ✅ 或使用 yield()
while (i < 1000000) {
yield() // 让出执行权,同时检查取消
i++
}追问:取消后怎么做清理工作?
val job = launch {
try {
doWork()
} finally {
// 取消后执行清理
withContext(NonCancellable) {
// NonCancellable 确保即使已取消也能执行挂起函数
closeResource()
}
}
}10. Kotlin 的 val 和 var 的区别?val 是不是线程安全的?
考察点:Kotlin 基础
完整回答:
val:只读引用(类似 Java final),赋值后不能重新赋值var:可变引用,可以重新赋值
val 不等于不可变:
val list = mutableListOf(1, 2, 3)
list.add(4) // ✅ 可以修改内容!val 只是引用不可变
// list = mutableListOf() // ❌ 不能重新赋值val 不是线程安全的:
- 自定义 getter 每次可能返回不同值
- 引用的对象内部状态可能被其他线程修改
- 只有
val+ 不可变对象(如 String、data class)才是线程安全的
11. repeatOnLifecycle 和 flowWithLifecycle 是什么?为什么需要它们?
考察点:Flow 与生命周期
完整回答:
直接在 lifecycleScope.launch 中 collect Flow,即使 Activity 进入后台(onStop),collect 仍在继续,浪费资源甚至导致崩溃(如更新已销毁的 View)。
// ❌ 不安全:后台仍在收集
lifecycleScope.launch {
viewModel.uiState.collect { state -> updateUI(state) }
}
// ✅ 安全:只在 STARTED 以上状态收集
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state -> updateUI(state) }
}
}
// ✅ 简写(单个 Flow)
viewModel.uiState
.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED)
.onEach { state -> updateUI(state) }
.launchIn(lifecycleScope)repeatOnLifecycle 在生命周期进入目标状态时启动协程,离开时取消,再次进入时重新启动。
实习 Android 岗常把 Kotlin 当作基础能力考察,重点是空安全、常用语法和协程的正确使用方式。
12. Kotlin 的 ?、?.、?:、!! 分别是什么意思?
考察点:空安全
完整回答:
String?:表示变量可以为 null。?.:安全调用,左边为 null 时整体返回 null,不继续调用。?::Elvis 操作符,左边为 null 时返回右边的默认值。!!:非空断言,强制认为不为 null;如果实际为 null,会抛NullPointerException。
val name: String? = null
val length = name?.length ?: 0追问:开发中为什么要少用 !!?
!! 会绕开 Kotlin 的空安全检查,一旦数据为空就会崩溃。更推荐使用安全调用、默认值、提前 return 或明确的异常处理。
13. val 和 var 的区别?
考察点:变量声明、不可变思想
完整回答:
val声明只读引用,初始化后不能重新赋值。var声明可变变量,可以重新赋值。
val list = mutableListOf(1, 2)
list.add(3) // 可以,list 指向的对象内容可变
// list = mutableListOf(4) // 不可以,val 不能重新赋值加分点:优先使用 val 可以减少状态变化,降低代码理解和并发问题的风险。
14. data class 有什么作用?
考察点:Kotlin 常用类
完整回答:
data class 适合表示数据模型,编译器会自动生成:
equals()/hashCode()toString()copy()componentN()解构方法
data class User(val id: Long, val name: String)
val user = User(1, "Tom")
val newUser = user.copy(name = "Jerry")追问:data class 适合做什么?
适合接口响应、列表 item、页面 UI 状态等数据承载对象。不适合承载复杂业务行为或需要继承体系的对象。
计算机网络 / 网络协议基础
1. 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 固定在客户端,防止中间人攻击。
2. 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 握手时协商协议版本。
3. TCP 和 UDP 的区别?
考察点:网络基础
完整回答:
| 维度 | TCP | UDP |
|---|---|---|
| 连接 | 面向连接(三次握手) | 无连接 |
| 可靠性 | 可靠(确认、重传、排序) | 不可靠(可能丢包、乱序) |
| 传输方式 | 字节流 | 数据报 |
| 速度 | 较慢(握手+确认开销) | 快 |
| 头部 | 20 字节 | 8 字节 |
| 拥塞控制 | 有(慢启动、拥塞避免) | 无 |
| 适用场景 | HTTP、文件传输 | 视频直播、DNS、游戏 |
追问:TCP 怎么保证可靠传输?
- 序列号 + 确认号:每个字节编号,接收方确认
- 超时重传:未收到 ACK 则重发
- 滑动窗口:流量控制,接收方告知可接收的数据量
- 拥塞控制:慢启动 → 拥塞避免 → 快重传 → 快恢复
4. GET 和 POST 有什么区别?
考察点:HTTP 基础
完整回答:
- GET 通常用于获取资源,参数常放在 URL query 中。
- POST 通常用于提交数据,参数常放在请求体中。
- GET 请求更容易被缓存和记录,POST 更适合提交表单、登录、创建资源等场景。
需要注意:GET 和 POST 的语义由 HTTP 规范定义,安全性不能只靠方法区分。敏感数据应该使用 HTTPS,并避免把 token、密码放在 URL 中。
追问:GET 一定没有请求体吗?
规范没有绝对禁止,但实际开发中不推荐给 GET 放请求体,因为很多服务器、代理和客户端不会按预期处理。
5. 常见 HTTP 状态码有哪些?
考察点:接口调试能力
完整回答:
200:请求成功。201:创建成功。301/302:重定向。400:请求参数错误。401:未认证或登录失效。403:无权限。404:资源不存在。500:服务端内部错误。502/503:网关或服务暂不可用。
加分点:移动端排查接口问题时,要同时看状态码、业务 code、错误信息、请求参数、响应体和服务端日志。
Android / 四大组件与生命周期
1. Activity 的生命周期?A 启动 B 时生命周期调用顺序?
考察点:生命周期理解深度
完整回答:
正常生命周期:onCreate → onStart → onResume → onPause → onStop → onDestroy。
A 启动 B:A.onPause → B.onCreate → B.onStart → B.onResume → A.onStop
B 返回 A:B.onPause → A.onRestart → A.onStart → A.onResume → B.onStop → B.onDestroy
关键点:A.onPause 先于 B.onCreate 执行,所以 onPause 中不能做耗时操作,否则会延迟 B 的显示。
追问:如果 B 是透明 Activity 或 Dialog 主题?
A 不会调用 onStop(因为 A 仍然部分可见),只调用 onPause。B 关闭后 A 直接调用 onResume。
追问:配置变更(旋转屏幕)时的生命周期?
onPause → onStop → onSaveInstanceState → onDestroy → onCreate(bundle) → onStart → onRestoreInstanceState → onResume
Activity 被销毁重建。可以通过 android:configChanges="orientation|screenSize" 阻止重建,此时只回调 onConfigurationChanged。
加分点:提到 ViewModel 通过 onRetainNonConfigurationInstance 在配置变更时保留,不受销毁重建影响。
2. Activity 的四种启动模式?
考察点:Task 和启动模式
完整回答:
- standard:默认模式,每次启动创建新实例
- singleTop:栈顶复用。已在栈顶则调用 onNewIntent,不在栈顶则新建
- singleTask:栈内复用。在目标 Task 中查找,存在则 clearTop 并调用 onNewIntent
- singleInstance:独占一个 Task,系统中只有一个实例
使用场景:
- singleTop:通知点击打开的页面(避免重复创建)、搜索页面
- singleTask:App 首页(从任何地方回到首页清除中间页面)
- singleInstance:来电界面、系统级页面
追问:singleTask 一定会在新 Task 中吗?
不一定。singleTask 首先查找 taskAffinity 匹配的 Task,如果已存在则在该 Task 中复用。默认 taskAffinity 是包名,所以默认情况下 singleTask 的 Activity 会在应用的主 Task 中。只有指定了不同的 taskAffinity 才会创建新 Task。
追问:FLAG_ACTIVITY_CLEAR_TOP 和 singleTask 的区别?
CLEAR_TOP 只是清除目标 Activity 上方的 Activity。如果目标是 standard 模式,会先销毁再重建(不调用 onNewIntent)。singleTask 则是复用已有实例(调用 onNewIntent)。CLEAR_TOP + SINGLE_TOP 的组合效果等同于 singleTask。
3. Fragment 的生命周期?为什么要用 viewLifecycleOwner?
考察点:Fragment 双生命周期
完整回答:
Fragment 生命周期:onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume → onPause → onStop → onDestroyView → onDestroy → onDetach。
Fragment 有两个生命周期:
- Fragment 实例生命周期(onCreate → onDestroy)
- View 生命周期(onCreateView → onDestroyView)
当 Fragment 加入 back stack 后按返回,View 被销毁(onDestroyView)但 Fragment 实例还在。再次显示时重新走 onCreateView。
如果在 Fragment 中用 this 作为 LifecycleOwner 观察 LiveData,Fragment 返回 back stack 时 Observer 不会被移除(Fragment 没有 destroy)。再次显示时又注册新的 Observer,导致重复观察。
用 viewLifecycleOwner 则 Observer 跟随 View 生命周期,onDestroyView 时自动移除。
追问:Fragment 之间怎么通信?
推荐方案:
- 共享 ViewModel:
by activityViewModels()获取 Activity 级别的 ViewModel - Fragment Result API:
setFragmentResult/setFragmentResultListener - Navigation 的 SavedStateHandle
不推荐:直接引用其他 Fragment、接口回调(耦合度高)。
4. Service 的两种启动方式?区别是什么?
考察点:Service 生命周期
完整回答:
startService:
- 生命周期:onCreate → onStartCommand → 运行 → onDestroy
- 调用者与 Service 无绑定关系,调用者销毁 Service 继续运行
- 需要主动调用 stopSelf() 或 stopService() 停止
- onStartCommand 返回值决定被杀后重启策略
bindService:
- 生命周期:onCreate → onBind → 运行 → onUnbind → onDestroy
- 返回 IBinder 供客户端通信
- 所有客户端解绑后自动销毁
- 适合需要与 Service 交互的场景
可以同时 start + bind,此时需要同时 stop + unbind 才会销毁。
追问:Android 8.0+ 对后台 Service 有什么限制?
应用进入后台后约 1 分钟,系统会停止后台 Service。解决方案:
- 前台 Service:
startForeground()显示通知 - WorkManager:适合可延迟的后台任务
- JobScheduler:系统调度的任务
Android 12+ 前台 Service 需要声明 foregroundServiceType,Android 14+ 必须指定具体类型。
5. ContentProvider 的启动时序?为什么很多库用它做初始化?
考察点:ContentProvider 原理
完整回答:
启动时序:Application.attachBaseContext → ContentProvider.onCreate → Application.onCreate
ContentProvider 在 Application.onCreate 之前初始化,且系统会自动实例化 Manifest 中声明的所有 ContentProvider。很多库利用这个特性做自动初始化(如 Firebase、LeakCanary、WorkManager),用户不需要手动在 Application 中调用 init 方法。
缺点:每个 ContentProvider 都会拖慢启动速度(约 2-5ms)。如果多个库都用这种方式,累积影响明显。
Jetpack App Startup 库的解决方案:提供一个统一的 ContentProvider(InitializationProvider),所有库的初始化逻辑注册为 Initializer,合并在一个 ContentProvider 中执行,减少开销。
追问:ContentProvider 的跨进程通信原理?
底层通过 Binder。客户端通过 ContentResolver 发起请求,AMS 查找目标 Provider 的 Binder 引用并返回给客户端。后续客户端直接通过 Binder 与 Provider 通信。大数据传输通过 ParcelFileDescriptor 传递文件描述符,绕过 Binder 1MB 大小限制。
6. Context 的继承体系?Application Context 和 Activity Context 有什么区别?
考察点:Context 体系
完整回答:
继承关系:
Context(抽象类)
├── ContextImpl(真正实现)
└── ContextWrapper(装饰器)
├── Application
├── Service
└── ContextThemeWrapper
└── Activity区别:
- Activity Context 包含主题信息(继承 ContextThemeWrapper),可以启动 Activity、弹 Dialog
- Application/Service Context 没有主题,不能直接启动 Activity(需要 FLAG_ACTIVITY_NEW_TASK),不能弹 Dialog
使用原则:
- UI 相关(Dialog、Toast、inflate 布局):用 Activity Context
- 生命周期长的对象(单例、全局缓存):用 Application Context,避免持有 Activity 导致内存泄漏
追问:为什么不能用 Application Context 弹 Dialog?
Dialog 需要依附于一个 Window,而 Window 需要 WindowManager.LayoutParams 中的 token。Activity 有自己的 Window 和 token,Application Context 没有。用 Application Context 弹 Dialog 会抛 BadTokenException。
加分点:提到 getApplicationContext() 返回的是 Application 对象,getBaseContext() 返回的是 ContextImpl。ContextWrapper 的所有方法都委托给 mBase(ContextImpl)。
7. onSaveInstanceState 和 ViewModel 的区别?什么时候用哪个?
考察点:状态保存机制
完整回答:
| 维度 | onSaveInstanceState | ViewModel |
|---|---|---|
| 存储位置 | Bundle(序列化到磁盘) | 内存 |
| 大小限制 | ~1MB(Bundle 限制) | 无限制(受堆内存限制) |
| 配置变更 | ✅ 保留 | ✅ 保留 |
| 进程被杀 | ✅ 保留 | ❌ 丢失 |
| 数据类型 | 可序列化的简单数据 | 任意对象 |
选择:
- 轻量关键数据(搜索关键词、列表位置、页码)→ onSaveInstanceState 或 SavedStateHandle
- 大量数据(列表数据、网络响应)→ ViewModel
- 需要进程恢复的关键数据 → SavedStateHandle(ViewModel 中使用 Bundle)
追问:onSaveInstanceState 什么时候调用?
Android 9(API 28)之前:在 onStop 之前调用。 Android 9+ 之后:在 onStop 之后调用。 只在系统可能销毁 Activity 时调用(如旋转屏幕、按 Home 键、切换 App),用户主动按返回键不会调用。
8. Activity 的 taskAffinity 是什么?怎么影响启动模式?
考察点:Task 管理
完整回答:
taskAffinity 是 Activity 的"亲和力"属性,决定 Activity 倾向于属于哪个 Task。默认值是应用包名。
影响:
- singleTask:先查找 taskAffinity 匹配的 Task,找到则在该 Task 中复用/创建 Activity。不同 taskAffinity 会创建新 Task。
- FLAG_ACTIVITY_NEW_TASK:如果目标 Activity 的 taskAffinity 与当前 Task 不同,会在匹配的 Task 中启动(或创建新 Task)。
- allowTaskReparenting:当 Activity 的 taskAffinity 对应的 Task 来到前台时,Activity 会从当前 Task 移到该 Task。
<activity
android:name=".DetailActivity"
android:taskAffinity="com.example.detail"
android:launchMode="singleTask" />9. Android 的权限机制?运行时权限怎么处理?
考察点:权限系统
完整回答:
Android 6.0+ 引入运行时权限,危险权限需要在运行时请求:
// 推荐:Activity Result API
val launcher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
val cameraGranted = permissions[Manifest.permission.CAMERA] ?: false
val locationGranted = permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false
if (cameraGranted && locationGranted) {
// 权限已授予
} else {
// 处理拒绝
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
// 用户选择了"不再询问",引导去设置页
}
}
}
// 请求
launcher.launch(arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.ACCESS_FINE_LOCATION
))权限最佳实践:
- 最小权限原则:只请求必要的权限
- 在使用功能时才请求(不要启动时一次性请求所有权限)
- 被拒绝后解释为什么需要(shouldShowRequestPermissionRationale)
- 用户选择"不再询问"后引导去设置页
10. IntentFilter 的匹配规则?隐式启动 Activity 的过程?
考察点:Intent 解析
完整回答:
隐式 Intent 需要同时匹配 action、category 和 data 三个条件:
- action:Intent 的 action 必须与 IntentFilter 中的某一个 action 匹配
- category:Intent 的所有 category 都必须在 IntentFilter 中存在。系统会自动添加
CATEGORY_DEFAULT,所以 IntentFilter 必须包含它 - data:匹配 URI(scheme://host:port/path)和 mimeType
<intent-filter>
<action android:name="com.example.SHOW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="https" android:host="example.com" />
</intent-filter>追问:startActivity 找不到匹配的 Activity 会怎样?
抛 ActivityNotFoundException。安全做法是先用 intent.resolveActivity(packageManager) 检查,或用 try-catch 包裹。
Android 11+ 包可见性限制:需要在 Manifest 中声明 <queries> 才能查询其他应用的 Activity。
实习面试更看重组件“会不会用、生命周期会不会踩坑”,不一定要求一开始就讲 AMS 源码。
11. Intent 显式启动和隐式启动有什么区别?
考察点:Intent 基础
完整回答:
- 显式 Intent:明确指定目标组件,通常用于应用内部页面跳转。
- 隐式 Intent:不指定具体组件,而是通过 action、category、data 让系统匹配能处理的组件,常用于打开浏览器、相机、分享等跨应用场景。
// 显式启动
startActivity(Intent(this, DetailActivity::class.java))
// 隐式启动
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://www.example.com"))
startActivity(intent)追问:隐式启动有什么风险?
可能没有应用能处理,导致 ActivityNotFoundException。启动前可以使用 resolveActivity(packageManager) 判断,或用 try-catch 兜底。
12. Activity 和 Fragment 分别适合承担什么职责?
考察点:页面组织、职责划分
完整回答:
Activity 通常作为页面容器,负责承载 Fragment、处理系统入口、权限、导航和生命周期协调。Fragment 更适合作为可复用的 UI 模块,负责具体页面内容和交互。
常见实践是单 Activity + 多 Fragment,配合 Navigation 管理页面跳转;也可以多 Activity,但不要把所有业务逻辑都堆在 Activity 中。
加分点:Fragment 有 View 生命周期,访问 ViewBinding 时要在 onDestroyView 置空,观察 LiveData/Flow 时优先绑定 viewLifecycleOwner。
13. Service 是不是运行在子线程?
考察点:Service 基础误区
完整回答:
不是。普通 Service 默认运行在主线程,只是没有界面,并不代表自动在后台线程执行。如果在 Service 中做耗时任务,仍然需要自己切到子线程,否则也可能造成 ANR。
如果是需要长期运行且用户可感知的任务,应使用前台服务并展示通知;如果是可延迟的后台任务,可以考虑 WorkManager。
追问:IntentService 现在还推荐吗?
IntentService 已废弃。现在更推荐根据场景使用 WorkManager、协程、线程池或前台服务。
14. Application类的作用是什么?
Application 类的设计初衷就是保存全局状态,并执行应用范围内的初始化操作。开发者通常会继承这个类,用以设置依赖项、配置第三方库,以及管理那些需要在多个 Activity 和 Service 之间持续存在的资源。
默认情况下,每个 Android 应用都会使用系统提供的 Application 基类实现,除非你在 AndroidManifest.xml 文件中明确指定了一个自定义的子类。
15. 如果一个 Activity 类未在 AndroidManifest 中注册会发生什么?
如果 Activity 没有在 AndroidManifest 中注册,系统不会把它识别为合法组件。通过 Intent 启动它时会失败,并抛出 ActivityNotFoundException,常见提示是 have you declared this activity in your AndroidManifest.xml?。
因为 Activity 的创建和生命周期由系统管理,系统需要通过 Manifest 获取组件信息,比如类名、启动模式、主题、exported、intent-filter 等。普通类可以不注册,但 Activity 作为四大组件之一必须声明。
16. onPause()和onStop()有什么区别
onPause()和onStop()都表示Avtivity不再处于前台状态,区别在于:
onPause()表示 Activity 失去焦点,但可能仍然可见onStop()表示 Activity 已经完全不可见。
17. 屏幕旋转一定会使activity重建吗
不会,可以通过更改manifest的属性进行更改,并且在activity中增加onConfigurationChanged,但是需要手动配置横屏后的组件ui,不方便,不如使用vm
18. 如何避免Activity内存泄漏
- 静态变量持有Activity引用
单例类或静态变量直接或间接持有Activity的Context
object Singleton {var context: Context? = null // 错误:可能持有Activity的引用}解决方案
把 Activity Context用Application Context来代替
如果实在要引用的话就用弱引用(WeakReference)
class Singleton {
private var activityRef: WeakReference<Activity>? = null
fun setActivity(activity: Activity) {
activityRef = WeakReference(activity)
}
}- 非静态内部类+匿名类
比如Handler、Runnable等内部类隐式持有Activity引用。
解决:静态内部类+弱引用,在onDestroy()移除回调
- 未正确注销监听器或者回调
场景:注册广播,事件总线,监听器没有及时注销
override fun onCreate(savedInstanceState: Bundle?){
super onCreate(savedInstanceState)
LocalBroadcastManager.getInstance(this).registerReceiver(receiver,intentFilter)
}解决方案:在onDestory()中进行反注册
override fun onDestory(){
LocalBroadcastManager.getInstance(this).unregisterReceiver(reciver)
super onDestory
}- 异步任务没有随Activity销毁终止(AsyncTask、Rxjava、Coroutine等)
解决方法:使用lifecycleScope
- 资源未释放
在onDestroy()释放资源即可。
19. onCreateView() 和 onDestroyView() 的作用是什么?
onCreateView() 创建 View,onDestroyView() 清理 View;Fragment 可能还活着,但它的 View 可以被反复创建和销毁。
20. 在一个Activity中启动Fragment,他们的生命周期顺序
Activity.onCreate()->Fragment.onAttach()->Fragment.onCreate()->Activity.onStart()->Fragment.onCreateView()->Fragment.onViewCreated->Fragment.onStart()->Activity.onResume()->Fragment.onResume()
21. Activity生命周期与fragment生命周期的区别
- onAttach(Context context)当Fragment与Activity关联的时候调用(可通过getActivity()来获得宿主Activity)常用来获得Activity的依赖(如回调)
- onCreateView()在创建Fragment视图层次结构时调用,通过LayoutInflater解析布局。
- onViewCreated()在onCreateView()创建完视图后调用,用来进行视图的初始化,比如findViewById或者RV适配器的设置
- onActivityCreated()(已经废弃)在AndroidX中,此方法已经被废弃,可以选择在onCraeteView(),onViewCreated()中监听生命周期
- onDestoryVew()当Fragment被销毁时调用,如Fragment替换或者移除,用于清除与视图相关的资源
- onDetach()当Fragment与Activity解除关联的时候调用,释放对Activity的引用,避免内存泄露
Android / UI 绘制与事件分发
1. View 的绘制流程?measure、layout、draw 各做什么?
考察点:View 绘制原理
完整回答:
绘制从 ViewRootImpl.performTraversals() 开始,依次执行三大流程:
measure:确定 View 的大小。ViewGroup 先测量子 View,再根据子 View 大小确定自身大小。核心是 MeasureSpec(高2位模式+低30位大小),三种模式:EXACTLY(精确值)、AT_MOST(最大值,wrap_content)、UNSPECIFIED(无限制)。
layout:确定 View 的位置。ViewGroup 在 onLayout 中调用每个子 View 的 layout(l, t, r, b) 确定其四个顶点坐标。
draw:绘制 View 内容。顺序是:背景 → onDraw(自身内容)→ dispatchDraw(子 View)→ 前景。
追问:自定义 View 中 wrap_content 不处理会怎样?
等同于 match_parent。因为父 EXACTLY + 子 wrap_content 得到 AT_MOST + 父大小,如果 onMeasure 不处理 AT_MOST 直接用 specSize,就是父容器大小。正确做法是在 AT_MOST 模式下计算内容所需大小,取 min(内容大小, specSize)。
追问:requestLayout 和 invalidate 的区别?
requestLayout 强制触发 measure + layout。如果布局发生变化,layout 内部会调用 invalidate 标记脏区域,连带触发 draw。invalidate 只触发 draw,用于内容变化(如颜色、文字)。invalidate 不会重新测量和布局。
加分点:提到 ViewRootImpl 通过 Choreographer 在下一个 VSYNC 信号到来时执行 performTraversals,保证 16ms 一帧。
2. 事件分发机制?从手指触摸到 View 响应的完整流程?
考察点:事件分发源码理解
完整回答:
事件从硬件到应用的完整链路:
- 触摸屏产生中断 → InputManagerService → InputDispatcher
- 通过 Socket 发送到应用进程的 InputChannel
- ViewRootImpl 的 WindowInputEventReceiver 接收
- 进入 View 树的分发流程
View 树分发流程:
- Activity.dispatchTouchEvent → PhoneWindow → DecorView → ViewGroup.dispatchTouchEvent
- ViewGroup 先调用 onInterceptTouchEvent 判断是否拦截
- 不拦截则倒序遍历子 View(后添加的先收到),调用 child.dispatchTouchEvent
- 子 View 的 dispatchTouchEvent 中:先调 OnTouchListener.onTouch,返回 false 才调 onTouchEvent
- onTouchEvent 的 ACTION_UP 中触发 OnClickListener.onClick
关键规则:
- DOWN 事件确定事件接收者(mFirstTouchTarget),后续 MOVE/UP 直接发给它
- 子 View 可以调用
requestDisallowInterceptTouchEvent(true)阻止父 ViewGroup 拦截 - 如果所有子 View 都不消费,事件回传给 ViewGroup 自己的 onTouchEvent
追问:onTouchListener、onTouchEvent、onClickListener 的优先级?
onTouchListener.onTouch > onTouchEvent > onClickListener.onClick。onTouch 返回 true 则 onTouchEvent 不调用,onClick 也不会触发。
3. 滑动冲突怎么解决?
考察点:事件分发实际应用
完整回答:
两种解决方案:
外部拦截法(推荐):在父 ViewGroup 的 onInterceptTouchEvent 中判断:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case ACTION_DOWN:
return false; // DOWN 不拦截,否则子 View 收不到事件
case ACTION_MOVE:
if (needIntercept(ev)) return true; // 满足条件拦截
return false;
case ACTION_UP:
return false; // UP 不拦截,否则子 View 的 click 不触发
}
}内部拦截法:子 View 通过 requestDisallowInterceptTouchEvent 控制:
// 子 View
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case ACTION_DOWN:
parent.requestDisallowInterceptTouchEvent(true); // 禁止父拦截
break;
case ACTION_MOVE:
if (parentNeedEvent(ev)) {
parent.requestDisallowInterceptTouchEvent(false); // 允许父拦截
}
break;
}
return super.dispatchTouchEvent(ev);
}常见场景:
- ViewPager + ListView:水平滑动给 ViewPager,垂直滑动给 ListView
- ScrollView 嵌套 RecyclerView:根据滑动方向和子 View 是否到顶/底判断
4. 自定义 View 的完整流程?需要注意什么?
考察点:自定义 View 实践能力
完整回答:
- 继承:简单绘制继承 View,需要布局子 View 继承 ViewGroup
- 构造函数:至少实现两个构造函数(代码创建 + XML 解析),处理自定义属性
- onMeasure:处理 wrap_content(AT_MOST 模式),调用 setMeasuredDimension
- onLayout(ViewGroup):确定子 View 位置
- onDraw:使用 Canvas 绑制内容
- 处理 padding:onDraw 中考虑 padding,ViewGroup 的 onLayout 中考虑 padding
- 处理触摸事件:重写 onTouchEvent
注意事项:
- onDraw 中不要创建对象(Paint 等在构造函数中创建)
- 不要在 onDraw 中做耗时操作
- 处理好 wrap_content,否则等同于 match_parent
- 如果有动画,用 invalidate() 触发重绘
- 考虑 View 的状态保存与恢复(onSaveInstanceState/onRestoreInstanceState)
加分点:提到自定义 ViewGroup 时需要处理子 View 的 margin(通过 generateLayoutParams 返回 MarginLayoutParams),以及 clipChildren/clipToPadding 的影响。
5. getWidth() 和 getMeasuredWidth() 的区别?
考察点:View 测量与布局
完整回答:
getMeasuredWidth():在 measure 阶段确定,是 View 期望的宽度getWidth():在 layout 阶段确定,是 View 实际的宽度(right - left)
通常两者相等,但在 onLayout 中可以让实际宽度与测量宽度不同:
// 强制让子 View 的实际宽度是测量宽度的一半
child.layout(0, 0, child.getMeasuredWidth() / 2, child.getMeasuredHeight());
// 此时 getWidth() = getMeasuredWidth() / 2追问:在 onCreate 中获取 View 的宽高为什么是 0?怎么解决?
onCreate 时 View 还没有经过 measure 和 layout。解决方案:
// 方案1:View.post(在 View 的消息队列中执行,此时已完成布局)
view.post { val width = view.width }
// 方案2:ViewTreeObserver
view.viewTreeObserver.addOnGlobalLayoutListener(object : OnGlobalLayoutListener {
override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val width = view.width
}
})
// 方案3:重写 onWindowFocusChanged(Activity 获得焦点时 View 已布局)
override fun onWindowFocusChanged(hasFocus: Boolean) {
if (hasFocus) { val width = view.width }
}6. View.post(Runnable) 的原理?
考察点:View 消息机制
完整回答:
如果 View 已经 attach 到 Window(有 ViewRootImpl),post 直接通过 Handler 发送到主线程消息队列。
如果 View 还没有 attach(如 onCreate 中),Runnable 会被暂存到 View.mRunQueue 中,等到 dispatchAttachedToWindow 时再通过 Handler 发送。
这就是为什么 view.post { view.width } 能获取到正确宽高——Runnable 在 performTraversals 之后执行。
7. Choreographer 是什么?VSYNC 信号是什么?
考察点:渲染机制
完整回答:
VSYNC(Vertical Synchronization)是显示器发出的垂直同步信号,60Hz 屏幕每 16.6ms 发一次。
Choreographer(编舞者)是 Android 渲染的调度器,在 VSYNC 信号到来时依次执行:
- Input 事件处理
- 动画计算
- View 的 measure/layout/draw(performTraversals)
VSYNC ──→ Choreographer.doFrame()
├── CALLBACK_INPUT(输入事件)
├── CALLBACK_ANIMATION(动画)
├── CALLBACK_TRAVERSAL(View 绘制)
└── CALLBACK_COMMIT(提交)requestLayout() 和 invalidate() 最终都是向 Choreographer 注册回调,等待下一个 VSYNC 信号触发执行。这保证了所有 UI 更新都在 VSYNC 节奏上,避免画面撕裂。
实习面试经常从“你项目里的列表怎么写”开始问,再追到事件分发、刷新方式和卡顿原因。
8. match_parent、wrap_content 和 dp 有什么区别?
考察点:布局基础
完整回答:
match_parent:尺寸尽量填满父容器允许的空间。wrap_content:尺寸根据自身内容决定。dp:密度无关像素,用于适配不同屏幕密度。
文字大小通常使用 sp,因为 sp 会跟随用户字体大小设置变化;普通布局尺寸一般使用 dp。
加分点:移动端布局要避免写死过多绝对尺寸,可以结合 ConstraintLayout、权重、约束和自适应资源。
9. 点击事件不响应可能有哪些原因?
考察点:事件分发排查
完整回答:
常见原因包括:
- View 没有设置点击监听,或
clickable状态不正确。 - 父 View 拦截了事件,比如外层 ScrollView、RecyclerView。
- 有其他 View 覆盖在目标 View 上方。
- View 的宽高为 0,或者实际点击区域不在可见区域内。
- 子 View 消费了事件,父 View 收不到点击。
排查时可以先确认布局层级和点击区域,再通过日志观察 dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent 的返回值。
Android / RecyclerView
1. RecyclerView 的缓存机制?四级缓存分别是什么?
考察点:RecyclerView 性能原理
完整回答:
RecyclerView 有四级缓存:
mAttachedScrap / mChangedScrap:屏幕内的 ViewHolder 缓存。layout 期间临时存放,layout 结束后复用。不需要重新绑定数据。
mCachedViews:刚滑出屏幕的 ViewHolder,默认容量 2。按 position 精确匹配,命中后不需要 onBindViewHolder。
ViewCacheExtension:用户自定义缓存,很少使用。
RecycledViewPool:按 viewType 分类存储,默认每种类型最多 5 个。命中后需要重新 onBindViewHolder。多个 RecyclerView 可以共享同一个 Pool。
复用流程:
tryGetViewHolderForPositionByDeadline()
→ 1. mAttachedScrap(position 匹配)
→ 2. mCachedViews(position 匹配)
→ 3. ViewCacheExtension
→ 4. RecycledViewPool(viewType 匹配)
→ 5. 都没命中 → onCreateViewHolder 创建新的追问:mCachedViews 和 RecycledViewPool 的区别?
mCachedViews 按 position 匹配,命中后直接复用不需要 bind,性能最好。RecycledViewPool 按 viewType 匹配,命中后需要重新 bind。mCachedViews 满了之后,最老的 ViewHolder 会被移到 RecycledViewPool。
追问:如何优化 RecyclerView 性能?
setHasFixedSize(true):item 大小固定时避免 requestLayoutDiffUtil:精确计算差异,避免 notifyDataSetChangedsetItemViewCacheSize:增大 mCachedViews 容量- 共享 RecycledViewPool:多个 RecyclerView 展示相同类型 item 时
setRecycledViewPool预创建 ViewHolder- 减少 onBindViewHolder 中的耗时操作
- 使用
ConcatAdapter替代多 viewType
2. DiffUtil 的原理?和 notifyDataSetChanged 有什么区别?
考察点:列表更新优化
完整回答:
notifyDataSetChanged 会刷新所有可见 item,触发所有 ViewHolder 的 rebind,且没有动画效果。
DiffUtil 使用 Eugene Myers 差分算法,计算新旧列表的最小编辑距离,只更新变化的 item:
- 移动、插入、删除有对应的动画
- 未变化的 item 不会 rebind
使用方式:
class MyDiffCallback(
private val oldList: List<Item>,
private val newList: List<Item>
) : DiffUtil.Callback() {
override fun getOldListSize() = oldList.size
override fun getNewListSize() = newList.size
override fun areItemsTheSame(oldPos: Int, newPos: Int) =
oldList[oldPos].id == newList[newPos].id // 是否同一个 item
override fun areContentsTheSame(oldPos: Int, newPos: Int) =
oldList[oldPos] == newList[newPos] // 内容是否相同
}
val diff = DiffUtil.calculateDiff(callback)
diff.dispatchUpdatesTo(adapter)推荐使用 ListAdapter(内置 AsyncListDiffer),自动在后台线程计算 diff:
class MyAdapter : ListAdapter<Item, ViewHolder>(ItemDiffCallback()) {
// submitList(newList) 自动计算 diff 并更新
}追问:DiffUtil 的时间复杂度?
O(N + D²),N 是新旧列表总长度,D 是编辑距离。列表很大且变化很多时可能耗时,所以 AsyncListDiffer 在后台线程计算。
3. RecyclerView 和 ListView 的区别?
考察点:列表控件对比
完整回答:
| 维度 | RecyclerView | ListView |
|---|---|---|
| ViewHolder | 强制使用(内置复用机制) | 可选(需手动实现) |
| 布局方式 | LayoutManager(线性/网格/瀑布流) | 只有垂直列表 |
| 动画 | ItemAnimator 内置动画支持 | 无内置动画 |
| 分割线 | ItemDecoration(灵活) | divider 属性(简单) |
| 点击事件 | 无内置,需自己实现 | setOnItemClickListener |
| 局部刷新 | notifyItemChanged/Inserted/Removed | 只有 notifyDataSetChanged |
| 缓存 | 四级缓存 | 两级缓存(ActiveView + ScrapView) |
| 嵌套滚动 | 支持 NestedScrolling | 不支持 |
追问:RecyclerView 的 notifyDataSetChanged 和 notifyItemChanged 的区别?
notifyDataSetChanged:所有 ViewHolder 失效,全部重新绑定,无动画notifyItemChanged(pos):只更新指定位置,有动画- 推荐用 DiffUtil / ListAdapter 自动计算差异
4. RecyclerView 写一个列表需要哪些核心角色?
考察点:RecyclerView 基本使用
完整回答:
RecyclerView 的基本角色包括:
RecyclerView:列表容器。Adapter:负责创建和绑定 item。ViewHolder:缓存 item 中的 View 引用,减少重复查找。LayoutManager:决定列表布局方式,比如线性、网格、瀑布流。
最常见流程是:准备数据列表 → 编写 item 布局 → 创建 Adapter/ViewHolder → 设置 LayoutManager 和 Adapter。
追问:为什么要用 ViewHolder?
列表滚动时 item 会频繁复用。ViewHolder 可以缓存 item 内部 View 的引用,避免反复 findViewById,减少开销。
Android / Handler
1. Handler 机制的原理?为什么主线程 Looper.loop() 不会 ANR?
考察点:消息机制
完整回答:
Handler 机制由四部分组成:
- Handler:发送和处理消息
- Message:消息载体
- MessageQueue:按时间排序的消息队列(单链表)
- Looper:死循环从 MessageQueue 取消息并分发
流程:Handler.sendMessage → MessageQueue.enqueueMessage(按 when 插入链表)→ Looper.loop 中 queue.next() 取出消息 → Handler.dispatchMessage 处理
为什么 loop() 不会 ANR?
ANR 是指应用在规定时间内没有处理完系统事件(如触摸事件 5 秒)。Looper.loop() 的死循环恰恰是在处理这些事件——触摸事件、Activity 生命周期回调、UI 绘制都是通过 Handler 消息驱动的。
当没有消息时,MessageQueue.next() 内部调用 nativePollOnce(底层 epoll_wait)阻塞,线程休眠不消耗 CPU。有新消息时通过 eventfd 唤醒。
ANR 发生在某个消息处理时间过长,导致后续的输入事件消息无法及时处理。
追问:同步屏障是什么?
同步屏障是 target 为 null 的特殊 Message。插入后,MessageQueue.next() 会跳过所有同步消息,优先处理异步消息。ViewRootImpl 在 scheduleTraversals 时使用同步屏障,确保 UI 绘制消息优先执行。
追问:IdleHandler 是什么?
MessageQueue 空闲时(无消息或下一条消息还没到时间)执行的回调。适合做延迟初始化、GC 等不紧急的任务。返回 false 执行一次后移除,返回 true 保持。
2. Handler 怎么实现延迟消息的?postDelayed 的原理?
考察点:Handler 细节
完整回答:
postDelayed(runnable, delayMillis) 内部调用 sendMessageDelayed,将 delay 转换为绝对时间 when = SystemClock.uptimeMillis() + delayMillis,然后插入 MessageQueue。
MessageQueue 是按 when 排序的单链表。next() 方法取出头部消息时,如果 now < msg.when,计算需要等待的时间,传给 nativePollOnce(timeout) 让线程休眠指定时间。
时间到后 epoll_wait 返回,取出消息执行。如果在等待期间有新消息插入且 when 更早,enqueueMessage 会调用 nativeWake 唤醒线程重新计算等待时间。
追问:postDelayed 准确吗?
不完全准确。如果主线程正在处理一个耗时消息,延迟消息即使到了时间也要等当前消息处理完。所以实际延迟 ≥ 指定延迟。对于精确定时需求,应该用 Choreographer 或 ValueAnimator。
加分点:提到 SystemClock.uptimeMillis() 不包含深度睡眠时间,而 System.currentTimeMillis() 可能被用户修改。Handler 使用 uptimeMillis 更可靠。
3. HandlerThread 和 IntentService 是什么?
考察点:线程工具
完整回答:
HandlerThread:自带 Looper 的线程,可以在子线程中使用 Handler 处理消息:
val handlerThread = HandlerThread("worker")
handlerThread.start()
val handler = Handler(handlerThread.looper)
handler.post { /* 在子线程执行 */ }适用场景:需要在子线程中串行处理任务(如数据库操作、文件 IO)。
IntentService(已废弃,推荐 WorkManager):
- 内部使用 HandlerThread
- 每次 startService 的 Intent 在子线程中串行处理
- 所有 Intent 处理完后自动 stopSelf
4. Handler、Looper、MessageQueue 分别是什么?
考察点:消息机制主线
完整回答:
MessageQueue:消息队列,保存待处理的 Message/Runnable。Looper:消息循环,不断从 MessageQueue 中取消息。Handler:发送消息,并在对应线程处理消息。
主线程启动时系统已经创建了 Looper,所以可以直接使用主线程 Handler。普通子线程默认没有 Looper,如果要使用 Handler,需要手动创建消息循环。
追问:Handler 内存泄漏怎么产生?
非静态内部类 Handler 会隐式持有外部 Activity。如果消息队列中还有延迟消息,Activity 退出后仍可能被 Handler 持有,导致泄漏。可以使用静态内部类、弱引用,或在销毁时移除消息。
5. Handler为什么会发生内存泄漏
- 在使用非静态内部类时就容易导致内存泄漏,原因在于非静态内部类默认持有外部类的引用
- 泄漏时机在于发送了一条延时Message,但宿主比如Activity已经回调了
onDestroy(),此时却因为Message还没有执行发生内存泄漏 - 发送泄漏的引用链为:Looper -> MessageQueue -> Message -> Handler(非静态内部类) -> Activity
6. 如何解决Handler内存泄漏
- 使用静态内部类+弱引用Activity解决。在Message被回调时判断当前Activity的弱引用是否为null,不为null时才执行
- 在Activity被摧毁时调用
handler.removeCallbacksAndMessages(null)把相关联的Message清空
7. MessageQueue怎么保证线程安全
在读取Message队列是会用synchronized枷锁保证线程安全
8. MessageQueue为什么不会造成死锁
MessageQueue在读取下一条Message时,如果没有Message可以执行,就会退出synchronized,然后调用native层的方法进行休眠,等待Message入队唤醒
9. MessageQueue在没有消息时会休眠,那么是什么促使Message的产生
虽然主线程会进行休眠,但是可以通过其他线程对主线程的 Looper 发送 Message。比如:AMS 通过 Binder 线程向主线程发送作用于 Activity 启动的 Message,然后触发一系列的 Message
10. Looper死循环会特别消耗CPU资源吗?
- 并不会消耗太多的资源。在 MessageQueue 当前没有消息要执行时会进行休眠,调用了 native 层 NativeMessageQueue,NativeMessageQueue 调用了 native 层的 Looper,该 Looper 会使用 Linux 的 epoll 机制进行休眠,休眠时会让出 CPU 调度
- epoll会监听了一个专门用于休眠操作的文件描述符,并向底层签写了一个当前文件描述符可读的回调,在 java 层 MessageQueue 入队时会对休眠的文件描述符进行写入,然后唤醒之前的休眠操作
11. 同步屏障是什么?它的原理是怎么实现的?
- 同步屏障是一种特殊的消息,可以使 Handler 优先执行异步消息。在 ViewRootImpl.scheduleTraversals() 方法中发送了一个同步屏障,并紧接着发送了一个用于测量布局绘制的异步消息。
- 在 MessageQueue.next() 读取下一条消息时,会先判断队列头是否是同步屏障,如果是的话,就会跳过同步消息,只寻找异步消息,最后返回给 Looper
- 通过同步屏障和异步消息来保证了 View 的绘制会优先执行,避免了消息过多而出现掉帧的情况
12. 主线程为什么不用初始化Looper
在Android程序入口ActivityThread的main方法中初始化了主线程Looper:
// 初始化主线程Looper
Looper.prepareMainLooper();
...
// 新建一个ActivityThread对象
ActivityThread thread = new ActivityThread();
thread.attach(false, startSeq);
// 获取ActivityThread的Handler,也是他的内部类H
if (sMainThreadHandler == null) {
sMainThreadHandler = thread.getHandler();
}
...
Looper.loop();
// 如果loop方法结束则抛出异常,程序结束
throw new RuntimeException("Main thread loop unexpectedly exited");
}ActivityThread并不是一个线程,而是主线程操作的管理者
13. Handler是如何切换线程的
Handler在创建时会绑定一个Looper,每个Looper都有一个对应的线程,在子线程中把消息投放到Handler对应的MessageQueue,然后Looper负责取出并消费消息,调用dispatchMessage方法就运行在其所在线程了。
14. post(Runnable)和sendMessage有什么区别
post和sendMessage的区别就在于,post方法给Message设置了一个callback回调,然后在消息处理方法dispatchMessage中:
public void dispatchMessage(@NonNull Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}
private static void handleCallback(Message message) {
message.callback.run();
}所以post(Runnable) 与 sendMessage的区别就在于后续消息的处理方式,是交给msg.callback还是 Handler.Callback或者Handler.handleMessage。
15. IdleHandler是什么,有什么使用场景
IdleHandler 是 MessageQueue 的一个内部接口,可以用于在 Loop 线程处于空闲状态的时候执行一些优先级不高的操作,通过 MessageQueue 的 addIdleHandler 方法来提交要执行的操作。MessageQueue 在执行 next() 方法时,如果发现当前队列是空的或者队头消息需要延迟处理的话,那么就会去尝试遍历 mIdleHandlers来依次执行 IdleHandler。
常见的使用场景:启动优化,我们一般会把一些事件(比如界面view的绘制、赋值)放到onCreate方法或者onResume方法中。但是这两个方法其实都是在界面绘制之前调用的,也就是说一定程度上这两个方法的耗时会影响到启动时间。所以我们可以把一些操作放到IdleHandler中,也就是界面绘制完成之后才去调用,这样就能减少启动时间了。
以及:ActivityThread 就向主线程 MessageQueue 添加了一个 GcIdler,用于在主线程空闲时尝试去执行 GC 操作
Android / RxJava
1. RxJava 的核心原理是什么?
RxJava 本质上是观察者模式 + 装饰器链 + 线程调度。上游通过 Observable/Flowable 发射事件,下游 Observer/Subscriber 接收事件,中间通过一系列操作符把事件转换、过滤、组合。
订阅发生时,RxJava 会从下游向上游逐层包装 Observer,形成一条链。事件真正发射时,再从上游向下游逐层传递。每个操作符本质上就是包装上游和下游,在 onNext、onError、onComplete 中插入自己的处理逻辑。
加分点:能说出 subscribeOn 影响订阅发生的线程,observeOn 影响后续观察者回调的线程。
2. subscribeOn 和 observeOn 有什么区别?
subscribeOn() 控制上游订阅和事件生产的线程,通常只有第一次调用生效;observeOn() 控制它后面那段链路的回调线程,可以调用多次。
常见写法:
request()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { render(it) }这表示网络请求在 IO 线程执行,UI 更新在主线程执行。
3. flatMap、concatMap、switchMap 的区别?
flatMap 会把一个事件转换成一个新的 Observable,并把多个 Observable 合并发射,不保证顺序,适合并发请求。
concatMap 会按上游顺序串行执行,保证结果顺序,适合有顺序要求的任务。
switchMap 只保留最新一次转换出来的 Observable,旧任务会被取消或忽略,适合搜索框联想、输入变化触发请求等场景。
4. RxJava 背压是什么?怎么解决?
背压是上游发射速度远快于下游消费速度时产生的问题。比如上游高频读取文件或传感器数据,下游处理不过来,可能导致缓存暴涨甚至 OOM。
解决方式是使用支持背压的 Flowable,并选择合适策略:
BUFFER:缓存全部,风险是 OOMDROP:下游忙时丢弃事件LATEST:只保留最新事件ERROR:处理不过来就报错
普通网络请求、按钮点击一般不需要 Flowable,高频连续数据流才需要关注背压。
5. RxJava 在 Android 中为什么容易内存泄漏?怎么处理?
RxJava 不感知 Activity/Fragment 生命周期。如果页面销毁后订阅还在执行,Observer 或 Lambda 可能继续持有页面引用,导致内存泄漏,甚至回调时访问已经销毁的 View。
处理方式:
- 用
CompositeDisposable管理订阅,在onDestroy()或onDestroyView()中clear() - Fragment 中尤其要在
onDestroyView()清理和 View 相关的订阅 - 使用 AutoDispose、RxLifecycle 等库绑定生命周期
- 更推荐把订阅收敛在 ViewModel 或 Repository 边界,避免 UI 层散落大量订阅
6. RxJava 和 Kotlin Flow 怎么选?
新 Kotlin 项目一般优先选择协程和 Flow,因为它有结构化并发,取消传播更清晰,和 Lifecycle、Compose、ViewModel 结合更自然。
RxJava 更适合存量项目、Java 项目,或者已经有大量 Rx 链路和操作符组合的项目。RxJava 的操作符生态成熟,但学习成本、调试成本和生命周期管理成本更高。
面试回答可以说:新功能优先 Flow,老项目尊重现有技术栈,复杂响应式链路可以继续使用 RxJava。
Android / Jetpack / MVVM
1. ViewModel 的原理?配置变更时为什么不会销毁?
考察点:ViewModel 存储机制
完整回答:
ViewModel 存储在 ViewModelStore(本质是 HashMap)中。Activity 实现了 ViewModelStoreOwner 接口,持有 ViewModelStore。
配置变更时,Activity 通过 onRetainNonConfigurationInstance() 将 ViewModelStore 保存到 ActivityClientRecord 中(AMS 不会清除这个数据)。新 Activity 创建后通过 getLastNonConfigurationInstance() 恢复 ViewModelStore,从而恢复所有 ViewModel。
ViewModel 的 onCleared() 只在 Activity 真正 finish 时调用(不是配置变更)。判断依据是 isChangingConfigurations() 返回 false 且 isFinishing() 返回 true。
追问:ViewModel 能保存多大的数据?
ViewModel 存在内存中,理论上没有大小限制,但要注意不要存过大的数据导致内存问题。大数据应该用 Room 或文件存储。
追问:进程被杀后 ViewModel 数据还在吗?
不在。进程被杀后 ViewModel 丢失。需要用 SavedStateHandle 将关键数据保存到 Bundle 中,进程恢复时可以恢复。Bundle 有 1MB 大小限制,只适合保存轻量数据(如 ID、搜索关键词)。
2. LiveData 的粘性事件问题是什么?怎么解决?
考察点:LiveData 源码理解
完整回答:
LiveData 内部有版本号机制。每次 setValue 时 mVersion++。新观察者注册时 mLastVersion 初始为 -1,小于 mVersion,所以会立即收到最后一次数据。
这在状态场景下是正确的(如 UI 状态恢复),但在事件场景下会导致问题。比如 ViewModel 中发了一个 Toast 事件,旋转屏幕后新 Observer 注册又收到这个事件,Toast 重复弹出。
解决方案:
- SharedFlow(replay=0)(推荐):不缓存历史数据,新收集者不会收到旧事件
- Channel:一次性消费,适合导航、Toast 等事件
- Event 包装类:用
content+hasBeenHandled标记是否已消费 - 反射修改 observer 的 mLastVersion(hack,不推荐)
追问:LiveData 的 postValue 和 setValue 区别?
setValue 只能在主线程调用,立即分发。postValue 可以在任意线程调用,通过 Handler post 到主线程执行。注意:短时间内多次 postValue,只有最后一次的值会被分发(中间值被覆盖)。
3. MVVM 和 MVI 的区别?你在项目中怎么选择?
考察点:架构模式理解
完整回答:
MVVM:View 观察 ViewModel 中的多个 LiveData/StateFlow,数据流可以是多向的。
MVI:单向数据流。View 发送 Intent → ViewModel 处理 → 产生新的不可变 State → View 渲染。所有 UI 状态集中在一个 State 对象中。
| 维度 | MVVM | MVI |
|---|---|---|
| 状态管理 | 分散(多个 LiveData) | 集中(单一 State) |
| 数据流 | 多向 | 单向 |
| 可预测性 | 一般 | 强 |
| 调试 | 需要追踪多个数据源 | 状态变化清晰可追溯 |
| 复杂度 | 低 | 较高 |
选择建议:
- 简单页面(列表展示、表单):MVVM 足够
- 复杂交互(多状态联动、需要状态回溯):MVI 更合适
- 团队规范统一比选哪个更重要
追问:MVI 的缺点?
- State 对象可能很大,每次都创建新对象有性能开销(可以用 data class copy 优化)
- 简单页面用 MVI 过度设计
- 需要定义大量 Intent 和 State 类
4. Lifecycle 的原理?怎么实现生命周期感知的?
考察点:Lifecycle 组件
完整回答:
Activity/Fragment 实现了 LifecycleOwner 接口,内部持有 LifecycleRegistry(Lifecycle 的实现类)。
在 Activity 中,通过注入一个无 UI 的 ReportFragment 来监听生命周期回调。ReportFragment 在各个生命周期方法中调用 LifecycleRegistry.handleLifecycleEvent(),分发事件给所有注册的 Observer。
LifecycleRegistry 维护了一个 Observer 列表和当前 State。添加新 Observer 时,会将其状态同步到当前 State(依次分发中间事件)。
追问:为什么用 ReportFragment 而不是直接在 Activity 中分发?
为了兼容不继承 AppCompatActivity 的场景。ReportFragment 是透明的,不影响 UI,且可以在任何 Activity 中注入。
5. Room 和 SharedPreferences 的区别?什么时候用 Room?
考察点:存储方案选型
完整回答:
SharedPreferences:
- 键值对存储,适合少量简单配置
- 全量读写 XML 文件,数据量大时性能差
- 不支持复杂查询
- ANR 风险:
apply()在 Activity onStop 时可能同步写入
Room:
- SQLite 的抽象层,适合结构化数据
- 编译期 SQL 验证,类型安全
- 支持 Flow/LiveData 观察数据变化
- 支持复杂查询、关联表、Migration
选择:
- 用户设置、token、简单标志位 → MMKV(替代 SP)
- 结构化数据、需要查询/排序/关联 → Room
- 大量键值对 → MMKV
加分点:提到 DataStore 是 Google 推荐的 SP 替代方案,基于 Flow,支持 Proto 序列化,线程安全。
6. WorkManager 的原理?和 Service 有什么区别?
考察点:后台任务调度
完整回答:
WorkManager 用于可延迟的、需要保证执行的后台任务。即使应用退出或设备重启,任务也会执行。
原理:
- 任务信息持久化到 Room 数据库
- 根据 API 级别选择底层实现:API 23+ 用 JobScheduler,低版本用 AlarmManager + BroadcastReceiver
- 支持约束条件(网络、电量、存储空间)
- 支持链式任务、周期任务
和 Service 的区别:
- Service 是四大组件,有自己的生命周期,适合需要立即执行的前台任务
- WorkManager 是任务调度框架,适合可延迟的后台任务
- WorkManager 保证任务最终执行,Service 可能被系统杀死
- WorkManager 自动处理 Doze 模式和省电限制
追问:WorkManager 的任务类型?
- OneTimeWorkRequest:一次性任务
- PeriodicWorkRequest:周期任务(最小间隔 15 分钟)
- 支持 ExistingWorkPolicy:REPLACE/KEEP/APPEND
7. DataBinding 和 ViewBinding 的区别?
考察点:视图绑定
完整回答:
| 维度 | ViewBinding | DataBinding |
|---|---|---|
| 功能 | 类型安全的 findViewById 替代 | ViewBinding + 数据绑定表达式 |
| 布局文件 | 普通 XML | 需要 <layout> 标签包裹 |
| 编译速度 | 快 | 慢(需要处理绑定表达式) |
| 双向绑定 | 不支持 | 支持(@={}) |
| 表达式 | 不支持 | 支持(android:text="@{user.name}") |
| 推荐度 | 推荐(简单场景) | 逐渐被 Compose 替代 |
现在的趋势:新项目用 Compose,老项目用 ViewBinding 替代 findViewById,DataBinding 逐渐不再推荐(复杂度高、调试困难)。
实习面试通常不要求你手写完整架构,但会问 ViewModel 为什么能解决问题、LiveData/Flow 怎么观察、项目为什么用 MVVM。
8. ViewModel 的作用是什么?
考察点:生命周期感知、页面状态管理
完整回答:
ViewModel 用来保存和管理页面相关的数据,特点是生命周期比 Activity/Fragment 的 View 更长,配置变更(如旋转屏幕)时不会立即销毁,因此可以避免页面重建后数据丢失。
典型职责:
- 保存 UI 状态。
- 调用 Repository 获取数据。
- 对外暴露 LiveData/StateFlow 给 UI 观察。
- 避免把业务逻辑全部写在 Activity/Fragment 中。
追问:ViewModel 能持有 Activity 引用吗?
不应该持有 Activity、Fragment、View 这类生命周期较短的对象引用,否则可能导致内存泄漏。如果确实需要 Context,可以谨慎使用 AndroidViewModel 的 Application Context。
9. LiveData 和 StateFlow 有什么共同点?
考察点:响应式 UI 状态
完整回答:
LiveData 和 StateFlow 都可以用来向 UI 暴露可观察状态,数据变化时通知界面刷新。
- LiveData 生命周期感知能力强,和传统 Android 组件结合简单。
- StateFlow 属于 Kotlin Flow 体系,适合协程和单向数据流,必须有初始值。
实习项目中如果是 Java/老项目,用 LiveData 很常见;如果是 Kotlin + 协程项目,StateFlow 更常见。
加分点:在 Fragment 中收集 Flow 时,要结合 repeatOnLifecycle,避免页面不可见时仍然收集。
10. MVVM 中 Model、View、ViewModel 分别负责什么?
考察点:架构分层
完整回答:
- View:Activity/Fragment/Compose UI,负责展示状态和接收用户操作。
- ViewModel:保存 UI 状态,处理页面逻辑,调用数据层。
- Model:数据来源,包括 Repository、网络、数据库、本地缓存等。
MVVM 的核心价值是降低 UI 和数据逻辑耦合,让 Activity/Fragment 更薄,代码更容易测试和维护。
追问:MVVM 是不是一定要用 Repository?
不是语法强制,但实际项目中推荐使用 Repository 隔离数据来源,让 ViewModel 不直接关心数据来自网络还是数据库。
11. 详细说明LiveData和ViewModel的工作原理,并讨论在实际项目中如何解决常见的生命周期问题。
LiveData是一种可观察的数据持有者,ViewModel用于存储和管理与用户界面相关的数据。深入理解包括:
LiveData的粘性事件: 了解
postValue和setValue的区别,以及如何避免LiveData的粘性事件在特定场景中引发的问题。ViewModel的存活周期: 使用
ViewModel正确处理配置变化,保证数据在屏幕旋转等情况下不丢失。LiveData和View绑定: 结合
DataBinding,实现LiveData与View之间的绑定,确保数据的实时更新。
12. LiveData和RxJava有什么区别
- 设计目标:
LiveData专为 Android 设计,主要用于在 UI 层观察数据变化;与 Android 生命周期紧密集成,确保数据更新只在 UI 处于活跃状态时触发,避免内存泄漏;简单易用,适合处理 UI 相关的数据流。
RxJava是一个通用的响应式编程库,适用于任何 Java 项目。提供了强大的数据流操作符(如 map、filter、flatMap 等),适合处理复杂的异步任务和数据流。需要手动管理生命周期,否则可能导致内存泄漏。
生命周期感知
LiveData自动感知生命周期,确保观察者只在
STARTED或RESUMED状态下接收数据更新,无需手动处理生命周期RXJava不直接支持生命周期感知,需要借助
RxLifecycle或AutoDispose等第三方库来管理生命周期。如果不处理生命周期,可能导致内存泄漏。数据流处理能力
LiveData功能简单,主要用于观察单一数据源的变化;不支持复杂的数据流操作(如线程切换、数据转换等)
RxJava提供了丰富的操作符(如 map、filter、flatMap、zip 等),可以轻松处理复杂的数据流。支持线程切换(如 subscribeOn、observeOn),方便处理异步任务。
- 线程管理
LiveData默认在主线程中触发数据刷新,如果需要在后台线程更新数据,可以使用 postValue 方法。
RxJava提供了强大的线程调度功能,可以通过 subscribeOn 和 observeOn 灵活切换线程。
13. 如何实现自定义生命周期的ViewModel
- 1). 继承ViewModel并重写
onCleared() - 2). 通过
ViewModelProvider.Factory注入自定义作用域 - 3). 使用
LifecycleObserver监听特定生命周期事件
14. ViewModel三大应用场景
- 跨屏幕旋转:
HolderFragment + ViewModelStore机制 - 跨组件通信:
ViewModelStoreOwner的多级作用域控制 - 跨进程恢复:
SavedStateHandle与Bundle的深度集成
15. 对比LiveData和Observable,分析它们在Android应用中的应用场景,以及在何种情况下选择使用哪种。
LiveData和Observable都是用于实现响应式编程的工具,但有一些关键区别:
- 生命周期感知: LiveData是生命周期感知的,它会在观察者(通常是UI组件)的生命周期内自动启动和停止。这使得在处理UI数据时更加安全,避免了潜在的内存泄漏。
- 背压处理: Observable在RxJava中通常使用背压策略来处理数据流,而LiveData则通过生命周期感知来实现反应式响应,避免了背压问题。
根据实际需求,选择使用LiveData还是Observable取决于应用的具体场景。对于需要与UI组件绑定的数据,以及对生命周期敏感的场景,LiveData是更好的选择。而在需要更强大的操作符和背压处理的情况下,可以考虑使用Observable。
Android / Compose
1. Compose 的重组原理?怎么优化性能?
考察点:Compose 底层机制
完整回答:
Compose 使用 Slot Table 存储组合树的状态。当 State 变化时,Compose 标记受影响的重组作用域(Scope),只重新执行这些 Composable 函数,比较新旧参数决定是否更新 UI。
重组的关键:
- Compose 编译器为每个 Composable 生成一个 group key(基于源码位置)
- 参数不变的 Composable 会被跳过(skip)
- 只有
@Stable或@Immutable标记的类型,或基本类型,才能被正确比较
性能优化:
- 缩小重组范围:将读取 State 的代码放在尽可能小的 Composable 中
- 使用 remember:缓存计算结果,避免重组时重复计算
- derivedStateOf:将多个 State 合并为一个派生 State,减少不必要的重组
- key():在列表中为 item 指定稳定的 key,避免不必要的重组
- @Stable/@Immutable:标记数据类,让 Compose 知道可以安全跳过
- 避免在 Composable 中创建 lambda:lambda 每次创建新对象会导致子 Composable 认为参数变了
// ❌ 每次重组都创建新 lambda
items.forEach { item ->
ItemView(onClick = { viewModel.onItemClick(item) })
}
// ✅ 使用 remember 缓存 lambda
items.forEach { item ->
val onClick = remember(item.id) { { viewModel.onItemClick(item) } }
ItemView(onClick = onClick)
}追问:Compose 和 View 系统可以混用吗?
可以。AndroidView 在 Compose 中嵌入传统 View,ComposeView 在传统布局中嵌入 Compose。适合渐进式迁移。
2. Compose 中的 remember 和 rememberSaveable 的区别?
考察点:Compose 状态管理
完整回答:
remember:在重组时保持状态,但配置变更(旋转屏幕)时丢失rememberSaveable:在重组和配置变更时都保持状态(内部使用 Bundle 保存)
// remember:旋转屏幕后 count 重置为 0
var count by remember { mutableStateOf(0) }
// rememberSaveable:旋转屏幕后 count 保持
var count by rememberSaveable { mutableStateOf(0) }rememberSaveable 只能保存 Bundle 支持的类型。自定义类型需要实现 Saver:
val userSaver = Saver<User, Bundle>(
save = { bundleOf("name" to it.name, "age" to it.age) },
restore = { User(it.getString("name")!!, it.getInt("age")) }
)
var user by rememberSaveable(stateSaver = userSaver) { mutableStateOf(User("", 0)) }3. Compose 的 LazyColumn 和 RecyclerView 有什么区别?
考察点:Compose 列表
完整回答:
LazyColumn 是 Compose 中的懒加载列表,类似 RecyclerView:
| 维度 | LazyColumn | RecyclerView |
|---|---|---|
| 声明方式 | 声明式 | 命令式(Adapter + ViewHolder) |
| 复用机制 | 组合复用(Slot Table) | ViewHolder 复用(四级缓存) |
| 布局管理 | 内置(LazyColumn/LazyRow/LazyGrid) | LayoutManager |
| 动画 | animateItemPlacement | ItemAnimator |
| 性能 | 略低(Compose 开销) | 略高(成熟优化) |
LazyColumn 性能优化:
- 为 item 指定
key(避免不必要的重组) - 避免在 item 中创建复杂的 lambda
- 使用
contentType帮助复用 - 大列表考虑
@Stable标记数据类
LazyColumn {
items(
items = users,
key = { it.id }, // 稳定的 key
contentType = { "user" } // 内容类型,帮助复用
) { user ->
UserItem(user)
}
}4. LaunchedEffect、DisposableEffect、rememberCoroutineScope 分别适合什么场景?
LaunchedEffect 用于在 Composable 进入 Composition 后启动协程,离开 Composition 时协程会取消;key 变化时旧协程取消并重新启动,适合首屏加载、动画、根据状态触发一次性异步任务。
DisposableEffect 用于需要注册和反注册的副作用,比如添加 LifecycleObserver、注册监听器;它必须提供 onDispose 做清理。
rememberCoroutineScope 返回一个绑定到当前 Composition 的 CoroutineScope,适合在点击事件等非 Composable 调用点手动启动协程。
面试可以总结为:自动随 key 重启用 LaunchedEffect,需要清理资源用 DisposableEffect,事件回调里手动发协程用 rememberCoroutineScope。
5. derivedStateOf 什么时候该用?什么时候不该用?
derivedStateOf 适合输入状态变化非常频繁,但 UI 只需要在派生结果变化时才重组的场景,例如列表滚动位置不断变化,但只关心是否超过第一个 item 来显示“回到顶部”按钮。
它的作用类似 distinctUntilChanged:派生值不变时减少不必要的重组。
但 derivedStateOf 本身也有成本,不应该把所有简单计算都包进去。比如 fullName = firstName + lastName 这种普通派生值,直接计算即可。
6. Compose 的稳定性 Stable/Unstable 对性能有什么影响?
Compose 编译器会根据参数稳定性决定 Composable 是否可以跳过重组。稳定参数如果和上次相等,Compose 可以跳过对应 Composable;不稳定参数在父级重组时更容易导致子级也重组。
稳定通常意味着不可变,或者 Compose 能判断值是否变化。可变对象、var 属性、普通可变集合等容易被推断为不稳定。
优化方向:优先使用不可变 UI State、val 属性、稳定的数据结构,避免把频繁变化或可变对象一路透传到大范围 Composable。
7. Strong Skipping 是什么?对 Compose 重组有什么影响?
Strong Skipping 是 Compose Compiler 的模式,Kotlin 2.0.20 起默认开启。它放宽了可跳过的条件:启用后,restartable Composable 即使带有不稳定参数,也可以成为 skippable。
跳过时参数比较规则不同:稳定参数通常按 equals 比较,不稳定参数按实例相等 === 比较。Strong Skipping 还会对 Composable 内部 lambda 做更多自动 remember,减少因为 lambda 重新分配造成的无效重组。
注意:Strong Skipping 不是让你忽略状态建模。UI State 仍应尽量不可变、单向流动,避免用可变对象绕过 Compose 的状态观察。
8. Compose 中状态提升是什么?为什么推荐状态下沉事件上提?
状态提升是把 Composable 内部状态移动到调用方,由父级持有状态并把 state 和 event lambda 传给子级。这样子组件更容易复用、测试,也更符合单向数据流。
常见写法是:子组件接收 value 和 onValueChange,自己不直接持有业务状态;ViewModel 或上层 Screen 持有 UI State,并处理事件。
好处是状态来源清晰,避免多个组件各自维护一份状态导致不一致;同时也方便配置变更恢复、预览和单元测试。
9. Compose 手势处理有哪些层级?为什么优先用 clickable 而不是 pointerInput?
Compose 手势从高到低大致分为:组件内置手势、手势 Modifier、底层 pointerInput。优先使用高层 API,因为它通常已经处理语义、无障碍、焦点、键盘触发、按压反馈和事件消费。
clickable 不只是监听点击,它还会提供可访问性语义、interaction、ripple、focus 等能力。pointerInput 更底层,适合自定义复杂手势,但很多行为需要自己补齐。
面试回答:简单点击用 clickable,长按/双击用 combinedClickable,拖拽/滚动/缩放优先用专门 Modifier,只有复杂自定义手势才用 pointerInput。
10. pointerInput 的 key 有什么作用?为什么 key 变化会影响手势?
pointerInput 的 block 运行在协程中,key 用来决定这段手势协程的生命周期。key 变化时,旧的 pointerInput 协程会取消,新的协程会启动。
如果手势逻辑依赖外部参数,key 设置不当可能导致捕获旧值;但 key 过于频繁变化,又可能让手势识别过程被中断。
常见做法:真正影响手势逻辑结构的值放进 key;只是回调变化但不希望重启手势时,可以用 rememberUpdatedState 保存最新回调。
11. draggable、scrollable、verticalScroll 有什么区别?
draggable 处理一维拖拽,返回拖动 delta,常用于滑块、拖动面板等场景。
scrollable 处理滚动手势和滚动状态,但不会自动偏移内容,你需要自己根据 state 改变布局。
verticalScroll / horizontalScroll 是更高层封装,会处理滚动状态、内容偏移和常见滚动行为。普通可滚动内容优先使用它们或 LazyColumn。
12. Compose 如何处理多指缩放、旋转和平移?
Compose 提供 transformable 和 rememberTransformableState 处理多点触控变换,可以同时获得 zoom、pan、rotation。UI 变换通常通过 graphicsLayer 应用到组件上。
如果只是图片预览、地图类缩放旋转,优先使用 transformable;如果要实现更复杂的识别流程,可以用 pointerInput 和 detectTransformGestures。
13. Compose 中嵌套滚动和手势冲突怎么处理?
Compose 使用 nested scroll 系统协调父子滚动。内置滚动组件通常已经支持嵌套滚动;自定义联动可以通过 Modifier.nestedScroll(connection) 参与 pre-scroll、post-scroll、fling 等阶段。
不要简单理解成 View 体系里的 requestDisallowInterceptTouchEvent。Compose 更推荐通过手势消费和 nested scroll 协议协调父子组件。典型场景是折叠 Toolbar、BottomSheet 内部列表、外层容器和内层列表联动。
Android / OkHttp
1. 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 还能在同一连接上多路复用。
2. 网络请求中的 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;双重检查避免重复刷新。
3. OkHttp 应用拦截器和网络拦截器有什么区别?
应用拦截器通过 addInterceptor() 添加,关注的是应用发起的一次逻辑请求;它通常只调用一次,即使内部发生重定向或重试,也更偏向业务统一处理。适合加公共 Header、统一日志、统一 Token、统一错误处理。
网络拦截器通过 addNetworkInterceptor() 添加,位于真正访问网络的阶段,能观察到重定向后的每一次网络请求,也可以访问连接信息。适合分析网络链路、查看服务端原始响应、调试缓存与重定向。
回答时可以抓住一句话:应用拦截器看“业务请求”,网络拦截器看“实际网络请求”。
4. OkHttp Dispatcher 是什么?它如何控制并发?
Dispatcher 负责异步请求调度。同步请求直接由调用线程执行,异步请求会先进入 Dispatcher,由线程池执行。Dispatcher 内部维护 running/ready 队列,并通过最大并发数和单 Host 最大并发数限制请求。
核心点:
maxRequests控制全局最大并发请求数maxRequestsPerHost控制同一 Host 的最大并发请求数- 超出限制的异步请求会进入等待队列
- 正在运行的请求结束后,会尝试提升等待队列里的请求
面试中可以进一步说:这能避免同一 App 同时打爆网络线程,也能避免某个 Host 占满全部请求资源。
5. OkHttp 连接池是怎么复用连接的?
OkHttp 使用 ConnectionPool 复用 HTTP 连接。一次请求结束后,如果连接满足复用条件,不会立刻关闭,而是放回连接池,后续相同地址的请求可以复用已有 TCP/TLS 连接,减少握手耗时。
HTTP/1.1 主要复用同一连接上的串行请求;HTTP/2 可以在一个连接上多路复用多个请求。连接池会清理长时间空闲或超过数量限制的连接。
收益是降低延迟、减少 TCP 和 TLS 握手、节省移动端电量。
6. OkHttp 缓存什么时候生效?为什么有时配置了 Cache 也不缓存?
OkHttp 的缓存遵循 HTTP 缓存语义,是否缓存主要由请求和响应头决定,例如 Cache-Control、Expires、ETag、Last-Modified 等。只配置 Cache 目录和大小,并不代表所有响应都会被缓存。
常见不缓存原因:
- 服务端响应包含
no-store或不允许缓存 - 请求方法或响应状态不适合缓存
- 请求头显式要求跳过缓存
- 响应缺少可缓存条件且 OkHttp 无法判断新鲜度
缓存命中时可以直接返回缓存;缓存过期时可能发起条件请求,服务端返回 304 后复用本地缓存体。
Android / Retrofit
1. 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()2. Retrofit 是如何把接口方法变成网络请求的?
Retrofit 通过动态代理为接口创建实现类。调用接口方法时,会进入 InvocationHandler,Retrofit 根据方法上的注解解析出 HTTP method、path、query、body、header 等信息,构建 ServiceMethod 和 RequestFactory,最终创建 OkHttp Call 执行请求。
它的核心不是自己发网络请求,而是把声明式接口转换成 OkHttp Request,再交给 OkHttp 执行。
3. Retrofit Converter 和 CallAdapter 分别负责什么?
Converter 负责数据转换,比如把响应体转换成 Kotlin/Java 对象,或者把对象转换成请求体。常见有 Gson、Moshi、kotlinx.serialization converter。
CallAdapter 负责调用返回类型适配,比如把 OkHttp Call 适配成 Call<T>、RxJava Observable<T> / Single<T>,或者支持 Kotlin suspend 函数。
一句话区分:Converter 管数据格式,CallAdapter 管调用形态。
4. Retrofit suspend 函数支持的原理是什么?
Retrofit 会识别接口方法是否是 Kotlin suspend 函数。suspend 函数在字节码层面会多一个 Continuation 参数,Retrofit 根据这个特征选择对应的 HttpServiceMethod,把 OkHttp 异步回调转换成协程恢复。
请求成功时 resume 返回结果,请求失败时 resumeWithException 抛出异常;协程取消时也会尽量取消底层 Call,避免无意义请求继续执行。
5. Retrofit 注解 @Path、@Query、@Body、@Field 有什么区别?
@Path 替换 URL 路径中的占位符,适合 /users/{id}。
@Query 拼接 URL 查询参数,适合 GET 请求筛选条件。
@Body 把对象作为请求体,常用于 JSON POST/PUT。
@Field 用于表单请求,需要配合 @FormUrlEncoded,最终按表单字段提交。
面试里要强调:注解决定参数如何进入 Request,不同注解对应 URL、Query、Header、Body、Form 等不同位置。
Android / Glide
1. 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。
2. Glide原理
Glide 是 Android 中一个非常成熟的图片加载框架,核心能力包括:图片异步加载、内存缓存、磁盘缓存、图片解码、复用、生命周期管理以及图片变换。
它整体采用了一个比较经典的流程:
- with() 绑定生命周期,避免页面销毁后还继续回调导致泄漏;
- load() 接收图片模型,比如 URL、File、Uri、资源 id;
- Glide 会先根据请求生成一个唯一 Key;
- 然后按顺序去查缓存:活动资源 ActiveResources → 内存缓存 LruResourceCache → 磁盘缓存 DiskLruCache;
- 如果缓存都没命中,就通过网络或本地数据源加载原始数据;
- 再经过解码、采样压缩、格式转换、Transform 变换;
- 最终回到主线程,把结果设置到 ImageView。
Glide 为了性能做了很多优化,比如:
- 多级缓存
- BitmapPool / ArrayPool 对象复用
- 按需缩放,避免大图直接加载导致 OOM
- 请求合并,防止同一资源重复加载
- 生命周期感知,自动暂停/恢复请求
所以一句话总结: Glide 本质上是一个围绕“资源复用 + 多级缓存 + 生命周期管理”构建的高性能图片加载框架。
3. 如何自定义Glide的缓存行为
通过DiskCacheStrategy枚举,可以自定义Glide的缓存行为:
DiskCacheStrategy.ALL: 缓存原始图片和转换后的图片到磁盘缓存DiskCacheStrategy.NONE: 不使用磁盘缓存DiskCacheStrategy.RESOURCE: 只缓存转换后的图片到磁盘缓存DiskCacheStrategy.DATA: 只缓存原始图片到磁盘缓存
Android / 缓存与存储
1. 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。
2. 如何设计一个多级缓存架构?
考察点:缓存设计能力
完整回答:
三级缓存:内存 → 磁盘 → 网络
请求数据
→ 查内存缓存(LruCache,O(1))
→ 命中:直接返回
→ 未命中:查磁盘缓存(DiskLruCache)
→ 命中:返回 + 写入内存缓存
→ 未命中:网络请求
→ 成功:返回 + 写入磁盘缓存 + 写入内存缓存设计要点:
- 内存缓存大小:通常用最大堆内存的 1/8
- 磁盘缓存大小:通常 50-200MB
- 缓存 key:URL 的 MD5 或 SHA256
- 过期策略:LRU(最近最少使用)+ TTL(过期时间)
- 线程安全:LruCache 内部 synchronized,DiskLruCache 内部有锁
- 缓存一致性:网络数据更新后同步更新缓存
以图片加载为例(Glide 的策略):
- 活动资源缓存(WeakReference,正在使用的图片)
- 内存缓存(LruCache)
- 磁盘缓存(原始图片 + 变换后的图片)
- 网络
加分点:提到缓存穿透(查询不存在的数据,可用布隆过滤器)、缓存雪崩(大量缓存同时过期,可用随机过期时间)。
3. 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、怎么保存登录态”这些具体问题开始。
4. SharedPreferences、DataStore 和 Room 分别适合存什么?
考察点:本地存储选型
完整回答:
SharedPreferences:适合少量 key-value 配置,比如开关、简单标记。同步读取方便,但不适合复杂数据和频繁写入。DataStore:适合替代 SharedPreferences,基于协程和 Flow,支持异步、类型安全更好的数据存储。Room:适合结构化数据和复杂查询,比如用户表、消息列表、搜索历史。
追问:登录 token 存哪里?
可以用 DataStore 或加密后的 SharedPreferences。更关键的是避免明文暴露,退出登录时及时清理,并注意 token 过期刷新逻辑。
Android / 性能优化与稳定性
1. 冷启动优化做过哪些?怎么衡量效果?
考察点:启动优化实战经验
完整回答:
冷启动全链路:Zygote fork → Application 初始化 → Activity 创建 → 首帧绘制。
优化手段:
- Application.onCreate:SDK 异步初始化(无依赖的放子线程)、延迟初始化(非必要的延迟到首帧后)、任务编排(DAG 处理依赖关系)
- ContentProvider 合并:用 App Startup 合并多个 Provider 为一个
- Activity:减少布局层级、ViewStub 延迟加载非首屏内容、异步 inflate
- 类加载优化:提前加载热点类、MultiDex 优化
- 闪屏页:设置 windowBackground 为品牌图,视觉上"秒开"
衡量指标:
- TTID:首帧显示时间(
adb shell am start -W的 TotalTime) - TTFD:完整内容显示时间(
reportFullyDrawn()) - 使用 Systrace/Perfetto 分析主线程耗时分布
追问:异步初始化怎么处理有依赖关系的 SDK?
用 DAG(有向无环图)编排。每个初始化任务声明依赖的前置任务,框架拓扑排序后并行执行无依赖的任务,有依赖的等前置完成后再执行。类似 Gradle 的 Task 依赖。
2. 内存泄漏怎么排查?常见的泄漏场景?
考察点:内存优化能力
完整回答:
排查工具:
- LeakCanary:自动检测 Activity/Fragment 泄漏,展示引用链
- Android Studio Profiler:dump heap,分析对象引用
- MAT(Memory Analyzer Tool):分析 hprof 文件,查找 GC Root 引用链
常见泄漏场景:
- Handler:非静态内部类持有 Activity 引用,Message 在队列中等待 → 用静态内部类 + WeakReference + onDestroy 清除消息
- 单例持有 Context:传了 Activity Context → 改用 Application Context
- 匿名内部类:Runnable、Callback 持有外部 Activity → 用静态类或在 onDestroy 取消
- 未注销监听:EventBus、BroadcastReceiver、SensorManager → onDestroy 中注销
- WebView:WebView 内部持有 Activity → 独立进程或动态添加/移除
- 集合类:只添加不移除 → 及时清理
追问:LeakCanary 的原理?
- 注册 ActivityLifecycleCallbacks 监听 onDestroy
- 将销毁的 Activity 包装为 WeakReference,关联 ReferenceQueue
- 延迟 5 秒检查 ReferenceQueue,如果 WeakReference 没入队说明未被回收
- 手动 GC 后再检查,仍未回收则 dump hprof
- 使用 Shark 库分析 hprof,找到 GC Root 到泄漏对象的最短引用链
3. ANR 是什么?怎么排查?
考察点:ANR 分析能力
完整回答:
ANR(Application Not Responding)是应用在规定时间内没有响应系统事件:
- 输入事件(触摸/按键):5 秒
- BroadcastReceiver:前台 10 秒,后台 60 秒
- Service:前台 20 秒,后台 200 秒
排查步骤:
- 获取 ANR 日志:
/data/anr/traces.txt或线上监控平台 - 分析主线程堆栈,看主线程在做什么:
- 死锁:
waiting to lock <0x...> held by thread X - IO 阻塞:文件读写、数据库查询在主线程
- Binder 调用:跨进程调用对端响应慢
- CPU 密集:主线程做大量计算
- 死锁:
- 查看 CPU 使用率:如果 CPU 使用率很高,可能是计算密集;如果很低,可能是锁等待或 IO
预防措施:
- 主线程不做 IO、网络、数据库操作
- 使用 StrictMode 检测主线程违规操作
- BroadcastReceiver 中不做耗时操作(用 goAsync 或转发给 Service/WorkManager)
- 监控主线程消息处理耗时(Looper printer 或 Choreographer)
追问:线上 ANR 怎么监控?
- FileObserver 监听
/data/anr/目录变化 - 主线程 Watchdog:子线程定期向主线程 post 消息,超时未执行则判定卡顿/ANR
- 使用 ANR-WatchDog 库或自建监控
4. 卡顿优化怎么做?怎么监控帧率?
考察点:渲染性能优化
完整回答:
卡顿原因:一帧超过 16.6ms(60fps)。
监控方式:
- Choreographer.FrameCallback:计算相邻帧的时间差,超过 16.6ms 即掉帧
- Looper printer:监控主线程每个 Message 的处理耗时(BlockCanary 原理)
- FrameMetrics API(API 24+):精确获取每帧各阶段耗时
优化手段:
- 布局优化:减少层级(ConstraintLayout)、移除过度绘制(
Debug GPU Overdraw)、ViewStub 延迟加载 - 主线程优化:IO/计算移到子线程、减少主线程锁等待
- RecyclerView 优化:setHasFixedSize、DiffUtil、预加载、共享 RecycledViewPool
- 减少内存抖动:避免在 onDraw/onBindViewHolder 中创建对象,复用对象
- 图片优化:合适的采样率、RGB_565、inBitmap 复用
追问:Systrace 怎么用?
python systrace.py -t 5 -o trace.html gfx view wm am在 Chrome 中打开 trace.html,查看主线程的方法调用耗时。关注:
- 绿色帧:正常(<16ms)
- 黄色/红色帧:掉帧
- 长条方法:耗时操作
5. Bitmap 内存怎么计算?怎么优化?
考察点:Bitmap 内存管理
完整回答:
内存计算:宽 × 高 × 每像素字节数
像素格式:
- ARGB_8888:4 字节/像素(默认)
- RGB_565:2 字节/像素(无透明通道)
- ALPHA_8:1 字节/像素
注意:从资源加载时会根据 dpi 缩放。如果图片在 mdpi(160) 目录,设备是 xxhdpi(480),图片会放大 3 倍,内存 = 原始宽×3 × 原始高×3 × 4。
优化手段:
- 采样加载:
inSampleSize降低分辨率 - 合适的像素格式:不需要透明通道用 RGB_565
- inBitmap 复用:复用已有 Bitmap 的内存块
- 及时回收:不用时
bitmap.recycle() - 放在合适的资源目录:避免不必要的缩放
- 使用 Glide/Coil:自动管理生命周期和缓存
6. 包体积优化做过哪些?
考察点:工程化能力
完整回答:
- 代码压缩:R8/ProGuard 移除无用代码、混淆、优化字节码
- 资源压缩:
shrinkResources true移除无用资源 - 图片优化:PNG → WebP(体积减少 25-35%)、TinyPNG 压缩、矢量图替代小图标
- 资源混淆:AndResGuard 将资源名缩短(res/drawable/icon → r/d/a)
- so 库:只保留 arm64-v8a(主流架构)、按需加载 so
- 动态下发:大资源/功能模块通过 Dynamic Feature 或插件化按需下载
- 代码优化:移除无用依赖、避免重复依赖
分析工具:Android Studio 的 APK Analyzer,查看各部分占比(dex/res/lib/assets)。
加分点:提到 R8 的 Full Mode 可以进一步优化(移除未使用的类成员),以及 Baseline Profile 优化运行时性能。
7. 内存抖动是什么?怎么检测和解决?
考察点:内存优化
完整回答:
内存抖动是短时间内大量创建和销毁对象,导致频繁 GC。表现为 Memory Profiler 中锯齿状的内存曲线。
常见场景:
- onDraw 中创建 Paint/Rect 对象(每帧调用)
- 循环中创建字符串(应用 StringBuilder)
- RecyclerView.onBindViewHolder 中创建对象
- 频繁装箱拆箱(int → Integer)
检测:Android Studio Profiler → Memory → 观察内存曲线是否呈锯齿状 → Allocations 面板定位频繁分配的对象。
解决:对象复用(在构造函数中创建,onDraw 中复用)、使用对象池、避免不必要的装箱。
8. 过度绘制是什么?怎么优化?
考察点:渲染优化
完整回答:
过度绘制(Overdraw)是同一个像素在一帧中被绘制多次。比如一个白色背景上放一个白色卡片,卡片区域被绘制了两次。
检测:开发者选项 → 调试 GPU 过度绘制。颜色含义:
- 无色:无过度绘制
- 蓝色:1 次过度绘制
- 绿色:2 次
- 粉色:3 次
- 红色:4 次以上
优化手段:
- 移除不必要的背景(Activity 默认有 Window 背景,如果布局有自己的背景可以移除 Window 背景)
android:background="@null"或window.setBackgroundDrawable(null)- 使用
clipRect限制绘制区域 - 减少布局层级(ConstraintLayout 替代嵌套)
- ViewStub 延迟加载不可见的布局
9. 线上 Crash 怎么治理?有什么方法论?
考察点:稳定性治理
完整回答:
治理流程:
监控:接入 Crash 监控 SDK(Firebase Crashlytics、Bugly),收集崩溃堆栈、设备信息、用户操作路径
分类:按影响面排序
- Top Crash:影响用户数最多的崩溃优先修复
- 按模块/页面分类,找到问题集中的区域
分析:
- Java Crash:直接看堆栈定位代码
- Native Crash:addr2line 还原符号,分析 tombstone
- ANR:分析 traces.txt 主线程堆栈
修复:
- 紧急问题:热修复(Tinker/Sophix)
- 常规问题:下个版本修复
预防:
- 代码规范 + Lint 检查
- 单元测试覆盖核心逻辑
- 灰度发布(先 1% → 10% → 全量)
- 质量门禁(Crash 率超标则阻止发版)
追问:Crash 率怎么计算?业界标准是多少?
Crash 率 = 崩溃用户数 / DAU × 100%。业界标准:Crash Rate < 0.1%(千分之一),ANR Rate < 0.3%。
10. 网络优化做过哪些?
考察点:网络性能
完整回答:
连接优化:
- HTTP/2 多路复用(一个连接并行多个请求)
- 连接池复用(OkHttp 默认 5 个空闲连接)
- DNS 预解析 + 缓存(避免 DNS 查询耗时)
数据优化:
- Protobuf 替代 JSON(体积减少 60-90%)
- gzip 压缩请求/响应体
- 增量更新(只传变化的数据)
缓存优化:
- HTTP 缓存(Cache-Control)
- 本地缓存(先展示缓存,后台更新)
- 预加载(提前请求下一页数据)
弱网优化:
- 超时时间自适应(WiFi 短,移动网络长)
- 请求优先级(核心请求优先)
- 失败重试(指数退避)
- 降级策略(弱网下降低图片质量)
监控:
- OkHttp EventListener 监控各阶段耗时
- 统计请求成功率、平均耗时、错误码分布
实习面试不会默认要求你做过完整线上治理,但会看你是否知道卡顿、内存泄漏、ANR 的基本原因和排查方向。
11. 什么是 ANR?常见原因有哪些?
考察点:稳定性基础
完整回答:
ANR 是 Application Not Responding,表示应用在规定时间内没有响应系统输入或组件调度。常见场景:
- 主线程做耗时操作,比如网络请求、数据库读写、大文件 IO。
- 主线程等待锁,锁被子线程长期持有。
- BroadcastReceiver 执行太久。
- Service 生命周期方法中执行耗时任务。
解决思路是减少主线程耗时,把耗时任务放到子线程,必要时通过日志、Trace、ANR traces 文件定位主线程卡在哪里。
追问:ANR 和 Crash 的区别?
Crash 是应用异常退出;ANR 是应用没有及时响应,系统弹出无响应对话框,用户可以选择等待或关闭。
12. 常见内存泄漏场景有哪些?
考察点:内存管理基础
完整回答:
常见内存泄漏包括:
- 静态变量持有 Activity 或 View。
- 匿名内部类、Handler、Runnable 长时间持有 Activity。
- 注册监听、广播、观察者后没有取消。
- Fragment 的 ViewBinding 在
onDestroyView后没有释放。 - 单例中保存了短生命周期对象。
修复原则是:长生命周期对象不要持有短生命周期对象;需要注册的资源要在合适生命周期取消。
加分点:可以使用 LeakCanary 在开发阶段发现泄漏。
13. 页面卡顿一般怎么排查?
考察点:性能排查思路
完整回答:
先判断卡顿发生在什么场景:启动、列表滑动、页面切换、图片加载还是动画。常见排查方向:
- 主线程是否有耗时任务。
- RecyclerView 是否重复创建对象、频繁全量刷新。
- 图片是否过大,是否在主线程解码。
- 布局是否层级过深或过度绘制。
- 是否频繁触发布局重绘。
简单优化包括:耗时任务异步化、使用 DiffUtil 局部刷新、压缩图片、减少嵌套布局、避免在 onDraw 中创建对象。
Android / 系统原理 / Binder / 启动流程
1. Binder 的原理?为什么 Android 选择 Binder?
考察点:IPC 机制理解
完整回答:
Binder 是 Android 特有的 IPC 机制,基于 C/S 架构。
选择 Binder 的原因:
- 性能:只需一次数据拷贝。发送方通过 copy_from_user 将数据拷贝到内核缓冲区,接收方的用户空间通过 mmap 映射到同一块物理内存,直接访问。而 Socket 需要两次拷贝(发送方→内核→接收方)。
- 安全:Binder 驱动在内核层面记录调用方的 UID/PID,不可伪造。其他 IPC(如 Socket)的身份信息由用户空间填写,可以伪造。
- 易用:面向对象的调用方式,AIDL 自动生成代理代码,调用远程方法就像调用本地方法。
追问:一次拷贝是怎么实现的?
接收进程在 Binder 驱动中通过 mmap 将一块内核缓冲区映射到自己的用户空间。发送进程的数据通过 copy_from_user 拷贝到这块内核缓冲区后,接收进程可以直接在用户空间访问,不需要再 copy_to_user。
追问:AIDL 生成的 Stub 和 Proxy 分别是什么?
- Stub(服务端):继承 Binder,实现接口方法。
onTransact中根据方法编号反序列化参数,调用真正的实现 - Proxy(客户端):持有远程 Binder 引用。调用方法时将参数序列化到 Parcel,通过
transact发送给 Binder 驱动
asInterface 方法会判断是否同进程:同进程直接返回 Stub(本地调用),跨进程返回 Proxy。
2. App 的启动流程?从点击图标到 Activity 显示?
考察点:系统启动流程
完整回答:
- Launcher 调用
startActivity,通过 Binder 发送给 AMS - AMS 检查目标 Activity 的进程是否存在
- 进程不存在 → AMS 通过 Socket 请求 Zygote fork 新进程
- Zygote fork 后,新进程执行
ActivityThread.main() main()中创建主线程 Looper 并Looper.loop()- 创建 ActivityThread 实例,通过 Binder 向 AMS 注册(
attachApplication) - AMS 回调
bindApplication:创建 Application → attachBaseContext → ContentProvider.onCreate → Application.onCreate - AMS 回调
scheduleLaunchActivity:创建 Activity → attach → onCreate → onStart → onResume - ViewRootImpl.performTraversals 执行首帧绘制
- 用户看到内容
追问:Zygote 为什么用 fork 而不是新建进程?
fork 是 COW(Copy-On-Write),子进程共享父进程的内存页,只在写入时才复制。Zygote 预加载了 Framework 类和资源,fork 后子进程直接共享这些内容,不需要重新加载,大幅加快启动速度。
追问:为什么 Zygote 用 Socket 而不是 Binder?
fork 时如果有多线程,子进程只会保留调用 fork 的线程,其他线程消失。如果 Zygote 使用 Binder(Binder 有线程池),fork 后 Binder 线程消失会导致死锁。所以 Zygote 使用单线程的 Socket 通信。
3. Activity 的启动流程?涉及哪些系统组件?
考察点:Activity 启动源码
完整回答:
- 调用方
startActivity→ Instrumentation.execStartActivity - 通过 Binder 调用 AMS 的
startActivity - AMS 中:
- ActivityStarter:解析 Intent,确定启动模式
- 查找或创建 TaskRecord(Task 栈)
- 创建 ActivityRecord
- 暂停当前 Activity(回调 onPause)
- 如果目标进程不存在,通知 Zygote fork
- 目标进程就绪后,AMS 通过 Binder 回调 ActivityThread
- ActivityThread.handleLaunchActivity:
- 通过反射创建 Activity 实例
- 调用 activity.attach(创建 PhoneWindow)
- 调用 onCreate(setContentView 创建 View 树)
- 调用 onStart、onResume
- WindowManager.addView → 创建 ViewRootImpl → performTraversals
追问:为什么 A.onPause 先于 B.onCreate?
AMS 的调度逻辑:先暂停当前 Activity(确保它保存状态),再启动新 Activity。这是为了保证数据安全——如果先启动 B 再暂停 A,A 可能来不及保存状态就被系统回收。
4. 多进程有什么问题?怎么通信?
考察点:多进程架构
完整回答:
多进程的问题:
- 静态变量/单例失效:每个进程有独立的虚拟机和内存空间
- SharedPreferences 不可靠:多进程同时读写会数据丢失
- 文件并发访问:需要文件锁
- Application 多次创建:每个进程都会创建 Application
通信方式:
- AIDL/Binder:最常用,支持方法调用,类型安全
- Messenger:基于 Handler 的轻量级方案,串行处理
- ContentProvider:适合数据共享
- BroadcastReceiver:适合一对多通知
- Socket:灵活但开发成本高
- MMKV:多进程安全的键值存储
追问:什么场景需要多进程?
- WebView 独立进程:隔离 WebView 的内存泄漏和崩溃
- 推送服务独立进程:保活
- 大内存操作独立进程:如图片处理、视频编辑
- 插件化:插件运行在独立进程
5. Binder 传输数据有大小限制吗?怎么传大数据?
考察点:Binder 限制
完整回答:
Binder 传输缓冲区大小约 1MB(准确说是 1016KB),这个限制是所有正在进行的 Binder 事务共享的。超过限制会抛 TransactionTooLargeException。
常见触发场景:
- Intent 传递大 Bundle(如大 Bitmap、大列表)
- onSaveInstanceState 保存过多数据
- ContentProvider 返回大量数据
传大数据的方案:
- 文件:写入文件,传递文件路径
- ContentProvider:通过 URI 访问数据
- ParcelFileDescriptor:传递文件描述符(Binder 只传 fd,数据通过文件系统传输)
- 共享内存(MemoryFile/SharedMemory):mmap 共享内存,Binder 只传引用
- Socket:大量数据通过 Socket 传输
追问:为什么 Intent 传 Bitmap 容易崩溃?
Bitmap 序列化后可能很大(1920×1080×4 = 8MB),远超 Binder 1MB 限制。应该传 URI 或文件路径,接收方自己加载。
6. Android 的 Parcelable 和 Serializable 的区别?
考察点:序列化
完整回答:
| 维度 | Parcelable | Serializable |
|---|---|---|
| 实现 | 手动实现(或 @Parcelize) | 实现接口即可 |
| 性能 | 快(直接内存操作) | 慢(反射 + IO 流) |
| 内存 | 少(无临时对象) | 多(创建大量临时对象) |
| 持久化 | 不适合(格式可能变化) | 适合 |
| 使用场景 | Android 组件间传递(Intent/Bundle) | 持久化存储、网络传输 |
Kotlin 中推荐用 @Parcelize:
@Parcelize
data class User(val name: String, val age: Int) : Parcelable
// 编译器自动生成 writeToParcel 和 CREATOR追问:Parcelable 为什么比 Serializable 快?
Serializable 使用 ObjectOutputStream,通过反射遍历对象的所有字段,创建大量临时对象。Parcelable 直接将数据写入 Parcel(连续内存块),无反射,无临时对象。
7. 热修复的原理?Tinker 是怎么工作的?
考察点:热修复机制
完整回答:
热修复的核心原理是利用类加载机制:
Android 的 ClassLoader 通过 DexPathList 中的 dexElements 数组查找类。查找时按数组顺序遍历,找到就返回。
Tinker 的方案:
- 生成补丁 dex(包含修复后的类)
- 将补丁 dex 插入到
dexElements数组的最前面 - 下次加载类时,先找到补丁 dex 中的修复类,原始 dex 中的 bug 类不会被加载
// 简化原理
Field pathListField = BaseDexClassLoader.class.getDeclaredField("pathList");
Object pathList = pathListField.get(classLoader);
Field dexElementsField = pathList.getClass().getDeclaredField("dexElements");
Object[] oldElements = (Object[]) dexElementsField.get(pathList);
// 将补丁 dex 的 Element 插入到数组最前面
Object[] newElements = concat(patchElements, oldElements);
dexElementsField.set(pathList, newElements);其他热修复方案:
- Sophix(阿里):类修复 + 资源修复 + so 修复,底层替换方案
- Robust(美团):编译时插桩,每个方法前插入 if 判断是否有补丁
- AndFix:Native 层替换方法指针(ArtMethod),即时生效但兼容性差
Binder、启动流程这些内容对实习生通常是加分项。目标是先讲清主线,不需要背完整源码调用链。
8. Binder 是用来解决什么问题的?
考察点:IPC 基础
完整回答:
Binder 是 Android 主要的跨进程通信机制。不同进程有独立内存空间,不能直接调用对方对象,Binder 提供了一套让客户端像调用本地接口一样调用远程服务的机制。
常见场景包括:
- App 调用系统服务,比如 ActivityManagerService、PackageManagerService。
- AIDL 跨进程通信。
- Service 的跨进程绑定。
加分点:Binder 相比传统 Socket 更适合 Android 系统服务调用,支持身份校验和对象引用语义。
9. App 启动大致经过哪些步骤?
考察点:启动流程主线
完整回答:
冷启动大致流程:
- 用户点击图标,Launcher 通过 Binder 请求系统启动目标 Activity。
- 系统进程中的 AMS 判断目标应用进程是否存在。
- 如果进程不存在,通过 Zygote fork 出应用进程。
- 应用进程启动 ActivityThread,创建主线程 Looper。
- ActivityThread 创建 Application 和 Activity。
- 依次执行 Activity 的
onCreate、onStart、onResume,完成首帧绘制。
实习面试中能讲清这条主线即可,源码细节可以作为加分展开。
Android / 架构 / Gradle / 工程化
1. MVC、MVP、MVVM、MVI 的区别?你在项目中用哪个?
考察点:架构模式理解
完整回答:
- MVC:Activity 同时是 Controller 和 View,职责不清,容易臃肿
- MVP:View 和 Presenter 通过接口通信,Presenter 可测试。但接口爆炸,生命周期管理复杂
- MVVM:View 观察 ViewModel 的 LiveData/StateFlow,数据驱动 UI。ViewModel 不持有 View 引用,配置变更时存活。目前 Android 官方推荐
- MVI:单向数据流,所有 UI 状态集中在一个不可变 State 中。可预测、可追溯,但复杂度较高
项目中通常用 MVVM + 部分 MVI 思想:ViewModel 暴露 StateFlow 作为 UI State,View 发送事件给 ViewModel 处理。简单页面用纯 MVVM,复杂交互页面用 MVI 的单一 State。
追问:Clean Architecture 了解吗?
三层架构:Presentation(UI + ViewModel)→ Domain(UseCase + Entity + Repository 接口)→ Data(Repository 实现 + DataSource)。依赖规则是外层依赖内层,Domain 层是纯 Kotlin 不依赖 Android。UseCase 封装单一业务逻辑,ViewModel 组合多个 UseCase。
好处是可测试性强、职责清晰、替换数据源不影响业务逻辑。
2. 组件化架构怎么设计?组件间怎么通信?
考察点:大型项目架构能力
完整回答:
组件化将应用拆分为多个独立模块:
App Shell(壳工程)
├── 业务组件(首页、播放、我的、消息...)
├── 公共组件(网络、图片、日志、埋点)
└── 基础库(工具类、UI 组件、路由)关键问题和解决方案:
页面跳转:路由框架(ARouter)。编译期 APT 生成路由表,运行时根据 path 查找目标 Activity。支持拦截器(登录检查)和降级。
组件间通信:接口下沉 + SPI。公共层定义接口,业务组件实现,通过 ServiceLoader 或 ARouter 获取实现。
独立运行:每个组件可以配置为 application(独立运行调试)或 library(集成到主工程)。
依赖注入:Hilt 管理跨组件的依赖。
追问:ARouter 的原理?
编译期:APT 扫描 @Route 注解,为每个模块生成路由表类(path → ActivityClass 映射)。
运行时:初始化时通过反射或 Gradle Transform 加载所有模块的路由表。跳转时根据 path 查找目标 Class,构建 Intent 并启动。
支持拦截器链(IInterceptor),可以在跳转前做登录检查、权限验证等。
3. 常见的设计模式在 Android 中的应用?
考察点:设计模式实践
完整回答:
- 观察者模式:LiveData、Flow、BroadcastReceiver、OnClickListener、Lifecycle Observer
- 责任链模式:OkHttp 拦截器链、View 事件分发机制
- 代理模式:Retrofit 动态代理、Binder 的 Stub/Proxy
- 建造者模式:AlertDialog.Builder、OkHttpClient.Builder、Notification.Builder
- 工厂模式:BitmapFactory、LayoutInflater、Fragment.newInstance
- 策略模式:RecyclerView.LayoutManager、动画 Interpolator
- 单例模式:Application、Room Database
- 模板方法:Activity 生命周期、BaseAdapter.getView
- 装饰器模式:ContextWrapper 装饰 ContextImpl
- 适配器模式:RecyclerView.Adapter、Retrofit CallAdapter
追问:举一个你在项目中用设计模式解决问题的例子?
用策略模式处理不同类型的消息展示:定义 MessageRenderer 接口,TextRenderer、ImageRenderer、VideoRenderer 分别实现。根据消息类型选择对应的 Renderer,避免了大量 if-else。新增消息类型只需添加新的 Renderer 实现。
4. Gradle 的构建流程?怎么优化构建速度?
考察点:构建系统理解
完整回答:
Gradle 构建三个阶段:
- 初始化:执行 settings.gradle,确定参与构建的项目
- 配置:执行所有 build.gradle,构建 Task 依赖图(DAG)
- 执行:按 DAG 拓扑排序执行 Task
优化手段:
- Gradle Daemon:常驻进程,避免每次启动 JVM
- 并行构建:
org.gradle.parallel=true,无依赖的模块并行编译 - Build Cache:缓存 Task 输出,相同输入直接复用
- Configuration Cache:缓存配置阶段结果(Gradle 7+)
- 合理使用 implementation:减少依赖传递,修改一个模块不会触发依赖它的模块重编译
- 避免配置阶段耗时操作:不要在 build.gradle 中执行网络请求或文件 IO
- 增量编译:Kotlin 增量编译、kapt 增量处理
追问:implementation 和 api 的区别?
implementation:依赖不传递。模块 A implementation 模块 B,模块 C 依赖 A 时看不到 B。修改 B 不会触发 C 重编译。api:依赖传递。模块 C 依赖 A 时也能看到 B。修改 B 会触发 C 重编译。
原则:默认用 implementation,只有需要暴露给消费者的依赖才用 api。
5. 怎么做代码质量管控?
考察点:工程化能力
完整回答:
静态检查:
- Android Lint:检查潜在 bug、性能问题、安全漏洞
- Detekt/ktlint:Kotlin 代码风格和质量检查
- 自定义 Lint 规则:检查项目特定的规范
测试:
- 单元测试:JUnit + MockK,覆盖 ViewModel 和 Repository 逻辑
- UI 测试:Espresso / Compose Testing
- 覆盖率:JaCoCo,设置最低覆盖率门槛
Code Review:PR 必须至少一人审核通过
CI/CD:
- PR 触发自动构建 + Lint + 单元测试
- 任何检查失败则阻止合并
- 合并后自动打包发布到测试环境
监控:
- 线上 Crash 率、ANR 率监控
- 性能指标(启动时间、帧率)监控
- 包体积变化监控
加分点:提到 Danger(自动化 Code Review 机器人)、SonarQube(代码质量平台)、Baseline Profile(运行时性能优化)。
6. 依赖注入的原理?Hilt 和 Dagger 的关系?
考察点:DI 框架
完整回答:
依赖注入(DI):对象不自己创建依赖,而是由外部注入。好处是解耦、可测试(注入 Mock 对象)。
Dagger 是编译期 DI 框架,通过 APT 生成依赖注入代码(无反射,性能好)。但配置复杂(Component、Module、Scope)。
Hilt 是 Dagger 的封装,简化了 Android 中的使用:
- 预定义了 Component 层级(SingletonComponent → ActivityComponent → FragmentComponent)
- 自动绑定 Android 组件的生命周期
@HiltAndroidApp、@AndroidEntryPoint注解简化配置
// Hilt 使用
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var repo: UserRepository // 自动注入
}
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun provideRepo(api: ApiService): UserRepository = UserRepositoryImpl(api)
}追问:Koin 和 Hilt 的区别?
Koin 是运行时 DI(基于 Kotlin DSL),配置简单但运行时解析有性能开销,且错误在运行时才发现。Hilt 是编译期 DI,编译时就能发现依赖缺失,性能更好。大型项目推荐 Hilt,小项目 Koin 也够用。
实习岗位常问你能不能看懂项目结构、依赖配置和 Git 协作,不一定要求做过大型组件化。
7. implementation 和 api 的区别?
考察点:Gradle 依赖配置
完整回答:
implementation:依赖只在当前模块内部可见,不会暴露给依赖当前模块的其他模块。api:依赖会传递暴露给上层模块。
一般优先使用 implementation,可以减少不必要的依赖暴露,加快编译,也让模块边界更清晰。只有当当前模块的公开 API 中使用了某个依赖的类型时,才考虑使用 api。
8. Debug 包和 Release 包有什么区别?
考察点:构建变体
完整回答:
Debug 包通常用于开发调试:
- 默认可调试。
- 可能开启日志、调试菜单、测试环境接口。
- 通常不做完整混淆和优化。
Release 包用于发布:
- 关闭调试。
- 使用正式环境配置。
- 通常开启签名、混淆、资源压缩等优化。
追问:为什么 Release 包要混淆?
混淆可以缩短类名和方法名,增加逆向难度,同时配合压缩移除未使用代码,减小包体积。但需要为反射、序列化、JNI、第三方 SDK 等场景配置 keep 规则。
9. Git 中 merge 和 rebase 有什么区别?
考察点:团队协作基础
完整回答:
merge会生成一次合并提交,保留分支真实合并历史。rebase会把当前分支的提交“挪到”目标分支之后,历史更线性。
多人协作时,不要随意 rebase 已经推送并被别人基于开发的公共分支,容易改写历史造成冲突。个人 feature 分支在合并前可以适当 rebase,让提交历史更清晰。
Android / 跨平台 Flutter / KMM
1. Flutter 的渲染原理?和 Android 原生有什么区别?
考察点:Flutter 架构理解
完整回答:
Flutter 不使用原生控件,通过 Skia(新版用 Impeller)自绘 UI。
Flutter 维护三棵树:
- Widget 树:不可变的配置描述,类似声明式 UI 的蓝图
- Element 树:Widget 的实例化,管理生命周期,决定是否复用
- RenderObject 树:负责实际的布局(layout)和绘制(draw)
渲染流程:Widget 变化 → Element 对比新旧 Widget(canUpdate:类型+key 相同则复用)→ 更新 RenderObject → Skia 绘制 → GPU 渲染
和 Android 原生的区别:
- Android 原生:View 系统,每个控件是平台原生组件,通过 Canvas 绑制
- Flutter:完全自绘,不依赖平台控件,所以跨平台 UI 完全一致
- 性能:Flutter 自绘接近原生,但首次启动需要加载 Dart VM 和 Flutter Engine
追问:Flutter 的 Widget 重建开销大吗?
Widget 是轻量的不可变对象,重建开销很小。真正的开销在 RenderObject 的布局和绘制。Element 层通过 canUpdate 机制复用 RenderObject,避免不必要的重建。类似 Compose 的重组跳过机制。
2. Flutter 和 Native 怎么通信?
考察点:混合开发能力
完整回答:
通过 Platform Channel 通信,三种类型:
MethodChannel:方法调用,一次请求一次响应。最常用,如获取电量、调用原生相机。
EventChannel:事件流,Native 持续向 Dart 发送数据。如传感器数据、位置更新。
BasicMessageChannel:自定义编解码的消息传递。
通信是异步的,数据通过标准编解码器序列化(StandardMessageCodec 支持基本类型、List、Map)。
追问:混合开发中页面栈怎么管理?
Flutter 和 Native 各有独立的页面栈。混合导航的挑战是两个栈的同步。解决方案:
- flutter_boost:统一管理 Flutter 和 Native 页面栈,每个 Flutter 页面对应一个 Native 容器
- 单 Engine 多页面:共享一个 FlutterEngine,通过路由管理 Flutter 内部页面
追问:FlutterEngine 的预热是什么?
FlutterEngine 创建时需要初始化 Dart VM、加载 Dart 代码,耗时约 200-500ms。预热是在 Application 中提前创建 Engine 并缓存,用户打开 Flutter 页面时直接复用,避免等待。
3. KMM 的 expect/actual 机制是什么?
考察点:KMM 原理
完整回答:
KMM 通过 expect/actual 实现跨平台:
expect:在 commonMain 中声明接口(期望)actual:在各平台模块中提供具体实现
// commonMain
expect fun platformName(): String
// androidMain
actual fun platformName(): String = "Android ${Build.VERSION.SDK_INT}"
// iosMain
actual fun platformName(): String = UIDevice.currentDevice.systemName()编译时,Kotlin 编译器检查每个 expect 声明都有对应的 actual 实现。
适合共享的:网络层(Ktor)、数据库(SQLDelight)、业务逻辑、数据模型。 不适合共享的:UI、平台特定 API。
追问:KMM 和 Flutter 怎么选?
- KMM:只共享逻辑层,UI 保持原生。适合已有成熟原生 App,想逐步共享业务逻辑。Android 开发者学习成本低(就是 Kotlin)。
- Flutter:共享 UI + 逻辑。适合新项目,追求开发效率和 UI 一致性。需要学习 Dart。
4. 跨平台方案怎么选型?
考察点:技术选型能力
完整回答:
选型维度:
| 维度 | Flutter | KMM | React Native |
|---|---|---|---|
| 共享范围 | UI + 逻辑 | 仅逻辑 | UI + 逻辑 |
| 性能 | 接近原生(自绘) | 原生 | 略低(JS 桥接) |
| UI 体验 | 跨平台一致 | 原生 UI | 跟随平台 |
| 热更新 | 不支持 | 不支持 | 支持(CodePush) |
| 团队 | 需要 Dart 开发者 | Kotlin 开发者 | 前端开发者 |
选型建议:
- 新项目 + UI 一致性 → Flutter
- 已有原生 App + 共享逻辑 → KMM
- 前端团队 + 热更新需求 → React Native
- 核心体验页面 → 保持原生
实际项目中,很多大厂采用混合方案:核心页面原生,非核心页面 Flutter/RN,业务逻辑 KMM 共享。
5. Flutter 的状态管理方案有哪些?
考察点:Flutter 开发实践
完整回答:
- setState:最基础,适合简单组件内部状态
- Provider:官方推荐的轻量级方案,基于 InheritedWidget
- Riverpod:Provider 的改进版,编译期安全,不依赖 BuildContext
- Bloc:基于事件驱动的状态管理,类似 MVI。Event → Bloc → State
- GetX:全家桶方案,包含状态管理、路由、依赖注入。简单但不够规范
选择建议:
- 小项目:Provider 或 Riverpod
- 大项目:Bloc(规范性强)或 Riverpod
- 快速原型:GetX
加分点:对比 Flutter 的 Bloc 和 Android 的 MVI,思想类似——都是单向数据流,Event/Intent → 处理 → State → UI。
对 Android 实习生来说,Flutter/KMM 通常是加分项。重点是知道它们解决什么问题、和原生开发有什么区别。
6. Flutter 和原生 Android 开发有什么区别?
考察点:跨平台基础认知
完整回答:
Flutter 使用 Dart 编写 UI 和业务逻辑,通过自己的渲染引擎绘制界面,可以一套代码运行在 Android、iOS 等平台。原生 Android 使用 Kotlin/Java,直接使用 Android 系统提供的 View、Activity、Fragment、Jetpack 等能力。
区别:
- Flutter 跨平台效率高,UI 一致性强。
- 原生 Android 对平台能力、系统 API 和性能细节控制更直接。
- Flutter 访问相机、定位、蓝牙等平台能力时,需要通过插件或 Platform Channel 调用原生代码。
加分点:如果团队主要追求多端一致 UI 和快速迭代,Flutter 有优势;如果需要深度使用 Android 系统能力,原生更稳。
7. KMM 主要共享什么?和 Flutter 最大区别是什么?
考察点:Kotlin Multiplatform 基础
完整回答:
KMM 通常共享业务逻辑、数据层、网络层、算法和工具代码,Android 和 iOS 仍然分别使用各自原生 UI。
和 Flutter 的最大区别:
- Flutter 倾向于 UI 和业务都跨平台。
- KMM 倾向于共享非 UI 逻辑,UI 仍然保持原生。
因此 KMM 更适合希望保留原生体验,同时减少业务逻辑重复开发的团队。
Android / 系统设计
1. 设计一个图片加载框架(类似 Glide)
考察点:缓存设计、线程调度、生命周期管理
完整回答:
需求:从网络/本地加载图片到 ImageView,支持缓存、变换、生命周期感知。
核心架构:
ImageLoader.with(context).load(url).transform(圆角).into(imageView)三级缓存:
- 活动资源缓存(WeakReference,正在使用的图片)
- 内存缓存(LruCache,最大堆内存的 1/8)
- 磁盘缓存(DiskLruCache,原始图 + 变换后的图)
缓存 key = url + 目标尺寸 + 变换参数 的 hash。
加载流程:
- 生成缓存 key
- 查活动资源 → 查内存缓存 → 查磁盘缓存 → 网络请求
- 网络下载 → 写磁盘 → 解码(inSampleSize 采样)→ 变换 → 写内存 → 显示
生命周期管理:注入空 Fragment 监听 Activity/Fragment 生命周期。onStop 暂停,onDestroy 取消请求并释放资源。
防止图片错位:ImageView 设置 tag 为当前 url,加载完成后检查 tag 是否匹配。
线程调度:网络/磁盘 IO 在 IO 线程池,解码在 CPU 线程池,显示切回主线程。
追问:怎么避免 OOM?
- inSampleSize 采样:根据 ImageView 尺寸计算采样率
- RGB_565 替代 ARGB_8888(省一半内存)
- inBitmap 复用 Bitmap 内存
- LruCache 控制内存缓存上限
- 大图使用 BitmapRegionDecoder 局部加载
2. 设计一个消息推送系统
考察点:长连接、可靠性、省电
完整回答:
架构:客户端通过长连接(WebSocket/TCP)与推送网关通信。
连接管理:
- 建立长连接后,定期发送心跳保活
- 心跳间隔自适应:WiFi 5分钟,移动网络 2分钟
- 连接断开后指数退避重连(1s → 2s → 4s → 8s,上限 5分钟)
消息可靠性:
- 服务端发送消息带 msgId
- 客户端收到后发送 ACK
- 服务端超时未收到 ACK 则重发
- 客户端用 msgId 去重,避免重复处理
离线消息:
- 客户端上线后发送最后收到的 msgId
- 服务端返回之后的所有消息(分页拉取)
省电优化:
- 合并心跳与数据传输
- 适配 Doze 模式(使用高优先级 FCM 唤醒)
- 后台时降低心跳频率
追问:心跳间隔怎么确定?
NAT 超时时间决定了心跳间隔上限。不同运营商/网络环境 NAT 超时不同(通常 1-5 分钟)。可以用二分法探测:从大间隔开始,连接断开则缩短,逐步找到最优间隔。
3. 设计一个路由框架(类似 ARouter)
考察点:组件化通信、编译期处理
完整回答:
核心功能:通过 path 跳转页面,支持参数传递、拦截器、降级。
路由表生成:
- 定义
@Route(path = "/user/detail")注解 - APT 编译期扫描注解,为每个模块生成路由表类
- 应用启动时加载所有路由表到内存 HashMap
跳转流程:
Router.navigate("/user/detail?id=123")
→ 解析 path 和参数
→ 执行拦截器链(登录检查 → 权限验证 → ...)
→ 查路由表找到目标 Class
→ 构建 Intent,设置参数
→ startActivity拦截器:责任链模式,每个拦截器可以放行、拦截或重定向。
降级策略:
- 路由表中找不到 → 打开 H5 兜底页
- 服务端下发路由映射表,支持动态路由
追问:怎么实现跨模块的服务调用(不是页面跳转)?
接口下沉 + SPI:公共层定义接口,业务模块实现并注册。通过 Router.getService(IUserService::class) 获取实现。注册方式可以用 APT 自动扫描 @Service 注解。
4. 设计一个埋点系统
考察点:数据采集、性能、可靠性
完整回答:
采集层:
- 页面浏览:Lifecycle 自动监听 onResume/onPause
- 点击事件:AOP(ASM 字节码插桩)自动采集 onClick
- 曝光事件:监听 RecyclerView 滚动,计算 item 可见面积 >50% 且停留 >500ms
- 自定义事件:手动调用
Tracker.track("event_name", params)
处理层:
- 统一数据格式(事件名、参数、时间戳、设备信息、用户ID)
- 本地缓存到 SQLite(保证崩溃不丢数据)
上报层:
- 批量上报:每 30 秒或累积 50 条触发上报
- 实时上报:关键事件(支付、崩溃)立即发送
- 失败重试:指数退避,最多 3 次
- 网络恢复后补报缓存数据
性能保证:
- 采集在主线程(最小操作),格式化和上报在后台线程
- 上报使用 gzip 压缩,减少流量
追问:曝光去重怎么做?
维护一个 Set 记录当前页面已曝光的 item ID。同一个 item 在同一页面生命周期内只上报一次。页面销毁时清空 Set。
5. 设计一个日志系统
考察点:高性能写入、文件管理
完整回答:
API 设计:
Logger.d(TAG, "debug message")
Logger.e(TAG, "error", exception)分级:VERBOSE < DEBUG < INFO < WARN < ERROR。Release 包只记录 INFO 以上。
写入方案:mmap 内存映射文件。写入内存页即完成,OS 异步刷盘。即使崩溃数据也不丢失。比 FileOutputStream 快 10 倍以上。
文件管理:
- 按天分文件:
log_2024-01-15.log - 单文件上限 10MB,超过则新建
- 保留最近 7 天,定期清理
- 上报前 gzip 压缩(压缩率约 80%)
上报策略:
- ERROR 级别实时上报
- 其他级别定期上报或用户反馈时上报
- 支持服务端动态调整日志级别(线上问题排查时临时开启 DEBUG)
追问:为什么用 mmap 而不是直接写文件?
直接写文件(FileOutputStream)每次 write 都是系统调用,且需要 flush/fsync 保证数据落盘。mmap 将文件映射到内存,写入就是内存操作,OS 负责异步刷盘。崩溃时 OS 也会将脏页刷盘,数据不丢失。
6. 设计一个网络层框架
考察点:网络架构设计
完整回答:
核心架构:基于 OkHttp 封装,拦截器链模式。
拦截器设计:
- 日志拦截器:记录请求/响应信息
- 认证拦截器:自动添加 Token,401 时自动刷新 Token 并重试
- 缓存拦截器:自定义缓存策略(强缓存 + 协商缓存)
- 重试拦截器:指数退避重试,只对幂等请求(GET)重试
- 监控拦截器:统计请求耗时、成功率、错误码分布
Token 刷新:
- 401 时 synchronized 加锁刷新 Token
- 双重检查:进入锁后先检查 Token 是否已被其他线程刷新
- 刷新成功后用新 Token 重试原请求
- 刷新失败则跳转登录页
错误处理:
- 网络异常 → 检查本地缓存 → 有则返回缓存数据 + 标记为缓存
- 服务端错误 → 统一错误码映射 → 展示友好提示
- 超时 → 重试(GET)或提示用户
追问:怎么做网络监控?
通过 OkHttp 的 EventListener 监听请求各阶段耗时:DNS 解析、TCP 连接、TLS 握手、请求发送、响应接收。聚合统计后上报,用于发现慢请求和网络质量问题。
实习系统设计题通常不会要求完整大厂方案,更看重你能否拆模块、讲流程、考虑缓存和异常。
7. 设计一个简单图片加载器,你会怎么做?
考察点:模块拆分、缓存意识
完整回答:
可以按以下模块拆:
- 接口层:提供
load(url).into(imageView)这样的调用方式。 - 内存缓存:使用 LruCache 缓存 Bitmap,避免重复解码和请求。
- 磁盘缓存:缓存下载后的图片文件,减少网络请求。
- 网络加载:通过 OkHttp 下载图片。
- 解码压缩:根据 ImageView 尺寸采样,避免加载过大图片导致 OOM。
- 线程切换:下载和解码在子线程,最终回到主线程设置图片。
追问:列表中图片错位怎么办?
RecyclerView item 复用时,ImageView 可能已经绑定了新的 URL。设置图片前要校验当前 ImageView 绑定的 URL 是否还是请求发起时的 URL。
8. Feed 列表分页加载怎么设计?
考察点:列表分页、异常处理
完整回答:
基础方案:
- 首次进入页面请求第一页数据。
- 滑动到底部附近触发加载下一页。
- 使用
page/pageSize或cursor作为分页参数。 - Adapter 增量追加数据,避免全量刷新。
- 页面维护 loading、empty、error、content 状态。
异常处理:
- 首次加载失败显示重试页。
- 下一页加载失败显示底部重试。
- 防止重复请求,加载中不再次触发。
- 下拉刷新时清空旧分页状态,重新请求第一页。
加分点:可以结合本地缓存,让弱网下先展示缓存数据,再刷新网络数据。
Android / 项目深度面试表达
1. 介绍一个你做过的最有技术深度的项目
考察点:项目经验深度、技术方案能力
回答模板:
"我负责过 xxx 项目的 xxx 模块。
背景:(一句话说清楚项目和问题) 我们的 App 冷启动时间达到 3.5 秒,远超行业标准的 2 秒,用户反馈启动慢,启动阶段的用户流失率达到 12%。
我的任务:作为性能优化负责人,目标是将冷启动时间降到 2 秒以内。
我做了什么:
- 首先用 Systrace 分析启动全链路,定位到三个主要瓶颈:Application 中 15 个 SDK 串行初始化(1.8s)、6 个 ContentProvider(300ms)、首页布局复杂(400ms)
- 设计了基于 DAG 的任务编排框架,将 SDK 初始化拆分为有依赖关系的任务图。无依赖的任务并行执行,有依赖的等前置完成后执行。非必要 SDK 延迟到首帧后通过 IdleHandler 初始化
- 用 App Startup 合并 6 个 ContentProvider 为 1 个
- 首页布局优化:ViewStub 延迟加载非首屏 Tab,异步 inflate 复杂子布局
结果:冷启动时间从 3.5s 降到 1.8s,降幅 48%。启动流失率从 12% 降到 7%。任务编排框架被其他业务线复用。"
追问应对:
- "任务编排框架怎么处理循环依赖?" → 拓扑排序时检测环,编译期报错
- "怎么衡量优化效果?" → 线上埋点统计 P50/P90/P99 启动时间,AB 实验对比
- "还有什么可以继续优化的?" → 类预加载、布局预编译、Baseline Profile
2. 你遇到过最难排查的线上问题是什么?
考察点:问题排查能力
回答模板:
"线上出现一个偶发的 ANR 问题,影响约 0.1% 的用户,但集中在低端机上。
排查过程:
- 从监控平台拉取 ANR 的 traces.txt,发现主线程堆栈停在
QueuedWork.waitToFinish() - 分析源码发现这是 SharedPreferences 的 apply 机制——apply 是异步的,但 Activity onStop 时会同步等待所有 apply 完成
- 进一步排查发现,我们的埋点模块在每次页面切换时都会 SP.apply 写入数据,低端机磁盘 IO 慢,导致 onStop 时等待时间过长
解决方案:
- 将埋点模块的 SP 替换为 MMKV(mmap 写入,不阻塞主线程)
- 其他高频写入的 SP 也逐步迁移到 MMKV
- 建立主线程 IO 检测机制:StrictMode 在 Debug 包开启,自定义 Lint 规则检查主线程 SP 调用
结果:ANR 率从 0.15% 降到 0.03%,低端机 ANR 率下降 85%。"
3. 你怎么做技术选型?举个例子
考察点:技术判断力
回答模板:
"以我们选择状态管理方案为例。
背景:项目从 Java 迁移到 Kotlin,需要选择新的架构模式和状态管理方案。
候选方案:
- MVVM + 多个 LiveData:简单直接,但状态分散,复杂页面难以管理
- MVI + 单一 StateFlow:状态集中,单向数据流可预测,但模板代码多
- MVVM + 部分 MVI 思想:简单页面用多个 StateFlow,复杂页面用单一 State
评估维度:
- 团队学习成本:团队熟悉 MVVM,纯 MVI 学习曲线陡
- 代码复杂度:纯 MVI 对简单页面过度设计
- 可维护性:单一 State 在复杂页面更易维护和调试
最终选择:方案 3。制定了规范:3 个以下状态的页面用独立 StateFlow,3 个以上用单一 State + sealed Intent。
结果:团队适应快,代码一致性好,新人上手也容易理解。"
4. 你怎么保证代码质量?
考察点:工程化意识
完整回答:
"我从四个层面保证代码质量:
编码规范:团队统一的 Kotlin 编码规范,用 ktlint + Detekt 自动检查。自定义了几条项目特定的 Lint 规则(如禁止主线程 SP 操作、禁止直接 new Thread)。
Code Review:所有 PR 必须至少一人审核通过。Review 重点关注:架构合理性、边界条件处理、性能隐患、线程安全。
自动化测试:ViewModel 和 Repository 层单元测试覆盖率 >70%。关键业务流程有 UI 自动化测试。CI 上每次 PR 自动运行测试。
线上监控:Crash 率 <0.1%、ANR 率 <0.3% 作为质量红线。每周 review 线上问题,分析根因并制定防护措施。
效果:半年内 Crash 率从 0.2% 降到 0.06%,线上严重 bug 数量减少 60%。"
5. 你对技术的未来规划是什么?
考察点:成长意识、技术热情
回答模板:
"短期(半年):深入 Compose 和 KMM。Compose 是 Android UI 的未来方向,我已经在项目中用 Compose 重写了部分页面,接下来想深入理解 Compose 编译器和运行时原理。KMM 在跨平台逻辑共享方面很有潜力,想在项目中落地实践。
中期(1-2年):从单点技术深入到系统性的架构能力。包括大型项目的模块化架构设计、性能优化体系建设、技术债务治理等。
长期:希望能在技术深度和技术影响力上都有提升。通过技术分享、开源贡献来扩大影响力,同时也愿意承担更多的技术管理职责。"
6. 为什么离开上一家公司?
考察点:职业动机
回答原则:
- 正面表达,不说前公司坏话
- 聚焦于成长和发展
模板:
"在上一家公司的 X 年里,我从 xxx 成长到 xxx,收获很大。离开主要是因为:
- 技术方向:我希望在 xxx 方向深入发展,而当前团队的技术栈和业务方向与我的规划有一定差距
- 成长空间:当前的角色已经比较稳定,我希望能接触更大规模/更有挑战的项目
选择贵公司是因为:xxx 业务的技术挑战很吸引我,团队的技术氛围和成长空间也是我看重的。"
7. 你有什么想问我的?
考察点:对岗位的思考深度
好的反问:
- "团队目前面临的最大技术挑战是什么?我能在哪些方面贡献价值?"
- "这个岗位未来半年的核心目标和期望产出是什么?"
- "团队的技术栈演进方向?比如 Compose 和 KMM 的落地计划?"
- "团队的协作方式是怎样的?Code Review 流程?"
- "新人入职后的 onboarding 流程和成长路径?"
避免的反问:
- 直接问加班(可以问"团队的工作节奏和迭代周期")
- 直接问薪资(HR 面谈)
- 过于基础的问题(显得没做功课)
8. 你觉得自己的优势和不足是什么?
回答模板:
"优势:
- 技术深度:对 Android Framework 层有较深的理解,能从源码层面分析和解决问题
- 问题解决:擅长排查复杂的线上问题,有系统性的分析方法论
- 工程意识:注重代码质量和工程化,推动过团队的 CI/CD 和质量体系建设
不足: 跨平台技术(Flutter/KMM)的实战经验还不够深入,目前在学习和实践中。我的计划是在接下来的项目中找机会落地,通过实际项目来加深理解。"
注意:不足要说真实的,但要是可以改进的,并且要说明你的改进计划。
实习面试的项目追问通常比源码追问更重要。不要只说“我用了 Retrofit、Room、MVVM”,要说清楚你负责什么、为什么这么做、遇到什么问题。
9. 实习生如何介绍自己的 Android 项目?
考察点:项目表达、真实性
完整回答:
可以按这个结构介绍:
- 项目背景:这个 App 解决什么问题,主要用户是谁。
- 我的职责:负责哪些页面或模块,不要把团队工作都说成自己做的。
- 技术栈:Kotlin/Java、MVVM、Retrofit、Room、RecyclerView、Compose 等。
- 核心流程:数据从哪里来,怎么请求、缓存、展示。
- 遇到的问题:至少准备 2 个真实问题,比如列表卡顿、接口错误处理、图片加载、状态丢失。
- 结果和收获:功能完成情况、性能改善、代码结构优化、学到了什么。
示例回答:
“我的项目是一个课程/记账/新闻类 App,我主要负责首页列表、详情页和登录状态保存。项目使用 MVVM 分层,ViewModel 负责页面状态,Repository 统一处理网络和本地缓存。首页列表用 RecyclerView 展示,网络层用 Retrofit 请求接口,登录 token 保存在 DataStore。开发中遇到过列表刷新闪烁的问题,后来改成 DiffUtil 做局部刷新,并把图片加载交给 Glide 处理,滚动流畅度明显更好。”
10. 项目中没有很复杂的难点怎么办?
考察点:问题提炼能力
完整回答:
实习项目不一定要有很复杂的架构难点,可以从真实细节中提炼:
- 列表性能:全量刷新、图片过大、滑动卡顿。
- 状态管理:旋转屏幕数据丢失、重复请求、加载/空/错误状态混乱。
- 网络异常:超时、无网、token 过期、接口字段为空。
- 生命周期:Fragment View 泄漏、页面退出后回调更新 UI。
- 工程规范:封装网络层、统一错误处理、抽取 BaseAdapter。
关键是讲出“问题现象 → 排查过程 → 解决方案 → 为什么有效”。
11. 面试最后反问可以问什么?
考察点:职业意识、沟通能力
完整回答:
适合实习生反问的问题:
- 团队 Android 技术栈主要是 Kotlin 还是 Java?是否使用 Compose?
- 实习生入组后通常会负责什么类型的任务?
- 团队对新人会有代码 Review 或导师机制吗?
- 这个岗位更看重 Android 基础、项目能力还是算法能力?
- 如果有幸加入,我入职前最应该补强哪一块?
避免一上来只问薪资、加班、转正概率。可以等 HR 面或合适时机再问。
Android / LLM 与 AI-Coding
1. 简单解释一下 Transformer 的工作原理
回答:
Transformer 的核心是 Self-Attention(自注意力)机制。处理一个序列时,每个 Token 都会去"关注"序列中的其他 Token,计算它们之间的相关度,然后根据相关度加权融合信息。
具体来说,每个 Token 生成 Q(Query)、K(Key)、V(Value)三个向量。Q 和 K 做点积得到注意力分数,经过 Softmax 归一化后作为权重,对 V 加权求和得到输出。
相比 RNN 的串行处理,Transformer 可以完全并行计算,而且每个 Token 都能直接关注到任意距离的其他 Token,解决了长距离依赖问题。
追问:GPT 和 BERT 的区别?
GPT 只用 Decoder,用 Masked Attention(只能看前面的 Token),适合生成任务。BERT 用 Encoder,用双向 Attention(能看前后),适合理解任务(分类、抽取)。现在的 LLM(ChatGPT、Claude)都是 GPT 类的 Decoder-only 架构。
2. LLM 是怎么生成代码的?
回答:
LLM 生成代码和生成自然语言的原理完全一样——自回归地一个 Token 一个 Token 生成。
模型在预训练阶段学习了大量代码(GitHub 上的开源代码),学会了代码的语法、模式和逻辑。当你给它一个 Prompt(比如"写一个 Kotlin 单例"),它根据训练中学到的模式,逐步预测下一个 Token,直到生成完整的代码。
代码补全工具(如 Copilot)用的是 Fill-in-the-Middle(FIM)技术,模型同时看到光标前后的代码,预测中间应该填什么,所以补全的代码能和上下文完美衔接。
追问:为什么有时候生成的代码有错误?
因为模型是基于概率预测的,它选择的是"最可能的下一个 Token",不是"一定正确的 Token"。它不会真正执行代码或做类型检查。所以可能生成语法正确但逻辑错误的代码,或者调用不存在的 API(幻觉)。这就是为什么 AI 生成的代码需要人工审查。
3. 什么是 RAG?在 AI Coding 中怎么用?
回答:
RAG(Retrieval-Augmented Generation,检索增强生成)是先检索相关信息,再让模型基于这些信息生成回答。
在 AI Coding 中的应用:模型不知道你的项目代码,但通过 RAG,工具可以先在你的代码库中检索相关文件和函数,把它们作为上下文注入 Prompt,模型就能基于你的实际代码给出准确的建议。
实现方式:
- 离线阶段:把项目代码切分为片段,用 Embedding 模型转为向量,存入向量数据库
- 在线阶段:用户提问 → 问题转为向量 → 在向量数据库中找最相似的代码片段 → 注入 Prompt → 模型生成回答
追问:Embedding 是什么?
Embedding 是把文本/代码映射为固定长度的数值向量。语义相似的内容,向量也相似。比如 getUser(id) 和 fetchUser(userId) 的向量很接近,但和 deleteAll() 的向量差距很大。通过计算向量的余弦相似度,就能找到语义最相关的代码片段。
4. AI Agent 和普通的 AI 对话有什么区别?
回答:
普通对话是一问一答:你问一个问题,模型回答一次,结束。
Agent 是一个循环:模型可以自主规划多个步骤,使用工具(读文件、编辑代码、运行命令、搜索),根据每一步的结果决定下一步做什么,直到任务完成。
核心区别在于 Agent 有"工具使用"能力(Function Calling)。模型不是直接输出文本,而是输出结构化的工具调用请求(比如 {"tool": "read_file", "path": "xxx"}),系统执行后把结果返回给模型,模型再决定下一步。
追问:Function Calling 是怎么实现的?
模型在训练时学会了一种特殊的输出格式。System Prompt 中定义了可用的工具列表(名称、参数、描述),模型根据当前任务判断需要调用哪个工具,输出 JSON 格式的调用请求。这不是模型真的在"执行"工具,而是模型生成了一段结构化文本,由外部系统解析并执行。
5. 什么是 MCP?
回答:
MCP(Model Context Protocol)是 Anthropic 提出的标准化协议,用于 AI 模型和外部工具之间的通信。
之前每个 AI 工具都要自己实现和各种外部系统的对接(Git、数据库、Jira 等),重复造轮子。MCP 定义了一个标准协议,工具提供者实现 MCP Server,AI 工具支持 MCP 协议,就能互相对接。
类似于 USB 协议:有了统一标准,任何设备都能连接任何电脑,不需要每个设备配专用接口。
追问:MCP 和 Function Calling 的关系?
Function Calling 是模型层面的能力——模型能输出工具调用请求。MCP 是通信协议层面的标准——定义了 AI 工具和外部服务之间怎么交互。Function Calling 决定"模型要调用什么工具",MCP 决定"这个调用怎么传递给工具提供者"。
6. LLM 的训练分哪几个阶段?
回答:
三个阶段:
预训练:在海量文本(万亿 Token)上训练,学习语言的通用知识。任务是预测下一个 Token。训练完后模型是一个"补全机器",能力很强但不会对话。
SFT(监督微调):用人工标注的对话数据(几万到几十万条)训练,让模型学会对话格式。训练完后模型知道什么时候该回答、用什么格式回答。
RLHF(人类反馈强化学习):让模型生成多个回答,人类标注哪个更好,训练一个奖励模型,再用强化学习优化 LLM。让模型的回答更有帮助、更安全。
追问:为什么预训练需要那么多数据?
因为预训练的目标是让模型学会"世界知识"——语法、常识、逻辑推理、代码模式等。这些知识分布在海量文本中,需要足够多的数据才能覆盖。而且模型参数越多(175B+),需要的数据也越多,否则会欠拟合。
7. Temperature 和 Top-p 是什么?
回答:
两者都是控制模型输出随机性的参数。
Temperature:调节概率分布的"尖锐程度"。T=0 时永远选概率最高的 Token(确定性输出);T 越高,低概率 Token 被选中的机会越大(更随机、更有创意)。写代码通常用低 Temperature(0-0.3),写创意文案用高 Temperature(0.7-1.0)。
Top-p(核采样):只从累积概率达到 p 的 Token 中采样。p=0.9 表示从概率最高的那些 Token(累积概率 90%)中随机选,排除了概率极低的 Token,避免生成离谱的内容。
追问:代码生成应该用什么参数?
代码生成通常用低 Temperature(0-0.2)+ Top-p 0.9。因为代码需要确定性和正确性,不需要太多"创意"。但如果是探索性的代码生成(比如"给我几种不同的实现方案"),可以适当提高 Temperature。
8. 端侧大模型(On-Device LLM)有什么挑战?
回答:
主要挑战是手机的硬件限制:
内存:7B 模型 FP16 需要 14GB,手机总共才 8-16GB。解决方案是量化——把 FP16 压缩到 INT4,7B 模型只需要 3.5GB。
算力:手机 GPU/NPU 远弱于服务器 GPU,推理速度慢。解决方案是用更小的模型(1B-3B)+ 硬件加速(NPU)。
功耗:持续推理会快速耗电发热。需要控制推理频率和模型大小。
目前端侧适合轻量级任务(文本分类、简单问答、输入建议),复杂的代码生成还是需要云端大模型。
追问:Android 上怎么集成端侧模型?
可以用 Google 的 MediaPipe LLM Inference API 或 TensorFlow Lite,加载量化后的模型(如 Gemma 2B INT4)。通过 NNAPI 调用 GPU/NPU 加速推理。也可以用 llama.cpp 的 Android 移植版本运行 LLaMA 系列模型。
9. 如何更好地使用 AI Coding 工具?
回答:
几个关键原则:
提供充足的上下文:不要只说"写个网络请求",要说明技术栈、参数、返回类型、错误处理方式、参考已有代码的风格。
分步骤提问:复杂任务拆成小步骤,每步确认后再继续,而不是一次性要求生成整个功能。
给示例(Few-shot):给模型一两个你项目中已有的代码示例,让它模仿风格。
审查生成的代码:AI 生成的代码可能有逻辑错误、安全漏洞、性能问题。不要盲目接受,要理解每一行代码。
理解局限性:模型不知道你的运行时状态、不会执行代码、可能产生幻觉(编造不存在的 API)。把它当作一个很强的助手,而不是万能的。
10. Prompt Engineering 有哪些实用技巧?
回答:
明确角色:在 Prompt 开头定义模型的角色,比如"你是一个资深 Android 开发者"。
结构化需求:用列表或编号明确列出需求,而不是一段模糊的描述。
Chain-of-Thought:让模型"一步步思考",先分析问题再给方案,比直接要答案更准确。
约束条件:明确告诉模型不要做什么,比如"不要使用已废弃的 API"、"不要添加额外的依赖"。
迭代优化:第一次生成不满意,不要重新开始,而是在对话中追加修改要求,利用上下文逐步完善。
追问:为什么 Chain-of-Thought 能提升效果?
因为 LLM 是自回归生成的,每个 Token 的生成依赖于前面的 Token。当模型先输出分析过程时,这些中间推理步骤成为后续生成的上下文,帮助模型做出更准确的判断。相当于给模型提供了"草稿纸"来思考,而不是要求它直接给出最终答案。
AI Coding 对实习面试通常是加分项。重点不是“会不会让 AI 写代码”,而是能否用它提高效率,并对结果负责。
11. 用 AI 辅助写代码时,怎么保证结果可靠?
考察点:工具使用、工程责任
完整回答:
AI 生成的代码不能直接无脑合入,需要自己校验:
- 检查 API 是否真实存在,是否已废弃。
- 检查线程、生命周期、空指针、异常处理等 Android 常见风险。
- 运行项目和测试,确认功能正确。
- 对关键逻辑自己重新理解一遍,不能只会复制。
- 对涉及安全、支付、隐私、加密的代码尤其谨慎。
加分点:可以让 AI 解释报错、生成样板代码、补测试用例,但最终代码质量由开发者负责。
12. 实习项目中适合让 AI 帮你做什么?
考察点:效率意识
完整回答:
适合的场景:
- 解释陌生代码和报错日志。
- 生成 Retrofit 接口、Room Entity、Adapter 等样板代码。
- 帮忙整理 README、接口文档、测试用例。
- 给出排查思路,比如 ANR、Crash、网络失败。
- 辅助学习新 API,但需要再查官方文档确认。
不适合完全交给 AI 的场景:
- 不理解就直接提交核心业务代码。
- 让 AI 编造接口字段、业务规则或线上结论。
- 不做运行验证就合并代码。
