引言

总所周知,无论是什么类型的App,要捕获用户首先靠的是UI,给用户第一体验的也是UI,所以UI有多重要已经不言而喻了,各式各样的UI仅仅依靠系统提供的组件,肯定是远远不能满足开发需求的,这就需要我们结合具体的业务去开发合适的控件。幸运的是得益于Android系统框架的高度开源和可扩展性,为我们提供了一个自定义View开发的大体框架,但真正的完全掌握也是有一点难度的,毕竟自定义View的需求有时候是源自产品参考IOS、或者其他系统的效果,笔者一直都想总结下自定义View的体系,一方面增强自己的理解并完善自己的Android开发体系,另一方面给一些迷路的人分享一点经验和理解。

一、Android系统控件架构

1、概述

在Android中每一个控件都会再界面中占据一块矩形的区域,这和大多数系统的控件机制都差不多。Android中控件是通过构造树的形式来管理的(所谓控件树如下图所示),主要分为ViewViewGroup两大类,其中ViewGroup直接继承自View,View作为系统所有可视组件的基类,而通过控件树,上层控件负责下层子控件的测量与绘制,并负责分发交互事件的即事件是先传递到ViewGroup的,再由ViewGroup决定是否传递给下层子View。而这颗树的根节点ViewParent(其实质是一个接口定义了一系列管理View的方法)对于该控件树所有的交互事件惊喜统一管理和分发,从而实现对整个树进行整体控制

Android GSYVideoPlayer自定义控件 android自定义控件高级进阶_ViewGroup

2、Android系统界面Activity的结构

Activity对于我们来说再熟悉不过了,他是我们App界面的最最基本的UI元素,我们构造并显示一个界面离不开这两个方法:findViewById()和setContentView(),其中findViewById的内部实现就是通过树的深度优先来遍历控件树从而查找对应元素,而只有在调用了setContentView之后,我们定义的UI元素才真正显示出来。通过Android自带的Hierachy View工具,我们经常可以看到如下的结构:

Android GSYVideoPlayer自定义控件 android自定义控件高级进阶_自定义View_02


从图上我们可以看到Activity基本的结构,每个Activity都包含一个Window对象——PhoneWindow,PhoneWindow将一个DecorView设置为整个应用窗口的根View。DecorView作为窗口界面的顶层视图,封装了一些窗口操作的通用方法。可以理解为DecorView将要显示的具体内容呈现在了PhoneWindow上,在此所有View的监听事件都通过WindowManagerService来进行接收,并通过Activity对象来回调相应的onClickListener。从界面上屏幕被分为两部分:TitleView(用于放置ActionBar或者ToolBar的布局)ContentView(其实质就是一个id为content的FrameLayout用于真正存放我们定义的layout文件,这也是在某些情况我们可以用merge来优化布局的原因之一)假如我们定义了这样的一个layout资源文件并设置Theme为带ActionBar的,那么对应的视图树应该是如下:

Android GSYVideoPlayer自定义控件 android自定义控件高级进阶_自定义View_03


而如果用户通过设requestWindowFeature(Window.FEATURE_NO_TITLE)来设置显示全屏,视图树中的布局就只有Content了,这也是requestWindowFeature()方法一定要在setContentView()方法之前调用的原因。最后当程序在onCreat()方法中调用setContentView()方法后,ActivityManagerService会回调onResume()方法,此时系统才会把整个DecorView添加到PhoneWindow中,并让其显示出来,从而最终完成界面的绘制工作。

二、自定义View的三种模式

在Android下开发自定义View,往往有四种方案:直接继承系统控件扩展具体的功能组合系统现有的控件直接继承View绘从零开始绘制使用修改第三方开源控件项目。至于具体的实现细节,我会尽力在后面的篇幅中总结出一些通用的思想。

1、直接继承系统控件扩展具体的功能

这一种方案应该是我们应该最优先考虑的,其根本思想就是继承系统控件的通用功能,通过去重写构造方法或者其他方法去改变其逻辑,从而实现扩展功能,我知道这么说一时难以理解,后面会针对这三个方案再结合实例详细去总结。

2、组合系统现有的控件

这也应该是我们优先考虑的,说白了就是自定义ViewGroup,其根本思想就是继承ViewGroup及其子类,通过布局或者代码的方式把一些原有的控件添加到自定义的ViewGroup里,这其实与第一种方案大同小异。

3、直接继承View绘从零开始绘制

我们都知道Android支持3D和2D绘图,2D绘图基本是通过Canvas相关方法来实现的,这一方案来根本思想就是直接继承View实现相关方法,通过onDrow、onMeasured方法来绘制界面,通过invalidate和postInvalidateI方法来主动刷新界面,通过重写onTouch等其他回调方法来实现交互,难点和重点也在于处理交互。

