一只修仙的猿

一只修仙的猿 查看完整档案

填写现居城市  |  填写毕业院校二叉树  |  右子树 编辑 qwerhuan.gitee.io 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 个人简介什么都没有

个人动态

一只修仙的猿 发布了文章 · 3月30日

通俗易懂,android是如何管理内存的

封面来源:https://medium.com/android-ne...

前言

很高兴遇见你~

内存优化一直是 Android 开发中的一个非常重要的话题,他直接影响着我们 app 的性能表现。但这个话题涉及到的内容很广且都偏向底层,让很多开发者望而却步。同时,内存优化更加偏向于“经验知识”,需要在实际项目中去应用来学习。

因而本文并不想深入到底层去讲内存优化的原理,而是着眼于宏观,聊聊 android 是如何分配和管理内存、在内存不足的时候系统会如何处理以及会对用户造成什么样的影响。

Android 应用基于 JVM 语言进行开发,虽然 google 根据移动设备特点开发了自家的虚拟机如 Dalvik、ART,但依旧是基于 JVM 模型,在堆区分配对象内存。因此 Java heap(java 堆)是android应用内存分配和回收的重点。其次,移动设备的 RAM 非常有限,如何为进程分配以及管理内存也是重中之重。

文章的主要内容是分析 Java heap、RAM 的内存管理,以及当内存不够时 android 会如何处理。

那么,我们开始吧。

Java Heap

Java Heap,也就是 JVM 中的堆区。简单回顾一下 JVM 中运行时数据区域的划分:

  • 橙色区域的方法栈以及程序计数器属于线程私有,主要存储方法中的局部数据。
  • 方法区主要存储常量以及类信息,线程共享。
  • 堆区主要负责存储创建的对象,几乎一切对象的内存都在堆区中分配,同时也是线程共享。

我们在 android 程序中使用如 Object o = new Object() 代码创建的对象都会在堆区中分配一块内存进行存储,具体如何分配由虚拟机解决而不需要我们开发者干预。当一个对象不再使用时, JVM 中具有垃圾回收机制(GC),会自动释放堆区中无用的对象,重新利用内存。当我们请求分配的内存已经超过堆区的内存大小,则会抛出 OOM 异常。

在 android 中,堆区是一个由 JVM 逻辑划分的区域,他并不是真正的物理区域。堆区并不会直接全部映射和他等量大小的物理内存,而是到了需要使用时,才会去建立逻辑地址和物理地址的映射:

这样可以给应用分配足够的逻辑内存大小,同时也不必在启动时一次性分配一大块的物理内存。在相同大小的内存中,可以运行更多的程序。

当堆区进程 GC 之后,释放出来多余的空闲内存,会返还给系统,减少物理内存的占用。但这个过程涉及到比较复杂的系统调用,若释放的内存较为少量,可能得不偿失,则无需返还给系统,在堆区中继续使用即可。

在 GC 过程中,如果一个对象不再使用,但是其所占用的内存无法被释放,导致资源浪费,这种现象称为内存泄漏。内存泄露会导致堆区中的对象越来越多,内存的压力越来越大,甚至出现 OOM 。因此,内存泄露是我们必须要尽量避免的现象。

进程内存分配

堆区的内存分配,属于进程内的内存分配,由进程自己管理。下面讲一个应用,系统是如何为其分配内存的。

系统的运行内存,即为我们常说的 RAM ,是应用的运行空间。每个应用必须装入内存中才可以被执行:

  • 我们安装的应用进程都位于硬盘中
  • 当一个应用被执行时,需要装入到 RAM 中才能被执行(zRAM 是为了压缩数据节省空间而设计,后续会讲到)
  • CPU 与 RAM 交互,读取指令、数据、写入数据等

    RAM 的大小为设备的硬件内存大小,是非常宝贵的资源。现代手机常见的运存是6G、8G或者12G,一些专为游戏研发的手机甚至有18G,但同时价格也会跟上去。

Android 采用分页存储的方式把一个进程存储到 RAM 中。分页存储,简单来说就是把内存分割成很多个小块,每个应用占用不同的小块,这些小块也可以称为页:

前面讲到,进程的堆区并不是一次性分配,当需要分配内存时,系统会为其分配空闲的页;当这些页被回收,那么有可能被返还到系统中。

这里的页、块概念涉及到操作系统的分页存储,这里并不打算展开详细讲解,有兴趣的读者可以自行了解:分页存储-维基百科。本文中的“页”与“块”可以不严谨地理解为同个概念,为了帮助理解这里不进行详细地区分。

分配给进程的页可以分为两种类型:干净页、脏页:

  • 干净页:进程从硬盘中读取数据或申请内存之后未进行修改。这种类型的页面在内存不足的时候可以被回收,因为页中存储的数据可通过其他的途径复原。
  • 脏页:进程对页中的数据进行了修改或数据存储。这类页面不能被直接回收,否则会造成数据丢失,必须先进行数据存储。

zRAM,是作为 RAM 中的一个分区,当内存不足时,可以把一些类型的页压缩之后存储在zRAM中,当需要使用的时候再从zRAM中调出。通过压缩来节省应用的空间占用,同时不需要与硬盘进行调度,提高了速度。

这里需要理解的一个点是:内存中的操作速度要远远比硬盘操作快。即使与zRAM的调入和调出需要压缩和解压,其速度也是比与硬盘交互快得多。

内存不足管理

前面我们一直强调,移动设备的内存容量是非常有限的,需要我们非常谨慎地去使用它。幸运的是,JVM 和 android 系统早就帮我们想到了这一点。

面对不同的内存压力,android 会有不同的应对策略。从低到高依次是 GC、内核交换守护进程释放内存、低内存终止守护进程杀死进程释放内存;他们的代价也是逐步上升。下面我们依个来介绍一下。

GC 垃圾回收

GC 属于 JVM 内部的内存管理机制,他管理的内存区域是堆区。当我们创建的对象越来多,堆区的压力越来越大时,GC 机制就会启动,开始回收堆区中的垃圾对象。

辨别一个对象是否是垃圾,虚拟机采用的是可达性分析法。即从一些确定活跃有用的对象出发,向下分析他的引用链;如果一个对象直接或者间接这些对象所引用,那么他就不是垃圾,否则就是垃圾。这些确定活跃有用的对象称为 GC Roots:

  • 如上图,其中绿色的对象被 GC Roots 直接或间接引用,则不会被回收;灰色的对象没有被引用则被标记为垃圾

GC Roots对象的类型比较常见的是静态变量以及栈中的引用。静态变量比较好理解,他在整个进程的执行期间不会被回收,因此他肯定是有用的。栈,这里指的是 JVM 运行数据区域中的方法栈,也就是局部变量引用,在方法执行期间肯定是活跃的。由于方法栈属于线程私有,因此这里等于活跃线程持有的对象不会被回收。

因此,如果一个对象对于我们的程序不再使用,则必须解除 GC Roots 对其的引用,否则会造成内存泄露。例如,不要把 activity 赋值给一个静态变量,这样会导致界面退出时activity无法被回收。

GC 也并不是直接对整个堆区进行回收,而是将堆区中的对象分成两个部分:新生代、老年代。

刚创建的对象大都会被回收,而在多次回收中存活的对象则后续也很少被回收。新生代中存储的对象主要是刚被创建不久的对象,而老年代则存储着那些在多次 GC 中存活的对象。那么我们可以针对这些不同特性的对象,执行不同的回收算法来提高GC性能:

  • 对于新创建的对象,我们需要更加频繁地对他们进行GC来释放内存,且每次只需要记录需要留下来的对象即可,而不必要去标记其他大量需要被回收的对象,提高性能。
  • 对于熬过很多次GC的对象,则可以以更低的频率对他门进行GC,且每次只需要关注少量需要被回收的对象即可。
具体的垃圾回收算法就不继续展开了,了解到这里就可以。感兴趣的读者可以点击查看垃圾回收文章,或者阅读相关书籍。

单次的垃圾回收速度是很快的,甚至我们都无法感知到。但当内存压力越来越大,垃圾回收的速度跟不上内存分配的速度,此时就会出现内存分配等待 GC 的情况,也就是发生了卡顿。同时,我们无法控制 GC 的时机,JVM 有一套完整的算法来决定什么时候进行 GC。假如在我们滑动界面的时候触发 GC ,那么展示出来的就是出现了掉帧情况。因此,做好内存优化,对于 app 的性能表现非常重要。

内核交换守护进程

GC 是针对于 Java 程序内部进行的优化。对于移动设备来说,RAM 非常宝贵,如何在有限的 RAM 资源上进行分配内存,也是一个非常重要的话题。

我们的应用程序都运行在 RAM 中,当进程不断申请内存分配,RAM 的剩余内存达到一定的阈值时,会启动内核交换守护进程来释放内存以满足资源的分配。

内核交换守护进程,是运行在系统内核的一个进程,他主要的工作时回收干净页、压缩页等操作来释放内存。前面讲到,android 是基于分页存储的操作系统,每个进程都会被存储到一些页中。分页的类型有两种:干净页、脏页:

  • 当内核交换守护进程启动时,他会把干净页回收以释放内存。当进程再次访问干净页时,则需要去硬盘中再次读取。
  • 对于脏页,内核交换守护进程会把他们压缩后放入 zRAM 中。当进程访问脏页时,则需要从zRAM中解压出来。

通过不断回收和压缩分页的方式来释放内存,以满足新的内存请求。使用此方式释放的内存也无法满足新的内存请求时,android 会启动低内存终止守护进程,来终止一些低优先级的进程。

低内存终止守护进程

当 RAM 的被占用内存达到一定的阈值,android 会根据进程的优先级,终止部分进程来释放内存。当低内存终止守护进程启动时,说明系统的内存压力已经非常大了,这在一些性能较差的设备中经常出现。

进程的优先级从高到低排序如下,优先级更高的进程会优先被终止:

图片来源:https://developer.android.goo...

从上到下依次是:

  • 后台应用:使用过的 app 会被缓存在后台,下一次打开可以更加快速地进行切换。当内存不足时,此类应用会最快被杀死。
  • 上一个应用:例如从微信跳转到浏览器,此时微信就是上一个应用。
  • 主屏幕应用:这是启动器应用,也就是我们的桌面。如果这个进程被kill了,那么返回桌面时会暂时黑屏。
  • 服务:同步服务、上传服务等等
  • 可觉察的应用:例如正在播放的音乐软件,他可以被我们感知到,但是不在前台。
  • 前台应用:当前正在使用的应用,如果这个应用被kill了,需要向用户报崩溃异常,此时的体验是极差的。
  • 持久性(服务):这些是设备的核心服务,例如电话和 WLAN。
  • 系统:系统进程。这些进程被终止后,手机可能即将重新启动,就像手机突然卡死重启。
  • 原生:系统使用的极低级别的进程,例如我们的内核交换守护进程。

当内存不足,会按照上面的规则,从上到下来终止进程,获得内存资源。这也就是为什么在 android 中我们的后台应用一直被杀死。为了避免我们的应用被优化,内存优化就显得非常重要了。


最后再来回顾一下:

图片来源:https://www.youtube.com/watch...
  • 在0-1阶段,系统的内存资源足够,程序请求内存分配,系统会不断地使用空闲页来满足应用的内存请求
  • 在1-2阶段,系统的可利用内存下降到一个阈值,程序继续请求内存分配,内核交换守护进程启动,开始释放缓存来满足内存请求
  • 在2-3阶段,系统的被利用内存达到一个阈值,系统将启动低内存终止守护进程来杀死进程释放内存

最后

我们文章分析了 android 是如何对内存进行分配以及低内存时如何释放内存来满足内存请求。可以很明显看到,当内存不足时,会严重影响我们 app 的体验甚至整个用户手机的体验:

  • 当内存不足会造成频繁GC、回收干净页、回写缓存,导致应用缓慢、卡顿
  • 如果设备内存一直不够,那么会一直杀死进程影响用户体验,特别是这些进程是用户非常在意的如游戏、微信
  • 内存占用过高会让app在后台被杀死、或者让用户的其他app被杀死、甚至整个系统无法运行而直接崩溃重启,
  • 不是所有的设备都有着高内存,有着设备只有很少的内存,在一些性能较差的设备上甚至会无法运行,这样我们就失去了这些设备的市场

反观现在国内的很多 app,有如扣扣、t宝、iqy,在我这个三年前的机器上运行会发生严重卡顿,偶尔还有ANR崩溃的出现;而当我去测试了youto、tele、Twit等 app ,发现基本不会发生卡顿,甚至在 youto 这样有大量图片视频加载的 app 界面切换也尽享丝滑。这两种 app 的体验是有着天壤之别的。

本文没有讲如何进行内存优化,是因为这一块的内容设计到的太广太深,无法在这篇文章中一并介绍。文章的目的只是为了帮助读者了解android是如何管理内存以及内存不足可能造成的后果,对内存的重要性能有一个感性的认知。

如果文章对你有帮助,还希望留个赞鼓励一下作者~

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
有任何想法欢迎评论区交流指正。
如需转载请评论区或私信告知。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 0 收藏 0 评论 0

一只修仙的猿 赞了文章 · 3月23日

Linux IO模式及 select、poll、epoll详解

注:本文是对众多博客的学习和总结,可能存在理解错误。请带着怀疑的眼光,同时如果有错误希望能指出。

同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。

本文讨论的背景是Linux环境下的network IO。

一 概念说明

在进行解释之前,首先要说明几个概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。

注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

二 IO模式

刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)

注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

阻塞 I/O(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
clipboard.png

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

非阻塞 I/O(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
clipboard.png

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

I/O 多路复用( IO multiplexing)

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

clipboard.png

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

异步 I/O(asynchronous IO)

inux下的asynchronous IO其实用得很少。先看一下它的流程:
clipboard.png

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

总结

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:
clipboard.png

通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

三 I/O 多路复用之select、poll、epoll详解

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。(这里啰嗦下)

select

int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

一 epoll操作过程

epoll操作过程需要三个接口,分别如下:

int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:

struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

二 工作模式

 epoll对文件描述符的操作有两种模式:LT(level trigger)ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
  LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

1. LT模式

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

2. ET模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

3. 总结

假如有这样一个例子:
1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
2. 这个时候从管道的另一端被写入了2KB的数据
3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
4. 然后我们读取了1KB的数据
5. 调用epoll_wait(2)......

LT模式:
如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能受到通知。

ET模式:
如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。

当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:

while(rs){
  buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
  if(buflen < 0){
    // 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
    // 在这里就当作是该次事件已处理处.
    if(errno == EAGAIN){
        break;
    }
    else{
        return;
    }
  }
  else if(buflen == 0){
     // 这里表示对端的socket已正常关闭.
  }

 if(buflen == sizeof(buf){
      rs = 1;   // 需要再次读取
 }
 else{
      rs = 0;
 }
}

Linux中的EAGAIN含义

Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。
从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。

例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

三 代码演示

下面是一段不完整的代码且格式不对,意在表述上面的过程,去掉了一些模板代码。

#define IPADDRESS   "127.0.0.1"
#define PORT        8787
#define MAXSIZE     1024
#define LISTENQ     5
#define FDSIZE      1000
#define EPOLLEVENTS 100

listenfd = socket_bind(IPADDRESS,PORT);

struct epoll_event events[EPOLLEVENTS];

//创建一个描述符
epollfd = epoll_create(FDSIZE);

//添加监听描述符事件
add_event(epollfd,listenfd,EPOLLIN);

//循环等待
for ( ; ; ){
    //该函数返回已经准备好的描述符事件数目
    ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
    //处理接收到的连接
    handle_events(epollfd,events,ret,listenfd,buf);
}

//事件处理函数
static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
{
     int i;
     int fd;
     //进行遍历;这里只要遍历已经准备好的io事件。num并不是当初epoll_create时的FDSIZE。
     for (i = 0;i < num;i++)
     {
         fd = events[i].data.fd;
        //根据描述符的类型和事件类型进行处理
         if ((fd == listenfd) &&(events[i].events & EPOLLIN))
            handle_accpet(epollfd,listenfd);
         else if (events[i].events & EPOLLIN)
            do_read(epollfd,fd,buf);
         else if (events[i].events & EPOLLOUT)
            do_write(epollfd,fd,buf);
     }
}

//添加事件
static void add_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}

//处理接收到的连接
static void handle_accpet(int epollfd,int listenfd){
     int clifd;     
     struct sockaddr_in cliaddr;     
     socklen_t  cliaddrlen;     
     clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);     
     if (clifd == -1)         
     perror("accpet error:");     
     else {         
         printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);                       //添加一个客户描述符和事件         
         add_event(epollfd,clifd,EPOLLIN);     
     } 
}

