安卓自定义控件(视图、改造控件、通知Notification、界面绘制)

视图的构建过程

此节介绍一个视图的构建过程,包括:如何编写视图的构造方法,4个构造方法之间有什么区别;如何测量实体的实际尺寸,包含文本、图像、线性视图的测量方法;如何利用画笔绘制视图的界面,并说明onDraw方法与dispatchDraw方法的先后执行顺序。

视图的构造方法

Android自带的控件往往外观欠佳,开发者常常需要修改某些属性,比如按钮控件Button就有好几个问题,其一字号大小,其二文字颜色太浅,其三字母默认大写。于是XML文件中的每个Button节点都得添加textSize、textColor、textAllCaps3个属性,以便定制按钮的字号、文字颜色和大小写开关,就像下面这样:

<Button
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="Hello World"
    android:textAllCaps="false"
    android:textColor="#000000"
    android:textSize="20sp"/>

如果只是一两个按钮控件到还好办,倘若App的许多页面都有很多Button,为了统一按钮风格,就得给全部Button节点都加上这些属性。要是哪天产品经理心血来潮,命令将所有按钮统一换成另一种风格,如此多的Button节点只好逐个去修改,令人苦不堪言。为此可以考虑把按钮样式提炼出来,将统一的按钮风格定义在某个地方,每个Button节点引用统一样式便可。为此打开res/values目录下的styles.xml,在resources节点内部补充如下所示的风格配置定义:

<style name="CommonButton">
    <item name="android:textAllCaps">false</item>
    <item name="android:textColor">#000000</item>
    <item name="android:textSize">20sp</item>
</style>

接着回到XML文件中,给Button节点添加形如style="@style/样式名称"的引用说明,表示当前控件将覆盖指定的属性样式,添加样式引用后的Button节点内容如下:

<Button
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="这是来自style的Button"
    style="@style/CommonButton"/>

运行App,打开的按钮界面如下图:
在这里插入图片描述
从上图可见通过style引用的按钮果然变了模样。以后若要统一更换所有按钮的样式,只需要修改styles.xml中的样式配置即可。
然而样式引用仍有不足之处,因为只有Button节点添加了style属性才奏效,要是忘了添加style属性就不管用了,而且样式引用只能修改已有的属性,不能添加新属性,也不能添加新方法。若要想更灵活地定制控件外观,就要通过自定义控件实现了。
自定义控件听起来很复杂地样子,其实并不高深,不管控件还是布局,它们本质上都是一个Java类,页拥有自身地构造方法。以视图基类View为例,它有4个构造方法,分别介绍如下:

  1. 带1个参数地构造方法public View (Context context),在Java代码中通过new关键字创建视图对象时,会调用这个构造方法。
  2. 带2个参数地构造方法public View (Context context, AttributeSet attrs),在XML文件中添加视图节点时,会调用这个构造方法。
  3. 带3个参数地构造方法public View (Context context, AttributeSet attrs, int defStyleAttr),在采取默认的样式属性时,会调用这个构造方法。如果defStyleAttr填0,则表示没有默认的样式。
  4. 带4个参数的构造方法public View (Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes),在采取默认的样式资源时,会调用这个构造方法。如果如果defStyleAttr填0,则表示无样式资源。

以上的4个构造方法中,前两个必须实现,否则要么不能在代码中创建视图对象,要么不能在XML文件中添加视图节点;至于后两个构造方法,则与styles.xml中的样式配置有关。先看带3个参数的构造方法,第3个参数defStyleAttr的意思是指定默认的样式属性,这个样式属性在res/values下面的attrs.xml中配置,如果values目录下没有attrs.xml就创建该文件,并填入以下的样式属性配置:

<resources>
    <declare-styleable name="CustomButton">
        <attr name="customButtonStyle" format="reference" />
    </declare-styleable>
</resources>

以上的配置内容表明了属性名称为customButtonStyle,属性格式为引用类型reference,也就是实际样式在别的地方定义,这个地方便是styles.xml中定义的样式配置。可是customButtonStyle怎样与styles.xml里的CommonButton样式关联起来呢?每当开发者创建新项目时,AndroidManifest.xml的application节点都设置了主题属性,通常为android:theme="@style/AppTheme,这个默认主题来自styles.xml的AppTheme,打开styles.xml发现文件开头的AppTheme配置定义如下:

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="customButtonStyle">@style/CommonButton</item>
</style>

接着到Java代码包中编写自定义的按钮控件,控件代码如下所示,注意在defStyleAttr处填上默认的样式属性R.attr.customButtonStyle。

public class CustomButton extends Button {
	public CustomButton(Context context) {
        super(context);
    }
    public CustomButton(Context context, AttributeSet attrs) {
        this(context, attrs, R.attr.customButtonStyle); // 设置默认样式
    }
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
}

然后打开测试界面的XML布局文件activity_custom_button.xml,添加如下所示的自定义控件节点CustomButton:

<!-- 注意自定义控件需要指定该控件的完整路径 -->
<com.example.chapter08.widget.CustomButton
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="这是自定义的Button"
    android:background="#ffff00"/>

运行App,此时按钮界面可见第三个按钮也就是自定义按钮控件字号变大、文字变黑,同时按钮的默认背景不见了,文字也不居中对齐了,如下图:
在这里插入图片描述
查看系统自带的按钮Button源码,发现它的构造方法是下面这样的:

public Button(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.buttonStyle);
    }

可见按钮控件的外观的默认样式都写在系统的内核的com.android.internal.R.attr.buttonStyle之中了,难怪Button与TextView的外观有所差异,原来时默认的样式属性造成的。
不过defStyleAttr的实现过程稍显繁琐,既要在styles.xml中配置好样式,又要在attrs.xml中添加样式属性定义,末了还得在App的当前主题中关联样式属性与样式配置。为简化操作,视图对象带4个参数的构造方法便排上用场了,第4个参数defStyleRes允许直接传入样式配置的资源名称,例如R.style.CommonButton就能直接指定当前视图的样式风格,于是defStyleRes的3个步骤简化为1个defStyleRes的1个步骤,也就是只需要在styles.xml文件中配置样式风格。此时自定义控件的代码就要将后两个构造方法改成下面这样:

public class CustomButton extends Button {
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
    	// 下面不使用defStyleAttr,直接使用R.style.CommonButton
        this(context, attrs, 0, R.style.CommonButton);
    }
    @SuppressLint("NewApi")
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
}

由于styles.xml定义的样式风格允许用在多个地方,包括XML文件中的style属性、构造方法中的defStyleAttr(对应当前主题)、构造方法中的defStyleRes,如果这三处地方分别引用了不同的样式,控件又该呈现什么样的风格呢?对于不同来源的样式配置,Android给每个来源都分配了优先级,优先级越大的来源,其样式会优先展示。至于上述的三处来源,它们之间的优先级顺序为:style属性>defStyleAttr>defStyleRes,也就是说,XML文件的style属性所引用的样式资源优先级最高,而defStyleRes所引用的样式资源优先级最低。

视图的测量方法

构造方法只是自定义控件的第一步,自定义控件的第二步是测量尺寸,也就是重写onMeasure方法。要想把自定义的控件画到界面上,首先得知道这个控件的宽高尺寸,而控件的宽和高在XML文件中分别由layout_width属性和layout_height属性规定,它们有3中赋值方式,具体说明见下表:

XML中的尺寸类型LayoutParams说明
match_parentMATCH_PARENT与上级视图大小一样
wrap_contentWRAP_CONTENT按照自身尺寸进行适配
**dp整型数具体的尺寸数值
方式1和方式3都较简单,要么取上级视图的数值,要么取具体数值。难办的是方式2,这个尺寸究竟要如何度量,总不能让开发者拿着尺子在屏幕上比划吧。当然,Android提供相关度量方法,支持在不同情况下测量尺寸。需要测量的实体主要有3种,分别是文本尺寸、图形尺寸和布局尺寸,依次说明如下:

1.文本尺寸测量

文本尺寸测量分为文本的宽度和高度,需根据文本大小分别计算。其中,文本宽度使用Paint类的measureText方法测量,具体代码如下:

// 获取指定文本的宽度(其实就是长度)
public static float getTextWidth(String text, float textSize) {
    if (TextUtils.isEmpty(text)) {
        return 0;
    }
    Paint paint = new Paint(); 		// 创建一个画笔对象
    paint.setTextSize(textSize); 	// 设置画笔的文本大小
    return paint.measureText(text); // 利用画笔丈量指定文本的宽度
}

至于文本高度的计算用到了FontMetrics类,该类提供了5个与高度相关的属性,详细说明见下表:

FontMetrics类的距离属性说明
top行的顶部与基线的距离
ascent字符的顶部与基线的距离
descent字符的底部与基线的距离
bottom行的底部与基线的距离
leading行间距

