0%

kotlin为何放弃了check和uncheck异常的分别?不强制要求代码捕获异常?

Kotlin 在官网给出了一些解释,大概的意思是Checked Exception 在项目小的时候确实能够提升效率和代码质量,但是在大型项目中却会降低代码质量。

关于CE,Anders Hejlsberg认为它带来了两个问题版本问题和扩展问题。

所谓的版本问题是什么意思呢?

Anders Hejlsberg举了一个例子:

假设有一个方法foo,它声明了抛出异常A、B和C,在下一个版本设计的时候,foo增加了一个新的特性,可能会抛出异常D。对于设计者来说,很明显这是一个大的改变,几乎可以确定的是,客户程序员不会去处理这个异常。为了避免出现问题,设计者不得不声明一个新的方法foo2,抛出一个新的异常。然后,客户程序员可以将针对foo的逻辑处理切换到foo2。

所谓的扩展问题又是什么意思呢?

以下来自Anders Hejlsberg的原话翻译并整理:

如果你在设计一个很小的系统,声明一个方法抛出一个异常,这很棒。可是,如果你尝试构建一个大的系统,其中包含了四、五个小系统的时候,问题来了。假设每个子系统可能抛出四到五个异常,而每上升一个系统,就犹如爬阶梯,异常数量会指数倍增加,最终你可能处理的异常将达到40个甚至80个。很显然,这是一个很糟糕的设计!

结论

综上,异常其实是发生在实现阶段,而不是定义阶段,既要在明确实现阶段的异常又要有灵活的实现是相悖的。

所以 Kotlin 选择抛弃 Checked Exception,靠工程师来处理,这的确增加了 Kotlin 编写的难度,但也对大型项目更加友好,并且可以增加开发速度。

参考资料

Activity的启动流程是怎样的?

为什么Activity生命周期函数是运行在UI线程的?

因为ActivityManagerService通过Binder通信给App进程的IApplicationThread这个Binder对象发送Activity生命周期的事件,而IApplicationThread是执行在Binder线程池中的,Activity生命周期函数需要执行在主线程,所以需要通过App进程的Handler转发一下,以执行在App进程的主线程。

ApplicationThread的作用是什么?

ApplicationThread是ActivityThread的内部类,继承ApplicationThreadNative,也是一个Binder对象。在此处它是作为IApplicationThread对象的server端等待client端的请求然后进行处理,最大的client就是AMS。

1
2
3
4
5
6
7
8
private class ApplicationThread extends ApplicationThreadNative {
//...
schedulePauseActivity()
scheduleStopActivity()
scheduleResumeActivity()
scheduleSendResult()
scheduleLaunchActivity()
}

可以看出来它继承了ApplicationThreadNative的,并且它内部有非常多的scheduleXXX的方法,这些 schedulexxx的方法会进一步的通过往外发送消息给ActivityThread的mH这个消息队列来做处理。

两个Activity相互跳转的生命周期?

A Activity跳转到B Activity

A.onPause() -> B.onCreate() -> B.onStart() -> B.onResume() -> A.onStop()

B Activity返回到A Activity

B.onPause() -> A.onStart() -> A.onResume() -> B.onStop() -> B.onDestroy()

一言以蔽之:

  1. 旧页面的onPause调用过后,才会调用新页面的onCreate
  2. 要进入的页面的onResume调用后,才会调用之前页面的onStop

什么情况下Activity会重建?

用户期望 Activity 的界面状态在整个配置变更(例如旋转或切换到多窗口模式)期间保持不变。但是,默认情况下,系统会在发生此类配置更改时销毁 Activity,从而清除存储在 Activity 实例中的任何界面状态。同样,如果用户暂时从您的应用切换到其他应用,并在稍后返回您的应用,他们也希望界面状态保持不变。但是,当用户离开应用且您的 Activity 停止时,系统可能会销毁该应用的进程。

Activity重建的时候怎么保存和恢复参数?

  1. onSaveInstance、onRestoreInstance方法,把对象序列化传递到Bundle对象中
  2. 单例存储。
  3. 数据持久化,重建后再读取。

为什么要用Zygote孵化一个进程?直接创建一个进程会有什么问题?

Zygote 作为一个孵化器,可以提前加载一些资源,这样 fork() 时基于 Copy-On-Write 机制创建的其他进程就能直接使用这些资源,而不用重新加载。

阅读全文 »

Android系统启动流程是怎样的?

当按电源键触发开机,首先会从 ROM 中预定义的地方加载引导程序 BootLoader 到 RAM 中,并执行 BootLoader 程序启动 Linux Kernel, 然后启动用户级别的第一个进程: init 进程。

init 进程会解析 init.rc 脚本做一些初始化工作,包括挂载文件系统、创建工作目录以及启动系统服务进程等,其中系统服务进程包括 Zygote、service manager、media 等。

在 Zygote 中会进一步去启动 system_server 进程,然后在 system_server 进程中会启动 AMS、WMS、PMS 等服务,等这些服务启动之后,AMS 中就会打开 Launcher 应用的 home Activity,最终就看到了手机的 “桌面”。

init.rc中引入了Zygote的启动脚本,Zygote的启动脚本存放在system/core/rootdir目录中,zygote的启动脚本其实也就是用app_process来启动一个java程序。