//读处理
static void do_read(int epollfd,int fd,char *buf){
    int nread;
    nread = read(fd,buf,MAXSIZE);
    if (nread == -1)     {         
        perror("read error:");         
        close(fd); //记住close fd        
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }
    else if (nread == 0)     {         
        fprintf(stderr,"client close.\n");
        close(fd); //记住close fd       
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }     
    else {         
        printf("read message is : %s",buf);        
        //修改描述符对应的事件,由读改为写         
        modify_event(epollfd,fd,EPOLLOUT);     
    } 
}

//写处理
static void do_write(int epollfd,int fd,char *buf) {     
    int nwrite;     
    nwrite = write(fd,buf,strlen(buf));     
    if (nwrite == -1){         
        perror("write error:");        
        close(fd);   //记住close fd       
        delete_event(epollfd,fd,EPOLLOUT);  //删除监听    
    }else{
        modify_event(epollfd,fd,EPOLLIN); 
    }    
    memset(buf,0,MAXSIZE); 
}

//删除事件
static void delete_event(int epollfd,int fd,int state) {
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}

//修改事件
static void modify_event(int epollfd,int fd,int state){     
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}

//注:另外一端我就省了

四 epoll总结

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

epoll的优点主要是一下几个方面:
1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。

  1. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

参考

用户空间与内核空间,进程上下文与中断上下文[总结]
进程切换
维基百科-文件描述符
Linux 中直接 I/O 机制的介绍
IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
Linux中select poll和epoll的区别
IO多路复用之select总结
IO多路复用之poll总结
IO多路复用之epoll总结

查看原文

赞 636 收藏 1075 评论 65

一只修仙的猿 发布了文章 · 3月23日

写给android工程师的cookie分析

前言

很高兴遇见你~

cookie在HTTP1.1版本中被添加,目的是为了解决HTTP的无状态特性,使HTTP变得“有状态”。

我们在做android开发,很多时候并不能很好理解cookie的存在价值、优化。这其实正常。Http中文翻译为超文本传输协议,是为web开发的传输协议。而cookie作为其中的一个功能,他的设计自然也是服务于web。为了更好地理解cookie他的本质,需要站在web开发的角度来理解他,具体而言就是使用浏览器网页具体化客户端。

android的发展要比web晚的多。虽然都使用cookie,但cookie相对于android并没有跟web一样有那么多的好处。但cookie也并不是完全没用,例如我们都知道cookie可以实现记住登录状态。

这篇的文章的目的在于帮助android开发者理解cookie的本质是什么,这样我们在使用cookie的时候会更加心里有底。

购物车的故事

我们都知道,HTTP是一个无状态的协议。无状态体现在每一次的请求与响应都是独立的,他们之间不会互相记录、互相影响。

嗯?我们在后端不是会维护购物车的状态,提供api给客户吗?是的,可以有,我们后端完全可以使用数据结构来存储购物车信息。但这不属于HTTP的范畴了,单纯的HTTP是无法实现这样的功能的,是吧?HTTP的每一次请求都是独立、不互相影响的,他不会记得上一次访问发送了什么返回了什么。

为了记住操作状态,http设计者在请求头中附加一些信息,来识别我们的操作状态:

客户端每次操作购物车,服务端都会为其生成一个字段,并把这个字段发送给客户端。这个字段存储了客户端前面的操作状态,下次客户端请求服务端的时候只需要把这个字段附加在请求头中,服务端通过解析这个字段的内容,就知道前面的请求都做了什么。

例如上图,客户端添加了一个橘子,服务端生成 【orange=1】 并发挥给客户端存储。客户端下一次操作只需要把 【orange=1】 附加在请求头中,服务端就知道客户已经添加一个橘子在购物车了。

到这里已经很明显了,这个字段就是cookie

cookie由服务端生成,在客户端中存储;客户端每次请求附加上cookie,服务端通过解析cookie的内容实现状态记录

整个cookie的机制中,客户端做的事情很简单:存储cookie,附加cookie。客户端不参与cookie的生成,也不参与cookie的修改。而服务端则负责cookie的生成与解析,但是不负责存储。

cookie的实现

cookie在HTTP中通过两个头部字段来实现:cookie和set-cookie

set-cookie在响应报文中附带,告知客户端需要存储该cookie。例如set-cookie:orange=1cookie是请求报文头部的一个字段,附带上服务端生成的cookie,例如cookie:orange=1

sequenceDiagram
客户端->>服务端: 添加一个橘子
服务端-->>客户端: set-cookie:orange=1
客户端->>服务端: cookie:orange=1, 添加两个香蕉
服务端-->>客户端: set-cookie:orange=1&bananer=2

在cookie机制中,客户端和服务端都必须各司其职。

  • 客户端,具体而言就是浏览器,他需要自动把一个网站的生成的cookie存储下来,下一次请求自动把cookie附加在请求头中。
  • 服务端,在收到cookie字段时,需要自动解析并响应对应的结果。如收到orange=1必须自动解析出来添加了一个橘子,再次添加香蕉就是orange=1&bananer=2。

同时,cookie是可以拥有多个的,也就是可以不止有一个cookie,这样一个网站就可以使用cookie同时记录多个状态。我这里我们使用postman看一下百度网站的cookie:

可以看到百度返回了多达6个set-cookie。此外,我们可以通过浏览器来查看一个网站的cookie:

可以看到百度这个网站累积使用多达37个cookie。点击cookie就可以查看具体的内容了。

同时需要注意的是,cookie一般不会明文在网络中传输,而是会进行加密。这在早期没有https的情况下是非常重要的,否则网络中任何节点都有可能劫持到我们的cookie。上面再postman中百度网站的Set-Cookie字段就可以很明显看的出来是经过了加密。

下面我们再通过两个例子来进一步理解cookie。

主题偏好功能

有一些网站具有的一个功能是:不需要登录,但是却可以记住我们的选择的主题,例如暗色或亮色;下一次访问还是我们上次的选择。我们当然可以在后端为每一个ip和端口记录选择结果,但越来越多的访问量占用的空间很大、查询的性能也收到了影响。这个时候就轮到cookie上场了,使用cookie即可更加轻量级地实现这个功能。他的功能模型如下图:

sequenceDiagram
客户端->>服务端: 设置暗色主题
服务端-->>客户端: set-cookie:theme=dark
客户端->>服务端: cookie:theme=dark
服务端-->>客户端: 返回暗色主题的网页

当我们设置主题的时候,服务端会生成一个主题偏好的cookie交给我们存储。下一次只需要把cookie附加在请求头中,服务端解析cookie中的内容,就可以返回对应主题的网页了。相比与在服务端使用数据结构来存储用户的信息,这种方式是不是更加轻量、更加简单?且无需登录注册既可以记住自己的主题偏好。

这种功能在android中似乎不太实用,因为我们的主题使用的是本地的配置,界面的设计内容也都是存储在本地,因而无需服务端来为我们记住主题偏好问题。

但站在web的角度这个功能就非常实用了。网页的具体内容都是存储在服务端,而浏览器只负责渲染界面。这个时候需要cookie来告诉服务端,上次我选择了什么样的主题,这次你也给我返回一样主题的网页界面。

记住登录状态

嘿,登录注册,我们android工程师,就很好理解了。毕竟第一次接触到cookie可能都是使用他来实现记住登录状态,笔者就是如此。当然,在商业项目中,由于cookie设计的不安全性,并不会拿来当记住登录状态的手段,这是后话了,属于登录注册更加具体的内容。先来看看cookie是如何实现记住登录状态的:

sequenceDiagram
客户端->>服务端: user=admin&password=123123
服务端-->>客户端: set-cookie:sessionId=ABC123
客户端->>服务端: cookie:sessionId=ABC123
服务端-->>客户端: 返回暗色主题的网页
  1. 当我们访问服务端进行登录之后,服务端会为我们创建一个session,会话。
  2. 服务端把sessionId放在cookie中返回给我们。
  3. 服务端下一次请求服务器的时候把cookie附加在请求头中。
  4. 服务端通过解析其中的sessionId,找到对应的session,就知道我们已经登录了且可以识别我们的身份,因为我们的登录信息都记录在session中。

此外还有另外一种比较方便但不太安全记住登录的方式:直接把用户名和密码通过某种加密手段加密后存储在cookie中,交由浏览器存储。这种非常简单粗暴,对于加密算法的要求比较高。通常这种类型的cookie都有一个有效期,过期之后则必须重新登录。

在web的环境下,使用cookie来记住登录还是不太安全的,下面了解一下cookie具有哪些缺点。

cookie的缺点

cookie的缺点在于他的 自动 特性,无论是浏览器自动存储和附加cookie,还是服务器自动识别cookie并直接响应,都是不安全的。举几个例子来理解一下。

假如一个银行网站,使用cookie来记录登录状态。当我们访问攻击者的网站是,网站的js脚本可以直接访问该银行的转账接口。浏览器会自动帮我们把银行网站的cookie附加上,而银行服务端解析到我们的cookie,判断在登录中,就直接把钱转账了。

又比如,本地js脚本拿到我们存储的cookie之后,就可以代替我们的身份去访问各个网站。和上面一样,服务端只要看到cookie,就判断是我们本人在操作了。

此外,web bugs这一类的问题也导致了我们的隐私泄露问题--简单来说就是把我们的访问网站信息存储在cookie中,然后请求收集信息的服务器,该服务器通过解析cookie就可以收集到我们的浏览信息,可以给我们精准投递广告。这种方案也常用语浏览器行为跟踪,在合法的范围内有助于提高我们的网络服务质量,但难免会有不法分子整一些不好的操作。

针对这些缺点,HTTP也进行了一些优化。例如httpOnly头部,设置有这个头部的响应报文,会让本地js脚本无法拿到cookie,从而保障了安全。但这个特性需要浏览器进行配合,如果浏览器没有这个实现,依旧是不安全的。

但这些缺点,在android这个环境中是天然安全的。因为我们的app不会被植入脚本代码,也不存在被其他的程序拿到cookie的情况。

在android中使用

首先明确一点是,客户端对于cookie的操作是很有限的。我们只需要负责两件事:对网站的cookie进行存储,在请求的时候附加上cookie

这里需要注意的是不同网站的cookie是需要分开的,不要把逻辑写死每次都附加上所有的cookie。但对于自家的app一般后端只有自家的服务器,那么这个也就相对来说无关紧要。

android开发的网络框架一般用的都是okHttp,下面分析一下如何使用okHttp来进行cookie操作。okHttp框架默认是不实现cookie存储的。如果需要操作cookie,那么有两个方法:使用okHttp的cookie存储接口、使用拦截器

使用cookie存储接口

okHttp预留了一个接口让我们可以很方便地进行cookie操作。我们可以通过调用okHttpClient的方法来实现,如下代码:

val okHttpBuilder = OkHttpClient.Builder()
okHttpBuilder.apply {
    cookieJar(object : CookieJar{
                override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) {
                    // 实现存储cookie的逻辑
                }
                override fun loadForRequest(url: HttpUrl): MutableList<Cookie> {
                    // 实现添加cookie的逻辑
                }
            })
}

CookieJar 是一个接口,这个接口有两个方法分别对应于存储和添加cookie。在收到响应报文时,okHttp会把响应头部的cookie取出来,并回调saveFromResponse方法;在发起一个请求的时候,会调用loadForRequest来返回一个cookie列表,用于添加到请求头部中。

因此我们只需要在创建OkHttpClient的时候,设置好cookie的回调监听即可。

这里需要注意的是,不管是直接使用OkHttp还是Retrofit,都尽量保持OkHttpClient全局单例,这样配置的cookie逻辑才不会失效。Retrofit可通过下面的方法来自定义okHttpClient:

mRetrofit = Retrofit.Builder()
            .client(okHttpBuilder.build())
            .baseUrl(BASE_URL)
            .build()

使用拦截器

okHttp在发送一个请求会经历一系列的拦截器。拦截器可以简单理解为,每一次请求发出去会经过我们配置的拦截器,返回的时候也会经过我们设置的拦截。这样我们就可以在请求时把请求拦截下来添加cookie之后再发送出去;然后在响应的时候,把cookie取下来,再把响应报文返回。如下代码:

class CookieInterceptor() :Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val requestBuilder = chain.request().newBuilder()
        // 为请求添加cookie
        addCookie(requestBuilder)
        // 执行请求
        var response = chain.proceed(requestBuilder.build())
        // 为响应存储cookie
        storeCookie(response)
        return response
    }
}

代码中request就是我们的请求,调用proceed方法之后就会发起请求并拿到response,之后我们把resp中的cookie取下来,再把response返回即可。addCookiestoreCookie方法需要我们自己去实现,具体怎么实现就比较灵活了,Room、SharePreference都是可以的。

然后通过配置client来添加拦截器:

val okHttpBuilder = OkHttpClient.Builder()
okHttpBuilder.apply {
    addInterceptor(CookieInterceptor()))
}

这里我们为OkHttpClient添加了一个我们自定义的拦截器了。

这种方法比第一种直接使用cookieJar要复杂一点,但拦截器的能做的事情比较多,更加灵活。拦截器可以修改一个request中的所有内容,例如我把baidu.com全部重定向到google.com,拦截器是可以做到的,但是cookieJar只专与cookie存储。

关于拦截器的方面就不展开了,感兴趣的读者可以深入去了解一下。

最后

与HTTP相关的很多东西,在一定程度上都是为web端设计。学习的时候觉得云里雾里,可能是打开的方式不对。了解一点前端的知识,从web的角度来理解,再运用到android开发中,会是一个更好的姿势。

cookie的功能更多的还是需要和后端配合,cookie本身只是服务端生成,客户端存储,自动附加与解析的一个字段。在此之上要建立什么功能,则由开发者而定了。

针对于前端而言,这些关于cookie的知识肯定是不够的,但对于android工程师已经差不多,常规的业务开发也已经游刃有余了。

金三银四,最近大家也都在春招吧,那就预祝各位可以顺利上岸,拿到喜欢的大厂offer。

文章如果有帮助,还希望可以点个赞鼓励一下作者~

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
有任何想法欢迎评论区交流指正。

如需转载请评论区或私信告知。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 0 收藏 0 评论 0

一只修仙的猿 赞了文章 · 3月23日

cookie详解

一、 什么是cookie

A cookie is a small stub of information left by a website on a visitor's computer through the web browser.

HTTP cookie,通常称为cookie,用于在客户端存储会话信息。

二、 cookie的构成

一般有以下几部分组成

set cookie: name=value; domain=.mozilla.org; expires=Feb, 13-Mar-2018 11:47:50; path=/; secure

  • 名称:一个唯一确定cookie的名称,部分大小写,cookie的名字必须是经过URL编码的,一般可以采用某个前缀在加上当前时间的做法,这样的话名称能够确保是唯一的,也比较方便。

  • 值:存储在cookie中的字符串值,必须经过被URL编码

  • 域:对于哪个域是有效的,如果没有设置的话,默认来自设置cookie的那个域,在上诉例子中就是.Mozilla.org

  • 失效时间:表示cookie何时应该被删除的时间戳,这个日期是GMT格式的日期,如果设置是以前的时间,cookie会被立刻删除。上诉cookie的失效时间是Feb,13-Mar-2018 11:47:50。

  • 路径:指定域中的那个路径,应该想服务器发送cookie,/ 表示没有限制
    安全标志:指定以后,cookie只有在使用SSL连接的时候才可以发送到服务器。

三、cookie常见操作

1. cookie的建立

function setCookie(name,value,expiredate,domain,path,secure){
    var cookieText=escape(name)+"="+escape(value);
    if(expiredate){
        var exdate=new Date();
        exdate.setDate(exdate.getDate()+expiredate);
        cookieText+=";expires="+exdate.toGMTString();
    }
    if(domain){
        cookieText+=";domain="+domain;
    }
    if(path){
        cookieText+=";path="+path;
    }
    if(secure){
        cookieText+=";secure";
    }
    document.cookie=cookieText;
}

把这六个参数传入进去,或者传入几个参数就可以了,必须传入的参数为name和value。
调用的方式可以如下:

setCookie("my","hello world","January 1,2017");

2.cookie的查询

function getCookie(name){
    var cookieName=encodeURIComponent(name)+"=",
    cookieStart=document.cookie.indexOf(cookieName),
    cookieValue=null;
    if(cookieStart>-1){
        var cookieEnd=document.cookie.indexOf(";",cookieStart);
        if(cookieEnd==-1){
            cookieEnd=document.cookie.Length;
        }
        cookieValue=decodeURIComponent(document.cookie.substring(cookieStart+document.cookie.length,cookieEnd));
    }
    return cookieValue;
}