之所以区分这些属性,是为了计算不同规格的高度。如果要得到文本自身的高度,则高度值=descent-ascent;如果要得到文本所在行的行高,则高度值=bottom-top+leading。以计算文本高度为例,具体的计算代码如下:

// 获取指定文本的高度
public static float getTextHeight(String text, float textSize) {
    Paint paint = new Paint(); 					// 创建一个画笔对象
    paint.setTextSize(textSize); 				// 设置画笔的文本大小
    FontMetrics fm = paint.getFontMetrics(); 	// 获取画笔默认字体的度量衡
    return fm.descent - fm.ascent; 				// 返回文本自身的高度
    //return fm.bottom - fm.top + fm.leading;  	// 返回文本所在行的行高
}

下面观察文本尺寸的度量结果,当字体大小为17sp时,示例文本的宽度为119、高度为19,如下图:
在这里插入图片描述

2.图形尺寸测量

相对于文本尺寸测量,图形尺寸的计算反而简单些,因为Android提供了现成的宽、高获取方法。如果图形是Bitmap格式,就通过getWidth方法获取位图对象的宽度,通过getHeight方法获取位图对象的高度;如果图形是Drawable格式,就通过getIntrinsicWidth方法获取图形的宽度,通过getIntrinsicHeight方法获取图形对象的高度。

3.布局尺寸测量

文本尺寸测量主要用于TextView、Button等文件控件,图形尺寸测量主要用于ImageView、ImageButton等图像控件。在实际开发中,有更多场合需要测量布局视图的尺寸。由于布局视图的内部可能有文本控件、图像控件,还可能有padding和margin,因此,逐个测量布局的内部控件是不现实的。幸而View类提供了一种测量整体布局的思路,对应layout_width和layout_height的3种赋值方式,Android的视图基类同样提供了3种测量模式,具体取值说明如下表:

MeasureSpac类视图宽、高的赋值方式说明
AT_MOSTMATCH_PARENT达到最大
UNSPECIFIEDWRAP_CONTENT未指定(实际就是自适应值)
EXACTLY具体dp值精确尺寸

围绕这3种测量模式衍生了相关度量方法,如ViewGroup类的getChildMeasureSpec方法(获取下级视图的测量规格)、MeasureSpec类的makeMeasureSpec方法(根据指定参数指定测量规格)、View类的measure方法(按照测量规格进行测量操作)等。以线性布局为例,详细的布局高度测量代码如下:

// 计算指定线性布局的实际高度
public static float getRealHeight(View child) {
    LinearLayout llayout = (LinearLayout) child;
    // 获得线性布局的布局参数
    LayoutParams params = llayout.getLayoutParams();
    if (params == null) {
        params = new LayoutParams(
                LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
    }
    // 获得布局参数里面的宽度规格
    int wdSpec = ViewGroup.getChildMeasureSpec(0, 0, params.width);
    int htSpec;
    if (params.height > 0) { // 高度大于0,说明这是明确的dp数值
        // 按照精确数值的情况计算高度规格
        htSpec = MeasureSpec.makeMeasureSpec(params.height, MeasureSpec.EXACTLY);
    } else { // MATCH_PARENT=-1,WRAP_CONTENT=-2,所以二者都进入该分支
        // 按照不确定的情况计算高度规则
        htSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
    }
    llayout.measure(wdSpec, htSpec); // 重新丈量线性布局的宽高
    // 获得并返回线性布局丈量之后的高度。调用getMeasuredWidth方法可获得宽度
    return llayout.getMeasuredHeight();
}

现在很多App页面都提供了下拉刷新功能,这需要计算下拉刷新的头部高度,以便在下拉时判断整个页面要拉动多少距离。比如比如下图所示的下拉刷新头部,对应的XML源码路径为res/layout/drag_drop_header.xml,其中包含图像、文字和间隔,调用getRealHeight方法计算得到的布局高度为544。
在这里插入图片描述
以上的几种尺寸测量办法看似复杂,其实相关的测量逻辑早已封装在View和ViewGroup之中,开发者自定义的视图一般无需重写onMeasure方法;就算重写了onMeasure方法,也可调用getMeasureWidth方法获得测量完成的宽度,调用getMeasureHeight方法获得测量完成的高度。

视图的绘制方法

测量完控件的宽和高,接下来就要绘制控件图案了,此时可以重写两个视图绘制方法,分别是onDraw和dispatchDraw,它们的区别主要有下列两点:

  1. onDraw即可用于普通控件,也可用于布局类视图;而dispatchDraw专门用于布局类的视图,像线性布局LinearLayout、相对布局RelativeLayout都属于布局类视图。
  2. onDraw方法先执行,dispatchDraw方法后执行,这两个方法中间再执行下级视图的绘制方法。比如App界面有个线性布局A,且线性布局内部有个相对布局B,同时相对布局B内部又有个文本视图C,则它们的绘制方法执行顺序为:线性布局A的onDraw方法->相对布局B的onDraw方法->文本视图的onDraw方法->相对布局的dispatchDraw方法->线性布局A的dispatchDraw方法,更直观的绘图顺序参见下图:
    在这里插入图片描述

不管是onDraw方法还是dispatchDraw方法,它们的入参都是Canvas画布对象,在画布上绘图想当于在屏幕上绘图。绘图本身是个很大的课题,画布的用法也多种多样,单单Canvas便提供了3类方法:划定可绘制的区域、在区域内部绘制图形、画布的控制操作,分别说明如下。

1.划定可绘制的区域

虽然视图内部的所有区域都允许绘制,但是有时候开发者只想在某个矩形区域内部画画,这时就得先制定允许绘图的区域界限,相关方法说明如下:

  • clipPath:裁剪不规则曲线区域。
  • clipRect:裁剪矩形区域。
  • clipRegion:裁剪一块组合区域。

2.在区域内部绘制图形

该类方法用来绘制各种基本几何图形,相关方法说明如下:

3.画布的控制操作

控制操作包括画布的旋转、缩放、平移以及存取画布状态的操作,相关方法说明如下:

上述第二大点提到的draw***方法只是准备绘制某种几何图形,真正的细节描绘还要靠画笔工具Paint实现。Paint类定义了画笔的颜色、样式、粗细、阴影等,常用方法说明如下:

接下来演示如何通过画布和画笔描绘不同的几何图形,以绘制圆角矩形与绘制椭圆为例,重写后的onDraw方法示例如下:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int width = getMeasuredWidth(); // 获得布局的实际宽度
    int height = getMeasuredHeight(); // 获得布局的实际高度
    if (width > 0 && height > 0) {
        if (mDrawType == 1) { // 绘制矩形
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 在画布上绘制矩形
        } else if (mDrawType == 2) { // 绘制圆角矩形
            RectF rectF = new RectF(0, 0, width, height);
            canvas.drawRoundRect(rectF, 30, 30, mPaint); // 在画布上绘制圆角矩形
        } else if (mDrawType == 3) { // 绘制圆圈
            int radius = Math.min(width, height) / 2 - mStrokeWidth;
            canvas.drawCircle(width / 2, height / 2, radius, mPaint); // 在画布上绘制圆圈
        } else if (mDrawType == 4) { // 绘制椭圆
            RectF oval = new RectF(0, 0, width, height);
            canvas.drawOval(oval, mPaint); // 在画布上绘制椭圆
        } else if (mDrawType == 5) { // 绘制矩形及其对角线
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 绘制矩形
            canvas.drawLine(0, 0, width, height, mPaint); // 绘制左上角到右下角的线段
            canvas.drawLine(0, height, width, 0, mPaint); // 绘制左下角到右上角的线段
        }
    }
}

运行App,即可观察到实际的绘图效果,其中调用drawRoundRect方法绘制圆角矩形的界面如下图:
在这里插入图片描述
由于onDraw方法的调用在绘制下级视图之前,而dispatchDraw方法的调用在绘制下级视图之后,因此如果希望当前视图不被下级视图覆盖,就只能在dispatchDraw方法中国绘图。下面是分别在onDraw和dispatchDraw两个方法种绘制矩形及其对角线的代码例子:

