产品需求驱动下的技术演进:动态缩放View的不同方案

前言

在不同阶段,哪怕是"临时方案",也有它存在的价值------它帮助我们快速推进了项目,并为后续优化打下了实践基础。

说起动态修改view的大小,好像很简单,第一反应应该是修改LayoutParams下的宽高。需求很简单,但是真正实现起来才会发现有很多问题需要解决:交互体验、边界值计算、性能优化等等。

于是,在产品需求和技术现实的双重驱动下,尝试了不同的方案。每一个方案都不是"完美解",但它们都解决了当时我们最急迫的问题,也让我们对"方案选择"有了更深的理解:技术不在于炫技,而在于能否在合适的时机,满足最合适的需求。


初代方案:直接修改view的LayoutParams

大致实现思路如下:

kotlin 复制代码
val layoutParams = mView.getLayoutParams()

layoutParams.width = 100

layoutParams.height = 100

setLayoutParams(layoutParams)

在这个方案下出现了非常多问题

1、setLayoutParams 会触发 requestLayout(),实际测量和绘制 不是立即完成 如果紧接着读取 view.width 或 view.height,可能还是旧值

2、不同父布局对 LayoutParams 类型要求不同,例如约束布局是ConstraintLayout.LayoutParams、线性布局是LinearLayout.LayoutParams,需要做很长的适配工作

3、原始布局可能使用wrap_content或0dp/match_constraint(ConstraintLayout),直接赋值固定宽高后,可能破坏布局约束逻辑,ConstraintLayout 中,如果原来宽度为 0dp(match_constraint),改成固定宽高可能导致布局错位

4、也是最大的一个问题,子View与内容自适应问题,改变父View尺寸后,子View或文字可能需要重新适配,不处理子View,可能出现TextView字体被裁剪、图片被拉伸或压缩等等问题


第一个上线方案:View生成ImageView

在我接触项目之前一直采用的这个方案,不可否认的是这个方案其实用imageView隐藏了很多的坑,但是因为我们的业务需要比较频繁的刷新view,所以还是留下了性能和清晰度的问题

核心思想:

1、把原始 View 绘制成一张 Bitmap,再用一个 ImageView 来承载这张图。

2、之后只需要对 ImageView 做缩放,就能达到"缩放 View"的视觉效果。

大致实现流程:

kotlin 复制代码
var bitmap = view.drawToBitmap()

bitmap = Bitmap.createScaledBitmap(  
    bitmap,  
    (bitmap.width * progress / 100f).toInt(),  
    (bitmap.height * progress / 100f).toInt(),  
    false  
)

mView.setImageBitmap(bitmap)

这样可以完全避免对子 View、文字、边界逻辑的修改,属于"一步到位"的替代方案。

但是也有一些缺陷,例如

1、性能与内存消耗

(1) View → Bitmap → 内存占用高

(2) 频繁截图或缩放会触发 GC,造成卡顿

2、清晰度问题

(1) Bitmap 有固定分辨率,放大会模糊

(2) 有需要时在,要在捕获时提高分辨率,但这又会增加内存开销

3、View 的一些硬件加速特效(如elevation阴影)不会出现在 Bitmap 中

第二个优化方案:scaleX / scaleY + 解决滑动边界冲突问题

核心思路:

1、通过修改view的sceleX/Y直接实现缩放效果,用于解决第一代方案的性能和清晰度问题

2、配合 setPivotX / setPivotY 调整缩放参考点,解决拖拽边界和视觉错位问题

kotlin 复制代码
// 设置缩放参考点(pivot)

view.pivotX = view.width / 2f // 中心为基准

view.pivotY = view.height / 2f

// 缩放

view.scaleX = 0.8f

view.scaleY = 0.8f

这个方案的重点是解决滑动冲突,因为在滑动时默认计算的是view的原大小,而scaleX/Y都是视觉上的效果,就会出现视觉上view并没有拖动到边界,而实际上已经拖不动的问题,所以这个时候view.pivotX/Y就显得很重要了,通过pivot可以将视觉上上的view在接触到边界后的视觉位置继续移动

