产品需求驱动下的技术演进:动态缩放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 问题",更像是一次团队的认知升级: 从"怎么做"到"为什么这么做",从"快点搞定"到"找到适合长期演进的方案"。

公众号:柿蒂

相关推荐
黄林晴17 小时前
如何判断手机是否是纯血鸿蒙系统
android
火柴就是我17 小时前
flutter 之真手势冲突处理
android·flutter
天花板之恋17 小时前
Compose之图片加载显示
android jetpack
法的空间17 小时前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
循环不息优化不止17 小时前
深入解析安卓 Handle 机制
android
恋猫de小郭17 小时前
Android 将强制应用使用主题图标,你怎么看?
android·前端·flutter
jctech18 小时前
这才是2025年的插件化!ComboLite 2.0:为Compose开发者带来极致“爽”感
android·开源
用户20187928316718 小时前
为何Handler的postDelayed不适合精准定时任务?
android
叽哥18 小时前
Kotlin学习第 8 课:Kotlin 进阶特性:简化代码与提升效率
android·java·kotlin
Cui晨18 小时前
Android RecyclerView展示List<View> Adapter的数据源使用View
android