SystemServer什么时候被启动?

android 29源码启动SystemServer流程:

init进程解析init.rc脚本

init.rc脚本中有执行app_process程序的代码。

service zygote /system/bin/app_process32 -Xzygote /system/bin –zygote –start-system-server –socket-name=zygote

app_process是启动java程序用的,没有指定要运行的类名,还是会走app_process的main方法,源码如下:

https://android.googlesource.com/platform/frameworks/base/+/android-8.0.0_r4/cmds/app_process/app_main.cpp

main方法中识别到–zygote参数会执行com.android.internal.os.ZygoteInit的Java类的main方法。

识别到–start-system-server的参数,会执行forkSystemServer方法启动系统服务进程。

得到系统服务进程的pid后,执行ZygoteInit的handleSystemServerProcess() ->ZygoteInit.zygoteInit() -> RuntimeInit.applicationInit() -> findStaticMain(),最后得到MethodAndArgsCaller,是一个Runnable,返回给ZygoteInit的main()中调用forkSystemServer()的地方执行run()。

MethodAndArgsCaller的run()里通过反射按照给定参数执行给定的方法,方法是main,是哪个类的main方法?类名是在ZygoteInit的forkSystemServer()指定的,类名是com.android.server.SystemServer。

Launcher中点击App图标后,App启动流程是怎样的?

以下时序图基于Android 9.0源码。

Launcher向AMS发起startActivity请求

点击桌面App图标,Launcher进程采用Binder IPC向system_server进程的ActivityManagerService发起startActivity请求。

zygote进程创建App进程

system_server进程的AMS接收到请求后,向zygote进程发送创建进程的请求

Zygote进程fork出新的子进程

即App进程。

App进程运行ActivityThread.main()

App进程跨进程绑定ApplicationThread给系统进程的AMS

AMS做好准备工作后,让App进程创建Application实例,并调用Application的onCreate()。

为什么要绑定ApplicationThread给AMS?

ActivityThread.attach()里调用IActivityManager.attachApplication()会把当前ActivityThread实例里的ApplicationThread实例对象传递给AMS,AMS把App进程的ApplicationThread存在ProcessRecord的thread属性中

ApplicationThread是什么?

ApplicationThread是一个IBinder对象,是给system_server进程中的AMS要发送数据给App进程时用的,这属于跨进程通信。

AMS保存了App进程的ApplicationThread后,经过一系列准备,最终调用App进程ActivityThread的handleBindApplication(),这里会调用mInstrumentation.callApplicationOnCreate()

AMS发送启动Activity的请求

在ActivityManagerService的attachApplication方法中调用了attachApplicationLocked进行绑定,从上面代码可以发现attachApplicationLocked中有两个重要的方法:thread.bindApplication和mStackSupervisor.attachApplicationLocked(app)。thread.bindApplication中的thread其实就是ActivityThread里ApplicationThread对象在AMS的代理对象,故此方法将最终调用ApplicationThread的bindApplication方法。而mStackSupervisor.attachApplicationLocked(app)主要是AMS启动Activity的作用。

在realStartActivityLocked方法中,创建了ClientTransaction对象并将App进程的ApplicationThread传递进去,接着为ClientTransaction对象添加LaunchActivityItem的callback,最终调用ClientLifecycleManager.scheduleTransaction()启动activity。

ClientLifecycleManager.scheduleTransaction()内部调用了App进程的ApplicationThread的scheduleTransaction(ClientTransaction),该方法实际调用了ActivityThread的scheduleTransaction(ClientTransaction)

ClientTransactionHandler是ActivityThread的父类,所以下面时序图的ClientTransactionHandler代表ActivityThread,H是ActivityThread内部的Handler

ActivityThread的scheduleTransaction(ClientTransaction)向ActivityThread的Handler发送EXECUTE_TRANSACTION的消息,处理Activity的启动。

ActivityThread的Handler处理启动Activity的请求

ActivityThread的Handler对EXECUTE_TRANSACTION的消息处理主要是执行TransactionExecutor.execute(ClientTransaction)

TransactionExecutor.execute(ClientTransaction)主要是执行

executeCallbacks(transaction)

executeLifecycleState(transaction)

executeCallbacks()中会取出ClientTransaction的callbacks,对callbacks列表的每一项item执行execute(),callbacks每一项的类型为ClientTransactionItem

ClientTransaction的callbacks在哪添加的?

AMS的ActivityStackSupervisor的realStartActivityLocked()中添加的LaunchActivityItem。

LaunchActivityItem的execute()中会调用ActivityThread的。handleLaunchActivity(),然后调用performLaunchActivity(),在这里通过Instrumentation.newActivity()创建Activity实例,然后调用Activity的onCreate()。

executeLifecycleState()做了什么?

获取ClientTransaction的getLifecycleStateRequest(),做Activity生命周期的回调。

Activity的onStart()、onResume()是在这里触发的。

具体看源码。

ClientTransaction的mLifecycleStateRequest是在哪设置的?

AMS的ActivityStackSupervisor的realStartActivityLocked()中创建了ResumeActivityItem,通过clientTransaction.setLifecycleStateRequest(lifecycleItem)设置添加。

Binder机制概述

