Android控件架构与自定义控件详解

Android控件架构与自定义控件详解

控件是每个Android App都必不可少的一部分,无论是使用系统控件,还是使用自定义控件。这些控件,组成了每个精美的界面。

1. Android控件架构

在Android中控件大致被分为两类,即ViewGroup控件与View控件。

ViewGroup控件作为父控件可以包含多个View控件,并管理其包含的View控件。通过ViewGroup,整个界面上的控件形成了一个树形结构,也就是我们常说的控制树,上层控件负责下层子控件的测量与绘制,并传递交互事件。findViewById()方法就是在控件树中以树的深度优先遍历来查找对应元素。在每颗控件树的顶部,都有一个ViewParent对象,这就是整棵树的控制核心,所有的交互管理事件都是由它来统一调度和分配,从而可以对整个视图进行整体控制。
Android控件架构与自定义控件详解
在Activity中使用setContentView()方法加载布局文件时,每个Activity都包含一个Window对象,在Android中Window对象通常由PhoneWindow来实现。PhoneWindow将一个DecorView设置为整个应用窗口的根View。DecorView作为窗口的顶层视图,封装了一些窗口操作的通用方法。所有View的监听事件都通过WindowManagerService来接收,并通过Activity对象来回调响应的onClickListener。在显示上,将屏幕分为TitleView和ContentView。activity_main.xml就是设置在一个id为content的Framelayout里。
Android控件架构与自定义控件详解
而在代码中,当程序在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。

简单的实例演示:

  1. 创建一个类ViewTest继承自View,并重写必要的构造方法。
  2. 重写onMeasure方法,在onMeasure中调用自定义的measureWidth()和measureheight()方法。
  3. 测试结果
    具体代码如下:
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;
    }
}

结果展示:
Android控件架构与自定义控件详解
Android控件架构与自定义控件详解
Android控件架构与自定义控件详解

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的背景更加丰富:
Android控件架构与自定义控件详解

原生的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标题栏为例:

  1. 定义属性
    创建一个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都可能想要进行处理,就产生了“事件拦截”。

举例说明:
Android控件架构与自定义控件详解
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

比如我现在就是一头雾水,摸不着头脑,从而丧失了学习的兴趣。

上一篇:android混淆代码!6年菜鸟开发面试字节跳动安卓研发岗,大厂面经合集


下一篇:太牛了!2021京东最新Android面试真题解析,面试真题解析