// onDraw方法在绘制下级视图之前调用
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int width = getMeasuredWidth(); // 获得布局的实际宽度
    int height = getMeasuredHeight(); // 获得布局的实际高度
    if (width > 0 && height > 0) {
        if (mDrawType == 1) { // 绘制矩形
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 在画布上绘制矩形
        } else if (mDrawType == 2) { // 绘制圆角矩形
            RectF rectF = new RectF(0, 0, width, height);
            canvas.drawRoundRect(rectF, 30, 30, mPaint); // 在画布上绘制圆角矩形
        } else if (mDrawType == 3) { // 绘制圆圈
            int radius = Math.min(width, height) / 2 - mStrokeWidth;
            canvas.drawCircle(width / 2, height / 2, radius, mPaint); // 在画布上绘制圆圈
        } else if (mDrawType == 4) { // 绘制椭圆
            RectF oval = new RectF(0, 0, width, height);
            canvas.drawOval(oval, mPaint); // 在画布上绘制椭圆
        } else if (mDrawType == 5) { // 绘制矩形及其对角线
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 绘制矩形
            canvas.drawLine(0, 0, width, height, mPaint); // 绘制左上角到右下角的线段
            canvas.drawLine(0, height, width, 0, mPaint); // 绘制左下角到右上角的线段
        }
    }
}

// dispatchDraw方法在绘制下级视图之前调用
@Override
protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    int width = getMeasuredWidth(); // 获得布局的实际宽度
    int height = getMeasuredHeight(); // 获得布局的实际高度
    if (width > 0 && height > 0) {
        if (mDrawType == 6) { // 绘制矩形及其对角线
            Rect rect = new Rect(0, 0, width, height);
            canvas.drawRect(rect, mPaint); // 绘制矩形
            canvas.drawLine(0, 0, width, height, mPaint); // 绘制左上角到右下角的线段
            canvas.drawLine(0, height, width, 0, mPaint); // 绘制左下角到右上角的线段
        }
    }
}

实验用的界面布局片段示例如下,主要观察对角线是否遮住内部的按钮控件:

<!-- 自定义的绘画视图,需要使用全路径 -->
<com.example.chapter08.widget.DrawRelativeLayout
    android:id="@+id/drl_content"
    android:layout_width="match_parent"
    android:layout_height="150dp" >

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="我在中间" />
</com.example.chapter08.widget.DrawRelativeLayout>

运行App,发现使用onDraw绘图的界面如下图所示:
在这里插入图片描述
使用dispatchDraw绘图的界面如下图:
在这里插入图片描述
对比可见,调用onDraw方法绘制对角线时,中间的按钮遮住了对角线;调用dispatchDraw方法绘制对角线,对角线没被按钮遮住,依然显示在视图中央。

改造已有的控件

此节介绍如何对现有控件加以改造,使之变得具备不同功能的新控件,包括:如何基于日期选择器实现月份选择器,如何给翻页标签栏添加文字样式属性,如何在滚动视图中展示完整的列表视图。

自定义月份选择器

虽然Android提供了许多控件,但是仍然不够用,比如系统自带日期选择器DatePicker和事件选择器TimePicker,却没有月份选择器MonthPicker,倘若希望选择某个月份,一时之间叫人不知如何是好。不过为什么支付宝账单查询支持选择月份呢?就像下图所示的支付宝查询账单页面,分明可以单独选择年月。
在这里插入图片描述
看上去,支付宝的年月日控件彷佛系统自带的日期选择器,区别在于去掉右侧的日子列表。二者之间如此相似,这可不是偶然撞衫,而是它们本来系出一源。只要把日期选择器稍加修改,想办法隐藏右边多余的日子列,即可实现移花接木的效果。下面是将日期选择器修改之后变成月份选择器的代码例子:

// 由日期选择器派生出月份选择器
public class MonthPicker extends DatePicker {
    public MonthPicker(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 获取年月日的下拉列表项
        ViewGroup vg = ((ViewGroup) ((ViewGroup) getChildAt(0)).getChildAt(0));
        if (vg.getChildCount() == 3) {
            // 有的机型显示格式为“年月日”,此时隐藏第三个控件
            vg.getChildAt(2).setVisibility(View.GONE);
        } else if (vg.getChildCount() == 5) {
            // 有的机型显示格式为“年|月|日”,此时隐藏第四个和第五个控件(即“|日”)
            vg.getChildAt(3).setVisibility(View.GONE);
            vg.getChildAt(4).setVisibility(View.GONE);
        }
    }
}

由于日期选择器有日历和下拉框两种展示形式。上面的月份选择器代码只对下拉选择框生效,因此布局文件中添加月份选择器之时,要特别注意添加属性android:datePickerMode="spinner",表示该控件采取下拉列表显示;并添加属性android:calendarViewShown="false",表示不显示日历视图。月份选择器在布局文件中的定义例子如下:

<!-- 自定义的月份选择器,需要使用全路径 -->
<com.example.chapter08.widget.MonthPicker
    android:id="@+id/mp_month"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:calendarViewShown="false"
    android:datePickerMode="spinner" />

这下大功告成,重新包装后的月份选择器俨然也是日期事件控件家族的一员,不但继承了日期选择器的所有方法,而且控件界面与支付宝几乎一样。月份选择器的界面效果如下图,果然只展示年份和月份了。
在这里插入图片描述

给翻页标签页添加新属性

前面介绍的月份选择器,是以日期选择器为基础,只保留年月两项同时屏蔽日子而成,这属于在现有控件上做减法。反过来,也允许在现有控件上做加法,也就是给控件增加新的属性或者新的方法。例如PagerTabStrip无法在XML文件中设置文本大小和颜色,只能在Java代码中调用setTextSize和setTextColor方法。这让人很不习惯,最好能够在XML文件中直接指定textSize和textColor属性。接下来通过自定义属性来扩展PagerTabStrip,以便在布局文件中指定文字大小和文字颜色的属性。具体步骤说明如下:

  1. 在res/values目录下创建attrs.xml。其中,declare-styleable的name属性值表示新控件名为CustomPagerTab,两个attr节点表示新增的两个属性分别是textColor和textSize。文件内容如下:
<resources>
<declare-styleable name="CustomPagerTab">
    <attr name="textColor" format="color" />
    <attr name="textSize" format="dimension" />
</declare-styleable>
</resources>

  1. 在Java代码的widget目录下创建CustomPagerTab.java,填入以下代码:
public class CustomPagerTab extends PagerTabStrip {
    private int textColor = Color.BLACK; // 文本颜色
    private int textSize = 15; // 文本大小

    public CustomPagerTab(Context context) {
        super(context);
    }

    public CustomPagerTab(Context context, AttributeSet attrs) {
        super(context, attrs);
        if (attrs != null) {
            // 根据CustomPagerTab的属性定义,从XML文件中获取属性数组描述
            TypedArray attrArray = context.obtainStyledAttributes(attrs, R.styleable.CustomPagerTab);
            // 根据属性描述定义,获取XML文件中的文本颜色
            textColor = attrArray.getColor(R.styleable.CustomPagerTab_textColor, textColor);
            // 根据属性描述定义,获取XML文件中的文本大小
            // getDimension得到的是px值,需要转换为sp值
            textSize = Utils.px2sp(context, attrArray.getDimension(
                    R.styleable.CustomPagerTab_textSize, textSize));
            attrArray.recycle(); // 回收属性数组描述
        }
    }
    
    @Override
    protected void onDraw(Canvas canvas) { // 绘制方法
        super.onDraw(canvas);
        setTextColor(textColor); // 设置标题文字的文本颜色
        setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize); // 设置标题文字的文本大小
    }
}
  1. 给演示页面的XML文件根节点增加命名空间声明xmlns:app="http://schemas.android.com/apk/res-auto",再把PagerTabStrip的节点名称改为自定义控件的全路径名称(如com.example.chapter08.widget.CustomPagerTab),同时在该节点下添加两个属性–app:textColor与app:textSize,也就是在XML文件中指定标签文本的颜色与大小。修改后的XML文件如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <androidx.viewpager.widget.ViewPager
        android:id="@+id/vp_content"
        android:layout_width="match_parent"
        android:layout_height="360dp">

        <!-- 这里使用自定义控件的全路径名称,其中textColor和textSize为自定义的属性 -->
        <com.example.chapter08.widget.CustomPagerTab
            android:id="@+id/pts_tab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:textColor="#ff0000"
            app:textSize="17sp" />
    </androidx.viewpager.widget.ViewPager>
</LinearLayout>

完成以上3个步骤之后,运行App,打开翻页界面显示如下图,可见此时翻页标签栏的标题文字变为红色,字体也变大了。
在这里插入图片描述
注意上述自定义控件的步骤1,attrs.xml里面attr节点的name表示新属性的名称,format表示新属性的数据格式:而在步骤2中,调用getColor方法获取颜色值,调用getDimensionPixelSize方法获取文字大小。有关属性格式及其获取方法的对应说明如下表:

属性格式的名称Java代码的获取方法XML布局文件中的属性值说明
booleangetBoolean布尔值。取值为true或false
integergetInt整型值
floatgetFloat浮点值
stringgetString字符串
colorgetColor颜色值。取值为开头带#的6位或8位十六进制数
dimensiongetDimensionPixelSize尺寸值。单位为px
referencegetResourceId参考某一资源。取值如@drawable/ic_launcher
enumgetInt枚举值
flaggetInt标志位