Android是基于Linux的,Linux的进程之间的内存地址空间是相互隔离的,进程之间无法直接访问对方的数据,这就需要有一个进程之间都可以访问的地方做数据交换,这就是内核空间。

传统的Linux跨进程通信机制下,通常是进程A把数据复制到内核中的一个缓冲区中,进程B从内核的缓冲区中读取数据。

Binder进程间通信机制自然也是要通过内核空间来进行的,但是又不是传统的Linux通信机制,怎么样去在内核中做一些操作呢?Linux有一个动态内核可加载模块,Binder就是加载了 Binder驱动 模块,来在内核中做一些操作,实现跨进程通信。

Binder驱动具体做了什么呢 ?

Binder驱动在内核空间中创建一个数据缓冲区,再通过内存映射机制,把缓冲区与数据接受进程用户空间地址做映射,这样数据发送进程的数据把数据拷贝到内核缓冲区过后,数据接受进程就可以直接 获取到数据,避免了一次内核空间的数据拷贝,提高了性能。

Binder机制还保证了进程间通信的安全性。

传统进程通信机制缺乏足够的安全措施:在传统进程通信中,只能由发送进程在请求中自行填入UID与PID,容易被恶意程序利用,是不可靠的。只有内置在进程通信机制内的可靠的进程身份标记才能提供必要的安全保障,Android的应用程序有自己UID,可用于鉴别进程身份。

Binder机制为每个进程分配了UID/PID来作为鉴别身份的标识,并且在Binder通信时会根据UID/PID进行有效性检测。传统的进程通信方式对于通信双方的身份并没有做出严格的验证,如socket通信ip地址是客户端手动填入,容易出现伪造。

Binder通信整体机制

  1. Server端向ServiceManager注册Binder对象,建立Binder名称和Binder引用的映射关系,这样Client端可以通过Binder名字获取Server中Binder实体的引用。在Android系统启动的时候,会初始化各种系统服务,这些系统服务会注册到ServiceManager。
  2. Client通过名字从ServiceManager获取到Binder实体引用,通常获取到的是一个Proxy代理对象
  3. Client调用代理对象的方法,代理对象会把要调用的方法的方法名称、参数等信息封装成Parcel对象,发送给Binder驱动
  4. Server会读取Binder驱动中的请求数据,解包Parcel对象,并把结果也以Parcel对象的形式返回
  5. 客户端发起Binder调用的时候会阻塞,服务端会有一个Binder线程池来响应处理客户端的请求
  6. 全程使用代理模式屏蔽进程间通信细节

Binder的优势是什么?

  1. 数据拷贝仅需一次,性能仅此于共享内存,优于管道、消息队列、套接字等通信方式
  2. C/S架构比共享内存清晰
  3. 传统IPC不做权限校验,由上层使用者自己实现,Binder做了权限控制

Binder的原理什么?

Binder是基于共享内存的,但是使用了mmap内存映射,只有一次用户空间到内核空间的拷贝,速度快。

在实际的实现中,binder 是作为一个特殊的字符型设备而存在的,设备节点为 /dev/binder, 其实现遵循linux设备驱动模型。

Binder机制的安全性是如何保证的?

传统进程通信机制缺乏足够的安全措施:

  • 传统进程通信的接收进程无法获得发送进程可靠的用户标识/进程标识(UID/PID),因而无法鉴别对方身份。
  • 在传统进程通信中,只能由发送进程在请求中自行填入UID与PID,容易被恶意程序利用,是不可靠的。
  • 传统进程通信的访问接入点是公开的,如FIFO与unix domain socket的路径名,socket的ip地址与端口号,lSystem V键值等,知道这些接入点的任何程序都可能试图建立连接,很难阻止恶意程序获得连接,如通过猜测地址获得连接等。

只有内置在进程通信机制内的可靠的进程身份标记才能提供必要的安全保障。
Android的应用程序有自己UID,可用于鉴别进程身份。

Binder的异常传递机制是怎样的?

可以推断所有Binder实体对象方法中发生的异常都会被处理。无非一种是将异常信息发送给对端进程,另一种是将异常信息在本进程输出。而这些处理都不会使Server进程退出。

仔细思考这样设计也是很合理的。作为Server进程,它在什么时候执行,该执行些什么都不由自己掌控,而是由Client进程控制。因此抛出异常本质上与Client进程相关,让一个Client进程的行为导致Server进程退出显然是不合理的。此外,Server进程可能关联着千百个Client,不能由于一个Client的错误行为而影响本可以正常获取服务的其他Client。

系统服务和context.bindService()的方式有什么区别?

服务可分为系统服务与普通服务,系统服务一般是在系统启动的时候,由SystemServer进程创建并注册到ServiceManager中的。而普通服务一般是通过ActivityManagerService启动的服务,或者说通过四大组件中的Service组件启动的服务。这两种服务在实现跟使用上是有不同的,主要从以下几个方面:

  • 服务的启动方式
  • 服务的注册与管理
  • 服务的请求使用方式

Binder缓冲区为什么大小是1MB?为什么不能更大?

因为缓冲区会他通过mmap映射到用户进程的虚拟地址空间,内存映射的空间不能太大。

Binder应用场景?

  1. 跨进程通信
  2. 跨进程观察者模式
  3. App提权

