本文转载自 xiao_nian 的Android面试整理
本文转载自 xiao_nian 的Android面试整理
本文转载自 xiao_nian 的Android面试整理
本文转载自 xiao_nian 的Android面试整理
最近在考虑换个工作,故整理一些面试中经常会问到的点,也是Android中比较重要的知识点,主要针对中高级面试。
本文转载自 xiao_nian 的Android面试整理
我觉得一个好的App应用首先要设计合理,能够让用户流畅方便的使用,应用的设计属于产品的工作,但是如果产品提出的需求不合理,我们一定要提出质疑。其次,一个好的应用应该具备体验流畅、界面灵动、崩溃率尽可能少、Apk尽可能小等特点,另外,现在的应用一般都需要进行网络请求,这也要求我们有一款好的网络请求框架。在我的理解中,开发出一款好的App需要具备技能:
1、熟练掌握网络请求
2、熟练掌握多线程开发以及线程切换
3、熟练掌握Android的控件体系
4、熟练掌握Android的绘制流程
5、熟练掌握Android的事件分发
6、熟练掌握Android的动画体系
7、熟练掌握Android的垃圾回收机制以及内存泄露
8、熟练掌握Android的四大组件
9、熟练掌握Android的常用框架(比如Retrofit、RxJava、EventBus、GreenDao、Glide等)
10、了解面向对象设计的思想(封装、继承、多态以及六大设计原则)以及常用的设计模式
11、了解Android插件化技术
12、了解Android apk的编译流程
13、了解Android App常用的安全技术
14、了解WebView的优化(内存、白屏、卡顿等),了解App与H5的交互
15、了解Https的安全机制
16、了解应用进程是怎样创建的
17、了解混合开发(Android+H5+RN+Flutter等,重要)
18、了解Android安全机制
19、了解JVM,以及Dalvik、Art虚拟机,以及Java虚拟机和Android虚拟机的主要区别
推荐书籍:Android艺术开发探索、深入理解Android、Android源码设计模式解析与实战、深入理解Java虚拟机
别人总结的面试基础:https://github.com/LRH1993/android_interview
一个不错的Android网址:https://www.wanandroid.com
我的博客:https://blog.csdn.net/xiao_nian
本文转载自 xiao_nian 的Android面试整理
为什么网络请求如此重要呢?这是因为现在的App一般都需要和后端进行数据交换,我们现在一般通过网络请求框架来处理网络请求,比如OkHttp、HttpURLConnection、Volley、Retrofit等,那么网络请求框架的原理是什么呢?如果没有了网络请求框架,我们又应该怎么去处理网络请求呢?
首先,我们来看一下网络分层的概念,网络分层可以分为5层,从上到下依次是:
- 应用层
- 传输层
- 网络层
- 数据链路层
- 物理层
其中,软件开发者需要重点关注的是传输层和应用层,传输层的作用是用来传输数据的,分为TCP协议和UDP协议,TCP协议是可靠的面向连接的协议,其传输数据会经过三次握手和四次挥手,并会校验数据传输的正确性和完整性,所以其是可靠的协议,这就好比打电话,只有电话通了双方才能进行通信;而UDP协议是不可靠的无连接的协议,它不需要通信双方建立连接,而是直接发送数据出去,至于数据是否可达就不知道了,所以其不能确保数据的完整性和正确性,这就好比写信,我们将信交给邮局后就不管了,至于邮局是否能够正确的将信交给对方就不知道了,由于UDP协议不需要建立连接并确保数据的完整性和正确性,其传输效率是要高于TCP协议的,UDP一般用来处理一些对通信实时性要求较高的但是完整性要求不高的场景,比如多人视频会议、实时对战游戏等。
TCP协议三次握手和四次挥手
TCP协议通过三次握手来确保通信双方都建立了传输数据的通道,而通过四次挥手来确保通信双方都断开了连接。
三次握手过程:
1、客户端发送连接SYN包(SYN=1,ACK=0,表示这个一个TCP连接请求报文)到服务端并等待服务端确认,客户端进入SYN_SENT状态;
2、服务端收到请求包后,确认客户端发送的包,同时自己也发送一个SYN包,即SYN+ACK包(SYN=1,ACK=1,表示这是一个TCP连接响应数据报文),此时服务器进入SYN_RECV状态;
3、客户端收到服务器的SYN+ACK包,向服务器发送一个序列号(seq=x+1),确认号为ack(客户端)=y+1,此包发送完毕,客户端和服务器进入ESTAB_LISHED(TCP连接成功)状态,完成三次握手。
为什么要进行三次握手呢?最后一次握手的目的主要是为了避免服务端收到过期的请求建立连接造成资源浪费,试想一下,客户端发送一个建立连接请求,但是这个请求由于网络阻塞阻塞在了某个网络节点中,客户端等待一段时间后没有收到服务端的确认连接报文就会认为请求失效了,将再次发送请求报文,这时网络通畅了,请求报文顺利的送到了服务端,之后建立了TCP连接并开始通信,之前阻塞在网络节点中的建立TCP连接请求也发送到了服务端,服务端不知道这个是失效的请求,回复确认报文后建立连接,客户端收到回复报文后知道这是一个过期的报文,将其丢弃,但是服务端却不知道客户端已经丢弃了这次连接,还在等待客户端发送数据,这样就造成了资源浪费,所以第三次握手的目的是为了让服务端知道客户端收到了服务端的确认报文,这样双方才正式开辟资源建立连接,准备传输数据。
四次挥手:
1、客户端向服务端发送请求断开连接报文,告知服务端客户端没有数据要传输了,并等待服务端回应;
2、服务端收到客户端断开连接报文后,解析报文信息并回复确认断开连接报文,告知客户端我知道你没有数据要传输了,服务端进入close_wait状态;
3、服务端继续向客户端发送数据直到没有数据发送了,服务端发送一个断开连接报文,通知客户端我也没有数据要发送了;
4、客户端在收到服务端的断开连接请求后回复服务端确认断开连接报文,表示我收到了你要断开连接的请求,服务端在收到确认报文后就可以真正关闭连接了,并回收资源,客户端在等待2MSL后如果没有收到服务端发送的数据就知道服务端已经断开连接了,于是客户端也关闭连接并回收资源。
为什么要四次挥手?
这是因为TCP连接是全双工的,通信双方都能够发送数据和接收数据,其中一端数据传输完了,另外一端数据可能还有数据需要传输,所以两端都需要发送断开连接报文,并确认对方收到了断开连接报文。
为什么被动关闭一方需要等待2MSL?
首先,我们要知道MSL的概念,MSL表示网络报文在网络中最长的逗留时间,如果超过了这个时间的报文将会被丢弃,等待2MSL的原因是为了确保服务端收到了确认断开连接报文,服务端已经正确的关闭了,考虑这样一种情况,如果客户端发送给服务端的确认断开连接的报文由于网络原因没有传递给服务端,这时服务端在等待一段时间后没有收到回复报文,就会继续发送请求断开连接报文,而这时如果客户端没有等待2MSL,而是直接关闭将无法收到服务端的报文,于是服务端就一直发送断开连接报文,这就造成了服务端资源浪费,等待2MSL的才关闭客户端的意义就是为了确保服务端收到确认断开连接报文。
那么开发者要如何来实现传输层的通信呢?一般的操作系统或语言都提供相应的API来帮助开发者实现传输层的通信,比如Android提供了Socket来给开发者完成传输层的通信,通过Socket我们能够轻松的实现基于TCP或UDP协议的通信。
上面介绍了传输层的作用是传输数据的,那么应用层的作用是什么呢?应用层的作用是规定通信双方传输数据的格式,以便通信双方都能够按照规定的格式解读数据。也就是说,应用层只是用来规定传输数据的格式,而真正数据传输是传输层处理的,Android开发中最常见的网络请求协议是HTTP协议,HTTP协议就是属于应用层的协议,基于Http协议的请求必须要严格按照HTTP协议来组织数据,然后交由给传输层发送。为了保证数据的可靠性,Http协议基于传输层的TCP协议。我们来看一下Http协议规定的传输数据的格式:
Http请求数据的格式:
请求方法 请求URL HTTP协议版本
请求头键值对
...
请求头键值对
请求正文
Http响应数据的格式:
响应状态码 Http协议版本
响应头键值对
...
响应头键值对
响应正文
上面是Http协议规定的请求数据格式和响应数据格式,所有的基于Http协议的请求都必须严格按照这种格式组织数据,这样通信双方才能解读数据,说到这里,我们就能够知道一个网络请求框架的核心原理了:
1、提供相应的Api给开发者传入请求数据,比如请求方法、请求的URL、请求参数等;
2、严格遵循Http规定的请求数据格式组织开发者传入的参数;
3、通过Socket建立通信双方的TCP连接;
5、通过TCP连接将组装的请求数据发送给服务端;
6、等待服务端返回数据;
7、通过TCP连接接收响应数据;
8、严格遵循HTTP规定的响应数据格式解读响应数据;
9、提供相应的接口给开发者获得响应数据(一般是通过回调)
以上是一个网络请求框架必须具备的核心功能,我们常用的OkHttp、HttpURLConnection等框架内部都实现了以上过程,同时,一个好的网络请求框架还需要注意一下几点:
1、提供给开发者的Api尽可能简单;
2、能够处理网络缓存,避免不必要的请求;
3、支持并发请求,能够同时处理多个网络请求;
4、能够复用TCP连接以提高请求效率;
5、具有相应的压缩策略,减少数据的传输量;
6、支持异步请求,这是为了避免阻塞发起网络请求的线程;
7、具有重试机制,提高网络请求成功率;
8、具有良好的可扩展性。
WebSocket
为什么需要WebSocket呢?WebSocket主要是为了解决服务端需要实时向客户端推送数据的问题,Http协议采用请求响应模式的,只有当客户端发送请求后,服务端才响应请求发送数据给客户端,考虑一下,如果采用传统的Http协议,那么客户端就需要轮询向服务端请求数据,如果对数据的实时性要求较高,轮询的时间间隔还不能太小,这就会频繁的建立Http连接,消耗性能和流量。
轮询又分为短轮询和长轮询:
短轮询:客户端每隔一段时间就向服务端请求一次数据
长轮询:客户端向服务端发送一次Http请求,然后服务端如果有新的数据则直接返回数据,如果没有新的数据则会一直保持连接,直到有新的数据才响应请求,客户端收到请求后再次发送Http请求,这种方式相对于短轮询可以减少Http请求的次数,并且实时性也较高。缺点是服务器没有数据到达时,http连接会停留一段时间,造成服务器资源浪费。
首先WebSocket属于应用层的协议,它也是基于TCP协议的,Http协议之所以不能实现服务端实时向客户端推送数据,主要原因是Http协议是基于请求响应模式的,只有当客户端发起请求后,服务端才会响应请求,其实Http协议底层传输数据是基于TCP协议的,TCP协议是支持全双工通信的,也就是说,服务端也可以实时向客户端发送数据,只是Http协议请求响应模式的规定,才无法做到实时推送,下面看一下WebSocket连接的过程:
1、发起Http请求,并在Http请求头中添加信息告知服务器要升级协议为WebSocket协议:
Upgrade: websocket // 表示升级采用webSocket协议
Connection: Upgrade // 表示要升级或切换协议
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
2、服务端响应Http请求,表示同意客户端协议转换请求,并将它转换为websocket协议
以上过程都是利用http通信完成的,称之为websocket协议握手(websocket Protocol handshake),经过这次握手之后,客户端和服务端就建立了websocket连接,以后的通信走的都是websocket协议了。所以总结为websocket握手需要借助于http协议,建立连接后通信过程使用websocket协议。同时需要了解的是,该websocket连接还是基于我们刚才发起http连接的那个TCP连接。一旦建立连接之后,我们就可以进行数据传输了,我们知道TCP连接是支持全双工通信的,客户端和服务端是对等的,可以随时向对方发送数据。
总结一下:WebSocket通信首先采用Http协议建立连接,并且告知服务端需要将Http协议升级为WebSocket协议,这样,即获得了能够让通信双方传输数据的TCP连接,又不会采用Http协议的请求/响应模式,之后的通信就是按照WebSocket协议规定的格式组织数据,并通过TCP连接让通信双方进行通信。
既然已经有了OkHttp、HttpUrlConnection等框架处理网络请求,为什么又会出现Volley、Retrofit等框架呢?这是因为基础的网络请求框架提供给开发者使用的Api还不够友好,开发者在使用基础的网络请求框架时不仅使用复杂,还需要关注线程的切换,这里说一句,Android的线程分为主线程和子线程,主线程也就是常说的UI线程,我们所有的和UI相关的操作都必须在UI线程中处理,同时,对于一些耗时的操作,比如网络请求,我们不能在UI线程中处理,这是因为UI线程需要处理所有的UI相关的操作,比如界面绘制、响应点击事件等,一旦我们阻塞了UI线程,不仅会造成界面卡顿,严重的还会导致ANR,一般的,Android提供了Handler来处理线程的切换,Handler的原理我们后面再来分析。
由于以上原因,其他的相对于开发者更加友好的框架不断的发布.
OkHttp和Volley对比:OkHttp也支持异步请求,并且其底层使用到了线程池发送请求,但是刷新UI需要自行处理线程切换;Volley与其相比,API接口封装的更好,并且处理了线程切换的问题,而一般使用OkHttp处理网络请求,我们需要再度封装来简化网络请求,当然,Volley也有自身的问题,Volley主要是用来处理频繁的、数据量小的请求,对于大文件的下载等其性能不如OkHttp。
Volley为什么不适合处理数据量大的请求呢?
1、Volley内部默认有一个处理缓存的线程和四个网络请求线程,当请求很多时,会在请求队列里面排队,网络请求线程挨个处理,但是如果请求的数据量很大时,就会造成网络请求线程阻塞(一直在处理数据量大的请求),这时,如果有多个大数据量的请求,就会造成Volley所有网络请求线程都阻塞,其他的请求将会等待很久才会执行。
2、Volley会将上传数据和返回数据全部读取到内存中,如果上传或者返回的数据量太大,将会占用大量的内存
网络优化的方式
常用工具:
1、Android studio提供的NetWork Monitor实时观察网络数据请求情况(可以监测App网络请求数据的大小、时间等);
2、使用Charles、Fiddler等抓包数据实时分析网络请求数据;
3、Stetho是Facebook出品的一个Android应用的调试工具。无需Root即可通过Chrome,在Chrome Developer Tools中可视化查看应用布局,网络请求,sqlite,preference等。同样集成了Stetho之后也可以很方便的查看网络请求的各种情况。
App网络优化要从三个方面入手:速度、流量、成功率
首先,我们要了解一次网络请求发生了什么,以Http协议为例:
1、客户端查找查找本地DNS解析器缓存是否有该网址对应的Ip,如果有则直接使用,如果没有则向DNS服务器请求查找该网址对应的Ip,获得Ip后存入本地缓存;
2、客户端和服务端通过三次握手建立TCP连接;
3、客户端按照Http协议规定的格式组织请求数据,包括请求行、请求头、请求正文等;
4、客户端通过TCP连接发送数据给服务端并等待服务端响应;
5、服务端接收数据并进行相应的处理并按照Http协议规定的格式组织返回数据,包括响应状态行、响应头、响应正文等;
6、服务端通过TCP连接将数据发送给客户端,如果在请求头里没有指定使用keep-alive,则TCP连接断开,请求完成;
7、客户端通过TCP连接获得数据并按照Http协议规定格式解读数据,进行相应处理。
通过网络请求的流程,我们可以从以下方面对网络请求进行优化:
1、DNS
我们知道,首次进行网络请求时需要请求DNS服务器获得网址对应的Ip,我们可以不用域名,采用IP直连的方式,这样可以省去DNS解析的过程;
2、复用TCP连接
我们知道,数据其实通过TCP连接传输的,而TCP连接的建立需要经过三次握手,这是比较消耗时间的,我们知道Http支持keep-alive机制,可以让服务端和客户端通信结束后保持连接指定时间,这就给复用TCP连接提供了思路,
OkHttp就采用了复用TCP连接,其主要思路是:
a、连接的复用:
将建立的连接放入连接池中,下次进行网络请求时首先在连接池中查找符合条件的连接,主要是判断请求地址是否符合,如果查找到了则直接使用此连接,如果没有查找到则创建连接并将其放入连接池中。
b、连接的回收
连接池的大小是有限的,并且缓存连接也需要消耗内存空间,对于长时间不使用的连接,我们需要将其回收以便放入其他连接,OkHttp3通过每个连接的引用计数对象StreamAllocation的计数来判断是否是空闲的连接,然后将空闲的连接按照空闲时间进行排序,找到空闲时间最长的连接,将其回收,向连接池添加新的连接时会触发执行清理空闲连接的任务。清理空闲连接的任务通过线程池来执行。
3、减小传输数据量
a、压缩传输数据,我们可以压缩需要传输的数据来减少传输数据量的大小,比如Http协议支持gzip压缩数据;
b、采用更加精简的数据格式协议来组织数据,比如Json格式就要比Xml精简;
c、针对图片,我们要按需请求大小、分辨率合适的图片;
d、采用更小的图片格式,比如使用WebP格式替代jpg、png;
4、采用网络缓存
针对不是经常变化的数据,比如图片,可以采用网络缓存来处理,下次使用时优先从缓存中获取
a、图片缓存
一般针对图片的缓存采用的是三级缓存测量,即下载图片后将图片缓存到内存和磁盘中,当下次再需要该图片时,首先从内存中获取,如果没有则从磁盘中获取,如果还没有找到才进行网络请求;
b、普通请求缓存
用请求信息生成对应的Key值,然后以返回数据作为Value,存入内存或者磁盘中,下次使用时,先判断数据是否过期,如果没有过期则优先使用缓存。
5、减少传输次数
合并多个请求,对于某些请求,我们可以将其合并为一个,比如埋点上报功能,我们可以将数据存储在本地,等到数据量达到一定程度时在上传,这样可以减少传输的次数;
6、重试策略提高网络请求成功率
网络可能出现波动的情况,对于一次网络请求,我们应该添加重试策略来提高请求的成功率
7、使用服务端推送的方式传输实时性要求较高的数据
对于实时性要求较高的数据,我们不应该采用轮询的方式来请求,这样会浪费流程,而且实时性也不能保证,而应该采用服务端推送的方式,比如使用WebSocket协议;
8、并发下载
我们需要支持并发处理请求,这样能够提高传输效率。
对于大文件的请求,我们可以开启多个线程分段下载文件,这样可以提高请求的效率。
9、断点续传
对于较大文件的上传和下载,我们可以记录传输的进度,下次继续传输,这样可以节省网络流量。
断点续传和并发下载的原理
利用Http协议提供的获取文件部分内容的功能来实现,Http提供了Range/Content-Range请求头字段来支持获取文件的部分内容,其中Range应用在请求报文中,Content-Range应用在响应报文中:
Range:bytes=start-end // 表示请求当前内容的start到end字节的内容
Content-Range: bytes start-end/total // 表示当前发送的数据范围是start到end,total表示总文件的大小
断点续传和并发下载还需要解决文件读写的问题,我们需要在文件的任意位置读写文件
RandomAccessFile支持调整到文件的任意位置读写文件,大致用法如下:
RandomAccessFile raf = new RandomAccessFile(file, "rwd");
//跳转到指定位置读写文件
raf.seek(start);
大致实现原理:
1、开启一个子线程,在其中建立Http连接并获得要下载的文件总长度;
2、创建一个文件,文件大小为上步获得的文件总长度;
3、开启多个子线程下载文件内容,设置不同的range范围,获得文件数据并写入文件,写入文件时记得设置读写文件的起始位置;
4、如果暂停文件下载,则记录每个子线程的下载进度,并结束线程;
5、重新开始下载时,开启多个子线程重新下载文件内容,根据上次记录的文件下载进度设置range以及读写文件的起始位置。
10、增量更新
数据更新采用增量的方式,而不是全量,仅将有变化的数据返回,客户端进行合并,减少流量消耗。
11、处理不同的网络状态
针对不同的网络状态,进行不同的处理,比如下载文件,在wifi状态下可以直接下载,而在运营商网络状态下则需要给出提示让用户选择是否下载。
Retrofit
Retrofit推荐参考:https://www.jianshu.com/p/f57b7cdb1c99
Retrofit是一款可以将Java接口转换成具体的网络请求对象的框架,并且其具备良好的可扩展性,比如和RxJava结合使用。其内部通过动态代理、注解等技术来实现,首先说一句,Retroift内部具体处理网络请求的还是OkHttp。
下面我们来看一下Retrofit的基本用法,通过用法来分析其实现原理:
-
public
interface RequestApi {
-
@Get(
"/getInfo")
-
public Call
getInfo(@Query("name") String name) ;
-
}
-
-
Retrofit retrofit =
new Retrofit.Builder()
-
.baseUrl(
"http://www.baidu.com")
-
.addConverterFactory(
new GsonConverterFactory())
-
.addCallAdpterFactory(
new RxJava2CallAdpaterFactory())
-
.build();
// 典型的构造者模式
-
-
RequestApi requestApi = retrofit.create(RequestApi.class);
-
Call
call = requestApi.getInfo(
"xiaoming");
-
-
call.enquene(
new CallBack() {
-
public void onResponse(DataBean dataBean) {
-
-
}
-
-
public void onFailure() {
-
-
}
-
})
-
public T create(Class
class) {
-
return Proxy.newProxyInstance(
class.getClassLoader(), new Class[] {
class}, new InvocationHandler() {
// 根据动态代理生成具体的对象并返回
-
public Object invoke(Object object, Method method, Object[] args) {
-
ServiceMethod serviceMethod = loadServiceMethod(method);
-
OkHttpCall okHttpCall =
new OkHttpCall(serviceMethod, args);
-
return serviceMethod.adpater.adapt(okHttpCall);
-
}
-
});
-
}
-
-
public Request implement RequestApi {
// 这个是Proxy.newProxyInstance通过动态代理生成的类的大致代码,这里只描述了思路,具体实现有差别
-
public Call
getInfo(String name) {
-
Method method = Class.forName(
"packagename.RequestApi").getMethod(
"getInfo", String.class);
// 通过反射拿到接口的对应方法
-
return mInvocationHandler.invoke(
this, method,
new Object[] {name});
// 将对象本身,接口方法,方法参数传给InvocationHandler统一处理
-
}
-
}
动态代理通过Proxy.newProxyInstance接口动态的生成了代理对象,并将方法的处理流程统一的交由给InvocationHandler的invoke方法处理,loadServiceMethod方法主要是根据Mehtod去生成ServiceMethod对象,首先,需要解读Method上的注解,获得注解上配置的请求信息,比如请求方法,URL等,并将其保存在ServiceMethod中,然后需要查找合适的CallAdapter来适配OkHttpCall,以便兼容返回结果。
接着,我们通过ServiceMethod对象和请求参数构造了一个OkHttpCall对象,我们知道ServiceMethod中存储了本次网络请求的信息,然后再加上开发者传入的请求参数args,这就包括一次网络请求所需要的全部信息,也就是说,OkHttpCall中存储了本次网络请求的全部信息,其内部会通过调用OkHttp的接口去处理网络请求,总结一点,我们可以将OkHttpCall看做是一个能够发送网络请求的对象。
最后,我们调用相应的CallAdapter将OkHttpCall对象适配成返回类型的对象,我们在构造Retrofit时添加了适配Call对象的CallAdapterFactory,在构造ServiceMethod时,会去遍历所有的CallAdapterFactory,然后查找能够适配方法返回类型的CallAdapter,这是典型的适配器模式。为什么Retrofit要用适配器模式去OkHttpCall对象呢?主要是为了提升框架的可扩展性,能够让我们的接口兼容任何的返回类型,Retrofit能够和RxJava结合使用的原理也归功于CallAdapter,请看Retrofit和RxJava结合使用的下面代码:
-
public
interface RequestApi {
-
@Get(
"/getInfo")
-
public Observable
getInfo(@Query("name") String name) ;
-
}
-
-
Observable
observable = retrofit.create(RequestApi.class);
-
observable.subscribleOn(Sechedulers.io())
-
.observerOn(AndroidSecheduler.mainThread())
-
.subscrible(
new Observer() {
-
public void onNext(DataBean dataBean) {
-
-
}
-
public void onError(Error e) {
-
-
}
-
});
-
// RxJava2CallAdapter的大致实现
-
public
class RxJava2CallAdapter {
-
public Observable adpat(Call call) {
-
return CallObservable(call);
-
}
-
}
-
-
// 包装了OkHttpCall对象的Observable的大致实现
-
public CallObservable extends Observable {
-
private Call mCall;
// 持有OkHttpCall的引用
-
public CallObservable(Call call) {
-
mCall = call;
-
}
-
-
public void subscibleAutal() {
-
Response resopnse = mCall.execute();
// 发起网络请求
-
mObserver.onNext(response.data());
// 将返回结果发送给Observer处理
-
mObserver.complete();
-
}
-
}
在上面的代码,RxJava2CallAdapter将OkHttpCall对象适配成一个自定义的Observable对象,这样,我们就可以通过Observable对象来订阅观察者了,也就将Retrofit和RxJava结合起来了,这个Observable对象内部持有OkHttpCall对象的引用,在其subscribleAtual方法中,会利用OkHttpCall对象发起网络请求并获得返回数据,然后在将返回数据发送给Observer处理。
从上,我们可以看出Retrofit的设计思路是将Java接口转换成一个能够发起网络请求的OkHttpCall对象,并且通过不同的CallAdapter将OkHttpCall适配成不同的数据类型。Retrofit的设计结构非常清晰,利用接口定义请求信息,通过解读注解来解读接口中定义的请求信息,并通过动态代理将请求方法的处理统一交给InvocationHandler的invoke方法处理;内部通过封装OkHttpCall对象来处理网络请求,OkHttpCall对象会调用OkHttp的接口来发送请求;然后通过适配器将OkHttpCall对象适配成不同的数据类型,以提升其可扩展性;最后再从ConverterFactory列表中找到合适的Converter转换数据。
Retrofit的设计遵循了面向对象的设计原则,比如:
单一职责原则:Retrofit将不同的功能分别让不同的模块去处理,OkHttpCall用来请求数据,ConverterFactory用来解读数据、ServiceMethod用来解读接口、CallAdapterFactory用来适配OkHttpCall,这让框架的结构变得清晰;
依赖倒置原则:Retrofit类之间的依赖尽量是依赖接口的,而没有依赖具体的实现类,比如Retrofit类对ConverterFactory以及CallAdapterFactory的依赖都是依赖接口或者抽象类,这样做是为了方便扩展,提高框架的灵活性,我们可以定制任何形式的ConverterFacotry来转换数据,也可以自定义CallAdapterFactory来适配OkHttpCall;
迪米特原则:不管Retrofit内部的实现何如复杂,其对开发者只提供了必要的Api,这样做的目的一是为了降低开发者的使用难度,同时降低维护成本,暴露出去的接口都是要维护的。
面向对象的六大原则可以参考:面向对象的六大原则
同时,Retrofit内部也使用了大量的设计模式,比如Build模式构造Retrofit对象、动态代理模式处理接口、适配器模式转换OkHttpCall、工厂模式生成CallAdapter和Converter等。
RxJava的核心原理是被观察者(Observable)通过订阅观察者(Observer),然后通过回调将事件发送给观察者的处理。RxJava能够方便的处理切换observer线程和subscrible线程,同时其提供了大量的操作符给开发者使用,比如map、filter、interval、throttleFirst、debounce等,使得RxJava成为了一款功能强大的基于链式调用的异步处理框架。
RxJava和其他框架结合使用,一般其他框架需要提供一个Observable对象,然后我们对这个Observable来做处理,比如上面的Retrofit,就是提供了一个自定义的Observable对象,然后我们再对这个Observable订阅Obserser并指定线程。 下面列举Rxjava的一些应用场景
1、功能防抖
有些时候为了避免按钮被多次点击产生重复事件,我们需要对点击事件做控制,比如,如果一个按钮1秒内点击了两次,我们只算其点击了一次,通过RxJava我们可以轻松的实现以上功能:
-
RxView.clicks(button).throttleFirst(
1, TimeUnit.SECONDS)
-
.subscrible(
new Observer() {
-
public onNext() {
-
// 处理点击事件
-
}
-
-
public onError(Error e) {
-
-
}
-
})
首先,我们通过RxView.clicks(button)接管按钮的点击事件,并生成一个对应的Observable返回,这个Observable的内部实现中,会为button设置点击监听,每次按钮点击后都会发送事件给观察者Observer处理。那是怎样控制Observer一秒内只会接收到一次事件的呢?这主要是通过操作符throttleFirst操作符来控制的,throttleFirst这个操作符用来控制事件的发送频率,它能够在某段时间内,控制只发送第一次事件,比如上面的防抖,如果用户在1秒内多次点击了按钮,observer的onNext方法只会在第一次点击时被调用一次。
2、联想搜索
我们在做搜索功能时,需要根据输入的内容变化实时的展示搜索结果,我们可以监听EditText的文字改变事件,然后在监听方法中发起网络请求,展示结果,但是这样会造成网络请求的浪费,比如,用户想搜索的内容为“abc”,那么在快速的输入的过程中,EditText就会产生3次文字改变事件,改变的文字内容分别是"a", "ab", "abc",那么就会发起三次网络请求进行搜索。这显然是不合理的,为了避免出现这种情况,我们可以监听EditText的文字改变,只有一段时间内EditText的文字没有再次改变时,我们才认为这段文字是用户想搜索的内容,才进行网络请求。我们可以利用RxJava提供的debounce操作符轻松的实现这个功能:
-
RxTextView.textChanges(edittext).debounce(
1, TimeUnit.SECONDS)
-
.subscrible(
new Observer
() {
-
public onNext(CharSequence s) {
-
// 发起网络请求
-
}
-
-
public onError() {
-
-
}
-
})
debounce操作符的作用是如果2次事件的时间间隔小于指定的时间,则会抛弃上一次事件,直到指定时间内没有再次发送事件,则会发送最后一次事件给观察者。
我们可以通过RxJava完成很多功能,其不仅提供了大量的操作符,而且开发者可以定义自己的操作符,RxJava提供的操作符分为几类:
1、创建操作符
举例:create、just、from、fromArray、fromIterable、Defer、Interval、Timer、Repeat等
作用:可以快速的创建一个被观察者(Obserable)
比较常用的有:
create:通过调用观察者的方法从头创建一个Observable
defer:在观察者订阅之前不创建这个Observable,为每一个观察者创建一个新的Observable
just:将对象或者对象集合转换成一个会发射这些对象的Observable
interval:创建一个定时发射事件的Observable
Timer:创建一个在指定延时之后发射数据的Observable
2、变换操作符
举例:Map、Buffer、FlatMap等
作用:可将Observable发送的数据进行变换
Map:通过对序列的每一项都应用一个函数变换Observable发射的数据
FlatMap:将observable发射的数据变换为observable集合,然后将这些Observable发射的数据平坦化的放进一个单独的Observable中,可以认为是将一个嵌套的数据结构展开的过程
3、过滤操作符
举例:debounce、throttleFirst、throttleLast、filter、skip、skipLast等
作用:用来对Observable发射的数据进行选择,去除不需要的数据
filter:过滤掉不符合条件的数据
skip:跳过前多少项数据
skipLast:跳过后多少项数据
throttleFirst:取一段时间内发射的第一项数据,其他数据跳过(功能防抖)
throttlelast:取一段时间内发射的最后一项数据,其他数据跳过
debunce:只有一段时间后没有再次发射数据,才发射数据(搜索联系)
4、组合操作符
举例:merge、and、zip等
作用:将多个Observable组合成一个单一的Observable
merge:将两个Observable发射的数据合并成一个
zip:将多个Observable发射的数据组合在一起
5、错误处理
举例:catch、retry
catch:捕获,继续序列操作,将错误替换为正常的数据,从OnError通知中恢复
retry:重试
6、辅助操作符
举例:observeOn、subscribleOn、subscribe、delay等
observeOn:指定observe的调度线程
subscribleOn:指定subscrible的调度线程
subscribe:Observable用来订阅Observer,并触发事件发送
delay:延迟一段时间发射结果数据
7、算术和聚合操作
举例:average、max、min、sum、reduce
average:计算Observable发射的数据序列的平均值,然后发射这个结果
max:计算并发射数据序列的最大值
min:计算并发射序列的最小值
sum:计算并发展数据序列的和
reduce:按顺序对数据序列应用某个函数,然后返回这个值
8、转换操作
举例:to、blocking
to:将Observable转换成其他类型的对象
blocking:阻塞Observable的操作符
RxJava的优点
1、基于链式调用,让代码的逻辑变得清晰;
2、能够方便的进行线程切换,轻松的完成异步操作;
3、功能强大,提供了大量的操作符,方便开发者开发。
线程切换可以参考:Android 中的线程、Android线程通信之Handler
我们知道,Android是有主线程和子线程的区分的,主线程是在应用启动时创建的,而子线程是开发者在后期创建的,主线程也叫UI线程,用来处理所有和UI相关的操作,比如界面绘制、事件处理、Activity生命周期函数回调等,正因为主线程要做如此多的工作,对于耗时的操作我们必须在子线程中处理,比如网络请求、读取较大文件等,其目的是为了不阻塞主线程,造成界面卡顿甚至ANR,Android提供了Handler来进行线程的切换。
首先,我们来看一下在子线程中如何通过Handler向主线程发送消息来让主线程处理:
1、构建Handler对象,并传入主线程的Looper,重写Handler的handlerMessage方法
-
handler =
new Handler(Looper.getMainLooper()) {
// 主线程的Looper对象会在调用Looper.prepareMainLooper时创建,并存入一个静态变量中,通过Looper.getMainLooper()可以直接获取
-
public void handlerMessage(Message e) {
-
// 处理消息代码
-
}
-
};
2、构造消息
-
Message message = Message.obtain();
-
message.args = obj;
// 消息携带的参数
3、通过Handler发送消息给主线程处理
handler.sendMessage(message);
我们知道,一个线程要想处理Message,必须首先创建Looper循环,那么主线程的Looper循环是在哪里创建的呢?
应用在启动后,会创建主线程,主线程会执行ActivityThread的public staic void main方法,这个方法可以看做一个应用的入口,在这个方法中,会创建主线程的Looper并开启Looper循环,代码如下:
-
public static void main(...) {
-
...
-
Looper.prepareMainLooper();
-
...
-
Looper.loop();
-
}
一旦开启Loop循环,主线程将陷入死循环,不断的处理主线程Looper的消息队列里面的消息,也就是说,主线程里面进行的所有操作都将由Looper循环去处理,如果循环退出了,应用也就退出了。那么Looper是一个什么东西呢?其内部的实现原理是什么?
首先,我们来看一下在子线程中何如建立Looper系统并接收其他线程发送的消息,以HandlerThread为例,HandlerThread内部建立了Loop循环,用来处理其他线程发送过来的消息,HandlerThread的核心代码大概如下:
-
public
class HandlerThread extends Thread {
-
private Looper mLooper;
-
-
public void run() {
-
Looper.prepare();
// 为该线程准备Looper循环
-
-
synchronized(
this) {
-
mLooper = Looper.myLooper();
-
notifyAll();
// 这里的同步是通知其他需要获得该线程Looper对象的线程,Looper已经创建了,可以继续执行了
-
}
-
Looper.loop();
-
}
-
-
public Looper getLooper() {
-
/*
-
* 这里的同步是为了确保其他线程通过getLooper方法能够获得HandlerThread的Looper对象,考虑这样一种情况,我们创建了HandlerThread对象,但是并没有启动它,也就是run方法并没有执行(就算启动了因为线程是并行执行的,可能还没有初始化完成Looper),
-
* 这时在其他线程中调用getLooper方法获得HandlerThread的Looper对象,就会为空,为了确保其他线程能获得HandlerThread的looper对象,需要使用线程同步,即如果其他线程调用getLooper方法获得Looper对象时,如果Looper对象还没有创建,则让其他线程等待
-
* ,直到HandlerThread的run方法中初始化了Looper,才通知其他线程继续执行并返回Looper对象。
-
*
-
*/
-
synchroniezed(
this) {
-
if (mLooper ==
null) {
-
wait();
-
}
-
}
-
return mLooper;
-
}
-
}
使用HandlerThread处理异步消息:
-
HandlerThread handerThread =
new HandlerThread();
-
handerThread.start();
-
Handler handler =
new Handler(handerThread.getLooper()) {
-
public void handlerMessage(Message e) {
-
// 处理消息
-
}
-
}
-
Message message = Message.obtain();
-
message.obj =
"I am a message";
-
handler.sendMessage(message);
上面的代码中,构造了一个HanderThread对象,并启动它,然后用它的Looper构造了一个Handler对象,利用Handler给HanderThread发送消息,消息最终是在HandlerThread线程中处理的。
我们在子线程中通过调用Looper.prepare()来为这个线程构造一个消息循环系统,Looper.prepare()内部会构造一个Looper对象,并将其放入到一个ThreadLocal对象中,这个ThreadLocal对象属于Looper类的静态对象,ThreadLocal用来存储和线程相关的数据,其内部存储数据和获取数据都是和线程相关的,ThreadLocal的实现也比较简单,Thread类内部有一个成员变量专门用来存储ThreadLocal相关的数据,我们通过ThreadLocal对象存储数据时,其实都是先获得当前线程对象,然后以ThreadLocal对象为Key,以实际数据为Value,存入到当前线程的成员变量中,获得数据时,同样首先获得当前线程对象,然后以ThreadLocal为key,在当前线程的成员变量中查找key对应的value,即实际存储数据。通过每一个线程维护不同的数据副本,ThreadLocal就做到了每个线程都能够存储自己相关的数据而不会相互影响,通过ThreadLocal我们做到了Looper对象和线程相关;构造Looper对象时,同时会为Looper对象构造消息队列MessageQueue,MessageQueue的作用是存储Message,其内部是通过链表来实现的,链表能够快速的插入和删除节点,每一个Message的next属性都可以指向下一个消息;当我们构造一个Message并通过Handler发送出去时,其会将消息放入到Looper的MessageQueue中存储,并且Message的target属性持有发送消息的Handler引用;MessageQueue有一个next()方法专门用来从消息队列中取出优先级最高的消息,如果没有消息则会阻塞函数的执行,我们在为线程构造了Looper对象后,还必须调用其loop()方法才能让线程陷入Loop循环,loop()方法是一个死循环,它会不断的调用MessageQueue.next()方法来获得消息队列中的消息,一旦消息队列中有了消息,其就会处理消息,处理消息首先会调用Message的callback处理,如果没有callback,则会调用Message的target(发送消息的Handler)的callback处理,如果target的callback处理返回结果为true,则会调用target的handerMessage来处理消息。从上面的流程,我们可以知道,Handler的作用是用来发送和处理消息,Message的作用是用来包装消息,MessageQueue的作用是用来存储消息,而Looper的作用是用来构造消息循环。Looper还提供了quit()和safelyQuit()方法来退出消息循环,两种的区别在于quit方法会直接清空消息队列并退出循环,而safelyQuit()方法会将队列中当前能够执行的消息执行完再退出。当然,主线程的Looper是不支持退出的,只有当应用进程死亡后,主线程Looper才会退出。
由上,我们了解了Android Handler线程切换的基本原理,Android中用到线程切换的地方非常多,比如网络请求,我们就需要在子线程中发起网络请求,并在获得请求数据后,通过Handler将请求数据发送给主线程,让主线程去更新UI。Android提供了AsyncTask类来供开发者使用的异步操作,主要实现原理就是将调用doInBackground()方法的操作封装在一个Runnable中,并将Runnable放在线程池中执行,然后将处理结果通过handler发送给主线程,主线程中在去执行onPostExecute()方法处理结果。
我们在多线程开发中,经常会用到线程池,线程池内部存储了一个或多个线程,开发者只需要将操作封装成Runnable提交给线程池去执行就好了,而不需要自己创建线程,使用线程池的好处有:
1、方便开发者统一管理线程;
2、能够节省创建以及销毁线程造成的开销,因为线程池中的线程是可以复用的;
3、能够灵活的指定满足不同功能的线程池。
线程池能够灵活的配置核心线程数、最大线程数、非核心线程闲置时的超时时长、线程池的任务队列、创建线程的工厂、处理异常情况的Handler等参数,我们可以根据需求来配置线程池,ThreadPoolExecutor执行任务时大致遵循如下规则:
(1)如果线程池中有空闲的核心线程,那么会直接使用一个核心线程来执行任务;
(2)如果线程池中没有空闲的核心线程来执行任务,那么会将任务插入到任务队列中排队等待执行;
(3)如果在步骤2中无法将任务插入到任务队列中,这往往是因为任务队列已满,这个时候如果线程数量没有达到线程池规定的最大个数,则会立即启动一个非核心线程来处理任务;
(4)如果在步骤3中线程数量已经达到了线程池规定的最大个数,那么就拒绝执行此任务,线程池会调用RejectedExecutionHandler的rejectedExecution方法来处理异常情况。
为什么线程池要在任务队列已经满了的时候才创建非核心线程处理任务
我的理解是线程的创建和回收都需要销毁资源,线程的创建需要内存和CPU,线程的回收也需要销毁CPU执行时间,而非核心线程只是临时创建的线程,只有当任务很多,任务队列填充满了的情况下才会创建,当任务数量恢复正常后,非核心线程数是要被回收的,其代价要比将任务放入任务队列要大,同时这也违背了线程池复用线程的原则,也就是说,我们首先应该让任务在任务队列中排队等待执行,只有特殊情况才创建非核心线程来处理。
线程池处理异常情况一般怎么处理
如果当前任务队列已经满了,并且开启的线程数也达到了最大线程数,这是就要采用异常处理机制来处理任务了,线程池提供了四种默认的异常处理机制:
1、CallerRunsPolicy
直接让请求执行任务的线程执行任务,这种策略可以延缓任务的产生速度,因为产生任务的线程会去执行任务,采用这种方式的问题是可能会阻塞产生任务的线程,比如在UI线程中执行耗时任务就会造成UI界面卡顿。
2、AbortPolicy
直接抛出异常并丢弃任务,让产生任务的线程捕获异常做相应的处理。
3、DiscardPolicy
直接丢弃任务,不做任何处理。
4、DiscardOldestPolicy
这种策略会丢弃任务队列头部的任务,然后尝试将新任务加入到队列中,要谨慎采用这种策略,这种策略采用的是丢弃旧的任务来容纳新的任务,不能确保任务能够执行。
一般的,如果出现了异常情况,这往往是因为产生任务的速度远远大于执行任务的速度,这是,我们需要考虑我们的线程池配置参数是否合理,比如核心线程数是不是太小了,或者CPU的处理能力不够,来不及处理任务。
我们也可以实现RejectedExecutionHandler接口来实现自己的异常处理机制,比如记录日志、报警等功能。
将可能需要重复使用的对象缓存起来,下次使用时直接从缓存中获取,这在设计模式中叫做享元模式。再举个例子:Handler中使用到的Message,其实也是享元模式的应用,我们在构造Message时尽量不要通过new关键字构造,而是应该使用Message.obtain()方法获得,Looper循环在处理完一个消息后,会调用Message的recyle()方法将Message回收,其内部会将message中的信息清空并且加入到缓存消息的队列中,这样,当调用Message.obtain()方法时,就会直接从缓存队列中获取一个消息对象,这就节省了频繁创建Message所造成的开销。在Java中,创建对象需要消耗内存和CPU,回收对象同样也需要消耗CPU,当对象频繁创建并需要回收时,就会频繁触发Java虚拟机的GC操作,GC操作是在GC守护线程中进行的,在某些过程中会让其他线程暂停执行,并且GC操作也会抢占CPU的资源,让其他线程的运行效率降低,如果频繁触发会影响主线程的性能,造成界面卡顿。
IntentService实现原理
我们知道,Android中的Service生命周期的回调是在主线程中执行的,那么如果要在Service中做耗时的操作,将会阻塞UI线程,所以一般我们会开启子线程处理Service中的耗时操作,在Service销毁时关闭子线程,这就需要我们维护子线程,Android提供了IntentService来方便开发者在子线程中处理Service任务,并在任务执行完后自动关闭Service。其内部的实现原理是Service+HandlerThread+Handler,下面看一下IntentService的大致代码:
-
public
abstract IntentService extends Service {
-
ServiceHandler mServiceHandler;
-
HandlerThread mHandlerThread;
-
-
public void onCreate() {
-
mHandlerThread =
new HandlerThread();
-
mHandlerThread.start();
-
mServiceHandler =
new ServiceHandler(mHandlerThread.getLooper());
-
}
-
-
public void onStart(Intent intent) {
-
Message message = Message.obtain();
-
message.obj = intent;
-
mHandlerThread.sendMessage(message);
-
}
-
-
public
class ServiceHandler extends Handler {
-
public void handlerMessage(Message e) {
-
onHanderIntent((Intent) e.obj);
-
stopSelf();
-
}
-
}
-
-
public void onDestory() {
-
mHandlerThread.getLooper().quit();
-
}
-
-
public abstract void onHanderIntent(Intent intent);
-
}
首先,IntentService是一个Service,在onCreate方法中会初始化HandlerThread以及传递消息的Handler对象,并启动HandlerThread,在onStart方法中封装将intent封装到Message中,并通过Handler发送给HandlerThread处理,HandlerThread调用Handler的handlerMessage方法处理消息,handlerMessage方法调用onHandlerIntent方法(抽象方法,具体逻辑有开发者实现)处理消息,处理完后关闭service,这时IntentService的onDestory方法会调用,我们在该方法中退出HandlerThread的looper循环,HandlerThread线程结束。
多线程同步的手段(重要)
线程同步的概念:当多个线程都需要同时操作一份数据时,为了避免造成数据混乱,需要利用同步机制来让线程顺序访问。
要解决线程同步问题,一般有有两种思路:
1、让线程排队执行,顺序访问资源,这种实现思路可以通过同步锁、信号量等实现
2、让每个线程都有自己的数据副本,它们之间互不影响,这种可以使用ThreadLocal来实现
具体实现方式
同步锁:Java所有的Object对象都可以作为同步锁,我们可以给操作共享资源的代码加上同步锁,当一个线程执行到这块代码时,首先需要获得同步锁,如果这个同步锁被其他线程获得了,JVM就会把这个线程放到本对象的锁池中,本线程进入阻塞状态。锁池中可能有很多的线程,等到其他的线程释放了锁,JVM就会从锁池中随机取出一个线程,使这个线程拥有锁,并且转到就绪状态。
同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可
一般,我们采用Synchronized来实现同步锁,比如:
-
Synchronized修饰代码块
-
public void methodA() {
-
Synchronized(
this) {
-
// 需要同步的操作
-
}
-
}
-
上面的代码是以调用当前方法的对象作为同步锁来进行线程同步,如果是不同的对象调用该方法,将不会有同步效果
-
-
Synchronized修饰方法
-
public void Synchronized methodA() {
-
// 需要同步的操作
-
}
-
Synchronized修饰方法,表示以调用该方法的对象作为同步锁,等价于
-
public
void methodA {
-
Synchronized(
this) {
-
// 需要同步的操作
-
}
-
}
-
-
Synchronized修饰静态方法
-
public static void Synchronized methodA() {
-
-
}
-
Synchronized修饰静态方法表示以该方法所属的Class对象作为同步锁,所有的该Class类型对象共享同步锁,等价于
-
public
static
void methodA {
-
Synchronized(Class) {
-
-
}
-
}
wait和notify/nofityAll()
wait和nofity要配合Synchronized结合使用
wait的作用是用来主动释放同步锁,让该线程进入等待状态,直到其他的线程调用了nofity后唤醒该线程,该线程才再次去竞争线程锁
HandlerThread中就有对wait/nofity的应用
在HandlerThread的getLooper方法中,会对获得HandlerThread的Looper代码进行加锁,如果HandlerThread线程还没有创建Looper,则会调用wait释放线程锁,其他线程处于等待状态,直到run方法中为HandlerThread创建了Looper后才会调用nofityAll唤醒其他线程去竞争同步锁,获得锁对象的线程将继续执行。
2、使用volatile关键字修饰
从根本上来说,volatile用于多线程之间内存的共享,它确保了多个线程之间数据的可见性,可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
相比于synchroinized来说,volatile要轻量很多,执行的成本会更低,它不会阻塞线程让线程顺序执行,volatile只能确保轻量级的同步操作,具体实现原理:
Java的内存模型定义了主内存和本地内存,主内存是所有的线程可以共享的区域,本地内存为线程私有化。为了提高性能,线程通常不直接从主内存中读出和写入内容,而是通过本地内存,并通过一定的刷新机制进行内容同步,volatile的实现原理是:被修饰变量值修改时处理器会将缓存行数据写入到主内存中。与锁相比,Volatile变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下比同步锁具有更好的性能和伸缩性。
3、ThreadLocal
前面我们知道,ThreadLocal存储数据每个线程都有单独的数据副本,其实现原理是将数据存储在Thread的成员变量中,这样每个线程都操作的是自己的数据副本,不会造成数据混乱。这是一种用空间换取时间的做法,而同步锁的核心思想是让线程排队操作资源,是用时间换取空间的做法。
4、使用重入锁(ReentrantLock)实现线程同步
ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized同步锁具有相同的基本行为和语义,并且扩展了其能力。比如
可以让线程中断等待,去执行其他的操作,尝试去获得锁(tryLock方法,如果能获得锁返回true,否则返回false)等。
ReentrantLock() : 创建一个ReentrantLock实例
lock() : 获得锁
unlock() : 释放锁
和synchronized对比:
在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态;
ReentrantLock提供了多样化的同步,比如有时间限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。
ReentrantLock的原理
重入锁是基于AQS实现的,AQS是Java并发包中很多同步组件的构建基础,简单来讲,它内部实现主要是由状态变量state(使用volatitle确保可见性)和一个先进先出队列(双向链表形式)来完成,同步队列的头结点是当前获取到同步状态的结点,获取同步状态state失败的线程,会被构造成一个结点加入到同步队列尾部,随后线程会阻塞;释放锁时将唤醒等待队列中的某个线程,使其加入对同步状态的争夺中。
AQS为我们定义好了顶层的处理实现逻辑,我们在使用AQS构建符合我们需求的同步组件时,只需重写tryAcquire,tryAcquireShared,tryRelease,tryReleaseShared几个方法,来决定同步状态的释放和获取即可,至于背后复杂的线程排队,线程阻塞/唤醒,如何保证线程安全,都由AQS为我们完成了,这也是非常典型的模板方法的应用。AQS定义好顶级逻辑的骨架,并提取出公用的线程入队列/出队列,阻塞/唤醒等一系列复杂逻辑的实现,将部分简单的可由使用者决定的操作逻辑延迟到子类中去实现。
可重入性:
所谓的可重入性,就是可以支持一个线程对锁的重复获取,原生的synchronized就具有可重入性,一个用synchronized修饰的递归方法,当线程在执行期间,它是可以反复获取到锁的,而不会出现自己把自己锁死的情况。ReentrantLock也是如此,在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞。
非公平锁形式获得锁流程:
1.先获取state值,若为0,意味着此时没有线程获取到资源,这时尝试将其设置为1,设置成功则代表获取到排他锁了;
2.若state大于0,肯定有线程已经抢占到资源了,此时再去判断是否就是自己抢占的,是的话,state累加,返回true,重入成功,state的值即是线程重入的次数;
3.其他情况,则获取锁失败。
公平锁的大致逻辑与非公平锁是一致的,不同的地方在于即便state为0,也不能贸然直接去获取,要先去看有没有还在排队的线程,若没有,才能尝试去获取,做后面的处理。反之,返回false,获取失败。
ReentrantLock是一种可重入的,可实现公平性的互斥锁,它的设计基于AQS框架,可重入和公平性的实现逻辑都不难理解,每重入一次,state就加1,当然在释放的时候,也得一层一层释放。至于公平性,在尝试获取锁的时候多了一个判断:是否有比自己申请早的线程在同步队列中等待,若有,去等待;若没有,才允许去抢占。
5、使用原子变量实现线程同步
需要使用线程同步的根本原因是不同的线程同时操作共享资源,造成资源的混乱,原子操作指的是对于一系列的操作,将其作为一个整体来执行,
要么一次都执行完,要么就不执行。在java的util.concurrent.atomic包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。其中AtomicInteger 表可以用原子方式更新int的值。
线程的级别,守护线程和用户线程
Java中线程可以分为守护线程和用户线程,守护线程和用户线程的唯一区别在于,当虚拟机中不存在用户线程运行时,虚拟机会退出,伴随着虚拟机的退出,守护线程也将结束运行。守护线程存在的意义在于
在后台默默的为应用处理一些必要操作,但是不能影响应用的生命周期,伴随着应用的结束,它也会结束,垃圾回收线程就是典型的守护线程,垃圾回收线程默默的在后台为应用回收内存,但是它不会影响到应用的
生命周期,当应用中所有的用户线程都结束时,应用也就应该退出了,这时也就不需要回收垃圾了,守护线程也将结束。通过Thread的setDaemon(boolean)方法可以将线程设置为守护线程还是用户线程。
线程的级别
Android中的线程可以设置优先级
Android中的线程是可以设置优先级的,优先级高的线程在竞争CPU资源时会更加有利,Android线程使用nice值来描述线程的优先级,其取值范围为[-20, 19],-20的优先级最高,19的优先级最低,Android提供了两种方法设置线程的优先级:
Process.setThreadPriority(int); // 设置线程的优先级,取值范围为[-20, 19],即设置线程的nice值
Thread.setPriority(int); // 取值范围为[1, 10],1对应的优先级最低,10对应的优先级最高,其最终也是调整线程的nice值来修改线程的优先级
这两种设置线程优先级的方式最终都是修改线程的Nice值来修改线程的优先级,只是Process.setThreadProiority能够设置的更为具体一点,而Thread.setPriority只能设置10个值,其中10对应的nice值为-8,1对应的是19。
一般我们使用Process.setThreadPriority设置线程的优先级,它能够设置的更加具体一点,Android的UI线程的Nice值为THREAD_PROORITY_DEFAULT(0),为了避免其他子线程和UI线程抢占CPU资源,一般我们可以将子线程的优先级设置的比UI线程低。
默认情况下,线程的优先级和调用new Thread创建它的线程优先级一样,所以,我们在UI线程中通过new Thread创建一个子线程,如果不设置优先级,其线程的优先级和UI线程一样。
Android中常见线程优先级分析
AsyncTask
AsyncTask采用线程池来执行异步任务,AsyncTask是在Runnable中设置线程的优先级的
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); // 其中THREAD_PRIOTIRY_BACKGROUND为10
AsyncTask中执行任务的子线程的优先级为BACKGROUND,其优先级比UI线程低的多,这也是为了避免后台任务影响到UI线程的运行
HandlerThread
HandlerThread默认的线程优先级和UI线程一样,是THREAD_PRIORITY_DEFAULT(0),当然我们也可以调用方法自行设置
ThreadPoolExecutor
线程池中的线程是由线程工厂ThreadFactory提供的,在ThreadFactory的newThread方法中我们可以设置线程的优先级,如果我们没有设置ThreadFactory,则线程池采用默认的DefaultThreadFactory,其采用默认的线程优先级,即nice=0。
为了不影响UI线程的运行,让UI线程抢占更多的CPU资源,我们在开启子线程时需要设置线程的优先级,对于一些优先级不高的后台任务,将其优先级设置为BACKGROUND。
从哪里开始说起呢?Android的控件体系涉及到的内容太多,包括Android坐标体系、测量、布局、绘制三大流程、事件分发、动画机制、屏幕刷新机制等等,要想弄明白不简单,这里从setContentView方法说起。
1、绑定PhoneWindow并添加控件树
从startAcvity启动Activity说起,我们在调用startActivity启动Activity后,首先是应用进程和AMS进行一些交互,在AMS的回调方法中,会通过反射创建Activity实例,并调用其attach方法为Activity绑定一个PhoneWindow对象,然后依次调用Activity的生命周期方法,在onCreate方法中,我们一般会调用setContentView方法设置布局,其实Activity的setContentView方法最终会调用到PhoneWindow的setContentView方法,在该方法中,首先判断PhoneWindow对象是否有DecorView,DecorView是控件树的根控件,它继承于FrameLayout,如果没有则为其创建一个DecorView,然后根据Activity的主题类型判断加载哪种系统布局,系统提供了很多默认的布局xml,比如带标题栏的或者不带标题栏的,系统提供这些布局的目的是为了简化应用开发者的开发,并且提供统一的界面样式,不管这些布局内容如何,它们都必须具备一个id为android:content的控件,然后解析我们自己的资源文件,生成对应的控件树,并添加到android:content的控件上。到这里为止,我们就初始化了Activity的实例,并调用其attach方法为其绑定了一个PhoneWindow对象,并且通过setContentView方法创建了以DecorView为根控件的控件树。
2、关联ViewRootImpl
在上一步中,我们为Activity绑定了PhoneWindow并添加了控件树,下面再来说一下关联ViewRootImpl,ViewRootImpl是Android中非常重要的类,它控制了Android控件的绘制、事件分发等等,AMS的回调函数在调用完onCreate方法后,会继续调用Activity的其他生命周期方法,比如onStart、onResume,在onResume方法调用完成后,会创建一个ViewRootImpl对象,并将PhoneWindow、ViewRootImpl、DecorView这三者关联起来,ViewRootImpl实现了ViewParent接口,虽然其不是一个真正意义上的View,但是其可以作为控件的父节点,在这里,会将DecorView的父节点设置为ViewRootImpl,也就是说,现在控件树的父节点其实是ViewRootImpl了(虽然从控件的层面上说,根控件应该是DecorView)。
3、Android的刷新机制
通过上面的两步,我们还只是建立控件树,并将其和ViewRootImpl、PhoneWindow关联在一起,Android是怎样将控件绘制到屏幕上的呢?这就要说到Android的刷新机制了,我们知道,Android的界面显示是由SurfaceFlinger控制的,SurfaceFlinger运行在独立的SurfaceFlinger进程中,它是由init进程fork而来的,它主要是用来接受多个来源的图形显示数据(Surface),将他们合成,然后发送到显示设备,SurfaceFlinger每16.6ms会产生一个屏幕刷新信号(VSYNC信号),用来触发应用进程屏幕刷新的回调。ViewRootImpl提供了两个方法scheduleTraversals()和performTraversals(),其中,scheduleTraversals()会通过Choreographer向SurfaceFlinger注册监听下一个VSYNC信号,这样,当下一个VSYNC信号到来时,应用就会收到通知,触发回调,在回调方法中,最终会调用ViewRootImpl的performTraversals()方法,在performTraversals()方法中,会依次调用ViewRootImpl的performMeasure()、performLayout()、performDraw()方法,这三个方法对应的就是View控件树的测量、布局、绘制三大流程,这每一个方法都会从DecorView开始遍历,依次调用每个控件的measure()、layout()、draw()方法,完成整个控件树的测量、布局以及绘制,当然,ViewRootImpl和控件都有判断机制,对于不需要重新测量、布局、绘制的控件是不会调用对应方法的,比如,调用requestLayout会触发调用ViewRootImpl的performLayout方法,而invalidate方法只会触发调用perfromDraw方法,这样做是为了提高效率。
4、Android View到底是什么
View分为两种,继承View和继承ViewGroup,其中ViewGroup又继承于View,只是ViewGroup实现了ViewParent接口;继承View的控件,比如Button、TextView等,它们就是单个控件,不能包含其他控件;继承ViewGroup的控件,比如LinearLyaout、RelativeLayout等,它们也属于控件,只是这个控件可以作为父控件包含其他控件;通过ViewGroup和View的组合就构成了Android的控件树,其实,在我看来,View就是封装了当前View的绘制、事件处理、子控件事件(绘制、测量、布局、点击事件等)分发的代码封装体,而它们提供的代码统一都是由ViewRootImpl管理调用的,也就是说,View在App端所有的流程都是从ViewRootImpl开始的,以控件树的绘制为例,在ViewRootImpl收到VSYNC信号后,调用performDraw方法开始重新绘制控件树,ViewRootImpl首先调用DecorView的draw方法,并传入Surface的canvas对象(canvas即画布,Android中的控件树内容其实都是绘制在Canvas上,然后提交给Surface,并通过WMS交由给SurfaceFlinger处理),DecorView继承于View,其draw方法会依次将控件背景、控件内容、子控件内容等绘制在Canvas上,当然,其中会调用Cavas的一些变化方法来完成View动画、切换绘制坐标系到当前控件等;最后将Canvas的内容提交给Surface,并交由给SurfaceFlinger处理。
5、View的invalidate、requestLayout方法
以invalidate举例,从上面的流程可以看出,View中所有的事件都是由ViewRootImpl控制的,那么我们调用View的invalidate方法是怎样触发重绘的呢?invalidate首先会标记当前View需要重新绘制,并记录刷新区域,然后沿着控件树逐级向上寻找,直到找到控件树的根节点,即ViewRootImpl,ViewRootImpl会记录需要重绘的区域,并判断当前线程是否是主线程,如果不是主线程则直接抛出异常(这也是为什么在非主线程中不能更新UI的原因,ViewRootImpl在调用sechudlerTraversals()向系统注册下次VSYNC信号回调前加了检测),接着调用scheduleTraversals()方法向系统注册下次VSYNC信号需要回调刷新页面,当下次VSYNC信号到来时,ViewRootImpl的performTraversals()方法被触发调用,在该方法中由会沿着控件树找到需要重绘的View,并调用其draw方法绘制其内容到Canvas上,经由WMS提交给SurfaceFlinger处理显示到屏幕上。从上面可以看出,invalidate()方法并没有立刻触发draw方法的调用,而是记录要刷新的区域,逐级回调到ViewRootImpl,调用其schedulesTraversals()方法注册监听下一个VSYNC,当下一个VSYNC到来时,ViewRootImpl的performTraversals()才被触发调用,并沿着控件树找到要重绘的View,调用其draw方法,刷新内容到画布上。
自定义控件举例说明:自定义View指南针、分贝仪、测量尺等,面试前大家可以将自己熟悉的自定义View安装在手机上并说明。
指南针
1、通过系统提供的传感器服务(SensorService)服务来监听当前所处的方位;
2、自定义View绘制指南针,并提供相应的API更新当前所处方位数据,并调用invalidate方法触发重绘刷新数据到界面上;
3、在监听回调中,调用自定义View提供的刷新方位Api,并传递数据。
绘制流程,重写OnDraw方法,在其中依次绘制背景、方位等内容到Canvas上。
分贝仪
1、通过系统提供的MediaRecorder收集声音数据;
2、每隔500ms(方法很多,可以直接使用Handler来发送延迟消息)通过MediaRecorder收集一次这500ms内最大音量的分贝值;
3、自定义View绘制分贝仪,并提供相应的API更新分贝数据,并调用invalidate方法触发重绘刷新数据到页面上;
4、获得这500ms内的最大值,开始一个属性动画,以上次收集的数据作为起始值,这次的数据作为最终值,变化时间设置为400ms,插值器设置为先加速后减速,开始动画,在动画的监听回调中调用自定义View提供的API,更新分贝数据。
提供动画的目的是为了让分贝的变化过程更加流畅,并且可以自定义滑动的快慢。绘制分贝仪指示线时用到了SweepGradient类。
测量尺
1、自定义控件绘制测量尺内容;
2、提供相应的Api更新当前刻度值,并调用invalidate方法触发重绘刷新数据到界面上(由于是内部调用,定义成私有方法);
3、重写onTouchEvent方法,判断事件类型,如果是DOWN事件,则记录事件产生位置,如果是MOVE事件,则将当前事件位置减去DOWN事件位置,这样可以计算出用户手指的滑动距离,然后将滑动距离加上初始刻度即为当前刻度,调用相应API刷新数据到界面上。
Window、Activity、View这三者的关系,最好是自己的理解
在我看来,Activity是作为四大组件之一提供给开发者使用的,其内部是由AMS在管理,开发者可以在其回调函数中处理自己的业务逻辑;而Window是作为窗口来显示界面内容,Window的具体实现是PhoneWindow,在Activity实例化后会创建一个PhoneWindow并附加给Activity,Window是由WMS统一管理的;View是界面的具体内容,我们通过View构成控件树,然后通过ViewRootImpl测量、布局、绘制控件树,绘制过程首先是获得Window对应的Surface,从Surface中获得画布,然后遍历控件树,依次调用其draw方法,将控件内容绘制在Cavas上,最后将画布内容提交给Surface,通过WMS交由给SurfaceFlinger合成显示。从上面的流程,我们可以看出,Activity更多的是作为业务逻辑的处理者,其将界面的显示工作交由给Window处理,而界面的具体内容则依赖于View组成的控件树。
Window的层级
WindowManagerService(以下简称WMS)是Android Framework中一个重要的系统服务,用来管理系统中窗口(Window)的行为。Window是一个抽象的概念,它是一个矩形区域,用来绘制UI界面,响应用户的输入事件。Android系统的界面,从Framework层的角度来看,就是由一个一个窗口组合而成的。
在WMS中一个重要的功能就是对当前窗口的显示顺序进行排序。但是窗口只是一个抽象的概念,WMS中所做的事情实际上是根据各种条件计算窗口的显示层级,然后将这个表示层级的数值传给SurfaceFlinger,SurfaceFlinger根据这个层级信息去进行渲染,比如将层级更高的Window对应的内容显示在上层。
MeasureSpec
理解MeasureSpec,以及MeasureSpec与LayoutParams的对应关系
MeasureSpec是一个32位的int类型,高两位代表SpecMode,低30位代表SpecSize,SpecMode指测量模式,SpecSize指在某种测量模式下的规格大小,MeasureSpec通过将SpceMode和SpceSize打包成一个int值来避免过多的对象内存分配,并通过位操作来提供打包和解包操作。
SpecMode有三种模式:
UnSPECIFIED:父容器不对View有任何限制,要多大给多大
EXACTILY:父容器已经检测出View所需要的精确大小,这个时候View的最终大小就是SpecSize所指定的值。它对应LayoutParams中的match_parent和具体的数值这两种情况
AT_MOST:父容器指定了一个可用大小即SpecSize,View的大小不能大于这个值,具体是什么值要看不同View的具体实现。它对应LayoutParams中的wrap_content
系统内部是通过MeasureSpec来对view进行测量。在view测量的时候,系统会将LayoutParams在父容器的约束下转换成对应的MeasureSpce,然后再根据这个MeasureSpec确定View的宽高。
对于DecorView和其他普通的View,MeasureSpec的转换过程略有不同,对于DecorView来说,其MeasureSpec有窗口的尺寸和其自身的LayoutParams来共同决定;而对于普通View,其MeasureSpec由父容器的MeasureSpec和自身的LayoutParams来共同决定。
对于DecorView,其MeasureSpec遵循如下规则:
LayoutParams为MATCH_PARENT:SpecMode为精确模式,SpecSize为窗口的宽高
LayoutParams为WRAP_CONTENT:SpecMode为AT_MOST模式,大小不确定,但是不能超过窗口的宽高,SpecSize为窗口的宽高
固定大小:SPECMODE为精确模式,SPECSIZE为指定的大小
对于普通的View,其MeasureSpec是由父控件的MeasureSpec和其LayoutParams共同决定的
父控件SpecMode/子控件LayoutParams EXACTLY AT_MOST UNSPECIFIED
MATCH_PARENT EXACTLY/parentLeftSize AT_MOST/parentLeftSize UNSPECTFIED/0
WRAP_CONTENT AT_MOST/parentLeftSize AT_MOST/parentLfetSize UNSPECTFIED/0
dp/px(指定大小) EXACTLY/childSize EXACTLY/childSize EXACTLY/childSize
自定义View分类
1、继承View重写onDraw方法
这种方法一般用来实现一些不规则的效果,既不能通过原生的控件组合来实现,比如自定义View中的指南针、测量尺等,需要我们自己去实现绘制规则。彩种这种方式需要自己支持wrap_content,并且padding也需要自己处理。
2、继承ViewGroup派生特殊的Layout
这种方式主要实现自定义的布局,即原生的布局LinearLayout、RelativeLayout不能满足,我们需要重新定义一种新布局,采用这种方式我们处理ViewGroup中子View的测量、布局这两个过程。
3、继承特定的View
比如继承RatingBar来实现自定义的星级评分功能,一般采用这种方式扩展原生控件的功能。
4、继承特定的ViewGroup(比如LinearLayout)
这种方式一般用来组合其他控件。
自定义View须知
1、让View支持wrap_content
这是因为直接继承View和ViewGroup的控件,如果没有在onMeasure方法中对wrap_content的情况做处理,其使用的SpecSize将是父控件的specSize,这样其大小将是父控件剩余空间,一般,我们可以在onMeasure中判断如果SpecMode为AT_MOST,则
根据控件内容计算控件大小,并设置其SpecSize为计算大小;
2、如果有必要,让View支持padding
3、View带有滑动嵌套情形时,需要处理好滑动冲突
我们知道Android的事件分发流程是从根节点开始的,沿着控件树依次分发事件,如果子控件不能处理事件,则将该事件交由给父控件处理,这是典型的责任链模式,基于此,有两种方式处理滑动冲突
1、外部拦截法
ViewGroup提供了onInterceptTouchEvent方法来控制是否拦截事件,在分发事件前,首先调用onInterceptTouchEvent方法来判断是否拦截该事件,如果返回true,表示拦截该事件,事件将不会继续分发,如果返回false表示不拦截该事件,事件将继续分发给子控件处理。如果出现了滑动冲突,我们可以记录两次MOVE事件的位置,然后判断用户是更加倾向于左右滑动还是上下滑动,根据用户的滑动方向判断父控件是否要拦截事件。
2、内部拦截法
内部拦截法比较复杂,顾名思义就是在子view中拦截事件,父viewGroup默认是不拦截任何事件的,所以,当事件传递到子view时, 子view根据自己的实际情况来,如果该事件是需要子view来处理的,那么子view就自己消耗处理,如果该事件不需要由子view来处理,那么就调用getParent().requestDisallowInterceptTouchEvent()方法来通知父viewgroup来拦截
这个事件,也就是说,叫父容器来处理这个事件,这刚好和view的分发机制相反。
首先父控件重写onInterceptTouchEvent方法,拦截除DOWN事件以外的事件
-
public boolean onInterceptTouchEvent(MotionEvent event) {
-
int action = event.getAction();
-
if (action == MotionEvent.ACTION_DOWN) {
-
return
false;
-
}
else {
-
return
true;
-
}
-
}
然后是子控件
-
public boolean dispatchTouchEvent(MotionEvent ev) {
-
-
switch (ev.getAction()) {
-
case MotionEvent.ACTION_DOWN:
-
getParent().requestDisallowInterceptTouchEvent(
true);
-
break;
-
case MotionEvent.ACTION_MOVE:
-
if (...) {
//根据业务需求判断是否需要通知父viewgroup来拦截处理该事件
-
//允许父View进行事件拦截
-
getParent().requestDisallowInterceptTouchEvent(
false);
-
}
-
break;
-
case MotionEvent.ACTION_UP:
-
break;
-
}
-
return
super.dispatchTouchEvent(ev);
-
}
View体系使用到的设计模式
1、组合模式:通过View和ViewGroup的组合构造了整个控件树;
2、责任链模式:View的事件分发是典型的责任链模式,事件从根节点开始分发,依次分发给子节点处理,如果子节点处理不了在交由给父节点处理;
3、观察者模式:给控件设置点击事件监听等使用到了观察者模式;
4、方法模板模式:比如View的draw方法就属于方法模板模式的应用,draw方法定义了绘制View的逻辑,比如先绘制背景、在绘制自身、绘制子View、绘制装饰等,但是其onDraw方法是一个空实现,其实现需要有具体的子类实现,这是因为每个控件的绘制内容都是不一样的,这种将大致逻辑封装在方法中,但是将需要变化的实现交由给子类处理的模式即方法模板模式。
可能View体系中还有其他的设计模式,有些设计模式我们经常用,但是确不知道它们的名字,推荐专门看一下设计模式方面的书籍。
Android中的动画分为帧动画、View动画、属性动画
Scroller的原理
我们知道,针对控件动画的原理其实就是不断改变控件的属性,然后调用其invalide方法来触发draw方法重绘控件,这样控件就会产生渐变的效果,Android每16ms会触发一次VSNC信号来刷新页面,调用过invalide的控件会重新绘制,要是绘制不卡顿,控件每次VSNC信号到来时都会重绘,这样1s差不多会产生60帧的数据,由于视觉残留,在用户看来就好像是一个流畅的动画。这里以Scroller来分析一下动画的原理。
在Android中,可以利用Scroller来实现弹性的滑动效果,比如ViewPager内部就采用Scroller来滑动内容。
首先,我们知道View中提供了scrollTo(int x, int y)方法,用来偏移绘制时View的内容,View定义了mScrollX、mScrollY两个属性来存储绘制内容的偏移量,scrollTo方法内部其实就是设置这两个变量,我们知道,控件树在绘制时,是由根节点开始,遍历控件树并一一调用其draw方法来绘制的,而draw方法其实就是将该控件的内容绘制到Surface的Canvas上,并依次调用其子控件的draw方法,这个Canvas是ViewRootImpl传递过来的,所有的控件内容都是绘制在这个Canvas上,而每个控件在父控件上的位置是不一样的,所以在父控件遍历子控件并调用其draw方法前,需要调用Canvas的translate方法对画布坐标系进行偏移,以便子控件在自己的坐标系里面绘制内容,绘制完后在调用Canvas的restore方法还原画布为父控件坐标系,在切换到子控件绘制坐标系时,就会考虑到mScollX、mScrollY参数,让控件的绘制内容产生偏移。先来看一下Scroller的基本用法
-
public
class MyView extends View {
-
Scroller mScroller =
new Scroller(mContext);
// 定义一个Scroller
-
-
public void smoothScrollXTo(int destX) {
// 弹性的滚动View内容
-
int curScrollX = getScrollX();
// 获得当前的scrollX(页面内容偏移)
-
int deltaX = destX - curScrollX;
// 获得要滚动的距离
-
-
mScroller.startScroll(curScrollX, getScrollY(), deltaX, getScrollY(),
1000);
// 表示1s内由scrollX位置开始滚动deltaX的距离,这个方法只会将参数传递给Scroller,并没有真正开始动画
-
invalidate();
// 触发View的draw方法调用并触发动画开始执行
-
}
-
-
public void computeScroll() {
// 重写View的computeScroll()方法,该方法在ViewGroup的drawChild方法中会调用,drawChild方法会调用控件的draw方法绘制控件,所以computeScroll会在View内容绘制前调用
-
if (mScroller.computeScrollOffset()) {
// 该方法会根据当前时间、动画开始时间、动画时长、起始偏移位置、目标偏移位置、插值器计算出View当前应该设置的scrollX、scrollY值,并且如果动画结束了则返回false
-
scrollTo(mScroller.getScrollX(), mScroller.getScrollY());
// 从Scroller中获得上一步计算得出的View当前应该设置的scrollX、scrollY值,并调用View的scrollTo方法设置
-
postInvalidate();
// 调用postInvalidate()方法刷新页面(在drawChild方法中会再次调用View的computeScroll方法,Scroller再次开始计算View当前应该设置的scrollX、scrollY值并设置,刷新页面,这个过程直到滚动结束)
-
}
-
}
-
}
Scroller的设计非常巧妙,它将动画的计算和刷新分离,Scroller其实只处理了动画的计算,动画的刷新还是需要View的刷新机制来完成,首先在ViewGroup的drawChild方法中会调用子控件的computeScroll方法,(Android中控件的绘制、布局、测量、事件分发都是从根控件(DecorView)沿着控件树依次调用的,所以invalidate()、postInvalidate()方法触发的重绘都会经历整个控件树,只是标记了无需重绘的控件不会调用其draw方法进行重绘,这样可以提高绘制效率)而我们重写了View的computeScroll方法,在这个方法中首先调用了Scroller的computeScrollOffset()方法,computeScrollOffset这个
方法内部会根据当前时间、动画的开始时间、持续时间、偏移、插值器计算出当前时间View内容的偏移距离(即mScrollX,mScrollY),并记录下来,如果动画结束了(当前时间超出了动画结束时间),这个方法返回false,否则返回true。如果动画没有结束,我们会调用View的scrollTo方法,并将computeScrollOffset计算的偏移距离设置给View,这样View的mScrollX,mScrollY属性就是Scroller动画计算的偏移量了,最后我们再调用View的postInvalidate()方法来刷新页面并触发计算动画的下一帧偏移距离。
从Scroller中我们可以看出动画的原理,即通过一定的时间间隔来计算当前帧页面的内容,然后将页面内容绘制到Cavas上。
动画的几个要素:初始值startValue、结束值endValue、开始时间startTime、持续时间duration、插值器interpolator,计算当前值一般采用如下算法:
(startValue + interpolator((curTime - startTime) / duration)) * (endValue - startValue))
即首先根据当前时间、开始时间、持续时间计算动画执行的百分比,然后将计算结果运用上插值器,插值器其实就是将原来的值按照某种函数(变化规律)进行重新映射,比如加速插值器,我们可以用X^2来对原来的值进行运算获得新的值,如果需要更快变化率的加速插值器,可以用x^3来运算,根据插值器重新计算动画的变化的百分比后,然后用百分比乘以动画的总变化量(结束值-开始值),获得动画当前的变化量,最后在加上起始值即可得到动画当前值,在将这个值应用到控件属性或者其他地方即可,然后刷新页面即可看到动画效果。
帧动画、View动画、属性动画的原理都是一样的,都是按照一定的时间间隔获得当前帧的内容,然后将其绘制到页面上,只是其内部的刷新机制可能不一样。
帧动画
帧动画就像播放电影一样,我们知道,帧动画一般需要会将很多图片添加到动画中管理,可以设置每张图片的持续显示时间,播放动画时,帧动画会按照顺序依次显示图片,这个过程和老式电影播放机的原理很像,首先按照一定的频率录制电影镜头,比如每秒录制30张图片,播放电影时,以每秒30张图片的速度切换图片,这些图片虽然不是连续的,但是间隔很小,由于视觉残留,在人看来就好像是一个连贯的过程。
一般情况下,如果不是特别复杂的动画(动画无规律、效果太炫酷),最好不要使用帧动画,帧动画需要大量的图片支持,不仅增大Apk体积,而且消耗内存资源,而且如果图片较大,还可能导致OOM。
帧动画的刷新是有Choreographer机制来控制的。
帧动画一般用法:
-
xml version="1.0" encoding="utf-8"?>
-
<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
-
android:oneshot=
"true">
-
<item android:drawable="@drawable/frame01" android:duration="100"/>
-
<item android:drawable="@drawable/frame02" android:duration="100"/>
-
<item android:drawable="@drawable/frame03" android:duration="100"/>
-
...
-
animation-list>
-
ImageView imageView = (ImageView) findViewById(R.id.imageView);
-
Animation animation = AnimationUtils.loadAnimation(
this, R.anim.animation);
-
imageView.startAnimation(animation);
View动画
View动画的实现原理和Scroller差不多,在调用startAnimation时,会将当前动画对象Animation存储在View中,并记录动画的开始时间,然后调用invalidate方法通知系统下一个VSYNC信号到来时当前View需要刷新,那么在下一个VSYNC信号到来时,ViewRootImpl就会调用performTraversals方法遍历控件树,找到当前要刷新的View并调用其draw方法,在draw方法中会调用applyLegacyAnimation()方法应用动画效果,具体过程是根据当前时间以及设置的动画获得当前时间View动画的效果(比如应该平移多少、缩放多少),并将变换效果用Matrix(矩阵)记录下来,然后应用到Cavas上(比如将画布平移),在绘制View内容时,就会在变换过的Canvas上绘制,applyLegacyAnimation会判断当前动画是否结束,如果没有结束会继续调用invalidate方法触发下次重绘直到动画完成。
从上面View动画的流程也可以看出,View动画并没有改变View的属性,只是在绘制内容前将当前动画效果应用在了Canvas上,绘制完后还原Cavas,所以View的位置什么的并没有改变,点击区域也不会变。
View动画一般用法:
-
Animation translateAnimation =
new TranslateAnimation(
0,
500,
0,
500);
-
translateAnimation.setDuration(
3000);
-
mButton.startAnimation(translateAnimation);
属性动画
首先说一下Choreographer机制,Android系统从4.1(API 16)开始加入Choreographer来控制同步处理输入(Input)、动画(Animation)、绘制(Draw)三个UI操作。VSync信号由SurfaceFlinger实现并定时发送。我们可以通过Choreographer向SurfaceFlinger注册监听下一个VSYNC,这样下一个VSYNC信号到来时,Choreographer.FrameDisplayEventReceiver收到信号后,调用onVsync方法组织消息发送到主线程处理。Choreographer主要功能是当收到VSync信号时,去调用通过postCallBack设置的回调函数。
Choreographer是在ViewRootImpl的构造函数中初始化的,在上面分析View的绘制流程中,我们知道ViewRootImpl需要调用Choreographer提供的接口向系统注册下一个VSYNC的回调,这个回调是一个Runnable对象,注册完后,当下一个VSYNC信号
到来时,Choreographer就会调用注册的Runnable对象的run方法,ViewRootImpl注册的Runnable的run方法中会调用doTraversals方法,接着调用performTraversals()来执行控件的绘制流程。其实,Choreographer不仅能够注册绘制的回调,还能够注册事件和动画的回调,Choreographer有三种消息类型:
CALLBACK_INPUT:事件回调(优先级最高,需要随时响应用户)
CALLBACK_ANIMATION:动画回调
CALLBACK_TRAVERSAL:绘制回调
Choreographer内部将这三种消息的CallBack分别存入不同的CallBack队列中,然后向系统请求VSYNC信号,当下一次VSYNC到来时,Choreographer就会从CallBack队列中取出CallBack并执行。
接着说属性动画,属性动画开始后,主要经理了如下过程:
1、调用start()方法后,首先会做初始化的工作,包括变量的初始化,执行动画开始的监听函数;
2、初始化完成后,将ValueAnimator添加到AnimationHandler的队列中,AnimationHandler是一个单例对象,它管理着所有的属性动画的执行,AnimationHandler会判断当前队列是否为空,如果不为空则表示有属性动画需要执行,则通过Choreographer注册监听一个VSYNC信号;
3、当下一次VSYNC信号到来时,AnimationHandler的回调函数将会执行,它会遍历队列中所有的属性动画,并调用其doAnimationFrame方法执行动画的当前帧;
4、ValueAnimation的doAnimationFrame会处理动画当前帧的逻辑,根据动画的参数、当前时间等计算出当前的属性值,并调用监听传递属性值,这样,在监听中就能到得到当前时间的属性值了,然后判断动画是否结束,如果动画结束了则将ValueAnimation从AnimationHandler的队列中移除;
5、如果AnimationHandler的队列不为空,即表示还有动画需要执行,AnimationHandler将会继续调用Choreographer注册监听下一个VSYNC信号,并执行下一帧动画;
6、重复以上过程直到动画结束。
ValueAnimator动画可以获得动画当前时间的属性值,而ObjectAnimation动画可以直接将当前属性值应用到具体的对象上,那么ObjectAnimation是如何实现的呢?ObjectAnimation继承于ValueAnimator,其计算动画当前属性值的原理和ValueAnimator一样,只是其初始化时会通过反射获得设置属性和获得属性的方法,比如属性名称为"x",那么属性动画就会尝试在对象所对应类中查找setX和getX方法,如果找到了则将其缓存起来,下次更新属性值时通过反射调用相应的方法。所以,这也是为什么ObjectAnimation针对某个属性时需要类提供对应的set和get方法。
ValueAnimator的一般用法:
-
ValueAnimator anim = ValueAnimator.ofFloat(
0f,
1f);
-
anim.setDuration(
300);
-
anim.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
-
@Override
-
public void onAnimationUpdate(ValueAnimator animation) {
-
float currentValue = (
float) animation.getAnimatedValue();
-
Log.d(
"TAG",
"cuurent value is " + currentValue);
-
}
-
});
-
anim.start();
ObjectAnimator的一般用法:
-
ObjectAnimator animator = ObjectAnimator.ofFloat(textview,
"alpha",
1f,
0f,
1f);
-
animator.setDuration(
5000);
-
animator.start();
-
界面布局中常见的卡顿情况总结
界面卡顿的根本原因是UI线程不能在一个信号帧的时间内完成UI界面的绘制,上面说到过,UI线程其实是一个Looper的死循环,其会不断的读取消息队列中的消息并处理,界面绘制、事件分发等事件都需要封装成Message通过Handler投递给主线程Looper循环的MessageQueue中才会被处理。
界面卡顿常见情况:
1、布局层级太深
我们知道,Android控件的绘制是从ViewRootImpl开始的,在一个VSYNC信号到来后,如果界面需要刷新,则会调用ViewRootImpl的performTravervals方法执行控件的三大流程,而这三大流程都是从根控件DecorView开始遍历控件树,依次刷新控件,所以如果控件的层次太深,绘制控件树也会相对比较耗时;
2、View绘制内容太复杂
ViewRootImpl会依次调用控件树中控件的draw方法来将控件内容绘制在画布上,如果某个控件绘制内容太复杂,导致draw方法耗时,也会影响到UI线程;
3、View添加了不必要的背景
View在调用draw方法绘制时,首先会绘制background,这也需要消耗时间,对于没有必要绘制背景的View,我们可以将其background设置为null;
4、在draw方法中创建对象
draw方法是用来绘制控件内容的方法,会频繁的被调用,在draw方法中创建对象将会在短时间内创建大量的对象,创建对象也需要消耗时间,而且这也会导致应用出现内存抖动,导致频繁GC,执行GC操作的某些时候,任何线程的任何操作都会需要暂停, 这个在虚拟机中叫做Stop The World,并且执行GC也会占用一定的CPU资源,故而如果程序频繁GC, 会导致界面卡顿。
5、界面执行了大量的动画
我们知道,动画的执行都是在主线程中执行的,如果界面中有大量的动画需要执行,同样会影响UI线程;
6、通过Handler向主线程发送耗时的消息
我们可以通过Handler向主线程的消息队列中加入耗时的消息,这样消息将会在主线程中执行,这也会导致UI线程卡顿;
7、设置子线程的优先级大于或等于UI线程
我们开启子线程在后台做某些耗时操作时,如果子线程的优先级大于或等于UI线程,其将会抢占大量的CPU资源,导致UI线程执行效率低;
8、内存泄露导致的卡顿
当内存泄露后,应用中的可用内存将会变少,相对来说更加容易触发GC操作。
为什么GC时某些事件段需要暂停其他线程呢?在我的理解中是出于线程安全的考虑,试想一下,GC线程标记了某个对象可以被回收,然而这时某个线程又通过其他方式获得了这个对象的引用(比如通过弱引用的方式),这时,GC线程如果将对象回收了,就会导致其他线程在使用该对象是产生空指针异常。
1、利用工具
比如TraceView、打开手机FPS显示、打开GPU呈现模式等;
TraceView的使用用法
TraceView能够导出线程中某个方法的执行时间、调用次数等信息,帮助我们分析卡顿问题
a、可以在代码中添加开始收集和结束收集的方法
android.os.Debug.startMethodTracing(String traceName);
android.os.Debug.stopMethodTracing();
这两个方法添加到你想分析的那些代码中,当程序运行了这段代码,就会在/sdcard目录中生成一个traceName命名的trace文件。
b、使用DDMS收集
1、在Devices里面,你想查看的进程。然后点击,start Method Profiling
2、操作你手机想调试的那部分功能
3、再次点击那个按钮Stop Method Profiling
生成的Trace文件中有两个重要维度来分析卡顿问题:
1、Cpu time/Call:方法平均每次调用占用CPU时间。
2、Calls+RecurCall: 调用次数+递归调用次数。
我们在分析性能问题时,重点要考虑两个方面的问题,一个是方法消耗的时间,另一个是方法的调用次数,分别对应上面的Cpu time/Call和Calls+RecurCall,我们可以从这两个维度对方法进行排序,查看耗时的方法,并分析其调用情况,找到耗时点,我们点击某个方法时,会展示调用该方法的父方法以及该方法调用的各个子方法以及耗时情况,逐级分析找到耗时点。
2、自行打印onDraw方法(或者其他耗时方法)执行的时间,找到耗时点;
3、利用线程Looper支持自定义打印日志,典型代表就是BlockCanary
实现原理:BlockCanary利用了Looper提供的自定义日志打印接口,Looper提供了setMessageLogging接口,这个方法可以设置一个Printer,Printer接口只有println方法,开发者需要自己实现,Looper在调用dispatchMessage分发处理消息前后都会调用println方法打印日志,我们可以给主线程的Looper设置Printer,并实现其println方法,在该方法中,如果当前调用是处理消息前触发的,则记录当前时间mStartTime,并开始收集主线程的堆栈信息、CPU信息,如果当前调用是处理消息后触发的,则记录当前时间为mEndTime,根据两个时间就能够知道处理当前消息所消耗的时间了,如果超过了伐值,则认为处理当前消息耗时了,会导致界面卡顿,于是将收集的主线程堆栈信息、CPU信息存储到本地文件中,并弹出提示窗口。
问题1:BlockCanary是如何获得主线程的堆栈信息的
BlockCanary主要通过Thread提供的getStackTrace()方法来获得主线程调用的堆栈信息
问题2:BlockCanary是如何获得CPU信息的
通过读取/proc/stat文件,获取所有CPU活动的信息来计算CPU使用率
问题3:BlockCanary信息的收集是在哪个线程中进行的
BlockCanary对线程堆栈、CPU信息的收集不是在主线程中进行的,而是通过HandlerThread线程进行的,我们知道HandlerThread其实就是一个Thread,只不过其内部实现了Looper循环,可以接收Message并处理,BlockCanary通过发送延时消息通知HandlerThread收集信息,收集完后会继续发送延时消息准备下次收集,直到收集停止。
总结:BlockCanary巧妙的利用了Android中主线程是一个Looper循环,其就是不断的从MessageQueue中取出消息并执行,我们只需要监控每一个消息的处理时间,就可以知道处理当前消息会不会导致主线程阻塞了。
4、利用Choreographer机制
前面我们说到过,Choreographer可以向系统注册监听下一次VSYNC信号,我们可以注册自己的监听FrameCallBack,这样,下次VSYNC信号到来时,FrameCallBack中的回调函数doFrame就会被调用,在doFrame函数中我们判断记录当前时间,并再次注册FrameCallBack,通过这种方式我们可以收集到App两次处理VSYNC信号的时间差,这个时间差理论上是要接近16.6ms的,如果超过了就说明卡顿了。
ANR-WatchDog的检测原理
ANR是卡顿的极端情况,当主线程被阻塞较长时间后,就会产生ANR,ANR-WatchDog原理是开启一个线程,持续循环不断的往UI线程中Post一个Runnable(修改一个数的大小),然后在规定时间之后检测这个Runnable是否被执行(数的大小有没有 被修改过来)。没有被执行的话说明主线程执行上一个Message超时,然后获取当前堆栈信息;
垃圾回收算法
我们知道,Android程序是运行在虚拟机之上的,其内存管理是由虚拟机自动管理的,现在,我们就来聊一聊虚拟机是如何进行垃圾回收的。
垃圾回收一般有两种算法,一种是引用计数法,另一种是根搜索算法。
1、引用计数法
引用计数法的核心思想是每个对象内部都维护一个引用计数器,每当该对象被其他对象所引用时,其引用计数加1,相反,当引用失效时减1,垃圾回收器在回收内存时,判断每个对象的引用计数是否为0,如果为0则表示该对象没有被引用到,也就确认了这个对象需要被回收。但是引用计数法有一个缺点,不能回收相互引用的对象,比如两个对象之间相互引用了,那么它们之间的引用计数就都是1,这两个对象都不会被垃圾回收器回收,这样就会导致内存泄露。
优点
1、效率高、实时性
引用计数法最大的优点就是实时性,只要某个对象的引用计数为0了,就可以立马将其回收,而根搜索算法因为要遍历所有的GC ROOT对象引用链,所以效率较差,并且只能在特定的情况下才触发。
缺点:
引用计数法每个对象都需要维护自身的引用计数,这需要消耗一定的内存,并且其不能解决循环引用的问题。
Python的就是采用的引用计数法,那么Python是如何解决循环引用的问题呢?
Python主要是采用的引用计数法进行垃圾回收,对于可能会产生循环引用的对象,辅以根搜索算法和分代回收算法来处理循环引用的问题。
首先,一般来说,只有能够持有其他对象引用的对象才会产生循环引用问题,比如Class、List等,对于PyIntObject、PyStringObject基本数据类型是不可能持有其他对象的引用的,Python可以采用根搜索算法,从GC Root对象出发,开始遍历,如果某个对象有被引用到,说明其正在被使用,不能回收,对于没有引用到的对象则将其回收。
分代回收算法的目的是提高垃圾回收的效率,将系统中的所有对象根据其存活时间划分为不同的集合,每一个集合就成为一个“代”,垃圾收集的频率随着“代”的存活时间的增大而减小。也就是说,活得越长的对象,就越不可能是垃圾,就应该减少对它的垃圾收集频率。那么如何来衡量这个存活时间:通常是利用几次垃圾收集动作来衡量,如果一个对象经过的垃圾收集次数越多,可以得出:该对象存活时间就越长。
2、根搜索算法
根搜索算法的核心思想是通过一系列的“GC Root”对象开始遍历,查找被“GC Root”对象直接或间接引用到的对象,只要被“GC Root”直接或间接引用到的对象,说明其还会被使用,则垃圾回收器不会将其回收,对于没有引用到的对象,则将其回收。
根搜索算法可以解决循环引用的问题,Android的垃圾回收算法也是采用的根搜索算法,根搜索算法还有一个关键问题就是确认“GC Root”对象
Android中作为GC Root的对象有:
1、虚拟机栈(栈帧中的本地变量表)中引用的对象;
2、方法区中类静态属性引用的对象;
3、方法区中常量引用的对象;
4、本地方法栈中JNI(即一般说的Native方法)引用的对象;
总结就是,方法运行时,方法中引用的对象;类的静态变量引用的对象;类中常量引用的对象;Native方法中引用的对象。
常见的内存泄露写法
从垃圾回收机制分析内存泄露
通过上面我们知道,虚拟机只会回收那些没有被GCRoot对象直接或间接引用到的对象,造成内存泄露的原因是本应该被回收的对象被GCRoot对象直接或间接引用到,造成对象无法回收,内存泄露是很严重的问题,每个App可以使用的内存都是有限的,一旦没有内存分配了,就会抛出Out of Memory异常,它就像一颗随时可能爆炸的炸弹,当泄露积累当一定程度时,App将崩溃。下面列举几种常见的造成内存泄露的写法:
1、被静态变量所引用
-
public Class Test {
-
public
static Context context;
-
}
-
-
Test.context = activity;
这种Activity直接被一个静态变量所引用,即使Activity的生命周期已经结束,但是Activity对象还是不会被回收
2、使用非静态内部类或者匿名类
我们知道,非静态内部类或者匿名类是持有外部类的引用的,如果非静态内部类或匿名类的对象生命周期大于外部类,则会造成外部类也无法回收,典型的是使用Thread和Handler的情况
-
public Class MyActivity extends Activity {
-
public void onCreate(...) {
-
new Thread {
-
public void run() {
-
sleep(
10000);
-
}
-
}.start();
-
}
-
}
在上面的代码中,我们在Actvity的onCreate方法中直接创建了一个匿名内部类的线程并启动它,这时Thread对象是持有外部类内对象Activity的引用的,我们可以看到,如果Thread没有运行完,Activity对象将一直不会被回收。
解决办法:
a、通过创建静态内部类的方式来创建线程类,静态内部类没有持有外部类的引用,如果需要使用外部类对象,可以通过弱引用来引用;
b、在Activity的onDestroy方法中停止线程
再来看一个Handler造成内存泄露的例子
-
public
class MyActivity extends Activity {
-
public void onCreate(...) {
-
Handler handler =
new Handler() {
-
public void handlerMessage(Message e) {
-
-
}
-
};
-
-
handler.sendMessageDelayed(Message.obtain(),
10000);
-
}
-
}
在上面的代码中,我们在Activity的onCreate方法中构造了一个Handler的匿名内部类,这个handler对象是持有Activity的引用的,然后我们通过Handler对象发送一个消息,并且指定这个消息要在10秒后处理,sendMessageDelayed会将消息存入主线程的MessageQueue中,并且设置Message的被处理时间(Message有一个when字段存储消息应该何时被处理)为10秒后,也就是说,这10秒内Message对象都会一直存在于MessageQueue中,而Message的target字段又持有handler对象的引用,这就造成了Handler对象10秒内也无法被回收,而handler的Activity对象又持有Activity对象的引用,这就造成Activity对象也无法被回收。引用链如下:
主线程-->主线程Looper-->主线程MessageQueue-->Message-->Handler-->Activity
解决办法:
a、通过创建静态内部类的方式创建Handler类,如果需要使用外部类对象,可以通过弱引用来引用外部类;
b、在Activity的onDestroy方法中调用Handler的removeCallbacksAndMessage(null)方法来移除消息队列中的该Handler发送的所有消息。
3、使用单例模式造成的内存泄露
单例模式造成的内存泄露本质也是应为被静态变量所引用
-
public
class ContextUtil {
-
private
static ContextUtil instance =
new ContextUtil();
-
private Context context;
-
-
private ContextUtil() {
-
-
}
-
-
public void init(Context context) {
-
this.Context = context;
-
}
-
-
public static ContextUtil getInstance() {
-
return instance;
-
}
-
}
-
-
ContextUtil.getInstance().init(activity);
在上面的代码中,我们将activity对象设置给了instance对象context变量中,这样instance就持有了Activity的引用,而instance是一个静态变量,这将造成Activity也无法被回收
解决办法:可以传入生命周期和应用保持一致的ApplicationContext
4、没有关闭一些需要关闭的对象
Android中有些对象在使用完后是要主动调用其关闭方法才能回收的,比如输入输入流的InputStream、OutputStream,游标Cursor等,Bitmap在3.0之前也需要调用recycle方法回收内存。
另外,我们动态注册的广播,在不需要使用时也需要调用unRegister方法来销毁广播。EventBus的使用也可能造成内存泄露,EventBus的register方法会将传入的对象保存起来,在使用完后如果不调用其unRegister方法,则对象将被
EventBus所引用,造成内存泄露
内存泄露的问题的解决
在开发中,我一般使用LeakCanary和Android Memory Monitor来分析内存泄露问题。
LeakCanary是一款用来检测应用内存泄露的框架,我们可以将其接入到应用代码中,其内部会监控内存的使用情况,并分析是否出现了内存泄露,如果发现内存泄露,则会给出提示以及内存泄露的引用链,由于监控内存的使用需要销毁性能,为避免对线上包造成影响,LeakCanary专门提供了Release的库,对Release包将什么都不做,而Debug包则启动内存监控。
Android Memory Monitor是一款Android Studio自带的分析应用内存使用情况的工具,通过它可以实时监控应用内存的使用情况,并且它能导出应用当前的内存分配情况以及对象之间的引用关系,通过它,我们可以方便的分析有哪些本应该被回收的对象没有被回收,以及其引用关系,我们只需要切除引用关系,就可以解决内存泄露问题。LeakCanary检测原理
LeakCanary默认只会检测Activity的内存泄露,如果需要监听其他模块,需要手动调用watch方法监听,这里就分析LeakCanary针对Activity内存泄露的检测原理
1、通过App的registerActivityLifeCycleCallbacks方法注册监听,用来监控App中所有Activity的生命周期函数
-
registerActivityLifecycleCallbacks(
new ActivityLifecycleCallbacks() {
-
@Override
-
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
-
-
}
-
-
@Override
-
public void onActivityStarted(Activity activity) {
-
-
}
-
-
@Override
-
public void onActivityResumed(Activity activity) {
-
-
}
-
-
@Override
-
public void onActivityPaused(Activity activity) {
-
-
}
-
-
@Override
-
public void onActivityStopped(Activity activity) {
-
-
}
-
-
@Override
-
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
-
-
}
-
-
@Override
-
public void onActivityDestroyed(Activity activity) {
-
}
-
});
2、在监听回调的onActivityDestoryed方法中监控该Activity对象是否有被回收
这里首先是将Activity对象用弱引用封装,并给其指定一个ReferenceQueue,当一个obj被gc掉之后,其相应的包装类,即ref对象会被放入queue中,通过WeakReference + ReferenceQueue来判断对象是否被系统GC回收,WeakReference 创建时,可以传入一个 ReferenceQueue 对象,当被 WeakReference 引用的对象的生命周期结束,如果被GC回收了,GC会把该对象的弱引用对象添加到 ReferenceQueue 中,待ReferenceQueue处理。当 GC 过后弱引用一直不被加入 ReferenceQueue,它可能存在内存泄漏。当我们初步确定待分析对象未被GC回收时候,手动触发GC,一段时间后二次确认弱引用对象是否在ReferenceQueue队列中,如果没在,则说明确实发生了内存泄露。
3、如果确认发生了内存泄露,则导出内存中的对象内存分配情况,具体是通过Debug类的dumpHprofData()方法来生成hprof格式的堆转储文件;
4、借助开源项目Haha解析hprof文件,找出泄漏对象到GC Root的最短强引用路径
5、将内存泄漏解析结果展示给用户,具体是通过DisplayLeakService服务将内存泄漏结果以通知的形式发送给用户。
Java中四种引用
1、强引用
代码中普遍存在的类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象
2、软引用
描述有些还有用但并非必需的对象。在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。Java中的类SoftReference表示软引用
3、弱引用
描述非必需对象。被弱引用关联的对象只能生存到下一次垃圾回收之前,垃圾收集器工作之后,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。Java中的类WeakReference表示弱引用
4、虚引用
这个引用存在的唯一目的就是在这个对象被收集器回收时收到一个系统通知,被虚引用关联的对象,和其生存时间完全没关系,也无法通过虚引用来取得一个对象实例。Java中的类PhantomReference表示虚引用
Android开发中常用的框架有很多,比如:Retrofit、RxJava、EventBus、GreenDao、Glide等,框架的目的是避免我们重复制造轮子,很多功能我们直接使用框架即可达到,而不需要自己编写大量的代码实现。比较常用的框架有:
1、网络请求框架Retrofit;
Retrofit将Java接口转换为一个可以发起网络请求的对象,其内部还是会调用OkHttp来发起网络请求,Retrofit具有使用简单,可扩展性强等特点。
2、RxJava
RxJava是一款基于链式调用的处理异步操作的框架,通过其可以轻松的实现调度线程的切换,并且由于其基于链式调用的结构,可以让代码的逻辑变得清晰。Rxjava的核心原理是被观察者通过订阅观察者持有观察者的引用,然后被观察者就可以调用观察者的相应方法来完成事件的发射了。这是一种典型的观察者模式,Android中的setOnClickListener也是使用了观察者模式,View作为被观察者,通过setOnClickLisenter添加观察者OnClickListener,一旦按钮被点击,View就可以调用OnClickListener的相应方法发送点击事件。RxJava内部能够轻松的处理切换调度线程,其切换线程的原理其实和AsyncTask差不多,切换到子线程时,将要执行的代码封装到Runnable中,并放入线程池中执行,切换到主线程时,通过新建一个持有主线程Looper的Handler对象,并将要执行的操作放入到Message的Callback字段中(这个CallBack其实就是一个Runnable类型),然后利用Handler对象发送Message,这样Message的CallBack就会在主线程中执行。RxJava还提供了大量的操作符来方便开发者,比如just、map、filter、throttleFirst、throttleLast、dobounce、interval、timer等等,不同的操作符有不同的作用,其中有的是用来快速创建Observable对象的,有的是用来过滤发送事件的,还有的是用来转换事件类型的,并且我们还可以其自定义操作符。现在项目中使用到Rxjava的地方有和Retrofit结合使用处理网络请求、和RxBinding结合使用实现功能防抖和联系搜索。
3、EventBus
EventBus是一款用于Android的事件发布-订阅总线,通过其我们可以方便的让应用中的两个模块进行通信,比如两个Activity之间。EventBus实现的核心原理是注解和反射,我们通过EventBus注册一个对象后,EventBus会获得这个对象所属类,并通过反射获得其所有方法,然后解读方法上的注解以及方法的形参,根据注解就能知道这个方法是否需要监控EventBus的事件,如果需要,则将该方法存储起来,EventBus是以参数的类型来确定事件的类型的,EventBus会将事件类型一致(形参一致)的方法放到一个数组中,然后以事件类型为key,方法数组为value存储在Map中,这是为了方便通过事件类型快速找到要回调的方法。当EventBus发送事件后,其首先会根据参数的类型来确认事件类型,然后根据事件类型在Map中找到要回调的方法列表,最后遍历方法列表,在循环中遍历注册对象列表,如果注册对象有这个方法的话,则通过反射调用该方法。EventBus还支持指定回调方法的执行线程,其切换线程的思路和AsyncTask差不多,如果是切换到子线程,则将方法的执行封装成一个Runnable,然后在线程池中执行Runnable,如果是切换到主线程,则通过构造一个拥有主线程Looper的Handler对象,将方法的执行切换到主线程。
4、GreenDao
GreenDao是一款方便开发者使用的Android端数据库框架,它可以让开发者不需要写Sql语句来直接操作Sqlite,而是操作对象,所以GreenDao应该属于将对象映射到数据库表的框架,其内部会对Sqlite的使用进行封装,将数据库表映射为Class类,将数据库的数据行映射为对象,比如,我们可以利用其提供的Api利用Class创建一个数据库表,并且可以直接将一个对象插入到数据库的行中,其内部实现也很简单,就是利用反射解读Class成员变量以及获取设置对象的属性的方法,然后通过Sql语句来完成建表、数据的增删改查。
GreenDao在使用时需要注意的一个点就是数据库的升级,如果数据库表需要修改的话,比如添加一个字段,如果我们不处理升级的逻辑,在插入对象时就会抛出异常,提示数据库表中没有对应的字段,那么,当数据库需要升级时,我们应该如何处理呢?GreenDao提供了专门用来处理数据库升级和迁移的回调接口,在这个接口中,一旦两次数据库的版本不一致,就会调用该方法,我们可以在该方法中实现升级过程,具体思路如下:
a、将原来的数据库表备份一份作为临时表,并且临时表的名称需要修改,临时表中拥有原来表的全部数据;
b、将原来的表全部删除;
c、利用新的Class对象新建数据库表,这个数据库表中就包含了最新的修改;
d、将临时表中的内容通过Sql语句拷贝到新表中;
e、删除临时表。
5、Glide
Glide是一款Android端常用的图片加载框架,通过它能够轻松的实现图片的加载,下面总结一下它的优缺点
优点:
1、Glide支持多种格式图片的加载,比如png、jpg、gif、webP、缩略图,甚至是Video;
2、高效的缓存策略,Glid支持内存缓存和磁盘缓存,并且会根据你 ImageView 的大小来缓存相应大小的图片尺寸;
3、生命周期集成,在Glide中,图片的加载会跟随这Activity或者Fragment的生命周期进行相应的加载,停止等操作
具体的实现是,给Activity添加一个不显示界面的Fragment,当Fragment attach到Activity上后,其具有和Activity相同的生命周期,我们只需要监控该Fragment的生命周期函数,就能够知道Activity的生命周期了,然后做相应的处理,比如Activity退入后台后停止加载图片等。
4、内存开销相对较小,Glide默认的Bitmap格式是RGB_565格式,而Picasso默认的是ARGB_8888格式,这个内存开销要小一半
面向对象的三大特性为封装、继承以及多态,面向对象要遵循的六大设计原则为
1、单一职责原则
定义:类应该是一组相关性很高的函数和数据的封装
解读:我们在设计类时,应该将相关性很高的功能和数据封装在一起,一个类应该只处理某部分功能,而不应该将相关性不高的功能封装到一个类中,这会造成类变得臃肿,不利于维护和修改;
2、依赖倒置原则
定义:高层次模块不应该依赖低层次模块的具体实现,两者之间都应该依赖其抽象
解读:我们构建两个类之间的依赖关系时,应该尽量让它们依赖抽象,这样做的目的是为了解耦,抽象是指接口或者抽象类,其是不能被实例化的,我们可以在运行时动态替换成其具体实现类对象,这就让代码的扩展性大大提高。
3、开闭式原则
定义:软件中的对象(类、函数、模块等)对扩展应该是开放的,而对修改应该是关闭的
解读:软件中的对象对扩展是开放的,而对修改应该是关闭的,因为如果我们直接修改了一个类,那么就有可能对原来使用该类的代码造成影响,所以我们尽量采用扩展的方式来实现需求变化,扩展即继承该类,对需要修改的部分进行重写。要想做到对扩展是开放的,我们还是应该通过抽象,对于某些需要经常变化的函数建立抽象,让具体的实现类实现。
4、里氏替换原则
定义:所有使用基类的地方都应该能够透明的使用其子类
解读:里氏替换原则和依赖倒置原则是相互依赖的,一般使用到了依赖倒置原则的地方都会使用到里氏替换原则,它们的目的都是通过建立抽象来动态替换具体的实现类对象,以提高程序的可扩展性。
5、接口隔离原则
定义:类之间的依赖应该建立在最小的接口之上
解读:我们在设计接口时,应该将相关性很强的函数封装在一起,而且接口尽量小一点,不要太臃肿,这样做的目的是减小实现类的难度,试想一下,如果将很多相关性很弱的函数都封装在一个接口里,那么所以的实现类都需要实现所有的接口,这显然是不合理的;另外一点,这也可以减小两个通过接口依赖的类之间的耦合性。
6、迪米特原则
定义:一个模块应该对其他对象有最少的了解
解读:类之间的依赖应该尽量的少,两个类之间的依赖应该建立在最小的接口之上,这也就要求我们在设计类时应该尽量隐藏具体的实现细节,只提供出必要的Api,这样做的目的是为了降低程序的耦合性。
总结:面向对象的设计思想核心就是要做到高内聚、低耦合,以降低维护成本,提升可扩展性。高内聚的实现方法是通过将相关性很高的功能封装在一起,低耦合的实现方法是通过建立抽象,在运行时动态的替换成具体的实现类对象。
常用的设计模式
1、单例模式(饿汉式、懒汉式、懒汉式同步锁、双重校验锁、静态内部类、枚举等)
2、Builder模式(Retrofit、OkHttp、AlertDialog等)
3、观察者模式(RxJava、点击监听等)
4、适配器模式(Retrofit OkHttpCall的适配、RecyclerView Adapter的适配等)
5、享元模式(Handler中Message的回收使用、线程池中的线程回收使用等)
6、方法模板模式(View的draw方法)
7、组合模式(View控件树)
8、代理模式(Context)
9、动态代理模式(Retrofit接口的转换、插件化对AMS代理的hook)
10、责任链模式(View事件的分发、OkHttp对请求的处理)
10、工厂模式(Retrofit中CallAdapterFactory、ConverterFactory)
11、抽象工厂模式
MVP模式与MVC模式
需要知道它们的区别以及设计结构,以及有哪些优势等
首先说一下架构设计的目的:架构设计的目的是为了让程序各个模块尽量的做到高内聚、低耦合;高内聚既要遵循单一职责原理,一个模块应该是相关性很高的一组功能的封装,这样做的目的是为了让代码结构变得清晰;低耦合即各个模块之间的依赖应该尽量依赖接口,而不应该依赖具体的实现,并且只建立必须的依赖关系,这样做的目的是为了解耦,我们可以在运行是动态的替换某个模块,以提升程序的可扩展性。但设计不能违背目的,对于不同量级的工程,具体架构的实现方式必然是不同的,切忌犯为了设计而设计,为了架构而架构的毛病。
MVC模式:即Model-View-Controller,Model代表业务逻辑,View一般指布局文件,Controller一般指Activity或者Fragment。
MVP模式:即Model-View-Presenter,相对于MVC模式,其最大的改变就是Activity不再作为Controller,而是将控制工作交由给Presenter处理,Activity不直接依赖业务逻辑,一般来说,Activity中需要处理大量和UI相关的操作,比如加载布局、处理点击事件等,如果在让Activity作为Controller,其功能将会比较混乱,这不符合单一职责原则,我们通过添加Presenter层来接手Activity作为Controller的功能,Activity只需要专心处理UI方面的工作即可,其调用Presenter来分发业务逻辑,Presenter通过回调来通知Activity处理结果;并且,我们最好让Activity和Presenter相互依赖其抽象,而不要依赖具体实现,这样做的目的是为了更好的解耦,在功能有变化是能够快速替换Activity或Presenter。
MVP模式是目前Android开发中常用的模式,其目的是为了让代码变得更加清晰,并降低耦合性,在Android中,经常是Activity或者Fragment充当View的角色,View层只处理和UI相关的操作,其他的功能比如网络请求等则交由给Presenter去实现,而Model则用来封装实体类或者功能模块的封装(比如封装一个DataModel专门用来处理数据库操作),在MVP模式中,View和Presenter相互依赖,Presenter则依赖Model,View通过调用Presenter提供的接口实现功能,Presenter在实现功能后通过调用View的接口来实现界面的更新。设计良好的MVP模式,View和Presenter应该尽量依赖其抽象,这样就可以让View可以随时替换Presenter的具体实现类,同样Presenter也可以提供给不同的View使用。
概念:动态加载没有安装的APK并运行
Android插件化技术出现的目的有两个:
1、可以用来热修复;
2、可以减小安装包的大小。
插件化技术的实现关键点:
1、需要加载未安装APK的代码;
2、需要解读未安装APK中的资源;
3、绕过AMS中的检查机制,启动未安装APK中的四大组件。
实现方法:
1、加载APK的代码到ClassLoader中
Android提供了三种ClassLoader来加载APK,分别是PathClassLoader、URLClassLoader以及DexClassLoader,其中PathClassLoader是用来加载已经安装过的Apk的;URLClassLoader是用来加载jar包的,但是Apk中是dex文件,所以也不能使用;我们可以用DexClassLoader来加载Apk中的代码:
-
String apkPath =
"/sdcard/test.apk";
-
File dexOutputDir =
this.getDir(
"dex",
0);
-
DexClassLoader dexClassLoader =
new DexClassLoader(apkPath,
-
dexOutputDir.getAbsolutePath(),
null, ClassLoader.getSystemClassLoaer());
-
获得DexClassLoader后,我们就可以通过其来加载具体的类,比如:
-
Class activityClass = dexClassLoader.loadClass(
"com.liunian.TestActivity");
-
然后通过反射就能创建对象了:
-
Activity activity = (Activity) activityClass.newInstance();
2、加载Apk中的资源
在Android中,我们一般是通过Context来获得获得资源的,Context也叫做上下文环境,通过它可以应用可以和系统打交道,比如获得资源、系统服务等,但是我们现在的Apk是另外一个Apk,通过主Apk的Context并不能获得其资源。Android中有一个AssetManager类来管理Apk中的资源,通过AssetMananger生成一个Resources,我们就可以通过这个Resource来获得资源了,大致代码如下:
-
AssetManager assetManager = AssetManager.class.newInstance();
// 由于AssetManager的构造方法是hide方法,所有需要通过反射来创建AssetManager
-
Method addAssetPathMethod = AssetManager.class.getDeclaredMethod(
"addAssetPath", String.class);
// 获得AssetManager的addAssetPath方法,该方法是将Apk的资源添加到AssetManager中管理
-
addAssetPathMethod.invoke(assetManager, apkPath);
// 调用AssetManager的addAssetPath方法将Apk资源添加到AssetManager中管理
-
Resources resources =
new Resources(assetManager, getResources().getDisplayMetrics(), getResources().getConfiguration());
// 通过AssetManager,屏幕分辨率,手机配置信息来构造一个Resources对象
这样,我们就构建了能够加载插件Apk中资源的Resources对象了,比如,我们可以通过
Drawable testImageDrawable = resources.getDrawable(R.drawable.test_image);
获得插件中的图片资源。
3、绕过AMS的检查机制,启动未安装的四大组件
a、代理Activity类调用插件Activity类的方法
通过在主Apk中创建代理Activity类,暂记为ProxyActivity,插件Apk中要启动的Activity记为PluginActivity,因为ProxyActivity是在主Apk中注册过的,
所以可以在应用程序中直接启动它,我们在启动PluginActivity时,首先启动ProxyActivity,并将PluginActivity的类名信息存入到intent中,在PluginActivity的onCreate方法中(也可以提前加载插件Apk的代码和资源信息,并放入缓存统一管理),首先通过DexClassLoader加载插件apk的dex文件,然后构造一个AssetManager并调用其addAssetPath方法管理插件资源,最后通过AssetManager构造一个Resources来获取插件资源,接着,我们通过反射构造一个PluginActivity对象,并将PluginActivity的所有生命周期方法重写,包括onCreate、onResume、onDestory等方法,在这些方法中,统一调用PluginActivity对应的生命周期方法,这样做的目的是让系统在回调ProxyActivity的生命周期方法时,能够调用到PluginActivity中对应的方法,也就执行到了PluginActivity中对应的代码,并且,我们需要重写PluginActivity的getResources方法,将返回的Resources对象替换为我们构造的插件Resources,这样做的目的是为了让ProxyActivity加载资源时,能够使用插件的Resources对象加载资源;最后,我们还有替换掉PluginActivity中的一些方法,比如setContentView、getResources等,将其实现替换为调用ProxyActivity的对应方法,这是因为现在的PluginActivity并不是传统意义上的Activity,其只是一个代码模块的封装体,它的作用就是封装代码供ProxyActivity调用,所以对于设置布局,获取资源等方法需要调用ProxyActivity相应的函数。可以看到,通过这种方式实现的插件化的核心思想就是代理,但是插件App编写比较复杂,并不能让开发插件App和开发普通App一样,其代表实现是DynamicLoadApk。
b、欺上瞒下,通过Hook欺骗AMS启动的Activity为已经注册的Activity(代理Activity),并在AMS通过校验后,替换代理Activity为插件Activity。
这种实现方式比较巧妙,首先,我们来了解一下Activity的启动流程,我们知道,Android中所有的Activity都是由AMS管理的,Activity的startActivity方法首先会调用Instrumentation的execStartActivity方法,然后调用AMP的startActivity方法,AMS的startActivity方法首先会利用ApplicationThreadProxy调用当前Activity的onPaused方法,然后调用PMS检测Activity是在哪个应用中注册的,如果找不到则直接报错,如果找到了,在判断Activity所依附的应用进程是否存在,如果不存在则调用startProcess方法向Zygote进程发送创建进程消息,Zygote进程收到消息后,通过fork创建进程,并调用其ActivityThread的main方法初始化应用信息(包括LoadApk中的类加载器,将ApplicationThreadProxy发送给AMS等)和创建Loop循环,接着,AMS将启动Activity信息封装在ActivityRecord中,并将其放入ActivityStack中管理,ActivityRecord中还存储着唯一标记Activity的Token对象,Token是一个Binder对象,可以跨进程通信,通过它可以拿到ActivityRecord中的信息;再接着,AMS调用ApplicationThreadProxy的scheduleLaunchActivity方法,并将Token的代理传递ApplicationThread,ApplicationThread的scheduleLaunchActivity根据Token可以获得Activity的信息,然后发送启动Activity消息给Handler(H),让启动Activity在主线程中处理,处理过程首先是通过反射创建Activity实例,然后调用其onCreate、onStart、onResume等生命周期方法,而创建Activity实例,调用其onCreate、onStart、onResume等生命周期方法都是封装在Instrumentation的方法中。
了解了Activity的启动流程,我们就可以知道如何欺骗AMS了,
1、注册代理Activity
首先,我们在主应用的AndroidManifest中注册代理Activity(Activity有四种启动模式,standard、singleTop、singleTask、singleInstance,其中标准模式每次都会创建新的Activity实例、其他三种模式都有可能要复用Activity实例,所以代理Activity standard模式只需要注册一个,其他三种模式需要注册多个,以免发生混乱);
2、欺骗AMS,绕过检测
应用启动Activity和AMS交互的流程是封装在Instrumentation的execStartActivity方法中,Instrumentation对象是在ActivityThread中创建的,并且所有的Activity都持有其引用,我们只需要在Application的onCreate方法中(这个时候还没有Activity创建)替换掉ActivityThread的Instrumentation对象,那么应用中所有Activity持有的Instrumentation都是我们替换过的Instrumentation,我们自定义一个Instrumentation类,重写其execStartActivity方法,在execStartActivity方法中获得要启动的Activity的启动模式,并根据启动模式找到合适的代理Activity,然后调用intent的setClassName将启动的Activity名称替换为代理Activity名,这样AMS检查时发现代理Activity是在主Apk中注册过的,也就能校验通过;
3、hook创建Activity的方法
Activity实例的创建封装在Instrumentation的newActivity方法中,我们重写自定义的Instrumentation的newActivity方法,将传入的Class(代理Activity类)替换成插件Activity类,这样在创建Activity时就会创建插件Activity的实例,于是真正启动的就是插件Activity了。Hook方式不仅可以Hook Instrumentation,还可以Hook AMS的代理等,总之只要是在这条线欺骗过了AMS即可。
4、访问插件资源
有两种方式访问:
1、每个插件都单独创建AssetManager,这样每个插件的资源都是单独管理的,不会产生资源冲突,但是访问资源会比较麻烦,需要替换掉Activity的mContext、mResources等对象,让它们访问资源时使用插件资源的Resources对象,并且主Apk、不同插件之间的资源无法共享。
2、将插件资源添加到主Apk的AssetManager中,这样就将所有的资源都添加到了主Apk的AssetManager中管理,主Apk、不同插件之间能够共享资源,但是会有资源冲突问题,这是因为编译Apk会对资源进行编译生成id,应用可以通过id去访问资源,如果两个apk的资源id一样,那就会产生冲突,如何解决资源冲突问题呢?
我们知道android apk编译资源用的是aapt工具,aapt在编译资源时,为资源生成的id的格式如下:PackageId+TypeId+EntryId
PackageId:占两个字节,是包的Id值,如果是系统应用的话,这个值0x01,如果是第三方应用的话,这个值默认是0x7f
TypeId:资源类型的Id值,一般Android中有这几个类型:attr,drawable,layout,dimen,string,style等,而且这些类型的值是从1开始逐渐递增的,而且顺序不能改变,attr=0x01,drawable=0x02....它占用两个字节
EntryId:实体资源Id,从0开始,依次递增,它占用四个字节
一般有两种思路:
1、修改aapt源码,定制aapt工具,编译期间修改PackageId字段
aapt的源码在framework层实现,我们可以修改源码,编译自定义aapt工具,替换Android原生的aapt,将插件资源的packageId字段修改为和主apk不同的字段
2、修改aapt的产物,即编译后期重新整理插件Apk的资源,重新编排Id
VirtualApk采用的就是这个方案,我们来看一下它的实现:
VirtualApk hook了ProcessAndroidResourcestask。这个task是用来编译Android资源的。VirtualApk拿到这个task的输出结果,做了以下处理:
1、根据编译产生的R.txt文件收集插件中所有的资源
2、根据编译产生的R.txt文件收集宿主apk中的所有资源
3、过滤插件资源:过滤掉在宿主中已经存在的资源(同名的)
4、重新设置插件资源的资源ID(主要是修改packageId)
5、删除掉插件资源目录下前面已经被过滤掉的资源
6、重新编排插件resources.arsc文件中插件资源ID为新设置的资源ID
7、重新产生R.java文件
除Activity外,其他组件的插件化:
Service:通过代理Service的方式去分发;主进程和其他进程,VirtualAPK使用了两个代理Service。
BroadcastReceiver:静态转动态。
ContentProvider:通过一个代理Provider进行分发。
插件Activity实例是如何和token关联的
token是一个Binder对象,它是由AMS创建的,应用进程获得的是token的代理,通过token的代理可以获得Activity的信息,应用进程在创建Activity实例后,会将token、Activity实例、Activity信息封装成ActivityClientRecord,然后以token为key,以ActivityClientRecord为value,存入到Map中,这样就可以通过token快速的找到Activity实例了,在AMS中,其实是不知道插件Activity的存在的,它一直以为管理的是代理Activity,然后通过binder机制和应用进程通信,发送token让主进程执行相应操作,然而在主进程中,根据token找到的Activity其实是插件的Activity实例,所以操作的也就是插件Activity了。
组件化:组件化架构的目标是告别结构臃肿,让各个业务变得相对独立,业务组件在组件模式下可以独立开发,而在集成模式下又可以变为arr包集成到“app壳工程”中,组成一个完整功能的APP;
从组件化工程模型中可以看到,业务组件之间是独立的,没有关联的,这些业务组件在集成模式下是一个个library,被app壳工程所依赖,组成一个具有完整业务功能的APP应用,但是在组件开发模式下,业务组件又变成了一个个application,它们可以独立开发和调试,由于在组件开发模式下,业务组件们的代码量相比于完整的项目差了很远,因此在运行时可以显著减少编译时间。
组件化具体的实现:https://www.cnblogs.com/ldq2016/p/9073105.html
为什么要实现组件化,两个字:解耦。通过功能划分将应用分为不同的模块,每个模块都相对独立,对于大型项目来说,解决了各个业务模块之间代码耦合的问题,降低应用的维护成本;并且各个业务模块之间可以单独编译运行,提高编译效率,也使得开发者只需要关注自己的模块。
1、划分功能模块
首先,我们需要抽取和业务无关的公共模块,比如网络请求、公共控件库等,公共模块是其他业务模块都需要依赖的模块,然后根据业务功能将应用划分为不同的模块,各个模块之间不直接依赖,然后创建壳app壳工程,壳工程没有具体的内容,它的作用是组装其他所有的业务模块。
2、业务模块如何切换作为集成模块以及单独的App运行
Android提供了Moudle来创建一个应用模块,这个Moudle根据配置,可以作为集成模块集成到App中,也可以单独作为App运行,具体配置是在build.gradle中:
apply plugin: 'com.android.application' // 将Moudle作为单独的App处理 apply plugin: 'com.android.library' // 将Moudle作为集成模块处理,将编译生成aar文件供应用程序依赖使用
我们可以在项目的根目录的gradle.properties文件中定义是否使用组件开发,gradle.properties文件中定义值是所有模块都能访问到的,需要使用组件开发的模块读取这个值,如果为true则采用App模式,否则采用library模式:
-
// gradle.properties中定义开关,是否采用组件模式
-
isModule=
false
-
// 各个业务模块的build.gradle文件中根据isMoudle的值配置采用哪种模式
-
if (isModule.toBoolean()) {
-
apply plugin:
'com.android.application'
-
}
else {
-
apply plugin:
'com.android.library'
-
}
3、AndroidManifest文件合并的问题
业务模块在作为单独App运行时需要定义Application、默认的启动Activity等,而作为library集成到壳App中时是不能定义这些内容的,这就需要我们定义两套AndroidManifestt文件,不同的情况使用不同的AndroidManifest文件,
在build.gradle文件中可以指定采用那个Manifest文件:
-
sourceSets {
-
main {
-
if (isModule.toBoolean()) {
-
manifest.srcFile
'src/main/module/AndroidManifest.xml'
-
}
else {
-
manifest.srcFile
'src/main/AndroidManifest.xml'
-
}
-
}
-
}
4、业务模块中Java文件屏蔽问题
业务模块作为App时需要指定Application,一般来说这个自定义的Application要继承BaseApplication,BaseApplication是所有模块自定义Application的父类,在Common模块中定义,定义BaseApplication的目的是为了让所有的业务模块都能使用Application中定义的一些公共功能,而且,我们还需要指定默认的启动的Activity,这些都需要创建对应的Java文件,而这些Java文件在模块作为library时是不需要的,build.gradle中可以指定屏幕某个目录下的Java文件,让其不参与编译,所以,我们可以将这些作为library时不需要的java文件放在一个目录下,比如debug目录,然后根据情况屏蔽:
-
sourceSets {
-
main {
-
if (isModule.toBoolean()) {
-
manifest.srcFile
'src/main/module/AndroidManifest.xml'
-
}
else {
-
manifest.srcFile
'src/main/AndroidManifest.xml'
-
//集成开发模式下排除debug文件夹中的所有Java文件
-
java {
-
exclude
'debug/**'
-
}
-
}
-
}
-
}
5、组件之间资源冲突问题
不同组件之间如果定义了名称相同的资源,那么在打包集成时就会产生资源冲突,最简单的办法是采用不同的命名方式来定义各个组件中的资源,比如组件中的资源都以组件名开头,如果觉得麻烦,我们可以写一个脚本来完成重命名工作,或者开发Android Studio的插件来完成。
6、组件之间通信的问题
比如一个组件中的数据改变了,需要通知到另一个组件刷新页面,这个可以采用EventBus或者广播来处理
组件之间Activity的跳转采用ARouter路由框架来实现,不采用intent.setClassName的方式是为了让模块之间不产生之间依赖。其核心原理也比较简单,即编译期间解读注解,扫描所有添加@Route注解的Activity类,然后将Route注解中的path地址和Activity.class映射关系保存到它自己生成的java文件中,然后在App初始化时初始化映射关系,这样就可以通过path来找到对应的Activity的Class类了,然后调用startActivity启动对应Activity。
通过上面可知,组件化的目的是为了解耦,将各个业务模块都独立出来,避免相互依赖,并且可以作为单独的App运行,但是打包时还是会作为library打包进主应用,而插件化的和组件化相比,也会将不同的业务打包成不同的App,但是其并不会在编译时打包进主应用,而是通过动态加载的技术来运行未安装的Apk。
Apk是Android应用程序包,其封装了一个应用程序运行的代码以及资源,那么Android是怎样将应用代码编译打包成Apk的呢?首先,我们来看一下Android Apk中有哪些内容:
1、res目录(用来存放应用资源,除图片和res/raw目录下的资源外,其余的资源都会由aapt工具编译成二进制文件)
2、resources.arsc(resources.arsc文件的作用就是通过ID,根据不同的配置索引到最佳的资源显示在UI中)
3、AndroidManifest.xml(会将原始的AndroidManifest.xml文件转换成二进制文件,AndroidManifest.xml文件中定义了应用的名字、版本、权限、注册的四大组件等信息)
4、assets目录(存放一些不需要编译的资源,比如.ttf文件等)
5、lib目录(存放一些底层依赖库,比如.so文件等)
6、classes.dex(用来存放应用代码,Android虚拟机不能识别jar文件,而是识别dex文件)
7、META-INF目录(存放签名文件)
Apk其实是一个zip文件,它就是将上面的这些内容压缩成一个包。
打包流程:
1、打包资源文件,生成R.java文件。
打包资源的工具aapt位于android-sdk/platform-tools目录下,在这个过程中,AndroidManifest.xml文件和res目录下的大部分文件都会被编译成二进制文件,并且生成索引id,首先对res目录下的资源进行处理,该类资源在APP打包前大多会被编译,变成二进制文件,并会为每个该类文件赋予一个resource id。对于该类资源的访问,应用层代码则是通过resource id进行访问的,
处理完成后会根据编译的结果生成resources.arsc文件,resources.arsc文件中定义了资源的映射关系,然后生成R.java文件,R.java文件中定义了资源文件的id,应用通过资源文件id在resources.arsc文件查找对应的资源位置,就能够访问到资源了。
2、处理aidl文件,生成相应的java文件
aidl文件是Android提供的为了方便应用开发者开发进程间通信模块的,我们按照格式写好aidl文件后,Android编译时会根据aidl文件生成对应的java文件,这一步使用到的工具为
aidl,位于android-sdk/platform-tools目录下
3、编译工程源码,生成class文件
这一步主要是调用javac编译源码目录的所有java文件,生成对于的classes文件。如果使用到了JNI开发,还需要用NDK来编译native代码,生成对应的so文件。
4、转换所有的class文件,生成dex文件
我们知道,Android虚拟机的可执行文件为dex文件(Java虚拟机为jar文件),这一步,我们采用android-sdk/platform-toos/dx工具将所有的class文件转换为dex文件,dex文件相对于class文件来说,压缩常量池、消除多余信息,会相对精简,并且dex文件运行在
Android虚拟机上,其运行速度相对于jvm更快。
5、打包生成APK文件
打包工具为android-sdk/tools/apkbuilder,其首先会以resources.arsc文件为基础生成apk文件,然后添加工程的资源到apk文件中,
处理内容包括AndroidManifest.xml、res和assets目录,接着添加dex文件到apk中,接着将lib目录下的依赖库添加到apk的lib目录下,最后关闭apk文件。
6、对APK文件进行签名
一旦APK文件生成,它必须被签名才能被按照在设备上,在开发过程中,主要用到的就是两种签名的keystore。一种是
用于调试的debug.keystore,它主要用于调试。另一种就是用于release版本的keystore。
7、对签名后的APK文件进行对齐处理
如果我们发布的apk是正式版的话,就必须对APK进行对其处理,用到的工具是zipalign,
对齐的主要过程是将APK包中的所有资源文件距离文件起始偏移为4字节的整数倍,这样通过内存映射访问apk文件时的速度会更快。
App为什么需要签名:
1、应用程序升级,验证app的唯一性,包名和签名都一致才允许升级。Android 签名是为了保证应用发布者是本人。
2、应用程序模块化,可以模块化部署多个应用到一个进程,只要他们的签名一样。
3、代码或者数据共享,同一个签名有相同的权限,可以共享数据和代码。
1、代码混淆
借助SDK中自带的混淆工具Proguard,我们可以轻松的实现代码混淆,只需要在build.gradle文件中配置对应的文件文件即可,
代码混淆会将应用代码中的类名、字段名、方法名等进行重命名,变成一些难以理解的字符串,而且代码混淆可以减少APK的大小,
主要是因为重新命名的字符串一般都比较简短。当然,我们也不能对所有的代码都进行混淆,比如自定义控件类、网络数据实体Bean类等,
自定义View类混淆后,在解析XML布局中定义的自定义View类时,将找不到它;网络数据实体Bean类一般都是通过Gson解析工具将Gson数据对应到
对象,如果混淆了,字段名和方法名也就变了,Gson解析工具将找不到对应的字段。
2、DexGuard
DexGuard是收费的,是在Proguard基础上,加入了更多的保护措施。使用DexGuard混淆后,生成的apk文件无法通过apktool反编译了,而通过Proguard
混淆过的代码,是可以通过apktool反编译的,虽然查看到的代码是混淆过的,但是程序的大致逻辑还是一览无余。
3、代码加固
除了常规的混淆外,我们还可以使用加固工具对apk进行加固,市面上的加固工具比较多,比如360加固、百度加固、梆梆加固等。混淆和加固是Android中实现应用安全的基本手段,但是这些还远远不够,我们可以通过apktool、jd-gui、jadx等工具查看混淆过的apk。对于加固过的应用,我们也有方法查看到其代码,最简单的加固方法是将要加固的apk中的dex文件加密后添加到壳Apk中的dex文件后面,并重新打包,然后在应用启动壳Apk中的Application,在Application的attachBaseContext方法中解密实际apk的dex并放在固定目录下,然后通过DexClassLoader加载实际apk的dex文件,并替换LoadApk中的ClassLoader,在Application的onCreate方法中通过反射创建实际Apk的Application并调用其相应方法。所以,我们只要找到解密后的实际Apk的dex文件,即可查看代码,有的加固方式很难找到解密后的dex文件,我们可以通过导出apk运行时内存中ClassLoader的代码,然后通过反编译技术即可查看代码。
Apk的加固流程:
首先了解三个概念
要加固的apk,记为source.apk
壳apk:记为shell.apk
加固程序
1、反编译source.apk,获得source.dex文件;
2、反编译shell.apk,获得shell.dex文件;
3、运行加固程序按照加密算法加密source.dex文件;
4、将加密后的source.dex追加到shell.dex文件后,生成新的dex文件,记为new.dex;
5、替换source.apk中的dex文件为new.dex文件;
6、修改source.apk的AndroidManifest文件中定义的启动Application为shell.apk中自定义的ShellApplication(可以将原始的Application类名保存meta-data信息中);
7、删除source.apk中的META-INFO信息;
8、重新签名source.apk,生成脱壳apk,记为jiagu.apk
9、运行jiagu.apk,会执行ShellApplitcation,在ShellApplication的attachBaseContext方法中读取dex信息,获取加密过的dex,然后按照解密算法解密dex文件(解密算法最好定义在native中实现),获得source.dex文件并放在固定目录;
10、在ShellApplication的onCreate方法中,通过DexClassLoader加载source.dex文件,并通过反射替换掉LoadApk中的classLoader,然后加载原始的Application类,并通过反射创建原始Application对象,然后,我们再通过反射替换掉ActivityThread中定义的Application为
原始的Application对象,并调用其onCreate方法,这样App就运行到source.apk的逻辑了。
4、日志清理
黑客为了了解我们代码的逻辑,很多时候都是通过查看打印日志的方式,所以我们要构建自己的日志系统,可以在Release包中屏蔽掉所有的日志信息。
5、网络安全
一般Android的网络请求是基于Http协议的,而Http协议是明文传输的,攻击者可以通过拦截网络请求轻松的获得传输数据,比如常见的抓包工具fiddler和charles。
6、存储安全
对于App中敏感数据的存储,例如:token,身份证,用户名,密码等。
这些信息如果明文存储在本地是非常危险的,手机被Root之后无论你存储在什么地方都会被看到。
所以我们需要采用加密算法对这些敏感的数据进行加密,然后存储在本地,在使用时根据解密算法进行解密获得原始数据。
对于加密解密算法应该放在Native代码中,Native代码会被编译成so文件,相对Java代码来说,其反编译成本更高。
7、屏蔽常用的Hook框架
对于Hook框架,其能够拦截应用中所有方法的调用,并替换成自己的逻辑,常用的Hook框架有Xposed、substrate、frida等,很多黑客都是利用Hook框架来分析App的逻辑以及改变App的执行流程,以Xposed为例,我们就可以通过Xposed hook应用中所有的方法,网上的微信抢红包、支付宝自动生成收款二维码等很多都是通过Xposed实现的,所以,我们需要屏蔽常见的Hook框架,以检测Xposed框架为例,我们可以通过判断线程的调用堆栈信息里面是否有Xposed字符来判断手机是否启用了Xposed,或者通过查看安装包来检测是否安装了Xposed框架,我们最好将检测的代码放在Native层,Native层的代码会被编译成so库,相对来说反编译的成本较高,而Java层的代码反编译成本比较低。
8、传输安全
可参考:https://blog.csdn.net/xiejun_xiaowu/article/details/53069217
我们可以对要传输的数据进行加密,然后在发送给服务端,服务端获得数据后再对数据进行解密,这里采用对称性加密算法对数据进行加密和解密,客户端将加密的算法和密钥最好封装在native代码中,以免被反编译查看到。
对称性加密算法和非对称性加密算法:
对称性加密算法采用的是同一个密钥进行数据的加密和解密(或者加密与解密的密钥能够相互推导出来)
非对称性加密算法:非对称性加密算法采用了一对密钥,公钥和私钥,采用公钥加密的数据只能使用私钥才能解密,采用私钥加密的数据只能使用公钥才能解密。
两者对比:
对称性加密算法因为采用相同的密钥加密和解密,其效率是要高于非对称性加密算法的,但是其安全性较差,因为密钥在传输过程中可能泄露,一旦泄露了数据就有可能被破解。
非对称性加密算法加密和解密采用不同的密钥,加密方生成公钥和私钥后,采用私钥对数据进行加密,并将公钥发送给接收方,接收方获得数据后采用公钥对数据进行解密,如果要发送数据给对方,可以利用公钥对数据进行加密,接收方采用私钥对数据进行解密。
非对称性加密算法也有可能泄露信息,在密钥传输的过程中可能被第三方劫持,一旦被劫持,第三方可以将密钥替换成自己的密钥,这样客户端收到的也就是第三方的密钥,然后用第三方的密钥对数据进行加密并发送给对方,第三方只要劫持了发送的信息,然后采用
对应的私钥进行解密即可获得数据。
混合加密算法:混合加密算法综合使用了对称性加密算法和非对称性加密算法,首先服务端根据对称性加密算法生成公钥和私钥,客户端发送数据时先向服务端请求公钥,然后采用对称性加密算法对传输的数据进行加密,并用非对称性加密算法的公钥对对象性加密的密钥进行
加密,再将加密的密钥和数据传送给服务端,这样在传输过程中就算数据被劫持了,劫持者没有服务端的私钥,也就无法破解对称性加密的密钥,从而无法解密密钥,而服务端在收到数据后,会用私钥对对称性加密的密钥进行解密,获得对称性密钥后,再对数据进行解密。
总结一点,混合加密采用对称性加密算法对数据进行加密,但是采用非对称性算法对密钥进行了加密,混合加密解决了对称性密钥算法密钥被劫持造成数据泄露的问题,并且由于对数据的加密和解密采用的是对称性加密算法,其效率也会比较高。
但是混合加密同样有可能造成数据泄露,劫持者可以劫持服务端发送给客户端的公钥,并将其替换掉,这样客户端收到的公钥就是劫持者的了,之后劫持者拦截发送的数据,采用私钥解密对称性加密的密钥,就可以对数据进行解密了。
混合加密泄露信息的情况:
1、客户端向服务端请求公钥
2、服务端收到请求后发送公钥给客户端
3、服务端发送给客户端的公钥被中间人拦截了
4、中间人将自己的公钥发送给客户端
5、客户端利用服务端的公钥对对称加密的密钥进行加密并传送给服务端
6、中间人拦截客户端发送给服务端的加密后的密钥
7、由于公钥被篡改成中间人的公钥,中间人可以通过私钥解密获得对称加密的密钥
8、中间人采用服务端的公钥对对称加密的密钥进行加密并发送给服务端
9、服务端收到密钥后用私钥解密获得对称性加密密钥
10、服务端和客户端通过对称性加密密钥进行数据的加密解密
11、中间人可以拦截发送数据,由于中间人之前获得了对称性加密密钥,所以可以对数据进行解密,这样就造成了数据泄露
综上可以看出,非对称性加密算法的安全性较高,但是由于数据发送过程中可能被劫持,公钥可能被替换掉,造成数据泄露。非对称性加密之所以不安全,是因为客户端无法知道传过来的公钥是否真的是服务端的公钥,所以解决办法就是要确保
公钥确实是服务端传递过来的。
数字证书:
上面说到过,非对称性加密算法之所以不安全,是因为客户端无法知道公钥的真实性,因此,我们需要找到一种策略来保证公钥的真实性。解决办法就是采用数字证书。具体是这样的:
1、首先我们需要找到具有公信力,通信双方都能够信任的认证机构,认证机构专门用来办理证书;
2、服务端首先向认证机构注册信息,获得认证证书;
3、认证机构将服务端的信息(比如网址等)、服务端公钥等通过Hash算法生成信息摘要(这个Hash算法是公开的);
4、认证机构也会使用非对称性加密算法,将生成的信息摘要通过私钥进行加密生成数字签名;
5、认证机构将服务端的信息、公钥、认证机构信息、数字签名等组合在一起,生成数字证书;
6、服务端获得数字证书后存储在本地;
7、客户端向服务端发送请求,首先需要请求服务端的数字证书;
8、获得数字证书后,客户端从浏览器或操作系统中查找能够解读该认证机构颁发的证书的公钥,这里说明一下,权威的认证机构就那么几个,浏览器或操作系统中会内置解读这些认证机构颁发的证书的公钥;如果找不到,说明不是权威机构颁发的认证证书,则弹出警告,如果找到了,则获得能够解读认证证书的公钥;
9、客户端根据认证证书的公钥对证书中的数字签名进行解密,获得信息摘要;
10、客户端根据公开的Hash算法将服务端信息、服务端公钥等通过公开的Hash算法生成信息摘要,然后判断将两份信息摘要进行对比,如果一致则说明证书内容没有被篡改过;
11、客户端判断发送的网络请求Host是否和证书里面的Host一致,如果一致则说明进行的网络请求是经过认证中心认证过的;
数字证书通过第三方权威机构认证的方式来确保公钥的正确性,其通过私钥对信息摘要进行加密,并将公钥放在浏览器或操作系统中,只有浏览器或者操作系统信任的认证机构才会拥有解读证书的权利,客户端通过公钥解密信息摘要后,通过公开的Hash算法来生成信息摘要,对比信息摘要就能知道证书信息是否被篡改。可以发现,认证证书的核心是要确保认证证书的权威性。考虑一下下面两种情况:
1、认证证书被第三方劫持并篡改信息
如果认证证书信息被篡改了,通过相同的Hash算法生成的两份信息摘要将不相同,这会被检查出来;
2、认证证书被第三方替换成非权威机构发布的证书
这种情况浏览器或操作系统中将找不到解读该认证证书的公钥,弹出警告;
3、认证证书被第三方替换成权威机构发布的证书
这种情况浏览器或操作系统虽然能够找到解读该证书的公钥,但是在网络请求时对比请求网址将不一样,也能被检查出,这是因为认证机构有严格的规定,一个网址对应着一份认证证书
Https采用的是混合加密加认证证书的方式来确保网络的安全性:
1、服务端将对称性加密算法的公钥、服务端信息等提交给认证机构;
2、认证机构对公钥、服务端信息等根据公开的Hash算法生成信息摘要;
3、认证机构将信息摘要用认证机构的私钥进行加密生成数字签名;
4、认证机构将数字签名、服务端公钥、服务端信息、认证机构信息等打包成认证证书;
5、服务端将认证证书存储在本地,等待客户端请求;
6、客户端向服务端请求数据首先要经过SSL的握手,首先客户端发出请求(Client Hello),请求的内容包括支持的SSL协议版本、一个客户端生成的随机数(稍后用来生成对称性加密密钥)、支持的加密方法、支持的压缩方法;
7、服务端回应请求(Server Hello),回应内容包括:确认SSL协议版本、一个服务端生成的随机数(稍后用来生成对称性加密密钥)、确认使用的加密方法、服务器认证证书;
8、客户端获得认证证书后,通过浏览器或者操作系统验证证书的权威性,并获得解读证书的公钥;
9、客户端通过公钥解读证书中的数字签名,获得信息摘要;
10、客户端通过公开的Hash算法将证书中的服务端信息、服务端公钥等生成信息摘要;
11、客户端对比生成的信息摘要和解密出来的信息摘要是否一致,如果一致则说明证书没有被修改;
12、客户端判断证书中的网址和请求网址是否一致,如果一致则说明证书是服务端证书;
13、客户端从证书中获得服务端公钥;
14、客户端回应,回应内容包括:一个随机数(pre-master key,该随机数用服务器公钥加密,防止被窃听)、编码改变通知(表示随后的信息都将用双方商定的加密方法和密钥发送)、客户端握手结束通知(表示客户端的握手阶段已经结束,这一项同时也是前面发送的所有内容的hash值,用来供服务器校验);
15、服务端用私钥解密pre-master key,至此,客户端和服务端之间总共有三个随机数,接着双方就用事先商定的密钥生成方法,以这三个随机数为参数,各自生成本次会话所用的同一把"会话密钥",即对称性加密算法密钥;
16、服务器的最后回应,服务器收到客户端的第三个随机数pre-master key之后,计算生成本次会话所用的"会话密钥"。然后,向客户端最后发送下面信息:编码改变通知(表示随后的信息都将用双方商定的加密方法和密钥发送)、服务器握手结束通知(表示服务器的握手阶段已经结束、这一项同时也是前面发送的所有内容的hash值,用来供客户端校验);
17、客户端和服务端采用协商的对称性加密算法进行数据传输。
SSL的握手协议:用来协商通信过程中使用的加密套件(加密算法、密钥交换算法和MAC算法等)、在服务器和客户端之间安全地交换密钥、实现服务器和客户端的身份验证。
为什么一定要用三个随机数,来生成"会话密钥"?
"不管是客户端还是服务器,都需要随机数,这样生成的密钥才不会每次都一样。由于SSL协议中证书是静态的,因此十分有必要引入一种随机因素来保证协商出来的密钥的随机性。对于RSA密钥交换算法来说,pre-master-key本身就是一个随机数,再加上hello消息中的随机,三个随机数通过一个密钥导出器最终导出一个对称密钥。pre master的存在在于SSL协议不信任每个主机都能产生完全随机的随机数,如果随机数不随机,那么pre master secret就有可能被猜出来,那么仅适用pre master secret作为密钥就不合适了,因此必须引入新的随机因素,那么客户端和服务器加上pre master secret三个随机数一同生成的密钥就不容易被猜出了,一个伪随机可能完全不随机,可是是三个伪随机就十分接近随机了,每增加一个自由度,随机性增加的可不是一。"
单向加密:单向加密一般指的是根据加密后的数据无法推导出原始数据,单向机密的作用是用来验证数据是否有被篡改,只要通信双方都使用相同的单向加密算法计算出加密后的数据,然后对比加密后的数据是否一致就可以判断数据是否又被篡改了,单向加密一般具有效率高、压缩性、不可逆等特点,比如Https中生成消息摘要的Hash算法就可以看做是一种单向加密算法,还有比如MD5算法。
MD5算法,MD5是生成消息摘要的一种算法,它是一种单向的加密算法,也就是加密后的数据无法被解密,所以其一般是用来验证数据是否有被篡改,即通信双方都使用相同的MD5算法对数据进行加密,生成消息摘要,对比消息摘要是否一致就可以知道数据是否又被篡改了。
MD5算法有如下特点:
1、压缩性:任意长度的数据,其计算出的MD5值长度都是一样的;
2、不可逆性:通过加密后MD5值无法推导出原始数据;
3、抗修改性:不同的元素数据对应着不同的MD5值(很小概率会重合,可以忽略),哪怕只是做了很小的改动,MD5也会不同;
4、效率高:从原始数据计算出MD5值比较容易,不需要耗费太多资源;
MD5一般是用来验证数据是否被篡改,比如,在传输网络数据时,我们就可以利用MD5算法对传输数据进行加密,生成对应的MD5值,然后传输数据时将原始数据和MD5值一起传递给对方,对方采用相同的MD5算法对原始数据进行计算,获得MD5值,然后对比两个MD5值是否一致即可
知道数据是否被篡改了。
MD5可能会被破解,这是因为MD5算法是公开的算法,每一个原始数据对应的MD5值是固定的,比如要通过MD5值匹配到用户的密码,最简单的有两种方法:
1、采用暴力枚举所有可能的用户密码,通过MD5算法计算MD5值,看是否和要匹配的MD5值一致,如果一致则很可能是密码,这种方法很消耗运算时间;
2、将常见的用户密码通过MD5算法计算出MD5值,并存入映射表中,将要匹配的MD5值和映射表中的MD5值匹配,如果匹配成功,则对应的原文很可能是密码,这种方法要消耗较多的存储空间;
当然,还有一些其他的高级破解方法,没有深入研究。
既然采用MD5算法很可能会被破解,那么有没有什么方法可以预防呢?一般可以采用两种方法:
1、进行多次MD5运算,即首先将原始数据进行MD5运算,获得MD5值,再对MD5值再次进行运算获得新的MD5值,验证方采用相对次数的MD5运算来计算MD5值,这种方式可以提升破解难度;
2、MD5加盐,即将原始数据加上固定的字符串然后再进行加密,验证法采用相同的机制生成MD5验证,或者可以在生成的MD5值前后加上随机数,以提升破解难度,验证方验证时去除随机数部分即可。
总结一点,就是要自定义一些规则来完成加密过程,提升第三者破解难度。
9、关键代码保护
对于关键性的代码,比如加密解密代码、防Hook框架代码等,我们可以将其放入在native层处理,一般来说,针对so库的反编译要比dex难的多。
10、四大组件的访问权限控制
我们知道,Android的四大组件的访问最终都是有AMS处理的,AMS运行在System Server进程中,应用程序通过Binder机制和其进行交互,如果一个应用的四大组件放开了权限,其他应用是可以直接启动它们的,所以,我们有必要
对四大组件进行权限控制,对于不需要和外部交互的四大组件,将其访问权限设置为只能由本应用或和本应用具有相同UID的应用才能访问。
android提供了android:exported属性来配置四大组件的访问权限,规定如下:
如果注册四大组件宝包含了节点,则默认experted的值为true,否则为false,如果experted属性为false,则表示该组件只能由本应用或和本应用具有相同UID的应用访问,否则其他应用也能访问。
这里说一下UID的概念:
android中uid用于标识一个应用程序,uid在应用安装时被分配,并且在应用存在于手机上期间,都不会改变。一个应用程序只能有一个uid,多个应用可以使用sharedUserId 方式共享同一个uid,前提是这些应用的签名要相同。
如果需要让其他应用能够访问到本应用的四大组件,一般有下面几种方式:
1、将android:experted设置为true
2、通过sharedUserId的方式让不同应用具有相同的UId,前提是这些应用签名要相同
3、在应用中使用uses-permission声明四大组件的访问权限,其他应用只有具有相同的user-permission才能访问。
使用LocalBroadcastManager发送应用内广播
LocalBroadcastManager可以用来发送应用内的广播,它的核心思想是将要接收消息的广播添加到LocalBroadcastManager中管理,并获得广播中的action、intent等,这样就知道了该广播的匹配规则了,需要接收何种类型的消息,其并没有真正意义上的注册广播,
然后通过LocalBroadcastManager发送一条消息时,会遍历LocalBroadcastManager中所有管理的广播,根据其action等信息匹配是否要处理该消息,如果有需要接收该消息的广播,则将其存入列表中,然后通过Handler发送一条处理消息的消息,这个Handler使用的是主线程的Looper,这样消息就会在主线程中处理,在Handler的handlerMessage中,遍历要接收消息的广播,并依次调用过期onReceive方法处理消息。
LocalBroadcastManager由于采用Handler机制处理消息,并且其只是利用广播来匹配消息和处理消息回调,并没有真正的注册广播,所以其他应用是无法接收到广播的,而且这一切都是在应用内部处理的,不需要跨进程通信,其效率也会较高。
11、设置android:debuggable为false,让应用无法被调试。
12、如果应用没有对数据进行备份的需求,将android:allowBackup设置为false,默认该值是为true的
自从 API 8以来,谷歌为用户提供了应用程序数据备份和恢复的功能。此功能的开关是AndroidManifest.xml 文件的 allowBackup属性,默认为true开启。当allowBackup标志为true时,用户即可通过adb backup和adb restore来进行对应用数据的备份和恢复,这可能会带来一定的安全风险。我曾经就通过这种方式获得了某款应用中的数据,并获得了存储在本地Preference的用户密码信息,推荐改进方法是对存储在本地的敏感数据全部采用算法进行加密,并将android:allowBackup设置为false。
13、了解常见的WebView漏洞并规避
以H5执行Java代码为例,如果我们采用addJavaScriptInterface接口注册Java对象的方法来让H5执行Java代码,那么在4.2以前的版本就会存在安全风险,在4.2以前,提供给Js执行的方法前不需要添加@JavaScriptInterface注解,Js获得注册的Java对象后,可以通过反射获得Runtime类,然后通过反射执行Runtime里面的静态方法,Runtime类提供了getRunTime方法获得RunTime对象,我们可以调用其exec方法执行adb命令,比如访问文件等。
现在的应用一般都是原生和H5混合开发,H5开发不仅能够跨平台,而且能够快速更新,这是原生开发所不能替代的,一般,对于一些需要经常变化的功能,我们可以采用H5开发,比如运营活动等,Android采用WebView来加载H5页面,这其中就设计到了如何让H5和Android交互的问题,比如,在H5页面中判断用户是否登录,如果没有登录则跳转到原生的登录页面,原生代码如何将登录信息传递给H5页面等等。
要想让H5和原生Android代码进行交互,需要进行如下操作:
1、首先,我们了解一下WebView如何加载一个H5页面
a、加载服务端页面
mWebView.loadUrl("https://www.baidu.com"); // 用loadUrl方法加载,直接传入H5地址
b、加载本地assets目录页面
mWebView.loadUrl("file:///android_asset/test.html"); // 注意传入的路径是以file:///android_asset/开始
2、让WebView支持和Js交互
我们知道,H5中的函数一般都是用Js代码写的,要想让Android的WebView执行H5的Js方法,需要让WebView支持和Js交互,代码如下:
-
WebSettings webSettings = mWebView.getSettings();
-
webSettings.setJavaScriptEnabled(
true);
即获得WebView的WebSettings,并调用其setJavaScriptEnabled(true)方法让其支持和Js交互,设置之后,就可以通过WebView执行Js方法了
比如,H5中有定义如下弹出消息的方法:
-
function alertMessage(message) {
-
alert(message);
-
}
WebView可以调用loadUrl方法执行它:
mWebView.loadUrl("javascript:alertMessage('哈哈')")
在4.4后提供了可以获得Js函数返回结果的evaluateJavascript方法,并且该方法不会使页面再次刷新
-
mWebView.evaluateJavascript(
"sum(1, 2)",
new ValueCallback
() {
-
@Override
-
public void onReceiveValue(String value) {
-
Log.i(
"liunianprint:", value);
-
}
-
});
3、让H5调用原生的Java方法
首先介绍第一种方法:
要想让H5调用原生方法,首先需要将原生方法定义在一个类中,并在方法上添加注解@JavaScriptInterface,这个注解的意思表示该方法会提供给H5调用,然后实例化该类,并调用WebView的addJavaScriptInterface方法将实例化对象注册到WebView中,之后H5就可以调用该类中的方法了。具体如下:
a、封装供H5调用的方法到类中
-
public
class JsInterface {
-
@JavaScriptInterface
-
public void printMessage(String message) {
// 打印message信息
-
Log.i(
"liunianprint:", message);
-
}
-
}
b、实例化封装方法的类并将实例化对象注册到WebView中
mWebView.addJavascriptInterface(new JsInterafce(), "android");
addJavascriptInterface有两个参数,第一个是封装了交互方法的实例化对象,第二个是这个对象的别名,H5就是通过别名来找到实例化对象的,并调用对应的Java方法
c、H5通过别名调用Java方法
调用格式为:window.别名.方法名(参数)
例如在H5的Js代码中:
window.android.printMessage('Hello World');
但是这种方法在4.2版本以前存在漏洞,在4.2版本以前,提供给H5调用的方法是不需要添加@JavaScriptInterface注解的,在Js代码中,可以在window中找到类对象,然后通过类对象找到Runtime类,通过执行Runtime类的静态方法我们执行adb命令,比如访问文件命令,这样就可能暴露用户隐私,大致的代码如下:
-
function execute(cmdArgs)
-
{
-
// 步骤1:遍历 window 中的对象
-
// 目的是为了找到包含 getClass()方法的对象,因为所有的Android对象都有getClass方法
-
// 因为Android映射的JS对象也在window中,所以肯定会遍历到
-
for (
var obj
in
window) {
-
if (
"getClass"
in
window[obj]) {
-
// 步骤2:利用反射调用forName()得到Runtime类对象
-
return
window[obj].getClass().forName(
"java.lang.Runtime")
-
-
// 步骤3:以后,就可以调用静态方法来执行一些命令,比如访问文件的命令
-
getMethod(
"getRuntime",
null).invoke(
null,
null).exec(cmdArgs);
-
-
// 从执行命令后返回的输入流中得到字符串,有很严重暴露隐私的危险。
-
// 如执行完访问文件的命令之后,就可以得到文件名的信息了。
-
}
-
}
-
}
4.2版本后,Google修复了该漏洞,即Js只能执行添加了@JavaScriptInterface注解的方法
方法二:
方法二的核心思想是规定一套数据协议的格式,并作为Js发送的URL,然后Java端重写WebViewClient的shouldOverrideUrlLoading方法来拦截要加载的URL,在shouldOverrideUrlLoading方法中解析URL,如果URL是我们规定的格式,则表示其需要调用对应的原生代码,
我们解读URL中的数据调用相应的代码,如果不是规定的格式则不拦截这个URL,让WebView去加载URL。比如,可以规定需要交互的数据格式为"protocal:" + "//android:" + data的形式,那么在shouldOverrideUrlLoading方法中我们就可以判断Url是否以"protocal://android"
开头来确认是否需要调用原生代码。
点击Back按钮,让WebView返回上级页面,而不是关闭页面
重写WebView所在Activity的onBackPressed方法,判断如果WebView有上级页面,则退回上级页面,如果没有上级页面,则调用默认的onBackPressed方法退出页面
-
@Override
-
public void onBackPressed() {
-
if (mWebView.canGoBack()) {
-
mWebView.goBack();
-
return
true;
-
}
-
super.onBackPressed();
-
}
Android应用进程创建流程
进程:Android App在启动前必须先创建一个进程,进程是资源操作系统分配资源的基本单位,进程具有独立的资源空间,不同进程之间的数据是相互隔离的,它们运行在用户空间中,如果要两个进程要交互数据,必须通过系统提供的接口通过内核空间交换数据,Android提供了Binder机制来供两个进程之间通信,其底层主要是通过动态的添加Binder驱动到内核中,然后通过内存映射实现的。一般情况下,一个App就运行在一个进程中,除非在AndroidManifest.xml
中配置了android:process属性,或通过native代码fork进程。
线程:线程是运行在进程中,它是任务调度和执行的基本单位,一个进程可以有很多线程,同一个进程中的线程之间共享数据。在Android中,线程有主线程和子线程之分,它们之间的主要区别是和UI相关的操作只能在主线程中执行(主要是因为在ViewRootImpl中做了校验,当然,可以通过特殊手段让子线程处理UI,比如绕开ViewRootImpl的检测,手动创建SurfaceView对象,然后在子线程中绘制SurfaceView的canvas),一般来说,不能在主线程中处理耗时的操作,这是因为主线程要处理所有的UI相关的操作,一旦被阻塞,将会造成界面卡顿甚至ANR。
多进程需要注意点:
1、静态成员和单例模式完全失效;(不同的进程数据独立)
2、线程同步机制完全失效,无论锁对象还是锁全局对象都无法保证线程同步;(不同的进程数据独立,对象锁都不是同一个对象)
3、SharedPreferences的可靠性下降,SharedPreferences不支持并发读写;(要控制进程同步)
4、Application会多次创建,当一个组件跑在一个新的进程的时候,系统要在创建新的进程的同时分配独立的虚拟机,应用会重新启动一次,也就会创建新的Application。同一个应用的不同组件,如果它们运行在不同进程中,那么和它们分别属于两个应用没有本质区别。
首先了解一下两个基本的进程
Zygote进程
Zygote进程是所有Android应用进程的父进程,它是Android系统的首个Java进程,Zygote进程在启动后会创建Android虚拟机和加载Android应用所需要的framework层的类和资源到内存中,其他的应用进程只需要由Zygote进程fork即可,fork将会创建一个新的进程,并将父进程的内存信息拷贝到子进程中,通过这种方式,Android应用进程在创建时就节省了创建虚拟机和加载Framework资源的时间了。
Hook框架Xposed的原理就是基于Zygote进程,Xposed框架通过替换/system/bin/app_process程序控制zygote进程,使得它在系统启动过程
中加载Xposed提供的XposedBridge.jar,从而完成对Zygote进程以及其fork的所有进程虚拟机的劫持,让开发者能够Hook应用的任意方法。
System Server进程
System Server进程是Android中非常重要的一个进程,它也是由Zygote进程孵化而来的,其管理着Android系统提供的很多系统服务,比如AMS、WMS、PMS等,这些
系统服务在底层完成了大量工作,比如AMS负责Activity、Service等四大组件的管理,WMS负责Android窗口的管理,PMS负责APK的管理,Android的运行离不开这些底层服务,应用进程和它们的交换是通过Binder机制来进行的,这些服务可以看做运行在System Server进程中的Binder服务端,它们会在ServiceManager中注册,应用进程通过Context(上下文环境)来向ServiceManager获得服务的代理,也就是Binder的代理,通过Binder代理可以间接的调用Binder服务端,当然,Binder服务端的代码运行还是在服务端进程中。
应用进程的创建流程
这里先了解几个重要的类:
1、ActivityThread:应用进程java层的根节点,其public static void main方法是应用进程java层的起点,在这个方法中,会初始化应用信息,并创建主线程的Loop循环系统,并通过主线程的Loop构建一个自定义的Handler,这个Handler名称为H
2、Instrumentation:Instrumentation里面封装了应用App和AMS交互的方法(比如应用启动一个Activity,就最终会调用Instrumentation的execStartActivity方法,其内部会调用AMS的代理和AMS交互),并且其还封装了Application、Activity的创建、生命周期回调等(其内部封装了创建Application、Activity实例的方法,这是通过反射实现的,并且Application、Activity生命周期的回调也有对应的方法封装),Instrumentation在ActivityThread中创建,并且每一个Activity都持有其引用。
3、AMS:AMS是运行在System Server进程中的系统服务,其管理着应用所有四大组件的启动、切换、调度以及应用进程的管理和调度,它是一个运行在System Server进程中的Binder服务端,应用进程和它交互需要持有其Binder代理,通过Binder机制进行通信。
4、AMP:即AMS的代理,AMS在创建后会在ServiceManager中注册,应用进程可以通过ServiceManager获得AMS的代理AMP,然后通过AMP即可和AMS通信。
5、ApplicationThread:它是一个运行在应用进程中的Binder服务端,作用是让AMS能够和应用进程通信,应用程序在创建后,会将ApplicationThread的代理IApplicationThread传递给AMS,这样AMS就可以通过IApplicationThread向应用程序发送消息了,ApplicationThread收到AMS消息后,会通过Handler(H)将消息发送到主线程中处理,H的handleMessage方法中定义了处理各种AMS消息的实现。
6、ActivityStack:Activity在AMS的栈管理,用来记录已经启动的Activity的先后关系,状态信息等。通过ActivityStack决定是否需要启动新的进程
7、ActivityRecord:ActivityStack的管理对象,每个Activity在AMS对应一个ActivityRecord,来记录Activity的状态以及其他的管理信息。其实就是服务器端的Activity对象的映像。
这里以点击桌面应用图标为例,首先桌面也是一个应用程序,为Launcher,在点击应用图标时,Launcher会调用startActivity方法启动应用图标对应应用的默认Activity,startActivity最终会调用ActivityThread的Instrumentation的execStartActivity方法,execStartActivity又会调用AMS的代理AMP的startActivity方法,然后通过Binder机制,AMS的startActivity方法被调用,AMS是运行在System Server进程中的,AMS的首先会调用Launcher进程的ApplicationThread的代理ApplicationThreadProxy的schedulePauseActivity,表示先要调用当前Activity(即桌面Activity)的onPaused方法,ApplicationThread的schedulePauseActivity会通过Handler(H)发送ACTIVITY_POST消息,主线程收到消息后,会调用H的handlerMessage处理消息,调用当前Activity的onPaused方法,然后主线程会调用AMP的activityPaused方法,通知AMS当前Activity已经调用了onPaused方法了,AMS的activityPaused方法首先会检查当前Activity依附的进程是否存在,如果不存在则调用startProcess创建一个进程,具体创建过程是通过Socket方式向Zygote进程发送创建进程消息,让zygote fork出应用进程,然后让应用进程执行ActivityThread的public static void main方法,初始化应用信息,创建Loop循环等,然后AMS将Activity信息存储在ActivityRecord中,并添加到ActivityStack中管理,ActivityRecord中存储着标识Activity的Token,Token是一个Binder对象,具有跨进程通信的能力,接着AMS调用ApplicationThreadProxy的scheduleLaunchActivity方法,ApplicationThread的scheduleLaunchActivity方法会调用Instrumentation的方法通过反射创建一个Activity实例,并调用其生命周期方法。
为什么SystemServer和Zygote进程通信不采用Binder机制呢?主要是因为SystemServer进程是由Zygote进程孵化而来的,而ServiceManager是运行在SystemServer进程中的,ServiceManager是Binder通信模块中的一员,而其在Zygote进程创建时还没有创建,所以和Zygote进程通信采用的是Socket。
总结一下:
Launcher点击图标-->调用Activity的startActivity方法-->调用Instrumentation的execStartActivity方法-->调用AMP的startActivity方法
-->调用AMS的startActivity方法-->调用ApplicationThreadProxy的schedulePauseActivity方法暂停当前Activity-->调用AMP的activityPaused方法通知AMS当前Activity已经暂停-->AMS activityPaused方法检测启动Activity所依附进程是否存在
(-->调用startProcess方法通过socket发送创建进程消息给zygote进程-->zygote收到消息后创建应用进程并调用其ActivityThread的main方法-->main方法初始化
应用信息,生成Loop循环)-->AMS将启动Activity信息封装成ActivityRecord并放入ActivityStack中管理-->AMS调用ApplicationThreadProxy的scheduleLaunchActivity方法并传递标记Activity的Token对象-->ApplicationThread的scheduleLaunchActivity方法从Token中获得Activity信息并通过反射创建Activity实例,并调用对应的生命周期方法
Android是基于Linux系统的,Linux为了进程的安全性,不同进程间的数据是隔离的,并且不能相互访问。进程的数据存在于用户空间,内核运行在内核空间,要想让两个进程之间进行通信,必须调用内核提供的Api来交换数据。内核空间是“公共场所”,所以内核显然可以提供这样的条件。除此以外,那就是双方都可以访问的外设了。在这个意义上,两个进程当然也可以通过磁盘上的普通文件交换信息,或者通过“注册表”或其它数据库中的某些表项和记录交换信息。广义上这也是进程间通信的手段,但是一般都不把这算作“进程间通信”。因为那些通信手段的效率太低了,而人们对进程间通信的要求是要有一定的实时性。
先说一下传统的进程间通信的方式:
1、管道
管道是由内核管理的一个缓冲区。管道的一端连接一个进程的输入,这个进程会向管道中放入信息,管道的另一端连接一个进程的输出,取出被放入管道的信息。当管道没有信息,从管道中读取的进程会等待,直到另一端进程放入信息。当管道被放满信息的时候,再次尝试放入信息会等待,直到另一端进程有取出信息。当两个进程都终结的时候,管道会自动消失。
2、消息队列
消息队列是消息的链表,存放在内核中并有消息队列标识符的标识。克服了信号量传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
3、共享内存
映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。是最快的IPC(进程间通信)方式,是针对其他进程间通信方式运行效率低而专门设计的,往往与信号量配合使用,实现进程间的同步和通信。
4、套机字(Socket)
可以用来在同一或者不同机器上通信(AMS和Zygote进程的通信就是通过Socket)
5、信号
信号是一种比较复杂的通信方式,用来通知接收进程某个事件已经发生。
6、信号量
信号量是一个计数器,可以用来控制多个进程对共享资源的访问,常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
总结:
1、两个进程共享文件系统中某个文件上的某些信息,为了访问这些信息,每个进程都得穿越内核(read、write、lseek等)。当然进程共享的文件(一切皆是文件)可以是硬盘上的实际文件,也可以是虚拟文件,或者外围设备等,这些虚拟文件只是在文件系统中有个访问点。
2、两个进程共享驻留于内核中的某些信息,管道是这种共享类型的一个例子,Binder也算是这种类型,进程可以通过访问/dev/binder(open、mmap、ioctl系统调用)从而实现互相通信。
3、两个进程有一个双方都能访问的共享内存区,每个进程一旦设置好该共享内存区,就可以不涉及内核而访问其中的数据,这种方式是IPC形式中最快的,它依赖于mmap系统调用。
Android提供了以下几种方式实现进程间的通信
1、Binder
Binder框架定义了四个角色:Server,Client,ServiceManager以及Binder驱动。其中Server,Client,ServiceManager运行于用户空间,驱动运行于内核空间。这四个角色的关系和网络类似:Server是服务器,Client是客户端,ServiceManager是域名服务器(DNS),驱动是路由器。
Binder底层原理也可能会问到,比如Binder驱动是如何发送数据的。???
2、AIDL
AIDL全称是Android接口定义语言,它是用来辅助开发者来实现Binder通信的,编译apk时aidl工具会将aidl文件转换为对应的java文件,其中就有Binder的client和server端实现(Server端将具体的业务方法定义为抽象方法,真正的Binder server端由具体实现类来实现),AIDL只是为了节省开发者的时间,我们完全可以自己实现Binder的client端和server端代码,效果是一样的。可能会要求分析AIDL生成的Java文件的结构。
3、Messenger
Messenger用来实现简单的跨进程通信,一般,Messenger会和Handler、Service结合使用,它底层的实现原理还是AIDL(Binder),只是它相对于AIDL来说更加简单,并且将所有的消息统一交由给Handler处理,一般来说,如果对并发性要求不高,我们可以使用Messager,如果并发性要求较高,还是应该用AIDL,Messager因为将消息统一交由给Handler处理,所以消息的最终处理是在Handler所指定的Looper的线程中。
Messager的大致使用流程:
服务器端new Messenger(new Handler())用来接收客户端消息,然后在Handler里定义handleMessage()接收和处理客户端传来的message,最后通过message.replyto.send()把结果发送回去(message.replyto获得的是客户端的Messager对象副本)
客户端new Messenger(new Handler())用来接收服务端消息,然后在Handler里定义handleMessage()接收和处理服务器端传来的message,并给message的replayto设置为创建的Messenger对象。实现ServiceConnection类,并在onServiceConnected里用mService = new Messenger(service)得到远程接口,bindService连接后用mService.send发送数据给服务器端。
大致原理:
Handler类中有一个getIMessenger方法,该方法返回一个MessengerImpl实例对象,这个可以看做Messenger的Binder Server端,MessengerImpl继承IMessenger.Stub抽象类,并实现其send方法,在send方法中将消息交由给handler处理。
其实messenger底层也是AIDL。客户端和服务端通讯,就是普通的AIDL,客户端获得MessagerImpl的代理后,通过stub的send方法把消息发到服务端。服务端和客户端通讯:服务端通过解析message的replyto,获得客户端的stub,然后通过send方法发送到客户端,只是服务端和客户端相互之间收到消息后是将消息发送给Handler,让Handler去处理的。
4、使用文件共享
我们可以通过文件来让两个进程之间进行通信,两个进程通过读写同一个文件来交换数据,但是这里我们要特别注意并发问题,以免造成数据混乱。
比如:
a、可以在一个进程中序列化一个对象到文件系统中,在另一个进程中反序列化恢复这个对象
b、SharedPreferences 是个特例,系统对它的读 / 写有一定的缓存策略,即内存中会有一份 ShardPreferences 文件的缓存,系统对他的读 / 写就变得不可靠,当面对高并发的读写访问,SharedPreferences 有很多大的几率丢失数据。因此,IPC 不建议采用 SharedPreferences。
5、使用ContentProvider
ContentProvider作为四大组件之一,可以提供接口让其他进程访问本进程的数据,比如通讯录等,和Messenger底层实现同样是 Binder 和 AIDL,系统做了封装,使用简单。 系统预置了许多 ContentProvider ,如通讯录、日程表,需要跨进程访问。
6、使用Intent
Activity,Service,Receiver这三大组件都支持用Intent传递数据,Intent有一个Bundle类型的变量mExtras,我们通过putExtra(getExtra)其实就是将数据放入mExtras(或从mExtras中获取)中,Bundle和Intent都实现了Parcelable接口,可以在不同的进程间进行传输。一个进程中启动了另一个进程的Activity,Service 和 Receiver ,可以将数据添加到Intent中发送出去。其实现原理也是Binder(AIDL),
我们知道,Binder支持传递实现了序列化接口的数据(Parcelable或Serializable),Android底层四大组件的启动其实都是由AMS完成的,AMS是System Server进程中的Binder Server端,应用程序通过Binder机制和AMS通信,并传递Intent。所以使用Intent传递数据的原理也是Binder(AIDL)。
Android 系统将尽量长时间地保持应用进程,但为了新建进程或运行更重要的进程,最终需要清除旧进程来回收内存。 为了确定保留或终止哪些进程,系统会根据进程中正在运行的组件以及这些组件的状态,将每个进程放入“重要性层次结构”中。 必要时,系统会首先消除重要性最低的进程,然后是清除重要性稍低一级的进程,依此类推,以回收系统资源。
按照进程的重要性,可以将进程划分为5个等级:
1、前台进程
用户当前操作所必需的进程,只有在内存不足以支持它们同时继续运行这一万不得已的情况下,系统才会终止它们。
A. 拥有用户正在交互的 Activity(已调用 onResume())
B. 拥有某个 Service,后者绑定到用户正在交互的 Activity
C. 拥有正在“前台”运行的 Service(服务已调用 startForeground())
D. 拥有正执行一个生命周期回调的 Service(onCreate()、onStart() 或 onDestroy())
E. 拥有正执行其 onReceive() 方法的 BroadcastReceiver
2、可见进程
没有任何前台组件、但仍会影响用户在屏幕上所见内容的进程。可见进程被视为是极其重要的进程,除非为了维持所有前台进程同时运行而必须终止,否则系统不会终止这些进程。
A. 拥有不在前台、但仍对用户可见的 Activity(已调用 onPause())。
B. 拥有绑定到可见(或前台)Activity 的 Service
3、服务进程
尽管服务进程与用户所见内容没有直接关联,但是它们通常在执行一些用户关心的操作(例如,在后台播放音乐或从网络下载数据)。因此,除非内存不足以维持所有前台进程和可见进程同时运行,否则系统会让服务进程保持运行状态。
A. 正在运行 startService() 方法启动的服务,且不属于上述两个更高类别进程的进程。
4、后台进程
后台进程对用户体验没有直接影响,系统可能随时终止它们,以回收内存供前台进程、可见进程或服务进程使用。 通常会有很多后台进程在运行,因此它们会保存在 LRU 列表中,以确保包含用户最近查看的 Activity 的进程最后一个被终止。如果某个 Activity 正确实现了生命周期方法,并保存了其当前状态,则终止其进程不会对用户体验产生明显影响,因为当用户导航回该 Activity 时,Activity 会恢复其所有可见状态。
A. 对用户不可见的 Activity 的进程(已调用 Activity的onStop() 方法)
5、空进程
保留这种进程的的唯一目的是用作缓存,以缩短下次在其中运行组件所需的启动时间。 为使总体系统资源在进程缓存和底层内核缓存之间保持平衡,系统往往会终止这些进程。
A. 不含任何活动应用组件的进程
Android 进程回收策略
Android 中对于内存的回收,主要依靠 Lowmemorykiller 来完成,是一种根据 OOM_ADJ 阈值级别触发相应力度的内存回收的机制。
回收内存时会根据进程的级别优先杀死 OOM_ADJ 比较大的进程,对于优先级相同的进程则进一步受到进程所占内存和进程存活时间的影响。
综上,可以得出减少进程被杀死概率无非就是想办法提高进程优先级,减少进程在内存不足等情况下被杀死的概率。
Android 进程拉活包括两个层面:
A. 提供进程优先级,降低进程被杀死的概率
B. 在进程被杀死后,进行拉活
具体方案
提升进程优先级类
1、锁屏时启动一个空的Activity来提高进程的优先级
监控手机锁屏解锁事件,在屏幕锁屏时启动1个像素的 Activity,在用户解锁时将 Activity 销毁掉。注意该 Activity 需设计成用户无感知。
通过该方案,可以使进程的优先级在屏幕锁屏时间由4提升为最高优先级1。
适用场景: 本方案主要解决第三方应用及系统管理工具在检测到锁屏事件后一段时间(一般为5分钟以内)内会杀死后台进程,已达到省电的目的问题。
2、利用 Notification 提升Service优先级
Android中Service 的优先级为4,通过setForeground接口可以将后台Service设置为前台Service,使进程的优先级由4提升为2,从而使进程的优先级仅仅低于用户当前正在交互的进程,与可见进程优先级一致,使进程被杀死的概率大大降低。
问题:调用setForeground将后台Service设置为前台Service时,必须在系统的通知栏发送一条通知,也就是前台Service与一条可见的通知时绑定在一起的。对于不需要常驻通知栏的应用来说,该方案虽好,但却是用户感知的,无法直接使用。
优化:通过实现一个内部Service,在LiveService和其内部Service中同时发送具有相同ID的Notification,然后将内部Service结束掉。随着内部Servic 的结束,Notification将会消失,但系统优先级依然保持为2。
进程被杀死重新拉活类
1、利用系统广播
在发生特定系统事件时,系统会发出响应的广播,通过在 AndroidManifest 中“静态”注册对应的广播监听器,即可在发生响应事件时拉活。
常用的用于拉活的系统事件有:开机,网络切换、拍照、拍视频等。
2、利用不同的app进程使用广播来进行相互唤醒
比如:假如你手机里装了支付宝、淘宝、天猫、UC等阿里系的app,那么你打开任意一个阿里系的app后,有可能就顺便把其他阿里系的app给唤醒了,因为它们之间可以通过广播相互发送消息并唤醒
一般来说,我们还是应该尽量优化应用的内存,让应用的内存保持在良好的情况,这样能够降低应用被杀死的概率。
简单说一下,实现原理层面后面再分析
1、RecyclerView将创建View和绑定数据放在了两个函数中处理,开发者不需要处理View对象的复用问题,而ListView则是在一个函数中完成的,开发者首先需要判断view是否为空,如果为空才创建View对象;
2、RecyclerView添加了布局管理器,可以设置纵向布局、横向布局、网格布局、瀑布流布局等,而ListView布局单一,只支持纵向布局;
3、RecyclerView封装了很多动画效果,比如插入、删除、拖拽item、的动画效果;
4、RecyclerView提供了ItemDecoration,用于给列表的item添加各种装饰效果,比如分割线、item悬浮效果等;
5、ListView提供了setEmptyView()、addHeaderView()、addFooterView()来分别处理数据为空、添加头布局、添加底部布局的功能,而RecyclerView需要自己实现。
了解Android安全机制
Android将安全设计贯穿系统架构的各个层面,覆盖系统内核、虚拟机、应用程序框架层以及应用层各个环节,力求在开放的同时,也恰当保护用户的数据、应用程序和设备的安全。Android安全模型主要提供以下几种安全机制:
进程沙箱隔离机制
应用程序签名机制
权限声明机制
访问控制机制
进程通信机制
内存管理机制
进程沙箱隔离机制:使得Android应用程序在安装时被赋予独特的用户标识(UID),并永久保持。应用程序及其运行的Dalvik虚拟机运行在独立的Linux进程空间,与其它应用程序完全隔离。
在特殊情况下,进程间还可以存在相互信任关系。如源自同一开发者或同一开发机构的应用程序,通过Android提供的共享UID(Shared UserId)机制,使得具备信任关系的应用程序可以运行在同一进程空间。
应用程序签名机制,规定APK文件必须被开发者进行数字签名,以便标识应用程序作者和在应用程序之间的信任关系。在安装应用程序APK时,系统安装程序首先检查APK是否被签名,有签名才能安装。当应用程序升级时,需要检查新版应用的数字签名与已安装的应用程序的签名是否相同,否则,会被当做一个新的应用程序。Android开发者有可能把安装包命名为相同的名字,通过不同的签名可以把他们区分开来,也保证签名不同的包不被替换,同时防止恶意软件替换安装的应用。
权限声明机制,要想获得在对象上进行操作,就需要把权限和此对象的操作进行绑定。不同级别要求应用程序行使权限的认证方式也不一样,Normal级申请就可以使用,Dangerous级需要安装时由用户确认,Signature和Signatureorsystem级则必须是系统用户才可用。
访问控制机制,确保系统文件和用户数据不受非法访问。
进程通信机制,基于共享内存的Binder实现,提供轻量级的远程进程调用(RPC)。通过接口描述语言(AIDL)定义接口与交换数据的类型,确保进程间通信的数据不会溢出越界。
内存管理机制,基于Linux的低内存管理机制,设计实现了独特的LMK,将进程重要性分级、分组,当内存不足时,自动清理低级别进程所占用的内存空间。同时,引入的Ashmem内存机制,使得Android具备清理不再使用共享内存区域的能力。
比如:
1、String、StringBuidler、StringBuffer的区别
2、Java内存模型
3、虚拟机垃圾回收算法
4、Dex、Class文件格式
5、常见的数据结构
开放性题目
1、水池有无限多的水,现在有两个瓶子,容量一个为3升,一个为5升,如何测量出4升的水?一分钟给出答案,现场计时。
其实这个问题很简单,主要是测试面试者的反应能力以及抗压能力,稍稍想一下就能知道,要测量出4升的水,可以采用如下步骤:
1、先将3升的瓶子装满水(只有装满才能知道到底有多少水,所以没得选择),将其倒入5升的瓶子;
2、在将3升瓶子装满,并将水倒入5升瓶子直到装满5升瓶子,现在3升瓶子中还剩下1升的水;
3、将5升瓶子清空,然后将3升瓶子里面剩余的1升水倒入,现在5升瓶子中有1升水;
4、装满3升瓶子,将水倒入5升瓶子,现在5升瓶子有4升水了。
2、有无限多的材质不均匀的绳子,每根绳子烧完都需要1个小时,问怎样通过烧绳子的方法计时1小时15分钟?
这个问题也不难,考验面试者的反应能力,首先,我们要计时1小时15分钟,1小时好说,烧一根绳子即可,主要是15分钟怎么计时,如果绳子材质均匀,我们可以将绳子对折两次,找到1/4的地方做上标记,当绳子烧到1/4的地方时,就计时15分钟了,但是,题目中明确说了绳子的材质是不均匀的,也就是说有的地方烧的时间会长,有的地方会短,所以烧到1/4并不能计时15分钟,那么有什么办法起到和绳子对折类似的效果呢?可以考虑从绳子的两端同时开始烧,不管绳子的材质是否均匀,当绳子烧完时是要花费30分钟的,我们现在知道了怎样计时30分钟,但是还不知道怎样计算15分钟,可以采取类似的思想,具体步骤如下:
1、取两根绳子,一根点燃一头,一根点燃两头;
2、当点燃两头的绳子烧完时,就表示30分钟过去了,这时立刻点燃另一根绳子的另一头;
3、当另一根绳子烧完时,就表示又有15分钟过去了;
4、此时立刻再取一根绳子,点燃两头烧完,当这根绳子烧完时,又有30分钟过去,总共计时30+15+30 = 75分钟,即一个小时15分钟。
规划
1、中间:多学习好的开源框架,学习其设计思想、所用技术等,并了解Android的安全相关知识
2、向上:学习h5和React、flutter等,全面了解前端技术,未来前端的发展方向一定是大前端,即H5、Android、Ios使用同一套代码。
3、向下:尽量了解Framework层以及虚拟机方面知识,加深自己对Android系统的了解
4、阅读完深入理解Jvm。
总之一句话,不要浪费时间就好。
本文转载自 xiao_nian 的Android面试整理
看都看完了,不看一下作者?