主要的思路是通过找到字符串的开头和结尾,然后通过substring()函数把value值提取出来。

3.cookie的删除

function deCookie(name,value,expiredate,domain,path,secure){
    this.setCookie(name,"",new Date(0),domain,path,secure);
    }

重新定义cookie,把时间调为过去,原先的cookie就会失效,value也被设置为空值,这样就可以删除一个cookie。

四、 cookie的应用

  • 购物车(网购)

  • 自动登录(登录账号时的自动登录)

  • 精准广告
    平常浏览网页时有时会推出商品刚好是你最近浏览过,买过的类似东西,这些是通过cookie记录的。

  • 记住登录状态

五、cookie优点

  1. 通过良好的编程,控制保存在cookie中的session对象的大小。

  2. 通过加密和安全传输技术(SSL),减少cookie被破解的可能性。

  3. 只在cookie中存放不敏感数据,即使被盗也不会有重大损失。

  4. 控制cookie的生命期,使之不会永远有效。偷盗者很可能拿到一个过期的cookie。、

  5. 不需要服务器资源,直接存储在本地。

六、cookie缺点

1. 每个域的cookie总数是有限的,不同浏览器之间各有不同。

  • IE6以及更低版本限制每个域名最多20个cookie

  • IE7之后的版本每个域名最多50个。

  • Firefox限制每个与最多50个cookie

  • Safari和Chrome对于每个域的cookie数量限制没有硬性规定。

2.cookie的清理

IE和Opera会删除最近最少使用过的cookie,但是Firefox是随机决定要清除哪个cookie。

3. cookie大小的限制

大多数浏览器4096B的长度限制,为了兼容多种浏览器,最好将长度限制在4095B以内.
每个domain最多只能有20条cookie。

4. 安全性

Cookie文件中可能含有涉密信息,可能会导致信息泄露。
Cookie可以被改写,欺骗服务程序或者搜集资料从事非法活动。

七、参考资料

  1. Document.cookie

  2. 《JavaScript高级程序设计》Nicholas C.Zakas著 李松峰 曹力译

查看原文

赞 4 收藏 18 评论 0

一只修仙的猿 发布了文章 · 3月17日

关于Handler同步屏障你可能不知道的问题

文章封面大图来自:https://www.zacsweers.dev/rxa...

前言

很高兴遇见你 ~

关于handler的内容,基本每个android开发者都掌握了,网络中的优秀博客也非常多,我之前也写过一篇文章,读者感兴趣可以去看看:传送门

这篇文章主要讲Handler中的同步屏障问题,这也是面试的热门问题。很多读者觉得这一块的知识很偏,实战中并没有什么用处,仅仅用来面试,包括笔者。我在Handler机制一文中写到:其实同步屏障对于我们的日常使用的话其实是没有多大用处。因为设置同步屏障和创建异步Handler的方法都是标志为hide,说明谷歌不想要我们去使用他

笔者在前段时间面试时被问到这个问题,之后重新思考了这个问题,发现了一些不一样的地方。结合了一些大佬的观点,发现同步屏障这个机制,并不如我们所想完全没用,而还是有他的长处。这篇文章则表达一下我对同步屏障机制的思考,希望对你有帮助。

文章主要内容是:先介绍什么同步屏障,再分析如何使用以及正确地使用。

那么,我们开始吧。

什么是同步屏障机制

同步屏障机制是一套为了让某些特殊的消息得以更快被执行的机制

注意这里我在同步屏障之后加上了机制二字,原因是单纯的同步屏障并不起作用,他需要和其他的Handler组件配合才能发挥作用。

这里我们假设一个场景:我们向主线程发送了一个UI绘制操作Message,而此时消息队列中的消息非常多,那么这个Message的处理可能会得到延迟,绘制不及时造成界面卡顿。同步屏障机制的作用,是让这个绘制消息得以越过其他的消息,优先被执行。

MessageQueue中的Message,有一个变量isAsynchronous,他标志了这个Message是否是异步消息;标记为true称为异步消息,标记为false称为同步消息。同时还有另一个变量target,标志了这个Message最终由哪个Handler处理。

我们知道每一个Message在被插入到MessageQueue中的时候,会强制其target属性不能为null,如下代码:

MessageQueue.class

boolean enqueueMessage(Message msg, long when) {
  // Hanlder不允许为空
  if (msg.target == null) {
      throw new IllegalArgumentException("Message must have a target.");
  }
  ...
}

而android提供了另外一个方法来插入一个特殊的消息,强行让target==null

private int postSyncBarrier(long when) {
    synchronized (this) {
        final int token = mNextBarrierToken++;
        final Message msg = Message.obtain();
        msg.markInUse();
        msg.when = when;
        msg.arg1 = token;

        Message prev = null;
        Message p = mMessages;
        // 把当前需要执行的Message全部执行
        if (when != 0) {
            while (p != null && p.when <= when) {
                prev = p;
                p = p.next;
            }
        }
        // 插入同步屏障
        if (prev != null) { 
            msg.next = p;
            prev.next = msg;
        } else {
            msg.next = p;
            mMessages = msg;
        }
        return token;
    }
}

代码有点长,重点在于:没有给Message赋值target属性,且插入到Message队列头部。当然源码中还涉及到延迟消息,我们暂时不关心。这个target==null的特殊Message就是同步屏障

MessageQueue在获取下一个Message的时候,如果碰到了同步屏障,那么不会取出这个同步屏障,而是会遍历后续的Message,找到第一个异步消息取出并返回。这里跳过了所有的同步消息,直接执行异步消息。为什么叫同步屏障?因为它可以屏蔽掉同步消息,优先执行异步消息。

我们来看看源码是怎么实现的:

Message next() {
    ···
    if (msg != null && msg.target == null) {
        // 同步屏障,找到下一个异步消息
        do {
            prevMsg = msg;
            msg = msg.next;
        } while (msg != null && !msg.isAsynchronous());
    }
    ···
}

如果遇到同步屏障,那么会循环遍历整个链表找到标记为异步消息的Message,即isAsynchronous返回true,其他的消息会直接忽视,那么这样异步消息,就会提前被执行了。

注意,同步屏障不会自动移除,使用完成之后需要手动进行移除,不然会造成同步消息无法被处理。我们可以看一下源码:

Message next() {
    ...
    // 阻塞时间
    int nextPollTimeoutMillis = 0;
    for (;;) {
        // 阻塞对应时间 
        nativePollOnce(ptr, nextPollTimeoutMillis);
        synchronized (this) {
            final long now = SystemClock.uptimeMillis();
            Message prevMsg = null;
            Message msg = mMessages;
            if (msg != null && msg.target == null) {
                // 同步屏障,找到下一个异步消息
                do {
                    prevMsg = msg;
                    msg = msg.next;
                } while (msg != null && !msg.isAsynchronous());
            }
            // 如果上面有同步屏障,但却没找到异步消息,
            // 那么msg会循环到链表尾,也就是msg==null
            if (msg != null) {
                ···
            } else {
                // 没有消息,进入阻塞状态
                nextPollTimeoutMillis = -1;
            }
            ···
        }
    }
}

可以看到如果没有即时移除同步屏障,他会一直存在且不会执行同步消息。因此使用完成之后必须即时移除。但我们无需操心这个,后面就知道了。

如何发送异步消息

上面我们了解到了同步屏障的作用,但是会发现postSyncBarrier方法被标记为@hide,也就是我们无法调用这个方法。那,讲了这么多有什么用?

咳咳~不要慌,但我们可以发异步消息啊。在系统添加同步屏障的时候,不就可以趁机上车了,是吧。

添加异步消息有两种办法:

  • 使用异步类型的Handler发送的全部Message都是异步的
  • 给Message标志异步

给Message标记异步是比较简单的,通过setAsynchronous方法即可。

Handler有一系列带Boolean类型的参数的构造器,这个参数就是决定是否是异步Handler:

public Handler(@NonNull Looper looper, @Nullable Callback callback, boolean async) {
    mLooper = looper;
    mQueue = looper.mQueue;
    mCallback = callback;
    // 这里赋值
    mAsynchronous = async;
}

在发送消息的时候就会给Message赋值:

private boolean enqueueMessage(@NonNull MessageQueue queue, @NonNull Message msg,
        long uptimeMillis) {
    msg.target = this;
    msg.workSourceUid = ThreadLocalWorkSource.getUid();
    // 赋值
    if (mAsynchronous) {
        msg.setAsynchronous(true);
    }
    return queue.enqueueMessage(msg, uptimeMillis);
}

但是异步类型的Handler构造器是标记为hide,我们无法使用,但在api28之后添加了两个重要的方法:

public static Handler createAsync(@NonNull Looper looper) {
    if (looper == null) throw new NullPointerException("looper must not be null");
    return new Handler(looper, null, true);
}

    
public static Handler createAsync(@NonNull Looper looper, @NonNull Callback callback) {
    if (looper == null) throw new NullPointerException("looper must not be null");
    if (callback == null) throw new NullPointerException("callback must not be null");
    return new Handler(looper, callback, true);
}

通过这两个api就可以创建异步Handler了,而异步Handler发出来的消息则全是异步的。

public void setAsynchronous(boolean async) {
    if (async) {
        flags |= FLAG_ASYNCHRONOUS;
    } else {
        flags &= ~FLAG_ASYNCHRONOUS;
    }
}

如何正确使用

上面我们似乎漏了一个问题:系统什么时候添加同步屏障?

异步消息需要同步屏障的辅助,但同步屏障我们无法手动添加,因此了解系统何时添加和删除同步屏障是非常必要的。只有这样,才能更好地运用异步消息这个功能,知道为什么要用和如何用

了解同步屏障需要简单了解一点屏幕刷新机制的内容。放心,只需要了解一丢丢就可以了。

我们的手机屏幕刷新频率有不同的类型,60Hz、120Hz等。60Hz表示屏幕在一秒内刷新60次,也就是每隔16.6ms刷新一次。屏幕会在每次刷新的时候发出一个 VSYNC 信号,通知CPU进行绘制计算。具体到我们的代码中,可以认为就是执行onMesure()onLayout()onDraw()这些方法。好了,大概了解这么多就可以了。

了解过 view 绘制原理的读者应该知道,view绘制的起点是在 viewRootImpl.requestLayout() 方法开始,这个方法会去执行上面的三大绘制任务,就是测量布局绘制。但是,重点来了:

**调用requestLayout()方法之后,并不会马上开始进行绘制任务,而是会给主线程设置一个同步屏障,并设置 ASYNC 信号监听。
当 ASYNC 信号的到来,会发送一个异步消息到主线程Handler,执行我们上一步设置的绘制监听任务,并移除同步屏障**

这里我们只需要明确一个情况:调用requestLayout()方法之后会设置一个同步屏障,知道ASYNC信号到来才会执行绘制任务并移除同步屏障。(这里涉及到Android屏幕刷新以及绘制原理更多的内容,本文不详细展开,感兴趣的读者可以点击文末的连接阅读。)

那,这样在等待ASYNC信号的时候主线程什么事都没干?是的。这样的好处是:保证在ASYNC信号到来之时,绘制任务可以被及时执行,不会造成界面卡顿。但这样也带来了相对应的代价:

  • 我们的同步消息最多可能被延迟一帧的时间,也就是16ms,才会被执行
  • 主线程Looper造成过大的压力,在VSYNC信号到来之时,才集中处理所有消息

改善这个问题办法就是:使用异步消息。当我们发送异步消息到MessageQueue中时,在等待VSYNC期间也可以执行我们的任务,让我们设置的任务可以更快得被执行且减少主线程Looper的压力。

可能有读者会觉得,异步消息机制本身就是为了避免界面卡顿,那我们直接使用异步消息,会不会有隐患?这里我们需要思考一下,什么情况的异步消息会造成界面卡顿:异步消息任务执行过长、异步消息海量。

如果异步消息执行时间太长,那即时是同步任务,也会造成界面卡顿,这点应该都很好理解。其次,若异步消息海量到达影响界面绘制,那么即使是同步任务,也是会导致界面卡顿的;原因是MessageQueue是一个链表结构,海量的消息会导致遍历速度下降,也会影响异步消息的执行效率。所以我们应该注意的一点是:

不可在主线程执行重量级任务,无论异步还是同步

那,我们以后岂不是可以直接使用异步Handler来取代同步Handler了?是,也不是。

同步Handler有一个特点是会遵循与绘制任务的顺序,设置同步屏障之后,会等待绘制任务完成,才会执行同步任务;而异步任务与绘制任务的先后顺序无法保证,在等待VSYNC的期间可能被执行,也有可能在绘制完成之后执行。因此,我的建议是:如果需要保证与绘制任务的顺序,使用同步Handler;其他,使用异步Handler

最后

技术深挖,总是能学到一些更加不一样的知识。当知识的广度越来越广,知识之间的联系会迸发出不一样的火花。

第一次学习Handler,仅仅知道可以发送消息并执行;第二次学习Handler,知道了其在Android消息机制重要地位;第三次学习Handler,知道了原来Handler和屏幕刷新机制还有这么一个联系。

温故而知新,古人诚不欺我。

如果文章对你有帮助,还希望可以点赞鼓励一下作者。

推荐文献

查看原文

赞 0 收藏 0 评论 0

一只修仙的猿 发布了文章 · 3月1日

清晰图解深度分析HTTPS原理

前言

很高兴遇见你~

Https现在基本已经覆盖所有的http请求了,作为一个伟大的发明,保障了我们的通信安全。在Android中对于HTTPS其实感知不多,因为这些内容都有成熟的框架帮我们完成了,例如okHttp。我们发起一个http或https的请求几乎感受不到区别。

但最近在研究okHttp的源码的时候,发现很多的内容没看懂,最后发现是http相关的网络知识不扎实,再一次回过头来,把https学了一遍。正如前面所说,得益于框架,我们几乎不需要学习https背后到底发生了什么,但是发生了相关的bug也就无法修复(面试要问[狗头])。所以,作为一个android开发者,也还是很有必要学一下https。

HTTPS的目标就是解决网络通信的安全问题。本文首先阐述网络中存在的风险,然后再讨论其涉及的加密方法、证书验证,最后再同从请求的角度解析整个安全连接的流程。

网络存在的风险

在没有经过任何加密手段的HTTP通信中,面临着三大危险:消息监听、消息篡改、冒充身份

消息监听

我们发送的消息需要经过很多的中间路由器,我们无法确保网络中每一个节点都是安全的,所以我们发送的数据会被恶意的对象截取到。假如我们的消息没有经过任何加密,那么恶意用户就可以监听到我们通信的所有数据。如下图:

image.png

解决的方法是:对通信数据进行加密。如下图:

image.png

经过加密的数据,即时被黑客截取到,他也无法知道数据的内容。

消息篡改

第二个危险是消息篡改。我们发出的数据会经过危险的中间节点,黑客可以监听我们的数据,也可以对我们的数据进行修改。如下图:

image.png

解决篡改的方法是:利用MD5等hash算法手段来检验数据的完整性 。下面会详解。

冒充身份

HTTP并没验证身份的流程,我们无法保证我们接收到的数据是服务器响应的,服务器也无法鉴别请求的用户是否是恶意用户。如下图:

image.png

解决的方法是:使用证书来检验对方的身份

HTTP通信面临的这些问题,让我们的网络通信变得极其不安全,HTTPS就是在HTTP的基础上来解决安全问题。

加密算法

加密算法依旧是HTTPS安全通信中的重头戏。在理想的情况下,如若有一个加密算法使得仅有用户和服务可以加密解密,那么其实是不存在上面的安全问题的。但黑客本身,他也可以作为一个客户存在,普通客户可以加密解密,那么黑客也就可以做到。所以需要附加上动态因子来保证算法的安全。

这里解释一下什么是动态因子算法(这个名字我自己起的,仅仅为了帮助理解)

假如现在需要发送的数据是:123
算法是:数据+动态整数

现在通信双方商量的动态因子是:5,那么

  1. 发送方对数据进行加密:123+5=128
  2. 接收方对数据进行解密:128-5 =123

即使黑客知道具体的算法就是数据+动态整数,但是他不知道具体的动态整数是多少,也就无法解出原始的数据内容。这个动态整数称之为密钥

下面介绍HTTPS中用到的加密算法。

对称算法

对称算法比较简单:加密和解密数据使用相同的密钥 。如下图:

image.png

对称算法的优点就是效率很高,可以对长数据进行加解密。但对称算法也存在缺点。

第一是双方使用相同的密钥,无法辨别数据到底是由服务器加密还是客户端加密,也就是无法区分一个消息是由服务器发出还是由客户端发出。解决这个问题方法也很简单:双方加密使用不同的密钥