数字签名解决了什么?

校验apk文件的完整性,检查是否有损坏或篡改。

数字签名格式

我们在对Apk签名时并没有直接指定私钥、公钥和数字证书,而是使用keystore文件,这些信息都包含在了keystore文件中。根据编码不同,keystore文件分为很多种,Android使用的是Java标准keystore格式JKS(Java Key Storage),所以通过Android Studio导出的keystore文件是以.jks结尾的。

keystore使用的证书标准是X.509,X.509标准也有多种编码格式,常用的有两种:pem(Privacy Enhanced Mail)和der(Distinguished Encoding Rules)。jks使用的是der格式,Android也支持直接使用pem格式的证书进行签名。

v1签名

v1签名也就是jar签名机制

签名过程:

  1. 先对apk中每个文件用SHA-1哈希算法求得摘要,再用BASE64编码,将这些信息保存在apk解压后的META-INF目录下的MANIFEST.MF里。
  2. 再对MANIFEST.MF整个文件以及MANIFEST.MF每个条目用SHA-1求得摘要,再用BASE64编码,存入META-INF/CERT.SF。
  3. 对CERT.SF计算摘要,用Android签名文件中的私钥加密这个摘要,作为签名存入CERT.RSA

MANIFEST.MF和CERT.SF保证apk数据的完整性,CERT.RSA从整体上保证了数据的完整性和来源可靠。

MANIFEST.MF和CERT.SF可能会被篡改,但最终CERT.RSA中的签名是由私钥加密生成的,没有私钥就不可能生成同样的签名。

CERT.RSA还存储了签名文件(keystore)的公钥,用于解密CERT.RSA中的签名,校验apk时,如果CERT.SF求得摘要后跟签名不一致,说明CERT.SF被篡改过。

v1签名缺点:

  1. META-INF不在校验访问,这样可以在这个目录下任意放置文件。
  2. 每一个文件计算摘要,比较耗时。

CERT.RSA中存储了什么?

签名文件的公钥、签名、签名算法、签名所有者信息。

v2签名

  • v1签名验证的是每一个文件的完整性。
  • v2签名验证的是整个apk的字节流。

APK签名信息存储在“APK签名分块”。

ZIP包原本的数据从头到尾分为三块:文件数据区、目录数据区、目录结尾数据区。

APK签名分块是位于文件数据区和目录数据区之间的,目录结尾数据区中记录了目录数据区的地址位置(也就是相当于开头的偏移量),加入APK签名分块后,仅需要修改目录数据区的地址,不影响ZIP其他原来的数据。

APK 签名方案 v2 负责保护第 1、3、4 部分的完整性,以及第 2 部分包含的“APK 签名方案 v2 分块”中的 signed data 分块的完整性。

优势:

  1. 对整个zip分块计算哈希摘要,计算量减少,同时可以并行,速度更快。
  2. 验证了整个zip文件,不会漏校验zip中的数据。

v3签名

android系统里不同的keystore签名的apk不能覆盖安装,但是keystore可能因为各种不可控的原因泄露了,按照这样的机制app的发布者就束手无策,别人就可以用恶意的apk覆盖安装发布者发布的正常的apk。如果能用新的keystore签名重新签名app,并能覆盖安装用旧的keystore签名的apk,就可以解决keystore泄露的问题了。

但是也不能让任意的keystore签名的apk可以覆盖安装任意apk,这样keystore签名就失去了校验开发者身份的功能,覆盖安装apk时,android系统得知道新的apk用的新的签名也是同一个发布者签上的,那怎样保证这点?

v3签名使用密钥轮转机制,让apk可以变更签名,在签名数据库新增了一个块,专门存储证书链,建立一个签名信任链,老的证书在前,新的证书在后,新的证书中的签名是由老的数字证书对应的keystore的私钥加密的,所以在覆盖安装apk时,就可以用旧apk中的老的数字证书中的公钥解密新的数字证书中的签名,得到新的数字证书的内容哈希摘要,再根据新证书中指明的哈希算法,计算新证书内容的哈希摘要,对比两个哈希摘要是否一致,一致则说明可信赖,可以覆盖安装。

okhttp解决了什么问题?优势在哪?

官网说法:

  1. 支持HTTP/2,允许所有相同host的请求复用一个socket。
  2. 连接池降低请求延迟(如果HTTP/2不可用)。
  3. 透明的GZIP支持,压缩下载体积。
  4. 支持响应体的缓存,避免重复请求。

除此之外:

  • 支持部分异常情况重试请求,支持HTTP协议的重定向(OkHttp在网络不良的情况下,他能够静默从常见连接问题中恢复,支持多IP自动重试等)。
  • 检验了很多非法的细节处理。
  • 支持DNS 扩展。

http2多路复用机制是怎么回事?

http1的请求存在对头阻塞的问题,因为http请求没有编号,只能顺序的等待响应后,才好发送第二个请求,如果同时发送多个http请求,没有编号的情况下,每个请求处理速度不一样,返回顺序不一样,就不知道响应对应的是哪个请求了,只能顺序等待。

http2对http请求做了报文分片,给每个分片编号了,可以理解tcp层的报文编号机制,在http层又实现了一下,这样就可以同时发送多个http请求,不用串行做http请求了。速度当然提升了。