不滚动的列表视图

一个视图的宽和高,其实在页面布局的时候就决定了,视图节点的android:layout_width属性指定了该视图的宽度,而android:layout_height属性指定了该视图的高度。这两个属性又有3种取值方式,分别是:取值match_parent表示与上一级视图一样尺寸,取值wrap_content表示按照自身内容的实际尺寸,最后一种则直接指定了具体的dp数值。在多数情况下,系统按照这3种取值方式,完全能够自动计算正确的视图宽度和视图高度。
当然也有例外,像列表视图ListView就是个另类,尽管ListView在多数场合的高度计算不会出错,但是把它放到ScrollView之中便出现问题了。ScrollView本身叫作滚动视图,而列表视图ListView也是允许滚动的,于是一个滚动视图嵌套另一个也能滚动的视图,那么在双方的重叠区域,上下滑动的手势究竟表示要滚动哪个视图?这个滚动冲突的问题,不仅令开发者糊里糊涂,即便是Android系统也得神经错乱。所以Android目前的处理对策是:如果ListView的高度被设置为wrap_content,则此时列表视图只显示一行的高度,然后整个界面只滚动ScrollView。
如此虽然滚动冲突的问题暂时解决了,但是又带来了一个新问题,好好的列表视图仅仅显示一行内容,这让出不了头的剩余列表情以何堪?按照用户正常的思维逻辑,列表视图应该显示所有行,并且列表内容要跟着整个页面一齐向上或者向下滚动。显然此时系统对ListView的默认处理方式并不符合用户习惯,只能对其改造使之满足用户的使用习惯。改造列表视图的一个可行方案,便是重写它的测量方法onMeasure,不管布局文件设定的视图高度为何,都把列表视图的高度改为最大高度,即所有列表高度加起来的总高度。
根据以上思路,编写一个扩展自ListView的不滚动列表视图NoScrollListView,它的实现代码如下:

public class NoScrollListView extends ListView {

    public NoScrollListView(Context context) {
        super(context);
    }

    public NoScrollListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public NoScrollListView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    // 重写onMeasure方法,以便自行设定视图的高度
    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 将高度设为最大值,即所有项加起来的总高度
        int expandSpec = MeasureSpec.makeMeasureSpec(
                Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, expandSpec); // 按照新的高度规格重新测量视图尺寸
    }
}

接下来演示改造后的列表视图界面效果,先在测试页面的XML文件中添加ScrollView节点,再在该节点下挂ListView节点,以及自定义的NoScrollListView节点。修改后的XML文件的内容如下:

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <LinearLayout
            android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:padding="10dp"
            android:text="下面是系统自带的列表视图"
            android:textColor="#ff0000"
            android:textSize="17sp" />

        <ListView
            android:id="@+id/lv_planet"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="10dp"
            android:dividerHeight="1dp" />

        <TextView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:gravity="center"
            android:padding="10dp"
            android:text="下面是自定义的列表视图"
            android:textColor="#00ff00"
            android:textSize="17sp" />

        <!-- 自定义的不滚动列表视图,需要使用全路径 -->
        <com.example.chapter08.widget.NoScrollListView
            android:id="@+id/nslv_planet"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginBottom="10dp"
            android:dividerHeight="1dp" />
    </LinearLayout>
</ScrollView>

回到该页面的活动代码,给ListView和ScrollListView两个控件设置一摸一样的行星列表,具体的Java代码如下:

public class NoscrollListActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_noscroll_list);
        PlanetListAdapter adapter1 = new PlanetListAdapter(this, Planet.getDefaultList());
        // 从布局文件中获取名叫lv_planet的列表视图
        // lv_planet是系统自带的ListView,被ScrollView嵌套只能显示一行
        ListView lv_planet = findViewById(R.id.lv_planet);
        lv_planet.setAdapter(adapter1); // 设置列表视图的行星适配器
        lv_planet.setOnItemClickListener(adapter1);
        lv_planet.setOnItemLongClickListener(adapter1);
        PlanetListAdapter adapter2 = new PlanetListAdapter(this, Planet.getDefaultList());
        // 从布局文件中获取名叫nslv_planet的不滚动列表视图
        // nslv_planet是自定义控件NoScrollListView,会显示所有行
        NoScrollListView nslv_planet = findViewById(R.id.nslv_planet);
        nslv_planet.setAdapter(adapter2); // 设置不滚动列表视图的行星适配器
        nslv_planet.setOnItemClickListener(adapter2);
        nslv_planet.setOnItemLongClickListener(adapter2);
    }
}

运行App,打开的行星列表界面如下图所示,可见系统自带的列表视图仅仅显示一条行星记录,而自定义的不滚动列表视图把所有行星记录都展示出来了。
在这里插入图片描述

推送消息通知

此节介绍消息通知的推送过程及其具体用法,包括,通知由哪几个部分组成,如何构建并推送通知,如何区分各种通知渠道及其重要性,如何让服务呈现在前台运行,也就是利用通知管理器把服务推送到系统通知栏,以及如何使用悬浮窗技术模拟屏幕顶端的悬浮消息通知。

通知推送Notification

在App的运行过程中,用户想要购买哪件商品,想浏览哪条新闻,通常都由自己主动寻找并打开对应的页面。当然用户不可避免地会漏掉部分有用信息,例如购物车里的某件商品降价了,有如刚刚报道了某条突发新闻,这些很有可能正是用户关注的信息。为了让用户及时收到此类信息,有必要由App主动向用户推送消息通知,以免错过有价值的信息。
在手机屏幕的顶端下拉会弹出通知栏,里面存放的便是App主动推送给用户的提示消息,消息通知的组成内容由Notification类所描述。每条消息通知都有图标、消息标题、消息内容等基本元素,偶尔还有附加文本、进度条、计时器等额外元素,这些元素由通知建造器Notification.Buider所设定。下面是通知建造起的常用方法说明。

  • setSmallIcon:设置应用名称左边的小图标。这是必要方法,否则不会显示通知消息。
  • setLargeIcon:设置通知栏右边的大图标。
  • setContentTitle:设置通知栏的标题文本。
  • setContentText:设置通知栏的内容文本。
  • setSubText:设置通知栏的附加文本,它位于应用名称的右边。
  • setProgress:设置进度条并显示当前进度。进度条位于标题文本与内容文本下方。
  • setUsesChronometer:设置是否显示计时器,计时器位于应用名称右边,它会动态显示从通知被推送到当前的时间间隔,计时器格式为“分钟:秒钟”。
  • setContentIntent:设置通知内容的延迟意图PendingIntent,点击通知时触发该意图。调用PendingIntent的getActivity方法获得延迟意图对象,触发该意图等同于跳转到getActivity设定的互动页面。
  • setDeleteIntent:设置删除通知的延迟意图PendingIntent,滑掉通知时触发该意图。
  • setAutoCancel:设置是否自动清除通知。若为true,则点击通知后,通知会自动消失;若为false,则点击通知后,通知不会消失。
  • build:构建通知。以上参数都设置完毕后,调用该方法返回Notification对象。

需要注意Notification仅仅描述了消息通知的组成内容,实际推送动作还需要通知管理器NotificationManager执行。NotificationManager是系统通知服务的管理工具,要调用getSystemService方法,先从系统服务Context.NOTIFICATION_SERVICE获取通知管理器,再调用管理器对象的消息操作方法。通知管理器的常用方法说明如下。

以发送简单消息为例,它包括消息标题、消息内容、小图标、大图标等基本信息,则对应的通知推送代码示例如下:

// 发送简单的通知消息(包括消息标题和消息内容)
private void sendSimpleNotify(String title, String message) {
    // 发送消息之前要先创建通知渠道,创建代码见MainApplication.java
    // 创建一个跳转到活动页面的意图
    Intent clickIntent = new Intent(this, MainActivity.class);
    // 创建一个用于页面跳转的延迟意图
    PendingIntent contentIntent = PendingIntent.getActivity(this,
            R.string.app_name, clickIntent, PendingIntent.FLAG_MUTABLE);
    // 创建一个通知消息的建造器
    Notification.Builder builder = new Notification.Builder(this);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        // Android 8.0开始必须给每个通知分配对应的渠道
        builder = new Notification.Builder(this, getString(R.string.app_name));
    }
    builder.setContentIntent(contentIntent) // 设置内容的点击意图
            .setAutoCancel(true) // 点击通知栏后是否自动清除该通知
            .setSmallIcon(R.mipmap.ic_launcher) // 设置应用名称左边的小图标
            .setSubText("这里是副本") // 设置通知栏里面的附加说明文本
            // 设置通知栏右边的大图标
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_app))
            .setContentTitle(title) // 设置通知栏里面的标题文本
            .setContentText(message); // 设置通知栏里面的内容文本
    Notification notify = builder.build(); // 根据通知建造器构建一个通知对象
    // 从系统服务中获取通知管理器
    NotificationManager notifyMgr = (NotificationManager)
            getSystemService(Context.NOTIFICATION_SERVICE);
    // 使用通知管理器推送通知,然后在手机的通知栏就会看到该消息
    notifyMgr.notify(R.string.app_name, notify);
}

