Android 自定义 View 三关:触摸分发、绘制与坐标错位

0 阅读20分钟

自定义 View 真正的坑,几乎都来自坐标系和事件流没对齐。

你写了一个自定义 View,布局里量得好好的尺寸,真机上却对不齐;你做了一个侧滑删除,发现手指一碰,点击和滑动就打架,菜单还和上一行同时开着;你实现了"点输入框外面收起键盘",结果键盘弹起后,明明点在了输入框外,键盘却死活不收。

这三件事看起来互不相干,但其实共用同一个根:自定义 View 的复杂度,从来不在绘制那几个 API 有多难,而在你拿到的坐标、收到的事件,到底属于哪一套坐标系、哪一条事件流。本文把三个真实控件拆开讲——电量图标讲"测量到绘制"的对齐,侧滑删除讲"触摸分发"的博弈,键盘收起讲"坐标错位"的坑。读完你会发现,三关的本质是同一句话:坐标系和事件流没有对齐,控件就会集体失控。

一、画出来:电量控件的测量与绘制关

电量图标是自定义 View 最经典的练手题:一个圆角矩形外壳、右侧一个正极小头、内部一条按电量比例填充的色块,电量低了变红。乍看是"两个矩形叠一起",真正用 Canvas 从零画,才会撞上第一关——你画的东西,凭什么是这个尺寸、在这个位置?

第一道坎:onDraw 用的是像素,布局写的是 dp

自定义 View 的第一步,是接受这样一个事实:onDraw(Canvas) 里所有坐标、宽高、线宽,单位都是像素(px);而布局文件里你写的是 dp。两者之间的桥梁是屏幕密度 density = dpi / 160。同一句 dp(28),在高密度屏上会被换算成更多像素,低密度屏上更少,视觉大小才一致。

class BatteryView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : View(context, attrs, defStyle) {

    private val density = resources.displayMetrics.density
    private fun dp(v: Int): Int = (v * density + 0.5f).toInt()
}

这里 density 直接来自 displayMetrics,+ 0.5f 是四舍五入——因为像素必须是整数,不四舍五入会在某些密度下整体偏移半个像素,长期累积会让细线描边发虚。把换算收成一个 dp() 工具,后面外壳、正极、内边距全部走它,任何一处尺寸都不许再手写裸像素,这是"画出来还对得齐"的前提。

边界情况值得展开:很多人只在 onDraw 里换算,却忘了 onMeasure 也该用同一套密度。如果测量阶段用裸数字、绘制阶段用 dp(),两个环节的密度基准不一致,控件在 wrap_content 时就会出现"测量给的尺寸"和"画出来的内容"对不上,外部布局跟着错位。这条经验可以推广:凡是在自定义 View 里出现尺寸的地方,必须统一经过同一个密度换算入口。

把属性暴露给布局:attrs.xml 与 TypedArray

一个只活在代码里的控件没有复用价值。要让布局文件能写 app:level="80"、app:innerColor="#ff0000",得先在 attrs.xml 声明,再在构造里用 TypedArray 读取。

// attrs.xml 里声明
// <declare-styleable name="BatteryView">
//     <attr name="level" format="integer" />
//     <attr name="outerColor" format="color" />
//     <attr name="innerColor" format="color" />
// </declare-styleable>

init {
    val ta = context.obtainStyledAttributes(attrs, R.styleable.BatteryView)
    level = ta.getInteger(R.styleable.BatteryView_level, 50)
    outerColor = ta.getColor(R.styleable.BatteryView_outerColor, Color.DKGRAY)
    innerColor = ta.getColor(R.styleable.BatteryView_innerColor, Color.GREEN)
    ta.recycle() // 必须回收
}

两个易错点:第一,obtainStyledAttributes 拿到的 TypedArray 是复用对象,不 recycle() 会污染后续同类型的属性读取,这是自定义 View 里最容易被忽略的资源泄漏;第二,默认值要选得有语义——电量默认 50、外壳默认深灰、内部默认绿,都是"看得见但不过分"的安全值,避免布局漏写属性时画出全黑或全透明。

onDraw 三步走:外壳、正极与电量填充