一个OkHttp请求和响应的的过程?

Okhttp的整个请求与响应的流程就是Dispatcher不断从Request Queue里取出请求(Call),根据是否已经存存缓存,从内存缓存或者服务器获取请求的数据。

请求分为同步和异步两种:

  1. 同步请求通过 调用Call.exectute()方法直接返回当前请求的Response
  2. 异步请求调用Call.enqueue()方法将请求(AsyncCall)添加到请求队列中去,并通过回调(Callback)获取服务器返回的结果。

请求用一个RealCall对象表示。

同步请求

调用client.dispatcher().executed(this);做请求,把RealCall对象添加到Dispatcher的runningSyncCalls双端队列里去

调用getResponseWithInterceptorChain();,内部调用chain.proceed(originalRequest);获取响应结果

异步请求 比同步请求多一个Callback

请求会调用client.dispatcher().enqueue(new AsyncCall(responseCallback));

创建一个AsyncCall封装一下Callback对象,然后放入Dispatcher的任务队列,分两种情况:

如果正在运行的异步请求不超过64,而且同一个host下的异步请求不得超过5个则将请求添加到正在运行的同步请求队列中runningAsyncCalls并开始 执行请求,否则就添加到readyAsyncCalls继续等待。

线程池执行的时候会调用AsyncCall的run(),再执行execute(),再执行getResponseWithInterceptorChain()。

getResponseWithInterceptorChain()做了什么?

通过责任链,先执行自定义的拦截器,再执行OkHttp内部的各个拦截器,包括重定向、缓存、连接、获取响应。

Dispatcher机制

Dispatcher是一个任务调度器,它内部维护了三个双端队列:

  1. readyAsyncCalls:准备运行的异步请求
  2. runningAsyncCalls:正在运行的异步请求
  3. runningSyncCalls:正在运行的同步请求

同步请求:直接把请求添加到正在运行的同步请求队列runningSyncCalls中

异步请求会做个判断:

如果正在运行的异步请求不超过64,而且同一个host下的异步请求不得超过5个则将请求添加到正在运行的同步请求队列中runningAsyncCalls并开始 执行请求,否则就添加到readyAsyncCalls继续等待。

为什么队列都用ArrayDeque,不用LinkedList?

ArrayDeque对局部性原理友好,数组存储的数据在物理内存上是连续的

LinkedList在物理内存上是不连续的

Dispatcher中的线程池是什么?

new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp Dispatcher", false));

特点:

  1. 无核心线程,创建的线程会保活60秒
  2. 只要线程池提交了任务,就立刻执行
    1. 有空闲存活的线程就用空闲线程执行
    2. 没有就创建新的线程执行,执行完任务后等待60秒再销毁

为什么异步请求要限制一个数量?

异步请求每个请求都会开一个线程,创建一个线程是要消耗内存的,虚拟机栈和本地方法栈会占用1到2MB,创建过多的线程可能会引发内存溢出。所以多余的请求应该放到等待队列里。

为什么是64个?

参考:
https://github.com/square/okhttp/issues/4354

为什么要限制同一个host下的异步请求数量不得超过5个?

防止其他host下的请求得不到响应,让每个host都有机会获得执行

重定向与重试

Okhttp对重定向和重新请求这块是怎么优化的?

RetryAndFollowUpInterceptor里处理请求时,用一个无限循环来执行请求。

请求失败,符合重新请求的请求,就会重新执行请求,最多重新请求20次。

  1. 当抛出RouteException或者发生IOException时,拦截器会根据用户的设置和异常分析,决定当前请求是否可以重连
  2. 当发送网络请求完成并获取到Response后,对响应码进行判断是否需要身份验证、重定向、是否超时,如果需要就构建新的Request重新发起请求

RetryAndFollowUpInterceptor优势:

对各种通用细节情况做了处理,还是比较方便的,直接拿来用还是比较好的

比如:

  1. 抛异常对连接池做了释放
  2. 重定向,发现Location头部指定的值不是http协议,就不支持重定向

这里有两种需要重新请求的情况

  1. 网络请求失败抛出了异常,有些异常不是致命的,比如SocketTimeoutException这类的超时,是可以重试的
  2. 网络请求完成了,但并不说明这个请求是成功的,需要通过followUpRequest方法对响应码进行判断,是否需要进行身份验证或者重定向,如果需要就构建新的Request,如下:
    1. 407/401:未进行身份认证,需要对请求头进行处理后再发起新的请求
    2. 408:客户端请求超时,如果 Request 的请求体没有被 UnrepeatableRequestBody 标记,会继续发起新的请求
    3. 308/307/303/302/301/300:需要进行重定向,发起新的请求

抛出异常,则检测连接是否还可以继续,以下情况不会重试:

  1. OkHttpClient里配置请求出错不可以再重试
  2. 出错后,request body不能再次发送(request的body是UnrepeatableRequestBody的子类)
  3. 发生以下致命异常也无法恢复连接:
    1. ProtocolException:协议异常
    2. InterruptedIOException:中断异常
    3. SSLHandshakeException:SSL握手异常
    4. SSLPeerUnverifiedException:SSL握手未授权异常
  4. 没有更多线路可以选择(streamAllocation.hasMoreRoutes()为false)