自从Android8.0(API level 26)开始必须至少在界面打开时要创建一个通知渠道才能显示通知,创建通知渠道示例如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = 
            new NotificationChannel(
            getString(R.string.app_name),
            getString(R.string.app_name),
            NotificationManager.IMPORTANCE_DEFAULT);
            
            NotificationManager notificationManager = 
            this.getSystemService(NotificationManager.class);
            notificationManager.createNotificationChannel(channel);
        }

运行App,在点击发送按钮时触发sendSimpleNotify方法,手机的通知栏马上收到通知推送的简单消息,如下图所示。根据图示的文字标记,即可得知每种消息元素的位置。
在这里插入图片描述
如果消息通知包含计时器与进度条,则需调用消息建造器的setUsesChronometer与setProgress方法,计时消息的通知推送代码示例如下:

// 发送计时的通知消息
private void sendCounterNotify(String title, String message) {
    // 发送消息之前要先创建通知渠道,创建代码见MainApplication.java
    // 创建一个跳转到活动页面的意图
    Intent cancelIntent = new Intent(this, MainActivity.class);
    // 创建一个用于页面跳转的延迟意图
    PendingIntent deleteIntent = PendingIntent.getActivity(this,
            R.string.app_name, cancelIntent, PendingIntent.FLAG_IMMUTABLE);
    // 创建一个通知消息的建造器
    Notification.Builder builder = new Notification.Builder(this);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        // Android 8.0开始必须给每个通知分配对应的渠道
        builder = new Notification.Builder(this, getString(R.string.app_name));
    }
    builder.setDeleteIntent(deleteIntent) // 设置内容的清除意图
            .setSmallIcon(R.mipmap.ic_launcher) // 设置应用名称左边的小图标
            // 设置通知栏右边的大图标
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.ic_app))
            .setProgress(100, 60, false) // 设置进度条及其具体进度
            .setUsesChronometer(true) // 设置是否显示计时器
            .setContentTitle(title) // 设置通知栏里面的标题文本
            .setContentText(message); // 设置通知栏里面的内容文本
    Notification notify = builder.build(); // 根据通知建造器构建一个通知对象
    // 从系统服务中获取通知管理器
    NotificationManager notifyMgr = (NotificationManager)
            getSystemService(Context.NOTIFICATION_SERVICE);
    // 使用通知管理器推送通知,然后在手机的通知栏就会看到该消息
    notifyMgr.notify(R.string.app_name, notify);
}

运行App,在点击发送按钮时触发sendCounterNotify方法,手机通知栏马上收到推送的技术消息,如下图所示。根据图文的文字标记,即可得知计时器的进度条的位置。
在这里插入图片描述

通知渠道NotificationChannel

为了分清消息通知的轻重急缓,从Android 8.0开始新增的了通知渠道,并且必须指定通知渠道才能正常推送消息。一个应用允许拥有多个多个通知渠道,每个通知渠道的重要性各不相同,有的渠道消息在通知栏被折叠成小行,有的渠道消息在通知栏展示完整的大行,有的渠道消息甚至会短暂悬浮于屏幕顶部,有的渠道消息在推送时会震动手机,有的渠道消息在推送时会发出铃声,有的渠道消息则完全静默推送,这些提示差别都有赖于通知渠道的特征设置。如果不考虑定制渠道特性,仅仅弄个默认渠道就去推送消息,那么只需要一下3行代码即可创建默认的通知渠道:

// 从系统服务中获取通知管理器
NotificationManager notifyMgr = (NotificationManager)ctx.getSystemService(Context.NOTIFICATION_SERVICE);
// 创建指定编号、指定名称、指定级别的通知渠道
NotificationChannel channel = new NotificationChannel(channelId, channelName, importance);
// 创建指定的通知渠道
notifyMgr.createNotificationChannel(channel); 

有了通知渠道后,在推送消息之前使用该渠道创建对应的通知建造器,接着就能按照原方式推送消息了。使用通知渠道创建通知建造器的代码示例如下:

// 创建一个通知消息的建造器
Notification.Builder builder = new Notification.Builder(this);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    // Android 8.0开始必须给每个通知分配对应的渠道
    builder = new Notification.Builder(this, mChannelId);
}

当然以上代码没有指定通知渠道的具体特征,消息通知的展示情况与提示方式完全由系统默认。若要个性化定制不同渠道的详细特征,就得单独设置渠道对象的各种特征属性。下面便是NotificationChannel提供的属性设置方法说明。

  • setSound:设置推送通知之时的铃声,若为null并表示静音推送。
  • enableLights:推送消息时是否让呼吸灯闪烁。
  • enableVibration:推送消息时是否让手机震动。
  • setShowBadge:是否在应用图标的右上角展示小红点。
  • setLockscreenVisibility:设置锁屏时候的可见性,可见性的取值说明见下表:
Notification类的通知可见性说明
VISIBILITY_PUBLIC显示所有通知
VISIBILITY_PRIVATE只显示通知标题不显示通知内容
VISIBILITY_SECRET不显示任何通知信息
  • setImportance:设置通知渠道的重要性,其实NotificationChannel的构造方法已经传入了重要性,所以该方法只在变更重要性时调用。重要性的取值说明见下表:
NotificationManager类的通知重要性说明
IMPORTANCE_NONE不重要。此时不显示通知
IMPORTANCE_MIN最小级别。此时通知栏折叠,无提示声音,无锁屏通知
IMPORTANCE_LOW有点重要。此时通知栏展开,无提示声音,有锁屏通知
IMPORTANCE_DEFAULT一般重要。此时通知栏展开,有提示声音,有锁屏通知
IMPORTANCE_HIGH非常重要。此时通知栏展开,有提示声音,有锁屏通知,在屏幕顶部短暂悬浮(有的手机需要在设置页面开启横幅)
IMPORTANCE_MAX最高级别。具体行为同IMPORTANCE_HIGH

特别注意:每个通知渠道一经创建,就不可重复创建,即使创建也是做无用功。因此在创建渠道之前,最好先调用通知管理器的getNotificationChannel方法,判断是否存在该编号的通知渠道,只有不存在的情况才要创建通知渠道。下面是通知渠道的创建代码例子:

// 创建通知渠道。Android 8.0开始必须给每个通知分配对应的渠道
public static void createNotifyChannel(Context ctx, String channelId, String channelName, int importance) {
    // 从系统服务中获取通知管理器
    NotificationManager notifyMgr = (NotificationManager)
            ctx.getSystemService(Context.NOTIFICATION_SERVICE);
    if (notifyMgr.getNotificationChannel(channelId) == null) { // 已经存在指定编号的通知渠道
        // 创建指定编号、指定名称、指定级别的通知渠道
        NotificationChannel channel = new NotificationChannel(channelId, channelName, importance);
        channel.setSound(null, null); // 设置推送通知之时的铃声。null表示静音推送
        channel.enableLights(true); // 通知渠道是否让呼吸灯闪烁
        channel.enableVibration(true); // 通知渠道是否让手机震动
        channel.setShowBadge(true); // 通知渠道是否在应用图标的右上角展示小红点
        // VISIBILITY_PUBLIC显示所有通知信息,VISIBILITY_PRIVATE只显示通知标题不显示通知内容,VISIBILITY_SECRET不显示任何通知信息
        channel.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); // 设置锁屏时候的可见性
        channel.setImportance(importance); // 设置通知渠道的重要性级别
        notifyMgr.createNotificationChannel(channel); // 创建指定的通知渠道
    }
}

尽管通知渠道提供了多种属性设置方法,但真正常用的莫过于重要性这个特征,它的演示代码参见NotifyChannelActivity.java。在测试页面推送重要性的消息外观,下图为IMPORTANCE_MIN最小级别时的通知栏,可见该通知被折叠了,只显示标题不显示消息内容;
在这里插入图片描述
下图为IMPORTANCE_DEFAULT默认重要性时的通知栏,可见该通知正常显示消息标题和消息内容;
在这里插入图片描述
下图为IMPORTANCE_HIGH高重要性时的顶部悬浮通知。
在这里插入图片描述

