牛客面经 - 我是蔬菜

链接:https://www.nowcoder.com/users/393594830


微派 安卓一面


低端机性能测试的具体操作流程是什么?

嗯,低端机性能测试的话,我一般会分几个步骤来做。

首先是选机器,我们会选一些 RAM 在 2-3G、CPU 比较弱的设备,像红米的入门款这种,然后把后台应用清干净,保证测试环境一致。

然后是确定测试场景,一般会覆盖冷启动、列表滑动、页面跳转这些核心路径。每个场景我会跑个 5-10 次取平均值,避免偶发因素影响。

采集数据的话,主要用 Perfetto 或者 Systrace 抓 trace 文件,看主线程的耗时分布。然后配合 adb shell dumpsys gfxinfo 看掉帧情况,用 dumpsys meminfo 看内存。如果要看启动时间,我会用 adb shell am start -W 拿 TotalTime。

最后就是分析和对比,把数据整理成表格,跟基线版本对比,看哪些指标劣化了,然后针对性地去 trace 里定位具体是哪个方法慢了。

其实整个流程核心就是:控制变量、多次采样、工具抓数据、定位瓶颈。


从性能分析报告中发现了哪些问题?提出了哪些优化策略?

嗯,我之前分析的时候发现了几类比较典型的问题。

第一个是主线程阻塞,在 trace 里能看到有些 IO 操作、SharedPreferences 的 commit、还有一些 JSON 解析直接跑在主线程,导致帧耗时飙高。这个优化策略就是把这些操作异步化,SP 用 apply 替代 commit,IO 放到子线程。

第二个是布局问题,用 Layout Inspector 看发现有些页面嵌套层级特别深,然后 GPU 呈现模式分析里过度绘制也比较严重。优化的话,我们用 ConstraintLayout 拍平层级,背景色能去掉的就去掉,用 ViewStub 做懒加载。

第三个是内存抖动,Profiler 里看到 GC 特别频繁,trace 里也有很多 GC_FOR_ALLOC。定位下来是列表滑动时频繁创建临时对象。这个就用对象池复用,还有把一些 lambda 提出来避免频繁 new。

第四个是启动阶段,任务堆得太多,其实很多不需要首屏就加载。我们做了启动任务分级,按优先级串行或并行执行,非必要的延迟到首帧之后。

总体来说就是:异步化、布局优化、内存复用、启动分级这几个方向。


安卓系统的 AOT 编译如何提升 APP 性能?后续针对该机制有什么优化思考?

好,AOT 编译的话,其实是 Android 5.0 引入 ART 虚拟机时带来的。

它的核心思路是安装时就把 dex 字节码编译成机器码,这样运行时就不需要像以前 Dalvik 那样解释执行或者 JIT 即时编译了,直接跑 native code,性能自然就上来了。启动速度、运行时的流畅度都会有提升。

不过嗯,纯 AOT 也有问题,就是安装时间变长、占用存储空间大。所以 Android 7.0 之后改成了混合模式,就是 JIT + AOT + Profile-guided 结合。刚安装时先用解释执行加 JIT,系统会在后台收集热点方法的 profile,然后趁设备充电空闲时做 AOT 编译,这样既保证了安装速度,后续运行也能享受 AOT 的好处。

后续优化思考的话,我觉得主要是利用好 Baseline Profile。就是我们可以在开发阶段就收集好关键路径的 profile 文件,打包进 APK,这样应用安装后系统能直接基于这个 profile 做 AOT 编译,不用等后台慢慢收集了。Google 官方也推荐这个做法,实测对冷启动和首次使用的流畅度提升还是挺明显的,尤其是在低端机上。

另外就是减小 dex 体积,用 R8 做好混淆和优化,代码越少编译压力越小。


JDK1.7 和 JDK1.8 中 HashMap 的底层实现有什么区别?

嗯,这个区别还挺大的。

JDK1.7 的话,底层是数组加链表的结构。put 的时候先算 hash 定位到数组下标,如果有冲突就用链表挂着。插入链表时用的是头插法,就是新节点直接插到链表头部。

JDK1.8 做了比较大的改进,变成了数组加链表加红黑树。主要是为了解决一个问题:当 hash 冲突严重的时候,链表会变得很长,查询效率从 O(1) 退化成 O(n)。所以 1.8 加了个机制——当链表长度超过 8,并且数组容量达到 64 的时候,就会把链表转成红黑树,查询效率变成 O(log n)。反过来,如果红黑树节点数缩减到 6 以下,又会退化回链表。

另外,1.8 的链表插入改成了尾插法,这个改动其实是为了避免多线程扩容时的一些问题,后面讲线程安全会提到。

还有就是 hash 算法也优化了,1.8 更简洁,用高 16 位异或低 16 位来扰动,减少碰撞。


HashMap 存在哪些线程安全问题?如何解决?

HashMap 本身是线程不安全的,主要有几个问题。

第一个是数据覆盖,比如两个线程同时 put,都判断某个位置是空的,然后都往里写,后写的就把先写的覆盖掉了,数据就丢了。

第二个是 1.7 特有的死循环问题。因为 1.7 扩容的时候用头插法迁移链表,多线程并发扩容时,可能会形成环形链表。之后再 get 的时候就会死循环,CPU 飙到 100%。1.8 改成尾插法之后,这个问题就没了,但数据覆盖的问题还是存在的。

怎么解决呢? 有几个方案:

第一个是用 Collections.synchronizedMap() 包装一下,但它本质是给每个方法加 synchronized,锁粒度太粗,性能不好。

第二个是用 Hashtable,但它更老,也是全表锁,基本不推荐。

最推荐的是 ConcurrentHashMap,它专门为并发设计的,锁粒度细,性能好,我们项目里基本都用这个。


ConcurrentHashMap 在 JDK1.7 和 JDK1.8 中的线程安全实现方式有什么区别?

这个变化还是挺大的。

JDK1.7 用的是分段锁的设计,就是 Segment。整个 map 分成多个 Segment,每个 Segment 继承自 ReentrantLock,内部是一个小的 HashEntry 数组。访问不同 Segment 的数据可以并行,只有访问同一个 Segment 才需要竞争锁。默认是 16 个 Segment,也就是最多支持 16 个线程并发写。

JDK1.8 把分段锁去掉了,改成了 CAS 加 synchronized,而且锁的粒度更细了,细到单个 Node 节点。put 的时候,如果数组位置是空的,直接 CAS 写入;如果已经有节点了,就用 synchronized 锁住这个链表或红黑树的头节点,然后再操作。

这样一来,并发度就不再受 Segment 数量限制了,有多少个桶位就能支持多少并发,比 1.7 高很多。

另外结构上 1.8 也跟 HashMap 一样,引入了红黑树来优化长链表的查询效率。整体来说 1.8 的设计更现代,锁竞争更少,性能更好。


Activity 的生命周期包含哪些方法?各自的触发时机是什么?

好,Activity 生命周期主要有 7 个回调方法。

首先是 onCreate,Activity 被创建的时候调用,一般在这里做初始化,setContentView、findViewById 这些。

然后是 onStart,这时候 Activity 已经可见了,但还不能交互。

接着 onResume,Activity 来到前台,可见且可交互,用户可以正常操作了。

然后是 onPause,Activity 失去焦点了,可能是被另一个 Activity 部分遮挡,或者要跳转到别的页面。这个方法要快,不能做耗时操作。

onStop 是 Activity 完全不可见的时候调用,比如跳到另一个全屏页面,或者按了 Home 键。

onDestroy 是 Activity 被销毁时调用,做一些资源释放。

还有个 onRestart,是从 stopped 状态重新回到前台时调用,在 onStart 之前。

整个流程大概是:onCreate → onStart → onResume,正常运行;然后 onPause → onStop,退到后台;如果再回来就是 onRestart → onStart → onResume。


onPause 和 onStop 方法的区别是什么?

嗯,这两个的核心区别在于 Activity 是否还可见

onPause 是 Activity 失去焦点可能还部分可见的时候调用。比如弹出一个透明的 Activity,或者一个 Dialog 主题的 Activity,底下的 Activity 能看到一部分,这时候就只会走 onPause,不会走 onStop。还有多窗口模式下,另一个窗口获得焦点,当前 Activity 也是走 onPause。

onStop 是 Activity 完全不可见的时候才会调用。比如跳转到一个普通的全屏 Activity,或者用户按了 Home 键回到桌面。

另外从源码角度说,AMS 那边会判断新 Activity 是不是全屏的,如果是 fullscreen,旧 Activity 才会收到 stop;如果新 Activity 是透明或者 dialog 主题,旧的就只收到 pause。

还有一点,onPause 里面不能做耗时操作,因为新 Activity 要等 onPause 执行完才能继续创建。


从 Activity A 跳转到 Activity B 时,A 和 B 的生命周期调用顺序是怎样的?

这个顺序是这样的:

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

嗯,为什么是这个顺序呢?其实核心思想是先让旧 Activity 让出焦点,再启动新的

首先 A 会 onPause,进入暂停状态。然后系统才去创建 B,走 B 的 onCreate、onStart、onResume,让 B 完全可见可交互。最后,A 才会走 onStop。

为什么 A.onStop 要等 B 完全起来之后呢?其实是为了保证用户体验,如果 B 启动过程中出了问题,A 还在 paused 状态可以快速恢复。而且从可见性角度,只有 B 真正显示出来了,A 才是"完全不可见"的,这时候走 onStop 才合理。

所以这也是为什么 onPause 里不能做耗时操作的原因——它会阻塞新 Activity 的启动,用户会感觉卡顿。


如果 Activity B 是透明的,Activity A 的生命周期会有什么变化?

如果 B 是透明的,A 的生命周期不会走 onStop,只会走到 onPause

因为透明 Activity 不会完全遮挡底下的 Activity,A 虽然失去焦点了,但它其实还是部分可见的。所以按照生命周期的定义,A 只是 paused 状态,不是 stopped。

调用顺序就变成了:A.onPause → B.onCreate → B.onStart → B.onResume,没有 A.onStop 了。

等 B finish 回来的时候,A 直接从 onPause 恢复,走 A.onResume,也不会走 onRestart 和 onStart。

这个在实际开发中还挺重要的,比如做一些引导遮罩、半透明弹窗页面的时候,要注意底层 Activity 的状态处理。如果你在 onStop 里释放了某些资源,透明场景下就不会执行,可能导致预期外的问题。

从源码角度,AMS 在 ActivityRecord 里会判断 fullscreen 属性,透明 Activity 这个值是 false,所以底层 Activity 不会被标记为 stopped。


安卓 Activity 的四种启动模式分别是什么?各自的适用场景是什么?

好,四种启动模式分别是 standard、singleTop、singleTask、singleInstance

standard 是默认模式,每次启动都会创建一个新的实例,放到当前任务栈的栈顶。大部分普通页面用这个就行。

singleTop 是栈顶复用,如果要启动的 Activity 已经在栈顶了,就不会创建新实例,而是调用它的 onNewIntent。适用场景比如通知栏点击跳转,用户可能连点好几下,用 singleTop 可以避免重复创建多个相同页面。

singleTask 是栈内复用,如果任务栈里已经有这个 Activity 的实例,就会把它上面的 Activity 全部出栈,然后复用它,走 onNewIntent。典型的场景是首页 MainActivity,从任何地方跳回首页都只保留一个实例,上面的页面都清掉。

singleInstance 是单实例模式,这个 Activity 会独占一个任务栈,全局只有一个实例。适合那种需要跟其他应用共享的页面,比如系统的来电界面、或者一些全局唯一的悬浮窗 Activity。

设置的话可以在 AndroidManifest 里用 launchMode 属性,也可以在 Intent 里加 FLAG 动态控制,比如 FLAG_ACTIVITY_SINGLE_TOPFLAG_ACTIVITY_CLEAR_TOP 这些。


安卓事件分发机制的流程是什么?

嗯,事件分发的核心是三个方法dispatchTouchEventonInterceptTouchEventonTouchEvent

整体流程是从上往下分发,从下往上处理

事件首先到达 Activity,Activity 的 dispatchTouchEvent 会把事件交给 Window,然后传到 DecorView,接着是 ViewGroup,最后到 View

ViewGroup 在 dispatchTouchEvent 里会先调用 onInterceptTouchEvent 判断要不要拦截。如果返回 true 就拦截,自己的 onTouchEvent 处理;返回 false 就继续往下传给子 View。

子 View 收到事件后,在 onTouchEvent 里决定消不消费。返回 true 就消费了,事件到此结束;返回 false 就往上抛,给父 View 的 onTouchEvent 处理,一层层往上,最终可能回到 Activity。

有个重要的点是,DOWN 事件决定了后续事件的走向。如果某个 View 在 DOWN 时消费了事件,后续的 MOVE、UP 都会直接给它,不会再走分发流程。如果 DOWN 没人消费,后续事件也不会再传了。

源码里核心逻辑在 ViewGroup.dispatchTouchEvent,会维护一个 mFirstTouchTarget 来记录消费事件的子 View。


如何解决滑动冲突问题?

滑动冲突的话,主要有两种解决思路:外部拦截法内部拦截法

外部拦截法是在父容器里处理,重写父 View 的 onInterceptTouchEvent。思路是 DOWN 事件不拦截,让子 View 能收到;MOVE 的时候根据滑动方向或距离判断,如果需要父容器处理就返回 true 拦截,否则返回 false 让子 View 继续处理;UP 事件一般不拦截。

比如外层是横向滑动的 ViewPager,里面是纵向滑动的 RecyclerView,在父容器判断如果是横向滑动就拦截,纵向就不拦截。

内部拦截法是在子 View 里处理,配合 requestDisallowInterceptTouchEvent 这个方法。子 View 在 dispatchTouchEvent 里,DOWN 的时候调用 parent.requestDisallowInterceptTouchEvent(true),让父容器不要拦截;MOVE 时根据情况决定要不要把拦截权还给父容器。父容器的 onInterceptTouchEvent 对 DOWN 返回 false,其他情况返回 true。

实际开发中外部拦截法用得更多,逻辑更清晰,也更符合事件分发的设计思想。内部拦截法稍微绕一点,但在某些子 View 需要更多控制权的场景也挺有用的。

关键还是要明确冲突的场景是什么,是同向滑动还是异向滑动,然后选合适的策略。


TCP 和 UDP 的区别是什么?

嗯,TCP 和 UDP 主要有这么几个区别。

首先是连接方式,TCP 是面向连接的,通信前要三次握手建立连接,结束时四次挥手断开;UDP 是无连接的,直接发数据就行,不需要建立连接。

然后是可靠性,TCP 是可靠传输,有确认机制、重传机制、排序这些,保证数据不丢、不乱、不重复;UDP 是不可靠的,发出去就不管了,可能丢包、可能乱序。

传输效率方面,UDP 更快,因为没有那些复杂的控制机制,头部开销也小,只有 8 字节;TCP 头部至少 20 字节,加上各种控制逻辑,延迟会高一些。

还有传输方式,TCP 是面向字节流的,没有边界概念;UDP 是面向报文的,一个发一个收,保留边界。

适用场景的话,TCP 适合对可靠性要求高的,比如 HTTP、文件传输、邮件;UDP 适合实时性要求高但允许少量丢包的,比如视频直播、语音通话、游戏。


TCP 保证数据传输可靠性的策略有哪些?

TCP 保证可靠性的机制还挺多的。

第一个是校验和,发送方计算数据的校验和放到头部,接收方收到后重新计算比对,不一致就丢弃。

第二个是序列号和确认应答,每个字节都有序列号,接收方收到后返回 ACK 确认,告诉发送方下一个期望收到的序列号。发送方知道哪些数据对方收到了。

第三个是超时重传,发送数据后启动定时器,如果超时没收到 ACK,就重传。这个超时时间是动态计算的,叫 RTO,会根据网络状况调整。

第四个是滑动窗口,控制发送速率,不用每发一个包就等 ACK,可以连续发窗口大小内的数据,提高效率的同时也控制流量。

第五个是流量控制,接收方通过窗口字段告诉发送方自己还能接收多少数据,避免发太快把接收方缓冲区撑爆。

第六个是拥塞控制,慢启动、拥塞避免、快重传、快恢复这些算法,根据网络拥塞程度动态调整发送速率,避免把网络打挂。

这些机制组合起来,就能保证数据可靠、有序、不重复地到达。


快手实习中 "破解网络" 具体是指什么问题?如何解决的?

嗯,这个"破解网络"其实是我们当时遇到的一个弱网环境下请求失败率高的问题。

具体表现是,在一些网络质量差的场景,比如地铁、电梯,用户的请求经常超时或者失败,体验很差。

排查下来主要有几个原因:一个是超时时间设置不合理,弱网下 RTT 本来就高,超时设太短就容易失败;另一个是没有合理的重试策略,失败了就直接报错;还有就是DNS 解析慢,有时候 DNS 就要好几秒。

解决方案的话,首先是优化超时策略,根据网络类型动态调整,WiFi 下短一点,移动网络下适当放宽。

然后加了重试机制,但不是简单的立即重试,而是用指数退避,避免打爆服务器。

DNS 这块用了 HTTPDNS,绕过运营商的 LocalDNS,解析更快也更准。

还有就是接入了网络质量监控,实时上报成功率、延迟这些指标,方便后续持续优化。

上线后弱网场景的请求成功率提升了大概 15% 左右,用户体验明显好了。


字节安全中台 安卓一面


快手实习中逆向反编译竞品源码的具体工具和流程是什么?

嗯,逆向分析竞品的话,我们主要用这么几个工具。

首先是 jadx,这个是最常用的,可以直接把 APK 反编译成 Java 代码,GUI 界面看起来也方便,支持搜索、跳转,体验挺好的。

apktool 用来反编译资源文件,拿到 AndroidManifest、res 目录这些,看布局结构、字符串资源什么的。

如果要分析 so 库,就用 IDA Pro 或者 Ghidra,做 native 层的逆向。

具体流程的话,第一步先拿到 APK,可以从应用市场下载或者 adb pull

第二步用 jadx 打开,先看 AndroidManifest,了解整体结构,有哪些组件、权限、入口 Activity 这些。

第三步根据分析目标定位关键代码。比如想看某个功能怎么实现的,可以通过字符串搜索、关键 API 调用来定位。如果代码被混淆了,就得根据调用链、特征慢慢追。

第四步如果有 native 层逻辑,就把 so 拖到 IDA 里分析。

不过说实话,现在大部分竞品都加固了,直接反编译看到的是壳的代码,还得先脱壳才行。


APP 加固的原理是什么?常见的加固厂商有哪些?

APP 加固的核心原理是隐藏和保护原始的 dex 文件,让逆向分析变得困难。

最基本的是 dex 加密,把原始 dex 加密后藏起来,运行时再解密加载。壳程序会替换原来的 Application,在 attachBaseContext 里解密 dex,然后通过反射或者 DexClassLoader 加载真正的代码。

进阶一点的有 dex 抽取,把方法体从 dex 里抽出来,运行时再填回去,静态分析就看不到方法实现了。

更高级的有 VMP(虚拟机保护),把关键代码编译成自定义的字节码,用自己实现的解释器执行,这种逆向难度就很大了。

还有 so 加固,对 native 库做加密、混淆、反调试。

常见的加固厂商的话,360 加固保 用得挺多的,免费版就够用;腾讯乐固 也很常见;梆梆安全爱加密 是比较老牌的;网易易盾 这几年也不错。大厂的话,像字节、阿里基本都是自研加固方案。

选加固方案主要看安全等级、兼容性、对性能的影响这几个维度。


如何对加壳的 APP 进行脱壳?

脱壳的思路主要有几种。

第一种是内存 dump,这是最通用的思路。因为不管怎么加密,运行时 dex 肯定要解密加载到内存里,所以可以在合适的时机把内存里的 dex dump 出来。

常用的工具比如 Frida,可以 hook DexFile 相关的函数,在 dex 加载的时候把它 dump 下来。还有 FDex2DexDump 这些现成的脱壳工具,原理差不多。

第二种是利用系统机制,Android 有个 dex2oat 的过程,可以 hook 这个流程拿到 dex。或者利用 /proc/pid/maps 找到 dex 在内存中的位置,直接读出来。

第三种是 hook 关键函数,比如 hook ClassLoader.loadClassDexFile.loadDex 这些,追踪 dex 的加载过程。

具体操作的话,一般是 root 的手机或者模拟器,装上 Frida server,然后写脚本 hook。比如用 Frida 的 dex-dump 脚本,attach 到目标进程,等它自己解密加载完,dex 就 dump 出来了。

不过要注意,现在很多加固有反 Frida、反调试的检测,可能还得先过掉这些检测。高级的 VMP 加固即使脱壳了,核心逻辑也还是被虚拟化的,分析起来还是很难。


代码混淆的常见手段有哪些?

嗯,代码混淆的手段主要有这么几类。

第一个是标识符混淆,就是把类名、方法名、变量名改成无意义的短字符,比如 a、b、c 这种。这是最基本的,ProGuard 默认就做这个。逆向的时候看到一堆 a.b.c.d() 调用,可读性就很差了。

第二个是控制流混淆,打乱代码的执行逻辑,插入一些虚假分支、不透明谓词,或者把顺序执行的代码改成 switch-case 状态机。静态分析的时候很难理清真正的执行流程。

第三个是字符串加密,把代码里的明文字符串加密存储,运行时再解密。这样逆向的时候搜关键字符串就搜不到了。

第四个是调用隐藏,用反射替代直接调用,或者把敏感逻辑放到 native 层。

第五个是花指令,主要针对 native 代码,插入一些干扰反汇编器的垃圾指令。

还有资源混淆,比如微信的 AndResGuard,把资源路径和名称也混淆掉,减小包体积的同时也增加逆向难度。

实际项目里一般会组合使用,光靠一种效果有限。


ProGuard 工具的混淆逻辑是什么?

ProGuard 主要做四件事:shrink、optimize、obfuscate、preverify

Shrink 压缩,就是删除没用到的类、方法、字段。它会从 entry point 开始分析,比如 Activity、Service 这些在 Manifest 里声明的组件,然后追踪引用链,没被引用到的代码就删掉。这一步能减小包体积。

Optimize 优化,做一些字节码级别的优化,比如内联短方法、删除无用参数、简化控制流这些。Android 里一般不太开这个,因为可能有兼容性问题。

Obfuscate 混淆,这是核心功能,把类名、方法名、字段名重命名成 a、b、c 这种短名字。它会生成一个 mapping 文件,记录混淆前后的对应关系,后面分析崩溃堆栈的时候需要用这个文件还原。

Preverify 预校验,主要是给 Java ME 用的,Android 不需要。

配置的话,通过 proguard-rules.pro 文件写 keep 规则,告诉它哪些不能混淆,比如反射调用的类、JNI 方法、序列化相关的。写规则的时候要小心,keep 太多混淆效果差,keep 太少运行时会崩。

现在 Android 官方推荐用 R8,是 ProGuard 的替代品,跟 D8 集成在一起,效果更好。


反编译后的代码如何阅读和分析?

反编译后的代码,尤其是混淆过的,确实比较难读,我一般有这么几个方法。

首先是找入口点,从 AndroidManifest 看主 Activity 是哪个,然后从 onCreate 开始往下追。如果是分析某个具体功能,可以先操作 APP 触发那个功能,用 adb shell dumpsys activity top 看当前是哪个 Activity。

然后是搜索特征,比如搜字符串、搜 URL、搜特定的 API 调用。虽然代码被混淆了,但是字符串资源、系统 API 名称是改不了的。jadx 的全局搜索很好用。

看交叉引用也很重要,jadx 里右键可以看谁调用了这个方法、这个方法调用了谁,顺着调用链追。

动静结合,光看静态代码有时候看不明白,可以配合 Frida hook 一下,打印参数和返回值,看运行时实际的数据流。

重命名和加注释,jadx 支持给混淆的类名、方法名重命名,分析到哪就标记到哪,不然看着一堆 a、b、c 很容易乱。

关注关键类,像 okhttp3retrofit2SharedPreferences 这些常用库,虽然业务代码混淆了,但库的调用模式是固定的,可以通过这些定位网络请求、数据存储的地方。


动态 Hook 的具体实现步骤是什么?

动态 Hook 的话,我主要用 Frida,步骤大概是这样。

第一步,环境准备。手机要 root,或者用模拟器。然后把 frida-server push 到手机上,adb push frida-server /data/local/tmp/,加执行权限,然后运行起来。

第二步,确定 hook 目标。通过静态分析先找到要 hook 的类名和方法名。如果是混淆过的,就得先定位,比如搜字符串特征。

第三步,写 Frida 脚本。用 JavaScript 写,基本模板就是 Java.perform 里面 Java.use 拿到类,然后重写目标方法的 implementation。可以打印参数、修改返回值、调用原方法。

Javascript
Java.perform(function() {
    var targetClass = Java.use("com.example.a.b");
    targetClass.c.implementation = function(arg) {
        console.log("arg: " + arg);
        var result = this.c(arg);
        console.log("result: " + result);
        return result;
    };
});

第四步,注入执行。用 frida -U -f 包名 -l script.js 启动 APP 并注入,或者 frida -U 包名 -l script.js attach 到已运行的进程。

第五步,观察输出,根据打印的日志分析逻辑,必要时调整脚本。

如果目标有反 Frida 检测,可能还要先过检测,比如 hook fopen 过掉对 /proc/self/maps 的检测。


面对异步回调的代码,如何溯源找到关键函数?

异步回调确实是逆向分析的一个难点,调用链断掉了,不好追。我一般这么处理。

第一个方法是找回调注册的地方。异步操作一般会有个 listener 或者 callback 参数,我会从回调接口的实现类往回追,看它是在哪里被注册的。jadx 里看交叉引用,找谁创建了这个 callback 对象。

第二个是搜索接口定义。比如看到一个 onSuccess(String data) 的回调,我会搜这个接口的定义,然后看它有哪些实现类、在哪里被调用。

第三个是 hook 关键节点。如果静态分析追不动,就动态 hook。比如 hook 网络请求的回调,像 OkHttp 的 Callback.onResponse;或者 hook Handler 的 handleMessageRunnable.run 这些。打印堆栈 Log.getStackTraceString(new Throwable()),就能看到完整的调用链了。

第四个是 hook 线程切换。很多异步是通过 Handler、RxJava、协程实现的,可以 hook Handler.postObservable.subscribe 这些,观察任务是从哪里提交的。

第五个是利用 Frida 的 backtrace。hook 到目标方法后,用 Thread.currentThread().getStackTrace() 打印调用栈,虽然异步场景下栈可能不完整,但结合多个点的信息也能拼出来。

关键是要理解目标 APP 用的异步框架是什么,然后针对性地 hook 那个框架的关键方法。


HTTP 和 HTTPS 协议的区别是什么?

嗯,HTTP 和 HTTPS 主要有这么几个区别。

第一个是安全性,HTTP 是明文传输的,数据在网络上裸奔,谁都能看到;HTTPS 在 HTTP 基础上加了一层 TLS/SSL 加密,数据是加密传输的,中间人看到的是密文。

第二个是端口,HTTP 默认用 80 端口,HTTPS 默认用 443 端口。

第三个是证书,HTTPS 需要服务器配置 CA 证书,客户端会验证证书的合法性,确保连的是真正的服务器而不是假冒的。HTTP 没有这个机制。

第四个是连接过程,HTTPS 在 TCP 三次握手之后还要进行 TLS 握手,协商加密算法、交换密钥,所以建立连接会慢一点,也会多一些开销。

第五个是成本,以前 HTTPS 证书要花钱买,现在 Let's Encrypt 这种免费证书普及了,成本基本不是问题了。

现在基本上正经的网站和 APP 都用 HTTPS 了,HTTP 越来越少。Android 9 开始默认禁止明文 HTTP 请求,得专门配置才能用。


HTTPS 使用的加密方式是什么,对称加密和非对称加密如何结合使用?

HTTPS 用的是混合加密,结合了对称加密和非对称加密的优点。

为什么要结合呢?因为非对称加密安全但是慢,像 RSA 这种,加解密计算量大,不适合加密大量数据;对称加密快但是密钥分发难,双方怎么安全地拿到同一个密钥是个问题。

所以 HTTPS 的思路是:用非对称加密来安全地交换对称密钥,然后用对称密钥加密实际的通信数据

具体流程大概是这样:TLS 握手阶段,客户端拿到服务器的公钥(在证书里),然后生成一个随机的 pre-master secret,用公钥加密发给服务器。服务器用私钥解密拿到这个值。然后双方用这个 pre-master secret 加上之前交换的随机数,各自计算出相同的 session key,这就是后续通信用的对称密钥。

握手完成后,实际传输数据就用这个 session key 做 AES 之类的对称加密,速度快,效率高。

所以总结就是:非对称加密解决密钥交换问题,对称加密解决数据传输效率问题,各取所长。


HTTPS 如何保证通信的服务器是目标服务器,而非中间人?

这个主要靠 CA 证书体系来保证。

服务器要用 HTTPS,首先要向 CA(证书颁发机构) 申请证书。CA 会验证你确实拥有这个域名,然后用 CA 自己的私钥对服务器的公钥和域名等信息进行签名,生成证书。

客户端连接服务器时,服务器会把证书发过来。客户端怎么验证这个证书是真的呢?操作系统和浏览器里预装了受信任的 CA 根证书,客户端用 CA 的公钥验证证书上的签名。如果签名对得上,说明这个证书确实是 CA 签发的,没被篡改。

然后客户端还会检查证书里的域名跟当前访问的域名是否匹配,检查证书有没有过期,有没有被吊销

中间人为什么没法伪造呢?因为他没有 CA 的私钥,没法生成合法签名的证书。他要是用自签名证书,客户端验证签名就过不了,会报证书错误。

当然,如果用户手动信任了中间人的证书,或者设备被装了恶意根证书,那就另说了。这也是为什么手机抓包要先装 Charles 或 Fiddler 的证书。


如何破解抖音的 HTTPS 证书校验,成功抓包?

抖音这种大厂 APP 都做了 SSL Pinning,就是证书绑定,光装抓包工具的证书是不够的。

普通 APP 抓包的话,手机装上 Charles 或者 Fiddler 的根证书就行了。但抖音在代码里写死了信任的证书指纹,发现证书不是预期的就拒绝连接。

破解的方法主要有几种:

第一种是用 Frida hook,这是最通用的。hook OkHttp 的 CertificatePinner 类,让 check 方法直接返回,跳过校验。或者 hook 更底层的 TrustManager,让 checkServerTrusted 方法空实现。网上有现成的脚本,比如 objection 工具的 android sslpinning disable 命令,一键过掉大部分 SSL Pinning。

第二种是用 Xposed 模块,比如 JustTrustMeTrustMeAlready 这些,原理差不多,hook 各种网络库的证书校验逻辑。

第三种是修改 APK,反编译后找到证书校验的代码,patch 掉再重打包。但抖音有签名校验,改完可能跑不起来,还得过签名校验。

第四种是用特定的抓包工具,比如 HttpCanary 配合平行空间,或者 r0capture 这种专门针对 SSL Pinning 的工具。

我之前实操用的是 Frida 方案,root 手机装好 frida-server,跑个通用的 SSL Pinning bypass 脚本,然后 Charles 就能正常抓到包了。


TCP 和 UDP 的区别是什么?各自的使用场景有哪些?

嗯,TCP 和 UDP 的核心区别其实就是可靠性和效率的取舍

TCP 是面向连接的,要三次握手建立连接,有确认重传、流量控制、拥塞控制这些机制,保证数据可靠有序到达,但开销也大,延迟高一些。

UDP 是无连接的,发出去就不管了,没有这些控制机制,可能丢包、乱序,但胜在,头部开销也小。

使用场景的话:

TCP 适合对可靠性要求高的场景,比如 HTTP/HTTPS 网页请求、文件传输 FTP、邮件 SMTP、数据库连接这些,丢一个字节都不行的。

UDP 适合实时性要求高、能容忍少量丢包的场景。典型的像视频直播、语音通话,丢几帧问题不大,但卡顿用户受不了;游戏里的实时状态同步,要的就是快;DNS 查询也是 UDP,一问一答很简单。

还有一个趋势是,现在有些场景在 UDP 之上自己实现可靠性,比如 QUIC 协议、还有一些游戏用的 KCP,既要 UDP 的速度又要一定的可靠性保证。


HTTP 不同版本(1.0、1.1、2.0、3.0)的核心区别是什么?

好,我按版本来说。

HTTP 1.0 最大的问题是短连接,每个请求都要新建 TCP 连接,请求完就断开,开销很大。

HTTP 1.1 引入了持久连接,就是 keep-alive,一个 TCP 连接可以发多个请求,不用反复握手。但它有个问题叫队头阻塞,请求必须排队,前一个响应没回来,后面的就得等着。虽然有 pipeline 机制,但实际用得少,因为服务器响应还是要按顺序。

HTTP 2.0 改进很大。首先是二进制分帧,把数据拆成帧传输,不再是文本协议。然后支持多路复用,一个连接上可以同时跑多个请求响应,互不阻塞,解决了应用层的队头阻塞。还有头部压缩 HPACK,减少重复头部的传输。另外支持服务器推送,服务器可以主动推资源给客户端。

HTTP 3.0 最大的变化是底层换成了 UDP,用的是 QUIC 协议。主要是为了解决 TCP 层的队头阻塞问题,还有就是连接建立更快,支持 0-RTT

总结就是:1.1 解决连接复用,2.0 解决应用层队头阻塞,3.0 解决传输层队头阻塞。


HTTP 3.0 为什么选择基于 UDP 协议,QUIC 协议的作用是什么?

嗯,HTTP 3.0 选择 UDP 主要是为了解决 TCP 的一些固有问题

第一个是 TCP 的队头阻塞。虽然 HTTP 2.0 在应用层做了多路复用,但 TCP 层是不知道的。TCP 只看到一个字节流,一旦某个包丢了,后面的包都得等重传,即使它们属于不同的 HTTP 请求。这在弱网环境下特别明显。

第二个是连接建立慢。TCP 要三次握手,再加上 TLS 握手,至少要 2-3 个 RTT 才能开始传数据。移动网络下 RTT 本来就高,这个延迟很可观。

第三个是协议僵化。TCP 是在内核里实现的,想改进非常难,要等操作系统更新,周期太长。

所以 Google 搞了 QUIC 协议,基于 UDP 在用户态实现。QUIC 的作用包括:

连接建立快,首次连接 1 个 RTT,后续连接甚至可以 0-RTT 直接发数据,因为会缓存之前的加密参数。

真正的多路复用,每个 stream 独立,一个 stream 丢包不影响其他 stream。

内置加密,QUIC 把 TLS 1.3 集成进去了,握手和加密一起做。

连接迁移,用 Connection ID 标识连接,不绑定 IP 和端口,WiFi 切 4G 连接不断。

用户态实现,迭代快,不依赖操作系统升级。


TCP 协议的逻辑在系统架构中是在哪一层实现的?

TCP 是在操作系统内核的传输层实现的。

从网络分层模型来看,TCP 属于 OSI 七层模型的第四层传输层,或者 TCP/IP 四层模型的传输层。它在网络层 IP 协议之上,应用层之下。

从代码实现角度,TCP 协议栈是在内核里的。Linux 的话,TCP 相关代码在 net/ipv4/tcp*.c 这些文件里。应用程序通过 Socket 系统调用跟内核交互,调用 socket()connect()send()recv() 这些接口,实际的 TCP 握手、重传、拥塞控制这些逻辑都在内核里跑。

为什么要放在内核呢?主要是性能和安全。内核处理网络数据效率高,而且可以统一管理端口、防止应用程序乱来。

但这也带来一个问题,就是协议演进慢。想改 TCP 的行为,得改内核,得等操作系统厂商发版本,周期太长。这也是为什么 QUIC 选择在用户态基于 UDP 实现类似 TCP 的功能,迭代快,不用等内核升级。

Android 里也是一样,TCP 在 Linux 内核实现,应用层通过 Java 的 Socket 或者 OkHttp 这些库间接调用。


个人项目 MyNews 新闻 APP 的 MVP 架构具体实现是怎样的?

好,MyNews 这个项目我是用 MVP 架构来做的,主要是为了解耦,让代码更好维护和测试。

Model 层负责数据相关的逻辑,包括网络请求和本地缓存。我用 Retrofit 封装了新闻 API 的请求,返回的数据用 Gson 解析成实体类。还有一个 Repository 类来统一管理数据来源,先查本地数据库有没有缓存,没有再走网络。

View 层就是 Activity 和 Fragment,只负责 UI 展示和用户交互。我定义了一个 NewsListContract.View 接口,里面有 showLoading()hideLoading()showNewsList(List<News>)showError(String msg) 这些方法。Activity 实现这个接口,Presenter 通过接口回调来更新 UI,这样 Presenter 就不依赖具体的 Activity 了。

Presenter 层是中间的桥梁,持有 View 和 Model 的引用。比如 NewsListPresenter,View 调用它的 loadNews() 方法,它就去调 Model 拿数据,拿到之后再调 View 的方法展示。业务逻辑都在这里,比如下拉刷新、加载更多的逻辑判断。

还有一点,为了防止内存泄漏,我在 Presenter 里用弱引用持有 View,并且在 onDestroy 的时候调用 detachView() 解绑。

这样分层之后,Presenter 的逻辑可以单独写单元测试,mock 一个 View 就行,不用跑真机。


Glide 图片加载库的三级缓存机制是什么?

嗯,Glide 的缓存机制其实是四级,不过通常说三级缓存一般指内存和磁盘这几层。

第一级是活动资源缓存,叫 Active Resources。这是正在被使用的图片,用一个弱引用的 HashMap 存着。如果一个图片正在某个 ImageView 上显示,就在这一层。好处是获取最快,而且不会被 LRU 算法回收掉正在用的图片。

第二级是内存缓存,Memory Cache,用的是 LruCache。当图片不再被使用了,就从活动资源移到这里。下次再加载同一张图,先查内存缓存,命中就直接用,不用再解码。

第三级是磁盘缓存,Disk Cache。Glide 的磁盘缓存策略还挺灵活的,可以缓存原始图片(DATA),也可以缓存处理后的图片(RESOURCE),或者都缓存(ALL)、都不缓存(NONE)。默认是 AUTOMATIC,会根据数据来源自动选择。处理后的图片缓存好处是下次加载不用再做缩放、变换这些操作。

第四级其实是网络,就是从服务器下载了。

加载流程就是:先查活动资源 → 再查内存缓存 → 再查磁盘缓存 → 最后走网络。这个是在 Engine.load() 方法里实现的,用责任链的方式依次查找。

另外 Glide 的 key 生成也很讲究,会把 URL、宽高、变换参数这些都算进去,保证不同配置的图片缓存不会冲突。


字节飞书 安卓三面


快手低端机性能优化专项中,你负责的具体工作是什么?提出了哪些优化策略?

好的,我来说一下这个项目。其实我当时主要负责的是启动链路和运行时的性能优化,重点针对的是内存小于等于4G的低端设备。

嗯,具体来说吧,我做了这么几件事。首先是启动优化这块,我们发现低端机上启动慢主要是因为JIT编译和类加载耗时长,所以我调研并落地了强制AOT编译的方案,通过Baseline Profile让热点代码在安装时就编译成机器码,这个对启动速度提升还是比较明显的。

然后是内存优化,低端机内存本来就紧张,我做了一些图片加载的降级策略,比如在低端机上限制图片的采样率、关闭一些动画效果。还有就是通过线上监控发现有一些内存泄漏的case,逐个修复了。

另外就是做了一套设备分级的框架,根据RAM、CPU核心数这些指标把设备分成高中低档,然后不同档位走不同的策略,比如低端机关闭预加载、减少并发线程数这些。

整体下来,低端机的启动时间大概优化了20%左右,OOM的crash率也降了不少。


安卓系统的 AOT 编译是什么时候执行的?

嗯,这个要分Android版本来说。

在Android 5.0到6.0的时候,ART刚引入,那时候是安装时全量AOT编译,就是用户安装APP的时候,系统会调用dex2oat把所有dex文件编译成oat文件。这个问题就是安装特别慢,而且占用存储空间大。

所以从Android 7.0开始,Google改成了混合编译策略。安装的时候基本不做AOT,或者只做很轻量的编译。然后运行时用JIT即时编译,同时会收集热点代码的Profile信息。关键点来了——系统会在设备空闲且充电的时候,有个BackgroundDexOptService后台服务,它会根据收集到的Profile去做AOT编译,只编译真正用到的热点方法。

其实从代码层面看,PackageManagerService在安装流程里会调用dex2oat,然后运行时的Profile收集是ART虚拟机在做,后台优化任务就是JobScheduler调度的。

所以现在的Android,AOT主要是在后台空闲时执行,而不是安装时。


强制 AOT 编译如何提升 APP 运行性能?

好,这个问题其实和我刚才说的项目经验是相关的。

核心原理就是:把运行时的编译开销提前到安装时。你想,正常情况下代码第一次执行是解释执行的,跑多了之后JIT才会编译,这个过程本身就消耗CPU和内存。强制AOT之后,热点代码直接就是机器码了,执行效率肯定更高。

具体的收益主要有几个方面。启动速度提升,因为启动路径上的代码不需要解释执行或者等JIT编译了,直接跑native code。然后是减少运行时卡顿,JIT编译本身会占用CPU,有时候会造成掉帧,AOT之后这个问题就没了。还有就是内存占用更稳定,JIT编译器本身是要占内存的。

实现方式的话,我们用的是Baseline Profile,就是在开发阶段通过跑benchmark收集热点方法,生成一个profile文件打到APK里,然后应用安装的时候系统识别到这个文件,就会对这些方法做AOT编译。这个是Google推荐的方案,从Android 9开始支持。

当然也可以用adb命令强制编译,cmd package compile -m speed -f 包名,但这个只能调试用,没法规模化。


Glide 图片加载库的三级缓存机制中,活动缓存和内存缓存的区别是什么?缓存 key 是如何计算的?

好,这个我比较熟,之前看过Glide的源码。

先说结论吧,活动缓存和内存缓存最大的区别在于引用方式和存储对象的状态。活动缓存用的是弱引用,存的是正在被使用的图片,就是当前有View在显示的那些;而内存缓存用的是LruCache,存的是暂时不用但可能很快会再用到的图片。

具体来说,当你加载一张图片,Glide会先查活动缓存,也就是ActiveResources这个类,它内部是一个HashMap存弱引用。如果找到了直接返回,引用计数加1。如果没找到就去查MemoryCache,找到之后会把它从内存缓存移到活动缓存里。这个设计的好处是,正在使用的图片不会被LRU算法给清掉。

然后说缓存key的计算,这个在Engine类的load方法里能看到。Glide用的是EngineKey这个类,它包含了很多参数:图片的URL或者model、宽高、各种transform变换、签名signature、还有Options配置等等。这些参数一起算hashcode,所以同一张图片不同尺寸会是不同的key。我觉得这个设计挺合理的,因为变换后的图片确实应该分开缓存。


快手智能音量调节需求的核心逻辑是什么?如何通过传感器数据替代麦克风权限获取环境音量?

嗯,这个需求的背景是这样的,我们希望APP能根据环境噪音自动调节播放音量,但是很多用户不愿意给麦克风权限,所以需要一个替代方案。

核心逻辑其实是建立一个环境场景识别模型,然后基于场景去推测噪音水平。

具体实现的话,我们用了多种传感器数据组合来判断。首先是加速度传感器和陀螺仪,可以判断用户的运动状态,比如静止、走路、跑步、坐车,这些场景的环境噪音是有明显差异的。然后是环境光传感器,可以大致判断室内外。还有气压传感器,配合变化趋势可以知道用户是不是在地铁里。

除了传感器,我们还会用一些上下文信息,比如当前时间、连接的WiFi或蓝牙设备、甚至是GPS位置的类型——商圈还是住宅区。

把这些特征收集起来,喂给一个轻量级的分类模型,预测当前属于哪种噪音场景,然后映射到预设的音量档位。当然精度肯定比不上直接用麦克风,但是作为fallback方案,用户体验还是比完全不调节要好。


计算音量分贝值的公式和关键步骤是什么?音频采样率、编码格式等参数的作用是什么?

好,这个涉及到音频基础知识。

分贝的计算公式是 dB = 20 × log₁₀(amplitude / reference)。这个amplitude一般我们取的是音频数据的RMS值,就是均方根,把采样点的值平方求和再开根号。reference是参考值,在AudioRecord里我们通常用最大振幅值作为参考,比如16位采样的话就是32767。

关键步骤的话,首先用AudioRecord去读取音频buffer,然后遍历buffer计算每个采样点的平方和,再除以采样点数,开根号得到RMS,最后套公式算分贝。代码大概就是一个循环加一个Math.log10。

然后说参数的作用。采样率决定每秒钟采多少个点,比如44100Hz就是CD音质,采样率越高频率响应越好,但数据量也越大。对于检测环境音量其实8000Hz就够了。

编码格式主要是PCM_16BIT还是PCM_8BIT,这个决定每个采样点用多少位表示,16位的动态范围更大,精度更高。

还有声道配置,一般检测音量用单声道MONO就行,省资源。

这些参数最终会影响到你buffer的大小计算,要用getMinBufferSize()这个API来获取最小buffer。


安卓 Activity 的生命周期有哪些关键钩子函数?onStart 和 onResume 的区别是什么?

好,这个是基础但很重要的知识点。

Activity的生命周期钩子主要有七个:onCreate、onStart、onResume、onPause、onStop、onDestroy,然后还有一个onRestart是从onStop状态回来时调用的。

onCreate是创建时调用,一般在这里做初始化、setContentView、绑定数据这些。onDestroy就是销毁时的清理工作。

然后重点说一下onStart和onResume的区别。嗯,其实核心区别在于可见性和可交互性。onStart被调用时,Activity已经可见了,但还不能交互,可能还在执行进入动画或者被Dialog遮挡着。而onResume被调用时,Activity不仅可见,而且已经获得焦点,用户可以正常点击操作了。

从源码角度看,这两个回调是在ActivityThreadhandleStartActivityhandleResumeActivity里分别触发的。onResume之后,DecorView才会被添加到WindowManager,这时候才真正能接收触摸事件。

实际开发中的话,onStart适合做一些可见时需要的准备工作,比如注册广播;onResume适合恢复动画、开始播放视频这类需要焦点的操作。对应的,onPause里暂停,onStop里注销。


如果 APP 耗电严重,如何排查和优化?

嗯,耗电问题其实挺复杂的,我一般会从几个维度去排查。

首先是定位问题。可以用Android Studio的Profiler看CPU和网络的使用情况,然后adb shell dumpsys batterystats可以拿到详细的电量统计。还有Google出的Battery Historian工具,可以可视化分析电量消耗的时间线,看看是哪个组件在持续耗电。

常见的耗电原因和优化方案大概这几类:

WakeLock滥用,这个是最常见的,有些代码acquire了锁忘记release,或者持有时间太长。优化就是确保在finally里释放,或者用带超时的acquire。

后台任务过于频繁,比如定时器间隔太短、频繁网络请求。应该用JobScheduler或者WorkManager来批量执行,让系统来调度。

GPS和传感器,定位精度设置太高或者一直开着。应该按需开启,用完就关,而且能用网络定位就别用GPS。

网络请求,频繁的短连接很耗电,应该合并请求、用长连接。

还有就是动画和屏幕刷新,后台了还在跑动画,这个onStop里要暂停掉。

整体思路就是减少CPU唤醒次数、减少后台活动、合理使用系统提供的省电API。


安卓后台更新功能如何实现?

后台更新的话,一般指的是应用在后台静默下载新版本APK然后提示用户安装,对吧。

实现思路大概是这样的。首先要有个版本检查机制,可以启动时或者定期去服务端请求最新版本号,和本地versionCode比较。如果有新版本,就拿到下载地址。

然后是后台下载,这块推荐用DownloadManager系统服务,它本身就支持后台下载、断点续传、通知栏进度显示,而且不用自己处理网络切换、应用被杀这些情况。当然也可以用WorkManager配合OkHttp自己实现,灵活度更高一些。

下载完成后,通过注册广播监听ACTION_DOWNLOAD_COMPLETE,拿到文件路径。然后就是触发安装,Android 7.0以上要用FileProvider去获取content://的uri,调用ACTION_VIEW的Intent,设置MIME类型为application/vnd.android.package-archive。用户点击确认就会走系统安装流程。

如果要做静默安装的话,那需要系统签名或者root权限,调用pm install命令,普通应用是做不到的。

另外现在Google Play推荐用In-App Updates API,可以做到更平滑的更新体验,支持灵活更新和立即更新两种模式。


Service 的作用是什么?

Service是Android四大组件之一,它的核心作用就是在后台执行长时间运行的任务,不需要用户界面

举一些典型场景吧:音乐播放器在后台播放音乐、APP在后台下载文件、即时通讯APP维护长连接接收消息,这些都得靠Service来实现。

Service有两种启动方式。startService启动的话,Service会一直运行直到自己调用stopSelf或者外部调用stopService。这种适合执行一次性的后台任务。bindService的话,Service的生命周期和绑定它的组件关联,所有客户端都解绑了Service就会销毁。这种适合需要和Service交互的场景,通过Binder来通信。

需要注意的是,Service默认是在主线程运行的,耗时操作还是要开子线程。如果想要单独进程,可以在manifest里配置android:process属性。

然后从Android 8.0开始,后台Service受到很大限制,应用进入后台后不久Service就会被杀。所以现在要做后台任务,推荐用前台Service,就是调用startForeground显示一个通知,这样优先级更高不容易被杀。或者用JobScheduler/WorkManager来处理可延迟的后台任务。


安卓系统的启动流程是什么?

好,这个流程我之前看源码的时候梳理过,大概分这么几个阶段。

首先是硬件引导阶段,按下电源键后,CPU会从固定地址加载Boot ROM里的代码,然后引导Bootloader启动,Bootloader负责初始化硬件、加载Linux内核到内存。

然后是内核启动阶段,Linux Kernel开始运行,初始化内存管理、进程调度、驱动程序这些,最后会启动第一个用户空间进程,也就是init进程,pid是1。

Init进程非常关键,它会解析init.rc配置文件,挂载文件系统、设置SELinux、启动各种native服务。其中最重要的就是fork出Zygote进程

Zygote启动后会预加载Android Framework的类和资源,然后fork出SystemServer进程。SystemServer里面会启动各种系统服务,像AMS、WMS、PMS、InputManagerService这些核心服务都是在这里通过SystemServiceManager启动的。

等AMS启动完成后,它会去启动Launcher应用,也就是桌面,这时候用户就能看到界面了。

整个流程可以简单记成:Boot ROM → Bootloader → Kernel → Init → Zygote → SystemServer → Launcher。从源码角度的话,可以重点看ZygoteInit.javaSystemServer.java这两个类。


Zygote 进程的作用是什么?

Zygote这个名字很形象,就是"受精卵"的意思,它是所有Android应用进程的父进程

它的核心作用有这么几个。首先是预加载资源,Zygote启动时会通过preload()方法加载常用的Java类、Android Framework的类、还有drawable、color这些系统资源。这样做的好处是,后面fork出的子进程可以直接共享这些资源,利用了Linux的Copy-On-Write机制,既加快了应用启动速度,也节省了内存。

第二个作用是fork应用进程。当AMS需要启动一个新的应用时,会通过Socket连接Zygote,发送进程创建请求。Zygote收到后调用fork()系统调用创建子进程,子进程继承了父进程的内存空间,然后走到ActivityThread.main()开始执行应用代码。

还有一点,Zygote在64位设备上其实有两个,一个是zygote64,一个是zygote32,分别负责fork对应架构的应用进程。

从源码看的话,ZygoteInit.main()是入口,ZygoteServer负责监听Socket请求,ZygoteConnection处理具体的fork逻辑。Zygote自身是不做任何业务逻辑的,就是专门用来孵化进程的。


TCP 流量控制和拥塞控制的原理是什么?

嗯,这两个概念容易混淆,我先说一下它们的区别。流量控制是端到端的,解决的是发送方发太快、接收方处理不过来的问题。拥塞控制是全局的,解决的是网络中间路径拥堵的问题。

先说流量控制。TCP用的是滑动窗口机制,接收方在ACK报文里会带一个窗口大小字段,告诉发送方"我现在还能接收多少字节"。发送方根据这个值来控制发送速率。如果接收方处理慢了,窗口变小,极端情况窗口变成0,发送方就暂停发送,然后定期发探测包看窗口有没有打开。

拥塞控制更复杂一些,经典算法有四个阶段。慢启动,刚开始拥塞窗口cwnd设成1个MSS,每收到一个ACK就翻倍,指数增长。到达慢启动阈值ssthresh后进入拥塞避免,变成线性增长,每个RTT加一个MSS。

如果发生丢包,有两种处理方式。超时重传的话,ssthresh减半,cwnd重置为1,重新慢启动。如果是收到三个重复ACK,说明只是个别包丢了,就进入快重传快恢复,ssthresh和cwnd都减半,然后直接进入拥塞避免阶段,这样恢复更快。

现在还有很多改进算法,比如BBR,是基于带宽和RTT来控制的,不依赖丢包信号。


安卓的 IPC 机制有哪些?

Android的IPC机制还挺多的,我按使用场景来说吧。

最核心的肯定是Binder,它是Android专门设计的IPC机制,整个系统服务都是基于Binder的。相比传统的管道、Socket,Binder只需要一次数据拷贝,而且有完善的身份验证机制,可以获取调用方的UID和PID。我们平时用的AIDL其实就是Binder的封装,编译器帮我们生成Stub和Proxy代码。

Messenger是对Binder的更高层封装,底层还是AIDL,但使用起来更简单,适合传递Message对象的场景,不过只能串行处理。

ContentProvider也是基于Binder的,主要用于跨进程的数据共享,像通讯录、相册这些系统数据都是通过ContentProvider暴露的。

然后是一些Linux原生的IPC方式。Socket,可以本地也可以网络,比如Zygote和AMS之间就是用的LocalSocket。共享内存,Android封装了MemoryFileSharedMemory,适合传大数据,因为没有拷贝开销。匿名共享内存Ashmem也是Android特有的。

还有管道,Looper里的唤醒机制用的就是eventfd,原理类似。广播也算是一种IPC,底层也是走的Binder。

选择的话,一般应用间通信首选Binder/AIDL,大数据传输用共享内存,简单通知用广播。


ContentProvider 和 Binder 机制的适用场景是什么?

好,这两个虽然ContentProvider底层也是用Binder实现的,但适用场景还是有明显区别的。

Binder/AIDL适合的是需要跨进程调用方法、执行操作的场景。比如说,你有一个后台Service在独立进程跑,主进程需要调用它的方法控制播放、暂停,这种就用AIDL定义接口,通过Binder来调用。系统服务也是这样,AMS、WMS这些都是通过Binder暴露接口给应用层调用的。它的特点是面向接口编程,支持同步调用、可以传复杂对象。

ContentProvider更适合数据共享的场景,特别是结构化数据。它提供了一套标准的CRUD接口,insert、delete、update、query,有点像数据库的抽象。典型的例子就是通讯录、媒体库、日历,这些系统数据都是通过ContentProvider暴露给第三方应用的。而且它有URI的概念,可以用ContentObserver监听数据变化,这个在AIDL里就得自己实现回调了。

简单来说,如果你是要共享数据,特别是表结构的数据,用ContentProvider更合适,它已经帮你封装好了很多东西。如果你是要远程调用方法,执行一些操作逻辑,那就直接用Binder/AIDL。当然如果只是简单的单向通知,广播也够用了。


你认为哪个项目的系统架构设计比较好?请具体介绍其优势。

嗯,让我想想...我觉得快手那个低端机优化项目的整体架构设计是比较好的。

它好在哪呢?首先是分层清晰。最底层是一个设备能力检测模块,负责采集设备信息、计算设备评分、划分档位。中间层是策略配置层,用一个配置中心管理各种优化策略,支持云端下发和本地缓存。最上层是各个业务模块,比如图片加载、视频播放、动画效果,它们只需要问策略层"我当前应该用什么配置",不需要关心设备判断的逻辑。

这个设计的优势有几个。解耦做得好,业务方不需要到处写if-else判断设备类型,策略变更也不用改业务代码。可扩展性强,新增一个优化项只需要在配置里加一条,注册一个策略执行器就行。支持AB测试,因为策略是云端下发的,可以针对不同用户群体灰度不同的配置,看数据效果再全量。

还有就是监控闭环,每个策略执行后会上报埋点,我们能看到不同策略对性能指标的影响,用数据来驱动优化迭代。

这种配置化、可观测的架构思路,我觉得在做性能优化类需求的时候是很值得借鉴的。


实习过程中遇到过哪些冲突(如项目交付、分支合并等)?如何解决的?

嗯,有遇到过一些,我说两个印象比较深的吧。

一个是代码合并冲突。当时我和另一个同事同时在改一个比较核心的工具类,他在重构,我在加新功能,结果合并的时候冲突特别多。那次之后我学到了,改公共代码之前要先在群里吼一声,或者看一下这个文件最近有没有人在动。然后就是commit要小而频繁,别攒一大堆一起提,不然冲突了很难解。后来我们组也建立了一个规范,核心模块的改动要提前在文档里登记。

另一个是项目交付时间的冲突。有一次产品临时加了个需求,说要赶某个版本上线。但当时我手上还有另一个技术优化的任务也承诺了交付时间。我当时的处理是,先跟mentor沟通了一下两个事情的优先级,然后找产品对齐,看新需求能不能砍掉一些非核心功能点,做一个MVP版本先上。最后两边都协调了一下,新需求简化了scope,技术优化那个延了几天,都能接受。

这个经历让我意识到,及时沟通很重要,有风险要早暴露,别自己扛着最后delay了才说。还有就是学会评估优先级和谈判scope,不是所有需求都必须100%满足的。


字节飞书 安卓二面


自定义 View 和自定义 ViewGroup 的实现流程?

嗯,这其实是 Android UI 开发里最基础也最见功底的一块。

简单来说,无论是自定义 View 还是 ViewGroup,本质上都是围绕 View 绘制系统的三个核心阶段展开的:Measure(测量)、Layout(布局)和 Draw(绘制)。不过,View 和 ViewGroup 侧重点稍微有点不一样。

首先是 Measure 阶段。 这一步是为了确定 View 自己有多大。 如果是自定义 View,通常我们需要重写 onMeasure 方法。这里核心要注意的是 MeasureSpec,它把测量模式(比如 AT_MOST 对应 wrap_content,EXACTLY 对应具体的 dp 值)和大小打包成了一个 int。我们需要根据父容器传下来的这个 Spec,结合 View 自己的内容需求,计算出宽高,最后一定要调用 setMeasuredDimension 来保存结果,不然就会抛异常。

如果是自定义 ViewGroup,除了测量自己,更重要的是遍历测量子 View。我一般会去遍历 children,调用 measureChild 或者 measureChildWithMargins。这样子 View 才能拿到自己的大小,父容器才能根据子 View 的总大小来确定自己的最终尺寸。

接下来是 Layout 阶段。 这一步是确定 View 摆在哪里。 对于自定义 View,一般很少重写 onLayout,除非有特殊的交互改变位置。 但对于自定义 ViewGroup,这一步就是重中之重了。因为 View 的位置是由父容器决定的。我们需要在 onLayout 里再次遍历子 View,根据具体的排列规则(比如流式布局的换行逻辑),计算出每个子 View 的 Left、Top、Right、Bottom 坐标,然后调用子 View 的 layout() 方法把它们安顿好。

最后是 Draw 阶段。 对于自定义 View,这是主角,我们需要重写 onDraw(Canvas canvas)。拿到 Canvas 后,就是调用各种 API 画圆、画线、画文字了。这里我通常会注意一点,就是千万不要在 onDraw 里创建对象,因为绘制频率很高,频繁创建会导致内存抖动(Memory Churn),触发 GC 引起卡顿。 对于自定义 ViewGroup,通常默认是不走 onDraw 的,因为它主要负责管理子 View。除非我们设置了 setWillNotDraw(false),比如要给整个容器画个背景或者前景,才会去处理绘制逻辑。

总结一下,其实整个流程就是:父容器问子 View “你要多大”(Measure),然后告诉子 View “你站这儿”(Layout),最后子 View 自己动手“画出来”(Draw)。

而在源码层面,这一切的调度者都是 ViewRootImpl 里的 performTraversals 方法,它会依次触发这三个流程。理解了这个调用链,写自定义 View 的时候心里就很有底了。


安卓界面刷新的垂直同步信号是什么,有什么作用?

嗯,这个问题涉及到了屏幕显示原理和 Android 的渲染机制。

先说结论: 垂直同步信号(VSync)就是一个硬件产生的周期性脉冲信号,它的作用主要是为了解决画面撕裂(Screen Tearing)问题,同时在 Android 中,它充当了系统渲染管线的**“节拍器”**。

首先,为什么要解决画面撕裂? 在早期的 Android 版本(或者 PC 上),如果 GPU 绘图的速度和屏幕刷新的速度不同步,比如屏幕正在从上往下扫描显示第 N 帧,扫到一半时,GPU 把第 N+1 帧的数据写进了 FrameBuffer。这时候屏幕下半部分显示的就是新的一帧,上半部分是旧的,画面就“裂”开了。 VSync 的出现,就是强行规定:只有当屏幕扫描完一帧(VBlank 区间),GPU 才能交换缓冲区(Swap Buffer)

然后在 Android 系统里,VSync 的作用不仅仅是防撕裂,它驱动了整个 UI 的刷新循环。 从 Android 4.1 引入 Project Butter(黄油计划)之后,系统引入了 Choreographer(编舞者)机制。 当我们需要更新 UI(比如调用 requestLayoutinvalidate)时,系统并不会马上执行绘制,而是向 Choreographer 注册一个监听。 Choreographer 内部有一个 FrameDisplayEventReceiver,它会监听底层的 VSync 信号。

当 VSync 信号(通常是 16.6ms 一次,对应 60Hz)到来时,SurfaceFlinger 会分发这个信号,Choreographer 收到后,才会去触发 doFrame 方法。 在 doFrame 里,才会依次执行:

  1. Input:处理触摸事件。
  2. Animation:处理属性动画。
  3. Traversal:这就回到了我们刚才说的 Measure、Layout、Draw 流程。

所以,VSync 的核心价值在于: 它让 CPU 的计算(Measure/Layout)、GPU 的绘制(Draw/Rasterization)和屏幕的显示(Display)在这个 16.6ms 的周期内有序排队

如果没有 VSync,CPU 可能会在两帧中间突然开始干活,结果做完了也没赶上这一帧的显示,只能等下一帧,这就造成了掉帧(Jank)。有了 VSync,大家听口令行动,效率最高。

我在看 Systrace 的时候,最上面那一行不同颜色的条,其实就是对应 VSync 的周期,如果一帧的处理时间超过了 VSync 的间隔,那一帧就变红了,也就是我们常说的“掉帧”。


如何从内存和卡顿两个角度优化 UI 性能?

这是个非常实战的问题,我们在做性能优化时基本也是盯着这两个指标。我结合我平时的项目经验分两方面说。

一、从内存角度优化 UI UI 引起的内存问题,主要集中在 BitmapView 泄漏上。

  1. Bitmap 优化:这是大头。

    • 首先是按需加载。一张 1080p 的图如果只显示在 100x100 的 ImageView 里,我会用 BitmapFactory.OptionsinSampleSize 进行采样压缩,把内存占用降下来。
    • 其次是复用。利用 inBitmap 属性,配合 LruCache,复用已经分配的内存块,减少内存分配和回收的开销。
    • 还有就是注意图片格式,能用 WebP 就不用 PNG,在不需要透明度的情况下尝试 RGB_565 代替 ARGB_8888,内存直接减半。
  2. 避免内存泄漏

    • 我在写代码时会特别注意 Context 的引用,尽量用 ApplicationContext,避免 Activity 被长生命周期的对象(比如单例、Handler、静态 View)持有。
    • 工具方面,集成 LeakCanary 是标配,线下跑 Monkey 的时候盯着看,有泄漏第一时间解掉。
  3. 绘制时的内存

    • 刚才提到了,坚决不在 onDrawnew 对象。

二、从卡顿(流畅度)角度优化 UI 卡顿的本质是 主线程在 16.6ms 内没干完活

  1. 降低 View 层级(Measure/Layout 优化)

    • 层级越深,递归测量越慢。我会优先使用 ConstraintLayout 来做扁平化布局,替代嵌套的 LinearLayout/RelativeLayout。
    • 对于重叠视图,使用 merge 标签减少一层容器。
    • 对于不常用的布局(比如网络错误页),使用 ViewStub 延迟加载,不占初始化资源。
  2. 解决过度绘制(Overdraw)

    • 打开开发者选项里的“显示过度绘制区域”,看到红色的就要警惕了。
    • 最常见的就是移除非必要的 background。比如父布局有了白色背景,子 View 就没必要再设白色了。
    • 自定义 View 里,可以用 canvas.clipRect() 指定只绘制变化的区域,或者用 quickReject 跳过完全遮挡的区域。
  3. 把耗时操作移出主线程

    • IO 操作、复杂的计算绝对不能在主线程做。
    • 这里有个进阶的优化:布局异步加载。如果是非常复杂的页面,我会尝试使用 AsyncLayoutInflater(或者像 X2C 这种框架把 XML 编译成 Java 代码),把 XML 解析这个耗时过程放到子线程去,主线程直接拿 View 用。
  4. 工具使用

    • 我会用 Systrace 来抓具体的掉帧现场,看看到底是 measure 慢了,还是 input 处理慢了,还是 binder 调用卡住了。
    • Layout Inspector 来审查视图层级是否合理。

总之,优化的核心原则就是:让 CPU 少干活(扁平化、防过度绘制),让内存少分配(复用、压缩),主线程只做必须做的事。


安卓中 ANR 的触发阈值是什么,底层是如何检测 ANR 的?

嗯,ANR(Application Not Responding)绝对是 App 质量的红线。我在看 Framework 源码的时候,专门去研究过它的触发机制。

先回答阈值,其实分几种情况: 最常见的有四个:

  1. Input 事件(比如按键、触摸):5秒。如果 5秒内 App 没能处理完输入事件,就会触发。
  2. BroadcastReceiver:前台广播是 10秒,后台广播是 60秒
  3. Service:前台 Service 是 20秒,后台 Service 是 200秒
  4. ContentProvider:发布超时是 10秒

至于底层检测机制,我觉得可以用一个很形象的词来概括: “埋雷”和“拆雷”

除了 Input 事件稍微特殊一点外,Service 和 Broadcast 的检测机制都在 AMS (ActivityManagerService) 线程中通过 Handler 消息机制完成的。

Service 为例: 当我调用 startService 时,AMS 里的 ActiveServices 类在真正把生命周期回调(比如 onCreate)发给 App 进程之前,会先干一件事——“埋雷”。 它会向 AMS 的 Handler 发送一条延时消息(SERVICE_TIMEOUT_MSG),延时时间就是刚才说的 20秒(前台)或 200秒。

然后,如果 App 进程在规定时间内顺顺利利走完了 onCreate,会通知 AMS 说“我完事了”。AMS 收到这个反馈后,就会去 Handler 里把之前发的那条延时消息移除掉——这就是**“拆雷”**。

如果 App 主线程卡住了,导致没能及时通知 AMS,那条延时消息就会被触发。这时候 AMS 就会判定发生了 ANR,收集堆栈信息(Dump StackTrace),然后弹窗告诉用户。

Input ANR 的检测稍微有点不同: 它主要是在 Native 层的 InputDispatcher 里做的。 InputDispatcher 负责分发事件,每当它分发一个事件给窗口时,会检查在这个窗口的“等待队列”里是不是还有上一个事件没处理完。 如果它发现上一个事件已经超时(超过 5秒),它不会像 AMS 那样用 Handler 倒计时,而是直接通过 Native 调用通知系统层,最终抛出 ANR。

所以总结一下,Service/Broadcast 是系统服务侧的倒计时机制,而 Input 是分发时的阻塞检测机制。我们在排查问题时,可以通过 /data/anr/traces.txt 文件看到到底是卡在了哪个环节,是主线程死锁了,还是被某个耗时 IO 拖垮了。


除了约束布局,还有哪些布局优化手段?RecyclerView 相比 ListView 有哪些性能优势?

嗯,这两个问题都是 UI 性能优化的核心。

先说布局优化手段: 除了现在主流推荐的 ConstraintLayout(虽然它功能强大,但初始化时的解析耗时其实比简单的 FrameLayout 要高一点,要注意平衡),我常用的还有:

  1. 标签优化

    • <include>:主要为了代码复用,虽然不直接提升性能,但让布局更清晰。
    • <merge>:这个很有用。如果子布局的根节点和父布局一样(比如都是 LinearLayout),或者子布局不需要父节点,用 merge 可以减少一层 View 的嵌套,直接降低 Measure/Layout 的成本。
    • <ViewStub>:对于那些不一定显示的 View(比如网络错误页、引导层),一定要用 ViewStub。它是一个轻量级的 View,宽高都是 0,只有在调用 inflate() 时才会加载真正的布局资源,延迟加载能极大减轻 Activity 启动时的压力。
  2. 异步加载

    • 比如 AsyncLayoutInflater,把 XML 解析成 View 的过程放到子线程去做,解决 IO 和反射的耗时问题。
    • 甚至在一些极致优化场景(像字节这种大厂),会用到像 X2C 这样的方案,在编译期把 XML 翻译成 Java 代码,直接 new 出来,完全避开运行时的反射。

接下来说 RecyclerView 和 ListView 的对比: 虽然 ListView 已经是过去式了,但对比能看出 RecyclerView 设计的精妙,尤其是 缓存机制

  1. 缓存层级更细粒度

    • ListView 只有两级缓存(ActiveView, ScrapView)。

    • RecyclerView 有 四级缓存

      • Scrap:屏幕内复用,不需要重新 bind 数据。
      • CacheViews:滑出屏幕刚不久的 Item,默认存 2 个,拿回来直接用,也不需要 bind,性能极高。
      • ViewCacheExtension:给开发者自定义的扩展。
      • RecycledViewPool:终极回收池。这里面的 View 需要重新 onBindViewHolder
  2. RecycledViewPool 的复用能力

    • 这是一个大杀器。如果我有多个 RecyclerView(比如垂直列表里嵌套水平列表,像应用商店或者 Feed 流),它们可以共用同一个 Pool。这样我在滑上面的列表时,滑出去的 Item 可以被下面的列表拿来直接用,内存占用和创建开销大大降低。这一点 ListView 做不到。
  3. 架构设计的解耦

    • ListView 把 UI 布局、数据绑定、点击事件全都揉在一起。
    • RecyclerView 把布局交给了 LayoutManager(想变网格、瀑布流随便切),动画交给了 ItemAnimator,数据变化交给了 DiffUtil,这种插件化的设计让它扩展性极强。

所以,RecyclerView 赢在多级缓存策略高度解耦的架构上。


屏幕适配中,尺度修饰符(如 sw600dp)的作用是什么?如何根据不同屏幕尺寸加载对应布局?

嗯,屏幕适配是 Android 开发里避不开的坑,尤其是现在折叠屏和平板越来越多的情况下。

首先解释 sw600dp 它的全称是 "smallest width",意思是**“最小宽度”。 这里的“最小宽度”指的不是屏幕的宽或者高,而是屏幕短边**的长度。 比如 values-sw600dp 这个文件夹,意思就是:只要你这台设备的屏幕短边(不管横屏还是竖屏)大于等于 600dp,系统就会优先去这里找资源。 这通常是手机和平板的分界线(7英寸左右的平板短边一般就在 600dp 上下)。这种方式比以前单纯判断 width/height 要稳得多,因为它不会受横竖屏切换的影响。

至于如何加载对应布局,这套机制完全依赖于 Android 的资源管理系统(Resources):

  1. 资源目录分包: 我们在开发时,会创建不同的资源文件夹,比如:

    • layout (默认,手机用)
    • layout-sw600dp (7寸平板)
    • layout-sw720dp (10寸平板)
    • 或者 values-sw600dp 里的 dimens.xml
  2. 系统的匹配流程: 当 App 启动或者 Configuration 发生变化(比如折叠屏展开)时,系统会拿到当前的设备配置(屏幕密度、尺寸、方向等)。 当我们调用 setContentView(R.layout.main) 时,Resources 会根据当前的配置,去寻找最匹配的资源目录。

    • 如果是平板(sw > 600dp),它就命中 layout-sw600dp 里的 xml。
    • 如果是手机(sw < 600dp),它找不到 sw600 的目录,就会**回退(Fallback)**到默认的 layout 目录。

在实际项目中,除了布局文件(layout),我更喜欢用 dimens 适配。 也就是布局文件只有一份(写在 layout 里),但在 valuesvalues-sw600dp 里定义同名的 dimen 变量。 比如 android:layout_width="@dimen/content_width"。 这样手机上读到的是 0dp (match_parent),平板上读到的是 400dp,既实现了适配,又不用维护两套 xml 代码,维护成本低很多。

如果是那种结构完全不一样的(比如手机是单栏,平板是左侧列表右侧详情的分栏),那我通常会结合 Fragment 来做,在 layout-sw600dp 里放两个 FragmentContainer,在默认 layout 里放一个,代码里动态加载,这样复用性最高。


volatile 关键字的作用是什么,能保证并发的哪些特性(可见性、有序性、原子性)?

嗯,这个问题是 Java 并发编程的基石,也是我在看 Android 源码(比如 Looper 或者 Handler 内部状态控制)时经常看到的。

先说结论: volatile 关键字主要保证了并发编程中的 可见性(Visibility)有序性(Ordering),但它无法保证原子性(Atomicity)

首先是可见性。 我们在 Java 内存模型(JMM)里知道,每个线程都有自己的工作内存(Working Memory),里面存的是主内存(Main Memory)中变量的副本。 平时一个线程修改了变量,可能只是改了自己工作内存里的副本,还没来得及刷回主内存,其他线程根本不知道。 加了 volatile 之后,JMM 会强制做两件事:

  1. 当前线程修改值后,必须立刻刷新回主内存
  2. 其他线程的工作内存中该变量的缓存行立刻失效,必须去主内存重新读取。 这就保证了“一个线程改了,其他线程马上能看到”。

然后是有序性。 这主要是通过禁止指令重排序(Instruction Reordering)来实现的。 编译器和处理器为了优化性能,经常会打乱指令执行顺序。但在多线程环境下,这种乱序可能会导致逻辑错误(比如单例模式里的经典坑)。 volatile 通过在这个变量的读写操作前后插入内存屏障,告诉编译器:“这一点不能乱动”,从而保证了代码执行的有序性。

最后是它做不到的原子性。 这是个常见的误区。比如最简单的 i++i++ 操作。 虽然 ivolatile 的,但 i++i++ 本质上是“读-改-写”三个步骤。 如果两个线程同时读到了 i=0,线程 A 改成 1 准备写回,还没写的时候,线程 B 也改成了 1 写回。这时候虽然它是可见的,但因为没有锁机制,两次 i++i++ 最后结果还是 1,而不是 2。 所以,如果要保证原子性,还是得用 synchronized 或者 J.U.C 包下的 AtomicInteger(底层是用 CAS 实现的)。


volatile 关键字底层是如何实现的,内存屏障的作用是什么?

嗯,这就要挖到 JVM 和汇编层面了,这也是我在研究 HotSpot 虚拟机实现时关注的点。

底层实现: 如果在字节码层面看,给变量加了 volatile,只是在访问标志(Access Flags)里多了一个 ACC_VOLATILE。 但真正的魔法发生在 JVM 将字节码翻译成机器码的时候。JVM 会根据不同的硬件架构(x86, ARM 等)插入内存屏障(Memory Barrier)

内存屏障的作用: 简单来说,内存屏障就是一条特殊的 CPU 指令(或者指令序列),它主要做两件事:

  1. 阻止屏障两侧的指令重排序
  2. 强制把写缓冲区/高速缓存的数据刷回主内存

具体到 JVM 的规范,有四种屏障:

  • LoadLoad 屏障:保证在读取 volatile 变量之前,前面的读操作都完成了。
  • StoreStore 屏障:保证在写 volatile 变量之前,前面的写操作都刷到内存里了。
  • LoadStore 屏障:禁止 volatile 读与后面的写重排序。
  • StoreLoad 屏障:这是最重要也是开销最大的一个。它保证写 volatile 变量后,所有后续的读写操作都能看到这个结果。这就有点像是一个“全能屏障”。

结合硬件层面(以 x86 架构为例): 其实在 x86 处理器上,JVM 通常是利用 lock 前缀指令(比如 lock addl $0x0,(%rsp))来实现的。 这个 lock 前缀指令会触发现代 CPU 的缓存一致性协议(比如 MESI 协议)。 它会锁住这块内存区域的缓存行(Cache Line),并向总线发送信号,让其他 CPU 核心里缓存了该地址的数据全部置为无效(Invalid)。这样下次别的核要读,就必须去主内存拿最新的。

所以总结一下,volatile 的底层就是:JVM 识别标志 -> 插入内存屏障指令 -> 触发 CPU 缓存一致性协议,从而物理上实现了可见性和有序性。


单例模式中如何使用 volatile 关键字?双重校验锁单例的实现原理是什么?

嗯,这也是个经典的面试题,我在写一些全局管理者(比如 OkHttp 的封装或者 Retrofit 的单例)时经常用到。

先说代码结构: 也就是

  1. 当前线程修改值后,必须立刻刷新回主内存
  2. 其他线程的工作内存中该变量的缓存行立刻失效,必须去主内存重新读取。 这就保证了“一个线程改了,其他线程马上能看到”。

然后是有序性。 这主要是通过禁止指令重排序(Instruction Reordering)来实现的。 编译器和处理器为了优化性能,经常会打乱指令执行顺序。但在多线程环境下,这种乱序可能会导致逻辑错误(比如单例模式里的经典坑)。 volatile 通过在这个变量的读写操作前后插入内存屏障,告诉编译器:“这一点不能乱动”,从而保证了代码执行的有序性。

最后是它做不到的原子性。 这是个常见的误区。比如最简单的 i++i++ 操作。 虽然 ivolatile 的,但 i++i++ 本质上是“读-改-写”三个步骤。 如果两个线程同时读到了 i=0,线程 A 改成 1 准备写回,还没写的时候,线程 B 也改成了 1 写回。这时候虽然它是可见的,但因为没有锁机制,两次 i++i++ 最后结果还是 1,而不是 2。 所以,如果要保证原子性,还是得用 synchronized 或者 J.U.C 包下的 AtomicInteger(底层是用 CAS 实现的)。


volatile 关键字底层是如何实现的,内存屏障的作用是什么?

嗯,这就要挖到 JVM 和汇编层面了,这也是我在研究 HotSpot 虚拟机实现时关注的点。

底层实现: 如果在字节码层面看,给变量加了 volatile,只是在访问标志(Access Flags)里多了一个 ACC_VOLATILE。 但真正的魔法发生在 JVM 将字节码翻译成机器码的时候。JVM 会根据不同的硬件架构(x86, ARM 等)插入内存屏障(Memory Barrier)

内存屏障的作用: 简单来说,内存屏障就是一条特殊的 CPU 指令(或者指令序列),它主要做两件事:

  1. 阻止屏障两侧的指令重排序
  2. 强制把写缓冲区/高速缓存的数据刷回主内存

具体到 JVM 的规范,有四种屏障:

  • LoadLoad 屏障:保证在读取 volatile 变量之前,前面的读操作都完成了。
  • StoreStore 屏障:保证在写 volatile 变量之前,前面的写操作都刷到内存里了。
  • LoadStore 屏障:禁止 volatile 读与后面的写重排序。
  • StoreLoad 屏障:这是最重要也是开销最大的一个。它保证写 volatile 变量后,所有后续的读写操作都能看到这个结果。这就有点像是一个“全能屏障”。

结合硬件层面(以 x86 架构为例): 其实在 x86 处理器上,JVM 通常是利用 lock 前缀指令(比如 lock addl $0x0,(%rsp))来实现的。 这个 lock 前缀指令会触发现代 CPU 的缓存一致性协议(比如 MESI 协议)。 它会锁住这块内存区域的缓存行(Cache Line),并向总线发送信号,让其他 CPU 核心里缓存了该地址的数据全部置为无效(Invalid)。这样下次别的核要读,就必须去主内存拿最新的。

所以总结一下,volatile 的底层就是:JVM 识别标志 -> 插入内存屏障指令 -> 触发 CPU 缓存一致性协议,从而物理上实现了可见性和有序性。


单例模式中如何使用 volatile 关键字?双重校验锁单例的实现原理是什么?

嗯,这也是个经典的面试题,我在写一些全局管理者(比如 OkHttp 的封装或者 Retrofit 的单例)时经常用到。

先说代码结构: 也就是我们常说的 DCL(Double-Checked Locking)

Java
public class Singleton {
    // 关键点:必须加 volatile
    private static volatile Singleton instance;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
        // 第一次校验:不加锁,为了性能。
        // 如果已经创建了,直接返回,避免 synchronized 的开销。
        if (instance == null) {
            synchronized (Singleton.class) {
                // 第二次校验:为了安全。
                // 防止两个线程同时过了第一道门,A 拿锁创建完释放,B 拿到锁如果不检查就会再创建一次。
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

为什么一定要用 volatile?这才是核心。 问题出在 instance = new Singleton(); 这行代码上。在 JVM 看来,这并不是一个原子操作,它大概分三步:

  1. memory = allocatememory = allocate()**:分配对象的内存空间。
  2. ctorInstance(memory):调用构造器,初始化对象。
  3. instance = memory:把 instance 引用指向这块内存(这时候 instance 就不为 null 了)。

如果没有 volatile, 编译器或 CPU 可能会进行指令重排序。 比如把顺序变成了 1 -> 3 -> 2。 也就是说,先分配内存,然后马上把引用指过去(这时候 instance 已经非空了),最后才去初始化对象。

这时候灾难就来了:这时候灾难就来了: 假设线程 A 执行到了第 3 步(引用指了,但没初始化),刚好线程 B 进来了。 线程 B 在“第一次校验” if (instance == null) 时,发现 instanceinstance` 不为 null(因为线程 A 已经指过去了)。 于是线程 B 直接拿着这个还没初始化的半还没初始化的半成品对象去用了。 这就可能导致空指针异常或者拿到错误的数据。

volatile 的作用: 加上 volatile 后,就利用了我们刚才说的“内存屏障”,强制禁止了第强制禁止了第 2 步和第 3 步的重排序**。 保证一定是“先初始化完,再赋值给引用”。这样线程 B 看到的,要么是 null,要么就是一个完整可用的对象,从而保证了单例模式的安全性。



Java 垃圾回收中,死亡对象的判断方式有哪些?

嗯,这个问题其实是 JVM 内存管理的起点。在 Java 虚拟机里,判断一个对象是不是“死了”(也就是可以被回收了),主要有两种流派:引用计数法可达性分析法

首先说引用计数法(Reference Counting)。 这个原理很简单,对象头里维护一个计数器,被引用一次就 +1,引用失效就 -1,变成 0 就可以回收。 但在现代的 JVM(包括 Android 的 ART 虚拟机)里,其实并不用这个算法。 最主要的原因就是它解决不了循环引用的问题。比如 A 引用 B,B 又引用 A,他俩的计数器永远是 1,但其实这两个对象外界都已经访问不到了,这就造成了内存泄漏。不过我做 Native 开发时发现,C++ 里的 std::shared_ptr 用的就是这个机制,所以写 JNI 的时候要格外小心循环引用。

目前主流使用的,是可达性分析算法(Reachability Analysis)。 这个算法的核心思想就是**“顺藤摸瓜”**。 它定义了一系列的根节点,叫做 GC Roots。如果不通过任何路径能从 GC Roots 到达某个对象,那这个对象就是不可达的,会被判定为“死亡”。

那么在 Android 开发中,哪些对象可以作为 GC Roots 呢? 我觉得这个是面试常考点,结合我平时的理解,主要有这几类:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如我们在方法里 new 了一个局部变量,只要方法没执行完,这个变量引用的对象就是活的。
  2. 方法区中类静态属性引用的对象:比如由于单例模式(Singleton)持有的 Context,如果处理不好,整个 Activity 都释放不掉,就是因为静态变量是 GC Root。
  3. 方法区中常量引用的对象:比如 static final 定义的字符串。
  4. Native 方法中 JNI 引用的对象:这也是我写 Framework 层代码时比较注意的,JNI如果不手动 DeleteLocalRef,很容易撑爆全局引用表。

稍微补充一点细节: 其实对象被判定不可达后,也不是“非死不可”。它还有一次“缓刑”的机会。 如果这个对象重写了 finalize() 方法(虽然现在极不推荐用),系统会把它放到一个队列里。如果在 finalize() 里它把自己又赋给了某个 GC Root 关联的变量,那它就“复活”了。不过这在 Android 性能优化里属于“反面教材”,因为它会拖慢 GC 速度,导致卡顿。


常见的垃圾回收算法有哪些?

嗯,垃圾回收算法其实是随着内存管理的复杂度一步步演进出来的。常见的有四种:标记-清除、复制、标记-整理,以及分代收集算法

1. 标记-清除算法(Mark-Sweep) 这是最基础的。分两步:先从 GC Roots 开始标记所有存活对象,然后统一回收没被标记的。 它的缺点很明显: 内存碎片。回收完后内存是“千疮百孔”的,如果这时候要分配一个大数组,虽然总剩余空间够,但没有连续空间,就会触发新的 GC,甚至 OOM。

2. 复制算法(Copying) 为了解决碎片问题,复制算法把内存一分为二。每次只用一半,回收时把活着的对象整齐地复制到另一半去,然后把这一半全部清空。 缺点是: 内存利用率太低了,直接腰斩。不过因为它效率高且无碎片,通常用来回收新生代,因为新生代里大部分对象都是“朝生夕死”的,需要复制的很少。

3. 标记-整理算法(Mark-Compact) 这个是为了解决老年代的问题。老年代对象存活率高,复制成本大。所以它在标记完后,不是直接清除,而是让所有存活对象向一端移动,然后直接清理掉边界以外的内存。 这样既没有碎片,又不需要浪费一半空间。

4. 分代收集算法(Generational Collection) 这是目前 JVM 和 Android ART 的主流策略(虽然 ART 在 Android 10 以后引入了 CC 模式,逻辑更复杂,但分代思想依然存在)。 它把堆内存划分为新生代(Young Gen)老年代(Old Gen)

  • 新生代:用复制算法。比如经典的 Eden 区和两个 Survivor 区(S0, S1)。对象在 Eden 出生,活过一次 GC 就去 Survivor,再活过几次就晋升到老年代。
  • 老年代:用标记-清除或者标记-整理。

结合 Android 来说: 早期的 Dalvik 虚拟机经常因为 GC 导致 Stop-The-World(STW),造成界面卡顿。 现在的 ART 虚拟机(特别是 Android 8.0 之后)引入了 Concurrent Copying (CC) 收集器。它利用读屏障(Read Barrier)技术,允许在应用线程运行的同时进行内存整理和复制。 这其实是我觉得 Android 流畅度提升的一个关键底层优化,它尽量把 STW 的时间压缩到了极短,甚至感觉不到。


Java 的四种引用?

嗯,这个知识点在处理缓存和防止内存泄漏时非常实用。Java 按照引用的强弱,分成了强、软、弱、虚四种。

1. 强引用(Strong Reference) 这就是我们平时写代码最常用的,比如 Object obj = new Object()特性: 只要强引用还在,垃圾回收器宁可抛出 OOM 异常,也不会回收它。 所以我们在 Activity 销毁时,必须把一些耗时的 Listener 或者静态变量置空,就是在切断强引用。

2. 软引用(Soft Reference) 通过 SoftReference<T> 实现。 特性: 它是“可有可无”的。如果内存够用,GC 不会管它;但如果内存不足(快要 OOM 了),GC 就会把这些对象回收掉。 应用场景: 非常适合做图片缓存。比如以前的图片加载框架,会用软引用存 Bitmap,内存不够时自动释放,防止崩溃。不过现在的 Glide/Picasso 更多是用 LruCache(强引用 + 算法控制),因为软引用回收时机太难控制,容易造成频繁 GC。

3. 弱引用(Weak Reference) 通过 WeakReference<T> 实现。 特性: 它比软引用更弱。只要发生 GC,不管内存够不够,弱引用关联的对象都会被回收。 应用场景: 这是防止内存泄漏的神器。 最经典的就是 Handler 里的 Activity 引用。如果 Handler 是静态内部类,它会默认持有外部 Activity 的引用。我们通常会把它改成静态内部类,然后内部持有一个 WeakReference<Activity>。这样 Activity 关闭后,GC 扫描时能正常回收 Activity,不会因为它被 Handler 持有而泄漏。

4. 虚引用(Phantom Reference) 通过 PhantomReference<T> 实现。 特性: 它是最弱的,你甚至没法通过虚引用来 get() 到对象实例(永远返回 null)。 作用: 它的唯一作用就是跟踪对象被垃圾回收的活动。 它必须和 引用队列(ReferenceQueue) 配合使用。当对象被回收时,系统会把这个虚引用放入队列。 应用场景: 主要用于管理堆外内存(DirectBuffer)。比如 NIO 中的 DirectByteBuffer,它在 Java 堆里只有一个“壳”,真正的内存分配在 Native 堆。当这个“壳”被回收时,通过虚引用机制,通知 Cleaner 去调用 C++ 层的 free 释放 Native 内存,防止 Native 内存泄漏。


Glide 图片加载库的三级缓存机制是什么?

嗯,这个问题非常经典。虽然大家常说“三级缓存”是 内存->磁盘->网络,但在 Glide 的源码里(特别是 Engine 类的实现逻辑),它的缓存机制其实设计的更细致,准确说应该是 “两级内存缓存 + 两级磁盘缓存”

先说结论: Glide 的加载流程是:

  1. Active Resources(活动缓存):第一级内存。
  2. Memory Cache(内存缓存):第二级内存。
  3. Disk Cache(磁盘缓存):这里又分了 Resource(处理后)Data(原始图) 两种。
  4. 最后才是 网络请求

接下来我展开讲讲每一层的细节,这也是我读源码时觉得最精彩的地方。

第一层:Active Resources(活动资源) 当我们调用 load 加载图片时,Glide 首先去 ActiveResources 里找。 这个缓存很特殊,它存的是 “当前正在 View 中显示的图片”。 它内部使用 HashMap 配合 WeakReference(弱引用) 来持有对象。为什么用弱引用?因为这些图片正在被 View 使用,有强引用在,所以弱引用不会被回收。一旦 View 销毁或者图片不再显示,强引用断开,弱引用就会配合 ReferenceQueue 监测到,然后把这个图片从 Active 移到下一级 Memory Cache 中。 它的作用是 防止正在使用的图片被 LruCache 误杀回收,保护现场。

第二层:Memory Cache(内存缓存) 如果 Active 里没找到,就去 LruResourceCache 里找。 这就是标准的 LRU 算法实现(底层是 LinkedHashMap)。 这里的逻辑刚好和上面相反:当我们需要一张图,从这里找到后,会把它从 Memory Cache 里移除,剪切(Move)到 Active Resources 里。 所以这两级内存缓存其实是 互斥 的,一张图同一时间只会存在于其中一个里面。

第三层:Disk Cache(磁盘缓存) 如果内存里都没有,就得去翻磁盘了。Glide 的磁盘缓存策略很灵活(通过 DiskCacheStrategy 配置),默认情况下(AUTOMATIC),它会缓存两个版本:

  1. Data Cache(Original):这是从网络下载下来的原始图片数据,没有任何修改。
  2. Resource Cache(Result):这是经过解码、缩放、变换(比如圆角)后的最终图片。 这样设计的好处是,如果我下次要在另一个地方显示同一张图但尺寸不同,我可以去读 Data Cache 里的原图重新剪裁,而不用重新下载。

总结一下整个流程: 就是 Active -> Memory -> Disk (Result -> Data) -> Network。 读取时从左到右,写入时(下载完成后)是先存 Disk,再存 Active。这套机制保证了最大的命中率和最小的内存抖动。


如何通过 Glide 优化图片加载性能?

嗯,在实际开发中,尤其是在像 Feed 流或者相册这种重图片场景,Glide 的优化直接决定了流畅度。我一般从 内存优化加载速度 两个维度入手。

一、内存优化(防止 OOM 和内存抖动)

  1. override() 指定尺寸: 这是最立竿见影的。很多时候 Server 返回的是 1080P 的大图,但我们 View 只有 100x100dp。 如果不处理,Glide 默认可能加载原图解码,内存直接爆炸。 我会显式调用 .override(targetWidth, targetHeight),或者确保 ImageView 的宽高是确定的,Glide 内部会根据 View 的大小去计算 inSampleSize 进行采样加载。

  2. 图片格式优化: Glide 默认解码格式是 ARGB_8888(一个像素 4 字节)。 对于不透明的图片(比如缩略图、背景图),我会把解码格式设为 RGB_565(一个像素 2 字节)。 代码里通过 RequestOptions.format(DecodeFormat.PREFER_RGB_565) 设置,内存直接砍半。

  3. 生命周期绑定: 这一点非常重要。使用 Glide.with() 时,尽量传 FragmentActivity,而不是 Application Context。 因为 Glide 会创建一个无 UI 的 SupportRequestManagerFragment 注入到当前 Activity,监听 onStop/onStart。 当页面不可见时,自动暂停加载请求;页面销毁时,自动清理请求。如果传 Application,请求就会一直在这个进程里跑,容易造成内存泄漏或浪费流量。

二、加载速度优化(提升用户体验)

  1. 缓存策略调整: 对于固定不变的图片(比如 Banner),我会把策略设为 DiskCacheStrategy.ALL。 对于频繁变化的图片,或者只需要临时展示的,可以考虑 DiskCacheStrategy.RESOURCE 甚至 NONE,减少文件 I/O 操作。

  2. RecyclerView 的预加载: 在滑动列表中,如果等滑到了再加载,用户肯定会看到白屏。 我会用到 Glide 提供的 ListPreloader 或者在 OnScrollListener 里手动调用 Glide.with(context).preload()。 在滑动停止(SCROLL_STATE_IDLE)时加载,滑动中(SCROLL_STATE_TOUCH_SCROLL)暂停加载,这样能极大减少滑动时的掉帧。

  3. 自定义 GlideModule: 针对弱网环境,我会自定义 AppGlideModule,替换底层的网络栈。比如把默认的 HttpURLConnection 换成 OkHttp,利用 OkHttp 的连接池和 HTTP/2 特性提升并发加载速度。

所以,优化的核心就是:只加载需要的尺寸,只在需要的时候加载,并且充分利用缓存。


LRU 算法的实现原理是什么?底层采用什么数据结构?如何优化 LRU 的时间和空间开销?

嗯,LRU (Least Recently Used) 确实是 Android 缓存策略的基石,无论是 Glide 的内存缓存,还是 OkHttp 的连接池,甚至 Android 系统的 LruCache 类,原理都是一样的。

1. 实现原理与数据结构 LRU 的核心逻辑是:“最近使用的放在前面,最久没用的放在后面;空间满了,就淘汰最后面的。”

底层最标准的数据结构是:哈希表(HashMap) + 双向链表(Doubly Linked List)。 在 Java 里,LinkedHashMap 已经完美实现了这个结构。

  • 为什么要双向链表? 为了维护顺序。我们需要能快速地把一个节点移动到链表头部(表示最近使用了),或者删除尾部节点。
  • 为什么要哈希表? 为了 O(1)O(1) 的查找。如果只用链表,查找缓存是否存在需要遍历,是 O(n)O(n) ,这在 UI 线程是不可接受的。

具体实现细节: LinkedHashMap 有一个构造参数叫 accessOrder

  • 如果设为 false(默认),它是按插入顺序排序。
  • 如果设为 true,它就是按访问顺序排序。 当我开启 accessOrder = true 后,每当我调用 get(key)LinkedHashMap 内部的 afterNodeAccess 方法就会把这个节点断开,移动到链表的尾部(或者头部,看具体实现,通常尾部代表最新)。 当调用 put(key, value) 时,如果 Map 的大小超过了阈值,afterNodeInsertion 方法就会把链表最老的那一端(Head)给移除掉。

2. 如何优化时间和空间开销?

时间开销优化: LRU 的操作理论上是 O(1)O(1) 的,但在多线程环境下,最大的瓶颈是 锁(Lock Contention)。 Android 的 LruCache 类里,getput 方法都加了 synchronized 锁。 如果在高并发场景下(比如 Glide 疯狂加载图片),这把锁会成为瓶颈。

  • 优化思路:可以参考 ConcurrentHashMap 的分段锁思想,或者使用无锁数据结构(比如基于 CAS 的链表),但在 Android UI 图片加载这种场景下,通常维持 synchronized 已经足够,因为主要耗时在 IO 和解码,而不是 Map 的查找。

空间开销优化: 这才是 Android 开发最关注的。原生的 LRU 是按 Count(数量) 来限制的(比如最多存 100 张图)。 但这不准确,一张 10KB 的图和一张 10MB 的图显然不一样。

  • 优化实现: 我们需要重写 sizeOf(key, value) 方法。 比如在 Bitmap 缓存中,我会计算 bitmap.getAllocationByteCount()。 初始化 LruCache 时,传入的 maxSize 不是个数,而是 可用内存的 1/8(常规做法)。 这样,当缓存满了触发 trimToSize 时,它是根据实际占用的字节数来淘汰旧图片的,从而精确控制内存水位,防止 OOM。

这也是为什么我们在使用 LruCache 时,一定不能只把它当 Map 用,而是要根据业务场景(是存对象个数还是存字节大小)来定制 sizeOf 的逻辑。


对跨平台技术(如 Flutter、React Native、KMP)有什么了解?Flutter 的渲染引擎有什么特点?

嗯,这几个跨平台技术我都有了解,特别是 Flutter 和 KMP,因为它们和原生开发的联系比较紧密。

先说一下它们的区别,其实主要是渲染机制的不同:

  1. React Native (RN): 它的核心理念是 "Learn once, write anywhere"。 它底层其实还是用的 Android/iOS 的原生控件。JS 代码运行在 JSCore 虚拟机里,通过 Bridge(桥接)机制,把 JSON 消息发给 Native 端,Native 端解析后再去操作 View。 它的痛点就在这个 Bridge 上。虽然 Facebook 推出了新架构 Fabric(JSI)来做直接绑定,但在旧架构里,一旦涉及大量数据传输或者高频动画,Bridge 就容易堵车,导致掉帧。

  2. Flutter: 它的理念是 "Write once, run anywhere"。 它完全抛弃了系统的 OEM 控件(比如 TextView、Button),自己带了一个渲染引擎(Skia 或新的 Impeller)。 简单说,Flutter 就像一个游戏引擎。它在 Android 上只有一个 SurfaceView(或者 TextureView),所有的 UI 元素都是它自己画上去的像素。 所以它的性能非常接近原生,而且能保证在不同系统版本上 UI 表现完全一致(Pixel Perfect),这是原生开发最羡慕的一点。

  3. Kotlin Multiplatform (KMP): 这个是我们 Android 开发者的“亲儿子”。 它的思路和前两者不一样,它主打 "Logic Sharing"(逻辑共享)。 我们可以用 Kotlin 写通用的业务逻辑(网络请求、数据库、ViewModel),然后编译成 Android 的 JVM 字节码和 iOS 的 Native 机器码(通过 Kotlin/Native)。 UI 层依然可以分别用 Jetpack Compose 和 SwiftUI 写,或者现在也有了 Compose Multiplatform。 我觉得 KMP 是未来的趋势,特别是对于像字节、腾讯这样已有庞大原生代码库的大厂,KMP 允许“渐进式迁移”,不用推倒重来,风险最小。

接下来说说 Flutter 渲染引擎的特点:

我觉得 Flutter 最核心的架构设计就是它的 “三棵树” 机制:

  • Widget Tree:配置信息,不可变,非常轻量,类似于“蓝图”。
  • Element Tree:Widget 的实例化对象,持有上下文(Context),负责协调。
  • RenderObject Tree:这才是真正干活的。它负责 Layout(布局)和 Paint(绘制)。

它的渲染特点主要体现在这几个方面:

  1. 自绘引擎(Self-drawing): 底层是 C++ 写的 Engine 层。在 Android 上,它通过 JNI 拿到 OpenGL 或 Vulkan 的接口,直接向 GPU 发指令。这使得它完全绕过了 Android Framework 的 measure/layout/draw 流程,减少了中间商赚差价。

  2. 分层架构(Layering): Flutter 每一帧渲染时,会生成一个 Layer Tree(图层树)。 RenderObject 调用 paint 方法时,并不是直接画到屏幕上,而是把绘制指令(DrawCall)记录在 Layer 上。 最后由 Compositor(合成器)把这些 Layer 合成,提交给 GPU。这个机制让它在做动画(比如平移动画)时,只需要调整 Layer 的 offset,而不需要重新重绘 layer 内部的内容(RepaintBoundary),性能非常高。

  3. Skia 与 Impeller: 以前 Flutter 用 Skia,但在 iOS 上因为 Shader(着色器)编译的问题会有“首帧卡顿”。 现在 Flutter 正在迁移到 Impeller 引擎,它利用现代图形 API(Metal/Vulkan)的特性,预编译 Shader,彻底解决了卡顿问题。这也是我持续关注的一个热点。


算法:实现数组的全排列(基于回溯思想,需处理重复元素),并分析时间复杂度和空间复杂度。

嗯,这是一道经典的 LeetCode 题目(LeetCode 47. Permutations II)。

核心思路: 这题是经典的回溯(Backtracking)算法。 既然要求“全排列”且“有重复元素”,重点就在于去重。 如果不去重,相同的数字在不同位置交换会导致生成重复的排列。

为了方便去重,我的策略是:

  1. 先排序。把数组排成有序的(比如 [1, 1, 2]),这样重复的元素就挨在一起了,方便我们在递归时判断。
  2. 使用 used 数组。记录当前元素是否被使用过。
  3. 剪枝(Pruning)。这是最关键的一步。 当我们在递归这一层,准备选 nums[i] 时,如果发现 nums[i] == nums[i-1](和前一个一样),并且 !used[i-1](前一个没被用过),这就说明前一个相同的元素在当前这层递归已经被“撤销”了,我们现在是在重复它的老路,所以必须跳过(continue)。

代码实现:

Java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
 
public class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        List<Integer> path = new ArrayList<>();
        boolean[] used = new boolean[nums.length];
        
        // 1. 排序,这是去重的前提
        Arrays.sort(nums);
        
        backtrack(nums, used, path, result);
        return result;
    }
 
    private void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
        // 终止条件:路径长度等于数组长度,说明找齐了一个排列
        if (path.size() == nums.length) {
            result.add(new ArrayList<>(path)); // 注意要 new 一个拷贝
            return;
        }
 
        for (int i = 0; i < nums.length; i++) {
            // 如果当前元素已经用过了,跳过
            if (used[i]) {
                continue;
            }
            
            // 剪枝逻辑:
            // i > 0: 保证 i-1 不越界
            // nums[i] == nums[i-1]: 只有相邻且相同才需要考虑去重
            // !used[i-1]: 这个条件的含义是“前一个相同的数在当前层还没被用过”
            // (其实用 !used[i-1] 效率更高,它限制了相同元素的相对顺序,保证了稳定性)
            if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) {
                continue;
            }
 
            // 做选择
            path.add(nums[i]);
            used[i] = true;
 
            // 进入下一层
            backtrack(nums, used, path, result);
 
            // 撤销选择(回溯)
            used[i] = false;
            path.remove(path.size() - 1);
        }
    }
}

复杂度分析:

  1. 时间复杂度: 最坏情况(没有重复元素)是 O(N×N!)O(N \times N!)
  • 全排列的总数是 N!N!
  • 每次得到一个排列需要 O(N)O(N) 的时间把它复制到 result 列表中。
  • 如果有重复元素,剪枝会减少实际的计算量,但
  • 全排列的总数是 N!N!
  • 每次得到一个排列需要 O(N)O(N) 的时间把它复制到 result 列表中。
  • 如果有重复元素,剪枝会减少实际的计算量,但量级还是这个。
  1. 空间复杂度O(N)O(N) 。 *

    • 我们需要一个 used 数组,O(N)O(N)
    • 递归调用栈的深度最大为 NNO(N)O(N)
    • (这里不算 result 存储结果的空间,只算算法辅助空间)。

其实这道题我在写的时候,最容易出错的就是那个剪枝条件 !used[i-1] 还是 used[i-1]。其实两个都能去重,但 !used[i-1] 相当于强制规定了相同元素的索引顺序(必须先选第一个 1,再选第二个 1),这样剪枝更彻底,递归树的规模更小。


字节飞书 安卓一面


Java 中接口和抽象类的区别是什么?

嗯,这个问题在设计模式和架构搭建中其实非常常见。简单的语法区别我就不一一背诵了,我想主要从设计理念和我在实际开发中的理解来谈谈。

首先,核心的区别在于设计目的不同。抽象类是对本质的抽象,也就是 "Is-A" 的关系,它是一种模板设计;而接口是对行为的抽象,是 "Like-A" 或者 "Can-Do" 的关系,更像是一种契约或者插件。

举个 Android 里的例子,比如 Activity。它本身是一个抽象类概念的具象化(虽然在 Framework 层它继承自 ContextThemeWrapper,但在我们业务层看来它是一个基类),它规定了生命周期的模板方法,比如 onCreateonResume,这是模板模式的体现。

而接口,比如 View.OnClickListener,它只定义了点击这个行为。任何类,不管是 Activity 还是一个普通的内部类,只要想拥有“处理点击”这个能力,实现这个接口就行了。

然后在语法细节上,有几个点比较关键:

  1. 状态保存:抽象类可以有成员变量,能保存状态;接口在 Java 8 之前只能有常量,虽然 Java 8 引入了 default 方法,但它依然不能保存实例变量。这意味着如果需要在这个类型里保存一些核心数据,就得用抽象类。
  2. 继承关系:Java 是单继承的,类只能继承一个抽象类,但可以实现多个接口。这就决定了抽象类是非常珍贵的资源,一般用于构建核心的继承树,比如 Android 的 View 树;而接口则灵活得多,用来扩展功能。

我在做项目的时候,通常会遵循“多用接口,少用抽象类”的原则,或者结合使用。比如我在封装一个网络请求库时,我会定义一个 INetworkRequest 接口来规定 getpost 行为,然后提供一个 BaseNetworkRequest 抽象类来实现一些通用的 Header 处理或者重试逻辑。这样业务层既可以直接继承抽象类复用代码,也可以直接实现接口做高度定制,扩展性会好很多。

所以总结来说,抽象类是自下而上的归纳,接口是自上而下的规范。


Java 的四大引用?

嗯,这四大引用在 Android 开发中,特别是做内存优化和防止内存泄漏的时候,是用得非常多的。Java 按照引用的强弱关系,分为强引用、软引用、弱引用和虚引用。

我是这样理解它们的区别和应用场景的:

  1. 强引用 (Strong Reference): 这是我们平时写代码最常用的,比如 Object obj = new Object()。只要强引用还在,垃圾回收器(GC)就绝对不会回收它,哪怕内存不够抛出 OOM 异常也不会回收。

  2. 软引用 (Soft Reference): 它比强引用弱一点。它的特点是“也就是在内存不足时才会被回收”。如果内存够用,它就一直活着。

    • 应用场景:以前我们在做图片缓存(比如自己写 LruCache 之前)经常用它来做简单的内存敏感缓存。但在 Android 现在的实践中,Google 其实更推荐用 LruCache(强引用 + LRU 算法)来精细控制,因为软引用的回收时机其实比较难把控,容易造成频繁 GC。
  3. 弱引用 (Weak Reference): 这个非常重要。它的强度比软引用更低。只要 GC 扫描到了,不管内存够不够,都会回收它。

    • 应用场景:这是我在解决 Handler 内存泄漏或者单例模式持有 Context 泄漏时最常用的方案。比如 Activity 里的静态内部类 Handler,为了调用 Activity 的方法,我通常会持有一个 WeakReference<Activity>。这样既能调用方法,又不会因为 Handler 里的 Message 没处理完而导致 Activity 无法销毁。
  4. 虚引用 (Phantom Reference): 这个平时业务开发用得少,它是最弱的。一个对象是否有虚引用,完全不影响它的生存时间,甚至无法通过虚引用获取对象实例(get() 永远返回 null)。

    • 它的唯一作用是跟踪对象被垃圾回收的活动。当对象被回收时,系统会把这个引用放入引用队列(ReferenceQueue)中。
    • 底层应用:我在看 Java NIO 源码的时候发现,DirectByteBuffer 就利用了虚引用(具体是 Cleaner 类)来管理堆外内存。因为堆外内存 GC 管不到,所以当堆内的 Java 对象被回收时,通过虚引用机制收到通知,然后去调用 C++ 层释放那块 Native 内存,防止泄露。

所以,这四种引用其实就是给了我们一种与 GC 交互的手段,让我们能根据对象的生命周期需求,灵活地决定它们什么时候该“死”。


Java 垃圾回收机制中,死亡对象的判断方式有哪些?常见的垃圾回收算法有哪些?

嗯,这块内容是 JVM 和 ART 虚拟机的核心了。我分两部分来说,先说怎么判断对象死亡,再说怎么回收。

第一部分:判断对象是否死亡

主要有两种方式:

  1. 引用计数法 (Reference Counting): 简单说就是对象被引用一次就 +1,引用失效就 -1,变成 0 就回收。

    • 缺陷:它有一个致命的问题就是循环引用。比如 A 引用 B,B 又引用 A,它俩的计数器永远不为 0,但其实都没用了。所以现在的 Java 虚拟机(包括 Android 的 ART)基本不使用这种方式来判断存活。
  2. 可达性分析算法 (Reachability Analysis): 这是目前主流的判断方式。它的思路是从一系列被称为 "GC Roots" 的对象作为起点,向下搜索,能搜到的就是活的,搜不到的就是“垃圾”。

    • 关于 GC Roots:我在排查内存泄漏时,主要关注这几类 Roots:

      • 虚拟机栈中引用的对象(比如方法里的局部变量)。
      • 类静态属性引用的对象(比如 static 单例)。
      • JNI 中引用的对象(Native 引用)。
    • 我们在 Android Studio 里用 Profiler 抓 Heap Dump 的时候,其实就是在看对象到 GC Roots 的引用链。

第二部分:常见的垃圾回收算法

GC 算法其实是在“空间碎片”和“效率”之间做权衡,主要有这么几种:

  1. 标记-清除算法 (Mark-Sweep): 最基础的。先标记出垃圾,然后直接清除。

    • 缺点:会产生大量不连续的内存碎片。如果后面要分配一个大对象(比如一个大的 Bitmap),明明剩余总内存够,但没有足够大的连续空间,就会触发不必要的 GC。
  2. 复制算法 (Copying): 把内存分为两块,每次只用一块。回收时,把活着的复制到另一块,然后把当前这块全部清空。

    • 优点:没有碎片,简单高效。
    • 缺点:内存利用率低,浪费了一半空间。
    • 应用:一般用在新生代(Young Generation)。因为新生代里大部分对象都是“朝生夕死”的,存活的很少,复制成本低。
  3. 标记-整理算法 (Mark-Compact): 标记完之后,不直接清,而是把存活对象往一端移动,然后清理掉边界外的内存。

    • 应用:一般用在老年代(Old Generation)。因为老年代对象存活率高,不适合复制算法,为了避免碎片,就用这个。
  4. 分代收集算法 (Generational Collection): 这其实是目前商业虚拟机(包括 Android 的 ART)的整体策略

    • 它根据对象的存活周期,把堆内存分为新生代和老年代。
    • 新生代用复制算法(GC 频率高,速度快)。
    • 老年代用标记-整理或者标记-清除(GC 频率低)。

在 Android 中,比如早期的 Dalvik 和现在的 ART,虽然实现细节有差异(比如 ART 做了很多并发 GC 的优化来减少 Stop-The-World 的时间,避免 UI 卡顿),但核心思想依然是基于分代收集和可达性分析的。


反编译抖音源码并进行网络抓包的具体流程是什么?遇到的最大难点是什么?如何解决的?

嗯,这个问题其实非常有挑战性。之前为了研究大厂的协议设计和安全防护,我确实在本地环境下尝试分析过这类 App。我主要分为环境搭建、抓包分析、反编译调试这几个步骤来进行。

首先是流程

  1. 工具准备:我会用 Charles 或者 Fiddler 作为抓包代理,配合 Postern 或者手机系统自带的代理设置。反编译方面,主要是 APKTool 用来解包资源,Jadx-GUI 用来查看 Java 层代码,如果是 Native 层分析,那肯定离不开 IDA Pro。
  2. 证书配置:这是第一步。因为现在的 App 全是 HTTPS,我需要把抓包工具的 CA 证书安装到手机里。注意,对于 Android 7.0 以上的系统,默认只信任 System 分区的证书,所以如果是 Root 过的手机,我会把证书通过 mv 命令或者 Magisk 模块移动到 /system/etc/security/cacerts/ 目录下,让它成为系统级证书。
  3. 初步抓包:配置好后,打开 App 刷一下。对于普通 App 这时候就能看到明文了,但对于抖音这种级别的 App,这时候看到的肯定是 "Unknown" 或者红色的连接失败,因为它们都有 SSL Pinning(证书锁定)

这就引出了最大的难点,以及我是怎么解决的:

难点一:HTTPS 证书锁定 (SSL Pinning) 抖音使用了非常严格的 SSL Pinning 机制,它在客户端内置了服务端证书的 Hash,连接时会校验,如果我们用 Charles 的证书中间人攻击,校验就会失败。 解决方案: 我使用了 Frida 进行动态插桩。 其实不用去改 APK 源码重新打包(那样会有签名校验问题),直接写一个 Frida 脚本,Hook 内存中的验证逻辑。比如针对 Java 层常用的 OkHttp,去 Hook CertificatePinnercheck 方法,让它直接 return 空,不做抛出异常的处理;或者更底层一点,Hook javax.net.ssl.X509TrustManagercheckServerTrusted 方法。对于一些使用了 Native 网络库的情况,可能还需要去 Hook boringssl 里的 SSL_CTX_set_custom_verify 相关的函数。

难点二:请求参数加密与签名 (X-Gorgon / X-Khronos) 这是更深一层的难点。解决完抓包后,你会发现请求体和 Header 里有一堆乱码一样的参数,比如 X-Gorgon。如果我想模拟请求,必须知道这个签名是怎么生成的。 解决方案: 这通常涉及到 Native 层逆向。 我通过 Jadx 追踪调用链,发现这些加密逻辑都下沉到了 .so 文件里(比如 libcms.solibttencrypt.so)。这时候直接看汇编很难,因为它们用了 O-LLVM 混淆,控制流平坦化非常严重。 我的做法是使用 Unidbg 或者 Frida 的 RPC 机制。我不去硬着头皮还原它的加密算法,而是把这个 .so 加载起来,或者直接在手机运行的进程里,通过 Frida 暴露一个接口,我要算签名的时候,直接远程调用它内存里的那个加密函数,也就是所谓的“算法借用”。

所以总结一下,流程是基础,真正的难点在于绕过 SSL Pinning 和搞定 Native 层的参数签名,核心武器是 Frida 和 IDA。


除了修改源码的方式,还有哪些中间人攻击手段可以绕过 CA 证书校验?

嗯,这其实就是在问,如果我们拿不到源码,或者不想进行重打包(怕触发签名校验)的情况下,怎么搞定 SSL Pinning。在我的实践中,主要有这么几种非侵入式或者运行时的手段:

1. 使用 Xposed / LSPosed 模块(JustTrustMe) 这是最经典的方法。如果手机 Root 并安装了 Xposed 框架,可以直接安装像 JustTrustMe 或者 TrustMeAlready 这样的模块。 原理是:这些模块在系统启动或者 App 启动时,自动 Hook 了 Java 核心库中所有涉及 HTTPS 校验的 API,比如 JSSEApache HttpClientOkHttp 的相关类。它简单粗暴地把 checkServerTrusted 这类方法置空,或者把 HostnameVerifier 永远返回 true。 这种方式的好处是全自动,不需要针对特定 App 写代码,对绝大多数中小厂的 App 直接有效。

2. Frida 动态插桩 (Dynamic Instrumentation) 这比 Xposed 更灵活,也是我目前用得最多的。 不需要重启手机,写好 JavaScript 脚本,通过 frida -U -f com.example.app -l script.js 启动即可。

  • Objection 工具:这是一个基于 Frida 的自动化工具。我通常会直接用命令 android sslpinning disable,它内置了市面上几十种常见的 SSL Pinning 绕过脚本,覆盖了 OkHttp、Retrofit、TrustManager 等等。
  • 自定义脚本:如果通用的不行,我就去反编译看它用了什么网络库,然后针对性地写 Hook 代码,在内存中动态修改验证函数的返回值。

3. 将抓包证书导入系统信任区 (针对 Android 7.0+) 虽然这不算直接绕过 Pinning(如果是强校验 Hash 还是不行),但对于很多只是简单配置了 network_security_config.xml 禁止用户证书的 App 来说,这招是必杀技。 因为 Android 7.0 以后不再信任用户安装的证书,我们必须把 Charles/Fiddler 的证书,计算 Hash 值重命名后,放入 /system/etc/security/cacerts/ 目录。这样 App 就会认为这是系统内置的 CA,从而放行。

4. 虚拟环境 / 容器技术 (VirtualApp) 还有一种比较偏门的,就是把 App 运行在 VirtualApp 这类容器里。容器拥有 App 的所有权限,可以拦截 App 的系统调用。有些商业化的抓包工具就是基于这个原理,在沙盒内部拦截网络请求,不过现在的 App 对虚拟环境的检测也挺严的,这招成功率在下降。

总的来说,现在的核心思路都是运行时 Hook,在内存层面“欺骗”App 校验通过,而不是去改它的二进制文件。


如何设计方案防止 APP 被抓包和破解?

嗯,既然知道了怎么攻,防守的时候就要针对性地做纵深防御。没有任何一种手段是绝对安全的,我们的目标是极大提高攻击者的成本。如果我要设计一套防护方案,我会从网络传输本地环境两个维度入手:

第一维度:网络层防护 (防止抓包)

  1. 强 SSL Pinning (证书锁定): 绝不能只依赖系统 CA。我会在客户端代码里硬编码服务端证书的公钥 Hash,或者用 OkHttp 的 CertificatePinner

    • 进阶版:为了防止证书过期导致 App 不可用,可以配置双证书备份,或者使用公钥锁定而不是证书锁定。
  2. 双向认证 (mTLS): 这是大招。不仅客户端验服务端,服务端也要验客户端。我们在打包时把一个客户端证书(p12 文件)加密存放在 App 里。如果攻击者用 Charles 抓包,Charles 只能伪造服务端证书,但他没有客户端的私钥,服务端握手时直接就拒绝连接了。这能防住 99% 的脚本小子。

  3. 应用层签名与加密: 不要让请求参数裸奔。

    • 签名 (Sign):把所有参数按字母排序,加上一个 Salt(盐),计算 MD5 或 SHA256,放在 Header 里。服务端收到后按同样算法算一遍,对不上就丢弃。
    • 加密:关键数据(如登录态、金钱)直接在 Body 里进行 AES 或 RSA 加密。即使被抓包,看到的也是乱码。

第二维度:本地代码防护 (防止逆向与 Hook)

  1. 核心逻辑 Native 化: Java 代码太容易被反编译了。我会把加密算法、Sign 生成逻辑、核心业务逻辑下沉到 C/C++ 层(NDK 开发)。

    • 并且,对 .so 文件进行 O-LLVM 混淆(控制流平坦化、虚假控制流),让 IDA 看起来像天书一样。
  2. 环境检测 (Anti-Environment)

    • Root/越狱检测:检查常见的 su 文件、Magisk 路径。
    • 模拟器检测:检查 CPU 架构、电池信息、传感器数据等。
    • 代理检测:检查系统代理设置,如果发现了代理,直接拒绝网络请求。
  3. 运行时对抗 (Anti-Hook/Anti-Debug)

    • 检测 Frida/Xposed:遍历 /proc/self/maps 查看有没有加载 Frida 相关的 so 库,或者检测 27042 端口。
    • Ptrace 占坑:在 Native 层自己 ptrace 自己(双进程守护),因为 Linux 下一个进程只能被 ptrace 一次,这样别人的调试器就挂不上去了。
  4. 完整性校验: App 启动时,计算自身的 APK 签名或 DEX 文件的 CRC 值。如果发现被修改了(说明可能被重打包了),直接 Crash 退出。

总结一下: 我会采用 "HTTPS 双向认证 + Native 混淆加密 + 运行时 Hook 检测" 的组合拳。虽然像 Frida 这种工具很强大,但如果我们把核心逻辑藏得够深(比如动态加载 so,代码虚拟化),会让破解者的耗时呈指数级上升,这其实就已经达到了防御的目的。


TCP 三次握手的具体过程是什么?

嗯,TCP 三次握手是网络编程里最基础但也最核心的概念了。它的本质其实不仅仅是建立连接,更重要的是为了同步双方的初始序列号(ISN),保证数据传输的可靠性。

我在学习计算机网络的时候,喜欢配合着 Wireshark 抓包或者 Linux 的 netstat 状态来看,这样理解会深很多。整个过程简单来说,就是 Client 和 Server 互相确认“我能发”、“我能收”的一个过程。

具体的流程是这样的:

  1. 第一次握手(Client -> Server): 客户端(比如我们的 Android App)调用 Socket.connect() 时,内核会发送一个 SYN 报文段。这个时候,客户端会随机生成一个初始序列号 seq=x

    • 发送完之后,客户端进入 SYN_SENT 状态。
    • 这一步的意思是:“你好,我想跟你建立连接,我的数据是从 x 开始传的。”
  2. 第二次握手(Server -> Client): 服务端收到 SYN 后,必须回复一个 SYN+ACK 报文段

    • ACK:确认号是 x+1,表示“收到了,你下次发 x+1 给我就行”。
    • SYN:服务端也得告诉客户端它的初始序列号,比如 seq=y
    • 这时候服务端进入 SYN_RCVD 状态。
    • 这一步的意思是:“收到你的请求了,我这边也没问题,我的数据是从 y 开始传的。”
  3. 第三次握手(Client -> Server): 客户端收到服务端的 SYN+ACK 后,会向服务端发送最后一个 ACK 报文段

    • 确认号是 y+1,表示“我也收到你的序列号了”。
    • 发送完毕后,客户端进入 ESTABLISHED 状态,服务端收到后也进入 ESTABLISHED 状态。
    • 这一步完成后,连接才算真正建立,Java 层的 connect() 方法才会返回。

这里常被问到的一个细节是:为什么一定要三次,两次行不行? 其实是不行的。最主要的原因是为了防止“已失效的连接请求报文段”突然又传到了服务端。 试想一下,如果只有两次握手:客户端发了一个 SYN,因为网络卡了没到,于是客户端重发了一个新的 SYN 建立了连接。等连接断开后,那个“迷路”的旧 SYN 突然到了服务端。如果是两次握手,服务端会以为是新的连接,直接回复 ACK 并分配资源,但客户端根本不理它(因为客户端没有发起新请求),这样服务端就会一直傻等着,造成资源浪费。

而在 Android 开发中,虽然我们通常用 OkHttp 这种上层库,但在做 Socket 长连接(比如 IM 聊天、推送服务)或者排查 Keep-Alive 问题时,理解这个握手状态(特别是 SYN_SENT 卡住通常意味着网络不通或防火墙拦截)是非常有帮助的。


TCP 和 UDP 的区别是什么?各自的使用场景有哪些?

嗯,这也是个经典问题。TCP 和 UDP 都在传输层,但它们的设计哲学完全不同。TCP 是为了“可靠”,而 UDP 是为了“快”。

我也结合一下我们在 Android 开发或者目标岗位(比如字节的音视频业务)的场景来对比一下:

1. 核心区别

  • 连接性

    • TCP 是面向连接的。发数据前必须先三次握手,发完了还得四次挥手。它维护了连接的状态。
    • UDP 是无连接的。想发就发,根本不管对方在不在,也不需要建立通道。
  • 可靠性

    • TCP 保证数据不丢失、不重复、按序到达。它有超时重传、流量控制(滑动窗口)、拥塞控制这些复杂的机制。
    • UDP 是“尽最大努力交付”,发出去就不管了,丢包了也不负责重传,数据到了也不保证顺序。
  • 数据形式

    • TCP 是面向字节流的。应用层给的数据可能会被拆分或合并(这就导致了所谓的粘包/拆包问题)。
    • UDP 是面向报文的。应用层给多大,它就发多大,保留了消息边界。

2. 使用场景

  • TCP 的场景

    • 绝大多数的应用层协议:比如 HTTP/HTTPS(浏览网页、刷 Feed 流)、FTP(文件下载)、SMTP(发邮件)。
    • App 开发中:我们的 API 请求(Retrofit/OkHttp)、下载 APK 更新包、登录注册接口。这些场景下,数据准确性是第一位的,少一个字节都不行,慢点没关系。
  • UDP 的场景

    • 实时性要求高的场景:比如 直播、视频会议(Zoom/腾讯会议)、在线游戏(王者荣耀)
    • 在这些场景里,实时性 > 可靠性。比如视频通话,偶尔丢一帧画面只是闪一下,但如果为了保证这一帧通过 TCP 重传导致画面卡顿几秒,体验就全毁了。
    • 还有基础服务像 DNS 查询,因为它包小,一来一回很快,用 UDP 最轻量。

3. 特别补充(针对字节/腾讯面试)

其实现在TCP 和 UDP 的界限正在模糊。 比如 Google 提出的 QUIC 协议(HTTP/3 的底层),它其实是基于 UDP 实现了一套类似 TCP 的可靠传输机制。 为什么要这么做?因为 TCP 在内核层实现,改动太慢,而且有**队头阻塞(Head-of-Line Blocking)**的问题。 像抖音、快手这种短视频应用,为了追求极致的加载速度和弱网表现,底层网络库(比如 Cronet)很多都已经切到 QUIC 了。这说明 UDP 的可定制性在现代高性能网络中反而成了优势,让我们能在应用层(User Space)自己去实现更高效的拥塞控制算法。


HTTP 协议的常见请求方法有哪些?GET 和 POST 的区别是什么?

嗯,HTTP 方法我们在写 Retrofit 接口定义的时候天天都在用。

常见的请求方法主要有这么几个:

  • GET:获取资源。
  • POST:提交数据(新建)。
  • PUT:更新资源(全量更新)。
  • PATCH:更新资源(局部更新,这个在 RESTful 规范里很常见)。
  • DELETE:删除资源。
  • HEAD:只获取响应头(比如做断点续传前先查一下文件大小)。
  • OPTIONS:跨域请求(CORS)时的预检请求。

关于 GET 和 POST 的区别,我觉得可以从规范层面底层实现层面两个角度来说:

1. 规范层面(语义区别)

  • 幂等性(Idempotence):这是最重要的区别。

    • GET 是安全且幂等的。无论我调多少次,服务器上的资源状态都不会改变(只是读数据)。
    • POST 是非幂等的。调一次就创建一个资源,调两次就创建两个。
  • 参数位置

    • GET 的参数通常放在 URL 里(Query Parameter)。
    • POST 的参数通常放在 Request Body 里。
  • 缓存

    • GET 请求默认是可以被浏览器或 CDN 缓存的。
    • POST 请求默认不会被缓存。

2. 细节与底层层面(面试加分项)

  • 数据长度限制

    • 大家常说 GET 有长度限制(比如 2KB),POST 没有。其实 HTTP 协议本身并没有限制 URL 长度,这个限制是浏览器(如 Chrome)或服务器(如 Tomcat/Nginx)为了性能和安全加上去的。
  • 安全性

    • GET 比 POST 更不安全,因为参数直接暴露在 URL 上,会被保留在浏览器历史记录或服务器 Access Log 中。如果有密码之类的敏感信息,绝对不能用 GET。
  • 底层数据包(TCP)

    • 这就比较硬核了。虽然在 HTTP 语义上它们不同,但在**传输层(TCP)**看来,它们都是 TCP 数据流。
    • 有一个流传很广的说法是“GET 发一个包,POST 发两个包(先发 Header,Server 回 100 Continue,再发 Body)”。
    • 其实这并不准确。大部分现代框架(包括 Android 的 OkHttp)为了效率,对于 POST 请求,除非 Body 特别大或者显式设置了 Expect: 100-continue,否则通常也是Header 和 Body 一起发送的,不会刻意分成两个包来拖慢速度。

所以总结下来,GET 适合用来查询信息,POST 适合用来提交业务数据。在 Android 开发中,我们要严格遵循后端定义的 RESTful 规范来选择合适的方法。


HTTP 请求报文和响应报文的格式是什么?

嗯,这个问题其实我们在用 Charles 或者 Fiddler 抓包调试接口的时候,每天都在盯着看。虽然现在用 Retrofit 这种上层库把细节封装了,但理解报文格式对于排查“为什么后端收不到参数”或者“为什么解析失败”这类问题是非常关键的。

HTTP 报文不管是请求还是响应,整体结构都非常清晰,都是由三部分组成的:起始行、头部(Headers)和主体(Body),中间用空行分隔

我具体展开说一下:

1. 请求报文 (Request Message)

它的结构是:请求行 + 请求头 + 空行 + 请求体

  • 请求行 (Request Line): 这是第一行,包含三个信息:请求方法(GET/POST 等)、请求 URL(具体的路径,比如 /api/v1/user)和 HTTP 协议版本(比如 HTTP/1.1)。

    • 比如:POST /login HTTP/1.1
  • 请求头 (Request Headers): 这里面全是 Key: Value 的键值对。我们在 Android 开发里最常设置的就在这儿。

    • 比如 Content-Type: application/json 告诉服务端我发的是 JSON。
    • 比如 User-Agent 告诉服务端我是 Android 手机。
    • 还有 Cookie 用来带上 Session ID。
  • 空行: 就是一个 \r\n,用来告诉服务器:“头部分结束了,下面是正文了”。

  • 请求体 (Request Body): 真正的数据就在这。如果是 GET 请求,这里通常是空的;如果是 POST,这里就是 JSON 字符串或者表单数据。

2. 响应报文 (Response Message)

结构跟请求很像:状态行 + 响应头 + 空行 + 响应体

  • 状态行 (Status Line): 第一行,包含:协议版本状态码(Status Code)和 状态描述(Reason Phrase)。

    • 比如:HTTP/1.1 200 OK 或者 HTTP/1.1 404 Not Found
    • 我们在 Retrofit 的 Callback 里判断 response.isSuccessful() 其实就是在检查这个状态码是不是 2xx。
  • 响应头 (Response Headers): 服务端告诉我们的元数据。

    • 比如 Content-Length 告诉我们 Body 有多大。
    • Set-Cookie 让我们保存登录态。
    • Content-Encoding: gzip 告诉我们数据是压缩过的,OkHttp 会自动帮我们解压。
  • 空行: 同样是分隔符。

  • 响应体 (Response Body): 这就是我们最关心的业务数据了,通常是 JSON,或者是图片二进制流。

其实在底层实现上,比如我看 OkHttp 的源码,Http1ExchangeCodec 类里写得很清楚,它就是严格按照这个格式,通过 Sink(Okio 的输出流)先把 Request Line 写进去,再写 Headers,最后写 Body。解析的时候也是按行读取,直到读到空行,再根据 Content-Length 读取 Body。理解了这个,就能明白为什么有时候 Body 里的换行符会导致解析异常,或者 Header 太大为什么会报错了。


浏览器输入 URL 到页面渲染完成的完整流程是什么?

哈哈,这是一道计算机网络的“背诵全文”级的题目了。不过作为 Android 开发者,我更喜欢从网络交互渲染机制这两个角度去理解它,因为 WebView 的加载流程或者 Native 的页面加载其实都有相通之处。

简单来说,这个过程可以分为 网络请求页面解析渲染 两个大阶段。

第一阶段:网络请求(拿数据)

  1. URL 解析: 浏览器先看你输的是不是合法的 URL。如果是关键字,就跳去搜索引擎;如果是 URL,就分析协议(HTTP/HTTPS)、域名、路径。
  2. DNS 解析: 要把域名变成 IP 地址。这个过程涉及浏览器缓存、系统缓存(Hosts)、路由器缓存,最后才到 ISP 的 DNS 服务器递归查询。(这个下一题我会细说)。
  3. 建立 TCP 连接: 拿到 IP 后,开始三次握手。
  4. TLS/SSL 握手(如果是 HTTPS): 现在基本都是 HTTPS 了。在 TCP 握手后,还要进行四次 TLS 握手,交换密钥,建立加密通道。这一步其实挺耗时的,所以我们做 Android 网络优化时会关注 Connection Pool(连接池)复用,避免每次都握手。
  5. 发送 HTTP 请求: 构建我刚才说的请求报文,发给服务器。
  6. 服务器处理与响应: 服务器 Nginx 反向代理、后端业务逻辑处理、数据库查询,最后返回 HTML 文本。

第二阶段:页面解析渲染(画界面)

这时候浏览器拿到了 HTML,浏览器的内核(比如 Chrome 的 Blink,或者 Android WebView 的内核)开始工作:

  1. 解析 HTML 构建 DOM 树: 从上到下解析标签,生成 Document Object Model 树。这跟我们在 Android 里 inflate XML 生成 View 树的概念很像。
  2. 解析 CSS 构建 CSSOM 树: 遇到 <style><link>,解析样式,生成 CSS 对象模型树。
  3. 生成渲染树 (Render Tree): 把 DOM 树和 CSSOM 树结合。注意,display: none 的节点不会出现在这里,这跟 Android 的 View.GONE 不参与 measure/layout 是一样的道理。
  4. 布局 (Layout/Reflow): 计算每个节点在屏幕上的确切位置和大小。这对应 Android View 体系里的 onMeasureonLayout 阶段。
  5. 绘制 (Paint): 把每个节点画出来(颜色、文字、边框)。这对应 Android 的 onDraw
  6. 合成 (Composite): 现在的浏览器和 Android 一样,都是硬件加速的。它会把不同的层(Layer)分别绘制,最后由 GPU 合成到一起显示在屏幕上。在 Android Framework 里,这就像是 SurfaceFlinger 把多个 Surface 合成并丢给 FrameBuffer 的过程。

最后,如果页面里有 JS,JS 的执行可能会阻塞 DOM 解析(这也是为什么 <script> 标签通常放到底部),或者 JS 修改了 DOM/CSS,会触发 回流 (Reflow)重绘 (Repaint),这在性能优化里是需要极力避免的。


DNS 解析的具体过程是什么?

嗯,DNS (Domain Name System) 就像是互联网的通讯录。我们在做 Android 网络优化时,DNS 优化往往是第一刀,比如大厂常用的 HTTPDNS,就是为了绕过传统 DNS 的坑。

传统 DNS 解析是一个分级查询的过程,大概可以分为这几步:

1. 浏览器/应用缓存检查: 首先看浏览器(或者我们的 App 内存)里有没有缓存过这个域名的 IP。如果有,直接用,速度最快。

2. 操作系统缓存检查 (Hosts): 如果应用没缓存,就去问操作系统(比如 Android 的 /etc/hosts 文件或者系统 DNS 缓存)。在 Android Framework 里,这个过程通常涉及到 netd 守护进程。

3. 路由器缓存/ISP DNS 缓存: 如果手机里也没找到,请求就会发给路由器,然后到运营商(ISP)提供的 Local DNS 服务器(递归解析服务器)。 注意:绝大多数普通用户的查询,到这一步如果 Local DNS 有缓存,就直接返回了。但问题也出在这,运营商的 DNS 经常有 DNS 劫持 或者 调度不准(比如我是联通网,它给我解析到电信的 IP)的问题。

4. 根域名服务器 (Root DNS): 如果 Local DNS 也没缓存,它就开始替我们跑腿了。它先去问全球只有 13 组的根域名服务器(.):

  • “喂,www.google.com 的 IP 是多少?”
  • 根服务器说:“我不知道,但你知道 .com 是谁管的,去找它吧。”

5. 顶级域名服务器 (TLD DNS): Local DNS 接着去问 .com 的顶级域名服务器:

  • “喂,www.google.com 的 IP 是多少?”
  • TLD 服务器说:“我不知道,但你知道 google.com 的权威 DNS 是谁,你去问它。”

6. 权威域名服务器 (Authoritative DNS): Local DNS 最后去问 google.com 的权威 DNS 服务器(通常是企业自己配置的,或者阿里云/AWS 这种):

  • “喂,www.google.com 的 IP 是多少?”
  • 权威 DNS 说:“查到了,是 142.250.x.x,给你。”

7. 返回结果与缓存: Local DNS 拿到 IP 后,先自己缓存一份,然后返回给操作系统,操作系统再返回给浏览器/App。

针对这个过程,我在做项目的时候,为了解决运营商 Local DNS 的劫持和跨网访问慢的问题,我研究过 HTTPDNS 方案。 也就是我们 App 不走 系统的 DNS 解析流程(不走 UDP),而是直接通过 HTTP 请求去访问我们自己的服务器,服务器返回最优的 IP 给我们。比如腾讯的 WCDB 或者阿里的 HTTPDNS 服务,在 Android Framework 层,我们需要 hook 或者自定义 OkHttp 的 Dns 接口,把 InetAddress.getAllByName 的逻辑替换成 HTTPDNS 的请求,这样就能从根源上解决 DNS 的问题。


安卓的 Handler 消息机制的底层原理?

嗯,这个问题可以说是 Android 面试的必考题了。但我不想只停留在“Handler 发送消息,Looper 轮询消息”这个表面层面,我想结合我在 Framework 源码里看到的实现机制来谈谈我的理解。

首先结论是:Handler 机制不仅仅是用于线程间通信,它其实是整个 Android 系统“事件驱动”的基石。ActivityThreadmain 方法入口,本质上也是启动了一个 Looper 循环来接管整个应用的生命周期。

它的核心包含四个部分:HandlerMessageMessageQueueLooper

1. Java 层的逻辑闭环 简单来说,我们在子线程调用 handler.sendMessage(),最终会走到 MessageQueue.enqueueMessage(),把消息按时间顺序插入链表。 然后主线程的 Looper.loop() 是一个死循环,它不断调用 MessageQueue.next() 取出消息,交给 handler.dispatchMessage() 去执行。

2. 核心难点:为什么 Looper 的死循环不会卡死主线程? 这涉及到了 Linux 的 epoll 机制。 我在看 MessageQueue 的源码时发现,它的 next() 方法里有一个关键的 Native 调用:nativePollOnce(ptr, timeoutMillis)

  • 当没有消息时:这个方法会通过 epoll 机制进入阻塞休眠状态,释放 CPU 时间片。所以虽然是死循环,但它不空转,不耗电。
  • 当有新消息入队时enqueueMessage() 会调用 nativeWake()。这个方法会向一个 eventfd 写入数据,唤醒 epoll,从而让 nativePollOnce 返回,主线程继续处理消息。 所以,Handler 的本质是 Java 层和 Native 层配合实现的一套基于 epoll 的 IO 多路复用机制

3. 进阶细节:同步屏障 (Sync Barrier) 我在研究 屏幕刷新机制 (Choreographer) 的时候,发现了一个特殊的方法 postSyncBarrier()。 它会往消息队列插一个 target 为 null 的特殊消息。一旦 Looper 碰到它,就会跳过所有的普通消息,优先处理所有的“异步消息”(SetAsynchronous(true))。 这是为了保证 UI 绘制(Vsync 信号来了之后的 Traversal 任务)拥有最高优先级,不被普通的 Handler 消息(比如后台下载进度更新)给堵住,从而避免掉帧。

所以总结一下,Handler 是一套基于内存共享(MessageQueue)和 Linux 管道/epoll 机制(NativeLooper)的跨线程通信方案,并且通过同步屏障机制保障了 UI 渲染的实时性。


安卓的事件分发机制是什么?

嗯,事件分发机制是 Android View 体系里非常重要的一环,特别是做自定义 View 或者处理复杂手势冲突的时候。

整体结论是:这是一个基于“责任链模式”的 U 型分发过程。 事件从 Activity 产生,一层层往下传(Dispatch),直到最底层的 View;如果 View 不消费,再一层层往回传(Handling)。

我习惯把这个过程分为三个核心方法来看:

  1. dispatchTouchEvent:负责分发。
  2. onInterceptTouchEvent:负责拦截(只有 ViewGroup 有)。
  3. onTouchEvent:负责消费。

具体的源码流程,我是这么理解的:

1. 事件的入口 硬件层产生的触摸信号,经由 InputManagerService 处理后,通过 Socket 传递给 App 端的 ViewRootImpl。它会调用 DecorView 的 dispatchTouchEvent,这就开始了 View 树的分发。

2. ViewGroup 的分发逻辑 (dispatchTouchEvent) 这里面有两个关键点:

  • 拦截判断:ViewGroup 会先判断 disallowIntercept 标记位(子 View 是否请求不要拦截),如果没有被置位,它就会调用自己的 onInterceptTouchEvent。如果返回 true,事件就被它自己扣下了,直接交给自己的 onTouchEvent 处理,不再往下传。
  • 寻找 Target:如果没拦截,它会倒序遍历子 View(按照 Z-order,也就是先看浮在上面的 View),判断坐标是否在子 View 范围内。如果在,就调用子 View 的 dispatchTouchEvent

3. mFirstTouchTarget 优化 这是一个很重要的性能优化点。 我在看 ViewGroup.java 源码时注意到,一旦有一个子 View 消费了 ACTION_DOWN 事件(返回 true),ViewGroup 就会把这个子 View 赋值给 mFirstTouchTarget 链表。 这就意味着:后续的 MOVEUP 事件,ViewGroup 不需要再遍历所有子 View 了,而是直接分发给 mFirstTouchTarget 记录的那个 View。这在复杂的 View 树中能极大提升分发效率。

4. View 的消费逻辑 (onTouchEvent) 到了最底层的 View,逻辑就比较简单了。 它会先看 OnTouchListener(也就是我们 set 的 onTouch 回调)。如果 onTouch 返回 true,那 onTouchEvent 就不会被调用,点击事件(onClick)也就不会触发。 如果 onTouch 返回 false,才会走 onTouchEvent,并在 ACTION_UP 时触发 performClick,也就是回调 onClick

所以,整个机制就像是一个公司的审批流:老板(Activity)收到任务,问经理(ViewGroup)能不能做,经理问员工(View)。如果员工能做(return true),以后这类任务都给他;如果员工做不了(return false),就退给经理做;经理也做不了,就退回给老板。


如何处理事件冲突(如 ViewPager 嵌套 ListView)?

嗯,滑动冲突是 Android 开发里特别典型的场景。本质上,这种冲突是因为父容器(ViewPager)和子 View(ListView)都想响应滑动手势,但系统不知道该把事件交给谁。

处理这个问题,主要有两种通用的模式,也就是我们在教科书或者源码里常看到的“外部拦截法”和“内部拦截法”。

1. 外部拦截法(Parent 说了算) 这是我比较推荐的方法,因为它符合 Android 事件分发的正常逻辑,代码逻辑比较清晰。

  • 原理:重写父容器(ViewPager)的 onInterceptTouchEvent

  • 逻辑

    • ACTION_DOWN 时,必须返回 false。因为如果父容器拦截了 DOWN,后续所有事件都直接归它,子 View 就永远接收不到任何事件了。
    • ACTION_MOVE 时,根据业务逻辑判断。比如计算 dx(水平位移)和 dy(垂直位移)。如果 Math.abs(dx) > Math.abs(dy),说明用户是想横向滑,父容器就 return true 把事件拦截下来自己处理;否则返回 false,交给子 View(ListView)去处理垂直滚动。
    • ACTION_UP 时,返回 false。

2. 内部拦截法(Child 说了算) 这种方式稍微复杂一点,需要配合 requestDisallowInterceptTouchEvent() 方法。

  • 原理:父容器默认不拦截任何事件(除了 DOWN),所有的决策权交给子 View。

  • 逻辑

    • 子 View(ListView)重写 dispatchTouchEvent
    • ACTION_DOWN 时,调用 parent.requestDisallowInterceptTouchEvent(true)。这就好比告诉父容器:“这事我先接手了,你别插手。”
    • ACTION_MOVE 时,如果子 View 发现用户其实是想横向滑(比如检测到 dx > dy),它就调用 parent.requestDisallowInterceptTouchEvent(false)
    • 这就相当于子 View 主动放弃了特权,父容器的 onInterceptTouchEvent 会再次生效,把事件拦截回去。

3. 实际项目中的补充(NestedScrolling) 其实,针对 ViewPager 嵌套 ListView 这种经典场景,Google 早就帮我们解决好了。 但在更复杂的场景下(比如我之前做过的一个首页,外层是 CoordinatorLayout,里面嵌套了复杂的 Recycler 列表),单纯用拦截法会比较麻烦,特别是处理惯性滑动(Fling)的传递。 这时候我会优先使用 Jetpack 的 NestedScrolling 机制(即 NestedScrollingParentNestedScrollingChild 接口)。

  • 它的机制不是靠“抢”事件,而是靠“协商”。
  • 子 View 在滑动前,会先问父 View:“我要滑这么大距离,你要不要先消费一点?”(dispatchNestedPreScroll)。
  • 这样就能非常优雅地实现比如“列表上滑时,顶部 Toolbar 自动折叠”这种联动效果,完全不需要手动去算拦截逻辑。

所以总结一下:简单冲突用外部拦截法,复杂联动或者标准控件嵌套优先用 NestedScrolling


如何扩大小按钮的点击区域?

这个问题在实际开发里其实还挺常见的,尤其是 UI 设计为了美观把图标做得特别小的时候。嗯...根据我的经验,主要有 三个方案,我会根据具体场景来选。

首先,最官方、也是最优雅 的方案是使用 TouchDelegate。 这个类专门就是为了解决这个问题的。它的原理其实是在父容器的 onTouchEvent 里做了一层代理,把点击事件“劫持”并转发给子 View。 具体的做法是,我们得在父布局(比如 FrameLayout 或者 ConstraintLayout)的 post 方法里去获取这个小按钮的 HitRect(点击区域),因为在 onCreate 的时候 View 还没测量布局完嘛,拿不到坐标。 拿到 Rect 之后,我们通过 Rect.inset() 方法把这个区域往外扩,比如上下左右各扩 100 像素,这扩大后的区域就是 代理响应区。最后实例化一个 TouchDelegate 对象传给父 View 的 setTouchDelegate 就行了。

不过呢,TouchDelegate 有个小坑,就是一个父 View 默认只能设置一个 Delegate。如果一个布局里好几个小按钮都要扩大,那可能得自定义一个支持多 Delegate 的 View,或者套多层布局,这就有点过度设计了。

所以,第二个方案,也就是 我平时用得最多、最简单粗暴的,就是 巧用 Padding。 如果这个按钮是 ImageView 或者 ImageButton,我通常会给它设置一个透明的背景,然后增加 padding。 因为 View 的点击区域是包含 Padding 的嘛。只要把 android:background 设成透明,然后加大 padding,这样视觉上图标大小没变,但实际占用的点击面积变大了。这个改法成本最低,也不需要写代码,直接改 XML 就行。

第三个方案,如果是那种 自定义 View 的情况。 那我可能会重写 onTouchEvent。在判断 ACTION_DOWN 的时候,我不直接用 event.getX()/getY() 去在这个 View 的宽和高范围内判断,而是人为地在这个范围基础上加一个 offset。只要触摸点落在 (width + offset) 这个范围内,我都算它点中了。这种方式在写 Framework 层的 Input 逻辑或者做一些复杂手势控件的时候比较常用。

总结一下,一般简单场景我直接加 Padding;如果有布局限制或者不想改 UI 结构,我就用 TouchDelegate;自定义控件我就重写 Touch 逻辑。


平时如何学习安卓相关技术?

这也是我大学这几年一直在摸索的过程。其实我觉得学习 Android 就像是在练级,我现在主要把精力分成了 三个维度:广度、深度和实战。

首先是 跟进新技术(广度)。 Android 更新太快了,Jetpack 组件库经常变。我会关注 Google 的官方文档和 Android Developers Blog,还有就是像 掘金、Medium 这些技术社区。 比如最近 Compose 比较火,我就会去翻翻官方的 Codelabs 动手写几个 Demo,搞清楚它的声明式 UI 和传统的 View 体系有什么区别。这部分主要是为了保持对技术栈的敏感度,不至于落伍。

然后是 死磕源码(深度),这其实是我投入时间最多的地方,也是我为了进大厂特意准备的。 我觉得光会用 API 是不够的,得知道它底下是怎么跑的。我会用 CS.Android.com 或者自己下载 AOSP 源码看。 比如为了搞懂 App 启动流程,我会从 ActivityStarter 一路追到 AMS,再看 Zygote 怎么 fork 进程;为了搞懂 UI 刷新机制,我会去啃 ChoreographerSurfaceFlinger 的交互,看 Vsync 信号是怎么分发的。 看源码的时候,我习惯 画时序图,把复杂的调用链画出来,不然看着看着就晕了。而且我会特别关注那些核心类,比如 ActivityThreadWindowManagerService 这些。

最后是 输出倒逼输入(实战)。 我看您简历上也看到了,我写过不少技术博客。其实很多时候我看源码以为自己懂了,但一写文章发现逻辑盘不通,这就逼着我回去重看。 另外我也参与过一些开源项目,或者自己在 GitHub 上造一些简易的轮子,比如模仿 Retrofit 写一个简单的网络请求库,用动态代理去处理注解,这样能让我对设计模式理解更深。

总的来说,就是:看文档上手 -> 读源码深挖 -> 写博客/Demo 巩固。我觉得这种闭环的学习方式效率是最高的。


算法题:实现斐波那契数列(要求分别用递归和非递归方式),并分析两种方式的时间复杂度和空间复杂度。如何快速估算第 N 个月的斐波那契数?

好的,斐波那契数列是一个非常经典的算法题。 它的定义是:F(0)=0,F(1)=1,从第三项开始,每一项等于前两项之和。 我先把代码大概写一下,然后分析复杂度。

第一种:递归实现(Recursive)

这应该是最直观的写法,直接把数学定义翻译成代码:

Java
public int fibRecursive(int n) {
    if (n <= 1) {
        return n;
    }
    return fibRecursive(n - 1) + fibRecursive(n - 2);
}

分析:

  • 时间复杂度:这里是 O(2N)O(2^N),也就是指数级。 为什么呢?因为这棵递归树上会有大量的 重复计算。比如算 F(5) 要算 F(4) 和 F(3),算 F(4) 又要算 F(3) 和 F(2)... F(3) 就被重复算了多次。随着 N 增大,计算量是爆炸式增长的。
  • 空间复杂度O(N)O(N)。 因为递归调用的深度是 N,每一层调用都需要在栈上压入帧,所以栈空间取决于 N。

第二种:非递归实现(Iterative)

为了解决递归的性能问题,我们通常会用 动态规划 的思想,或者叫“滚动数组”优化,其实只需要记录前两个状态就行了。

Java
public int fibIterative(int n) {
    if (n <= 1) return n;
 
    int a = 0; // F(n-2)
    int b = 1; // F(n-1)
    int sum = 0;
 
    for (int i = 2; i <= n; i++) {
        sum = a + b;
        a = b;      // 往前滚动
        b = sum;    // 往前滚动
    }
    return sum;
}

分析:

  • 时间复杂度:这里是 O(N)O(N)。 因为只有一个 for 循环,从 2 遍历到 N,非常线性。
  • 空间复杂度:这里是 O(1)O(1)。 因为我们只用了 absum 这几个固定的变量,不需要额外的数组或者栈空间。这是最优的解法。

关于如何快速估算第 N 个数:

其实这涉及到一个数学公式,叫 比内公式(Binet's Formula)。 斐波那契数列的增长速度其实是和 黄金分割率 ϕ1.618\phi \approx 1.618 紧密相关的。

通项公式大概是这样的: F(n)=15[(1+52)n(152)n]F(n) = \frac{1}{\sqrt{5}} [(\frac{1+\sqrt{5}}{2})^n - (\frac{1-\sqrt{5}}{2})^n]

如果要 快速估算,因为后面那一项 (152)(\frac{1-\sqrt{5}}{2}) 的绝对值小于 1,当 N 稍微大一点的时候,它的 N 次方就几乎趋近于 0 了,可以忽略不计。 所以,我们可以直接用前一半来估算: F(n)ϕn5F(n) \approx \frac{\phi^n}{\sqrt{5}}

也就是说,第 N 个斐波那契数,大概就等于 1.618 的 N 次方除以根号 5。这样就能迅速判断出它的量级了。 当然,如果在计算机里要精确计算非常大的 N,通常会用 矩阵快速幂 的方法,把时间复杂度降到 O(logN)O(\log N) ,不过那个写起来就复杂一点了。


商米科技 安卓一面


Charles 抓 HTTPS 包的原理是什么?

嗯,这个问题其实非常经典,它本质上就是利用了 中间人攻击(Man-in-the-Middle, MITM) 的原理。

简单来说结论就是:Charles 作为“中间人”,同时扮演了服务器和客户端两个角色。对真实的客户端(也就是我们的 App)来说,Charles 伪装成了服务器;而对真实的服务器来说,Charles 又伪装成了客户端。

具体到 HTTPS 的握手流程,我是这么理解的:

首先,当我们的 App 发起 HTTPS 请求时,它并没有直接连接到目标服务器,而是连到了设置好的代理,也就是 Charles。Charles 拦截到这个请求后,会做两件事:

  1. 伪装成服务器:Charles 会根据请求的域名,现场动态生成一张 伪造的服务端证书。这个证书是拿 Charles 自己的 Root CA(根证书) 签名的。然后它把这个伪造的证书发给 App。
  2. 伪装成客户端:同时,Charles 也会向真正的服务器发起连接,获取服务器真实的公钥证书,用来和服务器进行后续的加密通信。

这里最关键的一步是 客户端的信任校验

平时我们抓包如果不安装证书,App 会报错,原因就在这儿。因为 App 拿到那个“伪造证书”后,会去校验签发者。如果没有在手机系统里安装并信任 Charles 的 Root CA,App 就会发现:“哎,这个证书不是受信任机构发的”,然后中断握手。

只有当我们手动把 Charles 的根证书安装到手机的 受信任凭据 里之后,App 才会认为 Charles 发过来的伪造证书是合法的,从而用伪造证书里的公钥加密对称密钥(Pre-master secret)发给 Charles。

Charles 拿到加密内容后,用自己的私钥解密,拿到了 对称密钥。这时候,Charles 就完全掌握了 App 和服务器之间通信的钥匙。它就可以解密 App 发来的数据(明文显示在 Charles 界面上),然后再用刚才和真实服务器协商好的密钥,加密转发给真实服务器。

所以总结一下,原理就是:双向伪装 + 根证书信任 + 动态签发伪造证书,从而劫持了 SSL/TLS 握手过程中的对称密钥,实现对加密流量的解密和监控。


中间人抓包需要解决什么核心问题?

嗯,接这刚才的原理,其实中间人抓包(MITM)想要成功,我认为核心要解决的问题只有一个,那就是 信任链(Chain of Trust)的合法性 问题。

具体来说,HTTPS 的设计初衷就是为了防止中间人攻击的。它依靠 PKI(公钥基础设施)体系,通过 证书链 来验证服务器身份。

我们在做抓包的时候,面临的最大挑战就是:如何让客户端(App)相信我们伪造的那个“中间人”是合法的服务器。

这里面涉及到两个层面的难点:

第一,系统的信任。 就像我刚才提到的,Android 系统内部维护了一套 CA 根证书列表(位于 /system/etc/security/cacerts 目录下)。默认情况下,系统只信任这些预装的证书。如果我们用 Charles 生成一个自签名的证书,系统是不认的。所以核心问题之一是 如何将我们的攻击者证书(Charles CA)注入到系统的受信任列表里,或者让系统接受用户安装的证书。

第二,应用的信任(SSL Pinning)。 现在很多大厂的 App(比如我们要面或者正在做的项目),为了安全,不仅仅依赖系统的信任列表,还在代码里做了 证书锁定(SSL Pinning)。 这就更麻烦了。App 内部硬编码了服务端证书的 Hash 或者公钥。这就意味着,哪怕我把 Charles 的证书装到了系统根证书里,系统信了,但 App 自己校验时发现:“不对,这个证书的指纹跟我代码里写的不一样”,它还是会拒绝连接。

所以,中间人抓包的核心博弈就在于 对抗证书校验机制

如果是普通的抓包,解决“系统信任”就够了(装证书)。 如果是对付高安全性的 App,就需要解决“应用信任”,通常得用 Frida 或者 Xposed 这种 Hook 框架。比如说,去 Hook javax.net.ssl.TrustManager 相关的实现类,或者像 OkHttp 里的 CertificatePinner 类的 check 方法,强行把校验逻辑给 return true 掉,这样才能绕过核心的信任检查,完成抓包。


安卓 7.0 及以上版本如何安装 Charles 的 CA 证书?

哎,这确实是个坑,我在做开发的时候也经常遇到这个问题。Android 7.0(API 24)是一个分水岭。

在 7.0 之前,我们只要把 Charles 的证书下载下来,在“设置-安全-从存储设备安装”,安装成 “用户证书”,App 默认就会信任它,就能抓包了。

但是,Google 在 Android 7.0 修改了安全策略:默认情况下,App 只信任系统预装的根证书,不再信任用户安装的证书

所以现在要解决这个问题,一般有两种方案,分“有源码”和“无源码”两种情况:

方案一:如果我们是开发者(有源码),可以通过配置允许 App 信任用户证书。 这需要我们在 res/xml 下新建一个 network_security_config.xml 文件。在这个文件里,我们可以显式地声明:

Xml
<base-config cleartextTrafficPermitted="true">
    <trust-anchors>
        <certificates src="system" />
        <certificates src="user" /> <!-- 关键是这一行 -->
    </trust-anchors>
</base-config>

然后在 AndroidManifest.xml<application> 标签里加上 android:networkSecurityConfig="@xml/network_security_config"。 这样重新编译打个 Debug 包,App 就会信任我们手动安装的 Charles 证书了。但这只适用于我们自己开发的 App。

方案二:如果是逆向分析或者没源码(需要 Root),就得把证书搬进“系统证书”目录。 这也是我常用的方法,更底层一点。既然 App 只信系统证书,那我们就把 Charles 的证书伪装成系统证书。

具体步骤比较硬核:

  1. 首先得导出 Charles 的证书(PEM 格式)。
  2. 然后,Android 的系统证书文件名是有特定格式的,是基于 Subject Hash 的。我们需要用 OpenSSL 命令计算这个 Hash: openssl x509 -subject_hash_old -in charles.pem 算出比如 1234abcd 这样的哈希值,然后把证书重命名为 1234abcd.0
  3. 接着,通过 adb push 把这个文件推到手机里。
  4. 关键的一步来了,因为 /system 分区通常是只读的(Read-Only),我们需要重新挂载为可读写: adb shell mount -o rw,remount /system (这步在 Android 10+ 也就是动态分区上有时比较麻烦,可能需要 Magisk 的 OverlayFS 模块)。
  5. 最后把那个 .0 文件复制到 /system/etc/security/cacerts/ 目录下,并把权限改成 644

只要文件放进去了,重启手机,系统就会把它当成出厂自带的根证书,这时候哪怕是 Release 版的 App,只要没做 SSL Pinning,基本上都能抓到了。

其实现在为了方便,我也常用 Magisk 里的 Move Certificates 模块,它能自动把用户装的证书挂载到系统证书目录下,原理是一样的,但操作更无感一些。


反编译 APK 的具体工具和步骤是什么?

嗯,对于反编译,其实分为资源反编译代码反编译两个部分。我现在一般主要用三套工具组合打法:ApktoolJadxIDA Pro

具体步骤我是这么操作的:

首先是资源的提取。 拿到一个 APK 后,我会先用 Apktool。 命令很简单,就一句 apktool d target.apk。 这一步主要是为了拿 AndroidManifest.xmlres 目录下的资源文件。因为 APK 本质是个压缩包,直接解压出来的 xml 是乱码(二进制格式),必须用 apktool 这种工具才能把它还原成可读的文本。通过看 Manifest,我能快速定位到 App 的入口 Activity、注册的 Service 以及它申请了什么权限,这对后续分析逻辑很有帮助。

然后是核心的Java 代码反编译。 老一辈的工程师可能习惯用 dex2jar 配合 jd-gui,先把 classes.dex 转成 jar 包再看。 但说实话,我现在主要直接用 Jadx(特别是 jadx-gui)。它太强大了,可以直接把 APK 拖进去,它会自动解析所有的 .dex 文件,并把它们还原成非常接近源码的 Java 代码。 在 Jadx 里,我可以通过字符串搜索、引用跳转(Find Usage)来快速梳理业务逻辑。如果遇到混淆比较厉害的代码,Jadx 的重命名功能(Deobfuscation)也能帮我稍微理清一点头绪。

最后,如果涉及到 Native 层。 很多大厂的核心算法(比如加密签名、音视频处理)是写在 C/C++ 里的,也就是 .so 库。 这时候 Jadx 就不管用了,我得祭出 IDA Pro。 我会把 .so 文件拖进 IDA,它会分析汇编指令(ARM/ARM64)。虽然看起来比较痛苦,但配合 IDA 的 F5 插件(Hex-Rays Decompiler),能把汇编转成伪 C 代码,可读性瞬间提升很多。

总结下来就是:Apktool 看清单和资源,Jadx 看 Java 逻辑,IDA 啃 Native 算法。 这一套流程走下来,App 的底裤基本上就被扒得差不多了。


如果 APK 加壳了该如何处理?

这是一个非常有挑战性的问题。如果 APK 加了壳(比如各大厂商的加固保),我们直接用 Jadx 打开,看到的通常只有寥寥无几的类,或者全是乱码,核心代码都被隐藏起来了。

这时候就必须进行 “脱壳”(Unpacking)

其实脱壳的核心原理就一句话:代码必须要加载到内存中才能执行。 不管壳怎么加密、怎么隐藏,等到 App 真正运行起来,系统准备执行那些方法的时候,壳程序(Stub)必须把真正的 .dex 文件解密并加载到内存里,否则 ART 虚拟机是没法跑的。

所以,最通用的手段就是 “内存 Dump”

具体操作上,我一般会用 Frida 或者 Xposed 这种 Hook 框架。 思路是去 Hook Android 运行时(Runtime)加载 Dex 的关键函数。 因为我比较熟悉 Framework 层嘛,我知道在 Android 的 ART 虚拟机里,加载 Dex 文件最终都会走到 libart.so 里的几个关键函数。

比如,在 Android 8.0 以后,我经常会去 Hook OpenCommon 或者 OpenMemory 这种函数。 当 App 启动后,壳程序解密出真实的 Dex,调用系统 API 加载时,我的 Hook 代码拦截到了这个调用,这时候内存里躺着的就是解密后完整的 Dex 文件。我直接把这段内存数据写出(Dump)到文件,保存成 .dex

拿到这个 Dump 出来的 dex 文件后,经常还需要做一些修复工作(Fix)。 因为有些加固厂商会把 Dex Header 抹掉或者篡改,或者抽取了某些方法的指令(抽取壳),等到执行该方法时再动态填回去。 针对这种高级壳,简单的 Dump 可能拿到的是空方法体,这时候可能需要基于 指令执行流 的更深度的脱壳机(比如 FART),或者通过模拟执行来还原。

但对于面试来说,核心思路就是:静态分析搞不定,就利用 ART 运行时机制,在内存中截获解密后的真身。


代码混淆的作用是什么?

代码混淆(Obfuscation)在工程实践中非常重要,我在打包 Release 版本时肯定是要开的。我觉得它主要起两个核心作用:安全防护体积优化

首先是 安全防护(Security),这是最直观的。 我们在写代码时,类名、方法名、变量名都是有语义的,比如 getUserInfo()password。如果不混淆,别人反编译后直接就能看懂业务逻辑,改个代码、植入个广告简直轻而易举。 开启混淆后,构建工具(以前是 ProGuard,现在 Android Gradle Plugin 默认用 Google 的 R8 编译器)会把这些名字变成无意义的短字符,比如 a.b()v1。 这就极大地增加了逆向工程的成本。攻击者面对一堆 a.b.c.d,很难猜测原本的逻辑是干嘛的。

不过我也要补充一点,混淆并不是万能的。Android 四大组件的类名(在 Manifest 里注册的)通常不能混淆,Native 方法名(JNI 调用的)也不能混淆,否则反射找不到就会 Crash。

其次是 体积优化(Shrinking)。 其实 R8 不仅仅是改名字,它还会做 Tree Shaking(摇树优化)。 它会从入口点(比如 MainActivity)开始分析引用图,检测出项目中那些没有被调用的类、方法和字段,直接把它们移除掉。 这对引入了大量第三方库的项目特别有效。比如我引用了一个很大的库,但我只用了其中一个工具类,R8 会把剩下的没用代码全删了,能显著减小 APK 的体积。 此外,它还会做一些字节码层面的优化,比如内联短方法(Inlining)、合并类等,虽然幅度不如 Native 编译器那么大,但对性能和体积都有好处。

最后,提到混淆就不得不提 Mapping 文件。 因为线上 Crash 上报回来的堆栈也是混淆过的(比如报错在 a.b()),我们没法排查。所以每次打包都会生成一个 mapping.txt,我们需要用它来还原堆栈,这在 CrashKeep 或者 Bugly 这种平台上配置一下就好了。

所以总结来说,混淆是 Android 发布流程中必不可少的一环,对外防反编译,对内瘦身减重。



APP 加固的常见手段有哪些?

嗯,关于 APP 加固,其实这是一个攻防对抗的过程。现在的加固技术已经迭代了好几代了,从最早的整体加壳到现在的方法体抽取、VMP(虚拟机保护),手段非常多。我觉得比较核心的常见手段主要有这几种:

首先是 DEX 文件的整体加固(壳)。 这是最基础的。原理就是把我们编译好的 classes.dex 进行加密,藏在一个壳 DEX 或者 SO 库里。App 启动的时候,先运行壳程序的入口(通常是继承 ApplicationattachBaseContext 方法),在内存中把加密的 DEX 解密出来,然后通过反射调用 DexClassLoader 动态加载进去。 虽然这个能防住静态分析,但就像我上一题说的,容易被内存 Dump 破解。

所以进阶一点的手段是 DEX 指令抽取(抽取壳)。 这个就比较狠了。加固厂商会在打包时,把 DEX 里某些核心方法的指令流(Code Item)直接抽走,或者填成 nop 空指令。 只有当这个方法真正被调用的时候,利用 Android 运行时机制(比如 Hook art::ArtMethod::Invoke 或者拦截缺页中断),在运行时动态地把指令填回去,执行完再抹掉。这样哪怕你 Dump 出内存里的 DEX,里面的方法体也是空的,反编译出来什么都看不到。

再往深了说,就是 Native 层的混淆(OLLVM)。 现在很多核心逻辑都下沉到 C++ 层了嘛。针对 SO 库,常用 OLLVM(Obfuscator-LLVM) 进行混淆。 比如 控制流平坦化(Control Flow Flattening),它把原本清晰的 if-else 或者循环逻辑,打散成无数个小的代码块,用一个巨大的 switch-case 来调度。反编译出来你会看到一堆像迷宫一样的跳转,根本没法读。

还有就是 资源文件加密完整性校验。 比如把 assets 里的资源加密,防止被篡改或者换皮。同时在 Native 层做签名校验,如果发现签名不一致(说明被二次打包了),直接 Crash 或者让 App 逻辑异常。

总的来说,现在的加固是 DEX 保护 + SO 混淆 + 反调试/反篡改 的一套组合拳。


防止反调试的常见方法是什么?

嗯,防止反调试(Anti-Debug)主要是为了不让攻击者用调试器(比如 IDA、GDB、JDB)挂载到我们的进程上分析逻辑。我在研究这块的时候,发现大家用的手段主要集中在系统底层机制的检测上。

最经典的就是 利用 ptrace 的互斥性。 在 Linux 内核里,一个进程同一时间只能被一个调试器 ptrace。 所以,我们可以在 App 启动的时候(比如在 JNI 的 JNI_OnLoad 或者 init_array 里),自己 fork 一个子进程,让子进程去 ptrace 父进程(也就是 App 主进程)。 这样,当黑客想用调试器来 attach 我们的时候,系统就会报错说“该进程已经被调试了”,从而拒绝连接。这叫“双进程守护”。

其次是 检查进程状态文件。 Linux 系统下,每个进程的状态信息都存在 /proc/self/status 文件里。 我们可以开一个线程轮询读取这个文件,重点看里面的 TracerPid 字段。 正常情况下这个值是 0。如果有人调试我们,这个值就会变成调试器进程的 PID。一旦发现它不为 0,我们就直接 killProcess 自杀,或者上报异常。

还有一种是 时间检测(Timing Checks)。 程序的执行速度在正常运行和调试状态下差别是很大的。 如果有人在单步调试,或者下断点,两条指令之间的时间间隔会变得很长。 我们可以在关键逻辑前后记录 System.nanoTime() 或者在汇编层用 rdtsc 指令,计算时间差。如果发现执行时间明显超过阈值,就判定为正在被调试。

另外,针对 Frida 这种动态插桩工具,我们还可以扫描打开的端口(比如 Frida 默认端口 27042),或者扫描内存中是否有 frida-agent.so 这样的特征字符串。

不过话说回来,反调试没有绝对的安全,攻击者也可以通过 Patch 掉我们的检测代码来绕过,但这确实增加了逆向的门槛。🔒


Glide 图片加载库的三级缓存机制是怎样的?

嗯,Glide 我用得非常多,它的缓存机制设计得非常精妙,主要是为了解决 OOM(内存溢出)加载速度 的问题。它的三级缓存不仅仅是简单的“内存-磁盘-网络”,在内存缓存这一层做得特别细致。

简单来说,这三级分别是:弱引用缓存(Active Resources)内存缓存(Memory Cache)磁盘缓存(Disk Cache)

具体的查找流程是这样的(看 Engine 类的 load 方法源码就能看出来):

第一级是 Active Resources(活动资源)。 这一层存的是 当前正在使用的图片。Glide 用一个 HashMap 存储,Value 是用 WeakReference 包裹的。 当我们调用 into(ImageView) 的时候,图片被加载出来,就会放到这里。 这一层的作用是防止正在显示的图片被 LruCache 算法给回收掉,保证使用时的稳定性。

如果第一级没找到,就去查第二级 Memory Cache(内存缓存)。 这一层通常是一个 LruCache(最近最少使用算法)。 当图片从屏幕上移出(比如 RecyclerView 滑动),不再被使用时,它会从 Active Resources 移到 Memory Cache 里。 如果内存不够了,LruCache 会自动把最久没用的图片释放掉。 这两级构成了 Glide 的 双层内存缓存

如果内存里都没有,就去查第三级 Disk Cache(磁盘缓存)。 这里 Glide 用的是 DiskLruCache。 值得注意的是,Glide 的磁盘缓存分得很细,它可以缓存 原始图片(Data),也可以缓存 转换后的图片(Resource)(比如裁剪过、压缩过的)。 默认情况下,它会根据我们的 ImageView 大小和变换逻辑,缓存处理过的版本,这样下次加载直接读取就能显示,不需要再进行 decode 和 resize,非常快。

如果磁盘也没找到,最后才会去 网络层 下载。

总结一下:

  1. 先查 Active Resources(正在用的)。
  2. 再查 Memory Cache(最近用过的)。
  3. 再查 Disk Cache(本地文件的)。
  4. 最后 网络请求

这个流程既保证了流畅性,又最大程度避免了重复加载和 OOM。🚀


如何优化图片加载性能?

图片优化在 Android 开发里是重中之重,毕竟 OOM 的大户通常就是 Bitmap。结合我平时的开发和对 Glide 源码的理解,我认为优化主要从 内存占用加载速度 两个维度入手。

第一,最核心的是 尺寸压缩(Sampling)。 我们绝对不能把一张 4K 的原图直接加载到一个 100x100 的 ImageView 里,那是极大的浪费。 原生的做法是设置 BitmapFactory.OptionsinSampleSize。 我们需要先 inJustDecodeBounds = true 只读取图片宽高,计算出合适的采样率,然后再解码。 当然,用 Glide 的话它内部默认帮我们根据 View 的大小做了这个处理。

第二,是 Bitmap 的内存复用。 Android 在 3.0 以后引入了 inBitmap 属性。 这就好比我们可以维护一个 Bitmap Pool(对象池)。当一张图片不需要显示了,它的内存块不要直接回收,而是留给下一张新图片复用。 这样可以极大减少内存抖动(Memory Churn),减少 GC 的频率,界面滑动起来会更丝滑。Glide 内部就维护了一个非常复杂的 BitmapPool

第三,是 图片格式的选择(Config)。 默认 Bitmap.ConfigARGB_8888,一个像素占 4 字节。 如果图片不需要透明度(比如背景图),我们可以手动改成 RGB_565,一个像素只占 2 字节,内存直接减半! 另外,网络传输时,优先使用 WebP 格式,它比 PNG/JPG 体积小很多,能节省带宽和下载时间。

第四,大图加载(巨图方案)。 如果是清明上河图那种长图,不能一次性加载进内存,必须用 BitmapRegionDecoder,配合手势滑动,按需加载局部区域。

最后,注意 生命周期管理。 页面退出时,及时清理请求。虽然 Glide 绑定了 Lifecycle,但如果我们自己写 Loader,一定要在 onDestroy 或者 onViewDetached 的时候取消加载任务,释放资源。

所以,结论就是:按需加载尺寸、复用内存对象、选对色彩格式、善用缓存策略。 这样基本能搞定 90% 的图片性能问题。✨


TCP 三次握手的过程是什么?

嗯,这个问题是网络编程的基础,虽然我在做 Framework 开发时更多关注 Binder,但网络通信的底层逻辑必须得清。三次握手(Three-way Handshake) 是 TCP 协议建立可靠连接的核心过程。

简单来说,它的目的是 同步连接双方的序列号和确认号,并交换 TCP 窗口大小信息

整个过程大概是这样的:

第一步(SYN): 客户端(Client)想建立连接,它会随机选择一个初始序列号 x,然后向服务器(Server)发送一个 SYN 报文段。 这时候客户端进入 SYN_SENT 状态。 这一步的意思是:“喂,服务器,我想跟你说话,我的暗号是 x。”

第二步(SYN + ACK): 服务器收到请求后,如果同意连接,它会分配资源,并向客户端发送确认。 这个报文里包含两部分信息:

  1. ACK:确认收到客户端的 x,所以确认号是 x+1
  2. SYN:服务器自己也要发一个初始序列号 y。 这时候服务器进入 SYN_RCVD 状态。 这一步的意思是:“收到了 x,我也想跟你说话,我的暗号是 y。”

第三步(ACK): 客户端收到服务器的确认后,还要再回复一个确认。 它发送一个 ACK 报文段,确认号是 y+1,序列号是 x+1。 这个报文发出去之后,客户端就认为连接建立了,进入 ESTABLISHED 状态。 服务器收到这个 ACK 后,也进入 ESTABLISHED 状态。 这一步的意思是:“收到了 y,那咱们开始聊吧。”

结论: 为什么非要三次?其实核心是为了 防止已失效的连接请求报文段突然又传到了服务器。 试想一下,如果客户端发的第一个 SYN 包在网络里滞留了,客户端超时重发了第二个 SYN。等连接都建好了,数据都传完了,连接都断了,那个滞留的“旧 SYN”才到达服务器。 如果是两次握手,服务器收到旧 SYN 直接就以为是新连接,开始等待数据,白白浪费资源。 有了三次握手,客户端收到服务器对旧 SYN 的确认时,发现“不对啊,我没发这个请求”,就会发 RST 拒绝掉,避免了错误连接。


TCP 四次挥手为什么不能合并为三次?

这个问题问得好,很多同学容易跟三次握手搞混。其实 四次挥手(Four-way Wavehand) 之所以不能像握手那样把中间两步合并,根本原因是 TCP 是全双工(Full Duplex)的协议

这里的“全双工”意味着:数据发送和接收是分开的两个通道。 当客户端想断开连接时,它只能代表 “我这边没数据要发给你了”,但并不代表 “我不想收你的数据了”。服务器那边可能还有没发完的数据。

具体的流程差异就在这就体现出来了:

第一次挥手(FIN): 客户端发一个 FIN 包,表示“我发完了,我要关闭发送通道了”。客户端进入 FIN_WAIT_1

第二次挥手(ACK): 服务器收到 FIN,回一个 ACK,表示“我知道你想关了”。 注意! 这时候服务器 并不会马上关闭连接。 因为它可能还有数据没传完。它会进入 CLOSE_WAIT 状态,继续把剩下的数据传给客户端。 客户端收到 ACK 后,进入 FIN_WAIT_2,继续接收数据。

第三次挥手(FIN): 等服务器把数据真的全发完了,它才发自己的 FIN 包,表示“我也发完了,现在可以彻底关了”。服务器进入 LAST_ACK

第四次挥手(ACK): 客户端收到服务器的 FIN,回一个 ACK,表示“好的,收到,拜拜”。 客户端进入 TIME_WAIT(等待 2MSL),服务器收到 ACK 后彻底关闭(CLOSED)。

结论: 你看,中间的 ACK(确认收到客户端关闭请求)FIN(服务器发起关闭请求)分两步走 的。 因为服务器收到客户端 FIN 时,通常还来不及准备好关闭自己(还有数据在发),所以只能先回 ACK 稳住客户端,等自己忙完了再发 FIN。 而在三次握手时,服务器收到 SYN 既能确认又能马上发起连接,所以 SYN+ACK 可以合并。这就是本质区别。


JDK1.7 和 JDK1.8 中 HashMap 的底层实现有什么区别?

嗯,HashMap 是 Java 面试的常客,我在看 Android SDK 源码(比如 ArrayMap 对比)时也专门研究过它的演进。JDK 1.8 对 HashMap 做了非常大的优化,主要区别体现在 数据结构扩容机制 上。

1. 数据结构(最核心的区别):

  • JDK 1.7:采用 数组 + 链表 的结构。 当发生 Hash 冲突时,冲突的元素会以链表的形式挂在数组节点上(拉链法)。 但在极端情况下(比如 Hash 攻击,所有 Key 都冲突),链表会变得无限长,查找复杂度退化为 O(n),性能极差。
  • JDK 1.8:引入了 红黑树(Red-Black Tree)。 当链表长度超过阈值(默认是 8),且数组长度超过 64 时,链表会自动转换为红黑树。 红黑树的查找复杂度是 O(log n)。这一改动极大地提升了高冲突情况下的性能,避免了 DOS 攻击风险。

2. 插入方式:

  • JDK 1.7:采用 头插法。 新来的节点插在链表头部。 这就导致了一个著名的问题:在并发扩容时,容易造成 链表成环(Infinite Loop),导致 CPU 飙升到 100%。
  • JDK 1.8:改用了 尾插法。 新节点插在链表尾部。 这样做虽然多了一次遍历,但保证了扩容前后节点的顺序不变,彻底解决了链表死循环的问题。

3. Hash 计算:

  • JDK 1.7:扰动函数处理得非常繁琐(做了 4 次位运算),为了让 Hash 更散列。
  • JDK 1.8:简化了 Hash 算法(高 16 位和低 16 位异或),速度更快,配合红黑树,即便稍微有点冲突也能接受。

4. 扩容机制(Resize):

  • JDK 1.7:扩容时需要重新计算每个元素的 Hash 值,再取模求新下标(Rehash),效率较低。
  • JDK 1.8:利用了二进制规律优化。 因为数组长度总是 2 的幂次方,扩容后,元素的新位置要么还在 原下标,要么是 原下标 + 旧数组长度。 所以不需要重新 Hash,只需要看 Hash 值新增的那一位 bit 是 0 还是 1 就行了,效率极高。

结论: JDK 1.8 的 HashMap 无论是在 查询效率(引入红黑树) 还是 扩容性能(位运算优化) 以及 并发安全性(尾插法) 上,都完胜 JDK 1.7。这也是为什么现在 Android 开发都推荐升级到高版本 Java 的原因之一。


有序数组和有序链表在查找元素时的性能差异及原因是什么?

这个问题其实是在考察 数据结构的内存特性算法复杂度

虽然它们都是“有序”的,但在查找(Search)这个操作上,性能差异非常大。

结论:

  • 有序数组:查找性能极优,可以达到 O(log n)
  • 有序链表:查找性能较差,只能是 O(n)

原因分析:

1. 对于有序数组: 数组在内存中是 连续存储 的,支持 随机访问(Random Access)。 这意味着我们可以直接通过下标 index 计算出内存地址。 因为有序,我们可以利用 二分查找法(Binary Search)。 每次比较中间元素,如果目标值比中间小,就去左半边找;反之去右半边。 每一次比较都能排除掉一半的数据,所以时间复杂度是 O(log n)。这对大规模数据非常高效。

2. 对于有序链表: 链表在内存中是 非连续存储 的,节点之间通过指针相连。 它 不支持随机访问。要想访问第 k 个元素,必须从头节点开始,顺着指针一个一个往后跳。 即便它是有序的,二分查找法也没法用。因为你没法直接定位到“中间节点”(定位中间节点本身就要遍历一半链表)。 所以,只能用 顺序查找,最坏情况下要遍历整个链表,时间复杂度是 O(n)

扩展思考(跳表 SkipList): 其实为了解决有序链表查找慢的问题,工业界(比如 Redis 的 ZSet)引入了 跳表(SkipList)。 它在链表之上加了多层索引(Level),通过空间换时间,也能实现 O(log n) 的查找效率,性能接近二分查找,但实现起来比红黑树简单,并发也更好做。

所以回到面试题,如果是纯粹的有序链表,查找性能是远不如有序数组的。这也是为什么 ArrayList 读快写慢,而 LinkedList 写快读慢的底层原因。



Java 并发编程中的原子性、可见性、有序性分别是什么?如何保证?

嗯,这三个概念其实是 JMM(Java 内存模型) 最核心的三大特性。我在看《Java 并发编程实战》和研究 JVM 规范的时候,对这块印象特别深。我来逐个拆解一下。

首先是 原子性(Atomicity)。 它的意思是一个操作或者一系列操作,要么全部执行并且不被中断,要么就全都不执行。就像数据库事务一样。 在 Java 里,基本数据类型的读取和赋值(除了 long 和 double 在 32 位系统上可能不是原子的)是原子的。 但我们要保证复杂的原子性,通常有两种手段:

  1. 锁机制:比如 synchronized 关键字或者 Lock 接口。

    • 从字节码角度看,synchronized 是通过 monitorentermonitorexit 指令来实现的。
    • 从 Framework 源码来看,像 AMS 里的 ActivityManagerService,里面大量的 synchronized(this),就是为了保证对 ProcessRecord 等状态修改的原子性。
  2. CAS(Compare-And-Swap):利用 CPU 的原子指令。

    • 比如 java.util.concurrent.atomic 包下的 AtomicInteger。我看过它的源码,它底层调用的是 Unsafe 类的 compareAndSwapInt 方法,这是一种无锁的原子操作。

然后是 可见性(Visibility)。 这个是指 当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。 JMM 规定了变量存储在主内存,每个线程有自己的工作内存。默认情况下,线程改了变量可能只存在工作内存里,没刷回主内存,别的线程就看不到了。 保证可见性的方式主要有:

  1. volatile 关键字:这是最轻量级的实现。它会强制将修改后的值刷新到主内存,并且让其他线程的工作内存中该变量的缓存失效。

    • 底层原理其实是在汇编层面加了 Lock 前缀指令,触发了缓存一致性协议(像 MESI)。
  2. synchronizedLock:它们规定,释放锁之前必须把变量刷回主内存,获取锁时必须从主内存重新读取。

最后是 有序性(Ordering)。 程序执行的顺序不一定就是代码写的顺序,因为编译器和处理器为了优化性能,会做 指令重排序。 在单线程里这没问题(As-if-serial 语义),但在多线程环境下,重排序可能导致严重的逻辑错误。比如著名的 DCL(双重检查锁)单例模式,如果不加 volatile,就可能因为对象初始化和引用赋值的指令重排,导致拿到了一个半初始化的对象。 保证有序性主要靠:

  1. volatile:它通过插入 内存屏障(Memory Barrier) 来禁止特定类型的指令重排序。比如 StoreStore 屏障、LoadLoad 屏障等。
  2. Happens-Before 原则:这是 JMM 定义的一套规则,比如 start() 规则、join() 规则、锁规则等。只要符合这些规则,就不需要额外的同步手段来保证顺序。

所以总结一下:原子性主要靠锁和 CAS,可见性主要靠 volatile 和锁,有序性靠 volatile 和 Happens-Before 规则。在 Android Framework 开发中,处理 Binder 线程池并发时,这三个特性是我们必须时刻警惕的。


悲观锁和乐观锁的区别是什么?各自的适用场景是什么?

嗯,这两个其实不是具体的锁实现,而是一套 并发控制的理念策略

首先说 悲观锁(Pessimistic Lock)。 顾名思义,它很“悲观”。它总是假设 最坏的情况,认为每次去拿数据的时候,别人大概率都会修改它。 所以,为了安全,它在每次读写数据前,一定要先上锁。别人想拿这个数据,就必须阻塞等待,直到我把锁释放。

  • Java 中的体现:最典型的就是 synchronized 关键字和 ReentrantLock
  • 底层细节:比如 synchronized 在重量级锁状态下,是依赖操作系统的 Mutex Lock 实现的,这涉及到用户态和内核态的切换,开销比较大。虽然后来 JVM 做了偏向锁、轻量级锁的优化,但本质上它还是悲观的排他思想。

然后是 乐观锁(Optimistic Lock)。 它就很“乐观”,假设 最好的情况,认为每次去拿数据的时候,别人都不会修改它。 所以,它 不上锁。但是在更新的时候,它会去判断一下:在此期间有没有人改过这个数据?

  • 如果没人改过,我就更新成功;
  • 如果有人改过,我就重试(自旋)或者报错。
  • Java 中的体现:最经典的就是 J.U.C 包下的原子类,比如 AtomicInteger
  • 底层细节:它主要利用 CAS (Compare And Swap) 算法。我看过 AtomicInteger.getAndIncrement() 的源码,它在一个 do-while 循环里调用 Unsafe.getUnsafe().compareAndSwapInt(...)。如果返回值是 false(说明内存值被改过了),它就循环重试,直到成功为止。

关于适用场景:

  • 悲观锁 适合 写操作非常多(Write-Heavy) 的场景。

    • 因为如果竞争很激烈,乐观锁会一直在那儿自旋重试,白白消耗 CPU 资源,这时候不如直接加锁让线程阻塞挂起等待。
    • 比如在 Android 的 SurfaceFlinger 或者是数据库的行锁机制里,为了数据绝对一致性,常用这种方式。
  • 乐观锁 适合 读操作多、写操作少(Read-Heavy) 的场景。

    • 因为冲突几率小,大部分时候 CAS 一次就能成功,省去了加锁、释放锁以及线程上下文切换的开销,性能很高。
    • 比如我们需要做一个全局的计数器,或者在 Android 轻量级的状态标志位同步时,用 AtomicBoolean 就比 synchronized 轻便很多。

乐观锁可能导致什么问题?

嗯,虽然乐观锁在低竞争下性能很好,但它也不是完美的,其实在实际使用中,特别是在看 JDK 源码时,能发现它主要有三个典型的问题。

第一个,也是最著名的 ABA 问题。 这是 CAS 算法的一个漏洞。假设一个变量原本是 A,我读的时候是 A。在我准备把值改成 B 的时候,另一个线程把它先改成了 B,然后又改回了 A。 这时候,我的 CAS 检查会发现它还是 A,认为它没变过,然后就更新成功了。 但在某些业务场景下(比如链表节点的变动、内存回收),这会出大问题。

  • 解决办法:加版本号。就像数据库里的乐观锁一样。Java 里提供了 AtomicStampedReference 这个类,它在比较对象引用的同时,还要比较一个 “时间戳”或“版本号”。只有两个都对上了,才允许更新。

第二个问题是 循环时间长,CPU 开销大。 刚才提到了,乐观锁的核心是 CAS,通常配合 自旋(Spin) 使用。 如果并发冲突非常激烈,很多线程都在抢着更新同一个变量,那么大量的线程会 CAS 失败。它们就会在那个 do-while 循环里一直空转。 这会极大地消耗 CPU 资源。

  • 源码佐证:我看 Unsafe 的相关代码或者是 AQS (AbstractQueuedSynchronizer) 的入队逻辑时,经常能看到这种死循环重试的逻辑。如果长时间不成功,其实比悲观锁挂起线程的代价还要大。
  • 解决办法:像 LongAdder(JDK 8 引入)那样,把热点分离,用分散的 Cell 数组来分摊竞争压力,或者在竞争太激烈时升级为悲观锁(类似 synchronized 的锁膨胀)。

第三个问题是 只能保证一个共享变量的原子操作。 CAS 原生只能针对一个内存地址进行原子性判断。如果我们想对多个变量(比如对象里的 x 和 y)同时进行原子更新,普通的 CAS 就做不到了。

  • 解决办法

    1. AtomicReference 把多个变量封装成一个对象来更新。
    2. 或者直接认怂,用 synchronizedLock 这种悲观锁,这反而更简单直接。

所以在做 Framework 开发或者高性能组件设计时,选择乐观锁一定要评估并发的激烈程度,不能无脑用。


安卓开发中你最熟悉的知识点是什么?

嗯,这其实挺难选的,因为从应用层到 Framework 层我都有涉猎。但如果非要说一个 最熟悉、理解最透彻 的,那肯定是 Android 的 Handler 消息机制

为什么是它呢? 首先,Handler 是 Android 系统的“血管”。 我在读 Framework 源码的时候发现,Android 的驱动模型完全是基于 事件驱动 的。不管是应用层的主线程(UI 线程),还是系统服务像 ActivityManagerService (AMS)、WindowManagerService (WMS),甚至是 Input 系统的事件分发,底层全都依赖 LooperHandler 来运转。

其次,我对它的理解不仅限于 Java 层。 很多同学可能只知道 sendMessagehandleMessage,但我深入研究过它在 Native 层 的实现。 比如 MessageQueue 的阻塞唤醒机制,它其实是用 Linux 的 epoll 机制 加上 eventfd 来实现的。这一点非常关键,它解释了为什么主线程 Looper 死循环不会占用 CPU 资源。

再者,它串联了太多核心知识点。 通过 Handler,我顺藤摸瓜搞懂了:

  1. ThreadLocal:Handler 如何保证线程独立。
  2. 同步屏障(SyncBarrier):系统 UI 刷新(Vsync 信号)是如何优先处理的。
  3. 内存泄漏:匿名内部类持有外部类引用导致的经典 OOM 问题。
  4. IdleHandler:如何在系统空闲时做性能优化(比如延迟加载)。

所以,我觉得我对 Handler 机制的掌握程度,足以应对从应用开发到系统优化的各种场景,这也是我最自信的一个点。


Handler 消息机制的核心组成部分及工作原理是什么?

嗯,这个问题正好撞到我的“枪口”上了,我来详细拆解一下。

Handler 机制主要由四个核心组件组成:MessageHandlerMessageQueueLooper。它们的分工就像是一个 流水线工厂

  1. Message(消息): 这是流水线上的 “产品”。它包含了要传递的数据(whatarg1obj 等)和处理该消息的 Handler 引用(target)。

    • 细节:它有一个 next 字段,说明它是一个链表节点;还有一个 sPool 静态变量,实现了享元模式(Object Pool),避免频繁创建对象造成的内存抖动。
  2. Handler(搬运工): 它是开发者接触最多的接口,负责 发送消息处理消息

    • 发送:调用 sendMessage,最终会调到 enqueueMessage,把消息放入队列。
    • 处理:它的 dispatchMessage 方法会被 Looper 调用,分发给 handleMessage 或者 Runnable 回调。
  3. MessageQueue(传送带): 这是一个 优先级队列(底层是单链表),负责存储消息。

    • 原理:它按消息的执行时间(when)排序。
    • 核心方法next()。这是一个无限循环的方法,如果队列里没消息,或者时间没到,它就会 阻塞。这里的阻塞不是简单的 wait,而是调用了 Native 方法 nativePollOnce,利用 Linux 的 epoll 监听文件描述符,让出 CPU 时间片。
  4. Looper(动力引擎): 它是每个线程的消息泵,负责 不断从队列里取消息

    • 工作流:调用 Looper.loop() 后,它就进入一个死循环。
    • 不断调用 queue.next() 获取消息(可能会阻塞)。
    • 拿到消息后,调用 msg.target.dispatchMessage(msg) 把消息交给 Handler 处理。
    • 处理完后,调用 msg.recycleUnchecked() 回收消息。

整个工作原理流程如下:

  1. 我们在子线程创建一个 Handler
  2. 调用 handler.sendMessage(),消息被标记了时间戳,插入到主线程的 MessageQueue 中。
  3. 主线程的 Looper 正在 loop() 循环里。
  4. 如果队列之前是空的(阻塞状态),新消息来了会通过 nativeWake 唤醒 epoll
  5. LooperMessageQueue.next() 拿到这条消息。
  6. Looper 只有在当前线程(主线程)执行 msg.target.dispatchMessage(msg)
  7. 最终,handleMessage 方法就在主线程被执行了,从而实现了 线程切换

这就是我对 Handler 机制核心原理的理解。


如何在子线程中创建 Handler?

嗯,在子线程创建 Handler 并不像在主线程那么直接,因为子线程默认是没有 Looper 的。

如果在普通的子线程直接 new Handler(),程序会直接 Crash,抛出 RunTimeException: Can't create handler inside thread that has not called Looper.prepare()

所以,标准的步骤是这样的,分为三步:

  1. 调用 Looper.prepare()

    • 这个方法会在当前线程的 ThreadLocal 中初始化一个 Looper 对象。
    • 而在 Looper 的构造函数里,它又会创建一个 MessageQueue
    • 这步保证了当前线程拥有了消息队列和管家。
  2. 创建 Handler 实例

    • new Handler()
    • 这时候 Handler 构造函数内部会通过 Looper.myLooper() 获取到刚才 prepare 好的 Looper,并绑定它。
  3. 调用 Looper.loop()

    • 这一步最关键且最容易被遗忘
    • 不调用 loop(),消息队列就是死的,发了消息也没人取,Handler 就不会工作。
    • 调用后,当前子线程就会进入无限循环,开始处理消息。

举个代码例子(手写风格):

Java
new Thread(() -> {
    // 1. 准备 Looper
    Looper.prepare();
 
    // 2. 创建 Handler
    Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            // 处理消息...
        }
    };
 
    // 3. 启动循环
    Looper.loop();
}).start();

补充一点工程上的实践:

在实际开发中,我们很少这样手写,因为管理线程的生命周期很麻烦(Looper 死循环如果不手动 quit,线程是不会退出的,容易内存泄漏)。

如果你需要一个自带 Looper 的后台线程,Android 提供了一个非常好用的类叫 HandlerThread。 它继承自 Thread,内部已经封装好了 prepareloop 的逻辑。 我们只需要 new HandlerThread("WorkerThread")start() 之后,直接 new Handler(thread.getLooper()) 就可以用了。 像 Framework 里的 BackgroundThreadServiceThread 其实都是 HandlerThread 的实例,特别好用。


安卓 View 的绘制流程是什么?

嗯,这个问题是 Android UI 开发的基石,也是我研究 SurfaceFlinger 的切入点。View 的绘制流程其实是一个自上而下的树形递归过程,主要分为三个核心阶段:Measure(测量)Layout(布局)Draw(绘制)

整个流程始于 ViewRootImplperformTraversals() 方法,这个方法会在 Choreographer 收到 Vsync 信号后被触发。

  1. Measure(测量阶段)

    • 结论:确定 View 的大小。
    • 细节:这是一个递归过程。父 View 会根据自己的 MeasureSpec(测量规格,包含大小和模式)和子 View 的 LayoutParams,计算出子 View 的 MeasureSpec,然后调用子 View 的 measure() 方法。
    • 关键点MeasureSpec 是一个 32 位的 int,高 2 位是模式(UNSPECIFIEDEXACTLYAT_MOST),低 30 位是大小。
    • 最终,每个 View 都会保存自己的 mMeasuredWidthmMeasuredHeight。如果 View 是 ViewGroup,它还需要在 onMeasure 里遍历调用所有子 View 的 measure,并根据子 View 的大小来决定自己的大小(比如 wrap_content 模式)。
  2. Layout(布局阶段)

    • 结论:确定 View 的位置。
    • 细节:也是递归。父 View 在 layout() 方法中,根据上一步测量的结果,确定自己四个顶点的位置(mLeft, mTop, mRight, mBottom)。
    • 关键点:如果是 ViewGroup,它会执行 onLayout(),在里面遍历调用子 View 的 layout() 方法,把子 View 摆放到正确的位置上。比如 LinearLayout 就会根据 orientation 属性,把子 View 一个接一个地排开。
  3. Draw(绘制阶段)

    • 结论:将 View 的内容画到屏幕上(其实是画到 Surface 的 Canvas 上)。

    • 细节draw() 方法的流程非常标准,源码里注释写得很清楚,分 6 步,核心主要是 4 步:

      1. 绘制背景 (drawBackground)。
      2. 保存图层 (saveLayer)(如果有 fading edge 等)。
      3. 绘制内容 (onDraw):这是我们可以重写的方法,用 Canvas API 画圆、画线、画字。
      4. 绘制子 View (dispatchDraw):如果是 ViewGroup,会在这里递归调用子 View 的 draw()
      5. 绘制装饰 (onDrawForeground):比如滚动条、前景 Drawable。

最后补充一点,从 Android 3.0 开始开启了 硬件加速。开启后,draw() 流程会稍有不同,它构建的是 DisplayList(渲染指令流),而不是直接操作 Bitmap 像素,然后通过 RenderThread 交给 GPU 渲染,这大大提高了流畅度。


安卓事件分发机制的原理是什么?

嗯,事件分发机制是解决“手指摸了屏幕,谁来响应”的问题。它的核心逻辑是一个 U 型 的责任链模式,主要由三个方法组成:dispatchTouchEventonInterceptTouchEventonTouchEvent

整个流程是从 Activity 开始,经过 WindowDecorView,最后到达我们的 View 树。

  1. 分发(Dispatch)

    • 入口是 dispatchTouchEvent(MotionEvent ev)
    • 只要事件传递到了某个 View,这个方法一定会被调用。
    • 它的返回值决定了事件是否被消费。如果返回 true,表示事件被消费了,停止分发;如果返回 false,则回传给父 View 的 onTouchEvent
  2. 拦截(Intercept)

    • 这是 ViewGroup 特有的方法:onInterceptTouchEvent(MotionEvent ev)

    • dispatchTouchEvent 内部调用。

    • 作用:判断是否要拦截这个事件,不让它传给子 View。

    • 逻辑

      • 如果返回 true:表示拦截,事件将直接交给自己的 onTouchEvent 处理,不再传给子 View。
      • 如果返回 false(默认):表示不拦截,继续调用子 View 的 dispatchTouchEvent
    • 这在处理滑动冲突(比如 ViewPager 嵌套 RecyclerView)时非常关键,我们通常重写这个方法配合 requestDisallowInterceptTouchEvent 来解决冲突。

  3. 消费(Consume)

    • 方法是 onTouchEvent(MotionEvent ev)

    • 这是真正处理事件的地方(比如点击、长按逻辑)。

    • 逻辑

      • 如果返回 true:表示我消费了这个事件,后续的事件序列(Move、Up)都会直接发给我。
      • 如果返回 false:表示我不处理,事件会 冒泡 回传给父 View 的 onTouchEvent

总结一下 U 型流程:

  • 下发过程:Activity -> PhoneWindow -> DecorView -> ViewGroup A -> ViewGroup B -> View。如果不拦截,事件像水流一样一直流到底部的 View。
  • 回溯过程:如果底部的 View 在 onTouchEvent 返回 false,事件就会像气泡一样往上浮,依次触发 ViewGroup B -> ViewGroup A -> Activity 的 onTouchEvent
  • 一旦消费:如果某个 View 返回了 true,后续的 MOVE/UP 事件会直接发给它,不再经过 onIntercept 判断(除非父 View 强行拦截)。

这也是我在做自定义控件交互时最常打交道的机制。


自定义 View?

嗯,自定义 View 是 Android 进阶必经之路。通常我会根据需求,把自定义 View 分为三大类:自绘控件组合控件继承控件

  1. 自绘控件(完全自定义)

    • 场景:系统自带的 View 满足不了需求,比如做一个复杂的图表、一个炫酷的进度条或者波浪动画。

    • 做法:直接继承 View

    • 核心步骤

      • 重写 onMeasure:这里要注意处理 wrap_content 的情况。默认的 super.onMeasureAT_MOST 模式下会占满父容器,所以我们需要计算内容大小并调用 setMeasuredDimension
      • 重写 onDraw:这是重头戏。利用 Canvas(画布)和 Paint(画笔)进行绘制。比如用 canvas.drawCircle 画圆,canvas.drawPath 画贝塞尔曲线。
      • 处理 Padding:在 onDraw 里算坐标时,必须手动把 getPaddingLeft() 等考虑进去,否则 Padding 属性会失效。
  2. 组合控件(Composite View)

    • 场景:把几个现有的控件组合在一起,封装成一个通用的组件。比如一个带清除按钮的输入框,或者一个通用的标题栏(左边返回键,中间标题,右边菜单)。

    • 做法:通常继承 FrameLayoutLinearLayoutConstraintLayout

    • 核心步骤

      • 在构造函数里通过 LayoutInflater.from(context).inflate(R.layout.xxx, this) 加载布局。
      • 对外暴露接口(比如 setTitle(), setLeftListener()),方便调用者设置数据和回调。
      • 这种方式不需要怎么处理 onMeasureonDraw,主要是封装逻辑。
  3. 继承控件(扩展系统 View)

    • 场景:在现有控件基础上加功能。比如圆角 ImageView,或者支持侧滑删除的 RecyclerView

    • 做法:继承具体的 View 子类(如 AppCompatImageView)。

    • 核心步骤

      • 保留父类功能,只修改特定行为。比如在 onDraw 里先裁剪 canvas.clipPath 再调用 super.onDraw 来实现圆角。
      • 或者重写 onTouchEvent 来加入手势逻辑。

除了这三类,还有一个特别重要的点是自定义属性(Attributes): 为了让 View 能在 XML 里配置,我会在 res/values/attrs.xml 里定义 <declare-styleable>,然后在 View 的构造函数里通过 context.obtainStyledAttributes 解析出这些属性值(比如颜色、大小、开关状态),最后别忘了调用 recycle() 释放资源。

做自定义 View 最考验的就是对 坐标系计算绘制 API性能优化(比如不在 onDrawnew 对象)的综合掌握。


涂鸦智能 安卓一面


从页面 A 打开页面 B 时,页面 A 和页面 B 的生命周期变化分别是什么?

嗯,这个问题是 Android 开发里最经典的生命周期场景了。

先说结论吧: 整体的顺序是:A onPause -> B onCreate -> B onStart -> B onResume -> A onStop

这里其实有个很重要的点,就是 A 的 onPause 必须执行完,B 的 onCreate 才会开始。这也就是为什么我们一直强调,千万不要在 onPause 里做耗时操作,不然会直接拖慢下一个页面的显示速度,造成跳转卡顿。

然后我想结合我看过的 AOSP 源码,展开讲讲这里面 AMS (ActivityManagerService) 是怎么调度的,因为这里面其实藏着很多设计的细节。

当我们在 A 页面调用 startActivity 的时候,最终会通过 Binder IPC 走到系统进程的 AMS(或者新版本的 ATM,ActivityTaskManager)里。在 ActivityStarter 处理完启动模式那些逻辑后,会走到 resumeTopActivityInnerLocked 这种核心方法。

这时候,AMS 发现栈顶还有个 A 处于 Resumed 状态,它得先让 A 暂停。AMS 会给 A 所在的 应用进程 发送一个事务,调用 A 的 schedulePauseActivity

回到 A 的应用进程,主线程的 ActivityThread 收到消息后,会执行 handlePauseActivity,这里面就会回调 A 的 onPause。重点来了,A 执行完 onPause 后,必须通过 Binder 告诉 AMS "我暂停好了"(调用 activityPaused)。

只有 AMS 收到了 A 的这个 "paused" 汇报,它才会去真正调度 B 的启动。这也是为什么 A 的 onPause 和 B 的 onCreate 是严格串行的。

接下来,B 开始走 onCreate -> onStart -> onResume。 这时候 B 已经显示出来了(或者说 Window 可见了),那 A 什么时候走 onStop 呢?

其实 B 在 onResume 完成后,主线程空闲时会触发一个 IdleHandler,或者通过 activityIdle 告诉 AMS "我显示好了"。AMS 收到这个信号后,发现:"哦,B 已经 Resume 了,A 现在完全不可见了",这时候才会去通知 A 执行 onStop

所以,A 的 onStop 其实是在 B 显示出来之后才调用的

这里还有个特殊情况我得补一句,就是如果 B 设置了 themeTranslucent(透明主题)或者是一个 Dialog 样式的 Activity,那 A 就只会走到 onPause不会走 onStop,因为 A 在视觉上还是可见的。这也印证了 onStop 的本质含义是 "完全不可见"

所以总结下来,正常的完整流程就是:A 暂停 -> B 启动并显示 -> A 停止。这个设计是为了保证前台 UI 的流畅切换,优先让新页面出来,旧页面的收尾工作(onStop)稍微往后放一放。


Activity 的 onRestoreInstanceState 方法什么时候调用?

嗯,onRestoreInstanceState 这个方法其实不是每次 Activity 启动都会调用的。

结论是:它只有在 Activity 因为系统原因(非用户主动 finish)被销毁后,再次重建 时才会被调用。 至于它的调用时机,是在 onStart 之后,onResume 之前

具体来说,常见的触发场景主要有两个:

  1. 配置发生变化:比如最典型的 屏幕旋转,或者系统语言切换、深色模式切换。这时候 Activity 会被销毁重建,系统会尝试恢复之前的状态。
  2. 系统资源不足导致后台 Activity 被杀:比如应用在后台放久了,或者系统内存紧张(触发了 Low Memory Killer),把 Activity 所在的进程杀掉了。当用户再次回到这个页面时,系统会重建 Activity 并调用这个方法。

其实这里在源码层面,涉及到了 ActivityThread 的启动流程。

当 Activity 重建时,performLaunchActivity 里会从 ClientTransaction 里拿到一个 state Bundle。如果这个 Bundle 不为空,说明有保存的状态。 Activity 走完 onCreateonStart 后,ActivityThread 会调用 performRestoreInstanceState

在这个方法内部,如果不为空,就会触发 onRestoreInstanceState。这里有个细节,onRestoreInstanceState 的参数(Bundle)是一定不为 null 的。这点和 onCreate(Bundle savedInstanceState) 不一样,onCreate 的参数可能是 null(如果是首次启动)。

所以,我们在写代码的时候,通常有两种恢复状态的写法: 一种是在 onCreate 里判断 savedInstanceState 是否为空; 另一种就是重写 onRestoreInstanceState

官方其实更推荐在 onRestoreInstanceState 里做 View 状态的恢复,或者重写 onRestoreInstanceState 的时候,调用 super.onRestoreInstanceState(savedInstanceState)

为什么呢?因为在 super 的实现里,它会去调用 WindowrestoreHierarchyState,这会遍历 View 树,让每个 View(比如 EditText 输入的内容、RecyclerView 的滚动位置)去调用自己的 onRestoreInstanceState。如果我们把这行代码漏了,那界面上的输入框内容可能就丢了。

另外,我也注意到,如果是用户 主动按返回键 或者调用 finish() 退出页面,AMS 会认为这个 Activity 不需要保留了,这时候是 不会 触发 onSaveInstanceState 的,自然重建时也就没有 onRestoreInstanceState 了。

所以,简单总结就是:非人为销毁 -> 重建 -> onStart 之后 -> 拿着之前存好的 Bundle 恢复数据


按返回键从页面 B 返回页面 A 时,页面 A 和页面 B 的生命周期变化分别是什么?

好的,这个场景其实就是 页面 B 出栈,页面 A 重新成为栈顶 的过程。

结论是: B onPause -> A onRestart -> A onStart -> A onResume -> B onStop -> B onDestroy

这个流程跟 "A 打开 B" 有点像,都是 "旧的先 Pause,新的 Resume,旧的再 Stop/Destroy",为了保证 UI 衔接的流畅性。

咱们深入一点,从 Input 事件AMS 的流转过程来看这个事儿。

当我们按下返回键时,InputSystem 会把这个 Key Event 分发给当前焦点的 Window,也就是 B。 B 的 PhoneWindow 处理 onKeyDown,最终会调用 onBackPressed()(或者现在的 OnBackPressedDispatcher)。 默认实现里,这会调用 finish() 方法。

finish() 被调用后,AMS 就开始干活了:

  1. B onPause:AMS 首先通知 B 所在的进程:"你要暂停了"。B 执行 onPause。就像之前说的,B 暂停完必须告诉 AMS。

  2. A 恢复:AMS 收到 B 的暂停信号后,发现栈里下面一个是 A。

    • 这里分两种情况,如果 A 之前只是被盖住(没有被回收),那它处于 Stopped 状态。
    • 所以 A 会先执行 onRestart,告诉应用 "我要重新来了"。
    • 紧接着 onStart(变为可见)。
    • 最后 onResume(回到前台,可以交互)。
  3. B 销毁:等 A 彻底 Resume 了(汇报给 AMS 了),AMS 才会回过头来处理 B 的后事。

    • AMS 通知 B 执行 onStop
    • 因为是 finish() 触发的,紧接着会执行 onDestroy
    • 最后 B 的 Window 被移除,Activity 实例等待 GC 回收。

这里我也想补充一个在 Framework 源码 学习时注意到的点。

ActivityStack(或者 Task)的逻辑里,系统会极力避免 "B 消失了,A 还没出来" 这种黑屏间隙。所以,B 的 Window 销毁通常会延迟到 A 的 Window 绘制完成并显示之后

如果在 logcat 里看日志,有时候会发现 A 的 onResume 和 B 的 onStop 间隔非常短,甚至看起来像并行的,但逻辑顺序绝对是 A Resume 之后,B 才 Stop

除非!除非 A 在恢复的过程中崩溃了,或者 A 也是透明的,那生命周期回调会有所不同。但标准的 "返回键" 场景,就是这套 "B让位 -> A复活 -> B清理" 的流程。


安卓消息机制的核心组成部分有哪些?各自的作用是什么?

嗯,Android 的消息机制,也就是我们常说的 Handler 机制,其实是整个 Android 系统驱动的核心。

先说结论: 它的核心组成部分主要有四个:Message(消息)、MessageQueue(消息队列)、Looper(轮询器)和 Handler(处理器)

它们之间的配合关系,简单来说就是:Handler 负责,Looper 负责不停地转,MessageQueue 负责排队,Message 是载体

下面我结合我看过的 AOSP 源码(主要是 android.os 包下的代码)来具体展开讲讲每个部分:

  1. Message (消息) 它是数据的载体。它内部有一个指向下一个消息的引用 next,所以 Message 本身其实是一个单链表节点。 我们在使用的时候,通常建议用 Message.obtain(),而不是直接 new。因为源码里它维护了一个全局的消息池(sPool),这样可以复用对象,避免频繁创建销毁带来的内存抖动(Memory Churn)。

  2. MessageQueue (消息队列) 虽然叫 Queue,但它内部其实是基于单链表实现的优先级队列。 它的核心逻辑在 enqueueMessage(入队)和 next(出队)这两个方法里。

    • 入队时,是按照 Message 的 when(执行时间)来排序的,时间早的排前面。
    • 出队next 方法)是最关键的。它里面有一个死循环。如果当前没有消息,或者头部消息的时间还没到,它会阻塞在这里。
    • 这里必须提到一个底层细节:它的阻塞不是简单的 wait,而是调用了 Native 方法 nativePollOnce。这底层是用 Linux 的 epoll 机制实现的。当没有消息时,主线程会释放 CPU 资源进入休眠;当有新消息入队时,会通过 nativeWake 唤醒它。这是 Android 既能响应即时消息,又不占用 CPU 的关键。
  3. Looper (轮询器) Looper 是每个线程消息循环的引擎。 在 Looper.loop() 方法里,有一个死循环。它会不断调用 queue.next() 去拿消息。 拿到消息后,它会调用 msg.target.dispatchMessage(msg),这里的 target 其实就是发送这条消息的 Handler。 还有一个很重要的点是 ThreadLocalLooper 内部持有 sThreadLocal,保证了每个线程只对应一个 Looper 实例,以此来保证线程隔离。

  4. Handler (处理器) Handler 是我们需要直接打交道的接口。 它的作用有两个:一个是发送消息sendMessage / post),最终都会走到 enqueueMessage 把消息插到 MessageQueue 里; 另一个是处理消息handleMessage),当 Looper 拿到消息后,会分发回来让 Handler 处理具体的业务逻辑。

所以总结一下流程就是: 我在主线程创建 Handler,然后在子线程调用 handler.sendMessage()。这时候消息被插到了主线程 MessageQueue 的链表中。主线程的 Looper 醒过来(从 nativePollOnce 返回),拿到这个消息,再回调给 Handler 的 handleMessage 执行。

这套机制不仅用于应用层开发,整个 Android Framework,比如 AMS 里的 ActivityThread 也就是主线程,完全就是靠这就这套机制驱动起来的。


什么是同步屏障?

嗯,同步屏障(Sync Barrier),这个概念在做 UI 性能优化或者看 Choreographer 源码的时候非常重要。

结论是: 同步屏障是一种特殊的消息,它的作用是像栅栏一样挡住所有的同步消息,只允许“异步消息”通过。它是为了保证高优先级的任务(主要是 UI 绘制)能被立刻执行,而不被队列里堆积的普通消息堵塞。

我来细说一下它在源码里的表现:

通常咱们发的 Message,target 字段(也就是 Handler)都是有值的。 但是,如果你看 MessageQueue 的源码,有一个方法叫 postSyncBarrier()。这个方法会往队列里插入一条 target 为 null 的 Message。 这个 target 为 null 的消息,就是同步屏障。

MessageQueuenext() 方法在遍历队列时,一旦发现队头是一个同步屏障(msg.target == null),它就会进入一个特殊的循环模式: 它会跳过后面所有的同步消息(普通消息),专门在队列里找标记为 Asynchronous(异步) 的消息。

  • 如果是同步消息 -> 忽略,不处理,让它在队列里待着。
  • 如果是异步消息 -> 立刻取出来执行。

这东西主要用在哪呢? 最典型的场景就是 View 的绘制(VSync 信号)。 当 ViewRootImpl 请求重绘(scheduleTraversals)时,它会先往 MessageQueue 里插入一个同步屏障。 然后,它会通过 Choreographer 发送一个由 mHandler 处理的异步消息(设置了 msg.setAsynchronous(true))去执行 doFrame,也就是布局、测量、绘制的流程。

为什么要这么做? 想象一下,如果主线程的消息队列里堆了一堆普通消息(比如后台即使计算、打点日志),这时候 VSync 信号来了,需要马上更新 UI。如果还是排队,界面肯定就卡顿了(掉帧)。 有了同步屏障,系统就能强行插队,说:“大家先停一停,让渲染任务先走!”

等 UI 绘制完成后,ViewRootImpl 会调用 removeSyncBarrier() 把这个屏障移除,MessageQueue 才会恢复正常,继续处理那些被挡住的普通同步消息。

所以简单说,同步屏障就是 Android 为了保障 UI 流畅度设计的一个**“急诊通道”机制**。


消息队列的空闲机制是什么?

嗯,这个其实指的就是 IdleHandler 机制。

结论是: IdleHandler 是 MessageQueue 提供的一种机制,允许我们在消息队列空闲(即没有消息要处理,或者下一条消息执行时间还没到)的时候,执行一些低优先级的任务。

源码 层面看,MessageQueue 里面定义了一个接口 IdleHandler,里面只有一个方法 queueIdle(),返回值是 boolean

MessageQueue.next() 的那个死循环里,逻辑是这样的:

  1. 首先尝试拿下一条消息。
  2. 如果拿到了,就返回去执行。
  3. 如果没拿到(队列空了),或者拿到的消息时间还没到(要等待),这时候线程本该进入休眠(epoll_wait)。
  4. 但在休眠之前,系统会检查 mIdleHandlers 列表里有没有注册的 IdleHandler。
  5. 如果有,就遍历执行它们的 queueIdle() 方法。

这里有个很有意思的细节,就是 queueIdle()返回值

  • 如果返回 true:表示这个 Handler 保持存活。下次空闲时,还会再调用它(适合重复性的后台检测任务)。
  • 如果返回 false:表示一次性任务。执行完这一把,系统就会把它从列表中移除(这是最常用的用法)。

实际应用场景有哪些呢? 我在做项目优化的时候经常用到它:

  1. 延迟初始化(Lazy Load):有些笨重的第三方库或者非核心 UI 组件,没必要在 Activity.onCreate 里就把主线程卡住。可以扔到 IdleHandler 里,等界面画好了,由于用户还没开始交互,主线程空闲了,再慢慢加载。
  2. GC 优化:AOSP 源码里的 ActivityThread 就有个 GcIdler。它是为了在页面切换完、系统空闲的时候尝试触发 GC,尽量避免在用户操作的时候发生 GC 卡顿。
  3. Activity 启动优化ActivityThreadhandleResumeActivity 之后,会通过 IdleHandler 去告诉 AMS “我这边显示完了(Idling)”,AMS 才会去处理上一个 Activity 的 stop 流程。

所以,IdleHandler 其实就是利用了主线程的碎片化空闲时间,把一些不紧急的任务填进去,这也是性能优化里“打磨细节”的一个重要手段。


安卓跨进程通信(IPC)机制有哪些?

嗯,这个问题其实要分两层来看:因为 Android 基于 Linux 内核,所以 Linux 原有的 IPC 方式它都有,但 Android 自己又搞了一套更高效的。

结论是: 主要有 Binder(核心)、Socket共享内存 (Ashmem)管道 (Pipe)信号 (Signal),还有应用层封装好的 ContentProviderBroadcast

我详细梳理一下这几种在 Android 里的应用场景,特别是结合 Framework 来看:

  1. Binder(最核心) 这是 Android 的灵魂。 特点:性能高(只需要一次数据拷贝),安全性好(内核层添加了 UID/PID 校验)。 场景:几乎所有的系统服务(AMS、WMS、PMS)都是通过 Binder 发布服务的。我们在写 App 时调用的 startActivitybindService,底层全是 Binder 通信。

  2. Socket(套接字) 虽然效率不如 Binder,但在某些特殊场景很重要。 场景:最典型的就是 Zygote 进程。在 ZygoteInit 启动时,它会建立一个 LocalSocket 服务端。AMS 请求孵化新进程时,是通过 Socket 发送命令给 Zygote 的。因为那时候新进程还没 fork 出来,Binder 线程池还没初始化好,用 Socket 最稳妥。另外 adb 调试也是走的 Socket。

  3. Shared Memory(共享内存 / Ashmem) 特点:不需要拷贝数据,直接映射物理内存,效率最高,适合传输大数据。 场景:最典型的就是 SurfaceFlinger 图形渲染。App 端的 Surface 和 System 端的 SurfaceFlinger 之间传递图形缓冲区(Buffer),用的就是共享内存。还有 CursorWindow(数据库查询结果)也是用这玩意儿,不然几千条数据拷来拷去太慢了。

  4. Pipe(管道) 场景:主要用在线程间通信,或者读取一些系统文件。比如 Android 的 Looper 机制,底层 MessageQueuenativePollOnce 其实就用到了 epoll 监听管道(或者 eventfd)的可读事件来唤醒线程。

至于 ContentProvider 和 Broadcast,它们其实是基于 Binder 的上层封装,方便应用层开发使用的。

所以总结来说,Binder 是日常通信的主力,Socket 用于 Zygote 等特殊启动流程,共享内存用于图形和大数据传输


有没有使用过 AIDL?其底层原理是什么?

有的,我在做跨进程组件化开发的时候经常用到 AIDL。

结论是: AIDL (Android Interface Definition Language) 本质上是一个 接口定义语言,它的作用是帮我们自动生成 Binder 通信所需的模板代码

它的底层原理其实就是标准的 Binder 机制,采用了 Proxy-Stub(代理-桩) 的设计模式。

我稍微展开讲讲,因为我之前看过它生成的 .java 文件,逻辑非常清晰:

当我们写好一个 IMyService.aidl 并编译后,系统会生成一个同名的 Java 接口文件。这里面有几个核心部分:

  1. Stub 类(桩) 这是一个抽象类,继承自 Binder,实现了我们定义的接口。 它运行在 服务端(Service 所在进程)。 它的核心方法是 onTransact。当客户端发起请求时,Binder 驱动会把数据发过来,onTransact 负责解析数据(Unparcel),根据方法 ID(code)调用服务端具体的实现方法,最后把返回值写回给驱动。

  2. Proxy 类(代理) 这是 Stub 的一个静态内部类,实现了我们就定义的接口。 它运行在 客户端。 它的核心逻辑在实现了的方法里(比如 login())。它会把参数写入 Parcel 对象(序列化),然后调用 mRemote.transact。这个 transact 就会把数据通过 Binder 驱动发送给服务端。

  3. asInterface 方法 这是一个静态辅助方法。 客户端 bindService 成功后,拿到的是一个 IBinder 对象。 asInterface 会判断:如果客户端和服务端在同一个进程,就直接返回 Stub 对象本身(直接调用,不走 IPC);如果不在同一个进程,就封装成一个 Proxy 对象返回。

源码层面的流程大概是这样的: 客户端调用 Proxy.method() -> mRemote.transact() -> Binder Driver (ioctl) -> 挂起客户端线程 -> 切换到服务端进程 -> Binder 线程池 -> Stub.onTransact() -> 服务端实现逻辑 -> 返回结果。

所以 AIDL 只是一个工具,帮我们省去了手动写 Parcel 读写和 transact 调用的繁琐代码,让我们能像调用本地方法一样调用远程方法。


安卓事件分发机制的流程是什么?

嗯,这个问题也是 Framework 层的重点。

结论是: 事件分发是一个 U 型 的流程:先由外向内分发(Dispatch),再由内向外处理(Handle)。 核心对象是 Activity -> Window -> DecorView -> ViewGroup -> View

这里我结合 Input 系统View 源码 两个层面来讲:

  1. 事件的源头 当手指触摸屏幕,驱动层产生信号,InputManagerService 里的 InputReader 读取事件,交给 InputDispatcherInputDispatcher 通过 Socket 把事件发送给当前激活窗口的 ViewRootImpl

  2. 进入应用层 (ViewRootImpl) ViewRootImpl 收到事件后,经过一系列 InputStage,最终调用到 mView.dispatchTouchEvent,这里的 mView 通常就是 DecorViewDecorView 会把事件交给 Activity.dispatchTouchEvent,Activity 再交给 PhoneWindow,最后又回到 DecorView。这一圈是为了让 Activity 有机会在 View 处理前拦截事件。

  3. View树的分发 (核心) 接下来就是我们熟悉的 ViewGroup.dispatchTouchEvent。这里有三个关键方法:

    • dispatchTouchEvent:负责分发。
    • onInterceptTouchEvent:负责拦截。这是 ViewGroup 独有的。如果它返回 true,事件就不往下传了,直接交给自己的 onTouchEvent 处理。
    • onTouchEvent:负责消费。
  4. 具体的逻辑细节dispatchTouchEvent 里,ViewGroup 会倒序遍历子 View(先看 Z 轴上面的,也就是最后添加的)。 它会判断手指坐标是否在子 View 范围内(isTransformedTouchPointInView)。 如果命中,就调用子 View 的 dispatchTouchEvent

    • 如果子 View 消费了事件(返回 true),ViewGroup 会把这个子 View 记录在 mFirstTouchTarget 这个链表里。
    • 后续事件(MOVE/UP)的优化:下次事件来的时候,ViewGroup 就不用再遍历子 View 了,直接通过 mFirstTouchTarget 找到上次消费事件的 View,直接发给它。这一点非常重要,是性能优化的关键。
  5. 消费 (Handling) 如果一直传到最底层的 View,它没有子 View 了,就会调用它的 onTouchEvent。 如果它返回 false(不消费),事件就会回传给父容器的 onTouchEvent,直到某个 View 返回 true 或者最终回到 Activity。

所以简单总结就是:责任链模式AMS/WMS 确定窗口 -> ViewRootImpl 接收 -> DecorView 开始 -> 递归找目标 View -> 找到后记录 Target -> 后续事件直接给 Target -> 没人要就回传


TCP 三次握手的过程是什么?

嗯,TCP 三次握手是建立可靠连接的基础。

结论是: 三次握手的核心目的是 同步双方的初始序列号(ISN)确认双方的收发能力 都是正常的。 过程简单说是:SYN -> SYN + ACK -> ACK

具体的流程细节,结合 Socket 编程 的状态机来看是这样的:

  1. 第一次握手(Client -> Server) 客户端调用 connect() 方法,发送一个 SYN 标志位为 1 的包,指明客户端打算连接的服务器端口,以及初始序号 ISN(client)。 此时客户端进入 SYN_SENT 状态。 这也说明了为什么 connect() 是阻塞操作,它得等服务器回话。

  2. 第二次握手(Server -> Client) 服务器收到 SYN 包后,内核协议栈会分配资源,并回复一个 SYN + ACK 包。 这里包含两层意思:一是 ACK 确认客户端的序号(ack = ISN(client) + 1);二是发送自己的 SYN 和初始序号 ISN(server)。 此时服务器进入 SYN_RCVD 状态。

  3. 第三次握手(Client -> Server) 客户端收到服务器的 SYN+ACK 包后,知道连接建立了一半。它需要再次发送一个 ACK 包,确认服务器的序号(ack = ISN(server) + 1)。 一旦这个包发出,客户端就进入 ESTABLISHED 状态。 服务器收到这个 ACK 后,也进入 ESTABLISHED 状态。 这就好比我们在 Android 里做 Binder 通信,先注册服务再获取代理,双方得确认“暗号”对上了。

为什么一定要三次? 其实如果不看书死记硬背,从 逻辑 上推导: 如果是两次,客户端发了 SYN,服务器回了 ACK 就建立连接。那如果客户端发的第一个 SYN 包在网络里滞留了很久,连接都断了才到服务器。服务器以为是新的连接,直接建立了,这就会导致 脏连接 或者 资源浪费。 三次握手能让客户端有感知:“哎?我没请求啊,这个 ACK 哪里来的?”然后发送 RST 拒绝掉。


TCP 四次挥手的过程是什么?

嗯,挥手比握手多一次,是因为 TCP 是 全双工(Full Duplex) 的协议。

结论是: 因为发送方结束发送不代表接收方也结束了,所以断开连接需要双向分别断开。 流程是:FIN -> ACK -> (数据传输) -> FIN -> ACK

这里面有个非常经典的状态叫 TIME_WAIT,我得重点展开讲讲。

  1. 第一次挥手(Active Close -> Passive Close) 假设客户端(App)调用了 socket.close()。它会发送一个 FIN 包,表示“我没有数据要发了”。 此时客户端进入 FIN_WAIT_1 状态。

  2. 第二次挥手(Passive Close -> Active Close) 服务器收到 FIN,知道客户端此时只收不发了。内核会自动回一个 ACK 确认包。 此时服务器进入 CLOSE_WAIT 状态。 重点来了:这时候服务器可能还有数据没处理完(比如正在写数据库),所以它不会立马发 FIN,它得先把剩下的数据发完。 客户端收到这个 ACK 后,进入 FIN_WAIT_2

  3. 第三次挥手(Passive Close -> Active Close) 等服务器应用层处理完了(调用了 close()),它才会发送一个 FIN 包给客户端。 此时服务器进入 LAST_ACK 状态。

  4. 第四次挥手(Active Close -> Passive Close) 客户端收到 FIN 后,回一个 ACK。 此时客户端 不会立刻关闭,而是进入 TIME_WAIT 状态。 服务器收到 ACK 后,彻底关闭连接(CLOSED)。

关于 TIME_WAIT 的细节: 我们在排查线上网络问题或者看 Netty/OkHttp 源码 时,经常会关注这个状态。 客户端必须等待 2MSL(报文最大生存时间) 才能关闭。 这是为了:

  1. 保证最后一个 ACK 能到达服务器。如果 ACK 丢了,服务器会重传 FIN,客户端还能处理。如果客户端直接跑路了,服务器就一直报错了。
  2. 防止旧连接的报文干扰新连接。等 2MSL 可以让网络中所有残留的包都过期失效。

所以,如果我们在服务端发现大量 CLOSE_WAIT,通常是代码里忘了调 close();如果发现大量 TIME_WAIT,那是并发连接数太高了。


HTTP 请求报文的协议格式是什么?

嗯,这个在做网络库封装(比如 Retrofit 自定义 Converter)或者抓包(Charles/Fiddler)的时候经常看。

结论是: HTTP 请求报文严格分为三大部分:请求行(Request Line)、请求头(Headers) 和 请求体(Body)。 中间还要注意有一个 空行(CRLF) 用来分隔头和体。

我结合 OkHttp 的源码(比如 CallServerInterceptor 或者 Http1ExchangeCodec)来描述一下它是怎么拼这个包的:

  1. 请求行 (Request Line) 这是第一行,包含三个信息,用空格隔开:

    • Method:比如 GETPOST
    • URL:请求的路径,比如 /api/v1/user
    • Version:协议版本,通常是 HTTP/1.1
  2. 请求头 (Headers) 紧接着是一堆 Key-Value 键值对。 在 OkHttp 里,BridgeInterceptor 这个拦截器会自动帮我们补全很多头:

    • Host: 目标服务器地址(必填)。
    • Content-Type: 告诉服务器 Body 是 JSON 还是 Form 表单(比如 application/json)。
    • Content-Length: 这个非常关键。因为 TCP 是流式的,服务器必须知道读多少字节算结束,不然会发生粘包问题。
    • Connection: Keep-Alive: 默认开启长连接,复用 TCP 通道。
    • User-Agent: 客户端身份标识。
  3. 空行 (CRLF) 就是 \r\n。服务器解析的时候,一旦读到这个空行,就知道下面全是 Body 了。

  4. 请求体 (Body) 这是实际传输的数据。

    • 如果是 GET 请求,这里通常是空的。
    • 如果是 POST 请求,这里就是具体的 JSON 字符串或者二进制流。

其实我们在写 Android 代码时,Retrofit 把这些都屏蔽了。但如果我们自己写一个简单的 HTTP Client,就必须严格遵守这个格式,尤其是 Content-Length 的计算和 空行 的处理,少一个字节服务器都解析不了。


volatile 关键字和 synchronized 关键字的区别是什么?

嗯,这个问题其实是 Java 并发编程里最基础但也最核心的两个概念。简单来说,volatile 是轻量级的同步机制,而 synchronized 是重量级的锁机制

如果要展开说的话,我觉得可以从 可见性、原子性、有序性 这三个维度,以及它们底层的 实现原理 来对比。

首先是 可见性volatile 的主要作用就是保证变量对所有线程的可见性。当一个线程修改了 volatile 变量,JMM(Java 内存模型)会强制把这个变量的新值立即刷新回 主内存,并且让其他线程里缓存的该变量变无效。 而 synchronized 也能保证可见性,它在 unlock 之前,必须把共享变量同步回主内存。不过 volatile 在这方面更纯粹一点,它就像是一个轻量级的信号。

其次是 原子性,这个区别最大。 volatile 是不能保证原子性的。比如最经典的 i++ 操作,虽然 i 加了 volatile,但在字节码层面它还是 getaddput 三步,多线程下还是不安全的。 而 synchronized 是排他锁,它能保证一段代码在同一时刻只能被一个线程执行,所以它是 绝对保证原子性 的。

然后是 有序性volatile 可以 禁止指令重排序。在源码层面,通过在这个变量的读写操作前后插入 内存屏障(Memory Barrier) 来实现。 synchronized 虽然也能保证有序性(因为单线程内部执行看起来是有序的),但它比较重。

最后稍微提一下 底层实现volatile 比较底层,它是告诉 JVM 这个变量不稳定,每次都要去主存读。 而 synchronized,我看过一点 JVM 的源码,它的实现是基于 Monitor 对象的。 如果是修饰代码块,字节码里会有 monitorentermonitorexit 指令;如果是修饰方法,是看那个 ACC_SYNCHRONIZED 标志位。 在 JDK 1.6 之前它非常重,因为涉及到操作系统层面的 Mutex Lock,线程挂起唤醒要切换用户态和内核态。不过后来引入了 偏向锁、轻量级锁 之后,性能优化了很多。

所以总结一下:如果是单纯的标志位或者是为了保证可见性,我会优先用 volatile;但如果涉及到复合操作或者需要严格的互斥,那就肯定得用 synchronized 了。


ThreadLocal 的使用场景是什么?

ThreadLocal 的使用场景,其实核心就是四个字:线程隔离

它的主要作用就是给每个线程提供一个 独立的变量副本,这样不同线程之间就不会互相干扰了,同时也避免了在方法调用链里传递参数的麻烦。

在 Android 开发里,我觉得最经典、最显眼的使用场景就是 Looper 了。 我们在看 Handler 机制源码的时候,Looper.prepare() 方法里,其实就是通过 sThreadLocal.set(new Looper(quitAllowed)) 把 Looper 对象存到了当前线程的 ThreadLocal 里。 这样做的目的很明确:保证一个线程只能绑定一个 Looper,而且可以在当前线程的任何地方通过 Looper.myLooper() 获取到属于自己的那个 Looper,完全不用担心别的线程来捣乱。

除了 Looper,在 Android Framework 层,还有一个场景就是 Choreographer。 我们在做性能优化或者看屏幕刷新原理的时候,会发现 Choreographer 也是通过 ThreadLocal 来保证每个线程(通常是主线程)有自己独立的编舞者实例的。

另外,如果跳出 Android,在后端开发里,比如 数据库连接管理 或者 Session 管理,也常用 ThreadLocal。 因为一个请求通常对应一个线程,我们把数据库连接存在 ThreadLocal 里,在这个线程处理请求的整个过程中,不管是 Service 层还是 DAO 层,都能拿到同一个连接,既保证了事务的一致性,又避免了要把 Connection 对象当作参数到处传,代码会优雅很多。

所以,总结来说,只要是 “在这个线程的生命周期内,需要全局访问,但又不想被其他线程混用” 的对象,都适合用 ThreadLocal


ThreadLocal 的底层数据结构是什么?

这个其实挺有意思的,很多人容易产生一个误区,以为 ThreadLocal 内部维护了一个 Map,Key 是线程,Value 是数据。 但其实我看 JDK 源码的时候发现,关系完全是反过来的

实际上,数据是存在 Thread 对象里面的。 如果我们打开 Thread.java 的源码,会看到它有一个成员变量叫 threadLocals,它的类型是 ThreadLocal.ThreadLocalMap。 也就是说,每个线程自己维护了一个 Map,这个 Map 专门用来存自己的私有数据。

然后这个 ThreadLocalMap 的结构也很有讲究:

  1. 它的 Key 并不是 Thread,而是 ThreadLocal 对象本身
  2. 它的 Value 才是我们要存的那个对象。

这里有个细节特别值得注意,就是 ThreadLocalMap 里的 Entry。 源码里这个 Entry 是继承自 WeakReference<ThreadLocal<?>> 的。 也就是说,Key(ThreadLocal 对象)是一个弱引用。这主要是为了配合垃圾回收,防止 ThreadLocal 对象本身无法被回收。

还有一点,关于 Hash 冲突的解决。 大家常用的 HashMap 遇到冲突是用 链地址法(拉链法) 嘛,在这个桶后面挂一个链表或者红黑树。 但是 ThreadLocalMap 不一样,它采用的是 线性探测法(Linear Probing)。 也就是说,如果算出来的槽位 i 被占了,它就找 i + 1,直到找到一个空的槽位塞进去。 之所以敢这么做,是因为通常一个线程里不会存特别多的 ThreadLocal 变量,数据量不大,用线性探测效率反而更高,也省去了维护链表节点的内存开销。

所以总结一下:数据结构是 Thread 持有 ThreadLocalMap,Key 是 ThreadLocal 的弱引用,解决冲突用的是线性探测法。


使用 ThreadLocal 时需要注意什么?

这一块最需要注意的,绝对是 内存泄漏(Memory Leak) 的问题。这是面试里的高频考点,也是实际开发里最容易踩的坑。

就像我刚才提到的,ThreadLocalMap 里的 Key(也就是 ThreadLocal 对象)是 弱引用,但 Value 却是 强引用

这就导致了一个问题: 假如外部没有强引用指向这个 ThreadLocal 对象了,那在下一次 GC 的时候,这个 Key 就会被回收,变成了 null。 但是!Value 还在啊。 因为当前线程(CurrentThread)还在运行,它引用了 ThreadLocalMap,Map 引用了 Entry,Entry 引用了 Value。这条强引用链是一直存在的。 这就导致:Key 没了,但 Value 还在内存里占着茅坑不拉屎,而且永远访问不到它了(因为 Key 变成了 null)。

特别是如果我们使用了 线程池。 在线程池里,核心线程是复用的,往往是常驻内存不会销毁的。 如果不手动清理,这些 Entry 里的 Value 对象就会越积越多,最后导致内存泄漏,甚至 OOM。

虽然 ThreadLocal 的源码里做了一些防护措施,比如在 set()get()remove() 方法里,会调用 expungeStaleEntries() 方法,去扫描并清理 Key 为 null 的 Entry。 但是,这是一种 被动 的清理。如果我们不调用这些方法,或者长时间没触发,泄漏还是会发生。

所以,结论就是: 一定要养成良好的编码习惯,使用完 ThreadLocal 后,必须显式地调用 remove() 方法。 通常我们会把它写在 try-finally 代码块的 finally 里,确保一定能清理掉,这样才能彻底杜绝内存泄漏的隐患。


CPU 密集型和 IO 密集型任务的线程池核心线程数如何配置?

嗯,这个问题其实没有绝对的“银弹”,但在业界通常会遵循一些经验公式,主要取决于任务的性质是 “烧脑”(CPU 密集)还是 “等待”(IO 密集)。

先说结论: 如果是 CPU 密集型 任务,核心线程数通常配置为 N+1N + 1 。 如果是 IO 密集型 任务,核心线程数通常配置为 2N2N ,或者更严谨一点,是 N/(1阻塞系数)N / (1 - 阻塞系数) 。 (这里的 NN 指的是 CPU 的核心数,我们可以通过 Runtime.getRuntime().availableProcessors() 来获取)。

这也是我在做项目时调优的一个基准。下面稍微展开说一下为什么这么配。

对于 CPU 密集型 任务,比如复杂的加密解密、视频编解码或者高强度的算法计算,线程的大部分时间都在占用 CPU 进行运算。 这时候,如果线程数搞得太多,反而会因为 频繁的上下文切换(Context Switch) 而浪费 CPU 资源。 所以理论上设置为 NN 个线程就能跑满 CPU 了。 那为什么要 +1+ 1 呢?其实是为了 容错。 哪怕是 CPU 密集型任务,也可能会遇到内存页缺失(Page Fault)或者其他原因导致线程暂停一下。这时候多出来这 1 个线程就可以立马补上,保证 CPU 不闲着。

而对于 IO 密集型 任务,这也是 Android 开发里最常见的,比如网络请求、读写数据库、文件操作等。 这时候线程大部分时间其实是在 等待(Wait)IO 返回,CPU 其实是空闲的。 为了把 CPU 利用起来,我们需要配置更多的线程。 最简单的经验值是 2N2N 。 如果想更精确,可以用那个公式:N/(1阻塞系数)N / (1 - 阻塞系数) 。 这里的 阻塞系数 就是(阻塞时间 / 总时间)。比如一个任务 90% 的时间都在等网络,那阻塞系数就是 0.9,算下来可能需要 10N10N 个线程。 当然,在 Android 这种资源受限的移动设备上,我们通常不会开那么大,一般还是按经验值或者具体压测来定。


线程池的任务执行逻辑是什么?

这一块的逻辑非常严谨,看过 ThreadPoolExecutor 的源码就会发现,它其实就是一套 三步走 的流程控制。

简单总结一下流程: 核心线程 -> 阻塞队列 -> 非核心线程 -> 拒绝策略

具体的执行细节是这样的,当我们在外部调用 execute(Runnable command) 提交一个任务时:

  1. 判断核心线程: 首先,线程池会检查当前的运行线程数(源码里是用 ctl 这个 AtomicInteger 的低 29 位来存的)。 如果当前线程数 小于 corePoolSize,那没话说,直接调用 addWorker(command, true) 创建一个新的 核心线程 来执行这个任务。

  2. 判断任务队列: 如果核心线程都在忙(也就是线程数达到了 corePoolSize),线程池就会尝试把任务 塞进阻塞队列 里(workQueue.offer(command))。 这也是为什么我们在配置线程池时,队列的选择(比如 LinkedBlockingQueue 还是 ArrayBlockingQueue)很关键。

  3. 判断最大线程: 如果队列也满了(offer 失败),这时候就要看能不能创建 非核心线程 了。 线程池会判断当前线程数是否 小于 maximumPoolSize。 如果是,就调用 addWorker(command, false) 创建一个非核心线程来马上处理这个任务。

  4. 拒绝策略: 如果运气很差,队列满了,线程数也达到了最大值(Max),这时候线程池就真的处理不过来了。 它会调用 reject(command) 方法,根据我们配置的 拒绝策略 来处理这个被抛弃的任务。

其实这里有个源码细节很有意思,就是 ctl 变量。它把 线程池状态(高 3 位)线程数量(低 29 位) 压缩在一个 int 变量里,通过位运算来判断,既节省空间又保证了原子性,这在并发编程里是很常用的技巧。


线程池的拒绝策略有哪些?

在 JDK 的 ThreadPoolExecutor 里,默认提供了 四种 拒绝策略。它们都是 ThreadPoolExecutor 的静态内部类。

  1. AbortPolicy(默认策略) 这个最简单粗暴。如果线程池处理不了了,它直接抛出一个 RejectedExecutionException 异常。 这其实是提醒开发者:“系统撑不住了,赶紧看看”。我们在开发测试阶段常用这个,能快速暴露问题。

  2. CallerRunsPolicy(调用者运行策略) 这个策略我觉得最“机智”。既然线程池忙不过来,那就 谁提交的任务,谁自己去跑。 比如主线程提交的任务,就被退回到主线程去执行。 这样做有两个好处:第一,任务不会丢失;第二,因为调用者自己去跑任务了,它就没空提交新任务了,相当于变相地对生产端进行了 “背压”(Backpressure),给线程池喘息的机会。

  3. DiscardPolicy(丢弃策略) 这个比较狠,任务直接丢掉,不做任何处理,也不抛异常。 这种策略风险很大,除非是那种无关紧要的日志记录或者统计任务,丢了也就丢了,否则一般不用。

  4. DiscardOldestPolicy(丢弃最老策略) 这个是把队列里 最老的一个任务(也就是队头那个等待最久的任务)拿出来扔掉,然后尝试再次提交当前这个新任务。 这个在 Android 甚至有点像 Buffer 的机制,但在某些对顺序有要求的场景下要慎用。

除了这四种,其实我们还可以 自定义拒绝策略。 只需要实现 RejectedExecutionHandler 接口,重写 rejectedExecution 方法就行。 比如在一些高可靠的业务里,我们可能会把被拒绝的任务 持久化到磁盘 或者 发到消息队列 里,等系统空闲了再拿出来慢慢跑,保证任务绝对不丢失。


Java 虚拟机的内存模型分为哪些部分?各自的作用是什么?

嗯,这个问题其实是在问 JVM 的 运行时数据区(Runtime Data Area)。我们在看《深入理解 Java 虚拟机》或者 OpenJDK 源码的时候,通常会把它分为 线程私有线程共享 两大类。

首先是 线程私有 的部分,这意味着它们的生命周期是和线程绑定的,随线程生灭:

  1. 程序计数器(Program Counter Register): 这是一块很小的内存空间。它的作用就是记录当前线程执行到哪一行字节码了。 其实在 CPU 切换线程的时候,得知道切回来从哪儿接着跑,靠的就是它。它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域。

  2. 虚拟机栈(Java Virtual Machine Stack): 这个是我们平时最常接触的。每个 Java 方法在执行的时候,都会创建一个 栈帧(Stack Frame)。 栈帧里存了 局部变量表操作数栈、动态链接和方法出口。 我们常说的“栈内存”溢出(StackOverflowError),比如递归没写终止条件,爆的就是这里。

  3. 本地方法栈(Native Method Stack): 这个和虚拟机栈很像,区别在于虚拟机栈是为 Java 方法服务的,而本地方法栈是为 Native 方法(也就是 JNI 调用 C/C++ 代码)服务的。在 HotSpot 虚拟机里,其实这两个栈是合二为一的。

然后是 线程共享 的部分,这是 GC(垃圾回收) 关注的重点:

  1. 堆(Java Heap): 这是 JVM 管理的最大一块内存。几乎所有的 对象实例数组 都在这里分配。 从内存回收的角度看,这里也是分代的(也就是新生代、老年代),虽然现在的 ZGC 弱化了分代的概念,但经典模型里还是有的。我们遇到的 OOM 错误,绝大部分都发生在这里。

  2. 方法区(Method Area): 这其实是一个 逻辑概念。它用来存储已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。 在具体的实现上,JDK 1.7 叫 永久代(PermGen),而 JDK 1.8 之后改叫 元空间(Metaspace),这个变化还挺大的。

还有一个不属于运行时数据区的 直接内存(Direct Memory),我们在做 NIO 开发或者 Netty 优化的时候会用到它,它直接使用 Native 堆内存,不受 JVM 堆大小限制,但会受本机总内存限制。


JDK1.7 和 JDK1.8 中虚拟机内存模型的区别是什么?

嗯,这两个版本最大的区别,其实主要集中在 方法区(Method Area) 的实现上,也就是从 永久代(PermGen) 变成了 元空间(Metaspace)

具体来说,有这么几个关键的变动:

首先是 字符串常量池(String Constant Pool) 的位置。 在 JDK 1.7 的时候,HotSpot 虚拟机就已经开始动手了,它把字符串常量池从永久代移到了 Java 堆(Heap) 中。 这样做主要是因为永久代的空间比较小且固定,容易发生 OOM: PermGen space,而字符串在运行时生成得很频繁,放在堆里能更好地利用 GC 进行回收。

然后是 JDK 1.8 的彻底变革。 在 1.8 里,永久代被彻底移除了。取而代之的是 元空间(Metaspace)。 这两者最大的区别在于存储位置:

  • 永久代是占用 JVM 内存 的。
  • 元空间则是直接使用 本地内存(Native Memory)

为什么要这么改呢? 其实我觉得最主要的原因是 永久代的大小很难调优。 如果设小了,加载类多了容易 OOM;设大了,又浪费内存。 而元空间使用的是本地内存,理论上只要物理内存足够,它就可以一直扩容,这样就大大减少了因为加载类过多(比如用了大量的动态代理或者 Spring 这种重度框架)而导致的方法区 OOM 问题。

另外,类的一些 静态变量 在 1.8 里也是随着 Class 对象存放在 中的,只有类的 元数据(Metadata)(比如类结构、方法定义等)才存在元空间里。

所以总结一下:JDK 1.7 主要是把字符串常量池移到了堆;JDK 1.8 则是彻底废除了永久代,引入了基于本地内存的元空间来存储类元数据。


抓包获取的抖音网络请求报文是明文还是加密的?

哈哈,这个问题直击痛点啊,毕竟目标是字节跳动。 结论肯定是:加密的,而且是多重防护,想直接看到明文非常难。

如果只是简单的配置一下代理(比如用 Charles 或 Fiddler),去抓抖音的包,你会发现大部分请求根本抓不到,或者抓到了也是一堆乱码。

这主要涉及到了 Android 网络安全和字节内部的几个防护机制:

第一,HTTPS 和 SSL Pinning(证书锁定)。 现在的 App 几乎全是 HTTPS 的。但普通的 HTTPS 我们可以通过在手机上安装 Charles 的根证书来抓包(中间人攻击原理)。 但是!大厂的 App(包括抖音)都会做 SSL Pinning。 也就是在 App 内部硬编码了服务端证书的 Hash 值。在建立连接的时候,App 会校验服务端的证书是不是代码里存的那个。如果是 Charles 的伪造证书,校验直接失败,连接就断了。所以你抓包看到的一般是 Connect Refused 或者 Unknown。 要绕过这个,通常得用 Xposed 模块(比如 JustTrustMe)去 Hook 系统底层的 SSL 验证方法。

第二,应用层参数加密。 就算你绕过了 SSL Pinning,看到了 HTTP 报文,你会发现 Body 里的数据还是一堆看不懂的二进制或者乱码。 因为抖音使用了 Protobuf 协议来序列化数据,而不是普通的 JSON。Protobuf 是二进制格式,体积小、速度快,但可读性差,必须要有对应的 .proto 文件才能反序列化出来。

第三,签名校验(Signature)。 除了加密,还有一个关键是 防篡改。 你会发现请求头里有一堆奇怪的字段,比如 X-GorgonX-Khronos 这种(这在逆向圈很有名)。 这些是把 URL 参数、时间戳、Body 内容混在一起,通过 native 层(so 库)的一套复杂算法算出来的签名。 服务器收到请求后会用同样的算法算一遍,如果对不上,说明请求被修改过,直接丢弃。

所以,结论是:默认抓包全是加密乱码。要看明文,不仅要绕过 SSL Pinning,还得解决 Protobuf 反序列化,甚至可能需要去逆向分析它的 native 签名算法。


腾讯客户端 安卓一面


我看你有一段Java后端实习,为什么想投递安卓开发的?

嗯,这个问题其实很多人都问过我。😃

先说结论吧:我觉得安卓开发对我来说,既能满足我对“可视化反馈”的成就感,又能满足我对“底层系统机制”探索的技术野心,这种“上能写UI,下能改内核”的跨度让我非常着迷。

其实那段后端实习对我帮助挺大的,它让我理解了整个 C/S 架构的闭环。比如我以前只知道发个请求,现在我知道 Server 端是怎么分层处理、怎么查库的。但是呢,在实习过程中,我发现自己对**“直接面向用户”这部分更感兴趣。后端更多是在处理数据流转和高并发,比较抽象;而安卓这边,我的一行代码改动,用户手指一点就能立马感觉到,这种即时的正向反馈**特别戳我。

而且,随着我深入学习,我发现安卓根本不是简单的“画UI”。 为了搞懂一个 App 为什么会卡顿,我去啃了 Framework 源码,看到了 Google 是怎么在 Linux 内核之上构建出一套庞大的 SystemServer 的。比如我研究 Binder 驱动 的时候,发现它在 C++ 层和驱动层做了那么多精妙的内存映射(mmap)来实现一次拷贝,这种操作系统级别的设计让我觉得特别硬核。

做后端可能很少有机会去改 JDK 源码,但在安卓这边,无论是为了性能优化还是 Hook 系统功能,都需要去跟 ActivityManagerServiceWMS 这些大家伙打交道。这种不仅要懂应用层业务,还要懂底层 OS 原理的要求,我觉得特别有挑战性,也更符合我对“全栈工程师”的定义。

所以综合来看,我觉得安卓是一个能让我长期深耕、既有广度又有深度的领域,所以我坚定地选择了这边。💪


你在学安卓的时候,是通过什么方式来学习的?

嗯,其实我的学习路径大概经历了三个阶段,是一个从**“API调用”“源码求证”再到“系统构建”**的过程。

第一阶段肯定是打基础。 刚开始就是看官方文档和《第一行代码》,把四大组件、生命周期这些弄熟。这时候更多是知其然。但我比较喜欢钻牛角尖,比如以前背八股文说“主线程不能做耗时操作”,我就想知道系统到底是怎么监控这个耗时的? 是谁抛出的 ANR?

这就逼着我进入了第二阶段:源码阅读与调试。 我不会干读代码,那样太枯燥了。我通常是带着问题去 Debug。 比如为了搞懂 App 启动流程,我会自己在 ActivityThreadmain 方法打断点,或者是去跟 Instrumentation 的源码。我看源码的时候习惯用IDEGen把 AOSP 导入到 Android Studio 里,这样跳转跳转非常方便。 在这个过程中,我建立了自己的知识体系,比如顺着 View.invalidate() 一路往下找,一直追到 ViewRootImpl 里的 scheduleTraversals(),再到 Choreographer 监听 Vsync 信号,这一条线串起来之后,我就彻底明白了为什么 UI 更新必须在主线程,以及掉帧是怎么发生的。

第三阶段也就是最近,我开始尝试“系统级”的实践。 因为只看代码有时候还是虚,我就自己去下载了 Android 12 的源码进行全量编译(虽然编译一次真的好久😅)。 我在 Pixel 手机上尝试去修改 Framework 代码,比如在 ActivityStack(新版本是 ActivityTaskSupervisor 那些)里加一些 Log,或者尝试修改 PMS 的安装逻辑。 我还强迫自己写技术博客,把学到的 Binder 机制、Handler 原理画成图。我觉得能把复杂的 AMS 启动流程用人话讲清楚,才算是真的学会了。

所以总结下来就是:官方文档入门 -> 带着问题 Debug 源码 -> 编译 AOSP 动手魔改 -> 输出博客复盘。这套方法论让我觉得心里特别有底。📚


你挑一些在快手工作中,认为最难、有深度或有成就感的需求来讲讲?

嗯,我想分享一个在快手实习期间做的信息流列表滑动流畅度优化的 Case。虽然它听起来像是一个常见的 UI 优化,但我们当时遇到的问题比较特殊,最后是深入到 View 渲染机制Handler 消息调度层面才解决的。

背景是这样的: 我们的主 Feed 流是一个极其复杂的 RecyclerView,里面夹杂了视频、直播流、图文,Item 类型特别多。当时测试报了一个偶现的**掉帧(Jank)**问题,特别是在快速滑动并触发视频预加载的时候,界面会有明显的“顿感”。

最开始我以为是常见的 onBindViewHolder 耗时。 所以我第一时间上了 Systrace(现在叫 Perfetto 了),去抓那几秒的 Trace。结果很奇怪,主线程的 doFrame 确实超时了,有的帧甚至跑了 30ms,但是我看 User 代码段,onBindLayout 的耗时并不长。

这就很有意思了,时间去哪了? 🤔 我把 Trace 图放大,发现主线程在执行 ViewRootImpl.performTraversals 之前,有一段很长的 Gap,或者是有一些名为 Input 的系统调用占用了 CPU。 我当时推测:是不是主线程的消息队列里塞了别的东西,挤占了 UI 绘制消息的处理时间?

为了验证这个,我利用了 Looper.setMessageLogging 加上我自己写的一个简单的消息耗时监控工具,去抓取主线程所有 Message 的执行时间。 结果发现,业务层有一个第三方的埋点 SDK,它注册了一个 IdleHandler。本来 IdleHandler 是空闲时才执行,没问题,但这个 SDK 在 queueIdle 里做了一个极其隐蔽的同步 IO 操作(读取配置文件)。 这就导致了,虽然 Choreographer 已经请求了 Vsync 信号,但是当 Vsync 信号到来,通过 Binder 发给 App 进程,App 进程的主线程 Looper 此时正卡在那个 SDK 的 queueIdle 逻辑里出不来! 等它执行完,已经过了 10ms 了,留给 measure/layout/draw 的时间就不够了,自然就掉帧了。

解决过程: 定位到问题后,改动其实不大,但我做了一层防守策略。 除了推动 SDK 方把 IO 放到子线程,我自己封装了一个 TaskScheduler。对于这种非紧急的初始化任务,我利用 Choreographer.postFrameCallback,在每帧 doFrame 执行完之后,计算当前帧还剩余多少时间(比如 16.6ms - drawTime),如果时间充裕才去执行这些杂活。这有点像 React 的 Fiber 架构的思想,把大任务拆碎,利用帧间空隙去执行。

这个事情给我的成就感在于: 它不是简单的“把大图片改小”这种优化,而是要求我必须非常清楚 Handler 机制、Looper 优先级、Choreographer 与 Vsync 的配合关系 才能定位到病灶。这也验证了我之前看 Framework 源码确实是有用的,在关键时刻能帮我透过现象看到本质。🚀


具体是怎么破解抖音网络抓包证书校验问题的?

嗯,关于抓包这个问题,其实我在做逆向分析或者是学习大厂网络库实现的时候专门折腾过。针对抖音这种量级的 App,普通的抓包工具(像 Charles、Fiddler)配合手机装个 CA 证书肯定是抓不到的,因为它们做了非常严苛的 SSL Pinning(证书锁定),甚至部分流量走的都不是标准的系统网络库。

具体的破解思路,我一般是分三步走的:

首先,最基础的,如果是早期的版本或者一些非核心接口,它们可能还在用 Android 原生的网络栈。这时候,应用层会去校验服务端的证书是不是 App 内置的那个。如果是用的 OkHttp,通常是在 build 那个 Client 的时候配置了 CertificatePinner。 针对这种情况,我一般会直接上 Frida 或者 Xposed。比如用 JustTrustMe 这个模块,去 Hook 系统的 javax.net.ssl.X509TrustManager 接口,把 checkServerTrusted 这些校验方法直接置空,让它无脑返回 true。这样 HTTPS 的握手就能过。

但是,抖音现在的主流版本肯定没这么简单。你会发现即使 Hook 了 Java 层,抓包工具里还是一片红,或者是 Unknown。 这是因为抖音内部集成了 Google 的 Cronet 网络库,它底层是 C++ 实现的,而且它可能会优先走 QUIC 协议(基于 UDP),这直接就绕过了系统的 Java 层网络栈,也绕过了 Charles 这种基于 TCP 的代理。

所以,第二步,我通常会尝试强制降级。 我会去 Hook 它的网络配置,或者在系统防火墙层面把 UDP 的 443 端口给 Ban 掉,强迫它回退到 HTTP/2 或者 HTTP/1.1 的 TCP 连接,这样至少 Charles 能感知到连接了。

第三步,就是解决 Native 层的证书校验。 既然它用了 Cronet 或者自家改的 libsscronet.so,那校验逻辑肯定在 Native 层。这时候就需要用 Frida 去 Hook Native 函数了。 我当时的思路是去找它 so 库里类似 SSL_CTX_set_custom_verify 或者验证证书链的函数。其实网上也有开源的脚本(比如针对 Cronet 的 frida-cronet-ssl-pinning),原理就是通过动态插桩,找到验证 Cert 的那个回调点,把返回值强行改成“验证通过”。

总结一下就是:先尝试 Hook Java 层的 TrustManager,如果不行,说明走了 Native 网络库(如 Cronet),这时候尝试屏蔽 QUIC 降级协议,最后利用 Frida 深入 Native 层去 Patch 掉 SSL 的验证函数。 这一套下来,基本都能看到明文数据了。😎


网络上加密防止别人抓包有哪些方式?

嗯,这个问题其实就是攻防的另一面了。作为开发者,如果不希望自己的接口“裸奔”,通常会构筑好几道防线。我把它总结为从网络层应用层的层层加固。

第一道防线,肯定是 HTTPS。 这是最基础的,虽然像我刚才说的,它防不住中间人攻击(MITM),但它至少能防止同一局域网下的被动嗅探。没有 HTTPS,别人连 Wi-Fi 都能直接看明文,那太危险了。

第二道防线,就是 SSL Pinning(证书锁定)。 这是目前最通用的做法。就是我不信任系统里用户安装的那些 CA 证书(比如 Charles 生成的证书),我在 App 代码里硬编码我们要信任的服务端证书的 Hash 值(Public Key Hash)。 在 OkHttp 里,就是通过 .certificatePinner() 方法添加。当握手时,如果发现服务端发来的证书指纹跟我不一致,直接掐断连接。这能挡住 90% 的脚本小子。

第三道防线,双向认证(mTLS)。 一般 App 只是客户端校验服务端,双向认证就是服务端也要校验客户端。App 里得内置一套客户端证书(p12 文件),握手的时候发给服务端看。如果黑客用抓包工具做代理,他拿不到这个私钥,握手就没法完成。不过这种成本比较高,一般涉及支付等核心业务才会用。

第四道防线,应用层的自定义加密。 这个就比较狠了。就是不管 HTTPS 有没有被破解,我在 HTTP 的 Body 里传输的数据本身就是一坨乱码。 我们可以在发送请求前,先把 JSON 数据用 AES 对称加密一下,然后再把 AES 的密钥用服务端的 RSA 公钥加密放到 Header 里。 这样,就算黑客把 HTTPS 破解了,他看到的也是加密后的二进制流。除非他能逆向出我们的 AES 密钥生成逻辑或者是拿到 RSA 私钥。

第五道防线,就是环境检测。 这属于“侧面防御”。比如在 App 启动或者网络请求时,通过 ConnectivityManager 检查当前是否设置了 VPN 或者 系统代理。如果发现有代理,直接拒绝发送敏感请求。 或者通过 Debug.isDebuggerConnected() 看有没有被调试。

所以结论是:防抓包没有绝对的安全,只有成本的博弈。 目前最推荐的组合拳是:HTTPS + 强力的 SSL Pinning + 关键数据自定义加密 + 混淆代码(增加逆向难度)。 🛡️


什么叫做HTTPS?它比HTTP安全在哪?

嗯,HTTPS 其实说白了就是 HTTP + SSL/TLS。 HTTP 协议本身是明文传输的,它只负责数据的搬运,不负责数据的安全。而 HTTPS 就是在 HTTP 下面加了一层安全协议(Secure Sockets Layer / Transport Layer Security),就像是给数据装进了一个上了锁的保险箱再运输。

关于它比 HTTP 安全在哪里,我觉得主要体现在三个核心点:机密性、完整性、身份认证

首先是 机密性(Confidentiality),也就是防窃听。 HTTP 发出去的数据,谁都能看懂。而 HTTPS 在握手建立连接后,会协商出一个对称加密的密钥(Session Key)。 这里有个很经典的设计:因为非对称加密(像 RSA)太慢了,不适合传大文件,所以 HTTPS 只在握手阶段用非对称加密来安全地交换那个对称密钥。一旦握手完成,后面所有的数据传输都用这个对称密钥进行 AES 加密。这样既安全又快。就算你在路由器上截获了数据包,没有密钥也解不开。

其次是 完整性(Integrity),也就是防篡改。 如果是 HTTP,中间人可以把网页里的广告换成赌博网站,或者在下载的 APK 里植入病毒,客户端完全不知道。 HTTPS 引入了 MAC(消息验证码) 或者 Hash 签名机制。数据在传输过程中如果被修改了哪怕一个比特,接收端校验 Hash 值的时候就会发现对不上,从而报错丢弃。

最后是 身份认证(Authentication),也就是防冒充。 这就是 数字证书(Certificate) 发挥作用的地方了。怎么证明淘宝就是真的淘宝,而不是黑客搭建的钓鱼网站呢? 服务端会给客户端发一个由权威机构(CA)签名的证书。客户端(比如浏览器或 Android 系统)内置了这些 CA 的公钥。 拿到证书后,系统会用 CA 的公钥去验证证书的签名。如果验证通过,就说明这个服务器确实是它声称的那个域名,不是冒牌货。

所以简单总结一下:HTTP 是裸奔,HTTPS 是穿了防弹衣、带了身份证、还上了密码锁。 只要涉及到用户隐私或者商业数据,HTTPS 现在的确是标配,像 Android 9.0 以后,系统默认都已经禁止明文 HTTP 请求了(Cleartext Traffic Disabled)。🔐


公钥和私钥的区别是什么(在非对称加密中)?加签和验签是什么过程?加密解密、加签和验签分别保证了什么事情?

嗯,这个问题其实是计算机网络和安全领域最基础但也最重要的概念,特别是在我们做 Android 开发 时,无论是 HTTPS 的网络通信,还是 APK 打包签名 的机制,底层逻辑都是基于这一套非对称加密体系的。

首先说 公钥和私钥的区别

其实很简单,它俩是成对出现的,是通过某种算法(比如 RSA 或者 ECC)生成的一对密钥。

  • 公钥(Public Key):顾名思义,是公开给他人的。你可以把它分发给全世界,任何人都可以持有。
  • 私钥(Private Key):这是必须严格保密的,只有拥有者自己手里才有,绝对不能泄露。

在代码层面,比如在 Java 的 java.security 包下,我们经常用 KeyPairGenerator 来生成这么一对 KeyPair

它们最重要的特性就是:用公钥加密的数据,只有对应的私钥才能解密;反过来,用私钥处理过的数据,可以用公钥来验证。 这是一个不可逆的数学关系。


接下来说说 加签(签名)和验签(验证)的过程

这里经常容易和“加密”搞混,其实方向是反过来的。

加签(Signing) 的核心在于证明“这是我发的”以及“内容没被改过”。它的过程通常是这样的:

  1. 首先,发送方(比如我们打包 APK 的时候)会对原始数据(Message)做一个 Hash 运算(比如 SHA-256),生成一个固定长度的 摘要(Digest)。这样做是为了效率,因为直接对在大文件上做非对称加密太慢了。
  2. 然后,发送方用自己的 私钥 对这个 摘要 进行加密。
  3. 这个“加密后的摘要”就是我们所谓的 数字签名(Signature)
  4. 最后,把原始数据和这个签名一起发给接收方。

验签(Verification) 则是接收方(比如 Android 系统的 PMS 在安装应用时)做的事情:

  1. 接收方拿到原始数据和签名。
  2. 先用同样的 Hash 算法对 原始数据 算一遍,得到 摘要 A
  3. 然后,用发送方的 公钥签名 进行解密,得到 摘要 B
  4. 最后对比:如果 摘要A==摘要B摘要 A == 摘要 B ,那就说明验证通过。

我在看 AOSP 源码 的时候,特别留意过 Android 的签名机制。比如在 ApkSignatureVerifier 类或者老版本的 PackageParser 里,系统在安装 App 时,就会去解析 APK 里的 META-INF 文件夹,提取出证书里的公钥,去验证 APK 的完整性,走的完全就是这一套流程。


最后总结一下 加密解密、加签和验签分别保证了什么

这两个操作虽然用的都是公私钥,但目的完全不同

  1. 加密和解密(Encryption / Decryption)

    • 这是为了保证 机密性(Confidentiality)
    • 是用 接收方的公钥 加密,只有 接收方的私钥 能解密。
    • 它的目的是防止数据在传输过程中被别人“偷看”。就像我给你寄个带锁的箱子,只有你有钥匙能打开。
  2. 加签和验签(Signing / Verification)

    • 这是为了保证 完整性(Integrity)身份认证/不可抵赖性(Authentication / Non-repudiation)
    • 是用 发送方的私钥 签名,用 发送方的公钥 验证。
    • 完整性:如果数据传输中被篡改了一位,Hash 值就会变,验签就会失败(摘要对不上)。
    • 身份认证:因为私钥只有我有,所以如果公钥能解开签名,那这东西一定是我发的,我抵赖不掉。

所以在 Android 系统里,为什么我们更新 App 时必须用同一个签名文件?就是因为 PackageManagerService 会去校验新包的签名公钥和旧包是否一致(verifySignatures 方法),确保这还是原来的开发者发布的更新,防止被恶意篡改。


Java并发的三大原则是什么?

嗯,这其实是 Java 内存模型(JMM)为了解决多线程安全问题所定义的三个核心概念。

结论就是:原子性(Atomicity)、可见性(Visibility)和有序性(Ordering)。

下面我详细展开讲讲我对这三个原则的理解,毕竟在处理 Android 复杂的并发场景时,脑子里必须时刻绷着这根弦。

首先是原子性。 它的意思是一个操作或者一系列操作,要么全部执行并且不被中断,要么就完全不执行。 最典型的例子就是 i++。在代码里看它只是一行,但从字节码或者汇编层面看,它其实包含了“读取-修改-写入”三个步骤。如果在多线程环境下,A线程读了没写回去,B线程切进来了,那就会导致数据覆盖。 在 Android Framework 里,像 PMS (PackageManagerService) 更新应用状态时,必须保证状态切换是原子的,否则会导致系统状态不一致。

第二个是可见性。 这个原则是指,当一个线程修改了共享变量的值,其他线程能够立即看到修改后的值。 这里其实涉及到了硬件架构。因为 CPU 有 L1/L2 缓存,线程其实是在自己的工作内存(Working Memory)里操作变量副本,而不是直接操作主内存(Main Memory)。如果没有可见性保证,线程 A 改了值,线程 B 可能还在读自己缓存里的旧值。 这在 Android 开发中很常见,比如我们在子线程里通过一个 boolean flag 去控制主线程的某个动画停止,如果这个 flag 不具备可见性,主线程可能永远停不下来。

最后是有序性。 程序的执行顺序不一定和我们写的代码顺序一致。 编译器为了优化性能,或者是 CPU 为了指令流水线的效率,可能会进行指令重排序(Instruction Reordering)。 在单线程里这没问题(As-if-serial语义),但在多线程里这就乱套了。最经典的也是最坑的就是单例模式的 DCL(双重检查锁)。如果 instance = new Singleton() 这行代码发生了重排序——先把内存指给了引用,但对象还没初始化完——这时候另一个线程拿去用了,就会 Crash。

所以,理解这三大原则,是我们正确使用 synchronizedvolatile 这些关键字的前提。


原子性除了用synchronized关键字,还有什么方式可以保证?

嗯,synchronized 确实是最通用的悲观锁方案,但它比较重。在 Java 和 Android 底层,其实还有更轻量或者更灵活的方式。

主要有两种替代方案:一是使用 JUC 包下的显式锁(Lock),二是使用原子类(Atomic Classes)利用 CAS 机制。

先说 Lock 接口(比如 ReentrantLock)。 它提供了比 synchronized 更灵活的语义。比如 tryLock() 可以尝试获取锁,拿不到就走,不用一直阻塞死等;或者 lockInterruptibly() 可以响应中断。 我在看 Android 源码时发现,虽然 Framework 层(比如 AMS、WMS)为了代码简洁大量使用了 synchronized(this),但在一些对性能要求极高或者逻辑复杂的队列处理中,也会用到 ReentrantLock 配合 Condition 来做精细的线程等待唤醒。

但我觉得更值得一说的是 CAS(Compare And Swap)机制,也就是无锁并发。 这是一种乐观锁策略。它的核心思想是:我更新的时候不去加锁,而是拿着我预期的旧值(Expected)和内存里现在的值去比对。如果一样,说明没被人改过,我就更新;如果不一样,说明有人改了,那我这次更新失败,通常会进行自旋重试(Spin)。

Java 里的 Atomic 系列类(像 AtomicInteger, AtomicReference)就是靠这个保证原子性的。

如果我们点进 AtomicInteger.getAndIncrement() 的源码去看,会发现它最后调用的是 Unsafe 类的方法:

Java
unsafe.getAndAddInt(this, valueOffset, 1);

这个 Unsafe 类非常底层,它对应的 C++ 实现(在 ART 虚拟机里)会最终调用 CPU 的指令,比如 x86 下的 cmpxchg 指令。这是一种硬件级别的原子操作指令

我在写一些高性能的计数器,或者做一些简单的状态标记时,会优先用 AtomicBoolean 或者 AtomicInteger。因为它们没有线程切换和上下文切换的开销(Context Switch),比 synchronized 快得多,特别是在 Android 这种对 UI 线程流畅度很敏感的环境下,减少锁竞争是非常重要的优化手段。

所以总结一下:如果是复杂的代码块需要原子性,用 ReentrantLock;如果是单个变量的读写修改,优先用 CAS 机制的 Atomic 类


可见性可以通过什么来保证?

其实刚才提到的 synchronizedLock 在释放锁的时候,都会强制把工作内存的变量刷回主内存,是能保证可见性的。但针对“可见性”这个特定问题,最直接、最轻量的方案肯定是 volatile 关键字。

结论是:主要通过 volatile 关键字,其次是 synchronized/Lock,还有 final 关键字也能提供某种程度的初始化可见性。

我重点讲讲 volatile。 它的作用有两个:一是保证内存可见性,二是禁止指令重排序

当一个变量被声明为 volatile 后,Java 编译器生成的字节码在涉及这个变量写操作时,会多插入一条 Memory Barrier(内存屏障) 指令。 这个屏障的作用是告诉 CPU:“嘿,这一步写入不能放到缓存里等着,必须立马刷到主内存(Main Memory)去!而且,在这个屏障之前的写操作,都不能重排序到屏障之后。” 同时,它还会通过总线嗅探机制(Bus Snooping)通知其他 CPU 核心:你们缓存里的这个变量失效了,下次要用得去主内存重新拉取。

我在看 Android Handler 机制 源码的时候,虽然 MessageQueue 主要靠 synchronized 来保护链表操作,但我注意到像 Looper 里的 mQuitAllowed 这种状态位,或者一些系统服务里的控制开关,经常就是用 volatile 修饰的。 因为这种场景不需要原子性(比如只是单纯置为 true/false),用 synchronized 就太重了,volatile 刚好能保证我在 A 线程改了状态,B 线程里的死循环能立马感知到并退出。

另外提一嘴 final。 大家容易忽略 final 的可见性语义。其实在 JMM 中,final 修饰的字段在构造函数一旦初始化完成,并且构造函数没有把 this 逸出(Escaped),那么其他线程就能立即看到这个 final 字段的值,不需要同步。这在 Android 的 Binder 实体传递或者不可变对象设计中其实起到了隐形的保护作用。

所以,如果只是为了单纯的状态通知,我首选 volatile;如果涉及到复合操作(既要可见又要原子),那还是得老老实实上锁。


有序性可以通过什么来保证?

嗯,在 Java 并发编程里,保证有序性(Ordering)其实核心就靠两个东西:volatile 关键字加锁机制(synchronized 或 Lock)。当然,它们背后的理论基础是 JMM(Java 内存模型)定义的 Happens-Before 原则

首先说最轻量级的 volatile。 它的核心作用就是禁止指令重排序。 在底层实现上,它其实是通过插入 内存屏障(Memory Barrier) 来实现的。比如在写操作之前插入 StoreStore 屏障,写之后插入 StoreLoad 屏障。 这就告诉编译器和 CPU:哎,这行代码上面的指令必须先执行完,才能执行这行;下面的指令必须等这行执行完了才能开始。 举个最经典的例子,就是单例模式的 DCL(双重检查锁),那个 instance 变量必须加 volatile,就是为了防止对象初始化过程中的指令重排,避免其他线程拿到一个半成品的对像。

然后是 synchronized 和 Lock。 这个更好理解,它们通过互斥来实现有序性。 虽然在 synchronized 块内部,代码依然可能会发生重排序(因为只要不改变单线程语义,编译器是可以优化的),但是从宏观上看,一个锁同一时刻只能被一个线程持有。 这就相当于把多线程的并发执行强行变成了串行执行。既然是串行了,那在这个临界区看来,自然就是有序的。

最后,其实我们写代码不需要每行都加锁,是因为 JMM 规定了 Happens-Before 原则。 这算是一个“兜底”规则吧。比如:

  • 程序次序规则:在一个线程内,代码书写在前面的操作 Happens-Before 后面的。
  • volatile 变量规则:对一个 volatile 变量的写,Happens-Before 于后续对这个变量的读。
  • 传递性:A 先于 B,B 先于 C,那 A 肯定先于 C。

所以总结一下:想要保证有序性,要么利用 volatile 禁止重排,要么利用锁机制实现串行化,同时我们要利用好 JDK 原生提供的 Happens-Before 规则来推导线程间的可见性和顺序。🧐


什么情况下会发生Java的指令重排?

嗯,指令重排这个事儿,其实是编译器和处理器为了提高执行效率而做的一种优化手段。通常发生在三个阶段,只要满足 As-if-serial 语义(也就是不管怎么重排,单线程下的执行结果不能变)和 没有数据依赖性,就有可能发生重排。

具体来说,分这三种情况:

第一种是 编译器优化的重排。 Javac 或者 JIT 编译器在生成字节码或机器码的时候,如果发现两行代码没有依赖关系(比如 int a = 1;int b = 2;),它可能会为了减少寄存器的读取切换,调整指令顺序。

第二种是 指令级并行的重排。 这是现代 CPU 的特性。现在的 CPU 都是超标量流水线架构,只要指令之间不存在数据依赖,CPU 就可以同时执行多条指令,甚至打乱顺序执行(Out-of-Order Execution),最后再把结果重组。

第三种是 内存系统的重排。 这个比较隐蔽。因为 CPU 有 L1/L2 缓存和写缓冲区(Store Buffer)。哪怕指令执行顺序没变,但因为缓存同步的延时,导致在其他线程看来,写入内存的顺序好像变了。这在逻辑上也属于重排序。

最著名的重排案例还是我刚才提到的 new 一个对象的过程: User user = new User(); 这行代码在字节码层面其实分为三步:

  1. 分配内存空间(memory = allocate())。
  2. 调用构造函数初始化(ctorInstance(memory))。
  3. 将引用指向内存地址(instance = memory)。

如果发生重排,可能会变成 1 -> 3 -> 2。 也就是先分配内存,然后还没初始化呢,先把引用指过去了。 这时候如果在多线程环境下,另一个线程进来判空,发现 instance 不为 null,直接拿去用了,结果访问的是一段未初始化的内存,程序直接就崩了或者逻辑错了。这也是为什么必须要用 volatile 的原因。

所以结论是:只要不存在数据依赖,且编译器或 CPU 认为重排能提升性能,在没有 volatile 或 锁 约束的情况下,重排随时都可能发生。 ⚠️


JUC包下你知道哪些常用的类?

JUC(java.util.concurrent)包我平时用得挺多的,看 Android 源码的时候也经常看到。我觉得可以按功能分个类来说:

首先是 锁(Locks) 相关的。 最常用的肯定是 ReentrantLock。它比 synchronized 更灵活,支持公平锁、非公平锁,还能通过 Condition 实现更细粒度的等待/唤醒机制(类似于 wait/notify)。 它的底层核心是 AQS(AbstractQueuedSynchronizer),这个类简直是 JUC 的基石,依靠一个 volatile 的 state 变量和一个 FIFO 队列来管理线程的竞争。 还有一个是 ReentrantReadWriteLock,读写锁,读写分离,适合读多写少的场景,能提高并发性能。

其次是 原子类(Atomic)。 像 AtomicIntegerAtomicReference 这些。 它们不用加锁,而是基于 CAS(Compare And Swap) 指令实现的乐观锁。在 Android 源码里,比如 Handler 机制里的 MessageQueue,或者一些状态标志位,经常能看到原子类的身影,性能比锁要好。

然后是 并发容器(Collections)。 最著名的就是 ConcurrentHashMap 了。 在 JDK 1.7 是用分段锁(Segment)实现的,JDK 1.8 改成了 CAS + synchronized 控制哈希桶的头节点,粒度更细了。面试经常问这个。 还有 CopyOnWriteArrayList,这个在 Android Framework 里极其常见!比如 ActivityThread 里维护的各种 Listener 列表。因为读操作无锁,写操作是复制新数组,特别适合读多写少的观察者模式场景。

接着是 线程池(Executors) 相关的。 核心类是 ThreadPoolExecutor。我们平时定义线程池都要用这个,而不是用 Executors 工厂类去创建(因为有 OOM 风险)。还有 FutureTask,配合 Callable 可以拿到异步执行的结果。Android 的 AsyncTask 内部其实就是封装了 FutureTask 和线程池。

最后是 并发工具类(Tools)。 比如 CountDownLatch(倒计时门栓),可以实现一个线程等其他几个线程干完活再执行。 还有 CyclicBarrier(循环栅栏)和 Semaphore(信号量,控制并发数量)。

所以,JUC 包里我最关注的就是 AQS 体系、ConcurrentHashMap 的结构变化,以及线程池的参数配置,这些是平时开发和面试中最核心的部分。🛠️


CyclicBarrier(屏障栅栏)是干嘛的?ConcurrentHashMap底层是怎么保证线程安全的?Segment是怎么锁住ConcurrentHashMap的?

嗯,这三个问题其实涵盖了 Java 并发编程里关于 线程协作并发容器 的核心。特别是 ConcurrentHashMap,它在 JDK 1.7 和 1.8 的实现差异非常大,这在面试里绝对是个经典的考点。作为 Android 开发,我们在做一些后台预加载或者多线程初始化的任务时,偶尔也会用到这些工具。

先说 CyclicBarrier

它的字面意思就是“可循环使用的屏障”。我觉得可以用一个很形象的例子来理解:比如我们公司团建去聚餐,大家分头行动,但是约定好了“所有人到齐了才能开饭”。

这里的“所有人”就是参与的线程,“开饭”就是屏障打开后的后续动作。

  1. 核心机制: 它允许一组线程互相等待,直到所有线程都到达某个公共屏障点(common barrier point)。 在代码里,每个线程执行完自己的逻辑后,会调用 await() 方法。这时候这个线程就会被阻塞住(挂起)。
  2. 计数器逻辑CyclicBarrier 内部维护了一个计数器(底层是用 ReentrantLockCondition 实现的)。每当有一个线程 await(),计数器就减 1。 一旦计数器减到 0,说明大家都到齐了,屏障就会“被打破”,所有被阻塞的线程会被同时唤醒,继续执行后面的代码。
  3. 高级特性: 它构造的时候还可以传一个 Runnable 类型的 barrierAction。当计数器归零的那一瞬间,也就是最后一个线程到达时,会优先执行这个 Runnable。这在做多线程计算结果合并的时候特别有用。
  4. 和 CountDownLatch 的区别: 这点很重要。CountDownLatch一次性的,减到 0 就结束了,像倒计时火箭发射;而 CyclicBarrier可重用的(Cyclic),屏障打开后,计数器会重置,可以用于下一轮的等待。

在 Android 源码里,虽然我们平时更多用 Handler 机制,但在一些底层的测试框架或者 native 层的线程同步里,这种屏障思想是很通用的。不过要注意别把它和 Android MessageQueue 里的 Sync Barrier(同步屏障) 搞混了,那个是为了让 Vsync 信号优先处理 UI 绘制消息的,完全是两码事。


接下来详细聊聊 ConcurrentHashMapSegment

这个问题必须分 JDK 1.7JDK 1.8 两个版本来说,因为变化太大了。这也是面试官最喜欢问的细节。

Segment 是怎么锁住 ConcurrentHashMap 的?(针对 JDK 1.7)

在 JDK 1.7 版本里,ConcurrentHashMap 确实是基于 分段锁(Segment Locking) 机制来实现的。

  1. 结构设计: 它的内部维护了一个 Segment 数组。每个 Segment 其实本质上就是一个小的 HashMap(内部维护了 HashEntry 数组)。 最关键的是,这个 Segment 类直接继承自 ReentrantLock

  2. 加锁过程: 当我们要 put 一个数据时,不会像 Hashtable 那样把整个 Map 锁死。

    • 首先,根据 Key 的 Hash 值,算出这个 Key 应该存放在哪个 Segment 里。
    • 然后,只对这一个 Segment 调用 lock() 方法加锁。
  3. 并发度: 这就意味着,如果多个线程操作的是不同 Segment 里的数据,它们是可以真正并行的,互不干扰。默认情况下 Segment 数组长度是 16,理论上就支持 16 个线程并发写。

所以,回答你的问题:Segment 是通过继承 ReentrantLock,将锁的粒度细化到了“段”这个级别,只锁住当前操作的那一部分数据,从而提高了并发性能。

ConcurrentHashMap 底层是怎么保证线程安全的?(针对 JDK 1.8 及现状)

但是!嗯,这里要转折一下。

到了 JDK 1.8(Android N 以后基本都跟进了 OpenJDK 8+ 的逻辑),Segment 的设计被彻底抛弃了

现在的 ConcurrentHashMap 保证线程安全的方式变得更加高效和激进,主要靠 CAS + synchronized

  1. 结构变化: 去掉了 Segment,直接回到了 Node 数组 + 链表 + 红黑树的结构(和 HashMap 类似)。

  2. 怎么保证安全(put 操作流程)

    • CAS 阶段:当线程尝试往数组的一个空位置插入节点时,它不加锁。而是通过 Unsafe 类的 compareAndSwapObject(CAS 操作)来直接尝试写入。如果成功了就返回;如果失败了(说明有冲突),就进入下一步。
    • Synchronized 阶段:如果发现该位置已经有节点了(发生了 Hash 碰撞),它会利用 synchronized 关键字,只锁住这个链表(或红黑树)的头节点
  3. 为什么这么改?

    • 锁粒度更细:以前锁一个 Segment 可能管着一堆 HashEntry,现在只锁一个坑位(Bucket),并发冲突概率更低了。
    • 内存优化:原来的 Segment 对象本身就有内存开销,现在结构更紧凑。

总结一下: 如果是问 1.7,它是通过 Segment 分段锁(ReentrantLock) 来保证安全的; 如果是问 1.8(也就是现在的 Android 环境),它是通过 CAS(处理空节点)synchronized(锁定头节点处理冲突) 配合 volatile 关键字(保证可见性)来实现的。

我在看 AOSP 源码时,比如系统服务里的一些缓存 Cache,如果是高并发场景,基本都是直接上 ConcurrentHashMap,比 Collections.synchronizedMap 性能好太多了。


CyclicBarrier(屏障栅栏)是干嘛的?

嗯,CyclicBarrier,字面意思就是可循环使用的屏障

结论就是:它是一种多线程同步辅助工具,主要用来让一组线程到达一个共同的屏障点(Synchronization Point)之前阻塞,只有当所有线程都到达这个点之后,屏障才会打开,这些线程才能继续往下执行。

这就好比我们去旅游,导游拿着旗子在景点门口等,说“大家自由活动,下午3点在这里集合,人齐了才能上车去下一个景点”。这里的“3点集合”就是 Barrier,导游就是那个协调者。

在代码里,它的核心方法是 await()。 比如我初始化一个 CyclicBarrier(3),然后开了3个线程。每个线程做完自己的事(比如下载图片),调用 barrier.await()。 前两个线程调了之后就会卡在那里(park 状态),等第三个线程也调了 await(),计数器归零,不仅这三个线程会同时被唤醒继续跑,而且 CyclicBarrier 还可以接收一个 Runnable 作为“汇总任务”,在这个时刻自动执行。

它和 CountDownLatch 的区别其实是面试常考点。 我觉得最本质的区别是:CountDownLatch 是“减法”,是一次性的;CyclicBarrier 是“加法”,是可以重用的(Cyclic)。 CountDownLatch 像倒计时发射火箭,数到0就发了,不能重置。而 CyclicBarrier 可以在所有线程释放后,自动重置计数器,进行下一轮的同步,非常适合那种分阶段的并行计算任务

虽然在 Android 主线程开发里直接用得少(因为不能阻塞 Main 线程),但在做一些并行初始化框架的时候很有用。比如应用启动时,我有 A、B、C 三个独立的 SDK 初始化任务,它们不依赖顺序,但 D 任务必须等这三个都搞定才能跑。这时候与其写复杂的 join,不如用 CyclicBarrier 或者 CountDownLatch 来得优雅。


ConcurrentHashMap底层是怎么保证线程安全的?

这个问题其实得分 JDK 1.7 和 1.8 两个版本来讲,因为变化太大了。

结论是:JDK 1.7 采用的是分段锁(Segment)技术,把锁的粒度细化;而 JDK 1.8 也就是我们现在主流用的版本,抛弃了 Segment,直接采用了 CAS + synchronized 的方式,配合 Node 数组 + 链表 + 红黑树的数据结构来实现。

我现在主要研究的是 JDK 1.8 的源码,它的并发控制做得非常精妙,主要靠两点:

  1. CAS (Compare And Swap): 当 put 一个新数据时,会先计算 hash 值找到数组下标。如果这个位置(Bin)是空的(null),它不会去加锁,而是直接用 Unsafe.compareAndSwapObject 尝试把这个 Node 放进去。 如果失败了(说明有并发冲突),它会自旋重试。这就在没有冲突的情况下避免了内核态的锁开销。

  2. synchronized 关键字: 如果计算出来的数组位置已经有数据了(Hash 冲突),那说明需要链表或红黑树操作。这时候 1.8 会直接用 synchronized 锁住这个链表的头节点(Head Node)。 这就比 1.7 厉害了,因为锁的粒度从“一段”缩小到了“一个桶(Bucket)”。只要 hash 不冲突,线程间几乎是零竞争的。

这也体现了 Java 对 synchronized 的性能优化很有信心。以前大家都觉得它重,但现在有了锁升级(偏向锁、轻量级锁),在很多场景下它的性能表现并不比 ReentrantLock 差,而且还省内存。

所以总结下来,1.8 的 ConcurrentHashMap 是在一个死循环里,利用 CAS 做无锁插入,利用 synchronized 锁住哈希桶,既保证了安全,又把并发度拉满了。


Segment是怎么锁住ConcurrentHashMap的?

嗯,既然问到了 Segment,那就是指 JDK 1.7 的实现机制了。虽然现在用的少了,但这种**“分而治之”**的设计思想在系统设计里还是挺常见的。

结论是:Segment 本身其实就是继承自 ReentrantLock。ConcurrentHashMap 内部维护了一个 Segment 数组,每个 Segment 守护着哈希表里的一部分 Bucket(桶)。当要操作某个 Key 时,先通过 Hash 找到它属于哪个 Segment,然后对这个 Segment 加锁。

具体细节是这样的: 在 1.7 的源码里,ConcurrentHashMap 的结构像是一个二级哈希表。 第一层是 Segment 数组(默认 16 个),第二层是每个 Segment 里维护的 HashEntry 数组。

这就好比银行有 16 个窗口办业务。 如果是 Hashtable(那个古老的同步 Map),它是给整个银行大门上了一把大锁,同一时间只能有一个人进去办业务,效率极低。 而 Segment 的做法是:给这 16 个窗口每个都配一把锁。 当线程 A要去第 1 号窗口(Segment[0])存钱,它只需要获取 Segment[0] 的锁。 此时,线程 B 如果要去第 2 号窗口(Segment[1])取钱,它获取的是 Segment[1] 的锁。 这两把锁是独立的!所以这两个线程可以并行执行,互不干扰。

只有当多个线程同时都要去第 1 号窗口时,才会发生锁竞争。 因为默认有 16 个 Segment,所以理论上它支持 16 个线程的并发写,这个数字也就是构造函数里的 concurrencyLevel

但这种设计也有缺点,就是查找和遍历稍微麻烦点。比如要执行 size() 方法统计总数时,为了保证准确性,可能需要先把所有 Segment 的锁都加上(虽然它做了一些无锁尝试的优化),这在某些场景下开销还是挺大的。这也是为什么 1.8 把它优化掉了的原因之一。


Java和Kotlin的主要区别是什么?

嗯,作为一个从 Java 转到 Kotlin 的 Android 开发者,这个问题我感触还挺深的。虽然它们都运行在 JVM 上,最终编译成 .class 文件或者 Dex 字节码,但写起来的感觉完全不一样。

我觉得最本质的区别在于设计理念:Java 更加严谨、繁琐,强调样板代码(Boilerplate);而 Kotlin 更现代、简洁,强调生产力(Productivity)和安全性。

具体来说,有这么几个核心点:

第一,空安全(Null Safety)。 这是 Java 最大的痛点之一,NullPointerException 简直是噩梦。 Java 里任何对象都可能是 null,导致我们代码里到处都是 if (obj != null)。 而 Kotlin 在类型系统层面就把这个问题解决了。它把类型分成了可空(Nullable,比如 String?)和不可空(Non-Nullable,比如 String)。 如果你把一个 null 赋值给不可空变量,编译直接报错,根本跑不起来。调用的时候也不用每次都判空,直接用 ?. 或者 !! 操作符就行,代码一下子干净多了。

第二,简洁性(Conciseness)。 Java 里我们要写个 POJO,得写一堆 Getter/Setter、toStringequalshashCode。 Kotlin 直接一个 data class 搞定,一行代码顶 Java 一百行。 还有类型推断(Type Inference),不用像 Java 那样每次都写 String str = ...,直接 val str = ... 完事。 还有 扩展函数(Extension Functions),我们可以给 StringView 加方法,不用再去写一堆 StringUtilsViewUtils 工具类了,调用起来像原生方法一样自然。

第三,函数式编程支持。 虽然 Java 8 引入了 Lambda,但 Kotlin 对函数式的支持是一等公民(First-class citizen)。 它原生支持高阶函数、Lambda 表达式作为参数传递、内联函数(inline)等。 这在处理集合操作(Collection Operations)或者写回调(Callback)的时候特别爽,代码量能减少一半以上。

第四,协程(Coroutines)。 这是 Kotlin 的大杀器。 Java 处理并发通常是用线程池或者 RxJava。线程是操作系统级别的资源,开销大,切换成本高。 Kotlin 的协程是用户态的轻量级线程。它可以用同步的方式写异步代码。 比如网络请求,不用再写回调地狱(Callback Hell)了,直接 suspend 函数挂起,拿结果,就像写单线程代码一样简单,底层却是非阻塞的。这对 Android 开发来说简直是神器,大大降低了并发编程的门槛。

总结一下:Kotlin 解决了 Java 的空指针痛点,极大地减少了样板代码,通过扩展函数和协程提升了开发效率和代码可读性。 它是 Android 官方推荐的语言,也是未来的趋势。🚀


Kotlin语法中有哪些特性?

嗯,Kotlin 的语法糖非常多,而且都很实用。我说几个我在项目里最常用的特性吧。

首先是 扩展函数(Extension Functions)。 这个我刚才提到了,真的非常好用。 比如我在开发中经常要 dp 转 px,如果是 Java,得写个 DensityUtils.dp2px(context, 10)。 在 Kotlin 里,我可以给 Int 或者 Float 类直接扩展一个属性或方法:

Kotlin
val Int.dp: Int
    get() = (this * Resources.getSystem().displayMetrics.density).toInt()

然后代码里直接写 10.dp 就行了,语义非常清晰,完全不需要侵入源码。

其次是 空安全操作符。 除了 ?. 安全调用,还有一个 Elvis 操作符(?:) 很有用。 比如 val len = str?.length ?: 0。 意思就是如果 str 是 null,那就取冒号后面的默认值 0。这比 Java 的三元运算符或者 if-else 简洁太多了。

还有 标准库函数(Scope Functions)。 像 letrunapplyalsowith 这几个。 我们在初始化一个 View 或者构建一个对象的时候,经常用到 apply

Kotlin
val textView = TextView(context).apply {
    text = "Hello"
    textSize = 14f
    setTextColor(Color.BLACK)
}

在一个闭包里直接操作对象的属性,不需要重复写 textView.xxx,代码结构非常紧凑。

另外就是 属性代理(Delegated Properties)。 最经典的就是 by lazyval heavyObject by lazy { ... } 这实现了懒加载,只有在第一次用到这个变量的时候才会去执行大括号里的初始化代码,并且是线程安全的。 还有 Observable 代理,可以监听属性值的变化,用来做数据绑定也很方便。

最后不得不提 协程(Coroutines) 的语法支持。 suspend 关键字。 它标识一个函数是挂起函数,只能在协程或者另一个挂起函数里调用。它能暂停执行而不阻塞线程,等耗时任务(比如网络 IO)做完了自动恢复执行。 配合 lifecycleScope 或者 viewModelScope,管理协程的生命周期也非常容易,不用担心内存泄漏。

其实还有很多,比如 Data Class 自动生成方法、Sealed Class(密封类)用来做状态管理(代替枚举)、Object 关键字实现单例等等。这些特性组合起来,让 Kotlin 写起来真的很有“现代语言”的感觉。✨


什么叫做高阶函数?

嗯,高阶函数(Higher-Order Function) 其实定义很简单,满足下面两个条件之一的函数就是高阶函数:

  1. 参数里包含函数类型
  2. 返回值是一个函数类型

通俗点说,就是把函数当作参数传进去,或者把函数当作返回值拿出来。 在 Java 里,我们需要定义一个接口(Interface),比如 OnClickListener,然后传一个匿名内部类进去。 但在 Kotlin 里,函数是一等公民,它也是一种类型,可以直接传递。

举个最常见的例子,比如我们用的 Listfilter 方法:

Kotlin
val list = listOf(1, 2, 3, 4)
val result = list.filter { it > 2 }

这里的 filter 就是一个高阶函数。它的定义大概是这样的:

Kotlin
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T>

你看它的参数 predicate,类型是 (T) -> Boolean。这就是一个函数类型,表示它接收一个 T 类型的参数,返回一个 Boolean。 我们在调用的时候,可以直接传一个 Lambda 表达式 { it > 2 } 进去。

再比如我们封装网络请求的时候,经常会定义这样的高阶函数:

Kotlin
fun requestData(onSuccess: (String) -> Unit, onError: (Exception) -> Unit) {
    // ... 网络请求逻辑
    if (success) {
        onSuccess("Data") // 直接调用传进来的函数
    } else {
        onError(Exception("Error"))
    }
}

这就省去了定义 Callback 接口的麻烦。

高阶函数还有一个很重要的概念是 inline(内联)。 因为在 JVM 上,Lambda 表达式本质上会被编译成匿名内部类对象,如果高阶函数调用太频繁(比如在循环里),会产生大量的临时对象,增加 GC 压力。 所以 Kotlin 提供了 inline 关键字。编译器会在编译的时候,把高阶函数的代码传进去的 Lambda 代码直接复制粘贴到调用处。这样就避免了对象创建的开销,性能和手写循环是一样的。

总结一下:高阶函数就是把“逻辑”当作“数据”来传递,极大地提高了代码的抽象能力和复用性,配合 Lambda 和 inline,既优雅又高效。 🧩


什么叫做密封类?重写hashCode和equals方法的作用是什么?什么情况下需要重写?hashCode和equals的关系?

好,这几个问题既有 Kotlin/Java 新特性,又是 Java 基础中的基础,在 Android 开发里其实非常实用。

首先说 密封类(Sealed Classes)

这个概念在 Kotlin 中非常常用,现在 Java 15/17 也引入了类似的 sealed 关键字。

简单来说,密封类就是一种受限的类继承结构。 我们定义一个 sealed class,就意味着我们明确知道了这个类有哪些子类,并且只能有这些子类。这些子类必须定义在同一个文件里(Kotlin 1.1 之前是内部类,现在可以是同文件顶级类)。

它有什么用呢? 它最大的作用就是和 when 表达式(或者 Java 的 switch)配合使用。 比如我在做 Android 的 MVVM 架构时,经常用 sealed class 来定义 UI 状态(UiState)

Kotlin
sealed class UiState {
    object Loading : UiState()
    data class Success(val data: User) : UiState()
    data class Error(val message: String) : UiState()
}

当我在 ViewModel 或者 Activity 里用 when(state) 去判断时,编译器会强制要求我覆盖所有的情况(Loading、Success、Error)。因为编译器知道所有的子类也就这几种,不需要写那个多余的 else 分支。这大大增加了代码的安全性和可读性。

其实,你可以把它理解为枚举(Enum)的威力加强版。枚举的每个实例是单例对象,而密封类的子类可以持有状态(比如 Success 里面带着 data),每个实例都可以不同。


接下来说说 hashCode 和 equals。这俩简直是“焦不离孟”的好基友。

重写它们的作用是什么?

  • equals():它的作用是定义“逻辑相等”。默认情况下,Object 的 equals 比的是内存地址(引用相等)。但在业务中,我们通常认为,如果两个 User 对象的 id 一样,那它们就是同一个人,哪怕它们是内存里 new 出来的两个不同对象。这时候就必须重写 equals
  • hashCode():它的作用是计算对象的哈希码,主要是为了配合 Hash 表结构的集合(如 HashMap, HashSet, Hashtable)来使用的。这些集合靠 hash code 快速定位数据在数组中的索引位置。

什么情况下需要重写?

结论是:只要你重写了 equals,就必须重写 hashCode。 如果你不重写,当你把这个对象当作 Key 放到 HashMap 或者放到 HashSet 里去重的时候,就会出大问题。哪怕两个对象逻辑上一样(equals 为 true),因为 hashCode 不同,Map 会觉得它们是两个完全不相干的 Key,导致你存进去的东西取不出来,或者本该去重的没去重。

比如在 Android 开发中,我们经常用 DiffUtil 来计算 RecyclerView 的数据变化,里面就需要正确实现 equals 来判断 Item 是否改变。如果我们用 data class(Kotlin),编译器会自动帮我们生成这俩方法,特别省心。


最后是那个经典的逻辑题:hashCode 和 equals 的关系

这个直接记结论,背后的逻辑也很简单:

  1. equals 相同,hashCode 一定相同吗? 一定相同!(前提是你规范地重写了)。 这也是 Java 的硬性规定(Contract)。因为如果两个对象被认为是“相等”的,那它们在 HashMap 里就应该待在同一个坑位(Bucket)里,或者至少能被找到。如果 equals 相同但 hash 不同,那 HashMap 先根据 hash 去找位置,直接就找错地方了,根本没机会去调 equals 比较,这就不合理了。

  2. hashCode 相同,equals 一定相同吗? 不一定! 这就是所谓的 哈希冲突(Hash Collision)。 Hash 算法是将无限的输入映射到有限的整数范围内,必然会有概率撞车。 比如 “Aa”“BB” 这两个字符串,它们的 hashCode 恰好都是 2112。 这时候 HashMap 会把它们放到同一个数组下标对应的链表(或红黑树)里。当我们去取值时,系统发现 hash 一样,会进一步去调用 equals 方法来区分到底是谁。所以 hash 只是第一层筛选,equals 才是最终裁判。

总结一下

  • 密封类是带数据的枚举,专治状态管理,配合 when 简直完美。
  • equals 定义逻辑相等,hashCode 为 Hash 集合服务。
  • equals 相同则 hash 必同hash 相同 equals 未必同(那是哈希冲突)。

HashMap底层比较两个对象时,是先调用hashCode还是equals?

嗯,这个问题非常经典,结论非常明确:肯定是先调用 hashCode(),如果 hash 值一样,才会去调用 equals()

这背后的逻辑其实是性能优化。 你想啊,hashCode() 返回的是一个 int 整数,比较两个整数非常快,CPU 指令咔咔几下就搞定了。 但是 equals() 方法往往比较复杂,可能要对比对象里的好几个字段(比如 String 的 equals 要逐个字符比),开销大得多。

所以 HashMap 的设计思想是:“先快速排除异己,再仔细确认同类”。 绝大多数时候,两个对象的 hash 值是不一样的。如果 hash 值都不一样,那它们绝对不可能是同一个对象(根据契约),这时候就根本没必要浪费时间去调 equals() 了。 只有当 hash 值“撞车”了(也就是 Hash Collision),或者 hash 值正好相等时,HashMap 才会抱着“难道真的是同一个 key?”的怀疑态度,去调用 equals() 做最终确认。

这也是为什么 Java 官方一直强调:重写了 equals() 必须重写 hashCode()。如果不重写,两个逻辑上相等的对象(equals 返回 true)可能生成了不同的 hash 值,导致 HashMap 误以为它们是不同的 Key,存了两份进去,这就出 Bug 了。


HashMap存储对象时,如何判断两个对象是否重复?

这个问题其实就是上一题的延伸,也是 HashMap 核心机制所在。

结论是:HashMap 认为两个对象重复(Key 相同),必须同时满足两个条件:

  1. Hash 值相同:通过 hashCode() 计算出的 hash 值经过扰动函数处理后,定位到的桶下标(Bucket Index)要一致(其实严格来说是 hash 值本身要相等)。
  2. 内容相等:在同一个桶里,要么引用地址相同(==),要么 equals() 返回 true。

具体的判断流程,我看过 JDK 1.8 的源码,在 putVal 方法里写得非常清楚,大概逻辑是这样的:

第一步,计算 Key 的 hash 值:hash = (h = key.hashCode()) ^ (h >>> 16)。这里做异或运算是为了让高位也参与运算,减少碰撞。

第二步,根据 hash 值找到数组下标 i。 如果 table[i] 是空的(null),那就说明根本没有重复,直接创建一个新 Node 放进去。

第三步,关键来了,如果 table[i] 已经有东西了(发生了碰撞),HashMap 会遍历这个位置上的链表(或红黑树)。 在遍历每一个节点 p 时,它会执行这样一个判断语句(伪代码):

Java
if (p.hash == hash && 
   ((p.key == key) || (key != null && key.equals(p.key)))) {
    // 找到了重复的 Key!
    // 执行覆盖操作(Updating Value)
}

你看这个条件: 首先快速比较 p.hash == hash。如果 hash 值都不一样,短路与(&&)直接跳过,后面那个复杂的 equals 根本不会执行。 只有 hash 相等了,才会走后面的 (p.key == key) || key.equals(p.key)。这里先比引用地址(==),如果是同一个对象实例,那肯定相等,效率最高;如果你传的是两个不同的 String 实例但内容一样,那最后才会调 equals

所以总结一下,判断重复就是一套组合拳先 hash 过滤,再引用判断,最后 equals 兜底。这套机制既保证了数据的唯一性,又把性能损耗降到了最低。


安卓中四大组件是什么?

嗯,Android 的四大组件,这个是入门必问的,也是整个 Android 系统的基石。它们分别是:Activity(活动)、Service(服务)、BroadcastReceiver(广播接收器)和 ContentProvider(内容提供者)。

这四个组件虽然功能各异,但它们都是应用组件(Application Component),都需要在 AndroidManifest.xml 里注册(除了动态注册的广播),并且都由系统(主要是 AMS,ActivityManagerService)统一管理生命周期。

简单展开说一下:

第一是 Activity。 它是最直观的,负责UI展示和用户交互。 我们在手机屏幕上看到的每一个界面,基本上对应一个 Activity。它有复杂的生命周期(onCreateonStartonResume 等),用来管理界面的创建、显示、暂停和销毁。也是应用逻辑的主要入口。

第二是 Service。 它是后台运行的任务。 它没有界面,通常用来处理耗时操作,比如后台播放音乐、下载文件、或者与服务器保持长连接。Service 分为两种启动方式:

  • startService:启动后自行运行,与启动者无关。
  • bindService:与启动者绑定,提供 IPC(进程间通信)接口,随着绑定者的销毁而销毁。

第三是 BroadcastReceiver。 它是消息传递机制。 用来接收系统或者应用发出的广播消息。比如电池电量低、网络状态变化、开机启动等。它就像一个收音机,注册了频道就能收到消息。它的生命周期非常短,通常在 onReceive 方法里处理完逻辑就结束了,不能做耗时操作。

第四是 ContentProvider。 它是跨进程数据共享的标准接口。 Android 系统为了安全,应用间的数据是隔离的。如果我想访问系统的通讯录、短信、相册,或者让别的应用访问我的数据库,就需要用到 ContentProvider。它底层基于 Binder 机制,提供了一套增删改查(CRUD)的方法,统一管理数据访问权限。

所以总结一下:Activity 负责看脸(UI),Service 负责干活(后台),BroadcastReceiver 负责喊话(消息),ContentProvider 负责递东西(数据)。 它们共同构成了 Android 应用的骨架。🏢


Activity和Fragment的区别是什么?

嗯,Activity 和 Fragment 的区别,我觉得可以从定位、生命周期、灵活性这三个维度来看。

首先是 定位(Role)

  • Activity 是系统的四大组件之一,是应用层级的界面容器。它由系统 AMS 直接管理,拥有独立的 WindowContext,是应用交互的基本单元
  • Fragment 是 Activity 的一部分,它是UI 片段,也可以叫“子界面”。它必须依附于 Activity 存在,不能独立运行。它由 Activity 内部的 FragmentManager 管理,而不是 AMS。

其次是 生命周期(Lifecycle)

  • Activity 的生命周期是由系统回调驱动的(onCreate -> onDestroy),相对标准。
  • Fragment 的生命周期则更加复杂,它不仅受宿主 Activity 生命周期的影响(Activity onPause,Fragment 也会 onPause),还有自己特有的回调,比如 onAttach(关联 Activity)、onCreateView(创建视图)、onActivityCreated(Activity 创建完成)、onDestroyView(视图销毁)、onDetach(解绑)。
  • 这就导致 Fragment 的状态管理(比如屏幕旋转、内存重启)比 Activity 更容易出 Bug,特别是 IllegalStateException(比如 Can not perform this action after onSaveInstanceState)。

最后是 灵活性(Flexibility)

  • Activity 切换比较重,涉及到 AMS 的跨进程通信(IPC),开销大,界面切换有明显的过渡感。
  • Fragment 切换非常轻量,本质上只是 View 的替换和显示隐藏,都在同一个 Activity 内部进行。所以它特别适合做模块化 UI,比如底部导航栏切换 Tab,或者平板上的“左列表右详情”布局。
  • 现在 Google 推崇的 SingleActivity 架构(比如 Jetpack Navigation),就是利用 Fragment 的灵活性,一个 Activity 包含多个 Fragment,通过 Fragment 的回退栈来管理页面流转,性能更好,动画更流畅。

总结一下:Activity 是重量级的系统组件,Fragment 是轻量级的 UI 模块。Activity 是根,Fragment 是叶子。Activity 负责全局统筹,Fragment 负责局部展示。 🌱


Activity和Fragment的关系是一对一还是一对多?

嗯,Activity 和 Fragment 的关系,肯定是一对多(1:N) 的。这也是 Fragment 设计的初衷。

虽然最简单的情况是一个 Activity 里面只放一个 Fragment(比如很多简单的详情页),但这只是“一对一”的特例。从架构设计的角度看,Fragment 存在的意义就是为了实现 UI 的复用和灵活组合

具体体现在两个方面:

第一,同一个 Activity 可以包含多个 Fragment。 最典型的场景就是底部导航栏(BottomNavigationView)。 主页通常是一个 MainActivity,然后底部有 4-5 个 Tab,每个 Tab 对应一个 Fragment(比如 HomeFragmentDiscoveryFragmentMeFragment)。 用户点击 Tab 的时候,Activity 会通过 FragmentManagerbeginTransaction().replace() 或者 show()/hide() 来切换显示不同的 Fragment。这时候就是一个 Activity 对应多个 Fragment 实例。 还有平板的大屏适配,左边是列表 Fragment,右边是详情 Fragment,这也是典型的一对多。

第二,同一个 Fragment 可以被多个 Activity 复用。 比如一个通用的“用户登录 Fragment”或者“图片选择 Fragment”。 我不希望在 A Activity 里写一遍登录逻辑,在 B Activity 里又写一遍。我可以把它封装成一个独立的 Fragment,然后在 A 和 B 里面分别加载它。 虽然运行时 Fragment 实例是不同的,但从代码逻辑上讲,它是被多个 Activity 共享的组件。

但是,这里要注意一个,也就是Fragment 和 Activity 通信的问题。 因为是一对多的关系,Fragment 不能直接强转 getActivity() 为某个具体的 Activity 类(比如 (MainActivity) getActivity()),否则复用到其他 Activity 时会崩。 正确的做法是定义一个 Interface 接口,让宿主 Activity 实现这个接口。Fragment 在 onAttach 的时候把 Activity 强转为这个接口类型。这样 Fragment 就解耦了,不管宿主是谁,只要实现了接口就能通信。或者现在更推荐用 ViewModel + LiveData 来做通信,完全解耦。

所以结论是:设计上绝对是一对多。Activity 是宿主容器,Fragment 是可插拔的内容模块。通过一对多的组合,我们可以构建出丰富灵活的界面架构。 🧩


两个Fragment之间如何传数据?Handler了解吗?一个子线程有几个Looper、几个MessageQueue、几个Message?

好的,这几个问题可以说是 Android 开发中的灵魂三问 了,特别是 Fragment 通信和 Handler 机制,简直是日常开发和 Framework 源码阅读的必修课。

先说 两个 Fragment 之间如何传数据

其实方法挺多的,但随着 Jetpack 的普及,推荐的方式也在变。

  1. 最经典的方式:Bundle 这是最原始也是最稳妥的。比如在创建目标 Fragment 时,通过 setArguments(Bundle) 传递数据,目标 Fragment 在 onCreate 或者 onViewCreated 里通过 getArguments() 拿出来。

    • 优点:系统原生支持,Activity 重建时数据会自动恢复。
    • 缺点:只能传序列化好的数据(Parcelable/Serializable),而且写起来比较繁琐。
  2. 现代化的方式:ViewModel + LiveData / Flow (SharedViewModel) 这是 Google 官方推荐 的 MVVM 架构下的通信方式。

    • 让两个 Fragment 共享宿主 Activity 的 ViewModel(使用 activityViewModels() 委托)。
    • Fragment A 改一下 ViewModel 里的数据,Fragment B 观察这个数据,立马就能收到通知。
    • 优点:解耦、实时性好、支持复杂对象,不用像 Bundle 那样序列化。
  3. Fragment Result API 这是 FragmentManager 提供的新 API(setFragmentResultsetFragmentResultListener)。

    • 它专门解决 Fragment 之间(特别是父子 Fragment 或者同级 Fragment)一次性传递结果的场景。
    • 比如弹个 DialogFragment 选个日期,选完传回来,用这个最方便。

当然,还有像 EventBus 或者接口回调这种老生常谈的方式,但在现代 Android 开发里,Shared ViewModel 绝对是首选。


接下来说说 Handler 机制

嗯,这个我太熟了!Handler 是 Android 消息机制的核心,我在看 Framework 源码的时候,发现系统服务(比如 ActivityManagerService 里的 MainHandler)到处都在用它。

简单来说,Handler 就是用来在不同线程之间传递消息的桥梁,最常见的就是在子线程做耗时操作,然后通过 Handler 把结果发回主线程更新 UI。

它的工作流程就像一个 流水线

  1. Handler 把消息(Message)发送(sendMessage)到 MessageQueue 里。
  2. Looper 就像一个永不停歇的传送带引擎,它一直在死循环(loop() 方法)里从 MessageQueue 里取消息。
  3. 一旦取到消息,Looper 就会把消息派发(dispatchMessage)回 HandlerhandleMessage 方法去执行。

最后是那个经典的数字题:一个子线程有几个 Looper、几个 MessageQueue、几个 Message?

这个必须基于源码(Looper.javaMessageQueue.java)来回答,非常严谨:

  1. Looper:1 个

    • 一个线程只能有一个 Looper。
    • 我们在代码里调用 Looper.prepare() 时,它会通过 ThreadLocal 把 Looper 对象绑定到当前线程。如果再次调用 prepare(),源码里会直接抛异常 RuntimeException("Only one Looper may be created per thread")
  2. MessageQueue:1 个

    • Looper 在构造函数里直接 new 了一个 MessageQueue
    • 既然 Looper 只有一个,那它持有的 MessageQueue 自然也只有一个。它们是一一对应的关系。
  3. Message:N 个(无数个)

    • Message 是消息的载体,可以是 0 个,也可以是成千上万个。
    • 它们以 链表 的形式存放在 MessageQueue 里(按执行时间 when 排序)。
    • 顺便提一句,我们在代码里获取 Message 时,最好用 Message.obtain(),而不是直接 new。因为系统内部维护了一个 Message 缓冲池(sPool),复用对象可以避免频繁创建销毁带来的内存抖动(Memory Churn)。

总结一下线程 : Looper : MessageQueue : Message 的关系是 1 : 1 : 1 : N

这就是 Android 消息驱动模型的基石。


什么叫做同步消息和异步消息?

嗯,这个问题如果是问网络请求或者 IO,那通常是在讲阻塞和非阻塞。但在 Android 的 Handler 机制里,这个概念其实非常具体,而且直接关系到 UI 的流畅度。

结论是:在 Android 的 MessageQueue 中,消息默认都是同步的。所谓的“异步消息”,是指被标记了 FLAG_ASYNCHRONOUS 的 Message。它的特殊之处在于,当 Looper 遇到“同步屏障”(Sync Barrier)时,会优先处理这些异步消息,而忽略掉所有的同步消息。

这其实是系统为了保证高优先级任务(比如 UI 绘制)能及时执行的一种插队机制。

我们平时通过 handler.sendMessage() 发出去的消息,全都是同步消息。它们就像老实排队的普通乘客,严格按照时间顺序(when 字段)被 Looper 取出来执行。

但是,系统有时候会有十万火急的事情,比如 VSync 信号来了,需要立马进行 View 的 Measure/Layout/Draw。 这时候,Choreographer 会往 MessageQueue 里插入一个特殊的标记,叫同步屏障(通过 postSyncBarrier 方法)。这个屏障就像一个“暂停营业”的牌子,挡在队列最前面。 Looper 在循环取消息的时候(next() 方法),一旦看到这个屏障,就会跳过所有普通的同步消息,专门去队列里找那些标记了“异步”的消息(isAsynchronous() 为 true)。

我们在做 UI 优化或者自定义 View 动画的时候,如果想让某个消息尽快执行,不受主线程其他杂事的影响,也可以调用 Message.setAsynchronous(true) 把它变成异步消息。但这属于黑科技了,平时开发很少直接用到,更多是 Framework 层在用。

所以总结一下:同步消息是按部就班的普通公民,异步消息是持有“通行证”的特权公民,而同步屏障就是那个查证的关卡。


安卓中内存泄漏有哪些常见的场景?

嗯,内存泄漏(Memory Leak)绝对是 Android 开发中最头疼但也必须攻克的问题。我在实习和做开源项目的时候,为了抓泄漏没少折腾 LeakCanaryMAT

结论是:Android 内存泄漏的核心原因,通常是长生命周期的对象(比如单例、静态变量、后台线程)持有了短生命周期对象(主要是 Activity/Fragment/View)的强引用,导致后者在销毁时无法被 GC 回收。

我总结了几个最常见、也是面试最爱问的场景:

  1. Handler/Runnable 引起的泄漏(这是重灾区): 我们在 Activity 里直接 new Handler() 或者使用匿名内部类 Runnable。因为非静态内部类会隐式持有外部类(Activity)的引用。 如果发了一个延迟消息 postDelayed(Runnable, 10000),在 10s 内 Activity 关闭了,但 Message还在 Queue 里,Message 持有 Handler,Handler 持有 Activity,这条引用链就会导致 Activity 无法回收。 解决办法: 使用静态内部类 + WeakReference,或者在 onDestroy() 里调用 removeCallbacksAndMessages(null)

  2. 单例模式(Singleton)持有 Context: 有些工具类写成单例,构造函数里传了个 Context。如果调用方传的是 Activity 的 Context,那这个 Activity 就永远回不来了。 解决办法: 尽量传 ApplicationContext,因为它本身就是全局存活的。

  3. 静态变量(Static)导致泄漏: 为了方便访问,有时候会把一个 View 或者 Activity 赋值给一个 public static 变量。这简直是灾难,因为 Static 变量是在 ClassLoader 加载类时创建的,生命周期跟 App 一样长。 还有一种隐蔽的情况是 单例的 Listener。比如注册了一个 EventBus 或者系统服务的监听器,如果在页面销毁时忘了 unregister,那这个 Activity 也会被单例一直拉着。

  4. 资源未关闭: 虽然现在 IO流、Cursor 这些有 try-with-resources 好很多了,但像 Bitmap 如果用了 recycle() 虽然不一定能立即释放 native 内存(那是另一回事),但如果大对象一直被引用也很伤。 还有就是 WebView,这玩意儿是出了名的难伺候。它内部会持有 Activity 引用,甚至还会有独立的进程问题。通常做法是在销毁时先把 WebView 从父容器 remove 掉,再 destroy(),甚至直接开个独立进程跑 WebView。

  5. 属性动画(ValueAnimator): 如果做了一个无限循环的动画,页面退出了没 cancel,动画持有了 View,View 持有了 Activity,那也是泄漏。

其实判断内存泄漏最简单的逻辑就是:当你觉得一个对象该死了(onDestroy),但它还活着(Heap Dump里有),那就是泄漏。 🕵️‍♂️


非静态内部类和静态内部类的区别?

嗯,这个问题在 Android 开发里特别重要,因为如果用不好非静态内部类,内存泄漏分分钟找上门。简单来说,它们的区别主要体现在持有引用实例化方式上。

首先是 非静态内部类(Non-static Inner Class)。 最核心的特点是:它默认持有外部类的引用(Implicit Reference)。 这就意味着,它能直接访问外部类的所有成员变量和方法,包括私有的。 比如我们在 Activity 里写一个 Handler 或者 AsyncTask,如果没有加 static 关键字,它就是一个非静态内部类。 坑就在这里:如果这个内部类的生命周期比外部类长(比如 Handler 发了一个延时消息,或者 AsyncTask 在后台跑网络请求),当 Activity 销毁时,因为它被内部类持有引用,导致 Activity 无法被 GC 回收,这就是经典的内存泄漏。 所以我们通常建议把 Handler 定义成 static 的,然后通过 WeakReference(弱引用)来持有 Activity,这样 Activity 该死的时候就能死掉。

其次是 静态内部类(Static Nested Class)。 它就像是一个独立的类,只是为了代码组织方便,寄生在外部类里面而已。 它不持有外部类的引用。 所以它不能直接访问外部类的非静态成员,只能访问外部类的静态成员。 它的实例化不需要依赖外部类对象,直接 new Outer.Inner() 就行。 在 Android 里,比如 RecyclerView.ViewHolderFragment 的子类,或者是单例模式里的 SingletonHolder,都必须是静态内部类,否则系统反射创建的时候会报错(因为它没法隐式传外部类引用进去)。

总结一下:非静态内部类是“寄生虫”,离不开宿主,容易导致宿主死不掉;静态内部类是“合租室友”,虽然住在同一个房子里(类文件),但各过各的,互不干扰。 🏠


Java的GC垃圾回收算法有哪些?

嗯,Java 的 GC 算法其实经过了很多年的演进,主要有这么几种经典的思路,针对不同的内存区域(新生代、老年代)有不同的策略。

第一种是 标记-清除算法(Mark-Sweep)。 这是最基础的。 分为两个阶段:

  1. 标记:从 GC Roots 开始遍历,把所有引用的对象打上标记。
  2. 清除:遍历整个堆,把没有标记的对象直接回收。 缺点很明显:
  • 效率低:标记和清除两个过程都要遍历,尤其是堆很大的时候。
  • 内存碎片:回收后的内存是不连续的,像狗啃的一样。如果有大对象(比如 bitmap)要分配,虽然总空间够,但没有足够大的连续空间,就会提前触发 Full GC。

第二种是 复制算法(Copying)。 这是为了解决碎片问题搞出来的,主要用于 新生代(Young Generation)。 它把内存分为两块(比如 Eden 区和 Survivor 区),每次只用其中一块。 当 GC 的时候,把存活的对象复制到另一块空闲的内存里,然后把当前这块直接清空。 优点是简单高效,没有碎片。 缺点是内存利用率低,因为总有一半内存是闲置的。不过现在的 JVM(比如 HotSpot)把新生代分成了 Eden:S0:S1 = 8:1:1,每次只有 10% 的 Survivor 区是浪费的,非常划算。

第三种是 标记-整理算法(Mark-Compact)。 这是针对 老年代(Old Generation) 的。 因为老年代的对象存活率高,复制算法如果要复制很多对象,效率会很低,而且还得有额外的空间做担保。 所以它在标记完之后,不是直接清除,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。 这样既没有碎片,又不需要浪费一半空间,但移动对象本身也是个耗时的操作(Stop The World 时间会变长)。

第四种是 分代收集算法(Generational Collection)。 这其实不是一种具体的算法,而是一种策略。 现在的 JVM 基本上都用这个。 核心思想是:不同生命周期的对象采用不同的算法。

  • 新生代:对象朝生夕死(比如局部变量、临时对象),存活率低,适合用 复制算法
  • 老年代:对象存活久(比如缓存、Spring Bean),没额外空间担保,适合用 标记-清除 或者 标记-整理

总结一下:复制算法像“搬家”,把有用的搬走,剩下的房子拆了;标记-清除像“打扫卫生”,把垃圾扔了,但家里乱七八糟;标记-整理像“整理房间”,把东西整齐码好,腾出大片空地。分代收集就是看人下菜碟,怎么划算怎么来。 🧹


如何判断一个对象是垃圾对象?手撕算法:最长递增子序列

这两个问题一个考察 JVM/ART 原理,一个考察 动态规划 的基本功,都是大厂面试的必考题。

先说 如何判断对象是垃圾

其实历史上主要有两种算法:引用计数法可达性分析法

但在现在的 Android(ART 虚拟机)和 Java 中,我们绝对不用引用计数法。为啥呢?因为它解决不了 循环引用 的问题。比如 A 对象引用 B,B 又引用 A,如果不从外部断开,它俩的计数器永远是 1,永远回收不掉,这就导致内存泄漏了。

所以,现在的标准答案是 可达性分析算法(Reachability Analysis)

它的核心思想就是:从一系列被称为 GC Roots 的根节点对象出发,向下搜索。凡是能被搜索到的对象(也就是在引用链上的),都是存活的;凡是搜索不到的,就是垃圾,可以被回收。

那面试官肯定会接着问,谁能当 GC Roots? 我在看虚拟机规范的时候总结过,主要有这么几类:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如你在方法里 new 了一个 ArrayList,这个局部变量就是 GC Root。
  2. 方法区中类静态属性引用的对象:比如 static 的单例对象。
  3. 方法区中常量引用的对象:比如 final 类型的字符串常量。
  4. 本地方法栈中 JNI(Native 方法)引用的对象
  5. 被同步锁(synchronized)持有的对象

所以,只要断开了 GC Root 到某个对象的引用链,这个对象在下一次 GC 时大概率就会被带走了。


接下来是 手撕算法:最长递增子序列(LIS)

这是一道非常经典的 动态规划(DP) 题目(LeetCode 300)。

思路如下: 我们要维护一个 dp 数组。 定义 dp[i] 为:以第 i 个数字结尾的最长递增子序列的长度

注意,这里必须是以 nums[i] 结尾,这样才能保证后续的递推关系。 对于每一个 i,我们回头看前面的所有 j00i1i-1):

  • 如果 nums[i] > nums[j],说明 nums[i] 可以接在 nums[j] 后面形成一个新的递增子序列。
  • 那么状态转移方程就是:dp[i] = max(dp[i], dp[j] + 1)

最后,扫描整个 dp 数组,最大的那个值就是我们要的结果。

时间复杂度是 O(n2)O(n^2)。这也是面试时最稳妥的写法。如果要优化到 O(nlogn)O(n \log n),可以用 贪心 + 二分查找(维护一个 tail 数组),不过那个容易写错,我先写个最通用的 DP 版本给你看:

Java
public class Solution {
    // 给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
    public int lengthOfLIS(int[] nums) {
        // 边界条件判断,虽然题目通常给非空,但在 Android 开发里判空是好习惯
        if (nums == null || nums.length == 0) {
            return 0;
        }
 
        int n = nums.length;
        // dp[i] 表示以 nums[i] 结尾的最长递增子序列长度
        int[] dp = new int[n];
        
        // 初始化:每个元素本身至少可以构成长度为 1 的子序列
        Arrays.fill(dp, 1);
 
        int maxLen = 1;
 
        // 外层循环遍历每个位置
        for (int i = 1; i < n; i++) {
            // 内层循环遍历 i 前面的所有位置 j
            for (int j = 0; j < i; j++) {
                // 如果发现前面的数比当前的数小,说明可以接龙
                if (nums[j] < nums[i]) {
                    // 状态转移:尝试更新 dp[i]
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            // 每次算出 dp[i] 后,更新全局最大值
            maxLen = Math.max(maxLen, dp[i]);
        }
 
        return maxLen;
    }
}

代码里我加了注释,核心就是那个双重循环。 如果是校招面试,能写出这个 O(n2)O(n^2) 的版本基本就过了。如果面试官追问优化,可以提一下那个 O(nlogn)O(n \log n) 的贪心解法,表示自己有深度。😎


腾讯客户端 安卓二面


进程和线程的区别是什么?

嗯,这个问题其实是操作系统的基础,但在 Android 开发里理解它们非常关键,毕竟我们做性能优化或者看 Framework 源码的时候经常会碰到。

首先说结论:进程是操作系统资源分配的最小单位,而线程是 CPU 调度的最小单位。

具体展开来说,我觉得可以从资源隔离通信成本稳定性这几个方面来看。

第一是资源隔离。 在 Android 里,或者说在 Linux 内核里,一个应用启动(比如通过 Zygote fork 出来)就是一个进程。每个进程都有自己独立的虚拟地址空间,大概是 4GB(32位下),这意味着进程间的内存是互相看不到的。 而线程呢,它是依附于进程存在的。同一个进程里的多个线程,它们是共享这个进程的堆内存(Heap)和方法区的,但是每个线程会有自己独立的程序计数器(PC)和栈空间(Stack)。 这就好比,进程是一栋独立的房子,拥有独立的门牌号和资源;而线程是住在房子里的人,大家共用厨房和客厅(堆内存),但每个人有自己的房间(栈)。

第二是切换和通信成本。 因为进程间地址空间是隔离的,所以进程切换(Context Switch)的时候,CPU 需要保存和恢复的上下文非常多,还要刷新 TLB(页表缓存),开销很大。这也是为什么 Android 要设计 Binder 这种高效的 IPC 机制,尽量减少数据拷贝。 而线程切换就轻量很多,只需要保存寄存器和栈信息,不需要切页表。 这也是为什么我们在做 Android 开发时,耗时操作都要扔到子线程(Worker Thread)去做,比如用 AsyncTask 或者现在的 Coroutines,就是为了不阻塞主线程(UI 线程),保证界面的流畅性。

第三是稳定性。 这个区别在 Android 里体现特别明显。如果一个子线程崩了(比如抛出了未捕获的异常),通常会导致整个进程 Crash,也就是 App 闪退。 但是,如果一个进程崩了,它通常不会影响到其他进程。比如 SystemServer 进程如果崩了,Zygote 会重启它,但这属于系统级崩溃;如果是普通的 App 进程崩了,是绝对不会影响到微信或者系统的正常运行的。

稍微延伸一点源码层面的话,在 Linux 内核里(Android 基于 Linux),其实线程和进程的界限没那么绝对,它们底层都对应 task_struct 结构体。只不过创建的时候,线程通过 clone() 系统调用带上了 CLONE_VM 标记,告诉内核我们要共享内存空间。

所以总结一下,进程侧重于资源隔离,保证系统安全稳定;线程侧重于并发执行,提高 CPU 利用率和程序响应速度。我们在做 App 架构设计的时候,大多是在玩线程(比如线程池、协程),但在做多进程架构(比如单独的推送进程、WebView 进程)时,就要考虑进程隔离带来的通信成本了。


进程间通信的方式有哪些?

嗯,Android 是基于 Linux 内核的,所以 Linux 传统的 IPC 方式 Android 大多都支持,但 Android 自己搞了一套更牛的 Binder 机制。作为一个搞 Framework 的,我肯定得重点说说 Binder,但其他的我也接触过。

主要的方式大概有这几种:BinderSocket管道(Pipe)共享内存(Shared Memory)信号(Signal)

首先必须得详说的就是 Binder。 这是 Android 最核心的 IPC 方式,遍布整个系统。比如我们用的 startActivity,底层就是通过 ActivityManagerService(AMS)这个 Binder 服务来调度的。 为什么 Android 要单搞一套 Binder?主要还是为了性能安全。 传统 IPC 比如管道、Socket 通信通常需要 2次内存拷贝(用户空间 -> 内核空间 -> 用户空间),而 Binder 通过 mmap 内存映射,把内核空间和接收方的用户空间映射到同一块物理内存上,所以只需要 1次拷贝。 而且 Binder 基于 C/S 架构,在内核层(Binder Driver)就支持调用方的身份校验(UID/PID),这在安全上比共享内存那种裸奔的方式要好得多。我们在写 Framework 代码时,经常看到 Binder.getCallingUid() 这种校验逻辑。

然后是 Socket。 虽然 Binder 很强,但 Socket 在 Android 里也有很关键的应用。最典型的就是 Zygote 进程。 Zygote 是所有 Android 应用进程的父进程。当 AMS 请求启动新 App 时,它不是通过 Binder 告诉 Zygote 的,而是通过 LocalSocket。 因为 Binder 机制本身是多线程的,而 Zygote 在 fork 进程的时候是单线程运行的(为了避免死锁),所以这里特意避开了 Binder,选用了 Socket。还有像 InputManagerService 分发触摸事件给 App 时,用的也是 InputChannel,底层本质上也是一对 Socket(或者说是 Bitube)。

接着是 管道(Pipe)。 这个在 Android 的 Java 层平时用得少,但在 Native 层和 Looper 机制里很重要。 Android 的主线程消息循环 Looper,底层用了 Linux 的 epoll 机制。当消息队列没消息时,线程会阻塞;当有新消息插入时,需要唤醒线程。老版本的 Android 这里就是用管道(Pipe)写入字符来唤醒的,后来的版本用了 eventfd,原理差不多,都是基于文件描述符的通信。

还有 共享内存(Shared Memory)。 这是最快的 IPC 方式,因为不需要拷贝数据。Android 里封装了 Ashmem(Anonymous Shared Memory)。 这主要用于传输大数据。比如 SurfaceFlinger 在渲染合成的时候,App 端的 Surface 渲染好了画面数据,通过 GraphicBuffer 共享给 SurfaceFlinger 进程去合成上屏,这里绝对不能用 Binder 拷贝数据,否则 60fps 根本跑不起来,必须用共享内存。还有 MemoryFile 这个类也是对 Ashmem 的封装。

最后简单提一下 信号(Signal)。 这个主要用于系统管理,比如杀进程的时候发送 SIGKILL,或者发生 ANR/Crash 的时候,系统会抓取堆栈信息,这背后也涉及信号的处理(比如 SIGQUIT 用于抓取 ANR trace)。

所以总结一下,Android 日常开发和 Framework 调用主要靠 Binder;Zygote 孵化进程和 Input 事件分发靠 Socket;大数据传输(图形渲染)靠 共享内存;底层线程唤醒机制靠 管道/eventfd


线程间通信的方式有哪些?

嗯,线程间通信(Thread Communication)也是我们日常开发最高频的场景了。因为内存是共享的,所以重点在于同步互斥,以及如何有序地传递消息。

我把它分为两类:传统的 Java 并发机制Android 特有的消息机制

首先是 Android 特有的 Handler 机制,这是我们最熟悉的。 Android 是事件驱动的,主线程不仅要处理 UI 绘制,还要响应 Input 事件,这些都离不开 Handler。 这套机制主要由 HandlerLooperMessageQueueMessage 组成。 简单说就是:我们在子线程通过 Handler.sendMessage() 发送消息,消息被放入 MessageQueue(这就完成了数据从子线程到主线程的传递)。 主线程的 Looper.loop() 是一个死循环,它不断从队列里取消息。 这里有个关键点,也是面试常问的:为什么死循环不会卡死? 这就涉及到我刚才提到的 Native 层机制了。当队列没消息时,MessageQueue 会调用 nativePollOnce,利用 Linux 的 epoll 机制进入阻塞状态,释放 CPU 时间片。当有新消息来时,再通过往文件描述符写数据把线程唤醒。 所以,Handler 不仅仅是 UI 线程切换的工具,它本质上是一个跨线程的消息分发系统。

第二类是 Java 标准的并发机制。 这块主要依赖 JVM 的实现。

  1. volatile 关键字:保证了变量的可见性。一个线程改了标志位,另一个线程立马能看见,这是最轻量级的通信。
  2. synchronized 和 wait/notify:这是基于对象监视器(Monitor)的。比如一个生产者-消费者模型,缓冲区满了,生产者线程就 wait 释放锁,消费者消费了数据后 notify 唤醒它。这在老代码或者简单的同步场景很常用。
  3. ReentrantLock 和 Condition:这比 synchronized 更灵活,支持公平锁,也支持多个条件变量(Condition),在复杂的并发队列实现里经常用到。

第三类是 现代化的并发工具,比如 RxJavaKotlin Coroutines。 虽然底层还是基于 Handler 或者线程池,但它们在语义上提供了更高级的通信方式。 特别是 Kotlin 协程 中的 ChannelFlowChannel 就非常像 Go 语言里的管道,允许两个协程(可能在不同线程)之间发送和接收数据流,它在挂起和恢复的过程中,自动帮我们处理了线程切换和数据传递。这对于 MVVM 架构中 ViewModel 和 Repository 层的通信非常有用。

另外,从 Framework 源码 角度看,还有一些特殊的类,比如 AsyncTask(虽然过时了,但内部是 FutureTask + Handler),还有 IntentService(内部是 HandlerThread)。 说到 HandlerThread,它其实就是一个自带 Looper 的线程,非常适合处理那种需要按顺序执行的后台任务,我之前在做日志写入模块的时候就用过它,避免了频繁创建销毁线程的开销。

所以总结来说,做 Android 业务开发,主要用 Handler协程;做底层或者通用库开发,可能会用到 Lock/Condition 或者 volatile 来做更精细的并发控制。


三核 CPU 的 PC 机中,一个进程并发 10 个线程时,如何解决资源竞争问题实现并发?

嗯,这个问题其实主要考察的是并发(Concurrency)与并行(Parallelism)的区别,以及线程安全的机制。

首先结论是:在 3 核 CPU 上跑 10 个线程,这实际上是一个并行加并发的混合场景。因为只有 3 个核心,同一时刻真正的物理“并行”只能有 3 个线程在跑,剩下的 7 个线程得靠操作系统的时间片轮转(Time Slicing)来调度,这就是“并发”。

要解决资源竞争,核心思路就一个:保证临界区(Critical Section)的原子性

具体到代码实现上,主要有这么几种手段:

第一种,也是最常见的,就是互斥锁(Mutex/Lock)。 比如我们 Java 里的 synchronized 关键字或者 ReentrantLock。 如果我看过 JDK 源码的话,synchronized 底层其实依赖于 JVM 的 ObjectMonitor,在 Linux 上最终可能走到 pthread_mutex_lock 甚至内核的 futex 机制。它的逻辑是“悲观”的,就是我觉得肯定会冲突,所以我先锁住,别人都别动。 而 ReentrantLock 它是基于 AQS(AbstractQueuedSynchronizer)实现的,内部维护了一个 state 变量和一个等待队列。

第二种,非阻塞同步,也就是乐观锁。 如果竞争不是特别激烈,用锁太重了,会发生上下文切换(Context Switch),开销大。这时候可以用 CAS(Compare-And-Swap)。 比如 AtomicInteger 这种原子类。我看过它的源码,它底层调用的是 Unsafe 类的方法,直接对应 CPU 的指令(比如 x86 的 cmpxchg),在硬件层面保证原子性。如果更新失败了,就自旋(Spin)重试,这样就不需要挂起线程,效率高很多。

第三种,线程封闭(Thread Confinement)。 如果一个资源不需要共享,那干脆别共享。 比如用 ThreadLocal。它在每个线程的 Thread 对象里都维护了一个 ThreadLocalMap。我之前读 Handler 源码的时候,那个 Looper.prepare() 就是用 ThreadLocal 把 Looper 存在当前线程里的,这样 10 个线程各自有各自的 Looper,完全不存在竞争问题。

所以总结一下,在 3 核 10 线程的场景下,对于共享资源,我通常首选 ReentrantLocksynchronized 来保证互斥;如果是计数器这种简单场景,就用 Atomic 类;如果能设计成不共享,那就用 ThreadLocal 彻底避免竞争。


线程调度算法有哪些?

嗯,关于线程调度,其实操作系统课本里讲了很多经典算法,比如先来先服务(FCFS)短作业优先(SJF)、还有最常见的时间片轮转(Round Robin)

但在我们 Android 或者说 Linux 环境下,实际情况要复杂一些。

首先,Linux 内核目前默认使用的调度算法是 CFS(Completely Fair Scheduler,完全公平调度器)。 这个我看过一点内核源码,它的核心思想不是简单地分配固定时间片,而是引入了一个概念叫 vruntime(虚拟运行时间)。 内核会维护一颗红黑树(Red-black tree),把所有这就绪的进程/线程按 vruntime 排序。 逻辑很简单:谁的 vruntime 最小,说明谁“被亏待”了,就挑谁来运行。运行久了,vruntime 变大,就把它插回红黑树后面,换别人运行。

然后,为了支持优先级,Linux 用了 Nice 值(Android 里对应 Process Priority)。 优先级高的线程,它的 vruntime 增长得慢,所以在红黑树里总是排在前面,能抢到更多的 CPU 时间。

除了 CFS,Android 还做了一些特殊的策略,特别是涉及 UI 流畅度的时候。 比如 RT(Real-time)调度。 像 SurfaceFlinger 这种核心系统服务,或者应用的 RenderThread,往往会设置成 FIFO 或者 RR 的实时调度策略,优先级非常高,因为它们绝对不能卡顿。

另外,Android 还在 Framework 层做了 Cgroups(Control Groups) 的隔离。 比如我在 AMS(ActivityManagerService)源码里看到,当一个 App 切换到后台时,AMS 会调用 setProcessGroup 把它挪到 background 的 cpuset 里。这个 cpuset 可能只允许跑在某些小核上,限制它的 CPU 资源,从而保证前台应用(Top App)能独占大核,跑得更爽。

所以总结来说,理论上有 RR、SJF 等算法,但在 Android/Linux 实战中,主要是 CFS(完全公平调度) 配合 实时调度策略,再加上 Cgroups 的资源隔离 来共同完成调度的。


如何避免线程死锁?

死锁这个问题,其实在开发 Framework 层服务的时候非常敏感。

首先,死锁产生的四个必要条件我们都很熟了:

  1. 互斥(资源只能被一个线程用)。
  2. 请求与保持(拿着一个锁去请求另一个)。
  3. 不可剥夺(没人能抢我的锁)。
  4. 循环等待(A 等 B,B 等 A)。

避免死锁,本质上就是破坏这四个条件中的某一个,通常我们能操作的主要是破坏循环等待

这就引出了最实用的方案:统一锁的获取顺序(Lock Ordering)。 比如我有锁 A 和锁 B。规定所有线程必须先拿 A 再拿 B。 这样就不可能出现“线程 1 拿了 A 等 B,线程 2 拿了 B 等 A”的情况了。 我在读 AMS 和 WMS(WindowManagerService)源码的时候,发现 Google 的工程师为了防止死锁,内部有非常严格的锁顺序规定。甚至在代码注释里经常能看到 // Acquired with WindowManagerService.mWindowMap lock held 这种提示,就是为了规范锁的层级。

第二个方案是使用 tryLock 机制。 用 ReentrantLock.tryLock(timeout)。如果我在指定时间内拿不到锁,我就放弃,释放我已经持有的资源,回退一下,过会儿再试。这其实是破坏了“请求与保持”条件,避免死锁僵局。

虽然上面是“避免”的方法,但其实在 Framework 开发中,我们还需要“检测”机制作为兜底。 这就不得不提 Android 的 Watchdog 机制了。 Watchdog 是运行在 system_server 里的一条特殊的线程。它会定期向 AMS、PMS、WMS 的 Handler 发送消息,或者直接去检查某些核心锁。 如果主线程卡住了(比如死锁了),那个消息长时间没返回(默认是 60秒),Watchdog 就会判定 System Server 死锁(Watchdog bark!),然后直接把 system_server 杀掉重启。 这虽然不能“避免”死锁发生,但能避免手机彻底变砖,让系统有机会恢复。

所以结论是:在写代码时,最关键的是严格遵守锁的全局顺序;必要时用 tryLock 防御;而在系统设计层面,要有像 Watchdog 这样的监控机制来做最后的兜底。


什么是平衡二叉树?

嗯,这个问题其实得区分一下“狭义”和“广义”。

通常我们在教科书上说的平衡二叉树,指的都是 AVL 树(由 Adelson-Velsky 和 Landis 发明)。 它的核心结论是:它是一棵空树,或者它的左右两个子树的高度差(平衡因子)的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。

为什么要搞这么复杂呢?其实是为了解决普通二叉查找树(BST)的痛点。 如果我按 1, 2, 3, 4, 5 的顺序插入数据,普通的 BST 会退化成一个“链表”,查找时间复杂度直接从 O(logn)O(\log n) 变成 O(n)O(n),这在海量数据下是不可接受的。 而平衡二叉树通过严格的限制,保证了树的高度始终在 logn\log n 级别,这样无论是增删改查,效率都能稳定在 O(logn)O(\log n)

不过,如果在面试中稍微展延一点的话,其实 Java 集合框架里的 TreeMap 或者 Android 系统源码里用得更多的是 红黑树(Red-Black Tree)。 红黑树是一种“弱平衡”的二叉树。它不像 AVL 树那样严格要求高度差只能是 1,而是通过颜色约束保证最长路径不超过最短路径的两倍。 比如 Linux 内核的 CFS 调度器(就是我刚才提到的 Completely Fair Scheduler),它用来管理进程运行队列的数据结构,底子就是一颗红黑树,因为它在维护平衡的开销(插入/删除时的旋转次数)和查询效率之间,做了一个更好的 Trade-off。

所以总结来说:平衡二叉树就是通过限制子树高度差,来保证操作效率稳定在 O(logn)O(\log n) 的二叉搜索树。


平衡二叉树插入节点时如何保证平衡?

插入节点导致失衡时,保证平衡的核心手段就两个字:旋转

具体流程是这样的: 当按照二叉排序树的规则插入一个新节点后,我们必须从这个新节点开始,沿着路径往上回溯(Backtracking),去检查每一个祖先节点的平衡因子

一旦发现某个节点的平衡因子绝对值大于 1(比如变成了 2 或 -2),就说明这个地方失衡了。这时候就需要根据“失衡的姿态”来进行旋转。一共有四种情况:

  1. LL 型(左左):插入点在失衡节点的左孩子的左侧。 这时候需要右旋(Right Rotation)。就像把失衡节点顺时针“沉”下去,把左孩子“提”上来当新的根。

  2. RR 型(右右):插入点在失衡节点的右孩子的右侧。 这时候需要左旋(Left Rotation)。逆时针转一下。

  3. LR 型(左右):插入点在左孩子的右侧。 这是一个双旋操作。先对左孩子进行左旋,变成 LL 型,然后再对当前节点进行右旋

  4. RL 型(右左):插入点在右孩子的左侧。 这也是双旋。先对右孩子进行右旋,变成 RR 型,再对当前节点进行左旋

其实我在手写这部分算法的时候,发现最关键的是更新高度。旋转完之后,父节点和子节点的高度变了,要及时更新,否则后续的判断就错了。 而且 AVL 树有个很好的特性:插入操作导致的失衡,通常只需要进行**常数次(最多两次)**的旋转就能恢复全局平衡,不会一直蔓延到根节点。


平衡二叉树删除节点时如何保证平衡?

删除操作其实比插入要麻烦不少。

它的基本步骤是: 首先按标准 BST 的逻辑删除节点(如果是叶子直接删;如果有一个孩子就顶替;如果有两个孩子,通常是用中序遍历的前驱或后继节点值覆盖它,然后删除那个前驱/后继节点)。

难点在于删除后的调整: 和插入一样,删除节点后,也可能导致父节点或祖先节点失衡。我们需要从删除节点的位置向上回溯。 如果发现某个节点失衡了(平衡因子变成 2 或 -2),我们同样要根据它那边子树较高的情况,通过左旋、右旋或双旋来修复。

但是!删除操作的旋转可能会引发“连锁反应”。 这跟插入不一样。插入时,调整一下子树高度可能就恢复了。但在删除时,如果你把原本较高的一边转矮了,可能会导致它的父节点(更上层)也因为这一侧变矮而失衡。 所以,这种“失衡-旋转修复”的过程,可能会一直向上传播,最坏情况下要一直回溯到根节点,做 O(logn)O(\log n) 次旋转。

这也是为什么在工程实践中(比如 Android 的 HashMap 变树化,或者 TreeMap),我们更倾向于用红黑树。 因为红黑树在删除节点时,通过变色和旋转,最多只需要 3 次旋转 就能恢复平衡,而 AVL 树可能需要一直转到头。对于频繁删除的场景,AVL 树的维护成本有点高。


二叉树的遍历方式有哪些?

嗯,这个问题非常基础,但也很重要。通常我们把遍历方式分为两大类:深度优先搜索(DFS)广度优先搜索(BFS)

具体细分的话,主要有四种:

  1. 前序遍历(Pre-order Traversal):这是 DFS 的一种。
  2. 中序遍历(In-order Traversal)
  3. 后序遍历(Post-order Traversal)
  4. 层序遍历(Level-order Traversal):这就是典型的 BFS。

其实还有一种比较高阶的Morris 遍历。 它能在 O(1)O(1) 的空间复杂度下完成遍历,不需要栈也不需要递归。它的核心思想是利用叶子节点的空指针指向前驱节点(线索化),从而回到上层。虽然面试手写比较少见,但作为一个优化思路很值得提。

在我们 Android 开发里,其实View 树的遍历就是一个典型的例子。 比如 ViewGroupdispatchDraw 或者 measure / layout 的时候,本质上就是对这棵 View 树进行遍历。如果你看 ViewGroup 的源码,它的 dispatchDraw 基本就是一个前序遍历的变种,先处理自己,再循环处理子 View。


前序、中序、后序遍历的规则是什么?

这三种遍历规则,其实非常好记,它们的命名都是根据根节点(Root)被访问的顺序来定的。

  1. 前序遍历(Pre-order): 规则是:根 -> 左 -> 右。 也就是先访问根节点,然后递归遍历左子树,最后遍历右子树。 它的应用场景很有意思,比如我们在克隆一棵树,或者在 Android 里通过 XML 解析生成 View 树的时候,通常是这种由上而下的构建过程。

  2. 中序遍历(In-order): 规则是:左 -> 根 -> 右。 先遍历左子树,完了访问根节点,最后右子树。 这个规则对于 二叉搜索树(BST) 特别重要。因为对 BST 做中序遍历,得到的序列正好是从小到大的有序序列。如果我们想验证一棵树是不是 BST,最简单的办法就是中序遍历看它是不是单调递增的。

  3. 后序遍历(Post-order): 规则是:左 -> 右 -> 根。 先搞定左右子树,最后才访问根节点。 这种方式常用于资源释放。比如我们要销毁一个 View 树或者删除文件夹,肯定得先把子 View 或者子文件删干净了,最后才能把当前的这个父容器删掉,否则就内存泄漏或者报错了。


二叉树迭代遍历的实现思路是什么?

嗯,这其实是在考察怎么用**显式的栈(Stack)**来模拟系统调用栈,把递归转成迭代。这样可以避免树太深导致 StackOverflowError。

针对不同的遍历,思路稍微有点不一样:

1. 前序遍历的迭代: 这个最简单。 我们维护一个栈,先入栈根节点。 然后循环判断栈不为空: 弹出当前节点并访问(比如打印)。 关键点来了:先压入右孩子,再压入左孩子。 为什么要反过来?因为栈是**后进先出(LIFO)**的。我们要保证左孩子先出来被访问,所以得后压进去。

2. 中序遍历的迭代: 这个逻辑稍微绕一点,像是一个“贪心”的过程。 我们需要一个指针 cur 指向根节点。 循环逻辑是:只要 cur 不为空,就一直往左走,同时把路过的节点压栈(这是在模拟递归找最左节点)。 当 cur 为空了(走到最左底下了),就从栈里弹出一个节点,访问它,然后让 cur 指向它的右孩子。 这样就实现了“左 -> 根 -> 右”的顺序。

3. 后序遍历的迭代: 这个是最难的,因为根节点要最后访问,意味着我们要经过它两次(去左子树一次,去右子树一次),第三次回来才能访问它。 通常有两种写法:

  • 双栈法:这其实是个取巧的办法。前序是“根左右”,如果我们搞一个“根右左”的遍历(把前序那个压栈顺序改改),然后把结果存到另一个栈里倒序输出,就是“左右根”了。
  • 单栈法(这也是面试官最想看到的):我们需要多维护一个 prev 指针,记录上一次访问的节点。 在遍历时,如果当前节点的右孩子为空,或者右孩子就是 prev(说明刚从右边回来),那就可以访问当前节点了,并出栈。否则,就需要把右孩子压栈处理。

其实在 Android 的 View 源码里,找焦点或者 findViewById 的时候,虽然大部分是用递归,但在处理极深层级 View(比如几百层嵌套)的时候,也是推荐用这种迭代思想去手动管理栈的,防止炸栈。


TCP 属于网络协议模型的哪一层?TCP 主要解决什么问题?TCP 如何保证数据传输的可靠性?

嗯,这个问题非常经典,属于计算机网络最核心的基础,也是我们做 Android 网络优化、长连接或者是看 OkHttp 源码时必须要理解的底层逻辑。🧐

先说结论:在 OSI 七层模型中,TCP 属于 传输层(Transport Layer),对应到 TCP/IP 四层模型也是传输层。它主要解决的核心问题是:如何在不可靠的 IP 层之上,提供一种面向连接的、可靠的、基于字节流的数据传输服务。简单来说,IP 只管把包发出去,不保证能到,也不保证顺序;而 TCP 就是要负责把这些散乱的包整理好,保证不丢包、不乱序、不错乱地交给上层应用。

接下来我详细展开讲讲它是怎么通过一套组合拳来保证可靠性的。其实我觉得可以从这几个维度来理解:

首先是 数据的完整性和有序性。 TCP 给发送的每一个字节都编了一个号,这就是 序列号(Sequence Number)。 每次发送数据时,接收方收到后回传一个 确认应答(ACK),告诉发送方“我收到哪儿了,下次你从哪儿开始发”。这样一来,不仅解决了数据包在网络传输中可能发生的乱序问题——接收方可以根据序列号重排——还通过 校验和(Checksum) 机制,确保数据内容在传输过程中没有被篡改。如果校验失败,包会被直接丢弃,不发送 ACK,等着重传。

然后是 重传机制,这是兜底的手段。 发送方发出去数据后,会启动一个定时器。如果在 超时重传时间(RTO) 内没收到 ACK,它就默认包丢了,会重新发送。这里其实有个很有意思的细节,就是 RTO 不能设死,它必须是动态计算的(RTT 的加权平均),不然网络波动稍微大一点就会导致雪崩。除了超时重传,还有 快速重传,就是如果我连续收到 3 个重复的 ACK,说明中间有个包丢了,这时候不需要等超时,直接重传那个丢失的包,效率更高。

再来是 流量控制(Flow Control),这是为了迁就接收方的能力。 利用 滑动窗口(Sliding Window) 机制。接收方会在 ACK 里告诉发送方:“我现在缓冲区还剩多少空间(Window Size)”。如果接收方处理不过来了,窗口变小,发送方就会减慢发送速度;如果窗口为 0,发送方就停下来,通过探测包去轮询窗口什么时候变大。这就避免了“填鸭式”发送把接收方撑死。

最后是 拥塞控制(Congestion Control),这是为了迁就整个网络的能力。 这也是 TCP 最复杂的地方。如果网络堵了,大家都在疯狂重传,网络会瘫痪。TCP 设计了四个阶段:

  1. 慢启动:刚开始连接时,发送窗口指数级增长,先试探一下网络水深。
  2. 拥塞避免:当窗口达到阈值(ssthresh)后,变成线性增长,小心翼翼地试探上限。
  3. 拥塞发生:一旦检测到丢包(超时或收到重复 ACK),说明网络可能堵了,立马把发送窗口减半或者降到 1(根据版本不同,比如 Reno 算法)。
  4. 快速恢复:配合快速重传,不直接降到 1,而是减半后开始线性增长。

所以总结一下,TCP 是通过 序列号/ACK 保证有序和送达,通过 重传机制 应对丢包,通过 滑动窗口 解决接收端处理瓶颈,最后通过 拥塞控制 避免网络瘫痪,这一整套逻辑闭环,才构成了它“可靠传输”的金字招牌。我们在做 IM 即时通讯或者大文件断点续传的时候,其实很多应用层的逻辑设计,都是在借鉴 TCP 的这些思想。😁


TCP 的连接池管理有什么作用?TCP 的缓存机制有什么作用?IP 协议属于网络协议模型的哪一层?

嗯,这三个问题其实分别对应了网络优化的复用策略、传输层的速率匹配机制,以及网络层的定位基础。对于我们 Android 客户端开发来说,特别是前两个,直接关系到网络请求的性能和流畅度。

先说第一个,TCP 连接池管理。 其实结论很简单,它的核心作用就是 复用连接,减少频繁建立和断开连接带来的开销,从而降低延迟

咱们都知道,TCP 建立连接需要 三次握手,断开需要 四次挥手。如果我们做 App 的时候,每一个 HTTP 请求(比如刷一条 Feed 流,里面有十几张图片)都去新建一个 TCP 连接,那光是握手的 RTT(往返时延)就能把用户急死,而且频繁创建销毁 Socket 对 CPU 和内存也是浪费。 连接池的机制就是:当一个请求结束后,不立即关闭 TCP 连接,而是把它放在一个“池子”里 Keep-Alive。下一个请求如果目标主机一样,直接从池子里捞出来用,省去了握手环节,几乎是 0 延迟发送。

举个我看过的源码例子,OkHttp —— 我们 Android 最常用的网络库,它内部就有一个 ConnectionPool 类。 它的实现非常有意思,内部维护了一个 ArrayDeque 来存储 RealConnection。当我们发起请求时,它会通过 ExchangeFinder 去尝试复用连接。如果连接空闲超过 5 分钟(默认 Keep-Alive 时间),或者池子满了,它内部有个 cleanupRunnable 就会把过期的连接清理掉。这一套机制保证了我们在高并发请求时,网络层既快又稳。


接来说说 TCP 的缓存机制,也就是发送缓冲区和接收缓冲区。 它的主要作用是 解决应用层产生数据的速度和网络层传输数据速度不匹配的问题,实现解耦和流量控制

想象一下,应用层(比如我们的 Java 代码)调用 outputStream.write() 写数据是非常快的,内存拷贝嘛。但是网络传输受限于带宽、拥塞程度,是很慢的。

  • 发送缓冲区(Send Buffer):应用层把数据写到内核的发送缓冲区后,就可以返回做别的事了,不用傻等数据发完。操作系统内核会负责把缓冲区的数据拆包、发送。而且,数据在收到 ACK 之前必须留在缓冲区里,万一丢包了,TCP 还需要从这里取数据进行重传。
  • 接收缓冲区(Receive Buffer):数据从网卡进来,可能也是一阵一阵的(突发流量),或者乱序到达的。内核先把它们存到接收缓冲区,排好序,组装好,然后应用层什么时候有空了,再通过 inputStream.read() 把数据拿走。

这里其实还关联到刚才说的 滑动窗口。我们在 Socket 编程里设置的 SO_RCVBUF(接收缓冲区大小),其实就直接影响了通告窗口的大小。如果应用层处理太慢,接收缓冲区被填满了,窗口变成 0,发送方就被迫停止发送。这就是缓存机制在物理层面上对流量控制的支持。


最后是 IP 协议。 这个就很明确了,IP(Internet Protocol)属于网络协议模型的 网络层(Network Layer),对应 OSI 第三层。

TCP 负责“可靠传输”,而 IP 负责的是 “路径选择”和“寻址”。 IP 协议其实是个“尽力而为”(Best Effort)的协议,它不保证可靠。它的主要任务是根据 IP 地址,通过路由算法,把数据包从源主机一步步跳(Hop)到目标主机。如果数据包太大,它还负责在网络层进行 分片(Fragmentation)重组

所以在 Android Framework 里的 ConnectivityService 或者底层 netd 守护进程在配置网络的时候,其实主要就是在配 IP 地址、路由表(Route Table)和 DNS,这些都是网络层干的事儿。

总结一下:连接池是为了省时间,缓存是为了抗抖动和存底稿,而 IP 是为了找路。😁


IP 协议的主要作用是什么?HTTP 和 HTTPS 的区别是什么?

嗯,这两个问题一个是网络层的基石,一个是应用层安全的重点。我结合咱们 Android 开发的实际场景来聊聊。

先说 IP 协议的主要作用。 结论其实就两个词:寻址(Addressing)路由(Routing)。它的核心任务就是把数据包从源主机跨越各种复杂的网络,投递到目标主机。

具体展开来说,其实有这么几点: 第一是 IP 寻址。IP 协议给网络上的每个设备分配了一个逻辑地址(IP 地址)。就像送快递得有门牌号一样。在 Android 手机里,当我们从 Wi-Fi 切换到 5G 时,系统底层的 ConnectivityService 会感知到网络变化,网卡会获得一个新的 IP 地址,这样网络上的其他设备才能找到我们。

第二是 路由选择。网络结构是很复杂的,像一张大网。IP 协议(配合路由表)决定了数据包下一步该往哪个路由器发。它不关心最终路径的全貌,只关心“下一跳”(Next Hop)去哪。这就是所谓的逐跳转发。

第三是 分片与重组。网络链路是有 MTU(最大传输单元)限制的。如果应用层给下来的数据包太大(比如一个很大的 UDP 包),IP 层负责把它切碎(分片),传到目的地后再拼起来(重组)。不过现在的 TCP 协议通常会在传输层就处理好分段(MSS),尽量避免 IP 层分片,因为 IP 分片效率低且容易丢包。

最后必须强调一点,IP 是 “尽力而为”(Best Effort) 的。它不保证数据一定能送到,也不保证顺序,丢了它也不管,这些可靠性的脏活累活都甩锅给了上层的 TCP 去做。


接下来是重头戏:HTTP 和 HTTPS 的区别。 这个问题在面试里几乎必问。简单直接的结论是:HTTPS = HTTP + SSL/TLS。HTTPS 就是披了一层安全外壳(SSL/TLS 层)的 HTTP。

具体的区别主要体现在这几个方面:

  1. 安全性(最核心)

    • HTTP 是 明文传输 的。如果我在公共 Wi-Fi 下抓个包(比如用 Charles 或 Wireshark),你的账号密码全是肉眼可见的,非常容易遭遇 中间人攻击(MITM) 和信息劫持。
    • HTTPS 则通过 SSL/TLS 协议进行了 加密处理。它解决了三个问题:内容加密(防窃听)、身份验证(防伪装,通过证书保证你访问的官网是真的官网)、数据完整性(防篡改,哈希校验)。
  2. 连接建立的过程

    • HTTP 很简单,TCP 三次握手完了直接传数据。

    • HTTPS 就麻烦了,TCP 握手之后,还必须进行 SSL/TLS 握手。这个过程很消耗 CPU 和时间。

    • 这里稍微展开一下 SSL 握手 的原理,因为它很精妙。它采用的是 混合加密

      • 在握手阶段,使用 非对称加密(比如 RSA 或 ECC 算法)。服务器把公钥发给客户端(放在证书里),客户端生成一个随机的“会话密钥”,用公钥加密发回去。
      • 一旦双方拿到了这个“会话密钥”,后面的数据传输就全部改用 对称加密(比如 AES)。
      • 为什么要这样?因为非对称加密太慢了,对称加密快。这种组合既保证了密钥交换的安全,又保证了通信效率。
  3. 端口和证书

    • HTTP 默认用 80 端口,HTTPS 默认用 443 端口。
    • HTTPS 需要向 CA(证书授权中心)申请证书。

结合源码和 Android 开发场景来说: 我们在用 OkHttp 的时候,其实能看到它对这两者的处理。在 RealConnection 类建立连接的时候,如果是 HTTPS,它会调用 connectTls 方法。 在这个方法里,它会利用 SSLSocketFactory 创建 SSLSocket,并且开始握手(sslSocket.startHandshake())。 更有意思的是,为了防止证书欺诈,我们做金融类 App 时常常会用到 Certificate Pinning(证书锁定)。在 OkHttp 里配置 CertificatePinner,把服务端证书的公钥 Hash 值写死在客户端里。如果握手时发现服务端的证书指纹对不上,直接抛出 SSLPeerUnverifiedException 异常,强行断开连接。这就从代码层面彻底杜绝了中间人攻击的可能性。

所以总结一下,HTTPS 是在 HTTP 的基础上,用计算力(SSL握手加密)换取了安全性,是现在 App 开发的绝对主流。😎


滴滴Aiot 安卓二面


安卓的编译流程,你有了解过吗?

嗯,这个问题我之前在配置 CI/CD 流水线和写 Gradle 插件的时候专门研究过。

结论来说,Android 的编译流程其实就是一个将 源码(Java/Kotlin)、资源文件(XML/图片)、依赖库 经过一系列工具链的处理,最终打包成一个 APK 或 AAB 文件的过程。 现在的构建系统主要是 Gradle,但底层其实是调用了很多 SDK 的命令行工具。

如果把这个过程拆解一下,我觉得主要可以分为这几个核心步骤:

首先是 资源的处理。 这一步主要用到 AAPT2(Android Asset Packaging Tool)。它会把我们项目里的 AndroidManifest.xml、布局文件等进行编译。 这里其实有两个小阶段:先是 compile 阶段把资源文件编译成二进制格式(比如 .flat 文件),然后是 link 阶段,把这些文件合并,生成最终的 resources.arsc 资源映射表,并且会生成一个 R.java 文件。这个 R.java 很关键,我们在代码里引用资源全靠它。

然后是 代码的编译。 这块分两步走。 第一步是把我们写的 .java 文件通过 javac,或者 .kt 文件通过 kotlinc 编译器,结合刚才生成的 R.java 和依赖的 JAR 包,一起编译成 JVM 能识别的 .class 字节码文件。 第二步是 脱糖(Desugaring)和 Dex 转换。因为 Android 的虚拟机(Dalvik/ART)是跑不了标准的 .class 文件的,它需要 .dex 文件。以前是用 dx 工具,现在基本上都用 D8 或者 R8 了。 特别是 R8,它现在集成了混淆(ProGuard)、代码缩减、优化和 Dex 转换的功能。它会把所有的 .class 文件打包成一个或多个 classes.dex 文件。

接着是 打包(Packaging)。 这一步是用 zip 工具(或者 ApkBuilder)把生成的 classes.dex、编译好的资源 resources.arscAndroidManifest.xml,还有一些 Native 库(.so 文件)全部塞到一个压缩包里,生成未签名的 APK。

最后是 签名和对齐。 生成的 APK 必须签名才能安装。以前是 jarsigner,现在都用 apksigner。 签名机制从 V1(基于 JAR 的签名)发展到现在 V2、V3、V4(基于全文件散列的签名),安全性越来越高,也能加快安装速度。 签完名后,还会跑一个 zipalign 工具。它主要是做一个 4 字节的对齐优化,这样系统在运行的时候,可以通过 mmap 内存映射直接读取资源,不用解压,能减少内存消耗。

所以简单总结就是:资源编译 -> 代码编译 -> D8/R8 转 Dex -> 打包 -> 签名对齐。这就是一个 APK 诞生的全过程。


一个 APP 从手机屏幕上点开,它大致的一个运行过程吗?比如说从进程启动到界面起来这种。

嗯,这个过程其实非常经典,也是 Framework 层最核心的流程之一。我在读 AOSP 源码的时候,重点跟过好几次这个流程,特别是 AMS(ActivityManagerService)Zygote 交互的那一块。

我把它大概分为三个阶段来说:系统调度阶段应用进程启动阶段UI 渲染阶段

第一阶段:系统调度(Launcher -> AMS -> Zygote) 当我们点击桌面的图标时,其实是 Launcher 进程调用了 startActivity。 因为 Launcher 也是一个 App,它需要通过 Binder IPC 通信,告诉系统服务进程中的 ActivityTaskManagerService (ATMS,Android 10 以后专门分出来的) 说:“我要启动一个 Activity”。

AMS/ATMS 收到请求后,会先去解析 Intent,检查权限,处理启动模式(LaunchMode)等。 然后关键点来了,AMS 会去检查目标 App 的进程是否存在。 如果是冷启动,进程肯定是不在的。这时候 AMS 就会通过 Socket 通信,向 Zygote 进程发送一个 fork 请求。 Zygote 我们都知道,它是所有 Java 进程的父进程。它收到命令后,会 fork 出一个新的子进程,这个子进程就是我们 App 的进程。

第二阶段:应用进程启动(ActivityThread 初始化) 新进程起来后,会反射调用 ActivityThread.main() 方法。这个方法非常重要,它是 App 的入口。 在 main 方法里,它会做两件大事:

  1. 初始化 Looper.prepareMainLooper()Looper.loop(),这就开启了 App 的主线程消息循环(Main Looper)。
  2. 创建 ActivityThread 实例,并调用 attach 方法。这个 attach 其实又是通过 Binder IPC 调回给 AMS,告诉 AMS:“我的进程启动好了,请把 application 的信息发给我。”

AMS 收到后,就会通过 App 进程里的 ApplicationThread(这是一个 Binder 对象)回调回来,触发 handleBindApplication。 在这个过程中,App 会创建 LoadedApk,反射创建 Application 对象,并调用 Application.onCreate()。到这里,App 的上下文环境就准备好了。

第三阶段:UI 渲染(Activity 生命周期 -> ViewRootImpl) Application 初始化完后,AMS 就会真正调度 Activity 的启动了。 它会通过 Binder 告诉 ActivityThread 去执行 handleLaunchActivity。 这里面就会反射创建 Activity 实例,然后依次回调 onCreateonStartonResume 这些生命周期。

特别要注意的是,界面真正显示出来,其实是在 onResume 之后。 在 handleResumeActivity 里,会调用 WindowManager.addView()。 这一步会创建一个核心类 ViewRootImpl。它是连接 WindowManagerDecorView 的纽带。 ViewRootImpl 会调用 requestLayout,注册 Choreographer 的帧回调。等 Vsync 信号来了之后,就会触发 performTraversals 方法。 在这个方法里,依次执行 View 的 Measure(测量)、Layout(布局)、Draw(绘制) 三大流程。 最后,绘制好的数据会通过 SharedBuffer 或者 Surface 传递给 SurfaceFlinger 进行合成,最终显示在屏幕上。

所以,整个流程就是一个 跨进程通信(Binder/Socket) 配合 消息机制(Handler) 的接力跑过程。


你平时有了解过、看过安卓的源码吗?或者第三方开源框架的源码?

有的,我有比较深入地阅读过源码。因为我觉得不管是做应用层还是想往底层发展,不看源码很难理解很多问题的本质,比如内存泄漏的根源或者界面卡顿的原因。

我主要看两类源码:Android Framework 源码优秀的第三方库源码

关于 Android Framework 源码: 我自己电脑上是拉了一套 AOSP 代码的(大概 Android 10 和 12 的版本),也配置好了编译环境。 我最开始看的是 Handler 机制。 以前只知道怎么用,看了源码才发现它的精髓其实是在 Native 层的 MessageQueue 里。它是利用 Linux 的 epoll 机制来实现阻塞和唤醒的。这就解释了为什么主线程 Looper.loop() 是死循环但不会卡死 CPU——因为没消息的时候它其实是休眠状态(blocked),不占 CPU 资源。

然后我还深入看过 Binder 机制。 虽然驱动层主要是 C 语言比较晦涩,但我重点看了 Java 层和 Native 层的交互。 比如 ServiceManager 是怎么管理的,AIDL 编译生成的 Stub 和 Proxy 到底是怎么工作的。明白了 Binder 的 mmap 内存映射只需一次拷贝,才懂了为什么它比 Socket 效率高。

最近因为在研究性能优化,我也在看 AMSWMS 的交互。 比如刚才提到的 App 启动流程,如果不看源码,很难知道 ActivityThread 里的 H 类(Handler)是专门用来处理 AMS 发来的几百种消息的。

关于第三方开源库: 我看得比较多的是 OkHttpRetrofit,还有 Glide。 以 OkHttp 为例,它设计得最精彩的就是 责任链模式(Interceptor Chain)。 它把重试、桥接、缓存、连接池这些逻辑拆分成一个个拦截器。特别是 ConnectInterceptorCallServerInterceptor,看懂了它们,就明白了 OkHttp 是怎么复用 TCP 连接(ConnectionPool)的,以及它是怎么处理 HTTP2 多路复用的。 这也启发了我在自己的项目中,如果遇到复杂的业务流处理,也可以模仿这种拦截器链的设计,解耦效果非常好。

另外还有 LeakCanary。 看它的源码让我学会了怎么利用 ReferenceQueueWeakReference 来监控对象是否被回收。它那个检测 Activity 泄露的原理(IdleHandler 时机 + 弱引用 + 二次确认)真的非常巧妙。

所以总结一下,我看源码习惯带着问题去看,先梳理主线流程,再看关键细节。这对我排查疑难 Bug 和做架构设计帮助挺大的。


你知道 Looper 为什么不会导致 ANR 吗?

嗯,这也是个特别经典的问题。很多人乍一想,主线程里有个死循环 while(true),那不应该把 CPU 跑满或者卡死吗?

结论是:Looper 的死循环本身是不会导致 ANR 的,它反而是 Android 也就是 Event-Driven(事件驱动)模型的核心。真正的 ANR 是因为在 Looper 处理某一条具体消息时耗时太久,导致后续的消息(比如触摸事件、Service 启动)无法及时处理。

具体的原理要看 MessageQueue 的源码。 在 Looper.loop() 的死循环里,最关键的一行代码是 MessageQueue.next()。 当消息队列里没有消息的时候,这个 next() 方法是会 阻塞(Block) 的。

它底层调用的是一个 Native 方法 nativePollOnce。 这后面就涉及到 Linux 的 epoll 机制了。简单说,就是当没有消息时,主线程会释放 CPU 资源,进入休眠状态(Waiting),这时候它是不占用 CPU 时间片的。 当有新消息通过 Handler.sendMessage 发送过来,或者有系统事件(比如屏幕点击)写入文件描述符时,epoll 会唤醒主线程,nativePollOnce 返回,然后 Looper 继续往下执行,分发消息。

所以,Looper 就像一个随时待命的管家,有活干就干,没活干就睡觉,睡觉是不会累死(ANR)的。

那 ANR 是怎么来的呢? 是在 msg.target.dispatchMessage(msg) 这一步。 如果我们在 handleMessage 或者 onCreate/onResume 这种生命周期回调里(本质也是由 Handler 发起的),执行了耗时操作,比如读写大文件或者复杂的算法,导致这个方法迟迟不返回,Looper 就没法进行下一次循环去拿新的消息。 这时候,系统的 Watchdog 或者 InputDispatcher 发现主线程在规定时间内(比如 5 秒)没响应输入事件,就会弹出 ANR。

所以总结就是:死循环是为了维持线程存活和消息调度,epoll 机制保证了它在空闲时不占资源,而 ANR 是因为单次消息处理超时阻塞了循环。


Glide 根据设备内存动态调整缓存,它是怎么实现的,底层原理?

是的,Glide 之所以好用,很大原因就是它对内存管理做得非常精细。这块逻辑主要封装在一个叫 MemorySizeCalculator 的类里。

结论是:Glide 会结合当前设备的“屏幕参数”和“ActivityManager 给出的可用内存级别”,通过一套算法算出一个具体的字节数,然后动态设置 MemoryCache(内存缓存)和 BitmapPool(位图复用池)的大小。

我之前看过 MemorySizeCalculator 的 Builder 源码,它的计算逻辑大概是这样的:

首先,它会计算一个 “屏幕大小基准值”。 它通过 DisplayMetrics 获取屏幕的宽和高,相乘得到像素数,再乘以 BYTES_PER_PIXEL(通常是 4 字节,对应 ARGB_8888)。 这就代表了一张全屏图片占用的内存大小。

然后,它会设定一个 目标缓存倍数。 默认情况下,Glide 会希望缓存大约 2 屏 的图片作为 MemoryCache,再留 4 屏 的大小给 BitmapPool(用来复用 Bitmap 避免内存抖动)。加起来大概就是 6 个屏幕大小的内存。

但是,这只是理想情况。它还得看 系统给不给。 它会调用 ActivityManager.getMemoryClass() 获取当前 App 的内存限制(Heap Size)。 如果有 largeHeap 标记,就拿大一点的上限。 它会拿这个系统允许的最大内存,乘以一个百分比(比如 0.4,也就是 40%)。

最后做个 取舍(Min/Max)。 如果刚才算的“6 屏大小”小于“系统给的 40%”,那就按 6 屏来算。 如果超过了,就只能按系统给的上限来分配,防止 OOM。

至于底层存储原理,Glide 的内存缓存使用的是 LruResourceCache,它继承自 Android 的 LruCache原理就是 LRU(Least Recently Used)最近最少使用算法。 当缓存满的时候,它会把那个“最久没被用到”的图片移除掉,腾出空间给新图片。 而且 Glide 还有一个 BitmapPool,这块通常用的是 LruBitmapPool。它的作用是,当图片从界面上移除时,不直接回收 Bitmap 内存,而是放进池子里。下次要加载同样大小图片时,直接复用这块内存,避免频繁触发 GC,这对流畅度提升非常大。


你是怎么学习安卓的?

嗯,我是个比较喜欢钻研的人,我的学习路线大概经历了三个阶段,也就是 “广度 -> 深度 -> 实战” 的循环。

第一阶段是打基础和看官方文档。 刚开始学的时候,我没有只看网上的博客,因为很多博客可能是过时的。我习惯直接去 Android Developers 官网 看 Guide。 比如 Jetpack 出来的时候,我就是跟着官方文档把 Lifecycle、LiveData、ViewModel 这些组件一个个撸了一遍,理解 Google 为什么要推 MVVM,是为了解决以前 MVC 里的什么痛点(比如内存泄漏、逻辑臃肿)。

第二阶段是死磕源码和原理。 这可能是我和普通应用开发最大的区别。当我会用一个东西后,我一定会想知道它是怎么跑起来的。 比如学 Binder,我不满足于会写 AIDL,我会去下载 AOSP 源码(虽然编译一次很痛苦,哈哈),在 Android Studio 里把它配好。 我会顺着代码调用链去跟,比如 startActivity 到底怎么走到 AMS 的。 我也看了一些经典的书,比如**《Android 开发艺术探索》**,这本书虽然老了点,但对理解机制帮助很大。还有就是关注一些大牛的博客,像 Gityuan 的博客,他对 Framework 的分析非常深,对我影响很大。

第三阶段是造轮子和输出。 我觉得“看懂”和“会写”是两码事。 为了验证自己是不是真懂了,我会尝试写一些简单的 Demo 或者 开源库。 比如看了 Retrofit 源码后,我自己尝试写了一个简化版的网络请求框架,用到动态代理和注解处理。 看了 EventBus 后,我也试着写了个基于 LiveData 的消息总线。 另外,我坚持写 技术博客。把学到的东西用自己的话讲出来,如果能把别人讲懂,说明我是真懂了。

平时我也会逛 GitHubStackOverflow,看看国外大神的思路,或者关注 Google I/O 了解最新的技术趋势(比如现在的 Compose、Kotlin 协程)。

总之就是:多看源码不畏难,多动手写不眼高手低。


你简历里写到跨境进程通信(AIDL),能大概给我讲下原理吗?

嗯,这块我在项目中用得挺多的,也专门看过它生成的 Java 代码。

结论来说,AIDL(Android Interface Definition Language)本质上就是个接口定义语言,它的核心原理是利用 Binder 驱动来实现跨进程的方法调用。 它的作用其实是帮我们自动生成那些繁琐的序列化和反序列化代码,让我们像调用本地方法一样去调用远程进程的方法。

具体展开的话,流程是这样的:

首先,我们写好一个 .aidl 文件,AS 编译的时候,aidl 工具会自动生成一个同名的 .java 接口文件。 这个文件里最核心的是一个抽象类 Stub(继承自 Binder)和一个静态内部类 Proxy

服务端(Service 那边)继承 Stub,实现我们在接口里定义的方法。 客户端(Activity 那边)拿到的其实是 Proxy 代理对象。

当客户端调用方法时,比如 bookManager.addBook(book)

  1. 客户端的 Proxy 会把参数(比如 Book 对象)写入一个 Parcel 容器中,这叫序列化
  2. 然后调用 mRemote.transact 方法。这时候线程会挂起,通过系统调用陷入内核态,进入 Binder 驱动
  3. Binder 驱动利用 mmap 内存映射 技术,把数据从客户端的一块内存直接映射到服务端,只需要 一次拷贝(这是 Binder 比 Socket 快的原因,Socket 需要两次)。
  4. 驱动唤醒服务端进程,回调 StubonTransact 方法。
  5. onTransact 会从 Parcel 里读出参数,执行真正的 addBook 方法(这叫反序列化),然后再把结果写回 Parcel,返回给客户端。

所以,AIDL 其实就是给 Binder 机制 穿了一层方便使用的外衣。我在看 Framework 源码的时候,发现系统服务比如 AMSWMS 其实全是这套 StubProxy 的模式,只不过它们很多是手写的,不像我们可以用 AIDL 自动生成。


你平时有去写过一些后台 Service 吗?比如后台播放服务这种?

有的,我之前写过一个简单的音乐播放器 Demo,就用到了后台 Service。

结论是:为了保证音乐在退到后台甚至锁屏后还能播放,必须使用前台服务(Foreground Service)配合 Notification。

当时我是这么设计的: 我创建了一个 MusicService 继承自 Service。 在它的 onStartCommand 方法里,我处理了 Activity 发过来的 Intent 命令(比如播放、暂停、下一首)。 而在 onCreate 里,我做了一个很关键的操作:调用 startForeground(ID, notification)。 这一步会强制在通知栏显示一个 Notification(就像网易云音乐那样,带切歌按钮的)。如果不这么做,Android 8.0 以后系统对后台 Service 查得很严,App 一退后台,Service 很快就会被系统杀掉(报 IllegalStateException)。

关于怎么和界面交互,我用的是 Binder。 在 Service 内部,我写了一个 MusicBinder 继承 Binder,把 Service 的实例或者控制接口暴露出去。 Activity 绑定的时候,通过 ServiceConnection 拿到这个 Binder,就能直接调用 Service 里的 play()pause() 方法了。

还有一点,关于 MediaPlayer 的持有。 我把它放在 Service 里作为成员变量,而不是放在 Activity 里。因为 Activity 随时可能被销毁(比如配置发生变化或者内存不足),但 Service 的生命周期更长,这样能保证播放状态不丢失。


一个后台进程,现在安卓对后台管控严格,如何能保证它不被杀掉?

嗯,这个问题非常现实。其实从 Android 5.0 开始,一直到现在的 Android 14,谷歌对后台的限制是越来越严的。

结论是:现在已经没有什么“黑科技”能保证进程 100% 不被杀了。我们能做的只是尽量提高进程的优先级(OOM_ADJ 值),让系统在内存不足回收进程时,最后才轮到我们。

要理解这个,得先说下 LMK (Low Memory Killer) 机制。 系统会给每个进程打分(oom_adj 值)。

  • 前台进程(比如正在交互的 Activity)分数最低,最安全。
  • 可见进程(比如弹窗背后的 Activity)次之。
  • 服务进程(Service)再次之。
  • 缓存进程(后台的)分数最高,最容易被杀。

所以我之前尝试过这几种保活手段:

  1. 最正规、最有效的是:开启前台服务(Foreground Service)。 也就是刚才提到的 startForeground。这会让进程的 oom_adj 值降到很低(Perceptible 级别),系统基本上不会杀它,除非内存真的极度吃紧。这也是音乐、导航类应用能活下来的原因。

  2. 利用 JobSchedulerWorkManager 这不算严格的保活,而是**“死了再拉活”**。 我们可以注册一个周期性的任务。虽然进程可能被杀了,但系统会在合适的时机(比如充电、连 WiFi 时)把我们的 Service 拉起来执行任务。这是符合 Google 规范的做法。

  3. 以前的一些“黑科技”手段(现在基本失效了):

    • 比如 1像素 Activity:锁屏时启动一个透明 Activity,骗系统说我是前台进程。现在 8.0+ 基本不管用了。
    • 双进程守护:利用 NDK 在 Native 层 fork 一个子进程,互相监听,死了一个拉另一个。这种方法在 Android 5.0 以后也很难奏效了,因为系统杀进程是整个进程组一起杀的。
  4. 厂商白名单。 说实话,在国内环境下,最硬核的保活其实是让用户手动去设置里把 App 加入“允许后台运行”的白名单。 或者像微信、QQ 那样,接入各大手机厂商(小米、华为)的 Push SDK。系统级的 Push 通道是长连接的,可以通过透传消息把 App 唤醒。

所以总结下来,现在做应用开发,不要试图去挑战系统的省电策略,而是应该配合系统,用 WorkManager 处理后台任务,用 Foreground Service 处理必须要感知的场景。


讲一下 View 的绘制过程?

嗯,这块我在看 Framework 源码的时候专门跟过 ViewRootImpl 的逻辑。

结论来说,View 的绘制流程是从 ViewRootImplperformTraversals 方法开始的,它会依次触发 View 树的 Measure(测量)、Layout(布局)和 Draw(绘制)这三大流程。 这也是为什么我们有时候调用 requestLayout 会触发前两步,而调用 invalidate 只会触发最后一步。

具体展开讲一下这三个步骤:

第一步是 Measure(测量)。 这是个递归的过程。系统会从顶层 View 开始,把父容器的约束条件封装成一个 MeasureSpec(它是一个 32 位的 int,高 2 位是 SpecMode,低 30 位是 SpecSize)传给子 View。 View 需要在 onMeasure 方法里根据这个 SpecMode(比如 EXACTLY 对应固定值/match_parent,AT_MOST 对应 wrap_content)来计算自己需要多大的宽高。 如果是 ViewGroup,它还得负责去遍历调用所有子 View 的 measure 方法,把子 View 算完后,再决定自己的大小。

第二步是 Layout(布局)。 这也是递归的。父容器会在 onLayout 方法里,根据刚才 measure 出来的宽高,计算出每个子 View 在屏幕上的具体坐标(Left, Top, Right, Bottom),然后调用子 View 的 layout 方法把它们安置好。 这也是为什么我们在自定义 ViewGroup 时,必须重写 onLayout,否则子 View 是显示不出来的。

第三步是 Draw(绘制)。 这一步就是真正把像素画到屏幕上了。在 draw 方法里,流程非常固定:

  1. 绘制背景(drawBackground)。
  2. 保存图层(saveLayer,如果有需要)。
  3. 绘制自己(onDraw,这是我们自定义 View 最常重写的方法)。
  4. 绘制子 View(dispatchDraw)。
  5. 绘制装饰,比如滚动条(onDrawScrollBars)。

特别要提一点源码细节,就是在 Draw 的过程中,系统为了性能优化,大量使用了 Canvas 的裁切(Clip)功能,只绘制脏区域(Dirty Region),而且硬件加速开启后,会将绘制指令转成 DisplayList 给 GPU 执行,这比纯软件绘制快得多。


屏幕上有个按钮,点击按钮的事件分发机制,能讲一下过程吗?

嗯,这个机制我非常熟,它经常被比喻成一个 “U型” 的责任链模式。

结论是:事件分发是从 Activity 开始,由外向内一层层“递送”(Dispatch & Intercept),找到目标 View 后,再由内向外一层层“冒泡”处理(OnTouch)的过程。

涉及到三个核心方法:dispatchTouchEvent(分发)、onInterceptTouchEvent(拦截,ViewGroup 独有)、onTouchEvent(处理)。

具体的流程是这样的:

当手指按下(ACTION_DOWN)时,硬件层的 InputManager 会把事件传给 ActivityActivity 会把事件交给 PhoneWindow,再传给 DecorView(也就是 View 树的根节点)。

然后开始 “下发” 阶段: DecorView 是一个 ViewGroup,它会调用 dispatchTouchEvent。 在这个方法里,它首先会调用 onInterceptTouchEvent 问自己:“我要不要拦截这个事件?”

  • 如果返回 true,那事件就自己吞了,直接交给自己的 onTouchEvent 处理,子 View 就收不到了(比如 ScrollView 滑动时就会拦截)。
  • 如果返回 false(默认),它就会遍历子 View,通过坐标找到被点击的那个 Button,调用 Button 的 dispatchTouchEvent

接着是 “消费” 阶段: 事件传到了最底层的 Button。因为它不是 ViewGroup,没有拦截方法,所以直接调用 onTouchEvent

  • 如果 Button 在 onTouchEvent 里返回 true,表示“我消费了这个事件”。那么后续的 MOVE、UP 事件都会直接发给它,不会再问别人了。
  • 如果返回 false,表示“我不处理”。那这个事件就会 “冒泡” 回给父容器,调用父容器的 onTouchEvent。如果父容器也不处理,最后就丢回给 Activity。

这里有个常见的面试坑点,就是 OnClickListener 是什么时候触发的? 其实它是在 View.onTouchEvent 里的 ACTION_UP 分支触发的。 也就是说,如果 onTouch 返回 true 消费了事件,但没有调用 super.onTouchEvent,或者在 DOWN 的时候就拦截了,那 performClick 就不会执行,onClick 回调也就不会来了。


你平时除了公司项目,会自己学一些 demo 或者做过自定义 View 相关的经验吗?

有的,其实我觉得 Android 开发到后期,自定义 View 是一个分水岭,所以我平时花了不少时间专门练这个。

结论来说,我不光是照着博客写 Demo,而是会去尝试解决一些实际场景下的 UI 痛点,比如复杂的图表、特殊的交互效果或者流式布局。

我主要做过这几类自定义 View:

第一类是 继承自 View 的完全自定义绘制。 比如我写过一个 仪表盘 的控件。 这里面最大的挑战是坐标系的计算和 Canvas 的操作。我需要用 Math.cos/sin 算出刻度的位置,用 Path 画出指针。 有个细节我记得很清楚:就是必须手动处理 wrap_content 的情况。因为直接继承 View,在 onMeasure 里如果不特殊处理 AT_MOST 模式,那 wrap_content 的效果就会和 match_parent 一样填满父容器。所以我得给它一个默认的宽高。

第二类是 继承自 ViewGroup 的自定义布局。 最经典的就是 FlowLayout(流式布局),用来做搜索历史标签。 这个比单纯画图更难,因为要重写 onMeasureonLayout。 在 onMeasure 里,我得遍历所有子 View,把它们的宽累加,如果超过屏幕宽度就换行,最后算出整个 ViewGroup 的高度。 在 onLayout 里,再通过计算好的坐标,一个个调用 child.layout() 把标签摆放好。

第三类是 基于现有控件的扩展。 比如带圆角的 ImageView,或者仿 iOS 的侧滑删除列表。 做圆角图片的时候,我深入研究了 Xfermode(图像混合模式),利用 DST_IN 或者 SRC_IN 来实现遮罩效果。 做侧滑删除的时候,涉及到 ScrollerVelocityTracker 的配合使用,要处理好滑动的惯性和回弹,这让我对 View 的滑动机制理解得更深了。

另外,为了优化性能,我也学会了怎么避免在 onDrawnew 对象(防止内存抖动),以及怎么用 invalidate(Rect) 做局部刷新。这些经验对我后来做列表优化帮助挺大的。


你在MyNews这个app中,当时为什么选择 MVP 架构?有了解过 MVVM 这种架构吗?

嗯,这个问题问得挺好。其实当时做 MyNews 这个项目(虽然是几年前了),主流架构确实正在从 MVP 向 MVVM 过渡,但我选择 MVP 是有特定原因的。

结论来说,当时选 MVP 是为了解决 Activity/Fragment 代码臃肿的问题,把 UI 逻辑和业务逻辑彻底解耦。但后来随着 DataBinding 和 Jetpack 的成熟,我也深刻体会到了 MVVM 的优势,并且在后来的项目里重构过。

当时 MVP(Model-View-Presenter) 的好处是显而易见的:

  1. 逻辑清晰:Presenter 就像一个中介,持有 View 的接口引用(IView)和 Model 的引用。所有的网络请求、数据处理都在 Presenter 里做,做完后回调 view.showData()
  2. 易于测试:因为 View 是通过接口抽象的,我可以很容易地写单元测试(Unit Test),mock 一个 View 传给 Presenter,验证业务逻辑对不对,而不用启动模拟器。
  3. 避免内存泄漏:虽然 MVP 容易造成内存泄漏(Presenter 持有 View),但我通过在 BasePresenter 里做 attachViewdetachView 的生命周期绑定,解决了这个问题。

MVP 的缺点 也很明显:

  • 接口爆炸:每个页面都得写契约类(Contract),定义 IView、IPresenter、IModel 接口,文件数量激增。
  • 机械重复:很多简单的 UI 更新,比如改个 TextView,也得在 Presenter 里调一下,再回调 View,有点脱裤子放屁。

所以后来我也深入学习并实践了 MVVM(Model-View-ViewModel)。 我觉得 MVVM 的核心进步在于 双向绑定(DataBinding)生命周期感知(LiveData/ViewModel)

  • ViewModel 取代了 Presenter,但它不持有 View 的引用,而是持有 LiveData(可观察的数据)。
  • View 层(XML 或 Activity)去 观察(Observe) 这个 LiveData。
  • 当数据变了,UI 自动就变了,根本不需要手动 setText
  • 最爽的是 ViewModel 是感知生命周期的,屏幕旋转重建时数据不会丢,也不用手动处理 detachView 防泄漏了。

现在的项目,我肯定首选 Jetpack MVVM(ViewModel + LiveData/Flow + Repository),配合 Kotlin 协程,代码量少,逻辑还特别清晰。


首页的导航项、新闻推荐讲讲怎么实现的?

嗯,MyNews 的首页其实是个很标准的 底部导航 + 顶部 Tab + 列表流 的结构。

结论来说,导航项我用的是官方推荐的 BottomNavigationView 配合 ViewPager2,而新闻推荐列表则是基于 RecyclerView 做的多类型条目(MultiType)适配。

具体细节是这样的:

关于底部导航: 我用了 Jetpack Navigation 组件来管理 Fragment 的切换。 以前用 FragmentManager 手动 show/hide 很容易出状态错乱 Bug。 用 Navigation 就很爽,在 XML 里画个导航图(Graph),把 BottomNavigationView 的 menu ID 和 Fragment ID 对应上,一行代码 setupWithNavController 就搞定了,连 Fragment 的回退栈都自动管好了。

关于顶部 Tab(新闻分类): 这块我用了 TabLayout + ViewPager2ViewPager2 基于 RecyclerView 实现,性能比老 ViewPager 强太多了,而且支持懒加载。 我给每个分类(比如“推荐”、“科技”、“体育”)实例化一个通用的 NewsFragment,通过 Bundle 传递分类 ID,复用 Fragment 逻辑。

关于新闻推荐列表: 这是首页最复杂的部分。因为新闻列表里不光有纯文本,还有 单图、三图、大图、视频 甚至 广告 多种样式。 我设计了一个 MultiTypeAdapter(继承自 RecyclerView.Adapter)。

  1. 首先定义一个 ItemViewDelegate 接口,每种新闻类型写一个 Delegate(比如 BigImageDelegateVideoDelegate)。
  2. getItemViewType 方法里,根据新闻数据的 type 字段返回不同的 ID。
  3. onCreateViewHolder 里,根据 ID 找到对应的 Delegate,加载不同的 XML 布局。
  4. onBindViewHolder 里,让 Delegate 去负责具体的数据绑定。 这样做的好处是 解耦,以后加个“投票新闻”类型,只需要加个 Delegate,不用改 Adapter 的核心代码,符合开闭原则。

另外,为了优化滑动流畅度,我针对图片加载做了优化:

  • 使用 Glide 加载图片,开启了内存缓存和磁盘缓存。
  • RecyclerView 滑动的时候(onScrollStateChanged),暂停 Glide 的加载任务;停止滑动时再恢复加载。这招对解决低端机卡顿特别管用。

你平时有了解 Flutter 等等跨端开发这块吗?

有的,我也一直在关注跨端技术的发展,毕竟现在大厂都在追求“一套代码,多端运行”的效率。

结论来说,我有学习过 Flutter,也跑过一些官方 Demo 和简单的仿写项目,但我目前的重心还是在 Android 原生深入,Flutter 更多是作为技术储备和开拓视野。

我对 Flutter 的理解是: 它和早期的 React Native (RN) 或者 Weex 这种基于 WebView/JSBridge 的方案完全不同。 RN 是把 JS 映射成原生的 Android/iOS 控件,虽然性能不错,但还是受限于原生控件的差异。 而 Flutter 就像是自己在系统上装了个游戏引擎(Skia),它不经过原生控件,直接自己画 UI(Paint)。所以它的性能非常接近原生,甚至在动画方面表现更好,能达到稳定的 60fps 甚至 120fps。

我也学了 Dart 语言。 感觉它和 Java/Kotlin 挺像的,强类型,支持异步(Future/Stream)。 Flutter 的 Widget 树设计很有意思,“万物皆 Widget”。 它的状态管理(State Management)是难点,我了解过 ProviderBloc 模式。特别是 Bloc,利用 Stream 流式处理状态,和 Android 的 MVVM + LiveData 有异曲同工之妙。

不过我也发现 Flutter 有一些坑,比如:

  • 包体积大:因为它要把引擎打包进去,随便写个 Hello World 都要几十兆。
  • 原生能力依赖插件:想调蓝牙、相机、传感器,还是得写 Android/iOS 的原生代码(Platform Channels),如果插件库没人维护就很麻烦。
  • 嵌套地狱:Widget 一层套一层,代码缩进看着确实有点头大(虽然 IDE 插件能辅助看)。

所以目前的看法是,对于 重 UI、轻系统调用 的页面(比如电商展示页),Flutter 非常有优势;但对于 强系统交互(比如音视频编辑、硬件控制)的场景,还是原生 Android 更靠谱。