Android控件架构与自定义控件详解
控件是每个Android App都必不可少的一部分,无论是使用系统控件,还是使用自定义控件。这些控件,组成了每个精美的界面。
1. Android控件架构
在Android中控件大致被分为两类,即ViewGroup控件与View控件。
ViewGroup控件作为父控件可以包含多个View控件,并管理其包含的View控件。通过ViewGroup,整个界面上的控件形成了一个树形结构,也就是我们常说的控制树,上层控件负责下层子控件的测量与绘制,并传递交互事件。findViewById()方法就是在控件树中以树的深度优先遍历来查找对应元素。在每颗控件树的顶部,都有一个ViewParent对象,这就是整棵树的控制核心,所有的交互管理事件都是由它来统一调度和分配,从而可以对整个视图进行整体控制。
在Activity中使用setContentView()方法加载布局文件时,每个Activity都包含一个Window对象,在Android中Window对象通常由PhoneWindow来实现。PhoneWindow将一个DecorView设置为整个应用窗口的根View。DecorView作为窗口的顶层视图,封装了一些窗口操作的通用方法。所有View的监听事件都通过WindowManagerService来接收,并通过Activity对象来回调响应的onClickListener。在显示上,将屏幕分为TitleView和ContentView。activity_main.xml就是设置在一个id为content的Framelayout里。
而在代码中,当程序在onCreate()方法中调用setContentView()方法后,ActivityManagerService会回调onResume()方法,此时系统才会把整个DecorView添加到PhoneWindow中,让其显示出来,从而完成最终界面的绘制。
2. View的测量
系统是如何绘制View的呢?
Android系统在绘制View之前,必须对View进行测量,即告诉系统该画一个多大的View。这个过程是在onMeasure()方法中进行。
Android系统提供了一个MeasureSpec类,通过它来帮助我们测量View。测量的模式可以分为以下三种:
- EXACTLY
精确模式,当控件的layout_width或layout_height指定为具体值时,比如"android:layout_width=100dp",或者指定为match_parent,系统使用的是EXACTLY模式。 - AT_MOST
最大值模式,当控件layout_width或layout_height指定为wrap_content时,控件大小随子控件或内容变化而变化,只要不超过父控件最大尺寸即可。 - UNSPECIFIED
不指定大小测量模式,通常用于绘制自定义View时。
View类默认的onMeasure()方法支持EXACTLY模式,所以如果不重写onMeasure()方法,控件只可以响应指定的具体宽高值或match_parent。
简单的实例演示:
- 创建一个类ViewTest继承自View,并重写必要的构造方法。
- 重写onMeasure方法,在onMeasure中调用自定义的measureWidth()和measureheight()方法。
- 测试结果
具体代码如下:
package com.swpuiot.test.test3;
import android.content.Context;
import android.os.Build;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
public class MyView extends View {
public MyView(Context context) {
super(context);
}
public MyView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MyView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public MyView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(measureWidth(widthMeasureSpec),measureHeight(heightMeasureSpec));
}
private int measureHeight(int heightMeasureSpec) {
int result = 0;
int sepcMode = MeasureSpec.getMode(heightMeasureSpec);
int sepcSize = MeasureSpec.getSize(heightMeasureSpec);
if(sepcMode == MeasureSpec.EXACTLY){
result = sepcSize;
}else{
result = 200;
if (sepcMode == MeasureSpec.AT_MOST){
result = Math.min(result,sepcSize);
}
}
return result;
}
private int measureWidth(int widthMeasureSpec) {
int result = 0;
int sepcMode = MeasureSpec.getMode(widthMeasureSpec);
int sepcSize = MeasureSpec.getSize(widthMeasureSpec);
if(sepcMode == MeasureSpec.EXACTLY){
result = sepcSize;
}else{
result = 200;
if (sepcMode == MeasureSpec.AT_MOST){
result = Math.min(result,sepcSize);
}
}
return result;
}
}
结果展示:
3. View的绘制
测量好了一个View之后就可以重写onDraw()方法,并在Canvas对象上来绘制所需的图形了。
Canvas就像是一个画板,使用Paint就可以在上面作画,通常需要通过继承View并重写onDraw()方法来完成绘制。
一般情况下,可以使用重写View类中的onDraw()方法来绘制,onDraw()中有一个参数就是Canvas canvas对象。
创建一个Canvas对象
Canvas canvas = new Canvas(bitmap);
首先在onDraw()方法中绘制两个bitmap
canvas.drawBitmap(bitmap1,0,0,null);
canvas.drawBitmap(bitmap2,0,0,null);
将bitmap2装载到另一个Canvas对象中
Canvas mCanvas = new Canvas(bitmap2);
在其他地方使用Canvas对象的绘图方法用装在bitmap2的Canvas对象中进行绘图
mCanvas.drawXXX
虽然使用Canvas绘制API,但其实并没有将图形直接绘制在onDraw()方法指定的那块画布上,而且通过改变bitmap,让View重绘,从而显示改变之后的bitmap。
4. ViewGroup的测量
ViewGroup会去管理其子view,其中一个管理项目就是负责子view的显示大小。ViewGroup在测量时通过遍历所有子view,从而调用子view的Measure方法来获得每一个子view的测量结果。
当子view测量完毕后,就需要将子view放到合适的位置,这个过程就是View的Layout过程。
在自定义ViewGroup时,通常会去重写onLayout()方法来控制其子view显示位置的逻辑。如果需要支持wrap_content属性,它还必须重写onMeasure()方法。
5. ViewGroup的绘制
ViewGroup通常情况下不需要绘制,因为它本身没有需要绘制的东西,如果不是指定了ViewGroup的背景颜色,那个onDraw()方法都不会被调用。但是,ViewGroup会使用dispatchDraw()方法来绘制其子view,其过程同样是遍历所有子view,并调用子view的绘制方法来完成工作。
6. 自定义View
自定义控件作为Android中一个非常重要的功能,一直以来都被初学者认为是代表高手的象征。适当地使用自定义view可以丰富应用程序的体验效果,但滥用自定义view则会带来适得其反的效果。
了解Android系统自定义View的过程,可以帮助我们了解系统的绘图机制。同时,在适当的情况下也可以通过自定义View来帮助我们创建更加灵活的布局。
在自定义View时,我们通常会去重写onDraw()方法来绘制View的显示内容。如果该View还需要使用wrap_content属性,那么还必须重写onMeasure()方法。另外,通过自定义attrs属性,还可以设置新的属性配置值。
在View中通常会有一些比较重要的回调方法:
- onFinishInflate():从XML加载组件后回调
- onSizeChanged():组件大小改变时回调
- onMeasure():回调该方法来进行测量
- onLayout():回调该方法来确定显示的位置
- onTouchEvent():监听到触摸事件时回调
当然,创建自定义View时并不需要重写所有的方法,只需要重写特定条件的回调方法即可。
通常情况下,有以下三种方法来实现自定义控件:
- 对现有控件进行拓展
- 通过组合来实现新的控件
- 重写View来实现全新的控件
6.1 对现有控件进行拓展
这是一个非常重要的自定义View方法,它可以在原生控件的基础上进行拓展,增加新的功能、修改显示的UI等。一般来说,我们可以在onDraw()方法中对原生控件行为进行拓展。
以一个TextView为例,要实现让一个TextView的背景更加丰富:
原生的TextView使用onDraw()方法绘制要显示的文字。可以认为在自定义的TextView中调用TextView类的onDraw()方法来绘制显示的文字。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
程序调用super.onDraw(canvas)方法来实现原生控件的功能,我们可以在调用super.onDraw(canvas)之前或者之后来实现自己的逻辑。
@Override
protected void onDraw(Canvas canvas) {
//在回调父类方法前,实现自己的逻辑,对TextView来说即是在绘制文本内容前
super.onDraw(canvas);
//在回调父类方法后,实现自己的逻辑,对TextView来说即是在绘制文本内容后
}
我们在构造方法中必须完成必要对象的初始化工作,可编写一个initView()方法来初始化画笔等:
//初始化画笔,并设置画笔的颜色和样式
private void initView() {
paint1 = new Paint();
paint1.setColor(getResources().getColor(android.R.color.holo_blue_light));
paint1.setStyle(Paint.Style.FILL);
paint2 = new Paint();
paint2.setColor(Color.YELLOW);
paint2.setStyle(Paint.Style.FILL);
}
重写onDraw()方法:
@Override
protected void onDraw(Canvas canvas) {
//绘制外层矩形
canvas.drawRect(0,0,getMeasuredWidth(),getMeasuredHeight(),paint1);
//绘制内层矩形
canvas.drawRect(10,10,getMeasuredWidth()-10,getMeasuredHeight()-10,paint2);
canvas.save();
//绘制文字前平移10像素
canvas.translate(10,0);
//父类完成的方法,即绘制文本
super.onDraw(canvas);
canvas.restore();
}
下面再来实现一个复杂一点的TextView。上面的例子我们直接使用了Canvas对象来进行图像的绘制,然后利用Android的绘图机制,可以绘制出更加丰富复杂的图像。下面将利用LinearGradient Shader和Matrix来实现一个动态的文字闪动效果。
可以通过设置一个不断变化的LinearGradient并使用带有该属性的Paint对象来绘制要显示的文字。
在onSizeChanged()方法中进行一些对象的初始化工作:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if(viewWidth == 0){
viewWidth = getMeasuredWidth();
if(viewWidth > 0){
paint = getPaint();
linearGradient = new LinearGradient(0,0,viewWidth,0,new int[]{
Color.BLUE,0xffffffff,Color.BLUE
},null, Shader.TileMode.CLAMP);
paint.setShader(linearGradient);
matrix = new Matrix();
}
}
}
其中最关键的是使用getPaint()方法来获取当前绘制TextView的Paint对象,并给这个Paint对象设置原生TextView所没有的LinearGradient属性。最后在onDraw()方法中通过矩阵的方法来不断平移渐变效果。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if(matrix != null){
translate += viewWidth / 5;
if(translate > 2*viewWidth){
translate = -viewWidth;
}
matrix.setTranslate(translate,0);
linearGradient.setLocalMatrix(matrix);
postInvalidateDelayed(100);
}
}
6.2 创建复合控件
创建复合控件可以很好地创建出具有重用功能的控件集合。这种方式通常需要继承一个合适的ViewGroup再给它添加指定功能的控件,从而组合成新的复合控件。
通过这种方式创建的控件,我们一般会给它制定一些可配置的属性,让它具有更强的拓展性。
下面以一个TopBar标题栏为例:
- 定义属性
创建一个attrs.xml的属性定义文件:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="TopBar">
<attr name="title" format="string"/>
<attr name="titleTextSize" format="dimension"/>
<attr name="titleTextColor" format="color"/>
<attr name="leftTextColor" format="color"/>
<attr name="leftBackgroud" format="reference|color"/>
<attr name="leftText" format="string"/>
<attr name="rightTextColor" format="color"/>
<attr name="rightBackgroud" format="reference|color"/>
<attr name="rightText" format=dai"string"/>
</declare-styleable>
</resources>
我们在代码中通过< declare-styleable >标签来声明自定义属性,name确定引用的名称,< attrs >标签来声明具体的自定义属性,例如标题文字的字体、大小、颜色等,并通过format属性来指定属性的类型。reference表引用。
确定好属性之后,建立TopBar继承自RelativeLayout,通过如下代码来获取在xml文件中自定义的这些属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="TopBar">
<attr name="title" format="string"/>
<attr name="titleTextSize" format="dimension"/>
<attr name="titleTextColor" format="color"/>
<attr name="leftTextColor" format="color"/>
<attr name="leftBackgroud" format="reference|color"/>
<attr name="leftText" format="string"/>
<attr name="rightTextColor" format="color"/>
<attr name="rightBackgroud" format="reference|color"/>
<attr name="rightText" format="string"/>
</declare-styleable>
</resources>
完整代码:
package com.swpuiot.test.test3;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.View;
import android.widget.Button;
import android.widget.RelativeLayout;
import android.widget.TextView;
import com.swpuiot.test.R;
public class TopBar extends RelativeLayout {
//TopBar上的控件声明:左右按钮和标题
private Button left,right;
private TextView titleView;
//布局属性,用来控制组件元素在ViewGroup中的位置
private LayoutParams leftParams,rightParams,titleParams;
//左按钮的属性
private int leftTextColor;
private Drawable leftBackgroud;
private String leftText;
//右按钮的属性
private int rightTextColor;
private Drawable rightBackgroud;
private String rightText;
//标题的属性
private float titleTextSize;
private int titleTextColor;
private String title;
//映射传入的接口对象
private topbarClickListener listener;
public TopBar(Context context) {
super(context);
}
public TopBar(Context context, AttributeSet attrs) {
super(context, attrs);
//设置topbar的背景
setBackgroundColor(0xfff59563);
//通过这个方法将attrs.xml文件中定义的declare-styleable的所有属性的值,并存储到TypedArray中
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TopBar);
//从TypedArray中取出对应的值来为要设置的属性赋值
leftTextColor = ta.getColor(R.styleable.TopBar_leftTextColor,0);
leftBackgroud = ta.getDrawable(R.styleable.TopBar_leftBackgroud);
leftText = ta.getString(R.styleable.TopBar_leftText);
rightTextColor = ta.getColor(R.styleable.TopBar_rightTextColor,0);
rightBackgroud = ta.getDrawable(R.styleable.TopBar_rightBackgroud);
rightText = ta.getString(R.styleable.TopBar_rightText);
titleTextSize = ta.getDimension(R.styleable.TopBar_titleTextSize,10);
titleTextColor = ta.getColor(R.styleable.TopBar_titleTextColor,0);
title = ta.getString(R.styleable.TopBar_title);
//获取完TypedArray的值后,一般要调用recycle()方法来避免重新创建时的错误
ta.recycle();
left = new Button(context);
right = new Button(context);
titleView = new TextView(context);
//为创建的组件赋值,值就来源于我们在应用的attrs.xml文件中给对应属性的赋值
left.setTextColor(leftTextColor);
left.setBackground(leftBackgroud);
left.setText(leftText);
right.setTextColor(rightTextColor);
right.setBackground(rightBackgroud);
right.setText(rightText);
titleView.setText(title);
titleView.setTextColor(titleTextColor);
titleView.setTextSize(titleTextSize);
titleView.setGravity(Gravity.CENTER);
//为组件元素设置相应的布局
leftParams = new LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.MATCH_PARENT);
leftParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT,TRUE);
//添加到ViewGroup
addView(left,leftParams);
rightParams = new LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.MATCH_PARENT);
rightParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT,TRUE);
addView(right,rightParams);
titleParams = new LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.MATCH_PARENT);
titleParams.addRule(RelativeLayout.CENTER_IN_PARENT,TRUE);
addView(titleView,titleParams);
//按钮的点击事件,不需要具体的实现,只需要调用接口的方法,回调的时候会有具体的实现
right.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
listener.rightClick();
}
});
left.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
listener.leftClick();
}
});
}
public TopBar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
//暴露一个方法给调用者来注册接口回调,通过接口来获得回调者对接口方法的实现
public void setOnTopbarClickListener(topbarClickListener listener){
this.listener = listener;
}
/**
* 设置按钮的显示与否 通过id区分按钮,flag区分是否显示
* @param id id
* @param flag 是否显示
*/
public void setButtonVisable(int id,boolean flag){
if(flag){
if(id == 0){
left.setVisibility(View.VISIBLE);
}else{
right.setVisibility(View.VISIBLE);
}
}else{
if(id == 0){
left.setVisibility(View.GONE);
}else{
right.setVisibility(View.GONE);
}
}
}
//接口对象,实现回调机制,在回调方法中通过映射的接口对象调用接口中的方法,而不用去考虑如何实现,具体的实现由调用者去创建
public interface topbarClickListener{
//左按钮点击事件
void leftClick();
//右按钮点击事件
void rightClick();
}
}
UI模板:
添加xmlns:custom="http://schemas.android.com/apk/res-auto"这一句话,在布局文件中引入这个名字空间,将第三方控件的名字空间取名为custom
<?xml version="1.0" encoding="utf-8"?>
<com.swpuiot.test.test3.TopBar
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.swpuiot.test.test3.TopBar
android:id="@+id/topbar"
android:layout_width="match_parent"
android:layout_height="40dp"
custom:leftBackgroud="@drawable/page_first"
custom:leftText="上一页"
custom:leftTextColor="#ffffff"
custom:rightBackgroud="@drawable/page_last"
custom:rightText="下一页"
custom:rightTextColor="#ffffff"
custom:title="自定义标题"
custom:titleTextColor="#123412"
custom:titleTextSize="10sp"/>
</com.swpuiot.test.test3.TopBar>
通过如上所示代码就可以在其他布局文件中直接通过< include >标签来引用这个UI模板View了
<include layout="@layout/topbar"/>
6.3 重写View来实现全新的控件
当Android系统原生的控件无法满足我们的需求时,我们就可以完全创建一个新的自定义View来实现需要的功能。创建一个自定义View难点在于绘制控件和实现交互,这也是评价一个自定义View优劣的标准之一。通常需要继承View类,并重写它的onDraw(),onMeasure()等方法来实现绘制逻辑,同时通过重写onTouchEvent()等触控事件来实现交互逻辑。当然,我们还可以像实现组合控件方式那样,通过引入自定义属性,丰富自定义View的可定制性。
7.自定义ViewGroup
ViewGroup存在的目的就是为了对其子View进行管理,为其子View添加显示、响应的规则。因此,自定义ViewGroup通常需要重写onMeasure()方法来对子View进行测量,重写onLayout()方法来确定子View的位置,重写onTouchEvent()方法增加响应事件。
8. 事件拦截机制分析
触摸事件:触摸事件就是捕获触摸屏幕后产生的事件。当点击一个按钮时,通常会产生两个或者三个事件——①按钮按下;②如果不小心滑动一下;③手抬起。
Android为触摸事件封装了一个类——MotionEvent,如果重写onTouchEvent()方法,你就会发现该方法的参数就是一个MotionEvent。
在MotionEvent 里面封装了不少好东西,比如触摸点的坐标,可以通过event.getX()方法和event.getRawX()方法取出坐标点;再比如获得点击的各件类型,可以通过不同的Action(如
MotionEvent.ACTION_DOWN、MotionEvent. ACTION_MOVE) 来进行区分,并实现不同的逻辑。
要是将View放在一个ViewGroup里面,而触摸事件就只有一个,到底该分给谁呢?同一个事件中,子View和父ViewGroup都可能想要进行处理,就产生了“事件拦截”。
举例说明:
ViewGroup:重写:dispatchTouchEvent(派遣)、onInterceptTouchEvent(拦截)、onTouchEvent(触摸)
View:重写:dispatchTouchEvent(派遣)、onTouchEvent(触摸)
正常情况下:
事件传递的顺序是:总经理(外层ViewGroup)–>经理(中间ViewGroup)–>我(最底层的View)
事件传递的时候,先dispatchTouchEvent再onInterceptTouchEvent
事件处理的顺序是:我(最底层的View)–>经理(中间ViewGroup)–>总经理(外层ViewGroup)
事件处理的时候,都是执行onTouchEvent方法
事件传递的返回值很好理解:True:拦截,不继续;False不拦截,继续流程
事件处理的返回值也是:True:处理了,不用审核了;False:给上级处理
初始情况下返回值都是false
举例情况:
- 事件分发
情况1、如果总经理觉得这个任务太简单了,自己可以完成,没必要找下属,那么事件就被总经理拦截了。
即:(外层ViewGroup的onInterceptTouchEvent返回true)
情况2、如果经理觉得没必要让下属解决,自己就解决了,那么事件就被经理拦截了。
即:(中间层的ViewGroup的onInterceptTouchEvent返回true) - 事件处理
正常情况下你处理完事件要向上级汇报,需要上级确认,所以你就返回了false
情况1、如果你罢工不干了,那么你就不用报告上级了,就直接返回True
即:我(最底层的view)的onTouchEvent返回了true
情况2、我汇报的报告经理觉得太丢人,不敢给总经理看,就偷偷的返回的true,整个事件结束
即:经理(中间的viewGroup)的onTouchEvent返回了true
比如我现在就是一头雾水,摸不着头脑,从而丧失了学习的兴趣。
完