推送服务到前台

服务没有自己的布局文件,意味着无法直接在页面上展示服务信息,想起了解服务的运行情况,要么通过打印日志观察,要么通过某个页面的静态控件显示运行结果。然而活动页面有自身的生命周期,极有可能发生服务尚在运行但页面早已退出的情况,所以该方式不可靠。为此Android设计了一个让服务在前台运行的机制,也就是在手机的通知栏展示服务的画像,同时允许服务控制自己是否需要在通知栏显示,这类控制操作包括下列两个启停方法:

  • startForeground:把当前服务切换到前台运行,即展示到通知栏。第一个参数表示通知的编号,第二个参数表示Notification对象。
  • stopForeground:停止前台运行,即取消通知栏上的展示。参数为true时表示清除通知,参数为false时表示不清除通知。

注意:从Android 9.0开始,要想在服务中正常调用startForeground方法,还需要修改AndroidManifest.xml,添加如下所示的前台服务权限配置:

<!--  允许前台服务(Android 9.0之后需要)  -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

如果你的前台服务是一个音乐播放器,你还需要在AndroidManifest.xml的对应服务中添加如下前台服务类型android:foregroundServiceType

<service
    android:name=".service.MusicService"
    android:enabled="true"
    android:exported="true"
    android:foregroundServiceType="mediaPlayback" />

当音乐播放器是前台服务时,即使用户离开了播放器页面,手机仍然在后台继续播放音乐,同时还能在通知栏查看播放进度。接下来模拟音乐播放器的前台服务功能。首先创建名为MusicService的影月服务,该服务的通知推送代码示例如下:

// 发送前台通知
private void sendNotify(Context ctx, String song, boolean isPlaying, int progress) {
    String message = String.format("歌曲%s", isPlaying?"正在播放":"暂停播放");
    // 创建一个跳转到活动页面的意图
    Intent intent = new Intent(ctx, MainActivity.class);
    // 创建一个用于页面跳转的延迟意图
    PendingIntent clickIntent = PendingIntent.getActivity(ctx,
            R.string.app_name, intent, PendingIntent.FLAG_IMMUTABLE);
    // 创建一个通知消息的建造器
    Notification.Builder builder = new Notification.Builder(ctx);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        // Android 8.0开始必须给每个通知分配对应的渠道
        builder = new Notification.Builder(ctx, getString(R.string.app_name));
    }
    builder.setContentIntent(clickIntent) // 设置内容的点击意图
            .setSmallIcon(R.drawable.tt_s) // 设置应用名称左边的小图标
            // 设置通知栏右边的大图标
            .setLargeIcon(BitmapFactory.decodeResource(getResources(), R.drawable.tt))
            .setProgress(100, progress, false) // 设置进度条与当前进度
            .setContentTitle(song) // 设置通知栏里面的标题文本
            .setContentText(message); // 设置通知栏里面的内容文本
    Notification notify = builder.build(); // 根据通知建造器构建一个通知对象
    startForeground(2, notify); // 把服务推送到前台的通知栏
}

接着通过活动页面的播放按钮控制音乐服务,不管是开始播放还是暂停播放都调用startService方法,区别在于传给服务的isPlaying参数不同(开始播放传true,暂停播放传false),再由音乐服务根据isPlaying来刷新消息通知。活动页面的播放控制代码如下:

// 创建一个通往音乐服务的意图
Intent intent = new Intent(this, MusicService.class);
intent.putExtra("is_play", isPlaying); // 是否正在播放音乐
intent.putExtra("song", et_song.getText().toString());
btn_send_service.setText(isPlaying?"暂停播放音乐":"开始播放音乐");
startService(intent); // 启动音乐播放服务

运行App,先输入歌曲名称,活动页面如下图所示:
在这里插入图片描述
点击“开始播放音乐”按钮,启动音乐服务并推送到前台,此时通知栏如下图:
在这里插入图片描述
回到活动页面,点击“暂停播放音乐”按钮,音乐服务根据收到的isPlaying更新通知栏,此时通知栏如下图所示:
在这里插入图片描述

仿微信的悬浮通知

每个活动页面都是一个窗口,许多窗口对象需要一个管家来打理,这个管家被称作窗口管理器(WindowManager)。在手机屏幕上新增或删除页面窗口都可以归结为WindowManager的操作,下面是该管理类的常用方法:

  • getDefaultDisplay:获取默认的显示屏信息。通常可用该方法获取屏幕分辨率。
  • addView:往窗口中添加视图,第二个参数为WindowManager.LayoutParams对象。
  • updateViewLayout:更新指定视图的布局参数,第二个参数为WindowManager.LayoutParams对象。
  • removeView:从窗口中移除指定视图。

下面是窗口布局参数WindowManager.LayoutParams的常用参数:

  • alpha:窗口的透明度,取值为0.0~1.0(0.0表示全透明,1.0表示不透明)。
  • gravity:内部视图的对齐方式。取值说明同View类的setGravity方法。
  • xy:分别表示窗口左上角的横坐标和纵坐标。
  • width和height:分别表示窗口的宽度和高度。
  • format:窗口的像素点格式。取值见PixelFormat类中的常量定义,一般取值为PixelFormat.RGBA_8888
  • type:窗口的显示类型,常用的显示类型的取值说明见下表:
WindowManager类的窗口显示类型说明
TYPE_APPLICATION_MEDIA_OVERLAY悬浮窗(覆盖于应用之上)
TYPE_SYSTEM_ALERT系统警告提示,该类型从Android 8.0开始被废弃
TYPE_SYSTEM_ERROR系统错误提示
TYPE_SYSTEM_OVERLAY页面顶层提示
TYPE_SYSTEM_DIALOG系统对话框
TYPE_STATUS_BAR状态栏
TYPE_TOAST短暂提示
  • flags窗口的行为准则,对于悬浮窗来说,一般设置为FLAG_NOT_FOCUSABLE。常用的窗口标志位的取值说明如下表:
WindowManager类的窗口标志位说明
FLAG_NOT_FOCUSABLE不能抢占焦点,即不接受任何按键或按钮事件
FLAG_NOT_TOUCHABLE不接受触摸事件。悬浮窗一般不设置该标志,因为一旦设置该标志就将无法拖动
FLAG_NOT_TOUCH_MODAL当窗口允许获得焦点时(没有设置FLAG_NOT_FOCUSABLE标志),仍然将窗口之外的按键事件发送给后面的窗口处理,否则它将独占所有按键事件,而不管它们是不是发生在窗口范围之内
FLAG_LAYOUT_IN_SCREEN允许窗口占满整个屏幕
FLAG_LAYOUT_NO_LIMITS允许窗口扩展到屏幕之外
FLAG_WATCH_OUTSIDE_TOUCH设置了FLAG_NOT_TOUCH_MODAL标志后,当按键动作发生在窗口之外时,将接收一个MotionEvent.ACTION_OUTSIDE事件

自定义的悬浮窗有点类似于对话框,它们都是独立于活动页面的窗口,但是悬浮窗又有一些与众不同的特性,例如:

  1. 悬浮窗允许拖动,对话框不允许拖动。
  2. 悬浮窗不妨碍用户触摸窗外的区域,对话框不让用户操作窗外的控件。
  3. 悬浮窗独立于活动页面,当页面退出后,悬浮窗仍停留在屏幕上;对话框于活动页面是共存关系,一旦退出页面那么对话框就消失了。

基于悬浮窗的以上特性,若要实现窗口的悬浮效果,就不能仅仅调用WindowManager的addView方法,而要做一系列的自定义处理,具体步骤说明如下:

  1. 在AndroidManifest.xml中声明系统窗口权限,即增加下面这行权限配置:
<!-- 悬浮窗 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
  1. 自定义的悬浮窗控件需要设置触摸监听器,根据用户的手势动作相应调整窗口位置,以实现悬浮窗的拖动功能。
  2. 合理设置悬浮窗的窗口参数,主要是把窗口参数的显示类型设置为FLAG_NOT_FOCUSABLE。另外,还要设置标志位为FLAG_NOT_FOCUSABLE。
  3. 在构造悬浮窗实例时,要传入应用实例Application的上下文对象,这是为了保证即使退出活动页面,也不会关闭悬浮窗。因为应用对象在App运行过程中始终存在,而活动对象只在打开页面时有效;一旦退出页面,那么活动对象的上下文就会立刻被回收(这导致依赖于该上下文的悬浮窗也一块被回收了)。

下面是一个悬浮窗控件的自定义代码片段:

