本文整理自: Google 官方文档之自定义 View,笔者省略了对自己帮助不大的章节,拜读原文请点链接。
一、继承一个View
Android Framework 里面定义的 View 类都继承自 View 。你自定义的 View 也可以直接继承 View,或者你可以通过继承既有的一个子类(例如 Button )来节约一点时间。
为了让 Android Developer Tools 能够识别你的 View,你必须至少提供一个 constructor,它包含一个 Context 与一个 AttributeSet 对象作为参数。这个 constructor 允许 layout editor 创建并编辑你的 View 的实例。
class PieChart extends View {
public PieChart(Context context, AttributeSet attrs) {
super(context, attrs);
}
}
1.定义自定义属性
为了添加一个内置的 View 到你的 UI 上,你需要通过 XML 属性来指定它的样式与行为。良好的自定义 Views 可以通过 XML 添加和改变样式,为了让你的自定义的 View 也有如此的行为,你应该:
- 为你的 View 在资源标签下定义自设的属性
- 在你的 XML layout 中指定属性值
- 在运行时获取属性值
- 把获取到的属性值应用在你的 View 上
为了定义自设的属性,添加 资源到你的项目中。放置于 res/values/attrs.xml 文件中。下面是一个 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>
上面的代码声明了 2 个自设的属性,showText 与 labelPosition,它们都归属于 PieChart 的项目下的 styleable 实例。styleable 实例的名字,通常与自定义的 View 名字一致。尽管这并没有严格规定要遵守这个 convention,但是许多流行的代码编辑器都依靠这个命名规则来提供 statement completion。
一旦你定义了自设的属性,你可以在 layout XML 文件中使用它们,就像内置属性一样。唯一不同的是你自设的属性是归属于不同的命名空间。不是属于「 http://schemas.android.com/apk/res/android 」 的命名空间,它们归属于「 http://schemas.android.com/apk/res/你的包名 」。例如,下面演示了如何为 PieChart 使用上面定义的属性:
<?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>
为了避免输入长串的 namespace 名字,示例上面使用了 xmlns 指令,这个指令可以指派 custom 作为「http://schemas.android.com/apk/res/com.example.customviewsnamespace 」的别名。你也可以选择其他的别名作为你的 namespace。请注意,如果你的 View 是一个 inner class,你必须指定这个 View 的 outer class。同样的,如果 PieChart 有一个 inner class 叫做 PieView 。为了使用这个类中自设的属性,你应该使用 com.example.customviews.charting.PieChart$PieView.
2.应用自定义属性
当 View 从 XML layout 被创建的时候,在 xml 标签下的属性值都是从 resource 下读取出来并传递到 View 的 constructor 作为一个 AttributeSet 参数。尽管可以从 AttributeSet 中直接读取数值,可是这样做有些弊端:
- 拥有属性的资源并没有经过解析
- Styles 并没有运用上
翻译注:通过 attrs 的方法是可以直接获取到属性值的,但是不能确定值类型,如:
String title = attrs.getAttributeValue(null, "title");
int resId = attrs.getAttributeResourceValue(null, "title", 0);
title = context.getText(resId));
都能获取到 "title" 属性,但你不知道值是字符串还是 resId,处理起来就容易出问题,下面的方法则能在编译时就发现问题
取而代之的是,通过 obtainStyledAttributes() 来获取属性值。这个方法会传递一个 TypedArray 对象,它是间接 referenced 并且 styled 的。
Android资源编译器帮你做了许多工作来使调用 [obtainStyledAttributes()](http://developer.android.com/reference/android/content/res/Resources.Theme.html#obtainStyledAttributes(android.util.AttributeSet, int[], int, int)) 更简单。对 res 目录里的每一个 <declare-styleable>
资源,自动生成的 R.java 文件定义了存放属性 ID 的数组和常量,常量用来索引数组中每个属性。你可以使用这些预先定义的常量来从
TypedArray 中读取属性。这里就是 PieChart 类如何读取它的属性:
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 对象是一个共享资源,必须被在使用后进行回收。
3.添加属性和事件
Attributes 是一个强大的控制view的行为与外观的方法,但是他们仅仅能够在 View 被初始化的时候被读取到。为了提供一个动态的行为,需要暴露出一些合适的 getter 与 setter 方法。下面的代码演示了如何使用这个技巧:
public boolean isShowText() {
return mShowText;
}
public void setShowText(boolean showText) {
mShowText = showText;
invalidate();
requestLayout();
}
请注意,在 setShowText 方法里面有调用 invalidate()
and requestLayout()。 这两个调用是确保稳定运行的关键。当 View 的某些内容发生变化的时候,需要调用 invalidate 来通知系统对这个 View 进行 redraw,当某些元素变化会引起组件大小变化时,需要调用 requestLayout 方法。调用时若忘了这两个方法,将会导致 hard-to-find bugs。
自定义的 View 也需要能够支持响应事件的监听器。例如,PieChart 暴露了一个自定义的事件 OnCurrentItemChanged 来通知监听器,用户已经切换了焦点到一个新的组件上。我们很容易忘记了暴露属性与事件,特别是当你是这个 View 的唯一用户时。请花费一些时间来仔细定义你的
View 的交互。一个好的规则是总是暴露任何属性与事件。
二、实现自定义 View 的绘制
重绘一个自定义的 View 的最重要的步骤是重写 onDraw() 方法。onDraw() 的参数是一个 Canvas 对象。Canvas 类定义了绘制文本,线条,图像与许多其他图形的方法。你可以在 onDraw 方法里面使用那些方法来创建你的 UI。
在你调用任何绘制方法之前,你需要创建一个 Paint 对象。
1.创建绘图对象
android.graphics framework 把绘制定义为下面两类:
- 绘制什么,由 Canvas 处理
- 如何绘制,由 Paint 处理
例如 Canvas 提供绘制一条直线的方法,Paint 提供直线颜色。Canvas 提供绘制矩形的方法,Paint 定义是否使用颜色填充。简单来说:Canvas 定义你在屏幕上画的图形,而 Paint 定义颜色,样式,字体,所以在绘制之前,你需要创建一个或者多个 Paint 对象。在这个 PieChart 的例子,是在 init() 方法实现的,由 constructor 调用。
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));
...
刚开始就创建对象是一个重要的优化技巧。Views 会被频繁的重新绘制,初始化许多绘制对象需要花费昂贵的代价。在 onDraw 方法里面创建绘制对象会严重影响到性能并使得你的 UI 显得卡顿。
2.处理布局事件
为了正确的绘制你的 View,你需要知道 View 的大小。复杂的自定义 View 通常需要根据在屏幕上的大小与形状执行多次 layout 计算。而不是假设这个 View 在屏幕上的显示大小。即使只有一个程序会使用你的 View,仍然是需要处理屏幕大小不同,密度不同,方向不同所带来的影响。
尽管 View 有许多方法是用来计算大小的,但是大多数是不需要重写的。如果你的 View 不需要特别的控制它的大小,唯一需要重写的方法是[ onSizeChanged() ](http://developer.android.com/reference/android/view/View.html#onSizeChanged(int, int, int, int)),当你的 View 第一次被赋予一个大小时,或者你的 View 大小被更改时会被执行。在 onSizeChanged 方法里面计算位置,间距等其他与你的 View 大小值。
当你的 View 被设置大小时,layout manager (布局管理器)假定这个大小包括所有的 View 的内边距 (padding) 。当你计算你的 View 大小时,你必须处理内边距的值。这段 PieChart.onSizeChanged() 中的代码演示该怎么做:
// 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);
如果你想更加精确的控制你的 View 的大小,需要重写[ onMeasure() ](http://developer.android.com/reference/android/view/View.html#onMeasure(int, int))方法。这个方法的参数是 View.MeasureSpec,它会告诉你的 View 的父控件的大小。那些值被包装成 int 类型,你可以使用静态方法来获取其中的信息。
这里是一个实现 onMeasure() 的例子。在这个例子中 PieChart 试着使它的区域足够大,使pie可以像它的 label 一样大:
@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 考虑进去。这个在后面会提到,这部分是 View 所控制的。
- 帮助方法 resolveSizeAndState() 是用来创建最终的宽高值的。这个方法比较 View 的期望值与传递给 onMeasure 方法的 spec 值,然后返回一个合适的 View.MeasureSpec 值。
- onMeasure() 没有返回值。它通过调用 setMeasuredDimension() 来获取结果。调用这个方法是强制执行的,如果你遗漏了这个方法,会出现运行时异常。
3.绘图
每个 View 的 onDraw 都是不同的,但是有下面一些常见的操作:
- 绘制文字使用 drawText() 。指定字体通过调用 setTypeface() ,通过 setColor() 来设置文字颜色.
- 绘制基本图形使用 drawRect() , drawOval() , drawArc() . 通过 setStyle() 来指定形状是否需要 filled, outlined.
- 绘制一些复杂的图形,使用 Path 类. 通过给 Path 对象添加直线与曲线, 然后使用 drawPath() 来绘制图形. 和基本图形一样,paths 也可以通过
setStyle 来设置是outlined, filled, both. - 通过创建 LinearGradient 对象来定义渐变。调用 setShader() 来使用 LinearGradient。
- 通过使用 drawBitmap 来绘制图片.
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);
}
三、使得 View 可交互
绘制 UI 仅仅是创建自定义 View 的一部分。你还需要使得你的 View 能够以模拟现实世界的方式来进行反馈。对象应该总是与现实情景能够保持一致。例如,图片不应该突然消失又从另外一个地方出现,因为在现实世界里面不会发生那样的事情。正确的应该是,图片从一个地方移动到另外一个地方。
用户应该可以感受到 UI 上的微小变化,并对模仿现实世界的细微之处反应强烈。例如,当用户 fling (迅速滑动)一个对象时,应该在开始时感到摩擦带来的阻力,在结束时感到 fling 带动的动力。应该在滑动开始与结束的时候给用户一定的反馈。
1.处理输入的手势
像许多其他 UI 框架一样,Android 提供一个输入事件模型。用户的动作会转换成触发一些回调函数的事件,你可以重写这些回调方法来定制你的程序应该如何响应用户的输入事件。在 Android 中最常用的输入事件是 touch,它会触发 onTouchEvent(android.view.MotionEvent) 的回调。重写这个方法来处理 touch 事件:
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
Touch 事件本身并不是特别有用。如今的 touch UI 定义了 touch 事件之间的相互作用,叫做 gestures 。例如 tapping,pulling,flinging 与
zooming 。为了把那些 touch 的源事件转换成 gestures, Android 提供了 GestureDetector 。
通过传入 GestureDetector.OnGestureListener 的一个实例构建一个 GestureDetector 。如果你只是想要处理几种 gestures (手势操作)你可以继承 GestureDetector.SimpleOnGestureListener ,而不用实现 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 。这一步是必须的,因为所有的
gestures 都是从 onDown() 开始的。如果你在 onDown() 里面返回 false,系统会认为你想要忽略后续的 gesture,那么GestureDetector.OnGestureListener 的其他回调方法就不会被执行到了。一旦你实现了 GestureDetector.OnGestureListener 并且创建了GestureDetector 的实例, 你可以使用你的 GestureDetector 来中止你在 onTouchEven t里面收到的 touch 事件。
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean result = mDetector.onTouchEvent(event);
if (!result) {
if (event.getAction() == MotionEvent.ACTION_UP) {
stopScrolling();
result = true;
}
}
return result;
}
当你传递一个 touch 事件到 onTouchEvent() 时,若这个事件没有被辨认出是何种 gesture,它会返回 false 。你可以执行自定义的 gesture-decection 代码。
2.创建基本合理的物理运动
Gestures 是控制触摸设备的一种强有力的方式,但是除非你能够产出一个合理的触摸反馈,否则将是违反用户直觉的。一个很好的例子是 fling 手势,用户迅速的在屏幕上移动手指然后抬手离开屏幕。这个手势应该使得 UI 迅速的按照 fling 的方向进行滑动,然后慢慢停下来,就像是用户旋转一个飞轮一样。
但是模拟这个飞轮的感觉并不简单,要想得到正确的飞轮模型,需要大量的物理,数学知识。幸运的是,Android 有提供帮助类来模拟这些物理行为。 Scroller 是控制飞轮式的 fling 的基类。
要启动一个 fling,需调用 fling(),并传入启动速率 x 、y 的最小值和最大值,对于启动速度值,可以使用 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();
}
Note: 尽管速率是通过 GestureDetector 来计算的,许多开发者感觉使用这个值使得 fling 动画太快。通常把 x 与 y 设置为 4 到 8 倍的关系。
调用[ fling() ](http://developer.android.com/reference/android/widget/Scroller.html#fling(int, int, int, int, int, int, int, int))时会为 fling 手势设置物理模型。然后,通过调用定期调用 Scroller.computeScrollOffset() 来更新 Scroller 。 computeScrollOffset() 通过读取当前时间和使用物理模型来计算 x 和 y 的位置更新 Scroller 对象的内部状态。调用 getCurrX() 和 getCurrY() 来获取这些值。
大多数 View 通过 Scroller 对象的 x , y 的位置直接到[ scrollTo() ](http://developer.android.com/reference/android/view/View.html#scrollTo(int, int)),PieChart 例子稍有不同,它使用当前滚动 y 的位置设置图表的旋转角度。
if (!mScroller.isFinished()) {
mScroller.computeScrollOffset();
setPieRotation(mScroller.getCurrY());
}
Scroller 类会为你计算滚动位置,但是他不会自动把哪些位置运用到你的View 上面。你有责任确保 View 获取并运用到新的坐标。你有两种方法来实现这件事情:
- 在调用 fling() 之后执行 postInvalidate(),这是为了确保能强制进行重画。这个技术需要每次在 onDraw 里面计算过 scroll offsets (滚动偏移量)之后调用 postInvalidate()。
- 使用 ValueAnimator 在 fling 是展现动画,并且通过调用addUpdateListener() 增加对 fling 过程的监听。
这个 PieChart 的例子使用了第二种方法。这个方法使用起来会稍微复杂一点,但是它更有效率并且避免了不必要的重画的 View 进行重绘。缺点是 ValueAnimator 是从API Level 11 才有的。因此他不能运用到 3.0 的系统之前的版本上。
Note: ValueAnimator 虽然是 API 11 才有的,但是你还是可以在最低版本低于 3.0 的系统上使用它,做法是在运行时判断当前的 API Level,如果低于 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.使过渡平滑
用户期待一个 UI 之间的切换是能够平滑过渡的。UI 元素需要做到渐入淡出来取代突然出现与消失。Android 从 3.0 开始有提供 property animation framework ,用来使得平滑过渡变得更加容易。
使用这套动画系统时,任何时候属性的改变都会影响到你的视图,所以不要直接改变属性的值。而是使用 ValueAnimator 来实现改变。在下面的例子中,在 PieChart 中更改选择的部分将导致整个图表的旋转,以至选择的进入选择区内。ValueAnimator 在数百毫秒内改变旋转量,而不是突然地设置新的旋转值。
mAutoCenterAnimator = ObjectAnimator.ofInt(PieChart.this, "PieRotation", 0);
mAutoCenterAnimator.setIntValues(targetAngle);
mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION);
mAutoCenterAnimator.start();
如果你想改变的是 View 的某些基础属性,你可以使用 ViewPropertyAnimator ,它能够同时执行多个属性的动画。
animate().rotation(targetAngle).setDuration(ANIM_DURATION).start();
四、优化自定义 View
1.Do Less, Less Frequently
为了加速你的 View,对于频繁调用的方法,需要尽量减少不必要的代码。先从 onDraw 开始,需要特别注意不应该在这里做内存分配的事情,因为它会导致 GC,从而导致卡顿。在初始化或者动画间隙期间做分配内存的动作。不要在动画正在执行的时候做内存分配的事情。
你还需要尽可能的减少 onDraw 被调用的次数,大多数时候导致 onDraw 都是因为调用了 invalidate()。因此请尽量减少调用 invaildate() 的次数。如果可能的话,尽量调用含有4个参数的 invalidate() 方法而不是没有参数的 invalidate()。没有参数的 invalidate 会强制重绘整个 View 。
另外一个非常耗时的操作是请求layout。任何时候执行 requestLayout(),会使得 Android UI 系统去遍历整个 View 的层级来计算出每一个 View 的大小。如果找到有冲突的值,它会需要重新计算好几次。另外需要尽量保持 View 的层级是扁平化的,这样对提高效率很有帮助。
如果你有一个复杂的 UI,你应该考虑写一个自定义的 ViewGroup 来执行他的 layout 操作。与内置的 View 不同,自定义的 View 可以使得程序仅仅测量这一部分,这避免了遍历整个view的层级结构来计算大小。这个 PieChart 例子展示了如何继承 ViewGroup 作为自定义 View 的一部分。PieChart 有子 views,但是它从来不测量它们。而是根据他自身的 layout 法则,直接设置它们的大小。
2.使用硬件加速
从 Android 3.0 开始,Android 的 2D 图像系统可以通过 GPU (Graphics Processing Unit)) 来加速。GPU 硬件加速可以提高许多程序的性能。但是这并不是说它适合所有的程序。Android Framework 让你能过随意控制你的程序的各个部分是否启用硬件加速。
参考 Android Developers Guide 中的 Hardware Acceleration 来学习如何在 application,activity,或 window 层启用加速。注意除了 Android Guide 的指导之外,你必须要设置你的应用的 target API 为 11,或更高,通过在你的 AndroidManifest.xml 文件中增加 < uses-sdk android:targetSdkVersion="11"/> 。
一旦你开启了硬件加速,性能的提示并不一定可以明显察觉到。移动设备的 GPU 在某些例如 scaling,rotating 与 translating 的操作中表现良好。但是对其他一些任务,比如画直线或曲线,则表现不佳。为了充分发挥 GPU 加速,你应该最大化 GPU 擅长的操作的数量,最小化 GPU 不擅长操作的数量。
在下面的例子中,绘制 pie 是相对来说比较费时的。解决方案是把 pie 放到一个子 View 中,并设置 View 使用 LAYER_TYPE_HARDWARE 来进行加速。
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() 只会在第一次现实的时候被调用。之后,pie chart 会被缓存为一张图片,并通过 GPU 来进行重画不同的角度。GPU 特别擅长这类的事情,并且表现效果突出。
缓存图片到 hardware layer 会消耗 video memory,而 video memory 又是有限的。基于这样的考虑,仅仅在用户触发 scrolling 的时候使用LAYER_TYPE_HARDWARE,在其他时候,使用 LAYER_TYPE_NONE。