一些常用技能(Android面试)
1:为什么Android官方废弃SoftRefrerence软引用和WeakReference弱引用,而拥抱LruCache?
一些具有Java背景的研发者喜欢使用软引用(SoftRefrerence)和弱引用(WeakReference)来缓存Java对象和数据,但是如果在Android中仍然使用软引用(SoftRefrerence)和弱引用(WeakReference),会极易导致Android程序闪退崩溃,谷歌Android官方从Android 3.0以后,强烈建议开发者不要在Android中使用软引用(SoftRefrerence)和弱引用(WeakReference),Android的谷歌官方解释(原文):
Note: In the past, a popular memory cache implementation was a SoftReference or WeakReference bitmap cache, however this is not recommended. Starting from Android 2.3 (API Level 9) the garbage collector is more aggressive with collecting soft/weak references which makes them fairly ineffective. In addition, prior to Android 3.0 (API Level 11), the backing data of a bitmap was stored in native memory which is not released in a predictable manner, potentially causing an application to briefly exceed its memory limits and crash.
官方文档链接见附录3。
这段内容大致翻译出来是这样的(英文对照翻译):
-
Note: In the past, a popular memory cache implementation was a SoftReference or WeakReference bitmap cache, however this is not recommended. Starting from Android 2.3 (API Level 9) the garbage collector is more aggressive with collecting soft/weak references which makes them fairly ineffective. In addition, prior to Android 3.0 (API Level 11), the backing data of a bitmap was stored in native memory which is not released in a predictable manner, potentially causing an application to briefly exceed its memory limits and crash.
-
注意!在过去,一个非常流行的内存缓存实现是通过SoftReference或WeakReference对bitmap进行缓存,然而现在不推荐使用这种方案实施内存缓存了。从Android系统版本2.3 ( API Level 9 )以后,garbage collector(译者注:Java垃圾回收器)会更加积极的回收持有软/弱引用对象,这导致软引用和弱引用变的相当无能为力。除此之外,在Android系统版本3.0 ( API Level 11 )之前,在本地内存中缓存一个bitmap数据并不会以预期的方式释放,这可能导致一个应用在很短期间就超越它的内存上限(译者注:进而会引发OOM,Out Of Memory)而导致应用崩溃。
同样在官方文档链接下面,谷歌Android官方给出了在Android中处理缓存应该使用什么样的缓存策略:采用LruCache。关于LruCache,请看我过去写的附录文章1和2。
时至今日,网上搜一搜Android缓存技术,有感于当下竟然还有一些文章在给开发者推荐使用SoftRefrerence软引用和WeakReference弱引用缓存数据,不禁令人唏嘘。
2:理解TCP三次握手/四次断开的必要性
1 TCP的三次握手与必要性
(1)三次握手图
(2)必要性:TCP通过三次握手建立可靠的(确保收到)的全双工通信。
1)第一次握手和第二次握手(ACK部分)建立了从客户端到服务器传送数据的可靠连接;
2)第二次握手(SYN部分)和第三次握手建立了从服务器到客户端传送数据的可靠连接;
4)由于我们期望建立全双工连接,所以两个方向的通信都是需要的,于是合并了服务器发送的ACK和SYN。
5)第三次握手的必要性:防止已失效的请求报文段突然又传送到了服务端而造成连接的误判。假如客户端发出连接请求A,由于网络原因,服务端并没有收到A,于是客户端又发送了连接请求B,并建立了连接,完成通信,断开连接。这时候,服务端突然又收到了A,于是看作是一次新的连接请求,进行第二次握手,由于不存在第三次握手,所以这时已经建立了TCP连接。但实际上客户端并没有发起连接,所以不会传递数据,那么这条连接就会变成一条死连接。
2 TCP的四次断开与必要性
(1)四次断开图
(2)必要性:为保证单向通信的可行性,所以多一次握手。
1)主动断开方发送FIN时,被动断开方要回复ACK,意思是“我收到你的FIN了”;
2)主动断开方发送FIN并不意味着立即关闭TCP连接,而是告诉对方自己没有更多的数据要发送了,只有当对方发完自己的数据再发送FIN后,才意味着关闭TCP连接;
3)被动断开方收到FIN并回复ACK后,此时TCP处于“半关闭”状态,为保证被动断开方可以继续发送数据,所以第二个FIN并不会伴随ACK发送,所以比连接时多一个报文段。
3:LruCache的实现原理(图片三级缓存)
官方建议使用lrucache进行内存缓存。Lrucache底层实际是维护的一个linkedHashMap集合(他是hashmap的一个子类,可以保证存入和取出顺序的集合,与hashmap不同的是他是一个双向链表从Android2.3以后,系统GC操作更加频繁,所以软引用和弱引用的资源很容易被回收。Android的结构,内部会定义两个属性分别为before和after,用于记录元素的位置;而haspmap是一个单向的链表结构),他有一个关键的方法就是在我们向lrucache中存储元素的时候,会先去将该元素所占空间大小与lrucache中所有元素的所占空间求和,然后和我们设置的最大可用存储内存进行比较,如果超过我们设置的最大值,就会将最近最少使用的元素删除以腾挪空间,每当我们获取元素时,会将原位置的元素进行删除,然后重新在表头将获取的元素进行插入。
注意:DiskLruCache不是Google官方的,需要从github上下载放入工程中,在获取DiskLruCache实例的时候,方法中有个参数是应用的版本号,这里注意当应用版本号改变时,DiskLruCache中的所有数据都会被清除。因为DiskLruCache认为,当应用版本号更新后,数据都应该重新从服务器获取。DiskLruCache能否正常工作,主要依赖journal这个文件,所以我们要注意调用diskLrucache的flush方法,将内存中的操作同步到journal文件,但是注意,不要频繁去同步操作,这样会消耗大量的同步时间,所以我们一般在activity的onPause生命周期中去执行disklrucache的flush方法即可。
对于journal文件,其内部有一些信息我们是可以看懂的
DIRTY 每当我们调用edit方法的时候,回向journal写入一条DIRTY记录,因为我们不知道该操作是否能成功
CLEAN 当我们调用commit方法时,会像journal写入一条CLEAN记录,表示操作成功,并在该数据后面记录该文件的大小。
REMOVE 但我们调用abort方法时,会向journal写入一条REMOVE记录,变化操作失败
也就是说每一条DIRTY后面都有一条对应的CLEAN或者REMOVE记录,要不就表示这条是脏数据,会被自动删除。
READ 当我们调用get方法时,会向journal写入一条READ记录
HashMap的实现原理
(面试过程中也经常会被问到):数组和链表组合成的链表散列结构,通过hash算法,尽量将数组中的数据分布均匀,如果hashcode相同再比较equals方法,如果equals方法返回false,那么就将数据以链表的形式存储在数组的对应位置,并将之前在该位置的数据往链表的后面移动,并记录一个next属性,来指示后移的那个数据。注意数组中保存的是entry(其中保存的是键值
4:viewPager实现动态加载图片,附带大图片处理
viewPager实现动态加载图片,并且附带大图片的处理。
1.实现动态加载图片数量
将图片放在assets下这样就可以动态读取图片的数量,当图片的内容和数量改变的时候程序不需要修改。
Java代码
- private void initImage(){
- try {
- pics = this.getResources().getAssets().list("guide");
- LinearLayout.LayoutParams mParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT,
- LinearLayout.LayoutParams.WRAP_CONTENT);
- //初始化引导图片列表
- for(int i=0; i<pics.length; i++) {
- ImageView iv = new ImageView(this);
- iv.setLayoutParams(mParams);
- GetBitMap.instance().add(this, mHandler,"guide/"+pics[i], UPDATEDATA,iv);
- views.add(iv);
- }
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
2.当图片过大的时候,如果放在UI线程去读取图片很容易造成ANR,下面介绍一种异步线程加载图片并进行图片处理的方式
下面是异步加载图片的处理方式,而且不会开启很多子线程。
Java代码
- public void add(Context context,Handler handler,String path, int mCode,ImageView imageView){
- BitMapMsg msg = new BitMapMsg();
- msg.mConext = context;
- msg.mHandler = handler;
- msg.path = path;
- msg.mCode = mCode;
- msg.mImgView = imageView;
- mQueue.add(msg);
- if(!running){
- new Thread(this).start();
- }
- }
- public void run() {
- running = true;
- while(mQueue.size() > 0 ){
- BitMapMsg msg = null;
- synchronized (mQueue) {
- msg = mQueue.remove(0);
- }
- if(msg != null && !msg.path.equals("")){
- Message message = new Message();
- message.what = msg.mCode;
- if(msg.mImgView != null){
- BitmapData bitmapData = new BitmapData(getBitmap(msg,msg.path),msg.mImgView);
- message.getData().putParcelable("bitmap", bitmapData);
- msg.mHandler.sendMessage(message);
- }
- }
- }
- running = false;
- }
3 当图片过大的时候,加载图片的时候相信大家都遇到过内存溢出的问题,下面介绍一下处理bitmap的方式
尽量不要使用setImageBitmap或setImageResource或BitmapFactory.decodeResource来设置一张大图,因为这些函数在完成decode后,最终都是通过java层的createBitmap来完成的,需要消耗更多内存。
因此,改用先通过BitmapFactory.decodeStream方法,创建出一个bitmap,再将其设为ImageView的 source,decodeStream最大的秘密在于其直接调用JNI>>nativeDecodeAsset()来完成decode,无需再使用java层的createBitmap,从而节省了java层的空间。
如果在读取时加上图片的Config参数,可以跟有效减少加载的内存,从而跟有效阻止抛out of Memory异常
另外,decodeStream直接拿的图片来读取字节码了, 不会根据机器的各种分辨率来自动适应,所以请大家注意适配的问题。
下面介绍一下Config的参数
其实这都是色彩的存储方法:我们知道ARGB指的是一种色彩模式,里面A代表Alpha,R表示red,G表示green,B表示blue,其实所有的可见色都是右红绿蓝组成的,所以红绿蓝又称为三原色,每个原色都存储着所表示颜色的信息值
Bitmap.Config ALPHA_8 图形参数应该由一个字节来表示,应该是一种8位的位图
Bitmap.Config ARGB_4444 图形的参数应该由两个字节来表示 分别用4个bit来记录每个像素的A、R、G、B数据,16色位图
Bitmap.Config ARGB_8888 图形的参数应该由四个字节来表示 分别用8个bit来记录每个像素的A、R、G、B数据,就是常说的32bit位图、256色位图(这个也可能是RGB888这种24bit位图)
Bitmap.Config RGB_565 图形的参数应该由两个字节来表示 分别用5个、6个和5个bit记录像素的R、G、B数据,其中G的6个bit中一个是无效保留的,32色位图
位图位数越高代表其可以存储的颜色信息越多,当然图像也就越逼真
下面介绍下BitmapFactory.Options
设置恰当的inSampleSize是解决该问题的关键之一。BitmapFactory.Options提供了另一个成员inJustDecodeBounds。
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
Bitmap bitmap = BitmapFactory.decodeFile(imageFile, opts);
设置inJustDecodeBounds为true后,decodeFile并不分配空间,但可计算出原始图片的长度和宽度,即opts.width和opts.height。
有了这两个参数,再通过一定的算法,即可得到一个恰当的inSampleSize。
查看Android源码,Android提供了一种动态计算的方法。
Java代码
- /**
- * 动态计算inSampleSize
- * @param options
- * @param minSideLength
- * @param maxNumOfPixels
- * @return
- */
- public static int computeSampleSize(BitmapFactory.Options options,
- int minSideLength, int maxNumOfPixels) {
- int initialSize = computeInitialSampleSize(options, minSideLength,
- maxNumOfPixels);
- int roundedSize;
- if (initialSize <= 8) {
- roundedSize = 1;
- while (roundedSize < initialSize) {
- roundedSize <<= 1;
- }
- } else {
- roundedSize = (initialSize + 7) / 8 * 8;
- }
- return roundedSize;
- }
- private static int computeInitialSampleSize(BitmapFactory.Options options,
- int minSideLength, int maxNumOfPixels) {
- double w = options.outWidth;
- double h = options.outHeight;
- int lowerBound = (maxNumOfPixels == -1) ? 1 : (int) Math.ceil(Math
- .sqrt(w * h / maxNumOfPixels));
- int upperBound = (minSideLength == -1) ? 128 : (int) Math.min(Math
- .floor(w / minSideLength), Math.floor(h / minSideLength));
- if (upperBound < lowerBound) {
- // return the larger one when there is no overlapping zone.
- return lowerBound;
- }
- if ((maxNumOfPixels == -1) && (minSideLength == -1)) {
- return 1;
- } else if (minSideLength == -1) {
- return lowerBound;
- } else {
- return upperBound;
- }
- }
6:什么是代理设计模式?
什么是代理
从字面意思来看,代理比较好理解,无非就是代为处理的意思。举个例子,你在上大学的时候,总是喜欢逃课。因此,你拜托你的同学帮你答到,而自己却窝在宿舍玩游戏... 你的这个同学恰好就充当了代理的作用,代替你去上课。
是的,你没有看错,代理就是这么简单!
理解了代理的意思,你脑海中恐怕还有两个巨大的疑问:
- 怎么实现代理模式
- 代理模式有什么实际用途
要理解这两个问题,看一个简单的例子:
-
public interface Flyable {
-
void fly();
-
}
-
public class Bird implements Flyable {
-
@Override
-
public void fly() {
-
System.out.println("Bird is flying...");
-
try {
-
Thread.sleep(new Random().nextInt(1000));
-
} catch (InterruptedException e) {
-
e.printStackTrace();
-
}
-
}
-
}
很简单的一个例子,用一个随机睡眠时间模拟小鸟在空中的飞行时间。接下来问题来了,如果我要知道小鸟在天空中飞行了多久,怎么办?
有人说,很简单,在Bird->fly()方法的开头记录起始时间,在方法结束记录完成时间,两个时间相减就得到了飞行时间。
-
@Override
-
public void fly() {
-
long start = System.currentTimeMillis();
-
System.out.println("Bird is flying...");
-
try {
-
Thread.sleep(new Random().nextInt(1000));
-
} catch (InterruptedException e) {
-
e.printStackTrace();
-
}
-
long end = System.currentTimeMillis();
-
System.out.println("Fly time = " + (end - start));
-
}
的确,这个方法没有任何问题,接下来加大问题的难度。如果Bird这个类来自于某个SDK(或者说Jar包)提供,你无法改动源码,怎么办?
一定会有人说,我可以在调用的地方这样写:
-
public static void main(String[] args) {
-
Bird bird = new Bird();
-
long start = System.currentTimeMillis();
-
bird.fly();
-
long end = System.currentTimeMillis();
-
System.out.println("Fly time = " + (end - start));
-
}
这个方案看起来似乎没有问题,但其实你忽略了准备这些方法所需要的时间,执行一个方法,需要开辟栈内存、压栈、出栈等操作,这部分时间也是不可以忽略的。因此,这个解决方案不可行。那么,还有什么方法可以做到呢?
a)使用继承
继承是最直观的解决方案,相信你已经想到了,至少我最开始想到的解决方案就是继承。
为此,我们重新创建一个类Bird2,在Bird2中我们只做一件事情,就是调用父类的fly方法,在前后记录时间,并打印时间差:
-
public class Bird2 extends Bird {
-
@Override
-
public void fly() {
-
long start = System.currentTimeMillis();
-
super.fly();
-
long end = System.currentTimeMillis();
-
System.out.println("Fly time = " + (end - start));
-
}
-
}
这是一种解决方案,还有一种解决方案叫做:聚合,其实也是比较容易想到的。
我们再次创建新类Bird3,在Bird3的构造方法中传入Bird实例。同时,让Bird3也实现Flyable接口,并在fly方法中调用传入的Bird实例的fly方法:
-
public class Bird3 implements Flyable {
-
private Bird bird;
-
public Bird3(Bird bird) {
-
this.bird = bird;
-
}
-
@Override
-
public void fly() {
-
long start = System.currentTimeMillis();
-
bird.fly();
-
long end = System.currentTimeMillis();
-
System.out.println("Fly time = " + (end - start));
-
}
-
}
为了记录Bird->fly()方法的执行时间,我们在前后添加了记录时间的代码。同样地,通过这种方法我们也可以获得小鸟的飞行时间。那么,这两种方法孰优孰劣呢?咋一看,不好评判!
继续深入思考,用问题推导来解答这个问题:
问题一:如果我还需要在fly方法前后打印日志,记录飞行开始和飞行结束,怎么办?
有人说,很简单!继承Bird2并在在前后添加打印语句即可。那么,问题来了,请看问题二。
问题二:如果我需要调换执行顺序,先打印日志,再获取飞行时间,怎么办?
有人说,再新建一个类Bird4继承Bird,打印日志。再新建一个类Bird5继承Bird4,获取方法执行时间。
问题显而易见:使用继承将导致类无限制扩展,同时灵活性也无法获得保障。那么,使用 聚合 是否可以避免这个问题呢?
答案是:可以!但我们的类需要稍微改造一下。修改Bird3类,将聚合对象Bird类型修改为Flyable
-
public class Bird3 implements Flyable {
-
private Flyable flyable;
-
public Bird3(Flyable flyable) {
-
this.flyable = flyable;
-
}
-
@Override
-
public void fly() {
-
long start = System.currentTimeMillis();
-
flyable.fly();
-
long end = System.currentTimeMillis();
-
System.out.println("Fly time = " + (end - start));
-
}
-
}
为了让你看的更清楚,我将Bird3更名为BirdTimeProxy,即用于获取方法执行时间的代理的意思。同时我们新建BirdLogProxy代理类用于打印日志:
-
public class BirdLogProxy implements Flyable {
-
private Flyable flyable;
-
public BirdLogProxy(Flyable flyable) {
-
this.flyable = flyable;
-
}
-
@Override
-
public void fly() {
-
System.out.println("Bird fly start...");
-
flyable.fly();
-
System.out.println("Bird fly end...");
-
}
-
}
接下来神奇的事情发生了,如果我们需要先记录日志,再获取飞行时间,可以在调用的地方这么做:
-
public static void main(String[] args) {
-
Bird bird = new Bird();
-
BirdLogProxy p1 = new BirdLogProxy(bird);
-
BirdTimeProxy p2 = new BirdTimeProxy(p1);
-
p2.fly();
-
}
反过来,可以这么做:
-
public static void main(String[] args) {
-
Bird bird = new Bird();
-
BirdTimeProxy p2 = new BirdTimeProxy(bird);
-
BirdLogProxy p1 = new BirdLogProxy(p2);
-
p1.fly();
-
}
看到这里,有同学可能会有疑问了。虽然现象看起来,聚合可以灵活调换执行顺序。可是,为什么 聚合 可以做到,而继承不行呢。我们用一张图来解释一下:
静态代理
接下来,观察上面的类BirdTimeProxy,在它的fly方法中我们直接调用了flyable->fly()方法。换而言之,BirdTimeProxy其实代理了传入的Flyable对象,这就是典型的静态代理实现。
从表面上看,静态代理已经完美解决了我们的问题。可是,试想一下,如果我们需要计算SDK中100个方法的运行时间,同样的代码至少需要重复100次,并且创建至少100个代理类。往小了说,如果Bird类有多个方法,我们需要知道其他方法的运行时间,同样的代码也至少需要重复多次。因此,静态代理至少有以下两个局限性问题:
- 如果同时代理多个类,依然会导致类无限制扩展
- 如果类中有多个方法,同样的逻辑需要反复实现
那么,我们是否可以使用同一个代理类来代理任意对象呢?我们以获取方法运行时间为例,是否可以使用同一个类(例如:TimeProxy)来计算任意对象的任一方法的执行时间呢?甚至再大胆一点,代理的逻辑也可以自己指定。比如,获取方法的执行时间,打印日志,这类逻辑都可以自己指定。这就是本文重点探讨的问题,也是最难理解的部分:动态代理。
动态代理
继续回到上面这个问题:是否可以使用同一个类(例如:TimeProxy)来计算任意对象的任一方法的执行时间呢。
这个部分需要一定的抽象思维,我想,你脑海中的第一个解决方案应该是使用反射。反射是用于获取已创建实例的方法或者属性,并对其进行调用或者赋值。很明显,在这里,反射解决不了问题。但是,再大胆一点,如果我们可以动态生成TimeProxy这个类,并且动态编译。然后,再通过反射创建对象并加载到内存中,不就实现了对任意对象进行代理了吗?为了防止你依然一头雾水,我们用一张图来描述接下来要做什么:
动态生成Java源文件并且排版是一个非常繁琐的工作,为了简化操作,我们使用 JavaPoet 这个第三方库帮我们生成TimeProxy的源码。希望 JavaPoet 不要成为你的负担,不理解 JavaPoet 没有关系,你只要把它当成一个Java源码生成工具使用即可。
PS:你记住,任何工具库的使用都不会太难,它是为了简化某些操作而出现的,目标是简化而不是繁琐。因此,只要你适应它的规则就轻车熟路了。
第一步:生成TimeProxy源码
-
public class Proxy {
-
public static Object newProxyInstance() throws IOException {
-
TypeSpec.Builder typeSpecBuilder = TypeSpec.classBuilder("TimeProxy")
-
.addSuperinterface(Flyable.class);
-
FieldSpec fieldSpec = FieldSpec.builder(Flyable.class, "flyable", Modifier.PRIVATE).build();
-
typeSpecBuilder.addField(fieldSpec);
-
MethodSpec constructorMethodSpec = MethodSpec.constructorBuilder()
-
.addModifiers(Modifier.PUBLIC)
-
.addParameter(Flyable.class, "flyable")
-
.addStatement("this.flyable = flyable")
-
.build();
-
typeSpecBuilder.addMethod(constructorMethodSpec);
-
Method[] methods = Flyable.class.getDeclaredMethods();
-
for (Method method : methods) {
-
MethodSpec methodSpec = MethodSpec.methodBuilder(method.getName())
-
.addModifiers(Modifier.PUBLIC)
-
.addAnnotation(Override.class)
-
.returns(method.getReturnType())
-
.addStatement("long start = $T.currentTimeMillis()", System.class)
-
.addCode("\n")
-
.addStatement("this.flyable." + method.getName() + "()")
-
.addCode("\n")
-
.addStatement("long end = $T.currentTimeMillis()", System.class)
-
.addStatement("$T.out.println(\"Fly Time =\" + (end - start))", System.class)
-
.build();
-
typeSpecBuilder.addMethod(methodSpec);
-
}
-
JavaFile javaFile = JavaFile.builder("com.youngfeng.proxy", typeSpecBuilder.build()).build();
-
// 为了看的更清楚,我将源码文件生成到桌面
-
javaFile.writeTo(new File("/Users/ouyangfeng/Desktop/"));
-
return null;
-
}
-
}
在main方法中调用Proxy.newProxyInstance(),你将看到桌面已经生成了TimeProxy.java文件,生成的内容如下:
-
package com.youngfeng.proxy;
-
import java.lang.Override;
-
import java.lang.System;
-
class TimeProxy implements Flyable {
-
private Flyable flyable;
-
public TimeProxy(Flyable flyable) {
-
this.flyable = flyable;
-
}
-
@Override
-
public void fly() {
-
long start = System.currentTimeMillis();
-
this.flyable.fly();
-
long end = System.currentTimeMillis();
-
System.out.println("Fly Time =" + (end - start));
-
}
-
}
第二步:编译TimeProxy源码
编译TimeProxy源码我们直接使用JDK提供的编译工具即可,为了使你看起来更清晰,我使用一个新的辅助类来完成编译操作:
-
public class JavaCompiler {
-
public static void compile(File javaFile) throws IOException {
-
javax.tools.JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
-
StandardJavaFileManager fileManager = javaCompiler.getStandardFileManager(null, null, null);
-
Iterable iterable = fileManager.getJavaFileObjects(javaFile);
-
javax.tools.JavaCompiler.CompilationTask task = javaCompiler.getTask(null, fileManager, null, null, null, iterable);
-
task.call();
-
fileManager.close();
-
}
-
}
在Proxy->newProxyInstance()方法中调用该方法,编译顺利完成:
-
// 为了看的更清楚,我将源码文件生成到桌面
-
String sourcePath = "/Users/ouyangfeng/Desktop/";
-
javaFile.writeTo(new File(sourcePath));
-
// 编译
-
JavaCompiler.compile(new File(sourcePath + "/com/youngfeng/proxy/TimeProxy.java"));
第三步:加载到内存中并创建对象
-
URL[] urls = new URL[] {new URL("file:/" + sourcePath)};
-
URLClassLoader classLoader = new URLClassLoader(urls);
-
Class clazz = classLoader.loadClass("com.youngfeng.proxy.TimeProxy");
-
Constructor constructor = clazz.getConstructor(Flyable.class);
-
Flyable flyable = (Flyable) constructor.newInstance(new Bird());
-
flyable.fly();
通过以上三个步骤,我们至少解决了下面两个问题:
- 不再需要手动创建TimeProxy
- 可以代理任意实现了Flyable接口的类对象,并获取接口方法的执行时间
可是,说好的任意对象呢?
第四步:增加InvocationHandler接口
查看Proxy->newProxyInstance()的源码,代理类继承的接口我们是写死的,为了增加灵活性,我们将接口类型作为参数传入:
接口的灵活性问题解决了,TimeProxy的局限性依然存在,它只能用于获取方法的执行时间,而如果要在方法执行前后打印日志则需要重新创建一个代理类,显然这是不妥的!
为了增加控制的灵活性,我们考虑针将代理的处理逻辑也抽离出来(这里的处理就是打印方法的执行时间)。新增InvocationHandler
接口,用于处理自定义逻辑:
-
public interface InvocationHandler {
-
void invoke(Object proxy, Method method, Object[] args);
-
}
想象一下,如果客户程序员需要对代理类进行自定义的处理,只要实现该接口,并在invoke方法中进行相应的处理即可。这里我们在接口中设置了三个参数(其实也是为了和JDK源码保持一致):
- proxy => 这个参数指定动态生成的代理类,这里是
TimeProxy
- method => 这个参数表示传入接口中的所有Method对象
- args => 这个参数对应当前method方法中的参数
引入了InvocationHandler接口之后,我们的调用顺序应该变成了这样:
-
MyInvocationHandler handler = new MyInvocationHandler();
-
Flyable proxy = Proxy.newProxyInstance(Flyable.class, handler);
-
proxy.fly();
-
方法执行流:proxy.fly() => handler.invoke()
为此,我们需要在Proxy.newProxyInstance()方法中做如下改动:
- 在newProxyInstance方法中传入InvocationHandler
- 在生成的代理类中增加成员变量handler
- 在生成的代理类方法中,调用invoke方法
-
public static Object newProxyInstance(Class inf, InvocationHandler handler) throws Exception {
-
TypeSpec.Builder typeSpecBuilder = TypeSpec.classBuilder("TimeProxy")
-
.addModifiers(Modifier.PUBLIC)
-
.addSuperinterface(inf);
-
FieldSpec fieldSpec = FieldSpec.builder(InvocationHandler.class, "handler", Modifier.PRIVATE).build();
-
typeSpecBuilder.addField(fieldSpec);
-
MethodSpec constructorMethodSpec = MethodSpec.constructorBuilder()
-
.addModifiers(Modifier.PUBLIC)
-
.addParameter(InvocationHandler.class, "handler")
-
.addStatement("this.handler = handler")
-
.build();
-
typeSpecBuilder.addMethod(constructorMethodSpec);
-
Method[] methods = inf.getDeclaredMethods();
-
for (Method method : methods) {
-
MethodSpec methodSpec = MethodSpec.methodBuilder(method.getName())
-
.addModifiers(Modifier.PUBLIC)
-
.addAnnotation(Override.class)
-
.returns(method.getReturnType())
-
.addCode("try {\n")
-
.addStatement("\t$T method = " + inf.getName() + ".class.getMethod(\"" + method.getName() + "\")", Method.class)
-
// 为了简单起见,这里参数直接写死为空
-
.addStatement("\tthis.handler.invoke(this, method, null)")
-
.addCode("} catch(Exception e) {\n")
-
.addCode("\te.printStackTrace();\n")
-
.addCode("}\n")
-
.build();
-
typeSpecBuilder.addMethod(methodSpec);
-
}
-
JavaFile javaFile = JavaFile.builder("com.youngfeng.proxy", typeSpecBuilder.build()).build();
-
// 为了看的更清楚,我将源码文件生成到桌面
-
String sourcePath = "/Users/ouyangfeng/Desktop/";
-
javaFile.writeTo(new File(sourcePath));
-
// 编译
-
JavaCompiler.compile(new File(sourcePath + "/com/youngfeng/proxy/TimeProxy.java"));
-
// 使用反射load到内存
-
URL[] urls = new URL[] {new URL("file:" + sourcePath)};
-
URLClassLoader classLoader = new URLClassLoader(urls);
-
Class clazz = classLoader.loadClass("com.youngfeng.proxy.TimeProxy");
-
Constructor constructor = clazz.getConstructor(InvocationHandler.class);
-
Object obj = constructor.newInstance(handler);
-
return obj;
-
}
上面的代码你可能看起来比较吃力,我们直接调用该方法,查看最后生成的源码。在main方法中测试newProxyInstance查看生成的TimeProxy源码:
测试代码
-
Proxy.newProxyInstance(Flyable.class, new MyInvocationHandler(new Bird()));
生成的TimeProxy.java源码
-
package com.youngfeng.proxy;
-
import java.lang.Override;
-
import java.lang.reflect.Method;
-
public class TimeProxy implements Flyable {
-
private InvocationHandler handler;
-
public TimeProxy(InvocationHandler handler) {
-
this.handler = handler;
-
}
-
@Override
-
public void fly() {
-
try {
-
Method method = com.youngfeng.proxy.Flyable.class.getMethod("fly");
-
this.handler.invoke(this, method, null);
-
} catch(Exception e) {
-
e.printStackTrace();
-
}
-
}
-
}
MyInvocationHandler.java
-
public class MyInvocationHandler implements InvocationHandler {
-
private Bird bird;
-
public MyInvocationHandler(Bird bird) {
-
this.bird = bird;
-
}
-
@Override
-
public void invoke(Object proxy, Method method, Object[] args) {
-
long start = System.currentTimeMillis();
-
try {
-
method.invoke(bird, new Object[] {});
-
} catch (IllegalAccessException e) {
-
e.printStackTrace();
-
} catch (InvocationTargetException e) {
-
e.printStackTrace();
-
}
-
long end = System.currentTimeMillis();
-
System.out.println("Fly time = " + (end - start));
-
}
-
}
至此,整个方法栈的调用栈变成了这样:
看到这里,估计很多同学已经晕了,在静态代理部分,我们在代理类中传入了被代理对象。可是,使用newProxyInstance生成动态代理对象的时候,我们居然不再需要传入被代理对象了。我们传入了的实际对象是InvocationHandler实现类的实例,这看起来有点像生成了InvocationHandler的代理对象,在动态生成的代理类的任意方法中都会间接调用InvocationHandler->invoke(proxy, method, args)方法。
其实的确是这样。TimeProxy真正代理的对象就是InvocationHandler,不过这里设计的巧妙之处在于,InvocationHandler是一个接口,真正的实现由用户指定。另外,在每一个方法执行的时候,invoke方法都会被调用 ,这个时候如果你需要对某个方法进行自定义逻辑处理,可以根据method的特征信息进行判断分别处理。
如何使用
上面这段解释是告诉你在执行Proxy->newProxyInstance方法的时候真正发生的事情,而在实际使用过程中你完全可以忘掉上面的解释。按照设计者的初衷,我们做如下简单归纳:
- Proxy->newProxyInstance(infs, handler) 用于生成代理对象
- InvocationHandler:这个接口主要用于自定义代理逻辑处理
- 为了完成对被代理对象的方法拦截,我们需要在InvocationHandler对象中传入被代理对象实例。
查看上面的代码,你可以看到我将Bird实例已经传入到了MyInvocationHandler中,原因就是第三点。
这样设计有什么好处呢?有人说,我们大费周章,饶了一大圈,最终变成了这个样子,到底图什么呢?
想象一下,到此为止,如果我们还需要对其它任意对象进行代理,是否还需要改动newProxyInstance方法的源码,答案是:完全不需要!
只要你在newProxyInstance方法中指定代理需要实现的接口,指定用于自定义处理的InvocationHandler对象,整个代理的逻辑处理都在你自定义的InvocationHandler实现类中进行处理。至此,而我们终于可以从不断地写代理类用于实现自定义逻辑的重复工作中解放出来了,从此需要做什么,交给InvocationHandler。
事实上,我们之前给自己定下的目标“使用同一个类来计算任意对象的任一方法的执行时间”已经实现了。严格来说,是我们超额完成了任务,TimeProxy不仅可以计算方法执行的时间,也可以打印方法执行日志,这完全取决于你的InvocationHandler接口实现。因此,这里取名为TimeProxy其实已经不合适了。我们可以修改为和JDK命名一致,即$Proxy0,感兴趣的同学请自行实践,本篇文章的代码将放到我的Github仓库,文章结尾会给出代码地址。
JDK实现揭秘
通过上面的这些步骤,我们完成了一个简易的仿JDK实现的动态代理逻辑。接下来,我们一起来看一看JDK实现的动态代理和我们到底有什么不同。
Proxy.java
InvocationHandler
可以看到,官方版本Proxy类提供的方法多一些,而我们主要使用的接口newProxyInstance参数也和我们设计的不太一样。这里给大家简单解释一下,每个参数的意义:
- Classloader:类加载器,你可以使用自定义的类加载器,我们的实现版本为了简化,直接在代码中写死了Classloader。
- Class<?>[]:第二个参数也和我们的实现版本不一致,这个其实很容易理解,我们应该允许我们自己实现的代理类同时实现多个接口。前面设计只传入一个接口,只是为了简化实现,让你专注核心逻辑实现而已。
最后一个参数就不用说了,和我们实现的版本完全是一样的。
仔细观察官方版本的InvocationHandler,它和我们自己的实现的版本也有一个细微的差别:官方版本invoke方法有返回值,而我们的版本中是没有返回值的。那么,返回值到底有什么作用呢?直接来看官方文档:
核心思想:这里的返回值类型必须和传入接口的返回值类型一致,或者与其封装对象的类型一致。
遗憾的是,这里并没有说明返回值的用途,其实这里稍微发挥一下想象力就知道了。在我们的版本实现中,Flyable接口的所有方法都是没有返回值的,问题是,如果有返回值呢?是的,你没有猜错,这里的invoke方法对应的就是传入接口中方法的返回值。
答疑解惑
invoke方法的第一个参数proxy到底有什么作用?
这个问题其实也好理解,如果你的接口中有方法需要返回自身,如果在invoke中没有传入这个参数,将导致实例无法正常返回。在这种场景中,proxy的用途就表现出来了。简单来说,这其实就是最近非常火的链式编程的一种应用实现。
动态代理到底有什么用?
学习任何一门技术,一定要问一问自己,这到底有什么用。其实,在这篇文章的讲解过程中,我们已经说出了它的主要用途。你发现没,使用动态代理我们居然可以在不改变源码的情况下,直接在方法中插入自定义逻辑。这有点不太符合我们的一条线走到底的编程逻辑,这种编程模型有一个专业名称叫 AOP。所谓的AOP,就像刀一样,抓住时机,趁机插入。
基于这样一种动态特性,我们可以用它做很多事情,例如:
- 事务提交或回退(Web开发中很常见)
- 权限管理
- 自定义缓存逻辑处理
- SDK Bug修复
...
如果你阅读过 Android_Slide_To_Close 的源码会发现,它也在某个地方使用了动态代理设计模式。
总结
到此为止,关于动态代理的所有讲解已经结束了,原谅我使用了一个诱导性的标题“骗”你进来阅读这篇文章。如果你不是一个久经沙场的“老司机”,10分钟完全看懂动态代理设计模式还是有一定难度的。但即使没有看懂也没关系,如果你在第一次阅读完这篇文章后依然一头雾水,就不妨再仔细阅读一次。在阅读的过程中,一定要跟着文章思路去敲代码。反反复复,一定会看懂的。我在刚刚学习动态代理设计模式的时候就反复看了不下5遍,并且亲自敲代码实践了多次。
为了让你少走弯路,我认为看懂这篇文章,你至少需要学习以下知识点:
- 至少已经理解了面向对象语言的多态特性
- 了解简单的反射用法
- 会简单使用 JavaPoet 生成Java源码
如果你在阅读文章的过程中,有任何不理解的问题或者建议,欢迎在文章下方留言告诉我!
上一篇: java面试题