public class FloatWindow extends View {
    private final static String TAG = "FloatWindow";
    private Context mContext; // 声明一个上下文对象
    private WindowManager wm; // 声明一个窗口管理器对象
    private static WindowManager.LayoutParams wmParams; // 悬浮窗的布局参数
    public View mContentView; // 声明一个内容视图对象
    private float mScreenX, mScreenY; // 触摸点在屏幕上的横纵坐标
    private float mLastX, mLastY; // 上次触摸点的横纵坐标
    private float mDownX, mDownY; // 按下点的横纵坐标
    private boolean isShowing = false; // 是否正在显示

    public FloatWindow(Context context) {
        super(context);
        // 从系统服务中获取窗口管理器,后续将通过该管理器添加悬浮窗
        wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        if (wmParams == null) {
            wmParams = new WindowManager.LayoutParams();
        }
        mContext = context;
    }

    // 设置悬浮窗的内容布局
    public void setLayout(int layoutId) {
        // 从指定资源编号的布局文件中获取内容视图对象
        mContentView = LayoutInflater.from(mContext).inflate(layoutId, null);
        // 接管悬浮窗的触摸事件,使之即可随手势拖动,又可处理点击动作
        mContentView.setOnTouchListener((v, event) -> {
            mScreenX = event.getRawX();
            mScreenY = event.getRawY();
            if (event.getAction() == MotionEvent.ACTION_DOWN) { // 手指按下
                mDownX = mScreenX;
                mDownY = mScreenY;
            } else if (event.getAction() == MotionEvent.ACTION_MOVE) { // 手指移动
                updateViewPosition(); // 更新视图的位置
            } else if (event.getAction() == MotionEvent.ACTION_UP) { // 手指松开
                updateViewPosition(); // 更新视图的位置
                if (Math.abs(mScreenX-mDownX)<3 && Math.abs(mScreenY-mDownY)<3) {
                    if (mListener != null) { // 响应悬浮窗的点击事件
                        mListener.onFloatClick(v);
                    }
                }
            }
            mLastX = mScreenX;
            mLastY = mScreenY;
            return true;
        });
    }

    // 更新悬浮窗的视图位置
    private void updateViewPosition() {
        // 此处不能直接转为整型,因为小数部分会被截掉,重复多次后就会造成偏移越来越大
        wmParams.x = Math.round(wmParams.x + mScreenX - mLastX);
        wmParams.y = Math.round(wmParams.y + mScreenY - mLastY);
        wm.updateViewLayout(mContentView, wmParams); // 更新内容视图的布局参数
    }

    // 显示悬浮窗
    public void show(int gravity) {
        if (mContentView != null) {
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
                // 注意TYPE_SYSTEM_ALERT从Android8.0开始被舍弃了
                wmParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
            } else { // 从Android8.0开始悬浮窗要使用TYPE_APPLICATION_OVERLAY
                wmParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
            }
            wmParams.format = PixelFormat.RGBA_8888;
            wmParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
            wmParams.alpha = 1.0f; // 1.0为完全不透明,0.0为完全透明
            wmParams.gravity = gravity; // 指定悬浮窗的对齐方式
            wmParams.x = 0;
            wmParams.y = 0;
            // 设置悬浮窗的宽度和高度为自适应
            wmParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
            wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
            // 添加自定义的窗口布局,然后屏幕上就能看到悬浮窗了
            wm.addView(mContentView, wmParams);
            isShowing = true;
        }
    }

    // 关闭悬浮窗
    public void close() {
        if (mContentView != null) {
            wm.removeView(mContentView); // 移除自定义的窗口布局
            isShowing = false;
        }
    }

    // 判断悬浮窗是否打开
    public boolean isShow() {
        return isShowing;
    }

    private FloatClickListener mListener; // 声明一个悬浮窗的点击监听器对象
    // 设置悬浮窗的点击监听器
    public void setOnFloatListener(FloatClickListener listener) {
        mListener = listener;
    }

    // 定义一个悬浮窗的点击监听器接口,用于触发点击行为
    public interface FloatClickListener {
        void onFloatClick(View v);
    }
}

有了悬浮窗以后,就能很方便地在手机屏幕上弹出动态小窗,例如时钟、天气、实时流量、股市指数等。还有微信的新消息通知,每当好友发了一条新消息,屏幕顶部便弹出微信的悬浮通知栏,点击这个悬浮栏会打开好友的聊天界面。这些类似功能,都能通过悬浮窗控件实现。
悬浮窗的常见操作有打开、关闭、和点击三种,前面定义的悬浮窗控件正好提供了对应的方法,比如调用show方法可以显示悬浮窗,调用close方法可以关闭悬浮窗,调用setOnFloatListener可以设置悬浮窗的点击监听器。下面是在活动页面操作悬浮窗的代码例子:

private static FloatWindow mFloatWindow; // 声明一个悬浮窗对象
// 打开悬浮窗
private void openFloatWindow() {
    if (mFloatWindow == null) {
        // 创建一个新的悬浮窗
        mFloatWindow = new FloatWindow(MainApplication.getInstance());
        // 设置悬浮窗的布局内容
        mFloatWindow.setLayout(R.layout.float_notice);
        tv_content = mFloatWindow.mContentView.findViewById(R.id.tv_content);
        LinearLayout ll_float = mFloatWindow.mContentView.findViewById(R.id.ll_float);
        int margin = Utils.dip2px(this, 5);
        LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) ll_float.getLayoutParams();
        params.width = Utils.getScreenWidth(this) - 2*margin;
        // 在悬浮窗四周留白
        params.setMargins(margin, margin, margin, margin);
        ll_float.setLayoutParams(params);
        // 设置悬浮窗的点击监听器
        mFloatWindow.setOnFloatListener(v -> mFloatWindow.close());
    }
    if (mFloatWindow != null && !mFloatWindow.isShow()) {
        tv_content.setText(et_content.getText());
        mFloatWindow.show(Gravity.LEFT | Gravity.TOP); // 显示悬浮窗
    }
}

// 关闭悬浮窗
private void closeFloatWindow() {
    if (mFloatWindow != null && mFloatWindow.isShow()) {
        mFloatWindow.close(); // 关闭悬浮窗
    }
}

运行App,弹出的悬浮窗显示如下:
在这里插入图片描述
若想实时弹出悬浮窗,需要通过服务Service来实现,此时要改造成在服务中创建并显示悬浮窗。

通过持续绘制实现简单动画

本节介绍如何通过持续绘制实现动画效果:首先阐述Handler的延迟机制以及简单计时器的实现,然后描述刷新视图的两种方式以及它们之间的区别,最后叙述如何结合Handler的延迟机制与视图刷新实现饼图动画。

Handler的延迟机制

活动页面的Java代码通常时串行工作的,而且App界面很快就加载完成容不得半点迟延,不过偶尔也需要某些控件时不时地动一下,好让界面呈现动画效果显得更加活泼。这种简单动画基于视图的延迟处理机制,即间隔若干时间后不断刷新视图界面。这种延迟效果我们可以使用Handler+Runnable组合,调用Handler对象的postDelayed方法,延迟若干时间在执行指定的Runnable任务。
Runnable接口用于声明某项任务,它定义了接下来要做的事情。简单地说,Runnable接口就是一个代码片段。编写任务代码需要实现Runnable接口,此时必须重写接口的run方法,在该方法内部存放待运行的代码逻辑。run方法无须显示调用,因为在启动Runnable实例时就会调用任务对象的run方法。
尽管视图基类View同样提供了post与postDelayed方法,但在实际开发中一般利用处理器Handler启动任务实例。Handler操作任务的常见方法说明如下:

  • post:立即启动指定的任务。参数为Runnable对象。
  • postDelayed:延迟若干时间后启动指定任务。第一个参数为Runnable对象;第二个参数为任务的启动时间点,单位为毫秒。
  • postAtTime:在设定的时间启动指定的任务。第一个参数为Runnable对象;第二个参数为任务的启动时间点,单位为毫秒。
  • removeCallbacks:移除指定的任务。参数Runnable对象。

计时器是Handler+Runnable组合的简单应用,每隔若干时间就刷新当前的计数值,使得界面上的数字持续跳越。下面是一个简单计时器的活动代码例子:

public class HandlerPostActivity extends AppCompatActivity implements View.OnClickListener {
    private Button btn_count; // 声明一个按钮对象
    private TextView tv_result; // 声明一个文本视图对象

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_handler_post);
        btn_count = findViewById(R.id.btn_count);
        tv_result = findViewById(R.id.tv_result);
        btn_count.setOnClickListener(this); // 设置按钮的点击监听器
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_count) {
            if (!isStarted) { // 不在计数,则开始计数
                btn_count.setText("停止计数");
                mHandler.post(mCounter); // 立即启动计数任务
            } else { // 已在计数,则停止计数
                btn_count.setText("开始计数");
                mHandler.removeCallbacks(mCounter); // 立即取消计数任务
            }
            isStarted = !isStarted;
        }
    }

    private boolean isStarted = false; // 是否开始计数
    private Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
    private int mCount = 0; // 计数值
    // 定义一个计数任务
    private Runnable mCounter = new Runnable() {
        @Override
        public void run() {
            mCount++;
            tv_result.setText("当前计数值为:" + mCount);
            mHandler.postDelayed(this, 1000); // 延迟一秒后重复计数任务
        }
    };
}

