拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Android图片固定宽高比显示:从scaleType到自定义View全攻略

做Android开发图片这块需求几乎天天遇到。前阵子电商项目排期商品卡片要求所有封面图固定16:9显示后台返回的图有正方形、竖图、长图不管原图是什么比例界面上都要等比裁切展示不能拉伸变形。这个需求听起来不难真做起来全是细节scaleType怎么配、宽高比在layout里怎么表达、网络图加载时占位图会不会把布局顶飞随便一个处理不好视觉走查就要打回重做。所以今天围绕“android app设置图片宽度:高度显示”这个主题把固定宽高比的图片展示方案从头到尾捋一遍。这篇文章适合正在做列表页、详情页图片展示的Android开发者看也适合刚入门、被fitXY折磨过的新朋友。内容不会只贴代码我会把为什么这么写、哪些地方容易踩坑一起讲清楚方便你直接照着抄。先说一下我最终采用的方案自定义View做比例约束配合scaleTypecenterCrop处理原图比例不统一的问题。但这个方案不是唯一解后面我会把其他几种常见做法也放出来对比你在不同场景下可以灵活选。1. 为什么图片总是被拉变形——先搞清楚ImageView的显示机制1.1 你设置的宽高其实是View的容器大小很多刚接触Android的朋友会有一个误区以为给ImageView设置layout_width和layout_height就是给图片本身设置了宽高图片就会按照这个宽高去缩放。实际上完全不是这么回事。ImageView本质上是一个Viewlayout_width和layout_height设置的是这个View在布局中的占用尺寸也就是“相框”的大小。图片是通过setImageResource、setImageBitmap或者Glide等库load进去的相当于往相框里放入“照片”。照片怎么摆放、怎么缩放是由scaleType决定的跟View的宽高没有直接关系。所以你会看到这样一种情况把ImageView的宽高都设成100dp图片确实变成100dp×100dp显示但如果你加载的是一张竖图它会被硬生生压缩成一个正方形脸都拉变形了。这就是因为默认的scaleType是fitCenter它会在保持图片原始比例的前提下缩放到View内但View本身是个正方形图片为了适配这个方框就只能被裁掉一部分或留白——实际上fitCenter不会裁它会把图片等比缩放后放在中间多余部分留空看起来就是上下有两条大黑边。这就是最核心的认知你设置View宽高不代表图片会按这个宽高比去显示图片比例是否正常完全取决于scaleType和图片本身的比例。1.2 ScaleType决定图片在容器里怎么画scaleType是ImageView里最重要的一个属性它决定了图片在View中的绘制方式。官方提供了好几种我把常用的几个列出来配合实测表现和适用场景你们一看就明白。ScaleType行为表现典型场景fitXY图片不保持比例强制拉伸填满View横竖都可能变形不推荐用于照片仅适合背景色块、纯色图标fitCenter图片保持比例缩放完整显示在View内剩余区域透明或显示背景色详情页大图、用户头像原图预览centerCrop图片保持比例缩放并填满View超出View的部分被裁剪掉列表缩略图、封面图、卡片头图centerInside图片保持比例缩放完整显示但不会放大超过View小图预览、icon展示fitStart / fitEnd类似fitCenter但对齐到左上角或右下角特殊排版场景matrix不自动缩放按Matrix矩阵绘制手势缩放、自定义裁剪我最常用的是centerCrop因为对列表场景来说它几乎是最省心的方案。举个例子商品卡片要求16:9显示但后台传了一张竖图。fitXY会把竖图横向拉伸成16:9整个比例全乱fitCenter会把竖图等比缩放后放在中间左右两边空出来一大块而centerCrop会把竖图等比放大直到宽高都覆盖住16:9的View然后裁掉顶部和底部超出的部分。这样处理之后图片始终是清晰的、填满的只是裁掉了一部分内容视觉体验最好。生活化一点理解就像你把一张4:3的老照片放进16:9的相框fitXY是把照片硬拉宽人全变形fitCenter是在相框里等比缩放两边留白centerCrop是把照片放大到足够宽然后裁掉上下多余的部分最后效果就像是为这个相框重新构图过一样。2. 实现固定宽高比的三种主流方案选型2.1 ConstraintLayout的layout_constraintDimensionRatio如果你的布局本身就是用ConstraintLayout写的最简单的方式就是直接用layout_constraintDimensionRatio属性。这个属性专门用来控制子View的宽高比比自定义View省事得多。androidx.constraintlayout.widget.ConstraintLayout android:layout_widthmatch_parent android:layout_heightwrap_content ImageView android:idid/cover android:layout_width0dp android:layout_height0dp android:scaleTypecenterCrop app:layout_constraintDimensionRatioH,16:9 app:layout_constraintTop_toTopOfparent app:layout_constraintStart_toStartOfparent app:layout_constraintEnd_toEndOfparent / /androidx.constraintlayout.widget.ConstraintLayout注意几个关键细节。第一宽高必须用0dp不能是wrap_content或match_parent因为0dp意味着这个方向完全由约束决定。第二app:layout_constraintDimensionRatioH,16:9这个写法前面的H表示以宽度为基准、高度跟随宽度比例计算如果写成W,16:9就是以高度为基准宽度按比例计算。省略前导字母也可以比如直接写16:9默认行为是“尽可能大的方向作为基准”但为了可读性和确定性我建议始终把H或W写明确。不过我后来发现这种方案有个小限制当你的图片比例要求和父容器宽度强相关但同时又要套在复杂嵌套布局里时0dp 约束的组合偶尔会和RecyclerView的item测量产生冲突常见表现是图片高度不生效或者布局渲染初期比例不对。我的项目里绝大多数场景没问题但如果你遇到类似情况不想排查布局约束的复杂度直接上自定义View可能更省心。2.2 自定义View在onMeasure阶段控制宽高比自定义View是我个人最推荐的做法。核心思路是在onMeasure阶段拿到父容器给的宽度之后直接按比例算出高度强制setMeasuredDimension。这样View的宽高比在测量时就被固定了后续任何布局、绘制都基于这个固定尺寸非常稳定。class RatioImageView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : AppCompatImageView(context, attrs, defStyleAttr) { private var ratioWidth 16f private var ratioHeight 9f private var baseOnWidth true init { attrs?.let { val typedArray context.obtainStyledAttributes(it, R.styleable.RatioImageView) ratioWidth typedArray.getFloat(R.styleable.RatioImageView_ratioWidth, 16f) ratioHeight typedArray.getFloat(R.styleable.RatioImageView_ratioHeight, 9f) baseOnWidth typedArray.getBoolean(R.styleable.RatioImageView_baseOnWidth, true) typedArray.recycle() } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { if (baseOnWidth) { val width MeasureSpec.getSize(widthMeasureSpec) val height (width * ratioHeight / ratioWidth).toInt() setMeasuredDimension(width, height) } else { val height MeasureSpec.getSize(heightMeasureSpec) val width (height * ratioWidth / ratioHeight).toInt() setMeasuredDimension(width, height) } } }这段代码逻辑很直白如果baseOnWidth为true就取宽度值高度宽度×ratioHeight/ratioWidth。为什么不在布局里直接用wrap_content adjustViewBounds因为adjustViewBounds只对setImageBitmap或setImageDrawable设置的图片有效而且它根据的是图片当前的比例来调整View没法强制指定一个固定的目标比例。我们这里的需求是View的比例固定图片在View内部再用scaleType裁剪所以必须自己控制测量过程。还有一个容易被忽略的点onMeasure里不要做耗时的逻辑不要在比例计算时new对象这个方法在布局测量阶段会被调用多次尤其是列表滚动时。上面代码里连临时变量都很少性能上没有任何压力。2.3 外层容器固定比例内部填充裁剪第三种方案算是老传统了在ConstraintLayout普及之前很常用外层用一个布局固定比例内层放ImageView铺满再用scaleTypecenterCrop完成裁剪。早期没有layout_constraintDimensionRatio时很多人会用View paddingTop百分比来撑开高度。FrameLayout android:layout_widthmatch_parent android:layout_heightwrap_content View android:layout_widthmatch_parent android:layout_height0dp android:paddingTop56.25% / ImageView android:layout_widthmatch_parent android:layout_heightmatch_parent android:scaleTypecenterCrop / /FrameLayout16:9对应的高度百分比就是9/1656.25%。原理很简单View的paddingTop百分比是按父容器宽度计算的一个高度为0dp、paddingTop为56.25%的View实际高度就是父容器宽度的56.25%从而撑开了整个FrameLayout。ImageView高度设为match_parent宽度也是match_parent就刚好占据整个比例区域然后配合centerCrop把图片等比缩放填满。这个方案在简单的布局里能跑得很稳但缺点也很明显多套一层容器层级变深而且如果你要把这个比例缩进复用到多个item就得每个item都写一遍这种结构维护成本高。如果项目里ImageView出现频率高我仍建议优先考虑自定义View。三种方案选型可以参考这个表格方案实现成本稳定性适用场景ConstraintLayout dimensionRatio低中等嵌套复杂时可能异常布局不复杂的页面自定义View onMeasure比例控制中高测量稳定列表项、组件化封装外层容器固定比例 centerCrop中中等层级深临时页面、简单列表3. 完整实操做一个固定16:9的图片展示控件3.1 环境和依赖准备先交代一下我的工程环境方便你对照。我用的IDE是Android Studio Hedgehog也就是2023.1.1 Patch 2对应的AGP版本是8.xcompileSdk 34minSdk 21。这个项目配置在AGP 8.x下运行正常如果你用的是其他版本只要AGP和Gradle版本匹配代码兼容性都没问题。第一步在res/values目录下新建attrs.xml声明自定义属性resources declare-styleable nameRatioImageView attr nameratioWidth formatfloat / attr nameratioHeight formatfloat / attr namebaseOnWidth formatboolean / /declare-styleable /resources然后依赖方面自定义View继承自AppCompatImageView所以你的项目至少要有appcompat依赖。现在的Android Studio新建工程模板里默认就有不需要额外添加。如果用的是纯View而不是AppCompatImageView也可以但AppCompatImageView能保证在低版本系统上一样有MathUtils等兼容处理建议直接用。3.2 RatioImageView完整实现第二部的核心代码在2.2节已经给出来了这里我补全一下完整使用方式。在布局文件里记得根布局需要声明xmlns:apphttp://schemas.android.com/apk/res-auto这个命名空间。com.yourpackage.widget.RatioImageView android:idid/productImage android:layout_widthmatch_parent android:layout_heightwrap_content android:scaleTypecenterCrop app:ratioWidth16 app:ratioHeight9 app:baseOnWidthtrue /layout_width用match_parent确保宽度由父容器决定layout_height用wrap_content但实际在onMeasure里会被我们计算出的高度覆盖。XML里写wrap_content只是占位真正的高度是比例算出来的固定值。为什么高度不直接写0dp或者固定dp因为wrap_content在父容器测量时能更好地告知父布局这里的高度会被精确计算配合onMeasure里的setMeasuredDimension实际效果等同于“按比例算出来的固定高度”。如果你顾虑RecyclerView滑动测量问题实测下来这个方案是稳定的因为onMeasure里没有依赖其他异步值宽度一旦确定高度就是确定值。scaleType这里我固定用centerCrop。如果你需要显示完整图片而不是裁剪就改成fitCenter但那样在非比例图片的情况下会留白。按项目需求选择即可。代码里有一个小细节想提醒一下ratioWidth和ratioHeight是用Float保存的千万不要在两个参数都是Int的时候做除法比如width * (9 / 16)因为9/16在Kotlin里是整数除法结果等于0高度直接变成0。我封装的两个属性都声明成float计算时也把width转成Float或者直接让ratioHeight/ratioWidth先做浮点除法就不会踩这个坑。3.3 配合Glide/Coil加载网络图片的细节实际项目里ImageView要显示的不只是本地资源更多是网络图。这里配合Glide或Coil加载时也有几个经验值得分享。我用Glide 4.x比较顺手。加载网络图时建议给ImageView的scaleType固定为centerCrop然后在Glide里就不要再用CenterCrop变换了因为双重裁剪可能不一致偶尔会造成失真。Glide加载时会自动读取View的尺寸并做downsampling也就是按目标尺寸压缩原图这能明显降低内存占用。Glide.with(this) .load(url) .placeholder(R.drawable.img_placeholder) .override(800, 450) .into(productImage)override(800, 450)是我习惯加的一个优化参数告诉Glide最终要展示的目标尺寸是800×450。这个尺寸不要随便写最好按照View的实际像素尺寸来比如一个宽度为match_parent的图片按屏幕宽度360dp、密度2.75来算实际像素是990px高度按16:9算就是557pxoverride(990, 557)会更精准。写小了图片会模糊写大了浪费内存。如果你懒得算也可以不写overrideGlide会自动按View尺寸处理只是手动写清楚后加载逻辑更可控。占位图这里容易踩一个坑如果placeholder本身不是16:9在图片加载完成之前用户会先看到一张比例不对的占位图。虽然因为scaleTypecenterCrop它会被裁剪成View的比例但如果占位图色彩鲜艳、内容明确比如一个人物头像被裁掉头部就非常难看。所以建议占位图也尽量用16:9的纯色或模糊背景图或者干脆用shape drawable填充一个浅灰色视觉上不会太突兀。如果你用的是Coil写法也简单imageView.load(url) { placeholder(R.drawable.img_placeholder) crossfade(true) }Coil底层使用协程图片请求是异步的在RecyclerView里性能表现不错。但要注意Coil默认不会根据View的尺寸自动downsample到很精确的尺寸如果遇到OOM的问题建议手动指定size。4. 常见问题与排查实录4.1 图片依旧被拉伸或变形这个问题是评论区出现频率最高的一类。做了比例约束也设置了scaleType但图片还是变形怎么回事我一般会按这几个方向排查。第一检查是不是用对了ImageView的属性。很多人会误把图片设置到android:background上比如android:backgrounddrawable/xxx。background是View的背景它不是通过scaleType缩放的而是直接拉伸填满整个View。如果你把图片设置到background里即使scaleTypecenterCrop也不会生效因为centerCrop只管srcsetImageResource的图片。这是最经典的坑之一。第二检查scaleType是不是被代码覆盖了。有些业务逻辑会在Java/Kotlin里调用imageView.setScaleType(ScaleType.FIT_XY)这一行就会把XML里的centerCrop冲掉。第三用Layout Inspector看真实的View尺寸和图片尺寸确认比例有没有被父布局的权重或其他约束影响。一句话总结比例约束是View层面的缩放是图片层面的两件事必须同时正确显示才正常。4.2 设置比例后布局卡顿/测量频繁自定义View固定比例后有同学反馈列表滑动时会有点卡。原因一般有两个。第一个是比例计算里用了动态的值作为基准比如读取了某个全局变量或缓存导致每帧测量结果不稳定父容器不断触发重新测量。第二个是在onMeasure里做了耗时操作比如创建了对象、读写文件这在列表滑动时会被频繁调用直接拖垮UI线程。我自己遇到过最惊险的一次是同事在自定义View的onMeasure里为了算圆角半径new了一个Paint对象结果列表滚动时GC压力陡增帧率掉到40帧以下。排查方法也简单用Android Studio自带的Layout Inspector或者Profile GPU Rendering历史功能现在建议用Perfetto或者Systrace看onMeasure的耗时。我的经验是onMeasure里永远只做纯数学计算其他的都放到setImageDrawable或者draw阶段做。另外如果你在自定义View外面又手动调用了view.setLayoutParams去改高度会直接破坏比例约束而且可能触发父布局的一次完整重新测量频繁调用就是明显的卡顿。正确做法是要改高度就改ratioHeight属性然后调用requestLayout()让View自己按新的比例计算。4.3 高清大图加载内存溢出本地大图加载最容易OOM尤其是现在手机拍照都是4800万像素起步一张原图动辄4000×3000解析成ARGB_8888的Bitmap需要4000×3000×4字节大约48MB而App堆内存普遍只有256MB或512MB多加载几张就直接崩。如果项目里用Glide加载本地图片Glide内部会自动根据View尺寸做downsampling一般不会OOM。但如果是你自己用BitmapFactory.decodeFile读取就必须手动采样。我的做法是先读边界再计算inSampleSizefun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { val (width, height) options.outWidth to options.outHeight var inSampleSize 1 if (height reqHeight || width reqWidth) { val halfHeight height / 2 val halfWidth width / 2 while (halfHeight / inSampleSize reqHeight halfWidth / inSampleSize reqWidth) { inSampleSize * 2 } } return inSampleSize }使用方式比较固定先inJustDecodeBoundstrue读一次边界拿到宽高和采样率再inJustDecodeBoundsfalse正式解码。这里注意inSampleSize必须是2的幂次方这是BitmapFactory的优化机制决定的不是任意整数。我用2、4、8、16逐步提升内存可以指数级下降。还有一点不要为了省内存把图片的inPreferredConfig设置成RGB_565来降低质量除非你对颜色精度要求极低。现在主流做法是用ARGB_8888配合采样质量损失完全可控。4.4 从相册选择图片后显示比例不对最后一个常见场景是做头像上传、图片编辑这类需求。从相册选图后放进固定比例的ImageView结果比例不对、方向不对甚至直接加载失败。这里通常涉及三个问题Uri格式、文件路径、EXIF旋转。先讲Uri。在Android里从相册或文件管理器选图的返回结果可能是content://开头的Uri也可能是file://开头的Uri还可能是一些三方应用通过FileProvider分享出来的Uri比如content://xxx.searchbox.fileprovider/external_root/android/data/xxx这种路径。看着像路径但它不是真实文件路径直接new File(uri.path)几乎必然失败。正确姿势是用ContentResolver.openInputStream()来读取fun decodeSampledBitmapFromUri(context: Context, uri: Uri, reqWidth: Int, reqHeight: Int): Bitmap? { return runCatching { val input context.contentResolver.openInputStream(uri) ?: return null val options BitmapFactory.Options().apply { inJustDecodeBounds true } BitmapFactory.decodeStream(input, null, options) input.close() val inSampleSize calculateInSampleSize(options, reqWidth, reqHeight) val finalOptions BitmapFactory.Options().apply { this.inSampleSize inSampleSize } val finalInput context.contentResolver.openInputStream(uri) ?: return null val bitmap BitmapFactory.decodeStream(finalInput, null, finalOptions) finalInput.close() bitmap }.getOrNull() }第二个是EXIF旋转问题。很多手机拍出来的照片实际像素是横向存储的但通过EXIF信息里的orientation标记了正确的显示方向。如果你直接解码显示图片就是旋转90度的。解决办法是用ExifInterface读取orientation按角度进行旋转。这部分代码比较长项目里我通常是自己封装一个RotateBitmap方法或者直接交给Glide处理。Glide对content Uri和EXIF都有完善处理如果是展示场景用Glide可以少操心很多事。第三个是Android 10以后的分区存储。系统限制App直接访问公共目录的文件路径即使你在MediaStore里查到了DATA列在部分系统上访问也会被拒绝。所以前面那句“优先用ContentResolver.openInputStream”不是建议是必须。我在低版本targetSdk的三方App上遇到过DATA列还能用的情况但在新系统上已经不稳定了早点切换到InputStream方案就不用来回踩。我个人在实际操作中的体会是比例约束本身不复杂真正决定效果的是“View测量”和“图片缩放”这两件事的关系。你先想清楚自己到底要的是等比拉伸、等比裁剪还是完整显示再决定scaleType和布局方案。我见过太多项目上来就在Activity里手动setLayoutParams算宽高结果屏幕旋转一下或者列表复用就露出马脚。先把测量机制吃透把方案定在布局层面后面基本不用返工。最后再分享一个后续可以扩展的方向如果你需要在这个固定比例的基础上做圆角裁剪可以继承RatioImageView在onDraw阶段用ClipPath或OutlineProvider裁剪如果要支持多尺寸适配可以把ratioWidth和ratioHeight做成从服务端下发的配置然后动态更新属性并触发requestLayout。原理都是同一套代码基础打好了扩展起来很快。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门