绘制分三步,顺序不能乱:先外壳,再正极,最后填充。填充画在最上层,露出的部分就是"已用电量"。

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    val w = width
    val h = height

    // 正极:右侧小圆角矩形
    val tip = RectF(w - dp(6), h * 0.3f, w.toFloat(), h * 0.7f)
    paint.color = outerColor
    canvas.drawRoundRect(tip, dp(2).toFloat(), dp(2).toFloat(), paint)

    // 外壳:去掉正极那块的圆角矩形
    val shell = RectF(0f, 0f, w - dp(6), h.toFloat())
    paint.style = Paint.Style.STROKE
    paint.strokeWidth = dp(2).toFloat()
    paint.color = outerColor
    canvas.drawRoundRect(shell, dp(4).toFloat(), dp(4).toFloat(), paint)

    // 电量填充:按比例
    val ratio = (level.coerceIn(0, 100)) / 100f
    val innerPad = dp(3)
    val inner = RectF(
        innerPad.toFloat(), innerPad.toFloat(),
        (w - dp(6) - innerPad) * ratio,
        (h - innerPad).toFloat()
    )
    paint.style = Paint.Style.FILL
    paint.color = innerColorFor(level) // 低电量变红
    canvas.drawRoundRect(inner, dp(2).toFloat(), dp(2).toFloat(), paint)
}

这里的对齐细节是这关最磨人的地方:

  • 外壳宽度是 w - dp(6),正好给右侧正极留出 6dp 的位置,两者共用同一条右边界,外壳和正极才不会重叠或留缝。
  • 电量填充宽度按 * ratio 缩放,但高度和内边距保持固定,所以填充是"从左边按比例长出来",而不是整体缩放。整体缩放会让低电量时电池芯被压扁,不符合用户对"电量格"的直觉。
  • level 先 coerceIn(0, 100),防止外部传了 120 或 -5 时填充矩形宽度变成负的,Canvas 对负宽高矩形会直接不画或画出反向区域,是隐性 bug。

颜色别写死:低电量变红的策略函数

电量低了变红,这个逻辑不能硬塞进 onDraw。一旦写死,明天产品说"低于 15% 才红、中间加个黄色预警",你就得改绘制代码。正确做法是抽成独立映射函数,绘制只负责"拿到颜色就画"。

private fun innerColorFor(level: Int): Int = when {
    level < 20 -> LOW_COLOR    // 红
    level < 50 -> MID_COLOR    // 橙
    else -> HIGH_COLOR         // 绿
}

阈值 20 和 50 是业务语义,不是绘制细节:低于 20 切红、低于 50 切橙、其余绿。把它和 onDraw 解耦,好处是单元测试可以直接 innerColorFor(10) == RED,不用真的去渲染一块 Canvas。这步看似简单,却是"自定义 View 能不能长期维护"的分水岭——所有随状态变化的视觉,都应该能脱离绘制单独求值。

onMeasure 才是布局对齐的关键(写死尺寸的坑)

前面都在 onDraw 里忙活,但控件放进父布局后"到底占多大",是由 onMeasure 决定的。电量控件通常是固定小图标,但 wrap_content 时也必须给个合理默认,否则系统会按 match_parent 处理。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val defaultSize = dp(28) // 默认 28dp
    val w = resolveSize(defaultSize, widthMeasureSpec)
    val h = resolveSize(defaultSize, heightMeasureSpec)
    setMeasuredDimension(w, h)
}

resolveSize(defaultSize, spec) 会根据父布局给的 MeasureSpec 模式决定最终尺寸。MeasureSpec 有三种模式,理解它们是这一关的终点:

模式含义(父布局的意图)resolveSize 的实际行为直接写死尺寸的坑
EXACTLY父布局给了确定值(如 100dp 或 match_parent 已确定)直接用父布局指定的 size写死会忽略父布局约束,溢出或留白
AT_MOST父布局给了上限(如 wrap_content 在有限父容器内)取 min(defaultSize, 上限),不超过父布局写死会无视上限,撑破父容器边界
UNSPECIFIED父布局不限制(如 ScrollView、嵌套 RecyclerView)用 defaultSize 作为最终尺寸写死反而安全,但依旧丢失自适应能力