第二,通信双方难以确保拿到安全的密钥 。因为第一步总是需要通过网路通信来商量密钥,那可不可以使用固定的密钥?前面讲过,黑客也是一个客户,那么他也可以拿到密钥,这个算法就失去意义了。

解决这个问题的方法是:使用非对称算法

非对称算法

对称算法是加密解密使用相同的密钥,而非对称算法是加密与解密使用不同的密钥 。如下图:

image.png

  1. 非对称加密有两把密钥:公钥和私钥
  2. 公钥可公开给所有人,私钥必须自己保密,不给任何人拿到
  3. 客户端可以使用服务器的公钥加密数据,而这份密文,只有服务器的私钥才能解开
  4. 反过来,使用私钥加密的数据,也只有公钥可以解开

非对称算法很好地解决了对称算法存在的问题:无法安全交换密钥 。服务器的公钥可以公开给所有的用户,当客户端首次访问服务器,服务器便把公钥返回即可。

但是对于非对称算法有一个很严重的缺点:性能极差 。所以我们可以将对称与非对称算法结合起来,解决上述问题。

对称+非对称

对称算法存在的问题是无法安全地互换密钥;因此第一步我们可以使用非对称算法来交换密钥,后续使用对称算法来进行通信。如下图:

image.png

  1. 当客户访问服务器时,服务器返回一个公钥;
  2. 客户端拿到公钥之后,对客户端密钥使用公钥进行加密之后发送给服务端;
  3. 服务端拿到客户端密钥之后对服务端密钥进行加密发送给客户端;

这样就完成了双方密钥的交换,后续可以使用密钥进行高效率通信。

到此我们的网络传输依旧不是安全的,因为,我们无法保证第一步服务器返回的公钥不会被黑客篡改。假如黑客把服务器返回的公钥转换成自己的公钥,后续他就可以对客户端的的所有消息使用自己的私钥解密。而问题的本质在于:我们无法辨别返回的数据是否是真的由服务器返回的 。这个问题的解决方法就是:使用数字证书来证明信息发送方的身份

数字证书

经过前面加密算法的讨论,对称+非对称算法已经可以解决大部分的网络安全问题。但第一步服务器返回的公钥仍旧有被黑客篡改的风险,因为我们无法确保通信对方的身份。数字证书的引入,就是为了解决这个问题。

证书概述

数字证书是由公认的证书机构颁发给服务器的一个用于验证身份的数字认证

数字证书可以用身份证来进行类比:

身份证是我们自身身份信息的一个认证,颁发的机构是我们全国人民认可的公安局。
同理,服务器的数字证书也是服务器身份的一个认证,颁发的机构是互联网中普遍认可的证书机构。

服务器的证书中,包含有服务器信息例如公钥等、证书签名、证书机构信息等。客户端拿到服务器的证书,进行证书验证后,就可以准确得到服务器的公钥,利用这个公钥,就可以实现上述的算法加密了。

总之,数字证书的作用就是证明数据的来源,安全获取到服务器的公钥进行加密通信

证书验证

客户端如何验证服务器的证书呢?首先得看看证书是怎么做出来的:

image.png

  1. 服务器向证书机构申请证书,同时提供自己的域名、地址、公钥等信息;
  2. 证书机构对服务器的信息使用hash算法得出一份128位的摘要,并对这份摘要使用自己的私钥进行非对称加密得到证书数字签名
  3. 证书机构把服务器信息(明文)+数字签名+证书机构信息(包含证书机构公钥)发送给服务器
  4. 客户端请求服务器时,服务器把证书返回给客户端

客户端验证证书的重点就是:比较摘要

  1. 客户端拿到证书,得到服务器信息、数字签名、证书机构信息
  2. 客户端对服务器信息进行hash算法计算得出一份摘要S1
  3. 客户端使用证书机构的公钥对数字签名进行解密得到一份摘要S2
  4. 对比S1和S2即可辨别此证书是否来自服务器且没经过篡改

经过上面的证书验证流程,客户端就可以成功拿到服务器的公钥,进行下一步的加密流程。至于为什么通过比较摘要即可知道证书安全,下面进行讨论。

证书链

客户端验证证书的流程很简单:使用证书机构公钥解开证书的数字签名后进行比对即可。但这里有一个问题:如何保证证书机构的公钥可信 ?假如黑客使用自己的私钥加密,同时把证书机构的公钥修改成自己的公钥,那岂不是非常危险?

互联网中的主机对象非常多,但证书机构却不多。计算机产商,会在系统中安装一些根证书机构的信息,其中就包含了这些机构的公钥。这些公钥是在一定程度上是绝对安全的,是可以信任的。客户端可以使用这些公钥对数字签名进行解密。安全问题,终于得到了完美的解决。

系统中预装的证书机构是有限的,但世界上每时每刻申请数字证书却非常多,他们“忙不过来”,因此有了二级证书机构。二级证书机构由根证书机构签发,二级证书机构再去给服务器签发证书。那么此时如何进行证书验证呢?还是一样的道理:

  1. 利用根证书机构给二级证书机构签发的时候同样是一份数字证书,其中包含了二级证书机构信息、数字签名、根证书机构信息
  2. 服务器的数字证书中包含了二级证书机构的数字证书
  3. 客户端使用根证书机构的公钥对二级证书机构的数字签名进行解密得到摘要再进行比对,得到二级证书机构的公钥
  4. 使用二级证书机构的公钥对服务器证书进行验证

同理,三级、四级证书机构验证都类同。在浏览器中,我们可以查看网站的证书链:

image.png

可以看到这是一个包含了两级证书机构的证书链,最顶层的证书机构,即是根证书机构。

hash算法

我们会发现,证书并不是直接对服务器信息进行加密,而是利用hash算法得到服务器信息的摘要,再对摘要进行加密。那这里可能会有这些问题:

  1. 直接对信息进行加密不可以吗?为什么多此一举?
  2. 只对摘要进行加密,那么原文内容不是泄露了吗?

hash算法最常用的就是MD5,他可以把一段数据转化成一个128位的长度的摘要,不同的数据,会得到不同的摘要。

摘要的长度更短,使用非对称加密的效率更高。因此,证书中对摘要而不是直接对信息进行加密可以提高网络效率。而服务器信息本身并不是敏感信息,不怕被黑客截取监听,所以可以使用明文传输。

hash算法不仅为了提高效率,更重要的是可以辨别信息是否遭受了篡改

假如在证书中我们直接对服务器信息进行私钥加密,黑客截取到我们的数据后,他虽然看不懂,但是他可以直接对密文进行篡改。最后接收方解密之后得到的就是一分错误的信息。

如果信息是一个文本,我们可以很明显地辨别出来;但如果是一个数字编号,那么很难知道是否遭受了篡改。举个例子:

  1. 服务器发送货物编号123,对123进行加密之后得到098
  2. 黑客截取后无法解密,将098修改成048之后发送给客户端
  3. 客户端解密048之后得到129,数据遭受了篡改;虽然黑客不知道我们发送什么,但是可以让我们的业务发生错误

此时如果对密文进行hash得到一份摘要,同时对摘要进行加密。客户端拿到数据之后,对密文进行hash再加密,再与服务器发送过来的摘要进行比对即可知道数据是否发生了篡改。黑客不管是修改密文or摘要密文,最后都会导致最后两者的摘要不等。

hash算法的优化

MD5算法是有缺点的,他会发生碰撞。例如一年只有366天,但中国有13亿人口,肯定会有非常多的人生日相同。同理,摘要的长度只有128位,无法唯一表示所有的数据,存在一定的风险:两份不同的数据得到相同的摘要。让黑客变得有机可乘,所以需要引入一种优化方案:HMAC(消息认证码)

HMAC与MD5的差别在于,他并不是直接对数据进行hash,他还需要一个随机数来共同作用hash,只要保证每次的随机数不同,黑客拿不到随机数,也就无法对hash算法进行破解;即使两次的数据一样,因为随机数不同,最终得出的摘要也不同;这更进一步保证了安全。

但是随机数需要通信双方进行协商拟定,所以在证书中无法使用HMAC。但是在HTTPS安全通信中,则可以加入随机数来实现HMAC,提高安全性。

安全模型

这一小节主要讲一下HTTPS为我们建立的宏观安全模型。

需要特别注意的是,HTTPS并不是一个新的应用协议来取代HTTP,而是在HTTP的基础上,增加了网络安全的内容。HTTPS的全称:Hyper Text Transfer Protocol over SecureSocket Layer,建立在安全socket层次上的超文本传输协议,可以认为HTTPS = HTTP+SSL。HTTPS与HTTP、TCP的关系如下:

image.png

HTTPS在HTTP和TCP之间建立了一个安全连接层 。SSL/TLS层次和TCP很类似,双方建立TCP连接之后,需要再建立安全连接。与TCP连接一样,SSL连接本质上,是对双方安全信息的记录,并不是一个真正意义上的连接。HTTP通过安全连接,即可与目标主机进行安全的通信,不怕被监听、篡改、冒充身份。

这里的SSL与TLS指的都是安全协议。SSL全名Secure Sockets Layer,安全套接字层协议;TLS全名Transport Layer Security,安全传输层协议。TLS从SSL发展而来,SSL是早期的安全层协议;后期逐渐发现了其安全漏洞,发展出了TLS。现在使用的最多的是TLS1.2、TLS1.3版本,如我们查看掘金的证书:

image.png

可以看到使用了TLS1.2版本。安全协议版本需要通信双方进行协商,只有使用相同版本的协议,才能建立安全连接。

此外,建立安全连接是比较消耗性能的。如果每次请求都建立一次安全连接,那么网络的效率将会大打折扣。因此,在建立一次安全连接之后,服务器会存储客户端的安全相关信息,在一定时间内通信时无需再次建立安全连接,服务器会把先前的密钥等信息发送给客户端,直接使用此前已经记录的安全信息即可。

安全连接建立流程

和TCP连接类同,安全连接也需要一个建立的流程。但是经过了前面HTTPS加密算法以及证书体系的学习,理解HTTPS安全连接建立流程就非常简单了。基本就是把上面的流程走了一遍。先来看一张总体图:

image.png

  1. 客户端请求服务器建立安全连接,附加客户端支持的SSL与TLS版本、支持的加密算法版本、随机数

    加密算法与安全协议版本有很多,但服务不一定支持最新版本的协议预算法。所以客户端把所以支持的版本发送给服务器,让服务器去选择。

    随机数非常重要,前面讲hash算法的时候讲到,随机数是一个动态因子,让hash算法更加安全。同时,随机数也参与了对称密钥的生成。

  2. 服务器响应请求,附加选择的协议版本、加密算法版本、服务器随机数

    服务器从客户端支持的协议版本中,选择一套自己最喜欢的。

    为了辨别消息是由哪一方加密并发出的,需要准备两个对称密钥。因此服务器也需要产生一个随机数。

  3. 服务器向客户端发送证书

    服务器向客户端发送自己证书,其中就包含了服务器的公钥。
  4. 服务器发送hello done表示hello阶段结束
  5. 客户端验证证书,拿到服务器公钥;利用两个随机数,生成pre-master secret,并使用服务器的公钥加密发送给服务器。

    证书验证步骤参考上面的证书小节;

    pre-master secret是一个非常重要的东西,双方利用pre-master secret生成master-secret,利用前面的两个随机数生成两个对称加密密钥和两个HMAC密钥,两对密钥分别用于客户端加密和服务器加密。

  6. 客户端发送changeCipherSpec提示服务器此后使用pre-master secret产生的密钥加密通信
  7. 客户端发送FIN报文,表示结束
  8. 服务器也发送changeCipherSpec报文
  9. 服务器也发送FIN报文,表示结束
  10. 双方可以开始安全通信了

至此,对于HTTPS的加密流程,已经比较清晰了。

Android中运用

无论是HTTP还是HTTPS,事实上开源网络框架都已经为我们完成了这些粗活累活,例如okHttp。正常情况下,发起HTTP和HTTPS请求并没有任何异同。但有时候会出现一些特殊的问题,就需要我们自己动手解决:

  1. 系统过于老旧,没有安装根证书。缺乏根证书的公钥,那么无法验证服务器证书是否安全。
  2. 自签名证书。自己的app访问自己的服务器,有时候为了节约经费,可以自己给自己的服务器签名。
  3. 证书信息缺乏关键信息,如颁发证书的机构。

对于上面的问题,我们可以自己重写证书验证流程,或者在okHttp中添加信任的服务器公钥,可以解决上面的问题。

最后

HTTPS要解决的就是计算机网络中的安全问题,不同问题的解决方法要清楚:

  • 防止消息监听:加密
  • 防止消息篡改:hash算法
  • 验证身份:数字证书

HTTPS就是利用这些方法,为通信双方建立安全连接,从而来实现安全通信。

如果文章对你有帮助,还希望可以留下您的点赞鼓励一下作者~

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
有任何想法欢迎评论区交流指正。
如需转载请评论区或私信告知。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 1 收藏 1 评论 0

一只修仙的猿 发布了文章 · 2月24日

这一篇TCP总结请收下

前言

很高兴遇见你~

TCP这些东西,基本每个程序猿都或多或少是掌握的了。虽然感觉在实际开发中没有什么用武之处,但,面试他要问啊

而最近大家伙过完年,也都在准备春招,我也一样。阅读了一些okHttp源码之后,又屁颠屁颠地跑回来重新把tcp、http这些东西给重新学了一遍。okHttp基本都是这些协议的实现,而理解源码的基础是,理解tcp、http。

重新看了一遍tcp之后,我把这些东西给总结了下来,也就有了这篇文章。

计算机网络的知识特点就是:琐碎。靠背诵“面试八股文”估计没多久就忘了。TCP是计算机网络运输层的一个协议,所以首先要对计网分层结构以及运输层有一定的理解。然后是TCP的四个重点:面向连接、可靠传输原理、流量控制和拥塞控制,最后再补充一点粘包和拆包的知识。

计网分层结构

考虑最简单的情况:两台主机之间的通信。这个时候只需要一条网线把两者连起来,规定好彼此的硬件接口,如都用USB、电压10v、频率2.4GHz等,这一层就是物理层,这些规定就是物理层协议


我们当然不满足于只有两台电脑连接,因此我们可以使用交换机把多个电脑连接起来,如下图:

image.png

这样连接起来的网络,称为局域网,也可以称为以太网(以太网是局域网的一种)。在这个网络中,我们需要标识每个机器,这样才可以指定要和哪个机器通信。这个标识就是硬件地址MAC。硬件地址随机器的生产就被确定,永久性唯一。在局域网中,我们需要和另外的机器通信时,只需要知道他的硬件地址,交换机就会把我们的消息发送到对应的机器。

这里我们可以不管底层的网线接口如何发送,把物理层抽离,在他之上创建一个新的层次,这就是数据链路层


我们依然不满足于局域网的规模,需要把所有的局域网联系起来,这个时候就需要用到路由器来连接两个局域网:

image.png

但是如果我们还是使用硬件地址来作为通信对象的唯一标识,那么当网络规模越来越大,需要记住所有机器的硬件地址是不现实的;同时,一个网络对象可能会频繁更换设备,这个时候硬件地址表维护起来更加复杂。这里使用了一个新的地址来标记一个网络对象:IP地址

通过一个简单的寄信例子来理解IP地址。

我住在北京市,我朋友A住在上海市,我要给朋友A写信:

  1. 写完信,我会在信上写好我朋友A的地址,并放到北京市邮局(给信息附加目标IP地址,并发送给路由器)
  2. 邮局会帮我把信运输到上海市当地邮局(信息会经过路由传递到目标IP局域网的路由器)
  3. 上海市当地路由器会帮我把信交给朋友A(局域网内通信)

因此,这里IP地址就是一个网络接入地址(朋友A的住址),我只需要知道目标IP地址,路由器就可以把消息给我带到。在局域网中,就可以动态维护一个MAC地址与IP地址的映射关系,根据目的IP地址就可以寻找到机器的MAC地址进行发送

这样我们不需管理底层如何去选择机器,我们只需要知道IP地址,就可以和我们的目标进行通信。这一层就是网络层。网络层的核心作用就是 提供主机之间的逻辑通信 。这样,在网络中的所有主机,在逻辑上都连接起来了,上层只需要提供目标IP地址和数据,网络层就可以把消息发送到对应的主机。


一个主机有多个进程,进程之间进行不同的网络通信,如边和朋友开黑边和女朋友聊微信。我的手机同时和两个不同机器进行通信。那么当我的手机收到数据时,如何区分是微信的数据,还是王者的数据?那么就必须在网络层之上再添加一层:运输层

