牛客面经 - 一叶知秋霞
链接:https://www.nowcoder.com/users/707933039
字节跳动飞书 日常实习一面
多线程有用过吗或者学过相关知识吗?以及多线程的优点和缺点有哪些?
嗯,多线程这块我不仅在 Android 应用层开发中经常用到,在研究 Framework 源码的时候也深入学习过它的底层机制。
在 Android 开发里,多线程几乎是刚需。因为主线程(UI 线程)绝不能阻塞,所以我们必须把耗时操作,比如网络请求、数据库读写、或者复杂的图片处理,丢到子线程去跑。我平时在项目里常用 Kotlin 的 协程 (Coroutines),或者标准的 ThreadPoolExecutor 线程池。在看 Framework 源码时,我也注意到系统服务里大量使用了 HandlerThread 和 IntentService 的这种思想,比如 AMS 内部就有专门处理生命周期的线程。
说到多线程的优点,最明显的结论就是:提高系统的吞吐量和响应速度。
- 提升用户体验:把耗时任务异步化,保证 UI 渲染每秒 60 或 120 帧的流畅度,不会触发 ANR。
- 充分利用多核 CPU:现在的手机 CPU 动辄 8 核,如果只用单线程,那真是极大的浪费。通过并行计算,能显著缩短任务的总执行时间。
但多线程也是一把双刃剑,它的缺点或者说挑战也非常大:
- 上下文切换(Context Switch)的开销:线程不是越多越好,线程切换需要保存和恢复 CPU 寄存器、程序计数器(PC)等上下文信息。如果线程过多,CPU 可能光忙着切换了,实际干活的效率反而下降。
- 资源消耗:每个线程在 JVM 里都要分配独立的虚拟机栈(默认可能是 1MB 左右),线程开多了,内存压力会非常大,甚至 OOM。
- 编程复杂度:这是最头疼的。多线程会引入死锁、竞态条件等问题,调试起来非常困难,有时候线上一个偶发的 Bug 根本复现不出来。
线程安全问题的核心原因是什么(从内存角度分析)?
其实,线程安全问题的核心原因,可以归纳为 Java 内存模型 (JMM) 导致的三个特性缺失:原子性、可见性、有序性。
如果从内存底层的角度看,最根本的原因在于主内存(Main Memory)和工作内存(Working Memory)之间的数据不一致。
我们可以这么理解:在 JVM 中,所有变量都存在主内存里。但每个线程为了跑得快,都会有自己的本地栈和工作内存(对应 CPU 的 L1/L2/L3 缓存和寄存器)。
- 可见性问题:当线程 A 修改了一个变量
count,它其实是先在自己的本地缓存里改,还没来得及刷新回主内存。这时候线程 B 去读取主内存里的count,读到的还是旧值。这就是为什么我们经常发现“我明明改了,但另一个线程没看到”。 - 原子性问题:一个简单的
i++操作,在 CPU 层面其实分成了:读取、加一、写入这三步。如果在这些步骤中间发生了线程切换,数据就乱套了。 - 有序性问题:编译器和处理器为了优化性能,会进行指令重排。在单线程环境下这没问题,但在多线程下,这种重排可能会打破逻辑依赖。
我之前读过《Java 并发编程的艺术》,里面提到过 CPU 的 Store Buffer 和 Invalidate Queue。其实 JMM 就是为了屏蔽掉底层硬件这种复杂的缓存一致性协议(比如 MESI),给开发者提供了一套统一的规则,让我们通过关键字去保证内存操作的同步。
线程安全相关的关键字或容器有哪些?
为了解决上面说的内存一致性问题,Java 提供了很多工具。我把它们归类为:关键字、原子类、锁机制、以及并发容器。
首先是关键字,最常用的就是 synchronized。
它在底层是基于 Monitor(监视器锁) 实现的。我之前反编译过字节码,能看到 monitorenter 和 monitorexit 指令。它能同时保证原子性、可见性和有序性。现在的 JVM 对它做了很多优化,比如偏向锁、轻量级锁和锁粗化,性能已经很不错了。
然后是 volatile。它比 synchronized 更轻量。它的作用有两个:一是强制线程从主内存读取变量,保证可见性;二是禁止指令重排,通过插入内存屏障(Memory Barrier)来保证有序性。但要注意,它不保证原子性,所以 volatile i++ 依然不是线程安全的。
其次是原子类,比如 AtomicInteger、AtomicReference。
它们的核心是 CAS (Compare And Swap)。这是一种乐观锁的思想,底层调用的是 Unsafe 类的本地方法,直接利用 CPU 的原子指令(如 cmpxchg )来保证操作的原子性,避免了重量级锁的挂起和唤醒。
再就是 Lock 接口及其实现,比如 ReentrantLock。
它是基于 AQS (AbstractQueuedSynchronizer) 实现的。我在看 AQS 源码时,觉得它的设计非常精妙,通过一个 volatile state 变量和一个双向队列(CLH 队列)来管理锁状态和线程排队。相比 synchronized,它支持公平锁、响应中断以及超时机制。
最后是并发容器,这是我们在开发中用得最多的。
ConcurrentHashMap:在 Java 8 之后,它舍弃了分段锁,改用 CAS + synchronized。它把锁的粒度细化到了每个哈希桶(Node 节点),并发性能非常强。CopyOnWriteArrayList:它的思路是“写时复制”。当你要修改数据时,它会拷贝一份新数组,在旧数组上读,在新数组上写。这种机制非常适合读多写少的场景,比如应用里的配置信息。BlockingQueue:这在生产者-消费者模型里非常关键,像 Android 的线程池执行任务,底层就靠LinkedBlockingQueue来实现线程间的任务传递和阻塞唤醒。
volatile 一定能保证线程安全吗?
嗯,直接给结论:volatile 绝对不能保证绝对的线程安全。 它只能保证可见性和有序性,但它完全没法保证原子性。
其实,很多人在面试时会被 volatile 唬住,觉得它带了“同步”的意思就是安全的。但我们要从 JMM(Java 内存模型) 的角度去拆解。
比如,最经典的例子就是 i++ 操作。即便你给变量 i 加了 volatile,在多线程环境下自增,最后的结果大概率还是错的。为什么呢?因为 i++ 在底层其实对应了三条指令:读取、加一、写回。
volatile 虽然能保证每个线程在“读取”的时候拿到的都是主内存里最新的值,但它管不住多个线程同时进行“加一”和“写回”。
这就好比大家都在看同一个最新的布告栏(可见性),但当多个人同时想去改布告栏上的数字时,如果没有排队机制(原子性),最后写上去的数字肯定会互相覆盖,导致结果偏小。
我在写一些单例模式(比如 DCL 双重检查锁定)的时候,一定会给实例加上 volatile。这时候利用的是它的有序性,防止指令重排。因为 new Object() 过程也会被拆分成:分配空间、初始化、指向引用。如果不加 volatile,线程 B 可能会拿到一个还没初始化完成的“半成品”对象。
所以,只有当你的场景是 “纯粹的赋值操作”,且不依赖当前值(比如一个简单的 boolean 开关),volatile 才是安全的。否则,必须上锁。
有什么关键字能保证原子性?
如果说在 Java 的“关键字”层面,唯一能保证原子性的就是 synchronized。
嗯,其实除了关键字,Java 还提供了很多机制来达成原子性,我简单梳理一下:
-
synchronized关键字:它是最通用的。无论是修饰方法还是代码块,它都能保证在同一时刻只有一个线程能进入。在底层,它是通过 Monitor(监视器锁) 来实现的。我在分析字节码时,能看到monitorenter和monitorexit这两条指令。它是一种互斥锁,通过这种“排他性”自然而然地保证了操作的原子性。 -
JUC 包下的原子类(比如
AtomicInteger):虽然它们不是“关键字”,但它们在 Android 开发中解决原子性问题非常高效。它们底层不是靠synchronized这种重的锁,而是靠 CAS(Compare And Swap)。 其实 CAS 调用的就是Unsafe类里的本地方法。这种方式在硬件层面是一条原子指令(比如 x86 的lock cmpxchg),它不会让线程挂起,性能比synchronized好很多。 -
Lock接口及其实现(如ReentrantLock):这也是通过代码逻辑实现的原子性保证。它基于 AQS,利用一个volatile的state变量和 CAS 操作来竞争锁。虽然它不是关键字,但在处理复杂并发逻辑时比synchronized更灵活。
所以,如果面试官问“关键字”,那结论就是 synchronized;但如果聊到“机制”,那 CAS 和 Lock 绝对是绕不开的重点。
synchronized 和 volatile 的区别?
这两个东西经常被放在一起比较,但其实它们的分工非常明确。如果用一句话总结,那就是:volatile 是线程同步的轻量级实现,而 synchronized 是重量级的。
具体的区别,我通常从这四个维度来跟面试官拆解:
第一,保证的特性不同。
volatile只能保证 可见性 和 有序性(禁止指令重排)。synchronized则是全能型,能同时保证 原子性、可见性 和 有序性。
第二,性能开销不同。
volatile是非常轻量的。它不会导致线程阻塞,也就没有上下文切换的开销。它只是通过 内存屏障(Memory Barrier) 告诉 CPU 别乱优化,并强制刷缓存。synchronized相对较重。虽然 JVM 后来引入了偏向锁、轻量级锁等优化,但它在竞争激烈时依然会涉及线程的阻塞和唤醒,这在内核态和用户态之间的切换是非常耗时的。
第三,作用范围不同。
volatile只能修饰 变量。synchronized可以修饰 方法 或者 代码块。
第四,语义不同。
volatile本质是告诉 JVM 当前变量在寄存器中的值是不确定的,需要从主存读取。synchronized则是锁定当前变量或对象,让其他线程进不来。
其实在 AOSP 源码 里,这两者的配合非常多。比如在 Handler 机制 的底层,或者一些系统服务的单例里,经常能看到 volatile 用来保证实例引用的可见,再配合 synchronized 代码块来确保初始化过程的唯一性和原子性。这种结合(DCL)才是处理高并发最经典的玩法。
Java 和 Kotlin 的区别,各自的优势?
嗯,作为一个从 Java 转到 Kotlin 的 Android 开发者,我感触最深的一点是:Java 是严谨的基石,而 Kotlin 是高效的生产力工具。 如果非要说结论,我觉得 Kotlin 在设计上通过“语法糖”和编译器特性,解决了 Java 多年以来被诟病的“啰嗦”和“空指针”问题。
首先,最直观的区别就是 Null Safety(空安全)。
在 Java 里,我们得不停地写 if (obj != null),稍不注意就是 NullPointerException。但在 Kotlin 里,类型系统就把“可空”和“不可空”分开了。这种在编译期就强制检查空的机制,真的减少了 Android 应用在线上崩溃的概率。
其次,是代码的简洁度。
就拿 Data Class 来说,Java 要写一个 POJO,得手动生成 Getter、Setter、equals、hashCode,哪怕用 Lombok 也要额外配置。Kotlin 一个关键字全搞定。还有扩展函数(Extension Functions),它让我们能给 Context、View 这种类直接增加方法,不用写一堆臃肿的 Utils 类。
再聊聊底层,Kotlin 的 协程 (Coroutines) 简直是 Android 开发的救星。
在 Java 里做异步,我们要么用复杂的 RxJava,要么用嵌套的 Callback。协程能让我们用“同步的代码风格写异步逻辑”,配合 Jetpack 里的 viewModelScope,处理网络请求和生命周期绑定非常优雅。
当然,Java 也有它的优势。 Java 的生态极其成熟,而且 Android 的 Framework 层(像 AMS、WMS、PMS)绝大部分还是用 Java 写的。作为要深入 Framework 层的开发者,读 Java 源码是基本功。Java 的语法更直白,没有过多的“黑魔法”,在大型团队协作中,其规范性反而是一种保护。而且 Java 的编译速度在某些大项目中其实比 Kotlin 快一些。
所以,我现在的策略是:应用层开发首选 Kotlin,深入底层研究和性能调优时,必须精通 Java。
by lazy 的原理,使用 Java 要怎么实现相似的功能?
关于 Kotlin 的 by lazy,其实结论非常明确:它是基于委托属性(Delegated Properties)实现的一种延迟初始化机制,默认情况下它是线程安全的。
我之前翻过 Kotlin 标准库的源码,发现 by lazy { ... } 实际上是创建了一个 Lazy 接口的实例。默认使用的是 SynchronizedLazyImpl 这个类。
它的核心逻辑其实非常像我们 Java 里的 DCL(Double-Checked Locking,双重检查锁定)。
在 SynchronizedLazyImpl 内部,有一个 _value 变量来存结果,还有一个 initializer 闭包。当你第一次调用 getValue() 的时候,它会先检查 _value 是不是初始状态。如果是,就加个 synchronized 锁,进去再检查一遍,确认没被初始化后,执行闭包,把结果存进 _value,然后把闭包置空,释放内存。
如果你想用 Java 实现一个相似的功能,最经典的做法就是写一个 DCL 单例 模式的变体。代码逻辑大概是这样的:
public class LazyWrapper<T> {
private Callable<T> initializer;
private volatile T value = null; // 必须加 volatile 保证可见性和禁止指令重排
public LazyWrapper(Callable<T> initializer) {
this.initializer = initializer;
}
public T get() {
T result = value;
if (result == null) { // 第一次检查
synchronized (this) {
result = value;
if (result == null) { // 第二次检查
try {
value = result = initializer.call();
initializer = null; // 初始化后释放闭包,防止内存泄漏
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
}
return result;
}
}这里有几个细节:
volatile关键字:这绝对不能省。没有它,多线程环境下可能会因为指令重排导致另一个线程拿到一个还没初始化完的“半成品”对象。- 局部变量
result:这其实是一个性能优化的点。减少对volatile变量的直接读取次数,可以稍微提升一点运行效率,这在SynchronizedLazyImpl的源码里也有体现。
HashMap 的实现原理?
HashMap 绝对是面试里的高频题,我从底层数据结构、扩容机制和哈希冲突三个维度来讲。结论先行:Java 8 之后的 HashMap 采用了“数组 + 链表 + 红黑树”的结构。
首先,底层是一个 Node<K,V>[] table 数组。
当我们往里 put 元素时,它会先通过 hash() 函数计算 Key 的哈希值。这里有个细节,它不是直接用 hashCode(),而是做了一次高 16 位和低 16 位的异或运算。为什么要这么做?其实是为了让哈希分布更均匀,减少碰撞。
然后,怎么确定数组下标呢?它用了 (n - 1) & hash 的位运算( 是数组长度)。这就是为什么 HashMap 的初始容量必须是 的幂次方(默认 ),因为只有这样, 的二进制全是 ,做按位与运算才能达到取模的效果,而且效率更高。
接下来是处理哈希冲突。 如果两个 Key 算出来的下标一样,就发生了冲突。在 Java 8 之前,只是简单的链表。但如果冲突太多,链表过长,查询效率会从 退化到 。 所以 Java 8 引入了红黑树。当链表长度达到阈值(默认是 ),且数组总长度达到 时,链表就会转化成红黑树。红黑树的查询效率是 ,这极大地提升了最坏情况下的性能。
最后说一下扩容机制。
HashMap 有个负载因子(Load Factor),默认是 。当数组中的元素个数超过了 capacity * loadFactor,就会触发 resize()。
扩容的过程其实挺耗时的。它会创建一个原来 2 倍大小的新数组,然后遍历旧数组,把所有节点重新分配位置。这里有个精妙的设计:因为容量是 2 的幂次方,扩容后节点的位置要么在原位置,要么在原位置 + 旧容量的位置。它不需要重新计算哈希,只需要判断高位 bit 是 0 还是 1 就行了。
我在看这部分源码时感触最深的是,HashMap 并没有追求绝对的线程安全,它所有的设计都在为性能让步。如果要在多线程下用,我会选 ConcurrentHashMap。
TCP 和 UDP 的区别?
嗯,关于 TCP 和 UDP,其实结论非常直观:TCP 是为了“稳”,而 UDP 是为了“快”。
如果从 Android 开发的角度来看,我们平时接触的 Retrofit 网络请求底层跑的几乎都是 TCP(通过 HTTP);而如果我们做实时音视频通话,比如像微信视频那种,底层大概率会用到 UDP 或者基于 UDP 的变种协议。
具体的区别,我通常会从这四个核心点来拆解:
第一,连接性。 TCP 是面向连接的。在传数据前,必须经过“三次握手”建立连接。这就好比打电话,得等对方接了(建立连接)才能说话。 而 UDP 是无连接的。它只管发,不管对方在不在。这就像写信,信投进邮箱就不管了,对方能不能收到、什么时候收到,它不关心。
第二,可靠性。 这是最大的区别。TCP 提供可靠传输。它有确认机制(ACK)、超时重传、丢包检测等。只要 TCP 告诉你发送成功了,那数据一定是完整的、按顺序到达的。 UDP 则是不可靠的。它不保证丢不丢包,也不保证顺序。发出去的包可能会丢失,也可能后发的先到。
第三,传输形式。 TCP 是面向字节流的。它把数据看成一连串无结构的字节流,没有明显的边界。所以我们在应用层经常要处理“粘包”和“拆包”的问题。 UDP 是面向报文的。它保留了应用层传下来的报文边界,发一个就是一个,不会合并也不会拆分。
第四,效率和开销。 TCP 因为要维护连接状态、流量控制和拥塞控制,首部开销大(至少 字节),且速度相对较慢。 UDP 首部只有 字节,结构简单,处理速度极快,实时性非常好。
TCP 通过哪些方式实现可靠性?
TCP 为了保证“不丢、不重、不乱”,在协议栈底层设计了一套非常复杂的机制。一句话总结:它是通过确认应答、序列号、校验和、重传机制、以及流量/拥塞控制来实现可靠性的。
我之前在研究底层协议栈的时候,对这几个点印象非常深:
-
序列号和确认应答(ACK): 这是可靠性的根基。TCP 给每个字节都编了号。接收方收到数据后,会回传一个 ACK 包,告诉发送方:“我收到第 个字节之前的所有数据了,下次请发第 个。”如果发送方迟迟收不到 ACK,它就知道出事了。
-
超时重传: 发送方发完数据会启动一个定时器。如果在 RTO(超时重传时间) 内没收到 ACK,就会重发。这个 RTO 是动态计算的,会根据网络的往返时间(RTT)自动调整。
-
校验和(Checksum): 每个 TCP 报文段都会带一个校验和字段。接收方会根据数据内容重新计算,如果算出来跟发过来的对不上,说明数据在路上被干扰变质了,直接丢弃,等重传。
-
流量控制(Flow Control): 这是利用 滑动窗口(Sliding Window) 实现的。接收方会在 ACK 包里告诉发送方自己的“接收窗口”还有多大(也就是缓冲区剩余空间)。如果接收方处理太慢,它会让发送方慢点发,防止缓冲区溢出导致丢包。
-
拥塞控制(Congestion Control): 这是为了应对整个网络链路的拥堵。它引入了 拥塞窗口(CWND) 的概念。即使接收方能接得住,如果中间网络堵了,TCP 也会主动降速。这里面涉及到慢启动、拥塞避免等算法。
场景:下载速度通常是由慢到快,背后原理是什么?
这个现象非常有意思,其实它背后对应的就是 TCP 拥塞控制里的 “慢启动(Slow Start)”算法。
结论是:为了防止新连接一上来就发送大量数据导致网络瘫痪,TCP 会从一个小窗口开始,根据网络的反馈逐步增加发送速度。
具体的演进过程是这样的:
- 初始阶段:当连接刚建立时,发送方并不清楚当前网络的拥堵情况。所以它会把 拥塞窗口(CWND) 设置得非常小,通常只有几个 MSS(最大报文段长度)。
- 指数增长:每当发送方收到一个 ACK 确认,它就会把 CWND 增加一倍。也就是说,窗口大小会呈 这样的指数级跳跃。这时候你会发现下载速度猛增。
- 到达阈值(ssthresh):增长不是无止境的。当 CWND 达到一个预设的阈值(叫作 慢启动门限 ssthresh)时,它会觉得“差不多了,再快就要出事了”。
- 拥塞避免:过了阈值后,TCP 进入“拥塞避免”阶段。这时候不再是指数翻倍,而是线性增长。每过一个往返时间,窗口只加 。
- 遇到丢包:如果在这个过程中发生了丢包,TCP 会认为网络堵了,这时候它会迅速减小窗口,甚至直接降回初始值,重新开始这个过程。
所以,我们在 Android 手机上下载大文件时,看到进度条从几 KB/s 迅速爬升到几 MB/s,其实就是 TCP 在不断通过“探测”寻找当前网络带宽的最佳平衡点。
HTTP 和 TCP、UDP 的关系?
其实它们的关系非常明确,就是 应用层协议与传输层协议的关系。
如果用 OSI 七层模型或者 TCP/IP 四层模型来看:
- HTTP(超文本传输协议) 位于最顶层的 应用层。
- TCP 和 UDP 位于下面的 传输层。
结论是:HTTP 是数据的逻辑规则,而 TCP/IP 是数据的运输工具。
在传统的 HTTP/1.1 和 HTTP/2.0 时代,HTTP 必须跑在 TCP 之上。 这是因为 HTTP 需要保证网页内容(HTML、图片、脚本)的绝对完整。如果丢一个字节,图片可能就裂了,脚本可能就报错了。所以它利用了 TCP 的可靠性。 当我们发起一个 HTTP 请求时,浏览器会先调 Socket 接口跟服务器进行 TCP 三次握手,连接建好了,才会把 HTTP 的 Header 和 Body 塞进 TCP 管道里传出去。
但是,到了 HTTP/3.0,情况变了。 HTTP/3.0 跑在了 UDP 之上。为什么呢?因为 TCP 虽然稳,但它太老了,有很多结构性的缺陷(比如队头阻塞)。谷歌为了极致的性能,在 UDP 的基础上封装了一层叫 QUIC 的协议。 所以现在 HTTP 和传输层的关系变成了:
- HTTP/1.x、2.0 -> TCP
- HTTP/3.0 -> UDP (QUIC)
HTTP 2.0 和 HTTP 3.0 的区别?
这两者是目前互联网性能优化的前沿方向。如果非要说一个最核心的区别,那就是:HTTP/2.0 试图在 TCP 的框架内解决问题,而 HTTP/3.0 彻底抛弃了 TCP,改用基于 UDP 的 QUIC 协议。
具体我有这几个深度的对比:
-
传输层的根本变革:
- HTTP/2.0 依然使用 TCP。虽然它通过“多路复用(Multiplexing)”让多个请求共享一个 TCP 连接,但它解决不了 TCP 层的 队头阻塞(Head-of-Line Blocking)。如果中间丢了一个 TCP 包,整个连接的所有请求都会卡住,等那个包重传。
- HTTP/3.0 使用 UDP 协议。它通过 QUIC 协议在应用层实现了可靠性。QUIC 也有多路复用,但它是基于“流”的。丢一个流的包,不会影响其他流。这彻底解决了队头阻塞问题。
-
连接建立的握手延迟:
- HTTP/2.0(TCP + TLS)通常需要 3 次握手建立 TCP,再加 2 次握手做 TLS 加密,延迟很高。
- HTTP/3.0 的 QUIC 协议把传输和加密握手合并了。它支持 0-RTT 或者 1-RTT 就能建立连接。这意味着你打开网页的第一秒,数据可能就已经在路上了。
-
连接迁移(Connection Migration):
- HTTP/2.0 依赖四元组(源IP、源端口、目的IP、目的端口)。我们在手机上从 Wi-Fi 切换到 4G/5G 时,IP 变了,TCP 连接必断,得重新握手。
- HTTP/3.0 的 QUIC 使用 Connection ID 来识别连接。只要 ID 没变,就算你 IP 变了,连接依然能无缝切换,用户完全感知不到断网。
我在关注 Android 端的网络框架时,发现 OkHttp 其实很早就开始支持 HTTP/2.0 了,而对于 HTTP/3.0 的支持,各大厂(如字节、阿里)现在基本都是通过自研的网络库或者 Cronet 来落地的。
内存泄漏原理,以及你怎么排查、怎么解决?
嗯,说到内存泄漏,其实结论非常简单:一个不再使用的对象,因为被另一个长生命周期的对象持有,导致无法被 GC 回收,从而一直占用堆内存。
在 Android 这种内存资源极其宝贵的环境下,内存泄漏直接会导致频繁的 GC(造成卡顿),甚至最终触发 OOM。
其实,Java 垃圾回收判断对象是否存活使用的是可达性分析算法(Reachability Analysis)。它的核心是从一组 GC Roots 出发,向下搜索。如果一个对象到 GC Roots 没有任何引用链相连,那它就是可以被回收的。常见的 GC Roots 包括:当前活跃线程的局部变量、静态变量、JNI 引用等。
内存泄漏的排查过程,我通常会分为三个阶段:
- 监控与预警:我会在开发环境集成 LeakCanary。它非常智能,一旦检测到 Activity 或 Fragment 泄露,会直接在通知栏弹出提醒。
- 定位分析:如果遇到 LeakCanary 搞不定的复杂泄露,我会用 Android Profiler 的 Memory 模块。我会手动点一下那个“捕获堆转储(Dump Heap)”,然后看那个
Leak列表。 - 深挖路径:我会重点看对象的 最短引用路径(Merge Shortest Paths to GC Roots)。看看到底是谁在“拽着”这个本该销毁的对象不撒手。比如是一个单例里的集合,还是一个没跑完的子线程。
至于怎么解决,核心思路就是:该断开的引用及时断开,或者让引用的生命周期与对象的生命周期保持一致。
- Context 误用:这是最常见的。比如单例里传了
Activity的 Context。我会建议改成getApplicationContext()。 - 非静态内部类/匿名内部类:它们会默认持有外部类(比如 Activity)的引用。如果内部类里有耗时任务(像
Handler发了个延时消息),Activity 销毁了任务还在跑,就泄露了。解决方法是把内部类改成static,并配合WeakReference(弱引用) 来操作外部类。 - 资源未关闭:比如
BroadcastReceiver没注销、EventBus没unregister、或者WebView没在onDestroy里彻底销毁。
我在做项目优化时,发现很多内存泄漏其实是代码习惯问题。建立一套生命周期感知(Lifecycle-Aware)的代码规范,能从源头上规避掉 80% 的泄露。
LeakCanary 转储堆记录了什么?它是怎么检测内存泄漏的,原理是什么?
嗯,LeakCanary 简直是 Android 程序员的救星。它转储的其实是一个 .hprof 文件。
这个文件里记录的内容非常详细,包括:堆内存中所有的对象实例、每个对象的类信息(类名、父类、静态字段)、对象之间的引用关系、以及当前所有线程的栈帧信息。 简单来说,它就像是给当时的堆内存拍了一张全景照片,通过这张照片,我们能反推出任何一个对象是被谁持有的。
关于 LeakCanary 的检测原理,其实我觉得它的设计非常精巧,主要可以拆解为以下几个步骤:
-
自动挂钩(Hooking): LeakCanary 利用了 Android 提供的
ActivityLifecycleCallbacks(还有 Fragment 的FragmentLifecycleCallbacks)。当一个 Activity 调用了onDestroy()之后,它会自动把这个 Activity 包装成一个WeakReference(弱引用),并关联到一个ReferenceQueue(引用队列) 中。 -
延迟检测(Watching): 它不会立马去查。它会等个 5 秒左右,然后手动触发一次系统的 GC。
-
引用队列校验(Checking): 这是核心逻辑:如果一个对象被回收了,那么它的弱引用就会出现在关联的
ReferenceQueue里。 LeakCanary 会去检查队列。如果 Activity 的弱引用没出现在队列里,说明这个对象还没被回收。这时候它会认为这个 Activity “疑似泄露”。 -
堆转储分析(Analyzing): 一旦确认泄露,它会调用
Debug.dumpHprofData()生成堆转储文件。然后它会启动一个专门的服务,使用一个叫 Shark 的库(这是 LeakCanary 自己实现的轻量级 HPROF 解析引擎)去分析这个文件。它会从 GC Roots 开始寻找最短路径,找到那个导致泄露的关键节点,并把引用链画出来。
我在看源码的时候发现,LeakCanary 2.x 版本之后完全用 Kotlin 重写了,分析速度比以前快了很多。它现在不仅能搜 Activity,甚至连 ViewModel 和 RootView 的泄露都能监控,非常强大。
所有内存泄漏问题弱引用都能解决吗?
嗯,这个问题的答案肯定是否定的。弱引用(WeakReference)不是内存泄漏的“万能药”,它只是一种缓解手段。
为什么这么说呢?我有几个维度的思考:
第一,弱引用不解决逻辑错误。 有些内存泄漏本质上是业务逻辑写得有问题。比如你注册了一个全局监听器(Listener),但在 Activity 销毁时忘了注销。虽然你可能通过弱引用让 Activity 能被回收了,但那个监听器回调可能依然在后台莫名其妙地触发,导致空指针异常或者多余的计算开销。这其实是逻辑上的“泄露”,弱引用治标不治本。
第二,弱引用可能导致过早回收,破坏业务逻辑。 弱引用的特性是:只要发生 GC,不管内存够不够,都会回收它。 如果你在做一个非常关键的功能,比如文件上传。如果你为了防止泄露把回调改成了弱引用,结果用户切换了一下 App,系统触发了 GC,你的回调对象被回收了。这时候上传成功后,App 就无法接收到结果通知。这种情况下,你应该做的是在正确的生命周期节点上手动清理引用,而不是简单粗暴地换成弱引用。
第三,内存抖动与开销。 虽然弱引用能帮我们回收内存,但如果一个项目里到处乱用弱引用,频繁地依赖 GC 来清理由于设计不当产生的残留对象,会导致 内存抖动(Memory Churn)。频繁 GC 会让 CPU 负荷变大,界面就会感到明显的掉帧。
最后,有些地方根本没法用弱引用。 比如在 Native 层(JNI)产生的泄露,或者是 FrameWork 层某些静态集合持有的对象。弱引用只能解决 Java/Kotlin 层面的对象引用关系。
所以,我的观点是:弱引用应该作为“最后的防线”或者处理“无法控制生命周期的第三方组件”时的手段。 真正的解决之道,应该是理清对象的生命周期。比如在 onStop 或 onDestroy 里手动把引用置为 null,或者利用 Jetpack 的 Lifecycle 组件来自动管理任务的生命周期。
Android 上内存泄漏的典型场景?
嗯,在 Android 开发中,内存泄漏是一个非常经典且让人头疼的问题。如果让我总结一下,结论就是:大部分内存泄漏都源于“长生命周期的对象持有了短生命周期的对象”,导致短命对象无法被 GC 回收。
具体到业务开发中,我遇到的典型场景主要有这么几个:
-
静态变量持有 Activity 的 Context: 这是最常见的。比如有人为了图方便,写了个全局单例,然后在构造函数里传了个
Activity。因为单例(静态变量)的生命周期跟进程一样长,而 Activity 在退出后本该销毁。这时候 Activity 就被“拽住”了,整个 View 树和资源都泄露了。其实,这时候应该传入ApplicationContext。 -
非静态内部类(尤其是 Handler): 大家写 Handler 的时候经常直接
new Handler() { ... }。这种匿名内部类会隐式持有外部 Activity 的引用。 其实,重点在于MessageQueue。你发出的Message里的target字段会持有这个 Handler,而 Handler 又持有 Activity。如果发了一个延迟 10 分钟的消息,那这 10 分钟内 Activity 哪怕关了也回收不了。所以我在看ActivityThread源码的时候,注意到系统很多 Handler 都是static的,或者通过WeakReference来引用 Activity。 -
匿名内部类里的异步任务(Thread/Timer): 比如在
onCreate里开了一个new Thread跑耗时任务,如果任务没结束,Activity 就退出了,线程依然在后台跑,它持有的 Activity 引用依然有效。这也就是为什么现在推荐用 协程 (Coroutines) 并绑定lifecycleScope,因为它能随生命周期自动取消任务。 -
资源未关闭或未注销: 比如我们在
onCreate里注册了BroadcastReceiver、EventBus或者订阅了某个Observable,但在onDestroy里忘了unregister或者dispose。这些全局的订阅中心会一直持有观察者的引用。 -
WebView 的泄露: 这算是 Android 里的一个“顽疾”。WebView 内部有很多复杂的引用关系,甚至涉及到系统的硬件加速逻辑。很多时候即便你调用了
destroy()也不管用。我之前的做法是把 WebView 放在单独的进程里,用完直接杀掉进程,这是最彻底的解决办法。 -
动画未停止: 属性动画(ValueAnimator)如果不手动调用
cancel(),它会一直持有 View 的引用,而 View 又持有 Activity。
我在排查这些问题时,通常会先看 LeakCanary 的报告。如果发现引用链里出现了 mContext 或者 this$0 这种字眼,基本上就能锁定是内部类或 Context 误用的问题。
双 token 的刷新流程,在服务端校验流程?
关于双 Token 机制,它的核心结论是:为了平衡“安全性”和“用户体验”,通过一个短命的 AccessToken 保证接口安全,通过一个长命的 RefreshToken 实现无感登录。
具体的刷新流程,我带大家走一遍逻辑:
-
登录阶段:用户登录成功,服务器下发两个 Token:
AccessToken(比如 30 分钟有效)和RefreshToken(比如 7 天有效)。 -
正常请求:客户端把
AccessToken放在 Header 的Authorization字段里。 -
AT 过期:当
AccessToken过期时,客户端发起的请求会收到服务器返回的 401 Unauthorized 错误码。 -
静默刷新:客户端的 OkHttp 拦截器(Interceptor) 或
Authenticator捕获到 401 后,暂停后续请求,自动调用“刷新接口”,把RefreshToken发给服务器。 -
服务端校验:
- 服务端收到
RefreshToken后,首先校验它的签名和有效期。 - 关键点:服务端通常会在数据库(比如 Redis)里查一下这个
RefreshToken是否已被拉黑或撤回。 - 如果校验通过,服务端生成一个新的
AccessToken(有时候也会顺便滚一个新RefreshToken)返回给客户端。
- 服务端收到
-
重试请求:客户端拿到新 Token 后,更新本地存储,并重新发起刚才那个失败的请求。对用户来说,整个过程是完全无感知的。
-
RT 也过期:如果
RefreshToken也过期了,服务端会返回特定的错误(比如 403),这时候客户端必须强制用户跳转到登录页。
在服务端的校验流程里,通常会有这样的逻辑:
- AccessToken:通常是 JWT 格式,服务端是无状态校验。只需要用密钥验签,解密出 Payload 里的
userId和过期时间即可,不需要查库,速度极快。 - RefreshToken:服务端通常会做有状态校验。虽然它也可以是 JWT,但为了能随时“踢人下线”,服务端会在数据库里记一笔。校验时要比对
token_id是否有效。
这种设计的精髓在于:即便是 AccessToken 被黑客截获了,它的有效期也很短;而 RefreshToken 只在刷新时发送一次,暴露风险极低。
token 是怎么生成的,保存在哪?
关于 Token 的生成和存储,这直接关系到应用的安全性。
首先说生成。 虽然 Token 是服务端生成的,但在面试中我们要展现出对 JWT (JSON Web Token) 的理解。JWT 通常由三部分组成:
- Header:声明类型(JWT)和加密算法(比如 HS256)。
- Payload:存放非敏感的业务数据,比如
user_id、权限列表、签发时间(iat)和过期时间(exp)。 - Signature:这是最关键的。它是把前两部分用 Base64 编码后,再加上一个只有服务端知道的 Secret(密钥),通过哈希算法生成的。 公式大概是: 这样客户端就没法伪造数据,因为一旦改了 Payload,签名就对不上了。
然后说存储,在 Android 端,我有几种选择:
-
SharedPreferences / MMKV: 这是最常见的。如果是普通的 Token,存
SharedPreferences够用了。如果追求极致的读写性能(比如在主线程读取),我会用腾讯开源的 MMKV,它是基于 mmap 的,性能比原生 SP 强很多。 -
DataStore: 这是 Google 现在推的 Jetpack 组件,用来替代 SP。它支持协程和 Flow,而且能保证原子性操作,不容易出现丢数据的情况。
-
EncryptedSharedPreferences: 如果对安全性要求极高(比如涉及到支付或敏感隐私),我会用这个。它底层配合了 Android Keystore System。密钥存在硬件级别的 TEE(可信执行环境)里,就算手机被 Root 了,黑客也很难拿到明文密钥来解密 Token。
-
内存存储: 在 App 运行期间,我会把它存在一个单例(Repository)里,方便各个模块直接调用,减少 IO 损耗。
总结一下:生成靠 JWT 保证不可篡改性,存储靠系统提供的持久化方案,并根据安全等级选择是否加密。
session 和 token 的区别?
嗯,这是一个非常经典的问题。虽然它们都是为了记录用户的登录状态,但底层的哲学完全不同。一句话总结:Session 是“有状态”的,Token 是“无状态”的。
具体区别我分三个维度来说:
1. 存储位置与压力
- Session:数据存在服务端。用户登录后,服务器开辟一块内存空间存用户信息,只给客户端发一个
sessionId(通常存在 Cookie 里)。这就意味着,如果有 100 万个活跃用户,服务器内存就要扛住 100 万条 Session。 - Token:数据存在客户端。服务器不存任何东西,它只负责“发证”和“验证”。所有的用户信息都在 Token 字符串里,服务端收到后解密一下就知道你是谁了。这大大减轻了服务器的压力。
2. 扩展性(分布式场景)
-
这是 Token 完胜的地方。如果我有 10 台服务器做了负载均衡:
- Session 模式下,如果用户第一请求在 A 机器,第二请求被分到了 B 机器,B 机器没存该用户的 Session,用户就会被踢下线。这时候得搞“Session 共享”(比如存 Redis),架构变复杂了。
- Token 模式下,只要 A、B 机器都有相同的校验密钥,哪台机器都能验证 Token。这非常适合现在的微服务架构。
3. 安全性与撤回
- Session:因为状态在服务器手里,想让谁下线,直接把 Session 删了就行。安全性较高,但对 Cookie 的依赖容易导致 CSRF(跨站请求伪造) 攻击。
- Token:一旦发出去,在过期前它都是有效的。服务端很难主动撤回(除非搞个黑名单数据库,但那样就又变回有状态了)。不过 Token 存放在 Header 里可以有效规避 CSRF 攻击。
在 Android 开发中,由于移动端对 RESTful API 的天然亲和力,Token(尤其是 JWT)已经是事实上的行业标准。Session 更多出现在传统的 Web 开发中。我在做 Android 架构设计时,基本都是围绕 Token 体系来构建权限管理的。
场景题:HashMap 用 A 类对象为键存储后修改其属性,再次 get 的结果一样吗?
嗯,直接给结论:大概率是不一样的,通常会返回 null。
其实这个问题的核心在于 HashMap 的寻址机制。我们要明白,get(key) 的时候发生了两件事:
- 算哈希:调用
key.hashCode()计算哈希值,然后根据数组长度取模找到对应的桶(Bucket)。 - 比对:在桶里遍历链表或红黑树,通过
equals()方法找到真正匹配的那个 Entry。
如果你修改了 A 类对象的某个属性,而这个属性恰好参与了 hashCode() 的计算,那么当你再次 get(a) 的时候,算出来的哈希值就变了。这时候 HashMap 会去另外一个桶里找,那肯定找不到,直接返回 null。
即便运气好,新的哈希值正好落在了原来的桶里(哈希碰撞),在第二步 equals() 比对时,如果你也重写了 equals() 且该属性参与了比对,那么 a.equals(old_a) 也会返回 false。
那怎么保证一样呢? 其实有几个方案:
- 最推荐的做法:保持 Key 的不可变性(Immutability)。就像
String或者Integer那样,一旦创建就不让改属性。在 Java 里我会给字段加final。 - 不重写
hashCode()和equals():这样它默认使用的是Object的实现,也就是基于“内存地址”来判断。哪怕你属性改得面目全非,只要还是那个对象实例,get就能成功。但这种做法在业务逻辑上往往不符合要求。 - 只用不变量计算哈希:比如 A 类有个
id字段是永远不变的,我只用id来重写hashCode和equals,其他会变的属性(比如name)不参与计算。
我在看 AOSP 源码 的时候发现,系统层很多 Map 的 Key 都是非常简单的不可变对象,这其实就是为了规避这种低级错误。
场景题:100MB 大文件,10MB 内存限制,找 Top 100 高频词?
这是一个非常经典的大数据处理面试题。100MB 虽然不算特别大,但 10MB 的内存限制意味着我们绝对不能一次性把文件全部读进内存。
我的解决思路是:分而治之(Hash Partitioning) + 小顶堆(Min-Heap)。
第一步,分片(Partitioning):
我们需要把这个 100MB 的大文件切成若干个小文件。但不能随便切,我们要保证相同的单词必须落在同一个小文件里。
我会用流式读取文件,每读到一个单词,计算它的 hash(word) % N(比如 ),然后把这个单词写到对应的第 个小文件中。这样,即便某个高频词出现了几万次,它也只会在那一个小文件里。
第二步,统计(Counting):
我们依次处理这 20 个小文件。因为每个小文件大约只有 5MB 左右,我们可以放心地在内存里开一个 HashMap<String, Integer> 来统计每个单词出现的频率。
处理完一个小文件,我们就得到了它内部的局部频率排名。
第三步,归并查找 Top 100(Merging):
这是最关键的一步。我们不需要把所有统计结果合并,只需要维护一个大小为 100 的小顶堆(在 Java 里可以用 PriorityQueue )。
- 遍历第一个小文件的统计结果,把前 100 个词塞进堆里。
- 从第 101 个词开始,跟堆顶(当前前 100 名里频率最低的那个)比。如果它的频率比堆顶高,就踢掉堆顶,把它塞进去。
- 依次处理完所有小文件的统计结果。
最后,这个堆里剩下的 100 个词,就是整个 100MB 文件里的 Top 100 高频词。
这种思路其实就是 MapReduce 的简化版。我在处理 Android 端的埋点日志解析时,如果日志文件太大,也会采用类似的流式处理思想,防止 App 出现 OOM。
算法题:二叉树的非递归后序遍历?
嗯,二叉树的后序遍历(左 -> 右 -> 根)在非递归实现里确实是三种遍历中最难的一种。因为我们要保证在访问根节点之前,左子树和右子树都已经被访问过了。
我通常有两种实现思路。第一种比较“投机取巧”,适合面试快速写出来;第二种是更正统的单栈法。
思路一:双栈法(或者结果反转法) 其实我们可以观察一下:
- 先序遍历是:根 -> 左 -> 右
- 后序遍历是:左 -> 右 -> 根 如果我们把先序遍历稍微改一下,改成:根 -> 右 -> 左。你会发现,这刚好是后序遍历的逆序! 所以,我们可以准备两个栈。栈 A 用来做类似先序的遍历(根入栈,弹栈,先压左再压右,这样弹出来就是根右左),每弹出一个元素就压入栈 B。最后把栈 B 全部弹出,得到的顺序就是左、右、根。
思路二:单栈法(更考察基本功)
这种方法只用一个辅助栈,通过一个 lastVisited 指针来记录上一个访问过的节点。
逻辑是这样的:
-
只要当前节点不为空,或者栈不为空,就进入循环。
-
内部循环先把左子树全部压入栈。
-
拿到栈顶元素(但不弹出),观察它的右子树:
- 如果右子树存在,且上一个访问的节点不是右子树,说明右子树还没处理,那就把当前节点切换到右子树,继续压栈。
- 否则(右子树为空,或者已经访问过了),说明轮到根节点了。这时候弹栈,访问它,并记录
lastVisited = current,最后把当前节点置为空(防止重复压左子树)。
我在写这种非递归算法时,脑子里其实是在模拟 JVM 的方法栈帧。递归本质上就是系统帮我们压栈,非递归就是我们自己管理这个 Stack。在 Android 开发中,这种深度优先搜索(DFS)的思想,也经常用在遍历 View 树查找特定控件的逻辑里。
字节跳动 日常实习二面
完整网络请求的过程
嗯,关于网络请求的完整过程,其实如果从我们 Android 应用层调用 OkHttp 或者 Retrofit 开始看,直到最后拿到数据,中间经历了一段非常复杂的“长途跋涉”。简单来说,这个过程可以概括为:域名解析、建立连接、发起请求、服务器处理、返回响应。
其实第一步是 DNS 解析。因为我们代码里写的通常是域名,但底层 Socket 通信需要 IP 地址。应用会先查本地缓存,没有的话就去请求运营商的 DNS 服务器,甚至递归到根域名服务器。在 Android 里,为了优化这个过程,我们有时候会搞 HttpDNS,绕过运营商域名劫持,直接通过 IP 访问。
拿到 IP 之后,就进入了 TCP 三次握手 阶段。也就是客户端发个 SYN,服务端回个 SYN-ACK,客户端再回个 ACK。这步是为了确保双方的收发能力都是正常的。如果是 HTTPS,接下来还得走 SSL/TLS 握手,这个我等下可以细说。
连接建好后,就是 HTTP 报文的传输。客户端会构造一个 Request 报文,包括请求行、请求头和请求体。其实在 OkHttp 源码里,这一步是在 CallServerInterceptor 里通过 BufferedSink 写入的。
然后数据经过路由器、交换机,到达服务器。服务器端的负载均衡(比如 Nginx)会把请求转发给具体的业务逻辑处理。处理完后,服务器按同样的路径返回一个 Response 报文。
最后,客户端收到数据,完成 TCP 四次挥手 断开连接(或者为了复用连接,保持 Keep-Alive 状态)。在 Android 应用里,我们拿到流数据,通过 Moshi 或 Gson 这种解析器转成 POJO 对象,整个流程就走完了。
SSL 握手的详细过程是什么样的
SSL(或者现在准确说是 TLS)握手,是整个网络安全的核心。它的结论就是:通过非对称加密来协商对称加密的密钥,同时验证服务器的身份。
具体的步骤,我当时看源码和抓包分析时,大概分为这四步:
首先是 Client Hello。客户端(比如我们的 App)会给服务器发一个“打招呼”的消息,里面包含了客户端支持的 TLS 版本、加密套件列表(Cipher Suites),还有一个最重要的:一个客户端随机数(Random1)。
然后是 Server Hello。服务器从中选一套加密方案,也回一个服务器随机数(Random2)。紧接着,服务器会发最重要的东西——数字证书。这个证书里包含了服务器的公钥和权威机构(CA)的签名。
第三步是 客户端验证与密钥交换。客户端拿到证书后,会去系统根证书库里找对应的公钥来验签。如果证书没问题,客户端会再生成一个随机数,叫 Pre-master Secret(预主密钥),并用服务器证书里的公钥对它进行加密,发给服务器。
最后一步,服务器用自己的私钥解开这个加密包,拿到预主密钥。到这里,客户端和服务器手里都有了三个随机数(Random1、Random2、Pre-master)。他们会用相同的算法把这三个数算成一个对称密钥(Session Key)。
之后所有的业务数据,都用这个对称密钥来加解密。为什么要这么绕呢?其实就是为了平衡安全性和性能:非对称加密慢,所以只用来换密钥;对称加密快,所以用来传大量数据。
请求的方法有哪些
嗯,其实根据 HTTP 标准,我们最常用的就是那几种。但如果站在 RESTful 设计 的角度来看,每个方法都有它的语义。
最常见的肯定是 GET(获取资源)和 POST(新建资源)。
然后是 PUT,通常用于更新资源。它跟 POST 的区别在于 PUT 是幂等的,也就是说你连续调十次 PUT 更新同一个用户头像,结果是一样的。
还有 DELETE,顾名思义就是删除资源。
除此之外,还有一些不太常用但很关键的:
- HEAD:它跟 GET 很像,但服务器只回 Header,不回 Body。我们做文件下载断点续传时,常先用 HEAD 探测一下文件大小。
- PATCH:它是对 PUT 的补充,用于局部更新。比如你只想修改用户的昵称,不需要传整个用户对象,用 PATCH 就更合适。
- OPTIONS:这在 Web 前端跨域请求里见得比较多,用来询问服务器支持哪些方法。
- TRACE:回显服务器收到的请求,主要用于诊断。
其实在 Android 开发中,我们绝大多数场景都是 GET 和 POST,其他的更多是在规范的微服务架构里会用到。
GET 和 POST 的区别
这个问题其实在面试中经常被问到,但我认为不能只答“一个参数在 URL,一个在 Body”。其实从 HTTP 规范 来看,最本质的区别是 语义(Safety & Idempotency)。
首先,GET 是安全且幂等的。所谓安全,是指它只读,不应该改变服务器状态;幂等是指多次请求结果一致。而 POST 是非安全、非幂等的,它通常用于产生副作用,比如下单、评论。
然后是数据传递方式。GET 的参数是挂在 URL 后面的 Query String 里的,受限于浏览器或 Web 服务器对 URL 长度的限制(虽然 HTTP 协议本身没限制长度,但实践中通常是 2KB 到 8KB)。而 POST 的数据放在 Request Body 里,理论上大小是没有限制的。
第三点是安全性。其实从协议层面说,两者都不安全,因为都是明文。但从感官上说,GET 会把敏感信息(比如密码)暴露在地址栏和日志里,所以涉及敏感操作必须用 POST。
还有一点细节,我之前看过一些分析,说是 POST 会发两个 TCP 包(先发 Header 得到 100 Continue 再发 Body),而 GET 只发一个。但其实这取决于具体的客户端实现(比如 Curl 或某些浏览器),在 OkHttp 里,它会根据实际连接情况做优化,不一定非要分两个包。
最后是缓存。GET 请求默认是可以被浏览器、CDN 缓存的,而 POST 请求默认不会被缓存,除非你显式设置 Header。
POST 请求的数据放在哪里
简单直接地说,POST 的数据是放在 HTTP 报文的请求体(Request Body / Entity Body) 里的。
但是,怎么放这些数据,是由请求头里的 Content-Type 决定的。这其实是 Android 开发中调试接口最容易出问题的地方。
大概有这么几种常见的“姿势”:
- application/x-www-form-urlencoded:这是最传统的表单提交。数据会被转成
key1=value1&key2=value2的格式,跟 URL 参数长得一样,只是挪到了 Body 里。 - application/json:这是目前移动端最主流的方式。Body 里就是一个 JSON 字符串。我们在 OkHttp 里写
RequestBody.create(json, JSON_TYPE)就是这种。 - multipart/form-data:这通常用于上传文件。它会定义一个
boundary(边界字符串),把 Body 分成好几段,每一段可以是一个字段,也可以是一个文件流。 - text/plain 或者 application/octet-stream:直接传纯文本或者二进制字节流。
其实在底层实现上,OkHttp 最终会通过 Sink 把这些数据写入到 Socket 的输出流里。
Header 通常有哪些内容
HTTP Header 就像是请求的“元数据”,告诉对方怎么处理这个报文。我把它分成 请求头(Request) 和 响应头(Response) 来说。
常见的请求头:
- Host:指定服务器域名,这在虚拟主机环境下非常重要。
- User-Agent:告诉服务器客户端的身份。我们在 Android 里通常会把它改成类似
MyApp/1.0 (Android 13; Pixel 6),方便后台做统计。 - Accept:告诉服务器我能处理什么类型的数据(比如
application/json)。 - Authorization:放 Token 用的,现在主流的 JWT 认证都放这儿。
- Content-Type:告诉服务器我 Body 里的数据是什么格式。
- Cache-Control:控制缓存策略,比如
no-cache。
常见的响应头:
- Content-Length:Body 的字节长度,客户端靠它来判断数据是否接收完整。
- Set-Cookie:服务器给客户端种下 Cookie,这也是 Session 机制的基础。
- Location:配合 301/302 状态码做重定向。
- Server:告诉客户端服务器用的什么软件(比如
nginx/1.18.0)。
其实在 Android 里,我们经常会写 Interceptor(拦截器)。比如在 OkHttp 的拦截器里统一加 Header,去处理日志或者身份校验,这非常方便。
响应状态码有哪些
状态码是服务器给我们的“回信”,告诉我们请求的结果。根据第一位数字,可以分成五类:
-
1xx(信息性状态码):请求已接收,正在处理。最常见的是
101 Switching Protocols,比如在用 WebSocket 通信时,协议升级就会看到它。 -
2xx(成功状态码):
200 OK:最标准的成功。201 Created:POST 请求成功创建了资源。
-
3xx(重定向状态码):
301 Moved Permanently:永久重定向。302 Found:临时重定向。304 Not Modified:这个对性能优化很重要。它告诉客户端,你缓存的数据还没过期,直接从本地拿就行。
-
4xx(客户端错误):
400 Bad Request:参数写错了,后台解析不了。401 Unauthorized:没登录或者 Token 失效。403 Forbidden:登录了,但没权限看这个东西。404 Not Found:路径写错了,没找到资源。
-
5xx(服务器错误):
500 Internal Server Error:后台代码抛异常崩溃了。502 Bad Gateway:通常是 Nginx 找不到后面的业务服务器了。503 Service Unavailable:服务器超负荷或者在维护。504 Gateway Timeout:网关等太久了,业务服务器没回。
平时使用互联网遇到过哪些状态码
除了写代码调接口时看到的,平时上网其实也经常遇到各种状态码。
印象最深的就是 404。小时候上网经常看到那个“页面找不到了”,甚至有些网站会把 404 页面做得很有个性。
然后是 401。比如我访问一些公司的内网系统,如果没有登录或者 Token 过期了,它会直接跳到登录页,后台其实返回的就是 401。
还有就是 502 Bad Gateway。其实很多大厂的服务崩了,比如之前某些 App 全线崩溃的时候,我打开网页版,经常能看到 Nginx 抛出的 502。这通常意味着接入层(网关)是好的,但是背后的业务逻辑服务集群挂掉了。
另外,我在做 Android 开发调试时,经常遇到 403。有时候是因为接口加了防盗链,或者 Headers 里少传了某个校验字段。
还有就是 304。其实在看浏览器的 Network 面板时,大量的静态资源(图片、JS)返回的都是 304。这说明 HTTP 缓存机制 确实生效了,节省了很多流量。
其实最怕见到的是 504 Gateway Timeout。以前抢票或者在学校抢课的时候,服务器卡得要死,点一下转半天圈,最后弹出一个“网关超时”,那就是后台服务器处理不过来,连接直接被上层断掉了。
熟悉哪些设计模式?
嗯,说到设计模式,其实在 Android 开发中,我们几乎每天都在跟它们打交道。我个人比较熟悉、也经常在项目里实践的大概有这几类:
首先是创建型模式。最基础的肯定就是 单例模式(Singleton),比如我们在做全局管理类或者数据库实例时常用。然后就是 建造者模式(Builder) 和 工厂模式(Factory),特别是 Builder,在处理复杂对象初始化时非常优雅。
其次是结构型模式。比如 代理模式(Proxy),Binder 机制底层就大量运用了它;还有 适配器模式(Adapter),我们写 RecyclerView 的时候天天都在用。另外还有 装饰者模式(Decorator),比如 Java 的 I/O 流。
最后是行为型模式。我觉得 Android 的灵魂就在于 观察者模式(Observer),像 LiveData、EventBus 或者系统的 Broadcast 都是这个思想。还有就是 责任链模式(Chain of Responsibility),在处理事件分发或者网络拦截时非常强大。
其实,我觉得学习设计模式不是为了“死记硬背”类图,而是要理解它怎么解耦、怎么提高代码的扩展性。我在读 AOSP 源码 或者 OkHttp 这种优秀开源库的时候,经常能感悟到这些模式组合使用的精妙之处。
建造者模式(Builder)什么时候用?
关于 Builder 模式,我觉得它的核心应用场景就是:当一个对象的构造过程非常复杂,或者可选参数非常多的时候。
其实,在传统的 Java 开发里,如果不使用 Builder,我们可能会面临两个尴尬的局面:
- 构造函数爆炸(Telescoping Constructor):为了适配不同的参数组合,你得写几十个重载的构造函数,维护起来简直是噩梦。
- Setter 模式的缺陷:虽然用空参构造加一系列
set方法能解决参数多的问题,但它会导致对象在构造过程中处于一种“中间状态”,不是线程安全的,而且你没法把这个对象设为不可变(immutable)。
所以,当我们希望一步步地构建复杂对象,并且想让代码读起来像自然语言一样清晰时,Builder 模式就是首选。
还有一个细节,就是当我们需要对参数做约束校验时,Builder 模式非常方便。我们可以在最终调用 build() 方法的那一刻,统一检查各个属性是否合法,如果不合法直接抛出异常,这样能保证生成的对象永远是有效状态。
责任链模式(Responsibility Chain)的场景?
责任链模式,它的精髓在于解耦了请求的发送者和接收者。
简单来说,它的应用场景是:一个请求需要经过多个环节处理,但具体由哪个环节处理、或者需要哪些环节共同处理,在编译期是不确定的,需要在运行期动态组合。
具体的表现形式通常是一个链表或者数组。请求沿着这条链传递,每个节点都有机会处理它,处理完后可以选择拦截,也可以选择继续传给下一个节点。
这种模式最大的好处就是灵活性。你可以随时增加、删除或者调整链条中节点的顺序,而不需要修改发送请求的代码。这非常符合开闭原则。
Android 哪些地方用到这些模式?
这部分我结合源码和常用库来详细说一下,其实 Android 内部到处都是这些模式的影子。
首先说 Builder 模式。
最经典的就是 AlertDialog.Builder。你看,我们要创建一个对话框,可能有标题、没标题、有确定按钮、没取消按钮……通过 Builder.setTitle().setMessage().create() 这种链式调用,代码非常直观。
另外,我们在用 OkHttp 的时候,配置客户端也是 new OkHttpClient.Builder().addInterceptor(...).build(),这保证了 OkHttpClient 一旦创建出来,它的配置就是不可变的,是线程安全的。
然后是 责任链模式,这在 Android 里有两个顶级应用:
- View 的事件分发机制:这其实就是一个变种的责任链。当一个
MotionEvent产生时,它会从DecorView开始,一层层往下传给ViewGroup再到View。每个onInterceptTouchEvent和onTouchEvent就像是链上的节点,决定是自己消费掉还是传给下一级。 - OkHttp 的拦截器(Interceptors):这是我最佩服的设计。它把重试、重定向、桥接、缓存、连接、数据读写全部封装成独立的
Interceptor。在RealInterceptorChain里,通过递归调用,请求像过关斩将一样流转。这种设计让我们开发者能非常方便地自定义拦截器,去统一加 Header 或者打印日志。
最后顺带提一下 适配器模式(Adapter)。
最直观的就是 RecyclerView.Adapter。其实 RecyclerView 本身并不关心你的数据长什么样(是 List 还是 Map),它只关心怎么显示视图。Adapter 就像一个“转换插头”,把复杂的业务数据转换成 ViewHolder 供 RecyclerView 渲染,完美解决了数据源和 UI 组件不兼容的问题。
我在看 AOSP 的 AMS(ActivityManagerService) 源码时,还发现它大量使用了 代理模式。比如应用进程持有的 IActivityManager 实际上是 AMS 的一个 Binder 代理对象,这种模式屏蔽了跨进程通信的复杂性,让我们用起来就像调用本地方法一样,这也是设计模式在系统底层非常核心的体现。
== 和 equals 的区别,以及 Object 的 equals 是怎么判断相同的?
嗯,关于 Java 里的相等性判断,这其实是面试里最基础但也最容易答浅的问题。
直接说结论:== 是操作符,比较的是“值”;而 equals 是 Object 类的一个方法,默认比较的是引用,但通常会被重写用来比较“逻辑内容”。
具体展开一下。对于 基本数据类型(比如 int、boolean),我们只能用 ==,因为它比较的就是具体的数值。但对于 引用类型(对象),== 比较的是这两个变量是不是指向堆内存里的同一个地址,也就是看它们是不是同一个对象。
然后是 equals。其实我之前特意去看过 java.lang.Object 的源码,它的实现非常简单粗暴,里面就一行代码:return (this == obj);。也就是说,如果不重写 equals,它跟 == 是完全等价的,都是比较内存地址。
但为什么我们平时觉得它们不一样呢?是因为像 String、Integer 这些常用的类,都把 equals 给重写了。比如 String 的 equals,它会先用 == 判断一下是不是同一个对象,如果不是,再通过一个 while 循环去逐个比较字符数组里的内容。
这里我多提一个细节,就是 hashCode 的契约。我们在重写 equals 的时候,一定要记得重写 hashCode。如果不重写,在使用 HashMap 这种散列表时,就会出现明明两个对象 equals 返回 true,但却找不回来的情况。我在读一些开源库源码时,发现大家对这个点的处理都非常谨慎,通常会用 Objects.hash() 来生成哈希值。
其实在 Android 开发里,我们也经常用到这个。比如我们自定义一个 User 实体类,如果不重写 equals,那在 DiffUtil 刷新 RecyclerView 的时候,逻辑可能就会出问题,导致不必要的全局刷新。
内部类怎么访问外部类?具体是怎么持有和区分的?
这个问题涉及到了 Java 虚拟机的底层实现,尤其是在做 Android 开发时,理解这一点对解决内存泄漏至关重要。
先说结论:非静态内部类之所以能访问外部类,是因为编译器在编译阶段,偷偷地给内部类增加了一个指向外部类实例的引用。
具体是怎么实现的呢?
其实我们可以通过 javap 命令反编译一下生成的 .class 文件。你会发现,非静态内部类的构造函数会被编译器修改,它会多出一个参数,类型就是外部类。这个引用在内部类里通常被命名为 this$0。
这就是为什么我们在内部类里可以直接调用外部类的方法或变量。哪怕那些变量是 private 的,内部类也能访问。这其实也是个很有意思的点,因为在 JVM 层面,private 是不能跨类访问的,所以编译器还会为外部类生成一些 access$xxx 样的静态方法,用来辅助内部类进行访问。
那在代码里怎么区分呢?
- 如果在内部类里想引用内部类自己的成员,直接用
this.xxx。 - 如果想引用外部类的成员,特别是当内外变量名冲突时,我们要写成
外部类名.this.xxx。这里的OuterClass.this实际上就是编译器通过那个this$0帮我们找回了外部类的实例。
这里我必须强调一下静态内部类(Static Inner Class)。
静态内部类是不持有外部类引用的。因为它不需要那个 this$0。这就是为什么在 Android 里,我们写 Handler 的时候,Lint 工具总是提示我们要用 static。
因为如果 Handler 是非静态内部类,它会隐式持有 Activity 的引用。如果 Activity 销毁了,但 Handler 的消息队列里还有延时消息,这个 Activity 就没法被 GC 回收,从而导致严重的内存泄漏。
所以,我平时的习惯是,除非内部类和外部类有非常强的逻辑耦合,否则优先使用静态内部类,并通过 WeakReference(弱引用) 来持有所需的 Context 或 Activity 实例。这样既能访问外部成员,又能避免内存泄漏风险。
视图的绘制流程以及它是如何确定大小的?
嗯,说到 View 的绘制流程,这其实是 Android UI 框架的核心。如果用一句话总结,那就是:由 ViewRootImpl 发起,经历 measure、layout 和 draw 三大阶段,最终把 UI 渲染到屏幕上。
具体来说,整个流程的入口是在 ViewRootImpl.performTraversals() 这个方法里。其实,View 树的遍历是深度优先的。
首先是 Measure(测量)。这个阶段决定了 View “想要多大”。
它是通过 MeasureSpec 这个概念来传递限制条件的。MeasureSpec 是一个 32 位的 int 值,高 2 位是模式(SpecMode),低 30 位是大小(SpecSize)。
模式分为三种:EXACTLY(精确值)、AT_MOST(最大值)和 UNSPECIFIED(不限制)。
父容器会结合自己的 MeasureSpec 和子 View 的 LayoutParams,计算出子 View 的 MeasureSpec,然后调用 child.measure()。
然后是 Layout(布局)。这个阶段决定了 View “具体在哪”。
父容器会根据测量出来的 measuredWidth 和 measuredHeight,结合自己的布局逻辑(比如 LinearLayout 的线性排列),调用子 View 的 layout(l, t, r, b) 方法,把四个坐标点定下来。
最后是 Draw(绘制)。这就是“画出来”的过程。
它会调用 draw() 方法,里面通常有六个步骤,最核心的是 onDraw()。我们在自定义 View 的时候,就是在这里拿到 Canvas 对象,利用 Paint 进行绘制。
那如何确定最终大小呢?
其实这就是 onMeasure 的职责。在 onMeasure 逻辑的最后,必须调用 setMeasuredDimension(width, height) 这个方法,这才是真正给 mMeasuredWidth 和 mMeasuredHeight 赋值的地方。如果你写自定义 View 忘了调这个,程序就会直接抛出 IllegalStateException 异常。
其实我之前在读源码时发现,getMeasuredWidth() 和 getWidth() 是有区别的。前者在 measure 之后就有值了,而后者要在 layout 之后才有。虽然绝大多数情况下它们相等,但在某些极端场景下(比如布局溢出),它们可能会不一样。
Looper 的作用、它与线程的关系以及主线程的创建?
关于 Looper,它是 Android 消息机制的“发动机”。
简单一句话:Looper 的作用就是让一个普通线程变成“循环线程”,它开启一个死循环,不断从 MessageQueue 里取消息并分发。
它跟线程的关系是 “一一对应” 的。
在 Java 层,这是通过 ThreadLocal<Looper> 来实现的。当你调用 Looper.prepare() 时,它会检查当前线程是否已经有 Looper 了,如果没有,就 new 一个存进 ThreadLocal。这意味着一个线程只能绑定一个 Looper,这样能保证消息的处理是线程安全的、串行的。
那 Android 主线程(UI 线程)是什么时候创建的呢?
其实主线程并不是在某个普通的 new Thread 里产生的,它是由 Zygote 进程 fork 出来的。
具体的入口点是 ActivityThread.main() 方法。这个方法非常关键,它是应用进程的起点。
在 main 方法里,系统会调用 Looper.prepareMainLooper(),这会创建一个特殊的 Looper(不允许退出)。然后接着调用 Looper.loop(),从此主线程就进入了消息循环。
我之前看 ActivityThread 源码的时候注意到一个细节,主线程里其实有一个叫 H 的内部类(其实就是一个 Handler)。所有来自系统的指令,比如启动 Activity(EXECUTE_TRANSACTION)、接收广播等,最终都是通过这个 H 发送到主线程的消息队列里去执行的。这就是为什么我们在主线程里不能做耗时操作,因为一旦某个消息处理太久,后面的 UI 刷新消息就会排队,导致卡顿。
前后台切换会回调什么函数?什么情况下只回调一个?
Activity 的生命周期切换其实体现了 Android 对资源的动态调度。
一般情况下,当一个应用从前台切换到后台(比如按了 Home 键),回调顺序是:
onPause() -> onStop()。
这时候 Activity 虽然不可见了,但实例还在内存里。如果系统内存实在不够了,可能会被回收。
当它从后台重新回到前台时,回调顺序是:
onRestart() -> onStart() -> onResume()。
那什么情况下只回调一个函数呢?(这里通常指只回调 onPause 而不回调 onStop)
这种场景其实比较特殊,结论是:当 Activity 失去焦点,但仍然在屏幕上可见时。
最典型的场景有:
- 弹出一个“非全屏”的 Activity:比如启动了一个 Theme 设为
Theme.Dialog的 Activity。这时候原 Activity 还能看到一部分,所以它只走onPause(),不会走onStop()。 - 多窗口/分屏模式(Multi-Window):在 Android 7.0 之后,如果你在分屏状态下操作另一个窗口,当前的 Activity 虽然可见,但它失去了焦点,这时候它处于
onPause状态。 - 权限申请弹窗:虽然现在的权限弹窗大多是系统级的,但在某些旧版本或者特殊实现下,也会触发
onPause。
这里我多说一点,很多人容易把 AlertDialog 跟这个搞混。弹出 AlertDialog 或者 PopupWindow 是不会触发 onPause 的。因为它们是当前 Activity 窗口的一部分,并没有发生 Activity 之间的切换。
理解这个区别在实际开发中很有用。比如我们通常在 onPause 里暂停视频播放或停止传感器监听,如果只是弹个 Dialog 就把视频停了,那用户体验就很差了。所以我一般会根据业务需求,决定是放在 onPause 还是 onStop 里去做资源释放。
请写一个完美一点的单例模式,并解释其中的细节
嗯,说到单例,在 Android 开发里最推荐的、也是最经得起面试官推敲的实现方式,一定是双重检查锁定(Double-Checked Locking, DCL)。
我先直接把代码的核心逻辑口述一下:
public class Singleton {
private static volatile Singleton instance; // 必须加 volatile
private Singleton() {} // 私有构造
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}其实这个写法里的每一行都是有讲究的。
为什么要加 volatile?
这其实是为了禁止指令重排序。你要知道,instance = new Singleton() 这行代码在 JVM 层面其实分成了三步:
- 分配内存空间。
- 初始化对象。
- 将
instance指向分配的内存地址。 如果没有volatile,CPU 可能会为了优化性能把第 2 步和第 3 步掉个个儿。那这时候,如果线程 A 执行到了第 3 步(还没执行第 2 步),线程 B 刚好调用getInstance,它会发现instance不为 null,于是直接拿走了一个还没初始化完成的残缺对象。这在运行期间就会出奇奇怪怪的 Bug。
如果去掉第一个判空会怎样?
程序依然是正确的,但性能会劣化。因为如果没有第一个判断,所有的线程每次调用 getInstance 都得去竞争锁,这在高并发场景下开销非常大。加了第一个判断,一旦单例创建好了,后面的线程直接拿走就行,不需要进锁。
如果去掉第二个判空呢?
那单例就失效了。假设线程 A 和线程 B 同时通过了第一个判断。线程 A 先拿到了锁,创建了对象,释放锁。紧接着线程 B 拿到锁,如果没有第二个判断,它会不管三七二十一再 new 一个出来。
锁对象可以换成其他的吗?
其实只要是一个静态的、唯一的对象就行。不过通常我们直接用 Singleton.class,因为它是全局唯一的类对象,简单又安全。但千万不能用 this 或者普通的成员变量,因为 getInstance 是静态方法。
其实除了 DCL,我也了解静态内部类的写法,它利用了类加载机制保证了线程安全和延迟加载,在 Android 里也挺常用的,不过 DCL 更加经典,更能体现对 JMM(Java 内存模型) 的理解。
如何实现一个生产者消费者模式?
生产者消费者模式本质上是解决线程通信和同步的问题。在 Android 的 Handler 机制底层,其实就闪烁着这种思想。
我通常会用 ReentrantLock 配合 Condition 来手写一个阻塞队列的逻辑,这样比原生的 wait/notify 更加灵活和可读。
public class Storage {
private final Queue<Object> queue = new LinkedList<>();
private final int MAX_SIZE = 10;
private final ReentrantLock lock = new ReentrantLock();
// 两个条件变量:一个管“不满”,一个管“不空”
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void produce() throws InterruptedException {
lock.lock();
try {
while (queue.size() == MAX_SIZE) {
notFull.await(); // 满了就等
}
queue.add(new Object());
notEmpty.signalAll(); // 生产了,通知消费者
} finally {
lock.unlock();
}
}
public void consume() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 没了就等
}
queue.poll();
notFull.signalAll(); // 消费了,通知生产者
} finally {
lock.unlock();
}
}
}其实实现它的核心在于那个 while 循环。为什么要用 while 而不是 if 呢?
这就是为了防止虚假唤醒(Spurious Wakeup)。线程被唤醒后,可能由于某些原因(比如竞争环境变化),队列依然是满的或空的,所以必须重新检查一遍条件。
当然了,在实际开发中,我们一般直接用 Java 提供的 BlockingQueue(比如 LinkedBlockingQueue)。我在读 OkHttp 源码的时候发现,它的线程池任务调度其实也是利用了类似的阻塞队列机制。
算法题:求岛屿的最大面积(DFS 优化版)
这是一个经典的网格搜索问题。 结论是:使用 DFS(深度优先搜索)遍历每个点,当遇到陆地(1)时,递归搜索其四周,并将搜索过的点置为 0(沉岛),最后统计递归深度。
为了不使用全局变量或静态变量,我们可以通过递归函数的返回值来累加面积。
public int maxAreaOfIsland(int[][] grid) {
int maxArea = 0;
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (grid[i][j] == 1) {
// 将递归返回的面积与当前最大面积做比较
maxArea = Math.max(maxArea, dfs(grid, i, j));
}
}
}
return maxArea;
}
private int dfs(int[][] grid, int r, int c) {
// 边界检查和水域检查
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length || grid[r][c] == 0) {
return 0;
}
// “沉岛”操作,防止重复计算
grid[r][c] = 0;
// 递归累加:1(当前点) + 上下左右四个方向的结果
return 1 + dfs(grid, r - 1, c) + dfs(grid, r + 1, c)
+ dfs(grid, r, c - 1) + dfs(grid, r, c + 1);
}关于复杂度分析:
- 时间复杂度: 是 。 其中 和 分别是网格的行数和列数。虽然我们用了递归,但每个格子最多只会被访问一次(因为访问完就变成 0 了),所以它是线性的。
- 空间复杂度: 最坏情况下是 。这主要取决于递归调用的栈深度。如果整个网格全是陆地,递归树的深度就会达到 。
其实这个题我在刷 LeetCode 的时候发现,除了 DFS,用 BFS(广度优先搜索)也能做。但在面试手写代码时,DFS 这种利用返回值累加的写法最简洁,也最能体现对递归模型的掌握。而且把 grid[i][j] 置为 0 这个“技巧”,省去了额外开辟 visited 数组的空间开销,我觉得是一个加分点。
字节跳动 三面
鸿蒙开发感受如何,和安卓相比有什么区别?
嗯,关于鸿蒙开发,其实我最近这段时间也一直在关注,尤其是 HarmonyOS NEXT,也就是大家说的“纯血鸿蒙”。作为一个有 Android 背景的开发者,我最直观的感受就是:鸿蒙在开发范式上做得非常激进且现代化,它基本舍弃了 Android 早期那种基于命令式的沉重设计。
具体对比的话,我觉得有这几个核心区别:
首先是 UI 开发范式。Android 虽然现在在推 Compose,但大部分存量代码还是 XML 那一套。而鸿蒙从一开始就主推 ArkUI,它是完全声明式的。如果你写过 Flutter 或者 Compose,你会发现 ArkTS 上手极快。它的状态管理机制,比如 @State、@Prop、@Link 这些装饰器,比 Android 的 ViewModel + LiveData 那一套写起来要简洁得多,代码量起码能少个 30% 到 40%。
其次是核心组件模型。Android 我们讲四大组件(Activity、Service 等),而鸿蒙是基于 Stage 模型。它把核心抽象成了 Ability。我觉得这个设计比 Android 聪明的一点是,它原生就考虑了“跨设备”。比如它的 UIAbility 和 ExtensionAbility 划分得非常清楚,配合 Distributed Data Service,实现那种跨端流转(比如手机上的视频流转到平板)比 Android 原生要简单几个数量级。
再就是底层运行环境。Android 主要是 JVM/Art 虚拟机,跑的是字节码。而鸿蒙 NEXT 用的是 ArkCompiler,它支持 AOT(Ahead-of-Time)编译,直接把代码编译成机器码,并且通过 ArkTS Runtime 做了深度优化。我研究过它的并发模型,它用的是 Worker 机制(基于 Actor 模型),跟 Android 的多线程共享内存机制完全不同,这从根源上规避了线程锁死的问题,也更安全。
其实说白了,鸿蒙更像是一个“去掉历史包袱”的 Android。它在语法糖、工具链、以及分布式场景的处理上,确实比现在的 Android 要更顺手一些。
你更倾向鸿蒙还是安卓开发?
这是一个挺现实的问题。如果说目前这个阶段,我个人在技术深度上更倾向于 Android,但从职业生命力和市场前景来看,我非常看好鸿蒙。
为什么这么说呢?
从技术深度来讲,Android 毕竟发展了十几年。像我研究的 Android Framework,它的 AMS、WMS、SurfaceFlinger 这些源码库非常庞大且深奥,它是目前移动端系统架构的“天花板”。我觉得作为一个软件工程专业的学生,通过钻研 Android 的底层,能学到最顶级的操作系统设计思想、Binder 通信机制以及内存管理方案。这种“打怪升级”的过程能建立起我非常扎实的技术底层。
但从未来倾向上说,如果我要去字节、腾讯这类大厂,我发现他们现在都在紧锣密鼓地重构鸿蒙版本。鸿蒙目前处在**“爆发前夜”,有点像 2011 年左右的 Android。虽然现在它的生态和技术文档还没 Android 那么厚实,但它的全场景分布式**特性,是目前 Android 甚至 iOS 都没能完全做透的。
所以,我的策略是**“深耕 Android 底层,积极拥抱鸿蒙”**。因为两者的系统架构底层逻辑其实是有相通之处的,比如异步处理、IPC 机制等。我通过 AOSP 积累的 Framework 经验,可以非常快速地迁移到鸿蒙的 OpenHarmony 底层研究中。如果面试的部门是专门做鸿蒙自研产品的,我也会非常愿意把精力投入到 ArkTS 和 HarmonyOS 系统底层开发中,因为在一个新兴平台的成长期切入,机会肯定是更多的。
鸿蒙开发安装包有哪些东西?
嗯,鸿蒙的安装包结构和 Android 的 APK 或者 AAB 有很大不同。它的最终发布形态是一个 .app 文件,但这个文件其实是个大包裹。
拆开来看,里面最核心的是 HAP(Harmony Ability Package)。一个应用可以包含多个 HAP。
具体的细节包括:
module.json5:这非常重要,相当于 Android 的AndroidManifest.xml。里面定义了应用的 Bundle 名称、Ability 声明、权限申请、以及设备类型。resources目录:存放所有的资源文件(图片、字符串等)。鸿蒙的资源处理有一点很好,它支持多层级覆盖,管理起来比 Android 的res文件夹更有序。ets目录:这里放的是编译后的代码。.abc文件:这是 Ark Byte Code。就是 ArkTS 代码经过 ArkCompiler 编译后的方舟字节码。libs:存放 C++ 编译生成的.so库,这跟 Android 是一样的。
此外,鸿蒙还有两个特殊的东西:
- HAR(Harmony Archive):静态共享库,类似 Android 的 AAR,编译时直接打包进 HAP。
- HSP(Harmony Shared Package):动态共享库,这是鸿蒙特有的。多个 HAP 可以在运行时共享同一个 HSP,这能有效减少安装包的大小。
其实,这种分层设计是为了实现其“一次开发,多端部署”的目标。系统会根据当前设备(比如是表还是车机)去按需组合这些 HAP 包进行安装。
安卓应用升级到鸿蒙数据是怎么迁移的?
这在目前“纯血鸿蒙”替代 Android 的过程中是一个非常核心的痛点。因为 HarmonyOS NEXT 不再兼容 Android,应用沙箱空间、文件系统架构都全变了。
目前市面上主流的迁移方案主要分三个维度:
第一是云端同步(最主流)。 其实像腾讯、字节的应用,用户数据主要是在服务器上的。这种情况下,用户在鸿蒙端登录同一个 华为帐号(或应用自有账号),通过接口直接拉取云端数据。这是代价最小、也是最无感的迁移方式。
第二是利用鸿蒙官方提供的迁移套件(Data Migration Kit)。
华为专门为大厂提供了数据迁移工具。当用户从 Android 切换到鸿蒙,通过“手机克隆”或者类似的系统级同步时,系统会尝试将 Android 路径下的 /data/data/com.pkg.name/ 下的特定数据(比如 SharedPreferences、数据库、文件)进行映射。
- 数据库层面:如果是 SQLite,需要把数据库文件导出,再通过鸿蒙的
relationalStore模块进行重新加载和格式转换。 - 文件存储:由于鸿蒙强制要求分区存储且路径和 Android 完全不同,开发者需要写转换逻辑,把旧的物理路径映射到鸿蒙的
Context.filesDir等新路径下。
第三是本地文件导出与导入。 对于一些单机属性较强的应用(比如本地记事本),我们会通过应用自定义的备份文件。用户在 Android 端导出一个加密包,然后在鸿蒙端通过“文件选择器(FilePicker)”读取并解析。
我在研究这个流程时发现,最麻烦的其实是 KeyStore(密钥管理)。Android 加密过的数据,在鸿蒙里由于硬件隔离级别和算法实现的差异,可能无法直接解密。这通常需要通过华为账号提供的安全存储能力来实现跨系统的密钥平替。
总的来说,这不仅仅是文件的拷贝,更多是业务逻辑层面的“数据重构”。
Java 中接口(Interface)和抽象类(Abstract Class)的区别?
嗯,这两个概念其实是 Java 面向对象设计的核心。简单一句话总结的话:抽象类是对“本质”的抽象,而接口是对“行为”的抽象。
其实在实际开发中,我会从三个层面去区分它们:
首先是设计语义上。抽象类体现的是 "is-a" 的关系,它代表一个类族。比如在 Android Framework 里,View 就是一个抽象类,所有的 TextView、Button 都是它的子类。而接口体现的是 "can-do" 或 "like-a" 的关系,它定义了一套规范。比如 CharSequence 接口,不管是 String 还是 StringBuilder,只要能提供字符序列功能,就得实现它。
其次是语法细节。
- 多继承:这是最明显的,Java 类只能单继承一个抽象类,但可以实现多个接口。
- 成员变量:抽象类可以有各种类型的成员变量,包括实例变量;但接口里的变量默认都是
public static final的常量。 - 构造函数:抽象类可以有构造函数,给子类初始化用;接口绝对没有构造函数。
- 方法实现:在 Java 8 之后,接口可以用
default关键字写默认实现,这让它跟抽象类越来越像,但它依然不能持有“对象状态”。
最后是使用场景。如果你想定义一个物体“是什么”,并且需要代码复用(比如在 BaseActivity 里封装通用的逻辑),那就用抽象类;如果你只是想定义一个“能力”或者“插件”,那就用接口。
接口的应用场景说个你使用到的例子?
我在项目中用得最多的地方就是 “模块间解耦”,特别是 回调(Callback)机制。
比如我之前写过一个图片加载框架的封装层。我不会直接在 Activity 里调用 Glide。我会定义一个接口叫 IImageLoader,里面有一个方法 displayImage(View view, String url)。
然后我写一个 GlideLoader 去实现这个接口。在 Application 初始化的时候,我把 GlideLoader 注入进去。这样,如果哪天老板说 Glide 不行,要换成 Coil 或者 ImageLoader,我只需要再写一个实现类,而我 UI 层的业务代码一行都不用动。
另外一个典型的例子就是 Android 源码里的 View.OnClickListener。View 根本不关心是谁点击了它,它只持有一个 OnClickListener 接口的引用。当你点击时,它调一下 onClick() 方法。这就是典型的通过接口实现控制反转。
这样会导致接口膨胀,该怎么办?
嗯,确实,“接口膨胀”或者说“胖接口”是开发中经常遇到的坑。就是你在一个接口里写了太多的方法,导致实现类不得不实现一堆它根本用不到的空方法。
针对这个问题,我通常会用几种方案来解决:
第一,接口隔离原则(ISP)。我会把大接口拆分成多个小接口。比如一个 Player 接口,我会拆成 IPlayControl(控制播放、暂停)和 IDataSource(设置数据源)。需要什么能力就接什么接口,不要强迫实现类去依赖它不需要的方法。
第二,使用 Java 8 的 default 方法。如果某些方法是“可选”的,我会给它一个默认的空实现。这样子类就不需要强制重写它了。
第三,适配器模式(Adapter Pattern)。这在老的 Android 源码里很常见,比如 Window.Callback。我们可以写一个 EmptyWindowCallback 的抽象类,把所有方法都空实现一遍,然后业务类继承这个适配器,只挑需要的方法来重写。
第四,函数式接口(Functional Interface)。在 Kotlin 或者 Java 8 里,很多简单的接口其实可以用 Lambda 表达式或者高阶函数代替,这样连接口声明都省了。
接口能继承抽象类吗?
不能。 在 Java 语法里,接口只能继承接口(而且可以多继承接口),它不能继承类,不管这个类是不是抽象的。
其实从逻辑上也说不通。接口是纯粹的“协议”,它不应该包含任何具体的实现和状态信息。而抽象类是可以有成员变量、有构造方法、有方法实现的。如果接口继承了抽象类,那接口就会带上“实现细节”和“状态”,这就违背了接口“纯粹”的定义。
不过反过来是可以的,抽象类是可以实现(implements)接口的。这在 Android 架构里非常常见,比如 AbstractList 实现了 List 接口,它帮我们把一些通用的逻辑先写好。
四大引用说一下,以及各自的应用场景?
嗯,Java 的四大引用分别是:强引用、软引用、弱引用、虚引用。它们的区别主要在于 GC 时的回收时机。
-
强引用(StrongReference):就是我们平时最常用的
Object obj = new Object()。只要强引用还在,垃圾回收器永远不会回收它,就算 OOM 也不会收。 -
软引用(SoftReference):它的特点是“空间不足即回收”。在内存足够时,它不被回收;只有在内存快溢出时,系统才会回收它。
- 场景:适合做一些非核心缓存。比如从网络下载的大图,缓存到内存里,内存紧缺时删掉也无所谓,大不了再下一次。
-
弱引用(WeakReference):它的生命周期更短,只要 GC 一扫描到它,不管内存够不够,都会回收。
- 场景:主要用于解决内存泄漏。比如 Android 里的
Handler、静态变量持有 Activity 引用等场景。
- 场景:主要用于解决内存泄漏。比如 Android 里的
-
虚引用(PhantomReference):它最特殊,你甚至没法通过它拿到对象实例。它唯一的用途就是对象被回收时,能收到一个系统通知。
- 场景:一般用于管理堆外内存(DirectBuffer)的释放,在 Framework 底层用的比较多。
弱引用常用来做什么?
在 Android 开发中,弱引用几乎是内存泄漏的“克星”。
最经典的场景就是 Static Handler 内部类。
如果你在 Activity 里写一个非静态内部类 Handler,它会隐式持有 Activity 的强引用。如果 Activity 销毁了,但 Handler 队列里还有延时消息,Activity 就回收不了,导致泄漏。
这时候我们的做法是:把 Handler 定义为 static,然后内部持有一个 WeakReference<Activity>。这样当 Activity 退出时,GC 就能正常把它收掉。
还有就是跨进程的回调监听。比如你在某个单例或者全局 Service 里注册了一个监听器。如果用强引用,那个 Activity 就永远死不掉。改用弱引用,就能保证 UI 销毁时,引用能被正常断开。
弱引用对象被回收了,那调用对象的方法或成员变量会导致状态异常?
其实在调用之前,我们必须做一个**“判空”**的操作。
弱引用的设计就是这样的:当你通过 weakRef.get() 去获取对象时,如果对象已经被回收了,它会返回 null。
为了防止状态异常或 NPE(空指针),我们通常采用这种写法:
Activity activity = weakActivity.get();
if (activity != null && !activity.isFinishing()) {
// 这里 activity 已经变成了“强引用”,在当前方法执行完之前不会被回收
activity.doSomething();
}其实这里有个细节:先把弱引用赋值给一个局部变量(强引用)。这样可以保证在执行 if 块里的逻辑时,即使这时候发生了 GC,由于局部变量这个“强引用”还存在,对象就不会在逻辑执行中途突然消失。
弱引用在业务上的使用多吗?
实话说,在**纯业务逻辑层(UI 展示、数据请求)**直接用的并不算特别多。业务开发更多还是依赖架构组件(比如 ViewModel、Lifecycle)来自动管理生命周期。
但在底层框架和架构层,弱引用无处不在。 比如:
- Glide 内部的
Engine类,它在维护活跃资源(Active Resources)时,用的就是WeakReference。 - ThreadLocal 里的
ThreadLocalMap的Entry,它的 Key 就是弱引用的。 - LeakCanary 监控内存泄漏的原理,核心就是利用
WeakReference配合ReferenceQueue来判断一个对象是否被成功回收。
所以,作为高级 Android 开发,弱引用是你必须掌握的利器。虽然你可能不会每天写,但当你需要设计一个生命周期不一致的长效组件时,它是唯一的选型。
谈谈 GC(垃圾回收机制)是什么?
嗯,说到 GC(Garbage Collection),其实它就是 JVM 或 Android 的 ART 虚拟机提供的一套自动内存管理机制。它的核心目的就是:找出那些不再被程序引用的对象,把它们占用的内存释放掉,防止内存泄漏和 OOM。
如果非要说它的结论,GC 其实解决了三个问题:哪些内存需要回收?什么时候回收?以及怎么回收?
在 Android 里,我们最常用的是基于**可达性分析(Reachability Analysis)**的算法。它会从一系列被称为 GC Roots 的对象开始向下搜索。如果一个对象到 GC Roots 没有任何引用链相连,那它就是“不可达”的,也就是可以被回收的垃圾。
这里的 GC Roots 一般包括:
- 当前线程栈里的局部变量。
- 方法区里的静态变量。
- JNI(Native 层)持有的引用。
- 正在运行的线程本身。
我之前在研究 ART 虚拟机源码时发现,相比于早期的 Dalvik,ART 对 GC 做了大量的优化。比如它引入了多级回收策略,并且在后台进行并发标记,目的就是为了减少对应用主线程的干扰。
常见的 GC 算法及其特点有哪些?
其实主流的 GC 算法主要有三种,它们各有优缺点,现代虚拟机通常是结合着用的:
-
标记-清除算法(Mark-Sweep):
- 原理:先标记出所有需要回收的对象,标记完之后统一回收。
- 特点:效率还可以,但最大的问题是会产生内存碎片。这在 Android 里很致命,因为碎片多了,即便总内存够,你想申请一个大的
Bitmap时也可能因为找不到连续空间而 OOM。
-
复制算法(Copying):
- 原理:把内存分成两块,每次只用一块。回收时把还活着的移动到另一块,剩下的全清掉。
- 特点:没碎片,效率极高。但缺点是浪费内存,你得空出一半空间。所以它非常适合那种“朝生夕死”的新生代对象。
-
标记-整理算法(Mark-Compact):
- 原理:标记完后,不直接清理,而是让所有存活对象都向一端移动,然后清理掉端边界以外的内存。
- 特点:既没有碎片,也不浪费空间,但移动对象的成本很高,需要更新所有引用的地址。所以它一般用在老年代(Old Generation)。
现代 JVM/ART 是如何利用这些算法的?
嗯,现在的虚拟机基本都采用分代收集算法(Generational Collection)。它不是一种新算法,而是一种组合策略。
其实它的核心逻辑是基于一个经验:绝大多数对象都是临时生成的,很快就会死掉。
所以,虚拟机把堆内存分成了:
- 新生代(Young Generation):里面的对象更新极其频繁。这里会细分为
Eden区和两个Survivor区(S0, S1)。这里通常用复制算法,因为它快,而且大部分对象都会被回收,复制的量很小。 - 老年代(Old Generation):存放生命周期比较长的对象。这里空间大,通常用标记-清除或标记-整理算法。
在 Android 的 ART 虚拟机中,其实还有一个很聪明的做法。它会根据应用的状态来切换。比如应用在前台时,它会尽量使用并发 GC 来减少卡顿;当应用切到后台,它可能会执行一次 Semi-Space(SS) 或者更加彻底的回收,把内存碎片整理一遍,压榨出更多可用空间。
刚才提到新生代,它具体是怎么使用复制算法的?
新生代的逻辑其实挺有意思的。大家常听到的比例是 。
具体的流程是这样的:
- 新对象首先会在 Eden 区创建。
- 当 Eden 区满了,触发一次 Minor GC。这时候,Eden 里还活着的会被复制到 Survivor 0(S0)。
- 下次 Eden 再满时,GC 会把 Eden 和 S0 里活着的,全部复制到 S1。然后清空 Eden 和 S0。
- 就这样,S0 和 S1 轮流交换(From 区和 To 区)。每熬过一次 GC,对象的“年龄”就加 1。
- 当年龄达到一定阈值(默认通常是 15)或者 Survivor 区放不下了,这些老员工就会被“晋升”到老年代。
为什么比例是 呢?其实是因为经过统计,Eden 区里 以上的对象在第一次 GC 时都会死掉。所以我们只需要留出 的空间(一个 Survivor 区)来存放活下来的就行。这样就把复制算法带来的空间浪费从 降到了 。
说说 GC 的过程和它的缺陷?
其实 GC 的过程大致就是:GC Roots 追踪 -> 标记存活对象 -> 物理回收/移动内存 -> 更新引用。
但 GC 最大的缺陷就是 Stop The World (STW)。
简单来说,为了保证在标记和清理的过程中,对象之间的引用关系不发生乱跳,虚拟机必须在某个时刻暂停所有的应用线程。 在 Android 里,如果 STW 的时间太长,最直观的感受就是掉帧(Jank)。
其实,早期的 Dalvik 虚拟机 STW 挺严重的。但现在的 ART 做了很多改进,比如:
- 并发标记(Concurrent Marking):应用跑着的时候,GC 线程也在后台偷偷标记。
- 部分回收:不再每次都全堆扫描。
- 硬件协同:利用多核 CPU 优势。
即便如此,GC 依然有开销。如果我们在 onDraw 里频繁 new 对象,导致 Minor GC 频繁触发,依然会感觉到卡顿。这就是为什么我们写 Android 代码时要强调“对象池”和“避免在循环里 new 对象”。
引用计数法可能有什么问题?
虽然 Java/Android 不用引用计数,但这个算法在 Python 或早期 C++ 智能指针里很常见。
它的结论就是:无法解决“循环引用”的问题。
引用计数法的原理很简单:每个对象有一个 counter,多一个引用就 ,少一个就 。到 就回收。这看起来很完美,而且是实时的。
但它的缺陷在于:
- 开销大:每次赋值都要操作计数器。
- 原子性要求:在多线程环境下,改计数器得加锁。
- 循环引用:这是致命伤。
循环引用能具体说说吗?
好的。我们举个很直观的例子: 假设有两个对象,对象 A 持有对象 B 的引用,同时对象 B 也持有对象 A 的引用。
class Node {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b; // a 引用 b
b.next = a; // b 引用 a
// 然后我们将局部变量置空
a = null;
b = null;如果是在引用计数算法下:
- 虽然我们在代码里已经拿不到
a和b了,但因为它们互相指着对方,它们的计数器永远都是 ,不是 。 - 这就像两个人互相拽着对方的衣服悬在半空,谁也不松手。
- 结果就是:这两块内存永远无法回收,直接导致内存泄漏。
而 Java 用的可达性分析就没有这个问题。因为从 GC Roots 开始搜,已经找不到通往 a 或 b 的路径了,不管它们之间怎么互指,系统都会把它们判定为垃圾并回收掉。
HashMap、HashTable 和 LinkedHashMap 的区别说一下?
嗯,这三个都是我们常用的 Map 集合,但它们的设计目标完全不同。
先说结论:HashMap 追求极致的查找性能,HashTable 是早期的线程安全实现,而 LinkedHashMap 则是为了维护元素的顺序。
具体的细节,我可以从三个维度来拆解:
首先是线程安全性。HashMap 是非线程安全的,它的性能最高;HashTable 是线程安全的,因为它在几乎所有方法上都加了 synchronized 关键字。但在现代开发中,如果需要线程安全,我们通常会选 ConcurrentHashMap 而不是它。
其次是对 Null 的支持。HashMap 是允许 key 和 value 为空的(key 只能有一个 null),而 HashTable 无论 key 还是 value 只要传 null 就会报 NullPointerException。其实我读过 HashTable 的源码,它里面直接调了 key.hashCode(),如果 key 是 null,那肯定直接崩了。
最后说一下 LinkedHashMap。它是 HashMap 的一个子类。它的核心是在 HashMap 的哈希表基础上,额外维护了一个双向链表。
这个链表有两个作用:
- 按插入顺序遍历。
- 按访问顺序(Access Order)遍历。这在 Android 开发中非常重要,因为 LruCache 的底层实现其实就是利用了
LinkedHashMap的访问顺序模式。当你访问一个元素,它会自动把该元素移到链表末尾,这样头部就是最久未使用的。
HashTable 线程安全的原理是什么?
其实 HashTable 的线程安全实现非常“暴力”,它的原理就是全表加锁。
如果你去看它的源码,你会发现它的 get()、put()、remove() 等几乎所有公有方法,都直接声明了 public synchronized。
这意味着什么呢?
这意味着它持有的锁是当前对象实例(this)。当线程 A 在执行 put 操作时,线程 B 哪怕只是想执行一次简单的 get,也必须在同步队列里等着。这种竞争粒度太粗了。在多线程高并发的情况下,HashTable 会成为整个系统的瓶颈。
这也是为什么在现代 Java 和 Android 开发中,HashTable 已经被标记为“过时”的原因。除了在一些极其老的遗留代码里,我们基本见不到它。
除了 HashTable,还有哪些线程安全的容器?
嗯,线程安全的容器其实挺多的,主要分布在 java.util.concurrent (JUC) 包下面:
-
ConcurrentHashMap:这是最常用的,它通过分段锁或者 CAS + Synchronized 实现了高性能的并发读写。
-
CopyOnWriteArrayList:它的原理很有意思。在写操作(add/set)时,它会把底层的数组 复制(Copy) 出一份新的,在新的上面写,写完再把引用指回去。
- 场景:它非常适合“读多写少”的场景,比如应用里的配置列表、白名单。
-
Collections.synchronizedMap/List:这是一种包装类,通过一个包装对象把普通的 HashMap 变成线程安全的。原理跟 HashTable 类似,也是全表锁。
-
ConcurrentLinkedQueue / LinkedBlockingQueue:这些是并发队列。在 Android 的消息机制或者线程池里经常能看到它们的身影。
ConcurrentHashMap 和 HashTable 的区别是什么?
这算是面试里的高频题了。核心结论是:HashTable 是单锁(全表锁),而 ConcurrentHashMap 是细粒度锁。
具体的演进过程,我在看源码时对比过 JDK 7 和 JDK 8 的实现,区别非常大:
在 JDK 7 中,ConcurrentHashMap 使用的是 Segment 分段锁。它把整个哈希表分成 16 个 Segment,每个 Segment 是一把 ReentrantLock。理想情况下,它能支持 16 个线程并发写入。
而在 JDK 8 及以后(包括我们现在 Android AOSP 用的版本),它的设计更巧妙了。它摒弃了 Segment,直接采用 CAS + synchronized。
- 锁的粒度更细了:它只锁住哈希桶的头节点(Node)。
- 只有在发生哈希冲突(即两个线程同时往同一个桶里插数据)时,才会触发
synchronized。 - 如果没有冲突,它直接通过 CAS 操作(无锁化)完成插入。
所以,ConcurrentHashMap 的并发能力是随着数组长度增加而增加的,性能远超 HashTable。
你提到 CAS,能具体说一下吗?
好的。CAS 全称是 Compare And Swap(比较并交换)。它是实现“无锁化”编程的核心,也是一种乐观锁的策略。
它的逻辑可以用三个变量来描述:
- ** (Value)**:要更新的变量在内存中的实际值。
- ** (Expected)**:预期的旧值。
- ** (New)**:准备写入的新值。
具体的执行逻辑是: 当线程尝试更新变量时,先检查内存中的 是否等于我拿到的 。如果相等,说明这段时间没有别人改过,我就直接把 更新成 ;如果不相等,说明值已经被别人改了,我就失败(或者重试,也就是所谓的“自旋”)。
在 Java 层,CAS 主要是通过 sun.misc.Unsafe 类提供的 native 方法实现的。它底层利用了 CPU 的原子指令(比如 x86 下的 cmpxchg)。因为是硬件层面的原子指令,所以它比 synchronized 这种系统调用级别的锁要快得多。
在 Android 源码里,比如 AtomicInteger 或者 AbstractQueuedSynchronizer(AQS),底层全是靠 CAS 撑起来的。
CAS 可能出现什么问题?
CAS 虽然高效,但它不是完美的,主要有三个问题:
- ABA 问题:这是最出名的。如果一个变量的值从 A 变成了 B,然后又变回了 A。这时候 CAS 会认为它没变过,直接更新成功。但在某些业务场景下(比如链表节点的删除和重组),这可能会导致逻辑错误。
- 循环时间长、开销大(自旋开销):如果并发非常高,线程一直 CAS 失败,它就会进入死循环不断尝试(自旋)。这会非常消耗 CPU。
- 只能保证一个共享变量的原子操作:如果你想同时修改两个变量且保证原子性,CAS 就没办法了(这时候只能用
synchronized或者把多个变量封装成一个对象)。
ABA 问题怎么解决?
解决 ABA 问题的核心思路就是引入“版本号”或“时间戳”。
在 Java 的并发包里,官方提供了一个工具类叫 AtomicStampedReference。
它的做法是:不仅比较值(Expected Value),还要比较邮戳(Timestamp/Stamp)。 每次修改变量时,版本号都会自增。 即便值从 A 变回了 A,但版本号已经从 1 变成了 3。 当 CAS 尝试更新时,发现虽然值对上了,但版本号对不上,依然会判定为失败。
其实这就有点像我们做 Git 提交或者数据库的乐观锁方案,通过一个 version 字段来防止覆盖掉别人的修改。在 Android 的一些底层状态同步中,如果涉及到复杂的对象引用变化,这种机制是非常必要的。
Handler 的 Message 有哪些类型?它们是同步的吗?
嗯,关于 Handler 的消息类型,如果我们从源码的底层逻辑来看,其实主要分为三类:普通消息(同步消息)、异步消息,以及一种特殊的消息——同步屏障(Sync Barrier)。
先说结论:它们在默认情况下是按时间顺序同步执行的,但通过“同步屏障”,我们可以开启异步消息的“绿色通道”。
具体的细节是这样的:
- 普通消息:这是我们平时
sendMessage发出的最常见的消息。它们被塞进MessageQueue后,会根据when(执行时间)在队列里排队,一个接一个地被 Looper 取出来处理。 - 异步消息:这种消息本质上也是
Message对象,只不过它的setAsynchronous(true)被设置为了 true。平时它跟普通消息没区别,但一旦队列里出现了“同步屏障”,它的特殊待遇就来了。 - 同步屏障(Sync Barrier):这是一种特殊的消息,它的
target成员变量为 null。我们在源码里可以看到,当MessageQueue.next()扫描到 target 为 null 的消息时,它就会开启“紧急模式”:跳过队列中所有的普通消息,优先寻找并执行后面的异步消息。
它们是同步的吗? 其实在单线程的 Looper 循环里,消息的执行依然是串行的(一个接一个)。但从调度机制上说,异步消息可以“插队”。
应用场景:
最经典的例子就是 UI 刷新。当 Choreographer 接收到 VSync 信号准备请求绘制时,它会先往消息队列发一个同步屏障,然后发一个异步消息去执行真正的绘制(doFrame)。这样可以保证即使主线程的消息队列里堆了一堆乱七八糟的普通点击事件或业务消息,UI 渲染也能被最高优先级处理,从而减少掉帧。
Looper 是如何保证线程唯一性的?
这个问题的核心就在一个类上:ThreadLocal。
简单来说,Looper 是通过 ThreadLocal<Looper> 变量来存储每个线程特有的实例,从而确保一个线程只有一个 Looper。
具体的流程我在源码里跟过:
- 当我们调用
Looper.prepare()时,它会先去sThreadLocal.get()检查一下。如果已经有值了,说明这个线程已经初始化过 Looper,它会直接抛出异常(这也是为什么我们不能在一个线程调两次prepare)。 - 如果没有,它会
new Looper(),然后通过sThreadLocal.set(new Looper())把这个实例存进去。 - 因为
ThreadLocal内部维护了一个ThreadLocalMap,它的 Key 是当前的Thread对象(或者是 ThreadLocal 本身,底层通过线程私有变量存储),这就物理隔离了不同线程的数据。
所以,无论你在哪个线程调 Looper.myLooper(),它都能准确地从当前线程的私有存储里把属于自己的那个 Looper 拿出来。这种设计非常优雅,既避免了全局加锁,又保证了线程隔离。
谈谈 Android 的事件分发机制?
嗯,事件分发是 Android UI 系统的灵魂。如果用一句话总结它的结论,那就是:基于责任链模式的“U型”探测机制——先由外向内拦截(Tunneling),再由内向外消费(Bubbling)。
具体的流程可以拆解为三个核心方法:
dispatchTouchEvent:负责事件的分发。onInterceptTouchEvent:ViewGroup 特有的,负责拦截事件。onTouchEvent:负责具体的事件消费。
传递逻辑是这样的:
- 下发阶段:当一个点击事件产生,它会从
Activity->PhoneWindow->DecorView->ViewGroup->View一层层往下传。在每一层 ViewGroup 经过dispatchTouchEvent时,都会问一下onInterceptTouchEvent:“你要不要拦截这个事件?”。如果拦截了,事件就直接交给这一层的onTouchEvent处理。 - 消费阶段:如果事件一直传到了最底层的 View,View 的
onTouchEvent返回了true,那这个事件序列就被它吃掉了。如果它返回false,说明它不想要,事件就会往回传给父控件的onTouchEvent,就像冒泡一样,直到有人消费为止。
我在看 ViewGroup 源码时注意到一个细节:如果子 View 消费了 ACTION_DOWN,那么后续的 ACTION_MOVE 和 UP 都会直接绕过拦截逻辑(除非父 View 强行拦截),直接交给这个子 View。这也是为了保证手势操作的连贯性。
点击事件的类型和执行顺序是什么?
点击事件(Touch Event)在 Android 里被封装在 MotionEvent 中。最基本的类型有:
ACTION_DOWN:手指按下,这是整个事件序列的开端。ACTION_MOVE:手指在屏幕上移动,通常会触发多次。ACTION_UP:手指抬起。ACTION_CANCEL:非人为因素导致的事件取消(比如滑动过程中父控件突然拦截了事件)。
执行顺序:
一个典型的“点击”动作,顺序是:ACTION_DOWN -> ACTION_UP。
如果中间有滑动,顺序就是:ACTION_DOWN -> ACTION_MOVE (若干次) -> ACTION_UP。
这里有个关键点:ACTION_DOWN 是所有事件的侦察兵。
如果在 ACTION_DOWN 阶段,没有任何一个 View 返回 true(表示消费),那么后续的 MOVE 和 UP 根本就不会再派发给这个 View,系统会认为它对这个手势不感兴趣。
手指按到屏幕上再滑动,会立即滑动吗?内部过程是怎样的?
这其实涉及到一个非常细腻的体验优化:Touch Slop(滑动阈值)。
结论是:不会立即滑动。系统会先观察一段距离,确认你真的是在“滑”而不是“误触点按”后,才会转换状态。
内部的传递过程是这样的:
-
按下(ACTION_DOWN):事件传给子 View。此时父 View(比如一个
RecyclerView)在onInterceptTouchEvent里观察。 -
开始移动(ACTION_MOVE):
- 刚开始移动的几个像素点,父 View 依然不会拦截。
- 直到手指移动的距离超过了系统定义的一个常量值
TouchSlop(通常是 8dp 左右)。
-
状态接管:
- 一旦超过
TouchSlop,父 View 意识到:“哦,用户这是要滑动啊!”。 - 这时候,父 View 的
onInterceptTouchEvent会返回true。 - 接下来,系统会给原本持有事件的子 View 发一个
ACTION_CANCEL,告诉它:“你被撤职了,接下来的事归我管”。 - 从此之后,这个事件序列剩下的
ACTION_MOVE都会直接跳过拦截逻辑,直接交给父 View 的onTouchEvent去处理。
- 一旦超过
滑动事件在哪里处理?
滑动逻辑最终是在 父 View(滚动控件)的 onTouchEvent 中处理的。它会根据 ACTION_MOVE 产生的坐标差值(),调用 scrollBy 或者偏移内部的 Canvas,从而实现丝滑的滚动效果。
其实在研究 NestedScrolling(嵌套滑动) 机制时,这个过程会更复杂,子 View 甚至可以反过来控制父 View 什么时候该拦截,什么时候不该拦截。但这套基本的“拦截-接管”模型是所有滑动控件的底层基石。
字节跳动 四面
请手写一个简易版本的 HashMap,并深入解释其底层设计考量(容量、扩容机制等)
嗯,面试官,其实手写一个 HashMap 的核心逻辑并不复杂,主要就是数组 + 链表的结构。但真正的难点在于它为了极致的性能,在位运算和扩容时机上做的那些精妙设计。
首先,我先口述一下我实现这个简易 HashMap 的核心代码结构:
我需要定义一个 Node<K, V> 内部类,里面包含 hash、key、value 和指向下一个节点的 next。
底层是一个 Node<K, V>[] table 数组。
在 put 的时候,先算 hash 值,然后通过 (n - 1) & hash 计算下标。如果冲突了,就用头插法或者尾插法(JDK 8 以后是尾插)挂在链表后面。
为什么要这么设计?接下来我针对几个底层细节深入聊聊我的理解:
1. 为什么容量必须是 2 的幂次方?
其实这是一个非常巧妙的数学优化。我们在给 Key 找数组下标时,最直观的想法是取模:hash % length。但是取模运算在 CPU 层面是非常慢的。
如果我们能保证 length 是 2 的幂次方,那么 hash % length 就可以等价于 (n - 1) & hash。
因为当 是 2 的幂时, 的二进制全都是 1。比如 16 是 10000,15 就是 01111。这时候进行 & 运算,不仅效率极高,而且能保证结果分布非常均匀。
2. 关于扩容(Resize)的具体过程和为什么是 2 倍?
扩容其实是 HashMap 最重的一步。当我们现有的 size 超过了 capacity * loadFactor(默认 0.75)时,就会触发 resize()。
过程是:先创建一个两倍容量的新数组,然后遍历老数组,把每个节点重新分配到新数组里。
为什么必须是 2 倍呢?
除了为了维持上面说的位运算优势外,还有一个极大的好处:在重新计算下标时,节点要么留在原位置,要么移动到“原位置 + 旧容量”的位置。
这是因为扩容 2 倍,意味着二进制位在高位多了一个 1。我们在判断新位置时,只需要看 hash & oldCap 是 0 还是 1 就行了。这种设计避免了重新调用 hashCode(),极大提高了数据迁移的效率。我在看 AOSP 里的 ArrayMap 时,虽然它为了内存优化放弃了这种数组结构,但在标准 Java 环境下,这种“翻倍扩容”带来的计算优势是非常明显的。
3. 是先插入再判断扩容,还是先扩容再插入?
在 JDK 8 的源码里,逻辑是先插入,再判断是否需要扩容。
其实关于这一点其实有过讨论。如果在插入之前判断,我们可能还没确定这个 Key 是否已经存在。如果 Key 已经存在,我们只是做一个 update(覆盖 value),这时候 size 是不会增加的,也就不需要扩容。
所以,先插入再判断可以避免在“覆盖操作”时触发不必要的扩容,这更符合逻辑上的节约原则。
4. 为什么负载因子(Load Factor)默认是 0.75?
其实这是一个空间和时间的平衡点。 如果设为 1,虽然空间利用率高,但 Hash 冲突会非常严重,链表变长,查询变成 。 如果设为 0.5,虽然冲突少了,但空间浪费太严重,而且会频繁触发扩容。 在 JDK 源码注释里提到了泊松分布(Poisson distribution),在 0.75 的负载因子下,链表长度达到 8 的概率极低(亿分之六左右),这保证了绝大多数情况下的 性能。
5. Hash 算法的“扰动函数”
我在实现时,不会直接用 key.hashCode()。我会参考 JDK 8 的做法:(h = key.hashCode()) ^ (h >>> 16)。
为什么要让高 16 位和低 16 位做异或?
因为我们的数组长度通常不会很大,下标计算 (n - 1) & hash 其实只用到了 hash 值的低位。如果不做扰动,高位的信息就完全丢失了,这会增加冲突概率。通过这个异或操作,我们把高位的特征混合到了低位,让分布更随机。
总结
其实在 Android 开发中,如果涉及内存极其敏感的场景,我会考虑用 ArrayMap 或者 SparseArray 来代替 HashMap。因为 HashMap 每一个 Entry 都是一个对象,额外内存开销很大。但如果从纯粹的查询性能和大数据量处理来看,HashMap 这种基于位运算和数组的底层设计,依然是目前最优秀的数据结构方案之一。
SQLite 的底层数据结构、B+ 树特点以及为什么不用二叉树?
嗯,说到 SQLite,它作为 Android 默认的数据库,底层最核心的数据结构其实是 B-Tree(针对表数据)和 B+ Tree(针对索引)。
先说结论吧,之所以选择 B+ 树 而不是二叉查找树(BST),核心考量在于减少磁盘 I/O 次数和提升范围查询性能。
其实,B+ 树有几个非常显著的特点: 第一,它的非叶子节点只存储“键(Key)”和“子节点指针”,而不存储具体的行记录数据。这就意味着一个节点(也就是一个磁盘页 Page)可以容纳更多的索引项,树的分叉(Fan-out)会变得非常大。 第二,所有的数据记录都整齐地存放在最后一层的叶子节点上。 第三,也是最关键的一点,叶子节点之间是通过双向链表相连的。
那为什么不使用二叉查找树呢?
其实在内存里,二叉树表现很好,但在磁盘存储场景下,它有个致命缺点:树太深了。
你想,二叉树每个节点只有两个分叉。如果我们有 100 万条数据,二叉树的高度大约是 。这意味着找一条数据可能需要 20 次磁盘 I/O。而 B+ 树因为分叉多(通常上百个),同样的数据量,高度可能只有 3 到 4 层。
在数据库里,磁盘 I/O 是最耗时的操作。B+ 树这种“矮胖”的结构,能把磁盘访问次数降到最低。而且因为叶子节点有链表,做类似 age > 20 这种范围查询时,B+ 树只需要找到起点,然后顺着链表往后拉就行,二叉树则需要不停地回溯父节点,效率完全没法比。
数据库查询优化思路是什么?索引在什么情况下会失效?
谈到 SQLite 的查询优化,我平时的思路主要是从“减少扫描范围”和“降低磁盘负载”这两个维度出发。
最直接的手段当然是建立索引。但我认为,盲目加索引是不行的,必须要配合 EXPLAIN QUERY PLAN 来分析。
我会先看 SQL 执行计划,确认它是不是在走 SEARCH TABLE(命中索引),还是在苦哈哈地做 SCAN TABLE(全表扫描)。
在 Android 开发中,我还有几个实战心得:
- 使用预编译语句(SQLiteStatement):这不仅能防止 SQL 注入,还能省去重复解析 SQL 树的开销。
- 开启 WAL(Write-Ahead Logging)模式:其实默认的 Rollback Journal 模式在写数据时会锁住读操作,开启 WAL 后,读写可以并发执行,对 UI 丝滑度提升很大。
- 事务优化:如果要批量插入数据,一定要显式开启
beginTransaction()。如果不手动开启,SQLite 会为每条insert自动创建一个事务,频繁同步磁盘会导致极其严重的卡顿。
至于索引失效的情况,这也是面试经常“拷打”的地方。我在技术博客里总结过几种典型的坑:
- LIKE 模糊查询以通配符开头:比如
LIKE '%abc'。这种情况下,B+ 树没法根据前缀定位,索引直接废掉,只能全表扫描。 - 在索引列上做运算或使用函数:比如
WHERE abs(column) = 10。数据库必须计算出每一行的结果才能对比,所以没法走索引。 - 隐式类型转换:如果你定义的是字符串索引,查询时却传入了数字(比如
WHERE phone = 123),SQLite 可能会放弃索引去做转换。 - 不满足最左前缀原则:如果你建的是联合索引
(a, b),但查询条件只有WHERE b = 1,那这个索引也是用不上的。 - 使用
OR连接条件:除非OR左右两边的列都分别有索引,否则索引也会失效。
其实在 Framework 层,比如 PMS(PackageManagerService) 扫描包信息或者 MediaProvider 处理媒体文件时,都会涉及大量的数据库操作。理解这些底层原理,对解决系统卡顿问题非常有帮助。
谈谈 Java 的 static 关键字,包括静态变量的存储、分配与初始化顺序
嗯,提到 Java 的 static,其实它的核心含义就是**“属于类,而不属于对象”**。
首先说一下静态变量存放在哪。在 Java 8 之前,静态变量逻辑上是在方法区的永久代(PermGen);但从 Java 8 开始,由于去掉了永久代,引入了元空间(Metaspace),这里有个细节:静态变量引用的对象以及类变量(静态变量)本身,其实是存放在堆(Heap)里的。具体来说,是在 java.lang.Class 对象的末尾。
关于内存分配和初始化,这就涉及到了类的生命周期。流程大致是这样的:
- 准备阶段(Preparation):这是在“链接”过程中发生的。这时候虚拟机会为类变量分配内存,并设置初始值。注意,这里的初始值是“零值”。比如
static int a = 10;,在这个阶段,a的值是 0,而不是 10。 - 初始化阶段(Initialization):这是类加载的最后一步。这时候才会真正执行 Java 代码,也就是执行类构造器
<clinit>()方法。在这个阶段,a才会真正被赋值为 10。
所以,回答您的问题,准备阶段肯定是在初始化之前。准备阶段是“占位和清零”,初始化阶段才是“真正的赋值”。
至于静态代码块,它也是在初始化阶段执行的。虚拟机会按顺序收集类中所有的静态变量赋值动作和静态代码块,封装成 <clinit>() 方法。而且 JVM 会保证这个方法在多线程环境下被正确地加锁、同步,所以静态代码块只会执行一次,这也就是为什么我们常用静态内部类或者双重检查锁来实现单例。
总结一下,类加载(Loading)肯定是先于初始化的。加载只是把磁盘上的字节码读到内存里,而初始化是赋予这些变量生命力的过程。
int 类型的数据存放在哪个地方?
关于 int 这种基本数据类型存放在哪,其实不能一概而论,它取决于这个变量声明的位置。我总结了三种主要的情况:
第一种,是局部变量。如果这个 int 是在方法内部声明的,那它就存在于栈(Stack)中。具体来说,是存在于当前线程栈帧的局部变量表里。当方法执行完,栈帧弹出,这个 int 也就随之销毁了。
第二种,是成员变量(实例变量)。如果这个 int 是类里的一个非静态属性,那它就是对象的一部分。因为对象是存在**堆(Heap)**里的,所以这个 int 自然也存在堆里,它的生命周期和对象是一致的。
第三种,是静态变量。就像我刚才提到的,static int 它是跟着 Class 对象走的。在现代 HotSpot 虚拟机里,它存放在堆中该类对应的 Class 实例的末尾。
其实这里还有一个微小的优化,叫逃逸分析。如果 JVM 发现一个对象不会逃逸出方法,它可能会把对象拆解掉,把里面的 int 字段直接放在栈上。但总的来说,记住“栈存局部,堆存成员与静态”这个大原则就没问题。
类初始化做了什么?类加载和初始化的顺序是怎样的?
这部分其实是 JVM 规范里定义的类加载全过程。如果我们把整个过程展开,顺序非常明确:加载 -> 验证 -> 准备 -> 解析 -> 初始化。所以,类加载(Loading)一定是先于初始化(Initialization)执行的。
那么类初始化到底做了什么呢?
从字节码层面来看,初始化就是执行 <clinit>() 方法的过程。
- 收集静态动作:编译器会自动收集类中所有静态变量的赋值语句和静态代码块(static )。
- 按序执行:收集的顺序是由它们在源代码中出现的顺序决定的。
- 父类优先:JVM 会保证在子类的
<clinit>()执行之前,父类的<clinit>()已经执行完毕。这也是为什么我们在子类静态块里能访问父类静态变量的原因。 - 线程安全:刚才也提到了,JVM 会通过加锁确保一个类的初始化在多线程环境下只被执行一次。
什么时候会触发初始化呢? 并不是类被加载了就一定会初始化。Java 虚拟机规定了“主动引用”的情况才会触发初始化,比如:
- 使用
new关键字实例化对象。 - 访问类的静态变量(非 final)或者静态方法。
- 使用
java.lang.reflect进行反射调用。 - 初始化一个子类时,发现父类还没初始化。
而像“通过子类引用父类的静态字段”或者“定义该类的数组”,其实是不会触发该类的初始化的。我在写 Framework 层代码或者研究插件化方案(比如 Tinker)的时候,经常需要处理类加载器的双亲委派机制,其实理解了这一套加载与初始化的顺序,对于排查 NoClassDefFoundError 或者 ClassNotFoundException 非常有帮助。
进程和线程的区别是什么?在 Android 系统中是如何体现的?
嗯,关于进程和线程,这是操作系统最基础的概念了。简单一句话总结的话:进程是资源分配的最小单位,而线程是 CPU 调度的最小单位。
我们可以把进程想象成一个工厂,它拥有独立的地址空间、内存、文件句柄等资源。而线程就是工厂里的工人,多个工人共享工厂里的所有资源,但每个工人都有自己独立的执行栈和程序计数器。
在 Android 开发中,这种区别体现得非常明显: 首先是隔离性。每个 Android App 默认运行在自己的进程中(基于 DVM/ART 虚拟机),一个进程崩了,通常不会影响其他进程。这就是利用了进程间内存不共享的特性。但如果我们要跨进程通信,就得用 Binder、Socket 或者匿名共享内存(Ashmem),这就是因为进程间有“壁垒”。
其次是开销。创建一个进程要分配内存空间、加载各种库,开销很大。所以 Android 系统为了优化体验,用了 Zygote 进程。当我们要启动新 App 时,Zygote 直接 fork() 一个子进程,利用**写时拷贝(Copy-on-Write)**技术,让新进程能快速继承预加载的基础类和资源。
而线程就轻量得多。我们常用的 Handler 机制 或者是 ThreadLocal,本质上都是在同一个进程的内存空间里玩。线程之间共享堆内存,所以我们可以直接传递对象引用,但这也带来了线程安全的问题,比如多个线程同时修改一个 ArrayList 可能会出问题。
总结一下,进程给了系统稳定性(隔离),线程给了应用并发能力。在 Android 里,我们通过四层模型(App 进程、SystemServer 进程、Native 进程、Kernel 进程)把这种协作发挥到了极致。
进程调度算法有哪些?能简单列举一下吗?
进程调度其实就是解决“下一个该谁用 CPU”的问题。操作系统为了平衡吞吐量和响应时间,演化出了很多算法。
其实常见的可以分成这两大类:
第一类是非抢占式/简单的算法:
- 先来先服务(FCFS, First-Come First-Served):最简单的排队,谁先来谁先用。
- 短作业优先(SJF, Shortest Job First):谁干活快谁先上,能降低平均等待时间,但容易让长任务“饿死”。
第二类是抢占式/更复杂的算法,这也是现代操作系统(包括 Linux/Android)常用的:
- 时间片轮转(RR, Round Robin):给每个进程分配一个小的时间片,用完就换人。
- 优先级调度:给进程打分,分高先跑。Android 里的 nice 值(可以通过
Process.setThreadPriority设置)其实就是这种思想。 - 多级反馈队列(Multilevel Feedback Queue):这是目前公认最公平、最高效的。它结合了时间片和优先级,任务如果没跑完就降级,如果一直在等待就提级。
其实在 Android 底层,Linux 内核现在主要用的是 CFS(Completely Fair Scheduler,完全公平调度器)。它不是简单地分时间片,而是利用红黑树来维护一个虚拟运行时间(vruntime),尽量让每个进程“看起上来”分到的 CPU 时间是一样多的。
聊聊时间片轮转(RR)调度算法的原理?
时间片轮转(Round Robin) 是一种非常经典的可抢占式调度算法,它的核心理念就是**“公平”**。
具体是怎么做的呢?操作系统会定义一个很小的时间单位,叫做时间片(Time Slice 或 Quantum),通常是几毫秒到几百毫秒。
- 系统把所有就绪状态的进程排成一个循环队列。
- 调度程序每次从队首取一个进程,让它跑完这一个时间片。
- 如果时间片到了,进程还没跑完,时钟中断会触发,内核强制剥夺它的 CPU 使用权,把它放回队尾,然后让下一个进程跑。
- 如果进程在时间片没结束前就阻塞了(比如去读磁盘),那它也会主动释放 CPU。
我之前读内核相关的书时注意到,时间片的长度选择非常关键。如果时间片设得太大,RR 就会退化成 FCFS(先来先服务),导致交互性变差;如果设得太小,CPU 就会频繁地在进程间切换,光是**上下文切换(Context Switch)**的开销(保存寄存器、刷新 TLB 等)就会吃掉大部分性能。
所以,RR 的本质是在公平性和系统开销之间做一个折中。
时间片轮转和先来先服务有什么区别?在实际中该怎么选择?
这两者的区别,其实就是**“是否有剥夺机制”以及“侧重点不同”**。
先来先服务(FCFS) 是非抢占的。就像排队买票,只要前面的人不走,后面的人就得一直等着。
- 优点:实现极其简单,没有频繁的切换开销,吞吐量高。
- 缺点:会产生**“护航效应”**(Convoy Effect)。比如一个长任务(计算圆周率)排在一个短任务(打印一行字)前面,短任务就得等很久,平均等待时间会非常难看。
时间片轮转(RR) 是抢占式的。它更像大家轮流玩游戏,每人玩 5 分钟。
- 优点:响应时间好。对于交互式系统(比如手机系统),用户点一下屏幕,系统能迅速响应,因为每个进程都能很快分到 CPU。
- 缺点:由于有上下文切换,总的执行时间其实比 FCFS 要长。
怎么选择呢? 这取决于你的系统目标:
- 如果是批处理系统(比如后台跑离线数据计算),任务大多是计算密集型的,不要求实时响应,那选 FCFS 效率更高,因为省下了切换的时间。
- 如果是分时系统或者交互式系统(比如我们的 Android 手机),必须保证用户界面的流畅,那肯定选 RR 或者是它的变种。
其实在现代 Android 中,系统会通过 cgroups 将进程分组。前台 App 所在的进程组会获得更多的 CPU 权重,这其实是在 RR 的基础上加上了权重的概念。
那时间片轮转和高响应比优先(HRRN)比起来呢?
这两个算法的设计思路完全不同。RR 是为了公平,而 HRRN(High Response Ratio Next)是为了兼顾“短作业”和“长等待”。
HRRN 的核心是一个公式。每次调度时,它会计算所有就绪进程的响应比 R:
然后选择 R 最大的那个进程来运行。
我们可以分析一下这个公式:
- 如果任务很短(要求服务时间小),那 R 就会变得很大,所以短作业会优先得到执行,这和 SJF 类似。
- 如果任务很长,但它已经等了很久了(等待时间变大),它的 R 也会慢慢涨上来。
所以,HRRN 解决了“饿死”问题,它既优待了短作业,又保证了长作业迟早能被执行。
对比 RR:
- RR 是被动的。它不管你任务长短,时间一到就掐掉。优点是逻辑简单,适合分时环境。
- HRRN 是主动计算的。它是非抢占式的(通常用于批处理),每运行完一个任务才重新计算一次 R。它的缺点是计算开销大,因为每次调度都要算一遍公式,而且系统很难准确预知一个任务到底要跑多久(要求服务时间)。
在移动端 Android 里,我们几乎看不到纯粹的 HRRN,因为它是非抢占的,不适合 UI 环境。但 HRRN 的思想——动态调整优先级,在 Linux 的调度器里是有体现的。比如一个进程如果长时间得不到 CPU,内核会临时提升它的优先级,防止它饿死,这和 HRRN 增加等待时间来提高响应比的逻辑异曲同工。
OkHttp 的连接复用机制是怎样的?它是如何实现的?
嗯,说到 OkHttp 的连接复用,其实它最核心的组件就是 ConnectionPool(连接池)。简单来说,它的目的就是为了避免每次请求都去进行 TCP 三次握手和 TLS 握手,从而提高网络请求的效率。
当我阅读 OkHttp 源码(比如 4.x 版本)时,我发现它通过一个 RealConnectionPool 来管理所有的 RealConnection。它的底层其实是用一个 Deque(双端队列) 来存储这些连接的。
具体的复用流程是这样的:
在拦截器链执行到 ConnectInterceptor 的时候,它会通过 ExchangeFinder 去找一个可用的连接。它会先去连接池里找,看看有没有**地址(Address)**完全一致的空闲连接。如果找到了,就直接拿来复用;如果没找到,才去创建新的连接。
那么,它是怎么判断一个连接是否还在被使用呢?
OkHttp 巧妙地利用了 引用计数 的思想。在 RealConnection 内部有一个 List<Reference<Transmitter>>(或者在旧版本里是 StreamAllocation)。每当有一个请求使用这个连接,就会往列表里加一个弱引用;请求结束了,就移除。如果这个列表空了,说明这个连接现在是闲置状态。
为了防止连接池无限膨胀,它还有一个 cleanup(清理)机制。连接池会开启一个后台线程执行 cleanupRunnable。它会扫描队列,如果发现某个连接闲置时间超过了 5 分钟(默认值),或者闲置连接数超过了 5 个(默认值),它就会把最老那个连接关掉并从队列里移除。
这种设计非常高效,通过这种“懒清理”和“弱引用计数”,既保证了连接的快速复用,又避免了内存泄漏和资源浪费。
连接复用有哪些优点和缺点?
连接复用在 Android 网络优化中是非常重要的一环,它的优缺点其实很明显。
优点方面:
- 降低延迟:这是最直观的。TCP 三次握手需要 1.5 个 RTT(往返时间),如果是 HTTPS 还要加上 TLS 握手的 1 到 2 个 RTT。复用连接能直接省去这些开销,请求能瞬间发出去。
- 减少 CPU 和电量消耗:握手过程涉及大量的加解密计算。复用连接意味着不用频繁进行复杂的数学运算,对手机端的续航非常友好。
- 减轻服务器压力:服务器不需要频繁处理连接的开启和关闭,维护的 Socket 数量也会相对稳定。
缺点或者说挑战方面:
- 占用服务器资源:如果客户端一直维持着空闲连接,服务器就需要一直分配资源(比如文件描述符、内存)来维护这些状态,如果连接数太多,服务器可能撑不住。
- “过期”连接问题(Stale Connection):这是最坑的一点。有时候服务端已经主动断开了连接,但客户端连接池并不知道。等客户端下次复用这个连接发请求时,会直接报
EOFException或者SocketException。这时候 OkHttp 通常会进行一次自动重试,但这确实增加了一次请求失败的风险。 - 队头阻塞(Head-of-Line Blocking):在 HTTP/1.1 协议下,虽然连接复用了,但同一个连接上的请求必须串行执行。如果第一个请求卡住了,后面的就全得等着。不过现在我们普遍用 HTTP/2 了,它支持多路复用,在同一个连接上并发请求,基本解决了这个问题。
聊聊 SparseArray 的原理?它的 key 是怎么来的?
SparseArray(稀疏数组)是 Android 特有的一种数据结构,它的设计初衷就是为了在特定场景下替代 HashMap<Integer, T>,以达到节省内存的目的。
它的核心原理其实就是“两个数组 + 二分查找”。
在 SparseArray 内部,维护了两个数组:一个是 int[] mKeys,用来存所有的 key;另一个是 Object[] mValues,用来存对应的 value。
关于您问的 key 是怎么来的:
这里的 key 其实是由开发者手动指定的 int 型数值。它不像 HashMap 那样需要对对象计算 hashCode。它的 key 数组是严格按升序排列的,这就为它的查找算法——二分查找(Binary Search) 提供了前提。
具体的工作流程是这样的:
- 查找:当你调用
get(key)时,它会在mKeys数组里通过二分查找找到这个 key 的索引index,如果找到了,就去mValues[index]取值。 - 删除:这是
SparseArray的一个优化点。为了避免频繁地移动数组元素(数组删除元素开销很大),它引入了延时回收机制。当我们调用delete()时,它只是把对应的 value 标记为一个特殊的DELETED对象。 - GC(Garbage Collection):这个不是 JVM 的 GC,而是它内部的
gc()方法。当下次需要插入新数据或者数组空间不足时,它会触发gc(),把那些标记为DELETED的坑位填满,通过一次性的数组移动来腾出空间。
为什么它比 HashMap 好?
- 避免了装箱(Auto-boxing):
HashMap<Integer, T>必须把int转成Integer对象,这会产生大量的临时对象。SparseArray直接用原生int[],省去了这部分开销。 - 内存占用更小:它不需要维护
Map.Entry对象,结构更简单。
当然,它的缺点是查找时间复杂度是 ,而 HashMap 在理想情况下是 。所以,在数据量比较小(通常是几百个以内)的时候,SparseArray 优势非常明显,但如果数据量达到几千上万,二分查找的开销就会上来了。
字节跳动 五面
安卓层是怎么执行 C++ 代码的?
嗯,在 Android 中,Java/Kotlin 层想要执行 C++ 代码,核心机制就是 JNI(Java Native Interface)。它其实是 JVM 规范的一部分,相当于在 Java 世界和 Native 世界之间架起了一座桥梁。
具体到执行过程,我觉得可以从加载、关联和调用这三个阶段来聊:
首先是加载。我们通常会在 Java 类的静态代码块里调用 System.loadLibrary("xxx")。这个方法在底层其实是调用了 Runtime.getRuntime().loadLibrary0(),最终会通过 dlopen 把编译好的 .so 动态链接库加载到进程的地址空间里。
接着是关联(注册)。就是怎么让 Java 里的 native 方法找到 C++ 里的对应实现。这里有两种主流方式:
- 静态注册:这是最简单的,按照特定的命名规范,比如
Java_包名_类名_方法名。当 Java 第一次调用 native 方法时,虚拟机通过这个名字去so库里搜寻。 - 动态注册:这是 Framework 层和大型项目更常用的方式。在 C++ 代码里定义一个
JNINativeMethod数组,然后在JNI_OnLoad回调里调用env->RegisterNatives进行手动绑定。这种方式效率更高,因为它不需要在运行时去按字符串搜索。
最后是调用。当我们在 Java 里面调用一个带有 native 修饰符的方法时,线程的状态会发生切换。虚拟机会准备好一个 JNIEnv 指针,它是我们 C++ 代码操作 Java 对象的“万能钥匙”。通过这个 JNIEnv,C++ 代码可以反过来调用 Java 的方法、访问字段或者创建 Java 对象。
我之前在研究 Framework 层的 Input 系统 时,发现比如 InputPublisher 的 native 实现,就是通过这种方式把内核里的触摸事件通过 JNIEnv 回传给 Java 层的 InputEventReceiver 的。总之,JNI 是 Android 系统运行的基石,如果没有它,Java 层就没法直接操作 Linux 内核提供的底层硬件能力。
包大小的减少是从哪些方面提升用户使用体验的?
包大小(APK Size)的优化,其实不仅仅是节省几个 MB 存储空间的问题,它对用户体验的提升是全方位的,甚至直接关系到产品的商业指标。
首先,最直观的就是下载转化率。其实业内有一个数据,APK 每增加 6MB,下载转化率就会下降 1% 左右。对于新用户来说,包体越小,下载时间越短,对流量的消耗越低,他们就越有动力完成安装,而不是中途取消。
其次,是安装和更新体验。
- 安装时间:包体小意味着文件解压和写入耗时短。尤其是 Android 会对代码进行 DEX 优化(AOT 编译),包越大,
dex2oat的时间就越长,用户在安装或者系统升级后的第一次启动时,等待感会非常明显。 - 磁盘占用:现在的 App 动辄几百 MB,对于中低端机型来说,存储空间其实非常紧张。包体小能降低用户因为“磁盘空间不足”而卸载 App 的概率。
再者,包大小其实也会间接影响运行性能。 当我们减小了包体积,通常意味着我们删除了多余的资源文件(比如冗余的 PNG)或者精简了代码逻辑。更少的资源意味着更低的内存(RAM)占用,因为有些资源在 App 启动时会被加载进内存。更精简的代码(DEX)也意味着更快的类加载速度,这在一定程度上能提升 App 的冷启动速度。
所以,做包体积优化,其实是在做“获客成本”和“运行效率”的双重优化。
MVP 和 MVVM 的区别?
嗯,MVP 和 MVVM 都是 Android 中为了解决 Activity/Fragment 代码臃肿(Big Activity)而产生的架构模式,它们的核心目标都是解耦。
如果说它们的共同点是都通过一个“中间人”把 View(界面)和 Model(数据)隔离开,那最根本的区别就在于这个“中间人”与 View 的交互方式。
在 MVP(Model-View-Presenter) 中:
- Presenter 手动控制 View:Presenter 内部通常持有一个 View 的接口引用。
- 双向依赖:当数据改变时,Presenter 必须显式地调用
view.showData();当用户操作时,View 也会显式调用presenter.onBtnClick()。 - 痛点:由于是手动控制,Presenter 会产生大量的样板代码,而且一旦 UI 变得复杂,接口里的方法会多到爆炸。
而在 MVVM(Model-View-ViewModel) 中:
- 数据驱动/自动同步:ViewModel 完全不持有 View 的引用。它只负责把数据暴露出来。
- 单向观察:View 通过观察者模式(比如使用 LiveData, Flow 或者 DataBinding)去监听 ViewModel 中状态的变化。
- 解耦更彻底:ViewModel 根本不关心数据最后是怎么展示的,它只管更新自己的状态。
总结一下,MVP 像是一个**“指挥家”,每一个动作都要手把手教 View 怎么做;而 MVVM 像是一个“广播台”**,View 只要戴上耳机(订阅)就能实时收到最新的动态。
你倾向使用哪种架构,哪个更易于维护?
就我个人的开发经验和目前大厂的工程实践来看,我绝对倾向于使用 MVVM 架构。
之所以觉得 MVVM 更易于维护,主要是基于以下几个核心痛点:
第一,是 生命周期安全。 在 MVP 里,我们要非常小心 Presenter 调用 View 时,View 是否已经被销毁了,否则会引起内存泄漏或者 NullPointerException。虽然可以通过一些 Base 类处理,但还是心累。而 MVVM 配合 Jetpack 的 ViewModel,它天然是生命周期感知的,数据更新只有在 View 处于活跃状态(Started/Resumed)时才会生效,这省去了大量防御性代码。
第二,是 代码量的显著减少。
MVP 最大的噩梦就是那一堆 IView、IPresenter 接口。改一个 UI 小功能,可能要动三个文件。MVVM 通过 DataBinding 或者 LiveData 减少了这种“胶水代码”,逻辑更加内聚,维护起来直观很多。
第三,是 单元测试的友好性。 ViewModel 不依赖具体的 View 接口,我们测试的时候只需要 Mock 出 Repository 数据,然后验证 ViewModel 里的状态值是否符合预期即可。这种纯数据的测试比 MVP 那种需要模拟 View 交互的测试要容易写得多。
当然,我也注意到现在 Google 在推 MVI(Intent 驱动),它其实是在 MVVM 的基础上增加了单向数据流(Unidirectional Data Flow)。但在目前的工程实践中,MVVM 配合协程和 Flow 已经能解决 90% 以上的问题,是灵活性和维护性的最佳平衡点。所以,如果让我主导一个项目,MVVM 一定是首选。
ArrayList 和 LinkedList 的区别?
嗯,这两个其实是我们在 Java 开发中最常用的集合类了。如果用一句话总结它们的区别,那就是:ArrayList 的底层是动态数组,而 LinkedList 的底层是双向链表。
这种底层结构的差异,直接决定了它们在不同场景下的性能表现:
首先看随机访问(取值)。ArrayList 表现得非常快,时间复杂度是 。因为它在内存里是连续存放的,系统可以通过“首地址 + 偏移量”直接定位到元素。而 LinkedList 就比较吃亏了,它得从头(或者从尾)顺着指针一个一个往后找,时间复杂度是 。
然后是插入和删除操作。
- 如果是在末尾操作,两个其实差不多,都很快。
- 但如果是在中间位置操作,ArrayList 就需要搬运数据了。比如我删除了索引为 2 的元素,后面的所有元素都要往前挪一位,这个开销是 的。
- LinkedList 在理论上插入删除只需要修改指针,是 。但面试官您要注意一个细节:虽然修改指针是 ,但定位到那个位置的过程仍然是 。所以除非你已经拿到了那个位置的迭代器,否则它的优势并不明显。
再聊聊内存占用。ArrayList 的额外开销主要来自于数组末尾预留的一些空位(扩容机制导致的)。而 LinkedList 的开销则比较碎,因为它每一个数据都要封装成一个 Node 对象,里面除了存数据,还得存 prev 和 next 两个指针。在 Android 这种对内存比较敏感的环境下,如果数据量特别大,LinkedList 产生的碎碎平安的对象其实对 GC 也是一种压力。
其实在 Android 开发中,我平时会更倾向于使用 ArrayList。甚至对于一些特定的 KV 场景,为了进一步优化内存,我还会考虑使用 Android 特有的 SparseArray 或者 ArrayMap,它们通过双数组和二分查找的方式,规避了 HashMap 那种大量的 Entry 对象创建,这在我做性能优化的时候是个挺常用的手段。
谈谈你对 Java 泛型的理解?
关于泛型,我觉得它是 Java 1.5 引入的一个非常伟大的特性。它的核心本质是参数化类型。简单来说,就是在定义类、接口或者方法的时候,把具体的类型当成一个参数传进来。
泛型最主要解决了两个痛点:
- 编译期类型安全检查:如果没有泛型,我们往 List 里塞什么都行(Object),结果在取出来强制转型的时候,经常会报
ClassCastException。有了泛型,这种错误在编译阶段就能被揪出来。 - 告别繁琐的强制转型:代码逻辑会变得非常简洁清晰。
但是,Java 的泛型有一个特别大的特点(或者说槽点),就是 类型擦除(Type Erasure)。
其实 Java 的泛型是“伪泛型”。为了兼容老版本的 JVM,Java 代码在编译成字节码之后,所有的泛型信息都会被擦除掉。比如 List<String> 和 List<Integer>,在运行时它们其实都是同一个类:List。泛型参数 <T> 会被替换成它的上限(如果没有指定上限,就是 Object),并在必要的地方自动插入强转指令。
我在读一些开源库源码,比如 Retrofit 或者 Gson 的时候,发现它们大量运用了泛型。这时候就会涉及到通配符的问题,也就是所谓的 ? extends T(生产者,上界)和 ? super T(消费者,下界),也就是著名的 PECS 原则。理解了这个,才能写出扩展性极强的库代码。
运行时能获取到具体的泛型信息吗?
这是一个非常有深度的问题。刚才我提到了类型擦除,按理说运行时的类型信息都丢了,像 new T() 或者 instanceof T 这种操作都是不合法的。但是,在某些特定场景下,我们确实可以通过反射获取到泛型信息。
关键点在于:类定义中的泛型签名会被记录在 Class 文件的 Constant Pool(常量池)中。
具体来说,有两种情况可以拿到:
-
通过匿名内部类:这是最常用的技巧。比如在 Gson 里,我们要反序列化一个
List<User>,我们会写new TypeToken<List<User>>(){}。这里的{}其实创建了一个匿名内部类,它继承了TypeToken。 这时候,我们可以通过getClass().getGenericSuperclass()拿到这个父类的类型。由于这个类型被硬编码在了子类的签名里,它就不会被擦除。接着把它强转为ParameterizedType,再调用getActualTypeArguments(),就能准确拿到User这个 Class。 -
通过反射成员变量或方法参数:如果一个类里定义了
public List<String> list;,我们通过Field.getGenericType()也是能拿到List<String>的。同样,方法的参数和返回值类型里的泛型也是可以拿到的。
所以总结一下:对象实例(比如一个普通的 ArrayList 对象)确实没法知道自己具体的泛型,但类声明、字段、方法参数中携带的泛型信息,是通过反射可以“捡回来”的。这也是为什么很多框架能够实现自动解析 JSON 到具体泛型对象的原因。
== 和 equals 的区别?
嗯,这虽然是个基础题,但其实里面涉及到 Java 对象模型的设计思想。
简单直接的结论是:== 比较的是“值”,而 equals 比较的是“逻辑上的相等性”。
详细展开说的话:
- 对于基本数据类型(比如
int,long):只能用==,比较的就是它们具体的数值。 - 对于引用数据类型:
==比较的是这两个引用是否指向了同一个内存地址(即是否是同一个对象)。
而 equals 是 java.lang.Object 类里的一个方法。如果你不重写它,它的默认实现就是 return (this == obj);。也就是说,默认情况下它和 == 是一样的。
但是,很多类为了符合业务逻辑,会重写这个方法。最典型的就是 String。String.equals() 会去逐个比较字符串里的每一个字符是否相同。所以,如果我们有两个内容都是 "hello" 的字符串对象,它们在堆里的地址可能不同(用 == 返回 false),但它们的 equals 会返回 true。
这里我还有一个非常重要的点想补充,就是关于 equals 和 hashCode 的等价关系。
在 Java 的规约里,如果你重写了 equals,就必须重写 hashCode。原因很简单:如果两个对象 equals 相等,那么它们的 hashCode 必须也相等。否则,在把这个对象存入 HashMap 或者 HashSet 时,就会出大问题。
比如你重写了 equals 逻辑但没改 hashCode,当你把对象存进 HashMap 时,Map 会根据旧的 hashCode 把对象丢进某个桶里;下次你用一个“逻辑相等”的对象去取,Map 算出的 hashCode 可能不一样,或者在同一个桶里对比 equals 前先比 hashCode 没对上,结果就找不到那个值了。
这在我平时写实体类或者自定义数据结构的时候,是必须要遵守的开发准则。
聊聊 Kotlin 中的 == 和 equals,以及它们和 Java 的区别?
嗯,在 Kotlin 里,关于相等性的判断其实比 Java 要直观很多,但也容易让刚从 Java 转过来的同学搞混。
结论先行:在 Kotlin 中,== 操作符等同于调用 equals() 方法。 而如果你想比较两个引用是否指向同一个内存地址(也就是 Java 里的 ==),在 Kotlin 里要用 ===(三个等号)。
我之前看过 Kotlin 编译器生成的字节码,其实当你写 a == b 时,Kotlin 编译器会把它翻译成类似 a?.equals(b) ?: (b === null) 这样的逻辑。
- 它会自动处理 null 安全。即使
a是 null,调用a == b也不会报空指针异常,而是会优雅地返回false(除非b也是 null)。 - 所以,在 Kotlin 里我们几乎不需要手动调
equals(),直接用==就行了,既简洁又安全。
所以总结一下:
==:结构相等(Structural Equality),调用的其实是equals()。===:引用相等(Referential Equality),比较的是内存地址。
如果同一个普通类(非 data class)的两个对象,它们内部都有一个 String 字段且值相同,那么 == 返回什么?
如果不做任何特殊处理,返回的是 false。
其实这个逻辑和 Java 是一模一样的。虽然在 Kotlin 里 == 会调用 equals(),但如果您定义的是一个普通的 class,而且没有手动重写(override)equals() 方法,那么它默认继承的是 Any 类的 equals() 实现。
而在 JVM 平台上,Any.equals() 的默认实现就是比较引用地址(即 this === other)。
所以,即便这两个对象内部的 String 字段值一模一样,但因为它们是两个独立的实例,在堆内存里的地址不同,默认的 equals() 就会判定它们不相等。
这时候,如果你希望它们“逻辑相等”,你就必须手动去 override fun equals(other: Any?): Boolean,在里面对比那个 String 字段。当然,在 Kotlin 里,更地道的做法是直接把这个类声明为 data class。
什么是 data class(数据类)?它有什么特别之处?
关于 data class,这是 Kotlin 中我非常喜欢的一个特性,它极大地减少了我们写实体类时的样板代码。
简单来说,data class 是专门用来存放数据的类。当你用 data 关键字修饰一个类时,编译器会根据你在主构造函数里定义的属性,自动帮你生成以下几个非常有用的方法:
equals()和hashCode():这就是它最核心的地方。它不再比较引用地址,而是去比较主构造函数里所有属性的值。toString():会自动生成一个排版美观的字符串,比如User(name=张三, age=20),而不是那一串看不懂的类名加哈希值。copy():这个非常实用,尤其是在做 MVI 架构或者处理不可变对象时。你可以通过user.copy(age = 21)快速创建一个新对象,同时保留其他属性不变。componentN():这就是解构声明的基础。你可以直接写val (name, age) = user。
不过我在实际开发中也注意到几个细节:
data class的主构造函数至少要有一个参数,且必须标记为val或var。- 它默认是
final的,不能被继承。 - 只有在主构造函数里定义的内容才会参与自动生成的逻辑。如果你在类内部定义的属性,是不会被包含在
equals()或copy()里的。
如果 data class 的两个对象内部属性完全一样,== 返回什么?
这种情况下,返回的是 true。
这正是 data class 的强大之处。因为编译器已经帮我们重写了 equals() 方法。
在生成的字节码里,data class 的 equals() 实现通常会遍历主构造函数里的每一个字段。比如我们有一个 data class User(val name: String),当你调用 user1 == user2 时:
- 它先判断两个引用的地址是否相同(
===),如果相同直接返回 true。 - 然后判断
other是否是User类型。 - 最后,它会对比
this.name == other.name。
因为 String 本身也重写了 equals(),所以只要字符串内容一致,最终的判定结果就是 true。
这在 Android 开发中处理 UI State 时非常有用。比如我们在用 LiveData 或者 StateFlow 时,经常会先判断新旧状态是否改变,data class 这种基于值的相等性判断能帮我们避免很多不必要的 UI 刷新,从而提升性能。
字节跳动 六面
Java 和 Kotlin 调用 JNI 有什么不一样?
嗯,其实从底层 JVM 字节码的角度来看,Java 和 Kotlin 调用 JNI 的本质是一样的,最终都是通过 native 标识位告诉虚拟机去调用本地方法。但在语法表现和开发细节上,Kotlin 确实做了一些适配和改进。
最直观的区别是关键字。在 Java 里我们用 native 关键字,而在 Kotlin 里则变成了 external。
具体的不同点,我觉得可以从这三个维度来聊:
-
静态方法的定义位置: 在 Java 中,静态 native 方法直接写在类里就行。但在 Kotlin 中,如果你想定义一个类级别的 native 方法,通常会写在
companion object(伴生对象)里。 这时候有个坑:如果你想让 C++ 侧生成的函数名保持“干净”,比如Java_包名_类名_方法名,你必须在 Kotlin 方法上加上@JvmStatic注解。否则,Kotlin 会把这个方法编译成伴生对象实例的方法,导致 C++ 侧的函数名变得非常冗长,甚至带有Companion字样。 -
顶层函数(Top-level Functions)的支持: Kotlin 支持直接在文件里写函数,不依赖类。如果你在文件顶层定义一个
external函数,编译后它会被放在一个自动生成的XXXKt类里。这就意味着你在 C++ 侧写实现函数名时,类名部分要带上这个Kt后缀。 -
空安全(Null Safety)的延伸: Kotlin 有严格的空安全检查。如果你在 Kotlin 里声明
external fun test(s: String),那么编译器会保证传给 JNI 的jstring不为 null。而在 Java 里,你必须在 C++ 层手动做if (obj == NULL)的检查,不然很容易在 native 层崩掉。虽然 JNI 本身不强制检查,但 Kotlin 的类型系统在调用前就帮你拦了一道。
其实我之前在搞 Android Framework 源码的时候,发现很多新的系统组件如果用 Kotlin 写,在调用 Native 层(比如 SurfaceFlinger 相关的逻辑)时,通常会大量使用 @JvmName 来重命名方法,避免 Kotlin 的一些特性导致符号解析失败。总之,底层逻辑没变,但 Kotlin 的语法特性需要我们通过注解来做一些“抹平”工作。
UTF-8 是几个字节?
关于 UTF-8 的字节长度,结论是:它是变长的,占用 1 到 4 个字节。
这其实是 UTF-8 设计得最精妙的地方,它向下兼容了 ASCII 码,同时又支持了全球所有的字符。具体的分配规律大概是这样的:
- 1 个字节:主要是标准的 ASCII 字符(数字、大小写英文字母、常用标点)。它们的二进制开头都是
0,范围是 到 。这也是为什么老代码在处理英文时,UTF-8 和 ASCII 是一样的。 - 2 个字节:主要是一些带重音符号的拉丁字母、希腊字母、阿拉伯文等。
- 3 个字节:这是我们最常用的,绝大多数的常用汉字在 UTF-8 编码下都是占 3 个字节。
- 4 个字节:主要是一些极少数的生僻字,或者是我们现在手机上常用的 Emoji 表情。
我在做 Android 开发的时候,经常会遇到一个坑,就是数据库字段长度或者网络传输限制。比如有些老旧的数据库环境(像早期的 MySQL utf8 编码)其实只支持 3 字节,一旦用户输入了 Emoji 表情(4 字节),App 就会报错。这时候就必须把编码改成 utf8mb4 才能正常存储。
所以,不能简单地说它是几个字节,得看它具体代表的是什么字符。
如果字节流乱了或者需要截取,如何识别字节流截取的位置?(UTF-8 编码识别)
嗯,这是一个很实际的工程问题,比如我们在做 Socket 断包处理或者底层文件读取时,如果直接按字节截断,很可能会把一个汉字“劈成两半”,导致乱码。
要识别截取位置,核心在于 UTF-8 的前缀校验机制。UTF-8 的每一个字节其实都自带有“身份信息”,我们可以根据字节的**高位(前几位)**来判断:
-
单字节字符(ASCII):二进制开头是
0(形式如 )。如果你读到一个字节最高位是 0,那它就是一个完整的字符,可以放心截断。 -
多字节字符的起始字节:
- 如果开头是
110,代表它是 2 字节字符的开头。 - 如果开头是
1110,代表它是 3 字节字符(比如汉字)的开头。 - 如果开头是
11110,代表它是 4 字节字符(Emoji)的开头。
- 如果开头是
-
多字节字符的后续字节(Continuation Byte):开头一定是
10(形式如 )。
具体的截取策略是什么呢?
假设我由于某些原因,拿到了一段可能被截断的乱码字节流,或者我需要强行截取前 个字节:
- 向前校验:从截断处(第 个字节)开始,检查当前的字节。如果它的开头是
10,说明它是一个字符的“中间部分”,不是开头。我就得继续往前找(),直到找到一个开头不是10的字节。 - 那个不是
10开头的字节,就是当前这个字符的起始位置。 - 然后我再根据这个起始位的前缀(比如
1110),就知道这个字符一共该占几个字节。如果剩下的长度不够,说明这个字符是不完整的,应该舍弃掉,或者留到下一段流里处理。
我在扒 AOSP 里的 String8 或 String16 源码时,发现底层 C++ 处理 UTF 转换时,逻辑基本也是这一套:通过位运算去屏蔽掉前缀位,取出真正的 Unicode 码点。理解了这个机制,我们在处理 Android NDK 里的字符串转换或者是日志打印时,就能精准地避开乱码问题。
Join 联表为什么能解决相关子查询导致的性能问题?
嗯,这个问题其实涉及到 SQL 优化器的工作原理。简单来说,相关子查询(Correlated Subquery) 的执行逻辑有点像代码里的 嵌套循环(Nested Loop)。
其实,如果你写一个相关子查询,比如在 WHERE 子句里又查了一次表,并且依赖外层的字段,数据库通常会对外层的每一行记录,都去跑一遍内层的查询。假设外层有 1000 行,内层也要跑 1000 次。这种 的复杂度,在数据量大的时候简直是灾难。
那为什么 Join 能解决这个问题呢?
首先,Join 把这种“逐行处理”的逻辑变成了**“集合处理”**。数据库的优化器(Optimizer)是非常聪明的,它在看到 Join 的时候,会有更多的选择空间。比如,它可以使用 Hash Join 或者 Merge Join。
- Hash Join:它会把小表在内存里建一个哈希表,然后扫描大表,匹配效率非常高,接近 。
- Merge Join:如果两张表是有序的,直接像合并有序链表一样走一遍就行了。
所以,结论就是:Join 给了优化器更大的自由度去选择更高效的算法,而不是死板地去跑嵌套循环。我在做 Android 的本地离线数据库(SQLite)优化时也发现,虽然 SQLite 的优化器不如 MySQL 或 PostgreSQL 那么强大,但尽量用 LEFT JOIN 代替 WHERE EXISTS 这种子查询,对查询耗时的提升依然是非常明显的。
怎么自动化识别和记录数据库查询慢的语句?
关于慢查询的识别,其实在开发阶段和线上监控阶段有不同的做法。
在**后端数据库(比如 MySQL)**层面,最直接的方法是开启 slow_query_log(慢查询日志)。
我们一般会设置一个阈值,比如 long_query_time = 1(1秒)。这样只要执行时间超过 1 秒的 SQL 都会被记录下来。然后我们可以配合 mysqldumpslow 这种工具去分析日志,看哪些 SQL 出现的频率最高、耗时最久。
如果是要做到自动化和可视化,现在一般会接入 APM(应用性能监控) 系统。比如在 Java 后端里,可以通过 Druid 连接池的监控功能,或者直接在 MyBatis 的拦截器里埋点,统计每条 SQL 的执行时间,如果超时了就异步上报到 Prometheus 或者 ELK 堆栈里,触发报警。
那回到我比较熟悉的 Android 端,如果我们在用 Room 或者原生 SQLite,我会这么做:
- Room 的回调:在构建
RoomDatabase的时候,可以调用setQueryCallback方法。这个回调里能拿到每一条 SQL 语句和它的耗时。 - 自定义 SQLiteOpenHelper:如果没用 ORM 框架,我们可以自己封装一层
execSQL,在执行前后打点。 - 断点调试与 Profiler:Android Studio 自带的 Database Inspector 其实就能看到实时查询情况,但在自动化监控层面,我更倾向于在 Debug 版本集成 LeakCanary 团队出的那个 Shark 或者类似的数据库监控工具,去自动捕获那些在 UI 线程执行的耗时查询。
总结一下,自动化识别的核心就是:“拦截执行过程 + 统计耗时 + 阈值上报”。
谈谈 Java 的垃圾回收算法,以及如何判断一个对象是否是垃圾?
嗯,Java 的垃圾回收(GC)其实是 JVM 调优里最核心的一块。要聊 GC,得先说怎么判断一个对象是不是“死”了。
目前主要有两种方法:
- 引用计数法(Reference Counting):给对象加个计数器,被引用一次加 1,失效减 1。它的优点是简单、实时性高,一旦变 0 立刻回收。但致命的缺点是无法解决循环引用的问题,比如 A 引用 B,B 也引用 A,它俩就永远没法被回收。
- 可达性分析算法(Reachability Analysis):这是 JVM 真正采用的方法。它从一系列 “GC Roots” 对象开始向下搜索,搜索过的路径叫“引用链”。如果一个对象到 GC Roots 没有任何引用链相连,那就说明它不可用了。
关于 GC Roots,我记得源码里主要包括:虚拟机栈里引用的对象、方法区里静态属性引用的对象、JNI 里的全局引用等等。
至于回收算法,其实是根据堆内存的分代(年轻代、老年代)来选择的:
- 标记-清除(Mark-Sweep):最基础的,先标记再清除。缺点是会产生大量的内存碎片,导致后面大对象申请不到空间。
- 复制算法(Copying):把内存分两块,只用一块,回收时把存活的拷贝到另一块。它的效率很高且没有碎片,但代价是内存利用率只有 50%。所以它一般用在年轻代的 Survivor 区。
- 标记-整理(Mark-Compact):老年代常用的算法。标记完后,让存活对象都向一端移动,然后清理掉边界以外的内存。这解决了碎片问题,但移动对象的开销比较大。
现在的 JVM(比如 HotSpot)其实是分代收集。年轻代因为对象“朝生夕死”,所以用复制算法;老年代对象活得久,就用标记-整理或者标记-清除。我在看 Framework 层的一些内存溢出(OOM)问题时,其实也经常会联想到这些算法的特性。
GC 到底使用了哪种判断垃圾的方法?优缺点对比?
其实刚才也提到了,现代成熟的 JVM(比如 HotSpot)是完全使用“可达性分析算法”来判断垃圾的,并没有用引用计数法。
虽然引用计数法在一些脚本语言(比如早期 Python)或者某些特定场景下还在用,但在 Java 这种复杂的工业级环境下,循环引用是一个绕不过去的坑。
我们可以对比一下这两者的优缺点:
引用计数法:
- 优点:执行快,不会像可达性分析那样需要
Stop The World(STW) 去扫描整个堆,它是分散在程序运行中的。 - 缺点:循环引用解不了;维护计数器本身也有开销。
可达性分析法(JVM 的选择):
- 优点:完美解决循环引用问题。只要你和 GC Roots 没关系,哪怕你们几个小团体互相引用得再欢,也会被一锅端掉。这保证了回收的准确性。
- 缺点:在回收时,为了保证一致性,通常需要 STW(Stop The World),即暂停用户线程,这就会带来所谓的“卡顿感”。
所以,Android 开发中我们常说的“内存泄漏”,本质上就是一个生命周期长的对象持有了生命周期短的对象,导致短生命周期的对象到 GC Roots 依然可达,从而无法被回收。我在实际工作中,会配合 MAT(Memory Analyzer Tool) 去看对象的引用链(Path to GC Roots),这正是基于可达性分析原理来排查问题的。
聊聊内存泄漏的原理,为什么会导致泄漏?
嗯,其实内存泄漏的本质,用一句话总结就是:生命周期长的对象,持有了生命周期短的对象的强引用,导致短生命周期的对象在逻辑上该被销毁时,却因为 GC Roots 可达而无法被回收。
我们可以从 JVM 的可达性分析算法来理解。在 Android 中,一个对象如果能被 GC Roots(比如:正在运行的线程、静态变量、JNI 全局引用等)直接或间接引用到,那么 GC 就会认为它是“活着的”,不会去回收它。
但在实际开发中,有些对象是有明确生命周期的。最典型的就是 Activity。当用户点击返回键,Activity 应该被销毁。但如果这时候,有一个长生命周期的对象(比如一个单例、一个静态变量,或者一个还在跑的后台线程)持有了这个 Activity 的引用,那么即使 Activity 执行了 onDestroy(),由于它到 GC Roots 依然有一条通路,垃圾回收器就不敢动它。
这时候,原本应该释放的一两兆、甚至几十兆的内存就一直卡在堆里。随着用户操作的增加,这种“死掉”但“占位”的对象越来越多,可用的堆空间就越来越小,最后的结果就是触发频繁的 GC 导致卡顿,甚至直接抛出 OOM(Out of Memory) 导致应用崩溃。
我在阅读 AOSP 源码或者分析内存快照时发现,绝大多数泄漏其实都源于对作用域的疏忽,特别是在多线程和回调里,很容易无意中拉长了对象的生命周期。
Android 中常见的内存泄漏场景有哪些?
在我的开发和项目实践中,最常遇到的内存泄漏场景大概有这么几种:
第一种,也是最经典的一种,是非静态内部类或者匿名内部类。比如在 Activity 里直接定义一个非静态的 Handler 或者 Thread。因为在 Java 中,非静态内部类会隐式持有外部类(也就是 Activity)的引用。如果 Handler 里的消息没处理完,或者 Thread 里的任务没跑完,Activity 就永远释放不掉。
第二种是单例或者静态变量。因为静态变量的生命周期是和进程一致的。如果我在单例里传了一个 Activity 的 Context,并且把它存在一个成员变量里,那这个 Activity 就算退出了,也会一直被单例拽着。所以我们一般建议在这里用 ApplicationContext。
第三种是资源未关闭或监听器未注销。比如我们用了 EventBus、RxJava 或者系统自带的 SensorManager 注册了监听,但在 onStop 或者 onDestroy 里忘了 unregister。还有就是像 WebView,它在 Activity 销毁后往往还占着内存,通常需要手动从父容器移除并调用 destroy()。
最后一种是 动画(Animation)。如果我们开了无限循环的属性动画,但在 Activity 销毁时没去 cancel 它,那么动画播放器产生的引用链会一直持有 View,进而持有 Activity,导致泄漏。
其实,只要把握住“对象不该存在时是否还被强引用”这个点,大部分泄漏场景都能一眼看出来。
排查内存泄漏的方法有哪些?
排查内存泄漏,我通常会遵循一套从“发现”到“定位”再到“验证”的流程:
首先是工具发现阶段。在平时的开发测试中,我一定会集成 LeakCanary。它是目前 Android 端最好用的自动化检测工具,一旦发生疑似泄漏,它会直接弹通知并给出引用链。
如果需要更精确的分析,我会用到 Android Profiler。
- 我会先打开 Profiler 的 Memory 视图,观察内存的走势。如果发现内存一直在阶梯状上升,且手动点击“强制 GC”后内存也没有降下来,那基本可以确定有泄漏。
- 接着,我会通过 Capture heap dump 抓取一份堆转储文件。
拿到 hprof 文件后,如果问题比较复杂,我会把它导出来,放到 MAT (Memory Analyzer Tool) 里进行深度分析。
在 MAT 里,我最常用的两个功能是:
- Histogram(直方图):看哪个类的实例数量异常多。
- Dominator Tree(支配树):看哪个对象占用的内存最大。 最核心的操作是,找到那个可疑对象,右键查看它的 Path to GC Roots,并过滤掉虚引用、弱引用等,只看强引用(exclude all phantom/weak/soft references)。这样就能清晰地看到,到底是哪个长命对象在拽着这个本该死掉的对象。
最后,我会根据定位到的代码进行修改,然后重复上面的流程,确保内存走势恢复正常。
聊聊 LeakCanary 的实现原理。
LeakCanary 的设计非常巧妙,我看过它的部分源码,其核心原理可以概括为:利用 ActivityLifecycleCallbacks 监听生命周期 + WeakReference 与 ReferenceQueue 检测回收情况。
具体流程是这样的:
首先,LeakCanary 会通过 AppWatcher 自动给所有的 Activity 和 Fragment 注册生命周期监听。当一个 Activity 执行完 onDestroy 以后,它会被封装成一个 KeyedWeakReference(这是一个弱引用)。
这时候,LeakCanary 会把这个弱引用关联到一个 ReferenceQueue(引用队列)上。它的核心逻辑是:如果一个对象被垃圾回收了,它的弱引用就会被加入到这个队列里。
然后,它会稍微等一下(默认是 5 秒),并手动触发一次背景 GC。接着去检查那个 ReferenceQueue。
- 如果队列里已经有这个引用了,说明对象被回收了,一切正常。
- 如果队列里没有这个引用,说明对象还没被回收,可能发生了内存泄漏。
如果确定发生了泄漏,LeakCanary 就会调用 Shark 库(它自研的一个高性能 hprof 解析器)去分析当前的堆内存快照。它会计算出从 GC Roots 到这个泄漏对象的最短路径,并把这条路径展示给开发者,这就是我们平时在 App 里看到的那个引用链。
我非常佩服它的点在于,它在 Android 10 以后通过 ContentProvider 实现了自动初始化,而且用 KeyedWeakReference 配合唯一的 key 能够非常精准地追踪到到底是哪一个实例泄漏了。
匿名对象一定会持有外部类的引用吗?
这是一个非常经典的问题,结论是:不一定。这取决于它是普通的匿名内部类,还是 Lambda 表达式。
-
普通的匿名内部类:是一定会持有外部类引用的。 即使你在代码里没有显式地写
OuterClass.this,Java 编译器在编译之后,也会为这个匿名内部类自动生成一个构造函数,并把外部类的引用作为参数传进去,保存在一个叫this$0的合成字段里。这就是为什么我们在Handler内部能直接调用 Activity 的方法,也是它导致内存泄漏的根源。 -
Lambda 表达式(Java 8+):不一定持有。 Lambda 表达式在字节码层面的实现和匿名内部类不一样,它使用的是
invokedynamic指令。- 如果 Lambda 内部没有访问外部类的成员变量或方法,它就不会持有外部类的引用。它本质上会被编译成外部类里的一个私有静态方法。
- 如果 Lambda 内部访问了外部类的实例变量或方法,它才会捕获(capture)这个引用。
所以,如果我们追求更安全的写法,在 Android 开发中,能用静态内部类(配合弱引用)就用静态内部类,或者在支持 Java 8 的项目里,注意 Lambda 对外部变量的捕获。
我在看一些优秀的开源库(比如 OkHttp 或者 Retrofit)时,发现它们在处理内部回调时非常小心,尽量避免不必要的引用捕获,这其实就是为了从根源上减少内存泄漏的风险。
图片的大小是如何确定的?图片的像素又是怎么决定的?
嗯,关于图片的大小,其实我们要分清楚两个概念:一个是磁盘上的文件大小,另一个是加载到内存里的位图(Bitmap)大小。面试官您问的应该是 Android 开发中更核心的“内存占用大小”。
结论先行:Bitmap 占用的内存大小 = 宽 高 每像素占用的字节数。
但在 Android 实际开发中,这个宽和高并不是简单的原图宽高。我之前扒过 BitmapFactory.java 的源码,发现有几个关键因素在起作用:
-
图片的原始像素:这是由图片文件本身决定的,比如一张 1080p 的图,它的像素宽高就是 。
-
屏幕密度(Density)与存放目录:这是一个很多初学者容易忽略的点。Android 会根据图片所在的资源文件夹(比如
drawable-xxhdpi)和当前设备的屏幕密度(targetDensity)进行缩放。- 公式其实是:。
- 如果我把一张针对 xxhdpi(480dpi)设计的图放在了 xhdpi(320dpi)的手机上跑,系统会自动对其进行缩放处理。
-
解码配置(Bitmap.Config):这决定了“每个像素占多少字节”。
- ARGB_8888:这是默认配置,每个像素占 4 字节(R、G、B、A 各 8 位),画质最好。
- RGB_565:没有透明度,每个像素占 2 字节。在内存吃紧或者不需要透明度的缩略图场景下,我经常会手动改这个配置来省一半内存。
- HARDWARE:这是 Android 8.0 引入的,Bitmap 直接存储在显存里,不占用 Java 堆内存,对渲染性能有很大提升。
其实我在处理大图加载的时候,最核心的工具就是 BitmapFactory.Options。我会先设置 inJustDecodeBounds = true,这样只读取图片的宽高信息而不加载整张图。然后计算出合适的 inSampleSize(采样率),比如设置为 2,那么宽高都会减半,内存占用直接缩减到原来的 。
总结一下,图片的像素是由源文件和采样率决定的,而最终的内存大小则是像素数量与像素格式共同作用的结果。我在看 native 层的 BitmapFactory.cpp 时,发现最终内存分配是交给 Skia 引擎去做的,这也是为什么在 Android 8.0 之后,Bitmap 的像素数据又回到了 Native 堆的原因。
HTTPS 的加密过程是怎么样的?
嗯,聊到 HTTPS,其实它就是在 HTTP 的基础上加了一层 SSL/TLS 协议。它的核心逻辑是:利用非对称加密来协商对称加密的密钥,最后用对称加密来传输实际数据。
为什么要这么折腾呢?因为对称加密快,但密钥分发不安全;非对称加密安全,但计算量太大,不适合传大量数据。所以它们俩“优劣互补”。
整个握手过程,我把它简化为这么几个步骤:
- Client Hello:客户端(比如我们的 App)先发起请求,告诉服务器:我支持哪些加密算法,还有我的 TLS 版本,并传一个随机数 A。
- Server Hello:服务器挑一个算法,也传回一个随机数 B,并把它的数字证书发过来。
- 证书验证:这是最关键的一步,客户端会检查证书是否合法(一会儿我会详细说验证过程)。
- 预主密钥(Pre-Master Secret)协商:如果证书没问题,客户端会再生成一个随机数 C,然后用服务器证书里的公钥把这个 C 加密发过去。
- 生成主密钥:现在,客户端和服务器手里都有了 A、B、C 这三个随机数。它们会按照约定的算法,算出同一个“主密钥”(Master Secret)。
- 握手完成:接下来的所有通信,就全都用这个“主密钥”进行对称加密传输了。
其实在 TLS 1.3 里,这个过程更简化了,去掉了冗余的往返,握手速度更快。我在做 Android 的网络层封装时,会配合 OkHttpClient 的 CertificatePinner(证书锁定)来进一步防止中间人攻击,确保即使用户手机上装了恶意证书,我们的 App 也不会中招。
验证证书的过程是怎样的?
验证证书其实就是为了确认“你真的是你所声称的那个人”。这在 HTTPS 流程里非常关键,否则中间人随便发个假证书,加密就全白费了。
整个过程可以看作是一个**信任链(Chain of Trust)**的校验:
-
解析证书内容:客户端收到证书后,会读取里面的颁发者、有效期、域名信息以及最重要的——数字签名。
-
校验数字签名:
- 证书里有一个签名值,它是 CA(证书颁发机构)用自己的私钥对证书内容进行哈希后加密得到的。
- 客户端会从本地系统的 根证书库(TrustStore) 里找到对应的 CA 根证书,取出它的公钥。
- 用这个公钥去解密证书上的签名,得到一个哈希值。
- 客户端自己再对证书内容算一遍哈希。
- 比对两个哈希值。如果一致,说明证书内容没被篡改过,且确实是这个 CA 签发的。
-
检查信任链:有时候服务器发的不是根证书,而是中间证书。客户端会一级一级往上找,直到追溯到系统内置的、绝对信任的根证书(Root CA)。
-
其他检查:除了签名,还会检查证书是否过期、是否在吊销列表(CRL/OCSP)里,以及证书里的域名是否和当前访问的域名匹配。
在 Android 侧,系统默认会帮我们完成这些工作。但我之前处理过一些特殊的业务场景,比如公司内部测试用的自签名证书。这时候我就得手动配置 Network Security Configuration 或者是自定义 TrustManager。我会重写 checkServerTrusted 方法,在这里面实现自己的校验逻辑,把我们特定的根证书打进 App 内部。
聊聊平衡二叉树(AVL 或 红黑树)。
关于平衡二叉树,其实它诞生的初衷是为了解决普通二叉搜索树(BST)的一个痛点:在极端情况下(比如按顺序插入),BST 会退化成一个链表,导致查询时间复杂度从 变成 。
为了维持高效的查询,我们需要让树的左右子树尽量“平衡”。
最经典的平衡二叉树是 AVL 树。它的定义非常严格:任何节点的左右子树高度差不能超过 1。
- 如何维持平衡? 主要是靠旋转。根据失衡的情况,分为 LL(左左)、RR(右右)、LR(左右)、RL(右左)四种旋转方式。
- 缺点:因为它的平衡要求太死板了,每次插入或删除如果破坏了平衡,都要进行大量的旋转调整。所以在频繁增删的场景下,性能开销比较大。
于是,在实际工程(比如 Java 的 TreeMap 或者 Linux 内核的进程调度)中,我们用得更多的是 红黑树(Red-Black Tree)。
红黑树不追求“绝对平衡”,它是一种弱平衡。它有五个核心性质(比如根节点是黑色、红色节点的子节点必须是黑色、从任一节点到叶子的黑节点数量一致等)。
- 它的查询效率虽然比 AVL 稍差一点点,但依然维持在 。
- 优势在于:它在插入和删除时,调整平衡所需的旋转次数比 AVL 树少很多。它通过变色和少量的旋转就能快速恢复平衡。
我在研究 Android 的组件化路由库(比如 ARouter) 的原理时,发现内部匹配路由路径的时候也会涉及到树形结构的搜索。而在 Java 层,最常见的红黑树实现就是 HashMap 在 JDK 8 之后的优化——当链表长度超过 8 时转为红黑树,这就是为了在发生哈希冲突时,依然能保证查询效率不至于崩掉。
总结一下,平衡二叉树就是通过牺牲一定的插入/删除性能来进行自平衡,从而换取稳定的查询时间复杂度。对于开发者来说,理解旋转的逻辑和红黑树的特性,是理解很多底层集合类(Collections)性能瓶颈的关键。
字节跳动 七面
聊聊你对 Jetpack Compose 声明式 UI 的理解
嗯,关于 Jetpack Compose,我觉得它不仅仅是一个新的 UI 库,更是 Android 开发范式的一次彻底演进。
其实最核心的区别在于:传统的 View 系统是“命令式”的,而 Compose 是“声明式”的。
在以前的 XML 体系里,我们要更新 UI,流程通常是这样的:先通过 findViewById 拿到 View 实例,然后手动调用 setText 或者 setVisibility。这其实是在“命令”代码去一步步修改 View 的状态。这种方式最大的痛点在于,当 UI 变得复杂时,状态(State)和 UI 的同步会变得极其痛苦,很容易出现“数据变了但 UI 没刷”或者“状态冲突”的问题。
而 Compose 的声明式逻辑是:我只负责描述“在某种状态下,UI 应该长什么样”。
简单来说,UI 变成了状态的函数,也就是所谓的 UI = f(State)。当状态发生变化时,Compose 的底层框架会自动触发重组(Recomposition),重新执行相关的 Composable 函数来刷新界面。
我在实际开发中感触最深的是以下几点:
- 单一数据源:由于 UI 是跟着状态跑的,我们只需要关注数据的维护。配合
MutableState和remember,我们可以很自然地实现状态提升(State Hoisting),让 UI 组件变得更纯粹、更好测试。 - 高性能重组:很多人担心重组会卡顿,但其实 Compose 编译器会做非常精细的优化。它只会刷新那些真正依赖了发生变化的状态的代码块。
- 代码量大减:以前写一个复杂的 RecyclerView 列表,要写 Adapter、ViewHolder、LayoutManager,还要处理各类数据更新。在 Compose 里,一个
LazyColumn配合几个items声明就能搞定,开发效率提升确实非常明显。
其实看源码也能发现,Compose 彻底抛弃了传统的 android.view.View 树,而是自己维护了一套 Slot Table(槽位表) 结构。每次重组时,它会对比新旧状态,只针对差异部分进行节点更新。这种思想跟 React 或者 Flutter 比较像,但在 Android 原生层面上,它是目前最优雅的解决方案。
讲讲 Java 的强引用和弱引用,还有其他引用类型吗?
嗯,在 Java 里,引用的强弱直接决定了对象被垃圾回收(GC)的时机。主要分为四种:强引用、软引用、弱引用,还有虚引用。
首先是 强引用(Strong Reference)。这就是我们平时最常用的,比如 Object obj = new Object()。只要这个强引用关系还在,哪怕内存不足(OOM)了,GC 也绝对不会回收这个对象。如果内存实在不够,系统宁愿抛出 OutOfMemoryError 导致程序崩溃。
然后是 弱引用(WeakReference)。它的特点是:只要 GC 发现了它,不管当前内存够不够,都会直接回收它。在 Android 开发中,弱引用简直是我们的“救命稻草”。最典型的场景就是 Handler 导致的内存泄漏。我们通常会把 Activity 做成弱引用传给静态内部类的 Handler,这样当 Activity 销毁时,GC 就能正常回收它,不会因为 Handler 里的匿名内部类持有 Activity 的强引用而导致泄漏。
除了这两个,还有另外两种:
一个是 软引用(SoftReference)。它的强度介于强和弱之间。只有在内存真的不足,快要发生 OOM 之前,GC 才会去回收软引用的对象。这非常适合用来做缓存,比如图片缓存。内存够用时,缓存就在;内存紧缺时,系统自动帮我们释放,非常智能。
最后一种是 虚引用(PhantomReference)。这个其实很罕见。它完全不会影响对象的生命周期,你甚至没法通过它拿到对象的实例。它的唯一作用是配合 ReferenceQueue(引用队列)使用,当对象被回收时,你会收到一个系统通知。这通常用于管理**堆外内存(DirectBuffer)**的释放,或者做一些深度的资源清理工作。
我在看 LeakCanary 的源码时发现,它其实就是利用了 WeakReference 配合 ReferenceQueue 的机制,来监控那些本该被回收的对象是否还在内存里,从而判断是否发生了泄漏。
聊聊 ThreadLocal 的原理,以及你平常在什么场景用到它?
嗯,ThreadLocal 简单来说就是线程局部变量。它的核心作用是实现数据的线程隔离。也就是说,如果你创建了一个 ThreadLocal 变量,每个访问它的线程都会有该变量的一个独立副本,线程之间互不干扰。
它的底层原理其实挺有意思的。很多人直觉上以为数据是存在 ThreadLocal 对象里的,但其实不是。
真正的秘密在于 Thread 类内部。每一个 Thread 对象里都维护着一个 ThreadLocalMap(这是一个类似于 HashMap 的自定义哈希表)。
- 当你调用
threadLocal.set(value)时,它其实是先拿到当前的线程对象,然后把这个ThreadLocal实例作为 Key,把你要存的值作为 Value,存到那个线程自己的ThreadLocalMap里。 - 当你调用
get()时,也是从当前线程的 Map 里取。
所以,数据的物理隔离是通过“每个线程持有一个独立的 Map”来实现的,ThreadLocal 本身更像是一个访问入口(Key)。
说到 应用场景,最经典的例子就是 Android 里的 Looper。
我们在主线程调用 Looper.prepare(),其实就是 new 了一个 Looper 并存进了 ThreadLocal。因为 Looper.prepare() 每个线程只能调用一次,这样就能确保一个线程只有一个 Looper。后续我们在任何地方调 Looper.myLooper(),其实就是从当前线程的 ThreadLocal 里把那个唯一的 Looper 拿出来。
另外一个常见的场景是 SimpleDateFormat。这个工具类不是线程安全的,如果多个线程共享一个实例会出问题。我们可以用 ThreadLocal 给每个线程分配一个独立的格式化工具,既保证了线程安全,又避免了频繁创建对象的开销。
最后我还想提一下 内存泄漏 的问题。ThreadLocalMap 里的 Key 是弱引用,但 Value 是强引用。如果线程生命周期很长(比如在线程池里),且没有手动调用 remove(),那么那个 Value 可能会一直占着内存回收不掉。所以我在用 ThreadLocal 的时候,习惯在 finally 块里加一个 remove(),这算是一个标准的操作。
聊聊 Java 的集合体系,以及多线程下的数据竞争问题怎么解决?
嗯,Java 的集合框架其实是我们开发中最常用的工具。大体上可以分为两类:一个是 Collection 接口,下面延伸出 List、Set、Queue;另一个是独立的 Map 接口。
- List 我们最常用,比如
ArrayList(基于动态数组,查快增删慢)和LinkedList(双向链表,增删快)。 - Set 主要是保证唯一性,常用的有
HashSet和TreeSet。 - Map 则是键值对存储,最核心的就是
HashMap。
但问题在于,像 ArrayList 和 HashMap 这些,它们都是线程不安全的。如果多个线程同时进行写操作,比如 HashMap 在扩容时可能会导致死循环或者数据丢失。
解决多线程数据竞争,我有几种常用的方案:
首先是比较“暴力”的同步包装类。可以用 Collections.synchronizedList 或者 synchronizedMap。它们其实是给每个方法都加了 synchronized 锁。虽然安全,但并发性能比较差。
其次是 Concurrent 并发包 下的组件。
如果是 List,我会用 CopyOnWriteArrayList。它的原理是“写时复制”,也就是写数据的时候先 copy 一份新数组,写完再把引用指过去。这种方式在读多写少的场景下效率极高,而且能实现无锁读。
如果是 Map,那肯定首选 ConcurrentHashMap。在 Java 8 之后,它的底层放弃了分段锁,改为使用 CAS + synchronized。它只锁住链表或红黑树的头节点,锁的粒度非常细。我在读源码时注意到,它还引入了 ForwardingNode 这种设计,支持多线程协同扩容,这设计真的非常精妙。
最后,如果只是针对简单的数值竞争,我会优先考虑 Atomic 原子类(比如 AtomicInteger)。它们底层利用的是 CPU 的 CAS 指令,属于乐观锁,性能通常比 synchronized 要好。
synchronized 的用法有哪些?它的底层原理是什么?
synchronized 应该是 Java 并发里最经典的关键字了。它的用法主要有三类:
- 修饰实例方法:锁的是当前的实例对象
this。 - 修饰静态方法:锁的是当前类的
Class对象。 - 修饰代码块:这就比较灵活了,可以指定锁任意对象。
其实我在学习 JVM 内存模型时,专门去看了它的底层原理。synchronized 的核心是依托于 Object Monitor(对象监视器)。
在字节码层面,如果你修饰的是代码块,你会看到 monitorenter 和 monitorexit 这两个指令。每个对象头里都有一个 Mark Word,里面存储了锁的状态位。当一个线程尝试获取锁时,它其实是在尝试获取这个对象的监视器所有权。
而且,现在的 JVM 对 synchronized 做了大量的优化,不再是以前那种一上来就动用内核态的“重量级锁”了。它有一个锁升级的过程:
- 偏向锁:如果锁一直被同一个线程持有,它就在对象头里记下线程 ID,几乎没有开销。
- 轻量级锁:当出现竞争时,升级为轻量级锁。它通过 CAS 自旋 来尝试获取锁,避免了线程上下文切换。
- 重量级锁:如果自旋多次还是拿不到锁,或者竞争太激烈,就会升级为重量级锁。这时候线程会进入阻塞状态,交给操作系统内核去管理。
所以我现在的理解是,只要不是极端高并发,synchronized 的性能其实已经非常不错了,而且它写起来简单,还不容易出错。
说说 LeakCanary 检测内存泄漏的原理,它到底是怎么发现 Activity 泄露的?
LeakCanary 是我们排查内存泄漏的标配。我觉得它最厉害的地方在于把复杂的引用链追踪做得非常自动化。
它的核心逻辑其实可以拆解为:监控、诱发 GC、分析堆快照这三步。
首先是监控。当一个 Activity 执行完 onDestroy() 后,LeakCanary 会通过 ActivityLifecycleCallbacks 监听到。然后它会把这个 Activity 包装成一个 WeakReference(弱引用),并且关联到一个 ReferenceQueue(引用队列)。
接着,它会给这个引用设置一个唯一的 Key。逻辑上,如果 Activity 正常被销毁了,那 GC 之后,这个弱引用就应该出现在 ReferenceQueue 里。
过了 5 秒后,LeakCanary 会去检查队列。如果 Activity 还在(也就是没出现在队列里),它会觉得“嗯,可能出问题了”。这时候它会手动触发一次 Runtime.getRuntime().gc(),确保不是因为 GC 还没来得及跑。
如果触发 GC 后,那个 Activity 还是没被回收,LeakCanary 就会确定发生了内存泄漏。这时它会调用 Debug.dumpHprofData() 来导出当前的堆内存快照文件(.hprof)。
最后,它会利用一个叫 Shark 的库(以前是 HAHA)来解析这个文件。它会从 GC Root 开始,沿着引用链一直往下找,直到找到那个被泄漏的对象,并计算出一条最短引用路径。
我看源码时发现,它还会过滤掉一些系统级的已知泄漏,避免误报。最后把这整条路径通过通知栏或者 Log 展示给我们。整个流程非常闭环,特别是利用 WeakReference 配合 ReferenceQueue 的思路,确实很经典。
谈谈你对 MVC、MVP、MVVM 三种架构的理解和区别
这三种架构其实是 Android 架构不断进化的结果,目标都是为了解耦和可维护性。
首先是 MVC(Model-View-Controller)。在 Android 里,Activity 既充当 View(持有 UI 控件)又充当 Controller(处理逻辑)。结果就是 Activity 动不动就几千行,耦合度极高,代码很难复用,也很难做单元测试。现在基本已经不推荐用了。
然后是 MVP(Model-View-Presenter)。它通过 Presenter 把 Model 和 View 彻底隔开了。
- View 层只负责 UI 显示,通常会定义一个 IView 接口。
- Presenter 持有 View 的接口引用,处理业务逻辑并手动更新 View。 它的优点是解耦很彻底。但缺点是随着业务变复杂,接口定义的代码量巨大,而且 Presenter 经常持有着 View 的引用,如果销毁时没处理好,非常容易导致内存泄漏。
最后是我目前主力使用的 MVVM(Model-View-ViewModel)。它是 Jetpack 官推的架构。
- 核心区别在于 ViewModel 和 数据驱动。
- ViewModel 并不持有 View 的引用,而是暴露一些 LiveData 或者 StateFlow。
- View 层(Activity/Fragment)只需要“观察”这些数据。数据一变,UI 自动刷新。
为什么 MVVM 更好?
- 自动解耦:ViewModel 通过观察者模式驱动 UI,彻底解决了内存泄漏问题(因为它不持有 View)。
- 生命周期感知:ViewModel 配合
Lifecycle插件,能跨越配置变更(比如旋屏)而存在。 - 配合 Compose:现在有了 Compose 声明式 UI,MVVM 的这种数据驱动特性简直如鱼得水。
其实在字节或者腾讯的大项目中,我们通常会在 MVVM 的基础上加入 Clean Architecture 的概念,比如增加 Repository 层来统一管理本地和网络数据源,这样代码的结构会更清晰。
算法题:如何搜索旋转排序数组?(比如 [4,5,6,7,0,1,2] 找 target)
这道题其实是二分查找的一个经典变种。虽然数组旋转了,但它依然保持了“局部有序”的特性。
我的思路是这样的:
由于是 的要求,我们还是用左右指针 left 和 right。
每次我们取中间位置 mid。这时候,mid 左右两边一定有一半是有序的,而另一半可能是有序也可能是旋转的。
具体的判断逻辑是:
- 我们先看
nums[left]和nums[mid]的关系。 - 如果
nums[left] <= nums[mid],说明左半部分是有序的。这时候我们就看target是不是落在左边这个有序区间里(即nums[left] <= target < nums[mid])。如果是,就把right往左移;否则去右边找。 - 如果上面的条件不成立,说明右半部分是有序的。同理,我们看
target是否在右边这个有序区间(即nums[mid] < target <= nums[right])。如果是,就把left往右移;否则去左边找。
其实这个算法的关键点就在于:二分之后,通过判断哪一半是有序的,来决定收缩范围。
在处理边界条件时,比如 nums[left] == nums[mid] 的情况,通常是因为数组里有重复元素,但这道题如果是典型的不重复情况,直接二分就行。这种“分段有序”的处理思想,在很多 Framework 层的索引查找逻辑里也能看到。
字节跳动 八面
如果不使用 KSP 这种插桩手段,有什么办法能定位 Compose 组件的执行耗时?
嗯,这其实是一个非常有深度的问题。因为 Compose 的重组(Recomposition)是高度优化的,而且它不像传统的 View 体系那样有明确的 onMeasure/onLayout 回调可以让我们直接埋点。如果不走 KSP 或者 Transform 这种字节码修改的路径,我其实会从 Compose 的底层运行时机制和系统级追踪这两个维度去考虑。
首先,最直接且官方推荐的方式是利用 Composition Tracing(系统性能追踪)。
Compose 从 1.2 版本开始,在底层就已经内置了对 Perfetto 和 System Trace 的支持。当我们开启特定的追踪开关后,Compose 编译器生成的代码会在每个 Composable 函数的开头和结尾插入 Trace.beginSection 和 Trace.endSection。这样我们直接通过 Android Studio 的 Profiler 或者 Perfetto 工具,就能看到每一个 Composable 函数的具体执行时间,包括它是在哪个线程跑的,耗时了多少毫秒。
其次,如果我想在代码运行时自己去感知耗时,可以利用 Side-Effect(侧边效应)。
我们可以自定义一个封装好的 Composable 容器。在函数开始处记录一个时间戳,然后利用 SideEffect 或者 DisposableEffect。因为 SideEffect 会在每次成功的重组之后执行,我们可以在这里计算差值。不过这种方式只能统计到“执行完”的时间,没法精确过滤掉线程切换或者系统调度的干扰,只能作为参考。
再进阶一点,其实可以研究一下 CompositionLocal 和 CompositionTracing 的源码实现。
Compose 内部有一个 CompositionTracer 接口。虽然它是内部 API,但通过反射或者特定配置,我们可以尝试注入自己的 Tracer。当 ComposerImpl 执行重组逻辑时,它会回调这些接口。
最后,还有一个比较取巧的方案是利用 Layout Inspector 的原理。
Compose 的所有节点最后都会反映在 Slot Table(槽位表) 里。我们可以通过反射 Composition 对象,拿到它的 CompositionImpl 里的 SlotTable。通过遍历这个表,我们可以观察到节点的变动频率。
总之,如果不动字节码,Perfetto 配合自带的 Tracing 是最准的;如果要做线上监控,可能需要结合 SideEffect 做一些采样统计。这块我在研究 Compose 性能优化时专门对比过,官方的 Trace 方案其实已经能覆盖 90% 的性能瓶颈定位需求了。
聊聊 LeakCanary 的原理和内部机制,它是怎么发现内存泄漏的?
嗯,关于 LeakCanary,它几乎是 Android 开发中解决内存泄漏的“标准答案”。我之前扒过它的源码,它的设计思路非常精妙,主要是通过自动监控生命周期和弱引用判定来实现的。
结论先行的话,它的工作流可以概括为:监听销毁 -> 延时检测 -> 触发 GC -> 导出并解析 Hprof。
具体细节是这样的:
- 自动监听:LeakCanary 利用了
Application.ActivityLifecycleCallbacks(如果是 Fragment 则用FragmentManager.FragmentLifecycleCallbacks)。当一个 Activity 执行完onDestroy时,它会收到回调。 - 弱引用包装:收到回调后,LeakCanary 会把这个 Activity 包装成一个
KeyedWeakReference(这是一个继承自WeakReference的类),并且给它关联一个ReferenceQueue(引用队列)。同时,它会生成一个唯一的 ID(Key)存在集合里。 - 延时判定:它不会立刻报错,而是等待 5 秒。之后它会去检查这个
ReferenceQueue。按照 Java 虚拟机的机制,如果对象被回收了,这个弱引用就会进入队列。如果队列里没看到它,说明这对象还在内存里呆着呢。 - 确认泄漏:为了排除由于 GC 没及时触发导致的“假阳性”,LeakCanary 会通过
Runtime.getRuntime().gc()强制触发一次垃圾回收。如果 GC 后,队列里还是没出现那个引用,那基本可以断定它是内存泄漏了。 - 链路分析:这是最关键的一步。它会调用
Debug.dumpHprofData()拿到内存快照,然后用它自研的 Shark 库(一个非常轻量级的 Hprof 解析器)去扫描这个文件。它会以 GC Root 为起点,寻找一条通往被泄漏对象的最短路径。
我当时看源码时最感慨的是它的 Shark 库。相比于早期的 HAHA 库,Shark 完全是针对移动端优化的,解析速度快了非常多,而且它能识别出很多 Android 特有的单例、系统服务等作为 GC Root,避免了我们自己去手动过滤那些系统造成的“假泄漏”。
聊聊 OkHttp 和 Retrofit 中用到的设计模式,它们为什么要这么设计?
嗯,OkHttp 和 Retrofit 是 Android 网络请求的黄金搭档。它们的设计非常优雅,可以说是一本活的“设计模式教材”。
首先说 OkHttp,它最核心的灵魂就是 责任链模式(Chain of Responsibility)。
OkHttp 所有的网络请求、缓存处理、连接复用,全部都是通过一个个 Interceptor(拦截器) 实现的。它内部维护了一个 RealInterceptorChain。
- 比如
RetryAndFollowupInterceptor负责重试。 CacheInterceptor负责缓存。ConnectInterceptor负责建立 Socket 连接。 这种设计的好处是极度解耦。每个拦截器只需要关心自己那一层的逻辑。对于我们开发者来说,通过addInterceptor就能无缝插入自定义逻辑(比如加 Header、日志打印),这简直是插件化开发的典范。
此外,OkHttp 还大量使用了 Builder 模式。比如 OkHttpClient 和 Request。因为网络请求的配置项实在是太多了(超时、缓存、协议、Proxy 等),用构造函数根本无法维护,Builder 模式让代码的可读性和灵活性达到了最高。
接下来说 Retrofit,它更多的是在 OkHttp 之上做了一层精美的封装。
- 外观模式(Facade Pattern):Retrofit 类本身就是一个外观。它隐藏了复杂的内部逻辑(如何解析注解、如何适配平台、如何发起请求),让用户只需要定义一个接口就能搞定网络访问。
- 动态代理(Dynamic Proxy):这是 Retrofit 的核心。当我们调用
retrofit.create(Service.class)时,它在运行时生成了一个InvocationHandler。它会解析你接口方法上的注解(比如@GET、@Query),把这些元数据转化成一个ServiceMethod对象。这使得我们可以用声明式的方式写代码,而不是去手动拼接参数。 - 适配器模式(Adapter Pattern):体现在
CallAdapter上。它允许 Retrofit 支持不同的返回类型,比如Observable(RxJava)、Deferred(Kotlin 协程)或者原生的Call。 - 策略模式 / 工厂模式:体现在
Converter上。无论是 Gson、Jackson 还是 Protobuf,只要实现Converter.Factory,就能无缝接入。
我觉得这样设计最大的好处是职责分离:OkHttp 专注于底层高效地“传输数据”,而 Retrofit 专注于上层优雅地“解析接口”。这种分层设计思想,在我自己参与开源项目或者写 Framework 层代码时,也一直是我追求的目标。
聊聊责任链模式(Responsibility Chain)的好处是什么?
嗯,提到责任链模式,我脑子里第一个蹦出来的就是 OkHttp 的拦截器机制。我觉得责任链模式在 Android 开发中之所以被重用,最核心的好处就是三个词:解耦、灵活、以及代码的单一职责。
首先是解耦。如果没有责任链,所有的逻辑可能都会堆在一个巨大的 if-else 或者一个方法里。比如网络请求,你要处理缓存、处理重试、处理重定向、处理权限。如果全都写在一起,代码简直是灾难。用了责任链后,发起请求的人(Client)完全不需要知道中间经过了多少层处理,它只需要把请求丢进链条的起点,最后从终点拿结果就行了。
其次是灵活性和可扩展性。这在 OkHttp 里体现得淋漓尽致。如果我想给所有的请求加一个加密头,或者想做一个全局的日志打印,我不需要去改源码。我只需要自定义一个 Interceptor,然后把它 add 到链条里。这种动态插拔的能力,让框架的扩展性变得极强。
最后是单一职责原则。在责任链里,每一个节点(或者说每一个 Interceptor)只负责一件事情。
RetryAndFollowupInterceptor只管失败重试。CacheInterceptor只管缓存逻辑。 这种拆分让每个节点的代码都非常纯粹,出 bug 了也非常好定位。
我之前读 RealInterceptorChain.proceed() 源码的时候,觉得它最精妙的地方在于这种递归式的调用逻辑。每一个拦截器持有一个 Chain 对象,它处理完自己的活,调用一下 chain.proceed(),就把接力棒传给了下一个。这种设计让逻辑的流转变得非常清晰,也非常容易做一些 AOP(面向切面)的操作,比如在 proceed 前记录请求时间,在 proceed 后记录返回时间,就能轻松统计出网络耗时。
谈谈包大小优化的措施,以及这些优化能提升哪些性能?
包大小优化其实是 Android 进阶开发中的一个必修课,特别是在大厂,APK 的体积直接关系到下载转化率。我之前针对这个问题做过一些总结,可以从代码、资源、以及 Native 库三个维度来聊。
首先是代码优化:
- 最基础的就是开启 R8/ProGuard。它不仅仅是混淆,更重要的是能做 Shrinking(压缩),也就是把那些没用到的类和方法直接删掉。
- Lint 检查。通过分析工具找出那些废弃的代码。
- 三方库瘦身。很多时候我们只用到库里的一个小功能,却引了整个 Jar 包。我会优先寻找轻量级的替代方案,或者用反射去剥离核心逻辑。
其次是资源优化(这是见效最快的):
- 图片转 WebP。比起传统的 PNG 和 JPG,WebP 的压缩率高得惊人,而且几乎不损质量。
- resConfigs。我们很多时候引的库带了十几国语言,其实国内版只需要中文,通过
resConfigs "zh"就能砍掉大量的多语言配置。 - 移除无用资源。配合
shrinkResources true,在打包时自动剔除没引用的图片。 - 使用 VectorDrawable。对于简单的图标,用矢量图代替多套分辨率的位图,能省下不少空间。
最后是 Native 库优化:
现在很多 App 会引 SO 库,比如地图、音视频。我会配置 ABI 过滤器(比如只保留 armeabi-v7a 或者 arm64-v8a),而不是打全平台的包。
至于性能提升,我觉得不仅仅是节省存储空间:
- 安装速度提升:APK 越小,解压和 DEX 优化的时间就越短,用户安装感知会更好。
- 运行时内存优化:较小的图片资源意味着加载进内存后占用的 Heap 也会变小。
- 启动速度提升:DEX 文件变小了,类加载(Class Loading)的 IO 耗时就会减少,从而间接优化了冷启动时间。
- 转化率:其实最直接的还是业务指标。包体积每减小 1MB,在 Play Store 或者应用宝里的下载转化率通常都会有明显的提升。
线程和协程的区别是什么?
这是一个经常被问到,但也容易产生误解的问题。很多人说协程是“轻量级线程”,但我更倾向于认为:线程是操作系统层面的资源,而协程是应用层面的抽象。
我们可以从几个维度来对比:
第一,管理权不同。
- 线程是由操作系统内核(Kernel)管理的。线程的切换涉及到了内核态和用户态的转换,会有上下文切换(Context Switch)的开销,这包括寄存器状态的保存和恢复,比较重。
- 协程(尤其是 Kotlin 协程)是由程序员或者说运行在用户态的库管理的。它的切换不经过内核,纯粹是代码逻辑上的跳转,所以非常轻量。
第二,调度方式不同。
- 线程通常是抢占式的。由 OS 分配时间片,时间到了就强制切走。
- 协程是协作式的。一个协程如果不主动交出控制权(比如调用
suspend挂起),它会一直占着所在的线程。它的核心在于“挂起”和“恢复”,这两个操作是无损的。
第三,资源消耗。
- 创建一个线程,系统通常会分配 1MB 左右的栈内存。如果你开一千个线程,内存可能就爆了。
- 而协程本质上是一个对象。一个协程可能只占几百个字节。你可以在一个线程里跑成千上万个协程,完全没压力。
在 Android 开发中的实际意义: 其实我们用协程,最大的爽点在于用同步的代码风格写异步逻辑。 以前我们处理网络请求,要么用回调(Callback Hell),要么用 RxJava 链式调用。虽然解决了阻塞问题,但逻辑是跳跃的。 用了协程后,我们可以直接写:
val user = api.getUser() // 这是一个挂起函数
showUser(user)这段代码看起来是阻塞的,但实际上它在执行到 api.getUser() 时会挂起当前协程,释放线程去干别的事。等结果回来了,它再恢复(Resume)回来。
所以总结一下:线程是干活的实体,协程是任务的封装。 协程并不能让代码跑得更快(毕竟最后还是在线程里跑),但它能极大地提高开发者的工作效率,让并发代码变得既安全又好维护。
聊聊鸿蒙(HarmonyOS)是怎么调用 C++ 的?
嗯,关于鸿蒙调用 C++,其实它有一套专门的机制叫做 Node-API(以前也叫 N-API)。因为鸿蒙的应用层主要使用 ArkTS(基于 TypeScript),而底层或者高性能模块通常是用 C++ 写的,所以 Node-API 就充当了它们之间的桥梁。
其实这跟我们 Android 里的 JNI(Java Native Interface)逻辑挺像的,但语法风格更偏向于 Node.js 的插件开发。
具体流程大概是这样的:
首先,在 C++ 侧,我们需要包含 <napi/native_api.h> 这些头文件。我们需要定义一个导出函数,比如 napi_value MyNativeMethod(napi_env env, napi_callback_info info)。在这里,我们会通过 napi_get_cb_info 拿到从 ArkTS 传过来的参数。
然后,最关键的一步是模块注册。我们需要定义一个 napi_property_descriptor 数组,把 C++ 的函数名和 ArkTS 里调用的方法名关联起来。最后通过 NAPI_MODULE_INIT 这个宏来完成初始化。
在 ArkTS 侧,我们需要写一个 index.d.ts 的接口声明文件,告诉系统这个 C++ 模块里有哪些方法、参数类型是什么。这样我们在业务代码里直接 import 这个模块,就可以像调用普通 TS 函数一样去调 C++ 了。
我之前在研究鸿蒙 Framework 的时候注意到,它之所以选 Node-API,主要是为了解耦。Node-API 是一套稳定的 ABI(应用二进制接口),这意味着你编译好的 C++ 库,在鸿蒙版本升级后通常不需要重新编译就能跑,这比传统的 JNI 要省心不少。而且它的内存管理也是跟 ArkTS 的 GC 挂钩的,通过 napi_ref 这种机制能有效防止内存泄漏。
你了解哪些跨端框架?
嗯,作为 Android 开发,我其实一直有在关注跨端技术。目前市面上主流的框架,我大概把它们分成三类:
- Web 容器类:比如最早的 Cordova、Ionic,或者现在的 Webview/H5 方案。这种最简单,其实就是套个壳。在大厂里,这种方案通常用于一些活动页或者不追求极致体验的二级页面。
- 泛原生类(桥接类):代表就是 React Native (RN)。它的逻辑层是用 JS 跑在引擎里的,但 UI 层是真正的原生组件。它通过一个“桥”(Bridge)来同步数据。
- 自渲染类:代表是 Flutter。它完全不理会原生的 View 系统,自己用 Skia 或者 Impeller 引擎在屏幕上画像素。
- 逻辑跨端类:这就是最近很火的 Kotlin Multiplatform (KMP)。它不强求 UI 跨端,而是让你把业务逻辑、网络请求、数据库这些用 Kotlin 写一遍,然后直接编译成各平台的原生库(比如 Android 的 JVM 字节码和 iOS 的 Framework)。
我个人目前更看好 KMP 和 Flutter。Flutter 适合那种追求视觉高度一致、快速迭代的小型应用;而 KMP 这种“逻辑共享、UI 原生”的思路,其实更符合大厂(比如字节、腾讯)对高性能和原生稳定性的追求。
Flutter 为什么有时候感觉比原生慢?
这是一个挺有意思的话题。虽然官方宣传 Flutter 能达到 60fps 甚至 120fps 的流畅度,但在某些场景下,它确实会让人觉得“不如原生顺滑”。我觉得主要有这么几个原因:
首先是启动耗时。原生 App 启动时,系统直接加载资源和 Activity;而 Flutter App 启动时,必须先初始化 Flutter Engine,还要启动 Dart VM。这一套流程下来,冷启动时间通常会比纯原生多出几百毫秒。
其次是着色器编译抖动(Shader Compilation Junk)。这是 Flutter 早期的一个大痛点。当 App 第一次运行某个动画时,Skia 引擎需要现场编译 GLSL 着色器,这会瞬间占用大量 CPU/GPU 资源,导致那一帧卡顿。虽然现在有了 Impeller 引擎,通过预编译技术解决了很多,但在老旧安卓机上还是能感觉到。
再一个就是线程间的通信开销。Flutter 有四个主要的线程(Platform、UI、Raster、IO)。如果你在原生侧(Platform 线程)和 Flutter 侧(UI 线程)频繁通过 MethodChannel 传输大量数据(比如解析几万行 JSON),这个序列化和反序列化的过程是非常耗时的,会直接拖慢整体性能。
最后,Flutter 的 UI 是自渲染的,它没法利用 Android 系统层的一些硬件加速优化(比如某些特定的系统渲染路径)。而且,因为它的组件是模拟原生的,在手势追踪、长列表滚动这些细节上,一旦逻辑写得不够精细,那种“跟手感”还是很难完全达到原生的级别。
Flutter 和 React Native 在渲染机制上有什么区别?
这两者的渲染思路其实是截然不同的,可以说是两个极端的代表。
React Native (RN) 走的是**“原生组件映射”**的路线。 它的 UI 描述是用 JS/JSX 写的,经过 Yoga 布局引擎计算后,会通过 Bridge(或者新架构里的 JSI)发送指令给原生层。
- 在 Android 上,它最终会变成一个个真实的
android.widget.TextView或者ImageView。 - 优点是它利用了系统的原生能力,无缝支持系统的无障碍、输入法等。
- 缺点是跨端一致性难保证,而且复杂的层级结构会导致布局计算非常重,数据传输的“桥”也容易成为性能瓶颈。
Flutter 走的是**“自渲染(渲染引擎)”**的路线。
它完全绕过了原生的 View 系统。它在 Android 上其实就是一个 SurfaceView 或者 TextureView。
- Flutter 所有的 Widget(文本、按钮、图片)都是由它自己的渲染引擎(Skia 或 Impeller)直接在屏幕上画出来的。
- 它就像一个游戏引擎,每一帧都由自己算、自己画。
- 优点是跨端一致性极高(像素级一致),而且省去了中间的指令传输,绘制性能理论上上限更高。
- 缺点是它不属于原生 View 系统,如果你想在 Flutter 里嵌入一个原生的
MapView或者WebView,就必须用到PlatformView,这会导致严重的性能损耗和层级同步问题。
简单总结:RN 是原生组件的搬运工,而 Flutter 是像素点的艺术家。
KMP(Kotlin Multiplatform)是怎么实现跨端的?
KMP 的思路非常硬核,它的核心不是“一套 UI 走天下”,而是**“一套代码,多端编译”**。
它的实现主要靠 Kotlin 编译器 的强大能力:
- 针对 Android/JVM:Kotlin 代码会被编译成标准的 JVM 字节码。这跟我们平时写 Android 是一模一样的,性能零损耗。
- 针对 iOS/Native:这是最关键的地方。KMP 利用了 Kotlin/Native 技术,它底层是基于 LLVM 的。它会把 Kotlin 代码直接编译成对应平台的机器码(二进制)。比如在 iOS 上,它会生成一个
.framework文件,Swift 可以像调用原生库一样直接调用它。
它是怎么解决平台差异的呢?
KMP 引入了 expect / actual 关键字。
- 我们可以在 common 模块里声明一个
expect的函数(比如获取系统版本号)。 - 然后在 Android 模块里写一个
actual实现,调用 Android 的 API;在 iOS 模块里写另一个actual实现,调用 iOS 的UIDevice接口。 - 在编译的时候,编译器会自动把它们拼在一起。
我觉得 KMP 最聪明的地方在于它不绑定 UI。它只负责共享数据逻辑、算法、网络请求等(通过 ktor、sqldelight 等库)。UI 部分,Android 依然用 Compose,iOS 依然用 SwiftUI。这种“逻辑共享、UI 独立”的方案,既享受了跨端的开发效率,又保证了 100% 的原生性能和用户体验,这在很多追求极致体验的 App 里非常受青睐。
View 事件分发中,ACTION_CANCEL 事件在什么情况下会触发?
嗯,关于 ACTION_CANCEL,其实它是事件分发机制中一个非常关键的“兜底”信号。简单来说,它的触发时机就是:当父容器拦截了原本属于子 View 的事件流时,子 View 就会收到一个 CANCEL 事件。
我们可以从 ViewGroup 的 dispatchTouchEvent 源码逻辑来拆解。
最典型的场景就是 RecyclerView 嵌套点击事件。
想象一下,当你的手指按在一个列表项(比如一个 Button)上时,首先触发的是 ACTION_DOWN。这时候 RecyclerView 还没打算拦截,所以 DOWN 事件传给了子 View。子 View 收到 DOWN 后,就把自己标记为当前的触摸目标,也就是赋值给了父容器的 mFirstTouchTarget。
但是,当你的手指开始滑动,达到滑动的阈值(TouchSlop)时,RecyclerView 的 onInterceptTouchEvent 就会返回 true,决定开始拦截。
这时候,源码里会有一段逻辑:ViewGroup 会给 mFirstTouchTarget 指向的那个子 View 发送一个特殊的 MotionEvent,它的 Action 就是 ACTION_CANCEL。
发完这个 CANCEL 后,mFirstTouchTarget 会被置为 null。这意味着从这一刻起,接下来的 MOVE 和 UP 事件都不会再经过这个子 View 了,而是直接由 RecyclerView 自己处理。
为什么要这么设计呢?
其实是为了保证逻辑的完整性。子 View 在收到 DOWN 时可能改变了 UI 状态(比如变成按下高亮状态),如果父容器中途“截胡”,却不通知子 View,那子 View 就会一直卡在按下状态。收到 CANCEL 后,子 View 就会知道:“噢,后面的事不归我管了”,从而重置自己的状态(比如清空点击回调、取消高亮)。
我在扒 InputDispatcher 相关源码时也注意到,如果系统弹窗突然弹出遮挡了应用,或者窗口突然失去焦点,系统底层也会通过 IPC 给应用进程发送 CANCEL 事件,这也是一种非常重要的安全保护机制。
详细说一下 View 的事件分发流程,源码层面是怎么跑的?
关于事件分发,我觉得用**“递归派发”和“责任链模式”**来描述最准确。整个流程可以总结为:从上往下的分发(Dispatch)和从下往上的消费(Consumption)。
逻辑的起点其实在 ViewRootImpl 的 WindowInputEventReceiver 里,但我们通常从 DecorView 开始说起。
-
分发阶段(Top-Down): 事件首先到达
Activity.dispatchTouchEvent,然后交给PhoneWindow,再传给DecorView。 重点在ViewGroup.dispatchTouchEvent。它会做两件事:- 拦截判断:调用
onInterceptTouchEvent。如果返回true,事件就由自己处理,不再往下传。这里有个细节,子 View 可以通过requestDisallowInterceptTouchEvent(true)来干预父容器的拦截逻辑(当然,DOWN事件除外)。 - 寻找目标:如果是
DOWN事件,ViewGroup会遍历子 View,判断触摸坐标是否在子 View 范围内,如果在,就调用子 View 的dispatchTouchEvent。
- 拦截判断:调用
-
消费阶段(Bottom-Up): 如果事件传到了最底层的
View。View.dispatchTouchEvent的逻辑很有意思,它的优先级是:OnTouchListener>onTouchEvent>OnClickListener。 如果onTouchEvent返回了true,表示事件被消费了,分发链条结束。 -
回溯阶段: 如果底层 View 的
onTouchEvent返回了false,事件就会像气泡一样往上传。这时候,父容器的onTouchEvent会被调用。如果所有层级都不消费,最后事件又回到了Activity.onTouchEvent。
我在读 AOSP 源码的时候,注意到 ViewGroup 里有一个变量叫 mFirstTouchTarget。这是个链表结构,一旦子 View 消费了事件,父容器就会记录下这个 Target。后续的 MOVE 事件就可以跳过复杂的遍历过程,直接顺着这个链表发给对应的子 View,这是一种非常高效的性能优化。
聊聊 Android 的 Binder 机制,它是怎么实现进程间通信的?
Binder 是 Android 系统运行的基石,所有的系统服务(AMS, WMS 等)都是建立在它之上的。
为什么要用 Binder 呢? 其实最核心的原因有两个:性能和安全。 相比于传统的 Linux IPC,比如管道或者 Socket,它们需要两次内存拷贝(用户态 -> 内核态 -> 用户态)。而 Binder 只需要 1 次拷贝。 而且 Binder 能够自动获取调用方的 UID/PID,这让系统能非常方便地进行权限校验,安全性极高。
Binder 的底层原理是怎么实现的?
Binder 的核心在于内核里的 Binder 驱动 和 mmap(内存映射)。
当一个 Service 进程启动并初始化 Binder 时,驱动会通过 mmap 在内核空间和该进程的用户空间之间建立一块共享的物理内存映射。
具体流程如下:
- Client 端发送数据:数据从 Client 进程拷贝到内核缓存区(这是唯一的一步拷贝)。
- Server 端接收数据:由于内核空间和 Server 进程的用户空间通过
mmap映射到了同一块物理内存,Server 进程不需要再拷贝数据,直接通过指针就能读取到内核里的这块数据。
Binder 的整体架构是什么样的? 它是一个典型的 C/S 架构,涉及四个角色:
- Binder 驱动:负责最底层的传输和映射。
- Service Manager:就像是一个“通讯录”,负责管理 Service 的注册和查询。
- Server(服务端):提供接口的具体实现。
- Client(客户端):通过 Proxy(代理对象)调用接口。
我们在 Java 层使用的 AIDL,其实就是编译器帮我们自动生成了这些复杂的 Proxy(BpBinder)和 Stub(BBinder)代码。我在研究 Framework 层的时候发现,Binder 线程池的默认上限通常是 15 到 16 个线程,所以我们在设计系统级服务时,一定要注意不能在 Binder 调用里做太重的耗时操作,否则很容易导致 Binder 线程耗尽。
算法题:寻找数组中第 k 个最大的元素(LeetCode 215)
这道题非常经典,面试中经常用来考察对排序和堆的理解。
解法分析:
- 暴力排序:直接
Arrays.sort,然后取第 个。时间复杂度 ,面试官肯定不满意。 - 小顶堆(优先队列):维护一个大小为 的小顶堆。遍历数组,如果元素比堆顶大,就入堆。最后堆顶就是第 大。复杂度 。这是工程上最稳妥的方法,适合处理大数据流。
- 快速选择(Quick Select):这是基于快排的思想。快排每轮
partition都能确定一个元素的最终位置。如果这个位置刚好是 ,那我们就找到了。它的平均时间复杂度是 ,但最坏情况下(比如有序数组)会退化到 。
我觉得快速选择是最能体现算法功底的,所以下面我用这个方法来实现。为了避免退化,我会加入随机化选取枢轴(pivot)的逻辑。
代码实现(Java):
class Solution {
public int findKthLargest(int[] nums, int k) {
int n = nums.length;
// 目标索引:第 k 大即升序排列后的第 n - k 个
int target = n - k;
return quickSelect(nums, 0, n - 1, target);
}
private int quickSelect(int[] nums, int left, int right, int target) {
if (left == right) return nums[left];
// 随机选择枢轴,优化最坏情况
int pivotIndex = left + new java.util.Random().nextInt(right - left + 1);
pivotIndex = partition(nums, left, right, pivotIndex);
if (pivotIndex == target) {
return nums[pivotIndex];
} else if (pivotIndex < target) {
return quickSelect(nums, pivotIndex + 1, right, target);
} else {
return quickSelect(nums, left, pivotIndex - 1, target);
}
}
private int partition(int[] nums, int left, int right, int pivotIndex) {
int pivotValue = nums[pivotIndex];
// 1. 把枢轴放到最后
swap(nums, pivotIndex, right);
int storeIndex = left;
// 2. 将小于枢轴的元素移到左边
for (int i = left; i < right; i++) {
if (nums[i] < pivotValue) {
swap(nums, storeIndex, i);
storeIndex++;
}
}
// 3. 把枢轴换回它的最终位置
swap(nums, storeIndex, right);
return storeIndex;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}这种实现的关键点在于 partition 的逻辑。其实这就是快排的核心。通过这种方式,我们每次能排除掉大约一半的搜索范围。由于每一层处理的数据量分别是 ,根据等比数列求和,最终的收敛时间就是 。这在面试中是非常加分的回答。
字节跳动 九面 HR面
简单聊聊你为什么选择直接就业而不是考研?
嗯,关于这个问题,其实我当时也是认真思考过的。身边的同学很多都选择了考研,但我最终决定直接就业,主要是基于我对软件工程这门学科的理解,以及我个人的职业规划。
首先,我觉得软件工程,尤其是 Android 开发这一块,它是非常看重工程实践和技术落地的。在本科期间,我花了大量时间去钻研 AOSP 源码,编译系统,甚至去写技术博客。在这个过程中我发现,我最快乐的时候并不是在实验室推导理论公式,而是当我写的一段优化代码能在真实的设备上跑通,或者当我解决了一个困扰很久的 Framework 层 Bug 的时候。那种即时的反馈感和解决实际问题的成就感,是我更向往的。
其次,现在的移动端技术迭代非常快。像字节、腾讯这样的大厂,它们面对的是亿级 DAU 的复杂场景。这种规模的并发、性能优化和稳定性保障,是在学校实验室里很难模拟出来的。我觉得与其花三年时间去拿一个学位,不如早点跳进真正的“战场”里,在实际的大项目、大业务中去磨炼。
最后一点,其实是我对自己比较自信。我在本科阶段已经建立了一套比较完整的知识体系,从应用层的 Jetpack、MVVM 到 Framework 层的 AMS、WMS 都有深入学习。我觉得我已经做好了进入工业界的准备,我想去看看最顶尖的工程师是怎么写代码的,去解决那些真正影响几亿用户的问题。所以,对我来说,在实践中成长是性价比最高的一条路。
你的这个开源项目为什么做了一年多?它的动机和背景是什么?
嗯,关于这个项目,其实它算是我大学期间的一个“心血之作”。虽然时间跨度有一年多,但并不是说我每天都在敲它的代码,它更像是一个伴随我技术成长、不断迭代的实验场。
先说背景和动机。当初做这个项目的初衷其实挺简单的:我发现市面上很多开源的 Android 学习项目要么太简单,只停留在调用 API 层面;要么太陈旧,还在用旧的 MVC 架构。我当时刚开始深入学习 Framework,又正好在研究 Jetpack Compose 和协程,所以我就想:能不能自己从零开始,用最前沿的技术栈,复刻一个工业级标准的 App? 我不仅想实现功能,我还想把我在 AOSP 源码里学到的那些原理,比如 Handler 机制、View 绘制流程,全都应用进去。
至于为什么做了一年,主要有这么几个原因:
第一,它是我的业余项目。我需要平衡校内的课程学习和后来的实习工作。我通常是利用周末或者晚上的时间来推进。
第二,我在项目中做了很多推倒重来的事情。比如最开始我是用 XML 写的 UI,后来 Compose 出了稳定版,我觉得这是未来的趋势,我就把整个 UI 层全部用声明式重构了一遍。再比如,原本只是简单的网络请求,后来为了深入理解 OkHttp,我又给它加上了自定义的拦截器和缓存策略。
第三,也是最重要的一点,我把这个项目当成了深度钻研的抓手。每当我学到一个新的 Framework 知识点,比如 Input 系统的分发,我就会回过头来想,我这个项目里有没有可能遇到相关的性能问题?我能不能加一个监控组件去定位它?
所以,这一年多其实是一个学习 -> 实践 -> 发现问题 -> 再学习的循环。这个过程虽然慢,但它让我对 Android 系统有了非常透彻的理解,也积累了很多处理复杂 Bug 的经验。我觉得慢一点没关系,关键是每一行代码我都知道它为什么这么写,以及它底层的运行逻辑是什么。
聊聊你的实习经历。挑一个你做过的工作,讲讲背景和具体做了什么?
好,那我就挑我在上一家公司实习期间,负责的一个关于 App 启动速度优化的任务来讲吧。
背景是这样的:当时我们那款 App 随着业务功能的堆砌,冷启动时间越来越长,已经影响到了用户的转化率。Leader 发现线上监控数据显示,在某些低端机型上,冷启动甚至要 3 秒以上。我当时刚进组不久,因为平时比较喜欢钻研 Framework 层,对进程启动和 Activity 启动流程比较熟,就主动请缨去接了这个任务。
我具体做了以下几件事情:
首先是排查问题。我没有盲目地改代码,而是先用了 System Trace 和 Profiler 做了精细的性能打点。我发现启动耗时主要卡在两个地方:一个是 Application.onCreate 里初始化的三方 SDK 太多,而且全都是在主线程同步初始化的;另一个是首页的布局太复杂,嵌套层级多,导致初次 measure/layout 耗时过长。
接着是实施方案。针对 SDK 初始化,我开发了一个异步启动框架。我利用有向无环图(DAG)的思想,把几十个 SDK 的依赖关系理清楚。那些不需要立即初始化的,我让它们延迟加载;那些可以并发的,我把它们丢到子线程去跑。针对 UI 耗时,我用 ViewStub 做了懒加载,并把复杂的 ConstraintLayout 进行了扁平化处理。
在这个过程中,我还遇到了一个棘手的问题:有个统计 SDK 必须在主线程初始化,但它又很慢。我最后是通过读了它的源码,发现它其实只在某个特定业务触发时才需要完整功能,于是我就用动态代理做了一个懒加载,等真正用到它的时候再触发初始化。
最后的结果是,我们在中端机上的冷启动时间从 2.8s 降低到了 1.6s 左右,优化了将近 40%。这次经历对我最大的帮助是,让我真正体会到了如何从全局视角去审视一个 App 的性能,以及如何通过对底层的理解来解决业务层面的痛点。
那些技术优化是你自己提出来的,还是 Leader 要求的?你觉得自己做完这些需求后,成长体现在哪里?
嗯,其实这里面有一部分是 Leader 给的方向,但很大一部分具体的实施方案和深度优化点,是我在执行过程中自发提出来的。
比如刚才提到的异步启动框架,Leader 最初只是说“你看看能不能把启动搞快点”。但在调研过程中,我发现简单的 new Thread 根本没法解决复杂的 SDK 依赖问题,还会导致有的 SDK 在没初始化完就被调用。于是我就主动向 Leader 提议:我们能不能做一个基于任务优先级的调度组件?在得到允许后,我才独立去完成了那套框架的设计。
我觉得这种自驱力在大厂里其实挺重要的。你不能只是当一个“翻译官”,把需求翻译成代码,你得去思考这个方案是不是最优的,有没有可能从底层原理层面做得更好。
说到成长和进步,我觉得主要体现在两个方面:
第一是从“知其然”到了“知其所以然”。以前看 Framework 源码可能只是为了面试,但在做这些优化的过程中,我必须去抠每一个细节。比如为了优化布局,我得研究 SurfaceFlinger 是怎么合成的,VSync 是怎么分发的。这种实战出来的知识,比死记硬背要牢固得多。
第二是工程思维的提升。以前写项目比较随性,但在公司里,我学会了如何考虑线上稳定性。每一段优化代码上线前,我都会想:如果子线程初始化失败了怎么办?会不会导致崩盘?我开始习惯于写单元测试,习惯于做全量后的数据回捞。
包括我后来独立开发的那套 SDK 提效工具,其实也是因为我发现组内大家在联调时效率很低,经常为了一个参数反复沟通。我就想能不能写个脚本或者插件自动生成 Mock 数据。这个建议确实是我自己想出来的,后来 leader 看到效果不错,还专门让我在组内做了一次分享。这种从解决自己的痛苦到解决团队的痛苦的转变,我觉得是我最大的进步。
除了技术问题,你在实习或者联调过程中还遇到过哪些困难?是怎么解决的?
嗯,其实在真实的工作场景中,技术问题往往只占一半,另一半其实是沟通和协调,也就是所谓的“推事情”。
我印象最深的一次困难是跨团队联调。当时我负责的功能需要后端接口的支持,但后端同学那边正好在赶双十一的活动,他们的排期非常满,告诉我说接口要到两周后才能给。但如果按这个进度,我的功能肯定会延期,导致整个版本发布计划被打乱。
当时我并没有就此放弃去等。我做了三件事:
- 主动对齐协议:我先主动写了一份详细的接口定义草案(包括字段名、类型、异常码),直接发给后端同学确认。我说:“哥,你先别写代码,帮我看看这个格式行不行”。
- 先行 Mock 联调:在协议达成一致后,我利用我之前写的提效工具,在客户端侧全量 Mock 了这套接口。这样我就可以在没有后端支持的情况下,把所有的 UI 逻辑和状态流转全部写完并测试通过。
- 积极跟进与同步:我每天会同步一下我的进度给 Leader,同时也侧面了解后端那边的压力。最后在他们项目稍微松动的时候,由于我这边的协议已经定死了,他们只需要把数据填进去就行。
最后,我们不仅没有延期,还提前完成了联调。
这次经历让我明白,在职场里,“阻塞”并不可怕,可怕的是你坐在那里等它自动解开。很多时候,你需要站在对方的角度想,怎么做能让对方配合你的成本降到最低。这种主动沟通、提前对齐、并寻找 Plan B 的心态,我觉得比单纯写好代码要重要得多。尤其是在像字节、腾讯这样协同极其复杂的公司,这种意识是非常必要的。
一开始不使用联调的原因是什么?是因为怕打乱工作节奏吗?
嗯,其实关于“不一开始就联调”,我当时的考虑主要是为了解耦开发进度和提高并行效率,而不仅仅是怕打乱节奏。
在实际的大厂工程环境下,前端(Android)和后端的进度往往是不完全同步的。如果我非要等到后端的接口完全写好、部署到测试环境了再开始写逻辑,那我的工作就会产生大量的空转时间。
所以我当时选择先通过 Mock 数据 的方式进行开发,主要有这么几个考量:
首先,是定义的确定性。在联调之前,我会先跟后端同学把 Protobuf 或者 JSON 的协议定死。只要协议定了,我就能根据这个结构模拟出各种边界情况。比如:如果返回的列表是空的怎么办?如果某个字段溢出了怎么办?如果网络超时了怎么办?这些极端情况在真实联调时很难复现,但在 Mock 阶段我可以非常轻松地覆盖到,这反而让我的代码鲁棒性更强。
其次,确实是为了保护“深度工作”的状态。真实的联调往往伴随着大量的沟通,“你改个字段”、“我重启下服务”,这种琐碎的打断确实会破坏写复杂逻辑时的思路。我习惯于先把内部的业务逻辑、状态流转(像 ViewState 的处理)全部写顺,确保我这边的代码“自洽”了,再去对接外部系统。
最后,这种做法能让最后的联调变得极其高效。当我真正接入真实接口时,往往只需要改一下 BaseUrl,然后处理一下鉴权,整个流程就通了。这时候如果报错,我能非常自信地判断是后端的问题还是网络的问题,而不是在自己的逻辑里瞎找。所以,这其实是一种**“先分后合”**的策略,能让整个交付周期变得更稳。
你觉得要做好一个程序员,除了技术扎实,还需要具备什么样的素质和能力?
嗯,这个问题我经常会反思。作为一名软件工程专业的学生,我观察过很多优秀的学长和 Leader,我觉得除了技术这一项硬指标,**“工程思维”、“抗压能力”和“好奇心”**是区分平庸与卓越的关键。
第一点是工程思维(或者说 Ownership)。技术只是工具,我们的最终目标是交付业务价值。一个好的程序员不仅要代码写得漂亮,还得理解**“为什么要做这个功能”**。比如在做性能优化时,如果只是为了优化而优化,不看对业务指标(比如留存、转化)的影响,那其实是自嗨。具备 Ownership 的人会主动站在产品和用户的角度思考:这个方案上线后,低端机用户会不会卡顿?这个逻辑有没有可能导致线上 Crash?
第二点是极强的自驱动力和好奇心。Android 领域的技术更新太快了,从 View 到 Compose,从 Java 到 Kotlin。如果只是被动地接收任务,很快就会遇到瓶颈。我平时会去扒 AOSP 源码,并不是因为工作需要,而是我真的想知道 SurfaceFlinger 到底是怎么把那一帧画面推送到屏幕上的。这种对底层原理的渴望,能让你在遇到那种“见鬼的 Bug”时,比别人更有底气去定位根因。
第三点是解决复杂问题的韧性。大厂的项目往往有大量的历史债务,或者是非常隐蔽的内存泄漏。有时候你花了一周时间都在看日志、做实验,最后可能只改了一行代码。这时候能不能坐得住冷板凳,能不能在反复失败中保持冷静,其实是非常考验一个人的素质的。
在沟通上你有什么心得吗?如何通过沟通来提高工作效率?
嗯,在实习期间我发现,程序员最怕的就是“无效沟通”。为了提高效率,我给自己定了几条准则,我总结为:结论先行、背景对齐、提供选项。
首先,结论先行。大厂大家都很忙,无论是发消息还是开会,我都会先把结果说出来。比如:“我们要推迟一天联调,因为遇到了一个 Binder 通信的偶发 Bug。”然后再去展开细节。这样对方能立刻评估这个变动对他影响有多大。
其次,是背景对齐(Context Over Control)。很多沟通矛盾是因为大家的信息不对等。当我要请别人帮忙或者协调资源时,我会先说:“为了解决 XX 业务在低端机上的卡顿,我需要调用你那个模块的 XX 接口。”让对方明白这件事情的优先级和价值,沟通起来阻力会小很多。
还有一点很关键,就是不要只带问题,要带方案。我去请教 Mentor 或者找后端对齐时,我不会只问“这怎么弄?”,我会说:“针对这个需求,我想了 A 和 B 两个方案。方案 A 性能好但改动大,方案 B 兼容性好但稍微冗余,我建议选 A,您看行吗?”这种选择题式的沟通,效率远比开放式问答要高。
最后,我会善用文档化工具。复杂的逻辑我一定会先写个简单的 design doc 或者流程图,发给对方看一眼。因为文字和图表往往比口头表达更不容易产生歧义。
在团队中,你遇到过矛盾和分歧吗?你是怎么解决的?
分歧其实是经常有的,尤其是在技术方案的设计上。
我记得有一次,我和另一位实习生在关于“某个模块是否要组件化重构”上产生了不同意见。他觉得那个模块现在代码太乱了,应该立刻拆分;我觉得版本马上要发布了,这时候动架构风险太大,而且目前业务变动并不频繁,重构的收益不明显。
我们当时的解决办法是:不争论对错,只讨论数据和场景。
我没有直接否定他的想法,而是拉着他一起列了一个对比表。我们分析了:
- 重构的成本:需要涉及多少个类,大概要多少人时。
- 潜在风险:有多少个核心流程会受影响,回归测试能不能覆盖。
- 长期收益:未来半年内,这个模块会有多少新需求。
通过客观分析,我们发现未来几个月这个模块确实相对稳定,重构的迫切性没那么高。最后我们达成一致:先做小修小补,把逻辑理顺,把重构计划放到下个大版本的迭代里去。
我觉得解决分歧的核心是:把“人对人”的争论,转化成“人对事”的分析。大家都是为了项目好,只要把数据和风险摆在桌面上,其实很容易达成共识。
在团队工作中,有哪些因素会影响你的产出或者让你感到困扰?
嗯,讲真,最影响产出的其实是频繁的上下文切换(Context Switching)。
作为 Android 开发,写代码、调性能或者是扒源码,都是需要进入**“深度工作状态”**的。如果这时候突然有个 IM 消息弹出来问一个琐碎的小事,或者临时拉个没准备好的会议,那种思路被切断的感觉真的很伤。
为了应对这个,我也会调整自己的工作习惯。比如我会把琐碎的邮件处理、报销等杂事放在下午快下班的时间段集中处理。在需要攻克难题的时候,我会戴上耳机,把 IM 挂成“忙碌”,尽量保证两三个小时的完整专注时间。
另外一个阻碍就是文档的缺失或者陈旧。有时候去接手一个前人留下的旧模块,没有任何注释和设计文档,只能靠硬啃代码。这种“考古”的过程非常消耗精力。所以我在工作中会强制要求自己写好 README 和关键逻辑的注释,因为我知道,如果不写,下一个被阻碍的人可能就是未来的我自己。
学习一个新的 UI 框架(比如 Compose),你大概花了多久学清楚?
关于 Jetpack Compose,我从开始接触到能独立承担项目开发,大概花了两周左右的时间。但如果要说“深入理解它的底层原理”,那可能花了一个月甚至更久。
第一周我主要是在思维转型。因为从命令式(XML)到声明式的转变是有点痛苦的,我得适应“状态驱动”的概念。我读了官方的大量文档,把 remember、SideEffect、derivedStateOf 这些核心概念理清楚,并做了一些 Demo 列表和动画。
第二周我开始深入性能调优。这时候我开始关注“为什么会发生非预期的重组(Recomposition)”,于是我去研究了 Compose 编译器的插件是怎么工作的,什么是 Stability(稳定性),怎么用 @Stable 和 @Immutable 去优化重组。
再往后,因为我有 Framework 的基础,我就比较好奇它到底是怎么画到屏幕上的。我花时间翻了 LayoutNode、MeasurePolicy 的源码,看它怎么绕过传统的 View.draw() 逻辑。
我觉得学新东西,上手快是基本素质,但钻得深才是核心竞争力。现在如果项目里遇到 Compose 性能问题,我能很快通过 Profiler 找到哪块布局在频繁重组,并从底层原理层面给出优化方案。
你的 Mentor 对你的评价是什么?
我的 Mentor 评价我时,提到了两个词:“靠谱”和“有钻劲”。
**“靠谱”**体现在交付物上。他给我的每一个 Task,无论大小,我都会给出一个清晰的反馈。比如我修完一个 Bug,不仅会把代码提交,还会在 CR(Code Review)里详细说明我定位问题的过程、复现路径以及我为什么这么修。他觉得带我非常省心,因为他不需要反复催进度。
“有钻劲”是指我不满足于只把活干完。比如有一次我负责一个简单的 UI 需求,我发现底层的某个系统方法在特定机型上有非常微小的内存抖动。我没有忽略它,而是自己去查了 AOSP 的 Issue 追踪器,最后给出了一个针对性的绕过方案。Mentor 当时觉得我作为一个实习生,能够主动发现并深挖这种系统级的隐患,确实超出了他的预期。
当然,他也建议过我要多关注业务全貌,不要只埋头写代码。现在我也在有意识地提升这方面的能力。
组内有几个实习生?他们都转正通过了吗?
我们组当时一共有三个实习生。据我所知,大家表现都非常优秀。
目前的情况是,由于业务缩减和 HC(招聘名额)的动态调整,转正的情况确实比较紧张。有些同学虽然技术面表现很好,但可能受限于部门的名额限制,正在尝试内部转岗或者看看外面的机会。
我自己其实已经拿到了转正的意向,但由于我个人对 Framework 层和底层性能优化 有更强烈的技术兴趣,而我目前所在的组更多偏向业务逻辑开发,所以我还是想出来看看,能不能在字节或者腾讯这样有深厚底层技术积淀的团队里,找到一个更匹配我技术栈的岗位。我觉得这种选择也是对我自己职业生涯的一种负责。
和其他实习生相比,你的优势在哪里?
嗯,我觉得如果把大家放在一起横向对比,我的优势主要体现在两个词:“深度”和“先发”。
首先是先发优势。我接触 Android 开发比较早,大一就开始写 App,大二大三就开始扎进 Framework 源码里。相比于很多到了大三或者研二才突击面试题的同学,我对 Android 系统的理解是有一条时间线上的积淀的。我见证了它从传统的 XML 到 Compose,从 Java 到 Kotlin 的演进。这种长期的实操经验,让我对很多技术选型的来龙去脉(Why and How)抓得更稳。
其次,是我对 Android 底层原理的垂直深度。 大多数应用层开发的实习生可能更多关注 Jetpack、MVVM 这种业务架构,或者 UI 怎么画得好看。但我比较“头铁”,我花了很多时间去啃 AMS、WMS、Input 系统,甚至是 SurfaceFlinger。 比如在处理一个滑动卡顿问题时,别人可能在看布局嵌套,而我会习惯性地去想 VSync 信号的传递有没有被阻塞,或者 Graphic Buffer 的生产消费是否平衡。这种自下而上的视角,让我能解决一些别人看都看不出来的隐蔽 Bug。
最后,我觉得我有一套成体系的学习方法。我会通过写技术博客、参与开源项目来强迫自己输出。这意味着我不仅能把活干好,我还能把复杂的技术点讲清楚,这对团队内部的技术沉淀和协作其实非常有价值。
你有想过未来规划怎么样长期保持自己的优势吗?
其实我一直有一种危机感,因为移动端技术更新真的太快了。为了保持长期的竞争力,我有三个原则:保持好奇心、深入底层、以及拥抱社区。
第一,是对新技术的“嗅觉”。 我会持续关注 Android 官方的 Release Note 和 AOSP 的代码提交记录。比如当 Compose 刚出的时候,我就意识到声明式 UI 是大势所趋,提前做了储备。保持这种敏锐度,能让我不被技术浪潮淘汰。
第二,是深挖底层“护城河”。 业务框架会变,但操作系统的核心原理(内存管理、进程通信、渲染机制)在很长一段时间内是相对稳定的。我会继续深入钻研 Linux 内核相关的知识,因为这是所有移动端开发的根基。只要你掌握了最底层的东西,换个平台(比如鸿蒙或者跨端方案)你也能很快上手。
第三,是持续的输出和交流。 我计划继续维护我的技术博客,并尝试给 AOSP 或者知名开源库贡献代码。在社区里和顶尖的开发者交流,能让我跳出自己的小圈子,看到更广阔的技术视野。
我觉得自驱力是程序员唯一的保值方案。我给自己定了一个目标:每年都要攻克一个自己完全不熟悉的领域,比如今年是音视频处理,明年可能是虚拟化技术。只有不断跳出舒适区,优势才不会变成劣势。
跨端方面你觉得哪家公司做得比较好?你们组的方案和他们比有什么差距?
就我个人的观察和调研,我觉得腾讯视频在跨端技术上的探索是非常深入且成熟的。
腾讯视频的 App 非常复杂,它涉及到海量的瀑布流、极其复杂的播放器逻辑和丰富的 UI 交互。据我了解,他们大量使用了 Hippy(腾讯自研的跨端框架)或者对 Flutter 做了深度定制。他们不仅是用了这些框架,还深入到了引擎层做了很多优化。比如他们能把跨端页面的秒开率做到和原生几乎一致,甚至在某些低端机上的表现优于纯原生,这背后的渲染链路优化和内存管理做得非常扎实。
相比之下,我实习期间所在的组,我们的跨端方案更多是基于业务驱动的平衡。 我们的差距主要体现在性能天花板和基础设施上:
- 性能细腻程度:腾讯视频那种级别的 App,会对每一毫秒的渲染耗时、每一个 Byte 的内存占用进行压榨。而我们组的方案可能在快速迭代上更有优势,但在极端性能场景(比如超长列表的快速滑动)下,原生的流畅度还是会有微小的折扣。
- 基建沉淀:大厂如腾讯有专门的工程团队去维护引擎层,甚至能改底层的 C++ 代码。而我们更多是在应用层去做适配和封装,对于黑盒内部的掌控力没那么强。
但我觉得这更多是业务场景选择的结果。对于我们当时的业务来说,快速验证想法、双端逻辑统一比极致的渲染性能更重要。而对于视频类 App,性能就是生命线。这种对比也让我意识到,没有最好的架构,只有最合适的选择。
对字节校招面试的感受是怎么样的?从其他部门流转到抖音搜索,感受有什么不同?
字节的面试给我的感觉就是两个词:硬核、专业。
首先,面试官非常看重基础和逻辑。不管是计算机网络、操作系统还是算法,问得都非常有深度,不是那种背背题就能过的。而且字节的面试官特别喜欢追问底层。比如你说你懂 Handler,他会从 Java 层问到 Native 层,再问到 Linux 的 epoll,直到触及你的知识边界。这种“打破砂锅问到底”的风格我很喜欢,因为这能真正体现一个人的技术底蕴。
关于从之前的部门流转到抖音搜索,我的感受是挑战更大了,但视野也更广了。 之前的面试可能更偏向于特定的业务场景或者通用的 Android 知识。而抖音搜索的面试明显感觉到对大规模性能优化和稳定性的要求极高。 搜索业务是一个 App 的核心入口,它的 QPS(每秒请求数)非常高,UI 交互也极其复杂(视频、图文、直播各种混排)。面试官会考察我在面对这种极端复杂的页面时,如何通过 Framework 层的理解去解决内存抖动或者掉帧问题。这种面试更像是一次高水平的技术探讨,虽然压力大,但收获也很多。
有反思过该部门(抖音搜索)二面没通过的原因吗?会有失落感吗?
嗯,我确实深刻反思过二面没过的原因。
我觉得核心问题在于:我对极端复杂场景下的稳定性把控,在那个当下没能给出最完美的答卷。 二面面试官当时问了一个关于“搜索结果页在复杂嵌套下的滑动冲突和渲染瓶颈”的问题。虽然我从 WMS 和 View 绘制流程给出了方案,但在面对这种海量用户机型适配的极端细节上,我的思考可能还不够周全,没能体现出在大规模生产环境下的工程严谨性。简单来说,就是我的技术深度够了,但大厂大规模业务的实战思考还有一点火候欠缺。
失落感肯定是有的,毕竟我一直把字节当成我的 Dream Company,也为此准备了很久。那种感觉就像是考试觉得自己发挥得不错,但最后发现有个大题答偏了。
但我怎么排解这种心情呢? 我是一个比较理性的人。我把面试看成是**“职业生涯的一次 Debug”**。既然挂了,说明我这段代码(我的知识体系)里还有 Bug。失落之后,我立刻去查阅了相关的技术方案,把面试官问到的那个点彻底吃透。我觉得,只要我从这次失败中变强了,那这次面试就没白费。 比起一时的得失,我更看重长期的成长。而且字节的流程非常公平,这次不行,复盘后再战就是了,我也不会因为一次挫折就否定自己的能力。
现在手头有哪些 Offer?看你也投了非互联网公司,对发展方向有倾向性吗?
目前手头确实拿到了两三个 Offer。一个是之前实习公司的转正意向,另外还有两家准一线大厂的口头 Offer。
关于非互联网公司(比如一些硬科技企业或者金融科技): 确实有尝试过,但我内心的第一优先级始终是像字节、腾讯这样的头部互联网公司。
我的想法是这样的: 非互联网公司的节奏可能相对平稳,技术栈也比较固定。但我现在还年轻,我是软件工程出身的,我渴望的是技术挑战和业务规模。 在字节或者腾讯,我能接触到亿级用户的真实场景,这种场景下产生的性能瓶颈、 Crash 治理、以及底层 Framework 的定制,是其他行业很难提供的。我觉得在大厂这几年的成长速度,可能抵得上在其他地方五年的积累。
所以,如果有的选,我一定会选择离核心业务、离底层技术最近的地方。因为我目前的职业目标是成为一名 Android 专家,而这个目标只有在互联网这种高强度、高并发的环境下才能最快实现。我会把其他 Offer 当作兜底,但我的梦想和努力方向一直是非常明确的。
字节跳动 十面
有了解鸿蒙如何与 Kotlin 通信吗?
嗯,关于鸿蒙(HarmonyOS)与 Kotlin 的通信,这其实要分两种情况来看。
首先是传统的 HarmonyOS(兼容 Android 阶段)。那时候鸿蒙本质上还是基于 AOSP 演进的,所以它和 Kotlin 的通信其实就是标准的 Java/Kotlin 互操作。在同一个 Ability 或者 Service 里面,你可以像在 Android 里一样直接调用 Kotlin 的方法,底层走的还是 JVM(或者说 ART)那一套。
但现在大家更关注的是 HarmonyOS Next(纯血鸿蒙)。Next 版本彻底抛弃了 Android 框架,主推的是 ArkTS 语言和 ArkUI 框架。这时候,Kotlin 想要和鸿蒙通信,其实就变成了一个**跨语言调用(Cross-language Invocation)**的问题。
目前主流的方案主要有两种:
第一种是通过 C++(NAPI)作为桥梁。 鸿蒙提供了一套 Native API(NAPI),这套接口是基于 Node.js 的插件机制设计的。Kotlin 代码可以通过 JNI 调用到 C++ 层,然后 C++ 层再通过 NAPI 把数据传给 ArkTS 层。反过来也是一样的。这种方式性能最高,适合做一些复杂的底层库对接,但开发成本也比较大,需要处理垃圾回收(GC)在两套 runtime 之间的同步问题。
第二种是利用跨平台框架,比如 Compose Multiplatform。 最近 JetBrains 和一些开源社区在推 Compose 往鸿蒙上切。它的原理是利用 Kotlin/Native 把 Kotlin 代码编译成鸿蒙能识别的库,或者通过特定的桥接层来操作鸿蒙的渲染表面。
其实在实际的大厂项目中,如果要把现有的 Android 业务迁移到鸿蒙,通常会采用逻辑层共享、UI 层重写的策略。我们会把核心的 Kotlin 业务逻辑抽成 C++ 库或者通过专门的动态代理层,与 ArkTS 侧进行通信。这块我也一直在关注鸿蒙底层的 Ability 调度机制,看它和 Android 的 Intent 通信到底有多大的底层差异。
Compose 对比常规 View 的优势是什么?
说到 Compose 对比传统 View 系统的优势,我觉得可以用“降维打击”来形容。虽然 View 系统非常成熟,但它背负了十几年的历史包袱,而 Compose 是完全面向现代开发的。
我认为优势主要体现在这三点:
1. 从“命令式”到“声明式”的转变
在传统的 View 系统里,我们要更新 UI,必须手动调用 setText() 或 setVisibility()。这最容易导致“状态不一致”的问题,比如数据变了但 UI 忘了刷。
而在 Compose 里,UI 是状态的函数,也就是 。我只需要定义好状态,当状态改变时,Compose 会自动计算出差异并局部刷新。这不仅让代码量减少了大概 40% 到 50%,更重要的是它消灭了大量由于状态同步导致的 Bug。
2. 解决 View 的“巨类”问题和继承灾难
我们知道,android.view.View 这个类极其臃肿,有几万行代码,哪怕是一个简单的 TextView 也继承了一堆它不需要的功能。
Compose 使用的是组合(Composition)优于继承的设计。每一个 Composable 函数都是轻量级的,它不持有复杂的内部状态。这种松耦合的架构让自定义 View 变得异常简单。以前在 View 里写个自定义布局要重写 onMeasure、onLayout,逻辑很碎;在 Compose 里,一个 Layout 函数配合简单的 lambda 就能搞定。
3. 开发体验和工具链的完全集成 Compose 是纯 Kotlin 开发的。这意味着我们可以直接使用 Kotlin 的所有高级特性,比如协程(Coroutine)、函数式编程。 而且,Compose 彻底解决了布局嵌套导致的性能问题。传统的 View 系统如果嵌套过深,多次测量(Measure)可能会导致掉帧;而 Compose 在底层协议上保证了单次测量策略(Intrinsic measurements),这从物理上规避了布局性能的坑。
谈谈 Compose 局部重组的原理和“标脏”机制
重组(Recomposition)是 Compose 的核心。简单来说,局部重组就是只运行那些依赖了已更改状态的 Composable 函数,而跳过那些没变的部分。
它的底层原理其实涉及到两个关键类:Composer 和 Slot Table(槽位表)。
当我阅读 Compose 源码时,我发现编译器会给每个 Composable 函数注入一个 Composer 参数。
- Slot Table 存储状态:Compose 维护了一个类似“线性的数据结构”,叫 Slot Table。它存储了界面树中所有 Composable 的位置信息、参数和
remember的状态。 - 标脏(Dirty Marking)与 Snapshot 机制:这是最精妙的地方。Compose 使用了一套类似 MVCC(多版本并发控制)的 Snapshot(快照)系统。当我们定义一个
mutableStateOf时,这个状态对象会被快照系统追踪。 - 追踪读取记录:当一个 Composable 函数在执行过程中读取了某个
State的值,快照系统会记录下:“这个函数(实际上是一个RecomposeScope)依赖了这个状态”。 - 触发重组:一旦这个
State的值发生了改变,快照系统会立即通知对应的RecomposeScope。这个 Scope 就会被“标脏”。
在下一帧渲染时,Composer 会扫描这些被标脏的 Scope。
它会对比传入函数的参数。如果参数是 Stable(稳定) 的(比如基本类型或加了 @Stable 注解的类),且值没变,它就直接跳过(Skip)。
如果参数变了,或者函数内部读取的状态变了,它就重新执行这个函数,并更新 Slot Table 里的数据。
这就是为什么我们常说 Compose 的重组是“精准打击”。了解了这一点,在优化性能时,我们就知道要尽量使用 Stable 类型,并减少不必要的 State 读操作 频率,比如把高频读取挪到 lambda 里去,从而减小重组范围。
对最近火的 AI 编程工具有了解吗?比如 Claude 这些?
嗯,我平时在写代码,尤其是阅读 AOSP 这种宏大源码的时候,AI 工具确实帮了大忙。目前我主要在用 Claude 3.5 Sonnet 和 Cursor。
我觉得 Claude 3.5 目前在编程逻辑和架构理解上,确实比 GPT-4 要强一些。比如我遇到一些复杂的 Framework 层 Bug,或者需要快速理解 SurfaceFlinger 里的图层合成逻辑时,我会把相关的 C++ 核心片段丢给它。它能非常敏锐地指出内存管理的隐患,或者解释清那些晦涩的宏定义。
而 Cursor 的强大在于它对**整个工程上下文(Context)**的理解。它不仅仅是一个对话框,它能索引我的整个 Android 项目。比如我想重构一个 MVVM 的模块,它能基于我现有的 Repository 基类和 DataStore 封装,自动生成符合我项目规范的代码。
不过,作为软件工程的学生,我也发现不能盲从 AI。 AI 经常会在一些细节 API 上“一本正经地胡说八道”,或者给出一些不符合 Android 生命周期安全的方案(比如在非主线程更新 UI)。所以我现在更多是把它定位成一个 “极其博学但偶尔会犯错的助手”。我会用它来写单元测试、生成 Boilerplate 代码,或者解释复杂的算法逻辑,但最终的架构决策和核心 Bug 调试,还是得靠我们对底层原理的掌握。
了解 AI 编程工具背后的原理吗?
其实 AI 编程工具,比如 Github Copilot 或 Cursor,它们的核心底层都是基于 Transformer 架构的大语言模型(LLM)。
简单来说,它的原理可以分为三个层面:
1. 预训练与微调(Training & Fine-tuning) 这些模型在训练阶段就“读过”海量的开源代码(比如 GitHub 上的代码库)。通过 Next Token Prediction(预测下一个词) 的任务,模型掌握了各种编程语言的语法、库的用法以及常见的模式。针对编程工具,厂商通常会进行 SFT(监督微调),让它更擅长生成代码而非废话。
2. 嵌入与检索增强生成(RAG) 这是 Cursor 这种工具能理解你整个项目的关键。它会把你的本地代码库进行向量化(Embedding)。当你提问时,它会先在你的代码库里检索出最相关的几个类或函数片段,把它们作为“背景知识”塞给模型。这就是为什么它能知道你项目里自定义的基类怎么用。
3. 窗口上下文管理(Context Window) 模型处理信息时有一个“视口”限制。AI 编程工具通过注意力机制(Attention Mechanism),在海量的上下文里筛选出最重要的信息。比如它在帮你写 Activity 的时候,会优先关注你的布局文件和相关的 ViewModel。
4. 静态分析增强 更高级的 AI 工具还会结合传统的编译器静态分析。它不只是靠概率猜代码,还会跑一下类似于 Lint 的检查,甚至在后台尝试编译,看看生成的代码有没有明显的语法错误。
我觉得理解这些原理对我们很有帮助。比如我知道它依赖上下文,那么我在提问时,如果能主动把相关的接口定义(Interface)给它看,它生成的代码准确率就会高得多。这本质上也是一种提示词工程(Prompt Engineering)。
字节跳动 12面
有了解鸿蒙如何与 Kotlin 通信吗?
嗯,关于鸿蒙(HarmonyOS)与 Kotlin 的通信,这其实要分两种情况来看。
首先是传统的 HarmonyOS(兼容 Android 阶段)。那时候鸿蒙本质上还是基于 AOSP 演进的,所以它和 Kotlin 的通信其实就是标准的 Java/Kotlin 互操作。在同一个 Ability 或者 Service 里面,你可以像在 Android 里一样直接调用 Kotlin 的方法,底层走的还是 JVM(或者说 ART)那一套。
但现在大家更关注的是 HarmonyOS Next(纯血鸿蒙)。Next 版本彻底抛弃了 Android 框架,主推的是 ArkTS 语言和 ArkUI 框架。这时候,Kotlin 想要和鸿蒙通信,其实就变成了一个**跨语言调用(Cross-language Invocation)**的问题。
目前主流的方案主要有两种:
第一种是通过 C++(NAPI)作为桥梁。 鸿蒙提供了一套 Native API(NAPI),这套接口是基于 Node.js 的插件机制设计的。Kotlin 代码可以通过 JNI 调用到 C++ 层,然后 C++ 层再通过 NAPI 把数据传给 ArkTS 层。反过来也是一样的。这种方式性能最高,适合做一些复杂的底层库对接,但开发成本也比较大,需要处理垃圾回收(GC)在两套 runtime 之间的同步问题。
第二种是利用跨平台框架,比如 Compose Multiplatform。 最近 JetBrains 和一些开源社区在推 Compose 往鸿蒙上切。它的原理是利用 Kotlin/Native 把 Kotlin 代码编译成鸿蒙能识别的库,或者通过特定的桥接层来操作鸿蒙的渲染表面。
其实在实际的大厂项目中,如果要把现有的 Android 业务迁移到鸿蒙,通常会采用逻辑层共享、UI 层重写的策略。我们会把核心的 Kotlin 业务逻辑抽成 C++ 库或者通过专门的动态代理层,与 ArkTS 侧进行通信。这块我也一直在关注鸿蒙底层的 Ability 调度机制,看它和 Android 的 Intent 通信到底有多大的底层差异。
Compose 对比常规 View 的优势是什么?
说到 Compose 对比传统 View 系统的优势,我觉得可以用“降维打击”来形容。虽然 View 系统非常成熟,但它背负了十几年的历史包袱,而 Compose 是完全面向现代开发的。
我认为优势主要体现在这三点:
1. 从“命令式”到“声明式”的转变
在传统的 View 系统里,我们要更新 UI,必须手动调用 setText() 或 setVisibility()。这最容易导致“状态不一致”的问题,比如数据变了但 UI 忘了刷。
而在 Compose 里,UI 是状态的函数,也就是 。我只需要定义好状态,当状态改变时,Compose 会自动计算出差异并局部刷新。这不仅让代码量减少了大概 40% 到 50%,更重要的是它消灭了大量由于状态同步导致的 Bug。
2. 解决 View 的“巨类”问题和继承灾难
我们知道,android.view.View 这个类极其臃肿,有几万行代码,哪怕是一个简单的 TextView 也继承了一堆它不需要的功能。
Compose 使用的是组合(Composition)优于继承的设计。每一个 Composable 函数都是轻量级的,它不持有复杂的内部状态。这种松耦合的架构让自定义 View 变得异常简单。以前在 View 里写个自定义布局要重写 onMeasure、onLayout,逻辑很碎;在 Compose 里,一个 Layout 函数配合简单的 lambda 就能搞定。
3. 开发体验和工具链的完全集成 Compose 是纯 Kotlin 开发的。这意味着我们可以直接使用 Kotlin 的所有高级特性,比如协程(Coroutine)、函数式编程。 而且,Compose 彻底解决了布局嵌套导致的性能问题。传统的 View 系统如果嵌套过深,多次测量(Measure)可能会导致掉帧;而 Compose 在底层协议上保证了单次测量策略(Intrinsic measurements),这从物理上规避了布局性能的坑。
谈谈 Compose 局部重组的原理和“标脏”机制
重组(Recomposition)是 Compose 的核心。简单来说,局部重组就是只运行那些依赖了已更改状态的 Composable 函数,而跳过那些没变的部分。
它的底层原理其实涉及到两个关键类:Composer 和 Slot Table(槽位表)。
当我阅读 Compose 源码时,我发现编译器会给每个 Composable 函数注入一个 Composer 参数。
- Slot Table 存储状态:Compose 维护了一个类似“线性的数据结构”,叫 Slot Table。它存储了界面树中所有 Composable 的位置信息、参数和
remember的状态。 - 标脏(Dirty Marking)与 Snapshot 机制:这是最精妙的地方。Compose 使用了一套类似 MVCC(多版本并发控制)的 Snapshot(快照)系统。当我们定义一个
mutableStateOf时,这个状态对象会被快照系统追踪。 - 追踪读取记录:当一个 Composable 函数在执行过程中读取了某个
State的值,快照系统会记录下:“这个函数(实际上是一个RecomposeScope)依赖了这个状态”。 - 触发重组:一旦这个
State的值发生了改变,快照系统会立即通知对应的RecomposeScope。这个 Scope 就会被“标脏”。
在下一帧渲染时,Composer 会扫描这些被标脏的 Scope。
它会对比传入函数的参数。如果参数是 Stable(稳定) 的(比如基本类型或加了 @Stable 注解的类),且值没变,它就直接跳过(Skip)。
如果参数变了,或者函数内部读取的状态变了,它就重新执行这个函数,并更新 Slot Table 里的数据。
这就是为什么我们常说 Compose 的重组是“精准打击”。了解了这一点,在优化性能时,我们就知道要尽量使用 Stable 类型,并减少不必要的 State 读操作 频率,比如把高频读取挪到 lambda 里去,从而减小重组范围。
对最近火的 AI 编程工具有了解吗?比如 Claude 这些?
嗯,我平时在写代码,尤其是阅读 AOSP 这种宏大源码的时候,AI 工具确实帮了大忙。目前我主要在用 Claude 3.5 Sonnet 和 Cursor。
我觉得 Claude 3.5 目前在编程逻辑和架构理解上,确实比 GPT-4 要强一些。比如我遇到一些复杂的 Framework 层 Bug,或者需要快速理解 SurfaceFlinger 里的图层合成逻辑时,我会把相关的 C++ 核心片段丢给它。它能非常敏锐地指出内存管理的隐患,或者解释清那些晦涩的宏定义。
而 Cursor 的强大在于它对**整个工程上下文(Context)**的理解。它不仅仅是一个对话框,它能索引我的整个 Android 项目。比如我想重构一个 MVVM 的模块,它能基于我现有的 Repository 基类和 DataStore 封装,自动生成符合我项目规范的代码。
不过,作为软件工程的学生,我也发现不能盲从 AI。 AI 经常会在一些细节 API 上“一本正经地胡说八道”,或者给出一些不符合 Android 生命周期安全的方案(比如在非主线程更新 UI)。所以我现在更多是把它定位成一个 “极其博学但偶尔会犯错的助手”。我会用它来写单元测试、生成 Boilerplate 代码,或者解释复杂的算法逻辑,但最终的架构决策和核心 Bug 调试,还是得靠我们对底层原理的掌握。
了解 AI 编程工具背后的原理吗?
其实 AI 编程工具,比如 Github Copilot 或 Cursor,它们的核心底层都是基于 Transformer 架构的大语言模型(LLM)。
简单来说,它的原理可以分为三个层面:
1. 预训练与微调(Training & Fine-tuning) 这些模型在训练阶段就“读过”海量的开源代码(比如 GitHub 上的代码库)。通过 Next Token Prediction(预测下一个词) 的任务,模型掌握了各种编程语言的语法、库的用法以及常见的模式。针对编程工具,厂商通常会进行 SFT(监督微调),让它更擅长生成代码而非废话。
2. 嵌入与检索增强生成(RAG) 这是 Cursor 这种工具能理解你整个项目的关键。它会把你的本地代码库进行向量化(Embedding)。当你提问时,它会先在你的代码库里检索出最相关的几个类或函数片段,把它们作为“背景知识”塞给模型。这就是为什么它能知道你项目里自定义的基类怎么用。
3. 窗口上下文管理(Context Window) 模型处理信息时有一个“视口”限制。AI 编程工具通过注意力机制(Attention Mechanism),在海量的上下文里筛选出最重要的信息。比如它在帮你写 Activity 的时候,会优先关注你的布局文件和相关的 ViewModel。
4. 静态分析增强 更高级的 AI 工具还会结合传统的编译器静态分析。它不只是靠概率猜代码,还会跑一下类似于 Lint 的检查,甚至在后台尝试编译,看看生成的代码有没有明显的语法错误。
我觉得理解这些原理对我们很有帮助。比如我知道它依赖上下文,那么我在提问时,如果能主动把相关的接口定义(Interface)给它看,它生成的代码准确率就会高得多。这本质上也是一种提示词工程(Prompt Engineering)。
聊聊跨端方案出现的背景,为什么大家都在搞这个?
嗯,说到跨端方案的背景,其实核心就两个词:效率和一致性。
其实在早期的移动开发里,Android 和 iOS 是两条完全平行的线。虽然业务逻辑是一样的,但公司得招两拨人,写两套代码。这就带来了几个非常痛的问题:
首先是成本太高。同一套逻辑,比如一个复杂的电商下单流程,Android 实现一遍,iOS 实现一遍,还得分别修 Bug,研发成本直接翻倍。对于很多初创公司或者追求快速迭代的互联网公司(像字节、大厂内部的创新项目)来说,这太慢了。
其次是体验的一致性。两端代码不一样,总会出现 Android 上按钮是圆的,iOS 上是方的情况,或者逻辑处理上有细微差别。这对品牌形象和用户体验其实是有损的。
然后就是动态化能力的需求。原生代码(Native)有个硬伤,就是发版慢。不管是 Android 还是 iOS,都得过审核,用户还得下载更新。这时候像 H5 方案 或者后来的 React Native 就应运而生了,大家想追求一种“像网页一样热更新,又像原生一样流畅”的中间地带。
所以,跨端方案的演进其实就是从最早的 WebView 混合开发,到后来的 桥接模式(RN/Weex),再到现在的 自绘引擎(Flutter) 以及 逻辑跨端(KMP)。本质上都是在平衡“开发效率”、“运行性能”和“动态化”这三者的关系。
跨端方案那么多,为什么有些底层库非要使用 C++?写起来方便吗?
嗯,确实,现在的跨端选择非常多,比如 JS、Dart、Kotlin。但如果你去看很多大厂的核心引擎,比如 Flutter 的底层渲染(Skia)、音视频处理(FFmpeg),或者是游戏的内核,几乎清一色都是 C++。
结论先行的话,选 C++ 主要是为了 性能上限、跨平台复用性 以及 对底层资源的控制力。
- 性能是绝对优势:C++ 编译后是直接运行在硬件上的机器码,没有虚拟机开销,也没有像 Java 那样的 GC(垃圾回收)停顿。在处理图形渲染、编解码这种 CPU 密集型任务时,C++ 的效率是最高的。
- 它是“跨端语言的公约数”:不管是 Android、iOS、Windows 还是鸿蒙,它们都支持 C/C++。你写一套 C++ 逻辑,可以无缝地给所有平台用,而不需要在每个平台上再跑一个虚拟机。
至于写起来方不方便? 说实话,如果不了解现代 C++,确实挺痛苦的。
以前大家觉得 C++ 难,主要是因为手动管理内存(malloc/free)很容易内存泄漏或者野指针。但其实在现代 C++(C++11 及以后)里,有了 智能指针(std::shared_ptr, std::unique_ptr) 和 RAII 机制,内存管理已经安全很多了。配合 STL 容器,写起来其实并没有想象中那么“原始”。
不过,C++ 的编译调试过程确实比 Java/Kotlin 慢很多,而且对开发者的要求很高。所以现在的趋势是:核心算法、渲染引擎、通信协议这些“重活”用 C++ 封装成库,而业务逻辑和 UI 层则交给更易用的语言(如 Kotlin 或 JS)去调用。这其实就是一种“空间换时间、性能换效率”的博弈。
Java 和 C++ 到底是怎么通信的?底层是怎么运作的?
嗯,在 Android 里,Java 和 C++ 的通信主要是通过 JNI(Java Native Interface) 这一层桥梁来实现的。这块我在看 Android Framework 层源码(比如 InputManagerService 或者 SurfaceFlinger 相关的 Java 层封装)时经常看到。
简单来说,通信过程可以分为“从 Java 调 C++”和“从 C++ 调 Java”两个方向:
-
从 Java 调 C++: 通常我们需要在 Java 类里声明一个
native方法,然后通过System.loadLibrary("xxx")加载对应的 SO 库。底层其实有两种映射方式:- 静态注册:根据特定的命名规则(比如
Java_包名_类名_方法名),JVM 在运行时会自动通过符号表找到对应的 C++ 函数。 - 动态注册:这是 Framework 层更常用的方式。在 C++ 层实现一个
JNI_OnLoad函数,在里面调用RegisterNatives方法。这种方式加载更快,方法名也更灵活,代码耦合度更低。
- 静态注册:根据特定的命名规则(比如
-
通信的核心:
JNIEnv指针: 无论是哪个方向,通信的核心都是JNIEnv这个结构体。它就像一个功能库,提供了大量的函数,比如FindClass、GetMethodID、NewStringUTF等。Java 的对象传到 C++ 层后会变成jobject,C++ 必须通过JNIEnv提供的接口才能操作这个对象。
底层的运作机制:
其实当 Java 执行到 native 方法时,JVM 会切换线程的执行状态。它会保存当前的 Java 栈帧,跳转到 native 代码区执行。这时候要特别注意内存消耗和跨语言调用的开销。因为 Java 和 C++ 属于不同的内存管理体系,频繁的 JNI 调用(比如在循环里调)会有明显的上下文切换成本。
我在写 Framework 层的 Demo 时发现,如果需要传输大数据(比如图片像素数据),最好使用 DirectBuffer。这样 Java 和 C++ 就能共享同一块物理内存,避免了昂贵的内存拷贝。
除了常见的几种,你还了解哪些其他的跨端技术?
嗯,除了现在最火的 Flutter 和 React Native,我其实还深入关注过另外几种非常有代表性的思路:
-
KMP(Kotlin Multiplatform): 这是我个人非常看好的一种方案,也是 JetBrains 主推的。它的思路和 Flutter 完全不同。Flutter 是“UI 跨端”,而 KMP 是 “逻辑跨端”。 它允许你用 Kotlin 编写 Data 层、网络层和业务逻辑,然后编译成 JVM 字节码给 Android 用,编译成 Native 库给 iOS 用。UI 还是原生的。这种方式最大的好处是性能完全没损失,而且保留了平台各自的 UI 特色。现在字节跳动内部也有很多团队在尝试 KMP,因为它对现有项目的侵入性很小。
-
Compose Multiplatform: 这是基于 KMP 的延伸。它尝试让 Compose 的声明式 UI 也能跑在 iOS、Desktop 和 Web 上。其实原理跟 Flutter 有点像,都是通过自绘引擎(比如 Skia)来渲染 UI,但它能让你实现 Android 和 iOS 的 UI 代码 100% 复用。
-
H5 与 Hybrid(容器化方案): 虽然它很老,但依然是主流。现在的进阶玩法是像微信小程序那样的架构:双线程模型(逻辑层在 JSWorker,渲染层在 WebView)。这种方案通过 JSBridge 隔离了直接操作 DOM 的权限,极大提高了安全性。
-
Uni-app / Taro: 这类属于“编译型跨端”。它们通常是写一套类 Vue 或 React 的代码,然后通过复杂的编译器,把代码编译成各个平台的小程序或原生代码。这在做国内各种平台的小程序适配时非常高效。
总结一下,我觉得现在的跨端技术已经从单一的“为了省人手”变成了针对不同业务场景的精准选型。如果是高性能工具用 C++,如果是快速业务迭代用 RN/Flutter,如果是核心业务逻辑共享则用 KMP。这也是我目前在学习这些方案时的思考角度。
怎么判断哪些代码可以下沉到逻辑复用层?
嗯,这其实是一个关于架构边界的问题。在做跨端或者模块化的时候,判断标准非常核心。我通常会从三个维度去衡量:平台相关性、业务稳定性、以及计算密集度。
首先,最容易判断的是平台无关的纯业务逻辑。比如一个电商应用的“购物车价格计算引擎”,或者一个金融应用的“还款计划算法”。这些代码逻辑完全是数学和业务规则的组合,不依赖 Android 的 Context,也不依赖 iOS 的 UIKit。这类代码是下沉到复用层(比如 C++ 或 KMP 层)的首选。
其次是数据处理与持久化层。 其实不管是哪个平台,网络请求后的 JSON 解析、本地数据库(像 SQLite)的 CRUD 操作、或者是缓存策略(LRU 算法),逻辑都是高度一致的。把这些封装在复用层,可以保证多端数据的一致性。比如我之前在项目里,就把所有的 Repository 层 和 网络拦截器逻辑 往下拉,这样 Android 和 iOS 看到的永远是同一份经过清洗的数据。
第三点是通用的工具类和协议层。 比如自定义的加解密算法(AES/RSA)、特定的埋点协议、或者是音视频的编解码逻辑。这些东西如果两端各写一套,后期维护简直是灾难,而且很容易出 Bug。
那哪些绝对不能下沉呢? 我觉得是那些涉及到系统硬件交互和特定 UI 组件的代码。比如相机预览、蓝牙扫描、传感器数据采集,或者是系统的通知推送。虽然我们可以抽象出一层接口,但底层的具体实现必须留在原生层,因为它们调用的全是系统特有的 API。
我一直坚持的一个原则是:复用层应该是“纯净”的。它不应该知道自己运行在哪个系统上,它只负责接收输入,处理逻辑,然后输出结果。
在跨端开发的过程中,你具体做了哪些工作?
在之前的项目和实践中,我主要承担的是底层架构搭建和性能桥接的工作,而不是简单的 UI 业务逻辑。
具体来说,我主要做了以下三件事:
第一,是跨语言调用的接口定义(Interface Bridge)。
因为 Java 和 C++(或者 Kotlin Native)的通信是有开销的,我负责设计了一套基于 Protobuf 的数据序列化协议。相比于传统的 JSON 或者逐个传参,Protobuf 在 JNI 层的传输效率更高,而且能自动生成多端代码,极大地减少了手动写 JNI 签名(比如 (Ljava/lang/String;)V 这种)出错的概率。
第二,是底层线程模型的统一。
在跨端开发里,最头疼的就是异步回调。我当时设计了一套机制,把 C++ 层的回调通过 MessageQueue 投递回 Android 的主线程(Looper)。我深入研究过 AOSP 里的 Handler 实现,所以我就在 C++ 侧模拟了一套类似的机制,确保底层数据的变更能安全、准时地触达应用层,避免了多线程竞态导致的应用崩溃。
第三,是生命周期的联动管理。
底层的逻辑组件必须知道 Android 端的 Activity 什么时候销毁,否则就会出现内存泄漏。我利用了 Jetpack 的 LifecycleObserver,在原生层监听生命周期,并实时通知底层的复用模块进行资源释放(比如关闭数据库、断开 Socket 链接)。
其实在这个过程中,我发现跨端最难的不是写代码,而是抹平不同平台的差异。我通过阅读 Framework 源码,借鉴了系统服务(比如 AMS 这种跨进程通信)的思路,来优化我们业务层的跨语言调用。
举个例子说明:哪些代码放在原生层,哪些放在复用层?
拿一个典型的“实时动态壁纸”或者“短视频播放器”为例:
复用层(Shared Logic Layer):
- 视频解析逻辑:比如 FFmpeg 的解封装(Demuxing)、视频流的缓存控制、码率自适应算法(ABR)。
- 网络下载策略:断点续传的逻辑、多线程下载的调度。
- 数据模型:
VideoInfo、CommentEntity等 POJO 类。 - 业务校验:比如用户是否有权限观看这个视频,这属于纯业务逻辑。
原生层(Native Layer):
- 渲染表面(Surface/TextureView):在 Android 上你需要
SurfaceView或TextureView,这是系统 UI 系统的核心,必须原生。 - 硬件加速解码(MediaCodec):虽然 FFmpeg 能软解,但为了省电和性能,我们必须调用 Android 原生的
MediaCodec接口,这部分代码是平台强相关的。 - 音频焦点(Audio Focus)处理:当有电话打进来时,要暂停播放。这涉及到调用 Android 的
AudioManager,属于系统级交互。 - 推送通知:利用 Firebase 或厂商推送(小米、华为)展示视频更新提醒。
简单来说,“思考的部分”放复用层,“感知和执行的部分”放原生层。
底层数据发生变化是怎么通知上层的?数据驱动是怎么做的?
这是一个非常经典的问题,在跨端架构里,我们通常采用 “观察者模式 + 响应式流” 的思路来实现。
具体流程是这样的:
首先,在底层(复用层)维护一个 状态机(State Machine) 或一个 Store。当底层逻辑完成操作(比如网络请求回来了,或者本地计算结束了),它会更新内部的 State 对象。
为了通知上层,我们会定义一个回调接口(Listener/Callback)。在 Android 端,这个接口通常在初始化底层模块时注入进去。在底层 C++ 侧,当状态改变时,会通过 JNI 调用 Java 层的那个接口方法。
那如何实现“数据驱动”呢?
我通常会把接收到的 JNI 回调转化成 LiveData 或者 StateFlow。
- 底层回调触达 Java 层。
- Java 层对应的
Repository把数据 push 到一个MutableStateFlow里。 - 原生层的 ViewModel 观察这个 Flow。
- UI 层(Activity 或 Compose)再订阅 ViewModel。
这样就形成了一个单向数据流(UDF)。UI 永远不直接找底层要数据,而是“观察”数据的变化。底层数据一变,通过 JNI 链条一路传导,UI 就像收到信号一样自动刷新。
我在做这部分优化时,特别注意了 Batching(批处理)。如果底层数据变动极快(比如传感器数据),我会在底层做一层节流(Throttling),每 16ms(对应屏幕刷新率)才往上层发一次信号,防止频繁的 JNI 调用把主线程卡死。
原生层的 ViewModel 和底层的 ViewModel 有什么区别?
虽然都叫 ViewModel,但它们在架构中的位置和职责是完全不同的。
原生层的 ViewModel(比如 Jetpack ViewModel):
- 生命周期感知:它是跟 Activity/Fragment 绑定的。它最大的意义是能在配置变更(比如屏幕旋转)时存活。
- UI 逻辑处理:它负责把原始数据转换成 UI 能直接用的数据。比如把一个
long类型的时间戳转换成 “5分钟前” 这种字符串,或者控制某个 ProgressBar 的可见性。 - 持有 UI 状态:它直接服务于 View,是 View 的“代言人”。
底层的 ViewModel(或者叫 Domain Model/Business Logic Holder):
- 生命周期无关:它通常存在于 C++ 层或 KMP 的公共模块里。它不关心 Activity 是否销毁,它关心的是业务流程。
- 纯业务状态:它存储的是纯粹的业务模型(Domain Model)。比如当前用户的登录状态、购物车里的商品列表、或者当前的下载进度百分比。
- 多端共享:它是 Android 和 iOS 共用的逻辑大脑。
它们的关系: 通常是 “一对一”或“多对一” 的关系。原生层的 ViewModel 会持有(或观察)底层 ViewModel 的数据。 简单说,底层 ViewModel 负责“做什么”,原生层 ViewModel 负责“怎么给用户看”。
比如一个下载功能,底层 ViewModel 负责跑下载线程、计算进度;Android 端的原生 ViewModel 拿到进度后,决定是展示在 Notification 里,还是弹出一个 Dialog。这种分层让我们的原生代码变得非常薄,逻辑也更容易测试。
怎么主动发现线上的性能问题?有没有什么成熟的手段?
嗯,关于线上性能问题的发现,其实不能等用户投诉,那太晚了。我们得有一套完整的 APM(Application Performance Monitoring) 体系。
结论先行的话,主动发现的核心手段就是:全量指标监控 + 异常样本采样 + 自动化归因分析。
具体来说,我会从这几个维度去建立监控:
-
卡顿监控(Looper 机制): 这是最基础的。其实原理跟
BlockCanary差不多,就是监控主线程Looper的消息分发耗时。通过自定义一个Printer传给Looper.setMessageLogging(),记录dispatchMessage前后的时间差。如果超过阈值(比如 200ms),我们就判定为一次卡顿,并迅速抓取当时的线程堆栈。现在更进阶的做法是利用 字节码插桩(ASM),在每个方法的入口和出口埋点,这样能拿到更精确的方法调用耗时链。 -
流畅度监控(Vsync 信号): 利用
Choreographer.FrameCallback。我们在每一帧 Vsync 信号到来时打个点,计算两帧之间的时间间隔。如果连续掉帧,就能实时算出当前的 FPS。字节内部其实有一套很成熟的工具叫 Argos,腾讯有 Matrix。它们都能做到在线上环境下,以极低的性能开销实时监控帧率波动。 -
内存监控(采样与 Leak 检测): 线上不能像开发环境那样跑 LeakCanary(太重了)。我们会定期通过
Debug.MemoryInfo获取当前的堆内存快照。如果内存持续上涨触及警戒线(比如占最大内存的 80%),我们会触发一个特殊的“异常上报”,只抓取对象引用的关键链,或者在本地做 Hprof 的裁剪压缩后再后台上传。 -
ANR 监控(Signal 信号监听): 现在主流的做法是监听 Linux 的 SIGQUIT 信号。当 ANR 发生时,系统会给进程发这个信号,我们拦截这个信号,然后去读取
/data/anr/traces.txt(虽然 Android 10+ 权限收紧了,但可以通过死循环监控或监控主线程运行状态来模拟)。
其实最重要的不是收集数据,而是采样策略。全量收集会导致后端压力巨大且耗电。我们通常会按用户比例采样,或者只在 App 刚启动、进核心业务页时开启高频监控。这种“主动发现”的机制,让我们能在版本发布后的几个小时内,就通过看板发现性能的下滑,从而快速回滚或热修复。
谈谈 MVVM 的设计思路,它和传统的 MVC、MVP 有什么本质区别?
嗯,MVVM 应该是目前 Android 开发的主流,也是 Jetpack 官方推崇的架构。它的核心思想是**“数据驱动 UI”和“生命周期感知”**。
我们可以通过对比来看它们的演进:
-
MVC(Model-View-Controller): 在 Android 里,Activity 充当了 View 和 Controller。结果就是 Activity 极其臃肿,业务逻辑和 UI 操作全搅在一起。View 直接持有 Model,Model 变了要手动通知 View 刷。这根本没法做单元测试,也谈不上解耦。
-
MVP(Model-View-Presenter): 它引进了 Presenter。View 把事件传给 Presenter,Presenter 处理完业务(调 Model),再通过 IView 接口 调回 View 的方法。
- 进步点:逻辑和 UI 彻底分离了,Presenter 不再依赖具体平台,好测了。
- 痛点:接口定义爆炸!一个小功能要定义三四个接口。而且 Presenter 强持有 View,一旦 Activity 销毁时没及时解绑,分分钟内存泄漏。
-
MVVM(Model-View-ViewModel): 它最大的变化是引入了 ViewModel 和 观察者模式(LiveData/Flow)。
- 核心思路:ViewModel 并不直接持有 View 的引用,它只管维护 State(状态)。View 观察这些状态,状态一变,UI 自动刷。
- 解耦更彻底:ViewModel 甚至不知道自己在服务哪个 Activity。它通过 DataBinding 或者手动订阅来实现同步。这就像是“我只管把菜炒好放在桌上(ViewModel),谁想吃谁来拿(View)”。
我在实践中觉得 MVVM 最牛的地方在于它解决了 Configuration Change(比如旋屏) 导致的数据丢失问题。ViewModel 独立于 Activity 的生命周期存在。而且配合 单向数据流(UDF),逻辑会变得非常清晰:User Action -> ViewModel -> State -> View。这种闭环让 Bug 非常好定位,因为状态的改变只发生在一个地方。
LiveData 相比于传统的观察者模式,有什么独特的优势?
嗯,其实传统的观察者模式(比如自己写接口回调)在 Android 里最头疼的就是生命周期管理。LiveData 实际上是专门为 Android 这种“有生有死”的系统设计的。
我觉得它的优势主要有这三点:
-
生命周期感知(Lifecycle-aware): 这是它最大的杀手锏。
LiveData内部其实监听了LifecycleOwner(也就是 Activity 或 Fragment)的生命周期。只有当页面处于 STARTED 或 RESUMED 状态(活跃状态)时,它才会下发数据更新。如果页面在后台,它会“憋着”,等用户回到前台的一瞬间再把最新的值推过去。 -
自动移除监听,杜绝内存泄漏: 以前我们用传统的
Observable,如果在onDestroy忘了removeCallback,那这个回调会一直持有 Activity 的引用,导致内存泄漏。LiveData在感应到页面销毁(DESTROYED)时,会自动把自己关联的观察者删掉。这让开发者省了很多心,代码也更安全。 -
解决了“后台更新导致崩溃”的问题: 我们经常遇到这种情况:子线程任务结束,准备更新 UI,结果这时候 Activity 已经销毁了。调
TextView.setText()就会崩。但LiveData因为感知生命周期,如果页面不活跃,它根本不会回调,从根源上规避了这种空指针或者非法状态异常。
其实我之前读源码发现,LiveData 的实现非常精简。它通过一个 mVersion 的概念来管理数据版本,确保观察者不会漏掉最新的状态。虽然现在大家开始转向 Kotlin Flow(因为它支持更多的操作符),但 LiveData 在简单的 UI 驱动场景下,依然是最稳、最简单的选择。
性能优化的整体思路是什么?你是如何着手的?
性能优化绝对不是盲目地改代码,它是一套**“闭环迭代”**的工程。我的思路总结为八个字:量化数据、定位瓶颈、定点爆破、持续线上监控。
具体步骤是这样的:
-
第一步:测量(Measure) 在优化前,一定要先建立指标。比如启动速度,是主进程创建耗时多?还是
Application.onCreate里三方库初始化多?我会用 Android Studio Profiler 或者 Perfetto(之前的 Systrace)去跑一遍。拿到具体的毫秒级数据,作为优化的 Baseline。 -
第二步:分析(Analyze) 根据数据找“大头”。比如:
- UI 耗时:我会看布局层级(Layout Inspector),看是不是有 Overdraw(过度绘制)。
- 内存耗时:看有没有频繁的 Memory Churn(内存抖动) 导致 GC 频繁,从而卡顿。
- IO 耗时:看主线程有没有在读写文件。
-
第三步:优化(Optimize) 针对不同的点采取不同的战术:
- 启动优化:异步初始化(Task Dispatcher)、延迟加载(IdleHandler)、预加载。
- 布局优化:用
ConstraintLayout减少嵌套,使用ViewStub延迟加载,或者直接上 Compose 提升渲染效率。 - 内存优化:处理泄漏,大图压缩(Bitmap 配置优化),合理使用缓存。
-
第四步:验证与线上回馈(Verify) 在本地验证有效后,通过 A/B Testing 推送到线上。看 P90、P99 指标有没有真正的下降,而不是只在我的开发机上变快了。
我觉得最核心的思想是:不要做过度优化。先解决最明显的性能损耗(那 20% 的代码导致了 80% 的性能损耗),然后通过建立监控屏障,防止性能以后再退化。这才是成熟的优化思路。
性能优化的收益怎么衡量?老板问你优化了多少,你怎么回答?
衡量优化收益不能只说“变快了”,得有一套技术指标 + 业务指标的双维度评估体系。
1. 技术指标(硬数据):
- 启动速度:冷启动/热启动耗时(P50、P90)。比如从 800ms 降到了 500ms,提升了 37.5%。
- 流畅度:**丢帧率(Janky Frames)**和 FPS。优化后卡顿率(卡顿次数/小时)下降了多少。
- 稳定性:OOM 率、ANR 率。内存优化最直接的收益就是 OOM 的大幅下降。
- 资源占用:APK 体积减小了多少,平均内存占用、功耗(电量损耗)降低了多少。
2. 业务指标(核心收益): 技术指标是过程,业务指标才是结果。面试官或老板其实更看重这个:
- 留存率/转化率:很多时候,启动速度每提升 100ms,用户的次留(次日留存)或者下单转化率就会有明显的百分比提升。
- 用户投诉量:线上性能报警的数量,或者客服收到的“卡顿”、“白屏”反馈是否减少。
我会这样汇报: “在这次优化中,我们通过 异步初始化 和 DEX 预热,将冷启动 P90 时间缩短了 200ms。上线后,由于卡顿感的减少,我们的核心页面加载完成后的用户跳出率降低了 2%,变相提升了业务的日活稳定性。同时,通过图片加载策略优化,内存峰值降低了 50MB,线上 OOM 率直接下降了 15%。”
这样的回答既有深度,又能体现出你工作的商业价值。
怎么分组快速发现新改动的性能问题?
这是大型项目 CI/CD 流程中的关键。我们要实现的是**“性能防退化”**。
我的做法通常是分层治理:
-
开发阶段:代码静态扫描 + Lint 在 CI 合代码的时候,先跑一遍 Lint。比如检查有没有在循环里创建对象,或者主线程里调用了
getSharedPreferences(可能导致磁盘 IO 阻塞)。如果不合规,直接拒绝合入。 -
构建阶段:自动化的 Benchmark 门禁 利用 Jetpack 的 Macrobenchmark 库。在 CI 流程里自动打出两个包:一个是
Base版本(上一个稳定版),一个是Feature版本(新改动版)。在同样的模拟器或者真机上,自动化地跑一遍核心链路(比如启动、滑动列表、进详情页)。 如果新包的耗时超过了旧包的一个阈值(比如 5%),CI 就会报警,并生成一份对比报告。这种**“分组对比”**能在代码还没上线前,就把性能 Bug 拦截在实验室里。 -
灰度阶段:AB 测试 + 关键指标波动报警 在小流量灰度时,把用户分为 A 组(旧代码)和 B 组(新代码)。 利用 APM 工具实时观察两组的数据对比。如果 B 组的 FPS 均值比 A 组低,或者 ANR 率突然飙升,系统会自动触发告警,甚至自动终止灰度。
-
线上阶段:版本对比看板 上线后,在监控看板上直接对比两个版本的曲线。通过这种版本维度的“组间差异”,能非常快速地定位到到底是哪个版本的改动引入了性能瓶颈。
这种**“实验室自动化测试 + 线上 AB 监控”**的组合拳,是我在处理大规模团队开发时,保证性能不退化的核心手段。
介绍下 Java 和 C++ 通信的细节
说到 Java 和 C++ 的通信,大家首先想到的肯定就是 JNI(Java Native Interface)。其实在 Android 系统里,JNI 就像是一座桥梁,连接了上层的 Java 框架层和底层的 C++ 运行库。
简单来说,它的通信流程可以概括为:注册、查找和转换。
在实际开发中,我们通常有两种方式来实现这个通信。一种是比较传统的 “静态注册”,就是按照 JNI 特定的命名规则(比如 Java_包名_类名_方法名)去写 C++ 函数。这种方式虽然简单,但缺点也很明显,就是名字太长了,而且在运行时查找效率比较低。
所以我在看 Android Framework 源码,比如 AndroidRuntime.cpp 的时候发现,系统更多使用的是 “动态注册”。它是通过 RegisterNatives 函数,手动把 Java 的 native 方法和 C++ 的函数实现映射起来。这种方式在库加载的时候就完成了关联,后续调用的效率会高很多,代码也更整洁。
这里面有一个核心的类叫 JNIEnv,它是一个指向函数表的指针。通过这个 JNIEnv,我们可以在 C++ 侧调用 Java 的方法、访问成员变量,甚至创建 Java 对象。比如,如果我们想从 C++ 回调 Java,通常会先通过 FindClass 找到类,再用 GetMethodID 获取方法 ID,最后通过 CallVoidMethod 这样的函数执行回调。
还有一个细节,就是 Java 的 对象引用。在 JNI 里面,Java 对象被映射为 jobject。这里要注意 局部引用(Local Reference) 和 全局引用(Global Reference) 的区别。局部引用在 native 函数返回后会自动释放,但如果我们要跨线程或者跨调用保存这个对象,就必须显式调用 NewGlobalRef 来创建一个全局引用,否则就会出现野指针或者内存泄漏,这在 Framework 开发中是非常需要注意的。
数据转换有性能上的问题吗
关于 JNI 通信中数据转换的性能问题,答案是肯定的,而且这往往是 Native 开发中的 性能瓶颈。其实,频繁地跨越 JNI 边界本身就有开销,而数据在两种语言环境下的 格式转换和拷贝 更是大头。
首先,最明显的开销在于 基本数据类型(Primitive Types) 之外的对象。像 int、float 这种基本类型,在 JNI 里是直接映射的,基本没损耗。但如果是 String 或者 Array,情况就不同了。
比如 Java 的 String 是 UTF-16 编码,而 C++ 常用的可能是 UTF-8。当你调用 GetStringUTFChars 时,JNI 驱动往往需要做一次 内存拷贝和编码转换。如果这个字符串非常大,或者在循环里频繁调用,性能损耗会非常明显。
其次,是 大规模数据的传输。比如我们要把相机的 YUV 数据或者音频原始数据传到 C++ 处理。如果用 jbyteArray,每调一次 GetByteArrayElements,系统可能都会把整个数组拷贝一份到 Native 堆。
为了优化这一点,我们通常有两种方案:
- 使用
Direct ByteBuffer:这是我在写视频编解码或者 OpenGL 渲染时经常用的。通过ByteBuffer.allocateDirect分配的内存,Java 侧和 C++ 侧可以直接通过地址访问 同一块物理内存,这就实现了“零拷贝”。 GetPrimitiveArrayCritical:这是一个“临界区”接口。它会尝试直接返回指向 Java 堆内存的指针,而不是拷贝。但代价是它会阻塞垃圾回收(GC),所以在调用之后必须尽快释放,中间不能写可能导致线程阻塞的代码。
最后还有一点,就是 频繁查找 jfieldID 和 jmethodID 的开销。在 Framework 层,为了性能,我们通常会在 JNI_OnLoad 的时候把这些 ID 缓存 起来,而不是每次调用时都去查一遍。因为 FindClass 和 GetMethodID 涉及字符串搜索,在高性能场景下是不能接受的。
C++ 和 Kotlin/Java 的差异有哪些
作为一名 Android 开发者,我经常要在 C++ 和 Java/Kotlin 之间切换。其实,除了语法层面的区别,我觉得它们最核心的差异体现在 内存管理机制、执行效率以及语言特性 这三个方面。
首先是 内存管理,这是最本质的区别。Java 和 Kotlin 运行在 ART 虚拟机 上,拥有自动的 GC(垃圾回收机制)。我们写代码时不需要关注内存释放,但也因此面临着 STW(Stop The World)带来的卡顿风险。而 C++ 是 手动管理内存 的,虽然现在有 RAII 和 智能指针(shared_ptr/unique_ptr),但如果逻辑复杂,还是容易出现内存泄漏或者 Use-after-free 这种安全隐患。在 Framework 层,像 SurfaceFlinger 这种追求极致稳定的模块,对 C++ 指针的管理就非常考究。
其次是 运行性能。Java/Kotlin 编译后是 字节码,虽然有 JIT(即时编译)和 AOT(预编译)技术,但它在运行时还是需要经过一层虚拟机的转换。而 C++ 是直接编译成机器码,对于计算密集型的任务,比如复杂的图形渲染、物理引擎或者加解密算法,C++ 的 执行效率和指令集优化 能力是 Java 没法比的。这也是为什么 Android 的核心库(如 HWUI、Skia)都是用 C++ 写的。
再一个就是 语言特性和安全性。Kotlin 引入了非常棒的 空安全(Null Safety) 和 协程(Coroutines),极大地提高了我们的开发效率,减少了 NPE。而 C++ 相对来说更“危险”一些,但也更“灵活”。它支持 多重继承、模板元编程 以及 内联汇编,这些特性让我们能从底层压榨硬件性能。
另外,从系统架构的角度来看,Java/Kotlin 更多地扮演 “管理者” 的角色,比如 AMS、PMS 这些逻辑复杂的系统服务,用 Java 写更容易维护和扩展;而 C++ 则扮演 “执行者” 的角色,负责底层的资源调度和数据搬运。理解了这种差异,我们在做技术选型的时候,就能更清晰地判断什么时候该往 Native 层走,什么时候该留在应用层。
安卓内存泄漏的场景
在 Android 开发里,内存泄漏本质上就是长生命周期的对象持有了短生命周期对象的引用,导致短生命周期对象在执行完使命后,无法被 GC 回收。如果这种泄漏堆积多了,轻则导致 OOM(内存溢出),重则会让 App 变得卡顿,因为 GC 频繁触发。
其实我在平时写代码和分析 AOSP 源码时,总结了几种最常见的场景:
首先,最经典的就是 非静态内部类或匿名内部类持有 Activity 的引用。比如我们经常在 Activity 内部写一个 Handler。如果这个 Handler 是非静态的,它会隐式地持有 Activity 的引用。当我们发送一个延迟消息,还没执行呢,用户就把 Activity 关了。这时候,由于消息队列(MessageQueue)里的 Message 持有 Handler,Handler 又持有 Activity,导致 Activity 无法被回收。要解决这个,其实也很简单,把 Handler 设为 static,然后通过 WeakReference(弱引用)来访问 Activity 就行了。
其次,是 静态变量(Static Variables)导致的泄漏。我有一次在项目中为了方便,在一个 Singleton 单例里存了一个 Context。如果这个 Context 传的是 Activity 的,那这个 Activity 基本上就永远留在内存里了,因为单例的生命周期是跟进程一样长的。所以我现在的习惯是,如果是单例持有的 Context,一定要用 context.getApplicationContext()。
再一个就是 未取消注册的监听器(Listeners)或广播接收器(BroadcastReceivers)。比如我们在 onCreate 里注册了系统服务,像 LocationManager 或者某些传感器监听,如果在 onDestroy 里忘记调用 unregister,那么系统服务会一直持有我们的回调引用,造成泄漏。
还有一种比较容易被忽视的,就是 资源未关闭。比如 Cursor、File 或者是 TypedArray。虽然现在的库(比如 Room)帮我们处理了很多,但在底层开发时,如果 CloseGuard 机制报警了,基本就是哪里忘了关流了。
其实分析这些泄漏,我一般会用 LeakCanary 做线上监控。它的原理很有意思,是利用了 WeakReference 和 ReferenceQueue,在 Activity 销毁后去检查引用是否被释放。如果没释放,它会通过 Shark 库去分析堆转储文件(Hprof),帮我们直接定位到泄漏的 GCRoot。
kotlin 协程的理解
关于 Kotlin 协程,我觉得它最大的价值不是“比线程更轻量”,而是一种 “异步编程的同步化写法”。结论就是:协程是一个基于 状态机 的线程框架,它让我们能用顺序执行的代码逻辑,去处理极其复杂的并发任务。
其实很多人误解协程是某种“微线程”,但我在看 Kotlin 编译后的字节码时发现,它底层其实还是线程池在跑。它的核心黑科技在于 suspend(挂起) 和 resume(恢复)。
嗯,简单来说,当一个协程执行到 suspend 函数时,它并不会阻塞当前的线程。相反,它会记录下当前的执行状态(通过一个叫 Continuation 的对象),然后从当前线程脱离。线程可以去干别的事,比如 UI 线程去继续刷新屏幕。等到异步任务(比如网络请求)回来了,协程框架再把之前保存的状态拿出来,切回到原来的线程(或者指定的 Dispatchers)继续执行。
这背后其实就是 CPS(Continuation Passing Style)转换 和 状态机(StateMachine)。编译器会把我们的顺序代码拆分成多个状态。这比 RxJava 满屏的 flatMap 要直观得多,代码的可读性完全不在一个量级。
我在项目里用协程时,最看重的是它的 结构化并发(Structured Concurrency)。比如我们给一个 Activity 绑定一个 lifecycleScope,当 Activity 销毁时,这个作用域下的所有子协程都会被自动取消。这就从根源上避免了协程跑丢了或者内存泄漏的问题,这比手动管理 Thread 或者 Handler 要优雅太多了。
还有就是 Dispatchers 的切换。比如我们在 Dispatchers.Main 开启协程,然后用 withContext(Dispatchers.IO) 去读数据库,读完直接返回。这种在不同线程池间自由穿梭的感觉,确实让 Framework 层的多任务处理变得非常清爽。
多线程读取数据可能会有哪些问题,可以通过什么方式避免
在多线程环境下,如果多个线程同时读取数据,其实通常是没问题的。但如果有的线程在读,有的线程在改,那问题就大了。最核心的问题其实就三个:原子性(Atomicity)、可见性(Visibility) 和 有序性(Ordering)。
首先是 可见性问题。由于每个 CPU 核心都有自己的高速缓存(L1/L2 Cache),一个线程修改了主内存的数据,另一个线程的缓存里可能还是旧值。这就会导致读到“脏数据”。 其次是 有序性问题。编译器和处理器为了优化性能,可能会对指令进行 重排序。在单线程下没问题,但在多线程下,这种乱序可能会导致逻辑崩掉。
要避免这些问题,我有几个常用的手段:
第一,如果是简单的状态位读取,我会给变量加上 volatile 关键字。它能保证可见性,并禁止部分指令重排序,确保每次读取都是从主内存拿最新值。
第二,针对 “读多写少” 的场景,我非常推荐使用 ReentrantReadWriteLock(读写锁)。它的逻辑是:允许多个线程同时持有“读锁”,这样读取效率很高;但只要有一个线程想拿“写锁”,就会排斥所有的读锁和写锁。这比直接用 synchronized 这种重型锁要高效得多。
第三,如果数据结构比较简单,我会考虑用 Atomic 原子类(比如 AtomicInteger)。它们底层利用的是 CPU 的 CAS(Compare And Swap) 指令,不需要加锁就能保证操作的原子性,性能非常均衡。
最后,在 Android 的某些特定场景,比如管理监听器列表,我还会用到 CopyOnWriteArrayList。它的思路很特别:在写的时候先 Copy 一份新数组,写完再把引用指回去。这样我们在遍历(读取)列表的时候,就不需要加锁,也不会抛出 ConcurrentModificationException。虽然写操作代价大,但对于这种读远多于写的场景,真的非常香。
100 瓶水,有 1 瓶是毒药,喝了 1 天后会毒发,最少多少只小白鼠和最短几天可以测出来
这是一个非常经典的 二进制编码 问题。
首先结论是:最少需要 7 只 小白鼠,时间最短只需要 1 天。
其实这个逻辑背后的核心就是 的计算。我们需要通过小白鼠的“死”或“活”这两种状态,来编码所有的水瓶编号。
嗯,具体的推导过程是这样的: 每一只小白鼠其实代表一个 二进制位(bit)。一只鼠有两种状态(死或活),那么 只鼠就能表示 种状态。 我们现在有 100 瓶水,需要满足 。 如果是 ,不够表示 100; 如果是 ,这就绰绰有余了。所以,7 只 小白鼠就够了。
操作方法其实很巧妙:
我们将 100 瓶水从 1 到 100 进行编号,并转换成 7 位二进制数。比如第 1 瓶是 0000001,第 100 瓶是 1100100。
然后,我们安排这 7 只小白鼠,每一只负责二进制中的一个位。比如第 1 只鼠,喝掉所有编号中“第一位是 1”的水;第 2 只鼠,喝掉所有编号中“第二位是 1”的水,以此类推。
等 1 天后,我们看哪几只鼠死了。比如第 1、3、5 只鼠死了,我们就把这个结果转回二进制:0010101,对应的十进制数就是那瓶有毒的水。
至于时间,因为毒发时间是固定的 1 天,而我们的实验方案是一次性喂食,所以 最短只需要 1 天 就能出结果。这个思路在计算机科学里其实就是典型的二进制索引,非常高效。
字节跳动 13面
SQLite 使用有遇到存储和删减导致数据库文件庞大的情况吗?
在实际开发中,这其实是一个非常普遍且有趣的现象。简单来说,结论就是:SQLite 的物理文件大小并不会随着数据的删除而实时减小。这背后涉及到了它的 “空闲列表(Free List)” 机制。
嗯,其实我之前在做一个需要频繁读写和清理缓存的 Android 项目时就发现,明明我把几千条过期的埋点数据都删了,但 .db 文件还是好几兆,一点没缩水。
这是为什么呢?因为 SQLite 为了追求性能,它的底层存储是按 页(Page) 来管理的。当你执行 DELETE 语句时,SQLite 只是把这些数据所在的页面标记为“已释放”,然后把它们扔进一个叫 Free List 的链表里。这时候,文件系统并不会回收这部分物理空间,而是留着等下次你插入新数据时直接复用这些页面。这样做的好处是避免了频繁向操作系统申请磁盘空间,降低了磁盘 I/O 的开销,毕竟磁盘操作是很慢的。
但问题在于,如果你的 App 经历了一次大规模的数据清理,比如清空了整个大表,这就会留下大量的“空洞”。如果不想让数据库文件白白占用用户的手机空间,其实有几个解决办法。
最直接的方法就是执行 VACUUM 命令。这个命令会创建一个全新的数据库文件,把原来数据库里所有的活跃数据(非空闲页面)重新拷贝过去,然后替换掉旧文件。执行完之后,你会发现文件大小瞬间就降下来了。
不过,这里有个坑我也踩过:VACUUM 是一个非常耗时的 重操作。它需要把数据全量拷贝一遍,而且在执行期间会持有排它锁,如果数据库很大,可能会导致 UI 卡顿。所以,我通常的做法是把 VACUUM 放在后台线程执行,或者在应用升级、低电量充电等特定场景下才去触发。
当然,SQLite 还有一个配置叫 AUTO_VACUUM。如果开启了它,数据库会在每次提交事务时自动清理空洞。虽然这看起来很省心,但它会带来额外的写入开销和内存抖动,而且还会导致索引碎片化。所以,在 Android 开发中,我个人还是倾向于根据业务逻辑,手动去把控 VACUUM 的时机。
数据库索引是怎么提高查询速度的,原理是什么?
关于数据库索引,其实它的核心原理就是 “空间换时间”。结论是:它通过维护一种特定的数据结构(通常是 B+Tree),将原本需要 时间复杂度的全表扫描,降低到了 的对数级别搜索。
其实我们可以把数据库索引类比成一本书的 目录。如果没有目录,我们要找某个知识点就得从第一页翻到最后一页;有了目录,我们只需要查一下对应的关键词在哪一页,直接跳转过去就行了。
在 SQLite 里,最常用的索引结构就是 B+Tree。它的设计非常精妙:
- 非叶子节点 只存储索引键(比如 ID 或者某个列的值)和指向子节点的指针。这样一页内存就能装下更多的索引项,让整棵树的高度变得非常低,通常也就 到 层。
- 叶子节点 则存储了实际的数据行(在聚集索引中)或者指向数据行的指针(在非聚集索引中)。而且,叶子节点之间是通过 双向链表 连接的。
为什么这种结构快呢?
首先,查询时我们可以通过二分查找快速定位。比如我要找 的数据,我从根节点开始比对,很快就能进到对应的子树,减少了大量的磁盘 I/O。
其次,B+Tree 的叶子节点有序性对 范围查询(Range Query) 特别友好。比如执行 SELECT * FROM users WHERE age > 20 AND age < 30,索引能帮我们定位到 的起点,然后顺着叶子节点的链表往后读就行了,完全不需要再去扫整张表。
当然,我在学习过程中也意识到,索引不是越多越好。
每建一个索引,其实都要额外占用磁盘空间。而且,由于 B+Tree 必须保持平衡,当你进行 INSERT、UPDATE 或 DELETE 时,数据库还得花精力去分裂或合并节点,这会显著 降低写入速度。
所以我在实际优化 Android 数据库时,一般会遵循几个原则:只给高频出现在 WHERE、JOIN 或者 ORDER BY 后的列加索引;对于区分度不高的列(比如性别,只有男女),加索引反而可能拖累性能;还有就是尽量利用 复合索引 的“最左匹配原则”,用一个索引解决多个查询场景。
除了 XML 解析还有没有看过其他三方库的源码?
除了 XML 解析相关的库,我其实花了不少时间去深度拆解 Glide 的源码。因为我觉得 Glide 不仅仅是一个图片加载库,它简直是 Android 性能优化和架构设计的典范。
我当时研究 Glide 的重点有两个:一个是它的 生命周期绑定,另一个是它的 多级缓存机制。
嗯,先说生命周期。我一直很好奇,为什么 Activity 销毁了,Glide 能自动停止加载?
翻了源码后发现,它用了一个很巧妙的黑科技:RequestManagerRetriever。当你调用 Glide.with(activity) 时,它会在你的 Activity 里偷偷添加一个看不见的 无 UI Fragment(叫 SupportRequestManagerFragment)。这个 Fragment 的生命周期是和 Activity 同步的。当 Fragment 触发 onStart 或 onDestroy 时,它会通过一个叫 Lifecycle 的接口回调通知 RequestManager 去启动或取消图片请求。这种设计在当时(Jetpack Lifecycle 还没普及时)真的非常惊艳。
再一个就是它的 缓存策略。Glide 的缓存分得很细:
- 活动资源缓存(Active Resources):它是用一个
HashMap配合 弱引用(WeakReference) 存储的,存的是当前正在显示的图片。这样能保证正在用的资源不被 GC 掉。 - 内存缓存(Memory Cache):默认用的是
LruResourceCache(LRU 算法)。 - 磁盘缓存(Disk Cache):它会把原始图和转换后的图(比如裁剪成特定尺寸后的图)都存下来,下次直接读对应的尺寸,效率极高。
我在看 DecodeJob 和 Engine 类的代码时感触很深,它把复杂的线程切换、位图池化(BitmapPool,通过复用内存减少频繁 GC)以及网络加载全部解耦开了。
另外,我也研究过 Retrofit。它的核心逻辑其实非常简洁,就是利用了 Java 的动态代理。通过 Proxy.newProxyInstance 拦截方法调用,然后解析方法上的注解(像 @GET、@POST),最后把这些信息封装成 OkHttp 的 Request。这种把“接口声明”转变为“实际网络操作”的思想,对我理解 AOP(面向切面编程)和解耦设计模式有很大的启发。
OkHttp 的架构是怎么设计的,有哪些印象深刻的设计,哪些觉得设计得不够好的?
OkHttp 的架构设计是我非常推崇的,它的核心其实就是一句话:“一切皆拦截器”。
它的整体架构像一个洋葱,最外层是我们的业务代码,最核心是真正的网络 I/O。它的核心组件是 RealCall 和 Dispatcher(调度器),而灵魂则是那 5 个核心拦截器(Interceptors) 构成的责任链(Chain)。
让我印象最深的设计有两点:
第一,是它的 责任链模式。
在 getResponseWithInterceptorChain 方法里,OkHttp 把重试、补全 Header、缓存、建立连接、读写数据这五个步骤,拆分成了独立的拦截器。比如 ConnectInterceptor 专门管连接,CallServerInterceptor 专门管读写。这种设计让每一个环节都极其专注。而且它支持自定义拦截器,这太好用了!我平时写项目,无论是加 Token、打印 Log 还是做统一的加解密,直接塞个 Interceptor 进去就行,完全不侵入业务。
第二,是它的 连接池(ConnectionPool)复用机制。
OkHttp 实现了 HTTP/2 的多路复用,并且在底层维护了一个 Socket 连接池。它通过 cleanupRunnable 自动清理闲置连接。这在移动端非常重要,因为建立 TCP 连接和 TLS 握手的成本很高。通过复用,OkHttp 能显著降低网络延迟。
至于觉得 设计得不够好 或者说比较复杂的地方:
我觉得 RealConnection 内部的逻辑过于臃肿了。它同时处理了 TCP 建立、TLS 握手、HTTP/1.1 和 HTTP/2 的协议协商。这部分代码里有很多状态位的判断,读起来逻辑链路非常长,维护起来其实挺有挑战性的。
另外,还有一个小槽点是 拦截器的顺序是硬编码的。虽然这种顺序是为了逻辑正确(比如必须先有 Header 才能发请求),但如果我想在两个系统拦截器之间强行插一个逻辑,目前的架构是非常难实现的。
还有就是 OkHttp 对 回调的处理。默认的 enqueue 回调是在子线程,如果我们想更新 UI,每次都要手动切回主线程。虽然在 Java 开发中这很正常,但在现在的 Kotlin 协程环境下,官方虽然提供了支持,但底层的异步逻辑和协程的结构化并发契合得还不算最完美,依然需要我们做一层封装。
总的来说,OkHttp 的设计思路——“通过高度解耦的拦截器把复杂的网络协议栈封装成简单的 Request/Response 模型”——是非常成功的,也是我面试和学习中经常参考的对象。
场景:微信朋友圈滑动卡顿,你会怎么分析?
如果遇到微信朋友圈这种复杂的列表滑动卡顿,我通常会遵循 “由表及里、先工具后源码” 的思路来排查。
首先,我会先定性。通过开发者选项里的 “GPU 呈现模式分析”(就是那个条形图)观察。如果蓝色的线(代表 draw 时间)很高,那可能是视图逻辑太重;如果红色的线(代表 process 时间)很高,通常是渲染压力或者 GPU 性能瓶颈。
嗯,接下来我会上专业的分析工具。首选是 Systrace 或者现在的 Perfetto。我会抓取滑动过程中的轨迹,重点看 UI Thread 和 RenderThread。我会去找那个红色的 F(Frame),看看究竟是哪个阶段超时了。
比如,我会点开 Choreographer#doFrame,看看里面的 input、animation、traversal(包含 measure、layout、draw)哪个占了大头。如果 traversal 时间特别长,那我会怀疑是布局嵌套太深,或者在 onDraw 里做了耗时操作。
然后,我会结合 Memory Profiler 观察内存波动。朋友圈滑动时会有大量的图片加载,如果看到内存曲线像锯齿一样频繁起伏,那很有可能发生了 内存抖动(Memory Churn),导致 GC 频繁触发,从而抢占了 CPU 时间片,引起掉帧。
最后,如果这些都没问题,我会进代码层排查。我会查一下在 getView(或者 onBindViewHolder)里是不是做了什么 I/O 操作、复杂的正则解析或者是大量的对象创建。
其实我还习惯用 Layout Inspector 扫一下视图树。微信这种复杂的列表,如果 ConstraintLayout 用得不当,或者有太多的 RelativeLayout 嵌套,会导致在滑动过程中触发多次重复测量,这在低端机上是非常致命的。
卡顿是由于什么引起的?
从底层原理上来说,卡顿的本质就是 “VSync 信号到来时,新的帧缓冲区(Buffer)还没准备好”。
在 Android 的显示系统中,屏幕每隔 ms(以 Hz 为例)就会发出一个 VSync 信号,提示系统该换下一帧了。如果因为某些原因,我们的 App 没能在信号到来前完成对下一帧数据的绘制,那么 SurfaceFlinger 就只能继续显示上一帧的旧内容,用户眼里的视觉感受就是“跳帧”了。
具体到技术细节,引起卡顿的原因主要有三个维度:
第一,是 主线程被阻塞(Main Thread Busy)。
这是最常见的。我们在主线程里写了耗时的逻辑,比如读写数据库、解析大型 JSON 或者是复杂的算法运算。由于 ActivityThread 的 Looper 必须按顺序处理消息,如果上一个消息处理太久,那 Choreographer 发出的绘图消息就得排队,导致 ms 内根本跑不完。
第二,是 UI 布局和渲染过于复杂(Overdraw & Complex Layout)。
如果布局嵌套太深,CPU 在执行 measure 和 layout 时会进行递归遍历,耗时随深度指数级增长。另外就是 过度绘制(Overdraw),如果 GPU 在同一个像素点上反复绘制了好几层(比如背景套背景),会超出 GPU 的填充率(Fill Rate)限制。
第三,是 内存压力导致的 GC(Garbage Collection)。 虽然现在的 ART 虚拟机对 GC 做了很多并行优化,但在堆内存不足或者频繁分配对象时,频繁触发的 GC 依然会抢占 CPU 资源,甚至在某些阶段会发生 Stop The World(停机检查),直接把所有线程挂起,这必然会导致 UI 渲染的中断。
图片加载过多为什么会引起卡顿(频繁 GC)?
图片加载是 Android 开发中的“内存大户”,它引起卡顿的核心原因在于 “短时间内大规模、高频率的对象分配与回收”,也就是我们常说的 内存抖动。
其实,每张 Bitmap 占用的空间都非常大。我们可以算一下,一张 的全屏图片,如果用 ARGB_8888 格式,它在内存里的大小是 MB。
当我们在快速滑动列表(比如朋友圈)时,系统会疯狂地创建这些大对象。 嗯,当大量 Bitmap 进入堆内存时,如果剩余空间不够,ART 虚拟机就会被迫触发 GC。虽然现在的并发 GC 优化的很好,但频繁的 GC 仍然有两个致命问题:
- CPU 竞争:GC 线程需要扫描堆内存、标记活跃对象,这会消耗大量的 CPU 周期。在滑动这种对 CPU 敏感的场景下,原本分配给 UI 渲染的 CPU 算力被 GC 抢走了,自然会掉帧。
- 内存碎片:频繁地分配和回收大对象会导致内存出现大量不连续的碎片。当又一张大图需要加载时,即使总内存够,但如果找不到连续的足够大的空间,系统会触发更加激进的 阻塞式同步 GC 来整理内存碎片,这会导致主线程直接挂起几十毫秒。
此外,早期的 Android 版本( 到 之前)Bitmap 的像素数据是放在 Java 堆里的,这更容易触顶。虽然 Android 以后把像素数据放到了 Native 堆,减轻了 Java 堆的压力,但频繁的对象分配依然会触发 Native GC 和 Java 侧的 Finalizer 机制,开销依然不小。
怎么解决频繁 GC 的卡顿问题?
解决频繁 GC 的核心思路就是四个字:“减量” 和 “复用”。
首先,最有效的手段是 “对象池复用”。
针对 Bitmap,我们要利用好 BitmapFactory.Options 里的 inBitmap 参数。这个参数可以让解码器去 复用 一块已经存在的内存区域,而不是去申请新的。
比如我在看 Glide 源码的时候,它内部实现了一个 BitmapPool。当一张图片从屏幕滑出后,Glide 不会把它销毁,而是把它丢进池子里。当下一张图片要进来时,如果尺寸和配置符合要求,就直接从池子里捞出一块内存覆盖掉,这样就几乎实现了“零内存分配”,GC 触发的频率自然就降下来了。
其次,是 针对图片进行合理的“采样减量”。
我们不能把一张 的原图直接塞进一个 的 ImageView 里。我会根据 View 的实际尺寸,计算出一个 inSampleSize。通过这种“下采样”技术,图片在内存中的占用会呈平方级下降。比如采样率设为 ,占用内存就变成了原来的 。
再者,就是 避免在频繁调用的地方创建对象。
比如,绝对不能在 onDraw 方法里 new 对象,哪怕是一个简单的 Paint 或者 Path。在列表滑动中,onBindViewHolder 也要尽量保持简洁。如果必须用到一些临时对象,我会考虑把它们做成类成员变量,实现复用。
最后,是 内存缓存的精细化管理。
使用 LruCache(最近最少使用算法)来管理内存中的 Bitmap,通过设置合理的 maxSize,防止内存无节制地膨胀。
如果是在 Framework 开发或者是处理大量原始数据的场景,我还会考虑使用 DirectByteBuffer 绕过 Java 堆,直接在 Native 层操作,这样能彻底避免 Java GC 对核心渲染流程的影响。
通过这几套组合拳,基本能把因内存问题导致的卡顿降到最低。
设计一个图片缓存库,会怎么设计(三级缓存)
如果要我从零设计一个图片缓存库,我会参考 Glide 的架构思想,核心目标就是 “快” 和 “稳”。结论是:我会采用 “活动资源 + 内存缓存 + 磁盘缓存” 的三级架构,并配合 Bitmap 池化复用 来压低内存抖动。
嗯,具体的流程大概是这样的:
第一级是 内存缓存。这里其实我会细分为两层。
首先是 “活动资源缓存(Active Resources)”。我会用一个 HashMap 配合 弱引用(WeakReference) 来存储当前正在屏幕上显示的图片。为什么要这么做呢?因为正在显示的图片不应该被 LRU 算法回收。
其次是传统的 LruCache。当图片从屏幕滑出,它会从活动资源进入 LruCache。通过 LruCache 的最近最少使用算法,保证我们在内存吃紧时能自动释放掉那些很久没用的图片。
第二级是 磁盘缓存。我会使用 DiskLruCache 把图片持久化到 SD 卡里。
这里有个细节:我会设计两种缓存策略。一种是 原始数据(Data),即直接存网络下载的二进制流;另一种是 处理后的资源(Resource),即按照 ImageView 尺寸裁剪、压缩后的图片。下次加载相同尺寸的图,直接读 Resource 缓存,速度飞快。
第三级就是 网络请求。我会封装 OkHttp 去下载图片。 当下完图片后,数据会像流水一样回填:先存磁盘,再进内存,最后显示。
其实除了这三级,我还会设计一个核心组件叫 BitmapPool。
在图片解码(decoding)阶段,我会尝试从池子里找一个 inBitmap 来复用内存。这样在滑动列表时,内存曲线会非常平稳,不会因为频繁创建和销毁 Bitmap 导致大量的 GC 卡顿。我觉得这才是高性能图片库的灵魂。
不同尺寸、分辨率的图片怎么标识(key 怎么设计)
这是一个很实用的问题。如果只用 URL 做 Key,那肯定是不够的。结论是:我们需要一个 “复合 Key”,把所有会影响最终位图生成的变量都封装进去。
其实我在看 Glide 源码时发现,它的 EngineKey 设计得非常严谨。如果由我来设计,这个 Key 至少要包含以下几个维度:
- 图片的来源标识:也就是最基本的 URL 或者文件路径。
- 目标尺寸:即
width和height。毕竟同一张图,在缩略图里和在大图预览里,它们在内存里的 Bitmap 大小是完全不一样的。 - 变换处理(Transformation):比如是否设置了
centerCrop、circleCrop或者圆角处理。 - 解码配置:比如是用
ARGB_8888还是RGB_565。 - 版本号/签名(Signature):如果服务端的图片内容变了但 URL 没变,我们需要通过一个额外的签名(比如时间戳)来强制刷新缓存。
嗯,具体的实现方式,我会把这些字段组合成一个对象,并重写它的 equals() 和 hashCode() 方法。
为了提高查找效率,我还会对这个复合 Key 进行 MD5 摘要算法 处理,生成一个唯一的字符串。这样在磁盘上存取文件时,文件名就不会包含特殊字符,而且长度固定,管理起来非常方便。
不同尺寸的同一张图片怎么关联起来
要把不同尺寸的同一张图关联起来,核心其实是为了实现 “资源复用” 和 “快速降级加载”。
我的设计思路主要有两种:
第一种是 “基于原始 URL 的关联映射”。 在磁盘缓存里,我会采用 “一源多稿” 的模式。我会为每一张图片维护一个主 ID(通常就是 URL 的 MD5)。
- Source 缓存:存储原始图,文件名为
MD5(URL)_source。 - Result 缓存:存储各种尺寸的处理图,文件名则加上尺寸后缀,比如
MD5(URL)_200x200。
这样,当我需要一个 的图而缓存里没有时,系统会先去查有没有 source 原图。如果有,直接拿原图在本地进行 Resize 处理,而不需要重新走网络请求。这大大节省了带宽。
第二种是 “向上取整的查找策略”。 这是一个比较进阶的优化。当请求一个 的图时,如果缓存里没有,但恰好有一个 的缓存图,其实我们可以直接拿这个大图去进行 采样和裁剪,甚至在某些性能优先的场景下,直接把大图压进小图的 View 里显示。
在代码实现上,我会维护一个简单的 索引表(Index/Manifest),记录每个 URL 对应已经生成了哪些尺寸的缓存。
其实我觉得,这种关联机制最难的点在于 清理策略。当我们要删除某个 URL 的缓存时,必须要能顺藤摸瓜,把这个 URL 对应的所有尺寸的 Resource 缓存全部清理干净。所以在设计 DiskLruCache 的时候,我会考虑把同一个 URL 的所有变体放在同一个子文件夹下,或者在 Key 的命名规则上保持前缀一致,方便批量操作。
内存缓存图片的淘汰思路
说到内存缓存的淘汰逻辑,其实最核心、最通用的思路就是 LRU(Least Recently Used,最近最少使用)算法。它的结论很简单:当内存达到预设的阈值时,优先剔除掉那些最久没有被访问过的图片。
嗯,在具体的工程实现上,我通常会直接用 Android SDK 提供的 LruCache 类,或者参考它的源码。它的底层其实是基于 LinkedHashMap 实现的。我记得在初始化 LinkedHashMap 的时候,要把 accessOrder 参数设为 true。这样每当我们通过 get 访问一张图片时,这个节点就会被移动到双向链表的尾部。
那么,淘汰的具体时机和策略是怎么做的呢?
首先是 主动淘汰。我会重写 LruCache 的 sizeOf 方法,按图片实际占用的内存大小(比如 字节)来计算。每当有一张新图存入时,系统会调用 trimToSize 方法。如果当前总内存超过了设定的 maxSize,它就会从链表的 头部(也就是最老的数据)开始,通过迭代器不断地执行 remove 操作,直到内存降到安全水位以下。
其次是 响应系统压力。在 Android 中,Activity 和 Application 都可以实现 ComponentCallbacks2 接口。我会监听 onTrimMemory(int level) 这个回调。
- 比如当 level 达到
TRIM_MEMORY_UI_HIDDEN(App 切到后台)时,我会主动释放掉一部分缓存。 - 如果系统报了
TRIM_MEMORY_COMPLETE或者TRIM_MEMORY_RUNNING_CRITICAL,这说明系统内存已经非常紧张了,这时候我会果断调用evictAll()清空内存缓存,避免 App 被系统直接杀掉(Low Memory Killer)。
最后,其实我还研究过 Glide 的二级内存缓存。它除了 LRU,还有一个 弱引用(WeakReference)池。正在被显示的图片会被从 LRU 缓存中移除,放到弱引用池里。只有当图片不再被任何 View 引用时,它才会重新回到 LRU 缓存。这种设计能确保“活跃”的图片绝对不会被 LRU 算法误杀,我觉得这是非常值得借鉴的。
图片在磁盘的存储思路
磁盘存储的设计比内存要复杂,因为它涉及到文件系统的性能、稳定性和检索效率。我的结论是:采用 “平滑哈希命名 + 日志文件管理 + 采样/转换版本分离” 的策略。
首先是 图片的命名。我绝对不会直接用 URL 做文件名,因为 URL 里包含特殊字符,而且长度不一,很容易超出操作系统的限制。我会对 URL 进行 MD5 或者 SHA-256 哈希处理,生成一个固定长度的字符串作为文件名。这样不仅安全,而且查找速度非常快。
至于 文件夹的命名和结构,我通常会分为两级:
- 根目录:放在
context.getCacheDir()下的自定义文件夹里,比如/cache/image_manager/。 - 二级分片(可选):如果图片数量级非常大(比如达到几万张),为了避免单个目录下文件过多导致 Linux 文件系统查找变慢,我会取哈希值的前两位作为子目录,比如
/cache/image_manager/a1/a1b2c3...。不过在现代 Android 的 F2FS 或者 EXT4 文件系统下,扁平化存储其实也能跑得很好。
再来说说 查找策略。我会把磁盘缓存分为两类 Key:
- Original Key:对应原始下载的图。
- Result Key:对应根据不同尺寸(width/height)和变换(比如圆角)处理后的缩略图。 查找时,我会先根据 Result Key 去找。如果没中,再去查 Original Key。如果 Original 命中了,就直接在本地做一次 Resize 转换,然后存入 Result 缓存。这样能极大减少重复的网络下载,对用户流量非常友好。
还有一个细节,就是 写入的原子性。我会参考 DiskLruCache 的实现,先写到一个 .tmp 临时文件。只有当下载和写入完全成功后,再通过 renameTo 改回正式文件名。如果写入中途崩溃了,下次启动时我会根据 journal(日志文件) 发现这个异常的临时文件并清理掉,防止读到损坏的图片数据。
图片在磁盘的淘汰策略
磁盘空间不是无限的,所以必须有一套严密的淘汰机制。我的核心思路是:“基于空间的定时 LRU 清理,配合高水位的‘直接砍半’策略”。
第一,是 基于空间的淘汰。
我会设置一个 磁盘配额(Disk Quota),比如 MB 或者设备剩余空间的 。在磁盘缓存初始化时,我会启动一个后台线程(使用 ScheduledExecutorService)去扫描目录。
在扫描过程中,我会读取每个文件的 lastModified(最后修改时间),并把它们按照时间戳排个序。如果总大小超标了,就从最旧的文件开始删。
第二,是 记录使用频率的方法。
单纯靠文件系统的 lastModified 有时候不准,因为有的系统不会在 read 时更新这个时间。所以我会维护一个简单的 journal 日志文件。每当一张图片被读取时,我会在日志里写下一行记录,比如 READ [key]。
当触发清理逻辑时,我会解析这个日志,通过内存中的计数器统计哪些图片是真正的“冷数据”,优先把它们干掉。
第三,是 关联删除策略。 这个挺关键的。如果我决定删除某张原始图片(Original),那么所有基于它生成的不同尺寸的缩略图(Result)其实也应该一并清理掉,否则就会产生大量无法被索引到的“孤儿文件”。我会通过 Key 的 前缀匹配(因为它们 MD5 前缀是一样的)来实现批量删除。
最后,是你说到的 “直接删一半空间” 的极端策略。 我通常会设置两个水位:高水位() 和 安全水位()。
- 当 App 发现磁盘占用达到 时,说明情况比较紧急了。为了避免频繁触发小规模的清理逻辑(这会带来大量的磁盘 I/O 竞争),我会执行一次 激进清理。
- 我会直接按照访问时间排序,一口气删掉最旧的那 的文件,腾出大块空间。 虽然这可能会降低一点缓存命中率,但在性能上它是最省电、最减少 I/O 损耗的做法。这种“大刀阔斧”的策略在处理视频缓存或者朋友圈这种信息流场景下非常有效。