三、View的绘制与测量

Android中的GUI系统是客户端和服务端配合的窗口系统,即后台运行了一个绘制服务,每个应用程序都是该服务端的一个客户端,当客户端需要绘制时,首先请求服务端创建一个窗口,然后在窗口中进行具体的视图内容绘制;对于每个客户端而言,他们都感觉自己独占了屏幕,而对于服务端而言,它会给每一个客户端窗口分配不同的层值,并根据用户的交互情况动态改变窗口的层值,这就给用户造成了所谓的前台窗口和后台窗口的概念。当然这是屏幕绘制的原理简要描述,绘制离不开测量,无论是系统控件和自定义的View要想展示于界面之上都离不开测量工作。

1、View的测量

我们都知道每一个控件都会占据一个矩形区域,但是Android系统在绘制前本身并不知道具体的大小和位置,所以它会先进行测量,主要是在View的onMeasure里去实现(这也是我们在自定义View里的构造方法里,无论是调用getMeasureWidth抑或getWidth获取宽度时得到的总是0的原因),而Android中还有一个功能类MeasureSpec(封装了子从父节点传递到子节点下的布局信息包括View的测量模式和大小)用于辅助测量View,当我们重写了onMeasure方法之后,系统通过super.onMeasure方法去调用setMeasuredDimension(width, height)将测量的大小设置进去完成测量。

2、View的绘制

完成测量工作之后,View的根据ViewGroup传人的测量值和模式,对自己宽高进行确定(onMeasure中完成),然后在onDraw中在Canvas上完成对自己的绘制。

四、ViewGroup测量与绘制

1、ViewGroup的测量

ViewGroup需要管理子View,所有其中一项重要的职责就是负责子View的大小,当ViewGroup大小设置为wrap_content,ViewGroup会对子View进行层级遍历,来决定自己的大小,而其他模式下则会取设置的值来为自己的大小。ViewGroup在测量时遍历所有子View,从调用子View对应的onMeasure方法获得子View的大小,完成测量之后再通过调用onLayout方法来决定子View的位置,同样是通过遍历调用子View的onLayout方法,最后在自己的onLayout中完成子View的位置布局工作

2、ViewGroup的绘制

ViewGroup通常不需要绘制,因为它本身没有需要绘制的东西,所以不会触发自己的onDraw方法,但如果指定了background属性则会触发自身的onDraw完成背景的绘制。但ViewGroup会通过dispatchDraw方法来绘制其子View,原理也是一样通过遍历子View调用其子View对应的onDraw方法来完成最终的绘制工作。

五、自定义属性和适配设备

1、自定义属性

无论是任何方案,我们在自定义View的过程中为了兼容大部分的效果,都离不开属性,至于自定义属性的详细介绍已经在前面介绍过了,如不懂请移步Android入门——样式主题和自定义属性资源,这就简单讲下用法。

  • 声明自己的自定义属性

在最新的版本开发中,无论是你把自定义的属性写在attr.xml还是style里都是可以的。

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="android:Theme.Material.Light.DarkActionBar">
    </style>
    <declare-styleable name="enhancedEditText">
        <attr name="during" format="integer"></attr>
        <attr name="counts" format="integer"></attr>
        <attr name="rightSize" format="float"></attr>
    </declare-styleable>
</resources>
  • 赋予自定义属性真正的作用
    如果你只是定义了属性,并没有在你的自定义View里的方法中使用是没有任何作用的,并不会因为你定义的属性叫做width或者height而去智能识别。
private void init() {
        //如果没有设置drawableRight属性则会获取默认的值,设置了drawableRight则会显示设置的值
        mRightIco = getCompoundDrawables()[2];
        if (mRightIco == null) {
            mRightIco = getResources().getDrawable(R.drawable.clear_selector);
        }
        //重新设置左边的Icon为左边Icon的大小,其中rightsize为自定义属性的值
        mRightIco.setBounds(0, 0, (int) ((getCompoundDrawables()[0].getIntrinsicWidth())*rightSize), (int) ((getCompoundDrawables()[0].getIntrinsicHeight())*rightSize));
        setRightIconVisiable(false);//默认设置隐藏图标
    }
  • 获取自定义属性的值
public EnhancedEditText(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        //引用自定义属性
        TypedArray typedArray=context.obtainStyledAttributes(attrs,R.styleable.enhancedEditText);
        during=typedArray.getInt(R.styleable.enhancedEditText_during,2000);
        counts=typedArray.getInt(R.styleable.enhancedEditText_counts,6);
        rightSize=typedArray.getFloat(R.styleable.enhancedEditText_rightSize,0.7f);
        init();
    }