在ViewDragHelper的onViewPositionChanged 中实现以下操作

kotlin 复制代码
fun adjustPivotForRightBoundary(changedView: View, dx: Float, parentWidth: Int) {
    val scaleX = changedView.scaleX
    val scaledWidth = changedView.width * scaleX
    val leftBoundary = changedView.left + scaledWidth

    if (leftBoundary > parentWidth) {
        // 计算视觉偏移
        val overScroll = leftBoundary - parentWidth
        // 修正 pivotX,确保缩放后的视觉边缘贴合父布局
        var newPivotX = changedView.pivotX - overScroll / scaleX

        // 限制 pivotX 在 [0, width] 范围内
        newPivotX = newPivotX.coerceIn(0f, changedView.width.toFloat())

        changedView.pivotX = newPivotX
    } else if (changedView.left < 0) {
        // 左边界处理
        val overScroll = -changedView.left.toFloat()
        var newPivotX = changedView.pivotX + overScroll / scaleX
        newPivotX = newPivotX.coerceIn(0f, changedView.width.toFloat())
        changedView.pivotX = newPivotX
    }
}
....//解决pivotY的思路参考上面的逻辑

第三个临时方案:递归修改所有view大小

在第二个方案遇到的一些问题中,在想,我们能不能通过手动处理所有的view,实现每个view的一个缩放效果,所以就有了这个临时方案

设计思路

1、递归遍历整个 View 树:从根布局开始,逐个访问子 View,对每个子 View 的 LayoutParams 修改宽高

2、处理 TextView 字体、ImageView圆角等:对 TextView,除了修改宽高,还要用 setTextSize(TypedValue.COMPLEX_UNIT_PX, newSize) 调整文字大小

3、保持父布局正确识别大小:修改 LayoutParams 后调用 requestLayout() 确保布局更新

4、可选动画/比例缩放:可以用一个统一的缩放比例,对整个布局和文字做增量缩放

核心代码:

kotlin 复制代码
fun resizeAllChildViews(viewGroup: ViewGroup, scale: Float) {
    for (i in 0 until viewGroup.childCount) {
        val childView: View = viewGroup.getChildAt(i)
        // 存储每个视图的初始大小
        if (childView is ViewGroup) {
            resizeAllChildViews(childView, scale) // 递归调用处理子 ViewGroup
        }
        if (childView is TextView) {
            // 如果是 TextView,调整字号
            val newTextSize = (initialTextSizes[childView] ?: 0f) * scale
            childView.setTextSize(TypedValue.COMPLEX_UNIT_PX, newTextSize)
        }
        val newMarginStart = ((initialMarginStartSizes[childView] ?: 0) * scale).toInt()
        val newMarginTop = ((initialMarginTopSizes[childView] ?: 0) * scale).toInt()
        val newMarginEnd = ((initialMarginEndSizes[childView] ?: 0) * scale).toInt()
        val newMarginBottom = ((initialMarginBottomSizes[childView] ?: 0) * scale).toInt()
        val newPaddingStart = ((initialPaddingStartSizes[childView] ?: 0) * scale).toInt()
        val newPaddingTop = ((initialPaddingTopSizes[childView] ?: 0) * scale).toInt()
        val newPaddingEnd = ((initialPaddingEndSizes[childView] ?: 0) * scale).toInt()
        val newPaddingBottom = ((initialPaddingBottomSizes[childView] ?: 0) * scale).toInt()
        val layoutParams = childView.layoutParams as ViewGroup.MarginLayoutParams
        val initialWidth = initialSizes[childView]?.first ?: 0
        val initialHeight = initialSizes[childView]?.second ?: 0
        val newHeight = (initialHeight * scale).toInt()
        if (childView is TextView) {
            // 针对textView要计算避免最后一个字不显示
            val paint = Paint()
            val newTextSize = (initialTextSizes[childView] ?: 0f) * scale
            paint.textSize = newTextSize // 设置字号,可以根据需要设置
            val textWidth = paint.measureText(childView.text.toString())
            // 添加额外的空间,考虑到 TextView 的 padding 和 margin
            val extraSpace = 2 * childView.paddingStart + 2 * childView.paddingEnd +
                    newMarginStart + newMarginEnd
            layoutParams.width = (textWidth + extraSpace).toInt()
        } else {
            val newWidth = (initialWidth * scale).toInt()
            layoutParams.width = newWidth
        }
        childView.setPadding(newPaddingStart, newPaddingTop, newPaddingEnd, newPaddingBottom)
        layoutParams.height = newHeight
        layoutParams.setMargins(newMarginStart, newMarginTop, newMarginEnd, newMarginBottom)
        childView.layoutParams = layoutParams
        childView.requestLayout() // 请求布局以确保更新生效
    }
}

