前面两篇讲解了自定义TextView,这篇将以之前的为基础,自定义View来仿QQ运动进度效果,如效果图显示。画一个缺的圆再内部嵌套一个缺的圆,然后将自定义TextView放在图形中间显示。
1. 仿QQ运动进度效果思路
思路:先画一个外圈表示最多能有多少步,线有宽度,填充为透明,且从135°画到270°即可。再画一个内圈表示当前走了多少步,与外圈同理,因为内圈是后画的,刚好可以覆盖在外圈上。最后加上步数文字即可。
// 1 分析效果
// 2 确定自定义属性,编写attrs.xml
// 3 在布局中使用
// 4 在自定义View中获取自定义属性
// 5 onMeasure
// 6 画外圆弧、圆弧、文字
// 6.1 画外圆弧
// 6.2 画内圆弧
// 6.3 画文本
// 7 其他
2. 仿QQ运动进度效果实现
2.1 自定义View
这里的自定义View与之前自定义的TextView方式一样。
自定义属性attrs:
<!-- /CustomView/View2/app/src/main/res/values/attrs.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="QQStepView">
<attr name="outerColor" format="color"/>
<attr name="innerColor" format="color"/>
<attr name="borderWidget" format="dimension"/>
<attr name="stepText" format="string"/>
<attr name="stepTextSize" format="dimension"/>
<attr name="stepTextColor" format="color"/>
</declare-styleable>
</resources>
新建QQStepView继承View,重写他的三个构造方法:
// /CustomView/View2/app/src/main/java/com/example/view2/QQStepView.java
public class QQStepView extends View {
private Paint mOuterPaint; // 总进度条画笔
private int mOuterColor = Color.RED; // 总进度条颜色,并设置默认
private int mMaxStep = 2400; // 最大步数
private int mStartAngle = 135; // 弧度开始角度
private int mEndAngle = 270; // 弧度结束角度
private Paint mInnerPaint; // 已走画笔
private int mInnerColor = Color.BLUE; // 已走颜色,并设置默认
private int mStep = 1000; // 设置已走步数
private float mBorderWidget = 20; // 默认圆弧宽度
private Paint mTextPaint; // 文字画笔
private String mStepText; // 已走步数
private int mTextColor = Color.GREEN; // 字体颜色
private int mTextSize = 20; // 默认字体大小
private Rect mTextBounds;
// 不管调用哪个构造方法,都能进入第三个构造方法
public QQStepView(Context context) {
this(context, null);
}
public QQStepView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public QQStepView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// 1 分析效果
// 2 确定自定义属性,编写attrs.xml
// 3 在布局中使用
// 4 在自定义View中获取自定义属性
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.QQStepView);
mStepText = ta.getString(R.styleable.QQStepView_stepText);
mOuterColor = ta.getColor(R.styleable.QQStepView_outerColor, mOuterColor);
mInnerColor = ta.getColor(R.styleable.QQStepView_innerColor, mInnerColor);
mBorderWidget = ta.getDimension(R.styleable.QQStepView_borderWidget, mBorderWidget);
mTextSize = ta.getDimensionPixelSize(R.styleable.QQStepView_stepTextSize, sp2px(mTextSize));
mTextColor = ta.getColor(R.styleable.QQStepView_stepTextColor, mTextColor);
ta.recycle(); // 回收
mOuterPaint = new Paint(); // 创建总画笔
mOuterPaint.setAntiAlias(true); // 取消锯齿
mOuterPaint.setColor(mOuterColor); // 设置颜色
mOuterPaint.setStrokeWidth(mBorderWidget); // 设置圆弧宽度
mOuterPaint.setStyle(Paint.Style.STROKE); // 设置圆弧Style
mOuterPaint.setStrokeCap(Paint.Cap.ROUND); // 设置起始,结束圆角
mInnerPaint = new Paint(); // 创建进度画笔
mOuterPaint.setAntiAlias(true); // 抗锯齿
mInnerPaint.setColor(mInnerColor); // 设置颜色
mInnerPaint.setStrokeWidth(mBorderWidget); // 设置圆弧宽度
mInnerPaint.setStyle(Paint.Style.STROKE); // 设置圆弧Style
mInnerPaint.setStrokeCap(Paint.Cap.ROUND); // 设置起始,结束圆角
mTextPaint = new Paint(); // 文字画笔
mTextPaint.setAntiAlias(true); // 抗锯齿
mTextPaint.setTextSize(mTextSize);
mTextPaint.setColor(mTextColor);
mTextBounds = new Rect();
}
/**
* 将sp值转换为px值,保证文字大小不变
*/
public int sp2px(int spValue) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, spValue, getResources().getDisplayMetrics());
}
}
在activity_main中使用自定义的QQStepView:
<com.example.view2.QQStepView
android:id="@+id/step_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/red"
app:borderWidget="10dp"
app:innerColor="@color/colorAccent"
app:outerColor="@color/colorPrimary" />
2.2 onMeasure
// /CustomView/View2/app/src/main/java/com/example/view2/QQStepView.java
// 5 onMeasure
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 在布局文件中,可能是wrap_content ,也可能宽高不一致
// 获取宽高的模式
int widgetMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
// 获取宽高的大小
int widgetSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int min_Size = Math.min(widgetSize, heightSize);
if(widgetMode == MeasureSpec.AT_MOST || heightMode == MeasureSpec.AT_MOST){
min_Size = Math.min(min_Size, sp2px(200));
}
// 高度不一致,取最小值,确保是个正方形
setMeasuredDimension(min_Size, min_Size);
}
如果不设置为正方形,那么可能就会变成这样:
2.3 onDraw
// /CustomView/View2/app/src/main/java/com/example/view2/QQStepView.java
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 6.1 画外圆弧
// 问题:边缘显示不全 描边有宽度 mBorderWidget
float half = mBorderWidget / 2;
// 描边有宽度borderWidget,如果不重新算rectF,绘制就会显示不全。这里将整个图多绘制半个宽度后,就刚刚好了。
@SuppressLint("DrawAllocation") RectF rectF = new RectF(half, half, getWidth() - half, getHeight() - half);
canvas.drawArc(rectF, mStartAngle, mEndAngle, false, mOuterPaint); // 这里如果是True的话,下面就是闭合的
// 6.2 画内圆弧
if (mMaxStep == 0) return;
float sweepAngle = (float) mStep / mMaxStep;
canvas.drawArc(rectF, mStartAngle, sweepAngle * mEndAngle, false, mInnerPaint);
// 6.3 画文本
mStepText = mStep + "";
// 计算的宽度 与 字体的长度有关 与字体的大小 用画笔来测量
// 获取文字排版信息
Paint.FontMetricsInt fontMetricsInt = mTextPaint.getFontMetricsInt();
int baseLine = getHeight() / 2 + (fontMetricsInt.bottom - fontMetricsInt.top) / 2 - fontMetricsInt.bottom; // 计算基线
// 获取文本宽度
float textWidget = mTextPaint.measureText(mStepText);
// // 使用mTextPaint对象的样式设置来测量mStepText的边界,并将边界存储在mTextBounds中。可使用mTextBounds对象来获取文本的宽度、高度等信息
// mTextPaint.getTextBounds(mStepText, 0, mStepText.length(), mTextBounds);
// float textWidget2 = mTextBounds.width();
int dx = (int) (getWidth() / 2 - textWidget / 2); // 控件的一半减去文字的一半,算出文字的初始绘制值
canvas.drawText(mStepText, dx, baseLine, mTextPaint);
}
3. 自定义View的动画-监听
// /CustomView/View2/app/src/main/java/com/example/view2/QQStepView.java
// 7 其他
public synchronized void setMaxStep(int maxStep) {
this.mMaxStep = maxStep;
}
public synchronized void setStep(int step) {
this.mStep = step;
//不断绘制 invalidate中会调用onDraw方法
invalidate();
}
public synchronized void setAnimatorStep(int step) {
// 属性动画
ValueAnimator valueAnimator = ObjectAnimator.ofFloat(0,step); // 从0变化到step
valueAnimator.setDuration(1000); // 设置动画一秒
valueAnimator.setInterpolator(new DecelerateInterpolator()); // 设置差时器,让其先快后慢,效果更明显
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(@NonNull ValueAnimator animation) {
float currentStep = (float) animation.getAnimatedValue();
setStep((int) currentStep);
}
});
valueAnimator.start();
}
// /CustomView/View2/app/src/main/java/com/example/view2/MainActivity.java
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
final QQStepView qqStepView = (QQStepView) findViewById(R.id.step_view);
qqStepView.setMaxStep(4000); // 设置最大值为4000
qqStepView.setAnimatorStep(3000); // 设置当前步数,并动画从0-3000
}
4. invalidate()源码分析
// /CustomView/View2/app/src/main/java/com/example/view2/QQStepView.java
public synchronized void setStep(int step) {
this.mStep = step;
//不断绘制
invalidate();
}
-------->
// /Android/Sdk/sources/android-33/android/view/View.java
public void invalidate() {
invalidate(true);
}
-------->
public void invalidate(boolean invalidateCache) {
invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
}
-------->
void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,
boolean fullInvalidate) {
...
p.invalidateChild(this, damage); // Parent父类
-------->
// /Android/Sdk/sources/android-33/android/view/ViewParent.java
@Deprecated
public void invalidateChild(View child, Rect r);
-------->
// /Android/Sdk/sources/android-33/android/view/ViewGroup.java
@Override
public final void invalidateChild(View child, final Rect dirty) {
do {
...
// 通过不断地do-while循环,不停地调用parent,
// 最后调到最外层View(ViewRootImpl)的invalidateChildInParent方法
parent = parent.invalidateChildInParent(location, dirty);
if (view != null) {
// Account for transform on current parent
Matrix m = view.getMatrix();
if (!m.isIdentity()) {
RectF boundingRect = attachInfo.mTmpTransformRect;
boundingRect.set(dirty);
m.mapRect(boundingRect);
dirty.set((int) Math.floor(boundingRect.left),
(int) Math.floor(boundingRect.top),
(int) Math.ceil(boundingRect.right),
(int) Math.ceil(boundingRect.bottom));
}
}
} while (parent != null);
-------->
// /Android/Sdk/sources/android-33/android/view/ViewRootImpl.java
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
invalidateRectOnScreen(dirty);
-------->
private void invalidateRectOnScreen(Rect dirty) {
if (!mWillDrawSoon && (intersected || mIsAnimating)) {
scheduleTraversals();
-------->
void scheduleTraversals() {
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
-------->
final class TraversalRunnable implements Runnable {
@Override
public void run() {
doTraversal();
-------->
void doTraversal() {
if (mTraversalScheduled) {
performTraversals();
-------->
// 在UI绘制中,这个方法非常重要
private void performTraversals() {
if (mFirst || windowShouldResize || viewVisibilityChanged || params != null
|| mForceNextWindowRelayout) {
// Ask host how big it wants to be
// 测量
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...
if (didLayout) {
// 摆放
performLayout(lp, mWidth, mHeight);
...
if (!isViewVisible) {
} else {
// 绘制
if (!performDraw() && mSyncBufferCallback != null) {
-------->
// 调用invalidate()主要就是调用performDraw(),performMeasure()和performLayout()是不会进的,虽然这三个方法都非常重要。
private boolean performDraw() {
try {
boolean canUseAsync = draw(fullRedrawNeeded, usingAsyncReport && mSyncBuffer);
-------->
private boolean draw(boolean fullRedrawNeeded, boolean forceDraw) {
if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset,
scalingRequired, dirty, surfaceInsets)) {
-------->
private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff, int yoff,
boolean scalingRequired, Rect dirty, Rect surfaceInsets) {
mView.draw(canvas); // mView是找到的最外层的View,他除了绘制自己,还会绘制childView
-------->
// /Android/Sdk/sources/android-33/android/view/View.java
// 这个方法,自定义View(2)中有看过
public void draw(Canvas canvas) {
// Step 3, draw the content
onDraw(canvas); // 绘制自己
// Step 4, draw the children
dispatchDraw(canvas); // 绘制child
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas); // 绘制背景
简单总结invalidate()流程: 从当前View一直往上找,找到最外层的View,调用draw() -> dispatchDraw() 一路往下画,最终画到当前View的draw方法。调用invalidate()方法会牵连整个Layout布局中的View,全部都会刷新。
5. 高级面试题讲解
- 为什么不能在子线程里面更新UI?
// /Android/Sdk/sources/android-33/android/view/ViewRootImpl.java
@Override
public ViewParent invalidateChildInParent(int[] location, Rect dirty) {
checkThread();
-------->
void checkThread() {
if (mThread != Thread.currentThread()) {
throw new CalledFromWrongThreadException(
"Only the original thread that created a view hierarchy can touch its views.");
}
}
开一个线程,更新UI,一般都会调用setText()、setImageView()等等方法,而这些方法,最终都会调用ViewRootImpl的checkThread()。checkThread()用于检测线程,
Thread.currentThread()
为当前线程,即子线程,而mThread
在构造函数中初始化,一般为主线程,两个线程不一样,会在这里抛出异常。
5.1 如何像微信朋友圈一样优化过度渲染
如果看自己应用的界面有没有过渡渲染:开发者选项->调式GPU过度绘制->显示过度绘制区域,红色即为过度绘制
自己写的一些界面一般都会比较复杂,类似QQ空间,WX朋友圈这种,都是列表嵌套列表,Item布局切套图片、文字等。获取到数据,然后去设置setText、setImageView的时候,其实都是调用的onInvalidate()
方法,从上到下所有View都会绘制,导致过度渲染,那如何解决呢?
- 网上的解决方案
尽量不要嵌套
能不设置背景就不要设置背景
…
- 最好的解决方案
不直接使用系统提供好的嵌套布局,自己画布局,其运行效率更高(虽然开发效率比较低)。比如不适用Item嵌套Item,而是自己画一个Item布局,直接包含Item内容的。