第一:LiveData 解决了什么核心痛点?
在没有LiveData的时代,我们在处理UI更新时经常遇到几个棘手的问题:
- 内存泄漏:当异步任务(如网络请求)完成时,如果Activity或Fragment已经被销毁,但回调依然持有其引用,就会导致内存泄漏。
- 空指针异常:同样,当异步回调执行时,如果视图(View)已经被销毁,更新UI的操作就会导致NullPointerException。
- 手动管理生命周期:我们需要在onStart()中开始观察数据,在onStop()中停止观察,在onDestroy()中移除回调,这不仅繁琐,而且容易出错。
- 数据一致性问题:当Activity因配置变更(如屏幕旋转)重建时,我们需要手动保存和恢复数据,否则数据会丢失。
LiveData的出现,就是为了优雅地解决以上所有问题。它确保UI只在安全的状态下,根据最新的数据进行更新。
第二:核心设计模式 —— 观察者模式
LiveData 的底层原理,是一个典型的观察者模式(Observer Pattern)的实现。
这个模式包含两个主要角色:
- 被观察者(Subject):在LiveData的场景中,就是LiveData对象本身。它内部维护了一个数据(mData)和一个观察者列表(mObservers)。
- 观察者(Observer):就是我们通过.observe()方法传进去的那个回调。
基本工作流程是这样的:
- 订阅/观察:当我们的UI控制器(Activity/Fragment)调用liveData.observe(owner, observer)时,实际上是创建了一个包装类(LifecycleBoundObserver),并将这个包装好的观察者添加到了LiveData内部的观察者列表中。
- 数据变更:当我们通过liveData.setValue(T value)(在主线程)或liveData.postValue(T value)(在子线程)更新数据时,LiveData会做两件事:
- 然后,遍历内部的观察者列表,通知每一个观察者:“数据变了!”
- 分发通知:LiveData会调用每个观察者的onChanged(T t)方法,并将最新的数据作为参数传进去。这样,UI层就能收到通知并用新数据更新界面。
第三:精髓所在 —— 如何实现生命周期感知?
这才是LiveData与普通观察者模式最大的不同,也是它的“魔法”所在。
生命周期感知的实现,依赖于另一个Jetpack组件:Lifecycle。
- 绑定生命周期:当我们调用liveData.observe(owner, observer)时,第一个参数owner,是一个LifecycleOwner(Activity和Fragment都默认实现了这个接口)。LiveData会通过这个owner获取到一个Lifecycle对象。
- 添加LifecycleObserver:LiveData会创建一个内部的观察者对象(LifecycleBoundObserver),这个对象不仅是一个Observer,它还实现了LifecycleObserver接口。然后,LiveData会把这个LifecycleBoundObserver添加到owner的Lifecycle中。
- 状态监听:从此以后,Lifecycle对象(比如Activity的生命周期状态)发生任何变化(如ON_CREATE, ON_START, ON_DESTROY),这个LifecycleBoundObserver都会收到通知。
- 智能分发:现在,当LiveData的数据发生变化,需要通知观察者时,它不会盲目地直接调用onChanged()。它会先通过LifecycleBoundObserver检查其绑定的LifecycleOwner(也就是我们的Activity/Fragment)的当前生命周期状态。
- 只有当LifecycleOwner的状态是STARTED或RESUMED(即处于活跃状态)时,LiveData才会真正地分发数据。
- 如果LifecycleOwner处于STOPPED或DESTROYED状态,LiveData不会分发数据,但数据依然会更新。当LifecycleOwner下次回到活跃状态时,它会立即收到最新的数据。
- 当LifecycleOwner的状态变为DESTROYED时,Lifecycle组件会通知LifecycleBoundObserver,然后LiveData会自动将这个观察者从自己的列表中移除,从而完美地避免了内存泄漏。
总结
LiveData的原理,可以概括为:
它是一个基于观察者模式的数据容器,通过与Lifecycle组件的精妙结合,实现了对生命周期的自动管理。它确保了数据更新的通知只在UI组件处于活跃状态时才被分发,并在组件销毁时自动清理引用,从而从根本上解决了传统UI更新方式中常见的内存泄漏、空指针和手动管理生命周期等一系列难题。
第一步:消息的创建与发送(Handler的角色)
这是整个流程的起点,通常发生在子线程。
- 生产者:Handler 在这个模型中扮演了生产者接口和消费者的双重角色。当我们在一个子线程中需要更新UI时,我们不会直接操作UI组件,而是通过一个与主线程绑定的Handler实例来发送消息。
- 创建消息:我们会创建一个Message对象。Message是消息的载体,它可以携带一些简单的数据(如what, arg1, arg2)或一个复杂的Object对象。为了性能,我们通常不直接new Message(),而是通过Message.obtain()从一个可复用的消息池中获取。
- 发送消息:我们调用Handler的sendMessage(Message msg)或post(Runnable r)等方法。
- post(Runnable r)方法实际上是一个语法糖,它内部会把Runnable对象封装进一个Message的callback字段中。
- 在sendMessage()的内部,Handler会把这个Message放入一个目标消息队列中。这个目标队列是哪个呢?就是创建这个Handler时,它所关联的那个线程的MessageQueue。
关键点:每个Handler实例在创建时,都会隐式或显式地与一个Looper相关联,也就关联了那个Looper所在的线程以及其唯一的消息队列MessageQueue。我们在主线程创建的Handler,默认就关联了主线程的Looper和MessageQueue。
第二步:消息的存储与排队(MessageQueue的角色)
- 仓库/传送带:MessageQueue顾名思义,就是消息队列。它在整个模型中扮演了仓库或传送带的角色。它并不是一个Java层面的队列,其底层数据结构是一个单链表,但它的插入和读取操作都经过了精心的设计和同步处理,是线程安全的。
- 入队:当Handler调用sendMessage()时,Handler会调用MessageQueue的enqueueMessage()方法。这个方法会将消息按照触发时间(when)的先后顺序,插入到链表的合适位置。这意味着,这是一个延时消息队列,延迟最久的消息排在链表尾部,即将触发的消息排在头部。
第三步:消息的循环与分发(Looper的角色)
这是整个机制得以持续运转的“发动机”,通常发生在主线程(UI线程)。
- “死循环”的发动机:Looper的字面意思就是“循环器”。它的核心作用,就是在其loop()方法中开启一个无限循环。
- 取消息:在这个无限循环里,Looper会不断地尝试从它自己绑定的MessageQueue中调用next()方法来取出消息。
- 如果队列中没有消息,next()方法会阻塞,让出CPU资源,避免空转消耗性能。这是一种高效的等待机制,底层依赖于Linux的epoll机制。
- 一旦有新的消息通过Handler入队,next()方法就会被唤醒,并返回队列头部的消息。
- 分发消息:Looper拿到消息后,并不会自己处理它,而是扮演一个“调度员”的角色。它会调用消息自带的目标Handler(msg.target)的dispatchMessage(Message msg)方法,将消息分发出去。msg.target就是当初发送这条消息的那个Handler实例。
关键点:一个线程最多只能有一个Looper和一个MessageQueue。主线程在应用启动时,系统就已经自动为我们创建并启动了Looper。这也是为什么我们可以在主线程直接创建Handler,但在子线程中,如果想使用Handler,就必须手动调用Looper.prepare()和Looper.loop()来创建和启动循环器。
第四步:消息的最终处理(Handler的角色回归)
- 消费者:消息经过一圈流转,最终又回到了最初发送它的Handler实例中。此时,Handler扮演了消费者的角色。
- 处理逻辑:Handler的dispatchMessage(Message msg)方法会按照以下优先级来处理消息:
- 首先,检查Message的callback字段(即Runnable对象)是否不为null。如果是,就直接执行run()方法。这就是handler.post()的原理。
- 如果callback为null,则检查Handler自身是否设置了mCallback(Handler.Callback接口)。如果设置了,就调用其handleMessage()方法。如果这个方法返回true,表示消息已被消费,流程结束。
- 如果以上两者都不满足,最后会调用我们最熟悉的、通常被我们重写的handler.handleMessage(Message msg)方法。
至此,一个从子线程发起,希望在主线程执行的异步操作,就通过Handler、MessageQueue和Looper的协同工作,安全、有序地完成了。
总结一下这个闭环流程:
- Handler.sendMessage():子线程把消息压入主线程的MessageQueue。
- Looper.loop():主线程的Looper不断从MessageQueue中取出消息。
- Handler.dispatchMessage():Looper将消息交还给对应的Handler处理。
- Handler.handleMessage():Handler在主线程中执行最终的UI更新等操作。
一是解决多线程并发访问UI的安全性问题,二是构建一个灵活、通用的异步消息处理框架。
第一个层面:保证UI操作的线程安全
这是Handler最核心、最广为人知的作用。
- 问题的根源:Android的UI工具包不是线程安全的。这意味着,如果允许多个线程同时、无序地访问和修改UI组件(比如一个TextView),就会导致UI状态不可预测,甚至引发程序崩溃。这就好比多个工人同时在同一面墙上刷不同颜色的漆,最终墙面会变得一团糟。
- Handler的解决方案:为了解决这个问题,Android框架强制规定:所有UI操作都必须在同一个线程中执行,这个线程就是我们常说的主线程(UI Thread)。
Handler机制就为此提供了一个官方的、标准的“管道”。它允许任何子线程(Worker Thread),将一个“UI更新任务”封装成一个Message或Runnable对象,然后通过这个“管道”发送到主线程的消息队列(MessageQueue)中去排队。
主线程的Looper会从队列里一个一个地取出这些任务,然后在主线程环境中安全地执行它们。
所以,Handler的第一个关键作用,就是充当了一个线程切换的桥梁,它确保了所有的UI更新请求,最终都能被序列化,并在单一的主线程中有序地执行,从而从根本上保证了UI操作的线程安全和一致性。
第二个层面:构建灵活的异步消息处理框架
虽然Handler最常用于UI更新,但它的设计远不止于此。它实际上是Android系统内部一个通用的、底层的异步消息驱动模型。
- 解耦任务的发送者和执行者:
- 发送者(比如一个子线程)只需要知道如何创建并发送一个消息,它完全不需要关心这个消息由谁接收、何时接收、以及如何处理。
- 接收者(Handler的实现)也只关心如何处理到来的消息,不关心消息是谁从哪里发来的。
- MessageQueue作为中间的缓冲池,进一步解耦了发送和接收的时间。发送者可以立即返回,而不必等待接收者处理完成。
- 实现任务的调度与延迟执行:
- Handler机制天然支持定时任务和延迟任务。通过sendMessageDelayed()或postDelayed(),我们可以精确地控制一个任务在未来的某个时间点执行,而不需要我们自己去管理Thread.sleep()或者Timer。
- MessageQueue内部是一个按时间排序的链表,它能高效地管理这些待执行的任务。
- 作为系统服务的基石:
- 这个作用我们平时可能不直接接触,但它非常重要。Android的四大组件,如Activity的生命周期回调(onCreate, onResume等)、Service的启动、广播的接收,它们之所以能被有序地调度和执行,其底层就是由主线程的Lo-oper和Handler机制驱动的。
- 应用的主线程就是一个大的Looper循环,不断地处理来自系统AMS(ActivityManagerService)或其他地方的消息,这些消息驱动了整个应用的生命周期和事件响应。
“方便切换线程”是Handler机制为我们开发者带来的最直观、最重要的一个“结果”,但它背后更深层次、更根本的设计目的,是为了解决“线程安全”和实现“任务序列化”。
我们可以这样来理解这个递进关系:
第一层:问题的根源 —— UI的线程不安全
首先,Android框架面临一个最基本的设计约束:UI工具包是单线程的,并且不是线程安全的。
这意味着,如果系统允许任何线程在任何时候都能随意地去修改UI,那么UI界面将会陷入混乱,程序也会频繁崩溃。
所以,Android必须强制建立一个规则:所有的UI更新,都必须排队,在同一个线程(主线程)里一个接一个地执行。
第二层:解决方案 —— 任务的序列化与调度
为了实现上面那个规则,就需要一套机制来扮演“交通警察”的角色。这个“交警”需要能做到:
- 接收来自四面八方(各个子线程)的“UI更新请求”。
- 让这些请求在一个地方(MessageQueue)排好队。
- 按照顺序,一个一个地放行,让它们在主线程这条唯一的“马路”上执行。
Handler、Looper、MessageQueue这套组合,就完美地扮演了这个“交通警察”和“等候区”的角色。
所以,Handler设计的根本目的,不是为了“切换”这个动作本身,而是为了“安全地、有序地处理”来自不同线程的任务。它通过将并发的、无序的请求,转化为一个串行的、有序的任务队列,来保证UI操作的绝对安全。
第三层:带来的结果 —— 方便的线程切换
正是因为Handler机制完美地实现了上面那个“交通警察”的功能,它才为我们开发者提供了一个极其方便、可靠的线程切换工具。
- 当我们说“切换线程”时,我们实际上是想把一个“任务”从当前线程“转移”到另一个线程去执行。
- Handler恰好就提供了这个能力。我们只需要在子线程把任务(Message或Runnable)交给Handler,它就会自动帮我们完成排队、调度、以及最终在目标线程(主线程)执行的所有复杂工作。
总结与类比
所以,如果把线程比作不同的车道,主线程是唯一通往“UI大厦”的车道。
- “方便切换线程”,就好比说“这个立交桥很方便,能让车从辅路开到主路”。这描述的是它的功能和结果。
- 而Handler的真实设计目的,更像是说:“为了防止所有车都挤到‘UI大厦’门口造成交通瘫痪和事故,我们必须修建这个立交桥,并配备一个交通指挥中心(Looper)和等候区(MessageQueue),来确保所有车辆(任务)都能安全、有序地进入大厦。” 这描述的是它存在的必要性和根本原因。
因此,Handler的设计不仅仅是为了方便,更是为了安全和秩序。这种安全和秩序的设计,最终以一种非常方便的形式提供给了开发者,让我们能够轻松地进行线程间的通信。
第一大类:线程切换(主要用于UI更新)
这是Handler最经典、最基础的用法,也是我们日常开发中使用频率最高的场景。
- 子线程更新UI:
- 场景描述:这是教科书级别的用法。当我们在子线程中执行一个耗时操作,比如网络请求、数据库查询、或者复杂计算后,需要将结果展示在UI上。
- 具体做法:在子线程中获取数据后,通过一个与主线程绑定的Handler,发送一个Message或post一个Runnable,将数据显示在TextView、更新ImageView或者刷新RecyclerView等。
- 为什么用Handler:因为它解决了Android UI非线程安全的核心问题,提供了一个将任务从任何线程安全地调度回主线程执行的官方通道。
第二大类:任务调度(定时与延迟执行)
Handler不仅仅能立即执行任务,它内置的延时消息队列机制,使其成为一个轻量级、精准的任务调度器。
- 延迟操作:
- 场景描述:很多UI交互都需要延迟执行。比如,引导页展示2秒后自动跳转到主页;或者用户点击一个按钮后,延迟一小段时间再执行某个动画,以获得更好的用户体验。
- 具体做法:使用handler.postDelayed(Runnable, delayMillis)或者handler.sendMessageDelayed(Message, delayMillis)。
- 周期性任务:
- 场景描述:需要规律性地、重复地执行某个任务。比如,实现一个轮播图,每隔3秒切换到下一张图片;或者在App内做一个计时器,每秒更新一次UI显示。
- 具体做法:在一个Runnable任务的结尾,再次调用handler.postDelayed(),将自身再次投递到消息队列中,从而形成一个循环。
- 相比Timer的优势:使用Handler实现周期性任务比使用Timer+TimerTask更受推荐,因为Handler的任务最终是在主线程执行的,可以直接更新UI,无需再次切换线程。而TimerTask是在子线程执行,更新UI还需要额外的Handler或runOnUiThread,增加了复杂性。
第三大类:控制消息流与任务执行顺序
Handler与MessageQueue的结合,确保了任务的序列化执行,这在某些场景下非常有用。
- 避免并发冲突:
- 场景描述:假设有一个功能,用户可能会在短时间内通过不同入口(比如快速点击按钮、收到推送)多次触发同一个耗时操作。我们不希望这些操作并发执行(可能会导致数据错乱或资源浪费),而是希望它们一个接一个地、有序地完成。
- 具体做法:我们可以创建一个与特定子线程(一个HandlerThread)绑定的Handler。所有触发请求都通过这个Handler发送消息。由于Handler机制保证了消息会被依次处理,这就天然地将并发请求转化为了一个串行队列,保证了任务的有序执行。
- 消息的插队与移除:
- 场景描述:我们可能安排了一个延迟任务,但在它执行之前,情况发生了变化,需要取消它,或者立即执行一个更高优先级的任务。
- 具体做法:Handler提供了removeMessages()和removeCallbacks()等方法,可以根据Message的what字段或Runnable对象,精确地从消息队列中移除还未执行的任务。同时,sendMessageAtFrontOfQueue()可以实现消息插队,让某个任务被优先处理。
总结
总的来说,Handler的使用场景非常广泛:
- 当你需要在后台线程完成工作后,回到主线程更新UI时,Handler是首选。
- 当你需要延迟执行、或者周期性地执行某个任务时,Handler是一个轻量且高效的选择。
- 当你需要确保一系列任务按照发送的顺序、一个接一个地串行执行时,Handler可以帮你轻松构建一个任务队列。
1. 为什么需要线程间通信?
首先,最核心的原因是安卓的单线程模型原则:只有主线程(UI线程)才能更新UI界面。
如果把网络请求、数据库读写、文件IO等耗时操作都放在主线程,就会导致界面卡顿,甚至出现ANR(应用无响应)的错误,这会极大地影响用户体验。因此,我们必须把这些耗时操作放在子线程中执行。
但子线程执行完任务后,比如从服务器获取了数据,最终还是需要将结果展示在界面上。由于子线程不能直接操作UI,就必须通过一种机制通知主线程,让主线程来完成UI更新。这个“通知”的过程,就是线程间通信。
所以,线程间通信的根本目的,就是在保证主线程流畅不被阻塞的前提下,将子线程的处理结果安全地传递给主线程进行UI更新。
2. 如何在线程间通信?Handler是什么角色?
安卓提供了一套核心的异步消息处理机制来实现线程通信,主要由 Handler、Looper 和 MessageQueue 三个角色组成。
- Looper: 扮演“轮询器”的角色。它在一个线程中无限循环,时刻检查该线程的 MessageQueue(消息队列)中是否有新的消息。主线程在应用启动时,系统已经自动为其创建了 Looper。
- MessageQueue: 扮演“消息队列”的角色,它以队列的形式存放由 Handler 发送过来的 Message 对象。
- Handler: 扮演“信使”和“处理器”的角色。它主要有两个作用:
- 发送消息:在子线程中,我们可以调用 Handler 的 sendMessage() 或 post() 方法,将一个 Message 或 Runnable 对象发送到目标线程的 MessageQueue 中排队。
- 处理消息:Looper 从 MessageQueue 中取出消息后,会回调给创建这个 Handler 时所在的线程。Handler 会在它的 handleMessage() 方法或 Runnable 的 run() 方法中处理这个消息。
整个流程可以概括为:子线程完成耗时任务 -> 通过主线程的 Handler 发送一个消息 -> 消息进入主线程的 MessageQueue 排队 -> 主线程的 Looper 取出该消息 -> 回调给 Handler 处理 -> Handler 在主线程中执行UI更新。
3. 使用线程池不好吗?它和Handler的区别是什么?
线程池和Handler解决的是不同维度的问题,它们是合作关系而非替代关系。
- 线程池(ThreadPoolExecutor):它是一个后台任务的管理框架。它的核心价值在于复用线程,避免了频繁创建和销毁线程带来的性能开销。它解决的是“如何高效地执行多个后台任务”的问题。
- Handler机制:它是一个线程间的通信桥梁。它的核心价值在于将任务的处理结果从一个线程安全地切换到另一个线程。它解决的是“如何在不同线程间传递信息”的问题。
所以,在实际开发中,我们常常将它们结合使用:用线程池来高效地执行后台的耗时任务,当任务执行完毕后,在线程池的线程里,通过 Handler 将结果发送给主线程去更新UI。线程池负责“干活”,Handler 负责“汇报”。
4. 什么场景下需要我们手动构建一个消息循环系统?
除了最常见的“子线程通知主线程更新UI”之外,还有一种非常重要的场景,需要我们手动为子线程构建消息循环系统,也就是调用 Looper.prepare() 和 Looper.loop()。
这个场景就是创建一个需要长期存在、并能接收和处理消息的“工作者线程”(Worker Thread)。
默认情况下,子线程执行完 run() 方法里的任务后就会销毁。但如果我们希望这个子线程能持续在后台运行,并可以随时接收来自其他线程(包括主线程)的指令,同时保证这些指令是按顺序串行执行的,这时就需要为它建立消息循环机制。
举个典型的例子:
假设我们有一个独立的文件处理线程,应用中的所有文件下载、写入、读取请求都由它来处理。为了避免多线程同时写文件导致数据错乱,我们要求所有文件操作必须按顺序执行。
这时,我们就可以创建一个带 Looper 的子线程。其他任何线程(UI线程、网络线程等)不直接进行文件操作,而是将文件操作的请求封装成一个 Message,通过与这个文件线程绑定的 Handler 发送给它。这些请求会进入文件线程的 MessageQueue 中排队,然后由这个线程一个一个地取出来串行执行,从而完美地保证了操作的顺序性和线程安全。
1. 核心定位不同:一个是“通信兵”,一个是“突击队”
- Handler机制的核心是“通信”。它就像一个“通信兵”,主要职责是在不同线程之间传递消息和任务。它最核心、最不可替代的价值,就是能将子线程的任务处理结果,安全地切换回主线程来更新UI。它保证了任务执行的线程环境切换。
- 线程池的核心是“并发执行”。它就像一支“突击队”,主要职责是管理和复用一组后台线程,来高效地执行大量的并发任务。它解决了频繁创建和销毁线程带来的性能开销问题,并能有效控制系统的最大并发数。
所以,它们一个管“怎么把话说过去”,一个管“怎么把活干完”,职责边界很清晰。
2. 各自的优势(好处)
- UI安全:这是它最根本的优势。它是Android官方推荐的、唯一可靠的与UI主线程交互的方式,能确保UI操作的线程安全性。
- 执行顺序保证:在一个Looper的消息队列中,消息是先进先出(FIFO)的,所以Handler处理的任务是串行的。这对于需要保证顺序性的操作非常重要。
- 灵活的调度:它提供了postDelayed、sendMessageAtTime等方法,可以非常方便地实现延迟任务和定时任务。
- 高并发和高性能:它能够同时执行多个任务,充分利用CPU的多核性能,这对于批量网络请求、多文件读写等场景,性能远超单线程的Handler。
- 资源管理:通过复用线程,极大地减少了系统开销。并且可以精细地控制线程的数量、存活时间等,防止资源耗尽。
- 强大的可配置性:可以根据业务场景,灵活配置核心线程数、最大线程数、任务队列类型等,实现最优的性能表现。
3. 各自的劣势(坏处)或不适用场景
- 无法处理并发:由于其串行执行的特性,它不适合处理大量独立的、耗时的并发任务。如果把多个耗时任务都交给一个Handler处理,它们只会排队等待,无法利用多核优势,效率低下。
- 内存泄漏风险:这是个经典问题。在Activity中使用非静态内部类的Handler,并且有延迟消息时,容易导致Activity无法被回收,从而引发内存泄漏。当然,这个问题可以通过使用静态内部类加弱引用的方式解决。
- 不能直接更新UI:这是它最本质的“短板”。在线程池的任何一个子线程中,都绝不能直接操作UI控件,否则会立即抛出CalledFromWrongThreadException异常。
- 无序性:默认情况下,线程池无法保证任务的执行顺序和完成顺序,这对于依赖前置任务结果的场景处理起来会比较复杂。
- 增加了并发编程的复杂度:当多个任务涉及到共享资源时,开发者需要自己处理线程同步、锁等问题,否则容易出现数据安全问题。
层面一:组件间的通信方式(以广播为例)
从应用组件(如Activity, Service)之间如何传递消息的角度看,我们可以把系统广播(Broadcast)分为两种类型:
- 异步消息 (Asynchronous) - 标准广播 (Standard Broadcasts)
- 是什么:这是我们最常用的广播形式。当应用发送一个标准广播时,系统会把这个消息(Intent)“广而告之”,所有对这个消息感兴趣的接收者(BroadcastReceiver),几乎在同一时间、以一种无序的方式接收到它。
- 高效:发送方发出消息后立刻返回,不需等待接收者处理完毕。
- 完全异步:接收者之间没有依赖关系,无法互相干预,也无法中止消息的传递。
- 好比:就像在微信群里发一个通知,群里所有人都能立刻看到,但谁先读、谁后读,发送者不关心,也无法控制。
- 同步消息 (Synchronous) - 有序广播 (Ordered Broadcasts)
- 是什么:这是一种同步化的、有顺序的消息传递方式。广播会按照接收者预先设定的优先级(Priority),从高到低,依次传递。
- 有序性:消息严格按照优先级顺序,一个一个地传递给接收者。
- 可拦截与修改:优先级高的接收者可以处理消息后,选择将消息继续传递下去(可以修改内容后再传递),或者直接中止广播,后续的接收者就再也收不到了。
- 阻塞性:从某种意义上说,它在接收者链条上是“同步”的,前一个不处理完,后一个就收不到。
- 好比:就像公司里审批文件,需要从主管、到经理、再到总监,一级一级地签批。中间任何一级都可以否决(中止),也可以批注意见(修改)后再传给下一级。
层面二:进程间的通信方式(以AIDL为例)
当我们讨论跨进程通信(IPC)时,调用的方式也可以分为同步和异步:
- 同步调用 (Synchronous Call)
- 是什么:这是AIDL默认的通信方式。客户端进程发起一个远程调用后,它的执行线程会被挂起(阻塞),直到服务端进程完成计算,并返回结果后,客户端线程才会继续向下执行。
- 调用简单:编码方式和调用普通本地方法一样,易于理解。
- 风险:如果服务端处理耗时较长,会直接阻塞客户端的调用线程。如果在UI线程中进行同步IPC调用,极易引发ANR。
- 好比:就像打电话,你问对方一个问题,你必须在线上等着对方想出答案告诉你,你才能挂电话去做别的事。
- 异步调用 (Asynchronous Call)
- 是什么:在AIDL中,我们可以通过oneway关键字将一个接口方法标记为异步调用。客户端发起调用后,不会被阻塞,会立刻返回继续执行自己的代码。服务端会接收到这个调用请求,并在自己的线程池中处理它。
- 非阻塞:客户端的体验非常好,不会因为服务端的耗时而卡顿。
- 无返回值:oneway方法不能有返回值。它是一种“发完不管”(fire and forget)的单向通信。如果需要结果,通常需要配合回调(Callback)机制来实现。
- 好比:就像发短信或发邮件,你把问题发过去之后,就可以立刻去做自己的事情了,不用在线等待。对方处理完后,可以通过再给你发一条短信(回调)的方式告诉你结果。
- 同步,通常意味着有序性、阻塞性、和等待结果。它强调的是一种可控的、顺序的执行流。
- 异步,通常意味着无序性、非阻塞、和不关心或通过回调获取结果。它强调的是并发和效率,避免调用方被长时间等待所拖累。
1. 什么是内存屏障?可以把它想象成一个“交通指挥员”
首先,内存屏障,又叫内存栅栏(Memory Fence),它本质上是一个CPU指令。
它的核心作用,就像一个“交通指挥员”,用来给代码的执行顺序和内存的可见性建立一个明确的规则。当程序执行到这个“指挥员”这里时,它会强制性地要求:
- 在它之前的指令,必须全部执行完毕。
- 在它之后的指令,必须等它执行完毕后才能开始。
- 强制将CPU高速缓存中的数据写回主内存(Flush),并让其他CPU的缓存失效(Invalidate),从而保证多核之间数据的可见性。
2. 它到底解决了什么问题?——“眼见不一定为实”
要理解内存屏障,就必须先理解它要解决的问题,那就是“指令重排序” 和 “内存可见性”。
在现代计算机体系中,为了极致的性能,代码的实际执行顺序,和你编写的顺序,很可能是不一样的。主要有两个“捣蛋鬼”会打乱顺序:
- 编译器/JIT:为了优化,它们可能会调整指令的顺序。
- CPU:为了充分利用内部的执行单元,CPU也可能进行乱序执行。
在单线程下,这种重排序不会有任何问题,因为系统会保证最终结果和你期望的一致。但在多线程环境下,这就可能导致灾难性的后果。
在Android/Java中,我们如何使用它?
作为应用层开发者,我们通常不会直接去插入一个汇编级别的内存屏障指令。我们是通过Java/Kotlin提供的一些关键字和类,来间接地使用它们。
最典型的两个例子就是:
- volatile关键字:
- 可见性保证:当一个线程写入一个volatile变量时,JIT/JVM会自动在这个写操作之后插入一个“写屏障”(Store Barrier)。这个屏障会强制将该变量的值和在它之前发生的所有修改都刷新到主内存。
- 有序性保证:当一个线程读取一个volatile变量时,JIT/JVM会自动在这个读操作之前插入一个“读屏障”(Load Barrier)。这个屏障会强制让本地CPU缓存失效,从主内存中重新加载。同时,它也禁止了编译器和CPU将读操作之后的指令重排序到读操作之前。
- 经典应用:最著名的就是双重检查锁定(DCL)实现的单例模式。给单例实例instance加上volatile关键字,就是为了防止在new Singleton()这个操作中,因指令重排导致其他线程拿到一个“半成品”对象。
- synchronized关键字:
- synchronized的底层实现也依赖于内存屏障。
- 当进入一个synchronized代码块时,会执行一个类似于读屏障的操作,确保能读到共享变量的最新值。
- 当退出synchronized代码块时,会执行一个类似于写屏障的操作,将代码块内对共享变量的所有修改都刷新回主内存。这保证了它的可见性和原子性。
- 内存屏障是一个底层的CPU指令,用于解决由编译器和CPU的重排序优化,以及多核CPU缓存不一致,所导致的并发问题。
- 它主要提供两大保证:禁止指令重排序 和 确保内存可见性。
1. 核心流程:一个“U”型责任链
您可以把整个事件分发流程想象成一个“U”型的责任链模式。
- “U”的左边(下降沿)是事件的“分发”过程:一个触摸事件(MotionEvent)产生后,会从最顶层的Activity开始,像命令一样,自上而下地传递,经过ViewGroup,最终到达最底层的目标View。这个过程主要是为了“找到最合适的处理者”。
- “U”的右边(上升沿)是事件的“处理”过程:如果最底层的View不处理这个事件,事件就会像皮球一样,沿着原路,自下而上地冒泡,传递给它的父ViewGroup,乃至最终的Activity来处理。这个过程是为了“给上级一个补救的机会”。
整个流程的目标,就是为一次触摸事件(从手指按下ACTION_DOWN到抬起ACTION_UP的完整序列),找到一个愿意负责到底的View。
2. 三个关键方法:分发、拦截、消费
这个“U”型流程是由三个核心方法来驱动的,它们分别扮演着不同的角色:
- public boolean dispatchTouchEvent(MotionEvent ev) - 任务调度官
- 作用:这是事件分发的入口和总指挥。只要触摸事件传递到任何一个View(包括ViewGroup),这个方法一定会被调用。它负责决定这个事件是应该向下分发给子View,还是交由自己处理,或者是拦截下来。
- 返回true:表示事件已经被消费,分发流程到此结束。
- 返回false:表示当前View不处理,事件会回传给父View的onTouchEvent方法来处理。
- 调用super.dispatchTouchEvent(ev):表示执行默认逻辑,对于ViewGroup就是继续向下分发,对于View就是调用自己的onTouchEvent。
- public boolean onInterceptTouchEvent(MotionEvent ev) - 哨卡安检员
- 作用:这个方法是ViewGroup独有的。它扮演一个“哨卡”的角色,有机会在事件向下传递的途中,“半路拦截”,把事件据为己有。
- 返回false(默认):表示“放行”,事件可以继续传递给子View。
- 返回true:表示“拦截”,事件不会再向下传递,而是直接交给当前ViewGroup自己的onTouchEvent方法来处理。
- 关键点:一旦某个ViewGroup拦截了事件(比如在ACTION_MOVE时返回了true),那么后续的同一序列事件(比如后续的MOVE和UP)将不会再经过它的onInterceptTouchEvent方法,而是直接发给它的onTouchEvent。
- public boolean onTouchEvent(MotionEvent ev) - 一线执行者
- 作用:这是真正处理和消费事件的地方。我们平时写的setOnClickListener、setOnLongClickListener等逻辑,最终都是在这个方法里被调用的。
- 返回true:表示“我消费了这个事件”,那么这个事件序列的后续所有事件(MOVE, UP)都会直接交给我处理。
- 返回false:表示“我不想处理这个事件”,那么事件就会冒泡给父View的onTouchEvent来处理。
总结几个关键规则
- 事件序列的起点是ACTION_DOWN:如果没有任何一个View的onTouchEvent在ACTION_DOWN事件中返回true,那么这个事件序列的后续所有事件(MOVE, UP)都不会再被分发下来,系统会认为没人对这个手势感兴趣,事件将由Activity自己处理。
- ViewGroup的默认行为:默认情况下,ViewGroup的onInterceptTouchEvent返回false,表示不拦截,所以事件能够顺利地传递到最底层的View。
- View的默认行为:如果一个View是可点击的(clickable=true),那么它的onTouchEvent默认就会返回true,消费掉事件。这就是为什么我们点击一个Button,它所在的ViewGroup的onTouchEvent不会被触发的原因。
- 一次拦截,终身负责:一旦一个ViewGroup在onInterceptTouchEvent中决定拦截,它就会成为这个事件序列的负责人。同时,系统会给原来的目标子View发送一个ACTION_CANCEL事件,通知它“你被剥夺处理权了”。最典型的例子就是ScrollView,它会先让子View处理ACTION_DOWN,但在ACTION_MOVE时如果检测到是滑动操作,就会拦截事件,自己处理滑动。
ViewPager 是一个非常经典的滑动切换视图的容器控件。关于它的工作机制、事件分发以及滑动冲突的解决,我的理解如下,我将从三个方面来阐述:
1. ViewPager是什么?—— 一个带“缓存”的滑动页面管理器
首先,ViewPager 本质上是一个ViewGroup,它允许用户通过左右滑动来翻阅页面(通常是Fragment)。它的核心是PagerAdapter,负责提供需要显示的页面、管理页面的生命周期。
它有一个很重要的特点,就是预加载和缓存机制。通过setOffscreenPageLimit()方法,它可以提前创建并保留左右相邻的页面,这样在滑动时就能立刻显示,保证了流畅性,避免了每次滑动都重新创建页面的开销。
2. ViewPager的事件分发:一个“优先判断滑动”的典型模型
ViewPager的事件分发是处理滑动冲突的绝佳学习案例。它的核心逻辑都体现在对onInterceptTouchEvent方法的重写上。
可以概括为以下几个步骤:
- ACTION_DOWN(手指按下时):无条件放行
- 当手指第一次按下时,ViewPager的onInterceptTouchEvent方法会直接返回false。
- 为什么这么做? 因为在DOWN的瞬间,ViewPager无法判断用户的意图。用户可能想点击页面里的按钮,也可能想滑动页面。所以,它必须“放行”,把事件先交给子View去处理,给自己和子View一个机会。同时,它会记录下按下的坐标点 (x, y)。
- ACTION_MOVE(手指移动时):滑动意图的“关键决策点”
- 当手指移动时,事件再次来到ViewPager的onInterceptTouchEvent。
- 此时,ViewPager会计算当前坐标和之前记录的DOWN坐标的差值,也就是水平滑动的距离 dx 和垂直滑动的距离 dy。
- 它会进行一个关键判断:如果用户在水平方向的滑动距离 dx,大于一个系统定义的最小滑动距离(TouchSlop),并且也大于垂直方向的滑动距离 dy,那么ViewPager就认定用户的意图是“左右滑动”。
- 一旦认定是滑动,onInterceptTouchEvent就会返回true,将事件拦截下来。
- 拦截的后果:
- 一旦ViewPager拦截了事件,子View会立即收到一个ACTION_CANCEL事件,通知它:“你的事件处理权被剥夺了”。
- 从此以后,这个手势序列中所有后续的事件(后续的MOVE和最终的UP),都会绕过onInterceptTouchEvent,直接交给ViewPager自己的onTouchEvent方法来处理,从而实现页面的滚动。
- 如果不是滑动呢?(点击事件如何成功)
- 如果用户从DOWN到UP的整个过程,手指的移动距离都很小,没有达到TouchSlop,那么ViewPager的onInterceptTouchEvent在MOVE阶段将一直返回false。
- 这意味着整个事件序列(DOWN-> MOVE -> UP)都会被完整地传递给子View。如果子View是一个Button,它就能成功响应onClick事件。
总结一下:ViewPager的事件处理策略非常“霸道”但也很聪明。它先礼后兵,先放行DOWN事件,然后在MOVE事件中“侦察”,一旦发现是自己的“滑动菜”,就毫不犹豫地拦截,自己处理。
3. 滑动冲突的解决:两种主流思想
ViewPager的这种机制,在嵌套使用时(比如ViewPager里嵌套另一个可以滑动的ViewPager或ScrollView),就会产生滑动冲突。比如,内外层都想处理滑动事件,听谁的?
解决滑动冲突主要有两种方法:外部拦截法 和 内部拦截法。
- 外部拦截法 (Parent-side Solution)
- 思想:事件的决定权交给父容器,由父容器的onInterceptTouchEvent来判断是否需要拦截。
- 实现:重写父容器的onInterceptTouchEvent方法。在方法内部,根据业务需求判断,什么时候应该由自己拦截(返回true),什么时候应该放行给子元素(返回false)。
- 例子:一个竖向的ScrollView里嵌套一个横向的ViewPager。我们就可以在ScrollView的onInterceptTouchEvent里判断:如果是横向滑动,就返回false,把事件交给ViewPager;如果是竖向滑动,就返回true,自己处理。
- 内部拦截法 (Child-side Solution)
- 思想:父容器默认不拦截任何事件,而是由子元素通过一个方法来“请求”父容器不要拦截。
- 实现:子元素在需要自己处理事件时,调用getParent().requestDisallowInterceptTouchEvent(true)方法。这个方法会暂时禁止父容器调用onInterceptTouchEvent来拦截事件,直到当前事件序列结束。
- 例子:还是ScrollView嵌套ViewPager。我们可以重写ViewPager的dispatchTouchEvent方法。当ACTION_DOWN发生时,就立刻调用requestDisallowInterceptTouchEvent(true),告诉ScrollView:“别拦我”。然后,在内部判断,如果发现是竖向滑动,不是自己该处理的,再调用requestDisallowInterceptTouchEvent(false),把事件处理权还给父容器。
- 注意:使用内部拦截法,需要父容器不拦截除ACTION_DOWN以外的事件,才能让子元素有机会处理。
public class Singleton
}
/**
* 3. 提供全局唯一的公共静态方法来获取实例。
*/
public static Singleton getInstance()
}
}
return instance;
}
// 单例类的其他方法
public void showMessage() {
System.out.println("Hello from the Singleton instance!");
}
}
synchronized锁定的不是代码,而是对象。所以,锁的范围,或者说锁的粒度,完全取决于它作用的对象是谁。
我们可以从三个主要的使用场景来分析它的范围:
1. 修饰“实例方法”(非静态方法)
- 语法:public synchronized void instanceMethod() { ... }
- 锁的对象:是 this,也就是调用这个方法的当前类的实例对象。
- 如果一个线程进入了某个对象(比如 objectA)的 instanceMethod(),那么其他任何线程都不能同时进入 objectA 这个对象 的任何其他 synchronized 实例方法。
- objectA 的非同步方法。
- 或者,一个不同的实例对象(比如 objectB)的任何同步方法。
可以理解为:每个对象实例都自己持有一把锁。线程进入同步方法时,需要先拿到这个对象实例的锁。
2. 修饰“静态方法”
- 语法:public static synchronized void staticMethod() { ... }
- 锁的对象:不再是this了,因为静态方法不属于任何实例。它锁住的是这个类的Class对象,也就是 ClassName.class。
- 这个锁是类级别的,是一个全局锁,作用于这个类的所有实例。
- 无论我们创建了多少个这个类的对象,ClassName.class 对象在JVM中永远只有一个。
- 因此,只要一个线程进入了 staticMethod(),其他所有线程都不能进入这个类的任何其他 synchronized 静态方法。同时,如果其他地方有代码 synchronized(ClassName.class),也会被阻塞。
可以理解为:整个类共用一把锁,这把锁就是这个类的.class文件在内存中的对象。它的影响范围比实例锁要大得多。
3. 修饰“代码块”
- 语法:synchronized(someObject) { ... }
- 锁的对象:是我们自己在括号里明确指定的那个对象 someObject。
- 这是最灵活,也是我们最推荐的一种方式,因为它给了我们精细化控制锁粒度的能力。
- 我们可以只锁住真正需要同步的关键代码部分,而不是整个方法,这样可以大大提高程序的并发性能。
- this:那它的效果就和修饰实例方法一样。
- 一个专门用于锁的私有成员变量:比如 private final Object lock = new Object();,这是非常推荐的做法,因为它把锁的作用域限制得非常明确,不会与其他锁混淆。
- ClassName.class:那它的效果就和修饰静态方法一样。
- 修饰静态方法:锁的是.class,即类的Class对象,是全局锁。
- 修饰代码块:锁的是括号里指定的那个对象,粒度最灵活。
. 什么是数据倒灌?—— 这不是Bug,而是Feature
首先,我们要明确一点:所谓“数据倒灌”,并不是LiveData的一个Bug,而是它核心设计(Lifecycle-aware State Holder)所带来的一个特性。
- LiveData的设计初衷:是作为一个“状态持有者”。它被设计用来保存和分发UI的最新状态。当一个新的观察者(Observer)开始订阅它,或者一个老的观察者从后台(onStop)恢复到前台(onStart)时,LiveData会“贴心”地将它持有的最新数据,立刻发送给这个观察者,以确保UI能立即恢复到正确的状态。
- 数据倒灌的发生:问题就出在这个“贴心”的机制上。如果我们把LiveData用来分发“一次性事件”(Event),而不是“状态”(State),就会产生问题。
举一个最典型的例子:
- 用户在一个界面上点击“提交”按钮。
- ViewModel处理逻辑后,通过LiveData发送一个“提交成功”的事件,UI收到后弹出一个Toast。
- 此时,用户旋转屏幕,Activity被销毁并重建。
- 重建后的Activity会重新observe这个LiveData。
- 根据LiveData的特性,它会把最后一次的那个“提交成功”的事件,再次发送给新的观察者。
- 结果就是,用户明明没有再点提交,但屏幕一转,又看到了一个“提交成功”的Toast。这个不该出现的Toast,就是所谓的“数据倒灌”。
总结一下:数据倒灌的根本原因,是我们用“状态分发”的工具,去做了“事件分发”的事情。
2. 如何解决这个问题?—— 核心思想:让事件只能被消费一次
解决这个问题的方案有很多,但核心思想都是一样的:引入一种机制,确保这个一次性的事件,在被第一个观察者消费后,就不能再被后续的(或者重建后的)观察者消费。
ViewModel是Android架构组件(AAC)的核心成员,它在UI数据管理方面扮演着至关重要的“数据管家”角色。它的核心使命是:以一种感知生命周期的方式,存储和管理UI相关的数据,并彻底将UI控制器(Activity/Fragment)与数据处理逻辑解耦。
我是从以下三个方面来理解它如何进行数据管理的:
1. 核心能力:数据持久化,跨越配置变更
ViewModel最核心、最根本的能力,就是它的实例能够“活得”比Activity或Fragment更久。
- 解决的痛点:在ViewModel出现之前,我们最大的痛点就是屏幕旋转等配置变更会导致Activity销毁和重建,UI状态和数据很容易丢失。我们过去依赖onSaveInstanceState(只能存少量数据)或者保留Fragment(用法复杂)等方式,都非常不便。
- ViewModel的生命周期是与一个ViewModelStoreOwner(通常是Activity或Fragment)绑定的。
- 当Activity因为屏幕旋转而销毁重建时,其关联的ViewModel实例并不会被销毁。
- 当新的Activity实例创建后,它会通过ViewModelProvider获取ViewModel,框架会返回之前那个同一个ViewModel实例。
- 这样一来,ViewModel中存储的数据就自然而然地“存活”了下来,UI重建后可以立即从中获取数据,恢复到之前的状态,避免了数据的重新加载。
总结:ViewModel通过其“超长”的生命周期,成为了一个可靠的、跨越配置变更的内存缓存。这是它管理UI数据的基础。
2. 管理方式:使用“可观察的”数据容器
ViewModel并不直接持有String、List等普通数据类型暴露给UI,因为它无法通知UI何时数据发生了变化。ViewModel管理数据是通过可观察的数据容器(Observable Data Holders)来实现的。
这主要有两种方式:
- LiveData(经典方式)
- 是什么:LiveData是一个可观察的数据持有类,而且它能感知生命周期。
- 如何管理:ViewModel将UI需要的数据(比如用户信息、列表数据)包装在LiveData中。UI层(Activity/Fragment)则“观察”(observe)这个LiveData。
- ViewModel从Repository获取数据,并通过liveData.postValue()或liveData.setValue()更新数据。
- LiveData会检查它的观察者(UI)是否处于活跃状态(STARTED或RESUMED)。
- 如果UI是活跃的,LiveData就会自动将最新的数据推送给UI。如果UI处于后台,它就不会推送,避免了不必要的工作和潜在的崩溃。
- 当UI重建后,它会重新observe,LiveData会立即将它持有的最新数据发送给UI。
- StateFlow(现代Kotlin方式)
- 是什么:StateFlow是Kotlin协程中的一个概念,它也是一个可观察的状态持有者,功能上可以看作是LiveData的现代替代品。
- 如何管理:ViewModel中定义一个StateFlow来持有UI状态。UI层使用.collect()方法在协程中收集(collect)这个Flow的数据流。
- ViewModel在自己的viewModelScope这个协程作用域内,执行业务逻辑,并更新StateFlow的值。
- UI层通过lifecycleScope.launchWhenStarted等与生命周期绑定的协程启动器来安全地收集数据流。
- StateFlow与LiveData类似,也会在UI重建后,将最新的状态值立即提供给新的收集者。
总结:ViewModel通过持有LiveData或StateFlow,将自己从一个单纯的数据“存储者”,升级为了一个能与UI进行响应式、自动化通信的数据“管理者”。
3. 职责边界:清晰的单向数据流
ViewModel的引入,强制我们遵循一个更清晰的架构模式,通常是单向数据流。
- UI -> ViewModel:用户的操作(如点击、输入)从UI层触发,调用ViewModel中相应的方法。这通常是“事件”的传递。
- ViewModel -> UI:ViewModel处理业务逻辑(可能会调用Repository与数据源交互),然后更新它持有的LiveData或StateFlow。这是“状态”的更新。
- LiveData或StateFlow自动将更新后的状态通知给UI,UI根据新状态刷新自己。
- ViewModel全权负责准备和管理UI所需的所有数据,UI层变得非常“傻瓜”,只负责展示数据和传递用户事件。
- ViewModel不持有任何View或Context的引用,这保证了它不会造成内存泄漏,并且更容易进行单元测试。
第一,我们为什么需要自定义View?
通常有两个主要原因:
- 实现独特的UI效果:当Android系统自带的控件,比如
TextView、Button这些,无法满足我们产品设计的复杂UI或者特殊的交互动画时,我们就需要自己去“画”一个。比如一个带有动画的圆形进度条、一个股票的K线图等等。
- 性能优化:当一个界面布局非常复杂,嵌套层级很深时,我们可以把多个系统控件组合成一个自定义View,减少View树的层级,从而提高界面的测量(Measure)和绘制(Draw)效率。
第二,实现一个自定义View的核心步骤是什么?
实现一个自定义View,最核心的就是继承View或者ViewGroup,并根据需求重写它的几个关键生命周期方法,我一般遵循“测量、布局、绘制”三部曲:
-
onMeasure() - 测量:
- 作用:这是第一步,用来告诉父布局“我想要多大”。
- 怎么做:在这个方法里,我们会拿到一个
MeasureSpec参数,它包含了父布局对我们尺寸的限制(比如AT_MOST、EXACTLY)。我们需要根据这个限制和我们View自身的内容(比如文字的大小、图片的大小),计算出最终的宽度和高度,然后必须调用setMeasuredDimension()方法把结果设置进去。如果继承View但不重写onMeasure,wrap_content属性可能会失效。
-
onLayout() - 布局:
- 作用:这一步主要是针对自定义
ViewGroup的,用来决定它的子View应该放在哪里。
- 怎么做:我们会遍历所有的子View,然后调用每个子View的
layout(left, top, right, bottom)方法,为它们确定最终的位置和大小。如果只是继承View,通常不需要重写这个方法。
-
onDraw() - 绘制:
- 作用:这是最后一步,也是最有趣的一步,用来画出View具体的样子。
- 怎么做:系统会给我们一个
Canvas(画布)对象,我们可以用Paint(画笔)在画布上画任何我们想要的东西,比如线、圆、文字、图片等等。为了性能,要避免在onDraw方法里创建新的Paint等对象,因为这个方法会被频繁调用。
第三,在开发中还有哪些关键点需要注意?
除了上面三个核心步骤,还有几个让自定义View变得更健壮和易用的关键点:
- 构造函数:自定义View通常有4个构造函数。最常用的是前两个:一个参数的用于在代码里
new;两个参数的用于在XML布局中使用,可以拿到自定义属性。
- 自定义属性:为了让我们的View能在XML里配置,比如设置颜色、大小等,我们需要在
res/values/attrs.xml中用<declare-styleable>来声明自定义属性。然后在构造函数里,通过context.obtainStyledAttributes()来获取这些值。
- 处理交互:如果View需要响应用户的触摸事件,比如点击、滑动,就需要重写
onTouchEvent()方法,在里面处理ACTION_DOWN、ACTION_MOVE等事件,并根据需要返回true来消费事件。
- 状态保存与恢复:当屏幕旋转或者内存不足导致View被销毁重建时,为了保存View的状态(比如进度条的进度),我们需要重写
onSaveInstanceState()和onRestoreInstanceState()。
要让父布局拦截事件,不让它传到子View,最核心、最直接的方法就是重写父布局的 onInterceptTouchEvent 方法,并让它返回 true。
我可以从以下三个方面来详细解释这是如何工作的:
1. 事件分发的“安检站” - onInterceptTouchEvent
首先,我们需要理解在事件分发流程中几个关键方法的角色:
- dispatchTouchEvent: 是事件分发的入口,像一个“总调度室”,负责决定事件的去向。
- onInterceptTouchEvent: 是ViewGroup独有的方法,像一个“门口的哨兵”或“安检站”。它在dispatchTouchEvent内部被调用,有机会在事件向下传递给子View之前,对事件进行拦截。
我们的目标,就是在这个“安检站”(onInterceptTouchEvent)把事件拦下来。
2. 如何实现拦截?—— “哨兵”的一票否决权
要实现拦截,我们需要自定义一个ViewGroup,然后重写它的onInterceptTouchEvent方法。这个方法的返回值至关重要:
- 事件会继续向下传递,交给子View的dispatchTouchEvent去处理。如果子View还能继续向下分发,流程就继续。
- 这相当于“哨兵”说:“站住!这个事件我扣下了!”。
- 这个被拦截的事件,会直接交给当前这个父布局自己的onTouchEvent方法来处理。
- 更关键的是:一旦onInterceptTouchEvent对某个事件序列(从ACTION_DOWN开始)返回了true,那么这个序列后续的所有事件(比如ACTION_MOVE, ACTION_UP)将不会再经过onInterceptTouchEvent的检查,而是会“抄近路”直接发给这个父布局的onTouchEvent。父布局一旦决定拦截,就要负责到底。
所以,最简单粗暴的实现就是:
public class InterceptLayout extends FrameLayout {
// ... 构造函数 ...
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
// 直接返回true,拦截所有事件
return true;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 拦截下来的事件会在这里处理
Log.d("InterceptLayout", "Event handled by parent layout!");
return true; // 返回true表示消费掉事件
}
}
在这个例子中,任何触摸到InterceptLayout区域的事件,都会被它自己拦截并处理,其内部的任何子View(比如按钮)将永远无法收到触摸事件,就好像它们被一层透明的玻璃罩住了一样。
3. 精准拦截:在合适的时机出手
在实际开发中,我们通常不希望“一刀切”地拦截所有事件,而是根据特定条件来决定是否拦截。比如ScrollView就是典型的例子:它允许子View响应点击(DOWN -> UP),但如果检测到是滑动(MOVE),它就会出手拦截。
我们可以通过判断MotionEvent的类型(getAction())来实现精准拦截:
public class SmartInterceptLayout extends FrameLayout
// 其他事件(MOVE, UP)不拦截(但实际上因为DOWN被拦了,它们也到不了这里)
return false;
}
// ... onTouchEvent ...
}
1. 资源编译打包(第一条线)
这条线处理的是除了代码以外的所有资源文件。
- 工具:主要使用aapt2(Android Asset Packaging Tool 2)。
- 输入:项目中的所有资源文件,比如AndroidManifest.xml、布局文件(XML)、图片(PNG/JPG)、字符串资源等。
- 编译(Compile):aapt2会首先将所有的XML资源文件(如图标、布局等)编译成二进制格式,这样做可以提高解析效率。
- 链接(Link):然后,它会把所有编译后的资源,连同AndroidManifest.xml,链接在一起,并生成一个至关重要的文件——R.java。
- R.java 文件:这个文件相当于一个“资源字典”,它为项目中的每一个资源都分配了一个独一无二的、静态的int类型的ID。我们在代码中通过R.id.button1这样的方式引用资源,其实就是在使用这个字典。
- 编译后的资源包:一个包含所有二进制资源的中间产物。
2. 源码编译(第二条线)
这条线与资源线并行,处理的是我们编写的所有Java或Kotlin代码。
- 工具:javac(Java编译器)和kotlinc(Kotlin编译器)。
- 输入:我们自己写的.java或.kt源代码,以及上面生成的R.java文件和所有库代码。
- 过程:编译器会将所有源代码(包括R.java)编译成标准的Java字节码,也就是.class文件。
3. 合并与DEX化(两线合并)
现在,代码和资源都准备好了,需要将它们合并并转换成Android系统认识的格式。
- 工具:d8(是新一代的DEX编译器,取代了旧的dx)。
- 输入:上一步生成的所有.class文件(包括我们自己的和所有依赖库的)。
- 转换为DEX:d8工具会将所有的Java字节码(.class)转换成Dalvik字节码(.dex)。Dalvik字节码是专门为Android运行时(ART)优化过的,执行效率更高。如果项目很大,方法数超过了65536个,就会生成多个.dex文件(这就是Multidex的由来)。
- 打包成APK:系统会将上一步生成的.dex文件、第一步编译好的资源包、so库文件以及其他assets资源,全部打包到一个压缩文件中。这个压缩文件,就是一个未签名的APK。本质上,APK就是一个遵循特定目录结构的ZIP压缩包。
4. 签名与对齐(最后盖章和整理)
这个未签名的APK还不能被安装,因为它没有“身份证明”。
- 签名(Signing)
- 保证来源的真实性:证明这个APK确实是由你(持有这个私钥的开发者)发布的。
- 保证内容的完整性:防止APK在传输过程中被篡改。Android系统在安装和更新时,会严格校验签名,如果签名不一致(比如想用一个不同签名的包覆盖安装),就会失败。
- 过程:使用你的私钥(Keystore文件)对APK进行签名。签名信息会被保存在APK包内的META-INF目录下。
- 对齐(Aligning)
- 目的:这是一个性能优化步骤。它能确保APK包中所有未压缩的数据(比如图片、so库)都相对于文件开头进行4字节对齐。
- 好处:这样做之后,Android系统就可以通过mmap(内存映射)的方式直接读取APK中的这些资源,而不需要先解压到内存中。这大大减少了应用的内存占用和启动时间。
关于SharedPreferences和数据库的区别,这是一个非常基础但重要的问题,因为它能反映出开发者对数据存储方案选型的理解。
我的看法是,它们并不是竞争关系,而是工具箱里用途完全不同的两件工具,就像锤子和扳手,我们应该根据要解决的具体问题来选择。
我将从以下四个维度来阐述它们的区别:
1. 核心定位与数据模型:便利贴 vs 档案柜
这是两者最根本的区别。
- SharedPreferences (SP):它的定位是一个轻量级的键值对(Key-Value)存储。您可以把它想象成一张“便利贴”。我们通常用它来记录一些零散的、简单的数据,比如:
它的数据模型非常简单,就是一个Key对应一个Value,没有复杂的结构。
- 数据库 (Database, 以SQLite为例):它的定位是一个结构化的数据仓库。您可以把它想象成一个分门别类的“档案柜”。它使用表格(Table)来组织数据,有行(Row)、有列(Column),可以存储大量复杂、有关系的数据。
- 比如,一个“用户”表可以有id, name, age等列。一个“订单”表可以记录order_id, user_id, price等信息,并且可以通过user_id与用户表建立关联。
2. 使用场景:记录“设置” vs 存储“内容”
基于它们的数据模型,它们的使用场景也截然不同。
- 保存应用配置和用户偏好设置:这是它最主要的用途。比如夜间模式开关、是否接收消息推送、字体大小等。
- 记录简单的状态标志:比如用户是否已经登录、是否是第一次打开App等。
- 缓存少量、非核心的数据:比如缓存一个登录token。
总的来说,SP适合存储那些数据量小、结构简单、读写不频繁的数据。
- 存储大量、结构化的业务数据:比如一个聊天应用的聊天记录、一个新闻App的文章列表、一个电商App的商品信息和用户订单。
- 需要进行复杂查询和数据管理:比如需要根据条件筛选、排序、聚合(求和、平均值)数据。
- 需要保证数据操作的事务性:比如转账操作,需要保证“A账户减钱”和“B账户加钱”这两个步骤要么同时成功,要么同时失败。数据库的事务(Transaction)机制能完美保证这一点。
3. 性能和效率:全盘加载 vs 精准查询
它们的底层实现机制决定了其性能特点。
- 当应用第一次读取SP时,系统会将整个XML文件一次性加载到内存中,并以Map的形式缓存起来。后续的读取操作都是直接访问内存,所以非常快。
- 缺点在于:如果用它存储大量数据,首次加载会非常耗时且占用大量内存。同时,每次写入(无论是commit还是apply)最终都会涉及到I/O操作,频繁写入大量数据性能会很差。
- 它是一个更专业的文件系统,有索引、缓存等各种优化机制。
- 它不会一次性把所有数据都加载到内存。当我们查询时,它只会根据我们的查询语句(SQL),高效地从磁盘文件中检索出我们需要的那部分数据。
- 优点在于:即使数据量达到百万级别,只要索引设计得当,查询依然可以很快。它对大量数据的增、删、改、查操作都做了深度优化。
4. 开发复杂度:开箱即用 vs 需要框架
- SharedPreferences:API非常简单,可以说是“开箱即用”,几行代码就能完成读写,学习成本极低。
- 原生的SQLiteOpenHelper API相对繁琐,需要手写SQL语句、处理Cursor对象,容易出错,开发体验不佳。
- 但幸运的是,在现代Android开发中,我们几乎不再直接使用原生API,而是使用Google官方推荐的Room框架。Room是一个ORM(对象关系映射)库,它帮我们把繁琐的数据库操作都封装好了,我们可以像调用普通方法一样操作数据库,同时它还能在编译期检查我们的SQL语句是否正确,极大地提高了开发效率和代码健壮性。
总结
面试官您好,总的来说,我的结论是:
| 特性 |
SharedPreferences |
数据库 (SQLite/Room) |
| 模型 |
键值对(便利贴) |
关系型表格(档案柜) |
| 场景 |
简单配置、状态标志 |
大量、结构化、需复杂查询的内容 |
| 性能 |
首次全盘加载入内存 |
按需精准查询,支持索引 |
| 复杂度 |
非常简单 |
原生复杂,但有Room框架加持 |
1. 多线程:线程安全,但需注意使用方式
我的结论是:SharedPreferences在其API层面是线程安全的。
- 如果我们去看SharedPreferences的具体实现类SharedPreferencesImpl的源码,就会发现它的所有读写方法,包括获取Editor对象、getString()、getInt()以及commit()和apply(),其方法内部的关键操作都被synchronized关键字保护起来了。
- 这意味着,在同一个进程内,即使有多个线程同时去读写同一个SharedPreferences实例,synchronized锁机制也能保证同一时间只有一个线程能进行操作,从而避免了多线程并发导致的数据错乱。它的内存缓存和文件I/O操作都是在同步块内完成的。
- 需要注意什么?—— commit() vs apply()
- 虽然是线程安全的,但我们必须注意性能,特别是不能阻塞UI主线程。这就引出了commit()和apply()这两个提交方法的关键区别:
- commit():同步提交。它会阻塞当前线程,等待数据真正在磁盘上写入成功后才返回。如果在主线程调用,一旦I/O操作耗时,就会导致界面卡顿甚至ANR。它有返回值,可以告诉我们写入是否成功。
- apply():异步提交。这是官方推荐的方式。它会立刻修改内存中的缓存数据,然后将真正的磁盘写入操作,放到一个后台的单线程任务队列中去执行。它会立刻返回,不会阻塞当前线程。但它没有返回值,我们无法直接知道写入是否成功。
所以,在多线程环境下,我们应该优先使用apply(),因为它既能保证原子性的内存更新,又不会阻塞调用它的线程。
2. 多进程:绝对不安全,且已被废弃
我的结论是:SharedPreferences完全不适合多进程场景,它不是进程安全的,强行使用会导致数据丢失或状态不一致。
- SharedPreferences的性能优化依赖于进程内的内存缓存。当一个进程首次读取SP时,它会将整个XML文件加载到自己的内存空间里。
- 进程A启动,读取SP文件,在内存中有一份缓存。
- 进程B也启动,它也读取同一个SP文件,在自己的内存中也有一份缓存。
- 现在,进程A修改了一个值,并写入了磁盘。此时,只有进程A的内存缓存和磁盘文件是最新的。进程B内存中的缓存,已经“过时”了,它对此毫不知情。
- 如果此时进程B也修改了一个值,它会基于自己那个“过时”的缓存进行修改,然后将这份包含了过时数据的内容,覆盖写入到磁盘文件中。
- 结果就是,进程A之前写入的数据,被进程B给覆盖丢失了。
- 您可能会提到,SharedPreferences曾经提供过一个叫MODE_MULTI_PROCESS的模式。
- 这个模式的本意是,每次访问SP时,都放弃内存缓存,直接从磁盘文件中重新读取,试图以此来保证数据同步。
- 但这个模式存在严重的性能问题和可靠性问题,它无法解决两个进程同时写入文件的竞态条件,而且在Android高版本中已经被明确废弃(Deprecated),官方文档也强烈建议不要使用它。
总结与替代方案
面试官您好,我的最终总结是:
| 场景 |
是否安全 |
原因与建议 |
| 多线程 |
安全 |
API内部使用synchronized保证。推荐使用apply()进行异步写入,避免阻塞线程。 |
| 多进程 |
不安全 |
基于进程内缓存,跨进程会因缓存不同步导致数据覆盖丢失。MODE_MULTI_PROCESS已被废弃。 |
那么,如果真的有多进程数据共享的需求,我们应该用什么?
- ContentProvider:这是Android系统提供的标准、强大的跨进程数据共享组件。它提供了统一的增删改查接口,并由系统来管理底层的进程同步和数据一致性,是重量级数据共享的首选。
- Jetpack DataStore:作为SharedPreferences的现代替代品,DataStore在设计上就考虑了更多场景。虽然它本身不直接解决跨进程问题,但可以结合ContentProvider来构建一个可靠的多进程数据存储方案。
- 使用AIDL或Messenger进行IPC通信:让一个进程作为“数据中心”,其他进程通过IPC通信的方式,请求它来读写数据,由这个中心进程来统一管理,从而保证数据一致性。
Retrofit和OkHttp不是竞争对手,它们是一对黄金搭档,一个典型的“上层封装”与“底层实现”的关系。
您可以把它们想象成一辆高性能的汽车:
- Retrofit 则是这辆车优雅、智能的驾驶舱和自动驾驶系统。
我从以下三个方面来具体阐述这个关系:
1. OkHttp:纯粹而强大的“网络实干家”
OkHttp的定位是一个纯粹、高效的HTTP客户端。它负责所有网络请求中“脏活累活”,是网络通信的基石。它的核心职责包括:
- 建立和管理连接:负责TCP连接、TLS握手、连接池复用等底层操作,极大地提高了网络请求效率。
- 请求和响应处理:它能发出HTTP请求,并接收服务器返回的响应数据流。
- 强大的中间件——拦截器(Interceptors):这是OkHttp的精髓。我们可以通过拦截器链,轻松实现请求日志打印、公共请求头添加、请求重试、缓存策略等功能。
- 内置缓存和重定向:它自带了一套遵循HTTP规范的缓存机制和自动处理重定向的能力。
但是,OkHttp本身用起来是比较“原始”的。我们需要手动构建Request对象,手动拼接URL和参数,拿到Response后还需要自己去解析JSON字符串,转换成Java或Kotlin对象。就像你虽然有了一台强劲的发动机,但还需要自己去拉各种阀门和管线才能让它工作。
2. Retrofit:优雅的“API翻译官”
Retrofit的定位是一个类型安全(Type-safe)的RESTful网络请求框架。它完全建立在OkHttp之上,不处理真正的网络请求,而是专注于简化网络请求的“应用层”开发。
它的核心优势在于:
- 通过注解定义API:我们只需要定义一个Java接口,然后使用@GET, @POST, @Path, @Query等注解来描述一个网络请求的全部信息(如请求方法、URL路径、参数等)。这非常直观和优雅。
- 自动序列化和反序列化:这是Retrofit的另一个杀手锏。它可以与Gson, Moshi, Jackson等转换器无缝集成。我们只需要在接口中定义方法的返回值是Call<User>,Retrofit就会在收到网络响应后,自动将JSON数据转换成User对象。我们从此告别了繁琐的手动JSON解析。
- 解耦与面向接口编程:它将网络API的定义,与具体的实现完全分离开来。我们只需要关心接口,而Retrofin通过动态代理技术,在运行时为我们自动生成这个接口的实现类。
Retrofit让我们从繁琐的HTTP请求构建和数据解析中解放出来,让我们像调用一个本地方法一样,去发起一个网络请求。它就是那个智能的驾驶舱,你只需要在GPS上设定目的地(定义接口),踩下油门(调用方法),车子就能自动行驶(完成网络请求并返回处理好的数据)。
3. 它们如何协同工作?
它们的关系在代码层面体现得非常清晰:我们在构建Retrofit实例时,必须提供一个OkHttpClient实例给它。
val okHttpClient = OkHttpClient.Builder()
.addInterceptor(LoggingInterceptor()) // 配置OkHttp
.build()
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient) // 把OkHttp引擎装配给Retrofit
.addConverterFactory(GsonConverterFactory.create()) // 配置Retrofit的翻译官
.build()
整个工作流程是这样的:
- 我们调用由Retrofit创建的API接口方法。
- Retrofit根据方法的注解和参数,将我们的请求“翻译”成一个标准的OkHttp的Request对象。
- Retrofit将这个Request对象,交给它内部持有的OkHttpClient实例。
- OkHttp开始接管,利用它的连接池、拦截器、缓存等机制,去执行真正的网络通信。
- OkHttp拿到服务器返回的Response后,再交还给Retrofit。
- Retrofit利用我们配置的Converter(如Gson),将Response的Body(JSON字符串)解析成我们期望的Java/Kotlin对象。
- 最终,Retrofit将这个完美的对象返回给我们。
这个问题其实包含两个层面:一个是我们如何主动将OkHttp实例提供给Retrofit,另一个是在某些情况下,如何从一个已有的Retrofit实例中反向获取它所使用的OkHttp实例。
最核心、最常见的方式,也是最佳实践,就是在构建Retrofit时,主动将一个我们配置好的OkHttpClient实例,通过.client()方法设置进去。
我将从以下三个方面来详细阐述:
1. 主动注入:通过.client()方法进行设置
这是Retrofit设计的核心思想之一,即依赖注入。Retrofit不自己创建和管理复杂的网络客户端,而是依赖一个外部的、功能强大的OkHttpClient来完成实际的工作。
- 我们在构建Retrofit对象时,会使用Retrofit.Builder()。在这个构建链中,有一个关键的方法叫做.client(OkHttpClient client)。
- 首先,创建一个OkHttpClient.Builder,并根据我们的需求进行各种配置。
- 然后,调用.build()方法,生成一个配置好的OkHttpClient实例。
- 最后,在构建Retrofit时,将这个OkHttpClient实例传给.client()方法。
举个例子:
// 1. 创建并配置OkHttpClient
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor()) // 添加自定义拦截器
.build();
// 2. 创建Retrofit时,将OkHttpClient注入进去
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient) // <-- 核心在这里
.addConverterFactory(GsonConverterFactory.create())
.build();
- 如果我们不主动调用.client()方法,Retrofit为了能正常工作,会在其内部默认new一个没有任何自定义配置的OkHttpClient实例来使用。
- 但在实际项目中,这几乎是不可接受的,因为我们总是需要添加日志、配置超时、处理Token等通用逻辑。
2. 为什么说主动注入是“最佳实践”?
主动将OkHttpClient注入给Retrofit,主要有三个核心好处:
- 统一配置与实例共享:这是最重要的原因。在一个App中,我们应该只持有一个全局的OkHttpClient实例。这样做可以充分利用OkHttp的连接池(Connection Pool)和缓存机制,复用TCP连接,极大地提升网络请求的性能并节省资源。我们可以用这个共享的实例去构建多个不同baseUrl的Retrofit实例,或者给图片加载框架(如Coil, Glide)使用。
- 强大的功能扩展:OkHttp的精髓在于其拦截器(Interceptor)。通过主动配置OkHttpClient,我们可以添加各种拦截器,来实现日志打印、公共请求头添加(如Authorization Token)、请求重试、Mock数据等一系列强大的定制功能。
- 精细化的行为控制:我们可以精细地控制网络行为,比如设置连接、读取、写入的超时时间,配置DNS,设置代理,管理Cookie等。
3. 反向获取:从Retrofit实例中拿回OkHttpClient
在某些特殊场景下,我们可能只有一个Retrofit的实例,但又需要用到它底层的OkHttpClient。这种情况虽然不常见,但Retrofit也提供了方法。
- Retrofit类有一个方法叫做 public okhttp3.Call.Factory callFactory()。
- okhttp3.Call.Factory是一个接口,而OkHttpClient恰好是这个接口的一个实现类。
- 所以,我们可以调用这个callFactory()方法,然后将返回的结果强制类型转换为OkHttpClient。
举个例子:
// 假设我们已经有了一个retrofit实例
val callFactory = retrofit.callFactory()
var okHttpClient: OkHttpClient? = null
if (callFactory is OkHttpClient) {
okHttpClient = callFactory // 强制类型转换
}
- 比如在一个架构中,某个模块只被依赖注入了Retrofit实例,但它现在需要发起一个WebSocket连接或者给图片加载库配置同一个网络客户端,这时就可以通过这种反向获取的方式,拿到共享的OkHttpClient实例。
OkHttp的线程管理是一个非常精巧的设计,它并不是简单地为每个请求都创建一个新线程。它的核心是一个叫做 Dispatcher(调度器) 的类,我们可以把它想象成一个网络请求的“总调度中心”或“空中交通管制员”。
Dispatcher 负责接收所有请求,并根据系统资源和配置,有条不紊地将它们分配到后台线程池中去执行。
我将从以下三个方面来阐述OkHttp是如何管理线程的:
1. 核心组件:Dispatcher 和它的线程池
Dispatcher 内部维护着几个关键的部分:
- 一个 ExecutorService (线程池):
- 默认情况下,这个线程池是一个CachedThreadPool。它的特点是:
- 核心线程数为0,最大线程数接近无限 (Integer.MAX_VALUE)。
- 当有新任务来时,如果池中有空闲线程,就复用;如果没有,就创建一个新的线程来处理任务。
- 这意味着OkHttp可以根据并发请求的数量,动态地调整其后台线程的数量,既能应对高并发,又能在空闲时释放资源。
- 两个双端队列(Deque):
- readyAsyncCalls:“准备队列”或“候车室”。所有通过.enqueue()方法发起的异步请求,都会先被放到这个队列里排队。
- runningAsyncCalls:“运行队列”或“站台”。当Dispatcher决定执行一个请求时,会把它从readyAsyncCalls队列移到这个队列中,并把它提交给上面的线程池。
2. 工作流程:异步请求 enqueue() 的调度之旅
当我们调用 Call.enqueue() 发起一个异步请求时,一场精密的调度就开始了:
- 入队:这个请求(被包装成一个AsyncCall对象)首先会被添加到Dispatcher的readyAsyncCalls(准备队列)中。
- 条件检查与“晋升”:Dispatcher会立刻进行检查,看是否满足执行条件。这个条件主要有两个:
- 当前正在运行的异步请求总数,是否小于设定的最大并发数(maxRequests,默认是64个)。
- 当前访问同一个主机(比如api.example.com)的请求数,是否小于设定的单个主机最大并发数(maxRequestsPerHost,默认是5个)。
- 执行:
- 如果条件满足,Dispatcher就会从readyAsyncCalls队列的尾部取出一个请求,将它“晋升”到runningAsyncCalls(运行队列)中。
- 然后,Dispatcher将这个请求任务,提交给内部的ExecutorService(线程池)。
- 线程处理:线程池会选择一个空闲的后台线程(或者创建一个新的),开始执行这个请求的网络I/O操作。
- 完成与循环:
- 当一个请求执行完毕(无论成功、失败或取消),它会从runningAsyncCalls队列中移除。
- 移除后,Dispatcher会再次进行第2步的条件检查,看看现在是否有能力从“准备队列”中,再拉一个新的请求出来执行。
- 这个“完成一个,检查一个,晋升一个”的循环,保证了请求总是在限制范围内,高效、有序地被处理。
3. 同步请求 execute():一个重要的对比
与异步请求不同,当我们调用Call.execute()发起一个同步请求时:
- 线程管理由调用者负责:这个请求的所有网络操作,都会阻塞地在当前调用execute()的这个线程上执行。
- Dispatcher的角色:Dispatcher不会把它放进准备队列,也不会为它分配后台线程。它仅仅是把这个同步请求记录到一个名为runningSyncCalls的队列中,用于追踪和取消,但不负责它的线程调度。
- 适用场景:正因为如此,我们绝对不能在Android的主线程(UI线程)中调用execute(),否则必然会导致ANR(应用无响应)。它只适合在我们自己创建和管理的后台线程中使用。
总结:
- OkHttp的线程管理核心是Dispatcher类,它像一个调度中心。
- 对于异步请求(enqueue),Dispatcher使用一个可缓存的线程池和两个队列(准备队列和运行队列),并根据最大并发数(默认64)和单主机最大并发数(默认5)的限制,来高效地调度和复用线程。
- 对于同步请求(execute),OkHttp不管理其线程,它会阻塞在调用者当前的线程上。
1. 应用拦截器 (Application Interceptors) - “第一次安检”
这是我们最常用的一种拦截器,它更贴近我们开发者的“业务逻辑”。
- 如何添加:通过OkHttpClient.Builder().addInterceptor(myInterceptor)来添加。
- 执行时机:它在OkHttp的责任链中最先执行。它处理的是我们最原始、最“纯粹”的那个Request,甚至在OkHttp的缓存、重定向等内部逻辑介入之前。
- 只执行一次:对于一个用户发起的请求调用(比如call.enqueue()),无论中间发生了多少次网络重定向或请求重试,应用拦截器始终只会被调用一次。它关心的是你的“最终目的”,而不是去目的地的“曲折过程”。
- 不关心网络细节:它无法访问到真正的网络连接Connection对象,所以它不知道这次请求最终会通过哪个IP地址发出。
- 能感知超时和取消:它可以影响到整个call的超时时间。
- 你带着“要去北京”的原始意图(原始Request)进入机场。
- 安检员(应用拦截器)检查你的机票和身份证(比如给你加上统一的Authorization请求头)。
- 这个安检只做一次。至于你后来因为天气原因,航班被改签到另一架飞机(重定向),安检员是不关心也看不到的。
- 添加公共请求头:比如User-Agent, Authorization Token等。
- 打印最原始的请求日志:记录下我们最初发起的请求长什么样。
- 请求加密/响应解密:对整个请求体进行统一的加密处理。
2. 网络拦截器 (Network Interceptors) - “登机口前的最后安检”
这种拦截器更贴近真实的物理网络通信。
- 如何添加:通过OkHttpClient.Builder().addNetworkInterceptor(myInterceptor)来添加。
- 执行时机:它在责任链中靠近末端的位置执行,在所有OkHttp的内部处理(如重定向、缓存判断)之后,即将要真正发起网络I/O操作之前。
- 可能会执行多次:如果一个请求发生了重定向,那么网络拦截器会为原始请求执行一次,为重定向后的新请求再执行一次。它能观察到所有真实的、即将发生或刚刚完成的网络交互。
- 能访问网络连接信息:它可以访问Connection对象,从而可以获取到服务器的IP地址、TLS握手信息等。
- 反映真实的网络流量:它可以精确地监控到通过网络传输的数据,比如Gzip压缩后的数据大小。
- 在经历了改签(重定向)后,你拿着新的登机牌,马上就要登上飞机了。
- 登机口的地面服务人员(网络拦截器)会做最后一次检查。
- 如果你被改签了两次,那么你就会经过两次这样的“登机口安检”。
- 这个安检员能看到你具体要上哪一架飞机(Connection信息)。
- 处理网络压缩:比如手动添加Accept-Encoding: gzip头,并处理压缩后的响应体。
总结与对比
为了更清晰,我用一个表格来总结它们的核心区别:
| 特性 |
应用拦截器 (addInterceptor) |
网络拦截器 (addNetworkInterceptor) |
| 执行时机 |
责任链最前端,靠近用户代码 |
责任链末端,靠近物理网络 |
| 调用次数 |
仅1次 |
可能多次(发生重定向/重试时) |
| 访问Connection |
不能 |
可以 |
| 关注点 |
用户的逻辑请求 |
真实的网络I/O |
| 典型用途 |
添加公共头、日志、加解密 |
流量监控、网络压缩、链路诊断 |
MVVM无疑是当前Android官方推荐的主流架构,它带来的好处,比如职责分离、可测试性、以及对生命周期的感知,是毋庸置疑的。但是,没有任何架构是“银弹”,MVVM在带来这些优势的同时,也确实存在一些固有的缺点和潜在的陷阱。
我认为它的缺点主要体现在以下三个方面:
1. 增加了代码的复杂度和模板化
这是最直观的一个缺点。
- 类的数量膨胀:一个简单的界面,如果严格遵循MVVM,可能就需要创建一个Activity/Fragment (View)、一个ViewModel,一个Repository,甚至还有DataSource。对于一个功能非常简单的页面,比如一个“关于我们”的静态展示页,引入一整套MVVM结构,会显得非常臃肿,有点“用牛刀杀鸡”的感觉。
- 数据流的链路变长:数据的流向从 View -> ViewModel -> Repository -> DataSource,然后原路返回。这使得追踪一个简单的数据请求,需要跨越好几个类和文件,对于初学者或者维护不熟悉的项目,可能会增加理解成本。
2. View与ViewModel的通信难题:状态 vs 事件
这是MVVM在实践中遇到的最核心、最经典的一个难题。
- 设计的初衷:ViewModel通过LiveData或StateFlow向View暴露UI状态(State)。View订阅这些状态,并根据状态的变化来更新UI。这个模型对于“状态”的展示是非常完美的。
- 问题的出现:但是,UI的交互不仅仅是展示状态,还包括大量的一次性事件(Event)。比如:
这些操作的特点是,它们只应该被执行一次。如果我们用LiveData来承载这些事件,就会遇到“数据倒灌”的问题。最典型的例子就是,ViewModel发送一个“显示Toast”的事件,View收到后显示了。此时用户旋转屏幕,View重建并重新订阅LiveData,LiveData会把上一次的那个“陈旧”的事件又发送一遍,导致Toast莫名其妙地又弹了一次。
- 解决方案的“百花齐放”:为了解决这个问题,社区和官方提出了各种方案,比如:
- Event包装类:将事件数据包在一个带有“是否已消费”标记的类里。
- SingleLiveEvent:一个自定义的LiveData,保证事件只被一个观察者消费一次。
- Kotlin的Channel:利用通道的特性来发送一次性事件。
- SharedFlow:目前官方比较推荐的方式,创建一个replay=0的SharedFlow作为事件总线。
这本身就成了一个缺点:没有一个“银弹”式的、官方统一的最佳实践。这导致团队内部需要约定规范,不同的开发者可能会采用不同的方案,增加了项目的复杂度 和维护成本。
3. Data Binding的双刃剑效应
Data Binding经常和MVVM结合使用,它在减少View层样板代码的同时,也可能带来新的问题。
- XML的过度复杂化:Data Binding允许在XML布局文件中编写一些简单的逻辑表达式。这虽然很方便,但也给了开发者一个“在XML里写代码”的口子。一旦滥用,比如在XML中进行复杂的格式转换、三元运算甚至方法调用,就会让XML变得难以阅读和维护。
- 调试困难:XML中的逻辑是无法打断点调试的。当绑定表达式出错时,编译期的错误提示有时也比较晦涩,定位问题会比在Kotlin/Java代码中困难得多。
- 编译速度:开启Data Binding会增加项目的编译时间,因为它需要在编译期生成大量的绑定类。
1. 什么是序列化与反序列化?—— 内存对象与字节流的“翻译官”
- 序列化 (Serialization):就是 “打包” 的过程。
- 我们在程序中创建的对象,比如一个User对象,它在内存中是以一种复杂、结构化的形式存在的。
- 当我们需要把这个内存中的对象,通过网络发送出去,或者保存到磁盘文件中时,我们就必须把它“拍扁”,转换成一种连续的、扁平化的字节流或文本格式。这个转换过程,就是序列化。
- 反序列化 (Deserialization):就是 “拆包” 的过程。
- 当我们从文件中读取了这段字节流,或者从网络接收到了一段数据后,我们需要把它重新“组装”成内存中那个有结构的、我们可以直接使用的User对象。这个逆向的转换过程,就是反序列化。
总而言之,序列化机制就是一个“翻译官”,负责在“内存对象”和“可存储/可传输的字节流”这两种不同形态之间进行双向翻译。
2. 在Android中,我们为什么需要它?
序列化在Android中的应用场景非常广泛,主要有以下几类:
- 组件间传递数据:这是最常见的场景。当我们使用Intent在Activity之间传递数据,或者使用Bundle在Fragment间传递参数时,如果要传递一个自定义对象,这个对象就必须是可序列化的。
- 进程间通信 (IPC):当应用需要跨进程通信时,比如使用AIDL,内存中的对象无法直接跨越进程边界。它必须先被序列化成一个“包裹”(Parcel),由底层的Binder驱动程序传递到另一个进程后,再反序列化成对象。
- 数据持久化:当我们需要将一个对象的状态保存到本地磁盘文件中,以便应用下次启动时恢复,就需要先将对象序列化。
- 网络通信:当我们通过Retrofit等框架与服务器交互时,通常会将一个对象序列化成JSON格式的字符串发送给服务器,并把服务器返回的JSON字符串,反序列化成我们需要的对象。
3. Android中有哪些主要的序列化方式?
在Android中,我们主要有两大原生机制,以及一种更现代、通用的方式:
- Serializable
- 来源:这是Java原生提供的序列化接口 (java.io.Serializable)。
- 实现:使用起来极其简单,只需要让我们的类 implements Serializable 即可,甚至不需要实现任何方法。它是一个标记接口。
- 原理:它依赖反射机制来实现。在序列化时,JVM会通过反射去分析类的结构和字段,并将其转换成字节流。这个过程会产生大量的临时对象,并且I/O操作比较频繁。
- Parcelable
- 来源:这是Android平台特有的序列化接口 (android.os.Parcelable)。
- 实现:使用起来相对复杂,需要我们手动实现序列化和反序列化的逻辑。具体来说,需要:
- 实现writeToParcel()方法,在其中明确地将对象的每个字段,按顺序写入到一个Parcel对象中。
- 提供一个名为CREATOR的静态常量,它是一个Parcelable.Creator接口的实现,负责调用构造函数,从Parcel中按顺序读出数据,来创建新对象。
- 原理:它不使用反射,而是通过我们显式编写的代码来完成打包和拆包。这种方式非常高效。
4. Serializable vs Parcelable:一个关键的对比
这是面试中几乎必问的一个点。
| 特性 |
Serializable (Java原生) |
Parcelable (Android特有) |
| 性能 |
性能较低 |
性能非常高,通常比Serializable快一个数量级 |
| 原因 |
大量使用反射,开销大;产生大量临时对象 |
无反射,直接读写数据,开销小 |
| 实现 |
非常简单 (implements Serializable) |
相对复杂 (需手动实现writeToParcel和CREATOR) |
| 存储 |
主要用于磁盘I/O或网络I/O (持久化) |
主要用于内存I/O (组件间、进程间通信) |
| 主要场景 |
数据持久化、网络传输 |
Intent、Bundle、AIDL中的对象传递 |
补充一点:对于Parcelable的实现复杂问题,在Kotlin中已经得到了完美的解决。我们只需要给数据类加上@Parcelize注解,编译器就会自动为我们生成所有Parcelable的模板代码,使其兼具了Serializable的简单和Parcelable的高性能。
首先,我们必须明确两个核心“生产工序”
在深入缓存之前,我们必须先理解onCreateViewHolder和onBindViewHolder这两个方法,它们是整个系统的“生产车间”和“包装车间”。
- onCreateViewHolder(ViewGroup parent, int viewType) - “生产车间”
- 职责:当RecyclerView发现没有任何可复用的ViewHolder时,就会调用这个方法来创建一个新的ViewHolder实例。
- 昂贵之处:这个过程涉及到LayoutInflater.inflate()(IO操作)和findViewById()(遍历视图树),是整个流程中最耗费性能的步骤之一。
- 目标:我们的缓存机制,其根本目标就是尽可能少地调用这个方法。
- onBindViewHolder(VH holder, int position) - “包装车间”
- 职责:这个方法负责“绑定数据”。它接收一个ViewHolder(可能是新创建的,也可能是从缓存中取出的),然后根据position,将对应的数据设置到ViewHolder的View上(比如textView.setText(...))。
- 要求:这个方法会被频繁调用(每次item滑入屏幕都会调用),所以它的逻辑必须极其轻量和快速,不能有任何耗时操作。
“四级仓储”:RecyclerView的缓存机制详解
现在,我们来看RecyclerView是如何通过它的四级缓存来避免调用onCreateViewHolder的。当一个Item滑出屏幕时,它的ViewHolder会依次尝试进入这几级缓存;当需要一个新的Item时,会反向依次从这几级缓存中寻找。
第一级缓存:mAttachedScrap & mChangedScrap (废料缓存)
- 保存什么:这里存放的ViewHolder是刚刚被分离(detach)但尚未完全离开屏幕,即将被重新利用的。它们仍然持有原来的数据。
- mAttachedScrap: 存放那些数据状态没有改变的ViewHolder。
- mChangedScrap: 存放那些数据已改变的ViewHolder,主要用于处理notifyItemChanged()等带动画的场景。
- 工作场景:主要在一次布局(Layout)过程中起作用。最典型的例子就是调用notifyDataSetChanged()时,RecyclerView会把当前屏幕上所有的ViewHolder都临时存放到Scrap中,然后重新布局。在布局新Item时,它会优先尝试从Scrap中根据position找到完全匹配的ViewHolder直接使用,甚至不需要重新调用onBindViewHolder。
第二级缓存:mCachedViews (视图缓存)
- 保存什么:存放刚刚滑出屏幕的ViewHolder。它不仅保存了ViewHolder的实例,还完整地保存了它最后一次绑定的数据。
- 工作场景:当用户快速地来回滑动时,这个缓存就起作用了。比如一个Item刚滑上去,用户又立刻滑下来,RecyclerView可以从mCachedViews中根据position精确地找到这个ViewHolder,然后直接拿来用,无需再次调用onBindViewHolder。
- 特点:按position存取,数据是干净的,无需重新绑定。但容量有限,默认大小只有2个。如果缓存满了,最早存入的ViewHolder就会被“挤”到下一级缓存。
第三级缓存:mRecycledViewPool (回收视图池)
- 保存什么:这是最核心、最重要的一级缓存。当ViewHolder从mCachedViews被挤出来,或者滑出屏幕足够远时,就会被存放到这里。
- 关键区别:存入RecycledViewPool之前,ViewHolder会被“洗刷一新”。它的所有数据都会被清除(position设为-1,状态被重置),只保留了itemView本身及其子View的引用。
- 工作场景:当Scrap和CachedViews都找不到合适的ViewHolder时,RecyclerView就会尝试从RecycledViewPool中获取。
- 它不按position查找,而是按viewType查找。只要viewType相同,就可以复用。
- 因为数据已经被清空,所以从这里取出的ViewHolder,必须重新调用onBindViewHolder来绑定新的数据。
- 按viewType存取,容量较大(默认每个viewType最多存5个)。
- 可以被多个RecyclerView共享。这是一个高级优化,比如在一个ViewPager2中,如果每一页都是一个结构相同的RecyclerView,它们就可以共享同一个RecycledViewPool,从而在页面切换时实现ViewHolder的复用。
第四级缓存:ViewCacheExtension (开发者自定义缓存)
- 这是一个留给开发者自己扩展的抽象类,默认是空实现。我们可以通过实现它来提供一套完全自定义的缓存逻辑。在实际开发中,我们极少会用到它。
onBindViewHolder() 绝对是在 onMeasure() 之前调用的。 并且,“先测量再Bind”这种模式是行不通的,它从根本上违背了View的渲染逻辑。
1. 为什么必须是“先Bind,后测量”?—— 建筑的蓝图与施工
我们可以把这个过程比作盖房子:
- onBindViewHolder() 的过程,就像是建筑师确定每个房间的功能和内部陈设。比如,这个房间是卧室,要放一张2米宽的床和一张1米宽的书桌;那个房间是客厅,要放一套3米长的沙发。
- onMeasure() 的过程,就像是施工队根据房间里的陈设,来计算这个房间到底需要多大的面积。如果卧室要wrap_content,那么施工队必须先知道床和书桌的尺寸,才能算出房间的最小尺寸。
- onLayout() 的过程,则是施工队在确定了房间大小后,把床、书桌、沙发这些家具,摆放到房间内的具体位置。
现在回到我们的问题:能不能先测量(施工),再Bind(确定陈设)?
答案显然是不能。 如果施工队连房间里要放什么都不知道,他们如何计算出需要多大的面积(onMeasure)呢?
举两个最典型的例子:
- TextView:假设我们一个Item的高度是wrap_content。TextView最终的高度,完全取决于onBindViewHolder给它设置了多少文字。如果设置的是一行文字,它可能高50px;如果设置的是三行文字,它可能高150px。如果在onBindViewHolder之前就去onMeasure,系统根本无法知道该为这个TextView预留多高的空间。
- ImageView:同样,如果ImageView的尺寸是wrap_content,它的最终大小取决于你给它绑定的那张图片的原始尺寸和宽高比。先测量,再绑定图片,得到的一定是错误的尺寸。
因此,bind是measure的数据输入和前提条件。这个顺序是Android View渲染机制的根本逻辑,RecyclerView必须严格遵守。
2. 内部工作流程:LayoutManager如何指挥这一切
理解了这个逻辑后,我们再来看RecyclerView内部的LayoutManager是如何实际操作的。LayoutManager是整个布局过程的总指挥。
当一个新的Item需要被显示在屏幕上时,LayoutManager会执行一个类似这样的流程:
- 向Recycler索要View:LayoutManager会告诉Recycler:“我需要在position为 X 的位置放一个View,请给我一个。”
- Recycler的准备工作:Recycler会去它的四级缓存中寻找一个可复用的ViewHolder。
- 如果从mCachedViews这种“干净”的缓存中找到了,可能连bind都省了。
- 但更常见的情况是,它从RecycledViewPool中找到了一个viewType匹配的“脏”ViewHolder(或者缓存里没有,就调用onCreateViewHolder创建一个新的)。
- 执行绑定 (onBindViewHolder):在把这个ViewHolder(及其itemView)正式交给LayoutManager之前,Recycler会调用Adapter的onBindViewHolder()方法。就在这一步,数据被绑定到了View上。TextView的内容被设置,ImageView的图片源被指定。
- 交给LayoutManager:现在,LayoutManager拿到了一个已经绑定好数据的、随时可以被测量的View。
- 执行测量 (onMeasure):LayoutManager会调用measureChild()或measureChildWithMargins()这类方法。这个方法内部,会触发我们拿到的那个itemView的measure()流程,进而调用到它的onMeasure()。因为此时View已经有了数据,所以wrap_content可以被正确地计算出来。
- 执行布局 (onLayout):测量完毕,LayoutManager知道了这个View的尺寸,然后它会调用layoutDecorated()或类似方法,这会触发itemView的layout()流程,进而调用到onLayout(),最终确定View在RecyclerView坐标系中的具体位置。
- 附加到窗口并绘制 (onDraw):最后,View被附加到RecyclerView上,等待下一个绘制信号到来时,执行onDraw(),最终被用户看到。
itemView 在 onMeasure() 之后,onLayout() 之前,被正式地加入(add)到RecyclerView中。
为了说清楚这一点,我需要将整个过程分解成一个由LayoutManager主导的、清晰的“三步走”战略。我们可以把LayoutManager想象成一个舞台的“场景导演”。
1. 第一步:准备演员 (获取并测量View)
当LayoutManager准备在屏幕上布置一个新的itemView时,它首先需要一个“演员”并知道这个“演员”有多大。
- 向Recycler索要View:导演(LayoutManager)对后台(Recycler)说:“给我一个position为X的演员(View)”。Recycler会执行我们之前讨论过的缓存逻辑,最终提供一个ViewHolder。如果需要,这个View在此时已经被onBindViewHolder绑定好了数据。
- 执行测量 (onMeasure):导演拿到演员后,并不会立刻让他上台。他需要先量一下这个演员的“三围”,看看他占多大地方。导演会调用measureChild()或measureChildWithMargins()。这个方法会触发itemView的measure()流程,最终调用到onMeasure()。因为View已经绑定了数据,所以尺寸(特别是wrap_content)可以被精确计算出来。
到目前为止,这个itemView虽然已经被测量了,但它仍然是一个“游离”的状态,它还没有被正式地添加到RecyclerView这个大的ViewGroup中。它就像一个在后台量好了尺寸,等待上台的演员。
2. 第二步:演员登台 (将View加入RecyclerView)
这是回答这个问题的关键一步。
测量完毕后,LayoutManager才会执行“添加”这个动作。
- 调用addView():LayoutManager会调用addView(itemView)或类似的方法(比如addView(itemView, index))。就在这一刻,这个itemView才正式成为了RecyclerView的一个子View,它被添加到了RecyclerView的子View列表中。
- 为什么是在测量之后? LayoutManager需要先知道这个View的尺寸,才能更好地规划整个布局,比如计算剩余空间、决定下一个View的位置等。
- 为什么又是在布局之前? 一个View必须先被addView(),成为ViewGroup的子View,然后才能参与到ViewGroup的onLayout流程中,被赋予一个具体的位置。你不能给一个还没“上台”的演员安排舞台位置。
3. 第三步:确定位置 (布局View)
演员已经登台了,现在导演要告诉他具体该站哪里。
- 执行布局 (onLayout):LayoutManager在addView()之后,会调用layoutDecorated()或layoutDecoratedWithMargins()。这个方法会计算出itemView的left, top, right, bottom坐标,然后调用itemView.layout()方法。这个调用最终会触发itemView自己的onLayout()(如果它也是一个ViewGroup),把它自己以及它的子View都安排在指定的位置上。
- 等待绘制 (onDraw):一旦itemView被layout,它的位置和尺寸就都确定了。它现在万事俱备,只等Android的下一个渲染信号(VSYNC)到来,由渲染系统自动调用它的draw()方法,最终将它绘制在屏幕上。
1. 核心布局哲学:堆叠 vs 关联
这是两者最根本的区别。
- 它的哲学是“堆叠”或“层叠”。所有被添加到FrameLayout中的子View,默认都会像一叠画纸一样,从左上角(0,0)开始,一个接一个地堆在另一个之上。后面添加的View会覆盖在前面添加的View上面。
- 它是一个非常简单、轻量级的布局,只关心“层次”,不关心兄弟View之间的关系。
- 它的哲学是“相对关联”。它像一块软木板,你可以把一个View(图钉)钉在任何地方,但它的位置是通过描述它与其他元素的关系来定义的。
- 相对于父布局(软木板边缘):比如layout_alignParentTop="true"(贴着顶部),layout_centerInParent="true"(在正中间)。
- 相对于兄弟View(其他图钉):比如layout_toRightOf="@id/buttonA"(在按钮A的右边),layout_below="@id/textViewB"(在文本B的下面)。
2. 子View定位机制:layout_gravity vs 丰富的关系属性
基于它们的哲学,它们为子View提供的定位工具也完全不同。
- 它控制子View位置的主要工具是 android:layout_gravity 属性。
- 通过layout_gravity,我们可以让子View不再堆在左上角,而是可以指定它在父布局中的对齐方式,比如center(居中)、bottom|right(右下角)等。
- 它提供了一整套丰富、语义化的关系属性来精确定位,比如:
- layout_toLeftOf, layout_toRightOf, layout_above, layout_below
- layout_alignTop, layout_alignBottom, layout_alignLeft, layout_alignRight
- layout_alignParentTop, layout_alignParentBottom, layout_centerHorizontal 等等。
- 正是这些属性,让RelativeLayout能够构建出非常灵活和复杂的UI结构。
3. 性能开销:轻量级 vs 重量级
这是它们在技术实现上的一个关键差异。
- 性能非常高。它的测量(onMeasure)和布局(onLayout)过程非常简单直接。它只需要遍历一次子View,询问它们的大小,然后根据layout_gravity把它们摆放好即可。通常只需要一次测量/布局传递。
- 性能开销相对较大。由于子View的位置是相互依赖的,RelativeLayout的测量过程通常需要执行两次:
- 第一次传递:测量所有子View,了解每个View期望的尺寸。
- 第二次传递:在知道了所有View的尺寸后,再根据它们之间的相对关系,计算出每个View的最终准确位置和大小。
- 这种双重测量的机制,使得RelativeLayout在布局复杂的界面时,性能会低于FrameLayout或LinearLayout。
4. 典型使用场景
- 需要视图重叠时:这是它最经典的场景。比如,在一个图片上层叠一个播放按钮,或者在一个背景图上层叠一些文本。
- 作为Fragment的容器:我们通常在Activity的布局中放一个FrameLayout,然后通过代码将Fragment动态地加载到这个“坑”里。
- 最简单的单View容器:当一个布局只需要容纳一个子元素时,用FrameLayout是最轻量、最高效的选择。
- 构建复杂的、非线性的UI:当界面元素的排列不是简单的横向或纵向时,比如登录界面,输入框、标签、按钮之间有着复杂的对齐关系。
- 为了避免深层嵌套:当使用LinearLayout需要嵌套很多层才能实现的效果,通常可以用一个单层的RelativeLayout来“拍平”,从而优化视图层级。
补充:现代的视角 - ConstraintLayout
值得一提的是,在现代Android开发中,RelativeLayout的很多使用场景,已经被更强大、更高效的 ConstraintLayout 所取代。ConstraintLayout可以看作是RelativeLayout的“超级进化版”,它同样使用相对关系来布局,但其内部的约束求解算法比RelativeLayout的双重测量更高效,并且能实现更复杂的布局,是目前Google官方推荐的首选布局方式。
总结
面试官您好,我的结论是:
| 特性 |
FrameLayout |
RelativeLayout |
| 哲学 |
堆叠、层级 |
相对、关联 |
| 定位 |
layout_gravity |
丰富的关系属性 |
| 性能 |
高 (单次测量) |
较低 (双次测量) |
| 场景 |
视图重叠、Fragment容器 |
复杂、非线性布局 |
在RelativeLayout中,一个子View的移动(位置、尺寸或可见性的变化),是否会影响其他子View,完全取决于其他子View是否在布局上与它存在“依赖关系”。
我们可以把RelativeLayout想象成一个由图钉和绳子组成的布局系统:
- layout_toRightOf, layout_below这些相对属性,就像是连接两个图钉的绳子。
现在,我们来分析这个“影响”是如何传递的:
1. 依赖链的传递:牵一发而动全身
如果多个View通过相对属性形成了一条“依赖链”,那么移动链条头部的View,就会像拉动绳子一样,导致整条链上的所有View都跟着移动。
举一个最经典的例子:
我们有三个View: A, B, C。
- View B 的布局是 layout_toRightOf="@id/A" (B在A的右边)。
- View C 的布局是 layout_below="@id/B" (C在B的下面)。
这样就形成了一条依赖链:C -> B -> A (C依赖B,B依赖A)。
- 因为B的左边界是由A的右边界决ipined的,所以当A向右移动10像素时,B为了维持“在A右边”这个规则,也必须跟着向右移动10像素。
- 因为C的上边界是由B的下边界决定的,所以当B移动时,C也必须跟着移动,以保持“在B下面”这个规则。
- 因为A和B的布局规则中,完全没有提到C,它们不依赖于C。
结论:影响是单向沿着依赖链传递的。
2. 尺寸变化的影响 (Size Change)
一个View尺寸的变化,同样会通过依赖链影响其他View。
- 继续上面的例子:如果View A的宽度增加了20像素。
- 因为View B的左边界依赖于View A的右边界,View A变宽,其右边界就会向右推。
- 为了维持相对位置,View B也必须向右移动20像素,进而导致View C也跟着向下移动。
3. 可见性变化的影响 (Visibility Change)
这是一个非常关键且容易被忽视的点。当一个View的可见性(Visibility)被设置为GONE时,它在布局中的效果,就好像这个“图钉”被拔掉了。
- 当一个View被设为GONE时,它在布局中将不再占据任何空间。
- 对于依赖于它的其他View来说,就好像这个锚点突然消失了。
- 还是上面的例子,如果我们将View A设置为View.GONE。
- View B的布局规则是layout_toRightOf="@id/A"。现在A“消失”了,这个规则就失效了。
- 此时,View B会按照它的次要规则或者默认规则来重新定位。如果没有其他规则,它默认会把自己定位在父布局的左上角。
- 由于View B的位置发生了剧变,依赖于View B的View C,自然也会跟着移动到新的位置。
- View.INVISIBLE vs View.GONE:
- 这是一个重要的对比。如果View A被设置为INVISIBLE,它只是在屏幕上看不见了,但在布局中依然占据着原来的空间。
- 此时,依赖于它的View B和View C的位置将不会发生任何变化。对于布局系统来说,A这个“图钉”还在原来的位置,只是它变成透明的了。
4. 重新布局的触发
当一个子View的位置、尺寸或可见性发生程序性改变时(比如我们调用了view.setX(), view.setVisibility(View.GONE)),它会触发一次requestLayout()调用。这个调用会向上传递,最终导致RelativeLayout重新执行它的onMeasure()和onLayout()方法,根据所有子View当前最新的状态和它们之间的相对关系规则,重新计算整个布局。
总结
面试官您好,我的结论是:
- RelativeLayout中一个子View的变动,只会影响那些在布局属性上直接或间接依赖于它的其他子View。
- 这种影响是沿着“依赖链”单向传递的,就像多米诺骨牌。
- 影响的来源可以是位置的改变、尺寸的改变,或者是可见性的改变。
- 特别需要注意的是,当一个View的可见性变为 GONE 时,它会从布局中“消失”,导致依赖于它的View布局规则失效,从而可能引发剧烈的连锁布局变化。而变为 INVISIBLE 则不会影响布局。
- TCP (Transmission Control Protocol, 传输控制协议) 就像是打一个电话。
- UDP (User Datagram Protocol, 用户数据报协议) 就像是寄一张明信片。
这个比喻贯穿了它们所有的核心区别,我将从以下几个方面来详细阐述:
1. 连接性:需要“接通” vs 直接“寄出”
- TCP(打电话):是面向连接的协议。在发送任何数据之前,通信双方必须先建立一个可靠的连接,这个过程就是著名的“三次握手”。通话结束后,还需要通过“四次挥手”来断开连接。这保证了双方都准备好并且能够通信。
- UDP(寄明信片):是无连接的协议。它不需要事先建立任何连接。它把数据打包好(写上地址),然后直接扔到网络中,尽力而为地去发送。
2. 可靠性:保证送达 vs “生死有命”
- TCP(打电话):是可靠的。它提供了一整套复杂的机制来确保数据的可靠传输:
- 确认与重传:接收方每收到一个数据包,都会发送一个确认(ACK)。如果发送方在一定时间内没收到确认,就会重新发送这个数据包。
- 有序传输:通过序列号,TCP能保证接收方收到的数据,和发送方发出的顺序完全一致,如果顺序乱了,接收方会负责重新排序。
- 流量控制和拥塞控制:自动调节发送速度,防止淹没接收方或者造成网络拥堵。
- UDP(寄明信片):是不可靠的(或者说“尽力而为”)。它不提供任何可靠性保证。
- 它不关心数据包是否到达、是否按顺序到达、或者是否重复到达。
- 如果明信片在路上寄丢了、寄重复了、或者先寄的后到了,邮局(UDP)是不负责的。
3. 效率与开销:精心包装 vs 轻装上阵
- TCP(打电话):由于需要维护连接状态、发送确认、处理序列号等,它的开销较大,头部也更复杂(20-60字节)。这导致它的传输速度相对较慢。
- UDP(寄明信片):它的开销极小,头部非常简单(固定8字节)。没有了那些复杂的确认和控制机制,它的传输速度非常快。
4. 数据传输模式:数据流 vs 数据报
- TCP(打电话):提供面向字节流的服务。应用程序发出的数据,在TCP看来就像一条没有边界的河流,它会根据网络状况,把这条河切分成合适大小的数据块进行传输。接收方收到的也是一条完整的河流,不需要关心中间是怎么被切分的。
- UDP(寄明信片):提供面向数据报的服务。它把应用层传下来的每一段数据,都原封不动地打成一个独立的“包裹”(数据报),并保留这些边界。接收方每次收到的,都是一个完整的“包裹”。如果包裹太大,可能会在IP层被分片,增加了丢失的风险。
5. 典型使用场景
基于以上的区别,它们的应用场景也泾渭分明:
- Web浏览 (HTTP/HTTPS):网页的每个部分都必须正确加载。
- 文件传输 (FTP):一个损坏的字节就可能导致整个文件无法使用。
- 电子邮件 (SMTP, POP3):绝不能丢失邮件内容。
- 什么时候用UDP?—— 当实时性和速度是第一要素,且能容忍少量丢包时
- 在线视频/直播/语音通话 (VoIP):在这种场景下,我们最怕的是卡顿。丢失一两帧画面或一瞬间的声音,用户可能无感知,但如果为了等一个丢失的数据包而让整个画面卡住,体验是毁灭性的。
- 在线游戏:玩家的位置信息需要被快速广播,实时性远比偶尔一个数据包的丢失更重要。
- DNS查询:一次简单的查询,用UDP非常快。如果没收到响应,客户端简单地再问一次就行了。
- 广播和多播:向网络中的多个节点发送消息,用UDP非常高效。
总结
面试官您好,为了让您更清晰,我用一个表格来总结:
| 特性 |
TCP (打电话) |
UDP (寄明信片) |
| 连接性 |
面向连接 (三次握手) |
无连接 |
| 可靠性 |
可靠 (确认、重传、有序) |
不可靠 (尽力而为) |
| 效率 |
慢,开销大 |
快,开销小 |
| 头部大小 |
20-60字节 |
8字节 |
| 控制机制 |
流量控制、拥塞控制 |
无 |
| 数据模式 |
字节流 |
数据报 |
| 应用场景 |
网页、文件、邮件 |
直播、游戏、DNS |
设计模式通常分为三大类:
- 创建型模式:主要解决“如何创建对象”的问题,它把对象的创建和使用分离开。比如 单例模式、工厂模式、建造者模式。
- 结构型模式:主要解决“如何组合类和对象以形成更大的结构”的问题。比如 适配器模式、装饰者模式、代理模式。
- 行为型模式:主要解决“对象之间如何通信和分配职责”的问题。比如 观察者模式、策略模式、责任链模式。
(第二部分:举例详述 - 结合Android开发实践,展示深度)
在Android开发中,设计模式的应用可以说是无处不在。我举几个最常用、也最能体现其思想的例子:
- 建造者模式 (Builder Pattern)
- 核心思想:将一个复杂对象的构建过程和它的表示分离开,使得同样的构建过程可以创建不同的表示。
- 怎么表达:“当一个类的构造函数参数非常多,尤其是很多参数还是可选的时候,用建造者模式就特别合适。它能让我们的代码可读性变得非常好,调用者可以像搭积木一样,按需设置属性,最后再build()出来一个完整的对象。”
- Android中的体现:最经典的例子就是 AlertDialog.Builder。我们通过链式调用 .setTitle(), .setMessage(), .setPositiveButton() 等方法来一步步配置一个对话框,配置的顺序和数量都很灵活,最后调用 .create() 或 .show() 得到最终的对话框。像 OkHttpClient.Builder 和 Notification.Builder 也是同样的设计思想。
- 单例模式 (Singleton Pattern)
- 核心思想:保证一个类在整个应用中只有一个实例,并提供一个全局的访问点。
- 怎么表达:“在我们的App中,有些对象从业务逻辑上讲,只需要一个就够了,比如数据库的连接管理类、网络请求的Client(像Retrofit实例)、或者一个全局的图片加载配置。使用单例可以避免重复创建这些重量级对象,节约系统资源。”
- Android中的体现:在Kotlin中,实现单例非常简单,直接使用 object 关键字声明一个类就可以了,这是线程安全的,也是官方推荐的最佳实践。在Java中,我们通常会使用“双重检查锁定(Double-Checked Locking)”来保证懒加载和线程安全。
- 适配器模式 (Adapter Pattern)
- 核心思想:作为一个中间件,将一个类的接口转换成客户端期望的另一个接口,从而让原本接口不兼容的两个类可以协同工作。
- 怎么表达:“适配器模式就像一个万能转换插头。比如我们的数据源是一个 List<User> 列表,而 RecyclerView 并不知道如何直接展示 User 对象。这时候就需要一个 Adapter 登场了,它在中间做了一层转换,把 List<User> 里的数据“适配”成了 RecyclerView 能够识别和显示的 View(也就是ViewHolder)。”
- Android中的体现:RecyclerView.Adapter 和 ListView/GridView 的 Adapter 是这个模式最直观的应用。我们通过继承它,并实现 onCreateViewHolder、onBindViewHolder 等方法,就完成了数据到视图的适配。
- 观察者模式 (Observer Pattern)
- 核心思想:定义了一种一对多的依赖关系,当一个对象(被观察者/主题)的状态发生改变时,所有依赖于它的对象(观察者)都会得到通知并自动更新。
- 怎么表达:“这个模式的核心在于解耦。一个数据源(被观察者)不需要关心谁在监听它,也不用关心监听到数据变化后要做什么。它只管在数据变化时吼一嗓子'我变了',所有'订阅'了它的观察者就会收到通知,然后各自执行自己的更新逻辑。这非常符合UI编程的场景。”
- Android中的体现:Jetpack中的 LiveData 和 StateFlow 是现代Android开发中观察者模式的最佳实践。ViewModel 持有 LiveData(被观察者),Activity/Fragment(观察者)通过 .observe() 方法订阅它。当 ViewModel 中的数据更新时,UI会自动收到回调并刷新,而且它还具备生命周期感知能力,能避免内存泄漏。传统的 setOnClickListener 其实也是一个简化的观察者模式。
(第一部分:核心思想 - 一句话讲明白工厂是干嘛的)
首先,工厂模式的核心思想就一句话:将对象的创建过程和使用过程分离开。
在代码里,我们最常见的创建对象的方式就是直接 new 一个类。但如果创建这个对象的过程非常复杂,或者我们需要根据不同的情况创建不同的子类实例,那么代码就会变得很臃ăpadă,并且使用方(客户端)和具体的实现类会紧紧地耦合在一起。工厂模式就是为了解决这个问题,它提供了一个专门的“工厂”来负责生产(创建)对象,而使用者只需要跟这个工厂打交道,告诉它我想要什么,工厂就会把对应的产品给我,我根本不需要关心这个产品是怎么被生产出来的。
这样做最大的好处就是 解耦 和 提升代码的灵活性。
(第二部分:具体分类与演进 - 从简单到复杂,展示知识的广度和深度)
工厂模式不是单一的模式,它其实是一个家族,通常我们把它分为三种:简单工厂模式、工厂方法模式和抽象工厂模式。它们之间有明显的演进关系。
- 简单工厂模式 (Simple Factory Pattern)
- 怎么表达:“这是最容易理解的一种。它就是一个具体的类,里面有一个静态方法。你传一个参数给这个方法,它内部用 if-else 或者 switch 来判断,然后 new 一个对应的产品实例返回给你。它就像一个什么都能生产的小作坊。”
- Android中的体现:一个非常经典的例子就是 BitmapFactory。我们调用 BitmapFactory.decodeResource() 或者 decodeStream(),传入不同的资源ID或输入流,它就能为我们创建出一个 Bitmap 对象。我们使用者完全不用关心这个 Bitmap 是怎么从二进制数据解析出来的,是 ARGB_8888 还是 RGB_565 格式,这些复杂的创建细节都被工厂封装好了。
- 优点和缺点:优点是简单直观,把创建逻辑集中管理。但缺点也很明显,就是 违反了开闭原则。每次要增加一种新产品,都必须去修改那个工厂类的判断逻辑,这在大型项目中是不太好的。
- 工厂方法模式 (Factory Method Pattern)
- 怎么表达:“工厂方法模式是对简单工厂的'开闭原则'问题的改进。它定义了一个创建对象的抽象方法(在接口或抽象类里),但把具体的实例化过程 延迟 到了子类工厂去做。也就是说,我们有一系列平行的工厂,每个工厂只负责生产一种特定的产品。这就好比从一个小作坊升级成了一个个的品牌专卖店,A店只卖A产品,B店只卖B产品。”
- 核心:核心在于 “一对一” 的关系:一个具体的工厂类只负责创建一个具体的产品类。
- Android中的体现:在现代Android开发中,Jetpack的 ViewModelProvider.Factory 是一个绝佳的例子。当我们想给 ViewModel 传递参数时,系统默认的 Factory 就不能满足需求了。这时我们需要自定义一个 Factory 类,实现 create 方法,在这个方法里 new 出我们想要的、带有参数的 ViewModel 实例。这里,ViewModelProvider.Factory 就是抽象工厂接口,我们的自定义 Factory 就是具体的子类工厂,它专门负责创建我们那个特定的 ViewModel。
- 抽象工厂模式 (Abstract Factory Pattern)
- 怎么表达:“这是最复杂,但也是功能最强大的工厂模式。它不是为了生产一个单一的产品,而是为了创建 一系列相互关联或相互依赖的对象,也就是一个'产品族'。你可以把它想象成一个'品牌代工厂',比如'苹果代工厂',它能生产出一整套苹果设备:iPhone、iPad、MacBook,这些产品都是'苹果系'的,可以完美配合。如果要换成'华为代工厂',那它就会生产出另一套完整的产品:Mate手机、MatePad、MateBook。”
- 核心:核心在于 “产品族”。一个具体的工厂能生产出属于同一个主题或风格的多个不同种类的产品。
- Android中的体现:最典型的应用场景就是 App的换肤功能。我们可以定义一个抽象的 UIFactory 接口,里面有 createButton()、createTextView()、createImageView() 等方法。然后我们实现两个具体的工厂:LightThemeFactory 和 DarkThemeFactory。当用户选择日间模式时,我们就使用 LightThemeFactory 来创建所有UI控件,它们都是浅色系的。当切换到夜间模式时,就换成 DarkThemeFactory,它创建出的所有控件就都是深色系的。这样一来,切换主题就只需要切换一次工厂实例,非常方便和统一。
(第一部分:宏观概述 - 讲清楚是什么,分几步)
首先,类加载过程指的是虚拟机把描述类的数据从 .class 文件(在Android中是 .dex 文件)加载到内存中,并对数据进行校验、转换、解析和初始化,最终形成可以被虚拟机直接使用的Java类型(也就是 java.lang.Class 对象)的过程。
这个过程宏观上可以分为三个大的阶段:加载(Loading)、链接(Linking)、和初始化(Initialization)。其中,链接阶段又可以细分为 验证(Verification)、准备(Preparation)、和解析(Resolution) 这三个小步骤。所以,完整的生命周期就是这五个步骤,它们通常是按顺序开始的,但解析阶段可能会在初始化之后才开始,这是为了支持Java的动态绑定。
(第二部分:分步详解 - 每一阶段干了什么,讲出细节)
接下来我详细解释一下每个阶段具体做了什么:
- 加载 (Loading)
- 怎么表达:“这是第一步,它的任务就是'找到并搬运'。虚拟机需要通过一个类的全限定名(比如 java.lang.String),找到定义这个类的二进制字节流(也就是 .class 文件或从 .dex 文件中提取)。然后,它会把这个字节流所代表的静态存储结构,转换成方法区(在Java 8以后是Metaspace)中的运行时数据结构。最后,在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。”
- 核心产出:方法区内的数据结构 + 一个 Class 对象。
- 链接 (Linking)
- 怎么表达:“这是'安全检查'。为了确保加载进来的字节流是符合虚拟机规范、不会危害虚拟机自身安全的。比如检查文件格式是否正确、元数据分析是否符合Java语言规范、字节码语义是否合法等等。这一步非常重要,是保证Java技术安全性的一个关键环节。”
- 怎么表达:“这是'分配初始内存'。在这一步,虚拟机会为类的 静态变量(static variables) 分配内存,并设置这些变量的 初始零值。这里有一个关键点要强调:是'零值',而不是代码里我们赋的那个初始值。比如 public static int value = 123;,在准备阶段 value 的值是 0,而不是 123。123 这个赋值动作是在最后的初始化阶段才执行的。”
- 特例:如果一个静态变量是 final 的常量(static final),并且在编译时就能确定其值,那么在准备阶段就会直接被赋值为我们代码中写的值。
- 怎么表达:“这是'符号变真身'的过程。在编译时,类还不知道它引用的其他类或方法的具体内存地址,只能用一些符号来代替,我们称之为'符号引用'。解析阶段的任务就是,把这些 符号引用 替换为 直接引用(也就是具体的内存地址指针)。比如,代码里调用了另一个类的方法,这个'调用关系'就是符号引用,解析后就会变成一个指向那个方法在内存中具体位置的指针。”
- 初始化 (Initialization)
- 怎么表达:“这是类加载的最后一步,也是真正开始执行我们自己编写的Java代码的部分。在这一步,虚拟机会执行类的构造器方法 <clinit>()。这个方法不是我们手写的,是编译器自动收集类中所有的 静态变量的赋值动作 和 静态代码块(static {}) 中的语句合并产生的。”
- 核心:执行 <clinit>() 方法。前面提到的 value = 123; 这个动作,就是在这个阶段完成的。
- 重要特性:虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步。也就是说,如果多个线程同时去初始化一个类,只有一个线程会去执行这个类的 <clinit>() 方法,其他线程都需要阻塞等待,这就保证了静态变量和静态代码块的线程安全。
(第三部分:结合Android,讲出差异和应用)
在Android开发中,类加载机制与标准Java有一些关键的不同和重要的应用:
- 加载对象不同:标准JVM加载的是 .class 文件,而Android的DVM或ART加载的是经过优化的 .dex 文件。
- ClassLoader不同:Android中有两个核心的ClassLoader:
- PathClassLoader:这是系统默认的类加载器,只能加载已经安装到系统目录(如 /data/app)下的APK文件中的类。可以理解为“体制内”的加载器。
- DexClassLoader:它更灵活,可以从任意路径加载 .jar、.apk 或者 .dex 文件。这为Android的 插件化 和 热修复 技术提供了基础。热修复的原理,本质上就是通过 DexClassLoader 从外部加载一个新的、修复了Bug的类,然后通过某种方式(比如Java的反射机制)让程序在运行时去使用这个新类,而不是原来有问题的旧类。
- 双亲委派模型 (Parent Delegation Model)
- 怎么表达:“无论是Java还是Android,类加载器都遵循'双亲委派模型'。简单说就是:一个类加载器收到了加载请求,它不会自己先去尝试加载,而是先把这个请求 委派给它的父加载器 去完成,层层向上委派,直到最顶层的启动类加载器(Bootstrap ClassLoader)。只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。”
- 避免类的重复加载:保证了同一个类只会被加载一次。
- 安全:保证了Java核心库的类型安全,防止我们自己写一个 java.lang.String 类来替代系统的核心类,从而破坏程序。
其实并不是静态内部类有什么特殊的、会被自动回收的机制。 真正核心的问题在于,非静态内部类(也叫成员内部类)会阻止外部类被系统回收,从而导致内存泄漏,而静态内部类则不会。 所以,我们提倡使用静态内部类,是为了“避免内存泄漏”,而不是因为它“更容易被回收”。
要理解这个问题,核心在于搞清楚 非静态内部类 和 静态内部类 的一个根本区别:
- 非静态内部类:会 隐式地持有 一个外部类实例的强引用。这意味着,只要这个内部类的对象还存在,那么它所对应的外部类对象就绝对不会被垃圾回收器(GC)回收。
- 静态内部类:它不依赖于外部类的任何实例,因此它 不会持有 外部类实例的引用。它的行为更像一个普通的、只是碰巧定义在另一个类内部的顶级类。
(接下来,用一个最经典的Android场景来把问题讲透)
我来举一个Android中最经典的、由非静态内部类导致内存泄漏的例子:在 Activity 中使用一个非静态的 Handler。
public class MyActivity extends AppCompatActivity {
private Handler mHandler = new Handler() { // 这是一个匿名的非静态内部类
@Override
public void handleMessage(Message msg) {
// ... 更新UI ...
}
};
@Override
protected void onCreate(Bundle savedInstanceState)
}, 60 * 1000);
}
}
这个场景下的内存泄漏是这样发生的:
- 当 MyActivity 启动时,我们创建了一个 mHandler 实例。因为 Handler 是一个非静态内部类(这里是匿名内部类,性质一样),所以这个 mHandler 实例会隐式地持有 MyActivity 实例的引用。
- 我们通过 mHandler 发送了一个延迟1分钟的消息。这个消息(Message 对象)会被放入主线程的 MessageQueue(消息队列)中。
- 这个 Message 对象为了能在未来被正确处理,它会持有对 mHandler 的引用。
- 现在,假设用户旋转了屏幕或者按返回键退出了这个页面。系统会尝试销毁 MyActivity 实例来回收内存。
- 但是,此时的引用链是这样的:MessageQueue -> Message -> mHandler -> MyActivity。
- 只要那条延迟1分钟的消息还没有被处理,它就会一直存在于 MessageQueue 中。这意味着 mHandler 会一直被引用,进而导致 MyActivity 的实例也一直被强引用,无法被GC回收。
- 一个本该被销毁的 Activity 实例(以及它所持有的所有View和资源)就这么泄漏在了内存里。如果这个操作频繁发生,就可能导致OOM(内存溢出)。
那么,静态内部类是如何解决这个问题的呢?
很简单,我们把 Handler 改成静态的:
public class MyActivity extends AppCompatActivity {
// 1. 定义成静态内部类
private static class SafeHandler extends Handler {
// 2. 使用弱引用持有Activity的引用
private final WeakReference<MyActivity> mActivity;
public SafeHandler(MyActivity activity) {
mActivity = new WeakReference<>(activity);
}
@Override
public void handleMessage(Message msg)
}
}
private final SafeHandler mHandler = new SafeHandler(this);
// ... onCreate 和其他代码 ...
}
第一部分:为什么需要多线程 - The "Why")
首先,我认为多线程的核心目的主要有两个:
- 提升性能:充分利用现代多核CPU的计算能力。对于计算密集型的任务,我们可以把任务分解到多个线程中并行处理,从而缩短总的执行时间。
- 避免阻塞,提升用户体验:这在Android中是 至关重要 的。Android应用有一个主线程,也叫UI线程,它负责处理所有的界面绘制、用户交互等事件。如果我们把耗时操作,比如网络请求、数据库读写、文件IO等,都放在主线程里执行,就会导致界面卡顿,甚至出现ANR(Application Not Responding)对话框。因此,我们必须把这些耗时操作放到子线程(或叫后台线程)中去执行,保证主线程的流畅。
(第二部分:如何实现多线程 - The "How")
在Java中,创建和管理线程主要有以下几种方式:
- 继承 Thread 类:重写 run() 方法。这是最基本的方式,但由于Java是单继承的,如果我们的类已经继承了其他类,就不能用这种方式了。
- 实现 Runnable 接口:实现 run() 方法,然后把 Runnable 实例传给一个 Thread 对象去执行。这是 更推荐 的方式,因为它将“任务”和“执行任务的线程”解耦了,更符合面向对象的思想。
- 实现 Callable 接口:与 Runnable 类似,但它的 call() 方法可以有返回值,并且可以向上抛出异常。通常需要配合 FutureTask 或线程池来获取执行结果。
但是, 在实际项目中,我们很少会直接 new Thread()。因为频繁地创建和销毁线程开销很大,而且不利于管理。所以,我们通常会使用 线程池(ThreadPool)。
- 控制并发数:可以有效控制系统中并发线程的最大数量,防止资源耗尽。
- 统一管理:提供了更强大的线程管理功能,比如定时执行、周期性执行等。
- 如何创建:通常使用 Executors 这个工具类提供的静态方法,比如 newFixedThreadPool(固定大小)、newCachedThreadPool(可缓存)、newSingleThreadExecutor(单线程)等。在更精细的控制场景下,我们会直接使用 ThreadPoolExecutor 的构造函数,手动配置核心线程数、最大线程数、存活时间等参数。
(第三部分:多线程带来的问题及解决方案 - The "Problems")
引入多线程的同时,也带来了线程安全问题,主要是由 “共享” 和 “可变” 这两个因素引起的,比如多个线程同时读写同一个变量,就可能导致数据错乱。
为了解决这些问题,Java提供了多种同步机制:
- synchronized 关键字:
- 这是Java最基础的同步原语,可以修饰方法或者代码块。它能保证在同一时刻,只有一个线程能进入被它保护的代码区。
- 它是一个 可重入 的 悲观锁,也是一个 非公平锁。
- Lock 接口:
- java.util.concurrent.locks.Lock 是一个更灵活、更强大的锁机制,最常见的实现是 ReentrantLock。
- 相比 synchronized,它提供了更多的功能,比如:可以尝试获取锁(tryLock)、可以被中断、可以实现公平锁等。
- volatile 关键字:
- 保证可见性:确保一个线程对共享变量的修改,能立刻被其他线程看到。
- 禁止指令重排序:在一定程度上保证了执行的有序性。
- 它常用于做一些简单的状态标记,比如 private volatile boolean isStopped = false;,但它 不保证原子性。
- 原子类(Atomic Classes):
- 比如 AtomicInteger, AtomicBoolean 等。它们底层利用了 CAS(Compare-And-Swap) 机制,提供了一种无锁的、线程安全的方式来更新变量,性能通常比加锁要好。
(第四部分:多线程在Android中的最佳实践 - The "Android Way")
理解了Java的多线程基础后,在Android开发中,我们更关心的是如何高效、安全地进行线程间通信,特别是 子线程如何通知UI线程更新界面。
- 传统方式:Handler 机制
- 这是Android提供的一套经典的消息处理机制。它的原理是:在UI线程创建一个 Handler,子线程通过这个 Handler 发送一个 Message 或 Runnable,这个消息会被放入UI线程的 MessageQueue(消息队列)中。UI线程的 Looper 会不断地从队列中取出消息,并交由 Handler 在UI线程中处理。
- 现代方式:Kotlin协程(Coroutines)
- 这是目前Google官方 首推 的异步编程解决方案。
- 轻量:协程比线程更轻量,可以在一个线程里运行成千上万个协程。
- 结构化并发:协程有自己的作用域(CoroutineScope),比如 viewModelScope,当 ViewModel 销毁时,作用域内的所有协程都会被自动取消,极大地简化了生命周期管理,避免了内存泄漏。
- 简化代码:可以用看似同步的方式写出异步的代码,可读性极高,告别了层层嵌套的回调地狱。比如,通过 withContext(Dispatchers.IO) 就可以轻松地将代码块切换到IO线程执行,执行完毕后会自动切回原来的线程。
- 其他方式:
- AsyncTask:一个轻量级的异步任务类,但由于存在一些设计缺陷(比如内存泄漏风险),现在已经被官方 废弃。
- RxJava:一个非常强大的、基于响应式编程思想的异步库。在协程普及之前,它是Android异步编程的主流方案,在很多现有项目中仍然被广泛使用。
首先,线程池的核心思想就是 管理和复用线程。
我们可以把它想象成一个银行。银行里总有那么几个固定的柜员(核心线程),他们一直在那里处理业务。当客户(任务)来了,如果正好有空闲的柜员,就直接去办理。
如果所有柜员都在忙,新来的客户就需要去等候区(工作队列)坐着排队。
如果等候区也坐满了,说明客户实在太多了,银行经理(线程池管理器)就会从办公室里再叫几个临时柜员出来帮忙(创建非核心线程),但银行总共能容纳的柜员数是有一个上限的(最大线程数)。
如果连临时柜员都叫出来了,并且都还在忙,等候区也满了,那银行就真的处理不过来了。这时,大堂经理就得执行 拒绝策略,比如跟新来的客户说:“不好意思,今天太忙了,请您改天再来”。
当高峰期过去,客户变少了,那些临时叫出来的柜员如果闲置了一段时间(存活时间),就会被叫回办公室,银行最终只保留那几个核心柜员。
这个比喻,基本上就完整地描述了线程池的工作原理。它避免了为每个任务都去“招聘一个新员工”(创建线程)和“解雇员工”(销毁线程)所带来的巨大开销。
(第二部分:核心参数 - ThreadPoolExecutor 的七大参数)
要深入理解它的原理,就必须了解它最重要的实现类 ThreadPoolExecutor 的七个构造参数,它们精确地定义了上面比喻中的每一个环节:
- corePoolSize (核心线程数): 银行里的常驻柜员数量。线程池创建后,即使是空闲的,也会保留这么多个线程。
- maximumPoolSize (最大线程数): 银行能容纳的总柜员数(常驻+临时)。
- keepAliveTime (存活时间): 临时柜员在没有任务时,还能继续存活多久才被“解雇”。
- unit (时间单位): keepAliveTime 的单位,比如秒、毫秒。
- workQueue (工作队列): 银行的等候区。它是一个阻塞队列,用来存放核心线程处理不过来的任务。常见的有 ArrayBlockingQueue (有界队列) 和 LinkedBlockingQueue (无界或有界队列)。
- threadFactory (线程工厂): 用来“招聘”新柜员(创建新线程)的方式,我们可以自定义它,比如给每个线程起一个有意义的名字,方便调试。
- rejectedExecutionHandler (拒绝策略): 当所有柜员都在忙,且等候区也满了,用来处理新任务的策略。常见的有:
- AbortPolicy: 直接抛出异常,这是默认策略。
- CallerRunsPolicy: 不使用线程池的线程,而是用提交任务的那个线程自己来执行。
- DiscardPolicy: 直接把任务丢弃,什么也不做。
- DiscardOldestPolicy: 把队列里最老的任务丢弃,然后把新任务加进去。
(第三部分:任务处理流程 - 讲清楚执行 execute() 时的步骤)
当一个新任务通过 execute() 方法提交给线程池时,内部的处理流程非常清晰,严格遵循以下步骤:
- 判断核心线程池是否已满:检查当前运行的线程数是否小于 corePoolSize。
- 如果是,则立即创建一个新的核心线程来执行这个任务,即使其他核心线程当前是空闲的。
- 判断工作队列是否已满:尝试将任务添加到 workQueue 中。
- 如果添加成功,任务就在队列里等待,后续由空闲的线程从队列中取出并执行。
- 如果添加失败(通常是因为队列是有界的并且已经满了),则进入下一步。
- 判断整个线程池是否已满:检查当前运行的线程数是否小于 maximumPoolSize。
- 如果是,则创建一个新的 非核心线程 来执行这个任务。
- 如果否(说明当前线程数已经达到上限),则进入最后一步。
- 执行拒绝策略:调用 rejectedExecutionHandler 来处理这个无法被接收的任务。
(第一部分:Java的通用做法 - 先说基础,但指出其在Android的局限性)
首先,从纯Java的角度来看,每个线程都有一个优先级,我们可以通过 Thread 类的 setPriority() 方法来设置。
- 这个方法接收一个从1到10的整数。Java定义了三个常量:
- Thread.MIN_PRIORITY (值为1)
- Thread.NORM_PRIORITY (值为5,这是默认值)
- Thread.MAX_PRIORITY (值为10)
- 理论上,优先级越高的线程,就越有可能被线程调度器优先选择执行。
但是, 在Android开发中,我们 强烈不推荐 直接使用Java的这套 setPriority() 机制。原因在于,Java的优先级模型只是一个给JVM的“建议”,而Android系统底层是基于Linux内核的,它有自己的一套更复杂、更精细的进程和线程调度策略(基于 "nice" 值)。Java的1-10优先级与Linux的 "nice" 值之间的映射关系并不稳定,也无法精确表达我们希望线程在Android系统中所扮演的角色。直接使用它,效果可能不符合预期,甚至没有效果。
(第二部分:Android的正确做法 - 给出官方推荐方案并解释原因)
在Android中,我们应该使用 android.os.Process 类提供的静态方法 setThreadPriority() 来设置线程优先级。
- 这个方法不接收1-10的数字,而是接收一系列以 THREAD_PRIORITY_ 开头的系统预设常量。这些常量能被Android系统精确地理解,并映射到正确的底层调度策略上。
- Process.THREAD_PRIORITY_BACKGROUND: 这是最重要的,也是我们最常用的一个。所有执行后台任务、不希望影响UI流畅度的子线程,都应该设置为这个优先级。它会告诉系统:“这个线程不着急,请优先把CPU资源让给UI线程和其他更重要的线程”。
- Process.THREAD_PRIORITY_DEFAULT: 这是线程的默认优先级,和UI线程在同一个级别,所以我们创建的子线程如果不设置,就可能和UI线程抢占CPU资源,导致卡顿。
- Process.THREAD_PRIORITY_FOREGROUND 和 Process.THREAD_PRIORITY_DISPLAY: 这些是为UI线程和关键的渲染线程准备的,优先级非常高。我们的业务代码绝对不应该主动去设置这么高的优先级,否则会严重影响系统性能。
- Process.THREAD_PRIORITY_AUDIO 和 Process.THREAD_PRIORITY_URGENT_AUDIO: 用于音频播放这类实时性要求很高的任务,保证音频数据能被及时处理,避免播放卡顿。
实际编码中,我们通常会在后台线程的 run() 方法开头,第一行就调用它,像这样:
new Thread(new Runnable()
}).start();
如果使用线程池,我们可以在自定义的 ThreadFactory 里统一为所有被创建出来的线程设置后台优先级。
(第三部分:核心原则和注意事项 - 展示深入思考和实践经验)
最后,关于线程优先级,我认为有几个核心原则需要牢记:
- 优先级是“建议”而非“命令”:我们设置了优先级,只是在向系统调度器提出一个“请求”,但最终哪个线程能执行,还是由调度器根据当前系统整体的负载情况来动态决定的。它并不能保证绝对的执行顺序。
- 核心目标是“降低”而非“提高”:在Android开发中,我们关心线程优先级的最主要目的,不是为了提高某个线程的优先级让它跑得更快,而是为了降低后台线程的优先级,从而保障UI主线程的绝对流畅。这是一个思维上的关键转变。
- 警惕“优先级反转”:这是一个经典的多线程问题。即一个高优先级的线程,因为需要等待一个低优先级线程释放某个锁资源,而被迫长时间阻塞。虽然在业务开发中不常见,但有这个意识很重要。
从设置操作本身来说,是的,调用 setThreadPriority() 这个动作是立即完成的,线程对象内部代表优先级的那个属性值会立刻被改变。
但是,这个改变是否能‘立刻’体现在线程的执行行为上,答案是:不一定,并且我们绝对不能依赖这一点。
要解释清楚这个问题,我们需要理解操作系统的线程调度器是如何工作的。
(第一部分:用一个比喻来解释)
您可以把操作系统内核的线程调度器想象成一个非常繁忙的CPU。它手里有一个“待办事项”列表,也就是所有处于“就绪”(Runnable)状态的线程队列。
- 修改标签是即时的:当我们调用 setThreadPriority() 时,就好比我们把队列中某个待办事项的标签,从“普通”紧急度改成了“非常紧急”。这个“改标签”的动作本身是瞬间完成的。
- 调度器需要时机来查看标签:但是,CPU调度器并不会因为你改了个标签,就立刻放下手里正在做的事情(当前正在运行的线程),然后马上重新扫描整个列表,找出那个最紧急的任务来执行。它只会在特定的 调度时机(Scheduling Point) 才会重新审视这个列表,决定接下来该轮到谁执行。
(第二部分:什么是“调度时机”)
这些关键的“调度时机”通常包括:
- 当前线程的时间片用完:大多数现代操作系统都采用时间片轮转的调度算法。每个线程被分配一个很短的时间片(比如几十毫秒)来运行,时间到了就会被强制暂停,调度器此时会重新选择下一个要运行的线程。
- 当前线程主动放弃CPU:比如线程调用了 sleep()、yield() 方法,或者因为等待IO(如读文件、等网络数据)而进入阻塞状态。
- 更高优先级的线程就绪:这是一个很重要的点。如果一个优先级更高的线程从阻塞状态恢复,或者被创建出来,变为就绪状态,这通常会触发一次抢占式调度(Preemption),调度器可能会立即中断当前正在运行的低优先级线程,转而去执行那个高优先级的。
- 硬件中断:比如用户点击屏幕、网络数据到达等,也会触发调度器重新评估。
(第三部分:这对我们开发者意味着什么)
理解了以上原理,我们就能得出最重要的结论:
- 不能依赖优先级来保证逻辑正确性:我们绝对不能编写依赖线程优先级来保证 程序逻辑正确性 的代码。比如,我们不能假设一个高优先级的线程A,总会在一个低优先级的线程B之前完成某个计算。这种严格的时序关系,必须通过 synchronized, Lock, Semaphore 等同步工具来严格保证,而不是靠优先级这个“君子协定”。
- 优先级是宏观上的“建议”:设置优先级的真正意义,在于从 宏观上 向操作系统提出一个“建议”,影响的是线程获取CPU时间的 概率。在系统繁忙时,一个 BACKGROUND 优先级的线程相比一个 DEFAULT 优先级的线程,获得CPU时间片的概率会低很多。这正是我们想要的效果:确保后台任务不会和UI线程抢夺宝贵的CPU资源。
总结一下:
设置是即时的,但生效(即调度器采纳这个新优先级并可能改变执行顺序)是有时机的。
在Android开发中,我们将一个子线程设置为 Process.THREAD_PRIORITY_BACKGROUND,这个设置会立刻更新线程的属性。然后,在下一个调度时机点,调度器就会认识到这个线程“不那么重要”,从而在系统资源紧张时,更有可能先去执行UI线程或其他更高优先级的任务。我们使用它,是为了从宏观上引导系统资源的分配,而不是为了在微观上控制线程的交替执行顺序。
关于自定义View的 onMeasure() 方法,它接收的两个 int 型参数,widthMeasureSpec 和 heightMeasureSpec,它们包含的 不仅仅是尺寸信息,更重要的是约束信息。
简单来说,这两个参数每一个都封装了两种信息:
- 测量模式 (Mode)
- 测量大小 (Size)
这是一个非常关键的设计。父视图(ViewGroup)并不是直接告诉子View:“你的宽度必须是100像素”。而是通过 MeasureSpec 这种方式,给子View下发一个带有“规则”的尺寸,更像是一种 建议或命令。
(接下来,我将详细解释这两种信息,特别是三种测量模式)
这两个信息被打包在一个32位的 int 值里。高2位代表 模式(Mode),低30位代表 大小(Size)。我们可以通过 MeasureSpec 这个工具类来解析它们:
- MeasureSpec.getMode(measureSpec) 可以获取模式。
- MeasureSpec.getSize(measureSpec) 可以获取大小。
而真正决定我们如何计算View尺寸的,是 测量模式,它有三种:
- EXACTLY (精确模式)
- 表达方式:“当父视图明确指定了子View的尺寸时,就是这个模式。比如,我们在XML布局里写了 layout_width="100dp" 或者 layout_width="match_parent"。在这种模式下,getSize() 返回的值就是父视图决定的精确尺寸,我们的自定义View 必须 遵守这个尺寸,并最终通过 setMeasuredDimension() 设置这个值。”
- 比喻:这就像父母直接告诉你:“你的零花钱就这么多,不能多也不能少。”
- AT_MOST (最大模式)
- 表达方式:“这种情况最常见于子View的尺寸被设置为 wrap_content。父视图会给出一个可用的最大空间,也就是 getSize() 返回的值。我们的View需要根据自己的内容(比如文字的长度、图片的固有大小)来计算一个期望的尺寸,但这个尺寸 不能超过 getSize() 给出的最大值。如果算出来比最大值还大,就必须使用最大值。”
- 比喻:这就像父母说:“这个月零花钱你自己看着办,但最多不能超过这个数。”
- UNSPECIFIED (不指定模式)
- 表达方式: “这是一种比较特殊的情况,父视图对子View没有任何尺寸限制。getSize() 返回的值没有太多参考意义。这种情况通常出现在一些可以滚动的容器里,比如 ScrollView 或 ListView,因为它们允许子View的高度(或宽度)超过容器本身的大小。在这种模式下,我们的View可以根据自己的内容计算出任意想要的尺寸。”
- 比喻:这就像父母说:“零花钱你想要多少就拿多少,没上限。”
(最后,进行总结)
所以,要正确地实现一个自定义View的 onMeasure 方法,核心就在于:
- 从 widthMeasureSpec 和 heightMeasureSpec 中解析出 Mode 和 Size。
- 根据不同的Mode,结合Size,计算出我们View最终的宽度和高度。特别是要正确处理 wrap_content 的情况,也就是 AT_MOST 模式,这是最能体现自定义View能力的地方。
- 最后,也是最关键的一步,必须调用 setMeasuredDimension(calculatedWidth, calculatedHeight) 方法,将我们计算出的最终尺寸保存起来。如果忘记调用这个方法,程序就会在运行时抛出异常。
1. EXACTLY (精确模式)
这表示父视图已经为当前View 指定了确切的尺寸。无论View自身想要多大,都必须严格遵守这个尺寸。
- 当我们在XML布局中,将View的 layout_width 或 layout_height 设置为 具体数值,比如 100dp。
- 当我们将View的 layout_width 或 layout_height 设置为 match_parent。因为此时父视图会用自己的剩余空间,给子View一个明确的大小。
在这种模式下,我们不需要自己计算尺寸。直接通过 MeasureSpec.getSize(measureSpec) 获取父视图给定的尺寸,然后把它作为我们最终的测量结果。
这是 命令式 的,父View说:“你就得这么大!”
2. AT_MOST (最大模式)
这表示父视图为当前View 提供了一个可用的最大空间。View可以根据自己的内容来决定尺寸,但最终尺寸 不能超过 父视图给定的这个上限。
最典型的情况就是,当我们将View的 layout_width 或 layout_height 设置为 wrap_content 时。
这是自定义View时 最需要我们自己写逻辑 的地方。我们需要:
- 计算一个基于内容的“理想尺寸”(比如根据文字的宽度、图片的大小等)。
- 通过 MeasureSpec.getSize(measureSpec) 获取父视图提供的最大尺寸。
- 比较两者,取 较小 的那个值作为我们最终的测量结果。例如:result = min(idealSize, maxSizeFromParent)。
这是 限制式 的,父View说:“你可以自己决定大小,但不能超过这个范围。”
3. UNSPECIFIED (不指定模式)
这表示父视图对当前View的尺寸 没有任何限制,View可以根据自己的需要,设置为任意大小。
这种情况比较少见,通常只在一些特殊的父容器中出现,最典型的就是那些 可以滚动的容器,比如 ScrollView 对其子View的高度(在垂直滚动时)就是 UNSPECIFIED 的,ListView 或 RecyclerView 在测量子项时也可能使用。因为容器本身允许内容超出屏幕。
在这种模式下,父视图给的 Size 通常没有意义。我们只需要计算出内容的“理想尺寸”,并直接使用这个尺寸作为最终的测量结果即可。
这是 放任式 的,父View说:“你想多大就多大,我不管。”
1. 架构组件 (Architecture Components)
这是 Jetpack 的灵魂,也是我日常开发中使用最频繁的部分。它们帮助我们构建稳定、可测试的 MVVM 架构。
- Lifecycle: 这是架构组件的基石。它能让我们的业务逻辑组件(比如一个 Presenter 或者一个数据获取类)感知并响应 Activity 或 Fragment 的生命周期变化。通过实现 LifecycleObserver,我们的组件可以在 onResume 时开始监听,在 onPause 时自动停止,从而彻底解决了因生命周期管理不当导致的内存泄漏和空指针问题。
- ViewModel: 它的核心作用是 以感知生命周期的方式存储和管理界面相关的数据。它最神奇的一点是,它能在屏幕旋转等配置变更导致 Activity 重建时 存活下来,从而保证了数据的连续性,避免了数据的重复加载。这让我们能轻松地将数据和逻辑从 Activity/Fragment 中分离出来,使其职责更单一。
- LiveData / Flow (Kotlin): 这是一个 可观察的、且具备生命周期感知能力 的数据持有类。当 LiveData 中的数据发生变化时,它只会通知处于活跃状态(STARTED 或 RESUMED)的观察者(比如我们的 Activity)。这意味着我们不再需要手动在 onDestroy 中取消订阅,因为它会自动处理,完美地避免了内存泄漏。在现代 Kotlin 开发中,StateFlow 和 SharedFlow 提供了更强大、更灵活的功能,是 LiveData 的一个很好的替代和演进。
- Room: 这是一个在 SQLite 之上的 对象关系映射(ORM)库。它极大地简化了数据库操作。我们只需要定义数据实体类(Entity)和数据访问对象接口(DAO),Room 就会在编译时自动生成所有实现代码。更棒的是,它能 在编译期验证我们的 SQL 语句,如果写错了会直接报错,避免了在运行时才发现 bug。它还可以直接返回 LiveData 或 Flow 对象,让数据库的变化能立刻响应到 UI 上。
- Navigation: 这个组件旨在 简化应用内的导航逻辑,特别是 Fragment 之间的跳转。通过可视化的 Navigation Graph,我们可以直观地定义页面间的流转关系和参数传递,彻底告别了繁琐且易错的 FragmentManager 和 FragmentTransaction 操作。
2. 界面组件 (UI Components)
这部分提供了构建现代化、美观用户界面的工具。
- Compose: 这是 Jetpack UI 的未来,是 Google 推出的全新声明式 UI 工具包。我们不再需要写 XML 布局,而是通过调用 Kotlin 函数来描述我们的 UI。它的核心思想是 UI = f(state),当数据状态改变时,UI 会自动、高效地进行重组(Recomposition)。这大大减少了代码量,并从根本上简化了复杂UI的状态管理。
- View Binding / Data Binding: 在使用传统 View 体系时,这两个是 findViewById 的完美替代品。View Binding 会为每个 XML 布局文件生成一个绑定类,我们可以通过它直接、类型安全地访问所有 View,告别了空指针和类型转换异常。Data Binding 功能更强大,它允许我们在 XML 中直接将数据源(比如 ViewModel 的字段)绑定到 View 属性上,进一步减少了 Activity 中的 UI 更新代码。
3. 行为组件 (Behavior Components)
这些组件帮助我们处理 Android 系统的一些核心交互。
- WorkManager: 这是一个极其强大的 后台任务调度库。它能保证我们的任务(比如上传日志、同步数据)最终一定会被执行,即使应用退出或者设备重启。它会根据设备的 API 级别和应用状态,智能地选择最合适的执行方式(如 JobScheduler 或 BroadcastReceiver + AlarmManager),并且支持设置各种约束,比如“仅在设备充电且连接 Wi-Fi 时执行”。
4. 基础组件 (Foundation Components)
这部分提供了核心的系统功能和跨版本兼容性。
- AppCompat: 这是我们最熟悉的老朋友了。它让我们的应用可以在旧版本的 Android 系统上,也能使用到新版本系统的 Material Design 控件和主题,保证了应用界面和行为的一致性。
我的回答是:对称加密之所以做不到协商密钥,是因为它存在一个无法靠自身解决的、根本性的 “密钥分发困境”,也就是一个典型的“先有鸡还是先有蛋”的问题。
(第一部分:用一个通俗的比喻来解释这个困境)
我们可以把对称加密想象成 使用一把普通的物理钥匙和锁。
- 我想给你寄一个装有秘密文件的保险箱。我用一把钥匙(对称密钥)把它锁上,然后把保险箱寄给你。
- 现在问题来了:我怎么把开锁的那把钥匙安全地交给你呢?
- 如果我把钥匙和保险箱一起寄过去,那任何一个快递员(中间人)都可以打开保险箱,秘密就泄露了。
- 如果我把钥匙单独用另一个快递寄给你,这个快递本身也是不安全的,钥匙还是可能被复制。
- 如果我用另一个保险箱锁着钥匙寄给你... 那第二个保险箱的钥匙又该怎么给你呢?
你看,这就陷入了一个死循环。在没有一个预先建立好的安全通道的情况下,我们无法安全地传递对称加密所需要的那把“共享密钥”。而在互联网这个公开的环境中,我们默认是没有任何安全通道的。
(第二部分:点明核心问题,并引出解决方案)
这个无法解决的问题,就叫做 “密钥分发问题”(Key Distribution Problem)。
对称加密的特点是加密和解密用的是 同一把密钥。它的优点是 速度快、效率高,非常适合加密大量的通信报文。但它的前提是,通信双方必须 事先 就拥有这把相同的密钥。
为了打破这个僵局,我们必须引入一种完全不同的加密思想,那就是 非对称加密。
非对称加密,我们可以把它想象成一个 只有投信口、没有钥匙孔的信箱。
- 它有一对密钥:一个 公钥 和一个 私钥。
- 公钥 就像这个信箱的地址和投信口,是完全公开的,你可以把它告诉任何人。任何人都可以往你的信箱里投信(用公钥加密信息)。
- 私钥 就像打开这个信箱的唯一钥匙,它由你本人严格保管,绝不外泄。只有你能打开信箱,读取里面的信件(用私钥解密信息)。
(第三部分:结合HTTPS,讲解两者如何完美协作)
现在,我们再来看HTTPS的握手过程,就非常清晰了:
- 客户端(浏览器)向服务器请求公钥。这个过程是明文的,无所谓,因为公钥本来就是公开的。
- 客户端生成一个随机数,这个随机数就是我们之后要真正用来通信的 “对称密钥”(我们称之为session key)。
- 客户端用服务器的公钥(信箱投信口),把这个“对称密钥”加密,然后发送给服务器。
- 因为这段信息是用公钥加密的,所以在整个互联网上,只有持有唯一私钥的服务器 才能解开它,安全地拿到了这个“对称密钥”。
- 至此,密钥协商完成。客户端和服务器双方都拥有了同一个、谁也没有截获到的“对称密钥”。
- 握手结束后,双方后续所有的通信,比如你请求的网页内容、提交的表单等,都使用这个“对称密钥”进行 对称加密 来传输,因为它的效率更高。
(第四部分:总结)
所以,总结一下:
- 对称加密本身 无法解决 在不安全网络中首次传递密钥的问题。
- 用 非对称加密 这种“高成本但安全”的方式,来解决 “如何交换密钥” 这个核心难题。
- 一旦密钥安全交换完毕,就立即换用 对称加密 这种“低成本高效率”的方式,来处理后续大量的 “数据传输”。
可以说,非对称加密的作用就像一个安全的“信使”,它的任务不是运送货物,而是专门用来把对称加密的“钥匙”安全地交到对方手上。
我的回答是:是的,协商的最终结果,就是客户端和服务端都拥有了完全相同的、用于对称加密的会话密钥(Session Key)。 这也是整个握手过程的目的所在。
但是,更有趣的是 它们是如何做到这一点的。这个密钥 并不是 由某一方生成然后直接发送给另一方,而是双方通过一个巧妙的过程,各自独立计算 出来的,但最终计算出的结果却能保证完全一致。
这里主要有两种主流的协商算法,我来分别解释一下它们是如何确保双方得到相同密钥的:
方式一:基于 RSA 的密钥交换(“加密后传递”)
这是我们上一题中讲到的、比较容易理解的方式。我再快速回顾一下关键步骤:
- 客户端生成“密钥的种子”:客户端会生成一个随机数,我们称之为 pre-master secret(预主密钥)。
- 客户端“锁上”这个种子:客户端用从服务器获取到的 公钥,将这个 pre-master secret 加密。
- 服务器“开锁”得到种子:服务器收到加密后的信息,用自己的 私钥 解密,得到了和客户端一模一样的 pre-master secret。
- 各自独立加工成最终密钥:现在,客户端和服务器都拥有了三个相同的“原材料”:
- pre-master secret (刚刚交换的)
- client_random (握手一开始客户端发的随机数)
- server_random (握手一开始服务器发的随机数)
- 然后,双方都 独立地、使用完全相同的算法(比如一个叫 PRF 的伪随机函数),将这三个原材料混合加工,生成最终的 master secret(主密钥)。
- 最后,再由 master secret 派生出会话密钥(Session Key)。
因为双方的“原材料”和“加工算法”都完全一样,所以他们各自在自己家里厨房做出来的“菜”(会话密钥),味道(内容)是 一模一样 的。
方式二:基于 Diffie-Hellman 的密钥交换(“共同计算”)
这种方式更巧妙,它甚至都不直接传递“密钥的种子”,而是双方通过公开的信息交换,共同“算出”一个谁也猜不到的共享密钥。
我用一个经典的 “颜料混合” 的比喻来解释它:
- 公开约定一种基础颜料:客户端和服务器首先公开约定一种大家都能看到的“公共颜料”(比如黄色)。这对应算法中的一些公开参数。
- 各自选择秘密颜料:
- 服务器也自己偷偷选了一种“秘密颜料”(比如蓝色)。
- 混合后进行第一次交换:
- 客户端把自己的“红色”和公共的“黄色”混合,得到“橙色”,然后把这个“橙色”公开寄给服务器。
- 服务器把自己的“蓝色”和公共的“黄色”混合,得到“绿色”,然后把这个“绿色”公开寄给客户端。
- 中间人就算截获了“橙色”和“绿色”,也无法轻易地从中分离出原始的“红色”或“蓝色”。
- 最后一步,生成相同的最终颜色:
- 客户端收到服务器寄来的“绿色”,然后把它和自己手里的“秘密红色”混合,最终得到一种“棕色”。
- 服务器收到客户端寄来的“橙色”,然后把它和自己手里的“秘密蓝色”混合,也得到了 完全相同的“棕色”!
在这个过程中,双方都没有直接传递任何秘密信息,但最终却神奇地得到了一个完全相同的共享秘密(最终的棕色),而这个秘密是中间人无法合成的。这个“棕色”就是双方共同协商出来的会话密钥的基础。
技术上,服务端当然可以生成一个密钥然后发给客户端。但是,如果这样做,整个HTTPS就形同虚设,完全失去了安全性。
为什么这么说呢?因为这样做会引入一个致命的、无法被修复的安全漏洞:中间人攻击(Man-in-the-Middle Attack)。
(第一部分:用一个简单的比喻来说明问题)
让我们回到之前的“寄信”比喻。
如果服务器直接生成一个密钥(一把钥匙)然后发送给客户端,这整个过程就相当于:
“服务器把一把保险箱的钥匙,写在一张明信片上,然后邮寄给客户端。”
这张明信片在邮寄的途中,会经过无数个邮差(网络路由器)的手。任何一个不怀好意的邮差(中间人攻击者)都可以拿起这张明信片,看一眼,然后把钥匙抄下来。
当客户端收到这张明信片时,他拿到了钥匙,服务器也保留了钥匙的备份。他们俩都以为这把钥匙是他们之间的秘密。但实际上,攻击者手里已经有了一把一模一样的复制品。
之后,客户端和服务器用这把“自以为安全”的钥匙加密通信,攻击者就可以用他复制的钥匙,轻松地解密所有内容,并且可以篡改内容后重新加密再发给对方。双方的通信,在攻击者面前,就如同裸奔。
(第二部分:点明核心设计原则)
这个问题的关键在于:在通信信道本身尚不安全的时候,你不能在信道中直接传递任何“秘密信息”本身,包括密钥。
HTTPS握手之所以安全,其设计的精妙之处就在于,它 从来没有在网络上直接传递过最终的会话密钥。
让我们回顾一下正确的做法(以RSA握手为例):
- 传递的是“锁”,而不是“钥匙”:服务器首先传递的是它的 公钥。公钥的作用相当于一个“只能锁、不能开”的保险箱。把这个保险箱公之于众是完全安全的,因为谁也打不开它。
- “秘密”由客户端在本地生成:客户端在自己的电脑里,悄悄地生成一个随机数(pre-master secret),这个就是密钥的“种子”。这个种子在生成的那一刻,从未离开过客户端的内存。
- 把“秘密”锁进“锁”里再传递:客户端用服务器的公钥(那个保险箱),把这个“种子”锁起来。现在,这个“种子”被安全地保护起来了。
- 只有服务器能打开“锁”:加密后的信息在网络上传输,即使被中间人截获了,他也打不开。因为只有服务器才有对应的 私钥,能打开这个保险箱,取出里面的“种子”。
通过这个过程,双方安全地共享了“密钥的种子”,然后各自用相同的算法生成了最终的会ip-address。
(第三部分:总结)
所以,总结一下:
- 如果服务端直接生成密钥并发送,就相当于把密钥本身暴露在了不安全的信道中,中间人可以直接窃取,导致整个加密通信失效。
- 而HTTPS的正确做法是,由 客户端 来生成密钥的“种子”,然后利用 非对称加密 的特性(服务器的公钥),将这个“种子”安全地送达服务器。
简单来说,关键的区别在于:不是 “谁生成密钥” 的问题,而是 “如何让双方在不被窃听的情况下,共同拥有一个别人都不知道的秘密” 的问题。服务器直接发送密钥,无法做到这一点。
第一个、也是最致命的原因:性能问题
非对称加密和对称加密在计算复杂度上,完全不是一个数量级的。
- 非对称加密是“计算密集型”的:它的安全性建立在复杂的数学难题之上,比如大数质因数分解(RSA)或离散对数问题(Diffie-Hellman)。其加密和解密过程涉及到大量的、高次方的模幂运算,这对CPU的计算能力消耗巨大。
- 对称加密是“效率密集型”的:它的操作要简单得多,主要是基于一些高效的位运算,比如替换(Substitution)、置换(Permutation)和异或(XOR)等。这些操作在硬件层面就能得到极高的支持,速度飞快。
为了更形象地说明这个性能差距:
我们可以把 非对称加密 想象成一个 银行金库的大门。它极其安全,但开启和关闭它需要复杂的流程(多把钥匙、密码、时间锁),非常耗时耗力。
而 对称加密 就像我们家里的 门锁。它足够安全,能够满足日常需求,而且开锁和关锁都只需要一瞬间。
在HTTPS通信中,我们要传输的是大量的网页内容、图片、视频等数据。
- 如果全程使用非对称加密(银行金库大门),就相当于我们每传递一点点数据,都要去费力地开启和关闭一次金库大门。这会让网页加载变得极其缓慢,CPU负载飙升,用户体验将是一场灾难。
- 而正确的做法是,我们只在最开始的时候,用一次“银行金库大门”(非对称加密),来安全地把“家里门锁的钥匙”(对称密钥)递交给对方。一旦对方拿到钥匙,我们马上锁上金库大<i>门</i>,后续所有的数据交流,都通过高效的 “家里门锁”(对称加密) 来进行。
第二个实际的限制:加密数据长度
除了性能问题之外,大多数非对称加密算法,由于其数学原理的限制,能够加密的数据长度是有限的。
例如,对于RSA加密,它一次能加密的数据块大小,不能超过其密钥的长度。比如一个2048位的RSA密钥,它一次最多只能加密大约245字节的数据(还需要减去一些填充padding)。
如果要用它来加密一个几MB的图片,我们就必须把图片分割成无数个小的数据块,对每一个小块都进行一次非对称加解密操作。这不仅进一步加剧了性能的噩梦,也使得整个实现过程变得异常复杂和低效。
而对称加密算法(如AES)则没有这个限制,它可以配合不同的分组模式(如CBC、GCM)轻松地加密任意长度的数据流。
S - 单一职责原则 (Single Responsibility Principle, SRP)
- 核心思想:一个类或模块应该有且只有一个引起它变化的原因。
- 怎么表达:通俗地讲,就是一个类只做一件专一的事情。如果一个类承担了太多的职责,那么任何一个职责的变化都可能影响到其他职责的实现,这会让代码变得脆弱和难以维护。
- Android 中的例子:一个 ImageLoader 类,它的职责就应该是加载和显示图片。我们不应该把图片压缩、添加滤镜、或者保存到本地这些功能都塞到这个类里。压缩和滤镜处理应该是独立的 ImageProcessor 的职责,保存文件应该是 FileSaver 的职责。
O - 开闭原则 (Open/Closed Principle, OCP)
- 核心思想:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。
- 怎么表达:当我们想增加一个新功能时,我们应该通过增加新代码的方式来实现,而不是去修改已经写好并且测试通过的旧代码。这是保证软件稳定性的基石。
- Android 中的例子:RecyclerView.Adapter 就是一个很好的例子。我们想展示一种新的列表项布局(ViewHolder)时,只需要新建一个 ViewHolder 类,并在 Adapter 中增加一个 viewType 的逻辑分支(这是扩展),而不需要去修改 RecyclerView 这个类本身(它是关闭的)。
L - 里氏替换原则 (Liskov Substitution Principle, LSP)
- 核心思想:所有引用基类的地方,必须能透明地使用其子类的对象,而程序行为不发生改变。
- 怎么表达:简单说,子类必须能够完全替代它的父类。子类可以有自己的新行为,但绝不能改变父类已经定义好的行为。如果子类替换父类后,程序的行为出现了错误,那就违反了这个原则。这通常意味着我们的继承体系设计出了问题。
- 例子:一个经典的例子是“正方形不是长方形”。如果 Square 类继承自 Rectangle 类,并且重写了 setWidth 方法,使其在改变宽度的同时也改变了高度,这就破坏了 Rectangle 父类的约定(改变宽度不应影响高度),违反了里氏替换原则。
I - 接口隔离原则 (Interface Segregation Principle, ISP)
- 核心思想:客户端不应该被强迫依赖它不使用的方法。一个类对另一个类的依赖应该建立在最小的接口上。
- 怎么表达:我们应该设计很多专一的小接口,而不是一个庞大臃肿的“万能”接口。如果一个接口包含了太多方法,那么实现它的类就必须实现所有方法,即使其中很多它根本用不上。
- Android 中的例子:Android 的事件监听器设计就是典范。它没有一个巨大的 ViewListener 接口,而是拆分成了 OnClickListener, OnLongClickListener, OnTouchListener 等等。我的 Button 只需要关心点击,那么我只实现 OnClickListener 就好了,完全不用理会其他事件。
D - 依赖倒置原则 (Dependency Inversion Principle, DIP)
- 核心思想:高层模块不应该依赖于低层模块,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖于抽象。
- 怎么表达:这不是简单的“依赖反转”,而是“依赖于抽象”。意思是,我们的业务逻辑(高层)不应该直接依赖于具体的实现(低层),比如直接去 new 一个网络请求库的实例。而应该依赖于一个我们自己定义的接口(抽象),然后通过依赖注入(DI)的方式,把具体的实现“注入”进来。
- Android 中的例子:这是 MVVM 架构的核心。ViewModel (高层) 不会直接依赖 Retrofit (低层),而是依赖一个 UserRepository 接口。在实际运行时,我们注入一个使用 Retrofit 实现的 UserRepositoryImpl。这样做最大的好处是,在做单元测试时,我们可以轻松地注入一个 MockUserRepository 来模拟数据,实现了完美的解耦。
除了 SOLID 五大原则,还有两个原则也非常重要:
6. 迪米特法则 (Law of Demeter, LoD) / 最少知识原则
- 核心思想:一个对象应该对其他对象有最少的了解。一个类应该只和它的“直接朋友”交谈。
- 怎么表达:不要写 a.getB().getC().doSomething() 这样的“链式调用”。这暴露了太多内部实现细节,使得 a 的代码和 B、C 的实现都紧紧地耦合在了一起。任何一环的变化都可能导致 a 的代码需要修改。正确的做法应该是在 A 类中封装一个方法,由 A 内部去完成和 B、C 的复杂交互。
7. 组合优于继承原则 (Composition Over Inheritance)
- 核心思想:尽量使用组合/聚合的方式,而不是使用继承来达到代码复用的目的。
- 怎么表达:继承是一种非常强的耦合关系(“白盒复用”),父类的任何变化都会影响到所有子类,而且 Java 的单继承也限制了其灵活性。而组合(在一个类中持有另一个类的实例)是一种更松散的耦合关系(“黑盒复用”),它更加灵活,可以在运行时动态地改变行为。
- 例子:如果想给一个 Button 增加日志和统计功能,与其创建 LogButton 和 AnalyticsButton 两个子类(那如果想同时拥有两个功能呢?),不如创建一个 Logger 类和一个 AnalyticsTracker 类,然后在我们的 CustomButton 中持有这两个类的实例,在需要的时候调用它们的方法。
核心线程和非核心线程,是 ThreadPoolExecutor 为了实现 资源高效利用 和 高并发响应能力 而设计的两种不同“工种”的线程。它们最本质的区别,在于它们的 创建时机 和 生命周期策略。
为了讲清楚这个区别,我喜欢用一个 公司招聘 的比喻:
公司招聘比喻
- 核心线程 (corePoolSize):可以看作是公司的 “正式员工”。
- 非核心线程 (maximumPoolSize - corePoolSize):可以看作是公司为了应对项目高峰期而招聘的 “临时工”或“外包人员”。
- 任务队列 (workQueue):可以看作是公司的 “任务待办池”。
现在,我来详细对比一下这两种“员工”的区别:
1. 创建时机 (Hiring Trigger)
这是两者最关键的区别,决定了它们什么时候会登场工作。
- 只要有新任务来了,并且当前“正式员工”的数量还没达到 corePoolSize 的编制上限,公司就会 立刻招聘一个新的正式员工 来处理这个任务,哪怕其他老员工现在正闲着。
- 一句话总结:优先招聘“正式员工”,直到编制满了为止。
- 所有“正式员工”(核心线程)都在忙,没一个闲着。
- 公司的“任务待办池”(工作队列)也已经 完全满了,新任务塞不进去了。
- 只有在这两个条件都满足的情况下,为了不让新任务被拒绝,公司才会开始招聘“临时工”来救火。
- 一句话总结:只有在正式员工和任务池都饱和后,才开始招聘“临时工”。
2. 生命周期与存活策略 (Lifecycle & Survival Strategy)
这决定了它们能“活”多久。
- 他们是公司的基石。默认情况下,一旦被创建,即使长时间没有任务可做,他们也 不会被解雇,会一直存活在线程池中,随时准备接收新任务。
- 这保证了线程池能以最快的速度响应常规任务,因为不需要重新创建线程。
- 例外:可以通过调用 allowCoreThreadTimeOut(true) 方法,让核心线程也拥有“末位淘汰”的机制,但这在实际中用得比较少。
- 他们是临时性的。当一个“临时工”完成了任务,并且在指定的 keepAliveTime (存活时间)内都没有接到新的任务,他就会被 自动“解雇”(线程被销毁)。
- 这样做的目的是为了在系统负载降低后,及时回收这些多余的线程资源,避免不必要的开销。
3. 核心作用与设计目的 (Core Purpose)
- 目的:处理 常规、稳定 的任务负载,保证线程池有基本的、即时的响应能力。它们是线程池性能的 基线。
- 目的:处理 突发、高并发 的任务负载,提供系统的 弹性 和 峰值处理能力。它们是线程池性能的 上限。
总结
总的来说,核心线程和非核心线程的区别,是 ThreadPoolExecutor 精心设计的一套 弹性伸缩机制 的体现:
- 核心线程 保证了 效率,它们是常驻内存的“快速反应部队”。
- 非核心线程 保证了 弹性,它们是应对流量洪峰的“后备支援部队”。
关于“创建一个对象(new Object())的步骤”,这个问题看似简单,但它背后揭示了 JVM(或 Android 的 ART 虚拟机)一系列严谨而有序的内部工作流程。我的理解是,这个过程可以分为 五个核心阶段。
为了让这个过程更形象,我们可以把它比喻成 “按照图纸盖一栋房子”。
比喻:盖房子
现在,我来详细解释一下这五个阶段具体发生了什么:
第 1 步:类加载检查 (找图纸)
当虚拟机遇到一条 new 指令时,它首先会去 检查 这个指令的参数(也就是类名)是否能在运行时常量池中定位到一个类的符号引用。
- 检查这个类的 Class 对象是否已经被加载、解析和初始化过了。
- 如果没有,虚拟机就必须先执行相应的 类加载过程(包括加载、链接、初始化)。这一步就相当于,盖房子前,我们得先确保已经拿到了并且看懂了房子的设计图纸。
第 2 步:内存分配 (划地皮)
一旦类加载完成,虚拟机接下来就要在 Java 堆(Heap) 中为这个新对象分配内存。
- 需要多大内存? 对象所需内存的大小在类加载完成后就已经完全确定了。
- 如何分配? 分配内存的方式有两种,取决于堆内存是否规整:
- 指针碰撞 (Bump the Pointer):如果Java堆是绝对规整的(比如使用带有整理过程的GC算法),所有用过的内存放一边,没用过的放另一边。分配时,只需把中间的指针向空闲那边移动对象大小的距离即可。
- 空闲列表 (Free List):如果堆内存是碎片化的,虚拟机就需要维护一个列表,记录哪些内存块是可用的。分配时,就从列表里找一块足够大的空间划分给对象实例。
- 并发问题:在多线程环境下,为了避免给对象分配内存时出现冲突,通常会采用 CAS(Compare-And-Swap) 或者 TLAB (Thread Local Allocation Buffer) 的方式来保证线程安全。TLAB是为每个线程在堆中预先分配一小块私有内存,这样线程在自己的“小金库”里分配内存时就不需要加锁了。
第 3 步:内存空间零值初始化 (打地基和毛坯房)
内存分配完成后,虚拟机会将分配到的这块内存空间(不包括对象头)都初始化为 零值。
- 比如,所有的数值类型(int, double)都设置为 0 或 0.0,boolean 设置为 false,引用类型设置为 null。
- 这一步至关重要。它保证了对象的实例字段,即使在我们的Java代码中没有显式地赋初始值,也可以直接使用,程序访问到的是对应类型的零值,而不是一块随机的内存数据。
第 4 步:设置对象头 (房产证登记)
零值初始化之后,虚拟机需要对对象进行一些必要的设置,这些信息都存放在 对象头(Object Header) 中。
- Mark Word: 存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁等。
- 类型指针 (Klass Pointer): 指向这个对象所属的类的元数据(Class 对象)的指针,虚拟机通过它来确定这个对象是哪个类的实例。
第 5 步:执行 <init> 构造方法 (精装修)
到上一步为止,从虚拟机的视角看,一个对象已经产生了。但从我们Java程序的视角看,对象创建才刚刚开始——虚拟机会开始执行对象的 <init>() 方法,也就是我们编写的 构造函数。
- 这个 <init> 方法会按照我们代码的意图,对对象进行真正的初始化。这个过程包括:
- 调用父类的构造方法 super()。
- 为成员变量进行显式赋值(比如 private String name = "Hello"; 这种赋值操作)。
- 执行构造方法体内的代码。
当 <init>() 方法执行完毕后,一个真正可用的、完整的对象才算创建完成,然后 new 指令的执行结果(也就是这个对象在堆内存中的地址引用)会被压入操作数栈顶。
关于类加载器(ClassLoader),我的理解是,它不仅仅是 JVM 和 ART 的一个基础组件,更是 支撑起 Java 和 Android 平台动态性的核心基石。它是我们理解很多高级技术(比如热修复、插件化)的钥匙。
我将从 “它是什么”、“它是如何工作的”、“在Android中它有哪些” 以及 “它有什么重要应用” 这四个方面来展开我的回答。
1. 它是什么?(What is it?)
首先,类加载器的 核心职责 就如其名:负责将 .class 文件(在Android中是 .dex 文件里的 classes.dex)的二进制数据,加载到内存中,并最终转换成一个我们可以使用的 java.lang.Class 对象。
你可以把它想象成一个 图书馆的管理员。当程序需要使用某个类时,就会告诉这个管理员:“我需要《java.lang.String》这本书”。管理员就会根据这个名字(类的全限定名),去书库(文件系统)里找到对应的文件,把它拿出来,解析内容,然后放到一个程序可以随时访问的“阅读区”(内存的方法区/Metaspace)里。
2. 它是如何工作的?(The Parent Delegation Model)
类加载器的工作机制,最核心、最必须理解的就是 双亲委派模型 (Parent Delegation Model)。
这个模型不是一个强制的规范,但是 Java 体系推荐的最佳实践。它的工作流程可以概括为 “层层上报,自上而下尝试加载”。
- 当一个类加载器(比如 AppClassLoader)收到一个加载类的请求时,它 不会 自己立刻去尝试加载。
- 相反,它会先把这个请求 委派给它的父加载器(ExtClassLoader)去处理。
- 父加载器收到请求后,也不会自己加载,而是继续 向上委派 给它的父加载器(BootstrapClassLoader)。
- 这个委派链会一直到达最顶层的 启动类加载器 (Bootstrap ClassLoader)。
- 然后,加载过程会 从顶层开始,自上而下地尝试。顶层的 BootstrapClassLoader 首先尝试加载,如果它能加载(比如是java.lang.String这种核心库),就成功返回。如果它加载不了,就告诉子加载器:“我搞不定,你来吧”。
- 子加载器收到父加载器的“失败”通知后,才会自己去尝试加载。如果它也加载不了,就再通知它的子加载器。
- 只有当 所有的父加载器都无法加载 这个类时,最初发起请求的那个类加载器才会自己动手去加载。
- 保证唯一性,避免重复加载:确保了任何一个类,在程序中都只有一份由同一个类加载器加载的 Class 对象。
- 保证安全:这是最重要的目的。它防止了核心API库被恶意篡改。比如,我们自己写一个java.lang.String类,想替代系统的String。在双亲委派模型下,加载请求最终会到达顶层的 BootstrapClassLoader,它会优先加载JDK自带的、安全的String类,我们自己写的那个恶意版本根本没有机会被加载。
3. 在 Android 中它有哪些?
Java 有一套标准的类加载器,但在 Android 中,由于虚拟机和文件格式(DEX)的不同,实现也略有差异。我们主要关心这两个:
- 这是 Android 系统 默认使用 的类加载器。
- 它只能加载 系统已经安装的 APK 的 dex 文件,也就是 /data/app 目录下的文件。你可以把它理解为“体制内”的加载器,只能加载官方认可路径下的代码。
- 它非常灵活,可以从 任意文件路径 加载包含 dex 文件的 .jar, .apk 或 .dex 文件。
- 它就像一个“万能加载器”,为我们从外部(比如服务器下载)加载代码提供了可能。
4. 它有什么重要应用?(The "Why it Matters" part)
理解了 DexClassLoader 的能力,我们就能明白 Android 中两大核心高级技术的原理:
- 当线上 App 出现一个 Bug,我们可以将修复好的类打包成一个补丁 patch.dex 文件,下发给客户端。
- 客户端 App 在启动时,使用 DexClassLoader 去加载这个 patch.dex 文件。
- 然后,通过 Java 的反射机制,将这个补丁 dex 文件对应的 dexElements 数组,“注入”到系统默认的 PathClassLoader 的 dexElements 数组的最前面。
- 这样,当 App 再次需要加载那个有 Bug 的类时,根据类加载器“从前到后”的查找顺序,它会 优先找到并加载补丁包里那个已经修复好的类,从而实现了在线修复 Bug 的目的,而有 Bug 的旧类则不会被加载。
- 插件化 (Plugin Architecture):
- 原理类似。主 App (宿主) 可以在完全不了解插件 APK 内容的情况下,通过 DexClassLoader 去加载一个独立的插件 APK 文件。
- 加载后,宿主就可以创建插件 APK 里的 Activity 实例、调用它的方法、加载它的资源,从而实现像安装独立 App 一样的动态功能扩展。
总结
总的来说,类加载器是 Java 虚拟机和 Android ART 的基础,而它所遵循的 双亲委派模型 是其设计的精髓,保证了系统的安全和稳定。在 Android 平台上,PathClassLoader 和 DexClassLoader 的分工,尤其是 DexClassLoader 的灵活性,为 热修复 和 插件化 这两大动态化技术提供了最底层的、坚实的理论和实践基础。
ViewPager 和 ViewPager2 是 Android 开发中实现 “可滑动页面” 效果最核心的两个组件。ViewPager2 是官方对 ViewPager 的一次 彻底重构和现代化升级,理解它们的区别和底层原理,非常能体现我们对 Android UI 组件演进的认知。
我将从 ViewPager 的实现和痛点、ViewPager2 的革新和优势,以及 两者最核心的底层区别 这三个方面来展开回答。
1. 经典的 ViewPager (The Original)
ViewPager 是一个存在了很久的、非常经典的容器控件。
- 它的作用:允许用户通过水平手势,像翻书一样,左右滑动来切换页面(通常是 Fragment)。它经常和 TabLayout 配合使用,实现顶部 Tab 联动的效果。
- 继承自 ViewGroup: ViewPager 是一个自定义的 ViewGroup,这意味着它内部 自己实现了一整套复杂的逻辑,包括:子 View 的布局、触摸事件的拦截与处理、滚动位置的计算、以及页面切换的动画效果。
- Adapter 模式: 它的数据由一个 PagerAdapter 来提供。我们最常用的 PagerAdapter 有两种实现:
- FragmentPagerAdapter: 这个 Adapter 会把每一个 Fragment 实例都 保存在内存中。当页面滑出屏幕时,它仅仅是 detach(分离)了 Fragment 的 View,但 Fragment 实例本身并没有被销毁。因此,它只适合用于承载 数量较少、且固定的 页面,比如 App 主界面的几个 Tab。
- FragmentStatePagerAdapter: 这个 Adapter 在内存管理上更智能。当页面滑出屏幕足够远时,它会调用 Fragment 的 onSaveInstanceState() 保存其状态,然后 彻底销毁 这个 Fragment 实例。当需要再次显示时,再根据保存的状态重新创建。因此,它非常适合用于承载 数量很多、或者数量不确定 的页面,比如相册浏览器。
- 它的主要痛点 (Why it needed a replacement):
- 只支持水平滑动:无法实现垂直方向的滑动切换,这是一个硬伤。
- notifyDataSetChanged() 的问题:它的刷新机制比较“笨拙”,notifyDataSetChanged() 无法精确地控制页面的更新,经常需要开发者使用一些 hack 的手段来强制刷新,非常不可靠。
- 不支持 DiffUtil: 无法实现高效、精细化的数据刷新和动画。
- 难以处理动态页面增删:在列表头部或中间增删页面时,常常会出现状态错乱的问题。
- 不支持 RTL (Right-to-Left):在需要支持阿拉伯语等从右到左布局的地区时,适配工作非常困难。
2. 现代化的 ViewPager2 (The Successor)
ViewPager2 是 Google 在 Jetpack 套件中推出的、用于完全取代 ViewPager 的新组件。
- 支持垂直滑动:通过一个 orientation 属性就可以轻松切换。
- 全面支持 RTL 布局:自动适配,无需开发者关心。
- 可靠的页面刷新:完全基于 RecyclerView 的刷新机制,notifyDataSetChanged() 工作得非常好,并且原生支持 DiffUtil,可以实现高效的定向刷新和漂亮的动画。
- 完美支持动态增删页面:可以像操作 RecyclerView 列表一样,轻松地在任何位置增删改查页面。
- 组合优于继承:内部封装了一个 RecyclerView
- 这可以说是 ViewPager2 与 ViewPager 最本质的区别。ViewPager2 不再是一个复杂的自定义 ViewGroup,它的内部 持有一个横向或纵向的 RecyclerView 作为其核心实现。
- 它将所有的页面都当作 RecyclerView 的 Item 来处理。
- 滑动切换的实现:
- 它并不是自己重新发明轮子去写滑动逻辑,而是给内部的 RecyclerView 附加了一个 PagerSnapHelper。PagerSnapHelper 的作用就是让 RecyclerView 在滑动后,能自动像翻页一样,精确地停在某一个 Item 的起始位置,从而实现了 ViewPager 的页面切换效果。
- 全新的 Adapter:
- 它废弃了 PagerAdapter,转而直接使用 RecyclerView.Adapter。
- 对于 Fragment 页面,官方提供了一个新的、专门为此设计的 FragmentStateAdapter (注意,和 ViewPager 的 FragmentStatePagerAdapter 名字不同)。这个新的 Adapter 在 Fragment 的生命周期管理和状态保存上,比旧的实现更加健壮和高效。
总结与对比
| 特性 |
ViewPager |
ViewPager2 |
| 底层实现 |
自定义 ViewGroup,自己实现所有逻辑 |
内部封装了一个 RecyclerView,复用其成熟的机制 |
| 滑动方向 |
仅支持水平 |
支持水平和垂直 |
| Adapter |
PagerAdapter (FragmentPagerAdapter / FragmentStatePagerAdapter) |
RecyclerView.Adapter (专门提供了 FragmentStateAdapter) |
| 数据刷新 |
notifyDataSetChanged() 不可靠 |
非常可靠,且支持 DiffUtil |
| 动态页面 |
支持不佳,易出错 |
完美支持 |
| RTL 支持 |
不支持 |
原生支持 |
| Jetpack 集成 |
属于旧的支持库 |
Jetpack 核心组件,与 Lifecycle, LiveData 等集成更好 |
Kotlin 协程(Coroutines)是目前 Google 官方首推的 Android 异步编程解决方案。我的理解是,它不是一个新东西去替代线程,而是一个 更上层的、用于简化和管理异步任务的框架。它让我们能够用 看似同步、顺序执行 的代码,来写出 实际上是非阻塞的、高性能 的异步逻辑。
要讲清楚协程,我主要从 “它解决了什么痛点”、“它的核心概念” 和 “在 Android 中如何最佳实践” 这三个方面来展开。
1. 它解决了什么痛点?(The "Why")
在协程出现之前,Android 的异步编程主要有几个痛点:
- 回调地狱 (Callback Hell):为了在子线程完成任务后通知主线程更新UI,我们经常需要一层层地嵌套回调函数,代码会形成一个“金字塔”结构,非常难以阅读和维护。
- 生命周期管理复杂:像 AsyncTask 或者我们自己 new Thread(),如果异步任务在 Activity 或 Fragment 销毁后还在运行,并且持有其引用,就很容易导致 内存泄漏。我们需要写很多模板代码在 onDestroy 里去手动取消任务。
- 线程切换繁琐:我们经常需要在主线程和子线程之间来回切换,传统的 Handler 机制虽然可行,但代码写起来也比较分散。
- 学习曲线陡峭:像 RxJava 这样的响应式编程框架虽然功能强大,但其操作符众多,学习和理解成本非常高。
协程的核心目标,就是用一种更简单、更安全、更直观的方式来解决以上所有问题。
2. 它的核心概念 (The Building Blocks)
要理解协程,必须理解它的几个核心组成部分:
- 这是协程的 魔法 所在。一个函数如果被 suspend 关键字修饰,就意味着它是一个 “可挂起” 的函数。
- “挂起”不等于“阻塞”。当一个协程执行到一个 suspend 函数时(比如一个网络请求),它会 暂停自己的执行,并把当前的线程资源释放出来,让线程可以去干别的事情(比如响应用户点击)。当 suspend 函数的结果准备好后,协程会在原来的线程或指定的线程上 “恢复” 执行。
- 整个过程,线程从未被阻塞,这就是协程轻量高效的关键。
- 这是协程的 “生命周期管理器”。每一个协程都必须在一个 Scope 内启动。
- 它引入了 结构化并发 (Structured Concurrency) 的概念。简单来说,Scope 就像一个“父级任务”,它会追踪和管理所有在它内部启动的子协程。
- 最关键的特性:当 Scope 被取消时,它内部的所有子协程都会被自动取消。这完美地解决了内存泄漏问题。在 Android 中,我们最常用的就是 Jetpack 提供的:
- viewModelScope: 绑定到 ViewModel 的生命周期,当 ViewModel 被销毁时自动取消。
- lifecycleScope: 绑定到 Activity 或 Fragment 的生命周期。
- 这是协程的 “线程调度中心”。它决定了协程应该在哪个线程或线程池上执行。
- Dispatchers.Main: Android 主线程。专门用于执行所有 UI 相关的操作。
- Dispatchers.IO: IO 线程池。专门为执行网络请求、数据库读写、文件操作等耗时的 IO 密集型任务优化。
- Dispatchers.Default: CPU 密集型线程池。适合执行非常消耗 CPU 的计算任务,比如对一个巨大的列表进行排序、解析复杂的 JSON 等。
3. 在 Android 中的最佳实践 (The "How-To")
理解了上面的概念后,在 Android 中的应用模式就非常清晰了:
- 启动协程:我们通常使用 CoroutineScope 的扩展函数 launch 或 async 来启动一个协程。
- launch:用于“发起并忘记”的任务,它不返回结果。最常用于启动一个数据请求或保存操作。
- async:用于需要返回结果的异步任务。它会返回一个 Deferred 对象,我们可以通过调用它的 .await() 方法(这是一个 suspend 方法)来获取最终结果。
- 最经典的线程切换模式:使用 withContext() 函数。
// 在 ViewModel 中
fun fetchUserData()
// 3. withContext 执行完毕后,自动切回主线程
// UI 可以用获取到的 user 数据更新界面
showUserData(user)
} catch (e: Exception) {
// 同样在主线程处理错误
showError(e)
} finally {
// 同样在主线程隐藏加载动画
hideLoading()
}
}
}
这段代码完美地展示了协程的优势:
- 代码是顺序的:从上到下,没有回调,逻辑一目了然。
- 线程安全:withContext 保证了耗时操作在后台,UI 操作在主线程。
- 生命周期安全:viewModelScope 保证了当用户离开这个页面、ViewModel 被销毁时,这个网络请求会自动被取消,绝不会发生内存泄漏。
最根本的区别在于,线程是操作系统层面的概念,由 OS 内核进行调度;而协程是程序/语言层面的概念,由我们自己的代码和 Kotlin 运行时来进行调度。
这个根本区别,引出了一系列重要的差异。为了让这些差异更清晰,我喜欢用一个 “厨房和厨师” 的比喻。
比喻:一个厨房,多个厨师 vs 一个厨师,多个菜谱
- 就像一个厨房里,雇佣了多个厨师(Threads)。每个厨师都是一个独立的、重量级的资源(需要占用独立的内存栈、由操作系统管理)。
- 当一个厨师需要等待食材解冻时(阻塞 I/O,比如等待网络响应),这个厨师就 只能站在原地干等,什么也做不了。他占着厨房的位置,却不产生任何价值。这就是 线程阻塞。
- 厨房老板(操作系统)会看着哪个厨师在偷懒,强行让他休息,换另一个厨师来干活。这种切换(上下文切换)的成本很高,因为需要记录每个厨师做到哪一步了,并进行交接。
- 就像厨房里只雇佣了一个(或少数几个)非常高效的厨师(Thread(s)),但他同时在处理多个菜谱(Coroutines)。
- 当厨师在处理 A 菜谱时,发现需要等待食材解冻(挂起 I/O),他 不会傻等。他会把 A 菜谱当前的进度做好标记(挂起),然后立刻转头去做 B 菜谱(切菜、备料)。
- 当解冻完成的提示音响起时(I/O 结果返回),他又能立刻拿起 A 菜谱,从刚才标记的地方继续做下去(恢复)。
- 在这个过程中,这位厨师(线程)一刻也没有闲着,一直在不同的菜谱(协程)之间高效切换。这种切换是由厨师自己控制的(程序控制),成本极低,就像转个身一样简单。
基于这个比喻,我们可以总结出以下几个核心技术区别:
| 特性 |
线程 (Thread) |
协程 (Coroutine) |
| 1. 抽象级别 |
操作系统级,是 OS 调度的最小单位。 |
语言/程序级,是逻辑上的任务单元,可以看作是“轻量级线程”。 |
| 2. 资源开销 |
重 (Heavyweight)。每个线程都有自己的栈空间,创建和销毁成本高。 |
轻 (Lightweight)。只是一个对象,多个协程可以共享单个线程的栈,开销极小。 |
| 3. 调度方式 |
抢占式 (Preemptive)。由操作系统强制切换,程序无法精确控制。 |
协作式 (Cooperative)。协程在 suspend 挂起点才会主动出让控制权。 |
| 4. 核心操作 |
阻塞 (Blocking)。一个线程被阻塞,它占用的系统资源就无法被利用。 |
挂起 (Suspending)。一个协程被挂起,它占用的线程资源会被 释放,可以去执行其他协程。 |
| 5. 上下文切换成本 |
高。需要在用户态和内核态之间切换。 |
极低。完全在用户态内部完成,本质上是函数调用的切换。 |
| 6. 数量级 |
系统能创建的线程数是有限的(几千个)。 |
可以在单个线程上轻松创建 成千上万甚至上百万个 协程。 |
协程不是要取代线程,而是要更好地利用线程
这是一个非常重要的认知。协程必须运行在线程之上。
- 协程就像是 “工作计划”,而线程是 “执行计划的工人”。
- Kotlin 协程的 Dispatchers 就扮演了线程池的角色,它把大量的协程(工作计划),智能地分配给一小部分线程(工人)去高效执行。
我的理解是:协程的多任务调度,并非由操作系统内核完成,而是由一个非常聪明的、位于 Kotlin 运行时库中的用户态调度器(CoroutineScheduler)来完成的。它之所以能高效地调度海量任务,核心在于 两个关键机制的完美协作:
- 编译器的“魔法”:将 suspend 函数转换成状态机。
- 调度器(Dispatcher)的“智慧”:一个高效的、带任务队列的线程池。
为了让这个过程更清晰,我喜欢用一个 “超级能干的个人助理” 的比喻来解释。
比喻:一个助理,处理上百个待办事项
- 一个助理 (A Single Thread):是你雇佣的、真正干活的人。你只雇了少数几个助理。
- 一个待办事项 (A Coroutine):是你派发的一个具体任务,比如“写一份报告”、“预订一张机票”、“整理发票”等等。你有成百上千个这样的任务。
- 助理的“待办清单” (Dispatcher's Task Queue):是一个中心化的任务列表,所有任务都在这里排队。
现在,我们来看看协程的调度流程是如何运作的:
第 1 步:编译器的魔法 —— 将任务“切片” (State Machine Transformation)
这是协程能够“挂起”的根本原因。当我们写下一个 suspend 函数时,Kotlin 编译器在背后做了一件非常重要的事情:它把我们写的看似顺序执行的代码,转换成了一个 状态机(State Machine)。
- Continuation 对象:每一次挂起和恢复,都伴随着一个 Continuation 对象。你可以把它理解成这个任务的 “进度书签” 或者 “存档点”。它包含了执行到哪里、局部变量是什么等等所有恢复任务所需的信息。
- 在我们的比喻里:编译器就像一个流程专家,他把你派发的“写一份报告”这个大任务,预先分解成了多个小步骤,并为每个步骤都准备好了可以做标记的便签(Continuation)。
第 2 步:调度器的角色 —— “助理”和他的“待办清单” (Dispatcher & Task Queue)
我们最常用的 Dispatchers.IO 或 Dispatchers.Default,背后都是一个非常高效的 CoroutineScheduler。它维护了一个或多个 任务队列 和一个 共享的线程池。
- 当我们调用 launch { ... } 启动一个协程时,这个协程的代码块会被包装成一个 Runnable 任务(内部持有 Continuation 对象)。
- 然后,这个 Runnable 任务被提交到 Dispatcher 的 任务队列 中。
- 在我们的比喻里:你对助理说“开始写报告吧”(launch),助理就把“写报告-步骤1”这个事项,写在了他的“待办清单”上。
第 3 步:协作流程 —— 挂起与恢复的闭环 (The Suspend/Resume Loop)
这是整个调度过程最精彩的部分:
- 领取任务并执行:助理(一个线程)从“待办清单”(任务队列)的头部取出一个任务,比如“写报告-步骤1”,然后开始执行。
- 遇到挂起点:报告写到一半,发现需要等待市场部提供数据(比如一个 suspen fun networkRequest())。
- 关键时刻来了! 助理 不会 站在原地傻等电话(阻塞)。
- 他会拿出那个便签(Continuation),在上面记录下“报告写到第3页,正在等市场部数据”,然后把这份报告 暂时归档(挂起)。
- 最重要的是,做完这一切后,助理立刻转身,回到“待办清单”前,领取下一个任务(比如“预订机票”)。他(线程)一刻也没有闲下来!
- 任务完成与恢复:过了一会儿,市场部的电话来了,数据准备好了(网络请求的回调被触发)。
- 这个回调函数会做一件事:它会把“写报告”这个任务,连同那个带有进度的便签(Continuation),重新放回到“待办清单”的队尾,并标记为“可以继续”。
- 在我们的比喻里:接电话的人(可能是另一个助理,也可能是他自己)会把归档的报告拿出来,贴上一张新便签“市场部数据已拿到,可以继续写”,然后把它放回“待-办清单”。
- 继续执行:某个时刻,某个空闲下来的助理(任意一个线程)又从“待办清单”里拿到了这份“可以继续的报告”。他看到便签上的记录,就能准确地从上次中断的地方,无缝地继续写下去。
总结
所以,协程的多任务调度,其核心就是 将一个大的、可能耗时的任务,通过 suspend 机制,切分成一个个小的、不阻塞的执行片段。
- 协程 (Coroutine) 负责逻辑的流转和状态的保存(我是谁,我从哪里来,要到哪里去)。
- 调度器 (Dispatcher) 负责物理的执行,它像一个高效的指挥官,不断地从任务队列中取出“可执行”的片段,分派给有限的线程去运行。
第 1 步:理解卡顿的根本原因 —— 16ms 原则
首先,我们要明白,人眼感觉到的“流畅”画面,需要屏幕刷新率达到 60fps (每秒60帧)。这意味着,每一帧画面的渲染时间,必须在 16ms (1000ms / 60) 之内完成。
Android 系统的渲染机制,是由一个叫做 Vsync (垂直同步) 的信号来驱动的。每隔 16ms,Vsync 信号就会触发一次 UI 的重绘。
卡顿的本质就是:我们的 App 在主线程(UI 线程)上做了太多的耗时操作,导致某一个 16ms 的时间周期内,系统没有来得及准备好下一帧的数据。 这时,系统为了等待数据,就只能选择 “掉帧”,也就是继续显示上一帧的画面。当掉帧连续发生时,用户在视觉上就会感觉到明显的卡顿。
所以,优化的核心目标就一个:想尽一切办法,保证主线程在 16ms 内轻装上阵,只做它必须做的核心工作(UI 计算、绘制、事件响应)。
第 2 步:系统性的优化策略
我会从以下几个最常见的“耗时大户”入手,进行优化:
1. UI 布局与绘制优化 (Layout & Drawing)
这是最直观、最常见的卡顿来源,尤其是在复杂的 RecyclerView 列表中。
- 问题:布局层级越深,onMeasure 和 onLayout 的过程就越耗时,因为每个 ViewGroup 都要层层传递测量和布局的请求。
- 首选 ConstraintLayout:它可以创建非常扁平、高效的视图层级,用一层就实现 LinearLayout 或 RelativeLayout 多层嵌套才能实现的效果。
- 使用 <merge> 标签:在自定义 View 或 include 布局时,使用 <merge> 可以帮助减少一个不必要的父 ViewGroup 节点。
- 使用 <ViewStub>:对于那些不常用、但在特定情况下才需要显示的布局(如错误提示、加载动画),使用 ViewStub 进行懒加载。它是一个轻量级的、没有尺寸的 View,只有在被 inflate 时,才会把真正的布局加载进来。
- 问题:过度绘制指的是在同一像素点上,一个渲染周期内绘制了多次。比如,Activity 有一个白色背景,CardView 也有一个白色背景,里面的 TextView 还有一个白色背景,这就造成了不必要的 GPU 资源浪费。
- 打开开发者选项中的“调试 GPU 过度绘制”,通过颜色来识别过度绘制的区域(蓝色是1次,绿色2次,红色3次以上)。
- 移除不必要的 background。例如,如果 RecyclerView 的每个 Item 都有背景,那么 RecyclerView 本身可能就不需要背景了。
2. 代码逻辑优化 (特别是 Adapter 中)
RecyclerView 的 onBindViewHolder 方法在列表滑动时会被频繁调用,是优化的重中之重。
- 绝不在主线程执行 I/O 操作:这是铁律。网络请求、数据库读写、文件操作等,必须切换到后台线程(首选 Kotlin 协程的 Dispatchers.IO)。
- 只做数据绑定:这个方法应该只做“把数据设置到 View 上”这一件事。
- 不要创建新对象:避免在这里 new 任何对象,比如 new Paint(), new OnClickListener()。监听器可以在 onCreateViewHolder 中创建并 setTag,或者使用更高效的 lambda 写法。
- 预计算和缓存:任何复杂的逻辑判断、文本格式化、颜色计算等,都应该在 数据层(比如 ViewModel)提前处理好,onBindViewHolder 只负责接收最终结果。
- 绝对禁止 使用 notifyDataSetChanged() 来更新整个列表。它会导致所有 ViewHolder 的重新绑定和重绘,非常低效且没有动画。
- 必须使用 DiffUtil(或其封装的 ListAdapter / AsyncListDiffer)。它能在后台线程计算出新旧数据集的最小差异,然后只对发生变化的 Item 进行精准的、带有动画的更新。
- 这是一个进阶但效果显著的技巧。当 Item 中只有一小部分内容变化时(比如一个点赞按钮的状态和数量),我们可以使用带 payload 的 notifyItemChanged(position, payload)。
- 然后在 onBindViewHolder 的另一个重载方法中,根据 payload 的内容,只更新那个点赞的 TextView,而不需要重新绑定整个 Item 的其他 View(如头像、标题等),大大减少了不必要的工作。
3. 内存管理优化 (Memory & GC)
- 问题:在滑动过程中,如果频繁创建大量短生命周期的对象,会导致 频繁的垃圾回收 (GC)。GC 本身会暂停主线程,如果 GC 发生在 16ms 的渲染周期内,同样会造成卡顿。
- 复用对象:使用对象池等技术来复用那些可以复用的对象。
- 使用专业的图片加载库(如 Glide, Coil)。它们内部已经处理好了缓存(内存和磁盘)、Bitmap 复用、自动下采样等所有棘手的优化。
第 3 步:使用工具进行科学分析
以上都是基于经验的策略,但在实际项目中,最重要的一步是用工具来定位真正的瓶颈。
- CPU Profiler 是我们的首选。通过录制一段滑动操作,我们可以清晰地看到主线程中每个方法的执行时间。如果发现某个我们自己写的方法耗时特别长(火焰图中特别宽),那它就是优化的目标。
- 这是更强大的系统级性能分析工具。它可以告诉我们每一帧的详细渲染过程,能精确地看到 Measure, Layout, Draw 等各个阶段的耗时,以及 GC 的影响。虽然学习成本更高,但对于解决复杂卡顿问题至关重要。
总结
总的来说,优化滑动卡顿是一个综合性的工程。我的方法论是:
- 树立 16ms 的性能意识,时刻警惕主线程的耗时操作。
- 遵循最佳实践:优先压平布局层级、在 Adapter 中只做最轻量的工作、使用 DiffUtil 和 Payload。
- 拥抱现代化工具:优先使用 ConstraintLayout, RecyclerView, Coil/Glide, Kotlin Coroutines 等,它们本身就包含了大量的性能优化。
- 相信数据,而非直觉:在动手优化前,必须使用 Profiler 等工具进行分析,找到真正的性能瓶颈,进行精准打击。
Room 和 SQLite,这两者并不是一个“非此即彼”的替代关系,而是一个 “封装与被封装” 的关系。
简单来说:SQLite 是 Android 系统内置的、底层的关系型数据库引擎。而 Room 是 Google 在 Jetpack 套件中推出的、一个位于 SQLite 之上的、官方推荐的 ORM(对象关系映射)库。
你可以把 SQLite 想象成一辆车的 “发动机”,它强大、可靠,提供了最核心的动力。而 Room 就像是整车的 “驾驶舱和自动驾驶系统”,它封装了发动机的复杂操作,为我们开发者提供了一个更安全、更便捷、更现代化的交互界面。
我们为什么需要 Room?(SQLite 的痛点)
要理解 Room 的价值,就必须先明白直接使用原生 SQLite API 有多痛苦:
- 海量的模板代码 (Boilerplate Code):
- 直接使用 SQLiteOpenHelper,我们需要手动编写大量的、重复的代码来创建表、升级数据库、执行增删改查。
- 我们需要自己管理 Cursor 的打开和关闭,非常容易忘记关闭导致内存泄漏。
- 我们需要手动地、逐个字段地从 Cursor 中取出数据,然后再封装成我们的 Java/Kotlin 数据对象。这个过程极其繁琐且容易出错。
- 缺乏编译期安全检查:
- 这是最致命的缺陷。我们所有的 SQL 语句都是以 纯字符串 的形式存在的。
- 这意味着,如果你写错了一个表名、一个字段名,或者 SQL 语法有误,编译器完全无法发现。只有在 App 运行到那段代码时,才会崩溃报错。这种运行时错误非常难以排查,且会严重影响线上应用的稳定性。
- 与现代化架构的集成困难:
- 原生的 SQLite 操作是同步的、阻塞的,需要我们自己手动管理线程切换。
- 它返回的数据也不是可观察的。如果我们想在数据变化时自动更新 UI,就需要自己写一套复杂的观察者模式逻辑。
Room 是如何解决这些痛点的?(Room 的优势)
Room 作为一个现代化的 ORM 库,精准地解决了以上所有问题:
- 极大地减少了模板代码:
- @Entity: 一个简单的 data class,用来表示数据库中的一张表。
- @Dao (Data Access Object): 一个接口,我们只需要在里面定义抽象方法,并用 @Insert, @Query, @Update, @Delete 等注解来声明我们想做的操作。
- @Database: 一个抽象类,用来配置数据库的版本、实体等信息。
- Room 会在编译时,自动为我们生成所有 DAO 接口的实现代码。我们不再需要和 Cursor 或 SQLiteDatabase 直接打交道,大大提升了开发效率。
- 提供了编译期 SQL 校验:
- 这是 Room 最核心的优势。当我们在 @Query 注解里写 SQL 语句时,Room 会在 项目编译的时候,就去检查我们的 SQL 语句是否合法、表名和字段名是否都能在 @Entity 中找到。
- 如果 SQL 有任何错误,编译会直接失败,并给出明确的错误提示。这把潜在的运行时崩溃,提前扼杀在了开发阶段,极大地提升了代码的健壮性。
- 完美融入现代化 Android 架构:
- 原生支持协程 (Coroutines):我们可以直接把 DAO 方法声明为 suspend 函数,Room 会自动保证它们在后台线程执行,避免了阻塞主线程。
- 原生支持可观察查询 (Observable Queries):DAO 方法可以直接返回 Flow (在 Kotlin 中) 或 LiveData。这意味着,当数据库中的数据发生变化时,Room 会自动推送最新的数据,我们的 UI 就可以通过观察这个 Flow 或 LiveData 来实现自动刷新。这完美地契合了 MVVM 架构和响应式编程的思想。
- 简化了数据库迁移 (Migration):
- 虽然数据库升级本身仍然是复杂的,但 Room 提供了一套清晰的 Migration 框架,让数据库版本的升级和数据迁移过程变得更加结构化和可控。
总结
| 特性 |
原生 SQLite (SQLiteOpenHelper) |
Room (Jetpack Component) |
| 抽象级别 |
底层 API,直接与数据库引擎交互 |
高层 ORM 框架,封装了底层细节 |
| 代码量 |
巨大,包含大量模板代码 |
极少,只需定义注解和接口 |
| 安全性 |
运行时检查,SQL 错误在运行时才暴露 |
编译期检查,SQL 错误在编译时就能发现 |
| 线程处理 |
手动,需要开发者自己管理线程 |
自动,原生支持 suspend 函数和 RxJava |
| 数据观察 |
不支持,需要自己实现 |
原生支持,可直接返回 Flow 或 LiveData |
| 与架构集成 |
较差 |
极佳,是官方 MVVM 架构的核心数据层组件 |
一句话总结:
在任何新的 Android 项目中,我们都应该 毫无疑问地选择使用 Room。它不是 SQLite 的替代品,而是 SQLite 的 最佳实践封装。它为我们屏蔽了底层的复杂性,提供了编译期的安全保障,并完美融入了现代化的 Android 应用架构,让我们能够更专注于业务逻辑的实现,而不是数据库的繁琐操作。
“Room 的原理”是一个非常好的问题,因为它考察的不仅仅是我知不知道怎么用 Room,更是我是否理解 “代码生成” 和 “编译期注解处理” 这种现代化框架设计的核心思想。
我的理解是:Room 的所有“魔法”,都发生在 编译期 (Compile Time),而不是运行时 (Runtime)。它的核心原理可以概括为:通过注解处理器(Annotation Processor),在编译代码时,自动生成一套高效、健壮的、可以直接与底层 SQLite 交互的实现代码。
为了说清楚这个过程,我把它分为三个阶段:我们写了什么 (The Blueprint)、编译器做了什么 (The Construction)、以及最终生成了什么 (The Final Building)。
第 1 步:我们写了什么 —— 提供“蓝图”
作为开发者,我们并不写真正的数据库操作逻辑,我们只提供“蓝图”和“契约”,也就是 Room 的三大组件:
- @Entity: 这是一个被注解的 data class。我们用它来告诉 Room:“我需要一张表,表名叫 users,它有 id, name, age 这几个列,对应我这个类的属性。”
- @Dao: 这是一个被注解的 interface。我们用它来告诉 Room:“我需要一组对 users 表的操作方法,比如 insertUser, getUserById 等。具体怎么实现你来搞定,我只定义方法签名和对应的 SQL 语句。”
- @Database: 这是一个被注解的 abstract class。我们用它来告诉 Room:“请帮我创建一个数据库,把上面定义的 users 表包含进去,数据库版本是 1。”
到这里,我们的工作就结束了。我们写的全都是接口、抽象类和数据类,没有任何具体的实现代码。
第 2 步:编译器做了什么 —— “施工队”进场
当我们点击 “Build” 按钮编译项目时,一个特殊的工具——注解处理器——就开始工作了。在 Kotlin 中,这个工具就是 kapt 或者更新、更快的 KSP (Kotlin Symbol Processing)。
这个处理器会做以下几件关键事情:
- 扫描代码:它会扫描我们项目中所有的源代码,专门查找 Room 的注解 (@Entity, @Dao, @Database)。
- 解析和验证:
- 然后,它会解析 @Dao 接口里的每一个方法。这是最关键的一步:它会 逐一检查 @Query 里的 SQL 语句。它会像一个 SQL 专家一样,验证这个 SQL 语法是否正确,查询的表和字段是否都能在我们定义的 @Entity 中找到。
- 如果 SQL 有任何错误,编译会立刻失败,并给出明确的错误信息。这就是 Room 编译期安全 的来源。
- 生成代码 (Code Generation):
- 验证通过后,注解处理器就会 动态地创建一系列新的 .java 或 .kt 文件。这些文件就是我们定义的接口和抽象类的 具体实现类。
- 比如,如果我们定义了 UserDao 接口,它就会生成一个 UserDao_Impl.java 文件。如果我们定义了 AppDatabase,它就会生成一个 AppDatabase_Impl.java 文件。
第 3 步:最终生成了什么 —— 交付“精装房”
这些由 Room 自动生成的 _Impl 类,才是真正干活的代码。它们内部包含了我们曾经需要手写的、所有繁琐的底层 SQLite 操作。
- 它继承了我们写的 AppDatabase 抽象类。
- 它最重要的工作是实现了 createOpenHelper() 方法。在这个方法里,它会创建一个 SupportSQLiteOpenHelper 的实例,这实际上就是对 Android 原生 SQLiteOpenHelper 的封装。这表明 Room 的底层,最终还是回归到了 Android 系统的标准 SQLite API 上。
- 它还会实例化所有 _Impl 结尾的 DAO 实现类。
- 我们接口里的每一个方法,在这里都有一个对应的、完整的实现:
- 对于 @Insert 方法,它会生成代码去调用 db.insert(),并且会帮你把 User 对象的字段一个个 bind 到 SQLiteStatement 上。
- 对于 @Query 方法,它会生成代码去调用 db.query(),执行 SQL,然后 创建并管理 Cursor,再从 Cursor 中一列一列地读取数据,最后 自动封装成 User 对象返回。
- 这就是我们得以从海量模板代码中解放出来的根本原因。
- suspend: 当 DAO 方法被标记为 suspend 时,生成的代码会自动将这些阻塞的数据库操作,包裹在 withContext 类似的逻辑里,确保它们被调度到 Room 自己管理的后台线程池中执行。
- Flow / LiveData: 当 DAO 方法返回 Flow 时,生成的代码会更复杂。它不仅会执行一次查询来获取初始数据,还会利用 Room 内部的 InvalidationTracker (失效追踪器)。它会去“订阅”这张表的变化。当有任何 INSERT, UPDATE, DELETE 操作发生在这张表上时,InvalidationTracker 就会得到通知,然后 自动重新执行查询,并将最新的结果通过 Flow 发射出去。这就是响应式数据库查询的实现原理。
总结
总的来说,Room 的原理可以高度概括为 一个基于注解处理器的编译期代码生成框架。
Kotlin Flow 是一个非常强大的工具,可以说它是 Kotlin 协程生态中,处理异步数据流的“标准答案”。要理解它的原理,我喜欢把它比喻成一个 “智能的、可定制的传送带系统”。
我的讲解将围绕 “它是什么(传送带的特性)”、“它是如何工作的(传送带的三大组件)” 以及 “它在Android中的高级应用(不同类型的传送带)” 这三个核心部分展开。
1. 它是什么?—— 一条“冷”的传送带 (Cold Stream)
Flow 的本质是一个 异步数据流 (Asynchronous Data Stream)。它能在一个时间序列上,按顺序地发射(emit)零个或多个值。
它最核心、最必须理解的特性是:Flow 是“冷的” (Cold Stream)。
- “冷”意味着,Flow 本身只是一个蓝图或定义,它不存储任何数据,也不会自己开始工作。 就像一条传送带,你把它设计好、安装好,但只要没人按下“启动”按钮,它就永远静止在那里,不消耗任何能源。
- 只有当 终端操作符 (Terminal Operator),最典型的就是 collect,被调用时,这条“传送带”才会为这个 collector 单独启动一次。
- 懒加载 (Lazy):只有在需要数据的时候,数据才会被生产和发射,非常节省资源。
- 数据隔离:每一个 collector 来订阅时,都会触发一次全新的、从头开始的数据流。A 订阅者和 B 订阅者看到的,是两条独立的、互不干扰的“传送带”。
2. 它是如何工作的?—— 传送带的三大组件
一个完整的 Flow 工作流程,由三个角色组成:
a) 生产者 (Producer) —— 在传送带上放东西
这是数据流的源头。我们使用 Flow 构建器 (Builder) 来创建一个生产者。
- 在这个代码块里,我们可以执行任何异步操作(比如网络请求、数据库查询),然后使用一个关键的 suspend 函数 emit(value) 来把数据一个一个地“放”到传送带上。
// 定义一个生产者
val myFlow: Flow<Int> = flow {
Log.d("Flow", "Flow started") // 只有 collect 时才会打印
for (i in 1..3) {
delay(100) // 模拟耗时操作
emit(i) // 发射数据
}
}
b) 中间操作符 (Intermediaries) —— 在传送带中途加工
这些是可选的“加工站”。它们可以对数据流中的每个元素进行转换、过滤等操作。
- 常见的操作符有 map, filter, onEach 等。
- 它们本身 不会 触发 Flow 的执行,它们只是在“蓝图”上增加了一个新的处理步骤,并返回一个新的 Flow 实例。
myFlow.map { value -> value * 2 } // 每个元素都乘以2
.filter { value -> value > 3 } // 只保留大于3的元素
c) 消费者 (Consumer) —— 在传送带末端取东西
这是数据流的终点,也是触发整个 Flow 开始
执行的“启动按钮”。
- 消费者通过调用 终端操作符 (Terminal Operator) 来消费数据。
- 最核心的终端操作符是 collect { ... }。collect 是一个 suspend 函数,它会挂起当前的协程,然后逐一接收 Flow 发射过来的值,直到 Flow 执行完毕。
// 在一个 CoroutineScope 中
scope.launch {
myFlow.collect { value ->
Log.d("Flow", "Collected value: $value")
}
}
// 输出:
// Flow started
// Collected value: 1
// Collected value: 2
// Collected value: 3
3. Flow 的底层原理 —— suspend 的双向制约
Flow 之所以高效,其原理深植于协程的 suspend 机制:
- 生产者侧的 emit() 和消费者侧的 collect() 的代码块,它们都是 suspend 函数。
- 这意味着,当生产者 emit 一个新值时,如果消费者正在忙于处理上一个值,emit 函数会“挂起”,它会等待消费者处理完毕、准备好接收下一个值时,才会恢复并继续发射。
- 反之亦然。这种双向的挂起机制,天然地实现了 背压,即生产者永远不会以超过消费者处理能力的速度来生产数据,从而避免了内存溢出。
- Flow 的代码遵循“上下文保留”原则,即生产者的代码块运行在消费者的 CoroutineContext 中。
- 如果我们想让生产者(比如耗时的数据库查询)运行在 IO 线程,而消费者(UI 更新)运行在 Main 线程,我们可以使用 flowOn(Dispatchers.IO) 这个操作符。它会改变 上游(flowOn 之前的所有代码)的执行上下文。
4. Android 中的高级应用 —— “热”的传送带
Flow 本身是“冷”的,但在 Android UI 编程中,我们经常需要一个能 共享数据、有状态、并且始终存在 的数据源。为此,Kotlin 提供了两种特殊的“热”流 (Hot Stream):
- 可以看作是 Flow 版本的 LiveData,但功能更强大。
- 它是一个 有状态的、可共享的、只关心最新值 的热流。
- 必须有初始值。
- 它的 value 属性永远持有最新的值。
- 当 UI 从后台恢复并重新订阅时,它会 立刻 收到最新的那个值。
- 它有去重功能,如果连续设置相同的值,只会发射一次。
- 最佳场景:在 ViewModel 中,用于向 UI 暴露当前界面的状态 (UI State)。
- 可以没有初始值。
- 可以配置 重放缓存 (replay cache),让新的订阅者也能收到最近的 N 个历史值。
- 可以配置 订阅者策略,比如当没有订阅者时,是否要停止上游的 Flow。
- 最佳场景:用于处理一次性的、需要广播给多个订阅者的事件(Single Live Event),比如弹出一个 Toast 或者进行一次页面跳转。
第一,内联函数是什么?
首先,inline 是 Kotlin 中的一个关键字,它用于修饰函数。它的核心作用是建议编译器在编译时,将这个函数的字节码以及传递给它的 Lambda 表达式的字节码,直接“粘贴”到调用这个函数的地方。
这就好比写代码时,我们把一个函数的内容直接复制粘贴过来,而不是通过常规的函数调用方式去执行。
第二,为什么要用内联函数?它的核心价值是什么?
它主要解决的是 Kotlin 中高阶函数(就是接收函数或 Lambda 作为参数的函数)带来的性能开销问题。
在 JVM 环境下,一个 Lambda 表达式本质上会被编译成一个匿名的内部类对象。如果我们把一个高阶函数放在一个循环里调用,每次循环都会创建一个新的 Lambda 对象,这会带来两方面的开销:
- 运行时开销:频繁地创建和销毁对象,会给内存和 CPU 带来负担,尤其是在性能敏感的场景下。
- 内存开销:创建大量的对象会增加垃圾回收(GC)的压力。
而使用 inline 关键字后,因为函数体和 Lambda 都被直接“粘贴”到调用处,所以根本就不会创建 Lambda 对象,也省去了函数调用的压栈出栈过程。这样一来,运行时的性能和内存开销就都被优化了。
第三,内联函数带来了哪些特殊的能力?
除了性能优化,inline 还解锁了两个非常强大的功能,这在普通函数里是做不到的:
- 非局部返回(Non-local returns):在一个普通的 Lambda 表达式里,我们是不能直接使用 return 关键字来返回外层的函数的,只能返回这个 Lambda 本身。但是,如果这个 Lambda 是传递给一个内联函数的,我们就可以在 Lambda 中直接 return,从而结束外层函数的执行。这是因为 Lambda 的代码已经成为了外层函数的一部分。
- 具体化的泛型参数(Reified Type Parameters):在 Java/Kotlin 中,泛型在编译后会进行类型擦除,导致我们无法在运行时获取到泛型的具体类型。但是,如果一个内联函数用 reified 关键字修饰了它的泛型参数,那么在函数体内,我们就可以像操作普通 Class 一样,访问这个泛型参数的具体类型。这在 Android 开发中非常实用,比如我们可以封装一个通用的 startActivity 方法:
inline fun <reified T : Activity> Context.startActivity() {
val intent = Intent(this, T::class.java)
startActivity(intent)
}
这样我们就可以通过 context.startActivity<DetailActivity>() 这样简洁的方式启动页面,而不需要显式传递 DetailActivity::class.java。
第四,使用内联函数的注意事项(Trade-off)
当然,内联函数也不是银弹,它有一个需要注意的地方:
如果一个函数非常大,代码量很多,把它声明为 inline 可能会导致最终生成的字节码体积显著增大。因为这个函数的代码会在每一个调用点都被复制一份。这可能会导致 APK 的体积变大。
所以,我们的使用原则通常是:优先对那些代码量不大、被频繁调用,并且接收 Lambda 作为参数的函数使用 inline。
关于 Android 的点击事件流转过程,我的理解是一个清晰的 “U” 型责任链模式。它始于 Activity,自顶向下传递(分发),直到最底层的 View,然后再自底向上冒泡(处理)。整个过程主要由三个核心方法协作完成:dispatchTouchEvent、onInterceptTouchEvent 和 onTouchEvent。
我可以从以下几个方面来阐述这个过程:
第一,事件流转的核心角色
参与事件流转的主要有三个角色:Activity、ViewGroup 和 View。
- Activity:事件分发的起点。当用户触摸屏幕时,系统最先将事件传递给当前的 Activity。
- ViewGroup:作为容器,它扮演着“分发者”和“拦截者”的角色。它需要决定是自己处理事件,还是继续向下分发给它的子 View。
- View:事件处理的末端。它是一个纯粹的“处理者”,负责消费事件。
第二,事件流转的三个核心方法
这三个方法定义了整个流转的规则,它们的返回值(true 或 false)决定了事件流的走向。
- public boolean dispatchTouchEvent(MotionEvent ev)
- 作用:事件分发。这是事件流的入口。无论 Activity、ViewGroup H View 都有这个方法。它的作用是决定事件该往哪里去。
- 返回值:返回 true 表示事件已被消费,整个事件流到此结束。返回 false 则表示当前 View 和它的子 View 都没有消费该事件,事件将回传给父 View 的 onTouchEvent 方法进行处理。
- public boolean onInterceptTouchEvent(MotionEvent ev)
- 作用:事件拦截。这是 ViewGroup 独有的方法。它像一个“哨兵”,站在 dispatchTouchEvent 内部,决定是否要拦截事件,不让它继续往下传。
- 返回值:返回 true 表示拦截。事件将不再向下分发给子 View,而是直接交给自己的 onTouchEvent 处理。返回 false 表示不拦截,事件会继续分发给子 View。
- public boolean onTouchEvent(MotionEvent ev)
- 返回值:返回 true 表示“我消费了这个事件”。那么后续的事件序列(如 ACTION_MOVE, ACTION_UP)都会继续交给我处理,并且事件不会再向上传递。返回 false 表示“我不处理”,事件会向上冒泡,交给父容器的 onTouchEvent 方法。
第三,完整的事件流转路径
现在我来描述一个典型的 ACTION_DOWN 事件的完整路径:
- 分发阶段(自顶向下)
- 事件首先到达 Activity 的 dispatchTouchEvent。
- Activity 将事件传递给 Window,Window 再传递给它的根 View(DecorView)。
- DecorView 开始向下分发,调用其子 ViewGroup 的 dispatchTouchEvent。
- 当事件到达一个 ViewGroup 时,它的 dispatchTouchEvent 会首先调用 onInterceptTouchEvent 进行判断。
- 如果 onInterceptTouchEvent 返回 false(不拦截),事件会继续向下传递,调用目标子 View 的 dispatchTouchEvent。
- 如果 onInterceptTouchEvent 返回 true(拦截),事件将不再向下,直接调用这个 ViewGroup 自己的 onTouchEvent。
- 这个过程会一直持续,直到事件传递到最底层的目标 View。
- 处理阶段(自底向上)
- 事件最终到达目标 View 的 dispatchTouchEvent,由于 View 没有 onInterceptTouchEvent,所以会直接调用它的 onTouchEvent。
- 此时,View 的 onTouchEvent 的返回值变得至关重要:
- 如果返回 true(消费),那么事件就到此为止。后续的 MOVE 和 UP 事件都会直接传递给这个 View 的 onTouchEvent。
- 如果返回 false(不消费),事件会开始“冒泡”,传递给它的父 ViewGroup 的 onTouchEvent 进行处理。
- 父 ViewGroup 的 onTouchEvent 如果也返回 false,事件会继续向上冒泡,直到最顶层的 Activity 的 onTouchEvent。
- 如果在整个向上的传递过程中,都没有任何一个 onTouchEvent 返回 true,那么这个事件序列就被认为是“无人认领”的,后续的 MOVE 和 UP 事件将不会再被分发下来。
第一,onSaveInstanceState() 的核心作用是什么?
它的核心作用就一句话:在 Activity 即将被系统销"毁但有机会重建"时,提供一个机会来保存瞬时的、临时的 UI 状态。
这里的关键是“瞬时 UI 状态”,比如用户在一个 EditText 里输入了一半的文字、一个列表的滚动位置、一个 CheckBox 的勾选状态等等。这些状态不是应用的持久化数据,但如果因为屏幕旋转或者内存回收而丢失,用户体验会非常糟糕。onSaveInstanceState 就是为了解决这个问题。
第二,这个方法在什么时候会被调用?
这一点非常关键,onSaveInstanceState() 并不是每次 Activity 退到后台都会被调用。它的触发时机有明确的限定,主要是以下两种情况:
- 发生配置变更时:最典型的例子就是屏幕旋转。当屏幕旋转时,系统会销毁当前的 Activity 实例,然后创建一个新的实例来加载适应新方向的布局。在销毁前,系统会调用 onSaveInstanceState() 来保存旧实例的 UI 状态。
- 应用在后台被系统杀死时:当用户按下 Home 键将应用切换到后台,如果此时系统内存不足,就有可能会杀死我们应用的进程以回收资源。在这种情况下,系统会认为用户可能还会回来,所以在杀死进程前会调用 onSaveInstanceState(),给我们机会保存现场。
反过来,有几种情况是不会被调用的:
- 用户主动按下返回键或者代码中调用 finish() 方法。因为这是用户明确的退出行为,系统认为用户不再需要保留这个页面的状态。
- 仅仅是从一个 Activity 跳转到另一个 Activity,而前一个 Activity 只是进入 onStop 状态,并没有被销毁。
第三,状态是如何保存和恢复的?
这个过程是一个闭环:
- 保存:当触发了上述时机,系统会调用 onSaveInstanceState(Bundle outState)。我们需要做的就是把想保存的数据以键值对的形式 put 进这个 outState 的 Bundle 对象里。比如 outState.putString("inputText", editText.getText().toString());
- 恢复:当 Activity 被重新创建时,系统会将我们之前保存的那个 Bundle 对象传递回来。我们有两个地方可以接收并恢复数据:
- onCreate(Bundle savedInstanceState):这是最常用的地方。onCreate 方法的 savedInstanceState 参数就是那个 Bundle。我们只需要在 onCreate 的开头判断一下 savedInstanceState 是否为 null,如果不为 null,就从中取出数据恢复 UI。
- onRestoreInstanceState(Bundle savedInstanceState):这个方法在 onStart() 之后被调用。它和 onCreate 的区别是,它的 savedInstanceState 参数一定不是 null(如果为 null,系统根本不会调用它),所以在这里恢复数据不需要做空判断,代码意图也更清晰,专门用于状态恢复。
第四,onSaveInstanceState() 和 ViewModel 的关系(现代开发中的考量)
在现代 Android 开发中,我们有了 ViewModel 这个架构组件。
- 对于配置变更(如屏幕旋转)导致的 Activity 重建,ViewModel 是首选的解决方案。因为 ViewModel 的生命周期比 Activity 更长,它可以在配置变更中存活下来,所以 ViewModel 中持有的数据不会丢失,UI 重建后可以直接使用,比 onSaveInstanceState 更简单、更强大,也能承载更复杂的数据。
- 但是,ViewModel 无法在进程被系统杀死的情况下存活。所以,onSaveInstanceState 的作用依然不可替代。它仍然是处理因内存回收导致 Activity 重建时,UI 状态恢复的最后一道防线。
为了结合两者的优点,Google 后来推出了 ViewModel 中的 SavedStateHandle。它本质上就是对 onSaveInstanceState 机制的封装,让我们可以在 ViewModel 内部方便地读写需要“跨进程死亡”保存的数据,代码组织更优雅。
总结一下:
面试官,onSaveInstanceState() 是 Android Framework 提供的用于保存和恢复 Activity 临时 UI 状态的核心机制。它主要应对屏幕旋转和后台进程被杀这两种意外场景。通过与 onCreate 或 onRestoreInstanceState 配合,可以实现无缝的用户体验。在现代开发中,虽然 ViewModel 处理了配置变更的场景,但 onSaveInstanceState 在应对进程被杀时依然是不可或缺的关键环节。
关于 LiveData 如何将数据更新通知到 UI 刷新,它的核心链路可以概括为:一个基于观察者模式、并与生命周期(Lifecycle)深度绑定的智能分发系统。
我可以从 “订阅”、“更新” 和 “分发” 这三个关键环节来阐述这个链路。
第一,订阅环节:observe() 方法做了什么?
这是整个链路的起点。当我们在 Activity 或 Fragment 中调用 liveData.observe(lifecycleOwner, observer) 时,内部主要完成了两件大事:
- 创建包装类:LiveData 并没有直接持有我们传入的 observer。而是创建了一个叫做 LifecycleBoundObserver 的内部包装类。这个包装类非常关键,因为它同时持有了三样东西:我们传入的 observer、lifecycleOwner(通常是 Activity/Fragment 自身),以及 LiveData 实例。
- 绑定生命周期:LifecycleBoundObserver 本身也是一个 LifecycleObserver。在创建后,它会立即通过 lifecycleOwner.getLifecycle().addObserver(this) 将自己注册为生命周期观察者。
这一步的核心是:通过 LifecycleBoundObserver 这个“中间人”,将数据(LiveData)、观察者(Observer)和生命周期(LifecycleOwner)三者牢牢地绑定在了一起。从此,这个“中间人”既能接收 LiveData 的数据更新,也能感知到 Activity 的生命周期变化。
第二,更新环节:setValue() 和 postValue() 做了什么?
当我们在 ViewModel 中调用 liveData.setValue() 或 postValue() 来更新数据时:
- setValue(T value) (主线程调用):
- 它会把 LiveData 内部的一个版本号(version)加一。这个版本号是用来防止数据重复分发的关键。
- 然后,它会立即触发 分发环节,遍历所有注册的观察者并尝试通知它们。
- postValue(T value) (子线程调用):
- 它与 setValue 不同,它不会立即更新数据和分发。
- 它会把更新数据的任务封装成一个 Runnable,然后通过 ArchTaskExecutor 抛到主线程的消息队列中。
- 当主线程执行这个 Runnable 时,最终还是会调用 setValue() 来完成真正的更新和分发。
- 这里有一个重要的细节:如果在主线程处理之前,postValue 被连续调用多次,只有最后一次调用的值会最终生效,中间的值会被覆盖。
第三,分发环节:数据是如何“智能”地到达 UI 的?
这是 LiveData 的核心价值所在。当 setValue 被调用后,会触发一个叫做 dispatchingValue 的内部方法,它会遍历所有“订阅者”(也就是那些 LifecycleBoundObserver)。
对于每一个“订阅者”,它会进行一系列严格的检查,我称之为 “分发守卫”:
- 生命周期状态检查:这是最重要的一步。它会检查 LifecycleBoundObserver 所绑定的 LifecycleOwner(我们的 Activity/Fragment)的当前状态是否至少是 STARTED。也就是说,只有当界面处于可见、可交互的状态(STARTED 或 RESUMED)时,通知才会继续。如果界面处于 STOPPED 状态,分发就会在此中断。这就是 LiveData 生命周期感知能力的核心体现,它避免了在后台更新 UI 导致的崩溃或无效操作。
- 版本号检查:为了避免重复通知,它会比较观察者自身记录的最后一次接收数据的版本号和 LiveData 当前的版本号。只有当 LiveData 的版本号大于观察者的版本号时,才证明这是个新数据,需要通知。
- 数据粘性处理:如果一个 Observer 是新加入的,或者它绑定的 Activity 从后台 (STOPPED) 恢复到前台 (STARTED),那么在通过了生命周期检查后,即使数据没有更新,它也会把 LiveData 中最新的数据(如果存在)分发一次。这确保了 UI 在恢复时总能显示最新的状态。
只有当所有这些检查都通过后,LiveData 才会调用 LifecycleBoundObserver 的 onChanged() 方法,而这个方法最终会调用我们自己写在 observe 回调里的 onChanged(T data) 方法。这时,我们就可以安全地用新数据去更新 UI 了,比如 textView.setText(data)。
第一,QUIC 是什么?它的目标是什么?
QUIC,全称是 Quick UDP Internet Connections。从名字就能看出两个关键点:它基于 UDP,并且目标是快(Quick)。
它是由 Google 设计并推动,现在已经成为 IETF 标准的下一代传输层协议。我们可以把它看作是 TCP、TLS、HTTP/2 功能的一个“集大成者”,它把这三者中很多优秀的功能重新设计并整合,最终运行在 UDP 之上。它的核心目标就是:降低延迟,提高网络传输效率。
第二,QUIC 为什么要基于 UDP,而不是改造 TCP?
这是因为 TCP 的实现在操作系统内核层。任何对 TCP 的修改都需要操作系统的更新,推广起来非常缓慢和困难。而 UDP 是一个非常轻量、简单的协议,把它作为基础,就可以在应用层去实现各种复杂的控制逻辑,比如可靠性、拥塞控制等。这样一来,协议的迭代和升级只需要更新应用程序或其依赖的库(比如 Android 中的 Cronet 库),非常灵活。
第三,QUIC 解决了哪些核心痛点?
QUIC 主要解决了 TCP 时代的几个老大难问题:
1. 解决了“队头阻塞”(Head-of-Line Blocking)问题:
- 这是 QUIC 最核心的改进。在 HTTP/2 中,虽然我们可以在一个 TCP 连接上并发传输多个“流”(Stream),比如同时请求 HTML、CSS、图片资源。但它们在传输层(TCP)上,依然是跑在一个有序的字节流里。
- 这就导致一个问题:如果其中一个资源(比如图片)的一个数据包丢失了,TCP 协议会要求重传这个包。在重传包到达之前,所有后续的数据包,即使是属于其他流(比如 CSS)并且已经到达了,也必须排队等待,整个连接都被阻塞了。这就是队头阻塞。
- QUIC 的解决方案是:它在 UDP 之上,原生实现了多个独立的流。每个流都有自己的序号和流量控制。如果一个流的数据包丢失了,它只会阻塞那一个流,其他流的数据可以被正常接收和处理,互不影响。这对于加载大量资源的复杂页面,性能提升非常显著。
2. 实现了 0-RTT/1-RTT 的快速连接建立:
- 传统的 TCP + TLS 建立连接,需要先进行 TCP 的三次握手(消耗 1 个 RTT),然后再进行 TLS 的握手(可能消耗 1-2 个 RTT),总共需要 2-3 个 RTT 才能开始发送业务数据,延迟很高。
- QUIC 把传输层握手和加密握手合并了。首次连接,它只需要 1-RTT。更厉害的是,对于已经建立过连接的客户端,服务器会把一些加密信息发给客户端缓存。客户端下次再连接时,可以在第一个包里就带上业务数据,实现了 0-RTT,大大减少了连接建立的延迟。
3. 实现了无缝的“连接迁移”(Connection Migration):
- 这对于移动设备尤其重要。我们经常会遇到手机从 Wi-Fi 切换到 4G/5G 网络。
- 传统的 TCP 连接是通过一个四元组(源 IP、源端口、目标 IP、目标端口)来唯一标识的。当网络切换时,源 IP 和端口都变了,TCP 连接就断了,必须重新建立,用户会感觉到明显的卡顿。
- QUIC 不使用四元组,而是使用一个 64 位的连接 ID(Connection ID)来标识一个连接。这个 ID 在整个连接生命周期内保持不变。当网络切换时,客户端只需要用新的 IP 和端口,带上同一个连接 ID 继续向服务器发送数据包即可,连接并不会中断。服务器看到这个 ID 就知道还是之前的那个连接,整个过程对上层应用是透明的,体验非常流畅。
第四,QUIC 在 Android 生态中的应用
在 Android 开发中,我们通常不会直接去操作 QUIC 协议。QUIC 主要是作为 HTTP/3 的底层传输协议存在的。当我们说一个应用支持 HTTP/3,就意味着它在使用 QUIC。
要在 Android 应用中使用 QUIC/HTTP/3,最主流的方式是集成 Google 的 Cronet 网络库。Cronet 是 Chromium 项目的网络栈,被封装成了一个独立的库。Google 自己的很多应用,比如 Chrome、YouTube,都在使用 Cronet 来充分利用 QUIC 带来的性能优势。我们可以通过在项目中引入 Cronet,来替换掉传统的 HttpURLConnection 或 OkHttp 的底层实现,从而让我们的应用也具备 QUIC 的能力。
1. 页面与页面间通信 (Activity / Fragment)
这是我们日常开发最频繁接触的场景。
- Intent + Bundle:这是最基础、最核心的方式。通过 Intent 的 putExtra() 方法携带 Bundle 数据来传递信息。对于需要返回结果的场景,使用官方推荐的 Activity Result API (registerForActivityResult) 来替代老旧的 startActivityForResult。
- 静态变量/单例:简单直接,但不推荐。容易造成内存泄漏,并且在 Activity 被系统回收后数据会丢失,不符合生命周期管理原则。
- Fragment 之间 / Fragment 与 Activity 之间:
- Shared ViewModel:这是 Google 官方首推的最佳实践。多个 Fragment 共享一个宿主 Activity 范围的 ViewModel。一个 Fragment 更新 ViewModel 中的 LiveData 或 StateFlow,另一个 Fragment 观察这个数据的变化即可,完美地实现了数据共享和通信,并且是生命周期安全的。
- Fragment Result API:与 Activity Result API 类似,通过 setFragmentResultListener 和 setFragmentResult 在 Fragment 之间传递一次性的结果数据,非常解耦。
- 接口回调:这是比较传统的方式。Fragment 定义一个接口,宿主 Activity 实现这个接口。Fragment 在 onAttach 时获取 Activity 的引用,然后通过接口调用 Activity 的方法,实现向 Activity 的通信。
2. 组件与组件间通信 (同进程内)
这里的组件指 Android 的四大组件:Activity, Service, BroadcastReceiver, ContentProvider。
- Intent:这是最通用的“信使”。不仅可以启动 Activity,还可以启动 Service、发送广播。是组件间松耦合通信的基础。
- Binder (绑定服务):这是 Activity 和 Service 之间进行双向、复杂交互的首选方式。Activity 通过 bindService() 绑定一个 Service,并获取一个 IBinder 接口。通过这个接口,Activity 可以直接调用 Service 的公共方法,实现高效的数据交换和方法调用。
- BroadcastReceiver (广播):这是一种典型的“发布-订阅”模式,用于一对多的通信。
- 全局广播:系统级事件(如网络变化、电量低)或应用间通信常用。但效率较低,有安全风险。
- 本地广播 (LocalBroadcastManager):(现已废弃) 之前是应用内广播的首选,数据被限制在应用内部,更安全高效。现在官方推荐使用 LiveData 或 Flow 等观察者模式替代。
- ContentProvider:虽然它主要是为跨进程数据共享设计的,但在应用内部也可以作为一种标准化的数据访问接口,实现组件间的数据解耦。
3. 线程与线程间通信 (同进程内)
这主要解决的是子线程(后台任务)与主线程(UI 线程)的通信问题。
- Handler + Looper + MessageQueue:这是 Android 消息机制的基石。子线程通过 Handler 将一个 Message 或 Runnable 发送到主线程的 MessageQueue 中,主线程的 Looper 不断从中取出消息并交由 Handler 处理,从而确保了所有 UI 操作都在主线程执行。
- Kotlin 协程 (Coroutines):这是 现代 Android 开发的首选方案。它提供了强大的结构化并发能力。通过 ViewModelScope 或 LifecycleScope 启动协程,使用 Dispatchers 可以极其方便地在不同线程(如 IO 线程和 Main 线程)之间切换,代码逻辑清晰,避免了回调地狱,且能更好地处理生命周期。
- RxJava:一个强大的响应式编程库。通过其线程调度器 subscribeOn 和 observeOn,可以精确控制数据流的生产和消费所在的线程,非常适合处理复杂、连续的异步事件流。
4. 进程与进程间通信 (IPC - Inter-Process Communication)
当我们的应用需要与其他应用或系统服务交互时,就需要用到 IPC。
- Binder & AIDL:这是 Android 中最核心、性能最好的 IPC 机制。它是一种远程过程调用(RPC)系统。我们通过 AIDL (Android Interface Definition Language) 定义一个跨进程的接口,系统会自动生成 Binder 通信所需的 Stub 和 Proxy 代码,使得调用远程服务的方法就像调用本地方法一样简单。绝大多数系统服务,如 ActivityManagerService,都是基于 Binder 实现的。
- Messenger:一个对 Binder 的轻量级封装。它将所有请求放入一个队列中,在服务端通过一个 Handler 单线程处理。它实现起来比 AIDL 简单,适合不需要高并发处理的简单 IPC 场景。
- ContentProvider:这是 Android 提供的标准化的、用于跨进程共享数据的组件。它提供了一套 CRUD (增删改查) 接口,并由系统统一管理权限。当一个应用需要向外暴露其结构化数据(如联系人、媒体库)时,ContentProvider 是最合适的选择。
- Broadcast (广播):广播也可以用于跨进程的、简单的、单向的事件通知。但效率不高,且数据传输能力有限。
- Socket:这是源于 Linux 内核的通用 IPC 方式,不限于 Android。它适用于任何需要进行网络通信的场景,包括本地进程间通信,非常灵活,但不是 Android 推荐的首选 IPC 方式。
总结一下:
面试官,Android 的通信方式是分场景、分层次的。从上层的 Intent、ViewModel,到中层的 Binder、Handler,再到底层的 AIDL 和 Socket,每种技术都有其最适合的应用场景。作为开发者,理解这些机制的原理和差异,并根据具体需求(如耦合度、数据复杂度、性能要求、安全性)做出正确的技术选型,是至关重要的。
关于 Intent 传数据的限制,这是一个非常重要且实际的问题。我的理解是,这个限制本质上并非来自 Intent 本身,而是源于 Android 底层的 Binder 跨进程通信(IPC)机制。
我可以从以下四个方面来详细阐述:
第一,限制是什么?具体有多大?
Intent 传递数据时,所有的数据都会被打包到一个 Bundle 对象中。当这个 Intent 用于跨进程通信时(比如启动另一个应用的 Activity,或发送系统广播),这个 Bundle 数据就需要通过 Binder 机制进行传输。
Binder 拥有一个事务缓冲区(Transaction Buffer),这个缓冲区的大小是有限的,在目前的 Android 版本中,这个上限大约是 1MB。
需要特别强调的是,这个 1MB 的缓冲区并不是单个 Intent 能独占的,而是由一个进程中所有正在进行的 Binder 事务共享的。所以,如果我们试图通过 Intent 传递一个非常大的数据对象(比如一个未经压缩的高清 Bitmap),就很容易超出这个限制。
当超出这个限制时,应用就会崩溃,并抛出 TransactionTooLargeException 异常。这是一个非常明确的信号,告诉我们传递的数据“超重”了。
第二,为什么会有这个限制?
这个限制的存在是出于系统稳定性和性能的考虑:
- 系统资源保护:Binder 的缓冲区是系统级的共享资源。如果不加以限制,一个行为不当的应用就可以通过传递大量数据耗尽这个缓冲区,导致其他正常的系统服务和应用无法进行 IPC 通信,从而拖慢甚至搞垮整个系统。
- 性能考量:IPC 本身是有性能开销的。设计 Intent 和 Bundle 的初衷是为了传递轻量级的、描述性的状态数据或指令,而不是用于传输海量数据。强制一个较低的上限,可以促使开发者采用更合适的方式来处理大数据,避免因为滥用 IPC 而导致应用卡顿。
第三,哪些操作会受到这个限制的影响?
这个限制不仅仅影响 startActivity()。所有涉及到 Bundle 跨组件或跨进程传递的场景,都会受到这个 Binder 事务大小的限制,包括但不限于:
- 通过 Intent 启动 Activity, Service, BroadcastReceiver。
- Activity 状态保存与恢复,即 onSaveInstanceState(Bundle outState)。因为这个 Bundle 也需要通过 Binder 传递给 ActivityManagerService 进行保管。
- Fragment 之间通过 setArguments(Bundle args) 传递参数。
- Jetpack Navigation 组件的 safe-args 传参,其底层也是 Bundle。
一个常见的误区是传递 Serializable 对象。Java 的序列化机制会产生大量的临时对象和元数据,比 Parcelable 更容易、也更不可预测地触及这个 1MB 的上限。这也是为什么 Android 强烈推荐使用 Parcelable 的原因之一。
第四,如何规避这个限制?(解决方案)
当我们需要在组件间传递大量数据时,正确的做法不是硬塞进 Intent,而是应该“传递引用,而非传递对象本身”。具体方案有:
- 持久化存储 + 传递标识符:这是最推荐、最通用的解决方案。
- 将大数据(如图片、JSON 文件、复杂对象列表)存入数据库(如 Room)或文件中。
- 然后只将该数据的唯一标识符(比如数据库记录的 ID、文件的 URI 或路径)通过 Intent 传递过去。
- 接收方(新的 Activity 或组件)根据这个标识符,再从数据库或文件中将完整数据加载出来。
- 使用静态单例/全局缓存:
- 将数据对象存放在一个全局的静态变量或者 LruCache 中。
- Intent 不传递数据,接收方直接从这个全局位置获取数据。
- 但这是一种有风险的方案。它会增加耦合度,并且在应用进程被系统杀死后,这个静态数据会丢失,导致恢复时出现空指针。只适用于一些临时、非关键数据的快速传递。
- 使用 ContentProvider:
- 如果数据需要在不同应用进程之间共享,ContentProvider 是 Android 官方提供的标准、安全的方式。
- 对于同进程内的 UI 组件通信:
- 首选 Shared ViewModel。这是 Jetpack 架构组件的最佳实践,可以在同属一个 Activity 的多个 Fragment 之间安全、高效地共享复杂数据对象,完全绕开了 Intent 和 Bundle 的限制。
一,volatile 提供了两大核心保证
- 保证可见性(Visibility)
- 这是 volatile 最核心的作用。在多核 CPU 架构下,每个线程都有自己的高速缓存(CPU Cache)。当一个线程修改一个共享变量时,它可能只是先更新了自己的缓存,而没有立刻写回主内存。这时,其他线程就无法看到这个最新的值。
- 一旦一个变量被 volatile 修饰,就相当于给这个变量加了一个“特殊指令”。当一个线程写入这个变量时,JMM(Java 内存模型)会强制将这个修改立刻写回主内存。当另一个线程读取这个变量时,JMM 会强制它从主内存中重新加载,而不是使用自己的缓存。
- 通过这一“写回”和“读出”,volatile 确保了所有线程看到的这个变量的值始终是最新的,从而保证了可见性。
- 保证有序性(Ordering)
- 为了提高性能,编译器和处理器可能会对我们写的代码指令进行重排序。在单线程下这通常没问题,但在多线程下就可能导致意想不到的后果。
- volatile 关键字可以禁止特定类型的指令重排序。它通过插入“内存屏障”(Memory Barrier)来实现。这确保了在 volatile 变量写操作之前的所有操作,都先于这个写操作完成;而在 volatile 变量读操作之后的所有操作,都后于这个读操作开始。这个特性在某些特定场景下至关重要,比如“双重检查锁定”的单例模式。
第二,volatile 不能保证什么?(它的局限性)
这一点非常重要,volatile 不能保证原子性(Atomicity)。
- 一个典型的反例就是 count++ 操作。这个操作在字节码层面实际上是三步:1. 读取 count 的值;2. 将值加一;3. 将新值写回 count。
- 即使 count 是 volatile 的,两个线程也可能同时执行第一步,读到相同的旧值。然后它们各自加一,再写回,最终结果就是 count 只被增加了一次,造成了数据不一致。
- 所以,对于依赖于前一个值的复合操作,volatile 是无能为力的。这种场景下,我们需要使用更重量级的 synchronized 关键字,或者使用 java.util.concurrent.atomic 包下的原子类,比如 AtomicInteger,它通过 CAS(比较并交换)操作来保证原子性。
第三,到底什么情况下用 volatile?
根据它的特性,volatile 的最佳使用场景是一个线程写,多个线程读的简单状态标记。
这个变量的状态必须满足以下两个条件:
- 对变量的写入操作不依赖于其当前值。
- 该变量没有包含在具有其他变量的不变式中。
最经典的应用场景就是“开关”或“取消标志”:
- 场景描述:我们有一个后台工作线程,它在一个 while 循环里持续执行任务,循环的条件是一个布尔标志位 isRunning。主线程可以在任何时候将 isRunning 设置为 false 来优雅地停止这个后台线程。
// 在某个类中
private volatile boolean isRunning = true;
// 工作线程中
public void run() {
while (isRunning) {
// ... do work ...
}
}
// 主线程或其他线程中
public void stopWork() {
isRunning = false;
}
- 如果没有 volatile,工作线程可能会将 isRunning 的初始值 true 缓存起来。当主线程修改了 isRunning 为 false 后,工作线程可能永远也看不到这个变化,导致 while 循环无法停止。
- volatile 保证了主线程对 isRunning 的写入对工作线程立即可见,从而确保了线程可以被安全、及时地停止。
另一个著名的使用场景是“双重检查锁定”(Double-Checked Locking)的单例模式中,对单例实例变量的声明,必须加上 volatile,以防止指令重排序导致的初始化问题。
第一幕:NEW (新生) - 剧本已写好,演员未登台
通过 Thread t = new Thread(); 代码,我们仅仅是创建了一个 Thread 类的实例。
此时,t 仅仅是 JVM 堆内存中的一个普通 Java 对象。操作系统内核完全不知道这个线程的存在。你可以调用这个 t 对象的任何普通方法,比如 t.setName(),但它还没有任何线程的执行能力。如果此时不调用 start() 而是直接调用 run(),那仅仅是一个普通的方法调用,并不会启动新线程。
唯一的途径是调用 t.start() 方法。一旦调用,演员就被通知准备登台,状态立刻切换到 RUNNABLE。如果对同一个线程对象多次调用 start(),会抛出 IllegalThreadStateException。
第二幕:RUNNABLE (可运行) - 在舞台或后台候场
- 从 NEW 状态调用 start() 方法后。
- 从 BLOCKED, WAITING, TIMED_WAITING 状态被唤醒后。
这是线程最核心的工作状态,但它其实是一个“复合状态”。在 JVM 看来,只要线程有资格被 CPU 执行,就都算 RUNNABLE。但从更底层的操作系统来看,这里有两种可能:
- Ready (就绪):演员已经化好妆、换好衣服,在后台候场区,万事俱备,只等导演(CPU 调度器)喊他的名字上台。此时,它不占用 CPU 资源。
- Running (运行中):演员正在舞台中央,聚光灯下,执行着 run() 方法里的代码。此时,它正在占用一个 CPU 核心的时间片。
一个 RUNNABLE 的线程会在这两种状态间频繁切换(比如 CPU 时间片用完,就从 Running 变为 Ready)。
这是状态转换最复杂的环节,可能性很多:
- 试图获取一个已被其他线程占有的 synchronized 锁 -> BLOCKED
- 主动调用 Object.wait() 或 Thread.join() -> WAITING
- 主动调用 Thread.sleep(t) 或 wait(t) -> TIMED_WAITING
- run() 方法执行完毕或抛出未捕获的异常 -> TERMINATED
第三幕:BLOCKED vs WAITING vs TIMED_WAITING - 因不同原因的“暂停”
这是最需要详细区分的三个状态。它们共同点是都放弃了 CPU,暂停执行,但暂停的原因和唤醒的条件截然不同。
我用一个“上厕所”的比喻来区分它们:
1. BLOCKED (阻塞) - “我在门口死等,门一开我就冲”
唯一原因:线程试图进入一个 synchronized 代码块或方法,但该代码块的“门”(监视器锁)被别人锁上了。
这个线程是被动地停下来。它的目标非常明确——就是要拿到那个锁。它就像一个堵在厕所门口的人,什么也不干,就等着里面的人出来。
当持有锁的线程执行完同步代码块,释放了锁,“门”开了。此时所有 BLOCKED 的线程会被唤醒,重新进入 RUNNABLE 的 Ready 状态,再次竞争这把锁,但不保证谁能抢到。
2. WAITING (等待) - “我先去旁边歇着,等朋友叫我”
主动调用 Object.wait()、Thread.join() 或 LockSupport.park()。
这个线程是主动地释放了执行权。它不像 BLOCKED 那样执着于一个锁,而是进入了一个更深层次的“休息室”。比如,调用 wait() 的线程,它会释放掉它已经持有的锁,然后去休息。它在等待一个特定的“信号”。
必须由其他线程发出明确的信号:
- wait() 必须等另一个线程在同一个对象上调用 notify() 或 notifyAll()。
- join() 必须等目标线程执行完毕 (TERMINATED)。
- park() 必须等另一个线程调用 unpark()。
关键细节:一个从 wait() 被唤醒的线程,并不会立刻执行,而是先进入 BLOCKED 状态,因为它需要重新去竞争它之前释放掉的那把锁。
3. TIMED_WAITING (限时等待) - “我等我朋友10分钟,不来我就自己玩”
调用带超时参数的方法,如 Thread.sleep(t)、Object.wait(t)、Thread.join(t)。
这是 WAITING 的“保险版”。线程同样是主动放弃执行权去休息,但它给自己设了个闹钟。
两条路:
- 和 WAITING 一样,在闹钟响之前,收到了朋友的信号(notify 等)。
- 朋友一直没来,但闹钟响了(超时时间到)。
无论哪种情况,线程都会被唤醒,并尝试回到 RUNNABLE 状态。
最终幕:TERMINATED (终止) - 演出结束,演员离场
- run() 方法中的所有代码被顺利执行完毕。
- run() 方法中抛出了一个未被捕获的异常或错误,导致线程提前非正常结束。
这是线程的终点。线程的所有资源(如线程栈)都会被回收。Thread 对象本身还在,但它已经失去了生命力,不能再通过 start() 重生。它的生命周期到此画上句号,剩下的 Thread 对象等待垃圾回收器处理。
第一,final 修饰变量:不变的量
这是 final 最常见的用法。当 final 修饰一个变量时,它就变成了一个常量,意味着这个变量只能被赋值一次。
根据变量类型的不同,这里有两种情况:
- 修饰基本数据类型:
- 示例:final int MAX_COUNT = 10; 之后任何对 MAX_COUNT 的赋值操作,如 MAX_COUNT = 11;,都会导致编译错误。
- 修饰引用数据类型:
- 这是一个非常关键的考点。final 在这里保证的是引用变量的引用地址不可改变,但不保证该引用所指向的对象内部的状态不可改变。
- 作用:这个引用变量永远只能指向初始化时指向的那个对象,不能再让它指向另一个新的对象。
- 示例:
final List<String> list = new ArrayList<>();
list.add("A"); // 这是允许的,我们在修改 list 对象内部的状态
list.add("B"); // 这也是允许的
// list = new LinkedList<>(); // 这是不允许的,试图让 final 引用指向一个新对象,会编译失败
- 使用场景:这种特性对于创建不可变对象(Immutable Object)至关重要。比如,一个 User 类,它的 userId 应该是创建后就不能再变的,我们就可以声明为 private final String userId;。
-
特别补充一点:在方法参数或局部变量前使用 final,在匿名内部类中尤其常见。这是因为匿名内部类会持有这个局部变量的一个副本,为了保证数据的一致性(即内外看到的值永远一样),Java 强制要求这个变量必须是 final 的(或者在 Java 8 之后是“事实上的 final”)。
第二,final 修饰方法:不许重写
当 final 修饰一个方法时,它向所有子类宣告:“这个方法的实现已经是最终版本,你们任何人都不能重写(Override)它。”
- 核心作用:
- 锁定核心逻辑:类的设计者认为这个方法的实现对于保证类的功能和状态至关重要,不希望子类通过重写来破坏这个逻辑。
- 保证安全性:防止子类通过重写关键方法(例如,一个权限校验方法)来引入安全漏洞。
- 示例:在 Object 类中,getClass() 方法就是 final 的,因为任何对象都必须能够准确地返回自身的运行时类型,这个行为不应该被任何子类篡改。
- 注意:private 方法是隐式 final 的。因为 private 方法对子类不可见,所以它本身就不可能被重写。
- 核心作用:
- 彻底的安全性:这是保证一个类行为不变的终极手段。当一个类是 final 时,我们可以完全确信它的所有方法(无论是 final 还是非 final 的)都不会被任何子类修改。
- 创建不可变类:这是创建不可变类的必要条件。最著名的例子就是 String 类。String 被设计为不可变的,如果它能被继承,那我们就可以创建一个子类,让它的行为变得“可变”,这将破坏整个 Java 世界中依赖 String 不可变性的无数代码。
-
第三,final 修饰类:不许继承
当 final 修饰一个类时,这个类就成为了“绝育”类,它不能被任何其他类继承。
对于 final int a; 这个定义,我们必须对 a 进行赋值,但是绝对不能对 a 进行修改。
这里需要把“赋值”和“修改”这两个概念拆开来看:
1. 关于“赋值”(我们更应该称之为“初始化”)
一个 final 变量不是不能赋值,而是必须且只能被赋值一次。这个过程叫做初始化。
Java 编译器会强制我们必须为 final 变量进行初始化。如果不初始化,编译器会直接报错。初始化的时机取决于这个变量在哪里定义:
- 在定义时:final int a = 10;
- 在构造代码块中:{ a = 10; }
- 在构造函数中:public MyClass() { this.a = 10; }
- 编译器会确保,在对象构造完成之前,这个 final 变量一定被赋予了初值。
- 我们不需要在定义时立即初始化,但必须在使用它之前的某个地方对它进行初始化。这被称为 “空白 final” (blank final)。
public void myMethod() {
final int a; // 定义时OK,未初始化
// ... 一些逻辑代码 ...
a = 10; // 在这里进行初始化
System.out.println(a); // 现在可以使用了
}
- 编译器会通过代码流分析,确保在任何可能执行到 a 的路径上,a 都已经被初始化了。如果存在某个分支没有初始化 a,就会编译失败。
2. 关于“修改”
一旦 final 变量被初始化(完成了那唯一的一次赋值)之后,它的生命周期内就再也不能被修改了。
任何试图再次对它进行赋值的操作,例如:
final int a = 10;
a = 20; // 这行代码会直接导致编译错误
都会被编译器拒绝。
总结一下:
所以,对于您的问题“直接定义final int a能不能对a进行赋值以及修改?”
- 赋值:能,而且是必须能。它必须被精确地赋值一次(也就是初始化)。
- 修改:绝对不能。在它被初始化之后,任何尝试改变它的值的行为都是非法的。
/**
* 使用 synchronized, wait(), notifyAll() 实现三个线程交替打印 1-100
*/
public class ThreeThreadPrinter
try {
lock.wait();
} catch (InterruptedException e) {
// 处理中断异常,恢复中断状态并退出
Thread.currentThread().interrupt();
return;
}
}
// 再次检查,因为线程可能是在数字超出范围后被唤醒的
if (number > MAX_NUMBER) {
return;
}
// 打印当前数字
System.out.println(Thread.currentThread().getName() + ": " + number);
// 数字加一
number++;
// 唤醒所有其他等待的线程,让它们来竞争锁并检查条件
lock.notifyAll();
}
}
}
public static void main(String[] args) {
ThreeThreadPrinter printer = new ThreeThreadPrinter();
// 创建三个线程,分别对应 threadId 0, 1, 2
Thread thread1 = new Thread(() -> printer.print(0), "Thread-1");
Thread thread2 = new Thread(() -> printer.print(1), "Thread-2");
Thread thread3 = new Thread(() -> printer.print(2), "Thread-3");
// 启动线程
thread1.start();
thread2.start();
thread3.start();
}
}
关于注解(Annotation)的使用场景,我的理解是,注解本身不做任何事情,它就像一个“标签”或“元数据”,我们把它贴在代码的各种元素上(比如类、方法、字段)。它的核心价值在于,在后续的编译时或运行时,有专门的工具或框架可以来读取这些“标签”,并根据标签信息执行特定的、自动化的处理流程。
注解极大地提升了代码的表达能力和自动化程度,是现代框架设计中不可或-缺的基石。在 Android 开发中,注解的使用场景非常广泛,主要可以分为以下三类:
第一类:编译时处理 (Annotation Processing)
这是注解在 Android 中最重要、最强大的使用场景。它的核心思想是,在 Java 源代码编译成 .class 字节码的过程中,通过一个叫做 “注解处理器”(Annotation Processor) 的工具,扫描我们代码中的特定注解,然后自动生成一系列的 .java 源代码文件。
这种方式的最大优点是,它在编译期就完成了所有工作,对应用的运行时性能没有任何影响,只是可能会增加一些编译时间。
典型使用场景举例:
- 依赖注入 (Dependency Injection) - Dagger / Hilt
- 我们在 Activity 的字段上标记 @Inject,Dagger/Hilt 的注解处理器在编译时就会识别到这个“标签”。
- 它会自动分析这个字段需要什么类型的实例,然后生成一整套复杂的、用于创建和提供这个实例的“工厂”代码。
- 最终,我们在运行时只需要调用 DaggerMyComponent.create().inject(this),所有被 @Inject 标记的字段就会被自动赋值,我们完全不需要自己写 new 对象的代码。
- 数据绑定 / 视图绑定 - ButterKnife (早期) / DataBinding & ViewBinding (现代)
- 早期我们在 Activity 中写 findViewById,非常繁琐。ButterKnife 允许我们用 @BindView 注解标记一个 View 字段。
- 它的注解处理器在编译时会扫描这些注解,并自动生成类似 view = activity.findViewById(R.id.xxx) 的模板代码。
- 虽然现在有了官方的 ViewBinding,但其设计思想是一脉相承的。
- 路由框架 - ARouter
- 我们在 Activity 类上标记 @Route(path = "/user/login")。
- ARouter 的注解处理器会收集所有这些路径和 Activity 的对应关系,并在编译时生成一个“路由表”的映射文件。
- 运行时,我们只需要调用 ARouter.getInstance().build("/user/login").navigation(),框架就能根据路径查表,然后通过 Intent 启动对应的 Activity。
- 数据持久化 - Room
- 我们用 @Entity 标记一个数据类,用 @Dao 标记一个数据访问接口。
- Room 的注解处理器会在编译时解析这些注解,自动生成所有用于操作 SQLite 数据库的 CREATE TABLE 语句和复杂的 CRUD 实现代码。
第二类:运行时处理 (Runtime Reflection)
这类注解的保留策略是 RetentionPolicy.RUNTIME,意味着它们在编译成 .class 文件后依然存在,并且在应用运行时可以通过反射(Reflection)来读取。
这种方式的优点是非常灵活,可以在运行时动态地改变逻辑。但缺点是依赖反射,会对性能产生一定的影响,尤其是在频繁调用的代码路径上。
典型使用场景举例:
- JSON 解析 - GSON / Jackson
- 我们定义一个 Java Bean,用 @SerializedName("user_name") 注解来指定其字段与 JSON key 的映射关系。
- 当 GSON 在运行时解析 JSON 字符串时,它会通过反射读取这个 Bean 的类结构和字段上的注解。
- 当它看到 user_name 这个 key 时,就知道应该把对应的值赋给被 @SerializedName 注解标记的那个字段。
- 网络请求 - Retrofit
- 这是运行时注解最经典的用法。我们定义一个接口,用 @GET("/users/{id}") 和 @Path("id") 等注解来描述一个 HTTP 请求的全部信息(请求方法、URL、参数等)。
- 当我们调用这个接口方法时,Retrofit 会在运行时通过动态代理创建一个实现类。在这个实现类中,它通过反射解析我们接口方法上的所有注解,然后动态地组装出一个完整的 OkHttp 的 Request 对象并执行。
第三类:代码检查与约束 (Lint Checks & Information)
这类注解主要是给开发者和编译工具看的,用于提供额外信息或施加某种约束,帮助我们写出更健通、更规范的代码。
典型使用场景举例:
- 资源类型约束 - @StringRes, @DrawableRes, @ColorInt
- 这些是 Android Support/AndroidX 库提供的注解。如果我们定义一个方法 void setTitle(@StringRes int resId),它就告诉调用者,这里必须传入一个字符串资源的 ID(比如 R.string.app_name),而不是一个普通的整数。
- 如果你传入了 setTitle(123),Android Studio 会立刻给出 Lint 警告,甚至在编译时报错。
- 线程约束 - @MainThread, @WorkerThread
- 用来标记一个方法或回调必须在哪个线程上执行。例如,View 的很多方法都被标记为 @MainThread,如果你尝试在子线程中调用,IDE 就会警告你。
- 空指针检查 - @NonNull, @Nullable
- 向编译器和开发者明确表示一个参数、字段或方法返回值是否可能为 null,这在 Java 和 Kotlin 混合编程中尤其重要,可以帮助我们避免很多 NullPointerException。
1. @Retention:这个标签能存活多久?
这是最重要的元注解,它决定了我们定义的注解的生命周期。它有三个可选值,分别对应注解从源码到运行的三个阶段:
- RetentionPolicy.SOURCE (源码级别)
- 区别:注解只存在于 .java 源码文件中,当编译器把源码编译成 .class 字节码文件后,这个注解就被丢弃了。
- 类比:这就像是程序员写给自己或编译工具看的便签。代码写完了,便签的作用就达到了,可以直接扔掉,不会带到最终的产品里。
- 使用场景:这类注解的消费者是编译器或静态代码检查工具(Lint)。
- 最经典的例子是 @Override。它告诉编译器去检查父类是否有这个方法,检查完就没用了。
- Android 中的 @NonNull, @StringRes 等,它们用于在编码和编译阶段进行静态检查,防止我们犯错,但不会进入到最终的 APK 运行。
- RetentionPolicy.CLASS (字节码级别)
- 区别:注解会被保留到编译后的 .class 文件中,但是当 JVM(或 Android 的 DVM/ART)在运行时加载这个类时,会把它丢弃。
- 类比:这就像是产品包装上的一个内部追踪码。在仓库流转时有用,但最终送到客户手里时,客户是看不到也用不上这个码的。
- 使用场景:这是默认的保留策略。它主要用于一些需要在字节码层面进行分析和处理的工具,比如字节码插桩(Bytecode Weaving)。在常规 Android 应用开发中,我们直接使用它的场景相对较少。
- RetentionPolicy.RUNTIME (运行时级别)
- 区别:注解会被一直保留到运行时,程序在运行的时候,可以通过反射(Reflection)机制来读取并使用这些注解的信息。
- 类比:这就像是产品包装上给最终用户看的“使用说明”。用户拿到产品后,可以随时查看并根据说明来操作。
- Retrofit:@GET, @POST 等注解,在运行时通过反射来动态构建网络请求。
- GSON/Jackson:@SerializedName 等注解,在运行时通过反射来完成 JSON 和 Java 对象的映射。
2. @Target:这个标签能贴在哪里?
这个元注解用来限定我们自定义的注解可以应用在哪些代码元素上。如果不加此注解,默认可以贴在任何地方。
- 区别:它通过 ElementType 枚举来指定有效的目标。
- 类比:这就像是规定“易碎”标签只能贴在“包裹”上,不能贴在“卡车司机”身上。
- ElementType.TYPE:类、接口、枚举。 (例如 Room 的 @Entity)
- ElementType.FIELD:类的成员变量。 (例如 Dagger 的 @Inject)
- ElementType.METHOD:类的方法。 (例如 Retrofit 的 @GET)
- ElementType.PARAMETER:方法的参数。 (例如 Retrofit 的 @Path)
- ElementType.CONSTRUCTOR:构造函数。
3. @Inherited:子类能继承这个标签吗?
这个元注解用来指定,如果一个类使用了某个被 @Inherited 修饰的注解,那么它的子类是否会自动拥有这个注解。
- 区别:它是一个标记注解,没有属性。默认情况下,注解是不被继承的。
- 类比:这就像是家族的一个“贵族”头衔。如果这个头衔是 @Inherited 的,那么父亲是贵族,儿子也自动是贵族。如果不是,那就只有父亲自己是。
- 使用场景:当你希望某个注解的效果可以传递给整个继承体系时使用。比如,你可以定义一个 @RequiresPermission 注解,如果将它标记为 @Inherited 并用在一个 BaseActivity 上,那么所有继承自 BaseActivity 的子类 Activity 都会被认为需要这个权限。
4. @Documented:要不要把这个标签写进说明书里?
这个元注解用来指定,在使用 javadoc 工具生成 API 文档时,是否要将被 @Documented 修饰的注解也包含进去。
- 类比:这决定了我们贴的这个“标签”是否属于公开信息的一部分,需要展示给所有看 API 文档的人。
- 使用场景:如果你的注解是公开 API 的一部分,并且你希望使用者在查阅文档时能够看到它,那么就应该使用 @Documented。
Retrofit 使用的注解,从元注解的分类上来看,全部属于运行时注解(Runtime Annotations)。
这意味着,定义 Retrofit 注解(比如 @GET, @POST, @Path 等)的源码中,都包含了元注解 @Retention(RetentionPolicy.RUNTIME)。
我可以从“为什么必须是运行时注解”以及“它内部是如何工作的”这两个角度来详细解释。
第一,为什么 Retrofit 必须使用运行时注解?
Retrofit 的核心设计理念是:让开发者通过一个简单的 Java 接口,来声明式地定义一个 HTTP 请求。我们写的只是接口,并没有写任何实现类。
那么,这个接口是如何变成一个能真正发起网络请求的对象的呢?这整个“魔法”过程,是在应用运行的时候动态完成的。
这个过程可以分为两步:
- 创建代理对象:当我们调用 retrofit.create(ApiService.class) 时,Retrofit 内部会使用 Java 的动态代理(Dynamic Proxy) 机制,在内存中动态地为你创建了一个 ApiService 接口的实现类实例。这个实例就是一个代理对象。
- 解析注解并执行:当我们调用这个代理对象的方法时(比如 api.getUser(123)),这个调用会被动态代理拦截下来。在拦截的处理逻辑中,Retrofit 会做一件核心的事情:使用 Java 反射(Reflection)来解析我们调用的那个方法(getUser)以及它上面的所有注解。
为了能在运行时通过反射读取到注解信息,这个注解的生命周期就必须是 RetentionPolicy.RUNTIME。如果是 SOURCE 或 CLASS 级别的,那么在运行时这些注解信息早就被丢弃了,Retrofit 将无法知道这个方法到底对应一个 GET 请求还是 POST 请求,URL 又是什么。
第二,Retrofit 是如何利用这些运行时注解的?
我们可以把 Retrofit 接口上的注解看作是一份详细的 “HTTP 请求说明书”。当方法被调用时,Retrofit 的代理逻辑就会像一个“阅读器”一样,来解读这份说明书,并组装出一个真正的 OkHttp 请求对象。
我们来看一个具体的例子:
@GET("users/{id}")
Call<User> getUser(@Path("id") int userId, @Query("sort") String sort);
当 api.getUser(123, "desc") 被调用时,Retrofit 的运行时处理流程如下:
- 读取方法注解:通过反射,它看到了 @GET("users/{id}") 注解。
- 解读:“好的,这是一个 GET 请求,URL 的基础路径是 users/{id}。”
- 遍历并读取参数注解:它接着检查方法的参数列表。
- 第一个参数:它看到了 @Path("id") 注解。
- 解读:“这个参数是用来替换 URL 路径中的占位符的。注解的值是 id,那么我就用调用时传入的实参 123 去替换路径中的 {id}。”
- 第二个参数:它看到了 @Query("sort") 注解。
- 解读:“这是一个 URL 查询参数。注解的值是 sort,那么我就用调用时传入的实参 desc 组装成一个查询参数 ?sort=desc。”
- 组装并执行:
- Retrofit 将所有解读到的信息组合起来,最终在内部构建出一个完整的、可以被 OkHttp 执行的 Request 对象,它的 URL 就是 https://your-base-url.com/users/123?sort=desc。
@Override 注解,从元注解的分类上来看,它是一个源码注解 (Source Annotation)。
这意味着,在 @Override 注解本身的定义上,它的元注解被设置为 @Retention(RetentionPolicy.SOURCE)。
为什么 @Override 是源码注解?
这完全是由它的核心作用决定的。@Override 的唯一作用就是给 Java 编译器看的,它像一个给编译器的“指令”或“提醒”。
它的意思是告诉编译器:“我(程序员)认为我正在写的这个方法,是用来重写父类或实现接口中的方法的。请你帮我检查一下,我的想法对不对。”
编译器在收到这个指令后,就会执行以下检查:
- 它会去检查当前类的所有父类以及实现的接口。
- 它会确认是否存在一个与被 @Override 注解标记的方法具有完全相同方法签名(即相同的方法名和参数列表)的方法。
- 如果找到了,编译通过。
- 如果没有找到,编译器就会立刻报错,提示“方法不会覆盖或实现超类型的方法”。
@Override 的生命周期
正是因为它的工作全部在编译期就完成了,所以它完全没有必要存活到运行时。
- 在源码中 (.java 文件):@Override 注解存在,并被编译器读取和处理。
- 在编译后 (.class 文件):一旦编译器完成了它的检查工作,@Override 注解的使命就结束了。因此,它会被编译器直接丢弃,不会被写入到 .class 字节码中。
- 在运行时:由于字节码中已经没有 @Override 的信息了,所以在应用运行时,JVM 或 ART 完全不知道这个注解的存在。
@Override 的价值
它是一个典型的利用注解进行静态代码检查、防止错误的例子。
我们来看一个最经典的场景——拼写错误:
假设我们想重写 toString() 方法。
- 不加 @Override:
public String tostring() { // 注意这里是小写 t
return "MyObject";
}
这段代码在编译和运行时都不会有任何错误。但是,我们实际上并没有重写 toString(),而是定义了一个全新的、名为 tostring() 的方法。当我们调用 System.out.println(myObject) 时,它还是会去调用 Object 类的默认 toString() 方法,这显然不是我们想要的结果,是一个非常隐蔽的 Bug。
- 加上 @Override:
@Override
public String tostring() { // 小写 t
return "MyObject";
}
现在,编译器在看到 @Override 后,会去检查父类有没有 tostring() 方法。它找不到,于是立刻给出一个编译期错误。这样,我们就在最早的阶段发现了这个拼写错误,避免了一个潜在的运行时 Bug。
我的决策框架主要基于以下核心原则:尽可能地将工作“左移”到编译期,以换取最优的运行时性能。
首先,我们需要清晰地认识到这两种方式背后的技术实现是完全不同的:
- RetentionPolicy.RUNTIME 的实现依赖于运行时反射 (Reflection)。
- RetentionPolicy.CLASS 的实现通常依赖于编译期或类加载期的字节码编辑/织入 (Bytecode Weaving),比如通过 AspectJ 或 ASM 这样的工具。
基于这个区别,我的选择流程如下:
第一步:重新审视问题,引入“第三个选项”——SOURCE
在 Android 的生态中,当我们讨论“编译期处理”时,90% 的场景其实并不是指 CLASS 级别的字节码编辑,而是指 RetentionPolicy.SOURCE 配合注解处理器(APT 或 KSP)进行代码生成。
所以,我会首先把这个问题扩展为三个选项的对比:SOURCE(代码生成)、CLASS(字节码编辑)、RUNTIME(反射)。
SOURCE 级别的代码生成是现代 Android 开发的首选。像 Dagger/Hilt, Room, ARouter 等主流框架,都遵循这个模式。
- 零运行时开销:所有繁重的工作都在编译期完成,生成的是普通的、高效的 Java/Kotlin 代码。运行时没有任何反射或额外处理,性能最优。
- 编译期安全:如果注解使用不当(比如路径写错),在编译阶段就会直接报错,而不是等到运行时才崩溃。
- 代码透明可调试:生成的代码是可见的 .java 文件,我们可以直接阅读和断点调试,完全没有“黑魔法”。
- 可能会增加编译时间。
- 对于需要极高动态性的场景无能为力。
所以,我的第一个判断标准是:
问题一:这个功能所需要的逻辑和信息,在编译期是否是完全已知的?
- 如果是,那么我毫不犹豫地选择 SOURCE 级别 + 注解处理器。这是性能最好、最安全、最符合现代 Android 架构思想的选择。
第二步:如果 SOURCE 不适用,再对比 CLASS 和 RUNTIME
只有在“代码生成”这条路走不通的情况下(比如,功能逻辑必须在运行时动态决定),我们才需要真正地在 CLASS 和 RUNTIME 之间做选择。
这时,我的判断标准是:
问题二:这个功能是“横切关注点”(Cross-Cutting Concern)吗?它是否适合用 AOP(面向切面编程)的思想来解决?
- 它最适合实现那些与核心业务逻辑无关,但又需要普遍应用的功能。比如:
- 无痕埋点:自动在所有 onClick 方法执行前后插入统计代码。
- 性能监控:自动在所有网络请求方法的出入口计算耗时。
- 权限检查:自动在所有需要特定权限的方法执行前插入检查逻辑。
- 选择理由:通过字节码编辑,我们可以将这些通用逻辑像“贴膏药”一样精确地“织入”到目标代码中,而完全不需要修改原始的业务代码,实现了高度的解耦。它的性能也优于运行时反射。
- 它适用于那些逻辑本身具有高度动态性,必须在运行时根据上下文信息才能做出决定的场景。
- JSON 解析 (Gson):在编译期,我们无法预知服务器会返回怎样的 JSON 字符串。只有在运行时拿到字符串后,才能通过反射去匹配 Bean 的字段并赋值。
- 网络请求 (Retrofit):Retrofit 的设计就是为了极度的灵活性。它在运行时动态地解析接口定义,允许我们通过拦截器等机制动态地修改请求。这种灵活性是编译期方案难以给予的。
- 选择RUNTIME,意味着我们愿意用一定的性能开销,去换取这种必要的运行时灵活性。
总结一下我的选择策略:
- 首选 SOURCE + 代码生成:只要功能能在编译期完成,就用这个方案。这是 Android 开发的最佳实践,兼顾了性能、安全和可维护性。
- 次选 CLASS + 字节码编辑:当功能是典型的 AOP 场景(如日志、埋点、性能监控),且不希望侵入业务代码时,选择这个方案。
- 最后考虑 RUNTIME + 反射:当且仅当功能逻辑必须在运行时动态决定,无法在编译期确定时(如 JSON 解析、Retrofit),才使用这个方案。这是一种为了获得极致灵活性而做的性能妥协。
个问题的关键在于如何定义“绕路”。在一个二维矩阵中,如果不考虑障碍物且只能沿网格线(上、下、左、右)移动,那么从点 A 到点 B 的最短路径长度是固定的,它等于两点之间的曼哈顿距离。
曼哈顿距离的计算公式为:|A.x - B.x| + |A.y - B.y|。
因此,判断一个给定的路径是否绕路,我们只需要做两件事:
- 计算理论最短距离:使用曼哈顿距离公式计算出从 A 到 B 的最短步数。
- 计算实际路径长度:一个由多个点组成的路径,其实际长度(步数)等于路径上的 点的数量 - 1。
- 比较:如果 实际路径长度 > 理论最短距离,那就说明路径中存在无效的移动(比如回头、绕圈),即“绕路”了。
FUNCTION isDetour(point A, point B, path):
// 1. 计算理论上的最短距离 (曼哈顿距离)
shortest_distance = abs(A.x - B.x) + abs(A.y - B.y)
// 2. 计算实际走的步数
// 路径的步数是路径上点的数量减一
actual_steps = path.length - 1
// 3. 比较并返回结果
// 如果实际走的步数比最短距离还多,就是绕路了
RETURN actual_steps > shortest_distance
END FUNCTION