Skip to content

Android 面经知识汇总

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

Java

基础

泛型

类型擦除原理

Java 泛型是编译期特性,编译后泛型信息被擦除:

  • List<String>List<Integer> 编译后都是 List
  • 泛型类型参数被替换为上界(无界则为 Object

证明类型擦除

java
List<String> strList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
// 运行时 class 相同
System.out.println(strList.getClass() == intList.getClass()); // true

// 通过反射绕过泛型检查
List<Integer> list = new ArrayList<>();
list.getClass().getMethod("add", Object.class).invoke(list, "hello");
System.out.println(list.get(0)); // "hello" —— Integer 列表里放了 String!
泛型边界
java
// 上界通配符:只能读,不能写(不知道具体是什么子类型)
List<? extends Number> producer = new ArrayList<Integer>();
Number n = producer.get(0);  // ✅ 可以读
// producer.add(1);           // ❌ 编译错误

// 下界通配符:可以写,读只能得到 Object
List<? super Integer> consumer = new ArrayList<Number>();
consumer.add(1);              // ✅ 可以写 Integer
Object obj = consumer.get(0); // 只能当 Object 读
PECS 原则

Producer Extends, Consumer Super

  • 如果你只从集合中读取数据(生产者),用 <? extends T>
  • 如果你只向集合中写入数据(消费者),用 <? super T>
  • 如果既读又写,不用通配符
java
// Collections.copy 的签名完美体现 PECS
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (int i = 0; i < src.size(); i++)
        dest.set(i, src.get(i)); // src 是生产者(extends),dest 是消费者(super)
}
TypeToken 与 Gson

由于类型擦除,运行时无法获取 List<User> 的泛型参数。Gson 通过匿名内部类保留泛型信息:

java
// 匿名内部类的父类泛型信息保存在 Class 文件的 Signature 属性中
Type type = new TypeToken<List<User>>(){}.getType();
List<User> users = gson.fromJson(json, type);

原理:匿名内部类 new TypeToken<List<User>>(){} 编译后会在字节码中保留 List<User> 这个泛型签名(因为它是类定义的一部分,不是方法调用的一部分)。

Kotlin reified

Kotlin 内联函数配合 reified 可以在运行时获取泛型类型:

kotlin
inline fun <reified T> Gson.fromJson(json: String): T {
    return fromJson(json, T::class.java) // 可以直接用 T::class
}

// 使用
val user = gson.fromJson<User>(jsonString)

原理:inline 函数在调用处内联展开,编译器直接将 T 替换为实际类型,所以不存在擦除问题。

反射与注解

反射 API
java
// 获取 Class 对象的三种方式
Class<?> clazz1 = Class.forName("com.example.User");
Class<?> clazz2 = User.class;
Class<?> clazz3 = user.getClass();

// 创建实例
Object obj = clazz1.getDeclaredConstructor().newInstance();

// 访问私有字段
Field field = clazz1.getDeclaredField("name");
field.setAccessible(true); // 突破 private 限制
field.set(obj, "张三");

// 调用方法
Method method = clazz1.getDeclaredMethod("getName");
method.setAccessible(true);
String name = (String) method.invoke(obj);
反射性能开销

反射比直接调用慢 5-50 倍,原因:

  1. 安全检查:每次 invoke 都要检查访问权限
  2. 无法 JIT 优化:编译器无法内联反射调用
  3. 参数装箱:基本类型需要装箱为 Object[]
  4. 方法查找:需要遍历方法表

优化手段:

  • setAccessible(true) 跳过安全检查(提升约 4 倍)
  • 缓存 Method/Field 对象,避免重复查找
  • 使用 MethodHandle(JDK 7+)替代传统反射
注解保留策略
java
@Retention(RetentionPolicy.SOURCE)  // 编译后丢弃,如 @Override
@Retention(RetentionPolicy.CLASS)   // 保留在字节码,运行时不可见,如 @NonNull
@Retention(RetentionPolicy.RUNTIME) // 运行时可通过反射获取,如 @Deprecated
APT 编译期注解处理

APT(Annotation Processing Tool)在编译期扫描注解并生成代码:

java
@AutoService(Processor.class)
public class MyProcessor extends AbstractProcessor {
    @Override
    public boolean process(Set<? extends TypeElement> annotations,
                          RoundEnvironment roundEnv) {
        for (Element element : roundEnv.getElementsAnnotatedWith(MyAnnotation.class)) {
            // 生成代码
            JavaFile javaFile = JavaFile.builder(packageName, typeSpec).build();
            javaFile.writeTo(processingEnv.getFiler());
        }
        return true;
    }
}

Android 中的应用:

  • ButterKnife@BindView → 生成 _ViewBinding
  • Dagger/Hilt@Inject → 生成依赖注入工厂
  • Room@Dao → 生成 SQL 实现类
  • Glide@GlideModule → 生成 GeneratedAppGlideModule

JVM 内存模型

运行时数据区
┌─────────────────────────────────────────┐
│              JVM 运行时数据区              │
├──────────────┬──────────────────────────┤
│  线程私有     │  线程共享                  │
├──────────────┼──────────────────────────┤
│ 程序计数器    │  堆(Heap)               │
│ 虚拟机栈     │   - 新生代(Eden+S0+S1)    │
│ 本地方法栈   │   - 老年代                 │
│              │  方法区(元空间)           │
│              │   - 类信息、常量池、静态变量 │
└──────────────┴──────────────────────────┘

各区域详解

区域存储内容异常
程序计数器当前线程执行的字节码行号唯一不会 OOM 的区域
虚拟机栈栈帧(局部变量表、操作数栈、动态链接、返回地址)StackOverflowError / OOM
本地方法栈Native 方法调用StackOverflowError / OOM
对象实例、数组OutOfMemoryError
方法区类信息、常量池、静态变量OutOfMemoryError
对象创建过程
  1. 类加载检查:检查 new 指令的参数能否在常量池中定位到类的符号引用,且该类已被加载
  2. 分配内存
    • 指针碰撞(Bump the Pointer):堆内存规整时,移动指针分配
    • 空闲列表(Free List):堆内存不规整时,从列表中找合适的块
    • 线程安全:TLAB(Thread Local Allocation Buffer)每个线程预分配一小块
  3. 初始化零值:所有字段设为默认值(0/null/false)
  4. 设置对象头:Mark Word + 类型指针
  5. 执行 <init>:调用构造方法
对象头 Mark Word

在 64 位 JVM 中,Mark Word 占 8 字节:

无锁状态:  [hashCode(31) | age(4) | biased(1) | lock(2)] = 01
偏向锁:    [threadId(54) | epoch(2) | age(4) | biased(1) | lock(2)] = 01
轻量级锁:  [指向栈中锁记录的指针(62) | lock(2)] = 00
重量级锁:  [指向 Monitor 的指针(62) | lock(2)] = 10
GC 标记:   [空(62) | lock(2)] = 11
四种引用类型
java
// 强引用:不会被 GC 回收,宁可 OOM
Object obj = new Object();

// 软引用:内存不足时回收,适合做缓存
SoftReference<Bitmap> softRef = new SoftReference<>(bitmap);
Bitmap b = softRef.get(); // 可能为 null

// 弱引用:下次 GC 就回收,不论内存是否充足
WeakReference<Activity> weakRef = new WeakReference<>(activity);
// 常用于避免内存泄漏,如 Handler 持有 Activity

// 虚引用:无法通过虚引用获取对象,仅用于回收通知
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
phantomRef.get(); // 永远返回 null

Android 中的应用

  • WeakReference:Handler 防泄漏、WeakHashMap 缓存
  • SoftReference:图片内存缓存(但现在更推荐 LruCache)

GC 算法与收集器

三种基础算法

标记-清除(Mark-Sweep)

  • 标记所有存活对象,清除未标记的
  • 缺点:内存碎片

标记-复制(Mark-Copy)

  • 将存活对象复制到另一块区域
  • 新生代使用:Eden:S0:S1 = 8:1:1
  • 优点:无碎片;缺点:浪费一半空间(实际只浪费 10%,因为 8:1:1)

标记-整理(Mark-Compact)

  • 标记后将存活对象向一端移动
  • 老年代使用
  • 优点:无碎片;缺点:移动对象开销大
新生代 GC 流程
1. 新对象分配到 Eden
2. Eden 满 → Minor GC
3. 存活对象复制到 S0,年龄+1
4. 下次 Minor GC:Eden + S0 存活对象 → S1,年龄+1
5. S0 和 S1 交替使用
6. 年龄达到阈值(默认15)→ 晋升老年代
7. 老年代满 → Major GC / Full GC
CMS 收集器

以最短停顿时间为目标的老年代收集器:

  1. 初始标记(STW):标记 GC Roots 直接关联的对象,速度快
  2. 并发标记:从 GC Roots 遍历整个对象图,与用户线程并发
  3. 重新标记(STW):修正并发标记期间变动的对象
  4. 并发清除:清除未标记对象,与用户线程并发

缺点:

  • CPU 敏感(并发阶段占用线程)
  • 浮动垃圾(并发清除阶段产生的新垃圾要下次才能清理)
  • 内存碎片(标记-清除算法)
G1 收集器

面向服务端的收集器,可预测停顿:

  • 将堆划分为多个大小相等的 Region(1-32MB)
  • 每个 Region 可以是 Eden/Survivor/Old/Humongous
  • 维护每个 Region 的回收价值(垃圾占比),优先回收价值高的(Garbage First)
  • Mixed GC:同时回收新生代和部分老年代 Region
Android ART GC

Android Runtime 使用 CC(Concurrent Copying)收集器

  • 并发复制:GC 线程与应用线程并发执行
  • 读屏障(Read Barrier):确保应用线程读到的是最新的对象引用
  • 分代收集:年轻代用 Sticky CMS,老年代用 CC
  • 压缩:复制算法天然无碎片
  • 相比 Dalvik 的 CMS,ART CC 的停顿时间更短(通常 < 1ms)

类加载机制

双亲委派模型
BootstrapClassLoader(C++ 实现,加载 rt.jar)

ExtClassLoader(加载 ext 目录)

AppClassLoader(加载 classpath)

自定义 ClassLoader
java
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 1. 检查是否已加载
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            // 2. 委托父加载器
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 父加载器无法加载
        }
        if (c == null) {
            // 3. 自己加载
            c = findClass(name);
        }
    }
    return c;
}

为什么要双亲委派?

  • 避免类的重复加载
  • 保证核心类库安全(用户无法自定义 java.lang.String 替换系统类)
类加载五阶段
  1. 加载:读取字节码,生成 Class 对象
  2. 验证:校验字节码格式、语义
  3. 准备:为静态变量分配内存并设零值(static int a = 10 此时 a=0)
  4. 解析:符号引用 → 直接引用
  5. 初始化:执行 <clinit>(静态变量赋值 + 静态代码块)
打破双亲委派

场景一:JDBC SPI

DriverManagerrt.jar 中(BootstrapClassLoader 加载),但具体驱动(如 MySQL Driver)在 classpath 中。Bootstrap 无法加载 classpath 的类,所以用线程上下文类加载器

java
// DriverManager 中
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load 内部使用 Thread.currentThread().getContextClassLoader()

场景二:热修复

Tinker 等热修复框架通过修改 DexPathList 中 dex 文件的顺序,将补丁 dex 插入到原始 dex 之前,利用类加载的"先找到先使用"特性实现类替换。

Android 类加载器
java
// PathClassLoader:加载已安装的 APK(data/app 目录)
// 系统默认使用,不能加载未安装的 dex
PathClassLoader pathCL = new PathClassLoader(apkPath, parent);

// DexClassLoader:可加载任意路径的 dex/jar/apk
// 插件化、热修复的基础
DexClassLoader dexCL = new DexClassLoader(dexPath, optimizedDir, libraryPath, parent);

两者都继承自 BaseDexClassLoader,核心区别在于 optimizedDirectory 参数(API 26+ 已废弃该参数,两者基本等价)。

关联知识

  • HashMap 的线程安全问题 → 引出 ConcurrentHashMap → 引出 CAS/synchronized → 见并发篇
  • 类加载机制 → 引出热修复/插件化 → 见架构篇
  • GC 与引用类型 → 引出内存泄漏检测 → 见性能优化篇
  • 反射与注解 → 引出 APT/字节码插桩 → 见工程化篇

1. HashMap 的 put 流程是怎样的?为什么用红黑树?

考察点:HashMap 底层数据结构、hash 计算、扩容机制

完整回答

HashMap 在 JDK 1.8 中底层是数组 + 链表 + 红黑树。put 流程如下:

  1. 对 key 调用 hash() 方法:(h = key.hashCode()) ^ (h >>> 16),高 16 位异或低 16 位做扰动,减少碰撞
  2. (n-1) & hash 定位桶索引
  3. 如果桶为空,直接创建新节点放入
  4. 如果桶不为空,判断第一个节点的 key 是否相同(hash 相等且 equals 为 true),相同则覆盖 value
  5. 如果是红黑树节点,调用 putTreeVal 插入
  6. 否则遍历链表,尾插法插入新节点。如果链表长度 ≥ 8 且数组长度 ≥ 64,将链表转为红黑树;数组长度 < 64 时优先扩容
  7. 插入后如果 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)。

影响:

  1. 运行时无法获取泛型类型:list instanceof List<String> 编译报错
  2. 不能创建泛型数组:new T[] 不合法
  3. 不能用基本类型作为类型参数:List<int> 不行,必须 List<Integer>
  4. 可以通过反射绕过泛型检查

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

追问:对象创建的过程?

  1. 类加载检查 → 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 不可变的原因:

  1. private final char[] value(JDK 9+ 改为 byte[]),final 修饰且没有提供修改方法
  2. String 类本身是 final 的,不能被继承

不可变的好处:

  • 线程安全:多线程共享无需同步
  • 哈希缓存:hashCode 只需计算一次,HashMap 的 key 常用 String
  • 字符串常量池:相同内容的字符串共享同一对象,节省内存
  • 安全性:网络连接、文件路径等用 String 不会被篡改

三者区别:

特性StringStringBuilderStringBuffer
可变性不可变可变可变
线程安全是(不可变)是(synchronized)
性能拼接慢(每次创建新对象)最快较快
场景少量字符串操作单线程大量拼接多线程大量拼接

追问:String s = new String("abc") 创建了几个对象?

最多 2 个:

  1. 如果常量池中没有 "abc",先在常量池创建一个
  2. 在堆中创建一个 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 不一定相等(哈希碰撞)
java
// ❌ 只重写 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 会比较字符串内容。
java
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?

字符串字面量会进入字符串常量池:

java
String a = "abc";
String b = "abc";
System.out.println(a == b); // true,指向常量池中的同一个对象

加分点:实际开发中比较字符串内容用 equals();为了避免空指针,可以写成 "abc".equals(str)

13. Java 异常分为哪几类?开发中怎么处理?

考察点:异常体系、编码规范

完整回答

Java 异常体系中,Throwable 下主要有两类:

  • Error:严重错误,通常程序无法处理,比如 OutOfMemoryErrorStackOverflowError
  • Exception:程序可以处理的异常。

Exception 又分为:

  • 受检异常:编译期要求处理,比如 IOException
  • 运行时异常:继承自 RuntimeException,比如 NullPointerExceptionIndexOutOfBoundsExceptionClassCastException

开发中不要用空的 catch 吞掉异常,至少要记录日志或转换成业务可理解的错误。

追问:finally 一定会执行吗?

大多数情况下会执行,但如果执行了 System.exit()、进程被杀、虚拟机崩溃,finally 不保证执行。

14. ArrayList 遍历时删除元素为什么容易出问题?

考察点:集合遍历、快速失败机制

完整回答

使用增强 for 遍历 ArrayList 时,本质上使用的是 Iterator。如果遍历过程中直接调用 list.remove() 修改集合,会导致 modCountexpectedModCount 不一致,可能抛出 ConcurrentModificationException

正确做法是使用迭代器的 remove()

java
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if (item.isEmpty()) {
        iterator.remove();
    }
}

加分点:如果只是过滤生成新列表,可以优先使用新集合承接结果,逻辑更清晰。

集合

ArrayList 源码分析

底层结构

ArrayList 底层使用 Object[] 数组存储元素:

java
transient Object[] elementData; // 存储元素的数组
private int size;               // 实际元素个数

elementData 被标记为 transient,这是序列化优化——ArrayList 自定义了 writeObject/readObject,只序列化 size 个有效元素,而非整个数组(数组末尾可能有空位)。

扩容机制(1.5 倍)

add() 发现容量不足时触发 grow()

java
private void grow(int minCapacity) {
    int oldCapacity = elementData.length;
    // 新容量 = 旧容量 + 旧容量/2,即 1.5 倍
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    elementData = Arrays.copyOf(elementData, newCapacity);
}

扩容流程:

  1. 新容量 = 旧容量 × 1.5(位运算 >> 1
  2. 若 1.5 倍仍不够,直接用所需最小容量
  3. Arrays.copyOf 创建新数组并复制数据(O(n) 操作)

性能提示:预知元素数量时用 new ArrayList<>(initialCapacity) 避免多次扩容拷贝。

modCount 快速失败

modCount 记录结构性修改次数。迭代器创建时快照 expectedModCount = modCount,每次 next() 前检查:

java
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

常见坑

java
// ❌ 错误:foreach 中直接 remove 触发 ConcurrentModificationException
for (String s : list) {
    if (s.equals("target")) list.remove(s);
}

// ✅ 正确:使用迭代器的 remove
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().equals("target")) it.remove();
}

// ✅ 正确:Java 8+ removeIf
list.removeIf(s -> s.equals("target"));
ArrayList vs LinkedList
特性ArrayListLinkedList
底层Object[] 数组双向链表 Node
随机访问O(1)O(n)
头部插入O(n)(需移动元素)O(1)
尾部插入均摊 O(1)O(1)
内存紧凑,缓存友好每个节点额外 prev/next 指针

结论:绝大多数场景用 ArrayList,LinkedList 只在频繁头部插入/删除且不需要随机访问时考虑。

LinkedList 源码分析

Node 结构
java
private static class Node<E> {
    E item;
    Node<E> next;
    Node<E> prev;

    Node(Node<E> prev, E element, Node<E> next) {
        this.item = element;
        this.next = next;
        this.prev = prev;
    }
}

维护 firstlast 两个指针,头尾操作 O(1)。

实现的接口

LinkedList 同时实现了 ListDeque 接口:

  • 作为 List:支持索引访问(但 O(n),内部会判断 index 靠前还是靠后来决定从头还是尾遍历)
  • 作为 Deque:addFirst/addLast/pollFirst/pollLast,可当队列或栈使用

HashMap 源码分析(JDK 1.8)

数据结构

数组 + 链表 + 红黑树:

table[0] → null
table[1] → Node → Node → Node(链表)
table[2] → TreeNode(红黑树根)
table[3] → null
...
java
// 链表节点
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;
}
hash() 扰动函数
java
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要扰动? 桶定位用 (n-1) & hash,当 n 较小时只有低位参与运算。将高 16 位异或到低 16 位,让高位信息也参与桶定位,减少碰撞。

put 流程(核心)
java
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // 1. 数组为空则初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;

    // 2. 桶为空,直接放入
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 3. 桶的第一个节点 key 相同,直接覆盖
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 4. 红黑树节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 5. 链表遍历
        else {
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度 >= 8 则尝试树化
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 6. key 已存在,更新 value
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            return oldValue;
        }
    }
    // 7. 超过阈值则扩容
    if (++size > threshold)
        resize();
    return null;
}

put 流程总结

  1. 数组为空 → resize() 初始化(默认容量 16,负载因子 0.75,阈值 12)
  2. 计算桶位置 (n-1) & hash
  3. 桶为空 → 直接放入新节点
  4. 桶非空 → 判断第一个节点 key 是否相同
  5. 不同 → 遍历链表/红黑树查找
  6. 链表尾插,长度 ≥ 8 则 treeifyBin
  7. treeifyBin 内部还会检查数组长度 < 64 时优先扩容而非树化
  8. ++size > threshold 则扩容
resize 扩容
java
// 核心逻辑:容量翻倍,重新分配节点
Node<K,V> loHead = null, loTail = null; // 低位链表(留在原位)
Node<K,V> hiHead = null, hiTail = null; // 高位链表(移到 原位+oldCap)

// 判断依据:hash & oldCap
if ((e.hash & oldCap) == 0) {
    // 低位:留在原索引 i
} else {
    // 高位:移到索引 i + oldCap
}

为什么这样拆分? 扩容后容量翻倍,(newCap-1) & hash(oldCap-1) & hash 多了一个高位 bit。这个 bit 就是 oldCap 的位置。如果 hash & oldCap == 0,说明这个高位是 0,索引不变;否则索引 = 原索引 + oldCap。

树化条件

两个条件同时满足才会树化:

  • 链表长度 ≥ 8(TREEIFY_THRESHOLD
  • 数组长度 ≥ 64(MIN_TREEIFY_CAPACITY

数组长度 < 64 时,优先扩容而非树化,因为扩容能更有效地分散节点。

线程不安全分析
  • JDK 1.7:头插法 + 并发扩容 → 链表成环 → 死循环
  • JDK 1.8:尾插法解决了成环问题,但并发 put 仍可能丢数据(两个线程同时写同一个桶)

ConcurrentHashMap 源码分析

JDK 1.7:分段锁
Segment[] → 每个 Segment 内部是一个小 HashMap
Segment extends ReentrantLock
  • 默认 16 个 Segment,并发度 = 16
  • 读不加锁(volatile 保证可见性),写锁 Segment
  • 缺点:Segment 数量固定,无法动态调整并发度
JDK 1.8:CAS + synchronized

抛弃 Segment,锁粒度细化到桶(数组的每个槽位):

java
final V putVal(K key, V value, boolean onlyIfAbsent) {
    // ...
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        if (tab == null || (n = tab.length) == 0)
            tab = initTable(); // CAS 初始化
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            // 桶为空:CAS 插入,无需加锁
            if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
                break;
        } else {
            // 桶非空:synchronized 锁住头节点
            synchronized (f) {
                // 链表或红黑树操作
            }
        }
    }
}

关键设计

  • 空桶用 CAS 无锁插入
  • 非空桶用 synchronized 锁头节点(比 ReentrantLock 更轻量,JVM 有锁升级优化)
  • 扩容时多线程协助 transfer(每个线程负责一段桶)
size() 计算
java
// 类似 LongAdder 的思路
private transient volatile long baseCount;
private transient volatile CounterCell[] counterCells;

// size = baseCount + Σ counterCells[i].value
  • 无竞争时 CAS 更新 baseCount
  • 有竞争时分散到 CounterCell[]
  • size() 汇总所有 cell 的值
ConcurrentHashMap 常见坑
java
// ❌ 非原子操作:check-then-act
if (!map.containsKey(key)) {
    map.put(key, value); // 两步之间可能被其他线程插入
}

// ✅ 原子操作
map.putIfAbsent(key, value);
map.computeIfAbsent(key, k -> createValue());

红黑树基本原理

HashMap 链表长度 ≥ 8 时转为红黑树,理解红黑树是面试高频考点。

红黑树的五个性质
  1. 每个节点是红色或黑色
  2. 根节点是黑色
  3. 叶子节点(NIL)是黑色
  4. 红色节点的两个子节点都是黑色(不能有连续红节点)
  5. 从任一节点到其所有叶子节点的路径上,黑色节点数量相同(黑高相同)

这些性质保证了红黑树的关键特性:最长路径不超过最短路径的 2 倍,因此查找/插入/删除都是 O(log n)。

红黑树 vs AVL 树
特性红黑树AVL 树
平衡条件黑高相同左右子树高度差 ≤ 1
平衡程度近似平衡严格平衡
查找性能O(log n),略慢O(log n),略快
插入/删除旋转次数少(最多 3 次)旋转次数多
适用场景插入删除频繁(HashMap、TreeMap)查找频繁(数据库索引)

HashMap 选择红黑树而非 AVL 树,是因为 HashMap 的插入删除操作频繁,红黑树的旋转次数更少,综合性能更好。

插入和旋转

插入新节点默认为红色(不破坏黑高),然后通过变色和旋转修复:

  • 叔叔节点是红色 → 变色(父和叔变黑,祖父变红,向上递归)
  • 叔叔节点是黑色 → 旋转 + 变色(左旋/右旋,最多 2 次旋转)

其他重要集合

ArrayDeque

双端队列,基于循环数组实现:

java
// 比 LinkedList 更适合做栈和队列
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);    // 栈操作
stack.pop();

Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1);   // 队列操作
queue.poll();

优势:

  • 数组连续内存,缓存友好
  • 无 Node 对象开销
  • 大多数操作 O(1)
  • 比 LinkedList 做栈/队列性能好 3-5 倍
TreeMap
  • 红黑树实现,key 有序
  • 时间复杂度 O(log n)
  • 需要 key 实现 Comparable 或传入 Comparator
  • 提供 firstKey/lastKey/subMap/headMap/tailMap 等范围操作
LinkedHashMap
  • HashMap + 双向链表维护插入顺序(或访问顺序)
  • accessOrder=true 时每次 get 会将节点移到链表尾部 → 可实现 LRU 缓存
java
// 简单 LRU 缓存
class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int maxSize;

    public LRUCache(int maxSize) {
        super(maxSize, 0.75f, true); // accessOrder = true
        this.maxSize = maxSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > maxSize;
    }
}
HashSet
  • 内部就是一个 HashMap,value 是固定的 PRESENT 对象
  • add(e) 实际调用 map.put(e, PRESENT)
PriorityQueue
  • 基于小顶堆(数组实现的完全二叉树)
  • offer/poll 时间复杂度 O(log n),peek O(1)
  • 常用于 Top K 问题、任务调度
java
// 大顶堆(取最大的 K 个元素)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());

// 自定义比较器
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
Collections 工具类常用方法
java
Collections.unmodifiableList(list);     // 不可变包装
Collections.synchronizedMap(map);       // 线程安全包装(全局锁,性能差)
Collections.singletonList(item);        // 单元素不可变列表
Collections.emptyList();                // 空不可变列表

关联知识

  • HashMap 的红黑树 → TreeMap 也是红黑树
  • ConcurrentHashMap 的 CAS → 参见并发篇 CAS 原理
  • LinkedHashMap 的 LRU → Android 中 LruCache 就是基于 LinkedHashMap
  • 集合的线程安全 → Collections.synchronizedMap vs ConcurrentHashMap:前者全局锁,后者分段/桶级锁

并发

Java 内存模型(JMM)

主内存与工作内存
线程A 工作内存          主内存          线程B 工作内存
┌──────────┐     ┌──────────┐     ┌──────────┐
│ 变量副本  │ ←→  │ 共享变量  │ ←→  │ 变量副本  │
└──────────┘     └──────────┘     └──────────┘
  • 所有共享变量存储在主内存
  • 每个线程有自己的工作内存,保存变量副本
  • 线程对变量的操作必须在工作内存中进行,不能直接操作主内存
  • 线程间通信必须通过主内存
happens-before 规则

JMM 定义了 8 条 happens-before 规则,保证可见性和有序性:

  1. 程序顺序规则:同一线程中,前面的操作 happens-before 后面的操作
  2. Monitor 锁规则:unlock happens-before 后续对同一锁的 lock
  3. volatile 规则:volatile 写 happens-before 后续对同一变量的读
  4. 线程启动规则Thread.start() happens-before 该线程的任何操作
  5. 线程终止规则:线程的所有操作 happens-before Thread.join() 返回
  6. 中断规则interrupt() happens-before 被中断线程检测到中断
  7. 终结器规则:构造函数 happens-before finalize()
  8. 传递性:A happens-before B,B happens-before C → A happens-before C

volatile

可见性

volatile 变量的写会立即刷新到主内存,读会从主内存重新加载:

java
volatile boolean running = true;

// 线程A
public void stop() {
    running = false; // 写入主内存
}

// 线程B
public void run() {
    while (running) { // 每次从主内存读取
        // do work
    }
}

没有 volatile,线程B 可能永远看不到 running = false(工作内存缓存了旧值)。

有序性(内存屏障)

volatile 通过插入内存屏障禁止指令重排:

屏障类型插入位置作用
StoreStorevolatile 写之前禁止上面的普通写与 volatile 写重排
StoreLoadvolatile 写之后禁止 volatile 写与下面的读重排
LoadLoadvolatile 读之后禁止 volatile 读与下面的普通读重排
LoadStorevolatile 读之后禁止 volatile 读与下面的普通写重排
不保证原子性
java
volatile int count = 0;

// ❌ 不安全!i++ 是三步操作:读-改-写
count++; // 1.读count 2.count+1 3.写回count
// 两个线程可能同时读到相同值,各自+1后写回,丢失一次自增

// ✅ 用 AtomicInteger
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // CAS 保证原子性
DCL 单例中的应用
java
public class Singleton {
    // 必须加 volatile!
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {                // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {        // 第二次检查
                    instance = new Singleton(); // 非原子操作!
                }
            }
        }
        return instance;
    }
}

为什么需要 volatile? new Singleton() 分三步:

  1. 分配内存
  2. 调用构造方法初始化
  3. 将引用赋值给 instance

没有 volatile,步骤 2 和 3 可能重排序。线程A 执行了 1→3(还没执行 2),线程B 看到 instance != null 就直接返回了一个未初始化的对象。

synchronized

锁升级过程
无锁 → 偏向锁 → 轻量级锁 → 重量级锁(单向升级,不可降级)

偏向锁(Biased Locking):

  • 场景:只有一个线程访问同步块
  • 实现:在 Mark Word 中记录线程 ID
  • 后续该线程进入同步块时,只需比较线程 ID,无需 CAS
  • 当第二个线程尝试获取锁时,偏向锁撤销,升级为轻量级锁

轻量级锁(Lightweight Lock):

  • 场景:多个线程交替执行同步块(无竞争)
  • 实现:在栈帧中创建 Lock Record,CAS 将 Mark Word 复制到 Lock Record
  • 退出时 CAS 恢复 Mark Word
  • CAS 失败(有竞争)→ 升级为重量级锁

重量级锁(Heavyweight Lock):

  • 场景:多个线程同时竞争
  • 实现:依赖操作系统 Mutex Lock
  • 未获取锁的线程阻塞(用户态→内核态切换,开销大)
  • 底层是 ObjectMonitor:
    • _EntryList:等待获取锁的线程队列
    • _WaitSet:调用 wait() 后等待的线程
    • _owner:当前持有锁的线程
锁优化

锁消除:JIT 编译器检测到同步块中的对象不会逃逸到其他线程,自动去除锁。

java
// StringBuffer.append 内部有 synchronized,但 sb 是局部变量不会逃逸
public String concat(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
    // JIT 会消除 append 中的 synchronized
}

锁粗化:连续对同一对象加锁解锁,合并为一次更大范围的锁。

synchronized vs ReentrantLock
特性synchronizedReentrantLock
实现JVM 内置,monitorenter/monitorexitJava API,基于 AQS
释放自动释放(退出同步块)手动 unlock()(必须在 finally 中)
可中断不可中断lockInterruptibly() 可中断
公平性非公平可选公平/非公平
条件变量只有一个 wait/notify多个 Condition
性能JDK 6 后优化,差距不大差距不大

AQS(AbstractQueuedSynchronizer)

核心结构
java
// 同步状态
private volatile int state;

// CLH 双向队列节点
static final class Node {
    volatile int waitStatus; // CANCELLED(1), SIGNAL(-1), CONDITION(-2), PROPAGATE(-3)
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
}

AQS 是 ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock 的基础框架。

acquire 流程(以 ReentrantLock 为例)
java
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&                    // 1. 尝试获取锁
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 2. 失败则入队等待
        selfInterrupt();
}

详细流程:

  1. tryAcquire(1):尝试 CAS 将 state 从 0 改为 1
    • 成功 → 设置 exclusiveOwnerThread 为当前线程,获取锁成功
    • 失败 → 检查是否是重入(当前线程 == ownerThread),是则 state++
  2. addWaiter(Node.EXCLUSIVE):创建节点加入 CLH 队列尾部(CAS + 自旋)
  3. acquireQueued:在队列中自旋等待
    • 如果前驱是 head,再次 tryAcquire
    • 否则 shouldParkAfterFailedAcquire:将前驱的 waitStatus 设为 SIGNAL
    • parkAndCheckInterruptLockSupport.park() 阻塞当前线程
release 流程
java
public final boolean release(int arg) {
    if (tryRelease(arg)) {          // state-- ,state==0 时释放成功
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);     // LockSupport.unpark() 唤醒后继节点
        return true;
    }
    return false;
}
公平锁 vs 非公平锁
java
// 非公平锁的 tryAcquire(默认)
final boolean nonfairTryAcquire(int acquires) {
    // 直接 CAS 抢锁,不管队列中有没有等待的线程
    if (compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
    // ...
}

// 公平锁的 tryAcquire
protected final boolean tryAcquire(int acquires) {
    // 多了一个检查:队列中是否有等待更久的线程
    if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
    // ...
}

非公平锁性能更好(减少线程切换),但可能导致饥饿。

线程池 ThreadPoolExecutor

七大参数
java
public ThreadPoolExecutor(
    int corePoolSize,      // 核心线程数(即使空闲也不回收,除非设置 allowCoreThreadTimeOut)
    int maximumPoolSize,   // 最大线程数
    long keepAliveTime,    // 非核心线程空闲存活时间
    TimeUnit unit,         // 时间单位
    BlockingQueue<Runnable> workQueue,  // 任务队列
    ThreadFactory threadFactory,        // 线程工厂
    RejectedExecutionHandler handler    // 拒绝策略
)
execute 流程
提交任务


workerCount < corePoolSize ?
  ├─ 是 → 创建核心线程执行任务

  ▼ 否
workQueue.offer(task) 成功?
  ├─ 是 → 任务入队等待

  ▼ 否(队列满)
workerCount < maximumPoolSize ?
  ├─ 是 → 创建非核心线程执行任务

  ▼ 否
执行拒绝策略

源码:

java
public void execute(Runnable command) {
    int c = ctl.get();
    // 1. 工作线程数 < 核心线程数
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true)) return;
        c = ctl.get();
    }
    // 2. 尝试入队
    if (isRunning(c) && workQueue.offer(command)) {
        int recheck = ctl.get();
        if (!isRunning(recheck) && remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    // 3. 队列满,尝试创建非核心线程
    else if (!addWorker(command, false))
        reject(command); // 4. 拒绝
}
四种拒绝策略
策略行为
AbortPolicy(默认)抛出 RejectedExecutionException
CallerRunsPolicy由提交任务的线程直接执行
DiscardPolicy静默丢弃任务
DiscardOldestPolicy丢弃队列中最老的任务,重新提交当前任务
常见线程池配置
java
// ❌ 不推荐使用 Executors 创建(阿里规约)
Executors.newFixedThreadPool(n);     // LinkedBlockingQueue 无界队列,可能 OOM
Executors.newCachedThreadPool();     // maximumPoolSize = Integer.MAX_VALUE,可能创建大量线程
Executors.newSingleThreadExecutor(); // 同 Fixed,无界队列

// ✅ 推荐手动创建
new ThreadPoolExecutor(
    Runtime.getRuntime().availableProcessors(), // CPU 密集型
    Runtime.getRuntime().availableProcessors() * 2, // IO 密集型可以更大
    60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000), // 有界队列
    new ThreadFactory() { ... },
    new ThreadPoolExecutor.CallerRunsPolicy()
);
Worker 工作原理

Worker 继承 AQS(实现不可重入锁)并实现 Runnable:

java
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
    final Thread thread;
    Runnable firstTask;

    public void run() {
        runWorker(this);
    }
}

final void runWorker(Worker w) {
    Runnable task = w.firstTask;
    w.firstTask = null;
    while (task != null || (task = getTask()) != null) {
        w.lock(); // Worker 自身加锁,用于 shutdown 时判断是否空闲
        try {
            beforeExecute(w.thread, task);
            task.run();
            afterExecute(task, null);
        } finally {
            task = null;
            w.unlock();
        }
    }
    // getTask() 返回 null → 线程退出
}

getTask() 从队列取任务,非核心线程用 poll(keepAliveTime) 超时获取,核心线程用 take() 阻塞获取。

CAS 与 ABA 问题

CAS 原理

Compare And Swap:比较内存值与预期值,相等则更新为新值,整个操作是原子的。

java
// Unsafe 类提供的 CAS 操作(CPU 指令级别保证原子性)
public final native boolean compareAndSwapInt(
    Object obj, long offset, int expected, int update);

// AtomicInteger 的 incrementAndGet
public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

// getAndAddInt 内部自旋 CAS
public final int getAndAddInt(Object obj, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(obj, offset); // 读取当前值
    } while (!compareAndSwapInt(obj, offset, v, v + delta)); // CAS 直到成功
    return v;
}
ABA 问题

线程1 读到值 A,线程2 将 A→B→A,线程1 CAS 发现仍是 A 就成功了,但值已经被改过。

解决方案:加版本号

java
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0);

int[] stampHolder = new int[1];
int value = ref.get(stampHolder); // value=1, stamp=0

// CAS 时同时比较值和版本号
ref.compareAndSet(1, 2, 0, 1); // 期望值1版本0 → 新值2版本1

ThreadLocal

原理

每个 Thread 持有一个 ThreadLocalMap,key 是 ThreadLocal 对象(弱引用),value 是存储的值:

java
public class Thread {
    ThreadLocal.ThreadLocalMap threadLocals; // 每个线程独有
}

// ThreadLocal.set()
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = t.threadLocals;
    if (map != null)
        map.set(this, value); // this 就是 ThreadLocal 对象作为 key
    else
        createMap(t, value);
}

// ThreadLocal.get()
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = t.threadLocals;
    if (map != null) {
        Entry e = map.getEntry(this);
        if (e != null) return (T) e.value;
    }
    return setInitialValue();
}
内存泄漏
java
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value; // value 是强引用!
    Entry(ThreadLocal<?> k, Object v) {
        super(k); // key 是弱引用
        value = v;
    }
}

泄漏场景

  1. ThreadLocal 对象被回收(弱引用 key 变为 null)
  2. 但 Entry 的 value 仍然被 Entry 强引用
  3. Entry 被 ThreadLocalMap 强引用
  4. ThreadLocalMap 被 Thread 强引用
  5. 如果线程是线程池中的长期存活线程 → value 永远不会被回收

解决方案:用完后手动调用 threadLocal.remove()

java
try {
    threadLocal.set(value);
    // 使用 value
} finally {
    threadLocal.remove(); // 必须清理!
}
Android 中的应用
  • Looper.myLooper():每个线程的 Looper 存储在 ThreadLocal 中
  • Choreographer:每个线程的编舞者实例
java
// Looper 源码
static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<>();

public static void prepare() {
    if (sThreadLocal.get() != null)
        throw new RuntimeException("Only one Looper may be created per thread");
    sThreadLocal.set(new Looper());
}

public static @Nullable Looper myLooper() {
    return sThreadLocal.get();
}

常见坑总结

说明
volatile 不保证原子性count++ 仍需 AtomicInteger 或 synchronized
DCL 单例必须加 volatile防止指令重排导致获取未初始化对象
ThreadLocal 用完必须 remove线程池场景下会内存泄漏
Executors 创建线程池有风险无界队列或无限线程数可能 OOM
synchronized 锁的是对象不是代码锁 this、锁 Class、锁任意 Object 要区分
wait/notify 必须在 synchronized 块中否则抛 IllegalMonitorStateException
CAS 自旋在高竞争下性能差大量线程竞争时不如 synchronized

并发工具类

CountDownLatch

一次性计数器,等待 N 个任务完成:

java
CountDownLatch latch = new CountDownLatch(3);

// 三个任务并行执行
executor.execute(() -> { doTask1(); latch.countDown(); });
executor.execute(() -> { doTask2(); latch.countDown(); });
executor.execute(() -> { doTask3(); latch.countDown(); });

latch.await(); // 阻塞直到计数为 0
// 三个任务都完成了

原理:基于 AQS,state 初始为 N,countDown 时 CAS 减 1,await 时检查 state 是否为 0。

不可重用:计数到 0 后无法重置。

CyclicBarrier

可重用的屏障,N 个线程互相等待到达屏障点:

java
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
    System.out.println("所有线程到达屏障,继续执行");
});

// 每个线程
barrier.await(); // 阻塞直到 3 个线程都调用了 await

与 CountDownLatch 的区别:

  • CountDownLatch:一个线程等待其他 N 个线程完成,一次性
  • CyclicBarrier:N 个线程互相等待,可重用(reset)
Semaphore

信号量,控制并发访问数量:

java
Semaphore semaphore = new Semaphore(3); // 最多 3 个线程同时访问

semaphore.acquire(); // 获取许可(-1),无许可则阻塞
try {
    // 访问共享资源
} finally {
    semaphore.release(); // 释放许可(+1)
}

应用场景:数据库连接池、限流。

CompletableFuture

Java 8 引入的异步编程工具:

java
CompletableFuture.supplyAsync(() -> fetchUser(id))      // 异步执行
    .thenApply(user -> user.getName())                    // 转换结果
    .thenCombine(
        CompletableFuture.supplyAsync(() -> fetchOrder(id)), // 并行另一个任务
        (name, order) -> name + ": " + order              // 合并结果
    )
    .thenAccept(result -> System.out.println(result))     // 消费结果
    .exceptionally(e -> { log(e); return null; });        // 异常处理

常用方法:

  • supplyAsync/runAsync:异步执行
  • thenApply/thenAccept/thenRun:链式处理
  • thenCombine/thenCompose:组合多个 Future
  • allOf/anyOf:等待全部/任一完成
  • exceptionally/handle:异常处理
ForkJoinPool

分治框架,将大任务拆分为小任务并行执行:

java
class SumTask extends RecursiveTask<Long> {
    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            return directSum(); // 小任务直接计算
        }
        int mid = (start + end) / 2;
        SumTask left = new SumTask(start, mid);
        SumTask right = new SumTask(mid, end);
        left.fork();  // 异步执行左半部分
        Long rightResult = right.compute(); // 当前线程执行右半部分
        Long leftResult = left.join(); // 等待左半部分结果
        return leftResult + rightResult;
    }
}

工作窃取(Work Stealing):每个线程有自己的双端队列,空闲线程从其他线程队列的尾部窃取任务,提高 CPU 利用率。

Java 8 的并行流(parallelStream)底层就是 ForkJoinPool。

ReadWriteLock

读写锁,读读不互斥,读写/写写互斥:

java
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

// 读操作(多个线程可以同时读)
rwLock.readLock().lock();
try { return data; }
finally { rwLock.readLock().unlock(); }

// 写操作(独占)
rwLock.writeLock().lock();
try { data = newValue; }
finally { rwLock.writeLock().unlock(); }

原理:AQS 的 state 高 16 位存读锁计数,低 16 位存写锁计数。

适用场景:读多写少的缓存。

1. synchronized 和 ReentrantLock 的区别?

考察点:锁机制对比

完整回答

维度synchronizedReentrantLock
实现层面JVM 内置,monitorenter/monitorexit 字节码Java API,基于 AQS
锁释放自动释放(退出同步块/异常)必须手动 unlock(),通常在 finally 中
可中断不可中断,线程会一直等待lockInterruptibly() 支持中断等待
公平性只有非公平构造时可选公平/非公平
条件变量只有一个等待队列(wait/notify)可创建多个 Condition
锁升级偏向锁→轻量级锁→重量级锁无锁升级机制
性能JDK 6 后大幅优化,差距不大差距不大

选择建议:简单同步用 synchronized(代码简洁、不会忘记释放锁);需要可中断、公平锁、多条件变量等高级功能时用 ReentrantLock。

追问:synchronized 的锁升级过程?

  1. 无锁 → 偏向锁:第一个线程进入,在 Mark Word 中记录线程 ID,后续该线程进入只需比较 ID
  2. 偏向锁 → 轻量级锁:第二个线程尝试获取锁,撤销偏向,两个线程 CAS 竞争
  3. 轻量级锁 → 重量级锁:CAS 自旋一定次数仍失败,膨胀为重量级锁,未获取锁的线程阻塞

锁升级是单向的,不可降级。

追问:什么是锁消除和锁粗化?

锁消除:JIT 编译器通过逃逸分析发现锁对象不会逃逸到其他线程,自动去除锁。比如方法内的 StringBuffer。

锁粗化:连续对同一对象多次加锁解锁,合并为一次大范围的锁。

2. volatile 的原理?能保证线程安全吗?

考察点:JMM、volatile 语义

完整回答

volatile 提供两个保证:

  1. 可见性:volatile 变量的写会立即刷新到主内存,读会从主内存加载。其他线程能立即看到最新值。

  2. 有序性:通过内存屏障禁止指令重排。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 流程:

  1. 当前线程数 < corePoolSize → 创建核心线程执行任务
  2. 核心线程满 → 任务放入 workQueue
  3. 队列满且线程数 < maximumPoolSize → 创建非核心线程
  4. 队列满且线程数 = maximumPoolSize → 执行拒绝策略

四种拒绝策略:

  • AbortPolicy(默认):抛 RejectedExecutionException
  • CallerRunsPolicy:调用者线程执行任务(降级)
  • DiscardPolicy:静默丢弃
  • DiscardOldestPolicy:丢弃队列头部最老的任务

追问:常见线程池的问题?

  • Executors.newFixedThreadPool:LinkedBlockingQueue 无界队列,任务堆积可能 OOM
  • Executors.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)

  1. tryAcquire:CAS 尝试将 state 从 0 改为 1,成功则获取锁
  2. 如果当前线程已持有锁(重入),state++
  3. 失败则 addWaiter 创建节点加入 CLH 队列尾部
  4. acquireQueued:在队列中自旋,如果前驱是 head 则再次 tryAcquire
  5. 否则 LockSupport.park() 阻塞

解锁流程(release)

  1. tryRelease:state--,state == 0 时真正释放
  2. 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 的区别?

考察点:并发工具类

完整回答

维度CountDownLatchCyclicBarrier
等待方式一个线程等待其他 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/notifyCondition
配合synchronizedReentrantLock
条件队列只有一个可以有多个(精确唤醒)
唤醒notify 随机唤醒一个signal 唤醒指定条件的线程

wait 必须在 synchronized 中的原因:wait 会释放锁并进入等待队列。如果不在 synchronized 中,没有锁可以释放,会抛 IllegalMonitorStateException。更重要的是,如果不加锁,check-then-wait 之间可能被其他线程插入 notify,导致信号丢失(lost wake-up)。

java
// 经典的生产者-消费者
synchronized (lock) {
    while (queue.isEmpty()) { // 用 while 不用 if,防止虚假唤醒
        lock.wait();
    }
    item = queue.poll();
}

9. 死锁的条件?怎么排查和避免?

考察点:并发问题排查

完整回答

死锁四个必要条件:

  1. 互斥:资源同一时刻只能被一个线程持有
  2. 持有并等待:持有一个资源的同时等待另一个资源
  3. 不可剥夺:已获取的资源不能被强制释放
  4. 循环等待:线程 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 的区别?

维度sleepwait
所属Thread 静态方法Object 实例方法
不释放锁释放锁
唤醒超时自动唤醒需要 notify/notifyAll
使用条件任何地方必须在 synchronized 中

实习面试通常先确认你是否理解“主线程负责 UI,耗时任务放子线程”,再逐步追问 Handler、线程安全和协程。

11. Android 为什么不能在子线程直接更新 UI?

考察点:主线程模型、线程安全

完整回答

Android 的 View 体系不是线程安全的。UI 的测量、布局、绘制和事件分发都在主线程中按消息队列顺序执行,如果多个线程同时修改 View 状态,会产生竞态条件,导致显示错乱甚至崩溃。

因此耗时任务应该放到子线程执行,拿到结果后切回主线程更新 UI。常见方式:

  • Activity.runOnUiThread
  • View.post
  • Handler(Looper.getMainLooper()).post
  • Kotlin 协程中的 withContext(Dispatchers.Main)

追问:子线程一定不能创建 Handler 吗?

可以,但这个线程必须先调用 Looper.prepare() 创建 Looper,再调用 Looper.loop() 开启消息循环。普通子线程默认没有 Looper。

12. 线程和进程有什么区别?

考察点:操作系统基础

完整回答

  • 进程是系统分配资源的基本单位,有独立的内存空间。
  • 线程是 CPU 调度的基本单位,同一个进程内的线程共享进程资源。

Android 中每个 App 通常运行在独立进程中,主线程也叫 UI 线程。网络请求、数据库读写、图片解码等耗时任务如果放在主线程,可能造成卡顿或 ANR。

加分点:多个线程共享内存,所以要注意线程安全;多个进程之间内存隔离,通信通常需要 IPC,比如 Binder。

13. 什么情况下会出现线程安全问题?

考察点:共享变量、并发修改

完整回答

线程安全问题通常出现在多个线程同时读写同一份可变数据时。例如多个线程同时执行 count++,它不是原子操作,实际包含读取、加一、写回三个步骤,可能导致结果丢失。

解决方式:

  • 使用 synchronizedLock 保护临界区。
  • 使用原子类,比如 AtomicInteger
  • 尽量使用不可变对象,减少共享可变状态。
  • 在 Android 中把 UI 状态收敛到主线程更新。

追问:volatile 能保证 count++ 线程安全吗?

不能。volatile 主要保证可见性和禁止部分重排序,不保证复合操作的原子性。

Kotlin

Kotlin 核心与协程

Kotlin 核心语法

data class

data class 编译器自动生成以下方法:

kotlin
data class User(val name: String, val age: Int)

// 编译后等价于:
public final class User {
    private final String name;
    private final int age;

    // 主构造函数
    public User(String name, int age) { ... }

    // getter
    public final String getName() { return name; }
    public final int getAge() { return age; }

    // equals:比较所有主构造函数中声明的属性
    public boolean equals(Object other) {
        if (this == other) return true;
        if (!(other instanceof User)) return false;
        User user = (User) other;
        return name.equals(user.name) && age == user.age;
    }

    // hashCode:基于所有属性计算
    public int hashCode() {
        return name.hashCode() * 31 + age;
    }

    // toString
    public String toString() {
        return "User(name=" + name + ", age=" + age + ")";
    }

    // copy:浅拷贝,可修改部分属性
    public User copy(String name, int age) {
        return new User(name, age);
    }

    // componentN:解构声明
    public String component1() { return name; }
    public int component2() { return age; }
}

注意事项

  • equals/hashCode 只基于主构造函数中的属性,body 中声明的属性不参与
  • copy 是浅拷贝,引用类型属性共享同一对象
  • data class 必须有至少一个主构造函数参数
sealed class / sealed interface
kotlin
sealed class Result<out T> {
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val exception: Throwable) : Result<Nothing>()
    object Loading : Result<Nothing>()
}

// when 表达式可以穷举所有子类,不需要 else
fun handleResult(result: Result<String>) = when (result) {
    is Result.Success -> println(result.data)
    is Result.Error -> println(result.exception.message)
    Result.Loading -> println("Loading...")
    // 编译器保证穷举,新增子类时会编译报错
}

sealed class vs enum:

  • enum:每个类型只有一个实例
  • sealed class:每个子类可以有多个实例,可以携带不同数据
委托属性
kotlin
// by lazy:延迟初始化,默认线程安全(SYNCHRONIZED 模式)
val heavyObject: HeavyObject by lazy {
    println("初始化")
    HeavyObject()
}

by lazy 三种模式:

  • LazyThreadSafetyMode.SYNCHRONIZED(默认):双重检查锁,线程安全
  • LazyThreadSafetyMode.PUBLICATION:多线程可能同时初始化,但只有第一个完成的值被使用
  • LazyThreadSafetyMode.NONE:不加锁,单线程使用
kotlin
// by observable:属性变化时回调
var name: String by Delegates.observable("初始值") { prop, old, new ->
    println("$old$new")
}

// by map:从 Map 中读取属性值(常用于解析 JSON/Bundle)
class User(map: Map<String, Any?>) {
    val name: String by map
    val age: Int by map
}
val user = User(mapOf("name" to "张三", "age" to 25))

委托原理:编译器生成一个 $$delegate 字段,属性的 get/set 转发给委托对象的 getValue/setValue

扩展函数
kotlin
fun String.addExclamation(): String = "$this!"

// 编译后(Java 字节码):
public static String addExclamation(String $this$addExclamation) {
    return $this$addExclamation + "!";
}

关键点

  • 扩展函数是静态分发的,不是虚函数,不支持多态
  • 如果类有同名成员函数,成员函数优先
  • 扩展函数可以访问 public 成员,不能访问 private/protected
kotlin
open class Animal
class Dog : Animal()

fun Animal.speak() = "Animal"
fun Dog.speak() = "Dog"

val animal: Animal = Dog()
println(animal.speak()) // "Animal"!不是 "Dog",因为静态分发看声明类型
内联函数
kotlin
inline fun <T> measureTime(block: () -> T): T {
    val start = System.currentTimeMillis()
    val result = block()
    println("耗时: ${System.currentTimeMillis() - start}ms")
    return result
}

// 调用处编译后,lambda 代码直接内联展开,不会创建 Function 对象
val result = measureTime {
    // 这段代码直接嵌入调用处
    heavyComputation()
}

noinline:标记不需要内联的 lambda 参数(需要将 lambda 作为对象传递时)

kotlin
inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) {
    inlined()           // 内联展开
    bar(notInlined)     // 作为对象传递给其他函数
}

crossinline:禁止 lambda 中的非局部返回

kotlin
inline fun foo(crossinline block: () -> Unit) {
    Runnable {
        block() // 如果 block 中有 return,会从 foo 返回,但这里在 Runnable 中不允许
    }.run()
}
空安全
kotlin
var name: String? = null

// 安全调用
val length = name?.length          // null

// Elvis 操作符
val length = name?.length ?: 0     // 0

// 非空断言(慎用!)
val length = name!!.length         // 抛 NullPointerException

// let 配合安全调用
name?.let { nonNullName ->
    println(nonNullName.length)    // 只在非空时执行
}

// 平台类型:Java 返回的类型在 Kotlin 中标记为 String!
// 既可以当 String 也可以当 String? 使用,但如果实际为 null 会崩溃

Kotlin 协程

挂起与恢复原理
CPS 变换

编译器将 suspend 函数转换为带 Continuation 参数的普通函数:

kotlin
// 源码
suspend fun fetchUser(): User {
    val token = getToken()      // 挂起点 1
    val user = getUser(token)   // 挂起点 2
    return user
}

// 编译后(伪代码)
fun fetchUser(continuation: Continuation<User>): Any? {
    val sm = continuation as? FetchUserSM ?: FetchUserSM(continuation)

    when (sm.label) {
        0 -> {
            sm.label = 1
            val result = getToken(sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
            // 没有挂起,继续执行
        }
        1 -> {
            val token = sm.result as String
            sm.label = 2
            val result = getUser(token, sm)
            if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
        }
        2 -> {
            return sm.result as User
        }
    }
}

核心概念

  • 每个 suspend 函数编译为一个状态机
  • 每个挂起点对应一个 label 状态
  • Continuation 保存了当前状态和局部变量
  • 返回 COROUTINE_SUSPENDED 表示真正挂起了
  • 恢复时调用 continuation.resumeWith(result)
Continuation 接口
kotlin
public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}
CoroutineContext

CoroutineContext 是一个类似 Map 的结构,由多个 Element 组合:

kotlin
val context = Job() + Dispatchers.IO + CoroutineName("myCoroutine")
// 等价于
val context = CoroutineContext(
    Job(),
    Dispatchers.IO,
    CoroutineName("myCoroutine")
)

常用 Element:

  • Job:控制协程生命周期(取消、等待完成)
  • CoroutineDispatcher:决定协程在哪个线程执行
  • CoroutineName:调试用的名称
  • CoroutineExceptionHandler:未捕获异常处理器
调度器
kotlin
Dispatchers.Main      // Android 主线程(通过 Handler(Looper.getMainLooper()) 实现)
Dispatchers.IO        // IO 密集型,共享线程池,最大 max(64, CPU核心数) 个线程
Dispatchers.Default   // CPU 密集型,线程数 = CPU 核心数
Dispatchers.Unconfined // 不切换线程,在当前线程执行到第一个挂起点

IO 和 Default 共享线程池:它们底层使用同一个线程池,但通过 LimitingDispatcher 限制并发数。IO 允许更多并发(因为 IO 操作大部分时间在等待),Default 限制为 CPU 核心数。

kotlin
// 切换调度器
withContext(Dispatchers.IO) {
    // 在 IO 线程执行
    val data = api.fetchData()
}
// 自动切回原来的调度器
结构化并发
kotlin
// CoroutineScope 管理协程生命周期
class MyViewModel : ViewModel() {
    // viewModelScope 在 ViewModel.onCleared() 时自动取消
    fun loadData() {
        viewModelScope.launch {
            val user = fetchUser()   // 子协程
            val posts = fetchPosts() // 子协程
        }
        // ViewModel 销毁时,所有子协程自动取消
    }
}

Job 层级关系

父 Job
├── 子 Job A
│   ├── 孙 Job A1
│   └── 孙 Job A2
└── 子 Job B
  • 父 Job 取消 → 所有子 Job 递归取消
  • 子 Job 失败 → 默认取消父 Job → 取消所有兄弟 Job
  • SupervisorJob:子 Job 失败不影响父和兄弟
kotlin
// 普通 Job:一个子协程失败,全部取消
coroutineScope {
    launch { throw Exception("失败") } // 导致整个 scope 取消
    launch { delay(1000) }             // 也会被取消
}

// SupervisorJob:子协程失败不影响兄弟
supervisorScope {
    launch { throw Exception("失败") } // 只有这个失败
    launch { delay(1000) }             // 正常执行
}
异常传播

launch vs async

kotlin
// launch:异常立即向上传播
val job = scope.launch {
    throw RuntimeException("boom") // 异常传播到父协程
}

// async:异常在 await() 时抛出
val deferred = scope.async {
    throw RuntimeException("boom") // 暂时不传播
}
try {
    deferred.await() // 这里才抛出异常
} catch (e: Exception) {
    // 处理异常
}

CoroutineExceptionHandler

kotlin
val handler = CoroutineExceptionHandler { _, exception ->
    println("捕获异常: ${exception.message}")
}

// 只在根协程(scope.launch)上设置才有效
val scope = CoroutineScope(SupervisorJob() + handler)
scope.launch {
    throw RuntimeException("boom") // 被 handler 捕获
}

// ❌ 在子协程上设置无效
scope.launch {
    launch(handler) { // handler 不会生效!
        throw RuntimeException("boom")
    }
}
Flow
冷流 vs 热流
kotlin
// 冷流:每次 collect 都重新执行
val coldFlow = flow {
    println("开始发射")
    emit(1)
    emit(2)
    emit(3)
}
coldFlow.collect { println(it) } // 打印:开始发射 1 2 3
coldFlow.collect { println(it) } // 再次打印:开始发射 1 2 3

// SharedFlow(热流):多个收集者共享
val sharedFlow = MutableSharedFlow<Int>(
    replay = 1,           // 新收集者能收到最近 1 个值
    extraBufferCapacity = 64,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)

// StateFlow(特殊的 SharedFlow):
// replay = 1,必须有初始值,自动 distinctUntilChanged
val stateFlow = MutableStateFlow(0)
stateFlow.value = 1 // 更新值
stateFlow.value = 1 // 相同值不会通知收集者
StateFlow vs LiveData
特性StateFlowLiveData
初始值必须有可以没有
空安全泛型约束可能为 null
生命周期感知需要 repeatOnLifecycle自动感知
相同值自动去重每次都通知
背压支持不支持
平台依赖纯 KotlinAndroid 专属
kotlin
// 在 Activity/Fragment 中安全收集 StateFlow
lifecycleScope.launch {
    repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.uiState.collect { state ->
            updateUI(state)
        }
    }
}
背压处理
kotlin
flow {
    for (i in 1..100) {
        emit(i)
        delay(10) // 生产快
    }
}
.buffer(64)          // 缓冲区,生产者不等消费者
// .conflate()       // 只保留最新值,丢弃中间值
// .collectLatest {} // 新值来时取消上一次处理
.collect { value ->
    delay(100) // 消费慢
    println(value)
}
常用操作符
kotlin
flowOf(1, 2, 3, 4, 5)
    .filter { it % 2 == 0 }           // 过滤:2, 4
    .map { it * 10 }                   // 变换:20, 40
    .onEach { println("处理: $it") }   // 副作用
    .catch { e -> emit(-1) }           // 异常处理
    .flowOn(Dispatchers.IO)            // 上游在 IO 线程执行
    .collect { println(it) }           // 收集

// combine:组合多个 Flow,任一发射都触发
combine(flow1, flow2) { a, b -> "$a-$b" }

// zip:配对组合,等待两个都发射
flow1.zip(flow2) { a, b -> "$a-$b" }

// flatMapConcat:顺序展开
// flatMapMerge:并发展开
// flatMapLatest:只处理最新的
Channel
kotlin
// Channel:协程间通信(CSP 模型)
val channel = Channel<Int>(capacity = Channel.BUFFERED)

// 生产者
launch {
    for (i in 1..5) {
        channel.send(i) // 缓冲区满时挂起
    }
    channel.close()
}

// 消费者
launch {
    for (value in channel) { // 自动在 close 后结束
        println(value)
    }
}

缓冲策略:

  • Channel.RENDEZVOUS(0):无缓冲,send 和 receive 必须同时就绪
  • Channel.BUFFERED(64):默认缓冲大小
  • Channel.CONFLATED:只保留最新值
  • Channel.UNLIMITED:无限缓冲(注意内存)

Channel vs Flow

  • Channel 是热的,创建即开始
  • Flow 是冷的,collect 才开始
  • Channel 适合一对一通信
  • Flow 适合一对多的数据流

常见坑总结

  1. 协程取消不是立即生效的:需要在耗时操作中检查 isActive 或使用可取消的挂起函数
  2. GlobalScope 不要用:没有结构化并发,泄漏风险
  3. Flow 在 collect 时才执行:不要期望 flow{} 块在创建时就运行
  4. StateFlow 需要 repeatOnLifecycle:直接在 lifecycleScope.launch 中 collect 会在后台继续收集
  5. by lazy 默认加锁:如果确定单线程访问,用 lazy(LazyThreadSafetyMode.NONE) 提升性能
  6. 扩展函数是静态分发:不要期望多态行为
  7. data class 的 equals 只看主构造函数参数:body 中的属性不参与比较

Kotlin 作用域函数

面试常考的五个作用域函数对比:

kotlin
// let:非空判断 + 转换,it 引用
val length = str?.let { it.length } // 返回 lambda 结果

// run:对象配置 + 计算,this 引用
val result = service.run {
    port = 8080
    query() // 返回 lambda 结果
}

// with:对同一对象多次操作,this 引用(非扩展函数)
with(config) {
    host = "localhost"
    port = 8080
}

// apply:对象配置,this 引用,返回对象本身
val intent = Intent().apply {
    action = "com.example.ACTION"
    putExtra("key", "value")
} // 返回 intent 本身

// also:附加操作,it 引用,返回对象本身
val list = mutableListOf(1, 2, 3).also {
    println("创建了列表: $it")
} // 返回 list 本身

速记表

函数引用方式返回值典型用途
letitlambda 结果非空判断、转换
runthislambda 结果对象配置 + 计算
withthislambda 结果多次操作同一对象
applythis对象本身对象初始化配置
alsoit对象本身附加操作(日志、验证)

Kotlin 与 Java 互操作

常见注解
kotlin
@JvmStatic    // 生成真正的静态方法(companion object 中使用)
@JvmField     // 暴露为 public 字段而非 getter/setter
@JvmOverloads // 为有默认参数的函数生成重载方法
@JvmName      // 指定生成的 Java 方法名
@Throws       // 声明受检异常(Kotlin 没有受检异常)
平台类型

Java 返回的类型在 Kotlin 中标记为 String!(平台类型),既可以当 String 也可以当 String?。如果实际为 null 会在运行时抛 NullPointerException。

kotlin
// Java 方法返回 String(无 @Nullable/@NonNull 注解)
val name: String = javaObj.getName() // 如果返回 null,这里崩溃
val name: String? = javaObj.getName() // 安全,需要空判断

最佳实践:Java 代码加 @Nullable/@NonNull 注解,Kotlin 侧就能正确推断。

协程与 Java 互操作
kotlin
// Kotlin 协程暴露给 Java 调用
fun fetchDataForJava(): CompletableFuture<Data> {
    return GlobalScope.future { fetchData() } // kotlinx-coroutines-jdk8
}

// Java 回调转协程
suspend fun <T> Call<T>.await(): T = suspendCancellableCoroutine { cont ->
    enqueue(object : Callback<T> {
        override fun onResponse(call: Call<T>, response: Response<T>) {
            cont.resume(response.body()!!)
        }
        override fun onFailure(call: Call<T>, t: Throwable) {
            cont.resumeWithException(t)
        }
    })
    cont.invokeOnCancellation { cancel() }
}

withContext vs launch vs async

kotlin
// launch:启动新协程,不返回结果(返回 Job)
// 用于"发射后不管"的场景
viewModelScope.launch {
    repository.saveData(data) // 不需要返回值
}

// async:启动新协程,返回 Deferred(可 await 获取结果)
// 用于并行执行多个任务
val user = async { fetchUser() }
val posts = async { fetchPosts() }
showData(user.await(), posts.await()) // 并行执行,等待两个结果

// withContext:切换上下文执行,挂起当前协程等待结果
// 用于切换线程(如主线程切到 IO 线程)
val data = withContext(Dispatchers.IO) {
    repository.loadData() // 在 IO 线程执行
} // 返回结果,回到原来的线程
updateUI(data)

关键区别

  • withContext 是顺序执行(挂起等待),async 是并行执行
  • withContext 不创建新协程,只切换上下文
  • 单个任务切线程用 withContext,多个任务并行用 async

1. Kotlin 协程的挂起原理是什么?

考察点:协程底层实现

完整回答

Kotlin 协程的挂起本质是 CPS(Continuation Passing Style)变换 + 状态机。

编译器将 suspend 函数转换为带 Continuation 参数的普通函数,函数体被改写为一个 when(label) 状态机。每个挂起点对应一个 label 状态。

执行到挂起点时:

  1. 保存当前局部变量到 Continuation 对象
  2. 设置下一个 label
  3. 调用挂起函数,如果返回 COROUTINE_SUSPENDED 则函数返回(挂起)
  4. 异步操作完成后,调用 continuation.resumeWith(result) 恢复执行
  5. 恢复时从上次的 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() 时才抛出
kotlin
// 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 捕获。

kotlin
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 的区别?什么时候用哪个?

考察点:响应式数据流

完整回答

维度FlowLiveData
所属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 对象和额外的方法调用。

主要用途:

  1. 消除 lambda 开销:普通高阶函数每次调用会创建一个 Function 对象(匿名内部类),inline 消除这个开销
  2. reified 泛型:只有 inline 函数才能用 reified,在运行时获取泛型类型
  3. 非局部返回:inline lambda 中可以 return 直接从外层函数返回
kotlin
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。

注意事项:

  1. 只基于主构造函数参数:body 中声明的属性不参与 equals/hashCode
kotlin
data class User(val name: String) {
    var age: Int = 0 // 不参与 equals/hashCode!
}
User("张三").apply { age = 20 } == User("张三").apply { age = 30 } // true
  1. copy 是浅拷贝:引用类型属性共享同一对象
  2. 必须有至少一个主构造函数参数
  3. 不能是 abstract/open/sealed/inner
  4. 解构声明按顺序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 的扩展函数是怎么实现的?有什么限制?

考察点:扩展函数原理

完整回答

扩展函数编译后是一个静态方法,接收者对象作为第一个参数:

kotlin
fun String.addStar() = "*$this*"
// 编译为:
public static String addStar(String $this) { return "*" + $this + "*"; }

限制:

  1. 静态分发:根据声明类型而非运行时类型调用,不支持多态
  2. 成员函数优先:如果类有同名同参数的成员函数,成员函数优先
  3. 不能访问 private/protected 成员:扩展函数本质是外部静态方法
  4. 可以被遮蔽:子类和父类定义同名扩展函数时,调用哪个取决于变量的声明类型

加分点:扩展函数非常适合给第三方库的类添加工具方法,比如给 Context 添加 toast 扩展、给 View 添加 visible/gone 扩展,代码更简洁且不需要继承。

7. 协程的结构化并发是什么?为什么重要?

考察点:协程生命周期管理

完整回答

结构化并发是指协程的生命周期被限定在一个作用域(CoroutineScope)内,形成父子层级关系:

  1. 父协程取消时,所有子协程自动取消
  2. 父协程会等待所有子协程完成后才完成
  3. 子协程的异常会传播到父协程
kotlin
// 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 语法

完整回答

三种用法:

  1. 对象声明(单例)
kotlin
object AppConfig {
    val baseUrl = "https://api.example.com"
}
// 编译为:static final INSTANCE + static 初始化块(线程安全的饿汉式单例)
  1. 伴生对象(companion object)
kotlin
class User {
    companion object {
        fun create(): User = User() // 类似 Java 的静态方法
    }
}
User.create()
  1. 匿名对象(对象表达式)
kotlin
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() 后:

  1. Job 的状态变为 Cancelling
  2. 在下一个挂起点(suspend 函数调用处)检查取消状态
  3. 如果已取消,抛出 CancellationException
  4. CancellationException 不会被当作异常传播(正常取消流程)

关键点:如果协程中没有挂起点(纯 CPU 计算),取消不会生效:

kotlin
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++
}

追问:取消后怎么做清理工作?

kotlin
val job = launch {
    try {
        doWork()
    } finally {
        // 取消后执行清理
        withContext(NonCancellable) {
            // NonCancellable 确保即使已取消也能执行挂起函数
            closeResource()
        }
    }
}

10. Kotlin 的 val 和 var 的区别?val 是不是线程安全的?

考察点:Kotlin 基础

完整回答

  • val:只读引用(类似 Java final),赋值后不能重新赋值
  • var:可变引用,可以重新赋值

val 不等于不可变:

kotlin
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)。

kotlin
// ❌ 不安全:后台仍在收集
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
kotlin
val name: String? = null
val length = name?.length ?: 0

追问:开发中为什么要少用 !!

!! 会绕开 Kotlin 的空安全检查,一旦数据为空就会崩溃。更推荐使用安全调用、默认值、提前 return 或明确的异常处理。

13. valvar 的区别?

考察点:变量声明、不可变思想

完整回答

  • val 声明只读引用,初始化后不能重新赋值。
  • var 声明可变变量,可以重新赋值。
kotlin
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() 解构方法
kotlin
data class User(val id: Long, val name: String)

val user = User(1, "Tom")
val newUser = user.copy(name = "Jerry")

追问:data class 适合做什么?

适合接口响应、列表 item、页面 UI 状态等数据承载对象。不适合承载复杂业务行为或需要继承体系的对象。

计算机网络

网络协议基础

HTTP/HTTPS 协议

TCP 三次握手
客户端              服务端
  │── SYN(seq=x) ──→│        第一次:客户端发起连接
  │←─ SYN+ACK ─────│        第二次:服务端确认并发起连接(seq=y, ack=x+1)
  │── ACK(ack=y+1)─→│        第三次:客户端确认

为什么三次?两次无法确认客户端的接收能力。如果只有两次,服务端无法确认客户端收到了自己的 SYN+ACK。

TCP 四次挥手
客户端              服务端
  │── FIN ──────→│    第一次:客户端请求关闭
  │←─ ACK ──────│    第二次:服务端确认(可能还有数据要发)
  │←─ FIN ──────│    第三次:服务端数据发完,请求关闭
  │── ACK ──────→│    第四次:客户端确认
  │ TIME_WAIT(2MSL) │

为什么四次?因为 TCP 是全双工,每个方向需要单独关闭。服务端收到 FIN 后可能还有数据要发,所以 ACK 和 FIN 分开发。

TIME_WAIT(2MSL):确保最后一个 ACK 能到达服务端,以及让网络中残留的数据包过期。

TLS 握手(HTTPS)
客户端                          服务端
  │── ClientHello ──────────→│   支持的TLS版本、加密套件、随机数A
  │←─ ServerHello ──────────│   选定的加密套件、随机数B、证书
  │   验证证书(CA 链)          │
  │── 预主密钥(用证书公钥加密)──→│
  │   双方用 随机数A+B+预主密钥   │
  │   生成对称密钥               │
  │←─ Finished ─────────────│
  │── Finished ─────────────→│
  │   后续用对称密钥加密通信      │

HTTPS = HTTP + TLS。非对称加密交换密钥,对称加密传输数据。

HTTP/2
  • 多路复用:一个 TCP 连接上并行多个请求/响应(Stream),解决 HTTP/1.1 的队头阻塞
  • 头部压缩:HPACK 算法,维护静态/动态表,减少重复头部传输
  • 服务端推送:服务端主动推送资源
  • 二进制分帧:数据分为 HEADERS 帧和 DATA 帧
HTTP 缓存
强缓存(不发请求):
  Cache-Control: max-age=3600    优先级高
  Expires: Thu, 01 Dec 2025      绝对时间,有时区问题

协商缓存(发请求验证):
  Last-Modified / If-Modified-Since    精度秒级
  ETag / If-None-Match                 精确到内容hash,优先级高
  命中返回 304 Not Modified

WebSocket

与 HTTP 的区别
  • HTTP:请求-响应模式,客户端发起
  • WebSocket:全双工通信,服务端可以主动推送
连接过程
客户端发送 HTTP 升级请求:
GET /chat HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: xxx

服务端响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: yyy

→ 后续通过 WebSocket 帧通信
OkHttp WebSocket
kotlin
val client = OkHttpClient()
val request = Request.Builder().url("wss://example.com/ws").build()

client.newWebSocket(request, object : WebSocketListener() {
    override fun onOpen(webSocket: WebSocket, response: Response) {
        webSocket.send("Hello")
    }
    override fun onMessage(webSocket: WebSocket, text: String) {
        // 收到消息
    }
    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
        // 连接失败,需要重连
    }
})

Protobuf vs JSON

维度ProtobufJSON
格式二进制文本
体积小(约 JSON 的 1/3-1/10)
解析速度快(直接映射内存)慢(需要解析字符串)
可读性不可读可读
Schema需要 .proto 文件定义无 Schema(灵活但不安全)
适用场景高性能、大数据量、RPCREST API、调试友好

Android 中 Protobuf 的应用:gRPC 通信、DataStore Proto 模式、MMKV 内部编码。

1. HTTPS 的原理?TLS 握手过程?

考察点:网络安全

完整回答

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

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

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

追问:证书验证的过程?

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

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

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

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 的区别?

考察点:网络基础

完整回答

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

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

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

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

四大组件与生命周期

Fragment 与 Service 原理内容较多,已拆分为独立文件:

  • fragment-service-deep.md — Fragment 双生命周期详解(add/replace/hide/show 对比)、Fragment 通信、Service 两种模式混合使用、前台 Service、Bound Service 与 AIDL

Activity 生命周期

正常生命周期
onCreate → onStart → onResume → [运行中] → onPause → onStop → onDestroy
  • onCreate:创建 Activity,setContentView、初始化数据。参数 savedInstanceState 用于恢复状态
  • onStart:可见但不可交互
  • onResume:可见且可交互,前台状态
  • onPause:部分可见(如弹出对话框 Activity),不要做耗时操作(影响下一个 Activity 显示)
  • onStop:完全不可见
  • onDestroy:销毁,释放资源
关键场景分析

A 启动 B

A.onPause → B.onCreate → B.onStart → B.onResume → A.onStop

注意:A.onPause 先执行,所以 onPause 中不能做耗时操作,否则 B 的显示会延迟。

B 返回 A

B.onPause → A.onRestart → A.onStart → A.onResume → B.onStop → B.onDestroy

配置变更(如旋转屏幕)

onPause → onStop → onSaveInstanceState → onDestroy → onCreate → onStart → onRestoreInstanceState → onResume

Activity 被销毁重建。数据保存在 onSaveInstanceState(Bundle),恢复在 onCreateonRestoreInstanceState

ViewModel 不受配置变更影响:ViewModel 存储在 ViewModelStore 中,ViewModelStore 通过 onRetainNonConfigurationInstance 在配置变更时保留。

异常销毁与恢复

系统内存不足时可能杀死后台 Activity。恢复时:

  • onSaveInstanceState 保存的 Bundle 会传给 onCreate
  • View 系统自动保存/恢复有 id 的 View 的状态(EditText 文本、ScrollView 位置等)
  • 大数据用 SavedStateHandle(ViewModel 中)或持久化存储

Activity 启动模式

四种启动模式

standard(默认):每次启动创建新实例,放入启动它的 Task 栈顶。

singleTop:栈顶复用。如果目标 Activity 已在栈顶,不创建新实例,调用 onNewIntent。不在栈顶则创建新实例。

  • 场景:通知点击打开详情页(避免重复创建)

singleTask:栈内复用。在目标 Task 中查找,如果已存在则将其上方的 Activity 全部出栈(clearTop),调用 onNewIntent

  • 场景:首页 Activity(从任何地方回到首页,清除中间页面)

singleInstance:独占 Task。Activity 独占一个 Task 栈,整个系统中只有一个实例。

  • 场景:来电界面、系统级页面
源码级分析

启动模式的处理在 AMS(ActivityManagerService)中:

  • TaskRecord:代表一个 Task 栈
  • ActivityRecord:代表一个 Activity 实例
  • ActivityStack:管理多个 TaskRecord

singleTask 的查找逻辑:

  1. 遍历所有 TaskRecord,查找是否有匹配的 ActivityRecord
  2. 找到则将该 Task 移到前台,清除目标 Activity 上方的所有 Activity
  3. 调用目标 Activity 的 onNewIntent
Intent Flags
java
// 等同于 singleTop
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);

// 清除目标 Activity 上方的所有 Activity(配合 singleTask 效果)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);

// 创建新 Task
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

// 清除 Task 中所有 Activity,重新创建目标 Activity
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);

Fragment

生命周期

Fragment 有自己的生命周期,且与宿主 Activity 关联:

onAttach → onCreate → onCreateView → onViewCreated → onViewStateRestored
→ onStart → onResume → [运行中]
→ onPause → onStop → onDestroyView → onDestroy → onDetach

关键区别

  • onCreateView:创建 Fragment 的 View 层级
  • onViewCreated:View 已创建,可以安全地 findViewById
  • onDestroyView:View 被销毁但 Fragment 实例可能还在(如加入 back stack)
ViewLifecycleOwner

Fragment 有两个生命周期:

  • Fragment 自身生命周期(onCreate → onDestroy)
  • View 生命周期(onCreateView → onDestroyView)

重要:在 Fragment 中观察 LiveData/Flow 时,应该使用 viewLifecycleOwner 而非 this

kotlin
// ✅ 正确:使用 viewLifecycleOwner
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewModel.data.observe(viewLifecycleOwner) { data ->
        // 更新 UI
    }
}

// ❌ 错误:使用 this(Fragment 生命周期)
// Fragment 加入 back stack 后 View 被销毁但 Fragment 还在
// 返回时重新创建 View,但旧的 Observer 还在,导致重复观察
Fragment 通信
kotlin
// 1. ViewModel 共享(推荐)
// Activity 和多个 Fragment 共享同一个 ViewModel
val sharedViewModel: SharedViewModel by activityViewModels()

// 2. Fragment Result API(Jetpack 推荐)
// 发送方
setFragmentResult("requestKey", bundleOf("data" to "value"))
// 接收方
setFragmentResultListener("requestKey") { _, bundle ->
    val data = bundle.getString("data")
}

// 3. 接口回调(传统方式)
interface OnItemClickListener {
    fun onItemClick(item: Item)
}

Service

两种启动方式

startService

onCreate → onStartCommand → [运行中] → onDestroy
  • 调用 stopSelf()stopService() 停止
  • onStartCommand 返回值决定被杀后的重启策略:
    • START_NOT_STICKY:不重启
    • START_STICKY:重启但 Intent 为 null
    • START_REDELIVER_INTENT:重启并重新传递最后一个 Intent

bindService

onCreate → onBind → [客户端绑定中] → onUnbind → onDestroy
  • 返回 IBinder 供客户端通信
  • 所有客户端解绑后自动销毁
  • 可以同时 start + bind,需要同时 stop + unbind 才会销毁
前台 Service

Android 8.0+ 后台 Service 限制严格,长时间运行需要前台 Service:

kotlin
class MyForegroundService : Service() {
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("运行中")
            .setSmallIcon(R.drawable.ic_notification)
            .build()
        startForeground(NOTIFICATION_ID, notification)
        return START_STICKY
    }
}

Android 12+ 需要声明 android:foregroundServiceType。 Android 14+ 进一步限制,必须指定具体类型(dataSync/location/mediaPlayback 等)。

BroadcastReceiver

注册方式

静态注册(AndroidManifest):

  • 应用未启动也能接收(Android 8.0+ 大部分隐式广播不再支持静态注册)
  • 适合:开机广播、应用安装/卸载广播

动态注册(代码):

kotlin
val receiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        // 处理广播,不能做耗时操作(10秒 ANR 限制)
    }
}
registerReceiver(receiver, IntentFilter("com.example.MY_ACTION"))
// 必须在适当时机 unregisterReceiver 避免泄漏
有序广播 vs 普通广播
  • 普通广播(sendBroadcast):异步分发,所有接收者几乎同时收到
  • 有序广播(sendOrderedBroadcast):按优先级依次分发,可以拦截(abortBroadcast
本地广播

LocalBroadcastManager(已废弃)→ 推荐用 LiveData、Flow 或 EventBus 替代。

ContentProvider

作用

跨进程数据共享的标准接口。即使在同一个应用内,也可以用 ContentProvider 封装数据访问。

启动时序

ContentProvider 在 Application.onCreate 之前初始化:

Application.attachBaseContext
→ ContentProvider.onCreate(所有 Provider)
→ Application.onCreate

这就是为什么很多库(如 Firebase、WorkManager)用 ContentProvider 做自动初始化——不需要用户手动在 Application 中调用 init。但这也会拖慢启动速度。

Jetpack App Startup 库通过合并多个 ContentProvider 为一个来优化启动性能。

跨进程原理

ContentProvider 底层通过 Binder 通信。ContentResolver 是客户端代理,通过 AMS 查找目标 Provider 的 Binder 引用,然后直接跨进程调用。

大数据传输(如文件)通过 ParcelFileDescriptor 传递文件描述符,避免 Binder 传输大小限制(1MB)。

Context 体系

继承关系
Context (抽象类)
├── ContextImpl (真正的实现)
├── ContextWrapper (装饰器)
│   ├── Application
│   ├── Service
│   └── ContextThemeWrapper
│       └── Activity
不同 Context 的能力差异
操作ApplicationActivityService
启动 Activity✅(需 NEW_TASK)✅(需 NEW_TASK)
弹 Dialog
Layout Inflation⚠️(无主题)⚠️(无主题)
启动 Service
发送广播
访问资源

核心原则

  • 涉及 UI 的操作用 Activity Context
  • 生命周期长的操作(如单例持有)用 Application Context,避免内存泄漏
  • getApplicationContext() 返回 Application 实例
  • getBaseContext() 返回 ContextWrapper 内部的 ContextImpl
常见坑
kotlin
// ❌ 内存泄漏:单例持有 Activity Context
object Manager {
    lateinit var context: Context // 如果传入 Activity,Activity 无法被回收
}

// ✅ 正确:使用 Application Context
object Manager {
    lateinit var context: Context
    fun init(context: Context) {
        this.context = context.applicationContext
    }
}

Activity Result API

替代 startActivityForResult + onActivityResult(已废弃):

kotlin
// 注册(在 onCreate 之前,通常作为成员变量)
val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    if (result.resultCode == RESULT_OK) {
        val data = result.data
        // 处理结果
    }
}

// 启动
launcher.launch(Intent(this, SecondActivity::class.java))

// 常用内置 Contract
ActivityResultContracts.TakePicture()           // 拍照
ActivityResultContracts.PickContact()            // 选择联系人
ActivityResultContracts.RequestPermission()      // 请求单个权限
ActivityResultContracts.RequestMultiplePermissions() // 请求多个权限
ActivityResultContracts.GetContent()             // 选择文件

与旧方式的对比

旧方式 startActivityForResult 需要开发者手动定义 requestCode,所有结果都回到同一个 onActivityResult 方法中,用 requestCode 做 switch 区分:

kotlin
// 旧方式:所有结果集中在一个回调,用 requestCode 区分
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    when (requestCode) {
        100 -> { /* 相机 */ }
        200 -> { /* 相册 */ }
        // 功能越多,这里越臃肿
    }
}

Activity Result API 中,每个 registerForActivityResult 创建独立的 ActivityResultLauncher,各自有自己的回调,天然隔离。框架内部(ActivityResultRegistry)仍然使用 requestCode 与系统通信,但这个 requestCode 由框架自动分配和管理,开发者完全不需要感知。

优势:解耦(每个 launcher 独立回调,无需集中式 requestCode 分发)、可测试(Contract 可以单独测试)、类型安全(不同 Contract 有明确的输入/输出类型)。

PendingIntent

PendingIntent 是对 Intent 的包装,允许其他应用或系统在未来某个时刻代替你执行操作:

kotlin
// 创建
val pendingIntent = PendingIntent.getActivity(
    context, requestCode, intent,
    PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

使用场景:

  • 通知点击(NotificationCompat.Builder.setContentIntent)
  • AlarmManager 定时任务
  • Widget 点击事件
  • 地理围栏触发

FLAG 说明

  • FLAG_IMMUTABLE(Android 12+ 必须指定):PendingIntent 不可被修改
  • FLAG_MUTABLE:允许修改(如 inline reply)
  • FLAG_UPDATE_CURRENT:如果已存在则更新 extras
  • FLAG_ONE_SHOT:只能使用一次

多窗口与画中画

多窗口模式(Android 7.0+)
xml
<!-- AndroidManifest.xml -->
<activity android:resizeableActivity="true" />

生命周期变化:多窗口模式下,可见但非焦点的 Activity 处于 onPause 状态(Android 10+ 改为 onResume,多个 Activity 可以同时 RESUMED)。

画中画(PiP,Android 8.0+)
kotlin
// 进入画中画
val params = PictureInPictureParams.Builder()
    .setAspectRatio(Rational(16, 9))
    .build()
enterPictureInPictureMode(params)

// 监听画中画状态变化
override fun onPictureInPictureModeChanged(isInPiP: Boolean, config: Configuration) {
    if (isInPiP) {
        // 隐藏非必要 UI 元素
    } else {
        // 恢复完整 UI
    }
}

Android 各版本重要变更

版本重要变更
Android 6.0 (API 23)运行时权限
Android 7.0 (API 24)多窗口、FileProvider(禁止 file:// URI)
Android 8.0 (API 26)通知渠道、后台 Service 限制、自适应图标
Android 9.0 (API 28)刘海屏适配、禁止 HTTP 明文(需 networkSecurityConfig)
Android 10 (API 29)分区存储、后台位置权限、深色模式
Android 11 (API 30)强制分区存储、包可见性、前台 Service 类型
Android 12 (API 31)SplashScreen API、PendingIntent 必须指定 mutability、蓝牙权限变更
Android 13 (API 33)通知权限(POST_NOTIFICATIONS)、细粒度媒体权限
Android 14 (API 34)前台 Service 类型必须指定、照片/视频部分访问权限

Fragment 生命周期详解

Fragment 与 Activity 生命周期同步
Activity.onCreate()
  → Fragment.onAttach()
  → Fragment.onCreate()
  → Fragment.onCreateView()
  → Fragment.onViewCreated()
  → Fragment.onActivityCreated()  // 已废弃,用 onViewCreated 替代
  → Fragment.onStart()

Activity.onStart()
  → Fragment.onStart()

Activity.onResume()
  → Fragment.onResume()

Activity.onPause()
  → Fragment.onPause()

Activity.onStop()
  → Fragment.onStop()

Activity.onDestroy()
  → Fragment.onDestroyView()
  → Fragment.onDestroy()
  → Fragment.onDetach()
Fragment 的两个生命周期

Fragment 有两个独立的生命周期:

Fragment 自身生命周期:onAttach → onCreate → ... → onDestroy → onDetach
Fragment View 生命周期:onCreateView → onViewCreated → ... → onDestroyView

为什么要区分?因为 Fragment 可以在 View 销毁后仍然存活(如加入 back stack):

kotlin
// replace + addToBackStack
supportFragmentManager.commit {
    replace(R.id.container, FragmentB())
    addToBackStack(null)
}

// FragmentA 的生命周期:
// onPause → onStop → onDestroyView  ← View 销毁了
// 但 Fragment 实例还在 back stack 中,onCreate/onDestroy 没有调用

// 用户按返回键,FragmentA 恢复:
// onCreateView → onViewCreated → onStart → onResume  ← 重新创建 View

这就是为什么在 Fragment 中观察 LiveData 要用 viewLifecycleOwner 而不是 this

kotlin
// ❌ 使用 Fragment 生命周期:Fragment 在 back stack 中时 Observer 仍然活跃
// 恢复后又注册新的 Observer,导致重复观察
viewModel.data.observe(this) { ... }

// ✅ 使用 View 生命周期:View 销毁时 Observer 自动移除
viewModel.data.observe(viewLifecycleOwner) { ... }
add/replace/hide/show 对生命周期的影响

add

kotlin
supportFragmentManager.commit {
    add(R.id.container, FragmentA())
}
// FragmentA: onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume

add 第二个 Fragment(不移除第一个)

kotlin
supportFragmentManager.commit {
    add(R.id.container, FragmentB())
}
// FragmentA: 无变化(仍然 RESUMED,只是被遮挡)
// FragmentB: onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume

replace

kotlin
supportFragmentManager.commit {
    replace(R.id.container, FragmentB())
}
// FragmentA: onPause → onStop → onDestroyView → onDestroy → onDetach(完全销毁)
// FragmentB: onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume

replace + addToBackStack

kotlin
supportFragmentManager.commit {
    replace(R.id.container, FragmentB())
    addToBackStack(null)
}
// FragmentA: onPause → onStop → onDestroyView(View 销毁,Fragment 保留)
// FragmentB: onAttach → onCreate → onCreateView → onViewCreated → onStart → onResume

// 按返回键:
// FragmentB: onPause → onStop → onDestroyView → onDestroy → onDetach
// FragmentA: onCreateView → onViewCreated → onStart → onResume(重建 View)

hide/show

kotlin
supportFragmentManager.commit {
    hide(fragmentA)
    show(fragmentB)
}
// 不触发任何生命周期回调!
// 只是改变 View 的 visibility
// 可以通过 onHiddenChanged(hidden: Boolean) 监听
Fragment 通信
kotlin
// 方案1:Fragment Result API(Jetpack 推荐)
// 发送方
parentFragmentManager.setFragmentResult("requestKey", bundleOf("data" to "value"))

// 接收方
parentFragmentManager.setFragmentResultListener("requestKey", viewLifecycleOwner) { _, bundle ->
    val data = bundle.getString("data")
}

// 方案2:共享 ViewModel
// 两个 Fragment 共享 Activity 级别的 ViewModel
class SharedViewModel : ViewModel() {
    val selectedItem = MutableLiveData<Item>()
}

// FragmentA
class FragmentA : Fragment() {
    private val sharedViewModel: SharedViewModel by activityViewModels()

    fun onItemSelected(item: Item) {
        sharedViewModel.selectedItem.value = item
    }
}

// FragmentB
class FragmentB : Fragment() {
    private val sharedViewModel: SharedViewModel by activityViewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        sharedViewModel.selectedItem.observe(viewLifecycleOwner) { item ->
            displayItem(item)
        }
    }
}
Fragment 常见坑
kotlin
// 坑1:getActivity() 返回 null
// Fragment detach 后 getActivity() 返回 null
// 解决:在使用前判空,或使用 requireActivity()

// 坑2:重叠问题
// 配置变更时 Activity 重建,FragmentManager 自动恢复 Fragment
// 如果在 onCreate 中无条件 add Fragment,会导致重叠
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    if (savedInstanceState == null) {  // ← 只在首次创建时添加
        supportFragmentManager.commit {
            add(R.id.container, MyFragment())
        }
    }
}

// 坑3:commit 时机
// onSaveInstanceState 之后不能 commit(会丢失状态)
// 解决:使用 commitAllowingStateLoss()(接受可能丢失状态)
// 或确保在 onResume 之前 commit

// 坑4:嵌套 Fragment 使用 childFragmentManager
// 子 Fragment 应该用 childFragmentManager,不是 parentFragmentManager
childFragmentManager.commit {
    add(R.id.child_container, ChildFragment())
}

Service 深入

两种启动方式的生命周期

startService

startService()
  → onCreate()(首次创建)
  → onStartCommand()(每次 startService 都调用)
  → 服务运行中...
  → stopSelf() 或 stopService()
  → onDestroy()

bindService

bindService()
  → onCreate()(首次创建)
  → onBind()(返回 IBinder)
  → 客户端通过 IBinder 调用服务方法
  → unbindService()(所有客户端都解绑后)
  → onUnbind()
  → onDestroy()

混合使用

startService() → onCreate → onStartCommand
bindService()  → onBind
unbindService() → onUnbind
// 此时服务不会销毁,因为还有 startService 保活
stopService() → onDestroy
// 必须同时 stop + 所有 client unbind 才会销毁
onStartCommand 返回值
kotlin
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    return START_STICKY
}

// START_NOT_STICKY:被杀后不重建。适合可以重新触发的任务
// START_STICKY:被杀后重建,但 intent 为 null。适合音乐播放器等
// START_REDELIVER_INTENT:被杀后重建,重新传递最后一个 intent。适合必须完成的任务
前台 Service

Android 8.0+ 后台 Service 限制严格,长时间运行的服务必须前台化:

kotlin
class MusicService : Service() {

    override fun onCreate() {
        super.onCreate()

        // 创建通知渠道(Android 8.0+)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                "music_channel",
                "音乐播放",
                NotificationManager.IMPORTANCE_LOW
            )
            getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
        }

        // 构建通知
        val notification = NotificationCompat.Builder(this, "music_channel")
            .setContentTitle("正在播放")
            .setContentText("歌曲名称")
            .setSmallIcon(R.drawable.ic_music)
            .build()

        // 前台化(必须在 onCreate 后 5 秒内调用)
        startForeground(1, notification)
    }

    override fun onBind(intent: Intent?): IBinder? = null
}

// 启动前台 Service
// Android 8.0+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    startForegroundService(Intent(this, MusicService::class.java))
} else {
    startService(Intent(this, MusicService::class.java))
}

// Android 12+ 需要在 Manifest 声明前台服务类型
// <service android:name=".MusicService"
//     android:foregroundServiceType="mediaPlayback" />
IntentService → WorkManager
kotlin
// IntentService(已废弃)
// 内部使用 HandlerThread,串行处理每个 Intent
class MyIntentService : IntentService("MyIntentService") {
    override fun onHandleIntent(intent: Intent?) {
        // 在子线程中执行
        // 所有 Intent 处理完后自动 stopSelf
    }
}

// 替代方案:WorkManager
class MyWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
    override suspend fun doWork(): Result {
        // 在后台执行
        return Result.success()
    }
}

// 一次性任务
val request = OneTimeWorkRequestBuilder<MyWorker>()
    .setConstraints(Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build())
    .build()
WorkManager.getInstance(context).enqueue(request)

// 周期任务(最小 15 分钟)
val periodicRequest = PeriodicWorkRequestBuilder<MyWorker>(15, TimeUnit.MINUTES)
    .build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "sync_work",
    ExistingPeriodicWorkPolicy.KEEP,
    periodicRequest
)
Bound Service 与 AIDL
kotlin
// 同进程绑定(Binder 直接调用)
class LocalService : Service() {
    inner class LocalBinder : Binder() {
        fun getService(): LocalService = this@LocalService
    }

    private val binder = LocalBinder()

    override fun onBind(intent: Intent): IBinder = binder

    fun doSomething(): String = "result"
}

// 客户端
class MyActivity : AppCompatActivity() {
    private var service: LocalService? = null

    private val connection = object : ServiceConnection {
        override fun onServiceConnected(name: ComponentName, binder: IBinder) {
            service = (binder as LocalService.LocalBinder).getService()
            service?.doSomething()
        }

        override fun onServiceDisconnected(name: ComponentName) {
            service = null
        }
    }

    override fun onStart() {
        super.onStart()
        bindService(Intent(this, LocalService::class.java), connection, BIND_AUTO_CREATE)
    }

    override fun onStop() {
        super.onStop()
        unbindService(connection)
    }
}
kotlin
// 跨进程绑定(AIDL)
// IRemoteService.aidl
interface IRemoteService {
    int calculate(int a, int b);
    void registerCallback(ICallback callback);
}

// Server 端
class RemoteService : Service() {
    // 使用 RemoteCallbackList 管理跨进程回调(自动处理死亡通知)
    private val callbacks = RemoteCallbackList<ICallback>()

    private val binder = object : IRemoteService.Stub() {
        override fun calculate(a: Int, b: Int): Int {
            return a + b  // 在 Binder 线程池中执行,注意线程安全
        }

        override fun registerCallback(callback: ICallback) {
            callbacks.register(callback)
        }
    }

    override fun onBind(intent: Intent): IBinder = binder

    // 通知所有客户端
    private fun notifyClients(result: String) {
        val count = callbacks.beginBroadcast()
        for (i in 0 until count) {
            try {
                callbacks.getBroadcastItem(i).onResult(result)
            } catch (e: RemoteException) {
                // 客户端进程已死
            }
        }
        callbacks.finishBroadcast()
    }
}

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 有两个生命周期:

  1. Fragment 实例生命周期(onCreate → onDestroy)
  2. 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 之间怎么通信?

推荐方案:

  1. 共享 ViewModel:by activityViewModels() 获取 Activity 级别的 ViewModel
  2. Fragment Result API:setFragmentResult/setFragmentResultListener
  3. 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。解决方案:

  1. 前台 Service:startForeground() 显示通知
  2. WorkManager:适合可延迟的后台任务
  3. 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 的区别?什么时候用哪个?

考察点:状态保存机制

完整回答

维度onSaveInstanceStateViewModel
存储位置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。默认值是应用包名。

影响:

  1. singleTask:先查找 taskAffinity 匹配的 Task,找到则在该 Task 中复用/创建 Activity。不同 taskAffinity 会创建新 Task。
  2. FLAG_ACTIVITY_NEW_TASK:如果目标 Activity 的 taskAffinity 与当前 Task 不同,会在匹配的 Task 中启动(或创建新 Task)。
  3. allowTaskReparenting:当 Activity 的 taskAffinity 对应的 Task 来到前台时,Activity 会从当前 Task 移到该 Task。
xml
<activity
    android:name=".DetailActivity"
    android:taskAffinity="com.example.detail"
    android:launchMode="singleTask" />

9. Android 的权限机制?运行时权限怎么处理?

考察点:权限系统

完整回答

Android 6.0+ 引入运行时权限,危险权限需要在运行时请求:

kotlin
// 推荐: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 三个条件:

  1. action:Intent 的 action 必须与 IntentFilter 中的某一个 action 匹配
  2. category:Intent 的所有 category 都必须在 IntentFilter 中存在。系统会自动添加 CATEGORY_DEFAULT,所以 IntentFilter 必须包含它
  3. data:匹配 URI(scheme://host:port/path)和 mimeType
xml
<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 让系统匹配能处理的组件,常用于打开浏览器、相机、分享等跨应用场景。
kotlin
// 显式启动
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 类的设计初衷就是保存全局状态,并执行应用范围内的初始化操作。开发者通常会继承这个类,用以设置依赖项、配置第三方库,以及管理那些需要在多个 ActivityService 之间持续存在的资源。

默认情况下,每个 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内存泄漏

  1. 静态变量持有Activity引用

单例类或静态变量直接或间接持有Activity的Context

Kotlin
object Singleton {var context: Context? = null // 错误:可能持有Activity的引用}

解决方案

  • 把 Activity Context用Application Context来代替

  • 如果实在要引用的话就用弱引用(WeakReference)

kotlin
class Singleton {
    private var activityRef: WeakReference<Activity>? = null 
       fun setActivity(activity: Activity) {
               activityRef = WeakReference(activity)
       }
 }
  1. 非静态内部类+匿名类

比如Handler、Runnable等内部类隐式持有Activity引用。

解决:静态内部类+弱引用,在onDestroy()移除回调

  1. 未正确注销监听器或者回调

场景:注册广播,事件总线,监听器没有及时注销

kotlin
override fun onCreate(savedInstanceState: Bundle?){
    super onCreate(savedInstanceState)
    LocalBroadcastManager.getInstance(this).registerReceiver(receiver,intentFilter)
}

解决方案:在onDestory()中进行反注册

Kotlin
override fun onDestory(){
    LocalBroadcastManager.getInstance(this).unregisterReceiver(reciver)
    super onDestory
}
  1. 异步任务没有随Activity销毁终止(AsyncTask、Rxjava、Coroutine等)

解决方法:使用lifecycleScope

  1. 资源未释放

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的引用,避免内存泄露

UI 绘制与事件分发

View 绘制流程

整体流程

绘制从 ViewRootImpl.performTraversals() 开始,依次执行三大流程:

performTraversals()
├── performMeasure()   → View.measure() → onMeasure()
├── performLayout()    → View.layout() → onLayout()
└── performDraw()      → View.draw() → onDraw()

触发时机:requestLayout()(触发 measure + layout)、invalidate()(触发 draw)。

MeasureSpec

MeasureSpec 是一个 32 位 int 值,高 2 位是模式,低 30 位是大小:

java
// 三种模式
EXACTLY    // 精确值:match_parent 或具体 dp 值
AT_MOST    // 最大值:wrap_content,不能超过父容器剩余空间
UNSPECIFIED // 无限制:ScrollView 中的子 View

父 SpecMode + 子 LayoutParams → 子 MeasureSpec 决策表

父 Mode子 LP = 具体值子 LP = match_parent子 LP = wrap_content
EXACTLYEXACTLY + 子值EXACTLY + 父大小AT_MOST + 父大小
AT_MOSTEXACTLY + 子值AT_MOST + 父大小AT_MOST + 父大小
UNSPECIFIEDEXACTLY + 子值UNSPECIFIED + 0UNSPECIFIED + 0

源码在 ViewGroup.getChildMeasureSpec()

java
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);

    int resultSize = 0;
    int resultMode = 0;

    switch (specMode) {
        case MeasureSpec.EXACTLY:
            if (childDimension >= 0) {          // 具体值
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == MATCH_PARENT) {
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == WRAP_CONTENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
        // ... AT_MOST 和 UNSPECIFIED 类似
    }
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
自定义 View 的 onMeasure

wrap_content 不处理时为什么等同于 match_parent?

从决策表可以看到,当父是 EXACTLY 时,wrap_content 得到的是 AT_MOST + 父大小。如果自定义 View 的 onMeasure 不处理 AT_MOST 模式,直接用 specSize,那就等于父容器大小,和 match_parent 效果一样。

正确处理:

java
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);

    int width;
    if (widthMode == MeasureSpec.EXACTLY) {
        width = widthSize; // 精确值,直接用
    } else {
        int desiredWidth = calculateContentWidth() + getPaddingLeft() + getPaddingRight();
        if (widthMode == MeasureSpec.AT_MOST) {
            width = Math.min(desiredWidth, widthSize); // wrap_content,取较小值
        } else {
            width = desiredWidth; // UNSPECIFIED,用期望值
        }
    }
    setMeasuredDimension(width, height);
}
onLayout

ViewGroup 必须重写 onLayout,确定每个子 View 的位置:

java
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childTop = getPaddingTop();
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() != GONE) {
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            child.layout(getPaddingLeft(), childTop,
                        getPaddingLeft() + childWidth, childTop + childHeight);
            childTop += childHeight;
        }
    }
}
onDraw

绘制顺序:

  1. drawBackground():绘制背景
  2. onDraw():绘制自身内容
  3. dispatchDraw():绘制子 View
  4. onDrawForeground():绘制前景(滚动条等)
java
@Override
protected void onDraw(Canvas canvas) {
    // 使用 Canvas API 绘制
    canvas.drawCircle(centerX, centerY, radius, paint);
    canvas.drawText(text, x, y, textPaint);
    canvas.drawBitmap(bitmap, srcRect, dstRect, paint);
}
requestLayout vs invalidate
  • requestLayout():标记 PFLAG_FORCE_LAYOUT,向上传递到 ViewRootImpl,强制触发 measure + layout。如果布局发生变化,View.layout() 内部会调用 invalidate() 标记脏区域,连带触发 draw
  • invalidate():标记脏区域,只触发 draw
  • postInvalidateOnAnimation():在下一帧绘制时 invalidate,避免同一帧多次绘制

事件分发机制

三个核心方法
java
// 分发事件
public boolean dispatchTouchEvent(MotionEvent ev)
// 拦截事件(只有 ViewGroup 有)
public boolean onInterceptTouchEvent(MotionEvent ev)
// 消费事件
public boolean onTouchEvent(MotionEvent ev)
分发流程
Activity.dispatchTouchEvent
  → PhoneWindow.superDispatchTouchEvent
    → DecorView.dispatchTouchEvent
      → ViewGroup.dispatchTouchEvent
        → ViewGroup.onInterceptTouchEvent  // 是否拦截?

        ├── 不拦截 → 遍历子 View
        │   → child.dispatchTouchEvent
        │     → child.onTouchEvent         // 子 View 消费?
        │     ├── true → 事件被消费,结束
        │     └── false → 回传给父 ViewGroup.onTouchEvent

        └── 拦截 → ViewGroup.onTouchEvent  // 自己处理
关键规则
  1. DOWN 事件决定后续事件的接收者:如果某个 View 在 DOWN 时返回 true(消费),后续的 MOVE/UP 都会直接发给它

  2. 一旦拦截,后续事件不再询问 onInterceptTouchEvent:ViewGroup 拦截后,后续事件直接交给自己的 onTouchEvent

  3. 子 View 可以请求父 ViewGroup 不拦截

java
parent.requestDisallowInterceptTouchEvent(true);
// 设置 FLAG_DISALLOW_INTERCEPT,父 ViewGroup 的 onInterceptTouchEvent 不会被调用
// 但 DOWN 事件会重置这个标志
  1. onTouchListener 优先于 onTouchEvent
java
// dispatchTouchEvent 中的逻辑
if (mOnTouchListener != null && mOnTouchListener.onTouch(this, event)) {
    return true; // OnTouchListener 消费了,不调用 onTouchEvent
}
return onTouchEvent(event); // 否则调用 onTouchEvent
  1. onClick 在 onTouchEvent 的 ACTION_UP 中触发
源码关键逻辑(ViewGroup.dispatchTouchEvent)
java
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    boolean handled = false;

    // DOWN 事件重置状态
    if (actionMasked == MotionEvent.ACTION_DOWN) {
        cancelAndClearTouchTargets(ev);
        resetTouchState(); // 清除 FLAG_DISALLOW_INTERCEPT
    }

    // 判断是否拦截
    final boolean intercepted;
    if (actionMasked == ACTION_DOWN || mFirstTouchTarget != null) {
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);
        } else {
            intercepted = false;
        }
    } else {
        // 没有子 View 消费 DOWN,后续事件直接拦截
        intercepted = true;
    }

    // 不拦截则遍历子 View
    if (!intercepted) {
        // 按 Z 序从上到下遍历子 View
        for (int i = childrenCount - 1; i >= 0; i--) {
            // 判断触摸点是否在子 View 范围内
            if (dispatchTransformedTouchEvent(ev, child)) {
                mFirstTouchTarget = addTouchTarget(child);
                break;
            }
        }
    }

    // 分发给 target 或自己处理
    if (mFirstTouchTarget == null) {
        handled = dispatchTransformedTouchEvent(ev, null); // 自己的 onTouchEvent
    } else {
        // 分发给 mFirstTouchTarget 链表中的子 View
    }
    return handled;
}
滑动冲突解决

外部拦截法(推荐):在父 ViewGroup 的 onInterceptTouchEvent 中判断:

java
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            return false; // DOWN 不拦截,否则子 View 收不到事件
        case MotionEvent.ACTION_MOVE:
            if (needIntercept(ev)) return true; // 根据滑动方向判断
            break;
        case MotionEvent.ACTION_UP:
            return false;
    }
    return false;
}

内部拦截法:子 View 通过 requestDisallowInterceptTouchEvent 控制:

java
// 子 View
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            parent.requestDisallowInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            if (parentNeedEvent(ev)) {
                parent.requestDisallowInterceptTouchEvent(false);
            }
            break;
    }
    return super.dispatchTouchEvent(ev);
}

Window / WindowManager / DecorView

层级关系
Activity
  └── PhoneWindow(Window 的唯一实现)
        └── DecorView(FrameLayout 子类,Window 的根 View)
              ├── TitleBar / ActionBar
              └── ContentView(android.R.id.content)
                    └── 你的布局(setContentView 设置的)

setContentView 实际流程:

  1. Activity.setContentView → PhoneWindow.setContentView
  2. PhoneWindow 创建 DecorView(如果还没有)
  3. 将你的布局 inflate 到 DecorView 的 ContentView 中
WindowManager

WindowManager 管理 Window 的添加、更新和删除:

kotlin
// 添加悬浮窗
val params = WindowManager.LayoutParams(
    WindowManager.LayoutParams.WRAP_CONTENT,
    WindowManager.LayoutParams.WRAP_CONTENT,
    WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, // Android 8.0+ 需要此类型
    WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
    PixelFormat.TRANSLUCENT
)
windowManager.addView(floatingView, params)

Window 类型(z-order 从低到高):

  • 应用窗口(1-99):Activity
  • 子窗口(1000-1999):Dialog、PopupWindow
  • 系统窗口(2000-2999):Toast、状态栏、导航栏、悬浮窗
ViewRootImpl

ViewRootImpl 是 View 树的管理者:

  • 连接 WindowManager 和 DecorView
  • 触发 View 的三大流程(measure/layout/draw)
  • 接收输入事件并分发
  • 通过 Choreographer 与 VSYNC 同步

SurfaceView vs TextureView

特性SurfaceViewTextureView
渲染线程独立 Surface,可在子线程绑制共享 Window 的 Surface
动画/变换不支持(独立 Surface 不在 View 层级中)支持(是普通 View)
内存较少较多(额外的 SurfaceTexture)
性能更好(独立渲染)略差
适用场景视频播放、相机预览、游戏需要动画/变换的视频场景

SurfaceView 的"挖洞"原理:SurfaceView 在 Window 上"挖"一个透明区域,其独立 Surface 在 Window 下方显示。所以 SurfaceView 不能做半透明效果。

属性动画原理

核心类
kotlin
// ValueAnimator:值动画,只产生值的变化
ValueAnimator.ofFloat(0f, 1f).apply {
    duration = 300
    addUpdateListener { animator ->
        view.alpha = animator.animatedValue as Float
    }
    start()
}

// ObjectAnimator:属性动画,直接修改对象属性
ObjectAnimator.ofFloat(view, "translationX", 0f, 100f).apply {
    duration = 300
    start()
}
工作原理
  1. start() 注册到 Choreographer 的 VSYNC 回调
  2. 每帧 VSYNC 到来时,根据已过时间和 Interpolator 计算进度
  3. 通过 TypeEvaluator 计算当前值
  4. ObjectAnimator 通过反射调用 setXxx() 方法更新属性
  5. 属性变化触发 invalidate() 重绘
Interpolator 与 TypeEvaluator
  • Interpolator:控制动画速度曲线(线性、加速、减速、弹性等)
  • TypeEvaluator:计算属性值(IntEvaluator、FloatEvaluator、ArgbEvaluator)
kotlin
// 自定义 Interpolator
class BounceInterpolator : TimeInterpolator {
    override fun getInterpolation(input: Float): Float {
        // input: 0.0 → 1.0(时间进度)
        // return: 属性进度(可以超过 1.0 实现弹性效果)
    }
}
View 动画 vs 属性动画
  • View 动画(补间动画):只改变绘制位置,不改变实际属性。点击事件仍在原位置
  • 属性动画:真正改变对象属性。点击事件跟随移动

ConstraintLayout 性能优势

ConstraintLayout 只需要一次 measure + layout 就能完成复杂布局,而嵌套的 LinearLayout/RelativeLayout 需要多次。

原理:ConstraintLayout 使用 Cassowary 线性约束求解算法,将所有约束转化为线性方程组一次性求解,避免了多层嵌套导致的指数级 measure 调用。

嵌套 LinearLayout(3层):measure 调用 2^3 = 8 次
ConstraintLayout(扁平):measure 调用 2 次(水平+垂直各一次)

1. View 的绘制流程?measure、layout、draw 各做什么?

考察点:View 绘制原理

完整回答

绘制从 ViewRootImpl.performTraversals() 开始,依次执行三大流程:

  1. measure:确定 View 的大小。ViewGroup 先测量子 View,再根据子 View 大小确定自身大小。核心是 MeasureSpec(高2位模式+低30位大小),三种模式:EXACTLY(精确值)、AT_MOST(最大值,wrap_content)、UNSPECIFIED(无限制)。

  2. layout:确定 View 的位置。ViewGroup 在 onLayout 中调用每个子 View 的 layout(l, t, r, b) 确定其四个顶点坐标。

  3. 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 响应的完整流程?

考察点:事件分发源码理解

完整回答

事件从硬件到应用的完整链路:

  1. 触摸屏产生中断 → InputManagerService → InputDispatcher
  2. 通过 Socket 发送到应用进程的 InputChannel
  3. ViewRootImpl 的 WindowInputEventReceiver 接收
  4. 进入 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 中判断:

java
@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 控制:

java
// 子 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 实践能力

完整回答

  1. 继承:简单绘制继承 View,需要布局子 View 继承 ViewGroup
  2. 构造函数:至少实现两个构造函数(代码创建 + XML 解析),处理自定义属性
  3. onMeasure:处理 wrap_content(AT_MOST 模式),调用 setMeasuredDimension
  4. onLayout(ViewGroup):确定子 View 位置
  5. onDraw:使用 Canvas 绑制内容
  6. 处理 padding:onDraw 中考虑 padding,ViewGroup 的 onLayout 中考虑 padding
  7. 处理触摸事件:重写 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 中可以让实际宽度与测量宽度不同:

java
// 强制让子 View 的实际宽度是测量宽度的一半
child.layout(0, 0, child.getMeasuredWidth() / 2, child.getMeasuredHeight());
// 此时 getWidth() = getMeasuredWidth() / 2

追问:在 onCreate 中获取 View 的宽高为什么是 0?怎么解决?

onCreate 时 View 还没有经过 measure 和 layout。解决方案:

kotlin
// 方案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 信号到来时依次执行:

  1. Input 事件处理
  2. 动画计算
  3. 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_parentwrap_contentdp 有什么区别?

考察点:布局基础

完整回答

  • match_parent:尺寸尽量填满父容器允许的空间。
  • wrap_content:尺寸根据自身内容决定。
  • dp:密度无关像素,用于适配不同屏幕密度。

文字大小通常使用 sp,因为 sp 会跟随用户字体大小设置变化;普通布局尺寸一般使用 dp

加分点:移动端布局要避免写死过多绝对尺寸,可以结合 ConstraintLayout、权重、约束和自适应资源。

9. 点击事件不响应可能有哪些原因?

考察点:事件分发排查

完整回答

常见原因包括:

  • View 没有设置点击监听,或 clickable 状态不正确。
  • 父 View 拦截了事件,比如外层 ScrollView、RecyclerView。
  • 有其他 View 覆盖在目标 View 上方。
  • View 的宽高为 0,或者实际点击区域不在可见区域内。
  • 子 View 消费了事件,父 View 收不到点击。

排查时可以先确认布局层级和点击区域,再通过日志观察 dispatchTouchEventonInterceptTouchEventonTouchEvent 的返回值。

RecyclerView

RecyclerView

RecyclerView 内容较多,已拆分为独立文件:

  • recyclerview-cache.md — 缓存机制(四级缓存详解、查找流程源码、回收流程、Scrap 工作时机)
  • recyclerview-layout-scroll.md — 布局与滑动(LayoutManager 原理、滑动机制、SnapHelper、ItemDecoration、ItemAnimator)
  • recyclerview-diffutil-optimize.md — DiffUtil 与性能优化(Myers 算法、Payload 局部更新、Prefetch 预取、嵌套优化、ConcatAdapter、ItemTouchHelper)

整体架构

RecyclerView 的核心设计是职责分离:

  • Adapter:数据 → ViewHolder 的映射(创建 + 绑定)
  • LayoutManager:决定 item 怎么摆放(线性、网格、瀑布流)
  • Recycler:ViewHolder 的缓存和复用管理
  • ItemAnimator:item 增删改的动画
  • ItemDecoration:分割线、间距等装饰
  • SnapHelper:滑动对齐(如 ViewPager 效果)
RecyclerView
├── Adapter          → 提供数据和 ViewHolder
├── LayoutManager    → 布局策略
├── Recycler         → 缓存管理(四级缓存)
├── ItemAnimator     → 动画
├── ItemDecoration   → 装饰
└── RecycledViewPool → 跨 RecyclerView 共享缓存

四级缓存详解

第一级:Scrap(屏幕内缓存)

包含两个列表:

mAttachedScrap:存放当前屏幕上的 ViewHolder。在 onLayoutChildren 时,LayoutManager 会先把所有子 View detach 并放入 mAttachedScrap,重新布局时再从中取回。

java
// LinearLayoutManager.onLayoutChildren() 简化流程
void onLayoutChildren(Recycler recycler, State state) {
    // 1. 把所有子 View 放入 scrap
    detachAndScrapAttachedViews(recycler);
    // 2. 重新填充
    fill(recycler, layoutState, state);
}

// detachAndScrapAttachedViews 内部
void scrapView(View view) {
    ViewHolder holder = getChildViewHolderInt(view);
    if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID)) {
        holder.addFlags(ViewHolder.FLAG_UPDATE);
        mChangedScrap.add(holder);  // 有变化的放 changedScrap
    } else {
        mAttachedScrap.add(holder); // 没变化的放 attachedScrap
    }
}

mChangedScrap:存放数据已变化的 ViewHolder(调用了 notifyItemChanged)。这些 ViewHolder 在 pre-layout 阶段使用(用于计算动画起始位置),post-layout 时会从 RecycledViewPool 获取新的 ViewHolder 来绑定新数据。

关键区别:

  • mAttachedScrap 复用时不需要重新绑定(数据没变)
  • mChangedScrap 是为了动画存在的,最终会被回收到 Pool
第二级:CachedViews(刚离开屏幕的缓存)
java
// 默认大小 2 + prefetch 预取数量
final ArrayList<ViewHolder> mCachedViews = new ArrayList<>();
int mViewCacheMax = DEFAULT_CACHE_SIZE; // 2

特点:

  • position 精确匹配
  • 命中后直接复用,不调用 onBindViewHolder(数据完全一致)
  • 容量满时,最老的 ViewHolder 被移到 RecycledViewPool
  • 适用场景:用户来回小幅滑动时,刚滑出去的 item 马上滑回来
java
// 查找逻辑
ViewHolder getScrapOrCachedViewForPosition(int position) {
    for (int i = 0; i < mCachedViews.size(); i++) {
        ViewHolder holder = mCachedViews.get(i);
        if (holder.getLayoutPosition() == position  // position 必须完全匹配
                && !holder.isInvalid()) {
            mCachedViews.remove(i);
            return holder;
        }
    }
    return null;
}

为什么默认只有 2?因为 CachedViews 保存了完整的 ViewHolder 状态(包括绑定的数据),占用内存较多。2 个刚好覆盖"滑出一两个 item 又滑回来"的场景。

第三级:ViewCacheExtension(自定义缓存)
java
public abstract static class ViewCacheExtension {
    public abstract View getViewForPositionAndType(Recycler recycler, int position, int type);
}

开发者自定义的缓存层,插在 CachedViews 和 RecycledViewPool 之间。实际开发中很少使用,适用于特殊场景(如固定位置的广告 item 需要特殊缓存策略)。

第四级:RecycledViewPool(回收池)
java
public static class RecycledViewPool {
    // 按 viewType 分桶存储
    SparseArray<ScrapData> mScrap = new SparseArray<>();

    static class ScrapData {
        final ArrayList<ViewHolder> mScrapHeap = new ArrayList<>();
        int mMaxScrap = DEFAULT_MAX_SCRAP; // 默认每种 type 最多 5 个
    }
}

特点:

  • viewType 匹配(不关心 position)
  • 命中后需要调用 onBindViewHolder 重新绑定数据
  • ViewHolder 被放入 Pool 前会调用 resetInternal() 清除所有绑定状态
  • 可以跨 RecyclerView 共享
java
// 放入 Pool 时清除状态
void resetInternal() {
    mFlags = 0;
    mPosition = NO_POSITION;
    mItemId = NO_ID;
    mPreLayoutPosition = NO_POSITION;
    mPayloads = null;
    // ViewHolder 变成"干净"的,只保留 itemView
}
完整查找流程源码分析
java
// Recycler.tryGetViewHolderForPositionByDeadline() 简化
ViewHolder tryGetViewHolderForPositionByDeadline(int position, long deadlineNs) {
    ViewHolder holder = null;

    // 1. 从 mChangedScrap 查找(仅 pre-layout 阶段)
    if (mState.isPreLayout()) {
        holder = getChangedScrapViewForPosition(position);
    }

    // 2. 从 mAttachedScrap 和 mCachedViews 按 position 查找
    if (holder == null) {
        holder = getScrapOrHiddenOrCachedHolderForPosition(position);
    }

    // 3. 如果 Adapter 有 stableId,按 id 查找
    if (holder == null && mAdapter.hasStableIds()) {
        holder = getScrapOrCachedViewForId(mAdapter.getItemId(position));
    }

    // 4. ViewCacheExtension
    if (holder == null && mViewCacheExtension != null) {
        View view = mViewCacheExtension.getViewForPositionAndType(this, position, type);
        if (view != null) {
            holder = getChildViewHolder(view);
        }
    }

    // 5. RecycledViewPool
    if (holder == null) {
        holder = getRecycledViewPool().getRecycledView(type);
        if (holder != null) {
            holder.resetInternal(); // 清除状态
            // 需要重新绑定
        }
    }

    // 6. 都没有,创建新的
    if (holder == null) {
        holder = mAdapter.createViewHolder(this, type);
    }

    // 7. 需要绑定的话,执行绑定
    if (needsUpdate) {
        mAdapter.bindViewHolder(holder, position);
    }

    return holder;
}
缓存对比总结
缓存层容量匹配方式需要 onBind说明
mAttachedScrap无限制position屏幕内,layout 期间临时存放
mChangedScrap无限制position数据变化的 item,用于动画
mCachedViews2(默认)position刚滑出屏幕,来回滑动时秒复用
ViewCacheExtension自定义自定义自定义很少使用
RecycledViewPool5/typeviewType最终回收站,可跨 RV 共享

回收流程

ViewHolder 的回收发生在滑动过程中,item 移出屏幕时:

item 滑出屏幕
  → LayoutManager 调用 removeAndRecycleView()
    → Recycler.recycleViewHolderInternal(holder)
      → 先尝试放入 mCachedViews
        → 如果 mCachedViews 满了
          → 把最老的(index 0)移到 RecycledViewPool
          → 当前 holder 放入 mCachedViews 末尾
        → 如果 mCachedViews 没满
          → 直接放入 mCachedViews
      → 如果不能放入 mCachedViews(如被标记 INVALID)
        → 直接放入 RecycledViewPool
java
// 源码简化
void recycleViewHolderInternal(ViewHolder holder) {
    boolean cached = false;

    if (forceRecycle || holder.isRecyclable()) {
        if (mViewCacheMax > 0
                && !holder.hasAnyOfTheFlags(FLAG_INVALID | FLAG_REMOVED | FLAG_ADAPTER_POSITION_UNKNOWN)) {
            // 尝试放入 CachedViews
            if (mCachedViews.size() >= mViewCacheMax && !mCachedViews.isEmpty()) {
                // 满了,把最老的移到 Pool
                recycleCachedViewAt(0);
            }
            mCachedViews.add(holder);
            cached = true;
        }

        if (!cached) {
            // 放入 RecycledViewPool
            addViewHolderToRecycledViewPool(holder);
        }
    }
}

Scrap 的工作时机

Scrap 缓存容易让人困惑,它只在 layout 过程中使用:

notifyXxx() 被调用
  → requestLayout()
    → onLayoutChildren()
      → Step 1: detachAndScrapAttachedViews()  ← 所有子 View 放入 scrap
      → Step 2: fill()                         ← 从 scrap 中取回需要的
      → Step 3: scrapView 中没被取回的         ← 移到 CachedViews 或 Pool

举个例子,屏幕上显示 item 0-9,调用 notifyItemRemoved(3)

  1. item 0-9 全部 detach 放入 mAttachedScrap
  2. 重新 fill 时,item 0-2、4-9 从 scrap 取回(position 匹配,不需要 rebind)
  3. item 3 没被取回,被回收到 Pool
  4. 底部可能需要新的 item 10,从 Pool 或 create 获取

LayoutManager 工作原理

职责

LayoutManager 负责:

  • 决定 item 的摆放位置(线性、网格、瀑布流)
  • 决定何时回收不可见的 item
  • 处理滚动
  • 支持预取(Prefetch)
核心方法
java
public abstract static class LayoutManager {
    // 必须实现:生成默认的 LayoutParams
    public abstract LayoutParams generateDefaultLayoutParams();

    // 布局子 View(核心方法)
    public void onLayoutChildren(Recycler recycler, State state) {}

    // 是否支持水平/垂直滚动
    public boolean canScrollHorizontally() { return false; }
    public boolean canScrollVertically() { return false; }

    // 处理滚动
    public int scrollHorizontallyBy(int dx, Recycler recycler, State state) { return 0; }
    public int scrollVerticallyBy(int dy, Recycler recycler, State state) { return 0; }
}
LinearLayoutManager 的 fill 过程
java
// LinearLayoutManager.fill() 简化
int fill(Recycler recycler, LayoutState layoutState, State state) {
    int remainingSpace = layoutState.mAvailable;

    while (remainingSpace > 0 && layoutState.hasMore(state)) {
        // 1. 从 Recycler 获取 ViewHolder
        View view = layoutState.next(recycler);  // 内部调用 recycler.getViewForPosition()

        // 2. 添加到 RecyclerView
        if (layoutState.mScrapList == null) {
            addView(view);  // 正常布局
        } else {
            addDisappearingView(view);  // 动画中消失的 View
        }

        // 3. 测量
        measureChildWithMargins(view, 0, 0);

        // 4. 布局(确定位置)
        layoutDecoratedWithMargins(view, left, top, right, bottom);

        // 5. 更新剩余空间
        remainingSpace -= view.getDecoratedMeasuredHeight();
    }
    return consumed;
}
三种内置 LayoutManager

LinearLayoutManager

  • 线性排列(垂直/水平)
  • 支持 reverseLayout(反向排列)
  • 支持 stackFromEnd(从底部开始填充,如聊天列表)

GridLayoutManager

  • 继承自 LinearLayoutManager
  • 支持 spanCount(列数)和 SpanSizeLookup(动态列宽)
kotlin
val layoutManager = GridLayoutManager(context, 3)
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
    override fun getSpanSize(position: Int): Int {
        // header 占满一行,普通 item 占 1 列
        return if (adapter.getItemViewType(position) == TYPE_HEADER) 3 else 1
    }
}

StaggeredGridLayoutManager

  • 瀑布流布局
  • 每个 item 可以有不同高度
  • 支持全宽 item(setFullSpan(true)
自定义 LayoutManager
kotlin
class CustomLayoutManager : RecyclerView.LayoutManager() {

    override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
        return RecyclerView.LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
    }

    override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) {
        // 1. 回收所有现有 View
        detachAndScrapAttachedViews(recycler)

        // 2. 填充可见区域
        var offsetY = 0
        for (i in 0 until itemCount) {
            if (offsetY > height) break  // 超出可见区域,停止

            val view = recycler.getViewForPosition(i)
            addView(view)
            measureChildWithMargins(view, 0, 0)

            val width = getDecoratedMeasuredWidth(view)
            val height = getDecoratedMeasuredHeight(view)
            layoutDecorated(view, 0, offsetY, width, offsetY + height)

            offsetY += height
        }
    }

    override fun canScrollVertically() = true

    override fun scrollVerticallyBy(dy: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int {
        // 1. 移动所有子 View
        offsetChildrenVertical(-dy)
        // 2. 回收不可见的 View
        recycleInvisibleViews(recycler)
        // 3. 填充新出现的 View
        fillVisibleViews(recycler)
        return dy
    }
}

滑动机制

滑动流程
手指触摸 ACTION_DOWN
  → RecyclerView.onInterceptTouchEvent
    → 记录初始位置

手指移动 ACTION_MOVE
  → 判断滑动距离是否超过 touchSlop
    → 超过:拦截事件,进入滑动模式
      → scrollByInternal(dx, dy)
        → LayoutManager.scrollVerticallyBy(dy)
          → offsetChildrenVertical(-dy)  // 移动子 View
          → fill()                       // 填充新 item
          → recycleByLayoutState()       // 回收旧 item

手指抬起 ACTION_UP
  → 计算 fling 速度
    → 如果速度足够大
      → ViewFlinger.fling(velocityX, velocityY)
        → OverScroller 计算每帧滚动距离
        → postOnAnimation → 每帧回调 run()
          → scrollBy() → 同上面的滑动逻辑
Fling 惯性滑动
java
// ViewFlinger 是 RecyclerView 的内部类
class ViewFlinger implements Runnable {
    OverScroller mOverScroller;

    void fling(int velocityX, int velocityY) {
        mOverScroller.fling(0, 0, velocityX, velocityY, ...);
        postOnAnimation();  // 注册到 Choreographer
    }

    @Override
    public void run() {
        if (mOverScroller.computeScrollOffset()) {
            int dx = mOverScroller.getCurrX() - mLastX;
            int dy = mOverScroller.getCurrY() - mLastY;

            // 执行滚动
            scrollByInternal(dx, dy);

            // 还没停,继续下一帧
            postOnAnimation();
        }
    }
}
SnapHelper

SnapHelper 让滑动停止时自动对齐到某个 item:

kotlin
// LinearSnapHelper:对齐到最近的 item 中心
LinearSnapHelper().attachToRecyclerView(recyclerView)

// PagerSnapHelper:一次只滑一页(类似 ViewPager)
PagerSnapHelper().attachToRecyclerView(recyclerView)

原理:SnapHelper 监听滑动状态,在 IDLE 时计算当前位置与目标对齐位置的偏移量,调用 smoothScrollBy 修正。

java
// SnapHelper 核心逻辑
private final RecyclerView.OnScrollListener mScrollListener = new OnScrollListener() {
    @Override
    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
        if (newState == RecyclerView.SCROLL_STATE_IDLE) {
            snapToTargetExistingView();  // 对齐
        }
    }
};

void snapToTargetExistingView() {
    View snapView = findSnapView(layoutManager);       // 找到要对齐的 View
    int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView); // 计算偏移
    recyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);  // 平滑滚动对齐
}

ItemDecoration

原理

ItemDecoration 在 item 绘制的前后插入自定义绘制,并可以为 item 添加偏移量(间距):

java
public abstract static class ItemDecoration {
    // 在 item 之前绘制(item 会覆盖在上面)
    public void onDraw(Canvas c, RecyclerView parent, State state) {}

    // 在 item 之后绘制(覆盖在 item 上面)
    public void onDrawOver(Canvas c, RecyclerView parent, State state) {}

    // 为 item 设置偏移量(间距)
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {}
}

绘制顺序:ItemDecoration.onDraw → item 自身绘制 → ItemDecoration.onDrawOver

分割线实现
kotlin
class DividerDecoration(
    private val height: Int = 1.dp,
    private val color: Int = Color.LTGRAY
) : RecyclerView.ItemDecoration() {

    private val paint = Paint().apply { this.color = color }

    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: State) {
        // 除了最后一个 item,每个 item 底部留出分割线高度
        val position = parent.getChildAdapterPosition(view)
        if (position < state.itemCount - 1) {
            outRect.bottom = height
        }
    }

    override fun onDraw(c: Canvas, parent: RecyclerView, state: State) {
        val left = parent.paddingLeft
        val right = parent.width - parent.paddingRight

        for (i in 0 until parent.childCount) {
            val child = parent.getChildAt(i)
            val params = child.layoutParams as RecyclerView.LayoutParams
            val top = child.bottom + params.bottomMargin
            c.drawRect(left.toFloat(), top.toFloat(), right.toFloat(), (top + height).toFloat(), paint)
        }
    }
}
吸顶效果(Sticky Header)
kotlin
class StickyHeaderDecoration(
    private val adapter: StickyHeaderAdapter
) : RecyclerView.ItemDecoration() {

    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: State) {
        val topChild = parent.getChildAt(0) ?: return
        val topPosition = parent.getChildAdapterPosition(topChild)
        val headerView = adapter.getHeaderView(parent, topPosition)

        // 测量和布局 header
        headerView.measure(
            View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY),
            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
        )
        headerView.layout(0, 0, headerView.measuredWidth, headerView.measuredHeight)

        // 检查是否需要被下一个 header 推上去
        val nextHeaderPos = findNextHeaderPosition(topPosition)
        val nextHeaderView = parent.findViewHolderForAdapterPosition(nextHeaderPos)?.itemView
        val offset = if (nextHeaderView != null && nextHeaderView.top < headerView.measuredHeight) {
            nextHeaderView.top - headerView.measuredHeight
        } else 0

        c.save()
        c.translate(0f, offset.toFloat())
        headerView.draw(c)
        c.restore()
    }
}

ItemAnimator

动画类型

DefaultItemAnimator 支持四种动画:

  • add:新 item 淡入
  • remove:旧 item 淡出
  • move:item 位置变化时平移
  • change:item 内容变化时交叉淡入淡出
动画触发条件
notifyItemInserted(pos)  → add 动画
notifyItemRemoved(pos)   → remove 动画
notifyItemMoved(from,to) → move 动画
notifyItemChanged(pos)   → change 动画
notifyDataSetChanged()   → 无动画(所有 ViewHolder 失效)
Pre-layout 和 Post-layout

RecyclerView 的动画依赖两次 layout:

Step 1: Pre-layout(dispatchLayoutStep1)
  → 记录当前所有 item 的位置(动画起始状态)
  → 对于 remove 的 item,仍然参与布局
  → 对于 insert 的 item,还不存在

Step 2: Post-layout(dispatchLayoutStep2)
  → 按新数据重新布局(动画结束状态)
  → remove 的 item 不再参与
  → insert 的 item 出现

Step 3: 执行动画(dispatchLayoutStep3)
  → 对比 pre 和 post 的位置差异
  → 生成对应的动画(add/remove/move/change)
  → 执行动画

这就是为什么 notifyItemChanged 会用到 mChangedScrap:pre-layout 需要旧的 ViewHolder 记录起始位置,post-layout 用新的 ViewHolder 显示新数据,两者之间做交叉淡入淡出动画。

自定义 ItemAnimator
kotlin
class SlideInAnimator : DefaultItemAnimator() {

    override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean {
        holder.itemView.translationX = holder.itemView.width.toFloat()
        holder.itemView.alpha = 0f

        ViewCompat.animate(holder.itemView)
            .translationX(0f)
            .alpha(1f)
            .setDuration(addDuration)
            .setListener(object : ViewPropertyAnimatorListener {
                override fun onAnimationEnd(view: View) {
                    dispatchAddFinished(holder)
                }
                override fun onAnimationStart(view: View) { dispatchAddStarting(holder) }
                override fun onAnimationCancel(view: View) {}
            })
            .start()

        return true
    }
}

DiffUtil 深入

Myers 差分算法

DiffUtil 使用 Eugene Myers 的差分算法,核心思想是在一个编辑图(edit graph)上找到从旧列表到新列表的最短编辑路径。

旧列表: [A, B, C, D]
新列表: [A, C, D, E]

最小编辑操作:
- 保留 A(位置不变)
- 删除 B
- 保留 C(位置从 2 移到 1)
- 保留 D(位置从 3 移到 2)
- 插入 E(位置 3)

时间复杂度:O(N + D²),N 是新旧列表总长度,D 是编辑距离(差异数量)。列表越相似(D 越小),速度越快。

两个关键回调
kotlin
class ItemDiffCallback : DiffUtil.ItemCallback<Item>() {

    // 判断是否是同一个 item(通常比较 id)
    // 返回 false → 认为是不同 item,触发 remove + add 动画
    // 返回 true → 认为是同一个 item,继续调用 areContentsTheSame
    override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
        return oldItem.id == newItem.id
    }

    // 判断同一个 item 的内容是否变化
    // 返回 false → 触发 change 动画,调用 onBindViewHolder
    // 返回 true → 什么都不做,完全复用
    override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
        return oldItem == newItem  // data class 的 equals
    }

    // 可选:返回变化的部分(payload),实现局部更新
    override fun getChangePayload(oldItem: Item, newItem: Item): Any? {
        val diff = mutableSetOf<String>()
        if (oldItem.title != newItem.title) diff.add("title")
        if (oldItem.avatar != newItem.avatar) diff.add("avatar")
        return diff.ifEmpty { null }
    }
}
Payload 局部更新

Payload 是 DiffUtil 最强大的特性之一,可以只更新 item 中变化的部分,避免整个 ViewHolder 重新绑定:

kotlin
class UserAdapter : ListAdapter<User, UserViewHolder>(UserDiffCallback()) {

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        // 全量绑定
        holder.bind(getItem(position))
    }

    // payloads 不为空时调用这个方法
    override fun onBindViewHolder(holder: UserViewHolder, position: Int, payloads: List<Any>) {
        if (payloads.isEmpty()) {
            // 没有 payload,全量绑定
            onBindViewHolder(holder, position)
            return
        }

        // 有 payload,局部更新
        val changes = payloads[0] as Set<String>
        val item = getItem(position)

        if ("title" in changes) {
            holder.titleView.text = item.title  // 只更新标题
        }
        if ("avatar" in changes) {
            Glide.with(holder.avatarView).load(item.avatar).into(holder.avatarView)  // 只更新头像
        }
        // 其他部分保持不变,不会闪烁
    }
}

好处:

  • 避免整个 item 重新绑定导致的闪烁
  • 图片不会重新加载(如果 URL 没变)
  • 性能更好
ListAdapter vs AsyncListDiffer
kotlin
// ListAdapter(推荐,封装更完善)
class MyAdapter : ListAdapter<Item, VH>(diffCallback) {
    // 提交新列表
    fun update(list: List<Item>) {
        submitList(list)  // 内部自动异步 diff
    }
}

// AsyncListDiffer(更灵活,可以在普通 Adapter 中使用)
class MyAdapter : RecyclerView.Adapter<VH>() {
    private val differ = AsyncListDiffer(this, diffCallback)

    override fun getItemCount() = differ.currentList.size

    fun update(list: List<Item>) {
        differ.submitList(list)
    }
}

注意事项:

  • submitList 传入的必须是新的 List 实例,传同一个引用不会触发 diff
  • 连续快速调用 submitList,中间的会被跳过,只执行最后一次
  • diff 计算在后台线程,结果分发在主线程
kotlin
// ❌ 错误:修改同一个 list 再提交,diff 认为没变化
val list = mutableListOf(item1, item2)
adapter.submitList(list)
list.add(item3)
adapter.submitList(list)  // 同一个引用,不会更新!

// ✅ 正确:提交新的 list
adapter.submitList(list.toList())  // 创建新的 List 实例

性能优化详解

setHasFixedSize(true)
java
// RecyclerView 源码
void onItemRangeInserted(int positionStart, int itemCount) {
    if (mHasFixedSize) {
        // 只触发 item 级别的布局
        mLayout.onItemsAdded(this, positionStart, itemCount);
    } else {
        // 触发整个 RecyclerView 的 requestLayout
        requestLayout();
    }
}

当 item 的增删不会改变 RecyclerView 自身大小时(比如 RecyclerView 是 match_parent),设置 setHasFixedSize(true) 可以避免不必要的 requestLayout,减少 measure 开销。

Prefetch 预取机制

RecyclerView 25.1.0 引入的 GapWorker 预取机制:

正常滑动时:
  帧 1: [滑动处理 + fill] ─────────── [空闲]
  帧 2: [滑动处理 + fill] ─────────── [空闲]

开启预取后:
  帧 1: [滑动处理 + fill] [预取下一个 item] [空闲]
  帧 2: [滑动处理 + fill(命中预取缓存)] ── [空闲]

GapWorker 利用每帧的空闲时间,提前创建和绑定即将出现的 ViewHolder。预取的 ViewHolder 存放在 mCachedViews 中。

java
// GapWorker 核心逻辑
void prefetch(long deadlineNs) {
    // 根据滑动速度预测下一个需要的 position
    int position = layoutManager.collectAdjacentPrefetchPositions();

    // 在 deadline 之前创建和绑定
    RecyclerView.ViewHolder holder = recycler.tryGetViewHolderForPositionByDeadline(
        position, deadlineNs);
}

嵌套 RecyclerView 的预取优化:

kotlin
// 外层 RecyclerView 的 LayoutManager 设置内层预取数量
(recyclerView.layoutManager as LinearLayoutManager).apply {
    // 告诉外层 LayoutManager,内层 RecyclerView 需要预取的 item 数量
    initialPrefetchItemCount = 4  // 内层一屏可见的 item 数
}
共享 RecycledViewPool

多个 RecyclerView 展示相同类型的 item 时(如 ViewPager + RecyclerView),共享 Pool 可以避免重复创建 ViewHolder:

kotlin
// 创建共享 Pool
val sharedPool = RecyclerView.RecycledViewPool().apply {
    setMaxRecycledViews(TYPE_NORMAL, 20)  // 增大容量
}

// ViewPager 的每个页面的 RecyclerView 共享同一个 Pool
class PageAdapter : RecyclerView.Adapter<VH>() {
    override fun onBindViewHolder(holder: VH, position: Int) {
        val innerRecyclerView = holder.recyclerView
        innerRecyclerView.setRecycledViewPool(sharedPool)
    }
}
onBindViewHolder 优化
kotlin
// ❌ 在 onBindViewHolder 中设置点击监听(每次 bind 都创建新 lambda)
override fun onBindViewHolder(holder: VH, position: Int) {
    holder.itemView.setOnClickListener {
        onClick(getItem(position))  // 每次 bind 创建新的 lambda
    }
}

// ✅ 在 onCreateViewHolder 中设置(只创建一次)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
    val view = LayoutInflater.from(parent.context).inflate(R.layout.item, parent, false)
    val holder = VH(view)
    holder.itemView.setOnClickListener {
        val pos = holder.bindingAdapterPosition
        if (pos != RecyclerView.NO_POSITION) {
            onClick(getItem(pos))
        }
    }
    return holder
}
kotlin
// ❌ 在 onBindViewHolder 中创建对象
override fun onBindViewHolder(holder: VH, position: Int) {
    val formatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())  // 每次创建!
    holder.dateView.text = formatter.format(item.date)
}

// ✅ 复用对象
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())

override fun onBindViewHolder(holder: VH, position: Int) {
    holder.dateView.text = dateFormatter.format(item.date)
}
图片加载优化
kotlin
override fun onBindViewHolder(holder: VH, position: Int) {
    // ✅ 使用 Glide 自动管理生命周期
    Glide.with(holder.itemView)
        .load(item.imageUrl)
        .placeholder(R.drawable.placeholder)  // 占位图,避免闪烁
        .override(200, 200)                   // 指定目标尺寸,避免加载原图
        .into(holder.imageView)
}

// ✅ 滑动时暂停加载,停止时恢复
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        when (newState) {
            RecyclerView.SCROLL_STATE_DRAGGING,
            RecyclerView.SCROLL_STATE_SETTLING -> Glide.with(context).pauseRequests()
            RecyclerView.SCROLL_STATE_IDLE -> Glide.with(context).resumeRequests()
        }
    }
})
性能优化清单
优化项效果适用场景
setHasFixedSize(true)避免不必要的 requestLayoutRV 大小固定时
DiffUtil / ListAdapter精确更新,有动画替代 notifyDataSetChanged
Payload 局部更新避免整个 item 重绑item 部分内容变化
setItemViewCacheSize增大离屏缓存频繁来回滑动
共享 RecycledViewPool减少 createViewHolder多个 RV 相同 viewType
Prefetch 预取利用空闲时间提前创建默认开启
onCreateViewHolder 设置监听减少对象创建所有场景
避免 onBind 创建对象减少内存抖动所有场景
图片指定尺寸减少内存和解码时间有图片的列表
RecyclerView.setItemAnimator(null)去掉动画开销不需要动画时

高级用法

ConcatAdapter(合并多个 Adapter)
kotlin
// 替代多 viewType 的复杂 Adapter
val headerAdapter = HeaderAdapter()
val contentAdapter = ContentAdapter()
val footerAdapter = FooterAdapter()

recyclerView.adapter = ConcatAdapter(
    ConcatAdapter.Config.Builder()
        .setIsolateViewTypes(true)  // 不同 Adapter 的 viewType 互相隔离
        .build(),
    headerAdapter,
    contentAdapter,
    footerAdapter
)

// 各自独立管理数据
headerAdapter.submitHeader(header)
contentAdapter.submitList(items)
footerAdapter.showLoading(true)

优势:

  • 每个 Adapter 职责单一,易维护
  • 不需要在一个 Adapter 中处理多种 viewType
  • 各 Adapter 独立更新,互不影响
ItemTouchHelper(拖拽和滑动删除)
kotlin
val callback = object : ItemTouchHelper.SimpleCallback(
    ItemTouchHelper.UP or ItemTouchHelper.DOWN,  // 拖拽方向
    ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // 滑动方向
) {
    override fun onMove(rv: RecyclerView, source: ViewHolder, target: ViewHolder): Boolean {
        val from = source.bindingAdapterPosition
        val to = target.bindingAdapterPosition
        // 交换数据
        Collections.swap(list, from, to)
        adapter.notifyItemMoved(from, to)
        return true
    }

    override fun onSwiped(viewHolder: ViewHolder, direction: Int) {
        val pos = viewHolder.bindingAdapterPosition
        // 删除数据
        list.removeAt(pos)
        adapter.notifyItemRemoved(pos)
    }

    // 自定义滑动时的绘制效果
    override fun onChildDraw(c: Canvas, rv: RecyclerView, viewHolder: ViewHolder,
                             dX: Float, dY: Float, actionState: Int, isActive: Boolean) {
        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
            // 滑动时绘制红色背景和删除图标
            val background = ColorDrawable(Color.RED)
            background.setBounds(viewHolder.itemView.right + dX.toInt(), ...)
            background.draw(c)
        }
        super.onChildDraw(c, rv, viewHolder, dX, dY, actionState, isActive)
    }
}

ItemTouchHelper(callback).attachToRecyclerView(recyclerView)
RecyclerView 嵌套优化

RecyclerView 嵌套 RecyclerView(如横向列表嵌在纵向列表中)的优化要点:

kotlin
// 1. 共享 RecycledViewPool
val sharedPool = RecyclerView.RecycledViewPool()

override fun onBindViewHolder(holder: OuterVH, position: Int) {
    holder.innerRecyclerView.apply {
        setRecycledViewPool(sharedPool)

        // 2. 设置预取数量
        (layoutManager as LinearLayoutManager).initialPrefetchItemCount = 4

        // 3. 设置固定大小
        setHasFixedSize(true)

        // 4. 避免重复设置 LayoutManager
        if (layoutManager == null) {
            layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
        }

        // 5. 避免重复设置 Adapter(复用时只更新数据)
        if (adapter == null) {
            adapter = InnerAdapter()
        }
        (adapter as InnerAdapter).submitList(data[position].innerList)
    }
}
保存和恢复滚动位置
kotlin
// 保存
val state = recyclerView.layoutManager?.onSaveInstanceState()

// 恢复
recyclerView.layoutManager?.onRestoreInstanceState(state)

// 或者在 Activity/Fragment 中自动保存恢复
// RecyclerView 会自动保存滚动位置(前提是有 id)
RecyclerView 与 Compose LazyColumn 对比
维度RecyclerViewLazyColumn
范式命令式(Adapter + ViewHolder)声明式(items DSL)
复用ViewHolder 复用(四级缓存)Composition 复用(Slot Table)
布局LayoutManager内置(LazyColumn/LazyRow/LazyGrid)
动画ItemAnimatoranimateItemPlacement
性能成熟优化,略高持续改进中
开发效率较低(模板代码多)高(声明式简洁)
自定义灵活(自定义 LayoutManager)受限
kotlin
// Compose LazyColumn 等价写法
LazyColumn {
    items(
        items = users,
        key = { it.id },            // 等价于 stableId
        contentType = { "user" }    // 等价于 viewType,帮助复用
    ) { user ->
        UserItem(user)
    }
}

1. RecyclerView 的缓存机制?四级缓存分别是什么?

考察点:RecyclerView 性能原理

完整回答

RecyclerView 有四级缓存:

  1. mAttachedScrap / mChangedScrap:屏幕内的 ViewHolder 缓存。layout 期间临时存放,layout 结束后复用。不需要重新绑定数据。

  2. mCachedViews:刚滑出屏幕的 ViewHolder,默认容量 2。按 position 精确匹配,命中后不需要 onBindViewHolder。

  3. ViewCacheExtension:用户自定义缓存,很少使用。

  4. 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 大小固定时避免 requestLayout
  • DiffUtil:精确计算差异,避免 notifyDataSetChanged
  • setItemViewCacheSize:增大 mCachedViews 容量
  • 共享 RecycledViewPool:多个 RecyclerView 展示相同类型 item 时
  • setRecycledViewPool 预创建 ViewHolder
  • 减少 onBindViewHolder 中的耗时操作
  • 使用 ConcatAdapter 替代多 viewType

2. DiffUtil 的原理?和 notifyDataSetChanged 有什么区别?

考察点:列表更新优化

完整回答

notifyDataSetChanged 会刷新所有可见 item,触发所有 ViewHolder 的 rebind,且没有动画效果。

DiffUtil 使用 Eugene Myers 差分算法,计算新旧列表的最小编辑距离,只更新变化的 item:

  • 移动、插入、删除有对应的动画
  • 未变化的 item 不会 rebind

使用方式:

kotlin
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:

kotlin
class MyAdapter : ListAdapter<Item, ViewHolder>(ItemDiffCallback()) {
    // submitList(newList) 自动计算 diff 并更新
}

追问:DiffUtil 的时间复杂度?

O(N + D²),N 是新旧列表总长度,D 是编辑距离。列表很大且变化很多时可能耗时,所以 AsyncListDiffer 在后台线程计算。

3. RecyclerView 和 ListView 的区别?

考察点:列表控件对比

完整回答

维度RecyclerViewListView
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,减少开销。

Handler

Handler 机制

核心组件
Handler → 发送/处理消息
Message → 消息载体(what, obj, arg1, arg2, target, callback)
MessageQueue → 消息队列(按 when 排序的单链表)
Looper → 循环取消息(一个线程只有一个 Looper)
消息循环
java
// Looper.loop() 核心逻辑
public static void loop() {
    final Looper me = myLooper();
    final MessageQueue queue = me.mQueue;

    for (;;) {
        Message msg = queue.next(); // 可能阻塞(epoll_wait)
        if (msg == null) return;    // null 表示 quit

        msg.target.dispatchMessage(msg); // 分发给 Handler
        msg.recycleUnchecked();          // 回收到消息池
    }
}
MessageQueue.next() 与 epoll
java
Message next() {
    int nextPollTimeoutMillis = 0;
    for (;;) {
        nativePollOnce(ptr, nextPollTimeoutMillis); // 阻塞,底层 epoll_wait

        synchronized (this) {
            final long now = SystemClock.uptimeMillis();
            Message msg = mMessages; // 队列头部

            if (msg != null) {
                if (now < msg.when) {
                    // 还没到执行时间,计算等待时间
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                    // 取出消息
                    mMessages = msg.next;
                    return msg;
                }
            } else {
                nextPollTimeoutMillis = -1; // 无消息,无限等待
            }

            // 处理 IdleHandler
            for (IdleHandler idler : mIdleHandlers) {
                if (!idler.queueIdle()) {
                    mIdleHandlers.remove(idler);
                }
            }
        }
    }
}

epoll 机制:Linux 的 IO 多路复用。MessageQueue 底层通过 eventfd + epoll 实现阻塞/唤醒:

  • 无消息时 epoll_wait 阻塞,不消耗 CPU
  • 有新消息时通过 eventfd 写入唤醒 epoll
同步屏障

同步屏障是一个 target 为 null 的特殊 Message,插入队列后,next() 会跳过所有同步消息,优先处理异步消息。

java
// 发送同步屏障(hide API)
int token = queue.postSyncBarrier();

// 异步消息
Message msg = Message.obtain();
msg.setAsynchronous(true); // 标记为异步
handler.sendMessage(msg);

// 移除同步屏障
queue.removeSyncBarrier(token);

应用场景:ViewRootImpl 在 scheduleTraversals() 时发送同步屏障,确保 UI 绘制的异步消息优先执行,不被其他同步消息阻塞。

IdleHandler

当 MessageQueue 空闲时(没有消息或下一条消息还没到时间)执行:

kotlin
Looper.myQueue().addIdleHandler {
    // 空闲时执行,适合做延迟初始化
    doSomeLazyInit()
    false // 返回 false 执行一次后移除,true 保持
}

应用:GC、Activity 销毁(ActivityThread 中)、延迟初始化。

Handler 机制深入

epoll 机制

MessageQueue.next() 在没有消息时会阻塞,底层使用 Linux 的 epoll 机制:

c
// Looper.cpp (Native 层)
Looper::Looper() {
    mWakeEventFd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);  // 创建 eventfd
    mEpollFd = epoll_create1(EPOLL_CLOEXEC);                 // 创建 epoll

    // 监听 eventfd
    epoll_ctl(mEpollFd, EPOLL_CTL_ADD, mWakeEventFd, &eventItem);
}

// 等待消息
int Looper::pollInner(int timeoutMillis) {
    // epoll_wait 阻塞,直到:
    // 1. 超时(延迟消息到时间了)
    // 2. eventfd 可读(有新消息写入)
    int eventCount = epoll_wait(mEpollFd, eventItems, EPOLL_MAX_EVENTS, timeoutMillis);

    // 被唤醒后处理事件
    for (int i = 0; i < eventCount; i++) {
        if (eventItems[i].data.fd == mWakeEventFd) {
            awoken();  // 读取 eventfd,清除唤醒标记
        }
    }
}

// 唤醒
void Looper::wake() {
    uint64_t inc = 1;
    write(mWakeEventFd, &inc, sizeof(uint64_t));  // 向 eventfd 写入,触发 epoll_wait 返回
}
同步屏障详解
java
// 插入同步屏障(target == null 的 Message)
// 这是一个 hide 方法,只有 Framework 内部使用
int postSyncBarrier() {
    synchronized (this) {
        Message msg = Message.obtain();
        msg.markInUse();
        msg.when = SystemClock.uptimeMillis();
        msg.arg1 = token;
        // 注意:没有设置 target!普通 Message 的 target 是 Handler

        // 按时间插入队列
        Message prev = null;
        Message p = mMessages;
        while (p != null && p.when <= msg.when) {
            prev = p;
            p = p.next;
        }
        if (prev != null) {
            msg.next = p;
            prev.next = msg;
        } else {
            msg.next = p;
            mMessages = msg;
        }
        return token;
    }
}

// next() 中处理同步屏障
Message next() {
    for (;;) {
        nativePollOnce(ptr, nextPollTimeoutMillis);

        synchronized (this) {
            Message msg = mMessages;

            // 遇到同步屏障(target == null)
            if (msg != null && msg.target == null) {
                // 跳过所有同步消息,找异步消息
                do {
                    msg = msg.next;
                } while (msg != null && !msg.isAsynchronous());
                // 只有异步消息能通过屏障
            }

            if (msg != null) {
                if (now < msg.when) {
                    nextPollTimeoutMillis = (int) Math.min(msg.when - now, Integer.MAX_VALUE);
                } else {
                    // 取出消息
                    return msg;
                }
            }
        }
    }
}

使用场景:ViewRootImpl 在 scheduleTraversals() 时插入同步屏障,确保 UI 绘制的异步消息优先执行:

java
// ViewRootImpl.java
void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;

        // 1. 插入同步屏障
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();

        // 2. 发送异步消息(Choreographer 回调)
        mChoreographer.postCallback(
            Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
        // Choreographer 内部发送的是异步消息(msg.setAsynchronous(true))
    }
}

void doTraversal() {
    mTraversalScheduled = false;

    // 3. 移除同步屏障
    mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

    // 4. 执行绘制
    performTraversals();
}
IdleHandler 详解
java
// MessageQueue.java
Message next() {
    int pendingIdleHandlerCount = -1;

    for (;;) {
        nativePollOnce(ptr, nextPollTimeoutMillis);

        synchronized (this) {
            // ... 取消息的逻辑 ...

            // 没有消息或下一条消息还没到时间
            if (pendingIdleHandlerCount < 0 && (mMessages == null || now < mMessages.when)) {
                pendingIdleHandlerCount = mIdleHandlers.size();
            }

            if (pendingIdleHandlerCount <= 0) {
                mBlocked = true;
                continue; // 没有 IdleHandler,继续等待
            }

            mPendingIdleHandlers = mIdleHandlers.toArray(mPendingIdleHandlers);
        }

        // 执行 IdleHandler(在 synchronized 外面,不持有锁)
        for (int i = 0; i < pendingIdleHandlerCount; i++) {
            IdleHandler idler = mPendingIdleHandlers[i];
            boolean keep = idler.queueIdle();  // 执行回调

            if (!keep) {
                // 返回 false,执行一次后移除
                synchronized (this) {
                    mIdleHandlers.remove(idler);
                }
            }
        }

        pendingIdleHandlerCount = 0; // 本轮已执行,不再重复
    }
}

实际应用:

kotlin
// 1. 延迟初始化(首帧绘制完成后的空闲时间)
Looper.myQueue().addIdleHandler {
    // 在主线程空闲时执行,不影响 UI 流畅度
    Analytics.init(application)
    false // 只执行一次
}

// 2. 监控主线程卡顿
Looper.myQueue().addIdleHandler {
    // 每次主线程空闲时记录时间
    lastIdleTime = SystemClock.uptimeMillis()
    true // 保持,每次空闲都执行
}

// 3. Activity 泄漏检测(LeakCanary 的做法)
// 在 onDestroy 后添加 IdleHandler,空闲时检查 Activity 是否被回收
Handler 内存泄漏完整防护
kotlin
// 方案1:静态内部类 + WeakReference(Java 传统方案)
class MyActivity : AppCompatActivity() {
    private class SafeHandler(activity: MyActivity) : Handler(Looper.getMainLooper()) {
        private val ref = WeakReference(activity)

        override fun handleMessage(msg: Message) {
            val activity = ref.get() ?: return
            when (msg.what) {
                MSG_UPDATE -> activity.updateUI()
            }
        }
    }

    private val handler = SafeHandler(this)

    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacksAndMessages(null)
    }
}

// 方案2:Lifecycle 感知(推荐)
class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 使用 lifecycleScope,Activity 销毁时自动取消
        lifecycleScope.launch {
            delay(60_000)
            updateUI()
        }
    }
}

// 方案3:自定义 LifecycleHandler
class LifecycleHandler(
    private val lifecycleOwner: LifecycleOwner,
    looper: Looper = Looper.getMainLooper()
) : Handler(looper), LifecycleEventObserver {

    init {
        lifecycleOwner.lifecycle.addObserver(this)
    }

    override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
        if (event == Lifecycle.Event.ON_DESTROY) {
            removeCallbacksAndMessages(null)
            lifecycleOwner.lifecycle.removeObserver(this)
        }
    }
}

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 处理消息:

kotlin
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为什么会发生内存泄漏

  1. 在使用非静态内部类时就容易导致内存泄漏,原因在于非静态内部类默认持有外部类的引用
  2. 泄漏时机在于发送了一条延时Message,但宿主比如Activity已经回调了onDestroy(),此时却因为Message还没有执行发生内存泄漏
  3. 发送泄漏的引用链为:Looper -> MessageQueue -> Message -> Handler(非静态内部类) -> Activity

6. 如何解决Handler内存泄漏

  1. 使用静态内部类+弱引用Activity解决。在Message被回调时判断当前Activity的弱引用是否为null,不为null时才执行
  2. 在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资源吗?

  1. 并不会消耗太多的资源。在 MessageQueue 当前没有消息要执行时会进行休眠,调用了 native 层 NativeMessageQueue,NativeMessageQueue 调用了 native 层的 Looper,该 Looper 会使用 Linux 的 epoll 机制进行休眠,休眠时会让出 CPU 调度
  2. epoll会监听了一个专门用于休眠操作的文件描述符,并向底层签写了一个当前文件描述符可读的回调,在 java 层 MessageQueue 入队时会对休眠的文件描述符进行写入,然后唤醒之前的休眠操作

11. 同步屏障是什么?它的原理是怎么实现的?

  1. 同步屏障是一种特殊的消息,可以使 Handler 优先执行异步消息。在 ViewRootImpl.scheduleTraversals() 方法中发送了一个同步屏障,并紧接着发送了一个用于测量布局绘制的异步消息。
  2. 在 MessageQueue.next() 读取下一条消息时,会先判断队列头是否是同步屏障,如果是的话,就会跳过同步消息,只寻找异步消息,最后返回给 Looper
  3. 通过同步屏障和异步消息来保证了 View 的绘制会优先执行,避免了消息过多而出现掉帧的情况

12. 主线程为什么不用初始化Looper

在Android程序入口ActivityThread的main方法中初始化了主线程Looper:

java
// 初始化主线程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中:

java
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 操作

RxJava

RxJava 核心模型

RxJava 是基于观察者模式的响应式编程库,核心目标是把异步事件流抽象成可组合、可转换、可切换线程的数据流。

核心角色:

  • Observable / Flowable:事件源,负责发射数据
  • Observer / Subscriber:观察者,负责接收数据
  • Disposable / Subscription:订阅关系,用于取消订阅
  • Operator:操作符,用于转换、过滤、组合事件流
  • Scheduler:线程调度器,用于控制上游订阅和下游回调所在线程

典型流程:

kotlin
api.getUser()
    .subscribeOn(Schedulers.io())
    .map { it.toUiModel() }
    .observeOn(AndroidSchedulers.mainThread())
    .subscribe(
        { user -> render(user) },
        { error -> showError(error) }
    )

Observable、Single、Maybe、Completable、Flowable

常见类型按语义区分:

  • Observable<T>:可以发射 0 到 N 个事件,适合普通事件流
  • Single<T>:只发射一个成功值或一个错误,适合网络请求
  • Maybe<T>:可能发射一个值,也可能只完成,适合可空结果
  • Completable:不关心返回值,只关心完成或失败,适合提交、删除、写入操作
  • Flowable<T>:支持背压,适合上游发射速度可能远快于下游消费速度的场景

Android 项目里,网络请求通常用 Single 更贴近语义;连续 UI 事件可以用 Observable;高频数据流或文件读取可考虑 Flowable

线程切换

subscribeOn() 决定上游订阅和事件生产在哪个线程执行,通常只有第一次调用生效。

observeOn() 决定下游观察者在哪个线程接收事件,可以调用多次,每次都会影响后续链路。

常见调度器:

  • Schedulers.io():IO 密集任务,如网络、数据库、文件读写
  • Schedulers.computation():CPU 密集任务,如计算、排序、图片处理
  • Schedulers.single():单线程串行任务
  • AndroidSchedulers.mainThread():Android 主线程,用于更新 UI

常见写法是:上游 subscribeOn(Schedulers.io()),下游 observeOn(AndroidSchedulers.mainThread())

常用操作符

转换类:

  • map:一对一转换
  • flatMap:一对多或异步嵌套转换,不保证顺序
  • concatMap:按顺序执行异步转换
  • switchMap:只保留最新一次请求,适合搜索联想

过滤类:

  • filter:按条件过滤
  • distinctUntilChanged:过滤连续重复值
  • debounce:防抖,适合搜索框输入
  • take / skip:截取或跳过事件

组合类:

  • zip:多个源一一配对,全部有值后合并
  • combineLatest:任一源更新时,用各源最新值合并
  • merge:多个源合并并发发射
  • concat:多个源按顺序串行发射

背压

背压是指上游发射速度超过下游处理速度时的压力控制问题。Observable 不支持背压,Flowable 支持背压。

常见背压策略:

  • BUFFER:缓存所有事件,可能 OOM
  • DROP:下游忙时丢弃新事件
  • LATEST:只保留最新事件
  • ERROR:无法处理时直接抛异常
  • MISSING:不指定策略,由下游自己处理

在 Android 中,普通网络请求和 UI 事件大多不需要 Flowable;高频传感器、文件读取、大量数据库流式读取才更需要关注背压。

生命周期与内存泄漏

RxJava 本身不感知 Android 生命周期。如果 Activity/Fragment 销毁后订阅仍未取消,链路里的 Lambda 或 Observer 可能持有页面引用,导致内存泄漏或销毁后更新 UI。

常见处理方式:

  • onDestroy() / onDestroyView() 中调用 CompositeDisposable.clear()
  • Fragment 中区分 Fragment 生命周期和 View 生命周期,避免 view 销毁后继续更新 binding
  • 使用 AutoDispose、RxLifecycle 等库绑定生命周期
  • Repository 层尽量返回冷流,订阅由 ViewModel 或 UI 层统一管理

RxJava 与协程 / Flow 对比

RxJava 优势:

  • 操作符非常丰富,复杂事件流组合能力强
  • Java 项目和老项目兼容性好
  • 生态成熟,Retrofit、Room 等历史支持完善

协程 / Flow 优势:

  • 语法更接近同步代码,可读性更好
  • 结构化并发天然绑定作用域,取消传播更清晰
  • Flow 与 Kotlin、Lifecycle、Compose 结合更自然

现在的新 Kotlin Android 项目通常优先使用协程和 Flow;存量项目或复杂响应式链路中,RxJava 仍然很常见。

1. RxJava 的核心原理是什么?

RxJava 本质上是观察者模式 + 装饰器链 + 线程调度。上游通过 Observable/Flowable 发射事件,下游 Observer/Subscriber 接收事件,中间通过一系列操作符把事件转换、过滤、组合。

订阅发生时,RxJava 会从下游向上游逐层包装 Observer,形成一条链。事件真正发射时,再从上游向下游逐层传递。每个操作符本质上就是包装上游和下游,在 onNextonErroronComplete 中插入自己的处理逻辑。

加分点:能说出 subscribeOn 影响订阅发生的线程,observeOn 影响后续观察者回调的线程。

2. subscribeOn 和 observeOn 有什么区别?

subscribeOn() 控制上游订阅和事件生产的线程,通常只有第一次调用生效;observeOn() 控制它后面那段链路的回调线程,可以调用多次。

常见写法:

kotlin
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:缓存全部,风险是 OOM
  • DROP:下游忙时丢弃事件
  • 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。

Jetpack / MVVM

Compose 原理内容较多,已拆分为独立文件:

  • compose-deep.md — Compose 原理深入(编译器变换、Slot Table、重组机制、Snapshot 系统、副作用 API、性能优化)

ViewModel

作用与原理

ViewModel 用于存储和管理 UI 相关数据,在配置变更(如旋转屏幕)时不会被销毁。

存储原理

Activity / Fragment
  └── ViewModelStoreOwner
        └── ViewModelStore(HashMap<String, ViewModel>)
              ├── "MyViewModel" → MyViewModel 实例
              └── "SharedViewModel" → SharedViewModel 实例

配置变更时,Activity 通过 onRetainNonConfigurationInstance() 保存 ViewModelStore 对象。新 Activity 创建后通过 getLastNonConfigurationInstance() 恢复。

kotlin
// 获取 ViewModel
val viewModel: MyViewModel by viewModels()

// 共享 ViewModel(Fragment 间共享)
val sharedViewModel: SharedViewModel by activityViewModels()
SavedStateHandle

ViewModel 在进程被杀死后会丢失数据。SavedStateHandle 将数据保存到 Bundle 中,进程恢复时可以恢复:

kotlin
class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    val query: LiveData<String> = savedStateHandle.getLiveData("query", "")

    fun setQuery(q: String) {
        savedStateHandle["query"] = q // 自动保存到 Bundle
    }
}
生命周期

ViewModel 的 onCleared() 在 Activity finish 或 Fragment detach 时调用(不是配置变更)。适合在这里取消网络请求、关闭数据库连接等。

Lifecycle

观察者模式

Lifecycle 组件让任何类都能感知生命周期:

kotlin
class MyObserver : DefaultLifecycleObserver {
    override fun onResume(owner: LifecycleOwner) {
        // 开始监听
    }
    override fun onPause(owner: LifecycleOwner) {
        // 停止监听
    }
}

// 注册
lifecycle.addObserver(MyObserver())
实现原理

Activity/Fragment 实现了 LifecycleOwner 接口,内部持有 LifecycleRegistry

在 Activity 中,通过注入一个无 UI 的 ReportFragment 来监听生命周期回调,然后分发给 LifecycleRegistry。

状态与事件的关系:

INITIALIZED → ON_CREATE → CREATED → ON_START → STARTED → ON_RESUME → RESUMED
RESUMED → ON_PAUSE → STARTED → ON_STOP → CREATED → ON_DESTROY → DESTROYED

LiveData

基本原理

LiveData 是生命周期感知的可观察数据容器:

kotlin
val liveData = MutableLiveData<String>()

// 观察(自动在 STARTED 以上状态分发)
liveData.observe(lifecycleOwner) { value ->
    textView.text = value
}

// 更新
liveData.value = "新数据"          // 主线程
liveData.postValue("新数据")       // 任意线程
源码分析

核心是版本号机制:

java
public class LiveData<T> {
    private int mVersion = START_VERSION; // -1
    private T mData;

    @MainThread
    protected void setValue(T value) {
        mVersion++;          // 版本号+1
        mData = value;
        dispatchingValue(null); // 通知所有观察者
    }

    private void considerNotify(ObserverWrapper observer) {
        // 观察者不活跃则跳过
        if (!observer.mActive) return;
        // 观察者的版本号 >= LiveData 版本号,说明已经收到过,跳过
        if (observer.mLastVersion >= mVersion) return;
        observer.mLastVersion = mVersion;
        observer.mObserver.onChanged(mData); // 通知
    }
}
粘性事件问题

新观察者注册时,如果 observer.mLastVersion(-1) < mVersion,会立即收到最后一次数据。这在状态场景下是正确的(如 UI 状态恢复),但在事件场景下会导致重复消费(如 Toast、导航)。

解决方案:

  1. SharedFlow(replay=0) 替代(推荐)
  2. Event 包装类(SingleLiveEvent)
  3. 反射修改 observer 的 mLastVersion

Room

核心组件
kotlin
// Entity:数据表
@Entity(tableName = "users")
data class User(
    @PrimaryKey val id: Int,
    @ColumnInfo(name = "user_name") val name: String,
    val age: Int
)

// DAO:数据访问接口
@Dao
interface UserDao {
    @Query("SELECT * FROM users WHERE id = :userId")
    suspend fun getUser(userId: Int): User?

    @Query("SELECT * FROM users")
    fun getAllUsers(): Flow<List<User>> // 数据变化时自动通知

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: User)

    @Delete
    suspend fun delete(user: User)
}

// Database
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}
编译期处理

Room 使用 APT 在编译期生成 DAO 的实现类。@Query 中的 SQL 在编译期就会验证语法和表名/列名是否正确,避免运行时崩溃。

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

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

MVVM vs MVI

MVVM
View ←(observe)← ViewModel ←→ Repository ←→ DataSource
  • View 观察 ViewModel 的 LiveData/StateFlow
  • ViewModel 处理业务逻辑,调用 Repository
  • 双向数据绑定(DataBinding)或单向观察
MVI
View →(Intent)→ ViewModel →(State)→ View
  • 单向数据流:View 发送 Intent(用户意图)→ ViewModel 处理 → 产生新 State → View 渲染
  • 状态不可变:每次产生全新的 State 对象
  • 可预测:相同的 Intent 序列产生相同的 State 序列
kotlin
// State
data class MainState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

// Intent
sealed class MainIntent {
    object LoadItems : MainIntent()
    data class DeleteItem(val id: Int) : MainIntent()
}

// ViewModel
class MainViewModel : ViewModel() {
    private val _state = MutableStateFlow(MainState())
    val state: StateFlow<MainState> = _state.asStateFlow()

    fun handleIntent(intent: MainIntent) {
        when (intent) {
            is MainIntent.LoadItems -> {
                _state.update { it.copy(isLoading = true) }
                viewModelScope.launch {
                    val items = repository.getItems()
                    _state.update { it.copy(isLoading = false, items = items) }
                }
            }
            is MainIntent.DeleteItem -> { /* ... */ }
        }
    }
}
对比
维度MVVMMVI
数据流多个 LiveData/Flow单一 State
状态管理分散在多个可观察对象集中在一个 State
可预测性一般强(单向数据流)
复杂度较低较高(需要定义 Intent/State)
适用场景简单页面复杂交互、需要状态回溯
核心组件
  • NavHost:容器,显示当前目的地(Fragment 或 Composable)
  • NavController:控制导航,管理回退栈
  • NavGraph:导航图,定义所有目的地和路径
kotlin
// Compose Navigation
NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(navController) }
    composable("detail/{id}") { backStackEntry ->
        val id = backStackEntry.arguments?.getString("id")
        DetailScreen(id)
    }
}

// 导航
navController.navigate("detail/123")

// 带参数和选项
navController.navigate("detail/123") {
    popUpTo("home") { inclusive = false } // 弹出到 home
    launchSingleTop = true               // 避免重复创建
}
Safe Args

类型安全的参数传递(编译期检查):

kotlin
// 传递
val action = HomeFragmentDirections.actionHomeToDetail(userId = 123)
findNavController().navigate(action)

// 接收
val args: DetailFragmentArgs by navArgs()
val userId = args.userId

Paging 3

分页加载库,自动处理加载更多、错误重试、刷新:

kotlin
// 数据源
class UserPagingSource(private val api: ApiService) : PagingSource<Int, User>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, User> {
        val page = params.key ?: 1
        return try {
            val response = api.getUsers(page, params.loadSize)
            LoadResult.Page(
                data = response.users,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (response.users.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

// ViewModel
val users: Flow<PagingData<User>> = Pager(PagingConfig(pageSize = 20)) {
    UserPagingSource(api)
}.flow.cachedIn(viewModelScope)

// UI (Compose)
val lazyPagingItems = viewModel.users.collectAsLazyPagingItems()
LazyColumn {
    items(lazyPagingItems) { user ->
        UserItem(user)
    }
}

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 重复弹出。

解决方案:

  1. SharedFlow(replay=0)(推荐):不缓存历史数据,新收集者不会收到旧事件
  2. Channel:一次性消费,适合导航、Toast 等事件
  3. Event 包装类:用 content + hasBeenHandled 标记是否已消费
  4. 反射修改 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 对象中。

维度MVVMMVI
状态管理分散(多个 LiveData)集中(单一 State)
数据流多向单向
可预测性一般
调试需要追踪多个数据源状态变化清晰可追溯
复杂度较高

选择建议:

  • 简单页面(列表展示、表单):MVVM 足够
  • 复杂交互(多状态联动、需要状态回溯):MVI 更合适
  • 团队规范统一比选哪个更重要

追问:MVI 的缺点?

  1. State 对象可能很大,每次都创建新对象有性能开销(可以用 data class copy 优化)
  2. 简单页面用 MVI 过度设计
  3. 需要定义大量 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 的区别?

考察点:视图绑定

完整回答

维度ViewBindingDataBinding
功能类型安全的 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的粘性事件: 了解postValuesetValue的区别,以及如何避免LiveData的粘性事件在特定场景中引发的问题。

  • ViewModel的存活周期: 使用ViewModel正确处理配置变化,保证数据在屏幕旋转等情况下不丢失。

  • LiveData和View绑定: 结合DataBinding,实现LiveData与View之间的绑定,确保数据的实时更新。

12. LiveData和RxJava有什么区别

  1. 设计目标:

LiveData专为 Android 设计,主要用于在 UI 层观察数据变化;与 Android 生命周期紧密集成,确保数据更新只在 UI 处于活跃状态时触发,避免内存泄漏;简单易用,适合处理 UI 相关的数据流。

RxJava是一个通用的响应式编程库,适用于任何 Java 项目。提供了强大的数据流操作符(如 mapfilterflatMap 等),适合处理复杂的异步任务和数据流。需要手动管理生命周期,否则可能导致内存泄漏。

  1. 生命周期感知

    LiveData自动感知生命周期,确保观察者只在STARTEDRESUMED状态下接收数据更新,无需手动处理生命周期

    RXJava不直接支持生命周期感知,需要借助 RxLifecycleAutoDispose 等第三方库来管理生命周期。如果不处理生命周期,可能导致内存泄漏。

  2. 数据流处理能力

LiveData功能简单,主要用于观察单一数据源的变化;不支持复杂的数据流操作(如线程切换、数据转换等)

RxJava提供了丰富的操作符(如 mapfilterflatMapzip 等),可以轻松处理复杂的数据流。支持线程切换(如 subscribeOnobserveOn),方便处理异步任务。

  1. 线程管理

LiveData默认在主线程中触发数据刷新,如果需要在后台线程更新数据,可以使用 postValue 方法。

RxJava提供了强大的线程调度功能,可以通过 subscribeOnobserveOn 灵活切换线程。

13. 如何实现自定义生命周期的ViewModel

  • 1). 继承ViewModel并重写onCleared()
  • 2). 通过ViewModelProvider.Factory注入自定义作用域
  • 3). 使用LifecycleObserver监听特定生命周期事件

14. ViewModel三大应用场景

  1. 跨屏幕旋转HolderFragment + ViewModelStore机制
  2. 跨组件通信ViewModelStoreOwner的多级作用域控制
  3. 跨进程恢复SavedStateHandleBundle的深度集成

15. 对比LiveData和Observable,分析它们在Android应用中的应用场景,以及在何种情况下选择使用哪种。

LiveData和Observable都是用于实现响应式编程的工具,但有一些关键区别:

  • 生命周期感知: LiveData是生命周期感知的,它会在观察者(通常是UI组件)的生命周期内自动启动和停止。这使得在处理UI数据时更加安全,避免了潜在的内存泄漏。
  • 背压处理: Observable在RxJava中通常使用背压策略来处理数据流,而LiveData则通过生命周期感知来实现反应式响应,避免了背压问题。

根据实际需求,选择使用LiveData还是Observable取决于应用的具体场景。对于需要与UI组件绑定的数据,以及对生命周期敏感的场景,LiveData是更好的选择。而在需要更强大的操作符和背压处理的情况下,可以考虑使用Observable。

Compose

Jetpack Compose

声明式 UI 原理

Compose 使用声明式范式:描述 UI 应该是什么样子,而非如何操作 UI。

kotlin
@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")  // 描述 UI,不是命令式操作
}
Slot Table 与 Gap Buffer

Compose 编译器将 @Composable 函数转换为对 Slot Table 的操作。Slot Table 使用 Gap Buffer 数据结构存储组合树:

  • Slot Table 存储所有 Composable 的状态和参数
  • Gap Buffer 使得插入/删除操作高效(类似文本编辑器的实现)
  • 重组时,Compose 比较新旧参数,只更新变化的部分
重组(Recomposition)

当 State 变化时,Compose 只重新执行读取了该 State 的 Composable 函数:

kotlin
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }  // State
    Button(onClick = { count++ }) {
        Text("Count: $count")  // 只有这个 Text 会重组
    }
}

重组作用域:Compose 编译器在每个 Composable 函数调用处插入重组作用域。State 变化时,只有包含该 State 读取的最小作用域会重组。

Snapshot 系统

Compose 的状态管理基于 Snapshot 系统:

  • mutableStateOf 创建的 State 对象会被 Snapshot 系统追踪
  • 读取 State 时注册依赖关系
  • 写入 State 时通知所有依赖的重组作用域
稳定性推断

Compose 编译器会推断类型的稳定性,决定是否可以跳过重组:

kotlin
// 稳定类型:所有属性都是 val 且类型稳定
data class User(val name: String, val age: Int) // ✅ 稳定

// 不稳定类型:有 var 属性或不稳定类型
data class User(var name: String) // ❌ 不稳定
data class State(val items: List<Item>) // ❌ List 接口不稳定(可能是 MutableList)

@Stable@Immutable 注解手动标记稳定性。或使用 kotlinx.collections.immutableImmutableList

副作用 API
kotlin
// LaunchedEffect:进入组合时启动协程,key 变化时重启
LaunchedEffect(userId) {
    val user = repository.getUser(userId)
    // ...
}

// DisposableEffect:需要清理的副作用
DisposableEffect(lifecycleOwner) {
    val observer = LifecycleEventObserver { _, event -> /* ... */ }
    lifecycleOwner.lifecycle.addObserver(observer)
    onDispose {
        lifecycleOwner.lifecycle.removeObserver(observer)
    }
}

// rememberCoroutineScope:获取与组合绑定的 CoroutineScope
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { /* ... */ } })

// derivedStateOf:派生状态,减少不必要的重组
val filteredList by remember {
    derivedStateOf { list.filter { it.isActive } }
}

// snapshotFlow:将 Compose State 转为 Flow
LaunchedEffect(Unit) {
    snapshotFlow { scrollState.firstVisibleItemIndex }
        .collect { index -> /* ... */ }
}
Compose 性能优化
  1. 减少重组范围:将读取 State 的代码下推到最小的 Composable
  2. 使用 keyLazyColumn 中为 item 指定稳定的 key
  3. 延迟读取:传递 lambda 而非值 Modifier.offset { IntOffset(x, 0) }
  4. remember:缓存计算结果
  5. derivedStateOf:避免中间状态触发重组
  6. @Stable/@Immutable:帮助编译器跳过重组

Compose 与 View 互操作

在 Compose 中使用 View
kotlin
@Composable
fun MapView() {
    AndroidView(
        factory = { context ->
            MapView(context).apply { onCreate(null) }
        },
        update = { mapView ->
            // State 变化时更新 View
        }
    )
}
在 View 中使用 Compose
kotlin
// XML 中添加 ComposeView
<androidx.compose.ui.platform.ComposeView
    android:id="@+id/compose_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content" />

// 代码中设置内容
composeView.setContent {
    MaterialTheme {
        MyComposable()
    }
}
渐进式迁移策略
  1. 新页面用 Compose 写
  2. 旧页面中的新组件用 ComposeView 嵌入
  3. 逐步将旧 Fragment 迁移为 Composable
  4. 最终移除 Fragment 和 XML 布局

Compose 编译器做了什么

@Composable 的本质

@Composable 不是普通注解,它会改变函数的类型签名。Compose 编译器插件在编译期对每个 @Composable 函数做以下变换:

kotlin
// 你写的代码
@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

// 编译器变换后(简化)
fun Greeting(name: String, $composer: Composer, $changed: Int) {
    $composer.startRestartGroup(2104126067) // group key,基于源码位置的哈希

    if ($changed and 0b0001 == 0 && $composer.getSkipping()) {
        // 参数没变,跳过重组
        $composer.skipToGroupEnd()
    } else {
        // 执行函数体
        Text("Hello $name", $composer, ...)
    }

    $composer.endRestartGroup()?.updateScope { composer, _ ->
        // 注册重组回调:当需要重组时,重新调用自己
        Greeting(name, composer, $changed or 0b0001)
    }
}

关键变换:

  1. 注入 $composer 参数:Composer 是重组的执行引擎,管理 Slot Table 的读写
  2. 注入 $changed 参数:位掩码,跟踪每个参数是否发生变化(每个参数占 2 bit)
  3. 生成 group key:基于源码文件路径 + 行号 + 列号的哈希值,唯一标识这个 Composable 在组合树中的位置
  4. 包裹 startGroup/endGroup:在 Slot Table 中标记这个 Composable 的范围
$changed 参数详解

每个参数用 2 bit 表示状态:

00 = Unknown(不确定是否变化,需要 equals 比较)
01 = Same(确定没变)
10 = Different(确定变了)
11 = Static(编译期常量,永远不变)
kotlin
// 调用时编译器会计算 $changed
@Composable
fun Parent() {
    val name = remember { "World" }
    // 编译器知道 name 来自 remember,标记为 Same
    Greeting(name, $composer, 0b01) // Same
}

这让 Compose 在很多情况下不需要调用 equals,直接通过位掩码判断是否可以跳过。

可跳过(Skippable)vs 可重启(Restartable)
kotlin
// Restartable + Skippable(理想状态)
// 所有参数都是稳定类型(基本类型、String、@Stable/@Immutable 标记的类)
@Composable
fun UserCard(name: String, age: Int) { ... }

// Restartable + NOT Skippable
// 参数包含不稳定类型(普通 class、List、Map 等)
@Composable
fun UserList(users: List<User>) { ... }
// List 不是稳定类型,Compose 无法确定内容是否变化,每次都要重组

查看编译器报告:

bash
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

Slot Table 与 Gap Buffer

Slot Table 是什么

Slot Table 是 Compose 存储组合树状态的核心数据结构。它用两个线性数组存储整棵树:

groups: IntArray    → 存储 Group 的元数据(key, size, parent, data slot 范围)
slots: Array<Any?> → 存储实际数据(State 值、remember 的值、CompositionLocal 等)
组合树:
  Column
  ├── Text("Hello")
  └── Button(onClick)
      └── Text("Click")

Slot Table(线性化):
groups: [Column | Text | Button | Text]
slots: ["Hello" | onClick_lambda | "Click"]

为什么用线性数组而不是树结构?

  • 内存紧凑,缓存友好
  • 遍历快(顺序访问)
  • 适合 Gap Buffer 优化
Gap Buffer 算法

Gap Buffer 是文本编辑器常用的数据结构。核心思想:在数组中维护一个"间隙"(gap),插入/删除操作只需要移动 gap 到目标位置。

初始状态(首次组合后):
[A][B][C][D][E]  gap在末尾

需要在 B 和 C 之间插入 X:
1. 移动 gap 到 B 后面:
   [A][B][___gap___][C][D][E]
2. 在 gap 位置写入 X:
   [A][B][X][__gap__][C][D][E]

需要删除 D:
1. 移动 gap 到 D 位置:
   [A][B][X][C][___gap___][E]
   (D 被 gap 覆盖,等于删除)

Compose 重组时的流程:

  1. 将 gap 移到当前重组位置
  2. 遍历旧的 Slot Table,与新的组合结果对比
  3. 相同的 group 保留(跳过)
  4. 新增的 group 写入 gap
  5. 删除的 group 被 gap 覆盖
首次组合 vs 重组

首次组合(Composition)

Composer.inserting = true
→ 所有 Composable 都执行
→ 数据写入 Slot Table
→ 生成 LayoutNode 树
→ 交给 Compose UI 进行 measure/layout/draw

重组(Recomposition)

Composer.inserting = false
→ 遍历 Slot Table,对比新旧数据
→ 参数没变的 Composable 跳过(skipToGroupEnd)
→ 参数变了的 Composable 重新执行,更新 Slot Table
→ 只有变化的 LayoutNode 需要重新 measure/layout/draw

重组机制

重组作用域(RecomposeScope)

每个 Restartable 的 Composable 函数对应一个 RecomposeScope。当函数内读取的 State 发生变化时,这个 Scope 被标记为 invalid,等待重组。

kotlin
@Composable
fun Counter() {  // ← 这是一个 RecomposeScope
    var count by remember { mutableStateOf(0) }  // 读取 State
    Text("Count: $count")  // ← 这也是一个 RecomposeScope
    Button(onClick = { count++ }) {
        Text("Add")  // ← 这也是一个 RecomposeScope
    }
}

count 变化时,只有读取了 count 的 Scope 需要重组。Text("Add") 没有读取 count,不会重组。

State 变化如何触发重组

完整链路:

1. count++
   → MutableState.setValue()
   → Snapshot.writeObserver 被通知

2. writeObserver 记录:这个 State 变了
   → 查找所有读取过这个 State 的 RecomposeScope
   → 将这些 Scope 标记为 invalid

3. Recomposer 收到通知
   → 在下一帧(通过 Choreographer)执行重组
   → 只重新执行 invalid 的 Scope 对应的 Composable 函数

4. 重组执行
   → Composer 遍历 Slot Table
   → 到达 invalid Scope 时,重新执行函数体
   → 对比新旧参数,更新 Slot Table
   → 生成新的 LayoutNode 变更
智能跳过的条件

Composable 函数被跳过需要满足:

  1. 函数是 Restartable 的(有 startRestartGroup)
  2. 所有参数都是稳定类型
  3. 所有参数的值与上次相同(通过 equals 或 $changed 位掩码判断)

稳定类型的定义:

  • 基本类型(Int, Float, Boolean, String 等)
  • 函数类型(lambda)—— 但有陷阱,见下文
  • 标记了 @Stable@Immutable 的类
  • 所有属性都是 val 且类型稳定的 data class
kotlin
// ✅ 稳定:所有属性都是 val + 基本类型
data class User(val id: Int, val name: String)

// ❌ 不稳定:有 var 属性
data class User(val id: Int, var name: String)

// ❌ 不稳定:List 不是稳定类型(可能被外部修改)
data class UserGroup(val users: List<User>)

// ✅ 手动标记稳定(你保证 List 不会被修改)
@Immutable
data class UserGroup(val users: List<User>)
@Stable vs @Immutable
kotlin
// @Immutable:所有属性永远不变(创建后不会修改)
// Compose 可以完全信任 equals 结果
@Immutable
data class Color(val r: Int, val g: Int, val b: Int)

// @Stable:属性可能变化,但变化时会通知 Compose(通过 MutableState)
// 适用于包含 MutableState 的类
@Stable
class CounterState {
    var count by mutableStateOf(0)  // 变化时自动通知
}

区别:

  • @Immutable:更强的保证,对象创建后不会变。Compose 可以更激进地跳过
  • @Stable:对象可能变,但变化是可观察的。Compose 仍然可以跳过(因为变化会触发重组)
Lambda 导致不必要重组的原因
kotlin
// ❌ 每次 Parent 重组,都创建新的 lambda 实例
@Composable
fun Parent() {
    var count by remember { mutableStateOf(0) }

    // 这个 lambda 捕获了 count,每次 count 变化都会创建新实例
    ChildButton(onClick = { println(count) })
}

@Composable
fun ChildButton(onClick: () -> Unit) {
    // onClick 每次都是新对象,equals 返回 false
    // 即使 ChildButton 内部没有变化,也会重组
    Button(onClick = onClick) { Text("Click") }
}

解决方案:

kotlin
// ✅ 方案1:用 remember 缓存 lambda
val onClick = remember { { println(count) } }
// 注意:这样 count 被捕获的是初始值,不会更新

// ✅ 方案2:用 rememberUpdatedState
val currentCount by rememberUpdatedState(count)
val onClick = remember { { println(currentCount) } }

// ✅ 方案3:不捕获变量,通过参数传递
ChildButton(count = count, onClick = { c -> println(c) })

// ✅ 方案4:Compose 编译器优化
// 如果 lambda 没有捕获任何可变变量,编译器会自动将其提升为单例
val onClick = { println("static") }  // 编译器优化为 static final

Snapshot 系统

MutableState 的实现
kotlin
// mutableStateOf 返回的是 SnapshotMutableStateImpl
fun <T> mutableStateOf(value: T): MutableState<T> =
    SnapshotMutableStateImpl(value, StructuralEqualityPolicy())

class SnapshotMutableStateImpl<T>(
    value: T,
    val policy: SnapshotMutationPolicy<T>
) : StateObject, MutableState<T> {

    // 实际值存储在 StateRecord 链表中(支持多版本)
    private var next: StateStateRecord<T> = StateStateRecord(value)

    override var value: T
        get() {
            // 读取时通知 readObserver
            val snapshot = Snapshot.current
            snapshot.readObserver?.invoke(this)  // ← 关键:记录谁读了这个 State
            return next.readable(this, snapshot).value
        }
        set(value) {
            // 写入时通知 writeObserver
            val snapshot = Snapshot.current
            val record = next.writable(this, snapshot)
            if (!policy.equivalent(record.value, value)) {
                record.value = value
                snapshot.writeObserver?.invoke(this)  // ← 关键:通知 State 变了
            }
        }
}
读取追踪

当 Composable 函数执行时,Composer 设置了 readObserver:

kotlin
// Composer 在执行重组时
Snapshot.observe(
    readObserver = { state ->
        // 记录:当前 RecomposeScope 读取了这个 State
        currentRecomposeScope.recordRead(state)
    },
    writeObserver = { state ->
        // 记录:这个 State 被修改了
        // 找到所有读取过它的 Scope,标记为 invalid
    }
) {
    // 在这个 block 中执行 Composable 函数
    composable()
}

这就是 Compose 的"自动依赖追踪":你不需要手动声明依赖关系,只要在 Composable 中读取了某个 State,Compose 就自动知道这个 Composable 依赖这个 State。

Snapshot 隔离

每次重组在自己的 Snapshot 中执行,类似数据库的事务隔离:

kotlin
// 重组开始
val snapshot = Snapshot.takeMutableSnapshot()

snapshot.enter {
    // 在这个 Snapshot 中执行重组
    // 读取的是 Snapshot 创建时的数据快照
    // 写入的修改暂时不可见给其他线程
}

// 重组完成,应用修改
snapshot.apply()  // 类似 commit
// 或者
snapshot.dispose()  // 类似 rollback

好处:

  • 重组过程中,其他线程修改 State 不会影响当前重组
  • 重组失败可以回滚
  • 支持并发重组(不同 Scope 可以在不同线程重组)

副作用 API 详解

LaunchedEffect

在 Composable 进入组合时启动协程,离开时自动取消。key 变化时重启。

kotlin
@Composable
fun SearchScreen(query: String) {
    var results by remember { mutableStateOf(emptyList<Item>()) }

    // query 变化时,取消旧协程,启动新协程
    LaunchedEffect(query) {
        delay(300)  // 防抖
        results = api.search(query)
    }

    ItemList(results)
}

源码原理:

kotlin
@Composable
fun LaunchedEffect(key1: Any?, block: suspend CoroutineScope.() -> Unit) {
    val applyContext = currentComposer.applyCoroutineContext
    // remember 一个 LaunchedEffectImpl
    remember(key1) {
        LaunchedEffectImpl(applyContext, block)
    }
}

// LaunchedEffectImpl 实现了 RememberObserver
class LaunchedEffectImpl : RememberObserver {
    private var job: Job? = null

    override fun onRemembered() {
        // 进入组合时启动协程
        job = scope.launch(context, block = task)
    }

    override fun onForgotten() {
        // 离开组合时取消协程
        job?.cancel()
    }

    override fun onAbandoned() {
        job?.cancel()
    }
}
DisposableEffect

需要清理资源的副作用(类似 onDestroy):

kotlin
@Composable
fun LocationTracker() {
    val context = LocalContext.current

    DisposableEffect(Unit) {
        val listener = LocationListener { location -> /* ... */ }
        val manager = context.getSystemService<LocationManager>()
        manager.requestLocationUpdates(GPS_PROVIDER, 0, 0f, listener)

        onDispose {
            // 离开组合时清理
            manager.removeUpdates(listener)
        }
    }
}

与 LaunchedEffect 的区别:

  • LaunchedEffect:异步操作(网络请求、延迟任务)
  • DisposableEffect:需要配对的注册/注销操作(监听器、回调)
SideEffect

每次成功重组后执行(不是挂起函数,同步执行):

kotlin
@Composable
fun Analytics(screenName: String) {
    // 每次重组后同步执行
    SideEffect {
        analytics.setCurrentScreen(screenName)
    }
}

适用场景:将 Compose 状态同步到非 Compose 管理的对象。

derivedStateOf

将多个 State 合并为一个派生 State,只在结果变化时触发重组:

kotlin
@Composable
fun FilteredList(items: List<Item>, query: String) {
    // ❌ 每次 items 或 query 变化都重组,即使过滤结果没变
    val filtered = items.filter { it.name.contains(query) }

    // ✅ 只在过滤结果变化时重组
    val filtered by remember(items, query) {
        derivedStateOf { items.filter { it.name.contains(query) } }
    }

    LazyColumn {
        items(filtered) { item -> ItemRow(item) }
    }
}

经典场景:列表滚动时判断是否显示"回到顶部"按钮:

kotlin
val listState = rememberLazyListState()

// 只在 "是否可见" 这个布尔值变化时重组,而不是每次滚动都重组
val showButton by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

if (showButton) {
    FloatingActionButton(onClick = { /* scroll to top */ }) { ... }
}
snapshotFlow

将 Compose State 转为 Flow,在 State 变化时发射新值:

kotlin
@Composable
fun ScrollLogger(listState: LazyListState) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .collect { index ->
                analytics.logScroll(index)
            }
    }
}

原理:snapshotFlow 内部创建 Snapshot,在 block 中读取 State 时记录依赖,State 变化时重新执行 block 并发射新值。

Compose 性能优化实战

缩小重组范围
kotlin
// ❌ 整个函数都会重组
@Composable
fun Screen() {
    var count by remember { mutableStateOf(0) }
    ExpensiveHeader()  // 不依赖 count,但每次都重组
    Text("Count: $count")
    ExpensiveFooter()  // 不依赖 count,但每次都重组
}

// ✅ 将读取 State 的部分提取为独立 Composable
@Composable
fun Screen() {
    var count by remember { mutableStateOf(0) }
    ExpensiveHeader()  // 不重组
    CountText(count)   // 只有这个重组
    ExpensiveFooter()  // 不重组
}

@Composable
fun CountText(count: Int) {
    Text("Count: $count")
}
延迟读取 State
kotlin
// ❌ 在组合阶段读取,触发重组
@Composable
fun AnimatedBox() {
    val offset by animateFloatAsState(targetValue = 100f)
    Box(Modifier.offset(x = offset.dp))  // offset 每帧变化,每帧都重组
}

// ✅ 在布局/绘制阶段读取,跳过重组
@Composable
fun AnimatedBox() {
    val offset by animateFloatAsState(targetValue = 100f)
    Box(Modifier.offset { IntOffset(offset.toInt(), 0) })  // lambda 版本,在布局阶段读取
    // 或
    Box(Modifier.graphicsLayer { translationX = offset })  // 在绘制阶段读取
}

Compose 的三个阶段:

Composition(组合)→ Layout(布局)→ Drawing(绘制)

State 在越晚的阶段读取,跳过的工作越多。

使用 Compose Compiler Metrics
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun UserCard(
  stable name: String
  stable age: Int
)

restartable scheme("[androidx.compose.ui.UiComposable]") fun UserList(
  unstable users: List<User>  ← 不稳定参数,无法跳过
)

看到 unstable 参数时的解决方案:

  1. @Immutable 标记数据类
  2. kotlinx.collections.immutableImmutableList 替代 List
  3. 将不稳定参数拆分为稳定的基本类型参数

手势与 Pointer Input

Compose 手势处理分为三个层级:组件内置手势、手势修饰符、底层 pointer input。

优先级通常是:能用组件内置能力就不用自己处理;组件不够用时用 clickabledraggablescrollabletransformable 等修饰符;需要完全自定义手势时再使用 pointerInput

组件内置手势

很多组件天然包含手势和语义能力:

  • ButtonIconButton 自带点击、按压反馈和无障碍语义
  • LazyColumn / LazyRow 自带滚动
  • TextField 自带输入、光标、选择等交互
  • SwipeToDismissBox 这类组件封装了特定交互模式

优先使用内置组件的好处是:语义、焦点、可访问性、键鼠和触摸行为通常都已经处理好了。

clickable / combinedClickable

Modifier.clickable 用于普通点击,它不仅处理 pointer 事件,还会补充语义、焦点、键盘触发、涟漪等交互能力。

kotlin
Box(
    Modifier.clickable { onClick() }
)

combinedClickable 可以同时处理 click、long click、double click,适合需要长按菜单或双击行为的场景。

scrollable / draggable

scrollable 处理滚动手势,但只消费手势和更新滚动状态,不会自动移动内容;内容如何偏移需要你自己根据 state 实现。verticalScroll / horizontalScroll 则是更高层封装,会处理内容偏移。

draggable 用于一维拖拽,它关注 delta 变化,常用于滑块、拖动面板等。二维拖拽通常用 pointerInput 搭配 detectDragGestures

transformable

transformable 用于处理多点触控变换,可以同时获得缩放、旋转、平移。它常配合 rememberTransformableStategraphicsLayer 实现图片预览、地图类缩放旋转。

kotlin
val state = rememberTransformableState { zoomChange, panChange, rotationChange ->
    scale *= zoomChange
    rotation += rotationChange
    offset += panChange
}

Box(
    Modifier
        .graphicsLayer(
            scaleX = scale,
            scaleY = scale,
            rotationZ = rotation,
            translationX = offset.x,
            translationY = offset.y
        )
        .transformable(state)
)
pointerInput

pointerInput 是底层手势入口,适合自定义复杂手势。它的 block 是挂起函数,可以使用 awaitPointerEventScope 逐帧读取 pointer event,也可以使用官方提供的 detector:

  • detectTapGestures
  • detectDragGestures
  • detectTransformGestures
  • awaitEachGesture
kotlin
Box(
    Modifier.pointerInput(Unit) {
        detectTapGestures(
            onLongPress = { offset -> showMenu(offset) },
            onDoubleTap = { offset -> zoom(offset) },
            onTap = { onTap() }
        )
    }
)

pointerInput(key) 的 key 变化时,内部协程会取消并重新启动;如果手势逻辑依赖外部参数,要合理选择 key,或使用 rememberUpdatedState 避免捕获旧值。

事件消费与手势冲突

Pointer event 会在组件树中传递,子父节点都可能观察到事件。手势识别时,如果某个处理方消费了位置变化或点击,其他处理方就应该尊重这个消费状态,避免重复响应。

常见原则:

  • 高层手势修饰符通常已经处理好消费逻辑
  • 自定义 pointerInput 时要关注 PointerInputChange.isConsumed
  • 需要父子滚动协同时使用 nested scroll,而不是手写抢事件
Nested Scroll

Compose 的嵌套滚动通过 nested scroll 系统协调父子滚动。内置可滚动组件通常已经接入 nested scroll;自定义场景可以使用 Modifier.nestedScroll(connection) 处理 pre-scroll、post-scroll、fling 等阶段。

典型场景:折叠 Toolbar、外层 AppBar 和内层列表联动、BottomSheet 与内部列表联动。

1. Compose 的重组原理?怎么优化性能?

考察点:Compose 底层机制

完整回答

Compose 使用 Slot Table 存储组合树的状态。当 State 变化时,Compose 标记受影响的重组作用域(Scope),只重新执行这些 Composable 函数,比较新旧参数决定是否更新 UI。

重组的关键:

  • Compose 编译器为每个 Composable 生成一个 group key(基于源码位置)
  • 参数不变的 Composable 会被跳过(skip)
  • 只有 @Stable@Immutable 标记的类型,或基本类型,才能被正确比较

性能优化:

  1. 缩小重组范围:将读取 State 的代码放在尽可能小的 Composable 中
  2. 使用 remember:缓存计算结果,避免重组时重复计算
  3. derivedStateOf:将多个 State 合并为一个派生 State,减少不必要的重组
  4. key():在列表中为 item 指定稳定的 key,避免不必要的重组
  5. @Stable/@Immutable:标记数据类,让 Compose 知道可以安全跳过
  6. 避免在 Composable 中创建 lambda:lambda 每次创建新对象会导致子 Composable 认为参数变了
kotlin
// ❌ 每次重组都创建新 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 保存)
kotlin
// remember:旋转屏幕后 count 重置为 0
var count by remember { mutableStateOf(0) }

// rememberSaveable:旋转屏幕后 count 保持
var count by rememberSaveable { mutableStateOf(0) }

rememberSaveable 只能保存 Bundle 支持的类型。自定义类型需要实现 Saver:

kotlin
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:

维度LazyColumnRecyclerView
声明方式声明式命令式(Adapter + ViewHolder)
复用机制组合复用(Slot Table)ViewHolder 复用(四级缓存)
布局管理内置(LazyColumn/LazyRow/LazyGrid)LayoutManager
动画animateItemPlacementItemAnimator
性能略低(Compose 开销)略高(成熟优化)

LazyColumn 性能优化:

  • 为 item 指定 key(避免不必要的重组)
  • 避免在 item 中创建复杂的 lambda
  • 使用 contentType 帮助复用
  • 大列表考虑 @Stable 标记数据类
kotlin
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 传给子级。这样子组件更容易复用、测试,也更符合单向数据流。

常见写法是:子组件接收 valueonValueChange,自己不直接持有业务状态;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 提供 transformablerememberTransformableState 处理多点触控变换,可以同时获得 zoom、pan、rotation。UI 变换通常通过 graphicsLayer 应用到组件上。

如果只是图片预览、地图类缩放旋转,优先使用 transformable;如果要实现更复杂的识别流程,可以用 pointerInputdetectTransformGestures

13. Compose 中嵌套滚动和手势冲突怎么处理?

Compose 使用 nested scroll 系统协调父子滚动。内置滚动组件通常已经支持嵌套滚动;自定义联动可以通过 Modifier.nestedScroll(connection) 参与 pre-scroll、post-scroll、fling 等阶段。

不要简单理解成 View 体系里的 requestDisallowInterceptTouchEvent。Compose 更推荐通过手势消费和 nested scroll 协议协调父子组件。典型场景是折叠 Toolbar、BottomSheet 内部列表、外层容器和内层列表联动。

OkHttp

OkHttp 源码分析

整体架构
OkHttpClient
  → newCall(Request) → RealCall
    → execute() / enqueue()
      → getResponseWithInterceptorChain()  // 拦截器链
拦截器链(责任链模式)
应用拦截器(用户添加)
  → RetryAndFollowUpInterceptor    // 重试和重定向
    → BridgeInterceptor            // 补充请求头(Content-Type、Cookie、gzip)
      → CacheInterceptor           // 缓存处理
        → ConnectInterceptor       // 建立连接(从连接池获取或新建)
          → 网络拦截器(用户添加)
            → CallServerInterceptor // 发送请求、读取响应

每个拦截器调用 chain.proceed(request) 传递给下一个拦截器,形成链式调用。

连接池(ConnectionPool)
java
// 默认:最多 5 个空闲连接,空闲 5 分钟回收
ConnectionPool(5, 5, TimeUnit.MINUTES)

连接复用条件:相同的 host + port + scheme。HTTP/2 还可以在同一连接上多路复用。

连接池通过后台线程定期清理空闲连接(cleanupRunnable)。

缓存策略(CacheInterceptor)

OkHttp 的缓存基于 DiskLruCache,遵循 HTTP 缓存规范:

  1. 检查是否有缓存响应
  2. 根据 Cache-Control 判断缓存是否过期
  3. 未过期 → 直接返回缓存(强缓存)
  4. 过期 → 发送条件请求(If-None-Match / If-Modified-Since)
  5. 服务端返回 304 → 使用缓存;200 → 使用新响应并更新缓存

OkHttp 整体架构

核心类关系
OkHttpClient(配置中心)
├── Dispatcher(调度器:管理异步请求的线程池)
├── ConnectionPool(连接池:复用 TCP 连接)
├── Interceptors(应用拦截器列表)
├── NetworkInterceptors(网络拦截器列表)
└── Cache(HTTP 缓存)

请求流程:
Call → RealCall → getResponseWithInterceptorChain() → 拦截器链
Dispatcher 调度器
java
public final class Dispatcher {
    private int maxRequests = 64;           // 最大并发请求数
    private int maxRequestsPerHost = 5;     // 每个主机最大并发数
    private ExecutorService executorService; // 线程池

    // 三个队列
    private final Deque<AsyncCall> readyAsyncCalls = new ArrayDeque<>();  // 等待执行
    private final Deque<AsyncCall> runningAsyncCalls = new ArrayDeque<>(); // 正在执行(异步)
    private final Deque<RealCall> runningSyncCalls = new ArrayDeque<>();   // 正在执行(同步)

    // 异步请求入队
    void enqueue(AsyncCall call) {
        synchronized (this) {
            readyAsyncCalls.add(call);
        }
        promoteAndExecute(); // 尝试将 ready 移到 running
    }

    private boolean promoteAndExecute() {
        for (Iterator<AsyncCall> i = readyAsyncCalls.iterator(); i.hasNext(); ) {
            AsyncCall asyncCall = i.next();

            if (runningAsyncCalls.size() >= maxRequests) break;        // 达到最大并发
            if (asyncCall.callsPerHost().get() >= maxRequestsPerHost) continue; // 达到单主机上限

            i.remove();
            asyncCall.callsPerHost().incrementAndGet();
            runningAsyncCalls.add(asyncCall);
            executorService().execute(asyncCall); // 提交到线程池
        }
    }
}

线程池配置:

java
// 核心线程数 0,最大线程数 Integer.MAX_VALUE,空闲 60 秒回收
// 使用 SynchronousQueue:不缓存任务,来一个执行一个
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
    new SynchronousQueue<>(), threadFactory);

拦截器链源码分析

责任链模式
java
// RealCall.getResponseWithInterceptorChain()
Response getResponseWithInterceptorChain() throws IOException {
    List<Interceptor> interceptors = new ArrayList<>();

    interceptors.addAll(client.interceptors());         // 1. 应用拦截器(用户添加的)
    interceptors.add(new RetryAndFollowUpInterceptor()); // 2. 重试和重定向
    interceptors.add(new BridgeInterceptor());           // 3. 桥接(补全请求头)
    interceptors.add(new CacheInterceptor());            // 4. 缓存
    interceptors.add(new ConnectInterceptor());          // 5. 建立连接
    interceptors.addAll(client.networkInterceptors());   // 6. 网络拦截器(用户添加的)
    interceptors.add(new CallServerInterceptor());       // 7. 发送请求/读取响应

    Interceptor.Chain chain = new RealInterceptorChain(interceptors, 0, request, ...);
    return chain.proceed(request);
}
java
// RealInterceptorChain.proceed()
public Response proceed(Request request) throws IOException {
    // 创建下一个 Chain,index + 1
    RealInterceptorChain next = new RealInterceptorChain(
        interceptors, index + 1, request, ...);

    // 取出当前拦截器
    Interceptor interceptor = interceptors.get(index);

    // 调用拦截器,传入 next chain
    Response response = interceptor.intercept(next);

    return response;
}
各拦截器详解

RetryAndFollowUpInterceptor

java
@Override
public Response intercept(Chain chain) throws IOException {
    while (true) {
        try {
            response = chain.proceed(request);
        } catch (RouteException e) {
            // 路由异常,尝试恢复
            if (!recover(e.getLastConnectException(), false)) throw e;
            continue; // 重试
        }

        // 处理重定向(301/302/307/308)
        Request followUp = followUpRequest(response);
        if (followUp == null) return response; // 不需要重定向

        if (++followUpCount > MAX_FOLLOW_UPS) throw new ProtocolException("Too many redirects");
        request = followUp; // 用重定向的 URL 重新请求
    }
}

BridgeInterceptor

java
// 补全请求头
@Override
public Response intercept(Chain chain) throws IOException {
    Request.Builder requestBuilder = userRequest.newBuilder();

    // 自动添加 Content-Type
    if (body != null) {
        requestBuilder.header("Content-Type", body.contentType().toString());
        requestBuilder.header("Content-Length", Long.toString(body.contentLength()));
    }

    // 自动添加 Host
    requestBuilder.header("Host", userRequest.url().host());

    // 自动添加 Accept-Encoding: gzip
    if (userRequest.header("Accept-Encoding") == null) {
        requestBuilder.header("Accept-Encoding", "gzip");
    }

    // 自动添加 Cookie
    List<Cookie> cookies = cookieJar.loadForRequest(userRequest.url());
    if (!cookies.isEmpty()) {
        requestBuilder.header("Cookie", cookieHeader(cookies));
    }

    Response response = chain.proceed(requestBuilder.build());

    // 自动解压 gzip
    if ("gzip".equalsIgnoreCase(response.header("Content-Encoding"))) {
        response = response.newBuilder()
            .body(new RealResponseBody(contentType, -1, Okio.buffer(new GzipSource(source))))
            .build();
    }
    return response;
}

CacheInterceptor

java
@Override
public Response intercept(Chain chain) throws IOException {
    Response cacheCandidate = cache != null ? cache.get(chain.request()) : null;

    CacheStrategy strategy = new CacheStrategy.Factory(now, request, cacheCandidate).compute();
    Request networkRequest = strategy.networkRequest;   // null 表示不需要网络
    Response cacheResponse = strategy.cacheResponse;    // null 表示没有可用缓存

    // 情况1:不需要网络,有缓存 → 直接返回缓存(强缓存命中)
    if (networkRequest == null && cacheResponse != null) {
        return cacheResponse;
    }

    // 情况2:需要网络
    Response networkResponse = chain.proceed(networkRequest);

    // 情况3:304 Not Modified → 用缓存 body + 网络 header
    if (networkResponse.code() == 304 && cacheResponse != null) {
        Response response = cacheResponse.newBuilder()
            .headers(combine(cacheResponse.headers(), networkResponse.headers()))
            .build();
        cache.update(cacheResponse, response);
        return response;
    }

    // 情况4:200 → 更新缓存,返回网络响应
    cache.put(networkResponse);
    return networkResponse;
}

ConnectInterceptor

java
@Override
public Response intercept(Chain chain) throws IOException {
    RealInterceptorChain realChain = (RealInterceptorChain) chain;

    // 从连接池获取或创建新连接
    Exchange exchange = realChain.call().initExchange(chain);

    return realChain.proceed(request, exchange);
}
应用拦截器 vs 网络拦截器
请求流程:
  应用拦截器 → Retry → Bridge → Cache → Connect → 网络拦截器 → CallServer

                                    ↑ 缓存命中时到这里就返回了
维度应用拦截器网络拦截器
位置最外层Connect 之后
调用次数只调用一次重定向时多次调用
能否短路可以(不调用 proceed)可以
看到的 Request原始请求Bridge 补全后的请求
看到的 Response最终响应原始网络响应(未解压 gzip)
缓存命中时仍然执行不执行
适用场景日志、公共参数、Token网络监控、流量统计

连接池详解

连接复用
java
public final class ConnectionPool {
    // 默认:最多 5 个空闲连接,空闲 5 分钟回收
    private final int maxIdleConnections;
    private final long keepAliveDurationNs;
    private final Deque<RealConnection> connections = new ArrayDeque<>();

    // 清理线程
    private final Runnable cleanupRunnable = () -> {
        while (true) {
            long waitNanos = cleanup(System.nanoTime());
            if (waitNanos == -1) return; // 没有连接了
            wait(waitNanos); // 等待下次清理
        }
    };
}

连接复用的条件:

java
// RealConnection.isEligible()
boolean isEligible(Address address, List<Route> routes) {
    // 1. 连接没有达到最大并发流数(HTTP/2 默认 256)
    if (allocationLimit <= allocations.size()) return false;

    // 2. 地址匹配(host, port, proxy, ssl 等)
    if (!address.equalsNonHost(this.route.address())) return false;

    // 3. 主机名匹配
    if (address.url().host().equals(this.route().address().url().host())) {
        return true; // 完全匹配
    }

    // 4. HTTP/2 连接合并(不同主机但同一 IP + 证书覆盖)
    if (this.http2Connection != null) {
        // 检查证书是否覆盖目标主机
        return route.address().hostnameVerifier().verify(address.url().host(), handshake);
    }

    return false;
}
连接清理
java
long cleanup(long now) {
    int idleConnectionCount = 0;
    RealConnection longestIdleConnection = null;
    long longestIdleDurationNs = Long.MIN_VALUE;

    for (RealConnection connection : connections) {
        // 统计空闲连接
        if (connection.allocations.isEmpty()) {
            idleConnectionCount++;
            long idleDurationNs = now - connection.idleAtNanos;
            if (idleDurationNs > longestIdleDurationNs) {
                longestIdleDurationNs = idleDurationNs;
                longestIdleConnection = connection;
            }
        }
    }

    if (longestIdleDurationNs >= keepAliveDurationNs
        || idleConnectionCount > maxIdleConnections) {
        // 超时或超量,移除最久空闲的连接
        connections.remove(longestIdleConnection);
        longestIdleConnection.socket().close();
        return 0; // 立即再次清理
    }

    if (idleConnectionCount > 0) {
        // 有空闲连接,等到最久的那个超时
        return keepAliveDurationNs - longestIdleDurationNs;
    }

    return -1; // 没有空闲连接
}

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:

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

        val response = chain.proceed(request)

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

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

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

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-ControlExpiresETagLast-Modified 等。只配置 Cache 目录和大小,并不代表所有响应都会被缓存。

常见不缓存原因:

  • 服务端响应包含 no-store 或不允许缓存
  • 请求方法或响应状态不适合缓存
  • 请求头显式要求跳过缓存
  • 响应缺少可缓存条件且 OkHttp 无法判断新鲜度

缓存命中时可以直接返回缓存;缓存过期时可能发起条件请求,服务端返回 304 后复用本地缓存体。

Retrofit

Retrofit

动态代理
kotlin
interface ApiService {
    @GET("users/{id}")
    suspend fun getUser(@Path("id") id: Int): User
}

val api = Retrofit.Builder()
    .baseUrl("https://api.example.com/")
    .addConverterFactory(GsonConverterFactory.create())
    .build()
    .create(ApiService::class.java) // 动态代理

create() 内部使用 Proxy.newProxyInstance 创建代理对象。调用接口方法时:

  1. 解析方法上的注解(@GET、@POST、@Path、@Query 等)
  2. 构建 OkHttp Request
  3. 通过 CallAdapter 适配返回类型(Call、Flow、suspend 函数)
  4. 通过 Converter 转换响应体(Gson、Moshi 等)
适配器模式
  • CallAdapter:将 OkHttp 的 Call 适配为其他类型(RxJava Observable、Kotlin suspend)
  • Converter:将 ResponseBody 转换为目标类型(JSON → 数据类)

Retrofit 动态代理详解

create() 源码
java
public <T> T create(final Class<T> service) {
    // 验证接口
    validateServiceInterface(service);

    return (T) Proxy.newProxyInstance(
        service.getClassLoader(),
        new Class<?>[] { service },
        new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                // Object 的方法直接调用
                if (method.getDeclaringClass() == Object.class) {
                    return method.invoke(this, args);
                }

                // 加载 ServiceMethod(解析注解,缓存结果)
                return loadServiceMethod(method).invoke(args);
            }
        });
}
ServiceMethod 解析注解
java
// ServiceMethod.parseAnnotations()
static <T> ServiceMethod<T> parseAnnotations(Retrofit retrofit, Method method) {
    // 1. 解析方法注解(@GET, @POST, @Headers 等)
    RequestFactory requestFactory = RequestFactory.parseAnnotations(retrofit, method);

    // 2. 获取返回类型
    Type returnType = method.getGenericReturnType();

    // 3. 创建 CallAdapter(将 Call 适配为目标类型)
    CallAdapter<ResponseT, ReturnT> callAdapter = createCallAdapter(retrofit, returnType);

    // 4. 创建 Converter(将 ResponseBody 转为数据类)
    Converter<ResponseBody, ResponseT> responseConverter = createResponseConverter(retrofit, responseType);

    return new HttpServiceMethod<>(requestFactory, callAdapter, responseConverter);
}
RequestFactory 解析注解
java
// 解析 @GET("/users/{id}")
// 解析 @Query("page")
// 解析 @Body
// 解析 @Path("id")
// 解析 @Header("Authorization")

// 最终构建 OkHttp Request
Request toRequest(Object[] args) {
    RequestBuilder requestBuilder = new RequestBuilder(
        httpMethod, baseUrl, relativeUrl, headers, contentType, ...);

    // 将方法参数填入对应位置
    for (int i = 0; i < args.length; i++) {
        parameterHandlers[i].apply(requestBuilder, args[i]);
        // PathParameterHandler: 替换 URL 中的 {id}
        // QueryParameterHandler: 添加 ?page=1
        // BodyParameterHandler: 序列化为 RequestBody
        // HeaderParameterHandler: 添加请求头
    }

    return requestBuilder.build();
}
suspend 函数支持
java
// Retrofit 检测 suspend 函数
// suspend fun getUser(): User
// 编译后变成: fun getUser(continuation: Continuation<User>): Any

if (isKotlinSuspendFunction) {
    // 最后一个参数是 Continuation
    return new SuspendForBody<>(requestFactory, callFactory, responseConverter, ...);
}

// SuspendForBody.invoke()
@Override
Object invoke(Object[] args) {
    // 取出 Continuation
    Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];

    Call<ResponseT> call = new OkHttpCall<>(requestFactory, args, responseConverter);

    // 将 OkHttp 的异步回调转为协程挂起
    return KotlinExtensions.awaitResponse(call, continuation);
}

// KotlinExtensions.kt
suspend fun <T> Call<T>.await(): T {
    return suspendCancellableCoroutine { continuation ->
        continuation.invokeOnCancellation { cancel() }

        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                if (response.isSuccessful) {
                    continuation.resume(response.body()!!)
                } else {
                    continuation.resumeWithException(HttpException(response))
                }
            }

            override fun onFailure(call: Call<T>, t: Throwable) {
                continuation.resumeWithException(t)
            }
        })
    }
}
Converter 和 CallAdapter
kotlin
// 自定义 Converter:处理特殊的响应格式
class ApiResponseConverter<T>(
    private val delegate: Converter<ResponseBody, T>
) : Converter<ResponseBody, T> {
    override fun convert(value: ResponseBody): T? {
        // 先解析外层包装
        val wrapper = JSON.parse(value.string())
        if (wrapper.code != 0) {
            throw ApiException(wrapper.code, wrapper.message)
        }
        // 再解析 data 字段
        return delegate.convert(ResponseBody.create(null, wrapper.data.toString()))
    }
}

// 自定义 CallAdapter:将 Call<T> 适配为 Flow<T>
class FlowCallAdapter<T>(private val responseType: Type) : CallAdapter<T, Flow<T>> {
    override fun responseType() = responseType

    override fun adapt(call: Call<T>): Flow<T> = flow {
        val response = call.awaitResponse()
        if (response.isSuccessful) {
            emit(response.body()!!)
        } else {
            throw HttpException(response)
        }
    }
}

1. Retrofit 的原理?动态代理是怎么工作的?

考察点:Retrofit 架构

完整回答

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

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

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

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

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

通过 OkHttp 的应用拦截器:

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

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 等不同位置。

Glide

Glide 源码分析

整体架构
Glide.with(context)     → RequestManager(生命周期管理)
    .load(url)          → RequestBuilder(构建请求)
    .into(imageView)    → 触发请求
        → Engine(调度核心)
            → 查活动资源 → 查内存缓存 → 查磁盘缓存 → 网络请求
生命周期管理

Glide.with(activity) 会向 Activity 注入一个空的 SupportRequestManagerFragment,通过 Fragment 的生命周期回调管理请求:

  • onStart → 恢复请求
  • onStop → 暂停请求
  • onDestroy → 取消请求并释放资源
四级缓存
1. 活动资源(ActiveResources)
   - WeakReference 持有正在使用的资源
   - 引用计数,使用中的资源不会被 LRU 回收

2. 内存缓存(MemoryCache / LruResourceCache)
   - LruCache 实现
   - 资源不再使用时从活动资源移到内存缓存

3. 磁盘缓存(DiskLruCache)
   - DATA:原始数据(网络下载的原图)
   - RESOURCE:变换后的数据(裁剪/缩放后的图)
   - 默认策略 AUTOMATIC:远程数据缓存原图,本地数据缓存变换后的图

4. 网络/数据源
   - HttpUrlFetcher / OkHttpStreamFetcher
缓存 Key

缓存 key 由多个因素组成:URL + 宽高 + 变换参数 + 签名等。所以同一张图片不同尺寸的 ImageView 会有不同的缓存。

Bitmap 复用

Glide 维护了一个 BitmapPool(LruBitmapPool),解码新图片时优先从 Pool 中获取合适大小的 Bitmap(inBitmap),避免频繁分配和回收内存。

1. Glide 的缓存机制?和 Picasso 有什么区别?

考察点:图片加载框架

完整回答

Glide 四级缓存:

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

Glide vs Picasso:

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

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

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

追问:Glide 的 BitmapPool 是什么?

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

2. Glide原理

Glide 是 Android 中一个非常成熟的图片加载框架,核心能力包括:图片异步加载、内存缓存、磁盘缓存、图片解码、复用、生命周期管理以及图片变换。

它整体采用了一个比较经典的流程:

  1. with() 绑定生命周期,避免页面销毁后还继续回调导致泄漏;
  2. load() 接收图片模型,比如 URL、File、Uri、资源 id;
  3. Glide 会先根据请求生成一个唯一 Key;
  4. 然后按顺序去查缓存:活动资源 ActiveResources → 内存缓存 LruResourceCache → 磁盘缓存 DiskLruCache
  5. 如果缓存都没命中,就通过网络或本地数据源加载原始数据;
  6. 再经过解码、采样压缩、格式转换、Transform 变换;
  7. 最终回到主线程,把结果设置到 ImageView。

Glide 为了性能做了很多优化,比如:

  • 多级缓存
  • BitmapPool / ArrayPool 对象复用
  • 按需缩放,避免大图直接加载导致 OOM
  • 请求合并,防止同一资源重复加载
  • 生命周期感知,自动暂停/恢复请求

所以一句话总结: Glide 本质上是一个围绕“资源复用 + 多级缓存 + 生命周期管理”构建的高性能图片加载框架。

3. 如何自定义Glide的缓存行为

通过DiskCacheStrategy枚举,可以自定义Glide的缓存行为:

  1. DiskCacheStrategy.ALL: 缓存原始图片和转换后的图片到磁盘缓存
  2. DiskCacheStrategy.NONE: 不使用磁盘缓存
  3. DiskCacheStrategy.RESOURCE: 只缓存转换后的图片到磁盘缓存
  4. DiskCacheStrategy.DATA: 只缓存原始图片到磁盘缓存

缓存与存储

缓存设计

三级缓存架构
内存缓存(LruCache)
  ↓ 未命中
磁盘缓存(DiskLruCache)
  ↓ 未命中
网络请求
  ↓ 响应
写入磁盘缓存 + 内存缓存
LruCache
java
// 基于 LinkedHashMap(accessOrder=true)实现
int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);
int cacheSize = maxMemory / 8; // 使用最大内存的 1/8

LruCache<String, Bitmap> cache = new LruCache<String, Bitmap>(cacheSize) {
    @Override
    protected int sizeOf(String key, Bitmap bitmap) {
        return bitmap.getByteCount() / 1024;
    }
};

LinkedHashMap 的 accessOrder=true 模式:每次访问元素会将其移到链表尾部,头部就是最久未使用的元素。超过容量时移除头部。

本地存储方案

SharedPreferences 的问题
  • 全量读写:每次修改都要序列化整个 Map 写入 XML 文件
  • ANR 风险apply() 异步写入,但 Activity onStop 时 QueuedWork.waitToFinish() 会同步等待写入完成
  • 不支持多进程:MODE_MULTI_PROCESS 已废弃,不可靠
  • 类型不安全:存取时需要手动指定类型
MMKV

MMKV 使用 mmap 内存映射文件:

进程内存空间          文件系统
┌──────────┐     ┌──────────┐
│ 虚拟内存页 │ ←→  │ 磁盘文件  │
└──────────┘     └──────────┘
     mmap 映射

优势:

  • 写入即持久化:写入内存页后由 OS 负责刷盘,不需要手动 fsync
  • 增量写入:使用 protobuf 编码,append 写入,不需要全量序列化
  • 多进程安全:文件锁 + mmap 共享内存
  • 性能:比 SP 快 10-100 倍
DataStore

Jetpack DataStore 是 SP 的官方替代:

kotlin
// Preferences DataStore(键值对)
val dataStore = context.createDataStore(name = "settings")
val DARK_MODE = booleanPreferencesKey("dark_mode")

// 读取
val darkMode: Flow<Boolean> = dataStore.data.map { it[DARK_MODE] ?: false }

// 写入
dataStore.edit { it[DARK_MODE] = true }

优势:基于 Flow 的异步 API、线程安全、不会阻塞 UI 线程、支持 Proto 序列化。

Repository 模式

kotlin
class UserRepository(
    private val api: ApiService,
    private val userDao: UserDao,
    private val cache: LruCache<Int, User>
) {
    fun getUser(id: Int): Flow<User> = flow {
        // 1. 内存缓存
        cache.get(id)?.let { emit(it); return@flow }

        // 2. 数据库缓存
        userDao.getUser(id)?.let {
            cache.put(id, it)
            emit(it)
        }

        // 3. 网络请求
        val user = api.getUser(id)
        userDao.insert(user)
        cache.put(id, user)
        emit(user)
    }.flowOn(Dispatchers.IO)
}

Repository 是数据层的统一入口,封装多个数据源的访问逻辑,对上层(ViewModel)屏蔽数据来源细节。

1. SharedPreferences 有什么问题?MMKV 为什么快?

考察点:存储方案对比

完整回答

SP 的问题:

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

MMKV 快的原因:

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

追问:DataStore 呢?

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

2. 如何设计一个多级缓存架构?

考察点:缓存设计能力

完整回答

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

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

设计要点:

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

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

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

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

3. Room 数据库升级怎么做?

考察点:数据库迁移

完整回答

通过 Migration 定义升级脚本:

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

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

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

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

追问:怎么测试 Migration?

Room 提供了 MigrationTestHelper:

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

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

4. SharedPreferences、DataStore 和 Room 分别适合存什么?

考察点:本地存储选型

完整回答

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

追问:登录 token 存哪里?

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

性能优化与稳定性

内存优化与启动优化内容较多,已拆分为独立文件:

  • memory-startup-deep.md — 内存管理基础、LeakCanary 源码、Bitmap 优化、OOM 排查、启动全链路分析、DAG 任务编排、ContentProvider 优化、Systrace/Perfetto 使用

启动优化

冷启动全链路
用户点击图标
→ Zygote fork 新进程
→ 创建 ActivityThread(主线程)
→ Application.attachBaseContext
→ ContentProvider.onCreate(所有 Provider)
→ Application.onCreate
→ Activity.onCreate → onStart → onResume
→ ViewRootImpl.performTraversals(首帧绘制)
→ 用户看到内容
优化手段

Application.onCreate 优化

  • 延迟初始化:非必要 SDK 延迟到首帧后或使用时初始化
  • 异步初始化:无依赖关系的 SDK 放到子线程
  • 任务编排:有依赖关系的初始化任务用 DAG(有向无环图)编排
kotlin
// 任务编排示例
class InitTask {
    fun start() {
        val executor = Executors.newFixedThreadPool(4)
        val latch = CountDownLatch(3)

        // 无依赖,并行执行
        executor.execute { initSDK_A(); latch.countDown() }
        executor.execute { initSDK_B(); latch.countDown() }
        executor.execute { initSDK_C(); latch.countDown() }

        latch.await() // 等待完成

        // 依赖 A/B/C 的初始化
        initSDK_D()
    }
}

Activity 优化

  • 减少布局层级(ConstraintLayout 替代嵌套 LinearLayout)
  • 延迟加载非首屏内容(ViewStub)
  • 异步 inflate(AsyncLayoutInflater)

指标体系

  • TTID(Time To Initial Display):首帧显示时间
  • TTFD(Time To Full Display):完整内容显示时间
  • 使用 reportFullyDrawn() 标记完整显示时间点
工具
  • Systrace / Perfetto:分析主线程耗时
  • Android Studio Profiler:CPU/内存/网络分析
  • Debug.startMethodTracing() / Debug.stopMethodTracing():方法耗时追踪

卡顿优化

渲染管线
VSYNC 信号(16.6ms 一次,60fps)
→ Choreographer.doFrame()
  → 处理 Input 事件
  → 处理动画
  → 执行 performTraversals(measure/layout/draw)
  → RenderThread 执行 GPU 渲染
→ SurfaceFlinger 合成显示

一帧超过 16.6ms 就会掉帧。

Choreographer 帧率监控
kotlin
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
    private var lastFrameTimeNanos = 0L

    override fun doFrame(frameTimeNanos: Long) {
        if (lastFrameTimeNanos != 0L) {
            val droppedFrames = ((frameTimeNanos - lastFrameTimeNanos) / 16_666_666) - 1
            if (droppedFrames > 0) {
                Log.w("Jank", "掉帧: $droppedFrames 帧")
            }
        }
        lastFrameTimeNanos = frameTimeNanos
        Choreographer.getInstance().postFrameCallback(this)
    }
})
常见卡顿原因
  • 主线程 IO(文件读写、SP 操作)
  • 主线程网络请求
  • 复杂布局(层级过深、过度绘制)
  • 频繁 GC(内存抖动)
  • 主线程锁等待
  • Binder 调用阻塞
BlockCanary 原理

利用 Looper 的日志打印机制:

java
Looper.getMainLooper().setMessageLogging(printer -> {
    if (printer.startsWith(">>>>> Dispatching")) {
        startTime = System.currentTimeMillis();
    }
    if (printer.startsWith("<<<<< Finished")) {
        long cost = System.currentTimeMillis() - startTime;
        if (cost > threshold) {
            // 卡顿!dump 主线程堆栈
        }
    }
});

内存优化

内存区域
  • Java 堆:对象实例,GC 管理
  • Native 内存:JNI 分配、Bitmap(Android 8.0+ 在 Native 堆)、so 库
  • 虚拟机栈:线程栈,默认 1MB/线程
内存泄漏

常见场景:

  1. 静态变量持有 Activity:单例持有 Context
  2. Handler 内存泄漏:非静态内部类持有外部 Activity 引用,Message 在队列中等待
  3. 匿名内部类/Lambda:持有外部类引用
  4. 未注销的监听器:EventBus、BroadcastReceiver、Sensor 等
  5. 集合类泄漏:只添加不移除

Handler 泄漏解决:

kotlin
class MyActivity : AppCompatActivity() {
    // ✅ 静态内部类 + 弱引用
    private class MyHandler(activity: MyActivity) : Handler(Looper.getMainLooper()) {
        private val weakActivity = WeakReference(activity)
        override fun handleMessage(msg: Message) {
            weakActivity.get()?.handleMsg(msg)
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacksAndMessages(null) // 清除所有消息
    }
}
LeakCanary 原理
  1. 注册 ActivityLifecycleCallbacks 监听 Activity 销毁
  2. Activity onDestroy 后,将其包装为 WeakReference + ReferenceQueue
  3. 延迟 5 秒后检查 ReferenceQueue,如果 WeakReference 没有入队说明 Activity 没被回收
  4. 手动触发 GC,再次检查
  5. 仍未回收 → dump hprof → 分析引用链 → 展示泄漏路径
Bitmap 优化
kotlin
// 1. 采样加载
val options = BitmapFactory.Options().apply {
    inJustDecodeBounds = true // 只读取尺寸
}
BitmapFactory.decodeFile(path, options)

options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, options)

// 2. 使用 inBitmap 复用内存
options.inMutable = true
options.inBitmap = reusableBitmap // 复用已有 Bitmap 的内存

// 3. 使用合适的像素格式
options.inPreferredConfig = Bitmap.Config.RGB_565 // 比 ARGB_8888 省一半内存

ANR

四种 ANR 类型
类型超时时间
InputDispatcher(触摸事件)5 秒
BroadcastReceiver前台 10 秒,后台 60 秒
Service前台 20 秒,后台 200 秒
ContentProvider10 秒
ANR 原理

以 InputDispatcher 为例:

  1. InputDispatcher 将事件发送给应用
  2. 启动 5 秒定时器
  3. 应用处理完事件后回复 InputDispatcher
  4. 如果 5 秒内没有回复 → ANR
ANR 排查
  1. 查看 /data/anr/traces.txt(主线程堆栈)
  2. 分析主线程在做什么:
    • 死锁:两个线程互相等待对方的锁
    • IO 阻塞:文件读写、数据库操作
    • Binder 调用:跨进程调用超时
    • CPU 密集计算
// traces.txt 示例
"main" prio=5 tid=1 Blocked
  | group="main" sCount=1 dsCount=0 flags=1 obj=0x... self=0x...
  | sysTid=12345 nice=-10 cgrp=default sched=0/0 handle=0x...
  | state=S schedstat=( 0 0 0 ) utm=100 stm=50 core=0 HZ=100
  | stack=0x...-0x... stackSize=8192KB
  at com.example.MyClass.doSomething(MyClass.java:42)
  - waiting to lock <0x...> (a java.lang.Object) held by thread 15

Crash 治理

Java Crash
kotlin
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
    // 1. 记录崩溃信息(堆栈、设备信息、用户操作路径)
    // 2. 上报到服务端
    // 3. 可选:尝试恢复或优雅退出
    CrashReporter.report(throwable)
    Process.killProcess(Process.myPid())
}
Native Crash

通过信号处理捕获:SIGSEGV(段错误)、SIGABRT(abort)等。

工具:Breakpad(Google)、xCrash(爱奇艺开源)。

Native Crash 分析:

  1. 获取 tombstone 文件
  2. addr2linendk-stack 将地址转换为源码位置
  3. 需要保留未 strip 的 so 文件(带符号表)
稳定性指标
  • Crash Rate:崩溃率 = 崩溃用户数 / DAU
  • ANR Rate:ANR 率
  • 业界标准:Crash Rate < 0.1%,ANR Rate < 0.3%

包体积优化

  • 代码:ProGuard/R8 混淆压缩、移除无用代码
  • 资源:移除无用资源(shrinkResources)、图片压缩(WebP)、资源混淆(AndResGuard)
  • so 库:只保留 arm64-v8a、按需加载 so
  • 动态下发:插件化、Feature Delivery

电量优化

  • 减少 WakeLock 使用
  • 合并网络请求(批量上报)
  • 使用 WorkManager 替代 AlarmManager
  • 适配 Doze 模式和 App Standby
  • GPS 定位使用合适的精度和频率

内存抖动

什么是内存抖动

短时间内大量创建和销毁对象,导致频繁 GC。GC 会暂停应用线程(虽然 ART 的 CC 收集器暂停时间很短),频繁 GC 累积起来会导致卡顿。

常见场景
java
// ❌ 在 onDraw 中创建对象(每帧调用)
@Override
protected void onDraw(Canvas canvas) {
    Paint paint = new Paint(); // 每帧创建新对象!
    Rect rect = new Rect();   // 每帧创建新对象!
    canvas.drawRect(rect, paint);
}

// ✅ 在构造函数中创建,onDraw 中复用
private final Paint paint = new Paint();
private final Rect rect = new Rect();

@Override
protected void onDraw(Canvas canvas) {
    canvas.drawRect(rect, paint); // 复用对象
}

其他常见场景:

  • 循环中创建字符串(用 StringBuilder)
  • RecyclerView.onBindViewHolder 中创建对象
  • 频繁装箱拆箱(int → Integer)
检测工具

Android Studio Profiler 的 Memory 面板:

  • 锯齿状的内存曲线 = 内存抖动
  • 查看 Allocations 面板定位频繁分配的对象

StrictMode

开发阶段检测主线程违规操作:

kotlin
if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()      // 检测主线程磁盘读
            .detectDiskWrites()     // 检测主线程磁盘写
            .detectNetwork()        // 检测主线程网络
            .penaltyLog()           // 违规时打日志
            .penaltyDeath()         // 违规时崩溃(严格模式)
            .build()
    )

    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectLeakedSqlLiteObjects()  // 检测未关闭的 Cursor
            .detectLeakedClosableObjects() // 检测未关闭的 Closeable
            .detectActivityLeaks()         // 检测 Activity 泄漏
            .penaltyLog()
            .build()
    )
}

Baseline Profile

Android 运行时(ART)使用 JIT 编译热点代码。Baseline Profile 提前告诉 ART 哪些代码是热点,在安装时就 AOT 编译,避免首次运行时的 JIT 开销。

kotlin
// 生成 Baseline Profile
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
    @get:Rule
    val rule = BaselineProfileRule()

    @Test
    fun generateProfile() {
        rule.collect(packageName = "com.example.app") {
            // 模拟用户操作路径
            pressHome()
            startActivityAndWait()
            // 滑动列表、点击按钮等
        }
    }
}

效果:启动速度提升 20-40%,滑动流畅度提升。Google Play 会自动使用 Baseline Profile。

R8 优化

R8 是 Android 的代码压缩和混淆工具(ProGuard 的替代):

  • 代码压缩(Shrinking):移除未使用的类、方法、字段
  • 混淆(Obfuscation):将类名/方法名缩短(a, b, c)
  • 优化(Optimization):内联方法、移除空方法、简化控制流
  • 资源压缩:配合 shrinkResources true 移除未引用的资源
groovy
android {
    buildTypes {
        release {
            minifyEnabled true       // 开启 R8
            shrinkResources true     // 资源压缩
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

R8 Full Mode(android.enableR8.fullMode=true):更激进的优化,可能需要额外的 keep 规则。

内存管理基础

Android 进程内存结构
进程内存
├── Java Heap(Dalvik/ART 管理)
│   ├── 对象实例
│   ├── 数组
│   └── 类信息
├── Native Heap(malloc/free)
│   ├── Bitmap 像素数据(Android 8.0+)
│   ├── so 库分配的内存
│   └── 硬件缓冲区(HardwareBuffer)
├── Code(代码段)
│   ├── dex/oat 文件映射
│   └── so 库代码
├── Stack(线程栈)
│   └── 每个线程默认 1MB
├── Graphics(GPU 内存)
│   ├── Surface 缓冲区
│   └── 纹理
└── mmap(内存映射文件)
    ├── APK 资源
    └── 字体文件
Java Heap 限制
kotlin
// 获取堆内存限制
val maxMemory = Runtime.getRuntime().maxMemory()  // 通常 256MB-512MB
val totalMemory = Runtime.getRuntime().totalMemory()  // 当前已分配
val freeMemory = Runtime.getRuntime().freeMemory()    // 当前空闲

// ActivityManager 获取
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
am.memoryClass      // 普通应用堆上限(MB),通常 256
am.largeMemoryClass  // largeHeap 应用堆上限(MB),通常 512
Bitmap 内存变迁
Android 版本Bitmap 像素存储位置回收方式
2.xNative Heap必须手动 recycle()
3.0 - 7.xJava HeapGC 自动回收
8.0+Native Heap(NativeAllocationRegistry)GC 自动回收

Android 8.0+ 将 Bitmap 像素数据移回 Native Heap,但通过 NativeAllocationRegistry 注册到 Java GC,GC 回收 Bitmap 对象时自动释放 Native 内存。好处是不占用 Java Heap 配额。

内存泄漏深入

LeakCanary 原理详解
Activity.onDestroy()
  → ActivityLifecycleCallbacks 监听到
  → 创建 KeyedWeakReference(activity, key, referenceQueue)
  → 等待 5 秒
  → 检查 referenceQueue
    → WeakReference 入队了 → 对象已被回收,正常
    → WeakReference 没入队 → 对象还活着,可能泄漏
      → 手动触发 GC(Runtime.getRuntime().gc())
      → 再等 100ms
      → 再次检查 referenceQueue
        → 入队了 → 正常(GC 延迟回收)
        → 还没入队 → 确认泄漏!
          → Debug.dumpHprofData() 导出堆快照
          → Shark 库分析 hprof 文件
          → 从 GC Root 到泄漏对象的最短引用链
          → 展示通知

核心源码:

kotlin
// ObjectWatcher.kt
fun expectWeaklyReachable(watchedObject: Any, description: String) {
    val key = UUID.randomUUID().toString()
    val watchUptimeMillis = clock.uptimeMillis()

    // 创建弱引用,关联 ReferenceQueue
    val reference = KeyedWeakReference(watchedObject, key, description, watchUptimeMillis, queue)
    watchedObjects[key] = reference

    // 5 秒后检查
    checkRetainedExecutor.execute {
        moveToRetained(key)
    }
}

private fun moveToRetained(key: String) {
    // 先清理已入队的引用
    removeWeaklyReachableObjects()

    val retainedRef = watchedObjects[key]
    if (retainedRef != null) {
        // 还在 watchedObjects 中,说明没被回收
        retainedRef.retainedUptimeMillis = clock.uptimeMillis()
        onObjectRetainedListeners.forEach { it.onObjectRetained() }
    }
}

private fun removeWeaklyReachableObjects() {
    var ref: KeyedWeakReference?
    do {
        ref = queue.poll() as KeyedWeakReference?
        if (ref != null) {
            // 引用入队了,说明对象被回收了,从监控列表移除
            watchedObjects.remove(ref.key)
        }
    } while (ref != null)
}
常见泄漏场景源码级分析

Handler 泄漏

java
// ❌ 非静态内部类持有外部 Activity 引用
public class MainActivity extends Activity {
    private Handler handler = new Handler() {  // 匿名内部类持有 MainActivity.this
        @Override
        public void handleMessage(Message msg) {
            // 处理消息
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        handler.postDelayed(new Runnable() {  // Runnable 也持有 MainActivity.this
            @Override
            public void run() { /* ... */ }
        }, 60_000);  // 60 秒后执行
    }
}

// 泄漏链:
// MessageQueue → Message → Handler(内部类)→ MainActivity
// 60 秒内 Activity 无法被回收
java
// ✅ 正确做法
public class MainActivity extends Activity {
    // 静态内部类不持有外部引用
    private static class SafeHandler extends Handler {
        private final WeakReference<MainActivity> activityRef;

        SafeHandler(MainActivity activity) {
            activityRef = new WeakReference<>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            MainActivity activity = activityRef.get();
            if (activity != null && !activity.isFinishing()) {
                // 安全处理
            }
        }
    }

    private final SafeHandler handler = new SafeHandler(this);

    @Override
    protected void onDestroy() {
        super.onDestroy();
        handler.removeCallbacksAndMessages(null); // 清除所有消息
    }
}

单例持有 Context 泄漏

kotlin
// ❌ 单例持有 Activity Context
object ImageLoader {
    private lateinit var context: Context

    fun init(context: Context) {
        this.context = context  // 如果传入 Activity,Activity 永远无法回收
    }
}

// ✅ 使用 Application Context
object ImageLoader {
    private lateinit var context: Context

    fun init(context: Context) {
        this.context = context.applicationContext  // Application 生命周期与进程一致
    }
}

匿名内部类/Lambda 泄漏

kotlin
// ❌ 注册监听后忘记注销
class MyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 传感器监听器持有 Activity 引用
        sensorManager.registerListener(object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent) {
                updateUI(event)  // 引用了 Activity 的方法
            }
            override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
        }, sensor, SensorManager.SENSOR_DELAY_NORMAL)
    }
    // 忘记在 onDestroy 中 unregisterListener → 泄漏
}
Bitmap 优化详解
kotlin
// 1. 采样加载:先获取图片尺寸,再按需采样
fun decodeSampledBitmap(res: Resources, resId: Int, reqWidth: Int, reqHeight: Int): Bitmap {
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = true  // 只读取尺寸,不分配内存
    }
    BitmapFactory.decodeResource(res, resId, options)

    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
    options.inJustDecodeBounds = false
    return BitmapFactory.decodeResource(res, resId, options)
}

fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
    val (height, width) = options.outHeight to options.outWidth
    var inSampleSize = 1

    if (height > reqHeight || width > reqWidth) {
        val halfHeight = height / 2
        val halfWidth = width / 2
        // inSampleSize 必须是 2 的幂
        while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
            inSampleSize *= 2
        }
    }
    return inSampleSize
}

// 2. inBitmap 复用
val reusableBitmap: Bitmap = ... // 之前用过的 Bitmap
val options = BitmapFactory.Options().apply {
    inMutable = true
    inBitmap = reusableBitmap  // 复用内存块,避免重新分配
    // Android 4.4+ inBitmap 只需要 >= 目标大小即可
    // Android 4.4 以下需要完全相同的尺寸和配置
}

// 3. 像素格式选择
val options = BitmapFactory.Options().apply {
    inPreferredConfig = Bitmap.Config.RGB_565  // 2 字节/像素,无透明通道
    // 默认 ARGB_8888 是 4 字节/像素
    // 一张 1920x1080 的图:
    // ARGB_8888: 1920 * 1080 * 4 = 8.3MB
    // RGB_565:   1920 * 1080 * 2 = 4.1MB
}
OOM 排查流程
1. 获取 hprof 文件
   - Android Studio Profiler → Dump Java Heap
   - 代码中:Debug.dumpHprofData(filePath)
   - LeakCanary 自动 dump

2. 分析 hprof(使用 MAT 或 Android Studio)
   - Dominator Tree:按 Retained Size 排序,找到占内存最大的对象
   - Histogram:按类统计对象数量和大小
   - GC Root 引用链:找到为什么对象无法被回收

3. 常见 OOM 原因
   - Bitmap 过大:加载原图到内存
   - 内存泄漏积累:Activity/Fragment 泄漏
   - 大量对象创建:内存抖动导致碎片化
   - Native 内存泄漏:JNI 层 malloc 未 free
   - WebView 内存:WebView 本身占用大量内存

4. 线上 OOM 监控
   - 注册 Thread.setDefaultUncaughtExceptionHandler 捕获 OOM
   - 定期检查内存使用率,超过阈值时 dump hprof 上报
   - 使用 KOOM(快手开源)自动检测和分析

启动优化实战

冷启动全链路
用户点击图标
  → Launcher 调用 startActivity(Binder → AMS)
  → AMS 检查进程是否存在
  → 不存在 → Zygote fork 新进程(~200ms)
  → ActivityThread.main()
    → Looper.prepareMainLooper()
    → ActivityThread.attach()(Binder → AMS 注册)
    → AMS 回调 bindApplication
      → Application.attachBaseContext()
      → ContentProvider.onCreate()(所有 Provider!)
      → Application.onCreate()
    → AMS 回调 scheduleLaunchActivity
      → Activity.onCreate()
        → setContentView() → inflate XML → 创建 View 树
      → Activity.onStart()
      → Activity.onResume()
    → ViewRootImpl.performTraversals()
      → measure → layout → draw
    → 首帧渲染完成(TTID)
  → 用户看到内容(TTFD)
各阶段耗时分析
典型冷启动耗时分布(总计 ~1500ms):
├── Zygote fork:           ~200ms(系统层面,无法优化)
├── Application 初始化:     ~400ms(重点优化)
│   ├── ContentProvider:    ~150ms(合并 Provider)
│   ├── SDK 初始化:         ~200ms(异步/延迟)
│   └── 其他:               ~50ms
├── Activity 创建:          ~300ms(重点优化)
│   ├── inflate 布局:       ~150ms(减少层级/异步 inflate)
│   ├── 数据加载:           ~100ms(预加载/缓存)
│   └── 其他:               ~50ms
└── 首帧绘制:               ~100ms
任务编排(DAG)
kotlin
// 定义初始化任务
abstract class InitTask {
    abstract fun run()
    open fun dependencies(): List<Class<out InitTask>> = emptyList()
    open fun runOnMainThread(): Boolean = false
}

// 具体任务
class NetworkInitTask : InitTask() {
    override fun run() {
        OkHttpClient.Builder().build()  // 初始化网络库
    }
    // 无依赖,可以并行
}

class ImageLoaderInitTask : InitTask() {
    override fun run() {
        Glide.init(app, config)
    }
    override fun dependencies() = listOf(NetworkInitTask::class.java)  // 依赖网络初始化
}

class PushInitTask : InitTask() {
    override fun run() {
        PushManager.init(app)
    }
    override fun runOnMainThread() = true  // 必须在主线程
}

// DAG 调度器
class TaskScheduler(private val tasks: List<InitTask>) {
    fun start() {
        // 1. 拓扑排序
        val sorted = topologicalSort(tasks)

        // 2. 按依赖关系分层
        // 第一层:无依赖的任务(并行执行)
        // 第二层:依赖第一层的任务(第一层完成后执行)
        // ...

        // 3. 使用 CountDownLatch 等待依赖完成
        val latch = CountDownLatch(mainThreadTasks.size)

        for (task in sorted) {
            if (task.runOnMainThread()) {
                mainHandler.post {
                    task.run()
                    latch.countDown()
                }
            } else {
                executor.execute {
                    // 等待依赖任务完成
                    task.dependencies().forEach { dep ->
                        dependencyLatches[dep]?.await()
                    }
                    task.run()
                    taskLatches[task::class.java]?.countDown()
                }
            }
        }

        // 4. 等待所有主线程任务完成(保证 Activity 启动前必要的初始化已完成)
        latch.await()
    }
}
延迟初始化策略
kotlin
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        // 第一优先级:必须同步初始化(影响首屏)
        CrashReporter.init(this)

        // 第二优先级:异步初始化(不影响首屏)
        thread {
            NetworkClient.init(this)
            ImageLoader.init(this)
            Database.init(this)
        }

        // 第三优先级:延迟到首帧后
        Handler(Looper.getMainLooper()).post {
            // 首帧绘制完成后执行
            Analytics.init(this)
            PushService.init(this)
        }

        // 第四优先级:延迟到空闲时
        Looper.myQueue().addIdleHandler {
            AdSDK.init(this)
            false  // 返回 false,只执行一次
        }
    }
}
ContentProvider 优化
kotlin
// 问题:每个 ContentProvider 都在 Application.onCreate 之前初始化
// 很多第三方 SDK 用 ContentProvider 做自动初始化(如 Firebase、WorkManager)

// 解决:使用 App Startup 合并多个 Provider 为一个
class MyInitializer : Initializer<Unit> {
    override fun create(context: Context) {
        // 在这里初始化
        WorkManager.initialize(context, config)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return emptyList()
    }
}

// AndroidManifest.xml
// 移除第三方 SDK 的 ContentProvider
<provider
    android:name="androidx.work.impl.WorkManagerInitializer"
    android:authorities="${applicationId}.workmanager-init"
    tools:node="remove" />  <!-- 移除自动初始化 -->

// 添加 App Startup 的统一 Provider
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup">
    <meta-data
        android:name="com.example.MyInitializer"
        android:value="androidx.startup" />
</provider>
启动监控指标
kotlin
// TTID(Time To Initial Display):首帧显示时间
// 方法1:adb
// adb shell am start -W com.example/.MainActivity
// TotalTime 就是 TTID

// 方法2:代码埋点
class MyApplication : Application() {
    companion object {
        val startTime = SystemClock.elapsedRealtime()  // 进程创建时间
    }
}

class MainActivity : AppCompatActivity() {
    override fun onResume() {
        super.onResume()
        // 首帧回调
        window.decorView.post {
            val ttid = SystemClock.elapsedRealtime() - MyApplication.startTime
            Log.d("Startup", "TTID: ${ttid}ms")
        }
    }
}

// TTFD(Time To Full Display):完整内容显示时间
class MainActivity : AppCompatActivity() {
    fun onDataLoaded() {
        // 数据加载完成,内容展示后
        reportFullyDrawn()  // 系统 API,会在 Logcat 输出 Fully drawn 时间
    }
}
Systrace / Perfetto 使用
bash
python systrace.py -t 5 -o trace.html \
    sched freq idle am wm gfx view binder_driver hal dalvik input res

adb shell perfetto \
    -c - --txt \
    -o /data/misc/perfetto-traces/trace \
    <<EOF
buffers: { size_kb: 63488 fill_policy: RING_BUFFER }
data_sources: { config { name: "linux.ftrace" ftrace_config {
    ftrace_events: "sched/sched_switch"
    ftrace_events: "power/suspend_resume"
    atrace_categories: "am" atrace_categories: "wm"
    atrace_categories: "view" atrace_categories: "gfx"
    atrace_apps: "com.example.myapp"
}}}
duration_ms: 5000
EOF

在 trace 中关注:

  • bindApplication:Application 初始化耗时
  • activityStart:Activity 启动耗时
  • inflate:布局加载耗时
  • measure/layout/draw:首帧绘制耗时
  • 主线程上的长条方法:耗时操作

1. 冷启动优化做过哪些?怎么衡量效果?

考察点:启动优化实战经验

完整回答

冷启动全链路:Zygote fork → Application 初始化 → Activity 创建 → 首帧绘制。

优化手段:

  1. Application.onCreate:SDK 异步初始化(无依赖的放子线程)、延迟初始化(非必要的延迟到首帧后)、任务编排(DAG 处理依赖关系)
  2. ContentProvider 合并:用 App Startup 合并多个 Provider 为一个
  3. Activity:减少布局层级、ViewStub 延迟加载非首屏内容、异步 inflate
  4. 类加载优化:提前加载热点类、MultiDex 优化
  5. 闪屏页:设置 windowBackground 为品牌图,视觉上"秒开"

衡量指标:

  • TTID:首帧显示时间(adb shell am start -W 的 TotalTime)
  • TTFD:完整内容显示时间(reportFullyDrawn()
  • 使用 Systrace/Perfetto 分析主线程耗时分布

追问:异步初始化怎么处理有依赖关系的 SDK?

用 DAG(有向无环图)编排。每个初始化任务声明依赖的前置任务,框架拓扑排序后并行执行无依赖的任务,有依赖的等前置完成后再执行。类似 Gradle 的 Task 依赖。

2. 内存泄漏怎么排查?常见的泄漏场景?

考察点:内存优化能力

完整回答

排查工具:

  1. LeakCanary:自动检测 Activity/Fragment 泄漏,展示引用链
  2. Android Studio Profiler:dump heap,分析对象引用
  3. MAT(Memory Analyzer Tool):分析 hprof 文件,查找 GC Root 引用链

常见泄漏场景:

  1. Handler:非静态内部类持有 Activity 引用,Message 在队列中等待 → 用静态内部类 + WeakReference + onDestroy 清除消息
  2. 单例持有 Context:传了 Activity Context → 改用 Application Context
  3. 匿名内部类:Runnable、Callback 持有外部 Activity → 用静态类或在 onDestroy 取消
  4. 未注销监听:EventBus、BroadcastReceiver、SensorManager → onDestroy 中注销
  5. WebView:WebView 内部持有 Activity → 独立进程或动态添加/移除
  6. 集合类:只添加不移除 → 及时清理

追问:LeakCanary 的原理?

  1. 注册 ActivityLifecycleCallbacks 监听 onDestroy
  2. 将销毁的 Activity 包装为 WeakReference,关联 ReferenceQueue
  3. 延迟 5 秒检查 ReferenceQueue,如果 WeakReference 没入队说明未被回收
  4. 手动 GC 后再检查,仍未回收则 dump hprof
  5. 使用 Shark 库分析 hprof,找到 GC Root 到泄漏对象的最短引用链

3. ANR 是什么?怎么排查?

考察点:ANR 分析能力

完整回答

ANR(Application Not Responding)是应用在规定时间内没有响应系统事件:

  • 输入事件(触摸/按键):5 秒
  • BroadcastReceiver:前台 10 秒,后台 60 秒
  • Service:前台 20 秒,后台 200 秒

排查步骤:

  1. 获取 ANR 日志:/data/anr/traces.txt 或线上监控平台
  2. 分析主线程堆栈,看主线程在做什么:
    • 死锁waiting to lock <0x...> held by thread X
    • IO 阻塞:文件读写、数据库查询在主线程
    • Binder 调用:跨进程调用对端响应慢
    • CPU 密集:主线程做大量计算
  3. 查看 CPU 使用率:如果 CPU 使用率很高,可能是计算密集;如果很低,可能是锁等待或 IO

预防措施:

  • 主线程不做 IO、网络、数据库操作
  • 使用 StrictMode 检测主线程违规操作
  • BroadcastReceiver 中不做耗时操作(用 goAsync 或转发给 Service/WorkManager)
  • 监控主线程消息处理耗时(Looper printer 或 Choreographer)

追问:线上 ANR 怎么监控?

  1. FileObserver 监听 /data/anr/ 目录变化
  2. 主线程 Watchdog:子线程定期向主线程 post 消息,超时未执行则判定卡顿/ANR
  3. 使用 ANR-WatchDog 库或自建监控

4. 卡顿优化怎么做?怎么监控帧率?

考察点:渲染性能优化

完整回答

卡顿原因:一帧超过 16.6ms(60fps)。

监控方式:

  1. Choreographer.FrameCallback:计算相邻帧的时间差,超过 16.6ms 即掉帧
  2. Looper printer:监控主线程每个 Message 的处理耗时(BlockCanary 原理)
  3. FrameMetrics API(API 24+):精确获取每帧各阶段耗时

优化手段:

  • 布局优化:减少层级(ConstraintLayout)、移除过度绘制(Debug GPU Overdraw)、ViewStub 延迟加载
  • 主线程优化:IO/计算移到子线程、减少主线程锁等待
  • RecyclerView 优化:setHasFixedSize、DiffUtil、预加载、共享 RecycledViewPool
  • 减少内存抖动:避免在 onDraw/onBindViewHolder 中创建对象,复用对象
  • 图片优化:合适的采样率、RGB_565、inBitmap 复用

追问:Systrace 怎么用?

bash
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。

优化手段:

  1. 采样加载inSampleSize 降低分辨率
  2. 合适的像素格式:不需要透明通道用 RGB_565
  3. inBitmap 复用:复用已有 Bitmap 的内存块
  4. 及时回收:不用时 bitmap.recycle()
  5. 放在合适的资源目录:避免不必要的缩放
  6. 使用 Glide/Coil:自动管理生命周期和缓存

6. 包体积优化做过哪些?

考察点:工程化能力

完整回答

  1. 代码压缩:R8/ProGuard 移除无用代码、混淆、优化字节码
  2. 资源压缩shrinkResources true 移除无用资源
  3. 图片优化:PNG → WebP(体积减少 25-35%)、TinyPNG 压缩、矢量图替代小图标
  4. 资源混淆:AndResGuard 将资源名缩短(res/drawable/icon → r/d/a)
  5. so 库:只保留 arm64-v8a(主流架构)、按需加载 so
  6. 动态下发:大资源/功能模块通过 Dynamic Feature 或插件化按需下载
  7. 代码优化:移除无用依赖、避免重复依赖

分析工具: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 次以上

优化手段:

  1. 移除不必要的背景(Activity 默认有 Window 背景,如果布局有自己的背景可以移除 Window 背景)
  2. android:background="@null"window.setBackgroundDrawable(null)
  3. 使用 clipRect 限制绘制区域
  4. 减少布局层级(ConstraintLayout 替代嵌套)
  5. ViewStub 延迟加载不可见的布局

9. 线上 Crash 怎么治理?有什么方法论?

考察点:稳定性治理

完整回答

治理流程:

  1. 监控:接入 Crash 监控 SDK(Firebase Crashlytics、Bugly),收集崩溃堆栈、设备信息、用户操作路径

  2. 分类:按影响面排序

    • Top Crash:影响用户数最多的崩溃优先修复
    • 按模块/页面分类,找到问题集中的区域
  3. 分析

    • Java Crash:直接看堆栈定位代码
    • Native Crash:addr2line 还原符号,分析 tombstone
    • ANR:分析 traces.txt 主线程堆栈
  4. 修复

    • 紧急问题:热修复(Tinker/Sophix)
    • 常规问题:下个版本修复
  5. 预防

    • 代码规范 + Lint 检查
    • 单元测试覆盖核心逻辑
    • 灰度发布(先 1% → 10% → 全量)
    • 质量门禁(Crash 率超标则阻止发版)

追问:Crash 率怎么计算?业界标准是多少?

Crash 率 = 崩溃用户数 / DAU × 100%。业界标准:Crash Rate < 0.1%(千分之一),ANR Rate < 0.3%。

10. 网络优化做过哪些?

考察点:网络性能

完整回答

  1. 连接优化

    • HTTP/2 多路复用(一个连接并行多个请求)
    • 连接池复用(OkHttp 默认 5 个空闲连接)
    • DNS 预解析 + 缓存(避免 DNS 查询耗时)
  2. 数据优化

    • Protobuf 替代 JSON(体积减少 60-90%)
    • gzip 压缩请求/响应体
    • 增量更新(只传变化的数据)
  3. 缓存优化

    • HTTP 缓存(Cache-Control)
    • 本地缓存(先展示缓存,后台更新)
    • 预加载(提前请求下一页数据)
  4. 弱网优化

    • 超时时间自适应(WiFi 短,移动网络长)
    • 请求优先级(核心请求优先)
    • 失败重试(指数退避)
    • 降级策略(弱网下降低图片质量)
  5. 监控

    • 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 中创建对象。

系统原理 / Binder / 启动流程

Android 系统架构

┌─────────────────────────────────┐
│         应用层 (App Layer)        │  各种 App
├─────────────────────────────────┤
│     Framework 层 (Java API)      │  AMS, WMS, PMS, ContentProvider
├─────────────────────────────────┤
│   Native 层 (C/C++ Libraries)    │  SurfaceFlinger, MediaServer, Binder Driver
├─────────────────────────────────┤
│        HAL (硬件抽象层)           │  Camera HAL, Audio HAL
├─────────────────────────────────┤
│       Linux Kernel              │  Binder 驱动, 进程管理, 内存管理
└─────────────────────────────────┘

Binder 机制

为什么用 Binder?

Linux 已有的 IPC 方式:管道、Socket、共享内存、信号量。

Binder 的优势:

  • 性能:只需一次数据拷贝(mmap),Socket 需要两次,共享内存零拷贝但同步复杂
  • 安全:内核层面验证调用方的 UID/PID,不可伪造
  • 易用:C/S 架构,面向对象的调用方式
一次拷贝原理(mmap)
发送进程用户空间 → 内核缓冲区(copy_from_user,一次拷贝)
                    ↕ mmap 映射
              接收进程用户空间(直接访问,零拷贝)

传统 IPC(如 Socket):发送方 → 内核 → 接收方(两次拷贝)。 Binder:接收方的用户空间和内核缓冲区通过 mmap 映射到同一块物理内存,所以数据从发送方拷贝到内核后,接收方可以直接访问,只需一次拷贝。

AIDL 生成代码分析
java
// IMyService.aidl
interface IMyService {
    String getData(int id);
}

编译后生成:

java
public interface IMyService extends IInterface {
    // Stub:服务端实现(Binder 本地对象)
    public static abstract class Stub extends Binder implements IMyService {
        private static final String DESCRIPTOR = "com.example.IMyService";
        static final int TRANSACTION_getData = IBinder.FIRST_CALL_TRANSACTION + 0;

        public Stub() { attachInterface(this, DESCRIPTOR); }

        // 关键:判断是否同进程
        public static IMyService asInterface(IBinder obj) {
            IInterface iin = obj.queryLocalInterface(DESCRIPTOR);
            if (iin != null && iin instanceof IMyService) {
                return (IMyService) iin;  // 同进程,直接返回 Stub
            }
            return new Proxy(obj);         // 跨进程,返回 Proxy
        }

        @Override
        public boolean onTransact(int code, Parcel data, Parcel reply, int flags) {
            switch (code) {
                case TRANSACTION_getData:
                    data.enforceInterface(DESCRIPTOR);
                    int id = data.readInt();
                    String result = this.getData(id);  // 调用真正的实现
                    reply.writeString(result);
                    return true;
            }
            return super.onTransact(code, data, reply, flags);
        }

        // Proxy:客户端代理(Binder 代理对象)
        private static class Proxy implements IMyService {
            private IBinder mRemote;

            @Override
            public String getData(int id) throws RemoteException {
                Parcel data = Parcel.obtain();
                Parcel reply = Parcel.obtain();
                try {
                    data.writeInterfaceToken(DESCRIPTOR);
                    data.writeInt(id);
                    mRemote.transact(TRANSACTION_getData, data, reply, 0); // 跨进程调用
                    reply.readException();
                    return reply.readString();
                } finally {
                    data.recycle();
                    reply.recycle();
                }
            }
        }
    }
}

调用流程:客户端 Proxy.getData() → transact() → Binder 驱动 → 服务端 Stub.onTransact() → getData() 实现

ServiceManager

ServiceManager 是 Binder 的"DNS",管理所有系统服务的注册和查找:

java
// 注册服务
ServiceManager.addService("activity", activityManagerService);

// 查找服务
IBinder binder = ServiceManager.getService("activity");
IActivityManager am = IActivityManager.Stub.asInterface(binder);

ServiceManager 自身的 Binder 引用(handle=0)是所有进程都知道的,所以可以作为入口查找其他服务。

App 启动流程

完整流程
1. Launcher 调用 startActivity
2. 通过 Binder 调用 AMS.startActivity
3. AMS 检查权限、解析 Intent、确定目标 Activity
4. AMS 发现目标进程不存在,通过 Socket 通知 Zygote fork 新进程
5. Zygote fork 子进程
6. 子进程执行 ActivityThread.main()
7. ActivityThread 创建 Looper、Handler
8. 通过 Binder 调用 AMS.attachApplication,告知 AMS 进程已就绪
9. AMS 通过 Binder 回调 ApplicationThread.bindApplication
10. ActivityThread.handleBindApplication:
    - 创建 Application 对象
    - 创建 ContentProvider 并调用 onCreate
    - 调用 Application.onCreate
11. AMS 通过 Binder 回调 ApplicationThread.scheduleLaunchActivity
12. ActivityThread.handleLaunchActivity:
    - 创建 Activity 对象
    - 调用 Activity.attach(创建 PhoneWindow)
    - 调用 Activity.onCreate
    - 调用 Activity.onStart、onResume
13. Activity.onResume 后创建 ViewRootImpl
14. ViewRootImpl.performTraversals 执行首帧绘制
Zygote

Zygote 是所有 App 进程的父进程:

  • 预加载了常用类和资源(Framework 类、系统资源)
  • fork 时子进程直接继承这些预加载内容(COW,Copy-On-Write)
  • 通过 Socket(不是 Binder)接收 fork 请求

为什么用 Socket 不用 Binder?Binder 是多线程的,fork 多线程进程会导致死锁(子进程只有一个线程,但锁状态被复制了)。

Activity 启动流程

App 进程                          system_server 进程
startActivity()
  → Instrumentation.execStartActivity
    → AMS.startActivity (Binder)  ──→  AMS.startActivityAsUser
                                        → ActivityStarter.execute
                                        → 解析 Intent、检查权限
                                        → 确定 Task 和启动模式
                                        → 暂停当前 Activity
                                  ←──  ApplicationThread.schedulePauseActivity
  handlePauseActivity
    → Activity.onPause
    → AMS.activityPaused (Binder) ──→  AMS 继续启动目标 Activity
                                  ←──  ApplicationThread.scheduleLaunchActivity
  handleLaunchActivity
    → Activity.onCreate/onStart/onResume

多进程

使用场景
  • WebView 独立进程(避免内存泄漏影响主进程)
  • 推送 Service 独立进程(保活)
  • 大内存操作独立进程(图片处理、视频编辑)
多进程问题
  • 静态变量不共享(每个进程有独立的虚拟机)
  • SharedPreferences 不可靠(文件锁不够)
  • Application 会创建多次(每个进程一次)
  • 单例失效
跨进程通信方式
方式特点
Binder (AIDL)性能好,支持双向通信
ContentProvider适合数据共享
Messenger基于 Handler,串行处理
Socket灵活但开销大
文件/MMKV简单数据共享

WMS(WindowManagerService)

职责

WMS 管理所有 Window 的:

  • 添加、删除、更新
  • Z-order 排序(决定哪个 Window 在上面)
  • 输入事件分发(确定事件发给哪个 Window)
  • 窗口动画
  • Surface 管理
Window 添加流程
WindowManager.addView(view, params)
  → WindowManagerGlobal.addView
    → 创建 ViewRootImpl
    → ViewRootImpl.setView
      → IWindowSession.addToDisplay (Binder)
        → WMS.addWindow
          → 创建 WindowState
          → 分配 Surface
与 AMS 的关系
  • AMS 管理 Activity 的生命周期和 Task 栈
  • WMS 管理 Activity 对应的 Window 的显示
  • Activity 启动时,AMS 通知 WMS 创建 Window;Activity 销毁时,AMS 通知 WMS 移除 Window

SurfaceFlinger

职责

SurfaceFlinger 是 Android 的合成器(Compositor),负责将多个 Surface(Window)合成为最终的屏幕画面。

渲染流程
App 进程                    SurfaceFlinger              显示器
┌──────────┐              ┌──────────────┐           ┌──────┐
│ 绘制到    │  BufferQueue │ 合成所有      │           │      │
│ Surface   │ ──────────→ │ Surface 层    │ ────────→ │ 显示  │
│ (GPU渲染) │              │ (HWC/GPU合成) │           │      │
└──────────┘              └──────────────┘           └──────┘
  • 每个 Window 有自己的 Surface 和 BufferQueue
  • App 通过 BufferQueue 的生产者端提交帧
  • SurfaceFlinger 通过消费者端获取帧
  • 使用 HWC(Hardware Composer)或 GPU 合成所有层
  • VSYNC 信号同步:App 在 VSYNC 时开始绘制,SurfaceFlinger 在下一个 VSYNC 时合成
三重缓冲
Buffer A: App 正在绘制
Buffer B: SurfaceFlinger 正在合成
Buffer C: 显示器正在显示

三重缓冲避免了 App 和 SurfaceFlinger 互相等待,减少掉帧。

PMS 与权限系统

PackageManagerService

PMS 管理所有已安装应用的信息:

  • 解析 AndroidManifest.xml
  • 管理权限声明和授予
  • 处理 Intent 解析(查找匹配的 Activity/Service/Receiver)
  • 管理应用安装/卸载/更新
运行时权限(Android 6.0+)
kotlin
// 检查权限
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
    != PackageManager.PERMISSION_GRANTED) {

    // 是否需要解释
    if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
        // 显示解释对话框
    }

    // 请求权限(推荐使用 ActivityResult API)
    requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}

权限分类:

  • 普通权限:安装时自动授予(INTERNET、VIBRATE)
  • 危险权限:运行时请求(CAMERA、LOCATION、STORAGE)
  • 特殊权限:需要跳转设置页(SYSTEM_ALERT_WINDOW、WRITE_SETTINGS)

权限组:同一组内授予一个权限后,组内其他权限自动授予(Android 11+ 不再自动授予)。

Binder 驱动层原理

Binder 通信模型
用户空间                          内核空间                        用户空间
┌──────────┐                  ┌──────────────┐              ┌──────────┐
│ Client   │  ① transact     │              │              │ Server   │
│          │ ──────────────→  │              │              │          │
│ Proxy    │  copy_from_user  │ Binder 驱动   │  ② 唤醒      │ Stub     │
│          │                  │              │ ──────────→  │          │
│          │                  │ 内核缓冲区    │              │ onTransact│
│          │  ④ 返回          │ (mmap 映射到  │  ③ 直接读取   │          │
│          │ ←──────────────  │  Server 用户  │ ←──────────  │          │
│          │  copy_to_user    │  空间)        │  无需拷贝!    │          │
└──────────┘                  └──────────────┘              └──────────┘

关键:Server 进程通过 mmap 将内核缓冲区映射到自己的用户空间,所以 Server 读取数据时不需要 copy_to_user,实现了"一次拷贝"。

mmap 一次拷贝详解
c
// binder_mmap() - Binder 驱动中的 mmap 实现
static int binder_mmap(struct file *filp, struct vm_area_struct *vma) {
    struct binder_proc *proc = filp->private_data;

    // 1. 在内核虚拟地址空间分配一块区域
    area = get_vm_area(vma->vm_end - vma->vm_start, VM_IOREMAP);
    proc->buffer = area->addr;

    // 2. 分配物理页面
    page = alloc_page(GFP_KERNEL);

    // 3. 将物理页面同时映射到:
    //    a) 内核虚拟地址空间(Binder 驱动可以访问)
    //    b) 用户虚拟地址空间(Server 进程可以访问)
    map_kernel_range_noflush(...)  // 映射到内核
    vm_insert_page(vma, ...)       // 映射到用户空间

    // 结果:内核缓冲区和 Server 用户空间指向同一块物理内存
}

数据传输过程:

1. Client 调用 transact(),数据在 Client 用户空间
2. Binder 驱动调用 copy_from_user(),将数据拷贝到内核缓冲区 ← 唯一的一次拷贝
3. 由于 mmap,Server 用户空间直接映射到这块内核缓冲区
4. Server 直接读取数据,无需再次拷贝
Binder 线程池
Server 进程
├── 主线程(UI 线程)
├── Binder 线程 1  ← 处理 Client A 的请求
├── Binder 线程 2  ← 处理 Client B 的请求
├── ...
└── Binder 线程 N  ← 最多 15 个(默认)

// ProcessState.cpp
#define DEFAULT_MAX_BINDER_THREADS 15

// 线程池启动
void ProcessState::startThreadPool() {
    spawnPooledThread(true);  // 创建第一个 Binder 线程
}

void IPCThreadState::joinThreadPool(bool isMain) {
    do {
        // 循环读取 Binder 驱动的命令
        result = getAndExecuteCommand();

        // 如果线程不够用,驱动会通知创建新线程
        // BR_SPAWN_LOOPER → spawnPooledThread(false)
    } while (result != -ECONNREFUSED && result != -EBADF);
}
AIDL 深入

oneway 关键字

aidl
// 普通调用:同步阻塞,Client 等待 Server 返回
interface IMyService {
    String getData();  // Client 线程阻塞直到 Server 返回
}

// oneway 调用:异步非阻塞,Client 不等待
oneway interface IMyCallback {
    void onResult(String data);  // Client 发完就返回,不等 Server 处理
}

oneway 的限制:

  • 不能有返回值(void)
  • 不能有 out/inout 参数
  • 异常不会传回 Client

in/out/inout 参数

aidl
interface IMyService {
    // in:Client → Server(默认,只传过去)
    void sendData(in MyParcelable data);

    // out:Server → Client(传过去的是空对象,Server 填充后传回)
    void receiveData(out MyParcelable data);

    // inout:双向传递(Client 传过去,Server 修改后传回)
    void processData(inout MyParcelable data);
}

Binder 死亡通知

kotlin
// 监听 Server 进程死亡
val deathRecipient = IBinder.DeathRecipient {
    // Server 进程死了,在这里重连
    reconnectService()
}

// 注册死亡通知
serviceBinder.linkToDeath(deathRecipient, 0)

// 取消注册
serviceBinder.unlinkToDeath(deathRecipient, 0)

原理:Binder 驱动维护了每个 Binder 引用的死亡通知列表。当 Server 进程退出时,驱动遍历列表,向所有注册了 DeathRecipient 的 Client 发送 BR_DEAD_BINDER 命令。

ServiceManager

ServiceManager 是 Binder 的"DNS",管理所有系统服务的注册和查找:

注册服务:
  SystemServer 启动时
    → AMS.addService("activity", ams)
    → WMS.addService("window", wms)
    → PMS.addService("package", pms)
    → ... 通过 Binder 调用 ServiceManager.addService()

查找服务:
  App 进程
    → ServiceManager.getService("activity")
    → 返回 AMS 的 Binder 代理对象
    → App 通过代理对象调用 AMS 的方法

ServiceManager 自身的 Binder 引用是固定的(handle = 0),所有进程都知道怎么找到它。

App 启动流程源码级分析

Zygote fork 过程
java
// ZygoteInit.java
public static void main(String[] argv) {
    // 1. 预加载资源(所有 App 共享)
    preload();  // 加载 Framework 类、资源、OpenGL 等

    // 2. 启动 SystemServer
    if (startSystemServer) {
        Runnable r = forkSystemServer();
        if (r != null) r.run();  // 在 SystemServer 进程中执行
    }

    // 3. 进入循环,等待 AMS 的 fork 请求
    caller = zygoteServer.runSelectLoop(abiList);
}

// ZygoteConnection.java
Runnable processOneCommand() {
    // 读取 AMS 发来的参数
    Arguments args = readArgumentList();

    // fork 子进程
    pid = Zygote.forkAndSpecialize(uid, gid, ...);

    if (pid == 0) {
        // 子进程
        return handleChildProc(args);  // → ActivityThread.main()
    } else {
        // Zygote 进程
        handleParentProc(pid);
        return null;
    }
}
ActivityThread.main()
java
// ActivityThread.java
public static void main(String[] args) {
    // 1. 准备主线程 Looper
    Looper.prepareMainLooper();

    // 2. 创建 ActivityThread 实例
    ActivityThread thread = new ActivityThread();

    // 3. 向 AMS 注册(Binder 调用)
    thread.attach(false);
    // attach 内部:
    //   IActivityManager mgr = ActivityManager.getService();
    //   mgr.attachApplication(mAppThread);  // mAppThread 是 ApplicationThread(Binder 服务端)

    // 4. 获取主线程 Handler
    if (sMainThreadHandler == null) {
        sMainThreadHandler = thread.getHandler();  // H 类,处理所有生命周期回调
    }

    // 5. 开始消息循环
    Looper.loop();  // 永不返回

    throw new RuntimeException("Main thread loop unexpectedly exited");
}
Application 创建过程
java
// AMS 收到 attachApplication 后回调
// ActivityThread.handleBindApplication()
private void handleBindApplication(AppBindData data) {
    // 1. 创建 Application 的 Context
    ContextImpl appContext = ContextImpl.createAppContext(this, data.info);

    // 2. 创建 Instrumentation
    mInstrumentation = new Instrumentation();

    // 3. 创建 Application
    Application app = data.info.makeApplication(false, mInstrumentation);
    // makeApplication 内部:
    //   app = mActivityThread.mInstrumentation.newApplication(cl, className, appContext);
    //   app.attach(context);  // → attachBaseContext()

    // 4. 初始化所有 ContentProvider
    installContentProviders(app, data.providers);
    // 遍历所有 Provider,依次调用 provider.attachInfo() → onCreate()

    // 5. 调用 Application.onCreate()
    mInstrumentation.callApplicationOnCreate(app);
}

注意顺序:attachBaseContext()ContentProvider.onCreate()Application.onCreate()

这就是为什么在 attachBaseContext 中不能使用 ContentProvider,而很多 SDK 利用 ContentProvider 做自动初始化(在 Application.onCreate 之前就执行了)。

显示系统全景

从代码到屏幕的完整链路
你写的代码                    Framework                    系统服务                  硬件
─────────                   ──────────                   ──────────               ──────
canvas.bindrawRect()
View.onDraw()
  → RenderThread           → Surface                   → SurfaceFlinger         → 显示器
    (Skia/HWUI)             (BufferQueue)               (合成器)                  (屏幕)
    绘制到 Buffer             传递 Buffer                  合成所有 Layer            显示

一个屏幕上同时有很多东西:状态栏、导航栏、App 窗口、可能还有悬浮窗、Dialog。每个都是独立的 Surface,各自绘制。SurfaceFlinger 的工作就是把这些 Surface 叠在一起,合成最终的一帧画面,送给屏幕显示。

核心概念一览
Surface        = App 拿到的绘制入口(BufferQueue 生产者端的包装)
Buffer         = 实际的像素数据(共享内存,跨进程零拷贝)
BufferQueue    = App 和 SurfaceFlinger 之间的传送带(2-3 个 Buffer 循环使用)
Layer          = SurfaceFlinger 眼中的图层(BufferQueue 消费者端 + 合成信息)
Canvas         = 绘制指令接口(提供 bindrawRect/bindrawText 等方法)
SurfaceFlinger = 合成器 + 显示流水线总控
HWC            = 硬件合成器(显示控制器,直接驱动屏幕)
Choreographer  = App 端的帧调度器(按 VSYNC 节奏调度绘制)

Surface、Buffer、BufferQueue、Layer 的关系

四者的对应关系
App 端                    中间                SurfaceFlinger 端
──────                   ──────              ──────────────────
Surface ──→ Producer ──→ BufferQueue ──→ Consumer ──→ Layer
                         [Buffer A]
                         [Buffer B]
                         [Buffer C]

Surface 和 BufferQueue 是一对一:一个 Surface 对应一个 BufferQueue
BufferQueue 和 Buffer 是一对多:一个 BufferQueue 里有 2-3 个 Buffer
Layer 和 BufferQueue 是一对一:一个 Layer 持有一个 BufferQueue 的消费者端
Surface 和 Layer 是同一个东西的两端:App 端叫 Surface,SurfaceFlinger 端叫 Layer
Surface:App 的绘制入口

Surface 本身不存储像素数据,它只是 BufferQueue 生产者端的一个句柄:

java
class Surface {
    IGraphicBufferProducer mProducer;  // 核心字段,BufferQueue 的生产者接口

    // 软件绘制用
    public Canvas lockCanvas(Rect dirty) {
        // 1. dequeueBuffer() 从 BufferQueue 取一个空闲 Buffer
        // 2. 把 Buffer 的内存地址包装成 Canvas
        // 3. 返回 Canvas
    }

    public void unlockCanvasAndPost(Canvas canvas) {
        // 1. 解锁 Buffer
        // 2. queueBuffer() 提交到 BufferQueue
    }
}

一个 Activity 至少有一个 Window,一个 Window 有一个 Surface:

Activity
  └── PhoneWindow(Window 的实现类)
        └── DecorView(根 View)
              └── ViewRootImpl
                    └── Surface

什么时候会有多个 Surface?
  Activity 本身的窗口    → Surface 1
  弹出一个 Dialog        → Surface 2(独立窗口)
  SurfaceView            → Surface 3(独立于 View 树)
Canvas:绘制指令接口

Canvas 提供所有绘制方法,但它本身不存储像素,需要一个目标:

kotlin
// 情况 1:画到 Bitmap(内存中的像素数组,和 Surface 无关)
val bitmap = Bitmap.createBitmap(100, 100, ARGB_8888)
val canvas = Canvas(bitmap)
canvas.bindrawRect(...)  // 像素写入 bitmap 的内存

// 情况 2:画到 Surface 的 Buffer(屏幕显示用)
val canvas = surface.lockCanvas(dirty)
canvas.bindrawRect(...)  // 像素写入 Surface 背后的 GraphicBuffer
surface.unlockCanvasAndPost(canvas)

// 情况 3:画到 DisplayList(硬件加速,只记录命令不执行)
// View.onDraw(canvas) 中的 canvas 实际是 RecordingCanvas
canvas.bindrawRect(...)  // 不画像素,只记录命令
// 后续由 RenderThread 用 GPU 执行

Surface 和 Canvas 的关系:

Surface 管"画布的生命周期":取出 Buffer、提交 Buffer
Canvas  管"怎么画":bindrawRect、bindrawText、bindrawBitmap

Surface 提供 Buffer → Canvas 往 Buffer 里画 → Surface 提交 Buffer
Buffer(GraphicBuffer):实际的像素数据
Buffer 是一块共享内存:
1080 × 1920 × 4 字节 = 8.3MB 的连续内存

通过 gralloc HAL 分配,跨进程共享:

App 进程                    SurfaceFlinger 进程
    │                            │
    │    同一块物理内存            │
    └──→ [像素数据] ←────────────┘
         8.3MB

App 写入像素 → SurfaceFlinger 直接读取 → 零拷贝
BufferQueue:生产者-消费者模型
App 进程(生产者)                          SurfaceFlinger(消费者)
┌──────────────┐                          ┌──────────────┐
│              │   dequeueBuffer          │              │
│  RenderThread │ ←──────────────────────  │              │
│              │   (取一个空 Buffer)       │              │
│              │                          │              │
│  绘制内容到   │                          │              │
│  Buffer 上    │                          │              │
│              │                          │              │
│              │   queueBuffer            │              │
│              │ ──────────────────────→  │  acquireBuffer│
│              │   (提交填好的 Buffer)     │  (取出来合成)│
│              │                          │              │
│              │                          │  releaseBuffer│
│              │                          │  (用完归还)  │
└──────────────┘                          └──────────────┘

Buffer 的状态流转:

FREE        App 可以取走绘制
  ↓ dequeueBuffer(App 取走)
DEQUEUED    App 正在绘制
  ↓ queueBuffer(App 提交)
QUEUED      等待 SurfaceFlinger 取走
  ↓ acquireBuffer(SurfaceFlinger 取走)
ACQUIRED    SurfaceFlinger 正在合成
  ↓ releaseBuffer(SurfaceFlinger 用完)
FREE        回到起点
Layer:SurfaceFlinger 端的图层

App 看到的是 Surface,SurfaceFlinger 看到的是 Layer。同一个东西的两面:

Layer {
    BufferQueue consumer;     // 从这里取 Buffer
    Rect bounds;              // 在屏幕上的位置和大小
    int zOrder;               // 叠放顺序
    float alpha;              // 透明度
    Matrix transform;         // 变换(旋转、缩放)
    Region visibleRegion;     // 可见区域(被遮挡的部分不需要合成)
    int compositionType;      // HWC 合成还是 GPU 合成
}

屏幕上的实际场景:

三个窗口 = 三套完整链路:

状态栏(SystemUI 进程):
  Surface₁ → BufferQueue₁ [B₁ B₂ B₃] → Layer₁ (z=3, 顶部48dp)

App 窗口(你的 App 进程):
  Surface₂ → BufferQueue₂ [B₄ B₅ B₆] → Layer₂ (z=2, 中间区域)

导航栏(SystemUI 进程):
  Surface₃ → BufferQueue₃ [B₇ B₈ B₉] → Layer₃ (z=1, 底部48dp)
Surface 的创建过程
1. App → WMS:"我要创建一个窗口"
   → WMS 记录窗口信息(位置、大小、Z 轴)

2. WMS → SurfaceFlinger:"创建一个新的 Layer"
   → SurfaceFlinger 创建 Layer + BufferQueue
   → 返回 BufferQueue 的 Producer 端

3. WMS → App:把 Producer 传回
   → App 用它创建 Surface 对象
   → ViewRootImpl 持有这个 Surface
   → App 可以通过 Surface 绘制了

VSYNC 与 Choreographer

VSYNC 信号的来源

硬件 VSYNC 由显示控制器(HWC)产生,SurfaceFlinger 接收后通过 DispSync 分发:

显示控制器(HWC)
  │ 每 16.6ms 产生一次硬件 VSYNC 中断

HWC HAL
  │ 通过回调通知

SurfaceFlinger
  │ 交给 DispSync(软件 PLL,平滑抖动)

DispSync
  ├── 加偏移 1ms → VSYNC-APP → Choreographer(通知 App 开始绘制)
  └── 加偏移 5ms → VSYNC-SF  → SurfaceFlinger 合成线程(通知开始合成)
为什么要分成 VSYNC-APP 和 VSYNC-SF
❌ 没有偏移(同时开始):
  App 和 SurfaceFlinger 同时工作
  SF 开始合成时 App 还没画完,没有新 Buffer → 白忙

✅ 有偏移(错开):
  VSYNC-APP 先到 → App 先画
  VSYNC-SF 后到  → SF 后合成(此时 App 大概率已经画完了)
一个 VSYNC 周期内的时间关系:

0ms        1ms              5ms                    16.6ms
|          |                |                      |
硬件VSYNC  VSYNC-APP        VSYNC-SF               下一个硬件VSYNC
           |                |
           ▼                ▼
     Choreographer    SurfaceFlinger
     通知 App 开始画   通知 SF 开始合成

DispSync 还有省电优化:屏幕静止时关闭 VSYNC 接收,有人需要绘制时再重新开启。

Choreographer 的工作机制

Choreographer 是每个 App 主线程的帧调度器,在 VSYNC-APP 到来时按顺序执行这一帧的所有工作:

java
public final class Choreographer {
    // 五种回调队列,按优先级排列
    // [0] CALLBACK_INPUT       → 处理输入事件
    // [1] CALLBACK_ANIMATION   → 属性动画
    // [2] CALLBACK_INSETS_ANIMATION → 窗口动画
    // [3] CALLBACK_TRAVERSAL   → View 的 measure/layout/draw
    // [4] CALLBACK_COMMIT      → 帧提交后的收尾

    private final FrameDisplayEventReceiver mDisplayEventReceiver;
}

注册 VSYNC 回调

当 View 调用 invalidate() 时,最终走到 scheduleTraversals()

java
// ViewRootImpl
void scheduleTraversals() {
    if (!mTraversalScheduled) {
        mTraversalScheduled = true;

        // 1. 插入同步屏障(让异步消息优先)
        mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();

        // 2. 向 Choreographer 注册回调
        mChoreographer.postCallback(
            Choreographer.CALLBACK_TRAVERSAL,
            mTraversalRunnable,    // VSYNC 来了就执行这个
            null);
    }
}

Choreographer 收到注册后,向 SurfaceFlinger 请求下一个 VSYNC-APP:

java
private void scheduleFrameLocked(long now) {
    if (!mFrameScheduled) {
        mFrameScheduled = true;
        mDisplayEventReceiver.scheduleVsync();
        // native 调用,注册到 SurfaceFlinger 的 DispSync
    }
}

注意:Choreographer 不是一直监听 VSYNC 的。只有当有回调需要执行时才注册,界面静止时不会收到 VSYNC,省电。

VSYNC-APP 到来时

java
// FrameDisplayEventReceiver 收到 VSYNC 信号
public void onVsync(long timestampNanos, ...) {
    // 发送异步消息到主线程(能穿过同步屏障)
    Message msg = Message.obtain(mHandler, this);
    msg.setAsynchronous(true);
    mHandler.sendMessageAtTime(msg, ...);
}

// 异步消息被处理时
void doFrame(long frameTimeNanos, int frame) {
    // 检查是否掉帧
    long skippedFrames = jitter / mFrameIntervalNanos;
    if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
        Log.i(TAG, "Skipped " + skippedFrames + " frames! "
            + "The application may be doing too much work on its main thread.");
    }

    // 按顺序执行五种回调
    doCallbacks(CALLBACK_INPUT, frameTimeNanos);      // 1. 输入事件
    doCallbacks(CALLBACK_ANIMATION, frameTimeNanos);   // 2. 动画
    doCallbacks(CALLBACK_INSETS_ANIMATION, ...);       // 3. 窗口动画
    doCallbacks(CALLBACK_TRAVERSAL, frameTimeNanos);   // 4. View 绘制
    doCallbacks(CALLBACK_COMMIT, frameTimeNanos);      // 5. 提交
}

为什么按这个顺序

1. INPUT 先执行 → 触摸事件先处理,可能触发 View 状态变化
2. ANIMATION 其次 → 计算动画值,更新 View 属性
3. TRAVERSAL 最后 → 此时所有状态变化都已发生,统一绘制一次
→ 一帧内先处理所有会导致 UI 变化的事件,最后统一绘制

绘制流程:软件绘制 vs 硬件加速

软件绘制

全部在主线程完成,没有 RenderThread 参与:

java
// ViewRootImpl.drawSoftware()
Canvas canvas = mSurface.lockCanvas(dirty);
// dequeueBuffer() 从 BufferQueue 取 Buffer
// 把 Buffer 内存包装成 Canvas

mView.bindraw(canvas);
// 从根 View 递归调用每个 View 的 onDraw(canvas)
// CPU 逐像素绘制(Skia 软件渲染)
// 直接写入 Buffer 的像素数据

surface.unlockCanvasAndPost(canvas);
// queueBuffer() 提交到 BufferQueue
时间线:
  VSYNC-APP
  |[measure][layout][lock][CPU绘制所有像素──────────][unlock+post]|
  |←──────────── 全部在主线程,全部用 CPU ──────────────────────→|
  → 主线程被绘制阶段长时间占用,期间不能处理触摸、动画
硬件加速

主线程只记录绘制命令,RenderThread 用 GPU 执行:

主线程:
  View.bindraw(canvas)
  → canvas 实际是 RecordingCanvas
  → 不真正绘制,只记录命令到 DisplayList

  syncFrameState()
  → 把 DisplayList 同步给 RenderThread
  → 主线程空闲了,可以处理其他消息

RenderThread:
  → 遍历 DisplayList
  → 转换为 GPU 指令(OpenGL/Vulkan)
  → GPU 执行渲染,写入 GraphicBuffer
  → eglSwapBuffers() → queueBuffer() 提交到 BufferQueue
时间线:
  主线程:     [measure][layout][记录DisplayList][同步] → 空闲!
  RenderThread:                                [GPU渲染────][提交]
  → 主线程提前释放,可以处理其他消息
  → 主线程和 RenderThread 可以并行
硬件加速的问题

不是所有操作都支持

kotlin
canvas.bindrawPicture(picture)        // 不支持
paint.setMaskFilter(BlurMaskFilter()) // 部分支持
// 解决:对单个 View 关闭硬件加速
view.setLayerType(View.LAYER_TYPE_SOFTWARE, null)

内存占用更大:DisplayList + GPU 纹理缓存 + 离屏缓冲,复杂页面可能额外占用 20-50MB。

离屏渲染

kotlin
view.alpha = 0.5f  // 半透明:需要先画完整个 View 到临时 Buffer,再整体设置 alpha
// 优化:用 RenderNode 属性代替
view.translationX = 100f  // ✅ 不触发离屏
view.scaleX = 1.5f        // ✅ 不触发离屏
view.rotation = 45f       // ✅ 不触发离屏

SurfaceFlinger 合成

两种合成方式

HWC 硬件合成(优先,省电)

显示控制器有多个叠加层(overlay plane)
每个 Layer 分配一个叠加层,硬件直接混合输出

Layer 0 (导航栏) ──→ Overlay Plane 0 ──┐
Layer 1 (App)    ──→ Overlay Plane 1 ──┼──→ 显示器(硬件实时混合)
Layer 2 (状态栏) ──→ Overlay Plane 2 ──┘

优点:省电,不占 GPU
限制:叠加层数量有限(通常 4-8 个)

GPU 合成(兜底方案)

Layer 太多或需要复杂效果时:
SurfaceFlinger 用 GPU 把多个 Layer 画到一个 Buffer
再把这个 Buffer 交给 HWC 显示

实际策略:混合合成

SurfaceFlinger 每帧询问 HWC 每个 Layer 的处理方式:
  状态栏:   OVERLAY(简单矩形,HWC 处理)
  App 窗口: OVERLAY(简单矩形,HWC 处理)
  圆角弹窗: CLIENT(需要 alpha 混合,GPU 处理)
  导航栏:   OVERLAY
SurfaceFlinger 的合成流程
cpp
void SurfaceFlinger::onMessageInvalidate() {
    // 1. 从每个 Layer 的 BufferQueue 取最新 Buffer
    for (Layer* layer : layers) {
        if (layer->hasNewBuffer()) {
            layer->acquireBuffer();
        }
    }

    // 2. 计算可见区域、遮挡关系
    computeVisibleRegions();

    // 3. 询问 HWC 合成策略
    hwc.prepare(layers);

    // 4. GPU 合成 CLIENT 类型的 Layer
    for (Layer* layer : clientLayers) {
        renderEngine->bindraw(layer);
    }

    // 5. 提交给 HWC 输出到显示器
    hwc.commit();

    // 6. 释放用完的 Buffer(归还给 BufferQueue)
    for (Layer* layer : layers) {
        layer->releaseBuffer();
    }
}

双缓冲与三缓冲

双缓冲的问题
VSYNC:  V0          V1          V2          V3
App:    [画帧1──────────超时了──]
                                [画帧2────]
SF:                 [没有新帧!]  [合成帧1──]
Display:            [重复旧帧──]  [重复旧帧──][显示帧1]

V1 时刻:
  Buffer A: App 还在画(DEQUEUED)
  Buffer B: 显示器在用
  → 两个 Buffer 都被占着,App 画完帧 1 后没有空闲 Buffer
  → 必须等 V2 才能开始画帧 2 → 连续掉两帧
三缓冲的解决
VSYNC:  V0          V1          V2          V3          V4
App:    [画帧1──────────超时了──]
                    [画帧2────]  [画帧3────]
                    ↑ 用 Buffer C,不用等!
SF:                 [没有新帧]   [合成帧1──][合成帧2──]
Display:            [重复旧帧──][显示帧1──][显示帧2──]
                     ↑ 只掉一帧    ↑ 恢复了

V1 时刻:
  Buffer A: App 还在画帧 1
  Buffer B: 显示器在用
  Buffer C: 空闲!App 可以立即开始画帧 2
  → 只掉一帧就恢复,不会连续掉帧

三缓冲的代价:多一帧延迟(约多 16ms),换来更少的连续掉帧。

稳定状态下三个 Buffer 的分布
Buffer A: DEQUEUED(App 在画下一帧)
Buffer B: ACQUIRED(SurfaceFlinger 在合成)
Buffer C: 显示中(通过 HWC 扫描输出)
→ 三个阶段完全并行,流水线满载

SurfaceView 与 TextureView

普通 View

Activity 的所有普通 View 共享同一个 Surface,画在同一个 Buffer 上。

SurfaceView
SurfaceView 有自己独立的 Surface:

Layer 1: Activity 窗口的 Surface(View 树,SurfaceView 位置透明)
Layer 0: SurfaceView 的 Surface(Z 轴在 Activity 下面)

Activity 的 Surface 上 SurfaceView 的位置是透明的(挖了个洞)
透过这个洞看到下面 SurfaceView 的 Surface

优点:可以在子线程绘制,不影响主线程,适合视频/游戏
缺点:不在 View 树中,不能做 View 动画,不能被其他 View 遮挡
TextureView
TextureView 没有独立的 Surface,用 SurfaceTexture:

内部有一个 BufferQueue,视频/相机内容先画到这里
TextureView.onDraw() 时从中取出最新帧,作为 GPU 纹理
画到 Activity 的 Surface 上

优点:在 View 树中,可以做任何 View 动画
缺点:多一次 GPU 纹理拷贝,必须硬件加速,比 SurfaceView 多一帧延迟

一帧的完整旅程

T=0.0ms   硬件 VSYNC 到来 → DispSync 收到

T=1.0ms   VSYNC-APP 触发
          Choreographer.doFrame()
            → CALLBACK_INPUT: 处理触摸事件
            → CALLBACK_ANIMATION: 计算动画值
            → CALLBACK_TRAVERSAL: performTraversals()
              → measure → layout → draw(记录 DisplayList)

T=6.0ms   主线程完成,DisplayList 同步给 RenderThread
          RenderThread 开始 GPU 渲染

T=5.0ms   VSYNC-SF 触发
          SurfaceFlinger 被唤醒

T=10.0ms  RenderThread 完成,queueBuffer() 提交到 BufferQueue

T=10.5ms  SurfaceFlinger acquireBuffer() 取出 Buffer
          与其他 Layer 一起交给 HWC 合成

T=15.0ms  合成完成,写入 framebuffer

T=16.6ms  下一个硬件 VSYNC
          显示器开始扫描新的 framebuffer
          用户看到画面更新
三级流水线
VSYNC:    V0          V1          V2          V3          V4

App:      [绘制帧1───]           [绘制帧3───]
                      [绘制帧2───]           [绘制帧4───]

SF:                   [合成帧1──]           [合成帧3──]
                                [合成帧2──]           [合成帧4──]

Display:                        [显示帧1──][显示帧2──][显示帧3──]

在 V2 这个时刻,三件事同时在发生:
  App 在绘制帧 3
  SurfaceFlinger 在合成帧 2
  显示器在显示帧 1

Choreographer 帧监控

kotlin
// 方案1:postFrameCallback 监控帧间隔
Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
    private var lastFrameTimeNanos = 0L

    override fun doFrame(frameTimeNanos: Long) {
        if (lastFrameTimeNanos != 0L) {
            val costMs = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000
            if (costMs > 16) {
                Log.w("Jank", "帧间隔 ${costMs}ms,掉了 ${costMs / 16 - 1} 帧")
            }
        }
        lastFrameTimeNanos = frameTimeNanos
        Choreographer.getInstance().postFrameCallback(this)
    }
})

// 方案2:Looper Printer 监控消息处理耗时(BlockCanary 原理)
Looper.getMainLooper().setMessageLogging { msg ->
    if (msg.startsWith(">>>>> Dispatching")) {
        startTime = SystemClock.elapsedRealtime()
    }
    if (msg.startsWith("<<<<< Finished")) {
        val cost = SystemClock.elapsedRealtime() - startTime
        if (cost > 16) {
            // dump 主线程堆栈,看在做什么
        }
    }
}

1. Binder 的原理?为什么 Android 选择 Binder?

考察点:IPC 机制理解

完整回答

Binder 是 Android 特有的 IPC 机制,基于 C/S 架构。

选择 Binder 的原因:

  1. 性能:只需一次数据拷贝。发送方通过 copy_from_user 将数据拷贝到内核缓冲区,接收方的用户空间通过 mmap 映射到同一块物理内存,直接访问。而 Socket 需要两次拷贝(发送方→内核→接收方)。
  2. 安全:Binder 驱动在内核层面记录调用方的 UID/PID,不可伪造。其他 IPC(如 Socket)的身份信息由用户空间填写,可以伪造。
  3. 易用:面向对象的调用方式,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 显示?

考察点:系统启动流程

完整回答

  1. Launcher 调用 startActivity,通过 Binder 发送给 AMS
  2. AMS 检查目标 Activity 的进程是否存在
  3. 进程不存在 → AMS 通过 Socket 请求 Zygote fork 新进程
  4. Zygote fork 后,新进程执行 ActivityThread.main()
  5. main() 中创建主线程 Looper 并 Looper.loop()
  6. 创建 ActivityThread 实例,通过 Binder 向 AMS 注册(attachApplication
  7. AMS 回调 bindApplication:创建 Application → attachBaseContext → ContentProvider.onCreate → Application.onCreate
  8. AMS 回调 scheduleLaunchActivity:创建 Activity → attach → onCreate → onStart → onResume
  9. ViewRootImpl.performTraversals 执行首帧绘制
  10. 用户看到内容

追问: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 启动源码

完整回答

  1. 调用方 startActivity → Instrumentation.execStartActivity
  2. 通过 Binder 调用 AMS 的 startActivity
  3. AMS 中:
    • ActivityStarter:解析 Intent,确定启动模式
    • 查找或创建 TaskRecord(Task 栈)
    • 创建 ActivityRecord
    • 暂停当前 Activity(回调 onPause)
  4. 如果目标进程不存在,通知 Zygote fork
  5. 目标进程就绪后,AMS 通过 Binder 回调 ActivityThread
  6. ActivityThread.handleLaunchActivity:
    • 通过反射创建 Activity 实例
    • 调用 activity.attach(创建 PhoneWindow)
    • 调用 onCreate(setContentView 创建 View 树)
    • 调用 onStart、onResume
  7. WindowManager.addView → 创建 ViewRootImpl → performTraversals

追问:为什么 A.onPause 先于 B.onCreate?

AMS 的调度逻辑:先暂停当前 Activity(确保它保存状态),再启动新 Activity。这是为了保证数据安全——如果先启动 B 再暂停 A,A 可能来不及保存状态就被系统回收。

4. 多进程有什么问题?怎么通信?

考察点:多进程架构

完整回答

多进程的问题:

  1. 静态变量/单例失效:每个进程有独立的虚拟机和内存空间
  2. SharedPreferences 不可靠:多进程同时读写会数据丢失
  3. 文件并发访问:需要文件锁
  4. Application 多次创建:每个进程都会创建 Application

通信方式:

  1. AIDL/Binder:最常用,支持方法调用,类型安全
  2. Messenger:基于 Handler 的轻量级方案,串行处理
  3. ContentProvider:适合数据共享
  4. BroadcastReceiver:适合一对多通知
  5. Socket:灵活但开发成本高
  6. MMKV:多进程安全的键值存储

追问:什么场景需要多进程?

  • WebView 独立进程:隔离 WebView 的内存泄漏和崩溃
  • 推送服务独立进程:保活
  • 大内存操作独立进程:如图片处理、视频编辑
  • 插件化:插件运行在独立进程

5. Binder 传输数据有大小限制吗?怎么传大数据?

考察点:Binder 限制

完整回答

Binder 传输缓冲区大小约 1MB(准确说是 1016KB),这个限制是所有正在进行的 Binder 事务共享的。超过限制会抛 TransactionTooLargeException。

常见触发场景:

  • Intent 传递大 Bundle(如大 Bitmap、大列表)
  • onSaveInstanceState 保存过多数据
  • ContentProvider 返回大量数据

传大数据的方案:

  1. 文件:写入文件,传递文件路径
  2. ContentProvider:通过 URI 访问数据
  3. ParcelFileDescriptor:传递文件描述符(Binder 只传 fd,数据通过文件系统传输)
  4. 共享内存(MemoryFile/SharedMemory):mmap 共享内存,Binder 只传引用
  5. Socket:大量数据通过 Socket 传输

追问:为什么 Intent 传 Bitmap 容易崩溃?

Bitmap 序列化后可能很大(1920×1080×4 = 8MB),远超 Binder 1MB 限制。应该传 URI 或文件路径,接收方自己加载。

6. Android 的 Parcelable 和 Serializable 的区别?

考察点:序列化

完整回答

维度ParcelableSerializable
实现手动实现(或 @Parcelize)实现接口即可
性能快(直接内存操作)慢(反射 + IO 流)
内存少(无临时对象)多(创建大量临时对象)
持久化不适合(格式可能变化)适合
使用场景Android 组件间传递(Intent/Bundle)持久化存储、网络传输

Kotlin 中推荐用 @Parcelize

kotlin
@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 的方案:

  1. 生成补丁 dex(包含修复后的类)
  2. 将补丁 dex 插入到 dexElements 数组的最前面
  3. 下次加载类时,先找到补丁 dex 中的修复类,原始 dex 中的 bug 类不会被加载
java
// 简化原理
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 启动大致经过哪些步骤?

考察点:启动流程主线

完整回答

冷启动大致流程:

  1. 用户点击图标,Launcher 通过 Binder 请求系统启动目标 Activity。
  2. 系统进程中的 AMS 判断目标应用进程是否存在。
  3. 如果进程不存在,通过 Zygote fork 出应用进程。
  4. 应用进程启动 ActivityThread,创建主线程 Looper。
  5. ActivityThread 创建 Application 和 Activity。
  6. 依次执行 Activity 的 onCreateonStartonResume,完成首帧绘制。

实习面试中能讲清这条主线即可,源码细节可以作为加分展开。

架构 / Gradle / 工程化

组件化、路由与依赖注入内容较多,已拆分为独立文件:

  • component-router-hilt-deep.md — 组件化架构设计(分层/独立运行/通信方案)、ARouter 源码分析(APT/路由查找/拦截器/降级)、Hilt 依赖注入(Module/Scope/Qualifier/编译期原理)

架构模式

MVC
View(XML 布局)→ Controller(Activity)→ Model(数据)

问题:Activity 同时承担 Controller 和部分 View 的职责,变得臃肿("God Activity")。

MVP
View(Activity/Fragment)←→ Presenter ←→ Model
  • View 和 Presenter 通过接口通信
  • Presenter 不持有 Android 组件引用(可单元测试)
  • 问题:接口爆炸、Presenter 可能臃肿、生命周期管理复杂
MVVM
View ←(observe)← ViewModel ←→ Repository
  • View 通过 LiveData/StateFlow 观察 ViewModel
  • ViewModel 不持有 View 引用
  • 数据驱动 UI,配置变更时 ViewModel 存活
MVI
View →(Intent)→ ViewModel →(State)→ View
  • 单向数据流
  • 不可变状态
  • 可预测、可追溯
Clean Architecture
┌─────────────────────────────────┐
│  Presentation Layer             │  ViewModel, UI
├─────────────────────────────────┤
│  Domain Layer                   │  UseCase, Entity, Repository接口
├─────────────────────────────────┤
│  Data Layer                     │  Repository实现, DataSource, API, DB
└─────────────────────────────────┘

依赖规则:外层依赖内层,内层不知道外层。Domain 层是纯 Kotlin,不依赖 Android 框架。

kotlin
// Domain 层
class GetUserUseCase(private val userRepository: UserRepository) {
    suspend operator fun invoke(userId: Int): Result<User> {
        return userRepository.getUser(userId)
    }
}

// Data 层
class UserRepositoryImpl(
    private val api: ApiService,
    private val dao: UserDao
) : UserRepository {
    override suspend fun getUser(userId: Int): Result<User> {
        return try {
            val user = api.getUser(userId)
            dao.insert(user)
            Result.success(user)
        } catch (e: Exception) {
            val cached = dao.getUser(userId)
            if (cached != null) Result.success(cached)
            else Result.failure(e)
        }
    }
}

设计模式在 Android 中的应用

单例模式
  • Application 全局单例
  • Room Database
  • Retrofit 实例
工厂模式
  • BitmapFactory.decodeResource/decodeFile
  • LayoutInflater.inflate
  • Fragment.newInstance(静态工厂方法)
观察者模式
  • LiveData / Flow
  • BroadcastReceiver
  • OnClickListener
  • Lifecycle Observer
责任链模式
  • OkHttp 拦截器链
  • View 事件分发(ViewGroup → View)
代理模式
  • Retrofit 动态代理
  • Binder 的 Stub/Proxy
  • AIDL 生成的代理类
建造者模式
  • AlertDialog.Builder
  • OkHttpClient.Builder
  • Retrofit.Builder
  • Notification.Builder
策略模式
  • RecyclerView.LayoutManager(LinearLayout/Grid/StaggeredGrid)
  • Interpolator(线性/加速/减速)
模板方法模式
  • Activity 生命周期(onCreate/onStart/onResume 是模板方法)
  • AsyncTask(doInBackground/onPostExecute)
  • BaseAdapter(getView)

组件化架构

为什么组件化?

单体应用的问题:

  • 编译慢(全量编译)
  • 代码耦合严重
  • 多团队协作困难
  • 无法独立测试
组件化架构
┌──────────────────────────────────┐
│            App Shell             │  壳工程,组装各组件
├──────┬──────┬──────┬────────────┤
│ 首页  │ 播放  │ 我的  │ 消息       │  业务组件(可独立运行)
├──────┴──────┴──────┴────────────┤
│          公共组件层               │  网络、图片、日志、埋点
├──────────────────────────────────┤
│          基础库层                 │  工具类、UI 组件、路由
└──────────────────────────────────┘
路由

组件间不能直接依赖,通过路由框架跳转:

kotlin
// 注册(注解)
@Route(path = "/video/detail")
class VideoDetailActivity : AppCompatActivity()

// 跳转
ARouter.getInstance()
    .build("/video/detail")
    .withLong("videoId", 12345)
    .navigation()

ARouter 原理:

  1. 编译期 APT 扫描 @Route 注解,生成路由表(path → Class 映射)
  2. 运行时根据 path 查找目标 Class
  3. 支持拦截器(登录检查、降级)
组件间通信
kotlin
// 1. 接口下沉 + SPI
// 公共层定义接口
interface IUserService {
    fun getUserName(): String
}

// 用户组件实现
class UserServiceImpl : IUserService {
    override fun getUserName() = "张三"
}

// 其他组件通过 ServiceLoader 或 ARouter 获取实现
val userService = ARouter.getInstance().navigation(IUserService::class.java)

// 2. EventBus / LiveDataBus
// 3. ContentProvider
依赖注入

Hilt(基于 Dagger):

kotlin
@HiltAndroidApp
class MyApp : Application()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var repository: UserRepository
}

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Provides
    @Singleton
    fun provideUserRepository(api: ApiService, dao: UserDao): UserRepository {
        return UserRepositoryImpl(api, dao)
    }
}

Gradle 构建系统

构建生命周期
初始化阶段(Initialization)
  → 执行 settings.gradle,确定参与构建的项目
配置阶段(Configuration)
  → 执行所有 build.gradle,构建 Task DAG
执行阶段(Execution)
  → 按 DAG 顺序执行 Task
Task DAG
:app:preBuild
  → :app:compileDebugKotlin
    → :app:mergeDebugResources
      → :app:packageDebug
        → :app:assembleDebug

Task 之间通过 dependsOnmustRunAfter 等建立依赖关系,Gradle 自动拓扑排序并行执行无依赖的 Task。

自定义 Plugin
kotlin
class MyPlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.tasks.register("myTask") {
            doLast {
                println("Hello from custom plugin!")
            }
        }
    }
}
依赖管理
kotlin
// Version Catalog (libs.versions.toml)
[versions]
kotlin = "1.9.0"
compose = "1.5.0"

[libraries]
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }

[plugins]
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

依赖冲突解决:

  • implementation:编译和运行时可见,不传递给消费者
  • api:编译和运行时可见,传递给消费者
  • compileOnly:只在编译时可见
  • 版本冲突:Gradle 默认选择最高版本,可用 forceexcluderesolutionStrategy 控制
构建优化
  • 开启 Gradle 守护进程和并行构建
  • 使用 Build Cache
  • 合理使用 implementation 减少重编译范围
  • 避免在配置阶段执行耗时操作
  • 使用 Configuration Cache(Gradle 7+)

CI/CD 与质量门禁

  • 代码检查:Lint、Detekt(Kotlin)、ktlint
  • 单元测试:JUnit + Mockk/Mockito
  • UI 测试:Espresso、Compose Testing
  • 代码覆盖率:JaCoCo
  • 自动化构建:Jenkins / GitHub Actions / GitLab CI
  • 质量门禁:PR 必须通过 Lint + 单元测试 + 代码审查

组件化架构设计

为什么要组件化

单体工程的问题:

  • 编译慢:改一行代码全量编译
  • 耦合重:模块间直接引用,牵一发动全身
  • 协作难:多团队改同一个模块容易冲突
  • 无法独立运行:不能单独调试某个业务模块

组件化的目标:

app(壳工程,只做组装)
├── feature-home(首页模块)
├── feature-video(视频模块)
├── feature-user(用户模块)
├── feature-search(搜索模块)
├── lib-network(网络库)
├── lib-image(图片库)
├── lib-common(公共工具)
└── lib-base(基础框架)
模块分层
┌─────────────────────────────────────┐
│              app(壳工程)            │  ← 只做模块组装和初始化
├─────────────────────────────────────┤
│  feature-home │ feature-video │ ... │  ← 业务模块(互相不依赖)
├─────────────────────────────────────┤
│  lib-network  │ lib-image │ lib-db  │  ← 功能模块(可被业务模块依赖)
├─────────────────────────────────────┤
│           lib-common                │  ← 公共工具(所有模块可依赖)
├─────────────────────────────────────┤
│           lib-base                  │  ← 基础框架(接口定义、路由表)
└─────────────────────────────────────┘

核心原则:

  • 业务模块之间不能直接依赖(通过路由或接口下沉通信)
  • 上层可以依赖下层,下层不能依赖上层
  • 公共接口下沉到 lib-base
模块独立运行
groovy
// feature-home/build.gradle.kts
// 通过 gradle.properties 中的标志位切换 application/library
if (isRunAlone) {
    apply plugin: 'com.android.application'
} else {
    apply plugin: 'com.android.library'
}

android {
    defaultConfig {
        if (isRunAlone) {
            applicationId "com.example.home"
        }
    }

    sourceSets {
        main {
            if (isRunAlone) {
                // 独立运行时使用独立的 AndroidManifest(有 launcher Activity)
                manifest.srcFile 'src/main/debug/AndroidManifest.xml'
            } else {
                manifest.srcFile 'src/main/AndroidManifest.xml'
            }
        }
    }
}
组件间通信方案

方案1:接口下沉 + SPI

kotlin
// lib-base 中定义接口
interface IUserService {
    fun getUserInfo(): UserInfo
    fun isLoggedIn(): Boolean
}

// feature-user 中实现
class UserServiceImpl : IUserService {
    override fun getUserInfo() = UserRepository.getUser()
    override fun isLoggedIn() = TokenManager.hasToken()
}

// 注册(通过 SPI 或手动注册)
// META-INF/services/com.example.base.IUserService
// com.example.user.UserServiceImpl

// 使用(在 feature-home 中)
val userService = ServiceLoader.load(IUserService::class.java).first()
if (userService.isLoggedIn()) { ... }

方案2:路由框架(ARouter)

kotlin
// feature-user 中暴露服务
@Route(path = "/user/service")
class UserServiceImpl : IProvider, IUserService {
    override fun getUserInfo() = UserRepository.getUser()
    override fun init(context: Context) {}
}

// feature-home 中使用
val userService = ARouter.getInstance()
    .build("/user/service")
    .navigation() as IUserService

方案3:EventBus / LiveDataBus

kotlin
// 发送事件(feature-user)
LiveDataBus.get<LoginEvent>("login_event").post(LoginEvent(userId))

// 接收事件(feature-home)
LiveDataBus.get<LoginEvent>("login_event").observe(this) { event ->
    refreshUserInfo(event.userId)
}

路由框架原理(以 ARouter 为例)

整体架构
编译期(APT)                    运行时
┌──────────────┐            ┌──────────────┐
│ @Route 注解   │            │ ARouter.init │
│ RouteProcessor│ ──生成──→  │ 加载路由表     │
│ 生成路由表类   │            │              │
└──────────────┘            │ navigation() │
                            │ 匹配路由      │
                            │ 执行拦截器    │
                            │ 跳转/获取服务  │
                            └──────────────┘
编译期:APT 生成路由表
java
// 你写的代码
@Route(path = "/home/main")
public class HomeActivity extends AppCompatActivity { ... }

@Route(path = "/user/detail")
public class UserDetailActivity extends AppCompatActivity { ... }

// APT 生成的代码(每个模块一个类)
public class ARouter$$Group$$home implements IRouteGroup {
    @Override
    public void loadInto(Map<String, RouteMeta> atlas) {
        atlas.put("/home/main", RouteMeta.build(
            RouteType.ACTIVITY,
            HomeActivity.class,
            "/home/main",
            "home",  // group
            null,    // paramsType
            -1       // priority
        ));
    }
}

// 根路由表(索引 group → 对应的 IRouteGroup 类)
public class ARouter$$Root$$app implements IRouteRoot {
    @Override
    public void loadInto(Map<String, Class<? extends IRouteGroup>> routes) {
        routes.put("home", ARouter$$Group$$home.class);
        routes.put("user", ARouter$$Group$$user.class);
    }
}
运行时:路由查找与跳转
java
// ARouter.init() 加载路由表
public static void init(Application application) {
    // 1. 扫描 dex 文件,找到所有 ARouter$$Root$$ 开头的类
    Set<String> routerMap = ClassUtils.getFileNameByPackageName(
        context, "com.alibaba.android.arouter.routes");

    // 2. 反射实例化,加载路由表到内存
    for (String className : routerMap) {
        if (className.startsWith(ROOT_PREFIX)) {
            ((IRouteRoot) Class.forName(className).newInstance())
                .loadInto(Warehouse.groupsIndex);
        }
    }
}

// navigation() 跳转
public Object navigation(Context context, Postcard postcard, int requestCode) {
    // 1. 根据 path 查找路由信息
    LogisticsCenter.completion(postcard);
    // 内部:从 Warehouse.routes 中查找 RouteMeta
    // 如果 group 还没加载,先加载 group

    // 2. 执行拦截器链
    interceptorService.doInterceptions(postcard, new InterceptorCallback() {
        @Override
        public void onContinue(Postcard postcard) {
            // 3. 拦截器通过,执行跳转
            _navigation(context, postcard, requestCode);
        }

        @Override
        public void onInterrupt(Throwable exception) {
            // 被拦截
        }
    });
}

private Object _navigation(Context context, Postcard postcard, int requestCode) {
    switch (postcard.getType()) {
        case ACTIVITY:
            Intent intent = new Intent(context, postcard.getDestination());
            intent.putExtras(postcard.getExtras());
            ActivityCompat.startActivityForResult(activity, intent, requestCode);
            break;

        case PROVIDER:
            // 获取服务实例(单例)
            return postcard.getProvider();

        case FRAGMENT:
            // 创建 Fragment 实例
            Fragment fragment = postcard.getDestination().newInstance();
            fragment.setArguments(postcard.getExtras());
            return fragment;
    }
}
拦截器
kotlin
// 登录拦截器:未登录时跳转到登录页
@Interceptor(priority = 8, name = "登录拦截器")
class LoginInterceptor : IInterceptor {
    override fun process(postcard: Postcard, callback: InterceptorCallback) {
        if (postcard.extra == NEED_LOGIN && !UserManager.isLoggedIn()) {
            // 拦截,跳转到登录页
            ARouter.getInstance().build("/user/login").navigation()
            callback.onInterrupt(null)
        } else {
            // 放行
            callback.onContinue(postcard)
        }
    }

    override fun init(context: Context) {}
}

// 使用:标记需要登录的页面
@Route(path = "/order/detail", extras = NEED_LOGIN)
class OrderDetailActivity : AppCompatActivity()
降级策略
kotlin
// 全局降级:路由找不到时的兜底
class GlobalDegradeService : DegradeService {
    override fun onLost(context: Context, postcard: Postcard) {
        // 跳转到 404 页面或 H5 兜底页
        ARouter.getInstance()
            .build("/common/webview")
            .withString("url", "https://example.com${postcard.path}")
            .navigation()
    }

    override fun init(context: Context) {}
}

依赖注入(Hilt)

Hilt 核心概念
kotlin
// 1. @HiltAndroidApp:触发 Hilt 代码生成,创建应用级组件
@HiltAndroidApp
class MyApplication : Application()

// 2. @AndroidEntryPoint:标记需要注入的 Android 类
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    // 3. @Inject:标记需要注入的字段
    @Inject lateinit var userRepository: UserRepository

    // 4. ViewModel 注入
    private val viewModel: MainViewModel by viewModels()
}

// 5. @HiltViewModel
@HiltViewModel
class MainViewModel @Inject constructor(
    private val userRepository: UserRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel()
Module 和 Provides
kotlin
// 提供接口的实现
@Module
@InstallIn(SingletonComponent::class)  // 作用域:Application 级别
object NetworkModule {

    @Provides
    @Singleton  // 单例
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .addInterceptor(LoggingInterceptor())
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(okHttpClient)  // Hilt 自动注入 OkHttpClient
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

// 绑定接口到实现
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    @Singleton
    abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
Scope(作用域)
kotlin
// Hilt 预定义的组件和作用域
@SingletonComponent@Singleton        → Application 生命周期
@ActivityRetainedComponent@ActivityRetainedScoped → ViewModel 生命周期(配置变更不销毁)
@ViewModelComponent@ViewModelScoped  → ViewModel 生命周期
@ActivityComponent@ActivityScoped   → Activity 生命周期
@FragmentComponent@FragmentScoped   → Fragment 生命周期
@ViewComponent@ViewScoped       → View 生命周期
@ServiceComponent@ServiceScoped    → Service 生命周期
kotlin
// 示例:Activity 级别的作用域
@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {
    @Provides
    @ActivityScoped  // 每个 Activity 实例一个,Activity 销毁时释放
    fun provideAnalytics(activity: Activity): AnalyticsTracker {
        return AnalyticsTracker(activity)
    }
}
Qualifier(限定符)
kotlin
// 同一类型有多个实现时,用 Qualifier 区分
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptor

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LoggingInterceptor

@Module
@InstallIn(SingletonComponent::class)
object InterceptorModule {
    @Provides
    @AuthInterceptor
    fun provideAuthInterceptor(): Interceptor = AuthInterceptorImpl()

    @Provides
    @LoggingInterceptor
    fun provideLoggingInterceptor(): Interceptor = HttpLoggingInterceptor()
}

// 使用时指定
@Provides
fun provideOkHttpClient(
    @AuthInterceptor authInterceptor: Interceptor,
    @LoggingInterceptor loggingInterceptor: Interceptor
): OkHttpClient {
    return OkHttpClient.Builder()
        .addInterceptor(authInterceptor)
        .addInterceptor(loggingInterceptor)
        .build()
}
Hilt 的编译期原理

Hilt 在编译期做了什么:

1. @HiltAndroidApp 处理
   → 生成 Hilt_MyApplication(继承 MyApplication)
   → 在 AndroidManifest 中替换 Application 类名
   → 创建 SingletonComponent(Dagger 组件)

2. @AndroidEntryPoint 处理
   → 生成 Hilt_MainActivity(继承 MainActivity)
   → 在 onCreate 中调用 inject()
   → 从对应的 Component 中获取依赖并注入

3. @Module + @InstallIn 处理
   → 将 Module 安装到指定的 Component
   → 生成 Dagger 的依赖图(DAG)
   → 编译期验证依赖是否完整(缺少依赖会编译报错)

与手动依赖管理的对比:

kotlin
// 手动创建(没有 DI)
val okHttpClient = OkHttpClient.Builder().build()
val retrofit = Retrofit.Builder().client(okHttpClient).build()
val apiService = retrofit.create(ApiService::class.java)
val repository = UserRepositoryImpl(apiService)
val viewModel = MainViewModel(repository)
// 问题:创建顺序、生命周期、单例管理全靠手动

// Hilt(自动管理)
@HiltViewModel
class MainViewModel @Inject constructor(
    private val repository: UserRepository  // Hilt 自动创建并注入整条依赖链
) : ViewModel()

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 组件、路由)

关键问题和解决方案:

  1. 页面跳转:路由框架(ARouter)。编译期 APT 生成路由表,运行时根据 path 查找目标 Activity。支持拦截器(登录检查)和降级。

  2. 组件间通信:接口下沉 + SPI。公共层定义接口,业务组件实现,通过 ServiceLoader 或 ARouter 获取实现。

  3. 独立运行:每个组件可以配置为 application(独立运行调试)或 library(集成到主工程)。

  4. 依赖注入: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 构建三个阶段:

  1. 初始化:执行 settings.gradle,确定参与构建的项目
  2. 配置:执行所有 build.gradle,构建 Task 依赖图(DAG)
  3. 执行:按 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. 怎么做代码质量管控?

考察点:工程化能力

完整回答

  1. 静态检查

    • Android Lint:检查潜在 bug、性能问题、安全漏洞
    • Detekt/ktlint:Kotlin 代码风格和质量检查
    • 自定义 Lint 规则:检查项目特定的规范
  2. 测试

    • 单元测试:JUnit + MockK,覆盖 ViewModel 和 Repository 逻辑
    • UI 测试:Espresso / Compose Testing
    • 覆盖率:JaCoCo,设置最低覆盖率门槛
  3. Code Review:PR 必须至少一人审核通过

  4. CI/CD

    • PR 触发自动构建 + Lint + 单元测试
    • 任何检查失败则阻止合并
    • 合并后自动打包发布到测试环境
  5. 监控

    • 线上 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 注解简化配置
kotlin
// 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. implementationapi 的区别?

考察点:Gradle 依赖配置

完整回答

  • implementation:依赖只在当前模块内部可见,不会暴露给依赖当前模块的其他模块。
  • api:依赖会传递暴露给上层模块。

一般优先使用 implementation,可以减少不必要的依赖暴露,加快编译,也让模块边界更清晰。只有当当前模块的公开 API 中使用了某个依赖的类型时,才考虑使用 api

8. Debug 包和 Release 包有什么区别?

考察点:构建变体

完整回答

Debug 包通常用于开发调试:

  • 默认可调试。
  • 可能开启日志、调试菜单、测试环境接口。
  • 通常不做完整混淆和优化。

Release 包用于发布:

  • 关闭调试。
  • 使用正式环境配置。
  • 通常开启签名、混淆、资源压缩等优化。

追问:为什么 Release 包要混淆?

混淆可以缩短类名和方法名,增加逆向难度,同时配合压缩移除未使用代码,减小包体积。但需要为反射、序列化、JNI、第三方 SDK 等场景配置 keep 规则。

9. Git 中 merge 和 rebase 有什么区别?

考察点:团队协作基础

完整回答

  • merge 会生成一次合并提交,保留分支真实合并历史。
  • rebase 会把当前分支的提交“挪到”目标分支之后,历史更线性。

多人协作时,不要随意 rebase 已经推送并被别人基于开发的公共分支,容易改写历史造成冲突。个人 feature 分支在合并前可以适当 rebase,让提交历史更清晰。

跨平台 Flutter / KMM

Flutter

架构
┌─────────────────────────────┐
│       Dart Framework         │  Widget / Rendering / Animation / Gestures
├─────────────────────────────┤
│       Engine (C++)           │  Skia 渲染 / Dart VM / Platform Channel
├─────────────────────────────┤
│       Embedder               │  平台嵌入层(Android: FlutterActivity/FlutterView)
└─────────────────────────────┘
渲染原理

Flutter 不使用原生控件,自己通过 Skia(现在是 Impeller)绘制 UI:

Widget 树(配置描述)
  → Element 树(生命周期管理)
    → RenderObject 树(布局和绘制)
      → Skia/Impeller → GPU → 屏幕
  • Widget:不可变的配置描述,类似 Compose 的 @Composable
  • Element:Widget 的实例化,管理生命周期和状态,类似 Compose 的 Slot Table
  • RenderObject:负责布局(layout)和绘制(draw),类似 Android 的 View
三棵树
Widget 树          Element 树         RenderObject 树
Container    →    ComponentElement  →  (无)
  └─ Row     →    ComponentElement  →  RenderFlex
    ├─ Text  →    LeafElement       →  RenderParagraph
    └─ Icon  →    LeafElement       →  RenderIcon

Widget 重建时,Element 通过 canUpdate(runtimeType + key 相同)判断是否复用,复用则更新 RenderObject,否则重建。

Platform Channel

Flutter 与 Native 通信的桥梁:

Flutter (Dart)  ←→  Platform Channel  ←→  Native (Kotlin/Swift)

三种 Channel:

  • MethodChannel:方法调用(一次性请求/响应)
  • EventChannel:事件流(Native → Dart 的持续数据流,如传感器)
  • BasicMessageChannel:自定义编解码的消息传递
dart
// Dart 端
final channel = MethodChannel('com.example/battery');
final batteryLevel = await channel.invokeMethod('getBatteryLevel');

// Kotlin 端
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.example/battery")
    .setMethodCallHandler { call, result ->
        if (call.method == "getBatteryLevel") {
            result.success(getBatteryLevel())
        } else {
            result.notImplemented()
        }
    }
混合开发

FlutterEngine:Dart VM 实例,创建开销大,建议预热缓存。

FlutterFragment / FlutterActivity:在原生 App 中嵌入 Flutter 页面。

页面栈管理:Flutter 和 Native 各有自己的页面栈,混合导航需要框架支持(如 flutter_boost)。

Kotlin Multiplatform (KMM)

架构
┌──────────────────────────────────┐
│          共享模块 (commonMain)     │  业务逻辑、网络、数据库
├──────────┬───────────────────────┤
│ androidMain │     iosMain         │  平台特定实现
├──────────┼───────────────────────┤
│ Android App │    iOS App          │  原生 UI
└──────────┴───────────────────────┘
expect / actual 机制
kotlin
// commonMain - 声明期望
expect class PlatformLogger() {
    fun log(message: String)
}

// androidMain - Android 实现
actual class PlatformLogger {
    actual fun log(message: String) {
        Log.d("KMM", message)
    }
}

// iosMain - iOS 实现
actual class PlatformLogger {
    actual fun log(message: String) {
        NSLog(message)
    }
}
共享范围

适合共享的:

  • 网络层(Ktor)
  • 数据库(SQLDelight)
  • 业务逻辑
  • 数据模型
  • 序列化(kotlinx.serialization)

不适合共享的:

  • UI(各平台原生 UI 体验更好)
  • 平台特定 API(相机、蓝牙等)
与 Native 互操作
  • Android:KMM 编译为 JVM 字节码,直接调用
  • iOS:KMM 编译为 Kotlin/Native → LLVM → Framework,通过 Objective-C 互操作

React Native

新架构
旧架构:JS → Bridge(JSON 序列化)→ Native
新架构:JS → JSI(C++ 直接调用)→ Native
  • JSI(JavaScript Interface):JS 直接调用 C++ 对象,无需 JSON 序列化
  • Fabric:新的渲染系统,支持同步渲染
  • TurboModules:按需加载 Native 模块,替代旧的 NativeModules
与 Flutter 对比
维度FlutterReact Native
语言DartJavaScript/TypeScript
渲染自绘(Skia/Impeller)原生控件
性能接近原生略低(JS 桥接开销)
UI 一致性跨平台完全一致跟随平台风格
生态较新但增长快成熟,npm 生态丰富
热更新不支持(需要重新编译)支持(CodePush)

跨平台方案选型

维度FlutterKMMReact Native
共享范围UI + 逻辑仅逻辑UI + 逻辑
原生体验自绘,需要适配原生 UI原生控件
学习成本Dart(新语言)Kotlin(Android 开发者友好)JS/TS
适用场景新项目、UI 一致性要求高已有原生 App、共享业务逻辑快速迭代、热更新需求
团队要求需要 Flutter 开发者Android + iOS 开发者前端开发者

选型建议:

  • 已有成熟原生 App,想共享业务逻辑 → KMM
  • 新项目,追求开发效率和 UI 一致性 → Flutter
  • 团队以前端为主,需要热更新 → React Native
  • 对性能要求极高的核心页面 → 保持原生

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 通信,三种类型:

  1. MethodChannel:方法调用,一次请求一次响应。最常用,如获取电量、调用原生相机。

  2. EventChannel:事件流,Native 持续向 Dart 发送数据。如传感器数据、位置更新。

  3. 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:在各平台模块中提供具体实现
kotlin
// 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. 跨平台方案怎么选型?

考察点:技术选型能力

完整回答

选型维度:

维度FlutterKMMReact Native
共享范围UI + 逻辑仅逻辑UI + 逻辑
性能接近原生(自绘)原生略低(JS 桥接)
UI 体验跨平台一致原生 UI跟随平台
热更新不支持不支持支持(CodePush)
团队需要 Dart 开发者Kotlin 开发者前端开发者

选型建议:

  • 新项目 + UI 一致性 → Flutter
  • 已有原生 App + 共享逻辑 → KMM
  • 前端团队 + 热更新需求 → React Native
  • 核心体验页面 → 保持原生

实际项目中,很多大厂采用混合方案:核心页面原生,非核心页面 Flutter/RN,业务逻辑 KMM 共享。

5. Flutter 的状态管理方案有哪些?

考察点:Flutter 开发实践

完整回答

  1. setState:最基础,适合简单组件内部状态
  2. Provider:官方推荐的轻量级方案,基于 InheritedWidget
  3. Riverpod:Provider 的改进版,编译期安全,不依赖 BuildContext
  4. Bloc:基于事件驱动的状态管理,类似 MVI。Event → Bloc → State
  5. 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 更适合希望保留原生体验,同时减少业务逻辑重复开发的团队。

系统设计

系统设计方法论

面试中的系统设计题,按以下步骤回答:

  1. 需求分析:明确功能需求和非功能需求(性能、可靠性、扩展性)
  2. 模块拆分:划分核心模块,明确职责
  3. 接口设计:定义模块间的 API
  4. 数据流:数据从输入到输出的完整流转
  5. 异常处理:降级、重试、兜底策略
  6. 扩展性:如何支持未来需求变化

设计一个图片加载框架

需求分析
  • 从网络/本地加载图片到 ImageView
  • 三级缓存(内存/磁盘/网络)
  • 生命周期感知(页面销毁取消加载)
  • 图片变换(圆角、裁剪、模糊)
  • 占位图和错误图
  • 采样压缩,避免 OOM
架构设计
ImageLoader(入口,Builder 模式配置)
├── RequestManager(生命周期管理)
├── Engine(调度核心)
│   ├── MemoryCache(LruCache)
│   ├── DiskCache(DiskLruCache)
│   ├── Fetcher(网络/本地/资源加载器)
│   └── Decoder(BitmapFactory 解码 + 采样)
├── TransformationPipeline(图片变换链)
└── Target(ImageView 绑定 + 占位图管理)
核心流程
load(url).into(imageView)
  → 生成缓存 key(url + 尺寸 + 变换参数 的 hash)
  → 查内存缓存 → 命中则直接显示
  → 查磁盘缓存 → 命中则解码 + 写入内存缓存 + 显示
  → 网络请求 → 下载 → 写入磁盘缓存 → 解码 → 变换 → 写入内存缓存 → 显示
关键设计点

生命周期管理

  • 注入空 Fragment(类似 Glide 的 RequestManagerFragment)监听生命周期
  • onStop 暂停加载,onStart 恢复,onDestroy 取消并释放资源

线程调度

  • 网络请求和磁盘 IO 在后台线程池
  • 图片解码在 CPU 密集型线程池
  • 显示切回主线程

防止图片错位

  • ImageView 设置 tag 为当前请求的 url
  • 加载完成后检查 tag 是否匹配,不匹配则丢弃

采样压缩

  • inJustDecodeBounds=true 获取原始尺寸
  • 根据目标 ImageView 尺寸计算 inSampleSize
  • 再解码,避免加载超大图片 OOM

设计一个消息推送系统

需求分析
  • 服务端实时推送消息到客户端
  • 消息可靠性(不丢失、不重复)
  • 离线消息
  • 省电省流量
架构设计
服务端
├── 推送网关(维护长连接)
├── 消息队列(Kafka/RabbitMQ)
└── 离线消息存储

客户端
├── 连接管理(长连接 + 心跳 + 重连)
├── 消息接收与 ACK
├── 本地消息存储
└── 通知展示
长连接方案
  • WebSocket:基于 TCP,全双工通信,适合实时推送
  • 自定义 TCP 协议:更灵活,可以优化协议头大小
  • MQTT:轻量级发布/订阅协议,适合 IoT 和移动端
心跳机制
客户端每 N 秒发送心跳包 → 服务端回复 ACK
连续 M 次无 ACK → 判定连接断开 → 触发重连

心跳间隔自适应:

  • WiFi 环境:较长间隔(如 5 分钟)
  • 移动网络:较短间隔(如 2 分钟)
  • NAT 超时前发送心跳保活
消息可靠性
服务端发送消息(带 msgId)
  → 客户端收到 → 发送 ACK(带 msgId)
  → 服务端收到 ACK → 标记已送达
  → 超时未收到 ACK → 重发

客户端去重:本地维护已处理的 msgId 集合
离线消息

客户端上线后,拉取离线消息:

  • 客户端发送最后收到的 msgId
  • 服务端返回该 msgId 之后的所有消息
  • 分页拉取,避免一次返回过多

设计一个路由框架

需求分析
  • 通过 URL/path 跳转到目标页面
  • 支持参数传递
  • 拦截器(登录检查、权限验证)
  • 降级策略(目标页面不存在时的处理)
架构设计
Router
├── RouteTable(path → Class 映射表)
├── Interceptor Chain(拦截器链)
├── Navigator(实际跳转执行)
└── DegradeHandler(降级处理)
路由表生成

编译期方案(推荐):

  • 自定义注解 @Route(path = "/user/detail")
  • APT 扫描注解,为每个模块生成路由表
  • 应用启动时加载所有模块的路由表

运行时方案

  • 手动注册:Router.register("/user/detail", UserDetailActivity::class)
  • 缺点:容易遗漏,不够自动化
拦截器
kotlin
interface RouteInterceptor {
    fun intercept(chain: RouteChain): RouteResult
}

class LoginInterceptor : RouteInterceptor {
    override fun intercept(chain: RouteChain): RouteResult {
        if (needLogin(chain.route) && !isLoggedIn()) {
            return RouteResult.Redirect("/login") // 重定向到登录页
        }
        return chain.proceed() // 继续
    }
}
降级策略
  • 目标页面未注册 → 打开 H5 兜底页
  • 目标页面崩溃 → 打开错误页
  • 服务端下发路由配置,动态控制跳转目标

设计一个埋点系统

需求分析
  • 采集用户行为数据(页面浏览、点击、曝光)
  • 高性能,不影响主线程
  • 数据可靠,不丢失
  • 支持实时和批量上报
架构设计
采集层
├── 页面埋点(自动化:Lifecycle 监听)
├── 点击埋点(AOP / 注解)
├── 曝光埋点(RecyclerView 可见性检测)
└── 自定义埋点(手动调用)

处理层
├── 数据格式化(统一 schema)
├── 数据聚合(合并相同事件)
└── 本地缓存(SQLite / 文件)

上报层
├── 实时上报(关键事件立即发送)
├── 批量上报(定时 + 条数阈值)
└── 降级策略(网络异常时本地缓存,恢复后补报)
关键设计点

不影响主线程

  • 采集在主线程(获取 UI 信息),但只做最小操作
  • 格式化、缓存、上报在后台线程

数据可靠性

  • 先写本地缓存,上报成功后删除
  • 应用崩溃时数据不丢失(已写入本地)
  • 上报失败自动重试

曝光埋点

  • 监听 RecyclerView 的滚动事件
  • 计算 item 可见面积比例(>50% 且停留 >500ms 算有效曝光)
  • 去重:同一个 item 在同一页面只上报一次

设计一个日志系统

架构
Logger API(统一接口)
├── 日志分级(VERBOSE/DEBUG/INFO/WARN/ERROR)
├── 日志格式化(时间/线程/TAG/内容)
├── 输出策略
│   ├── Console(Logcat,Debug 模式)
│   ├── File(本地文件,Release 模式)
│   └── Remote(上报服务端,ERROR 级别)
└── 文件管理
    ├── 按天/大小分文件
    ├── mmap 写入(高性能)
    ├── 压缩(gzip)
    └── 定期清理(保留 7 天)
高性能写入

使用 mmap 内存映射写入日志文件:

  • 写入内存即完成,OS 异步刷盘
  • 即使应用崩溃,已写入的数据不会丢失(OS 会刷盘)
  • 比 FileOutputStream 快 10 倍以上

美团 Logan、微信 xlog 都采用 mmap 方案。

设计一个网络层框架

架构
NetworkClient(统一入口)
├── 拦截器链
│   ├── 日志拦截器
│   ├── 认证拦截器(Token 管理 + 自动刷新)
│   ├── 缓存拦截器
│   ├── 重试拦截器(指数退避)
│   └── 监控拦截器(耗时/成功率/错误码统计)
├── 请求队列(优先级调度)
├── 连接管理(连接池复用)
└── 数据解析(JSON/Protobuf)
Token 自动刷新
kotlin
class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {
    override fun intercept(chain: Chain): Response {
        val request = chain.request().newBuilder()
            .addHeader("Authorization", "Bearer ${tokenManager.accessToken}")
            .build()

        val response = chain.proceed(request)

        if (response.code == 401) {
            synchronized(this) {
                // 双重检查:可能其他线程已经刷新了
                val newToken = tokenManager.refreshToken()
                val newRequest = request.newBuilder()
                    .header("Authorization", "Bearer $newToken")
                    .build()
                return chain.proceed(newRequest)
            }
        }
        return response
    }
}
重试策略

指数退避:第 1 次重试等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒...加上随机抖动避免惊群效应。

只对幂等请求重试(GET),非幂等请求(POST)需要业务层决定。

1. 设计一个图片加载框架(类似 Glide)

考察点:缓存设计、线程调度、生命周期管理

完整回答

需求:从网络/本地加载图片到 ImageView,支持缓存、变换、生命周期感知。

核心架构

ImageLoader.with(context).load(url).transform(圆角).into(imageView)

三级缓存

  1. 活动资源缓存(WeakReference,正在使用的图片)
  2. 内存缓存(LruCache,最大堆内存的 1/8)
  3. 磁盘缓存(DiskLruCache,原始图 + 变换后的图)

缓存 key = url + 目标尺寸 + 变换参数 的 hash。

加载流程

  1. 生成缓存 key
  2. 查活动资源 → 查内存缓存 → 查磁盘缓存 → 网络请求
  3. 网络下载 → 写磁盘 → 解码(inSampleSize 采样)→ 变换 → 写内存 → 显示

生命周期管理:注入空 Fragment 监听 Activity/Fragment 生命周期。onStop 暂停,onDestroy 取消请求并释放资源。

防止图片错位:ImageView 设置 tag 为当前 url,加载完成后检查 tag 是否匹配。

线程调度:网络/磁盘 IO 在 IO 线程池,解码在 CPU 线程池,显示切回主线程。

追问:怎么避免 OOM?

  1. inSampleSize 采样:根据 ImageView 尺寸计算采样率
  2. RGB_565 替代 ARGB_8888(省一半内存)
  3. inBitmap 复用 Bitmap 内存
  4. LruCache 控制内存缓存上限
  5. 大图使用 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 设计

kotlin
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 封装,拦截器链模式。

拦截器设计

  1. 日志拦截器:记录请求/响应信息
  2. 认证拦截器:自动添加 Token,401 时自动刷新 Token 并重试
  3. 缓存拦截器:自定义缓存策略(强缓存 + 协商缓存)
  4. 重试拦截器:指数退避重试,只对幂等请求(GET)重试
  5. 监控拦截器:统计请求耗时、成功率、错误码分布

Token 刷新

  • 401 时 synchronized 加锁刷新 Token
  • 双重检查:进入锁后先检查 Token 是否已被其他线程刷新
  • 刷新成功后用新 Token 重试原请求
  • 刷新失败则跳转登录页

错误处理

  • 网络异常 → 检查本地缓存 → 有则返回缓存数据 + 标记为缓存
  • 服务端错误 → 统一错误码映射 → 展示友好提示
  • 超时 → 重试(GET)或提示用户

追问:怎么做网络监控?

通过 OkHttp 的 EventListener 监听请求各阶段耗时:DNS 解析、TCP 连接、TLS 握手、请求发送、响应接收。聚合统计后上报,用于发现慢请求和网络质量问题。

实习系统设计题通常不会要求完整大厂方案,更看重你能否拆模块、讲流程、考虑缓存和异常。

7. 设计一个简单图片加载器,你会怎么做?

考察点:模块拆分、缓存意识

完整回答

可以按以下模块拆:

  1. 接口层:提供 load(url).into(imageView) 这样的调用方式。
  2. 内存缓存:使用 LruCache 缓存 Bitmap,避免重复解码和请求。
  3. 磁盘缓存:缓存下载后的图片文件,减少网络请求。
  4. 网络加载:通过 OkHttp 下载图片。
  5. 解码压缩:根据 ImageView 尺寸采样,避免加载过大图片导致 OOM。
  6. 线程切换:下载和解码在子线程,最终回到主线程设置图片。

追问:列表中图片错位怎么办?

RecyclerView item 复用时,ImageView 可能已经绑定了新的 URL。设置图片前要校验当前 ImageView 绑定的 URL 是否还是请求发起时的 URL。

8. Feed 列表分页加载怎么设计?

考察点:列表分页、异常处理

完整回答

基础方案:

  • 首次进入页面请求第一页数据。
  • 滑动到底部附近触发加载下一页。
  • 使用 page/pageSizecursor 作为分页参数。
  • Adapter 增量追加数据,避免全量刷新。
  • 页面维护 loading、empty、error、content 状态。

异常处理:

  • 首次加载失败显示重试页。
  • 下一页加载失败显示底部重试。
  • 防止重复请求,加载中不再次触发。
  • 下拉刷新时清空旧分页状态,重新请求第一页。

加分点:可以结合本地缓存,让弱网下先展示缓存数据,再刷新网络数据。

项目深度面试表达

社招面试评估维度

面试官关注什么
  • 技术深度:不只是会用,要理解原理和源码
  • 解决问题能力:遇到复杂问题的分析思路和解决过程
  • 技术广度:对相关技术栈的了解程度
  • 工程能力:代码质量、架构设计、性能优化
  • 业务理解:技术方案如何服务业务目标
  • 沟通表达:能否清晰地描述技术方案
社招 vs 校招

社招更看重:

  • 项目经验的深度(不是做了什么,而是怎么做的、为什么这么做)
  • 解决实际问题的能力(线上问题排查、性能优化实战)
  • 技术选型和架构设计能力
  • 团队协作和推动能力

STAR 框架

用 STAR 框架描述项目经验:

  • S(Situation):背景是什么?面临什么问题?
  • T(Task):你的任务/目标是什么?
  • A(Action):你具体做了什么?技术方案是什么?
  • R(Result):结果如何?数据指标提升了多少?
示例:启动优化
  • S:App 冷启动时间 3.5 秒,用户流失率高,产品要求优化到 2 秒以内
  • T:负责启动优化专项,目标将冷启动时间降低 40%
  • A
    1. 用 Systrace 分析启动全链路,发现 Application.onCreate 中 15 个 SDK 串行初始化耗时 1.8 秒
    2. 设计了基于 DAG 的任务编排框架,将 SDK 初始化拆分为有依赖关系的任务图,无依赖的并行执行
    3. 非必要 SDK 延迟到首帧后通过 IdleHandler 初始化
    4. 合并 6 个 ContentProvider 为 1 个(App Startup)
    5. 布局优化:首页 ViewStub 延迟加载非首屏内容
  • R:冷启动时间从 3.5 秒降到 1.8 秒,降幅 48%。启动相关的用户流失率下降 15%

技术方案表达结构

描述一个技术方案时,按以下结构:

问题 → 分析 → 方案 → 结果
  1. 问题:遇到了什么问题?(用数据说话)
  2. 分析:问题的根因是什么?(分析过程)
  3. 方案:怎么解决的?(技术细节)
  4. 结果:效果如何?(量化数据)
方案对比

描述技术选型时,展示你考虑了多个方案:

"我们考虑了三个方案:

  • 方案 A:xxx,优点是 xxx,缺点是 xxx
  • 方案 B:xxx,优点是 xxx,缺点是 xxx
  • 方案 C:xxx,优点是 xxx,缺点是 xxx

最终选择了方案 B,因为在我们的场景下 xxx 更重要。"

这展示了你的技术判断力,而不是只会执行。

数据量化

所有优化结果都要用数据说话:

  • "启动时间从 3.5s 降到 1.8s"(而不是"启动变快了")
  • "Crash 率从 0.3% 降到 0.05%"
  • "内存峰值从 280MB 降到 180MB"
  • "列表滑动帧率从 45fps 提升到 58fps"

常见场景题应对

"介绍一个你做过的最有挑战的项目"

选择标准:

  • 有技术深度(不是简单的 CRUD)
  • 有明确的问题和解决过程
  • 有量化的结果

回答结构:

  1. 一句话概括项目和你的角色
  2. 面临的核心挑战
  3. 你的分析和解决方案(技术细节)
  4. 最终结果和收获
"你在项目中遇到过最难的 bug"

回答结构:

  1. bug 的现象(线上还是开发阶段?影响范围?)
  2. 排查过程(用了什么工具?怎么定位的?)
  3. 根因分析(为什么会出现?)
  4. 解决方案(怎么修复?怎么防止再次发生?)

示例: "线上出现偶发的 ANR,影响约 0.1% 的用户。通过分析 traces.txt 发现主线程在等待一个锁,持有锁的线程在做数据库查询。根因是 SharedPreferences 的 apply 在 Activity onStop 时同步等待写入完成(QueuedWork.waitToFinish)。解决方案是将 SP 替换为 MMKV,ANR 率下降了 80%。同时建立了主线程 IO 检测机制(StrictMode + 自定义 Lint 规则),防止类似问题再次出现。"

"你怎么保证代码质量"
  • 代码规范:团队统一的编码规范 + Lint 检查
  • Code Review:PR 必须至少一人审核
  • 测试:单元测试覆盖核心逻辑,UI 测试覆盖关键流程
  • CI/CD:自动化构建 + 测试 + 质量门禁
  • 监控:线上 Crash/ANR/性能指标监控
"你对未来的技术规划"
  • 短期:深入某个技术方向(如 Compose、性能优化、跨平台)
  • 中期:从单点技术到系统性的架构能力
  • 长期:技术影响力(开源、技术分享、团队建设)

薪资谈判

基本原则
  • 了解市场行情(同级别同城市的薪资范围)
  • 不要先报价,让对方先出
  • 基于当前薪资 + 涨幅期望谈,而不是凭空要价
  • 考虑总包(base + 奖金 + 股票 + 福利)
常见话术

"我目前的期望是 xxx,这个是基于我的技术能力、项目经验以及市场行情综合考虑的。当然,我也很看重团队和成长空间,如果整体 package 合理,我是有一定灵活度的。"

反问环节

好的反问能展示你的思考深度:

  • "团队目前最大的技术挑战是什么?"
  • "这个岗位未来半年的核心目标是什么?"
  • "团队的技术栈和架构演进方向?"
  • "团队的 Code Review 和质量保障流程是怎样的?"

避免问:

  • 加班多吗?(可以委婉地问工作节奏)
  • 薪资多少?(HR 面再谈)

1. 介绍一个你做过的最有技术深度的项目

考察点:项目经验深度、技术方案能力

回答模板

"我负责过 xxx 项目的 xxx 模块。

背景:(一句话说清楚项目和问题) 我们的 App 冷启动时间达到 3.5 秒,远超行业标准的 2 秒,用户反馈启动慢,启动阶段的用户流失率达到 12%。

我的任务:作为性能优化负责人,目标是将冷启动时间降到 2 秒以内。

我做了什么

  1. 首先用 Systrace 分析启动全链路,定位到三个主要瓶颈:Application 中 15 个 SDK 串行初始化(1.8s)、6 个 ContentProvider(300ms)、首页布局复杂(400ms)
  2. 设计了基于 DAG 的任务编排框架,将 SDK 初始化拆分为有依赖关系的任务图。无依赖的任务并行执行,有依赖的等前置完成后执行。非必要 SDK 延迟到首帧后通过 IdleHandler 初始化
  3. 用 App Startup 合并 6 个 ContentProvider 为 1 个
  4. 首页布局优化:ViewStub 延迟加载非首屏 Tab,异步 inflate 复杂子布局

结果:冷启动时间从 3.5s 降到 1.8s,降幅 48%。启动流失率从 12% 降到 7%。任务编排框架被其他业务线复用。"

追问应对

  • "任务编排框架怎么处理循环依赖?" → 拓扑排序时检测环,编译期报错
  • "怎么衡量优化效果?" → 线上埋点统计 P50/P90/P99 启动时间,AB 实验对比
  • "还有什么可以继续优化的?" → 类预加载、布局预编译、Baseline Profile

2. 你遇到过最难排查的线上问题是什么?

考察点:问题排查能力

回答模板

"线上出现一个偶发的 ANR 问题,影响约 0.1% 的用户,但集中在低端机上。

排查过程

  1. 从监控平台拉取 ANR 的 traces.txt,发现主线程堆栈停在 QueuedWork.waitToFinish()
  2. 分析源码发现这是 SharedPreferences 的 apply 机制——apply 是异步的,但 Activity onStop 时会同步等待所有 apply 完成
  3. 进一步排查发现,我们的埋点模块在每次页面切换时都会 SP.apply 写入数据,低端机磁盘 IO 慢,导致 onStop 时等待时间过长

解决方案

  1. 将埋点模块的 SP 替换为 MMKV(mmap 写入,不阻塞主线程)
  2. 其他高频写入的 SP 也逐步迁移到 MMKV
  3. 建立主线程 IO 检测机制:StrictMode 在 Debug 包开启,自定义 Lint 规则检查主线程 SP 调用

结果:ANR 率从 0.15% 降到 0.03%,低端机 ANR 率下降 85%。"

3. 你怎么做技术选型?举个例子

考察点:技术判断力

回答模板

"以我们选择状态管理方案为例。

背景:项目从 Java 迁移到 Kotlin,需要选择新的架构模式和状态管理方案。

候选方案

  1. MVVM + 多个 LiveData:简单直接,但状态分散,复杂页面难以管理
  2. MVI + 单一 StateFlow:状态集中,单向数据流可预测,但模板代码多
  3. MVVM + 部分 MVI 思想:简单页面用多个 StateFlow,复杂页面用单一 State

评估维度

  • 团队学习成本:团队熟悉 MVVM,纯 MVI 学习曲线陡
  • 代码复杂度:纯 MVI 对简单页面过度设计
  • 可维护性:单一 State 在复杂页面更易维护和调试

最终选择:方案 3。制定了规范:3 个以下状态的页面用独立 StateFlow,3 个以上用单一 State + sealed Intent。

结果:团队适应快,代码一致性好,新人上手也容易理解。"

4. 你怎么保证代码质量?

考察点:工程化意识

完整回答

"我从四个层面保证代码质量:

  1. 编码规范:团队统一的 Kotlin 编码规范,用 ktlint + Detekt 自动检查。自定义了几条项目特定的 Lint 规则(如禁止主线程 SP 操作、禁止直接 new Thread)。

  2. Code Review:所有 PR 必须至少一人审核通过。Review 重点关注:架构合理性、边界条件处理、性能隐患、线程安全。

  3. 自动化测试:ViewModel 和 Repository 层单元测试覆盖率 >70%。关键业务流程有 UI 自动化测试。CI 上每次 PR 自动运行测试。

  4. 线上监控: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,收获很大。离开主要是因为:

  1. 技术方向:我希望在 xxx 方向深入发展,而当前团队的技术栈和业务方向与我的规划有一定差距
  2. 成长空间:当前的角色已经比较稳定,我希望能接触更大规模/更有挑战的项目

选择贵公司是因为:xxx 业务的技术挑战很吸引我,团队的技术氛围和成长空间也是我看重的。"

7. 你有什么想问我的?

考察点:对岗位的思考深度

好的反问

  1. "团队目前面临的最大技术挑战是什么?我能在哪些方面贡献价值?"
  2. "这个岗位未来半年的核心目标和期望产出是什么?"
  3. "团队的技术栈演进方向?比如 Compose 和 KMM 的落地计划?"
  4. "团队的协作方式是怎样的?Code Review 流程?"
  5. "新人入职后的 onboarding 流程和成长路径?"

避免的反问

  • 直接问加班(可以问"团队的工作节奏和迭代周期")
  • 直接问薪资(HR 面谈)
  • 过于基础的问题(显得没做功课)

8. 你觉得自己的优势和不足是什么?

回答模板

"优势:

  1. 技术深度:对 Android Framework 层有较深的理解,能从源码层面分析和解决问题
  2. 问题解决:擅长排查复杂的线上问题,有系统性的分析方法论
  3. 工程意识:注重代码质量和工程化,推动过团队的 CI/CD 和质量体系建设

不足: 跨平台技术(Flutter/KMM)的实战经验还不够深入,目前在学习和实践中。我的计划是在接下来的项目中找机会落地,通过实际项目来加深理解。"

注意:不足要说真实的,但要是可以改进的,并且要说明你的改进计划。

实习面试的项目追问通常比源码追问更重要。不要只说“我用了 Retrofit、Room、MVVM”,要说清楚你负责什么、为什么这么做、遇到什么问题。

9. 实习生如何介绍自己的 Android 项目?

考察点:项目表达、真实性

完整回答

可以按这个结构介绍:

  1. 项目背景:这个 App 解决什么问题,主要用户是谁。
  2. 我的职责:负责哪些页面或模块,不要把团队工作都说成自己做的。
  3. 技术栈:Kotlin/Java、MVVM、Retrofit、Room、RecyclerView、Compose 等。
  4. 核心流程:数据从哪里来,怎么请求、缓存、展示。
  5. 遇到的问题:至少准备 2 个真实问题,比如列表卡顿、接口错误处理、图片加载、状态丢失。
  6. 结果和收获:功能完成情况、性能改善、代码结构优化、学到了什么。

示例回答

“我的项目是一个课程/记账/新闻类 App,我主要负责首页列表、详情页和登录状态保存。项目使用 MVVM 分层,ViewModel 负责页面状态,Repository 统一处理网络和本地缓存。首页列表用 RecyclerView 展示,网络层用 Retrofit 请求接口,登录 token 保存在 DataStore。开发中遇到过列表刷新闪烁的问题,后来改成 DiffUtil 做局部刷新,并把图片加载交给 Glide 处理,滚动流畅度明显更好。”

10. 项目中没有很复杂的难点怎么办?

考察点:问题提炼能力

完整回答

实习项目不一定要有很复杂的架构难点,可以从真实细节中提炼:

  • 列表性能:全量刷新、图片过大、滑动卡顿。
  • 状态管理:旋转屏幕数据丢失、重复请求、加载/空/错误状态混乱。
  • 网络异常:超时、无网、token 过期、接口字段为空。
  • 生命周期:Fragment View 泄漏、页面退出后回调更新 UI。
  • 工程规范:封装网络层、统一错误处理、抽取 BaseAdapter。

关键是讲出“问题现象 → 排查过程 → 解决方案 → 为什么有效”。

11. 面试最后反问可以问什么?

考察点:职业意识、沟通能力

完整回答

适合实习生反问的问题:

  • 团队 Android 技术栈主要是 Kotlin 还是 Java?是否使用 Compose?
  • 实习生入组后通常会负责什么类型的任务?
  • 团队对新人会有代码 Review 或导师机制吗?
  • 这个岗位更看重 Android 基础、项目能力还是算法能力?
  • 如果有幸加入,我入职前最应该补强哪一块?

避免一上来只问薪资、加班、转正概率。可以等 HR 面或合适时机再问。

LLM 与 AI-Coding

内容较多,已拆分为独立文件:

  • llm-fundamentals.md — Transformer 架构、注意力机制、Token 与分词、训练三阶段(预训练/SFT/RLHF)、推理过程、上下文窗口
  • ai-coding-deep.md — AI Coding 原理(代码补全/对话式编程/Agent)、RAG 与代码检索、Prompt Engineering、Function Calling 与 MCP、AI Coding 工具架构

为什么 Android 开发者需要了解 LLM

  • AI Coding 工具(Cursor、Claude Code、GitHub Copilot)已成为日常开发工具
  • 理解 LLM 原理有助于更好地使用这些工具(写出更好的 Prompt、理解工具的局限性)
  • 越来越多的 App 需要集成 AI 能力(端侧模型、API 调用)
  • 面试中 AI 相关问题越来越常见

核心概念速览

LLM(Large Language Model):大语言模型,通过海量文本训练,能理解和生成自然语言/代码
Transformer:LLM 的核心架构,基于注意力机制
Token:模型处理文本的最小单位(不是字,不是词,是子词片段)
Prompt:你给模型的输入(提示词)
Context Window:模型一次能处理的最大 Token 数
Inference:推理,模型根据输入生成输出的过程
Fine-tuning:微调,在预训练模型基础上用特定数据继续训练
RAG:检索增强生成,先检索相关文档再让模型生成回答
Agent:让模型使用工具、执行多步任务的框架
Function Calling:模型调用外部函数/API 的能力
MCP:Model Context Protocol,标准化的模型-工具交互协议

Transformer 架构

为什么 Transformer 能成功

在 Transformer 之前,处理序列数据(文本、代码)主要用 RNN/LSTM:

RNN 的问题:
  输入: "用户 点击 按钮 触发 网络 请求"
  处理: 用户 → 点击 → 按钮 → 触发 → 网络 → 请求
        ↑ 串行处理,一个一个来
        → 慢(不能并行)
        → 长距离依赖丢失(处理"请求"时已经忘了"用户")

Transformer 的方式:
  输入: "用户 点击 按钮 触发 网络 请求"
  处理: 所有词同时处理,每个词都能直接看到其他所有词
        → 快(完全并行)
        → 长距离依赖保留("请求"能直接关注到"用户")
核心组件
Transformer 的结构(以 GPT 类模型为例,只用 Decoder):

输入 Token 序列

Token Embedding(把 Token 变成向量)+ Position Embedding(加上位置信息)

┌─────────────────────────────────┐
│  Transformer Block × N 层       │  ← GPT-3 有 96 层,GPT-4 更多
│                                 │
│  ┌───────────────────────────┐  │
│  │ Masked Self-Attention     │  │  ← 核心:每个 Token 关注其他 Token
│  └───────────────────────────┘  │
│           ↓                     │
│  ┌───────────────────────────┐  │
│  │ Feed-Forward Network      │  │  ← 两层全连接网络
│  └───────────────────────────┘  │
│           ↓                     │
│  (每层都有 Layer Norm + 残差连接)│
└─────────────────────────────────┘

Linear + Softmax

输出:下一个 Token 的概率分布
Self-Attention(自注意力)

这是 Transformer 最核心的机制。直觉理解:

句子:"The cat sat on the mat because it was tired"

当模型处理 "it" 这个词时,需要知道 "it" 指的是什么
Self-Attention 让 "it" 去关注句子中的每个词,计算相关度:

  "it" 关注 "cat"    → 相关度 0.7(高!it 指的是 cat)
  "it" 关注 "mat"    → 相关度 0.1(低)
  "it" 关注 "sat"    → 相关度 0.05(低)
  "it" 关注 "tired"  → 相关度 0.1
  ...

通过这种方式,"it" 的表示中融入了 "cat" 的信息
模型就理解了 "it = cat"

数学上的实现:

每个 Token 生成三个向量:
  Q(Query):我在找什么?
  K(Key):我能提供什么?
  V(Value):我的实际内容

计算过程:
  1. 每个 Token 的 Q 和所有 Token 的 K 做点积 → 得到注意力分数
  2. 分数除以 √d(缩放)→ Softmax 归一化 → 得到注意力权重
  3. 用权重对所有 Token 的 V 加权求和 → 得到输出

公式:Attention(Q, K, V) = softmax(QK^T / √d) × V
具体例子(简化为 2 维向量):

Token:    "cat"    "sat"    "it"
Q:        [1, 0]   [0, 1]   [1, 0.5]
K:        [1, 0]   [0, 1]   [0.5, 0.5]
V:        [0.8, 0.2] [0.1, 0.9] [0.5, 0.5]

计算 "it" 对其他 Token 的注意力:
  "it" 的 Q = [1, 0.5]

  Q · K_cat = [1,0.5] · [1,0] = 1.0     ← 高分,it 关注 cat
  Q · K_sat = [1,0.5] · [0,1] = 0.5     ← 中等
  Q · K_it  = [1,0.5] · [0.5,0.5] = 0.75

  Softmax([1.0, 0.5, 0.75]) = [0.42, 0.24, 0.34]

  "it" 的输出 = 0.42 × V_cat + 0.24 × V_sat + 0.34 × V_it
             = 0.42 × [0.8,0.2] + 0.24 × [0.1,0.9] + 0.34 × [0.5,0.5]
             = [0.53, 0.47]
  → "it" 的表示融合了 cat 的信息(权重最高)
Multi-Head Attention(多头注意力)

一个注意力头只能关注一种关系。多头注意力让模型同时关注多种关系:

Head 1:关注语法关系("it" → "cat",代词指代)
Head 2:关注位置关系("it" → "was",相邻词)
Head 3:关注语义关系("tired" → "cat",谁累了)
...

每个 Head 独立计算 Attention,最后拼接起来:
  MultiHead = Concat(Head1, Head2, ..., HeadN) × W_O

GPT-3 有 96 个 Head,能同时捕捉 96 种不同的关系
Masked Self-Attention(因果注意力)

GPT 类模型生成文本时,只能看到前面的 Token,不能看到后面的:

生成 "The cat sat on" 时:

"The" 只能看到: [The]
"cat" 只能看到: [The, cat]
"sat" 只能看到: [The, cat, sat]
"on"  只能看到: [The, cat, sat, on]

通过 Mask 矩阵实现:
       The  cat  sat  on
The  [  1    0    0    0  ]   ← The 只看自己
cat  [  1    1    0    0  ]   ← cat 看 The 和自己
sat  [  1    1    1    0  ]   ← sat 看前三个
on   [  1    1    1    1  ]   ← on 看所有前面的

0 的位置在 Softmax 前设为 -∞,Softmax 后变成 0(完全不关注)
Position Embedding(位置编码)

Self-Attention 本身不知道词的顺序("cat sat" 和 "sat cat" 对它来说一样),需要额外加上位置信息:

Token Embedding:    "cat" → [0.8, 0.2, 0.5, ...]
Position Embedding: pos=2 → [0.1, -0.3, 0.2, ...]
最终输入:                    [0.9, -0.1, 0.7, ...]  ← 相加

现代模型(如 LLaMA)用 RoPE(旋转位置编码):
  不是加法,而是对 Q 和 K 做旋转变换
  优点:能更好地表示相对位置,支持外推到更长的序列

Token 与分词

什么是 Token

模型不直接处理文字,而是处理 Token(子词片段):

英文分词(BPE 算法):
  "unhappiness" → ["un", "happiness"]     ← 2 个 Token
  "Hello world" → ["Hello", " world"]     ← 2 个 Token
  "ChatGPT"     → ["Chat", "G", "PT"]     ← 3 个 Token

中文分词:
  "你好世界" → ["你", "好", "世", "界"]     ← 4 个 Token(每个字一个)
  "人工智能" → ["人工", "智能"]             ← 2 个 Token(常见词合并)

代码分词:
  "System.out.println" → ["System", ".", "out", ".", "print", "ln"]  ← 6 个 Token
  "getUserById"        → ["get", "User", "By", "Id"]                ← 4 个 Token
BPE(Byte Pair Encoding)分词算法
训练过程(在大量文本上统计):

1. 初始词表:所有单个字符 [a, b, c, ..., z, A, B, ..., 空格, 换行, ...]

2. 统计相邻字符对的频率:
   "th" 出现 10000 次 → 合并为 "th",加入词表
   "he" 出现 8000 次  → 合并为 "he",加入词表
   "the" 出现 7000 次 → 合并为 "the",加入词表
   ...

3. 重复直到词表达到目标大小(如 50000)

结果:高频词是一个 Token,低频词被拆成多个 Token
  "the"(超高频)→ 1 个 Token
  "Transformer"(中频)→ 2-3 个 Token
  "xyzzy"(罕见)→ 5 个 Token(逐字符)
Context Window(上下文窗口)
模型一次能处理的最大 Token 数:

GPT-3.5:    4K / 16K Token
GPT-4:      8K / 32K / 128K Token
Claude 3.5: 200K Token
Claude 4:   200K Token

1K Token ≈ 750 个英文单词 ≈ 500 个中文字

Context Window 的限制:
  输入 + 输出 的总 Token 数不能超过窗口大小
  超过了就需要截断或压缩

为什么有限制:
  Self-Attention 的计算量 = O(n²),n 是序列长度
  128K Token 的注意力矩阵 = 128K × 128K = 160 亿个元素
  需要大量 GPU 显存

训练三阶段

预训练(Pre-training)
目标:学习语言的通用知识
数据:互联网上的海量文本(万亿 Token)
方法:预测下一个 Token(自回归)

训练过程:
  输入: "The capital of France is"
  目标: "Paris"
  损失: 模型预测的概率分布 vs 真实的下一个 Token

  输入: "public static void main(String[]"
  目标: "args"

  重复几万亿次...

结果:模型学会了语法、常识、代码模式、推理能力等
      但它只是一个"补全机器",不会对话
预训练的规模:
  GPT-3: 175B 参数,300B Token,数千张 GPU 训练数月
  LLaMA 3: 405B 参数,15T Token
  训练成本:数千万到上亿美元
监督微调(SFT - Supervised Fine-Tuning)
目标:让模型学会对话格式
数据:人工标注的对话数据(几万到几十万条)

训练数据示例:
  User: "解释一下 Android 的 Handler 机制"
  Assistant: "Handler 是 Android 的消息传递机制..."

  User: "写一个单例模式"
  Assistant: "```kotlin\nobject Singleton { ... }\n```"

结果:模型从"补全机器"变成了"对话助手"
      知道什么时候该回答、什么格式回答
RLHF(Reinforcement Learning from Human Feedback)
目标:让模型的回答更符合人类偏好
方法:

1. 收集人类偏好数据:
   给同一个问题生成多个回答,人类标注哪个更好
   问题: "什么是协程?"
   回答 A: "协程是一种轻量级线程..." ← 人类标注:好
   回答 B: "协程就是 coroutine..."   ← 人类标注:差

2. 训练奖励模型(Reward Model):
   学习人类的偏好标准
   输入一个回答 → 输出一个分数(越高越好)

3. 用 PPO 算法优化 LLM:
   LLM 生成回答 → 奖励模型打分 → 调整 LLM 参数使得高分回答更可能被生成

结果:模型回答更有帮助、更安全、更符合人类期望

推理过程(Inference)

自回归生成

LLM 生成文本是一个 Token 一个 Token 地生成:

输入: "写一个 Android 的"

第 1 步: 模型计算 → 概率最高的下一个 Token 是 "单"
         当前序列: "写一个 Android 的单"

第 2 步: 模型计算 → 概率最高的下一个 Token 是 "例"
         当前序列: "写一个 Android 的单例"

第 3 步: 模型计算 → "模"
         当前序列: "写一个 Android 的单例模"

第 4 步: "式"
         ...

第 N 步: 生成 <EOS>(结束标记)→ 停止

每一步都要把整个序列重新过一遍 Transformer
所以生成越长的文本越慢
采样策略

模型每一步输出的是概率分布,怎么从中选一个 Token:

模型输出(下一个 Token 的概率):
  "模" → 0.35
  "例" → 0.25
  "类" → 0.15
  "方" → 0.10
  "的" → 0.05
  ...

Greedy(贪心):永远选概率最高的 → "模"
  确定性输出,但可能单调重复

Temperature(温度):
  T < 1.0:概率分布更尖锐(更确定,更保守)
    "模" → 0.60, "例" → 0.25, "类" → 0.10, ...
  T > 1.0:概率分布更平坦(更随机,更有创意)
    "模" → 0.25, "例" → 0.22, "类" → 0.18, ...
  T = 0:等于 Greedy

Top-p(核采样):
  只从累积概率达到 p 的 Token 中采样
  p = 0.9:从 "模"(0.35) + "例"(0.25) + "类"(0.15) + "方"(0.10) + "的"(0.05) = 0.90 中随机选
  排除了概率极低的 Token,避免生成离谱的内容

Top-k:
  只从概率最高的 k 个 Token 中采样
  k = 5:从前 5 个 Token 中随机选
KV Cache

每生成一个新 Token,都要重新计算所有之前 Token 的注意力。KV Cache 缓存之前的计算结果:

没有 KV Cache:
  生成第 100 个 Token 时,重新计算前 99 个 Token 的 K 和 V
  → O(n²) 计算量

有 KV Cache:
  前 99 个 Token 的 K 和 V 已经缓存了
  只需要计算第 100 个 Token 的 Q,和缓存的 K、V 做注意力
  → O(n) 计算量

代价:需要大量 GPU 显存存储缓存
  每层每个 Token 缓存 K 和 V 两个向量
  GPT-3(96 层,12288 维):每个 Token 的 KV Cache ≈ 4.7MB
  128K 上下文 ≈ 600GB KV Cache(需要多张 GPU)

模型参数与规模

"175B 参数"是什么意思:

Transformer 中的参数 = 所有权重矩阵中的数字
  Attention 的 Q/K/V 投影矩阵
  Feed-Forward 的两层全连接矩阵
  Embedding 矩阵
  Layer Norm 的参数

175B = 1750 亿个浮点数
  FP16 存储:175B × 2 字节 = 350GB
  需要多张 80GB 的 A100 GPU 才能加载

模型越大:
  能力越强(涌现能力:推理、代码、数学)
  推理越慢(更多计算)
  成本越高(更多 GPU)

常见模型规模:
  7B(LLaMA 7B):单张消费级 GPU 可运行
  13B:单张 A100 可运行
  70B:需要多张 GPU
  175B+:需要 GPU 集群

AI Coding 的三种形态

形态 1:代码补全(Autocomplete)
  你打字 → 模型预测你接下来要写什么 → 显示灰色建议
  例:GitHub Copilot 的 Tab 补全

形态 2:对话式编程(Chat)
  你用自然语言描述需求 → 模型生成代码
  例:ChatGPT、Claude 的代码对话

形态 3:AI Agent(自主编程)
  你描述目标 → Agent 自主规划、搜索代码、编辑文件、运行测试
  例:Claude Code、Cursor Agent、Devin

代码补全的原理

基本流程
你在编辑器中输入:
  fun calculateTotal(items: List<Item>): Double {
      return items.

光标在 "items." 后面

IDE 插件做的事:
  1. 收集上下文
     → 当前文件的内容(光标前后)
     → 打开的其他文件
     → 项目的语言、框架信息

  2. 构建 Prompt
     → <prefix>fun calculateTotal(items: List<Item>): Double {\n    return items.</prefix>
     → <suffix>\n}</suffix>
     → 加上相关文件片段作为上下文

  3. 发送给模型(Fill-in-the-Middle)
     → 模型看到前缀和后缀,预测中间应该填什么

  4. 模型返回
     → "sumOf { it.price }"

  5. IDE 显示灰色建议
     → 你按 Tab 接受
Fill-in-the-Middle(FIM)

代码补全不是简单的"预测下一个 Token",而是"根据前后文填空":

普通的自回归生成(只看前面):
  输入: "fun add(a: Int, b: Int): Int {"
  生成: "\n    return a + b\n}"

FIM(看前面和后面):
  前缀: "fun add(a: Int, b: Int): Int {\n    "
  后缀: "\n}"
  填空: "return a + b"
  → 生成的代码能和后面的 "}" 完美衔接

训练时的数据格式:
  <PRE>前缀内容<SUF>后缀内容<MID>中间内容
  模型学会了根据前后文生成中间部分
上下文收集策略
代码补全的质量取决于给模型多少有用的上下文:

1. 当前文件(最重要)
   → 光标前的代码(前缀)
   → 光标后的代码(后缀)

2. 相关文件
   → 当前文件 import 的文件
   → 最近编辑过的文件
   → 同目录下的文件

3. 项目信息
   → 语言、框架(Kotlin + Android + Compose)
   → 代码风格(命名规范、缩进)

4. LSP 信息
   → 类型信息(items 是 List<Item>,Item 有 price 字段)
   → 函数签名
   → 编译错误

上下文窗口有限,需要智能选择最相关的内容
→ 这就是不同 AI Coding 工具效果差异的关键

对话式编程的原理

System Prompt
每个 AI Coding 工具都有一个 System Prompt,定义模型的行为:

System: """
你是一个 Android 开发助手。
- 使用 Kotlin 语言
- 遵循 Android 最佳实践
- 代码要有适当的错误处理
- 优先使用 Jetpack 组件
"""

User: "帮我写一个网络请求的 Repository"

模型根据 System Prompt 的约束生成代码
不同工具的 System Prompt 不同 → 生成风格不同
多轮对话的上下文管理
对话历史会占用 Context Window:

Turn 1: User: "写一个 UserRepository"
        Assistant: "```kotlin\nclass UserRepository...\n```"  ← 500 Token

Turn 2: User: "加上缓存功能"
        Assistant: "```kotlin\nclass UserRepository...\n```"  ← 800 Token

Turn 3: User: "加上错误处理"
        → 此时模型的输入 = System Prompt + Turn1 + Turn2 + Turn3
        → 可能已经用了 5000 Token

对话越长,可用的 Context Window 越少
工具需要策略来管理:
  → 压缩早期对话
  → 只保留关键代码片段
  → 滑动窗口(丢弃最早的对话)

AI Agent 的原理

Agent 的核心循环
Agent 不是一次性生成代码,而是循环执行:

while (任务未完成) {
    1. 观察(Observe)
       → 读取文件、查看错误、检查测试结果

    2. 思考(Think)
       → 分析当前状态,决定下一步做什么

    3. 行动(Act)
       → 编辑文件、运行命令、搜索代码

    4. 反馈(Feedback)
       → 检查行动的结果,决定是否继续
}
实际例子:Claude Code 修复一个 Bug

用户: "RecyclerView 滑动时崩溃,修复一下"

Agent 循环:
  Round 1:
    观察: 读取崩溃日志 → IndexOutOfBoundsException in onBindViewHolder
    思考: 可能是数据源和 Adapter 不同步
    行动: 搜索项目中的 Adapter 代码
    反馈: 找到 UserAdapter.kt

  Round 2:
    观察: 读取 UserAdapter.kt
    思考: notifyDataSetChanged 在子线程调用,和 RecyclerView 的布局冲突
    行动: 修改代码,用 DiffUtil + submitList 替代
    反馈: 文件已修改

  Round 3:
    观察: 运行测试
    思考: 测试通过
    行动: 完成,向用户报告修复内容
Function Calling(函数调用)

Agent 能使用工具,靠的是 Function Calling:

模型不能直接读文件、运行命令
但它可以输出结构化的"工具调用请求"

模型的输出:
{
  "tool": "read_file",
  "arguments": {
    "path": "app/src/main/java/com/example/UserAdapter.kt"
  }
}

系统(IDE/CLI)执行这个调用,把结果返回给模型:
{
  "result": "class UserAdapter : RecyclerView.Adapter<...> {\n    ..."
}

模型看到文件内容后,决定下一步操作:
{
  "tool": "edit_file",
  "arguments": {
    "path": "app/src/main/java/com/example/UserAdapter.kt",
    "old_string": "notifyDataSetChanged()",
    "new_string": "submitList(newList)"
  }
}
Function Calling 的实现原理:

模型在训练时学会了一种特殊的输出格式:
  当需要使用工具时,输出 JSON 格式的工具调用
  而不是直接输出文本

System Prompt 中定义了可用的工具:
  tools: [
    {
      name: "read_file",
      description: "读取文件内容",
      parameters: { path: string }
    },
    {
      name: "edit_file",
      description: "编辑文件",
      parameters: { path: string, old_string: string, new_string: string }
    },
    {
      name: "run_command",
      description: "运行 shell 命令",
      parameters: { command: string }
    }
  ]

模型根据当前任务,选择合适的工具调用
MCP(Model Context Protocol)
问题:每个 AI 工具都要自己实现和外部系统的对接
  Cursor 要自己实现 Git 集成
  Claude Code 要自己实现文件系统操作
  每个工具都重复造轮子

MCP 的解决方案:标准化的协议

AI 模型 ←→ MCP 协议 ←→ MCP Server(工具提供者)

MCP Server 示例:
  Git MCP Server:提供 git_log、git_diff、git_commit 等工具
  Database MCP Server:提供 query、insert、update 等工具
  Jira MCP Server:提供 create_issue、update_status 等工具

任何 AI 工具只要支持 MCP 协议,就能使用所有 MCP Server
任何工具提供者只要实现 MCP Server,就能被所有 AI 工具使用
MCP 的通信流程:

1. AI 工具启动时,连接 MCP Server
   → Server 返回可用工具列表

2. 模型需要使用工具时
   → AI 工具通过 MCP 协议调用 Server
   → Server 执行操作,返回结果
   → 结果传回给模型

3. 协议格式(JSON-RPC):
   请求: {"method": "tools/call", "params": {"name": "git_diff", "arguments": {}}}
   响应: {"result": {"content": [{"type": "text", "text": "diff --git a/..."}]}}

RAG(检索增强生成)在代码中的应用

为什么需要 RAG
问题:模型的训练数据有截止日期,不知道你的项目代码

用户: "UserRepository 的 getUser 方法有什么问题?"
模型: "我不知道你的 UserRepository 长什么样..."

RAG 的解决方案:先检索相关代码,再让模型回答

1. 检索:在你的代码库中搜索 "UserRepository" 相关的文件
2. 注入:把找到的代码片段放入 Prompt
3. 生成:模型基于这些代码片段回答问题
代码检索的实现
代码库索引(离线建立):

1. 遍历项目所有代码文件
2. 把每个文件/函数/类切分为代码片段(Chunk)
3. 用 Embedding 模型把每个片段转为向量
4. 存入向量数据库

代码片段:                          向量:
"class UserRepository {"     →    [0.12, -0.34, 0.56, ...]
"fun getUser(id: Int)"       →    [0.45, 0.23, -0.12, ...]
"suspend fun fetchData()"    →    [-0.08, 0.67, 0.34, ...]

查询时:
  用户问题 "UserRepository 的缓存策略" → 转为向量 → 在向量数据库中找最相似的片段
  → 返回 UserRepository 相关的代码片段
  → 注入到 Prompt 中
Embedding(嵌入向量)
Embedding 模型把文本/代码转为固定长度的向量
语义相似的内容,向量也相似(余弦相似度高)

"fun getUser(id: Int): User"  → [0.45, 0.23, -0.12, ...]
"fun fetchUser(userId: Int)"  → [0.44, 0.25, -0.10, ...]  ← 相似!
"fun deleteAll()"             → [-0.30, 0.10, 0.80, ...]  ← 不相似

相似度计算:
  cosine_similarity(getUser, fetchUser) = 0.95(高,语义相近)
  cosine_similarity(getUser, deleteAll) = 0.12(低,语义不同)

Prompt Engineering 在 AI Coding 中的应用

好的 Prompt vs 差的 Prompt
❌ 差的 Prompt:
  "帮我写个网络请求"

✅ 好的 Prompt:
  "用 Kotlin + Retrofit + 协程写一个 UserRepository,
   包含 getUser(id) 和 getUsers(page) 两个方法,
   返回 Flow,有错误处理,
   参考项目中已有的 ArticleRepository 的风格"

好的 Prompt 包含:
  1. 技术栈(Kotlin + Retrofit + 协程)
  2. 具体需求(哪些方法,什么参数)
  3. 返回类型(Flow)
  4. 约束条件(错误处理)
  5. 参考示例(已有代码的风格)
Few-shot Prompting
给模型几个示例,让它学会你的代码风格:

"参考以下代码风格:

// 示例 1
class ArticleRepository @Inject constructor(
    private val api: ArticleApi,
    private val dao: ArticleDao
) {
    fun getArticles(): Flow<Result<List<Article>>> = flow {
        emit(Result.Loading)
        try {
            val articles = api.getArticles()
            dao.insertAll(articles)
            emit(Result.Success(articles))
        } catch (e: Exception) {
            emit(Result.Error(e))
        }
    }
}

现在用同样的风格写一个 UserRepository"

模型会模仿示例的:
  命名规范、错误处理方式、Flow 的使用方式、Result 封装
Chain-of-Thought(思维链)
让模型先分析再写代码:

"分析这个崩溃日志,一步步思考可能的原因,然后给出修复方案:

java.lang.IndexOutOfBoundsException: Inconsistency detected.
    at RecyclerView.Recycler.tryGetViewHolderForPositionByDeadline
    at RecyclerView.fill
    at LinearLayoutManager.onLayoutChildren

请先分析:
1. 这个异常通常是什么原因导致的?
2. 在什么场景下会触发?
3. 项目中哪些代码可能有问题?
4. 最佳的修复方案是什么?"

模型会逐步推理,而不是直接给一个可能不准确的答案

AI Coding 工具的架构

典型架构
┌─────────────────────────────────────────────┐
│                IDE / CLI                     │
│                                             │
│  ┌─────────┐  ┌──────────┐  ┌───────────┐  │
│  │代码补全  │  │对话面板   │  │Agent 模式 │  │
│  └────┬────┘  └────┬─────┘  └─────┬─────┘  │
│       │            │              │         │
│  ┌────┴────────────┴──────────────┴────┐    │
│  │         上下文收集引擎               │    │
│  │  ┌──────┐ ┌──────┐ ┌──────────┐    │    │
│  │  │当前文件│ │LSP   │ │代码索引   │    │    │
│  │  └──────┘ └──────┘ └──────────┘    │    │
│  └─────────────────┬───────────────────┘    │
│                    │                        │
│  ┌─────────────────┴───────────────────┐    │
│  │         Prompt 构建引擎              │    │
│  └─────────────────┬───────────────────┘    │
└────────────────────┼────────────────────────┘
                     │ API 调用

              ┌──────────────┐
              │   LLM API    │
              │ (Claude/GPT) │
              └──────────────┘
不同工具的差异
GitHub Copilot:
  强项:代码补全(FIM 模型专门优化)
  模型:Codex / GPT-4 系列
  上下文:当前文件 + 打开的 Tab

Cursor:
  强项:整个项目的上下文理解
  模型:Claude / GPT-4 可切换
  上下文:代码库索引 + RAG 检索 + 当前文件

Claude Code(CLI):
  强项:Agent 模式,自主完成复杂任务
  模型:Claude
  上下文:文件系统访问 + Shell 命令 + 工具调用
  特点:在终端中运行,直接操作文件系统

端侧 AI(On-Device LLM)

为什么要在手机上跑模型
云端 API 的问题:
  → 需要网络连接
  → 有延迟(几百 ms 到几秒)
  → 隐私问题(数据发送到服务器)
  → API 调用成本

端侧模型的优势:
  → 离线可用
  → 低延迟(本地推理)
  → 数据不出设备
  → 无 API 成本
端侧模型的技术挑战
手机的限制:
  内存:8-16GB(模型 + App + 系统共享)
  算力:GPU/NPU 远弱于服务器 GPU
  功耗:不能持续高负载

解决方案:

1. 模型量化(Quantization)
   FP32(32位浮点)→ INT8(8位整数)→ INT4(4位整数)
   7B 模型:FP16 = 14GB → INT4 = 3.5GB(可以在手机上跑)
   精度损失很小,速度提升 2-4 倍

2. 小模型
   Gemma 2B、Phi-3 Mini 3.8B、LLaMA 3.2 1B/3B
   专门为端侧设计的小模型

3. 硬件加速
   Android: NNAPI → GPU / NPU / DSP
   iOS: Core ML → Neural Engine
   高通: QNN SDK → Hexagon NPU
Android 端侧 AI 集成
kotlin
// Google AI Edge(MediaPipe LLM Inference API)
val llmInference = LlmInference.createFromOptions(
    context,
    LlmInference.LlmInferenceOptions.builder()
        .setModelPath("/path/to/gemma-2b-it-q4.bin")
        .setMaxTokens(1024)
        .build()
)

// 推理
val response = llmInference.generateResponse("解释 ViewModel 的作用")

// 流式输出
llmInference.generateResponseAsync("写一个单例模式") { partialResult ->
    // 逐 Token 回调
    textView.append(partialResult)
}

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,模型就能基于你的实际代码给出准确的建议。

实现方式:

  1. 离线阶段:把项目代码切分为片段,用 Embedding 模型转为向量,存入向量数据库
  2. 在线阶段:用户提问 → 问题转为向量 → 在向量数据库中找最相似的代码片段 → 注入 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 的训练分哪几个阶段?

回答

三个阶段:

  1. 预训练:在海量文本(万亿 Token)上训练,学习语言的通用知识。任务是预测下一个 Token。训练完后模型是一个"补全机器",能力很强但不会对话。

  2. SFT(监督微调):用人工标注的对话数据(几万到几十万条)训练,让模型学会对话格式。训练完后模型知道什么时候该回答、用什么格式回答。

  3. 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)有什么挑战?

回答

主要挑战是手机的硬件限制:

  1. 内存:7B 模型 FP16 需要 14GB,手机总共才 8-16GB。解决方案是量化——把 FP16 压缩到 INT4,7B 模型只需要 3.5GB。

  2. 算力:手机 GPU/NPU 远弱于服务器 GPU,推理速度慢。解决方案是用更小的模型(1B-3B)+ 硬件加速(NPU)。

  3. 功耗:持续推理会快速耗电发热。需要控制推理频率和模型大小。

目前端侧适合轻量级任务(文本分类、简单问答、输入建议),复杂的代码生成还是需要云端大模型。

追问:Android 上怎么集成端侧模型?

可以用 Google 的 MediaPipe LLM Inference API 或 TensorFlow Lite,加载量化后的模型(如 Gemma 2B INT4)。通过 NNAPI 调用 GPU/NPU 加速推理。也可以用 llama.cpp 的 Android 移植版本运行 LLaMA 系列模型。

9. 如何更好地使用 AI Coding 工具?

回答

几个关键原则:

  1. 提供充足的上下文:不要只说"写个网络请求",要说明技术栈、参数、返回类型、错误处理方式、参考已有代码的风格。

  2. 分步骤提问:复杂任务拆成小步骤,每步确认后再继续,而不是一次性要求生成整个功能。

  3. 给示例(Few-shot):给模型一两个你项目中已有的代码示例,让它模仿风格。

  4. 审查生成的代码:AI 生成的代码可能有逻辑错误、安全漏洞、性能问题。不要盲目接受,要理解每一行代码。

  5. 理解局限性:模型不知道你的运行时状态、不会执行代码、可能产生幻觉(编造不存在的 API)。把它当作一个很强的助手,而不是万能的。

10. Prompt Engineering 有哪些实用技巧?

回答

  1. 明确角色:在 Prompt 开头定义模型的角色,比如"你是一个资深 Android 开发者"。

  2. 结构化需求:用列表或编号明确列出需求,而不是一段模糊的描述。

  3. Chain-of-Thought:让模型"一步步思考",先分析问题再给方案,比直接要答案更准确。

  4. 约束条件:明确告诉模型不要做什么,比如"不要使用已废弃的 API"、"不要添加额外的依赖"。

  5. 迭代优化:第一次生成不满意,不要重新开始,而是在对话中追加修改要求,利用上下文逐步完善。

追问:为什么 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 编造接口字段、业务规则或线上结论。
  • 不做运行验证就合并代码。

算法与数据结构

社招面试中算法题以 LeetCode Medium 为主,偶尔 Easy/Hard。重点不是刷题量,而是掌握每种题型的核心思路和模板。

数组与双指针

核心思路

  • 左右指针:从两端向中间逼近,常用于有序数组、容器盛水类问题
  • 快慢指针:一快一慢,用于链表环检测、删除倒数第 N 个节点
  • 滑动窗口:维护一个窗口 [left, right),right 扩张、left 收缩,用于子串/子数组问题

滑动窗口模板

java
int left = 0;
Map<Character, Integer> window = new HashMap<>();

for (int right = 0; right < s.length(); right++) {
    char c = s.charAt(right);
    window.merge(c, 1, Integer::sum); // 扩张:加入右边元素

    while (窗口需要收缩) {
        char d = s.charAt(left);
        window.merge(d, -1, Integer::sum); // 收缩:移除左边元素
        left++;
    }
    // 在这里更新结果
}

经典题目

题目思路难度
两数之和HashMap 存已遍历的值Easy
三数之和排序 + 固定一个数 + 左右指针Medium
盛最多水的容器左右指针,移动较短的一边Medium
无重复字符的最长子串滑动窗口 + HashSetMedium
最小覆盖子串滑动窗口 + 计数器Hard
合并区间按起点排序,逐个合并Medium
下一个排列从右找第一个降序位,交换后翻转Medium

链表

核心思路

  • 虚拟头节点(dummy):简化头节点的边界处理
  • 快慢指针:找中点、判环、找环入口
  • 反转链表:迭代(prev/curr/next 三指针)或递归
  • 合并链表:归并思想

反转链表模板

java
ListNode prev = null, curr = head;
while (curr != null) {
    ListNode next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
}
return prev;

经典题目

题目思路难度
反转链表三指针迭代Easy
反转链表 II(区间反转)找到区间,断开反转再接回Medium
合并两个有序链表dummy + 逐个比较Easy
合并 K 个有序链表小顶堆 / 分治归并Hard
环形链表 II快慢指针相遇后,头和相遇点同速走Medium
LRU 缓存HashMap + 双向链表Medium
相交链表两指针各走一遍对方的路Easy
K 个一组翻转链表分组 + 组内反转 + 拼接Hard

哈希表

核心思路

  • 用空间换时间,O(1) 查找
  • 计数:统计频率、判断异位词
  • 映射:两数之和、字符对应关系
  • 去重:判断是否出现过

经典题目

题目思路难度
两数之和遍历时查 HashMapEasy
字母异位词分组排序后的字符串作为 keyMedium
最长连续序列HashSet + 只从起点开始计数Medium
前 K 个高频元素HashMap 计数 + 小顶堆/桶排序Medium

栈与队列

核心思路

  • 单调栈:维护一个单调递增/递减的栈,用于"下一个更大/更小元素"类问题
  • 辅助栈:括号匹配、表达式求值、最小栈
  • 栈模拟:DFS、路径简化

单调栈模板(下一个更大元素)

java
int[] result = new int[nums.length];
Deque<Integer> stack = new ArrayDeque<>(); // 存下标

for (int i = nums.length - 1; i >= 0; i--) {
    while (!stack.isEmpty() && nums[stack.peek()] <= nums[i]) {
        stack.pop(); // 弹出比当前小的
    }
    result[i] = stack.isEmpty() ? -1 : nums[stack.peek()];
    stack.push(i);
}

经典题目

题目思路难度
有效的括号栈匹配Easy
每日温度单调递减栈Medium
柱状图中最大的矩形单调递增栈,找左右边界Hard
最小栈辅助栈同步记录最小值Medium
接雨水单调栈 / 双指针 / 动态规划Hard
字符串解码双栈(数字栈 + 字符串栈)Medium

二叉树

核心思路

  • 递归三要素:①返回值和参数 ②终止条件 ③单层逻辑
  • 遍历方式:前序(根左右)、中序(左根右)、后序(左右根)、层序(BFS)
  • 分治思想:把问题拆成左子树和右子树分别解决,再合并结果

递归模板

java
// 自顶向下(传参数下去)
void traverse(TreeNode node, int depth) {
    if (node == null) return;
    // 前序位置:处理当前节点
    traverse(node.left, depth + 1);
    // 中序位置
    traverse(node.right, depth + 1);
    // 后序位置
}

// 自底向上(收集返回值)
int maxDepth(TreeNode node) {
    if (node == null) return 0;
    int left = maxDepth(node.left);
    int right = maxDepth(node.right);
    return Math.max(left, right) + 1; // 后序位置合并结果
}

经典题目

题目思路难度
二叉树的最大深度递归:max(左, 右) + 1Easy
翻转二叉树递归交换左右子树Easy
对称二叉树双指针递归比较Easy
二叉树的层序遍历BFS + 队列Medium
从前序与中序构造二叉树前序第一个是根,中序分左右Medium
二叉树的最近公共祖先后序遍历,左右子树分别找Medium
二叉树的直径后序遍历,每个节点算左+右深度Easy
验证二叉搜索树中序遍历递增 / 递归传上下界Medium
二叉树的序列化与反序列化前序遍历 + null 标记Hard
二叉树中的最大路径和后序遍历,维护全局最大值Hard

二叉搜索树(BST)

核心性质

  • 左子树所有节点 < 根 < 右子树所有节点
  • 中序遍历是有序的(这是解题关键)

经典题目

题目思路难度
验证二叉搜索树中序遍历递增 / 递归传范围Medium
二叉搜索树中第 K 小的元素中序遍历到第 K 个Medium
将有序数组转换为 BST取中间元素为根,递归建树Easy
删除 BST 中的节点找后继节点替换Medium

回溯(Backtracking)

核心思路

回溯 = DFS + 撤销选择。本质是在决策树上穷举所有路径。

做选择 → 递归进入下一层 → 撤销选择

回溯模板

java
List<List<Integer>> result = new ArrayList<>();

void backtrack(int[] nums, List<Integer> path, boolean[] used) {
    if (满足结束条件) {
        result.add(new ArrayList<>(path)); // 注意拷贝
        return;
    }
    for (int i = 0; i < nums.length; i++) {
        if (used[i]) continue;          // 剪枝
        path.add(nums[i]);              // 做选择
        used[i] = true;
        backtrack(nums, path, used);    // 递归
        path.remove(path.size() - 1);   // 撤销选择
        used[i] = false;
    }
}

去重技巧

排列/组合中有重复元素时:先排序,然后 if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;

经典题目

题目思路难度
全排列回溯 + used 数组Medium
全排列 II(有重复)排序 + 去重剪枝Medium
子集每个元素选或不选Medium
组合总和回溯 + 可重复选(i 不加 1)Medium
组合总和 II(不可重复)排序 + 去重 + i+1Medium
N 皇后回溯 + 列/对角线冲突检测Hard
电话号码的字母组合回溯,每层对应一个数字的字母Medium
括号生成回溯,左括号数 ≥ 右括号数Medium
单词搜索二维网格 DFS 回溯Medium

动态规划(DP)

核心思路

  1. 定义状态:dp[i] 或 dp[i][j] 代表什么
  2. 状态转移方程:dp[i] 怎么从之前的状态推导出来
  3. 初始条件:dp[0] 或 dp[0][0] 是什么
  4. 遍历顺序:确保计算 dp[i] 时所依赖的状态已经算好

常见 DP 类型

线性 DP
dp[i] = f(dp[i-1], dp[i-2], ...)
题目状态定义转移方程难度
爬楼梯dp[i] = 到第 i 阶的方法数dp[i] = dp[i-1] + dp[i-2]Easy
打家劫舍dp[i] = 前 i 家最大金额dp[i] = max(dp[i-1], dp[i-2]+nums[i])Medium
最长递增子序列dp[i] = 以 i 结尾的 LIS 长度dp[i] = max(dp[j]+1),j < i 且 nums[j] < nums[i]Medium
最大子数组和dp[i] = 以 i 结尾的最大和dp[i] = max(nums[i], dp[i-1]+nums[i])Medium
解码方法dp[i] = 前 i 个字符的解码数dp[i] = dpi-1 + dpi-2Medium
二维 DP / 网格 DP
dp[i][j] = f(dp[i-1][j], dp[i][j-1], ...)
题目状态定义转移方程难度
不同路径dp[i][j] = 到 (i,j) 的路径数dp[i][j] = dp[i-1][j] + dp[i][j-1]Medium
最小路径和dp[i][j] = 到 (i,j) 的最小和dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]Medium
编辑距离dp[i][j] = word1 前 i 个变成 word2 前 j 个的最少操作相等跳过,不等取 min(增删改)+1Hard
最长公共子序列dp[i][j] = 前 i 和前 j 的 LCS 长度相等 dp[i-1][j-1]+1,不等 max(dp[i-1][j], dp[i][j-1])Medium
背包 DP
0-1 背包:每个物品只能选一次
完全背包:每个物品可以选无限次
java
// 0-1 背包(一维优化,逆序遍历)
for (int i = 0; i < n; i++) {
    for (int j = W; j >= weight[i]; j--) {
        dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

// 完全背包(正序遍历)
for (int i = 0; i < n; i++) {
    for (int j = weight[i]; j <= W; j++) {
        dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
    }
}
题目背包类型难度
分割等和子集0-1 背包(目标 sum/2)Medium
零钱兑换完全背包(求最少硬币数)Medium
零钱兑换 II完全背包(求组合数)Medium
目标和0-1 背包(转化为子集和问题)Medium
单词拆分完全背包变体Medium
区间 DP
dp[i][j] = 区间 [i, j] 上的最优解
枚举分割点 k:dp[i][j] = f(dp[i][k], dp[k+1][j])
题目思路难度
最长回文子串dp[i][j] = s[i..j] 是否回文Medium
戳气球区间 DP,枚举最后戳的气球Hard

贪心

核心思路

每一步都做局部最优选择,期望得到全局最优。关键是证明贪心策略的正确性。

经典题目

题目贪心策略难度
买卖股票的最佳时机 II只要明天涨就今天买Medium
跳跃游戏维护能到达的最远位置Medium
跳跃游戏 II每步跳到能到最远的位置Medium
分发糖果左右各扫一遍取 maxHard
无重叠区间按结束时间排序,贪心选不重叠的Medium
划分字母区间记录每个字母最后出现位置Medium

BFS / DFS(图与网格搜索)

BFS 模板(层序遍历 / 最短路径)

java
Queue<int[]> queue = new LinkedList<>();
boolean[][] visited = new boolean[m][n];
queue.offer(new int[]{startX, startY});
visited[startX][startY] = true;
int step = 0;

while (!queue.isEmpty()) {
    int size = queue.size();
    for (int i = 0; i < size; i++) {
        int[] curr = queue.poll();
        if (curr 是目标) return step;

        for (int[] dir : directions) { // {{0,1},{0,-1},{1,0},{-1,0}}
            int nx = curr[0] + dir[0], ny = curr[1] + dir[1];
            if (nx >= 0 && nx < m && ny >= 0 && ny < n && !visited[nx][ny]) {
                visited[nx][ny] = true;
                queue.offer(new int[]{nx, ny});
            }
        }
    }
    step++;
}

经典题目

题目思路难度
岛屿数量DFS/BFS 遍历连通区域Medium
岛屿的最大面积DFS 计数Medium
腐烂的橘子多源 BFSMedium
课程表(拓扑排序)BFS + 入度表Medium
课程表 II拓扑排序输出顺序Medium
单词接龙BFS 最短路径Hard

二分查找

核心思路

有序 + 查找 → 二分。关键是确定搜索区间和边界条件。

三种模板

java
// 1. 标准二分:找 target
int left = 0, right = nums.length - 1;
while (left <= right) {
    int mid = left + (right - left) / 2;
    if (nums[mid] == target) return mid;
    else if (nums[mid] < target) left = mid + 1;
    else right = mid - 1;
}

// 2. 左边界:找第一个 >= target 的位置
int left = 0, right = nums.length - 1;

while (left <= right) {
    int mid = left + (right - left) / 2;
    if (nums[mid] < target) {
        left = mid + 1;   // 不够大,往右走
    } else {
        right = mid - 1;  // 太大或相等,往左找更早的
    }
}

return left; // 最终 left 就是第一个 ≥ target 的下标

// 3. 右边界:找最后一个 <= target 的位置
int left = 0, right = nums.length - 1;

while (left <= right) {
    int mid = left + (right - left) / 2;
    if (nums[mid] > target) {
        right = mid - 1;  // 太大,往左走
    } else {
        left = mid + 1;   // 太小或相等,往右找更晚的
    }
}

return right; // 最终 right 就是最后一个 ≤ target 的下标

经典题目

题目思路难度
搜索旋转排序数组二分,判断哪半边有序Medium
在排序数组中查找元素的第一个和最后一个位置两次二分找左右边界Medium
搜索二维矩阵展开为一维二分 / 右上角搜索Medium
寻找旋转排序数组中的最小值二分,和右端点比较Medium
寻找两个正序数组的中位数二分找分割线Hard

堆(优先队列)

核心思路

  • Top K 问题:用大小为 K 的小顶堆
  • 合并 K 个有序序列:小顶堆维护每个序列的当前最小值
  • 动态中位数:大顶堆(左半)+ 小顶堆(右半)

经典题目

题目思路难度
前 K 个高频元素HashMap 计数 + 小顶堆Medium
数组中的第 K 个最大元素小顶堆 / 快速选择Medium
合并 K 个升序链表小顶堆Hard
数据流的中位数大顶堆 + 小顶堆Hard

前缀和 / 差分

前缀和

java
// 构建前缀和
int[] prefix = new int[nums.length + 1];
for (int i = 0; i < nums.length; i++) {
    prefix[i + 1] = prefix[i] + nums[i];
}
// 区间和 [i, j] = prefix[j+1] - prefix[i]

经典题目

题目思路难度
和为 K 的子数组前缀和 + HashMapMedium
除自身以外数组的乘积左前缀积 × 右前缀积Medium

并查集(Union-Find)

模板

java
class UnionFind {
    int[] parent, rank;

    UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) parent[i] = i;
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]); // 路径压缩
        return parent[x];
    }

    void union(int x, int y) {
        int px = find(x), py = find(y);
        if (px == py) return;
        if (rank[px] < rank[py]) { int t = px; px = py; py = t; }
        parent[py] = px; // 按秩合并
        if (rank[px] == rank[py]) rank[px]++;
    }
}

经典题目

题目思路难度
岛屿数量(并查集解法)合并相邻陆地Medium
冗余连接加边时发现环Medium

字符串

经典题目

题目思路难度
最长回文子串中心扩展 / DPMedium
最长公共前缀纵向比较Easy
字符串转换整数 (atoi)状态机 / 逐字符处理Medium
实现 strStr()KMP / 直接匹配Easy

位运算

常用技巧

n & (n - 1)    → 消除最低位的 1
n & (-n)       → 获取最低位的 1
a ^ a = 0      → 异或找唯一数

经典题目

题目思路难度
只出现一次的数字全部异或Easy
位 1 的个数n & (n-1) 循环计数Easy
2 的幂n > 0 && (n & (n-1)) == 0Easy

设计题

题目核心数据结构难度
LRU 缓存HashMap + 双向链表Medium
LFU 缓存HashMap + 频率桶(双向链表)Hard
最小栈辅助栈Medium
用栈实现队列两个栈Easy

面试高频 Top 30(必刷)

按出现频率排序,这些题在社招面试中反复出现:

  1. 两数之和
  2. 反转链表
  3. LRU 缓存
  4. 无重复字符的最长子串
  5. 合并两个有序链表
  6. 二叉树的层序遍历
  7. 最大子数组和
  8. 三数之和
  9. 买卖股票的最佳时机
  10. 岛屿数量
  11. 全排列
  12. 二叉树的最近公共祖先
  13. 搜索旋转排序数组
  14. 合并区间
  15. 爬楼梯
  16. 零钱兑换
  17. 有效的括号
  18. 接雨水
  19. 最长递增子序列
  20. 二叉树的最大深度
  21. 环形链表 II
  22. 每日温度
  23. 编辑距离
  24. 前 K 个高频元素
  25. 课程表
  26. 最小路径和
  27. 单词搜索
  28. 数组中的第 K 个最大元素
  29. 最长回文子串
  30. K 个一组翻转链表

解题策略

面试中的做题流程

1. 理解题意(2 分钟)
   → 确认输入输出、边界条件、数据规模
   → 用例子验证理解

2. 说思路(3 分钟)
   → 先说暴力解法和复杂度
   → 再说优化思路
   → 和面试官确认方向

3. 写代码(15 分钟)
   → 先写主体逻辑,再处理边界
   → 变量命名清晰
   → 边写边解释

4. 测试(5 分钟)
   → 用示例 dry run 一遍
   → 考虑边界:空输入、单元素、全相同、最大值

看到题目的第一反应

有序数组/矩阵        → 二分查找
最优/最大/最小/最长   → DP 或贪心
所有方案/排列/组合    → 回溯
最短路径/层数        → BFS
连通性              → DFS / 并查集
Top K               → 堆
子串/子数组          → 滑动窗口 / 前缀和
树的问题            → 递归(想清楚用前序/中序/后序)

实习面试刷题重点

实习面试算法通常比社招系统设计更高频,建议先把 Easy 和常见 Medium 刷熟,重点练“边写边讲”和复杂度分析。

必须掌握的题型

题型必刷能力代表题
数组/哈希下标、去重、计数、映射关系两数之和、合并区间、前 K 个高频元素
字符串字符计数、双指针、滑动窗口有效括号、最长无重复子串、最长回文子串
链表指针移动、反转、快慢指针反转链表、合并两个有序链表、环形链表
栈/队列匹配、单调栈、BFS有效括号、每日温度、二叉树层序遍历
二叉树递归、遍历、深度最大深度、层序遍历、最近公共祖先
动态规划状态定义、转移方程爬楼梯、最大子数组和、买卖股票

实习面试答题模板

1. 先复述题意,确认输入输出和边界。
2. 先说最直接的思路,再说优化方式。
3. 写代码时主动解释关键变量含义。
4. 写完用一个普通用例和一个边界用例 dry run。
5. 最后说时间复杂度和空间复杂度。

Android 实习建议优先级

  1. 先刷数组、字符串、链表、栈队列,保证 Easy 稳定 AC。
  2. 再刷二叉树、滑动窗口、二分查找,掌握常见模板。
  3. 最后补基础 DP 和回溯,不要一开始死磕 Hard。
  4. 每道题都要能口述思路,面试时“沉默写代码”会明显扣分。