# Android 原生面试题精选
覆盖语言基础、系统原理、
UI、并发、架构、性能、安全、测试与发布。每题都有 30 秒速记、代码示例、详细解析和面试官追问,流程类题目配了时序图。
# 1 Java 与 JVM 基础
# 1. Java 泛型擦除是什么?Kotlin 里怎么在运行时拿到泛型类型?
30 秒速记
- 擦除 = 泛型只在编译期检查,编译完
List<String>和List<Int>都是同一个List类 - 后果:不能
is List<String>、不能T::class、不能new T(),as List<String>只给警告,真错在取元素时才炸 - 类、字段、方法签名上的泛型会留在
Signature属性里,Gson的TypeToken就是靠匿名子类读父类签名 Kotlin用inline+reified把类型在调用处「抄」进去,本质是编译器替你传了Class- 开
R8混淆要保留Signature,不然TypeToken拿到的是原始类型,解析出LinkedTreeMap然后类型转换崩溃
泛型擦除就是编译器做完类型检查后,把类型参数擦成上界(默认 Object),运行时 List<String> 和 List<User> 是同一个东西。 这么设计是为了兼容 Java 5 以前没有泛型的字节码。日常最常碰到的坑是 Gson 解析列表:传 List::class.java 进去,Gson 不知道元素是什么,只能给你一堆 LinkedTreeMap,到取元素当 User 用的时候才抛 ClassCastException。解决办法要么用 TypeToken 匿名子类把类型留在签名里,要么在 Kotlin 里写 inline fun <reified T>,让编译器在调用处把具体类型填进去。
# 擦掉的是什么,留下的是什么
打个比方,泛型像快递单上的「易碎品」标签,分拣中心(编译器)看一眼就撕了,货车上(运行时)的箱子都长一个样。所以运行时你问一个对象「你是不是 List<String>」,它答不上来,Kotlin 直接不让你这么写:
fun check(x: Any) {
// if (x is List<String>) {} // 编译报错:Cannot check for instance of erased type
if (x is List<*>) println("是个 List,元素类型不知道")
}
fun main() {
val raw: Any = listOf(1, 2, 3)
@Suppress("UNCHECKED_CAST")
val strs = raw as List<String> // 只有警告,这行不会崩
println(strs.size) // 输出 3,也不会崩
val s: String = strs[0] // 这里才抛 ClassCastException:Integer cannot be cast to String
}
注意崩溃点不在强转那一行,而在编译器偷偷插入 checkcast 的取值那一行。线上堆栈经常指向一个看起来毫无问题的地方,根因在很远的上游,这就是擦除最烦人的地方。
但擦除不是全擦。声明在类、字段、方法签名上的泛型信息会写进 class 文件的 Signature 属性,反射能读到。Gson 的 TypeToken 就利用了这一点:
data class User(val id: Long, val name: String)
val json = """[{"id":1,"name":"Tom"}]"""
// 错误:只告诉 Gson「这是个 List」
val bad: List<User> = Gson().fromJson(json, List::class.java) as List<User>
// bad[0].name -> ClassCastException: LinkedTreeMap cannot be cast to User
// 正确:匿名子类 object : TypeToken<List<User>>() {} 的父类签名里保留了 List<User>
val type = object : TypeToken<List<User>>() {}.type
val good: List<User> = Gson().fromJson(json, type)
println(good[0].name) // Tom
// Kotlin 写法:reified 让 T 在调用处变成真实类型
inline fun <reified T> Gson.fromJsonTyped(json: String): T =
fromJson(json, object : TypeToken<T>() {}.type)
val users: List<User> = Gson().fromJsonTyped(json)
# reified 为什么能做到
reified 只能用在 inline 函数上,因为函数体会被复制到每个调用点,编译器在调用点知道 T 具体是谁,就直接把 User::class.java 写进字节码。代价是函数不能被 Java 调用(Java 那边没有内联),而且函数体大的话会让包体膨胀。startActivity<DetailActivity>()、viewModels<VM>() 这种扩展都是这个套路。
# 混淆后的坑
R8 / ProGuard 默认会去掉 Signature 属性。没保留的话,TypeToken 拿到的只是原始的 List,现象和上面「错误写法」一模一样,而且只在正式包出现。规则里要有 -keepattributes Signature,并且保留 TypeToken 的子类。Gson 2.11 起 jar 里自带了这部分规则,老版本要自己加;AGP 8 默认开了 R8 full mode,对没被保留的类会更激进地删签名,升级后正式包突然解析失败,优先查这里。
面试官追问
追问 1List<String> 和 List<Int> 能写成两个重载方法吗?
不能,擦除后两个方法签名都是 foo(List),JVM 层面冲突。Kotlin 里可以用 @JvmName("fooInts") 给其中一个改字节码名字绕过去。
追问 2reified 那么好用,为什么 Retrofit 接口不用它?
Retrofit 是靠动态代理在运行时读接口方法的返回类型,比如 suspend fun list(): List<User> 的泛型签名本来就保留在方法的 Signature 里,不需要 reified。它反而怕混淆删掉签名,所以官方规则里也保留了 Signature。
追问 3调试包解析正常,正式包列表里每一项都是 LinkedTreeMap,第一步看什么?
先看混淆规则有没有 -keepattributes Signature,再看 Gson 版本和数据类有没有被保留。用 jadx 打开正式包看那个 TypeToken 匿名类的父类签名还在不在,一眼就能确认。
追问 4泛型数组为什么不能直接 arrayOf<T>()?
数组在运行时是知道自己元素类型的(String[] 和 Object[] 是不同的类),创建时必须给出真实类型,而 T 被擦了。Kotlin 里要写 inline fun <reified T> make() = arrayOf<T>(),或者传一个 Class<T> 进去。
追问 5擦除之后,class Box<T : Number> 里的 T 在字节码里是什么?
擦成上界 Number,不是 Object。所以方法里可以直接调 t.toInt(),编译器知道它至少是个 Number。
# 2. Kotlin 的 out / in 和 Java 的 ? extends / ? super 是什么关系?什么时候用哪个?
30 秒速记
- 默认泛型是不变的:
Dog是Animal的子类,但List<Dog>不是List<Animal>(指可变的那种) out T=? extends T,只往外拿,叫协变;in T=? super T,只往里放,叫逆变- 口诀
PECS:生产者用extends/out,消费者用super/in Kotlin可以在声明处写一次(interface Source<out T>),Java只能在每个使用处写通配符Kotlin的List<out E>只读所以协变,MutableList<E>能写所以不变;Java数组协变是历史设计,会在运行时抛ArrayStoreException
out 和 in 就是 Kotlin 版的 ? extends 和 ? super,解决的是「装狗的盒子能不能当装动物的盒子用」这个问题。 答案取决于你往盒子里放还是从里面拿:只拿不放,Box<Dog> 当 Box<Animal> 用没问题,这叫协变,写 out;只放不拿,Box<Animal> 当 Box<Dog> 用也安全,这叫逆变,写 in。Kotlin 比 Java 方便的一点是可以在声明处标一次,比如 List<out E>,所以 List<Dog> 能直接传给要 List<Animal> 的参数;Java 得在每个方法参数上写 List<? extends Animal>。