运输层通过socket(套接字),将网络信息进行进一步的拆分,不同的应用进程可以独立进行网络请求,互不干扰。这就是运输层的最本质特点:提供进程之间的逻辑通信 。这里的进程可以是主机之间,也可以是同个主机,所以在android中,socket通信也是进程通信的一种方式。


现在不同的机器上的应用进程之间可以独立通信了,那么我们就可以在计算机网络上开发出形形式式的应用:如web网页的http,文件传输ftp等等。这一层称为应用层

应用层还可以进一步拆分出表示层、会话层,但他们的本质特点都没有改变:完成具体的业务需求 。和下面的四层相比,他们并不是必须的,可以归属到应用层中。


最后对计网分层进行小结:

image.png

  1. 最底层物理层,负责两个机器之间通过硬件的直接通信;
  2. 数据链路层使用硬件地址在局域网中进行寻址,实现局域网通信;
  3. 网络层通过抽象IP地址实现主机之间的逻辑通信;
  4. 运输层在网络层的基础上,对数据进行拆分,实现应用进程的独立网络通信;
  5. 应用层在运输层的基础上,根据具体的需求开发形形式式的功能。

这里需要注意的是,分层并不是在物理上的分层,而是逻辑上的分层。通过对底层逻辑的封装,使得上层的开发可以直接依赖底层的功能而无需理会具体的实现,简便了开发。

这种分层的思路,也就是责任链设计模式,通过层层封装,把不同的职责独立起来,更加方便开发、维护等等。okHttp中的拦截器设计模式,也是这种责任链模式。

运输层

本文主要是讲解TCP,这里需要增加一些运输层的知识。

本质:提供进程通信

yqs3gx.md.png

在运输层之下的网络层,是不知道该数据包属于哪个进程,他只负责数据包的接收与发送。运输层则负责接收不同进程的数据交给网络层,同时把网络层的数据拆分交给不同的进程。从上往下汇聚到网络层,称为多路复用,从下往上拆分,称为多路拆分

运输层的表现,受网络层的限制。这很好理解,网络层是运输层的底层支持。所以运输层是无法决定自己带宽、时延等的上限。但可以基于网络层开发更多的特性:如可靠传输。网络层只负责尽力把数据包从一端发送到另一端,而不保证数据可以到达且完整。

底层实现:socket

前面讲到,最简单的运输层协议,就是提供进程之间的独立通信 ,但底层的实现,是socket之间的独立通信 。在网络层中,IP地址是一个主机逻辑地址,而在运输层中,socket是一个进程的逻辑地址;当然,一个进程可以拥有多个socket。应用进程可以通过监听socket,来获取这个socket接受到的消息。

举个例子来理解socket。如下图

每一个主机可以创建很多个socket来接收信息。如主机A的微信进程,想要发送给主机B的微信,那么他只需要发送给主机B的socketC,主机B的微信就会从socketC中取到消息。(当然实际的流程不是这样的,我们的消息需要经过微信后台服务器,这里只是举例子)

同理,主机B的QQ,想要发送消息给主机A的QQ,那么只需要把消息发送给socketB,主机A的QQ就可以拿到消息了。

socket并不是一个实实在在的东西,而是运输层抽象出来的一个对象。运输层增加了端口这个概念,来区分不同的socket。端口可以理解为一个主机上有很多的网络通信口,每个端口都有一个端口号,端口的数量由运输层协议确定。

不同的运输层协议对socket有不同的定义方式。在UDP协议中,使用目标IP+目标端口号来定义一个socket;在TCP中使用目标IP+目标端口号+源IP+源端口号来定义一个socket。我们只需要在运输层报文的头部附加上这些信息,目标主机就会知道我们要发送给哪个socket,对应监听该socket的进程就可获得信息。

运输层协议

运输层的协议就是大名鼎鼎的TCP和UDP。其中,UDP是最精简的运输层协议,只实现了进程间的通信;而TCP在UDP的基础上,实现了可靠传输、流量控制、拥塞控制、面向连接等等特性,同时也更加复杂。

当然除此之外,还有更多更优秀的运输层协议,但目前广为使用的,就是TCP和UDP。UDP在后面也会总结到。

TCP协议首部

TCP协议,表现在报文上,就是会在应用层传输下来的数据前附加上一个TCP首部,这个首部附加了TCP信息,先来整体看一下这个首部的结构:

这张图是来自我大学老师的课件, 非常好用,所以一直拿来学习。最下面部分表示了报文之间的关系,TCP数据部分就是应用层传下来的数据。

TCP首部固定长度是20字节,下面还有4字节是可选的。内容很多,但其中有一些我们比较熟悉的:源端口,目标端口。嗯?socket不是还需要IP进行定位吗?IP地址在网络层被附加了。其他的内容后面都会慢慢讲解,作为一篇总结文章,这里放出查阅表,方便复习:

头部参数字节数作用
源端口和目的端口字段各占两字节socket是通过端口号和IP号来进行定义,这里表示发出消息的主机端口以及接收消息的目标主机端口
序号字段4 字节TCP 连接中传送的数据流中的每一个字节都编上一个序号。序号字段的值则指的是本报文段所发送的数据的第一个字节的序号。长度4字节,所以序号的范围是【0,2^32 - 1】
确认号字段4字节是期望收到对方的下一个报文段的数据的第一个字节的序号。
数据偏移(即首部长度)4位指出 TCP 报文段的数据起始处距离 TCP 报文段的起始处有多远。“数据偏移”的单位是 32 位(以 4 字节为计算单位)
保留字段6位保留为今后使用,但目前应置为 0
紧急 URG1位当 URG =1 时,表明紧急指针字段有效。它告诉系统此报文段中有紧急数据,应尽快传送(相当于高优先级的数据)
确认 ACK1位只有当 ACK=1 时确认号字段才有效。当 ACK = 0 时,确认号无效。当收到报文需要向发送方发送确认报时设置该标志位为1。
推送 PSH1位接收 TCP 收到 PSH = 1 的报文段,就尽快地交付接收应用进程,而不再等到整个缓存都填满了后再向上交付。
复位 RST1位当 RST =1 时,表明 TCP 连接中出现严重差错(如由于主机崩溃或其他原因),必须释放连接,然后再重新建立运输连接。
同步 SYN1位同步 SYN = 1 表示这是一个连接请求或连接接受报文。
终止 FIN1位用来释放一个连接。FIN = 1 表明此报文段的发送端的数据已发送完毕,并要求释放运输连接
窗口字段2字节发送方接收缓存区剩下的字节 数,注意单位是字节。
检验和2字节检验和字段检验的范围包括首部和数据这两部分。在计算检验和时,要在 TCP 报文段的前面加上 12 字节的伪首部。主要是检验报文是否发生了错误,如某个‘1’变成了‘0’。
紧急指针字段2字节指出在本报文段中紧急数据共有多少个字节(紧急数据放在本报文段数据的最前面)
选项字段长度不定TCP 最初只规定了一种选项,即最大报文段长度 MSS。MSS 告诉对方 TCP:“我的缓存所能接收的报文段的数据字段的最大长度是 MSS 个字节。”
填充字段不定这是为了使整个首部长度是 4 字节的整数倍。

选项字段中包含以下其他选项:

选项作用
窗口扩大选项占 3 字节,其中有一个字节表示移位值 S。新的窗口值等于 TCP 首部中的窗口位数增大到 (16 + S),相当于把窗口值向左移动 S 位后获得实际的窗口大小
时间戳选项占 10 字节,其中最主要的字段时间戳值字段(4 字节)和时间戳回送回答字段(4 字节),主要是用于计算数据报在网络中传输的往返时间。
选择确认选项接收方收到了和前面的字节流不连续的两个字节块,需要告诉发送方目前已经接收到的数据报范围。每一个段需要两个边界,一个边界需要4字节来表示,选项字段最长是40字节,所以最多可以表示4个已接收的字段。

讲完下面内容,再回来看这些字段就熟悉了。

TCP面向字节流特性

TCP并不是把应用层传输过来的数据直接加上首部然后发送给目标,而是把数据看成一个字节 流,给他们标上序号之后分部分发送。这就是TCP的 面向字节流 特性:

  • TCP会以流的形式从应用层读取数据并存放在自己的发送缓存区中,同时为这些字节标上序号
  • TCP会从发送方缓冲区选择适量的字节组成TCP报文,通过网络层发送给目标
  • 目标会读取字节并存放在自己的接收方缓冲区中,并在合适的时候交付给应用层

面向字节流的好处是无需一次存储过大的数据占用太多内存,坏处是无法知道这些字节代表的意义,例如应用层发送一个音频文件和一个文本文件,对于TCP来说就是一串字节流,没有意义可言,这会导致粘包以及拆包问题,后面讲。

可靠传输原理

前面讲到,TCP是可靠传输协议,也就是,一个数据交给他,他肯定可以完整无误地发送到目标地址,除非网络炸了。他实现的网络模型如下:

对于应用层来说,他就是一个可靠传输的底层支持服务;而运输层底层采用了网络层的不可靠传输。虽然在网络层甚至数据链路层就可以使用协议来保证数据传输的可靠性,但这样网络的设计会更加复杂、效率会随之降低。把数据传输的可靠性保证放在运输层,会更加合适。

可靠传输原理的重点总结一下有:滑动窗口、超时重传、累积确认、选择确认、连续ARQ

停止等待协议

要实现可靠传输,最简便的方法就是:我发送一个数据包给你,然后你跟我回复收到,我继续发送下一个数据包。传输模型如下:

这种“一来一去”的方法来保证传输可靠就是停止等待协议(stop-and-wait)。不知道还记不记得前面TCP首部有一个ack字段,当他设置为1的时候,表示这个报文是一个确认收到报文。

然后再来考虑一种情况:丢包。网络环境不可靠,导致每一次发送的数据包可能会丢失,如果机器A发送了数据包丢失了,那么机器B永远接收不到数据,机器A永远在等待。解决这个问题的方法是:超时重传 。当机器A发出一个数据包时便开始计时,时间到还没收到确认回复,就可以认为是发生了丢包,便再次发送,也就是重传。

但重传会导致另一种问题:如果原先的数据包并没有丢失,只是在网络中待的时间比较久,这个时候机器B会受到两个数据包,那么机器B是如何辨别这两个数据包是属于同一份数据还是不同的数据?这就需要前面讲过的方法:给数据字节进行编号。这样接收方就可以根据数据的字节编号,得出这些数据是接下来的数据,还是重传的数据。

在TCP首部有两个字段:序号和确认号,他们表示发送方数据第一个字节的编号,和接收方期待的下一份数据的第一个字节的编号。前面讲到TCP是面向字节流,但是他并不是一个字节一个字节地发送,而是一次截取一整段。截取的长度受多种因素影响,如缓存区的数据大小、数据链路层限制的帧大小等。

连续ARQ协议

停止等待协议已经可以满足可靠传输了,但有一个致命缺点:效率太低。发送方发送一个数据包之后便进入等待,这个期间并没有干任何事,浪费了资源。解决的方法是:连续发送数据包。模型如下:

和停止等待最大的不同就是,他会源源不断地发送,接收方源源不断收到数据之后,逐一进行确认回复。这样便极大地提高了效率。但同样,带来了一些额外的问题:

发送是否可以无限发送直到把缓冲区所有数据发送完?不可以。因为需要考虑接收方缓冲区以及读取数据的能力。如果发送太快导致接收方无法接受,那么只是会频繁进行重传,浪费了网络资源。所以发送方发送数据的范围,需要考虑到接收方缓冲区的情况。这就是TCP的流量控制 。解决方法是:滑动窗口 。基本模型如下:

  • 发送方需要根据接收方的缓冲区大小,设置自己的可发送窗口大小,处于窗口内的数据表示可发送,之外的数据不可发送。
  • 当窗口内的数据接收到确认回复时,整个窗口会往前移动,直到发送完成所有的数据

在TCP的首部有一个窗口大小字段,他表示接收方的剩余缓冲区大小,让发送方可以调整自己的发送窗口大小。通过滑动窗口,就可以实现TCP的流量控制,不至于发送太快,导致太多的数据丢失。


连续ARQ带来的第二个问题是:网络中充斥着和发送数据包一样数据量的确认回复报文,因为每一个发送数据包,必须得有一个确认回复。提高网络效率的方法是:累积确认 。接收方不需要逐个进行回复,而是累积到一定量的数据包之后,告诉发送方,在此数据包之前的数据全都收到。例如,收到 1234,接收方只需要告诉发送方我收到4了,那么发送方就知道1234都收到了。

第三个问题是:如何处理丢包情况。在停止等待协议中很简单,直接一个超时重传就解决了。但,连续ARQ中不太一样。例如:接收方收到了 123 567,六个字节,编号为4的字节丢失了。按照累积确认的思路,只能发送3的确认回复,567都必须丢掉,因为发送方会进行重传。这就是GBN(go-back-n) 思路。

但是我们会发现,只需要重传4即可,这样不是很浪费资源,所以就有了:选择确认SACK 。在TCP报文的选项字段,可以设置已经收到的报文段,每一个报文段需要两个边界来进行确定。这样发送方,就可以根据这个选项字段只重传丢失的数据了。

可靠传输小结

到这里关于TCP的可靠传输原理就已经介绍的差不多。最后进行一个小结:

  • 通过连续ARQ协议与发送-确认回复模式来保证每一个数据包都到达接收方
  • 通过给字节编号的方法,来标记每一个数据是属于重传还是新的数据
  • 通过超时重传的方式,来解决数据包在网络中丢失的问题
  • 通过滑动窗口来实现流量控制
  • 通过累积确认+选择确认的方法来提高确认回复与重传的效率

当然,这只是可靠传输的冰山一角,感兴趣可以再深入去研究(和面试官聊天已经差不多了[狗头])。

拥塞控制

拥塞控制考虑的是另外一个问题:避免网络过分拥挤导致丢包严重,网络效率降低

拿现实的交通举例子:

高速公路同一时间可通行的汽车数量是一定的,当节假日时,就会发生严重的堵车。在TCP中,数据包超时,会进行重传,也就是会进来更多的汽车,这时候更堵,最后导致的结果就是:丢包-重传-丢包-重传。最后整个网络瘫痪了。

这里的拥塞控制和前面的流量控制不是一个东西,流量控制是拥塞控制的手段:为了避免拥塞,必须对流量进行控制。拥塞控制目的是:限制每个主机的发送的数据量,避免网络拥塞效率下降。就像广州等地,限制车牌号出行是一个道理。不然大家都堵在路上,谁都别想走。

拥塞控制的解决方法是流量控制,流量控制的实现是滑动窗口,所以拥塞控制最终也是通过限制发送方的滑动窗口大小来限制流量 。当然,拥塞控制的手段不只是流量控制,导致拥塞的因素有:路由器缓存、带宽、处理器处理速度等等。提升硬件能力(把4车道改成8车道)是其中一个方法,但毕竟硬件提升是有瓶颈的,没办法不断提升,还是需要从tcp本身来增加算法,解决拥塞。

拥塞控制的重点有4个:慢开始、快恢复、快重传、拥塞避免。这里依旧献祭出大学老师的ppt图片:

Y轴表示的是发送方窗口大小,X轴表示的是发送的轮次(不是字节编号)。

  • 最开始的时候,会把窗口设置一个较小的值,然后每轮变为原来的两倍。这是慢开始。
  • 当窗口值到达ssthresh值,这个值是需要通过实时网络情况设置的一个窗口限制值,开始进入拥塞避免,每轮把窗口值提升1,慢慢试探网络的底线。
  • 如果发生了数据超时,表示极可能发生了拥塞,然后回到慢开始,重复上面的步骤。
  • 如果收到三个相同的确认回复,表示现在网络的情况不太好,把ssthresh的值设置为原来的一半,继续拥塞避免。这部分称为快恢复。
  • 如果收到丢包信息,应该尽快把丢失的包重传一次,这是快重传。
  • 当然,窗口的最终上限是不能无限上涨的,他不能超过接收方的缓存区大小。

通过这个算法,就可以在很大程度上,避免网络拥挤。

除此之外,还可以让路由器在缓存即将满的时候,告知发送方我快满了,而不是等到出现了超时再进行处理,这是主动队列管理AQM。此外还有很多方法,但是上面的算法是重点。

面向连接

这一小节讲的就是无人不晓的TCP三次握手与四次挥手这些,经过前面的内容,这一小节其实已经很好理解。

