又又攒了一个月的Android面试题奉上
前言
日子过的好快,2020结束了,不管你在上一年中是开心,是难过,是苦闷,还是平淡,都过去了,向前看,老铁们~
新一期的面试题月刊奉上。
新的一年祝大家技术越来越棒,钱越来越多,当然身体健康是最重要的,也希望疫情早点结束!
kotlin为什么被设计出来
kotlin被设计出来并被Google推广,主要有以下优势:
-
完全兼容Java
-
更少的空指针异常
-
更少的代码量,更快的开发速度
kotlin工作原理
首先,我们了解下Java的工作原理:
Java 代码是经过编译才能运行的。首先会编译成class文件,然后通过java虚拟机运行,在Android中也就是ART。
所以,任何语言只要能被编译成符合规格的class文件,就能被java虚拟机运行,也就能运行在我们的Android手机上,kotlin亦是如此。
-
另外Android studio也提供了一个功能,可以查看kotlin对应的字节码:
Tools -> Kotlin -> Show Kotlin Bytecode
再点击Decomplie还可以反编译成Java文件。
kotlin的空安全
-
java中,我们可以任意初始化一个变量,而不需要赋值,比如String,就有它的默认值null。
String a;
如果要调用对象的参数,必须判空:
if (a!=null){ Log.d("lz","length="+a.length()); }else{ Log.d("lz","length=null"); }
-
kotlin中,为了保证减少空指针的问题,不允许直接设置为空,可以通过?=的方式设置可以为空。
val a: String ? = null
1)赋值的时候,可以直接使用?来表示这个对象可能为空,如果为空则表达式结果也为空,而不用进行非空判断。
//如果 b 非空,就返回 b.length,否则返回 null val length = b?.length //如果 b 非空,就返回 String类型的b,否则返回 null val str = b as? String
也就是通过问号来表示对象为空则整个表达式结果为空,而不会报错空指针。
2)如果需要设定为空的时候返回的表达式值不为空,可以用操作符?:来表示,也叫Elvis操作符。
//b为空则表达式返回-1 val length = b?.length ?: -1
3)如果要将值转换为非空类型,就可以使用 !!来标识非空,但是这种操作符就有可能会抛出空指针异常,如果实际对象为空的话。所以这种操作符相当于去除了空判断。
//如果b为空,空指针异常 val length = b!!.length
val和var
val,全称value,声明一个不可变的变量,这种变量在初始赋值之后就再也不能重新赋值了,所以相当于java中的final变量。
var,全称variable(可变的),所以是用来声明一个可变的变量,可以重复赋值。
kotlin中这么设计的原因主要是把不可变变量 这种因素和可变变量拿到同一级来设计,也就是说我们以后编码设计变量的时候,必须要考虑这个变量是不可变还是可变的,养成良好习惯,不是以前在java中需要添加final这种稍微繁琐的举动。
扩展函数(Extension Function)
扩展函数,其实就是扩展类的函数,可以在已有的类中添加新的方法,比继承更加简洁优雅方便。
-
扩展函数比如:
fun Activity.showToast( msgId:Int){ Toast.makeText(this,msgId,Toast.LENGTH_SHORT).show() }
这样任何的Activity里面就可以直接调用showToast方法来展示Toast了。
-
同样,可以设置扩展属性,比如:
var MutableList.lastData: T //获取List中最后一个对象 get() = this[this.size - 1] //设置List中最后一个对象的值 set(value) { this[this.size - 1] = value }
用法:
var strs = mutableListOf() strs.lastData="heihei" Log.e(TAG,"lastdata= ${strs.lastData}")
这里还涉及到两个知识点:
-
kotlin中,在使用对象的get和set方法,可以直接省略,直接使用属性名即可,会根据表达式的实际功能来添加对应的set或者get方法。
-
kotlin中,对于$符号表示 串模板,就是可计算的代码片段,可以将其计算结果链接到字符串中。
扩展属性原理
kotlin这个扩展功能确实设计的很巧妙,那就一起来研究下它的原理:
按照上面的方法,也就是Tools -> Kotlin -> Show Kotlin Bytecode -> Decomplie, 我们得到showToast扩展函数和使用代码所对应的java代码:
//扩展函数 public final class UtilsKt { public static final void showToast(@NotNull Activity $this$showToast, int msgId) { Intrinsics.checkParameterIsNotNull($this$showToast, "$this$showToast"); Toast.makeText((Context)$this$showToast, msgId, 0).show(); } } //使用 UtilsKt.showToast(this, 1900026);
可以看到所谓的扩展函数不过就是自动生成一个带有当前对象的函数,扩展函数的所在类被public final修饰,函数被public static final修饰,然后扩展的那个类被作为方法的一个参数传进去,这样就跟我们用java的时候写的工具类很像。
然后使用的时候就跟我们使用工具类一样调用工具类的方法即可。
可以定义同名的扩展方法吗
在同一个包名下,是不可以定义相同类相同方法名的扩展方法的。但是,在不同包名下,是可以定义的。
比如我在不同的包名下定义了相同的扩展方法:
//Utils2.kt package com.example.studynote.kotlin fun Activity.showToast(msg:String){ Toast.makeText(this,msg,Toast.LENGTH_LONG).show() } //Utils.kt package com.example.studynote fun Activity.showToast(msg:String){ Toast.makeText(this,msg,Toast.LENGTH_SHORT).show() }
具体会用哪个呢?就要看你导入的包是哪个了~
扩展方法可以覆盖掉某个类的已有方法吗
肯定是不能的,如果一个类的扩展方法和它已有方法同名,是可以编译过的。
但是调用的时候会优先调用类中本来就有的方法,而不是扩展方法。
kotlin中有没有用到;的时候
kotlin中一般会把;省略,但是有两种情况还是会用到:
-
枚举中,如果有方法的情况,必须用;来分割枚举常量列表和方法
enum class Color { RED, BLACK, BLUE, GREEN, WHITE; fun getTopColor():Color { return BLACK } }
-
两个表达式在一行的时候,当然这种有点累赘,为啥要写成一行呢是吧:
var test="nihao" ; var test2="heihei"
let、apply、with、run
-
let 默认当前这个对象作为闭包的it参数,返回值为函数最后一行,或者return
fun getInt():Int{ "jimu".let { println(it.length) return 0 } }
-
apply 在apply函数范围内,可以任意调用该对象的任意方法,并返回该对象
fun getInt(): Int { return ArrayList().apply { add("jimu") }.size }
-
with 返回值是最后一行,这点类似let。可以直接调用对象的方法,这点类似apply。
fun getInt(): Int { return with(ArrayList()){ add("jimu") size } }
-
run run和with很像,可以调用对象的任意函数,返回值是最后一行
fun getInt(): Int { return ArrayList().run{ add("jimu") size } }
lateinit和by lazy
上篇说过,Kotlin有空限制,所以有些变量如果不想设置为空的时候初始化该怎么做呢?这就用到延迟初始化了,lateinit和by lazy都能实现。
-
lateinit
lateinit用于修饰var变量,它会让编译器暂时忽略初始化这个事情,到后面用的时候我们在进行初始化,但是不能用到基本数据类型,比如int,double这种。
lateinit var test: String
-
by lazy
by lazy用于val类型的变量,它会暂时不进行初始化,并且在第一次使用的时候自动调用我们设置好的表达式进行初始化。
val str by lazy { println("Init lazy") "Hello World" }
Kotlin中的构造函数
kotlin中构造函数分为主构造函数和次构造函数。
-
主构造函数
主构造函数没有函数体,直接定义在类名后。每个类都会默认带一个不带参数的构造函数,也可以直接定义参数,如果需要在构造函数中进行初始化工作,可以用init关键字:
class Student { } class Student(var name: String) { init { Log.e(TAG,"name=$name") } } class Student constructor(var name: String) { init { Log.e(TAG,"name=$name") } }
-
次构造函数
除了类名后这种主构造函数,其他的构造函数方法就是通过constructor关键字来定义次构造函数,一个类可以定义多个次构造函数。如果主构造函数和次构造函数同时存在的时候,次构造函数必须调用主构造函数。
class Student{ private val username: String constructor(username: String){ this.username = username } } class Student(username: String) { private var username: String private var age: Int init { this.username = username this.age = 10 } constructor(username: String, age: Int) : this(username) { this.age = age } }
协程
Kotlin协程是对线程的一种封装,同样是用来解决并发任务(异步任务)的方案,可以理解为一种线程框架,特点是挂起时不需要阻塞线程,更好的解决了线程切换,魔鬼调用的问题。
简单举个例子,具体的说明大家可以翻翻以前的文章——协程三问。
GlobalScope.launch(Dispatchers.Main) { var name = ioTask() updateUI(name) var name1 = ioTask() updateUI(name1) var name2 = ioTask() updateUI(name2) } private suspend fun ioTask(): String { var name = "" withContext(Dispatchers.IO) { //耗时操作,比如网络接口访问 name = "jimu" } return name }
-
GlobalScope.launch去开启一个协程
-
Dispatchers.Main表示运行在主线程
-
suspend关键字用于标记挂起函数的关键字
-
withContext函数用来构建一个协程作用域,可以标明作用线程,比如这里的Dispatchers.IO。这个函数必须在挂起函数或者协程中执行
说说插值器和估值器
-
插值器
一般指时间插值器TimeInterpolator,是设置 属性值 从初始值过渡到结束值 的变化规律,比如匀速,加速,减速等等。可以通过xml属性和java代码设置。
系统默认的插值器是AccelerateDecelerateInterpolator,即先加速后减速。
//匀速插值器设置 android:interpolator="@android:anim/linear_interpolator" alphaAnimation.setInterpolator(new LinearInterpolator());
属性动画中,插值器的含义就是要设置时间和属性的变化关系,也就是根据动画的进度(0%-100%)通过逻辑计算 计算出当前属性值改变的百分比。比如匀速关系就是动画进度和属性值改变的进度保持一致,50%时间进度就完成了属性值50%的变化。
//自定义匀速插值器
public class MyLinearInterpolator implements TimeInterpolator { @Override public float getInterpolation(float input) { return input; }
-
估值器
又叫类型估值算法TypeEvaluator,用来设置 属性值 从初始值过渡到结束值 的变化具体数值,刚才介绍的插值器是指变化规律,而这个估值器是决定具体的变化数值,是用来协助插值器完成动画设置。
比如属性动画设置:
ObjectAnimator anim = ObjectAnimator.ofObject(view, "scale", new IntEvaluator(),1,10); //系统估值器类型 IntEvaluator:针对整型属性 FloatEvaluator:针对浮点型属性 ArgbEvaluator:针对Color属性
可以看看IntEvaluator源码,其实就是根据三个参数—估值小数(fraction),开始值(startValue)和 结束值(endValue)然后计算具体属性变化的值:
public class IntEvaluator implements TypeEvaluator { public Integer evaluate(float fraction, Integer startValue, Integer endValue) { int startInt = startValue; return (int)(startInt + fraction * (endValue - startInt)); } }
所以要实现一个完整的属性动画,需要估值器和插值器进行协同工作:
-
首先由TimeInterpolator(插值器)根据时间流逝的百分比计算出当前属性值改变的百分比,并且 插值器 将这个百分比返回,这个时候 插值器 的工作就完成了。
-
比如 插值器 返回的值是0.5,很显然我们要的不是0.5,而是当前属性的值,即当前属性变成了什么值,这就需要 估值器根据当前属性改变的百分比来计算改变后的属性值,根据这个属性值,我们就可以设置当前属性的值了。
使用动画的注意事项
-
OOM问题 :这个问题主要出现在帧动画中,当图片数量较多且图片较大时就极易出现OOM,这个在实际开发中要尤其注意,尽量避免使用帧动画。
-
内存泄露 :在属性动画中有一类无限循环的动画,这类动画需要在Activity退出时及时停止,否则将导致Activity无法释放从而造成内存泄露,通过验证后发现View动画并不存在此问题。
-
兼容性问题 :动画在3.0以下的系统有兼容性问题,在某些特殊场景可能无法正常工作,因此要做好适配工作。
-
View动画的问题 :View动画是对View的影像做动画,并不是真正改变View的状态,因此有时候会出现动画完成后View无法隐藏的现象,即setVisibility(View.GOEN)失效了,这个时候只要调用view.clearAnimation()清除View动画即可解决问题。
-
不要使用px :在进行动画的过程中,要尽量使用dp,使用px会导致在不用的设备上有不用的效果。
-
动画元素的交互 :从3.0开始,将view移动(平移)后,属性动画的单击事件触发位置为移动后的位置,但是View动画仍然在原位置。在Android3.0以前的系统中,不管是View动画还是属性动画,新位置都无法触发单击事件同时,老位置仍然能触发单击事件(因为属性动画在Android3.0以前是没有的,是通过兼容包实现的,底层也是调用View动画)。
-
硬件加速 :使用动画的过程中,建议开启硬件加速,这样会提高动画的流畅性。
关于硬件加速,直观上说就是依赖GPU实现图形绘制加速,同软硬件加速的区别主要是图形的绘制究竟是GPU来处理还是CPU,如果是GPU,就认为是硬件加速绘制,反之,软件绘制
RxJava的订阅关系
Observable.create(new ObservableOnSubscribe() { @Override public void subscribe(@NonNull ObservableEmitter emitter) throws Throwable { emitter.onNext(1); emitter.onComplete(); } }).subscribe(new Observer() { @Override public void onNext(Integer integer) { Log.d(TAG, "onNext: " + integer); } @Override public void onCompleted() { } @Override public void onError(Throwable e) { Toast.makeText(activity, "Error!", Toast.LENGTH_SHORT).show(); } });
代码中主要有三个角色:
-
被订阅者Observable,是整个事件的来源,可以发射数据给订阅者。
-
订阅者Observer,通过subscribe方法和被订阅者产生关系,也就是开始订阅,同时可以接受被订阅者发送的消息。
-
发射器Subscriber/Emitter,在Rxjava2之后,发射器改为了Emitter,他的作用主要是用来发射一系列事件的,比如next事件,complete事件等等。
有了这三个角色,一个完整的订阅关系也就生成了。
Observer处理完onComplete后会还能onNext吗?
要弄清楚这个问题,得去看看onComplete,onNext方法到底做了什么。
@Override public void onComplete() { if (!isDisposed()) { try { observer.onComplete(); } finally { dispose(); } } } @Override public void onNext(T t) { if (t == null) { onError(new NullPointerException("onNext called with null. Null values are generally not allowed in 2.x operators and sources.")); return; } if (!isDisposed()) { observer.onNext(t); } } public static boolean isDisposed(Disposable d) { return d == DISPOSED; } public static boolean dispose(AtomicReference field) { Disposable current = field.get(); Disposable d = DISPOSED; if (current != d) { current = field.getAndSet(d); if (current != d) { if (current != null) { current.dispose(); } return true; } } return false; }
源码还是比较清晰明了,无论是onComplete还是onNext,都会判断当前订阅是否被取消,也就是Disposable类型的变量的引用是否等于DISPOSED,如果等于则代表该订阅已经被取消,起点和终点已经断开联系。而在onComplete方法的结尾调用了dispose方法,将原子引用类中的 Disposable 对象设置为 DisposableHelper 内的 DISPOSED 枚举实例,即断开订阅关系,所以在这之后所有的onNext,onComplete,onError方法中的isDisposed判断都不会通过,也就不会执行后续的数据发送等处理了。
RxJava中的操作符
-
concatMap
-
flatMap
这两个操作符的功能是一样的,都是将一个发射数据的Observable变换为多个Observables,然后将它们发射的数据放进一个单独的Observable。区别在于concatMap是有序的,flatMap是无序的,concatMap最终输出的顺序与原序列保持一致,而flatMap则不一定,有可能出现交错。
举个例子,发送数字01234,通过操作符对他们进行+1处理,发送2的时候进行一个延时:
Observable.fromArray(1,2,3,4,5) .flatMap(new Function<Integer, ObservableSource>() { @Override public ObservableSource apply(@NonNull Integer integer) throws Exception { int delay = 0; if(integer == 2){ delay = 500;//延迟发射 } return Observable.just(integer*10).delay(delay, TimeUnit.MILLISECONDS); } }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Consumer() { @Override public void accept(@NonNull Integer integer) throws Exception { Log.e("jimu","accept:"+integer); } });
如上述操作,最终打印结果为:10,20,40,50,30。因为发送数字2的时候,进行了延时。
但是如果flatMap操作符改成concatMap,打印结果就是10,20,30,40,50了,这是因为concatMap是有序的,会按照原序列的顺序进行变换输出。
-
merge、concat、zip,合并
这几个操作符是用作合并发射物的,可以将多个Obserable和并成一个Obserable:
Observable odds=Observable.just(1,2,3,4); Observable events=Observable.just(5,6,7,8); Observable.merge(odds,events).subscribe(i->Log.d("TAG","merge->"+i));
区别在于concat操作符是在合并后按顺序串行执行,merge操作符是在合并后按时间线并行执行,如果出现某个数据进行延时发射,那么结果序列就会发生变化。
而zip操作符的特点是合并之后并行执行,发射事件和最少的一个相同,什么意思呢?比如一个发送两个数据的Obserable和一个发射4条数据的Obserable进行zip合并,那么最终只会有两条数据被发射出来,看个例子:
Observable .zip(Observable.just(1,2),Observable.just(3,4,5,6),new BiFunction() { @Override public Integer apply(@NonNull Integer response, @NonNull Integer response2) throws Exception { //将两个发射器的结果相加 return response+response2; } }) .subscribe(new Consumer() { @Override public void accept(@NonNull Integer s) throws Exception { Log.e("lz","accept+"+s); } });
结果只会有两条数据:4,6。第二个发射器发射的后面两条数据会被抛弃。
-
interval,周期执行
这个操作符主要用作定时周期任务,比如我需要每100ms发送一次数据:
Observable.interval(100, TimeUnit.MILLISECONDS) .subscribe(new Observer() { @Override public void onCompleted() { } @Override public void onError(Throwable e) { } @Override public void onNext(Long aLong) { } });
-
timer,delay延迟发送数据
这两个操作符都是用作延时发送数据,不同在于timer是创建型操作符,而delay是辅助型操作符。意思就是timer操作符是可以直接创建一个Observable,然后在订阅之后延时发送数据项,看例子:
Observable .timer(1000,TimeUnit.MILLISECONDS) .subscribeOn(Schedulers.io()) .subscribe(disposableObserver);
而delay是当原始的Observable发送数据后,启动一个定时器,然后延时将这个数据发送,所以它相当于是处在上游与下游之间的一个辅助项,用作延时发送,它的作用对象必须是个创建好的Observable:
Observable .just(0L) .doOnNext(new Consumer() { @Override public void accept(Long aLong) throws Exception { } } .timer(1000,TimeUnit.MILLISECONDS) .subscribeOn(Schedulers.io()) .subscribe(disposableObserver);
Drawable、Canvas、Bitmap
Drawable表示的是一种可以在Canvas上进行绘制的抽象的概念,种类很多,最常见的颜色和图片都可以是一个Drawable。
所以他是一种抽象的概念,是表示一种图像,但是又不全是图片,也可以表示颜色,一般被用作View的背景或者填充图。
到这里有的小伙伴可能又要问了,Canvas又是什么呢?
Canvas一个矩形区域, 没有边界, 我们可以在上面调用任意drawXXX开头的方法绘制到引用的Bitmap上. 同时提供对图形的处理, 比如裁剪, 缩放, 旋转(需要Matrix对象配合使用). 所以Canvas与其说是画布, 不如说是一个绘制工具,它实际操作和存储的像素数据都在它的私有成员 Bitmap 对象中,所谓的画到Canvas画布上,其实就是画到其Bitmap,存储到其Bitmap变量中。
说到这,又要提下Bitmap了:
Bitmap是一个存储格式, 存储了一个矩形区域内各像素点的信息. 这种格式适合显示, 但是存储效率低.可以理解为int[] buffer,用来保存每个像素的信息。
所以这三者的关系简单点就是:
Drawable表示一个可以被绘制的图像,是一个抽象概念,需要通过Canvas绘制到Bitmap上,作为一个图像的存储。所以Bitmap是Drawable存在的一种实体。
Drawable分类
Drawable的种类很多,这里列举几个比较常用的,具体代码使用方式可以看往期文章这次来把Drawable翻了个遍:
https://mp.weixin.qq.com/s?__biz=MzU0MTYwMTIzMw==&mid=2247484547&idx=1&sn=63992f7a05331ceedcae8059ff322ea7&scene=21#wechat_redirect
-
BitmapDrawablw 最常用的Drawable,表示的就是一张图片,一张带规则的图片,可以设置一些规则,比较适用于对图片有限制的情况,比如背景图。
-
NinePatchDrawable 和BitmapDrawablw类似,使用时src传.9图片即可。实际上BitmapDrawablw也可以直接用.9图片,所以这个NinePatchDrawable没有太大的实际作用。
-
ShapeDrawable 这个很常见,一般纯颜色的图形就用这个画出来。
-
LayerDrawable
这是一种层次化的Drawable,相当于一种Drawable的布局嵌套,或者说集合,通过不同的Drawable放置到不同的层上达到一种叠加后的效果。
-
StateListDrawble
一般用作点击效果,对应标签是selector,可以设置按下,点击后,不点击时候的各种状态。
-
LevelListDrawable
和LayerDrawable类似,也是一个Drawable的集合,但是他是有等级的概念的,也就是可以通过设置不同的等级来展示对应的drawable,有点像一个有多种样式的Drawable,可以通过代码来展示哪一个样式。可以通过ImageView的setImageLevel方法来切换Drawable。
-
TransitionDrawable
该Drawable可以实现两个Drawable之间的淡入淡出,对应的标签是transition。
-
InsetDrawable
可以将其他的Drawable内嵌到自己当中,当一个View希望自己的背景比自己的实际区域小的时候,就可以采用这个,通过LayerDrawable也可以实现。一般用作加大点击区域。
-
ScaleDrawable
用于缩放,并且和它的等级Level有关,等级为0则不可见。
-
ClipDrawable
用作裁剪,会根据自己的等级来裁剪一个Drawable,等级0表示完全裁剪,即整个Drawable都不可见,等级10000表示不裁剪,所以主要是通过控制drawable的等级来完成裁剪功能
Window是什么?在Android中都用到了哪些地方?
-
首先,它是一个窗口,是Android中唯一的展示视图的中介,所有的视图都是通过Window来呈现的,无论是Activity,Dialog或Toast,他们的视图都是附加到WIndow上的,所以Window是View的直接管理者。
-
Window是一个抽象类,他的具体实现就是PhoneWindow。
-
Window的具体实现在WindowManagerService中,但是创建Window或者访问Window的操作都需要WindowManager。所以这就需要WindowManager和WindowManagerService进行交互,交互的方式就是通过IPC,具体涉及的参数就是token。
-
每一个Window都对应着一个View和一个ViewRootImpl,Window和View通过ViewRootImpl建立联系,所以Window并不是实际存在的,而是以View的形式存在。
涉及到Window的地方:
-
事件分发机制。界面上事件分发机制的开始都是这样一个过程:DecorView——>Activity——>PhoneWindow——>DecorView——>ViewGroup。之前看过一个比较有趣的问题:事件到底是先到DecorView还是先到Window的?,其实是先到DecorView的,具体逻辑可以自己翻下源码,有机会也可以出篇文章讲讲~
-
各种视图的显示。比如Activity的setContentView,Dialog,Toast的显示视图等等都是通过Window完成的。
Window的分层和类别?
-
由于界面上有不止一个的Window,所以就有了分层的概念。每个Window都有自己对应的Window层级—z-ordered,层级大的会覆盖到层级小的上面,类似HTML中的z-index。
Window主要分为三个类别:
-
应用Window 。对应着一个Activity,Window层级为1-99,在视图最下层。
-
子Window 。不能单独存在,需要附属在特定的父Window之中(如Dialog就是子Window),Window层级为1000~1999。
-
系统Window 。需要声明权限才能创建的Window,比如Toast和系统状态栏,Window层级为2000~2999,处在视图最上层。
Window的内部机制—添加、删除、更新。
-
之前说过,Window的操作都是通过WindowManager来完成的,而WindowManager是一个接口,他的实现类是WindowManagerImpl,并且全部交给WindowManagerGlobal来处理。下面具体说下addView,updateViewLayout,和removeView。
1) addView
public void addView(View view, ViewGroup.LayoutParams params, Display display, Window parentWindow) { //... ViewRootImpl root; View panelParentView = null; root = new ViewRootImpl(view.getContext(), display); view.setLayoutParams(wparams); mViews.add(view); mRoots.add(root); mParams.add(wparams); try { root.setView(view, wparams, panelParentView); } catch (RuntimeException e) { // BadTokenException or InvalidDisplayException, clean up. if (index >= 0) { removeViewLocked(index, true); } throw e; } } }
-
首先,通过add方法修改了WindowManagerGlobal中的一些参数,比如mViews—存储了所有Window所对应的View,mRoots——所有Window所对应的ViewRootImpl,mParams—所有Window对应的布局参数。
-
其次,setView方法主要完成了两件事,一是通过requestLayout方法完成异步刷新界面的请求,进行完整的view绘制流程。其次,会通过WindowSession进行一次IPC调用,交给到WMS来实现Window的添加。
2)updateViewLayout
public void updateViewLayout(View view, ViewGroup.LayoutParams params) { //... final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams)params; view.setLayoutParams(wparams); synchronized (mLock) { int index = findViewLocked(view, true); ViewRootImpl root = mRoots.get(index); mParams.remove(index); mParams.add(index, wparams); root.setLayoutParams(wparams, false); } }
这里更新了WindowManager.LayoutParams和ViewRootImpl.LayoutParams,然后在ViewRootImpl内部同样会重新对View进行绘制,最后通过IPC通信,调用到WMS的relayoutWindow完成更新。
3)removeView
public void removeView(View view, boolean immediate) { if (view == null) { throw new IllegalArgumentException("view must not be null"); } synchronized (mLock) { int index = findViewLocked(view, true); View curView = mRoots.get(index).getView(); removeViewLocked(index, immediate); if (curView == view) { return; } throw new IllegalStateException("Calling with view " + view + " but the ViewAncestor is attached to " + curView); } } private void removeViewLocked(int index, boolean immediate) { ViewRootImpl root = mRoots.get(index); View view = root.getView(); if (view != null) { InputMethodManager imm = view.getContext().getSystemService(InputMethodManager.class); if (imm != null) { imm.windowDismissed(mViews.get(index).getWindowToken()); } } boolean deferred = root.die(immediate); if (view != null) { view.assignParent(null); if (deferred) { mDyingViews.add(view); } } }
该方法中,通过view找到mRoots中的对应索引,然后同样走到ViewRootImpl中进行View删除工作,通过die方法,最终走到dispatchDetachedFromWindow()方法中,主要做了以下几件事:
-
回调onDetachedFromeWindow。
-
垃圾回收相关操作;
-
通过Session的remove()在WMS中删除Window;
-
通过Choreographer移除监听器
Window中的Token是什么?
public abstract class Window { private IBinder mAppToken; }
-
是Window类中的一个变量,是一个Binder对象。在Window中主要是实现WindowManagerService和应用所在的进程通信,也就是上文说到的WindowManager和WindowManagerService进行交互。
-
是一个添加view的权限标识。拥有token的context可以创建界面、进行UI操作,而没有token的context如service、Application,是不允许添加view到屏幕上的。所以它存在的意义就是为了保护window的创建,也是为了防止Application或Service来做进行view或者UI相关的一些操作。
Activity,Dialog,Toast的Window创建过程
上篇文章说过Dialog的创建,先来回顾下:
1)Dialog
//构造函数 Dialog(Context context, int theme, boolean createContextThemeWrapper) { //...... //获取了WindowManager对象,mContext一般是个Activity,获取系统服务一般是通过Binder获取 mWindowManager = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE); //创建新的Window Window w = PolicyManager.makeNewWindow(mContext); mWindow = w; //这里也是上方mWindow.getCallback()为什么是Activity的原因,在创建新Window的时候会设置callback为自己 w.setCallback(this); w.setOnWindowDismissedCallback(this); //关联WindowManager与新Window,token为null w.setWindowManager(mWindowManager, null, null); w.setGravity(Gravity.CENTER); mListenersHandler = new ListenersHandler(this); } //show方法 public void show() { //...... if (!mCreated) { //回调Dialog的onCreate方法 dispatchOnCreate(null); } //回调Dialog的onStart方法 onStart(); //获取当前新Window的DecorView对象 mDecor = mWindow.getDecorView(); WindowManager.LayoutParams l = mWindow.getAttributes(); try { //把一个View添加到Activity共用的windowManager里面去 mWindowManager.addView(mDecor, l); //...... } finally { } }
可以看到一个Dialog从无到有经历了以下几个步骤:
-
首先创建了一个新的Window,类型是PhoneWindow类型,与Activity创建Window过程类似,并设置setCallback回调。
-
将这个新Window与从Activity拿到的WindowManager对象相关联,也就是dialog与Activity公用了同一个WindowManager对象。
-
show方法展示Dialog,先回调了Dialog的onCreate,onStart方法。
-
然后获取Dialog自己的DecorView对象,并通过addView方法添加到WindowManager对象中,Dialog出现到屏幕上。
2)Activity
final void attach(Context context, ActivityThread aThread, Instrumentation instr, IBinder token, int ident, Application application, Intent intent, ActivityInfo info, CharSequence title, Activity parent, String id, NonConfigurationInstances lastNonConfigurationInstances, Configuration config, String referrer, IVoiceInteractor voiceInteractor, Window window, ActivityConfigCallback activityConfigCallback, IBinder assistToken) { attachBaseContext(context); mFragments.attachHost(null /*parent*/); mWindow = new PhoneWindow(this, window, activityConfigCallback); mWindow.setWindowControllerCallback(this); mWindow.setCallback(this); mWindow.setOnWindowDismissedCallback(this); mWindow.getLayoutInflater().setPrivateFactory(this); if (info.softInputMode != WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED) { mWindow.setSoftInputMode(info.softInputMode); } if (info.uiOptions != 0) { mWindow.setUiOptions(info.uiOptions); } mUiThread = Thread.currentThread(); //... mWindow.setWindowManager( (WindowManager)context.getSystemService(Context.WINDOW_SERVICE), mToken, mComponent.flattenToString(), (info.flags & ActivityInfo.FLAG_HARDWARE_ACCELERATED) != 0); if (mParent != null) { mWindow.setContainer(mParent.getWindow()); } mWindowManager = mWindow.getWindowManager(); mCurrentConfig = config; mWindow.setColorMode(info.colorMode); setAutofillOptions(application.getAutofillOptions()); setContentCaptureOptions(application.getContentCaptureOptions()); } public void setContentView(@LayoutRes int layoutResID) { // 交给Window getWindow().setContentView(layoutResID); // 创建ActionBar initWindowDecorActionBar(); }
关于Activity的启动流程,相比大伙都知道些,流程最后会走到ActivityThread中的performLauchActivity方法,然后会创建Activity的实例对象,并调用attach方法,也就是上述贴的源码。
在这个方法中,创建了新的Window对象,设置回调接口。这个回调接口主要就是用作Window在接收到外界状态改变的时候,就会回调给这个callback,比如onAttachedToWindow、dispatchTouchEvent方法等,这个上篇文章也有说过,事件分发的时候就是通过在DecorView中这个callback进行分发的。
然后view怎么显示到界面上的呢,Activity可没有show方法哦?其实就是通过setContentView方法。该方法主要做了以下几件事:
-
创建DecorView,如果不存在的话。
-
然后将xml中解析到的view添加到DecorView的mContentParent中,也就是布局为android.R.id.content的ContentView。
-
回调onContentChanged方法,通知Activity视图已经发生改变。
贴张图:
到这里,一个有完整view结构的DecorView就创建出来了,但是它还没有被显示到手机界面上,也就是没有被添加到Window中。最后要调用了WMS的addView方法才会被用户真正看到:
void makeVisible() { if (!mWindowAdded) { ViewManager wm = getWindowManager(); wm.addView(mDecor, getWindow().getAttributes()); mWindowAdded = true; } mDecor.setVisibility(View.VISIBLE); }
3)Toast
public void show() { if (mNextView == null) { throw new RuntimeException("setView must have been called"); } INotificationManager service = getService(); String pkg = mContext.getOpPackageName(); TN tn = mTN; tn.mNextView = mNextView; final int displayId = mContext.getDisplayId(); try { service.enqueueToast(pkg, tn, mDuration, displayId); } catch (RemoteException e) { // Empty } } public void cancel() { mTN.cancel(); } //class TN public void handleShow() { // ...... mWM = (WindowManager)context.getSystemService(Context.WINDOW_SERVICE); mWM.addView(mView, mParams); } public void handleHide() { if (mView != null) { if (mView.getParent() != null) { mWM.removeView(mView); } mView = null; } }
Toast有点不同的在于,它内部维护了两个IPC通信,一个是NotificationManagerService,一个是回调TN接口。最终的实现都是走到TN.class的handleShow和handleHide方法,也就是addView和removeView。
Android中创建多进程的方式
1) 第一种,大家熟知的,就是给四大组件再AndroidManifest中指定android:process属性。
可以看到,android:process有两种表达方式:
-
:test。“:”的含义是指要在当前的进程名前面加上当前的包名,如果当前包名为com.example.jimu。那么这个进程名就应该是com.example.jimu:test。这种冒号开头的进程属于当前应用的私有进程,其他应用的组件不可以和他跑到同一个进程中。
-
com.example.test。第二种表达方式,是完整的命名方式,它就是新进程的进程名,这种属于全局进程,其他应用可以通过shareUID的方式跑到同一个进程中。
简单说下shareUID:正常来说,Android中每个app都是一个单独的进程,与之对应的是一个唯一的linux user ID,所以就能保住该应用程序的文件或者组件只对该应用程序可见。但是也有一个办法能让不同的apk进行共享文件,那就是通过shareUID,它可以使不同的apk使用相同的 user ID。贴下用法:
//app1 //app2 //app1中获取app2的上下文: Context mContext=this.createPackageContext("com.test.app2", Context.CONTEXT_IGNORE_SECURITY);
2)第二种创建进程的方法,就是通过JNI在native层中去fork一个进程。
这种就比较复杂了,我在网上找了一些资料,找到一个fork普通进程的:
//主要代码 long add(long x,long y) { //fpid表示fork函数返回的值 pid_t fpid; int count=0; fpid=fork(); } //结果: USER PID PPID VSZ RSS STAT NAME root 152 1 S zygote u0_a66 17247 152 297120 44096 S com.example.jni u0_a66 17520 17247 0 0 Z com.example.jni
最终的结果是可以创建出一个进程,但是没有运行,占用的内存为0,处于僵尸程序状态。
但是它这个是通过普通进程fork出来的,我们知道Android中所有的进程都是直接通过zygote进程fork出来的(fork可以理解为孵化出来的当前进程的一个副本)。所以不知道直接去操作zygote进程可不可以成功,有了解的小伙伴可以在微信讨论群里给大家说说。
对了,有的小伙伴可能会问,为什么所有进程都必须用zygote进程fork呢?
-
这是因为fork的行为是复制整个用户的空间数据以及所有的系统对象,并且只复制当前所在的线程到新的进程中。也就是说,父进程中的其他进程在子进程中都消失了,为了防止出现各种问题(比如死锁,状态不一致)呢,就只让zygote进程,这个单线程的进程,来fork新进程。
-
而且在zygote进程中会做好一些初始化工作,比如启动虚拟机,加载系统资源。这样子进程fork的时候也就能直接共享,提高效率,这也是这种机制的优点。
一个应用使用多进程会有什么问题吗?
上面说到创建进程的方法很简单,写个android:process属性即可,那么使用是不是也这么简单呢?很显然不是,一个应用中多进程会导致各种各样的问题,主要有如下几个:
-
静态成员和单例模式完全失效。因为每个进程都会分配到一个独立的虚拟机,而不同的虚拟机在内存分配上有不同的地址空间,所以在不同的进程,也就是不同的虚拟机中访问同一个类的对象会产生多个副本。
-
线程同步机制完全失效。同上面一样,不同的内存是无法保证线程同步的,因为线程锁的对象都不一样了。
-
SharedPreferences不在可靠。之前有一篇说SharedPreferences的文章中说过这一点,SharedPreferences是不支持多进程的。
-
Application会多次创建。多进程其实就对应了多应用,所以新进程创建的过程其实就是启动了一个新的应用,自然也会创建新的Application,Application和虚拟机和一个进程中的组件是一一对应的。
Android中的IPC方式
既然多进程有很多问题,自然也就有解决的办法,虽然不能共享内存,但是可以进行数据交互啊,也就是可以进行多进程间通信,简称IPC。
下面就具体说说Android中的八大IPC方式:
-
Bundle Android四大组件都是支持在Intent中使用Bundle来传递数据,所以四大组件直接的进程间通信就可以使用Bundle。但是Bundle有个大小限制要注意下,bundle的数据传递限制大小为1M,如果你的数据超过这个大小就要使用其他的通信方式了。
-
文件共享 这种方式就是多个进程通过读写一个文件来交换数据,完成进程间通信。但是这种方式有个很大的弊端就是多线程读写容易出问题,也就是并发问题,如果出现并发读或者并发写都容易出问题,所以这个方法适合对数据同步要求不高的进程直接进行通信。
这里可能有人就奇怪了,SharedPreference不就是读写xml文件吗?怎么就不支持进程间通信了?
-
这是因为系统对于SharedPreference有读写缓存策略,也就是在内存中有一份SharedPreference文件的缓存,涉及到内存了,那肯定在多进程中就不那么可靠了。
-
MessengerMessenger是用来传递Message对象的,在Message中可以放入我们要传递的数据。它是一种轻量级的IPC方案,底层实现是AIDL。
-
AIDL
Messenger虽然可以发送消息和接收消息,但是无法同时处理大量消息,并且无法跨进程方法。但是AIDL则可以做到,这里简单说下AIDL的使用流程:
服务端首先建立一个Service监听客户端的连接请求,然后创建一个AIDL文件,将暴露给客户端的接口在这个AIDL文件中申明,最后在Service中实现这个AIDL接口。客户端需要绑定这个服务端的Service,然后将服务端返回的Binder对象转换成AIDL接口的属性,然后就可以调用AIDL中的方法了。
-
ContentProvider
这个大家应很熟悉了,四大组件之一,专门用于不同应用间进行数据共享的。它的底层实现是通过Binder实现的。
-
Socket
套接字,在网络通信中用的很多,比如TCP,UDP。关于Socket通信,借用网络上的一张图说明:
-
Binder连接池
关于Binder的介绍,之前的文章已经说过了。这里主要讲一个Binder的实际使用的技术——Binder连接池。由于每个AIDL请求都要开启一个服务,防止太多服务被创建,就引用了Binder连接池技术。Binder连接池的主要作用就是将每个业务模块的Binder请求统一 转发到远程Service中去执行,从而避免了重复创建Service的过程。贴一下Binder连接池的工作原理:

-
每个业务模块创建自己的AIDL接口并实现此接口,然后向服务端提供自己的唯一标识和其对应的Binder对象.
-
对于服务端来说,只需要一个 Service就可以了,服务端提供一个queryBinder接口,这个接口能够根据业务模块的特征来 返回相应的Binder对象给它们,不同的业务模块拿到所需的Binder对象后就可以进行远程方法调用了。
-
BroadcastReceiver
广播,不用多说了吧~ 像我们可以监听系统的开机广播,网络变动广播等等,都是体现了进程间通信的作用。
Android在版本迭代中,总会进行很多改动,那么你熟知各版本都改动了什么内容?又要怎么适配呢?
这个内容太多,我就不贴了哈,具体可以往期文章 Android版本迭代信息
https://mp.weixin.qq.com/s?__biz=MzU0MTYwMTIzMw==&mid=2247484691&idx=1&sn=4d29d9d533a09b0e36bfb9cfc2f86f14&scene=21#wechat_redirect
冷启动、温启动、热启动
首先了解下启动的这三个概念,也是面试常被问到的:
-
冷启动。冷启动指的是该应用程序在此之前没有被创建,发生在应用程序首次启动或者自上次被终止后的再次启动。简单的说就是app进程还没有,需要创建app的进程启动app。
比如开机后,点击屏幕的app图标启动应用。
冷启动的过程主要分为两步:
1) 系统任务 。加载并启动应用程序;显示应用程序的空白启动窗口;创建APP进程
2) APP进程任务 。启动主线程;创建Activity;加载布局;屏幕布局;绘制屏幕
其实这不就是APP的启动流程嘛?所以冷启动是会完整走完一个启动流程的,从系统到进程。
-
温启动。温启动指的是App进程存在,但Activity可能因为内存不足被回收,这时候启动App不需要重新创建进程,只需要执行APP进程中的一些任务,比如创建Activity。
比如返回主页后,又继续使用其他的APP,时间久了或者打开的应用多了,之前应用的Activity有可能被回收了,但是进程还在。
所以温启动相当于执行了冷启动的第二过程,也就是APP进程任务,需要重新启动线程,Activity等。
-
热启动。热启动就是App进程存在,并且Activity对象仍然存在内存中没有被回收。
比如app被切到后台,再次启动app的过程。
所以热启动的开销最少,这个过程只会把Activity从后台展示到前台,无需初始化,布局绘制等工作。
启动优化我们可以介入的优化点
所以三种启动方式中,冷启动经历的时间最长,也是走完了最完整的启动流程,所以我们再次分析下冷启动的启动流程,看看有哪些可以优化的点:
-
Launcher startActivity
-
AMS startActivity
-
Zygote fork 进程
-
ActivityThread main()
-
ActivityThread attach
-
handleBindApplication
-
attachBaseContext
-
Application attach
-
installContentProviders
-
Application onCreate
-
Looper.loop
-
Activity onCreate,onResume
纵观整个流程,其实我们能动的地方不多,无非就是Application的attach,onCreate方法,Activity的onCreate,onResume方法,这些方法也就是我们的优化点。
启动优化方案总结
最后再和大家回顾下今天说到的启动优化方案:
-
消除启动时的白屏/黑屏。windowBackground。
-
第三方库懒加载/异步加载。线程池,启动器。
-
预创建Activity。对象预创建。
-
预加载数据。
-
Multidex预加载优化。5.0以下多dex情况。
-
Webview启动优化。预创建,缓存池,静态资源。
-
避免布局嵌套。多层嵌套。
为了方便记忆,我再整理成以下三类,分别是Application、Activity、UI:
-
Application 三方库,Multidex。
-
Activity 预创建类,预加载数据。
-
UI方面 windowBackground,布局嵌套,webview。
具体说明可以看往期文章 Android启动优化全解析
https://mp.weixin.qq.com/s?__biz=MzU0MTYwMTIzMw==&mid=2247484835&idx=1&sn=f62c8fb5c9a461987f5bb5446bc5a19b&scene=21#wechat_redirect
类的生命周期
借用网上的一张图
类从被加载到JVM中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。
类加载阶段
类的加载主要有三步:
-
将class文件字节码内容加载到内存中。
-
并将这些静态数据转换成方法区中的运行时数据结构。
-
在堆中生成一个代表这个类的java.lang.Class对象。
我们编写的java文件会在编译后变成.class文件,类加载器就是负责加载class字节码文件,class文件在文件开头有特定的文件标识,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由执行引擎Execution Engine决定。
简单来说类加载机制就是从文件系统将一系列的 class 文件读入 JVM 内存中为后续程序运行提供资源的动作。
类加载器种类
类加载器种类主要有四种:
-
BootstrapClassLoader:启动类加载器,使用C++实现
-
ExtClassLoader:扩展类加载器,使用Java实现
-
AppClassLoader:应用程序类加载器,加载当前应用的classpath的所有类
-
UserDefinedClassLoader:用户自定义类加载器
属于依次继承关系,也就是上一级是下一级的父加载器(例外:BootstrapClassLoader无父子关系,属于根加载器)。
类加载过程(双亲委派机制)
类加载的过程可以用一句话概括:
先在方法区找class信息,有的话直接调用,没有的话则使用类加载器加载到方法区。
对于类加载器加载过程,就用到了双亲委派机制,具体如下:
当一个类加载器收到了类加载的请求,它不会直接去加载这类,而是先把这个请求委派给父加载器去完成,依次会传递到最上级也就是启动类加载器,然后父加载器会检查是否已经加载过该类,如果没加载过,就会去加载,加载失败才会交给子加载器去加载,一直到最底层,如果都没办法能正确加载,则会跑出ClassNotFoundException异常。
举例:
-
当Application ClassLoader 收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器Extension ClassLoader去完成。
-
当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给父类加载器Bootstrap ClassLoader去完成。
-
如果Bootstrap ClassLoader加载失败(在\lib中未找到所需类),就会让Extension ClassLoader尝试加载。
-
如果Extension ClassLoader也加载失败,就会使用Application ClassLoader加载。
-
如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载。
-
如果均加载失败,就会抛出ClassNotFoundException异常。
这么设计的原因主要有两点:
-
这种层级关系可以避免类的重复加载。
-
是为了防止危险代码的植入,比如String类,如果在AppClassLoader就直接被加载,就相当于会被篡改了,所以都要经过老大,也就是BootstrapClassLoader进行检查,已经加载过的类就不需要再去加载了。
Bitmap是什么,怎么存储图片。
Bitmap,位图,本质上是一张图片的内容在内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值,每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来。其中,A代表透明度,RGB代表红绿蓝三种颜色通道值。
Bitmap内存如何计算
Bitmap一直都是Android中的内存大户,计算大小的方式有三种:
-
getRowBytes() 这个在API Level 1添加的,返回的是bitmap一行所占的大小,需要乘以bitmap的高,才能得出btimap的大小
-
getByteCount() 这个是在API Level 12添加的,其实是对getRowBytes()乘以高的封装
-
getAllocationByteCount() 这个是在API Level 19添加的
这里我将一张图片放到项目的drawable-xxhdpi文件夹中,然后通过方法获取图片所占的内存大小:
var bitmap = BitmapFactory.decodeResource(resources, R.drawable.test) img.setImageBitmap(bitmap) Log.e(TAG,"dpi = ${resources.displayMetrics.densityDpi}") Log.e(TAG,"size = ${bitmap.allocationByteCount}")
打印出来的结果是
size=1960000
具体是怎么计算的呢?
图片内存=宽 * 高 * 每个像素所占字节。
这个像素所占字节又和Bitmap.Config有关,Bitmap.Config是个枚举类,用于描述每个像素点的信息,比如:
-
ARGB_8888。常用类型,总共32位,4个字节,分别表示透明度和RGB通道。
-
RGB_565。16位,2个字节,只能描述RGB通道。
所以我们这里的图片内存计算就得出:
宽700 * 高700 * 每个像素4字节=1960000
Bitmap内存 和drawable目录的关系
首先放一张drawable目录对应的屏幕密度对照表,来自郭霖的博客:

刚才的案例,我们是把图片放到drawable-xxhdpi文件夹,而drawable-xxhdpi文件夹对应的dpi就是我们测试手机的dpi—480。所以图片的内存就是我们所计算的宽 * 高 * 每个像素所占字节。
如果我们把图片放到其他的文件夹,比如drawable-hdpi文件夹(对应的dpi是240),会发生什么呢?
再次打印结果:
size = 7840000
这是因为一张图片的实际占用内存大小计算公式是:
占用内存 = 宽 * 缩放比例 * 高 * 缩放比例 * 每个像素所占字节
这个缩放比例就跟屏幕密度DPI有关了:
缩放比例 = 设备dpi/图片所在目录的dpi
所以我们这张图片的实际占用内存位:
宽700 * (480/240) * 高700 * (480/240) * 每个像素4字节 = 7840000
Bitmap加载优化?不改变图片质量的情况下怎么优化?
常用的优化方式是两种:
-
修改Bitmap.Config
这一点刚才也说过,不同的Conifg代表每个像素不同的占用空间,所以如果我们把默认的ARGB_8888改成RGB_565,那么每个像素占用空间就会由4字节变成2字节了,那么图片所占内存就会减半了。
可能一定程度上会降低图片质量,但是我实际测试看不出什么变化。
-
修改inSampleSize
inSampleSize,采样率,这个参数是用于图片尺寸压缩的,他会在宽高的维度上每隔inSampleSize个像素进行一次采集,从而达到缩放图片的效果。这种方法只会改变图片大小,不会影响图片质量。
val options=BitmapFactory.Options() options.inSampleSize=2 val bitmap = BitmapFactory.decodeResource(resources, R.drawable.test2,options) img.setImageBitmap(bitmap)
实际项目中,我们可以设置一个与目标图像大小相近的inSampleSize,来减少实际使用的内存:
fun getImage(): Bitmap { var options = BitmapFactory.Options() options.inJustDecodeBounds = true BitmapFactory.decodeResource(resources, R.drawable.test2, options) // 计算最佳采样率 options.inSampleSize = getImageSampleSize(options.outWidth, options.outHeight) options.inJustDecodeBounds = false return BitmapFactory.decodeResource(resources, R.drawable.test2, options) }
inJustDecodeBounds是什么?
上面的例子大家应该发现了,其中有个inJustDecodeBounds,又设置为true,又设置成false的,总感觉多此一举,那么他到底是干嘛呢?
因为我们要获取图片本身的大小,如果直接decodeResource加载一遍的话,那么就会增加内存了,所以官方提供了这样一个参数inJustDecodeBounds。如果inJustDecodeBounds为ture,那么decode的bitmap为null,也就是不返回实际的bitmap,只把图片的大小信息放到了options的值中。
所以这个参数就是用来获取图片的大小信息的同时不占用内存。
Bitmap内存复用怎么实现?
如果有个需求,是在同一个imageview中可以加载不同的图片,那我们需要每次都去新建一个Bitmap对象,占用新的内存空间吗?如果我们这样写的话:
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.actvitiy_bitmap) btn1.setOnClickListener { img.setImageBitmap(getBitmap(R.drawable.test)) } btn2.setOnClickListener { img.setImageBitmap(getBitmap(R.drawable.test2)) } } fun getBitmap(resId: Int): Bitmap { var options = BitmapFactory.Options() return BitmapFactory.decodeResource(resources, resId, options) }
这样就会Bitmap就会频繁去申请内存,释放内存,从而导致大量GC,内存抖动。
为了防止这种情况呢,我们就可以用到inBitmap参数,用于Bitmap的内存复用。这样同一块内存空间就可以被多个Bitmap对象复用,从而减少了频繁的GC。
val options by lazy { BitmapFactory.Options() } val reuseBitmap by lazy { options.inMutable = true BitmapFactory.decodeResource(resources, R.drawable.test, options) } fun getBitmap(resId: Int): Bitmap { options.inMutable = true options.inBitmap = reuseBitmap return BitmapFactory.decodeResource(resources, resId, options) }
这里有几个要注意的点
-
inBitmap要和inMutable属性配套使用,否则将无法复用。
-
在Android 4.4之前,只能重用相同大小的 Bitmap 内存区域;4.4之后只要复用内存空间的Bitmap对象大小比inBitmap指向的内存空间要小即可。
所以一般在复用之前,还要判断下,新的Bitmap内存是不是小于可以复用的Bitmap内存,然后才能进行复用。
高清大图加载该怎么处理?
如果是高清大图,那就说明不允许进行图片压缩,比如微博长图,清明上河图。
所以我们就要对图片进行局部显示,这就用到BitmapRegionDecoder属性,主要用于显示图片的某一块矩形区域。
比如我要显示左上角的100 * 100区域:
fun setImagePart() { val inputStream: InputStream = assets.open("test.jpg") val bitmapRegionDecoder: BitmapRegionDecoder = BitmapRegionDecoder.newInstance(inputStream, false) val options = BitmapFactory.Options() val bitmap = bitmapRegionDecoder.decodeRegion( Rect(0, 0, 100, 100), options) image.setImageBitmap(bitmap) }
实际项目使用中,我们可以根据手势滑动,然后不断更新我们的Rect参数来实现具体的功能即可。
具体实现源码可以参考鸿洋的博客:
https://blog.csdn.net/lmj623565791/article/details/49300989
如何跨进程传递大图?
-
Bundle直接传递。bundle最常用于Activity间传递,也属于跨进程的一种方式,但是传递的大小有限制,一般为1M。
//intent.put的putExtra方法实质也是通过bundle intent.putExtra("image",bitmap); bundle.putParcelable("image",bitmap)
Bitmap之所以可以直接传递,是因为其实现了Parcelable接口进行了序列化。而Parcelable的传递原理是利用了Binder机制,将Parcel序列化的数据写入到一个共享内存(缓冲区)中,读取的时候也会从这个缓冲区中去读取字节流,然后再反序列化成对象使用。这个共享内存也就是缓存区有一个大小限制—1M,而且是公用的。所以传图片的话很容易就容易超过这个大小然后报错TransactionTooLargeException。
所以这个方案不可靠。
-
文件传输。
将图片保存到文件,然后只传输文件路径,这样肯定是可以的,但是不高效。
-
putBinder
这个就是考点了。通过传递binder的方式传递bitmap。
//传递binder val bundle = Bundle() bundle.putBinder("bitmap", BitmapBinder(mBitmap)) //接收binder中的bitmap val imageBinder: BitmapBinder = bundle.getBinder("bitmap") as BitmapBinder val bitmap: Bitmap? = imageBinder.getBitmap() //Binder子类 class BitmapBinder :Binder(){ private var bitmap: Bitmap? = null fun ImageBinder(bitmap: Bitmap?) { this.bitmap = bitmap } fun getBitmap(): Bitmap? { return bitmap } }
为什么用putBinder就没有大小限制了呢?
-
因为putBinder中传递的其实是一个文件描述符fd,文件本身被放到一个共享内存中,然后获取到这个fd之后,只需要从共享内存中取出Bitmap数据即可,这样传输就很高效了。
-
而用Intent/bundle直接传输的时候,会禁用文件描述符fd,只能在parcel的缓存区中分配空间来保存数据,所以无法突破1M的大小限制。
文件描述符是一个简单的整数,用以标明每一个被进程所打开的文件和socket。第一个打开的文件是0,第二个是1,依此类推。
描述new一个对象的过程
先上图,再描述:



Java中对象的创建过程包括 类初始化和类实例化两个阶段。而new就是创建对象的一种方式,一种时机。
当执行到new的字节码指令的时候,会先判断这个类是否已经初始化,如果没有初始化就要进行类的初始化,也就是执行类构造器()方法。如果已经初始化了,就直接进行类对象的实例化。
-
类的初始化,是类的生命周期中的一个阶段,会为类中各个类成员赋初始值。
-
类的实例化,是指创建一个类的实例的过程。
但是在类的初始化之前,JVM会保证类的装载,链接(验证、准备、解析)四个阶段都已经完成,也就是上面的第一张图。
-
装载是指 Java虚拟机查找.class文件并生成字节流,然后根据字节流创建java.lang.Class对象的过程。
-
链接是指验证创建的类,并将其解析到JVM中使之能够被 JVM 执行。
那到底类加载的时机是什么时候呢?JVM 并没有规范何时具体执行,不同虚拟机的实现会有不同,常见有以下两种情况:
-
隐式装载:在程序运行过程中,当碰到通过 new 等方式生成对象时,系统会隐式调用 ClassLoader 去装载对应的 class 到内存中;
-
显示装载:在编写源代码时,主动调用 Class.forName() 等方法也会进行 class 装载操作,这种方式通常称为显示装载。
所以到这里,大的流程框架就搞清楚了:
-
当JVM碰到new字节码的时候,会先判断类是否已经初始化,如果没有初始化(有可能类还没有加载,如果是隐式装载,此时应该还没有类加载,就会先进行装载、验证、准备、解析四个阶段),然后进行类初始化。
-
如果已经初始化过了,就直接开始类对象的实例化工作,这时候会调用类对象的方法。
结合例子说明
然后说说具体的逻辑,结合一段类代码:
public class Run { public static void main(String[] args) { new Student(); } } public class Person{ public static int value1 = 100; public static final int value2 = 200; public int value4 = 400; static{ value1 = 101; System.out.println("1"); } { value1 = 102; System.out.println("3"); } public Person(){ value1 = 103; System.out.println("4"); } } public class Student extends Person{ public static int value3 = 300; public int value5 = 500; static{ value3 = 301; System.out.println("2"); } { value3 = 302; System.out.println("5"); } public Student(){ value3 = 303; System.out.println("6"); } }
-
首先是类装载,链接(验证、准备、解析)。
-
当执行类准备过程中,会对类中的静态变量分配内存,并设置为初始值也就是“0值”。比如上述代码中的value1,value3,会为他们分配内存,并将其设置为0。但是注意,用final修饰静态常量value2,会在这一步就设置好初始值102。
-
初始化阶段,会执行类构造器方法,其主要工作就是初始化类中静态的(变量,代码块)。但是在当前类的方法执行之前,会保证其父类的方法已经执行完毕,所以一开始会执行最上面的父类Object的方法,这个例子中会先初始化父类Person,再初始化子类Student。
-
初始化中,静态变量和静态代码块顺序是由语句在源文件中出现的顺序所决定的,也就是谁写在前面就先执行谁。所以这里先执行父类中的value1=100,value1 = 101,然后执行子类中的value3 = 300,value3 = 301。
-
接着就是创建对象的过程,也就是类的实例化,当对象被类创建时,虚拟机会分配内存来存放对象自己的实例变量和父类继承过来的实例变量,同时会为这些事例变量赋予默认值(0值)。
-
分配完内存后,会初始化父类的普通成员变量(value4 = 400),和执行父类的普通代码块(value1=102),顺序由代码顺序决定。
-
执行父类的构造函数(value1 = 103)。
-
父类实例化完了,就实例化子类,初始化子类的普通成员变量(value5 = 500),执行子类的普通代码块(value3 = 302),顺序由代码顺序决定。
-
执行子类的构造函数(value3 = 303)。
所以上述例子打印的结果是:
总结一下执行流程就是:
-
父类静态变量和静态代码块;
-
子类静态变量和静态代码块;
-
父类普通成员变量和普通代码块;
-
父类的构造函数;
-
子类普通成员变量和普通代码块;
-
子类的构造函数。
类初始化的触发时机
在同一个类加载器下,一个类型只会被初始化一次,刚才说到new对象是类初始化的一个判断时机,其实一共有六种能够触发类初始化的时机:
-
虚拟机启动时,初始化包含 main 方法的主类;
-
遇到 new等指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作;
-
当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作;
-
子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
-
使用反射API 进行反射调用时,如果类没有进行过初始化则需要先触发其初始化;
-
第一次调用java.lang.invoke.MethodHandle 实例时,需要初始化 MethodHandle 指向方法所在的类。
多线程进行类的初始化会出问题吗
不会,()方法是阻塞的,在多线程环境下,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的(),其他线程都会被阻塞。
类的实例化触发时机
-
使用new关键字创建对象
-
使用Class类的newInstance方法,Constructor类的newInstance方法(反射机制)
-
使用Clone方法创建对象
-
使用(反)序列化机制创建对象
()方法和()方法区别。
-
()方法发生在类初始化阶段,会执行类中的静态类变量的初始化和静态代码块中的逻辑,执行顺序就是语句在源文件中出现的顺序。
-
()方法发生在类实例化阶段,是默认的构造函数,会执行普通成员变量的初始化和普通代码块的逻辑,执行顺序就是语句在源文件中出现的顺序。
在类都没有初始化完毕之前,能直接进行实例化相应的对象吗?
刚才都说了先初始化,再实例化,如果这个问题可以的话那不是打脸了吗?
没错,要打脸了哈哈。
确实是先进行类的初始化,再进行类的实例化,但是如果我们在类的初始化阶段就直接实例化对象呢?比如:
public class Run { public static void main(String[] args) { new Person2(); } } public class Person2 { public static int value1 = 100; public static final int value2 = 200; public static Person2 p = new Person2(); public int value4 = 400; static{ value1 = 101; System.out.println("1"); } { value1 = 102; System.out.println("2"); } public Person2(){ value1 = 103; System.out.println("3"); } }
嘿嘿,这时候该怎么打印结果呢?
按照上面说过的逻辑,应该是先静态变量和静态代码块,然后普通成员变量和普通代码块,最后是构造函数。
但是因为静态变量又执行了一次new Person2(),所以实例化过程被强行提前了,在初始化过程中就进行了实例化。这段代码的结果就变成了:
所以,实例化不一定要在类初始化结束之后才开始初始化,有可能在初始化过程中就进行了实例化。
类的初始化过程与类的实例化过程的异同?
学了上面的内容,这个问题就很简单了:
-
类的初始化,是指在类装载,链接之后的一个阶段,会执行()方法,初始化静态变量,执行静态代码块等。只会执行一次。
-
类的实例化,是指在类完全加载到内存中后创建对象的过程,会执行()方法,初始化普通变量,调用普通代码块。可以被调用多次。
一个实例变量在对象初始化的过程中最多可以被赋值几次?
那我们就试试举例出最多的情况,其实也就是每个要经过的地方都对实例变量进行一次赋值:
-
1、对象被创建时候,分配内存会把实例变量赋予默认值,这是肯定会发生的。
-
2、实例变量本身初始化的时候,就给他赋值一次,也就是int value1=100。
-
3、初始化代码块的时候,也赋值一次。
-
4、构造函数中,在进行赋值一次。
一共四次,看代码:
public class Person3 { public int value1 = 100; { value1 = 102; System.out.println("2"); } public Person3(){ value1 = 103; System.out.println("3"); } }
高刷手机,60hz,120hz指的是什么
指的是屏幕的刷新频率,也就是一秒内屏幕刷新的次数。刷新频率这个参数是手机出厂就决定的,取决于硬件的固定参数。
高刷手机,一般就是指高刷新率屏幕,也就是大于一般的60hz,比如90hz,120hz等等。它的特点就在于每秒刷新的频率更高,使得画面更加流畅,顺滑,就算出现丢帧等情况,画面还能保证一个稳定性。
屏幕的刷新过程。
屏幕的刷新过程是每一行从左到右,从上到下,顺序显示像素点。当整个屏幕刷新完毕,即一个垂直刷新周期完成,会有短暂的空白期,此时发出VSync 信号。如果是60hz的手机,那么每次屏幕刷新的过程占用时间就是16ms(1000/60)左右。
一般一个图形界面的绘制,需要CPU准备数据,然后GPU进行绘制,绘制完写入缓存区,然后屏幕按照刷新频率来从这个缓存区中取图形显示。
所以整个刷新过程是CPU,GPU,屏幕(Display)三方合作的工作关系。
帧率,VSYNC是什么
帧率,就是GPU一秒内绘制操作的帧数,单位是fps。游戏中比较常见,越大也就代表越流畅。所以这个参数并不是固定值,但是如果屏幕刷新频率是60hz,你的帧率大于60fps也就浪费了,所以一般情况下最好是帧率和屏幕刷新频率保持一致,即同样是60fps。这样就能保证一个比较平滑的视觉动画。
VSync,垂直同步,在Android4.1引进,是一种定时发送绘制信号的机制,它的作用就是让帧率和屏幕刷新率保持一致,防止跳帧卡顿等等。玩过lol的朋友应该都知道,设置界面就可以开启垂直同步选项。
正常如果没有开启vsync,屏幕刷新有可能会出现什么问题呢?

如图,由于CPU,GPU绘制图像的时间不定,所以就有可能会发生卡顿情况,也就是下一帧的数据没准备好,无法正常显示到屏幕上。
如果我们开启vsync,也就是给CPU和GPU规定了开始绘制帧数据的时间。开启后,系统会每16ms就发送一次vsync信号,CPU收到信号就开始处理数据,然后GPU绘制图像。这样就把16ms最大化的利用起来了,只要CPU和GPU在16ms之内把下一帧数据处理好,那么屏幕就能从缓存区中拿到下一帧数据并显示出来了。如图:

所以vsync信号就是为了保证16ms绘制出一帧的数据出来。使得屏幕每16ms刷新一次,就能用到最新的帧数据了。这样画面就是比较流畅的了。
这整个过程其实就在Choreographer类中实现的,包括同步屏障的使用也在其中,下次会具体讲到。
单缓存,双缓存,三缓存
-
单缓存。就是CPU计算好的数据交给GPU,然后GPU进行图像绘制,最后放到缓存区。而屏幕就直接从这个缓存区中拿到数据并显示。
但是这样做有个问题就是,因为Display和GPU都是操作的同一个缓存,就会出现同一个画面中有不同帧的数据。比如屏幕刷新的时候,第二帧还没绘制完,那么缓存中就有第二帧数据还有第一帧残留的数据,这样显示出来的画面就有两个帧的画面了,比如画面撕裂。
-
双缓存。这个双缓存就是设计出来解决单缓存问题的。既然Display和GPU不能共用一个缓存,那么就设计两个缓存就可以啦。
FrameBuffer来做显示输出,也就是屏幕每次从这个缓存中取图形数据。BackBuffer用来放下一帧的画面,也就是CPU每次绘制数据到这个缓存中。然后当CPU完整绘制完下一帧图形,也就是BackBuffer准备好,屏幕也显示完上一帧数据的时候,就进行缓存交换,把数据同步到FrameBuffer。而这个缓存交换点,就是vsync信号时刻。
-
三缓存。Android4.1 引入,一般来说,双缓存就能够使用了,但是为什么还有一个三缓存呢?看图:

双缓存情况下,如果CPU/GPU处理数据过慢,就会发生上图的情况。也就是vsync信号来的时候,上一帧数据还没绘制完,于是A数据图片显示了两帧的时间,而且由于vsync来的时候cpu才开始处理数据,而图上vsync来的时候,GPU还在处理数据,导致GPU处理完了之后,无法触发下一帧数据的处理,浪费了一大半时间。后面情况类似,只要CPU/GPU处理数据过慢,就会发生Jank(卡顿等问题)
所以这时候就引入了第三个缓存,如图:

如图所示,在vsync来的时候,如果GPU还没处理好数据帧B的图形,这时候第三个缓存区可以来处理后面C帧的数据,然后第二个vsync信号来的时候,虽然第三缓存区还在用作处理C帧数据,但是之前的BackBuffer又可以来缓存下一帧的数据了。
这样一来,虽然A帧数据还是显示了两个时间点,但是后面由于有新Buffer的加入,可以保证后续图像显示能正常平滑的显示了。就相当于多了一个劳动力,可以最大限度利用好时间。
代码中修改了UI,屏幕是怎么进行刷新的?
当我们用代码修改了UI,比如使用了setText,修改Textview的值。这时候屏幕不会马上绘制刷新。而是会调用到invalidate方法请求重绘,然后会向VSYNC服务发送请求,等到下一个VSYNC信号触发的时候,就开始上面说过的流程,也就是处理数据,绘制图像,具体所做的工作就是测量—布局—绘制。接着,屏幕就可以拿到缓存区中绘制好的图像并显示到屏幕上了。
所以任何UI的改变,都要遵从上述所说的VSYNC机制,只是这个过程很短。当然为了保证最快时间绘制到屏幕上,而不让其他消息影响到VSYNC的响应速度,就加入了同步屏障。
如果界面保持静止不变,屏幕会刷新吗?图像会被重新绘制吗?
首先,屏幕刷新频率这个是不会变的,也就是每隔16ms左右就会进行一次刷新,而刷新的帧数据就是我们的程序内部在接收到刷新的vsync信号之后,经过计算绘制后的图像数据。
但是,app并不是每一个vsync信号都能接收到的,只有当应用有绘制需求的时候,才会通过scheduledVsync 方法申请VSYNC信号,然后下一个屏幕刷新的信号才能被我们的程序所接收到,也就是Choreographer类的onVsync方法才能被执行,然后就开始测量—布局—绘制等工作了。
所以,如果界面不变化,我们的程序就收不到VSYNC信号,也就无法处理数据进行绘制了。
只有当需要改变界面的时候,才会去申请这个屏幕刷新服务,才能接收到VSYNC信号。这种情况下,屏幕还会进行刷新,只不过刷新的都是同样的图像数据。
说说UI(布局)优化
UI优化知识点主要分为三部分:
-
第一部分,系统为我们做的优化。由于前端中UI展示的特殊性和重要性,Android团队也是在不断想办法提供UI方面的渲染速度等等,所以也是提供了很多方案进行优化,比如:
硬件加速、黄油计划、RenderThread。
-
第二部分,具体的优化方案。主要包括:
java代码布局、View重用、xml布局优化、异步布局框架Litho、RenderThread与RenderScript、屏幕适配、Flutter、Jetpack Compose
-
第三部分,工具使用,主要包括:
Choreographer、monitor、Systrace
具体内容可以查看往期文章 UI(布局)优化全解析
https://mp.weixin.qq.com/s?__biz=MzU0MTYwMTIzMw==&mid=2247485105&idx=1&sn=246b12a78ca4466c28aa970f93a0d31a&scene=21#wechat_redirect
总结
进阶不在于一蹴而就,而在点点滴滴。
参考
《Android开发艺术探索》
https://juejin.cn/post/6863756420380196877
https://www.cnblogs.com/frrj/archive/2018/07/30/brief-info-of-android-display.html
https://www.jianshu.com/p/10db590ed9a6
https://blog.csdn.net/justloveyou_/article/details/72466416
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=67#/detail/pc?id=1860 https://www.jianshu.com/p/8a14ed0ed1e9
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=67#/detail/pc?id=1872 https://www.cnblogs.com/shakinghead/p/11025805.html https://blog.csdn.net/lmj623565791/article/details/49300989
https://blog.csdn.net/ylyg050518/article/details/97671874 https://juejin.cn/post/6844903641032163336 https://juejin.cn/post/6844903958113157128
https://www.cnblogs.com/mythou/p/3258715.html
https://www.jianshu.com/p/71480c680a65 https://juejin.cn/post/6844903775153422343
https://www.cnblogs.com/huolongluo/p/6523552.html https://www.jianshu.com/p/f67e05d7cd30
https://juejin.cn/post/6900870262062120967
https://segmentfault.com/a/1190000023876273
https://www.cnblogs.com/throwable/p/12272269.html https://www.cnblogs.com/pu20065226/p/12206463.html
https://blog.csdn.net/yisuoyanyv/article/details/104657546