2、适配兼容各种设备

为了兼容各种分辨率的设备,所以组件所需要的资源一般都是以属性的方式(尽量避免以xml的方式引用),所以我们可以通过代码把dp转为px的方式

int dip=100;
DisplayMetrics displayMetrics =new DisplayMetrics();
displayMetrics.setToDefaults();
int pixel= (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,dip,displayMetrics);

六、实现自己的交互功能

如果一个自定义View没有了交互功能,那么和咸鱼有什么区别,具体的实现是通过重写onTouch等方法来实现,为了便于扩展通常是定义成为一个回调接口,具体的实现留给使用者。

七、手机屏幕一些参数

Android碎片化特别严重,有一部分原因是由于Android设备的屏幕种类繁多,也加重了我们在适配屏幕时的负担,所以有必要了解些关于屏幕的信息。

1、手机屏幕的参数

  • 屏幕大小 :屏幕大小就是屏幕的对角线长度,单位为“寸”,常见的4.7、5.0、5.5寸等。
  • 分辨率:手机屏幕上像素点的个数,比如说1080*1920,指的就是屏幕宽有1080个像素点,高邮1920个像素点, 以pixels(像素).为单位,不同设备显示效果相同,比如我们800*480的屏幕宽度就是 800px。
  • PPI:又称为DPI,每英寸像素,由对角线的像素点个数除以屏幕大小而得。

2、系统屏幕密度

正是由于各个厂商的Android手机具有各种不同的大小和分辨率,Android定义了几个DPI值,DPI的全称是 Dots Per Inch,Inch是一个物理单位(无论在任何设备上,其大小都是固定的),所以DPI就指在一个Inch的物理长度内有多少个Dots

Android GSYVideoPlayer自定义控件 android自定义控件高级进阶_CustomView_04


Android中使用mdpi即密度值为160的屏幕作为标准,其他密度屏幕通过划算关系,在mdpi中1dp=1px,hdpi中1dp=1.5px,在xdpi中1dp=2px,在xxdpi中1dp=3px,即各分辨率的比例为ldpi:mdpi:hdpi:xhdpi:xxhdpi=3:4:6:8:12所以px和dp的换算关系为:

px = dp * (dpi / 160) 或者 px = dp * (ppi / 160)

3、DP、PX和SP

正是由于有那么多不同分辨率和大小的屏幕,使用px必然会导致适配困难,为了进一步简化适配工作,DP 或者 DIP (Density-Independent pixel)应运而生,他是一个虚拟的像素单位,因为它的大小不是一个物理值,而是由操作系统根据屏幕大小和密度动态渲染出来的。比如Pad的屏幕密度为326dpi,如果需要显示的图片大小为20dp,那么就需要提供一个 20 (326 / 160) = 40px的图片才能达到最佳显示效果,如果还要适配一个163dpi的屏幕,那么还需要再提供一个20 (163 / 160) = 20px的图片。再比如iPad2 和 iPad Retina的的物理尺寸都是 9.7 inch,不同的是分辨率和PPI,一个是1024x768 / 132ppi,另一个是2048x1536 / 264ppi,分别计算一下20dp对应多少inch

pad2 = 20 * (132 / 160) * (7.9 / (math.sqrt(1024 * 1024 + 768 * 768))) 

ipad_retina = 20 * (264 / 160) * (7.9 / (math.sqrt(2048 * 2048 + 1536 * 1536)))

计算结果都是0.1018359375,这就是dp的功能,它能保证在所有的设备上显示的大小都一样。如果只提供了一个大小为20px的图片,为了保证图片在所有设备上的物理大小都一样,高DPI的设备上系统会拉伸图片,低DPI的设备上图片会被缩小,所以我们需要为不同屏幕密度的设备提供不同的图片,他们之间的对应关系如下。


再举一个简单的例子,在Canvas中绘制一个环形,同样设置宽度为100,而我们知道draw是以px为单位的,这就造成了高密度的屏幕上环形的宽度要窄于低密度的上的环形宽度。

SP 全称则是 Scale-independent Pixels,用于字体大小,其概念与DP是一致的,也是为了保持设备无关。一般作为设置字体的大小的单位。

PS:

由于个人技术能力有限,也因为自定义View实在是种类繁多,或许对于三种自定义View的根本思想总结的不那么完全。接下来将正式进入自定义View的实战旅程,这一系列文章也会长期更新,希望大家有啥见解还望不吝赐教,为Android开发分享自己的一点点经验。