1. 为什么需要掌握自定义View开发在Android应用开发中系统提供的标准控件往往不能满足复杂UI需求。我遇到过太多这样的情况产品经理拿着设计稿过来指着某个特殊进度条或者动态图表说这个效果我们要在下个版本实现。这时候如果只会用系统控件堆砌要么做不出效果要么性能堪忧。自定义View的核心价值在于实现独特视觉效果如环形菜单、指纹扫描动画优化复杂布局性能减少View层级封装可复用UI组件如带删除动画的SwipeLayout处理特殊交互逻辑如游戏手柄控件去年我做的一个音乐播放器项目需要实现唱片旋转指针摆动的效果。用ImageView动画组合不仅性能差还难以精确控制播放进度。最终通过自定义View重绘CPU占用降低了40%且完美同步了音频进度。2. 自定义View的三种实现方式2.1 扩展现有控件继承法这是最安全的入门方式适合需要增强标准控件功能的场景。比如要给TextView增加自动调整字号功能public class AutoScaleTextView extends AppCompatTextView { // 测量文本宽度与控件宽度的比例 private void adjustTextSize() { float size getTextSize(); while (getPaint().measureText(getText().toString()) getWidth()) { size - 1; setTextSize(TypedValue.COMPLEX_UNIT_PX, size); } } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); adjustTextSize(); } }关键经验通过super调用保留父类原有逻辑只覆盖需要修改的方法2.2 组合控件布局复用当需要将多个控件组合成新组件时这种方案最合适。比如实现带清除按钮的搜索框!-- search_bar.xml -- RelativeLayout xmlns:android... EditText android:idid/et_search/ ImageButton android:idid/btn_clear android:srcdrawable/ic_clear android:layout_alignParentRighttrue/ /RelativeLayoutpublic class SearchBar extends RelativeLayout { public SearchBar(Context context) { super(context); LayoutInflater.from(context).inflate(R.layout.search_bar, this); findViewById(R.id.btn_clear).setOnClickListener(v - { ((EditText)findViewById(R.id.et_search)).setText(); }); } }避坑指南记得在构造函数中调用inflate的attachToRoot参数要传true2.3 完全自定义继承View当需要实现全新视觉元素时这是唯一选择。我们以绘制一个温度计为例public class ThermometerView extends View { private Paint paint new Paint(Paint.ANTI_ALIAS_FLAG); private float temperature 36.5f; Override protected void onDraw(Canvas canvas) { // 绘制温度计底部圆球 paint.setColor(Color.RED); canvas.drawCircle(getWidth()/2, getHeight()-50, 30, paint); // 绘制温度计柱 paint.setStrokeWidth(10); canvas.drawLine(getWidth()/2, 50, getWidth()/2, getHeight()-80, paint); // 绘制温度刻度 float fillHeight (temperature - 35) * (getHeight()-130) / 5; canvas.drawRect(getWidth()/2-15, getHeight()-80-fillHeight, getWidth()/215, getHeight()-80, paint); } }3. 深入View测量与绘制原理3.1 onMeasure的完整工作流程测量过程决定View的最终尺寸这个看似简单的过程实际暗藏玄机。以实现一个正方形ImageView为例Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 获取父容器给出的建议参数 int widthMode MeasureSpec.getMode(widthMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); // 计算最终尺寸 int size; if (widthMode MeasureSpec.EXACTLY) { size widthSize; } else { size Math.min(widthSize, getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec)); } // 关键步骤必须调用setMeasuredDimension保存结果 setMeasuredDimension(size, size); }测量模式解析EXACTLY具体数值100dp/match_parentAT_MOST最大限制wrap_contentUNSPECIFIED无限制少见实测发现忘记调用setMeasuredDimension会导致IllegalStateException3.2 onDraw性能优化技巧绘制是最耗时的操作需要特别注意对象复用在初始化时创建Paint对象避免在onDraw中频繁创建// 正确做法 private Paint paint new Paint(); // 错误示范 Override protected void onDraw(Canvas canvas) { Paint paint new Paint(); // 每次绘制都新建对象 }局部刷新通过invalidate(Rect)只重绘脏区域// 只刷新温度计刻度部分 invalidate(new Rect(getWidth()/2-15, getHeight()-80-fillHeight, getWidth()/215, getHeight()-80));硬件加速在AndroidManifest中开启application android:hardwareAcceleratedtrue4. 手势交互处理实战4.1 触摸事件分发机制理解事件传递顺序是处理复杂交互的基础。以一个可拖动的悬浮按钮为例public class DraggableButton extends View { private float lastX, lastY; Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: lastX event.getX(); lastY event.getY(); break; case MotionEvent.ACTION_MOVE: float dx event.getX() - lastX; float dy event.getY() - lastY; setX(getX() dx); setY(getY() dy); break; } return true; } }事件处理三要素返回true表示消费事件通过getX()/getY()获取相对坐标使用setTranslationX/Y实现平滑移动4.2 手势检测器(GestureDetector)对于复杂手势如双击、长按、快速滑动建议使用系统提供的工具类gestureDetector new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { Override public boolean onDoubleTap(MotionEvent e) { // 处理双击事件 return true; } Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { // 处理快速滑动手势 return true; } }); Override public boolean onTouchEvent(MotionEvent event) { return gestureDetector.onTouchEvent(event); }5. 自定义布局容器实现5.1 重写onLayout方法实现一个简单的流式布局示例public class FlowLayout extends ViewGroup { Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int x 0, y 0, maxHeight 0; for (int i 0; i getChildCount(); i) { View child getChildAt(i); if (x child.getMeasuredWidth() getWidth()) { x 0; y maxHeight; maxHeight 0; } child.layout(x, y, x child.getMeasuredWidth(), y child.getMeasuredHeight()); x child.getMeasuredWidth(); maxHeight Math.max(maxHeight, child.getMeasuredHeight()); } } }5.2 处理MeasureSpec的特殊情况在实现垂直滚动的标签云时需要特殊处理测量逻辑Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 测量所有子View measureChildren(widthMeasureSpec, heightMeasureSpec); // 计算总高度所有子View高度之和 int totalHeight 0; for (int i 0; i getChildCount(); i) { totalHeight getChildAt(i).getMeasuredHeight(); } // 处理AT_MOST模式 int heightMode MeasureSpec.getMode(heightMeasureSpec); int heightSize MeasureSpec.getSize(heightMeasureSpec); if (heightMode MeasureSpec.AT_MOST) { totalHeight Math.min(totalHeight, heightSize); } setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), totalHeight); }6. 高级技巧与性能优化6.1 使用ViewTreeObserver监听布局变化当需要获取View的最终尺寸时不能直接在onCreate中获取view.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { Override public void onGlobalLayout() { view.getViewTreeObserver().removeOnGlobalLayoutListener(this); int width view.getWidth(); // 现在可以获取到实际宽度 } });6.2 自定义属性实现样式配置在res/values/attrs.xml中定义属性declare-styleable nameThermometerView attr nameminTemp formatfloat/ attr namemaxTemp formatfloat/ attr namecolor formatcolor/ /declare-styleable在View中读取属性TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.ThermometerView); minTemp ta.getFloat(R.styleable.ThermometerView_minTemp, 35); maxTemp ta.getFloat(R.styleable.ThermometerView_maxTemp, 40); color ta.getColor(R.styleable.ThermometerView_color, Color.RED); ta.recycle(); // 必须回收6.3 使用Canvas.saveLayer实现特殊效果当需要实现遮罩、离屏绘制等效果时Override protected void onDraw(Canvas canvas) { int layer canvas.saveLayer(0, 0, getWidth(), getHeight(), null); // 绘制内容 paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); // 更多绘制操作 canvas.restoreToCount(layer); }性能提示saveLayer会创建临时离屏缓冲区过度使用会导致性能问题7. 常见问题排查指南7.1 View不显示的可能原因忘记调用setMeasuredDimension导致测量结果无效布局参数设置错误比如宽度设置为0父容器限制父View的onLayout没有正确摆放子View绘制超出边界clipChildren属性设置为false7.2 触摸事件不响应的排查步骤检查onTouchEvent返回值是否为true确认没有父View拦截了事件requestDisallowInterceptTouchEvent检查View的clickable或focusable属性确认View的尺寸足够大至少1x1像素7.3 内存泄漏预防措施避免在View中持有Activity的长期引用及时移除Handler的消息在View被移除时取消异步任务使用WeakReference持有上下文引用自定义View开发中最容易忽视的是性能优化。我曾经实现过一个复杂的图表View在低端设备上滚动时出现明显卡顿。通过以下改进使FPS从15提升到60将动态计算的参数改为预计算使用canvas.clipRect限制绘制区域将阴影效果从实时绘制改为预渲染位图启用硬件加速层setLayerType
Android自定义View开发实战与性能优化指南
1. 为什么需要掌握自定义View开发在Android应用开发中系统提供的标准控件往往不能满足复杂UI需求。我遇到过太多这样的情况产品经理拿着设计稿过来指着某个特殊进度条或者动态图表说这个效果我们要在下个版本实现。这时候如果只会用系统控件堆砌要么做不出效果要么性能堪忧。自定义View的核心价值在于实现独特视觉效果如环形菜单、指纹扫描动画优化复杂布局性能减少View层级封装可复用UI组件如带删除动画的SwipeLayout处理特殊交互逻辑如游戏手柄控件去年我做的一个音乐播放器项目需要实现唱片旋转指针摆动的效果。用ImageView动画组合不仅性能差还难以精确控制播放进度。最终通过自定义View重绘CPU占用降低了40%且完美同步了音频进度。2. 自定义View的三种实现方式2.1 扩展现有控件继承法这是最安全的入门方式适合需要增强标准控件功能的场景。比如要给TextView增加自动调整字号功能public class AutoScaleTextView extends AppCompatTextView { // 测量文本宽度与控件宽度的比例 private void adjustTextSize() { float size getTextSize(); while (getPaint().measureText(getText().toString()) getWidth()) { size - 1; setTextSize(TypedValue.COMPLEX_UNIT_PX, size); } } Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); adjustTextSize(); } }关键经验通过super调用保留父类原有逻辑只覆盖需要修改的方法2.2 组合控件布局复用当需要将多个控件组合成新组件时这种方案最合适。比如实现带清除按钮的搜索框!-- search_bar.xml -- RelativeLayout xmlns:android... EditText android:idid/et_search/ ImageButton android:idid/btn_clear android:srcdrawable/ic_clear android:layout_alignParentRighttrue/ /RelativeLayoutpublic class SearchBar extends RelativeLayout { public SearchBar(Context context) { super(context); LayoutInflater.from(context).inflate(R.layout.search_bar, this); findViewById(R.id.btn_clear).setOnClickListener(v - { ((EditText)findViewById(R.id.et_search)).setText(); }); } }避坑指南记得在构造函数中调用inflate的attachToRoot参数要传true2.3 完全自定义继承View当需要实现全新视觉元素时这是唯一选择。我们以绘制一个温度计为例public class ThermometerView extends View { private Paint paint new Paint(Paint.ANTI_ALIAS_FLAG); private float temperature 36.5f; Override protected void onDraw(Canvas canvas) { // 绘制温度计底部圆球 paint.setColor(Color.RED); canvas.drawCircle(getWidth()/2, getHeight()-50, 30, paint); // 绘制温度计柱 paint.setStrokeWidth(10); canvas.drawLine(getWidth()/2, 50, getWidth()/2, getHeight()-80, paint); // 绘制温度刻度 float fillHeight (temperature - 35) * (getHeight()-130) / 5; canvas.drawRect(getWidth()/2-15, getHeight()-80-fillHeight, getWidth()/215, getHeight()-80, paint); } }3. 深入View测量与绘制原理3.1 onMeasure的完整工作流程测量过程决定View的最终尺寸这个看似简单的过程实际暗藏玄机。以实现一个正方形ImageView为例Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 获取父容器给出的建议参数 int widthMode MeasureSpec.getMode(widthMeasureSpec); int widthSize MeasureSpec.getSize(widthMeasureSpec); // 计算最终尺寸 int size; if (widthMode MeasureSpec.EXACTLY) { size widthSize; } else { size Math.min(widthSize, getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec)); } // 关键步骤必须调用setMeasuredDimension保存结果 setMeasuredDimension(size, size); }测量模式解析EXACTLY具体数值100dp/match_parentAT_MOST最大限制wrap_contentUNSPECIFIED无限制少见实测发现忘记调用setMeasuredDimension会导致IllegalStateException3.2 onDraw性能优化技巧绘制是最耗时的操作需要特别注意对象复用在初始化时创建Paint对象避免在onDraw中频繁创建// 正确做法 private Paint paint new Paint(); // 错误示范 Override protected void onDraw(Canvas canvas) { Paint paint new Paint(); // 每次绘制都新建对象 }局部刷新通过invalidate(Rect)只重绘脏区域// 只刷新温度计刻度部分 invalidate(new Rect(getWidth()/2-15, getHeight()-80-fillHeight, getWidth()/215, getHeight()-80));硬件加速在AndroidManifest中开启application android:hardwareAcceleratedtrue4. 手势交互处理实战4.1 触摸事件分发机制理解事件传递顺序是处理复杂交互的基础。以一个可拖动的悬浮按钮为例public class DraggableButton extends View { private float lastX, lastY; Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: lastX event.getX(); lastY event.getY(); break; case MotionEvent.ACTION_MOVE: float dx event.getX() - lastX; float dy event.getY() - lastY; setX(getX() dx); setY(getY() dy); break; } return true; } }事件处理三要素返回true表示消费事件通过getX()/getY()获取相对坐标使用setTranslationX/Y实现平滑移动4.2 手势检测器(GestureDetector)对于复杂手势如双击、长按、快速滑动建议使用系统提供的工具类gestureDetector new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { Override public boolean onDoubleTap(MotionEvent e) { // 处理双击事件 return true; } Override public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { // 处理快速滑动手势 return true; } }); Override public boolean onTouchEvent(MotionEvent event) { return gestureDetector.onTouchEvent(event); }5. 自定义布局容器实现5.1 重写onLayout方法实现一个简单的流式布局示例public class FlowLayout extends ViewGroup { Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int x 0, y 0, maxHeight 0; for (int i 0; i getChildCount(); i) { View child getChildAt(i); if (x child.getMeasuredWidth() getWidth()) { x 0; y maxHeight; maxHeight 0; } child.layout(x, y, x child.getMeasuredWidth(), y child.getMeasuredHeight()); x child.getMeasuredWidth(); maxHeight Math.max(maxHeight, child.getMeasuredHeight()); } } }5.2 处理MeasureSpec的特殊情况在实现垂直滚动的标签云时需要特殊处理测量逻辑Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 测量所有子View measureChildren(widthMeasureSpec, heightMeasureSpec); // 计算总高度所有子View高度之和 int totalHeight 0; for (int i 0; i getChildCount(); i) { totalHeight getChildAt(i).getMeasuredHeight(); } // 处理AT_MOST模式 int heightMode MeasureSpec.getMode(heightMeasureSpec); int heightSize MeasureSpec.getSize(heightMeasureSpec); if (heightMode MeasureSpec.AT_MOST) { totalHeight Math.min(totalHeight, heightSize); } setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), totalHeight); }6. 高级技巧与性能优化6.1 使用ViewTreeObserver监听布局变化当需要获取View的最终尺寸时不能直接在onCreate中获取view.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { Override public void onGlobalLayout() { view.getViewTreeObserver().removeOnGlobalLayoutListener(this); int width view.getWidth(); // 现在可以获取到实际宽度 } });6.2 自定义属性实现样式配置在res/values/attrs.xml中定义属性declare-styleable nameThermometerView attr nameminTemp formatfloat/ attr namemaxTemp formatfloat/ attr namecolor formatcolor/ /declare-styleable在View中读取属性TypedArray ta context.obtainStyledAttributes(attrs, R.styleable.ThermometerView); minTemp ta.getFloat(R.styleable.ThermometerView_minTemp, 35); maxTemp ta.getFloat(R.styleable.ThermometerView_maxTemp, 40); color ta.getColor(R.styleable.ThermometerView_color, Color.RED); ta.recycle(); // 必须回收6.3 使用Canvas.saveLayer实现特殊效果当需要实现遮罩、离屏绘制等效果时Override protected void onDraw(Canvas canvas) { int layer canvas.saveLayer(0, 0, getWidth(), getHeight(), null); // 绘制内容 paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); // 更多绘制操作 canvas.restoreToCount(layer); }性能提示saveLayer会创建临时离屏缓冲区过度使用会导致性能问题7. 常见问题排查指南7.1 View不显示的可能原因忘记调用setMeasuredDimension导致测量结果无效布局参数设置错误比如宽度设置为0父容器限制父View的onLayout没有正确摆放子View绘制超出边界clipChildren属性设置为false7.2 触摸事件不响应的排查步骤检查onTouchEvent返回值是否为true确认没有父View拦截了事件requestDisallowInterceptTouchEvent检查View的clickable或focusable属性确认View的尺寸足够大至少1x1像素7.3 内存泄漏预防措施避免在View中持有Activity的长期引用及时移除Handler的消息在View被移除时取消异步任务使用WeakReference持有上下文引用自定义View开发中最容易忽视的是性能优化。我曾经实现过一个复杂的图表View在低端设备上滚动时出现明显卡顿。通过以下改进使FPS从15提升到60将动态计算的参数改为预计算使用canvas.clipRect限制绘制区域将阴影效果从实时绘制改为预渲染位图启用硬件加速层setLayerType