根据响应码处理请求,返回Request不为空时则进行重定向处理,重定向的次数不能超过20次。

比如401需要身份认证,调用配置的验证器重新创建一个请求。

RouteException是怎么抛出来的?

该异常最终是由RealConnection.connect和StreamAllocation.newStream这两个方法抛出的,而newStream方法又是由ConnectInterceptor的intercept方法内部调用的(newStream方法最终会调用connect方法);connect()方法是与服务器建立连接,newStream()是获取流,所以在连接拦截器中抛出也是正常的。

要注意抛出这个异常意味着请求还没有发出去,只在连接阶段出问题了,就是打开Socket失败了,比如连接超时抛出的 SocketTimeoutException,包裹在 RouteException 中。

RouteException 是 OkHttp 自定义的异常,是一个包裹类,包裹住了建连失败中发生的各种 Exception

GZIP这块是怎么支持的?

BridgeInterceptor里支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
boolean transparentGzip = false;
if (userRequest.header("Accept-Encoding") == null && userRequest.header("Range") == null) {
transparentGzip = true;
requestBuilder.header("Accept-Encoding", "gzip");
}

//判断服务器是否支持gzip压缩,如果支持,则将压缩提交给Okio库来处理
if (transparentGzip
&& "gzip".equalsIgnoreCase(networkResponse.header("Content-Encoding"))
&& HttpHeaders.hasBody(networkResponse)) {
GzipSource responseBody = new GzipSource(networkResponse.body().source());
Headers strippedHeaders = networkResponse.headers().newBuilder()
.removeAll("Content-Encoding")
.removeAll("Content-Length")
.build();
responseBuilder.headers(strippedHeaders);
responseBuilder.body(new RealResponseBody(strippedHeaders, Okio.buffer(responseBody)));
}

缓存

OKHttp对缓存这一块有什么处理和优化?

通过CacheInterceptor来支持,对http的缓存机制做出实现,按照http协议的RFC文档标准来实现。

HTTP的缓存可以分为两种:

  • 强制缓存:需要服务端参与判断是否继续使用缓存,当客户端第一次请求数据是,服务端返回了缓存的过期时间(Expires与Cache-Control),没有过期就可以继续使用缓存,否则则不适用,无需再向服务端询问。
  • 对比缓存:需要服务端参与判断是否继续使用缓存,当客户端第一次请求数据时,服务端会将缓存标识(Last-Modified/If-Modified-Since与Etag/If-None-Match)与数据一起返回给客户端,客户端将两者都备份到缓存中 ,再次请求数据时,客户端将上次备份的缓存 标识发送给服务端,服务端根据缓存标识进行判断,如果返回304,则表示通知客户端可以继续使用缓存。

强制缓存优先于对比缓存。

上面提到强制缓存使用的的两个标识:

  • Expires:Expires的值为服务端返回的到期时间,即下一次请求时,请求时间小于服务端返回的到期时间,直接使用缓存数据。到期时间是服务端生成的,客户端和服务端的时间可能有误差。
  • Cache-Control:Expires有个时间校验的问题,所有HTTP1.1采用Cache-Control替代Expires。

Cache-Control的取值有以下几种:

  • private: 客户端可以缓存。
  • public: 客户端和代理服务器都可缓存。
  • max-age=xxx: 缓存的内容将在 xxx 秒后失效
  • no-cache: 需要使用对比缓存来验证缓存数据。
  • no-store: 所有内容都不会缓存,强制缓存,对比缓存都不会触发。

底层缓存机制是LRU缓存,key是请求中url的md5,value是文件中查询到的缓存

Okhttp的缓存策略就是根据上述流程图实现的,具体的实现类是CacheStrategy,CacheStrategy的构造函数里有两个参数:

1
2
3
4
CacheStrategy(Request networkRequest, Response cacheResponse) {  
this.networkRequest = networkRequest;
this.cacheResponse = cacheResponse;
}

这两个参数参数的含义如下:

  1. networkRequest:网络请求。
  2. cacheResponse:缓存响应,基于DiskLruCache实现的文件缓存,可以是请求中url的md5,value是文件中查询到的缓存,这个我们下面会说。

CacheStrategy就是利用这两个参数生成最终的策略,有点像map操作,将networkRequest与cacheResponse这两个值输入,处理之后再将这两个值输出,们的组合结果如下所示:

  1. 如果networkRequest为null,cacheResponse为null:only-if-cached(表明不进行网络请求,且缓存不存在或者过期,一定会返回503错误)。
  2. 如果networkRequest为null,cacheResponse为non-null:不进行网络请求,而且缓存可以使用,直接返回缓存,不用请求网络。
  3. 如果networkRequest为non-null,cacheResponse为null:需要进行网络请求,而且缓存不存在或者过期,直接访问网络。
  4. 如果networkRequest为non-null,cacheResponse为non-null:Header中含有ETag/Last-Modified标签,需要在条件请求下使用,还是需要访问网络。

连接池

OKHttp对TCP连接这块有什么优化?具体怎么实现的?

连接池复用

每次网络请求都会创建一个StreamAllocation,代表一个请求。