TCP是面向连接的,那连接是什么?这里的连接并不是实实在在的连接,而是通信双方彼此之间的一个记录 。TCP是一个全双工通信,也就是可以互相发送数据,所以双方都需要记录对方的信息。根据前面的可靠传输原理,TCP通信双方需要为对方准备一个接收缓冲区可以接收对方的数据、记住对方的socket知道怎么发送数据、记住对方的缓冲区来调整自己的窗口大小等等,这些记录,就是一个连接。

在运输层小节中讲到,运输层双方通信的地址是采用socket来定义的,TCP也不例外。TCP的每一个连接只能有两个对象,也就是两个socket,而不能有三个。所以socket的定义需要源IP、源端口号、目标IP、目标端口号四个关键因素,才不会发生混乱。

假如TCP和UDP一样只采用目标IP+目标端口号来定义socket,那么就会出现多个发送方同时发送到同一个目标socket的情况。这个时候TCP无法区分这些数据是否来自不同的发送方,就会导致出现错误。

既然是连接,就有两个关键要点:建立连接、断开连接。

建立连接

建立连接的目的就是交换彼此的信息,然后记住对方的信息。所以双方都需要发送彼此的信息给对方:

但前面的可靠传输原理告诉我们,数据在网络中传输是不可靠的,需要对方给予我们一个确认回复,才可以保证消息正确到达。如下图:

机器B的确认收到和机器B信息可以进行合并,减少次数;而且发送机器B给机器A本身就代表了机器B已经收到了消息,所以最后的示例图是:

步骤如下:

  1. 机器A发送syn包向机器B请求建立TCP连接,并附加上自身的接收缓冲区信息等,机器A进入SYN_SEND状态,表示请求已经发送正在等待回复;
  2. 机器B收到请求之后,根据机器A的信息记录下来,并创建自身的接收缓存区,向机器A发送syn+ack的合成包,同时自身进入SYN_RECV状态,表示已经准备好了,等待机器A 的回复就可以向A发送数据;
  3. 机器A收到回复之后记录机器B 的信息,发送ack信息,自身进入ESTABLISHED状态,表示已经完全准备好了,可以进行发送和接收;
  4. 机器B收到ACK数据之后,进入ESTABLISHED状态。

三次消息的发送,称为三次握手。

断开连接

断开连接和三次握手类似,直接上图:

  1. 机器A发送完数据之后,向机器B请求断开连接,自身进入FIN_WAIT_1状态,表示数据发送完成且已经发送FIN包(FIN标志位为1);
  2. 机器B收到FIN包之后,回复ack包表示已经收到,但此时机器B可能还有数据没发送完成,自身进入CLOSE_WAIT状态,表示对方已发送完成且请求关闭连接,自身发送完成之后可以关闭连接;
  3. 机器B数据发送完成之后,发送FIN包给机器B ,自身进入LAST_ACK状态,表示等待一个ACK包即可关闭连接;
  4. 机器A收到FIN包之后,知道机器B也发送完成了,回复一个ACK包,并进入TIME_WAIT状态

    TIME_WAIT状态比较特殊。当机器A收到机器B的FIN包时,理想状态下,确实是可以直接关闭连接了;但是:

    1. 我们知道网络是不稳定的,可能机器B 发送了一些数据还没到达(比FIN包慢);
    2. 同时回复的ACK包可能丢失了,机器B会重传FIN包;

    如果此时机器A马上关闭连接,会导致数据不完整、机器B无法释放连接等问题。所以此时机器A需要等待2个报文生存最大时长,确保网络中没有任何遗留报文了,再关闭连接

  5. 最后,机器A等待两个报文存活最大时长之后,机器B 接收到ACK报文之后,均关闭连接,进入CLASED状态

双方之间4次互相发送报文来断开连接的过程,就是四次挥手

现在,对于为什么握手是三次挥手是四次、一定要三次/四次吗、为什么要停留2msl再关闭连接等等这些问题,就都解决了。

UDP协议

运输层协议除了TCP,还有大名鼎鼎的UDP。如果说TCP凭借他完善稳定的功能独树一帜,那UDP就是精简主义乱拳打死老师傅。

UDP只实现了运输层最少的功能:进程间通信。对于应用层传下来的数据,UDP只是附加一个首部就直接交给网络层了。UDP的头部非常简单,只有三部分:

  • 源端口、目标端口:端口号用来区分主机的不同进程
  • 校验码:用于校验数据包在传输的过程中没有出现错误,例如某个1变成了0
  • 长度:报文的长度

所以UDP的功能也只有两个:校验数据报是否发生错误、区分不同的进程通信。

但,TCP的功能虽然多,但同时也是要付出相对应的代价。例如面向连接的特性,在建立和断开连接的时候会有开销;拥塞控制的特性,会限制传输的上限等等。下面来罗列一下UDP的优缺点:

UDP的缺点

  • 无法保证消息完整、正确到达,UDP是一个不可靠的传输协议;
  • 缺少拥塞控制容易互相竞争资源导致网络系统瘫痪

UDP的优点

  • 效率更快;不需要建立连接以及拥塞控制
  • 连接更多的客户;没有连接状态,不需要为每个客户创建缓存等
  • 分组首部字节少,开销小;TCP首部固定首部是20字节,而UDP只有8字节;更小的首部意味着更大比例的数据部分
  • 在一些需要高效率允许可限度误差的场景下可以使用。如直播场景,并不需要保证每个数据包都完整到达,允许一定的丢包率,这个时候TCP的可靠特性反而成为了累赘;精简的UDP更高的效率是更加适合的选择
  • 可以进行广播;UDP并不是面向连接的,所以可以同时对多个进程进行发送报文

UDP适用场景

UDP适用于对传输模型需要应用层高度自定义、允许出现丢包、需要高效率的场景、需要广播;例如

  • 视屏直播
  • DNS
  • RIP路由选择协议

其他补充

分块传输

我们可以发现,运输层在传输数据的时候,并不是把整个数据包加个首部直接发送过去,而是会拆分成多个报文分开发送;那他这样做原因是什么?

有读者可能会想到:数据链路层限制了数据长度只能有1460。那数据链路层为什么要这么限制?他的本质原因就是:网络是不稳定的。如果报文太长,那么极有可能在传输一般的时候突然中断了,这个时候就要整个数据重传,效率就降低了。把数据拆分成多个数据报,那么当某个数据报丢失,只需要重传该数据报即可。

那是不是拆分得越细越好?报文中数据字段长度太低,会使得首部的占比太大,这样首部就会成为网络传输最大的负担了。例如1000字节,每个报文首部是40字节,如果拆分成10个报文,那么只需要传输400字节的首部;而如果拆分成1000个,那么需要传输40000字节的首部,效率就极大地降低了。

路由转换

先看下图:

  • 正常情况下,主机A的数据包可以又 1-3-6-7路径进行传送
  • 如果路由3坏掉了,那么可以从 1-4-6-7进行传送
  • 如果4也坏掉了,那么只能从2-5-6-7传送
  • 如果5坏掉了,那么就中断线路了

可以看出来,使用路由转发的好处是:提高网络的容错率,本质原因依旧是网络是不稳定的 。即使坏掉几个路由器,网络依旧畅通。但是如果坏掉路由器6那就直接导致主机A和主机B无法通信,所以要避免这种核心路由器的存在。

使用路由的好处还有:分流。如果一条线路太拥堵,可以从别的路线进行传输,提高效率。

粘包与拆包

在面向字节流那一小节讲过,TCP不懂这些数据流的意义,他只知道从应用层拿到数据流,切割成一份份报文,然后发送给目标对象。而如果应用层传输下来的是两个数据包,那么极有可能出现这种情况:

  • 应用层需要向目标进程发送两份数据,一份音频,一份文本
  • TCP只知道接收到一个流,并把流拆分成4段进行发送
  • 中间第二个报文的数据就出现两个文件的数据混在一起,这就是粘包
  • 目标进程应用层在接收到数据之后,需要把这些数据拆分成正确的两个文件,就是拆包

粘包与拆包都是应用层需要解决的问题,可以在每个文件的最后附加上一些特殊的字节,如换行符;或者控制每个报文只包含一个文件的数据,不足的用0补充等等。

恶意攻击

TCP的面向连接特点可能会被恶意的人利用,对服务器进行攻击。

前面我们知道,当我们向一个主机发送syn包请求创建连接时,服务器会为我们创建缓冲区等,然后向我们返回syn+ack报文;如果我们伪造IP和端口,向一个服务器进行海量的请求,会使得服务器创建了大量的创建一半的TCP连接,使得其无法正常响应用户的请求,导致服务器瘫痪。

解决的方法可以有限制IP的创建连接数、让创建一半的tcp连接在更短的时间内自行关闭、延缓接收缓冲区内存的分配等等。

长连接

我们向服务器的每一次请求都需要创建一个TCP连接,服务器返回数据之后就会关闭连接;如果在短时间内有大量的请求,那么频繁创建TCP连接关闭TCP连接是一个很浪费资源的行为。所以我们可以让TCP连接不要关闭,在这个期间进行请求,提高效率。

需要注意长连接维持时间、创建条件等,避免被恶意利用创建大量的长连接,消耗殆尽服务器的资源。

最后

以前学习的时候觉得这些东西好像没什么卵用,貌似就是用来考试的。事实上,在没应用到的时候,对这些知识很难有更深层次的认知,例如现在我看上面的总结,很多只是表面上的认知,不知道他背后代表的真正含义。

但当我学习的更加广泛、深入,会对这些知识有越来越深刻的认识。有那么几个瞬间觉得:哦原来那个东西是这样运用,那个东西是这样的啊,原来学了是真的有用。

现在可能学了之后没有什么感觉,但是当用到或者学到相关的应用时,会有一个顿悟感,会瞬间收获很多。

觉得有帮助留个赞鼓励一下作者吧~

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
笔者才疏学浅,文章有错误或有不同观点欢迎评论区交流。
如需转载请评论区或私信告知即可。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 1 收藏 1 评论 0

一只修仙的猿 发布了文章 · 2月22日

Android事件分发机制五:面试官你坐啊

前言

很高兴遇见你~

事件分发系列文章已经到最后一篇了,先来回顾一下前面四篇,也当个目录:

本文是最后一篇,主要是模拟面试情况提出一些问题以及解答,也当是整个事件分发知识的回顾。读者也可以尝试一下看看这些问题是否都能解答出来。

面试开始

  1. 学过事件分发吗,聊聊什么是事件分发

    事件分发是将屏幕触控信息分发给控件树的一个套机制。
    当我们触摸屏幕时,会产生一些列的MotionEvent事件对象,经过控件树的管理者ViewRootImpl,调用view的dispatchPointerEvnet方法进行分发。
  2. 那主要的分发流程是什么:

    在程序的主界面情况下,布局的顶层view是DecorView,他会先把事件交给Activity,Activity调用PhoneWindow的方法进行分发,PhoneWindow会调用DecorView的父类ViewGroup的dispatchTouchEvent方法进行分发。也就是Activity->Window->ViewGroup的流程。ViewGroup则会向下去寻找合适的控件并把事件分发给他。
  3. 事件一定会经过Activity吗?

    不是的。我们的程序界面的顶层viewGroup,也就是decorView中注册了Activity这个callBack,所以当程序的主界面接收到事件之后会先交给Activity。
    但是,如果是另外的控件树,如dialog、popupWindow等事件流是不会经过Activity的。只有自己界面的事件才会经Activity。
  4. Activity的分发方法中调用了onUserInteraction()方法,你能说说这个方法有什么作用吗?

    好的。这个方法在Activity接收到down的时候会被调用,本身是个空方法,需要开发者自己去重写。
    通过官方的注释可以知道,这个方法会在我们以任意的方式开始与Activity进行交互的时候被调用。比较常见的场景就是屏保:当我们一段时间没有操作会显示一张图片,当我们开始与Activity交互的时候可在这个方法中取消屏保;另外还有没有操作自动隐藏工具栏,可以在这个方法中让工具栏重新显示。
  5. 前面你讲到最后会分发到viewGroup,那么viewGroup是如何分发事件的?

    viewGroup处理事件信息分为三个步骤:拦截、寻找子控件、派发事件。

    事件分发中有一个重要的规则:一个触控点的一个事件序列只能给一个view处理,除非异常情况。所以如果viewGroup消费了down事件,那么子view将无法收到任何事件。

    viewGroup第一步会判读这个事件是否需要分发给子view,如果是则调用onInterceptTouchEvent方法判断是否要进行拦截。
    第二步是如果这个事件是down事件,那么需要为他寻找一个消费此事件的子控件,如果找到则为他创建一个TouchTarget。
    第三步是派发事件,如果存在TouchTarget,说明找到了消费事件序列的子view,直接分发给他。如果没有则交给自己处理。

  6. 你前面讲到“一个触控点的一个事件序列只能给一个view处理,除非异常情况”,这里有什么异常情况呢?如果发生异常情况该如何处理?

    这里的异常情况主要有两点:1.被viewGroup拦截,2.出现界面跳转等其他情况。

    当事件流中断时,viewGroup会发送一个ACTION_CANCEL事件给到view,此时需要做一些状态的恢复工作,如终止动画,恢复view大小等等。

  7. 那既然说到ACTION_CANCEL类型,那你可以说说还有什么事件类型吗?

    除了ACTION_CANCEL,其他事件类型还有:

    • ACTION_MOVE:当我们手指在屏幕上滑动时产生此事件
    • ACTION_UP:当我们手指抬起时产生此事件

    此外多指操作也比较常见:

    • ACTION_POINTER_DOWN: 当已经有一个手指按下的情况下,另一个手指按下会产生该事件
    • ACTION_POINTER_UP: 多个手指同时按下的情况下,抬起其中一个手指会产生该事件。

    一个完整的事件序列是从ACTION_DOWN开始,到ACTION_UP或者ACTION_CANCEL结束。
    一个手指的完整序列是从ACTION_DOWN/ACTION_POINTER_DOWN开始,到ACTION_UP/ACTION_POINTER_UP/ACTION_CANCEL结束。

  8. 哦?说到多指,那你知道ViewGroup是如何将多个手指产生的事件准确分发给不同的子view吗

    这个问题的关键在于MotionEvent以及ViewGroup内部的TouchTarget。

    每个MotionEvent中都包含了当前屏幕所有触控点的信息,他的内部用了一个数组来存储不同的触控id所对应的坐标数值。

    当一个子view消费了down事件之后,ViewGroup会为该view创建一个TouchTarget,这个TouchTarget就包含了该view的实例与触控id。这里的触控id可以是多个,也就是一个view可接受多个触控点的事件序列。

    当一个MotionEvent到来之时,ViewGroup会将其中的触控点信息拆开,再分别发送给感兴趣的子view。从而达到精准发送触控点信息的目的。

  9. 那view支持处理多指信息吗?

    View默认是不支持的。他在获取触控点信息的时候并没有传入触控点索引,也就是获取的是MotionEvent内部数组中的第一个触控点的信息。多指需要我们自己去重写方法支持他。
  10. 嗯嗯...那View是如何处理触摸事件的?

    首先,他会判断是否存在onTouchListener,存在则会调用他的onTouch方法来处理事件。如果该方法返回true那么就分发结束直接返回。而如果该监听器为null或者onTouch方法返回了false,则会调用onTouchEvent方法来处理事件。

    onTouchEvent方法中支持了两种监听器:onClickListener和onLongClickListener。View会根据不同的触摸情况来调用这两个监听器。同时进入到onTouchEvent方法中,无论该view是否是enable,只要是clickable,他的分发方法都是返回true。

    总结一下就是:先调用onTouchListener,再调用onClickListener和onLongClickListener。

  11. 你前面多次讲到分发方法和返回值,那你可以讲讲主要有什么方法以及他们之间的关系吗?

    嗯嗯。核心的方法有三个:dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent。

    简单来说:dispatchTouchEvent是核心的分发方法,所有分发逻辑都在这个方法中执行;onInterceptTouchEvent在viewGroup负责判断是否拦截;onTouchEvent是消费事件的核心方法。viewGroup中拥有这三个方法,而view没有onInterceptTouchEvent方法。

    • viewGroup

      1. viewGroup的dispatchTouchEvent方法接收到事件消息,首先会去调用onInterceptTouchEvent判断是否拦截事件

        • 如果拦截,则调用自身的onTouchEvent方法
        • 如果不拦截则调用子view的dispatchTouchEvent方法
      2. 子view没有消费事件,那么会调用viewGroup本身的onTouchEvent
      3. 上面1、2步的处理结果为viewGroup的dispatchTouchEvent方法的处理结果,没有消费则返回false并返回给上一层的onTouchEvent处理,如果消费则分发结束并返回true。
    • view

      1. view的dispatchTouchEvent默认情况下会调用onTouchEvent来处理事件,返回true表示消费事件,返回false表示没有消费事件
      2. 第1步的结果就是dispatchTouchEvent方法的处理结果,成功消费则返回true,没有消费则返回false并交给上一层的onTouchEvent处理

    简单来说,在控件树中,每个viewGroup在dispatchTouchEvent方法中不断往下分发寻找消费的view,如果底层的view没有消费事件则会一层层网上调用viewGroup的onTouchEvent方法来处理事件。

    同时,由于Activity继承了Window.CallBack接口,所以也有dispatchTouchEvent和onTouchEvent方法:

    1. activity接收到触摸事件之后,会直接把触摸事件分发给viewGroup
    2. 如果viewGroup的dispatchTouchEvent方法返回false,那么会调用Activity的onTouchEvent来处理事件
    3. 第1、2步的处理结果就是activity的dispatchTouchEvent方法的处理结果,并返回给上层
  12. 看来你对事件分发了解得挺多的,那你在实际中有运用到事件分发吗?

    嗯嗯,有的。举两个例子。

    第一个需求是要设计一个按钮块,按下的时候会缩小高度变低同时变得半透明,放开的时候又会回弹。这个时候就可以在这个按钮的onTouchEvent方法中判断事件类型:down则开启按下动画,up则开启释放动画。同时注意接收到cancel事件的时候要恢复状态。

    第二个是滑动冲突。解决滑动冲突的核心思路就是把滑动事件根据具体的情况分发给viewGroup或者内部view。主要的方法有外部拦截法和内部拦截法。
    外部拦截法的思路就是在viewGroup中判断滑动的情况,对符合自身滑动的事件进行拦截,对不符合的事件不拦截,给到内部view。内部拦截法的思路要求viewGroup拦截除了down事件以外的所有事件,然后再内部view中判断滑动的情况,对符合自身滑动情况的时间设置禁止拦截标志,对不符合自身滑动情况的事件则取消标志让viewGroup进行拦截。

  13. 那外部和内部拦截法该如何选择呢?

    在一般的情况下,外部拦截法不需要对子view进行方法重写,比内部拦截法更加简单,推荐使用外部拦截法。

    但如果需要在子view判断更多的触摸情况时,则使用内部拦截法可更加方法子view处理情况。

  14. 前面一直聊到触摸事件,那你知道一个触摸事件是如何从触摸屏幕开始产生的吗?

    额....在屏幕接收到触摸信息后,会把这个信息交给InputServiceManager去处理,最后通过WindowManagerService找到符合的window,并把触摸信息发送给viewRootImpl,viewRootImpl经过层层封和处理之后,产生一个MotionEvent事件分发给view。
  15. 可以具体讲讲前面IMS处理的流程吗?

    啊。。这。。。嗯。。。。不会。。。
  16. 你还有什么想问的吗?

    可不可以。。。。给我个小小的点赞再走?
  17. 下次一定。

    =_=....

