牛客面经 - Jolteon
链接:https://www.nowcoder.com/users/193735488
腾讯WXG 客户端
你的登录怎么做的
嗯,其实在我之前的项目中,登录模块我是基于 MVVM 架构 和 Token 鉴权机制 来做的。结论上说,就是前端对密码进行加密后通过网络层发给后端,后端验证通过后下发 Token,前端再把 Token 存起来,用网络拦截器统一管理后续请求的凭证。
具体展开来说的话,首先是 UI 和架构层。我用了 Jetpack 的 ViewModel 和 LiveData(或者 StateFlow)来管理登录页面的状态。用户输入账密后,触发 ViewModel 的业务方法,这中间会做一些前置校验,比如正则检查邮箱格式、做防抖处理防止疯狂点击等。
然后是网络请求部分。这里的底线是绝对不能明文传输密码。我通常会在客户端生成请求前,用服务端的 RSA 公钥对密码进行一次加密。网络库的话用的就是经典的 Retrofit 结合 OkHttp。在拿到后端返回的 JSON 数据并且判断状态码成功后,我会提取出响应体里的 Access Token。
接着比较关键的一步是 Token 的存储和携带。为了保证本地读取的性能和安全性,我没有用传统的 SharedPreferences,而是用了腾讯开源的 MMKV 来做本地化持久存储,它的 mmap 机制效率高很多。同时,为了避免每个业务请求都手写一遍鉴权头,我在 OkHttp 里自定义了一个全局的 HeaderInterceptor(请求头拦截器)。每次发起 HTTP 请求的时候,这个拦截器就会自动去本地读 Token,然后塞到请求的 Authorization Header 里面。
大致完整的登录和鉴权链路就是这样,前后端职责分明,客户端侧分层也很清晰,后续拓展维护就比较方便。
经过实习你有没有学到别的方法(抓包)
嗯,这个确实。在实习之前,我做个人项目基本上也就是防个明文传输,觉得用了 HTTPS 加密就万事大吉了。但在实习期间,接触到了真实的线上复杂业务后,我发现如果只有基础的 HTTPS,黑客非常容易通过在手机上装 Charles 或者 Fiddler,然后在系统里安装并信任自定义证书,直接实现中间人攻击。这样我们的接口和核心数据就被抓得一清二楚了。
所以经过实习,我学到了几种在移动端比较成熟的防抓包和反制手段。
首先,最基础的一招是检测代理。我们在 App 发起网络请求之前,可以通过检查系统属性比如 System.getProperty("http.proxyHost") 来判断当前环境有没有挂上代理。如果有,就直接阻断核心业务的请求。不过这招只能防小白,别人用 VPN 机制(比如 VpnService 抓包)就能绕过。
所以然后,我学到了进阶一点的方案,就是做 SSL Pinning(证书锁定)。其实在 OkHttp 里实现起来并不难,我们可以利用它提供的 CertificatePinner 类。简单来说,就是把服务端公钥证书的 Hash 值硬编码到客户端代码里,在建立 TLS 连接的时候,强制校验服务端返回的证书链里有没有我们指定的这个 Hash。一旦中间人伪造了证书,校验这一步直接就抛异常挂掉了,抓包工具就只能抓到一堆乱码或者显示握手失败。
再深一层的话,因为纯 Java 层的证书锁定还是很容易被 Xposed 或者 Frida 这类 Hook 框架给绕过,比如黑客可以直接 Hook 掉 CertificatePinner 的校验方法让它永远返回 true。所以实习带我的导师教了一种更底层的思路:把核心的签名加密逻辑下沉到 Native 层。我们用 C++ 写一个 JNI 动态库,在发起核心请求前,把时间戳、关键参数加随机盐做个 Hash 签名(比如 HMAC-SHA256)。就算攻击者真的绕过了网络层抓到了包,因为他没有 Native 层的签名算法,没办法构造合法的签名,也就无法伪造或者篡改请求了。
总之,经过实习我觉得移动端网络安全就是一个不断攻防博弈的过程,不能指望一劳永逸,这也是我之前在学校里很难接触到的工程经验。
之前贴吧中有人发一个链接,用户点击后会自动发一个逆天评论,怎么解决这个问题
嗯,这其实是一个非常经典的 CSRF(跨站请求伪造) 攻击场景。简单来说,它的核心原理就是:用户在贴吧端其实已经登录过了,所以 App 的 WebView 或者浏览器本地存着用户的认证凭据(也就是 Cookie)。当用户不小心点击了恶意链接,打开了黑客精心构造的网页时,这个网页会在后台悄悄向贴吧的服务器发一个“发表评论”的异步请求。因为浏览器的机制会自动带上目标域名下的 Cookie,贴吧服务器一看,凭证是对的,就以为是用户本人在发帖,结果就中招了。
要彻底解决这个问题,主要有几个层面的防御策略:
首先,最根本的解决思路是架构改造,废弃纯基于 Cookie 的身份验证,改用基于 Header 的 Token 鉴权。就像我之前提到的现代 App 接口设计一样,我们把验证身份的 Token 放在 HTTP 请求头的 Authorization 字段里,而不是依赖 Cookie。因为黑客的钓鱼网站只能触发请求自动携带 Cookie,但它没有权限跨域读取到我们存的 Token 字符串,它就没法构造包含 Token 的正确 Header,这样 CSRF 攻击自然就失效了。
然后,如果是一些历史包袱比较重的老业务,比如贴吧里面嵌入的必须依赖 Cookie 的旧 WebView 页面,我们可以引入 Anti-CSRF Token 机制。也就是服务端每次渲染页面时,生成一个强随机的 Token 下发给前端。前端在提交评论表单或者发 Ajax 请求的时候,必须把这个 Token 当作一个普通参数(比如放到 POST 请求的 Body 里面)带上。由于同源策略的限制,恶意网站没法读取贴吧页面的内容,自然也拿不到这个隐藏的随机 Token,服务端一对比发现没有 Token 就会把请求拦截掉。
除此之外,还有个低成本且有效的做法是在服务端校验 Referer 和 Origin 请求头。服务端在接到发帖请求时,先检查一下这个请求是从哪个域名发过来的。如果发现 Referer 是一个不知名的野鸡域名,直接判定为非法请求丢弃。同时,在客户端端内 WebView 处理时,配合设置 Cookie 的 SameSite 属性为 Lax 或者 Strict,从浏览器底层限制跨站携带 Cookie,也能非常有效地掐断这种攻击。
token怎么来
嗯,这个问题其实涉及到了前后端整个的鉴权交互闭环。在我们常用的架构里,Token 一般采用的都是 JWT(JSON Web Token) 标准,它核心的逻辑是服务端生成,客户端保存。
具体是怎么来的呢?当我们用户在 App 里面输入账号密码点击登录后,这个请求会打到后端服务器。后端拿着传过来的账密去查询数据库,如果匹配通过,说明身份是合法的。这时候,后端服务就会把用户的唯一标识(比如 User ID)、角色权限、以及过期时间等信息,放到 JWT 格式的 Payload(负载)部分。
然后这里有个最核心的步骤:后端会用自己本地服务器上保存的一个 Secret Key(密钥),通过 HMAC-SHA256 等加密算法,对头部和负载部分生成一个 Signature(签名)。最后把 Header、Payload 和 Signature 这三部分用 . 拼接成一个 Base64 字符串返回给客户端,这就是我们拿到的 Token。因为只有服务端有密钥,所以这就保证了即使别人拿到了 Token 字符串,也没办法篡改里面的用户数据,因为一改签名就对不上了。
对于客户端来说,我收到后端的 JSON 响应后,Token 对我就是个不透明的字符串。我把它直接持久化存储到本地,比如 MMKV 里。
不过其实在真实的工业级项目里,单 Token 机制是不太好用的。有效期设长了,一旦泄漏风险极大;设短了,用户老是掉线体验很差。所以我们一般会采用双 Token 方案。也就是说,登录成功后服务端会给我下发两个 Token:一个是短期的 Access Token(比如两小时失效),专门用来请求业务接口;另一个是长期的 Refresh Token(比如 30 天失效)。
当我在 App 里请求业务接口时,如果拿到服务端返回的 HTTP 401(未授权)错误码,我就知道短 Token 过期了。这时候我不需要让用户重新登录,而是会在 OkHttp 的 Authenticator 接口里面拦截住这个失败的请求,然后在底层默默地拿着长期的 Refresh Token 去请求专门的“刷新接口”,从后端换一个新的 Access Token 回来。换取成功后,再自动把刚才那个业务请求重发一次。这样 Token 的来源和续期就形成了一个对用户完全无感知的闭环。
介绍一下实习
嗯,好的。其实我在实习期间,主要是在公司的基础应用架构团队,参与了客户端 APM(应用性能监控)SDK 的底层能力预研和部分落地工作。因为我之前自己深入阅读过一部分 AOSP 源码,对 Framework 层的机制比较熟悉,所以导师就让我重点负责了 Native 层性能监控这一块。
简单说结论的话,我在实习期间最核心产出的,就是一个基于 Native Hook 技术实现的底层线程泄露检测和大内存分配监控的模块。
当时我们线上的 App 会偶尔遇到那种极其隐蔽的 OOM 崩溃,经过 Java 层的排查发现并不是我们常说的 Bitmap 或者 Activity 泄露引起的,而是底层的 FD(文件描述符) 耗尽或者虚拟内存空间耗尽(也就是 VmData 异常膨胀)。因为现在的业务逻辑非常庞杂,很多第三方 SDK 或者自己团队底层的 C/C++ 模块,可能会在后台疯狂创建 pthread 线程却不释放,或者调用了底层的 malloc 申请大块内存却忘记释放。
为了把这个黑盒打开,我就去研究了怎么通过 Hook 底层系统的 C 库函数(比如 pthread_create 和 malloc),在真正调用发生之前或者之后,把调用栈(也就是 Backtrace)抓取下来上报。当时也是经历了从开源方案调研(比如字节的 bHook 方案和爱奇艺的 xHook),到自己理解其底层 ELF 文件结构和 PLT/GOT 动态链接机制,最后封装成自己 SDK 的过程。经过实习,我除了代码能力的提升,其实更多的是学到了一种从系统底层和内存级别去降维打击应用层难题的思维方式。
为什么用到Native Hook
嗯,这个问题其实特别关键。为什么要费这么大劲搞 Native Hook?先说结论:因为 Java 层的监控能力在面对底层资源的消耗时,存在严重的“盲区”,我们必须下沉到 Native 层才能做全局拦截和更精确的治理。
我展开来说一下。在我们平时写 Android 应用层的时候,不管是四大组件还是 Jetpack 的 ViewModel,我们的内存分配和生命周期其实都跑在 ART 虚拟机里面。如果在 Java 层发生内存泄漏,我们可以非常舒服地用 LeakCanary 这类工具,通过 WeakReference 配合 Runtime.getRuntime().gc() 去监听对象的回收情况,或者直接 dump 出 .hprof 堆快照来分析。
但是,现在稍微大型一点的 App 都有大量的 Native 层代码,比如音视频处理引擎、游戏引擎(Unity/Cocos)、甚至是 SQLite 的底层实现。这就带来一个大麻烦:C/C++ 代码是不受 ART 虚拟机管辖的。它们通过 malloc、mmap 或者 calloc 申请的内存,都是在进程的 Native Heap 里;它们通过 pthread_create 创建的线程,也是直接向内核申请的系统资源。
如果我们在 Java 层去做监控,比如重写 Thread 类或者通过 Transform 字节码插桩去拦截 Java 线程的创建,那只能防君子防不了小人。第三方 SO 库在底层偷偷创建了 100 个线程,Java 层是完全无感知的,直到最后直接抛出一个 OutOfMemoryError: Could not allocate JNI Env 让 App 崩溃。
所以,为了实现真正意义上的全局监控,我们就必须直接切断它们调用系统底层 API 的通道。因为不管是哪里的 C/C++ 代码,它只要想创建线程或者申请内存,最终都绕不开 libc.so 这个系统的基础 C 库里面的核心函数。我们通过 Native Hook 把这些入口给“劫持”了,就能拿到一手的、最真实的资源分配数据。这就是我们非要用 Native Hook 不可的核心原因。
Native Hook方式?
其实 Native Hook 在 Android 侧主流的方式主要就两种,结论是:PLT Hook(或者是 GOT Hook) 和 Inline Hook。我们在工程实践中通常会根据具体的场景和性能要求来选择。
先简单说第一种 PLT Hook。它算是一种宏观层面的重定向技术。因为 Android 里的动态库(也就是 SO 文件)采用的是 ELF 格式。在 ELF 文件为了实现动态链接,维护了一个叫 PLT(Procedure Linkage Table) 和 GOT(Global Offset Table) 的表。PLT Hook 的本质就是去找到并且修改这张表里面某个外部函数(比如 malloc)的真实内存地址,把它替换成我们自己写的代理函数的地址。它的优点是非常稳定,兼容性极好,比如字节的 bhook 就是业界非常出色的 PLT Hook 方案。
然后第二种是 Inline Hook。这个可以说是微观指令层面的暴力修改。它的思路非常生猛,它不管你什么表不表的,它直接跑到目标函数在内存中的真正起始地址那里,强行把开头的前几条汇编机器指令,给改写成一条强制跳转指令(比如 ARM 下的 B 或者 BLX 指令),让 CPU 执行到这儿的时候直接跳到我们的函数里去。常见的开源库比如 Dobby 或者 AndHook 就是干这个的。
如果对比一下的话,PLT Hook 只能拦截跨 SO 的外部函数调用,而 Inline Hook 可以拦截任何地方的任意函数(包括 SO 库内部自己写的私有函数)。但在实际实习项目中,因为我们主要是拦截 libc.so 里面的系统函数,跨库调用已经满足需求了,而且 PLT Hook 不涉及复杂的指令修复,崩溃率低很多,所以我们最终重点采用的是 PLT Hook 方案。
inline hook原理?
嗯,这个原理其实涉及到非常底层的 CPU 指令执行机制。结论是:Inline Hook 的核心思想是通过在运行时的内存中,直接覆盖目标函数头部的前几条汇编指令,把它变成一条无条件跳转指令(Jump),从而强制改变程序的执行流。
如果我们要把细节展开,可以分为三个核心步骤:指令替换、执行自定义逻辑 和 恢复现场/跳回原函数。
第一步是找准位置和偷梁换柱。假设我们要 Hook 内存里的一个 TargetFunction。首先我们要去修改这块内存的读写权限,因为代码段默认是只读的,要调用底层的 mprotect 函数把它改成可读可写可执行(PROT_READ | PROT_WRITE | PROT_EXEC)。接着,我们会把目标函数开头的前几个字节(比如 ARM64 下的 16 个字节,四条指令)给备份保存下来,然后在这个位置写入我们精心构造的一条跳转指令(通常叫 Trampoline 也就是跳板指令)。这条指令的目的地,就是我们自己写的 HookedFunction。
第二步是执行拦截逻辑。当任何其他代码尝试调用这个 TargetFunction 的时候,CPU 刚走到这个函数的开头,立刻就被我们刚才改写的跳转指令给“劫持”到了我们自定义的函数里。在这里我们就可以拿到所有的寄存器状态、参数,甚至修改返回值,或者打印调用栈。
最后第三步,也是最难的一步:执行原函数逻辑并返回。我们拦截完之后,很多时候还是要让原来的目标函数继续执行下去的。怎么做呢?因为原函数开头那几条指令已经被我们覆盖了,所以我们必须单独开辟一小块内存,把刚才备份的那几条原始指令放进去,并且在这几条指令后面再加一条跳转指令,跳回到目标函数的原始位置(也就是跳过被我们覆盖的部分)。当原始逻辑跑完后,再把结果返回给真正的调用方。
因为我之前看源码的时候对底层稍微有些涉猎,其实 Inline Hook 最大的技术痛点是指令修复。因为如果原本那几条指令是 PC 相对寻址(比如它内部依赖了当前的程序计数器 PC),你把它挪到另一块内存去执行,计算出来的相对地址全错了,直接就会段错误崩溃(SIGSEGV)。所以强大的 Inline Hook 框架,最核心的工程量其实都花在写一个庞大而严谨的指令解析和修复引擎上。
PLT Hook的原理?
嗯,PLT Hook 的原理相对于 Inline Hook 来说,其实更像是一个软件工程层面的“查表替换”。结论是:它是利用了 ELF 文件的动态链接(Dynamic Linking)机制,通过修改 GOT(全局偏移表)里面的函数地址指针,来实现调用的重定向。
我们展开细节的话,得先理解为什么会有 PLT 和 GOT 这两个东西。以前我看《程序员的自我修养》这本书的时候,里面讲过,我们的 App 运行时会加载很多不同的动态库(SO 文件)。当我们的业务库 libbusiness.so 想要调用系统 libc.so 里面的 malloc 函数时,在编译和链接阶段,libbusiness.so 根本不知道 libc.so 会被加载到内存的哪个绝对地址,所以它没办法直接在汇编里写死跳转地址。
为了解决这个问题,ELF 文件引入了一套叫做**延迟绑定(Lazy Binding)或者说位置无关代码(PIC)**的机制。
在 libbusiness.so 自己的数据段里,有一张表叫做 GOT(Global Offset Table),里面存的都是它需要用到的外部函数的真实内存地址。
而代码段里还有另一张表叫 PLT(Procedure Linkage Table),它像是一个跳板。
当我们的代码第一次调用 malloc 时,流程是这样的:代码先跳转到 PLT 表里的 malloc@plt 桩代码,这个桩代码接着去读 GOT 表里的地址。如果是第一次调用,GOT 表里还没有真实的地址,它会触发底层的链接器(Linker,也就是 linker64 进程)跑出来干活,链接器去内存里查找到 malloc 的真正地址,把它填入 GOT 表里,然后再跳转过去。以后再去调用的话,就直接走 GOT 表里现成的真实地址了。
所以,理解了这个流程,PLT Hook 的原理就非常清晰了:我们只要写一段代码,去解析当前 SO 文件的内存结构,找到对应的 GOT 表,然后把里面本来指向真正 malloc 的那个内存地址,给强行替换成我们自己写的代理函数地址。这样一来,以后本 SO 内部任何代码再去调 malloc 的时候,去查 GOT 表,拿到的就是我们的假地址,自然就乖乖地跳进我们的拦截函数里了。
改什么?
嗯,紧接着刚才 PLT Hook 的原理来讲的话,其实我们在内存里直接“动刀子”去改的地方,结论非常明确:我们改的就是 GOT(Global Offset Table,全局偏移表)里面的绝对地址值。
详细来说,GOT 表本质上就是一个指针数组。每个需要跨 SO 调用的外部函数(导入函数),在这个表里都占据着一个固定的槽位(Slot),存放着一个指向内存地址的指针(在 32 位系统上是 4 字节,在 64 位系统上是 8 字节)。
所以整个 Hook 的核心操作就是:通过解析内存中 ELF 文件的视图(从 ELF Header 开始找,经过 Program Header Table 和 Section Header Table,一直找到 PT_DYNAMIC 段,再从里面解析出相关的 DT_SYMTAB 符号表和重定位表),准确地定位到我们要 Hook 的那个函数(比如 malloc)在 GOT 表中对应的那 8 个字节的起始地址。
一旦拿到了这 8 个字节的内存地址,剩下的事就简单粗暴了。我们先把这块内存所在页的权限改掉,通常调用 mprotect 去掉写保护。然后直接用一个指针强转操作,把原本指向真正 malloc 内存地址的值,覆写为我们自己写的代理方法 my_malloc 的函数地址。
其实整个过程,我们只改了这个指针,其他的地方全都没动。原来的调用方依然浑然不知地跳向 PLT 表,PLT 表依然尽职尽责地去读 GOT 表,只是 GOT 表里交出的纸条已经被我们悄悄换过了。
PLT Hook使用条件
嗯,既然用 PLT Hook 去改 GOT 表,那就意味着它有一些先天的限制。结论是:PLT Hook 最硬性的使用条件就是,它只能拦截跨动态库(也就是跨 SO 文件)调用的外部函数,绝对无法拦截库内部定义的本地函数的相互调用。
其实顺着前面的原理就很好理解细节了。我们假设我们在开发一个 libtest.so。在这个库里面,我们自己用 C 语言写了一个私有函数叫 internal_func(),然后在同一个库里的 main() 函数去调用它。在这个编译过程里,因为大家都在同一个包里,编译器非常清楚它们之间的相对位置。所以它在生成汇编代码的时候,根本不需要走 PLT/GOT 那一套动态链接的弯路,而是直接生成一条类似于 BL + 相对偏移量 的指令(也就是 PC 相对寻址),直接就跳过去了。
因为整个调用链路里压根就没有经过 GOT 表,所以就算你把 GOT 表改出花来,也拦不住这种内部调用。
因此,如果我们在业务上需要满足 PLT Hook 的条件,目标必须是外部符号(External Symbol)。比如我们去 Hook libc.so 里的 open、read、malloc,或者去 Hook libart.so 里的虚拟机底层方法,只要这个方法是别的 SO 库提供的,你的 SO 库是用外部引入的方式调用的,那就可以用 PLT Hook。这也是为什么在做底层 APM 监控的时候,大家基本都用 PLT Hook,因为不管是申请内存还是创建线程,最终都是在调用外部基础 C 库的函数,完全符合它的使用条件。
怎么保证一定能Hook到?也就是你要在实际执行前hook。
其实这是一个非常现实且致命的工程问题。尤其是在做基础监控的时候,如果我们的 Hook 发生得太晚,很多早期的底层线程或者重要的大内存分配早就跑完了,这就导致监控数据的丢失。结论是:我们必须抢在目标代码执行前进行 Hook,核心的解法就是把初始化的时机尽早提前,通常是通过 Hook dlopen 系列函数或者利用 ELF 文件的 .init_array 段来实现。
展开细节来说,首先,我们得明白为什么会漏。在 Android 系统里,SO 文件的加载并不是在一瞬间全部完成的。比如某个第三方 SDK 内部偷偷 System.loadLibrary("hacker"),只要它一加载完,里面的代码马上就会执行。如果我们 App 的 Hook 逻辑放在比较靠后的 Activity.onCreate 里,那前面的都错过了。
为了保证一定能 Hook 到,第一招就是尽早介入主进程生命周期。在 Java 层,我们通常把加载我们自己 Hook 引擎的逻辑放在 Application 甚至是更早的 ContentProvider 的 onCreate 里,甚至利用 attachBaseContext。在 Native 层,我们在自己的 SO 刚被加载时,在 JNI_OnLoad 方法里立刻执行 Hook 逻辑。
但光是靠自己早还不够,更高级的第二招是:监听或者拦截系统的 SO 加载动作。
在底层,任何 SO 库的加载,最终都会调用 Linker 里的 dlopen 或者 android_dlopen_ext 函数。因此,我们一开始就用 Inline Hook 或者对各种系统基础库的 PLT Hook 去劫持 dlopen 这个函数本身。
这样一来,以后只要有任何新的 SO 文件被加载进内存,它都必须先经过我们的拦截器。我们在它刚映射进内存、准备执行它的初始代码之前,先停下它的动作,强行扫描并修改它的 GOT 表,把里面的目标函数给替换掉,然后再放行。
通过这种“守株待兔”的底层拦截,只要是发生在我们初始化之后的任何 SO 加载,不管它藏得多深,都会被强制注入我们预设的 Hook,这就从根本上保证了在目标函数实际执行前,我们的 Hook 是一定生效的。
Hook时机?
嗯,具体讲到 Hook 时机,其实在真实业务里是个需要权衡的问题,不能盲目求早。因为 Hook 得越早,系统的环境可能越没有准备好,容易导致兼容性 Crash。总结来说,时机可以从应用层生命周期和底层 Linker 加载周期两个维度来看。
从应用层的生命周期看:
如果我们的需求是全局监控(比如我们实习期做的内存泄露和线程监控 APM),那我一定希望越早越好。一般我们会把触发时机放在 Application 的 attachBaseContext 里面。因为这里是应用层面我们能拿到的最早的切入点(甚至早于 Application.onCreate 和四大组件的初始化)。我们在这一步调用 System.loadLibrary("my_apm_hook"),然后立刻在 Native 层的 JNI_OnLoad 中启动 PLT Hook 引擎,去替换当前已经加载好的基础系统库函数。
从底层 Linker 的加载周期看:
有时候我们甚至需要比 JNI_OnLoad 还要早。因为我在看 ELF 源码时注意到,一个 SO 文件被加载时,链接器 Linker 会去寻找 ELF 文件里的 .init_array 节(也就是全局 C++ 对象的构造函数和标注了 __attribute__((constructor)) 的函数),并且优先执行它们,然后再回调 Java 层的 JNI_OnLoad。如果第三方 SDK 在这些更早的构造函数里就悄悄分配了大量内存,我们在 JNI_OnLoad 里去 Hook 就已经晚了半拍。
所以,如果遇到那种极端的安全防御或者底层监控需求,我们会把自己 Hook 引擎的核心初始化逻辑,也写在一个由 __attribute__((constructor)) 标注的函数里。这样当我们的 SO 被加载时,在这个最底层的 init 阶段,我们的 Hook 逻辑就被触发了。再配合刚才提到的对 dlopen 的二次拦截,基本就能实现在运行时环境里,占领时间线上最前面的“制高点”。当然,时机越前置,能调用的高层 API 就越少,就越考验我们对纯底层的把控。
讲讲Java层Hook?
嗯,讲完了前面比较硬核的 Native 层,其实在业务开发里,Java 层的 Hook 也同样不可或缺。结论是:Java 层的 Hook 主要分为三个流派:基于反射和动态代理的运行期 Hook(应用层常用)、基于字节码插桩的编译期 Hook(构建期常用),以及直接修改 ART 虚拟机底层结构的系统级运行期 Hook(Xposed 类框架)。
细节展开的话,先说第一种,最常见的反射和动态代理。比如很多早期的插件化框架(像 DroidPlugin),或者我们要拦截全局的 Activity 启动。我们就利用反射拿到 ActivityThread 里面的 Instrumentation 对象,或者拿到系统服务 ActivityManager 的 IActivityManager 代理对象,然后用 Proxy.newProxyInstance 动态生成一个我们自己的代理对象,强行塞回去。这样应用在调用 startActivity 的时候,就会先经过我们的 invoke 方法。这种方法优点是不用改代码,缺点是严重依赖系统内部未公开的隐藏 API,一旦 Android 版本更新(比如 Android 9 引入的限制反射机制),很容易就挂了。
第二种是目前业界极其推崇的字节码插桩(编译期 Hook)。也就是大名鼎鼎的 ASM 或者基于 Transform API(现在是 AsmClassVisitorFactory)的做法。我们不在线上环境折腾,而是在打包编译的阶段,当 .java 编译成 .class 之后、被转换成 .dex 之前,写个 Gradle 插件去拦截并修改这些 Class 文件里的字节码。比如我们要给所有点击事件加埋点,只要扫描到实现 OnClickListener.onClick 的方法,就在它里面硬塞进去一行我们埋点逻辑的字节码指令。它的好处是运行期零开销,而且极其稳定,完全不挑 Android 版本,现在大厂的基础设施几乎全在用这个。
最后一种是基于 ART 虚拟机底层的运行期 Hook。这个可以说是 Java 层的“黑魔法”。比如早期的 Xposed,或者现在的 Epic、SandHook。虽然是在 Hook Java 方法,但它的底层原理其实是在 Native 层实现的。当 ART 虚拟机加载一个 Java 方法时,在底层其实都对应着一个 ArtMethod 结构体。这类框架会在运行时找到目标 Java 方法在 C++ 层的这个 ArtMethod 指针,然后强行把它的入口点(也就是 entry_point_from_quick_compiled_code_)指向我们自己写的一个跳板函数,从而实现对任何 Java 方法的无死角拦截。虽然能力通天,但因为过于依赖 ART 虚拟机的底层数据结构(每个 Android 版本结构都不一样),在非 Root 手机上崩溃率很高,所以一般只用在内部调试、逆向工程或者特殊的安全沙箱里。
native层怎么监控数组越界
嗯,其实 Native 层的数组越界,或者叫内存踩踏,在 C/C++ 开发里一直是个非常头疼的问题。因为这种越界往往不会立刻崩溃,而是默默破坏了相邻的内存数据,等后来程序用到那块数据时才抛出极其诡异的异常。关于怎么监控,先说结论:如果在开发和测试阶段,我们会直接使用编译器提供的 AddressSanitizer (ASan) 或 HWASan;如果在生产环境,我们会结合 Hook 技术和内存页保护(Guard Page)机制来实现动态监控。
详细展开来说,在开发态,Google 官方其实非常推荐使用 AddressSanitizer。我们在 CMakeLists 里面加上 -fsanitize=address 编译选项就能开启。它的底层原理是编译器插桩加上影子内存(Shadow Memory)。编译器会在我们每次调用 malloc 申请数组的时候,在真实内存的前后额外分配一段“红区(Redzone)”。同时,它会修改每一处读写内存的汇编指令,在读写前去影子内存里查一下这个地址是不是处于红区。如果是,说明越界了,直接主动抛出堆栈。这套方案非常精确,但是内存和 CPU 的开销极大,甚至会导致 App 性能下降好几倍,所以绝对不能带到线上去。
如果是面对线上环境,或者像我们在 APM 团队做预研时,思路就不一样了。因为我们要兼顾性能,通常会采用自定义内存分配器拦截的做法。也就是说,我们去 Hook 掉底层的内存分配函数,然后在应用层模拟一套类似的边界检测机制。比如,我们可以在申请的数组内存的首尾,手工填入特定的魔数(Magic Number,也就是 Canary 金丝雀机制)。比如数组后面固定写死 0xDEADBEEF。等业务代码调用 free 释放这块内存的时候,我们的 Hook 函数先去检查首尾的魔数有没有被改掉。如果发现这个特定的十六进制值变了,那就百分之百说明发生了数组越界写入,这时候我们再把当时的上下文和调用栈上报上去。
已有的代码呢
嗯,这个问题非常切中痛点。前面我说的 ASan 方案有一个致命的缺陷:它必须要求我们有源码,并且能够重新编译插桩。如果遇到的是第三方闭源的 SO 库,或者是安卓系统自带的底层库,我们根本没法重新编译,ASan 就彻底无能为力了。结论是:对于已有的、编译好的二进制代码,我们只能依靠动态的 PLT Hook 或者 Inline Hook 技术,在运行时劫持它们的内存分配函数,然后配合系统级的内存保护机制来实现监控。
其实在我实习期间做底层监控调研的时候,就遇到过类似场景。比如某个极其老旧的音视频引擎库,经常偶发内存踩踏。因为没有源码,我的做法是使用 PLT Hook 去单独劫持这个特定的第三方 SO 库里面的 malloc、calloc、realloc 和 free 函数。
当这个已有的 SO 库内部去调用 malloc 想要创建一个数组时,它原本以为会调到系统 libc.so 里的分配器,但实际上被我们的 PLT 表重定向,跳进了我写的代理函数 my_malloc 里。这就给了我们做手脚的机会。
在 my_malloc 里面,我们会采用一种叫 Electric Fence(电网机制) 的思想。既然我不能在你的源码里插桩,那我就在系统级别给你下套。当第三方库申请一块内存时,我不直接用默认的分配方式,而是通过 mmap 向操作系统申请一段对齐到物理内存页的独立空间。然后我把你真正需要的数组空间,紧紧贴着这个内存页的物理边缘放置。最后,我把你数组紧挨着的下一个物理内存页,设置为“不可访问”状态。
这样一来,即使是没有任何插桩的第三方老代码,只要它的指针稍微越过数组边界哪怕一个字节,就会直接触碰到那个被我们设为不可访问的“高压电网页”,直接触发系统级的硬件异常(Page Fault),从而被我们捕获。这就完美解决了已有代码无法监控的问题。
hook了怎么改
嗯,顺着刚才的思路,一旦我们成功 Hook 到了第三方 SO 的 malloc,在代理函数 my_malloc 里具体要怎么魔改呢?结论是:我们需要接管内存分配的布局逻辑,手动进行内存页对齐,强行插入 Guard Page(警戒页),最后配合信号处理函数(Signal Handler)来捕获崩溃。
展开细节来说,整个魔改过程分为三个步骤:
首先第一步是计算并重新分配内存。假设第三方库想通过 malloc 申请 S 字节的数组。在我的 my_malloc 里,我会先算出把 S 凑到系统页大小(通常是 4KB)所需要的页数,然后再额外多申请一页。比如我们可以调用 mmap 或者 memalign,申请出这块总内存。
第二步是精细化布局和设置权限。这是最核心的一步。我会把这块总内存的最后一页,通过系统的 mprotect 函数,将其内存保护属性强制修改为 PROT_NONE。这意味着这一页内存变成了黑洞,任何针对它的读写都会被 CPU 级别的 MMU(内存管理单元)直接拦截。
同时,对于前面那些允许访问的页,我不直接把起始地址返回给第三方库,而是把起始地址往后偏移,使得 返回地址 + S 正好等于那个 PROT_NONE 警戒页的起始边界。这样,用户数组的末尾就严丝合缝地顶在了警戒页上。
最后第三步是注册异常捕获。因为一旦第三方库的数组越界访问了那个警戒页,系统会抛出一个 SIGSEGV(段错误) 信号,默认情况下 App 直接就闪退了。为了拿到监控数据,我们需要在 Hook 初始化的时候,通过 sigaction 注册一个自定义的信号处理函数。
当 SIGSEGV 发生时,我们的信号处理函数会被回调,并且系统会把发生错误的那个内存地址通过 siginfo_t 结构体传给我们。我们拿着这个地址一对比,发现正好落在我们布置的警戒页范围里,那就实锤了数组越界。这时候我们立刻抓取 Native 的 Backtrace(调用栈)并生成 Tombstone 报告,这样就能精准定位到第三方代码里究竟是哪一行汇编越界了。
设置多大的范围?
嗯,这个关于“警戒范围”大小的问题非常关键,因为它直接关系到这套监控方案能不能在真实机器上跑起来。结论是:这个范围不能随意设定,受限于操作系统的内存管理机制(MMU),我们设置的警戒页大小必须严格等于系统内存页大小的整数倍,在大部分安卓 ARM 机器上也就是 4KB(4096 字节)。
我具体举个例子来算一下这笔账。假设已有的第三方代码调用 malloc 仅仅只申请了 100 字节的数组。
如果我们用 Hook 加 Guard Page 的机制,为了保护这 100 字节,我们在底层实际上的操作是:
首先,我们需要 1 个数据页来存放数据(4096 字节),再外加 1 个警戒页(4096 字节),总共向系统申请了 8192 字节的虚拟内存。
然后,为了防范往后越界(Overflow),我们会把这 100 字节的数据挪到第一个数据页的最后面。也就是说,第一页的前 3996 字节全部用作填充(Padding),紧接着是 100 字节的真实数据,然后再紧紧挨着 4096 字节的 Guard Page。整个保护范围其实就是这额外的 4096 字节的不可读写页。
这就引出了一个非常残酷的现实:这套方案的内存空间碎片化和浪费极其严重。为了监控区区 100 字节的越界,我们硬生生消耗了 8KB 的虚拟内存。如果 App 里所有的 malloc 都这么搞,虚拟内存空间分分钟就会被撑爆,直接导致 VmData OOM。
所以在实际的 APM 工程实践中,我们绝对不会全局开启这个机制。我们通常会加入一层策略下发和白名单过滤。比如,通过配置平台,我们只针对特定 SO 库的特定大小范围的内存分配(比如只 Hook 超过 1MB 的大对象分配),或者在灰度测试时通过随机采样的比例来动态开启。用这种空间换时间的极端做法,精准捕获那种极难复现的越界 Bug。
安卓中内存泄漏了解吗
嗯,内存泄漏在 Android 开发里是非常经典的问题。之前我在看 Framework 源码和写应用层代码的时候,对这个有比较深的体会。结论是:Android 内存泄漏的本质,就是长生命周期的对象,错误地持有了一个本该被回收的短生命周期对象(通常是 Activity、Fragment 或者 View)的强引用,导致 GC 可达性分析时认为它还活着,从而无法回收。
其实在我们平时的开发场景里,容易引发内存泄漏的代码模式大概有这么几种:
最常见的第一种是 非静态内部类或者匿名内部类。比如在 Activity 里面直接 new 一个 Handler 实例去发延时消息。因为 Java 语言的设计,非静态内部类会隐式持有外部 Activity 的引用。如果 Activity 被销毁了,但消息队列 MessageQueue 里还有没处理完的 Message,Message 持有 Handler,Handler 持有 Activity,这就直接漏了。所以我一般的标准做法是把 Handler 写成静态内部类,内部用 WeakReference 去包装 Activity,并在 onDestroy 里调用 removeCallbacksAndMessages。
第二种是 静态变量或者单例模式的滥用。比如我们写了一个网络管理的单例对象,在初始化的时候不小心传了 Activity 的 Context 进去保存下来。因为单例的生命周期是和整个 App 进程一样长的,这就导致那个初始化的 Activity 永远驻留在内存里。这种情况下,一定要习惯性地调用 context.getApplicationContext() 来获取全局上下文。
第三种是 系统级资源的未解绑。比如在 onCreate 里注册了 EventBus、BroadcastReceiver 或者底层的硬件监听器(比如 SensorManager),但是在 onDestroy 里忘记调用对应的 unregister 方法。因为底层的系统服务(如 AMS)往往会持有这些 Listener 的引用跨进程通信,不注销就没法释放。
至于怎么排查,业界最知名的肯定就是 Square 开源的 LeakCanary。它底层的原理我之前也去研究过,非常精妙。它是在 Application 里面注册了 ActivityLifecycleCallbacks。当监听到某个 Activity 执行了 onDestroy 之后,它会把这个 Activity 包装进一个带 ReferenceQueue 的虚引用(WeakReference)里。
接着它会等待 5 秒钟,并主动触发一次系统的 Runtime.getRuntime().gc()。如果 GC 过后,那个引用的对象没有出现在 ReferenceQueue 里面,就说明这个 Activity 被强引用卡住了,发生泄漏。这时候 LeakCanary 就会利用系统 API Debug.dumpHprofData() 把整个堆内存快照保存成 .hprof 文件,然后用它自带的 Shark 引擎去解析,自动算出从 GC Root 到泄露对象的最短引用链并展示在通知栏。这套机制对我们排查问题非常有帮助。
解决memcpy覆盖的问题
嗯,其实这是一道非常经典的底层 C 库算法题,也就是考察如何实现标准库里的 memmove 函数。结论是:当原生的 memcpy 遇到源地址(src)和目的地址(dest)所在的内存区域存在重叠时,传统的从前往后依次拷贝会导致尚未读取的源数据被覆盖污染。解决这个问题的核心,在于判断这两个指针的高低位地址,在发生重叠时,动态改变拷贝的方向,改为从后往前拷贝。
我可以在脑海里模拟一下在白板上写这段代码的逻辑。
首先,我们会接收三个参数:void *dest,const void *src,以及需要拷贝的字节数 size_t count。因为 void* 是不知道步长的,为了能逐字节操作,第一步必须先把它们强转成字符指针类型,也就是 char *d = (char *)dest 和 const char *s = (const char *)src。
接下来是最核心的内存重叠判断:
第一种情况,如果 d <= s,也就是目的地址在源地址的前面(低地址方向),或者两者压根就没有交集(比如 d >= s + count)。这时候无论是源数据怎么拷,写下去的数据都不会踩到还没读的源数据。这时候我们就采用最常规的从前往后拷贝。写一个循环,for(size_t i = 0; i < count; i++) { d[i] = s[i]; }。
第二种情况,就是覆盖问题发生的地方。如果 d > s 并且 d < s + count。这意味着目的地址处于源地址数据块的中间某处。如果我们还是从索引 0 开始拷贝,当我们把 s[0] 写到 d[0] 时,由于 d[0] 实际上对应着 s 后面的某个位置(比如 s[2]),我们就把未来需要读取的 s[2] 给提前抹掉破坏了。
为了解决这个冲突,我们必须从后往前拷贝。也就是把循环反过来,先去拷贝数组的最后一个字节,放到新地址的最后一个位置。代码逻辑就是:for(size_t i = count; i > 0; i--) { d[i - 1] = s[i - 1]; }。通过这种从高地址向低地址倒退着复制的方式,每次写入的数据所占用的位置,都是源地址已经读取过、不再需要的数据位,完美避开了覆盖污染。
最后,作为一个标准库函数,别忘了把最开始传入的 dest 指针作为返回值 return 回去,支持链式调用。在实际的 AOSP 源码(比如 bionic 库里的 libc)实现中,为了极致的性能,底层其实还会在汇编层面结合 CPU 架构(比如 ARM 的 NEON 指令集)去做按 8 字节或者 16 字节对齐的向量化批量拷贝,但核心的地址重叠判断逻辑,和上面说的是完全一致的。
讲讲替换ClassLoader实习hook的细节
嗯,好的。其实替换 ClassLoader 来实现 Hook,在 Android 领域最经典的应用场景就是热修复和早期的插件化技术了,比如大家熟悉的 QQ 空间热修复方案或者微信的 Tinker。先说核心结论:它的本质是通过反射,去修改 Android 中 BaseDexClassLoader 内部的 DexPathList,把我们自己构造的包含了补丁或者插件代码的 DexFile,强行插入到 dexElements 数组的最前面。
如果我们要把细节铺开来讲,主要分为这么几个关键步骤。
首先,我们要了解 Android 的类加载机制。我们在 App 里面正常写代码,默认使用的是 PathClassLoader。其实无论是 PathClassLoader 还是用于加载外部 dex 的 DexClassLoader,它们都继承自 BaseDexClassLoader。
在 BaseDexClassLoader 的源码里,有一个非常核心的成员变量叫做 pathList,它的类型是 DexPathList。在这个 DexPathList 里面,维护着一个叫 dexElements 的数组,类型是 Element[]。
每次我们在 Java 代码里 new 一个对象或者用到一个类的时候,虚拟机会调用 ClassLoader 的 loadClass,最终走到 DexPathList 的 findClass 方法。这个方法干的事情非常简单粗暴:就是从前往后遍历这个 dexElements 数组,去每个 Element 里面找对应的类,只要找到了,就立刻返回,不再继续往后找了。这种机制我们一般叫它“双亲委派机制”在 Android 里的一个具象化体现。
了解了这个原理,我们 Hook 的具体操作就很清晰了。
第一步,我们需要在运行时通过我们自己写的补丁 dex 文件,构建出一个全新的 Element 数组。一般我们会先 new 一个自定义的 DexClassLoader 去加载这个补丁路径,然后再通过反射把它的 dexElements 数组给拿出来。
第二步,我们去拿到当前应用上下文(Context)真正的 PathClassLoader,同样通过反射,顺藤摸瓜拿到它的 pathList,然后再拿到它的 dexElements 数组。
第三步,也就是最关键的一步,我们在内存中创建一个新的、更长的数组。然后把我们第一步拿到的补丁 Element 放在这个新数组的最前面,把原来系统的 Element 放在后面,做一次数组拼接。
最后一步,把这个拼接好的新数组,通过反射重新赋值回当前应用的 DexPathList 对象的 dexElements 字段里。
完成替换之后,当下一次系统再去查找某个类(比如有 Bug 的那个类)的时候,它从头开始遍历数组,第一个就会命中我们放在前面的补丁类,直接加载并返回了。这样,原本后面那个存在 Bug 的同名类就被“短路”掉了,永远没有机会被加载,从而实现了 Hook 也就是热修复的目的。
当然了,这里面在实际工程中会有很多坑,比如经典的热修复 CLASS_ISPREVERIFIED 异常。因为在安装时 dexopt 阶段,如果一个类引用的所有其它类都在同一个 dex 里面,它会被打上 PREVERIFIED 标记。如果运行时它引用了我们补丁包里的类(在另一个 dex),就会报错。所以腾讯早期的方案还需要插桩一个空的 AntilazyLoad 类来阻止这种提前校验。不过整体上,替换 dexElements 就是它的核心细节了。
PLT Hook的原理
好的,刚刚说的是 Java 层的 Hook,现在转到 Native 层。PLT Hook 在 Native 开发、APM 性能监控里用得非常多。先说结论:PLT Hook 的本质,是利用了 ELF 动态链接的延迟绑定和间接跳转机制,通过修改内存中 GOT(Global Offset Table,全局偏移表)里面的函数绝对地址,让目标函数跳转到我们自定义的函数里。
为了说清楚这个原理,我需要先从 ELF 文件(也就是我们编译出来的 .so 库)的结构说起。
在 Native 代码里,如果我们调用本模块自己实现的函数,编译器在编译时就知道相对位置,直接相对跳转就行了。但是,如果我们调用的是外部 .so 库里的函数,比如 libc.so 里的 malloc 或者 log,编译器在编译当前模块的时候,是根本不可能知道 malloc 在最终运行内存里的绝对地址的。
那怎么办呢?Linux 引入了 PLT(Procedure Linkage Table,过程链接表) 和 GOT 机制来解决这个问题。
当我们在代码里调用 malloc 时,实际上汇编代码跳向的并不是真正的 malloc,而是当前模块的 .plt 段里的一个桩代码(stub)。
在这个 PLT 桩代码里,它只有一条核心指令,就是去读 GOT 表 里对应的一个槽位(slot)。GOT 表其实就是一个指针数组,它通常放在 .got.plt 或者是 .got 数据段里,这里面存放的才是真正的外部函数绝对地址。
在程序刚加载的时候(如果是延迟绑定方案的话),GOT 表里存的其实不是真实的 malloc 地址,而是回到 PLT 另一段代码的地址。当第一次调用发生时,会触发系统的 linker(动态链接器)去查找真正的 malloc 内存地址,然后把真实的地址回写到 GOT 表里。等第二次再调用时,PLT 去查 GOT 表,拿到的就是真实的地址,直接跳过去了。
我们做 PLT Hook,其实就是在程序运行起来、系统链接器已经把真实的地址写进 GOT 表之后,我们跑过去横插一脚。
我们在内存中找到当前这个 .so 的基地址,然后解析它的 ELF 头部,顺着 Program Header 找到 .dynamic 段,接着找到重定位表(.rel.plt 或 .rela.plt)和符号表(.dynsym)。
通过查表,我们定位到 malloc 这个符号在 GOT 表里具体是在哪个内存偏移量。
找到这个存放地址的内存槽位后,我们就调用 mprotect 把这段内存的权限改成可写,然后把里面原本的 malloc 真实地址,强行替换成我们自己写的 Hook 函数的地址。
这样一来,下次当前模块再有代码想调用 malloc 时,它还是傻傻地跳到 PLT,PLT 还是去查 GOT 表,但是它从 GOT 表里读出的地址,已经变成我们的 Hook 函数地址了。于是,控制流就完美地被劫持到了我们的代码里。我们在自己的代码里干完监控或者替换的活儿,如果需要的话,还可以再调用原本真实的 malloc 地址,完成整个闭环。其实像爱奇艺开源的 xhook 或者字节的 bhook,底层的核心原理都是这个。
有哪些先决条件
嗯,既然刚才把 PLT Hook 的原理讲清楚了,那要让这一套流程能顺利跑通,其实是有几个硬性先决条件的。总结来说,我觉得大概有 三个核心条件。
第一个先决条件,也是最根本的,就是你要 Hook 的调用必须是一个跨模块的外部调用。
我在前面也提到了,PLT 和 GOT 机制本来就是为了解决动态链接库之间的外部符号解析而发明的。如果我在 libA.so 里面写了一个函数 foo(),然后在 libA.so 的另一个地方调用 foo(),编译器知道它们在同一个文件里,距离是固定的,所以通常会直接生成 B 或者 BL 这样的相对跳转指令(PC-relative jump),根本就不会去走什么 PLT 表。
所以,如果你想用 PLT Hook 去拦截本 .so 内部的私有函数调用,那是完全行不通的。它只能拦截从当前 .so 向外发起的调用,比如从你的业务 .so 去调系统 libc.so 的 open 或者 read。
第二个先决条件,是必须拥有修改 GOT 表所在内存区域的写权限。
在现代操作系统里,为了安全(特别是 Android 从 5.0 之后引入了越来越严格的安全机制),ELF 文件加载到内存后,各个段的读写权限是严格控制的。
比如,为了防范攻击,很多模块编译时开启了 RELRO (Relocation Read-Only) 保护。特别是 Full RELRO 模式下,系统动态链接器在加载阶段解析完所有的符号并且填入 GOT 表之后,会立刻把 .got 和 .got.plt 所在的内存页权限设置成只读(Read-Only)。
如果我们在代码里直接用指针去覆写这个内存,立马就会触发 SIGSEGV 段错误导致崩溃。所以,我们在执行写入之前,必须通过 mprotect 系统调用,传入通过页面对齐(PAGE_SIZE)计算出的起始地址,强行把这块内存页的权限改成 PROT_READ | PROT_WRITE,改完之后最好还得恢复原样。
第三个先决条件,是我们能够在内存或者磁盘中正确解析 ELF 结构。
因为要找到需要替换的具体是 GOT 表的第几个坑位,我们需要大量的查表工作。我们需要拿到目标 .so 的基地址,需要找到并解析 .dynamic 段,还要处理 .rel.plt、.symtab、.strtab 甚至哈希表(.hash 或 .gnu.hash)。如果遇到一些加壳的库,或者被抹除了部分 Section Header 信息的库,直接按标准格式在内存里去寻址可能会失败。这就要求我们的 Hook 框架在解析 ELF 的时候必须非常健壮,通常会采用直接通过 Program Header 进行基于内存视图的解析,而不是依赖由于 strip 而可能缺失的 Section Header。
满足了这三点——目标是外部符号、搞定内存权限、成功解析地址——PLT Hook 才能算是具备了实施的前提。
为什么同一个so里的Hook不到
对,其实刚才讲先决条件的时候稍微提到了这一点。很多新手在刚用 PLT Hook 框架的时候经常会踩这个坑:明明 Hook 了某个函数,结果发现别的 .so 调用能拦截到,自己 .so 里面的调用却依然我行我素,拦不住。
先说结论:这是因为同一个 .so 内部的函数调用,在编译期间就已经计算好了相对偏移量,生成的是直接相对跳转指令(比如 ARM 的 BL 指令),它完全绕过了 PLT 和 GOT 表机制。既然不走 GOT 表,我们修改 GOT 表对它自然就没有任何影响。
我们深入到编译原理的层面来看。当我们用 GCC 或 Clang 编译 C/C++ 代码时,编译器会进行指令生成。
如果编译器发现,当前函数 A 正在调用同一个文件(或者同一个编译单元)里的函数 B,而且它明确知道这两个函数最终会被链接到同一个动态库文件(.so)里面去。那么编译器会非常聪明地做优化。
因为它们在同一个文件里,无论这个 .so 将来在运行时被加载到内存的哪个基地址(Base Address),函数 A 和函数 B 之间的相对距离(Offset)是绝对固定不变的。
所以在生成汇编代码的时候,编译器会直接生成一条基于当前程序计数器(PC)的相对跳转指令。在 ARM 架构下,通常就是一条 BL <offset> 指令。意思是:当前执行到这里时,PC 寄存器加上一个固定的 offset 就可以直接跳到函数 B 的入口了。
这个过程,既不需要动态链接器去查找符号,也不需要去读内存里的指针表,效率极高。这就是我们在逆向分析时常说的 Intra-module jump(模块内跳转)。
而 PLT/GOT 机制是为了什么设计的?是为了 PIC(Position Independent Code,位置无关代码) 中访问外部模块符号而设计的。跨模块的话,编译阶段根本不知道别的模块会被加载到哪里,所以只能预留一个 GOT 表的空位,等运行时系统 linker 来填,然后调用方通过 PLT 去读这个空位跳转。
所以,由于同一个 .so 内部的调用根本不查 GOT 表,那你在 GOT 表里怎么大做文章,把地址改成一朵花,人家内部的跳转指令该怎么执行还是怎么执行,完全不搭理你的 GOT 表。这就是为什么同一个 .so 里的调用通过 PLT Hook 根本 Hook 不到的根本原因。
还有哪些Hook方式
嗯,刚才说到了 PLT Hook 虽然很好用,而且非常稳定,但它有局限性——比如无法 Hook 模块内部的私有函数。其实在 Android 的开发和逆向安全领域,Hook 技术可以说是百花齐放。除了 PLT Hook,我们还有几种非常主流的 Hook 方式。
简单归纳的话,按照发生层级的不同,除了 PLT Hook,主要还有 Native 层的 Inline Hook,以及Java 层的 ART Method Hook(比如 Xposed 或者是 SandHook 的机制)。
我可以重点挑 Native 层大家最常用的 Inline Hook 来讲一下。
其实如果说 PLT Hook 是“暗改地图”(修改路标指向),那么 Inline Hook 就是“直接修路”(直接修改函数本身的代码)。因为它是直接修改目标函数在内存中的汇编指令,所以它极其强大,不管是不是同一个 .so,甚至是系统隐藏的私有函数,只要你能拿到它的内存地址,就能 Hook 掉。不仅如此,它还可以精确 Hook 到函数的中间某一行代码,不一定非得是函数开头。
除了 Inline Hook,在 Native 层还有一种更加底层、偏向调试器原理的 Hook 方式,叫做 硬件断点 Hook 或者 Trap Hook(异常 Hook)。
它是基于 CPU 提供的调试寄存器或者是 Linux 的 ptrace 机制来实现的。原理是在目标地址下断点,或者故意写入一条会导致非法指令异常的机器码(比如 ARM 下的 UDF 指令)。当程序执行到这里时,CPU 会抛出异常(比如产生 SIGILL 或 SIGTRAP 信号)。我们通过注册信号处理函数(Signal Handler),在异常回调里面捕获到线程上下文,把 PC 寄存器指到我们的代码里,执行完了再恢复原状。这种方式不需要修改大量的原函数代码,所以对绕过某些内存完整性校验非常有效。
而在 Java 层,由于运行环境是 ART 虚拟机,除了我之前提到的修改 ClassLoader 里的 dexElements(热修复),我们真正意义上的函数 Hook 更多是去修改 ArtMethod 结构体。
大家都知道,ART 虚拟机里的每一个 Java 方法在底层都对应着一个 C++ 的 ArtMethod 对象。这个对象里存放了方法的执行入口(比如 entry_point_from_quick_compiled_code_)。像早期的 Xposed 或者现在的各种动态 Hook 框架,它们的原理就是去修改这个执行入口,把它指向自定义的 Native trampoline(跳板函数),从而接管 Java 方法的执行流程。
总结起来,除了 PLT Hook,我们主要还有 Native 的 Inline Hook、异常/硬件断点 Hook,以及 Java 层的 ArtMethod Hook 和 ClassLoader 注入等方法。每种都有特定的使用场景和优缺点。
什么原理
那我就顺着刚才提到的,详细展开讲讲 Inline Hook 的核心原理。
刚才我打了个比方,Inline Hook 相当于“直接修路”。它的本质是直接修改目标函数在内存中开头的几条机器码指令,强行插入一条无条件跳转指令(比如 ARM 里的 B 指令或者向 PC 寄存器直接赋值的指令),让原本要执行的函数强行跳转到我们自己写的 Hook 函数(跳板代码)里去。
如果要细分成具体的步骤,这是一个非常精细且危险的“心脏搭桥”手术,一般分为这么几个关键阶段:
第一阶段:保存现场与备份。 在我们动手改目标函数之前,必须要先把它开头的几条指令备份下来。因为目标函数的前面几个字节马上就要被我们覆盖掉了。如果我们以后还想调用原始的方法完成正常逻辑,就必须得有一份备份。我们会把目标函数开头的比如 8 个字节或者 12 个字节,读出来存放到另外一块新申请的内存(我们叫它 stub 或者 trampoline)里。
第二阶段:指令修复(最难的一步)。
这一步是 Inline Hook 的技术壁垒。我们刚刚备份的那几条指令,在原先的位置执行时没问题。但是,如果我们把它搬到了另一块内存去执行,有些位置相关指令(比如涉及到相对 PC 寄存器寻址的 LDR、B 或者 BL 指令)就会出错。因为当前执行地址变了,相对偏移自然也就错了。所以在这个阶段,我们必须实现一个庞大且复杂的指令解析引擎,如果是相对跳转指令,我们需要把它强行翻译并修复成基于新地址的绝对跳转指令。
第三阶段:覆盖跳转。
一切准备就绪后,我们同样通过 mprotect 去掉目标函数首地址内存的写保护,然后把目标函数的前几个字节,粗暴地覆盖为一条绝对跳转指令。
如果是 32 位 ARM,可能会写入 LDR PC, [PC, #-4] 然后跟上目标地址;如果是 64 位 ARM(AArch64),可能要用 LDR X16, #8; BR X16 这样的跳板指令。
这一步做完,只要有任何线程调用了原函数,刚跑第一步,就会瞬间被“劫持”飞往我们的 Hook 函数代码处。
第四阶段:执行我们自己的逻辑并恢复闭环。 在我们的 Hook 函数执行完之后,如果我们还想让原逻辑继续走下去,我们就会去调用刚刚在第一二阶段准备好的那个“修复好的备份指令区域(Trampoline)”。执行完那几条被替换的指令后,再通过一条跳转指令,重新跳回到目标函数剩下没被修改的指令处继续执行。
这样,整个控制流不仅被我们劫持了,而且原函数也没有损坏,这也就是 Inline Hook 最底层的运作原理。这也是字节的 bhook 除了提供 PLT Hook,很多团队还要配合 Dobby 这样的 Inline Hook 框架一起使用的原因,因为它的权限实在太高了。
还有什么方法
嗯,如果说刚才聊的 PLT Hook 和 Inline Hook 算是常规的“动刀子”的做法,在某些高强度的攻防对抗(比如反外挂、安全风控或者底层 APM 极限性能监控)场景下,其实还有一种思路非常独特的方法,叫做 Symtab Hook(符号表 Hook)。
我们先来做个对比。PLT Hook 的思路是:顺着调用方的思路去找,把调用方模块里指向外部的地址给改了。这是“治标”。
那如果有一个核心模块,有很多个别的 .so 都会去调用它,我们要把它全部用 PLT Hook 拦截一遍就很麻烦。这时候我们就可以用 Symtab Hook,它的核心思想是“治本”:直接修改提供函数的那个目标 .so 的导出符号表(Symbol Table)。
这里我也简单说一下原理。
在 ELF 文件加载到内存后,除了 GOT 和 PLT,还有一个非常重要的数据结构叫做动态符号表(通常在 .dynsym 段)。这个表里面记录了当前 .so 向外提供的所有函数的名称(哈希值)以及对应的真实内存地址。
当其他的 .so 比如要 dlopen 一个库并调用 dlsym 去查找某个函数,或者系统的动态链接器 linker 在加载新的 .so 并为它绑定外部符号时,它们底层的逻辑都是去查目标库的 .dynsym 表,配合哈希表(.hash 或者 .gnu.hash)来快速定位符号。
那 Symtab Hook 要做的,就是在内存中找到这个 .dynsym 段,然后找到目标函数的符号表项(通常是一个 Elf_Sym 结构体),把里面记录函数绝对地址或相对偏移的那个字段(比如 st_value),直接篡改成我们自己写的 Hook 函数的地址!
这种做法的威力在于它是全局生效的。只要你把 libtarget.so 里面的 .dynsym 表里的 foo 函数地址改了。那么之后,无论系统中哪个其他的模块,只要它是通过动态链接器去重新解析或者通过 dlsym 去查找 foo,系统返回给它的都会是你的 Hook 函数地址。
不过呢,这种方法在工程实践中有一定的挑战。首先,Android 不同版本的 linker 对符号表的缓存机制不一样;其次,很多模块加载完后,.dynsym 所在的页面权限保护得很死;最麻烦的是,很多开发者为了安全或者减小包体积,编译时直接加了 -fvisibility=hidden 或者用了 strip 命令,把非必要的符号全抹除了。如果没有了导出符号,或者走的是私有静态链接,那 Symtab Hook 也就无能为力了。这也是为什么它作为一种高阶的 Hook 补充方案,通常在一些特殊的底座框架或者逆向工具里面才会大规模看到。
外部引用地址是怎么填到GOT表中的
这个问题其实直接切中了 Android 动态链接机制的最核心脉络。外部符号引用地址被填到 GOT 表的过程,完全是由操作系统用户态的那个“幕后大老板”——动态链接器(linker) 来完成的。
先直接说结论:当一个 ELF 文件(比如 .so 库)被加载到内存时,或者在调用它里面的未绑定外部函数时,系统的 linker 会读取该 ELF 的重定位表(Relocation Table),然后去被依赖的共享库的符号表里查找真实的内存地址,最后把这个真实地址写进 GOT 表对应的槽位里。
这个过程如果在 Android 平台上细分,我觉得主要体现在 linker 的两种工作模式:延迟绑定(Lazy Binding)和严格绑定(Strict Binding 或 BIND_NOW)。
如果按照最经典的延迟绑定来看,它是怎么填的呢?
当程序刚启动时,linker 其实不会马上去把所有外部函数地址都查清楚。此时 GOT 表里填的地址,其实是指向 PLT 表里另一段叫 PLT0 的公共代码。
当我们第一次调用比如 malloc 时:
- 代码跳到
malloc的 PLT 桩代码。 - PLT 桩代码读取对应的 GOT 表项。此时由于没被真正解析,读出来的地址又把它跳回了 PLT0。
- 在 PLT0 里,会执行准备工作,把当前这个函数的重定位偏移量压入栈中,然后跳转到一个极其重要的系统函数——
_dl_runtime_resolve(它其实就是linker提供的一个解析服务)。 _dl_runtime_resolve拿到请求后,开始干脏活累活。它会去查当前.so的.rel.plt(重定位表),知道你要找的是malloc。- 然后它拿着
malloc这个字符串,遍历所有已经加载的.so(比如libc.so)的.dynsym(动态符号表)和.gnu.hash(哈希表)去找它的真实内存地址。 - 找到之后,
_dl_runtime_resolve会做最后也是最关键的一步:把这个算好的真实内存地址,写入到当前模块的 GOT 表里。 并且它会顺便直接跳转到那个真实地址去执行malloc。 这就完成了第一次填入的过程。以后再调用,直接查 GOT 就是真实的地址了。
不过,这里必须要提一个 Android 上的重要变化。从安全角度出发,为了防止黑客利用覆盖 GOT 表发动攻击,现在的 Android 编译大多默认开启了 RELRO(Relocation Read-Only) 保护机制。尤其是现在基本上都是 Full RELRO。
在 Full RELRO 模式下,Android 根本就不允许延迟绑定了!
它的做法是:在应用启动、linker 刚用 mmap 把你的 .so 加载进内存的时候,linker 会顺着 .dynamic 段找到所有的重定位表项,一次性地把所有的外部引用全部去查出来,挨个填到 GOT 表里。
填完所有的表之后,linker 会立刻调用 mprotect,把存放 GOT 表的内存页权限强行改成只读(Read-Only)。这样不仅填完了地址,还顺便把篡改的后门给堵上了。如果我们要 Hook 它,就必须自己在代码里把可写权限强行改回来。
这就是外部引用地址填入 GOT 表的底层运作逻辑。
so函数地址是怎么来的
刚才聊到 linker 去查真实地址填入 GOT 表,那 linker 或者我们自己在做 Hook 的时候,这个目标函数在内存中的真实绝对地址到底是怎么算出来的?其实非常简单,归纳成一个公式就是:
内存绝对地址 = 所在 .so 在内存中的加载基址(Base Address) + 函数在 ELF 文件中的相对偏移量(Offset)。
我们可以分两步来看这到底是怎么得来的。
首先是 第一部分:怎么拿到 .so 在内存中的基址(Base Address)?
当 Android 加载一个 .so 时,它是通过 mmap 系统调用把 ELF 文件按段映射到虚拟内存里的。由于有 ASLR(地址空间布局随机化)的机制,每次程序启动,这个 .so 被加载到内存的起始位置都是随机的。
在 Native 层想要获取这个基址,我们通常有两种做法:
- 解析
/proc/self/maps文件:这是最直观的。我们在代码里读这个文件,它里面按行记录了当前进程所有内存映射的信息。我们通过字符串匹配找到比如libc.so或者我们自己的libnative.so对应的第一行r-xp(可读可执行) 权限的记录,那一行的最前面的十六进制地址,就是这个.so在内存中的加载基址。 - 使用
dl_iterate_phdrAPI:这是 Android C 库推荐的标准做法。它会遍历当前进程里所有已经加载的动态共享对象,在回调函数里,系统会直接把基地址(info->dlpi_addr)返回给我们。
拿到了基地址,那 第二部分:函数的相对偏移量(Offset)怎么来?
这个就得求助于 ELF 文件的符号表了。在编译的时候,由于是独立编译,每个函数在当前 .so 里的相对位置就已经固定死了。
我们需要解析这个 .so 内存映射的 ELF Header 和 Section Header(或者 Program Header),找到它的动态符号表(.dynsym 段)和字符串表(.dynstr 段)。
动态符号表里面存的都是 Elf32_Sym 或者 Elf64_Sym 的结构体数组。我们遍历这个数组,把里面存的名字偏移去字符串表里查一查,看看叫不叫我们想找的那个名字(比如 foo)。
如果找到了,这个 Elf_Sym 结构体里面有一个非常关键的字段,叫做 st_value。
在共享库(.so)里面,这个 st_value 存放的,正是该函数相对于 ELF 文件开头的相对偏移量。
所以,最后我们拿第一步得到的基地址(比如 0x70000000),加上第二步读出来的 st_value(比如 0x1234),最后得出的 0x70001234 就是这个函数此刻在内存中的真实执行地址了。不管是系统 linker 绑定,还是我们做 Inline Hook 去覆盖指令,依赖的都是这套计算规则。
ELF加载过程
嗯,好的。其实从我们在 Java 代码里调用 System.loadLibrary,到最终一个 ELF(.so 文件)被加载到内存并准备就绪,系统底层发生了一系列极其复杂的协作。这个过程不仅涉及内核态的内存分配,还有用户态 linker 的精细调度。
要简单扼要地总结的话,ELF 的加载过程可以分为这么几个核心步骤:读取头部并分配内存、加载段数据、依赖解析、符号重定位,以及最后的初始化回调。 我按时间顺序展开说一下。
当我们触发加载时,实际上底层最终会走到系统调用(如果是可执行文件是 execve,如果是加载共享库最后是打开文件映射)。
首先第一步,内核检查与读取。系统会去读取这个 ELF 文件的前几个字节,也就是 Magic Number。检查是不是 \x7fELF 打头,以此来确认这到底是不是个合法的 ELF 文件,并且解析出它是 32 位还是 64 位。
接着第二步,读取 Program Header Table,映射到内存。
在这个阶段,系统就不看什么 Section 了(也就是编译时给人看的那些段)。系统只看 Program Header。这里面定义了一系列的 Segment。系统找到所有类型为 PT_LOAD 的 Segment(通常至少有两个:一个可读可执行的代码段,一个可读可写的数据段)。
然后,系统利用 mmap 系统调用,根据这些 Segment 要求的对齐方式和大小,在虚拟内存中分配连续的空间,并把磁盘上的 ELF 数据直接映射到对应的内存页上。
到了第三步,真正的带头大哥 linker(动态链接器)就正式接管了。
映射完毕后,linker 会去查找 ELF 里面一个类型叫 PT_DYNAMIC 的特殊 Segment,这里面包含了很多动态链接需要的核心信息表(比如符号表在哪、重定位表在哪等)。
linker 会先处理这个库的依赖关系。它去读取 .dynamic 里面的 DT_NEEDED 项,看看当前 .so 还需要哪些别的 .so 支持(比如依赖了 libc.so)。如果依赖的库还没加载,linker 就会递归地先去走上面那一套流程,把依赖库也用 mmap 加载进来。
第四步,重定位(Relocation)。
这是最耗时也最关键的一步,也就是我们刚才一直在聊的写 GOT 表。
因为模块每次加载的基地址都不一样,所以 .so 里面的全局变量的绝对地址、对外部函数的引用地址,都必须在这一步由 linker 进行修正。linker 会遍历 .rel.dyn(数据段重定位)和 .rel.plt(代码段函数重定位),查符号表算出真实地址,然后把它们填进对应的 GOT 槽位里。
最后一步,执行初始化代码(Initialization)。
当所有的地址都填完,这块代码终于可以正常运行了。但在把控制权交给 App 之前,linker 会去检查 ELF 里面的 init_array(或者旧版本的 .init 段)。
我们在 C++ 里面写的全局变量的构造函数,或者是通过 __attribute__((constructor)) 声明的那些初始化方法,全都是被存放在这个 init_array 里面的。
linker 会挨个去调用这些函数,完成 Native 层的静态初始化。
等这所有的函数都执行完后,整个 ELF 才算是彻底加载并准备完毕了,如果是 loadLibrary,这时候就会去回调 JNI_OnLoad 函数了。
改符号导出表可以吗
关于改符号导出表可以吗这个问题。如果是在问“能不能通过这种方式来实现 Hook”,那结论是:完全可以,而且这种方式有它非常独特的全局级 Hook 优势。 其实这就是我之前提到过的 Symtab Hook(符号表 Hook) 机制。
其实原理刚刚大概带过了一次,如果要进一步从源码层面看细节的话,它是怎么做到的呢?
比如,我想把 libart.so 导出的一个内部函数给 Hook 掉。我不再像 PLT Hook 那样去改别人家 GOT 表里的坑位,而是直接对 libart.so 的 导出符号表(.dynsym) 开刀。
在 ELF 解析出来的内存里,.dynsym 里面是一个个连续排列的 Elf_Sym 结构体。这个结构体里包含 st_name(指向字符串表的偏移量)和 st_value(函数的相对地址或绝对地址)。
当我们确定了目标函数(比如通过查 .hash 表找到了目标函数对应的那个 Elf_Sym 的数组索引)之后,我们就去内存里拿到这个 Elf_Sym 的指针。
把内存权限用 mprotect 改成可写后,我们直接把里面原本的 st_value�,修改成我们自己的 Hook 函数地址与当前 .so 基址的相对差值。
一旦改完之后,会有什么效果呢?
以后只要有任何新加载的 .so 库,或者业务代码里调用了 dlsym(handle, "目标函数名")。系统的 linker 就会去遍历刚才我们篡改过的那个符号表。linker 看到我们改过的那个 st_value,就会把它当作原函数的真实偏移量,加上基址后返回给调用方。
于是,所有试图引用这个导出符号的地方,都会被不知不觉地导向我们的 Hook 函数里。 这比挨个去改调用方的 PLT 表要彻底得多,是一种非常强大的底层操作。
但在实际开发中,如果面试官问我们为什么平时很少看到大家大规模用它,我也会如实回答:因为它的兼容性坑实在太多了。
首先,它只对动态导出的符号有效。如果那个函数在编译时被标记为隐藏(hidden),或者根本没有被放入 .dynsym(比如是一个内部静态方法或已被 strip 掉了),那就没法改。
其次,在 Android N(7.0)之后,系统引入了 Namespace 机制来隔离类库,而且系统的 .so 很多符号表的结构也发生了变化(比如引入了 .gnu.hash 而不再只有 .hash),这就导致你要在内存中健壮地解析并修改系统库的符号表变得极其困难。所以现阶段,对于常规的外部函数 Hook,大家还是首选更加稳定和成熟的 PLT Hook(比如爱奇艺的 xhook 或者字节的 bhook);对付高难度场景,则直接上 Inline Hook,改符号表的方式现在更多是作为某些极其特殊底层的探针方案之一了。
编译打包优化展开讲讲
嗯,好的。关于编译打包优化,其实在大型项目里,这也是咱们工程效能和用户体验非常看重的点。先说结论:编译优化的核心是利用缓存、并发以及减少不必要的编译动作来提升构建速度;而打包优化的核心则是通过代码缩减、资源压缩和动态下发来极限压缩 APK 的体积。
先说说编译速度优化这块吧。在日常开发中,如果每次 run 一下都要等好几分钟,那简直太折磨了。
首先,我会去修改 gradle.properties,把 Gradle 的 Daemon 守护进程打开,开启并行编译(org.gradle.parallel=true),并且把 JVM 的内存分配调大一些。
其次,也是收益最大的,就是模块化改造和按需编译。如果我们的项目是单体大工程,改一行代码就要全量编译,这肯定不行。我会倾向于把底层的基础组件、甚至成熟的业务模块打包成 AAR,发布到内部的 Maven 仓库。平时开发的时候,只把当前正在开发的模块通过 include 引入源码,其他一律依赖 AAR 二进制产物。这样 Gradle 的 Task 依赖图会小很多,编译速度直接起飞。
另外,如果是用 Kotlin 开发的话,我会尽量把之前用的 KAPT 替换成 KSP (Kotlin Symbol Processing)。因为 KAPT 底层需要先生成 Java 的 Stub 代码再走注解处理,非常慢,而 KSP 是直接解析 Kotlin AST,速度一般能提升一倍以上。还有就是利用好构建缓存(Build Cache),包括本地缓存和 CI 机器上的远端缓存。
再说说打包体积优化。
第一步肯定是最直接的,开启 R8/ProGuard 进行代码混淆和压缩。把 minifyEnabled 和 shrinkResources 都设为 true。R8 不仅能把类名方法名变短,更关键的是它有一个 Tree Shaking(摇树)的机制,能通过静态分析把没被调用的无用代码直接剔除掉。
第二步是资源层面的优化。除了把不需要的冗余资源删掉,我们一般会要求 UI 设计师把图片全切成 WebP 格式,或者直接用 SVG 矢量图。如果是必须要保留的 PNG,也会用 tinypng 之类的工具压一遍。在 build.gradle 里,我们可以通过 resConfigs "zh" 来只保留中文语言包,剔除掉第三方库里带的各种乱七八糟的语言。
第三步是 SO 库的优化。现在的手机基本都是 ARM 架构了,所以我们会在 ndk.abiFilters 里面只保留 armeabi-v7a 和 arm64-v8a,甚至为了极致体积,有些 App 会只打单编的包。如果 SO 库还是很大,就可以考虑走动态下发了,也就是把大的 SO 库或者不常用的功能模块(比如动态特性插件)放到云端,等用户用到的时候再下载加载。
其实编译打包是一套组合拳,核心就是对 Gradle 构建生命周期的深刻理解,然后在配置阶段和执行阶段分别去找性能瓶颈。
AB实验了解吗
嗯,了解的。其实像字节、腾讯这样的大厂,基本上所有的业务迭代和产品决策都是数据驱动的。作为 Android 开发,我们日常工作中经常会接触到 AB 实验。
简单来说,平时我们接需求,产品经理可能想上一个新样式的首页,或者换一套新的推荐算法。他们不会凭直觉直接全量上线,而是会要求我们在客户端接入 AB 实验的 SDK(比如字节内部的 Libra 或者火山引擎的 DataTester)。 我们需要在代码里预留逻辑分支,根据服务端下发的实验参数(也就是配置的策略)来决定给当前用户展示 A 样式还是 B 样式。最后配合埋点(Event Tracking)上报用户在这个样式下的点击、停留时长等行为数据,交由数据平台去验证哪个效果更好。可以说,AB 实验已经是现代 App 敏捷迭代的基础设施了。
AB实验原理是什么
关于 AB 实验的具体原理,我觉得核心其实就是四个字:分流和对比。先说结论:AB 实验的原理是通过特定的哈希算法,将大盘的用户流量随机且均匀地分配到不同的实验组,客户端根据命中组别执行对应的代码逻辑,最后回收数据进行统计学上的对比分析。
如果要从端到端的链路展开说,可以分为这几个关键步骤:
首先是分流策略。这是实验平台后端干的事。当一个用户打开 App 时,系统会获取他的唯一标识(比如设备 ID 或者登录后的 User ID)。实验平台会用这个 ID 和实验的 ID 组合起来做一次 Hash 计算(比如用 MurmurHash),算出一个特征值,然后把这个特征值映射到 0 到 99 的桶里。比如规定 0-49 进对照组,50-99 进实验组。为了保证多个实验同时进行而不互相干扰,还会引入“正交分层”的概念,保证流量在不同实验层之间是被均匀打散的。
然后是客户端的获取和路由。在 Android 这边,App 启动或者进入特定页面时,我们会调用实验 SDK 的接口去拉取当前的实验配置。拿到配置后(比如一个 JSON 字典),我们在 UI 渲染或者逻辑处理的地方写 if-else 分支,或者是用工厂模式进行路由。比如 if (experiment_config == "A") { showOldStyle(); } else { showNewStyle(); }。
接着是数据上报。展示完对应的 UI 后,我们要精准地上报埋点。这时候埋点的公共参数里,必须要带上当前用户命中的“实验 ID”和“策略 ID”(也就是所谓的 vid 列表)。这样后端收到埋点数据后,才知道这个点击行为是实验组的人贡献的,还是对照组的人贡献的。
最后是数据分析。这就偏向数据科学了,平台会把两组用户的核心指标(比如留存率、转化率)算出来,并且进行假设检验,看看两者之间的差异是否具有统计学意义,从而指导我们是该把新功能全量上线,还是直接下线砍掉。整个闭环就是这样的。
对照实验原理是什么
其实 AB 实验本质上就是一种对照实验。它的核心科学依据就是控制变量法和大数定律。结论就是:通过构建两个除了我们想要验证的单一变量不同之外,其余所有外部条件和内部特征都完全相同的样本群体,从而推断出这个单一变量是否真的导致了结果的变化。
这里面有几个非常核心的关键点:
首先,就是控制变量。现实世界是极其复杂的,一个 App 今天点击率涨了,可能是因为上了新功能,也可能是因为今天是周末,甚至可能是因为昨天系统发了个大 Push。对照实验的原理就是强行设定一个“基准线”(也就是对照组 Control Group,一般是线上正在运行的老版本)。在这个同一时间段里,对照组和实验组经历的外部环境(时间、节日、网络波动)是完全一致的。这样一来,外部的干扰因素就被抵消掉了。
其次,是随机化(Randomization)和同分布。我们怎么保证被分到 A 组和 B 组的人是“公平”的呢?不能说把活跃用户都分到了 B 组,然后说 B 组数据好,这就成了著名的“辛普森悖论”了。对照实验利用大数定律,当样本量足够大的时候,只要我们的分流算法是完全随机的,那么这两组用户在年龄、性别、机型、活跃度等各个维度的画像分布,必然是极其趋同的。
最后是**反证法(假设检验)**的逻辑。在对照实验里,我们通常会先设立一个“原假设”(Null Hypothesis),也就是假设“新策略和老策略没有区别”。然后我们去收集两组的数据,如果观察到两组的数据差异非常大,大到在“没有区别”的假设下几乎不可能发生,我们就会推翻原假设,从而证明我们的新策略确实产生了实质性的影响。这也就是为什么在做业务迭代时,对照组永远不可或缺的原因。
观察到B比A点击率高0.1%,可以认为B比A好吗
嗯,这个显然是不能直接下结论的。在日常开发看数据看板的时候,我们经常会遇到这种情况。结论是:单纯看绝对数值的差异是没有任何意义的,我们必须要结合“样本量”和“统计学显著性(P值)”来判断这个 0.1% 是真的业务提升,还是仅仅由于随机波动产生的“噪音”。
为什么这么说呢?我可以举个很简单的例子。 假设我们抛硬币,正常来说正反面的概率都是 50%。现在我们抛了 10 次,发现正面出现了 6 次(60%),反面 4 次(40%)。你能说这枚硬币不均匀、正面概率比反面高 20% 吗?肯定不能,因为样本量太小了,这点波动完全是运气问题,也就是统计学上的“偶然误差”。
回到我们的 AB 实验。如果我们的 B 策略相比 A 策略点击率高了 0.1%,但今天这个实验总共才跑了 1000 个用户,那这 0.1% 的差异可能就只是正好有几个人手抖多点了一下而已,换一批人重跑一次,可能 A 就比 B 高了。这种情况下,这 0.1% 就属于“不显著”。
那什么时候这 0.1% 才能说明问题呢?如果我们的大盘流量极其巨大,比如是微信或者抖音,跑了 1 个亿的用户,A 组点击率 5.0%,B 组点击率 5.1%。因为样本量足够庞大,随机误差已经被无限缩小了,这个时候哪怕只有 0.1% 的提升,数据平台也会计算出它的置信区间不跨零,P值极小。这就说明这 0.1% 的确是真实的收益,B 就是比 A 好。
所以,作为开发去验证业务结果的时候,我们绝不能只盯大盘均值,必须要看实验报告上的置信水平(通常要求 )以及置信区间,这样才能避免被数据的偶然性给骗了。
那要百分之多少才没有偶然性
其实这个问题,稍微有点陷阱哈哈。因为并没有一个固定的、普适的绝对百分比阈值可以用来消除偶然性。结论是:有没有偶然性,取决于两者的差异是否达到了统计学上的“最小可微小显著性(MDE)”,而这个标准是由样本量、数据的方差以及我们能容忍的假阳性率共同决定的。
我们可以把这个问题从统计学的角度拆解一下。要判断一个差异是不是偶然的,我们通常会依赖假设检验里面的两个核心概念:P值(P-value) 和 置信区间(Confidence Interval)。
在业界,我们一般约定俗成的标准是:如果计算出来的 ,我们就认为这个结果只有不到 5% 的概率是由于偶然因素导致的,我们就可以拒绝“没有差异”的原假设,认为这个结果是“统计显著”的。这就是你说的“没有偶然性”的量化标准。
那这个 P 值是怎么算出来的呢?它是由你观察到的“提升百分比”和“实验样本量”共同决定的。 如果你的样本量非常小,比如只有一千人,那么由于个体差异带来的方差(Variance)会非常大。这时候,即使 B 比 A 提升了 5% 甚至 10%,由于数据波动太大,P 值可能依然大于 0.05,我们还是认为存在偶然性。 反之,如果你的样本量达到了千万级别,由于大数定律把方差抹平了,哪怕 B 仅仅比 A 提升了 0.05%,这个极其微弱的信号也能穿透噪音被捕捉到,此时 P 值可能远小于 0.05,我们就可以认为它没有偶然性。
除此之外,如果要在实验前就预估我们需要多大的百分比提升才能被测出来,数据科学家会计算一个叫做 MDE (Minimum Detectable Effect,最小可检测效应) 的指标。MDE 的意思是:在当前的流量规模下,如果新版本的提升幅度小于这个 MDE 值,实验是很难从统计上给出确切结论的。
所以,如果面试官非要问我到底差百分之多少才算数,我会回答:这就好比问“多大的网眼能捞到鱼”,不光取决于网眼(百分比差异),更取决于河里鱼的密度和水流的大小(样本量和方差)。必须借助实验平台的 Z检验或 T检验公式,算出 P 值和置信区间。只要置信区间的下限大于 0(比如置信区间是 [0.02%, 0.18%] ),不管它的绝对值有多小,我们都可以从统计学意义上判定,B 确实比 A 好,排除了偶然性。
请求到服务器到返回结果,经历了哪些事情
嗯,这其实是个非常经典的八股文了。如果从全局宏观的角度来看,先说结论:整个过程主要经历了 URL 解析、DNS 域名解析、建立 TCP 和 TLS 连接、发送 HTTP 请求、服务器处理与网关路由、最后服务器返回 HTTP 响应数据并由客户端解析渲染。
因为我是做 Android 开发的,所以我结合咱们平时最常用的 OkHttp 源码,以第一人称视角来串一下这个流程的具体细节。
首先,我们在业务代码里构造一个 Request 对象,然后调用 Call.execute() 或者 enqueue() 发起请求。这时候,请求会进入 OkHttp 的核心调度器 Dispatcher。紧接着,就进入了非常经典的责任链模式,也就是一系列的拦截器(Interceptor)。
在这个拦截器链条里,真正开始和网络打交道的是第一步:DNS 解析。在 ConnectInterceptor(连接拦截器)里,系统会去检查我们要请求的域名有没有被解析成具体的 IP 地址,如果没有,就会触发 DNS 解析逻辑。
拿到 IP 之后,同样是在这个连接拦截器里,OkHttp 会去底层的连接池(ConnectionPool)找有没有复用的连接。如果没有,就会通过底层的 Socket 发起 TCP 的三次握手。如果是 HTTPS 请求,紧接着还会进行 TLS 的四次握手,完成各种加密算法的协商和证书的校验,最终建立起一条安全的 TCP 通道。
建立好通道后,流程流转到下一个拦截器——CallServerInterceptor(请求服务器拦截器)。在这里,OkHttp 会利用 Okio 这个 IO 库,把我们的 HTTP 请求头(Header)、请求体(Body)按照 HTTP 协议的格式,转换成二进制字节流,通过刚才建立好的 Socket 写入到网络底层,发送给服务器。
然后视角切到服务端。请求顺着光缆到达机房后,一般不会直接打到具体的业务机器,而是先经过一层反向代理和网关,比如 Nginx 或者 API Gateway。网关会做负载均衡,把请求路由到真正处理业务的微服务节点上。业务代码处理完逻辑(比如查了 MySQL、读了 Redis),把结果封装成 HTTP 响应报文,原路丢回给网关,再通过 TCP 连接发回给我们的手机客户端。
最后,回到客户端这边。我们的 CallServerInterceptor 会阻塞等待或者通过底层 Socket 的可读事件被唤醒,然后把服务器返回的输入流读取出来。一层层往上冒泡,经过拦截器链的处理(比如处理 Gzip 解压),最终把 Response 对象返回给我们的业务代码。如果是用 Retrofit 或者是 MVVM 架构,这会儿我们拿到 JSON 数据,就会通过 Gson 解析成实体类,接着通过 LiveData 或 Flow 通知 UI 刷新,用户就能看到结果了。
DNS解析是谁做的
这个问题如果深挖的话,其实横跨了应用层、系统 Framework 层和底层操作系统。先说结论:在 Android 客户端发起请求时,DNS 解析首先是由网络库(比如 OkHttp)发起查询,然后层层委托给 Android 底层的 Bionic libc 库和 netd 守护进程,最终由 netd 向上级 Local DNS 服务器或者根域名服务器发起真正的 UDP 网络请求来完成解析的。
具体展开来说,我们可以分层来看。
首先是在应用层。拿 OkHttp 来说,它内部有一个叫做 Dns 的接口,默认实现是 Dns.SYSTEM。当连接拦截器需要 IP 时,它会调用这个接口的 lookup 方法。而这个方法的默认实现,其实就是简单地调用了 Java 层的标准 API:InetAddress.getAllByName(host)。
然后我们顺着这行代码往下沉,就到了 Android 的 Framework 层和 Native 层。InetAddress 底层会通过 JNI 调用到 Android 系统的 C/C++ 运行库,也就是 Bionic libc。在这个库里面,会调用一个叫 getaddrinfo 的 POSIX 标准函数。
其实很多同学以为 getaddrinfo 就是自己去发网络请求了,但在 Android 系统里并不是。为了统一管理网络和做 DNS 缓存,Android 专门设计了一个 Native 层的系统守护进程叫 netd (Network Daemon)。
所以,Bionic libc 里的 getaddrinfo 实际上是通过 Unix Domain Socket 跨进程通信,把解析域名的请求打给了 netd 进程。
接着到了网络层的工作。netd 收到请求后,会先查一下系统本地的 DNS 缓存(也就是平时说的 NSCD 缓存机制)。如果有,直接返回;如果没有,netd 就会封装一个 DNS 查询的 UDP 报文,目标端口是 53,发送给 LDNS(Local DNS,本地域名服务器)。这个 LDNS 地址通常是我们手机连上 Wi-Fi 时路由器 DHCP 分配的,或者是运营商的基站分配的。
最后是 DNS 递归解析和迭代解析。如果运营商的 LDNS 缓存里也没有这个域名,它就会充当咱们的代理,去问根域名服务器,然后再问顶级域名服务器(比如 .com),最后问到这个域名对应的权威域名服务器,拿到最终的 IP 地址,再原路返回给 netd,netd 返回给 App。
另外我还想补充一点,因为传统的 DNS 解析是走 UDP 且容易被运营商劫持(也就是 DNS 劫持),所以在现代 App 开发里,我们往往会自己实现 OkHttp 的 Dns 接口,接入 HTTPDNS。也就是直接通过发 HTTP/HTTPS 请求到大厂(比如阿里云、腾讯云)的专有 IP 去获取域名对应的 IP,这样就完全绕过了系统 netd 和运营商的本地 DNS 解析逻辑,既防劫持又快。
用过微信网页传输助手吗,怎么做一个消息收发的功能
嗯,用过的,平时手机和电脑互传文件或者文字经常用它。关于怎么从零设计这样一个消息收发功能,先说结论:这个功能的核心是基于 WebSocket 构建一条全双工的持久连接,并通过一个全局唯一的身份标识(Session/Token)把网页端和手机客户端绑定在一起,配合服务端的长连接网关和消息队列,实现双向的实时推送和确认。
其实可以把这个系统拆分成三个端来设计:Web 端、服务端和手机客户端。如果我是架构师,我会这么设计它的核心交互流程。
第一步,首先是端与端的绑定(身份认证)。
Web 端打开页面时,先向服务端请求一个带唯一 UUID 的二维码。这时候 Web 端会通过 WebSocket 或者传统的 HTTP 长轮询一直监听这个 UUID 的状态。
然后我们拿起手机,用已经登录的微信客户端去扫这个码。手机端会把自己的 UserID 和这个二维码的 UUID 发给服务端。服务端收到后,在 Redis 里面建立一个映射关系:UUID 绑定 UserID。绑定成功后,服务端顺着刚才 Web 端建立的连接推送一条“扫码成功”的消息,Web 端也就顺理成章地完成了登录,进入了聊天界面。
第二步,也是最核心的长连接建立与消息收发。 为了保证实时的聊天体验,Web 端和手机客户端都需要与服务端保持一条长连接。现在主流的做法都是基于 WebSocket 或者底层的 TCP 协议。 假设我现在在 Web 端发了一条文本消息:“文件.txt”。
- Web 端会把这条消息封装成一个 JSON(包含发送方 ID、接收方 ID、消息内容、一个本地生成的临时消息 ID),通过 WebSocket 发给服务端。
- 服务端收到后,先做落库处理(存入数据库),并且给这个消息生成一个全局唯一的、单调递增的真实 MessageID,然后再回复 Web 端一个 ACK:“我收到了”。这样 Web 端上的消息气泡就去掉了 loading 的圈圈。
- 接着,服务端去内存或者 Redis 里的“路由表”查一下,这台手机当前连在哪个网关节点上。查到之后,服务端把这条消息通过下行通道(手机端的长连接)推送给手机客户端。
- 手机客户端收到后,存入本地 SQLite 数据库,展示在界面上,并且立刻给服务端回一个 ACK。服务端收到手机的 ACK,就知道这条消息送达了。
第三步,应对断网和离线的情况。
如果手机恰好断网了没收到,怎么办?这时候其实就需要引入消息同步机制了。服务端会为每个用户维护一个游标(比如叫 SyncKey 或者最大消息 Seq 号)。当手机重新连上网时,会把自己本地最大的 Seq 发给服务端,服务端一对比,发现手机落后了 3 条消息,就会把这 3 条遗漏的消息一次性打包下发给手机。这也是微信典型的推拉结合(Push-Pull)的同步方案。
总体来说,扫码绑定、双向长连接通讯、完善的 ACK 和离线拉取机制,就是实现这个功能的骨架。
服务端你会怎么设计
如果重点设计服务端的话,结论是:为了应对海量的长连接和高并发的消息吞吐,服务端的设计不能是单体的,必须采用“接入层(长连接网关)+ 逻辑层 + 存储层”的三层微服务架构,并且重度依赖 Redis 和 MQ(消息队列)进行状态管理和系统解耦。
我会把服务端拆解成以下几个核心模块来设计:
首先是 长连接接入层(Gateway / Connection Server)。
这是直面 Web 端和手机端的第一道大门。因为要扛住几十万甚至上百万并发的 WebSocket 或 TCP 连接,我会选择用 Netty 来开发这个网关组件。
这个接入层只干两件事:维持连接和收发数据包。它不能处理复杂的业务逻辑,以防阻塞 NIO 的事件循环。
当手机或 Web 端连上来并认证成功后,接入层网关会在本地内存里维护一个 Channel 映射表。同时,最关键的是,它要去 Redis 里写一条路由记录,比如:UserID: 1001 -> Gateway Node IP: 192.168.1.100。这样全局的系统就知道这个用户连在具体哪台物理机上了。
其次是 逻辑处理层(Logic Server / Message Server)。 这层是无状态的 HTTP 或者 RPC 服务。当接入层收到 Web 端发来的消息后,会直接把消息扔进一个内部的 MQ(比如 Kafka 或者 RocketMQ),然后接入层就不管了。 逻辑层消费 MQ 里的消息,开始干重活:
- 消息去重:根据客户端传上来的临时 UUID 检查一下是不是重复重试的消息。
- 生成全局自增 ID:给这条消息分配一个绝对递增的 Seq ID。这个特别重要,一般会用雪花算法(Snowflake)或者 Redis 自增生成,用于后续客户端排序和防丢失。
- 消息落库:把消息持久化到数据库。
- 消息投递:逻辑层去 Redis 查上面提到的那张“路由表”,发现目标手机连在
192.168.1.100这台网关上。于是逻辑层通过 RPC 调用那台网关的下发接口,让它把消息顺着长连接推给手机客户端。
最后是 存储层(Storage Layer)。 聊天记录属于写多读多、且数据量极其庞大的业务。传统单表 MySQL 肯定扛不住。 如果是小规模,我会用 MySQL 做分库分表,按 UserID 取模路由;如果规模很大,我会直接上 HBase 或者 Cassandra 这种天生支持海量时序数据的 NoSQL 数据库,每一行就是一个对话的消息列,根据 Seq 范围可以极快地拉取历史记录。
另外,还需要设计一个旁路的 离线消息/同步服务。当客户端断线重连主动发请求过来“拉取”时,专门由这个服务根据客户端上报的 SyncKey,从存储层取出增量差异返回给客户端。整个服务端大概就是这样一个高可用、高扩展的设计。
客户端呢
客户端这边的设计,也就是 Android 端,因为咱们前面在简历里提到熟悉 Jetpack 和 MVVM 架构,所以我可以直接套用这套现代 Android 架构来设计。先说结论:Android 客户端需要一个独立在后台运行的长连接模块(Service 配合守护线程)负责通信,本地必须要有一层健壮的 SQLite 数据库做数据源中心(Single Source of Truth),而 UI 层则是纯粹的数据驱动渲染。
我们按照数据流向,从底向上拆解一下。
最底层是 网络与连接管理层。
其实为了防止 App 在后台被杀导致收不到消息,我们通常会把长连接模块放在一个单独的进程,或者启动一个前台 Service。在这个 Service 里,我们会基于 Android 的 NIO 或者 OkHttp WebSocket 封装一个 ConnectionManager。
这个模块必须具备三个核心能力:
- 心跳保活(Heartbeat):每隔几十秒向服务端发一个 Ping 包,服务端回 Pong 包。不仅能防止 NAT 超时路由器掐断连接,还能及时发现断网。
- 断线重连:如果在规定时间内没收到 Pong,或者收到了底层的 SocketException,立马触发重连机制。重连不能死循环狂连,要用指数退避算法(Exponential Backoff),比如隔 1秒、2秒、4秒、8秒重连,防止网络刚恢复就把服务器彻底打死。
- 推拉结合:连上的一瞬间,立马发起一个 HTTP 请求去拉取离线期间丢失的消息。
中间层是 数据存储与 Repository 层。 这一层我是坚定的“本地数据库驱动”倡导者。接收到的任何消息,不管是下推过来的,还是我们自己发出去的,第一步永远是先写进本地数据库。 这里我会用 Jetpack 的 Room。当网络层收到消息并存入 Room 数据库后,Repository 的工作就结束了。
最上层是 UI 与 ViewModel 层(MVVM)。
在 ViewModel 里,我们会通过 Room 暴露的 Flow 或者 LiveData,直接观察数据库里某张消息表的变动。一旦数据库有新数据插入,Flow 会自动发射最新的数据列表给 UI 层。
在 Activity 或者 Fragment 里,我们用一个 RecyclerView 来展示消息气泡。这里有一个极其重要的性能优化点:必须使用 DiffUtil。当收到新列表时,DiffUtil 会在后台线程计算新老列表的差异,然后把这一条新消息以丝滑的动画插入到列表底部,而不是粗暴地 notifyDataSetChanged()。
如果涉及到文件或者图片传输,比如网页端发了个图片过来。长连接里其实是不传大文件的,通道里只传包含图片 URL 的 JSON 文本。客户端拿到 JSON 后,解析出 URL,再去交由 Glide 去发起真正的 HTTP 图片下载。我们自己往外发大文件也是一样的,先通过分片上传到 OSS(对象存储),拿到 URL 后,再把带 URL 的消息告诉长连接网关。
你是怎么想到的,了解过吗
嗯,是的,其实这些设计思路并不全是我凭空捏造出来的,而是我平时主动学习和实践积累的结果。先说结论:我平时非常喜欢阅读大厂的技术博客,特别是仔细研究过微信开源的 Mars 网络库架构以及他们披露的 Sync 消息同步协议,同时,我也在自己的开源项目里亲手撸过一套基于 Netty 的简易 IM 系统,实打实地踩过里面的坑。
其实在我深入看 Android Framework 源码的那段时间,我也对底层网络通信产生了极大的兴趣。我专门去找了微信团队当年在 InfoQ 和微信技术公众号上发的系列文章。
比如刚才我提到的长连接心跳保活和指数退避重连,就是从微信 Mars 库的设计理念里学来的。Mars 里面的智能心跳机制(根据网络环境动态调整心跳间隔)让我印象非常深刻。 还有我反复提到的 Seq 号和 Sync 同步机制。传统的 IM 可能用 HTTP 轮询或者单纯的丢消息,但我看了微信的《微信终端跨平台组件 mars 系列》文章后,了解到他们采用的是类似 Git 版本的 Timeline 模型。服务端只管维护单调递增的序列号,客户端通过比对序列号的 Diff 来做增量拉取。这完美解决了由于网络抖动导致的消息丢失和乱序问题。
纸上得来终觉浅,所以我在大三的时候,自己动手写了一个前后端分离的开源即时通讯小 Demo。 服务端我就是用 Spring Boot + Netty + Redis 写的,客户端用的是 Android 的 MVVM 架构加 OkHttp 的 WebSocket。 在这个过程中,我真实地遇到了“如果我发了一条消息,服务端没给我回 ACK,我该怎么办”的问题。这就逼迫我去实现了本地消息的状态机(发送中、发送失败、发送成功),并且在界面上做出了对应的红色感叹号和菊花 Loading 动画。同时为了解决重连后拉取消息可能产生的“消息重复”问题,我才深刻理解了为什么服务端必须生成一个全局唯一的 MessageID,供客户端做去重(幂等性处理)。
所以,结合我之前对 Android 系统底层比如 Handler 消息机制、Binder 跨进程通信机制的理解,我觉得这种大型系统的设计都是一脉相承的。不管是在 Framework 里 AMS 和应用进程的通信,还是手机客户端和远程服务器的通信,底层都离不开“连接管理、状态同步、可靠投递”这几个核心命题。
学了安卓有什么收获?
嗯,其实回顾这几年学习 Android 的历程,我觉得最大的收获可以概括为两点:一是建立了一套从应用层到底层系统的完整运行思维,二是完成了从“API 调用工程师”到“知其然更知其所以然”的工程能力蜕变。
具体来说,在刚刚接触 Android 的时候,我的视线主要停留在四大组件怎么用、怎么画 UI、怎么发网络请求这些纯应用层的 API 上。但随着学习的深入,尤其是当我开始自己去编译 AOSP 源码,去阅读 Framework 层代码之后,我整个人的技术视野被完全打开了。
比如,以前我只知道 startActivity,但现在我脑海里会自然浮现出应用进程是如何通过 Binder 跟 AMS 通信的,系统是怎么调度栈的,Zygote 是怎么 fork 新进程的;以前画个 View 就完事了,现在我能联想到 SurfaceFlinger 是怎么分配 BufferQueue,WMS 是怎么控制窗口层级,最终图形又是怎么配合 Vsync 信号被硬件合成显示到屏幕上的。这种从上到下的通透感,是我觉得学习 Android 带来最宝贵的技术财富。
另外,从工程能力上讲,Android 这个庞大的生态逼着我去学习了很多优秀的架构思想。比如为了解耦业务,我深入实践了 MVVM 架构和 Jetpack 组件;为了解决异步问题,我系统学习了 RxJava 和后来的 Kotlin 协程;为了做性能优化,我又去研究了虚拟机的类加载机制、内存模型以及各类 Hook 技术。其实我觉得,Android 只是一个载体,通过它我掌握的操作系统原理、跨进程通信模型、UI 渲染管线以及像 Kotlin 这种优秀的现代编程语言,这些硬核知识放在任何大前端或者系统开发岗位上,都是高度相通并且极具价值的。
你提到了kotlin,那说说kotlin协程和线程的区别
好的。协程和线程的区别是 Kotlin 里非常核心的一个知识点。先说最根本的结论:线程是操作系统级别的资源,由操作系统的内核去调度和管理;而 Kotlin 协程是用户态的、语言级别的轻量级任务编排框架,它寄生在线程之上,由 Kotlin 的运行时库(Runtime)在用户态完成调度。
如果我们要详细对比的话,主要体现在三个维度:
首先是调度层级和开销。我们知道,普通线程的创建、销毁以及上下文切换(Context Switch)是非常昂贵的。因为这涉及到从用户态陷入内核态,保存大量的寄存器状态,这属于重开销操作。但是协程不一样,协程的挂起和恢复,本质上只是一个普通的方法返回和状态机的流转,全程都在用户态发生,根本不需要操作系统内核介入。这也是为什么我们可以在一台机器上开一万个甚至十万个协程而不会 OOM,但绝对开不了一万个线程的原因,因为协程太“轻量”了。
其次是执行的控制权。线程的调度是抢占式的(Preemptive),操作系统觉得你的时间片用完了,就会强制把你切走,不管你乐不乐意。但协程是协作式的(Cooperative),也就是“协”这个字的来源。一个协程只有在执行到 suspend 挂起点的时候,才会主动让出执行权,把所在的底层线程交还给线程池,让其他协程有机会运行。
最后是设计理念。其实在底层,Kotlin 协程依然是跑在 Java 线程池里面的。它并没有脱离 JVM 线程的限制。它的本质是把那些原本需要写成 Callback 回调的异步代码,通过编译器的黑魔法,封装成了同步线性的代码结构。与其说协程是一种新的并发模型,不如说它是一个基于线程池的高级 API 封装框架,极大简化了我们处理异步逻辑的复杂度。
用Main调度器会创建线程吗
嗯,直接说结论:使用 Dispatchers.Main 绝对不会创建新的线程。它的底层是复用了 Android 系统已经存在的主线程(也就是 UI 线程)。
这个逻辑其实和 Android 的系统启动流程紧密相关。我们知道,当一个 App 冷启动,Zygote fork 出新进程后,会执行 ActivityThread 的 main 方法。在这个 main 方法里,系统会主动调用 Looper.prepareMainLooper() 和 Looper.loop(),这就把当前线程变成了 Android 的主线程,并且开启了死循环去处理消息。这根线程在 App 的整个生命周期里只会被创建这一次。
那 Kotlin 协程是怎么切到主线程的呢?其实我在看 kotlinx-coroutines-android 这个扩展库源码的时候注意到,Dispatchers.Main 的本质是一个叫做 HandlerContext 的调度器。
当我们在协程里使用 withContext(Dispatchers.Main) 的时候,Kotlin 协程库底层的做法非常简单巧妙:它拿到了 Android 主线程的全局 MainLooper,然后用这个 Looper 实例化了一个 Handler。当协程需要在这个调度器上恢复执行时,它仅仅是把后面要执行的代码块封装成一个 Runnable,然后调用了主线程这个 handler.post(runnable) 方法。
所以,请求会被塞进主线程的 MessageQueue 里,等主线程的 Looper 轮询到这条消息时,直接在主线程回调执行。整个过程完全就是我们最熟悉的 Handler 机制的运用,没有任何新线程的创建。
对比普通线程处理上下文,协程是怎么处理的?
这其实是个非常经典的设计差异。结论是:普通线程处理上下文通常依赖 ThreadLocal(与具体的系统线程强绑定),而协程由于会在多个线程之间来回切换,所以它抛弃了这种绑定,转而设计了一套专属的、类似 Map 结构的 CoroutineContext,在协程挂起和恢复时显式地在代码层面进行传递。
我们在传统的多线程开发中,比如后端的 Spring 框架或者 Android 的一些底层库,如果想在整个调用链路里传递一个用户信息或者请求 ID,通常会塞进 ThreadLocal 里。因为同一条业务链路通常是在同一个线程里跑到底的。
但是,把 ThreadLocal 直接用在协程里是极其危险的!因为协程是非常灵活的。一个协程可能在 Thread A 上开始执行,碰到一个 delay 或者网络请求挂起了;等网络数据回来后,调度器可能会把它扔到 Thread B 上去恢复执行。如果你的上下文保存在 Thread A 的 ThreadLocal 里,那它在 Thread B 上恢复的时候,上下文就彻底丢失甚至串号了。
为了解决这个问题,Kotlin 引入了 CoroutineContext。你可以把它看作是一个以 Element 为节点的高级 Map 集合。这个上下文里存储了协程的各类关键信息,比如它的生命周期管理(Job)、它的运行线程池(Dispatcher)、异常处理器(CoroutineExceptionHandler)甚至协程的名称。
在底层处理上,当我们启动或者切换协程时,这个 CoroutineContext 会被打包塞进一个叫 Continuation(续体)的对象里。无论协程被挂起多少次,又在哪个线程上被唤醒,这个 Continuation 就像是一个随身带的背包一样跟着协程流转。当你调用 coroutineContext 属性时,它其实是从当前这个背包里把之前存的上下文拿出来给你,完美解耦了底层操作系统的具体线程。
你有没有研究过kotlin协程的底层原理
嗯,有深入研究过。其实 Kotlin 协程之所以能用看似同步的代码写出异步的效果,关键在于它并不是一个 OS 层面的黑科技,而是一个纯粹的编译器语法糖。先说结论:Kotlin 协程的底层原理核心是两个词——CPS 变换(Continuation-Passing Style,续体传递风格)和 状态机(State Machine)。
如果我们要扒开编译器的外衣,来看看它到底干了什么,大致是这么个过程:
首先是 CPS 变换。当 Kotlin 编译器在代码里看到带有 suspend 关键字的挂起函数时,它会在编译期偷偷改写这个函数的签名。它会在原有的参数列表最后,强制塞入一个隐藏参数:Continuation<T>。这个 Continuation 就像是一个回调接口,里面有一个 resumeWith 方法。这就把我们写的顺序执行的代码,在底层强行转换成了类似 Callback 嵌套的结构。
然后是最关键的 状态机生成。对于一个包含了多个挂起点(比如调了两次网络请求)的 suspend 函数,编译器会把它内部的代码逻辑拆解,生成一个内部类,通常这个类会继承自 SuspendLambda。
这个内部类里面有一个非常核心的 invokeSuspend 方法,编译器会把原来函数里的代码,用一个大的 switch/case 块包起来。
每一次调用别的 suspend 函数(挂起点),就是一个状态(比如 label = 0, label = 1)。
当代码执行到一个挂起点时,状态机不仅会把当前的局部变量保存到这个内部类的成员变量里(这叫做保存现场),然后还会把当前的 label 值加 1,最后 return 退出当前方法。
等异步结果回来了,回调触发了传入的那个 Continuation 的 resume 方法。resume 内部又会再次调用这个 invokeSuspend 方法。这时候因为 label 变成了 1,switch/case 就会直接 goto 跳转到上一段挂起点后面的代码继续执行,顺便把之前存起来的局部变量恢复出来(恢复现场)。这也就是协程底层的运作骨架。
会挂起线程吗
结论是非常明确的:协程的挂起(suspend)绝对不会挂起或者阻塞它底层的系统线程。这也是协程高并发性能的核心所在。
在传统的多线程模型里,比如我们用 Thread.sleep() 或者通过阻塞式 IO 去读网络流,底层操作系统是真的会把这个线程打入阻塞态(Blocked / Waiting)。这个线程会停在那里死等,既不占用 CPU 继续执行别的指令,但它又霸占着系统的内存和线程表资源。如果这是 Android 的主线程,那直接就 ANR 掉了。
但是 Kotlin 协程里的 suspend,我们翻译成“挂起”,其实我觉得翻译成**“让出”或者“暂停当前任务”**更准确。
当一个协程执行到 delay() 或者发起一个挂起的网络请求时,就跟咱们刚才说的一样,状态机会保存好当前的局部变量,然后直接 return 跳出这个方法。
注意,既然方法都 return 了,说明这段代码对当前这根底层 JVM 线程的占用就结束了!这就好比你在这根线程上执行了一个立刻返回的方法。这根底层线程瞬间就被释放了,它会立刻回到协程的 Dispatcher(也就是底层的线程池)里,去领下一个准备好的协程任务来执行。如果这是主线程,那它 return 之后,马上就可以去处理下一条 UI 绘制或者触摸事件的 Message。
所以,“协程挂起”是对当前逻辑控制流的暂停,而底层的物理线程却依然在欢快地跑着别的东西,完全没有被阻塞。
怎么挂起的
嗯,刚刚说到协程挂起不会阻塞线程而是直接 return,那很多面试官可能会追问:它具体是怎么向运行时库发出“我要挂起并交出控制权”的信号的呢? 先说结论:协程真正的挂起魔法,是依赖于底层一个极度核心的内在函数 suspendCoroutineUninterceptedOrReturn,通过它返回一个特殊的标志位 COROUTINE_SUSPENDED 来实现的。
我们可以推演一下这个过程的具体细节。
当我们在协程里调用一个封装好的异步操作,比如用 Retrofit 发起一个网络请求时。我们在底层往往会使用 suspendCancellableCoroutine 这个高阶函数去桥接传统的 Callback 代码。
这个函数在编译后,内部会调用 Kotlin 标准库极其底层的方法 suspendCoroutineUninterceptedOrReturn。
这个方法做的事情非常巧妙:
第一步,它会暴露出当前协程的隐藏参数,也就是刚才我们说的那个大背包——Continuation 对象。
第二步,它把这个 Continuation 扔给你要执行的异步逻辑。比如你把 Continuation.resume() 写在网络请求的 onSuccess 回调里面,让网络框架持有它。
第三步,也就是最核心的一步:这个异步任务发出去之后(因为是非阻塞 IO 操作,瞬间就发完了),当前这个包装函数会立即向外抛出一个特殊的枚举单例对象:Intrinsics.COROUTINE_SUSPENDED。
当 Kotlin 编译器生成的那个状态机代码(就是那个 switch/case 里的逻辑)看到调用的子函数返回了 COROUTINE_SUSPENDED 这个魔法值时,它立刻就明白了:“哦,这是一个真正的挂起操作,异步任务还没完事儿呢。”
于是,状态机什么多余的废话都不说,直接执行一句 return�,一层一层地退出当前的调用栈,把底层的物理线程给彻底解放出来。
这会儿线程去干别的活了,那我们的代码去哪了呢?别忘了,在第二步里,我们的网络回调里死死地捏着那个 Continuation。
过了几百毫秒,网络响应回来了。底层的网络线程触发了你的 onSuccess 回调,回调里执行了 continuation.resume(响应数据)。
这时候,Continuation 就会根据之前保存的上下文(比如如果配置了 Dispatchers.Main),把这个恢复的任务重新 post 到主线程去。状态机被重新唤醒,拿着响应数据,跳转到刚才挂起的地方继续往下执行。这就完美闭环了整个挂起和恢复的底层链路。
知道Apk包含哪些内容吗
嗯,好的。其实 APK 文件本质上就是一个标准的 ZIP 压缩包,我们平时在终端里直接用 unzip 命令把它解压,就能看到里面的全貌。先说结论:一个标准的 APK 主要包含了代码(dex)、资源(res、assets、arsc)、原生库(lib)、清单文件(AndroidManifest)以及签名信息(META-INF)这五大核心部分。
我可以详细展开说一下每一块的作用和系统是怎么处理它们的:
第一块是最核心的代码部分,也就是 classes.dex 文件。如果是大型项目开启了 Multidex,还会看到 classes2.dex、classes3.dex 等等。这里面装的是我们写的 Java 或 Kotlin 代码经过 D8/R8 编译器编译后生成的 Dalvik/ART 字节码。当 App 运行或者安装的时候,系统的 dexopt 或者 dex2oat 机制就会去读取这些文件,把它编译成机器码以提升运行速度。
第二块是资源文件,主要分为三个部分。首先是 res/ 目录,这里面放的是那些没有被直接编译进索引表里的原始文件,比如大部分的图片(png、webp)和经过二进制编译压缩过的 XML 布局文件。其次是 assets/ 目录,这个目录里的文件系统完全不会去压缩或者编译它们,原封不动地打包进去,我们在代码里可以通过 AssetManager 以字节流的方式直接读取,平时经常用来放一些字体文件、本地 HTML 网页或者大体积的 JSON 配置。最后,也是最重要的数据结构,就是 resources.arsc 文件,它是整个应用的资源索引表,负责把代码里的 R.id 和实际的资源路径或者值映射起来。
第三块是原生库,也就是 lib/ 目录。如果我们在项目里用了 C/C++ 写的 JNI 代码,编译出来的 .so 动态链接库就会按 CPU 架构分门别类地放在这里。比如平时最常见的 armeabi-v7a 和 arm64-v8a。在 App 安装的时候,系统的 PMS(PackageManagerService)会根据当前手机的 CPU 架构,只把对应架构的 .so 库拷贝到系统的数据分区里去。
第四块是全局配置的 AndroidManifest.xml。和我们平时在 AS 里写的明文 XML 不同,打包进 APK 的这个文件是被 Aapt2 编译过的二进制 XML。它包含了包名、版本号、四大组件的声明以及申请的权限。系统在开机扫描或者安装 App 的时候,PMS 就是靠解析这个二进制文件来注册组件和分配权限的。
最后一块是安全相关的 META-INF/ 目录。这里面存放了应用的签名信息,比如 MANIFEST.MF(记录所有文件的哈希值)、CERT.SF(对前者的签名)以及 CERT.RSA(开发者的公钥证书)。Android 系统在安装 APK 时,会通过校验这个目录里的数据来确保应用没有被篡改,并且验证是不是原作者更新了应用(也就是 v1/v2/v3 签名校验机制)。
有没有用打印log去看过arsc索引文件有哪些东西
对,我看过。其实当时主要是为了研究插件化机制和动态换肤的底层原理,因为想要解决宿主和插件资源 ID 冲突的问题,就必须得深入去扒 resources.arsc 这个文件的结构。先说结论:resources.arsc 本质上是一个包含多个 Chunk(数据块)的二进制关系映射表,里面主要存放了全局字符串池、包数据头、资源类型字符串池、资源名称字符串池,以及最核心的 ResTable_type 也就是具体的资源配置和值的映射。
平时如果想看里面的内容,我一般不会直接打 log,而是习惯用 Android SDK 自带的 aapt2 dump resources app.apk 命令把它以可视化的结构导出来,或者用 010 Editor 配合 Android 模板直接看它的二进制十六进制结构。
如果从 C++ 层面的 ResTable 源码结构来剖析,当我们去解析这个文件时,会看到它非常严谨的层级:
首先,文件的最开头是一个 ResTable_header,它描述了整个文件的大小和包含的 Package 数量。紧接着是一个非常大的 Global String Pool(全局字符串池)。这里面其实去重并集中存放了整个 App 用到的所有字符串,包括文本的值(比如 "你好")以及各个资源文件的相对路径(比如 "res/drawable/icon.png")。
然后再往下,就是一个个的 Package Chunk。普通的 App 通常只有一个 Package,它的 ID 默认是 0x7F(如果是 Android 系统底层的 framework-res.apk,ID 就是 0x01)。在这个 Package 数据块里面,又包含了两个非常重要的局部字符串池:
一个是 Type String Pool(类型字符串池),里面存的是 "attr"、"drawable"、"layout"、"string" 这些资源类型;
另一个是 Key String Pool(键名字符串池),里面存的就是我们在代码里定义的资源名字,比如 "app_name"、"activity_main" 等等。
最后,就是真正起到映射作用的各种 Type Chunk 了。在 Android 里,一个资源的 ID 是一个 32 位的 int 值,通常写成 0xPPTTEEEE。PP 就是刚才说的 Package ID,TT 对应 Type ID(比如 03 代表 layout),而 EEEE 就是具体的 Entry ID。
在 Type Chunk 里,它就根据这个 ID,把资源的键名和实际的值关联起来。如果这个资源是一个简单的颜色值或者 bool 值,表里面就直接存这个具体的值;如果这个资源是一张图片或者一个 XML 布局,表里面存的其实是一个索引,这个索引指向文件最开头的那个全局字符串池里的某一个字符串(也就是那个文件的相对路径)。
所以,我看过之后的感受就是,它其实就像是一个经过了高度压缩和去重优化的离线关系型数据库。
从你的角度为什么要用这种方式去组织资源
其实当初我在看 AssetManager 和 ResTable 的源码时也思考过这个问题。其实 Google 之所以不用简单的 JSON 或者纯文本来组织资源,而是设计出这么一套复杂的二进制索引表,先说结论:核心目的是为了在内存占用、查找性能和多设备碎片化适配之间取得一个极其完美的平衡,实现极其高效的 查找和基于 Configuration 的动态路由。
我们可以从三个角度来拆解一下这套设计的精妙之处:
第一个角度是极致的查找性能(运行时效率)。
我们平时在 Java 代码里调用 getResources().getString(R.string.app_name) 的时候,传入的那个 R.string.app_name 其实是一个 32 位的整型常量,而不是一个字符串。
如果系统用文本字典去存资源,那就得做字符串匹配,这在 UI 渲染这种需要极高帧率的场景下是不可接受的。但用了 ARSC 这种组织方式,底层 C++ 代码在拿到 0x7F030001 这个整型 ID 后,直接通过位运算解析出 Package、Type 和 Entry。然后在内存里直接当做数组下标(Index)进行指针偏移操作。这是一种纯正的 内存偏移数组寻址,查找的时间复杂度是绝对的常量级,极其高效。
第二个角度是强大的多配置路由(多维度适配)。
Android 的设备碎片化太严重了,有各种分辨率(hdpi, xxhdpi)、各种语言(zh-rCN, en-US)、甚至是暗黑模式(night)。在这个 ARSC 文件的 Type Chunk 里,同一个资源 ID(比如一张背景图),会对应着多个 ResTable_type 的数据块,每个数据块带有一个 ResTable_config 的标记。
当我们在运行时请求资源时,AssetManager2 的底层实现里,会拿到当前设备的真实硬件配置(Configuration),然后在表里去进行一次高效的优先级匹配算法。它会瞬间从众多配置中挑出最符合当前屏幕密度和语言的那个资源。把这种复杂的路由逻辑全部下沉到二进制文件的结构里,大大减轻了应用层开发的负担。
第三个角度是高度的数据复用和体积压缩。
资源文件里充斥着大量重复的字符串,比如命名空间 xmlns:android 或者某些通用的文本。ARSC 把所有的字符串全部抽离到了全局的 String Pool 里面。不管你在多少个 XML 里面用到了这个字符串,在编译后的二进制产物里,它们全部被替换成了指向 String Pool 的一个几字节的整型索引。这种字典化的压缩方式,让 APK 的体积大幅度减小,同时也对防止小白随手篡改资源起到了一定程度的门槛保护。
所以,这套组织方式实际上是 Android 团队针对移动端资源受限、需求又极度复杂的场景量身定制的一套高性能解决方案。
他不会一次性读出来吧,你知道他怎么读的吗
对,非常敏锐的问题!如果在应用启动的时候把几 MB 甚至十几 MB 的 resources.arsc 一次性全部用 IO 流读进内存并且反序列化成 Java 对象,那不仅启动速度会非常慢,而且直接就会造成严重的内存浪费甚至 OOM。先说结论:系统绝对不会一次性把它读出来。底层真正的玩法是利用 Linux 操作系统的 mmap(内存映射)技术,结合 C++ 结构体指针强转,实现“按需读取”和“零拷贝”的高效加载。
我们深入到 Android Framework 的 Native 层来看一下这个过程。
当我们的 App 进程启动,初始化 AssetManager 的时候,底层的 C++ 代码(主要是 ApkAssets.cpp 和 AssetManager2.cpp)会去打开 APK 这个 ZIP 文件。找到里面的 resources.arsc 块之后,它并不会调用 read() 把它读进缓冲数组,而是直接调用操作系统的 mmap 系统调用。
mmap 的作用是什么呢?它仅仅是在当前进程的虚拟内存空间中,划出了一块区域,然后把这块虚拟内存的地址和磁盘上 resources.arsc 文件的物理位置建立了一个映射表。在这个瞬间,其实没有任何实际的文件数据被加载到物理内存(RAM)里。
那什么时候才会去读呢?
当我们的代码真正执行到 getString(R.string.app_name) 时,系统底层拿着 ID 开始去刚才映射的那段虚拟内存地址里寻址。
这个时候,非常核心的 C++ 技巧就来了:因为 ARSC 文件在编译打包的时候,是严格按照四字节(4-byte)对齐的方式写入的,所以它的内存结构和 C++ 里的结构体(比如 ResTable_header、ResStringPool_header)是完全一模一样的。底层代码拿到内存首地址后,直接加一个偏移量,然后用类似 (ResStringPool_header*) address 这样的方式,直接把这块内存强转成 C++ 的结构体指针来访问里面的字段,完全不需要任何类似于 JSON 解析或者反序列化的过程。
当 CPU 顺着指针去访问这块内存时,由于这块内存的数据目前还在磁盘上,并没有在 RAM 里,这个时候系统 MMU 就会触发一个缺页中断(Page Fault)。操作系统的内核捕捉到这个中断后,才会真正在物理内存中分配一个页(通常是 4KB),然后把磁盘上那 4KB 的数据搬进内存,接着 CPU 继续执行。
这种机制带来了两个极其巨大的好处:
第一是极低的内存占用和启动延迟。你用到哪里,系统就按页去加载哪里,那些一辈子都没用到的生僻语言包和多余分辨率的资源,永远只躺在磁盘上,根本不占用 RAM。
第二是进程间的数据共享。像系统层面的 framework-res.apk 里面的资源,是被 Zygote 进程 mmap 加载的。所有由 Zygote fork 出来的应用进程,它们在物理内存上其实都共享同一份系统资源的页帧,这极大地节省了系统整体的物理内存。
这就是它能够瞬间读取且不撑爆内存的核心底层原理。
微信14亿用户信息,不用数据库,设计一个用纯文件去保存的方案
嗯,其实这个问题本质上是让我们脱离现成的 MySQL 或者 Redis,手写一个极简版的底层存储引擎。面对 14 亿这么庞大的体量,先说结论:为了实现海量数据下的快速查找,我会采用“哈希分目录(打散文件) + 定长记录(Fixed-length Record) + Mmap 内存映射”的方案来设计这个纯文件存储系统。
我们可以先算一笔账。微信有 14 亿用户,假设每个用户的基本信息(比如 UID、昵称、性别、地区、头像 URL 等)我们把它序列化后,固定分配 1024 个字节,也就是 1KB。那么 14 亿用户总共需要大约 1.4TB 的存储空间。
如果直接把这 1.4TB 写进一个超大的 users.dat 文件里,无论是操作系统的文件系统还是我们平时的 I/O 操作,都会直接崩溃。所以,第一步一定是拆分文件。
由于用户的 UID 通常是唯一的整数,我不会用一个大文件,而是通过对 UID 取模或者哈希,把用户分摊到数以万计的小文件里。比如,我会根据 UID 的十六进制前缀,建立两级或者三级目录(类似 00/1A/ 这样的结构),避免单个目录下文件过多导致 Linux 的 inode 检索变慢。
第二步,也是最核心的一点,必须采用“定长记录”来存储每一条用户信息。
如果我们用 JSON 这种变长的数据格式一行行写,那每次想要查找 UID 为一亿的用户,就只能从文件头一行行往下扫描,时间复杂度是极其恐怖的线性的。
但如果我们强制规定每个用户的数据就是严格的 1024 字节。那么当我们要找文件里的第 个用户时,可以直接算出他在文件里的物理绝对偏移量:Offset = N * 1024。
拿到偏移量后,在代码层面,我就不需要读前面的数据了,直接调用系统的 fseek(),或者在 Java 里面用 RandomAccessFile.seek(),一步到位把文件指针跳到那个位置,直接读出 1KB 数据。这样查找的时间复杂度就被降到了完美的常量级。
最后,考虑到这套系统的 I/O 效率,我会借鉴我在看 Android 底层 Binder 和 MMKV 源码时学到的思路——引入 mmap(内存映射)机制。
因为传统的 read() 和 write() 需要在内核空间和用户空间之间做两次数据拷贝。如果我用 mmap 将这些分块的用户文件直接映射到进程的虚拟内存空间,那么对用户信息的读写,在代码里就变成了直接操作内存数组,而且缺页时系统会自动把磁盘数据加载到内存,这比传统的 File I/O 要快非常多。
如果要修改信息呢
好的。其实在定长记录的设计下,修改信息相对来说比较直观,但也要分情况讨论。先说结论:对于定长字段的修改,直接通过计算偏移量进行“原地覆盖(In-place Update)”;但对于超出预留长度的变长字段,必须引入“追加写(Append-only)配合墓碑机制(Tombstone)”来解决。
具体来说,第一种情况最简单,也就是字段没有超出预留空间。
比如刚才提到的,每个用户固定占 1024 字节。假如用户只是改了个性别,或者昵称从“张三”改成了“李四”。因为总长度没有超过 1024 字节,我只需要像刚才查找那样,算出 Offset,直接把指针移过去,将新的序列化字节流原地覆盖写进这 1024 字节的空间里即可。这种方式没有碎片,效率最高。
但是,第二种情况就比较棘手了。假设用户原本的名字很短,但他突然把个人签名或者名字改得非常长,导致修改后的信息有 1500 字节,原本的 1024 字节塞不下了。 因为文件是连续存储的,我们绝对不能把后面的所有用户数据往后挪 500 个字节,那样的 I/O 开销是灾难性的。
所以,我会在原本的设计中引入一个逻辑删除和追加的机制。 我会给每个用户的记录头部预留几个字节的 MetaData(元数据),里面包含一个特定的标志位(比如 1 代表有效,0 代表已删除,也就是墓碑标志),以及一个指向新数据的“指针”字段。
当发生越界修改时,我先定位到老数据的位置,把它的墓碑标志位置为 0(标记为已删除)。然后,我把更新后的这 1500 字节新数据,直接**追加(Append)**写到当前文件的最末尾。 为了下次还能找到他,我需要在这个文件对应的一块专门存放“索引表”的区域(或者内存里),把该 UID 对应的 Offset 指向这个文件最末尾的新地址。其实我在研究腾讯微信开源的 MMKV 时发现,它底层就是极度依赖这种 Append-only 的追加写方式来保证极速写入的,只不过它会在达到一定阈值后做一次全局的数据重整。
你得分页吧,基于这种分页结构,你还会怎么去完善它
对,面试官您说到了非常底层且关键的点!单纯靠算出偏移量一条条去读写,在面对海量并发或者复杂查询时,依然会导致极为频繁的磁盘小 I/O 和缺页中断。先说结论:为了完善这个存储引擎,我会全面引入操作系统和现代数据库(如 InnoDB)都在使用的“分页管理(Page/Block)”思想,并配合构建“Page Cache(页缓存)”和“页内目录”。
我会这么去完善它:
第一步,重构文件的物理结构,把按条存储改为按页(Page)存储。 我会规定文件被划分为固定大小的页,比如 16KB 一页(和 MySQL 一样)。那么一页里面大概能装 15 条刚才说的 1024 字节的用户记录,还会剩下一点空间。 这就意味着,以后磁盘 I/O 的最小单位不再是单条记录,而是 16KB 的页。哪怕我只读一个用户的性别,系统底层也是把这包含 15 个用户的 16KB 整个页给拉进内存里。根据计算机科学里的局部性原理,很有可能接下来要读的用户就在同一页里,这样就极大减少了后续的磁盘 I/O。
第二步,在页的内部构建“页内目录(Page Directory)”。
刚才说一页装了 15 条记录,有些记录可能被删了(打上了墓碑),有些可能是变长的。如果在这 16KB 里线性遍历去找某个 UID,还是有点慢。所以我会利用那剩下的几百个字节,在每一页的尾部或者头部建一个稀疏索引数组(类似于一个小型的 B+ 树叶子节点)。里面记录着 [UID -> 页内相对 Offset]。这样哪怕一页里面有几百条小记录,我也能通过二分查找瞬间定位。
第三步,在应用层建立 LRU Page Cache(页缓存)池。 因为磁盘太慢了,我会向系统申请一块比如 2GB 的常驻内存作为缓冲池。每次从文件中读出一个 16KB 的页,我就把它丢进这个基于双向链表和哈希表实现的 LRU Cache 里。 下次如果有请求过来,先用 UID 算出它属于哪个页号,去 Cache 里查。如果命中了(Cache Hit),就直接在内存里读写,速度堪比 Redis;如果没命中,再去磁盘里把整页捞出来替换掉 LRU 尾部的冷数据。只有当页内数据被修改了(变成了脏页 / Dirty Page),后台线程才会异步地把这整页刷写(Flush)回磁盘。这样整个系统的吞吐量就彻底被盘活了。
这样每页都会有碎片,你不能每次都开一个新页吧,太浪费了,怎么找空页
没错,一旦引入了刚才说的更新时的“墓碑机制”以及数据页的管理,那些被标记删除的记录就会变成一个个空洞(碎片)。如果每次变长追加都去申请新页,文件很快就会像气球一样膨胀,磁盘空间会被严重浪费。先说结论:为了高效找到并复用这些空洞,我会设计一套全局的“空闲空间位图(Free Space Map, FSM)”配合局部的“空闲链表(Free List)”,同时加上后台的 Compaction(碎片整理)机制。
我们从上往下看这个查找空页的机制:
首先是在全局 / 文件级别,引入 FSM(空闲空间位图)。
如果我们在插入新数据时去一个个页扫描有没有空间,那就太蠢了。所以,我会在文件的头部开辟几个单独的元数据页(Meta Page),专门用来存放 位图(Bitmap)。
比如,我可以用 2 个 Bit 来代表一个 16KB 数据页的空闲程度:00 代表空余大于 75%,01 代表空余 50%,10 代表空余 25%,11 代表页已经写满了。
当我们需要新开辟空间去写入一条修改后的长数据时,我们只需要把文件头的这个位图读进内存,做极其快速的位运算扫描。因为位图极其紧凑(几个字节就能表示几十个页的状态),我们能瞬间锁定那个处于 00 状态的页号。这就解决了“去哪找空页”的问题。
然后是在页内级别,引入空闲链表(Free List)。
当我们定位到了一个有空闲空间的页,并且把它加载进内存后,怎么知道页里面哪一段是原本被删除的碎片呢?
在这个 16KB 页的 Header(页头)里,我会维护一个指针,指向该页内部的第一个被删除的记录。而这个被删除的记录的原有空间的前几个字节,我会把它改写成一个 Next 指针,指向下一个被删除的记录。这样,所有碎片空间就被穿成了一个单向链表。
每次要在页内复用空间时,我只需要从这个链表头部摘取一块能容纳新数据的碎片即可。其实我在学习 Android 虚拟机 ART 垃圾回收机制的时候发现,底层的内存分配器(比如 dlmalloc 或者 jemalloc)管理小内存碎片用的正是这种 Free List 思想。
最后是兜底的 Compaction(碎片整理)。 如果页内的碎片太碎了,比如连续几个 10 字节的空洞,塞不进一条 100 字节的新记录,那位图和链表也救不了。所以必须有一个类似 JVM 垃圾回收的后台低优先级线程。它会在凌晨系统闲时,把那些碎片化严重的页读进内存,把活着的数据按顺序紧凑地挪到一起,把空洞全部挤压合并到页尾,然后再写回磁盘。这就是我们常说的 Vacuum 或 Trim 操作。
这是一个好的设计。那并发读写效率差怎么解决
是的,在单线程下这个模型已经很完美了,但微信这种体量并发极高。如果我们对一整个大文件加一把排他锁(互斥锁),那 14 亿用户的读写请求都会排队,系统瞬间就崩了。先说结论:为了极致提升并发读写效率,必须把锁的粒度细化到“页级读写锁(Page Latch)”,同时最关键的是要引入“预写式日志(WAL)”和“多版本并发控制(MVCC)”机制,实现读写无锁化分离。
针对并发效率问题,我的解决策略分为这几个层次:
第一层:粒度细化,使用分段锁和页级读写锁。
我们绝对不锁文件。既然系统已经分了 16KB 的页,并且缓存在了 Page Cache 里,那么锁的最小粒度就应该是页(Page)。
对于内存里的每一个 Page,我们给它配一把 ReadWriteLock(读写锁)。当多个请求都在读同一个页里的不同用户时,大家上的都是读锁,读读不互斥,完全并发。只有当某个请求需要修改这页里的数据时,才会短暂地升级为写锁。这样冲突的概率就被大大降低了。
第二层:化随机写为顺序写,引入 WAL (Write-Ahead Logging)。
磁盘最怕的就是高并发的随机写。如果一万个用户并发修改信息,我们要去磁盘的一万个不同位置写数据,磁头寻道时间会拉满。
所以我会在设计中加入 WAL 机制。当有并发修改请求来时,我先不管那个数据最终到底在文件的哪个偏移量,我直接把修改的指令(比如“把 UID=100 的名字改为李四”)序列化后,以纯顺序追加(Append)的方式,极速写入到一个单独的 Log 文件中。
因为顺序写磁盘的速度极其快(甚至接近写内存),请求只要写完了 Log 就可以立刻返回给客户端说“修改成功”。至于真正的数据文件里的那个页怎么更新,交由后台线程慢慢从 Log 里回放并合并到主数据文件中去。其实在 Android 开发里,我在优化 SQLite 性能时,如果开启了 enableWriteAheadLogging(),并发性能就能成倍飙升,底层正是这个原理。
第三层:终极杀招,引入 MVCC(多版本并发控制)。 就算是有了读写锁,如果一个线程在写某页,另一个线程想读这页,依然会被阻塞。为了彻底不阻塞,我们可以借用 MVCC 思想。 当用户更新信息时,我们不直接在原本的内存地址上覆盖,而是拷贝一份旧数据,在这份拷贝上修改,并且给它打上一个新的版本号(Transaction ID)。 这样,正在执行的写操作依然在一个新版本上工作,而此时进来的海量读请求,根本不需要等写锁释放,它们可以直接顺着指针去读那个未被修改的、历史版本的旧数据快照。通过牺牲一丁点内存,换取了“读写互不阻塞”的巅峰并发效率。
怎么分
嗯,14 亿用户的 1.4TB 数据,再加上高并发,单靠一台机器的一个文件系统,不管引擎写得多好,CPU、内存带宽和网卡 I/O 也早就被彻底打满、成为物理瓶颈了。所以数据必须“分”。先说结论:对于这套系统,我会采用“自顶向下的多级分片架构”:在集群层面使用“一致性哈希(Consistent Hashing)”进行跨机器分片,而在单机节点内部则采用“Range(范围)与 Hash(哈希)结合”的策略进行文件目录分片。
我们可以把“怎么分”的逻辑拆解成横向和纵向两部分来看:
第一部分是横向的分布式机器级拆分。
当用户的请求通过网关打过来,我们首先面临的是把这个 UID 路由到哪一台具体的存储服务器上。这里我绝对不会用简单的 UID % N(N 是机器数)。因为微信的数据是一直在增长的,如果机器不够了需要加机器,N 一变,之前算好的取模映射就全作废了,会导致全网级别的数据大迁移(缓存雪崩)。
所以我会在网关层引入一致性哈希算法。把 14 亿 UID 映射到一个 的哈希环上,所有的存储机器节点也通过 IP 的哈希值映射到这个环上。一个 UID 顺时针找,遇到的第一台机器就是它归属的存储节点。
为了防止某些机器性能太好或者太差导致的数据倾斜,我还会为每台物理机配置几十个虚拟节点(Virtual Nodes),均匀地穿插在环里,确保 14 亿用户的请求在几十台甚至上百台机器间被完美打散。
第二部分是纵向的单机内部文件级拆分。
当请求被路由到一台特定的机器上(假设这台机器分到了两千万用户),这台机器本地的磁盘该怎么存?
如果我们把两千万用户的数据塞进一个大文件,即便有了分页,在进行文件修复或者备份迁移时,也会因为单体太大而极其痛苦。
所以,在单机内部,我会采用 Range(范围分片)为主。比如,机器内部按照 UID 大小,把每 1000 万个 UID 划分为一个独立的文件系统或者物理文件。
比如这台机器只负责 1000W ~ 3000W 这个区间。这 1000 万个用户的操作都落在一个独立的文件里。如果某个区间的用户极其活跃(比如热点明星 UID,也就是所谓的“热点数据倾斜”),那这个范围的读写就会爆掉。为了打平这种倾斜,我可以在 Range 的基础上再加上一层小粒度的 Hash 算法(比如把这个文件的读写操作通过 UID % 1024 进一步打散到更细的 1024 个小文件中去)。这样,14 亿用户的数据既能在全局保证高可用和动态扩缩容,又能在单机做到并发和 I/O 隔离。
快手客户端 编辑器部门
讲讲四大组件
嗯,好的。其实 Android 的四大组件也就是 Activity、Service、BroadcastReceiver 和 ContentProvider,它们构成了整个 Android 应用的骨架。在我看来,四大组件的核心设计思想是解耦和跨进程通信。这也是为什么我们不能像普通 Java 类那样直接 new 它们,而是必须通过系统服务(比如 AMS/ATMS)来调度和生命周期管理。
我挨个简单说一下吧。首先是 Activity,它是我们最熟悉的,主要负责和用户进行 UI 交互。我们在屏幕上看到的每一个页面基本都是 Activity。从 Framework 的角度看,Activity 的管理是非常复杂的,现在的版本主要由 ATMS(ActivityTaskManagerService)里的 ActivityRecord、Task 还有 TaskDisplayArea 构成的树状结构来管理它的层级和栈。
然后是 Service,它主要用于在后台执行长时间运行的任务,没有用户界面。比如后台播放音乐,或者后台下载文件。在源码里,Service 的调度由 AMS 内部的 ActiveServices 这个类专门负责,它会维护一个 ServiceRecord 来记录 Service 的状态和绑定的客户端。
第三个是 BroadcastReceiver,也就是广播接收器。它其实是一个典型的发布-订阅模式,主要用于组件之间,甚至应用之间的消息通信。广播分静态注册和动态注册。我在看源码的时候注意到,动态注册的广播最终会把一个 ReceiverDispatcher 注册到 AMS 的 BroadcastQueue 里,当有广播发送时,系统会根据是无序还是有序广播,通过 Binder 回调到应用进程去做处理。
最后是 ContentProvider,它主要是用来在不同的应用程序之间共享数据的。比如我们去读取系统的通讯录、相册,用的就是它。底层其实还是封装了 Binder 通信,通过 URI 来做数据的标识和路由。在 AMS 这边,它对应的是 ContentProviderRecord,并且系统做了很多缓存机制来优化 Provider 的跨进程获取速度。
总结一下的话,四大组件其实就是 Android 系统提供给我们的四种不同的“入口”,系统通过底层的 Binder 机制把它们串联起来,屏蔽了复杂的进程创建和通信细节,让我们可以专心写业务逻辑。
Service的几种启动方式和区别
好的。其实 Service 主要有两种标准的启动方式:startService 和 bindService。它们最大的区别就在于生命周期的控制权以及和客户端的耦合度。
先说 startService 吧。这种方式启动的 Service 生命周期是独立的。当我们调用 startService 时,如果 Service 还没创建,就会先走 onCreate,然后走 onStartCommand。重点是,一旦它启动了,它就会一直在后台运行,哪怕调用它的那个 Activity 已经被销毁了,它还是活着。除非我们自己调用 stopService 或者在 Service 内部调用 stopSelf,它才会走 onDestroy 被销毁。在 Framework 层,AMS 的 ActiveServices 会把这个 Service 标记为 started 状态。
然后再说 bindService。这种方式更像是客户端和服务端建立了一个“契约”或者说连接。当我们调用 bindService 时,走完 onCreate 后,会回调 onBind 方法,并返回一个 IBinder 接口给客户端,这样客户端就可以直接调用 Service 里的方法了。它的特点是生命周期和客户端绑定。如果客户端调用了 unbindService,或者客户端自己死掉了(比如 Activity 销毁),系统会自动帮我们解除绑定。当所有的客户端都解绑后,Service 就会回调 onUnbind,然后走 onDestroy 销毁。
其实在实际开发中,我们还经常遇到混合使用的情况。比如我之前做过一个音乐播放器的开源项目,后台播放音乐的 Service 就是先通过 startService 把它启动起来,确保用户哪怕退出了 App 界面,音乐还能继续播。然后当用户打开播放界面时,我再通过 bindService 去绑定它,拿到 Binder 句柄去控制暂停、播放、切歌。最后退出界面时就 unbind,但 Service 因为之前被 start 过,所以依然存活。
从底层调度的角度看,startService 的逻辑相对简单,就是发个请求给 AMS 跑起来;而 bindService 比较复杂,AMS 内部需要维护一个叫 ConnectionRecord 的对象,用来记录到底有哪个应用进程的哪个客户端绑定了这个 Service,从而精准控制引用计数和生命周期。
Activity生命周期
嗯,Activity 的生命周期是 Android 基础里的重中之重。其实它的生命周期方法是成对设计的,主要是为了让系统知道我们当前的 UI 处于什么状态,从而决定资源的分配。
我们按照一个正常的启动到销毁的流程来说吧。
首先是 onCreate,这是生命周期的起点,主要用来做初始化工作,比如我们最常用的 setContentView 就是在这里调用的。接着是 onStart,这个时候 Activity 已经可见了,但是还没有获得焦点,也就是用户还不能和它交互。紧接着系统会调用 onResume,到了这里,Activity 就真正来到了前台,拥有了窗口的焦点,用户可以点击按钮、输入文字了。
然后是退出或者被遮挡的过程。如果此时弹出了一个不是全屏的弹窗(比如一个透明主题的 Activity),当前的 Activity 就会走 onPause。这个时候它依然可见,但失去了焦点。这里有一个非常关键的面试高频点:onPause 里面绝对不能做耗时操作。因为从 Framework 源码来看,比如 A 启动 B,系统必须等 A 执行完 onPause,并且跨进程通知回 AMS 之后,AMS 才会去调度 B 的 onResume。如果在 A 的 onPause 里做耗时操作,会直接卡住 B 的显示,导致严重的卡顿。
如果新弹出的 Activity 把老的 Activity 完全遮挡住了,老的就会接着走 onStop。在这个阶段,Activity 就完全不可见了。我们通常会在 onStop 里做一些比较重的资源释放,比如停止动画、取消网络请求,或者把用户的进度持久化到数据库。
如果用户从前台再回到这个老页面,它就会走 onRestart,然后再依次重新走 onStart 和 onResume。
最后是 onDestroy,当用户按下返回键,或者我们代码里调用了 finish 方法,Activity 就会被销毁,走到生命周期的终点,在这里我们会清空所有的回调、注销广播,防止内存泄漏。
其实,到了 Android 9 之后,系统底层的生命周期调度做了重构,引入了 ClientTransaction(客户端事务) 机制。AMS/ATMS 不再像以前那样分别发跨进程通信来调用各个生命周期,而是把请求打包成一个事务(比如包含一个 LaunchActivityItem 和一个状态目标),然后一次性发给应用进程的 ActivityThread。应用侧拿到后通过一个叫 TransactionExecutor 的类去自己计算并执行生命周期的流转,这样极大减少了 IPC 的开销。
Fragment生命周期
好的。Fragment 的设计初衷是为了在大屏幕(比如平板)上更好地复用 UI 组件,所以它必须依附于 Activity 存在。也正因为如此,它的生命周期其实是受到宿主 Activity 严格控制的,但同时它又多了一些自己特有的视图生命周期。
它的生命周期可以分为两类:一类是和它自身实例相关的,另一类是和它的 View(视图)相关的。
当 Fragment 被添加进宿主时,首先走的是 onAttach,这时候它和 Activity 建立了关联,我们可以通过 getContext() 拿到宿主的 Context。然后走 onCreate,进行实例的初始化。
紧接着就进入了视图相关的生命周期:onCreateView 是最核心的,我们需要在这里把布局的 XML 文件 inflate 成一个 View 返回给系统。然后是 onViewCreated,这时候视图已经创建好了,我们通常在这里做 findViewById、设置监听器、初始化数据这些操作。
接下来的生命周期就和宿主 Activity 保持同步了:Activity 走 onStart,Fragment 就走 onStart;Activity 走 onResume,它也走 onResume。
当页面退出或者 Fragment 被替换掉的时候,走法是相反的。先是同步的 onPause 和 onStop。
然后关键点来了,Fragment 会走 onDestroyView。这时候 Fragment 的视图被移除了,相关的 View 会被垃圾回收。但是!Fragment 的实例有可能还在。比如我们使用了 Fragment 返回栈(Back Stack),当被替换到后台时,它只会走到 onDestroyView。
如果它真的被彻底移除了(比如宿主销毁,或者没有加到返回栈),它才会继续走 onDestroy,最后走 onDetach 和 Activity 解除绑定。
其实,结合我平时用的 Jetpack 框架来看,Fragment 的双重生命周期经常导致一个问题:就是 LiveData 观察数据时的内存泄漏。因为 View 可能被销毁重建(走了 onDestroyView 后又回来走 onCreateView),但 Fragment 实例没死。如果我们传 this 作为 LifecycleOwner 给 LiveData,就会导致旧的 Observer 无法移除。所以 Google 在后来强推了 viewLifecycleOwner,让我们在观察数据时严格绑定视图的生命周期,这就是深刻理解 Fragment 源码和生命周期分离机制后的一个最佳实践。
Activity和Activity,Fragment之间怎么数据传递
嗯,日常开发中不管是哪种架构,数据传递都是必不可少的。结合我现在习惯使用的 MVVM 架构以及底层的限制,我把它们分场景来说。
第一种场景:Activity 和 Activity 之间的数据传递。
最传统也最直接的方式就是通过 Intent 和 Bundle。我们在启动页调用 putExtra,在目标页调用 getIntent().getXXXExtra。这种方式非常简单,但它有个致命的弱点:数据大小受限。
因为 Activity 启动要经过底层的 AMS,这是一个跨进程的 IPC 通信。Binder 驱动为每个进程分配的内存池默认最大只有 1MB-2MB,而且是共享的。所以如果用 Intent 传了一张大 Bitmap 或者超大的列表,直接就报 TransactionTooLargeException 崩溃了。
所以如果遇到需要双向传递或者稍微复杂点的回调,以前老办法是 startActivityForResult,但现在早就被废弃了,我现在用的都是 Jetpack 提供的 ActivityResultLauncher,用起来非常解耦。
第二种场景:Activity 传递数据给内部的 Fragment。
这也是最常见的,一般我们在创建 Fragment 的时候,会通过 Fragment.setArguments(Bundle) 把数据塞进去。这样做的好处是,当系统因为内存不足回收重建(也就是发生 configuration change)时,Fragment 的底层源码内部会自动帮我们把 Arguments 里的数据恢复回来,如果我们用普通的方法 setter 传值,重建时数据就丢失了。
第三种场景:Fragment 和 Fragment 之间,或者 Fragment 回传给 Activity。
在早期的开发中,大家喜欢让 Activity 实现一个接口,然后 Fragment 强转 Activity 去调接口;或者粗暴点用 EventBus。
但在我学习并实践了 Jetpack 之后,最优雅的方案绝对是使用作用域为 Activity 的 ViewModel。
因为在 ViewModelProvider 获取 ViewModel 时,如果我们传入的是宿主 Activity 作为 ViewModelStoreOwner,那么这个 Activity 下的所有 Fragment 拿到的其实是同一个 ViewModel 实例。
我们在 ViewModel 里定义几个 LiveData 或者 Kotlin 的 StateFlow,一个 Fragment 更新数据,另一个 Fragment 或者 Activity 只要在监听,就能立刻响应。它不仅完美解耦,而且完全不用担心生命周期导致的空指针或内存泄漏。
当然,针对 Fragment 之间的简单临时数据通信,我现在也会用 AndroidX 后来出的 Fragment Result API(setFragmentResult 和 setFragmentResultListener),基于底层的 FragmentManager 做事件分发,用起来比接口轻量,比 ViewModel 也简单,非常适合弹窗 Fragment 选完数据后回传给列表 Fragment 这种场景。
其实总结下来,数据传递的发展趋势就是从基于底层 IPC 和强耦合的回调,慢慢演变成了基于架构组件(ViewModel/LiveData)的响应式、生命周期安全的数据流驱动。
写过自定义View吗,要重写什么
嗯,写过的。其实在日常开发中,不管是做一些炫酷的 UI 动效,还是为了优化复杂布局的渲染性能,都会用到自定义 View。如果要概括的话,自定义 View 的核心就是搞定测量(Measure)、布局(Layout)和绘制(Draw)这三大流程。
具体要重写什么,主要看我们自定义的需求属于哪一类。我一般把它分为两类:自定义 View 和 自定义 ViewGroup。
首先说自定义 View,比如我们要画一个特殊的仪表盘或者统计图表。这时候最核心要重写的就是 onMeasure 和 onDraw。
在 onMeasure 里,我们需要处理 MeasureSpec 的几种模式。其实系统默认的 super.onMeasure 在处理 AT_MOST 模式(也就是我们在 XML 里写 wrap_content 时)会表现得和 match_parent 一样大。所以我在看代码的时候,一般都会重写它,判断如果 specMode 是 AT_MOST,就给它设定一个默认的宽高值,最后调用 setMeasuredDimension 把测量好的宽高保存下来。
然后就是 onDraw,这里是真正干活的地方。系统会传给我们一个 Canvas(画布),我们会配合 Paint(画笔)来画图。比如用 drawPath 画不规则形状,用 drawArc 画圆弧。为了避免在绘制时产生内存抖动,我通常会把 Paint 和 Path 的实例化放到初始化方法里,绝对不会在 onDraw 里去 new 对象,因为 onDraw 会被系统频繁调用。
然后是自定义 ViewGroup,比如我们要做一个流式布局(FlowLayout)。这种情况下,重点要重写的是 onMeasure 和 onLayout。
在 onMeasure 里,我们要去遍历所有的子 View,调用它们的 measure 方法,根据子 View 的大小和我们自己的布局规则,计算出这个 ViewGroup 最终应该有多大,再保存下来。
接着在 onLayout 里,这也是 ViewGroup 必须实现的一个抽象方法。我们再次遍历所有的子 View,根据我们在 onMeasure 里算好的位置,调用每个子 View 的 child.layout(l, t, r, b) 方法,把它们一个个放到屏幕的对应位置上。
另外,从 Framework 的视角来看,自定义 View 的这三个流程,其实都是由 ViewRootImpl 的 performTraversals 方法自顶向下发起的。所以我们重写的这些方法,本质上也就是在参与整个 View 树的渲染回调。如果有自定义属性的需求,还会重写构造方法,通过 TypedArray 去解析 XML 里传进来的自定义参数。
事件分发机制
关于事件分发机制,其实这是 Android UI 系统里非常经典的设计。用一句话总结它的核心结论就是:事件分发是一个呈现“U型”的责任链模式,经历了由外向内的传递,以及由内向外的处理过程。
如果我们从底层 Input 系统往上说,当我们的手指触摸屏幕,屏幕硬件产生中断,底层的 EventHub 读取后交给 InputDispatcher,然后通过 Socket 跨进程发送给应用进程的 ViewRootImpl。在应用侧,这被封装成了一个 MotionEvent。
在咱们应用层的代码里,事件主要是通过三个核心方法来流转的:dispatchTouchEvent(分发)、onInterceptTouchEvent(拦截,只有 ViewGroup 有)和 onTouchEvent(消费处理)。
整个流程是这样的:事件首先会来到 Activity 的 dispatchTouchEvent。Activity 会把事件交给内部的 PhoneWindow,然后传递给 DecorView,接着就一路往下传到了我们布局的最外层 ViewGroup。
当事件来到 ViewGroup 时,会先调用它的 dispatchTouchEvent。在这个方法里,系统会去问 onInterceptTouchEvent:“这个事件你想不想自己处理?”。
如果 ViewGroup 返回 true 表示拦截,那么这个事件就不会再往下传了,而是直接交给 ViewGroup 自己的 onTouchEvent 来处理。
如果返回 false 表示不拦截,那么 ViewGroup 就会倒序遍历(其实就是根据 Z 轴从上到下)它所有的子 View,找到手指触摸区域的那个子 View,然后调用子 View 的 dispatchTouchEvent,把事件传递下去。
等事件传到底层的 View(比如一个 Button)时,View 没有拦截方法,它只能在 dispatchTouchEvent 里处理。它会先检查有没有设置 OnTouchListener,如果设置了并且 onTouch 返回了 true,事件就被消费了;如果返回 false,它就会去调用自己的 onTouchEvent。
如果在 onTouchEvent 里(比如我们点击了 Button)返回了 true,那这个事件就彻底被消费了,传递结束。如果连底层的 View 都不愿意处理,返回了 false,那么这个事件就会像冒泡一样原路返回,去调用父容器的 onTouchEvent,如果都不处理,最后会回到 Activity 的 onTouchEvent。
这里面有个非常关键的细节我印象很深,就是 ACTION_DOWN 事件是极其特殊的。它是整个触摸序列的开始。在 ViewGroup 的源码里,只有子 View 消费了 ACTION_DOWN,ViewGroup 才会把这个子 View 记录到 mFirstTouchTarget 这个链表里。后续的 MOVE 和 UP 事件到来时,ViewGroup 就会直接把事件交给 mFirstTouchTarget,而不会再去遍历所有子 View 了。这也是为什么如果一个 View 不消费 DOWN 事件,后续的事件它根本就收不到的原因。
嵌套ScrollView怎么解决滑动冲突的问题
嗯,滑动冲突在日常开发中简直太常见了,比如在 ScrollView 里嵌套一个横向滑动的 RecyclerView,或者像抖音那种上下滑动里嵌套左右滑动。其实解决滑动冲突的核心思路就一个:明确在什么滑动条件下,哪个层级的 View 应该拿到事件的控制权。
在传统的事件分发机制下,我们一般有两种经典的解决方案:外部拦截法和内部拦截法。
第一种是外部拦截法。这个思路是从父容器入手的,比较符合事件自顶向下分发的规律。我们需要重写父 ViewGroup 的 onInterceptTouchEvent 方法。
具体怎么做呢?首先,ACTION_DOWN 事件父容器绝对不能拦截(必须返回 false),因为一旦拦截了,根据刚才说的事件分发机制,子 View 连建立 mFirstTouchTarget 的机会都没有,后续的事件就全归父容器了。
然后在 ACTION_MOVE 事件里,我们会计算手指滑动的距离和角度。比如父容器是上下滑动,子容器是左右滑动。如果算出 dy(纵向滑动的绝对值)大于 dx(横向滑动的绝对值),说明用户意图是上下滑,这时候父容器就返回 true 把事件拦截下来自己消费;否则就返回 false 放行给子 View。最后 ACTION_UP 也返回 false。这种方法逻辑清晰,也是我最推荐的传统做法。
第二种是内部拦截法。这稍微绕一点,是从子 View 入手的,需要配合父容器一起工作。我们需要重写子 View 的 dispatchTouchEvent 方法,并使用 requestDisallowInterceptTouchEvent 这个方法。
在 ACTION_DOWN 的时候,子 View 调用 parent.requestDisallowInterceptTouchEvent(true),意思是“爸爸,你先别管,事件先给我”。
然后在 ACTION_MOVE 时,子 View 去判断滑动的方向或者自己的滑动状态,如果发现自己已经滑到底了,或者滑动方向应该是父容器处理的,就调用 parent.requestDisallowInterceptTouchEvent(false),把拦截权交还给父容器。
这需要父容器在 onInterceptTouchEvent 里除了 DOWN 以外,其他的 MOVE 都默认返回 true 去拦截,这样控制权才能顺利交接。
不过其实在现在的开发中,如果涉及到复杂的滑动嵌套,我更倾向于直接使用 Jetpack 提供的 NestedScrolling 机制。
你看像现在的 NestedScrollView 和 RecyclerView 内部都实现了 NestedScrollingChild 和 NestedScrollingParent 接口。它的设计思想比 U 型传递更先进。它会在子 View 准备滑动(消费事件)之前,先通过 dispatchNestedPreScroll 问一下父容器:“我要滑动了,你要不要先滑一部分?” 父容器如果滑了,子 View 拿到的就是父容器消耗剩下的滚动距离。这种协同滑动的机制,从根本上非常优雅地解决了吸顶效果或者折叠头部这种复杂的嵌套冲突,不用再去痛苦地算各种坐标了。
讲讲ClassLoader加载原理
其实我们在 Android 开发中写的各种 Java 或者 Kotlin 代码,最终都要运行在虚拟机里。ClassLoader(类加载器)的核心工作,就是把这些编译好的字节码文件,从磁盘读到内存中,然后解析转换成虚拟机能够识别的 Class 对象。
不过,Android 的 ClassLoader 机制和标准的 Java JVM 还是有很大区别的。Java 加载的是 .class 文件或者 JAR 包,而 Android 的 Dalvik 或者是现在的 ART 虚拟机,加载的是优化后的 .dex 文件。
在 Android 侧,主要有两个常用的 ClassLoader 供我们应用层使用:一个是 PathClassLoader,它主要用来加载系统类和已经安装到系统里的应用代码(也就是咱们 App 的 base.apk);另一个是 DexClassLoader,它比较灵活,可以加载指定路径下的 .dex 或包含 dex 的 .jar、.apk 文件,这也是以前做插件化或者动态下发代码的基础。
从 Framework 的源码来看,这两个加载器其实最后都继承自 BaseDexClassLoader。核心的加载逻辑也都在这里面。
当调用 loadClass 去加载一个类时,真正的干活的是 BaseDexClassLoader 里的一个内部对象叫 DexPathList。这个 DexPathList 里面维护了一个叫 dexElements 的数组,数组里的每一个 Element 就代表了一个 .dex 文件。
当开始查找类时,它的底层源码逻辑非常直接:就是遍历这个 dexElements 数组。它会挨个去问数组里的每一个 Element:“你这里面有没有我要找的这个类?” 如果找到了,就立马返回这个 Class 对象,并且停止遍历;如果所有的 Element 都找遍了还没有,就会抛出 ClassNotFoundException。
其实说到这里,就不得不提热修复的底层原理了。像以前腾讯开源的 Tinker 或者 QQ 空间的补丁技术,就是巧妙利用了这个数组遍历的原理。当应用有 Bug 时,我们下发一个修复了 Bug 的补丁 dex 文件,然后在应用启动的时候,通过反射去拿到这个 dexElements 数组,把我们的补丁 dex 插入到数组的最前面。
这样一来,ClassLoader 在遍历寻找修复过的那个类时,第一时间就在前面的补丁 dex 里找到了并加载,而原版 APK 里那个带 Bug 的类虽然还在后面的 dex 里,但永远不会被加载到了,这就实现了热修复。
讲讲双亲委派模型
嗯,双亲委派模型其实是和 ClassLoader 原理紧密绑定的一套加载规则。用大白话来概括它的核心机制就是:先找爸爸加载,爸爸不行,儿子自己再上。
具体到代码逻辑里,我们在调用 ClassLoader.loadClass() 的时候,主要分为三步:
首先,ClassLoader 会检查这个类是不是之前已经加载过了(调用 findLoadedClass),如果加载过了就直接返回,这其实是个内存缓存机制。
如果没加载过,它绝对不会自己直接去加载,而是先拿到自己的父加载器(parent),把这个加载请求委派给父加载器去处理。如果父加载器还有父加载器,就一直往上委派,直到最顶层的 BootstrapClassLoader(在 Android 里叫 BootClassLoader)。
只有当父加载器在它的搜索范围内找不到这个类,抛出了异常(或者返回 null)时,子加载器才会调用自己的 findClass() 方法去磁盘里读文件加载。
我在刚开始学 JVM 的时候,觉得这设计挺绕的,但后来结合系统安全一想,就明白了为什么必须得用双亲委派模型。主要原因有两个:
第一,也是最重要的,是为了沙箱安全,防止核心系统库被篡改。
打个比方,如果我自己写了一个黑客类,包名和类名故意取名叫 java.lang.String,然后在里面加了恶意代码。如果没有双亲委派,我的 ClassLoader 直接把它加载了,那系统的 String 就被覆盖了,这太可怕了。但是有了双亲委派,我的请求会被一路送到最顶层的 BootClassLoader。BootClassLoader 一看,java.lang 包下的,这归我管,然后在系统核心库里找到了真正的 String 类并加载返回。我自己写的那个假 String 直接被无视了,永远得不到执行的机会。
第二,是为了避免类的重复加载。
因为在虚拟机眼里,如果两个相同的类被不同的 ClassLoader 加载了,它们也是不相等的(强制类型转换会报 ClassCastException)。有了双亲委派体系,像 Activity、HashMap 这些 Android Framework 层和 Java 核心层的类,统一都被上层的 BootClassLoader 加载了一次并缓存了起来。底下所有的 App 的 PathClassLoader 要用的时候,直接向父加载器要就行了,既保证了类型的全局唯一性,又节省了内存。
所以在 Android 源码中,比如系统从 Zygote 进程 fork 出我们的应用进程时,App 的 PathClassLoader 的 parent 会被强制指定为 BootClassLoader,以此来保证 Framework 层的基础环境是不受应用层干扰的。
锁机制接触多吗
嗯,接触还是挺多的。其实不管是在平时写多线程并发的业务逻辑,还是在看 Framework 底层源码的时候,锁机制都是绕不开的一个核心点。在我看来,Android 或者说 Java 里的锁,主要就是为了解决多线程并发下的资源竞态问题。我平时用得最多、也研究得比较深的主要有 synchronized 和 ReentrantLock。
先说说 synchronized 吧。它是 JVM 层面的内置锁,也是我平时写代码最常用的,因为用起来简单。其实早期的 synchronized 挺重的,但 JDK 1.6 之后做了大量的锁优化,引入了锁升级机制。就是说它一开始是个无锁状态,如果有线程来访问,它会先变成“偏向锁”,把线程 ID 记在对象头的 Mark Word 里;如果这时候有其他线程来竞争,它就会升级成“轻量级锁”,通过 CAS 自旋去尝试获取;要是竞争实在太激烈,自旋了一定次数还没拿到,最后才会膨胀成“重量级锁”,这时候就会真的去调用操作系统的 Mutex 挂起线程了。
我在看 Android Framework 源码的时候,发现系统服务里大量使用了 synchronized。比如 AMS 和 WMS 里面就有巨大的全局锁(像以前的 mGlobalLock,或者 WMS 里的 mWindowMap)。这也解释了为什么有时候系统稍微卡顿一下,就容易触发 Watchdog(看门狗)机制导致系统重启,往往就是因为某个线程持有着这些大锁做耗时操作,导致其他关键线程被阻塞了。
然后再说说 ReentrantLock。它是在 Java API 层面实现的锁,底层核心是基于 AQS(AbstractQueuedSynchronizer) 和 CAS 操作。相比于 synchronized,我觉得它最大的优势就是灵活性。
比如它可以实现公平锁和非公平锁(synchronized 只能是非公平的),也可以响应中断(lockInterruptibly),还能尝试非阻塞地获取锁(tryLock)。在 AQS 底层,其实就是维护了一个 volatile 的 state 变量来表示同步状态,外加一个双向链表组成的等待队列。当线程拿不到锁时,会被封装成 Node 节点扔进队列里,底层通过 LockSupport.park() 来挂起线程。
除了这两个绝对主力,我在处理读多写少的缓存场景时,也会用到 ReadWriteLock(读写锁),也就是 ReentrantReadWriteLock。它其实也是基于 AQS 的,巧妙地把那个 32 位的 state 变量拆成了两半,高 16 位存读锁状态,低 16 位存写锁状态,这样就实现了读读并发、读写互斥,性能提升挺大的。
总结来说,如果逻辑简单不需要特殊控制,我一般无脑上 synchronized,毕竟 JVM 会帮我自动优化和释放;但如果遇到像需要超时返回、或者要严格保证先来后到(公平锁)的复杂并发控制,我就会用 ReentrantLock。
单例里volatile是干嘛的
嗯,这个问题挺经典的。其实在写单例模式的时候,特别是我们最常用的**双重检查锁定(Double-Checked Locking,也就是 DCL 单例)**中,那个 volatile 关键字是必不可少的。用一句话总结它的作用:主要是为了防止指令重排序,保证多线程下的内存可见性,从而避免拿到一个没有完全初始化好的半成品对象。
我可以把这个底层逻辑稍微展开说一下。在 DCL 单例里,我们一般会写这样一行代码:instance = new Singleton();。这行代码在我们的 Java 源码里看起来只有一句,但是如果把它编译成字节码,或者看底层的 CPU 指令,它其实是分成了三个独立的步骤的:
第一步,是在堆内存里分配一块空间给这个对象。
第二步,是调用构造方法,对这个对象进行初始化。
第三步,是把 instance 这个引用,指向刚刚分配的那块内存地址。
如果是在单线程环境下,这三步怎么执行都没问题。但是,现在的编译器和 CPU 为了榨干性能,往往会做**指令重排序(Instruction Reordering)**优化。也就是说,它觉得第二步和第三步没有数据依赖关系,可能会把执行顺序变成 1 -> 3 -> 2。
这时候在多线程并发的情况下,危险就来了。假设线程 A 进来了,刚好执行完了第 1 步和重排后的第 3 步。这时候 instance 引用已经不为空了,因为它已经指向了一块内存地址,但是对象还没初始化呢(也就是第 2 步还没走完)。
如果刚好这时候 CPU 切到了线程 B,线程 B 来到最外层的第一重 if (instance == null) 检查,发现它不为空,就会直接把这个半成品对象 return 拿去用了。那接下来调用这个对象的任何属性或者方法,极大概率直接报空指针异常(NPE)崩溃。
所以,给 instance 加上 volatile 关键字之后,其实就是在底层触发了 JMM(Java内存模型)的内存屏障(Memory Barrier)机制。它会在写操作前后插入屏障,强制要求必须严格按照 1 -> 2 -> 3 的顺序来执行,绝对不允许把第 3 步排到第 2 步前面去。
同时,volatile 还保证了可见性,也就是说线程 A 一旦把对象创建好并写回到主内存,线程 B 再去读的时候,一定会强制去主内存里拉取最新的值,而不是读自己工作内存里的脏数据。
其实我在看 Android 源码的时候,比如看 EventBus 或者一些全局大组件的管理类,只要是用 DCL 写法的,基本都严谨地加上了 volatile。这是一个高级工程师写并发代码的基本素养。
你用过什么图片库,知道原理吗
嗯,平时项目里基本都是用 Glide,早期也看过一点 Fresco,但因为 Glide 的 API 设计得太优雅,而且和 Android 的生命周期结合得非常完美,所以后来就一直用它了,我也专门去啃过它的核心源码。
关于 Glide 的原理,我觉得最核心的有两块:一个是生命周期绑定机制,另一个就是它极其出色的多级缓存架构。
先说第一点,生命周期绑定。平时我们用 Glide.with(this) 的时候感觉很神奇,即使 Activity 退出了,图片请求也能自动取消,不会导致内存泄漏。
它底层是怎么做的呢?其实当我们传一个 Activity 或者 Fragment 进去的时候,Glide 会在当前页面里偷偷 add 一个没有 UI 的、透明的 SupportRequestManagerFragment。因为这个 Fragment 是嵌在 Activity 里的,所以它的生命周期和宿主是完全同步的。当宿主触发 onStop 或者 onDestroy 的时候,这个 Fragment 就会收到回调,然后通过它内部的 Lifecycle 接口,去通知 Glide 的 RequestManager 暂停或者清理当前页面的网络请求。这个设计真的非常巧妙,彻底解耦了。
再说第二点,缓存原理。Glide 的缓存机制为了极致的性能,拆分得非常细,大概是一个四级缓存(算上复用池的话):
第一层叫活动资源缓存(Active Resources)。这里面主要用 HashMap 配合弱引用(WeakReference)来保存当前正在屏幕上显示的图片。弱引用的好处是如果系统内存紧张,这些图片一旦不显示了,随时能被 GC 回收掉。
第二层是内存缓存(Memory Cache)。用的是 LRU(最近最少使用)算法。如果一张图片从屏幕上移除了,它就会从 Active 资源里被挪到 LRU 缓存里。下次再用的时候,直接从这里拿。
第三层是磁盘缓存(Disk Cache)。Glide 默认用的是 DiskLruCache。这里有个很牛的特点,Glide 默认缓存的是被裁剪和转换后的图片(ResultCache),而不是原图(SourceCache)。比如服务器给的是 1080p,ImageView 只有 200x200,它存的就是 200x200 的像素,这样下次直接拿出来就能用,连解码和缩放的 CPU 算力都省了。
最后,我觉得不能忽略的是它的 BitmapPool(对象池)机制。Glide 在解码图片的时候,会利用 Android 底层的 inBitmap 属性,去复用以前废弃的 Bitmap 的内存空间。这样在列表快速滑动的时候,就不会频繁地去 new 出一块大内存又马上回收,极大减少了系统的 GC 抖动(Memory Churn),这也是滑动流畅的根本保证。
平时看它那个 Engine 类和 DecodeJob 类的源码,虽然逻辑错综复杂,但基本都是围绕着网络获取、这几层缓存的存取,以及线程池的切换在转。
RecyclerView了解吗,讲讲缓存机制
RecyclerView 的话,肯定是开发里用得最多、也非常熟悉的组件了。如果要说它的核心优势,其实完全就建立在它的四级缓存机制上。它的设计思想非常明确:就是把**视图的创建(Create)和数据的绑定(Bind)**解耦,然后通过一套复杂的池化技术,尽可能不让系统去频繁执行 findViewById 和 inflate 操作。
在 Framework 层或者说它的底层实现里,真正管缓存的那个老大叫做 Recycler,它内部维护了四个主要的缓存结构,按照优先级从高到低依次是:
第一级是 mAttachedScrap 和 mChangedScrap(统称 Scrap 缓存)。
这个缓存是最快的。它主要是针对当前屏幕内的 View。比如我们调用了 notifyItemChanged 刷新某一条数据,或者在 LayoutManager 重新进行 measure/layout 的那极短的时间里,原本在屏幕上的 ViewHolder 会被临时扒下来放到这里。等重新布局完,如果位置和类型对得上,直接拿出来用,连 onBindViewHolder 都不用重新走,可以说是无缝衔接。
第二级是 mCachedViews。
它主要缓存的是刚刚滑出屏幕的 ViewHolder。默认大小是 2 个。这里的精髓在于,它不仅缓存了 View,还保留了原有的数据绑定状态。假设我们手指往上滑,最上面那条滑出去了,它就会进这里;如果马上往下滑退回来,RecyclerView 会直接从 mCachedViews 里把它捞出来原样显示。依然不需要重新走 onBindViewHolder。这对于列表的反向滑动体验优化巨大。
第三级是 ViewCacheExtension。
这个说实话,我平时开发根本没用过,查资料也发现很少有人用。它是 Google 留给开发者自定义缓存的一个口子。但因为前面的缓存和后面的缓存池已经足够强大了,所以绝大多数情况下都不需要去碰它。
第四级是终极大招,也就是 RecycledViewPool。
当 mCachedViews 存满了(超过 2 个),再有滑出屏幕的 ViewHolder,系统就会把它的数据全部清空,然后扔进这个对象池里。它的结构是一个 SparseArray,按照 viewType 来分类缓存,默认每种 type 可以缓存 5 个。
从这里取出来的 ViewHolder,相当于是一个干净的、没有数据的躯壳。因此,如果是从 Pool 里捞出来的,必须要重新走一遍 onBindViewHolder 去绑定新的数据。
其实在实际业务中,遇到复杂的嵌套滑动(比如淘宝首页,外层一个 RecyclerView,里面每个卡片又是横向的 RecyclerView),我会经常手动把内部所有横向 RecyclerView 的 RecycledViewPool 设置成同一个实例(setRecycledViewPool),这样多个列表就能共享缓存池,极大降低了内存占用和卡顿风险。
总结下来就是:屏幕内的找 Scrap,刚滑出去的找 Cache(不用重新绑定),彻底不要的扔进 Pool(要重新绑定)。这就是一套用内存换 CPU 渲染时间的巅峰设计。
两个进程间通信怎么做
说到进程间通信(IPC),Android 里面其实提供了挺多种方案的,但如果要追本溯源,整个系统最核心的基石绝对是 Binder。当然,在不同的场景下,我们也会配合使用 Socket、共享内存等其他方式。
我把它们按照常用场景分类说一下吧。
首当其冲的肯定就是 Binder,我们平时用的 AIDL、Intent/Bundle、Messenger 甚至是 ContentProvider 和 Broadcast,底层全都是封装的 Binder。
我在研究 Framework 源码的时候体会特别深,为什么 Google 要自己造个 Binder 出来不用 Linux 原生的?有两个核心原因:
第一是性能。像 Linux 原生的管道或者消息队列,发送端要把数据从用户态拷到内核态,接收端再从内核态拷回用户态,需要拷贝 2 次。而 Binder 利用了内存映射机制(mmap),在内核空间和接收方的用户空间映射了同一块物理内存,数据只需要在发送端拷贝 1 次,这对于移动设备太重要了。
第二是安全性。Linux IPC 没办法严格校验发送方的身份,但 Binder 机制会在底层由 Binder 驱动自动给每个请求打上调用方进程的 UID 和 PID。我们在写跨进程服务(比如 AMS 检查应用权限)时,调一下 Binder.getCallingUid() 就能百分百确认对方是谁,根本伪造不了。
第二种常用的 IPC 方式是 Socket(本地套接字 LocalSocket)。
其实 Socket 性能比 Binder 差,但是 Android 里有个极其关键的场景不得不用它,那就是 Zygote 进程孵化应用。当我们在桌面点击图标,AMS 想要启动新应用时,就是通过 Socket 发消息给 Zygote 的。
为什么不用 Binder 呢?因为 Zygote 启动新进程用到的是 Linux 的 fork() 机制。而 fork() 对多线程是非常不友好的(fork 出的子进程只会保留调用 fork 的那个线程,其他线程会死锁)。Binder 接收端天生就是一个多线程池,如果在 Zygote 里用了 Binder 再 fork,很容易引发状态混乱和死锁。所以 Zygote 只能使用单线程的 Socket 来接收 AMS 的指令。
第三种是共享内存(Shared Memory / Ashmem)。
Binder 虽然好,但它有个致命缺陷:为了防止占用太多系统内存,Binder 驱动为每个进程分配的内存池默认最大只有 1MB-2MB,而且是所有在跑的跨进程通信共享的。如果要传几十兆的高清图片或者大规模的数据矩阵,肯定直接崩溃。这时候就会用 Ashmem,它直接映射一块物理内存给两个进程共用,实现真正的“零拷贝”,性能极高。比如底层的 SurfaceFlinger 在做屏幕图层合成时,应用层传给它的图像 Buffer,底层走的也就是共享内存。
所以,平时开发业务,普通的传数据调接口,我就写 AIDL 用 Binder;如果数据实在太大,就考虑写文件或者用底层的文件描述符跨进程传递。
如果要传一个Bitmap呢
嗯,这个问题其实特别经典,正好承接了咱们刚才聊的 IPC 机制里的痛点。如果在进程间传一个 Bitmap,具体的方案完全取决于这个 Bitmap 的大小。
最简单的场景,如果这个 Bitmap 是一个非常小的图标(比如十几KB),那我们直接把它塞进 Bundle 里,或者用 Intent putExtra 传过去就行了。因为 Bitmap 本身实现了 Parcelable 接口,它在底层会被序列化成字节数组,通过标准的 Binder 机制拷贝过去。
但是,这就是个坑!刚才我也提到了,Binder 的跨进程内存大小是有限制的,普通应用通常只有 1MB 左右(甚至有的系统更小),如果是一张 1080p 的全屏图,随便算一下(1080 * 1920 * 4 字节的 ARGB_8888)差不多要 8MB。这时候如果还硬塞进 Intent 里,当场就会给你抛出一个大名鼎鼎的 TransactionTooLargeException 崩溃。
所以,面对稍微大一点的 Bitmap,我们就必须得绕开 Binder 缓冲区大小的限制。底层 Android 是怎么做的呢?其实在 Android 8.0 及以后的版本里,Google 在 Bitmap 的源码层面做了一个极其优雅的优化。
如果去翻 Bitmap.writeToParcel 的源码,你会发现,当 Bitmap 发现自己的体积比较大的时候(通常超过 100KB 左右),它就不会傻傻地把自己写进 Binder 的普通 Buffer 里了。它会在底层去申请一块 Ashmem(匿名共享内存)。
系统会把 Bitmap 的像素数据一股脑写进这块共享内存里,然后生成一个指向这块内存的 FileDescriptor(文件描述符,简称 FD)。接着,它把这个仅仅只有几个字节大小的 FD,通过 Binder 传递给目标进程。
目标进程收到这个 FD 之后,利用 mmap 把这块共享内存映射到自己的虚拟内存地址空间里,直接就能读到那些像素数据,不仅绕过了 1MB 的限制,而且整个过程数据完全没有被拷贝(或者说只在最初拷贝了一次到共享内存),性能高得离谱。这个底层机制被封装在了一个叫 putBinder 的机制里(通常配合 Bundle.putBinder 或者 MemoryFile 使用)。
当然了,如果是从我们日常的业务层开发角度来解决的话,我通常不会自己去折腾写底层的共享内存。
最稳妥和标准的应用层解法,是把 Bitmap 存进磁盘文件,然后传 URI。比如我要跨进程把一张照片传给微信分享,我会先把 Bitmap compress 写到本地的 Cache 目录,然后利用 Jetpack 提供的 FileProvider 生成一个 content:// 开头的 URI,把这个 URI 塞进 Intent 传过去。微信那边拿到 URI 后,通过 ContentResolver 去 openInputStream,自己再解码出一张图来。这种方式最安全,也不受任何内存大小的束缚。
JVM怎么判断一个对象是否要回收,讲讲gc算法
嗯,好的。其实在 JVM(或者 Android 的 ART 虚拟机)里,判断一个对象是否“死亡”需要被回收,目前主流的机制并不是早期的引用计数法,而是可达性分析算法(Reachability Analysis)。先说结论的话,整个 GC 过程就是先通过可达性分析找到那些不再被引用的“垃圾”对象,然后根据不同内存区域的特点,采用不同的垃圾收集算法来清理这些内存。
我可以先把判断对象存活的机制展开说说。
为什么不用引用计数呢?其实道理很简单,因为它解决不了循环引用的问题。比如 A 引用 B,B 也引用 A,但除此之外没有任何人引用它们。按计数法它们都是 1,永远掉不下来,这就内存泄漏了。
所以现在用的是可达性分析。它的核心思想是找一系列被称为 GC Roots 的根对象作为起点。从这些根节点开始向下搜索,搜索走过的路径叫“引用链”。如果一个对象到 GC Roots 没有任何引用链相连(用图论的话说就是不可达),那这个对象就是不可用的,会被标记为垃圾。
在 Android 环境下,能作为 GC Roots 的对象主要包括:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象:比如我们在方法里
new的局部变量。 - 方法区中类静态属性引用的对象:这也是为什么静态变量持有 Activity 容易导致内存泄漏的原因。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI(Native方法)引用的对象。
- 所有被同步锁(
synchronized)持有的对象。
即使被标记为不可达,对象也不一定立刻死。如果重写了 finalize() 方法且没被执行过,它还有一次自救机会,但这种做法现在极为不推荐。另外,Java 里的四种引用类型(强、软、弱、虚)也会影响回收时机。比如**软引用(SoftReference)**是在内存不足时才回收,非常适合做缓存;**弱引用(WeakReference)**是只要发生 GC 就会被回收,比如 Handler 内存泄漏解决方案里就会用到它。
确定了哪些对象要回收后,接下来就是垃圾收集算法了。常用的有这么几种:
第一种是标记-清除算法(Mark-Sweep)。这是最基础的。分为“标记”和“清除”两个阶段:先把垃圾标记出来,然后统一塞进空闲列表里。它的缺点非常明显:一个是效率问题,标记和清除两个过程效率都不高;另一个是空间问题,清除之后会产生大量不连续的内存碎片。如果下次要分配一个大对象(比如一个大 Bitmap),虽然总空闲内存够,但没有一块连续空间,就会提前触发再一次 GC。
第二种是复制算法(Copying)。为了解决碎片问题,它把内存分为大小相等的两块,每次只使用其中一块。当这一块用完了,就把还活着的对象复制到另一块上面,然后把已使用过的内存空间一次清理掉。这种算法对于存活对象少、垃圾对象多的区域非常高效(比如新生代),而且没有碎片。缺点是内存利用率太低,直接砍掉了一半空间。现在的虚拟机优化了这个算法,比如 HotSpot 把新生代分成了 Eden、From Survivor、To Survivor 三块,比例是 8:1:1,每次只浪费 10% 的空间。
第三种是标记-整理算法(Mark-Compact)。这是根据老年代的特点提出的。老年代里对象存活率高,如果用复制算法,复制开销太大,还得有额外的空间担保。所以标记-整理是在标记完之后,让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。这样既没有碎片,也利用了全部空间。
最后,其实现在的虚拟机采用的都是分代收集算法(Generational Collection)。它并不是一种新的算法,而是根据对象存活周期的不同将内存划分为几块。一般把 Java 堆分为新生代(Young Generation)和老年代(Old Generation)。 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。 而在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记-清除或者标记-整理算法来进行回收。
讲讲HashMap
嗯,好的。HashMap 在 Java 集合框架里算是一个绝对的核心类,不管是在平时业务开发还是在看其他开源库源码时,它的身影无处不在。用一句话概括:HashMap 是一个基于哈希表的 Map 接口实现,它允许 null 键和 null 值,并且是非线程安全的。
如果聊到它的底层实现结构,就必须以 Java 8 为分界线。在 Java 7 之前,HashMap 的底层是数组 + 链表。而从 Java 8 开始,为了解决链表过长导致的查询性能退化问题,底层结构变成了数组 + 链表 + 红黑树。
我可以详细拆解一下它最核心的 put 和 get 操作的内部逻辑,这其实就能体现它的设计精髓。
当我们调用 put(key, value) 的时候,系统主要干了这么几件事:
首先,它会去计算 key 的哈希值。它并没有直接用 key 对象的 hashCode(),而是调用了一个内部的 hash() 方法。这个方法把 hashCode 进行了高 16 位和低 16 位的异或操作。这叫“扰动函数”,目的是为了让哈希值的高位也参与到后续的寻址计算中,从而减少哈希冲突。
然后,根据算出的扰动哈希值,通过 (n - 1) & hash 的方式(n是数组长度)计算出这个 key 应该放在数组的哪个下标位置。这里用按位与而不是取模,是因为数组长度永远是 2 的 n 次幂,按位与的效率极高。
接着,判断该下标位置是否为空。如果为空,直接 new 一个 Node(Java 7 叫 Entry)放到这里。如果不为空,说明发生了哈希冲突。
发生冲突后,就需要遍历这个位置上的链表(或者红黑树)。如果找到了相同的 key(equals 返回 true),就直接覆盖旧的 value。
如果没找到相同的 key,在 Java 8 里,会判断当前是链表还是红黑树。如果是红黑树,就调用红黑树的插入逻辑;如果是链表,就尾插法插入(Java 7 是头插法,多线程下容易形成环形链表导致死循环)。
这里有个关键的转树机制:当链表长度大于等于 8,并且数组总长度大于等于 64 时,为了防止拉链过长导致查询时间复杂度从 退化到 ,HashMap 会把这个链表转换成红黑树,把查询性能提升到 。如果不满足数组长度 64 的条件,只会优先进行数组扩容。
最后,插入完成后,会检查当前 Map 里的元素个数是否超过了阈值(Threshold)。这个阈值等于数组容量 负载因子(Load Factor,默认 0.75)。如果超过了,就会触发大名鼎鼎的 resize()(扩容)。
说到扩容,HashMap 的扩容是翻倍扩容,也就是从 N 变成 2N。扩容是一个比较耗时的过程,它需要创建一个新数组,然后遍历老数组,把里面的所有节点重新计算下标塞进新数组里。不过 Java 8 在这里做了一个非常巧妙的优化。因为容量是 2 的次幂,扩容后,节点在新数组的位置要么在原位置,要么在原位置 + 老数组长度的位置。它通过检查哈希值里新增的那一个 bit 是 0 还是 1 来快速判断,省去了重新 & 运算的步骤。
总结来说,HashMap 靠着精妙的哈希算法、冲突处理机制和红黑树的引入,在大多数情况下能提供接近 的极致读写性能。但因为是非线程安全的,如果并发场景下,我们得用 ConcurrentHashMap。
你对红黑树的理解
嗯,提到红黑树,其实刚才在聊 HashMap 的时候也涉及到了。在我看来,红黑树(Red-Black Tree)是一种特化的自平衡二叉查找树(BST)。它的出现主要是为了解决普通二叉查找树在极端情况下(比如依次插入有序数据)退化成线性链表,导致查询时间复杂度从 暴跌到 的问题。
红黑树最大的特点就是通过在每个节点上增加一个表示颜色的位(红色或黑色),并拥有一套严格的着色和平衡规则,从而保证没有任何一条路径会比其他路径长出两倍。这使得红黑树在最坏情况下的查找、插入、删除时间复杂度都能稳定在 。
具体来说,一棵合格的红黑树必须满足以下 5 条红黑属性:
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色。
- 每个叶子节点(NIL,空节点)都是黑色。(这点在看很多图解时容易被忽略,但对平衡很重要)。
- 如果一个节点是红色的,则它的两个子节点必须是黑色的(也就是说,不能有两个连续的红色节点相连)。
- 从任一节点到其每个叶子的所有路径,都包含相同数目的黑色节点。(这叫“黑色完美平衡”,是红黑树自平衡的核心所在)。
这五条规则听起来挺绕的,但它们共同构成了红黑树平衡的基石。在进行插入或者删除操作时,由于新节点的加入或旧节点的移除,很可能会破坏这些规则。这时候,红黑树就会通过两种手段来进行自我调整,使其重新满足这 5 条属性,也就是所谓的自平衡过程:
第一种手段是变色(Recoloring):顾名思义,就是把节点的颜色从红变黑,或者从黑变红。这通常是最轻量级的调整。
第二种手段是旋转(Rotation):分为左旋(Left Rotate)和右旋(Right Rotate)。旋转是为了在不破坏二叉查找树“左小右大”特性的前提下,改变树的局部层级结构,把过深的一支“拉平”一点。
其实,提到红黑树,很多人喜欢拿它和 AVL 树(严格平衡二叉树)做比较。我觉得理解它们的区别更有助于把握红黑树的精髓。 AVL 树要求每个节点的左右子树高度差绝对值不超过 1,是一种追求极致平衡的树。它的优点是查询效率极高,但在插入和删除时,为了维持这种极致平衡,需要进行大量的旋转操作,开销很大。 而红黑树通过那 5 条稍微宽松一点的规则,追求的是一种弱平衡。虽然它的查询效率在理论上比 AVL 树稍微慢那么一点点(但都在 量级),但在插入和删除时所需的旋转次数远远少于 AVL 树。 所以,红黑树是一种在查询、插入和删除性能之间取得了极好平衡的数据结构,非常适合应用在像 Java 的 TreeMap、HashMap(Java 8 后),以及 Linux 内核的进程调度(CFS)这些写读都很频繁的场景中。
讲讲Java中泛型的super和extends
嗯,好的。Java 的泛型其实是一个编译时的语法糖,主要为了解决类型安全和避免强制类型转换的问题。而在泛型里,? extends T 和 ? super T 是两个比较高级但又极其核心的概念,它们被称为通配符的上界和下界。
用一句话总结它们的使用原则,就是大名鼎鼎的 PECS 原则,也就是 Producer Extends, Consumer Super。
我可以把这两个通配符的含义和适用场景展开详细说说所在。
首先是 ? extends T(上界通配符)。
它表示泛型类型必须是 T 类型或者 T 的子类。这里的 T 就是一个“上界”,最高也就是它了。
根据 PECS 原则,它是 Producer(生产者)。也就是说,如果你有一个集合,你只需要从里面读取数据,这个集合就是一个数据的生产者,那你就应该用 ? extends T。
为什么呢?因为编译器只知道这里面存的都是 T 的子类,那不管具体是哪个子类,它一定能安全地转型为父类 T。所以,我们可以安全地从 List<? extends T> 里调用 get() 方法拿到 T 类型的对象。
但是,我们不能往里面写数据(除了 null)。因为编译器不知道这个泛型具体是指向 T1 还是 T2(都是 T 的子类),为了防止你把 T1 塞进了本该装 T2 的 List 里,编译器直接禁止了写操作。这也叫协变(Covariance)。
然后是 ? super T(下界通配符)。
它表示泛型类型必须是 T 类型或者 T 的父类。这里的 T 是一个“下界”,最低也就是它了。
根据 PECS 原则,它是 Consumer(消费者)。也就是说,如果你有一个集合,主要工作是往里面写入数据,这个集合消费了你的数据,那你就应该用 ? super T。
为什么呢?因为编译器知道这里面存的不管是 T、T 的父类还是 Object,它们都是 T 的某种超类。根据多态性,把一个 T 类型的对象或者是 T 的子类对象塞进去,绝对是类型安全的。所以,我们可以安全地向 List<? super T> 里调用 add(T)。
但是,读取数据就很尴尬了。因为编译器不知道拿出来的到底是 T 还是 Object,除了能强转成 Object 之外,你没办法确定它的具体类型。这也叫逆变(Contravariance)。
可以举一个很经典的 Java 源码里的例子。比如 Collections.copy 这个方法,它的定义大概是这样的:
public static <T> void copy(List<? super T> dest, List<? extends T> src) { ... }
这里面,src 是数据的来源,是生产者,它里面的数据要符合 T 或者 T 的子类,这样才能安全读出转型为 T,所以用 extends。
而 dest 是数据的目的地,是消费者,它要准备接收来自 src 的 T 类型数据,所以它必须能容纳 T 或者 T 的父类,所以用 super。
其实在平时写业务代码时,如果是简单的 List<String>,一般用不到这两个。但在编写一些通用的工具类、框架代码,或者是像用 RxJava、Kotlin Flow 处理复杂继承关系的数据流时,深刻理解并熟练运用 PECS 原则,是保证代码既灵活又类型安全的必修课。
序列化Serializable和Parcelable有什么区别
性能谁更优,为什么
嗯,这两个接口都是我们在 Android 开发中处理对象序列化和跨进程通信(IPC)时经常打交道的。
先说结论:Serializable 是 Java 自带的通用序列化接口,用起来极其简单,但性能很差;而 Parcelable 是 Android 专门为了跨进程通信性能优化而设计的 native 接口,实现起来比较繁琐,但性能极其优秀。在 Android 应用程序内部的数据传递(比如 Intent 传值、Fragment arguments)场景下,绝对优先选择 Parcelable。
我可以把它们的区别和底层的性能差异展开说一下。
首先说 Serializable。
它的最大优点就是简单。你只需要让你的 Java 类实现一个 Serializable 接口(甚至这只是一个空标记接口,里面没有任何方法),再显式定义一个 serialVersionUID 防止版本冲突,这个类就可以通过 ObjectOutputStream 和 ObjectInputStream 进行读写了。
但是,它的简单是有巨大代价的,也就是性能极其低下。
为什么呢?因为 Serializable 在序列化和反序列化过程中,极度依赖底层的反射机制。
当系统去读写一个 Serializable 对象时,它需要在运行期通过反射去频繁查找该类的构造函数、字段属性、方法信息等。反射本身就是一种比较慢的操作。
更糟糕的是,在这个过程中,系统会产生大量的临时对象,这就加剧了 JVM 堆内存的压力,极容易引起频繁的垃圾回收(GC),从而导致应用卡顿。
另外,它把对象转换为字节流的形式也比较冗余,不仅包含了对象的数据,还包含了大量的类元数据信息,不仅占内存,I/O 开销也大。
它唯一的适用场景,可能就是把对象持久化到磁盘文件,或者进行真正的网络传输时,因为这种场景通常对版本兼容性要求高(依赖 serialVersionUID),且对极度高并发的即时性要求不如 Android IPC 那么高。
然后再说 Parcelable。 这是 Google 的工程师看着 Serializable 在移动端表现太差,专门为 Android “量身定制”的。用大白话总结它的精髓就是:把繁重的反射工作抛弃掉,让开发者自己手动或者通过工具把对象的属性一个个“打包”和“拆包”。
它要求实现类必须实现 writeToParcel 方法(把属性序列化到 Parcel 对象里)以及一个静态的 CREATOR 内部类(实现 createFromParcel,按刚才打包的顺序把属性读出来还原成对象)。
它的性能优势极其明显:
- 没有反射:读写顺序是开发者在代码里写死的,直接调用
Parcel对象的writeInt、writeString等 Native 方法,效率极高。 - 轻量级字节流:
Parcel内部是一块连续的内存空间(底层的共享内存 Ashmem),它只存储对象的纯数据,不存储那些冗余的类元信息,序列化后的体积非常小。 - 零拷贝(某种程度上):结合我们之前聊的 IPC 机制,Parcel 序列化后的数据非常适合在内核中通过
mmap进行内存映射传递,极大减少了由于 IPC 导致的数据拷贝开销。
在以前,写 Parcelable 确实挺痛苦的,全是样板代码。不过现在的开发环境好多了,我们可以直接用 Jetpack 提供的 @Parcelize 注解(Kotlin 插件自动生成实现代码),或者在 Android Studio 里安装插件一键生成。这就在享受 Parcelable 极致性能的同时,又解决了开发效率低的痛点。
总结一下:在 Android 的世界里,除了像把用户设置持久化到本地文件这种极少数场景可以勉强用用 Serializable 外,只要涉及到在组件之间、进程之间传递对象,无脑选 Parcelable 准没错,这是保证 App 滑动流畅、不卡顿的基础。
讲讲你见过的设计模式
嗯,其实在平时写业务代码和深入看 Android 源码的过程中,设计模式真的是无处不在。先说结论:我见得最多、也最常用的主要有单例模式、建造者模式、观察者模式和责任链模式。它们的核心目的都是为了解耦和提升代码的可维护性。
先说单例模式。这绝对是 Framework 里用得最泛滥的一个。比如管理全局窗口的 WindowManagerGlobal,或者我们在应用进程里拿到的各种系统服务代理(像 AMS、ATMS)。我自己写的话,一般会用带 volatile 的双重检查锁定(DCL)或者静态内部类来实现,保证线程安全。
然后是建造者模式(Builder)。当你遇到一个类的构造函数参数特别多,而且很多是可选参数的时候,用它最合适。平时最常见的比如弹 AlertDialog,或者配 OkHttp 的 OkHttpClient,都是一套链式调用 Builder.setXXX().build(),代码读起来非常清晰。
第三个是观察者模式。它非常适合用来做响应式编程和解耦。Android 里的广播机制其实就是一种跨进程的观察者模式。在应用层架构里,Jetpack 的 LiveData 也是典型的观察者。数据源一变,所有的订阅者(UI层)都能自动收到更新,不用互相拿引用调方法。
最后我想特别提一下责任链模式。这个模式在底层的系统设计里太巧妙了。比如 Android 的事件分发机制,从 Activity 到 ViewGroup 再到 View,就是一个典型的 U 型责任链。还有网络库 OkHttp 里的各种 Interceptor(拦截器),请求像洋葱一样一层层传进去,响应再一层层剥出来。看懂了这个模式,对理解系统底层的流转逻辑帮助特别大。
讲讲tcp和udp
嗯,TCP 和 UDP 算是计算机网络里最常被拿来对比的传输层协议了。一句话概括它们的区别就是:TCP 是面向连接的、可靠的,但传输比较慢;UDP 是无连接的、不可靠的,但传输速度极快。
先展开说说 TCP。它的核心机制就是为了保证数据能够一丝不差地送到对方手里。为了建立连接,它需要经过经典的三次握手;断开时要经过四次挥手。在传输过程中,它有一整套极其严密的机制,比如序列号、确认应答(ACK)、超时重传,还有用来做流量控制的滑动窗口,以及用来避免网络拥塞的拥塞控制算法。因为要做这么多校验和确认,它的头部开销比较大,而且一旦发生丢包,后续的数据就会在缓冲区里排队阻塞。像我们平时用的 HTTP/HTTPS 请求、下载文件,这种绝不允许数据出错的场景,底层用的都是 TCP。
然后再说说 UDP。它就简单粗暴多了,没有任何握手和连接的概念,拿过数据来加上一个简单的头部,直接就往网络上扔。不管对方有没有收到,也不管收到的顺序对不对。正因为它没有那些复杂的重传和确认机制,所以它的延迟非常低,开销极小。
其实在实际的 Android 开发中,纯用 UDP 的场景相对少一些,一般用于局域网发现(比如投屏的 SSDP 协议)或者 DNS 查询。但在很多对实时性要求极高的场景,比如音视频直播流(WebRTC)或者多人实时竞技游戏,大家更倾向于在 UDP 的基础上,自己在应用层去实现一层可靠传输(比如 KCP 或者 QUIC 协议),这样既享受了 UDP 的低延迟,又避免了 TCP 容易导致的拥塞阻塞问题。
讲讲dns
好的。DNS 其实就是互联网世界的“通讯录”。用最简单的话来说,它的作用就是把我们人类容易记住的域名(比如 www.baidu.com ),翻译成计算机网络通信真正需要的 IP 地址。
如果我们要详细看它的一次解析过程,其实是一个层层递进的查询链路。 首先,当我们在 App 里发起一个网络请求时,系统会先查本地缓存,看看浏览器或者操作系统的 DNS 缓存里有没有这个域名对应的 IP。如果有,直接用。 如果没有,系统就会向本地 DNS 服务器(Local DNS)发请求,这个服务器通常是运营商(比如电信、联通)分配给你的。 如果 Local DNS 也没有缓存,它就会代替我们去互联网上进行迭代查询:它会先去问根域名服务器(Root DNS),根域名服务器会告诉它“这个你得去问 .com 的顶级域名服务器(TLD DNS)”。接着它再去问 .com 服务器,.com 服务器又会把它指向目标网站自己维护的权威域名服务器(Authoritative DNS)。最后在权威服务器上,真正查到了对应的 IP 记录,然后原路返回给 Local DNS 缓存起来,并最终交回给我们的手机终端。
另外,作为 Android 开发者,我们在实际业务中经常会遇到一个痛点,就是DNS 劫持。因为传统的 DNS 查询默认走的是 UDP 的 53 端口,而且是明文传输的,很容易被中间的网络节点篡改,导致用户请求被引流到错误的服务器上。为了解决这个问题,我们在项目中一般会接入 HTTPDNS。也就是绕过系统底层的 DNS 解析,直接用 HTTP 协议向我们自己信任的服务器(比如大厂的 HTTPDNS 接口)发送请求来获取 IP。拿到了纯净的 IP 之后,再配合 OkHttp 里的 Dns 接口去做网络请求的替换,这样就能从根源上解决域名劫持和跨网访问慢的问题。
平常本地缓存你用什么
嗯,平常开发中本地缓存的方案还挺多的,我一般会根据数据的结构复杂度、读写频率以及多进程需求,来选择最合适的存储方式。总结下来,我用得最多的主要是 MMKV、Room 以及早期的 SharedPreferences。
对于最常见的轻量级键值对(Key-Value)存储,比如存个用户的 Token、设置开关之类的。以前大家基本都用原生的 SharedPreferences(SP)。但在我深入看过源码之后,就很少用了。因为 SP 性能问题挺明显的,它每次加载都要把整个 XML 文件读到内存里解析,如果是用 commit() 会阻塞主线程;即使是用 apply(),系统在 Activity 走 onStop() 的时候,底层还是会有一个 QueuedWork 的等待机制去强制等磁盘写入完成,特别容易导致我们应用的 ANR。
所以现在对于 KV 存储,我的首选绝对是腾讯开源的 MMKV。它的读写速度比 SP 快了数量级,而且天生支持多进程并发读写,API 还能直接兼容以前的 SP 代码,迁移成本极低。
除了 KV 存储,如果遇到需要存复杂的结构化数据或者关系型数据,比如聊天记录列表、离线的搜索历史等,我就会用 Jetpack 的 Room。它底层其实还是 SQLite,但它的好处是提供了一层非常优雅的 ORM 封装,而且能在编译期校验 SQL 语句,配合 Kotlin 的 Coroutines(协程)或者 Flow,做异步的增删改查和 UI 数据观察简直不要太爽。
当然,如果是要缓存大文件、图片或者音视频流,那我肯定不用前面这些,一般会结合 DiskLruCache 把它们以文件的形式写到沙盒目录里,然后数据库里只存对应的文件绝对路径。这就是我日常开发里打出的一套“缓存组合拳”。
知道mmkv的底层原理吗
嗯,仔细研究过的。因为当时把项目里的 SP 替换成 MMKV 之后,发现速度快得离谱,就很好奇它到底是怎么做到的。其实它的底层核心原理主要归功于两点:内存映射(mmap) 和 Protobuf 编码。
先说最核心的 mmap(内存映射技术)。像传统的 SP 或者写普通文件,调用的是标准 I/O,数据要从应用的用户态内存拷贝到系统内核态,再由内核刷入磁盘,经历了两次拷贝,而且写磁盘的过程会阻塞线程。而 mmap 的神作之处在于,它直接把磁盘上的一个文件,映射到了应用进程的用户态内存空间里。这时候,我们往这块内存里写数据,就完全等同于写内存,没有系统调用的开销,也没有两次拷贝,速度极快。至于这块内存里的数据什么时候刷回磁盘,由操作系统的内核机制在后台自动管理,就算是 App 崩溃了,内核也会确保把脏页写回文件里,不会丢数据。
第二点是它采用了 Protobuf 格式的编码。SP 存的是 XML,不管读写都有极大的字符串解析和拼接开销。而 MMKV 采用的 Protobuf 是一种非常紧凑的二进制序列化格式。它在存数据的时候,采取的是**增量追加(Append)**的模式。也就是说,即使是修改同一个 key 的值,它也不会像 SP 那样把整个文件重写一遍,而是直接把新数据追加到文件末尾。读取的时候,后面读到的新值会自动覆盖前面的旧值。只有当文件大小不够用了,它才会触发一次全量重写去清理冗余数据。这种追加写的方式把 I/O 损耗降到了最低。
最后,它还非常巧妙地利用了底层的 文件锁(flock)。通过配合状态同步的标记位,完美解决了很多应用苦恼的多进程并发读写冲突的问题。所以无论是单进程的极限性能,还是多进程的数据一致性,它底层在架构设计上确实做到了极致。
小红书客户端 社区工程
native层怎么调Java层函数。
嗯,其实关于 Native 层怎么调 Java 层,不管是在我们做 NDK 开发,还是在看 Android Framework 底层源码的时候都非常常见。先说结论:Native 层调用 Java 层函数核心是依赖 JNI(Java Native Interface)提供的机制,具体来说是通过 JNIEnv 指针去查找到对应的 Java 类,获取到方法 ID,然后发起调用。
如果要展开说的话,整个流程大概分为这几个步骤:
首先,获取 JNIEnv 指针。JNIEnv 是一个与线程绑定的环境变量,它里面封装了大量 JNI 调用的函数指针。如果是在 Java 层调入 Native 层的方法里,系统会直接把 JNIEnv 作为参数传进来;但如果是在 Native 层自己创建的子线程里(比如 pthreads),这时候是没有 JNIEnv 的,就必须得先拿到全局的 JavaVM 指针,然后调用 AttachCurrentThread 方法把当前线程挂载到 JVM 上,才能获取到专属的 JNIEnv。
然后第二步,查找目标 Java 类。我们会通过 JNIEnv 的 FindClass 方法,传入类的全路径名(比如 java/lang/String 这样以斜杠分隔的路径),拿到一个 jclass 对象。因为查找类是一个相对耗时的操作,在 Framework 源码里,我们经常会看到在 JNI 的 JNI_OnLoad 初始化阶段,就把经常调用的 jclass 获取到,并且通过 NewGlobalRef 转换成全局引用缓存起来,防止被 GC 回收。
第三步,获取方法 ID。拿到类之后,如果是普通方法就调用 GetMethodID,静态方法就调用 GetStaticMethodID。这里除了要传 jclass 和方法名,还有一个非常关键的参数就是方法签名(Signature)。因为 Java 支持方法重载,Native 层只能通过类似 (I)Ljava/lang/String; 这样的签名字符串来精确匹配到底是哪个方法。其实我一开始看源码的时候经常被这些签名搞晕,后来查文档才知道这是为了类型安全。
最后一步就是执行调用了。根据 Java 方法返回值的不同,我们会调用不同系列的函数,比如返回值是 void 就调 CallVoidMethod,返回值是对象就调 CallObjectMethod。
在 Framework 层其实有非常多这种调用的经典例子。比如我之前看 Input 系统源码的时候,底层 InputDispatcher 把事件分发上来,就是经过 JNI 层的 android_view_InputEventReceiver.cpp,里面拿到了 Java 层的 InputEventReceiver 对象,然后通过预先缓存好的 dispatchInputEvent 方法的 MethodID,调用 env->CallVoidMethod 把事件抛给 Java 层去处理的。这就是 Native 调 Java 的一个非常典型的场景。
了解Native层和Java层内存模型的差异吗
嗯,了解过。我们在做 Android 开发时,经常会碰到 OOM 或者内存泄漏,这背后其实都和内存模型息息相关。先说结论:Java 层和 Native 层内存模型最大的差异在于“管理权”和“空间限制”。Java 内存由 ART 虚拟机接管,受制于严格的堆大小限制,有自动的 GC 机制;而 Native 内存属于 C/C++ 层面,直接映射到进程的虚拟内存,基本只受限于物理内存上限,但必须由开发者手动分配和释放。
我可以从三个维度来详细对比一下这两种内存模型:
第一个维度是内存结构的划分。Java 层的内存结构其实就是 JVM/ART 定义的那一套,主要包括 Java 堆(Heap)、方法区、虚拟机栈等等。我们平时 new 出来的对象大多都在 Java 堆里分配,而这个堆的大小在 Android 系统里是有严格上限的(比如早期的 192MB、256MB 等,通过 dalvik.vm.heapsize 配置),一旦超了就会抛出 Java 的 OutOfMemoryError。
而 Native 层这边,它面对的是 Linux 操作系统的进程空间。在 32 位系统下有 3GB 的用户空间,64 位系统下就更大了。它的动态内存主要是在 Native Heap 里通过 malloc 或者 new 分配的。所以从容量上讲,Native 内存的空间要比 Java 堆大得多得多。
第二个维度是内存的管理和回收机制。Java 层最大的优势就是自动化,ART 虚拟机会通过可达性分析(比如基于 GC Root 的追踪)来标记存活对象,然后在合适的时机(比如内存不足或者空闲时)触发 CMS 或者 Concurrent Copying 等 GC 算法去回收垃圾。我们一般只需要关注不要保留长生命周期的无用引用就能避免泄漏。
但是 Native 层的内存是完全手动的,申请了 malloc 就必须对应 free。如果没有释放,就会造成 Native 层的内存泄漏。而且 Native 层的泄漏比较难查,很多时候需要借助类似 AddressSanitizer (ASan) 或者 malloc debug 这样的底层工具才能定位。
第三个维度是两者的交互和优化。其实在 Android 系统演进的过程中,有很多利用这两个内存差异来做优化的例子。比如我重点研究过的 Bitmap 内存分配机制演进。在 Android 8.0 之前,Bitmap 的像素数据是存在 Java 堆里的,这就导致稍微加载几张大图,Java 堆就很容易 OOM。但是从 Android 8.0 开始,Google 把 Bitmap 的像素数据转移到了 Native Heap 中,Java 层只保留一个很小的包装对象。并且通过 NativeAllocationRegistry 机制把 Native 内存的生命周期和 Java 对象绑定起来,当 Java 对象被 GC 回收时,自动触发 Native 层的释放。这样既绕过了 Java 堆的严格限制,大大降低了 OOM 的概率,又利用了 Java 的自动回收机制,我觉得这是一个非常优雅的架构设计。
Activity的生命周期
嗯,Activity 的生命周期可以说是 Android 开发的基础,但结合 Framework 来看其实别有一番洞天。先说结论:Activity 的生命周期表面上是一系列 onCreate 到 onDestroy 的方法回调,代表了页面从创建、可见、前台交互到销毁的全过程;但在底层本质上,它是服务端的 ATMS(ActivityTaskManagerService)通过 Binder 跨进程下发给客户端 ActivityThread 的一系列状态转移指令。
从常规的角度来看,它的流转主要是这几个核心节点:
首先是 onCreate,表示 Activity 被创建了,我们一般在这里做数据的初始化和 setContentView 加载布局。接着是 onStart,这时候 Activity 在屏幕上变得可见了。紧接着是 onResume,到了这个状态,Activity 不仅可见,而且获取到了焦点,也就是跑到前台了,用户可以真正开始和它进行点击、滑动这些交互了。
当用户打开一个新页面,或者按 Home 键切到后台时,当前 Activity 会回调 onPause,失去焦点但可能依然可见(比如上面弹了一个透明的 Activity)。然后新的 Activity 走完启动流程显示出来后,老的 Activity 被完全遮挡,就会回调 onStop,变成不可见状态。如果系统内存紧张或者用户主动杀掉应用,最后就会走到 onDestroy 释放资源。如果是从后台又切回来,就会经历 onRestart -> onStart -> onResume 这个重新可见的流程。
其实,如果你看过 AMS/ATMS 的源码,就会发现生命周期的调度是一个异步且复杂的跨进程过程。
比如我们从 Activity A 跳转到 Activity B,很多人死记硬背说是 A 先 onPause,然后 B 走 onCreate、onStart、onResume,最后 A 再走 onStop。其实为什么是这个顺序呢?我在看源码的时候发现,ATMS 在调度时,必须先挂起当前获得焦点的 A(也就是给 A 发送 pause 事务)。等应用进程执行完 A 的 onPause,并把状态同步回给系统后,系统才会放心去启动 B。
这时候 B 在应用进程被反射创建,依次走完 onCreate 到 onResume。注意,这时候 A 的 onStop 还没有立刻执行!系统是怎么知道 B 已经显示好了可以去 stop A 呢?其实是在 B 端主线程 Looper 的消息队列里,注册了一个叫 IdleHandler 的东西。当 B 的 UI 渲染完,主线程空闲下来时,这个 IdleHandler 会触发 activityIdle 方法,通过 Binder 告诉 ATMS:“我 B 已经画完了”。ATMS 收到这个消息后,才会真正下发指令让 A 去执行 onStop。
所以我觉得,结合系统底层的交互逻辑来理解生命周期,而不是死记硬背,会感觉非常通透。
onStart和onResume的区别
好的,这是一个非常经典的问题。先说结论:onStart 和 onResume 最核心的区别在于“可见性”与“交互能力”的边界。onStart 标志着页面对用户可见,但此时它还不在前台,无法接收用户的触摸等输入事件;而 onResume 标志着页面不仅可见,而且获得了系统的焦点,真正准备好与用户进行交互了。
我们可以把这两个状态的差异拆解得更详细一点:
首先是在感官体验上。假设我们当前在一个大满屏的 Activity A 里面,这时候我们启动了一个 Activity B,但这个 B 比较特殊,它不是全屏的,而是一个只占了半个屏幕的悬浮窗(比如配置了 Dialog 主题),或者是背景全透明的页面。这个时候,由于 B 把 A 的焦点抢走了,A 不能再交互,A 就会回调 onPause。但是,因为 B 是透明的或者半屏的,A 并没有被完全遮挡掉,用户依然能看到 A 的内容。在这种情况下,A 就不往下走 onStop 了。此时 A 的状态就类似于一种“只读”的状态——它是 onStart(准确说是被 pause 在了可见状态),但它没有 onResume,因为它没有焦点了。
其次,也是我觉得最重要的一点,是在 Framework 层底层资源的绑定时机上。我们在讲 onResume 的时候,往往忽略了系统在背后做了什么。
我在看 ActivityThread 源码的时候注意到,当我们收到服务端下发的 Resume 请求时,会执行 handleResumeActivity 这个方法。在这个方法里面,首先会去回调我们业务代码里的 onResume() 方法。但在 onResume() 执行完之后,源码紧接着会做一件非常重要的事情:它会调用 WindowManager.Global 的 addView() 方法。
这就直接串联到了我们前面提到的 WMS 的流程!也就是说,直到 onResume 执行完左右的节点,应用才真正开始创建 ViewRootImpl,才开始向 WMS 申请窗口,然后才触发 View 树的第一次 performTraversals(去走 measure, layout, draw)。
更关键的是,只有当 ViewRootImpl 创建并挂载好之后,应用端才会建立起和底层 InputDispatcher 的连接通路(比如注册 InputEventReceiver)。也就是说,在 onResume 之前,底层的输入事件是根本没有渠道分发给当前 Activity 的。这也从底层源码的角度完美解释了为什么 onStart 只能看,而到了 onResume 才能摸。
所以,总结来说,onStart 是 UI 变得可见的里程碑,而 onResume 是窗口真正挂载并接管输入系统的里程碑。
你怎么学安卓的
嗯,其实我学安卓经历了一个从“会用 API”到“探究底层原理”的过程。先说结论:我的学习方法大致分为三个阶段:打好应用层基础、啃底层框架源码,以及通过输出博客和参与开源项目来闭环验证。
在第一阶段,也就是刚接触 Android 的时候,我主要是为了快速建立正反馈。 我跟着书和官方文档,先把四大组件、UI 布局这些基础概念弄懂。为了避免纸上谈兵,我立刻去动手写了几个实际的 App。在这个过程中,我发现老旧的 MVC 写法越来越难以维护,就顺势切入到了 Google 推荐的 Jetpack 组件库,系统学习了 ViewModel、LiveData、Room,并把项目重构成 MVVM 架构。这段时间主要是积累应用层开发的经验,学会怎么把一个业务需求变成代码跑起来。
然后就到了第二阶段,也就是深入 Framework 源码。
随着应用越写越复杂,我遇到了很多光看 API 文档解决不了的问题。比如遇到了奇奇怪怪的 ANR,或者列表滑动卡顿。我发现只停留在应用层,就像是在黑盒上开发,出了问题只能瞎猜。所以,我下决心要把底层跑通。
我是怎么看源码的呢?首先我学会了自己编译 AOSP。我拉了一份 Android 10 的源码,自己在 Linux 环境下编出镜像,跑在模拟器上。然后我顺着应用层的核心流程往里跟,比如我好奇一个应用是怎么启动的,我就从 startActivity 一直追到 AMS,再追到 Zygote。遇到看不懂的 C++ 代码,我就去学一些 JNI 和基础的 C++ 语法。看源码时,我特别喜欢用 Android Studio 的调试功能,或者直接在 Framework 层里加 Log,重新 make 出 framework.jar 替换进去跑。这种“真刀真枪”验证猜想的感觉特别好。在这个阶段,我逐渐掌握了 AMS、WMS、SurfaceFlinger 这些核心服务的流转机制。
最后是第三阶段,也是我觉得最重要的阶段:输出与实践闭环。 源码这东西极其庞大,看完了如果不总结,不出半个月就会忘得一干二净。所以我养成了写技术博客的习惯。我会把我看过的 Binder 通信原理、Window 添加流程、View 的绘制机制,用自己的话配上时序图梳理出来。写博客逼迫我去查漏补缺,把很多模棱两可的地方彻底弄懂。同时,我也会去 GitHub 上关注一些优秀的开源项目,看看行业里的前沿技术(比如最新的 Compose 或者架构演进),并且尝试去提 PR。
所以我觉得,学安卓不能只停留在背诵八股文,一定要“知其然,并知其所以然”。从解决实际问题出发去读源码,再把源码里的思想沉淀下来变成自己的知识体系,这就是我一直以来的学习方式。
了解View的绘制吗
嗯,了解的。其实 View 的绘制是 Android UI 渲染中最基础但也最核心的一环。先说结论:View 的绘制流程是由系统底层的 Vsync 信号驱动的,核心入口在 ViewRootImpl,主要包含 measure(测量)、layout(布局)和 draw(绘制)这三大主线任务,是一个从根节点向叶子节点深度优先遍历的过程。
如果展开细节来讲,当应用端通过 WindowManager 添加了窗口后,就会创建 ViewRootImpl。当我们的 UI 需要更新(比如调用了 requestLayout 或者 invalidate),ViewRootImpl 并不会马上就去画,而是会向底层的 Choreographer 注册一个回调,等待下一次 Vsync 信号的到来。
Vsync 信号到了之后,会触发大名鼎鼎的 performTraversals() 方法。这个方法可以说是整个 View 树工作的“总导演”,它里面按顺序调度了三个核心动作:
首先是 performMeasure()。系统会根据父容器传递下来的尺寸和模式(也就是 MeasureSpec,包含 EXACTLY、AT_MOST、UNSPECIFIED 三种模式),计算出每一个 View 想要的宽高。这是一个递归的过程,父 View 会根据自身的逻辑生成子 View 的 MeasureSpec 并调用子 View 的 measure() 方法。
其次是 performLayout()。测量完了只是知道了“该多大”,但还不知道“放在哪”。layout 的过程就是确定 View 在父容器里的上下左右坐标(Left, Top, Right, Bottom)。
最后就是 performDraw()。也就是真正的绘制。在这个阶段,其实 Android 系统在演进中做过很大的优化。如果是早期的软件绘制,就是直接拿一个 Canvas 在位图上画像素;但现在默认都是硬件加速了,所以 performDraw() 本质上是构建了一棵 RenderNode 树。每个 View 在 onDraw 里调用的 Canvas API,实际上并没有立刻画出来,而是被转换成了底层的绘制指令(DisplayList),最后整个树的指令被收集起来,交给一个独立的渲染线程(RenderThread),再由它去调用 OpenGL 或者 Vulkan 与 GPU 通信完成真正的光栅化。
所以总结下来,我理解 View 的绘制不仅仅是三个方法的重写,它背后其实是 Framework 层对屏幕刷新机制、布局测量算法以及硬件加速渲染的一个精妙协同。
ViewGroup里面一些子View,是怎么个绘制流程
好的。刚才提到 View 树的绘制是深度优先遍历,其实针对 ViewGroup 这种容器节点,它的绘制逻辑会有自己特定的一套流程。先说结论:ViewGroup 对于子 View 的绘制,核心依赖于 dispatchDraw() 方法,它会遍历自己内部的子 View 列表,根据层级(Z-order)和裁剪区域(ClipRect),依次调用 drawChild() 将画笔交还给各个子 View 去完成各自的渲染。
其实看源码的时候,我们会发现标准的 View.draw() 方法里面规定了严格的步骤,官方注释里写得很清楚,大致是:画背景 -> 画内容(onDraw) -> 画子 View(dispatchDraw) -> 画装饰(比如滚动条)。
对于普通的 View 来说,它没有子节点,所以它的 dispatchDraw 是空实现。但是 ViewGroup 重写了这个方法。在 ViewGroup 的 dispatchDraw 里面,它主要做了这么几件事:
首先,它会根据子 View 们的 Z轴高度(elevation)和绘制顺序 进行排序。如果有动画或者开启了子 View 顺序自定义(比如调用了 setChildrenDrawingOrderEnabled),它还会按照自定义的索引去拿子 View。
然后,它会进入一个循环,遍历 mChildren 数组。对于每一个可见的子 View,ViewGroup 会计算出这个子 View 的显示区域,并通过 Canvas.clipRect() 做好画布的裁剪,防止子 View 画到自己的边界外面去(除非关了 clipChildren 属性)。
接着,最重要的就是调用了 drawChild(canvas, child, drawingTime) 方法。这个方法其实是个包装,它里面核心的逻辑就是去调用那个具体的子 View 的 child.draw(canvas, ...) 方法。这就相当于把接力棒又传回给了 View 的标准绘制流程里。
如果是开启了硬件加速的场景,这个过程其实更加高效。ViewGroup 只需要在遍历的时候,把每个子 View 对应的 RenderNode 挂载到自己的 RenderNode 下面去,构建出一棵树形结构就行了,不需要像软件绘制那样真的去操作画布的像素。这就是 ViewGroup 调度子 View 绘制的核心机制。
如果ViewGroup自己有内容呢
嗯,这是个非常细节但也非常考究底层机制的问题。先说结论:系统为了性能优化,默认会给 ViewGroup 设置一个“不绘制自身”的标志位;但如果 ViewGroup 真的有自己的内容需要画,我们需要通过设置背景,或者显式地清除这个标志位,才能让它的 onDraw() 被正常回调。
其实我在深入看 Framework 源码中 View 和 ViewGroup 的初始化过程时注意到一个很有意思的地方。在 View 的构造函数里,有一个名为 WILL_NOT_DRAW 的 flag,默认是 false 的,意思是我这个 View 有东西要画。但是,在 ViewGroup 的构造函数或者初始化逻辑里,系统会默认调用 setFlags(WILL_NOT_DRAW, DRAW_MASK),强行把这个标志位置为 true。
系统为什么要这么干呢?因为绝大多数情况下,像 LinearLayout、FrameLayout 这样的容器,它们仅仅是作为布局容器存在的,本身并没有背景,也没有额外的图形要画。如果让系统在每一帧都去调用它们的 onDraw(),其实是白白浪费性能。所以加上 WILL_NOT_DRAW 之后,在执行 draw() 方法时,系统如果发现这个标志位是 true,并且没有背景,它就会直接跳过自身内容的绘制(即跳过 onDraw),直接进入 dispatchDraw 去画子 View。
那么如果我们要在一个自定义的 ViewGroup 里画点自己的东西,比如画个边框、或者画个分割线,该怎么办呢?
通常有两种做法。第一种是最常见的,如果你给这个 ViewGroup 设置了一个背景(比如调用了 setBackground),系统在内部检测到有背景后,会自动帮你把 WILL_NOT_DRAW 这个 flag 给清掉。
第二种做法是,如果你不需要背景,就是纯粹想在 onDraw 里画点线段或者图形,那你必须在自定义 ViewGroup 的构造函数里,显式地调用 setWillNotDraw(false)。
只要这个标志位被清除了,ViewGroup 的绘制流程就会和普通 View 一样完整:先画背景,然后走 onDraw() 画自己加的那些内容,画完之后再走 dispatchDraw() 去把内部的子 View 一个个画出来。
Layout是怎么做的
好的。刚才我们聊了绘制,其实在绘制之前,必须得先有 Layout。先说结论:Layout 的核心目的是确定 View 在父容器中的最终位置和实际大小。它也是一个自顶向下的分发过程,主要由 layout() 方法确定自身的坐标,然后通过重写 onLayout() 方法来决定子 View 的摆放规则。
我们可以把 Layout 过程拆分成 View 自身的定位和 ViewGroup 对子 View 的排版两个层面来看。
首先是 View 自身的定位。我们在外层调用一个 View 的 layout(left, top, right, bottom) 的时候,实际上就把这个 View 在父容器里的边界定死了。在 View.layout() 的源码里,它会调用 setFrame() 方法,把这四个坐标值保存到成员变量 mLeft、mTop、mRight、mBottom 中。有了这四个值,View 的实际宽(mRight - mLeft)和高(mBottom - mTop)也就确定了。如果这个坐标相比上一次发生了变化,它还会触发 onSizeChanged() 回调。
然后是 子 View 的排版。在 View.layout() 的最后,会调用 onLayout()。对于普通的单一 View,onLayout 是一个空方法,因为它没有子元素需要排版。但是对于 ViewGroup,onLayout() 是一个抽象方法(abstract),这就意味着所有继承自 ViewGroup 的容器(比如 LinearLayout、RelativeLayout)都必须强制实现这个方法。
在这个方法里,ViewGroup 会去读取之前在 measure 阶段计算好的各个子 View 的测量宽高(getMeasuredWidth 和 getMeasuredHeight)。然后,结合这个 ViewGroup 自身的特性,比如如果是 LinearLayout,它就会根据方向(横向还是纵向),计算出一个累加的偏移量。最后,遍历所有的子 View,给每一个子 View 传入计算好的四个坐标参数,调用子 View 的 layout(l, t, r, b) 方法。
这样一来,父容器确定了自己的位置,又根据自己的排版规则算出了子容器的位置并下发,子容器拿到位置后再去排版孙子容器,形成一个递归,最终整棵 View 树上的每一个节点都有了确切的屏幕坐标。这就是整个 Layout 的核心运作机制。
点击屏幕发生的事
嗯,这是一个非常考验系统级视野的题目。因为我深入学习过 Input 系统和 Framework 源码,所以我不仅能从应用层说,还可以从硬件中断到应用响应的完整链路来理一下。先说结论:点击屏幕是一个跨越硬件驱动、System Server 系统服务、再到应用进程跨进程通信,最终落地到 View 树事件分发的一个极其复杂的全链路过程。
我把这个过程分为四个主要阶段:
第一阶段,内核与驱动层。当我们的手指触碰到屏幕时,屏幕的硬件触控 IC 会产生一个硬件中断。Linux 内核的 Input 驱动捕获到这个中断后,会将原始的触控数据(X、Y坐标、压力等)包装成一个个 input_event 结构体,写入到设备节点中,也就是我们常说的 /dev/input/eventX 文件里。
第二阶段,Framework 的系统服务层。在 System Server 进程中,有一个非常重要的服务叫做 InputManagerService。它底层维护了两个非常核心的 C++ 线程:InputReaderThread 和 InputDispatcherThread。
首先是 InputReader,它通过一个叫 EventHub 的组件,利用 Linux 的 epoll 机制死循环监听 /dev/input 目录。一旦有点击事件,它就立刻读出来,加工转换成 Android 系统能识别的 MotionEvent 格式。接着它把这个事件放进一个队列,交给 InputDispatcher。
InputDispatcher 的任务是“找目标”。它会根据当前 WindowManagerService (WMS) 维护的窗口状态,找到当前处于前台获取焦点的目标 Window。找到之后,怎么发给应用呢?这里用到了一对 Socket(通过 InputChannel 创建的双向通信管道),Dispatcher 会通过这个 Socket 把事件发送给目标应用进程。
第三阶段,应用进程接收层。在我们的 App 进程里,主线程的 Looper 其实早就注册监听了这个 Socket。当事件顺着管道流过来时,应用端对应的 WindowInputEventReceiver 就会被唤醒。事件会先交给当前 Activity 关联的 ViewRootImpl。在 ViewRootImpl 里面,事件会经过一条很长的“责任链”(InputStages),比如先经过输入法(IME)处理,如果不被拦截,最后会流转到 ViewPostImeInputStage。
最后,第四阶段,就是我们最熟悉的 UI 事件分发层。事件被传给了顶层的 DecorView。接着就是典型的 Activity -> PhoneWindow -> DecorView -> ViewGroup -> View 的 U 型事件分发机制了,经过 dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent,最终找到处理这个点击的那个 Button 或者 View。
整个链路非常长,但 Android 通过 epoll 驱动和 Socket 跨进程直连,把这个过程的延迟压到了极低,保证了系统的流畅度。
具体场景,一个ScrollView里有个按钮,点击按住不动,向上滑动,事件分发过程是怎样的
好的,这是一个非常经典的嵌套滑动事件冲突与拦截的场景。先说结论:这个过程体现了 ViewGroup 对事件的动态拦截机制。初始的按下事件会分发给 Button,Button 处于按下状态;但随着手指滑动超过一定阈值,外层的 ScrollView 会触发拦截机制,接管后续的滑动事件,并向 Button 发送一个 ACTION_CANCEL 事件让它重置状态。
结合源码的事件分发逻辑,我们可以把这连贯的动作拆解成三个阶段来看:
第一阶段是 点击按住不动(ACTION_DOWN)。
当手指刚碰到屏幕时,产生 ACTION_DOWN。事件从 Activity 传到 ScrollView(它也是个 ViewGroup)。ScrollView 执行 dispatchTouchEvent,接着调用 onInterceptTouchEvent。对于 DOWN 事件,ScrollView 默认是不拦截的(返回 false)。所以事件继续往下走,传给了内部的 Button。Button 是可点击的,所以它的 onTouchEvent 返回 true,成功消费了这个 DOWN 事件。此时,Framework 会把这个 Button 标记为 mFirstTouchTarget,也就是告诉系统:“后续的事件都默认发给它”。同时,Button 会变暗,进入 pressed 状态。
第二阶段是 开始向上滑动(ACTION_MOVE 触发拦截)。
手指开始移动,产生 ACTION_MOVE 事件。这个事件同样会先来到 ScrollView 的 dispatchTouchEvent。因为它内部已经有 Target 了,所以它会再次调用 onInterceptTouchEvent 来询问是否要拦截。
这里有个极其核心的 Framework 概念叫 TouchSlop(滑动最小阈值)。如果手指滑动的距离很小,没有超过 TouchSlop,ScrollView 认为这是误触,依然返回 false,事件传给 Button。
但是,当你的手指越滑越远,Y 轴的偏移量超过了 TouchSlop,ScrollView 在 onInterceptTouchEvent 里判定用户的意图是要滚动页面了!此时,它会果断返回 true,表示“我要拦截这个事件”。
第三阶段是 事件交接与状态重置(ACTION_CANCEL 与后续响应)。
因为 ScrollView 中途拦截了本该属于 Button 的事件,系统必须得给 Button 一个交代。于是,ScrollView 的 dispatchTouchEvent 内部会偷偷生成一个 ACTION_CANCEL 事件下发给 Button。Button 收到 CANCEL 以后,就知道自己的点击操作被打断了,它会立刻清除 pressed 状态,恢复原来的颜色,并且绝不会触发 onClick 回调。
同时,原本的 mFirstTouchTarget 会被清空。从此以后,随着你的手指继续向上滑动,后续所有的 ACTION_MOVE 事件来到 ScrollView 时,由于没有目标子 View 了,它不再调用 onInterceptTouchEvent,而是直接走自己的 onTouchEvent。在 onTouchEvent 里,它计算滑动的距离,调用 scrollTo 或者 scrollBy 来移动内部的内容。
所以,整个过程就是一个典型的“子 View 先处理,父容器动态观察,满足条件立刻夺权并通知子 View 取消”的精妙设计。这也正好解答了日常开发中各种滑动冲突的底层逻辑。
讲讲Java里的集合
嗯,好的。其实 Java 的集合框架是我们日常开发中使用频率最高的基础组件了。先说结论:Java 里的集合主要分为两大派系,一个是实现了 Collection 接口的单列集合(比如 List、Set、Queue),另一个是实现了 Map 接口的键值对双列集合。它们的设计初衷是为了在不同的业务场景下,提供最高效的数据存储和检索方案。
如果我们在脑海里梳理一下它的源码结构,可以这样展开:
首先是 Collection 这条线。它下面最常用的就是 List 和 Set。
List 的特点是有序、可重复。我们在写 Android 业务的时候,比如给 RecyclerView 喂数据,绝大多数情况用的都是 ArrayList。因为它底层是数组,内存连续,通过下标访问非常快。当然 List 还有一个常用的实现类是 LinkedList,这块它更适合频繁的插入和删除。
Set 的特点则是无序、不可重复。比如我们需要对一批数据进行去重的时候,就会用到 HashSet。其实看源码的话会发现,HashSet 底层就是一个“阉割版”的 HashMap�,它把存进去的元素作为 Map 的 Key,Value 则统一塞一个固定的 Object 占位符(叫做 PRESENT)。
然后是 Map 这条线,它独立于 Collection。
Map 的核心是键值对(Key-Value)映射。最经典的就是 HashMap,它简直是面试和源码阅读的必修课。它是基于哈希表实现的,查询效率极高。除了 HashMap,为了保证遍历顺序,还有 LinkedHashMap(底层用双向链表维护了插入顺序或访问顺序),像我们在 Android 里手写 LRU 缓存(比如 Glide 内存缓存的设计),底层核心依赖的就是 LinkedHashMap 的 accessOrder 特性。
其实,作为 Android 开发者,我在深入了解这些 Java 基础集合后,也会对比去看 Android 专属的优化集合。因为 Java 原生的 HashMap 在每次扩容和插入时,不可避免地会产生大量的 Node 或 Entry 对象,这在内存受限的移动端容易引发频繁的 GC。所以 Google 官方提供了一些内存更紧凑的替代品,比如 SparseArray 和 ArrayMap。它们底层完全抛弃了传统的哈希表和链表,改用两个数组(一个存 Hash 值并保持有序,另一个存真实对象),通过二分查找来定位。虽然牺牲了一点点 CPU 的查询速度(时间复杂度变成了 O(log n)),但极大地节约了内存。我觉得这也是学 Java 集合能在实际 Android 开发中落地的最典型的思考。
ArrayList忽然LinkedList的区别是什么
好的,这确实是一个非常经典的对比。先说结论:ArrayList 和 LinkedList 的核心区别在于它们底层的物理数据结构不同。ArrayList 底层是基于动态数组实现的,内存空间连续;而 LinkedList 底层是基于双向链表实现的,内存空间分散。这种物理结构的差异,直接导致了它们在随机访问和增删操作上的性能表现完全相反。
我们可以从这几个维度详细剖析一下:
第一个维度是查询与访问效率。
因为 ArrayList 是一块连续的内存,它直接通过首地址加上偏移量就能计算出目标元素的内存地址,所以它的随机访问时间复杂度是绝对的 O(1)。比如调用 get(index),速度极快。
而 LinkedList 底层是由一个个 Node 节点组成的,每个节点只记录了前驱(prev)和后继(next)的指针。你要找第 n 个元素,就必须从头(或者从尾)顺着指针一个一个往下找,所以它的时间复杂度是 O(n)。不过我在看 JDK 源码的时候注意到,LinkedList 的 node(int index) 方法做了一点小优化,它会先判断 index 离头近还是离尾近,以此来折半遍历的长度,但依然改变不了 O(n) 的本质。
第二个维度是增删操作的代价。
如果是在列表的中间位置插入或删除一个元素,ArrayList 的代价非常高。因为它必须调用 System.arraycopy(),把该位置之后的所有元素都往后(或往前)挪一个位置,时间复杂度是 O(n)。更麻烦的是,如果 ArrayList 空间不够了,还会触发扩容机制(调用 grow() 方法)。我在 Android Studio 里跟过源码,默认扩容是原来容量的 1.5 倍(oldCapacity + (oldCapacity >> 1)),这需要申请一块更大的新连续内存,然后把老数据全盘拷贝过去,非常消耗性能。
反观 LinkedList,因为它不需要连续内存,所以在已知节点位置的情况下,插入或删除只需要修改前后几个节点的指针引用(比如 prev.next = newNode 这种),时间复杂度是 O(1),而且不存在扩容的问题。
第三个维度是在 Android 开发中的实际选型。
理论上,频繁增删用 LinkedList,频繁读取用 ArrayList。但在我们 Android 开发的真实场景中,绝大多数情况下我们都应该无脑首选 ArrayList。
为什么呢?因为 LinkedList 每添加一个元素,都要 new 一个 Node 对象出来。这种零散的、细碎的对象分配,会导致 Android 的堆内存(Heap)产生大量的内存碎片,进而极易触发虚拟机的 GC(垃圾回收),造成 UI 掉帧卡顿。而 ArrayList 只要初始化时预估好容量(比如 new ArrayList<>(64)),就能一次性分配好内存,对 GC 非常友好。所以其实除非是做类似队列(FIFO)且要在头部极其频繁插入的特殊场景,否则我都推荐用 ArrayList。
Hash集合和Tree集合的区别是什么
嗯,这个问题探讨到了数据结构更深层次的选型。先说结论:Hash 集合(如 HashSet/HashMap)和 Tree 集合(如 TreeSet/TreeMap)最根本的区别在于“是否有序”以及“底层驱动的算法”。Hash 集合依赖哈希表,追求的是极致的增删改查速度(接近 O(1)),但元素是完全无序的;而 Tree 集合底层依赖红黑树,它会牺牲一定的速度(退化为 O(log n)),但能保证所有元素始终处于一个全局有序的状态。
我们可以拿最典型的 HashMap 和 TreeMap 来展开对比一下底层的原理:
首先说 HashMap。我之前详细啃过 JDK 1.8 里面 HashMap 的源码。它底层是一个**“数组 + 链表 + 红黑树”**的混合结构。
当你 put 一个元素进去的时候,它会先调用键对象的 hashCode() 方法算出一个哈希值,然后经过一次扰动函数(高低 16 位异或),最后和数组长度减一做按位与运算(& (n-1)),直接定位到数组的槽位(Bucket)。这个过程非常快。如果发生哈希冲突,它会用链表挂在这个槽位下面;当链表长度超过 8 且数组总长度达到 64 时,还会触发 treeifyBin() 方法,把链表转化为红黑树来兜底查询性能。因为这个算下标的过程和放入顺序无关,只跟 Hash 值有关,所以你遍历 HashMap 的时候,拿出来的顺序是完全随机的。
然后我们看 TreeMap。
TreeMap 的底层是没有数组概念的,它彻彻底底就是一棵红黑树(Red-Black Tree)。当你往 TreeMap 里 put 元素时,它不需要算哈希值,而是必须要比较大小。这就要求你存入的 Key 要么自己实现了 Comparable 接口,要么你在创建 TreeMap 的时候传一个自定义的 Comparator 进去。
在源码里,每次插入新节点,它都会从树根开始比对,比当前节点小就往左子树走,大就往右子树走。找到位置插进去之后,为了防止树退化成链表,它还会调用内部复杂的 fixAfterInsertion() 方法,通过左旋、右旋和重新着色,来维持红黑树的绝对平衡。正是因为这种严密的插入逻辑,TreeMap 里的元素永远是按大小排好序的。
所以在实际场景的应用上,它们的定位非常清晰。
如果我们只是为了单纯的缓存数据,或者通过一个唯一 ID 去快速捞一个对象,没有任何排序需求,那绝对是闭眼用 HashMap,因为它的时间开销极小。
但如果我们的业务需求是“帮我找出排行榜上积分前 10 名的用户”,或者是“查询价格在 100 到 200 块之间的商品”,这种涉及到范围查找或者顺序遍历的场景,用 HashMap 就不行了,这时候就必须上 TreeMap。我觉得理解这两个集合,本质上就是理解“哈希映射”和“二叉搜索树”在工程上的取舍。
了解过Java中的并发吗,多线程间并发处理有哪些方法
嗯,了解过。在 Android 开发中并发处理是非常关键的,因为我们有严格的“主线程(UI线程)不能阻塞”的铁律,遇到网络请求、数据库读写或者大量计算,都必须切到子线程,这就必然会遇到多线程间的并发与同步问题。先说结论:Java 中的并发处理核心是为了解决多线程环境下的“可见性、原子性和有序性”问题。我们常用的处理方法主要经历了三个层级的演进:基础的关键字(synchronized/volatile)、J.U.C 并发包下的锁机制(如 ReentrantLock),以及用于统筹管理的线程池(ThreadPoolExecutor)。
我们可以一层层地来细说一下这些并发处理方法:
第一种,也是最基础的,是内置锁 synchronized 和轻量级同步 volatile。
synchronized 是 JVM 层面的关键字。我在研究它底层原理时了解到,它编译后会在代码块前后生成 monitorenter 和 monitorexit 字节码指令,本质上是依赖操作系统的 Mutex Lock 来实现互斥的。它能保证修饰的代码块在同一时刻只有一个线程能执行,从而保证原子性。在 JDK 1.6 之后,官方对它做了大量优化,引入了偏向锁、轻量级锁和自旋锁,让它没那么“重”了。
而 volatile 比较特殊,它主要解决的是“可见性”和“禁止指令重排”。比如在实现单例模式(DCL双重校验锁)时,我们必须给实例对象加上 volatile,就是为了防止对象的初始化指令发生重排序,导致别的线程拿到了一个只分配了内存但还没初始化的半成品对象。不过 volatile 不保证原子性,这是要注意的。
第二种方法,是用到 java.util.concurrent (J.U.C) 包下的显式锁机制,最典型的就是 ReentrantLock。
相比于 synchronized 这种把人锁死在屋里的做法,ReentrantLock 是基于 API 层面的锁,显得更加灵活。我深读过这块的源码,它的核心基石是 AQS (AbstractQueuedSynchronizer) 和 CAS (Compare-And-Swap) 乐观锁。在 AQS 里有一个 volatile int state 变量代表同步状态,线程通过 CAS 去尝试修改这个状态来抢锁,抢不到就被放进一个双向队列里阻塞等待。
在开发中,如果我们遇到需要“尝试获取锁但设置超时时间”(tryLock),或者想要实现“公平锁”(按排队顺序拿锁),再或者需要响应中断,那 synchronized 就无能为力了,必须用 ReentrantLock。
第三种方法,是基于线程池(ThreadPoolExecutor)的任务级并发管理。
其实在真实的工程里,我们是绝对禁止直接 new Thread().start() 的。因为线程的创建和销毁极其消耗操作系统资源,而且不可控。所以我们会用线程池来做并发。
在 Android 里,早期的 AsyncTask 底层就是封装了内部的静态线程池。线程池的核心在于它的几个参数:核心线程数、最大线程数、阻塞队列。它的调度逻辑非常清晰:任务来了,先看核心线程满没满;满了就塞进队列;队列也满了,再开非核心线程;直到达到了最大线程数,才会触发拒绝策略。通过线程池,我们能很好地控制并发的线程数量,避免把系统资源榨干导致 OOM。
当然,如果贴近现代的 Android 开发,我们现在更多的已经转向了 Kotlin 协程(Coroutines)。但协程的底层,它所谓的 Dispatchers.IO 或 Dispatchers.Default,本质上依然是复用了 Java 的线程池机制。所以我觉得,万变不离其宗,把 Java 这一套并发底层原理(JMM、锁机制、线程池)吃透,是做复杂 Framework 和应用层架构的根基。
讲讲synchronized的特点
嗯,好的。其实 synchronized 是 Java 里最基本也是最经典的一种并发控制机制。先说结论:synchronized 的核心特点是它是一个内置的、非公平的、可重入的悲观锁,它可以保证代码块或方法在多线程环境下的原子性、可见性和有序性。而且随着 JDK 1.6 的底层架构演进,它引入了锁升级机制,现在已经不再是我们早期认为的那么“笨重”了。
如果要扒开底层源码来详细聊的话,我觉得可以从它的实现原理和锁升级过程两个方面来说。
首先是在 JVM 底层的实现机制上。我们在写代码的时候,给一个方法或者代码块加上 synchronized,经过 javac 编译后,其实在字节码层面会生成一对 monitorenter 和 monitorexit 指令。
当我们去看 HotSpot 虚拟机的 C++ 源码时,会发现每一个 Java 对象在底层都关联着一个 ObjectMonitor 对象(对象监视器)。这个监视器里有几个非常关键的属性:一个是 _owner,用来指向当前持有锁的线程;还有一个 _WaitSet 队列,用来存放调用了 wait() 方法被挂起的线程;以及一个 _EntryList,用来存放那些排队等待竞争锁的线程。
另外,它之所以是可重入的,是因为 ObjectMonitor 里面维护了一个 _recursions 计数器。如果当前线程已经拿到了锁,再次进入被该锁保护的代码块时,只需要把计数器加一就可以了,出来的时候减一,减到零才会真正释放锁。这就避免了自己把自己死锁的尴尬情况。
其次,就是它极其重要的锁升级机制。
在早期的 JDK 版本里,synchronized 一上来就会去向操作系统申请 Mutex Lock(互斥量),这涉及到从用户态切到内核态的上下文切换,开销极大,也就是所谓的“重量级锁”。但在现代的 JDK 里,系统给 Java 对象头的 Mark Word 里设计了不同的锁状态。
当我们刚创建一个对象时,它是无锁状态。当第一个线程来访问同步块时,系统并不会去向 OS 申请锁,而是直接用 CAS(比较并交换)操作,把当前线程的 ID 记录到对象的 Mark Word 里,这叫偏向锁。因为统计发现,绝大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。
如果这时候有第二个线程来竞争了,偏向锁就会升级成轻量级锁。这时候系统会在当前线程的栈帧里开辟一块 Lock Record 空间,然后尝试用 CAS 把对象头的 Mark Word 替换为指向这块记录的指针。如果成功了,就拿到锁;如果失败了,线程不会立刻阻塞,而是会执行一段自旋操作(就是写个空循环等一会儿),看看持有锁的线程会不会马上释放。
只有当自旋也失败了,竞争变得非常激烈时,它才会最终膨胀成重量级锁,这个时候没抢到锁的线程才会被真正挂起,进入阻塞状态。
所以总结下来,synchronized 的特点就是使用简单,由 JVM 自动管理加解锁,并且在底层有一套非常精妙的从偏向到重量级的自适应优化策略。
讲讲volatile
好的。volatile 是 Java 并发编程里另一个出场率极高的关键字,可以说是轻量级的同步机制。先说结论:volatile 关键字的核心特点只有两个:第一是保证多线程环境下的“内存可见性”,第二是“禁止指令重排”。但它最大的局限性在于,它绝对不保证复合操作的原子性。
我们可以结合 JMM(Java 内存模型)来把这两个特点拆解开来说。
第一个特点,保证内存可见性。
在 Java 内存模型里,为了提高运行效率,每个线程都有自己私有的“工作内存”(也就是 CPU 高速缓存和寄存器的抽象),而所有的共享变量都存放在主内存中。当一个线程读取变量时,会先拷贝一份到自己的工作内存里去操作。如果这时候线程 A 把一个变量的值改了,但还没来得及刷回主内存,线程 B 去读的时候,读到的依然是旧值,这就叫可见性问题。
但是,当我们用 volatile 修饰一个变量时,JVM 会给它加上特殊的语义:只要有一个线程修改了它的值,底层(通常通过 CPU 的 MESI 缓存一致性协议)会立刻强制将新值刷新回主内存,并且会让其他所有线程工作内存里的这个变量缓存立刻失效。这样一来,其他线程下次再去读的时候,发现缓存失效了,就必须被迫去主内存里读最新的值。所以说,volatile 变量的改动对所有线程都是实时可见的。
第二个特点,禁止指令重排序。
现在的编译器和 CPU 为了榨干性能,往往会对我们的代码指令进行重新排序执行,只要单线程下的最终执行结果不变就行。但这在多线程下会引发致命问题。
最经典、也是我们在 Android 源码里看单例模式时最常见的例子,就是 DCL(双重检查锁定)单例模式。我们在写 instance = new Singleton(); 这句代码时,它其实并不是一步到位的,底层分为了三步操作:
- 分配对象的内存空间;
- 调用构造方法初始化对象;
- 把
instance引用指向这块内存地址。 如果发生指令重排,执行顺序变成了 1 -> 3 -> 2,那么当线程 A 执行完 1 和 3,此时instance已经不是 null 了,但对象还没初始化完。这时候如果线程 B 进来判断instance != null,直接拿去用了,就会导致程序崩溃。 所以我们必须在instance前面加上volatile。加上之后,JVM 会在生成的汇编指令中插入内存屏障(Memory Barrier)。内存屏障的作用就像是一堵墙,它规定了屏障前后的指令绝对不允许交叉重排,从而完美解决了 DCL 的半初始化问题。
最后,为什么说它不保证原子性?比如我们常见的 i++ 操作,它其实是“读取 -> 加一 -> 写回”三个步骤。即使 i 是 volatile 的,如果有两个线程同时读取了主内存的 i,各自在自己的线程里加一,然后再写回,就会导致数据被覆盖。遇到这种涉及到原子性的场景,咱们就不能依赖 volatile 了,必须得上 synchronized 或者 J.U.C 里的 AtomicInteger。
线程和协程的区别
嗯,这个问题在现在的 Android 开发中非常核心,因为自从 Google 强推 Kotlin 之后,协程基本上已经取代了早期的 AsyncTask 和 RxJava。先说结论:线程是操作系统级别的并发单元,由内核进行抢占式调度,极其耗费资源;而协程是应用层面的“轻量级线程”,由用户态的运行时逻辑(比如 Kotlin 内部的协程调度器)进行协作式调度,资源开销极小。
我觉得如果从我们 Framework 或者底层源码的视角来看,可以从这三个维度把它们的区别理得很清楚:
第一个维度是调度者的不同,这直接决定了上下文切换的代价。 线程的调度权在操作系统内核手里(比如 Linux 的 CFS 调度器)。当发生线程切换时,CPU 需要从用户态切换到内核态,保存当前线程的执行上下文(包括各种寄存器的值、程序计数器 PC 等),还要刷新 CPU 缓存(TLB),这个开销是非常昂贵的。 而协程的调度权完全在用户态(JVM 级别)。在 Kotlin 里,协程的切换本质上只是普通的 Java 方法调用和对象状态的流转,根本不需要陷入系统内核。它不存在内核层面的上下文切换,所以速度飞快。
第二个维度是底层的实现原理不同。
其实很多人一开始学协程有个误区,以为协程脱离了线程。其实不是的,协程是跑在线程上的任务封装。
我在反编译 Kotlin 协程的字节码时看过,当我们写一个带 suspend 关键字的挂起函数时,Kotlin 编译器会在底层把它转换成一个状态机(实现了 Continuation 接口)。代码一旦遇到 delay 或者网络请求这种耗时操作,就会保存当前状态机的 label(也就是挂起点),并且把当前所在的工作线程“让出去”给别的协程用。等 IO 操作结束了,系统再通过一个回调机制(CPS 续体传递风格),把刚才保存的状态机恢复(resumeWith),继续往下执行。所以协程其实就是一套高级的异步回调状态机,它把复杂的异步回调压平了,让我们能用同步的代码结构写异步逻辑。
第三个维度是在并发模型上的差异。
线程是“抢占式”的,谁能抢到时间片谁就跑,开发者很难精准预测执行时机,所以需要加一堆锁来防竞态条件。而很多协程机制(比如早期的 Python 或者部分 Go 逻辑)是“协作式”的,需要代码主动让出控制权(比如 yield 或者 suspend)。不过在 Kotlin 协程里,我们通常也是配合 Dispatchers.IO、Dispatchers.Main 这种底层的线程池来实现跨线程的并发调度的,所以它本质上是对线程池做了一层非常优雅的、支持非阻塞挂起的封装。
总结来说,线程是对 CPU 执行过程的抽象,而协程是对普通代码块或者说计算任务的抽象。
他们的上限有区别吗
是的,有区别,而且这个上限差异极其巨大。先说结论:在同一台机器上,系统能创建的线程数量是有严格物理和操作系统上限的,通常在几千到一两万级别就会崩溃;但是由于协程极度轻量,单机创建几十万甚至上百万个协程是完全没有压力的。
我们可以算一笔账,这也是我在深入理解 JVM 内存模型时常常会思考的问题。
首先看线程的上限。
在 Android 或 Linux 系统下,当你 new Thread().start() 的时候,JVM 必须去向操作系统申请创建一个真实的 pthread(POSIX 线程)。每个线程只要一创建,系统默认就会给它分配一块私有的线程栈内存。在 64 位系统下,这个默认栈大小通常是 1MB。
也就是说,如果你一口气创建了 1000 个线程,哪怕这些线程什么都不干,光是占用虚拟内存就已经达到了 1GB。如果我们真在代码里写个死循环去创建线程,很快就会触发 OutOfMemoryError: pthread_create failed,报错原因一般是底层内存不足,或者达到了系统内核参数 /proc/sys/kernel/threads-max 对进程最大线程数的硬性限制。
我们再来看协程的上限。
协程为什么能创建那么多?因为在 Kotlin 里,创建一个协程(比如调用 GlobalScope.launch),本质上只是在 Java 的堆内存(Heap)里面 new 了一个实现了 Continuation 接口的普通 Java 对象而已。
这个对象非常小,里面只保存了几个局部变量、一个状态机进度(label)和一些上下文引用,大小通常只有几十个字节,撑死几百字节。
当你用一个 delay(1000) 去挂起这 10 万个协程的时候,它们并不会去阻塞底层的线程,也不会去霸占 CPU 时间片。它们仅仅是被存放到了协程调度器的内存队列里,底层的那个定时器线程(或者线程池里的极少几个线程)依然在欢快地跑着,等时间到了再把它们捞出来执行。所以只要你的 JVM 堆内存(比如有几个 G)没有被撑爆,你开一百万个协程也是轻轻松松的。
这其实也是为什么我们在 Android 应用层开发中,遇到大量的、高并发的 I/O 密集型任务(比如并发去请求十几个接口然后组合数据),必须用协程来替代手动开线程的原因。协程用极小的内存开销,完美绕过了操作系统对线程数量的严格钳制。
http和https的区别
好的。这是一个经典的计算机网络题目,不仅面试常考,其实在我们实际做 Android 网络层(比如配置 OkHttp)的时候也天天打交道。先说结论:HTTP 是明文传输的无状态应用层协议,数据极其容易被窃听和篡改;而 HTTPS 本质上就是“HTTP + SSL/TLS 安全层”,它通过加密、数字证书和完整性校验三大机制,建立了一条从客户端到服务端的安全通信通道。
如果展开对比的话,我觉得有以下这几个核心的差异点:
第一,数据传输的安全性。 HTTP(超文本传输协议)在网络中跑的都是明文。假如你在咖啡馆连了公共 Wi-Fi,黑客只需用 Wireshark 之类的抓包工具,就能直接把你发送的账号、密码或者 Json 报文看得一清二楚。 而 HTTPS 里面的那个 'S' 代表 Secure(安全)。它在 HTTP 和底层的 TCP 协议之间加了一层 SSL/TLS 协议。你在应用层发的明文数据,经过这一层的时候会被高强度的对称加密算法(比如 AES)加密成密文,然后再发向网络。即便黑客截获了数据包,拿到的也是一堆乱码,没有密钥根本解不开。
第二,身份认证机制。
用 HTTP 的时候,你向 www.bank.com 发请求,你其实并不知道对端是不是真的是银行的服务器,可能中途被 DNS 劫持到了一个钓鱼网站。
HTTPS 引入了**数字证书(Certificate)**机制。服务端必须去权威的 CA 机构(证书授权中心)申请一张证书。客户端(比如我们 Android 系统的 TrustManager)在建立连接的时候,会去校验这张证书里的公钥、域名信息以及 CA 的数字签名,确认这台服务器的身份是合法且真实的,这就防住了中间人攻击(MITM)。
第三,端口和性能开销。 最直观的一点,HTTP 的默认端口是 80,而 HTTPS 的默认端口是 443。 在性能上,HTTP 只要走完 TCP 的三次握手就可以开始发数据了,速度很快。但是 HTTPS 在 TCP 握手之后,还必须紧接着进行极其复杂的 TLS 四次握手(或者叫密钥协商过程)。这个过程涉及到非对称加密的计算和多次网络 RTT(往返时延),所以建立连接的耗时会比 HTTP 长不少。不过现在有了 TLS 1.3 的加持,这个延迟已经被极大优化了。
第四,其实也是结合我们 Android 实际开发视角的区别。
从 Android 9.0(Pie)开始,Google 官方强制要求了网络安全策略(Network Security Configuration),默认全面禁止了明文的 HTTP 流量(usesCleartextTraffic 默认被置为 false)。也就是说,除非我们在 Manifest 里开后门,否则 App 里直接发 HTTP 请求是会被系统底层拒绝并抛异常的,这就倒逼着我们必须全面拥抱 HTTPS。
https握手过程
嗯,HTTPS 的握手过程确实非常复杂,但如果弄懂了它背后“非对称加密”和“对称加密”的配合逻辑,就很好理解了。先说结论:HTTPS 的握手过程(以 TLS 1.2 为例),本质上是客户端和服务端互相验证身份,并通过非对称加密(如 RSA 或 ECDHE)安全协商出一个“对称密钥(Session Key)”的过程。这个握手通常需要耗费两个网络往返时间(2-RTT)。
我在做网络优化和抓包分析的时候,经常会盯着这个过程看。我们可以把它拆解成以下几个明确的步骤:
第一步是 Client Hello。
握手总是由客户端发起的。客户端(也就是咱们的 App)会向服务端发送一个 Client Hello 报文。这里面包含了三个关键信息:客户端支持的最高 TLS 版本、支持的加密套件列表(比如 ECDHE-RSA-AES256-GCM-SHA384 这种一长串的名字),以及一个非常重要的客户端随机数(Client Random)。
第二步是 Server Hello。
服务端收到后,会回复一个 Server Hello。它会从客户端提供的加密套件里挑一个大家都支持的,然后也生成一个**服务端随机数(Server Random)发给客户端。
紧接着,服务端会把自己的数字证书(Certificate)**发给客户端。这个证书里面包含了服务端的公钥、域名信息和 CA 的签名。如果是要求双向认证的场景,服务端还会请求客户端发证书,但我们普通 App 一般不需要。
第三步是 客户端验证证书与生成预主密钥。 我们的 Android 系统内部预置了很多根 CA 证书。客户端拿到服务端下发的证书后,会用系统的 CA 公钥去解密证书的数字签名,验证这个证书确实是合法的,且域名能对上。 验证通过之后,客户端的安全机制会生成第三个随机数,叫做预主密钥(Premaster Secret)。由于这个预主密钥极其关键,绝对不能被窃听,所以客户端会用刚才从证书里提取出来的服务端的公钥对它进行非对称加密,然后再发送给服务端。
第四步是 服务端解密与生成最终会话密钥。
服务端收到这串密文后,用只有自己才掌握的私钥将其解开,成功拿到了预主密钥。
重点来了!此时此刻,客户端和服务端手里都同时握有了三个信息:Client Random�、Server Random 和 Premaster Secret。双方会使用前面协商好的哈希算法,用这三个材料进行一次数学运算,生成一串相同的、最终用于加密后续传输内容的对称密钥(Session Key)。之所以搞这么复杂用三个随机数,是为了防止重放攻击,确保每一次握手生成的密钥都是独一无二的。
最后一步是 Finished(验证密钥)。
双方都生成好对称密钥后,会互相发送一个 Finished 消息。这个消息是用刚才算出来的对称密钥加密的,里面包含了整个握手过程的校验数据。如果对方都能成功解密并校验通过,说明这把锁和钥匙没问题。
到这里,耗时耗力的 TLS 握手就正式结束了。接下来的所有 HTTP 报文,都会用这把协商出来的“对称密钥”进行极速的加密和解密传输了。
讲讲ClassLoader的双亲委派机制
嗯,好的。其实双亲委派机制无论是 Java 还是 Android 的面试,都是绕不开的底层基础。先说结论:双亲委派机制的核心思想是“向上委托,向下查找”。当一个 ClassLoader 收到加载类的请求时,它不会自己先去加载,而是把这个请求一层层向上委托给父加载器去完成,直到最顶层的 Bootstrap ClassLoader;只有当父加载器在自己的搜索范围内找不到这个类时,子加载器才会尝试自己去加载。
我们可以深入到源码层面来看看这个过程是怎么实现的。其实这一切的逻辑都写在 ClassLoader 的 loadClass(String name, boolean resolve) 方法里。
首先,当调用 loadClass 时,系统会先调用 findLoadedClass(name) 检查这个类是不是已经被加载过了。如果加载过,直接返回缓存里的 Class 对象,这就避免了重复加载。
然后,如果这个类没被加载过,它就会去检查自己内部维护的 parent 成员变量(注意,这里的 parent 是一个组合关系,而不是继承关系)。如果 parent 不为空,它就会去调用 parent.loadClass()。如果 parent 为空,说明已经委托到了最顶层,系统会调用 findBootstrapClassOrNull(),交由底层的 Bootstrap ClassLoader 去处理(在 Android 里主要是 BootClassLoader)。
最后,如果这通向上委托走完,父加载器都抛出 ClassNotFoundException 或者返回 null,说明长辈们都找不到这个类。这时候,子加载器才会调用自己的 findClass(name) 方法,真正去磁盘或者 Dex 文件里读取字节码进行加载。
系统为什么要设计这么一套看似繁琐的机制呢?其实主要有两个极其重要的目的:
第一,是防止重复加载。只要父加载器加载过,子加载器就不需要再加载一遍,保证了全局唯一性。
第二,也是最重要的,是为了安全(沙箱机制)。你想想,如果没有双亲委派,我自己写一个黑客类,名字就叫 java.lang.String,里面植入恶意代码。有了双亲委派之后,系统加载 String 类时,一定会一路委托到最顶层的 Bootstrap ClassLoader,系统加载器会发现:“哎,这个类我已经在系统库里加载过了”,然后直接返回系统安全的那个 String 类。你自己写的那个伪造类,根本就没有机会被执行。这就从源头上保证了核心 API 不被篡改。
PathClassLoader和DexClassLoader的区别
这是一个极具 Android 版本年代感的问题。因为如果脱离了 Android 版本去谈它们的区别,其实是不准确的。先说结论:在 Android 8.0(API 26)之前,PathClassLoader 只能用于加载已经安装的 APK,而 DexClassLoader 可以加载外部存储(SD 卡)上未安装的 APK、Dex 或 Jar 文件。但是,从 Android 8.0 开始,官方在底层源码上抹平了它们的差异,现在这两个类在功能上已经完全一样了,都可以加载外部的 Dex 文件。
我们可以结合 Framework 的源码演进,把这里的细节拆开聊一下。
不管是 PathClassLoader 还是 DexClassLoader,它们在源码里其实都非常薄,它们都继承自同一个父类——BaseDexClassLoader。真正干活的,其实是 BaseDexClassLoader 内部的那个 DexPathList。
在 Android 8.0 之前,当我们去 new DexClassLoader 的时候,它的构造函数里有四个参数:dexPath(你要加载的 dex 路径)、optimizedDirectory(优化的 odex 输出目录)、librarySearchPath(so 库路径)和 parent。
那个年代,因为虚拟机的机制,Dex 文件被加载前需要被 dexopt 优化成 odex 文件。DexClassLoader 允许你传入一个私有的 optimizedDirectory 目录(比如你 App 的 data 目录),让系统把外部 SD 卡上的 dex 优化并存放到这里,所以它可以加载外部的代码,非常适合用来做热修复或者插件化。
而反观老版本的 PathClassLoader,它的构造函数里直接把 optimizedDirectory 这个参数写死了为 null。当传 null 时,系统默认会把优化后的文件放到 /data/dalvik-cache 这个系统目录里。普通应用是没有权限往这个目录写的,所以它只能用来加载系统已经帮我们安装好、优化好的 APK 文件。
但是,到了 Android 8.0 及以后,如果我们去看 BaseDexClassLoader 的源码,就会发现 optimizedDirectory 这个参数被废弃了(标记为 @Deprecated 并且在底层直接被忽略)。
为什么呢?因为 ART 虚拟机的演进,现在不再需要开发者手动去指定优化目录了,系统内部会自动根据 dexPath 来决定并生成对应的 .oat 或 .vdex 文件。既然不需要指定优化目录了,那 PathClassLoader 因为写死 null 带来的限制也就自然消失了。
所以总结下来,现在咱们做 SDK 或者插件化开发,直接用 PathClassLoader 也是完全可以动态加载 SD 卡上的 Dex 文件的。它们现在的区别仅仅停留在类名的语义层面上了。
native怎么调java
嗯,其实不管是刚刚提到的 Framework 底层事件分发,还是我们自己写 NDK 业务,Native 调 Java 都是一套非常标准化的流程。先说结论:Native 层调用 Java 层函数,核心是依赖 JNI (Java Native Interface) 机制,必须获取到与当前线程绑定的 JNIEnv 指针,然后通过它去反射查找到 Java 的目标 jclass,再拿到对应的方法 ID(jmethodID),最后发起调用并处理返回值。
我可以把这个流程总结为四个必须要走的关键步骤:
第一步是 环境的获取(JNIEnv)。
如果是 Java 层主动调下来的 JNI 函数,系统已经把 JNIEnv* 作为第一个参数传给我们了,直接用就行。但如果你是在 C++ 里自己开了一个 pthread 异步线程(比如做完底层音视频解码想要回调给 Java),这个新线程里是没有 JNIEnv 的。这时候,必须先通过全局保存的 JavaVM* 指针,调用 AttachCurrentThread 方法,把当前 C++ 线程依附到 ART 虚拟机上,这样才能拿到专属的 JNIEnv。用完之后,一定记得调 DetachCurrentThread 释放,否则会内存泄漏。
第二步是 寻找目标类(jclass)。
我们有了 env,接下来就是调用 env->FindClass("com/example/MyClass")。这里传的是类的全限定名,点要换成斜杠。如果是经常要用的类,通常会在 JNI_OnLoad 的时候查出来,然后通过 env->NewGlobalRef 转换成全局引用缓存起来,防止被虚拟机的 GC 回收掉。
第三步是 获取方法ID(jmethodID)。
拿到类之后,如果是普通方法就调 GetMethodID,静态方法调 GetStaticMethodID。这里除了传方法名,最容易踩坑的就是方法签名(Signature)。比如你想调一个接收 String 返回 int 的方法,签名必须写成 (Ljava/lang/String;)I。因为 Java 有方法重载,Native 只能通过这串奇怪的签名字符串来精确定位。
最后一步是 执行调用。
拿到了 jmethodID,接下来就是拼装参数。如果 Java 方法返回的是 void,就调 env->CallVoidMethod(jobject, jmethodID, ...args);如果返回对象,就调 CallObjectMethod。如果是静态方法,那一套函数就是 CallStaticXXXMethod。
其实在 Android 底层,比如 Binder 机制,底层 C++ 的 JavaBBinder 收到 IPC 请求时,就是通过预先缓存好的 execTransact 方法的 ID,通过这套流程把请求抛回给 Java 层的 Binder.java 的。整个 Native 调 Java 的本质,就是一个受严格控制的底层反射过程。
cpp的string转jstring的两种方式
好的。我们在做 NDK 开发的时候,C++ 的 std::string 和 Java 的 String(也就是 JNI 里的 jstring)的转换是非常高频的操作。先说结论:通常有两种方式可以把 C++ 的 string 转成 jstring。第一种是最常见的,直接调用 JNI 提供的内置函数 NewStringUTF;第二种是相对底层且安全的做法,先在 C++ 创建一个 jbyteArray,然后通过 JNI 反射调用 Java 层 String 的带字节数组和编码格式的构造函数来生成。
我们可以对比一下这两种方式的实现细节和适用场景。
第一种方式:使用 env->NewStringUTF(const char* bytes)
这是代码写起来最爽、最直接的一招。如果我们在 C++ 里有一个 std::string cppStr,只需要一行代码:jstring jStr = env->NewStringUTF(cppStr.c_str()); 就能搞定。
但是,这里其实藏着一个巨大的“深坑”。JNI 接口里的这个 UTF,并不是我们常规意义上的标准 UTF-8,而是 Modified UTF-8(变种 UTF-8)。在绝大多数情况下(比如英文字母和常见的汉字),这两者是兼容的,转起来没问题。但如果你的 C++ string 里包含了一些四字节的 Unicode 字符(比如很生僻的汉字,或者 Emoji 表情),标准的 UTF-8 是用 4 个字节存的,而 JNI 的 Modified UTF-8 要求用两个 3 字节的代理对(Surrogate Pair)来存。这时候如果你强行用 NewStringUTF 去转,底层在做合法性校验时就会失败,直接导致 JNI 奔溃报错,或者传到 Java 层变成乱码。
这就引出了第二种方式:通过反射调用 Java 的 String 构造函数。
这种方式虽然写起来繁琐,但它是绝对安全的,因为我们把解码的权力交还给了 Java 虚拟机,而且可以指定任意的字符集(比如 GBK、UTF-8 等)。
它的源码流程大概是这样的:
- 首先,我们用
env->FindClass("java/我们可以深入到源码层面来看看这个过程是怎么实现的。其实这一切的逻辑都写在ClassLoader的loadClass(String name, boolean resolve)` 方法里。
首先,当调用 loadClass 时,系统会先调用 findLoadedClass(name) 检查这个类是不是已经被加载过了。如果加载过,直接返回缓存里的 Class 对象,这就避免了重复加载。
然后,如果这个类没被加载过,它就会去检查自己内部维护的 parent 成员变量(注意,这里的 parent 是一个组合关系,而不是继承关系)。如果 parent 不为空,它就会去调用 parent.loadClass()。如果 parent 为空,说明已经委托到了最顶层,系统会调用 findBootstrapClassOrNull(),交由底层的 Bootstrap ClassLoader 去处理(在 Android 里主要是 BootClassLoader)。
最后,如果这通向上委托走完,父加载器都抛出 ClassNotFoundException 或者返回 null,说明长辈们都找不到这个类。这时候,子加载器才会调用自己的 findClass(name) 方法,真正去磁盘或者 Dex 文件里读取字节码进行加载。
系统为什么要设计这么一套看似繁琐的机制呢?其实主要有两个极其重要的目的:
第一,是防止重复加载。只要父加载器加载过,子加载器就不需要再加载一遍,保证了全局唯一性。
第二,也是最重要的,是为了安全(沙箱机制)。你想想,如果没有双亲委派,我自己写一个黑客类,名字就叫 java.lang.String,里面植入恶意代码。有了双亲委派之后,系统加载 String 类时,一定会一路委托到最顶层的 Bootstrap ClassLoader,系统加载器会发现:“哎,这个类我已经在系统库里加载过了”,然后直接返回系统安全的那个 String 类。你自己写的那个伪造类,根本就没有机会被执行。这就从源头上保证了核心 API 不被篡改。
PathClassLoader和DexClassLoader的区别
这是一个极具 Android 版本年代感的问题。因为如果脱离了 Android 版本去谈它们的区别,其实是不准确的。先说结论:在 Android 8.0(API 26)之前,PathClassLoader 只能用于加载已经安装的 APK,而 DexClassLoader 可以加载外部存储(SD 卡)上未安装的 APK、Dex 或 Jar 文件。但是,从 Android 8.0 开始,官方在底层源码上抹平了它们的差异,现在这两个类在功能上已经完全一样了,都可以加载外部的 Dex 文件。
我们可以结合 Framework 的源码演进,把这里的细节拆开聊一下。
不管是 PathClassLoader 还是 DexClassLoader,它们在源码里其实都非常薄,它们都继承自同一个父类——BaseDexClassLoader。真正干活的,其实是 BaseDexClassLoader 内部的那个 DexPathList。
在 Android 8.0 之前,当我们去 new DexClassLoader 的时候,它的构造函数里有四个参数:dexPath(你要加载的 dex 路径)、optimizedDirectory(优化的 odex 输出目录)、librarySearchPath(so 库路径)和 parent。
那个年代,因为虚拟机的机制,Dex 文件被加载前需要被 dexopt 优化成 odex 文件。DexClassLoader 允许你传入一个私有的 optimizedDirectory 目录(比如你 App 的 data 目录),让系统把外部 SD 卡上的 dex 优化并存放到这里,所以它可以加载外部的代码,非常适合用来做热修复或者插件化。
而反观老版本的 PathClassLoader,它的构造函数里直接把 optimizedDirectory 这个参数写死了为 null。当传 null 时,系统默认会把优化后的文件放到 /data/dalvik-cache 这个系统目录里。普通应用是没有权限往这个目录写的,所以它只能用来加载系统已经帮我们安装好、优化好的 APK 文件。
但是,到了 Android 8.0 及以后,如果我们去看 BaseDexClassLoader 的源码,就会发现 optimizedDirectory 这个参数被废弃了(标记为 @Deprecated 并且在底层直接被忽略)。
为什么呢?因为 ART 虚拟机的演进,现在不再需要开发者手动去指定优化目录了,系统内部会自动根据 dexPath 来决定并生成对应的 .oat 或 .vdex 文件。既然不需要指定优化目录了,那 PathClassLoader 因为写死 null 带来的限制也就自然消失了。
所以总结下来,现在咱们做 SDK 或者插件化开发,直接用 PathClassLoader 也是完全可以动态加载 SD 卡上的 Dex 文件的。它们现在的区别仅仅停留在类名的语义层面上了。
native怎么调java
嗯,其实不管是刚刚提到的 Framework 底层事件分发,还是我们自己写 NDK 业务,Native 调 Java 都是一套非常标准化的流程。先说结论:Native 层调用 Java 层函数,核心是依赖 JNI (Java Native Interface) 机制,必须获取到与当前线程绑定的 JNIEnv 指针,然后通过它去反射查找到 Java 的目标 jclass,再拿到对应的方法 ID(jmethodID),最后发起调用并处理返回值。
我可以把这个流程总结为四个必须要走的关键步骤:
第一步是 环境的获取(JNIEnv)。
如果是 Java 层主动调下来的 JNI 函数,系统已经把 JNIEnv* 作为第一个参数传给我们了,直接用就行。但如果你是在 C++ 里自己开了一个 pthread 异步线程(比如做完底层音视频解码想要回调给 Java),这个新线程里是没有 JNIEnv 的。这时候,必须先通过全局保存的 JavaVM* 指针,调用 AttachCurrentThread 方法,把当前 C++ 线程依附到 ART 虚拟机上,这样才能拿到专属的 JNIEnv。用完之后,一定记得调 DetachCurrentThread 释放,否则会内存泄漏。
第二步是 寻找目标类(jclass)。
我们有了 env,接下来就是调用 env->FindClass("com/example/MyClass")。这里传的是类的全限定名,点要换成斜杠。如果是经常要用的类,通常会在 JNI_OnLoad 的时候查出来,然后通过 env->NewGlobalRef 转换成全局引用缓存起来,防止被虚拟机的 GC 回收掉。
第三步是 获取方法ID(jmethodID)。
拿到类之后,如果是普通方法就调 GetMethodID,静态方法调 GetStaticMethodID。这里除了传方法名,最容易踩坑的就是方法签名(Signature)。比如你想调一个接收 String 返回 int 的方法,签名必须写成 (Ljava/lang/String;)I。因为 Java 有方法重载,Native 只能通过这串奇怪的签名字符串来精确定位。
最后一步是 执行调用。
拿到了 jmethodID,接下来就是拼装参数。如果 Java 方法返回的是 void,就调 env->CallVoidMethod(jobject, jmethodID, ...args);如果返回对象,就调 CallObjectMethod。如果是静态方法,那一套函数就是 CallStaticXXXMethod。
其实在 Android 底层,比如 Binder 机制,底层 C++ 的 JavaBBinder 收到 IPC 请求时,就是通过预先缓存好的 execTransact 方法的 ID,通过这套流程把请求抛回给 Java 层的 Binder.java 的。整个 Native 调 Java 的本质,就是一个受严格控制的底层反射过程。
cpp的string转jstring的两种方式
好的。我们在做 NDK 开发的时候,C++ 的 std::string 和 Java 的 String(也就是 JNI 里的 jstring)的转换是非常高频的操作。先说结论:通常有两种方式可以把 C++ 的 string 转成 jstring。第一种是最常见的,直接调用 JNI 提供的内置函数 NewStringUTF;第二种是相对底层且安全的做法,先在 C++ 创建一个 jbyteArray,然后通过 JNI 反射调用 Java 层 String 的带字节数组和编码格式的构造函数来生成。
我们可以对比一下这两种方式的实现细节和适用场景。
第一种方式:使用 env->NewStringUTF(const char* bytes)
这是代码写起来最爽、最直接的一招。如果我们在 C++ 里有一个 std::string cppStr,只需要一行代码:jstring jStr = env->NewStringUTF(cppStr.c_str()); 就能搞定。
但是,这里其实藏着一个巨大的“深坑”。JNI 接口里的这个 UTF,并不是我们常规意义上的标准 UTF-8,而是 Modified UTF-8(变种 UTF-8)。在绝大多数情况下(比如英文字母和常见的汉字),这两者是兼容的,转起来没问题。但如果你的 C++ string 里包含了一些四字节的 Unicode 字符(比如很生僻的汉字,或者 Emoji 表情),标准的 UTF-8 是用 4 个字节存的,而 JNI 的 Modified UTF-8 要求用两个 3 字节的代理对(Surrogate Pair)来存。这时候如果你强行用 NewStringUTF 去转,底层在做合法性校验时就会失败,直接导致 JNI 奔溃报错,或者传到 Java 层变成乱码。
这就引出了第二种方式:通过反射调用 Java 的 String 构造函数。
这种方式虽然写起来繁琐,但它是绝对安全的,因为我们把解码的权力交还给了 Java 虚拟机,而且可以指定任意的字符集(比如 GBK、UTF-8 等)。
我们可以深入到源码层面来看看这个过程是怎么实现的。其实这一切的逻辑都写在 ClassLoader 的 loadClass(String name, boolean resolve) 方法里。
首先,当调用 loadClass 时,系统会先调用 findLoadedClass(name) 检查这个类是不是已经被加载过了。如果加载过,直接返回缓存里的 Class 对象,这就避免了重复加载。
然后,如果这个类没被加载过,它就会去检查自己内部维护的 parent 成员变量(注意,这里的 parent 是一个组合关系,而不是继承关系)。如果 parent 不为空,它就会去调用 parent.loadClass()。如果 parent 为空,说明已经委托到了最顶层,系统会调用 findBootstrapClassOrNull(),交由底层的 Bootstrap ClassLoader 去处理(在 Android 里主要是 BootClassLoader)。
最后,如果这通向上委托走完,父加载器都抛出 ClassNotFoundException 或者返回 null,说明长辈们都找不到这个类。这时候,子加载器才会调用自己的 findClass(name) 方法,真正去磁盘或者 Dex 文件里读取字节码进行加载。
系统为什么要设计这么一套看似繁琐的机制呢?其实主要有两个极其重要的目的:
第一,是防止重复加载。只要父加载器加载过,子加载器就不需要再加载一遍,保证了全局唯一性。
第二,也是最重要的,是为了安全(沙箱机制)。你想想,如果没有双亲委派,我自己写一个黑客类,名字就叫 java.lang.String,里面植入恶意代码。有了双亲委派之后,系统加载 String 类时,一定会一路委托到最顶层的 Bootstrap ClassLoader,系统加载器会发现:“哎,这个类我已经在系统库里加载过了”,然后直接返回系统安全的那个 String 类。你自己写的那个伪造类,根本就没有机会被执行。这就从源头上保证了核心 API 不被篡改。
PathClassLoader和DexClassLoader的区别
这是一个极具 Android 版本年代感的问题。因为如果脱离了 Android 版本去谈它们的区别,其实是不准确的。先说结论:在 Android 8.0(API 26)之前,PathClassLoader 只能用于加载已经安装的 APK,而 DexClassLoader 可以加载外部存储(SD 卡)上未安装的 APK、Dex 或 Jar 文件。但是,从 Android 8.0 开始,官方在底层源码上抹平了它们的差异,现在这两个类在功能上已经完全一样了,都可以加载外部的 Dex 文件。
我们可以结合 Framework 的源码演进,把这里的细节拆开聊一下。
不管是 PathClassLoader 还是 DexClassLoader,它们在源码里其实都非常薄,它们都继承自同一个父类——BaseDexClassLoader。真正干活的,其实是 BaseDexClassLoader 内部的那个 DexPathList。
在 Android 8.0 之前,当我们去 new DexClassLoader 的时候,它的构造函数里有四个参数:dexPath(你要加载的 dex 路径)、optimizedDirectory(优化的 odex 输出目录)、librarySearchPath(so 库路径)和 parent。
那个年代,因为虚拟机的机制,Dex 文件被加载前需要被 dexopt 优化成 odex 文件。DexClassLoader 允许你传入一个私有的 optimizedDirectory 目录(比如你 App 的 data 目录),让系统把外部 SD 卡上的 dex 优化并存放到这里,所以它可以加载外部的代码,非常适合用来做热修复或者插件化。
而反观老版本的 PathClassLoader,它的构造函数里直接把 optimizedDirectory 这个参数写死了为 null。当传 null 时,系统默认会把优化后的文件放到 /data/dalvik-cache 这个系统目录里。普通应用是没有权限往这个目录写的,所以它只能用来加载系统已经帮我们安装好、优化好的 APK 文件。
但是,到了 Android 8.0 及以后,如果我们去看 BaseDexClassLoader 的源码,就会发现 optimizedDirectory 这个参数被废弃了(标记为 @Deprecated 并且在底层直接被忽略)。
为什么呢?因为 ART 虚拟机的演进,现在不再需要开发者手动去指定优化目录了,系统内部会自动根据 dexPath 来决定并生成对应的 .oat 或 .vdex 文件。既然不需要指定优化目录了,那 PathClassLoader 因为写死 null 带来的限制也就自然消失了。
所以总结下来,现在咱们做 SDK 或者插件化开发,直接用 PathClassLoader 也是完全可以动态加载 SD 卡上的 Dex 文件的。它们现在的区别仅仅停留在类名的语义层面上了。
native怎么调java
嗯,其实不管是刚刚提到的 Framework 底层事件分发,还是我们自己写 NDK 业务,Native 调 Java 都是一套非常标准化的流程。先说结论:Native 层调用 Java 层函数,核心是依赖 JNI (Java Native Interface) 机制,必须获取到与当前线程绑定的 JNIEnv 指针,然后通过它去反射查找到 Java 的目标 jclass,再拿到对应的方法 ID(jmethodID),最后发起调用并处理返回值。
我可以把这个流程总结为四个必须要走的关键步骤:
第一步是 环境的获取(JNIEnv)。
如果是 Java 层主动调下来的 JNI 函数,系统已经把 JNIEnv* 作为第一个参数传给我们了,直接用就行。但如果你是在 C++ 里自己开了一个 pthread 异步线程(比如做完底层音视频解码想要回调给 Java),这个新线程里是没有 JNIEnv 的。这时候,必须先通过全局保存的 JavaVM* 指针,调用 AttachCurrentThread 方法,把当前 C++ 线程依附到 ART 虚拟机上,这样才能拿到专属的 JNIEnv。用完之后,一定记得调 DetachCurrentThread 释放,否则会内存泄漏。
第二步是 寻找目标类(jclass)。
我们有了 env,接下来就是调用 env->FindClass("com/example/MyClass")。这里传的是类的全限定名,点要换成斜杠。如果是经常要用的类,通常会在 JNI_OnLoad 的时候查出来,然后通过 env->NewGlobalRef 转换成全局引用缓存起来,防止被虚拟机的 GC 回收掉。
第三步是 获取方法ID(jmethodID)。
拿到类之后,如果是普通方法就调 GetMethodID,静态方法调 GetStaticMethodID。这里除了传方法名,最容易踩坑的就是方法签名(Signature)。比如你想调一个接收 String 返回 int 的方法,签名必须写成 (Ljava/lang/String;)I。因为 Java 有方法重载,Native 只能通过这串奇怪的签名字符串来精确定位。
最后一步是 执行调用。
拿到了 jmethodID,接下来就是拼装参数。如果 Java 方法返回的是 void,就调 env->CallVoidMethod(jobject, jmethodID, ...args);如果返回对象,就调 CallObjectMethod。如果是静态方法,那一套函数就是 CallStaticXXXMethod。
其实在 Android 底层,比如 Binder 机制,底层 C++ 的 JavaBBinder 收到 IPC 请求时,就是通过预先缓存好的 execTransact 方法的 ID,通过这套流程把请求抛回给 Java 层的 Binder.java 的。整个 Native 调 Java 的本质,就是一个受严格控制的底层反射过程。
cpp的string转jstring的两种方式
好的。我们在做 NDK 开发的时候,C++ 的 std::string 和 Java 的 String(也就是 JNI 里的 jstring)的转换是非常高频的操作。先说结论:通常有两种方式可以把 C++ 的 string 转成 jstring。第一种是最常见的,直接调用 JNI 提供的内置函数 NewStringUTF;第二种是相对底层且安全的做法,先在 C++ 创建一个 jbyteArray,然后通过 JNI 反射调用 Java 层 String 的带字节数组和编码格式的构造函数来生成。
我们可以对比一下这两种方式的实现细节和适用场景。
第一种方式:使用 env->NewStringUTF(const char* bytes)
这是代码写起来最爽、最直接的一招。如果我们在 C++ 里有一个 std::string cppStr,只需要一行代码:jstring jStr = env->NewStringUTF(cppStr.c_str()); 就能搞定。
但是,这里其实藏着一个巨大的“深坑”。JNI 接口里的这个 UTF,并不是我们常规意义上的标准 UTF-8,而是 Modified UTF-8(变种 UTF-8)。在绝大多数情况下(比如英文字母和常见的汉字),这两者是兼容的,转起来没问题。但如果你的 C++ string 里包含了一些四字节的 Unicode 字符(比如很生僻的汉字,或者 Emoji 表情),标准的 UTF-8 是用 4 个字节存的,而 JNI 的 Modified UTF-8 要求用两个 3 字节的代理对(Surrogate Pair)来存。这时候如果你强行用 NewStringUTF 去转,底层在做合法性校验时就会失败,直接导致 JNI 奔溃报错,或者传到 Java 层变成乱码。
这就引出了第二种方式:通过反射调用 Java 的 String 构造函数。
这种方式虽然写起来繁琐,但它是绝对安全的,因为我们把解码的权力交还给了 Java 虚拟机,而且可以指定任意的字符集(比如 GBK、UTF-8 等)。
它的源码流程大概是这样的:
- 首先,我们用
env->FindClass("java/lang/String")拿到 String 类,然后拿到它构造函数的 ID:GetMethodID(strClass, "<init>", "([BLjava/lang/String;)V")。 - 接着,根据 C++ string 的长度,在 JNI 里通过
env->NewByteArray(length)创建一个 Java 的字节数组。 - 把 C++ 的字符串数据拷贝到这个数组里,调用
env->SetByteArrayRegion(byteArray, 0, length, (jbyte*)cppStr.c_str())。 - 创建一个表示编码格式的 jstring,比如
env->NewStringUTF("UTF-8")。 - 最后,调用
env->NewObject把刚才的字节数组和编码格式传进去,反射生成最终的jstring。
所以在实际的工程项目中,如果我能百分百确定 C++ 传过来的是常规字符串,我就会图省事用第一种;但如果这个字符串是从网络抓取的不可控数据,或者涉及复杂的表情符号,我一定会封装一个工具类,用第二种方式去转换,确保系统的稳定性。
安卓中Binder的原理?
嗯,好的。Binder 绝对是整个 Android Framework 的基石,不管是四大组件的调度,还是我们平时调用的各种系统服务,底层全都离不开它。先说结论:Binder 是 Android 独有的跨进程通信(IPC)机制,它的核心原理是利用底层的 mmap(内存映射)技术,实现了只需要一次数据拷贝就能完成跨进程的数据传输,并且采用 C/S 架构,结合 ServiceManager 提供了极高的安全性和极佳的性能。
如果要把这个原理掰开揉碎了讲,我觉得可以从“传统 IPC 的痛点”、“一次拷贝的实现”以及“完整的通信链路”这三个方面来展开。
首先,为什么 Android 不用 Linux 原生的管道、Socket 或者共享内存呢?传统 IPC(除了共享内存)通常需要两次拷贝:发送方把数据从用户空间拷贝到内核空间,接收方再把数据从内核空间拷贝到自己的用户空间。这在移动设备上太耗费性能了。而共享内存虽然是零拷贝,但控制起来极其复杂,很难做好并发和权限管理。
这时候 Binder 的优势就出来了,它实现了一次拷贝。
底层是怎么做的呢?核心在于 mmap() 函数。当接收方进程启动 Binder 机制时,它会打开 /dev/binder 节点,并且调用 mmap。这个操作会在操作系统的内核空间分配一块物理内存,然后把这块物理内存同时映射到接收方进程的用户空间虚拟内存,以及内核空间的虚拟内存上。
这样一来,当发送方(Client)要发数据时,它只需要调用 copy_from_user,把数据从自己的用户空间拷贝到内核空间的那块虚拟内存里。因为这块内核内存和接收方(Server)的用户空间是物理映射绑定的,所以接收方瞬间就能看到这笔数据,省去了第二次拷贝。这就是 Binder 高效的本质。
其次,从架构链路上看,我之前在读 AOSP 源码时,理出了一条非常清晰的线。Binder 是一个典型的 C/S 架构,里面有四个重要角色:Client、Server、ServiceManager 和 Binder 驱动。
ServiceManager 就像是互联网里的 DNS 服务器。系统服务(比如 AMS、WMS)启动时,会作为 Server 向 ServiceManager 注册一个大管家(字符串名字对应 Binder 实体)。
当我们的应用(Client)想要调用系统服务时,会先去找 ServiceManager 查询,拿到一个代理对象(Proxy)。在 Java 层,如果是我们自己写的 AIDL,就会生成 Stub.Proxy。
当我们调用 Proxy 的方法时,数据会被打包成 Parcel 对象,然后进入 JNI 层,调用到 android_os_BinderProxy_transact,接着进入 C++ 层的 BpBinder。在这里,IPCThreadState 会接管一切,它会把数据封装成 binder_transaction_data 结构体,通过 ioctl 系统调用,将指令(如 BINDER_WRITE_READ)发给底层的 Binder 驱动。
最后,驱动会根据 handle 句柄找到目标进程,唤醒目标进程的 Binder 线程池。目标进程里的 IPCThreadState 收到命令后,一路上抛到 JavaBBinder,最终回调到服务端 Java 代码里的 onTransact() 方法。
所以,我觉得 Binder 的设计非常优雅,它在应用层包装成了面向对象的远程方法调用,而在底层利用了极其高效的 Linux 内存映射机制。这也是我读 Framework 源码时觉得最受震撼的地方。
你会不会觉得客户端能深钻的技术不多
其实很多刚入行或者没深入接触过大前端的人会有这种错觉,但我极其不认同这个观点。先说结论:客户端(尤其是 Android 开发)表面的 UI 堆叠确实容易碰到天花板,但如果把视角向下延伸到系统底层,向广延伸到架构与跨平台,能深钻的技术不仅多,而且非常硬核,它的深度完全不亚于任何后端或者底层系统开发。
为什么这么说呢?结合我自己的学习和源码阅读经历,我可以从三个“深水区”来分享一下我的看法。
第一个深水区是极致的性能优化与 APM(应用性能监控)建设。
如果只是写一个能跑的 App,那很简单;但如果要在几千万日活的国民级 App(比如字节、腾讯的产品)上保证流畅度,这就很难了。比如为了解决哪怕一帧的卡顿,我们就不能只停留在改改布局,而是必须深入到 Choreographer 的 Vsync 机制,理解 RenderThread 的同步模型,甚至去分析 Linux 的 CPU 调度策略。再比如内存泄漏和 OOM 问题,为了做线上监控,我们需要掌握 JVM 的 GC 机制,学会利用 JVMTI 去做内存快照,甚至去 Hook 底层的 malloc 和 free 函数来排查 Native 层的内存泄漏。这些都需要极其扎实的操作系统和底层语言功底。
第二个深水区是大型工程的架构设计与编译期技术。
当一个工程达到几百万行代码,有几百个开发人员同时提交时,怎么防劣化?怎么做组件化?这就涉及到非常复杂的架构设计。这就要求我们必须掌握动态加载技术(像 ClassLoader 的机制)、插件化原理。而且,为了做无侵入的埋点或者方法耗时统计,我们还要深入学习字节码插桩技术(ASM),在编译的 Transform 阶段去动态修改 .class 文件。这就要求开发者对 Java 虚拟机规范和字节码结构有非常透彻的了解。
第三个深水区就是Framework 源码与跨平台引擎。 像我简历里写的深入学习过 AMS、WMS、SurfaceFlinger,其实 Android 的 Framework 就是一个极其庞大且经典的分布式操作系统架构。研究这些,能学到大量的并发模型、IPC 设计以及进程管理(比如 Zygote 和 LMK)的优秀思想。再往后看,现在的 Flutter、自研的渲染引擎(像底层的 OpenGL/Vulkan),这些都需要扎实的 C++ 基础和图形学知识。
所以我觉得,客户端开发绝不是画画 UI 就结束了。UI 只是冰山一角,水面之下关于系统内核、虚拟机、编译原理、图形学等硬核技术,足够一个工程师钻研十几二十年。
你更喜欢做业务还是sdk
嗯,这是一个很好的职业规划问题。先说结论:基于我个人的技术栈和兴趣,我目前更倾向于做 SDK(也就是基础架构或中间件)的开发,但我并不排斥做业务开发,因为好的 SDK 最终一定是服务于业务的,脱离了业务场景的基础设施是没有生命力的。
我可以详细说说我为什么偏好 SDK 开发,以及我对这两者之间关系的理解。
首先,我为什么更倾向于做 SDK 开发?这其实跟我平时喜欢啃 AOSP 源码、研究 Framework 底层机制有很大的关系。 做业务往往追求的是“快”和“多变”,把产品经理的需求迅速落地,它的挑战在于应对复杂的业务逻辑和敏捷迭代。而做 SDK 的挑战完全不同。开发一个供全公司甚至开源出去给大家用的 SDK(比如网络库、图片加载库、APM 监控工具),要求开发者有极高的代码质量意识和架构抽象能力。 因为 SDK 的代码是要接入到成百上千个宿主 App 里的,如果我在 SDK 里写了一个内存泄漏,或者在主线程做了耗时的 I/O,那坑害的是所有的业务线。做 SDK 要求我必须死磕性能、严格管理内存、保证 API 的向下兼容性,并且尽可能地减少包体积(AAR 的大小)。这种对技术深度极致压榨的工作模式,我觉得非常有成就感。
其次,为什么我也看重业务经验? 我看过很多做底层或者架构的同学,容易陷入“闭门造车”的陷阱,搞出一些非常庞大但业务同学根本不愿意接的框架。我觉得优秀的 SDK 开发者必须有同理心,必须了解业务的痛点。比如业务线经常抱怨列表滑动卡顿,那我作为基础架构的同学,就要去研究 RecyclerView 的预加载优化,去封装一个更高效的异步 UI 渲染 SDK 丢给他们用。
所以总结下来,我的目标是:以业务的实际痛点为导向,利用我对 Android 底层和 Framework 的深入理解,去打造高性能、易用、高稳定性的 SDK 和基础设施。 我觉得这也是字节、腾讯这种大厂里基础技术团队最核心的价值所在。
Java中new一个String会创建几个对象
这是一个非常经典的 Java 底层内存面试题。先说结论:执行 new String("abc") 这句代码,通常会创建 1 个或者 2 个对象。具体是几个,取决于在执行这行代码之前,字符串常量池(String Pool)里面是否已经存在了 "abc" 这个字面量对象。
我们可以把这句代码拆解开,看看 JVM 底层到底做了什么。这个过程涉及到 Java 的堆内存(Heap)和字符串常量池。
当 JVM 执行到这行代码时,其实分为两个主要步骤:
第一步是处理双引号括起来的字面量 "abc"。
在类加载的解析阶段,或者代码执行到这里时,JVM 会首先去检查字符串常量池。
如果常量池里没有 "abc" 这个字符串对象,JVM 就会在常量池里创建出一个 "abc" 对象。
如果常量池里已经有了 "abc"(比如你前面已经写过 String s = "abc"; 了),那这一步什么都不做,直接复用池子里的那个对象。
第二步是处理 new 关键字。
无论常量池里刚才有没有创建对象,只要遇到 new 关键字,JVM 都会雷打不动地在 Java 堆(Heap) 中开辟一块全新的内存空间,创建一个新的 String 对象。并且,这个新创建的堆对象的内部字符数组(char[] 或 byte[])的引用,会指向常量池里那个 "abc" 对象的数据。
所以总结一下就是:
- 如果
"abc"之前没出现过:常量池里建 1 个,堆里建 1 个,总共 2 个对象。 - 如果
"abc"之前已经存在了:常量池不建了,只在堆里建 1 个,总共 1 个对象。
另外,我在深入学习 JVM 内存模型的时候还注意到一个版本演进的细节。在 JDK 1.7 之前,字符串常量池是放在方法区(PermGen)里的;但是从 JDK 1.7 开始,为了防止常量池导致的方法区 OOM,Google/Oracle 官方把字符串常量池移到了 Java 堆(Heap) 里面。这也使得 String.intern() 方法的机制发生了一些巧妙的变化,它现在可以不用拷贝整个对象,而是直接在池子里存一个指向堆中已有对象的引用了,极大优化了内存。
抽象类和接口有什么区别
好的。在 Java 和 Android 的面向对象设计中,抽象类和接口虽然都可以用来定义规范,但它们的语义和应用场景有着本质的区别。先说结论:抽象类代表的是“是什么(is-a)”的继承关系,主要用于代码的复用和提取公共模板;而接口代表的是“能做什么(has-a / can-do)”的行为契约,主要用于系统模块间的解耦和多态的实现。
除了这个核心的思想差异,我们可以从几个具体的维度来看看它们在语法和特性上的不同:
第一个维度是成员的组成差异。
在抽象类里,我们可以拥有极其丰富的“私人财产”。它可以有普通的成员变量(有状态的),可以有构造方法,可以有普通的实现了的方法,也可以有抽象方法。
但是传统的接口非常纯粹,它本身是没有状态的。接口里定义的变量默认全都是 public static final 的全局常量,不能有实例变量;也没有构造方法。虽然 JDK 8 引入了 default 默认方法和静态方法,允许接口包含具体实现,但它依然不能保存状态(不能有成员变量),这个本质是没有变的。
第二个维度是继承体系的限制。
这也是 Java 设计上的一条铁律。Java 只支持单继承,所以一个类只能继承一个抽象类;但 Java 支持多实现,一个类可以同时实现无数个接口。这就使得接口具有极高的灵活性,它可以像插件一样给一个类赋予各种额外的能力(比如让一个类既是 Runnable 又是 Serializable)。
第三个维度,我结合 Android Framework 和应用架构 里的实际例子来说可能更直观。
我们在看 AOSP 源码的时候,最经典的抽象类就是 Context。Context 是一个庞大的抽象类,它里面定义了 startActivity、sendBroadcast 这些抽象方法。系统为它提供了 ContextImpl 这个核心实现类,以及 ContextWrapper 这个包装类(Activity 和 Application 都继承自包装类)。系统用抽象类,是因为它需要提供一套包含具体逻辑和成员变量的底层模板。
而接口呢?在 Android 里最典型的就是各种监听器,比如 OnClickListener。它不管你是 Button 还是 ImageView,只要你想响应点击,你签个约(实现接口)就行了。
另外,在我熟练掌握的 MVVM 架构 中,为了做单元测试(Mock)和符合依赖倒置原则(DIP),ViewModel 层和 Repository 之间必须用接口来隔离。这样 ViewModel 只依赖接口,完全不关心具体的数据是从网络拉的还是从本地数据库查的,把解耦做到了极致。
final关键字有什么用
嗯,final 关键字在 Java 里面可以说是“不可变性”的代名词。先说结论:final 的核心作用是施加严格的约束,表示“最终的、不可更改的”。它可以用来修饰类、方法和变量,分别起到禁止继承、禁止重写和禁止重新赋值的作用。同时,在并发编程和 JVM 内存模型(JMM)层面,final 还能提供非常重要的内存可见性保证。
我们可以从它的四个主要使用场景把它拆解清楚。
第一,修饰类。
当一个类被 final 修饰时,就意味着它是一个“断子绝孙”的类,不允许被任何类继承。在源码里最经典的例子就是 java.lang.String。系统为什么要把它设计成 final?主要是为了安全性和不可变性。你想,如果任何人都能写一个黑客类继承 String 并重写里面的方法,那我们在网络传输或者做反射的时候传一个假 String 进去,整个系统的底层逻辑就全乱套了。
第二,修饰方法。
如果一个父类里的方法被 final 修饰,子类可以继承它,但是绝对不能重写(Override)它。这在我们在写 SDK 或者框架底层类的时候非常有用。当我们需要向外提供一个生命周期或者核心的调度机制,不希望业务端的子类“乱改”我的核心逻辑时,就会把这个方法加上 final。
第三,修饰变量。
被 final 修饰的变量就是一个常量,只能被赋值一次。
这里有一个很重要的细节区分:如果这个变量是基本数据类型(比如 int),那就是它的数值永远不能变;但如果它是引用数据类型(比如一个 final HashMap),那 final 只能保证这个引用指针的地址不能换,但你依然是可以往这个 HashMap 里面 put 新东西,改变它内部状态的。
第四,也是相对硬核一点的,在并发模型(JMM)里的作用。
除了我们前面聊过 volatile 能保证可见性,其实 final 也是可以保证可见性的。
在多线程环境下,当我们实例化一个对象时,如果这个对象内部有 final 字段,Java 虚拟机会在底层通过插入内存屏障来保证:当这个对象的构造方法执行完毕时,它里面的 final 字段一定已经初始化完成,并且对所有其他线程立即可见。也就不会出现别的线程拿到一个引用,却看到 final 变量还是默认零值这种半初始化状态的 Bug。这也就是为什么在做并发编程的时候,我们极度推崇把对象设计成不可变的(Immutable)的原因。
cpp从源文件到可执行文件经历了什么
嗯,好的。其实在做 Android NDK 开发,或者我们自己用 CMake 编译底层的 C/C++ 代码时,弄懂这个底层编译链是非常关键的。先说结论:C++ 从源文件(.cpp)到最终能在操作系统上跑的可执行文件或者动态库(.so),主要经历了四个极其严谨的阶段:预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。
如果我们要把细节展开的话,每一层其实都有专门的工具链在干活,比如我们 Android 里常用的 Clang/LLVM 工具链:
第一阶段是预处理。这一步主要是处理那些以 # 开头的指令。预处理器会把 #include 包含的头文件内容直接展开,原封不动地塞到当前文件里;同时它会把代码里所有的宏定义(#define)进行字符替换;如果有条件编译(比如 #ifdef),它会根据条件把不符合条件的代码块直接删掉。另外,所有的注释也会在这个阶段被抹除。预处理完了之后,会生成一个 .i 为后缀的纯净版 C++ 源文件。
第二阶段是编译。这是整个流程里最核心、技术含量最高的一步。编译器(比如 Clang 核心前端)会对这个 .i 文件进行词法分析、语法分析,生成抽象语法树(AST),然后进行语义检查。检查没问题后,编译器的后端会介入,对代码进行极致的优化(比如死代码消除、内联函数展开),最后把高级语言翻译成低级的汇编代码。这个阶段输出的是一个 .s 结尾的汇编源文件。
第三阶段是汇编。到了这一步,其实就是个纯粹的翻译工作了。汇编器(Assembler)会把上面生成的 .s 文件里的每一条汇编指令,机械地翻译成 CPU 真正能听懂的机器语言(也就是 0 和 1 组成的机器码指令)。汇编结束后,生成的是一个可重定位目标文件(Object File),也就是我们常见的 .o 文件。此时,它已经是一个二进制的 ELF 格式文件了,但是它还不能运行,因为里面如果有调用其他文件里的函数,地址还是空的。
最后阶段就是链接。在一个大工程里,往往有成百上千个 .cpp 文件,最终生成了一堆 .o 文件。链接器(Linker)的工作,就是把这一大堆 .o 文件,再加上我们代码里依赖的各种系统级静态库(.a)或者动态库(.so)全部组装到一起。它最重要的任务是做符号决议与重定位,也就是说,如果文件 A 调用了文件 B 里的一个打印函数,链接器会查找到那个函数在 B 里的确切内存偏移量,然后把 A 里面的空地址填上。所有的段(代码段、数据段等)合并完毕后,最终输出的就是一个可以被操作系统加载执行的二进制文件了。
可执行文件在操作系统怎么运行的
这是一个极其硬核但也非常有意思的系统底层问题。结合咱们 Android 的系统架构来理解会特别清晰。先说结论:操作系统运行一个可执行文件,本质上是通过系统调用(如 fork 和 execve)分配进程资源,然后利用系统加载器解析 ELF 格式文件,将磁盘上的代码段和数据段映射到进程的虚拟内存空间,最终跳转到程序的入口点(Entry Point)把控制权交给 CPU 去执行。
我可以把整个运行时生命周期按照时间线分为这几个关键步骤:
首先,是进程的创建。在 Android 或 Linux 系统里,新的进程绝大多数都是通过父进程调用 fork() 系统调用孵化出来的。比如在 Android 里,我们所有的 App 进程,其实都是底层的 Zygote 进程 fork 出来的子进程。fork 出来的瞬间,子进程继承了父进程的虚拟内存镜像。
然后,也就是最核心的一步,覆盖自身镜像并加载文件。子进程会紧接着调用 execve() 系统调用,并传入我们要运行的可执行文件路径。此时,操作系统的内核层会读取这个文件的头部,发现它有个特殊的魔数(Magic Number),确认这是一个合法的 ELF 文件。
随后,内核的加载器(Loader)开始干活了。它不会把整个文件傻乎乎地全读进物理内存,而是基于虚拟内存机制,读取 ELF 的程序头表(Program Header Table),把文件里的只读代码段(.text)映射到进程虚拟内存的代码区,把已经初始化的数据段(.data)映射到数据区,并且为未初始化的变量分配 .bss 段。接着,系统会为新进程分配好函数调用所需的栈(Stack)和动态分配用的堆(Heap)。
第三步,是动态链接的介入。现在的程序几乎都不可能完全静态编译,往往都依赖了大量的共享库(比如 Android 里的 libc.so)。内核加载完主程序后,会发现 ELF 里有一段指定了动态链接器(比如 /system/bin/linker64)。于是内核会把控制权先交给这个动态链接器,由它去找到这些依赖的 .so 文件,把它们也映射进内存,并完成那些动态符号的最终重定位操作。
最后一步,移交控制权。当内存映射和环境准备全部就绪,环境变量和命令行参数也都压入用户栈之后,操作系统的内核态会切换回用户态,并且把 CPU 的指令寄存器(比如 PC 寄存器)指向 ELF 文件里定义好的那个入口点(Entry Point,通常是 _start 函数)。
注意,这里调用的还不是我们写的 main 函数。_start 是 C/C++ 运行时库(CRT)提供的一段启动代码,它会去做一些全局变量初始化、C++ 全局对象的构造,等这些擦屁股的事干完了,它才会去调用真正的 main 函数。这时候,我们的业务代码才真正跑了起来。
调用一个函数,栈空间怎么变化
嗯,熟悉栈帧(Stack Frame)的开辟和销毁,其实是咱们看底层汇编或者排查 JNI 严重 Crash 时必须具备的能力。先说结论:调用一个函数时,操作系统会在当前线程的栈空间上为这个被调函数新开辟一块专属的内存区域,叫做“栈帧”。主要经历了:参数压栈、保存返回地址、保存旧栈底指针、分配新局部变量空间;而函数返回时,这个过程完全逆转,栈顶指针回退,将内存归还给调用者。
其实由于 Android 移动端现在主流都是 ARM64 架构,我就结合 ARM64(AArch64)的底层约定来详细说一下这个过程,比起古老的 x86 会更贴近咱们客户端的现实:
假设此时在函数 A(Caller)里,正准备去调用函数 B(Callee)。
第一步是传参阶段。A 函数准备好要传给 B 的参数。在 ARM64 下,系统非常注重性能,前 8 个参数会直接塞进寄存器(x0 到 x7)里,而不是老老实实地压栈。如果参数超过了 8 个,多出来的那些参数,才会顺着当前的栈顶向下压入内存。
第二步是执行跳转并保存返回地址。A 会执行一条分支跳转指令,比如 BL(Branch with Link)。这条指令极其关键,它不仅把 PC 指针跳到了 B 函数的第一条代码地址去,更重要的是,它自动把 A 函数里紧接着调用完 B 之后的那条指令的地址,存进了**链接寄存器 LR(也就是 x30 寄存器)**里。这样 B 执行完了才知道该回哪儿。
第三步,正式进入被调函数 B,建立新的栈帧。B 函数开始运行的第一件事,就是所谓的“函数序言(Prologue)”。它需要把上一个函数(A)的“栈底/帧指针”保存起来,也就是把当前的 FP 寄存器(x29)和刚才保存了返回地址的 LR 寄存器一起压入栈中。
压完之后,把此时的栈顶指针 SP,直接赋值给帧指针 FP。到这一刻,标志着函数 B 正式划定了属于自己的领地,建立了一个全新的栈帧。
第四步,为局部变量分配空间。如果 B 里面写了几个局部变量,编译器会算好它们占多少字节,然后把 SP 指针**往下(向低地址方向)**减去对应的字节数,腾出足够的栈内存空间来存放这些局部变量。
最后,当 B 函数执行完毕要结束时,就会进入“函数尾声(Epilogue)”。这个过程就是前面一连串动作的时光倒流:
先把 SP 恢复到 B 刚进来时的位置,这就等于把 B 的所有局部变量瞬间“销毁”释放了。然后从栈里把当初存好的旧 FP 和旧 LR 弹出来,分别恢复到寄存器里。最后执行一条 RET 指令,CPU 会自动读取 LR 寄存器里的地址,一脚把控制权踢回给函数 A。至此,栈空间完美复原,就像 B 从来没被调用过一样。
返回值是放在哪的
好的。关于函数返回值存放在哪,其实是一个由编译器和操作系统的 ABI(应用程序二进制接口) 或者调用约定(Calling Convention)来统一定义的问题。先说结论:返回值的存放位置完全取决于要返回的数据类型的“体积大小”。如果是占用空间小的基础类型,会直接放在 CPU 的通用寄存器里;如果是体积庞大的结构体或类对象,则是由调用者提前在栈上分配内存,然后以隐式指针的形式传给底层函数去写入。
我们可以把这种差异拆成几种常见的场景来看:
第一种是最普遍的,返回普通的整型、指针,或者非常小的简单结构体(比如小于等于 8 字节)。
这种情况下,系统为了追求极致的性能,绝对不会走内存交互,而是直接走寄存器。
如果是在我们传统的 x86 架构或者 32 位机器下,这个返回值通常会被放在大名鼎鼎的 eax 寄存器里。
如果是在咱们 Android 主流的 ARM64 环境下,返回值会被极其有默契地塞在 x0 寄存器里。当被调函数执行完 ret 指令回到上层调用者时,调用者会立刻从 x0 里把数据取出来用。如果返回值是两倍长(比如 128 位的 SIMD 向量),可能就会用到 x0 和 x1 联合返回。另外,如果返回的是 float 或者 double 这种浮点数,它也有专用的浮点寄存器(比如 s0 或 d0)来装,和整数互不干扰。
第二种场景就比较麻烦了,如果你非要在 C++ 里按值返回一个几十字节甚至上百字节的大型 struct 或者对象呢?
很显然,没有任何一个通用寄存器能装得下这么大的东西。所以编译器会在底层偷偷做一套障眼法。
这时候,调用者(Caller)会在调用函数之前,先在自己的栈帧里预留出一块足够大的内存空间,用来准备接收这个巨大的返回值。
然后,调用者会把这块预留内存的“首地址”,作为一个隐藏的参数(通常作为底层汇编的第一个参数,比如塞进 x8 等特定约定的寄存器),偷偷传递给被调函数(Callee)。
被调函数在内部经过一通猛烈计算,得出结果后,并不会去用 x0 返回数据,而是直接拿着那个隐式指针,把计算结果的字节一个个拷贝或者写入到调用者预留的那块栈内存里。写完之后,被调函数通常会把那块内存的地址通过 x0 返还给调用者。所以这种按值返回其实本质上在底层就是被偷偷优化成了指针传递(Pass-by-Pointer),调用者只需要通过那个地址去读就好了,这在 C++ 里极其常见,比如常听说的 RVO/NRVO(返回值优化)其实就是省去了这种临时对象的多次拷贝操作。
看你做过JNI,怎么动态和静态注册JNI?
嗯,这个我非常熟悉。其实在 Android 底层开发中,Native 代码怎么和 Java 代码建立绑定关系是 JNI 最核心的入口。先说结论:JNI 注册主要分为静态注册和动态注册两种方式。静态注册是依靠 Java 虚拟机按照固定的、冗长的函数命名规范,在加载动态库时自动通过符号表去查找并绑定映射关系的;而动态注册则是开发者在 C++ 的 JNI_OnLoad 入口函数里,手动将 Java 层的方法名和 C++ 层的函数指针通过结构体数组强行“缝合”在一起的。
我们可以把这两种注册方式的原理和优缺点拆开来看一下。
先说静态注册。大家刚开始学 NDK 的时候肯定都是从静态注册开始的。
它的用法很简单,你在 Java 层写一个带有 native 关键字的方法,然后用 javah 命令或者 Android Studio 自动帮你生成对应的 C/C++ 函数声明。
它最大的特点就是那个长得像贪吃蛇一样的函数名。比如你在 com.example.MyActivity 里定义了一个 stringFromJNI() 方法,那 C++ 层的函数名必须叫 Java_com_example_MyActivity_stringFromJNI,并且前面通常得加上 JNIEXPORT 和 JNICALL 这两个宏。
当 Java 虚拟机第一次执行到这个 native 方法时,它会去底层加载好的 .so 库的**导出符号表(Symbol Table)**里,按照这个固定名字去搜索。一旦找到了,虚拟机就会把 Java 方法和 C++ 函数的内存地址绑定起来,以后再调用就直接走地址了。
这种方式的缺点很明显:函数名太丑太长;一旦 Java 层的包名或者类名改了,C++ 这边全得跟着改;而且每次第一次调用都要去查表,效率稍微低那么一点点。
所以,真正在做大型项目或者看 AOSP 系统源码(比如 android_util_Binder.cpp 这种核心类)的时候,我们几乎清一色用的都是动态注册。
动态注册是怎么玩的呢?当我们在 Java 层调用 System.loadLibrary("xxx") 把 .so 库加载进内存时,虚拟机会去底层找一个名叫 JNI_OnLoad 的默认 C/C++ 默认入口函数。
我们就重写这个 JNI_OnLoad 函数。在这个函数里,我们会定义一个 JNINativeMethod 类型的结构体数组。这个结构体里只有三个字段:第一个是 Java 层的短方法名(比如 "stringFromJNI"),第二个是这个方法的 JNI 签名(比如 "()Ljava/lang/String;"),第三个就是我们 C++ 层自己随便起的名字的函数指针(比如 (void*)my_custom_cpp_func)。
然后,我们通过传入的 JavaVM 指针拿到 JNIEnv,调用 env->RegisterNatives() 方法,把这个 Java 类名和我们刚才那个结构体数组一把梭地注册进虚拟机里。
动态注册的好处非常多:首先,C++ 层的函数名你可以随便起,不用遵循那种死板的命名规则,安全性也高(别人不容易通过符号表猜出你的函数逻辑);其次,它不需要系统在第一次调用时去吭哧吭哧查表,所以首次调用的性能更高。这也是为什么大厂的 SDK 或核心底层模块都偏向动态注册的原因。
cpp的线程怎么call java的方法
好的。其实不管是刚刚提到的底层音视频解码,还是我们在自己写的 NDK 业务里做 Socket 异步接收,在 C++ 的子线程里回调 Java 层是非常高频的操作。先说结论:C++ 线程调用 Java 方法的核心难点在于拿到与当前线程绑定的 JNIEnv 指针。由于开发者自己创建的 C++ 线程默认是没有这个环境的,所以必须先通过全局保存的 JavaVM 指针把当前线程“挂载(Attach)”到虚拟机上,然后再走标准的反射流程获取类和方法 ID 发起调用,最后千万别忘了“卸载(Detach)”释放资源。
我可以把这个流程总结为这几个必须要走的关键步骤,可以说是字字珠玑的血泪史:
第一步是环境准备和获取(JavaVM 与 JNIEnv)。
前面咱们提到,JNIEnv 是一个线程局部变量(Thread Local),也就是说它是每个线程独有的。如果你是在 Java 层主动调下来的 JNI 函数里,系统已经把当前线程的 JNIEnv* 作为第一个参数传给你了,直接用就行。但如果你是在 C++ 里自己开了一个 std::thread 或者 pthread 异步线程,这个新线程里是压根没有 JNIEnv 的,你直接拿着主线程传过来的那个 env 去用,保证立刻 Crash。
这时候,咱们必须先通过全局保存下来的 JavaVM* 指针(通常在 JNI_OnLoad 时保存),调用 JavaVM->AttachCurrentThread(&env, nullptr)。这个方法极其关键,它会把当前这根毫无背景的 C++ 线程依附到 ART 虚拟机上,并给你返回一个当前子线程专属的 JNIEnv。
第二步是寻找目标类(jclass)和方法 ID。
我们有了 env,接下来就是去调用 env->FindClass()。注意,子线程这里有个天坑!子线程默认的 ClassLoader 是系统级的 BootClassLoader,它很可能找不到咱们 App 自己写的业务类,报 ClassNotFoundException。所以,如果是子线程回调,咱们通常会在主线程初始化的时候,提前用 FindClass 查出来,并且通过 env->NewGlobalRef() 转换成全局引用存好,这里直接拿缓存的 jclass 用就行了。
拿到类之后,接着调 GetMethodID 拿到你要调的方法 ID,这里必须要传对那串像乱码一样的方法签名。
第三步就是执行调用。
拿到了 jclass 和 jmethodID,接下来就是拼装参数。如果 Java 方法返回的是 void,咱们就调 env->CallVoidMethod(jobject, jmethodID, ...args);如果返回对象,就调 CallObjectMethod。如果是静态方法,就换成 CallStaticVoidMethod。整个 Native 调 Java 的本质,就是一个受底层严格控制的反射过程。
最后一步,也是最容易忘的一步。
调用结束后,当你这个 C++ 线程准备退出或者销毁之前,必须执行 JavaVM->DetachCurrentThread(),把当前线程从虚拟机上解绑。如果不解绑,这个线程一旦销毁,虚拟机会认为有一个附着的线程异常死亡,直接导致整个 App 进程 Crash。
需要注意些什么
嗯,这个问题非常有含金量。做 NDK 和 JNI 开发,如果不小心,真的到处都是坑,分分钟导致 Native Crash 或者神秘的内存泄漏。在我看来,最需要注意的有四个核心痛点:内存管理的泄漏陷阱、多线程环境下的 JNIEnv 隔离问题、ClassLoader 上下文的丢失,以及 C++ 的异常绝不会自动打断 Java 流程的机制差异。
如果把这几个坑拆解来看的话:
首先,内存引用管理(引用表溢出和野指针)绝对是头号重灾区。
在 JNI 里,我们通过 FindClass 或者作为参数传进来的 Java 对象,默认全部都是局部引用(Local Reference)。局部引用的生命周期非常短,在当前这个 JNI 方法执行完返回 Java 层后,不管你 C++ 这边有没有释放,系统底层的局部引用表都会自动清空它。如果你在 C++ 里手欠,用一个全局变量或者静态变量把它存起来,下次在别的函数里继续用,那是百分之百会发生野指针 Crash 的(也就是常见的 Fatal signal 11)。
如果咱们想跨函数或者跨线程使用一个 Java 对象,必须显式调用 env->NewGlobalRef() 升级为全局引用。而且用完之后,一定要养成好习惯主动调用 DeleteGlobalRef,否则不仅这块内存会泄漏,时间长了还会把 JNI 底层固定大小的引用表撑爆(JNI ERROR: local reference table overflow)。
其次,刚刚咱们也稍微提到了一嘴,就是线程隔离与 ClassLoader 问题。
这是我在写底层网络库回调时踩过的深坑。在 C++ 自己开的子线程里,通过 AttachCurrentThread 拿到的 JNIEnv,它的上下文环境非常干净。这时候你去调用 FindClass("com/example/MyClass"),系统用的默认是 BootClassLoader,而不是咱们 App 的 PathClassLoader。所以它压根找不到咱们业务代码里写的类,大概率会抛异常。
标准的解决方案是:在主线程(或者任何由 Java 层发起的 JNI 调用里)提前把目标类的 jclass 找到,然后用全局引用缓存下来;或者更底层一点,把主线程的 ClassLoader 对象传到底层存起来,子线程需要找类的时候,通过反射去调那个 ClassLoader 对象的 loadClass 方法。
第三个痛点,是异常处理机制的极其割裂。
在 Java 里,咱们遇到个空指针或者网络超时,如果不 try-catch,程序立刻就在那一行崩溃退出或者阻断执行了。但在 C++ 里,如果你调了 env->CallVoidMethod() 并且 Java 层抛出了异常,C++ 层的代码并不会立刻中断!它会默默把异常挂起,然后跟没事人一样继续往下执行! 这非常危险,如果接下来你还用这个挂起的环境去调别的 JNI 函数,系统会直接判定为致命错误。
所以,咱们在 C++ 里调完 Java 代码后,必须养成习惯调用 env->ExceptionCheck() 或者 env->ExceptionOccurred()。如果发现有异常,应该立刻 env->ExceptionClear() 清除掉,然后妥善清理 C++ 侧的内存,最后执行 return 结束当前 C++ 函数,把控制权还给 Java,让 Java 去真正抛出并处理这个异常。千万不要在 C++ 里带病强跑。
安卓中遇到ui卡顿了你会怎么排查
嗯,其实遇到 UI 卡顿,这在客户端开发里是非常典型的问题。先说结论:排查 UI 卡顿的核心思路是“定位耗时发生的具体层级”,我会按照“从主线程业务耗时,到布局渲染复杂度,再到系统底层内存与调度”这三个大方向,结合线下复现和线上监控来进行系统性排查。
如果展开我的排查步骤,一般是这样的流程:
第一步,我首先会排查主线程的过度耗时操作。因为 Android 系统要求每 16.6ms(在 60Hz 屏幕下)发出一个 Vsync 信号就要完成一帧的绘制,如果主线程在这段时间内没跑完 measure/layout/draw,就会掉帧卡顿。我会重点看是不是在 UI 线程里误写了 I/O 操作、数据库读写,或者是做了非常复杂的 JSON 解析。比如我之前在项目里遇到过 RecyclerView 快速滑动时卡顿,排查后发现其实是因为在 onBindViewHolder 里面做了大量的字符串拼接,并且频繁 new 了对象,导致了瞬间的耗时。
第二步,我会排查布局复杂度与过度绘制(Overdraw)。如果主线程没有明显的业务耗时,那可能就是 UI 树太深了。特别是以前用 LinearLayout 嵌套嵌套再嵌套,如果还带有 weight 属性,会导致子 View 经历多次 measure 阶段,时间复杂度直接指数级飙升。我会尽量使用 ConstraintLayout 把视图层级拍平。同时,过度绘制也是个大坑,如果有太多不可见的背景在重叠绘制,GPU 的像素填充率就会吃紧,也会拖慢渲染。
第三步,也是比较隐蔽的一点,排查内存抖动带来的 GC 卡顿。如果在短时间内(比如动画执行期间或者列表滑动期间)疯狂创建局部对象,会导致老年代或新生代内存迅速被占满,频繁触发虚拟机的 Garbage Collection。因为不管是 Dalvik 还是现在的 ART 虚拟机,在做垃圾回收时多多少少都会有 Stop The World(STW)的现象,这会直接把主线程挂起,导致 UI 突然顿住。
最后,如果上面业务层都没问题,由于我比较熟悉 Framework,我还会考虑是不是系统的底层资源争抢或者锁竞争导致的卡顿。比如后台有个极其消耗 CPU 的子线程,或者主线程在等一个跨进程的 Binder 锁,这种时候单看 UI 代码是看不出毛病的,必须深入到底层的调度机制去看了。
有没有用过一些工具
好的,用过的。排查卡顿光靠肉眼看代码肯定是不够的。先说结论:在线下开发阶段,我主要依赖手机的开发者选项、Android Studio Profiler 以及非常底层的 Systrace(或者更新的 Perfetto)来进行精准分析;而对于线上环境,我深入了解并使用过基于 Looper 机制的 BlockCanary 类 APM 监控工具。
我可以详细说说这几个工具我是怎么配合使用的。
如果是线下调试时觉得卡,我最随手的工具就是手机自带的**“开发者选项”里的“GPU 过度绘制”和“严格模式(StrictMode)”**。一打开过度绘制,屏幕如果红了一片,我就知道肯定是有多余的背景叠加,赶紧去业务代码里把多余的 setBackground 去掉。如果开了严格模式,主线程一做网络或者磁盘读写,屏幕就会闪红框,这能帮我迅速拦住一些低级错误。
如果是相对复杂的卡顿,肉眼看不出来,我一定会去抓一把 Systrace(现在我更习惯用 Perfetto)。这真的是看 Framework 底层调度和渲染链路的神器。我会生成一份 trace 网页文件,在 Chrome 里打开,重点去看主线程里的 Choreographer#doFrame 这一段。如果某一个 Frame 的时间跨度特别长(会出现红色的 F 块警告),我就可以利用鼠标向下层层展开,清清楚楚地看到究竟是 inflate 加载 XML 耗时,还是 performTraversals 里的哪一步计算卡住了。结合我自己编译 AOSP 时在底层加的 Trace 点,我甚至能直观地看到当时 CPU 到底在跑哪个线程,是不是因为时间片被抢走了导致的卡顿。
至于线上环境的监控,因为咱们拿不到用户的手机,这时候我通常会引入类似腾讯 Matrix 或者经典的 BlockCanary 这样的工具。
BlockCanary 的底层原理我觉得特别巧妙。它其实是利用了主线程 Looper 的一个机制:在每次调用 dispatchMessage 分发消息之前和之后,Looper 都会通过一个 Printer 打印一段固定格式的日志。我们只要自定义一个 Printer 替换进去,记录一下分发前后的时间差。如果发现这个差值超过了 16.6ms(或者自定义的卡顿阈值),就说明刚才主线程在处理某个消息时卡住了!这时候工具会立刻去抓取主线程的堆栈信息(StackTrace)并上报。这样一来,即使是线上的偶尔卡顿,我们也能从大盘数据里精准定位到是哪一行业务代码闯的祸。
你觉得kotlin和java比有什么优势
其实我在学安卓的过程中,经历了从 Java 到 Kotlin 的全面转型,写了挺多项目之后感受还是非常深的。先说结论:我觉得 Kotlin 相比 Java 最大的优势可以总结为:语法的极致简洁与现代化、从语言层面消灭了空指针异常的设计,以及引入了协程这套极其优雅的异步并发模型。
我们可以从实际开发的痛点出发,分几个维度来看 Kotlin 是怎么降维打击 Java 的。
首先,最让我感到舒服的是空安全(Null Safety)机制。
在写 Java 的时候,最头疼的就是 NullPointerException,不管拿到什么对象,第一反应就是写一堆 if (obj != null) 的防御性代码,非常臃肿。而 Kotlin 在编译期就把类型强行分成了“可空类型(?)”和“不可空类型”。如果你给一个不可空类型赋值 null,代码直接飘红编译不通过。配合上安全调用符 ?. 和 Elvis 表达式 ?:,整个业务链条的调用变得既安全又清爽,崩溃率肉眼可见地下降了。
其次是强大的现代化语法糖和扩展机制。
Kotlin 提供了大量的扩展函数(Extension Functions)。以前我们用 Java 写工具类,全都是 StringUtils.isEmpty(str) 这种写法。现在用了 Kotlin,我可以直接给系统原生的 String 或者 View 加上自定义方法,写成 str.isEmpty() 或者 view.visible()。这感觉就像是拥有了修改系统源码的特权,代码读起来完全是人类自然语言的逻辑。
另外就是它的高阶函数和 Lambda 表达式。在对集合进行操作(比如 map、filter、reduce)时,Kotlin 的写法极其优雅,彻底干掉了 Java 里那种动不动就 new 一个匿名内部类的臃肿结构。在写 Android 的监听器时体验也是质的飞跃。
另外一个深层次的架构优势是密封类(Sealed Class)和数据类(Data Class)。
以前在 Java 里写 JavaBean,还得手写一大堆 Getter、Setter、hashCode 和 equals,哪怕用了 Lombok 也依然觉得重。Kotlin 一个 data class 关键字就全搞定了。而在做 MVVM 架构时,我们经常用 Sealed Class 来表示页面的有限状态(比如 Loading、Success、Error),配合 when 表达式,编译器会自动检查咱们的状态有没有处理全。如果没有覆盖全,编译就会报错,这就极大地增强了状态机的健壮性。
当然,除了上面这些,最核心、也是 Android 官方力推它的原因,就是它自带的**协程(Coroutines)**机制。这个特性直接让 Android 的异步编程进入了下一个时代。
你刚刚提到了协程,那协程是怎么实现的
好的。其实很多初学者会觉得 Kotlin 协程像是一个有黑魔法的全新并发框架,但其实只要扒开它的字节码看,就会豁然开朗。先说结论:Kotlin 协程并没有脱离 JVM 线程去凭空创造什么魔法,它的底层本质上是基于状态机(State Machine)机制和 CPS(续体传递风格),将复杂的异步回调嵌套在编译期“铺平”,它依然是跑在底层线程池上的一套轻量级任务调度封装。
我在自己写技术博客的时候,专门反编译过挂起函数(suspend)的字节码,其实它的核心运作原理大致分为这么几个步骤:
首先是编译器的 CPS 转换(续体传递)。
当我们在一个普通函数前面加上 suspend 关键字后,Kotlin 编译器在编译这行代码时,会偷偷地在这个函数的参数列表最后面,强行塞入一个极其核心的接口参数——Continuation(续体)。你可以把这个 Continuation 理解为传统 Java 里的 Callback 接口。它里面有一个非常重要的方法叫 resumeWith,用来接收最终的结果或者异常。
其次是状态机的生成。
当这个挂起函数体内部包含多个挂起点(比如调了两次网络的 suspend 请求)时,编译器会把整个函数体转换成一个状态机类(通常继承自 SuspendLambda)。这个状态机类里面维护了一个名叫 label 的整型变量,用来记录当前代码执行到了哪一步。
状态机里的代码大概是一个大的 switch-case。第一次执行时 label 是 0,执行第一段逻辑;遇到第一个真正的挂起点时,函数会立刻返回一个特殊的系统级标志位 COROUTINE_SUSPENDED。
然后是真正的挂起与线程让出。
当函数返回 COROUTINE_SUSPENDED 的这一刻,也就是咱们常说的“协程被挂起了”。但请注意,这时候当前的底层线程并没有被阻塞!线程一看当前任务返回了,它就被解放了,立马去执行同一个线程池里排队的其他协程任务。而那个耗时的网络请求,此时正交由底层的 IO 线程去慢慢跑。
最后是协程的恢复(Resume)。
等到那个耗时的 IO 任务拿到了网络响应,底层框架就会去调用咱们刚才传进去的那个 Continuation 的 resumeWith(result) 方法。这个方法一执行,就会拿着结果重新进入刚才那个状态机的 switch-case 里。因为 label 已经变成了 1,代码就自然而然地跳转到了上次挂起点之后的代码继续往下跑。
所以总结下来,协程的“非阻塞挂起”,本质上就是 return 退出当前函数调用;而它的“恢复”,本质上就是 通过 Callback 重新进入状态机并顺着执行。它是编译器用极其巧妙的手段,把回调地狱在底层偷偷处理了,让咱们能在表面上写出极其清爽的同步代码。
讲一下Handler的实现原理
嗯,Handler 是 Android 消息机制的灵魂。无论是四大组件的生命周期流转、屏幕的触摸事件分发,还是我们平时自己写业务代码切回主线程更新 UI,底层全靠它撑着。先说结论:Handler 机制的核心原理是一个典型的“生产者-消费者”模型。它基于 MessageQueue 构筑了一个按时间戳排序的内存优先级阻塞队列,再通过 Looper 利用 Linux 底层的 epoll I/O 多路复用机制,实现了一个极低能耗的死循环事件分发系统。
如果深入到 Framework 和 Native 层的源码,我们可以把这套机制拆解为四个主要角色和它们之间的流转过程:
首先是环境的初始化,也就是 Looper。
在应用启动时,系统服务会调用 ActivityThread 的 main 方法,这里面第一件事就是调用 Looper.prepareMainLooper()。这个方法会在当前主线程的 ThreadLocal 里存入一个 Looper 实例。而 Looper 在被 new 出来的时候,会在自己肚子里创建一个属于主线程的 MessageQueue(消息队列)。环境准备好之后,紧接着调用 Looper.loop(),主线程就正式进入了死循环,开始不断地去队列里掏消息。
然后是生产端,也就是 Handler 和 Message。
当我们在子线程里拿着主线程的 Handler 调用 sendMessage(msg) 的时候,实际上这个消息经过几层包装,最终会跑到 MessageQueue 的 enqueueMessage() 方法里。
在队列里,所有的消息都是按照 Message.when(也就是期望执行的系统时间戳)从小到大排序的,底层是一条单向链表。因为这个时候可能有好几个子线程都在往里塞消息,为了保证线程安全,这个插入操作是加了 synchronized 锁的。
接下来是最核心的消费端阻塞机制。
回到刚才的 Looper.loop(),它是在主线程死循环调用 MessageQueue.next() 的。如果这时候队列里是空的,或者最前面的那条消息的时间戳还没到,next() 方法就会阻塞住。
很多初学者会疑惑,主线程写个死循环怎么不报 ANR 呢?我在看底层源码时发现,这里的阻塞绝不是像 while(true) 那样死耗 CPU。next() 在底层其实是调用了 C++ 层的 nativePollOnce()。它利用了 Linux 操作系统的 epoll 机制去监听一个 eventfd(事件文件描述符)。一旦没消息了,系统内核会直接把主线程挂起,让出 CPU 的时间片,这其实是个休眠状态。
最后是消息的唤醒与分发。
一旦我们的子线程往队列里成功插了一条新消息,或者到了某条消息设定的延迟时间,底层的 C++ 代码就会调用 nativeWake(),往那个 eventfd 里写入一点数据,内核就会瞬间把主线程唤醒。
主线程醒来后,从 next() 里拿到了那个 Message,接着就会调用 msg.target.dispatchMessage(msg)。这里的 target 其实就是咱们最初发消息的那个 Handler 的引用。转了一大圈,代码终于又回到了咱们重写的 handleMessage() 或者 Runnable 里面。这时候,代码已经真真切切地跑在主线程里了,咱们就可以放心地去更新 UI 了。整个过程极其严密且高效。