会先去ConnectionPool连接池寻找有没有可用的Connection,有就直接用

怎么判断一个Connection可不可以用?

连接里没有正在运行的请求,也就是看RealConnection里有没有StreamAllocation对象

没有就创建一个RealConnection,然后调用connect方法,内部会通过SSLSocketFactory创建一个Socket,调用Socket的connect方法进行TCP+TLS握手。然后把RealConnection放入连接池。

在RealConnection里有个StreamAllocation虚引用列表,每创建一个StreamAllocation,就会把它添加进该列表中,如果留关闭以后就将StreamAllocation 对象从该列表中移除,正是利用利用这种引用计数的方式判定一个连接是否为空闲连接,

连接池里的连接什么时候清理?

在第一次往ConnectionPool添加Connection的时候,就用线程池启动一个线程执行一个清理任务,任务是无限循环的。

规定的是连接池最多保 持5个地址的连接能够keep-alive,每个keep-alive时长为5分钟,如果发现当前空闲的连接数大于5个,就会清理掉空闲的一个连接。

每个Connection连接对象里会存一个StreamAllocation的弱引用,通过判断这个引用的个数来判断当前连接是否有正在执行的请求,http1协议下一个连接只有一个请求,http2中一个连接有多个请求。

如果一个Connection连接对象里没有StreamAllocation的引用了,就会记录一个空闲的时间戳,在清理连接的线程中会判断Connection的空闲时间是否超过最大空闲时间,超过了就回收,没超过计算一个要等待的时间,然后通过synchronize加锁,再调用锁对象的wait()方法等待一段时间。

如果不存在空闲连接就结束清理线程,等到有新的连接放入连接池,再启动清理线程。

Okhttp底层如何建立连接的?

通过Socket的connect()方法做TCP和TLS握手。

通过Okio的Source和Sink对Socket的输入输出流做代理。

发送请求就是往Socket的outputStream写入数据,读响应结果就是从Socket的inputStream读入数据

Okio自己实现的缓存共享机制,可以避免在读输入流然后写输出流时的一次中间拷贝,因为常规读入输入流会读到一个字节数组里,再从字节数组写入到输出流。

OkHttp对DNS这块有什么优化吗?

提供DNS接口,可以自定义域名解析。

默认用InetAddress.getAllByName()做域名解析。

参考资料

B和B+里的B是什么意思?

B是Balanced的缩写,平衡的意思。

全称可以叫多路(多叉)平衡查找(搜索)树。

多路平衡查找树解决了什么问题?平衡二叉查找树为什么不能解决?

由于平衡二叉查找树只有两个分叉,查询叶子结点需要访问$log_2{n}$次节点,即树的高度。

如果要减少查找次数,就要让树变矮一点。

那么一个节点有多个分叉,同时让一个节点存储多个值,就可以降低树的高度,进而减少节点平均的访问次数。

减少节点的访问次数有什么好处?

访问节点的成本可能非常大,减少访问节点的次数,就可以降低总的访问成本。

例如访问IO比访问内存要慢的多,文件系统中普遍采用多路平衡查找树作为存储数据的结构。

B树与B+的区别?

B树与B+共同点:

  1. 都是一个节点按顺序存储多个值
  2. 每个节点可以有多个分叉

B树独有:

  1. 非叶子节点存储了数据,非叶子节点占用空间更大

B+树独有:

  1. 非叶子节点不存数据只存索引信息,数据全部在叶子节点,非叶子节点占用空间更小
  2. 叶子结点用双向链表相连,便于顺序查找

B+树作为数据库索引有什么优势?

非叶子结点的大小可以设置为一页,内存从外存读取数据是按页读取的,这样就减少了IO访问次数。

结点内部是有序的,可以再用二分查找去查找元素。

B+树的非叶子结点不存储数据,只存键,这样同样空间大小可以存的键就更多,非叶子结点的数量就会减少,IO访问次数也就变少了。

B+树的叶子结点用双链表链接,这样对区间查询友好,只需要通过非叶结点查找到区间范围,然后顺序遍历即可,可以减少对非叶结点的访问,进而减少IO访问次数。因为由于虚拟内存机制,非叶结点加载到内存后,可能也会被置换到外存,减少对非叶结点的访问次数,也就降低了置换次数,置换是需要IO访问的。

参考资料

为什么要用依赖注入?

让每个类保持职责单一。

好处:

  • 方便mock测试,减少排错和调试成本。
  • 类职责单一后,代码复用性非常强,减少改动成本。
  • 方便单元测试、自动化测试,促进项目健壮性。

Dagger2的优势?

  • 无反射,编译时生成注入代码,无运行时性能损耗。
  • 符合jsr-330依赖注入标准,切换成本低。

其他Java下的依赖注入框架,比如Dagger1、Guice,是通过反射在运行时对要注入的对象进行赋值,反射在运行时是比较耗时的,Dagger2没有反射,在编译时进行生成辅助代码,利用辅助代码做对象赋值,运行时性能没有消耗。

Dagger2按照jsr-330依赖注入标准要求来设计,比如支持@Inject、@Qualifier、@Scope等jsr-330中规定的注解,以后替换为其他符合jsr-330标准的依赖注入框架时切换成本小。