最后

关于面试,我一直坚持的一个观点就是:可以面向面试知识点学习,但不可面向面试题目答案学习 。把相关热门题目的答案背诵下来可以忽悠到一些面试官,但现在基本上都不是简单的询问什么是事件分发,而会给一个具体的需求让我们思考等等。背诵面试题短期可能会让我们好像学到了很多,但事实上,我们什么都没学到。

事件分发系列文章到此完结。有疑问欢迎评论区交流,希望文章对你有帮助~

都看到这了,要不给作者留下个点赞再走(:

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
笔者才疏学浅,有任何想法欢迎评论区交流指正。
如需转载请评论区或私信交流。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 0 收藏 0 评论 0

一只修仙的猿 发布了文章 · 2月22日

Android事件分发机制四:学了事件分发有什么用?

“ 学了事件分发,影响我CV大法吗?”

“ 影响我陪女朋友的时间”

“ ..... ”

前言

Android事件分发机制已经来到第四篇了,在前三篇中:

那么关于事件分发的知识在上面三篇文章也就分析地差不多了,接下来就分析一下学了之后该如何使运用到实际开发中,简单阐述一下笔者的思考。

Android中的view一般由两个重要的部分组成:绘制和触摸反馈。如何精准地针对用户的操作给出正确的反馈,是我们学事件分发最重要的目标。

运用事件分发一般有两个场景:给view设置监听器和自定义view。接下来就针对这两方面展开分析,最后再给出笔者的一些思考与总结。

监听器

触摸事件监听器可以说是我们接触Android事件体系的第一步。监听器通常有:

  • OnClickListener : 单击事件监听器
  • OnLongClickListener : 长按事件监听器
  • OnTouchListener : 触摸事件监听器

这些是我们使用得最频繁的监听器,他们之间的关系是:

if (mOnTouchListener!=null && mTouchListener.onTouch(event)){
    return true;
}else{
    if (单击事件){
        mOnClickListener.onClick(view);
    }else if(长按事件){
        mOnLongClickListener.onLongClick(view);
    }
}

上面的伪代码可以很明显地发现:onTouchListener是直接把MotionEvent对象直接接管给自己处理且会最先调用,而其他的两个监听器是view判断点击类型之后再分别调用

除此之外,另一个很常见的监听器是双击监听器,但这种监听器并不是view默认支持的,需要我们自己去实现。双击监听器的实现思路可以参考view实现长按监听器的思路来实现:

当我们接收到点击事件时,可以发送一个单击延时任务。如果在延迟时间到还没收到另一个点击事件,那么这就是一个单击事件;如果在延迟时间内收到另一个点击事件,说明这是一个双击事件,并取消延时任务。

我们可以实现 view.OnClickListener 接口来完成以上逻辑,核心代码如下:

// 实现onClickListener接口
class MyClickListener() : View.OnClickListener{
    private var isClicking = false
    private var singleClickListener : View.OnClickListener? = null
    private var doubleClickListener : View.OnClickListener? = null
    private var delayTime = 1000L
    private var clickCallBack : Runnable? = null
    private var handler : Handler = Handler(Looper.getMainLooper())

    override fun onClick(v: View?) {
        // 创建一个单击延迟任务,延迟时间到了之后触发单击事件
        clickCallBack = clickCallBack?: Runnable {
            singleClickListener?.onClick(v)
            isClicking = false
        }
        // 如果已经点击过一次,在延迟时间内再次接受到点击
        // 意味着这是个双击事件
        if (isClicking){
            // 移除延迟任务,回调双击监听器
            handler.removeCallbacks(clickCallBack!!)
            doubleClickListener?.onClick(v)
            isClicking = false
        }else{
            // 第一次点击,发送延迟任务
            isClicking = true
            handler.postDelayed(clickCallBack!!,delayTime)
        }
    }
...
}

代码中实现了创建了一个 View.OnclickListener 接口实现类,并在类型实现单击和双击的逻辑判断。我们可以如下使用这个类:

val c = MyClickListener()
// 设置单击监听事件
c.setSingleClickListener(View.OnClickListener {
    Log.d(TAG, "button: 单击事件")
})
// 设置双击监听事件
c.setDoubleClickListener(View.OnClickListener {
    Log.d(TAG, "button: 双击事件")
})
// 把监听器设置给按钮
button.setOnClickListener(c)

这样就实现了按钮的双击监听了。

其他类型的监听器如:三击、双击长按等等,都可以基于这种思路来实现监听器接口。

自定义view

在自定义view中,我们可以更加灵活地运用事件分发来解决实际的需求。举几个例子:

滑动嵌套问题:外层是viewPager,里层是recyclerView,要实现左右滑动切换viewPager,上下滑动recyclerView。这也就是著名的滑动冲突问题。类似的还有外层viewPager,里层也是可以左右滑动的recyclerView。
实时触摸反馈问题:如设计一个按钮,要让他按下的时候缩小降低高度,放开的时候恢复到原来的大小和高度,而且如果在一个可滑动的容器中,按下之后滑动不会触发点击事件而是把事件交给外层可滑动容器。

我们可以发现,基本上都是基于实际的开发需求来灵活运用事件分发。具体到代码实现,都是围绕着三个关键方法展开: dispatchTouchEventonIntercepterTouchEventonTouchEvent 。这三个方法在view和viewGroup中已经有了默认的实现,而我们需要基于默认实现来完成我们的需求。下面看看几种常见的场景如何实现。

实现方块按下缩小

我们先来看看具体的实现效果:

方块按下后,会缩小高度变低透明度增加,释放又会恢复。

这个需求可以通过结合属性动画来实现。按钮块本身有高度、有圆角,我们可以考虑继承cardView来实现,重写cardView的dispatchTouchEvent方法,在按下的时候,也就是接收到down事件的时候缩小,在接收到up和cancel事件的时候恢复。注意,这里可能会忽视cancel事件,导致按钮块的状态无法恢复,一定要加以考虑cancel事件 。然后来看下代码实现:

public class NewCardView extends CardView {

    //点击事件到来的时候进行判断处理
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        // 获取事件类型
        int actionMarked = ev.getActionMasked();
        // 根据时间类型判断调用哪个方法来展示动画
        switch (actionMarked){
            case MotionEvent.ACTION_DOWN :{
                clickEvent();
                break;
            }
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                upEvent();
                break;
            default: break;
        }
        // 最后回调默认的事件分发方法即可
        return super.dispatchTouchEvent(ev);
    }

    //手指按下的时候触发的事件;大小高度变小,透明度减少
    private void clickEvent(){
        setCardElevation(4);
        AnimatorSet set = new AnimatorSet();
        set.playTogether(
                ObjectAnimator.ofFloat(this,"scaleX",1,0.97f),
                ObjectAnimator.ofFloat(this,"scaleY",1,0.97f),
                ObjectAnimator.ofFloat(this,"alpha",1,0.9f)
        );
        set.setDuration(100).start();
    }

    //手指抬起的时候触发的事件;大小高度恢复,透明度恢复
    private void upEvent(){
        setCardElevation(getCardElevation());
        AnimatorSet set = new AnimatorSet();
        set.playTogether(
                ObjectAnimator.ofFloat(this,"scaleX",0.97f,1),
                ObjectAnimator.ofFloat(this,"scaleY",0.97f,1),
                ObjectAnimator.ofFloat(this,"alpha",0.9f,1)
        );
        set.setDuration(100).start();
    }
}

动画方面的内容就不分析了,不属于本文的范畴。可以看到我们只是给cardView设置了动画效果,监听事件我们可以设置给cardView内部的ImageView或者直接设置给CardView。需要注意的是,如果设置给cardView需要重写cardView的 intercepTouchEvent 方法永远返回true,防止事件被子view消费而无法触发监听事件。

解决滑动冲突

滑动冲突是事件分发运用最频繁的场景,也是一个重难点(敲黑板,考试要考的)。滑动冲突的基本场景有以下三种:

  • 情况一:内外view的滑动方向不同,例如viewPager嵌套ListView
  • 情况二:内外view滑动方向相同,例如viewPager嵌套水平滑动的recyclerView
  • 情况三:情况一和情况二的组合

解决这类问题一般有两个步骤:确定最终实现效果、代码实现。

滑动冲突的解决需要结合具体的实现需求,而不是一套解决方案可以解决一切的滑动冲突问题,这不现实。因此在解决这类问题时,需要先确定好最终的实现效果,然后再根据这个效果去思考代码实现。这里主要讨论情况一和情况二,情况三类同。

情况一

情况一是内外滑动方向不一致。这种情况的通用解决方案就是:根据手指滑动直线与水平线的角度来判断是左右滑动还是上下滑动:

如果这个角度小于45度,可以认为是在左右滑动,如果大于45度,则认为是上下滑动。那么现在确定好解决方案,接下来就思考如何代码实现。

滑动角度可以通过两个连续的MotionEvent对象的坐标计算出来,之后我们再根据角度的情况选择把事件交给外部容器还是内部view。这里根据事件处理的位置可分为内部拦截法和外部拦截法

  • 外部拦截法:在viewGroup中判断滑动的角度,如果符合自身滑动方向消费则拦截事件
  • 内部拦截法:在内部view中判断滑动的角度,如果是符合自身滑动方向则继续消费事件,否则请求外部viewGroup拦截事件处理

从实现的复杂度来看,外部拦截法会更加优秀,不需要里外view去配合,只需要viewGroup自身做好事件拦截处理即可。两者的区别就在于主动权在谁的手上。如果view需要做更多的判断可以采用内部拦截法,而一般情况下采用外部拦截法会更加简单。

接下来思考一下这两种方法的代码实现。


外部拦截法中,重点在于是否拦截事件,那么我们的重心就放在了 onInterceptTouchEvent 方法中。在这个方法中计算滑动角度并判断是否要进行拦截。这里以ScrollView为例子(外部是垂直滑动,内部是水平滑动),代码如下:

public class MyScrollView extends ScrollView {
    // 记录上一次事件的坐标
    float lastX = 0;
    float lastY = 0;

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        int actionMasked = ev.getActionMasked();
        // 不能拦截down事件,否则子view永远无法获取到事件
        // 不能拦截up事件,否则子view的点击事件无法被触发
        if (actionMasked == MotionEvent.ACTION_DOWN || actionMasked == MotionEvent.ACTION_UP){
            lastX = ev.getX();
            lastY = ev.getY();
            return false;
        }   

        // 获取斜率并判断
        float x = ev.getX();
        float y = ev.getY();
        return Math.abs(lastX - x) < Math.abs(lastY - y);
    }
}

代码的实现思路很简单,记录两次触控点的位置,然后计算出斜率来判断是垂直还是水平滑动。代码中有个需要注意的点:viewGroup不能拦截up事件和down事件。如果拦截了down事件那么子view将永远接收不到事件信息;如果拦截了up事件那么子view将永远无法触发点击事件。

上面的代码是事件分发的核心代码,更加具体的代码还需要根据实际需求去完善细节,但整体的逻辑是不变的。


内部拦截法的思路和外部拦截的思路很像,只是判断的位置放到了内部view中。内部拦截法意味着内部view必须要有控制事件流走向的能力,才能对事件进行处理。这里就运用到了内部view一个重要的方法: requestDisallowInterceptTouchEvent

这个方法可以强制外层viewGroup不拦截事件。因此,我们可以让viewGroup默认拦截除了down事件以外的所有事件。当子view需要处理事件时,只需要调用此方法即可获取事件;而当想要把事件交给viewGroup处理时,那么只需要取消这个标志,外层viewGroup就会拦截所有事件。从而达到内部view控制事件流走向的目的。

代码实现需要分两步走,首先是设置外部viewGroup拦截除了down事件以外的所有事件(这里用viewPager和ListView来进行代码演示):

public class MyViewPager extends ViewPager {
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getActionMasked()==MotionEvent.ACTION_DOWN){
            return false;
        }
        return true;
    }
}

接下来需要重写内部view的dispatchTouchEvent方法:

public class MyListView extends ListView {
    float lastX = 0;
    float lastY = 0;

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        int actionMarked = ev.getActionMasked();
        switch (actionMarked){
            // down事件,必须请求不拦截,否则拿不到move事件无法进行判断
            case MotionEvent.ACTION_DOWN:{
                requestDisallowInterceptTouchEvent(true);
                break;
            }
            // move事件,进行判断是否处理事件
            case MotionEvent.ACTION_MOVE:{
                float x = ev.getX();
                float y = ev.getY();
                // 如果滑动角度大于90度自己处理事件
                if (Math.abs(lastY-y)<Math.abs(lastX-x)){
                    requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            default:break;
        }
        // 保存本次触控点的坐标
        lastX = ev.getX();
        lastY = ev.getY();
        // 调用ListView的dispatchTouchEvent方法来处理事件
        return super.dispatchTouchEvent(ev);
    }
}

两种方法的代码思路基本一致,但是内部拦截法会更加复杂一点,所以在一般的情况下,还是使用外部拦截法较好。

到这里已经解决了情况一的滑动冲突解决方案,接下来看看情况二的滑动冲突如何解决。

情况二

第二种情况是里外容器的滑动方向是一致的,这种情况的主流解决方法有两种,一种是外容器先滑动,外容器滑动到边界之后再滑动内部view,例如京东app(注意向下滑动时的情况):

第二种情况的内部view先滑动,等内部view滑动到边界之后再滑动外部viewGroup,例如饿了么app(注意向下滑动时的情况):

这两种方案没有孰好孰坏,而是需要根据具体的业务需求来确定具体的解决方案。下面就上述的第二种方案展开分析,第一种方案类同。

首先分析一下具体的效果:外层viewGroup与内层view的滑动方向是一致的,都是垂直滑动或水平滑动;向上滑动时,先滑动viewGroup到顶部,再滑动内部view;向下滑动时,先滑动内部view到顶部后再滑动外层viewGroup。

这里我们采用外部拦截法来实现。首先我们先确定好我们的布局:

image.png

最外层是一个ScrollView,内部首先是一个LinearLayout,因为ScrollView只能有一个view。内部顶部是一个LinearLayout可以放置头部布局,下面是一个ListView。现在需要确定ScrollView的拦截规则:

