Android 框架里有一系列可以与用户交互及展示数据的View控件。但是,有时候这些组件并不能满足你的app的需求。
下文将对如何创建一个健壮可重用的View控件进行讲解。
1 创建一个View类
一个设计良好的View类应满足以下几个条件:
- 满足android标准
- 提供可在XML文件中操作的风格化(styleable)参数
- 发送可接受的事件
- 与不同的android版本兼容
1.1 继承一个View
所有定义在android框架内的控件类都是View的子类,自定义控件需要继承View类或者直接继承一个已有的类如Button类。
为了使控件与ADT交互,自定义控件的构造函数至少应包含一Context,和AttributeSet作为参数,layout编辑器是依据这个构造函数来创建该控件的。
class PieChart extends View {
public PieChart(Context context, AttributeSet attrs) {
super(context, attrs);
}
}
1.2 自定义属性
为了把一个android原生控件加入你的用户接口,你可以使用XML元素控制控件的外表和行为。同样的,一个良好的自定义控件也应可以通过XML被加入界面及实现控件的风格化。为了满足该条件,自定义控件需要满足一下要求:
- 在一个 declare-stylable 标签中为自定义控件增加属性。
- 在XML布局文件中定义属性值
- 运行时取回属性值
- 将取回的值赋给你的控件
为了定义控件属性,需要在工程中定义 declare-styleable 标签,通常把该标签定义在res/values/attrs.xml文件中。
<resources>
<declare-styleable name="PieChart">
<attr name="showText" format="boolean" />
<attr name="labelPosition" format="enum">
<enum name="left" value="0"/>
<enum name="right" value="1"/>
</attr>
</declare-styleable>
</resources>
这段代码为一个名为PieCHart的控件定义了showtext和labelposition两个属性。该风格化的实体与定义的View类是同名的。虽然并没有严格要求名字的一致,但多数的代码编辑器需要依靠相同的名字来找到来完成代码语句。
定义好参数之后,你就可以在XMl布局文件里像使用原生控件一样使用你的自定义控件。唯一的不同是自定义控件的特有的命名空间。android原生控件的命名空间是在http://schemas.android.com/apk/res/android 自定义控件的命名空间是http://schemas.android.com/apk/res/你的包名。 举个例子
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:custom="http://schemas.android.com/apk/res/com.example.customviews">
<com.example.customviews.charting.PieChart
custom:showText="true"
custom:labelPosition="left" />
</LinearLayout>
为了避免重复写较长的命名空间地址,例子使用了xmlns指令,该指令的作用是为http://schemas.android.com/apk/res/com.example.customviews 这串字符起了个别名——custom。你可以选择自己喜欢的别名赋给自己的控件。
1.3*为自定义属性赋值*
当一个控件在XML布局文件中被创建后,XML标签中的所有属性被resource bundle中读出,以AttributeSet的形式传给控件的构造函数。虽然可以直接把属性从AttributeSet中读出来,但是这么做有如下缺点。
- 属性值中的资源应用无法被解析。
- style属性不起作用
所以,obtainStyledAttributes()这个方法返回的是已经被间接引用和风格化后的TypedArray类型的数组。
而不是直接把AttributeSet返回。
Android资源编译器为了方便我们调用obtainStyledAttributes()做了许多工作。对于res目录下的每个
declare-styleable 标签,自动生成的R.java文件定义了属性id的数组和定义每个属性索引的常数集合。你需要使用预定义的常数来读取TypedArray中的Attribute属性值。下面是例子。
public PieChart(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.getTheme().obtainStyledAttributes(
attrs,
R.styleable.PieChart,
0, 0);
try {
mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0);
} finally {
a.recycle();
}
}
注意:
TypedArray是一个共享对象,使用后需要回收。
1.4 增加属性和事件
属性是控制控件外表和行为的有效方法,但是属性只有在控件初始化的时候才会被读取。为了后续的动态行为,需要为控件的每个属性增加getter和setter方法。下面是例子。
public boolean isShowText() {
return mShowText;
}
public void setShowText(boolean showText) {
mShowText = showText;
invalidate();
requestLayout();
}
setShowText()方法里调用了invalidate()和requestLayout()方法。要保证控件的正常运行调用这些方法是必要的。每次更改可能改变控件形态的属性是都需要这样操作,系统才知道该控件需要重绘。类似的,当你改变了控件的尺寸或者新增相关是属性时,需要重新请求一个layout,否则会引发一些难以查找的bug。
自定义控件也支持与重要事件交互的监听器。比如PieChart公布了一个叫OnCurrentItemChanged()的事件来提醒监听器,用户旋转了饼图,把焦点给了饼图的新一部分。
2 自定义绘制
一个控件最重要的部分就是外表。自定义绘制的复杂与简单根据应用程序的需求不同而不同。下面的讲解包含了常见的操作。
2.1 重写OnDraw()方法
自定义绘制最重要的步骤就是重写onDraw()方法,传给onDraw()方法的是一个Canvas对象,我们用该对象来绘制控件。canvas对象定义了绘制文本,线条,位图及其他一些图表原型的方法。你可以在onDraw()方法中调用这些方法来绘制你的控件UI界面。
2.2 创建Drawing对象
android.graphics中把绘制分为两类:
- Canvas
- Paint
比如Canvas对象提供画线的方法,而Paint对象提供定义线条颜色的方法。简单地说,Canvas提供了在屏幕上绘制你要显示的图形的方法,而Paint则提供更改图形颜色,字体,风格等属性的方法。
所以在绘制之前,需要创建几个Paint对象,下面例子的init()是被PieChart的构造函数调用的。
private void init() {
mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mTextPaint.setColor(mTextColor);
if (mTextHeight == 0) {
mTextHeight = mTextPaint.getTextSize();
} else {
mTextPaint.setTextSize(mTextHeight);
}
mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPiePaint.setStyle(Paint.Style.FILL);
mPiePaint.setTextSize(mTextHeight);
mShadowPaint = new Paint(0);
mShadowPaint.setColor(0xff101010);
mShadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));
...
预先创建绘图工具对象是重要的优化步骤。控件被重绘的频率很高,许多绘图工具对象的初始化资源消耗大。在onDraw()方法中显式创建绘画工具类会使控件的反应变得迟钝。(所以在控件初始化的时候就把paint对象new出来)。
2.3 处理布局事件
为了绘制你的控件,你需要知道控件的尺寸。复杂的控件需要根据它们的尺寸和形状来计算在布局中的尺寸。永远不要凭空想象你的控件在屏幕上应该是多大的尺寸。即使只有一个app使用你的控件,也需要适配不同尺寸不同密度,分辨率的屏幕。
虽然View类有许多处理尺寸的方法,但是大部分都不必重写。如果你不需要对你的控件尺寸进行特殊控制的话,你只需要重写onSizeChanged()这个方法。
当控件的size在第一次被赋值后发生变化后,onSizeChanged()被调用。在该方法中计算位置,尺寸及其他与控件有关的值而不是每次绘制的时候都重新计算这些值。在PieChart实例中,该方法中计算了矩形的边界及文本标签和其他视图元素的相对位置。
当控件的尺寸被重新复制后,布局管理器认为该尺寸已包含内部视图元素的间距(padding),所以我们在计算尺寸的时候要考虑间距。下面是PieChart中重写该函数的例子。
// Account for padding
float xpad = (float)(getPaddingLeft() + getPaddingRight());
float ypad = (float)(getPaddingTop() + getPaddingBottom());
// Account for the label
if (mShowText) xpad += mTextWidth;
float ww = (float)w - xpad;
float hh = (float)h - ypad;
// Figure out how big we can make the pie.
float diameter = Math.min(ww, hh);
如果想要更好地控制控件的布局的参数实现onMeasure()方法。该方法的参数是View.Spec传递父控件对自定义控件的尺寸要求(最大值,最佳值…)。出于优化的目的,这些值以装包的integer形式存储,我们需要使用View.Spec的静态方法来拆包,得到这些数据。
下面是一个onMeasure()的实现实例。该例中,PieChart视图最大化其区域来使它的饼图块和文字标签一样大。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// Try for a width based on our minimum
int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();
int w = resolveSizeAndState(minw, widthMeasureSpec, 1);
// Whatever the width ends up being, ask for a height that would let the pie
// get as big as it can
int minh = MeasureSpec.getSize(w) - (int)mTextWidth + getPaddingBottom() + getPaddingTop();
int h = resolveSizeAndState(MeasureSpec.getSize(w) - (int)mTextWidth, heightMeasureSpec, 0);
setMeasuredDimension(w, h);
}
关于以上代码有三点需要注意:
- 尺寸计算考虑了view内部的元素的间距(padding)
- resolveSizeAndState()方法用来创建不可改变的高度和宽度。该方法通过比较控件的理想尺寸与传入OnMeasure()的尺寸信息,返回一个合适的View.MeasureSpec对象。
- OnMeasure()没有返回值,它调用setMeasuredDimension()来传值。调用该方法是强制的,如果你忘了调用,View会抛出一个运行时异常。
2.4 绘制
在完成对象创建及尺寸测量的代码后,就可以实现onDraw()方法了。每个控件实现不同的onDraw()方法。但是有以下一些通用个操作。
- 绘制文本用drawText()方法。 setTypeface()设置字体。setColor()设置颜色。
- 绘制图形时可用drawRect(), drawOval(), and drawArc()方法。setStyle()设置图形的填充,边界。
- 使用Path类绘制复杂图形。通过为Path对象来添加直线曲线来定义一个图形,然后使用drawPath()绘制图形。然后可用setStyle()来对图形进行与2类似的操作。
- 通过定义LinearGradient 对象可以实现渐变填充。调用setShader()方法可使用你定义的LinearGradient 填充。
- 调用drawBitmap()方法绘制位图。
下面是绘制PieChart的代码,使用了文本,直线,形状。
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// Draw the shadow
canvas.drawOval(
mShadowBounds,
mShadowPaint
);
// Draw the label text
canvas.drawText(mData.get(mCurrentItem).mLabel, mTextX, mTextY, mTextPaint);
// Draw the pie slices
for (int i = 0; i < mData.size(); ++i) {
Item it = mData.get(i);
mPiePaint.setShader(it.mShader);
canvas.drawArc(mBounds,
360 - it.mEndAngle,
it.mEndAngle - it.mStartAngle,
true, mPiePaint);
}
// Draw the pointer
canvas.drawLine(mTextX, mPointerY, mPointerX, mPointerY, mTextPaint);
canvas.drawCircle(mPointerX, mPointerY, mPointerSize, mTextPaint);
}
3. 使控件可交互
绘制UI只是定义一个自定义控件的部分工作。你也需要使控件以你模仿的现实世界的相似操作对用户的输入做出响应。对象要永远像真实的对象那样响应。
For example, images should not immediately pop out of existence and reappear somewhere else, because objects in the real world don’t do that. Instead, images should move from one place to another.
例如,图像不应该迅速消失后在再别处重新出现,因为现实中的对象是不会这样做的。图像应该从一处移动到另一处。
Users also sense subtle behavior or feel in an interface, and react best to subtleties that mimic the real world. For example, when users fling a UI object, they should sense friction at the beginning that delays the motion, and then at the end sense momentum that carries the motion beyond the fling.
用户也会感觉到界面的细微操作,模拟现实世界的响应反应最好。例如,当用户在找一个UI对象的时候,他们应该在延迟操作的时候感到“摩擦力”,最后感觉到抛物之后的动力。
本节主要展示了如何为你的控件加入拟物的效果。
3.1 处理输入手势操作
与多数的UI框架类似,Android支持输入时间模型,用户操作可转化为触发回调函数的输入事件,并且你可以重写这些回调函数来使你的应用更好的响应用户。Android系统中最常见的输入是触摸(touch),该操作会触发onTouchEvent(android.view.MotionEvent),重写如下方法处理该事件。
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
单独的触摸事件并不是很实用。当代触摸界面定义了许多交互手势比如轻拍(tapping),下拉(pulling),上推(pushing),抛掷(fling),缩放(zooming),为了把这些原始的触摸事件转化为手势操作,Android提供了GestureDetector。
通过传入一个实现GestureDetector.OnGestureListener接口可构建一个GestureDetector。
如果你只想处理部分的手势操作,你可以继承GestureDetector.impleOnGestureListener而不是实现GestureDetector.OnGestureListener接口。
下面的例子创建了一个继承GestureDetector.SimpleOnGestureListener重写了onDown(MotionEvent)方法的类。
class mListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(MotionEvent e) {
return true;
}
}
mDetector = new GestureDetector(PieChart.this.getContext(), new mListener());
无论你是否使用GestureDetector.SimpleOnGestureListener你都必须实现onDown()方法并且返回true。
这个步骤是必要的,因为所以的手势操作都需要以onDown()方法的消息来开始。要是你的onDown()方法像GestureDetector.SimpleOnGestureListener 一样返回false,系统将认为你忽略了其他手势和GestureDetector的其他方法。OnGestureListener不会被调用。只有当你真的要忽略整个的手势的时候你才能使onDown()返回false。一但你实现了GestureDetector.OnGestureListener 创建了GestureDetector实例后,你可以使用GeureDetector来传递你在onTouchEvent()中接收到的触摸事件。
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean result = mDetector.onTouchEvent(event);
if (!result) {
if (event.getAction() == MotionEvent.ACTION_UP) {
stopScrolling();
result = true;
}
}
return result;
}
当你传递给onTouchEvent()一个触摸事件的时候,该方法不会把触摸事件视为手势,返回false。之后你可以
使用你自己的手势识别代码。
3.2 定义遵循物理规律手势
手势操作是控制触摸屏设备的强力方法,但是手势有时候会难以记忆,又与直觉相悖,除非它们会产生物理上正确的结果。抛掷操作,用户手势迅速在屏幕移动,然后在将控件举起,就是一个比较好的例子。如果UI控件在抛掷方向移动迅速然后减速,就像用户推一个飞轮旋转那样。
然而,模拟推飞轮的感受并不是很容易。许多物理数学的知识来保证飞轮模型运行正确。幸运的是Android提供了一些模拟类似行为的帮助类,Scroller类就是处理飞轮模型类似风格的基础类。
开始抛掷的时候,调用有起始速率和包含x,y最大值,最小值的fling()方法。你可以使用有GestureDetector提供的计算速率的方法。
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
mScroller.fling(currentX, currentY, velocityX / SCALE, velocityY / SCALE, minX, minY, maxX, maxY);
postInvalidate();
}
注意:虽然被GestureDetector计算的速率在物理上是精确的,许多开发者感觉使用这个速率会使抛掷动画太快。通常把x和y的速率除以4到8范围内的数。
调用filing()方法会为抛掷手势设置物理模型。之后,你需要每隔一段时间久调用Scroller.computeScrollOffset()来更新Scroller。computeScrollOffset()会通过读取现在的时间使用物理模型来计算该时间的x,y坐标来确定位置。调用getCurrX() 和getCurrY() 可以去到这些值。
多数控件会把Scroller对象的x,y位置直接传给scrollTo()。PieChart有些不同,它用当前的y位置来设置图表 的旋转角度。
if (!mScroller.isFinished()) {
mScroller.computeScrollOffset();
setPieRotation(mScroller.getCurrY());
}
Scroller类会为你计算滑动时的位置,但是不会把这些值自动赋给你的控件。合适跟新位置使得你的滚动动画看起来流畅是需要你控制的。有如下两个方法:
- 在调用fling()后调用postInvalidate()强制重绘。该技巧需要你在onDraw()方法中计算滚动位移,并且在每次位移变化的时候调用postInvalidate()方法。
- 设置一个ValueAnimator开为抛掷动画的间隔添加动画。并且添加一个监听器处理由调用addUpdateListener更新的动画。
PieChart实例使用了第二个方法。该技巧设置略繁琐,但可以更好地狱动画系统协调工作,减少不必要的视图更新。缺点是ValueAnimator需要在api11以上的环境中使用。只能在Android3.0以上的系统中使用。
**主要:**ValueAnimator在api11之前是没有的。但你依旧可以在较低的api中使用。只需要在运行时检查api版本,如果低于11就跳过视图动画系统。
mScroller = new Scroller(getContext(), null, true);
mScrollAnimator = ValueAnimator.ofFloat(0,1);
mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
if (!mScroller.isFinished()) {
mScroller.computeScrollOffset();
setPieRotation(mScroller.getCurrY());
} else {
mScrollAnimator.cancel();
onScrollFinished();
}
}
});
3.3 使转换更流畅
用户期待UI可以在不同状态里相互流畅转换。UI元素渐隐渐现,而不是简单的消失和重现。动作流畅开始和结束而不是突然发生。Android3.0后提供的动画更容易地实现流畅的转换。
为了使用动画,因为无论何时属性的改变都会影响控件的外表,所以不要直接改变属性。需要使用ValueAnimator来改变它们。下面的例子改变了当前选中的饼图的部分使得整个饼图旋转这样选中的指针就在被选中的部分中央了。ValueAnimator会在几百毫秒后进行旋转而不是立即赋值使其旋转。
mAutoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0);
mAutoCenterAnimator.setIntValues(targetAngle);
mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION);
mAutoCenterAnimator.start();
如果你想要改变的属性是View的基本属性的话,添加动画会更容易,因为Views有内部类——ViewPropertyAnimator,该类是一个被优化的有许多启动动画的类。例如
animate().rotation(targetAngle).setDuration(ANIM_DURATION).start();
4. 优化控件
在前三个步骤之后,你现在有了一个设计良好可以对不同手势反应在不同状态之间转换的控件,你还需要确保你的控件快速运行。为了避免使用中的UI控件的延迟和阻塞你必须要保证你的动画是持续每秒60帧的运行的。
4.1 减少操作,降低频率
为了使你的控件更有效率,需要消除不必要的经常调用的常规代码。从onDraw()开始,优化该方法会给你最大的回报。在onDraw()方法里不要有任何请求内存的操作,因为任何请求内存的操作可能会导致之后的垃圾回收,这会造成卡顿。初始化时收集对象或是在动画运行之前或之后。绝不要在动画运行时分配对象。
为了使得onDraw()方法更简洁,你要尽可能减少该函数调用。多数情况下,对onDraw()的调用是我们调用invalidate()是引起的,所以减少不必要的invalidate()调用。尽可能调用有四个参数的invalidate()方法而不是没有参数的那个。因为无参数的invalidate()会刷新整个view而有参数的只会刷新view的特定某区域。该方法是绘制调用更加有效,可减少不必要的在有效区域之外的视图刷新操作。
另一个开销较大的操作是在不同的布局间切换。任何时候view调用requestLayout()方法的时候,Android UI系统需要遍历整个视图层次结构,来确认每个空间的大小。如果发现测量冲突,可能需要反复遍历视图层次结构。UI设计人员有时候会创建比较深的嵌套ViewGroup的视图层次来确保UI的正常响应。这些多层的视图结构就会引发问题。所以,尽量使你的视图层层次较浅。
如果你的UI很复杂,你应该考虑实现一个自定义的ViewGroup来生成布局。你的自定义控件可以在控件和子控件的尺寸和形状方面做出一些定义,这样就可以避免遍历子控件来计算尺寸,原生控件不能这样做。PieChart实例展示了如何扩展ViewGroup使其作为一个控件的一部分。PieChart有子控件,但是它从没有计算它们的尺寸。PieChart直接根据它们在布局算法设置了它们的大小。
4.2 使用硬件加速
GPU硬件加速会大幅提升多数应用的表现,但它并不适应于每个应用。Android框架使你可以很好地控制应用程序中硬件加速或非硬件加速的部分。
在Android Developers Guide中可查看如何在application,activity,window层次实现硬件加速。为了使你的程序支持硬件加速,先在 AndroidManifest.xml文件中进行如下设置。
<uses-sdk android:targetSdkVersion="11"/>
使用硬件加速后,可能看到了效果,也可能没有。手机的GPU擅长处理某些工作,比如放大,旋转,位图转换。不擅长某些工作,如画直线,画曲线。为了更有效地使用硬件加速,你需要把GPU擅长的工作最大化,将它不擅长的工作最小化。
在PieChart实例中,画出饼图是很耗资源的。每次重绘饼图的时候的旋转会使UI卡顿。解决方案就是吧饼图放在一个子视图中,并将该视图的层属性(layer type)设为LAYER_TYPE_HARDWARE,这样GPU会将其缓存为静态图。下面的例子在PieChart的内部定义了一个内部类作为子视图。这样写可以减少实现该方案的代码的改变。
private class PieView extends View {
public PieView(Context context) {
super(context);
if (!isInEditMode()) {
setLayerType(View.LAYER_TYPE_HARDWARE, null);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (Item it : mData) {
mPiePaint.setShader(it.mShader);
canvas.drawArc(mBounds,
360 - it.mEndAngle,
it.mEndAngle - it.mStartAngle,
true, mPiePaint);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
mBounds = new RectF(0, 0, w, h);
}
RectF mBounds;
}
在改变了代码之后,只有视图第一次出现的时候才会调用PieChart.PieView.onDraw()方法。在应用程序的其他生命周期里,饼图被缓存成了一个图像,之后又GPU在不同角度重绘。GPU非常擅长做这类事情,带来的性能提升也会是显而易见的。
但这也是个折衷的方案,硬件层缓存图像会消耗有限资源video memory。基于此原因,最终版本的PieChart值是在滚动时使用了硬件加速。其他时间都将其属性设为了LAYER_TYPE_NONE使GPU停止缓存图像。
最后,不要忘了规范化你的代码。在一个控件上提升性能的某些操作可能会对另一个起反作用。