运行App,观察到计时器的计数效果如下图:
在这里插入图片描述

重新绘制视图界面

控件的内容一旦发生变化,就得通知界面刷新它的外观,例如文本视图修改了文字,图像视图更换了图片等。然而,之前听说TextView提供了setText方法,ImageView提供了setImageBitmap方法,这两个方法调用之后便能直接呈现最新的控件界面,好像并不需要刷新动作。虽然表面上看不出刷新操作,但仔细分析setText和setImageBitmap的源码,会发现它们的内部都调用了invalidate方法,该方法便用来刷新控件界面。只要调用了invalidate方法,系统就会重新执行该控件的onDraw方法和dispatchDraw方法,从而实现重新绘制界面,也就是刷新的功能。
除了invalidate方法,另一种postInvalidate方法也能刷新界面,它们之间的区别主要有下列两点:

  1. invalidate:不是线程安全的,它只保证在主线程(UI线程)中能够正常刷新视图;而postInvalidate是线程安全的,即使在分线程中调用也能正常刷新视图。
  2. invalidate只能立即刷新视图,而post方式还提供了postInvalidateDelayed方法,允许延迟一段时间后再刷新视图。

为了演示invalidate、postInvalidate、postInvalidateDelayed这3种用法,并验证分线程内部的视图刷新情况,下面先定义一个椭圆视图OvalView,每次刷新该视图都将绘制更大角度扇形。椭圆视图的定义代码示例如下:

public class OvalView extends View {
    private Paint mPaint = new Paint(); // 创建一个画笔对象
    private int mDrawingAngle = 0; // 当前绘制的角度

    public OvalView(Context context) {
        this(context, null);
    }

    public OvalView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint.setColor(Color.RED); // 设置画笔的颜色
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mDrawingAngle += 30; // 绘制角度增加30度
        int width = getMeasuredWidth(); // 获得布局的实际宽度
        int height = getMeasuredHeight(); // 获得布局的实际高度
        RectF rectf = new RectF(0, 0, width, height); // 创建扇形的矩形边界
        // 在画布上绘制指定角度的扇形。第四个参数为true表示绘制扇形,为false表示绘制圆弧
        canvas.drawArc(rectf, 0, mDrawingAngle, true, mPaint);
    }
}

接着在演示用的布局文件种加入自定义的椭圆视图节点,具体的OvalView标签代码如下:

<!-- 自定义的椭圆视图,需要使用全路径 -->
<com.example.chapter08.widget.OvalView
    android:id="@+id/ov_validate"
    android:layout_width="match_parent"
    android:layout_height="150dp" />

然后在对应的活动代码中依据不同的选项,分别调用invalidate、postInvalidate、postInvalidateDelayed三个方法之一,加上分线程内部的两个方法调用,总共五种刷新选项。下面是这五种选项的方法调用代码片段:

public void onItemSelected(AdapterView<?> arg0, View arg1, int arg2, long arg3) {
    if (arg2 == 0) {  // 主线程调用invalidate
        ov_validate.invalidate(); // 刷新视图(用于主线程)
    } else if (arg2 == 1) {  // 主线程调用postInvalidate
        ov_validate.postInvalidate(); // 刷新视图(主线程和分线程均可使用)
    } else if (arg2 == 2) {  // 延迟3秒后刷新
        ov_validate.postInvalidateDelayed(3000); // 延迟若干时间后再刷新视图
    } else if (arg2 == 3) {  // 分线程调用invalidate
        // invalidate不是线程安全的,虽然下面代码在分线程中调用invalidate方法也没报错,但在复杂场合可能出错
        new Thread(new Runnable() {
            @Override
            public void run() {
                ov_validate.invalidate(); // 刷新视图(用于主线程)
            }
        }).start();
    } else if (arg2 == 4) {  // 分线程调用postInvalidate
        // postInvalidate是线程安全的,分线程中建议调用postInvalidate方法来刷新视图
        new Thread(new Runnable() {
            @Override
            public void run() {
                ov_validate.postInvalidate(); // 刷新视图(主线程和分线程均可使用)
            }
        }).start();
    }
}

运行App,观察发现,不管是在主线程中调用刷新方法,界面都能正常显示角度渐增的椭圆视图。从实验结果可知,尽管invalidate不是线程安全的方法,但它仍然能够在简单的分线程中刷新视图。不过考虑到实际的业务场景较为复杂,建议还是遵循安卓的开发规范,在主线程中使用invalidate方法刷新视图,在分线程中使用postInvalidate方法刷新视图。
在这里插入图片描述

自定义饼图动画

掌握了Handler的延迟机制,加上视图对象的刷新方法,就能间隔固定时间不断渲染控件界面,从而实现简单的动画效果。接下来通过饼图动画的实现过程,进一步加深对自定义控件技术的熟练运用。自定义饼图动画的具体实现步骤说如下:

  1. 在Java代码的widget目录下创建PieAnimationActivity.java,该类继承了视图基类View,并重写onDraw方法,在onDraw方法中使用画笔对象绘制指定角度的扇形。
  2. 在PieAnimation内部定义一个视图刷新任务,每次刷新操作都新增大一点绘图角度,然后调用invalidate方法刷新视图界面。如果动画尚未播放完毕,就调用处理器对象的postDelayed方法,间隔几十毫秒后重新执行刷新任务。
  3. 给PieAnimation补充一个start方法,用于控制饼图动画的播放操作。start方法内部先初始化绘图角度,再调用处理器对象的post方法立即启动刷新任务。

按照上述3个步骤,编写自定义的饼图动画控件代码示例如下:

public class PieAnimation extends View {
    private Paint mPaint = new Paint(); // 创建一个画笔对象
    private int mDrawingAngle = 0; // 当前绘制的角度
    private Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
    private boolean isRunning = false; // 是否正在播放动画

    public PieAnimation(Context context) {
        this(context, null);
    }

    public PieAnimation(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint.setColor(Color.GREEN); // 设置画笔的颜色
    }

    // 开始播放动画
    public void start() {
        mDrawingAngle = 0; // 绘制角度清零
        isRunning = true;
        mHandler.post(mRefresh); // 立即启动绘图刷新任务
    }

    // 是否正在播放
    public boolean isRunning() {
        return isRunning;
    }

    // 定义一个绘图刷新任务
    private Runnable mRefresh = new Runnable() {
        @Override
        public void run() {
            mDrawingAngle += 3; // 每次绘制时角度增加三度
            if (mDrawingAngle <= 270) { // 未绘制完成,最大绘制到270度
                invalidate(); // 立即刷新视图
                mHandler.postDelayed(this, 70); // 延迟若干时间后再次启动刷新任务
            } else { // 已绘制完成
                isRunning = false;
            }
        }
    };

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (isRunning) { // 正在播放饼图动画
            int width = getMeasuredWidth(); // 获得已测量的宽度
            int height = getMeasuredHeight(); // 获得已测量的高度
            int diameter = Math.min(width, height); // 视图的宽高取较小的那个作为扇形的直径
            // 创建扇形的矩形边界
            RectF rectf = new RectF((width - diameter) / 2, (height - diameter) / 2,
                    (width + diameter) / 2, (height + diameter) / 2);
            // 在画布上绘制指定角度的图形。第四个参数为true绘制扇形,为false绘制圆弧
            canvas.drawArc(rectf, 0, mDrawingAngle, true, mPaint);
        }
    }
}

接着创建演示用的活动页面,在该页面的XML文件中放置新控件PieAnimation,完整的XML文件内容示例如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <!-- 自定义的饼图动画,需要使用全路径 -->
    <com.example.chapter08.widget.PieAnimation
        android:id="@+id/pa_circle"
        android:layout_width="match_parent"
        android:layout_height="350dp" />
</LinearLayout>

然后在该页面的Java代码中获取饼图控件,并调用饼图对象的start方法开始播放动画,相应的活动代码如下:

public class PieAnimationActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_pie_animation);
        // 从布局文件中获取名叫pa_circle的饼图动画
        PieAnimation pa_circle = findViewById(R.id.pa_circle);
        pa_circle.start(); // 开始播放饼图动画
    }
}

最后运行App。观察到饼图动画的播放效果如下:
在这里插入图片描述

工程源码

本文涉及所有代码,可点击工程源码下载。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值