  1. 当ScrollView没有滑动到底部时,直接给ScrollView处理
  2. 当ScrollView滑动到底部时:

    • 如果LinearLayout没有滑动到顶部,则交给ListView处理
    • 如果LinearLayout滑动到顶部:

      • 如果是向上滑动则交给listView处理
      • 如果是向下滑动则交给ScrollView处理

接下来就可以确定我们的代码了:

public class MyScrollView extends ScrollView {
    ...
    float lastY = 0;
    boolean isScrollToBottom = false;
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int actionMarked = ev.getActionMasked();
        switch (actionMarked){
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:{
                // 这三种事件默认不拦截,必须给子view处理
                break;
            }
            case MotionEvent.ACTION_MOVE:{
                LinearLayout layout = (LinearLayout) getChildAt(0);
                ListView listView = (ListView)layout.getChildAt(1);
                // 如果没有滑动到底部,由ScrollView处理,进行拦截
                if (!isScrollToBottom){
                    intercept = true;
                    // 如果滑动到底部且listView还没滑动到顶部,不拦截
                }else if (!ifTop(listView)){
                    intercept = false;
                }else{
                    // 否则判断是否是向下滑
                    intercept = ev.getY() > lastY;
                }
                break;
            }
            default:break;
        }
        // 最后记录位置信息
        lastY = ev.getY();
        // 调用父类的拦截方法,ScrollView需要做一些处理,不然可能会造成无法滑动
        super.onInterceptTouchEvent(ev);
        return intercept;
    }
    ...
}

代码中我还增加了如果listView下面有view的情况,判断是否滑动到底部。判断listView滑动情况和scrollView滑动情况的代码如下:

{
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        // 设置滑动监听
        setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
            ViewGroup viewGroup = (ViewGroup)v;
            isScrollToBottom = v.getHeight() + scrollY >= viewGroup.getChildAt(0).getHeight();
        });
    }
}
// 判断listView是否到达顶部
private boolean ifTop(ListView listView){
    if (listView.getFirstVisiblePosition()==0){
        View view = listView.getChildAt(0);
        return view != null && view.getTop() >= 0;
    }
    return false;
}

最终的实现效果如下图:

这样就简单地解决一个滑动冲突了。但是要注意的是,在实际问题中,往往有更加复杂的细节需要处理。而上述只是把解决滑动冲突的一个思想分析了一下,具体到业务上,还需要去细心打磨代码才行。有兴趣可以去看看NeatedScrollView是如何解决滑动冲突的源码。

最后

事件分发作为Android的基础知识储备可谓是非常重要。不能说学了事件分发,就可以直接一飞冲天。而是掌握了事件分发之后,面对一些具体的需求,就有了一定的思路去处理。或者在了解一些框架的源码的时候,懂得他这些代码是什么意思。

学习事件分发的过程中,深入研究了很多的源码,有一些小伙伴觉得没必要。实际开发中也就用到那三个主要的方法,了解一个主要的流程就足够了。我想说:确实是这样;但没有研究背后的原理,就只能知其然而不知其所以然。当遇到一些异常的情况时,就无法从源码的角度去分析结果的bug。学习源码的过程中,也是与设计android系统的作者的一种交流。倘若现在没有事件分发机制,那么我该如何去解决触摸信息的分发问题?学习的过程就是在思考android系统作者给出的解决方案。而掌握原理之后,对于事件分发的问题,稍加思考和分析,也就手到擒来了。正所谓:

只有打败10级的敌人,才能掌控9级的敌人。

希望文章对你有帮助。

要不留下个小小的点赞鼓励一下作者?

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
笔者才疏学浅,有任何想法欢迎评论区交流指正。
如需转载请评论区或私信交流。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 0 收藏 0 评论 0

一只修仙的猿 发布了文章 · 2月8日

Android事件分发机制三:事件分发工作流程

前言

很高兴遇见你~

本文是事件分发系列的第三篇。

在前两篇文章中,Android事件分发机制一:事件是如何到达activity的? 分析了事件分发的真正起点:viewRootImpl,Activity只是其中的一个环节;Android事件分发机制二:viewGroup与view对事件的处理 源码解析了viewGroup和view是如何分发事件的。

事件分发的核心内容,则为viewGroup和view对事件的分发,也就是第二篇文章。第二篇文章对源码的分析较为深入,缺乏一个更高的角度来审视事件分发流程。本文在前面的分析基础上,对整个事件分发的工作流程进行一个总结,更好地把握事件是如何在不同的对象和方法之间进行传递。

回顾

先来回顾一下整体的流程,以便更好地定位我们的知识。

  1. 触摸信息从手机触摸屏幕时产生,通过IMS和WMS发送到viewRootImpl
  2. viewRootImpl通过调用view的dispatchPointerEvent方法把触摸信息传递给view
  3. view通过调用自身的dispatchTouchEvent方法开始了事件分发

图中的view指的是一个控件树,他可以是一个viewGroup也可以是一个简单的view。因为viewGroup是继承自view,所以一个控件树,也可以看做是一个view。

我们今天探讨的工作流程,就是从图中的view调用自身的dispatchTouchEvent开始。

主要对象与方法

事件分发的对象

这一部分内容在第二篇有详细解析,这里做个简单的回顾。

当我们手机触碰屏幕时会产生一系列的MotionEvent对象,根据触摸的情况不同,这些对象的类型也会不同。具体如下:

  • ACTION_DOWN: 表示手指按下屏幕
  • ACTION_MOVE: 手指在屏幕上滑动时,会产生一系列的MOVE事件
  • ACTION_UP: 手指抬起,离开屏幕、
  • ACTION_CANCEL:当出现异常情况事件序列被中断,会产生该类型事件
  • ACTION_POINTER_DOWN: 当已经有一个手指按下的情况下,另一个手指按下会产生该事件
  • ACTION_POINTER_UP: 多个手指同时按下的情况下,抬起其中一个手指会产生该事件

事件分发的方法

事件分发属于控件系统的一部分,主要的分发对象是viewGroup与view。而其中核心的方法有三个: dispatchTouchEventonInterceptTouchEventonTouchEvent 。那么在讲分发流程之前,先来介绍一下这三个方法。这三个方法属于view体系的类,其中Window.CallBack接口中包含了 dispatchTouchEventonTouchEvent 方法,Activity和Dialog都实现了Window.CallBack接口,因此都实现了该方法。因这三个方法经常在自定义view中被重写,以下的分析,如果没有特殊说明都是在默认方法实现的情况下。

dispatchTouchEvent

该方法是事件分发的核心方法,事件分发的逻辑都是在这个方法中实现。该方法存在于类View中,子类ViewGroup、以及其他的实现类如DecorView都重写了该方法。

无论是在viewGroup还是view,该方法的主要作用都是处理事件。如果成功处理则返回true,处理失败则返回false,表示事件没有被处理。具体到类,在viewGroup相关类中,该方法的主要作用是把事件分发到该viewGroup所拥有的子view,如果子view没有处理则自己处理;在view的相关类中,该方法的主要作用是消费触摸事件。

onInterceptTouchEvent

该方法只存在于viewGroup中,当一个事件需要被分发到子view时,viewGroup会调用此方法检查是否要进行拦截。如果拦截则自己处理,而如果不拦截才会调用子view的 dispatchTouchEvent 方法分发事件。

方法返回true表示拦截事件,返回false表示不拦截。

这个方法默认只对鼠标的相关操作的一种特殊情况进行了拦截,其他的情况需要具体的实现类去重写拦截。

onTouchEvent

该方法是消费事件的主要方法,存在于view中,viewGroup默认并没有重写该方法。方法返回true表示消费事件,返回false表示不消费事件。

viewGroup分发事件时,如果没有一个子view消费事件,那么会调用自身的onTouchEvent方法来处理事件。View的dispatchTouchEvent方法中,并不是直接调用onTouchEvent方法来消费事件,而是先调用onTouchListener判断是否消费;如果onTouchListener没有消费事件,才会调用onTouchEvent来处理事件。

我们为view设置的onClickListener与onLongClickListener都是在View的dispatchTouchEvent方法中,根据具体的触摸情况被调用。

重要规则

事件分发有一个很重要的原则:一个触控点的事件序列只能给一个view消费,除非发生异常情况如被viewGroup拦截 。具体到代码实现就是:消费了一个触控点事件序列的down事件的view,将持续消费该触控点事件序列接下来的所有的事件 。举个栗子:

当我手指按下屏幕时产生了一个down事件,只有一个view消费了这个down事件,那么接下来我的手指滑动屏幕产生的move事件会且仅会给这个view消费。而当我手机抬起,再按下时,这时候又会产生新的down事件,那么这个时候就会再一次去寻找消费down事件的view。所以,事件分发,是以事件序列为单位的

因此下面的工作流程中都是指down事件的分发 ,而不是ACTION_MOVE或ACTION_UP的分发。因为消费了down事件,意味着接下来的move和up事件都会给这个view处理,也就无所谓分发了。但同时注意事件序列是可以被viewGroup的onInterceptTouchEvent中断的,这些就属于其他的情况了。

细心的读者还会发现事件分发中包含了多点触控。在多点触控的情况下,ACTION_POINTER_DOWN与ACTION_DOWN的分发规则是不同的,具体可前往第二篇文章了解详细。ACTION_POINTER_DOWN在ACTION_DOWN的分发模型上稍作了一些修改而已,后面会详细解析,

工作流程模型

工作流程模型,本质上就是不同的控件对象,viewGroup和view之间事件分发方法的关系。需要注意的是,这里讨论的是viewGroup和view的默认方法实现,不涉及其他实现类如DecorView的重写方法。

下面用一段伪代码来表示三个事件分发方法之间的关系( 这里再次强调,这里的事件分发模型分发的事件类型是ACTION_DOWN且都是默认的方法,没有经过重写,这点很重要 ):

public boolean dispatchTouchEvent(MotionEvent event){
    
    // 先判断是否拦截
    if (onInterceptTouchEvent()){
        // 如果拦截调用自身的onTouchEvent方法判断是否消费事件
        return onTouchEvent(event);
    }
    // 否则调用子view的分发方法判断是否处理事件
    if (childView.dispatchTouchEvent(event)){
        return true;
    }else{
        return onTouchEvent(event);
    }
}

这段代码非常好的展示了三个方法之间的关系:在viewGroup收到触摸事件时,会先去调用 onInterceptTouchEvent 方法判断是否拦截,如果拦截则调用自己的 onTouchEvent 方法处理事件,否则调用子view的 dispatchTouchEvent 方法来分发事件。因为子view也有可能是一个viewGroup,这样就形成了一个类似递归的关系。

这里我再补上view分发逻辑的简化伪代码:

public boolean dispatchTouchEvent(MotionEvent event){
    // 先判断是否存在onTouchListener且返回值为true
    if (mOnTouchListener!=null && mOnTouchListener.onTouch(event)){
        // 如果成功消费则返回true
        return true;
    }else{
        // 否则调用onTouchEvent消费事件
        return onTouchEvent(event);
    }
}

view与viewGroup不同的是他不需要分发事件,所以也就没有必要拦截事件。view会先检查是否有onTouchListener且返回值是否为true,如果是true则直接返回,否则调用onTouchEvent方法来处理事件。

基于上述的关系,可以得到下面的工作流程图:

这里为了展示类递归关系使用了画了两个viewGroup,只需看中间一个即可,下面对这个图进行解析:

  • viewGroup

    1. viewGroup的dispatchTouchEvent方法接收到事件消息,首先会去调用onInterceptTouchEvent判断是否拦截事件

      • 如果拦截,则调用自身的onTouchEvent方法
      • 如果不拦截则调用子view的dispatchTouchEvent方法
    2. 子view没有消费事件,那么会调用viewGroup本身的onTouchEvent
    3. 上面1、2步的处理结果为viewGroup的dispatchTouchEvent方法的处理结果,并返回给上一层的onTouchEvent处理
  • view

    1. view的dispatchTouchEvent默认情况下会调用onTouchEvent来处理事件,返回true表示消费事件,返回false表示没有消费事件
    2. 第1步的结果就是dispatchTouchEvent方法的处理结果,成功消费则返回true,没有消费则返回false并交给上一层的onTouchEvent处理

可以看到整个工作流程就是一个“U”型结构,在不拦截的情况下,会一层层向下寻找消费事件的view。而如果当前view不处理事件,那么就一层层向上抛,寻找处理的viewGroup。

上述的工作流程模型并不是完整的,还有其他的特殊情况没有考虑。下面讨论几种特殊的情况:

事件序列被中断

我们知道,当一个view接收了down事件之后,该触控点接下来的事件都会被这个view消费。但是,viewGroup是可以在中途掐断事件流的,因为每一个需要分发给子view的事件都需要经过拦截方法:onInterceptTouchEvent (当然,这里不讨论子view设置不拦截标志的情况)。那么当viewGroup掐断事件流之后,事件的走向又是如何的呢?参看下图:(注意,这里不讨论多指操作的情况,仅讨论单指操作的move或up事件被viewGroup拦截的情况

  1. 当viewGroup拦截子view的move或up事件之后,会将当前事件改为cancel事件并发送给子view
  2. 如果当前事件序列还未结束,那些接下来的事件都会交给viewGroup的ouTouchEvent处理
  3. 此时不管是viewGroup还是view的onTouchEvent返回了false,那么将导致整个控件树的dispatchTouchEvent方法返回false

    • 秉承着一个事件序列只能给一个view消费的原则,如果一个view消耗了down事件却在接下来的move或up事件返回了false,那么此事件不会给上层的viewGroup处理,而是直接返回false。
多点触控情况

上面讨论的所有情况,都是不包含多点触控情况的。多点触控的情况,在原有的事件分发流程上,新增了一些特殊情况。这里就不再画图,而是把一些特殊情况描述一下,读者了解一下就可以了。

默认情况下,viewGroup是支持多点触控的分发,但view是不支持多点触控的,需要自己去重写 dispatchTouchEvent 方法来支持多点触控。

多点触控的分发规则如下:

viewGroup在已有view接受了其他触点的down事件的情况下,另一个手指按下产生ACTION_POINTER_DOWN事件传递给viewGroup:

  1. viewGroup会按照ACTION_DOWN的方式去分发ACTION_POINTER_DOWN事件

    • 如果子view消费该事件,那么和单点触控的流程一致
    • 如果子view未消费该事件,那么会交给上一个最后接收down事件的view去处理
  2. viewGroup两个view接收了不同的down事件,那么拦截其中一个view的事件序列,viewGroup不会消费拦截的事件序列。换句话说,viewGroup和其中的view不能同时接收触摸事件。

Activity的事件分发

细心的读者会发现,上述的工作流程并不涉及Activity。我们印象中的事件分发都是 Activity -> Window -> ViewGroup ,那么这是怎么回事?这一切,都是DecorView “惹的祸” 。

DecorView重写viewGroup的 dispatchTouchEvent 方法,当接收到触摸事件后,DecorView会首先把触摸对象传递给内部的callBack对象。没错,这个callBack对象就是Activity。加入Activity这个环节之后,分发的流程如下图所示:

整体上和前面的流程没有多大的不同,Activity继承了Window.CallBack接口,所以也有dispatchTouchEvent和onTouchEvent方法。对上图做个简单的分析:

  1. activity接收到触摸事件之后,会直接把触摸事件分发给viewGroup
  2. 如果viewGroup的dispatchTouchEvent方法返回false,那么会调用Activity的onTouchEvent来处理事件
  3. 第1、2步的处理结果就是activity的dispatchTouchEvent方法的处理结果,并返回给上层

上面的流程不仅适用于Activity,同样适用于Dialog等使用DecorView和callback模式的控件系统。

最后

到这里,事件分发的主要内容也就讲解完了。结合前两篇文章,相信读者对于事件分发有更高的认知。

纸上得来终觉浅,绝知此事要躬行。学了知识之后最重要的就是实践。下一篇文章将简单分析一下如何利用学习到的事件分发知识运用到实际开发中。

原创不易,你的点赞是我创作最大的动力,感谢阅读 ~

全文到此,原创不易,觉得有帮助可以点赞收藏评论转发。
笔者才疏学浅,有任何想法欢迎评论区交流指正。
如需转载请评论区或私信交流。

另外欢迎光临笔者的个人博客:传送门

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 5 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2020-12-02
个人主页被 895 人浏览