Dagger2基本运行流程

  • 开发者把要注入的对象写在Module对象中,Module对象挂载在各种Component下,都用注解来配置。
  • 要注入的对象的生命周期受Component对象的生命周期控制,Component对象什么时候创建、什么时候丢弃是完全自行控制的。
  • 编译时通过解析注解处理器,生成注入相关的代码。
  • 在Activity、Fragment中通过调用Component的inject()方法,给所有标注为@Inject的属性赋值。
    • 也可以通过Component直接get到对象。
    • inject()后,所有依赖路径上的对象都会被创建。

作用域Scope

Scope可以在三个地方使用:

  • Component类上标记。
  • Module中providerXXX方法。
  • Dagger2要创建的类上标记。

作用域的唯一的实际作用,是让Component里的对象创建过后就缓存在Component对象里。

所有作用域是完全跟Component对象的生命周期走的。

比如,有一个UserScope,表示用户登陆到用户注销期间的作用域范围。

  • 在Dagger2中,UserScope是要标记到Component上去的,可以把所有用户登陆期间需要的对象都挂载到一个UserComponent上去。
  • 用户登陆时创建一个UserComponent对象并保存,用户注销时丢弃保存的UserComponent对象。
  • 在Module中标记为UserScope的对象,通过UserComponent去获取的时候都是同一个。

作用域如何嵌套?

比如用户作用域内,可能通过蓝牙绑定智能设备到App,会存在设备相关的状态,就定义一个DeviceComponent代表设备相关的状态对象。

  • 在设备绑定时创建DeviceComponent对象并保存,在设备解绑时丢弃DeviceComponent对象。
  • 再次绑定设备时,创建新的DeviceComponent对象示例,这样挂载在DeviceComponent的对象就都重新生成了,设备的状态对象都会更新。
  • 设备绑定和解绑的动作一定是发生在用户登陆期间的,所以DeviceComponent和UserComponent并无代码上的直接关联,就可以实现概念上的作用域嵌套。

SubComponent解决了什么?

有了Component,怎么还搞一个SubComponent?

顾名思义,就是建立Component的层级关系,而建立层级关系是为了读取共享的对象。

比如:

  • 用户登陆生命周期对应UserComponent。
  • 用户绑定手环手表的生命周期对应DeviceComponent。
  • 用户在App开启设备的跑步到停止跑步的生命周期对应RunningComponent。
  • 用户在App开启设备的心率检测到结束心率检测的生命周期对应HeartRateComponent。

这里的层级关系从功能上看比较明显:

  • 用户绑定了设备才能启用跑步和心率检测的功能,所以:
    • HeartRateComponent和RunningComponent平级。
    • DeviceComponent是RunningComponent和HeartRateComponent的父Component。
    • RunningComponent和HeartRateComponent中的对象想要读取设备信息,应该都是读取DeviceComponent中同一份设备信息,这就催生了共享的需求。
  • 不同用户之间设备信息是不一样的,所以:
    • UserComponent是DeviceComponent的父Component。
    • DeviceComponent中的对象需要读取用户信息,读取的都是UserComponent下的对象状态。
    • RunningComponent和HeartRateComponent中的对象要读取用户相关的状态,也要读取UserComponent挂载的对象。
    • 退出登录换一个账号登录后,DeviceComponent里的对象内容应该也要跟着变,因为不同的用户绑定的设备不一样。

如果没有Component的层级关系,DeviceComponent和RunningComponent中的对象要读取设备和用户相关的状态都要写重复的代码,Component层级关系消除了冗余的重复。

如何异步注入?

官方提供Producers机制,理解起来比较困难,其实更方便的是,通过Dagger2获取一个创建对象的函数,自己在代码里异步控制什么时候创建对象。

多绑定解决了什么?

多绑定的意思是把要注入的对象信息都存储到一个集合里,比如Set或Map。

把要注入的对象存储到集合有什么好处?

可以实现一种插件化的架构。
把对象收集到一个中心,再做对象注入的时候,不需要依赖某个具体的Component或者Module对象,而是直接去中心里的集合中查表就行了。

比如dagger.android中:

  • 每个Activity和Fragment都是单独的生命周期,都是单独的SubComponent,因为它们可能会读取共享的父Component的里的状态值。
  • 可以把这些SubComponent挂载的对象通过多绑定的注解标记,存入一个Map中,Key就是Activity或者Fragment类名。
  • 在Activity和Fragment的onCreate中,就可以只调用AndroidInjection.inject(this)
  • Dagger2内部就可以到Map中通过类名的Key查询到对应的Activity或者Fragment对应的SubComponent。
  • 然后创建Component对象,做实际的对象创建和注入。

这样就简化了注入处的代码。

参考:

Dagger2存在什么问题?

  1. 学习和科普成本极高。
  2. APT生成的辅助代码增加编译时间。
  3. APT生成的辅助代码增加了Apk体积。

dagger2中各种基本情况组合起来非常复杂,光注解就非常繁多,比如@Inject、@Component、@Subcomponent、@MultiBinding、@Binds、@ContributesAndroidInjector,要在大型团队中推广,必须要保证至少有一个人能完全hold住所有Dagger2相关的问题,否则就是灾难。

这位负责人还要尽量文档化,负责科普宣讲,减少认知和沟通成本。