这个方案的重点是解决方案二的一些问题

1、方案2是视觉上的缩放,会导致用户点击了视觉上的"空白位置"实际上是view的真实位置触发了相关的点击事件

2、解决旋转、位移等需要用到真实布局大小和位置信息的逻辑出现的问题

但是这个方案有一些很棘手和需要消耗大量精力的问题

1、不同类型的 View 可能需要不同处理:TextView、ImageView、RecyclerView 等

2、大布局 + 多层 View 树 + 频繁缩放 → 可能导致 UI 卡顿/掉帧

3、如果后续新增自定义 View 或特殊控件,需要额外适配

4、缩放时的动画很诡异,会出现一节一节的缩放的情况

5、View的绘制时机会出现冲突等问题,例如有些view是子view撑开父view的大小,有些是父view限制了子view的大小,让子TextView自动换行,等等一系列操作都需要很复杂的适配

第四个视觉最优方案:管理不同的缩放

在第三个临时方案的处理中,我们处理了所有的子view的一个缩放效果,但是因为是手动处理,系统对于view的处理就被我强行替代了,那有没有可能让系统帮我们执行缩放呢?这就是我们的第四个方案

1、在第三个方案的基础上不再强求指定所有的view的大小

2、针对textView修改size/换行处理

3、对于宽度和高度是wrap_content和match_parent的不进行手动缩放

4、封装所有子view

封装可缩放接口

kotlin 复制代码
interface Scalable {

    fun applyScale(scale: Float)

}

在单独特性的子view中单独进行适配,例如TextView中触发applyScale执行修改textSize

这样我们只需要调用不同view的applyScale

kotlin 复制代码
private fun traverseChildren(viewGroup: ViewGroup, action: (View) -> Unit) {

    for (i in 0 until viewGroup.childCount) {

        val child = viewGroup.getChildAt(i)

        action(child)

        if (child is ViewGroup) traverseChildren(child, action)

    }

}

回顾一下

其实在工程实践里面很少有最优解,更多的是"阶段解",我们的每个版本都是为了在当前的条件下一步一步的解决问题。方案的取舍,其实是需求、体验、性能、维护、成本之间的平衡,所以,动态修改 View 大小的过程,对我们来说不仅仅是"解决一个 UI 问题",更像是一次团队的认知升级: 从"怎么做"到"为什么这么做",从"快点搞定"到"找到适合长期演进的方案"。

公众号:柿蒂

相关推荐
jiushiapwojdap25 分钟前
Flutter上手记:为什么我的按钮能同时在iOS和Android上跳舞?[特殊字符][特殊字符]
android·其他·flutter·ios
limuyang23 小时前
Android RenderScript-toolkit库,替换老式的脚本方式(常用于高斯模糊)
android
Andy_GF6 小时前
鸿蒙Next在蒲公英平台分发测试包
android·ios·harmonyos
恋猫de小郭7 小时前
iOS 26 正式版即将发布,Flutter 完成全新 devicectl + lldb 的 Debug JIT 运行支持
android·前端·flutter
幻雨様7 小时前
UE5多人MOBA+GAS 54、用户登录和会话创建请求
android·ue5
Just_Paranoid7 小时前
【SystemUI】锁屏来通知默认亮屏Wake模式
android·framework·systemui·keyguard·aod
没有了遇见7 小时前
Android +,++,+= 的区别
android·kotlin
_无_妄_9 小时前
Android 使用 WebView 直接加载 PDF 文件,通过 JS 实现
android