很多人图省事,直接在 onMeasure 里 setMeasuredDimension(dp(28), dp(28)) 写死:

// 错误示例:直接写死尺寸,不支持 wrap_content 也不跟随父布局
override fun onMeasure(w: Int, h: Int) {
    setMeasuredDimension(dp(28), dp(28)) // 父布局给 AT_MOST 时也强制 28dp
}

问题就在这里:当父布局是 wrap_content 或只给了 AT_MOST 上限,写死的 28dp 会无视父布局的测量约束——要么在紧凑布局里撑出多余空白,要么在受限容器里溢出,导致这一行的高度和其他控件对不齐。这正是"写死尺寸会让布局对不齐"的真实场景:onMeasure 是控件和父布局协商尺寸的唯一入口,协商的姿势错了,后面 onDraw 画得再准也救不回整体错位。resolveSize 才是把"我的默认期望"和"父布局的限制"正确合并的标准答案。

下面这张图串起"测量→绘制"的完整生命周期,也是电量控件这一关的主线:

flowchart TD
    C[&#34;构造器读取 attrs.xml<br/>TypedArray 自定义属性&#34;] --> M[&#34;onMeasure 被父布局调用&#34;]
    M --> M1{&#34;父布局 MeasureSpec 模式&#34;}
    M1 -->|&#34;EXACTLY&#34;| M2[&#34;采用父布局指定尺寸&#34;]
    M1 -->|&#34;AT_MOST&#34;| M3[&#34;取 默认与上限 的较小值&#34;]
    M1 -->|&#34;UNSPECIFIED&#34;| M4[&#34;采用默认尺寸 dp 28&#34;]
    M2 --> SD[&#34;setMeasuredDimension 定稿&#34;]
    M3 --> SD
    M4 --> SD
    SD --> D[&#34;onDraw Canvas 绘制&#34;]
    D --> D1[&#34;画外壳圆角矩形 STROKE&#34;]
    D --> D2[&#34;画正极 tip 圆角矩形&#34;]
    D --> D3[&#34;画电量填充 按 ratio 变宽&#34;]

二、摸得准:侧滑删除的触摸分发关

第一关解决"画出来",第二关解决"摸得准"。列表 Item 左滑露出删除键,今天看是标配,但从零实现自绘侧滑菜单,坑一个比一个隐蔽:点击和滑动怎么区分?长按和侧滑怎么共存?快速滑出去该不该回弹?列表里好几行,滑开这一行,上一行怎么自动合上?

从 ViewGroup 说起:菜单层在下,内容层在上

一个列表项,内容区之上盖着一层菜单区(删除、编辑按钮),平时藏在右侧屏幕外,手指左滑时露出。整体是一个自定义 ViewGroup,里面放了两个子布局:菜单层在下、内容层在上。

class SwipeLayout : ViewGroup {
    private var menuView: View // 底层菜单(藏在右侧)
    private var contentView: View // 顶层内容(可左移)

    var isOpen = false
        private set
}

问题从事件分发那一刻就开始——你以为手指按下去事件就直接给你了,其实中间隔着三层"谁该处理"的博弈。

事件分发的三层博弈

侧滑 Item 放在一个可上下滑动的列表里,于是同一个手势既可能被列表当成"上下滑",也可能被我们当成"左右滑"。Android 的事件分发链路里,父容器(列表)拥有优先拦截权:事件先到父容器,父容器 onInterceptTouchEvent 返回 true 就截走,返回 false 才往下传给 Item。

第一层,列表在 onInterceptTouchEvent 判断:如果是横向滑动(dx > dy),说明是侧滑,交给 Item 处理,列表自己不拦;如果是纵向滑动,列表截走去做滚动。

// 列表层的拦截逻辑(示意)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
        val dx = ev.rawX - lastX
        val dy = ev.rawY - lastY
        // 横向为主 => 让给 Item 处理侧滑
        if (abs(dx) > abs(dy) && abs(dx) > touchSlop) return false
    }
    return super.onInterceptTouchEvent(ev)
}

第二层,Item 自己在 dispatchTouchEvent / onInterceptTouchEvent 里判断:自己的内容层在左移,如果是横向拖动就自己消费;如果是点击、纵向拖动,就交给内容层。

第三层是滑动边界:内容已经滑到最左,继续左滑没意义,此时要把事件还回去,让列表能继续上下滚动。

这三层博弈的核心原则是:能识别出手势意图,就把事件交出去;识别不出来,宁可先拦下来观察。 大量控件 bug 出在"识别时机太晚"(手指都滑出去了才想起该自己处理,结果被列表滚走了)或"过早拦截"(手指刚按下就 return true,点击和侧滑全乱套)。下面这张图把三层博弈画清楚:

flowchart TD
    A[&#34;触摸事件到达列表&#34;] --> B{&#34;纵向滑动为主?<br/>dy 大于 dx&#34;}
    B -->|&#34;是&#34;| C[&#34;列表拦截 onInterceptTouchEvent<br/>自己上下滚动&#34;]
    B -->|&#34;否 横向为主&#34;| D{&#34;Item 已滑到边界?&#34;}
    D -->|&#34;是&#34;| E[&#34;事件还给列表<br/>继续上下滚&#34;]
    D -->|&#34;否&#34;| F[&#34;Item 消费事件<br/>处理侧滑位移&#34;]
    F --> G[&#34;内容层 translationX 左移<br/>露出菜单&#34;]

点击、长按、侧滑的三方共存:一个不可逆状态机

列表项通常还要支持点击(进详情)和长按(弹菜单)。于是同一个手指按下,究竟是准备点、准备长按、还是准备滑?

  • 按下后几乎没有移动,抬手 → 判定为点击。
  • 按住不动超过 500ms → 判定为长按。
  • 按住后横向移动超过阈值(touchSlop) → 判定为侧滑,取消点击和长按的资格。

实现上,用一个状态机在 ACTION_DOWN 进入"待定",每次 ACTION_MOVE 更新状态,一旦横向位移超过阈值就不可逆地进入滑动态——此后所有后续事件都当作滑动处理,绝不再触发点击。这步"不可逆"是关键:如果允许从滑动态退回点击态,用户快速一划,松手时又被判成点击,就跳进了详情页,体验崩掉。

private var touchState = STATE_PENDING

override fun onTouchEvent(ev: MotionEvent): Boolean {
    when (ev.actionMasked) {
        MotionEvent.ACTION_DOWN -> touchState = STATE_PENDING
        MotionEvent.ACTION_MOVE -> {
            val dx = ev.x - downX
            if (abs(dx) > touchSlop) {
                touchState = STATE_SLIDING // 一旦滑动,终身不可再点
            }
        }
        MotionEvent.ACTION_UP -> {
            if (touchState == STATE_PENDING) performClick() // 只有没滑过才点
        }
    }
    return true
}

touchSlop 是系统定义的"误触容差",通过 ViewConfiguration.get(context).scaledTouchSlop 获取,不同屏幕密度下数值不同——别自己拍一个常量,否则高密度屏上轻微手抖就被判成滑动。500ms 的长按阈值同理,尽量走系统 ViewConfiguration 的标准值,和全系统手势保持一致。

松手去哪:位移过半 + 速度双判据

手指松开时,内容层可能滑到半路。判断它该"弹回关闭"还是"顺势展开",有两个判据:

  1. 位移过半:滑出距离超过总距离一半,就展开,否则关闭。
  2. 滑动速度:用速度追踪器记录手指松开瞬间的速率,向左速度快(比如超过每秒 1000 像素)就直接展开,向右快就关闭——哪怕位移没过半。

速度判据解决的是"用户甩了一下"的场景:人家明明是想打开,只是手指松得快、位移还没过半,如果只看位移就会误关。反过来快速右甩该立刻关。1000 px/s 是个经验阈值,太灵敏会误触,太迟钝又没手感,需要真机调。

回弹动画用属性动画加一个过冲插值器,让内容先略微过头一点再停住,视觉上有"duang"一下的弹性:

fun toggle(open: Boolean) {
    val target = if (open) maxSlide else 0f
    valueAnimator = ValueAnimator.ofFloat(translation, target).apply {
        interpolator = OvershootInterpolator() // 过冲回弹
        addUpdateListener { anim ->
            contentView.translationX = anim.animatedValue as Float
        }
        start()
    }
}

速度追踪本身需要一点样板代码,源文把它藏在"速度追踪器"几个字里,这里补上,免得你以为有魔法:

private val velocityTracker = VelocityTracker.obtain()

// 在 ACTION_MOVE 里持续喂入事件
velocityTracker.addMovement(ev)

// 在 ACTION_UP 里结算,单位 像素/秒
velocityTracker.computeCurrentVelocity(1000)
val vx = velocityTracker.xVelocity // 向左为负,向右为正
// 判断 abs(vx) 是否超过 1000,以及方向
velocityTracker.clear()

computeCurrentVelocity(1000) 的 1000 是时间单位(毫秒),返回的就是"每秒多少像素",和我们的 1000 px/s 阈值天然同单位。下面这张图把"松手去哪"的分支画全:

flowchart TD
    START[&#34;手指松开 ACTION_UP&#34;] --> Q1{&#34;位移过半?<br/>offset 大于 maxSlide 除 2&#34;}
    Q1 -->|&#34;是&#34;| OPEN[&#34;展开 open = true&#34;]
    Q1 -->|&#34;否&#34;| Q2{&#34;速度超阈值?<br/>abs vx 大于 1000 像素每秒&#34;}
    Q2 -->|&#34;向左 快&#34;| OPEN
    Q2 -->|&#34;向右 快&#34;| CLOSE[&#34;关闭 open = false&#34;]
    Q2 -->|&#34;速度不足&#34;| CLOSE
    OPEN --> ANIM[&#34;OvershootInterpolator<br/>过冲回弹动画&#34;]
    CLOSE --> ANIM

多菜单互斥:全局静态引用与泄漏陷阱

侧滑控件最经典的 bug 场景:列表有十行,你滑开第 3 行,再去滑第 5 行——第 3 行还开着,两个菜单同时露出来,界面瞬间乱了。

解决思路是:全局记住"当前展开的是哪个 Item"。每次 ACTION_DOWN,如果本次按下的不是当前展开的那个 Item,就先把这个展开的关掉:

companion object {
    // 全局唯一展开项引用
    private var currentOpened: SwipeLayout? = null
}

override fun onTouchEvent(ev: MotionEvent): Boolean {
    if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
        // 按下别的项时,先合上当前展开的
        if (currentOpened != null && currentOpened != this) {
            currentOpened?.close()
        }
    }
    // ...
}

注意这里用了静态字段,因为"是不是当前展开项"需要在全局所有 Item 之间共享信息。但这引出一个必须小心的点:静态引用会让 View 无法被回收。

解法是在控件离开窗口时清理——当这个 Item 被滑出屏幕或列表重建,onDetachedFromWindow 被调用,如果静态引用正指向它,就置空:

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()
    if (currentOpened == this) {
        currentOpened = null // 释放静态引用,避免内存泄漏
    }
}

漏掉这步的后果很隐蔽:列表滚动复用 Item,旧的 SwipeLayout 本该被回收,却因为静态字段还指着它而常驻内存,页面滚得越多、泄漏越多,内存只涨不跌。所以这个"全局互斥"不是加个静态变量就完事,必须配 onDetachedFromWindow 的清理才闭环。

八年里的教训:一个内部控件从 2016 写到 2024

回到开头那个问题:左滑出删除键,能有多难?一个内部控件从 2016 年写到 2024 年,文件头部的注释就是一部完整的 bug 修复编年史——从"点击误触"到"滚动冲突",从"回弹手感发硬"到"多行同开",再到"列表滚完内存飙升"。每一条教训,都是用户在真机上划出来的。这一节的结论不是"把代码写对",而是"手势处理的每一个分支,都得为假手感和假场景留好退路"。

三、坐标不跑偏:键盘收起的坐标错位关

前两关在控件内部——测量、绘制、事件分发,坐标系都还在你的掌控里。第三关最阴险:控件没写错,事件也对了,但你拿到的坐标,和你以为的那套坐标系,已经悄悄错开了。

朴素实现:点击外部就收起键盘

很多 App 都有这个交互:键盘弹着,用户点输入框外面的区域,键盘收起。实现思路简单——监听全局触摸事件,如果点击点不在输入框范围内,就收起键盘、清掉焦点。

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    if (ev.action == MotionEvent.ACTION_DOWN) {
        val focusView = currentFocus
        if (focusView is EditText) {
            // 取输入框的全局可见区域
            val rect = Rect()
            focusView.getGlobalVisibleRect(rect)
            // 点击点在区域外,则收起键盘
            if (ev.rawX < rect.left || ev.rawX > rect.right ||
                ev.rawY < rect.top || ev.rawY > rect.bottom
            ) {
                hideKeyboardAndClearFocus(focusView)
            }
        }
    }
    return super.dispatchTouchEvent(ev)
}

逻辑看起来完全正确:取输入框全局区域,判断点击点(rawX/rawY 是屏幕全局坐标)是否在区域内,不在就收键盘。问题出在它假设了"取区域"和"取点击点"用的是同一套坐标系——而这个假设,在键盘弹起后会崩。

Bug 现场:输入框被顶起来后,区域坐标漂了

坑来自 getGlobalVisibleRect。这个方法返回的是 View 的全局可见区域——注意是"可见"的,也就是 View 和其父容器裁剪后的实际显示区域。

当软键盘弹起,系统会把界面往上顶,输入框也被顶上去。这时候坑来了:界面上移之后,getGlobalVisibleRect 返回的区域可能还是"上移之前"的旧位置,或者与真实的屏幕坐标有偏移。而 ev.getRawX()/getRawY() 拿到的是真实的屏幕坐标。

于是出现一个经典错判:用户点输入框外的下方区域,明明 rawY 已经很大了,但 getGlobalVisibleRect 返回的 rect.bottom 因为"没跟上上移"还停留在一个更小的值上,结果 rawY > rect.bottom 这条"在区域外"的判断失败,键盘不收起。用户疯狂点外面,键盘纹丝不动——这种 bug 复现率看机型,排查起来极抓狂。

根因:两种坐标系的错位

一句话总结根因:getGlobalVisibleRect 的"可见区域"和 getRawX/getRawY 的"屏幕坐标"不是同一套坐标系下的量,窗口位移时两者会错位。

getGlobalVisibleRect 返回的是相对屏幕的矩形,但它内部计算时会考虑窗口内 View 的可见性裁剪。当界面被 adjustResize 顶起,View 实际可见范围缩小、位置移动,这个 API 在某些 Android 版本/场景下没有及时反映真实位置,仍返回偏移前的矩形。而 rawX/rawY 来自系统派发的 MotionEvent,是窗口位移后真实的屏幕坐标。一个停在过去,一个活在当下,两者一比,必然错乱。

解法:用屏幕坐标重新校准偏移

修正思路是:不要信任 getGlobalVisibleRect 的单一结果,改用更可靠的屏幕坐标来源来校准。

做法是同时用 getLocationOnScreen 拿到输入框在屏幕上的真实左上角坐标,然后和 getGlobalVisibleRect 的结果对比,如果两者不一致(说明区域发生了位移),就用屏幕坐标修正矩形:

if (ev.action == MotionEvent.ACTION_DOWN) {
    val focusView = currentFocus
    if (focusView is EditText) {
        val rect = Rect()
        focusView.getGlobalVisibleRect(rect)

        // 关键修正:用屏幕坐标校准
        val loc = IntArray(2)
        focusView.getLocationOnScreen(loc)
        if (loc[1] < rect.top) {
            // 输入框被顶起,rect 还没跟上,用屏幕坐标修正
            rect.offset(0, loc[1] - rect.top)
        }

        val inRange = ev.rawX in rect.left..rect.right &&
            ev.rawY in rect.top..rect.bottom
        if (!inRange) hideKeyboardAndClearFocus(focusView)
    }
}

getLocationOnScreen 返回的是 View 在屏幕上的绝对位置,与 rawX/rawY 是同一坐标系,用它来修正 getGlobalVisibleRect 的偏移,就能让"点击点是否在框内"的判断回到正确的语义。rect.offset(0, loc[1] - rect.top) 这一步就是"把冻结在旧位置的矩形,沿着 y 方向挪回真实位置"。

flowchart LR
    SUB[&#34;输入框被键盘顶起前&#34;] --> S1[&#34;getGlobalVisibleRect 等于真实屏幕位置&#34;]
    S1 --> S2[&#34;rawX rawY 同坐标系<br/>内外判断正确&#34;]
    PUB[&#34;输入框被键盘顶起后&#34;] --> P1[&#34;getGlobalVisibleRect 滞留旧位置&#34;]
    P1 --> P2[&#34;rawY 已变大 仍判在框内<br/>键盘不收起&#34;]
    P2 --> FIX[&#34;getLocationOnScreen 校准<br/>rect offset 修正&#34;]
    FIX --> OK[&#34;判断回到正确语义<br/>点外部可收起&#34;]

三关串起来,坑的对照一目了然:

现象根因解法
电量控件在不同屏幕对不齐、wrap_content 留白/溢出测量阶段写死像素、onMeasure 无视 MeasureSpec 模式统一经密度换算入口;onMeasure 用 resolveSize 跟随父布局
侧滑时点击误触、多行菜单同时开、列表滚动后内存涨手势意图识别时机错、缺全局互斥、静态引用未清理不可逆状态机区分点击/长按/侧滑;静态 currentOpened + onDetachedFromWindow 释放
键盘弹起后点输入框外,键盘死活不收getGlobalVisibleRect 与 rawX/rawY 两套坐标系窗口位移后错位用 getLocationOnScreen 取同坐标系屏幕坐标校准 rect

再把三关放到一张表里横向看,会更清楚它们为什么是"同一类问题":

关核心矛盾关键 API / 工具最容易错的环节
测量绘制关布局 dp 与绘制 px、父布局约束与自身期望没对齐displayMetrics.density、TypedArray、resolveSize、三种 MeasureSpec在 onMeasure 写死尺寸,无视父布局模式
触摸分发关同一手势的"意图"在列表、Item、边界三层间归属不明onInterceptTouchEvent、VelocityTracker、OvershootInterpolator识别时机太晚或过早拦截,状态机可回退
坐标错位关取位置的坐标系与取点击的坐标系不是同一套getGlobalVisibleRect、getLocationOnScreen、MotionEvent.rawX/Y窗口位移后信任单一"可见区域"坐标

横向看,三关的本质都是"对齐"二字:测量关对齐尺寸基准,触摸关对齐事件归属,坐标关对齐坐标系本身。任何一关的"对齐"假设一旦在运行时被打破——密度变了、手势意图模糊了、窗口被键盘顶起了——控件就失控。所以写自定义 View,先别急着画,先想清楚:我手里的每一个数,到底站在哪条基线、哪条事件流上。

四、小结

  • 自定义 View 第一关是测量与绘制对齐:布局用 dp、绘制用像素,统一经密度换算;onMeasure 必须 resolveSize 跟随父布局,写死尺寸会让整体布局错位。
  • 第二关是触摸分发的三方博弈:列表、Item、滑动边界谁该拦截要按手势意图动态判断;点击/长按/侧滑靠不可逆状态机区分,松手去向由位移过半 + 速度双判据决定。
  • 第三关是坐标系不跑偏:getGlobalVisibleRect 与 rawX/rawY 在窗口位移后会错位,必须用 getLocationOnScreen 的同坐标系屏幕坐标校准。
  • 三关共享同一根:自定义 View 的复杂度不在绘制的 API,而在"我收到的坐标到底是谁的坐标系、事件流有没有被正确地交给该处理的角色"。
  • 排坐标类问题,第一步永远是问:这个值是什么坐标系下的?和我正在比的那个,是不是同一套?

系列导航:Android 基础库系列第 2 篇(共 9 篇)。上一篇《Android 万级数据表格:滚动同步、局部刷新与性能优化》,下一篇《Android UI 复用体系:泛型基类、骨架与插槽》。

你在自定义 View 时,有没有遇到过"明明坐标算对了,真机上却偏了一点"的情况?最后是怎么定位到是哪一套坐标系在捣鬼的?